|
|
@@ -61,7 +61,8 @@ fn is_relevant(ev: ¬ify::Event) -> bool {
|
|
|
ev.paths.iter().any(|p| {
|
|
|
p.ancestors()
|
|
|
.any(|a| a.file_name().is_some_and(|n| n == "reports"))
|
|
|
- || p.file_name().is_some_and(|n| n == "recipe.toml")
|
|
|
+ || p.file_name()
|
|
|
+ .is_some_and(|n| n == "recipe.toml" || n == ".lock")
|
|
|
})
|
|
|
}
|
|
|
|
|
|
@@ -98,6 +99,8 @@ pub(crate) async fn refresh_loop(
|
|
|
let mut last = String::new();
|
|
|
let mut rows: Vec<crate::report::ReportRow> = Vec::new();
|
|
|
let mut fleet: Option<crate::web::events::Snapshot> = None;
|
|
|
+ // ts of the fleet snapshot the rows were last reconciled against
|
|
|
+ let mut hull_ts: Option<u64> = None;
|
|
|
let cluster_nodes: HashMap<String, Vec<String>> = cfg
|
|
|
.clusters
|
|
|
.iter()
|
|
|
@@ -120,7 +123,7 @@ pub(crate) async fn refresh_loop(
|
|
|
tokio::time::sleep(DEBOUNCE).await;
|
|
|
while ev_rx.try_recv().is_ok() {}
|
|
|
rows = collect(&cfg, archived);
|
|
|
- cross_check(&cfg, &mut rows, &cluster_nodes, &mut fleet).await;
|
|
|
+ crate::report::stale::cross_check(&cfg, &mut rows, &cluster_nodes, &mut fleet).await;
|
|
|
let json = serde_json::to_string(&rows).unwrap_or_default();
|
|
|
if json != last {
|
|
|
last = json;
|
|
|
@@ -142,15 +145,23 @@ pub(crate) async fn refresh_loop(
|
|
|
fleet = Some(snapshot);
|
|
|
}
|
|
|
}
|
|
|
- // Keepalive safety net (missed watcher events): full collect only
|
|
|
- // while uncollected (startup); afterwards only the cheap
|
|
|
- // cross-check — and only when a run is open.
|
|
|
+ // Keepalive safety net (missed watcher events): a full collect when
|
|
|
+ // the RUN LEDGER disagrees with the rows — `serve`/`stop` rewrite
|
|
|
+ // `.lock`, and a missed event used to leave the panel showing lanes
|
|
|
+ // as up long after they were stopped (2026-10-21). Otherwise the
|
|
|
+ // cheap cross-check, re-run whenever a run is open OR a fresh fleet
|
|
|
+ // snapshot arrived (the add-on has no ledger: `fleet_up` is its
|
|
|
+ // only running signal, so it must be recomputed from the probe).
|
|
|
_ = tokio::time::sleep(keepalive) => {
|
|
|
- if rows.is_empty() {
|
|
|
+ let ledger = crate::report::stale::ledger_open(&cfg);
|
|
|
+ let fresh_fleet = fleet.as_ref().is_some_and(|f| Some(f.ts) != hull_ts);
|
|
|
+ if rows.is_empty() || crate::report::stale::ledger_differs(&rows, &ledger) {
|
|
|
rows = collect(&cfg, archived);
|
|
|
- cross_check(&cfg, &mut rows, &cluster_nodes, &mut fleet).await;
|
|
|
- } else if has_open_run(&rows) {
|
|
|
- cross_check(&cfg, &mut rows, &cluster_nodes, &mut fleet).await;
|
|
|
+ crate::report::stale::cross_check(&cfg, &mut rows, &cluster_nodes, &mut fleet).await;
|
|
|
+ hull_ts = fleet.as_ref().map(|f| f.ts);
|
|
|
+ } else if has_open_run(&rows) || fresh_fleet {
|
|
|
+ crate::report::stale::cross_check(&cfg, &mut rows, &cluster_nodes, &mut fleet).await;
|
|
|
+ hull_ts = fleet.as_ref().map(|f| f.ts);
|
|
|
} else {
|
|
|
continue; // nothing to do — zero work, zero allocation
|
|
|
}
|
|
|
@@ -164,50 +175,6 @@ pub(crate) async fn refresh_loop(
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-/// Reconcile the open-ledger rows against the live fleet: prefer the main
|
|
|
-/// dashboard's last fleet snapshot (no extra SSH); probe directly only when
|
|
|
-/// no snapshot has arrived yet (CLI `report --serve`, first 30 s of web).
|
|
|
-async fn cross_check(
|
|
|
- cfg: &Config,
|
|
|
- rows: &mut [crate::report::ReportRow],
|
|
|
- cluster_nodes: &HashMap<String, Vec<String>>,
|
|
|
- fleet: &mut Option<crate::web::events::Snapshot>,
|
|
|
-) {
|
|
|
- let (reachable, live) = match fleet {
|
|
|
- Some(snap) => {
|
|
|
- let mut reachable: HashMap<String, bool> = HashMap::new();
|
|
|
- let mut live: std::collections::HashSet<String> = std::collections::HashSet::new();
|
|
|
- for c in &snap.clusters {
|
|
|
- for n in &c.nodes {
|
|
|
- reachable.insert(n.name.clone(), n.reachable);
|
|
|
- for ct in &n.containers {
|
|
|
- live.insert(ct.recipe.clone());
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
- (reachable, live)
|
|
|
- }
|
|
|
- None => {
|
|
|
- let f = crate::web::probe::probe(cfg).await;
|
|
|
- let mut reachable: HashMap<String, bool> = HashMap::new();
|
|
|
- let mut live: std::collections::HashSet<String> = std::collections::HashSet::new();
|
|
|
- for (node, st) in &f.nodes {
|
|
|
- reachable.insert(node.clone(), st.reachable);
|
|
|
- for c in &st.containers {
|
|
|
- live.insert(c.recipe.clone());
|
|
|
- }
|
|
|
- }
|
|
|
- (reachable, live)
|
|
|
- }
|
|
|
- };
|
|
|
- crate::report::stale::mark_stale(rows, cluster_nodes, &reachable, &live);
|
|
|
- // The inverse direction: a container in the fleet with NO local ledger
|
|
|
- // run (the add-on machine) still renders as running.
|
|
|
- crate::report::stale::mark_fleet_up(rows, &live);
|
|
|
-}
|
|
|
-
|
|
|
-/// `GET /events` — the latest rows JSON on connect, then one message per
|
|
|
-/// refresher update. The stream ends when the refresher (the sender) drops.
|
|
|
async fn events_stream(
|
|
|
axum::extract::State(s): axum::extract::State<AppState>,
|
|
|
) -> Sse<impl Stream<Item = Result<SseEvent, Infallible>>> {
|
|
|
@@ -485,3 +452,7 @@ pub(crate) fn router(
|
|
|
base: base.to_string(),
|
|
|
})
|
|
|
}
|
|
|
+
|
|
|
+#[cfg(test)]
|
|
|
+#[path = "serve_tests.rs"]
|
|
|
+mod tests;
|