feat(topo): per-socket bar layout with disks under their controller

Rework the /topo diagram from side-by-side stacked cards into one tall
vertical bar per CPU socket with everything attached to it branching off
sideways (socket 0 left/branches right, socket 1 right/branches left).

Disks are now parented under the storage controller they physically hang
off (SATA/AHCI, SAS HBA, RAID) — itself a NUMA-affine PCIe device under one
socket — instead of a synthetic catch-all node. The disk->controller link
is read from a new storage-controllers.txt techdump
(platform.StorageControllerMapScript, a /sys/block walk); disks with no
resolvable controller fall back to an "Other" bar. No board/root node.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYjrG6bVmeG1Z2Wmc8kg3o
This commit is contained in:
Mikhail Chusavitin
2026-09-03 12:15:49 +03:00
co-authored by Claude Sonnet 5
parent b1ab866f58
commit 319f08ac4b
5 changed files with 634 additions and 100 deletions
+2 -1
View File
@@ -22,7 +22,8 @@ func techdumpBucketFor(name string) string {
return "cpu" return "cpu"
case name == "dmidecode-type17.txt": case name == "dmidecode-type17.txt":
return "memory" 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-"): strings.HasPrefix(name, "smartctl-"), strings.HasPrefix(name, "nvme-"):
return "storage" return "storage"
case name == "nvidia-smi-q.txt", name == "nvidia-smi-query.csv", name == "nvidia-smi-conf-compute-q.txt", case name == "nvidia-smi-q.txt", name == "nvidia-smi-query.csv", name == "nvidia-smi-conf-compute-q.txt",
+29
View File
@@ -23,6 +23,7 @@ var techDumpFixedCommands = []struct {
{Name: "lspci", Args: []string{"-vvv"}, File: "lspci-vvv.txt"}, {Name: "lspci", Args: []string{"-vvv"}, File: "lspci-vvv.txt"},
{Name: "lscpu", Args: nil, File: "lscpu.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: "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: "sensors", Args: []string{"-j"}, File: "sensors.json"},
{Name: "ipmitool", Args: []string{"fru", "print"}, File: "ipmitool-fru.txt"}, {Name: "ipmitool", Args: []string{"fru", "print"}, File: "ipmitool-fru.txt"},
{Name: "ipmitool", Args: []string{"sdr"}, File: "ipmitool-sdr.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 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=<h:c:t:l> ctrl=<dddd:bb:dd.f>" 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 — // KernelAERNvidiaScript filters dmesg for PCIe AER, NVRM, and Xid lines —
// cheap (dmesg is already in memory) and the fastest way to tell a real // cheap (dmesg is already in memory) and the fastest way to tell a real
// PCIe/GPU hardware fault from power-management noise. // PCIe/GPU hardware fault from power-management noise.
-2
View File
@@ -477,8 +477,6 @@ func topoStackLayers(count int) int {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const ( const (
topoColWidth = 220
topoBoxWidth = 190
topoBoxHeight = 70 topoBoxHeight = 70
topoDeviceGap = 14 topoDeviceGap = 14
topoTopMargin = 30 topoTopMargin = 30
+422 -97
View File
@@ -63,17 +63,6 @@ func renderTopoMainDiagram(hw schema.HardwareSnapshot, exportDir string) string
} }
placed = append(placed, placedDevice{dev: dev, kind: kind, col: col, bdf: bdf}) 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 // GPU index<->BDF map + pairwise NVLink adjacency, read from the
// persisted techdump captured during the last audit cycle, best-effort: // 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 memCol[i] = col
} }
var boxes []topoBox // Map each disk to the PCI function of the controller it hangs off, read
var pcieEdges []topoEdge // from the persisted storage-controllers.txt techdump
// (platform.StorageControllerMapScript). Lets a disk be drawn as a branch
for col := 0; col < totalCols; col++ { // of its real storage controller (SATA/AHCI, SAS HBA, RAID) — which is
colX := (col+1)*24 + col*topoColWidth // itself a NUMA-affine PCIe device under one CPU — instead of floating in
if col < len(hw.CPUs) { // a synthetic catch-all node.
cpu := hw.CPUs[col] ctrlByHCTL := map[string]string{}
model := "" if raw, err := readTopoTechDump(exportDir, "storage-controllers.txt"); err == nil {
if cpu.Model != nil { ctrlByHCTL = parseStorageControllerMap(raw)
model = *cpu.Model }
} // NUMA node of each PCIe function, for joining a controller BDF to a CPU
socket := col // column (the controller itself is usually classed "SATA controller" /
if cpu.Socket != nil { // "Serial Attached SCSI controller" and so isn't in `placed`).
socket = *cpu.Socket numaByBDF := map[string]int{}
} classByBDF := map[string]string{}
var tally topoStatusTally modelByBDF := map[string]string{}
tally.add(classifyTopoSeverity(cpu.Status)) for _, dev := range hw.PCIeDevices {
fill, stroke, text := topoSeverityColors(tally.worst()) bdf := ""
boxes = append(boxes, topoBox{ if dev.Slot != nil {
x: colX, y: topoTopMargin, w: topoBoxWidth, h: topoBoxHeight, bdf = normalizeTopoBDF(*dev.Slot)
topoCardInfo: topoCardInfo{ } else if dev.BDF != nil {
label: fmt.Sprintf("CPU %d", socket), sublabel: model, count: 1, bdf = normalizeTopoBDF(*dev.BDF)
statusLine: tally.line(),
fillVar: fill, strokeVar: stroke, textVar: text,
detailType: "cpu",
},
})
} }
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 // diskCtrlGroups[cpuCol][ctrlBDF] = disks on that controller (cpuCol == -1
// for controllers with no resolvable NUMA/CPU — rendered under "Other").
// Memory goes first in the chain, directly under the CPU box: DIMMs type ctrlGroup struct {
// are wired straight to the socket's memory controller, not reached bdf string
// over PCIe like the GPU/NIC/RAID chain below it. disks []schema.HardwareStorage
var memGroup []schema.HardwareMemory }
for i, m := range hw.Memory { diskCtrlGroups := map[int][]*ctrlGroup{}
if memMatched[i] && memCol[i] == col { var looseDisks []schema.HardwareStorage // no HCTL / no controller match at all
memGroup = append(memGroup, m) 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 { g := ctrlSeen[ctrl]
var tally topoStatusTally if g == nil {
sizeGB := 0 g = &ctrlGroup{bdf: ctrl}
for _, m := range memGroup { ctrlSeen[ctrl] = g
tally.add(classifyTopoSeverity(m.Status)) diskCtrlGroups[col] = append(diskCtrlGroups[col], g)
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.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 { for _, kind := range kindOrder {
var group []placedDevice var group []placedDevice
for _, p := range placed { for _, p := range placed {
@@ -235,7 +287,6 @@ func renderTopoMainDiagram(hw schema.HardwareSnapshot, exportDir string) string
if len(group) == 0 { if len(group) == 0 {
continue continue
} }
var tally topoStatusTally var tally topoStatusTally
model := "" model := ""
edgeColor := "var(--ok-fg)" edgeColor := "var(--ok-fg)"
@@ -256,39 +307,220 @@ func renderTopoMainDiagram(hw schema.HardwareSnapshot, exportDir string) string
} }
} }
fill, stroke, text := topoSeverityColors(tally.worst()) fill, stroke, text := topoSeverityColors(tally.worst())
stackLayers := topoStackLayers(len(group)) out = append(out, topoBranch{
boxes = append(boxes, topoBox{ info: topoCardInfo{
x: colX, y: y, w: topoBoxWidth, h: topoBoxHeight,
topoCardInfo: topoCardInfo{
label: kindLabel[kind], sublabel: model, count: len(group), label: kindLabel[kind], sublabel: model, count: len(group),
statusLine: tally.line(), statusLine: tally.line(),
fillVar: fill, strokeVar: stroke, textVar: text, fillVar: fill, strokeVar: stroke, textVar: text,
detailType: kind, detailType: kind,
}, },
edge: edgeColor,
}) })
}
return out
}
if col < len(hw.CPUs) { var blocks []topoBlock
pcieEdges = append(pcieEdges, topoEdge{ for col := 0; col < numCols && col < len(hw.CPUs); col++ {
x1: colX + topoBoxWidth/2, y1: topoTopMargin + topoBoxHeight, cpu := hw.CPUs[col]
x2: colX + topoBoxWidth/2, y2: y, model := ""
color: edgeColor, 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 { for _, box := range boxes {
bottom := box.y + box.h + topoStackLayers(box.count)*topoStackStep bottom := box.y + box.h + topoStackLayers(box.count)*topoStackStep
if bottom > maxDeviceY { if bottom > svgHeight {
maxDeviceY = bottom svgHeight = bottom
} }
} }
svgHeight += 24
svgHeight := maxDeviceY + 24
svgWidth := totalCols*topoColWidth + 48
var b strings.Builder var b strings.Builder
// Wrapped in its own horizontally-scrolling container (matching the // Wrapped in its own horizontally-scrolling container (matching the
@@ -432,6 +664,99 @@ func renderTopoFlexRow(title string, items []topoCardInfo) string {
return b.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
// "<model> · <capacity> 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
// "<name> hctl=<h:c:t:l> ctrl=<dddd:bb:dd.f>" 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) { func writeTopoBoxSVG(b *strings.Builder, box topoBox) {
onclick := "" onclick := ""
cursor := "default" cursor := "default"
+181
View File
@@ -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(&#39;storage&#39;)`)
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, "<svg"):]
svg = svg[:strings.Index(svg, "</svg>")]
if strings.Contains(svg, board) {
t.Fatalf("board node must not render inside the topology diagram: %s", svg)
}
}
func TestTopoPageLinkedFromNav(t *testing.T) { func TestTopoPageLinkedFromNav(t *testing.T) {
handler := NewHandler(HandlerOptions{}) handler := NewHandler(HandlerOptions{})
rec := httptest.NewRecorder() rec := httptest.NewRecorder()