diff --git a/internal/collector/redfish.go b/internal/collector/redfish.go index f5daea7..c3b6158 100644 --- a/internal/collector/redfish.go +++ b/internal/collector/redfish.go @@ -1427,6 +1427,7 @@ func redfishCriticalEndpoints(systemPaths, chassisPaths, managerPaths []string) add(joinPath(p, "/PCIeDevices")) add(joinPath(p, "/Accelerators")) add(joinPath(p, "/Drives")) + add(joinPath(p, "/Assembly")) } for _, p := range managerPaths { add(p) @@ -1916,6 +1917,58 @@ func shouldCrawlPath(path string) bool { return false } } + // Non-inventory top-level service branches. + for _, prefix := range []string{ + "/redfish/v1/AccountService", + "/redfish/v1/CertificateService", + "/redfish/v1/EventService", + "/redfish/v1/Registries", + "/redfish/v1/SessionService", + "/redfish/v1/TaskService", + } { + if strings.HasPrefix(normalized, prefix) { + return false + } + } + // Manager-specific configuration paths (not hardware inventory). + if strings.Contains(normalized, "/Managers/") { + for _, part := range []string{ + "/FirewallRules", + "/KvmService", + "/LldpService", + "/SecurityService", + "/SmtpService", + "/SnmpService", + "/SyslogService", + "/VirtualMedia", + "/VncService", + "/Certificates", + } { + if strings.Contains(normalized, part) { + return false + } + } + } + // Per-CPU operating frequency configurations — not hardware inventory. + if strings.HasSuffix(normalized, "/OperatingConfigs") { + return false + } + // Per-core/thread sub-processors — inventory is captured at the top processor level. + if strings.Contains(normalized, "/SubProcessors") { + return false + } + // Non-inventory system endpoints. + for _, part := range []string{ + "/BootOptions", + "/HostPostCode", + "/Bios/Settings", + "/GetServerAllUSBStatus", + "/Oem/Public/KVM", + } { + if strings.Contains(normalized, part) { + return false + } + } heavyParts := []string{ "/JsonSchemas", "/LogServices/", @@ -2435,24 +2488,76 @@ func findFirstNormalizedStringByKeys(doc map[string]interface{}, keys ...string) func parseCPUs(docs []map[string]interface{}) []models.CPU { cpus := make([]models.CPU, 0, len(docs)) - for idx, doc := range docs { + socketIdx := 0 + for _, doc := range docs { + // Skip non-CPU processors (GPUs, FPGAs, etc.) that some BMCs list in the + // same Processors collection. + if pt := strings.TrimSpace(asString(doc["ProcessorType"])); pt != "" && + !strings.EqualFold(pt, "CPU") && !strings.EqualFold(pt, "General") { + continue + } + socket := socketIdx + socketIdx++ + if s := strings.TrimSpace(asString(doc["Socket"])); s != "" { + // Parse numeric suffix from labels like "CPU0", "Processor 1", etc. + trimmed := strings.TrimLeft(strings.ToUpper(s), "ABCDEFGHIJKLMNOPQRSTUVWXYZ _") + if n, err := strconv.Atoi(trimmed); err == nil { + socket = n + } + } + l1, l2, l3 := parseCPUCachesFromProcessorMemory(doc) cpus = append(cpus, models.CPU{ - Socket: idx, + Socket: socket, Model: firstNonEmpty(asString(doc["Model"]), asString(doc["Name"])), Cores: asInt(doc["TotalCores"]), Threads: asInt(doc["TotalThreads"]), FrequencyMHz: asInt(doc["OperatingSpeedMHz"]), MaxFreqMHz: asInt(doc["MaxSpeedMHz"]), SerialNumber: findFirstNormalizedStringByKeys(doc, "SerialNumber"), + L1CacheKB: l1, + L2CacheKB: l2, + L3CacheKB: l3, + Status: mapStatus(doc["Status"]), }) } return cpus } +// parseCPUCachesFromProcessorMemory reads L1/L2/L3 cache sizes from the +// Redfish ProcessorMemory array (Processor.v1_x spec). +func parseCPUCachesFromProcessorMemory(doc map[string]interface{}) (l1, l2, l3 int) { + mem, _ := doc["ProcessorMemory"].([]interface{}) + for _, mAny := range mem { + m, ok := mAny.(map[string]interface{}) + if !ok { + continue + } + capMiB := asInt(m["CapacityMiB"]) + if capMiB == 0 { + continue + } + capKB := capMiB * 1024 + switch strings.ToUpper(strings.TrimSpace(asString(m["MemoryType"]))) { + case "L1CACHE": + l1 = capKB + case "L2CACHE": + l2 = capKB + case "L3CACHE": + l3 = capKB + } + } + return +} + func parseMemory(docs []map[string]interface{}) []models.MemoryDIMM { out := make([]models.MemoryDIMM, 0, len(docs)) for _, doc := range docs { - slot := firstNonEmpty(asString(doc["DeviceLocator"]), asString(doc["Name"]), asString(doc["Id"])) + slot := firstNonEmpty( + asString(doc["DeviceLocator"]), + redfishLocationLabel(doc["Location"]), + asString(doc["Name"]), + asString(doc["Id"]), + ) present := true if strings.EqualFold(asString(doc["Status"]), "Absent") { present = false @@ -3154,6 +3259,12 @@ func hasMergedPlaceholderIndex(indexes map[int]struct{}, idx int) bool { } func looksLikeGPU(doc map[string]interface{}, functionDocs []map[string]interface{}) bool { + // "Display Device" is how MSI labels H100 secondary display/audio controller + // functions — these are not compute GPUs and should be excluded. + if strings.EqualFold(strings.TrimSpace(asString(doc["Description"])), "Display Device") { + return false + } + deviceType := strings.ToLower(asString(doc["DeviceType"])) if strings.Contains(deviceType, "gpu") || strings.Contains(deviceType, "graphics") || strings.Contains(deviceType, "accelerator") { return true @@ -3192,6 +3303,20 @@ func looksLikeGPU(doc map[string]interface{}, functionDocs []map[string]interfac return false } +// isVirtualStorageDrive returns true for BMC-virtual drives that should not +// appear in hardware inventory (e.g. AMI virtual CD/USB sticks with 0 capacity). +func isVirtualStorageDrive(doc map[string]interface{}) bool { + if strings.EqualFold(asString(doc["Protocol"]), "USB") && asInt64(doc["CapacityBytes"]) == 0 { + return true + } + mfr := strings.ToUpper(strings.TrimSpace(asString(doc["Manufacturer"]))) + model := strings.ToUpper(strings.TrimSpace(asString(doc["Model"]))) + if strings.Contains(mfr, "AMI") && strings.Contains(model, "VIRTUAL") { + return true + } + return false +} + func looksLikeDrive(doc map[string]interface{}) bool { if asString(doc["MediaType"]) != "" { return true @@ -3951,8 +4076,7 @@ func parseFirmware(system, bios, manager, secureBoot, networkProtocol map[string appendFW("BIOS", asString(system["BiosVersion"])) appendFW("BIOS", asString(bios["Version"])) appendFW("BMC", asString(manager["FirmwareVersion"])) - appendFW("SecureBoot", asString(secureBoot["SecureBootCurrentBoot"])) - appendFW("NetworkProtocol", asString(networkProtocol["Id"])) + appendFW("SecureBoot", asString(secureBoot["SecureBootMode"])) return out } @@ -4441,7 +4565,6 @@ func redfishSnapshotPrioritySeeds(systemPaths, chassisPaths, managerPaths []stri add(joinPath(p, "/Memory")) add(joinPath(p, "/EthernetInterfaces")) add(joinPath(p, "/NetworkInterfaces")) - add(joinPath(p, "/BootOptions")) add(joinPath(p, "/PCIeDevices")) add(joinPath(p, "/PCIeFunctions")) add(joinPath(p, "/Accelerators")) diff --git a/internal/collector/redfish_replay.go b/internal/collector/redfish_replay.go index 1310e99..5247cc7 100644 --- a/internal/collector/redfish_replay.go +++ b/internal/collector/redfish_replay.go @@ -72,6 +72,7 @@ func ReplayRedfishFromRawPayloads(rawPayloads map[string]any, emit ProgressFn) ( psus := r.collectPSUs(chassisPaths) pcieDevices := r.collectPCIeDevices(systemPaths, chassisPaths) gpus := r.collectGPUs(systemPaths, chassisPaths) + gpus = r.collectGPUsFromProcessors(systemPaths, chassisPaths, gpus) nics := r.collectNICs(chassisPaths) r.enrichNICsFromNetworkInterfaces(&nics, systemPaths) thresholdSensors := r.collectThresholdSensors(chassisPaths) @@ -86,13 +87,15 @@ func ReplayRedfishFromRawPayloads(rawPayloads map[string]any, emit ProgressFn) ( firmware = dedupeFirmwareInfo(append(firmware, r.collectFirmwareInventory()...)) boardInfo := parseBoardInfoWithFallback(systemDoc, chassisDoc, fruDoc) applyBoardInfoFallbackFromDocs(&boardInfo, boardFallbackDocs) + boardInfo.BMCMACAddress = r.collectBMCMAC(managerPaths) + assemblyFRU := r.collectAssemblyFRU(chassisPaths) collectedAt, sourceTimezone := inferRedfishCollectionTime(managerDoc, rawPayloads) result := &models.AnalysisResult{ CollectedAt: collectedAt, SourceTimezone: sourceTimezone, Events: append(append(append(make([]models.Event, 0, len(discreteEvents)+len(healthEvents)+len(driveFetchWarningEvents)+1), healthEvents...), discreteEvents...), driveFetchWarningEvents...), - FRU: make([]models.FRUInfo, 0), + FRU: assemblyFRU, Sensors: dedupeSensorReadings(append(append(thresholdSensors, thermalSensors...), powerSensors...)), RawPayloads: cloneRawPayloads(rawPayloads), Hardware: &models.HardwareConfig{ @@ -274,7 +277,12 @@ func (r redfishSnapshotReader) collectFirmwareInventory() []models.FirmwareInfo asString(doc["Name"]), asString(doc["Id"]), ) - if strings.TrimSpace(name) == "" { + name = strings.TrimSpace(name) + if name == "" { + continue + } + // BMCImageN entries are redundant backup slot labels; skip them. + if strings.HasPrefix(strings.ToLower(name), "bmcimage") { continue } out = append(out, models.FirmwareInfo{DeviceName: name, Version: version}) @@ -977,7 +985,9 @@ func (r redfishSnapshotReader) collectStorage(systemPath string) []models.Storag driveDocs, err := r.getCollectionMembers(driveCollectionPath) if err == nil { for _, driveDoc := range driveDocs { - out = append(out, parseDrive(driveDoc)) + if !isVirtualStorageDrive(driveDoc) { + out = append(out, parseDrive(driveDoc)) + } } if len(driveDocs) == 0 { for _, driveDoc := range r.probeDirectDiskBayChildren(driveCollectionPath) { @@ -1002,7 +1012,9 @@ func (r redfishSnapshotReader) collectStorage(systemPath string) []models.Storag if err != nil { continue } - out = append(out, parseDrive(driveDoc)) + if !isVirtualStorageDrive(driveDoc) { + out = append(out, parseDrive(driveDoc)) + } } continue } @@ -1154,6 +1166,10 @@ func (r redfishSnapshotReader) collectNICs(chassisPaths []string) []models.Netwo functionDocs := r.getLinkedPCIeFunctions(pcieDoc) enrichNICFromPCIe(&nic, pcieDoc, functionDocs) } + // Collect MACs from NetworkDeviceFunctions when not found via PCIe path. + if len(nic.MACAddresses) == 0 { + r.enrichNICMACsFromNetworkDeviceFunctions(&nic, doc) + } nics = append(nics, nic) } } @@ -1268,3 +1284,237 @@ func stringsTrimTrailingSlash(s string) string { } return s } + +// collectBMCMAC returns the MAC address of the first active BMC management +// interface found in Managers/*/EthernetInterfaces. Returns empty string if +// no MAC is available. +func (r redfishSnapshotReader) collectBMCMAC(managerPaths []string) string { + for _, managerPath := range managerPaths { + members, err := r.getCollectionMembers(joinPath(managerPath, "/EthernetInterfaces")) + if err != nil || len(members) == 0 { + continue + } + for _, doc := range members { + mac := strings.TrimSpace(firstNonEmpty( + asString(doc["PermanentMACAddress"]), + asString(doc["MACAddress"]), + )) + if mac == "" || strings.EqualFold(mac, "00:00:00:00:00:00") { + continue + } + return strings.ToUpper(mac) + } + } + return "" +} + +// collectAssemblyFRU reads Chassis/*/Assembly documents and returns FRU entries +// for subcomponents (backplanes, PSUs, DIMMs, etc.) that carry meaningful +// serial or part numbers. Entries already present in dedicated collections +// (PSUs, DIMMs) are included here as well so that all FRU data is available +// in one place; deduplication by serial is performed. +func (r redfishSnapshotReader) collectAssemblyFRU(chassisPaths []string) []models.FRUInfo { + seen := make(map[string]struct{}) + var out []models.FRUInfo + + add := func(fru models.FRUInfo) { + key := strings.ToUpper(strings.TrimSpace(fru.SerialNumber)) + if key == "" { + key = strings.ToUpper(strings.TrimSpace(fru.Description + "|" + fru.PartNumber)) + } + if key == "" || key == "|" { + return + } + if _, ok := seen[key]; ok { + return + } + seen[key] = struct{}{} + out = append(out, fru) + } + + for _, chassisPath := range chassisPaths { + doc, err := r.getJSON(joinPath(chassisPath, "/Assembly")) + if err != nil || len(doc) == 0 { + continue + } + assemblies, _ := doc["Assemblies"].([]interface{}) + for _, aAny := range assemblies { + a, ok := aAny.(map[string]interface{}) + if !ok { + continue + } + name := strings.TrimSpace(firstNonEmpty(asString(a["Name"]), asString(a["Description"]))) + model := strings.TrimSpace(asString(a["Model"])) + partNumber := strings.TrimSpace(asString(a["PartNumber"])) + serial := extractAssemblySerial(a) + + if serial == "" && partNumber == "" { + continue + } + add(models.FRUInfo{ + Description: name, + ProductName: model, + SerialNumber: serial, + PartNumber: partNumber, + }) + } + } + return out +} + +// extractAssemblySerial tries to find a serial number in an Assembly entry. +// Standard Redfish Assembly has no top-level SerialNumber; vendors put it in Oem. +func extractAssemblySerial(a map[string]interface{}) string { + // Some implementations expose it at top level. + if s := strings.TrimSpace(asString(a["SerialNumber"])); s != "" { + return s + } + // Dig into Oem for vendor-specific structures (e.g. Huawei COMMONb). + oem, _ := a["Oem"].(map[string]interface{}) + for _, v := range oem { + subtree, ok := v.(map[string]interface{}) + if !ok { + continue + } + for _, v2 := range subtree { + node, ok := v2.(map[string]interface{}) + if !ok { + continue + } + if s := strings.TrimSpace(asString(node["SerialNumber"])); s != "" { + return s + } + } + } + return "" +} + +// enrichNICMACsFromNetworkDeviceFunctions reads the NetworkDeviceFunctions +// collection linked from a NetworkAdapter document and populates the NIC's +// MACAddresses from each function's Ethernet.PermanentMACAddress / MACAddress. +// Called when PCIe-path enrichment does not produce any MACs. +func (r redfishSnapshotReader) enrichNICMACsFromNetworkDeviceFunctions(nic *models.NetworkAdapter, adapterDoc map[string]interface{}) { + ndfCol, ok := adapterDoc["NetworkDeviceFunctions"].(map[string]interface{}) + if !ok { + return + } + colPath := asString(ndfCol["@odata.id"]) + if colPath == "" { + return + } + funcDocs, err := r.getCollectionMembers(colPath) + if err != nil || len(funcDocs) == 0 { + return + } + for _, fn := range funcDocs { + eth, _ := fn["Ethernet"].(map[string]interface{}) + if eth == nil { + continue + } + mac := strings.TrimSpace(firstNonEmpty( + asString(eth["PermanentMACAddress"]), + asString(eth["MACAddress"]), + )) + if mac == "" { + continue + } + nic.MACAddresses = dedupeStrings(append(nic.MACAddresses, strings.ToUpper(mac))) + } + if len(funcDocs) > 0 && nic.PortCount == 0 { + nic.PortCount = sanitizeNetworkPortCount(len(funcDocs)) + } +} + +// collectGPUsFromProcessors finds GPUs that some BMCs (e.g. MSI) expose as +// Processor entries with ProcessorType=GPU rather than as PCIe devices. +// It supplements the existing gpus slice (already found via PCIe path), +// skipping entries already present by UUID or SerialNumber. +// Serial numbers are looked up from Chassis members named after each GPU Id. +func (r redfishSnapshotReader) collectGPUsFromProcessors(systemPaths, chassisPaths []string, existing []models.GPU) []models.GPU { + // Build a lookup: chassis member ID → chassis doc (for serial numbers). + chassisByID := make(map[string]map[string]interface{}) + for _, cp := range chassisPaths { + doc, err := r.getJSON(cp) + if err != nil || len(doc) == 0 { + continue + } + id := strings.TrimSpace(asString(doc["Id"])) + if id != "" { + chassisByID[strings.ToUpper(id)] = doc + } + } + + // Build dedup sets from existing GPUs. + seenUUID := make(map[string]struct{}) + seenSerial := make(map[string]struct{}) + for _, g := range existing { + if u := strings.ToUpper(strings.TrimSpace(g.UUID)); u != "" { + seenUUID[u] = struct{}{} + } + if s := strings.ToUpper(strings.TrimSpace(g.SerialNumber)); s != "" { + seenSerial[s] = struct{}{} + } + } + + out := append([]models.GPU{}, existing...) + idx := len(existing) + 1 + + for _, systemPath := range systemPaths { + procDocs, err := r.getCollectionMembers(joinPath(systemPath, "/Processors")) + if err != nil { + continue + } + for _, doc := range procDocs { + if !strings.EqualFold(strings.TrimSpace(asString(doc["ProcessorType"])), "GPU") { + continue + } + + // Resolve serial from Chassis/. + gpuID := strings.TrimSpace(asString(doc["Id"])) + serial := "" + if chassisDoc, ok := chassisByID[strings.ToUpper(gpuID)]; ok { + serial = strings.TrimSpace(asString(chassisDoc["SerialNumber"])) + } + + uuid := strings.TrimSpace(asString(doc["UUID"])) + uuidKey := strings.ToUpper(uuid) + serialKey := strings.ToUpper(serial) + + if uuidKey != "" { + if _, dup := seenUUID[uuidKey]; dup { + continue + } + seenUUID[uuidKey] = struct{}{} + } + if serialKey != "" { + if _, dup := seenSerial[serialKey]; dup { + continue + } + seenSerial[serialKey] = struct{}{} + } + + slotLabel := firstNonEmpty( + redfishLocationLabel(doc["Location"]), + redfishLocationLabel(doc["PhysicalLocation"]), + ) + if slotLabel == "" && gpuID != "" { + slotLabel = gpuID + } + if slotLabel == "" { + slotLabel = fmt.Sprintf("GPU%d", idx) + } + + out = append(out, models.GPU{ + Slot: slotLabel, + Model: firstNonEmpty(asString(doc["Model"]), asString(doc["Name"])), + Manufacturer: asString(doc["Manufacturer"]), + PartNumber: asString(doc["PartNumber"]), + SerialNumber: serial, + UUID: uuid, + Status: mapStatus(doc["Status"]), + }) + idx++ + } + } + return out +} diff --git a/internal/exporter/reanimator_converter.go b/internal/exporter/reanimator_converter.go index 1b61a23..82f1038 100644 --- a/internal/exporter/reanimator_converter.go +++ b/internal/exporter/reanimator_converter.go @@ -385,10 +385,10 @@ func mergeCanonicalDevice(primary, secondary models.HardwareDevice) models.Hardw fillFloat(&primary.InputVoltage, secondary.InputVoltage) fillInt(&primary.TemperatureC, secondary.TemperatureC) fillString(&primary.Status, secondary.Status) - if primary.StatusCheckedAt.IsZero() && !secondary.StatusCheckedAt.IsZero() { + if primary.StatusCheckedAt == nil && secondary.StatusCheckedAt != nil { primary.StatusCheckedAt = secondary.StatusCheckedAt } - if primary.StatusChangedAt.IsZero() && !secondary.StatusChangedAt.IsZero() { + if primary.StatusChangedAt == nil && secondary.StatusChangedAt != nil { primary.StatusChangedAt = secondary.StatusChangedAt } if primary.StatusAtCollect == nil && secondary.StatusAtCollect != nil { @@ -1130,8 +1130,8 @@ type convertedStatusMeta struct { func buildStatusMeta( currentStatus string, - checkedAt time.Time, - changedAt time.Time, + checkedAt *time.Time, + changedAt *time.Time, statusAtCollection *models.StatusAtCollection, history []models.StatusHistoryEntry, errorDescription string, @@ -1145,7 +1145,7 @@ func buildStatusMeta( convertedHistory := make([]ReanimatorStatusHistoryEntry, 0, len(history)) for _, h := range history { - changed := formatOptionalRFC3339(h.ChangedAt) + changed := formatOptionalRFC3339(&h.ChangedAt) if changed == "" { continue } @@ -1166,7 +1166,7 @@ func buildStatusMeta( } if statusAtCollection != nil { - at := formatOptionalRFC3339(statusAtCollection.At) + at := formatOptionalRFC3339(&statusAtCollection.At) if at != "" && strings.TrimSpace(statusAtCollection.Status) != "" { meta.StatusAtCollection = &ReanimatorStatusAtCollection{ Status: normalizeStatus(statusAtCollection.Status, true), @@ -1191,8 +1191,8 @@ func buildStatusMeta( return meta } -func formatOptionalRFC3339(t time.Time) string { - if t.IsZero() { +func formatOptionalRFC3339(t *time.Time) string { + if t == nil || t.IsZero() { return "" } return t.UTC().Format(time.RFC3339) diff --git a/internal/models/models.go b/internal/models/models.go index 36a18be..83d33e9 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -148,8 +148,8 @@ type HardwareDevice struct { TemperatureC int `json:"temperature_c,omitempty"` Status string `json:"status,omitempty"` - StatusCheckedAt time.Time `json:"status_checked_at,omitempty"` - StatusChangedAt time.Time `json:"status_changed_at,omitempty"` + StatusCheckedAt *time.Time `json:"status_checked_at,omitempty"` + StatusChangedAt *time.Time `json:"status_changed_at,omitempty"` StatusAtCollect *StatusAtCollection `json:"status_at_collection,omitempty"` StatusHistory []StatusHistoryEntry `json:"status_history,omitempty"` ErrorDescription string `json:"error_description,omitempty"` @@ -167,13 +167,14 @@ type FirmwareInfo struct { // BoardInfo represents motherboard/system information type BoardInfo struct { - Manufacturer string `json:"manufacturer,omitempty"` - ProductName string `json:"product_name,omitempty"` - Description string `json:"description,omitempty"` - SerialNumber string `json:"serial_number,omitempty"` - PartNumber string `json:"part_number,omitempty"` - Version string `json:"version,omitempty"` - UUID string `json:"uuid,omitempty"` + Manufacturer string `json:"manufacturer,omitempty"` + ProductName string `json:"product_name,omitempty"` + Description string `json:"description,omitempty"` + SerialNumber string `json:"serial_number,omitempty"` + PartNumber string `json:"part_number,omitempty"` + Version string `json:"version,omitempty"` + UUID string `json:"uuid,omitempty"` + BMCMACAddress string `json:"bmc_mac_address,omitempty"` } // CPU represents processor information @@ -193,8 +194,8 @@ type CPU struct { SerialNumber string `json:"serial_number,omitempty"` Status string `json:"status,omitempty"` - StatusCheckedAt time.Time `json:"status_checked_at,omitempty"` - StatusChangedAt time.Time `json:"status_changed_at,omitempty"` + StatusCheckedAt *time.Time `json:"status_checked_at,omitempty"` + StatusChangedAt *time.Time `json:"status_changed_at,omitempty"` StatusAtCollect *StatusAtCollection `json:"status_at_collection,omitempty"` StatusHistory []StatusHistoryEntry `json:"status_history,omitempty"` ErrorDescription string `json:"error_description,omitempty"` @@ -217,8 +218,8 @@ type MemoryDIMM struct { Status string `json:"status,omitempty"` Ranks int `json:"ranks,omitempty"` - StatusCheckedAt time.Time `json:"status_checked_at,omitempty"` - StatusChangedAt time.Time `json:"status_changed_at,omitempty"` + StatusCheckedAt *time.Time `json:"status_checked_at,omitempty"` + StatusChangedAt *time.Time `json:"status_changed_at,omitempty"` StatusAtCollect *StatusAtCollection `json:"status_at_collection,omitempty"` StatusHistory []StatusHistoryEntry `json:"status_history,omitempty"` ErrorDescription string `json:"error_description,omitempty"` @@ -240,8 +241,8 @@ type Storage struct { BackplaneID int `json:"backplane_id,omitempty"` Status string `json:"status,omitempty"` - StatusCheckedAt time.Time `json:"status_checked_at,omitempty"` - StatusChangedAt time.Time `json:"status_changed_at,omitempty"` + StatusCheckedAt *time.Time `json:"status_checked_at,omitempty"` + StatusChangedAt *time.Time `json:"status_changed_at,omitempty"` StatusAtCollect *StatusAtCollection `json:"status_at_collection,omitempty"` StatusHistory []StatusHistoryEntry `json:"status_history,omitempty"` ErrorDescription string `json:"error_description,omitempty"` @@ -278,8 +279,8 @@ type PCIeDevice struct { MACAddresses []string `json:"mac_addresses,omitempty"` Status string `json:"status,omitempty"` - StatusCheckedAt time.Time `json:"status_checked_at,omitempty"` - StatusChangedAt time.Time `json:"status_changed_at,omitempty"` + StatusCheckedAt *time.Time `json:"status_checked_at,omitempty"` + StatusChangedAt *time.Time `json:"status_changed_at,omitempty"` StatusAtCollect *StatusAtCollection `json:"status_at_collection,omitempty"` StatusHistory []StatusHistoryEntry `json:"status_history,omitempty"` ErrorDescription string `json:"error_description,omitempty"` @@ -314,8 +315,8 @@ type PSU struct { OutputVoltage float64 `json:"output_voltage,omitempty"` TemperatureC int `json:"temperature_c,omitempty"` - StatusCheckedAt time.Time `json:"status_checked_at,omitempty"` - StatusChangedAt time.Time `json:"status_changed_at,omitempty"` + StatusCheckedAt *time.Time `json:"status_checked_at,omitempty"` + StatusChangedAt *time.Time `json:"status_changed_at,omitempty"` StatusAtCollect *StatusAtCollection `json:"status_at_collection,omitempty"` StatusHistory []StatusHistoryEntry `json:"status_history,omitempty"` ErrorDescription string `json:"error_description,omitempty"` @@ -352,8 +353,8 @@ type GPU struct { CurrentLinkSpeed string `json:"current_link_speed,omitempty"` Status string `json:"status,omitempty"` - StatusCheckedAt time.Time `json:"status_checked_at,omitempty"` - StatusChangedAt time.Time `json:"status_changed_at,omitempty"` + StatusCheckedAt *time.Time `json:"status_checked_at,omitempty"` + StatusChangedAt *time.Time `json:"status_changed_at,omitempty"` StatusAtCollect *StatusAtCollection `json:"status_at_collection,omitempty"` StatusHistory []StatusHistoryEntry `json:"status_history,omitempty"` ErrorDescription string `json:"error_description,omitempty"` @@ -377,8 +378,8 @@ type NetworkAdapter struct { MACAddresses []string `json:"mac_addresses,omitempty"` Status string `json:"status,omitempty"` - StatusCheckedAt time.Time `json:"status_checked_at,omitempty"` - StatusChangedAt time.Time `json:"status_changed_at,omitempty"` + StatusCheckedAt *time.Time `json:"status_checked_at,omitempty"` + StatusChangedAt *time.Time `json:"status_changed_at,omitempty"` StatusAtCollect *StatusAtCollection `json:"status_at_collection,omitempty"` StatusHistory []StatusHistoryEntry `json:"status_history,omitempty"` ErrorDescription string `json:"error_description,omitempty"` diff --git a/internal/parser/vendors/inspur/gpu_status.go b/internal/parser/vendors/inspur/gpu_status.go index b48bda4..4416cd7 100644 --- a/internal/parser/vendors/inspur/gpu_status.go +++ b/internal/parser/vendors/inspur/gpu_status.go @@ -70,11 +70,13 @@ func applyGPUStatusFromEvents(hw *models.HardwareConfig, events []models.Event) ChangedAt: e.Timestamp, Details: strings.TrimSpace(e.Description), }) - gpu.StatusChangedAt = e.Timestamp + ts := e.Timestamp + gpu.StatusChangedAt = &ts currentStatus[idx] = newStatus } - gpu.StatusCheckedAt = e.Timestamp + ts := e.Timestamp + gpu.StatusCheckedAt = &ts } } @@ -85,7 +87,7 @@ func applyGPUStatusFromEvents(hw *models.HardwareConfig, events []models.Event) } else { gpu.ErrorDescription = "" } - if gpu.StatusCheckedAt.IsZero() && strings.TrimSpace(gpu.Status) == "" { + if gpu.StatusCheckedAt == nil && strings.TrimSpace(gpu.Status) == "" { gpu.Status = "OK" } } diff --git a/internal/parser/vendors/nvidia/component_status_time.go b/internal/parser/vendors/nvidia/component_status_time.go index ed19c4d..e7d1ae1 100644 --- a/internal/parser/vendors/nvidia/component_status_time.go +++ b/internal/parser/vendors/nvidia/component_status_time.go @@ -230,7 +230,7 @@ func ApplyGPUAndNVSwitchCheckTimes(result *models.AnalysisResult, times componen if ts.IsZero() { continue } - gpu.StatusCheckedAt = ts + gpu.StatusCheckedAt = &ts status := strings.TrimSpace(gpu.Status) if status == "" { status = "Unknown" @@ -261,7 +261,7 @@ func ApplyGPUAndNVSwitchCheckTimes(result *models.AnalysisResult, times componen continue } - dev.StatusCheckedAt = ts + dev.StatusCheckedAt = &ts status := strings.TrimSpace(dev.Status) if status == "" { status = "Unknown" diff --git a/internal/parser/vendors/nvidia/component_status_time_test.go b/internal/parser/vendors/nvidia/component_status_time_test.go index e2a20d6..292d4e2 100644 --- a/internal/parser/vendors/nvidia/component_status_time_test.go +++ b/internal/parser/vendors/nvidia/component_status_time_test.go @@ -72,20 +72,20 @@ func TestApplyGPUAndNVSwitchCheckTimes(t *testing.T) { NVSwitchBySlot: map[string]time.Time{"NVSWITCH0": nvsTs}, }) - if got := result.Hardware.GPUs[0].StatusCheckedAt; !got.Equal(gpuTs) { - t.Fatalf("expected gpu status_checked_at %s, got %s", gpuTs.Format(time.RFC3339), got.Format(time.RFC3339)) + if got := result.Hardware.GPUs[0].StatusCheckedAt; got == nil || !got.Equal(gpuTs) { + t.Fatalf("expected gpu status_checked_at %s, got %v", gpuTs.Format(time.RFC3339), got) } if result.Hardware.GPUs[0].StatusAtCollect == nil || !result.Hardware.GPUs[0].StatusAtCollect.At.Equal(gpuTs) { t.Fatalf("expected gpu status_at_collection.at %s", gpuTs.Format(time.RFC3339)) } - if got := result.Hardware.PCIeDevices[0].StatusCheckedAt; !got.Equal(nvsTs) { - t.Fatalf("expected nvswitch status_checked_at %s, got %s", nvsTs.Format(time.RFC3339), got.Format(time.RFC3339)) + if got := result.Hardware.PCIeDevices[0].StatusCheckedAt; got == nil || !got.Equal(nvsTs) { + t.Fatalf("expected nvswitch status_checked_at %s, got %v", nvsTs.Format(time.RFC3339), got) } if result.Hardware.PCIeDevices[0].StatusAtCollect == nil || !result.Hardware.PCIeDevices[0].StatusAtCollect.At.Equal(nvsTs) { t.Fatalf("expected nvswitch status_at_collection.at %s", nvsTs.Format(time.RFC3339)) } - if !result.Hardware.PCIeDevices[1].StatusCheckedAt.IsZero() { - t.Fatalf("expected non-nvswitch device status_checked_at to stay zero") + if result.Hardware.PCIeDevices[1].StatusCheckedAt != nil { + t.Fatalf("expected non-nvswitch device status_checked_at to stay nil") } } diff --git a/internal/server/device_repository.go b/internal/server/device_repository.go index 864bfa2..ebc09fc 100644 --- a/internal/server/device_repository.go +++ b/internal/server/device_repository.go @@ -568,10 +568,10 @@ func mergeDevices(primary, secondary models.HardwareDevice) models.HardwareDevic fillFloat(&primary.InputVoltage, secondary.InputVoltage) fillInt(&primary.TemperatureC, secondary.TemperatureC) fillString(&primary.Status, secondary.Status) - if primary.StatusCheckedAt.IsZero() && !secondary.StatusCheckedAt.IsZero() { + if primary.StatusCheckedAt == nil && secondary.StatusCheckedAt != nil { primary.StatusCheckedAt = secondary.StatusCheckedAt } - if primary.StatusChangedAt.IsZero() && !secondary.StatusChangedAt.IsZero() { + if primary.StatusChangedAt == nil && secondary.StatusChangedAt != nil { primary.StatusChangedAt = secondary.StatusChangedAt } if primary.StatusAtCollect == nil && secondary.StatusAtCollect != nil {