serve.rs 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211
  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::convert::Infallible;
  29. use std::sync::Arc;
  30. use std::time::Duration;
  31. /// Safety-net rescan interval: covers missed watcher notifications (a
  32. /// legacy `.log`-only report whose score lands in a Modify the filter
  33. /// ignores, a source dir created after startup). The normal path is the
  34. /// event-driven one, so this can stay slow.
  35. pub(crate) const KEEPALIVE: Duration = Duration::from_secs(30);
  36. /// Wait after a filesystem event before re-scanning, so a write burst
  37. /// (the launcher copying a report triplet) collapses into one collect.
  38. const DEBOUNCE: Duration = Duration::from_millis(500);
  39. /// Shared server state: the config (for `/r/...` file loads) plus the
  40. /// latest rows JSON (each SSE connection clones the receiver).
  41. #[derive(Clone)]
  42. pub(crate) struct AppState {
  43. pub(crate) config: Arc<Config>,
  44. pub(crate) data: tokio::sync::watch::Receiver<String>,
  45. }
  46. /// True when the event touches a report-relevant path: anything under a
  47. /// `reports/` directory, or a `recipe.toml` (row fields come from it).
  48. /// Everything else (build churn, git, the rest of the repo) is ignored so
  49. /// the watcher can watch a wide tree cheaply.
  50. fn is_relevant(ev: &notify::Event) -> bool {
  51. ev.paths.iter().any(|p| {
  52. p.ancestors()
  53. .any(|a| a.file_name().is_some_and(|n| n == "reports"))
  54. || p.file_name().is_some_and(|n| n == "recipe.toml")
  55. })
  56. }
  57. /// Background refresher: watch the recipe dirs, re-collect after a debounced
  58. /// filesystem event (or the keepalive safety net), publish the rows JSON on
  59. /// the watch channel only when it changed. The first collect runs
  60. /// immediately so clients see current data without waiting. If the watcher
  61. /// cannot be created (unsupported FS), the keepalive alone keeps the data
  62. /// fresh.
  63. pub(crate) async fn refresh_loop(
  64. cfg: Arc<Config>,
  65. tx: tokio::sync::watch::Sender<String>,
  66. keepalive: Duration,
  67. ) {
  68. let (ev_tx, mut ev_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
  69. let watcher = notify::recommended_watcher(move |res: Result<notify::Event, notify::Error>| {
  70. if let Ok(ev) = res
  71. && is_relevant(&ev)
  72. {
  73. let _ = ev_tx.send(());
  74. }
  75. });
  76. let mut watcher = watcher.ok();
  77. if let Some(w) = &mut watcher {
  78. for dir in crate::config::recipe_dirs(&cfg) {
  79. if dir.is_dir() {
  80. let _ = w.watch(&dir, notify::RecursiveMode::Recursive);
  81. }
  82. }
  83. }
  84. let mut last = String::new();
  85. loop {
  86. tokio::select! {
  87. _ = ev_rx.recv() => {}
  88. _ = tokio::time::sleep(keepalive) => {}
  89. }
  90. // debounce: let the burst settle, then drain the remaining events
  91. tokio::time::sleep(DEBOUNCE).await;
  92. while ev_rx.try_recv().is_ok() {}
  93. let json = serde_json::to_string(&collect(&cfg)).unwrap_or_default();
  94. if json != last {
  95. last = json;
  96. let _ = tx.send(last.clone());
  97. }
  98. }
  99. }
  100. /// `GET /events` — the latest rows JSON on connect, then one message per
  101. /// refresher update. The stream ends when the refresher (the sender) drops.
  102. async fn events_stream(
  103. axum::extract::State(s): axum::extract::State<AppState>,
  104. ) -> Sse<impl Stream<Item = Result<SseEvent, Infallible>>> {
  105. let mut rx = s.data;
  106. let first = rx.borrow_and_update().clone();
  107. let first = futures::stream::once(async move { Ok(SseEvent::default().data(first)) });
  108. let rest = futures::stream::unfold(rx, |mut rx| async move {
  109. if rx.changed().await.is_err() {
  110. return None;
  111. }
  112. let json = rx.borrow_and_update().clone();
  113. Some((Ok(SseEvent::default().data(json)), rx))
  114. });
  115. Sse::new(first.chain(rest))
  116. }
  117. /// `GET /r/<recipe>/<file>` — full file text (report, log, json, or the
  118. /// recipe's own `recipe.toml`) served through the viewer page. Both path
  119. /// parts must be plain names (no traversal); the resolve walks every
  120. /// recipe dir, so stay strict.
  121. pub(crate) fn load_report(config: &Config, recipe: &str, file: &str) -> Option<String> {
  122. let plain = |s: &str| {
  123. !s.contains("..")
  124. && s.chars()
  125. .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
  126. };
  127. if !plain(recipe) || !plain(file) {
  128. return None;
  129. }
  130. crate::config::recipe_dirs(config)
  131. .into_iter()
  132. .find_map(|dir| {
  133. let base = dir.join(recipe);
  134. // "recipe.toml" resolves from the recipe dir root, everything
  135. // else from its reports/ subdir
  136. let p = if file == "recipe.toml" {
  137. base.join("recipe.toml")
  138. } else {
  139. base.join("reports").join(file)
  140. };
  141. p.is_file()
  142. .then(|| std::fs::read_to_string(p).ok())
  143. .flatten()
  144. })
  145. }
  146. /// `GET /r/<recipe>/<file>` — file viewer page: rendered markdown (with a
  147. /// raw toggle), syntax-highlighted TOML/JSON, plain logs.
  148. const VIEW_TEMPLATE: &str = include_str!("view.html");
  149. async fn report_file(
  150. axum::extract::State(state): axum::extract::State<AppState>,
  151. axum::extract::Path((recipe, file)): Path<(String, String)>,
  152. ) -> Response {
  153. use axum::http::StatusCode;
  154. use axum::response::IntoResponse;
  155. match load_report(&state.config, &recipe, &file) {
  156. Some(text) => {
  157. // Strip ANSI escapes (old logs were saved raw; new ones are
  158. // already clean) and normalize line ends: the old pty-saved
  159. // logs carry CRLF (ONLCR) and the Live-soup ones bare \r —
  160. // CRLF first, then lone \r, so nothing doubles.
  161. let clean = crate::ansi::strip_ansi(text.as_bytes());
  162. let clean = String::from_utf8_lossy(&clean)
  163. .replace("\r\n", "\n")
  164. .replace('\r', "\n");
  165. let meta = serde_json::json!({ "recipe": recipe, "file": file, "content": clean });
  166. let meta = serde_json::to_string(&meta)
  167. .unwrap_or_default()
  168. .replace('<', "\\u003c");
  169. axum::response::Html(VIEW_TEMPLATE.replace("{{META}}", &meta)).into_response()
  170. }
  171. None => (
  172. StatusCode::NOT_FOUND,
  173. axum::response::Html(
  174. "<!doctype html><meta charset=\"utf-8\"><title>404</title>report not found"
  175. .to_string(),
  176. ),
  177. )
  178. .into_response(),
  179. }
  180. }
  181. /// Build the report server router: static page at `/`, live SSE at
  182. /// `/events`, full report text at `/r/<recipe>/<file>`. `initial` is the
  183. /// rows JSON snapshot used to seed the watch channel (from the same
  184. /// `collect` that rendered the page, so the first paint and the first SSE
  185. /// message agree).
  186. pub(crate) fn router(config: Arc<Config>, page: String, initial: String) -> Router {
  187. let (tx, rx) = tokio::sync::watch::channel(initial);
  188. tokio::spawn(refresh_loop(config.clone(), tx, KEEPALIVE));
  189. Router::new()
  190. .route(
  191. "/",
  192. get(move || {
  193. let page = page.clone();
  194. async move { axum::response::Html(page) }
  195. }),
  196. )
  197. .route("/events", get(events_stream))
  198. .route("/r/{recipe}/{file}", get(report_file))
  199. .with_state(AppState { config, data: rx })
  200. }