Browse Source

release: sm121 0.2.3 — FLARE all-rank topology and orbit attach logs

Alexander Korolev 2 hours ago
parent
commit
dd8dc57843
13 changed files with 292 additions and 122 deletions
  1. 7 1
      AGENTS.md
  2. 17 0
      CHANGELOG.md
  3. 1 1
      Cargo.lock
  4. 1 1
      Cargo.toml
  5. 2 0
      specs/tech-architecture/web-dashboard.md
  6. 7 7
      src/docker/health/collect.rs
  7. 3 1
      src/handlers.rs
  8. 1 1
      src/native/flare.rs
  9. 5 2
      src/ssh.rs
  10. 151 0
      src/web/flare_status.rs
  11. 27 2
      src/web/logs.rs
  12. 1 0
      src/web/mod.rs
  13. 69 106
      src/web/probe.rs

+ 7 - 1
AGENTS.md

@@ -152,7 +152,8 @@ the action bar sits *above* the toml link): **▶ serve / ⏹ stop** (POST
 `/api/serve` / `/api/stop` with `cluster: "auto"` — the same tracked jobs the
 CLI uses; the state cell flips when the fleet probe sees the change), **▤ logs**
 (shown only while running — attaches the dashboard's `/api/logs` stream for a
-node hosting the recipe's container, node picker + live tail rendered inline),
+node hosting the recipe's container, node picker + live tail rendered inline;
+FLARE recipes stream `flare orbit attach` on the controller, not Docker logs),
 and **⚡ bench** (opens the bench window: choose recipe, cluster, target node,
 run host (`ssh_host`, e.g. `pluto.lan`), label, seed, concurrency, parallel,
 timeout, tokenizer, perf-only/depth/no-think; parameters persist in localStorage;
@@ -175,6 +176,11 @@ jobs window fed by docker labels + SSE. UI checks: `cargo test ui_playwright_che
 Playwright; one-time browser: `deno run -A src/web/pw-install.mjs`).
 Design/progress: `specs/tech-architecture/web-dashboard.md`.
 
+FLARE ranks are per-node: `health`/web read `flare orbit status` rows and fold a
+pseudo-container onto **every** rank node (a 4-rank FLARE serve = 4 busy nodes,
+`free 0`), deduping groups that merely appear in shared-node clusters (`all8`/
+`pair12`).
+
 Cluster: exact name, prefix (`4`→`4nodes-switch`), or `auto` (smallest with NCCL + matching `required_nodes`).
 
 `serve --solo` overrides cluster selection: finds the first free node across

+ 17 - 0
CHANGELOG.md

@@ -3,6 +3,23 @@
 Semantic versioning for the launcher CLI (`sm121`). The add-on image has its
 own version and its own `sm121/CHANGELOG.md`; do not mix them.
 
+## 0.2.3 — 2026-10-28
+
+- **FLARE topology/LED board: every rank node is busy.** `flare orbit status`
+  rows carry the orbit node and rank; the probe/`health` fold one pseudo-container
+  onto **each** rank node, not just the controller. A 4-rank FLARE serve now
+  shows 4 busy nodes and `free 0`, not one busy node and three idle.
+- **Shared-node dedup.** A group is visible in `all8`/`pair12` too because those
+  paired clusters share nodes; the lane is owned by the cluster whose node count
+  and the recipe's `required_nodes` match the group's rank rows. Duplicate
+  pseudo-containers on a shared node are collapsed by `(node, group)`.
+- **FLARE logs.** Web log windows detect `engine = "flare"` and stream
+  `flare orbit attach <group> --cluster <orbit>` on the controller (not Docker
+  logs). The CLI does the same and prints both SSH stdout/stderr; `run_ssh`'s
+  5 s inactivity timeout had killed the attach stream.
+- `GET /api/logs` history and attach keys work for FLARE; the recipe-row `▤ logs`
+  opens the controller's orbit session.
+
 ## 0.2.2 — 2026-10-28
 
 - **Dashboard bench window.** The recipes panel's `⚡ bench` button now opens a

+ 1 - 1
Cargo.lock

@@ -2941,7 +2941,7 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
 
 [[package]]
 name = "sm121"
-version = "0.2.2"
+version = "0.2.3"
 dependencies = [
  "anyhow",
  "axum",

+ 1 - 1
Cargo.toml

@@ -1,6 +1,6 @@
 [package]
 name = "sm121"
-version = "0.2.2"
+version = "0.2.3"
 edition = "2024"
 license = "MIT"
 

+ 2 - 0
specs/tech-architecture/web-dashboard.md

@@ -154,6 +154,8 @@ UI outgrows ~2–3k lines.)
 - **Window kinds** (parameterized views):
   - `state` — the LED grid (default, opens at start).
   - `logs {node, recipe, rank}` — live stream + history + pause + raw-copy.
+    FLARE recipes use the controller's `flare orbit attach <group> --cluster <orbit>`,
+    not Docker logs; the CLI does the same with a long-running SSH follow.
   - `jobs` — in-flight jobs with progress lines. Running containers are
     grouped **one row per launcher job** (docker `sm121.cluster` +
     `sm121.recipe` label) with the node list — a container is reported by

+ 7 - 7
src/docker/health/collect.rs

@@ -129,17 +129,17 @@ async fn collect_cluster(config: &Config, cluster: &Cluster, dry_run: bool) -> H
     // FLARE groups run without a docker container: fold them in so `health`
     // shows the lane as busy on the controller (the dashboard does the same).
     if config.flare.is_some() {
-        // Only THIS cluster's lane: several clusters overlap on the same
-        // nodes, so folding every lane in would show another orbit's groups
-        // as this cluster's containers.
+        // Every rank of THIS cluster's lane marks its node busy (a FLARE
+        // group has one rank per node; folding only the controller made the
+        // cluster look like one busy node with three idle).
         for lane in crate::web::probe::flare_lanes(config).await {
             if lane.cluster != cluster.name {
                 continue;
             }
-            for node in &mut nodes {
-                if node.name == lane.controller {
-                    for (group, _) in &lane.groups {
-                        node.containers.push(format!("{group}\tUp (flare)"));
+            for rank in &lane.ranks {
+                for node in &mut nodes {
+                    if node.name == rank.node {
+                        node.containers.push(format!("{}\tUp (flare)", rank.group));
                     }
                 }
             }

+ 3 - 1
src/handlers.rs

@@ -35,7 +35,9 @@ pub(crate) async fn handle_logs(
             .ok_or_else(|| anyhow::anyhow!("no head node (rank 0) in cluster"))?;
         let orbit = crate::native::flare::orbit_cluster_for(config, &cluster.name);
         let cmd = crate::native::flare::attach_cmd(config, &recipe_obj, &orbit)?;
-        crate::ssh::run_ssh(config, head, cmd, dry_run).await?;
+        // `orbit attach` is a long-lived stream; `run_ssh`'s 5 s inactivity
+        // timeout killed it before any log line reached the operator.
+        crate::ssh::run_ssh_follow(config, head, cmd, dry_run).await?;
         return Ok(());
     }
     docker::show_logs(

+ 1 - 1
src/native/flare.rs

@@ -121,7 +121,7 @@ pub(crate) fn local_dir_for(config: &Config, cluster: &str) -> Option<String> {
 
 /// The orbit-side name for one of our nodes (rank order → `orbit_nodes`,
 /// falling back to the cluster's own name).
-fn orbit_node_name(config: &Config, cluster: &str, node: &Node) -> String {
+pub(crate) fn orbit_node_name(config: &Config, cluster: &str, node: &Node) -> String {
     let f = config.flare.as_ref();
     let per_cluster = f
         .and_then(|f| f.clusters.get(cluster))

+ 5 - 2
src/ssh.rs

@@ -181,8 +181,11 @@ pub async fn run_ssh_follow(
     channel.exec(true, command.as_bytes()).await?;
 
     while let Some(msg) = channel.wait().await {
-        if let ChannelMsg::Data { data } = msg {
-            print!("{}", String::from_utf8_lossy(&data));
+        match msg {
+            ChannelMsg::Data { data } | ChannelMsg::ExtendedData { data, .. } => {
+                print!("{}", String::from_utf8_lossy(&data));
+            }
+            _ => {}
         }
     }
     Ok(())

+ 151 - 0
src/web/flare_status.rs

@@ -0,0 +1,151 @@
+//! `flare orbit status` parsing: group rows carry the orbit node and rank,
+//! so the dashboard and `health` can mark **every** node of the FLARE lane
+//! busy, not just the controller that runs `orbit`.
+//!
+//! The table is read as `<orbit-node> … <group> rank <n>`; an exited/stopped
+//! group must not be counted. The start timestamp comes from the `held by
+//! <group>-<YYYYMMDDTHHMMSSZ>-<hex>` detail line.
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct OrbitRank {
+    /// Orbit node name (`gx10-dgx1`).
+    pub node: String,
+    /// Orbit group base name (the recipe's `[flare].name`).
+    pub group: String,
+    pub created: Option<u64>,
+}
+
+pub fn parse_orbit_ranks(stdout: &str) -> Vec<OrbitRank> {
+    let mut rows: Vec<OrbitRank> = Vec::new();
+    for line in stdout.lines() {
+        if line.contains("exited") || line.contains("stopped") {
+            continue;
+        }
+        let tokens: Vec<&str> = line.split_whitespace().collect();
+        let node = tokens.first().copied().unwrap_or("");
+        if !node.starts_with("gx10") {
+            continue;
+        }
+        let Some(rank_pos) = tokens.iter().position(|t| *t == "rank") else {
+            continue;
+        };
+        if rank_pos == 0 {
+            continue;
+        }
+        let group = tokens[rank_pos - 1].trim_matches(|c: char| c == '`' || c == '"' || c == '\'');
+        if group.is_empty()
+            || group == "-"
+            || !group
+                .chars()
+                .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
+        {
+            continue;
+        }
+        rows.push(OrbitRank {
+            node: node.to_string(),
+            group: group.to_string(),
+            created: None,
+        });
+    }
+
+    // Attach the timestamp: `held by <group>-<stamp>-<hex>`.
+    let live: String = stdout
+        .lines()
+        .filter(|l| !(l.contains("exited") || l.contains("stopped")))
+        .collect::<Vec<_>>()
+        .join("\n");
+    for tok in live.split(|c: char| c.is_whitespace() || c == ',') {
+        let t = tok.trim_matches(|c: char| c == '`' || c == '"' || c == '\'');
+        let parts: Vec<&str> = t.split('-').collect();
+        if parts.len() < 3 {
+            continue;
+        }
+        let base = parts[..parts.len() - 2].join("-");
+        let stamp = parts[parts.len() - 2];
+        let Some(stamp) = stamp.strip_suffix('Z') else {
+            continue;
+        };
+        let Some((d, hms)) = stamp.split_once('T') else {
+            continue;
+        };
+        if d.len() != 8 || hms.len() != 6 || base.is_empty() {
+            continue;
+        }
+        let Some(created) = epoch_from_stamp(d, hms) else {
+            continue;
+        };
+        for row in rows.iter_mut().filter(|r| r.group == base) {
+            row.created = Some(created);
+        }
+    }
+    rows
+}
+
+/// Distinct groups (the old group-list view).
+pub fn parse_orbit_groups(stdout: &str) -> Vec<(String, Option<u64>)> {
+    let mut out: Vec<(String, Option<u64>)> = Vec::new();
+    for row in parse_orbit_ranks(stdout) {
+        if let Some(slot) = out.iter_mut().find(|(n, _)| n == &row.group) {
+            slot.1 = slot.1.or(row.created);
+        } else {
+            out.push((row.group.clone(), row.created));
+        }
+    }
+    out
+}
+
+fn epoch_from_stamp(d: &str, hms: &str) -> Option<u64> {
+    let num = |s: &str| s.parse::<i64>().ok();
+    let (Some(y), Some(mo), Some(da)) = (num(&d[0..4]), num(&d[4..6]), num(&d[6..8])) else {
+        return None;
+    };
+    let (Some(h), Some(mi), Some(se)) = (num(&hms[0..2]), num(&hms[2..4]), num(&hms[4..6])) else {
+        return None;
+    };
+    let y2 = y - if mo <= 2 { 1 } else { 0 };
+    let era = if y2 >= 0 { y2 } else { y2 - 399 } / 400;
+    let yoe = y2 - era * 400;
+    let mp = (mo + 9) % 12;
+    let doy = (153 * mp + 2) / 5 + da - 1;
+    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
+    let secs = (era * 146_097 + doe - 719_468) * 86_400 + h * 3600 + mi * 60 + se;
+    u64::try_from(secs).ok()
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn rows_carry_node_and_group() {
+        let out = "cluster prod4\n\
+                   NODE       FINGERPRINT   FREE GiB  REVISION  GROUPS\n\
+                   gx10-dgx1  f504d885832c     266.5         1  dsv41-flash-1017-4x-flare-gb10 rank 0\n\
+                               gpu 0 NVIDIA GB10 (gb10) 117.0/121.6 GiB free, held by dsv41-flash-1017-4x-flare-gb10-20261028T085613Z-5425\n\
+                   gx10-dgx2  adacb037c512     299.0         1  dsv41-flash-1017-4x-flare-gb10 rank 1\n";
+        let rows = parse_orbit_ranks(out);
+        assert_eq!(rows.len(), 2, "{rows:?}");
+        assert_eq!(rows[0].node, "gx10-dgx1");
+        assert_eq!(rows[1].node, "gx10-dgx2");
+        assert_eq!(rows[0].group, "dsv41-flash-1017-4x-flare-gb10");
+        assert!(rows.iter().all(|r| r.created.is_some()), "{rows:?}");
+    }
+
+    #[test]
+    fn exited_groups_are_not_rows() {
+        let out =
+            "gx10-dgx5  ab12  86.1  1  grpA-20261024T062753Z-3dc3 rank 0 exited with status 1\n";
+        assert!(parse_orbit_ranks(out).is_empty());
+        assert!(parse_orbit_groups(out).is_empty());
+    }
+
+    #[test]
+    fn no_groups_parse_empty() {
+        assert!(
+            parse_orbit_groups(
+                "cluster x\nNODE FINGERPRINT FREE GiB REVISION GROUPS\ngx10-dgx5 ab12 562.3 1 -\n"
+            )
+            .is_empty()
+        );
+    }
+}

+ 27 - 2
src/web/logs.rs

@@ -10,6 +10,7 @@ use std::sync::{Arc, Mutex};
 
 use tokio::task::JoinHandle;
 
+use crate::config::Recipe;
 use crate::ssh::shq;
 use crate::web::events::Event;
 use crate::web::ssh_stream::stream_lines;
@@ -106,9 +107,33 @@ pub fn attach(
         .iter()
         .find(|n| n.name == node)
         .ok_or_else(|| format!("unknown node '{node}' in cluster '{cluster}'"))?;
+    // FLARE has no docker container: `flare orbit attach` is the log stream,
+    // and it lives on the orbit controller (not the requested node).
+    let recipe_obj = crate::config::recipe_file(recipe, &config.recipe_sources)
+        .and_then(|p| std::fs::read_to_string(p).ok())
+        .and_then(|t| toml::from_str::<Recipe>(&t).ok());
+    let is_flare = recipe_obj.as_ref().is_some_and(|r| r.engine == "flare");
+    let mut node_cfg: &crate::config::Node = node_cfg;
+    let mut rank = rank;
+    let cmd;
+    if is_flare {
+        let recipe_obj = recipe_obj.expect("recipe parsed");
+        let ctl = crate::web::flare_nodes::pinned_for(config, cl)
+            .or_else(|| cl.nodes.iter().find(|n| n.rank == 0))
+            .ok_or_else(|| "FLARE cluster has no controller".to_string())?;
+        let orbit = crate::native::flare::orbit_cluster_paired(config, &cl.name)
+            .or_else(|| Some(crate::native::flare::orbit_cluster_for(config, &cl.name)))
+            .ok_or_else(|| "FLARE cluster is not paired with an orbit".to_string())?;
+        node_cfg = ctl;
+        rank = 0;
+        cmd = crate::native::flare::attach_cmd(config, &recipe_obj, &orbit)
+            .map_err(|e| e.to_string())?;
+    } else {
+        cmd = logs_stream_cmd(recipe, rank, TAIL_LINES);
+    }
     let key: AttachKey = (
         cluster.to_string(),
-        node.to_string(),
+        node_cfg.name.clone(),
         recipe.to_string(),
         rank,
     );
@@ -133,7 +158,7 @@ pub fn attach(
         Arc::new(Mutex::new(VecDeque::with_capacity(RING_LINES)));
     let stop = Arc::new(AtomicBool::new(false));
     let done = Arc::new(AtomicBool::new(false));
-    let cmd = logs_stream_cmd(recipe, rank, TAIL_LINES);
+    // cmd was selected above (docker or FLARE attach).
     let (h, s, d) = (history.clone(), stop.clone(), done.clone());
     let (cfg, node_obj, bus) = (config.clone(), node_cfg.clone(), app.bus.clone());
     let (idc, cluster_s, node_s, recipe_s) = (

+ 1 - 0
src/web/mod.rs

@@ -13,6 +13,7 @@ mod bench_api;
 mod cache_api;
 pub mod events;
 pub(crate) mod flare_nodes;
+pub(crate) mod flare_status;
 mod hf_api;
 pub mod jobs;
 pub mod logs;

+ 69 - 106
src/web/probe.rs

@@ -2,8 +2,10 @@
 //! running sm121 containers (the stop-confirm dialog names them).
 
 use crate::config::Config;
+use crate::config::Recipe;
 use crate::ssh::run_ssh;
-use std::collections::HashMap;
+use crate::web::flare_status::{OrbitRank, parse_orbit_groups, parse_orbit_ranks};
+use std::collections::{HashMap, HashSet};
 
 /// Nodes already warned as unreachable (the probe runs every 30 s —
 /// log only the reachable → unreachable transition, clear on recovery).
@@ -71,97 +73,6 @@ pub fn created_epoch(s: &str) -> Option<u64> {
     u64::try_from(secs).ok()
 }
 
-/// Parse `flare orbit status --cluster <c>` into (group, created-epoch) pairs.
-///
-/// The status table lists `… GROUPS` per node plus a `held by
-/// <name>-<YYYYMMDDTHHMMSSZ>-<hex>` detail line; a group name carries its
-/// start time, so the dashboard gets an uptime without a run ledger (the same
-/// reason the docker path reads `CreatedAt`).
-pub fn parse_orbit_groups(stdout: &str) -> Vec<(String, Option<u64>)> {
-    let mut out: Vec<(String, Option<u64>)> = Vec::new();
-    // The GROUPS column reads `<group> rank <n>`; the start time only appears
-    // in the `held by <group>-<stamp>-<hex>` detail line, so collect names
-    // first and attach a timestamp when one is found.
-    for line in stdout.lines() {
-        // A group that exited still prints its row: never count it as running.
-        if !(line.contains("exited") || line.contains("stopped"))
-            && let Some((before, _)) = line.split_once(" rank ")
-            && let Some(name) = before.split_whitespace().last()
-            && !name.starts_with("gx10")
-            && !name.contains("GiB")
-            && name
-                .chars()
-                .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
-            && !out.iter().any(|(n, _)| n == name)
-        {
-            out.push((name.to_string(), None));
-        }
-    }
-    let live: String = stdout
-        .lines()
-        .filter(|l| !(l.contains("exited") || l.contains("stopped")))
-        .collect::<Vec<_>>()
-        .join("\n");
-    for tok in live.split(|c: char| c.is_whitespace() || c == ',') {
-        let t = tok.trim_matches(|c: char| c == '`' || c == '"' || c == '\'');
-        let parts: Vec<&str> = t.split('-').collect();
-        if parts.len() < 3 {
-            continue;
-        }
-        let base = parts[..parts.len() - 2].join("-");
-        let stamp = parts[parts.len() - 2];
-        let Some(stamp) = stamp.strip_suffix('Z') else {
-            continue;
-        };
-        let Some((d, hms)) = stamp.split_once('T') else {
-            continue;
-        };
-        if d.len() != 8
-            || hms.len() != 6
-            || base.is_empty()
-            || !base
-                .chars()
-                .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.'))
-        {
-            continue;
-        }
-        let num = |s: &str| s.parse::<i64>().ok();
-        let (Some(y), Some(mo), Some(da)) = (num(&d[0..4]), num(&d[4..6]), num(&d[6..8])) else {
-            continue;
-        };
-        let (Some(h), Some(mi), Some(se)) = (num(&hms[0..2]), num(&hms[2..4]), num(&hms[4..6]))
-        else {
-            continue;
-        };
-        let y2 = y - if mo <= 2 { 1 } else { 0 };
-        let era = if y2 >= 0 { y2 } else { y2 - 399 } / 400;
-        let yoe = y2 - era * 400;
-        let mp = (mo + 9) % 12;
-        let doy = (153 * mp + 2) / 5 + da - 1;
-        let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
-        let secs = (era * 146_097 + doe - 719_468) * 86_400 + h * 3600 + mi * 60 + se;
-        let created = u64::try_from(secs).ok();
-        match out.iter_mut().find(|(n, _)| n == &base) {
-            Some(slot) => slot.1 = created,
-            None => out.push((base.to_string(), created)),
-        }
-    }
-    out
-}
-
-/// The ssh_ip of a node by its name (the probe keys its map on node names).
-fn node_ip_of<'a>(config: &'a crate::config::Config, name: &str) -> &'a String {
-    config
-        .clusters
-        .iter()
-        .flat_map(|c| c.nodes.iter())
-        .find(|n| n.name == *name)
-        .map(|n| &n.ssh_ip)
-        .unwrap_or(&EMPTY_IP)
-}
-
-static EMPTY_IP: std::sync::LazyLock<String> = std::sync::LazyLock::new(String::new);
-
 /// Per-node state with containers.
 #[derive(Clone, Debug, Default)]
 pub struct WebNodeState {
@@ -178,11 +89,13 @@ pub struct FlareLane {
     pub cluster: String,
     /// The orbit cluster name actually queried (`[flare.clusters.<name>]`).
     pub orbit: String,
-    /// Controller: our node name + ssh ip.
+    /// Controller: our node name.
     pub controller: String,
-    pub controller_ip: String,
     /// `(group, start-epoch)` as printed by `orbit status`.
     pub groups: Vec<(String, Option<u64>)>,
+    /// Per-node rank rows from the same table — every node of the group,
+    /// not only the controller.
+    pub ranks: Vec<OrbitRank>,
 }
 
 /// Fleet-wide probe: ask every **paired** cluster its own orbit name.
@@ -196,6 +109,17 @@ pub struct FlareLane {
 /// flat default (`sparks`) applied to `6nodes-switch`/`1node-*`/`ds4`/`qwen38`
 /// — they share nodes with the paired clusters — made the sparks lane report
 /// itself as a lane of whichever unpaired cluster happened to be probed.
+/// The recipe's `required_nodes` for a group base name (the recipe dir and
+/// `[flare].name` are normally the same). Used to decide which paired cluster
+/// owns a group: `pair12` also *sees* a prod4 group on its shared nodes, but
+/// its node count is not the package's TP.
+fn required_nodes_for_group(config: &crate::config::Config, group: &str) -> Option<u32> {
+    let path = crate::config::recipe_file(group, &config.recipe_sources)?;
+    let text = std::fs::read_to_string(path).ok()?;
+    let r: Recipe = toml::from_str(&text).ok()?;
+    r.required_nodes
+}
+
 pub async fn flare_lanes(config: &crate::config::Config) -> Vec<FlareLane> {
     let Some(f) = config.flare.as_ref() else {
         return Vec::new();
@@ -224,12 +148,41 @@ pub async fn flare_lanes(config: &crate::config::Config) -> Vec<FlareLane> {
             if groups.is_empty() {
                 continue;
             }
+            let rows = parse_orbit_ranks(&o);
+            // Orbit node names → our node names (rank order from the config).
+            let mut by_orbit: HashMap<String, String> = HashMap::new();
+            for n in &cluster.nodes {
+                by_orbit.insert(
+                    crate::native::flare::orbit_node_name(config, &cluster.name, n),
+                    n.name.clone(),
+                );
+            }
+            let mut ranks: Vec<OrbitRank> = Vec::new();
+            for row in rows {
+                if let Some(our) = by_orbit.get(&row.node) {
+                    ranks.push(OrbitRank {
+                        node: our.clone(),
+                        group: row.group,
+                        created: row.created,
+                    });
+                }
+            }
+            // A group can be *visible* in several paired clusters that share
+            // nodes (all8/pair12 both show the prod4 group). Only the cluster
+            // whose node count matches the group's rank rows and the recipe's
+            // required_nodes owns it.
+            let req = required_nodes_for_group(config, &groups[0].0);
+            let owned = ranks.len() == cluster.nodes.len()
+                && req.is_none_or(|n| n as usize == cluster.nodes.len());
+            if !owned {
+                continue;
+            }
             out.push(FlareLane {
                 cluster: cluster.name.clone(),
                 orbit: orbit.clone(),
                 controller: node.name.clone(),
-                controller_ip: node.ssh_ip.clone(),
                 groups,
+                ranks,
             });
             break; // this orbit answered; the cluster needs no other candidate
         }
@@ -355,18 +308,28 @@ pub async fn probe(config: &Config) -> Fleet {
                         });
                     }
                 }
-                // A group is reported once, on the node that runs `orbit`
-                // (the loop is per node — without the guard every node would
-                // report the same group).
-                let ip = node_ip_of(config, &name);
-                for lane in lanes.iter().filter(|l| &l.controller_ip == ip) {
-                    for (group, created) in &lane.groups {
+                // Every rank row marks its own node busy — a FLARE group has
+                // one rank per node, so the topology/LED board must show all
+                // of them, not only the controller that runs `orbit`.
+                // Several paired clusters can *see* the same group on shared
+                // nodes (all8/pair12 also show a prod4 group); dedup per
+                // (node, group) keeps one pseudo-container, owned by the first
+                // lane (the actual cluster).
+                let mut flare_seen: HashSet<(String, String)> = HashSet::new();
+                for lane in &lanes {
+                    for rank in &lane.ranks {
+                        if rank.node != name {
+                            continue;
+                        }
+                        if !flare_seen.insert((name.clone(), rank.group.clone())) {
+                            continue;
+                        }
                         containers.push(ContainerSnap {
-                            name: group.clone(),
+                            name: rank.group.clone(),
                             status: "Up (flare)".into(),
                             cluster: lane.cluster.clone(),
-                            recipe: group.clone(),
-                            created: *created,
+                            recipe: rank.group.clone(),
+                            created: rank.created,
                         });
                     }
                 }
@@ -440,7 +403,7 @@ mod created_tests {
 
 #[cfg(test)]
 mod orbit_tests {
-    use super::parse_orbit_groups;
+    use crate::web::flare_status::parse_orbit_groups;
 
     #[test]
     fn orbit_status_yields_group_and_start_time() {
@@ -465,7 +428,7 @@ mod orbit_tests {
 
 #[cfg(test)]
 mod orbit_dead_tests {
-    use super::parse_orbit_groups;
+    use crate::web::flare_status::parse_orbit_groups;
 
     #[test]
     fn dead_groups_are_not_running() {