Each fan tile carries data-fan / data-ceil / data-measured and a small script polls /api/metrics/latest every 5s — the metrics collector's own sampling period, served from memory with no extra BMC call — updating each tile's spin rate, duty fill and tooltip in place. Polling faster would only re-read identical numbers; sampling the BMC faster would choke it under load. FanReading gains json tags for the endpoint. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019VHG21rgTUiR1G3qFHTVmN
1285 lines
44 KiB
Go
1285 lines
44 KiB
Go
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))
|
||
|
||
if len(hw.PowerSupplies) > 0 {
|
||
var tally topoStatusTally
|
||
watt := 0
|
||
for _, psu := range hw.PowerSupplies {
|
||
tally.add(classifyTopoSeverity(psu.Status))
|
||
if psu.WattageW != nil {
|
||
watt = *psu.WattageW
|
||
}
|
||
}
|
||
fill, stroke, text := topoSeverityColors(tally.worst())
|
||
sublabel := ""
|
||
if watt > 0 {
|
||
sublabel = fmt.Sprintf("%dW each", watt)
|
||
}
|
||
b.WriteString(renderTopoFlexRow("Power Supplies", []topoCardInfo{{
|
||
label: "Power Supplies", sublabel: sublabel, count: len(hw.PowerSupplies),
|
||
statusLine: tally.line(),
|
||
fillVar: fill, strokeVar: stroke, textVar: text,
|
||
detailType: "psu",
|
||
}}))
|
||
}
|
||
|
||
// Cooling fans — one small clickable square per fan (no PCIe/CPU affinity,
|
||
// arbitrary count, so a wrapping flex row like PSUs rather than SVG boxes).
|
||
// Square SIZE encodes the fan's ceiling RPM (its class); the coloured FILL
|
||
// rising from the bottom encodes live duty cycle (current / ceiling).
|
||
if fans := dedupeFansByName(hw.Sensors); 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()))
|
||
}
|
||
|
||
return topoCard("Topology", b.String())
|
||
}
|
||
|
||
// 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>`)
|
||
b.WriteString(topoFanLiveScript())
|
||
return b.String()
|
||
}
|
||
|
||
// topoFanLiveScript polls the already-collected live-metrics snapshot
|
||
// (/api/metrics/latest — served from memory, no BMC call) every 5s and
|
||
// updates each fan tile's spin rate, duty fill and tooltip in place. 5s is
|
||
// the metrics collector's own sampling period, so polling faster would only
|
||
// re-read identical numbers; the endpoint is a mutex read + small JSON, so
|
||
// this is cheap even with many viewers.
|
||
func topoFanLiveScript() string {
|
||
return `<script>(function(){
|
||
var tiles=document.querySelectorAll('.topo-fan-tile');
|
||
if(!tiles.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||!m.fans)return;
|
||
var by={};m.fans.forEach(function(f){by[f.name]=f.rpm;});
|
||
tiles.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');
|
||
});
|
||
}).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
|
||
}
|