diff --git a/internal/parser/vendors/lenovo_xcc/parser.go b/internal/parser/vendors/lenovo_xcc/parser.go new file mode 100644 index 0000000..2d53c68 --- /dev/null +++ b/internal/parser/vendors/lenovo_xcc/parser.go @@ -0,0 +1,689 @@ +// Package lenovo_xcc provides parser for Lenovo XCC mini-log archives. +// Tested with: ThinkSystem SR650 V3 (XCC mini-log zip, exported via XCC UI) +// +// Archive structure: zip with tmp/ directory containing JSON .log files. +// +// IMPORTANT: Increment parserVersion when modifying parser logic! +package lenovo_xcc + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + "time" + + "git.mchus.pro/mchus/logpile/internal/models" + "git.mchus.pro/mchus/logpile/internal/parser" +) + +const parserVersion = "1.0" + +func init() { + parser.Register(&Parser{}) +} + +// Parser implements VendorParser for Lenovo XCC mini-log archives. +type Parser struct{} + +func (p *Parser) Name() string { return "Lenovo XCC Mini-Log Parser" } +func (p *Parser) Vendor() string { return "lenovo_xcc" } +func (p *Parser) Version() string { return parserVersion } + +// Detect checks if files match the Lenovo XCC mini-log archive format. +// Returns confidence score 0-100. +func (p *Parser) Detect(files []parser.ExtractedFile) int { + confidence := 0 + for _, f := range files { + path := strings.ToLower(f.Path) + switch { + case strings.HasSuffix(path, "tmp/basic_sys_info.log"): + confidence += 60 + case strings.HasSuffix(path, "tmp/inventory_cpu.log"): + confidence += 20 + case strings.HasSuffix(path, "tmp/xcc_plat_events1.log"): + confidence += 20 + case strings.HasSuffix(path, "tmp/inventory_dimm.log"): + confidence += 10 + case strings.HasSuffix(path, "tmp/inventory_fw.log"): + confidence += 10 + } + if confidence >= 100 { + return 100 + } + } + return confidence +} + +// Parse parses the Lenovo XCC mini-log archive and returns an analysis result. +func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, error) { + result := &models.AnalysisResult{ + Events: make([]models.Event, 0), + FRU: make([]models.FRUInfo, 0), + Sensors: make([]models.SensorReading, 0), + Hardware: &models.HardwareConfig{ + Firmware: make([]models.FirmwareInfo, 0), + CPUs: make([]models.CPU, 0), + Memory: make([]models.MemoryDIMM, 0), + Storage: make([]models.Storage, 0), + PCIeDevices: make([]models.PCIeDevice, 0), + PowerSupply: make([]models.PSU, 0), + }, + } + + if f := findByPath(files, "tmp/basic_sys_info.log"); f != nil { + parseBasicSysInfo(f.Content, result) + } + if f := findByPath(files, "tmp/inventory_fw.log"); f != nil { + result.Hardware.Firmware = append(result.Hardware.Firmware, parseFirmware(f.Content)...) + } + if f := findByPath(files, "tmp/inventory_cpu.log"); f != nil { + result.Hardware.CPUs = parseCPUs(f.Content) + } + if f := findByPath(files, "tmp/inventory_dimm.log"); f != nil { + result.Hardware.Memory = parseDIMMs(f.Content) + } + if f := findByPath(files, "tmp/inventory_disk.log"); f != nil { + result.Hardware.Storage = parseDisks(f.Content) + } + if f := findByPath(files, "tmp/inventory_card.log"); f != nil { + result.Hardware.PCIeDevices = parseCards(f.Content) + } + if f := findByPath(files, "tmp/inventory_psu.log"); f != nil { + result.Hardware.PowerSupply = parsePSUs(f.Content) + } + if f := findByPath(files, "tmp/inventory_ipmi_fru.log"); f != nil { + result.FRU = parseFRU(f.Content) + } + if f := findByPath(files, "tmp/inventory_ipmi_sensor.log"); f != nil { + result.Sensors = parseSensors(f.Content) + } + for _, f := range findEventFiles(files) { + result.Events = append(result.Events, parseEvents(f.Content)...) + } + + result.Protocol = "ipmi" + result.SourceType = models.SourceTypeArchive + parser.ApplyManufacturedYearWeekFromFRU(result.FRU, result.Hardware) + + return result, nil +} + +// findByPath returns the first file whose lowercased path ends with the given suffix. +func findByPath(files []parser.ExtractedFile, suffix string) *parser.ExtractedFile { + for i := range files { + if strings.HasSuffix(strings.ToLower(files[i].Path), suffix) { + return &files[i] + } + } + return nil +} + +// findEventFiles returns all xcc_plat_eventsN.log files. +func findEventFiles(files []parser.ExtractedFile) []parser.ExtractedFile { + var out []parser.ExtractedFile + for _, f := range files { + path := strings.ToLower(f.Path) + if strings.Contains(path, "tmp/xcc_plat_events") && strings.HasSuffix(path, ".log") { + out = append(out, f) + } + } + return out +} + +// --- JSON structures --- + +type xccBasicSysInfoDoc struct { + Items []xccBasicSysInfoItem `json:"items"` +} + +type xccBasicSysInfoItem struct { + MachineName string `json:"machine_name"` + MachineTypeModel string `json:"machine_typemodel"` + SerialNumber string `json:"serial_number"` + UUID string `json:"uuid"` + PowerState string `json:"power_state"` + ServerState string `json:"server_state"` + CurrentTime string `json:"current_time"` +} + +// xccFWEntry covers both basic_sys_info firmware (no type_str) and inventory_fw (has type_str). +type xccFWEntry struct { + Index int `json:"index"` + TypeCode int `json:"type"` + TypeStr string `json:"type_str"` // only in inventory_fw.log + Version string `json:"version"` + Build string `json:"build"` + ReleaseDate string `json:"release_date"` +} + +type xccFirmwareDoc struct { + Items []xccFWEntry `json:"items"` +} + +type xccCPUDoc struct { + Items []xccCPUItem `json:"items"` +} + +type xccCPUItem struct { + Processors []xccCPU `json:"processors"` +} + +type xccCPU struct { + Name int `json:"processors_name"` + Model string `json:"processors_cpu_model"` + Cores json.RawMessage `json:"processors_cores"` // may be int or string + Threads json.RawMessage `json:"processors_threads"` // may be int or string + ClockSpeed string `json:"processors_clock_speed"` + L1DataCache string `json:"processors_l1datacache"` + L2Cache string `json:"processors_l2cache"` + L3Cache string `json:"processors_l3cache"` + Status string `json:"processors_status"` + SerialNumber string `json:"processors_serial_number"` +} + +type xccDIMMDoc struct { + Items []xccDIMMItem `json:"items"` +} + +type xccDIMMItem struct { + Memory []xccDIMM `json:"memory"` +} + +type xccDIMM struct { + Index int `json:"memory_index"` + Status string `json:"memory_status"` + Name string `json:"memory_name"` + Type string `json:"memory_type"` + Capacity json.RawMessage `json:"memory_capacity"` // int (GB) or string + PartNumber string `json:"memory_part_number"` + SerialNumber string `json:"memory_serial_number"` + Manufacturer string `json:"memory_manufacturer"` + MemSpeed json.RawMessage `json:"memory_mem_speed"` // int or string + ConfigSpeed json.RawMessage `json:"memory_config_speed"` // int or string +} + +type xccDiskDoc struct { + Items []xccDiskItem `json:"items"` +} + +type xccDiskItem struct { + Disks []xccDisk `json:"disks"` +} + +type xccDisk struct { + ID int `json:"id"` + SlotNo int `json:"slotNo"` + Type string `json:"type"` + Interface string `json:"interface"` + Media string `json:"media"` + SerialNo string `json:"serialNo"` + PartNo string `json:"partNo"` + CapacityStr string `json:"capacityStr"` // e.g. "3.20 TB" + Manufacture string `json:"manufacture"` + ProductName string `json:"productName"` + RemainLife int `json:"remainLife"` // 0-100 + FWVersion string `json:"fwVersion"` + Temperature int `json:"temperature"` + HealthStatus int `json:"healthStatus"` // int code: 2=Normal + State int `json:"state"` + StateStr string `json:"statestr"` +} + +type xccCardDoc struct { + Items []xccCard `json:"items"` +} + +type xccCard struct { + Key int `json:"key"` + SlotNo int `json:"slotNo"` + AdapterName string `json:"adapterName"` + ConnectorLabel string `json:"connectorLabel"` + OOBSupported int `json:"oobSupported"` + Location int `json:"location"` + Functions []xccCardFunc `json:"functions"` +} + +type xccCardFunc struct { + FunType int `json:"funType"` + BusNo int `json:"generic_busNo"` + DevNo int `json:"generic_devNo"` + FunNo int `json:"generic_funNo"` + VendorID int `json:"generic_vendorId"` // direct int + DeviceID int `json:"generic_devId"` // direct int + SlotDesignation string `json:"generic_slotDesignation"` +} + +type xccPSUDoc struct { + Items []xccPSUItem `json:"items"` +} + +type xccPSUItem struct { + Power []xccPSU `json:"power"` +} + +type xccPSU struct { + Name int `json:"name"` + Status string `json:"status"` + RatedPower int `json:"rated_power"` + PartNumber string `json:"part_number"` + FRUNumber string `json:"fru_number"` + SerialNumber string `json:"serial_number"` + ManufID string `json:"manuf_id"` +} + +type xccFRUDoc struct { + Items []xccFRUItem `json:"items"` +} + +type xccFRUItem struct { + BuiltinFRU []map[string]string `json:"builtin_fru_device"` +} + +type xccSensorDoc struct { + Items []xccSensor `json:"items"` +} + +type xccSensor struct { + Name string `json:"Sensor Name"` + Value string `json:"Value"` + Status string `json:"status"` + Unit string `json:"unit"` +} + +type xccEventDoc struct { + Items []xccEvent `json:"items"` +} + +type xccEvent struct { + Severity string `json:"severity"` // "I", "W", "E", "C" + Source string `json:"source"` + Date string `json:"date"` // "2025-12-22T13:24:02.070" + Index int `json:"index"` + EventID string `json:"eventid"` + CmnID string `json:"cmnid"` + Message string `json:"message"` +} + +// --- Parsers --- + +func parseBasicSysInfo(content []byte, result *models.AnalysisResult) { + var doc xccBasicSysInfoDoc + if err := json.Unmarshal(content, &doc); err != nil || len(doc.Items) == 0 { + return + } + item := doc.Items[0] + + result.Hardware.BoardInfo = models.BoardInfo{ + ProductName: strings.TrimSpace(item.MachineTypeModel), + SerialNumber: strings.TrimSpace(item.SerialNumber), + UUID: strings.TrimSpace(item.UUID), + } + + if t, err := parseXCCTime(item.CurrentTime); err == nil { + result.CollectedAt = t.UTC() + } +} + +func parseFirmware(content []byte) []models.FirmwareInfo { + var doc xccFirmwareDoc + if err := json.Unmarshal(content, &doc); err != nil { + return nil + } + var out []models.FirmwareInfo + for _, fw := range doc.Items { + if fi := xccFWEntryToModel(fw); fi != nil { + out = append(out, *fi) + } + } + return out +} + +func xccFWEntryToModel(fw xccFWEntry) *models.FirmwareInfo { + name := strings.TrimSpace(fw.TypeStr) + version := strings.TrimSpace(fw.Version) + if name == "" && version == "" { + return nil + } + build := strings.TrimSpace(fw.Build) + v := version + if build != "" { + v = version + " (" + build + ")" + } + return &models.FirmwareInfo{ + DeviceName: name, + Version: v, + BuildTime: strings.TrimSpace(fw.ReleaseDate), + } +} + +func parseCPUs(content []byte) []models.CPU { + var doc xccCPUDoc + if err := json.Unmarshal(content, &doc); err != nil || len(doc.Items) == 0 { + return nil + } + var out []models.CPU + for _, item := range doc.Items { + for _, c := range item.Processors { + cpu := models.CPU{ + Socket: c.Name, + Model: strings.TrimSpace(c.Model), + Cores: rawJSONToInt(c.Cores), + Threads: rawJSONToInt(c.Threads), + FrequencyMHz: parseMHz(c.ClockSpeed), + L1CacheKB: parseKB(c.L1DataCache), + L2CacheKB: parseKB(c.L2Cache), + L3CacheKB: parseKB(c.L3Cache), + Status: strings.TrimSpace(c.Status), + SerialNumber: strings.TrimSpace(c.SerialNumber), + } + out = append(out, cpu) + } + } + return out +} + +func parseDIMMs(content []byte) []models.MemoryDIMM { + var doc xccDIMMDoc + if err := json.Unmarshal(content, &doc); err != nil || len(doc.Items) == 0 { + return nil + } + var out []models.MemoryDIMM + for _, item := range doc.Items { + for _, m := range item.Memory { + present := !strings.EqualFold(strings.TrimSpace(m.Status), "not present") && + !strings.EqualFold(strings.TrimSpace(m.Status), "absent") + // memory_capacity is in GB (int); convert to MB + capacityGB := rawJSONToInt(m.Capacity) + dimm := models.MemoryDIMM{ + Slot: strings.TrimSpace(m.Name), + Location: strings.TrimSpace(m.Name), + Present: present, + SizeMB: capacityGB * 1024, + Type: strings.TrimSpace(m.Type), + MaxSpeedMHz: rawJSONToInt(m.MemSpeed), + CurrentSpeedMHz: rawJSONToInt(m.ConfigSpeed), + Manufacturer: strings.TrimSpace(m.Manufacturer), + SerialNumber: strings.TrimSpace(m.SerialNumber), + PartNumber: strings.TrimSpace(strings.TrimRight(m.PartNumber, " ")), + Status: strings.TrimSpace(m.Status), + } + out = append(out, dimm) + } + } + return out +} + +func parseDisks(content []byte) []models.Storage { + var doc xccDiskDoc + if err := json.Unmarshal(content, &doc); err != nil || len(doc.Items) == 0 { + return nil + } + var out []models.Storage + for _, item := range doc.Items { + for _, d := range item.Disks { + sizeGB := parseCapacityToGB(d.CapacityStr) + stateStr := strings.TrimSpace(d.StateStr) + present := !strings.EqualFold(stateStr, "absent") && + !strings.EqualFold(stateStr, "not present") + disk := models.Storage{ + Slot: fmt.Sprintf("%d", d.SlotNo), + Type: strings.TrimSpace(d.Media), + Model: strings.TrimSpace(d.ProductName), + SizeGB: sizeGB, + SerialNumber: strings.TrimSpace(d.SerialNo), + Manufacturer: strings.TrimSpace(d.Manufacture), + Firmware: strings.TrimSpace(d.FWVersion), + Interface: strings.TrimSpace(d.Interface), + Present: present, + Status: stateStr, + } + if d.RemainLife >= 0 && d.RemainLife <= 100 { + v := d.RemainLife + disk.RemainingEndurancePct = &v + } + out = append(out, disk) + } + } + return out +} + +func parseCards(content []byte) []models.PCIeDevice { + var doc xccCardDoc + if err := json.Unmarshal(content, &doc); err != nil { + return nil + } + var out []models.PCIeDevice + for _, card := range doc.Items { + slot := strings.TrimSpace(card.ConnectorLabel) + if slot == "" { + slot = fmt.Sprintf("%d", card.SlotNo) + } + dev := models.PCIeDevice{ + Slot: slot, + Description: strings.TrimSpace(card.AdapterName), + } + if len(card.Functions) > 0 { + fn := card.Functions[0] + dev.BDF = fmt.Sprintf("%02x:%02x.%x", fn.BusNo, fn.DevNo, fn.FunNo) + dev.VendorID = fn.VendorID + dev.DeviceID = fn.DeviceID + } + out = append(out, dev) + } + return out +} + +func parsePSUs(content []byte) []models.PSU { + var doc xccPSUDoc + if err := json.Unmarshal(content, &doc); err != nil || len(doc.Items) == 0 { + return nil + } + var out []models.PSU + for _, item := range doc.Items { + for _, p := range item.Power { + psu := models.PSU{ + Slot: fmt.Sprintf("%d", p.Name), + Present: true, + WattageW: p.RatedPower, + SerialNumber: strings.TrimSpace(p.SerialNumber), + PartNumber: strings.TrimSpace(p.PartNumber), + Vendor: strings.TrimSpace(p.ManufID), + Status: strings.TrimSpace(p.Status), + } + out = append(out, psu) + } + } + return out +} + +func parseFRU(content []byte) []models.FRUInfo { + var doc xccFRUDoc + if err := json.Unmarshal(content, &doc); err != nil || len(doc.Items) == 0 { + return nil + } + var out []models.FRUInfo + for _, item := range doc.Items { + for _, entry := range item.BuiltinFRU { + fru := models.FRUInfo{ + Description: entry["FRU Device Description"], + Manufacturer: entry["Board Mfg"], + ProductName: entry["Board Product"], + SerialNumber: entry["Board Serial"], + PartNumber: entry["Board Part Number"], + MfgDate: entry["Board Mfg Date"], + } + if fru.ProductName == "" { + fru.ProductName = entry["Product Name"] + } + if fru.SerialNumber == "" { + fru.SerialNumber = entry["Product Serial"] + } + if fru.PartNumber == "" { + fru.PartNumber = entry["Product Part Number"] + } + if fru.Description == "" && fru.ProductName == "" && fru.SerialNumber == "" { + continue + } + out = append(out, fru) + } + } + return out +} + +func parseSensors(content []byte) []models.SensorReading { + var doc xccSensorDoc + if err := json.Unmarshal(content, &doc); err != nil { + return nil + } + var out []models.SensorReading + for _, s := range doc.Items { + name := strings.TrimSpace(s.Name) + if name == "" { + continue + } + sr := models.SensorReading{ + Name: name, + RawValue: strings.TrimSpace(s.Value), + Unit: strings.TrimSpace(s.Unit), + Status: strings.TrimSpace(s.Status), + } + if v, err := strconv.ParseFloat(sr.RawValue, 64); err == nil { + sr.Value = v + } + out = append(out, sr) + } + return out +} + +func parseEvents(content []byte) []models.Event { + var doc xccEventDoc + if err := json.Unmarshal(content, &doc); err != nil { + return nil + } + var out []models.Event + for _, e := range doc.Items { + ev := models.Event{ + ID: e.EventID, + Source: strings.TrimSpace(e.Source), + Description: strings.TrimSpace(e.Message), + Severity: xccSeverity(e.Severity), + } + if t, err := parseXCCTime(e.Date); err == nil { + ev.Timestamp = t.UTC() + } + out = append(out, ev) + } + return out +} + +// --- Helpers --- + +func xccSeverity(s string) models.Severity { + switch strings.ToUpper(strings.TrimSpace(s)) { + case "C": + return models.SeverityCritical + case "E": + return models.SeverityCritical + case "W": + return models.SeverityWarning + default: + return models.SeverityInfo + } +} + +func parseXCCTime(s string) (time.Time, error) { + s = strings.TrimSpace(s) + formats := []string{ + "2006-01-02T15:04:05.000", + "2006-01-02T15:04:05", + "2006-01-02 15:04:05", + } + for _, f := range formats { + if t, err := time.Parse(f, s); err == nil { + return t, nil + } + } + return time.Time{}, fmt.Errorf("unparseable time: %q", s) +} + +// parseMHz parses "4100 MHz" → 4100 +func parseMHz(s string) int { + s = strings.TrimSpace(s) + parts := strings.Fields(s) + if len(parts) == 0 { + return 0 + } + v, _ := strconv.Atoi(parts[0]) + return v +} + +// parseKB parses "384 KB" → 384 +func parseKB(s string) int { + s = strings.TrimSpace(s) + parts := strings.Fields(s) + if len(parts) == 0 { + return 0 + } + v, _ := strconv.Atoi(parts[0]) + return v +} + +// parseMB parses "32768 MB" → 32768 +func parseMB(s string) int { + return parseKB(s) +} + +// parseMTs parses "4800 MT/s" → 4800 (treated as MHz equivalent) +func parseMTs(s string) int { + return parseKB(s) +} + +// parseCapacityToGB parses "3.20 TB" or "480 GB" → GB integer +func parseCapacityToGB(s string) int { + s = strings.TrimSpace(s) + parts := strings.Fields(s) + if len(parts) < 2 { + return 0 + } + v, err := strconv.ParseFloat(parts[0], 64) + if err != nil { + return 0 + } + switch strings.ToUpper(parts[1]) { + case "TB": + return int(v * 1000) + case "GB": + return int(v) + case "MB": + return int(v / 1024) + } + return int(v) +} + +// rawJSONToInt parses a json.RawMessage that may be an int or a quoted string → int +func rawJSONToInt(raw json.RawMessage) int { + if len(raw) == 0 { + return 0 + } + // try direct int + var n int + if err := json.Unmarshal(raw, &n); err == nil { + return n + } + // try string + var s string + if err := json.Unmarshal(raw, &s); err == nil { + v, _ := strconv.Atoi(strings.TrimSpace(s)) + return v + } + return 0 +} + +// parseHexID parses "0x15b3" → 5555 +func parseHexID(s string) int { + s = strings.TrimSpace(strings.ToLower(s)) + s = strings.TrimPrefix(s, "0x") + v, _ := strconv.ParseInt(s, 16, 32) + return int(v) +} diff --git a/internal/parser/vendors/lenovo_xcc/parser_test.go b/internal/parser/vendors/lenovo_xcc/parser_test.go new file mode 100644 index 0000000..41e2ba3 --- /dev/null +++ b/internal/parser/vendors/lenovo_xcc/parser_test.go @@ -0,0 +1,225 @@ +package lenovo_xcc_test + +import ( + "testing" + + "git.mchus.pro/mchus/logpile/internal/parser" + lxcc "git.mchus.pro/mchus/logpile/internal/parser/vendors/lenovo_xcc" +) + +const exampleArchive = "/Users/mchusavitin/Documents/git/logpile/example/7D76CTO1WW_JF0002KT_xcc_mini-log_20260413-122150.zip" + +func TestDetect_LenovoXCCMiniLog(t *testing.T) { + files, err := parser.ExtractArchive(exampleArchive) + if err != nil { + t.Skipf("example archive not available: %v", err) + } + + p := &lxcc.Parser{} + score := p.Detect(files) + if score < 80 { + t.Errorf("expected Detect score >= 80 for XCC mini-log archive, got %d", score) + } +} + +func TestParse_LenovoXCCMiniLog_BasicSysInfo(t *testing.T) { + files, err := parser.ExtractArchive(exampleArchive) + if err != nil { + t.Skipf("example archive not available: %v", err) + } + + p := &lxcc.Parser{} + result, err := p.Parse(files) + if err != nil { + t.Fatalf("Parse returned error: %v", err) + } + if result == nil || result.Hardware == nil { + t.Fatal("Parse returned nil result or hardware") + } + + hw := result.Hardware + if hw.BoardInfo.SerialNumber == "" { + t.Error("BoardInfo.SerialNumber is empty") + } + if hw.BoardInfo.ProductName == "" { + t.Error("BoardInfo.ProductName is empty") + } + t.Logf("BoardInfo: serial=%s model=%s uuid=%s", hw.BoardInfo.SerialNumber, hw.BoardInfo.ProductName, hw.BoardInfo.UUID) +} + +func TestParse_LenovoXCCMiniLog_CPUs(t *testing.T) { + files, err := parser.ExtractArchive(exampleArchive) + if err != nil { + t.Skipf("example archive not available: %v", err) + } + + p := &lxcc.Parser{} + result, _ := p.Parse(files) + if result == nil || result.Hardware == nil { + t.Fatal("Parse returned nil") + } + + if len(result.Hardware.CPUs) == 0 { + t.Error("expected at least one CPU, got none") + } + for i, cpu := range result.Hardware.CPUs { + t.Logf("CPU[%d]: socket=%d model=%q cores=%d threads=%d freq=%dMHz", i, cpu.Socket, cpu.Model, cpu.Cores, cpu.Threads, cpu.FrequencyMHz) + } +} + +func TestParse_LenovoXCCMiniLog_Memory(t *testing.T) { + files, err := parser.ExtractArchive(exampleArchive) + if err != nil { + t.Skipf("example archive not available: %v", err) + } + + p := &lxcc.Parser{} + result, _ := p.Parse(files) + if result == nil || result.Hardware == nil { + t.Fatal("Parse returned nil") + } + + if len(result.Hardware.Memory) == 0 { + t.Error("expected memory DIMMs, got none") + } + t.Logf("Memory: %d DIMMs", len(result.Hardware.Memory)) + for i, m := range result.Hardware.Memory { + t.Logf("DIMM[%d]: slot=%s present=%v size=%dMB sn=%s", i, m.Slot, m.Present, m.SizeMB, m.SerialNumber) + } +} + +func TestParse_LenovoXCCMiniLog_Storage(t *testing.T) { + files, err := parser.ExtractArchive(exampleArchive) + if err != nil { + t.Skipf("example archive not available: %v", err) + } + + p := &lxcc.Parser{} + result, _ := p.Parse(files) + if result == nil || result.Hardware == nil { + t.Fatal("Parse returned nil") + } + + t.Logf("Storage: %d disks", len(result.Hardware.Storage)) + for i, s := range result.Hardware.Storage { + t.Logf("Disk[%d]: slot=%s model=%q size=%dGB sn=%s", i, s.Slot, s.Model, s.SizeGB, s.SerialNumber) + } +} + +func TestParse_LenovoXCCMiniLog_PCIeCards(t *testing.T) { + files, err := parser.ExtractArchive(exampleArchive) + if err != nil { + t.Skipf("example archive not available: %v", err) + } + + p := &lxcc.Parser{} + result, _ := p.Parse(files) + if result == nil || result.Hardware == nil { + t.Fatal("Parse returned nil") + } + + t.Logf("PCIe cards: %d", len(result.Hardware.PCIeDevices)) + for i, c := range result.Hardware.PCIeDevices { + t.Logf("Card[%d]: slot=%s desc=%q bdf=%s", i, c.Slot, c.Description, c.BDF) + } +} + +func TestParse_LenovoXCCMiniLog_PSUs(t *testing.T) { + files, err := parser.ExtractArchive(exampleArchive) + if err != nil { + t.Skipf("example archive not available: %v", err) + } + + p := &lxcc.Parser{} + result, _ := p.Parse(files) + if result == nil || result.Hardware == nil { + t.Fatal("Parse returned nil") + } + + if len(result.Hardware.PowerSupply) == 0 { + t.Error("expected PSUs, got none") + } + for i, p := range result.Hardware.PowerSupply { + t.Logf("PSU[%d]: slot=%s wattage=%dW status=%s sn=%s", i, p.Slot, p.WattageW, p.Status, p.SerialNumber) + } +} + +func TestParse_LenovoXCCMiniLog_Sensors(t *testing.T) { + files, err := parser.ExtractArchive(exampleArchive) + if err != nil { + t.Skipf("example archive not available: %v", err) + } + + p := &lxcc.Parser{} + result, _ := p.Parse(files) + if result == nil { + t.Fatal("Parse returned nil") + } + + if len(result.Sensors) == 0 { + t.Error("expected sensors, got none") + } + t.Logf("Sensors: %d", len(result.Sensors)) +} + +func TestParse_LenovoXCCMiniLog_Events(t *testing.T) { + files, err := parser.ExtractArchive(exampleArchive) + if err != nil { + t.Skipf("example archive not available: %v", err) + } + + p := &lxcc.Parser{} + result, _ := p.Parse(files) + if result == nil { + t.Fatal("Parse returned nil") + } + + if len(result.Events) == 0 { + t.Error("expected events, got none") + } + t.Logf("Events: %d", len(result.Events)) + for i, e := range result.Events { + if i >= 5 { + break + } + t.Logf("Event[%d]: severity=%s ts=%s desc=%q", i, e.Severity, e.Timestamp.Format("2006-01-02T15:04:05"), e.Description) + } +} + +func TestParse_LenovoXCCMiniLog_FRU(t *testing.T) { + files, err := parser.ExtractArchive(exampleArchive) + if err != nil { + t.Skipf("example archive not available: %v", err) + } + + p := &lxcc.Parser{} + result, _ := p.Parse(files) + if result == nil { + t.Fatal("Parse returned nil") + } + + t.Logf("FRU: %d entries", len(result.FRU)) + for i, f := range result.FRU { + t.Logf("FRU[%d]: desc=%q product=%q serial=%q", i, f.Description, f.ProductName, f.SerialNumber) + } +} + +func TestParse_LenovoXCCMiniLog_Firmware(t *testing.T) { + files, err := parser.ExtractArchive(exampleArchive) + if err != nil { + t.Skipf("example archive not available: %v", err) + } + + p := &lxcc.Parser{} + result, _ := p.Parse(files) + if result == nil || result.Hardware == nil { + t.Fatal("Parse returned nil") + } + + if len(result.Hardware.Firmware) == 0 { + t.Error("expected firmware entries, got none") + } + for i, f := range result.Hardware.Firmware { + t.Logf("FW[%d]: name=%q version=%q buildtime=%q", i, f.DeviceName, f.Version, f.BuildTime) + } +} diff --git a/internal/parser/vendors/vendors.go b/internal/parser/vendors/vendors.go index 9b1536c..f52efbf 100644 --- a/internal/parser/vendors/vendors.go +++ b/internal/parser/vendors/vendors.go @@ -14,6 +14,7 @@ import ( _ "git.mchus.pro/mchus/logpile/internal/parser/vendors/unraid" _ "git.mchus.pro/mchus/logpile/internal/parser/vendors/xfusion" _ "git.mchus.pro/mchus/logpile/internal/parser/vendors/xigmanas" + _ "git.mchus.pro/mchus/logpile/internal/parser/vendors/lenovo_xcc" // Generic fallback parser (must be last for lowest priority) _ "git.mchus.pro/mchus/logpile/internal/parser/vendors/generic" diff --git a/logpile b/logpile index 0be115f..b886ed5 100755 Binary files a/logpile and b/logpile differ