diff --git a/audit/internal/app/bundle_layout.go b/audit/internal/app/bundle_layout.go index eadcbe4..db09b7f 100644 --- a/audit/internal/app/bundle_layout.go +++ b/audit/internal/app/bundle_layout.go @@ -22,7 +22,8 @@ func techdumpBucketFor(name string) string { return "cpu" case name == "dmidecode-type17.txt": return "memory" - case name == "lsblk.json", name == "storcli64-drives.json", name == "storcli2-show-all.json", + case name == "lsblk.json", name == "storage-controllers.txt", name == "storcli64-drives.json", + name == "storcli2-show-all.json", strings.HasPrefix(name, "smartctl-"), strings.HasPrefix(name, "nvme-"): return "storage" case name == "nvidia-smi-q.txt", name == "nvidia-smi-query.csv", name == "nvidia-smi-conf-compute-q.txt", diff --git a/audit/internal/platform/techdump.go b/audit/internal/platform/techdump.go index 334408e..eed3d3e 100644 --- a/audit/internal/platform/techdump.go +++ b/audit/internal/platform/techdump.go @@ -23,6 +23,7 @@ var techDumpFixedCommands = []struct { {Name: "lspci", Args: []string{"-vvv"}, File: "lspci-vvv.txt"}, {Name: "lscpu", Args: nil, File: "lscpu.txt"}, {Name: "lsblk", Args: []string{"-J", "-d", "-o", "NAME,TYPE,SIZE,SERIAL,MODEL,TRAN,HCTL"}, File: "lsblk.json"}, + {Name: "sh", Args: []string{"-c", StorageControllerMapScript}, File: "storage-controllers.txt"}, {Name: "sensors", Args: []string{"-j"}, File: "sensors.json"}, {Name: "ipmitool", Args: []string{"fru", "print"}, File: "ipmitool-fru.txt"}, {Name: "ipmitool", Args: []string{"sdr"}, File: "ipmitool-sdr.txt"}, @@ -85,6 +86,34 @@ wait "$burn_pid" 2>/dev/null || true rm -f /tmp/bee-pcie-load-burn.log ` +// StorageControllerMapScript walks /sys/block to record, for every real +// disk, the PCI function of the controller it hangs off and its SCSI HCTL +// address. Consumed by webui's /topo page to parent each disk under its +// actual storage controller (SATA/AHCI, SAS HBA, RAID) node in the topology +// tree instead of a synthetic catch-all branch. Output is one +// "name hctl= ctrl=" line per disk; hctl is empty for +// NVMe (which is its own PCIe endpoint). +const StorageControllerMapScript = ` +for dev in /sys/block/*; do + n=$(basename "$dev") + case "$n" in loop*|ram*|zram*|sr*|md*|dm-*|nbd*|fd*) continue;; esac + [ -e "$dev/device" ] || continue + real=$(readlink -f "$dev/device") || continue + ctrl=""; hctl=""; seen_pci=0 + oldIFS=$IFS; IFS=/ + for seg in $real; do + case "$seg" in + pci[0-9]*:[0-9]*) seen_pci=1 ;; + [0-9a-f][0-9a-f][0-9a-f][0-9a-f]:[0-9a-f][0-9a-f]:[0-9a-f][0-9a-f].[0-9a-f]) [ "$seen_pci" = 1 ] && ctrl=$seg ;; + [0-9]*:[0-9]*:[0-9]*:[0-9]*) hctl=$seg ;; + *) : ;; + esac + done + IFS=$oldIFS + echo "$n hctl=$hctl ctrl=$ctrl" +done +` + // KernelAERNvidiaScript filters dmesg for PCIe AER, NVRM, and Xid lines — // cheap (dmesg is already in memory) and the fastest way to tell a real // PCIe/GPU hardware fault from power-management noise. diff --git a/audit/internal/webui/page_topo.go b/audit/internal/webui/page_topo.go index dab6b36..f6904d3 100644 --- a/audit/internal/webui/page_topo.go +++ b/audit/internal/webui/page_topo.go @@ -477,8 +477,6 @@ func topoStackLayers(count int) int { // --------------------------------------------------------------------------- const ( - topoColWidth = 220 - topoBoxWidth = 190 topoBoxHeight = 70 topoDeviceGap = 14 topoTopMargin = 30 diff --git a/audit/internal/webui/page_topo_diagram.go b/audit/internal/webui/page_topo_diagram.go index b814bd5..62e224d 100644 --- a/audit/internal/webui/page_topo_diagram.go +++ b/audit/internal/webui/page_topo_diagram.go @@ -63,17 +63,6 @@ func renderTopoMainDiagram(hw schema.HardwareSnapshot, exportDir string) string } placed = append(placed, placedDevice{dev: dev, kind: kind, col: col, bdf: bdf}) } - hasUnknownCol := false - for _, p := range placed { - if p.col == unknownCol { - hasUnknownCol = true - break - } - } - totalCols := numCols - if hasUnknownCol { - totalCols++ - } // GPU index<->BDF map + pairwise NVLink adjacency, read from the // persisted techdump captured during the last audit cycle, best-effort: @@ -151,80 +140,143 @@ func renderTopoMainDiagram(hw schema.HardwareSnapshot, exportDir string) string memCol[i] = col } - var boxes []topoBox - var pcieEdges []topoEdge - - for col := 0; col < totalCols; col++ { - colX := (col+1)*24 + col*topoColWidth - if col < len(hw.CPUs) { - cpu := hw.CPUs[col] - model := "" - if cpu.Model != nil { - model = *cpu.Model - } - socket := col - if cpu.Socket != nil { - socket = *cpu.Socket - } - var tally topoStatusTally - tally.add(classifyTopoSeverity(cpu.Status)) - fill, stroke, text := topoSeverityColors(tally.worst()) - boxes = append(boxes, topoBox{ - x: colX, y: topoTopMargin, w: topoBoxWidth, h: topoBoxHeight, - topoCardInfo: topoCardInfo{ - label: fmt.Sprintf("CPU %d", socket), sublabel: model, count: 1, - statusLine: tally.line(), - fillVar: fill, strokeVar: stroke, textVar: text, - detailType: "cpu", - }, - }) + // Map each disk to the PCI function of the controller it hangs off, read + // from the persisted storage-controllers.txt techdump + // (platform.StorageControllerMapScript). Lets a disk be drawn as a branch + // of its real storage controller (SATA/AHCI, SAS HBA, RAID) — which is + // itself a NUMA-affine PCIe device under one CPU — instead of floating in + // a synthetic catch-all node. + ctrlByHCTL := map[string]string{} + if raw, err := readTopoTechDump(exportDir, "storage-controllers.txt"); err == nil { + ctrlByHCTL = parseStorageControllerMap(raw) + } + // NUMA node of each PCIe function, for joining a controller BDF to a CPU + // column (the controller itself is usually classed "SATA controller" / + // "Serial Attached SCSI controller" and so isn't in `placed`). + numaByBDF := map[string]int{} + classByBDF := map[string]string{} + modelByBDF := map[string]string{} + for _, dev := range hw.PCIeDevices { + bdf := "" + if dev.Slot != nil { + bdf = normalizeTopoBDF(*dev.Slot) + } else if dev.BDF != nil { + bdf = normalizeTopoBDF(*dev.BDF) } + if bdf == "" { + continue + } + if dev.NUMANode != nil { + numaByBDF[bdf] = *dev.NUMANode + } + if dev.DeviceClass != nil { + classByBDF[bdf] = *dev.DeviceClass + } + if dev.Model != nil { + modelByBDF[bdf] = *dev.Model + } + } - y := topoTopMargin + topoBoxHeight + topoDeviceGap*2 - - // Memory goes first in the chain, directly under the CPU box: DIMMs - // are wired straight to the socket's memory controller, not reached - // over PCIe like the GPU/NIC/RAID chain below it. - var memGroup []schema.HardwareMemory - for i, m := range hw.Memory { - if memMatched[i] && memCol[i] == col { - memGroup = append(memGroup, m) + // diskCtrlGroups[cpuCol][ctrlBDF] = disks on that controller (cpuCol == -1 + // for controllers with no resolvable NUMA/CPU — rendered under "Other"). + type ctrlGroup struct { + bdf string + disks []schema.HardwareStorage + } + diskCtrlGroups := map[int][]*ctrlGroup{} + var looseDisks []schema.HardwareStorage // no HCTL / no controller match at all + ctrlSeen := map[string]*ctrlGroup{} + for _, d := range hw.Storage { + hctl := "" + if d.Slot != nil { + hctl = strings.TrimSpace(*d.Slot) + } + ctrl := "" + if hctl != "" { + ctrl = ctrlByHCTL[hctl] + } + if ctrl == "" { + looseDisks = append(looseDisks, d) + continue + } + col := -1 + if n, ok := numaByBDF[ctrl]; ok { + if ci, ok := socketIdx[n]; ok && ci < numCols { + col = ci } } - if len(memGroup) > 0 { - var tally topoStatusTally - sizeGB := 0 - for _, m := range memGroup { - tally.add(classifyTopoSeverity(m.Status)) - if m.SizeMB != nil { - sizeGB += *m.SizeMB / 1024 - } - } - fill, stroke, text := topoSeverityColors(tally.worst()) - sublabel := "" - if sizeGB > 0 { - sublabel = fmt.Sprintf("%d GB total", sizeGB) - } - stackLayers := topoStackLayers(len(memGroup)) - boxes = append(boxes, topoBox{ - x: colX, y: y, w: topoBoxWidth, h: topoBoxHeight, - topoCardInfo: topoCardInfo{ - label: "Memory", sublabel: sublabel, count: len(memGroup), - statusLine: tally.line(), - fillVar: fill, strokeVar: stroke, textVar: text, - detailType: "memory", - }, - }) - if col < len(hw.CPUs) { - pcieEdges = append(pcieEdges, topoEdge{ - x1: colX + topoBoxWidth/2, y1: topoTopMargin + topoBoxHeight, - x2: colX + topoBoxWidth/2, y2: y, - color: "var(--ok-fg)", - }) - } - y += topoBoxHeight + topoDeviceGap + stackLayers*topoStackStep + g := ctrlSeen[ctrl] + if g == nil { + g = &ctrlGroup{bdf: ctrl} + ctrlSeen[ctrl] = g + diskCtrlGroups[col] = append(diskCtrlGroups[col], g) } + g.disks = append(g.disks, d) + } + // ── Layout ────────────────────────────────────────────────────────────── + // Each CPU socket is a tall vertical bar; everything attached to it + // branches off sideways as a vertical stack of boxes. Socket 0 sits on the + // left with its branches growing rightward, socket 1 on the right growing + // leftward (further sockets alternate sides, one row per pair). Disks hang + // off their storage-controller box as a further branch. An "Other" bar + // collects devices/disks with no resolvable socket. The figure grows + // downward, not sideways. + const ( + topoBarW = 118 + topoColGap = 46 + topoBranchW = 208 + topoSubW = 172 + topoBlockGap = 30 + topoMinBarH = 92 + topoMidGap = 72 + ) + + type topoBranch struct { + info topoCardInfo + edge string + subs []topoCardInfo // disk groups under a storage-controller branch + } + type topoBlock struct { + head topoCardInfo + branches []topoBranch + } + + // storageControllerBranch builds one branch for a controller and its disks. + storageControllerBranch := func(g *ctrlGroup) topoBranch { + label := "Storage ctrl" + if c := strings.TrimSpace(classByBDF[g.bdf]); c != "" { + label = strings.TrimSpace(strings.NewReplacer("Controller", "", "controller", "").Replace(c)) + if label == "" { + label = "Storage" + } + label += " ctrl" + } + sub := g.bdf + if m := strings.TrimSpace(modelByBDF[g.bdf]); m != "" && !strings.HasPrefix(m, "Device ") { + sub = m + } + var tally topoStatusTally + for _, d := range g.disks { + tally.add(classifyTopoSeverity(d.Status)) + } + fill, stroke, text := topoSeverityColors(tally.worst()) + return topoBranch{ + info: topoCardInfo{ + label: label, sublabel: sub, count: 1, + statusLine: fmt.Sprintf("%d disk(s)", len(g.disks)), + fillVar: fill, strokeVar: stroke, textVar: text, + detailType: "storage", + }, + edge: "var(--ok-fg)", + subs: buildStorageGroupCards(g.disks), + } + } + + // deviceBranches gathers the GPU/NIC/RAID branches for a given column + // (col == unknownCol for the "Other" bar). + deviceBranches := func(col int) []topoBranch { + var out []topoBranch for _, kind := range kindOrder { var group []placedDevice for _, p := range placed { @@ -235,7 +287,6 @@ func renderTopoMainDiagram(hw schema.HardwareSnapshot, exportDir string) string if len(group) == 0 { continue } - var tally topoStatusTally model := "" edgeColor := "var(--ok-fg)" @@ -256,39 +307,220 @@ func renderTopoMainDiagram(hw schema.HardwareSnapshot, exportDir string) string } } fill, stroke, text := topoSeverityColors(tally.worst()) - stackLayers := topoStackLayers(len(group)) - boxes = append(boxes, topoBox{ - x: colX, y: y, w: topoBoxWidth, h: topoBoxHeight, - topoCardInfo: topoCardInfo{ + out = append(out, topoBranch{ + info: topoCardInfo{ label: kindLabel[kind], sublabel: model, count: len(group), statusLine: tally.line(), fillVar: fill, strokeVar: stroke, textVar: text, detailType: kind, }, + edge: edgeColor, }) + } + return out + } - if col < len(hw.CPUs) { - pcieEdges = append(pcieEdges, topoEdge{ - x1: colX + topoBoxWidth/2, y1: topoTopMargin + topoBoxHeight, - x2: colX + topoBoxWidth/2, y2: y, - color: edgeColor, - }) + var blocks []topoBlock + for col := 0; col < numCols && col < len(hw.CPUs); col++ { + cpu := hw.CPUs[col] + model := "" + if cpu.Cores != nil && cpu.Threads != nil { + model = fmt.Sprintf("%dC / %dT", *cpu.Cores, *cpu.Threads) + } else if cpu.Model != nil { + model = cleanCPUModel(*cpu.Model) + } + socket := col + if cpu.Socket != nil { + socket = *cpu.Socket + } + var tally topoStatusTally + tally.add(classifyTopoSeverity(cpu.Status)) + fill, stroke, text := topoSeverityColors(tally.worst()) + blk := topoBlock{head: topoCardInfo{ + label: fmt.Sprintf("CPU %d", socket), sublabel: model, count: 1, + statusLine: tally.line(), + fillVar: fill, strokeVar: stroke, textVar: text, + detailType: "cpu", + }} + + // Memory first — wired straight to the socket's memory controller. + var memGroup []schema.HardwareMemory + for i, m := range hw.Memory { + if memMatched[i] && memCol[i] == col { + memGroup = append(memGroup, m) } + } + if len(memGroup) > 0 { + var mt topoStatusTally + sizeGB := 0 + for _, m := range memGroup { + mt.add(classifyTopoSeverity(m.Status)) + if m.SizeMB != nil { + sizeGB += *m.SizeMB / 1024 + } + } + mfill, mstroke, mtext := topoSeverityColors(mt.worst()) + sublabel := "" + if sizeGB > 0 { + sublabel = fmt.Sprintf("%d GB total", sizeGB) + } + blk.branches = append(blk.branches, topoBranch{ + info: topoCardInfo{ + label: "Memory", sublabel: sublabel, count: len(memGroup), + statusLine: mt.line(), + fillVar: mfill, strokeVar: mstroke, textVar: mtext, + detailType: "memory", + }, + edge: "var(--ok-fg)", + }) + } + blk.branches = append(blk.branches, deviceBranches(col)...) + for _, g := range diskCtrlGroups[col] { + blk.branches = append(blk.branches, storageControllerBranch(g)) + } + blocks = append(blocks, blk) + } - y += topoBoxHeight + topoDeviceGap + stackLayers*topoStackStep + // "Other" bar: unmatched PCIe devices, unmatched storage controllers, and + // disks with no controller/HCTL at all. + var otherBranches []topoBranch + otherBranches = append(otherBranches, deviceBranches(unknownCol)...) + for _, g := range diskCtrlGroups[-1] { + otherBranches = append(otherBranches, storageControllerBranch(g)) + } + if len(looseDisks) > 0 { + for _, ci := range buildStorageGroupCards(looseDisks) { + otherBranches = append(otherBranches, topoBranch{info: ci, edge: "var(--ok-fg)"}) + } + } + hasOther := len(otherBranches) > 0 + if hasOther { + fill, stroke, text := topoSeverityColors(0) + blocks = append(blocks, topoBlock{ + head: topoCardInfo{ + label: "Other", sublabel: "no socket affinity", count: 1, + fillVar: fill, strokeVar: stroke, textVar: text, + }, + branches: otherBranches, + }) + } + + var boxes []topoBox + var pcieEdges []topoEdge + + // side: 0 = left (branches grow right), 1 = right (branches grow left). + // CPU sockets alternate; the "Other" block is always left. + blockSide := func(i int) int { + if hasOther && i == len(blocks)-1 { + return 0 + } + return i % 2 + } + sideHasSubs := [2]bool{} + hasRight := false + for i, blk := range blocks { + s := blockSide(i) + if s == 1 { + hasRight = true + } + for _, br := range blk.branches { + if len(br.subs) > 0 { + sideHasSubs[s] = true + } } } - maxDeviceY := topoTopMargin + topoBoxHeight + topoDeviceGap*2 + reach := func(s int) int { + r := topoBarW + topoColGap + topoBranchW + if sideHasSubs[s] { + r += topoColGap + topoSubW + } + return r + } + leftReach := reach(0) + svgWidth := 24 + leftReach + 24 + if hasRight { + svgWidth = 24 + leftReach + topoMidGap + reach(1) + 24 + } + + leftBarX := 24 + leftBranchX := leftBarX + topoBarW + topoColGap + leftSubX := leftBranchX + topoBranchW + topoColGap + rightBarX := svgWidth - 24 - topoBarW + rightBranchX := rightBarX - topoColGap - topoBranchW + rightSubX := rightBranchX - topoColGap - topoSubW + + geom := func(s int) (barX, barLinkX, branchX, branchLinkX, subX int) { + if s == 0 { + return leftBarX, leftBarX + topoBarW, leftBranchX, leftBranchX + topoBranchW, leftSubX + } + return rightBarX, rightBarX, rightBranchX, rightBranchX, rightSubX + } + + // No root/board node — board identity lives in the Firmware row below. + // Each socket bar is an independent column with its own branch stack. + + // layoutBlock places one socket bar + its branch/sub boxes and returns the + // bar's bottom Y. + layoutBlock := func(blk topoBlock, s, barTop int) int { + barX, barLinkX, branchX, branchLinkX, subX := geom(s) + by := barTop + for _, br := range blk.branches { + midY := by + topoBoxHeight/2 + boxes = append(boxes, topoBox{x: branchX, y: by, w: topoBranchW, h: topoBoxHeight, topoCardInfo: br.info}) + pcieEdges = append(pcieEdges, topoEdge{x1: barLinkX, y1: midY, x2: branchLinkX, y2: midY, color: br.edge}) + advance := topoBoxHeight + topoDeviceGap + topoStackLayers(br.info.count)*topoStackStep + if len(br.subs) > 0 { + sy := by + for _, sc := range br.subs { + smid := sy + topoBoxHeight/2 + sLink := subX + if s == 1 { + sLink = subX + topoSubW + } + boxes = append(boxes, topoBox{x: subX, y: sy, w: topoSubW, h: topoBoxHeight, topoCardInfo: sc}) + pcieEdges = append(pcieEdges, topoEdge{x1: branchLinkX, y1: midY, x2: sLink, y2: smid, color: "var(--ok-fg)"}) + sy += topoBoxHeight + topoDeviceGap + topoStackLayers(sc.count)*topoStackStep + } + if sy-by > advance { + advance = sy - by + } + } + by += advance + } + barBottom := by - topoDeviceGap + if barBottom < barTop+topoMinBarH { + barBottom = barTop + topoMinBarH + } + boxes = append(boxes, topoBox{x: barX, y: barTop, w: topoBarW, h: barBottom - barTop, topoCardInfo: blk.head}) + return barBottom + } + + rowTop := topoTopMargin + i := 0 + for i < len(blocks) { + s := blockSide(i) + rowBottom := layoutBlock(blocks[i], s, rowTop) + next := i + 1 + if next < len(blocks) && blockSide(next) == 1 && s == 0 { + rb := layoutBlock(blocks[next], 1, rowTop) + if rb > rowBottom { + rowBottom = rb + } + next++ + } + rowTop = rowBottom + topoBlockGap + i = next + } + + svgHeight := topoTopMargin + topoMinBarH for _, box := range boxes { bottom := box.y + box.h + topoStackLayers(box.count)*topoStackStep - if bottom > maxDeviceY { - maxDeviceY = bottom + if bottom > svgHeight { + svgHeight = bottom } } - - svgHeight := maxDeviceY + 24 - svgWidth := totalCols*topoColWidth + 48 + svgHeight += 24 var b strings.Builder // Wrapped in its own horizontally-scrolling container (matching the @@ -432,6 +664,99 @@ func renderTopoFlexRow(title string, items []topoCardInfo) string { return b.String() } +// buildStorageGroupCards groups disks by media type ("SSD"/"HDD"/"NVMe", or +// "Disk" when Type is absent) into one card per type: label "SSD ×2", a +// " · total" sublabel, and the worst-of status line for +// that group. Order follows first appearance in hw.Storage. Every card is +// clickable through to the shared "storage" component-detail modal. +func buildStorageGroupCards(disks []schema.HardwareStorage) []topoCardInfo { + if len(disks) == 0 { + return nil + } + type diskGroup struct { + label string + tally topoStatusTally + count int + sizeGB int + } + var order []string + groups := map[string]*diskGroup{} + for _, d := range disks { + label := "Disk" + if d.Type != nil && strings.TrimSpace(*d.Type) != "" { + label = strings.TrimSpace(*d.Type) + } + g := groups[label] + if g == nil { + g = &diskGroup{label: label} + groups[label] = g + order = append(order, label) + } + g.count++ + g.tally.add(classifyTopoSeverity(d.Status)) + if d.SizeGB != nil { + g.sizeGB += *d.SizeGB + } + } + var cards []topoCardInfo + for _, label := range order { + g := groups[label] + fill, stroke, text := topoSeverityColors(g.tally.worst()) + // Capacity only — the model string is too long for an SVG node label + // and is one click away in the storage detail modal anyway. + sublabel := "" + if g.sizeGB > 0 { + if g.sizeGB >= 1024 { + sublabel = fmt.Sprintf("%.1f TB total", float64(g.sizeGB)/1024) + } else { + sublabel = fmt.Sprintf("%d GB total", g.sizeGB) + } + } + cards = append(cards, topoCardInfo{ + label: g.label, sublabel: sublabel, count: g.count, + statusLine: g.tally.line(), + fillVar: fill, strokeVar: stroke, textVar: text, + detailType: "storage", + }) + } + return cards +} + +// parseStorageControllerMap parses storage-controllers.txt +// (platform.StorageControllerMapScript output: one +// " hctl= ctrl=" line per disk) into a map from +// SCSI HCTL address (which matches schema.HardwareStorage.Slot) to the +// normalized PCI BDF of the controller the disk hangs off. Disks with an +// empty hctl (NVMe) are skipped — they have no HCTL Slot to join on. +func parseStorageControllerMap(raw string) map[string]string { + out := map[string]string{} + for _, line := range strings.Split(raw, "\n") { + var hctl, ctrl string + for _, f := range strings.Fields(line) { + if v, ok := strings.CutPrefix(f, "hctl="); ok { + hctl = v + } + if v, ok := strings.CutPrefix(f, "ctrl="); ok { + ctrl = normalizeTopoBDF(v) + } + } + if hctl != "" && ctrl != "" { + out[hctl] = ctrl + } + } + return out +} + +// cleanCPUModel trims the marketing noise ("(R)", "(TM)", "CPU", "Processor") +// out of a dmidecode CPU model string so it fits the narrow socket bar. +func cleanCPUModel(s string) string { + s = strings.NewReplacer( + "(R)", "", "(r)", "", "(TM)", "", "(tm)", "", + " CPU", "", " Processor", "", " processor", "", + ).Replace(s) + return strings.Join(strings.Fields(s), " ") +} + func writeTopoBoxSVG(b *strings.Builder, box topoBox) { onclick := "" cursor := "default" diff --git a/audit/internal/webui/page_topo_test.go b/audit/internal/webui/page_topo_test.go index fe16246..36ed1b2 100644 --- a/audit/internal/webui/page_topo_test.go +++ b/audit/internal/webui/page_topo_test.go @@ -152,6 +152,187 @@ func TestTopoPageRendersArbitraryPSUAndFirmwareCountsAsFlexRows(t *testing.T) { } } +func TestTopoPageRendersStorageDisksGroupedByType(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "audit.json") + + okStatus := "OK" + warnStatus := "WARNING" + ssd := "SSD" + nvme := "NVMe" + model := "SAMSUNG MZ7L3960HCJR-00B7C" + size := 960 + + ingest := schema.HardwareIngestRequest{ + CollectedAt: "2026-03-15T00:00:00Z", + Hardware: schema.HardwareSnapshot{ + Storage: []schema.HardwareStorage{ + {HardwareComponentStatus: schema.HardwareComponentStatus{Status: &okStatus}, Type: &ssd, Model: &model, SizeGB: &size}, + {HardwareComponentStatus: schema.HardwareComponentStatus{Status: &okStatus}, Type: &ssd, Model: &model, SizeGB: &size}, + {HardwareComponentStatus: schema.HardwareComponentStatus{Status: &warnStatus}, Type: &nvme, SizeGB: &size}, + }, + }, + } + data, err := json.Marshal(ingest) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0644); err != nil { + t.Fatal(err) + } + + handler := NewHandler(HandlerOptions{AuditPath: path}) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/topo", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d", rec.Code) + } + body := rec.Body.String() + + if !strings.Contains(body, "SSD ×2") { + t.Fatalf("topo page missing grouped SSD x2 card: %s", body) + } + if !strings.Contains(body, "NVMe") { + t.Fatalf("topo page missing NVMe disk card: %s", body) + } + // SSD card totals capacity across both disks. + if !strings.Contains(body, "1.9 TB total") { + t.Fatalf("topo page missing SSD total capacity: %s", body) + } + // The degraded NVMe disk must escalate that card's status. + nvmeIdx := strings.Index(body, ">NVMe<") + if nvmeIdx < 0 || !strings.Contains(body[nvmeIdx:nvmeIdx+400], "Warning") { + t.Fatalf("topo page missing NVMe warning status: %s", body) + } + // With no storage-controller techdump, disks have no resolvable socket and + // land under the "Other" bar rather than being glued to a CPU. + if !strings.Contains(body, ">Other<") { + t.Fatalf("topo page missing Other bar for unattached disks: %s", body) + } + clicks := strings.Count(body, `openComponentDetail('storage')`) + strings.Count(body, `openComponentDetail('storage')`) + if clicks != 2 { + t.Fatalf("expected one clickable card per disk-type group, got %d: %s", clicks, body) + } +} + +func TestParseStorageControllerMap(t *testing.T) { + raw := "sda hctl=2:0:0:0 ctrl=0000:00:17.0\n" + + "sdb hctl=3:0:0:0 ctrl=0000:00:17.0\n" + + "nvme0n1 hctl= ctrl=0000:65:00.0\n" + got := parseStorageControllerMap(raw) + if got["2:0:0:0"] != "0000:00:17.0" || got["3:0:0:0"] != "0000:00:17.0" { + t.Fatalf("SATA disks not mapped to controller: %#v", got) + } + if _, ok := got["nvme0n1"]; ok { + t.Fatalf("NVMe line with empty hctl must be skipped: %#v", got) + } +} + +func TestTopoPageParentsDisksUnderTheirControllerSocket(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "audit.json") + techdump := filepath.Join(dir, "techdump") + if err := os.MkdirAll(techdump, 0755); err != nil { + t.Fatal(err) + } + // Both SSDs hang off the SATA controller at 0000:00:17.0, which is a + // NUMA-node-0 PCIe device -> they must render under CPU 0, not "Other". + if err := os.WriteFile(filepath.Join(techdump, "storage-controllers.txt"), + []byte("sda hctl=2:0:0:0 ctrl=0000:00:17.0\nsdb hctl=3:0:0:0 ctrl=0000:00:17.0\n"), 0644); err != nil { + t.Fatal(err) + } + + socket0, socket1 := 0, 1 + numa0 := 0 + okStatus := "OK" + sataClass := "SATA controller" + sataBDF := "0000:00:17.0" + hctlA, hctlB := "2:0:0:0", "3:0:0:0" + ssd := "SSD" + size := 960 + + ingest := schema.HardwareIngestRequest{ + CollectedAt: "2026-03-15T00:00:00Z", + Hardware: schema.HardwareSnapshot{ + CPUs: []schema.HardwareCPU{ + {HardwareComponentStatus: schema.HardwareComponentStatus{Status: &okStatus}, Socket: &socket0}, + {HardwareComponentStatus: schema.HardwareComponentStatus{Status: &okStatus}, Socket: &socket1}, + }, + PCIeDevices: []schema.HardwarePCIeDevice{ + {HardwareComponentStatus: schema.HardwareComponentStatus{Status: &okStatus}, Slot: &sataBDF, DeviceClass: &sataClass, NUMANode: &numa0}, + }, + Storage: []schema.HardwareStorage{ + {HardwareComponentStatus: schema.HardwareComponentStatus{Status: &okStatus}, Slot: &hctlA, Type: &ssd, SizeGB: &size}, + {HardwareComponentStatus: schema.HardwareComponentStatus{Status: &okStatus}, Slot: &hctlB, Type: &ssd, SizeGB: &size}, + }, + }, + } + data, _ := json.Marshal(ingest) + if err := os.WriteFile(path, data, 0644); err != nil { + t.Fatal(err) + } + + handler := NewHandler(HandlerOptions{AuditPath: path, ExportDir: dir}) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/topo", nil)) + body := rec.Body.String() + + if strings.Contains(body, ">Other<") { + t.Fatalf("disks resolved to a socket — no Other bar expected: %s", body) + } + if !strings.Contains(body, "SATA ctrl") { + t.Fatalf("topo page missing the SATA controller branch node: %s", body) + } + if !strings.Contains(body, "SSD ×2") { + t.Fatalf("topo page missing SSD disk group under the controller: %s", body) + } + // controller branch must render before its SSD sub-node + if strings.Index(body, "SATA ctrl") > strings.Index(body, "SSD ×2") { + t.Fatalf("controller node should render before its disks: %s", body) + } +} + +func TestTopoPageRendersOneBarPerSocketNoRoot(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "audit.json") + + socket0, socket1 := 0, 1 + okStatus := "OK" + board := "AS-4125GS-TNRT" + vendor := "Supermicro" + + ingest := schema.HardwareIngestRequest{ + CollectedAt: "2026-03-15T00:00:00Z", + Hardware: schema.HardwareSnapshot{ + Board: schema.HardwareBoard{ProductName: &board, Manufacturer: &vendor}, + CPUs: []schema.HardwareCPU{ + {HardwareComponentStatus: schema.HardwareComponentStatus{Status: &okStatus}, Socket: &socket0}, + {HardwareComponentStatus: schema.HardwareComponentStatus{Status: &okStatus}, Socket: &socket1}, + }, + }, + } + data, _ := json.Marshal(ingest) + if err := os.WriteFile(path, data, 0644); err != nil { + t.Fatal(err) + } + + handler := NewHandler(HandlerOptions{AuditPath: path}) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/topo", nil)) + body := rec.Body.String() + + if !strings.Contains(body, ">CPU 0<") || !strings.Contains(body, ">CPU 1<") { + t.Fatalf("topo diagram missing a bar per CPU socket: %s", body) + } + // No board/root node in the diagram itself (board identity is the + // Firmware row lower down). + svg := body[strings.Index(body, "")] + if strings.Contains(svg, board) { + t.Fatalf("board node must not render inside the topology diagram: %s", svg) + } +} + func TestTopoPageLinkedFromNav(t *testing.T) { handler := NewHandler(HandlerOptions{}) rec := httptest.NewRecorder()