|
@@ -20,9 +20,11 @@
|
|
|
--btn: #f6f8fa; --btn-hover: #eaeef2;
|
|
--btn: #f6f8fa; --btn-hover: #eaeef2;
|
|
|
}
|
|
}
|
|
|
* { box-sizing: border-box; }
|
|
* { box-sizing: border-box; }
|
|
|
|
|
+ html, body { height: 100%; }
|
|
|
body {
|
|
body {
|
|
|
margin: 0; background: var(--bg); color: var(--fg);
|
|
margin: 0; background: var(--bg); color: var(--fg);
|
|
|
font: 14px/1.45 ui-sans-serif, system-ui, "SF Pro", sans-serif;
|
|
font: 14px/1.45 ui-sans-serif, system-ui, "SF Pro", sans-serif;
|
|
|
|
|
+ display: flex; flex-direction: column;
|
|
|
transition: background .25s, color .25s;
|
|
transition: background .25s, color .25s;
|
|
|
}
|
|
}
|
|
|
header {
|
|
header {
|
|
@@ -41,13 +43,45 @@
|
|
|
border-radius: 6px; padding: 4px 10px; cursor: pointer; font: inherit; font-size: 13px;
|
|
border-radius: 6px; padding: 4px 10px; cursor: pointer; font: inherit; font-size: 13px;
|
|
|
}
|
|
}
|
|
|
button.tbtn:hover { background: var(--btn-hover); }
|
|
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.anim { animation: winIn .35s ease both; }
|
|
|
.cluster { margin-bottom: 14px; }
|
|
.cluster { margin-bottom: 14px; }
|
|
|
.cluster-head { display: flex; align-items: baseline; gap: 8px; margin-bottom: 6px; }
|
|
.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 .free { color: var(--muted); font-size: 12px; }
|
|
|
.cluster-head .down { color: var(--led-off); font-size: 12px; }
|
|
.cluster-head .down { color: var(--led-off); font-size: 12px; }
|
|
|
.cluster-head .soft { color: var(--led-busy); 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; }
|
|
.node:last-child { border-bottom: none; }
|
|
|
.led {
|
|
.led {
|
|
|
width: 11px; height: 11px; border-radius: 50%; background: var(--led-off);
|
|
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; }
|
|
.jmsg { color: var(--muted); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
|
.jjob .jmsg { color: var(--fg); }
|
|
.jjob .jmsg { color: var(--fg); }
|
|
|
.jobs-empty { color: var(--muted); font-size: 12.5px; padding: 6px 0; }
|
|
.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[hidden] { display: none; }
|
|
|
.dlg-back {
|
|
.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;
|
|
display: flex; align-items: center; justify-content: center; animation: fadeIn .15s ease;
|
|
|
}
|
|
}
|
|
|
.dlg {
|
|
.dlg {
|
|
@@ -145,7 +214,7 @@
|
|
|
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
|
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
|
|
@keyframes winIn { from { opacity: 0; transform: translateY(-4px) scale(.985); } to { opacity: 1; transform: none; } }
|
|
@keyframes winIn { from { opacity: 0; transform: translateY(-4px) scale(.985); } to { opacity: 1; transform: none; } }
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
@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>
|
|
</style>
|
|
|
</head>
|
|
</head>
|
|
@@ -156,10 +225,8 @@
|
|
|
<div class="ssec" id="sse"><span class="dot"></span><span id="sse-t">connecting…</span></div>
|
|
<div class="ssec" id="sse"><span class="dot"></span><span id="sse-t">connecting…</span></div>
|
|
|
<button class="tbtn" id="theme">◐</button>
|
|
<button class="tbtn" id="theme">◐</button>
|
|
|
</header>
|
|
</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-back" id="dlg" hidden>
|
|
|
<div class="dlg">
|
|
<div class="dlg">
|
|
|
<h3 id="dlg-title"></h3>
|
|
<h3 id="dlg-title"></h3>
|
|
@@ -179,6 +246,8 @@ const state = { snap: null, jobs: new Map(), recipes: [], active: localStorage.g
|
|
|
/*SNAP-LT:START*/
|
|
/*SNAP-LT:START*/
|
|
|
// Pure: same fleet + jobs? (`ts` changes on every probe, so it is
|
|
// Pure: same fleet + jobs? (`ts` changes on every probe, so it is
|
|
|
// excluded). Used to skip DOM rebuilds when nothing changed — no blink.
|
|
// 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) {
|
|
function sameSnapshot(a, b) {
|
|
|
return JSON.stringify({ c: a.clusters, j: a.jobs }) === JSON.stringify({ c: b.clusters, j: b.jobs });
|
|
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);
|
|
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 -------------------------------------------------------------------
|
|
// --- SSE -------------------------------------------------------------------
|
|
|
function sse(cls, txt) { $("#sse").className = "ssec " + cls; $("#sse-t").textContent = txt; }
|
|
function sse(cls, txt) { $("#sse").className = "ssec " + cls; $("#sse-t").textContent = txt; }
|
|
|
function connectSSE() {
|
|
function connectSSE() {
|
|
@@ -202,6 +359,7 @@ function connectSSE() {
|
|
|
try { ev = JSON.parse(e.data); } catch { return; }
|
|
try { ev = JSON.parse(e.data); } catch { return; }
|
|
|
if (ev.type === "state") onState(ev.snapshot);
|
|
if (ev.type === "state") onState(ev.snapshot);
|
|
|
else if (ev.type === "job") onJob(ev);
|
|
else if (ev.type === "job") onJob(ev);
|
|
|
|
|
+ else if (ev.type === "log") onLog(ev);
|
|
|
};
|
|
};
|
|
|
es.onerror = () => { sse("re", "reconnecting…"); };
|
|
es.onerror = () => { sse("re", "reconnecting…"); };
|
|
|
}
|
|
}
|
|
@@ -241,20 +399,22 @@ function renderCluster(c, prev, animate) {
|
|
|
for (const n of c.nodes) {
|
|
for (const n of c.nodes) {
|
|
|
const row = document.createElement("div");
|
|
const row = document.createElement("div");
|
|
|
row.className = "node";
|
|
row.className = "node";
|
|
|
|
|
+ row.title = "open logs for " + n.name;
|
|
|
const cls = ledState(n);
|
|
const cls = ledState(n);
|
|
|
const flip = prevNodes[n.name] && ledState(prevNodes[n.name]) !== cls ? " flip" : "";
|
|
const flip = prevNodes[n.name] && ledState(prevNodes[n.name]) !== cls ? " flip" : "";
|
|
|
row.innerHTML = `<span class="led ${cls}${flip}"></span>
|
|
row.innerHTML = `<span class="led ${cls}${flip}"></span>
|
|
|
<span class="nname">${esc(n.name)}</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>`;
|
|
|
|
|
+ row.onclick = () => openLog(c.name, n.name);
|
|
|
el.appendChild(row);
|
|
el.appendChild(row);
|
|
|
if (n.occupied && n.containers.length) {
|
|
if (n.occupied && n.containers.length) {
|
|
|
const box = document.createElement("div");
|
|
const box = document.createElement("div");
|
|
|
box.className = "containers";
|
|
box.className = "containers";
|
|
|
for (const x of n.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);
|
|
el.appendChild(box);
|
|
|
}
|
|
}
|
|
@@ -424,6 +584,114 @@ function onJob(ev) {
|
|
|
renderJobs();
|
|
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 -----------------------------------------------------------------------
|
|
// --- actions -----------------------------------------------------------------------
|
|
|
const cur = () => state.snap;
|
|
const cur = () => state.snap;
|
|
|
function recipeOf(cluster) {
|
|
function recipeOf(cluster) {
|
|
@@ -563,10 +831,35 @@ $("#dlg-cancel").onclick = () => { $("#dlg").hidden = true; };
|
|
|
document.addEventListener("keydown", (e) => { if (e.key === "Escape") $("#dlg").hidden = true; });
|
|
document.addEventListener("keydown", (e) => { if (e.key === "Escape") $("#dlg").hidden = true; });
|
|
|
|
|
|
|
|
// --- boot ---------------------------------------------------------------------------
|
|
// --- 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() {
|
|
async function boot() {
|
|
|
|
|
+ initManagedWindows();
|
|
|
try {
|
|
try {
|
|
|
const res = await fetch("/api/state");
|
|
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 {}
|
|
} catch {}
|
|
|
try {
|
|
try {
|
|
|
const res = await fetch("/api/recipes");
|
|
const res = await fetch("/api/recipes");
|