Files
bee/audit/internal/webui/page_topo_test.go
T
Mikhail ChusavitinandClaude Sonnet 5 20cd317c87 platform/cmd/webui: add a scriptable test-scenario engine, load scenarios from blackbox USB, GPU status detail inventory fallback
Investigating the CG480-S6053 reboot needed a way to run an ad-hoc load
(nvbandwidth across a specific GPU set) while sampling IPMI/nvidia-smi
telemetry in the background — without hardcoding a one-off test into the
SAT pack code for a single investigation.

- audit/internal/platform/scenario.go: ScenarioSpec/ScenarioJob (JSON,
  no new dependency) + System.RunScenario. "command" jobs run sequential
  or parallel (per-job "parallel" flag); "sampler" jobs run concurrently
  in the background on their own interval until every command job
  finishes or the scenario's timeout elapses. "{{gpus}}" in a command's
  cmd is substituted from that job's gpu_indices. Command jobs are wired
  through the same satJobBoundaryHook/satSyncBracketHook seams the SAT
  job runner uses, so a scenario run gets the same durability treatment
  (evidence that a risky command started/finished reaches blackbox before
  a possible crash, not just whatever streamed to the RAM-backed export
  dir).
- export.go: ReadScenarioFromRemovableMedia mounts each removable target
  looking for scenarios/<name>.json — an air-gapped engineer can author a
  scenario elsewhere, drop it under scenarios/ on the same USB stick
  already plugged in for blackbox, and run it with no network path onto
  the host.
- cmd/bee: new `bee run <file.json|name>` (bare name = looked up on
  removable media); `bee scenario run <arg>` kept as a longer alias.
- scenarios/nvbandwidth-all-gpu-power-watch.json: the scenario that
  reproduced the actual reboot (full nvbandwidth across all GPUs, which
  crashed, vs. clean per-socket passes), with IPMI sensor + GPU power/temp
  sampling for a power-delivery correlation check.

Also: webui/page_topo.go — the /topo page's component-status-detail modal
(GET /api/component-detail/{type}) showed "No status data recorded yet"
for any component type ComponentStatusDB has no history for yet (e.g. GPU
before a SAT run this boot), even though the topology card for the same
component already showed "N OK" from the audit inventory snapshot.
inventoryFallbackRecords now synthesizes records from that same inventory
snapshot when StatusDB is empty, using the same device classifiers
(isGPUDeviceClass etc.) and severity mapping (classifyTopoSeverity) the
topology card itself uses, so the two views never disagree.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 17:51:45 +03:00

652 lines
21 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"
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(&#39;psu&#39;)"`) != 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()
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)
}
}