--- 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 map is **scrollable**: `#topo` draws the SVG at container width with `min-width: 720px` and scrolls; a **cluster filter** — `#topofilter` chip top-right, checkboxes, persisted to `localStorage sm121-topo-hidden` — drops clusters from `topoFrames` *before* the depth calc, so hiding an outer frame shrinks the nest; the filter state is part of `topoSig`): 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; row-1 fabric = S-curve in the 346..386 corridor so the port approach is vertical; `swSvg` ib ports 4+4 across top/bottom edges), `swSvg`/`fanSvg` (boxes 210/290, fan cy=318), legend 526, `TG_H` 540. **Cable mode** (legend toggle `data-topobus`, `state.topoBus`, persisted `sm121-topobus`, folded into the rebuild sig): *direct* = bezier runs; *bus + bridges* = `tgCablePathBus` — row-1 Manhattan runs (200g lane y=366, 10g lane y=278; 10g detours around the fabric box via x=350/710) with `bridgesSvg` hop arcs (semicircle over the 200g lane) where a 10g vertical strictly crosses it — detour devices turn on the lane (a T), so no hop. Row 0 and DAC are unchanged in both modes. - 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, but **capped at 2** (`x 14+10·min(Δ,2)`, top `12+18·min(Δ,2)`, bottom `12+5·min(Δ,2)`) — nesting now runs 5 deep (4nodes ⊃ 6nodes ⊃ 8nodes ⊃ dgx58 ⊃ dgx56) and uncapped pads pushed the outer frame above y=0, where the viewBox clipped it. Chip bands are quantized (`round(y/16)`) so near-colliding floors stagger horizontally. - 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 `