serve.rs 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361
  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().is_some_and(|n| n == "recipe.toml")
  60. })
  61. }
  62. /// Background refresher: watch the recipe dirs, re-collect after a debounced
  63. /// filesystem event (or the keepalive safety net), publish the rows JSON on
  64. /// the watch channel only when it changed. The first collect runs
  65. /// immediately so clients see current data without waiting. If the watcher
  66. /// cannot be created (unsupported FS), the keepalive alone keeps the data
  67. /// fresh.
  68. pub(crate) async fn refresh_loop(
  69. cfg: Arc<Config>,
  70. tx: tokio::sync::watch::Sender<String>,
  71. keepalive: Duration,
  72. archived: bool,
  73. ) {
  74. let (ev_tx, mut ev_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
  75. let watcher = notify::recommended_watcher(move |res: Result<notify::Event, notify::Error>| {
  76. if let Ok(ev) = res
  77. && is_relevant(&ev)
  78. {
  79. let _ = ev_tx.send(());
  80. }
  81. });
  82. let mut watcher = watcher.ok();
  83. if let Some(w) = &mut watcher {
  84. for dir in crate::config::recipe_dirs(&cfg) {
  85. if dir.is_dir() {
  86. let _ = w.watch(&dir, notify::RecursiveMode::Recursive);
  87. }
  88. }
  89. }
  90. let mut last = String::new();
  91. loop {
  92. tokio::select! {
  93. _ = ev_rx.recv() => {}
  94. _ = tokio::time::sleep(keepalive) => {}
  95. }
  96. // debounce: let the burst settle, then drain the remaining events
  97. tokio::time::sleep(DEBOUNCE).await;
  98. while ev_rx.try_recv().is_ok() {}
  99. let mut rows = collect(&cfg, archived);
  100. // Live cross-check: an open ledger run with no running container
  101. // anywhere (all its nodes reachable) is marked stale — otherwise a
  102. // killed serve or a node that died before `stop` could verify would
  103. // show as "up" forever. No open run with a cluster → no probe.
  104. if rows
  105. .iter()
  106. .any(|r| r.up.is_some() && r.up_cluster.is_some())
  107. {
  108. let fleet = crate::web::probe::probe(&cfg).await;
  109. let mut cluster_nodes: HashMap<String, Vec<String>> = HashMap::new();
  110. for c in &cfg.clusters {
  111. cluster_nodes.insert(
  112. c.name.clone(),
  113. c.nodes.iter().map(|n| n.name.clone()).collect(),
  114. );
  115. }
  116. let mut reachable: HashMap<String, bool> = HashMap::new();
  117. let mut live: std::collections::HashSet<String> = std::collections::HashSet::new();
  118. for (node, st) in &fleet.nodes {
  119. reachable.insert(node.clone(), st.reachable);
  120. for c in &st.containers {
  121. live.insert(c.recipe.clone());
  122. }
  123. }
  124. crate::report::stale::mark_stale(&mut rows, &cluster_nodes, &reachable, &live);
  125. }
  126. let json = serde_json::to_string(&rows).unwrap_or_default();
  127. if json != last {
  128. last = json;
  129. let _ = tx.send(last.clone());
  130. }
  131. }
  132. }
  133. /// `GET /events` — the latest rows JSON on connect, then one message per
  134. /// refresher update. The stream ends when the refresher (the sender) drops.
  135. async fn events_stream(
  136. axum::extract::State(s): axum::extract::State<AppState>,
  137. ) -> Sse<impl Stream<Item = Result<SseEvent, Infallible>>> {
  138. let mut rx = s.data;
  139. let first = rx.borrow_and_update().clone();
  140. let first = futures::stream::once(async move { Ok(SseEvent::default().data(first)) });
  141. let rest = futures::stream::unfold(rx, |mut rx| async move {
  142. if rx.changed().await.is_err() {
  143. return None;
  144. }
  145. let json = rx.borrow_and_update().clone();
  146. Some((Ok(SseEvent::default().data(json)), rx))
  147. });
  148. Sse::new(first.chain(rest))
  149. // the rows only change when a bench report changes — a silent
  150. // stream would be indistinguishable from a dead one for anything
  151. // between the server and the browser
  152. .keep_alive(axum::response::sse::KeepAlive::new().interval(Duration::from_secs(15)))
  153. }
  154. /// `GET /r/<recipe>/<file>` — full file text (report, log, json, or the
  155. /// recipe's own `recipe.toml`) served through the viewer page. Both path
  156. /// parts must be plain names (no traversal); the resolve walks every
  157. /// recipe dir, so stay strict.
  158. pub(crate) fn load_report(config: &Config, recipe: &str, file: &str) -> Option<String> {
  159. let plain = |s: &str| {
  160. !s.contains("..")
  161. && s.chars()
  162. .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
  163. };
  164. if !plain(recipe) || !plain(file) {
  165. return None;
  166. }
  167. crate::config::recipe_dirs(config)
  168. .into_iter()
  169. .find_map(|dir| {
  170. let base = dir.join(recipe);
  171. // "recipe.toml" resolves from the recipe dir root, everything
  172. // else from its reports/ subdir
  173. let p = if file == "recipe.toml" {
  174. base.join("recipe.toml")
  175. } else {
  176. base.join("reports").join(file)
  177. };
  178. p.is_file()
  179. .then(|| std::fs::read_to_string(p).ok())
  180. .flatten()
  181. })
  182. }
  183. /// Braille spinner frame or progress bar — llama-benchy's rich in-place
  184. /// progress redraws.
  185. fn is_spinner_frame(line: &str) -> bool {
  186. let t = line.trim_start();
  187. t.starts_with(['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']) || t.contains('━')
  188. }
  189. /// Progress-redraw identity: the spinner frames differ only by the glyph,
  190. /// the elapsed clock and the run/progress counters — same key = same phase
  191. /// redrawn in place.
  192. fn progress_key(line: &str) -> String {
  193. line.split_whitespace()
  194. .filter(|t| {
  195. // keep only wordish tokens (the test name): bars — the tip glyph
  196. // varies as the bar advances (━╸╺…) — spinner glyphs, @ signs and
  197. // other graphics carry no alphanumerics; counters (0/18), elapsed
  198. // clocks (0:01:48) and the "run N" pair are progress churn.
  199. t.chars().any(|c| c.is_alphanumeric())
  200. && !t.chars().all(|c| c.is_ascii_digit() || c == '/')
  201. && !t.contains(':')
  202. && *t != "run"
  203. })
  204. .collect::<Vec<_>>()
  205. .join(" ")
  206. }
  207. /// Make old pty-captured bench logs read like the new ones: drop pty
  208. /// control-char artifacts (`^D`/backspace echoes), collapse each spinner
  209. /// phase's redraw run down to its last frame, drop consecutive duplicate
  210. /// lines. Idempotent on logs that are already clean.
  211. pub(crate) fn clean_log(content: &str) -> String {
  212. let lines: Vec<String> = content
  213. .lines()
  214. .map(|l| {
  215. let l: String = l
  216. .chars()
  217. .filter(|c| !matches!(c, '\u{4}' | '\u{8}'))
  218. .collect();
  219. // the pty echoes control chars as caret notation (^D, ^H) —
  220. // printable '^' + @.._ pairs at the line start
  221. let mut l = l.as_str();
  222. while l.starts_with('^')
  223. && l[1..]
  224. .chars()
  225. .next()
  226. .is_some_and(|c| ('@'..='_').contains(&c))
  227. {
  228. l = &l[2..];
  229. }
  230. l.to_string()
  231. })
  232. .collect();
  233. let mut keep = vec![true; lines.len()];
  234. let mut i = 0;
  235. while i < lines.len() {
  236. let prog = is_spinner_frame(&lines[i]);
  237. let key = if prog {
  238. progress_key(&lines[i])
  239. } else {
  240. String::new()
  241. };
  242. let mut j = i + 1;
  243. while j < lines.len() {
  244. let same = if prog {
  245. is_spinner_frame(&lines[j]) && progress_key(&lines[j]) == key
  246. } else {
  247. !lines[i].is_empty() && lines[j] == lines[i]
  248. };
  249. if !same {
  250. break;
  251. }
  252. keep[j] = false;
  253. j += 1;
  254. }
  255. if j > i + 1 && prog {
  256. // a redraw run keeps only its final frame (exact dups keep the first)
  257. keep[i] = false;
  258. keep[j - 1] = true;
  259. }
  260. i = j;
  261. }
  262. lines
  263. .iter()
  264. .enumerate()
  265. .filter(|(k, _)| keep[*k])
  266. .map(|(_, l)| l.as_str())
  267. .collect::<Vec<_>>()
  268. .join("\n")
  269. }
  270. /// `GET /r/<recipe>/<file>` — file viewer page: rendered markdown (with a
  271. /// raw toggle), syntax-highlighted TOML/JSON, plain logs.
  272. pub(crate) const VIEW_TEMPLATE: &str = include_str!("view.html");
  273. async fn report_file(
  274. axum::extract::State(state): axum::extract::State<AppState>,
  275. axum::extract::Path((recipe, file)): Path<(String, String)>,
  276. ) -> Response {
  277. use axum::http::StatusCode;
  278. use axum::response::IntoResponse;
  279. match load_report(&state.config, &recipe, &file) {
  280. Some(text) => {
  281. // Strip ANSI escapes (old logs were saved raw; new ones are
  282. // already clean) and normalize line ends: the old pty-saved
  283. // logs carry CRLF (ONLCR) and the Live-soup ones bare \r —
  284. // CRLF first, then lone \r, so nothing doubles.
  285. let clean = crate::ansi::strip_ansi(text.as_bytes());
  286. let clean = String::from_utf8_lossy(&clean)
  287. .replace("\r\n", "\n")
  288. .replace('\r', "\n");
  289. // old pty-captured logs carry spinner-redraw soup and pty
  290. // control-char artifacts — render them like the clean ones
  291. let clean = if file.ends_with(".log") {
  292. clean_log(&clean)
  293. } else {
  294. clean
  295. };
  296. let links = if file == "recipe.toml" {
  297. super::repo::toml_links(&clean, &recipe, &state.base)
  298. } else {
  299. Vec::new()
  300. };
  301. let meta = serde_json::json!({ "recipe": recipe, "file": file, "content": clean, "links": links });
  302. let meta = serde_json::to_string(&meta)
  303. .unwrap_or_default()
  304. .replace('<', "\\u003c");
  305. axum::response::Html(VIEW_TEMPLATE.replace("{{META}}", &meta)).into_response()
  306. }
  307. None => (
  308. StatusCode::NOT_FOUND,
  309. axum::response::Html(
  310. "<!doctype html><meta charset=\"utf-8\"><title>404</title>report not found"
  311. .to_string(),
  312. ),
  313. )
  314. .into_response(),
  315. }
  316. }
  317. /// Build the report server router: static page at `/`, live SSE at
  318. /// `/events`, full report text at `/r/<recipe>/<file>`. `initial` is the
  319. /// rows JSON snapshot used to seed the watch channel (from the same
  320. /// `collect` that rendered the page, so the first paint and the first SSE
  321. /// message agree).
  322. pub(crate) fn router(
  323. config: Arc<Config>,
  324. page: String,
  325. initial: String,
  326. archived: bool,
  327. base: &str,
  328. ) -> Router {
  329. let (tx, rx) = tokio::sync::watch::channel(initial);
  330. tokio::spawn(refresh_loop(config.clone(), tx, KEEPALIVE, archived));
  331. Router::new()
  332. .route(
  333. "/",
  334. get(move || {
  335. let page = page.clone();
  336. async move { axum::response::Html(page) }
  337. }),
  338. )
  339. .route("/events", get(events_stream))
  340. .route("/r/{recipe}/{file}", get(report_file))
  341. .route("/repo/{*path}", get(super::repo::repo_route))
  342. .with_state(AppState {
  343. config,
  344. data: rx,
  345. base: base.to_string(),
  346. })
  347. }