diff --git a/internal/chart b/internal/chart index c025ae0..2fb01d3 160000 --- a/internal/chart +++ b/internal/chart @@ -1 +1 @@ -Subproject commit c025ae04779a518e742de08151cbe8fa8789b95b +Subproject commit 2fb01d30a622960fcd9848d636c30285cc47be95 diff --git a/internal/exporter/reanimator_converter.go b/internal/exporter/reanimator_converter.go index 5054d6a..d3ca41b 100644 --- a/internal/exporter/reanimator_converter.go +++ b/internal/exporter/reanimator_converter.go @@ -358,10 +358,12 @@ func dedupeCanonicalDevices(items []models.HardwareDevice) []models.HardwareDevi prev.score = canonicalScore(prev.item) byKey[key] = prev } - // Secondary pass: for items without serial/BDF (noKey), try to merge into an - // existing keyed entry with the same model+manufacturer. This handles the case - // where a device appears both in PCIeDevices (with BDF) and NetworkAdapters - // (without BDF) — e.g. Inspur outboardPCIeCard vs PCIeCard with the same model. + // Secondary pass: for PCIe-class items without serial/BDF (noKey), try to merge + // into an existing keyed entry with the same model+manufacturer. This handles + // the case where a device appears both in PCIeDevices (with BDF) and + // NetworkAdapters (without BDF) — e.g. Inspur outboardPCIeCard vs PCIeCard + // with the same model. Do not apply this to storage: repeated NVMe slots often + // share the same model string and would collapse incorrectly. // deviceIdentity returns the best available model name for secondary matching, // preferring Model over DeviceClass (which may hold a resolved device name). deviceIdentity := func(d models.HardwareDevice) string { @@ -377,6 +379,10 @@ func dedupeCanonicalDevices(items []models.HardwareDevice) []models.HardwareDevi var unmatched []models.HardwareDevice for _, item := range noKey { mergeKind := canonicalMergeKind(item.Kind) + if mergeKind != "pcie-class" { + unmatched = append(unmatched, item) + continue + } identity := deviceIdentity(item) mfr := strings.ToLower(strings.TrimSpace(item.Manufacturer)) if identity == "" { @@ -721,18 +727,16 @@ func convertStorageFromDevices(devices []models.HardwareDevice, collectedAt stri if isVirtualExportStorageDevice(d) { continue } - if strings.TrimSpace(d.SerialNumber) == "" { - continue - } - present := d.Present == nil || *d.Present - if !present { + if !shouldExportStorageDevice(d) { continue } + present := boolFromPresentPtr(d.Present, true) status := inferStorageStatus(models.Storage{Present: present}) if strings.TrimSpace(d.Status) != "" { - status = normalizeStatus(d.Status, false) + status = normalizeStatus(d.Status, !present) } meta := buildStatusMeta(status, d.StatusCheckedAt, d.StatusChangedAt, d.StatusHistory, d.ErrorDescription, collectedAt) + presentValue := present result = append(result, ReanimatorStorage{ Slot: d.Slot, Type: d.Type, @@ -742,6 +746,7 @@ func convertStorageFromDevices(devices []models.HardwareDevice, collectedAt stri Manufacturer: d.Manufacturer, Firmware: d.Firmware, Interface: d.Interface, + Present: &presentValue, TemperatureC: floatFromDetailMap(d.Details, "temperature_c"), PowerOnHours: int64FromDetailMap(d.Details, "power_on_hours"), PowerCycles: int64FromDetailMap(d.Details, "power_cycles"), @@ -1386,14 +1391,16 @@ func convertStorage(storage []models.Storage, collectedAt string) []ReanimatorSt result := make([]ReanimatorStorage, 0, len(storage)) for _, stor := range storage { - // Skip storage without serial number - if stor.SerialNumber == "" { + if isVirtualLegacyStorageDevice(stor) { + continue + } + if !shouldExportLegacyStorage(stor) { continue } status := inferStorageStatus(stor) if strings.TrimSpace(stor.Status) != "" { - status = normalizeStatus(stor.Status, false) + status = normalizeStatus(stor.Status, !stor.Present) } meta := buildStatusMeta( status, @@ -1403,6 +1410,7 @@ func convertStorage(storage []models.Storage, collectedAt string) []ReanimatorSt stor.ErrorDescription, collectedAt, ) + present := stor.Present result = append(result, ReanimatorStorage{ Slot: stor.Slot, @@ -1413,6 +1421,7 @@ func convertStorage(storage []models.Storage, collectedAt string) []ReanimatorSt Manufacturer: stor.Manufacturer, Firmware: stor.Firmware, Interface: stor.Interface, + Present: &present, RemainingEndurancePct: stor.RemainingEndurancePct, Status: status, StatusCheckedAt: meta.StatusCheckedAt, @@ -1424,6 +1433,53 @@ func convertStorage(storage []models.Storage, collectedAt string) []ReanimatorSt return result } +func shouldExportStorageDevice(d models.HardwareDevice) bool { + if normalizedSerial(d.SerialNumber) != "" { + return true + } + if strings.TrimSpace(d.Slot) != "" { + return true + } + if hasMeaningfulExporterText(d.Model) { + return true + } + if hasMeaningfulExporterText(d.Type) || hasMeaningfulExporterText(d.Interface) { + return true + } + if d.SizeGB > 0 { + return true + } + return d.Present != nil +} + +func shouldExportLegacyStorage(stor models.Storage) bool { + if normalizedSerial(stor.SerialNumber) != "" { + return true + } + if strings.TrimSpace(stor.Slot) != "" { + return true + } + if hasMeaningfulExporterText(stor.Model) { + return true + } + if hasMeaningfulExporterText(stor.Type) || hasMeaningfulExporterText(stor.Interface) { + return true + } + if stor.SizeGB > 0 { + return true + } + return stor.Present +} + +func isVirtualLegacyStorageDevice(stor models.Storage) bool { + return isVirtualExportStorageDevice(models.HardwareDevice{ + Kind: models.DeviceKindStorage, + Slot: stor.Slot, + Model: stor.Model, + Manufacturer: stor.Manufacturer, + }) +} + // convertPCIeDevices converts PCIe devices, GPUs, and network adapters to Reanimator format func convertPCIeDevices(hw *models.HardwareConfig, collectedAt string) []ReanimatorPCIe { result := make([]ReanimatorPCIe, 0) diff --git a/internal/exporter/reanimator_converter_test.go b/internal/exporter/reanimator_converter_test.go index 8802512..7587de4 100644 --- a/internal/exporter/reanimator_converter_test.go +++ b/internal/exporter/reanimator_converter_test.go @@ -447,20 +447,26 @@ func TestConvertStorage(t *testing.T) { Slot: "OB02", Type: "NVMe", Model: "INTEL SSDPF2KX076T1", - SerialNumber: "", // No serial - should be skipped + SerialNumber: "", Present: true, }, } result := convertStorage(storage, "2026-02-10T15:30:00Z") - if len(result) != 1 { - t.Fatalf("expected 1 storage device (skipped one without serial), got %d", len(result)) + if len(result) != 2 { + t.Fatalf("expected both inventory slots to be exported, got %d", len(result)) } if result[0].Status != "Unknown" { t.Errorf("expected Unknown status, got %q", result[0].Status) } + if result[1].SerialNumber != "" { + t.Errorf("expected empty serial for second storage slot, got %q", result[1].SerialNumber) + } + if result[1].Present == nil || !*result[1].Present { + t.Fatalf("expected present=true to be preserved for populated slot without serial") + } } func TestConvertToReanimator_SkipsAMIVirtualStorageDevices(t *testing.T) { @@ -994,6 +1000,52 @@ func TestConvertToReanimator_StatusFallbackUsesCollectedAt(t *testing.T) { } } +func TestConvertToReanimator_ExportsStorageInventoryWithoutSerial(t *testing.T) { + collectedAt := time.Date(2026, 4, 1, 9, 0, 0, 0, time.UTC) + input := &models.AnalysisResult{ + Filename: "nvme-inventory.json", + CollectedAt: collectedAt, + Hardware: &models.HardwareConfig{ + BoardInfo: models.BoardInfo{SerialNumber: "BOARD-001"}, + Storage: []models.Storage{ + { + Slot: "OB01", + Type: "NVMe", + Model: "PM9A3", + SerialNumber: "SSD-001", + Present: true, + }, + { + Slot: "OB02", + Type: "NVMe", + Model: "PM9A3", + Present: true, + }, + { + Slot: "OB03", + Type: "NVMe", + Model: "PM9A3", + Present: false, + }, + }, + }, + } + + out, err := ConvertToReanimator(input) + if err != nil { + t.Fatalf("ConvertToReanimator() failed: %v", err) + } + if len(out.Hardware.Storage) != 3 { + t.Fatalf("expected 3 storage entries including inventory slots without serial, got %d", len(out.Hardware.Storage)) + } + if out.Hardware.Storage[1].Slot != "OB02" || out.Hardware.Storage[1].SerialNumber != "" { + t.Fatalf("expected OB02 storage slot without serial to survive export, got %#v", out.Hardware.Storage[1]) + } + if out.Hardware.Storage[2].Present == nil || *out.Hardware.Storage[2].Present { + t.Fatalf("expected OB03 to preserve present=false, got %#v", out.Hardware.Storage[2]) + } +} + func TestConvertToReanimator_FirmwareExcludesDeviceBoundEntries(t *testing.T) { input := &models.AnalysisResult{ Filename: "fw-filter-test.json",