fix(export): keep storage inventory without serials
This commit is contained in:
Submodule internal/chart updated: c025ae0477...2fb01d30a6
@@ -358,10 +358,12 @@ func dedupeCanonicalDevices(items []models.HardwareDevice) []models.HardwareDevi
|
|||||||
prev.score = canonicalScore(prev.item)
|
prev.score = canonicalScore(prev.item)
|
||||||
byKey[key] = prev
|
byKey[key] = prev
|
||||||
}
|
}
|
||||||
// Secondary pass: for items without serial/BDF (noKey), try to merge into an
|
// Secondary pass: for PCIe-class items without serial/BDF (noKey), try to merge
|
||||||
// existing keyed entry with the same model+manufacturer. This handles the case
|
// into an existing keyed entry with the same model+manufacturer. This handles
|
||||||
// where a device appears both in PCIeDevices (with BDF) and NetworkAdapters
|
// the case where a device appears both in PCIeDevices (with BDF) and
|
||||||
// (without BDF) — e.g. Inspur outboardPCIeCard vs PCIeCard with the same model.
|
// 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,
|
// deviceIdentity returns the best available model name for secondary matching,
|
||||||
// preferring Model over DeviceClass (which may hold a resolved device name).
|
// preferring Model over DeviceClass (which may hold a resolved device name).
|
||||||
deviceIdentity := func(d models.HardwareDevice) string {
|
deviceIdentity := func(d models.HardwareDevice) string {
|
||||||
@@ -377,6 +379,10 @@ func dedupeCanonicalDevices(items []models.HardwareDevice) []models.HardwareDevi
|
|||||||
var unmatched []models.HardwareDevice
|
var unmatched []models.HardwareDevice
|
||||||
for _, item := range noKey {
|
for _, item := range noKey {
|
||||||
mergeKind := canonicalMergeKind(item.Kind)
|
mergeKind := canonicalMergeKind(item.Kind)
|
||||||
|
if mergeKind != "pcie-class" {
|
||||||
|
unmatched = append(unmatched, item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
identity := deviceIdentity(item)
|
identity := deviceIdentity(item)
|
||||||
mfr := strings.ToLower(strings.TrimSpace(item.Manufacturer))
|
mfr := strings.ToLower(strings.TrimSpace(item.Manufacturer))
|
||||||
if identity == "" {
|
if identity == "" {
|
||||||
@@ -721,18 +727,16 @@ func convertStorageFromDevices(devices []models.HardwareDevice, collectedAt stri
|
|||||||
if isVirtualExportStorageDevice(d) {
|
if isVirtualExportStorageDevice(d) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(d.SerialNumber) == "" {
|
if !shouldExportStorageDevice(d) {
|
||||||
continue
|
|
||||||
}
|
|
||||||
present := d.Present == nil || *d.Present
|
|
||||||
if !present {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
present := boolFromPresentPtr(d.Present, true)
|
||||||
status := inferStorageStatus(models.Storage{Present: present})
|
status := inferStorageStatus(models.Storage{Present: present})
|
||||||
if strings.TrimSpace(d.Status) != "" {
|
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)
|
meta := buildStatusMeta(status, d.StatusCheckedAt, d.StatusChangedAt, d.StatusHistory, d.ErrorDescription, collectedAt)
|
||||||
|
presentValue := present
|
||||||
result = append(result, ReanimatorStorage{
|
result = append(result, ReanimatorStorage{
|
||||||
Slot: d.Slot,
|
Slot: d.Slot,
|
||||||
Type: d.Type,
|
Type: d.Type,
|
||||||
@@ -742,6 +746,7 @@ func convertStorageFromDevices(devices []models.HardwareDevice, collectedAt stri
|
|||||||
Manufacturer: d.Manufacturer,
|
Manufacturer: d.Manufacturer,
|
||||||
Firmware: d.Firmware,
|
Firmware: d.Firmware,
|
||||||
Interface: d.Interface,
|
Interface: d.Interface,
|
||||||
|
Present: &presentValue,
|
||||||
TemperatureC: floatFromDetailMap(d.Details, "temperature_c"),
|
TemperatureC: floatFromDetailMap(d.Details, "temperature_c"),
|
||||||
PowerOnHours: int64FromDetailMap(d.Details, "power_on_hours"),
|
PowerOnHours: int64FromDetailMap(d.Details, "power_on_hours"),
|
||||||
PowerCycles: int64FromDetailMap(d.Details, "power_cycles"),
|
PowerCycles: int64FromDetailMap(d.Details, "power_cycles"),
|
||||||
@@ -1386,14 +1391,16 @@ func convertStorage(storage []models.Storage, collectedAt string) []ReanimatorSt
|
|||||||
|
|
||||||
result := make([]ReanimatorStorage, 0, len(storage))
|
result := make([]ReanimatorStorage, 0, len(storage))
|
||||||
for _, stor := range storage {
|
for _, stor := range storage {
|
||||||
// Skip storage without serial number
|
if isVirtualLegacyStorageDevice(stor) {
|
||||||
if stor.SerialNumber == "" {
|
continue
|
||||||
|
}
|
||||||
|
if !shouldExportLegacyStorage(stor) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
status := inferStorageStatus(stor)
|
status := inferStorageStatus(stor)
|
||||||
if strings.TrimSpace(stor.Status) != "" {
|
if strings.TrimSpace(stor.Status) != "" {
|
||||||
status = normalizeStatus(stor.Status, false)
|
status = normalizeStatus(stor.Status, !stor.Present)
|
||||||
}
|
}
|
||||||
meta := buildStatusMeta(
|
meta := buildStatusMeta(
|
||||||
status,
|
status,
|
||||||
@@ -1403,6 +1410,7 @@ func convertStorage(storage []models.Storage, collectedAt string) []ReanimatorSt
|
|||||||
stor.ErrorDescription,
|
stor.ErrorDescription,
|
||||||
collectedAt,
|
collectedAt,
|
||||||
)
|
)
|
||||||
|
present := stor.Present
|
||||||
|
|
||||||
result = append(result, ReanimatorStorage{
|
result = append(result, ReanimatorStorage{
|
||||||
Slot: stor.Slot,
|
Slot: stor.Slot,
|
||||||
@@ -1413,6 +1421,7 @@ func convertStorage(storage []models.Storage, collectedAt string) []ReanimatorSt
|
|||||||
Manufacturer: stor.Manufacturer,
|
Manufacturer: stor.Manufacturer,
|
||||||
Firmware: stor.Firmware,
|
Firmware: stor.Firmware,
|
||||||
Interface: stor.Interface,
|
Interface: stor.Interface,
|
||||||
|
Present: &present,
|
||||||
RemainingEndurancePct: stor.RemainingEndurancePct,
|
RemainingEndurancePct: stor.RemainingEndurancePct,
|
||||||
Status: status,
|
Status: status,
|
||||||
StatusCheckedAt: meta.StatusCheckedAt,
|
StatusCheckedAt: meta.StatusCheckedAt,
|
||||||
@@ -1424,6 +1433,53 @@ func convertStorage(storage []models.Storage, collectedAt string) []ReanimatorSt
|
|||||||
return result
|
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
|
// convertPCIeDevices converts PCIe devices, GPUs, and network adapters to Reanimator format
|
||||||
func convertPCIeDevices(hw *models.HardwareConfig, collectedAt string) []ReanimatorPCIe {
|
func convertPCIeDevices(hw *models.HardwareConfig, collectedAt string) []ReanimatorPCIe {
|
||||||
result := make([]ReanimatorPCIe, 0)
|
result := make([]ReanimatorPCIe, 0)
|
||||||
|
|||||||
@@ -447,20 +447,26 @@ func TestConvertStorage(t *testing.T) {
|
|||||||
Slot: "OB02",
|
Slot: "OB02",
|
||||||
Type: "NVMe",
|
Type: "NVMe",
|
||||||
Model: "INTEL SSDPF2KX076T1",
|
Model: "INTEL SSDPF2KX076T1",
|
||||||
SerialNumber: "", // No serial - should be skipped
|
SerialNumber: "",
|
||||||
Present: true,
|
Present: true,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
result := convertStorage(storage, "2026-02-10T15:30:00Z")
|
result := convertStorage(storage, "2026-02-10T15:30:00Z")
|
||||||
|
|
||||||
if len(result) != 1 {
|
if len(result) != 2 {
|
||||||
t.Fatalf("expected 1 storage device (skipped one without serial), got %d", len(result))
|
t.Fatalf("expected both inventory slots to be exported, got %d", len(result))
|
||||||
}
|
}
|
||||||
|
|
||||||
if result[0].Status != "Unknown" {
|
if result[0].Status != "Unknown" {
|
||||||
t.Errorf("expected Unknown status, got %q", result[0].Status)
|
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) {
|
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) {
|
func TestConvertToReanimator_FirmwareExcludesDeviceBoundEntries(t *testing.T) {
|
||||||
input := &models.AnalysisResult{
|
input := &models.AnalysisResult{
|
||||||
Filename: "fw-filter-test.json",
|
Filename: "fw-filter-test.json",
|
||||||
|
|||||||
Reference in New Issue
Block a user