Selaa lähdekoodia

report page: update bell — SSE diff of rows snapshots, badge count, dropdown panel with per-update links, click-through to the row; node probe + playwright click-through tests

Alexander Korolev 1 kuukausi sitten
vanhempi
sitoutus
0b41469beb
5 muutettua tiedostoa jossa 351 lisäystä ja 5 poistoa
  1. 3 0
      src/report/mod.rs
  2. 111 4
      src/report/page.html
  3. 91 0
      src/report/report.playwright.mjs
  4. 1 1
      src/report/tests.rs
  5. 145 0
      src/report/ui_tests.rs

+ 3 - 0
src/report/mod.rs

@@ -441,3 +441,6 @@ pub async fn run(config: &Config, serve: bool, port: u16, open: bool) -> Result<
 
 #[cfg(test)]
 mod tests;
+
+#[cfg(test)]
+mod ui_tests;

+ 111 - 4
src/report/page.html

@@ -27,6 +27,19 @@
   #count { color: gray; }
   #live { color: #2a9d2a; font-size: 11px; white-space: nowrap; }
   #live.dim { color: gray; }
+  #bell { position: relative; padding: 3px 8px; cursor: pointer; background: none; border: 1px solid color-mix(in srgb, currentColor 25%, transparent); border-radius: 4px; }
+  #bell svg { vertical-align: -3px; }
+  #bellcount { position: absolute; top: -7px; right: -7px; min-width: 17px; height: 17px; line-height: 17px; text-align: center;
+               font-size: 10px; font-weight: 700; color: #fff; background: #c0392b; border-radius: 9px; padding: 0 3px; }
+  #notifbox { position: absolute; z-index: 6; right: 0; top: 100%; width: min(460px, 90vw); max-height: 60vh; overflow: auto;
+              background: canvas; border: 1px solid color-mix(in srgb, currentColor 25%, transparent); border-radius: 5px; padding: 6px; }
+  #bar { position: relative; }
+  .noti { display: flex; gap: 8px; padding: 5px 6px; border-bottom: 1px solid color-mix(in srgb, currentColor 12%, transparent); cursor: pointer; }
+  .noti:hover { background: color-mix(in srgb, currentColor 6%, canvas); }
+  .noti:last-child { border-bottom: none; }
+  .nic { font-weight: 700; }
+  @keyframes ring { 0% { transform: rotate(0) } 20% { transform: rotate(13deg) } 45% { transform: rotate(-11deg) } 70% { transform: rotate(6deg) } 100% { transform: rotate(0) } }
+  #bell.ring svg { animation: ring 0.6s ease; transform-origin: 50% 0; }
   .dim { color: gray; }
 </style>
 </head>
@@ -52,6 +65,11 @@
   </label>
   <button id="prev">‹</button><span id="count"></span><button id="next">›</button>
   <span id="live" class="dim" title="live updates">● off</span>
+  <button id="bell" title="updates" aria-label="updates">
+    <svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/></svg>
+    <span id="bellcount" style="display:none"></span>
+  </button>
+  <div id="notifbox" style="display:none"></div>
 </div>
 <table id="tbl"><thead></thead><tbody></tbody></table>
 <script>
@@ -82,10 +100,44 @@ 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.
+// Update notifications: pure diff between two rows snapshots → the list of
+// what changed (new bench, new/removed recipe, changed entry or row config).
+// Newest first. Probed by the node test, extracted between the markers.
+/*NOTIFDIFF:START*/
+function notifDiff(prev, next) {
+  const out = [];
+  const byName = new Map(prev.map(r => [r.name, r]));
+  const meta = r => JSON.stringify({ ...r, reports: [] });
+  const item = (t, r, e) => ({
+    t, name: r.name,
+    label: e ? e.label : (t === "recipe" ? (r.model || r.title) : "recipe config"),
+    date: e ? e.date : "", time: e ? e.time : null,
+    score: e ? e.pct : null, points: e ? e.points : null,
+    files: e ? (e.files || [e.file]) : [],
+  });
+  for (const r of next) {
+    const old = byName.get(r.name);
+    if (!old) { out.push(item("recipe", r, null)); continue; }
+    if (meta(old) !== meta(r)) out.push(item("config", r, null));
+    const oldR = new Map(old.reports.map(e => [e.date + "\u0000" + e.label, e]));
+    for (const e of r.reports) {
+      const oe = oldR.get(e.date + "\u0000" + e.label);
+      if (!oe) out.push(item("bench", r, e));
+      else if (JSON.stringify(oe) !== JSON.stringify(e)) out.push(item("change", r, e));
+    }
+  }
+  const nextNames = new Set(next.map(r => r.name));
+  for (const r of prev)
+    if (!nextNames.has(r.name)) out.push(item("removed", r, null));
+  return out.reverse();
+}
+/*NOTIFDIFF:END*/
+
+// LIVE: the server re-scans the reports/ dirs on file-system events 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);
@@ -95,15 +147,70 @@ function syncTp() {
   if (S.tp && ![...sel.options].some(o => o.value === S.tp)) S.tp = "";
   sel.value = S.tp;
 }
+
+// Bell + notifications: UNREAD counts updates since the panel was last
+// opened; NOTIFS (capped) is the list the panel shows. Clicking an item
+// expands the matching row in the table.
+const bellEl = document.getElementById("bell");
+const notifBox = document.getElementById("notifbox");
+let UNREAD = 0;
+let NOTIFS = [];
+const NICON = { bench: "+", change: "↻", recipe: "★", config: "✎", removed: "−" };
+function renderBell() {
+  const c = document.getElementById("bellcount");
+  c.textContent = String(UNREAD);
+  c.style.display = UNREAD ? "" : "none";
+}
+function renderNotifs() {
+  if (!NOTIFS.length) {
+    notifBox.innerHTML = '<div class="dim" style="padding:8px">no updates yet</div>';
+    return;
+  }
+  notifBox.innerHTML = NOTIFS.map(n => {
+    const when = n.date ? `${n.date}${n.time ? "·" + n.time : ""}` : "";
+    const score = n.score != null ? `${n.score} /100` : n.points || "";
+    const links = n.files.map(f =>
+      `<a class="open" target="_blank" href="/r/${encodeURIComponent(n.name)}/${encodeURIComponent(f)}" title="open ${esc(f)}">${esc(f.split(".").pop())}</a>`).join(" ");
+    return `<div class="noti" data-name="${esc(n.name)}" title="show in table">
+      <span class="nic">${NICON[n.t] || "•"}</span>
+      <div><b>${esc(n.name)}</b> <span class="dim">${esc(n.label)}</span><br>
+      <span class="dim">${esc(when)}</span> ${score ? esc(score) : ""} ${links}</div>
+    </div>`;
+  }).join("");
+}
+bellEl.addEventListener("click", e => {
+  e.stopPropagation();
+  const open = notifBox.style.display === "block";
+  notifBox.style.display = open ? "none" : "block";
+  if (!open) { UNREAD = 0; renderBell(); }
+});
+document.addEventListener("click", () => { notifBox.style.display = "none"; });
+notifBox.addEventListener("click", e => {
+  const it = e.target.closest(".noti");
+  if (!it) return;
+  S.openRow = it.dataset.name;
+  notifBox.style.display = "none";
+  render();
+});
+
 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;
+  const upd = notifDiff(DATA, rows);
   DATA = rows; syncTp(); render();
   liveEl.title = "updated " + new Date().toLocaleTimeString();
+  if (upd.length) {
+    NOTIFS.unshift(...upd);
+    if (NOTIFS.length > 100) NOTIFS.length = 100;
+    UNREAD += upd.length;
+    renderBell(); renderNotifs();
+    bellEl.classList.remove("ring"); void bellEl.offsetWidth; bellEl.classList.add("ring");
+  }
 };
+renderBell(); renderNotifs();
 const P = new URLSearchParams(location.search);
 const size0 = +(P.get("size") ?? 50);
 // distinct TP values from the data, for the exact-TP filter

+ 91 - 0
src/report/report.playwright.mjs

@@ -0,0 +1,91 @@
+// Playwright checks for the interactive recipe report page (src/report/
+// page.html). Runs against a live `sm121 report --serve`-style router
+// (base URL + fixture reports dir in env, provided by the Rust test
+// `report_playwright_checks` in src/report/tests.rs).
+//
+// No npm: uses deno's npm-package specifiers. One-time browser setup:
+//   deno run -A src/web/pw-install.mjs
+//
+// Exits 0 with "SKIP" lines when the browser is missing; non-zero on
+// real failures.
+
+import { chromium } from "npm:playwright@1.62.1";
+
+const BASE = Deno.env.get("SM121_REPORT_URL");
+const FIX = Deno.env.get("SM121_REPORT_FIXTURE");
+if (!BASE || !FIX) {
+  console.log("SKIP env not set (run via cargo test report_playwright_checks)");
+  Deno.exit(0);
+}
+
+let failed = 0;
+const ok = (name, cond, extra = "") => {
+  if (!cond) failed++;
+  console.log((cond ? "ok   " : "FAIL ") + name + (!cond && extra ? " — " + extra : ""));
+};
+
+let browser;
+try {
+  browser = await chromium.launch();
+} catch (e) {
+  console.log("SKIP playwright browser not installed — run: deno run -A src/web/pw-install.mjs");
+  console.log(String(e).split("\n").find((l) => l.trim()) || "");
+  Deno.exit(0);
+}
+
+const page = await (await browser.newContext()).newPage();
+const pageErrors = [];
+page.on("pageerror", (e) => pageErrors.push(String(e)));
+
+await page.goto(BASE, { waitUntil: "domcontentloaded" });
+
+// the page must come up live (SSE connected, refresher publishing)
+await page.waitForFunction(
+  () => document.getElementById("live").textContent === "● live",
+  null,
+  { timeout: 10000 },
+);
+ok("sse connected (● live)", true);
+
+// empty bell: opens the panel, nothing yet
+await page.click("#bell");
+await page.waitForSelector("#notifbox", { state: "visible" });
+ok("bell opens the panel", await page.isVisible("#notifbox"));
+ok("empty panel says so", (await page.textContent("#notifbox")).includes("no updates yet"));
+await page.click("#bell"); // close again
+
+// trigger a real update: drop a new bench report into the watched dir
+const label = "pw-" + Date.now();
+await Deno.writeFile(
+  `${FIX}/2026-09-09_${label}.md`,
+  new TextEncoder().encode(`# pw\n## Score: 159/176 (90.4)\n`),
+);
+await page.waitForFunction(
+  () => {
+    const c = document.getElementById("bellcount");
+    return c.style.display !== "none" && +c.textContent >= 1;
+  },
+  null,
+  { timeout: 15000 },
+);
+ok("badge appeared after the new report", true);
+
+await page.click("#bell");
+await page.waitForFunction(
+  () => document.querySelector("#notifbox .noti"),
+  null,
+  { timeout: 5000 },
+);
+ok(
+  "panel lists the new bench",
+  (await page.textContent("#notifbox")).includes(label),
+);
+ok(
+  "badge reset after opening",
+  await page.evaluate(() => document.getElementById("bellcount").style.display === "none"),
+);
+
+ok("no page errors", pageErrors.length === 0, pageErrors.join(" | "));
+
+await browser.close();
+Deno.exit(failed ? 1 : 0);

+ 1 - 1
src/report/tests.rs

@@ -74,7 +74,7 @@ fn detect_quant_prefers_longest_token() {
     assert_eq!(detect_quant("deepseek-v4-flash-dspark38", "m"), "—");
 }
 
-fn fixture() -> PathBuf {
+pub(crate) fn fixture() -> PathBuf {
     use std::sync::atomic::{AtomicU32, Ordering};
     static N: AtomicU32 = AtomicU32::new(0);
     // unique per call — tests run in parallel and clean up after themselves

+ 145 - 0
src/report/ui_tests.rs

@@ -0,0 +1,145 @@
+// Report page UI tests: the live-update bell (SSE → diff → badge → panel).
+// Split from tests.rs at the 500-line cap (same split as web: tests + ui);
+// shared helpers (fixture) live in tests.rs.
+
+use super::tests::fixture;
+use super::{collect, html_page, serve};
+use crate::config::Config;
+use std::fs;
+
+#[test]
+fn page_has_bell_and_notif_panel() {
+    let page = html_page(&[]);
+    for needle in [
+        "id=\"bell\"",
+        "id=\"bellcount\"",
+        "id=\"notifbox\"",
+        "notifDiff(DATA, rows)",
+        "new EventSource(\"/events\")",
+        "NOTIFDIFF:START",
+    ] {
+        assert!(page.contains(needle), "page missing {needle}");
+    }
+}
+
+/// The `notifDiff` JS function (extracted between markers from the live
+/// page) must classify every update kind and order newest first — probed
+/// under node, like the web dashboard's UI probes.
+#[test]
+fn page_notif_diff_js() {
+    if std::process::Command::new("node")
+        .arg("--version")
+        .output()
+        .is_err()
+    {
+        eprintln!("SKIP page_notif_diff_js: node not available");
+        return;
+    }
+    let page = html_page(&[]);
+    let s = page.find("/*NOTIFDIFF:START*/").unwrap();
+    let e = page.find("/*NOTIFDIFF:END*/").unwrap();
+    let fnjs = &page[s + "/*NOTIFDIFF:START*/".len()..e];
+    let probe = format!(
+        "{fn}\n\
+         const rep = (n, l, date, pct) => ({{ date: date, time: null, label: l, \
+           file: date + \"_\" + l + \".md\", files: [date + \"_\" + l + \".md\"], \
+           pct: pct, points: null, ratio: null, perf: [] }});
+         const row = (n, model, image, reports) => ({{ name: n, title: n, model: model, \
+           quant: \"NVFP4\", engine: \"vllm\", image: image, nodes: 1, tp: 1, \
+           deploy: \"docker\", port: 30000, reports: reports }});
+         const a0 = row(\"r-a\", \"m/A\", \"img:1\", [rep(\"r-a\", \"old\", \"2026-01-01\", 90.0)]);
+         // 1: identical snapshots → empty
+         if (JSON.stringify(notifDiff([a0], [a0])) !== \"[]\")
+           throw new Error(\"identical must diff empty: \" + JSON.stringify(notifDiff([a0], [a0])));
+         // 2: image change + new bench + new recipe
+         const a1 = row(\"r-a\", \"m/A\", \"img:2\", [rep(\"r-a\", \"old\", \"2026-01-01\", 90.0), rep(\"r-a\", \"new\", \"2026-01-02\", 92.0)]);
+         const b0 = row(\"r-b\", \"m/B\", \"img:3\", []);
+         const d = notifDiff([a0], [a1, b0]);
+         if (d.map(x => x.t).sort().join(\",\") !== \"bench,config,recipe\")
+           throw new Error(\"kinds: \" + JSON.stringify(d));
+         const bi = d.find(x => x.t === \"bench\");
+         if (bi.name !== \"r-a\" || bi.label !== \"new\" || bi.score !== 92.0)
+           throw new Error(\"bench item: \" + JSON.stringify(bi));
+         if (bi.files[0] !== \"2026-01-02_new.md\") throw new Error(\"bench files: \" + JSON.stringify(bi));
+         if (d.find(x => x.t === \"config\").name !== \"r-a\") throw new Error(\"config item\");
+         const ri = d.find(x => x.t === \"recipe\");
+         if (ri.name !== \"r-b\" || ri.label !== \"m/B\") throw new Error(\"recipe item: \" + JSON.stringify(ri));
+         // 3: entry value change → change
+         const a2 = row(\"r-a\", \"m/A\", \"img:1\", [rep(\"r-a\", \"old\", \"2026-01-01\", 95.0)]);
+         const d2 = notifDiff([a0], [a2]);
+         if (d2.length !== 1 || d2[0].t !== \"change\")
+           throw new Error(\"expected one change: \" + JSON.stringify(d2));
+         // 4: dropped row → removed
+         const d3 = notifDiff([a1, b0], [a1]);
+         if (d3.length !== 1 || d3[0].t !== \"removed\" || d3[0].name !== \"r-b\")
+           throw new Error(\"expected removed r-b: \" + JSON.stringify(d3));
+         // 5: newest first — the new bench must precede the config note
+         if (d.findIndex(x => x.t === \"bench\") > d.findIndex(x => x.t === \"config\"))
+           throw new Error(\"newest-first violated\");
+         console.log(\"ok\");", fn = fnjs
+    );
+    let js = std::env::temp_dir().join(format!("sm121-report-notif-{}.js", std::process::id()));
+    fs::write(&js, &probe).unwrap();
+    let out = std::process::Command::new("node")
+        .arg(&js)
+        .output()
+        .unwrap();
+    let _ = fs::remove_file(&js);
+    assert!(
+        out.status.success(),
+        "notifDiff probe failed: {}",
+        String::from_utf8_lossy(&out.stderr)
+    );
+}
+
+/// Click-through checks for the live-update bell: boots the real report
+/// router on an ephemeral port (fixture recipe dir), drives page.html with
+/// Playwright (deno, npm-free), drops a new report file into the watched
+/// dir and expects the badge + panel to react. Skipped without deno/browser.
+#[tokio::test]
+async fn report_playwright_checks() {
+    if std::process::Command::new("deno")
+        .arg("--version")
+        .output()
+        .is_err()
+    {
+        eprintln!("SKIP report_playwright_checks: deno not available");
+        return;
+    }
+    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 rows = collect(&cfg);
+    let page = html_page(&rows);
+    let initial = serde_json::to_string(&rows).unwrap();
+    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+    let addr = listener.local_addr().unwrap();
+    let app = serve::router(cfg, page, initial);
+    let _h = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
+    let reports = dir.join("test-recipe-nvfp4/reports");
+    // tokio::process (not std::process::output — that would block this
+    // runtime's only thread while the axum server runs on it → deadlock).
+    let out = tokio::process::Command::new("deno")
+        .arg("run")
+        .arg("-A")
+        .arg("src/report/report.playwright.mjs")
+        .env("SM121_REPORT_URL", format!("http://{addr}"))
+        .env("SM121_REPORT_FIXTURE", reports.to_str().unwrap())
+        .output()
+        .await
+        .unwrap();
+    let stdout = String::from_utf8_lossy(&out.stdout);
+    let stderr = String::from_utf8_lossy(&out.stderr);
+    let skip = stdout.contains("SKIP");
+    fs::remove_dir_all(&dir).ok();
+    if skip {
+        eprintln!("SKIP report_playwright_checks:\n{stdout}");
+        return;
+    }
+    assert!(
+        out.status.success(),
+        "report playwright checks failed:\n{stdout}{stderr}"
+    );
+    eprintln!("{stdout}");
+}