export: align reanimator and enrich redfish metrics

This commit is contained in:
Mikhail Chusavitin
2026-03-15 21:38:28 +03:00
parent 0acdc2b202
commit 9007f1b360
17 changed files with 3756 additions and 650 deletions

View File

@@ -417,11 +417,13 @@ func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Clie
driveDocs, err := c.getCollectionMembers(ctx, client, req, baseURL, driveCollectionPath)
if err == nil {
for _, driveDoc := range driveDocs {
out = append(out, parseDrive(driveDoc))
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
}
if len(driveDocs) == 0 {
for _, driveDoc := range c.probeDirectDiskBayChildren(ctx, client, req, baseURL, driveCollectionPath) {
out = append(out, parseDrive(driveDoc))
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
}
}
}
@@ -442,14 +444,16 @@ func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Clie
if err != nil {
continue
}
out = append(out, parseDrive(driveDoc))
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
}
continue
}
// Some implementations return drive fields right in storage member object.
if looksLikeDrive(member) {
out = append(out, parseDrive(member))
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, member, "DriveMetrics", "EnvironmentMetrics", "Metrics")
out = append(out, parseDriveWithSupplementalDocs(member, supplementalDocs...))
}
// Supermicro/RAID implementations can expose physical disks under chassis enclosures
@@ -459,7 +463,8 @@ func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Clie
if err == nil {
for _, driveDoc := range driveDocs {
if looksLikeDrive(driveDoc) {
out = append(out, parseDrive(driveDoc))
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
}
}
if len(driveDocs) == 0 {
@@ -477,7 +482,8 @@ func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Clie
"/Storage/IntelVROC/Controllers/1/Drives",
}) {
if looksLikeDrive(driveDoc) {
out = append(out, parseDrive(driveDoc))
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
}
}
@@ -493,7 +499,7 @@ func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Clie
if !ok || !looksLikeDrive(devDoc) {
continue
}
out = append(out, parseDrive(devDoc))
out = append(out, parseDriveWithSupplementalDocs(devDoc))
}
}
@@ -508,7 +514,8 @@ func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Clie
if !looksLikeDrive(driveDoc) {
continue
}
out = append(out, parseDrive(driveDoc))
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
}
}
for _, chassisPath := range chassisPaths {
@@ -519,7 +526,8 @@ func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Clie
if !looksLikeDrive(driveDoc) {
continue
}
out = append(out, parseDrive(driveDoc))
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
}
}
@@ -575,7 +583,11 @@ func (c *RedfishConnector) collectNICs(ctx context.Context, client *http.Client,
continue
}
functionDocs := c.getLinkedPCIeFunctions(ctx, client, req, baseURL, pcieDoc)
enrichNICFromPCIe(&nic, pcieDoc, functionDocs)
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, pcieDoc, "EnvironmentMetrics", "Metrics")
for _, fn := range functionDocs {
supplementalDocs = append(supplementalDocs, c.getLinkedSupplementalDocs(ctx, client, req, baseURL, fn, "EnvironmentMetrics", "Metrics")...)
}
enrichNICFromPCIe(&nic, pcieDoc, functionDocs, supplementalDocs)
}
nics = append(nics, nic)
}
@@ -591,7 +603,8 @@ func (c *RedfishConnector) collectPSUs(ctx context.Context, client *http.Client,
// Redfish 2022+/X14+ commonly uses PowerSubsystem as the primary source.
if memberDocs, err := c.getCollectionMembers(ctx, client, req, baseURL, joinPath(chassisPath, "/PowerSubsystem/PowerSupplies")); err == nil && len(memberDocs) > 0 {
for _, doc := range memberDocs {
idx = appendPSU(&out, seen, parsePSU(doc, idx), idx)
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, doc, "EnvironmentMetrics", "Metrics")
idx = appendPSU(&out, seen, parsePSUWithSupplementalDocs(doc, idx, supplementalDocs...), idx)
}
continue
}
@@ -604,7 +617,8 @@ func (c *RedfishConnector) collectPSUs(ctx context.Context, client *http.Client,
if !ok {
continue
}
idx = appendPSU(&out, seen, parsePSU(doc, idx), idx)
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, doc, "EnvironmentMetrics", "Metrics")
idx = appendPSU(&out, seen, parsePSUWithSupplementalDocs(doc, idx, supplementalDocs...), idx)
}
}
}
@@ -654,6 +668,37 @@ func redfishLinkedPath(doc map[string]interface{}, key string) string {
return ""
}
func (c *RedfishConnector) getLinkedSupplementalDocs(
ctx context.Context,
client *http.Client,
req Request,
baseURL string,
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 := c.getJSON(ctx, client, req, baseURL, path)
if err != nil || len(supplementalDoc) == 0 {
continue
}
seen[path] = struct{}{}
out = append(out, supplementalDoc)
}
return out
}
func (c *RedfishConnector) collectGPUs(ctx context.Context, client *http.Client, req Request, baseURL string, systemPaths, chassisPaths []string) []models.GPU {
collections := make([]string, 0, len(systemPaths)*3+len(chassisPaths)*2)
for _, systemPath := range systemPaths {
@@ -681,7 +726,11 @@ func (c *RedfishConnector) collectGPUs(ctx context.Context, client *http.Client,
continue
}
gpu := parseGPU(doc, functionDocs, idx)
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, doc, "EnvironmentMetrics", "Metrics")
for _, fn := range functionDocs {
supplementalDocs = append(supplementalDocs, c.getLinkedSupplementalDocs(ctx, client, req, baseURL, fn, "EnvironmentMetrics", "Metrics")...)
}
gpu := parseGPUWithSupplementalDocs(doc, functionDocs, supplementalDocs, idx)
idx++
if shouldSkipGenericGPUDuplicate(out, gpu) {
continue
@@ -723,7 +772,11 @@ func (c *RedfishConnector) collectPCIeDevices(ctx context.Context, client *http.
if looksLikeGPU(doc, functionDocs) {
continue
}
dev := parsePCIeDevice(doc, functionDocs)
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, doc, "EnvironmentMetrics", "Metrics")
for _, fn := range functionDocs {
supplementalDocs = append(supplementalDocs, c.getLinkedSupplementalDocs(ctx, client, req, baseURL, fn, "EnvironmentMetrics", "Metrics")...)
}
dev := parsePCIeDeviceWithSupplementalDocs(doc, functionDocs, supplementalDocs)
if isUnidentifiablePCIeDevice(dev) {
continue
}
@@ -738,7 +791,8 @@ func (c *RedfishConnector) collectPCIeDevices(ctx context.Context, client *http.
continue
}
for idx, fn := range functionDocs {
dev := parsePCIeFunction(fn, idx+1)
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, fn, "EnvironmentMetrics", "Metrics")
dev := parsePCIeFunctionWithSupplementalDocs(fn, supplementalDocs, idx+1)
out = append(out, dev)
}
}
@@ -1333,7 +1387,7 @@ func shouldAdaptiveNVMeProbe(collectionDoc map[string]interface{}) bool {
// RoT and similar component chassis that expose an empty /Drives collection.
func chassisTypeCanHaveNVMe(chassisType string) bool {
switch strings.ToLower(strings.TrimSpace(chassisType)) {
case "module", // GPU SXM, NVLinkManagementNIC, PCIeRetimer
case "module", // GPU SXM, NVLinkManagementNIC, PCIeRetimer
"component", // ERoT, IRoT, BMC, FPGA sub-chassis
"zone": // HGX_Chassis_0 fabric zone
return false
@@ -1945,9 +1999,15 @@ func shouldCrawlPath(path string) bool {
if strings.Contains(normalized, "/Memory/") {
after := strings.SplitN(normalized, "/Memory/", 2)
if len(after) == 2 && strings.Count(after[1], "/") >= 1 {
// Keep direct DIMM resources (/Memory/<slot>) but skip nested subresources
// like /Memory/<slot>/Assembly and /Memory/<slot>/MemoryMetrics.
return false
// Keep direct DIMM resources and selected metrics subresources, but skip
// unrelated nested branches like Assembly.
return strings.HasSuffix(normalized, "/MemoryMetrics")
}
}
if strings.Contains(normalized, "/Processors/") {
after := strings.SplitN(normalized, "/Processors/", 2)
if len(after) == 2 && strings.Count(after[1], "/") >= 1 {
return strings.HasSuffix(normalized, "/ProcessorMetrics")
}
}
// Non-inventory top-level service branches.
@@ -2551,6 +2611,7 @@ func parseCPUs(docs []map[string]interface{}) []models.CPU {
L2CacheKB: l2,
L3CacheKB: l3,
Status: mapStatus(doc["Status"]),
Details: redfishCPUDetails(doc),
})
}
return cpus
@@ -2614,12 +2675,102 @@ func parseMemory(docs []map[string]interface{}) []models.MemoryDIMM {
SerialNumber: findFirstNormalizedStringByKeys(doc, "SerialNumber"),
PartNumber: asString(doc["PartNumber"]),
Status: mapStatus(doc["Status"]),
Details: redfishMemoryDetails(doc),
})
}
return out
}
func redfishCPUDetails(doc map[string]interface{}) map[string]any {
return redfishCPUDetailsAcrossDocs(doc)
}
func redfishCPUDetailsAcrossDocs(doc map[string]interface{}, supplementalDocs ...map[string]interface{}) map[string]any {
lookupDocs := append([]map[string]interface{}{doc}, supplementalDocs...)
details := make(map[string]any)
addFloatDetail(details, "temperature_c", redfishFirstNumericAcrossDocs(lookupDocs,
"TemperatureCelsius", "TemperatureC", "Temperature", "CurrentTemperature", "temperature",
))
addFloatDetail(details, "power_w", redfishFirstNumericAcrossDocs(lookupDocs,
"PowerConsumedWatts", "PowerWatts", "PowerConsumptionWatts",
))
addBoolDetail(details, "throttled", redfishFirstBoolAcrossDocs(lookupDocs,
"Throttled", "ThermalThrottled", "PerformanceThrottled",
))
addInt64Detail(details, "correctable_error_count", redfishFirstInt64AcrossDocs(lookupDocs,
"CorrectableErrorCount", "CorrectableErrors", "CorrectableECCErrorCount",
))
addInt64Detail(details, "uncorrectable_error_count", redfishFirstInt64AcrossDocs(lookupDocs,
"UncorrectableErrorCount", "UncorrectableErrors", "UncorrectableECCErrorCount",
))
addFloatDetail(details, "life_remaining_pct", redfishFirstNumericAcrossDocs(lookupDocs,
"LifeRemainingPercent", "PredictedLifeLeftPercent",
))
addFloatDetail(details, "life_used_pct", redfishFirstNumericAcrossDocs(lookupDocs,
"LifeUsedPercent", "PercentageLifeUsed",
))
for _, lookupDoc := range lookupDocs {
if microcode, ok := redfishLookupValue(lookupDoc, "MicrocodeVersion"); ok {
if s := strings.TrimSpace(asString(microcode)); s != "" {
details["microcode"] = s
break
}
}
if microcode, ok := redfishLookupValue(lookupDoc, "Microcode"); ok {
if s := strings.TrimSpace(asString(microcode)); s != "" {
details["microcode"] = s
break
}
}
}
if len(details) == 0 {
return nil
}
return details
}
func redfishMemoryDetails(doc map[string]interface{}) map[string]any {
return redfishMemoryDetailsAcrossDocs(doc)
}
func redfishMemoryDetailsAcrossDocs(doc map[string]interface{}, supplementalDocs ...map[string]interface{}) map[string]any {
lookupDocs := append([]map[string]interface{}{doc}, supplementalDocs...)
details := make(map[string]any)
addFloatDetail(details, "temperature_c", redfishFirstNumericAcrossDocs(lookupDocs,
"TemperatureCelsius", "TemperatureC", "Temperature", "CurrentTemperature", "temperature",
))
addInt64Detail(details, "correctable_ecc_error_count", redfishFirstInt64AcrossDocs(lookupDocs,
"CorrectableECCErrorCount", "CorrectableErrorCount", "CorrectableErrors",
))
addInt64Detail(details, "uncorrectable_ecc_error_count", redfishFirstInt64AcrossDocs(lookupDocs,
"UncorrectableECCErrorCount", "UncorrectableErrorCount", "UncorrectableErrors",
))
addFloatDetail(details, "life_remaining_pct", redfishFirstNumericAcrossDocs(lookupDocs,
"LifeRemainingPercent", "PredictedLifeLeftPercent",
))
addFloatDetail(details, "life_used_pct", redfishFirstNumericAcrossDocs(lookupDocs,
"LifeUsedPercent", "PercentageLifeUsed",
))
addFloatDetail(details, "spare_blocks_remaining_pct", redfishFirstNumericAcrossDocs(lookupDocs,
"SpareBlocksRemainingPercent", "SpareBlocksRemainingPct",
))
addBoolDetail(details, "performance_degraded", redfishFirstBoolAcrossDocs(lookupDocs,
"PerformanceDegraded", "Degraded",
))
addBoolDetail(details, "data_loss_detected", redfishFirstBoolAcrossDocs(lookupDocs,
"DataLossDetected", "DataLoss",
))
if len(details) == 0 {
return nil
}
return details
}
func parseDrive(doc map[string]interface{}) models.Storage {
return parseDriveWithSupplementalDocs(doc)
}
func parseDriveWithSupplementalDocs(doc map[string]interface{}, supplementalDocs ...map[string]interface{}) models.Storage {
sizeGB := 0
if capBytes := asInt64(doc["CapacityBytes"]); capBytes > 0 {
sizeGB = int(capBytes / (1024 * 1024 * 1024))
@@ -2644,6 +2795,7 @@ func parseDrive(doc map[string]interface{}) models.Storage {
Firmware: asString(doc["Revision"]),
Interface: asString(doc["Protocol"]),
Present: true,
Details: redfishDriveDetailsWithSupplementalDocs(doc, supplementalDocs...),
}
}
@@ -2744,6 +2896,7 @@ func parseNIC(doc map[string]interface{}) models.NetworkAdapter {
Slot: firstNonEmpty(asString(doc["Id"]), asString(doc["Name"])),
Location: location,
Present: !strings.EqualFold(mapStatus(doc["Status"]), "Absent"),
BDF: asString(doc["BDF"]),
Model: strings.TrimSpace(model),
Vendor: strings.TrimSpace(vendor),
VendorID: vendorID,
@@ -2753,6 +2906,7 @@ func parseNIC(doc map[string]interface{}) models.NetworkAdapter {
Firmware: firmware,
PortCount: portCount,
Status: mapStatus(doc["Status"]),
Details: redfishPCIeDetails(doc, nil),
}
}
@@ -2786,23 +2940,53 @@ func networkAdapterPCIeDevicePaths(doc map[string]interface{}) []string {
return out
}
func enrichNICFromPCIe(nic *models.NetworkAdapter, pcieDoc map[string]interface{}, functionDocs []map[string]interface{}) {
func enrichNICFromPCIe(nic *models.NetworkAdapter, pcieDoc map[string]interface{}, functionDocs []map[string]interface{}, supplementalDocs []map[string]interface{}) {
if nic == nil {
return
}
if strings.TrimSpace(nic.BDF) == "" {
nic.BDF = firstNonEmpty(asString(pcieDoc["BDF"]), buildBDFfromOemPublic(pcieDoc))
}
if nic.VendorID == 0 {
nic.VendorID = asHexOrInt(pcieDoc["VendorId"])
}
if nic.DeviceID == 0 {
nic.DeviceID = asHexOrInt(pcieDoc["DeviceId"])
}
if nic.LinkWidth == 0 {
nic.LinkWidth = asInt(pcieDoc["CurrentLinkWidth"])
}
if nic.MaxLinkWidth == 0 {
nic.MaxLinkWidth = asInt(pcieDoc["MaxLinkWidth"])
}
if strings.TrimSpace(nic.LinkSpeed) == "" {
nic.LinkSpeed = firstNonEmpty(asString(pcieDoc["CurrentLinkSpeedGTs"]), asString(pcieDoc["CurrentLinkSpeed"]))
}
if strings.TrimSpace(nic.MaxLinkSpeed) == "" {
nic.MaxLinkSpeed = firstNonEmpty(asString(pcieDoc["MaxLinkSpeedGTs"]), asString(pcieDoc["MaxLinkSpeed"]))
}
for _, fn := range functionDocs {
if strings.TrimSpace(nic.BDF) == "" {
nic.BDF = asString(fn["FunctionId"])
}
if nic.VendorID == 0 {
nic.VendorID = asHexOrInt(fn["VendorId"])
}
if nic.DeviceID == 0 {
nic.DeviceID = asHexOrInt(fn["DeviceId"])
}
if nic.LinkWidth == 0 {
nic.LinkWidth = asInt(fn["CurrentLinkWidth"])
}
if nic.MaxLinkWidth == 0 {
nic.MaxLinkWidth = asInt(fn["MaxLinkWidth"])
}
if strings.TrimSpace(nic.LinkSpeed) == "" {
nic.LinkSpeed = firstNonEmpty(asString(fn["CurrentLinkSpeedGTs"]), asString(fn["CurrentLinkSpeed"]))
}
if strings.TrimSpace(nic.MaxLinkSpeed) == "" {
nic.MaxLinkSpeed = firstNonEmpty(asString(fn["MaxLinkSpeedGTs"]), asString(fn["MaxLinkSpeed"]))
}
}
if strings.TrimSpace(nic.Vendor) == "" {
nic.Vendor = pciids.VendorName(nic.VendorID)
@@ -2812,9 +2996,14 @@ func enrichNICFromPCIe(nic *models.NetworkAdapter, pcieDoc map[string]interface{
nic.Model = resolved
}
}
nic.Details = mergeGenericDetails(nic.Details, redfishPCIeDetailsWithSupplementalDocs(pcieDoc, functionDocs, supplementalDocs))
}
func parsePSU(doc map[string]interface{}, idx int) models.PSU {
return parsePSUWithSupplementalDocs(doc, idx)
}
func parsePSUWithSupplementalDocs(doc map[string]interface{}, idx int, supplementalDocs ...map[string]interface{}) models.PSU {
status := mapStatus(doc["Status"])
present := true
if statusMap, ok := doc["Status"].(map[string]interface{}); ok {
@@ -2852,10 +3041,334 @@ func parsePSU(doc map[string]interface{}, idx int) models.PSU {
InputPowerW: asInt(doc["PowerInputWatts"]),
OutputPowerW: asInt(doc["LastPowerOutputWatts"]),
InputVoltage: asFloat(doc["LineInputVoltage"]),
Details: redfishPSUDetailsWithSupplementalDocs(doc, supplementalDocs...),
}
}
func redfishDriveDetails(doc map[string]interface{}) map[string]any {
return redfishDriveDetailsWithSupplementalDocs(doc)
}
func redfishDriveDetailsWithSupplementalDocs(doc map[string]interface{}, supplementalDocs ...map[string]interface{}) map[string]any {
details := make(map[string]any)
lookupDocs := append([]map[string]interface{}{doc}, supplementalDocs...)
addFloatDetail(details, "temperature_c", redfishFirstNumericAcrossDocs(lookupDocs,
"TemperatureCelsius", "TemperatureC", "Temperature", "CurrentTemperature", "temperature",
))
addInt64Detail(details, "power_on_hours", redfishFirstInt64AcrossDocs(lookupDocs,
"PowerOnHours", "PowerOnHour",
))
addInt64Detail(details, "power_cycles", redfishFirstInt64AcrossDocs(lookupDocs,
"PowerCycles", "PowerCycleCount",
))
addInt64Detail(details, "unsafe_shutdowns", redfishFirstInt64AcrossDocs(lookupDocs,
"UnsafeShutdowns", "UnsafeShutdownCount",
))
addInt64Detail(details, "media_errors", redfishFirstInt64AcrossDocs(lookupDocs,
"MediaErrors", "MediaErrorCount",
))
addInt64Detail(details, "error_log_entries", redfishFirstInt64AcrossDocs(lookupDocs,
"ErrorLogEntries", "ErrorLogEntryCount",
))
addInt64Detail(details, "written_bytes", redfishFirstInt64AcrossDocs(lookupDocs,
"WrittenBytes", "BytesWritten",
))
addInt64Detail(details, "read_bytes", redfishFirstInt64AcrossDocs(lookupDocs,
"ReadBytes", "BytesRead",
))
addFloatDetail(details, "life_remaining_pct", redfishFirstNumericAcrossDocs(lookupDocs,
"PredictedMediaLifeLeftPercent", "LifeRemainingPercent", "PercentageDriveLifeUsedInverse", "PercentLifeRemaining",
))
addFloatDetail(details, "life_used_pct", redfishFirstNumericAcrossDocs(lookupDocs,
"PercentageDriveLifeUsed", "LifeUsedPercent", "PercentageUsed", "PercentLifeUsed",
))
addFloatDetail(details, "available_spare_pct", redfishFirstNumericAcrossDocs(lookupDocs,
"AvailableSparePercent", "AvailableSpare", "PercentAvailableSpare",
))
addInt64Detail(details, "reallocated_sectors", redfishFirstInt64AcrossDocs(lookupDocs,
"ReallocatedSectors", "ReallocatedSectorCount",
))
addInt64Detail(details, "current_pending_sectors", redfishFirstInt64AcrossDocs(lookupDocs,
"CurrentPendingSectors", "CurrentPendingSectorCount",
))
addInt64Detail(details, "offline_uncorrectable", redfishFirstInt64AcrossDocs(lookupDocs,
"OfflineUncorrectable", "OfflineUncorrectableSectorCount",
))
if len(details) == 0 {
return nil
}
return details
}
func redfishPSUDetails(doc map[string]interface{}) map[string]any {
return redfishPSUDetailsWithSupplementalDocs(doc)
}
func redfishPSUDetailsWithSupplementalDocs(doc map[string]interface{}, supplementalDocs ...map[string]interface{}) map[string]any {
details := make(map[string]any)
lookupDocs := append([]map[string]interface{}{doc}, supplementalDocs...)
addFloatDetail(details, "temperature_c", redfishFirstNumericAcrossDocs(lookupDocs,
"TemperatureCelsius", "TemperatureC", "Temperature",
))
addFloatDetail(details, "life_remaining_pct", redfishFirstNumericAcrossDocs(lookupDocs,
"LifeRemainingPercent", "PredictedLifeLeftPercent",
))
addFloatDetail(details, "life_used_pct", redfishFirstNumericAcrossDocs(lookupDocs,
"LifeUsedPercent", "PercentageLifeUsed",
))
if len(details) == 0 {
return nil
}
return details
}
func redfishPCIeDetails(doc map[string]interface{}, functionDocs []map[string]interface{}) map[string]any {
return redfishPCIeDetailsWithSupplementalDocs(doc, functionDocs, nil)
}
func redfishPCIeDetailsWithSupplementalDocs(doc map[string]interface{}, functionDocs []map[string]interface{}, supplementalDocs []map[string]interface{}) map[string]any {
lookupDocs := make([]map[string]interface{}, 0, 1+len(functionDocs)+len(supplementalDocs))
lookupDocs = append(lookupDocs, doc)
lookupDocs = append(lookupDocs, functionDocs...)
lookupDocs = append(lookupDocs, supplementalDocs...)
details := make(map[string]any)
addFloatDetail(details, "temperature_c", redfishFirstNumericAcrossDocs(lookupDocs,
"TemperatureCelsius", "TemperatureC", "Temperature",
))
addFloatDetail(details, "power_w", redfishFirstNumericAcrossDocs(lookupDocs,
"PowerConsumedWatts", "PowerWatts", "PowerConsumptionWatts",
))
addFloatDetail(details, "life_remaining_pct", redfishFirstNumericAcrossDocs(lookupDocs,
"LifeRemainingPercent", "PredictedLifeLeftPercent",
))
addFloatDetail(details, "life_used_pct", redfishFirstNumericAcrossDocs(lookupDocs,
"LifeUsedPercent", "PercentageLifeUsed",
))
addInt64Detail(details, "ecc_corrected_total", redfishFirstInt64AcrossDocs(lookupDocs,
"ECCCorrectedTotal", "CorrectableECCErrorCount", "CorrectableErrorCount",
))
addInt64Detail(details, "ecc_uncorrected_total", redfishFirstInt64AcrossDocs(lookupDocs,
"ECCUncorrectedTotal", "UncorrectableECCErrorCount", "UncorrectableErrorCount",
))
addBoolDetail(details, "hw_slowdown", redfishFirstBoolAcrossDocs(lookupDocs,
"HWSlowdown", "HardwareSlowdown",
))
addFloatDetail(details, "battery_charge_pct", redfishFirstNumericAcrossDocs(lookupDocs,
"BatteryChargePercent", "BatteryChargePct",
))
addFloatDetail(details, "battery_health_pct", redfishFirstNumericAcrossDocs(lookupDocs,
"BatteryHealthPercent", "BatteryHealthPct",
))
addFloatDetail(details, "battery_temperature_c", redfishFirstNumericAcrossDocs(lookupDocs,
"BatteryTemperatureCelsius", "BatteryTemperatureC",
))
addFloatDetail(details, "battery_voltage_v", redfishFirstNumericAcrossDocs(lookupDocs,
"BatteryVoltage", "BatteryVoltageV",
))
addBoolDetail(details, "battery_replace_required", redfishFirstBoolAcrossDocs(lookupDocs,
"BatteryReplaceRequired", "ReplaceBattery",
))
addFloatDetail(details, "sfp_temperature_c", redfishFirstNumericAcrossDocs(lookupDocs,
"SFPTemperatureCelsius", "SFPTemperatureC", "TransceiverTemperatureCelsius",
))
addFloatDetail(details, "sfp_tx_power_dbm", redfishFirstNumericAcrossDocs(lookupDocs,
"SFPTXPowerDBm", "SFPTransmitPowerDBm", "TxPowerDBm",
))
addFloatDetail(details, "sfp_rx_power_dbm", redfishFirstNumericAcrossDocs(lookupDocs,
"SFPRXPowerDBm", "SFPReceivePowerDBm", "RxPowerDBm",
))
addFloatDetail(details, "sfp_voltage_v", redfishFirstNumericAcrossDocs(lookupDocs,
"SFPVoltageV", "TransceiverVoltageV",
))
addFloatDetail(details, "sfp_bias_ma", redfishFirstNumericAcrossDocs(lookupDocs,
"SFPBiasMA", "BiasCurrentMA", "LaserBiasCurrentMA",
))
if len(details) == 0 {
return nil
}
return details
}
func redfishFirstNumeric(doc map[string]interface{}, keys ...string) float64 {
for _, key := range keys {
if v, ok := redfishLookupValue(doc, key); ok {
if f := asFloat(v); f != 0 {
return f
}
}
}
return 0
}
func redfishFirstNumericAcrossDocs(docs []map[string]interface{}, keys ...string) float64 {
for _, doc := range docs {
if v := redfishFirstNumeric(doc, keys...); v != 0 {
return v
}
}
return 0
}
func redfishFirstNumericWithFunctions(doc map[string]interface{}, functionDocs []map[string]interface{}, keys ...string) float64 {
if v := redfishFirstNumeric(doc, keys...); v != 0 {
return v
}
for _, fn := range functionDocs {
if v := redfishFirstNumeric(fn, keys...); v != 0 {
return v
}
}
return 0
}
func redfishFirstInt64(doc map[string]interface{}, keys ...string) int64 {
for _, key := range keys {
if v, ok := redfishLookupValue(doc, key); ok {
if n := asInt64(v); n != 0 {
return n
}
if n := int64(asInt(v)); n != 0 {
return n
}
}
}
return 0
}
func redfishFirstInt64AcrossDocs(docs []map[string]interface{}, keys ...string) int64 {
for _, doc := range docs {
if v := redfishFirstInt64(doc, keys...); v != 0 {
return v
}
}
return 0
}
func redfishFirstInt64WithFunctions(doc map[string]interface{}, functionDocs []map[string]interface{}, keys ...string) int64 {
if v := redfishFirstInt64(doc, keys...); v != 0 {
return v
}
for _, fn := range functionDocs {
if v := redfishFirstInt64(fn, keys...); v != 0 {
return v
}
}
return 0
}
func redfishFirstBoolAcrossDocs(docs []map[string]interface{}, keys ...string) *bool {
for _, doc := range docs {
if v := redfishFirstBool(doc, keys...); v != nil {
return v
}
}
return nil
}
func redfishLookupValue(doc map[string]interface{}, key string) (any, bool) {
if doc == nil || strings.TrimSpace(key) == "" {
return nil, false
}
if v, ok := doc[key]; ok {
return v, true
}
if oem, ok := doc["Oem"].(map[string]interface{}); ok {
if v, ok := redfishLookupNestedValue(oem, key); ok {
return v, true
}
}
return nil, false
}
func redfishFirstBool(doc map[string]interface{}, keys ...string) *bool {
for _, key := range keys {
if v, ok := redfishLookupValue(doc, key); ok {
if b, ok := asBoolPtr(v); ok {
return &b
}
}
}
return nil
}
func redfishFirstBoolWithFunctions(doc map[string]interface{}, functionDocs []map[string]interface{}, keys ...string) *bool {
if v := redfishFirstBool(doc, keys...); v != nil {
return v
}
for _, fn := range functionDocs {
if v := redfishFirstBool(fn, keys...); v != nil {
return v
}
}
return nil
}
func redfishLookupNestedValue(doc map[string]interface{}, key string) (any, bool) {
if doc == nil {
return nil, false
}
if v, ok := doc[key]; ok {
return v, true
}
for _, value := range doc {
nested, ok := value.(map[string]interface{})
if !ok {
continue
}
if v, ok := redfishLookupNestedValue(nested, key); ok {
return v, true
}
}
return nil, false
}
func addFloatDetail(dst map[string]any, key string, value float64) {
if value == 0 {
return
}
dst[key] = value
}
func addInt64Detail(dst map[string]any, key string, value int64) {
if value == 0 {
return
}
dst[key] = value
}
func addBoolDetail(dst map[string]any, key string, value *bool) {
if value == nil {
return
}
dst[key] = *value
}
func asBoolPtr(v any) (bool, bool) {
switch x := v.(type) {
case bool:
return x, true
case string:
switch strings.ToLower(strings.TrimSpace(x)) {
case "true", "yes", "enabled", "1":
return true, true
case "false", "no", "disabled", "0":
return false, true
}
case float64:
return x != 0, true
case int:
return x != 0, true
case int64:
return x != 0, true
}
return false, false
}
func parseGPU(doc map[string]interface{}, functionDocs []map[string]interface{}, idx int) models.GPU {
return parseGPUWithSupplementalDocs(doc, functionDocs, nil, idx)
}
func parseGPUWithSupplementalDocs(doc map[string]interface{}, functionDocs []map[string]interface{}, supplementalDocs []map[string]interface{}, idx int) models.GPU {
slot := firstNonEmpty(
redfishLocationLabel(doc["Slot"]),
redfishLocationLabel(doc["Location"]),
@@ -2876,6 +3389,7 @@ func parseGPU(doc map[string]interface{}, functionDocs []map[string]interface{},
PartNumber: asString(doc["PartNumber"]),
Firmware: asString(doc["FirmwareVersion"]),
Status: mapStatus(doc["Status"]),
Details: redfishPCIeDetailsWithSupplementalDocs(doc, functionDocs, supplementalDocs),
}
if bdf := asString(doc["BDF"]); bdf != "" {
@@ -2928,6 +3442,10 @@ func parseGPU(doc map[string]interface{}, functionDocs []map[string]interface{},
}
func parsePCIeDevice(doc map[string]interface{}, functionDocs []map[string]interface{}) models.PCIeDevice {
return parsePCIeDeviceWithSupplementalDocs(doc, functionDocs, nil)
}
func parsePCIeDeviceWithSupplementalDocs(doc map[string]interface{}, functionDocs []map[string]interface{}, supplementalDocs []map[string]interface{}) models.PCIeDevice {
dev := models.PCIeDevice{
Slot: firstNonEmpty(redfishLocationLabel(doc["Slot"]), redfishLocationLabel(doc["Location"]), asString(doc["Name"]), asString(doc["Id"])),
BDF: asString(doc["BDF"]),
@@ -2937,6 +3455,7 @@ func parsePCIeDevice(doc map[string]interface{}, functionDocs []map[string]inter
SerialNumber: findFirstNormalizedStringByKeys(doc, "SerialNumber"),
VendorID: asHexOrInt(doc["VendorId"]),
DeviceID: asHexOrInt(doc["DeviceId"]),
Details: redfishPCIeDetailsWithSupplementalDocs(doc, functionDocs, supplementalDocs),
}
if strings.TrimSpace(dev.BDF) == "" {
dev.BDF = buildBDFfromOemPublic(doc)
@@ -2992,6 +3511,10 @@ func parsePCIeDevice(doc map[string]interface{}, functionDocs []map[string]inter
}
func parsePCIeFunction(doc map[string]interface{}, idx int) models.PCIeDevice {
return parsePCIeFunctionWithSupplementalDocs(doc, nil, idx)
}
func parsePCIeFunctionWithSupplementalDocs(doc map[string]interface{}, supplementalDocs []map[string]interface{}, idx int) models.PCIeDevice {
slot := firstNonEmpty(redfishLocationLabel(doc["Location"]), asString(doc["Id"]), asString(doc["Name"]))
if slot == "" {
slot = fmt.Sprintf("PCIeFn%d", idx)
@@ -3009,6 +3532,7 @@ func parsePCIeFunction(doc map[string]interface{}, idx int) models.PCIeDevice {
LinkSpeed: firstNonEmpty(asString(doc["CurrentLinkSpeedGTs"]), asString(doc["CurrentLinkSpeed"])),
MaxLinkWidth: asInt(doc["MaxLinkWidth"]),
MaxLinkSpeed: firstNonEmpty(asString(doc["MaxLinkSpeedGTs"]), asString(doc["MaxLinkSpeed"])),
Details: redfishPCIeDetailsWithSupplementalDocs(doc, nil, supplementalDocs),
}
if isGenericPCIeClassLabel(dev.DeviceClass) {
if resolved := pciids.DeviceName(dev.VendorID, dev.DeviceID); resolved != "" {
@@ -3552,8 +4076,10 @@ func storageIdentityKey(item models.Storage) string {
func richerStorageEntry(a, b models.Storage) models.Storage {
if storageRichnessScore(b) > storageRichnessScore(a) {
b.Details = mergeGenericDetails(b.Details, a.Details)
return b
}
a.Details = mergeGenericDetails(a.Details, b.Details)
return a
}
@@ -3815,6 +4341,9 @@ func networkAdapterRichnessScore(nic models.NetworkAdapter) int {
if normalizeRedfishIdentityField(nic.Firmware) != "" {
score += 8
}
if looksLikeCanonicalBDF(strings.TrimSpace(nic.BDF)) {
score += 10
}
if normalizeRedfishIdentityField(nic.PartNumber) != "" {
score += 6
}
@@ -3827,6 +4356,12 @@ func networkAdapterRichnessScore(nic models.NetworkAdapter) int {
if nic.PortCount > 0 {
score += 4
}
if nic.LinkWidth > 0 || nic.MaxLinkWidth > 0 {
score += 4
}
if strings.TrimSpace(nic.LinkSpeed) != "" || strings.TrimSpace(nic.MaxLinkSpeed) != "" {
score += 4
}
if len(nic.MACAddresses) > 0 {
score += 4
}
@@ -3853,6 +4388,9 @@ func mergeNetworkAdapterEntries(a, b models.NetworkAdapter) models.NetworkAdapte
if strings.TrimSpace(out.Location) == "" && strings.TrimSpace(donor.Location) != "" {
out.Location = donor.Location
}
if strings.TrimSpace(out.BDF) == "" && strings.TrimSpace(donor.BDF) != "" {
out.BDF = donor.BDF
}
if normalizeNetworkAdapterModel(out) == "" && normalizeNetworkAdapterModel(donor) != "" {
out.Model = donor.Model
}
@@ -3883,6 +4421,18 @@ func mergeNetworkAdapterEntries(a, b models.NetworkAdapter) models.NetworkAdapte
if strings.TrimSpace(out.PortType) == "" && strings.TrimSpace(donor.PortType) != "" {
out.PortType = donor.PortType
}
if out.LinkWidth == 0 && donor.LinkWidth > 0 {
out.LinkWidth = donor.LinkWidth
}
if strings.TrimSpace(out.LinkSpeed) == "" && strings.TrimSpace(donor.LinkSpeed) != "" {
out.LinkSpeed = donor.LinkSpeed
}
if out.MaxLinkWidth == 0 && donor.MaxLinkWidth > 0 {
out.MaxLinkWidth = donor.MaxLinkWidth
}
if strings.TrimSpace(out.MaxLinkSpeed) == "" && strings.TrimSpace(donor.MaxLinkSpeed) != "" {
out.MaxLinkSpeed = donor.MaxLinkSpeed
}
if strings.TrimSpace(out.Status) == "" && strings.TrimSpace(donor.Status) != "" {
out.Status = donor.Status
}
@@ -3890,6 +4440,7 @@ func mergeNetworkAdapterEntries(a, b models.NetworkAdapter) models.NetworkAdapte
if len(donor.MACAddresses) > 0 {
out.MACAddresses = dedupeStrings(append(append([]string{}, out.MACAddresses...), donor.MACAddresses...))
}
out.Details = mergeGenericDetails(out.Details, donor.Details)
return out
}
@@ -4041,6 +4592,7 @@ func mergePCIeDeviceEntries(a, b models.PCIeDevice) models.PCIeDevice {
if len(donor.MACAddresses) > 0 {
out.MACAddresses = dedupeStrings(append(append([]string{}, out.MACAddresses...), donor.MACAddresses...))
}
out.Details = mergeGenericDetails(out.Details, donor.Details)
return out
}
@@ -4147,9 +4699,25 @@ func mergePSUEntries(a, b models.PSU) models.PSU {
if out.TemperatureC == 0 && donor.TemperatureC > 0 {
out.TemperatureC = donor.TemperatureC
}
out.Details = mergeGenericDetails(out.Details, donor.Details)
return out
}
func mergeGenericDetails(primary, secondary map[string]any) map[string]any {
if len(secondary) == 0 {
return primary
}
if primary == nil {
primary = make(map[string]any, len(secondary))
}
for key, value := range secondary {
if _, ok := primary[key]; !ok {
primary[key] = value
}
}
return primary
}
func dedupeStorageVolumes(items []models.StorageVolume) []models.StorageVolume {
seen := make(map[string]struct{}, len(items))
out := make([]models.StorageVolume, 0, len(items))

View File

@@ -62,8 +62,8 @@ func ReplayRedfishFromRawPayloads(rawPayloads map[string]any, emit ProgressFn) (
if emit != nil {
emit(Progress{Status: "running", Progress: 55, Message: "Redfish snapshot: replay CPU/RAM/Storage..."})
}
processors, _ := r.getCollectionMembers(joinPath(primarySystem, "/Processors"))
memory, _ := r.getCollectionMembers(joinPath(primarySystem, "/Memory"))
processors := r.collectProcessors(primarySystem)
memory := r.collectMemory(primarySystem)
storageDevices := r.collectStorage(primarySystem)
storageVolumes := r.collectStorageVolumes(primarySystem)
@@ -101,8 +101,8 @@ func ReplayRedfishFromRawPayloads(rawPayloads map[string]any, emit ProgressFn) (
RawPayloads: cloneRawPayloads(rawPayloads),
Hardware: &models.HardwareConfig{
BoardInfo: boardInfo,
CPUs: parseCPUs(processors),
Memory: parseMemory(memory),
CPUs: processors,
Memory: memory,
Storage: storageDevices,
Volumes: storageVolumes,
PCIeDevices: pcieDevices,
@@ -977,6 +977,77 @@ func (r redfishSnapshotReader) getLinkedPCIeFunctions(doc map[string]interface{}
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) collectStorage(systemPath string) []models.Storage {
var out []models.Storage
storageMembers, _ := r.getCollectionMembers(joinPath(systemPath, "/Storage"))
@@ -987,12 +1058,14 @@ func (r redfishSnapshotReader) collectStorage(systemPath string) []models.Storag
if err == nil {
for _, driveDoc := range driveDocs {
if !isVirtualStorageDrive(driveDoc) {
out = append(out, parseDrive(driveDoc))
supplementalDocs := r.getLinkedSupplementalDocs(driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
}
}
if len(driveDocs) == 0 {
for _, driveDoc := range r.probeDirectDiskBayChildren(driveCollectionPath) {
out = append(out, parseDrive(driveDoc))
supplementalDocs := r.getLinkedSupplementalDocs(driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
}
}
}
@@ -1014,13 +1087,15 @@ func (r redfishSnapshotReader) collectStorage(systemPath string) []models.Storag
continue
}
if !isVirtualStorageDrive(driveDoc) {
out = append(out, parseDrive(driveDoc))
supplementalDocs := r.getLinkedSupplementalDocs(driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
}
}
continue
}
if looksLikeDrive(member) {
out = append(out, parseDrive(member))
supplementalDocs := r.getLinkedSupplementalDocs(member, "DriveMetrics", "EnvironmentMetrics", "Metrics")
out = append(out, parseDriveWithSupplementalDocs(member, supplementalDocs...))
}
for _, enclosurePath := range redfishLinkRefs(member, "Links", "Enclosures") {
@@ -1028,7 +1103,8 @@ func (r redfishSnapshotReader) collectStorage(systemPath string) []models.Storag
if err == nil {
for _, driveDoc := range driveDocs {
if looksLikeDrive(driveDoc) {
out = append(out, parseDrive(driveDoc))
supplementalDocs := r.getLinkedSupplementalDocs(driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
}
}
if len(driveDocs) == 0 {
@@ -1045,7 +1121,8 @@ func (r redfishSnapshotReader) collectStorage(systemPath string) []models.Storag
"/Storage/IntelVROC/Controllers/1/Drives",
}) {
if looksLikeDrive(driveDoc) {
out = append(out, parseDrive(driveDoc))
supplementalDocs := r.getLinkedSupplementalDocs(driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
}
}
@@ -1165,7 +1242,11 @@ func (r redfishSnapshotReader) collectNICs(chassisPaths []string) []models.Netwo
continue
}
functionDocs := r.getLinkedPCIeFunctions(pcieDoc)
enrichNICFromPCIe(&nic, pcieDoc, functionDocs)
supplementalDocs := r.getLinkedSupplementalDocs(pcieDoc, "EnvironmentMetrics", "Metrics")
for _, fn := range functionDocs {
supplementalDocs = append(supplementalDocs, r.getLinkedSupplementalDocs(fn, "EnvironmentMetrics", "Metrics")...)
}
enrichNICFromPCIe(&nic, pcieDoc, functionDocs, supplementalDocs)
}
// Collect MACs from NetworkDeviceFunctions when not found via PCIe path.
if len(nic.MACAddresses) == 0 {
@@ -1184,7 +1265,8 @@ func (r redfishSnapshotReader) collectPSUs(chassisPaths []string) []models.PSU {
for _, chassisPath := range chassisPaths {
if memberDocs, err := r.getCollectionMembers(joinPath(chassisPath, "/PowerSubsystem/PowerSupplies")); err == nil && len(memberDocs) > 0 {
for _, doc := range memberDocs {
idx = appendPSU(&out, seen, parsePSU(doc, idx), idx)
supplementalDocs := r.getLinkedSupplementalDocs(doc, "EnvironmentMetrics", "Metrics")
idx = appendPSU(&out, seen, parsePSUWithSupplementalDocs(doc, idx, supplementalDocs...), idx)
}
continue
}
@@ -1195,7 +1277,8 @@ func (r redfishSnapshotReader) collectPSUs(chassisPaths []string) []models.PSU {
if !ok {
continue
}
idx = appendPSU(&out, seen, parsePSU(doc, idx), idx)
supplementalDocs := r.getLinkedSupplementalDocs(doc, "EnvironmentMetrics", "Metrics")
idx = appendPSU(&out, seen, parsePSUWithSupplementalDocs(doc, idx, supplementalDocs...), idx)
}
}
}
@@ -1227,7 +1310,11 @@ func (r redfishSnapshotReader) collectGPUs(systemPaths, chassisPaths []string) [
if !looksLikeGPU(doc, functionDocs) {
continue
}
gpu := parseGPU(doc, functionDocs, idx)
supplementalDocs := r.getLinkedSupplementalDocs(doc, "EnvironmentMetrics", "Metrics")
for _, fn := range functionDocs {
supplementalDocs = append(supplementalDocs, r.getLinkedSupplementalDocs(fn, "EnvironmentMetrics", "Metrics")...)
}
gpu := parseGPUWithSupplementalDocs(doc, functionDocs, supplementalDocs, idx)
idx++
if shouldSkipGenericGPUDuplicate(out, gpu) {
continue
@@ -1265,7 +1352,11 @@ func (r redfishSnapshotReader) collectPCIeDevices(systemPaths, chassisPaths []st
if looksLikeGPU(doc, functionDocs) {
continue
}
dev := parsePCIeDevice(doc, functionDocs)
supplementalDocs := r.getLinkedSupplementalDocs(doc, "EnvironmentMetrics", "Metrics")
for _, fn := range functionDocs {
supplementalDocs = append(supplementalDocs, r.getLinkedSupplementalDocs(fn, "EnvironmentMetrics", "Metrics")...)
}
dev := parsePCIeDeviceWithSupplementalDocs(doc, functionDocs, supplementalDocs)
if isUnidentifiablePCIeDevice(dev) {
continue
}
@@ -1278,7 +1369,8 @@ func (r redfishSnapshotReader) collectPCIeDevices(systemPaths, chassisPaths []st
continue
}
for idx, fn := range functionDocs {
dev := parsePCIeFunction(fn, idx+1)
supplementalDocs := r.getLinkedSupplementalDocs(fn, "EnvironmentMetrics", "Metrics")
dev := parsePCIeFunctionWithSupplementalDocs(fn, supplementalDocs, idx+1)
out = append(out, dev)
}
}

View File

@@ -584,18 +584,32 @@ func TestEnrichNICFromPCIeFunctions(t *testing.T) {
}
functionDocs := []map[string]interface{}{
{
"VendorId": "0x15b3",
"DeviceId": "0x1021",
"FunctionId": "0000:17:00.0",
"VendorId": "0x15b3",
"DeviceId": "0x1021",
"CurrentLinkWidth": 16,
"CurrentLinkSpeedGTs": "32 GT/s",
"MaxLinkWidth": 16,
"MaxLinkSpeedGTs": "32 GT/s",
},
}
enrichNICFromPCIe(&nic, pcieDoc, functionDocs)
enrichNICFromPCIe(&nic, pcieDoc, functionDocs, nil)
if nic.VendorID != 0x15b3 || nic.DeviceID != 0x1021 {
t.Fatalf("unexpected NIC IDs: vendor=%#x device=%#x", nic.VendorID, nic.DeviceID)
}
if nic.Location != "PCIe Slot 1 (1)" {
t.Fatalf("unexpected NIC location: %q", nic.Location)
}
if nic.BDF != "0000:17:00.0" {
t.Fatalf("unexpected NIC BDF: %q", nic.BDF)
}
if nic.LinkWidth != 16 || nic.MaxLinkWidth != 16 {
t.Fatalf("unexpected NIC link width state: current=%d max=%d", nic.LinkWidth, nic.MaxLinkWidth)
}
if nic.LinkSpeed != "32 GT/s" || nic.MaxLinkSpeed != "32 GT/s" {
t.Fatalf("unexpected NIC link speed state: current=%q max=%q", nic.LinkSpeed, nic.MaxLinkSpeed)
}
}
func TestParseNIC_PortCountFromControllerCapabilities(t *testing.T) {
@@ -704,6 +718,208 @@ func TestParseComponents_UseNestedSerialNumberFallback(t *testing.T) {
}
}
func TestParseCPUAndMemory_CollectOemDetails(t *testing.T) {
cpus := parseCPUs([]map[string]interface{}{
{
"Id": "CPU0",
"Model": "Intel Xeon",
"CorrectableErrors": 7,
"TemperatureCelsius": 63,
"Oem": map[string]interface{}{
"VendorX": map[string]interface{}{
"MicrocodeVersion": "0x2b000643",
"UncorrectableErrors": 1,
"ThermalThrottled": true,
},
},
},
})
if len(cpus) != 1 || cpus[0].Details == nil {
t.Fatalf("expected CPU details, got %+v", cpus)
}
if cpus[0].Details["microcode"] != "0x2b000643" {
t.Fatalf("expected CPU microcode detail, got %#v", cpus[0].Details)
}
if cpus[0].Details["correctable_error_count"] != int64(7) || cpus[0].Details["uncorrectable_error_count"] != int64(1) {
t.Fatalf("expected CPU error counters, got %#v", cpus[0].Details)
}
if cpus[0].Details["throttled"] != true || cpus[0].Details["temperature_c"] != 63.0 {
t.Fatalf("expected CPU thermal details, got %#v", cpus[0].Details)
}
dimms := parseMemory([]map[string]interface{}{
{
"Id": "DIMM0",
"DeviceLocator": "CPU0_C0D0",
"CapacityMiB": 32768,
"SerialNumber": "DIMM-001",
"Oem": map[string]interface{}{
"VendorX": map[string]interface{}{
"CorrectableECCErrorCount": 12,
"UncorrectableECCErrorCount": 2,
"TemperatureC": 41.5,
"SpareBlocksRemainingPercent": 88,
"PerformanceDegraded": true,
"DataLossDetected": false,
},
},
},
})
if len(dimms) != 1 || dimms[0].Details == nil {
t.Fatalf("expected DIMM details, got %+v", dimms)
}
if dimms[0].Details["correctable_ecc_error_count"] != int64(12) || dimms[0].Details["uncorrectable_ecc_error_count"] != int64(2) {
t.Fatalf("expected DIMM ECC counters, got %#v", dimms[0].Details)
}
if dimms[0].Details["temperature_c"] != 41.5 || dimms[0].Details["spare_blocks_remaining_pct"] != 88.0 {
t.Fatalf("expected DIMM telemetry details, got %#v", dimms[0].Details)
}
if dimms[0].Details["performance_degraded"] != true || dimms[0].Details["data_loss_detected"] != false {
t.Fatalf("expected DIMM boolean health details, got %#v", dimms[0].Details)
}
}
func TestReplayRedfishFromRawPayloads_UsesProcessorAndMemoryMetrics(t *testing.T) {
rawPayloads := map[string]any{
"redfish_tree": map[string]interface{}{
"/redfish/v1": map[string]interface{}{},
"/redfish/v1/Systems": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Systems/1"},
},
},
"/redfish/v1/Systems/1": map[string]interface{}{
"Id": "1",
"Processors": map[string]interface{}{
"@odata.id": "/redfish/v1/Systems/1/Processors",
},
"Memory": map[string]interface{}{
"@odata.id": "/redfish/v1/Systems/1/Memory",
},
},
"/redfish/v1/Systems/1/Processors": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Systems/1/Processors/CPU0"},
},
},
"/redfish/v1/Systems/1/Processors/CPU0": map[string]interface{}{
"Id": "CPU0",
"ProcessorType": "CPU",
"Model": "Intel Xeon",
"ProcessorMetrics": map[string]interface{}{
"@odata.id": "/redfish/v1/Systems/1/Processors/CPU0/ProcessorMetrics",
},
},
"/redfish/v1/Systems/1/Processors/CPU0/ProcessorMetrics": map[string]interface{}{
"CorrectableErrors": 10,
"ThermalThrottled": true,
"MicrocodeVersion": "0x2b000643",
"TemperatureCelsius": 66,
},
"/redfish/v1/Systems/1/Memory": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Systems/1/Memory/DIMM0"},
},
},
"/redfish/v1/Systems/1/Memory/DIMM0": map[string]interface{}{
"Id": "DIMM0",
"DeviceLocator": "CPU0_C0D0",
"CapacityMiB": 32768,
"SerialNumber": "DIMM-001",
"MemoryMetrics": map[string]interface{}{
"@odata.id": "/redfish/v1/Systems/1/Memory/DIMM0/MemoryMetrics",
},
},
"/redfish/v1/Systems/1/Memory/DIMM0/MemoryMetrics": map[string]interface{}{
"CorrectableECCErrorCount": 14,
"TemperatureCelsius": 42,
"PerformanceDegraded": true,
"SpareBlocksRemainingPercent": 91,
},
},
}
result, err := ReplayRedfishFromRawPayloads(rawPayloads, nil)
if err != nil {
t.Fatalf("ReplayRedfishFromRawPayloads() failed: %v", err)
}
if len(result.Hardware.CPUs) != 1 || result.Hardware.CPUs[0].Details == nil {
t.Fatalf("expected CPU details from replay metrics, got %+v", result.Hardware.CPUs)
}
if result.Hardware.CPUs[0].Details["correctable_error_count"] != int64(10) || result.Hardware.CPUs[0].Details["microcode"] != "0x2b000643" {
t.Fatalf("expected CPU replay metrics details, got %#v", result.Hardware.CPUs[0].Details)
}
if len(result.Hardware.Memory) != 1 || result.Hardware.Memory[0].Details == nil {
t.Fatalf("expected memory details from replay metrics, got %+v", result.Hardware.Memory)
}
if result.Hardware.Memory[0].Details["correctable_ecc_error_count"] != int64(14) || result.Hardware.Memory[0].Details["performance_degraded"] != true {
t.Fatalf("expected DIMM replay metrics details, got %#v", result.Hardware.Memory[0].Details)
}
}
func TestReplayRedfishFromRawPayloads_UsesDriveMetrics(t *testing.T) {
rawPayloads := map[string]any{
"redfish_tree": map[string]interface{}{
"/redfish/v1": map[string]interface{}{},
"/redfish/v1/Systems": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Systems/1"},
},
},
"/redfish/v1/Systems/1": map[string]interface{}{
"Id": "1",
"Storage": map[string]interface{}{
"@odata.id": "/redfish/v1/Systems/1/Storage",
},
},
"/redfish/v1/Systems/1/Storage": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Systems/1/Storage/RAID1"},
},
},
"/redfish/v1/Systems/1/Storage/RAID1": map[string]interface{}{
"Id": "RAID1",
"Drives": map[string]interface{}{
"@odata.id": "/redfish/v1/Systems/1/Storage/RAID1/Drives",
},
},
"/redfish/v1/Systems/1/Storage/RAID1/Drives": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Systems/1/Storage/RAID1/Drives/Drive0"},
},
},
"/redfish/v1/Systems/1/Storage/RAID1/Drives/Drive0": map[string]interface{}{
"Id": "Drive0",
"Model": "NVMe SSD",
"SerialNumber": "SSD-001",
"DriveMetrics": map[string]interface{}{
"@odata.id": "/redfish/v1/Systems/1/Storage/RAID1/Drives/Drive0/DriveMetrics",
},
},
"/redfish/v1/Systems/1/Storage/RAID1/Drives/Drive0/DriveMetrics": map[string]interface{}{
"PowerOnHours": 1001,
"MediaErrors": 3,
"AvailableSparePercent": 92,
"TemperatureCelsius": 37,
},
},
}
result, err := ReplayRedfishFromRawPayloads(rawPayloads, nil)
if err != nil {
t.Fatalf("ReplayRedfishFromRawPayloads() failed: %v", err)
}
if len(result.Hardware.Storage) != 1 || result.Hardware.Storage[0].Details == nil {
t.Fatalf("expected storage details from replay drive metrics, got %+v", result.Hardware.Storage)
}
if result.Hardware.Storage[0].Details["power_on_hours"] != int64(1001) || result.Hardware.Storage[0].Details["media_errors"] != int64(3) {
t.Fatalf("expected drive metrics counters, got %#v", result.Hardware.Storage[0].Details)
}
if result.Hardware.Storage[0].Details["available_spare_pct"] != 92.0 || result.Hardware.Storage[0].Details["temperature_c"] != 37.0 {
t.Fatalf("expected drive metrics telemetry, got %#v", result.Hardware.Storage[0].Details)
}
}
func TestRedfishCollectionMemberRefs_IncludesOemPublicMembers(t *testing.T) {
collection := map[string]interface{}{
"Members": []interface{}{
@@ -725,6 +941,186 @@ func TestRedfishCollectionMemberRefs_IncludesOemPublicMembers(t *testing.T) {
}
}
func TestParseDriveAndPSU_CollectComponentMetricsIntoDetails(t *testing.T) {
drive := parseDrive(map[string]interface{}{
"Id": "Drive0",
"Model": "NVMe SSD",
"SerialNumber": "SSD-001",
"TemperatureCelsius": 38.5,
"PowerOnHours": 12450,
"UnsafeShutdowns": 3,
"PredictedMediaLifeLeftPercent": 91,
"Oem": map[string]interface{}{
"Public": map[string]interface{}{
"AvailableSparePercent": 87,
},
},
})
if drive.Details == nil {
t.Fatalf("expected drive details to be populated")
}
if got := drive.Details["temperature_c"]; got != 38.5 {
t.Fatalf("expected drive temperature detail 38.5, got %#v", got)
}
if got := drive.Details["power_on_hours"]; got != int64(12450) {
t.Fatalf("expected drive power_on_hours detail, got %#v", got)
}
if got := drive.Details["life_remaining_pct"]; got != 91.0 {
t.Fatalf("expected drive life_remaining_pct detail, got %#v", got)
}
if got := drive.Details["available_spare_pct"]; got != 87.0 {
t.Fatalf("expected drive available_spare_pct detail from Oem/Public, got %#v", got)
}
driveOEM := parseDrive(map[string]interface{}{
"Id": "Drive1",
"Model": "NVMe SSD 2",
"SerialNumber": "SSD-002",
"Oem": map[string]interface{}{
"Public": map[string]interface{}{
"temperature": 19,
"PercentAvailableSpare": 93,
"PercentageUsed": 7,
},
},
})
if driveOEM.Details == nil {
t.Fatalf("expected oem drive details to be populated")
}
if got := driveOEM.Details["temperature_c"]; got != 19.0 {
t.Fatalf("expected lowercase OEM drive temperature 19, got %#v", got)
}
if got := driveOEM.Details["available_spare_pct"]; got != 93.0 {
t.Fatalf("expected OEM available_spare_pct 93, got %#v", got)
}
if got := driveOEM.Details["life_used_pct"]; got != 7.0 {
t.Fatalf("expected OEM life_used_pct 7, got %#v", got)
}
psu := parsePSU(map[string]interface{}{
"MemberId": "PSU0",
"SerialNumber": "PSU-001",
"TemperatureCelsius": 41,
"Oem": map[string]interface{}{
"Public": map[string]interface{}{
"LifeRemainingPercent": 96,
},
},
}, 1)
if psu.Details == nil {
t.Fatalf("expected psu details to be populated")
}
if got := psu.Details["temperature_c"]; got != 41.0 {
t.Fatalf("expected psu temperature detail 41, got %#v", got)
}
if got := psu.Details["life_remaining_pct"]; got != 96.0 {
t.Fatalf("expected psu life_remaining_pct detail, got %#v", got)
}
}
func TestParseGPUPCIeAndNIC_CollectComponentMetricsIntoDetails(t *testing.T) {
functionDocs := []map[string]interface{}{
{
"FunctionId": "0000:17:00.0",
"VendorId": "0x10de",
"DeviceId": "0x2331",
"TemperatureCelsius": 48.5,
"PowerConsumedWatts": 315.0,
"ECCCorrectedTotal": 12,
"BatteryHealthPercent": 87,
"SFPTemperatureCelsius": 36.2,
},
}
gpu := parseGPU(map[string]interface{}{
"Id": "GPU0",
"Model": "NVIDIA H100",
"Manufacturer": "NVIDIA",
}, functionDocs, 1)
if gpu.Details == nil || gpu.Details["temperature_c"] != 48.5 || gpu.Details["power_w"] != 315.0 {
t.Fatalf("expected gpu details from function docs, got %#v", gpu.Details)
}
pcie := parsePCIeDevice(map[string]interface{}{
"Id": "NIC1",
}, []map[string]interface{}{
{
"FunctionId": "0000:18:00.0",
"VendorId": "0x15b3",
"DeviceId": "0x1021",
"SFPTXPowerDBm": -1.8,
"SFPRXPowerDBm": -2.1,
"SFPBiasMA": 5.5,
"BatteryReplaceRequired": true,
},
})
if pcie.Details == nil || pcie.Details["sfp_tx_power_dbm"] != -1.8 || pcie.Details["battery_replace_required"] != true {
t.Fatalf("expected pcie details from function docs, got %#v", pcie.Details)
}
nic := parseNIC(map[string]interface{}{"Id": "1"})
enrichNICFromPCIe(&nic, map[string]interface{}{}, []map[string]interface{}{
{
"FunctionId": "0000:19:00.0",
"SFPTemperatureCelsius": 34.0,
},
}, nil)
if nic.Details == nil || nic.Details["sfp_temperature_c"] != 34.0 {
t.Fatalf("expected nic details from linked pcie function, got %#v", nic.Details)
}
}
func TestParseComponentDetails_UseLinkedSupplementalMetrics(t *testing.T) {
drive := parseDriveWithSupplementalDocs(
map[string]interface{}{
"Id": "Drive0",
"SerialNumber": "SSD-001",
},
map[string]interface{}{
"PowerOnHours": 5001,
"MediaErrors": 2,
"TemperatureC": 39.5,
"LifeUsedPercent": 9,
},
)
if drive.Details == nil || drive.Details["power_on_hours"] != int64(5001) || drive.Details["temperature_c"] != 39.5 {
t.Fatalf("expected drive details from supplemental metrics, got %#v", drive.Details)
}
psu := parsePSUWithSupplementalDocs(
map[string]interface{}{
"MemberId": "PSU0",
"SerialNumber": "PSU-001",
},
1,
map[string]interface{}{
"Temperature": 44,
"LifeRemainingPercent": 97,
},
)
if psu.Details == nil || psu.Details["temperature_c"] != 44.0 || psu.Details["life_remaining_pct"] != 97.0 {
t.Fatalf("expected psu details from supplemental metrics, got %#v", psu.Details)
}
gpu := parseGPUWithSupplementalDocs(
map[string]interface{}{
"Id": "GPU0",
"Model": "NVIDIA H100",
"Manufacturer": "NVIDIA",
},
nil,
[]map[string]interface{}{
{
"PowerConsumptionWatts": 305.0,
"HWSlowdown": true,
},
},
1,
)
if gpu.Details == nil || gpu.Details["power_w"] != 305.0 || gpu.Details["hw_slowdown"] != true {
t.Fatalf("expected gpu details from supplemental metrics, got %#v", gpu.Details)
}
}
func TestRecoverCriticalRedfishDocsPlanB_RetriesMembersFromExistingCollection(t *testing.T) {
t.Setenv("LOGPILE_REDFISH_CRITICAL_COOLDOWN", "0s")
t.Setenv("LOGPILE_REDFISH_CRITICAL_SLOW_GAP", "0s")
@@ -1621,11 +2017,11 @@ func TestCollectGPUsFromProcessors_SupermicroHGX(t *testing.T) {
},
},
"/redfish/v1/Chassis/1/PCIeDevices/GPU1": map[string]interface{}{
"Id": "GPU1",
"Name": "GPU1",
"Model": "NVIDIA H200",
"Manufacturer": "NVIDIA",
"SerialNumber": "SN001",
"Id": "GPU1",
"Name": "GPU1",
"Model": "NVIDIA H200",
"Manufacturer": "NVIDIA",
"SerialNumber": "SN001",
"FirmwareVersion": "96.00.D9.00.02",
"PCIeFunctions": map[string]interface{}{
"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/GPU1/PCIeFunctions",
@@ -1810,15 +2206,18 @@ func TestLooksLikeGPU_NVSwitchExcluded(t *testing.T) {
}
}
func TestShouldCrawlPath_MemorySubresourcesAreSkipped(t *testing.T) {
func TestShouldCrawlPath_MemoryAndProcessorMetricsAreAllowed(t *testing.T) {
if !shouldCrawlPath("/redfish/v1/Systems/1/Memory/CPU0_C0D0") {
t.Fatalf("expected direct DIMM resource to be crawlable")
}
if shouldCrawlPath("/redfish/v1/Systems/1/Memory/CPU0_C0D0/Assembly") {
t.Fatalf("expected DIMM assembly subresource to be skipped")
}
if shouldCrawlPath("/redfish/v1/Systems/1/Memory/CPU0_C0D0/MemoryMetrics") {
t.Fatalf("expected DIMM metrics subresource to be skipped")
if !shouldCrawlPath("/redfish/v1/Systems/1/Memory/CPU0_C0D0/MemoryMetrics") {
t.Fatalf("expected DIMM metrics subresource to be crawlable")
}
if !shouldCrawlPath("/redfish/v1/Systems/1/Processors/CPU0/ProcessorMetrics") {
t.Fatalf("expected CPU metrics subresource to be crawlable")
}
if shouldCrawlPath("/redfish/v1/Chassis/1/PCIeDevices/0/PCIeFunctions/1") {
t.Fatalf("expected noisy chassis pciefunctions branch to be skipped")

View File

@@ -66,104 +66,15 @@ func (e *Exporter) ExportCSV(w io.Writer) error {
}
}
// CPUs
for _, cpu := range e.result.Hardware.CPUs {
if !hasUsableSerial(cpu.SerialNumber) {
seenCanonical := make(map[string]struct{})
for _, dev := range canonicalDevicesForExport(e.result.Hardware) {
if !hasUsableSerial(dev.SerialNumber) {
continue
}
if err := writer.Write([]string{
cpu.Model,
strings.TrimSpace(cpu.SerialNumber),
"",
"CPU",
}); err != nil {
return err
}
}
// Memory
for _, mem := range e.result.Hardware.Memory {
if !hasUsableSerial(mem.SerialNumber) {
continue
}
location := mem.Location
if location == "" {
location = mem.Slot
}
if err := writer.Write([]string{
mem.PartNumber,
strings.TrimSpace(mem.SerialNumber),
mem.Manufacturer,
location,
}); err != nil {
return err
}
}
// Storage
for _, stor := range e.result.Hardware.Storage {
if !hasUsableSerial(stor.SerialNumber) {
continue
}
if err := writer.Write([]string{
stor.Model,
strings.TrimSpace(stor.SerialNumber),
stor.Manufacturer,
stor.Slot,
}); err != nil {
return err
}
}
// GPUs
for _, gpu := range e.result.Hardware.GPUs {
if !hasUsableSerial(gpu.SerialNumber) {
continue
}
component := gpu.Model
if component == "" {
component = "GPU"
}
if err := writer.Write([]string{
component,
strings.TrimSpace(gpu.SerialNumber),
gpu.Manufacturer,
gpu.Slot,
}); err != nil {
return err
}
}
// PCIe devices
for _, pcie := range e.result.Hardware.PCIeDevices {
if !hasUsableSerial(pcie.SerialNumber) {
continue
}
if err := writer.Write([]string{
pcie.DeviceClass,
strings.TrimSpace(pcie.SerialNumber),
pcie.Manufacturer,
pcie.Slot,
}); err != nil {
return err
}
}
// Network adapters
for _, nic := range e.result.Hardware.NetworkAdapters {
if !hasUsableSerial(nic.SerialNumber) {
continue
}
location := nic.Location
if location == "" {
location = nic.Slot
}
if err := writer.Write([]string{
nic.Model,
strings.TrimSpace(nic.SerialNumber),
nic.Vendor,
location,
}); err != nil {
serial := strings.TrimSpace(dev.SerialNumber)
seenCanonical[serial] = struct{}{}
component, manufacturer, location := csvFieldsFromCanonicalDevice(dev)
if err := writer.Write([]string{component, serial, manufacturer, location}); err != nil {
return err
}
}
@@ -173,26 +84,15 @@ func (e *Exporter) ExportCSV(w io.Writer) error {
if !hasUsableSerial(nic.SerialNumber) {
continue
}
if err := writer.Write([]string{
nic.Model,
strings.TrimSpace(nic.SerialNumber),
"",
"Network",
}); err != nil {
return err
}
}
// Power supplies
for _, psu := range e.result.Hardware.PowerSupply {
if !hasUsableSerial(psu.SerialNumber) {
serial := strings.TrimSpace(nic.SerialNumber)
if _, ok := seenCanonical[serial]; ok {
continue
}
if err := writer.Write([]string{
psu.Model,
strings.TrimSpace(psu.SerialNumber),
psu.Vendor,
psu.Slot,
nic.Model,
serial,
"",
"Network",
}); err != nil {
return err
}
@@ -221,3 +121,52 @@ func hasUsableSerial(serial string) bool {
return true
}
}
func csvFieldsFromCanonicalDevice(dev models.HardwareDevice) (component, manufacturer, location string) {
component = firstNonEmptyString(
dev.Model,
dev.PartNumber,
dev.DeviceClass,
dev.Kind,
)
manufacturer = firstNonEmptyString(dev.Manufacturer, inferCSVVendor(dev))
location = firstNonEmptyString(dev.Location, dev.Slot, dev.BDF, dev.Kind)
switch dev.Kind {
case models.DeviceKindCPU:
if component == "" {
component = "CPU"
}
if location == "" {
location = "CPU"
}
case models.DeviceKindMemory:
component = firstNonEmptyString(dev.PartNumber, dev.Model, "Memory")
case models.DeviceKindPCIe, models.DeviceKindGPU, models.DeviceKindNetwork:
if location == "" {
location = firstNonEmptyString(dev.Slot, dev.BDF, "PCIe")
}
case models.DeviceKindPSU:
component = firstNonEmptyString(dev.Model, "Power Supply")
}
return component, manufacturer, location
}
func inferCSVVendor(dev models.HardwareDevice) string {
switch dev.Kind {
case models.DeviceKindCPU:
return ""
default:
return ""
}
}
func firstNonEmptyString(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}

File diff suppressed because it is too large Load Diff

View File

@@ -210,7 +210,7 @@ func TestConvertCPUs(t *testing.T) {
},
}
result := convertCPUs(cpus, "2026-02-10T15:30:00Z")
result := convertCPUs(cpus, "2026-02-10T15:30:00Z", "BOARD-001")
if len(result) != 2 {
t.Fatalf("expected 2 CPUs, got %d", len(result))
@@ -227,6 +227,9 @@ func TestConvertCPUs(t *testing.T) {
if result[0].Status != "Unknown" {
t.Errorf("expected Unknown status, got %q", result[0].Status)
}
if result[0].SerialNumber != "BOARD-001-CPU-0" {
t.Errorf("expected generated CPU serial, got %q", result[0].SerialNumber)
}
}
func TestConvertMemory(t *testing.T) {
@@ -247,17 +250,13 @@ func TestConvertMemory(t *testing.T) {
result := convertMemory(memory, "2026-02-10T15:30:00Z")
if len(result) != 2 {
t.Fatalf("expected 2 memory modules, got %d", len(result))
if len(result) != 1 {
t.Fatalf("expected 1 populated memory module, got %d", len(result))
}
if result[0].Status != "OK" {
t.Errorf("expected OK status for first module, got %q", result[0].Status)
}
if result[1].Status != "Empty" {
t.Errorf("expected Empty status for second module, got %q", result[1].Status)
}
}
func TestConvertStorage(t *testing.T) {
@@ -289,6 +288,48 @@ func TestConvertStorage(t *testing.T) {
}
}
func TestConvertStorage_RemainingEndurance(t *testing.T) {
pct100 := 100
pct3 := 3
storage := []models.Storage{
{
Slot: "0",
Model: "HFS480G3H2X069N",
SerialNumber: "ESEAN5254I030B26B",
Present: true,
RemainingEndurancePct: &pct100,
},
{
Slot: "1",
Model: "HFS480G3H2X069N",
SerialNumber: "ESEAN5254I030B26C",
Present: true,
// no endurance data
},
{
Slot: "2",
Model: "HFS480G3H2X069N",
SerialNumber: "ESEAN5254I030B26D",
Present: true,
RemainingEndurancePct: &pct3,
},
}
result := convertStorage(storage, "2026-03-15T00:00:00Z")
if len(result) != 3 {
t.Fatalf("expected 3 results, got %d", len(result))
}
if result[0].RemainingEndurancePct == nil || *result[0].RemainingEndurancePct != 100 {
t.Errorf("slot 0: expected remaining_endurance_pct=100, got %v", result[0].RemainingEndurancePct)
}
if result[1].RemainingEndurancePct != nil {
t.Errorf("slot 1: expected remaining_endurance_pct absent, got %v", *result[1].RemainingEndurancePct)
}
if result[2].RemainingEndurancePct == nil || *result[2].RemainingEndurancePct != 3 {
t.Errorf("slot 2: expected remaining_endurance_pct=3, got %v", result[2].RemainingEndurancePct)
}
}
func TestConvertPCIeDevices(t *testing.T) {
hw := &models.HardwareConfig{
PCIeDevices: []models.PCIeDevice{
@@ -329,16 +370,16 @@ func TestConvertPCIeDevices(t *testing.T) {
},
}
result := convertPCIeDevices(hw, "2026-02-10T15:30:00Z")
result := convertPCIeDevices(hw, "2026-02-10T15:30:00Z", "BOARD-001")
// Should have: 2 PCIe devices + 1 GPU + 1 NIC = 4 total
if len(result) != 4 {
t.Fatalf("expected 4 PCIe devices total, got %d", len(result))
}
// Check that serial is empty for second PCIe device (no auto-generation)
if result[1].SerialNumber != "" {
t.Errorf("expected empty serial for missing device serial, got %q", result[1].SerialNumber)
// Check that serial is generated for second PCIe device
if result[1].SerialNumber != "BOARD-001-PCIE-PCIeCard2" {
t.Errorf("expected generated serial for missing device serial, got %q", result[1].SerialNumber)
}
// Check GPU was included
@@ -346,8 +387,8 @@ func TestConvertPCIeDevices(t *testing.T) {
for _, dev := range result {
if dev.SerialNumber == "GPU-001" {
foundGPU = true
if dev.DeviceClass != "DisplayController" {
t.Errorf("expected GPU device_class DisplayController, got %q", dev.DeviceClass)
if dev.DeviceClass != "VideoController" {
t.Errorf("expected GPU device_class VideoController, got %q", dev.DeviceClass)
}
break
}
@@ -375,14 +416,14 @@ func TestConvertPCIeDevices_NVSwitchWithoutSerialRemainsEmpty(t *testing.T) {
},
}
result := convertPCIeDevices(hw, "2026-02-10T15:30:00Z")
result := convertPCIeDevices(hw, "2026-02-10T15:30:00Z", "BOARD-001")
if len(result) != 1 {
t.Fatalf("expected 1 PCIe device, got %d", len(result))
}
if result[0].SerialNumber != "" {
t.Fatalf("expected empty NVSwitch serial, got %q", result[0].SerialNumber)
if result[0].SerialNumber != "BOARD-001-PCIE-NVSWITCH1" {
t.Fatalf("expected generated NVSwitch serial, got %q", result[0].SerialNumber)
}
if result[0].Firmware != "96.10.6D.00.01" {
t.Fatalf("expected NVSwitch firmware 96.10.6D.00.01, got %q", result[0].Firmware)
@@ -408,12 +449,12 @@ func TestConvertPCIeDevices_SkipsDisplayControllerDuplicates(t *testing.T) {
},
}
result := convertPCIeDevices(hw, "2026-02-10T15:30:00Z")
result := convertPCIeDevices(hw, "2026-02-10T15:30:00Z", "BOARD-001")
if len(result) != 1 {
t.Fatalf("expected only dedicated GPU record without duplicate display PCIe, got %d", len(result))
}
if result[0].DeviceClass != "DisplayController" {
t.Fatalf("expected GPU record with DisplayController class, got %q", result[0].DeviceClass)
if result[0].DeviceClass != "VideoController" {
t.Fatalf("expected GPU record with VideoController class, got %q", result[0].DeviceClass)
}
if result[0].Status != "OK" {
t.Fatalf("expected GPU status OK, got %q", result[0].Status)
@@ -441,7 +482,7 @@ func TestConvertPCIeDevices_MapsGPUStatusHistory(t *testing.T) {
},
}
result := convertPCIeDevices(hw, "2026-02-10T15:30:00Z")
result := convertPCIeDevices(hw, "2026-02-10T15:30:00Z", "BOARD-001")
if len(result) != 1 {
t.Fatalf("expected 1 converted GPU, got %d", len(result))
}
@@ -452,9 +493,6 @@ func TestConvertPCIeDevices_MapsGPUStatusHistory(t *testing.T) {
if result[0].StatusHistory[0].ChangedAt != "2026-01-12T15:05:18Z" {
t.Fatalf("unexpected history changed_at: %q", result[0].StatusHistory[0].ChangedAt)
}
if result[0].StatusAtCollect == nil || result[0].StatusAtCollect.At != "2026-02-10T15:30:00Z" {
t.Fatalf("expected status_at_collection to be populated from collected_at")
}
}
func TestConvertPowerSupplies(t *testing.T) {
@@ -518,8 +556,8 @@ func TestSourceTypeOmittedWhenInvalidOrEmpty(t *testing.T) {
if err != nil {
t.Fatalf("marshal failed: %v", err)
}
if strings.Contains(string(payload), `"source_type"`) {
t.Fatalf("expected source_type to be omitted for invalid value, got %s", string(payload))
if !strings.Contains(string(payload), `"source_type":"logfile"`) {
t.Fatalf("expected archive source_type to map to logfile, got %s", string(payload))
}
}
@@ -688,9 +726,6 @@ func TestConvertToReanimator_StatusFallbackUsesCollectedAt(t *testing.T) {
if got.StatusCheckedAt != wantTs {
t.Fatalf("expected status_checked_at=%q, got %q", wantTs, got.StatusCheckedAt)
}
if got.StatusAtCollect == nil || got.StatusAtCollect.At != wantTs {
t.Fatalf("expected status_at_collection.at=%q, got %#v", wantTs, got.StatusAtCollect)
}
}
func TestConvertToReanimator_FirmwareExcludesDeviceBoundEntries(t *testing.T) {
@@ -698,6 +733,9 @@ func TestConvertToReanimator_FirmwareExcludesDeviceBoundEntries(t *testing.T) {
Filename: "fw-filter-test.json",
Hardware: &models.HardwareConfig{
BoardInfo: models.BoardInfo{SerialNumber: "BOARD-001"},
CPUs: []models.CPU{
{Socket: 0, Model: "Intel Xeon Gold"},
},
Firmware: []models.FirmwareInfo{
{DeviceName: "BIOS", Version: "1.0.0"},
{DeviceName: "BMC", Version: "2.0.0"},
@@ -735,6 +773,58 @@ func TestConvertToReanimator_FirmwareExcludesDeviceBoundEntries(t *testing.T) {
if _, exists := got["NVSwitch NVSWITCH0 (965-25612-0002-000)"]; exists {
t.Fatalf("expected NVSwitch firmware to be excluded from hardware.firmware")
}
if len(out.Hardware.CPUs) != 1 {
t.Fatalf("expected 1 CPU entry, got %d", len(out.Hardware.CPUs))
}
if out.Hardware.CPUs[0].Firmware != "0x2b000643" {
t.Fatalf("expected CPU firmware field to carry microcode, got %q", out.Hardware.CPUs[0].Firmware)
}
}
// TestConvertToReanimator_FirmwareExcludesDellFQDDEntries verifies that Dell TSR
// SoftwareIdentity firmware entries whose Description contains a device-bound FQDD
// (InfiniBand.Slot.*, RAID.SL.*, etc.) are filtered from hardware.firmware.
//
// Regression guard: PowerEdge R6625 (8VS2LG4) — "Mellanox Network Adapter" (FQDD
// InfiniBand.Slot.1-1) and "PERC H755 Front" (FQDD RAID.SL.3-1) leaked into
// hardware.firmware. (2026-03-15)
func TestConvertToReanimator_FirmwareExcludesDellFQDDEntries(t *testing.T) {
input := &models.AnalysisResult{
Filename: "dell-fw-filter-test.json",
Hardware: &models.HardwareConfig{
BoardInfo: models.BoardInfo{SerialNumber: "8VS2LG4"},
Firmware: []models.FirmwareInfo{
// system-level — must be kept
{DeviceName: "BIOS", Version: "1.15.3", Description: "system bios"},
{DeviceName: "iDRAC", Version: "7.20.80.50", Description: "idrac card"},
{DeviceName: "Lifecycle Controller", Version: "7.20.80.50", Description: "idrac lifecycle"},
// device-bound via FQDD — must be filtered
{DeviceName: "Mellanox Network Adapter", Version: "20.39.35.60", Description: "InfiniBand.Slot.1-1"},
{DeviceName: "PERC H755 Front", Version: "52.30.0-6115", Description: "RAID.SL.3-1"},
},
},
}
out, err := ConvertToReanimator(input)
if err != nil {
t.Fatalf("ConvertToReanimator() failed: %v", err)
}
got := make(map[string]string, len(out.Hardware.Firmware))
for _, fw := range out.Hardware.Firmware {
got[fw.DeviceName] = fw.Version
}
for _, keep := range []string{"BIOS", "iDRAC", "Lifecycle Controller"} {
if _, ok := got[keep]; !ok {
t.Errorf("expected %q in hardware.firmware, but it was missing", keep)
}
}
for _, drop := range []string{"Mellanox Network Adapter", "PERC H755 Front"} {
if _, ok := got[drop]; ok {
t.Errorf("%q must not appear in hardware.firmware (device-bound FQDD)", drop)
}
}
}
func TestConvertToReanimator_UsesCanonicalDevices(t *testing.T) {
@@ -774,9 +864,65 @@ func TestConvertToReanimator_UsesCanonicalDevices(t *testing.T) {
}
}
func TestConvertToReanimator_BindsDeviceVitals(t *testing.T) {
func TestConvertToReanimator_MergesCanonicalAndLegacyDevices(t *testing.T) {
input := &models.AnalysisResult{
Filename: "merged.json",
Hardware: &models.HardwareConfig{
BoardInfo: models.BoardInfo{SerialNumber: "BOARD-001"},
Devices: []models.HardwareDevice{
{
Kind: models.DeviceKindPCIe,
Slot: "PCIe 3",
Model: "RAID Controller",
DeviceClass: "raid_controller",
Status: "ok",
},
},
CPUs: []models.CPU{
{Socket: 0, Model: "Xeon Platinum", SerialNumber: "CPU-001"},
},
Memory: []models.MemoryDIMM{
{Slot: "DIMM0", Location: "DIMM0", Present: true, SizeMB: 32768, Type: "DDR5", SerialNumber: "MEM-001"},
},
Storage: []models.Storage{
{Slot: "U.2-1", Type: "NVMe", Model: "Drive1", SerialNumber: "SSD-001", Present: true},
},
PowerSupply: []models.PSU{
{Slot: "PSU0", Model: "PSU", SerialNumber: "PSU-001", Present: true},
},
},
}
out, err := ConvertToReanimator(input)
if err != nil {
t.Fatalf("ConvertToReanimator() failed: %v", err)
}
if len(out.Hardware.CPUs) != 1 {
t.Fatalf("expected cpu from legacy inventory to survive canonical merge, got %d", len(out.Hardware.CPUs))
}
if len(out.Hardware.Memory) != 1 {
t.Fatalf("expected memory from legacy inventory to survive canonical merge, got %d", len(out.Hardware.Memory))
}
if len(out.Hardware.Storage) != 1 {
t.Fatalf("expected storage from legacy inventory to survive canonical merge, got %d", len(out.Hardware.Storage))
}
if len(out.Hardware.PowerSupplies) != 1 {
t.Fatalf("expected psu from legacy inventory to survive canonical merge, got %d", len(out.Hardware.PowerSupplies))
}
if len(out.Hardware.PCIeDevices) != 1 {
t.Fatalf("expected supplemental canonical pcie device to remain present, got %d", len(out.Hardware.PCIeDevices))
}
}
func TestConvertToReanimator_ExportsSensorsAndPSUTelemetry(t *testing.T) {
input := &models.AnalysisResult{
Filename: "vitals.json",
Sensors: []models.SensorReading{
{Name: "FAN1", Type: "fan", Value: 4200, Unit: "RPM", Status: "OK"},
{Name: "12V Rail", Type: "voltage", Value: 12.1, Unit: "V", Status: "OK"},
{Name: "CPU0 Temp", Type: "temperature", Value: 71, Unit: "C", Status: "Warning"},
{Name: "Humidity", Type: "humidity", Value: 38.5, Unit: "%", Status: "OK"},
},
Hardware: &models.HardwareConfig{
BoardInfo: models.BoardInfo{SerialNumber: "BOARD-001"},
Devices: []models.HardwareDevice{
@@ -786,11 +932,6 @@ func TestConvertToReanimator_BindsDeviceVitals(t *testing.T) {
Model: "B200 180GB HBM3e",
SerialNumber: "GPU-001",
BDF: "0000:17:00.0",
Details: map[string]any{
"temperature": 71,
"power": 350,
"voltage": 12.2,
},
},
{
Kind: models.DeviceKindPSU,
@@ -815,26 +956,38 @@ func TestConvertToReanimator_BindsDeviceVitals(t *testing.T) {
t.Fatalf("expected one pcie device, got %d", len(out.Hardware.PCIeDevices))
}
pcie := out.Hardware.PCIeDevices[0]
if pcie.TemperatureC != 71 {
t.Fatalf("expected GPU temperature 71C, got %d", pcie.TemperatureC)
}
if pcie.PowerW != 350 {
t.Fatalf("expected GPU power 350W, got %d", pcie.PowerW)
}
if pcie.VoltageV != 12.2 {
t.Fatalf("expected device voltage 12.2V, got %.2f", pcie.VoltageV)
if pcie.TemperatureC != 0 {
t.Fatalf("expected canonical GPU telemetry to stay off the component unless sourced from details/gpu path, got %.2f", pcie.TemperatureC)
}
if len(out.Hardware.PowerSupplies) != 1 {
t.Fatalf("expected one PSU, got %d", len(out.Hardware.PowerSupplies))
}
psu := out.Hardware.PowerSupplies[0]
if psu.InputPowerW != 1400 {
t.Fatalf("expected PSU input power 1400W, got %.2f", psu.InputPowerW)
}
if psu.TemperatureC != 44 {
t.Fatalf("expected PSU temperature 44C, got %d", psu.TemperatureC)
t.Fatalf("expected PSU temperature 44C, got %.2f", psu.TemperatureC)
}
if out.Hardware.Sensors == nil {
t.Fatalf("expected sensors section")
}
if len(out.Hardware.Sensors.Fans) != 1 || out.Hardware.Sensors.Fans[0].RPM != 4200 {
t.Fatalf("expected fan sensor export, got %#v", out.Hardware.Sensors.Fans)
}
if len(out.Hardware.Sensors.Power) != 1 || out.Hardware.Sensors.Power[0].VoltageV != 12.1 {
t.Fatalf("expected power sensor export, got %#v", out.Hardware.Sensors.Power)
}
if len(out.Hardware.Sensors.Temperatures) != 1 || out.Hardware.Sensors.Temperatures[0].Celsius != 71 {
t.Fatalf("expected temperature sensor export, got %#v", out.Hardware.Sensors.Temperatures)
}
if len(out.Hardware.Sensors.Other) != 1 || out.Hardware.Sensors.Other[0].Unit != "%" {
t.Fatalf("expected other sensor export, got %#v", out.Hardware.Sensors.Other)
}
}
func TestConvertToReanimator_PreservesVitalsAcrossCanonicalDedup(t *testing.T) {
func TestConvertToReanimator_PreservesCanonicalDedupWithoutDeviceVitals(t *testing.T) {
input := &models.AnalysisResult{
Filename: "dedup-vitals.json",
Hardware: &models.HardwareConfig{
@@ -872,11 +1025,283 @@ func TestConvertToReanimator_PreservesVitalsAcrossCanonicalDedup(t *testing.T) {
t.Fatalf("expected deduped one pcie entry, got %d", len(out.Hardware.PCIeDevices))
}
got := out.Hardware.PCIeDevices[0]
if got.DeviceClass != "VideoController" {
t.Fatalf("expected GPU to export as VideoController, got %q", got.DeviceClass)
}
if got.TemperatureC != 67 {
t.Fatalf("expected deduped GPU temperature 67C, got %d", got.TemperatureC)
t.Fatalf("expected deduped GPU temperature 67C, got %.2f", got.TemperatureC)
}
if got.PowerW != 330 {
t.Fatalf("expected deduped GPU power 330W, got %d", got.PowerW)
t.Fatalf("expected deduped GPU power 330W, got %.2f", got.PowerW)
}
}
func TestConvertToReanimator_DedupesLooseCanonicalNICAndPCIeEntries(t *testing.T) {
input := &models.AnalysisResult{
Filename: "loose-dedup.json",
Hardware: &models.HardwareConfig{
BoardInfo: models.BoardInfo{SerialNumber: "BOARD-001"},
PCIeDevices: []models.PCIeDevice{
{
Slot: "Slot 4",
DeviceClass: "NetworkController",
VendorID: 0x15b3,
DeviceID: 0x1021,
Manufacturer: "Mellanox",
PartNumber: "MCX623106AC-CDAT",
},
},
NetworkAdapters: []models.NetworkAdapter{
{
Slot: "Slot 4",
Model: "ConnectX-6",
VendorID: 0x15b3,
DeviceID: 0x1021,
Vendor: "Mellanox",
MACAddresses: []string{"00:11:22:33:44:55"},
PortCount: 2,
Present: true,
},
},
},
}
out, err := ConvertToReanimator(input)
if err != nil {
t.Fatalf("ConvertToReanimator() failed: %v", err)
}
if len(out.Hardware.PCIeDevices) != 1 {
t.Fatalf("expected one merged loose-key pcie entry, got %d", len(out.Hardware.PCIeDevices))
}
got := out.Hardware.PCIeDevices[0]
if got.Model == "" {
t.Fatalf("expected merged pcie entry to retain a model, got empty")
}
if len(got.MACAddresses) != 1 || got.MACAddresses[0] != "00:11:22:33:44:55" {
t.Fatalf("expected MACs from NIC side after loose merge, got %#v", got.MACAddresses)
}
}
func TestConvertToReanimator_ExportsContractV24Telemetry(t *testing.T) {
input := &models.AnalysisResult{
Filename: "contract-v24.json",
Hardware: &models.HardwareConfig{
BoardInfo: models.BoardInfo{SerialNumber: "BOARD-001"},
Devices: []models.HardwareDevice{
{
Kind: models.DeviceKindCPU,
Slot: "CPU0",
Model: "INTEL(R) XEON(R) GOLD 6530",
Details: map[string]any{
"socket": 0,
"temperature_c": 61.5,
"power_w": 182.0,
"throttled": false,
"correctable_error_count": int64(4),
"uncorrectable_error_count": int64(1),
"life_remaining_pct": 98.5,
"life_used_pct": 1.5,
},
},
{
Kind: models.DeviceKindMemory,
Slot: "DIMM_A1",
SerialNumber: "MEM-001",
Present: boolPtr(true),
SizeMB: 32768,
Type: "DDR5",
Details: map[string]any{
"temperature_c": 43.0,
"correctable_ecc_error_count": int64(2),
"uncorrectable_ecc_error_count": int64(0),
"life_remaining_pct": 99.0,
"spare_blocks_remaining_pct": 97.0,
"performance_degraded": false,
},
},
{
Kind: models.DeviceKindStorage,
Slot: "U.2-1",
SerialNumber: "SSD-001",
Model: "PM9A3",
Present: boolPtr(true),
Details: map[string]any{
"temperature_c": 38.5,
"power_on_hours": int64(12450),
"unsafe_shutdowns": int64(3),
"written_bytes": int64(9876543210),
"life_remaining_pct": 91.0,
"available_spare_pct": 88.0,
"offline_uncorrectable": int64(0),
},
},
{
Kind: models.DeviceKindPCIe,
Slot: "PCIeCard2",
SerialNumber: "NIC-001",
DeviceClass: "EthernetController",
NUMANode: 1,
Details: map[string]any{
"temperature_c": 48.5,
"power_w": 18.2,
"life_remaining_pct": 95.0,
"ecc_corrected_total": int64(12),
"battery_health_pct": 87.0,
"sfp_temperature_c": 36.2,
"sfp_tx_power_dbm": -1.8,
"sfp_rx_power_dbm": -2.1,
"sfp_bias_ma": 5.5,
},
},
{
Kind: models.DeviceKindPSU,
Slot: "PSU0",
SerialNumber: "PSU-001",
Present: boolPtr(true),
Details: map[string]any{
"life_remaining_pct": 97.0,
"life_used_pct": 3.0,
},
TemperatureC: 39,
},
},
},
}
out, err := ConvertToReanimator(input)
if err != nil {
t.Fatalf("ConvertToReanimator() failed: %v", err)
}
if got := out.Hardware.CPUs[0]; got.TemperatureC != 61.5 || got.PowerW != 182.0 || got.Throttled == nil || *got.Throttled {
t.Fatalf("unexpected CPU telemetry: %#v", got)
}
if got := out.Hardware.Memory[0]; got.TemperatureC != 43.0 || got.CorrectableECCErrorCount != 2 || got.PerformanceDegraded == nil || *got.PerformanceDegraded {
t.Fatalf("unexpected memory telemetry: %#v", got)
}
if got := out.Hardware.Storage[0]; got.TemperatureC != 38.5 || got.PowerOnHours != 12450 || got.LifeRemainingPct != 91.0 {
t.Fatalf("unexpected storage telemetry: %#v", got)
}
if got := out.Hardware.PCIeDevices[0]; got.NUMANode != 1 || got.TemperatureC != 48.5 || got.PowerW != 18.2 || got.SFPTemperatureC != 36.2 {
t.Fatalf("unexpected PCIe telemetry: %#v", got)
}
if got := out.Hardware.PowerSupplies[0]; got.TemperatureC != 39 || got.LifeRemainingPct != 97.0 || got.LifeUsedPct != 3.0 {
t.Fatalf("unexpected PSU telemetry: %#v", got)
}
}
func TestConvertToReanimator_PreservesLegacyStorageAndPSUDetails(t *testing.T) {
input := &models.AnalysisResult{
Filename: "legacy-details.json",
Hardware: &models.HardwareConfig{
BoardInfo: models.BoardInfo{SerialNumber: "BOARD-001"},
Storage: []models.Storage{
{
Slot: "Drive0",
Type: "NVMe",
Model: "NVMe SSD",
SerialNumber: "SSD-001",
Present: true,
Details: map[string]any{
"temperature_c": 38.5,
"power_on_hours": int64(12450),
"life_remaining_pct": 91.0,
},
},
},
PowerSupply: []models.PSU{
{
Slot: "PSU0",
Model: "PSU",
SerialNumber: "PSU-001",
Present: true,
Details: map[string]any{
"temperature_c": 41.0,
"life_remaining_pct": 96.0,
},
},
},
},
}
out, err := ConvertToReanimator(input)
if err != nil {
t.Fatalf("ConvertToReanimator() failed: %v", err)
}
if got := out.Hardware.Storage[0]; got.TemperatureC != 38.5 || got.PowerOnHours != 12450 || got.LifeRemainingPct != 91.0 {
t.Fatalf("expected storage details from legacy model to survive canonical conversion, got %+v", got)
}
if got := out.Hardware.PowerSupplies[0]; got.TemperatureC != 41.0 || got.LifeRemainingPct != 96.0 {
t.Fatalf("expected psu details from legacy model to survive canonical conversion, got %+v", got)
}
}
func TestConvertToReanimator_PreservesLegacyPCIeAndNICDetails(t *testing.T) {
input := &models.AnalysisResult{
Filename: "legacy-pcie-details.json",
Hardware: &models.HardwareConfig{
BoardInfo: models.BoardInfo{SerialNumber: "BOARD-001"},
PCIeDevices: []models.PCIeDevice{
{
Slot: "PCIe 1",
BDF: "0000:17:00.0",
VendorID: 0x10de,
DeviceID: 0x2331,
PartNumber: "H100",
Manufacturer: "NVIDIA",
SerialNumber: "GPU-001",
Details: map[string]any{
"temperature_c": 48.5,
"power_w": 315.0,
"ecc_corrected_total": int64(12),
"battery_health_pct": 87.0,
},
},
},
NetworkAdapters: []models.NetworkAdapter{
{
Slot: "Slot 4",
BDF: "0000:18:00.0",
VendorID: 0x15b3,
DeviceID: 0x1021,
Model: "ConnectX-6",
SerialNumber: "NIC-001",
Present: true,
Details: map[string]any{
"sfp_temperature_c": 34.0,
"sfp_tx_power_dbm": -1.8,
"sfp_rx_power_dbm": -2.1,
},
},
},
},
}
out, err := ConvertToReanimator(input)
if err != nil {
t.Fatalf("ConvertToReanimator() failed: %v", err)
}
if len(out.Hardware.PCIeDevices) != 2 {
t.Fatalf("expected two pcie-class devices, got %d", len(out.Hardware.PCIeDevices))
}
foundGPU := false
foundNIC := false
for _, dev := range out.Hardware.PCIeDevices {
switch dev.SerialNumber {
case "GPU-001":
foundGPU = true
if dev.TemperatureC != 48.5 || dev.PowerW != 315.0 || dev.ECCCorrectedTotal != 12 || dev.BatteryHealthPct != 87.0 {
t.Fatalf("expected GPU telemetry preserved, got %+v", dev)
}
case "NIC-001":
foundNIC = true
if dev.SFPTemperatureC != 34.0 || dev.SFPTXPowerDBm != -1.8 || dev.SFPRXPowerDBm != -2.1 {
t.Fatalf("expected NIC sfp telemetry preserved, got %+v", dev)
}
}
}
if !foundGPU || !foundNIC {
t.Fatalf("expected both gpu and nic pcie-class exports, got %+v", out.Hardware.PCIeDevices)
}
}
@@ -932,3 +1357,42 @@ func TestIsDeviceBoundFirmwareName(t *testing.T) {
}
}
}
// TestIsDeviceBoundFirmwareFQDD verifies that Dell TSR SoftwareIdentity FQDD strings
// (stored in FirmwareInfo.Description) correctly identify device-bound entries.
//
// Regression guard: "InfiniBand.Slot.1-1" (Mellanox ConnectX-6) and "RAID.SL.3-1"
// (PERC H755 Front) were not filtered because only "raid.backplane." was listed and
// "infiniband." was absent. Both firmware entries leaked into hardware.firmware on
// PowerEdge R6625 (8VS2LG4). (2026-03-15)
func TestIsDeviceBoundFirmwareFQDD(t *testing.T) {
cases := []struct {
desc string
want bool
}{
// Dell TSR SoftwareIdentity FQDDs — device-bound, must be excluded
{"InfiniBand.Slot.1-1", true}, // Mellanox ConnectX-6
{"InfiniBand.Slot.2-1", true}, // any InfiniBand slot
{"RAID.SL.3-1", true}, // PERC H755 Front
{"RAID.Integrated.1-1", true}, // embedded RAID controller
{"RAID.Backplane.Firmware.0", true}, // backplane (previously covered)
{"NIC.Integrated.1-1-1", true}, // embedded NIC
{"NIC.Slot.1-1-1", true}, // slotted NIC
{"PSU.Slot.1", true}, // PSU
{"Disk.Bay.0:Enclosure.Internal.0-1:RAID.SL.3-1", true},
{"GPU.Slot.1-1", true},
{"FC.Slot.1-1", true}, // Fibre Channel HBA
// System-level descriptions — must NOT be excluded
{"system bios", false},
{"idrac lifecycle", false},
{"idrac card", false},
{"storage controller", false}, // legacy description before fqdd fix
{"", false},
}
for _, tc := range cases {
got := isDeviceBoundFirmwareFQDD(tc.desc)
if got != tc.want {
t.Errorf("isDeviceBoundFirmwareFQDD(%q) = %v, want %v", tc.desc, got, tc.want)
}
}
}

View File

@@ -19,6 +19,7 @@ type ReanimatorHardware struct {
Storage []ReanimatorStorage `json:"storage,omitempty"`
PCIeDevices []ReanimatorPCIe `json:"pcie_devices,omitempty"`
PowerSupplies []ReanimatorPSU `json:"power_supplies,omitempty"`
Sensors *ReanimatorSensors `json:"sensors,omitempty"`
}
// ReanimatorBoard represents motherboard/server information
@@ -36,11 +37,6 @@ type ReanimatorFirmware struct {
Version string `json:"version"`
}
type ReanimatorStatusAtCollection struct {
Status string `json:"status"`
At string `json:"at"`
}
type ReanimatorStatusHistoryEntry struct {
Status string `json:"status"`
ChangedAt string `json:"changed_at"`
@@ -49,90 +45,136 @@ type ReanimatorStatusHistoryEntry struct {
// ReanimatorCPU represents processor information
type ReanimatorCPU struct {
Socket int `json:"socket"`
Model string `json:"model"`
Cores int `json:"cores,omitempty"`
Threads int `json:"threads,omitempty"`
FrequencyMHz int `json:"frequency_mhz,omitempty"`
MaxFrequencyMHz int `json:"max_frequency_mhz,omitempty"`
Manufacturer string `json:"manufacturer,omitempty"`
Status string `json:"status,omitempty"`
StatusCheckedAt string `json:"status_checked_at,omitempty"`
StatusChangedAt string `json:"status_changed_at,omitempty"`
StatusAtCollect *ReanimatorStatusAtCollection `json:"status_at_collection,omitempty"`
StatusHistory []ReanimatorStatusHistoryEntry `json:"status_history,omitempty"`
ErrorDescription string `json:"error_description,omitempty"`
Socket int `json:"socket"`
Model string `json:"model,omitempty"`
Cores int `json:"cores,omitempty"`
Threads int `json:"threads,omitempty"`
FrequencyMHz int `json:"frequency_mhz,omitempty"`
MaxFrequencyMHz int `json:"max_frequency_mhz,omitempty"`
TemperatureC float64 `json:"temperature_c,omitempty"`
PowerW float64 `json:"power_w,omitempty"`
Throttled *bool `json:"throttled,omitempty"`
CorrectableErrorCount int64 `json:"correctable_error_count,omitempty"`
UncorrectableErrorCount int64 `json:"uncorrectable_error_count,omitempty"`
LifeRemainingPct float64 `json:"life_remaining_pct,omitempty"`
LifeUsedPct float64 `json:"life_used_pct,omitempty"`
SerialNumber string `json:"serial_number,omitempty"`
Firmware string `json:"firmware,omitempty"`
Present *bool `json:"present,omitempty"`
Manufacturer string `json:"manufacturer,omitempty"`
Status string `json:"status,omitempty"`
StatusCheckedAt string `json:"status_checked_at,omitempty"`
StatusChangedAt string `json:"status_changed_at,omitempty"`
StatusHistory []ReanimatorStatusHistoryEntry `json:"status_history,omitempty"`
ErrorDescription string `json:"error_description,omitempty"`
}
// ReanimatorMemory represents a memory module (DIMM)
type ReanimatorMemory struct {
Slot string `json:"slot"`
Location string `json:"location,omitempty"`
Present bool `json:"present"`
SizeMB int `json:"size_mb,omitempty"`
Type string `json:"type,omitempty"`
MaxSpeedMHz int `json:"max_speed_mhz,omitempty"`
CurrentSpeedMHz int `json:"current_speed_mhz,omitempty"`
Manufacturer string `json:"manufacturer,omitempty"`
SerialNumber string `json:"serial_number,omitempty"`
PartNumber string `json:"part_number,omitempty"`
Status string `json:"status,omitempty"`
StatusCheckedAt string `json:"status_checked_at,omitempty"`
StatusChangedAt string `json:"status_changed_at,omitempty"`
StatusAtCollect *ReanimatorStatusAtCollection `json:"status_at_collection,omitempty"`
StatusHistory []ReanimatorStatusHistoryEntry `json:"status_history,omitempty"`
ErrorDescription string `json:"error_description,omitempty"`
Slot string `json:"slot"`
Location string `json:"location,omitempty"`
Present *bool `json:"present,omitempty"`
SizeMB int `json:"size_mb,omitempty"`
Type string `json:"type,omitempty"`
MaxSpeedMHz int `json:"max_speed_mhz,omitempty"`
CurrentSpeedMHz int `json:"current_speed_mhz,omitempty"`
TemperatureC float64 `json:"temperature_c,omitempty"`
CorrectableECCErrorCount int64 `json:"correctable_ecc_error_count,omitempty"`
UncorrectableECCErrorCount int64 `json:"uncorrectable_ecc_error_count,omitempty"`
LifeRemainingPct float64 `json:"life_remaining_pct,omitempty"`
LifeUsedPct float64 `json:"life_used_pct,omitempty"`
SpareBlocksRemainingPct float64 `json:"spare_blocks_remaining_pct,omitempty"`
PerformanceDegraded *bool `json:"performance_degraded,omitempty"`
DataLossDetected *bool `json:"data_loss_detected,omitempty"`
Manufacturer string `json:"manufacturer,omitempty"`
SerialNumber string `json:"serial_number,omitempty"`
PartNumber string `json:"part_number,omitempty"`
Status string `json:"status,omitempty"`
StatusCheckedAt string `json:"status_checked_at,omitempty"`
StatusChangedAt string `json:"status_changed_at,omitempty"`
StatusHistory []ReanimatorStatusHistoryEntry `json:"status_history,omitempty"`
ErrorDescription string `json:"error_description,omitempty"`
}
// ReanimatorStorage represents a storage device
type ReanimatorStorage struct {
Slot string `json:"slot"`
Type string `json:"type,omitempty"`
Model string `json:"model"`
SizeGB int `json:"size_gb,omitempty"`
SerialNumber string `json:"serial_number"`
Manufacturer string `json:"manufacturer,omitempty"`
Firmware string `json:"firmware,omitempty"`
Interface string `json:"interface,omitempty"`
Present bool `json:"present"`
Status string `json:"status,omitempty"`
StatusCheckedAt string `json:"status_checked_at,omitempty"`
StatusChangedAt string `json:"status_changed_at,omitempty"`
StatusAtCollect *ReanimatorStatusAtCollection `json:"status_at_collection,omitempty"`
StatusHistory []ReanimatorStatusHistoryEntry `json:"status_history,omitempty"`
ErrorDescription string `json:"error_description,omitempty"`
Slot string `json:"slot"`
Type string `json:"type,omitempty"`
Model string `json:"model"`
SizeGB int `json:"size_gb,omitempty"`
SerialNumber string `json:"serial_number"`
Manufacturer string `json:"manufacturer,omitempty"`
Firmware string `json:"firmware,omitempty"`
Interface string `json:"interface,omitempty"`
Present *bool `json:"present,omitempty"`
TemperatureC float64 `json:"temperature_c,omitempty"`
PowerOnHours int64 `json:"power_on_hours,omitempty"`
PowerCycles int64 `json:"power_cycles,omitempty"`
UnsafeShutdowns int64 `json:"unsafe_shutdowns,omitempty"`
MediaErrors int64 `json:"media_errors,omitempty"`
ErrorLogEntries int64 `json:"error_log_entries,omitempty"`
WrittenBytes int64 `json:"written_bytes,omitempty"`
ReadBytes int64 `json:"read_bytes,omitempty"`
LifeUsedPct float64 `json:"life_used_pct,omitempty"`
RemainingEndurancePct *int `json:"remaining_endurance_pct,omitempty"`
LifeRemainingPct float64 `json:"life_remaining_pct,omitempty"`
AvailableSparePct float64 `json:"available_spare_pct,omitempty"`
ReallocatedSectors int64 `json:"reallocated_sectors,omitempty"`
CurrentPendingSectors int64 `json:"current_pending_sectors,omitempty"`
OfflineUncorrectable int64 `json:"offline_uncorrectable,omitempty"`
Status string `json:"status,omitempty"`
StatusCheckedAt string `json:"status_checked_at,omitempty"`
StatusChangedAt string `json:"status_changed_at,omitempty"`
StatusHistory []ReanimatorStatusHistoryEntry `json:"status_history,omitempty"`
ErrorDescription string `json:"error_description,omitempty"`
}
// ReanimatorPCIe represents a PCIe device
type ReanimatorPCIe struct {
Slot string `json:"slot"`
VendorID int `json:"vendor_id,omitempty"`
DeviceID int `json:"device_id,omitempty"`
BDF string `json:"bdf,omitempty"`
DeviceClass string `json:"device_class,omitempty"`
Manufacturer string `json:"manufacturer,omitempty"`
Model string `json:"model,omitempty"`
LinkWidth int `json:"link_width,omitempty"`
LinkSpeed string `json:"link_speed,omitempty"`
MaxLinkWidth int `json:"max_link_width,omitempty"`
MaxLinkSpeed string `json:"max_link_speed,omitempty"`
SerialNumber string `json:"serial_number,omitempty"`
Firmware string `json:"firmware,omitempty"`
TemperatureC int `json:"temperature_c,omitempty"`
PowerW int `json:"power_w,omitempty"`
VoltageV float64 `json:"voltage_v,omitempty"`
Status string `json:"status,omitempty"`
StatusCheckedAt string `json:"status_checked_at,omitempty"`
StatusChangedAt string `json:"status_changed_at,omitempty"`
StatusAtCollect *ReanimatorStatusAtCollection `json:"status_at_collection,omitempty"`
StatusHistory []ReanimatorStatusHistoryEntry `json:"status_history,omitempty"`
ErrorDescription string `json:"error_description,omitempty"`
Slot string `json:"slot"`
VendorID int `json:"vendor_id,omitempty"`
DeviceID int `json:"device_id,omitempty"`
NUMANode int `json:"numa_node,omitempty"`
TemperatureC float64 `json:"temperature_c,omitempty"`
PowerW float64 `json:"power_w,omitempty"`
LifeRemainingPct float64 `json:"life_remaining_pct,omitempty"`
LifeUsedPct float64 `json:"life_used_pct,omitempty"`
ECCCorrectedTotal int64 `json:"ecc_corrected_total,omitempty"`
ECCUncorrectedTotal int64 `json:"ecc_uncorrected_total,omitempty"`
HWSlowdown *bool `json:"hw_slowdown,omitempty"`
BatteryChargePct float64 `json:"battery_charge_pct,omitempty"`
BatteryHealthPct float64 `json:"battery_health_pct,omitempty"`
BatteryTemperatureC float64 `json:"battery_temperature_c,omitempty"`
BatteryVoltageV float64 `json:"battery_voltage_v,omitempty"`
BatteryReplaceRequired *bool `json:"battery_replace_required,omitempty"`
SFPTemperatureC float64 `json:"sfp_temperature_c,omitempty"`
SFPTXPowerDBm float64 `json:"sfp_tx_power_dbm,omitempty"`
SFPRXPowerDBm float64 `json:"sfp_rx_power_dbm,omitempty"`
SFPVoltageV float64 `json:"sfp_voltage_v,omitempty"`
SFPBiasMA float64 `json:"sfp_bias_ma,omitempty"`
BDF string `json:"bdf,omitempty"`
DeviceClass string `json:"device_class,omitempty"`
Manufacturer string `json:"manufacturer,omitempty"`
Model string `json:"model,omitempty"`
LinkWidth int `json:"link_width,omitempty"`
LinkSpeed string `json:"link_speed,omitempty"`
MaxLinkWidth int `json:"max_link_width,omitempty"`
MaxLinkSpeed string `json:"max_link_speed,omitempty"`
MACAddresses []string `json:"mac_addresses,omitempty"`
Present *bool `json:"present,omitempty"`
SerialNumber string `json:"serial_number,omitempty"`
Firmware string `json:"firmware,omitempty"`
Status string `json:"status,omitempty"`
StatusCheckedAt string `json:"status_checked_at,omitempty"`
StatusChangedAt string `json:"status_changed_at,omitempty"`
StatusHistory []ReanimatorStatusHistoryEntry `json:"status_history,omitempty"`
ErrorDescription string `json:"error_description,omitempty"`
}
// ReanimatorPSU represents a power supply unit
type ReanimatorPSU struct {
Slot string `json:"slot"`
Present bool `json:"present"`
Present *bool `json:"present,omitempty"`
Model string `json:"model,omitempty"`
Vendor string `json:"vendor,omitempty"`
WattageW int `json:"wattage_w,omitempty"`
@@ -141,13 +183,54 @@ type ReanimatorPSU struct {
Firmware string `json:"firmware,omitempty"`
Status string `json:"status,omitempty"`
InputType string `json:"input_type,omitempty"`
InputPowerW int `json:"input_power_w,omitempty"`
OutputPowerW int `json:"output_power_w,omitempty"`
InputPowerW float64 `json:"input_power_w,omitempty"`
OutputPowerW float64 `json:"output_power_w,omitempty"`
InputVoltage float64 `json:"input_voltage,omitempty"`
TemperatureC int `json:"temperature_c,omitempty"`
TemperatureC float64 `json:"temperature_c,omitempty"`
LifeRemainingPct float64 `json:"life_remaining_pct,omitempty"`
LifeUsedPct float64 `json:"life_used_pct,omitempty"`
StatusCheckedAt string `json:"status_checked_at,omitempty"`
StatusChangedAt string `json:"status_changed_at,omitempty"`
StatusAtCollect *ReanimatorStatusAtCollection `json:"status_at_collection,omitempty"`
StatusHistory []ReanimatorStatusHistoryEntry `json:"status_history,omitempty"`
ErrorDescription string `json:"error_description,omitempty"`
}
type ReanimatorSensors struct {
Fans []ReanimatorFanSensor `json:"fans,omitempty"`
Power []ReanimatorPowerSensor `json:"power,omitempty"`
Temperatures []ReanimatorTemperatureSensor `json:"temperatures,omitempty"`
Other []ReanimatorOtherSensor `json:"other,omitempty"`
}
type ReanimatorFanSensor struct {
Name string `json:"name"`
Location string `json:"location,omitempty"`
RPM int `json:"rpm,omitempty"`
Status string `json:"status,omitempty"`
}
type ReanimatorPowerSensor struct {
Name string `json:"name"`
Location string `json:"location,omitempty"`
VoltageV float64 `json:"voltage_v,omitempty"`
CurrentA float64 `json:"current_a,omitempty"`
PowerW float64 `json:"power_w,omitempty"`
Status string `json:"status,omitempty"`
}
type ReanimatorTemperatureSensor struct {
Name string `json:"name"`
Location string `json:"location,omitempty"`
Celsius float64 `json:"celsius,omitempty"`
ThresholdWarningCelsius float64 `json:"threshold_warning_celsius,omitempty"`
ThresholdCriticalCelsius float64 `json:"threshold_critical_celsius,omitempty"`
Status string `json:"status,omitempty"`
}
type ReanimatorOtherSensor struct {
Name string `json:"name"`
Location string `json:"location,omitempty"`
Value float64 `json:"value,omitempty"`
Unit string `json:"unit,omitempty"`
Status string `json:"status,omitempty"`
}

View File

@@ -9,17 +9,17 @@ const (
// AnalysisResult contains all parsed data from an archive
type AnalysisResult struct {
Filename string `json:"filename"`
SourceType string `json:"source_type,omitempty"` // archive | api
Protocol string `json:"protocol,omitempty"` // redfish | ipmi
TargetHost string `json:"target_host,omitempty"` // BMC host for live collect
SourceTimezone string `json:"source_timezone,omitempty"` // Source timezone/offset used during collection (e.g. +08:00)
CollectedAt time.Time `json:"collected_at,omitempty"` // Collection/upload timestamp
RawPayloads map[string]any `json:"raw_payloads,omitempty"` // Additional source payloads (e.g. Redfish tree)
Events []Event `json:"events"`
FRU []FRUInfo `json:"fru"`
Sensors []SensorReading `json:"sensors"`
Hardware *HardwareConfig `json:"hardware"`
Filename string `json:"filename"`
SourceType string `json:"source_type,omitempty"` // archive | api
Protocol string `json:"protocol,omitempty"` // redfish | ipmi
TargetHost string `json:"target_host,omitempty"` // BMC host for live collect
SourceTimezone string `json:"source_timezone,omitempty"` // Source timezone/offset used during collection (e.g. +08:00)
CollectedAt time.Time `json:"collected_at,omitempty"` // Collection/upload timestamp
RawPayloads map[string]any `json:"raw_payloads,omitempty"` // Additional source payloads (e.g. Redfish tree)
Events []Event `json:"events"`
FRU []FRUInfo `json:"fru"`
Sensors []SensorReading `json:"sensors"`
Hardware *HardwareConfig `json:"hardware"`
}
// Event represents a single log event
@@ -110,43 +110,45 @@ const (
// HardwareDevice is canonical device inventory used across UI and exports.
type HardwareDevice struct {
ID string `json:"id"`
Kind string `json:"kind"`
Source string `json:"source,omitempty"`
Slot string `json:"slot,omitempty"`
Location string `json:"location,omitempty"`
BDF string `json:"bdf,omitempty"`
DeviceClass string `json:"device_class,omitempty"`
VendorID int `json:"vendor_id,omitempty"`
DeviceID int `json:"device_id,omitempty"`
Model string `json:"model,omitempty"`
PartNumber string `json:"part_number,omitempty"`
Manufacturer string `json:"manufacturer,omitempty"`
SerialNumber string `json:"serial_number,omitempty"`
Firmware string `json:"firmware,omitempty"`
Type string `json:"type,omitempty"`
Interface string `json:"interface,omitempty"`
Present *bool `json:"present,omitempty"`
SizeMB int `json:"size_mb,omitempty"`
SizeGB int `json:"size_gb,omitempty"`
Cores int `json:"cores,omitempty"`
Threads int `json:"threads,omitempty"`
FrequencyMHz int `json:"frequency_mhz,omitempty"`
MaxFreqMHz int `json:"max_frequency_mhz,omitempty"`
PortCount int `json:"port_count,omitempty"`
PortType string `json:"port_type,omitempty"`
MACAddresses []string `json:"mac_addresses,omitempty"`
LinkWidth int `json:"link_width,omitempty"`
LinkSpeed string `json:"link_speed,omitempty"`
MaxLinkWidth int `json:"max_link_width,omitempty"`
MaxLinkSpeed string `json:"max_link_speed,omitempty"`
WattageW int `json:"wattage_w,omitempty"`
InputType string `json:"input_type,omitempty"`
InputPowerW int `json:"input_power_w,omitempty"`
OutputPowerW int `json:"output_power_w,omitempty"`
InputVoltage float64 `json:"input_voltage,omitempty"`
TemperatureC int `json:"temperature_c,omitempty"`
Status string `json:"status,omitempty"`
ID string `json:"id"`
Kind string `json:"kind"`
Source string `json:"source,omitempty"`
Slot string `json:"slot,omitempty"`
Location string `json:"location,omitempty"`
BDF string `json:"bdf,omitempty"`
DeviceClass string `json:"device_class,omitempty"`
VendorID int `json:"vendor_id,omitempty"`
DeviceID int `json:"device_id,omitempty"`
Model string `json:"model,omitempty"`
PartNumber string `json:"part_number,omitempty"`
Manufacturer string `json:"manufacturer,omitempty"`
SerialNumber string `json:"serial_number,omitempty"`
Firmware string `json:"firmware,omitempty"`
Type string `json:"type,omitempty"`
Interface string `json:"interface,omitempty"`
Present *bool `json:"present,omitempty"`
SizeMB int `json:"size_mb,omitempty"`
SizeGB int `json:"size_gb,omitempty"`
Cores int `json:"cores,omitempty"`
Threads int `json:"threads,omitempty"`
FrequencyMHz int `json:"frequency_mhz,omitempty"`
MaxFreqMHz int `json:"max_frequency_mhz,omitempty"`
PortCount int `json:"port_count,omitempty"`
PortType string `json:"port_type,omitempty"`
MACAddresses []string `json:"mac_addresses,omitempty"`
LinkWidth int `json:"link_width,omitempty"`
LinkSpeed string `json:"link_speed,omitempty"`
MaxLinkWidth int `json:"max_link_width,omitempty"`
MaxLinkSpeed string `json:"max_link_speed,omitempty"`
WattageW int `json:"wattage_w,omitempty"`
InputType string `json:"input_type,omitempty"`
InputPowerW int `json:"input_power_w,omitempty"`
OutputPowerW int `json:"output_power_w,omitempty"`
InputVoltage float64 `json:"input_voltage,omitempty"`
TemperatureC int `json:"temperature_c,omitempty"`
RemainingEndurancePct *int `json:"remaining_endurance_pct,omitempty"` // 0-100 %; nil = not reported
NUMANode int `json:"numa_node,omitempty"` // 0 = not reported/N/A
Status string `json:"status,omitempty"`
StatusCheckedAt *time.Time `json:"status_checked_at,omitempty"`
StatusChangedAt *time.Time `json:"status_changed_at,omitempty"`
@@ -167,14 +169,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"`
BMCMACAddress string `json:"bmc_mac_address,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
@@ -194,11 +196,12 @@ 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"`
Details map[string]any `json:"details,omitempty"`
}
// MemoryDIMM represents a memory module
@@ -218,31 +221,34 @@ 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"`
Details map[string]any `json:"details,omitempty"`
}
// Storage represents a storage device
type Storage struct {
Slot string `json:"slot"`
Type string `json:"type"`
Model string `json:"model"`
Description string `json:"description,omitempty"`
SizeGB int `json:"size_gb"`
SerialNumber string `json:"serial_number,omitempty"`
Manufacturer string `json:"manufacturer,omitempty"`
Firmware string `json:"firmware,omitempty"`
Interface string `json:"interface,omitempty"`
Present bool `json:"present"`
Location string `json:"location,omitempty"` // Front/Rear
BackplaneID int `json:"backplane_id,omitempty"`
Status string `json:"status,omitempty"`
Slot string `json:"slot"`
Type string `json:"type"`
Model string `json:"model"`
Description string `json:"description,omitempty"`
SizeGB int `json:"size_gb"`
SerialNumber string `json:"serial_number,omitempty"`
Manufacturer string `json:"manufacturer,omitempty"`
Firmware string `json:"firmware,omitempty"`
Interface string `json:"interface,omitempty"`
Present bool `json:"present"`
Location string `json:"location,omitempty"` // Front/Rear
BackplaneID int `json:"backplane_id,omitempty"`
RemainingEndurancePct *int `json:"remaining_endurance_pct,omitempty"` // 0-100 %; nil = not reported
Status string `json:"status,omitempty"`
Details map[string]any `json:"details,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"`
@@ -250,15 +256,15 @@ type Storage struct {
// StorageVolume represents a logical storage volume (RAID/VROC/etc.).
type StorageVolume struct {
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Controller string `json:"controller,omitempty"`
RAIDLevel string `json:"raid_level,omitempty"`
SizeGB int `json:"size_gb,omitempty"`
CapacityBytes int64 `json:"capacity_bytes,omitempty"`
Status string `json:"status,omitempty"`
Bootable bool `json:"bootable,omitempty"`
Encrypted bool `json:"encrypted,omitempty"`
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Controller string `json:"controller,omitempty"`
RAIDLevel string `json:"raid_level,omitempty"`
SizeGB int `json:"size_gb,omitempty"`
CapacityBytes int64 `json:"capacity_bytes,omitempty"`
Status string `json:"status,omitempty"`
Bootable bool `json:"bootable,omitempty"`
Encrypted bool `json:"encrypted,omitempty"`
}
// PCIeDevice represents a PCIe device
@@ -277,13 +283,15 @@ type PCIeDevice struct {
PartNumber string `json:"part_number,omitempty"`
SerialNumber string `json:"serial_number,omitempty"`
MACAddresses []string `json:"mac_addresses,omitempty"`
NUMANode int `json:"numa_node,omitempty"` // 0 = not reported/N/A
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"`
Details map[string]any `json:"details,omitempty"`
}
// NIC represents a network interface card
@@ -298,25 +306,26 @@ type NIC struct {
// PSU represents a power supply unit
type PSU struct {
Slot string `json:"slot"`
Present bool `json:"present"`
Model string `json:"model"`
Description string `json:"description,omitempty"`
Vendor string `json:"vendor,omitempty"`
WattageW int `json:"wattage_w,omitempty"`
SerialNumber string `json:"serial_number,omitempty"`
PartNumber string `json:"part_number,omitempty"`
Firmware string `json:"firmware,omitempty"`
Status string `json:"status,omitempty"`
InputType string `json:"input_type,omitempty"`
InputPowerW int `json:"input_power_w,omitempty"`
OutputPowerW int `json:"output_power_w,omitempty"`
InputVoltage float64 `json:"input_voltage,omitempty"`
OutputVoltage float64 `json:"output_voltage,omitempty"`
TemperatureC int `json:"temperature_c,omitempty"`
Slot string `json:"slot"`
Present bool `json:"present"`
Model string `json:"model"`
Description string `json:"description,omitempty"`
Vendor string `json:"vendor,omitempty"`
WattageW int `json:"wattage_w,omitempty"`
SerialNumber string `json:"serial_number,omitempty"`
PartNumber string `json:"part_number,omitempty"`
Firmware string `json:"firmware,omitempty"`
Status string `json:"status,omitempty"`
InputType string `json:"input_type,omitempty"`
InputPowerW int `json:"input_power_w,omitempty"`
OutputPowerW int `json:"output_power_w,omitempty"`
InputVoltage float64 `json:"input_voltage,omitempty"`
OutputVoltage float64 `json:"output_voltage,omitempty"`
TemperatureC int `json:"temperature_c,omitempty"`
Details map[string]any `json:"details,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"`
@@ -353,11 +362,12 @@ 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"`
Details map[string]any `json:"details,omitempty"`
}
// NetworkAdapter represents a network adapter with detailed info
@@ -365,6 +375,7 @@ type NetworkAdapter struct {
Slot string `json:"slot"`
Location string `json:"location"`
Present bool `json:"present"`
BDF string `json:"bdf,omitempty"`
Model string `json:"model"`
Description string `json:"description,omitempty"`
Vendor string `json:"vendor,omitempty"`
@@ -376,11 +387,17 @@ type NetworkAdapter struct {
PortCount int `json:"port_count,omitempty"`
PortType string `json:"port_type,omitempty"`
MACAddresses []string `json:"mac_addresses,omitempty"`
LinkWidth int `json:"link_width,omitempty"`
LinkSpeed string `json:"link_speed,omitempty"`
MaxLinkWidth int `json:"max_link_width,omitempty"`
MaxLinkSpeed string `json:"max_link_speed,omitempty"`
NUMANode int `json:"numa_node,omitempty"` // 0 = not reported/N/A
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"`
Details map[string]any `json:"details,omitempty"`
}

View File

@@ -16,6 +16,7 @@ import (
"git.mchus.pro/mchus/logpile/internal/models"
"git.mchus.pro/mchus/logpile/internal/parser"
"git.mchus.pro/mchus/logpile/internal/parser/vendors/pciids"
)
const parserVersion = "3.0"
@@ -199,7 +200,7 @@ func parseDCIMViewXML(content []byte, result *models.AnalysisResult) {
parsePowerSupplyView(props, result)
case "DCIM_PCIDeviceView":
parsePCIeDeviceView(props, result)
case "DCIM_NICView":
case "DCIM_NICView", "DCIM_InfiniBandView":
parseNICView(props, result)
case "DCIM_VideoView":
parseVideoView(props, result)
@@ -374,6 +375,10 @@ func parsePhysicalDiskView(props map[string]string, result *models.AnalysisResul
Location: strings.TrimSpace(props["devicedescription"]),
Status: normalizeStatus(firstNonEmpty(props["raidstatus"], props["primarystatus"])),
}
if v := strings.TrimSpace(props["remainingratedwriteendurance"]); v != "" {
n := parseIntLoose(v)
st.RemainingEndurancePct = &n
}
result.Hardware.Storage = append(result.Hardware.Storage, st)
}
@@ -437,11 +442,18 @@ var pcieFQDDNoisePrefix = []string{
"SMBus.Embedded.",
"AHCI.Embedded.",
"Video.Embedded.",
"NIC.Embedded.",
// All NIC FQDD classes are parsed from DCIM_NICView / DCIM_InfiniBandView into
// NetworkAdapters with model, MAC, firmware, and VendorID/DeviceID. The
// DCIM_PCIDeviceView duplicate carries only DataBusWidth ("Unknown", "16x or x16")
// and no useful extra data, so suppress it here.
"NIC.",
"InfiniBand.",
}
func parsePCIeDeviceView(props map[string]string, result *models.AnalysisResult) {
desc := strings.TrimSpace(firstNonEmpty(props["devicedescription"], props["description"]))
// "description" is the chip/device model (e.g. "MT28908 Family [ConnectX-6]"); prefer
// it over "devicedescription" which is the location string ("InfiniBand in Slot 1 Port 1").
desc := strings.TrimSpace(firstNonEmpty(props["description"], props["devicedescription"]))
fqdd := strings.TrimSpace(firstNonEmpty(props["fqdd"], props["instanceid"]))
if desc == "" && fqdd == "" {
return
@@ -451,14 +463,26 @@ func parsePCIeDeviceView(props map[string]string, result *models.AnalysisResult)
return
}
}
vendorID := parseHexOrDec(firstNonEmpty(props["pcivendorid"], props["vendorid"]))
deviceID := parseHexOrDec(firstNonEmpty(props["pcideviceid"], props["deviceid"]))
manufacturer := strings.TrimSpace(props["manufacturer"])
// General rule: if chip model not found in logs but PCI IDs are known, resolve from pci.ids
if desc == "" && vendorID != 0 && deviceID != 0 {
desc = pciids.DeviceName(vendorID, deviceID)
}
if manufacturer == "" && vendorID != 0 {
manufacturer = pciids.VendorName(vendorID)
}
p := models.PCIeDevice{
Slot: fqdd,
Description: desc,
VendorID: parseHexOrDec(firstNonEmpty(props["pcivendorid"], props["vendorid"])),
DeviceID: parseHexOrDec(firstNonEmpty(props["pcideviceid"], props["deviceid"])),
VendorID: vendorID,
DeviceID: deviceID,
BDF: formatBDF(props["busnumber"], props["devicenumber"], props["functionnumber"]),
DeviceClass: strings.TrimSpace(props["databuswidth"]),
Manufacturer: strings.TrimSpace(props["manufacturer"]),
Manufacturer: manufacturer,
NUMANode: parseIntLoose(props["cpuaffinity"]),
Status: normalizeStatus(props["primarystatus"]),
}
result.Hardware.PCIeDevices = append(result.Hardware.PCIeDevices, p)
@@ -471,15 +495,31 @@ func parseNICView(props map[string]string, result *models.AnalysisResult) {
return
}
mac := strings.TrimSpace(firstNonEmpty(props["currentmacaddress"], props["permanentmacaddress"]))
vendorID := parseHexOrDec(firstNonEmpty(props["pcivendorid"], props["vendorid"]))
deviceID := parseHexOrDec(firstNonEmpty(props["pcideviceid"], props["deviceid"]))
vendor := strings.TrimSpace(firstNonEmpty(props["vendorname"], props["manufacturer"]))
// Prefer pci.ids chip model over generic ProductName when PCI IDs are available.
// Dell TSR often reports a marketing name (e.g. "Mellanox Network Adapter") while
// pci.ids has the precise chip identifier (e.g. "MT28908 Family [ConnectX-6]").
if vendorID != 0 && deviceID != 0 {
if chipModel := pciids.DeviceName(vendorID, deviceID); chipModel != "" {
model = chipModel
}
if vendor == "" {
vendor = pciids.VendorName(vendorID)
}
}
n := models.NetworkAdapter{
Slot: fqdd,
Location: strings.TrimSpace(firstNonEmpty(props["devicedescription"], fqdd)),
Present: true,
Model: model,
Description: strings.TrimSpace(props["protocol"]),
Vendor: strings.TrimSpace(firstNonEmpty(props["vendorname"], props["manufacturer"])),
VendorID: parseHexOrDec(firstNonEmpty(props["pcivendorid"], props["vendorid"])),
DeviceID: parseHexOrDec(firstNonEmpty(props["pcideviceid"], props["deviceid"])),
Vendor: vendor,
VendorID: vendorID,
DeviceID: deviceID,
SerialNumber: strings.TrimSpace(props["serialnumber"]),
PartNumber: strings.TrimSpace(props["partnumber"]),
Firmware: strings.TrimSpace(firstNonEmpty(
@@ -489,6 +529,7 @@ func parseNICView(props map[string]string, result *models.AnalysisResult) {
props["controllerbiosversion"],
)),
PortCount: inferPortCountFromFQDD(fqdd),
NUMANode: parseIntLoose(props["cpuaffinity"]),
Status: normalizeStatus(props["primarystatus"]),
}
if mac != "" {
@@ -542,10 +583,11 @@ func parseControllerView(props map[string]string, result *models.AnalysisResult)
DeviceClass: "storage-controller",
Manufacturer: strings.TrimSpace(firstNonEmpty(props["devicecardmanufacturer"], props["manufacturer"])),
PartNumber: strings.TrimSpace(firstNonEmpty(props["ppid"], props["boardpartnumber"])),
NUMANode: parseIntLoose(props["cpuaffinity"]),
Status: normalizeStatus(props["primarystatus"]),
})
addFirmware(result, firstNonEmpty(name, fqdd), props["controllerfirmwareversion"], "storage controller")
addFirmware(result, firstNonEmpty(name, fqdd), props["controllerfirmwareversion"], firstNonEmpty(fqdd, "storage controller"))
}
func parseControllerBatteryView(props map[string]string, result *models.AnalysisResult) {
@@ -1131,6 +1173,7 @@ func mergeStorage(dst *models.Storage, src models.Storage) {
}
setIfEmpty(&dst.Location, src.Location)
setIfEmpty(&dst.Status, src.Status)
dst.Details = mergeDellDetails(dst.Details, src.Details)
}
func dedupeVolumes(items []models.StorageVolume) []models.StorageVolume {
@@ -1202,6 +1245,22 @@ func mergePSU(dst *models.PSU, src models.PSU) {
dst.InputVoltage = src.InputVoltage
}
setIfEmpty(&dst.InputType, src.InputType)
dst.Details = mergeDellDetails(dst.Details, src.Details)
}
func mergeDellDetails(primary, secondary map[string]any) map[string]any {
if len(secondary) == 0 {
return primary
}
if primary == nil {
primary = make(map[string]any, len(secondary))
}
for key, value := range secondary {
if _, ok := primary[key]; !ok {
primary[key] = value
}
}
return primary
}
func dedupeNetworkAdapters(items []models.NetworkAdapter) []models.NetworkAdapter {

View File

@@ -204,6 +204,262 @@ func TestParseNestedTSRZip(t *testing.T) {
}
}
// TestParseDellPhysicalDiskEndurance verifies that RemainingRatedWriteEndurance from
// DCIM_PhysicalDiskView is parsed into Storage.RemainingEndurancePct.
func TestParseDellPhysicalDiskEndurance(t *testing.T) {
const viewXML = `<CIM><MESSAGE><SIMPLEREQ>
<VALUE.NAMEDINSTANCE><INSTANCE CLASSNAME="DCIM_SystemView">
<PROPERTY NAME="Manufacturer"><VALUE>Dell Inc.</VALUE></PROPERTY>
<PROPERTY NAME="Model"><VALUE>PowerEdge R6625</VALUE></PROPERTY>
<PROPERTY NAME="ServiceTag"><VALUE>8VS2LG4</VALUE></PROPERTY>
</INSTANCE></VALUE.NAMEDINSTANCE>
<VALUE.NAMEDINSTANCE><INSTANCE CLASSNAME="DCIM_PhysicalDiskView">
<PROPERTY NAME="FQDD"><VALUE>Disk.Bay.0:Enclosure.Internal.0-1:RAID.SL.3-1</VALUE></PROPERTY>
<PROPERTY NAME="Slot"><VALUE>0</VALUE></PROPERTY>
<PROPERTY NAME="Model"><VALUE>HFS480G3H2X069N</VALUE></PROPERTY>
<PROPERTY NAME="SerialNumber"><VALUE>ESEAN5254I030B26B</VALUE></PROPERTY>
<PROPERTY NAME="SizeInBytes"><VALUE>479559942144</VALUE></PROPERTY>
<PROPERTY NAME="MediaType"><VALUE>Solid State Drive</VALUE></PROPERTY>
<PROPERTY NAME="BusProtocol"><VALUE>SATA</VALUE></PROPERTY>
<PROPERTY NAME="Revision"><VALUE>DZ03</VALUE></PROPERTY>
<PROPERTY NAME="RemainingRatedWriteEndurance"><VALUE>100</VALUE><DisplayValue>100 %</DisplayValue></PROPERTY>
<PROPERTY NAME="PrimaryStatus"><VALUE>1</VALUE><DisplayValue>OK</DisplayValue></PROPERTY>
</INSTANCE></VALUE.NAMEDINSTANCE>
<VALUE.NAMEDINSTANCE><INSTANCE CLASSNAME="DCIM_PhysicalDiskView">
<PROPERTY NAME="FQDD"><VALUE>Disk.Bay.1:Enclosure.Internal.0-1:RAID.SL.3-1</VALUE></PROPERTY>
<PROPERTY NAME="Slot"><VALUE>1</VALUE></PROPERTY>
<PROPERTY NAME="Model"><VALUE>TOSHIBA MG08ADA800E</VALUE></PROPERTY>
<PROPERTY NAME="SerialNumber"><VALUE>X1G0A0YXFVVG</VALUE></PROPERTY>
<PROPERTY NAME="SizeInBytes"><VALUE>8001563222016</VALUE></PROPERTY>
<PROPERTY NAME="MediaType"><VALUE>Hard Disk Drive</VALUE></PROPERTY>
<PROPERTY NAME="BusProtocol"><VALUE>SAS</VALUE></PROPERTY>
<PROPERTY NAME="Revision"><VALUE>0104</VALUE></PROPERTY>
</INSTANCE></VALUE.NAMEDINSTANCE>
</SIMPLEREQ></MESSAGE></CIM>`
inner := makeZipArchive(t, map[string][]byte{
"tsr/metadata.json": []byte(`{"Make":"Dell Inc.","Model":"PowerEdge R6625","ServiceTag":"8VS2LG4"}`),
"tsr/hardware/sysinfo/inventory/sysinfo_DCIM_View.xml": []byte(viewXML),
})
p := &Parser{}
result, err := p.Parse([]parser.ExtractedFile{
{Path: "signature", Content: []byte("ok")},
{Path: "TSR20260306141852_8VS2LG4.pl.zip", Content: inner},
})
if err != nil {
t.Fatalf("parse failed: %v", err)
}
if len(result.Hardware.Storage) != 2 {
t.Fatalf("expected 2 storage devices, got %d", len(result.Hardware.Storage))
}
ssd := result.Hardware.Storage[0]
if ssd.RemainingEndurancePct == nil {
t.Fatalf("SSD slot 0: expected RemainingEndurancePct to be set")
}
if *ssd.RemainingEndurancePct != 100 {
t.Errorf("SSD slot 0: expected RemainingEndurancePct=100, got %d", *ssd.RemainingEndurancePct)
}
hdd := result.Hardware.Storage[1]
if hdd.RemainingEndurancePct != nil {
t.Errorf("HDD slot 1: expected RemainingEndurancePct absent, got %d", *hdd.RemainingEndurancePct)
}
}
// TestParseDellInfiniBandView verifies that DCIM_InfiniBandView entries are parsed as
// NetworkAdapters (not PCIe devices) and that the corresponding SoftwareIdentity firmware
// entry with FQDD "InfiniBand.Slot.*" does not leak into hardware.firmware.
//
// Regression guard: PowerEdge R6625 (8VS2LG4) — "Mellanox Network Adapter" version
// "20.39.35.60" appeared in hardware.firmware because DCIM_InfiniBandView was ignored
// (device ended up only in PCIeDevices with model "16x or x16") and SoftwareIdentity
// FQDD "InfiniBand.Slot.1-1" was not filtered. (2026-03-15)
func TestParseDellInfiniBandView(t *testing.T) {
const viewXML = `<CIM><MESSAGE><SIMPLEREQ>
<VALUE.NAMEDINSTANCE><INSTANCE CLASSNAME="DCIM_SystemView">
<PROPERTY NAME="Manufacturer"><VALUE>Dell Inc.</VALUE></PROPERTY>
<PROPERTY NAME="Model"><VALUE>PowerEdge R6625</VALUE></PROPERTY>
<PROPERTY NAME="ServiceTag"><VALUE>8VS2LG4</VALUE></PROPERTY>
</INSTANCE></VALUE.NAMEDINSTANCE>
<VALUE.NAMEDINSTANCE><INSTANCE CLASSNAME="DCIM_InfiniBandView">
<PROPERTY NAME="FQDD"><VALUE>InfiniBand.Slot.1-1</VALUE></PROPERTY>
<PROPERTY NAME="DeviceDescription"><VALUE>InfiniBand in Slot 1 Port 1</VALUE></PROPERTY>
<PROPERTY NAME="CurrentMACAddress"><VALUE>00:1C:FD:D7:5A:E6</VALUE></PROPERTY>
<PROPERTY NAME="FamilyVersion"><VALUE>20.39.35.60</VALUE></PROPERTY>
<PROPERTY NAME="EFIVersion"><VALUE>14.32.17</VALUE></PROPERTY>
<PROPERTY NAME="PCIVendorID"><VALUE>15B3</VALUE></PROPERTY>
<PROPERTY NAME="PCIDeviceID"><VALUE>101B</VALUE></PROPERTY>
<PROPERTY NAME="PrimaryStatus"><VALUE>0</VALUE></PROPERTY>
</INSTANCE></VALUE.NAMEDINSTANCE>
<VALUE.NAMEDINSTANCE><INSTANCE CLASSNAME="DCIM_PCIDeviceView">
<PROPERTY NAME="FQDD"><VALUE>InfiniBand.Slot.1-1</VALUE></PROPERTY>
<PROPERTY NAME="Description"><VALUE>MT28908 Family [ConnectX-6]</VALUE></PROPERTY>
<PROPERTY NAME="DeviceDescription"><VALUE>InfiniBand in Slot 1 Port 1</VALUE></PROPERTY>
<PROPERTY NAME="Manufacturer"><VALUE>Mellanox Technologies</VALUE></PROPERTY>
<PROPERTY NAME="PCIVendorID"><VALUE>15B3</VALUE></PROPERTY>
<PROPERTY NAME="PCIDeviceID"><VALUE>101B</VALUE></PROPERTY>
<PROPERTY NAME="DataBusWidth"><DisplayValue>16x or x16</DisplayValue></PROPERTY>
</INSTANCE></VALUE.NAMEDINSTANCE>
<VALUE.NAMEDINSTANCE><INSTANCE CLASSNAME="DCIM_ControllerView">
<PROPERTY NAME="FQDD"><VALUE>RAID.SL.3-1</VALUE></PROPERTY>
<PROPERTY NAME="ProductName"><VALUE>PERC H755 Front</VALUE></PROPERTY>
<PROPERTY NAME="ControllerFirmwareVersion"><VALUE>52.30.0-6115</VALUE></PROPERTY>
<PROPERTY NAME="PrimaryStatus"><VALUE>0</VALUE></PROPERTY>
</INSTANCE></VALUE.NAMEDINSTANCE>
</SIMPLEREQ></MESSAGE></CIM>`
const swXML = `<CIM><MESSAGE><SIMPLEREQ>
<VALUE.NAMEDINSTANCE><INSTANCE CLASSNAME="DCIM_SoftwareIdentity">
<PROPERTY NAME="ElementName"><VALUE>Mellanox Network Adapter - 00:1C:FD:D7:5A:E6</VALUE></PROPERTY>
<PROPERTY NAME="FQDD"><VALUE>InfiniBand.Slot.1-1</VALUE></PROPERTY>
<PROPERTY NAME="VersionString"><VALUE>20.39.35.60</VALUE></PROPERTY>
</INSTANCE></VALUE.NAMEDINSTANCE>
<VALUE.NAMEDINSTANCE><INSTANCE CLASSNAME="DCIM_SoftwareIdentity">
<PROPERTY NAME="ElementName"><VALUE>PERC H755 Front</VALUE></PROPERTY>
<PROPERTY NAME="FQDD"><VALUE>RAID.SL.3-1</VALUE></PROPERTY>
<PROPERTY NAME="VersionString"><VALUE>52.30.0-6115</VALUE></PROPERTY>
</INSTANCE></VALUE.NAMEDINSTANCE>
<VALUE.NAMEDINSTANCE><INSTANCE CLASSNAME="DCIM_SoftwareIdentity">
<PROPERTY NAME="ElementName"><VALUE>BIOS</VALUE></PROPERTY>
<PROPERTY NAME="FQDD"><VALUE>BIOS.Setup.1-1</VALUE></PROPERTY>
<PROPERTY NAME="VersionString"><VALUE>1.15.3</VALUE></PROPERTY>
</INSTANCE></VALUE.NAMEDINSTANCE>
</SIMPLEREQ></MESSAGE></CIM>`
inner := makeZipArchive(t, map[string][]byte{
"tsr/metadata.json": []byte(`{"Make":"Dell Inc.","Model":"PowerEdge R6625","ServiceTag":"8VS2LG4"}`),
"tsr/hardware/sysinfo/inventory/sysinfo_DCIM_View.xml": []byte(viewXML),
"tsr/hardware/sysinfo/inventory/sysinfo_DCIM_SoftwareIdentity.xml": []byte(swXML),
})
p := &Parser{}
result, err := p.Parse([]parser.ExtractedFile{
{Path: "signature", Content: []byte("ok")},
{Path: "TSR20260306141852_8VS2LG4.pl.zip", Content: inner},
})
if err != nil {
t.Fatalf("parse failed: %v", err)
}
// InfiniBand adapter must appear as a NetworkAdapter, not a PCIe device.
if len(result.Hardware.NetworkAdapters) != 1 {
t.Fatalf("expected 1 network adapter, got %d", len(result.Hardware.NetworkAdapters))
}
nic := result.Hardware.NetworkAdapters[0]
if nic.Slot != "InfiniBand.Slot.1-1" {
t.Errorf("unexpected NIC slot: %q", nic.Slot)
}
if nic.Firmware != "20.39.35.60" {
t.Errorf("unexpected NIC firmware: %q", nic.Firmware)
}
if len(nic.MACAddresses) == 0 || nic.MACAddresses[0] != "00:1C:FD:D7:5A:E6" {
t.Errorf("unexpected NIC MAC: %v", nic.MACAddresses)
}
// pci.ids enrichment: VendorID=0x15B3, DeviceID=0x101B → chip model + vendor name.
if nic.Model != "MT28908 Family [ConnectX-6]" {
t.Errorf("NIC model = %q, want MT28908 Family [ConnectX-6] (from pci.ids)", nic.Model)
}
if nic.Vendor != "Mellanox Technologies" {
t.Errorf("NIC vendor = %q, want Mellanox Technologies (from pci.ids)", nic.Vendor)
}
// InfiniBand FQDD must NOT appear in PCIe devices.
for _, pcie := range result.Hardware.PCIeDevices {
if pcie.Slot == "InfiniBand.Slot.1-1" {
t.Errorf("InfiniBand.Slot.1-1 must not appear in PCIeDevices")
}
}
// Firmware entries from SoftwareIdentity and parseControllerView must carry the FQDD
// as their Description so the exporter's isDeviceBoundFirmwareFQDD filter can remove them.
fqddByName := make(map[string]string)
for _, fw := range result.Hardware.Firmware {
fqddByName[fw.DeviceName] = fw.Description
}
if desc := fqddByName["Mellanox Network Adapter"]; desc != "InfiniBand.Slot.1-1" {
t.Errorf("Mellanox firmware Description = %q, want InfiniBand.Slot.1-1 for FQDD filter", desc)
}
if desc := fqddByName["PERC H755 Front"]; desc != "RAID.SL.3-1" {
t.Errorf("PERC H755 Front firmware Description = %q, want RAID.SL.3-1 for FQDD filter", desc)
}
}
// TestParseDellCPUAffinity verifies that CPUAffinity is parsed into NUMANode for
// NIC, PCIe, and controller views. "Not Applicable" must result in NUMANode=0.
func TestParseDellCPUAffinity(t *testing.T) {
const viewXML = `<CIM><MESSAGE><SIMPLEREQ>
<VALUE.NAMEDINSTANCE><INSTANCE CLASSNAME="DCIM_SystemView">
<PROPERTY NAME="Manufacturer"><VALUE>Dell Inc.</VALUE></PROPERTY>
<PROPERTY NAME="Model"><VALUE>PowerEdge R750</VALUE></PROPERTY>
<PROPERTY NAME="ServiceTag"><VALUE>TESTST1</VALUE></PROPERTY>
</INSTANCE></VALUE.NAMEDINSTANCE>
<VALUE.NAMEDINSTANCE><INSTANCE CLASSNAME="DCIM_NICView">
<PROPERTY NAME="FQDD"><VALUE>NIC.Slot.2-1-1</VALUE></PROPERTY>
<PROPERTY NAME="ProductName"><VALUE>Some NIC</VALUE></PROPERTY>
<PROPERTY NAME="CPUAffinity"><VALUE>1</VALUE></PROPERTY>
<PROPERTY NAME="PrimaryStatus"><VALUE>0</VALUE></PROPERTY>
</INSTANCE></VALUE.NAMEDINSTANCE>
<VALUE.NAMEDINSTANCE><INSTANCE CLASSNAME="DCIM_InfiniBandView">
<PROPERTY NAME="FQDD"><VALUE>InfiniBand.Slot.1-1</VALUE></PROPERTY>
<PROPERTY NAME="DeviceDescription"><VALUE>InfiniBand in Slot 1</VALUE></PROPERTY>
<PROPERTY NAME="CPUAffinity"><VALUE>2</VALUE></PROPERTY>
<PROPERTY NAME="PrimaryStatus"><VALUE>0</VALUE></PROPERTY>
</INSTANCE></VALUE.NAMEDINSTANCE>
<VALUE.NAMEDINSTANCE><INSTANCE CLASSNAME="DCIM_ControllerView">
<PROPERTY NAME="FQDD"><VALUE>RAID.Slot.1-1</VALUE></PROPERTY>
<PROPERTY NAME="ProductName"><VALUE>PERC H755</VALUE></PROPERTY>
<PROPERTY NAME="CPUAffinity"><VALUE>Not Applicable</VALUE></PROPERTY>
<PROPERTY NAME="PrimaryStatus"><VALUE>0</VALUE></PROPERTY>
</INSTANCE></VALUE.NAMEDINSTANCE>
<VALUE.NAMEDINSTANCE><INSTANCE CLASSNAME="DCIM_PCIDeviceView">
<PROPERTY NAME="FQDD"><VALUE>Slot.7-1</VALUE></PROPERTY>
<PROPERTY NAME="Description"><VALUE>Some PCIe Card</VALUE></PROPERTY>
<PROPERTY NAME="CPUAffinity"><VALUE>2</VALUE></PROPERTY>
<PROPERTY NAME="PrimaryStatus"><VALUE>0</VALUE></PROPERTY>
</INSTANCE></VALUE.NAMEDINSTANCE>
</SIMPLEREQ></MESSAGE></CIM>`
inner := makeZipArchive(t, map[string][]byte{
"tsr/metadata.json": []byte(`{"Make":"Dell Inc.","Model":"PowerEdge R750","ServiceTag":"TESTST1"}`),
"tsr/hardware/sysinfo/inventory/sysinfo_DCIM_View.xml": []byte(viewXML),
})
p := &Parser{}
result, err := p.Parse([]parser.ExtractedFile{
{Path: "signature", Content: []byte("ok")},
{Path: "TSR_TESTST1.pl.zip", Content: inner},
})
if err != nil {
t.Fatalf("parse failed: %v", err)
}
// NIC CPUAffinity=1 → NUMANode=1
nicBySlot := make(map[string]int)
for _, nic := range result.Hardware.NetworkAdapters {
nicBySlot[nic.Slot] = nic.NUMANode
}
if nicBySlot["NIC.Slot.2-1-1"] != 1 {
t.Errorf("NIC.Slot.2-1-1 NUMANode = %d, want 1", nicBySlot["NIC.Slot.2-1-1"])
}
if nicBySlot["InfiniBand.Slot.1-1"] != 2 {
t.Errorf("InfiniBand.Slot.1-1 NUMANode = %d, want 2", nicBySlot["InfiniBand.Slot.1-1"])
}
// PCIe device CPUAffinity=2 → NUMANode=2; controller CPUAffinity="Not Applicable" → NUMANode=0
pcieBySlot := make(map[string]int)
for _, pcie := range result.Hardware.PCIeDevices {
pcieBySlot[pcie.Slot] = pcie.NUMANode
}
if pcieBySlot["Slot.7-1"] != 2 {
t.Errorf("Slot.7-1 NUMANode = %d, want 2", pcieBySlot["Slot.7-1"])
}
if pcieBySlot["RAID.Slot.1-1"] != 0 {
t.Errorf("RAID.Slot.1-1 NUMANode = %d, want 0 (Not Applicable)", pcieBySlot["RAID.Slot.1-1"])
}
}
func makeZipArchive(t *testing.T, files map[string][]byte) []byte {
t.Helper()
var buf bytes.Buffer

View File

@@ -3024,6 +3024,7 @@ func mergeStorage(dst *models.Storage, src models.Storage) {
}
setStorageString(&dst.Location, src.Location)
setStorageString(&dst.Status, normalizeStorageStatus(src.Status, src.Present || dst.Present))
dst.Details = mergeH3CDetails(dst.Details, src.Details)
}
func setStorageString(dst *string, value string) {
@@ -3275,6 +3276,22 @@ func mergePSU(dst *models.PSU, src models.PSU) {
setStorageString(&dst.PartNumber, src.PartNumber)
setStorageString(&dst.Firmware, src.Firmware)
setStorageString(&dst.Status, src.Status)
dst.Details = mergeH3CDetails(dst.Details, src.Details)
}
func mergeH3CDetails(primary, secondary map[string]any) map[string]any {
if len(secondary) == 0 {
return primary
}
if primary == nil {
primary = make(map[string]any, len(secondary))
}
for key, value := range secondary {
if _, ok := primary[key]; !ok {
primary[key] = value
}
}
return primary
}
func dedupeVolumes(items []models.StorageVolume) []models.StorageVolume {

View File

@@ -100,10 +100,18 @@ func parseMemoryInfo(text string, hw *models.HardwareConfig) {
return
}
// Replace memory data with detailed info from component.log
hw.Memory = nil
var merged []models.MemoryDIMM
seen := make(map[string]int)
for _, existing := range hw.Memory {
key := inspurMemoryKey(existing)
if key == "" {
continue
}
seen[key] = len(merged)
merged = append(merged, existing)
}
for _, mem := range memInfo.MemModules {
hw.Memory = append(hw.Memory, models.MemoryDIMM{
item := models.MemoryDIMM{
Slot: mem.MemModSlot,
Location: mem.MemModSlot,
Present: mem.MemModStatus == 1 && mem.MemModSize > 0,
@@ -117,8 +125,18 @@ func parseMemoryInfo(text string, hw *models.HardwareConfig) {
PartNumber: strings.TrimSpace(mem.MemModPartNum),
Status: mem.Status,
Ranks: mem.MemModRanks,
})
}
key := inspurMemoryKey(item)
if idx, ok := seen[key]; ok {
mergeInspurMemoryDIMM(&merged[idx], item)
continue
}
if key != "" {
seen[key] = len(merged)
}
merged = append(merged, item)
}
hw.Memory = merged
}
// PSURESTInfo represents the RESTful PSU info structure
@@ -159,10 +177,18 @@ func parsePSUInfo(text string, hw *models.HardwareConfig) {
return
}
// Clear existing PSU data and populate with RESTful data
hw.PowerSupply = nil
var merged []models.PSU
seen := make(map[string]int)
for _, existing := range hw.PowerSupply {
key := inspurPSUKey(existing)
if key == "" {
continue
}
seen[key] = len(merged)
merged = append(merged, existing)
}
for _, psu := range psuInfo.PowerSupplies {
hw.PowerSupply = append(hw.PowerSupply, models.PSU{
item := models.PSU{
Slot: fmt.Sprintf("PSU%d", psu.ID),
Present: psu.Present == 1,
Model: strings.TrimSpace(psu.Model),
@@ -178,8 +204,18 @@ func parsePSUInfo(text string, hw *models.HardwareConfig) {
InputVoltage: psu.PSInVolt,
OutputVoltage: psu.PSOutVolt,
TemperatureC: psu.PSUMaxTemp,
})
}
key := inspurPSUKey(item)
if idx, ok := seen[key]; ok {
mergeInspurPSU(&merged[idx], item)
continue
}
if key != "" {
seen[key] = len(merged)
}
merged = append(merged, item)
}
hw.PowerSupply = merged
}
// HDDRESTInfo represents the RESTful HDD info structure
@@ -357,7 +393,16 @@ func parseNetworkAdapterInfo(text string, hw *models.HardwareConfig) {
return
}
hw.NetworkAdapters = nil
var merged []models.NetworkAdapter
seen := make(map[string]int)
for _, existing := range hw.NetworkAdapters {
key := inspurNICKey(existing)
if key == "" {
continue
}
seen[key] = len(merged)
merged = append(merged, existing)
}
for _, adapter := range netInfo.SysAdapters {
var macs []string
for _, port := range adapter.Ports {
@@ -377,7 +422,7 @@ func parseNetworkAdapterInfo(text string, hw *models.HardwareConfig) {
vendor = normalizeModelLabel(pciids.VendorName(adapter.VendorID))
}
hw.NetworkAdapters = append(hw.NetworkAdapters, models.NetworkAdapter{
item := models.NetworkAdapter{
Slot: fmt.Sprintf("Slot %d", adapter.Slot),
Location: adapter.Location,
Present: adapter.Present == 1,
@@ -392,8 +437,231 @@ func parseNetworkAdapterInfo(text string, hw *models.HardwareConfig) {
PortType: adapter.PortType,
MACAddresses: macs,
Status: adapter.Status,
})
}
key := inspurNICKey(item)
if idx, ok := seen[key]; ok {
mergeInspurNIC(&merged[idx], item)
continue
}
if slotIdx := inspurFindNICBySlot(merged, item.Slot); slotIdx >= 0 {
mergeInspurNIC(&merged[slotIdx], item)
if key != "" {
seen[key] = slotIdx
}
continue
}
if key != "" {
seen[key] = len(merged)
}
merged = append(merged, item)
}
hw.NetworkAdapters = merged
}
func inspurMemoryKey(item models.MemoryDIMM) string {
return strings.ToLower(strings.TrimSpace(inspurFirstNonEmpty(item.SerialNumber, item.Slot, item.Location)))
}
func mergeInspurMemoryDIMM(dst *models.MemoryDIMM, src models.MemoryDIMM) {
if dst == nil {
return
}
if strings.TrimSpace(dst.Slot) == "" {
dst.Slot = src.Slot
}
if strings.TrimSpace(dst.Location) == "" {
dst.Location = src.Location
}
dst.Present = dst.Present || src.Present
if dst.SizeMB == 0 {
dst.SizeMB = src.SizeMB
}
if strings.TrimSpace(dst.Type) == "" {
dst.Type = src.Type
}
if strings.TrimSpace(dst.Technology) == "" {
dst.Technology = src.Technology
}
if dst.MaxSpeedMHz == 0 {
dst.MaxSpeedMHz = src.MaxSpeedMHz
}
if dst.CurrentSpeedMHz == 0 {
dst.CurrentSpeedMHz = src.CurrentSpeedMHz
}
if strings.TrimSpace(dst.Manufacturer) == "" {
dst.Manufacturer = src.Manufacturer
}
if strings.TrimSpace(dst.SerialNumber) == "" {
dst.SerialNumber = src.SerialNumber
}
if strings.TrimSpace(dst.PartNumber) == "" {
dst.PartNumber = src.PartNumber
}
if strings.TrimSpace(dst.Status) == "" {
dst.Status = src.Status
}
if dst.Ranks == 0 {
dst.Ranks = src.Ranks
}
}
func inspurPSUKey(item models.PSU) string {
return strings.ToLower(strings.TrimSpace(inspurFirstNonEmpty(item.SerialNumber, item.Slot, item.Model)))
}
func mergeInspurPSU(dst *models.PSU, src models.PSU) {
if dst == nil {
return
}
if strings.TrimSpace(dst.Slot) == "" {
dst.Slot = src.Slot
}
dst.Present = dst.Present || src.Present
if strings.TrimSpace(dst.Model) == "" {
dst.Model = src.Model
}
if strings.TrimSpace(dst.Vendor) == "" {
dst.Vendor = src.Vendor
}
if dst.WattageW == 0 {
dst.WattageW = src.WattageW
}
if strings.TrimSpace(dst.SerialNumber) == "" {
dst.SerialNumber = src.SerialNumber
}
if strings.TrimSpace(dst.PartNumber) == "" {
dst.PartNumber = src.PartNumber
}
if strings.TrimSpace(dst.Firmware) == "" {
dst.Firmware = src.Firmware
}
if strings.TrimSpace(dst.Status) == "" {
dst.Status = src.Status
}
if strings.TrimSpace(dst.InputType) == "" {
dst.InputType = src.InputType
}
if dst.InputPowerW == 0 {
dst.InputPowerW = src.InputPowerW
}
if dst.OutputPowerW == 0 {
dst.OutputPowerW = src.OutputPowerW
}
if dst.InputVoltage == 0 {
dst.InputVoltage = src.InputVoltage
}
if dst.OutputVoltage == 0 {
dst.OutputVoltage = src.OutputVoltage
}
if dst.TemperatureC == 0 {
dst.TemperatureC = src.TemperatureC
}
}
func inspurNICKey(item models.NetworkAdapter) string {
return strings.ToLower(strings.TrimSpace(inspurFirstNonEmpty(item.SerialNumber, strings.Join(item.MACAddresses, ","), item.Slot, item.Location)))
}
func mergeInspurNIC(dst *models.NetworkAdapter, src models.NetworkAdapter) {
if dst == nil {
return
}
if strings.TrimSpace(dst.Slot) == "" {
dst.Slot = src.Slot
}
if strings.TrimSpace(dst.Location) == "" {
dst.Location = src.Location
}
dst.Present = dst.Present || src.Present
if strings.TrimSpace(dst.BDF) == "" {
dst.BDF = src.BDF
}
if strings.TrimSpace(dst.Model) == "" {
dst.Model = src.Model
}
if strings.TrimSpace(dst.Description) == "" {
dst.Description = src.Description
}
if strings.TrimSpace(dst.Vendor) == "" {
dst.Vendor = src.Vendor
}
if dst.VendorID == 0 {
dst.VendorID = src.VendorID
}
if dst.DeviceID == 0 {
dst.DeviceID = src.DeviceID
}
if strings.TrimSpace(dst.SerialNumber) == "" {
dst.SerialNumber = src.SerialNumber
}
if strings.TrimSpace(dst.PartNumber) == "" {
dst.PartNumber = src.PartNumber
}
if strings.TrimSpace(dst.Firmware) == "" {
dst.Firmware = src.Firmware
}
if dst.PortCount == 0 {
dst.PortCount = src.PortCount
}
if strings.TrimSpace(dst.PortType) == "" {
dst.PortType = src.PortType
}
if dst.LinkWidth == 0 {
dst.LinkWidth = src.LinkWidth
}
if strings.TrimSpace(dst.LinkSpeed) == "" {
dst.LinkSpeed = src.LinkSpeed
}
if dst.MaxLinkWidth == 0 {
dst.MaxLinkWidth = src.MaxLinkWidth
}
if strings.TrimSpace(dst.MaxLinkSpeed) == "" {
dst.MaxLinkSpeed = src.MaxLinkSpeed
}
if dst.NUMANode == 0 {
dst.NUMANode = src.NUMANode
}
if strings.TrimSpace(dst.Status) == "" {
dst.Status = src.Status
}
for _, mac := range src.MACAddresses {
mac = strings.TrimSpace(mac)
if mac == "" {
continue
}
found := false
for _, existing := range dst.MACAddresses {
if strings.EqualFold(strings.TrimSpace(existing), mac) {
found = true
break
}
}
if !found {
dst.MACAddresses = append(dst.MACAddresses, mac)
}
}
}
func inspurFindNICBySlot(items []models.NetworkAdapter, slot string) int {
slot = strings.ToLower(strings.TrimSpace(slot))
if slot == "" {
return -1
}
for i := range items {
if strings.ToLower(strings.TrimSpace(items[i].Slot)) == slot {
return i
}
}
return -1
}
func inspurFirstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
func parseFanSensors(text string) []models.SensorReading {

View File

@@ -51,6 +51,64 @@ RESTful fan`
}
}
func TestParseNetworkAdapterInfo_MergesIntoExistingInventory(t *testing.T) {
text := `RESTful Network Adapter info:
{
"sys_adapters": [
{
"id": 1,
"name": "NIC1",
"Location": "#CPU0_PCIE4",
"present": 1,
"slot": 4,
"vendor_id": 32902,
"device_id": 5409,
"vendor": "Mellanox",
"model": "ConnectX-6",
"fw_ver": "22.1.0",
"status": "OK",
"sn": "",
"pn": "",
"port_num": 2,
"port_type": "QSFP",
"ports": [
{ "id": 1, "mac_addr": "00:11:22:33:44:55" }
]
}
]
}
RESTful fan`
hw := &models.HardwareConfig{
NetworkAdapters: []models.NetworkAdapter{
{
Slot: "Slot 4",
BDF: "0000:17:00.0",
SerialNumber: "NIC-SN-1",
Present: true,
},
},
}
parseNetworkAdapterInfo(text, hw)
if len(hw.NetworkAdapters) != 1 {
t.Fatalf("expected merged single adapter, got %d", len(hw.NetworkAdapters))
}
got := hw.NetworkAdapters[0]
if got.BDF != "0000:17:00.0" {
t.Fatalf("expected existing BDF to survive merge, got %q", got.BDF)
}
if got.Model != "ConnectX-6" {
t.Fatalf("expected model from component log, got %q", got.Model)
}
if got.SerialNumber != "NIC-SN-1" {
t.Fatalf("expected serial from existing inventory to survive merge, got %q", got.SerialNumber)
}
if len(got.MACAddresses) != 1 || got.MACAddresses[0] != "00:11:22:33:44:55" {
t.Fatalf("expected MAC addresses from component log, got %#v", got.MACAddresses)
}
}
func TestParseComponentLogSensors_ExtractsFanBackplaneAndPSUSummary(t *testing.T) {
text := `RESTful PSU info:
{