Bläddra i källkod

web: topology map window — physical 2x4 GX10 grid, 200G/10G/DAC cables, HA glyphs, cluster frames; derive_topo from config facts; per-node HA plug states in probe

Alexander Korolev 1 månad sedan
förälder
incheckning
69690f0faf
8 ändrade filer med 709 tillägg och 15 borttagningar
  1. 7 4
      AGENTS.md
  2. 29 4
      src/web/events.rs
  3. 2 1
      src/web/mod.rs
  4. 44 0
      src/web/probe.rs
  5. 3 0
      src/web/tests.rs
  6. 255 0
      src/web/topo.rs
  7. 333 5
      src/web/ui.html
  8. 36 1
      src/web/ui.playwright.mjs

+ 7 - 4
AGENTS.md

@@ -58,10 +58,13 @@ each run shows a provenance chip (`⚙ img@<buildsum12>·rcp@<sha12>`, ⚠ when
 the recipe has since drifted from the copy) and the markdown table gets a
 Provenance column. Old runs without a package show `—` / no chip.
 
-`sm121 web` — local axum + SSE dashboard: LED board (cluster-level health,
-non-blocking `extra_entities` as ⚙ badges), themes, serve/stop/power jobs
-with type-the-name power gate, live log-less jobs window fed by docker
-labels + SSE. UI checks: `cargo test ui_playwright_checks` (deno +
+`sm121 web` — local axum + SSE dashboard: topology map (physical layer:
+8 GX10 boxes on a 2×4 grid, 200G fabric switch + passive 10G switch + DAC
+direct links derived from config `ib_ip`/`extra_entities`, HA glyphs with
+live states, cluster frames — click a frame chip to activate the cluster),
+LED board (cluster-level health, non-blocking `extra_entities` as ⚙ badges),
+themes, serve/stop/power jobs with type-the-name power gate, live log-less
+jobs window fed by docker labels + SSE. UI checks: `cargo test ui_playwright_checks` (deno +
 Playwright; one-time browser: `deno run -A src/web/pw-install.mjs`).
 Design/progress: `specs/tech-architecture/web-dashboard.md`.
 

+ 29 - 4
src/web/events.rs

@@ -7,6 +7,7 @@
 use crate::config::Config;
 use crate::web::probe::ContainerSnap;
 use serde::Serialize;
+use std::collections::HashMap;
 use std::time::{SystemTime, UNIX_EPOCH};
 
 /// One SSE event (serialized as `{"type":"state"|"job", ...}` on the wire).
@@ -47,6 +48,9 @@ pub struct NodeSnap {
     pub containers: Vec<ContainerSnap>,
     /// Node rank — log windows attach the rank's `docker logs` stream.
     pub rank: u32,
+    /// The node's HA power plug (`node.entity`) reports "on"; `None` = no
+    /// entity or HA unreachable (don't darken the device on a probe lag).
+    pub ha: Option<bool>,
 }
 
 /// Per-cluster view.
@@ -82,6 +86,8 @@ pub struct AttachSnap {
 pub struct Snapshot {
     pub ts: u64,
     pub clusters: Vec<ClusterSnap>,
+    /// The fixed physical map (nodes, cables, HA glyphs).
+    pub topo: crate::web::topo::TopoSnap,
     pub jobs: Vec<crate::web::jobs::Job>,
     pub attaches: Vec<AttachSnap>,
 }
@@ -103,6 +109,7 @@ pub fn build_snapshot(
     fleet: &crate::web::probe::Fleet,
     jobs: &[crate::web::jobs::Job],
     attaches: &[AttachSnap],
+    ha: &HashMap<String, Option<bool>>,
 ) -> Snapshot {
     let nodes = &fleet.nodes;
     let switch_off = &fleet.switch_off;
@@ -137,6 +144,10 @@ pub fn build_snapshot(
                 occupied,
                 containers,
                 rank: n.rank,
+                ha: n
+                    .entity
+                    .as_deref()
+                    .and_then(|e| ha.get(e).copied().flatten()),
             });
         }
         clusters.push(ClusterSnap {
@@ -148,9 +159,19 @@ pub fn build_snapshot(
             free: v.free,
         });
     }
+    let mut topo = crate::web::topo::derive_topo(config);
+    topo.states = topo
+        .switches
+        .iter()
+        .map(|s| crate::web::topo::TopoState {
+            entity: s.entity.clone(),
+            on: ha.get(&s.entity).copied().flatten(),
+        })
+        .collect();
     Snapshot {
         ts: now_ts(),
         clusters,
+        topo,
         jobs: jobs.to_vec(),
         attaches: attaches.to_vec(),
     }
@@ -248,8 +269,9 @@ mod tests {
             nodes,
             switch_off: std::collections::HashMap::new(),
             soft_off: std::collections::HashMap::new(),
+            ha: std::collections::HashMap::new(),
         };
-        let snap = build_snapshot(&config, &fleet, &[], &[]);
+        let snap = build_snapshot(&config, &fleet, &[], &[], &HashMap::new());
         assert_eq!(snap.clusters.len(), 2);
         let a = &snap.clusters[0];
         assert!(a.up);
@@ -290,8 +312,9 @@ mod tests {
             nodes,
             switch_off: std::collections::HashMap::new(),
             soft_off: std::collections::HashMap::new(),
+            ha: std::collections::HashMap::new(),
         };
-        let snap = build_snapshot(&config, &fleet, &[], &[]);
+        let snap = build_snapshot(&config, &fleet, &[], &[], &HashMap::new());
         let a = &snap.clusters[0];
         assert!(!a.up, "one offline node takes the cluster down");
         assert_eq!(a.free, 0);
@@ -322,8 +345,9 @@ mod tests {
             nodes,
             switch_off: so,
             soft_off: std::collections::HashMap::new(),
+            ha: std::collections::HashMap::new(),
         };
-        let snap = build_snapshot(&config, &fleet, &[], &[]);
+        let snap = build_snapshot(&config, &fleet, &[], &[], &HashMap::new());
         assert!(!snap.clusters[0].up);
         assert!(snap.clusters[0].switch_off);
         assert_eq!(snap.clusters[0].free, 0);
@@ -353,8 +377,9 @@ mod tests {
             nodes,
             switch_off: HashMap::new(),
             soft_off: soft,
+            ha: std::collections::HashMap::new(),
         };
-        let snap = build_snapshot(&config, &fleet, &[], &[]);
+        let snap = build_snapshot(&config, &fleet, &[], &[], &HashMap::new());
         assert!(
             snap.clusters[0].up,
             "non-blocking off must not down the cluster"

+ 2 - 1
src/web/mod.rs

@@ -14,6 +14,7 @@ pub mod jobs;
 pub mod logs;
 pub mod probe;
 pub mod ssh_stream;
+pub mod topo;
 
 use crate::config::Config;
 use crate::web::events::{Event, build_snapshot};
@@ -69,7 +70,7 @@ pub(crate) async fn push_state(app: &AppState) {
             done: a.done.load(std::sync::atomic::Ordering::Relaxed),
         })
         .collect();
-    let snap = build_snapshot(&app.config, &fleet, &jobs, &attaches);
+    let snap = build_snapshot(&app.config, &fleet, &jobs, &attaches, &fleet.ha);
     let _ = app.bus.send(Event::State {
         ts: snap.ts,
         snapshot: snap.clone(),

+ 44 - 0
src/web/probe.rs

@@ -29,6 +29,49 @@ pub struct Fleet {
     pub switch_off: HashMap<String, bool>,
     /// cluster name → non-blocking entities that are off (display only).
     pub soft_off: HashMap<String, Vec<String>>,
+    /// HA entity id → on/off (`None` = unknown/unreachable) for every
+    /// node power plug and extra entity — the topology map's HA states.
+    pub ha: HashMap<String, Option<bool>>,
+}
+
+/// HA state per entity id (node power plugs + extra entities): on/off,
+/// `None` when HA is unconfigured or the entity is unknown.
+async fn ha_states(config: &Config) -> HashMap<String, Option<bool>> {
+    let Some(ha_cfg) = &config.home_assistant else {
+        return HashMap::new();
+    };
+    let Ok(client) = crate::home_assistant::Client::new(ha_cfg) else {
+        return HashMap::new();
+    };
+    let mut ents: Vec<String> = Vec::new();
+    for c in &config.clusters {
+        for n in &c.nodes {
+            if let Some(e) = &n.entity
+                && !ents.contains(e)
+            {
+                ents.push(e.clone());
+            }
+        }
+        for e in &c.extra_entities {
+            let id = e.entity().to_string();
+            if !ents.contains(&id) {
+                ents.push(id);
+            }
+        }
+    }
+    let handles = ents.iter().map(|id| {
+        let client = client.clone();
+        let id = id.clone();
+        crate::locale::spawn_with_locale(async move {
+            let on = client.get_state(&id).await.map(|s| s == "on");
+            (id, on)
+        })
+    });
+    futures::future::join_all(handles)
+        .await
+        .into_iter()
+        .filter_map(|h| h.ok())
+        .collect()
 }
 
 /// One `docker ps` over SSH per unique node, parallel + one HA check per
@@ -118,5 +161,6 @@ pub async fn probe(config: &Config) -> Fleet {
         nodes: out,
         switch_off,
         soft_off,
+        ha: ha_states(config).await,
     }
 }

+ 3 - 0
src/web/tests.rs

@@ -131,6 +131,7 @@ async fn bus_delivers_state_events() {
         ts: 1,
         clusters: Vec::new(),
         jobs: Vec::new(),
+        topo: Default::default(),
         attaches: Vec::new(),
     };
     tx.send(Event::State {
@@ -159,9 +160,11 @@ async fn snapshot_serializes_with_type_tag() {
                 nodes: std::collections::HashMap::new(),
                 switch_off: std::collections::HashMap::new(),
                 soft_off: std::collections::HashMap::new(),
+                ha: std::collections::HashMap::new(),
             },
             &[],
             &[],
+            &std::collections::HashMap::new(),
         ),
     };
     let json = serde_json::to_value(&ev).unwrap();

+ 255 - 0
src/web/topo.rs

@@ -0,0 +1,255 @@
+//! Physical topology derivation for the dashboard map.
+//!
+//! Pure config → map translation: nodes, cables, HA glyphs. The UI draws
+//! the fixed scene; the snapshot builder only attaches live HA states.
+
+use crate::config::Config;
+use serde::Serialize;
+
+/// One cable on the map: `a` → `b`, where `a` is a switch entity id, the
+/// pseudo-switch `"lan"` (the passive 10G), or a node name (DAC), and `b`
+/// is a node name.
+#[derive(Serialize, Clone, Debug, PartialEq)]
+pub struct TopoLink {
+    /// "200g" (HA-managed fabric switch), "dac" (direct-attach copper),
+    /// "10g" (passive 10G switch).
+    pub kind: String,
+    pub a: String,
+    pub b: String,
+}
+
+/// An HA-managed device glyph: the blocking fabric switch ("ib") or an
+/// auxiliary entity like a fan ("aux").
+#[derive(Serialize, Clone, Debug, PartialEq)]
+pub struct TopoSwitch {
+    pub entity: String,
+    pub kind: String,
+}
+
+/// HA state for a map glyph: `on`/`off`, `None` = unknown/unreachable.
+#[derive(Serialize, Clone, Debug, PartialEq)]
+pub struct TopoState {
+    pub entity: String,
+    pub on: Option<bool>,
+}
+
+/// The fixed physical map, derived from config facts (see [`derive_topo`]).
+#[derive(Serialize, Clone, Debug, Default, PartialEq)]
+pub struct TopoSnap {
+    /// Every configured node, sorted by slot (digits parsed from the name).
+    pub nodes: Vec<String>,
+    pub links: Vec<TopoLink>,
+    pub switches: Vec<TopoSwitch>,
+    /// HA states for [`TopoSnap::switches`] (filled by the snapshot builder).
+    pub states: Vec<TopoState>,
+}
+
+/// Derive the fixed physical topology from the config alone:
+///
+/// - every configured node occupies its slot (`dgx7.lan` → slot 7) —
+///   absent slots render as "planned";
+/// - a node with an `ib_ip` hangs on its cluster's *blocking* switch
+///   entity that is not a node plug (the 200G fabric, HA-managed) —
+///   or, when the cluster has no blocking switch (`2nodes-dac`), the IB
+///   link is direct-attach copper between consecutive ranks;
+/// - every node hangs on the passive 10G switch (pseudo-entity `"lan"`,
+///   no HA state);
+/// - extra entities that are neither blocking-fabric switches nor node
+///   plugs appear as auxiliary glyphs (a fan).
+pub(crate) fn derive_topo(config: &Config) -> TopoSnap {
+    let slot = |n: &str| {
+        n.chars()
+            .filter(|c| c.is_ascii_digit())
+            .collect::<String>()
+            .parse::<u32>()
+            .unwrap_or(u32::MAX)
+    };
+    let mut nodes: Vec<String> = Vec::new();
+    let mut links: Vec<TopoLink> = Vec::new();
+    let mut switches: Vec<TopoSwitch> = Vec::new();
+    let push_switch = |switches: &mut Vec<TopoSwitch>, entity: &str, kind: &str| {
+        if !switches.iter().any(|s| s.entity == entity) {
+            switches.push(TopoSwitch {
+                entity: entity.to_string(),
+                kind: kind.to_string(),
+            });
+        }
+    };
+    for c in &config.clusters {
+        let node_entities: Vec<String> = c.nodes.iter().filter_map(|n| n.entity.clone()).collect();
+        for n in &c.nodes {
+            if !nodes.contains(&n.name) {
+                nodes.push(n.name.clone());
+            }
+        }
+        let ib: Vec<&crate::config::Node> = c.nodes.iter().filter(|n| n.ib_ip.is_some()).collect();
+        let blocking = c
+            .extra_entities
+            .iter()
+            .find(|e| e.blocks() && !node_entities.contains(&e.entity().to_string()));
+        if let Some(sw) = blocking {
+            push_switch(&mut switches, sw.entity(), "ib");
+            for n in ib {
+                let link = TopoLink {
+                    kind: "200g".into(),
+                    a: sw.entity().to_string(),
+                    b: n.name.clone(),
+                };
+                // the same physical cable appears in every cluster that
+                // shares the fabric — one line on the map
+                if !links.contains(&link) {
+                    links.push(link);
+                }
+            }
+        } else {
+            // An IB subnet with no switch fabric: direct-attach copper,
+            // point-to-point between consecutive ranks.
+            let mut ranked = ib;
+            ranked.sort_by_key(|n| n.rank);
+            for w in ranked.windows(2) {
+                links.push(TopoLink {
+                    kind: "dac".into(),
+                    a: w[0].name.clone(),
+                    b: w[1].name.clone(),
+                });
+            }
+        }
+        for e in &c.extra_entities {
+            if !e.blocks() && !node_entities.contains(&e.entity().to_string()) {
+                push_switch(&mut switches, e.entity(), "aux");
+            }
+        }
+    }
+    nodes.sort_by_key(|n| (slot(n), n.clone()));
+    for n in &nodes {
+        links.push(TopoLink {
+            kind: "10g".into(),
+            a: "lan".into(),
+            b: n.clone(),
+        });
+    }
+    TopoSnap {
+        nodes,
+        links,
+        switches,
+        states: Vec::new(),
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use std::collections::HashMap;
+
+    fn node(name: &str, ib: Option<&str>, entity: Option<&str>, rank: u32) -> crate::config::Node {
+        crate::config::Node {
+            name: name.into(),
+            ssh_ip: "10.0.0.1".into(),
+            ib_ip: ib.map(String::from),
+            rank,
+            host_key: None,
+            entity: entity.map(String::from),
+        }
+    }
+
+    fn cluster(
+        name: &str,
+        nodes: Vec<crate::config::Node>,
+        extra: Vec<crate::config::ExtraEntity>,
+    ) -> crate::config::Cluster {
+        crate::config::Cluster {
+            name: name.into(),
+            master_ib_ip: None,
+            nodes,
+            extra_entities: extra,
+        }
+    }
+
+    #[test]
+    fn derive_topo_fabric_dac_lan() {
+        let config = Config {
+            ssh_user: "u".into(),
+            ssh_key_path: "/k".into(),
+            nccl: HashMap::new(),
+            hf_cache_dir: None,
+            init_port: 25000,
+            before_serve: HashMap::new(),
+            exec_aliases: HashMap::new(),
+            home_assistant: None,
+            global_mods_dir: None,
+            drop_caches: true,
+            mount_aliases: HashMap::new(),
+            recipe_sources: Vec::new(),
+            docker_image_sources: Vec::new(),
+            cluster_sources: Vec::new(),
+            clusters: vec![
+                // fabric: blocking switch + ib nodes → 200g links
+                cluster(
+                    "fabric",
+                    vec![
+                        node("dgx2.lan", Some("10.0.200.2"), Some("switch.dgx2"), 1),
+                        node("dgx1.lan", Some("10.0.200.1"), Some("switch.dgx1"), 0),
+                    ],
+                    vec![
+                        crate::config::ExtraEntity::Simple("switch.sw6".into()),
+                        crate::config::ExtraEntity::Detailed {
+                            entity: "switch.dgx1".into(),
+                            blocks: true,
+                        },
+                    ],
+                ),
+                // dac: ib nodes, no blocking switch → direct chain by rank
+                cluster(
+                    "dac",
+                    vec![
+                        node("dgx1.lan", Some("10.0.100.1"), Some("switch.dgx1"), 0),
+                        node("dgx2.lan", Some("10.0.100.2"), Some("switch.dgx2"), 1),
+                    ],
+                    vec![],
+                ),
+                // lan-only nodes (no ib_ip), plus a fan extra
+                cluster(
+                    "solo",
+                    vec![node("dgx10.lan", None, Some("switch.dgx10"), 0)],
+                    vec![crate::config::ExtraEntity::Detailed {
+                        entity: "switch.fan".into(),
+                        blocks: false,
+                    }],
+                ),
+            ],
+        };
+        let t = derive_topo(&config);
+        // slots: dgx1 < dgx2 < dgx10 (numeric, not lexicographic)
+        assert_eq!(t.nodes, vec!["dgx1.lan", "dgx2.lan", "dgx10.lan"]);
+        // 200g: every ib node of the fabric cluster → the blocking switch
+        assert_eq!(t.links.iter().filter(|l| l.kind == "200g").count(), 2);
+        assert!(
+            t.links
+                .iter()
+                .any(|l| l.kind == "200g" && l.a == "switch.sw6" && l.b == "dgx1.lan")
+        );
+        // dac chain between the ranks (dgx1 → dgx2)
+        assert!(
+            t.links
+                .iter()
+                .any(|l| l.kind == "dac" && l.a == "dgx1.lan" && l.b == "dgx2.lan")
+        );
+        // every node hangs on the passive lan
+        assert_eq!(t.links.iter().filter(|l| l.kind == "10g").count(), 3);
+        // switch glyphs: the fabric switch (ib, node-plug entities excluded)
+        // and the fan (aux) — deduped across clusters
+        assert_eq!(
+            t.switches,
+            vec![
+                TopoSwitch {
+                    entity: "switch.sw6".into(),
+                    kind: "ib".into()
+                },
+                TopoSwitch {
+                    entity: "switch.fan".into(),
+                    kind: "aux".into()
+                }
+            ]
+        );
+    }
+}

+ 333 - 5
src/web/ui.html

@@ -279,6 +279,62 @@
   @media (prefers-reduced-motion: reduce) {
     *, .led, .led.flip, .ssec.re .dot, .cluster, .job, .dlg, .win, .skeleton, .cled.exited, .abtn:disabled { animation: none !important; }
   }
+
+  /* --- topology map ------------------------------------------------------- */
+  #topo { height: 100%; overflow: hidden; }
+  #topo svg { width: 100%; height: 100%; display: block; }
+  #topo .topo-empty { height: 100%; display: grid; place-items: center; color: var(--muted); }
+  #topo text { font: 12px/1.25 ui-sans-serif, system-ui; fill: var(--fg); }
+  #topo .sub { fill: var(--muted); font-size: 10px; }
+  #topo .dev { transition: opacity .5s; }
+  #topo .dev.unpowered { opacity: .32; }
+  #topo .dev .body { fill: color-mix(in srgb, var(--panel) 72%, var(--border)); stroke: var(--border); stroke-width: 1.2; }
+  #topo .dev .top { fill: color-mix(in srgb, var(--fg) 10%, transparent); }
+  #topo .dev .vent { fill: color-mix(in srgb, var(--muted) 55%, transparent); }
+  #topo .dev .plate { fill: color-mix(in srgb, var(--bg) 45%, var(--panel)); }
+  #topo .dev .nm { font-size: 12.5px; font-weight: 600; }
+  #topo .dev .ip { font-size: 10px; fill: var(--muted); font-family: var(--mono); }
+  #topo .dev .ctr { fill: color-mix(in srgb, var(--led-free) 65%, transparent); stroke: var(--border); }
+  #topo .dev .ctr.hot { fill: color-mix(in srgb, var(--led-busy) 30%, transparent); }
+  #topo .dev .ctrt { font-size: 8.5px; fill: var(--muted); }
+  #topo .dev.planned .body { stroke-dasharray: 4 4; opacity: .5; }
+  .pled { fill: var(--led-free); }
+  .pled.on { fill: var(--led-on); filter: drop-shadow(0 0 3px var(--led-on)); }
+  .pled.b { fill: var(--led-busy); filter: drop-shadow(0 0 3px var(--led-busy)); animation: pulse 1.6s infinite; }
+  .pled.off { fill: var(--led-off); }
+  #topo .cable { fill: none; stroke-linecap: round; stroke-linejoin: round; }
+  #topo .c-200g { stroke: color-mix(in srgb, var(--accent) 60%, transparent); stroke-width: 2.6; }
+  #topo .c-dac { stroke: color-mix(in srgb, #a371f7 60%, transparent); stroke-width: 2.6; }
+  #topo .c-10g { stroke: color-mix(in srgb, var(--muted) 50%, transparent); stroke-width: 1.4; }
+  #topo .flow { opacity: 0; transition: opacity .6s; }
+  #topo .flow.live { opacity: .9; }
+  #topo .flow.c-200g { stroke: var(--accent); stroke-dasharray: 4 10; animation: flow 1.1s linear infinite; }
+  #topo .flow.c-dac { stroke: #a371f7; stroke-dasharray: 4 10; animation: flow 1.1s linear infinite; }
+  #topo .flow.c-10g { stroke: color-mix(in srgb, var(--fg) 50%, transparent); stroke-width: 1.5; stroke-dasharray: 3 9; animation: flow 1.6s linear infinite; }
+  @keyframes flow { to { stroke-dashoffset: -14; } }
+  #topo .port { fill: var(--led-free); }
+  #topo .port.on { fill: var(--led-on); }
+  #topo .swbox { fill: var(--panel); stroke: var(--border); stroke-width: 1.4; }
+  #topo .sw .swbox { transition: stroke .4s; }
+  #topo .sw.ib.on .swbox { stroke: color-mix(in srgb, var(--led-on) 65%, transparent); filter: drop-shadow(0 0 5px color-mix(in srgb, var(--led-on) 35%, transparent)); }
+  #topo .sw.ib.off .swbox { stroke: color-mix(in srgb, var(--led-off) 75%, transparent); }
+  #topo .sw.ib.unk .swbox { stroke: color-mix(in srgb, var(--led-busy) 60%, transparent); }
+  #topo .fan .fbody { fill: var(--panel); stroke: var(--border); stroke-width: 1.4; }
+  #topo .fan .blade { fill: color-mix(in srgb, var(--muted) 75%, transparent); }
+  #topo .fan .hub { fill: var(--border); }
+  #topo .fan .blades { transform-box: fill-box; transform-origin: center; animation: spin 1.4s linear infinite; }
+  #topo .fan.off .blades { animation: none; opacity: .4; }
+  #topo .fan.unk .blades { animation: spin 4s linear infinite; opacity: .55; }
+  @keyframes spin { to { transform: rotate(1turn); } }
+  #topo .cluster-frame { cursor: pointer; }
+  #topo .cluster-frame:hover .frame { stroke-width: 2.2; }
+  #topo .frame { fill: color-mix(in srgb, var(--led-on) 5%, transparent); stroke: color-mix(in srgb, var(--led-on) 40%, transparent); stroke-width: 1.5; transition: stroke .4s, fill .4s; }
+  #topo .cluster-frame.down .frame { fill: color-mix(in srgb, var(--led-off) 5%, transparent); stroke: color-mix(in srgb, var(--led-off) 45%, transparent); }
+  #topo .fchip rect { fill: var(--panel); stroke: var(--border); }
+  #topo .fchip text { font-size: 10.5px; fill: var(--muted); letter-spacing: .3px; }
+  #topo .fchip .cdot { fill: var(--led-off); }
+  #topo .fchip .cdot.on { fill: var(--led-on); }
+  #topo .legend text { font-size: 10.5px; fill: var(--muted); }
 </style>
 </head>
 <body>
@@ -457,6 +513,7 @@ function toggleDockMenu() {
     b.onclick = () => { closeDockMenu(); fn(); };
     m.appendChild(b);
   };
+  item("topo", () => { (wins.topo.isMinimized() ? wins.topo.restore() : front(wins.topo.win)); });
   item("state", () => { (wins.state.isMinimized() ? wins.state.restore() : front(wins.state.win)); });
   item("jobs", () => { (wins.jobs.isMinimized() ? wins.jobs.restore() : front(wins.jobs.win)); });
   item("logs…", openLogsPicker);
@@ -646,6 +703,7 @@ function onState(snap, force) {
   }
   state.snap = snap;
   state.jobs = new Map(snap.jobs.map((j) => [j.id, j]));
+  renderTopo(snap);
   if (!snap.clusters.find((c) => c.name === state.active)) {
     state.active = snap.clusters[0] ? snap.clusters[0].name : "";
   }
@@ -688,6 +746,264 @@ function restoreControls(name, recipe, dry) {
   }
 }
 
+// --- topology map -------------------------------------------------------------
+// The physical layer behind the windows: 8 GX10 boxes on a 2×4 grid, the
+// 200G fabric switch and the passive 10G switch in the middle corridor,
+// DAC direct links, HA glyphs (fan) and cluster frames. The structure is
+// rebuilt only when the topo signature changes (config edit); state ticks
+// just flip classes so flow/pulse/spin animations keep running.
+const TG_W = 1060, TG_H = 516;
+const tgPos = (i) => {
+  const cx = 20 + (i % 4) * 255;
+  return { x: cx + 37, y: i < 4 ? 30 : 370, w: 180, h: 104, dcx: cx + 127 };
+};
+const tgSlot = (n) => {
+  const m = String(n).match(/\d+/);
+  return m ? +m[0] : 999;
+};
+const tgIdx = (t, name) => t.nodes.findIndex((n) => n === name);
+
+function topoSig(t) {
+  return t.nodes.join(",") + "|" + t.links.map((l) => l.kind + l.a + ">" + l.b).join(",")
+    + "|" + t.switches.map((s) => s.entity).join(",");
+}
+
+function tgCablePath(l, t) {
+  const bi = tgIdx(t, l.b);
+  if (bi < 0) return "";
+  const b = tgPos(bi);
+  if (l.kind === "10g") {
+    const px = 396 + bi * 38;
+    if (bi < 4) { // row0: device bottom → 10G switch top
+      const ex = b.dcx + 40, ey = b.y + b.h;
+      return `M ${ex} ${ey} C ${ex} ${ey + 26} ${px} ${ey + 6} ${px} 170`;
+    }
+    return `M ${b.dcx} ${b.y} C ${b.dcx} ${b.y - 50} ${px} ${b.y - 80} ${px} 226`;
+  }
+  if (l.kind === "200g") { // row0: device bottom → fabric switch top
+    const ex = b.dcx - 40, ey = b.y + b.h;
+    const fi = t.links.filter((x) => x.kind === "200g").findIndex((x) => x.b === l.b);
+    const px = 396 + fi * 80;
+    return `M ${ex} ${ey} C ${ex} ${ey + 76} ${px} ${ey + 66} ${px} 250`;
+  }
+  // dac: arc between adjacent device tops
+  const ai = tgIdx(t, l.a);
+  if (ai < 0) return "";
+  const a = tgPos(ai);
+  const mx = (a.x + a.w + b.x) / 2;
+  return `M ${a.x + a.w - 4} ${a.y + 14} C ${mx} ${a.y - 22} ${mx} ${a.y - 22} ${b.x + 4} ${b.y + 14}`;
+}
+
+function devSvg(name, i) {
+  const d = tgPos(i);
+  return `<g class="dev" data-dev="${esc(name)}" transform="translate(${d.x},${d.y})">
+    <title>${esc(name)}</title>
+    <rect class="body" width="${d.w}" height="${d.h}" rx="10"/>
+    <rect class="top" x="4" y="3" width="${d.w - 8}" height="7" rx="3.5"/>
+    <rect class="vent" x="14" y="24" width="5" height="24" rx="2.5"/>
+    <rect class="vent" x="23" y="24" width="5" height="24" rx="2.5"/>
+    <rect class="vent" x="32" y="24" width="5" height="24" rx="2.5"/>
+    <rect class="ctr" x="122" y="18" width="52" height="15" rx="7.5"/>
+    <text class="ctrt" x="148" y="29" text-anchor="middle">…</text>
+    <rect class="plate" x="1" y="62" width="${d.w - 2}" height="41" rx="8"/>
+    <text class="nm" x="12" y="79">${esc(name)}</text>
+    <text class="ip" x="12" y="94">…</text>
+    <circle class="pled" cx="166" cy="74" r="4"/>
+  </g>`;
+}
+
+function swSvg(sw, entries) {
+  const label = sw.kind === "lan" ? "10G switch"
+    : sw.entity.includes(".") ? sw.entity.split(".")[1] : sw.entity;
+  const ib = sw.kind === "ib";
+  const y = ib ? 250 : 170;
+  const sub = ib ? "200G fabric · HA" : "passive · no HA";
+  const step = ib ? 80 : 38;
+  const rows = entries.map((p) => `<rect class="port" data-${ib ? "fab" : "lan"}port="${esc(p.name)}"
+    x="${396 + p.i * step - 7}" y="${p.top ? y + 2 : y + 49}" width="14" height="7" rx="2"/>`).join("");
+  return `<g class="sw ${sw.kind}" data-sw="${esc(sw.entity)}" transform="translate(0,0)">
+    <title>${esc(sw.entity)}</title>
+    <rect class="swbox" x="370" y="${y}" width="320" height="56" rx="8"/>
+    ${rows}
+    <text x="386" y="${y + 32}">${esc(label)}</text>
+    <text class="sub" x="386" y="${y + 46}">${sub}</text>
+    ${ib ? '<circle class="pled swled" cx="672" cy="' + (y + 40) + '" r="4"/>' : ""}
+  </g>`;
+}
+
+function fanSvg(sw) {
+  return `<g class="fan" data-sw="${esc(sw.entity)}">
+    <title>${esc(sw.entity)}</title>
+    <circle class="fbody" cx="770" cy="278" r="26"/>
+    <g class="blades">
+      <path class="blade" d="M770 254 C 781 258 783 270 775 276 L 770 278 Z"/>
+      <path class="blade" d="M770 254 C 781 258 783 270 775 276 L 770 278 Z" transform="rotate(120 770 278)"/>
+      <path class="blade" d="M770 254 C 781 258 783 270 775 276 L 770 278 Z" transform="rotate(240 770 278)"/>
+      <circle class="hub" cx="770" cy="278" r="5"/>
+    </g>
+    <text class="sub" x="770" y="322" text-anchor="middle">${esc(sw.entity.split(".")[1] || sw.entity)}</text>
+  </g>`;
+}
+
+function topoFrames(snap) {
+  const t = snap.topo;
+  const rects = [];
+  for (const c of snap.clusters) {
+    const ps = c.nodes.map((n) => tgPos(tgIdx(t, n.name))).filter(Boolean);
+    if (!ps.length) continue;
+    const x = Math.min(...ps.map((p) => p.x)) - 14;
+    const y = Math.min(...ps.map((p) => p.y)) - 24;
+    const w = Math.max(...ps.map((p) => p.x + p.w)) + 14 - x;
+    const h = Math.max(...ps.map((p) => p.y + p.h)) + 14 - y;
+    rects.push({ name: c.name, x, y, w, h, area: w * h });
+  }
+  rects.sort((a, b) => b.area - a.area); // big frames behind, nested on top
+  const depthCount = {};
+  let out = "";
+  for (const r of rects) {
+    const depth = rects.filter((o) => o !== r && o !== undefined && o.x <= r.x && o.y <= r.y
+      && o.x + o.w >= r.x && o.y + o.h >= r.y).length;
+    const n = (depthCount[depth] = (depthCount[depth] || 0) + 1);
+    const chipX = r.x + 10 + depth * 20 + (n - 1) * 14;
+    const cw = r.name.length * 6.6 + 30;
+    out += `<g class="cluster-frame" data-cluster="${esc(r.name)}">
+    <title>${esc(r.name)}</title>
+    <rect class="frame" x="${r.x}" y="${r.y}" width="${r.w}" height="${r.h}" rx="14"/>
+    <g class="fchip" transform="translate(${chipX},${r.y - 8})">
+      <rect width="${cw}" height="16" rx="8"/>
+      <circle class="cdot" cx="10" cy="8" r="3.5"/>
+      <text x="18" y="12">${esc(r.name)}</text>
+    </g>
+  </g>`;
+  }
+  return out;
+}
+
+function cablesSvg(t) {
+  let out = "";
+  for (const l of t.links) {
+    const p = tgCablePath(l, t);
+    if (!p) continue;
+    const key = `${l.kind}|${l.a}|${l.b}`;
+    out += `<path class="cable c-${l.kind}" d="${p}"/>`;
+    out += `<path class="cable c-${l.kind} flow" data-flow="${esc(key)}" d="${p}"/>`;
+  }
+  return out;
+}
+
+function switchesSvg(t) {
+  const fi = {};
+  const fabEntries = t.links.filter((l) => l.kind === "200g").map((l) => ({
+    name: l.b, i: (fi[l.b] = fi[l.b] ?? Object.keys(fi).length), top: true,
+  }));
+  const lanEntries = t.nodes.map((n, i) => ({ name: n, i, top: i < 4 }));
+  const glyphs = t.switches.map((s) => s.kind === "aux" ? fanSvg(s) : swSvg(s, fabEntries)).join("");
+  // the passive 10G switch has no HA entity — the UI draws it explicitly
+  return glyphs + swSvg({ entity: "lan", kind: "lan" }, lanEntries);
+}
+
+const devsSvg = (t) => t.nodes.map((n, i) => devSvg(n, i)).join("");
+
+function legendSvg() {
+  return `<g class="legend" transform="translate(20,502)">
+    <line x1="0" y1="0" x2="26" y2="0" class="cable c-200g"/><text x="32" y="4">200G fabric</text>
+    <line x1="120" y1="0" x2="146" y2="0" class="cable c-dac"/><text x="152" y="4">DAC direct</text>
+    <line x1="240" y1="0" x2="266" y2="0" class="cable c-10g"/><text x="272" y="4">10G mgmt</text>
+    <circle cx="352" cy="0" r="4" class="pled on"/><text x="362" y="4">plug on</text>
+    <circle cx="428" cy="0" r="4" class="pled off"/><text x="438" y="4">plug off / HA?</text>
+  </g>`;
+}
+
+// Per-tick state update: flip LED/port/frame/flow classes in place —
+// the SVG skeleton survives, so CSS animations keep running.
+function topoTick(snap) {
+  const t = snap.topo;
+  const ns = {};
+  for (const c of snap.clusters) for (const n of c.nodes) {
+    const s = (ns[n.name] ||= { reachable: false, occupied: false, ha: undefined, containers: [] });
+    s.reachable ||= n.reachable;
+    s.occupied ||= n.occupied;
+    if (!s.ip) s.ip = n.ssh_ip;
+    if (n.ha !== null) s.ha = n.ha;
+    for (const ct of n.containers) if (!s.containers.some((x) => x.name === ct.name)) s.containers.push(ct);
+  }
+  for (const name of t.nodes) {
+    const g = document.querySelector(`#topo g[data-dev="${CSS.escape(name)}"]`);
+    const s = ns[name];
+    if (!g || !s) continue;
+    const led = g.querySelector(".pled");
+    if (led) led.setAttribute("class", "pled " + (s.reachable ? (s.occupied ? "b" : "on") : "off"));
+    g.classList.toggle("unpowered", s.ha === false);
+    const ip = g.querySelector(".ip");
+    if (ip) ip.textContent = s.ip || "";
+    const ctr = g.querySelector(".ctrt"), chip = g.querySelector(".ctr");
+    const recipes = [...new Set(s.containers.map((x) => x.recipe))];
+    if (ctr) ctr.textContent = s.containers.length ? `${s.containers.length}·${recipes[0].slice(0, 7)}` : "idle";
+    if (chip) chip.classList.toggle("hot", s.containers.length > 0);
+    const tt = g.querySelector("title");
+    if (tt) tt.textContent = `${name} · ${s.ip || ""} · ${s.reachable ? (s.occupied ? "serving" : "free") : "down"}`
+      + (s.ha === false ? " · plug OFF" : "")
+      + (s.containers.length ? " · " + s.containers.map((x) => x.name).join(", ") : "");
+  }
+  for (const l of t.links) {
+    const fl = document.querySelector(`#topo path[data-flow="${CSS.escape(l.kind + "|" + l.a + "|" + l.b)}"]`);
+    if (!fl) continue;
+    const B = ns[l.b], A = l.kind === "dac" ? ns[l.a] : null;
+    fl.classList.toggle("live", l.kind === "10g" ? !!B?.reachable
+      : l.kind === "200g" ? !!B?.occupied
+      : !!(B?.occupied && A?.occupied));
+  }
+  for (const name of t.nodes) {
+    const lp = document.querySelector(`#topo rect[data-lanport="${CSS.escape(name)}"]`);
+    if (lp) lp.classList.toggle("on", !!ns[name]?.reachable);
+    const fp = document.querySelector(`#topo rect[data-fabport="${CSS.escape(name)}"]`);
+    if (fp) fp.classList.toggle("on", !!ns[name]?.occupied);
+  }
+  for (const sw of t.switches) {
+    const st = t.states.find((x) => x.entity === sw.entity);
+    const g = document.querySelector(`#topo [data-sw="${CSS.escape(sw.entity)}"]`);
+    if (!g) continue;
+    const cls = st?.on === true ? "on" : st?.on === false ? "off" : "unk";
+    g.classList.remove("on", "off", "unk");
+    g.classList.add(cls);
+    const led = g.querySelector(".swled");
+    if (led) led.setAttribute("class", "pled swled " + (st?.on === true ? "on" : st?.on === false ? "off" : ""));
+    const tt = g.querySelector("title");
+    if (tt) tt.textContent = `${sw.entity} · ${st?.on === true ? "on" : st?.on === false ? "OFF" : "state unknown"}`;
+  }
+  for (const c of snap.clusters) {
+    const g = document.querySelector(`#topo g[data-cluster="${CSS.escape(c.name)}"]`);
+    if (!g) continue;
+    g.classList.toggle("down", !c.up);
+    const dot = g.querySelector(".cdot");
+    if (dot) dot.setAttribute("class", "cdot " + (c.up ? "on" : "off"));
+    const tt = g.querySelector("title");
+    if (tt) tt.textContent = `${c.name} · ${c.up ? `up · ${c.free} free` : "down"}${c.switch_off ? " · HA switch off" : ""}`;
+  }
+}
+
+function renderTopo(snap) {
+  const host = document.getElementById("topo");
+  const t = snap.topo;
+  if (!host || !t) return;
+  const sig = topoSig(t);
+  if (state.topoSig !== sig) {
+    state.topoSig = sig;
+    host.innerHTML = `<svg viewBox="0 0 ${TG_W} ${TG_H}" preserveAspectRatio="xMidYMid meet">
+      ${topoFrames(snap)}${cablesSvg(t)}${switchesSvg(t)}${devsSvg(t)}${legendSvg()}
+    </svg>`;
+    for (const g of host.querySelectorAll(".cluster-frame")) {
+      g.onclick = () => {
+        state.active = g.dataset.cluster;
+        localStorage.setItem("sm121-active", state.active);
+        if (wins.state) wins.state.isMinimized() ? wins.state.restore() : front(wins.state.win);
+        onState(state.snap, true);
+      };
+    }
+  }
+  topoTick(snap);
+}
+
 // --- jobs ---------------------------------------------------------------------
 // "Running" entries come from the docker labels (sm121.recipe / sm121.cluster)
 // on live containers — jobs don't need to have started from this dashboard.
@@ -1122,15 +1438,27 @@ document.addEventListener("keydown", (e) => { if (e.key === "Escape") $("#dlg").
 
 // --- boot ---------------------------------------------------------------------------
 function initManagedWindows() {
-  const W = innerWidth, H = innerHeight;
+  const W = innerWidth;
+  // vertical budget = the actual window layer (header/dock already excluded);
+  // +40 reserve for the dock, which appears when a window gets minimized
+  const L = Math.max(420, $("#winlayer").clientHeight - 40);
+  // topology map: the primary window (left); state/jobs stack on the right
+  const tw = Math.max(560, Math.floor(W * 0.58));
+  const rx = tw + 30, rw = Math.max(340, W - rx - 12);
+  const rh = Math.max(240, Math.floor(L * 0.48));
+  wins.topo = makeWin({
+    id: "win-topo", title: "Topology", x: 12, y: 12,
+    w: tw, h: L - 12,
+    bodyHtml: `<div id="topo"><div class="topo-empty">waiting for the first fleet scan…</div></div>`,
+  });
   wins.state = makeWin({
-    id: "win-state", title: "Cluster state", x: 12, y: 12,
-    w: Math.max(430, Math.floor(W * 0.52)), h: Math.max(320, Math.floor(H * 0.78)),
+    id: "win-state", title: "Cluster state", x: rx, y: 12,
+    w: rw, h: rh,
     bodyHtml: `<div id="clusters"><div class="skeleton"></div><div class="skeleton"></div><div class="skeleton"></div></div>`,
   });
   wins.jobs = makeWin({
-    id: "win-jobs", title: "Jobs", x: Math.max(450, Math.floor(W * 0.52) + 30), y: 12,
-    w: Math.max(320, W - Math.floor(W * 0.52) - 50), h: Math.max(320, Math.floor(H * 0.78)),
+    id: "win-jobs", title: "Jobs", x: rx, y: rh + 24,
+    w: rw, h: L - rh - 24,
     bodyHtml: `<div class="jobs" id="jobs"><div class="jobs-empty">no sm121 containers running, no actions yet — serve / stop / power started from this dashboard appear here</div></div>`,
   });
   renderDock();

+ 36 - 1
src/web/ui.playwright.mjs

@@ -49,6 +49,26 @@ ok(
     (await page.locator("#jobs .jobs-empty").count()) === 1,
 );
 
+// --- topology map (structure) ---------------------------------------------------
+// The fake fleet: cluster "fake" (n0) + "fake2" (n1), both lan-only (no
+// ib_ip, no entities) → 2 devices, 2 lan cables, 2 frames, the passive
+// 10G glyph; no HA switch glyphs.
+await page.waitForSelector("#topo .dev", { timeout: 15000 });
+await page.waitForTimeout(300); // let the SSE settle — no mid-rebuild tearing
+const tc = await page.evaluate(() => ({
+  dev: document.querySelectorAll("#topo .dev").length,
+  cab: document.querySelectorAll("#topo path.c-10g:not(.flow)").length,
+  fr: document.querySelectorAll("#topo .cluster-frame").length,
+  sw: document.querySelectorAll("#topo .sw").length,
+}));
+ok(
+  "topo: 2 devices + 2 lan cables + 2 frames + passive 10G glyph",
+  tc.dev === 2 && tc.cab === 2 && tc.fr === 2 && tc.sw === 1,
+  JSON.stringify(tc),
+);
+
+
+
 // --- 2. offline node rendering ----------------------------------------------
 ok(
   "offline node: red LED + 'offline' meta",
@@ -156,6 +176,7 @@ const after = await page.locator("#win-state").boundingBox();
 ok(
   "titlebar drag moves the state window",
   Math.abs(after.x - (before.x + 80)) < 3 && Math.abs(after.y - (before.y + 50)) < 3,
+  `dx=${(after.x - before.x).toFixed(1)} dy=${(after.y - before.y).toFixed(1)}`,
 );
 
 // --- 10. minimize / restore ------------------------------------------------------
@@ -252,7 +273,7 @@ ok(
 await page.click("#dock .plus");
 ok(
   "dock + menu lists state / jobs / logs / raw",
-  (await page.locator(".dockmenu .dock-chip").count()) === 4,
+  (await page.locator(".dockmenu .dock-chip").count()) === 5, // topo / state / jobs / logs / raw
 );
 await page.click('.dockmenu .dock-chip:has-text("raw")');
 await page.waitForSelector(".rawwin", { timeout: 10000 });
@@ -282,6 +303,20 @@ ok(
   monoFont,
 );
 
+// --- topology map (interaction) ---------------------------------------------------
+// frame chip click activates the cluster (frames sit behind devices —
+// the chip is the always-clickable part); hide the other windows first —
+// earlier test steps leave log/raw windows stacked over the map
+await page.evaluate(() => {
+  for (const w of document.querySelectorAll(".win")) if (w.id !== "win-topo") w.style.display = "none";
+});
+await page.click('#topo [data-cluster="fake2"] .fchip');
+await page.waitForFunction(
+  () => document.querySelector("#clusters .chip.active")?.textContent?.includes("fake2"),
+  { timeout: 5000 },
+);
+ok("topo: frame chip click activates cluster", true);
+
 // --- no page JS errors across the whole flow -------------------------------------
 ok("no page JS errors", pageErrors.length === 0, pageErrors.join("; "));