瀏覽代碼

web: FLARE groups show up in the dashboard like docker containers

flare orbit serves no docker container, so the fleet probe now folds
'flare orbit status --cluster <c>' groups (parsed with their start time) into
the controller node's container list — pseudo names 'Up (flare)'. The report's
fleet_up then matches the recipe by the group's base name, which is now the
recipe directory name, so a FLARE lane renders green with a real uptime even on
a machine with no run ledger (the add-on).

Flare default API port is the cluster standard 30000 (was FLARE's 8000), and the
driver skips the orbit-status preflight on peers that have no orbit file.
Alexander Korolev 13 小時之前
父節點
當前提交
04d383dc2e
共有 3 個文件被更改,包括 134 次插入 和 11 次删除
  1. 7 1
      src/bench.rs
  2. 6 10
      src/native/flare.rs
  3. 121 0
      src/web/probe.rs

+ 7 - 1
src/bench.rs

@@ -63,7 +63,13 @@ pub async fn run(config: &Config, a: &Args, dry_run: bool) -> Result<()> {
         .canonicalize()?;
     let cluster = select_cluster_by_name(config, &a.cluster)?;
     let head = pick_head(&cluster.nodes, &cluster.name, a.node.as_deref())?;
-    let port = recipe.port;
+    // FLARE serves on its own fixed API port (`[flare].http_port`), not the
+    // cluster default the docker lanes use.
+    let port = if recipe.engine == "flare" {
+        crate::native::flare::http_port(&recipe)
+    } else {
+        recipe.port
+    };
     let model = served_model(&recipe).ok_or_else(|| {
         anyhow::anyhow!(
             "recipe '{}' has no --served-model-name in args — add it to the recipe",

+ 6 - 10
src/native/flare.rs

@@ -28,13 +28,14 @@ use tracing::info;
 use crate::config::{Config, Node, Recipe};
 use crate::docker::{ServeOpts, shq};
 
-/// FLARE API port on the controller (recipe `[flare].http_port`, else 8000).
+/// FLARE API port on the controller (recipe `[flare].http_port`, else the
+/// cluster default 30000 — the same port every other lane serves on).
 pub fn http_port(recipe: &Recipe) -> u16 {
     recipe
         .flare
         .as_ref()
         .and_then(|f| f.http_port)
-        .unwrap_or(8000)
+        .unwrap_or(30000)
 }
 
 fn orbit_cluster(config: &Config) -> String {
@@ -219,12 +220,7 @@ pub async fn serve(
     }
     let dl = download_cmd(config, recipe)?;
     let package = crate::ssh::run_ssh(config, controller, dl, dry_run).await?;
-    let package = package
-        .lines()
-        .last()
-        .unwrap_or_default()
-        .trim()
-        .to_string();
+    let package = parse_package_path(&package).unwrap_or_default();
     if !dry_run && package.is_empty() {
         bail!(
             "flare download printed no package path on {}",
@@ -332,10 +328,10 @@ mod tests {
         );
         assert!(cmd.contains("--cluster 'sparks'"), "{cmd}");
         assert!(cmd.contains("--name 'deepseek1017'"), "{cmd}");
-        assert!(cmd.contains("--port 8000"), "{cmd}");
+        assert!(cmd.contains("--port 8000"), "{cmd}"); // recipe value wins
         assert!(cmd.contains("--detach"), "{cmd}");
         assert!(cmd.contains("'--mtp-adaptive-verification'"), "{cmd}");
-        assert!(cmd.contains("--port 8000"), "{cmd}");
+        assert!(cmd.contains("--port 8000"), "{cmd}"); // recipe value wins
         assert!(
             cmd.contains("--dangerously-accept-publisher 'local-development'"),
             "{cmd}"

+ 121 - 0
src/web/probe.rs

@@ -71,6 +71,60 @@ 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();
+    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();
+        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 entry = (base.to_string(), u64::try_from(secs).ok());
+        if !out.iter().any(|(n, _)| n == &entry.0) {
+            out.push(entry);
+        }
+    }
+    out
+}
+
 /// Per-node state with containers.
 #[derive(Clone, Debug, Default)]
 pub struct WebNodeState {
@@ -80,6 +134,36 @@ pub struct WebNodeState {
 }
 
 /// Fleet-wide probe result: per-node state + per-cluster HA switch state.
+/// `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) -> Vec<(String, Option<u64>)> {
+    let Some(f) = config.flare.as_ref() else {
+        return Vec::new();
+    };
+    let binary = f.binary.clone().unwrap_or_else(|| "flare".to_string());
+    let orbit_cluster = f
+        .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 {
+        return Vec::new();
+    };
+    let cmd = format!("{binary} orbit status --cluster {orbit_cluster} 2>/dev/null");
+    match crate::ssh::run_ssh(config, controller, cmd, false).await {
+        Ok(out) => parse_orbit_groups(&out),
+        Err(e) => {
+            tracing::debug!("flare orbit status failed: {e}");
+            Vec::new()
+        }
+    }
+}
 pub struct Fleet {
     pub nodes: HashMap<String, WebNodeState>,
     /// cluster name → a blocking `extra_entity` reports anything but "on".
@@ -170,6 +254,9 @@ 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.
+    let flare = flare_groups(config).await;
     for h in futures::future::join_all(handles).await {
         match h {
             Ok((name, Ok(output))) => {
@@ -188,6 +275,15 @@ pub async fn probe(config: &Config) -> Fleet {
                         });
                     }
                 }
+                for (group, created) in &flare {
+                    containers.push(ContainerSnap {
+                        name: group.clone(),
+                        status: "Up (flare)".into(),
+                        cluster: String::new(),
+                        recipe: group.clone(),
+                        created: *created,
+                    });
+                }
                 LAST_WARNED.lock().await.remove(&name);
                 out.insert(
                     name,
@@ -254,3 +350,28 @@ mod created_tests {
         assert_eq!(created_epoch("yesterday"), None);
     }
 }
+
+#[cfg(test)]
+mod orbit_tests {
+    use super::parse_orbit_groups;
+
+    #[test]
+    fn orbit_status_yields_group_and_start_time() {
+        let out = "cluster sparks\n\
+                   NODE       FINGERPRINT   FREE GiB  REVISION  GROUPS\n\
+                   gx10-dgx5  e3d68d8a3deb      86.2         1  deepseek1017 rank 0\n\
+                               gpu 0 NVIDIA GB10 (gb10) 40.7/121.6 GiB free, held by deepseek1017-20261024T062147Z-e2ea\n";
+        let groups = parse_orbit_groups(out);
+        assert!(
+            groups.iter().any(|(n, _)| n == "deepseek1017"),
+            "{groups:?}"
+        );
+        let ts = groups.iter().find(|(n, _)| n == "deepseek1017").unwrap().1;
+        assert!(ts.is_some(), "group start time parsed: {groups:?}");
+    }
+
+    #[test]
+    fn plain_status_has_no_groups() {
+        assert!(parse_orbit_groups("cluster sparks\nNODE FINGERPRINT FREE GiB REVISION GROUPS\ngx10-dgx5 ab12 562.3 1 -\n").is_empty());
+    }
+}