diff --git a/audit/internal/webui/page_topo.go b/audit/internal/webui/page_topo.go index 8e87d83..c004943 100644 --- a/audit/internal/webui/page_topo.go +++ b/audit/internal/webui/page_topo.go @@ -80,26 +80,6 @@ func isRAIDControllerClass(class string) bool { // Status / link-speed coloring // --------------------------------------------------------------------------- -// topoStatusBadgeClass maps a component's Status pointer to a badge class, -// treating a nil/absent status the same as literal "Unknown" (matches how -// the rest of the UI already renders missing status, per chipLetterClass/ -// runtimeStatusBadge in pages.go). -func topoStatusBadgeClass(status *string) (label, cls string) { - if status == nil { - return "?", "badge-unknown" - } - switch strings.ToUpper(strings.TrimSpace(*status)) { - case "OK": - return "OK", "badge-ok" - case "WARNING", "WARN", "PARTIAL": - return "WARN", "badge-warn" - case "CRITICAL", "FAIL", "FAILED", "ERROR": - return "CRIT", "badge-err" - default: - return "?", "badge-unknown" - } -} - // pcieGenRank ranks a PCIe generation label ("Gen3", "Gen4", ...) for // comparison. Mirrors collector.pcieLinkSpeedRank's ordering; duplicated // locally rather than exported, per the same "no collector import in webui" @@ -341,27 +321,166 @@ func normalizeTopoBDF(bdf string) string { return bdf } +// --------------------------------------------------------------------------- +// Card status aggregation +// +// Every card on this page — whether it represents one component (CPU 1) or a +// group of identical ones (GPU ×4) — is colored as a whole by its worst +// observed status, with a plain-text summary as the card's last line +// (e.g. "4 OK" or "3 OK, 1 Warning"). There is no separate status chip: a +// chip needs its own fill, and the SVG boxes previously colored that chip +// via CSS classes written for HTML (.badge-ok sets `background`/`color`, +// which do nothing on an SVG / — only `fill` does), so every +// chip rendered with the SVG default fill of solid black. Coloring the card +// itself uses real `fill:var(--ok-bg)` etc. declarations, which sidesteps +// that class entirely. +// --------------------------------------------------------------------------- + +// classifyTopoSeverity converts a component's Status pointer to a severity +// rank (0=unknown, 1=OK, 2=Warning, 3=Critical), treating nil/unrecognized +// the same as "Unknown" — matches topoStatusBadgeClass's classification. +func classifyTopoSeverity(status *string) int { + if status == nil { + return 0 + } + switch strings.ToUpper(strings.TrimSpace(*status)) { + case "OK": + return 1 + case "WARNING", "WARN", "PARTIAL": + return 2 + case "CRITICAL", "FAIL", "FAILED", "ERROR": + return 3 + default: + return 0 + } +} + +// topoSeverityColors returns the (fill, stroke, text) CSS var() triple a +// whole card is painted with for a given worst-observed severity. +func topoSeverityColors(sev int) (fill, stroke, text string) { + switch sev { + case 3: + return "var(--crit-bg)", "var(--crit-border)", "var(--crit-fg)" + case 2: + return "var(--warn-bg)", "#c9ba9b", "var(--warn-fg)" + case 1: + return "var(--ok-bg)", "#a3c293", "var(--ok-fg)" + default: + return "var(--surface-2)", "var(--border)", "var(--muted)" + } +} + +// topoStatusTally counts how many components in a group fall into each +// severity bucket, so a group card can report "3 OK, 1 Warning" rather than +// collapsing to a single worst-of value and losing the rest. +type topoStatusTally struct { + unknown, ok, warn, crit int +} + +func (t *topoStatusTally) add(sev int) { + switch sev { + case 3: + t.crit++ + case 2: + t.warn++ + case 1: + t.ok++ + default: + t.unknown++ + } +} + +func (t topoStatusTally) total() int { return t.unknown + t.ok + t.warn + t.crit } + +func (t topoStatusTally) worst() int { + switch { + case t.crit > 0: + return 3 + case t.warn > 0: + return 2 + case t.ok > 0: + return 1 + default: + return 0 + } +} + +// line renders the card's last-line status summary. +func (t topoStatusTally) line() string { + if t.total() == 0 { + return "No data" + } + if t.total() == 1 { + switch { + case t.crit > 0: + return "Critical" + case t.warn > 0: + return "Warning" + case t.ok > 0: + return "OK" + default: + return "Unknown" + } + } + var parts []string + if t.crit > 0 { + parts = append(parts, fmt.Sprintf("%d Critical", t.crit)) + } + if t.warn > 0 { + parts = append(parts, fmt.Sprintf("%d Warning", t.warn)) + } + if t.ok > 0 { + parts = append(parts, fmt.Sprintf("%d OK", t.ok)) + } + if t.unknown > 0 { + parts = append(parts, fmt.Sprintf("%d Unknown", t.unknown)) + } + return strings.Join(parts, ", ") +} + +// topoCardInfo is the shared visual content for one card, rendered either as +// an absolutely-positioned SVG box (main diagram) or an HTML flex item +// (Memory/Power Supplies rows) by the two writers below. +type topoCardInfo struct { + label string // e.g. "CPU 1", "GPU", "Power Supplies" + sublabel string // representative model/description, "" to omit + count int // components represented by this card; >1 draws a stack + statusLine string // last line of card text, e.g. "4 OK, 1 Warning" + fillVar string + strokeVar string + textVar string + detailType string // "" = not clickable +} + +// topoStackLayers returns how many faint backing cards to draw behind the +// front card to read as "a stack of N", capped at 2 — enough to signal +// "more than one" without the deck becoming its own visual clutter. +func topoStackLayers(count int) int { + if count <= 1 { + return 0 + } + if count-1 > 2 { + return 2 + } + return count - 1 +} + // --------------------------------------------------------------------------- // Main topology diagram // --------------------------------------------------------------------------- const ( - topoColWidth = 220 - topoBoxWidth = 190 - topoBoxHeight = 56 - topoDeviceGap = 14 - topoTopMargin = 30 - topoEdgeBand = 60 - topoBottomRowH = 90 + topoColWidth = 220 + topoBoxWidth = 190 + topoBoxHeight = 70 + topoDeviceGap = 14 + topoTopMargin = 30 + topoStackStep = 4 // px offset per backing layer in the card-stack effect ) type topoBox struct { x, y, w, h int - label string - sublabel string - badgeText string - badgeCls string - detailType string // "" = not clickable + topoCardInfo } func renderTopoMainDiagram(hw schema.HardwareSnapshot, exportDir string) string { @@ -421,26 +540,56 @@ func renderTopoMainDiagram(hw schema.HardwareSnapshot, exportDir string) string // 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), GPU-GPU edges are - // simply omitted. + // 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"} var boxes []topoBox var pcieEdges []struct { x1, y1, x2, y2 int color string } - gpuBoxCenter := map[int][2]int{} // gpu index -> (x, yBottom) - gpuBoxIndex := map[int]int{} // gpu index -> index into boxes - gpuNUMANode := map[int]*int{} // gpu index -> its PCIe device's numa_node for col := 0; col < totalCols; col++ { colX := (col+1)*24 + col*topoColWidth - if col < numCols { + if col < len(hw.CPUs) { cpu := hw.CPUs[col] model := "" if cpu.Model != nil { @@ -450,159 +599,216 @@ func renderTopoMainDiagram(hw schema.HardwareSnapshot, exportDir string) string if cpu.Socket != nil { socket = *cpu.Socket } - label, cls := topoStatusBadgeClass(cpu.Status) + var tally topoStatusTally + tally.add(classifyTopoSeverity(cpu.Status)) + fill, stroke, text := topoSeverityColors(tally.worst()) boxes = append(boxes, topoBox{ x: colX, y: topoTopMargin, w: topoBoxWidth, h: topoBoxHeight, - label: fmt.Sprintf("CPU %d", socket), sublabel: model, - badgeText: label, badgeCls: cls, detailType: "cpu", + topoCardInfo: topoCardInfo{ + label: fmt.Sprintf("CPU %d", socket), sublabel: model, count: 1, + statusLine: tally.line(), + fillVar: fill, strokeVar: stroke, textVar: text, + detailType: "cpu", + }, }) } y := topoTopMargin + topoBoxHeight + topoDeviceGap*2 - for _, p := range placed { - if p.col != col { + 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 } - label, cls := topoStatusBadgeClass(p.dev.Status) - model := "" - if p.dev.Model != nil { - model = *p.dev.Model - } - box := topoBox{ - x: colX, y: y, w: topoBoxWidth, h: topoBoxHeight, - label: strings.ToUpper(p.kind), sublabel: model, - badgeText: label, badgeCls: cls, detailType: p.kind, - } - boxes = append(boxes, box) - boxIdx := len(boxes) - 1 - if col < numCols { + 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 + } + 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()) + stackLayers := topoStackLayers(len(group)) + boxes = append(boxes, topoBox{ + x: colX, y: y, w: topoBoxWidth, h: topoBoxHeight, + topoCardInfo: topoCardInfo{ + label: kindLabel[kind], sublabel: model, count: len(group), + statusLine: tally.line(), + fillVar: fill, strokeVar: stroke, textVar: text, + detailType: kind, + }, + }) + + if col < len(hw.CPUs) { pcieEdges = append(pcieEdges, struct { x1, y1, x2, y2 int color string }{ x1: colX + topoBoxWidth/2, y1: topoTopMargin + topoBoxHeight, x2: colX + topoBoxWidth/2, y2: y, - color: topoEdgeColorVar(p.dev), + color: edgeColor, }) } - if p.kind == "gpu" && p.bdf != "" { - if idx, ok := bdfToIndex[p.bdf]; ok { - gpuBoxCenter[idx] = [2]int{colX + topoBoxWidth/2, y + topoBoxHeight} - gpuBoxIndex[idx] = boxIdx - gpuNUMANode[idx] = p.dev.NUMANode - } - } - - y += topoBoxHeight + topoDeviceGap + y += topoBoxHeight + topoDeviceGap + stackLayers*topoStackStep } } maxDeviceY := topoTopMargin + topoBoxHeight + topoDeviceGap*2 - for _, b := range boxes { - if b.y+b.h > maxDeviceY { - maxDeviceY = b.y + b.h + for _, box := range boxes { + bottom := box.y + box.h + topoStackLayers(box.count)*topoStackStep + if bottom > maxDeviceY { + maxDeviceY = bottom } } - // GPU-GPU NVLink edges: drawn as an elbow connector through a dedicated - // band below the device row, kept strictly separate from the vertical - // CPU->device PCIe edges above so neither visually obscures the other. - // - // A bonded pair spanning two different NUMA nodes is treated as an - // anomaly (not a neutral fact) per project decision: we expect a bonded - // pair to sit on one NUMA node, so a cross-NUMA bond is flagged Warning - // on the edge AND on both GPU boxes, regardless of their own SAT status. - bandY := maxDeviceY + topoEdgeBand/2 - var gpuEdgesSVG strings.Builder - for _, pair := range pairs { - c1, ok1 := gpuBoxCenter[pair.GPUA] - c2, ok2 := gpuBoxCenter[pair.GPUB] - if !ok1 || !ok2 { - continue - } - color := "var(--ok-fg)" - title := fmt.Sprintf("NVLink: GPU%d↔GPU%d (%d links)", pair.GPUA, pair.GPUB, pair.NVLinks) - numaA, numaB := gpuNUMANode[pair.GPUA], gpuNUMANode[pair.GPUB] - if numaA == nil || numaB == nil { - color = "var(--muted)" - } else if *numaA != *numaB { - color = "var(--warn-fg)" - title += " — spans NUMA nodes (unexpected)" - upgradeTopoBoxBadgeToWarn(boxes, gpuBoxIndex[pair.GPUA]) - upgradeTopoBoxBadgeToWarn(boxes, gpuBoxIndex[pair.GPUB]) - } - fmt.Fprintf(&gpuEdgesSVG, - `%s`+"\n", - c1[0], c1[1], c1[0], bandY, c2[0], bandY, c2[0], c2[1], color, html.EscapeString(title)) - } - - svgHeight := bandY + topoEdgeBand/2 + topoBottomRowH + svgHeight := maxDeviceY + 24 svgWidth := totalCols*topoColWidth + 48 var b strings.Builder - fmt.Fprintf(&b, ``+"\n", svgWidth, svgHeight, svgWidth, svgHeight) + // 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(`
`) + fmt.Fprintf(&b, ``+"\n", svgWidth, svgHeight, svgWidth, svgHeight) for _, e := range pcieEdges { fmt.Fprintf(&b, ``+"\n", e.x1, e.y1, e.x2, e.y2, e.color) } - b.WriteString(gpuEdgesSVG.String()) for _, box := range boxes { writeTopoBoxSVG(&b, box) } + b.WriteString(`
`) - // PSU / BMC row: standalone boxes, no connecting lines. - psuY := svgHeight - topoBottomRowH + 20 - psuX := 24 - for _, psu := range hw.PowerSupplies { - label, cls := topoStatusBadgeClass(psu.Status) - slot := "" - if psu.Slot != nil { - slot = *psu.Slot + // Memory, 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. + if len(hw.Memory) > 0 { + var tally topoStatusTally + for _, m := range hw.Memory { + tally.add(classifyTopoSeverity(m.Status)) } - watt := "" - if psu.WattageW != nil { - watt = fmt.Sprintf("%dW", *psu.WattageW) + fill, stroke, text := topoSeverityColors(tally.worst()) + sizeGB := 0 + for _, m := range hw.Memory { + if m.SizeMB != nil { + sizeGB += *m.SizeMB / 1024 + } } - writeTopoBoxSVG(&b, topoBox{ - x: psuX, y: psuY, w: 150, h: topoBoxHeight, - label: "PSU " + slot, sublabel: watt, - badgeText: label, badgeCls: cls, detailType: "psu", + sublabel := "" + if sizeGB > 0 { + sublabel = fmt.Sprintf("%d GB total", sizeGB) + } + b.WriteString(renderTopoFlexRow("Memory", []topoCardInfo{{ + label: "Memory", sublabel: sublabel, count: len(hw.Memory), + 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, }) - psuX += 150 + topoDeviceGap } - if bmcVersion, ok := findBMCFirmware(hw.Firmware); ok { - writeTopoBoxSVG(&b, topoBox{ - x: svgWidth - 200 - 24, y: psuY, w: 200, h: topoBoxHeight, - label: "BMC", sublabel: "fw " + bmcVersion, - badgeText: "?", badgeCls: "badge-unknown", - }) + 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", + }})) } - b.WriteString(``) return topoCard("Topology", b.String()) } -// upgradeTopoBoxBadgeToWarn upgrades a box's badge to Warning unless it is -// already at Critical severity (never downgrades a worse status). -func upgradeTopoBoxBadgeToWarn(boxes []topoBox, boxIdx int) { - if boxIdx < 0 || boxIdx >= len(boxes) { - return +// 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 "" } - if boxes[boxIdx].badgeCls == "badge-err" { - return - } - boxes[boxIdx].badgeText = "WARN" - boxes[boxIdx].badgeCls = "badge-warn" -} - -func findBMCFirmware(records []schema.HardwareFirmwareRecord) (string, bool) { - for _, rec := range records { - if strings.EqualFold(strings.TrimSpace(rec.DeviceName), "BMC") { - return rec.Version, true + var b strings.Builder + fmt.Fprintf(&b, `
%s
`, + html.EscapeString(title)) + b.WriteString(`
`) + 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, `
`, + stackLayers*topoStackStep, stackLayers*topoStackStep) + for i := stackLayers; i >= 1; i-- { + off := i * topoStackStep + fmt.Fprintf(&b, `
`, + off, off, item.fillVar, item.strokeVar) + } + fmt.Fprintf(&b, ``, + 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, `
%s
`, html.EscapeString(label)) + if item.sublabel != "" { + fmt.Fprintf(&b, `
%s
`, html.EscapeString(item.sublabel)) + } + if item.statusLine != "" { + fmt.Fprintf(&b, `
%s
`, html.EscapeString(item.statusLine)) + } + b.WriteString(`
`) } - return "", false + b.WriteString(``) + return b.String() } func writeTopoBoxSVG(b *strings.Builder, box topoBox) { @@ -613,18 +819,35 @@ func writeTopoBoxSVG(b *strings.Builder, box topoBox) { cursor = "pointer" } fmt.Fprintf(b, ``, onclick, cursor) - fmt.Fprintf(b, ``+"\n", - box.x, box.y, box.w, box.h) - fmt.Fprintf(b, `%s`+"\n", - box.x+10, box.y+20, html.EscapeString(box.label)) + + // 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, ``+"\n", + box.x+off, box.y+off, box.w, box.h, box.fillVar, box.strokeVar) + } + + fmt.Fprintf(b, ``+"\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, `%s`+"\n", + box.x+10, box.y+20, box.textVar, html.EscapeString(label)) if box.sublabel != "" { - fmt.Fprintf(b, `%s`+"\n", - box.x+10, box.y+36, html.EscapeString(truncateTopoLabel(box.sublabel, 26))) + fmt.Fprintf(b, `%s`+"\n", + box.x+10, box.y+36, box.textVar, html.EscapeString(truncateTopoLabel(box.sublabel, 26))) + } + if box.statusLine != "" { + fmt.Fprintf(b, `%s`+"\n", + box.x+10, box.y+box.h-10, box.textVar, html.EscapeString(box.statusLine)) } - fmt.Fprintf(b, ``+"\n", - box.x+box.w-54, box.y+box.h-26, box.badgeCls) - fmt.Fprintf(b, `%s`+"\n", - box.x+box.w-49, box.y+box.h-13, box.badgeCls, html.EscapeString(box.badgeText)) b.WriteString(`` + "\n") } diff --git a/audit/internal/webui/page_topo_test.go b/audit/internal/webui/page_topo_test.go index 8b5abac..5548e22 100644 --- a/audit/internal/webui/page_topo_test.go +++ b/audit/internal/webui/page_topo_test.go @@ -6,6 +6,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "strconv" "strings" "testing" @@ -84,6 +85,73 @@ func TestTopoPageRendersCPUAndDegradedPCIeLink(t *testing.T) { } } +func TestTopoPageRendersArbitraryPSUAndFirmwareCountsAsFlexRows(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "audit.json") + + okStatus := "OK" + watt := 3000 + + var psus []schema.HardwarePowerSupply + for i := 0; i < 6; i++ { + slot := strconv.Itoa(i) + psus = append(psus, schema.HardwarePowerSupply{ + HardwareComponentStatus: schema.HardwareComponentStatus{Status: &okStatus}, + Slot: &slot, + WattageW: &watt, + }) + } + + ingest := schema.HardwareIngestRequest{ + CollectedAt: "2026-03-15T00:00:00Z", + Hardware: schema.HardwareSnapshot{ + Firmware: []schema.HardwareFirmwareRecord{ + {DeviceName: "BIOS", Version: "2.1.0"}, + {DeviceName: "BMC", Version: "5.17.00"}, + }, + PowerSupplies: psus, + }, + } + data, err := json.Marshal(ingest) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0644); err != nil { + t.Fatal(err) + } + + handler := NewHandler(HandlerOptions{AuditPath: path}) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/topo", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d", rec.Code) + } + body := rec.Body.String() + + // Firmware row must render every record (BIOS + BMC, not just BMC). + if !strings.Contains(body, "BIOS") || !strings.Contains(body, "BMC") { + t.Fatalf("topo page missing BIOS/BMC firmware boxes: %s", body) + } + // All 6 PSUs must be represented, grouped into one stacked card with a + // count rather than 6 separate boxes. + if !strings.Contains(body, "Power Supplies ×6") { + t.Fatalf("topo page missing grouped Power Supplies x6 card: %s", body) + } + if strings.Count(body, `onclick="openComponentDetail('psu')"`) != 1 && + strings.Count(body, `onclick="openComponentDetail('psu')"`) != 1 { + t.Fatalf("expected exactly one clickable PSU group card, not one per PSU: %s", body) + } + // Firmware/PSU rows must be flex-wrap HTML (arbitrary count, no overlap), + // not absolutely-positioned SVG rects sharing fixed x/y coordinates. + if !strings.Contains(body, "flex-wrap:wrap") { + t.Fatalf("topo page missing flex-wrap layout for firmware/PSU rows: %s", body) + } + // Firmware row must come before the PSU row. + if strings.Index(body, "BIOS") > strings.Index(body, "Power Supplies") { + t.Fatalf("firmware row should render before PSU row: %s", body) + } +} + func TestTopoPageLinkedFromNav(t *testing.T) { handler := NewHandler(HandlerOptions{}) rec := httptest.NewRecorder() @@ -237,15 +305,90 @@ func TestIsNICDeviceClassDev(t *testing.T) { } } -func TestTopoStatusBadgeClassNilIsUnknown(t *testing.T) { - label, cls := topoStatusBadgeClass(nil) - if label != "?" || cls != "badge-unknown" { - t.Fatalf("nil status = (%q,%q) want (?, badge-unknown)", label, cls) +func TestClassifyTopoSeverityNilIsUnknown(t *testing.T) { + if sev := classifyTopoSeverity(nil); sev != 0 { + t.Fatalf("nil status severity=%d want 0 (unknown)", sev) } ok := "OK" - label, cls = topoStatusBadgeClass(&ok) - if label != "OK" || cls != "badge-ok" { - t.Fatalf("OK status = (%q,%q) want (OK, badge-ok)", label, cls) + if sev := classifyTopoSeverity(&ok); sev != 1 { + t.Fatalf("OK status severity=%d want 1", sev) + } + warn := "Warning" + if sev := classifyTopoSeverity(&warn); sev != 2 { + t.Fatalf("Warning status severity=%d want 2", sev) + } + crit := "Critical" + if sev := classifyTopoSeverity(&crit); sev != 3 { + t.Fatalf("Critical status severity=%d want 3", sev) + } +} + +func TestTopoStatusTallyLine(t *testing.T) { + var t1 topoStatusTally + t1.add(1) + if got := t1.line(); got != "OK" { + t.Fatalf("single-OK line=%q want %q", got, "OK") + } + var t2 topoStatusTally + t2.add(1) + t2.add(1) + t2.add(1) + t2.add(2) + if got := t2.line(); got != "1 Warning, 3 OK" { + t.Fatalf("mixed line=%q want %q", got, "1 Warning, 3 OK") + } +} + +func TestTopoMainDiagramGroupsSameKindSameColumnIntoOneStackedCard(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "audit.json") + + socket := 0 + numaNode := 0 + deviceClass := "VideoController" + model := "NVIDIA H100 80GB HBM3" + okStatus := "OK" + + var gpus []schema.HardwarePCIeDevice + for i := 0; i < 4; i++ { + gpus = append(gpus, schema.HardwarePCIeDevice{ + HardwareComponentStatus: schema.HardwareComponentStatus{Status: &okStatus}, + DeviceClass: &deviceClass, + Model: &model, + NUMANode: &numaNode, + }) + } + + ingest := schema.HardwareIngestRequest{ + CollectedAt: "2026-03-15T00:00:00Z", + Hardware: schema.HardwareSnapshot{ + CPUs: []schema.HardwareCPU{ + {HardwareComponentStatus: schema.HardwareComponentStatus{Status: &okStatus}, Socket: &socket}, + }, + PCIeDevices: gpus, + }, + } + data, err := json.Marshal(ingest) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0644); err != nil { + t.Fatal(err) + } + + handler := NewHandler(HandlerOptions{AuditPath: path}) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/topo", nil)) + body := rec.Body.String() + + if !strings.Contains(body, "GPU ×4") { + t.Fatalf("expected one grouped GPU x4 card, got: %s", body) + } + if strings.Count(body, "openComponentDetail('gpu')") != 1 { + t.Fatalf("expected exactly one clickable GPU card, not one per GPU: %s", body) + } + if !strings.Contains(body, "4 OK") { + t.Fatalf("expected group status line '4 OK': %s", body) } }