Kaynağa Gözat

web/report: recipe logs open a dashboard WINDOW, not an inline stream

The recipes panel (report page, embedded as an iframe) tried to tail logs
inline (its own /api/logs attach + EventSource /events) — broken under the
/recipes prefix mount and against the user's expectation. Now the logs
button postMessages {sm121:"openLogs", recipe} to the parent dashboard,
which opens its existing logs window (openLog — container select, pause/
copy/refresh, its own /events) on the recipe's head (rank 0) node, falling
back to the node picker when the fleet snapshot lags. Same window as the
cluster overview. Removed the inline LOG machinery + logbox CSS from the
report page. Tests: report page posts (no inline /api/logs fetch), dashboard
handles the message.
Alexander Korolev 1 hafta önce
ebeveyn
işleme
503fe88e78
4 değiştirilmiş dosya ile 46 ekleme ve 86 silme
  1. 9 85
      src/report/page.html
  2. 1 1
      src/report/ui_tests.rs
  3. 11 0
      src/web/tests.rs
  4. 25 0
      src/web/ui.html

+ 9 - 85
src/report/page.html

@@ -23,11 +23,6 @@
   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); }
@@ -515,22 +510,10 @@ 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>");
-      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>`);
+      out.push(`<tr class="hist"><td colspan="${vis.length}">${actionBar(r)}<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 => {
@@ -564,7 +547,6 @@ document.querySelector("tbody").addEventListener("click", e => {
   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();
   }
@@ -593,86 +575,28 @@ function setNote(recipe, text) {
   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).
+// (tracked dashboard jobs), a logs button (opens a logs WINDOW in the parent
+// dashboard via postMessage — same window as the cluster overview, not an
+// inline 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;
   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>`;
+  if (running) b += `<button class="act" data-act="logs" data-r="${esc(r.name)}" title="open a logs window (head node)">▤ 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; }
+  if (kind === "logs") { parent.postMessage({ sm121: "openLogs", recipe }, "*"); return; }
   const path = kind === "serve" ? "/api/serve" : kind === "stop" ? "/api/stop" : "/api/bench";
   b.disabled = true;
   try {

+ 1 - 1
src/report/ui_tests.rs

@@ -36,7 +36,7 @@ fn page_has_sync_button_and_row_actions() {
         "data-act=\"logs\"",
         "data-act=\"bench\"",
         "/api/bench",
-        "/api/logs",
+        "sm121: \"openLogs\"",
         "fleet_up",
     ] {
         assert!(page.contains(needle), "page missing {needle}");

+ 11 - 0
src/web/tests.rs

@@ -134,6 +134,17 @@ async fn recipes_panel_is_mounted_under_prefix() {
         .await
         .unwrap();
     assert!(ui.contains("recwin") || ui.contains("Recipes"));
+    // Recipe logs open a dashboard WINDOW (postMessage bridge), not an inline
+    // stream: the report page posts {sm121:"openLogs"}, the dashboard listens
+    // and opens its logs window. No inline /api/logs fetch in the report page.
+    assert!(
+        page.contains("sm121: \"openLogs\"") && !page.contains("fetch(\"/api/logs\""),
+        "report page should postMessage for logs, not stream inline"
+    );
+    assert!(
+        ui.contains("openLogsForRecipe") && ui.contains("sm121 === \"openLogs\""),
+        "dashboard should handle the recipes iframe logs message"
+    );
     handle.abort();
 }
 

+ 25 - 0
src/web/ui.html

@@ -841,6 +841,31 @@ function openLogsPicker() {
   });
 }
 
+// The embedded recipes panel (iframe) asks the dashboard to open a logs
+// WINDOW for a recipe — the same window the cluster overview uses, not an
+// inline stream. Prefer the head (lowest rank) node running the recipe; fall
+// back to the node picker when the fleet snapshot lags the launch.
+function openLogsForRecipe(recipe) {
+  const snap = state.snap;
+  if (snap) {
+    let best = null;
+    for (const c of snap.clusters)
+      for (const n of c.nodes)
+        if ((n.containers || []).some((x) => x.recipe === recipe) && (!best || n.rank < best.n.rank))
+          best = { c, n };
+    if (best) {
+      openLog(best.c.name, best.n.name, recipe);
+      return;
+    }
+  }
+  openLogsPicker();
+}
+// Messages from the recipes iframe (postMessage from report/page.html).
+window.addEventListener("message", (e) => {
+  const d = e.data;
+  if (d && d.sm121 === "openLogs" && typeof d.recipe === "string") openLogsForRecipe(d.recipe);
+});
+
 // --- SSE -------------------------------------------------------------------
 function sse(cls, txt) { $("#sse").className = "ssec " + cls; $("#sse-t").textContent = txt; }
 /*SSE-LT:START*/