|
|
@@ -10,12 +10,14 @@
|
|
|
--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 {
|
|
|
@@ -26,139 +28,432 @@
|
|
|
header {
|
|
|
display: flex; align-items: center; gap: 12px;
|
|
|
padding: 10px 16px; border-bottom: 1px solid var(--border);
|
|
|
- background: var(--panel);
|
|
|
- }
|
|
|
- header h1 { font-size: 15px; font-weight: 600; margin: 0; letter-spacing: .3px; }
|
|
|
- #sse {
|
|
|
- display: inline-flex; align-items: center; gap: 6px;
|
|
|
- font-size: 12px; color: var(--muted);
|
|
|
- }
|
|
|
- #sse .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--led-free); transition: background .3s; }
|
|
|
- #sse.ok .dot { background: var(--led-on); box-shadow: var(--glow-on); }
|
|
|
- #sse.err .dot { background: var(--led-off); }
|
|
|
- #theme {
|
|
|
- margin-left: auto; cursor: pointer; border: 1px solid var(--border);
|
|
|
- background: var(--panel); color: var(--fg); border-radius: 6px;
|
|
|
- padding: 4px 10px; font-size: 12px; transition: transform .12s;
|
|
|
- }
|
|
|
- #theme:active { transform: scale(.95); }
|
|
|
-
|
|
|
- .stage { padding: 20px; }
|
|
|
- .win {
|
|
|
+ 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;
|
|
|
- box-shadow: 0 8px 28px rgba(0,0,0,.25); overflow: hidden;
|
|
|
- animation: win-in .28s cubic-bezier(.2,.9,.3,1.2);
|
|
|
- }
|
|
|
- @keyframes win-in { from { opacity: 0; transform: scale(.94) translateY(6px); } to { opacity: 1; transform: none; } }
|
|
|
- .titlebar {
|
|
|
- padding: 7px 12px; font-size: 12px; color: var(--muted);
|
|
|
- border-bottom: 1px solid var(--border); display: flex; gap: 8px; align-items: center;
|
|
|
- user-select: none; cursor: grab;
|
|
|
- }
|
|
|
- .titlebar b { color: var(--fg); font-weight: 600; }
|
|
|
- .grid { padding: 10px 12px; }
|
|
|
- .cluster { margin-bottom: 14px; }
|
|
|
- .cluster-head {
|
|
|
- display: flex; gap: 10px; align-items: baseline; font-size: 13px;
|
|
|
- font-weight: 600; margin: 0 0 6px 2px;
|
|
|
- }
|
|
|
- .cluster-head .free { font-weight: 400; font-size: 12px; color: var(--muted); }
|
|
|
- .node {
|
|
|
- display: grid; grid-template-columns: 14px 14px 190px 1fr; gap: 10px; align-items: center;
|
|
|
- padding: 6px 8px; border-radius: 8px; margin-bottom: 2px;
|
|
|
- transition: background .15s;
|
|
|
+ padding: 14px; min-height: 120px;
|
|
|
}
|
|
|
- .node:hover { background: color-mix(in srgb, var(--fg) 6%, transparent); }
|
|
|
- .led { width: 10px; height: 10px; border-radius: 50%; background: var(--led-free); transition: background .35s, box-shadow .35s; }
|
|
|
- .led.on { background: var(--led-on); box-shadow: var(--glow-on); }
|
|
|
+ .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-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; }
|
|
|
+ .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.busy{ background: var(--led-busy); box-shadow: var(--glow-busy); }
|
|
|
- .led.pulse { animation: pulse 1.1s ease-in-out infinite; }
|
|
|
- @keyframes pulse { 50% { opacity: .35; } }
|
|
|
- .nname { font-weight: 500; font-size: 13px; }
|
|
|
- .nmeta { font-size: 12px; color: var(--muted); font-family: ui-monospace, monospace; }
|
|
|
- .nstate { font-size: 12px; }
|
|
|
- .nstate .up { color: var(--led-on); } .nstate .down { color: var(--led-off); }
|
|
|
- .nstate .occupied { color: var(--led-busy); }
|
|
|
- .empty { color: var(--muted); padding: 18px; text-align: center; }
|
|
|
+ .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, .actions input[type=checkbox] { font: inherit; }
|
|
|
+ .actions select {
|
|
|
+ background: var(--btn); color: var(--fg); border: 1px solid var(--border);
|
|
|
+ border-radius: 6px; padding: 3px 6px; font-size: 12.5px; max-width: 260px;
|
|
|
+ }
|
|
|
+ .actions label { font-size: 12px; color: var(--muted); display: flex; align-items: center; gap: 4px; }
|
|
|
+ .abtn {
|
|
|
+ background: var(--btn); color: var(--fg); border: 1px solid var(--border);
|
|
|
+ border-radius: 6px; padding: 3px 12px; cursor: pointer; font: inherit; font-size: 12.5px;
|
|
|
+ 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); 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 {
|
|
|
+ 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>
|
|
|
- <span id="sse"><span class="dot"></span><span id="sse-text">connecting…</span></span>
|
|
|
- <button id="theme">🌙 dark</button>
|
|
|
+ <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>
|
|
|
-<div class="stage">
|
|
|
- <div class="win" style="max-width: 760px">
|
|
|
- <div class="titlebar"><b>state</b><span id="updated"></span></div>
|
|
|
- <div class="grid" id="grid"><div class="empty">waiting for state…</div></div>
|
|
|
+<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 jobs yet</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";
|
|
|
-// ---- theme -------------------------------------------------------------
|
|
|
+const $ = (s) => document.querySelector(s);
|
|
|
+const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));
|
|
|
+const state = { snap: null, jobs: new Map(), recipes: [] };
|
|
|
+
|
|
|
+// --- theme ---------------------------------------------------------------
|
|
|
const root = document.documentElement;
|
|
|
-const saved = localStorage.getItem("theme");
|
|
|
-const prefersLight = matchMedia("(prefers-color-scheme: light)").matches;
|
|
|
-setTheme(saved || (prefersLight ? "light" : "dark"));
|
|
|
-document.getElementById("theme").onclick = () => setTheme(root.dataset.theme === "dark" ? "light" : "dark");
|
|
|
-function setTheme(t) {
|
|
|
- root.dataset.theme = t;
|
|
|
- localStorage.setItem("theme", t);
|
|
|
- document.getElementById("theme").textContent = t === "dark" ? "🌙 dark" : "☀️ light";
|
|
|
-}
|
|
|
-// ---- state grid ----------------------------------------------------------
|
|
|
-const grid = document.getElementById("grid");
|
|
|
-const updated = document.getElementById("updated");
|
|
|
-function esc(s) { const d = document.createElement("i"); d.textContent = s; return d.innerHTML; }
|
|
|
-function renderState(snap) {
|
|
|
- if (!snap.clusters || !snap.clusters.length) {
|
|
|
- grid.innerHTML = '<div class="empty">no clusters in config</div>';
|
|
|
+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 (n.occupied) return n.containers.map((x) => x.status).join(", ");
|
|
|
+ return c.free ? "free" : "no free nodes in cluster";
|
|
|
+}
|
|
|
+function renderCluster(c, prev) {
|
|
|
+ const el = document.createElement("div");
|
|
|
+ el.className = "cluster";
|
|
|
+ const head = document.createElement("div");
|
|
|
+ head.className = "cluster-head";
|
|
|
+ head.innerHTML = `<b>${esc(c.name)}</b><span class="free">${c.free} free</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";
|
|
|
+ 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) {
|
|
|
+ const prev = state.snap;
|
|
|
+ state.snap = snap;
|
|
|
+ state.jobs = new Map(snap.jobs.map((j) => [j.id, j]));
|
|
|
+ const box = $("#clusters");
|
|
|
+ box.innerHTML = "";
|
|
|
+ for (const c of snap.clusters) box.appendChild(renderCluster(c, prev ? prev.clusters.find((p) => p.name === c.name) : null));
|
|
|
+ renderJobs();
|
|
|
+}
|
|
|
+
|
|
|
+// --- jobs ---------------------------------------------------------------------
|
|
|
+function renderJobs() {
|
|
|
+ const box = $("#jobs");
|
|
|
+ box.innerHTML = "";
|
|
|
+ const js = [...state.jobs.values()].sort((a, b) => b.started - a.started);
|
|
|
+ if (!js.length) { box.innerHTML = '<div class="jobs-empty">no jobs yet</div>'; return; }
|
|
|
+ for (const j of js) {
|
|
|
+ const el = document.createElement("div");
|
|
|
+ el.className = "job " + (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);
|
|
|
+ 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;
|
|
|
}
|
|
|
- grid.innerHTML = snap.clusters.map(c => `
|
|
|
- <div class="cluster">
|
|
|
- <div class="cluster-head">${esc(c.name)}<span class="free">${c.free}/${c.nodes.length} free</span></div>
|
|
|
- ${c.nodes.map(n => `
|
|
|
- <div class="node">
|
|
|
- <span class="led ${n.reachable ? "on" : "off"} ${!n.reachable ? "pulse" : ""}" title="reachability"></span>
|
|
|
- <span class="led ${n.occupied ? "busy" : "free"}" title="occupancy"></span>
|
|
|
- <span class="nname">${esc(n.name)}</span>
|
|
|
- <span class="nmeta">${esc(n.ssh_ip)}</span>
|
|
|
- </div>
|
|
|
- <div class="node" style="padding-top:0;margin-left:38px">
|
|
|
- <span></span><span></span>
|
|
|
- <span class="nstate">
|
|
|
- ${n.reachable
|
|
|
- ? (n.occupied ? '<span class="occupied">● occupied</span>' : '<span class="up">○ free</span>')
|
|
|
- : '<span class="down">● unreachable</span>'}
|
|
|
- </span><span></span>
|
|
|
- </div>`).join("")}
|
|
|
- </div>`).join("");
|
|
|
- updated.textContent = "updated " + new Date(snap.ts * 1000).toLocaleTimeString();
|
|
|
-}
|
|
|
-// ---- SSE -----------------------------------------------------------------
|
|
|
-const TOKEN = new URLSearchParams(location.search).get("token") || "";
|
|
|
-const sseEl = document.getElementById("sse"), sseText = document.getElementById("sse-text");
|
|
|
-function connect() {
|
|
|
- const url = "/events" + (TOKEN ? `?token=${encodeURIComponent(TOKEN)}` : "");
|
|
|
- const es = new EventSource(url);
|
|
|
- es.onopen = () => { sseEl.classList.add("ok"); sseEl.classList.remove("err"); sseText.textContent = "live"; };
|
|
|
- es.onerror = () => { sseEl.classList.add("err"); sseEl.classList.remove("ok"); sseText.textContent = "reconnecting…"; };
|
|
|
- es.onmessage = (e) => {
|
|
|
+ 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 || [];
|
|
|
+ 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 ev = JSON.parse(e.data);
|
|
|
- if (ev.type === "state") renderState(ev.snapshot);
|
|
|
- } catch (_) {}
|
|
|
- };
|
|
|
+ const res = await fetch("/api/state");
|
|
|
+ if (res.ok) onState(await res.json());
|
|
|
+ } catch {}
|
|
|
+ }, 30000);
|
|
|
}
|
|
|
-// Initial snapshot, then live updates.
|
|
|
-fetch("/api/state" + (TOKEN ? `?token=${encodeURIComponent(TOKEN)}` : ""))
|
|
|
- .then(r => r.ok ? r.json() : null)
|
|
|
- .then(snap => snap && renderState(snap))
|
|
|
- .catch(() => {});
|
|
|
-connect();
|
|
|
+boot();
|
|
|
</script>
|
|
|
</body>
|
|
|
</html>
|