|
|
@@ -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");
|