diff --git a/internal/parser/vendors/hpe_ilo_ahs/parser.go b/internal/parser/vendors/hpe_ilo_ahs/parser.go index b40e3c2..624bd75 100644 --- a/internal/parser/vendors/hpe_ilo_ahs/parser.go +++ b/internal/parser/vendors/hpe_ilo_ahs/parser.go @@ -1045,11 +1045,30 @@ func dedupeCPUs(items []models.CPU) []models.CPU { return out } +// dedupeMemory collapses the DIMM inventory to one entry per physical slot. +// +// The AHS blackbox carries a separate inventory snapshot for every POST cycle, +// and parseDIMMs walks all of them. When a module is swapped between captures the +// same slot shows up with different serials; keying the dedupe on serial alone +// (as an earlier version did) let every historical occupant survive, inflating +// the module count. Tokens are appended in blackbox-record order, which is +// chronological, so the last occurrence for a slot is the most recent snapshot +// and wins. Entries without a slot label fall back to serial-based dedupe. func dedupeMemory(items []models.MemoryDIMM) []models.MemoryDIMM { + slotIndex := make(map[string]int) seen := make(map[string]bool) out := make([]models.MemoryDIMM, 0, len(items)) for _, item := range items { - key := valueOr(item.SerialNumber, item.Slot+"|"+item.PartNumber) + if item.Slot != "" { + if idx, ok := slotIndex[item.Slot]; ok { + out[idx] = item + continue + } + slotIndex[item.Slot] = len(out) + out = append(out, item) + continue + } + key := valueOr(item.SerialNumber, item.PartNumber) if seen[key] { continue } diff --git a/internal/parser/vendors/hpe_ilo_ahs/parser_test.go b/internal/parser/vendors/hpe_ilo_ahs/parser_test.go index 3145c20..81ad8a3 100644 --- a/internal/parser/vendors/hpe_ilo_ahs/parser_test.go +++ b/internal/parser/vendors/hpe_ilo_ahs/parser_test.go @@ -8,6 +8,7 @@ import ( "path/filepath" "testing" + "git.mchus.pro/mchus/logpile/internal/models" "git.mchus.pro/mchus/logpile/internal/parser" ) @@ -153,6 +154,34 @@ func TestParseAHSInventory(t *testing.T) { } } +func TestDedupeMemorySlotSwap(t *testing.T) { + // The AHS blackbox keeps one inventory snapshot per POST cycle. When a module + // is swapped between captures the same slot appears with different serials, in + // chronological (token stream) order. dedupeMemory must keep one entry per + // slot, with the most recent snapshot winning. + items := []models.MemoryDIMM{ + {Slot: "PROC 1 DIMM 1", PartNumber: "HMCG94AHBRA480N", SerialNumber: "AAA1"}, + {Slot: "PROC 1 DIMM 7", PartNumber: "HMCG94AHBRA487N", SerialNumber: "OLD7"}, + {Slot: "PROC 1 DIMM 1", PartNumber: "HMCG94AHBRA480N", SerialNumber: "AAA1"}, // repeated snapshot + {Slot: "PROC 1 DIMM 7", PartNumber: "HMCG94AHBRA480N", SerialNumber: "NEW7"}, // module swapped + } + + out := dedupeMemory(items) + if len(out) != 2 { + t.Fatalf("expected 2 DIMMs (one per slot), got %d: %+v", len(out), out) + } + bySlot := map[string]models.MemoryDIMM{} + for _, d := range out { + bySlot[d.Slot] = d + } + if got := bySlot["PROC 1 DIMM 7"].SerialNumber; got != "NEW7" { + t.Fatalf("expected newest occupant NEW7 for slot 7, got %q", got) + } + if got := bySlot["PROC 1 DIMM 1"].SerialNumber; got != "AAA1" { + t.Fatalf("unexpected serial for slot 1: %q", got) + } +} + func TestParseAHSTruncatedEntry(t *testing.T) { p := &Parser{} // Build archive where the last entry's declared size exceeds available data.