webui/topo: responsive PSU/firmware layout, stacked group cards, fix zero-CPU crash
PSU and firmware (BMC/BIOS) boxes were absolutely-positioned SVG rects in a
single fixed-width row with no wrap, so an arbitrary/larger count piled up
and overlapped once a board had more of either than fit in that row. Move
them to plain flex-wrap HTML below the diagram (Firmware row first, then
Power Supplies), which reflows naturally for any count. The main CPU/PCIe/
GPU diagram now scrolls horizontally (overflow-x:auto) instead of being
squashed to fit narrow viewports, matching the wide-table convention used
elsewhere in webui.
Also fixes a crash: renderTopoMainDiagram forced numCols to 1 for layout
purposes when a snapshot has zero CPUs, then unconditionally indexed
hw.CPUs[0], panicking the whole /topo page on any audit without CPU data.
Same-kind/same-column components (e.g. 4 GPUs in one NUMA node) now render
as one stacked card summarizing worst-case status plus a tally line ("3 OK,
1 Warning") instead of one box per component, and card severity coloring
uses real fill/stroke vars instead of HTML badge classes that don't apply
any style to SVG shapes.
This commit is contained in:
+377
-154
@@ -80,26 +80,6 @@ func isRAIDControllerClass(class string) bool {
|
|||||||
// Status / link-speed coloring
|
// 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
|
// pcieGenRank ranks a PCIe generation label ("Gen3", "Gen4", ...) for
|
||||||
// comparison. Mirrors collector.pcieLinkSpeedRank's ordering; duplicated
|
// comparison. Mirrors collector.pcieLinkSpeedRank's ordering; duplicated
|
||||||
// locally rather than exported, per the same "no collector import in webui"
|
// locally rather than exported, per the same "no collector import in webui"
|
||||||
@@ -341,27 +321,166 @@ func normalizeTopoBDF(bdf string) string {
|
|||||||
return bdf
|
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 <rect>/<text> — 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
|
// Main topology diagram
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const (
|
const (
|
||||||
topoColWidth = 220
|
topoColWidth = 220
|
||||||
topoBoxWidth = 190
|
topoBoxWidth = 190
|
||||||
topoBoxHeight = 56
|
topoBoxHeight = 70
|
||||||
topoDeviceGap = 14
|
topoDeviceGap = 14
|
||||||
topoTopMargin = 30
|
topoTopMargin = 30
|
||||||
topoEdgeBand = 60
|
topoStackStep = 4 // px offset per backing layer in the card-stack effect
|
||||||
topoBottomRowH = 90
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type topoBox struct {
|
type topoBox struct {
|
||||||
x, y, w, h int
|
x, y, w, h int
|
||||||
label string
|
topoCardInfo
|
||||||
sublabel string
|
|
||||||
badgeText string
|
|
||||||
badgeCls string
|
|
||||||
detailType string // "" = not clickable
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderTopoMainDiagram(hw schema.HardwareSnapshot, exportDir string) string {
|
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
|
// GPU index<->BDF map + pairwise NVLink adjacency, read from the
|
||||||
// persisted techdump captured during the last audit cycle, best-effort:
|
// persisted techdump captured during the last audit cycle, best-effort:
|
||||||
// if the dump is missing (older audit, no NVIDIA GPUs), GPU-GPU edges are
|
// if the dump is missing (older audit, no NVIDIA GPUs), this is simply
|
||||||
// simply omitted.
|
// 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)
|
bdfToIndex, _ := readNVIDIAIndexByBDF(exportDir)
|
||||||
var pairs []gpuPairLink
|
var pairs []gpuPairLink
|
||||||
if topoMatrix, err := readGPUTopologyMatrix(exportDir); err == nil {
|
if topoMatrix, err := readGPUTopologyMatrix(exportDir); err == nil {
|
||||||
pairs = parseGPUPairAdjacency(topoMatrix)
|
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 boxes []topoBox
|
||||||
var pcieEdges []struct {
|
var pcieEdges []struct {
|
||||||
x1, y1, x2, y2 int
|
x1, y1, x2, y2 int
|
||||||
color string
|
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++ {
|
for col := 0; col < totalCols; col++ {
|
||||||
colX := (col+1)*24 + col*topoColWidth
|
colX := (col+1)*24 + col*topoColWidth
|
||||||
if col < numCols {
|
if col < len(hw.CPUs) {
|
||||||
cpu := hw.CPUs[col]
|
cpu := hw.CPUs[col]
|
||||||
model := ""
|
model := ""
|
||||||
if cpu.Model != nil {
|
if cpu.Model != nil {
|
||||||
@@ -450,159 +599,216 @@ func renderTopoMainDiagram(hw schema.HardwareSnapshot, exportDir string) string
|
|||||||
if cpu.Socket != nil {
|
if cpu.Socket != nil {
|
||||||
socket = *cpu.Socket
|
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{
|
boxes = append(boxes, topoBox{
|
||||||
x: colX, y: topoTopMargin, w: topoBoxWidth, h: topoBoxHeight,
|
x: colX, y: topoTopMargin, w: topoBoxWidth, h: topoBoxHeight,
|
||||||
label: fmt.Sprintf("CPU %d", socket), sublabel: model,
|
topoCardInfo: topoCardInfo{
|
||||||
badgeText: label, badgeCls: cls, detailType: "cpu",
|
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
|
y := topoTopMargin + topoBoxHeight + topoDeviceGap*2
|
||||||
for _, p := range placed {
|
for _, kind := range kindOrder {
|
||||||
if p.col != col {
|
var group []placedDevice
|
||||||
|
for _, p := range placed {
|
||||||
|
if p.col == col && p.kind == kind {
|
||||||
|
group = append(group, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(group) == 0 {
|
||||||
continue
|
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 {
|
pcieEdges = append(pcieEdges, struct {
|
||||||
x1, y1, x2, y2 int
|
x1, y1, x2, y2 int
|
||||||
color string
|
color string
|
||||||
}{
|
}{
|
||||||
x1: colX + topoBoxWidth/2, y1: topoTopMargin + topoBoxHeight,
|
x1: colX + topoBoxWidth/2, y1: topoTopMargin + topoBoxHeight,
|
||||||
x2: colX + topoBoxWidth/2, y2: y,
|
x2: colX + topoBoxWidth/2, y2: y,
|
||||||
color: topoEdgeColorVar(p.dev),
|
color: edgeColor,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if p.kind == "gpu" && p.bdf != "" {
|
y += topoBoxHeight + topoDeviceGap + stackLayers*topoStackStep
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
maxDeviceY := topoTopMargin + topoBoxHeight + topoDeviceGap*2
|
maxDeviceY := topoTopMargin + topoBoxHeight + topoDeviceGap*2
|
||||||
for _, b := range boxes {
|
for _, box := range boxes {
|
||||||
if b.y+b.h > maxDeviceY {
|
bottom := box.y + box.h + topoStackLayers(box.count)*topoStackStep
|
||||||
maxDeviceY = b.y + b.h
|
if bottom > maxDeviceY {
|
||||||
|
maxDeviceY = bottom
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GPU-GPU NVLink edges: drawn as an elbow connector through a dedicated
|
svgHeight := maxDeviceY + 24
|
||||||
// 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,
|
|
||||||
`<path d="M %d %d L %d %d L %d %d L %d %d" style="fill:none;stroke:%s;stroke-width:2;stroke-dasharray:4,3"><title>%s</title></path>`+"\n",
|
|
||||||
c1[0], c1[1], c1[0], bandY, c2[0], bandY, c2[0], c2[1], color, html.EscapeString(title))
|
|
||||||
}
|
|
||||||
|
|
||||||
svgHeight := bandY + topoEdgeBand/2 + topoBottomRowH
|
|
||||||
svgWidth := totalCols*topoColWidth + 48
|
svgWidth := totalCols*topoColWidth + 48
|
||||||
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
fmt.Fprintf(&b, `<svg width="%d" height="%d" viewBox="0 0 %d %d" style="max-width:100%%">`+"\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(`<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 {
|
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)
|
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)
|
||||||
}
|
}
|
||||||
b.WriteString(gpuEdgesSVG.String())
|
|
||||||
for _, box := range boxes {
|
for _, box := range boxes {
|
||||||
writeTopoBoxSVG(&b, box)
|
writeTopoBoxSVG(&b, box)
|
||||||
}
|
}
|
||||||
|
b.WriteString(`</svg></div>`)
|
||||||
|
|
||||||
// PSU / BMC row: standalone boxes, no connecting lines.
|
// Memory, Firmware (BMC/BIOS/...) and PSUs have no PCIe/CPU affinity to
|
||||||
psuY := svgHeight - topoBottomRowH + 20
|
// anchor them to a column, and there can be an arbitrary number of any
|
||||||
psuX := 24
|
// of them — so unlike the diagram above, they're plain flex-wrap HTML
|
||||||
for _, psu := range hw.PowerSupplies {
|
// below the SVG rather than absolutely-positioned SVG boxes. A
|
||||||
label, cls := topoStatusBadgeClass(psu.Status)
|
// fixed-size SVG canvas has no way to wrap overflow onto a new row,
|
||||||
slot := ""
|
// which is exactly what caused these to pile up and overlap once a
|
||||||
if psu.Slot != nil {
|
// board had more PSUs/firmware records than fit in one fixed-width row.
|
||||||
slot = *psu.Slot
|
if len(hw.Memory) > 0 {
|
||||||
|
var tally topoStatusTally
|
||||||
|
for _, m := range hw.Memory {
|
||||||
|
tally.add(classifyTopoSeverity(m.Status))
|
||||||
}
|
}
|
||||||
watt := ""
|
fill, stroke, text := topoSeverityColors(tally.worst())
|
||||||
if psu.WattageW != nil {
|
sizeGB := 0
|
||||||
watt = fmt.Sprintf("%dW", *psu.WattageW)
|
for _, m := range hw.Memory {
|
||||||
|
if m.SizeMB != nil {
|
||||||
|
sizeGB += *m.SizeMB / 1024
|
||||||
|
}
|
||||||
}
|
}
|
||||||
writeTopoBoxSVG(&b, topoBox{
|
sublabel := ""
|
||||||
x: psuX, y: psuY, w: 150, h: topoBoxHeight,
|
if sizeGB > 0 {
|
||||||
label: "PSU " + slot, sublabel: watt,
|
sublabel = fmt.Sprintf("%d GB total", sizeGB)
|
||||||
badgeText: label, badgeCls: cls, detailType: "psu",
|
}
|
||||||
|
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 {
|
b.WriteString(renderTopoFlexRow("Firmware", firmwareItems))
|
||||||
writeTopoBoxSVG(&b, topoBox{
|
|
||||||
x: svgWidth - 200 - 24, y: psuY, w: 200, h: topoBoxHeight,
|
if len(hw.PowerSupplies) > 0 {
|
||||||
label: "BMC", sublabel: "fw " + bmcVersion,
|
var tally topoStatusTally
|
||||||
badgeText: "?", badgeCls: "badge-unknown",
|
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(`</svg>`)
|
|
||||||
return topoCard("Topology", b.String())
|
return topoCard("Topology", b.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
// upgradeTopoBoxBadgeToWarn upgrades a box's badge to Warning unless it is
|
// renderTopoFlexRow renders a labeled, wrapping row of component cards.
|
||||||
// already at Critical severity (never downgrades a worse status).
|
// Returns "" if items is empty (e.g. no PSU data in this audit).
|
||||||
func upgradeTopoBoxBadgeToWarn(boxes []topoBox, boxIdx int) {
|
func renderTopoFlexRow(title string, items []topoCardInfo) string {
|
||||||
if boxIdx < 0 || boxIdx >= len(boxes) {
|
if len(items) == 0 {
|
||||||
return
|
return ""
|
||||||
}
|
}
|
||||||
if boxes[boxIdx].badgeCls == "badge-err" {
|
var b strings.Builder
|
||||||
return
|
fmt.Fprintf(&b, `<div style="font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin:16px 0 6px">%s</div>`,
|
||||||
}
|
html.EscapeString(title))
|
||||||
boxes[boxIdx].badgeText = "WARN"
|
b.WriteString(`<div style="display:flex;flex-wrap:wrap;gap:10px">`)
|
||||||
boxes[boxIdx].badgeCls = "badge-warn"
|
for _, item := range items {
|
||||||
}
|
onclick := ""
|
||||||
|
cursor := "default"
|
||||||
func findBMCFirmware(records []schema.HardwareFirmwareRecord) (string, bool) {
|
if item.detailType != "" {
|
||||||
for _, rec := range records {
|
onclick = fmt.Sprintf(` onclick="openComponentDetail('%s')"`, item.detailType)
|
||||||
if strings.EqualFold(strings.TrimSpace(rec.DeviceName), "BMC") {
|
cursor = "pointer"
|
||||||
return rec.Version, true
|
|
||||||
}
|
}
|
||||||
|
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>`)
|
||||||
}
|
}
|
||||||
return "", false
|
b.WriteString(`</div>`)
|
||||||
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeTopoBoxSVG(b *strings.Builder, box topoBox) {
|
func writeTopoBoxSVG(b *strings.Builder, box topoBox) {
|
||||||
@@ -613,18 +819,35 @@ func writeTopoBoxSVG(b *strings.Builder, box topoBox) {
|
|||||||
cursor = "pointer"
|
cursor = "pointer"
|
||||||
}
|
}
|
||||||
fmt.Fprintf(b, `<g%s style="cursor:%s">`, onclick, cursor)
|
fmt.Fprintf(b, `<g%s style="cursor:%s">`, onclick, cursor)
|
||||||
fmt.Fprintf(b, `<rect x="%d" y="%d" width="%d" height="%d" rx="6" ry="6" style="fill:var(--surface);stroke:var(--border)"/>`+"\n",
|
|
||||||
box.x, box.y, box.w, box.h)
|
// Stack-of-cards effect: faint offset rects behind the front card when
|
||||||
fmt.Fprintf(b, `<text x="%d" y="%d" style="fill:var(--ink,#000);font-size:13px;font-weight:700">%s</text>`+"\n",
|
// this box represents more than one physical component (e.g. 4 GPUs in
|
||||||
box.x+10, box.y+20, html.EscapeString(box.label))
|
// 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 != "" {
|
if box.sublabel != "" {
|
||||||
fmt.Fprintf(b, `<text x="%d" y="%d" style="fill:var(--muted);font-size:11px">%s</text>`+"\n",
|
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, html.EscapeString(truncateTopoLabel(box.sublabel, 26)))
|
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))
|
||||||
}
|
}
|
||||||
fmt.Fprintf(b, `<rect x="%d" y="%d" width="46" height="18" rx="3" ry="3" class="%s"/>`+"\n",
|
|
||||||
box.x+box.w-54, box.y+box.h-26, box.badgeCls)
|
|
||||||
fmt.Fprintf(b, `<text x="%d" y="%d" style="font-size:10px;font-weight:700" class="%s">%s</text>`+"\n",
|
|
||||||
box.x+box.w-49, box.y+box.h-13, box.badgeCls, html.EscapeString(box.badgeText))
|
|
||||||
b.WriteString(`</g>` + "\n")
|
b.WriteString(`</g>` + "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"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) {
|
func TestTopoPageLinkedFromNav(t *testing.T) {
|
||||||
handler := NewHandler(HandlerOptions{})
|
handler := NewHandler(HandlerOptions{})
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
@@ -237,15 +305,90 @@ func TestIsNICDeviceClassDev(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTopoStatusBadgeClassNilIsUnknown(t *testing.T) {
|
func TestClassifyTopoSeverityNilIsUnknown(t *testing.T) {
|
||||||
label, cls := topoStatusBadgeClass(nil)
|
if sev := classifyTopoSeverity(nil); sev != 0 {
|
||||||
if label != "?" || cls != "badge-unknown" {
|
t.Fatalf("nil status severity=%d want 0 (unknown)", sev)
|
||||||
t.Fatalf("nil status = (%q,%q) want (?, badge-unknown)", label, cls)
|
|
||||||
}
|
}
|
||||||
ok := "OK"
|
ok := "OK"
|
||||||
label, cls = topoStatusBadgeClass(&ok)
|
if sev := classifyTopoSeverity(&ok); sev != 1 {
|
||||||
if label != "OK" || cls != "badge-ok" {
|
t.Fatalf("OK status severity=%d want 1", sev)
|
||||||
t.Fatalf("OK status = (%q,%q) want (OK, badge-ok)", label, cls)
|
}
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user