فهرست منبع

web: fold flare orbit groups into the controller node only

The fleet probe now shows a FLARE lane as one pseudo-container on the controller
node (cluster-labelled), matching the recipe by the orbit group's base name.
FLARE default port is the cluster standard 30000.
Alexander Korolev 18 ساعت پیش
والد
کامیت
3f076dbe59
2فایلهای تغییر یافته به همراه73 افزوده شده و 11 حذف شده
  1. 19 0
      src/config/types.rs
  2. 54 11
      src/web/probe.rs

+ 19 - 0
src/config/types.rs

@@ -275,6 +275,20 @@ pub struct ComposedFile {
 }
 
 impl Config {
+    /// The node that runs `flare orbit` (the `[flare].controller_node` when
+    /// set, else the first cluster's rank-0 node).
+    pub fn flake_controller_node(&self) -> Option<&Node> {
+        let want = self.flare.as_ref().and_then(|f| f.controller_node.clone());
+        let all = self.clusters.iter().flat_map(|c| c.nodes.iter());
+        match want {
+            Some(name) => all.clone().find(|n| n.name == name),
+            None => all
+                .clone()
+                .find(|n| n.rank == 0)
+                .or_else(|| all.into_iter().next()),
+        }
+    }
+
     pub fn resolved_hf_cache_dir(&self) -> std::path::PathBuf {
         if let Some(ref dir) = self.hf_cache_dir {
             return std::path::PathBuf::from(dir);
@@ -485,6 +499,11 @@ pub struct FlareSettings {
     /// `flare` binary path (default: from PATH).
     #[serde(default)]
     pub binary: Option<String>,
+    /// The node that runs the `flare orbit` CLI (our name, e.g. `dgx5.lan`).
+    /// Defaults to the first cluster's rank-0 node, which is wrong on a
+    /// multi-cluster config (the recipes panel then sees no FLARE groups).
+    #[serde(default)]
+    pub controller_node: Option<String>,
     /// `[cluster.<name>]` in `~/.config/flare/orbit.toml` (default `sparks`).
     #[serde(default)]
     pub orbit_cluster: Option<String>,

+ 54 - 11
src/web/probe.rs

@@ -79,6 +79,22 @@ pub fn created_epoch(s: &str) -> Option<u64> {
 /// 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() {
+        if 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));
+        }
+    }
     for tok in stdout.split(|c: char| c.is_whitespace() || c == ',') {
         let t = tok.trim_matches(|c: char| c == '`' || c == '"' || c == '\'');
         let parts: Vec<&str> = t.split('-').collect();
@@ -117,14 +133,28 @@ pub fn parse_orbit_groups(stdout: &str) -> Vec<(String, Option<u64>)> {
         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 entry = (base.to_string(), u64::try_from(secs).ok());
-        if !out.iter().any(|(n, _)| n == &entry.0) {
-            out.push(entry);
+        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 {
@@ -147,12 +177,7 @@ pub async fn flare_groups(config: &crate::config::Config) -> Vec<(String, Option
         .orbit_cluster
         .clone()
         .unwrap_or_else(|| "sparks".to_string());
-    let Some(controller) = config
-        .clusters
-        .iter()
-        .flat_map(|c| c.nodes.iter())
-        .find(|n| n.rank == 0)
-    else {
+    let Some(controller) = config.flake_controller_node() else {
         return Vec::new();
     };
     let cmd = format!("{binary} orbit status --cluster {orbit_cluster} 2>/dev/null");
@@ -256,7 +281,19 @@ 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 = flare_groups(config).await;
+    let controller_cluster = config
+        .clusters
+        .iter()
+        .find(|c| {
+            config
+                .flake_controller_node()
+                .is_some_and(|n| c.nodes.iter().any(|x| x.name == n.name))
+        })
+        .map(|c| c.name.clone())
+        .unwrap_or_default();
     for h in futures::future::join_all(handles).await {
         match h {
             Ok((name, Ok(output))) => {
@@ -275,11 +312,17 @@ pub async fn probe(config: &Config) -> Fleet {
                         });
                     }
                 }
-                for (group, created) in &flare {
+                // Only the controller node hosts the orbit groups (the fold
+                // below runs per node — without this guard every node would
+                // report the same group).
+                let is_controller = config
+                    .flake_controller_node()
+                    .is_some_and(|c| c.ssh_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: String::new(),
+                        cluster: controller_cluster.clone(),
                         recipe: group.clone(),
                         created: *created,
                     });