diff --git a/internal/parser/vendors/unraid/parser.go b/internal/parser/vendors/unraid/parser.go index a36a5ed..f569eeb 100644 --- a/internal/parser/vendors/unraid/parser.go +++ b/internal/parser/vendors/unraid/parser.go @@ -10,10 +10,11 @@ import ( "git.mchus.pro/mchus/logpile/internal/models" "git.mchus.pro/mchus/logpile/internal/parser" + "git.mchus.pro/mchus/logpile/internal/parser/vendors/pciids" ) // parserVersion - increment when parsing logic changes. -const parserVersion = "1.0.0" +const parserVersion = "1.2" func init() { parser.Register(&Parser{}) @@ -97,6 +98,10 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er // Track storage by slot to avoid duplicates storageBySlot := make(map[string]*models.Storage) + hasDetailedMemory := false + ethtoolByIface := make(map[string]ethtoolInfo) + ethtoolByBDF := make(map[string]ethtoolInfo) + ifconfigByIface := make(map[string]ifconfigInfo) // Parse different file types for _, f := range files { @@ -116,8 +121,23 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er case strings.HasSuffix(path, "/system/memory.txt") || strings.HasSuffix(path, "\\system\\memory.txt"): parseMemory(content, result) + case strings.HasSuffix(path, "/system/meminfo.txt") || strings.HasSuffix(path, "\\system\\meminfo.txt"): + if parseMemoryDIMMs(content, result) > 0 { + hasDetailedMemory = true + } + + case strings.HasSuffix(path, "/system/ifconfig.txt") || strings.HasSuffix(path, "\\system\\ifconfig.txt"): + parseIfconfig(content, ifconfigByIface) + + case strings.HasSuffix(path, "/system/ethtool.txt") || strings.HasSuffix(path, "\\system\\ethtool.txt"): + parseEthtool(content, ethtoolByIface, ethtoolByBDF) + + case strings.HasSuffix(path, "/system/lspci.txt") || strings.HasSuffix(path, "\\system\\lspci.txt"): + parseLSPCI(content, ifconfigByIface, ethtoolByIface, ethtoolByBDF, result) + case strings.HasSuffix(path, "/system/vars.txt") || strings.HasSuffix(path, "\\system\\vars.txt"): parseVarsToMap(content, storageBySlot, result) + parseHostIdentityFromVars(content, result) case strings.Contains(path, "/smart/") && strings.HasSuffix(path, ".txt"): parseSMARTFileToMap(content, f.Path, storageBySlot, result) @@ -127,6 +147,17 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er } } + if hasDetailedMemory { + filtered := make([]models.MemoryDIMM, 0, len(result.Hardware.Memory)) + for _, dimm := range result.Hardware.Memory { + if strings.EqualFold(strings.TrimSpace(dimm.Slot), "system") { + continue + } + filtered = append(filtered, dimm) + } + result.Hardware.Memory = filtered + } + // Convert storage map to slice for _, disk := range storageBySlot { result.Hardware.Storage = append(result.Hardware.Storage, *disk) @@ -238,19 +269,19 @@ func parseMotherboard(content string, result *models.AnalysisResult) { func parseMemory(content string, result *models.AnalysisResult) { // Parse memory from free output // Example: Mem: 50Gi 11Gi 1.4Gi 565Mi 39Gi 39Gi - if m := regexp.MustCompile(`(?m)^Mem:\s+(\d+(?:\.\d+)?)(Ki|Mi|Gi|Ti)`).FindStringSubmatch(content); len(m) >= 3 { + if m := regexp.MustCompile(`(?m)^Mem:\s+(\d+(?:\.\d+)?)(Ki|Mi|Gi|Ti|KB|MB|GB|TB)`).FindStringSubmatch(content); len(m) >= 3 { size := parseFloat(m[1]) - unit := m[2] + unit := strings.ToUpper(m[2]) var sizeMB int switch unit { - case "Ki": + case "KI", "KB": sizeMB = int(size / 1024) - case "Mi": + case "MI", "MB": sizeMB = int(size) - case "Gi": + case "GI", "GB": sizeMB = int(size * 1024) - case "Ti": + case "TI", "TB": sizeMB = int(size * 1024 * 1024) } @@ -266,6 +297,358 @@ func parseMemory(content string, result *models.AnalysisResult) { } } +func parseMemoryDIMMs(content string, result *models.AnalysisResult) int { + blocks := strings.Split(content, "Handle ") + added := 0 + for _, block := range blocks { + b := strings.TrimSpace(block) + if b == "" || !strings.Contains(b, "DMI type 17") || !strings.Contains(b, "Memory Device") { + continue + } + + sizeRaw := extractFieldValue(b, "Size:") + if sizeRaw == "" || strings.Contains(strings.ToLower(sizeRaw), "no module installed") { + continue + } + + sizeMB := parseDIMMSizeMB(sizeRaw) + if sizeMB <= 0 { + continue + } + + slot := extractFieldValue(b, "Locator:") + if slot == "" { + slot = extractFieldValue(b, "Bank Locator:") + } + if slot == "" { + slot = "dimm" + } + + dimm := models.MemoryDIMM{ + Slot: slot, + Location: extractFieldValue(b, "Bank Locator:"), + Present: true, + SizeMB: sizeMB, + Type: extractFieldValue(b, "Type:"), + MaxSpeedMHz: parseSpeedMTs(extractFieldValue(b, "Speed:")), + CurrentSpeedMHz: parseSpeedMTs(extractFieldValue(b, "Configured Memory Speed:")), + Manufacturer: strings.TrimSpace(extractFieldValue(b, "Manufacturer:")), + SerialNumber: strings.TrimSpace(extractFieldValue(b, "Serial Number:")), + PartNumber: strings.TrimSpace(extractFieldValue(b, "Part Number:")), + Ranks: parseInt(extractFieldValue(b, "Rank:")), + Status: "ok", + } + if dimm.Type == "" || strings.EqualFold(dimm.Type, "Unknown") { + dimm.Type = "DRAM" + } + if dimm.CurrentSpeedMHz == 0 { + dimm.CurrentSpeedMHz = dimm.MaxSpeedMHz + } + + result.Hardware.Memory = append(result.Hardware.Memory, dimm) + added++ + } + return added +} + +func extractFieldValue(block, key string) string { + for _, line := range strings.Split(block, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, key) { + return strings.TrimSpace(strings.TrimPrefix(line, key)) + } + } + return "" +} + +func parseDIMMSizeMB(s string) int { + s = strings.TrimSpace(strings.ToUpper(s)) + if s == "" { + return 0 + } + parts := strings.Fields(s) + if len(parts) < 2 { + return 0 + } + v := parseFloat(parts[0]) + switch parts[1] { + case "KB", "KIB": + return int(v / 1024) + case "MB", "MIB": + return int(v) + case "GB", "GIB": + return int(v * 1024) + case "TB", "TIB": + return int(v * 1024 * 1024) + default: + return 0 + } +} + +func parseSpeedMTs(s string) int { + s = strings.TrimSpace(strings.ToUpper(s)) + if s == "" { + return 0 + } + re := regexp.MustCompile(`(\d+)\s*MT/S`) + if m := re.FindStringSubmatch(s); len(m) == 2 { + return parseInt(m[1]) + } + return 0 +} + +type ethtoolInfo struct { + Interface string + BusInfo string + Driver string + Firmware string + SpeedMbps int + LinkUp bool +} + +type ifconfigInfo struct { + Interface string + State string + Addresses []string +} + +func parseIfconfig(content string, out map[string]ifconfigInfo) { + lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + iface := strings.Split(fields[0], "@")[0] + if iface == "" || strings.HasPrefix(iface, "lo") || strings.HasPrefix(iface, "docker") || strings.HasPrefix(iface, "veth") { + continue + } + state := fields[1] + addrs := make([]string, 0, 2) + for _, f := range fields[2:] { + if strings.Contains(f, ".") || strings.Contains(f, ":") { + addrs = append(addrs, f) + } + } + out[iface] = ifconfigInfo{ + Interface: iface, + State: state, + Addresses: addrs, + } + } +} + +func parseEthtool(content string, byIface, byBDF map[string]ethtoolInfo) { + sections := strings.Split(content, "--------------------------------") + for _, sec := range sections { + s := strings.TrimSpace(sec) + if s == "" { + continue + } + var info ethtoolInfo + for _, line := range strings.Split(s, "\n") { + t := strings.TrimSpace(line) + switch { + case strings.HasPrefix(t, "Settings for "): + info.Interface = strings.TrimSuffix(strings.TrimPrefix(t, "Settings for "), ":") + case strings.HasPrefix(t, "driver:"): + info.Driver = strings.TrimSpace(strings.TrimPrefix(t, "driver:")) + case strings.HasPrefix(t, "firmware-version:"): + info.Firmware = strings.TrimSpace(strings.TrimPrefix(t, "firmware-version:")) + case strings.HasPrefix(t, "bus-info:"): + info.BusInfo = normalizeBDF(strings.TrimSpace(strings.TrimPrefix(t, "bus-info:"))) + case strings.HasPrefix(t, "Speed:"): + info.SpeedMbps = parseSpeedMbps(strings.TrimSpace(strings.TrimPrefix(t, "Speed:"))) + case strings.HasPrefix(t, "Link detected:"): + info.LinkUp = strings.EqualFold(strings.TrimSpace(strings.TrimPrefix(t, "Link detected:")), "yes") + } + } + if info.Interface != "" { + byIface[info.Interface] = info + } + if info.BusInfo != "" { + byBDF[info.BusInfo] = info + } + } +} + +func parseLSPCI( + content string, + iface map[string]ifconfigInfo, + ethtoolByIface map[string]ethtoolInfo, + ethtoolByBDF map[string]ethtoolInfo, + result *models.AnalysisResult, +) { + lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n") + lspciLineRe := regexp.MustCompile(`^([0-9a-fA-F:.]+)\s+(.+?)\s+\[[0-9a-fA-F]{4}\]:\s+(.+?)\s+\[([0-9a-fA-F]{4}):([0-9a-fA-F]{4})\]`) + + hasPCIe := make(map[string]bool) + hasAdapter := make(map[string]bool) + + for _, line := range lines { + m := lspciLineRe.FindStringSubmatch(strings.TrimSpace(line)) + if len(m) != 6 { + continue + } + + bdf := normalizeBDF(m[1]) + class := strings.TrimSpace(m[2]) + desc := strings.TrimSpace(m[3]) + vendorID := parseHexID(m[4]) + deviceID := parseHexID(m[5]) + + if bdf == "" { + continue + } + + if isInterestingPCIClass(class) && !hasPCIe[bdf] { + vendor := pciids.VendorName(vendorID) + result.Hardware.PCIeDevices = append(result.Hardware.PCIeDevices, models.PCIeDevice{ + Slot: bdf, + BDF: bdf, + DeviceClass: class, + Description: desc, + VendorID: vendorID, + DeviceID: deviceID, + Manufacturer: vendor, + Status: "ok", + }) + hasPCIe[bdf] = true + } + + if !isNICClass(class) || hasAdapter[bdf] { + continue + } + + etInfo := ethtoolByBDF[bdf] + if etInfo.Interface == "" { + for _, it := range ethtoolByIface { + if normalizeBDF(it.BusInfo) == bdf { + etInfo = it + break + } + } + } + if etInfo.Driver == "bonding" { + continue + } + + model := desc + if devName := pciids.DeviceName(vendorID, deviceID); devName != "" { + model = devName + } + vendor := pciids.VendorName(vendorID) + if vendor == "" { + vendor = firstWords(desc, 2) + } + + slot := etInfo.Interface + if slot == "" { + slot = bdf + } + status := "ok" + if etInfo.Interface != "" && !etInfo.LinkUp { + status = "warning" + } else if etInfo.Interface != "" { + if ifInfo, ok := iface[etInfo.Interface]; ok && !strings.EqualFold(ifInfo.State, "UP") { + status = "warning" + } + } + + adapter := models.NetworkAdapter{ + Slot: slot, + Location: bdf, + Present: true, + Model: model, + Vendor: vendor, + VendorID: vendorID, + DeviceID: deviceID, + Firmware: strings.TrimSpace(etInfo.Firmware), + PortCount: 1, + Status: status, + } + result.Hardware.NetworkAdapters = append(result.Hardware.NetworkAdapters, adapter) + + result.Hardware.NetworkCards = append(result.Hardware.NetworkCards, models.NIC{ + Name: slot, + Model: model, + SpeedMbps: etInfo.SpeedMbps, + }) + + hasAdapter[bdf] = true + } +} + +func isNICClass(class string) bool { + c := strings.ToLower(strings.TrimSpace(class)) + return strings.Contains(c, "ethernet controller") || strings.Contains(c, "network controller") +} + +func isInterestingPCIClass(class string) bool { + c := strings.ToLower(strings.TrimSpace(class)) + if isNICClass(c) { + return true + } + switch { + case strings.Contains(c, "scsi storage controller"), + strings.Contains(c, "sata controller"), + strings.Contains(c, "raid bus controller"), + strings.Contains(c, "vga compatible controller"), + strings.Contains(c, "3d controller"), + strings.Contains(c, "display controller"), + strings.Contains(c, "non-volatile memory controller"), + strings.Contains(c, "processing accelerators"): + return true + default: + return false + } +} + +func parseHexID(s string) int { + v, err := strconv.ParseInt(strings.TrimSpace(s), 16, 32) + if err != nil { + return 0 + } + return int(v) +} + +func parseSpeedMbps(s string) int { + s = strings.TrimSpace(strings.ToUpper(s)) + if s == "" || s == "UNKNOWN!" { + return 0 + } + if m := regexp.MustCompile(`(\d+)MB/S`).FindStringSubmatch(s); len(m) == 2 { + return parseInt(m[1]) + } + return 0 +} + +func normalizeBDF(bdf string) string { + bdf = strings.TrimSpace(strings.ToLower(bdf)) + if bdf == "" { + return "" + } + if strings.Count(bdf, ":") == 1 { + return "0000:" + bdf + } + return bdf +} + +func firstWords(s string, n int) string { + parts := strings.Fields(strings.TrimSpace(s)) + if len(parts) == 0 { + return "" + } + if len(parts) < n { + n = len(parts) + } + return strings.Join(parts[:n], " ") +} + func parseVarsToMap(content string, storageBySlot map[string]*models.Storage, result *models.AnalysisResult) { // Normalize line endings content = strings.ReplaceAll(content, "\r\n", "\n") @@ -385,6 +768,57 @@ func parseVarsToMap(content string, storageBySlot map[string]*models.Storage, re } } +func parseHostIdentityFromVars(content string, result *models.AnalysisResult) { + if result == nil || result.Hardware == nil { + return + } + serial := strings.TrimSpace(result.Hardware.BoardInfo.SerialNumber) + if isUsableHostIdentifier(serial) { + return + } + + flashGUID := findVarValue(content, "flashGUID") + regGUID := findVarValue(content, "regGUID") + rawUUID := findVarValue(content, "uuid") + + candidates := []string{flashGUID, regGUID, rawUUID} + for _, candidate := range candidates { + candidate = strings.TrimSpace(candidate) + if !isUsableHostIdentifier(candidate) { + continue + } + result.Hardware.BoardInfo.SerialNumber = candidate + if result.Hardware.BoardInfo.UUID == "" && candidate == rawUUID { + result.Hardware.BoardInfo.UUID = candidate + } + return + } +} + +func findVarValue(content, key string) string { + re := regexp.MustCompile(`(?m)^\s*\[` + regexp.QuoteMeta(key) + `\]\s*=>\s*(.+?)\s*$`) + if m := re.FindStringSubmatch(content); len(m) == 2 { + return strings.TrimSpace(m[1]) + } + return "" +} + +func isUsableHostIdentifier(v string) bool { + v = strings.TrimSpace(v) + if v == "" { + return false + } + l := strings.ToLower(v) + if l == "n/a" || l == "unknown" || l == "none" || l == "not available" { + return false + } + // Unraid may redact GUID values as 1... or 1..7 in diagnostics. + if strings.Contains(v, "...") || strings.Contains(v, "..") { + return false + } + return true +} + func extractDiskSection(content, diskName string) string { // Find the start of this disk's array section startPattern := regexp.MustCompile(`(?m)^\s+\[` + regexp.QuoteMeta(diskName) + `\]\s+=>\s+Array\s*\n\s+\(`) diff --git a/internal/parser/vendors/unraid/parser_test.go b/internal/parser/vendors/unraid/parser_test.go index 8550dcc..7238a38 100644 --- a/internal/parser/vendors/unraid/parser_test.go +++ b/internal/parser/vendors/unraid/parser_test.go @@ -275,3 +275,119 @@ func TestParser_Metadata(t *testing.T) { t.Error("Version() should not be empty") } } + +func TestParse_MemoryDIMMsFromMeminfo(t *testing.T) { + memInfo := `MemTotal: 53393436 kB + +Handle 0x002D, DMI type 17, 34 bytes +Memory Device + Size: 16 GB + Locator: Node0_Dimm1 + Bank Locator: Node0_Bank0 + Type: DDR3 + Speed: 1333 MT/s + Manufacturer: Samsung + Serial Number: 238F7649 + Part Number: M393B2G70BH0- + Rank: 4 + Configured Memory Speed: 1333 MT/s + +Handle 0x002F, DMI type 17, 34 bytes +Memory Device + Size: No Module Installed + Locator: Node0_Dimm2 +` + + files := []parser.ExtractedFile{ + {Path: "diagnostics/system/memory.txt", Content: []byte("Mem: 50Gi")}, + {Path: "diagnostics/system/meminfo.txt", Content: []byte(memInfo)}, + } + + p := &Parser{} + result, err := p.Parse(files) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + if got := len(result.Hardware.Memory); got != 1 { + t.Fatalf("expected only installed DIMM entries, got %d entries", got) + } + dimm := result.Hardware.Memory[0] + if dimm.Slot != "Node0_Dimm1" { + t.Errorf("Slot = %q, want Node0_Dimm1", dimm.Slot) + } + if dimm.SizeMB != 16*1024 { + t.Errorf("SizeMB = %d, want %d", dimm.SizeMB, 16*1024) + } + if dimm.Type != "DDR3" { + t.Errorf("Type = %q, want DDR3", dimm.Type) + } + if dimm.SerialNumber != "238F7649" { + t.Errorf("SerialNumber = %q, want 238F7649", dimm.SerialNumber) + } +} + +func TestParse_NetworkAndPCIeFromLSPCIAndEthtool(t *testing.T) { + lspci := `03:00.0 SCSI storage controller [0100]: Broadcom / LSI SAS2008 PCI-Express Fusion-MPT SAS-2 [Falcon] [1000:0072] (rev 03) +07:00.0 Ethernet controller [0200]: Realtek Semiconductor Co., Ltd. RTL8111/8168/8211/8411 PCI Express Gigabit Ethernet Controller [10ec:8168] (rev 06) +` + ethtool := `Settings for eth0: + Speed: 1000Mb/s + Link detected: yes +driver: r8168 +firmware-version: +bus-info: 0000:07:00.0 +-------------------------------- +` + files := []parser.ExtractedFile{ + {Path: "diagnostics/system/lspci.txt", Content: []byte(lspci)}, + {Path: "diagnostics/system/ethtool.txt", Content: []byte(ethtool)}, + } + + p := &Parser{} + result, err := p.Parse(files) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + 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.Location != "0000:07:00.0" { + t.Errorf("Location = %q, want 0000:07:00.0", nic.Location) + } + if nic.Model == "" { + t.Error("Model should not be empty") + } + if nic.Vendor == "" { + t.Error("Vendor should not be empty") + } + + if len(result.Hardware.PCIeDevices) < 2 { + t.Fatalf("expected at least 2 PCIe devices, got %d", len(result.Hardware.PCIeDevices)) + } +} + +func TestParse_HostSerialFallbackFromVarsUUID(t *testing.T) { + vars := ` [flashGUID] => 1... + [regGUID] => 1...7 + [uuid] => 2713440667722491190 +` + files := []parser.ExtractedFile{ + {Path: "diagnostics/system/vars.txt", Content: []byte(vars)}, + } + + p := &Parser{} + result, err := p.Parse(files) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + if result.Hardware.BoardInfo.SerialNumber != "2713440667722491190" { + t.Fatalf("BoardInfo.SerialNumber = %q, want vars uuid", result.Hardware.BoardInfo.SerialNumber) + } + if result.Hardware.BoardInfo.UUID != "2713440667722491190" { + t.Fatalf("BoardInfo.UUID = %q, want vars uuid", result.Hardware.BoardInfo.UUID) + } +} diff --git a/internal/server/device_repository.go b/internal/server/device_repository.go index 28c01c5..864bfa2 100644 --- a/internal/server/device_repository.go +++ b/internal/server/device_repository.go @@ -300,7 +300,7 @@ func BuildHardwareDevices(hw *models.HardwareConfig) []models.HardwareDevice { }) } - return dedupeDevices(all) + return annotateDuplicateSerials(dedupeDevices(all)) } func isEmptyPCIeDevice(p models.PCIeDevice) bool { @@ -443,9 +443,22 @@ func shouldMergeDevices(a, b models.HardwareDevice) bool { bSN := strings.ToLower(normalizedSerial(b.SerialNumber)) aBDF := strings.ToLower(strings.TrimSpace(a.BDF)) bBDF := strings.ToLower(strings.TrimSpace(b.BDF)) + aSlot := normalizeSlot(a.Slot) + bSlot := normalizeSlot(b.Slot) + + // Memory DIMMs can legitimately share serial number in some dumps. + // Never merge DIMMs with different slots. + if a.Kind == models.DeviceKindMemory && b.Kind == models.DeviceKindMemory { + if aSlot != "" && bSlot != "" && aSlot != bSlot { + return false + } + } // Hard conflicts. if aSN != "" && bSN != "" && aSN == bSN { + if a.Kind == models.DeviceKindMemory && b.Kind == models.DeviceKindMemory { + return aSlot != "" && bSlot != "" && aSlot == bSlot + } return true } if aSN != "" && bSN != "" && aSN != bSN { @@ -465,7 +478,7 @@ func shouldMergeDevices(a, b models.HardwareDevice) bool { if hasMACOverlap(a.MACAddresses, b.MACAddresses) { return true } - if normalizeSlot(a.Slot) != "" && normalizeSlot(a.Slot) == normalizeSlot(b.Slot) { + if aSlot != "" && aSlot == bSlot { return true } return false @@ -481,7 +494,7 @@ func shouldMergeDevices(a, b models.HardwareDevice) bool { if sameManufacturer(a, b) { score += 2 } - if normalizeSlot(a.Slot) != "" && normalizeSlot(a.Slot) == normalizeSlot(b.Slot) { + if aSlot != "" && aSlot == bSlot { score += 2 } if hasMACOverlap(a.MACAddresses, b.MACAddresses) { @@ -732,3 +745,35 @@ func buildFirmwareBySlot(firmware []models.FirmwareInfo) map[string]slotFirmware func normalizeSlotKey(slot string) string { return strings.ToLower(strings.TrimSpace(slot)) } + +func annotateDuplicateSerials(items []models.HardwareDevice) []models.HardwareDevice { + if len(items) < 2 { + return items + } + + countByKindSerial := make(map[string]int) + for _, d := range items { + serial := normalizedSerial(d.SerialNumber) + if serial == "" { + continue + } + key := d.Kind + "|" + strings.ToLower(serial) + countByKindSerial[key]++ + } + + seenByKindSerial := make(map[string]int) + for i := range items { + serial := normalizedSerial(items[i].SerialNumber) + if serial == "" { + continue + } + key := items[i].Kind + "|" + strings.ToLower(serial) + if countByKindSerial[key] < 2 { + continue + } + seenByKindSerial[key]++ + items[i].SerialNumber = serial + " (DUP#" + strconv.Itoa(seenByKindSerial[key]) + ")" + } + + return items +} diff --git a/internal/server/device_repository_test.go b/internal/server/device_repository_test.go index f347bb7..c89ecea 100644 --- a/internal/server/device_repository_test.go +++ b/internal/server/device_repository_test.go @@ -65,6 +65,54 @@ func TestBuildHardwareDevices_SkipsEmptyMemorySlots(t *testing.T) { } } +func TestBuildHardwareDevices_MemorySameSerialDifferentSlots_NotDeduped(t *testing.T) { + hw := &models.HardwareConfig{ + Memory: []models.MemoryDIMM{ + {Slot: "Node0_Dimm1", Location: "Node0_Bank0", Present: true, SizeMB: 16384, SerialNumber: "238F7649", PartNumber: "M393B2G70BH0-"}, + {Slot: "Node0_Dimm3", Location: "Node0_Bank0", Present: true, SizeMB: 16384, SerialNumber: "238F7649", PartNumber: "M393B2G70BH0-"}, + }, + } + + devices := BuildHardwareDevices(hw) + memorySlots := make(map[string]bool) + for _, d := range devices { + if d.Kind != models.DeviceKindMemory { + continue + } + memorySlots[d.Slot] = true + } + + if len(memorySlots) != 2 { + t.Fatalf("expected 2 memory devices, got %d", len(memorySlots)) + } + if !memorySlots["Node0_Dimm1"] || !memorySlots["Node0_Dimm3"] { + t.Fatalf("expected both Node0_Dimm1 and Node0_Dimm3 to remain") + } +} + +func TestBuildHardwareDevices_DuplicateSerials_AreAnnotated(t *testing.T) { + hw := &models.HardwareConfig{ + Memory: []models.MemoryDIMM{ + {Slot: "A1", Location: "BANK0", Present: true, SizeMB: 16384, SerialNumber: "SN-1"}, + {Slot: "A2", Location: "BANK1", Present: true, SizeMB: 16384, SerialNumber: "SN-1"}, + }, + } + + devices := BuildHardwareDevices(hw) + var serials []string + for _, d := range devices { + if d.Kind == models.DeviceKindMemory { + serials = append(serials, d.SerialNumber) + } + } + if len(serials) != 2 { + t.Fatalf("expected 2 memory devices, got %d", len(serials)) + } + if serials[0] != "SN-1 (DUP#1)" || serials[1] != "SN-1 (DUP#2)" { + t.Fatalf("unexpected annotated serials: %+v", serials) + } +} + func TestBuildHardwareDevices_DedupCrossKindByBDF(t *testing.T) { hw := &models.HardwareConfig{ PCIeDevices: []models.PCIeDevice{