SKILL.md 6.3 KB


name: web-topology

description: Tune the sm121 web dashboard's topology map — the physical-layer visualization in the Topology window (2×4 GX10 grid, 200G/10G/DAC cables, HA glyphs, cluster frames). Use whenever adjusting the map's layout/geometry, colors and cluster frames, cable routing, device/switch/fan glyphs, HA state display, or its derivation rules and tests.

Web Topology Map (the dashboard's physical layer)

The Topology window in sm121 web renders the fixed physical map as an animated SVG infographic: 8 GX10 boxes on a 2×4 grid, the 200G fabric switch + passive 10G switch in the middle corridor, DAC direct links, HA glyphs (fan), cluster frames with per-cluster hues. Built to be tuned — this is the working manual.

Where things live

  • src/web/ui.html — everything visual:
    • CSS block /* --- topology map */ (all SVG styling; inline `style="stroke:…" carries per-cluster hues, classes carry state)
    • script section // --- topology map: geometry constants (TG_W/TG_H, tgPos, switch/fan y-anchors), hueOf/TG_HUES, topoSig, tgCablePath, devSvg, swSvg, fanSvg, topoFrames, cablesSvg, switchesSvg, devsSvg, legendSvg, topoTick, renderTopo
    • initManagedWindows places Topology as the primary window (left); state/jobs stack right. Vertical budget = #winlayer clientHeight minus header/dock, minus 40px dock reserve (dock appears when a window is minimized; windows overflow the layer otherwise and the layer silently scrolls, breaking click tests).
  • src/web/topo.rs — derive_topo(config) -> TopoSnap (pure, unit-tested): the semantic contract below. Snapshot builder in events.rs fills TopoSnap.states from probe HA states and NodeSnap.ha per node plug.
  • src/web/probe.rs — ha_states(): one parallel HA get_state batch per poll for every node plug + extra entity (Some(true/false), None = unknown/unreachable — never dim a device on None).
  • AGENTS.md sm121 web blurb — keep in sync when semantics change.

Derivation rules (topo.rs — don't break these)

  • Nodes: unique names across clusters, sorted by the digits in the name (dgx10 > dgx9, not lexicographic). Grid slot = index in that list.
  • The cluster's blocking extra_entity that is not some node's plug = the 200G fabric switch ("ib" glyph). Every node of that cluster with an ib_ip gets a 200g link, deduped across clusters sharing the fabric.
  • A cluster with ib_ip nodes but no blocking switch (2nodes-dac) = direct-attach copper: dac links between consecutive ranks.
  • Every node gets a 10g link to the pseudo-switch "lan" — the passive 10G switch is drawn UI-side (switchesSvg always appends it); it has no HA state, ever. Don't add it to topo.switches.
  • Non-blocking extras that aren't node plugs = "aux" glyphs (the fan).

Layout & frame algorithm (ui.html)

  • Geometry anchors are hard-coded in three places that must stay in sync: tgPos (rows y=74/386), tgCablePath (cable targets 210/266/290), swSvg/fanSvg (boxes 210/290, fan cy=318), legend 526, TG_H 540.
  • Frames: clusters with identical node sets merge into one rect with side-by-side chips (kills doubled frames + mutual-containment depth inflation). Pads grow with region rank Δ = regionMaxDepth − depth: x 14+10Δ, top 12+18Δ, bottom 12+5Δ — outer cluster sits furthest out on both edges, so chips land on separate "floors" (y 18/36/54) and bottom edges don't stack into one line.
  • Render order: all frame rects first, then all chips — no frame edge ever crosses a label. Chips x-stagger per floor band (cursor walks rect.x+12, widths ≈ len*6.6+30).
  • Hues: TG_HUES palette (10 hues, green/red reserved for up/down), stable hash of the cluster name; a chip never shares its hue with a frame containing it (rotate +1 until unique; containers sort first by area).
  • State ticks: rebuild the SVG only when topoSig changes (config edit); topoTick flips classes in place (pled on/b/off, .unpowered when ha === false, .live flows, port .on, frame .down = dashed+dim, chip dot) so the CSS animations (dash flow, fan spin, LED pulse) keep running. prefers-reduced-motion is covered by the global kill switch.
  • Flow semantics: 10g live = node reachable; 200g live = node occupied; dac live = both endpoints occupied. Passive 10G glyph is never touched by tick. Merged-frame tooltips come from state.topoGroupTitles.

Verification ladder (run all of it after visual changes)

  1. Syntax: extract the <script> from ui.html (python regex) → node --check.
  2. Logic probe (/tmp/topo_probe.cjs, recreate as needed): stubs $, esc, state, wins, front, minimal document; evals only the topo slice. Gotchas: the slice must be prefixed with // (the split eats the comment start); const bindings don't leak from eval — export them via globalThis.__x = {...} appended to the eval string; the probe must assert no Infinity/NaN in output (a name-vs-object membership bug once shipped as Infinity coords).
  3. Stage + screenshot: build a standalone page (extracted CSS + topo slice + the real config.toml topology) → deno run -A Playwright → screenshot; dump the rendered #topo innerHTML — the file text is not the DOM (innerHTML assignment happens at runtime).
  4. Geometry audit on the rendered HTML: counts, viewBox bounds, no device overlaps, switches clear of devices, frames clear of the switch corridor, no frame edge cuts, no chip overlaps (the whole point of the floors), cable endpoints hit the right switch edges. Parse tags attr-wise — browsers re-serialize attribute order, positional regexes break; </title> inside a regex literal needs <\/.
  5. cargo test ui_playwright_checks (fake fleet: 2 devs, 2 lan cables, 2 frames, 1 passive glyph; chip-click selector #topo .fchip[data-cluster="fake2"]; hide all other windows before clicking — earlier steps leave log/raw windows stacked over the map).
  6. Routine: cargo fmt → make check → cargo install --path . (ui.html is include_str!'d — a stale binary serves stale pages) → commit → push. Parallel sessions may sweep your working-tree changes into their commits — check git log --stat after pushing.