Files
bee/audit/internal/webui/page_topo_diagram.go
T
Mikhail ChusavitinandClaude Sonnet 5 08044c81e7 fix(webui): don't show PSU load % until the observed peak clears idle draw
The passive collector records PSU draw at idle too, so right after boot
the "observed peak" is just idle wattage and a load % off it is
meaningless. Require the observed peak to sit >=25% above current draw
before using it as a load scale; until a real full-load run bumps it,
show plain watts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VHG21rgTUiR1G3qFHTVmN
2026-09-04 12:56:25 +03:00

1398 lines
48 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package webui
import (
"encoding/json"
"fmt"
"html"
"path/filepath"
"regexp"
"strconv"
"strings"
"bee/audit/internal/app"
"bee/audit/internal/platform"
"bee/audit/internal/schema"
)
func renderTopoMainDiagram(hw schema.HardwareSnapshot, exportDir string) string {
// A GPU hardware fault that needs a physical reboot (Xid 79 "fallen off
// the bus", Xid 154 "Node Reboot Required" — see gpuNeedsPhysicalReboot)
// isn't visible in dev.Status: the collector only sets that from PCIe
// link-speed checks, not from SAT/kmsg results. Without this, a GPU that
// dropped off the bus mid-test still renders green here even though the
// Hardware Summary card is showing a critical banner for it.
gpuHardwareFault := false
if db, err := app.OpenComponentStatusDB(filepath.Join(exportDir, "component-status.json")); err == nil {
if _, needsReboot := gpuNeedsPhysicalReboot(matchedRecords(db.All(), nil, []string{"pcie:gpu:"})); needsReboot {
gpuHardwareFault = true
}
}
socketIdx := buildSocketIndex(hw.CPUs)
numCols := len(hw.CPUs)
if numCols == 0 {
numCols = 1
}
unknownCol := numCols // extra trailing column for unmatched devices
// Group PCIe devices (GPU/NIC/RAID only — matches the mockup's node
// types) into columns by NUMA node, falling back to the "unknown" bucket.
type placedDevice struct {
dev schema.HardwarePCIeDevice
kind string // "gpu", "nic", "raid"
col int
bdf string
}
var placed []placedDevice
for _, dev := range hw.PCIeDevices {
kind := pcieDeviceKind(dev)
if kind == "" {
continue
}
col := unknownCol
if dev.NUMANode != nil {
if ci, ok := socketIdx[*dev.NUMANode]; ok {
col = ci
}
}
bdf := ""
if dev.Slot != nil {
bdf = normalizeTopoBDF(*dev.Slot)
} else if dev.BDF != nil {
bdf = normalizeTopoBDF(*dev.BDF)
}
placed = append(placed, placedDevice{dev: dev, kind: kind, col: col, bdf: bdf})
}
// GPU index<->BDF map + pairwise NVLink adjacency, read from the
// persisted techdump captured during the last audit cycle, best-effort:
// if the dump is missing (older audit, no NVIDIA GPUs), this is simply
// skipped. Used only to detect the cross-NUMA-bonded-pair anomaly below;
// the pairwise links themselves are drawn in the separate NVLink
// Topology card, since grouping same-kind/same-column devices into one
// stacked card here leaves no single per-GPU anchor point to draw a
// pairwise connector to or from.
bdfToIndex, _ := readNVIDIAIndexByBDF(exportDir)
var pairs []gpuPairLink
if topoMatrix, err := readGPUTopologyMatrix(exportDir); err == nil {
pairs = parseGPUPairAdjacency(topoMatrix)
}
gpuNUMAByIndex := map[int]*int{}
gpuBDFByIndex := map[int]string{}
for _, p := range placed {
if p.kind != "gpu" || p.bdf == "" {
continue
}
if idx, ok := bdfToIndex[p.bdf]; ok {
gpuNUMAByIndex[idx] = p.dev.NUMANode
gpuBDFByIndex[idx] = p.bdf
}
}
// A bonded pair spanning two different NUMA nodes is treated as an
// anomaly (not a neutral fact) per project decision: a bonded pair is
// expected to sit on one NUMA node, so a cross-NUMA bond escalates both
// GPUs' effective severity to at least Warning, regardless of their own
// reported SAT status.
crossNUMAWarnBDF := map[string]bool{}
for _, pair := range pairs {
numaA, okA := gpuNUMAByIndex[pair.GPUA]
numaB, okB := gpuNUMAByIndex[pair.GPUB]
if !okA || !okB || numaA == nil || numaB == nil || *numaA == *numaB {
continue
}
crossNUMAWarnBDF[gpuBDFByIndex[pair.GPUA]] = true
crossNUMAWarnBDF[gpuBDFByIndex[pair.GPUB]] = true
}
kindOrder := []string{"gpu", "nic", "raid"}
kindLabel := map[string]string{"gpu": "GPU", "nic": "NIC", "raid": "RAID"}
// Attach memory DIMMs to their CPU column too, the same way GPU/NIC/RAID
// PCIe devices are attached via NUMANode — memory has no NUMANode field
// in the schema, so this reads the DIMM's own Locator/Bank Locator
// strings instead (see dimmRawNode). DIMMs that can't be confidently
// attached fall back to the unattached "Memory" row below the diagram,
// same as before this existed.
memBankNodes := map[string]int{}
if raw, err := readTopoTechDump(exportDir, "dmidecode-type17.txt"); err == nil {
memBankNodes = parseDIMMBankLocatorNodes(raw)
}
memCol := make([]int, len(hw.Memory))
memMatched := make([]bool, len(hw.Memory))
var memRawNodes []int
for i, m := range hw.Memory {
if node, ok := dimmRawNode(m, memBankNodes); ok {
memCol[i] = node
memMatched[i] = true
memRawNodes = append(memRawNodes, node)
}
}
memColIdx := buildMemoryColumnIndex(memRawNodes)
for i := range hw.Memory {
if !memMatched[i] {
continue
}
col := memColIdx[memCol[i]]
if col >= numCols {
memMatched[i] = false
continue
}
memCol[i] = col
}
// Map each disk to the PCI function of the controller it hangs off, read
// from the persisted storage-controllers.txt techdump
// (platform.StorageControllerMapScript). Lets a disk be drawn as a branch
// of its real storage controller (SATA/AHCI, SAS HBA, RAID) — which is
// itself a NUMA-affine PCIe device under one CPU — instead of floating in
// a synthetic catch-all node.
ctrlByHCTL := map[string]string{}
if raw, err := readTopoTechDump(exportDir, "storage-controllers.txt"); err == nil {
ctrlByHCTL = parseStorageControllerMap(raw)
}
// NUMA node of each PCIe function, for joining a controller BDF to a CPU
// column (the controller itself is usually classed "SATA controller" /
// "Serial Attached SCSI controller" and so isn't in `placed`).
numaByBDF := map[string]int{}
classByBDF := map[string]string{}
modelByBDF := map[string]string{}
for _, dev := range hw.PCIeDevices {
bdf := ""
if dev.Slot != nil {
bdf = normalizeTopoBDF(*dev.Slot)
} else if dev.BDF != nil {
bdf = normalizeTopoBDF(*dev.BDF)
}
if bdf == "" {
continue
}
if dev.NUMANode != nil {
numaByBDF[bdf] = *dev.NUMANode
}
if dev.DeviceClass != nil {
classByBDF[bdf] = *dev.DeviceClass
}
if dev.Model != nil {
modelByBDF[bdf] = *dev.Model
}
}
// 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
for _, d := range g.disks {
tally.add(classifyTopoSeverity(d.Status))
}
fill, stroke, text := topoSeverityColors(tally.worst())
return topoBranch{
info: topoCardInfo{
label: label, sublabel: sub, count: 1,
statusLine: fmt.Sprintf("%d disk(s)", len(g.disks)),
fillVar: fill, strokeVar: stroke, textVar: text,
detailType: "storage",
},
edge: "var(--ok-fg)",
subs: buildStorageGroupCards(g.disks),
}
}
// deviceBranches gathers the GPU/NIC/RAID branches for a given column
// (col == unknownCol for the "Other" bar).
deviceBranches := func(col int) []topoBranch {
var out []topoBranch
for _, kind := range kindOrder {
var group []placedDevice
for _, p := range placed {
if p.col == col && p.kind == kind {
group = append(group, p)
}
}
if len(group) == 0 {
continue
}
var tally topoStatusTally
model := ""
edgeColor := "var(--ok-fg)"
for i, p := range group {
sev := classifyTopoSeverity(p.dev.Status)
if kind == "gpu" && crossNUMAWarnBDF[p.bdf] && sev < 2 {
sev = 2
}
if kind == "gpu" && gpuHardwareFault && sev < 3 {
sev = 3
}
tally.add(sev)
if i == 0 && p.dev.Model != nil {
model = *p.dev.Model
}
if topoEdgeColorVar(p.dev) == "var(--warn-fg)" {
edgeColor = "var(--warn-fg)"
}
}
fill, stroke, text := topoSeverityColors(tally.worst())
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
}
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,
})
}
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
}
}
}
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 > svgHeight {
svgHeight = bottom
}
}
svgHeight += 24
var b strings.Builder
// Wrapped in its own horizontally-scrolling container (matching the
// overflow-x:auto convention used for wide tables elsewhere in webui)
// rather than max-width:100% — squashing a node/edge diagram to fit a
// narrow viewport makes labels and badges illegible, whereas scrolling
// keeps the diagram readable at its natural size on any screen width.
b.WriteString(`<div style="overflow-x:auto">`)
fmt.Fprintf(&b, `<svg width="%d" height="%d" viewBox="0 0 %d %d">`+"\n", svgWidth, svgHeight, svgWidth, svgHeight)
for _, e := range pcieEdges {
fmt.Fprintf(&b, `<line x1="%d" y1="%d" x2="%d" y2="%d" style="stroke:%s;stroke-width:2"/>`+"\n", e.x1, e.y1, e.x2, e.y2, e.color)
}
for _, box := range boxes {
writeTopoBoxSVG(&b, box)
}
b.WriteString(`</svg></div>`)
// Firmware (BMC/BIOS/...) and PSUs have no PCIe/CPU affinity to anchor
// them to a column, and there can be an arbitrary number of any of them
// — so unlike the diagram above, they're plain flex-wrap HTML below the
// SVG rather than absolutely-positioned SVG boxes. A fixed-size SVG
// canvas has no way to wrap overflow onto a new row, which is exactly
// what caused these to pile up and overlap once a board had more
// PSUs/firmware records than fit in one fixed-width row.
//
// Memory DIMMs that were matched to a CPU column above already got a
// box in the SVG diagram; only DIMMs that couldn't be attached to a
// column (see memMatched above) fall back to this row.
var unmatchedMem []schema.HardwareMemory
for i, m := range hw.Memory {
if !memMatched[i] {
unmatchedMem = append(unmatchedMem, m)
}
}
if len(unmatchedMem) > 0 {
var tally topoStatusTally
for _, m := range unmatchedMem {
tally.add(classifyTopoSeverity(m.Status))
}
fill, stroke, text := topoSeverityColors(tally.worst())
sizeGB := 0
for _, m := range unmatchedMem {
if m.SizeMB != nil {
sizeGB += *m.SizeMB / 1024
}
}
sublabel := ""
if sizeGB > 0 {
sublabel = fmt.Sprintf("%d GB total", sizeGB)
}
b.WriteString(renderTopoFlexRow("Memory", []topoCardInfo{{
label: "Memory", sublabel: sublabel, count: len(unmatchedMem),
statusLine: tally.line(),
fillVar: fill, strokeVar: stroke, textVar: text,
detailType: "memory",
}}))
}
var firmwareItems []topoCardInfo
for _, rec := range hw.Firmware {
// Firmware records carry no per-item status in the schema (they are
// identity, not health, facts), so each stays a neutral, uncolored
// card rather than forcing a fake "Unknown" status line.
fillVar, strokeVar, textVar := topoSeverityColors(0)
firmwareItems = append(firmwareItems, topoCardInfo{
label: rec.DeviceName, sublabel: "fw " + rec.Version, count: 1,
fillVar: fillVar, strokeVar: strokeVar, textVar: textVar,
})
}
b.WriteString(renderTopoFlexRow("Firmware", firmwareItems))
hasPSU := len(hw.PowerSupplies) > 0
if hasPSU {
b.WriteString(renderTopoPSURow(hw.PowerSupplies, platform.ObservedPSUMaxW()))
}
// Cooling fans — one small clickable square per fan. Square SIZE encodes
// the fan's ceiling RPM (its class); the coloured FILL rising from the
// bottom encodes live duty cycle (current / ceiling).
fans := dedupeFansByName(hw.Sensors)
if len(fans) > 0 {
current := map[string]float64{}
for _, f := range fans {
if f.RPM != nil {
current[strings.TrimSpace(f.Name)] = float64(*f.RPM)
}
}
b.WriteString(renderTopoFanRow(fans, platform.ResolveFanMaxRPM(current), platform.ObservedFanMaxRPM()))
}
if hasPSU || len(fans) > 0 {
b.WriteString(topoLiveScript())
}
return topoCard("Topology", b.String())
}
// renderTopoPSURow renders the POWER SUPPLIES row: one card per PSU, coloured
// by that PSU's own status (a failed unit goes red on its own). The card shows
// input voltage and draw, and a load fill rising from the bottom — same idea
// as the fan duty-cycle fill. The load scale is the nameplate rating when the
// BMC reports it; otherwise it is the observed peak draw (observedMaxW, keyed
// by ordinal — the "autotune" recorded during any full-load run), and the
// figure is marked as an estimate.
func renderTopoPSURow(psus []schema.HardwarePowerSupply, observedMaxW map[string]float64) string {
var b strings.Builder
b.WriteString(topoRowHeading("Power Supplies"))
b.WriteString(`<div style="display:flex;flex-wrap:wrap;gap:8px">`)
for i, p := range psus {
sev := classifyTopoSeverity(p.Status)
_, stroke, text := topoSeverityColors(sev)
label := fmt.Sprintf("PSU %d", i)
if p.Slot != nil && strings.TrimSpace(*p.Slot) != "" {
label = strings.TrimSpace(*p.Slot)
}
draw, haveDraw := psuDrawW(p)
rating := 0
if p.WattageW != nil && *p.WattageW > 0 {
rating = *p.WattageW
}
// Load scale: true rating if known, else the observed peak draw — but
// only once that peak sits meaningfully above the current draw. Until
// a real full-load run has bumped it, the "peak" is just idle draw and
// a load % off it would be nonsense, so fall back to plain watts.
scaleMax := float64(rating)
scaleEstimate := false
if scaleMax <= 0 {
if m := observedMaxW[strconv.Itoa(i)]; m > 0 && (!haveDraw || m >= draw*1.25) {
scaleMax = m
scaleEstimate = true
}
}
var parts []string
if p.InputVoltage != nil && *p.InputVoltage > 0 {
parts = append(parts, fmt.Sprintf("%.0f V", *p.InputVoltage))
}
switch {
case haveDraw && scaleMax > 0 && scaleEstimate:
parts = append(parts, fmt.Sprintf("%.0f / ~%.0f W · ~%.0f%% load", draw, scaleMax, draw/scaleMax*100))
case haveDraw && scaleMax > 0:
parts = append(parts, fmt.Sprintf("%.0f / %d W · %.0f%% load", draw, rating, draw/scaleMax*100))
case haveDraw:
parts = append(parts, fmt.Sprintf("%.0f W", draw))
case rating > 0:
parts = append(parts, fmt.Sprintf("%d W rated", rating))
}
detail := strings.Join(parts, " · ")
statusWord := ""
if sev >= 2 {
statusWord = topoSeverityStatus(p.Status)
}
fillH := 0.0
if haveDraw && scaleMax > 0 {
fillH = draw / scaleMax * 100
if fillH < 0 {
fillH = 0
}
if fillH > 100 {
fillH = 100
}
}
voltAttr := ""
if p.InputVoltage != nil && *p.InputVoltage > 0 {
voltAttr = fmt.Sprintf("%.0f", *p.InputVoltage)
}
maxSrc := "rated"
if scaleEstimate {
maxSrc = "observed"
}
fmt.Fprintf(&b, `<div class="topo-psu-tile" data-psu="%d" data-psu-max="%.0f" data-psu-max-src="%s" data-psu-v="%s" onclick="openComponentDetail('psu')" `+
`style="position:relative;overflow:hidden;cursor:pointer;min-width:104px;padding:8px 11px;border-radius:6px;background:var(--surface-2);border:1px solid %s;color:%s">`,
i, scaleMax, maxSrc, voltAttr, stroke, text)
fmt.Fprintf(&b, `<div class="topo-psu-fill" style="position:absolute;left:0;right:0;bottom:0;height:%.0f%%;background:%s;opacity:.5;transition:height .8s linear"></div>`, fillH, stroke)
fmt.Fprintf(&b, `<div style="position:relative"><div style="font-size:13px;font-weight:700">%s</div>`, html.EscapeString(label))
fmt.Fprintf(&b, `<div class="topo-psu-detail" style="font-size:11px;opacity:.9;margin-top:2px">%s</div>`, html.EscapeString(detail))
if statusWord != "" {
fmt.Fprintf(&b, `<div style="font-size:10px;font-weight:700;margin-top:3px">%s</div>`, html.EscapeString(strings.ToUpper(statusWord)))
}
b.WriteString(`</div></div>`)
}
b.WriteString(`</div>`)
return b.String()
}
// psuDrawW returns the PSU's current power draw (measured output preferred,
// else measured input).
func psuDrawW(p schema.HardwarePowerSupply) (float64, bool) {
switch {
case p.OutputPowerW != nil && *p.OutputPowerW > 0:
return *p.OutputPowerW, true
case p.InputPowerW != nil && *p.InputPowerW > 0:
return *p.InputPowerW, true
default:
return 0, false
}
}
// renderTopoFanRow renders the COOLING row. ceilByName (from
// platform.ResolveFanMaxRPM) has a value for every fan and drives tile size.
// observedByName (from platform.ObservedFanMaxRPM) holds only ceilings that
// were actually measured under load — a fan present there gets a duty-cycle
// fill; one that isn't shows no fill (ceiling not measured yet).
func renderTopoFanRow(fans []schema.HardwareFanSensor, ceilByName, observedByName map[string]float64) string {
const (
fanTileMin = 34 // px, the smallest-ceiling fan
fanTileMax = 60 // px, the largest-ceiling fan
)
ceilMax := 0.0
for _, v := range ceilByName {
if v > ceilMax {
ceilMax = v
}
}
var b strings.Builder
b.WriteString(topoRowHeading("Cooling"))
b.WriteString(topoFanSpinStyle())
b.WriteString(`<div style="display:flex;flex-wrap:wrap;gap:6px;align-items:flex-end">`)
for _, f := range fans {
name := strings.TrimSpace(f.Name)
_, stroke, text := topoSeverityColors(classifyTopoSeverity(f.Status))
ceil := ceilByName[name]
sizeRatio := 1.0
if ceilMax > 0 && ceil > 0 {
sizeRatio = ceil / ceilMax
}
side := fanTileMin + int(float64(fanTileMax-fanTileMin)*sizeRatio+0.5)
glyphSz := side * 7 / 16
// Duty cycle: only when the ceiling was actually measured under load.
duty := -1.0
if _, measured := observedByName[name]; measured && ceil > 0 && f.RPM != nil {
duty = float64(*f.RPM) / ceil * 100
if duty < 0 {
duty = 0
}
if duty > 100 {
duty = 100
}
}
title := name
switch {
case f.RPM == nil:
title = name + " · no reading"
case duty >= 0:
title = fmt.Sprintf("%s · %d RPM · %.0f%% duty (ceiling %d)", name, *f.RPM, duty, int(ceil))
default:
title = fmt.Sprintf("%s · %d RPM · ceiling not measured — run Fan Ceiling Check", name, *f.RPM)
}
glyph := fmt.Sprintf(`<svg width="%d" height="%d" viewBox="0 0 24 24" fill="currentColor" style="opacity:.35" aria-hidden="true">`, glyphSz, glyphSz) + topoFanGlyphPaths() + `</svg>`
if f.RPM != nil && *f.RPM > 0 {
period := fanSpinPeriodSec(float64(*f.RPM))
glyph = fmt.Sprintf(`<svg class="topo-fan-spin" style="animation-duration:%.2fs" width="%d" height="%d" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">`,
period, glyphSz, glyphSz) + topoFanGlyphPaths() + `</svg>`
}
measured := 0
fillH := 0.0
if duty >= 0 {
measured = 1
fillH = duty
}
fillBar := fmt.Sprintf(`<div class="topo-fan-fill" style="position:absolute;left:0;right:0;bottom:0;height:%.0f%%;background:%s;opacity:.55;transition:height .8s linear"></div>`, fillH, stroke)
fmt.Fprintf(&b, `<div class="topo-fan-tile" data-fan="%s" data-ceil="%d" data-measured="%d" title="%s" onclick="openComponentDetail('fan')" `+
`style="position:relative;overflow:hidden;width:%dpx;height:%dpx;display:flex;align-items:center;justify-content:center;`+
`border-radius:5px;background:var(--surface-2);border:1px solid %s;color:%s;cursor:pointer">`+
`%s<span style="position:relative;display:flex">%s</span></div>`,
html.EscapeString(name), int(ceil), measured, html.EscapeString(title),
side, side, stroke, text, fillBar, glyph)
}
b.WriteString(`</div>`)
return b.String()
}
// topoLiveScript polls the already-collected live-metrics snapshot
// (/api/metrics/latest — served from memory, no BMC call) every 5s and
// refreshes the fan tiles (spin rate, duty fill, tooltip) and PSU tiles
// (wattage) in place. 5s is the metrics collector's own sampling period, so
// polling faster only re-reads identical numbers; the endpoint is a mutex
// read + small JSON, so this stays cheap with many viewers.
func topoLiveScript() string {
return `<script>(function(){
var fans=document.querySelectorAll('.topo-fan-tile');
var psus=document.querySelectorAll('.topo-psu-tile');
if(!fans.length&&!psus.length)return;
function period(rpm){var lo=1000,hi=13000,slow=2.2,fast=0.35;
if(rpm<=lo)return slow;if(rpm>=hi)return fast;
return slow+(rpm-lo)/(hi-lo)*(fast-slow);}
function tick(){
fetch('/api/metrics/latest',{cache:'no-store'}).then(function(r){return r.json();}).then(function(m){
if(!m)return;
if(m.fans){
var by={};m.fans.forEach(function(f){by[f.name]=f.rpm;});
fans.forEach(function(t){
var rpm=by[t.dataset.fan];if(rpm==null)return;
var ceil=parseFloat(t.dataset.ceil)||0;
var svg=t.querySelector('.topo-fan-spin');
if(svg&&rpm>0)svg.style.animationDuration=period(rpm).toFixed(2)+'s';
var meas=t.dataset.measured==='1'&&ceil>0;
var duty=meas?Math.max(0,Math.min(100,rpm/ceil*100)):-1;
if(meas){var fill=t.querySelector('.topo-fan-fill');if(fill)fill.style.height=duty.toFixed(0)+'%';}
t.title=t.dataset.fan+' · '+Math.round(rpm)+' RPM'+(meas?' · '+Math.round(duty)+'% duty (ceiling '+ceil+')':' · ceiling not measured — run Fan Ceiling Check');
});
}
if(m.psus){
psus.forEach(function(t){
var p=m.psus[parseInt(t.dataset.psu,10)];if(!p)return;
var w=p.power_w||0,max=parseFloat(t.dataset.psuMax)||0,v=t.dataset.psuV;
var est=t.dataset.psuMaxSrc==='observed';
var parts=[];
if(v)parts.push(v+' V');
if(w>0&&max>0){
var pct=Math.round(w/max*100);
parts.push(est?Math.round(w)+' / ~'+Math.round(max)+' W · ~'+pct+'% load'
:Math.round(w)+' / '+Math.round(max)+' W · '+pct+'% load');
}else if(w>0)parts.push(Math.round(w)+' W');
else if(max>0)parts.push(Math.round(max)+' W rated');
var d=t.querySelector('.topo-psu-detail');if(d&&parts.length)d.textContent=parts.join(' · ');
if(w>0&&max>0){var f=t.querySelector('.topo-psu-fill');if(f)f.style.height=Math.max(0,Math.min(100,w/max*100)).toFixed(0)+'%';}
});
}
}).catch(function(){});
}
setInterval(tick,5000);tick();
})();</script>`
}
// fanSpinPeriodSec maps an absolute fan RPM to a CSS animation period (one
// full turn of the glyph, in seconds). The real period would be 60/RPM — a
// blur at any real fan speed — so it is compressed into a band the eye can
// actually read: at/below fanSpinRPMLo the glyph turns at its slowest still
// clearly-moving rate, at/above fanSpinRPMHi at the fastest rate past which
// faster is indistinguishable (and starts to stutter), linear in between.
func fanSpinPeriodSec(rpm float64) float64 {
const (
fanSpinRPMLo = 1000.0
fanSpinRPMHi = 13000.0
fanSpinSlowSec = 2.2
fanSpinFastSec = 0.35
)
switch {
case rpm <= fanSpinRPMLo:
return fanSpinSlowSec
case rpm >= fanSpinRPMHi:
return fanSpinFastSec
default:
t := (rpm - fanSpinRPMLo) / (fanSpinRPMHi - fanSpinRPMLo)
return fanSpinSlowSec + t*(fanSpinFastSec-fanSpinSlowSec)
}
}
// topoFanSpinStyle emits the keyframes + base class for the spinning fan
// glyph once per row. A repeated identical <style> is harmless.
func topoFanSpinStyle() string {
return `<style>@keyframes topoFanSpin{to{transform:rotate(360deg)}}` +
`.topo-fan-spin{transform-box:fill-box;transform-origin:center;` +
`animation-name:topoFanSpin;animation-timing-function:linear;animation-iteration-count:infinite}` +
`@media (prefers-reduced-motion:reduce){.topo-fan-spin{animation:none}}</style>`
}
// topoFanGlyphPaths is the fan-blade drawing shared by every fan square,
// designed on a 24×24 viewBox.
func topoFanGlyphPaths() string {
return `<ellipse cx="12" cy="6.5" rx="3.1" ry="5.2"/>` +
`<ellipse cx="12" cy="6.5" rx="3.1" ry="5.2" transform="rotate(120 12 12)"/>` +
`<ellipse cx="12" cy="6.5" rx="3.1" ry="5.2" transform="rotate(240 12 12)"/>` +
`<circle cx="12" cy="12" r="2.3"/>`
}
// dedupeFansByName returns the fan sensors from a snapshot with duplicate
// names collapsed to their first occurrence, matching the ingest contract's
// "(sensor_type, name) — first wins" rule and skipping unnamed sensors.
func dedupeFansByName(sensors *schema.HardwareSensors) []schema.HardwareFanSensor {
if sensors == nil {
return nil
}
seen := map[string]bool{}
var out []schema.HardwareFanSensor
for _, f := range sensors.Fans {
name := strings.TrimSpace(f.Name)
if name == "" || seen[name] {
continue
}
seen[name] = true
out = append(out, f)
}
return out
}
// topoRowHeading renders the small uppercase section label shared by the
// flex rows below the SVG diagram (Firmware / Power Supplies / Cooling / ...).
func topoRowHeading(title string) string {
return fmt.Sprintf(`<div style="font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin:16px 0 6px">%s</div>`,
html.EscapeString(title))
}
// renderTopoFlexRow renders a labeled, wrapping row of component cards.
// Returns "" if items is empty (e.g. no PSU data in this audit).
func renderTopoFlexRow(title string, items []topoCardInfo) string {
if len(items) == 0 {
return ""
}
var b strings.Builder
b.WriteString(topoRowHeading(title))
b.WriteString(`<div style="display:flex;flex-wrap:wrap;gap:10px">`)
for _, item := range items {
onclick := ""
cursor := "default"
if item.detailType != "" {
onclick = fmt.Sprintf(` onclick="openComponentDetail('%s')"`, item.detailType)
cursor = "pointer"
}
stackLayers := topoStackLayers(item.count)
// Extra right/bottom padding on the wrapper reserves room for the
// backing layers of the stack effect so they aren't clipped by the
// flex container.
fmt.Fprintf(&b, `<div style="position:relative;padding-right:%dpx;padding-bottom:%dpx">`,
stackLayers*topoStackStep, stackLayers*topoStackStep)
for i := stackLayers; i >= 1; i-- {
off := i * topoStackStep
fmt.Fprintf(&b, `<div style="position:absolute;top:%dpx;left:%dpx;right:0;bottom:0;border-radius:6px;background:%s;border:1px solid %s;opacity:.55"></div>`,
off, off, item.fillVar, item.strokeVar)
}
fmt.Fprintf(&b, `<div%s style="position:relative;cursor:%s;min-width:160px;padding:10px 12px;border-radius:6px;background:%s;border:1px solid %s;color:%s">`,
onclick, cursor, item.fillVar, item.strokeVar, item.textVar)
label := item.label
if item.count > 1 {
label = fmt.Sprintf("%s ×%d", item.label, item.count)
}
fmt.Fprintf(&b, `<div style="font-size:13px;font-weight:700">%s</div>`, html.EscapeString(label))
if item.sublabel != "" {
fmt.Fprintf(&b, `<div style="font-size:11px;opacity:.85">%s</div>`, html.EscapeString(item.sublabel))
}
if item.statusLine != "" {
fmt.Fprintf(&b, `<div style="font-size:11px;font-weight:600;margin-top:4px">%s</div>`, html.EscapeString(item.statusLine))
}
b.WriteString(`</div></div>`)
}
b.WriteString(`</div>`)
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"
if box.detailType != "" {
onclick = fmt.Sprintf(` onclick="openComponentDetail('%s')"`, box.detailType)
cursor = "pointer"
}
fmt.Fprintf(b, `<g%s style="cursor:%s">`, onclick, cursor)
// Stack-of-cards effect: faint offset rects behind the front card when
// this box represents more than one physical component (e.g. 4 GPUs in
// one NUMA column), so a group reads as "a deck of N" rather than a
// single item. Peeks toward the bottom-right, into space already
// reserved between this box and the next one in the column.
for i := topoStackLayers(box.count); i >= 1; i-- {
off := i * topoStackStep
fmt.Fprintf(b, `<rect x="%d" y="%d" width="%d" height="%d" rx="6" ry="6" style="fill:%s;stroke:%s;opacity:.55"/>`+"\n",
box.x+off, box.y+off, box.w, box.h, box.fillVar, box.strokeVar)
}
fmt.Fprintf(b, `<rect x="%d" y="%d" width="%d" height="%d" rx="6" ry="6" style="fill:%s;stroke:%s"/>`+"\n",
box.x, box.y, box.w, box.h, box.fillVar, box.strokeVar)
label := box.label
if box.count > 1 {
label = fmt.Sprintf("%s ×%d", box.label, box.count)
}
fmt.Fprintf(b, `<text x="%d" y="%d" style="fill:%s;font-size:13px;font-weight:700">%s</text>`+"\n",
box.x+10, box.y+20, box.textVar, html.EscapeString(label))
if box.sublabel != "" {
fmt.Fprintf(b, `<text x="%d" y="%d" style="fill:%s;font-size:11px;opacity:.85">%s</text>`+"\n",
box.x+10, box.y+36, box.textVar, html.EscapeString(truncateTopoLabel(box.sublabel, 26)))
}
if box.statusLine != "" {
fmt.Fprintf(b, `<text x="%d" y="%d" style="fill:%s;font-size:10px;font-weight:600">%s</text>`+"\n",
box.x+10, box.y+box.h-10, box.textVar, html.EscapeString(box.statusLine))
}
b.WriteString(`</g>` + "\n")
}
func truncateTopoLabel(s string, max int) string {
if len(s) <= max {
return s
}
if max <= 1 {
return s[:max]
}
return s[:max-1] + "…"
}
// ---------------------------------------------------------------------------
// Separate NVLink topology card (read from techdump, not written to any
// ingest contract)
// ---------------------------------------------------------------------------
type topoNVLinkPort struct {
Index int
Active bool
SpeedGBs *float64
ReplayErrors int64
RecoveryErrors int64
CRCErrors int64
}
var (
topoNVLinkGPUHeaderRe = regexp.MustCompile(`^GPU (\d+):`)
topoNVLinkSpeedLineRe = regexp.MustCompile(`^Link (\d+):\s*([\d.]+)\s*GB/s`)
topoNVLinkInactiveRe = regexp.MustCompile(`^Link (\d+):\s*<inactive>`)
)
func readTopoNVLinkStatus(exportDir string) (map[int][]topoNVLinkPort, error) {
raw, err := readTopoTechDump(exportDir, "nvidia-smi-nvlink-status.txt")
if err != nil {
return nil, err
}
return parseTopoNVLinkStatus(raw), nil
}
func parseTopoNVLinkStatus(raw string) map[int][]topoNVLinkPort {
result := map[int][]topoNVLinkPort{}
currentGPU := -1
for _, line := range strings.Split(raw, "\n") {
trimmed := strings.TrimSpace(line)
if m := topoNVLinkGPUHeaderRe.FindStringSubmatch(trimmed); m != nil {
currentGPU, _ = strconv.Atoi(m[1])
continue
}
if currentGPU < 0 {
continue
}
if m := topoNVLinkInactiveRe.FindStringSubmatch(trimmed); m != nil {
idx, _ := strconv.Atoi(m[1])
result[currentGPU] = append(result[currentGPU], topoNVLinkPort{Index: idx, Active: false})
continue
}
if m := topoNVLinkSpeedLineRe.FindStringSubmatch(trimmed); m != nil {
idx, _ := strconv.Atoi(m[1])
port := topoNVLinkPort{Index: idx, Active: true}
if speed, err := strconv.ParseFloat(m[2], 64); err == nil {
port.SpeedGBs = &speed
}
result[currentGPU] = append(result[currentGPU], port)
}
}
return result
}
func readTopoNVLinkErrors(exportDir string) (map[int]map[int][3]int64, error) {
raw, err := readTopoTechDump(exportDir, "nvidia-smi-nvlink-errors.txt")
if err != nil {
return nil, err
}
return parseTopoNVLinkErrors(raw), nil
}
// parseTopoNVLinkErrors returns, per GPU then link index, [replay, recovery, crc].
func parseTopoNVLinkErrors(raw string) map[int]map[int][3]int64 {
return platform.ParseNvidiaNVLinkErrors(raw)
}
// renderTopoNVLinkCard renders the separate NVLink topology card. Returns ""
// if there are fewer than 2 NVIDIA GPUs, or the nvidia-smi nvlink techdump
// wasn't captured (older audit, or nvidia-smi unavailable on that run).
func renderTopoNVLinkCard(hw schema.HardwareSnapshot, exportDir string) string {
gpuCount := 0
for _, dev := range hw.PCIeDevices {
if dev.DeviceClass != nil && isGPUDeviceClass(*dev.DeviceClass) {
gpuCount++
}
}
if gpuCount < 2 {
return ""
}
status, err := readTopoNVLinkStatus(exportDir)
if err != nil || len(status) == 0 {
return topoCard("NVLink Topology", `<span class="badge badge-unknown">nvidia-smi nvlink data unavailable</span>`)
}
errors, _ := readTopoNVLinkErrors(exportDir)
topoMatrix, _ := readGPUTopologyMatrix(exportDir)
pairs := parseGPUPairAdjacency(topoMatrix)
var bodyB strings.Builder
if gpuCount <= 4 && len(pairs) > 0 {
// Small GPU count: per-pair box+line with per-link detail.
for _, pair := range pairs {
activeCount, total, hasError := 0, 0, false
for _, port := range status[pair.GPUA] {
total++
if port.Active {
activeCount++
}
}
for _, counters := range errors[pair.GPUA] {
if counters[0] != 0 || counters[1] != 0 || counters[2] != 0 {
hasError = true
}
}
color := "var(--ok-fg)"
switch {
case hasError:
color = "var(--crit-fg)"
case total > 0 && activeCount < total:
color = "var(--warn-fg)"
}
fmt.Fprintf(&bodyB, `<div style="display:flex;align-items:center;gap:12px;margin-bottom:10px">`+
`<div style="padding:8px 12px;border:1px solid var(--border);border-radius:6px">GPU %d</div>`+
`<div style="flex:1;height:2px;background:%s"></div>`+
`<div style="padding:8px 12px;border:1px solid var(--border);border-radius:6px">GPU %d</div>`+
`<div style="font-size:12px;color:var(--muted)">%d/%d links active%s</div>`+
`</div>`,
pair.GPUA, color, pair.GPUB, activeCount, total, errNoteSuffix(hasError))
}
} else if len(pairs) > 0 {
// Larger GPU counts (NVSwitch fabric): aggregate pair table instead of
// an unreadable all-to-all graph.
bodyB.WriteString(`<table><thead><tr><th>GPU A</th><th>GPU B</th><th>NVLinks</th></tr></thead><tbody>`)
for _, pair := range pairs {
fmt.Fprintf(&bodyB, `<tr><td>GPU %d</td><td>GPU %d</td><td>%d</td></tr>`, pair.GPUA, pair.GPUB, pair.NVLinks)
}
bodyB.WriteString(`</tbody></table>`)
} else {
bodyB.WriteString(`<span class="badge badge-unknown">No NVLink-bonded GPU pairs found</span>`)
}
return topoCard("NVLink Topology", bodyB.String())
}
func errNoteSuffix(hasError bool) string {
if hasError {
return " — errors detected"
}
return ""
}
// ---------------------------------------------------------------------------
// Inventory fallback for the component-detail modal
//
// handleAPIComponentDetail normally sources records from app.ComponentStatusDB,
// which only gains entries once something has actually written a status
// observation (SAT run, watchdog tick, ...). On a freshly booted host that
// hasn't run SAT yet, StatusDB can be entirely empty for a component type even
// though the /topo card for it already shows "N OK" — that card reads
// schema.HardwareComponentStatus.Status straight from the audit snapshot.
// inventoryFallbackRecords bridges that gap by building synthetic records
// from the same snapshot/classifiers the topology card uses, so the two
// views never disagree about how many devices exist or their status.
// ---------------------------------------------------------------------------
// topoSeverityStatus renders classifyTopoSeverity's rank back into the status
// string vocabulary renderComponentDetail/chipLetterClass expect ("OK",
// "Warning", "Critical", "Unknown") — kept in lockstep with classifyTopoSeverity
// so a device the topo card counts as "OK" is never shown here as "Unknown".
func topoSeverityStatus(status *string) string {
switch classifyTopoSeverity(status) {
case 3:
return "Critical"
case 2:
return "Warning"
case 1:
return "OK"
default:
return "Unknown"
}
}
// pcieDeviceKind classifies a PCIe device the same way renderTopoMainDiagram
// does, returning "" for devices that aren't GPU/NIC/RAID.
func pcieDeviceKind(dev schema.HardwarePCIeDevice) string {
switch {
case dev.DeviceClass != nil && isGPUDeviceClass(*dev.DeviceClass):
return "gpu"
case isNICDeviceClassDev(dev):
return "nic"
case dev.DeviceClass != nil && isRAIDControllerClass(*dev.DeviceClass):
return "raid"
default:
return ""
}
}
// pcieDeviceKey builds a stable, human-readable component key for a PCIe
// device: "<kind>:<bdf>" when a slot/BDF is known, else "<kind>:<index>".
func pcieDeviceKey(kind string, index int, dev schema.HardwarePCIeDevice) string {
bdf := ""
if dev.Slot != nil {
bdf = normalizeTopoBDF(*dev.Slot)
} else if dev.BDF != nil {
bdf = normalizeTopoBDF(*dev.BDF)
}
if bdf != "" {
return kind + ":" + bdf
}
return fmt.Sprintf("%s:%d", kind, index)
}
// inventoryFallbackRecords builds ComponentStatusRecord entries straight from
// the audit inventory (bee-audit.json) for the given component type, used
// when ComponentStatusDB has no matching records yet. Records carry only
// ComponentKey/Status — no LastCheckedAt/History — so renderComponentDetail
// renders them without a "checked at" timestamp or sparkline.
func inventoryFallbackRecords(compType string, opts HandlerOptions) []app.ComponentStatusRecord {
data, err := loadSnapshot(opts.AuditPath)
if err != nil {
return nil
}
var ingest schema.HardwareIngestRequest
if err := json.Unmarshal(data, &ingest); err != nil {
return nil
}
hw := ingest.Hardware
var records []app.ComponentStatusRecord
switch compType {
case "cpu":
for i, cpu := range hw.CPUs {
key := fmt.Sprintf("cpu:%d", i)
if cpu.Socket != nil {
key = fmt.Sprintf("cpu:socket%d", *cpu.Socket)
}
records = append(records, app.ComponentStatusRecord{ComponentKey: key, Status: topoSeverityStatus(cpu.Status)})
}
case "memory":
for i, m := range hw.Memory {
key := fmt.Sprintf("memory:%d", i)
if m.Slot != nil && strings.TrimSpace(*m.Slot) != "" {
key = "memory:" + strings.TrimSpace(*m.Slot)
}
records = append(records, app.ComponentStatusRecord{ComponentKey: key, Status: topoSeverityStatus(m.Status)})
}
case "storage":
for i, s := range hw.Storage {
key := fmt.Sprintf("storage:%d", i)
if s.Slot != nil && strings.TrimSpace(*s.Slot) != "" {
key = "storage:" + strings.TrimSpace(*s.Slot)
}
records = append(records, app.ComponentStatusRecord{ComponentKey: key, Status: topoSeverityStatus(s.Status)})
}
case "psu":
for i, p := range hw.PowerSupplies {
key := fmt.Sprintf("psu:%d", i)
if p.Slot != nil && strings.TrimSpace(*p.Slot) != "" {
key = "psu:" + strings.TrimSpace(*p.Slot)
}
records = append(records, app.ComponentStatusRecord{ComponentKey: key, Status: topoSeverityStatus(p.Status)})
}
case "fan":
for i, f := range dedupeFansByName(hw.Sensors) {
name := strings.TrimSpace(f.Name)
key := fmt.Sprintf("fan:%d", i)
if name != "" {
key = "fan:" + name
}
records = append(records, app.ComponentStatusRecord{ComponentKey: key, Status: topoSeverityStatus(f.Status)})
}
case "gpu", "nic", "raid":
for i, dev := range hw.PCIeDevices {
if pcieDeviceKind(dev) != compType {
continue
}
key := pcieDeviceKey(compType, i, dev)
records = append(records, app.ComponentStatusRecord{ComponentKey: key, Status: topoSeverityStatus(dev.Status)})
}
}
return records
}