Browse Source

web: persist window layout (geometry/minimized/closed/maximized + open optional windows) in localStorage across reloads; recipes panel drops its own title and live chip when hosted (body.embed) and reopens from the menu when minimized; closed named windows keep a dock chip

Alexander Korolev 1 tháng trước cách đây
mục cha
commit
beda9d96ae

+ 12 - 2
specs/tech-architecture/web-dashboard.md

@@ -151,13 +151,23 @@ UI outgrows ~2–3k lines.)
     bench history, stars, the `/r/...` viewer) embedded in an iframe; the
     dashboard **nests that page at `/recipes`** (`report::html_page_at`
     rewrites the page's own absolute paths, its SSE stream and viewer links
-    move with it) so there is one implementation of the panel, not two.
+    move with it, and marks the body `embed`) so there is one implementation
+    of the panel, not two. The *hosted* copy drops its own `<h1>` branding
+    (the window bar names it) and shows its live chip only when the stream is
+    broken — the panel and the dashboard are two independent SSE streams
+    (`/recipes/events` refreshes report rows, `/events` the fleet), so the
+    chip is a fallback, not a duplicate.
   - `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. Maximized = **frameless fill**: no
+  minimize → dock chip → restore, close. **Layout persists**: geometry,
+  minimized/closed/maximized and which optional windows were open are saved
+  to `localStorage["sm121-wins"]` (`saveWinState` on drag/resize release and
+  on every state change; `restoring` suppresses writes during the multi-step
+  restore). Log windows are not in there — they come back from the server's
+  attach registry. Maximized = **frameless fill**: no
   border/radius/shadow **and no titlebar** — the window *is* the layer (flush
   to the dock strip, which is a sibling below it and stays usable), while its
   title *and* controls move into the header (`#maxinfo`: `⛶ <title>`, then

+ 7 - 0
src/report/page.html

@@ -51,6 +51,13 @@
   @keyframes ring { 0% { transform: rotate(0) } 20% { transform: rotate(13deg) } 45% { transform: rotate(-11deg) } 70% { transform: rotate(6deg) } 100% { transform: rotate(0) } }
   #bell.ring svg { animation: ring 0.6s ease; transform-origin: 50% 0; }
   .dim { color: gray; }
+  /* Embedded in the dashboard's Recipes window: the window bar already
+     carries the title and the dashboard header carries the live indicator,
+     so the page drops its own branding and shows its live chip only when
+     the stream is actually broken. */
+  body.embed h1 { display: none; }
+  body.embed #bar { margin-top: 8px; }
+  body.embed #live:not(.dim) { display: none; }
 </style>
 </head>
 <body>

+ 3 - 0
src/report/render.rs

@@ -113,4 +113,7 @@ pub(crate) fn html_page_at(rows: &[ReportRow], prefix: &str) -> String {
     }
     page.replace("/events", &format!("{prefix}/events"))
         .replace("/r/", &format!("{prefix}/r/"))
+        // marks the page as hosted (dashboard window): no own branding, and
+        // the live chip only warns on a broken stream
+        .replacen("<body>", "<body class=\"embed\">", 1)
 }

+ 9 - 0
src/report/ui_tests.rs

@@ -196,6 +196,15 @@ fn html_page_at_rewrites_own_paths() {
         !super::html_page(&[]).contains("/recipes"),
         "unprefixed page must stay clean"
     );
+    // hosted copy is marked so it can drop its own header/live chrome
+    assert!(
+        page.contains("<body class=\"embed\">"),
+        "embed marker missing"
+    );
+    assert!(
+        super::html_page(&[]).contains("<body>"),
+        "standalone page keeps its unmarked body"
+    );
 }
 
 #[test]

+ 114 - 22
src/web/ui.html

@@ -458,6 +458,36 @@ function hideMaxTitle(h) {
   $("#maxinfo").hidden = true;
 }
 
+// --- window layout persistence -------------------------------------------
+// The dashboard has no session: geometry, minimized/closed/maximized and
+// which optional windows are open are kept in localStorage and restored on
+// load (log windows come back from the server's attach registry instead).
+const WS_KEY = "sm121-wins";
+let restoring = false; // don't write half-restored state back
+function readWinState() {
+  try { return JSON.parse(localStorage.getItem(WS_KEY) || "{}"); } catch { return {}; }
+}
+function saveWinState() {
+  if (restoring) return;
+  const st = {};
+  const put = (k, h) => {
+    st[k] = { ...h.geometry(), min: h.isMinimized(), closed: h.isClosed(), max: h.isMaximized() };
+  };
+  for (const [k, h] of Object.entries(wins)) put(k, h);
+  if (recipesWin) put("recipes", recipesWin);
+  if (rawWin) put("raw", rawWin);
+  try { localStorage.setItem(WS_KEY, JSON.stringify(st)); } catch { /* private mode */ }
+}
+// Re-open an optional window (recipes/raw) exactly as it was left.
+function restoreWindow(key, open) {
+  const st = readWinState()[key];
+  if (!st || st.closed) return;
+  Promise.resolve(open(st)).then((h) => {
+    if (st.max) h.maximize();
+    else if (st.min) h.minimize();
+  });
+}
+
 // Windows keep px geometry, so a shrinking layer (dock appears, browser
 // resize) would push their bottom edge out of view — clamp them back in.
 function fitToLayer() {
@@ -498,6 +528,7 @@ function makeWin({ id, title, short, x, y, w, h, bodyHtml, onClosed, extraCls, r
     unmax(); // minimize a normal-shaped window; the dock chip restores it
     win.style.display = "none";
     renderDock();
+    saveWinState();
   };
   bar.querySelector('[data-act="min"]').onclick = doMin;
   // maximize = drop the frame and fill the window layer (the dock is a
@@ -512,6 +543,7 @@ function makeWin({ id, title, short, x, y, w, h, bodyHtml, onClosed, extraCls, r
     maxBtn.textContent = "▢";
     maxBtn.title = "maximize";
     hideMaxTitle(handle);
+    saveWinState();
   };
   maxBtn.onclick = () => {
     front(win);
@@ -525,14 +557,21 @@ function makeWin({ id, title, short, x, y, w, h, bodyHtml, onClosed, extraCls, r
     maxBtn.textContent = "❐";
     maxBtn.title = "restore size";
     showMaxTitle(handle);
+    saveWinState();
   };
   const doClose = () => {
-    if (removeOnClose) win.remove(); // log windows die with their attach
-    else win.style.display = "none";
     closed.value = true;
     hideMaxTitle(handle);
-    winRegistry.delete(handle);
+    if (removeOnClose) {
+      // log/recipes/raw: gone for good (reopened from the + menu)
+      win.remove();
+      winRegistry.delete(handle);
+    } else {
+      // named windows stay addressable: the dock offers a + chip
+      win.style.display = "none";
+    }
     renderDock();
+    saveWinState();
     if (onClosed) onClosed();
   };
   bar.querySelector('[data-act="close"]').onclick = doClose;
@@ -540,19 +579,29 @@ function makeWin({ id, title, short, x, y, w, h, bodyHtml, onClosed, extraCls, r
     if (e.target.closest(".wbtn") || win.classList.contains("max")) 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";
-    });
+    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";
+      },
+      saveWinState,
+    );
   });
   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";
-    });
+    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";
+      },
+      saveWinState,
+    );
   });
   win.addEventListener("pointerdown", () => front(win));
   const handle = {
@@ -561,6 +610,21 @@ function makeWin({ id, title, short, x, y, w, h, bodyHtml, onClosed, extraCls, r
     unmaximize: unmax,
     minimize: doMin,
     close: doClose,
+    maximize: () => maxBtn.onclick(),
+    isMaximized: () => !!geom,
+    setGeometry(g) {
+      if (!g) return;
+      const px = (v, d) => (Number.isFinite(parseFloat(v)) ? parseFloat(v) : d);
+      win.style.left = Math.max(0, px(g.left, 0)) + "px";
+      win.style.top = Math.max(0, px(g.top, 0)) + "px";
+      win.style.width = Math.max(240, px(g.width, 600)) + "px";
+      win.style.height = Math.max(140, px(g.height, 400)) + "px";
+    },
+    // the normal (un-maximized) rect — that is what gets persisted
+    geometry: () => {
+      const g = geom || { left: win.style.left, top: win.style.top, width: win.style.width, height: win.style.height };
+      return { left: g.left, top: g.top, width: g.width, height: g.height };
+    },
     restore() { win.style.display = ""; closed.value = false; front(win); renderDock(); },
     isMinimized: () => win.style.display === "none",
     isClosed: () => closed.value,
@@ -569,10 +633,14 @@ function makeWin({ id, title, short, x, y, w, h, bodyHtml, onClosed, extraCls, r
   return handle;
 }
 
-function drag(sx, sy, move) {
+function drag(sx, sy, move, done) {
   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); };
+  const up = () => {
+    removeEventListener("pointermove", mv);
+    removeEventListener("pointerup", up);
+    if (done) done();
+  };
   addEventListener("pointermove", mv);
   addEventListener("pointerup", up);
 }
@@ -588,7 +656,8 @@ function renderDock() {
     dock.appendChild(b);
   };
   const min = [...winRegistry].filter((h) => h.isMinimized() && h.win.isConnected);
-  for (const h of min) chip("– " + h.label, h.restore);
+  // "– " = minimized (still open), "+ " = closed (bring it back)
+  for (const h of min) chip((h.isClosed() ? "+ " : "– ") + 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");
@@ -1425,22 +1494,27 @@ let recipesWin = null;
 // server at /recipes and embedded as-is: table, grouping, stars, the /r/
 // viewer and live updates stay one implementation (paths prefix-rewritten
 // server-side, theme inherited through the iframe's color-scheme).
-function openRecipes() {
-  if (recipesWin) { front(recipesWin.win); return; }
+function openRecipes(g) {
+  if (recipesWin) { recipesWin.restore(); return recipesWin; } // also un-hides a minimized one
+  const r = (k, d) => (g && Number.isFinite(parseFloat(g[k])) ? parseFloat(g[k]) : d);
   recipesWin = makeWin({
-    id: "win-recipes", title: "Recipes", x: 24, y: 24,
-    w: Math.min(1180, innerWidth - 48), h: Math.min(760, innerHeight - 80),
+    id: "win-recipes", title: "Recipes", x: r("left", 24), y: r("top", 24),
+    w: r("width", Math.min(1180, innerWidth - 48)), h: r("height", Math.min(760, innerHeight - 80)),
     bodyHtml: `<iframe src="/recipes" title="recipes report"></iframe>`,
     extraCls: "recwin",
     removeOnClose: true,
     onClosed: () => { recipesWin = null; },
   });
+  saveWinState();
+  return recipesWin;
 }
 
-async function openRaw() {
-  if (rawWin) { front(rawWin); return; }
+async function openRaw(g) {
+  if (rawWin) { rawWin.restore(); return rawWin; }
+  const r = (k, d) => (g && Number.isFinite(parseFloat(g[k])) ? parseFloat(g[k]) : d);
   const handle = makeWin({
-    title: "raw", x: 20, y: 20, w: Math.min(680, innerWidth - 40), h: Math.min(460, innerHeight * 0.65),
+    title: "raw", x: r("left", 20), y: r("top", 20),
+    w: r("width", Math.min(680, innerWidth - 40)), h: r("height", Math.min(460, innerHeight * 0.65)),
     bodyHtml: `<div class="rawbar">
       <select></select>
       <button class="abtn rawcopy">copy</button>
@@ -1450,7 +1524,7 @@ async function openRaw() {
     removeOnClose: true,
     onClosed: () => { rawWin = null; },
   });
-  rawWin = handle.win;
+  rawWin = handle;
   const sel = handle.body.querySelector("select");
   const pre = handle.body.querySelector(".rawpre");
   const note = handle.body.querySelector(".rawnote");
@@ -1496,6 +1570,8 @@ async function openRaw() {
   sel.onchange = load;
   fill(); // config/effective are always available; recipes fill in later
   state.onRecipesReady = fill; // recipes may arrive after the first open
+  saveWinState();
+  return handle;
 }
 
 // --- actions -----------------------------------------------------------------------
@@ -1670,6 +1746,19 @@ function initManagedWindows() {
     w: rw, h: L - 12,
     bodyHtml: `<div id="clusters"><div class="skeleton"></div><div class="skeleton"></div><div class="skeleton"></div></div>`,
   });
+  // last session's layout wins over the defaults
+  const st = readWinState();
+  restoring = true;
+  for (const [k, h] of Object.entries(wins)) {
+    const g = st[k];
+    if (!g) continue;
+    if (g.closed) { h.close(); continue; }
+    h.setGeometry(g);
+    if (g.max) h.maximize();
+    else if (g.min) h.minimize();
+  }
+  restoring = false;
+  fitToLayer();
   renderDock();
 }
 async function boot() {
@@ -1700,6 +1789,9 @@ async function boot() {
   connectSSE();
   // 30 s heartbeat refresh (mirrors the server poll; harmless if state is identical)
   addEventListener("resize", fitToLayer);
+  // optional windows (their own SSE/API calls are cheap and idempotent)
+  restoreWindow("recipes", openRecipes);
+  restoreWindow("raw", openRaw);
   setInterval(async () => {
     try {
       const res = await fetch("/api/state");

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

@@ -348,6 +348,24 @@ ok(
   "recipes: iframe loads the mounted panel",
   (await page.locator("#win-recipes iframe").getAttribute("src")) === "/recipes",
 );
+// hosted copy drops its own branding (the window bar names it) and keeps
+// its live chip only as a warning — the dashboard header already says live
+ok(
+  "recipes: embedded page hides its own title",
+  (await recipes.locator("h1").isVisible()) === false,
+);
+ok(
+  "recipes: embedded page hides the live chip while the stream is fine",
+  (await recipes.locator("#live").isVisible()) === false,
+);
+ok(
+  "recipes: embedded page keeps its filter controls",
+  (await recipes.locator("#bar").isVisible()) === true,
+);
+ok(
+  "recipes: body is marked as hosted (one implementation, adapted chrome)",
+  (await recipes.locator("body").getAttribute("class")) === "embed",
+);
 // 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']");
@@ -461,6 +479,46 @@ const flush = await page.evaluate(() => {
 });
 ok("windows end flush at the dock strip (no dead space)", Math.abs(flush) <= 1, String(flush));
 
+// --- 15. window layout survives a reload (localStorage) ---------------------------
+await page.evaluate(() => {
+  const t = document.querySelector("#win-topo");
+  t.style.left = "40px"; t.style.top = "20px"; t.style.width = "700px"; t.style.height = "300px";
+  document.querySelector("#win-state .win-bar .wbtn[data-act='min']").click();
+  document.querySelector("#win-jobs .win-bar .wbtn[data-act='close']").click();
+});
+await page.click("#dock .plus");
+await page.click('.dockmenu .dock-chip:text-is("recipes")');
+await page.waitForSelector("#win-recipes iframe");
+await page.waitForTimeout(400);
+await page.evaluate(() => document.querySelector("#win-recipes .win-bar .wbtn[data-act='max']").click());
+await page.waitForTimeout(500);
+const laidOut = await page.evaluate(() => ({
+  saved: JSON.parse(localStorage.getItem("sm121-wins") || "null"),
+  topo: (() => { const b = document.querySelector("#win-topo").getBoundingClientRect(); return [Math.round(b.x), Math.round(b.y), Math.round(b.width), Math.round(b.height)]; })(),
+}));
+ok("layout: state written to localStorage", !!laidOut.saved?.topo?.width, JSON.stringify(laidOut.saved?.topo));
+await page.reload({ waitUntil: "domcontentloaded" });
+await page.waitForTimeout(2500);
+const back = await page.evaluate(() => {
+  const box = (sel) => { const e = document.querySelector(sel); if (!e) return null; const b = e.getBoundingClientRect(); return [Math.round(b.x), Math.round(b.y), Math.round(b.width), Math.round(b.height)]; };
+  return {
+    topo: box("#win-topo"),
+    stateMin: getComputedStyle(document.querySelector("#win-state")).display === "none",
+    jobsClosed: getComputedStyle(document.querySelector("#win-jobs")).display === "none",
+    recipesMax: document.querySelector("#win-recipes")?.classList.contains("max") ?? false,
+    chips: [...document.querySelectorAll("#dock .dock-chip")].map((c) => c.textContent),
+    header: document.querySelector("#maxinfo").getBoundingClientRect().width === 0 ? "" : document.querySelector("#max-title").textContent,
+  };
+});
+ok(
+  "layout: geometry restored after reload",
+  JSON.stringify(back.topo) === JSON.stringify(laidOut.topo),
+  `${JSON.stringify(laidOut.topo)} -> ${JSON.stringify(back.topo)}`,
+);
+ok("layout: minimized window stays minimized", back.stateMin);
+ok("layout: closed window stays closed, with a dock chip to reopen", back.jobsClosed && back.chips.includes("+ Jobs"), JSON.stringify(back.chips));
+ok("layout: maximized recipes window restored maximized", back.recipesMax && back.header === "Recipes", back.header);
+
 // --- no page JS errors across the whole flow -------------------------------------
 ok("no page JS errors", pageErrors.length === 0, pageErrors.join("; "));