diff --git a/internal/parser/vendors/lenovo_xcc/parser.go b/internal/parser/vendors/lenovo_xcc/parser.go index 7719a03..636f014 100644 --- a/internal/parser/vendors/lenovo_xcc/parser.go +++ b/internal/parser/vendors/lenovo_xcc/parser.go @@ -100,9 +100,11 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er } if f := findByPath(files, "tmp/inventory_ipmi_fru.log"); f != nil { result.FRU = parseFRU(f.Content) + enrichBoardFromFRU(result) } if f := findByPath(files, "tmp/inventory_ipmi_sensor.log"); f != nil { result.Sensors = parseSensors(f.Content) + result.Hardware.PowerSupply = enrichPSUsFromSensors(result.Hardware.PowerSupply, result.Sensors) } for _, f := range findEventFiles(files) { result.Events = append(result.Events, parseEvents(f.Content)...) @@ -341,9 +343,13 @@ func parseBasicSysInfo(content []byte, result *models.AnalysisResult) { item := doc.Items[0] result.Hardware.BoardInfo = models.BoardInfo{ - ProductName: strings.TrimSpace(item.MachineTypeModel), - SerialNumber: strings.TrimSpace(item.SerialNumber), - UUID: strings.TrimSpace(item.UUID), + ProductName: cleanXCCValue(item.MachineTypeModel), + SerialNumber: cleanXCCValue(item.SerialNumber), + UUID: cleanXCCValue(item.UUID), + } + + if host := cleanXCCValue(item.MachineName); host != "" { + result.TargetHost = host } if t, err := parseXCCTime(item.CurrentTime); err == nil { @@ -464,17 +470,21 @@ func parseDisks(content []byte) []models.Storage { stateStr := strings.TrimSpace(d.StateStr) present := !strings.EqualFold(stateStr, "absent") && !strings.EqualFold(stateStr, "not present") + status := mapDiskHealthStatus(d.HealthStatus, stateStr) disk := models.Storage{ Slot: fmt.Sprintf("%d", d.SlotNo), Type: strings.TrimSpace(d.Media), - Model: strings.TrimSpace(d.ProductName), + Model: cleanXCCValue(d.ProductName), SizeGB: sizeGB, - SerialNumber: strings.TrimSpace(d.SerialNo), - Manufacturer: strings.TrimSpace(d.Manufacture), - Firmware: strings.TrimSpace(d.FWVersion), + SerialNumber: cleanXCCValue(d.SerialNo), + Manufacturer: cleanXCCValue(d.Manufacture), + Firmware: cleanXCCValue(d.FWVersion), Interface: strings.TrimSpace(d.Interface), Present: present, - Status: stateStr, + Status: status, + } + if d.Temperature > 0 { + disk.Details = map[string]any{"temperature_c": d.Temperature} } if d.RemainLife >= 0 && d.RemainLife <= 100 { v := d.RemainLife @@ -551,13 +561,18 @@ func parsePSUs(content []byte) []models.PSU { var out []models.PSU for _, item := range doc.Items { for _, p := range item.Power { + model := cleanXCCValue(p.FRUNumber) + if model == "" { + model = cleanXCCValue(p.PartNumber) + } psu := models.PSU{ Slot: fmt.Sprintf("%d", p.Name), Present: true, + Model: model, WattageW: p.RatedPower, - SerialNumber: strings.TrimSpace(p.SerialNumber), - PartNumber: strings.TrimSpace(p.PartNumber), - Vendor: strings.TrimSpace(p.ManufID), + SerialNumber: cleanXCCValue(p.SerialNumber), + PartNumber: cleanXCCValue(p.PartNumber), + Vendor: cleanXCCValue(p.ManufID), Status: strings.TrimSpace(p.Status), } out = append(out, psu) @@ -611,11 +626,13 @@ func parseSensors(content []byte) []models.SensorReading { if name == "" { continue } + unit := strings.TrimSpace(s.Unit) sr := models.SensorReading{ Name: name, RawValue: strings.TrimSpace(s.Value), - Unit: strings.TrimSpace(s.Unit), + Unit: unit, Status: strings.TrimSpace(s.Status), + Type: classifySensorType(name, unit), } if v, err := strconv.ParseFloat(sr.RawValue, 64); err == nil { sr.Value = v @@ -646,6 +663,151 @@ func parseEvents(content []byte) []models.Event { return out } +// --- Cross-reference enrichment --- + +// enrichBoardFromFRU sets BoardInfo.Manufacturer from the system board FRU entry +// when it is not already populated. Mirrors bee's board parsing from dmidecode type 1. +func enrichBoardFromFRU(result *models.AnalysisResult) { + if result.Hardware.BoardInfo.Manufacturer != "" { + return + } + for _, fru := range result.FRU { + desc := strings.ToLower(fru.Description) + if !strings.Contains(desc, "system board") && + !strings.Contains(desc, "planar") && + !strings.Contains(desc, "backplane") { + continue + } + if mfg := cleanXCCValue(fru.Manufacturer); mfg != "" { + result.Hardware.BoardInfo.Manufacturer = mfg + return + } + } +} + +// psuSensorSlot extracts a 1-based PSU slot number from a sensor name. +// Recognises patterns: "PSU1 ...", "PSU 2 ...", "Power Supply 1 ...", "PWS1 ..." +var psuSensorSlotPattern = regexp.MustCompile(`(?i)(?:PSU|Power\s+Supply|PWS)\s*(\d+)`) + +// enrichPSUsFromSensors cross-references sensor readings into PSU InputPowerW / +// OutputPowerW / InputVoltage. Mirrors bee's enrichPSUsWithTelemetry approach. +func enrichPSUsFromSensors(psus []models.PSU, sensors []models.SensorReading) []models.PSU { + if len(psus) == 0 || len(sensors) == 0 { + return psus + } + for i := range psus { + slot, err := strconv.Atoi(psus[i].Slot) + if err != nil { + continue + } + for _, s := range sensors { + m := psuSensorSlotPattern.FindStringSubmatch(s.Name) + if len(m) < 2 { + continue + } + sensorSlot, err := strconv.Atoi(m[1]) + if err != nil || sensorSlot != slot { + continue + } + nameLower := strings.ToLower(s.Name) + switch { + case isPSUInputPower(nameLower): + psus[i].InputPowerW = int(s.Value) + case isPSUOutputPower(nameLower): + psus[i].OutputPowerW = int(s.Value) + case isPSUInputVoltage(nameLower): + psus[i].InputVoltage = s.Value + } + } + } + return psus +} + +func isPSUInputPower(name string) bool { + return strings.Contains(name, "input power") || + strings.Contains(name, "input watts") || + strings.Contains(name, "_pin") || + strings.Contains(name, " pin") +} + +func isPSUOutputPower(name string) bool { + return strings.Contains(name, "output power") || + strings.Contains(name, "output watts") || + strings.Contains(name, "_pout") || + strings.Contains(name, " pout") +} + +func isPSUInputVoltage(name string) bool { + return strings.Contains(name, "input voltage") || + strings.Contains(name, "ac voltage") || + strings.Contains(name, "_vin") || + strings.Contains(name, " vin") +} + +// mapDiskHealthStatus maps an XCC disk healthStatus integer to a canonical status +// string. Mirrors bee's mapRAIDDriveStatus logic. +// XCC codes: 1=Warning, 2=Normal, 3=Critical, 4=PredictiveFailure; 0=Unknown. +func mapDiskHealthStatus(code int, stateStr string) string { + switch code { + case 2: + return "OK" + case 1, 4: + return "Warning" + case 3: + return "Critical" + default: + if stateStr != "" { + return stateStr + } + return "Unknown" + } +} + +// classifySensorType returns a sensor category based on bee's classification logic: +// fan / temperature / power / voltage / current / other. +func classifySensorType(name, unit string) string { + u := strings.ToLower(strings.TrimSpace(unit)) + switch u { + case "rpm": + return "fan" + case "c", "celsius", "°c": + return "temperature" + case "w", "watts": + return "power" + case "v", "volts": + return "voltage" + case "a", "amps": + return "current" + } + n := strings.ToLower(name) + switch { + case strings.Contains(n, "fan"): + return "fan" + case strings.Contains(n, "temp"): + return "temperature" + case strings.Contains(n, "power") || strings.Contains(n, " pwr"): + return "power" + case strings.Contains(n, "volt") || strings.Contains(n, " vin") || strings.Contains(n, " vout"): + return "voltage" + case strings.Contains(n, "curr") || strings.Contains(n, " amp"): + return "current" + default: + return "other" + } +} + +// cleanXCCValue strips XCC placeholder strings, returning "" for non-values. +// Mirrors bee's cleanDMIValue for IPMI/XCC context. +func cleanXCCValue(v string) string { + v = strings.TrimSpace(v) + switch strings.ToLower(v) { + case "", "n/a", "na", "none", "unknown", "not available", + "not applicable", "not present", "not specified", "-": + return "" + } + return v +} + // --- Helpers --- func xccSeverity(s, message string) models.Severity { diff --git a/internal/parser/vendors/lenovo_xcc/parser_test.go b/internal/parser/vendors/lenovo_xcc/parser_test.go index 7b28dcd..188501e 100644 --- a/internal/parser/vendors/lenovo_xcc/parser_test.go +++ b/internal/parser/vendors/lenovo_xcc/parser_test.go @@ -364,3 +364,143 @@ func TestApplyDIMMWarningsFromEvents_UpdatesDIMMStatusForExport(t *testing.T) { t.Fatalf("expected warning status history entry, got %#v", dimm.StatusHistory) } } + +func TestParseBasicSysInfo_CleansPlaceholderValuesAndSetsTargetHost(t *testing.T) { + result := &models.AnalysisResult{Hardware: &models.HardwareConfig{}} + content := []byte(`{ + "items": [{ + "machine_name": " sr650v3-node01 ", + "machine_typemodel": " 7D76CTO1WW ", + "serial_number": " Not Specified ", + "uuid": "N/A" + }] + }`) + + parseBasicSysInfo(content, result) + + if result.TargetHost != "sr650v3-node01" { + t.Fatalf("unexpected target host: %q", result.TargetHost) + } + if result.Hardware.BoardInfo.ProductName != "7D76CTO1WW" { + t.Fatalf("unexpected product name: %q", result.Hardware.BoardInfo.ProductName) + } + if result.Hardware.BoardInfo.SerialNumber != "" { + t.Fatalf("expected serial number to be cleaned, got %q", result.Hardware.BoardInfo.SerialNumber) + } + if result.Hardware.BoardInfo.UUID != "" { + t.Fatalf("expected UUID to be cleaned, got %q", result.Hardware.BoardInfo.UUID) + } +} + +func TestEnrichBoardFromFRU_SystemBoardManufacturerOnly(t *testing.T) { + result := &models.AnalysisResult{ + Hardware: &models.HardwareConfig{}, + FRU: []models.FRUInfo{ + {Description: "Power Supply 1", Manufacturer: "Ignore Me"}, + {Description: "System Board", Manufacturer: " Lenovo "}, + }, + } + + enrichBoardFromFRU(result) + + if result.Hardware.BoardInfo.Manufacturer != "Lenovo" { + t.Fatalf("unexpected manufacturer: %q", result.Hardware.BoardInfo.Manufacturer) + } +} + +func TestEnrichPSUsFromSensors_AssignsTelemetryBySlot(t *testing.T) { + psus := []models.PSU{ + {Slot: "1"}, + {Slot: "2"}, + } + sensors := []models.SensorReading{ + {Name: "PSU1 Input Power", Value: 430}, + {Name: "Power Supply 1 Output Power", Value: 390}, + {Name: "PWS1 AC Voltage", Value: 230.5}, + {Name: "PSU2 Input Power", Value: 0}, + {Name: "PSU3 Input Power", Value: 999}, + {Name: "Fan 1", Value: 12000}, + } + + got := enrichPSUsFromSensors(psus, sensors) + + if got[0].InputPowerW != 430 { + t.Fatalf("unexpected PSU1 input power: %d", got[0].InputPowerW) + } + if got[0].OutputPowerW != 390 { + t.Fatalf("unexpected PSU1 output power: %d", got[0].OutputPowerW) + } + if got[0].InputVoltage != 230.5 { + t.Fatalf("unexpected PSU1 input voltage: %v", got[0].InputVoltage) + } + if got[1].InputPowerW != 0 || got[1].OutputPowerW != 0 || got[1].InputVoltage != 0 { + t.Fatalf("unexpected telemetry assigned to PSU2: %+v", got[1]) + } +} + +func TestMapDiskHealthStatus(t *testing.T) { + tests := []struct { + name string + code int + stateStr string + want string + }{ + {name: "normal", code: 2, stateStr: "Online", want: "OK"}, + {name: "warning", code: 1, stateStr: "Online", want: "Warning"}, + {name: "predictive failure", code: 4, stateStr: "Online", want: "Warning"}, + {name: "critical", code: 3, stateStr: "Failed", want: "Critical"}, + {name: "fallback state", code: 0, stateStr: "Rebuilding", want: "Rebuilding"}, + {name: "unknown", code: 0, stateStr: "", want: "Unknown"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := mapDiskHealthStatus(tt.code, tt.stateStr); got != tt.want { + t.Fatalf("got %q, want %q", got, tt.want) + } + }) + } +} + +func TestClassifySensorType(t *testing.T) { + tests := []struct { + name string + in string + unit string + want string + }{ + {name: "unit rpm", in: "Fan 1", unit: "RPM", want: "fan"}, + {name: "unit celsius", in: "CPU Temp", unit: "C", want: "temperature"}, + {name: "unit watts", in: "PSU1 Input Power", unit: "W", want: "power"}, + {name: "unit volts", in: "PWS1 AC Voltage", unit: "V", want: "voltage"}, + {name: "unit amps", in: "PSU1 Current", unit: "A", want: "current"}, + {name: "name fallback", in: "GPU Temp", unit: "", want: "temperature"}, + {name: "other", in: "Presence", unit: "", want: "other"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := classifySensorType(tt.in, tt.unit); got != tt.want { + t.Fatalf("got %q, want %q", got, tt.want) + } + }) + } +} + +func TestCleanXCCValue(t *testing.T) { + tests := []struct { + in string + want string + }{ + {in: " Lenovo ", want: "Lenovo"}, + {in: "N/A", want: ""}, + {in: " not specified ", want: ""}, + {in: "-", want: ""}, + } + + for _, tt := range tests { + if got := cleanXCCValue(tt.in); got != tt.want { + t.Fatalf("cleanXCCValue(%q) = %q, want %q", tt.in, got, tt.want) + } + } +}