Selaa lähdekoodia

report --serve: live updates over SSE — 5 s refresher re-scans reports/, pushes rows JSON on change; page re-renders keeping view state

Alexander Korolev 1 kuukausi sitten
vanhempi
sitoutus
c0409a5a0c
6 muutettua tiedostoa jossa 227 lisäystä ja 66 poistoa
  1. 1 1
      locales/en/main.ftl
  2. 1 1
      locales/ru/main.ftl
  3. 14 61
      src/report/mod.rs
  4. 26 1
      src/report/page.html
  5. 142 0
      src/report/serve.rs
  6. 43 2
      src/report/tests.rs

+ 1 - 1
locales/en/main.ftl

@@ -207,7 +207,7 @@ health-container-line = { $name }  { $status }  { $image }
 
 # sm121 report
 report-title = Tested recipes ({ $count })
-report-serving = Interactive recipe report: { $url }  (Ctrl-C to stop)
+report-serving = Interactive recipe report: { $url }  (live updates; Ctrl-C to stop)
 report-col-model = Model
 report-col-quant = Quant
 report-col-recipe = Recipe

+ 1 - 1
locales/ru/main.ftl

@@ -218,7 +218,7 @@ health-container-line = { $name }  { $status }  { $image }
 
 # sm121 report
 report-title = Тестируемые рецепты ({ $count })
-report-serving = Интерактивный отчёт по рецептам: { $url }  (Ctrl-C для остановки)
+report-serving = Интерактивный отчёт по рецептам: { $url }  (живые обновления; Ctrl-C для остановки)
 report-col-model = Модель
 report-col-quant = Квант
 report-col-recipe = Рецепт

+ 14 - 61
src/report/mod.rs

@@ -1,7 +1,10 @@
 //! `sm121 report` — one table of all tested recipes and their benchmark
 //! history. Default renders a Markdown table to stdout; `--serve` (implied
 //! by `--open`) serves an interactive HTML version (tree grouping, column
-//! visibility, sorting, search, pagination) on a local port.
+//! visibility, sorting, search, pagination) on a local port, with live
+//! updates: a background refresher re-scans the `reports/` dirs every 5 s
+//! and an SSE endpoint (`/events`) pushes the rows JSON on change, so a
+//! finishing bench appears in the page without a reload.
 //!
 //! Data sources are read-only and local: the recipe dirs from
 //! [`crate::config::recipe_dirs`] (each `<name>/recipe.toml`) plus each
@@ -15,6 +18,7 @@ use std::collections::HashSet;
 use std::path::Path;
 
 pub(crate) mod parse;
+pub(crate) mod serve;
 pub(crate) use parse::{PerfCell, parse_bench_json, perf_from};
 
 const PAGE_TEMPLATE: &str = include_str!("page.html");
@@ -393,56 +397,6 @@ fn t(key: &str) -> String {
     fluent_i18n::t!(key)
 }
 
-/// Load a report file for the `/r/<recipe>/<file>` route. Both path parts
-/// must be plain names (no traversal) — the route is loopback-only, but the
-/// resolve walks every recipe dir, so stay strict.
-fn load_report(config: &Config, recipe: &str, file: &str) -> Option<String> {
-    let plain = |s: &str| {
-        !s.contains("..")
-            && s.chars()
-                .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
-    };
-    if !plain(recipe) || !plain(file) {
-        return None;
-    }
-    crate::config::recipe_dirs(config)
-        .into_iter()
-        .find_map(|dir| {
-            let p = dir.join(recipe).join("reports").join(file);
-            p.is_file()
-                .then(|| std::fs::read_to_string(p).ok())
-                .flatten()
-        })
-}
-
-/// `GET /r/<recipe>/<file>` — full report text in a minimal pre page.
-async fn report_file(
-    axum::extract::State(state): axum::extract::State<std::sync::Arc<Config>>,
-    axum::extract::Path((recipe, file)): axum::extract::Path<(String, String)>,
-) -> axum::response::Response {
-    use axum::http::StatusCode;
-    use axum::response::IntoResponse;
-    match load_report(&state, &recipe, &file) {
-        Some(text) => axum::response::Html(format!(
-            "<!doctype html><meta charset=\"utf-8\"><title>{recipe} — {file}</title>\
-             <pre style=\"font:12px/1.45 ui-monospace,monospace;white-space:pre-wrap;\
-             word-break:break-word;margin:16px;\">{}</pre>",
-            text.replace('&', "&amp;")
-                .replace('<', "&lt;")
-                .replace('>', "&gt;")
-        ))
-        .into_response(),
-        None => (
-            StatusCode::NOT_FOUND,
-            axum::response::Html(
-                "<!doctype html><meta charset=\"utf-8\"><title>404</title>report not found"
-                    .to_string(),
-            ),
-        )
-            .into_response(),
-    }
-}
-
 /// The interactive page: template with `{{DATA}}` replaced by the JSON
 /// rows (`<` escaped so `</script>` in data can't break out).
 fn html_page(rows: &[ReportRow]) -> String {
@@ -452,6 +406,8 @@ fn html_page(rows: &[ReportRow]) -> String {
 
 /// Entry point: Markdown to stdout, or the interactive page on
 /// `http://127.0.0.1:<port>/` (`--open` also spawns the browser).
+/// The page is live: `/events` (SSE) pushes the rows JSON whenever the
+/// refresher detects a change in the `reports/` dirs.
 pub async fn run(config: &Config, serve: bool, port: u16, open: bool) -> Result<()> {
     let rows = collect(config);
     if !serve {
@@ -459,18 +415,15 @@ pub async fn run(config: &Config, serve: bool, port: u16, open: bool) -> Result<
         return Ok(());
     }
     let page = html_page(&rows);
+    let initial = serde_json::to_string(&rows).unwrap_or_default();
     let listener = tokio::net::TcpListener::bind(("127.0.0.1", port)).await?;
     let addr = listener.local_addr()?;
-    let app = axum::Router::new()
-        .route(
-            "/",
-            axum::routing::get(move || {
-                let page = page.clone();
-                async move { axum::response::Html(page) }
-            }),
-        )
-        .route("/r/{recipe}/{file}", axum::routing::get(report_file))
-        .with_state(std::sync::Arc::new(config.clone()));
+    let app = serve::router(
+        std::sync::Arc::new(config.clone()),
+        page,
+        initial,
+        serve::REFRESH_TICK,
+    );
     if open {
         let url = format!("http://{addr}/");
         tokio::spawn(async move {

+ 26 - 1
src/report/page.html

@@ -25,6 +25,8 @@
   tr.row { cursor: pointer; }
   td.num { text-align: right; white-space: nowrap; }
   #count { color: gray; }
+  #live { color: #2a9d2a; font-size: 11px; white-space: nowrap; }
+  #live.dim { color: gray; }
   .dim { color: gray; }
 </style>
 </head>
@@ -49,11 +51,12 @@
     <select id="size"><option>25</option><option selected>50</option><option>100</option><option value="0">All</option></select>
   </label>
   <button id="prev">‹</button><span id="count"></span><button id="next">›</button>
+  <span id="live" class="dim" title="live updates">● off</span>
 </div>
 <table id="tbl"><thead></thead><tbody></tbody></table>
 <script>
 "use strict";
-const DATA = {{DATA}};
+let DATA = {{DATA}};
 const COLS = [
   ["name", "Recipe"], ["title", "Title"], ["model", "Model"], ["quant", "Quant"],
   ["engine", "Engine"], ["image", "Image"], ["nodes", "Nodes"], ["tp", "TP"],
@@ -79,6 +82,28 @@ function cell(r, k) {
 
 // deep-link state: the full view (filter, grouping, sort, columns, paging,
 // collapsed groups) lives in the URL — survives any reload and is shareable
+// LIVE: the server re-scans the reports/ dirs on a tick and pushes the rows
+// JSON over SSE whenever something changed (new bench, finishing run, new
+// report file). The view state (filter, sort, paging, columns, collapsed
+// groups) is untouched — render() re-derives everything from DATA.
+const liveEl = document.getElementById("live");
+function syncTp() {
+  const vals = [...new Set(DATA.map(r => r.tp))].sort((a, b) => a - b);
+  const sel = document.getElementById("tp");
+  if (vals.map(String).join(",") !== [...sel.options].map(o => o.value).join(","))
+    sel.innerHTML = '<option value="">All</option>' + vals.map(v => `<option>${v}</option>`).join("");
+  if (S.tp && ![...sel.options].some(o => o.value === S.tp)) S.tp = "";
+  sel.value = S.tp;
+}
+const es = new EventSource("/events");
+es.addEventListener("open", () => { liveEl.textContent = "● live"; liveEl.classList.remove("dim"); });
+es.addEventListener("error", () => { liveEl.textContent = "● off"; liveEl.classList.add("dim"); });
+es.onmessage = ev => {
+  let rows; try { rows = JSON.parse(ev.data); } catch { return; }
+  if (JSON.stringify(rows) === JSON.stringify(DATA)) return;
+  DATA = rows; syncTp(); render();
+  liveEl.title = "updated " + new Date().toLocaleTimeString();
+};
 const P = new URLSearchParams(location.search);
 const size0 = +(P.get("size") ?? 50);
 // distinct TP values from the data, for the exact-TP filter

+ 142 - 0
src/report/serve.rs

@@ -0,0 +1,142 @@
+//! Live-update plumbing for `report --serve`: a background refresher that
+//! re-scans the recipe `reports/` dirs on a tick, and an SSE endpoint
+//! (`/events`) that pushes the rows JSON to the page whenever anything
+//! changed (a new report file, a bench finishing, a log growing).
+//!
+//! One refresher serves all clients: it publishes the rows JSON on a `watch`
+//! channel only when the serialization differs from the last publish, and
+//! each SSE connection forwards its own `watch::Receiver`.
+
+use crate::config::Config;
+use crate::report::collect;
+use axum::{
+    Router,
+    extract::Path,
+    response::{Response, Sse, sse::Event as SseEvent},
+    routing::get,
+};
+use futures::{Stream, StreamExt};
+use std::convert::Infallible;
+use std::sync::Arc;
+use std::time::Duration;
+
+/// How often the refresher re-scans the report dirs. The scan is a local
+/// dir walk + small-file reads (a few dozen ms); 5 s keeps a finishing bench
+/// visible in the page within one poll while staying negligible I/O.
+pub(crate) const REFRESH_TICK: Duration = Duration::from_secs(5);
+
+/// Shared server state: the config (for `/r/...` file loads) plus the
+/// latest rows JSON (each SSE connection clones the receiver).
+#[derive(Clone)]
+pub(crate) struct AppState {
+    pub(crate) config: Arc<Config>,
+    pub(crate) data: tokio::sync::watch::Receiver<String>,
+}
+
+/// Background refresher: collect the rows, publish the JSON when it changed,
+/// sleep, repeat. The first collect runs immediately so clients see current
+/// data without waiting a tick. Runs on the tokio runtime; the scan is fast
+/// local disk I/O.
+pub(crate) async fn refresh_loop(
+    cfg: Arc<Config>,
+    tx: tokio::sync::watch::Sender<String>,
+    tick: Duration,
+) {
+    let mut last = String::new();
+    loop {
+        let json = serde_json::to_string(&collect(&cfg)).unwrap_or_default();
+        if json != last {
+            last = json;
+            let _ = tx.send(last.clone());
+        }
+        tokio::time::sleep(tick).await;
+    }
+}
+
+/// `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>>> {
+    let mut rx = s.data;
+    let first = rx.borrow_and_update().clone();
+    let first = futures::stream::once(async move { Ok(SseEvent::default().data(first)) });
+    let rest = futures::stream::unfold(rx, |mut rx| async move {
+        if rx.changed().await.is_err() {
+            return None;
+        }
+        let json = rx.borrow_and_update().clone();
+        Some((Ok(SseEvent::default().data(json)), rx))
+    });
+    Sse::new(first.chain(rest))
+}
+
+/// `GET /r/<recipe>/<file>` — full report text in a minimal pre page.
+/// Both path parts must be plain names (no traversal); the resolve walks
+/// every recipe dir, so stay strict.
+pub(crate) fn load_report(config: &Config, recipe: &str, file: &str) -> Option<String> {
+    let plain = |s: &str| {
+        !s.contains("..")
+            && s.chars()
+                .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
+    };
+    if !plain(recipe) || !plain(file) {
+        return None;
+    }
+    crate::config::recipe_dirs(config)
+        .into_iter()
+        .find_map(|dir| {
+            let p = dir.join(recipe).join("reports").join(file);
+            p.is_file()
+                .then(|| std::fs::read_to_string(p).ok())
+                .flatten()
+        })
+}
+
+async fn report_file(
+    axum::extract::State(state): axum::extract::State<AppState>,
+    axum::extract::Path((recipe, file)): Path<(String, String)>,
+) -> Response {
+    use axum::http::StatusCode;
+    use axum::response::IntoResponse;
+    match load_report(&state.config, &recipe, &file) {
+        Some(text) => axum::response::Html(format!(
+            "<!doctype html><meta charset=\"utf-8\"><title>{recipe} — {file}</title>\
+             <pre style=\"font:12px/1.45 ui-monospace,monospace;white-space:pre-wrap;\
+             word-break:break-word;margin:16px;\">{}</pre>",
+            text.replace('&', "&amp;")
+                .replace('<', "&lt;")
+                .replace('>', "&gt;")
+        ))
+        .into_response(),
+        None => (
+            StatusCode::NOT_FOUND,
+            axum::response::Html(
+                "<!doctype html><meta charset=\"utf-8\"><title>404</title>report not found"
+                    .to_string(),
+            ),
+        )
+            .into_response(),
+    }
+}
+
+/// Build the report server router: static page at `/`, live SSE at
+/// `/events`, full report text at `/r/<recipe>/<file>`. `initial` is the
+/// rows JSON snapshot used to seed the watch channel (from the same
+/// `collect` that rendered the page, so the first paint and the first SSE
+/// message agree).
+pub(crate) fn router(config: Arc<Config>, page: String, initial: String, tick: Duration) -> Router {
+    let (tx, rx) = tokio::sync::watch::channel(initial);
+    tokio::spawn(refresh_loop(config.clone(), tx, tick));
+    Router::new()
+        .route(
+            "/",
+            get(move || {
+                let page = page.clone();
+                async move { axum::response::Html(page) }
+            }),
+        )
+        .route("/events", get(events_stream))
+        .route("/r/{recipe}/{file}", get(report_file))
+        .with_state(AppState { config, data: rx })
+}

+ 43 - 2
src/report/tests.rs

@@ -1,6 +1,7 @@
+use super::serve::load_report;
 use super::{
-    ReportRow, collect, detect_quant, extract_time, fmt_pct, html_page, load_report, markdown,
-    parse_score, slash_pair,
+    ReportRow, collect, detect_quant, extract_time, fmt_pct, html_page, markdown, parse_score,
+    slash_pair,
 };
 use crate::config::Config;
 use std::fs;
@@ -354,3 +355,43 @@ fn report_keys_fall_back_outside_en() {
     assert!(s.contains('3'), "fallback broken, got: {s}");
     assert_ne!(s, "report-title", "missing fluent fallback, got: {s}");
 }
+
+#[tokio::test]
+async fn refresher_publishes_on_change_only() {
+    use super::serve::refresh_loop;
+    use std::time::Duration;
+    let dir = fixture();
+    let config: Config =
+        toml::from_str(&format!("recipe_sources = [\"{}\"]", dir.to_str().unwrap())).unwrap();
+    let cfg = std::sync::Arc::new(config);
+    let (tx, mut rx) = tokio::sync::watch::channel(String::new());
+    let h = tokio::spawn(refresh_loop(cfg, tx, Duration::from_millis(20)));
+    // the first collect runs immediately: the fixture's report is visible
+    tokio::time::timeout(
+        Duration::from_secs(3),
+        rx.wait_for(|j| j.contains("demo-run")),
+    )
+    .await
+    .expect("initial collect not published")
+    .expect("channel closed");
+    // a new report file appears → the next tick publishes the fresh JSON
+    fs::write(
+        dir.join("test-recipe-nvfp4/reports/2026-01-05_live-update.md"),
+        "# lu\n## Score: 159/176 (90.4)\n",
+    )
+    .unwrap();
+    tokio::time::timeout(
+        Duration::from_secs(3),
+        rx.wait_for(|j| j.contains("live-update")),
+    )
+    .await
+    .expect("change not published within 3 s")
+    .expect("channel closed");
+    // stability: with no further changes the JSON stays put (no re-publish
+    // storm on a quiet tree — the SSE side only wakes on watch changes)
+    let stable = rx.borrow().clone();
+    tokio::time::sleep(Duration::from_millis(150)).await;
+    assert_eq!(*rx.borrow(), stable, "unchanged tree must not re-publish");
+    h.abort();
+    fs::remove_dir_all(&dir).ok();
+}