| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591 |
- <!doctype html>
- <html data-theme="dark">
- <head>
- <meta charset="utf-8">
- <meta name="viewport" content="width=device-width, initial-scale=1">
- <title>sm121</title>
- <style>
- :root[data-theme="dark"] {
- --bg: #0d1117; --panel: #161b22; --fg: #e6edf3; --muted: #8b949e;
- --border: #30363d; --accent: #58a6ff;
- --led-on: #3fb950; --led-off: #f85149; --led-busy: #d29922; --led-free: #30363d;
- --glow-on: 0 0 8px 2px rgba(63,185,80,.55); --glow-busy: 0 0 8px 2px rgba(210,153,34,.55);
- --btn: #21262d; --btn-hover: #30363d;
- }
- :root[data-theme="light"] {
- --bg: #f6f8fa; --panel: #ffffff; --fg: #1f2328; --muted: #656d76;
- --border: #d0d7de; --accent: #0969da;
- --led-on: #1a7f37; --led-off: #cf222e; --led-busy: #9a6700; --led-free: #d0d7de;
- --glow-on: 0 0 8px 2px rgba(26,127,55,.35); --glow-busy: 0 0 8px 2px rgba(154,103,0,.35);
- --btn: #f6f8fa; --btn-hover: #eaeef2;
- }
- * { box-sizing: border-box; }
- body {
- margin: 0; background: var(--bg); color: var(--fg);
- font: 14px/1.45 ui-sans-serif, system-ui, "SF Pro", sans-serif;
- transition: background .25s, color .25s;
- }
- header {
- display: flex; align-items: center; gap: 12px;
- padding: 10px 16px; border-bottom: 1px solid var(--border);
- background: var(--panel); position: sticky; top: 0; z-index: 5;
- }
- header h1 { font-size: 15px; margin: 0; font-weight: 650; letter-spacing: .3px; }
- header .spacer { flex: 1; }
- .ssec { font-size: 12px; color: var(--muted); display: flex; align-items: center; gap: 6px; }
- .ssec .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--led-off); }
- .ssec.ok .dot { background: var(--led-on); }
- .ssec.re .dot { animation: pulse 1s infinite; }
- button.tbtn {
- background: var(--btn); color: var(--fg); border: 1px solid var(--border);
- border-radius: 6px; padding: 4px 10px; cursor: pointer; font: inherit; font-size: 13px;
- }
- 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; }
- .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; }
- .cluster-head .down { color: var(--led-off); 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:last-child { border-bottom: none; }
- .led {
- width: 11px; height: 11px; border-radius: 50%; background: var(--led-off);
- transition: background .4s, box-shadow .4s; flex-shrink: 0;
- }
- .led.on { background: var(--led-on); box-shadow: var(--glow-on); }
- .led.b { background: var(--led-busy); box-shadow: var(--glow-busy); }
- .led.off { background: var(--led-off); }
- .led.flip { animation: flip .45s ease; }
- .nname { width: 64px; font-family: ui-monospace, monospace; font-size: 12.5px; }
- .nmeta { color: var(--muted); font-size: 12px; flex: 1; overflow: hidden; text-overflow: ellipsis; }
- .containers { width: 100%; margin-top: 2px; }
- .crow { display: flex; align-items: center; gap: 8px; padding: 2px 0 2px 21px; font-size: 12px; color: var(--muted); }
- .cled { width: 7px; height: 7px; border-radius: 50%; background: var(--led-busy); box-shadow: var(--glow-busy); flex-shrink: 0; }
- .cname { font-family: ui-monospace, monospace; color: var(--fg); }
- .actions { display: flex; gap: 8px; align-items: center; margin: 6px 0 0 21px; flex-wrap: wrap; }
- .actions select { font: inherit; }
- .actions input[type=checkbox] {
- appearance: none; -webkit-appearance: none;
- width: 15px; height: 15px; margin: 0; flex-shrink: 0;
- border: 1.5px solid var(--border); border-radius: 4px; background: var(--btn);
- cursor: pointer; position: relative; transition: background .15s, border-color .15s;
- }
- .actions input[type=checkbox]:hover { border-color: var(--accent); }
- .actions input[type=checkbox]:checked { background: var(--accent); border-color: var(--accent); }
- .actions 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);
- }
- .actions select {
- background: var(--btn); color: var(--fg); border: 1px solid var(--border);
- border-radius: 6px; padding: 2px 6px; font-size: 12.5px; max-width: 260px; height: 26px;
- }
- .actions label { font-size: 12px; color: var(--muted); display: inline-flex; align-items: center; gap: 4px; height: 26px; }
- .actions label input { margin: 0; }
- .strip { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--border); }
- .chip {
- display: inline-flex; align-items: center; gap: 6px; cursor: pointer; user-select: none;
- border: 1px solid var(--border); border-radius: 999px; padding: 3px 10px; font-size: 12px;
- color: var(--muted); background: var(--btn); transition: border-color .15s, color .15s;
- }
- .chip .m { opacity: .7; }
- .chip.active { color: var(--fg); border-color: var(--accent); }
- .abtn {
- background: var(--btn); color: var(--fg); border: 1px solid var(--border);
- border-radius: 6px; padding: 0 12px; cursor: pointer; font: inherit; font-size: 12.5px;
- height: 26px; transition: background .15s;
- }
- .abtn:hover:not(:disabled) { background: var(--btn-hover); }
- .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); }
- .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); }
- .job.failed .jdot { background: var(--led-off); }
- .jkind { width: 48px; color: var(--muted); }
- .jmsg { color: var(--muted); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
- .jjob .jmsg { color: var(--fg); }
- .jobs-empty { color: var(--muted); font-size: 12.5px; padding: 6px 0; }
- /* confirm dialog */
- .dlg-back[hidden] { display: none; }
- .dlg-back {
- position: fixed; inset: 0; background: rgba(0,0,0,.5); z-index: 10;
- display: flex; align-items: center; justify-content: center; animation: fadeIn .15s ease;
- }
- .dlg {
- background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
- padding: 18px; width: min(460px, 92vw); max-height: 80vh; overflow: auto;
- box-shadow: 0 12px 40px rgba(0,0,0,.5); animation: winIn .2s ease both;
- }
- .dlg h3 { margin: 0 0 10px; font-size: 14px; }
- .dlg .body { font-size: 13px; color: var(--fg); margin-bottom: 12px; }
- .dlg .body code { font-family: ui-monospace, monospace; font-size: 12px; background: var(--btn); padding: 1px 5px; border-radius: 4px; }
- .dlg .cbox { font-family: ui-monospace, monospace; font-size: 12px; color: var(--muted); margin: 8px 0 0; padding: 8px; background: var(--btn); border-radius: 6px; }
- .dlg .cbox li { margin: 2px 0; }
- .dlg input {
- width: 100%; background: var(--btn); color: var(--fg); border: 1px solid var(--border);
- border-radius: 6px; padding: 6px 8px; font: inherit; margin: 8px 0;
- }
- .dlg .row { display: flex; justify-content: flex-end; gap: 8px; }
- @keyframes flip { 0% { transform: scale(1); } 40% { transform: scale(1.5); } 100% { transform: scale(1); } }
- @keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .35; } }
- @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
- @keyframes winIn { from { opacity: 0; transform: translateY(-4px) scale(.985); } to { opacity: 1; transform: none; } }
- @media (prefers-reduced-motion: reduce) {
- *, .led, .led.flip, .ssec.re .dot, .cluster, .job, .dlg { animation: none !important; }
- }
- </style>
- </head>
- <body>
- <header>
- <h1>sm121</h1>
- <div class="spacer"></div>
- <div class="ssec" id="sse"><span class="dot"></span><span id="sse-t">connecting…</span></div>
- <button class="tbtn" id="theme">◐</button>
- </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 class="dlg-back" id="dlg" hidden>
- <div class="dlg">
- <h3 id="dlg-title"></h3>
- <div class="body" id="dlg-body"></div>
- <div class="row">
- <button class="tbtn" id="dlg-cancel">Cancel</button>
- <button class="abtn" id="dlg-ok">Confirm</button>
- </div>
- </div>
- </div>
- <script>
- "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") || "", 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;
- root.dataset.theme = localStorage.getItem("sm121-theme") || (matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark");
- $("#theme").onclick = () => {
- root.dataset.theme = root.dataset.theme === "dark" ? "light" : "dark";
- localStorage.setItem("sm121-theme", root.dataset.theme);
- };
- // --- SSE -------------------------------------------------------------------
- function sse(cls, txt) { $("#sse").className = "ssec " + cls; $("#sse-t").textContent = txt; }
- function connectSSE() {
- const es = new EventSource("/events");
- es.onmessage = (e) => {
- sse("ok", "live");
- let ev;
- try { ev = JSON.parse(e.data); } catch { return; }
- if (ev.type === "state") onState(ev.snapshot);
- else if (ev.type === "job") onJob(ev);
- };
- es.onerror = () => { sse("re", "reconnecting…"); };
- }
- // --- state render ------------------------------------------------------------
- function ledState(n) { return n.reachable ? (n.occupied ? "b" : "on") : "off"; }
- function meta(n, c) {
- if (!n.reachable) return "offline";
- if (!c.up) return "online — cluster offline";
- if (n.occupied) return n.containers.map((x) => x.status).join(", ");
- return "free";
- }
- function clusterReason(c) {
- let r = "";
- if (c.up) r = "";
- else if (c.switch_off) r = "switch off";
- else {
- const off = c.nodes.filter((n) => !n.reachable).map((n) => n.name);
- r = off.length + " node" + (off.length === 1 ? "" : "s") + " offline" + (off.length ? " (" + off.join(", ") + ")" : "");
- }
- if (c.soft_off && c.soft_off.length) r = (r ? r + " · " : "") + "⚙ " + c.soft_off.join(", ") + " off";
- return r;
- }
- function renderCluster(c, prev, animate) {
- const el = document.createElement("div");
- el.className = "cluster" + (animate ? " anim" : "");
- const head = document.createElement("div");
- head.className = "cluster-head";
- if (c.up) {
- const soft = c.soft_off && c.soft_off.length ? `<span class="soft" title="non-blocking entity off">⚙ ${esc(c.soft_off.join(", "))} off</span>` : "";
- head.innerHTML = `<b>${esc(c.name)}</b><span class="free">${c.free} free</span>${soft}`;
- } else {
- head.innerHTML = `<b>${esc(c.name)}</b><span class="down">⏻ offline — ${esc(clusterReason(c))}</span>`;
- }
- el.appendChild(head);
- const prevNodes = prev ? Object.fromEntries(prev.nodes.map((n) => [n.name, n])) : {};
- for (const n of c.nodes) {
- const row = document.createElement("div");
- row.className = "node";
- const cls = ledState(n);
- const flip = prevNodes[n.name] && ledState(prevNodes[n.name]) !== cls ? " flip" : "";
- row.innerHTML = `<span class="led ${cls}${flip}"></span>
- <span class="nname">${esc(n.name)}</span>
- <span class="nmeta" title="${esc(n.ssh_ip)}">${esc(meta(n, c))}</span>`;
- el.appendChild(row);
- if (n.occupied && n.containers.length) {
- const box = document.createElement("div");
- box.className = "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);
- }
- el.appendChild(box);
- }
- }
- el.appendChild(actionBar(c, prevNodes));
- return el;
- }
- function actionBar(c, prevNodes) {
- const bar = document.createElement("div");
- bar.className = "actions";
- if (!c.up) {
- // Down cluster: serve/stop are meaningless — only power actions apply.
- bar.appendChild(abtn("power-" + c.name, "power on", () => doPower("on", c.name), ""));
- bar.appendChild(abtn("pwswitch-" + c.name, "switch", () => doPower("switch", c.name), ""));
- return bar;
- }
- const sel = document.createElement("select");
- sel.id = "recipe-" + c.name;
- if (!state.recipes.length) {
- sel.innerHTML = "<option value=''></option>";
- } else {
- for (const r of state.recipes) {
- const o = document.createElement("option");
- o.value = r.name;
- o.textContent = r.name + (r.deploy === "tmux" ? " (tmux)" : "");
- sel.appendChild(o);
- }
- }
- bar.appendChild(sel);
- const dry = document.createElement("label");
- dry.innerHTML = `<input type="checkbox" id="dry-${esc(c.name)}"> dry-run`;
- bar.appendChild(dry);
- bar.appendChild(abtn("serve-" + c.name, "serve", () => doServe(c.name)));
- bar.appendChild(abtn("stop-" + c.name, "stop", () => doStop(c.name), "danger"));
- bar.appendChild(abtn("power-" + c.name, "power off", () => doPower("off", c.name), "danger"));
- bar.appendChild(abtn("pwswitch-" + c.name, "switch", () => doPower("switch", c.name), ""));
- return bar;
- }
- function abtn(id, label, fn, cls) {
- const b = document.createElement("button");
- b.className = "abtn " + (cls || "");
- b.id = id;
- b.textContent = label;
- b.onclick = fn;
- return b;
- }
- 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 === activeName);
- if (active) {
- 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";
- for (const c of snap.clusters) {
- const chip = document.createElement("div");
- chip.className = "chip" + (c.name === state.active ? " active" : "");
- const dot = c.up ? (c.free > 0 ? "on" : "b") : "off";
- 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, 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)
- // on live containers — jobs don't need to have started from this dashboard.
- function runningFromLabels() {
- const out = [];
- if (!state.snap) return out;
- for (const c of state.snap.clusters) {
- if (!c.up) continue; // down clusters report no containers
- for (const n of c.nodes) {
- for (const x of n.containers) out.push({ cluster: c.name, node: n.name, name: x.name, recipe: x.recipe, status: x.status });
- }
- }
- return out;
- }
- 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) {
- 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);
- if (!run.length && !js.length) { box.innerHTML = '<div class="jobs-empty">no sm121 containers running, no actions yet — serve / stop / power started from this dashboard appear here</div>'; return; }
- if (run.length) {
- const sep = document.createElement("div");
- sep.className = "jobs-empty"; sep.textContent = "actions";
- box.appendChild(sep);
- }
- for (const j of js) {
- const cls = j.status === "ok" ? "ok" : j.status === "failed" ? "failed" : "jjob";
- const when = new Date(j.started * 1000).toLocaleTimeString();
- 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");
- }
- }
- function setBtnBusy(j, busy) {
- // power jobs disable both the off and switch buttons (the job event
- // doesn't carry the action)
- const keys = j.kind === "power" ? ["power", "pwswitch"] : [j.kind];
- for (const k of keys) {
- const el = document.getElementById(k + "-" + j.cluster);
- if (el) el.disabled = busy;
- }
- }
- // --- job events ------------------------------------------------------------------
- function onJob(ev) {
- if (ev.event === "started") {
- state.jobs.set(ev.id, { id: ev.id, kind: ev.kind, cluster: ev.cluster, recipe: ev.recipe, status: "running", message: ev.message, started: Date.now() / 1000 });
- }
- const j = state.jobs.get(ev.id);
- if (j && ev.event !== "started") {
- j.message = ev.message;
- j.status = ev.event === "ok" ? "ok" : "failed";
- }
- renderJobs();
- }
- // --- actions -----------------------------------------------------------------------
- const cur = () => state.snap;
- function recipeOf(cluster) {
- const sel = document.getElementById("recipe-" + cluster);
- return sel ? sel.value : "";
- }
- function dryOf(cluster) {
- const d = document.getElementById("dry-" + cluster);
- return d ? d.checked : false;
- }
- async function api(path, body) {
- const res = await fetch(path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
- if (!res.ok) {
- let m = res.status;
- try { m = (await res.json()).error || m; } catch {}
- throw new Error(m);
- }
- return res.json();
- }
- function doServe(cluster) {
- const recipe = recipeOf(cluster);
- if (!recipe) { alert("pick a recipe first"); return; }
- const dry = dryOf(cluster);
- const body = { cluster, recipe, dry_run: dry };
- if (dry) {
- api("/api/serve", body).then((r) => openDialog("dry-run: " + r.job, "started (watch the jobs window)", () => {}));
- return;
- }
- openDialog(`serve ${recipe} on ${cluster}`, `Launches <code>${esc(recipe)}</code> on cluster <code>${esc(cluster)}</code>.`, () => {
- api("/api/serve", body).catch((e) => openDialog("serve failed", esc(e.message), () => {}, true));
- });
- }
- function doStop(cluster) {
- const snap = cur();
- const c = snap ? snap.clusters.find((x) => x.name === cluster) : null;
- const containers = c ? c.nodes.flatMap((n) => n.containers) : [];
- const recipe = recipeOf(cluster);
- if (!recipe) { alert("pick the recipe to stop first"); return; }
- const list = containers.length
- ? `<ul class="cbox">${containers.map((x) => `<li><b>${esc(x.name)}</b> — ${esc(x.status)}</li>`).join("")}</ul>`
- : `<div class="cbox">no containers detected on ${esc(cluster)} (recipe may not be running here)</div>`;
- openDialog(`stop ${recipe}`, `Will run <code>sm121 stop</code> for <code>${esc(recipe)}</code> on <code>${esc(cluster)}</code>. Currently running:${list}`, () => {
- api("/api/stop", { cluster, recipe }).catch((e) => openDialog("stop failed", esc(e.message), () => {}, true));
- });
- }
- function doPower(action, cluster) {
- if (action === "off") {
- const snap = cur();
- const c = snap ? snap.clusters.find((x) => x.name === cluster) : null;
- const busy = c ? c.nodes.filter((n) => n.occupied) : [];
- if (busy.length) {
- openDialog(`power off ${cluster} blocked`,
- `Cluster has ${busy.length} occupied node(s): <code>${esc(busy.map((n) => n.name).join(", "))}</code>. Stop its workloads first.`,
- () => {}, true);
- return;
- }
- twoStep(`power off ${cluster}`,
- `Shuts down <b>all nodes</b> of <code>${esc(cluster)}</code> (SSH then Home Assistant). This can kill live traffic.`,
- cluster, () => {
- api("/api/power", { action: "off", cluster }).catch((e) => openDialog("power failed", esc(e.message), () => {}, true));
- });
- return;
- }
- if (action === "switch") {
- const snap = cur();
- const names = snap ? snap.clusters.map((c) => c.name) : [];
- const body = document.createElement("div");
- body.className = "body";
- let inner = `Power <code>${esc(cluster)}</code> off and bring up another cluster instead.`;
- const sel = document.createElement("select");
- sel.style.cssText = "width:100%;margin:8px 0;padding:6px;background:var(--btn);color:var(--fg);border:1px solid var(--border);border-radius:6px;";
- for (const n of names.filter((n) => n !== cluster)) {
- const o = document.createElement("option");
- o.value = n; o.textContent = n;
- sel.appendChild(o);
- }
- body.innerHTML = inner;
- body.appendChild(sel);
- openDialogHtml(`power switch from ${cluster}`, body, () => {
- const to = sel.value;
- api("/api/power", { action: "switch", cluster, to }).catch((e) => openDialog("power failed", esc(e.message), () => {}, true));
- });
- return;
- }
- twoStep(`power on ${cluster}`, `Turns on <code>${esc(cluster)}</code> via Home Assistant (SSH is not used).`, cluster, () => {
- api("/api/power", { action: "on", cluster }).catch((e) => openDialog("power failed", esc(e.message), () => {}, true));
- });
- }
- function twoStep(title, bodyHtml, nameToType, go) {
- openDialogHtml(title, (() => { const d = document.createElement("div"); d.className = "body"; d.innerHTML = bodyHtml; return d; })(),
- () => {}, { twoStep: nameToType, go });
- }
- // --- dialog -----------------------------------------------------------------------
- let dlg = { ok: null, twoStep: null };
- function openDialog(title, bodyHtml, go, danger) {
- $("#dlg-title").textContent = title;
- $("#dlg-body").innerHTML = bodyHtml;
- const ok = $("#dlg-ok");
- ok.style.display = "";
- ok.textContent = "Confirm";
- ok.className = "abtn" + (danger ? " danger" : "");
- dlg = { ok: go, twoStep: null };
- $("#dlg").hidden = false;
- }
- function openDialogHtml(title, bodyEl, go, opts) {
- $("#dlg-title").textContent = title;
- $("#dlg-body").innerHTML = "";
- $("#dlg-body").appendChild(bodyEl);
- const ok = $("#dlg-ok");
- ok.style.display = "";
- if (opts && opts.twoStep) {
- ok.style.display = "none";
- const wrap = document.createElement("div");
- wrap.innerHTML = `<div style="font-size:12.5px;color:var(--muted)">Type <b>${esc(opts.twoStep)}</b> to confirm.</div>
- <input id="dlg-type" placeholder="${esc(opts.twoStep)}" autocomplete="off">`; $("#dlg-body").appendChild(wrap);
- const inp = wrap.querySelector("#dlg-type");
- inp.oninput = () => {
- if (inp.value === opts.twoStep) {
- ok.style.display = "";
- ok.textContent = "⚡ Confirm power action";
- ok.className = "abtn danger";
- } else { ok.style.display = "none"; }
- };
- dlg = { ok: null, twoStep: opts.twoStep, go };
- } else {
- dlg = { ok: go, twoStep: null };
- }
- $("#dlg").hidden = false;
- }
- $("#dlg-ok").onclick = () => {
- const go = dlg.twoStep ? dlg.go : dlg.ok;
- $("#dlg").hidden = true;
- if (go) go();
- };
- $("#dlg-cancel").onclick = () => { $("#dlg").hidden = true; };
- document.addEventListener("keydown", (e) => { if (e.key === "Escape") $("#dlg").hidden = true; });
- // --- boot ---------------------------------------------------------------------------
- async function boot() {
- try {
- const res = await fetch("/api/state");
- if (res.ok) onState(await res.json());
- } catch {}
- try {
- const res = await fetch("/api/recipes");
- if (res.ok) {
- const d = await res.json();
- state.recipes = (d.recipes || []).sort((a, b) => a.name.localeCompare(b.name));
- if (state.snap) onState(state.snap); // re-render with recipes
- }
- } catch {}
- connectSSE();
- // 30 s heartbeat refresh (mirrors the server poll; harmless if state is identical)
- setInterval(async () => {
- try {
- const res = await fetch("/api/state");
- if (res.ok) onState(await res.json());
- } catch {}
- }, 30000);
- }
- boot();
- </script>
- </body>
- </html>
|