Parcourir la source

recipes panel: move row controls into the expanded section (before the toml) — serve/stop, a live log tail (▤ logs, /api/logs attach + /events stream, node picker) for running recipes, and ⚡ bench (new POST /api/bench tracked job, standard seed-42 campaign, report lands in the recipe repo)

Alexander Korolev il y a 1 semaine
Parent
commit
606670fa28
6 fichiers modifiés avec 200 ajouts et 19 suppressions
  1. 10 4
      AGENTS.md
  2. 115 15
      src/report/page.html
  3. 4 0
      src/report/ui_tests.rs
  4. 53 0
      src/web/api.rs
  5. 17 0
      src/web/api_tests.rs
  6. 1 0
      src/web/mod.rs

+ 10 - 4
AGENTS.md

@@ -122,14 +122,20 @@ own SSE stream and `/r/` viewer prefix-rewritten server-side). In hosted
 mode the panel is interactive: a **⟳ sync** button (POST `/api/repos/sync` —
 the same best-effort batch as the command-start sync, single in-flight
 409 guard; the refresher's file watcher picks up new recipes on its own),
-and **per-row serve/stop action buttons** (POST `/api/serve` / `/api/stop`
-with `cluster: "auto"` — the same tracked jobs the CLI uses; the state cell
-flips when the fleet probe sees the change). Standing state is
+and **per-row controls in the expanded section** (click a row to expand it;
+the action bar sits *above* the toml link): **▶ serve / ⏹ stop** (POST
+`/api/serve` / `/api/stop` with `cluster: "auto"` — the same tracked jobs the
+CLI uses; the state cell flips when the fleet probe sees the change), **▤ logs**
+(shown only while running — attaches the dashboard's `/api/logs` stream for a
+node hosting the recipe's container, node picker + live tail rendered inline),
+and **⚡ bench** (POST `/api/bench` — the standard seed-42 hardmode
+quality bench as a tracked job; the report + run package land in the shared
+recipe repo and re-appear in the table via the live stream). Standing state is
 **fleet-aware**: a recipe the live probe sees running but the local ledger
 knows nothing about (the add-on machine has no `.lock` — they are
 gitignored and never ship with the synced repo) renders green
 "up (fleet)"; the launch-count column shows `—` without a ledger. The
-standalone `report --serve` has no job/repo API, so the buttons stay
+standalone `report --serve` has no job/repo API, so the controls stay
 hidden there. topology map
 (physical layer:
 8 GX10 boxes on a 2×4 grid, 200G fabric switch + passive 10G switch + DAC

+ 115 - 15
src/report/page.html

@@ -20,6 +20,14 @@
   tr.g1 td { font-weight: 700; background: color-mix(in srgb, currentColor 8%, canvas); cursor: pointer; }
   tr.g2 td { font-style: italic; background: color-mix(in srgb, currentColor 4%, canvas); cursor: pointer; }
   tr.hist td { font-family: ui-monospace, monospace; font-size: 12px; background: color-mix(in srgb, currentColor 3%, canvas); }
+  tr.hist .actbar { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; margin: 2px 0 6px; }
+  tr.hist .actbar button.act { padding: 2px 8px; font-size: 12px; }
+  tr.hist .actnote { font-size: 11px; color: gray; }
+  tr.hist .logbox { display: flex; gap: 8px; align-items: flex-start; margin: 6px 0; }
+  tr.hist .logbox select { font: 11px ui-monospace, monospace; }
+  tr.hist .logbox pre { flex: 1; min-width: 0; font: 11px/1.4 ui-monospace, monospace;
+    background: color-mix(in srgb, currentColor 5%, canvas); border: 1px solid color-mix(in srgb, currentColor 15%, transparent);
+    border-radius: 3px; padding: 6px; max-height: 240px; overflow: auto; white-space: pre-wrap; word-break: break-word; }
   a.open { color: inherit; text-decoration: none; border: 1px solid color-mix(in srgb, currentColor 30%, transparent); border-radius: 3px; padding: 0 4px; }
   .prov { font-family: ui-monospace, monospace; font-size: 10px; opacity: .8; white-space: nowrap; }
   a.open:hover { background: color-mix(in srgb, currentColor 12%, canvas); }
@@ -472,8 +480,7 @@ function render() {
               : `<span title="up since ${fmtSince(r.up)} UTC"><span style="color:green">●</span><span class="dim"> up</span></span>`)
           : (r.fleet_up
               ? `<span title="running in the fleet — a container was found on a cluster node, but this machine has no local run ledger (served by another launcher)"><span style="color:green">●</span><span class="dim"> up</span><span class="dim" style="font-size:.8em">(fleet)</span></span>`
-              : '<span class="dim" title="not running">○</span>')
-            + actions(r);
+              : '<span class="dim" title="not running">○</span>');
       } else if (c[0] === "uptime") {
         body = r.up != null ? fmtUp(v) : '<span class="dim">—</span>';
       } else if (c[0] === "runs") {
@@ -508,10 +515,22 @@ function render() {
           `<a class="open" href="${auth("/r/" + encodeURIComponent(r.name) + "/" + encodeURIComponent(f))}" target="_blank" title="open ${esc(f)}">${esc(f.split('.').pop())}</a>`).join(" ")}</span> ` +
         `${esc(e.date)}${e.time ? "·" + esc(e.time) : ""} ${esc(e.label)} — ${e.pct != null ? esc(e.pct) + "/100" : '<span class="dim">no score</span>'}${e.points ? " (" + esc(e.points) + ")" : ""}${ts}${chip}`;
       }).join("<br>");
-      out.push(`<tr class="hist"><td colspan="${vis.length}">${toml}${r.reports.length ? " " + lines : ' <span class="dim">no reports</span>'}</td></tr>`);
+      const logbox = EMBED && LOG.recipe === r.name && LOG.opts.length
+        ? `<div class="logbox"><select class="lognode" title="node streaming">${LOG.opts.map((o, i) => `<option value="${i}"${i === LOG.cur ? " selected" : ""}>${esc(o.cluster)}/${esc(o.node)}</option>`).join("")}</select><pre class="logpre">${esc(LOG.raw)}</pre></div>`
+        : "";
+      out.push(`<tr class="hist"><td colspan="${vis.length}">${actionBar(r)}${logbox}<div>${toml}${r.reports.length ? " " + lines : ' <span class="dim">no reports</span>'}</div></td></tr>`);
     }
   }
   document.querySelector("tbody").innerHTML = out.join("");
+  // Rebind the live log box (if any): the DOM was rebuilt, so the <pre>/
+  // select re-attach to the live stream state (the stream itself keeps
+  // running in LOG.raw between renders).
+  if (EMBED && LOG.recipe && S.openRow === LOG.recipe) {
+    LOG.pre = document.querySelector("tr.hist .logpre");
+    if (LOG.pre) { LOG.pre.textContent = LOG.raw; LOG.pre.scrollTop = LOG.pre.scrollHeight; }
+    const sel = document.querySelector("tr.hist .lognode");
+    if (sel) sel.onchange = e => { LOG.cur = +e.target.value; attachLog(); };
+  }
 }
 
 document.querySelector("thead").addEventListener("click", e => {
@@ -542,7 +561,13 @@ document.querySelector("tbody").addEventListener("click", e => {
   const g = e.target.closest("tr.g1,tr.g2");
   if (g) { const k = decodeURIComponent(g.dataset.g); S.closed.has(k) ? S.closed.delete(k) : S.closed.add(k); render(); return; }
   const row = e.target.closest("tr.row");
-  if (row) { S.openRow = S.openRow === row.dataset.n ? null : row.dataset.n; render(); }
+  if (row) {
+    const n = row.dataset.n;
+    const next = S.openRow === n ? null : n;
+    if (LOG.recipe && next !== LOG.recipe) closeLog(); // switching/collapsing drops the stream
+    S.openRow = next;
+    render();
+  }
 });
 document.getElementById("q").addEventListener("input", e => { S.q = e.target.value; S.page = 0; render(); });
 document.getElementById("group").addEventListener("change", e => { S.group = e.target.value; S.closed.clear(); S.page = 0; render(); });
@@ -556,36 +581,111 @@ document.getElementById("size").addEventListener("change", () => { S.page = 0; r
 document.getElementById("prev").addEventListener("click", () => { if (S.page > 0) { S.page--; render(); } });
 document.getElementById("next").addEventListener("click", () => { S.page++; render(); });
 // ---- dashboard actions (hosted/embed mode only) --------------------------
-// The standalone `report --serve` has no job/repo API — the buttons exist
+// The standalone `report --serve` has no job/repo API — the controls exist
 // only when the page is mounted by the web dashboard (body.embed), and they
 // call the dashboard's same-origin endpoints (the session cookie rides
 // along). State flips come back through the fleet-probe live stream.
 const EMBED = document.body.classList.contains("embed");
-function actions(r) {
+const NOTES = {};
+function setNote(recipe, text) {
+  NOTES[recipe] = text;
+  const n = document.querySelector(`.actbar .actnote[data-r="${CSS.escape(recipe)}"]`);
+  if (n) n.textContent = text;
+}
+// The expanded row carries the controls before the toml link: serve/stop
+// (tracked dashboard jobs), a live log tail for a running recipe (the
+// dashboard's /api/logs attach + the /events log stream), and a bench
+// launch (POST /api/bench — the standard campaign as a job; the report
+// lands in the shared recipe repo and re-appears in this table via the
+// live stream).
+function actionBar(r) {
   if (!EMBED) return "";
   const running = r.up != null || r.fleet_up;
-  return running
-    ? `<button class="act" data-act="stop" data-r="${esc(r.name)}" title="stop (dashboard job)">⏹</button>`
-    : `<button class="act" data-act="serve" data-r="${esc(r.name)}" title="serve (dashboard job, cluster auto)">▶</button>`;
+  let b = running
+    ? `<button class="act" data-act="stop" data-r="${esc(r.name)}" title="stop (dashboard job)">⏹ stop</button>`
+    : `<button class="act" data-act="serve" data-r="${esc(r.name)}" title="serve (dashboard job, cluster auto)">▶ serve</button>`;
+  if (running) b += `<button class="act" data-act="logs" data-r="${esc(r.name)}" title="live container logs">▤ logs</button>`;
+  b += `<button class="act" data-act="bench" data-r="${esc(r.name)}" title="standard bench (seed 42, hardmode, perf c1,2) as a dashboard job — the report lands in the recipe repo">⚡ bench</button>`;
+  return `<div class="actbar">${b}<span class="actnote" data-r="${esc(r.name)}">${esc(NOTES[r.name] || "")}</span></div>`;
+}
+// ---- live log tail (one active attach at a time — one row expands at a time)
+const LOG = { attach: null, es: null, raw: "", buf: "", pre: null, opts: [], cur: 0, recipe: "" };
+function paintLog() {
+  if (LOG.buf) { LOG.raw += LOG.buf; LOG.buf = ""; }
+  const lines = LOG.raw.split("\n");
+  if (lines.length > 2000) LOG.raw = lines.slice(-2000).join("\n");
+  if (LOG.pre && LOG.pre.isConnected) { LOG.pre.textContent = LOG.raw; LOG.pre.scrollTop = LOG.pre.scrollHeight; }
+}
+function ensureLogStream() {
+  if (LOG.es) return;
+  LOG.es = new EventSource("/events");
+  LOG.es.onmessage = e => {
+    let ev; try { ev = JSON.parse(e.data); } catch { return; }
+    if (ev.type !== "log" || ev.attach !== LOG.attach) return;
+    LOG.buf += ev.line + "\n";
+    paintLog();
+  };
+}
+async function attachLog() {
+  const o = LOG.opts[LOG.cur];
+  if (!o) return;
+  if (LOG.attach) {
+    fetch("/api/logs", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ attach_id: LOG.attach }) }).catch(() => {});
+    LOG.attach = null;
+  }
+  const res = await fetch("/api/logs", {
+    method: "POST",
+    headers: { "Content-Type": "application/json" },
+    body: JSON.stringify({ cluster: o.cluster, node: o.node, recipe: LOG.recipe, rank: o.rank }),
+  });
+  const j = await res.json().catch(() => ({}));
+  if (!res.ok || !j.attach_id) { setNote(LOG.recipe, "log attach failed: " + (j.error || res.status)); return; }
+  LOG.attach = j.attach_id;
+  LOG.raw = (j.history || []).join("\n");
+  ensureLogStream();
+  paintLog();
+}
+function closeLog() {
+  if (LOG.attach) fetch("/api/logs", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ attach_id: LOG.attach }) }).catch(() => {});
+  LOG.attach = null;
+  if (LOG.es) { LOG.es.close(); LOG.es = null; }
+  LOG.raw = ""; LOG.buf = ""; LOG.pre = null; LOG.opts = []; LOG.cur = 0; LOG.recipe = "";
+}
+async function openLogs(recipe) {
+  LOG.recipe = recipe;
+  setNote(recipe, "opening logs…");
+  let st;
+  try { st = await (await fetch("/api/state")).json(); }
+  catch (err) { setNote(recipe, "✗ " + err); return; }
+  const opts = [];
+  for (const c of st.clusters || [])
+    for (const n of c.nodes || [])
+      if ((n.containers || []).some(x => x.recipe === recipe))
+        opts.push({ cluster: c.name, node: n.name, rank: n.rank });
+  LOG.opts = opts; LOG.cur = 0;
+  if (!opts.length) { setNote(recipe, "no live container found — probe lag, or the recipe just stopped"); render(); return; }
+  render(); // the log box (select + pre) renders for LOG.recipe
+  await attachLog();
 }
 document.addEventListener("click", async (e) => {
   const b = e.target.closest("button.act");
   if (!b) return;
   e.stopPropagation(); // no row toggle under the action button
   const kind = b.dataset.act, recipe = b.dataset.r;
+  if (kind === "logs") { openLogs(recipe); return; }
+  const path = kind === "serve" ? "/api/serve" : kind === "stop" ? "/api/stop" : "/api/bench";
   b.disabled = true;
-  const label0 = b.textContent;
   try {
-    const res = await fetch(kind, { // "/api/serve" | "/api/stop"
+    const res = await fetch(path, { // "/api/serve" | "/api/stop" | "/api/bench"
       method: "POST",
       headers: { "Content-Type": "application/json" },
       body: JSON.stringify({ cluster: "auto", recipe }),
     });
     const j = await res.json().catch(() => ({}));
-    if (j.job != null) { b.textContent = "…"; b.title = `job ${j.job} running — the state cell updates when the fleet probe sees it`; }
-    else { b.textContent = "✗"; b.title = j.error || String(res.status); }
-  } catch (err) { b.textContent = "✗"; b.title = String(err); }
-  setTimeout(() => { b.disabled = false; b.textContent = label0; b.title = kind === "stop" ? "stop (dashboard job)" : "serve (dashboard job, cluster auto)"; }, 5000);
+    if (j.job != null) setNote(recipe, `${kind} job ${j.job} running — the state updates when the fleet probe sees it`);
+    else setNote(recipe, `✗ ${kind}: ` + (j.error || res.status));
+  } catch (err) { setNote(recipe, `✗ ${kind}: ` + err); }
+  setTimeout(() => { b.disabled = false; }, 3000);
 });
 const syncBtn = document.getElementById("syncbtn");
 if (EMBED) syncBtn.style.display = "";

+ 4 - 0
src/report/ui_tests.rs

@@ -33,6 +33,10 @@ fn page_has_sync_button_and_row_actions() {
         "/api/repos/sync",
         "button.act",
         "data-act=\"stop\"",
+        "data-act=\"logs\"",
+        "data-act=\"bench\"",
+        "/api/bench",
+        "/api/logs",
         "fleet_up",
     ] {
         assert!(page.contains(needle), "page missing {needle}");

+ 53 - 0
src/web/api.rs

@@ -117,6 +117,59 @@ pub(crate) async fn stop(
     Ok(Json(serde_json::json!({ "job": id })))
 }
 
+#[derive(Deserialize)]
+pub(crate) struct BenchReq {
+    pub cluster: String,
+    pub recipe: String,
+    /// Label for the run. A quality run without one is auto-labeled
+    /// `web-<UTC-stamp>` (dashboard campaigns stay distinguishable from
+    /// operator campaigns); perf-only runs default to `perf` in bench::run.
+    #[serde(default)]
+    pub label: Option<String>,
+    #[serde(default)]
+    pub perf_only: bool,
+    /// Run the context-depth suite after the main pass (off by default).
+    #[serde(default)]
+    pub depth: bool,
+    /// Run the harness on a remote host over SSH (its network stays off the
+    /// measurement path); None = the launcher host itself.
+    #[serde(default)]
+    pub ssh_host: Option<String>,
+}
+
+/// `POST /api/bench` — the standard bench (seed 42, hardmode, perf,
+/// concurrency 1,2) as a tracked job; the report + run package land in the
+/// shared recipe repo (`recipes/<lane>/reports/`).
+pub(crate) async fn bench(
+    app: State<AppState>,
+    Json(req): Json<BenchReq>,
+) -> Result<Json<serde_json::Value>, ApiError> {
+    let id = super::jobs::new_job(&app, "bench", &req.cluster, Some(&req.recipe))?;
+    let config = app.config.clone();
+    let app2 = (*app).clone();
+    let label = match req.label {
+        Some(l) => Some(l),
+        None if !req.perf_only => Some(format!("web-{}", crate::stamp::utc_stamp())),
+        None => None,
+    };
+    let args = crate::bench::Args {
+        recipe: req.recipe.clone(),
+        cluster: req.cluster.clone(),
+        label,
+        concurrency: String::from("1,2"),
+        seed: 42,
+        parallel: None,
+        perf_only: req.perf_only,
+        depth: req.depth,
+        tokenizer: None,
+        ssh_host: req.ssh_host,
+    };
+    super::jobs::spawn(app2, id.clone(), async move {
+        crate::bench::run(&config, &args, false).await
+    });
+    Ok(Json(serde_json::json!({ "job": id })))
+}
+
 #[derive(Deserialize)]
 pub(crate) struct PowerReq {
     /// "off" | "on" | "switch"

+ 17 - 0
src/web/api_tests.rs

@@ -83,6 +83,23 @@ async fn serve_endpoint_registers_job_and_rejects_concurrent() {
     let _ = id;
 }
 
+#[tokio::test]
+async fn bench_endpoint_registers_job() {
+    let (addr, _h) = boot().await;
+    let body = serde_json::json!({ "cluster": "c1", "recipe": "r1" });
+    let res = reqwest::Client::new()
+        .post(format!("http://{addr}/api/bench"))
+        .json(&body)
+        .send()
+        .await
+        .unwrap();
+    assert_eq!(res.status(), StatusCode::OK);
+    let v: serde_json::Value = res.json().await.unwrap();
+    assert!(v["job"].is_string(), "job id missing: {v}");
+    // the job fails asynchronously (no such recipe in the test config) —
+    // only the registration path is under test here.
+}
+
 // --- P3: log windows -----------------------------------------------------------
 
 #[tokio::test]

+ 1 - 0
src/web/mod.rs

@@ -260,6 +260,7 @@ pub fn router(app: AppState) -> Router {
         .route("/api/state", get(api::state))
         .route("/api/serve", post(api::serve))
         .route("/api/stop", post(api::stop))
+        .route("/api/bench", post(api::bench))
         .route("/api/power", post(api::power))
         .route("/api/recipes", get(api::recipes))
         .route("/api/repos/sync", post(api::repos_sync))