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"
|
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",
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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) {
|
func TestTopoPageLinkedFromNav(t *testing.T) {
|
||||||
handler := NewHandler(HandlerOptions{})
|
handler := NewHandler(HandlerOptions{})
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
|
|||||||
Reference in New Issue
Block a user