ui.html 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591
  1. <!doctype html>
  2. <html data-theme="dark">
  3. <head>
  4. <meta charset="utf-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1">
  6. <title>sm121</title>
  7. <style>
  8. :root[data-theme="dark"] {
  9. --bg: #0d1117; --panel: #161b22; --fg: #e6edf3; --muted: #8b949e;
  10. --border: #30363d; --accent: #58a6ff;
  11. --led-on: #3fb950; --led-off: #f85149; --led-busy: #d29922; --led-free: #30363d;
  12. --glow-on: 0 0 8px 2px rgba(63,185,80,.55); --glow-busy: 0 0 8px 2px rgba(210,153,34,.55);
  13. --btn: #21262d; --btn-hover: #30363d;
  14. }
  15. :root[data-theme="light"] {
  16. --bg: #f6f8fa; --panel: #ffffff; --fg: #1f2328; --muted: #656d76;
  17. --border: #d0d7de; --accent: #0969da;
  18. --led-on: #1a7f37; --led-off: #cf222e; --led-busy: #9a6700; --led-free: #d0d7de;
  19. --glow-on: 0 0 8px 2px rgba(26,127,55,.35); --glow-busy: 0 0 8px 2px rgba(154,103,0,.35);
  20. --btn: #f6f8fa; --btn-hover: #eaeef2;
  21. }
  22. * { box-sizing: border-box; }
  23. body {
  24. margin: 0; background: var(--bg); color: var(--fg);
  25. font: 14px/1.45 ui-sans-serif, system-ui, "SF Pro", sans-serif;
  26. transition: background .25s, color .25s;
  27. }
  28. header {
  29. display: flex; align-items: center; gap: 12px;
  30. padding: 10px 16px; border-bottom: 1px solid var(--border);
  31. background: var(--panel); position: sticky; top: 0; z-index: 5;
  32. }
  33. header h1 { font-size: 15px; margin: 0; font-weight: 650; letter-spacing: .3px; }
  34. header .spacer { flex: 1; }
  35. .ssec { font-size: 12px; color: var(--muted); display: flex; align-items: center; gap: 6px; }
  36. .ssec .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--led-off); }
  37. .ssec.ok .dot { background: var(--led-on); }
  38. .ssec.re .dot { animation: pulse 1s infinite; }
  39. button.tbtn {
  40. background: var(--btn); color: var(--fg); border: 1px solid var(--border);
  41. border-radius: 6px; padding: 4px 10px; cursor: pointer; font: inherit; font-size: 13px;
  42. }
  43. button.tbtn:hover { background: var(--btn-hover); }
  44. main { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; padding: 14px; max-width: 1200px; margin: 0 auto; }
  45. @media (max-width: 860px) { main { grid-template-columns: 1fr; } }
  46. .card {
  47. background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
  48. padding: 14px; min-height: 120px;
  49. }
  50. .card h2 { font-size: 12px; text-transform: uppercase; letter-spacing: .8px; color: var(--muted); margin: 0 0 10px; }
  51. .cluster.anim { animation: winIn .35s ease both; }
  52. .cluster { margin-bottom: 14px; }
  53. .cluster-head { display: flex; align-items: baseline; gap: 8px; margin-bottom: 6px; }
  54. .cluster-head b { font-size: 13px; }
  55. .cluster-head .free { color: var(--muted); font-size: 12px; }
  56. .cluster-head .down { color: var(--led-off); font-size: 12px; }
  57. .cluster-head .soft { color: var(--led-busy); font-size: 12px; }
  58. .node { display: flex; align-items: center; gap: 10px; padding: 5px 0; border-bottom: 1px solid var(--border); }
  59. .node:last-child { border-bottom: none; }
  60. .led {
  61. width: 11px; height: 11px; border-radius: 50%; background: var(--led-off);
  62. transition: background .4s, box-shadow .4s; flex-shrink: 0;
  63. }
  64. .led.on { background: var(--led-on); box-shadow: var(--glow-on); }
  65. .led.b { background: var(--led-busy); box-shadow: var(--glow-busy); }
  66. .led.off { background: var(--led-off); }
  67. .led.flip { animation: flip .45s ease; }
  68. .nname { width: 64px; font-family: ui-monospace, monospace; font-size: 12.5px; }
  69. .nmeta { color: var(--muted); font-size: 12px; flex: 1; overflow: hidden; text-overflow: ellipsis; }
  70. .containers { width: 100%; margin-top: 2px; }
  71. .crow { display: flex; align-items: center; gap: 8px; padding: 2px 0 2px 21px; font-size: 12px; color: var(--muted); }
  72. .cled { width: 7px; height: 7px; border-radius: 50%; background: var(--led-busy); box-shadow: var(--glow-busy); flex-shrink: 0; }
  73. .cname { font-family: ui-monospace, monospace; color: var(--fg); }
  74. .actions { display: flex; gap: 8px; align-items: center; margin: 6px 0 0 21px; flex-wrap: wrap; }
  75. .actions select { font: inherit; }
  76. .actions input[type=checkbox] {
  77. appearance: none; -webkit-appearance: none;
  78. width: 15px; height: 15px; margin: 0; flex-shrink: 0;
  79. border: 1.5px solid var(--border); border-radius: 4px; background: var(--btn);
  80. cursor: pointer; position: relative; transition: background .15s, border-color .15s;
  81. }
  82. .actions input[type=checkbox]:hover { border-color: var(--accent); }
  83. .actions input[type=checkbox]:checked { background: var(--accent); border-color: var(--accent); }
  84. .actions input[type=checkbox]:checked::after {
  85. content: ""; position: absolute; left: 3.5px; top: 0;
  86. width: 4px; height: 8px; border: solid #fff; border-width: 0 2px 2px 0;
  87. transform: rotate(45deg);
  88. }
  89. .actions select {
  90. background: var(--btn); color: var(--fg); border: 1px solid var(--border);
  91. border-radius: 6px; padding: 2px 6px; font-size: 12.5px; max-width: 260px; height: 26px;
  92. }
  93. .actions label { font-size: 12px; color: var(--muted); display: inline-flex; align-items: center; gap: 4px; height: 26px; }
  94. .actions label input { margin: 0; }
  95. .strip { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--border); }
  96. .chip {
  97. display: inline-flex; align-items: center; gap: 6px; cursor: pointer; user-select: none;
  98. border: 1px solid var(--border); border-radius: 999px; padding: 3px 10px; font-size: 12px;
  99. color: var(--muted); background: var(--btn); transition: border-color .15s, color .15s;
  100. }
  101. .chip .m { opacity: .7; }
  102. .chip.active { color: var(--fg); border-color: var(--accent); }
  103. .abtn {
  104. background: var(--btn); color: var(--fg); border: 1px solid var(--border);
  105. border-radius: 6px; padding: 0 12px; cursor: pointer; font: inherit; font-size: 12.5px;
  106. height: 26px; transition: background .15s;
  107. }
  108. .abtn:hover:not(:disabled) { background: var(--btn-hover); }
  109. .abtn:disabled { opacity: .45; cursor: default; }
  110. .abtn.danger { color: var(--led-off); }
  111. .jobs { font-family: ui-monospace, monospace; font-size: 12px; }
  112. .job { display: flex; gap: 8px; align-items: baseline; padding: 4px 0; border-bottom: 1px solid var(--border); }
  113. .job.new { animation: winIn .3s ease both; }
  114. .job:last-child { border-bottom: none; }
  115. .jdot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); flex-shrink: 0; align-self: center; }
  116. .job.ok .jdot { background: var(--led-on); }
  117. .job.failed .jdot { background: var(--led-off); }
  118. .jkind { width: 48px; color: var(--muted); }
  119. .jmsg { color: var(--muted); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  120. .jjob .jmsg { color: var(--fg); }
  121. .jobs-empty { color: var(--muted); font-size: 12.5px; padding: 6px 0; }
  122. /* confirm dialog */
  123. .dlg-back[hidden] { display: none; }
  124. .dlg-back {
  125. position: fixed; inset: 0; background: rgba(0,0,0,.5); z-index: 10;
  126. display: flex; align-items: center; justify-content: center; animation: fadeIn .15s ease;
  127. }
  128. .dlg {
  129. background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
  130. padding: 18px; width: min(460px, 92vw); max-height: 80vh; overflow: auto;
  131. box-shadow: 0 12px 40px rgba(0,0,0,.5); animation: winIn .2s ease both;
  132. }
  133. .dlg h3 { margin: 0 0 10px; font-size: 14px; }
  134. .dlg .body { font-size: 13px; color: var(--fg); margin-bottom: 12px; }
  135. .dlg .body code { font-family: ui-monospace, monospace; font-size: 12px; background: var(--btn); padding: 1px 5px; border-radius: 4px; }
  136. .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; }
  137. .dlg .cbox li { margin: 2px 0; }
  138. .dlg input {
  139. width: 100%; background: var(--btn); color: var(--fg); border: 1px solid var(--border);
  140. border-radius: 6px; padding: 6px 8px; font: inherit; margin: 8px 0;
  141. }
  142. .dlg .row { display: flex; justify-content: flex-end; gap: 8px; }
  143. @keyframes flip { 0% { transform: scale(1); } 40% { transform: scale(1.5); } 100% { transform: scale(1); } }
  144. @keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .35; } }
  145. @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
  146. @keyframes winIn { from { opacity: 0; transform: translateY(-4px) scale(.985); } to { opacity: 1; transform: none; } }
  147. @media (prefers-reduced-motion: reduce) {
  148. *, .led, .led.flip, .ssec.re .dot, .cluster, .job, .dlg { animation: none !important; }
  149. }
  150. </style>
  151. </head>
  152. <body>
  153. <header>
  154. <h1>sm121</h1>
  155. <div class="spacer"></div>
  156. <div class="ssec" id="sse"><span class="dot"></span><span id="sse-t">connecting…</span></div>
  157. <button class="tbtn" id="theme">◐</button>
  158. </header>
  159. <main>
  160. <section class="card" id="state-card"><h2>Cluster state</h2><div id="clusters"></div></section>
  161. <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>
  162. </main>
  163. <div class="dlg-back" id="dlg" hidden>
  164. <div class="dlg">
  165. <h3 id="dlg-title"></h3>
  166. <div class="body" id="dlg-body"></div>
  167. <div class="row">
  168. <button class="tbtn" id="dlg-cancel">Cancel</button>
  169. <button class="abtn" id="dlg-ok">Confirm</button>
  170. </div>
  171. </div>
  172. </div>
  173. <script>
  174. "use strict";
  175. const $ = (s) => document.querySelector(s);
  176. const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
  177. const state = { snap: null, jobs: new Map(), recipes: [], active: localStorage.getItem("sm121-active") || "", panel: null, shown: new Set() };
  178. /*SNAP-LT:START*/
  179. // Pure: same fleet + jobs? (`ts` changes on every probe, so it is
  180. // excluded). Used to skip DOM rebuilds when nothing changed — no blink.
  181. function sameSnapshot(a, b) {
  182. return JSON.stringify({ c: a.clusters, j: a.jobs }) === JSON.stringify({ c: b.clusters, j: b.jobs });
  183. }
  184. /*SNAP-LT:END*/
  185. // --- theme ---------------------------------------------------------------
  186. const root = document.documentElement;
  187. root.dataset.theme = localStorage.getItem("sm121-theme") || (matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark");
  188. $("#theme").onclick = () => {
  189. root.dataset.theme = root.dataset.theme === "dark" ? "light" : "dark";
  190. localStorage.setItem("sm121-theme", root.dataset.theme);
  191. };
  192. // --- SSE -------------------------------------------------------------------
  193. function sse(cls, txt) { $("#sse").className = "ssec " + cls; $("#sse-t").textContent = txt; }
  194. function connectSSE() {
  195. const es = new EventSource("/events");
  196. es.onmessage = (e) => {
  197. sse("ok", "live");
  198. let ev;
  199. try { ev = JSON.parse(e.data); } catch { return; }
  200. if (ev.type === "state") onState(ev.snapshot);
  201. else if (ev.type === "job") onJob(ev);
  202. };
  203. es.onerror = () => { sse("re", "reconnecting…"); };
  204. }
  205. // --- state render ------------------------------------------------------------
  206. function ledState(n) { return n.reachable ? (n.occupied ? "b" : "on") : "off"; }
  207. function meta(n, c) {
  208. if (!n.reachable) return "offline";
  209. if (!c.up) return "online — cluster offline";
  210. if (n.occupied) return n.containers.map((x) => x.status).join(", ");
  211. return "free";
  212. }
  213. function clusterReason(c) {
  214. let r = "";
  215. if (c.up) r = "";
  216. else if (c.switch_off) r = "switch off";
  217. else {
  218. const off = c.nodes.filter((n) => !n.reachable).map((n) => n.name);
  219. r = off.length + " node" + (off.length === 1 ? "" : "s") + " offline" + (off.length ? " (" + off.join(", ") + ")" : "");
  220. }
  221. if (c.soft_off && c.soft_off.length) r = (r ? r + " · " : "") + "⚙ " + c.soft_off.join(", ") + " off";
  222. return r;
  223. }
  224. function renderCluster(c, prev, animate) {
  225. const el = document.createElement("div");
  226. el.className = "cluster" + (animate ? " anim" : "");
  227. const head = document.createElement("div");
  228. head.className = "cluster-head";
  229. if (c.up) {
  230. const soft = c.soft_off && c.soft_off.length ? `<span class="soft" title="non-blocking entity off">⚙ ${esc(c.soft_off.join(", "))} off</span>` : "";
  231. head.innerHTML = `<b>${esc(c.name)}</b><span class="free">${c.free} free</span>${soft}`;
  232. } else {
  233. head.innerHTML = `<b>${esc(c.name)}</b><span class="down">⏻ offline — ${esc(clusterReason(c))}</span>`;
  234. }
  235. el.appendChild(head);
  236. const prevNodes = prev ? Object.fromEntries(prev.nodes.map((n) => [n.name, n])) : {};
  237. for (const n of c.nodes) {
  238. const row = document.createElement("div");
  239. row.className = "node";
  240. const cls = ledState(n);
  241. const flip = prevNodes[n.name] && ledState(prevNodes[n.name]) !== cls ? " flip" : "";
  242. row.innerHTML = `<span class="led ${cls}${flip}"></span>
  243. <span class="nname">${esc(n.name)}</span>
  244. <span class="nmeta" title="${esc(n.ssh_ip)}">${esc(meta(n, c))}</span>`;
  245. el.appendChild(row);
  246. if (n.occupied && n.containers.length) {
  247. const box = document.createElement("div");
  248. box.className = "containers";
  249. for (const x of n.containers) {
  250. const row = document.createElement("div");
  251. row.className = "crow";
  252. row.innerHTML = `<span class="cled"></span><span class="cname">${esc(x.name)}</span><span>${esc(x.recipe)} · ${esc(x.status)}</span>`;
  253. box.appendChild(row);
  254. }
  255. el.appendChild(box);
  256. }
  257. }
  258. el.appendChild(actionBar(c, prevNodes));
  259. return el;
  260. }
  261. function actionBar(c, prevNodes) {
  262. const bar = document.createElement("div");
  263. bar.className = "actions";
  264. if (!c.up) {
  265. // Down cluster: serve/stop are meaningless — only power actions apply.
  266. bar.appendChild(abtn("power-" + c.name, "power on", () => doPower("on", c.name), ""));
  267. bar.appendChild(abtn("pwswitch-" + c.name, "switch", () => doPower("switch", c.name), ""));
  268. return bar;
  269. }
  270. const sel = document.createElement("select");
  271. sel.id = "recipe-" + c.name;
  272. if (!state.recipes.length) {
  273. sel.innerHTML = "<option value=''></option>";
  274. } else {
  275. for (const r of state.recipes) {
  276. const o = document.createElement("option");
  277. o.value = r.name;
  278. o.textContent = r.name + (r.deploy === "tmux" ? " (tmux)" : "");
  279. sel.appendChild(o);
  280. }
  281. }
  282. bar.appendChild(sel);
  283. const dry = document.createElement("label");
  284. dry.innerHTML = `<input type="checkbox" id="dry-${esc(c.name)}"> dry-run`;
  285. bar.appendChild(dry);
  286. bar.appendChild(abtn("serve-" + c.name, "serve", () => doServe(c.name)));
  287. bar.appendChild(abtn("stop-" + c.name, "stop", () => doStop(c.name), "danger"));
  288. bar.appendChild(abtn("power-" + c.name, "power off", () => doPower("off", c.name), "danger"));
  289. bar.appendChild(abtn("pwswitch-" + c.name, "switch", () => doPower("switch", c.name), ""));
  290. return bar;
  291. }
  292. function abtn(id, label, fn, cls) {
  293. const b = document.createElement("button");
  294. b.className = "abtn " + (cls || "");
  295. b.id = id;
  296. b.textContent = label;
  297. b.onclick = fn;
  298. return b;
  299. }
  300. function onState(snap, force) {
  301. const prev = state.snap;
  302. if (!force && prev && sameSnapshot(prev, snap)) return; // nothing changed — no DOM churn
  303. state.snap = snap;
  304. state.jobs = new Map(snap.jobs.map((j) => [j.id, j]));
  305. if (!snap.clusters.find((c) => c.name === state.active)) {
  306. state.active = snap.clusters[0] ? snap.clusters[0].name : "";
  307. }
  308. // control state must survive the rebuild (recipe pick, dry-run tick)
  309. const activeName = state.active;
  310. const keepRecipe = recipeOf(activeName);
  311. const keepDry = dryOf(activeName);
  312. const animate = state.panel !== activeName; // first paint or cluster switch
  313. state.panel = activeName;
  314. const box = $("#clusters");
  315. box.innerHTML = "";
  316. const active = snap.clusters.find((c) => c.name === activeName);
  317. if (active) {
  318. box.appendChild(renderCluster(active, prev ? prev.clusters.find((p) => p.name === activeName) : null, animate));
  319. }
  320. restoreControls(activeName, keepRecipe, keepDry);
  321. // compact fleet strip: one chip per cluster, click to make it active
  322. const strip = document.createElement("div");
  323. strip.className = "strip";
  324. for (const c of snap.clusters) {
  325. const chip = document.createElement("div");
  326. chip.className = "chip" + (c.name === state.active ? " active" : "");
  327. const dot = c.up ? (c.free > 0 ? "on" : "b") : "off";
  328. const m = c.up ? c.free + " free" : "⏻ OFF";
  329. chip.title = clusterReason(c);
  330. chip.innerHTML = `<span class="led ${dot}"></span>${esc(c.name)}<span class="m">${m}</span>`;
  331. chip.onclick = () => { state.active = c.name; localStorage.setItem("sm121-active", c.name); onState(state.snap, true); };
  332. strip.appendChild(chip);
  333. }
  334. box.appendChild(strip);
  335. renderJobs();
  336. }
  337. function restoreControls(name, recipe, dry) {
  338. if (!name) return;
  339. const d = document.getElementById("dry-" + name);
  340. if (d && dry) d.checked = true;
  341. const sel = document.getElementById("recipe-" + name);
  342. if (sel && recipe) {
  343. for (const o of sel.options) if (o.value === recipe) { sel.value = recipe; break; }
  344. }
  345. }
  346. // --- jobs ---------------------------------------------------------------------
  347. // "Running" entries come from the docker labels (sm121.recipe / sm121.cluster)
  348. // on live containers — jobs don't need to have started from this dashboard.
  349. function runningFromLabels() {
  350. const out = [];
  351. if (!state.snap) return out;
  352. for (const c of state.snap.clusters) {
  353. if (!c.up) continue; // down clusters report no containers
  354. for (const n of c.nodes) {
  355. for (const x of n.containers) out.push({ cluster: c.name, node: n.name, name: x.name, recipe: x.recipe, status: x.status });
  356. }
  357. }
  358. return out;
  359. }
  360. function renderJobs() {
  361. const box = $("#jobs");
  362. box.innerHTML = "";
  363. const prevShown = state.shown;
  364. state.shown = new Set();
  365. const row = (key, cls, html) => {
  366. const el = document.createElement("div");
  367. el.className = "job " + cls + (prevShown.has(key) ? "" : " new");
  368. el.innerHTML = html;
  369. box.appendChild(el);
  370. state.shown.add(key);
  371. return el;
  372. };
  373. const run = runningFromLabels();
  374. if (run.length) {
  375. const h = document.createElement("div");
  376. h.className = "jobs-empty"; h.textContent = "running (docker labels)";
  377. box.appendChild(h);
  378. for (const r of run) {
  379. row("c:" + r.cluster + ":" + r.recipe + ":" + r.name, "jjob",
  380. `<span class="jdot"></span><span class="jkind">serve</span>
  381. <span class="jmsg">${esc(r.cluster)}:${esc(r.recipe)} — ${esc(r.name)} on ${esc(r.node)} · ${esc(r.status)}</span>`);
  382. }
  383. }
  384. const js = [...state.jobs.values()].sort((a, b) => b.started - a.started);
  385. 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; }
  386. if (run.length) {
  387. const sep = document.createElement("div");
  388. sep.className = "jobs-empty"; sep.textContent = "actions";
  389. box.appendChild(sep);
  390. }
  391. for (const j of js) {
  392. const cls = j.status === "ok" ? "ok" : j.status === "failed" ? "failed" : "jjob";
  393. const when = new Date(j.started * 1000).toLocaleTimeString();
  394. row("j:" + j.id, cls, `<span class="jdot"></span><span class="jkind">${esc(j.kind)}</span>
  395. <span class="jmsg">${esc(j.cluster)}${j.recipe ? ":" + esc(j.recipe) : ""} — ${esc(j.message)} <span>(${when})</span></span>`);
  396. setBtnBusy(j, j.status === "running");
  397. }
  398. }
  399. function setBtnBusy(j, busy) {
  400. // power jobs disable both the off and switch buttons (the job event
  401. // doesn't carry the action)
  402. const keys = j.kind === "power" ? ["power", "pwswitch"] : [j.kind];
  403. for (const k of keys) {
  404. const el = document.getElementById(k + "-" + j.cluster);
  405. if (el) el.disabled = busy;
  406. }
  407. }
  408. // --- job events ------------------------------------------------------------------
  409. function onJob(ev) {
  410. if (ev.event === "started") {
  411. 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 });
  412. }
  413. const j = state.jobs.get(ev.id);
  414. if (j && ev.event !== "started") {
  415. j.message = ev.message;
  416. j.status = ev.event === "ok" ? "ok" : "failed";
  417. }
  418. renderJobs();
  419. }
  420. // --- actions -----------------------------------------------------------------------
  421. const cur = () => state.snap;
  422. function recipeOf(cluster) {
  423. const sel = document.getElementById("recipe-" + cluster);
  424. return sel ? sel.value : "";
  425. }
  426. function dryOf(cluster) {
  427. const d = document.getElementById("dry-" + cluster);
  428. return d ? d.checked : false;
  429. }
  430. async function api(path, body) {
  431. const res = await fetch(path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
  432. if (!res.ok) {
  433. let m = res.status;
  434. try { m = (await res.json()).error || m; } catch {}
  435. throw new Error(m);
  436. }
  437. return res.json();
  438. }
  439. function doServe(cluster) {
  440. const recipe = recipeOf(cluster);
  441. if (!recipe) { alert("pick a recipe first"); return; }
  442. const dry = dryOf(cluster);
  443. const body = { cluster, recipe, dry_run: dry };
  444. if (dry) {
  445. api("/api/serve", body).then((r) => openDialog("dry-run: " + r.job, "started (watch the jobs window)", () => {}));
  446. return;
  447. }
  448. openDialog(`serve ${recipe} on ${cluster}`, `Launches <code>${esc(recipe)}</code> on cluster <code>${esc(cluster)}</code>.`, () => {
  449. api("/api/serve", body).catch((e) => openDialog("serve failed", esc(e.message), () => {}, true));
  450. });
  451. }
  452. function doStop(cluster) {
  453. const snap = cur();
  454. const c = snap ? snap.clusters.find((x) => x.name === cluster) : null;
  455. const containers = c ? c.nodes.flatMap((n) => n.containers) : [];
  456. const recipe = recipeOf(cluster);
  457. if (!recipe) { alert("pick the recipe to stop first"); return; }
  458. const list = containers.length
  459. ? `<ul class="cbox">${containers.map((x) => `<li><b>${esc(x.name)}</b> — ${esc(x.status)}</li>`).join("")}</ul>`
  460. : `<div class="cbox">no containers detected on ${esc(cluster)} (recipe may not be running here)</div>`;
  461. openDialog(`stop ${recipe}`, `Will run <code>sm121 stop</code> for <code>${esc(recipe)}</code> on <code>${esc(cluster)}</code>. Currently running:${list}`, () => {
  462. api("/api/stop", { cluster, recipe }).catch((e) => openDialog("stop failed", esc(e.message), () => {}, true));
  463. });
  464. }
  465. function doPower(action, cluster) {
  466. if (action === "off") {
  467. const snap = cur();
  468. const c = snap ? snap.clusters.find((x) => x.name === cluster) : null;
  469. const busy = c ? c.nodes.filter((n) => n.occupied) : [];
  470. if (busy.length) {
  471. openDialog(`power off ${cluster} blocked`,
  472. `Cluster has ${busy.length} occupied node(s): <code>${esc(busy.map((n) => n.name).join(", "))}</code>. Stop its workloads first.`,
  473. () => {}, true);
  474. return;
  475. }
  476. twoStep(`power off ${cluster}`,
  477. `Shuts down <b>all nodes</b> of <code>${esc(cluster)}</code> (SSH then Home Assistant). This can kill live traffic.`,
  478. cluster, () => {
  479. api("/api/power", { action: "off", cluster }).catch((e) => openDialog("power failed", esc(e.message), () => {}, true));
  480. });
  481. return;
  482. }
  483. if (action === "switch") {
  484. const snap = cur();
  485. const names = snap ? snap.clusters.map((c) => c.name) : [];
  486. const body = document.createElement("div");
  487. body.className = "body";
  488. let inner = `Power <code>${esc(cluster)}</code> off and bring up another cluster instead.`;
  489. const sel = document.createElement("select");
  490. sel.style.cssText = "width:100%;margin:8px 0;padding:6px;background:var(--btn);color:var(--fg);border:1px solid var(--border);border-radius:6px;";
  491. for (const n of names.filter((n) => n !== cluster)) {
  492. const o = document.createElement("option");
  493. o.value = n; o.textContent = n;
  494. sel.appendChild(o);
  495. }
  496. body.innerHTML = inner;
  497. body.appendChild(sel);
  498. openDialogHtml(`power switch from ${cluster}`, body, () => {
  499. const to = sel.value;
  500. api("/api/power", { action: "switch", cluster, to }).catch((e) => openDialog("power failed", esc(e.message), () => {}, true));
  501. });
  502. return;
  503. }
  504. twoStep(`power on ${cluster}`, `Turns on <code>${esc(cluster)}</code> via Home Assistant (SSH is not used).`, cluster, () => {
  505. api("/api/power", { action: "on", cluster }).catch((e) => openDialog("power failed", esc(e.message), () => {}, true));
  506. });
  507. }
  508. function twoStep(title, bodyHtml, nameToType, go) {
  509. openDialogHtml(title, (() => { const d = document.createElement("div"); d.className = "body"; d.innerHTML = bodyHtml; return d; })(),
  510. () => {}, { twoStep: nameToType, go });
  511. }
  512. // --- dialog -----------------------------------------------------------------------
  513. let dlg = { ok: null, twoStep: null };
  514. function openDialog(title, bodyHtml, go, danger) {
  515. $("#dlg-title").textContent = title;
  516. $("#dlg-body").innerHTML = bodyHtml;
  517. const ok = $("#dlg-ok");
  518. ok.style.display = "";
  519. ok.textContent = "Confirm";
  520. ok.className = "abtn" + (danger ? " danger" : "");
  521. dlg = { ok: go, twoStep: null };
  522. $("#dlg").hidden = false;
  523. }
  524. function openDialogHtml(title, bodyEl, go, opts) {
  525. $("#dlg-title").textContent = title;
  526. $("#dlg-body").innerHTML = "";
  527. $("#dlg-body").appendChild(bodyEl);
  528. const ok = $("#dlg-ok");
  529. ok.style.display = "";
  530. if (opts && opts.twoStep) {
  531. ok.style.display = "none";
  532. const wrap = document.createElement("div");
  533. wrap.innerHTML = `<div style="font-size:12.5px;color:var(--muted)">Type <b>${esc(opts.twoStep)}</b> to confirm.</div>
  534. <input id="dlg-type" placeholder="${esc(opts.twoStep)}" autocomplete="off">`; $("#dlg-body").appendChild(wrap);
  535. const inp = wrap.querySelector("#dlg-type");
  536. inp.oninput = () => {
  537. if (inp.value === opts.twoStep) {
  538. ok.style.display = "";
  539. ok.textContent = "⚡ Confirm power action";
  540. ok.className = "abtn danger";
  541. } else { ok.style.display = "none"; }
  542. };
  543. dlg = { ok: null, twoStep: opts.twoStep, go };
  544. } else {
  545. dlg = { ok: go, twoStep: null };
  546. }
  547. $("#dlg").hidden = false;
  548. }
  549. $("#dlg-ok").onclick = () => {
  550. const go = dlg.twoStep ? dlg.go : dlg.ok;
  551. $("#dlg").hidden = true;
  552. if (go) go();
  553. };
  554. $("#dlg-cancel").onclick = () => { $("#dlg").hidden = true; };
  555. document.addEventListener("keydown", (e) => { if (e.key === "Escape") $("#dlg").hidden = true; });
  556. // --- boot ---------------------------------------------------------------------------
  557. async function boot() {
  558. try {
  559. const res = await fetch("/api/state");
  560. if (res.ok) onState(await res.json());
  561. } catch {}
  562. try {
  563. const res = await fetch("/api/recipes");
  564. if (res.ok) {
  565. const d = await res.json();
  566. state.recipes = (d.recipes || []).sort((a, b) => a.name.localeCompare(b.name));
  567. if (state.snap) onState(state.snap); // re-render with recipes
  568. }
  569. } catch {}
  570. connectSSE();
  571. // 30 s heartbeat refresh (mirrors the server poll; harmless if state is identical)
  572. setInterval(async () => {
  573. try {
  574. const res = await fetch("/api/state");
  575. if (res.ok) onState(await res.json());
  576. } catch {}
  577. }, 30000);
  578. }
  579. boot();
  580. </script>
  581. </body>
  582. </html>