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:
co-authored by
Claude Sonnet 5
parent
b1ab866f58
commit
319f08ac4b
@@ -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",
|
||||
|
||||
@@ -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=<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 —
|
||||
// cheap (dmesg is already in memory) and the fastest way to tell a real
|
||||
// PCIe/GPU hardware fault from power-management noise.
|
||||
|
||||
@@ -477,8 +477,6 @@ func topoStackLayers(count int) int {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const (
|
||||
topoColWidth = 220
|
||||
topoBoxWidth = 190
|
||||
topoBoxHeight = 70
|
||||
topoDeviceGap = 14
|
||||
topoTopMargin = 30
|
||||
|
||||
@@ -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
|
||||
// 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)
|
||||
}
|
||||
socket := col
|
||||
if cpu.Socket != nil {
|
||||
socket = *cpu.Socket
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
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
|
||||
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",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
for _, d := range g.disks {
|
||||
tally.add(classifyTopoSeverity(d.Status))
|
||||
}
|
||||
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(),
|
||||
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: "memory",
|
||||
detailType: "storage",
|
||||
},
|
||||
})
|
||||
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)",
|
||||
})
|
||||
edge: "var(--ok-fg)",
|
||||
subs: buildStorageGroupCards(g.disks),
|
||||
}
|
||||
y += topoBoxHeight + topoDeviceGap + stackLayers*topoStackStep
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// "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,
|
||||
})
|
||||
}
|
||||
|
||||
y += topoBoxHeight + topoDeviceGap + stackLayers*topoStackStep
|
||||
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
|
||||
// "<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) {
|
||||
onclick := ""
|
||||
cursor := "default"
|
||||
|
||||
@@ -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, "<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) {
|
||||
handler := NewHandler(HandlerOptions{})
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
Reference in New Issue
Block a user