package collector import ( "encoding/json" "fmt" "log" "sort" "strings" "time" "git.mchus.pro/mchus/logpile/internal/collector/redfishprofile" "git.mchus.pro/mchus/logpile/internal/models" ) // ReplayRedfishFromRawPayloads rebuilds AnalysisResult from saved Redfish raw payloads. // It expects rawPayloads["redfish_tree"] to contain a map[path]document snapshot. func ReplayRedfishFromRawPayloads(rawPayloads map[string]any, emit ProgressFn) (*models.AnalysisResult, error) { if len(rawPayloads) == 0 { return nil, fmt.Errorf("missing raw_payloads") } treeAny, ok := rawPayloads["redfish_tree"] if !ok { return nil, fmt.Errorf("raw_payloads.redfish_tree is missing") } tree, ok := treeAny.(map[string]interface{}) if !ok || len(tree) == 0 { return nil, fmt.Errorf("raw_payloads.redfish_tree has invalid format") } r := redfishSnapshotReader{tree: tree} if emit != nil { emit(Progress{Status: "running", Progress: 10, Message: "Redfish snapshot: replay service root..."}) } if _, err := r.getJSON("/redfish/v1"); err != nil { log.Printf("redfish replay: service root /redfish/v1 missing from snapshot, continuing with defaults: %v", err) } systemPaths := r.discoverMemberPaths("/redfish/v1/Systems", "/redfish/v1/Systems/1") chassisPaths := r.discoverMemberPaths("/redfish/v1/Chassis", "/redfish/v1/Chassis/1") managerPaths := r.discoverMemberPaths("/redfish/v1/Managers", "/redfish/v1/Managers/1") primarySystem := firstPathOrDefault(systemPaths, "/redfish/v1/Systems/1") primaryChassis := firstPathOrDefault(chassisPaths, "/redfish/v1/Chassis/1") primaryManager := firstPathOrDefault(managerPaths, "/redfish/v1/Managers/1") if emit != nil { emit(Progress{Status: "running", Progress: 30, Message: "Redfish snapshot: replay system..."}) } systemDoc, err := r.getJSON(primarySystem) if err != nil { return nil, fmt.Errorf("system info: %w", err) } chassisDoc, _ := r.getJSON(primaryChassis) managerDoc, _ := r.getJSON(primaryManager) biosDoc, _ := r.getJSON(joinPath(primarySystem, "/Bios")) systemFRUDoc, _ := r.getJSON(joinPath(primarySystem, "/Oem/Public/FRU")) chassisFRUDoc, _ := r.getJSON(joinPath(primaryChassis, "/Oem/Public/FRU")) fruDoc := systemFRUDoc if len(fruDoc) == 0 { fruDoc = chassisFRUDoc } boardFallbackDocs := r.collectBoardFallbackDocs(systemPaths, chassisPaths) profileSignals := redfishprofile.CollectSignalsFromTree(tree) profileMatch := redfishprofile.MatchProfiles(profileSignals) analysisPlan := redfishprofile.ResolveAnalysisPlan(profileMatch, tree, redfishprofile.DiscoveredResources{ SystemPaths: systemPaths, ChassisPaths: chassisPaths, ManagerPaths: managerPaths, }, profileSignals) if emit != nil { emit(Progress{Status: "running", Progress: 55, Message: "Redfish snapshot: replay CPU/RAM/Storage..."}) } processors := r.collectProcessors(primarySystem) memory := r.collectMemory(primarySystem) storageDevices := r.collectStorage(primarySystem, analysisPlan) storageVolumes := r.collectStorageVolumes(primarySystem, analysisPlan) if emit != nil { emit(Progress{Status: "running", Progress: 80, Message: "Redfish snapshot: replay network/BMC..."}) } psus := r.collectPSUs(chassisPaths) pcieDevices := r.collectPCIeDevices(systemPaths, chassisPaths) boardInfo := parseBoardInfoWithFallback(systemDoc, chassisDoc, fruDoc) applyBoardInfoFallbackFromDocs(&boardInfo, boardFallbackDocs) gpus := r.collectGPUs(systemPaths, chassisPaths, analysisPlan) gpus = r.collectGPUsFromProcessors(systemPaths, chassisPaths, gpus, analysisPlan) nics := r.collectNICs(chassisPaths) r.enrichNICsFromNetworkInterfaces(&nics, systemPaths) thresholdSensors := r.collectThresholdSensors(chassisPaths) thermalSensors := r.collectThermalSensors(chassisPaths) powerSensors := r.collectPowerSensors(chassisPaths) discreteEvents := r.collectDiscreteSensorEvents(chassisPaths) healthEvents := r.collectHealthSummaryEvents(chassisPaths) driveFetchWarningEvents := buildDriveFetchWarningEvents(rawPayloads) networkProtocolDoc, _ := r.getJSON(joinPath(primaryManager, "/NetworkProtocol")) firmware := parseFirmware(systemDoc, biosDoc, managerDoc, networkProtocolDoc) firmware = dedupeFirmwareInfo(append(firmware, r.collectFirmwareInventory()...)) boardInfo.BMCMACAddress = r.collectBMCMAC(managerPaths) assemblyFRU := r.collectAssemblyFRU(chassisPaths) collectedAt, sourceTimezone := inferRedfishCollectionTime(managerDoc, rawPayloads) inventoryLastModifiedAt := inferInventoryLastModifiedTime(r.tree) logEntryEvents := parseRedfishLogEntries(rawPayloads, collectedAt) result := &models.AnalysisResult{ CollectedAt: collectedAt, InventoryLastModifiedAt: inventoryLastModifiedAt, SourceTimezone: sourceTimezone, Events: append(append(append(append(make([]models.Event, 0, len(discreteEvents)+len(healthEvents)+len(driveFetchWarningEvents)+len(logEntryEvents)+1), healthEvents...), discreteEvents...), driveFetchWarningEvents...), logEntryEvents...), FRU: assemblyFRU, Sensors: dedupeSensorReadings(append(append(thresholdSensors, thermalSensors...), powerSensors...)), RawPayloads: cloneRawPayloads(rawPayloads), Hardware: &models.HardwareConfig{ BoardInfo: boardInfo, CPUs: processors, Memory: memory, Storage: storageDevices, Volumes: storageVolumes, PCIeDevices: pcieDevices, GPUs: gpus, PowerSupply: psus, NetworkAdapters: nics, Firmware: firmware, }, } match := profileMatch for _, profile := range match.Profiles { profile.PostAnalyze(result, tree, profileSignals) } if result.RawPayloads == nil { result.RawPayloads = map[string]any{} } appliedProfiles := make([]string, 0, len(match.Profiles)) for _, profile := range match.Profiles { appliedProfiles = append(appliedProfiles, profile.Name()) } result.RawPayloads["redfish_analysis_profiles"] = map[string]any{ "mode": match.Mode, "profiles": appliedProfiles, } result.RawPayloads["redfish_analysis_plan"] = map[string]any{ "mode": analysisPlan.Match.Mode, "profiles": appliedProfiles, "notes": analysisPlan.Notes, "directives": map[string]any{ "processor_gpu_fallback": analysisPlan.Directives.EnableProcessorGPUFallback, "supermicro_nvme_backplane": analysisPlan.Directives.EnableSupermicroNVMeBackplane, "processor_gpu_chassis_alias": analysisPlan.Directives.EnableProcessorGPUChassisAlias, "generic_graphics_controller_dedup": analysisPlan.Directives.EnableGenericGraphicsControllerDedup, "msi_processor_gpu_chassis_lookup": analysisPlan.Directives.EnableMSIProcessorGPUChassisLookup, "storage_enclosure_recovery": analysisPlan.Directives.EnableStorageEnclosureRecovery, "known_storage_controller_recovery": analysisPlan.Directives.EnableKnownStorageControllerRecovery, }, } if strings.TrimSpace(sourceTimezone) != "" { result.RawPayloads["source_timezone"] = sourceTimezone } appendMissingServerModelWarning(result, systemDoc, joinPath(primarySystem, "/Oem/Public/FRU"), joinPath(primaryChassis, "/Oem/Public/FRU")) return result, nil } func inferRedfishCollectionTime(managerDoc map[string]interface{}, rawPayloads map[string]any) (time.Time, string) { dateTime := strings.TrimSpace(asString(managerDoc["DateTime"])) offset := strings.TrimSpace(asString(managerDoc["DateTimeLocalOffset"])) if dateTime != "" { if ts, err := time.Parse(time.RFC3339Nano, dateTime); err == nil { if offset == "" { offset = ts.Format("-07:00") } return ts.UTC(), offset } if ts, err := time.Parse(time.RFC3339, dateTime); err == nil { if offset == "" { offset = ts.Format("-07:00") } return ts.UTC(), offset } } if offset == "" && len(rawPayloads) > 0 { if tz, ok := rawPayloads["source_timezone"].(string); ok { offset = strings.TrimSpace(tz) } } return time.Time{}, offset } // inferInventoryLastModifiedTime reads InventoryData/Status.InventoryData.LastModifiedTime // from the Redfish snapshot. Returns zero time if not present or unparseable. func inferInventoryLastModifiedTime(snapshot map[string]interface{}) time.Time { docAny, ok := snapshot["/redfish/v1/Oem/Ami/InventoryData/Status"] if !ok { return time.Time{} } doc, ok := docAny.(map[string]interface{}) if !ok { return time.Time{} } invData, ok := doc["InventoryData"].(map[string]interface{}) if !ok { return time.Time{} } raw := strings.TrimSpace(asString(invData["LastModifiedTime"])) if raw == "" { return time.Time{} } for _, layout := range []string{time.RFC3339, time.RFC3339Nano} { if ts, err := time.Parse(layout, raw); err == nil { t := ts.UTC() log.Printf("redfish replay: inventory last modified at %s (InventoryData/Status.LastModifiedTime)", t.Format(time.RFC3339)) return t } } return time.Time{} } func appendMissingServerModelWarning(result *models.AnalysisResult, systemDoc map[string]interface{}, systemFRUPath, chassisFRUPath string) { if result == nil || result.Hardware == nil { return } if strings.TrimSpace(result.Hardware.BoardInfo.ProductName) != "" { return } reasons := make([]string, 0, 3) systemModelRaw := strings.TrimSpace(asString(systemDoc["Model"])) if systemModelRaw != "" && normalizeRedfishIdentityField(systemModelRaw) == "" { reasons = append(reasons, fmt.Sprintf("system model is placeholder: %q", systemModelRaw)) } errs := redfishFetchErrorsFromRawPayloads(result.RawPayloads) if msg := errs[normalizeRedfishPath(systemFRUPath)]; strings.TrimSpace(msg) != "" { reasons = append(reasons, fmt.Sprintf("%s unavailable: %s", systemFRUPath, msg)) } if msg := errs[normalizeRedfishPath(chassisFRUPath)]; strings.TrimSpace(msg) != "" { reasons = append(reasons, fmt.Sprintf("%s unavailable: %s", chassisFRUPath, msg)) } if len(reasons) == 0 { reasons = append(reasons, "no non-placeholder ProductName/Model found in collected Redfish documents") } result.Events = append(result.Events, models.Event{ Timestamp: time.Now(), Source: "Redfish", EventType: "Collection Warning", Severity: models.SeverityWarning, Description: "Server model is missing in collected Redfish data", RawData: strings.Join(reasons, "; "), }) } func redfishFetchErrorsFromRawPayloads(rawPayloads map[string]any) map[string]string { out := make(map[string]string) if len(rawPayloads) == 0 { return out } raw, ok := rawPayloads["redfish_fetch_errors"] if !ok { return out } switch list := raw.(type) { case []map[string]interface{}: return redfishFetchErrorListToMap(list) case []interface{}: normalized := make([]map[string]interface{}, 0, len(list)) for _, item := range list { m, ok := item.(map[string]interface{}) if !ok { continue } normalized = append(normalized, m) } return redfishFetchErrorListToMap(normalized) default: return out } } func buildDriveFetchWarningEvents(rawPayloads map[string]any) []models.Event { errs := redfishFetchErrorsFromRawPayloads(rawPayloads) if len(errs) == 0 { return nil } paths := make([]string, 0, len(errs)) timeoutCount := 0 for path, msg := range errs { normalizedPath := normalizeRedfishPath(path) if !strings.Contains(strings.ToLower(normalizedPath), "/drives/") { continue } paths = append(paths, normalizedPath) low := strings.ToLower(msg) if strings.Contains(low, "timeout") || strings.Contains(low, "deadline exceeded") { timeoutCount++ } } if len(paths) == 0 { return nil } sort.Strings(paths) preview := paths const maxPreview = 8 if len(preview) > maxPreview { preview = preview[:maxPreview] } rawData := strings.Join(preview, ", ") if len(paths) > len(preview) { rawData = fmt.Sprintf("%s (+%d more)", rawData, len(paths)-len(preview)) } if timeoutCount > 0 { rawData = fmt.Sprintf("timeouts=%d; paths=%s", timeoutCount, rawData) } return []models.Event{ { Timestamp: time.Now(), Source: "Redfish", EventType: "Collection Warning", Severity: models.SeverityWarning, Description: fmt.Sprintf("%d drive documents were unavailable; storage details may be incomplete", len(paths)), RawData: rawData, }, } } func (r redfishSnapshotReader) collectFirmwareInventory() []models.FirmwareInfo { docs, err := r.getCollectionMembers("/redfish/v1/UpdateService/FirmwareInventory") if err != nil || len(docs) == 0 { return nil } out := make([]models.FirmwareInfo, 0, len(docs)) for _, doc := range docs { version := firstNonEmpty( asString(doc["Version"]), asString(doc["FirmwareVersion"]), asString(doc["SoftwareVersion"]), ) if strings.TrimSpace(version) == "" { continue } name := firmwareInventoryDeviceName(doc) 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}) } return out } func firmwareInventoryDeviceName(doc map[string]interface{}) string { name := strings.TrimSpace(asString(doc["DeviceName"])) if name != "" { return name } id := strings.TrimSpace(asString(doc["Id"])) rawName := strings.TrimSpace(asString(doc["Name"])) if strings.EqualFold(rawName, "Software Inventory") || strings.EqualFold(rawName, "Firmware Inventory") { if id != "" { return id } } if rawName != "" { return rawName } return id } func dedupeFirmwareInfo(items []models.FirmwareInfo) []models.FirmwareInfo { seen := make(map[string]struct{}, len(items)) out := make([]models.FirmwareInfo, 0, len(items)) for _, fw := range items { name := strings.TrimSpace(fw.DeviceName) ver := strings.TrimSpace(fw.Version) if name == "" || ver == "" { continue } key := strings.ToLower(name + "|" + ver) if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} out = append(out, models.FirmwareInfo{DeviceName: name, Version: ver}) } return out } func (r redfishSnapshotReader) collectThresholdSensors(chassisPaths []string) []models.SensorReading { out := make([]models.SensorReading, 0) seen := make(map[string]struct{}) for _, chassisPath := range chassisPaths { thresholdPath := joinPath(chassisPath, "/ThresholdSensors") docs, _ := r.getCollectionMembers(thresholdPath) if len(docs) == 0 { if thresholdDoc, err := r.getJSON(thresholdPath); err == nil { docs = append(docs, redfishInlineSensors(thresholdDoc)...) } } for _, doc := range docs { sensor, ok := parseThresholdSensor(doc) if !ok { continue } key := strings.ToLower(strings.TrimSpace(sensor.Name)) if key == "" { key = strings.ToLower(strings.TrimSpace(sensor.Type) + "|" + strings.TrimSpace(sensor.RawValue)) } if _, exists := seen[key]; exists { continue } seen[key] = struct{}{} out = append(out, sensor) } } return out } func parseThresholdSensor(doc map[string]interface{}) (models.SensorReading, bool) { if len(doc) == 0 { return models.SensorReading{}, false } name := firstNonEmpty(asString(doc["Name"]), asString(doc["Id"])) status := mapStatus(doc["Status"]) if status == "" { status = firstNonEmpty(asString(doc["Health"]), asString(doc["State"])) } reading := 0.0 unit := "" rawValue := "" switch { case asString(doc["ReadingCelsius"]) != "": reading = asFloat(doc["ReadingCelsius"]) unit = "C" rawValue = asString(doc["ReadingCelsius"]) case asString(doc["ReadingVolts"]) != "": reading = asFloat(doc["ReadingVolts"]) unit = "V" rawValue = asString(doc["ReadingVolts"]) case asString(doc["ReadingAmps"]) != "": reading = asFloat(doc["ReadingAmps"]) unit = "A" rawValue = asString(doc["ReadingAmps"]) case asString(doc["ReadingWatts"]) != "": reading = asFloat(doc["ReadingWatts"]) unit = "W" rawValue = asString(doc["ReadingWatts"]) case asString(doc["Reading"]) != "": reading = asFloat(doc["Reading"]) unit = asString(doc["ReadingUnits"]) rawValue = asString(doc["Reading"]) } if name == "" && rawValue == "" && status == "" { return models.SensorReading{}, false } return models.SensorReading{ Name: firstNonEmpty(name, "threshold-sensor"), Type: firstNonEmpty(asString(doc["ReadingType"]), asString(doc["SensorType"]), "threshold"), Value: reading, Unit: unit, RawValue: rawValue, Status: status, }, true } func (r redfishSnapshotReader) collectDiscreteSensorEvents(chassisPaths []string) []models.Event { out := make([]models.Event, 0) for _, chassisPath := range chassisPaths { discretePath := joinPath(chassisPath, "/DiscreteSensors") docs, _ := r.getCollectionMembers(discretePath) if len(docs) == 0 { if discreteDoc, err := r.getJSON(discretePath); err == nil { docs = append(docs, redfishInlineSensors(discreteDoc)...) } } for _, doc := range docs { ev, ok := parseDiscreteSensorEvent(doc) if !ok { continue } out = append(out, ev) } } return out } func parseDiscreteSensorEvent(doc map[string]interface{}) (models.Event, bool) { name := firstNonEmpty(asString(doc["Name"]), asString(doc["Id"])) status := mapStatus(doc["Status"]) if status == "" { status = firstNonEmpty(asString(doc["Health"]), asString(doc["State"])) } if name == "" || status == "" { return models.Event{}, false } normalized := strings.ToLower(strings.TrimSpace(status)) if normalized == "ok" || normalized == "enabled" || normalized == "normal" || normalized == "present" { return models.Event{}, false } return models.Event{ Timestamp: time.Now(), Source: "Redfish", SensorName: name, EventType: "Discrete Sensor Status", Severity: models.SeverityWarning, Description: fmt.Sprintf("%s reports %s", name, status), RawData: firstNonEmpty(asString(doc["Description"]), status), }, true } func (r redfishSnapshotReader) collectThermalSensors(chassisPaths []string) []models.SensorReading { out := make([]models.SensorReading, 0) for _, chassisPath := range chassisPaths { doc, err := r.getJSON(joinPath(chassisPath, "/Thermal")) if err != nil || len(doc) == 0 { continue } for _, fanDoc := range redfishArrayObjects(doc["Fans"]) { out = append(out, parseThermalFanSensor(fanDoc)) } for _, tempDoc := range redfishArrayObjects(doc["Temperatures"]) { out = append(out, parseThermalTemperatureSensor(tempDoc)) } } return out } func (r redfishSnapshotReader) collectPowerSensors(chassisPaths []string) []models.SensorReading { out := make([]models.SensorReading, 0) for _, chassisPath := range chassisPaths { doc, err := r.getJSON(joinPath(chassisPath, "/Power")) if err != nil || len(doc) == 0 { continue } out = append(out, parsePowerOemPublicSensors(doc)...) for _, controlDoc := range redfishArrayObjects(doc["PowerControl"]) { if sensor, ok := parsePowerControlSensor(controlDoc); ok { out = append(out, sensor) } } for _, psuDoc := range redfishArrayObjects(doc["PowerSupplies"]) { out = append(out, parsePowerSupplySensors(psuDoc)...) } } return out } func parseThermalFanSensor(doc map[string]interface{}) models.SensorReading { name := firstNonEmpty(asString(doc["Name"]), asString(doc["MemberId"]), "Fan") unit := firstNonEmpty(asString(doc["ReadingUnits"]), "RPM") value := asFloat(doc["Reading"]) raw := firstNonEmpty(asString(doc["Reading"]), asString(doc["Name"])) status := firstNonEmpty(mapStatus(doc["Status"]), asString(doc["State"]), asString(doc["Health"]), "unknown") return models.SensorReading{ Name: name, Type: "fan_speed", Value: value, Unit: unit, RawValue: raw, Status: status, } } func parseThermalTemperatureSensor(doc map[string]interface{}) models.SensorReading { name := firstNonEmpty(asString(doc["Name"]), asString(doc["MemberId"]), "Temperature") reading := asFloat(doc["ReadingCelsius"]) raw := asString(doc["ReadingCelsius"]) if raw == "" { reading = asFloat(doc["Reading"]) raw = asString(doc["Reading"]) } status := firstNonEmpty(mapStatus(doc["Status"]), asString(doc["State"]), asString(doc["Health"]), "unknown") return models.SensorReading{ Name: name, Type: "temperature", Value: reading, Unit: "C", RawValue: raw, Status: status, } } func parsePowerOemPublicSensors(doc map[string]interface{}) []models.SensorReading { oem, ok := doc["Oem"].(map[string]interface{}) if !ok { return nil } public, ok := oem["Public"].(map[string]interface{}) if !ok { return nil } var out []models.SensorReading add := func(name, key string) { raw := asString(public[key]) if strings.TrimSpace(raw) == "" { return } out = append(out, models.SensorReading{ Name: name, Type: "power", Value: asFloat(public[key]), Unit: "W", RawValue: raw, Status: "OK", }) } add("Total_Power", "TotalPower") add("CPU_Power", "CurrentCPUPowerWatts") add("Memory_Power", "CurrentMemoryPowerWatts") add("Fan_Power", "CurrentFANPowerWatts") return out } func parsePowerControlSensor(doc map[string]interface{}) (models.SensorReading, bool) { raw := asString(doc["PowerConsumedWatts"]) if strings.TrimSpace(raw) == "" { return models.SensorReading{}, false } name := firstNonEmpty(asString(doc["Name"]), asString(doc["MemberId"]), "PowerControl") status := firstNonEmpty(mapStatus(doc["Status"]), asString(doc["State"]), asString(doc["Health"]), "unknown") return models.SensorReading{ Name: name + "_Consumed", Type: "power", Value: asFloat(doc["PowerConsumedWatts"]), Unit: "W", RawValue: raw, Status: status, }, true } func parsePowerSupplySensors(doc map[string]interface{}) []models.SensorReading { name := firstNonEmpty(asString(doc["Name"]), "PSU") status := firstNonEmpty(mapStatus(doc["Status"]), asString(doc["State"]), asString(doc["Health"]), "unknown") var out []models.SensorReading add := func(suffix, key, unit string) { raw := asString(doc[key]) if strings.TrimSpace(raw) == "" { return } out = append(out, models.SensorReading{ Name: fmt.Sprintf("%s_%s", name, suffix), Type: strings.ToLower(suffix), Value: asFloat(doc[key]), Unit: unit, RawValue: raw, Status: status, }) } add("InputPower", "PowerInputWatts", "W") add("OutputPower", "LastPowerOutputWatts", "W") add("InputVoltage", "LineInputVoltage", "V") return out } func redfishArrayObjects(v any) []map[string]interface{} { list, ok := v.([]interface{}) if !ok || len(list) == 0 { return nil } out := make([]map[string]interface{}, 0, len(list)) for _, item := range list { m, ok := item.(map[string]interface{}) if !ok { continue } out = append(out, m) } return out } func redfishInlineSensors(doc map[string]interface{}) []map[string]interface{} { return redfishArrayObjects(doc["Sensors"]) } func dedupeSensorReadings(items []models.SensorReading) []models.SensorReading { if len(items) <= 1 { return items } out := make([]models.SensorReading, 0, len(items)) seen := make(map[string]struct{}, len(items)) for _, s := range items { key := strings.ToLower(strings.TrimSpace(s.Name) + "|" + strings.TrimSpace(s.Type)) if strings.TrimSpace(key) == "|" { key = strings.ToLower(strings.TrimSpace(s.RawValue)) } if strings.TrimSpace(key) == "" { continue } if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} out = append(out, s) } return out } func (r redfishSnapshotReader) collectHealthSummaryEvents(chassisPaths []string) []models.Event { out := make([]models.Event, 0) for _, chassisPath := range chassisPaths { doc, err := r.getJSON(joinPath(chassisPath, "/HealthSummary")) if err != nil || len(doc) == 0 { continue } health := firstNonEmpty( mapStatus(doc["Status"]), asString(doc["Health"]), asString(doc["HealthRollup"]), findFirstNormalizedStringByKeys(doc, "Health", "HealthRollup", "OverallHealth"), ) if health == "" { continue } if strings.EqualFold(health, "OK") || strings.EqualFold(health, "Normal") { continue } raw, _ := json.Marshal(doc) out = append(out, models.Event{ Timestamp: time.Now(), Source: "Redfish", EventType: "Health Summary", Severity: models.SeverityWarning, Description: fmt.Sprintf("Chassis health summary reports %s", health), RawData: string(raw), }) } return out } func collectNetworkPortMACs(doc map[string]interface{}) []string { if len(doc) == 0 { return nil } out := make([]string, 0, 4) if list, ok := doc["AssociatedNetworkAddresses"].([]interface{}); ok { for _, item := range list { if s := strings.TrimSpace(asString(item)); s != "" { out = append(out, s) } } } for _, key := range []string{"MACAddress", "PermanentMACAddress", "CurrentMACAddress"} { if s := strings.TrimSpace(asString(doc[key])); s != "" { out = append(out, s) } } return out } func dedupeStrings(items []string) []string { seen := make(map[string]struct{}, len(items)) out := make([]string, 0, len(items)) for _, item := range items { s := strings.TrimSpace(item) if s == "" { continue } key := strings.ToLower(s) if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} out = append(out, s) } return out } type redfishSnapshotReader struct { tree map[string]interface{} } func (r redfishSnapshotReader) getJSON(requestPath string) (map[string]interface{}, error) { p := normalizeRedfishPath(requestPath) if doc, ok := r.tree[p]; ok { if m, ok := doc.(map[string]interface{}); ok { return m, nil } } if p != "/" { if doc, ok := r.tree[stringsTrimTrailingSlash(p)]; ok { if m, ok := doc.(map[string]interface{}); ok { return m, nil } } if doc, ok := r.tree[p+"/"]; ok { if m, ok := doc.(map[string]interface{}); ok { return m, nil } } } return nil, fmt.Errorf("snapshot path not found: %s", requestPath) } func (r redfishSnapshotReader) getCollectionMembers(collectionPath string) ([]map[string]interface{}, error) { collection, err := r.getJSON(collectionPath) if err != nil { return r.fallbackCollectionMembers(collectionPath, err) } memberPaths := redfishCollectionMemberRefs(collection) if len(memberPaths) == 0 { return r.fallbackCollectionMembers(collectionPath, nil) } out := make([]map[string]interface{}, 0, len(memberPaths)) for _, memberPath := range memberPaths { doc, err := r.getJSON(memberPath) if err != nil { continue } if strings.TrimSpace(asString(doc["@odata.id"])) == "" { doc["@odata.id"] = normalizeRedfishPath(memberPath) } out = append(out, doc) } if len(out) == 0 { return r.fallbackCollectionMembers(collectionPath, nil) } return out, nil } func (r redfishSnapshotReader) fallbackCollectionMembers(collectionPath string, originalErr error) ([]map[string]interface{}, error) { prefix := strings.TrimSuffix(normalizeRedfishPath(collectionPath), "/") + "/" if prefix == "/" { if originalErr != nil { return nil, originalErr } return []map[string]interface{}{}, nil } paths := make([]string, 0) for key := range r.tree { p := normalizeRedfishPath(key) if !strings.HasPrefix(p, prefix) { continue } rest := strings.TrimPrefix(p, prefix) if rest == "" || strings.Contains(rest, "/") { continue } paths = append(paths, p) } if len(paths) == 0 { if originalErr != nil { return nil, originalErr } return []map[string]interface{}{}, nil } sort.Strings(paths) out := make([]map[string]interface{}, 0, len(paths)) for _, p := range paths { doc, err := r.getJSON(p) if err != nil { continue } if strings.TrimSpace(asString(doc["@odata.id"])) == "" { doc["@odata.id"] = normalizeRedfishPath(p) } out = append(out, doc) } return out, nil } func cloneRawPayloads(src map[string]any) map[string]any { if len(src) == 0 { return nil } dst := make(map[string]any, len(src)) for k, v := range src { dst[k] = v } return dst } func (r redfishSnapshotReader) discoverMemberPaths(collectionPath, fallbackPath string) []string { collection, err := r.getJSON(collectionPath) if err == nil { if refs, ok := collection["Members"].([]interface{}); ok && len(refs) > 0 { paths := make([]string, 0, len(refs)) for _, refAny := range refs { ref, ok := refAny.(map[string]interface{}) if !ok { continue } memberPath := asString(ref["@odata.id"]) if memberPath != "" { paths = append(paths, memberPath) } } if len(paths) > 0 { return paths } } } if fallbackPath != "" { return []string{fallbackPath} } return nil } func (r redfishSnapshotReader) getLinkedPCIeFunctions(doc map[string]interface{}) []map[string]interface{} { if links, ok := doc["Links"].(map[string]interface{}); ok { if refs, ok := links["PCIeFunctions"].([]interface{}); ok && len(refs) > 0 { out := make([]map[string]interface{}, 0, len(refs)) for _, refAny := range refs { ref, ok := refAny.(map[string]interface{}) if !ok { continue } memberPath := asString(ref["@odata.id"]) if memberPath == "" { continue } memberDoc, err := r.getJSON(memberPath) if err != nil { continue } out = append(out, memberDoc) } return out } } if pcieFunctions, ok := doc["PCIeFunctions"].(map[string]interface{}); ok { if collectionPath := asString(pcieFunctions["@odata.id"]); collectionPath != "" { memberDocs, err := r.getCollectionMembers(collectionPath) if err == nil { return memberDocs } } } return nil } func (r redfishSnapshotReader) getLinkedSupplementalDocs(doc map[string]interface{}, keys ...string) []map[string]interface{} { if len(doc) == 0 || len(keys) == 0 { return nil } var out []map[string]interface{} seen := make(map[string]struct{}) for _, key := range keys { path := normalizeRedfishPath(redfishLinkedPath(doc, key)) if path == "" { continue } if _, ok := seen[path]; ok { continue } supplementalDoc, err := r.getJSON(path) if err != nil || len(supplementalDoc) == 0 { continue } seen[path] = struct{}{} out = append(out, supplementalDoc) } return out } func (r redfishSnapshotReader) collectProcessors(systemPath string) []models.CPU { memberDocs, err := r.getCollectionMembers(joinPath(systemPath, "/Processors")) if err != nil || len(memberDocs) == 0 { return nil } out := make([]models.CPU, 0, len(memberDocs)) socketIdx := 0 for _, doc := range memberDocs { if pt := strings.TrimSpace(asString(doc["ProcessorType"])); pt != "" && !strings.EqualFold(pt, "CPU") && !strings.EqualFold(pt, "General") { continue } cpu := parseCPUs([]map[string]interface{}{doc})[0] if cpu.Socket == 0 && socketIdx > 0 && strings.TrimSpace(asString(doc["Socket"])) == "" { cpu.Socket = socketIdx if cpu.Details == nil { cpu.Details = map[string]any{} } cpu.Details["socket"] = cpu.Socket } supplementalDocs := r.getLinkedSupplementalDocs(doc, "ProcessorMetrics", "EnvironmentMetrics", "Metrics") if len(supplementalDocs) > 0 { cpu.Details = mergeGenericDetails(cpu.Details, redfishCPUDetailsAcrossDocs(doc, supplementalDocs...)) } out = append(out, cpu) socketIdx++ } return out } func (r redfishSnapshotReader) collectMemory(systemPath string) []models.MemoryDIMM { memberDocs, err := r.getCollectionMembers(joinPath(systemPath, "/Memory")) if err != nil || len(memberDocs) == 0 { return nil } out := make([]models.MemoryDIMM, 0, len(memberDocs)) for _, doc := range memberDocs { dimm := parseMemory([]map[string]interface{}{doc})[0] supplementalDocs := r.getLinkedSupplementalDocs(doc, "MemoryMetrics", "EnvironmentMetrics", "Metrics") if len(supplementalDocs) > 0 { dimm.Details = mergeGenericDetails(dimm.Details, redfishMemoryDetailsAcrossDocs(doc, supplementalDocs...)) } out = append(out, dimm) } return out } func (r redfishSnapshotReader) collectPSUs(chassisPaths []string) []models.PSU { var out []models.PSU seen := make(map[string]int) idx := 1 for _, chassisPath := range chassisPaths { if memberDocs, err := r.getCollectionMembers(joinPath(chassisPath, "/PowerSubsystem/PowerSupplies")); err == nil && len(memberDocs) > 0 { for _, doc := range memberDocs { supplementalDocs := r.getLinkedSupplementalDocs(doc, "EnvironmentMetrics", "Metrics") idx = appendPSU(&out, seen, parsePSUWithSupplementalDocs(doc, idx, supplementalDocs...), idx) } continue } if powerDoc, err := r.getJSON(joinPath(chassisPath, "/Power")); err == nil { if members, ok := powerDoc["PowerSupplies"].([]interface{}); ok && len(members) > 0 { for _, item := range members { doc, ok := item.(map[string]interface{}) if !ok { continue } supplementalDocs := r.getLinkedSupplementalDocs(doc, "EnvironmentMetrics", "Metrics") idx = appendPSU(&out, seen, parsePSUWithSupplementalDocs(doc, idx, supplementalDocs...), idx) } } } } return out } func stringsTrimTrailingSlash(s string) string { for len(s) > 1 && s[len(s)-1] == '/' { s = s[:len(s)-1] } return s }