|
|
@@ -83,7 +83,9 @@ pub fn parse_orbit_groups(stdout: &str) -> Vec<(String, Option<u64>)> {
|
|
|
// 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 ")
|
|
|
+ // 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")
|
|
|
@@ -95,7 +97,12 @@ pub fn parse_orbit_groups(stdout: &str) -> Vec<(String, Option<u64>)> {
|
|
|
out.push((name.to_string(), None));
|
|
|
}
|
|
|
}
|
|
|
- for tok in stdout.split(|c: char| c.is_whitespace() || c == ',') {
|
|
|
+ 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 {
|
|
|
@@ -168,27 +175,43 @@ pub struct WebNodeState {
|
|
|
/// 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>)> {
|
|
|
+pub async fn flare_groups(
|
|
|
+ config: &crate::config::Config,
|
|
|
+) -> (Option<String>, Vec<(String, Option<u64>)>) {
|
|
|
let Some(f) = config.flare.as_ref() else {
|
|
|
- return Vec::new();
|
|
|
+ return (None, 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) = crate::web::flare_nodes::controller_node(config) 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()
|
|
|
+ let remembered = LAST_CONTROLLER.lock().await.clone();
|
|
|
+ for ip in crate::web::flare_nodes::probe_order(config, remembered.as_deref()) {
|
|
|
+ let Some(node) = config
|
|
|
+ .clusters
|
|
|
+ .iter()
|
|
|
+ .flat_map(|c| c.nodes.iter())
|
|
|
+ .find(|n| n.ssh_ip == ip)
|
|
|
+ else {
|
|
|
+ continue;
|
|
|
+ };
|
|
|
+ if let Ok(out) = crate::ssh::run_ssh(config, node, cmd.clone(), false).await {
|
|
|
+ let groups = parse_orbit_groups(&out);
|
|
|
+ if !groups.is_empty() {
|
|
|
+ *LAST_CONTROLLER.lock().await = Some(ip.clone());
|
|
|
+ return (Some(ip), groups);
|
|
|
+ }
|
|
|
}
|
|
|
}
|
|
|
+ (None, Vec::new())
|
|
|
}
|
|
|
+
|
|
|
+/// Controller address remembered between probe ticks.
|
|
|
+static LAST_CONTROLLER: std::sync::LazyLock<tokio::sync::Mutex<Option<String>>> =
|
|
|
+ std::sync::LazyLock::new(|| tokio::sync::Mutex::new(None));
|
|
|
+
|
|
|
pub struct Fleet {
|
|
|
pub nodes: HashMap<String, WebNodeState>,
|
|
|
/// cluster name → a blocking `extra_entity` reports anything but "on".
|
|
|
@@ -283,13 +306,14 @@ pub async fn probe(config: &Config) -> Fleet {
|
|
|
// 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 (flare_controller, flare) = flare_groups(config).await;
|
|
|
let controller_cluster = config
|
|
|
.clusters
|
|
|
.iter()
|
|
|
.find(|c| {
|
|
|
- crate::web::flare_nodes::controller_node(config)
|
|
|
- .is_some_and(|n| c.nodes.iter().any(|x| x.name == n.name))
|
|
|
+ c.nodes
|
|
|
+ .iter()
|
|
|
+ .any(|x| Some(&x.ssh_ip) == flare_controller.as_ref())
|
|
|
})
|
|
|
.map(|c| c.name.clone())
|
|
|
.unwrap_or_default();
|
|
|
@@ -314,8 +338,13 @@ 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
|
|
|
// report the same group).
|
|
|
- let is_controller = crate::web::flare_nodes::controller_node(config)
|
|
|
- .is_some_and(|c| c.ssh_ip == *node_ip_of(config, &name));
|
|
|
+ // 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(),
|
|
|
@@ -416,3 +445,19 @@ mod orbit_tests {
|
|
|
assert!(parse_orbit_groups("cluster sparks\nNODE FINGERPRINT FREE GiB REVISION GROUPS\ngx10-dgx5 ab12 562.3 1 -\n").is_empty());
|
|
|
}
|
|
|
}
|
|
|
+
|
|
|
+#[cfg(test)]
|
|
|
+mod orbit_dead_tests {
|
|
|
+ use super::parse_orbit_groups;
|
|
|
+
|
|
|
+ #[test]
|
|
|
+ fn dead_groups_are_not_running() {
|
|
|
+ let out =
|
|
|
+ "gx10-dgx5 ab12 86.1 1 grpA-20261024T062753Z-3dc3 rank 0 exited with status 1\n";
|
|
|
+ assert!(
|
|
|
+ parse_orbit_groups(out).is_empty(),
|
|
|
+ "{:?}",
|
|
|
+ parse_orbit_groups(out)
|
|
|
+ );
|
|
|
+ }
|
|
|
+}
|