|
|
@@ -48,7 +48,8 @@
|
|
|
padding: 14px; min-height: 120px;
|
|
|
}
|
|
|
.card h2 { font-size: 12px; text-transform: uppercase; letter-spacing: .8px; color: var(--muted); margin: 0 0 10px; }
|
|
|
- .cluster { margin-bottom: 14px; animation: winIn .35s ease both; }
|
|
|
+ .cluster.anim { animation: winIn .35s ease both; }
|
|
|
+ .cluster { margin-bottom: 14px; }
|
|
|
.cluster-head { display: flex; align-items: baseline; gap: 8px; margin-bottom: 6px; }
|
|
|
.cluster-head b { font-size: 13px; }
|
|
|
.cluster-head .free { color: var(--muted); font-size: 12px; }
|
|
|
@@ -108,7 +109,8 @@
|
|
|
.abtn:disabled { opacity: .45; cursor: default; }
|
|
|
.abtn.danger { color: var(--led-off); }
|
|
|
.jobs { font-family: ui-monospace, monospace; font-size: 12px; }
|
|
|
- .job { display: flex; gap: 8px; align-items: baseline; padding: 4px 0; border-bottom: 1px solid var(--border); animation: winIn .3s ease both; }
|
|
|
+ .job { display: flex; gap: 8px; align-items: baseline; padding: 4px 0; border-bottom: 1px solid var(--border); }
|
|
|
+ .job.new { animation: winIn .3s ease both; }
|
|
|
.job:last-child { border-bottom: none; }
|
|
|
.jdot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); flex-shrink: 0; align-self: center; }
|
|
|
.job.ok .jdot { background: var(--led-on); }
|
|
|
@@ -172,7 +174,15 @@
|
|
|
"use strict";
|
|
|
const $ = (s) => document.querySelector(s);
|
|
|
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));
|
|
|
-const state = { snap: null, jobs: new Map(), recipes: [], active: localStorage.getItem("sm121-active") || "" };
|
|
|
+const state = { snap: null, jobs: new Map(), recipes: [], active: localStorage.getItem("sm121-active") || "", panel: null, shown: new Set() };
|
|
|
+
|
|
|
+/*SNAP-LT:START*/
|
|
|
+// Pure: same fleet + jobs? (`ts` changes on every probe, so it is
|
|
|
+// excluded). Used to skip DOM rebuilds when nothing changed — no blink.
|
|
|
+function sameSnapshot(a, b) {
|
|
|
+ return JSON.stringify({ c: a.clusters, j: a.jobs }) === JSON.stringify({ c: b.clusters, j: b.jobs });
|
|
|
+}
|
|
|
+/*SNAP-LT:END*/
|
|
|
|
|
|
// --- theme ---------------------------------------------------------------
|
|
|
const root = document.documentElement;
|
|
|
@@ -215,9 +225,9 @@ function clusterReason(c) {
|
|
|
if (c.soft_off && c.soft_off.length) r = (r ? r + " · " : "") + "⚙ " + c.soft_off.join(", ") + " off";
|
|
|
return r;
|
|
|
}
|
|
|
-function renderCluster(c, prev) {
|
|
|
+function renderCluster(c, prev, animate) {
|
|
|
const el = document.createElement("div");
|
|
|
- el.className = "cluster";
|
|
|
+ el.className = "cluster" + (animate ? " anim" : "");
|
|
|
const head = document.createElement("div");
|
|
|
head.className = "cluster-head";
|
|
|
if (c.up) {
|
|
|
@@ -291,19 +301,27 @@ function abtn(id, label, fn, cls) {
|
|
|
b.onclick = fn;
|
|
|
return b;
|
|
|
}
|
|
|
-function onState(snap) {
|
|
|
+function onState(snap, force) {
|
|
|
const prev = state.snap;
|
|
|
+ if (!force && prev && sameSnapshot(prev, snap)) return; // nothing changed — no DOM churn
|
|
|
state.snap = snap;
|
|
|
state.jobs = new Map(snap.jobs.map((j) => [j.id, j]));
|
|
|
if (!snap.clusters.find((c) => c.name === state.active)) {
|
|
|
state.active = snap.clusters[0] ? snap.clusters[0].name : "";
|
|
|
}
|
|
|
+ // control state must survive the rebuild (recipe pick, dry-run tick)
|
|
|
+ const activeName = state.active;
|
|
|
+ const keepRecipe = recipeOf(activeName);
|
|
|
+ const keepDry = dryOf(activeName);
|
|
|
+ const animate = state.panel !== activeName; // first paint or cluster switch
|
|
|
+ state.panel = activeName;
|
|
|
const box = $("#clusters");
|
|
|
box.innerHTML = "";
|
|
|
- const active = snap.clusters.find((c) => c.name === state.active);
|
|
|
+ const active = snap.clusters.find((c) => c.name === activeName);
|
|
|
if (active) {
|
|
|
- box.appendChild(renderCluster(active, prev ? prev.clusters.find((p) => p.name === active.name) : null));
|
|
|
+ box.appendChild(renderCluster(active, prev ? prev.clusters.find((p) => p.name === activeName) : null, animate));
|
|
|
}
|
|
|
+ restoreControls(activeName, keepRecipe, keepDry);
|
|
|
// compact fleet strip: one chip per cluster, click to make it active
|
|
|
const strip = document.createElement("div");
|
|
|
strip.className = "strip";
|
|
|
@@ -314,12 +332,21 @@ function onState(snap) {
|
|
|
const m = c.up ? c.free + " free" : "⏻ OFF";
|
|
|
chip.title = clusterReason(c);
|
|
|
chip.innerHTML = `<span class="led ${dot}"></span>${esc(c.name)}<span class="m">${m}</span>`;
|
|
|
- chip.onclick = () => { state.active = c.name; localStorage.setItem("sm121-active", c.name); onState(state.snap); };
|
|
|
+ chip.onclick = () => { state.active = c.name; localStorage.setItem("sm121-active", c.name); onState(state.snap, true); };
|
|
|
strip.appendChild(chip);
|
|
|
}
|
|
|
box.appendChild(strip);
|
|
|
renderJobs();
|
|
|
}
|
|
|
+function restoreControls(name, recipe, dry) {
|
|
|
+ if (!name) return;
|
|
|
+ const d = document.getElementById("dry-" + name);
|
|
|
+ if (d && dry) d.checked = true;
|
|
|
+ const sel = document.getElementById("recipe-" + name);
|
|
|
+ if (sel && recipe) {
|
|
|
+ for (const o of sel.options) if (o.value === recipe) { sel.value = recipe; break; }
|
|
|
+ }
|
|
|
+}
|
|
|
|
|
|
// --- jobs ---------------------------------------------------------------------
|
|
|
// "Running" entries come from the docker labels (sm121.recipe / sm121.cluster)
|
|
|
@@ -338,17 +365,25 @@ function runningFromLabels() {
|
|
|
function renderJobs() {
|
|
|
const box = $("#jobs");
|
|
|
box.innerHTML = "";
|
|
|
+ const prevShown = state.shown;
|
|
|
+ state.shown = new Set();
|
|
|
+ const row = (key, cls, html) => {
|
|
|
+ const el = document.createElement("div");
|
|
|
+ el.className = "job " + cls + (prevShown.has(key) ? "" : " new");
|
|
|
+ el.innerHTML = html;
|
|
|
+ box.appendChild(el);
|
|
|
+ state.shown.add(key);
|
|
|
+ return el;
|
|
|
+ };
|
|
|
const run = runningFromLabels();
|
|
|
if (run.length) {
|
|
|
const h = document.createElement("div");
|
|
|
h.className = "jobs-empty"; h.textContent = "running (docker labels)";
|
|
|
box.appendChild(h);
|
|
|
for (const r of run) {
|
|
|
- const el = document.createElement("div");
|
|
|
- el.className = "job jjob";
|
|
|
- el.innerHTML = `<span class="jdot"></span><span class="jkind">serve</span>
|
|
|
- <span class="jmsg">${esc(r.cluster)}:${esc(r.recipe)} — ${esc(r.name)} on ${esc(r.node)} · ${esc(r.status)}</span>`;
|
|
|
- box.appendChild(el);
|
|
|
+ row("c:" + r.cluster + ":" + r.recipe + ":" + r.name, "jjob",
|
|
|
+ `<span class="jdot"></span><span class="jkind">serve</span>
|
|
|
+ <span class="jmsg">${esc(r.cluster)}:${esc(r.recipe)} — ${esc(r.name)} on ${esc(r.node)} · ${esc(r.status)}</span>`);
|
|
|
}
|
|
|
}
|
|
|
const js = [...state.jobs.values()].sort((a, b) => b.started - a.started);
|
|
|
@@ -359,12 +394,10 @@ function renderJobs() {
|
|
|
box.appendChild(sep);
|
|
|
}
|
|
|
for (const j of js) {
|
|
|
- const el = document.createElement("div");
|
|
|
- el.className = "job " + (j.status === "ok" ? "ok" : j.status === "failed" ? "failed" : "jjob");
|
|
|
+ const cls = j.status === "ok" ? "ok" : j.status === "failed" ? "failed" : "jjob";
|
|
|
const when = new Date(j.started * 1000).toLocaleTimeString();
|
|
|
- el.innerHTML = `<span class="jdot"></span><span class="jkind">${esc(j.kind)}</span>
|
|
|
- <span class="jmsg">${esc(j.cluster)}${j.recipe ? ":" + esc(j.recipe) : ""} — ${esc(j.message)} <span>(${when})</span></span>`;
|
|
|
- box.appendChild(el);
|
|
|
+ row("j:" + j.id, cls, `<span class="jdot"></span><span class="jkind">${esc(j.kind)}</span>
|
|
|
+ <span class="jmsg">${esc(j.cluster)}${j.recipe ? ":" + esc(j.recipe) : ""} — ${esc(j.message)} <span>(${when})</span></span>`);
|
|
|
setBtnBusy(j, j.status === "running");
|
|
|
}
|
|
|
}
|