Browse Source

release: sm121 0.2.1 — FLARE pairing, package skip/rsync, settings check

Alexander Korolev 10 hours ago
parent
commit
1bf1c11ce4

+ 29 - 0
CHANGELOG.md

@@ -3,6 +3,35 @@
 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.1 — 2026-10-28
+
+- **FLARE cluster identification.** A cluster is only paired with an orbit if
+  the private config names it (`[flare.clusters.<our-cluster>]`). The flat
+  `orbit_cluster` is a fallback for `serve`, not for the dashboard/`health`: our
+  clusters share nodes (`dgx1` is rank 0 of prod4, 6nodes-switch, 2nodes-dac,
+  qwen38 and 1node-1), so probing an unpaired cluster with `sparks` made the
+  sparks lane look like that cluster's lane. `serve` now refuses an unpaired
+  FLARE cluster instead of silently launching onto the flat default.
+- **Dashboard shows the orbit.** Each cluster head carries a `◆ prod4` badge
+  (orbit + controller + live groups; dimmed when nothing runs), and the topology
+  frame/chip labels include the orbit. `health` folds only the selected cluster's
+  lane, not every lane that shares its controller.
+- **Prepared-package identification.** After a successful mirror the controller
+  writes `.sm121-flare-package.json` (repo, revision, TP, package version,
+  local `model.safetensors`). A serve with a matching marker — checked against
+  the CURRENT `flare models list --json` version, so a lab re-cut under the same
+  revision is detected — skips both `flare download` verification and the
+  500 GB copy.
+- **Incremental mirror with `rsync`.** The local mirror uses
+  `rsync -a --delete` (marker excluded), so a changed/stale package copies only
+  the changed files; a partial mirror resumes instead of being wiped and
+  re-copied. Download/mirror/orbit launch all use long-running SSH (the 5 s
+  inactivity timeout used to kill a 500 GB copy or a detached launch).
+- **Settings editor.** `POST /api/settings/extra/check` validates without
+  writing; parse errors report line/column for the textarea; `Ctrl+Enter`
+  saves. The private block stays in `/data/config-extra.toml`, edited in the
+  dashboard's settings window.
+
 ## 0.2.0 — 2026-10-28
 
 - FLARE: cached `flare download` now resolves the prepared package through

+ 1 - 1
Cargo.lock

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

+ 1 - 1
Cargo.toml

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

+ 5 - 3
src/config/flare_cluster.rs

@@ -18,9 +18,11 @@ pub struct FlareCluster {
     /// Local package mirror dir for this cluster's controller.
     #[serde(default)]
     pub local_dir: Option<String>,
-    /// Extra `orbit run` flags for this cluster — fabric shape differs per
-    /// cluster (a switch-emulated ring needs `--roce-ring`, a DAC pair wants
-    /// `--roce-chain`), so the shape belongs to the cluster, not the recipe.
+    /// Extra `orbit run` flags for this cluster — fabric shape is a cluster
+    /// property, not a recipe flag. Empty for the prod 4-node switch: FLARE
+    /// finds the star links itself once the legacy CX7 ports are down. A DAC
+    /// pair can still need `--roce-chain`; `--roce-ring` was a workaround for
+    /// the unpaired legacy ports and is no longer used.
     #[serde(default)]
     pub extra_flags: Vec<String>,
 }

+ 9 - 8
src/docker/health/collect.rs

@@ -129,15 +129,16 @@ 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() {
-        let (controller, groups) = crate::web::probe::flare_groups(config).await;
-        if let Some(ip) = controller {
-            let ctl_name = crate::web::flare_nodes::candidates(config)
-                .into_iter()
-                .find(|n| n.ssh_ip == ip)
-                .map(|n| n.name.clone());
+        // 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.
+        for lane in crate::web::probe::flare_lanes(config).await {
+            if lane.cluster != cluster.name {
+                continue;
+            }
             for node in &mut nodes {
-                if Some(&node.name) == ctl_name.as_ref() {
-                    for (group, _) in &groups {
+                if node.name == lane.controller {
+                    for (group, _) in &lane.groups {
                         node.containers.push(format!("{group}\tUp (flare)"));
                     }
                 }

+ 122 - 46
src/native/flare.rs

@@ -49,10 +49,51 @@ pub fn orbit_cluster_for(config: &Config, cluster: &str) -> String {
         .unwrap_or_else(|| "sparks".to_string())
 }
 
+/// The orbit cluster a **specific** cluster is paired with, or `None` when the
+/// config does not say (the flat `[flare].orbit_cluster` is then only a
+/// fallback for `serve`, not evidence of a pairing).
+///
+/// This is the identification rule the dashboard and `health` use: a cluster
+/// whose own nodes were never paired with an orbit must not be probed with the
+/// flat default (`sparks`) — `6nodes-switch`, `1node-*`, `ds4` and `qwen38`
+/// share nodes with the paired clusters, so asking one of them `--cluster
+/// sparks` made the sparks lane look like a lane of *that* cluster.
+pub fn orbit_cluster_paired(config: &Config, cluster: &str) -> Option<String> {
+    let f = config.flare.as_ref()?;
+    if let Some(own) = f.clusters.get(cluster) {
+        // A per-cluster block without an orbit name inherits the flat one —
+        // the operator already said "this cluster speaks FLARE".
+        return own
+            .orbit_cluster
+            .clone()
+            .or_else(|| f.orbit_cluster.clone());
+    }
+    // Once the map exists it is the source of truth: the flat block describes
+    // the machine it was written for, and our clusters share nodes (dgx5 is
+    // rank 0 of `dgx58-switch` AND rank 4 of `6nodes-switch`), so a flat
+    // `sparks` would pair `6nodes-switch` too.
+    if !f.clusters.is_empty() {
+        return None;
+    }
+    let flat = f.orbit_cluster.clone()?;
+    match f.controller_node.as_deref() {
+        // Legacy flat block: it speaks for the cluster that holds its
+        // controller node.
+        Some(ctl) => config
+            .clusters
+            .iter()
+            .find(|c| c.name == cluster)
+            .is_some_and(|c| c.nodes.iter().any(|n| n.name == ctl))
+            .then_some(flat),
+        // No controller either: unambiguous only with a single cluster.
+        None => (config.clusters.len() == 1 && config.clusters[0].name == cluster).then_some(flat),
+    }
+}
+
 /// Extra `orbit run` flags for a cluster (per-cluster map first, then flat).
-/// Fabric shape is a cluster property: `--roce-ring` emulates a ring on a
-/// switch (our 4nodes-switch) where FLARE cannot find direct links, while the
-/// DAC/switch pair clusters take `--roce-chain`/nothing.
+/// Fabric shape is a cluster property. The prod 4-node switch uses no extra
+/// flag: FLARE finds the star links itself once the legacy CX7 ports are down;
+/// `--roce-ring` was a symptom-level fix and is retired.
 pub fn extra_flags_for(config: &Config, cluster: &str) -> Vec<String> {
     let Some(f) = config.flare.as_ref() else {
         return Vec::new();
@@ -70,7 +111,7 @@ pub fn extra_flags_for(config: &Config, cluster: &str) -> Vec<String> {
 }
 
 /// Local package mirror dir for a cluster (per-cluster map first).
-fn local_dir_for(config: &Config, cluster: &str) -> Option<String> {
+pub(crate) fn local_dir_for(config: &Config, cluster: &str) -> Option<String> {
     let f = config.flare.as_ref()?;
     f.clusters
         .get(cluster)
@@ -92,7 +133,7 @@ fn orbit_node_name(config: &Config, cluster: &str, node: &Node) -> String {
         .unwrap_or_else(|| node.name.clone())
 }
 
-fn binary(config: &Config) -> String {
+pub(crate) fn binary(config: &Config) -> String {
     config
         .flare
         .as_ref()
@@ -240,8 +281,8 @@ pub fn mirror_cmd(local_dir: &str, remote_pkg: &str) -> String {
     format!(
         "set -e; src=$(dirname {pkg}); dst={dir}; \
          if [ \"$src\" = \"$dst\" ]; then echo {pkg}; exit 0; fi; \
-         rm -rf \"$dst\"; mkdir -p \"$dst\"; \
-         cp -a \"$src\"/. \"$dst\"/; \
+         mkdir -p \"$dst\"; \
+         rsync -a --delete --exclude '.sm121-flare-package.json' \"$src\"/ \"$dst\"/; \
          echo \"$dst/$(basename {pkg})\""
     )
 }
@@ -298,47 +339,82 @@ pub async fn serve(
         }
         info!("FLARE agents ok on {} node(s)", cluster.nodes.len());
     }
-    let dl = download_cmd(config, recipe)?;
-    let package = crate::ssh::run_ssh_long_running(config, controller, dl, dry_run).await?;
-    let mut package = parse_package_path(&package).unwrap_or_default();
-    // A cached download prints only "Resolved package: … / model.safetensors"
-    // and no final path; resolve the default prepared model from Flare's
-    // model cache instead of failing after a successful download.
-    if !dry_run && package.is_empty() {
-        let cache_cmd = format!(
-            "{} models list --json 2>/dev/null | python3 -c 'import json,sys; d=json.load(sys.stdin); m=d.get(\"models\") or []; vers=(m[0].get(\"versions\") or []) if m else []; v=vers[0] if vers else dict(); print(d.get(\"cache\",\"\") + \"/models/\" + m[0].get(\"model_key\",\"\") + \"/\" + v.get(\"version\",\"\"))'",
-            binary(config)
-        );
-        let dir = crate::ssh::run_ssh(config, controller, cache_cmd, false).await?;
-        let dir = dir.trim();
-        if !dir.is_empty() {
-            let find = format!(
-                "find {} -maxdepth 1 -name '*.safetensors' -print -quit",
-                shq(dir)
+    let id = crate::native::flare_package::package_id(recipe)?;
+    // Identification: a complete local mirror of this exact package (repo,
+    // revision, TP, and the CURRENT prepared version) means the download
+    // verification and the 500 GB recopy are both unnecessary.
+    let mut package: String;
+    if !dry_run {
+        if let Some(local) =
+            crate::native::flare_package::local_package(config, recipe, &cluster.name, controller)
+                .await?
+        {
+            info!(
+                "FLARE package already local on {}; skipping download and mirror",
+                controller.name
             );
-            let primary = crate::ssh::run_ssh(config, controller, find, false).await?;
-            package = primary.trim().to_string();
-        }
-    }
-    if !dry_run && package.is_empty() {
-        bail!(
-            "flare download printed no package path on {}",
-            controller.name
-        );
-    }
-    // Local mirror: launch from the controller's own disk when configured —
-    // engram rows are read at request time, so a shared mount is the wrong
-    // place for the package even with a RAM row cache.
-    if let Some(dir) = local_dir_for(config, &cluster.name) {
-        let dir = dir.replace('~', "$HOME");
-        let cmd = mirror_cmd(&dir, &package);
-        let out = crate::ssh::run_ssh_long_running(config, controller, cmd, dry_run).await?;
-        if !dry_run {
-            let local = out.lines().last().unwrap_or_default().trim().to_string();
-            if local.is_empty() {
-                bail!("flare local mirror produced no path on {}", controller.name);
-            }
             package = local;
+        } else {
+            let dl = download_cmd(config, recipe)?;
+            let out = crate::ssh::run_ssh_long_running(config, controller, dl, dry_run).await?;
+            package = parse_package_path(&out).unwrap_or_default();
+            // A cached download prints only "Resolved package: … / model.safetensors"
+            // and no final path; resolve the default prepared model from Flare's
+            // model cache instead of failing after a successful download.
+            if package.is_empty() {
+                let cache_cmd = format!(
+                    "{} models list --json 2>/dev/null | python3 -c 'import json,sys; d=json.load(sys.stdin); m=d.get(\"models\") or []; vers=(m[0].get(\"versions\") or []) if m else []; v=vers[0] if vers else dict(); print(d.get(\"cache\",\"\") + \"/models/\" + m[0].get(\"model_key\",\"\") + \"/\" + v.get(\"version\",\"\"))'",
+                    binary(config)
+                );
+                let dir = crate::ssh::run_ssh(config, controller, cache_cmd, false).await?;
+                let dir = dir.trim();
+                if !dir.is_empty() {
+                    let find = format!(
+                        "find {} -maxdepth 1 -name '*.safetensors' -print -quit",
+                        shq(dir)
+                    );
+                    let primary = crate::ssh::run_ssh(config, controller, find, false).await?;
+                    package = primary.trim().to_string();
+                }
+            }
+            if package.is_empty() {
+                bail!(
+                    "flare download printed no package path on {}",
+                    controller.name
+                );
+            }
+
+            // Local mirror: launch from the controller's own disk when configured —
+            // engram rows are read at request time, so a shared mount is the wrong
+            // place for the package even with a RAM row cache.
+            if let Some(dir) = local_dir_for(config, &cluster.name) {
+                let dir = dir.replace('~', "$HOME");
+                // Version from the NAS package directory (the local mirror root
+                // has no version-named subdir).
+                let version = crate::native::flare_package::version_of(&package);
+                let cmd = mirror_cmd(&dir, &package);
+                let out =
+                    crate::ssh::run_ssh_long_running(config, controller, cmd, dry_run).await?;
+                let local = out.lines().last().unwrap_or_default().trim().to_string();
+                if local.is_empty() {
+                    bail!("flare local mirror produced no path on {}", controller.name);
+                }
+                package = local;
+                crate::native::flare_package::write_marker(
+                    config, controller, &dir, &package, &id, &version,
+                )
+                .await?;
+            }
+        }
+    } else {
+        // Dry-run: print the download and mirror commands, no SSH.
+        let dl = download_cmd(config, recipe)?;
+        package = crate::ssh::run_ssh_long_running(config, controller, dl, true).await?;
+        package = parse_package_path(&package).unwrap_or_default();
+        if let Some(dir) = local_dir_for(config, &cluster.name) {
+            let dir = dir.replace('~', "$HOME");
+            let cmd = mirror_cmd(&dir, &package);
+            crate::ssh::run_ssh_long_running(config, controller, cmd, true).await?;
         }
     }
     let orbit_name = orbit_node_name(config, &cluster.name, controller);

+ 175 - 0
src/native/flare_package.rs

@@ -0,0 +1,175 @@
+//! Identification of a prepared FLARE package: when the controller already has
+//! the exact package (same repo, revision, TP size and version) in its local
+//! mirror, `serve` must not run `flare download` again and must not recopy the
+//! 500 GB tree.
+//!
+//! The local mirror carries a marker written after a successful `cp -a`:
+//! `{"repo": "...", "revision": "...", "tensor_parallel": 4, "version": "...",
+//! "package": ".../model.safetensors"}`. On the next serve the marker is read,
+//! its version is checked against `flare models list --json` (the current
+//! prepared version, not just the recipe revision — the lab re-cuts a package
+//! under the same revision), and the package files are tested. A matching
+//! marker short-circuits both the download verification and the mirror copy.
+
+use anyhow::{Context, Result};
+use serde::{Deserialize, Serialize};
+
+use crate::config::{Config, Node, Recipe};
+use crate::docker::shq;
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct PackageMarker {
+    pub repo: String,
+    pub revision: String,
+    pub tensor_parallel: u32,
+    /// The prepared package version (its directory name), e.g.
+    /// `gb10-tp4-202610272221`.
+    pub version: String,
+    /// The local `model.safetensors` path.
+    pub package: String,
+}
+
+/// The identity a recipe asks for: repo + revision + the package's TP size.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct PackageId {
+    pub repo: String,
+    pub revision: String,
+    pub tensor_parallel: u32,
+}
+
+pub fn package_id(recipe: &Recipe) -> Result<PackageId> {
+    let f = recipe
+        .flare
+        .as_ref()
+        .ok_or_else(|| anyhow::anyhow!("engine=flare requires a [flare] block"))?;
+    let tp = recipe
+        .required_nodes
+        .unwrap_or(recipe.tensor_parallel_size)
+        .max(1);
+    Ok(PackageId {
+        repo: f.package.clone(),
+        revision: f.revision.clone().unwrap_or_default(),
+        tensor_parallel: tp,
+    })
+}
+
+fn marker_path(dir: &str) -> String {
+    format!("{}/.sm121-flare-package.json", dir.trim_end_matches('/'))
+}
+
+/// Current prepared versions in Flare's cache (`flare models list --json`).
+/// The package version is a directory name; comparing it catches a re-cut
+/// package under the same revision (the stale-NFS class, but for a package).
+async fn current_versions(config: &Config, controller: &Node) -> Result<Vec<String>> {
+    let cmd = format!(
+        "{} models list --json 2>/dev/null",
+        crate::native::flare::binary(config)
+    );
+    let out = crate::ssh::run_ssh(config, controller, cmd, false).await?;
+    let v: serde_json::Value =
+        serde_json::from_str(&out).context("flare models list --json returned invalid JSON")?;
+    let mut versions = Vec::new();
+    if let Some(models) = v.get("models").and_then(|m| m.as_array()) {
+        for m in models {
+            if let Some(vers) = m.get("versions").and_then(|v| v.as_array()) {
+                for ver in vers {
+                    if let Some(s) = ver.get("version").and_then(|v| v.as_str()) {
+                        versions.push(s.to_string());
+                    }
+                }
+            }
+        }
+    }
+    Ok(versions)
+}
+
+/// Test that a local mirror is complete enough to launch: the primary
+/// `model.safetensors`, the `weights/` companion and every rank deployment.
+async fn local_files_present(
+    config: &Config,
+    controller: &Node,
+    dir: &str,
+    package: &str,
+) -> Result<bool> {
+    let cmd = format!(
+        "test -f {} && test -d {}/weights && test -d {}/deployments",
+        shq(package),
+        shq(dir),
+        shq(dir)
+    );
+    match crate::ssh::run_ssh(config, controller, cmd, false).await {
+        Ok(_) => Ok(true),
+        Err(_) => Ok(false),
+    }
+}
+
+/// The local package path if the marker matches the recipe identity and the
+/// current prepared version. `None` means a download/mirror pass is needed.
+pub async fn local_package(
+    config: &Config,
+    recipe: &Recipe,
+    cluster: &str,
+    controller: &Node,
+) -> Result<Option<String>> {
+    let Some(dir) = crate::native::flare::local_dir_for(config, cluster) else {
+        return Ok(None);
+    };
+    let dir = dir.replace('~', "$HOME");
+    let id = package_id(recipe)?;
+    let mp = marker_path(&dir);
+    let read = format!("test -f {} && cat {}", shq(&mp), shq(&mp));
+    let out = match crate::ssh::run_ssh(config, controller, read, false).await {
+        Ok(o) => o,
+        Err(_) => return Ok(None),
+    };
+    let marker: PackageMarker =
+        serde_json::from_str(out.trim()).context("local FLARE marker is not valid JSON")?;
+    if marker.repo != id.repo
+        || marker.revision != id.revision
+        || marker.tensor_parallel != id.tensor_parallel
+    {
+        return Ok(None);
+    }
+    let versions = current_versions(config, controller).await?;
+    if !versions.iter().any(|v| v == &marker.version) {
+        // Same revision, but the lab re-cut the package: the mirror is stale.
+        return Ok(None);
+    }
+    if !local_files_present(config, controller, &dir, &marker.package).await? {
+        return Ok(None);
+    }
+    Ok(Some(marker.package))
+}
+
+/// Write the marker after a successful mirror copy. `package` is the local
+/// `model.safetensors` path; `version` is its package-directory basename.
+pub async fn write_marker(
+    config: &Config,
+    controller: &Node,
+    dir: &str,
+    package: &str,
+    id: &PackageId,
+    version: &str,
+) -> Result<()> {
+    let marker = PackageMarker {
+        repo: id.repo.clone(),
+        revision: id.revision.clone(),
+        tensor_parallel: id.tensor_parallel,
+        version: version.to_string(),
+        package: package.to_string(),
+    };
+    let json = serde_json::to_string(&marker).context("cannot serialize marker")?;
+    let mp = marker_path(dir);
+    let cmd = format!("printf '%s\\n' {} > {}", shq(&json), shq(&mp));
+    crate::ssh::run_ssh(config, controller, cmd, false).await?;
+    Ok(())
+}
+
+pub fn version_of(package: &str) -> String {
+    package
+        .trim_end_matches('/')
+        .rsplit('/')
+        .nth(1)
+        .unwrap_or_default()
+        .to_string()
+}

+ 19 - 0
src/native/mod.rs

@@ -16,6 +16,7 @@ use crate::config::{Cluster, Config, DgppConfig, Node, Recipe};
 use crate::docker::ServeOpts;
 
 pub mod flare;
+pub mod flare_package;
 mod provision;
 
 /// DGPP HTTP port for a recipe (its `[dgpp].http_port`, else 18080).
@@ -306,6 +307,24 @@ async fn flare_serve(
     if recipe.flare.is_none() {
         anyhow::bail!("engine=flare requires a [flare] block");
     }
+    // Cluster identification: the orbit name must come from this cluster's own
+    // `[flare.clusters.<name>]` entry. Falling back to the flat `sparks`
+    // default silently launches onto another machine's orbit (our clusters
+    // share nodes, and `orbit run` creates the group wherever it is told).
+    let flat = flare::orbit_cluster_for(config, &cluster.name);
+    if let Some(orbit) = flare::orbit_cluster_paired(config, &cluster.name) {
+        info!("FLARE: cluster '{}' → orbit '{}'", cluster.name, orbit);
+    } else {
+        anyhow::bail!(
+            "FLARE cluster '{}' is not paired with an orbit cluster — add a \
+             [flare.clusters.\"{}\"] block (orbit_cluster = \"…\") to the private \
+             config. Without it the launch would silently target the flat \
+             default '{}'.",
+            cluster.name,
+            cluster.name,
+            flat
+        );
+    }
     // before_serve hooks (the global GPU clock lock among them): a Spark that
     // runs unclocked drifts and stalls a collective, so native launches get
     // the same prepare step docker/tmux lanes do.

+ 34 - 1
src/settings.rs

@@ -57,6 +57,38 @@ pub fn read_extra() -> Result<ExtraConfig> {
     })
 }
 
+/// Validate without writing — the dashboard's editor "check" button (and the
+/// same parse the save path runs, so both agree on the message).
+pub fn validate_extra(text: &str) -> Result<()> {
+    let trimmed = text.trim();
+    if trimmed.is_empty() {
+        return Ok(());
+    }
+    toml::from_str::<toml::Value>(trimmed)
+        .map(|_| ())
+        .map_err(|e| anyhow::anyhow!("{}", parse_error(trimmed, &e)))
+}
+
+/// A parse error with the line/column the editor can jump to: the raw
+/// `toml` message carries a byte span, which is useless in a textarea.
+fn parse_error(text: &str, e: &toml::de::Error) -> String {
+    let Some(span) = e.span() else {
+        return format!("TOML parse error: {}", e.message());
+    };
+    let upto = &text[..span.start.min(text.len())];
+    let line = upto.matches('\n').count() + 1;
+    let col = upto
+        .rsplit('\n')
+        .next()
+        .map(|l| l.chars().count())
+        .unwrap_or(0)
+        + 1;
+    format!(
+        "TOML parse error at line {line}, column {col}: {}",
+        e.message()
+    )
+}
+
 /// Validate + save. An empty body deletes the file (the block goes away);
 /// a parse error is reported before anything is written.
 pub fn write_extra(text: &str) -> Result<ExtraConfig> {
@@ -71,7 +103,8 @@ pub fn write_extra(text: &str) -> Result<ExtraConfig> {
         }
         return read_extra();
     }
-    toml::from_str::<toml::Value>(trimmed).map_err(|e| anyhow::anyhow!("TOML parse error: {e}"))?;
+    toml::from_str::<toml::Value>(trimmed)
+        .map_err(|e| anyhow::anyhow!("{}", parse_error(trimmed, &e)))?;
     if let Some(dir) = path.parent() {
         std::fs::create_dir_all(dir).with_context(|| format!("cannot create {}", dir.display()))?;
     }

+ 56 - 0
src/web/events.rs

@@ -53,6 +53,23 @@ pub struct NodeSnap {
     pub ha: Option<bool>,
 }
 
+/// FLARE identification for one cluster — which orbit it speaks to, which
+/// node runs the `flare orbit` CLI, and the groups that answer there.
+///
+/// The dashboard prints this next to the cluster name (`◆ prod4`), so an
+/// operator can see at a glance that `4nodes-switch` is the `prod4` orbit and
+/// that a green lane came from that pairing — and, when two clusters share
+/// nodes, which of them the lane belongs to.
+#[derive(Serialize, Clone, Debug, Default)]
+pub struct OrbitSnap {
+    /// Orbit cluster name (`[cluster.<name>]` in orbit.toml).
+    pub orbit: String,
+    /// Our controller node name.
+    pub controller: String,
+    /// Live `orbit status` groups (empty = paired, nothing running).
+    pub groups: Vec<String>,
+}
+
 /// Per-cluster view.
 #[derive(Serialize, Clone, Debug)]
 pub struct ClusterSnap {
@@ -66,6 +83,9 @@ pub struct ClusterSnap {
     pub soft_off: Vec<String>,
     /// Free nodes; 0 when the cluster is down.
     pub free: u32,
+    /// FLARE pairing (absent when the cluster speaks no FLARE).
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub orbit: Option<OrbitSnap>,
 }
 
 /// One log window attach (for state snapshots — the UI restores open
@@ -161,6 +181,7 @@ pub fn build_snapshot(
             switch_off: so,
             soft_off: soft_off.get(&c.name).cloned().unwrap_or_default(),
             free: v.free,
+            orbit: orbit_of(config, c, fleet),
         });
     }
     let mut topo = crate::web::topo::derive_topo(config);
@@ -182,6 +203,37 @@ pub fn build_snapshot(
     }
 }
 
+/// The FLARE pairing + live groups of one cluster. The pairing comes from the
+/// config (so an idle lane still says which orbit it would use); the groups
+/// come from the fleet probe's lanes, which carry their own cluster label.
+fn orbit_of(
+    config: &Config,
+    cluster: &crate::config::Cluster,
+    fleet: &crate::web::probe::Fleet,
+) -> Option<OrbitSnap> {
+    let paired = crate::native::flare::orbit_cluster_paired(config, &cluster.name);
+    let lane = fleet.lanes.iter().find(|l| l.cluster == cluster.name);
+    // The live lane wins (it names the orbit actually queried); without one
+    // the config pairing still says which orbit this cluster would use.
+    let orbit = lane.map(|l| l.orbit.clone()).or(paired)?;
+    let controller = lane
+        .map(|l| l.controller.clone())
+        .or_else(|| {
+            crate::web::flare_nodes::pinned_for(config, cluster)
+                .or_else(|| cluster.nodes.iter().find(|n| n.rank == 0))
+                .map(|n| n.name.clone())
+        })
+        .unwrap_or_default();
+    let groups = lane
+        .map(|l| l.groups.iter().map(|(g, _)| g.clone()).collect())
+        .unwrap_or_default();
+    Some(OrbitSnap {
+        orbit,
+        controller,
+        groups,
+    })
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -281,6 +333,7 @@ mod tests {
             switch_off: std::collections::HashMap::new(),
             soft_off: std::collections::HashMap::new(),
             ha: std::collections::HashMap::new(),
+            lanes: Vec::new(),
         };
         let snap = build_snapshot(&config, &fleet, &[], &[], &HashMap::new());
         assert_eq!(snap.clusters.len(), 2);
@@ -325,6 +378,7 @@ mod tests {
             switch_off: std::collections::HashMap::new(),
             soft_off: std::collections::HashMap::new(),
             ha: std::collections::HashMap::new(),
+            lanes: Vec::new(),
         };
         let snap = build_snapshot(&config, &fleet, &[], &[], &HashMap::new());
         let a = &snap.clusters[0];
@@ -358,6 +412,7 @@ mod tests {
             switch_off: so,
             soft_off: std::collections::HashMap::new(),
             ha: std::collections::HashMap::new(),
+            lanes: Vec::new(),
         };
         let snap = build_snapshot(&config, &fleet, &[], &[], &HashMap::new());
         assert!(!snap.clusters[0].up);
@@ -390,6 +445,7 @@ mod tests {
             switch_off: HashMap::new(),
             soft_off: soft,
             ha: std::collections::HashMap::new(),
+            lanes: Vec::new(),
         };
         let snap = build_snapshot(&config, &fleet, &[], &[], &HashMap::new());
         assert!(

+ 78 - 49
src/web/flare_nodes.rs

@@ -1,47 +1,29 @@
 //! Which of our nodes runs the `flare orbit` CLI.
 //!
 //! FLARE names its peers itself (`gx10-dgx5`…), so the launcher *finds* the
-//! controller: every cluster's rank-0 node is a candidate and the first one
-//! whose `orbit status` answers is it. The winner is remembered, and
-//! `[flare].controller_node` remains an explicit override.
+//! controller: a cluster's rank-0 node is the candidate and the first one
+//! whose `orbit status` answers is it. `[flare.clusters.<name>].controller_node`
+//! pins it per cluster; the flat `[flare].controller_node` is the
+//! single-cluster fallback.
+//!
+//! Only clusters the config actually **pairs** with an orbit are candidates:
+//! several of our clusters share nodes (`dgx1` is rank 0 of `4nodes-switch`,
+//! `6nodes-switch`, `2nodes-dac`, `qwen38`, `1node-1`), so probing an unpaired
+//! cluster with the flat `sparks` default made another cluster's lane look
+//! like its own.
 
-use crate::config::{Config, Node};
+use crate::config::{Cluster, Config, Node};
 
-/// The pinned controller: a per-cluster override wins (so one config can pin
-/// a different controller on each cluster), then the flat `[flare]` field.
-pub fn pinned(config: &Config) -> Option<&Node> {
+/// The pinned controller of ONE cluster: a per-cluster override wins, then the
+/// flat `[flare]` field — and either must actually be a node of that cluster.
+pub fn pinned_for<'a>(config: &'a Config, cluster: &'a Cluster) -> Option<&'a Node> {
     let f = config.flare.as_ref()?;
     let want = f
         .clusters
-        .values()
-        .find_map(|c| c.controller_node.clone())
+        .get(&cluster.name)
+        .and_then(|c| c.controller_node.clone())
         .or_else(|| f.controller_node.clone())?;
-    find(config, &want)
-}
-
-fn find<'a>(config: &'a Config, name: &str) -> Option<&'a Node> {
-    config
-        .clusters
-        .iter()
-        .flat_map(|c| c.nodes.iter())
-        .find(|n| n.name == name)
-}
-
-/// Candidate controllers: the rank-0 node of every cluster (deduplicated by
-/// ssh address), the pinned one first.
-pub fn candidates(config: &Config) -> Vec<&Node> {
-    let mut out: Vec<&Node> = Vec::new();
-    if let Some(n) = pinned(config) {
-        out.push(n);
-    }
-    for c in &config.clusters {
-        if let Some(n) = c.nodes.iter().find(|n| n.rank == 0)
-            && !out.iter().any(|x| x.ssh_ip == n.ssh_ip)
-        {
-            out.push(n);
-        }
-    }
-    out
+    cluster.nodes.iter().find(|n| n.name == want)
 }
 
 /// Rank-0 candidates of ONE cluster (pinned controller first) — the fleet
@@ -51,21 +33,8 @@ pub fn candidates_for<'a>(
     config: &'a Config,
     cluster: &'a crate::config::Cluster,
 ) -> Vec<&'a Node> {
-    let pinned_ip = config
-        .flare
-        .as_ref()
-        .and_then(|f| f.clusters.get(&cluster.name))
-        .and_then(|c| c.controller_node.clone())
-        .or_else(|| {
-            config
-                .flare
-                .as_ref()
-                .and_then(|f| f.controller_node.clone())
-        });
     let mut out: Vec<&Node> = Vec::new();
-    if let Some(ip) = pinned_ip
-        && let Some(n) = cluster.nodes.iter().find(|n| n.name == ip)
-    {
+    if let Some(n) = pinned_for(config, cluster) {
         out.push(n);
     }
     for n in &cluster.nodes {
@@ -75,3 +44,63 @@ pub fn candidates_for<'a>(
     }
     out
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn config() -> Config {
+        toml::from_str(
+            r#"
+            ssh_user = "u"
+            ssh_key_path = "/k"
+
+            [flare]
+            orbit_cluster = "sparks"
+            controller_node = "n5"
+
+            [flare.clusters.prod4]
+            orbit_cluster = "prod4"
+            controller_node = "n1"
+
+            [[clusters]]
+            name = "prod4"
+            nodes = [
+              { name = "n1", ssh_ip = "10.0.0.1", rank = 0 },
+              { name = "n2", ssh_ip = "10.0.0.2", rank = 1 },
+            ]
+
+            [[clusters]]
+            name = "six"
+            nodes = [
+              { name = "n1", ssh_ip = "10.0.0.1", rank = 0 },
+              { name = "n2", ssh_ip = "10.0.0.2", rank = 1 },
+              { name = "n5", ssh_ip = "10.0.0.5", rank = 2 },
+            ]
+            "#,
+        )
+        .expect("config toml")
+    }
+
+    #[test]
+    fn pinned_is_per_cluster_and_in_that_cluster() {
+        let cfg = config();
+        let prod4 = cfg.clusters.iter().find(|c| c.name == "prod4").unwrap();
+        let six = cfg.clusters.iter().find(|c| c.name == "six").unwrap();
+        assert_eq!(pinned_for(&cfg, prod4).map(|n| n.name.as_str()), Some("n1"));
+        // `six` has no per-cluster pin; the flat one (n5) is one of its nodes
+        assert_eq!(pinned_for(&cfg, six).map(|n| n.name.as_str()), Some("n5"));
+    }
+
+    #[test]
+    fn pairing_only_names_clusters_the_config_mentions() {
+        use crate::native::flare::orbit_cluster_paired;
+        let cfg = config();
+        assert_eq!(
+            orbit_cluster_paired(&cfg, "prod4").as_deref(),
+            Some("prod4")
+        );
+        // dgx5 is six's rank 2, not its controller — no pairing
+        assert_eq!(orbit_cluster_paired(&cfg, "six"), None);
+    }
+}

+ 1 - 0
src/web/mod.rs

@@ -283,6 +283,7 @@ pub fn router(app: AppState) -> Router {
             "/api/settings/extra",
             get(settings_api::get_extra).post(settings_api::save_extra),
         )
+        .route("/api/settings/extra/check", post(settings_api::check_extra))
         .route("/api/login", post(login))
         .route(
             "/api/logs",

+ 76 - 47
src/web/probe.rs

@@ -170,38 +170,78 @@ pub struct WebNodeState {
     pub containers: Vec<ContainerSnap>,
 }
 
-/// Fleet-wide probe result: per-node state + per-cluster HA switch state.
+/// One live FLARE lane: the orbit cluster that answered, the node running
+/// `flare orbit` for it, and the groups it reports.
+#[derive(Clone, Debug)]
+pub struct FlareLane {
+    /// OUR cluster the orbit is paired with (the frame/board that owns it).
+    pub cluster: String,
+    /// The orbit cluster name actually queried (`[flare.clusters.<name>]`).
+    pub orbit: String,
+    /// Controller: our node name + ssh ip.
+    pub controller: String,
+    pub controller_ip: String,
+    /// `(group, start-epoch)` as printed by `orbit status`.
+    pub groups: Vec<(String, Option<u64>)>,
+}
+
+/// Fleet-wide probe: ask every **paired** cluster its own orbit name.
+///
 /// `flare orbit status` groups → pseudo-containers on the controller node, so
 /// a FLARE serve shows up in the dashboard exactly like a docker container
 /// (green "up (fleet)" + uptime). The recipe column carries the orbit group's
-/// base name (`[flare].name`), which the report maps back to a recipe.
-pub async fn flare_groups(
-    config: &crate::config::Config,
-) -> (Option<String>, Vec<(String, Option<u64>)>) {
+/// base name, which the report maps back to a recipe.
+///
+/// Only clusters the config names (`[flare.clusters.<name>]`) are asked: the
+/// 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.
+pub async fn flare_lanes(config: &crate::config::Config) -> Vec<FlareLane> {
     let Some(f) = config.flare.as_ref() else {
-        return (None, Vec::new());
+        return Vec::new();
     };
     let binary = f.binary.clone().unwrap_or_else(|| "flare".to_string());
-    // Per cluster, with ITS orbit name: `sparks` groups do not exist in the
-    // `prod4` orbit, and asking the wrong one made a serving prod lane look
-    // empty (health said "no containers", the dashboard showed no rows).
+    let mut out: Vec<FlareLane> = Vec::new();
+    // one query per (node, orbit) pair — the same node can legitimately answer
+    // for two orbits (dgx1 is prod4's and pair12's rank 0), but a cluster with
+    // no live group must not be re-asked forever.
+    let mut asked: Vec<(String, String)> = Vec::new();
     for cluster in &config.clusters {
-        let orbit_cluster = crate::native::flare::orbit_cluster_for(config, &cluster.name);
-        let cmd = format!("{binary} orbit status --cluster {orbit_cluster} 2>/dev/null");
+        let Some(orbit) = crate::native::flare::orbit_cluster_paired(config, &cluster.name) else {
+            continue;
+        };
         for node in crate::web::flare_nodes::candidates_for(config, cluster) {
-            if let Ok(out) = crate::ssh::run_ssh(config, node, cmd.clone(), false).await {
-                let groups = parse_orbit_groups(&out);
-                if !groups.is_empty() {
-                    return (Some(node.ssh_ip.clone()), groups);
-                }
+            let key = (node.ssh_ip.clone(), orbit.clone());
+            if asked.contains(&key) {
+                continue;
             }
+            asked.push(key);
+            let cmd = format!("{binary} orbit status --cluster {orbit} 2>/dev/null");
+            let Ok(o) = crate::ssh::run_ssh(config, node, cmd, false).await else {
+                continue;
+            };
+            let groups = parse_orbit_groups(&o);
+            if groups.is_empty() {
+                continue;
+            }
+            out.push(FlareLane {
+                cluster: cluster.name.clone(),
+                orbit: orbit.clone(),
+                controller: node.name.clone(),
+                controller_ip: node.ssh_ip.clone(),
+                groups,
+            });
+            break; // this orbit answered; the cluster needs no other candidate
         }
     }
-    (None, Vec::new())
+    out
 }
 
 pub struct Fleet {
     pub nodes: HashMap<String, WebNodeState>,
+    /// Live FLARE lanes (orbit cluster + controller + groups) — the dashboard
+    /// renders the pairing and the groups straight from here.
+    pub lanes: Vec<FlareLane>,
     /// cluster name → a blocking `extra_entity` reports anything but "on".
     pub switch_off: HashMap<String, bool>,
     /// cluster name → non-blocking entities that are off (display only).
@@ -292,19 +332,11 @@ pub async fn probe(config: &Config) -> Fleet {
     let mut out: HashMap<String, WebNodeState> = HashMap::new();
     // FLARE serves out of `flare orbit`, not docker: its groups are folded in
     // as pseudo-containers so the dashboard shows them like any other lane.
-    // They carry the controller's cluster label — the snapshot builder drops
-    // containers that belong to no known cluster.
-    let (flare_controller, flare) = flare_groups(config).await;
-    let controller_cluster = config
-        .clusters
-        .iter()
-        .find(|c| {
-            c.nodes
-                .iter()
-                .any(|x| Some(&x.ssh_ip) == flare_controller.as_ref())
-        })
-        .map(|c| c.name.clone())
-        .unwrap_or_default();
+    // Each lane already knows its own cluster — never re-derive it from the
+    // controller's IP (dgx1 answers for prod4 AND pair12, and the first
+    // cluster containing an IP is `4nodes-switch`, which mislabelled every
+    // other lane).
+    let lanes = flare_lanes(config).await;
     for h in futures::future::join_all(handles).await {
         match h {
             Ok((name, Ok(output))) => {
@@ -323,24 +355,20 @@ pub async fn probe(config: &Config) -> Fleet {
                         });
                     }
                 }
-                // Only the controller node hosts the orbit groups (the fold
-                // below runs per node — without this guard every node would
+                // 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).
-                // The group is reported once, on the node that runs `orbit`:
-                // clusters overlap here (dgx5 sits in six of them), so folding
-                // it onto "the controller's cluster" would light up several
-                // clusters for one lane.
-                let is_controller = flare_controller
-                    .as_deref()
-                    .is_some_and(|ip| ip == node_ip_of(config, &name));
-                for (group, created) in flare.iter().filter(|_| is_controller) {
-                    containers.push(ContainerSnap {
-                        name: group.clone(),
-                        status: "Up (flare)".into(),
-                        cluster: controller_cluster.clone(),
-                        recipe: group.clone(),
-                        created: *created,
-                    });
+                let ip = node_ip_of(config, &name);
+                for lane in lanes.iter().filter(|l| &l.controller_ip == ip) {
+                    for (group, created) in &lane.groups {
+                        containers.push(ContainerSnap {
+                            name: group.clone(),
+                            status: "Up (flare)".into(),
+                            cluster: lane.cluster.clone(),
+                            recipe: group.clone(),
+                            created: *created,
+                        });
+                    }
                 }
                 LAST_WARNED.lock().await.remove(&name);
                 out.insert(
@@ -382,6 +410,7 @@ pub async fn probe(config: &Config) -> Fleet {
         switch_off,
         soft_off,
         ha: ha_states(config).await,
+        lanes,
     }
 }
 

+ 25 - 1
src/web/settings_api.rs

@@ -8,7 +8,7 @@
 
 use axum::http::StatusCode;
 use axum::{Json, extract::State};
-use serde::Deserialize;
+use serde::{Deserialize, Serialize};
 
 use super::AppState;
 
@@ -17,6 +17,12 @@ pub(crate) struct SaveBody {
     text: String,
 }
 
+#[derive(Serialize)]
+pub(crate) struct CheckResult {
+    ok: bool,
+    error: Option<String>,
+}
+
 /// `GET /api/settings/extra`
 pub(crate) async fn get_extra(
     State(_app): State<AppState>,
@@ -26,6 +32,24 @@ pub(crate) async fn get_extra(
         .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))
 }
 
+/// `POST /api/settings/extra/check` — parse without writing. The same parser
+/// as save, so the editor's "check" agrees with what a save will accept.
+pub(crate) async fn check_extra(
+    State(_app): State<AppState>,
+    Json(body): Json<SaveBody>,
+) -> Json<CheckResult> {
+    match crate::settings::validate_extra(&body.text) {
+        Ok(()) => Json(CheckResult {
+            ok: true,
+            error: None,
+        }),
+        Err(e) => Json(CheckResult {
+            ok: false,
+            error: Some(e.to_string()),
+        }),
+    }
+}
+
 /// `POST /api/settings/extra` — validate, save, echo the stored state.
 pub(crate) async fn save_extra(
     State(_app): State<AppState>,

+ 3 - 0
src/web/tests.rs

@@ -208,6 +208,7 @@ fn stale_from_snapshot_picks_unreachable_nodes() {
             switch_off: false,
             soft_off: Vec::new(),
             free: 0,
+            orbit: None,
             nodes: vec![node("a", "1.1.1.1", true), node("b", "1.1.1.2", false)],
         }],
         jobs: Vec::new(),
@@ -347,6 +348,7 @@ fn header_shows_the_build_version() {
             switch_off: std::collections::HashMap::new(),
             soft_off: std::collections::HashMap::new(),
             ha: std::collections::HashMap::new(),
+            lanes: Vec::new(),
         },
         &[],
         &[],
@@ -375,6 +377,7 @@ async fn snapshot_serializes_with_type_tag() {
                 switch_off: std::collections::HashMap::new(),
                 soft_off: std::collections::HashMap::new(),
                 ha: std::collections::HashMap::new(),
+                lanes: Vec::new(),
             },
             &[],
             &[],

+ 54 - 9
src/web/ui.html

@@ -108,6 +108,14 @@
   .cluster-head .free { color: var(--muted); font-size: 12px; }
   .cluster-head .down { color: var(--led-off); font-size: 12px; }
   .cluster-head .soft { color: var(--led-busy); font-size: 12px; }
+  /* FLARE identification: which orbit this cluster speaks to and where the
+     `flare orbit` CLI runs. `live` adds the running group names. */
+  .cluster-head .orbit {
+    color: var(--accent); font-size: 11.5px; font-family: var(--mono);
+    border: 1px solid color-mix(in srgb, var(--accent) 45%, transparent);
+    border-radius: 999px; padding: 0 7px; cursor: help;
+  }
+  .cluster-head .orbit.idle { color: var(--muted); border-color: var(--border); }
   .node {
     display: flex; align-items: center; gap: 10px; padding: 5px 0;
     border-bottom: 1px solid var(--border); cursor: pointer;
@@ -977,16 +985,29 @@ function clusterReason(c) {
   if (c.soft_off && c.soft_off.length) r = (r ? r + " · " : "") + "⚙ " + c.soft_off.join(", ") + " off";
   return r;
 }
+// FLARE identification badge: `◆ prod4` — the orbit cluster this cluster
+// speaks to, the node running `flare orbit`, and the live groups. Rendered
+// even when idle (the pairing is a config fact), dimmed when nothing runs,
+// so an operator can tell which orbit a green lane came from.
+function orbitBadge(c) {
+  const o = c.orbit;
+  if (!o) return "";
+  const groups = o.groups || [];
+  const tip = "FLARE orbit: " + o.orbit + "\ncontroller: " + o.controller +
+    "\ngroups: " + (groups.length ? groups.join(", ") : "none running");
+  return `<span class="orbit${groups.length ? "" : " idle"}" title="${esc(tip)}">◆ ${esc(o.orbit)}</span>`;
+}
 function renderCluster(c, prev, animate) {
   const el = document.createElement("div");
   el.className = "cluster" + (animate ? " anim" : "");
   const head = document.createElement("div");
   head.className = "cluster-head";
+  const orb = orbitBadge(c);
   if (c.up) {
     const soft = c.soft_off && c.soft_off.length ? `<span class="soft" title="non-blocking entity off">⚙ ${esc(c.soft_off.join(", "))} off</span>` : "";
-    head.innerHTML = `<b>${esc(c.name)}</b><span class="free">${c.free} free</span>${soft}`;
+    head.innerHTML = `<b>${esc(c.name)}</b>${orb}<span class="free">${c.free} free</span>${soft}`;
   } else {
-    head.innerHTML = `<b>${esc(c.name)}</b><span class="down">⏻ offline — ${esc(clusterReason(c))}</span>`;
+    head.innerHTML = `<b>${esc(c.name)}</b>${orb}<span class="down">⏻ offline — ${esc(clusterReason(c))}</span>`;
   }
   el.appendChild(head);
   const prevNodes = prev ? Object.fromEntries(prev.nodes.map((n) => [n.name, n])) : {};
@@ -1132,7 +1153,7 @@ function onState(snap, force) {
     chip.className = "chip" + (c.name === state.active ? " active" : "");
     const dot = c.up ? (c.free > 0 ? "on" : "b") : "off";
     const m = c.up ? c.free + " free" : "⏻ OFF";
-    chip.title = clusterReason(c);
+    chip.title = clusterReason(c) + (c.orbit ? " · flare orbit " + c.orbit.orbit : "");
     chip.innerHTML = `<span class="led ${dot}"></span>${esc(c.name)}<span class="m">${m}</span>`;
     chip.onclick = () => { state.active = c.name; localStorage.setItem("sm121-active", c.name); onState(state.snap, true); };
     strip.appendChild(chip);
@@ -1375,21 +1396,27 @@ function topoFrames(snap) {
     band.sort((a, b) => a.r.x - b.r.x || a.c.name.localeCompare(b.c.name));
     let cursor = -1e9;
     for (const chip of band) {
-      chip.cw = chip.c.name.length * 6.6 + 30;
+      const orbTxt = chip.c.orbit ? " ◆" + chip.c.orbit.orbit : "";
+      chip.cw = (chip.c.name + orbTxt).length * 6.6 + 30;
       chip.x = Math.max(chip.r.x + 12, cursor);
       cursor = chip.x + chip.cw + 6;
     }
   }
   state.topoGroupTitles = {};
+  // a frame's label names the frame's clusters and, if any of them is a FLARE
+  // pairing, its orbit — the same nodes can host several clusters, so the
+  // frame must say WHICH orbit the lane belongs to.
+  const frameLabel = (cs) => cs.map((c) => c.name).join(" · ") +
+    cs.filter((c) => c.orbit).map((c) => ` ◆${c.orbit.orbit}`).join("");
   for (const r of rects) {
-    state.topoGroupTitles[r.g.clusters[0].name] = r.g.clusters.map((c) => c.name).join(" · ");
+    state.topoGroupTitles[r.g.clusters[0].name] = frameLabel(r.g.clusters);
   }
   // two layers: frame rects (below cables/devices) and chips (topmost —
   // opaque badges, nothing shows through, always clickable)
   let frames = "", chips = "";
   for (const r of rects) {
     frames += `<g class="cluster-frame" data-cluster="${esc(r.g.clusters[0].name)}">
-    <title>${esc(r.g.clusters.map((c) => c.name).join(" · "))}</title>
+    <title>${esc(frameLabel(r.g.clusters))}</title>
     <rect class="frame" style="stroke:${r.hue};fill:color-mix(in srgb, ${r.hue} 6%, transparent)"
       x="${r.x}" y="${r.y}" width="${r.w}" height="${r.h}" rx="14"/>
   </g>`;
@@ -1397,11 +1424,13 @@ function topoFrames(snap) {
   for (const r of rects) {
     for (const c of r.g.clusters) {
       const chip = bands.get(r.band).find((x) => x.c === c);
+      const orbTxt = c.orbit ? " ◆" + c.orbit.orbit : "";
+      const orbTip = c.orbit ? "\nflare orbit " + c.orbit.orbit + " · controller " + c.orbit.controller : "";
       chips += `<g class="fchip" data-cluster="${esc(c.name)}" transform="translate(${chip.x},${r.y - 8})">
-      <title>${esc(c.name)}</title>
+      <title>${esc(c.name + orbTip)}</title>
       <rect width="${chip.cw}" height="16" rx="8" style="stroke:${c._hue}"/>
       <circle class="cdot" cx="10" cy="8" r="3.5"/>
-      <text x="18" y="12" style="fill:${c._hue}">${esc(c.name)}</text>
+      <text x="18" y="12" style="fill:${c._hue}">${esc(c.name)}<tspan style="opacity:.75">${esc(orbTxt)}</tspan></text>
     </g>`;
     }
   }
@@ -2053,6 +2082,7 @@ async function openSettings(g) {
         <span class="adv setpath">loading…</span>
         <span class="setstat"></span>
         <button class="abtn sreload">reload</button>
+        <button class="abtn scheck">check</button>
         <button class="abtn ssave">save</button>
       </div>
       <div class="setnote">Private config block: clusters, nccl, HA token, mikrotik, registry —
@@ -2078,7 +2108,18 @@ async function openSettings(g) {
     } catch (e) { stat.textContent = "error: " + e.message; }
   }
   handle.body.querySelector(".sreload").onclick = () => { if (confirm("discard unsaved changes?")) load(); };
-  handle.body.querySelector(".ssave").onclick = async () => {
+  handle.body.querySelector(".scheck").onclick = async () => {
+    stat.textContent = "checking…";
+    try {
+      const res = await fetch("/api/settings/extra/check", {
+        method: "POST", headers: { ...AUTH(), "content-type": "application/json" },
+        body: JSON.stringify({ text: ta.value }),
+      });
+      const j = await res.json();
+      stat.textContent = j.ok ? "valid TOML ✓" : "invalid: " + (j.error || res.status);
+    } catch (e) { stat.textContent = "error: " + e.message; }
+  };
+  const save = async () => {
     stat.textContent = "saving…";
     try {
       const res = await fetch("/api/settings/extra", {
@@ -2089,6 +2130,10 @@ async function openSettings(g) {
       stat.textContent = res.ok ? "saved ✓ (applies on restart)" : "error: " + (j.error || res.status);
     } catch (e) { stat.textContent = "error: " + e.message; }
   };
+  handle.body.querySelector(".ssave").onclick = save;
+  ta.addEventListener("keydown", (e) => {
+    if ((e.ctrlKey || e.metaKey) && e.key === "Enter") { e.preventDefault(); save(); }
+  });
   saveWinState();
   load();
   return handle;