Преглед изворни кода

web P3b: window manager + log windows (UI)

The two static cards become real windows (vanilla JS, no deps):
drag by titlebar (baseline captured at pointerdown — reading
offsetLeft per move event compounded the delta), corner resize,
minimize/close to a dock bar (chip restores), click-to-front z-order.

Log windows:
- click a node row to open one; the container select (from the
  snapshot's sm121 containers) switches streams (old attach
  detached, window kept); explicit recipe attaches even when the
  snapshot shows no containers (it may lag the fleet)
- seeded with 200 lines of ring history; 'type:log' SSE events
  append live; pause buffers; copy button; 1000-line DOM cap
- closing sends DELETE /api/logs; Snapshot.attaches restores open
  windows after reload (idempotent attach reuses live streams)
- NodeSnap.rank so multi-node attaches follow the right rank

Playwright: +9 checks (27 total) — drag, minimize/restore via dock,
log spawn, SSE line append, pause buffer/flush, close-detach,
node-click note window. Negative-verified (breaking pause ->
'FAIL paused window buffers the line'). Live: 200 real vLLM lines
streaming into the window on the running qwen38 serve.
Alexander Korolev пре 2 месеци
родитељ
комит
3e0b67908e
3 измењених фајлова са 376 додато и 20 уклоњено
  1. 3 0
      src/web/events.rs
  2. 313 20
      src/web/ui.html
  3. 60 0
      src/web/ui.playwright.mjs

+ 3 - 0
src/web/events.rs

@@ -45,6 +45,8 @@ pub struct NodeSnap {
     pub reachable: bool,
     pub occupied: bool,
     pub containers: Vec<ContainerSnap>,
+    /// Node rank — log windows attach the rank's `docker logs` stream.
+    pub rank: u32,
 }
 
 /// Per-cluster view.
@@ -134,6 +136,7 @@ pub fn build_snapshot(
                 reachable,
                 occupied,
                 containers,
+                rank: n.rank,
             });
         }
         clusters.push(ClusterSnap {

+ 313 - 20
src/web/ui.html

@@ -20,9 +20,11 @@
     --btn: #f6f8fa; --btn-hover: #eaeef2;
   }
   * { box-sizing: border-box; }
+  html, body { height: 100%; }
   body {
     margin: 0; background: var(--bg); color: var(--fg);
     font: 14px/1.45 ui-sans-serif, system-ui, "SF Pro", sans-serif;
+    display: flex; flex-direction: column;
     transition: background .25s, color .25s;
   }
   header {
@@ -41,13 +43,45 @@
     border-radius: 6px; padding: 4px 10px; cursor: pointer; font: inherit; font-size: 13px;
   }
   button.tbtn:hover { background: var(--btn-hover); }
-  main { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; padding: 14px; max-width: 1200px; margin: 0 auto; }
-  @media (max-width: 860px) { main { grid-template-columns: 1fr; } }
-  .card {
-    background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
-    padding: 14px; min-height: 120px;
-  }
-  .card h2 { font-size: 12px; text-transform: uppercase; letter-spacing: .8px; color: var(--muted); margin: 0 0 10px; }
+
+  /* --- window manager ------------------------------------------------------ */
+  #winlayer { position: relative; flex: 1; overflow: hidden; }
+  .win {
+    position: absolute; background: var(--panel); border: 1px solid var(--border);
+    border-radius: 10px; display: flex; flex-direction: column;
+    box-shadow: 0 6px 24px rgba(0,0,0,.35); animation: winIn .3s ease both;
+  }
+  .win-bar {
+    display: flex; align-items: center; gap: 6px; padding: 7px 10px;
+    border-bottom: 1px solid var(--border); cursor: grab; user-select: none;
+    font-size: 12px; text-transform: uppercase; letter-spacing: .6px; color: var(--muted);
+    flex-shrink: 0;
+  }
+  .win-bar:active { cursor: grabbing; }
+  .win-bar .wtitle { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+  .wbtn {
+    background: none; border: 1px solid transparent; color: var(--muted);
+    border-radius: 5px; width: 20px; height: 18px; cursor: pointer; font: inherit; font-size: 12px;
+    line-height: 1; padding: 0;
+  }
+  .wbtn:hover { background: var(--btn-hover); color: var(--fg); }
+  .win-body { flex: 1; overflow: auto; padding: 12px 14px; }
+  .win-resize {
+    position: absolute; right: 0; bottom: 0; width: 14px; height: 14px; cursor: nwse-resize;
+    background: linear-gradient(135deg, transparent 50%, var(--border) 50%);
+  }
+  #dock {
+    display: flex; gap: 6px; align-items: center; padding: 6px 12px;
+    border-top: 1px solid var(--border); background: var(--panel); min-height: 34px; flex-shrink: 0;
+  }
+  #dock:empty { display: none; }
+  .dock-chip {
+    background: var(--btn); color: var(--muted); border: 1px solid var(--border);
+    border-radius: 999px; padding: 2px 10px; font-size: 12px; cursor: pointer; font: inherit;
+  }
+  .dock-chip:hover { color: var(--fg); border-color: var(--accent); }
+
+  /* --- cluster board ------------------------------------------------------- */
   .cluster.anim { animation: winIn .35s ease both; }
   .cluster { margin-bottom: 14px; }
   .cluster-head { display: flex; align-items: baseline; gap: 8px; margin-bottom: 6px; }
@@ -55,7 +89,11 @@
   .cluster-head .free { color: var(--muted); font-size: 12px; }
   .cluster-head .down { color: var(--led-off); font-size: 12px; }
   .cluster-head .soft { color: var(--led-busy); font-size: 12px; }
-  .node { display: flex; align-items: center; gap: 10px; padding: 5px 0; border-bottom: 1px solid var(--border); }
+  .node {
+    display: flex; align-items: center; gap: 10px; padding: 5px 0;
+    border-bottom: 1px solid var(--border); cursor: pointer;
+  }
+  .node:hover { background: var(--btn); }
   .node:last-child { border-bottom: none; }
   .led {
     width: 11px; height: 11px; border-radius: 50%; background: var(--led-off);
@@ -119,10 +157,41 @@
   .jmsg { color: var(--muted); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
   .jjob .jmsg { color: var(--fg); }
   .jobs-empty { color: var(--muted); font-size: 12.5px; padding: 6px 0; }
-  /* confirm dialog */
+
+  /* --- log windows ---------------------------------------------------------- */
+  .logwin .win-body { display: flex; flex-direction: column; padding: 0; }
+  .logbar {
+    display: flex; gap: 8px; align-items: center; padding: 8px 10px;
+    border-bottom: 1px solid var(--border); flex-shrink: 0; flex-wrap: wrap;
+  }
+  .logbar select {
+    background: var(--btn); color: var(--fg); border: 1px solid var(--border);
+    border-radius: 6px; padding: 2px 6px; font-size: 12px; max-width: 300px; height: 26px;
+  }
+  .logbar label { font-size: 12px; color: var(--muted); display: inline-flex; gap: 4px; align-items: center; }
+  .logbar input[type=checkbox] {
+    appearance: none; -webkit-appearance: none; width: 15px; height: 15px; margin: 0;
+    border: 1.5px solid var(--border); border-radius: 4px; background: var(--btn);
+    cursor: pointer; position: relative;
+  }
+  .logbar input[type=checkbox]:checked { background: var(--accent); border-color: var(--accent); }
+  .logbar input[type=checkbox]:checked::after {
+    content: ""; position: absolute; left: 3.5px; top: 0;
+    width: 4px; height: 8px; border: solid #fff; border-width: 0 2px 2px 0;
+    transform: rotate(45deg);
+  }
+  .lognote { font-size: 12px; color: var(--muted); flex: 1; }
+  .lognote.dead { color: var(--led-busy); }
+  .logpre {
+    flex: 1; margin: 0; padding: 10px 12px; overflow: auto;
+    font: 11.5px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap;
+    word-break: break-word; color: var(--fg);
+  }
+
+  /* --- confirm dialog ------------------------------------------------------- */
   .dlg-back[hidden] { display: none; }
   .dlg-back {
-    position: fixed; inset: 0; background: rgba(0,0,0,.5); z-index: 10;
+    position: fixed; inset: 0; background: rgba(0,0,0,.5); z-index: 400;
     display: flex; align-items: center; justify-content: center; animation: fadeIn .15s ease;
   }
   .dlg {
@@ -145,7 +214,7 @@
   @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
   @keyframes winIn { from { opacity: 0; transform: translateY(-4px) scale(.985); } to { opacity: 1; transform: none; } }
   @media (prefers-reduced-motion: reduce) {
-    *, .led, .led.flip, .ssec.re .dot, .cluster, .job, .dlg { animation: none !important; }
+    *, .led, .led.flip, .ssec.re .dot, .cluster, .job, .dlg, .win { animation: none !important; }
   }
 </style>
 </head>
@@ -156,10 +225,8 @@
   <div class="ssec" id="sse"><span class="dot"></span><span id="sse-t">connecting…</span></div>
   <button class="tbtn" id="theme">◐</button>
 </header>
-<main>
-  <section class="card" id="state-card"><h2>Cluster state</h2><div id="clusters"></div></section>
-  <section class="card"><h2>Jobs</h2><div class="jobs" id="jobs"><div class="jobs-empty">no actions yet — serve / stop / power started from this dashboard appear here</div></div></section>
-</main>
+<div id="winlayer"></div>
+<div id="dock"></div>
 <div class="dlg-back" id="dlg" hidden>
   <div class="dlg">
     <h3 id="dlg-title"></h3>
@@ -179,6 +246,8 @@ const state = { snap: null, jobs: new Map(), recipes: [], active: localStorage.g
 /*SNAP-LT:START*/
 // Pure: same fleet + jobs? (`ts` changes on every probe, so it is
 // excluded). Used to skip DOM rebuilds when nothing changed — no blink.
+// (log-attaches are intentionally excluded: they change out-of-band and are
+// tracked in their own windows, not the cluster board)
 function sameSnapshot(a, b) {
   return JSON.stringify({ c: a.clusters, j: a.jobs }) === JSON.stringify({ c: b.clusters, j: b.jobs });
 }
@@ -192,6 +261,94 @@ $("#theme").onclick = () => {
   localStorage.setItem("sm121-theme", root.dataset.theme);
 };
 
+// --- window manager ------------------------------------------------------
+// Vanilla win manager: absolute windows, drag by titlebar, corner resize,
+// minimize/close to the dock (chip to restore), click-to-front z-order.
+let ztop = 20;
+const wins = {};      // managed windows: { state, jobs }
+const logWins = new Map(); // attach_id -> { win, pre, buf, paused, ... }
+const front = (el) => { el.style.zIndex = ++ztop; };
+
+function makeWin({ id, title, x, y, w, h, bodyHtml, onClosed, extraCls, removeOnClose }) {
+  const win = document.createElement("section");
+  win.className = "win" + (extraCls ? " " + extraCls : "");
+  if (id) win.id = id;
+  win.style.cssText = `left:${x}px;top:${y}px;width:${w}px;height:${h}px;z-index:${++ztop}`;
+  const bar = document.createElement("div");
+  bar.className = "win-bar";
+  bar.innerHTML = `<span class="wtitle">${esc(title)}</span>
+    <button class="wbtn" data-act="min" title="minimize">–</button>
+    <button class="wbtn" data-act="close" title="close">×</button>`;
+  const body = document.createElement("div");
+  body.className = "win-body";
+  body.innerHTML = bodyHtml;
+  const rz = document.createElement("div");
+  rz.className = "win-resize";
+  win.append(bar, body, rz);
+  $("#winlayer").appendChild(win);
+  const closed = { value: false };
+  bar.querySelector('[data-act="min"]').onclick = () => { win.style.display = "none"; renderDock(); };
+  bar.querySelector('[data-act="close"]').onclick = () => {
+    if (removeOnClose) win.remove(); // log windows die with their attach
+    else win.style.display = "none";
+    closed.value = true;
+    renderDock();
+    if (onClosed) onClosed();
+  };
+  bar.addEventListener("pointerdown", (e) => {
+    if (e.target.closest(".wbtn")) return;
+    front(win);
+    const ox = win.offsetLeft, oy = win.offsetTop; // baseline — read ONCE
+    drag(e.clientX, e.clientY, (dx, dy) => {
+      win.style.left = Math.max(0, ox + dx) + "px";
+      win.style.top = Math.max(0, oy + dy) + "px";
+    });
+  });
+  rz.addEventListener("pointerdown", (e) => {
+    e.stopPropagation();
+    front(win);
+    const ow = win.offsetWidth, oh = win.offsetHeight; // baseline — read ONCE
+    drag(e.clientX, e.clientY, (dx, dy) => {
+      win.style.width = Math.max(240, ow + dx) + "px";
+      win.style.height = Math.max(140, oh + dy) + "px";
+    });
+  });
+  win.addEventListener("pointerdown", () => front(win));
+  const handle = {
+    win, body, title,
+    restore() { win.style.display = ""; closed.value = false; front(win); renderDock(); },
+    isMinimized: () => win.style.display === "none",
+    isClosed: () => closed.value,
+  };
+  return handle;
+}
+
+function drag(sx, sy, move) {
+  const st = { x: sx, y: sy };
+  const mv = (e) => move(e.clientX - st.x, e.clientY - st.y);
+  const up = () => { removeEventListener("pointermove", mv); removeEventListener("pointerup", up); };
+  addEventListener("pointermove", mv);
+  addEventListener("pointerup", up);
+}
+
+function renderDock() {
+  const dock = $("#dock");
+  dock.innerHTML = "";
+  const chip = (label, fn) => {
+    const b = document.createElement("button");
+    b.className = "dock-chip";
+    b.textContent = label;
+    b.onclick = fn;
+    dock.appendChild(b);
+  };
+  for (const h of Object.values(wins)) {
+    if (h.isMinimized()) chip((h.isClosed() ? "+ " : "– ") + h.title, h.restore);
+  }
+  for (const w of logWins.values()) {
+    if (w.win.style.display === "none") chip("– logs: " + w.node, () => { w.win.style.display = ""; front(w.win); renderDock(); });
+  }
+}
+
 // --- SSE -------------------------------------------------------------------
 function sse(cls, txt) { $("#sse").className = "ssec " + cls; $("#sse-t").textContent = txt; }
 function connectSSE() {
@@ -202,6 +359,7 @@ function connectSSE() {
     try { ev = JSON.parse(e.data); } catch { return; }
     if (ev.type === "state") onState(ev.snapshot);
     else if (ev.type === "job") onJob(ev);
+    else if (ev.type === "log") onLog(ev);
   };
   es.onerror = () => { sse("re", "reconnecting…"); };
 }
@@ -241,20 +399,22 @@ function renderCluster(c, prev, animate) {
   for (const n of c.nodes) {
     const row = document.createElement("div");
     row.className = "node";
+    row.title = "open logs for " + n.name;
     const cls = ledState(n);
     const flip = prevNodes[n.name] && ledState(prevNodes[n.name]) !== cls ? " flip" : "";
     row.innerHTML = `<span class="led ${cls}${flip}"></span>
       <span class="nname">${esc(n.name)}</span>
       <span class="nmeta" title="${esc(n.ssh_ip)}">${esc(meta(n, c))}</span>`;
+    row.onclick = () => openLog(c.name, n.name);
     el.appendChild(row);
     if (n.occupied && n.containers.length) {
       const box = document.createElement("div");
       box.className = "containers";
       for (const x of n.containers) {
-        const row = document.createElement("div");
-        row.className = "crow";
-        row.innerHTML = `<span class="cled"></span><span class="cname">${esc(x.name)}</span><span>${esc(x.recipe)} · ${esc(x.status)}</span>`;
-        box.appendChild(row);
+        const crow = document.createElement("div");
+        crow.className = "crow";
+        crow.innerHTML = `<span class="cled"></span><span class="cname">${esc(x.name)}</span><span>${esc(x.recipe)} · ${esc(x.status)}</span>`;
+        box.appendChild(crow);
       }
       el.appendChild(box);
     }
@@ -424,6 +584,114 @@ function onJob(ev) {
   renderJobs();
 }
 
+// --- log windows -------------------------------------------------------------------
+const MAX_PRE_LINES = 1000;
+// ponytail: trimPre re-splits the whole text per line (O(n)); fine at log
+// rates — ring it (line array) if a busy container makes this visible.
+function trimPre(pre) {
+  const t = pre.textContent;
+  if (t.split("\n").length > MAX_PRE_LINES + 1) {
+    pre.textContent = t.split("\n").slice(-MAX_PRE_LINES).join("\n");
+  }
+}
+function appendLogLine(w, line) {
+  if (w.paused) { w.buf += line + "\n"; return; }
+  preSet(w, w.pre.textContent + line + "\n");
+}
+function preSet(w, text) {
+  w.pre.textContent = text;
+  trimPre(w.pre);
+  if (!w.paused) w.pre.scrollTop = w.pre.scrollHeight;
+}
+function onLog(ev) {
+  const w = logWins.get(ev.attach);
+  if (!w) return; // window closed (or from another tab) — nothing to show
+  appendLogLine(w, ev.line);
+}
+async function openLog(cluster, node, recipe) {
+  const snap = state.snap;
+  const c = snap ? snap.clusters.find((x) => x.name === cluster) : null;
+  const n = c ? c.nodes.find((x) => x.name === node) : null;
+  if (!n) return;
+  // A window for this node is already open and no specific recipe asked for → front it.
+  if (!recipe) {
+    for (const w of logWins.values()) if (w.cluster === cluster && w.node === node) { front(w.win); return; }
+  }
+  recipe = recipe || (n.containers[0] ? n.containers[0].recipe : "");
+  const win = makeWin({
+    title: "logs: " + node + (recipe ? " · " + recipe : ""),
+    x: 20 + (logWins.size * 26) % 240, y: 20 + (logWins.size * 26) % 160,
+    w: Math.min(620, innerWidth - 40), h: Math.min(420, innerHeight * 0.6),
+    bodyHtml: `<div class="logbar">
+      <select title="container on ${esc(node)}"></select>
+      <label><input type="checkbox" class="logpause"> pause</label>
+      <button class="abtn logcopy">copy</button>
+      <span class="lognote"></span>
+    </div><pre class="logpre"></pre>`,
+    extraCls: "logwin",
+    removeOnClose: true,
+    onClosed: () => {
+      const w = [...logWins.entries()].find(([, x]) => x.node === node && x.cluster === cluster && x.win === win);
+      if (w) detachLog(w[0]);
+    },
+  });
+  const w = { cluster, node, recipe, rank: n.rank, win: win.win, pre: win.body.querySelector(".logpre"), buf: "", paused: false };
+  win.body.querySelector(".logcopy").onclick = async () => {
+    try { await navigator.clipboard.writeText(w.pre.textContent); } catch {}
+  };
+  const pauseCb = win.body.querySelector(".logpause");
+  pauseCb.onchange = () => {
+    w.paused = pauseCb.checked;
+    if (!w.paused && w.buf) { preSet(w, w.pre.textContent + w.buf); w.buf = ""; }
+  };
+  const sel = win.body.querySelector("select");
+  for (const x of n.containers) {
+    const o = document.createElement("option");
+    o.value = x.recipe;
+    o.textContent = x.name + " · " + x.recipe;
+    sel.appendChild(o);
+  }
+  if (n.containers.length) sel.value = recipe;
+  sel.onchange = async () => {
+    if (!sel.value) return;
+    const cur = [...logWins.entries()].find(([, x]) => x.win === win);
+    if (cur) detachLog(cur[0], false); // drop the old stream, keep this window
+    await attachLog(w, sel.value);
+  };
+  const note = win.body.querySelector(".lognote");
+  // No containers AND no explicit recipe → nothing to attach; just the note.
+  // An explicit recipe attaches anyway (the snapshot may lag the fleet —
+  // the container can be running but not yet probed).
+  if (!n.containers.length && !recipe) {
+    note.textContent = "no sm121 containers on " + node + " — nothing to stream";
+    note.classList.add("dead");
+    logWins.set("noop:" + cluster + ":" + node, { ...w, attach: null });
+    return;
+  }
+  await attachLog(w, recipe || (n.containers[0] ? n.containers[0].recipe : ""));
+}
+async function attachLog(w, recipe) {
+  w.recipe = recipe;
+  w.win.querySelector(".wtitle").textContent = "logs: " + w.node + " · " + recipe;
+  const post = await api("/api/logs", { cluster: w.cluster, node: w.node, recipe, rank: w.rank });
+  w.attach = post.attach_id;
+  preSet(w, (post.history || []).join("\n"));
+  // Re-key: a select-change swap detached the old entry under its old id.
+  for (const [k] of [...logWins.entries()]) {
+    if (logWins.get(k).win === w.win && logWins.get(k).attach !== post.attach_id) logWins.delete(k);
+  }
+  logWins.set(post.attach_id, w);
+  const note = w.win.querySelector(".lognote");
+  note.textContent = "";
+}
+function detachLog(attachId, hide = true) {
+  const w = logWins.get(attachId);
+  if (!w) return;
+  logWins.delete(attachId);
+  if (w.attach) fetch("/api/logs", { method: "DELETE", headers: { "content-type": "application/json" }, body: JSON.stringify({ attach_id: w.attach }) }).catch(() => {});
+  if (hide && w.win && w.win.isConnected) { w.win.style.display = "none"; renderDock(); }
+}
+
 // --- actions -----------------------------------------------------------------------
 const cur = () => state.snap;
 function recipeOf(cluster) {
@@ -563,10 +831,35 @@ $("#dlg-cancel").onclick = () => { $("#dlg").hidden = true; };
 document.addEventListener("keydown", (e) => { if (e.key === "Escape") $("#dlg").hidden = true; });
 
 // --- boot ---------------------------------------------------------------------------
+function initManagedWindows() {
+  const W = innerWidth, H = innerHeight;
+  wins.state = makeWin({
+    id: "win-state", title: "Cluster state", x: 12, y: 12,
+    w: Math.max(430, Math.floor(W * 0.52)), h: Math.max(320, Math.floor(H * 0.78)),
+    bodyHtml: `<div id="clusters"></div>`,
+  });
+  wins.jobs = makeWin({
+    id: "win-jobs", title: "Jobs", x: Math.max(450, Math.floor(W * 0.52) + 30), y: 12,
+    w: Math.max(320, W - Math.floor(W * 0.52) - 50), h: Math.max(320, Math.floor(H * 0.78)),
+    bodyHtml: `<div class="jobs" id="jobs"><div class="jobs-empty">no sm121 containers running, no actions yet — serve / stop / power started from this dashboard appear here</div></div>`,
+  });
+  renderDock();
+}
 async function boot() {
+  initManagedWindows();
   try {
     const res = await fetch("/api/state");
-    if (res.ok) onState(await res.json());
+    if (res.ok) {
+      const snap = await res.json();
+      onState(snap);
+      // Restore log windows from other tabs / previous session (idempotent
+      // attach: a live stream is reused, a finished one re-streams history).
+      for (const a of snap.attaches || []) {
+        if (![...logWins.values()].some((w) => w.cluster === a.cluster && w.node === a.node)) {
+          openLog(a.cluster, a.node, a.recipe);
+        }
+      }
+    }
   } catch {}
   try {
     const res = await fetch("/api/recipes");

+ 60 - 0
src/web/ui.playwright.mjs

@@ -140,6 +140,66 @@ ok(
 );
 await page.click("#dlg-cancel");
 
+// --- 9. window manager ---------------------------------------------------------
+const before = await page.locator("#win-state").boundingBox();
+const barBox = await page.locator("#win-state .win-bar").boundingBox();
+await page.mouse.move(barBox.x + barBox.width / 2, barBox.y + 5);
+await page.mouse.down();
+await page.mouse.move(barBox.x + barBox.width / 2 + 80, barBox.y + 5 + 50, { steps: 5 });
+await page.mouse.up();
+const after = await page.locator("#win-state").boundingBox();
+ok(
+  "titlebar drag moves the state window",
+  Math.abs(after.x - (before.x + 80)) < 3 && Math.abs(after.y - (before.y + 50)) < 3,
+);
+
+// --- 10. minimize / restore ------------------------------------------------------
+await page.click("#win-state .wbtn[data-act=min]");
+ok(
+  "minimize hides the window and adds a dock chip",
+  !(await page.locator("#win-state").isVisible()) &&
+    (await page.locator("#dock .dock-chip").count()) === 1,
+);
+await page.click("#dock .dock-chip");
+ok(
+  "dock chip restores the window",
+  await page.locator("#win-state").isVisible(),
+);
+
+// --- 11. log window: spawn, SSE lines, pause, close ------------------------------
+await page.evaluate(() => openLog("fake", "n0", "deepseek-v4-flash"));
+await page.waitForSelector(".logwin", { timeout: 10000 });
+ok("log window spawned (attach l1)", (await page.locator(".logwin").count()) === 1);
+await page.evaluate(() => onLog({ type: "log", attach: "l1", line: "hello-from-ui-test" }));
+ok(
+  "SSE log line lands in the window",
+  (await page.locator(".logwin .logpre").textContent()).includes("hello-from-ui-test"),
+);
+await page.click(".logwin .logpause");
+await page.evaluate(() => onLog({ type: "log", attach: "l1", line: "while-paused" }));
+ok(
+  "paused window buffers the line",
+  !(await page.locator(".logwin .logpre").textContent()).includes("while-paused"),
+);
+await page.click(".logwin .logpause");
+ok(
+  "unpause flushes the buffer",
+  (await page.locator(".logwin .logpre").textContent()).includes("while-paused"),
+);
+await page.click(".logwin .wbtn[data-act=close]");
+ok(
+  "closing the log window removes it (detach sent)",
+  (await page.locator(".logwin").count()) === 0,
+);
+
+// --- 12. node click: log window with no containers ---------------------------------
+await page.click("#clusters .node");
+ok(
+  "node click opens a log window (no containers note)",
+  (await page.locator(".logwin").count()) === 1 &&
+    (await page.locator(".logwin .lognote").textContent()).includes("no sm121 containers"),
+);
+
 // --- no page JS errors across the whole flow -------------------------------------
 ok("no page JS errors", pageErrors.length === 0, pageErrors.join("; "));