Prechádzať zdrojové kódy

web windows: dock lists every window (recipes/raw/logs no longer minimize into nowhere), add a maximize/restore button to every titlebar, size the Topology window to the map aspect and reflow the default layout (topo+jobs left, state full height right) so nothing is dead space

Alexander Korolev 1 mesiac pred
rodič
commit
aa49a8b61e

+ 10 - 0
specs/tech-architecture/web-dashboard.md

@@ -154,6 +154,16 @@ UI outgrows ~2–3k lines.)
     move with it) so there is one implementation of the panel, not two.
   - `raw {kind: config|recipe:name|effective}` — verbatim content, monospace,
     copy button, JSON pretty for `effective`.
+- **Window manager** (vanilla, `ui.html`): absolute windows, drag by
+  titlebar, corner resize, `▢` **maximize** (fills the window layer minus
+  the dock strip; a second click restores the exact previous geometry),
+  minimize → dock chip → restore, close. Every window ever created is
+  registered in `winRegistry`, and the dock lists minimized ones from
+  there — a window kept only in a local variable (recipes/raw/logs used to
+  be) minimizes into nowhere. Default layout: left column = Topology
+  window **sized to the map's aspect ratio** (a fixed-aspect drawing
+  letterboxes ~190px of empty bands otherwise) + Jobs below it; right
+  column = Cluster state, full height — no dead space in the layer.
 - **LED grid:** rows = clusters → nodes; each node row: power LED,
   occupancy LED, per-container LEDs (Up / Created / Exited), and action
   toggles (serve/stop). Click a node → spawn its logs window. Colors:

+ 45 - 17
src/web/ui.html

@@ -419,11 +419,14 @@ $("#theme").onclick = () => {
 // 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 wins = {};      // named windows: { topo, state, jobs }
+// every window ever created (named, logs, raw, recipes) — the dock lists
+// minimized ones from here, so a window can't be minimized into nowhere
+const winRegistry = new Set();
 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 }) {
+function makeWin({ id, title, short, x, y, w, h, bodyHtml, onClosed, extraCls, removeOnClose }) {
   const win = document.createElement("section");
   win.className = "win" + (extraCls ? " " + extraCls : "");
   if (id) win.id = id;
@@ -431,6 +434,7 @@ function makeWin({ id, title, x, y, w, h, bodyHtml, onClosed, extraCls, removeOn
   const bar = document.createElement("div");
   bar.className = "win-bar";
   bar.innerHTML = `<span class="wtitle">${esc(title)}</span>
+    <button class="wbtn" data-act="max" title="maximize">▢</button>
     <button class="wbtn" data-act="min" title="minimize">–</button>
     <button class="wbtn" data-act="close" title="close">×</button>`;
   const body = document.createElement("div");
@@ -442,10 +446,32 @@ function makeWin({ id, title, x, y, w, h, bodyHtml, onClosed, extraCls, removeOn
   $("#winlayer").appendChild(win);
   const closed = { value: false };
   bar.querySelector('[data-act="min"]').onclick = () => { win.style.display = "none"; renderDock(); };
+  // maximize = fill the window layer (minus the dock strip, which hosts the
+  // + menu and holds the chips of minimized windows)
+  const maxBtn = bar.querySelector('[data-act="max"]');
+  let geom = null;
+  maxBtn.onclick = () => {
+    front(win);
+    if (geom) {
+      Object.assign(win.style, geom);
+      geom = null;
+      maxBtn.textContent = "▢";
+      maxBtn.title = "maximize";
+      return;
+    }
+    geom = { left: win.style.left, top: win.style.top, width: win.style.width, height: win.style.height };
+    win.style.left = "0px";
+    win.style.top = "0px";
+    win.style.width = $("#winlayer").clientWidth + "px";
+    win.style.height = Math.max(200, $("#winlayer").clientHeight - 40) + "px";
+    maxBtn.textContent = "❐";
+    maxBtn.title = "restore size";
+  };
   bar.querySelector('[data-act="close"]').onclick = () => {
     if (removeOnClose) win.remove(); // log windows die with their attach
     else win.style.display = "none";
     closed.value = true;
+    winRegistry.delete(handle);
     renderDock();
     if (onClosed) onClosed();
   };
@@ -470,10 +496,13 @@ function makeWin({ id, title, x, y, w, h, bodyHtml, onClosed, extraCls, removeOn
   win.addEventListener("pointerdown", () => front(win));
   const handle = {
     win, body, title,
+    label: short || title,
+    unmaximize() { if (geom) maxBtn.onclick(); },
     restore() { win.style.display = ""; closed.value = false; front(win); renderDock(); },
     isMinimized: () => win.style.display === "none",
     isClosed: () => closed.value,
   };
+  winRegistry.add(handle);
   return handle;
 }
 
@@ -495,13 +524,9 @@ function renderDock() {
     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(); });
-  }
-  if (!rawWin) chip("+ raw config / recipe / effective", openRaw);
+  const min = [...winRegistry].filter((h) => h.isMinimized() && h.win.isConnected);
+  for (const h of min) chip("– " + h.label, h.restore);
+  if (!min.length && !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";
@@ -1243,6 +1268,7 @@ async function openLog(cluster, node, recipe) {
   recipe = recipe || (n.containers[0] ? n.containers[0].recipe : "");
   const win = makeWin({
     title: "logs: " + node + (recipe ? " · " + recipe : ""),
+    short: "logs: " + node,
     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">
@@ -1562,22 +1588,24 @@ function initManagedWindows() {
   // topology map: the primary window (left); state/jobs stack on the right
   const tw = Math.max(560, Math.floor(W * 0.58));
   const rx = tw + 30, rw = Math.max(340, W - rx - 12);
-  const rh = Math.max(240, Math.floor(L * 0.48));
+  // the map is a fixed-aspect drawing: size its window to the drawing, or
+  // the SVG letterboxes itself with big empty bands above and below
+  const TH = Math.min(Math.round(tw * (TG_H / TG_W)) + 34, L - 260);
   wins.topo = makeWin({
     id: "win-topo", title: "Topology", x: 12, y: 12,
-    w: tw, h: L - 12,
+    w: tw, h: TH,
     bodyHtml: `<div id="topo"><div class="topo-empty">waiting for the first fleet scan…</div></div>`,
   });
+  wins.jobs = makeWin({
+    id: "win-jobs", title: "Jobs", x: 12, y: TH + 24,
+    w: tw, h: L - TH - 24,
+    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>`,
+  });
   wins.state = makeWin({
     id: "win-state", title: "Cluster state", x: rx, y: 12,
-    w: rw, h: rh,
+    w: rw, h: L - 12,
     bodyHtml: `<div id="clusters"><div class="skeleton"></div><div class="skeleton"></div><div class="skeleton"></div></div>`,
   });
-  wins.jobs = makeWin({
-    id: "win-jobs", title: "Jobs", x: rx, y: rh + 24,
-    w: rw, h: L - rh - 24,
-    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() {

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

@@ -341,8 +341,56 @@ ok(
   "recipes: iframe loads the mounted panel",
   (await page.locator("#win-recipes iframe").getAttribute("src")) === "/recipes",
 );
+// minimize must land in the dock (this window used to vanish: only the
+// three named windows were in the dock's registry)
+await page.click("#win-recipes .win-bar .wbtn[data-act='min']");
+ok(
+  "recipes: minimize leaves a dock chip",
+  (await page.locator('#dock .dock-chip:has-text("Recipes")').count()) === 1,
+);
+await page.click('#dock .dock-chip:has-text("Recipes")');
+await page.waitForTimeout(400); // winIn animation replays on re-display
+ok(
+  "recipes: dock chip restores the window",
+  (await page.evaluate(() => getComputedStyle(document.querySelector("#win-recipes")).display)) !== "none",
+);
+// maximize fills the layer, a second click restores the old geometry
+const wBefore = await page.evaluate(
+  () => Math.round(document.querySelector("#win-recipes").getBoundingClientRect().width),
+);
+await page.click("#win-recipes .win-bar .wbtn[data-act='max']");
+await page.waitForTimeout(400);
+const maxInfo = await page.evaluate(() => ({
+  win: Math.round(document.querySelector("#win-recipes").getBoundingClientRect().width),
+  layer: Math.round(document.querySelector("#winlayer").getBoundingClientRect().width),
+}));
+ok(
+  "recipes: maximize fills the window layer",
+  Math.abs(maxInfo.win - maxInfo.layer) < 2,
+  JSON.stringify(maxInfo),
+);
+await page.click("#win-recipes .win-bar .wbtn[data-act='max']");
+await page.waitForTimeout(400);
+ok(
+  "recipes: un-maximize restores the size",
+  Math.abs(
+    (await page.evaluate(() => Math.round(document.querySelector("#win-recipes").getBoundingClientRect().width))) - wBefore,
+  ) < 2,
+);
 await page.evaluate(() => document.querySelector("#win-recipes").style.display = "none");
 
+// the map is a fixed-aspect drawing: its window is sized to it, otherwise
+// the SVG letterboxes and the panel is half empty
+const band = await page.evaluate(() => {
+  const svg = document.querySelector("#win-topo svg");
+  const body = document.querySelector("#win-topo .win-body");
+  const vb = svg.viewBox.baseVal;
+  const bb = body.getBoundingClientRect();
+  const scale = Math.min(bb.width / vb.width, bb.height / vb.height);
+  return Math.round((bb.height - vb.height * scale) / 2);
+});
+ok("topo: window fits the drawing (no letterbox bands)", band < 8, String(band));
+
 // --- no page JS errors across the whole flow -------------------------------------
 ok("no page JS errors", pageErrors.length === 0, pageErrors.join("; "));