// 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" "regexp" "strconv" "strings" "time" "git.mchus.pro/mchus/logpile/internal/models" "git.mchus.pro/mchus/logpile/internal/parser" ) const parserVersion = "1.2" 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 { memory, events := parseDIMMs(f.Content) result.Hardware.Memory = memory result.Events = append(result.Events, events...) } if f := findByPath(files, "tmp/inventory_disk.log"); f != nil { result.Hardware.Storage = parseDisks(f.Content) } if f := findByPath(files, "tmp/inventory_volume.log"); f != nil { result.Hardware.Volumes = parseVolumes(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) 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)...) } applyDIMMWarningsFromEvents(result) 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 xccVolumeDoc struct { Items []xccVolumeItem `json:"items"` } type xccVolumeItem struct { Volumes []xccVolume `json:"volumes"` TotalCapacityStr string `json:"totalCapacityStr"` } type xccVolume struct { ID int `json:"id"` Name string `json:"name"` Drives string `json:"drives"` // e.g. "M.2 Drive 0, M.2 Drive 1" RDLvlStr string `json:"rdlvlstr"` // e.g. "RAID 1" CapacityStr string `json:"capacityStr"` // e.g. "893.750 GiB" Status int `json:"status"` StatusStr string `json:"statusStr"` // e.g. "Optimal" } 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: 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 { 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, []models.Event) { var doc xccDIMMDoc if err := json.Unmarshal(content, &doc); err != nil || len(doc.Items) == 0 { return nil, nil } var out []models.MemoryDIMM var events []models.Event for _, item := range doc.Items { for _, m := range item.Memory { status := strings.TrimSpace(m.Status) present := !strings.EqualFold(status, "not present") && !strings.EqualFold(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: status, } out = append(out, dimm) if isUnqualifiedDIMM(status) { events = append(events, models.Event{ Source: "Memory", SensorType: "Memory", SensorName: dimm.Slot, EventType: "DIMM Qualification", Severity: models.SeverityWarning, Description: status, }) } } } return out, events } 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") status := mapDiskHealthStatus(d.HealthStatus, stateStr) disk := models.Storage{ Slot: fmt.Sprintf("%d", d.SlotNo), Type: strings.TrimSpace(d.Media), Model: cleanXCCValue(d.ProductName), SizeGB: sizeGB, SerialNumber: cleanXCCValue(d.SerialNo), Manufacturer: cleanXCCValue(d.Manufacture), Firmware: cleanXCCValue(d.FWVersion), Interface: strings.TrimSpace(d.Interface), Present: present, 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 disk.RemainingEndurancePct = &v } out = append(out, disk) } } return out } func parseVolumes(content []byte) []models.StorageVolume { var doc xccVolumeDoc if err := json.Unmarshal(content, &doc); err != nil || len(doc.Items) == 0 { return nil } var out []models.StorageVolume for _, item := range doc.Items { for _, v := range item.Volumes { vol := models.StorageVolume{ ID: fmt.Sprintf("%d", v.ID), Name: strings.TrimSpace(v.Name), RAIDLevel: strings.TrimSpace(v.RDLvlStr), SizeGB: parseCapacityToGB(v.CapacityStr), Status: strings.TrimSpace(v.StatusStr), } drives := strings.TrimSpace(v.Drives) if drives != "" { for _, d := range strings.Split(drives, ",") { vol.Drives = append(vol.Drives, strings.TrimSpace(d)) } // M.2 NVMe volumes are managed by Intel VROC (VMD) if strings.Contains(strings.ToLower(drives), "m.2") { vol.Controller = "Intel VROC" } } out = append(out, vol) } } 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 { 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: cleanXCCValue(p.SerialNumber), PartNumber: cleanXCCValue(p.PartNumber), Vendor: cleanXCCValue(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 } unit := strings.TrimSpace(s.Unit) sr := models.SensorReading{ Name: name, RawValue: strings.TrimSpace(s.Value), Unit: unit, Status: strings.TrimSpace(s.Status), Type: classifySensorType(name, unit), } 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, e.Message), } if t, err := parseXCCTime(e.Date); err == nil { ev.Timestamp = t.UTC() } out = append(out, ev) } 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 { if isUnqualifiedDIMM(message) { return models.SeverityWarning } 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 isUnqualifiedDIMM(value string) bool { return strings.Contains(strings.ToLower(strings.TrimSpace(value)), "unqualified dimm") } var ( unqualifiedDIMMSlotRE = regexp.MustCompile(`(?i)\bunqualified dimm\s+(\d+)\b`) unqualifiedDIMMSerialRE = regexp.MustCompile(`(?i)\bserial number is\s+([A-Z0-9-]+)`) ) func applyDIMMWarningsFromEvents(result *models.AnalysisResult) { if result == nil || result.Hardware == nil || len(result.Hardware.Memory) == 0 || len(result.Events) == 0 { return } for _, ev := range result.Events { if !isUnqualifiedDIMM(ev.Description) { continue } idx := findDIMMIndexForUnqualifiedEvent(result.Hardware.Memory, ev.Description) if idx < 0 { continue } dimm := &result.Hardware.Memory[idx] dimm.Status = "Warning" dimm.ErrorDescription = ev.Description if !ev.Timestamp.IsZero() { ts := ev.Timestamp.UTC() dimm.StatusChangedAt = &ts dimm.StatusCheckedAt = &ts } appendDIMMStatusHistory(dimm, ev) } } func findDIMMIndexForUnqualifiedEvent(memory []models.MemoryDIMM, description string) int { slot := extractUnqualifiedDIMMSlot(description) serial := normalizeUnqualifiedDIMMSerial(extractUnqualifiedDIMMSerial(description)) for i := range memory { if slot != "" && strings.EqualFold(strings.TrimSpace(memory[i].Slot), slot) { return i } } for i := range memory { if serial != "" && normalizeUnqualifiedDIMMSerial(memory[i].SerialNumber) == serial { return i } } return -1 } func extractUnqualifiedDIMMSlot(description string) string { m := unqualifiedDIMMSlotRE.FindStringSubmatch(description) if len(m) < 2 { return "" } return "DIMM " + strings.TrimSpace(m[1]) } func extractUnqualifiedDIMMSerial(description string) string { m := unqualifiedDIMMSerialRE.FindStringSubmatch(description) if len(m) < 2 { return "" } return strings.TrimSpace(m[1]) } func normalizeUnqualifiedDIMMSerial(serial string) string { serial = strings.ToUpper(strings.TrimSpace(serial)) if idx := strings.Index(serial, "-"); idx >= 0 { serial = serial[:idx] } return serial } func appendDIMMStatusHistory(dimm *models.MemoryDIMM, ev models.Event) { if dimm == nil || ev.Timestamp.IsZero() { return } for _, item := range dimm.StatusHistory { if strings.EqualFold(strings.TrimSpace(item.Status), "Warning") && item.ChangedAt.Equal(ev.Timestamp.UTC()) && strings.TrimSpace(item.Details) == strings.TrimSpace(ev.Description) { return } } dimm.StatusHistory = append(dimm.StatusHistory, models.StatusHistoryEntry{ Status: "Warning", ChangedAt: ev.Timestamp.UTC(), Details: ev.Description, }) } 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 "TIB": return int(v * 1099.511627776) // 1 TiB = 1099.511... GB case "GB": return int(v) case "GIB": return int(v * 1.073741824) // 1 GiB = 1.073741824 GB 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) }