serve.rs 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500
  1. //! Live-update plumbing for `report --serve`: a background refresher that
  2. //! re-scans the recipe `reports/` dirs when the file system says something
  3. //! changed, and an SSE endpoint (`/events`) that pushes the rows JSON to the
  4. //! page whenever the scan result differs (a new report file, a bench
  5. //! finishing, a log growing).
  6. //!
  7. //! The refresher is event-driven, not a fixed poll: a `notify` watcher
  8. //! (FSEvents on macOS, inotify on Linux) over the recipe dirs feeds an mpsc
  9. //! channel; after a short debounce burst the rows are re-collected and
  10. //! published on the `watch` channel only when the serialization changed.
  11. //! Filesystem events are filtered to the report-relevant paths (anything
  12. //! under a `reports/` dir, or a `recipe.toml`), so build churn elsewhere in
  13. //! the repo is ignored. A slow keepalive scan stays as a safety net for
  14. //! missed notifications (legacy log-only reports, dirs created late).
  15. //!
  16. //! One refresher serves all clients: each SSE connection forwards its own
  17. //! `watch::Receiver`.
  18. use crate::config::Config;
  19. use crate::report::collect;
  20. use axum::{
  21. Router,
  22. extract::Path,
  23. response::{Response, Sse, sse::Event as SseEvent},
  24. routing::get,
  25. };
  26. use futures::{Stream, StreamExt};
  27. use notify::Watcher;
  28. use std::collections::HashMap;
  29. use std::convert::Infallible;
  30. use std::sync::Arc;
  31. use std::time::Duration;
  32. /// Safety-net rescan interval: covers missed watcher notifications (a
  33. /// legacy `.log`-only report whose score lands in a Modify the filter
  34. /// ignores, a source dir created after startup). The normal path is the
  35. /// event-driven one, so this can stay slow.
  36. pub(crate) const KEEPALIVE: Duration = Duration::from_secs(30);
  37. /// Wait after a filesystem event before re-scanning, so a write burst
  38. /// (the launcher copying a report triplet) collapses into one collect.
  39. const DEBOUNCE: Duration = Duration::from_millis(500);
  40. /// Shared server state: the config (for `/r/...` and `/repo/...` file
  41. /// loads) plus the latest rows JSON (each SSE connection clones the
  42. /// receiver) plus the URL base this server is mounted under ("" for
  43. /// `report --serve`, "/recipes" when the dashboard nests it) — the viewer
  44. /// link targets carry it.
  45. #[derive(Clone)]
  46. pub(crate) struct AppState {
  47. pub(crate) config: Arc<Config>,
  48. pub(crate) data: tokio::sync::watch::Receiver<String>,
  49. pub(crate) base: String,
  50. }
  51. /// True when the event touches a report-relevant path: anything under a
  52. /// `reports/` directory, or a `recipe.toml` (row fields come from it).
  53. /// Everything else (build churn, git, the rest of the repo) is ignored so
  54. /// the watcher can watch a wide tree cheaply.
  55. fn is_relevant(ev: &notify::Event) -> bool {
  56. ev.paths.iter().any(|p| {
  57. p.ancestors()
  58. .any(|a| a.file_name().is_some_and(|n| n == "reports"))
  59. || p.file_name()
  60. .is_some_and(|n| n == "recipe.toml" || n == ".lock")
  61. })
  62. }
  63. /// Background refresher — lazy by design (the web process must stay small;
  64. /// a full collect scans every recipe + report file): a full re-collect runs
  65. /// ONLY on a debounced filesystem event (or once at startup); the keepalive
  66. /// tick just re-runs the live stale cross-check (the only row field that
  67. /// changes without a file change) and only when a run is open — otherwise it
  68. /// does zero work. The cross-check rides on the main dashboard's fleet
  69. /// probe (`fleet_rx` bus State events — no extra SSH) and falls back to a
  70. /// direct probe when no bus is wired (CLI `report --serve`).
  71. pub(crate) async fn refresh_loop(
  72. cfg: Arc<Config>,
  73. tx: tokio::sync::watch::Sender<String>,
  74. keepalive: Duration,
  75. archived: bool,
  76. fleet_rx: Option<tokio::sync::broadcast::Receiver<crate::web::events::Event>>,
  77. ) {
  78. let mut fleet_rx = fleet_rx;
  79. // The dashboard outlives its own startup: a recipe repo cloned later (a
  80. // manual ⟳ sync, the background loop, a first boot whose sync failed) has
  81. // to become visible without a restart. This is a private copy of the
  82. // config — the CLI paths already append the roots at process start.
  83. let mut cfg: Config = (*cfg).clone();
  84. let (ev_tx, mut ev_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
  85. let watcher = notify::recommended_watcher(move |res: Result<notify::Event, notify::Error>| {
  86. if let Ok(ev) = res
  87. && is_relevant(&ev)
  88. {
  89. let _ = ev_tx.send(());
  90. }
  91. });
  92. let mut watcher = watcher.ok();
  93. let mut watched: std::collections::HashSet<std::path::PathBuf> = Default::default();
  94. if let Some(w) = &mut watcher {
  95. for dir in crate::config::watchable_recipe_dirs(&cfg) {
  96. if w.watch(&dir, notify::RecursiveMode::Recursive).is_ok() {
  97. watched.insert(dir);
  98. }
  99. }
  100. }
  101. let mut last = String::new();
  102. let mut rows: Vec<crate::report::ReportRow> = Vec::new();
  103. let mut fleet: Option<crate::web::events::Snapshot> = None;
  104. // ts of the fleet snapshot the rows were last reconciled against
  105. let mut hull_ts: Option<u64> = None;
  106. let cluster_nodes: HashMap<String, Vec<String>> = cfg
  107. .clusters
  108. .iter()
  109. .map(|c| {
  110. (
  111. c.name.clone(),
  112. c.nodes.iter().map(|n| n.name.clone()).collect(),
  113. )
  114. })
  115. .collect();
  116. let has_open_run = |rows: &[crate::report::ReportRow]| {
  117. rows.iter()
  118. .any(|r| r.up.is_some() && r.up_cluster.is_some())
  119. };
  120. loop {
  121. tokio::select! {
  122. // Filesystem event (debounced) → the only full-rescan trigger.
  123. _ = ev_rx.recv() => {
  124. // debounce: let the burst settle, then drain the remaining events
  125. tokio::time::sleep(DEBOUNCE).await;
  126. while ev_rx.try_recv().is_ok() {}
  127. rows = collect(&cfg, archived);
  128. crate::report::stale::cross_check(&cfg, &mut rows, &cluster_nodes, &mut fleet).await;
  129. let json = serde_json::to_string(&rows).unwrap_or_default();
  130. if json != last {
  131. last = json;
  132. let _ = tx.send(last.clone());
  133. }
  134. }
  135. // Bus State event (the main dashboard's 30 s fleet probe) →
  136. // just track the fleet; the keepalive tick consumes it.
  137. ev = async {
  138. match fleet_rx.as_mut() {
  139. Some(rx) => rx.recv().await,
  140. None => std::future::pending::<Result<
  141. crate::web::events::Event,
  142. tokio::sync::broadcast::error::RecvError,
  143. > >().await,
  144. }
  145. } => {
  146. if let Ok(crate::web::events::Event::State { snapshot, .. }) = ev {
  147. fleet = Some(snapshot);
  148. }
  149. }
  150. // Keepalive safety net (missed watcher events): a full collect when
  151. // the RUN LEDGER disagrees with the rows — `serve`/`stop` rewrite
  152. // `.lock`, and a missed event used to leave the panel showing lanes
  153. // as up long after they were stopped (2026-10-21). Otherwise the
  154. // cheap cross-check, re-run whenever a run is open OR a fresh fleet
  155. // snapshot arrived (the add-on has no ledger: `fleet_up` is its
  156. // only running signal, so it must be recomputed from the probe).
  157. _ = tokio::time::sleep(keepalive) => {
  158. // A repo cloned since the last tick (manual sync, the add-on's
  159. // background loop, a first boot whose sync failed) must become
  160. // visible without a restart: watch it and re-collect.
  161. let mut dirs_changed = false;
  162. for root in crate::recipe_repos::cached_roots() {
  163. let src = root.join("recipes").display().to_string();
  164. if !cfg.recipe_sources.contains(&src) {
  165. cfg.recipe_sources.push(src);
  166. cfg.docker_image_sources.push(root.display().to_string());
  167. dirs_changed = true;
  168. }
  169. }
  170. for dir in crate::config::watchable_recipe_dirs(&cfg) {
  171. if watched.contains(&dir) {
  172. continue;
  173. }
  174. if let Some(w) = &mut watcher
  175. && w.watch(&dir, notify::RecursiveMode::Recursive).is_ok()
  176. {
  177. watched.insert(dir);
  178. dirs_changed = true;
  179. }
  180. }
  181. if dirs_changed {
  182. rows = collect(&cfg, archived);
  183. crate::report::stale::cross_check(&cfg, &mut rows, &cluster_nodes, &mut fleet).await;
  184. hull_ts = fleet.as_ref().map(|f| f.ts);
  185. let json = serde_json::to_string(&rows).unwrap_or_default();
  186. if json != last {
  187. last = json;
  188. let _ = tx.send(last.clone());
  189. }
  190. continue;
  191. }
  192. let ledger = crate::report::stale::ledger_open(&cfg);
  193. let fresh_fleet = fleet.as_ref().is_some_and(|f| Some(f.ts) != hull_ts);
  194. if rows.is_empty() || crate::report::stale::ledger_differs(&rows, &ledger) {
  195. rows = collect(&cfg, archived);
  196. crate::report::stale::cross_check(&cfg, &mut rows, &cluster_nodes, &mut fleet).await;
  197. hull_ts = fleet.as_ref().map(|f| f.ts);
  198. } else if has_open_run(&rows) || fresh_fleet {
  199. crate::report::stale::cross_check(&cfg, &mut rows, &cluster_nodes, &mut fleet).await;
  200. hull_ts = fleet.as_ref().map(|f| f.ts);
  201. } else {
  202. continue; // nothing to do — zero work, zero allocation
  203. }
  204. let json = serde_json::to_string(&rows).unwrap_or_default();
  205. if json != last {
  206. last = json;
  207. let _ = tx.send(last.clone());
  208. }
  209. }
  210. }
  211. }
  212. }
  213. async fn events_stream(
  214. axum::extract::State(s): axum::extract::State<AppState>,
  215. ) -> Sse<impl Stream<Item = Result<SseEvent, Infallible>>> {
  216. let mut rx = s.data;
  217. let first = rx.borrow_and_update().clone();
  218. let first = futures::stream::once(async move { Ok(SseEvent::default().data(first)) });
  219. let rest = futures::stream::unfold(rx, |mut rx| async move {
  220. if rx.changed().await.is_err() {
  221. return None;
  222. }
  223. let json = rx.borrow_and_update().clone();
  224. Some((Ok(SseEvent::default().data(json)), rx))
  225. });
  226. Sse::new(first.chain(rest))
  227. // the rows only change when a bench report changes — a silent
  228. // stream would be indistinguishable from a dead one for anything
  229. // between the server and the browser
  230. .keep_alive(axum::response::sse::KeepAlive::new().interval(Duration::from_secs(15)))
  231. }
  232. /// `GET /r/<recipe>/<file>` — full file text (report, log, json, or the
  233. /// recipe's own `recipe.toml`) served through the viewer page. Both path
  234. /// parts must be plain names (no traversal); the resolve walks every
  235. /// recipe dir, so stay strict.
  236. pub(crate) fn load_report(config: &Config, recipe: &str, file: &str) -> Option<String> {
  237. let plain = |s: &str| {
  238. !s.contains("..")
  239. && s.chars()
  240. .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
  241. };
  242. if !plain(recipe) || !plain(file) {
  243. return None;
  244. }
  245. crate::config::recipe_dirs(config)
  246. .into_iter()
  247. .find_map(|dir| {
  248. let base = dir.join(recipe);
  249. // "recipe.toml" resolves from the recipe dir root, everything
  250. // else from its reports/ subdir
  251. let p = if file == "recipe.toml" {
  252. base.join("recipe.toml")
  253. } else {
  254. base.join("reports").join(file)
  255. };
  256. p.is_file()
  257. .then(|| std::fs::read_to_string(p).ok())
  258. .flatten()
  259. })
  260. }
  261. /// Braille spinner frame or progress bar — llama-benchy's rich in-place
  262. /// progress redraws.
  263. fn is_spinner_frame(line: &str) -> bool {
  264. let t = line.trim_start();
  265. t.starts_with(['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']) || t.contains('━')
  266. }
  267. /// Progress-redraw identity: the spinner frames differ only by the glyph,
  268. /// the elapsed clock and the run/progress counters — same key = same phase
  269. /// redrawn in place.
  270. fn progress_key(line: &str) -> String {
  271. line.split_whitespace()
  272. .filter(|t| {
  273. // keep only wordish tokens (the test name): bars — the tip glyph
  274. // varies as the bar advances (━╸╺…) — spinner glyphs, @ signs and
  275. // other graphics carry no alphanumerics; counters (0/18), elapsed
  276. // clocks (0:01:48) and the "run N" pair are progress churn.
  277. t.chars().any(|c| c.is_alphanumeric())
  278. && !t.chars().all(|c| c.is_ascii_digit() || c == '/')
  279. && !t.contains(':')
  280. && *t != "run"
  281. })
  282. .collect::<Vec<_>>()
  283. .join(" ")
  284. }
  285. /// Make old pty-captured bench logs read like the new ones: drop pty
  286. /// control-char artifacts (`^D`/backspace echoes), collapse each spinner
  287. /// phase's redraw run down to its last frame, drop consecutive duplicate
  288. /// lines. Idempotent on logs that are already clean.
  289. pub(crate) fn clean_log(content: &str) -> String {
  290. let lines: Vec<String> = content
  291. .lines()
  292. .map(|l| {
  293. let l: String = l
  294. .chars()
  295. .filter(|c| !matches!(c, '\u{4}' | '\u{8}'))
  296. .collect();
  297. // the pty echoes control chars as caret notation (^D, ^H) —
  298. // printable '^' + @.._ pairs at the line start
  299. let mut l = l.as_str();
  300. while l.starts_with('^')
  301. && l[1..]
  302. .chars()
  303. .next()
  304. .is_some_and(|c| ('@'..='_').contains(&c))
  305. {
  306. l = &l[2..];
  307. }
  308. l.to_string()
  309. })
  310. .collect();
  311. let mut keep = vec![true; lines.len()];
  312. let mut i = 0;
  313. while i < lines.len() {
  314. let prog = is_spinner_frame(&lines[i]);
  315. let key = if prog {
  316. progress_key(&lines[i])
  317. } else {
  318. String::new()
  319. };
  320. let mut j = i + 1;
  321. while j < lines.len() {
  322. let same = if prog {
  323. is_spinner_frame(&lines[j]) && progress_key(&lines[j]) == key
  324. } else {
  325. !lines[i].is_empty() && lines[j] == lines[i]
  326. };
  327. if !same {
  328. break;
  329. }
  330. keep[j] = false;
  331. j += 1;
  332. }
  333. if j > i + 1 && prog {
  334. // a redraw run keeps only its final frame (exact dups keep the first)
  335. keep[i] = false;
  336. keep[j - 1] = true;
  337. }
  338. i = j;
  339. }
  340. lines
  341. .iter()
  342. .enumerate()
  343. .filter(|(k, _)| keep[*k])
  344. .map(|(_, l)| l.as_str())
  345. .collect::<Vec<_>>()
  346. .join("\n")
  347. }
  348. /// `GET /r/<recipe>/<file>` with `?follow=1` — SSE stream of a `.log`
  349. /// report file, updated as the file grows (the bench-log follow). The first
  350. /// message carries the full cleaned content (the client's baseline); each
  351. /// subsequent message carries only the appended tail (the delta since the
  352. /// last poll). The client appends deltas to its baseline. The stream polls
  353. /// the file every 2 s and ends when the client disconnects. Non-`.log`
  354. /// files and a missing `?follow=1` fall through to the static `report_file`.
  355. async fn report_file_or_stream(
  356. axum::extract::State(state): axum::extract::State<AppState>,
  357. axum::extract::Path((recipe, file)): Path<(String, String)>,
  358. axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
  359. ) -> axum::response::Response {
  360. use axum::response::IntoResponse;
  361. let follow = params.get("follow").is_some_and(|s| s == "1");
  362. if !follow || !file.ends_with(".log") {
  363. return report_file(state, Path((recipe, file))).await;
  364. }
  365. let config = state.config.clone();
  366. type SseRes = Result<SseEvent, std::convert::Infallible>;
  367. let stream = futures::stream::unfold(
  368. (config, recipe, file, 0usize),
  369. |mut st: (Arc<Config>, String, String, usize)| async move {
  370. let (cfg, recipe, file, last_len) = &mut st;
  371. if let Some(content) = load_report(cfg, recipe, file) {
  372. let clean = crate::ansi::strip_ansi(content.as_bytes());
  373. let clean = String::from_utf8_lossy(&clean)
  374. .replace("\r\n", "\n")
  375. .replace('\r', "\n");
  376. let clean = clean_log(&clean);
  377. if clean.len() > *last_len {
  378. let delta = clean.split_at(*last_len).1.to_string();
  379. *last_len = clean.len();
  380. return Some((
  381. Ok(SseEvent::default().data(delta)) as SseRes,
  382. (cfg.clone(), recipe.clone(), file.clone(), *last_len),
  383. ));
  384. }
  385. }
  386. tokio::time::sleep(Duration::from_secs(2)).await;
  387. Some((
  388. Ok(SseEvent::default().comment("keepalive")) as SseRes,
  389. (cfg.clone(), recipe.clone(), file.clone(), *last_len),
  390. ))
  391. },
  392. );
  393. Sse::new(stream)
  394. .keep_alive(axum::response::sse::KeepAlive::new().interval(Duration::from_secs(15)))
  395. .into_response()
  396. }
  397. /// `GET /r/<recipe>/<file>` — file viewer page: rendered markdown (with a
  398. /// raw toggle), syntax-highlighted TOML/JSON, plain logs.
  399. pub(crate) const VIEW_TEMPLATE: &str = include_str!("view.html");
  400. async fn report_file(
  401. state: AppState,
  402. axum::extract::Path((recipe, file)): Path<(String, String)>,
  403. ) -> Response {
  404. use axum::http::StatusCode;
  405. use axum::response::IntoResponse;
  406. match load_report(&state.config, &recipe, &file) {
  407. Some(text) => {
  408. // Strip ANSI escapes (old logs were saved raw; new ones are
  409. // already clean) and normalize line ends: the old pty-saved
  410. // logs carry CRLF (ONLCR) and the Live-soup ones bare \r —
  411. // CRLF first, then lone \r, so nothing doubles.
  412. let clean = crate::ansi::strip_ansi(text.as_bytes());
  413. let clean = String::from_utf8_lossy(&clean)
  414. .replace("\r\n", "\n")
  415. .replace('\r', "\n");
  416. // old pty-captured logs carry spinner-redraw soup and pty
  417. // control-char artifacts — render them like the clean ones
  418. let clean = if file.ends_with(".log") {
  419. clean_log(&clean)
  420. } else {
  421. clean
  422. };
  423. let links = if file == "recipe.toml" {
  424. super::repo::toml_links(&clean, &recipe, &state.base)
  425. } else {
  426. Vec::new()
  427. };
  428. let meta = serde_json::json!({ "recipe": recipe, "file": file, "content": clean, "links": links, "base": state.base });
  429. let meta = serde_json::to_string(&meta)
  430. .unwrap_or_default()
  431. .replace('<', "\\u003c");
  432. axum::response::Html(VIEW_TEMPLATE.replace("{{META}}", &meta)).into_response()
  433. }
  434. None => (
  435. StatusCode::NOT_FOUND,
  436. axum::response::Html(
  437. "<!doctype html><meta charset=\"utf-8\"><title>404</title>report not found"
  438. .to_string(),
  439. ),
  440. )
  441. .into_response(),
  442. }
  443. }
  444. /// Build the report server router: static page at `/`, live SSE at
  445. /// `/events`, full report text at `/r/<recipe>/<file>`. `initial` is the
  446. /// rows JSON snapshot used to seed the watch channel (from the same
  447. /// `collect` that rendered the page, so the first paint and the first SSE
  448. /// message agree).
  449. pub(crate) fn router(
  450. config: Arc<Config>,
  451. page: String,
  452. initial: String,
  453. archived: bool,
  454. base: &str,
  455. fleet_rx: Option<tokio::sync::broadcast::Receiver<crate::web::events::Event>>,
  456. ) -> Router {
  457. let (tx, rx) = tokio::sync::watch::channel(initial);
  458. tokio::spawn(refresh_loop(
  459. config.clone(),
  460. tx,
  461. KEEPALIVE,
  462. archived,
  463. fleet_rx,
  464. ));
  465. Router::new()
  466. .route(
  467. "/",
  468. get(move || {
  469. let page = page.clone();
  470. async move { axum::response::Html(page) }
  471. }),
  472. )
  473. .route("/events", get(events_stream))
  474. .route("/r/{recipe}/{file}", get(report_file_or_stream))
  475. .route("/repo/{*path}", get(super::repo::repo_route))
  476. .with_state(AppState {
  477. config,
  478. data: rx,
  479. base: base.to_string(),
  480. })
  481. }
  482. #[cfg(test)]
  483. #[path = "serve_tests.rs"]
  484. mod tests;