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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user