Bladeren bron

web: close the UI<->API gap — every server capability reachable

Audit of 'each available object has a best UI variant' found the API
accepting more than the UI could express:

- serve: solo / force / fit (=N or bare) / clear / mirror (default path
  or explicit) / target node / keep — all were in ServeReq but the UI
  only sent dry_run. Now a '⚙ flags' expander in the action bar; body
  built by pure serveBody() (FLAGS-LT block) probed in node like
  sameSnapshot (absent keys omitted, fit/mirror path-wins-over-bare,
  bare = empty string).
- stop: per-node scope (StopReq.node) was unreachable — the confirm
  dialog now offers all-nodes / one node.
- logs: node rows carried a tooltip-only affordance — visible '▤ logs'
  button per node (row stays clickable; re-click fronts, no duplicate).
- dock: '+' new-window menu (state / jobs / logs… / raw) per the plan's
  window-kinds table; logs… opens a node picker (★ = running
  containers). The raw chip is now a menu item too.

Playwright 31 → 36 checks (stop node select, logs button + dedupe, +
menu). Live: dry-run serve on qwen38 through the UI posted
{solo,force,fit:'1',dry_run} exactly.
Alexander Korolev 2 maanden geleden
bovenliggende
commit
4f1837d856
3 gewijzigde bestanden met toevoegingen van 237 en 10 verwijderingen
  1. 53 0
      src/web/tests.rs
  2. 162 7
      src/web/ui.html
  3. 22 3
      src/web/ui.playwright.mjs

+ 53 - 0
src/web/tests.rs

@@ -238,6 +238,59 @@ console.log('ok');",
     let _ = std::fs::remove_file(&js2);
 }
 
+/// serveBody (FLAGS-LT block): the serve flags → API body mapping is the
+/// trickiest pure logic in the UI — probe it in node like sameSnapshot.
+#[test]
+fn ui_js_serve_body_logic() {
+    let html = include_str!("ui.html");
+    if std::process::Command::new("node")
+        .arg("--version")
+        .output()
+        .is_err()
+    {
+        eprintln!("SKIP ui_js_serve_body_logic: node not available");
+        return;
+    }
+    let script = extract_script(html);
+    let s = script.find("/*FLAGS-LT:START*/").unwrap() + "/*FLAGS-LT:START*/".len();
+    let e = script.find("/*FLAGS-LT:END*/").unwrap();
+    let pid = std::process::id();
+    let probe = format!(
+        "const state = {{ flags: {{}} }};\n{fn}\n\n\
+         // all flags set: everything passes through\n\
+         let b = serveBody('c', 'r', true, {{ node: 'n9', solo: true, force: true, clear: true, keep: true, fit: '2', fitAll: false, mirror: '/m', mirrorAll: true }});\n\
+         if (b.node !== 'n9' || b.solo !== true || b.force !== true || b.clear !== true || b.keep !== true || b.dry_run !== true) throw new Error('flag passthrough: ' + JSON.stringify(b));\n\
+         if (b.fit !== '2') throw new Error('fit N must win over bare: ' + b.fit);\n\
+         if (b.mirror !== '/m') throw new Error('mirror path must win over bare: ' + b.mirror);\n\
+         // bare fit / bare mirror (empty strings = whole cluster / default path)\n\
+         b = serveBody('c', 'r', false, {{ fit: '', fitAll: true }});\n\
+         if (b.fit !== '') throw new Error('bare fit');\n\
+         b = serveBody('c', 'r', false, {{ mirror: '', mirrorAll: true }});\n\
+         if (b.mirror !== '') throw new Error('bare mirror');\n\
+         // no flags: only cluster/recipe/dry_run — server defaults win\n\
+         b = serveBody('c', 'r', true, {{}});\n\
+         for (const k of ['node', 'solo', 'force', 'clear', 'keep', 'fit', 'mirror']) {{\n\
+           if (k in b) throw new Error('absent flag must be omitted: ' + k);\n\
+         }}\n\
+         // flagsOf caches per cluster\n\
+         if (flagsOf('x') !== flagsOf('x')) throw new Error('flagsOf must cache');\n\
+         console.log('ok');",
+        fn = &script[s..e]
+    );
+    let js2 = std::env::temp_dir().join(format!("sm121-ui-flags-{pid}.js"));
+    std::fs::write(&js2, &probe).unwrap();
+    let out = std::process::Command::new("node")
+        .arg(&js2)
+        .output()
+        .unwrap();
+    assert!(
+        out.status.success(),
+        "serveBody probe failed: {}",
+        String::from_utf8_lossy(&out.stderr)
+    );
+    let _ = std::fs::remove_file(&js2);
+}
+
 /// Two offline clusters (both nodes on refused loopback ports) — the
 /// fleet the Playwright UI checks render: chip strip with two chips,
 /// down-cluster action bar, type-the-name power flow.

+ 162 - 7
src/web/ui.html

@@ -210,6 +210,43 @@
   .abtn:disabled { animation: pulse 1.2s infinite; }
   .job.jjob .jdot { animation: pulse 1.2s infinite; }
 
+  /* --- serve flags (CLI parity) + node logs button + dock menu ---------------- */
+  .fnode {
+    background: var(--btn); color: var(--muted); border: 1px solid var(--border);
+    border-radius: 6px; padding: 2px 6px; font-size: 12px; height: 26px; max-width: 140px;
+  }
+  .flags summary {
+    cursor: pointer; font-size: 12px; color: var(--muted); height: 26px;
+    display: inline-flex; align-items: center; padding: 0 8px;
+    border: 1px dashed var(--border); border-radius: 6px; user-select: none;
+  }
+  .flags summary:hover { color: var(--fg); border-color: var(--accent); }
+  .flags-in {
+    display: flex; gap: 10px; flex-wrap: wrap; margin-top: 8px;
+    padding: 8px; background: var(--btn); border-radius: 6px;
+  }
+  .flags-in label { font-size: 12px; color: var(--muted); display: inline-flex; align-items: center; gap: 4px; }
+  .flags-in input[type=checkbox] {
+    appearance: none; -webkit-appearance: none; width: 14px; height: 14px; margin: 0;
+    border: 1.5px solid var(--border); border-radius: 4px; background: var(--bg);
+    cursor: pointer; position: relative;
+  }
+  .flags-in input[type=checkbox]:checked { background: var(--accent); border-color: var(--accent); }
+  .flags-in input[type=checkbox]:checked::after {
+    content: ""; position: absolute; left: 3px; top: 0; width: 3.5px; height: 7px;
+    border: solid #fff; border-width: 0 2px 2px 0; transform: rotate(45deg);
+  }
+  .flags-in input[type=text] {
+    background: var(--bg); color: var(--fg); border: 1px solid var(--border);
+    border-radius: 5px; padding: 1px 5px; font: inherit; font-size: 12px; height: 22px;
+  }
+  .logsbtn { margin-left: auto; height: 20px; font-size: 11px; padding: 0 8px; flex-shrink: 0; }
+  .dockmenu {
+    position: fixed; bottom: 44px; right: 12px; display: flex; flex-direction: column; gap: 4px;
+    background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 8px;
+    box-shadow: 0 12px 40px rgba(0,0,0,.5); z-index: 300; animation: winIn .15s ease both;
+  }
+
   /* --- confirm dialog ------------------------------------------------------- */
   .dlg-back[hidden] { display: none; }
   .dlg-back {
@@ -263,7 +300,32 @@
 "use strict";
 const $ = (s) => document.querySelector(s);
 const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
-const state = { snap: null, jobs: new Map(), recipes: [], active: localStorage.getItem("sm121-active") || "", panel: null, shown: new Set() };
+const state = { snap: null, jobs: new Map(), recipes: [], active: localStorage.getItem("sm121-active") || "", panel: null, shown: new Set(), flags: {} };
+
+/*FLAGS-LT:START*/
+// Pure: build the POST /api/serve body from cluster + recipe + dry + the
+// per-cluster flags state. Absent keys are omitted (server defaults win):
+// fit "" = bare --fit (whole cluster), mirror "" = bare --mirror (default
+// path); a non-empty path wins over the bare checkbox.
+function serveBody(cluster, recipe, dry, f) {
+  const body = { cluster, recipe, dry_run: !!dry };
+  if (f.node) body.node = f.node;
+  if (f.solo) body.solo = true;
+  if (f.force) body.force = true;
+  if (f.clear) body.clear = true;
+  if (f.keep) body.keep = true;
+  const fit = String(f.fit || "").trim();
+  if (fit) body.fit = fit;
+  else if (f.fitAll) body.fit = "";
+  const mir = String(f.mirror || "").trim();
+  if (mir) body.mirror = mir;
+  else if (f.mirrorAll) body.mirror = "";
+  return body;
+}
+function flagsOf(cluster) {
+  return (state.flags[cluster] = state.flags[cluster] || { node: "", solo: false, force: false, clear: false, keep: false, fit: "", fitAll: false, mirror: "", mirrorAll: false });
+}
+/*FLAGS-LT:END*/
 
 /*SNAP-LT:START*/
 // Pure: same fleet + jobs? (`ts` changes on every probe, so it is
@@ -368,8 +430,57 @@ function renderDock() {
   }
   for (const w of logWins.values()) {
     if (w.win.style.display === "none") chip("– logs: " + w.node, () => { w.win.style.display = ""; front(w.win); renderDock(); });
+  }
   if (!rawWin) chip("+ raw config / recipe / effective", openRaw);
+  // New-window menu (plan): State / Logs / Jobs / Raw.
+  const plus = document.createElement("button");
+  plus.className = "dock-chip plus";
+  plus.textContent = "+";
+  plus.title = "new window";
+  plus.onclick = (e) => { e.stopPropagation(); toggleDockMenu(); };
+  dock.appendChild(plus);
+}
+let dockMenuEl = null;
+function closeDockMenu() { if (dockMenuEl) { dockMenuEl.remove(); dockMenuEl = null; } }
+function toggleDockMenu() {
+  if (dockMenuEl) { closeDockMenu(); return; }
+  const m = document.createElement("div");
+  m.className = "dockmenu";
+  const item = (label, fn) => {
+    const b = document.createElement("button");
+    b.className = "dock-chip";
+    b.textContent = label;
+    b.onclick = () => { closeDockMenu(); fn(); };
+    m.appendChild(b);
+  };
+  item("state", () => { (wins.state.isMinimized() ? wins.state.restore() : front(wins.state.win)); });
+  item("jobs", () => { (wins.jobs.isMinimized() ? wins.jobs.restore() : front(wins.jobs.win)); });
+  item("logs…", openLogsPicker);
+  item("raw", openRaw);
+  document.body.appendChild(m);
+  dockMenuEl = m;
+  setTimeout(() => addEventListener("click", closeDockMenu, { once: true }), 0);
+}
+
+// Open a logs window from the dock: pick a node (★ = running containers).
+function openLogsPicker() {
+  const snap = state.snap;
+  const body = document.createElement("div");
+  body.className = "body";
+  const sel = document.createElement("select");
+  sel.style.cssText = "width:100%;margin:8px 0;padding:6px;background:var(--btn);color:var(--fg);border:1px solid var(--border);border-radius:6px;";
+  if (snap) for (const c of snap.clusters) for (const n of c.nodes) {
+    const o = document.createElement("option");
+    o.value = c.name + "|" + n.name;
+    o.textContent = (n.containers.length ? "★ " : "") + c.name + " / " + n.name + (n.containers.length ? " (" + n.containers.length + ")" : "");
+    sel.appendChild(o);
   }
+  body.innerHTML = `Open a logs window for a node (★ = has sm121 containers).`;
+  body.appendChild(sel);
+  openDialogHtml("open logs", body, () => {
+    const i = sel.value.indexOf("|");
+    openLog(sel.value.slice(0, i), sel.value.slice(i + 1));
+  });
 }
 
 // --- SSE -------------------------------------------------------------------
@@ -427,7 +538,9 @@ function renderCluster(c, prev, animate) {
     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>`;
+      <span class="nmeta" title="${esc(n.ssh_ip)}">${esc(meta(n, c))}</span>
+      <button class="abtn logsbtn" title="open logs for ${esc(n.name)}">▤ logs</button>`;
+    row.querySelector(".logsbtn").onclick = (e) => { e.stopPropagation(); openLog(c.name, n.name); };
     row.onclick = () => openLog(c.name, n.name);
     el.appendChild(row);
     if (n.occupied && n.containers.length) {
@@ -471,6 +584,41 @@ function actionBar(c, prevNodes) {
   const dry = document.createElement("label");
   dry.innerHTML = `<input type="checkbox" id="dry-${esc(c.name)}"> dry-run`;
   bar.appendChild(dry);
+  // Serve flags (CLI parity): solo / force / clear / keep / fit / mirror / node.
+  // Values persist in state.flags across re-renders.
+  const f = flagsOf(c.name);
+  const nodeSel = document.createElement("select");
+  nodeSel.className = "fnode";
+  nodeSel.title = "target node (default: cluster selection)";
+  nodeSel.innerHTML = `<option value=''>node: auto</option>` + c.nodes.map((n) => `<option value='${esc(n.name)}'>${esc(n.name)}</option>`).join("");
+  nodeSel.value = f.node || "";
+  nodeSel.onchange = () => { f.node = nodeSel.value; };
+  const det = document.createElement("details");
+  det.className = "flags";
+  det.innerHTML = `<summary title="solo · force · fit · clear · mirror · keep (keep containers after stop)">⚙ flags</summary>
+    <div class="flags-in">
+      <label><input type="checkbox" id="fsolo-${esc(c.name)}"> solo</label>
+      <label><input type="checkbox" id="fforce-${esc(c.name)}"> force</label>
+      <label><input type="checkbox" id="fclear-${esc(c.name)}" title="clear cgroup memory limits (sudo) on launch nodes"> clear</label>
+      <label><input type="checkbox" id="fkeep-${esc(c.name)}" title="keep containers after stop"> keep</label>
+      <label title="scale TP to N nodes / whole cluster">fit <input type="checkbox" id="ffitb-${esc(c.name)}" title="bare --fit: whole cluster">all <input type="text" id="ffit-${esc(c.name)}" placeholder="N" size="3"></label>
+      <label title="per-node HF mirror">mirror <input type="checkbox" id="fmirr-${esc(c.name)}" title="bare --mirror: ~/.cache/hf-models">~ <input type="text" id="fmirror-${esc(c.name)}" placeholder="~/.cache/hf-models" size="18"></label>
+    </div>`;
+  const wire = (id, key, isText) => {
+    const el = det.querySelector("#" + id);
+    if (isText) { el.value = f[key] || ""; el.oninput = () => { f[key] = el.value; }; }
+    else { el.checked = !!f[key]; el.onchange = () => { f[key] = el.checked; }; }
+  };
+  wire("fsolo-" + c.name, "solo");
+  wire("fforce-" + c.name, "force");
+  wire("fclear-" + c.name, "clear");
+  wire("fkeep-" + c.name, "keep");
+  wire("ffitb-" + c.name, "fitAll");
+  wire("ffit-" + c.name, "fit", true);
+  wire("fmirr-" + c.name, "mirrorAll");
+  wire("fmirror-" + c.name, "mirror", true);
+  bar.appendChild(det);
+  bar.appendChild(nodeSel);
   bar.appendChild(abtn("serve-" + c.name, "serve", () => doServe(c.name)));
   bar.appendChild(abtn("stop-" + c.name, "stop", () => doStop(c.name), "danger"));
   bar.appendChild(abtn("power-" + c.name, "power off", () => doPower("off", c.name), "danger"));
@@ -795,9 +943,8 @@ async function api(path, body) {
 function doServe(cluster) {
   const recipe = recipeOf(cluster);
   if (!recipe) { alert("pick a recipe first"); return; }
-  const dry = dryOf(cluster);
-  const body = { cluster, recipe, dry_run: dry };
-  if (dry) {
+  const body = serveBody(cluster, recipe, dryOf(cluster), flagsOf(cluster));
+  if (body.dry_run) {
     api("/api/serve", body).then((r) => openDialog("dry-run: " + r.job, "started (watch the jobs window)", () => {}));
     return;
   }
@@ -811,11 +958,19 @@ function doStop(cluster) {
   const containers = c ? c.nodes.flatMap((n) => n.containers) : [];
   const recipe = recipeOf(cluster);
   if (!recipe) { alert("pick the recipe to stop first"); return; }
+  const body = document.createElement("div");
+  body.className = "body";
+  const nodeSel = document.createElement("select");
+  nodeSel.id = "stopnode";
+  nodeSel.title = "stop on one node instead of all";
+  nodeSel.innerHTML = `<option value=''>all nodes</option>` + (c ? c.nodes.map((n) => `<option value='${esc(n.name)}'>${esc(n.name)}</option>`).join("") : "");
   const list = containers.length
     ? `<ul class="cbox">${containers.map((x) => `<li><b>${esc(x.name)}</b> — ${esc(x.status)}</li>`).join("")}</ul>`
     : `<div class="cbox">no containers detected on ${esc(cluster)} (recipe may not be running here)</div>`;
-  openDialog(`stop ${recipe}`, `Will run <code>sm121 stop</code> for <code>${esc(recipe)}</code> on <code>${esc(cluster)}</code>. Currently running:${list}`, () => {
-    api("/api/stop", { cluster, recipe }).catch((e) => openDialog("stop failed", esc(e.message), () => {}, true));
+  body.innerHTML = `Will run <code>sm121 stop</code> for <code>${esc(recipe)}</code> on <code>${esc(cluster)}</code>. Node: <span data-stopnode></span>. Currently running:${list}`;
+  body.querySelector("[data-stopnode]").replaceWith(nodeSel);
+  openDialogHtml(`stop ${recipe}`, body, () => {
+    api("/api/stop", { cluster, recipe, node: nodeSel.value || undefined }).catch((e) => openDialog("stop failed", esc(e.message), () => {}, true));
   });
 }
 function doPower(action, cluster) {

+ 22 - 3
src/web/ui.playwright.mjs

@@ -138,6 +138,11 @@ ok(
   (await page.locator("#dlg").isVisible()) &&
     (await page.locator("#dlg-body").textContent()).includes("no containers detected"),
 );
+ok(
+  "stop dialog offers per-node scope",
+  (await page.locator("#stopnode option").count()) === 2 &&
+    (await page.locator("#stopnode option").last().textContent()).includes("n1"),
+);
 await page.click("#dlg-cancel");
 
 // --- 9. window manager ---------------------------------------------------------
@@ -199,11 +204,25 @@ ok(
   (await page.locator(".logwin").count()) === 1 &&
     (await page.locator(".logwin .lognote").textContent()).includes("no sm121 containers"),
 );
+ok(
+  "visible logs button exists on the node row",
+  (await page.locator("#clusters .node .logsbtn").count()) >= 1,
+);
+await page.click("#clusters .node .logsbtn");
+ok(
+  "logs button on an already-open node fronts, not duplicates",
+  (await page.locator(".logwin").count()) === 1,
+);
 
-// --- 13. raw window ------------------------------------------------------------
-await page.click('#dock .dock-chip:has-text("raw")');
+// --- 13. raw window (via the dock + new-window menu) -------------------------------
+await page.click("#dock .plus");
+ok(
+  "dock + menu lists state / jobs / logs / raw",
+  (await page.locator(".dockmenu .dock-chip").count()) === 4,
+);
+await page.click('.dockmenu .dock-chip:has-text("raw")');
 await page.waitForSelector(".rawwin", { timeout: 10000 });
-ok("raw window opens from the dock", (await page.locator(".rawwin").count()) === 1);
+ok("raw window opens via the new-window menu", (await page.locator(".rawwin").count()) === 1);
 await page.selectOption(".rawwin select", "effective");
 await page.waitForFunction(
   () => (document.querySelector(".rawwin .rawpre").textContent || "").includes('"clusters"'),