feat(topo): per-socket bar layout with disks under their controller

Rework the /topo diagram from side-by-side stacked cards into one tall
vertical bar per CPU socket with everything attached to it branching off
sideways (socket 0 left/branches right, socket 1 right/branches left).

Disks are now parented under the storage controller they physically hang
off (SATA/AHCI, SAS HBA, RAID) — itself a NUMA-affine PCIe device under one
socket — instead of a synthetic catch-all node. The disk->controller link
is read from a new storage-controllers.txt techdump
(platform.StorageControllerMapScript, a /sys/block walk); disks with no
resolvable controller fall back to an "Other" bar. No board/root node.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYjrG6bVmeG1Z2Wmc8kg3o
This commit is contained in:
Mikhail Chusavitin
2026-09-03 12:15:49 +03:00
co-authored by Claude Sonnet 5
parent b1ab866f58
commit 319f08ac4b
5 changed files with 634 additions and 100 deletions
+181
View File
@@ -152,6 +152,187 @@ func TestTopoPageRendersArbitraryPSUAndFirmwareCountsAsFlexRows(t *testing.T) {
}
}
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(&#39;storage&#39;)`)
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()