Each PSU is its own card, coloured by its own status — a failed unit goes red on its own instead of dragging a single grouped card down — and shows input voltage + draw (measured output/input, else nameplate rating). Cards click through to the PSU detail modal and carry data-psu so the shared topoLiveScript refreshes their wattage from /api/metrics/latest on the same 5s poll as the fan tiles. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019VHG21rgTUiR1G3qFHTVmN
936 lines
31 KiB
Go
936 lines
31 KiB
Go
package webui
|
||
|
||
import (
|
||
"encoding/json"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"os"
|
||
"path/filepath"
|
||
"strconv"
|
||
"strings"
|
||
"testing"
|
||
|
||
"bee/audit/internal/schema"
|
||
)
|
||
|
||
func TestTopoPageNoAuditDataGracefulFallback(t *testing.T) {
|
||
handler := NewHandler(HandlerOptions{})
|
||
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()
|
||
if !strings.Contains(body, "No audit data") {
|
||
t.Fatalf("topo page missing no-audit-data fallback: %s", body)
|
||
}
|
||
}
|
||
|
||
func TestTopoPageRendersCPUAndDegradedPCIeLink(t *testing.T) {
|
||
dir := t.TempDir()
|
||
path := filepath.Join(dir, "audit.json")
|
||
|
||
socket := 0
|
||
numaNode := 0
|
||
gen3, gen4 := "Gen3", "Gen4"
|
||
deviceClass := "VideoController"
|
||
model := "NVIDIA H100 80GB HBM3"
|
||
cpuModel := "Intel Xeon 6530"
|
||
okStatus := "OK"
|
||
|
||
ingest := schema.HardwareIngestRequest{
|
||
CollectedAt: "2026-03-15T00:00:00Z",
|
||
Hardware: schema.HardwareSnapshot{
|
||
CPUs: []schema.HardwareCPU{
|
||
{
|
||
HardwareComponentStatus: schema.HardwareComponentStatus{Status: &okStatus},
|
||
Socket: &socket,
|
||
Model: &cpuModel,
|
||
},
|
||
},
|
||
PCIeDevices: []schema.HardwarePCIeDevice{
|
||
{
|
||
DeviceClass: &deviceClass,
|
||
Model: &model,
|
||
NUMANode: &numaNode,
|
||
LinkSpeed: &gen3,
|
||
MaxLinkSpeed: &gen4,
|
||
},
|
||
},
|
||
},
|
||
}
|
||
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()
|
||
if !strings.Contains(body, "CPU 0") {
|
||
t.Fatalf("topo page missing CPU 0 box: %s", body)
|
||
}
|
||
if !strings.Contains(body, "GPU") {
|
||
t.Fatalf("topo page missing GPU box: %s", body)
|
||
}
|
||
if !strings.Contains(body, "var(--warn-fg)") {
|
||
t.Fatalf("topo page missing degraded-link warn edge color: %s", body)
|
||
}
|
||
}
|
||
|
||
func TestTopoPageRendersArbitraryPSUAndFirmwareCountsAsFlexRows(t *testing.T) {
|
||
dir := t.TempDir()
|
||
path := filepath.Join(dir, "audit.json")
|
||
|
||
okStatus := "OK"
|
||
failStatus := "Critical"
|
||
watt := 3000
|
||
volt := 230.0
|
||
|
||
var psus []schema.HardwarePowerSupply
|
||
for i := 0; i < 6; i++ {
|
||
slot := strconv.Itoa(i)
|
||
st := okStatus
|
||
if i == 3 {
|
||
st = failStatus
|
||
}
|
||
psus = append(psus, schema.HardwarePowerSupply{
|
||
HardwareComponentStatus: schema.HardwareComponentStatus{Status: &st},
|
||
Slot: &slot,
|
||
WattageW: &watt,
|
||
InputVoltage: &volt,
|
||
})
|
||
}
|
||
|
||
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)
|
||
}
|
||
// One card per PSU (6), each clickable, each showing voltage + power.
|
||
if n := strings.Count(body, `class="topo-psu-tile"`); n != 6 {
|
||
t.Fatalf("expected 6 per-PSU cards, got %d", n)
|
||
}
|
||
if n := strings.Count(body, `onclick="openComponentDetail('psu')"`) +
|
||
strings.Count(body, `onclick="openComponentDetail('psu')"`); n != 6 {
|
||
t.Fatalf("expected one clickable card per PSU (6), got %d", n)
|
||
}
|
||
if !strings.Contains(body, "230 V · 3000 W rated") {
|
||
t.Fatalf("PSU card missing voltage/power line: %s", body)
|
||
}
|
||
// The one failed PSU is coloured red on its own (crit token) and labelled.
|
||
if !strings.Contains(body, "var(--crit-bg)") || !strings.Contains(body, "CRITICAL") {
|
||
t.Fatalf("failed PSU should render individually as critical: %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 TestTopoPageRendersCoolingFansAsFlexRow(t *testing.T) {
|
||
dir := t.TempDir()
|
||
path := filepath.Join(dir, "audit.json")
|
||
|
||
ok, warn := "OK", "Warning"
|
||
rpm := func(v int) *int { return &v }
|
||
|
||
ingest := schema.HardwareIngestRequest{
|
||
CollectedAt: "2026-03-15T00:00:00Z",
|
||
Hardware: schema.HardwareSnapshot{
|
||
Sensors: &schema.HardwareSensors{
|
||
Fans: []schema.HardwareFanSensor{
|
||
{Name: "FAN1", RPM: rpm(4200), Status: &ok},
|
||
{Name: "FAN2", RPM: rpm(15000), Status: &warn},
|
||
{Name: "FAN2", RPM: rpm(15000), Status: &warn}, // dup name, first wins
|
||
},
|
||
},
|
||
},
|
||
}
|
||
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()
|
||
|
||
// One clickable square per fan (2 after dedup by name), each with a
|
||
// spinning glyph, under a COOLING heading.
|
||
if !strings.Contains(body, "Cooling") {
|
||
t.Fatalf("topo page missing Cooling heading: %s", body)
|
||
}
|
||
if n := strings.Count(body, `onclick="openComponentDetail('fan')"`) +
|
||
strings.Count(body, `onclick="openComponentDetail('fan')"`); n != 2 {
|
||
t.Fatalf("expected one clickable square per fan (2), got %d: %s", n, body)
|
||
}
|
||
if n := strings.Count(body, `class="topo-fan-spin"`); n != 2 {
|
||
t.Fatalf("expected 2 spinning fan glyphs, got %d: %s", n, body)
|
||
}
|
||
if !strings.Contains(body, "FAN1 · 4200 RPM") || !strings.Contains(body, "FAN2 · 15000 RPM") {
|
||
t.Fatalf("topo page missing per-fan RPM tooltips: %s", body)
|
||
}
|
||
// No observed ceiling in this test → tiles show no duty fill and say so.
|
||
if !strings.Contains(body, "ceiling not measured") {
|
||
t.Fatalf("topo fan tooltip should note the ceiling is unmeasured: %s", body)
|
||
}
|
||
// Live-update: each tile is addressable and the poll script is present.
|
||
if n := strings.Count(body, `class="topo-fan-tile"`); n != 2 {
|
||
t.Fatalf("expected 2 addressable fan tiles, got %d", n)
|
||
}
|
||
if !strings.Contains(body, `data-fan="FAN1"`) || !strings.Contains(body, "/api/metrics/latest") {
|
||
t.Fatalf("topo fan row missing live-update wiring: %s", body)
|
||
}
|
||
|
||
// Component-detail fallback endpoint must resolve the "fan" type.
|
||
rec2 := httptest.NewRecorder()
|
||
handler.ServeHTTP(rec2, httptest.NewRequest(http.MethodGet, "/api/components/fan", nil))
|
||
if rec2.Code != http.StatusOK {
|
||
t.Fatalf("/api/components/fan status=%d", rec2.Code)
|
||
}
|
||
if b := rec2.Body.String(); !strings.Contains(b, "FAN1") || !strings.Contains(b, "FAN2") {
|
||
t.Fatalf("fan component detail missing fan names: %s", b)
|
||
}
|
||
}
|
||
|
||
func TestFanSpinPeriodSec(t *testing.T) {
|
||
// Clamped at both ends; monotonically faster (smaller period) with RPM.
|
||
if got := fanSpinPeriodSec(200); got != 2.2 {
|
||
t.Fatalf("low RPM: got %v want 2.2 (slowest visible)", got)
|
||
}
|
||
if got := fanSpinPeriodSec(25000); got != 0.35 {
|
||
t.Fatalf("high RPM: got %v want 0.35 (fastest visible)", got)
|
||
}
|
||
mid := fanSpinPeriodSec(7000)
|
||
if mid <= 0.35 || mid >= 2.2 {
|
||
t.Fatalf("mid RPM period %v out of band", mid)
|
||
}
|
||
if fanSpinPeriodSec(10000) >= fanSpinPeriodSec(3000) {
|
||
t.Fatalf("higher RPM must spin faster (shorter period)")
|
||
}
|
||
}
|
||
|
||
func TestTopoPageRendersStorageDisksGroupedByType(t *testing.T) {
|
||
dir := t.TempDir()
|
||
path := filepath.Join(dir, "audit.json")
|
||
|
||
okStatus := "OK"
|
||
warnStatus := "WARNING"
|
||
ssd := "SSD"
|
||
nvme := "NVMe"
|
||
model := "SAMSUNG MZ7L3960HCJR-00B7C"
|
||
size := 960
|
||
|
||
ingest := schema.HardwareIngestRequest{
|
||
CollectedAt: "2026-03-15T00:00:00Z",
|
||
Hardware: schema.HardwareSnapshot{
|
||
Storage: []schema.HardwareStorage{
|
||
{HardwareComponentStatus: schema.HardwareComponentStatus{Status: &okStatus}, Type: &ssd, Model: &model, SizeGB: &size},
|
||
{HardwareComponentStatus: schema.HardwareComponentStatus{Status: &okStatus}, Type: &ssd, Model: &model, SizeGB: &size},
|
||
{HardwareComponentStatus: schema.HardwareComponentStatus{Status: &warnStatus}, Type: &nvme, SizeGB: &size},
|
||
},
|
||
},
|
||
}
|
||
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()
|
||
|
||
if !strings.Contains(body, "SSD ×2") {
|
||
t.Fatalf("topo page missing grouped SSD x2 card: %s", body)
|
||
}
|
||
if !strings.Contains(body, "NVMe") {
|
||
t.Fatalf("topo page missing NVMe disk card: %s", body)
|
||
}
|
||
// SSD card totals capacity across both disks.
|
||
if !strings.Contains(body, "1.9 TB total") {
|
||
t.Fatalf("topo page missing SSD total capacity: %s", body)
|
||
}
|
||
// The degraded NVMe disk must escalate that card's status.
|
||
nvmeIdx := strings.Index(body, ">NVMe<")
|
||
if nvmeIdx < 0 || !strings.Contains(body[nvmeIdx:nvmeIdx+400], "Warning") {
|
||
t.Fatalf("topo page missing NVMe warning status: %s", body)
|
||
}
|
||
// With no storage-controller techdump, disks have no resolvable socket and
|
||
// land under the "Other" bar rather than being glued to a CPU.
|
||
if !strings.Contains(body, ">Other<") {
|
||
t.Fatalf("topo page missing Other bar for unattached disks: %s", body)
|
||
}
|
||
clicks := strings.Count(body, `openComponentDetail('storage')`) + strings.Count(body, `openComponentDetail('storage')`)
|
||
if clicks != 2 {
|
||
t.Fatalf("expected one clickable card per disk-type group, got %d: %s", clicks, body)
|
||
}
|
||
}
|
||
|
||
func TestParseStorageControllerMap(t *testing.T) {
|
||
raw := "sda hctl=2:0:0:0 ctrl=0000:00:17.0\n" +
|
||
"sdb hctl=3:0:0:0 ctrl=0000:00:17.0\n" +
|
||
"nvme0n1 hctl= ctrl=0000:65:00.0\n"
|
||
got := parseStorageControllerMap(raw)
|
||
if got["2:0:0:0"] != "0000:00:17.0" || got["3:0:0:0"] != "0000:00:17.0" {
|
||
t.Fatalf("SATA disks not mapped to controller: %#v", got)
|
||
}
|
||
if _, ok := got["nvme0n1"]; ok {
|
||
t.Fatalf("NVMe line with empty hctl must be skipped: %#v", got)
|
||
}
|
||
}
|
||
|
||
func TestTopoPageParentsDisksUnderTheirControllerSocket(t *testing.T) {
|
||
dir := t.TempDir()
|
||
path := filepath.Join(dir, "audit.json")
|
||
techdump := filepath.Join(dir, "techdump")
|
||
if err := os.MkdirAll(techdump, 0755); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// Both SSDs hang off the SATA controller at 0000:00:17.0, which is a
|
||
// NUMA-node-0 PCIe device -> they must render under CPU 0, not "Other".
|
||
if err := os.WriteFile(filepath.Join(techdump, "storage-controllers.txt"),
|
||
[]byte("sda hctl=2:0:0:0 ctrl=0000:00:17.0\nsdb hctl=3:0:0:0 ctrl=0000:00:17.0\n"), 0644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
socket0, socket1 := 0, 1
|
||
numa0 := 0
|
||
okStatus := "OK"
|
||
sataClass := "SATA controller"
|
||
sataBDF := "0000:00:17.0"
|
||
hctlA, hctlB := "2:0:0:0", "3:0:0:0"
|
||
ssd := "SSD"
|
||
size := 960
|
||
|
||
ingest := schema.HardwareIngestRequest{
|
||
CollectedAt: "2026-03-15T00:00:00Z",
|
||
Hardware: schema.HardwareSnapshot{
|
||
CPUs: []schema.HardwareCPU{
|
||
{HardwareComponentStatus: schema.HardwareComponentStatus{Status: &okStatus}, Socket: &socket0},
|
||
{HardwareComponentStatus: schema.HardwareComponentStatus{Status: &okStatus}, Socket: &socket1},
|
||
},
|
||
PCIeDevices: []schema.HardwarePCIeDevice{
|
||
{HardwareComponentStatus: schema.HardwareComponentStatus{Status: &okStatus}, Slot: &sataBDF, DeviceClass: &sataClass, NUMANode: &numa0},
|
||
},
|
||
Storage: []schema.HardwareStorage{
|
||
{HardwareComponentStatus: schema.HardwareComponentStatus{Status: &okStatus}, Slot: &hctlA, Type: &ssd, SizeGB: &size},
|
||
{HardwareComponentStatus: schema.HardwareComponentStatus{Status: &okStatus}, Slot: &hctlB, Type: &ssd, SizeGB: &size},
|
||
},
|
||
},
|
||
}
|
||
data, _ := json.Marshal(ingest)
|
||
if err := os.WriteFile(path, data, 0644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
handler := NewHandler(HandlerOptions{AuditPath: path, ExportDir: dir})
|
||
rec := httptest.NewRecorder()
|
||
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/topo", nil))
|
||
body := rec.Body.String()
|
||
|
||
if strings.Contains(body, ">Other<") {
|
||
t.Fatalf("disks resolved to a socket — no Other bar expected: %s", body)
|
||
}
|
||
if !strings.Contains(body, "SATA ctrl") {
|
||
t.Fatalf("topo page missing the SATA controller branch node: %s", body)
|
||
}
|
||
if !strings.Contains(body, "SSD ×2") {
|
||
t.Fatalf("topo page missing SSD disk group under the controller: %s", body)
|
||
}
|
||
// controller branch must render before its SSD sub-node
|
||
if strings.Index(body, "SATA ctrl") > strings.Index(body, "SSD ×2") {
|
||
t.Fatalf("controller node should render before its disks: %s", body)
|
||
}
|
||
}
|
||
|
||
func TestTopoPageRendersOneBarPerSocketNoRoot(t *testing.T) {
|
||
dir := t.TempDir()
|
||
path := filepath.Join(dir, "audit.json")
|
||
|
||
socket0, socket1 := 0, 1
|
||
okStatus := "OK"
|
||
board := "AS-4125GS-TNRT"
|
||
vendor := "Supermicro"
|
||
|
||
ingest := schema.HardwareIngestRequest{
|
||
CollectedAt: "2026-03-15T00:00:00Z",
|
||
Hardware: schema.HardwareSnapshot{
|
||
Board: schema.HardwareBoard{ProductName: &board, Manufacturer: &vendor},
|
||
CPUs: []schema.HardwareCPU{
|
||
{HardwareComponentStatus: schema.HardwareComponentStatus{Status: &okStatus}, Socket: &socket0},
|
||
{HardwareComponentStatus: schema.HardwareComponentStatus{Status: &okStatus}, Socket: &socket1},
|
||
},
|
||
},
|
||
}
|
||
data, _ := json.Marshal(ingest)
|
||
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, ">CPU 0<") || !strings.Contains(body, ">CPU 1<") {
|
||
t.Fatalf("topo diagram missing a bar per CPU socket: %s", body)
|
||
}
|
||
// No board/root node in the diagram itself (board identity is the
|
||
// Firmware row lower down).
|
||
svg := body[strings.Index(body, "<svg"):]
|
||
svg = svg[:strings.Index(svg, "</svg>")]
|
||
if strings.Contains(svg, board) {
|
||
t.Fatalf("board node must not render inside the topology diagram: %s", svg)
|
||
}
|
||
}
|
||
|
||
func TestTopoPageLinkedFromNav(t *testing.T) {
|
||
handler := NewHandler(HandlerOptions{})
|
||
rec := httptest.NewRecorder()
|
||
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
|
||
if rec.Code != http.StatusOK {
|
||
t.Fatalf("status=%d", rec.Code)
|
||
}
|
||
if !strings.Contains(rec.Body.String(), `href="/topo"`) {
|
||
t.Fatalf("nav missing /topo link")
|
||
}
|
||
}
|
||
|
||
func TestParseGPUPairAdjacencyRealTwoGPUDump(t *testing.T) {
|
||
// Real system/nvidia-smi-topo.txt from a support bundle for this exact
|
||
// server: two H100s directly bridged (NV17), spanning two NUMA nodes.
|
||
input := "\tGPU0\tGPU1\tNIC0\tNIC1\tCPU Affinity\tNUMA Affinity\tGPU NUMA ID\n" +
|
||
"GPU0\t X \tNV17\tSYS\tSYS\t0-23,48-71\t0\t\tN/A\n" +
|
||
"GPU1\tNV17\t X \tNODE\tNODE\t24-47,72-95\t1\t\tN/A\n" +
|
||
"NIC0\tSYS\tNODE\t X \tPIX\t\t\t\n" +
|
||
"NIC1\tSYS\tNODE\tPIX\t X \t\t\t\n"
|
||
|
||
pairs := parseGPUPairAdjacency(input)
|
||
if len(pairs) != 1 {
|
||
t.Fatalf("pairs=%d want 1 (%#v)", len(pairs), pairs)
|
||
}
|
||
if pairs[0].GPUA != 0 || pairs[0].GPUB != 1 || pairs[0].NVLinks != 17 {
|
||
t.Fatalf("pair=%#v want {0,1,17}", pairs[0])
|
||
}
|
||
}
|
||
|
||
func TestParseGPUPairAdjacencyDoesNotChainUnrelatedGPUs(t *testing.T) {
|
||
// 4 GPUs: only (0,1) and (2,3) are actually bonded. GPU1 and GPU2 must
|
||
// NOT get an edge just because they're adjacent in the layout.
|
||
input := "\tGPU0\tGPU1\tGPU2\tGPU3\n" +
|
||
"GPU0\t X \tNV18\tSYS\tSYS\n" +
|
||
"GPU1\tNV18\t X \tSYS\tSYS\n" +
|
||
"GPU2\tSYS\tSYS\t X \tNV18\n" +
|
||
"GPU3\tSYS\tSYS\tNV18\t X \n"
|
||
|
||
pairs := parseGPUPairAdjacency(input)
|
||
if len(pairs) != 2 {
|
||
t.Fatalf("pairs=%d want 2 (%#v)", len(pairs), pairs)
|
||
}
|
||
want := map[[2]int]bool{{0, 1}: true, {2, 3}: true}
|
||
for _, p := range pairs {
|
||
if !want[[2]int{p.GPUA, p.GPUB}] {
|
||
t.Fatalf("unexpected pair %#v (GPU1-GPU2 chaining bug?)", p)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestParseGPUPairAdjacencyANSIUnderlinedHeader(t *testing.T) {
|
||
// Real techdump capture: nvidia-smi underlines the header row with ANSI
|
||
// escapes even when writing to a file, so the header line starts with
|
||
// ESC[4m, not "GPU0".
|
||
input := "\x1b[4m\tGPU0\tGPU1\tGPU2\tGPU3\tNIC0\tNIC1\tCPU Affinity\x1b[0m\n" +
|
||
"GPU0\t X \tNV18\tPIX\tPIX\tNODE\tNODE\t0-31,64-95\t0\n" +
|
||
"GPU1\tNV18\t X \tPIX\tPIX\tNODE\tNODE\t0-31,64-95\t0\n" +
|
||
"GPU2\tPIX\tPIX\t X \tNV18\tNODE\tNODE\t0-31,64-95\t0\n" +
|
||
"GPU3\tPIX\tPIX\tNV18\t X \tNODE\tNODE\t0-31,64-95\t0\n" +
|
||
"NIC0\tNODE\tNODE\tNODE\tNODE\t X \tPIX\n" +
|
||
"NIC1\tNODE\tNODE\tNODE\tNODE\tPIX\t X \n"
|
||
|
||
pairs := parseGPUPairAdjacency(input)
|
||
if len(pairs) != 2 {
|
||
t.Fatalf("pairs=%d want 2 (%#v)", len(pairs), pairs)
|
||
}
|
||
want := map[[2]int]bool{{0, 1}: true, {2, 3}: true}
|
||
for _, p := range pairs {
|
||
if !want[[2]int{p.GPUA, p.GPUB}] || p.NVLinks != 18 {
|
||
t.Fatalf("unexpected pair %#v", p)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestParseGPUPairAdjacencyEmptyOnNoMatrix(t *testing.T) {
|
||
if pairs := parseGPUPairAdjacency("no gpus here"); pairs != nil {
|
||
t.Fatalf("pairs=%v want nil", pairs)
|
||
}
|
||
}
|
||
|
||
func TestPcieGenRank(t *testing.T) {
|
||
if pcieGenRank("Gen5") <= pcieGenRank("Gen4") {
|
||
t.Fatalf("Gen5 should rank higher than Gen4")
|
||
}
|
||
if pcieGenRank("bogus") != 0 {
|
||
t.Fatalf("unparseable gen should rank 0")
|
||
}
|
||
}
|
||
|
||
func TestTopoEdgeColorVar(t *testing.T) {
|
||
gen3, gen4 := "Gen3", "Gen4"
|
||
degraded := schema.HardwarePCIeDevice{LinkSpeed: &gen3, MaxLinkSpeed: &gen4}
|
||
if got := topoEdgeColorVar(degraded); got != "var(--warn-fg)" {
|
||
t.Fatalf("degraded color=%q want warn", got)
|
||
}
|
||
full := schema.HardwarePCIeDevice{LinkSpeed: &gen4, MaxLinkSpeed: &gen4}
|
||
if got := topoEdgeColorVar(full); got != "var(--ok-fg)" {
|
||
t.Fatalf("full-speed color=%q want ok", got)
|
||
}
|
||
unknown := schema.HardwarePCIeDevice{}
|
||
if got := topoEdgeColorVar(unknown); got != "var(--muted)" {
|
||
t.Fatalf("unknown color=%q want muted", got)
|
||
}
|
||
}
|
||
|
||
func TestBuildSocketIndex(t *testing.T) {
|
||
s0, s1 := 0, 1
|
||
cpus := []schema.HardwareCPU{{Socket: &s1}, {Socket: &s0}}
|
||
idx := buildSocketIndex(cpus)
|
||
if idx[0] != 1 || idx[1] != 0 {
|
||
t.Fatalf("idx=%#v want {0:1, 1:0}", idx)
|
||
}
|
||
}
|
||
|
||
func TestBuildSocketIndexOneIndexedSocketDesignation(t *testing.T) {
|
||
// dmidecode "Socket Designation" is frequently 1-indexed ("CPU1", "CPU2")
|
||
// while Linux NUMA nodes are always 0-indexed. NUMA node 0 must still
|
||
// resolve to the first CPU in Socket order, not fall through to the
|
||
// "unknown" column (the bug reported against the /topo page).
|
||
s1, s2 := 1, 2
|
||
cpus := []schema.HardwareCPU{{Socket: &s1}, {Socket: &s2}}
|
||
idx := buildSocketIndex(cpus)
|
||
if idx[0] != 0 || idx[1] != 1 {
|
||
t.Fatalf("idx=%#v want {0:0, 1:1}", idx)
|
||
}
|
||
}
|
||
|
||
func TestIsRAIDControllerClass(t *testing.T) {
|
||
if !isRAIDControllerClass("StorageController") || !isRAIDControllerClass("MassStorageController") {
|
||
t.Fatalf("expected known RAID/storage classes to match")
|
||
}
|
||
if isRAIDControllerClass("VideoController") {
|
||
t.Fatalf("GPU class should not match RAID classifier")
|
||
}
|
||
}
|
||
|
||
func TestIsNICDeviceClassDev(t *testing.T) {
|
||
class := "EthernetController"
|
||
nic := schema.HardwarePCIeDevice{DeviceClass: &class}
|
||
if !isNICDeviceClassDev(nic) {
|
||
t.Fatalf("expected EthernetController to classify as NIC")
|
||
}
|
||
withMac := schema.HardwarePCIeDevice{MacAddresses: []string{"aa:bb:cc:dd:ee:ff"}}
|
||
if !isNICDeviceClassDev(withMac) {
|
||
t.Fatalf("expected device with MAC address to classify as NIC")
|
||
}
|
||
other := schema.HardwarePCIeDevice{}
|
||
if isNICDeviceClassDev(other) {
|
||
t.Fatalf("expected empty device to not classify as NIC")
|
||
}
|
||
}
|
||
|
||
func TestClassifyTopoSeverityNilIsUnknown(t *testing.T) {
|
||
if sev := classifyTopoSeverity(nil); sev != 0 {
|
||
t.Fatalf("nil status severity=%d want 0 (unknown)", sev)
|
||
}
|
||
ok := "OK"
|
||
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)
|
||
}
|
||
}
|
||
|
||
func TestDimmRawNodeFromCPULocatorPrefix(t *testing.T) {
|
||
slot := "CPU1_DIMM_A1"
|
||
mem := schema.HardwareMemory{Slot: &slot}
|
||
node, ok := dimmRawNode(mem, nil)
|
||
if !ok || node != 1 {
|
||
t.Fatalf("dimmRawNode() = (%d, %v), want (1, true)", node, ok)
|
||
}
|
||
}
|
||
|
||
func TestDimmRawNodeFromBankLocatorFallback(t *testing.T) {
|
||
// Boards whose Locator carries no CPU number of its own (e.g.
|
||
// "DIMM000(A)") still need to be attached to a column via the node
|
||
// number in Bank Locator, read separately from techdump.
|
||
slot := "DIMM100(A)"
|
||
mem := schema.HardwareMemory{Slot: &slot}
|
||
bankNodes := map[string]int{"DIMM100(A)": 2}
|
||
node, ok := dimmRawNode(mem, bankNodes)
|
||
if !ok || node != 2 {
|
||
t.Fatalf("dimmRawNode() = (%d, %v), want (2, true)", node, ok)
|
||
}
|
||
}
|
||
|
||
func TestDimmRawNodeUnmatched(t *testing.T) {
|
||
slot := "SOMETHING_UNRECOGNIZED"
|
||
mem := schema.HardwareMemory{Slot: &slot}
|
||
if _, ok := dimmRawNode(mem, nil); ok {
|
||
t.Fatalf("expected no match for an unrecognized locator")
|
||
}
|
||
if _, ok := dimmRawNode(schema.HardwareMemory{}, nil); ok {
|
||
t.Fatalf("expected no match when Slot is nil")
|
||
}
|
||
}
|
||
|
||
func TestParseDIMMBankLocatorNodes(t *testing.T) {
|
||
// Real dmidecode -t 17 shape: "Locator" precedes "Bank Locator" within
|
||
// each "Memory Device" section.
|
||
raw := `Handle 0x0017, DMI type 17, 92 bytes
|
||
Memory Device
|
||
Size: 64 GB
|
||
Locator: DIMM000(A)
|
||
Bank Locator: _Node1_Channel0_Dimm0
|
||
Type: DDR5
|
||
Handle 0x0018, DMI type 17, 92 bytes
|
||
Memory Device
|
||
Size: No Module Installed
|
||
Locator: DIMM001(I)
|
||
Bank Locator: _Node1_Channel0_Dimm1
|
||
Handle 0x0019, DMI type 17, 92 bytes
|
||
Memory Device
|
||
Size: 64 GB
|
||
Locator: DIMM100(A)
|
||
Bank Locator: _Node2_Channel0_Dimm0
|
||
`
|
||
got := parseDIMMBankLocatorNodes(raw)
|
||
want := map[string]int{"DIMM000(A)": 1, "DIMM001(I)": 1, "DIMM100(A)": 2}
|
||
if len(got) != len(want) {
|
||
t.Fatalf("got=%#v want=%#v", got, want)
|
||
}
|
||
for k, v := range want {
|
||
if got[k] != v {
|
||
t.Fatalf("got[%q]=%d want %d (full: %#v)", k, got[k], v, got)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestBuildMemoryColumnIndex(t *testing.T) {
|
||
// Node numbers observed on real boards are not guaranteed 0-based (Bank
|
||
// Locator "NodeN" has been seen starting at 1) — this must rank by
|
||
// order, not treat the raw value as a column index.
|
||
idx := buildMemoryColumnIndex([]int{1, 2, 1, 2})
|
||
if idx[1] != 0 || idx[2] != 1 {
|
||
t.Fatalf("idx=%#v want {1:0, 2:1}", idx)
|
||
}
|
||
}
|
||
|
||
// TestTopoMainDiagramAttachesMemoryToItsCPUColumn is the regression test for
|
||
// the actual feature request: memory used to render as one unattached
|
||
// "MEMORY" row below the whole diagram regardless of which CPU it belonged
|
||
// to. DIMMs whose Locator carries a CPU number must now render as their own
|
||
// box directly under that CPU, wired to it with an edge, the same as
|
||
// GPU/NIC/RAID — and must NOT also show up in the leftover flex row.
|
||
func TestTopoMainDiagramAttachesMemoryToItsCPUColumn(t *testing.T) {
|
||
dir := t.TempDir()
|
||
path := filepath.Join(dir, "audit.json")
|
||
|
||
socket0, socket1 := 0, 1
|
||
okStatus := "OK"
|
||
slot0, slot1 := "CPU0_DIMM_A1", "CPU1_DIMM_A1"
|
||
sizeMB := 98304
|
||
|
||
ingest := schema.HardwareIngestRequest{
|
||
CollectedAt: "2026-03-15T00:00:00Z",
|
||
Hardware: schema.HardwareSnapshot{
|
||
CPUs: []schema.HardwareCPU{
|
||
{HardwareComponentStatus: schema.HardwareComponentStatus{Status: &okStatus}, Socket: &socket0},
|
||
{HardwareComponentStatus: schema.HardwareComponentStatus{Status: &okStatus}, Socket: &socket1},
|
||
},
|
||
Memory: []schema.HardwareMemory{
|
||
{HardwareComponentStatus: schema.HardwareComponentStatus{Status: &okStatus}, Slot: &slot0, SizeMB: &sizeMB},
|
||
{HardwareComponentStatus: schema.HardwareComponentStatus{Status: &okStatus}, Slot: &slot1, SizeMB: &sizeMB},
|
||
},
|
||
},
|
||
}
|
||
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.Count(body, `>Memory<`) != 2 {
|
||
t.Fatalf("expected 2 attached Memory boxes (one per CPU column), got body: %s", body)
|
||
}
|
||
if strings.Contains(body, "Memory</div>") {
|
||
t.Fatalf("both DIMMs matched a CPU column — the leftover unattached Memory row must not render: %s", body)
|
||
}
|
||
if strings.Count(body, "openComponentDetail('memory')") != 2 {
|
||
t.Fatalf("expected 2 clickable Memory boxes, got body: %s", body)
|
||
}
|
||
}
|
||
|
||
// TestTopoMainDiagramUnattachableMemoryFallsBackToFlexRow guards that a DIMM
|
||
// whose Locator can't be parsed into a CPU/node number still shows up
|
||
// somewhere (the old unattached row) instead of silently disappearing.
|
||
func TestTopoMainDiagramUnattachableMemoryFallsBackToFlexRow(t *testing.T) {
|
||
dir := t.TempDir()
|
||
path := filepath.Join(dir, "audit.json")
|
||
|
||
socket0 := 0
|
||
okStatus := "OK"
|
||
slot := "SOMETHING_UNRECOGNIZED"
|
||
sizeMB := 32768
|
||
|
||
ingest := schema.HardwareIngestRequest{
|
||
CollectedAt: "2026-03-15T00:00:00Z",
|
||
Hardware: schema.HardwareSnapshot{
|
||
CPUs: []schema.HardwareCPU{
|
||
{HardwareComponentStatus: schema.HardwareComponentStatus{Status: &okStatus}, Socket: &socket0},
|
||
},
|
||
Memory: []schema.HardwareMemory{
|
||
{HardwareComponentStatus: schema.HardwareComponentStatus{Status: &okStatus}, Slot: &slot, SizeMB: &sizeMB},
|
||
},
|
||
},
|
||
}
|
||
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, "Memory</div>") {
|
||
t.Fatalf("unattachable DIMM should still appear in the leftover flex row: %s", body)
|
||
}
|
||
}
|
||
|
||
func TestParseTopoNVLinkStatus(t *testing.T) {
|
||
input := `GPU 0: NVIDIA H100 80GB HBM3 (UUID: GPU-a59f6931-c099-8fba-a0b3-08469d86f140)
|
||
Link 0: 26.562 GB/s
|
||
Link 15: <inactive>
|
||
GPU 1: NVIDIA H100 80GB HBM3 (UUID: GPU-603fe750-0516-9db5-86ec-ea61af3fce35)
|
||
Link 0: 26.562 GB/s
|
||
`
|
||
got := parseTopoNVLinkStatus(input)
|
||
if len(got[0]) != 2 || got[0][1].Active {
|
||
t.Fatalf("gpu0=%#v want link15 inactive", got[0])
|
||
}
|
||
if len(got[1]) != 1 || got[1][0].SpeedGBs == nil || *got[1][0].SpeedGBs != 26.562 {
|
||
t.Fatalf("gpu1=%#v want link0 26.562 GB/s", got[1])
|
||
}
|
||
}
|
||
|
||
// TestComponentDetailFallsBackToInventoryWhenStatusDBEmpty covers the bug where
|
||
// a /topo card shows "3 OK" (from schema.HardwareComponentStatus.Status in the
|
||
// audit snapshot) but clicking it opens a modal saying "No status data recorded
|
||
// yet" (because ComponentStatusDB has no pcie:gpu:* entries — nothing has run a
|
||
// SAT test on this boot yet). The modal must show the same 3 devices/status the
|
||
// card does, not an empty state.
|
||
func TestComponentDetailFallsBackToInventoryWhenStatusDBEmpty(t *testing.T) {
|
||
dir := t.TempDir()
|
||
path := filepath.Join(dir, "audit.json")
|
||
|
||
okStatus := "OK"
|
||
warnStatus := "Warning"
|
||
deviceClass := "VideoController"
|
||
var gpus []schema.HardwarePCIeDevice
|
||
for i, st := range []*string{&okStatus, &okStatus, &warnStatus} {
|
||
slot := "0000:c" + strconv.Itoa(i) + ":00.0"
|
||
gpus = append(gpus, schema.HardwarePCIeDevice{
|
||
HardwareComponentStatus: schema.HardwareComponentStatus{Status: st},
|
||
DeviceClass: &deviceClass,
|
||
Slot: &slot,
|
||
})
|
||
}
|
||
|
||
ingest := schema.HardwareIngestRequest{
|
||
Hardware: schema.HardwareSnapshot{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)
|
||
}
|
||
|
||
// No HandlerOptions.App / StatusDB set — matches a host where nothing has
|
||
// written to ComponentStatusDB yet.
|
||
handler := NewHandler(HandlerOptions{AuditPath: path})
|
||
rec := httptest.NewRecorder()
|
||
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/components/gpu", nil))
|
||
if rec.Code != http.StatusOK {
|
||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||
}
|
||
body := rec.Body.String()
|
||
|
||
if strings.Contains(body, "No status data recorded yet") {
|
||
t.Fatalf("modal should not show empty state when inventory has GPUs: %s", body)
|
||
}
|
||
if strings.Count(body, "chip-ok") != 2 {
|
||
t.Fatalf("expected 2 OK chips from inventory fallback: %s", body)
|
||
}
|
||
if strings.Count(body, "chip-warn") != 1 {
|
||
t.Fatalf("expected 1 Warning chip from inventory fallback: %s", body)
|
||
}
|
||
if !strings.Contains(body, "No SAT-test history yet") {
|
||
t.Fatalf("expected fallback marker text: %s", body)
|
||
}
|
||
}
|
||
|
||
func TestParseTopoNVLinkErrors(t *testing.T) {
|
||
input := `GPU 0: NVIDIA H100 80GB HBM3 (UUID: GPU-a59f6931-c099-8fba-a0b3-08469d86f140)
|
||
Link 0: Replay Errors: 0
|
||
Link 0: Recovery Errors: 0
|
||
Link 0: CRC Errors: 0
|
||
Link 1: Replay Errors: 3
|
||
Link 1: Recovery Errors: 1
|
||
Link 1: CRC Errors: 2
|
||
`
|
||
got := parseTopoNVLinkErrors(input)
|
||
c := got[0][1]
|
||
if c[0] != 3 || c[1] != 1 || c[2] != 2 {
|
||
t.Fatalf("link1 counters=%#v want {3,1,2}", c)
|
||
}
|
||
}
|