|
@@ -210,6 +210,43 @@
|
|
|
.abtn:disabled { animation: pulse 1.2s infinite; }
|
|
.abtn:disabled { animation: pulse 1.2s infinite; }
|
|
|
.job.jjob .jdot { animation: pulse 1.2s infinite; }
|
|
.job.jjob .jdot { animation: pulse 1.2s infinite; }
|
|
|
|
|
|
|
|
|
|
+ /* --- serve flags (CLI parity) + node logs button + dock menu ---------------- */
|
|
|
|
|
+ .fnode {
|
|
|
|
|
+ background: var(--btn); color: var(--muted); border: 1px solid var(--border);
|
|
|
|
|
+ border-radius: 6px; padding: 2px 6px; font-size: 12px; height: 26px; max-width: 140px;
|
|
|
|
|
+ }
|
|
|
|
|
+ .flags summary {
|
|
|
|
|
+ cursor: pointer; font-size: 12px; color: var(--muted); height: 26px;
|
|
|
|
|
+ display: inline-flex; align-items: center; padding: 0 8px;
|
|
|
|
|
+ border: 1px dashed var(--border); border-radius: 6px; user-select: none;
|
|
|
|
|
+ }
|
|
|
|
|
+ .flags summary:hover { color: var(--fg); border-color: var(--accent); }
|
|
|
|
|
+ .flags-in {
|
|
|
|
|
+ display: flex; gap: 10px; flex-wrap: wrap; margin-top: 8px;
|
|
|
|
|
+ padding: 8px; background: var(--btn); border-radius: 6px;
|
|
|
|
|
+ }
|
|
|
|
|
+ .flags-in label { font-size: 12px; color: var(--muted); display: inline-flex; align-items: center; gap: 4px; }
|
|
|
|
|
+ .flags-in input[type=checkbox] {
|
|
|
|
|
+ appearance: none; -webkit-appearance: none; width: 14px; height: 14px; margin: 0;
|
|
|
|
|
+ border: 1.5px solid var(--border); border-radius: 4px; background: var(--bg);
|
|
|
|
|
+ cursor: pointer; position: relative;
|
|
|
|
|
+ }
|
|
|
|
|
+ .flags-in input[type=checkbox]:checked { background: var(--accent); border-color: var(--accent); }
|
|
|
|
|
+ .flags-in input[type=checkbox]:checked::after {
|
|
|
|
|
+ content: ""; position: absolute; left: 3px; top: 0; width: 3.5px; height: 7px;
|
|
|
|
|
+ border: solid #fff; border-width: 0 2px 2px 0; transform: rotate(45deg);
|
|
|
|
|
+ }
|
|
|
|
|
+ .flags-in input[type=text] {
|
|
|
|
|
+ background: var(--bg); color: var(--fg); border: 1px solid var(--border);
|
|
|
|
|
+ border-radius: 5px; padding: 1px 5px; font: inherit; font-size: 12px; height: 22px;
|
|
|
|
|
+ }
|
|
|
|
|
+ .logsbtn { margin-left: auto; height: 20px; font-size: 11px; padding: 0 8px; flex-shrink: 0; }
|
|
|
|
|
+ .dockmenu {
|
|
|
|
|
+ position: fixed; bottom: 44px; right: 12px; display: flex; flex-direction: column; gap: 4px;
|
|
|
|
|
+ background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 8px;
|
|
|
|
|
+ box-shadow: 0 12px 40px rgba(0,0,0,.5); z-index: 300; animation: winIn .15s ease both;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
/* --- confirm dialog ------------------------------------------------------- */
|
|
/* --- confirm dialog ------------------------------------------------------- */
|
|
|
.dlg-back[hidden] { display: none; }
|
|
.dlg-back[hidden] { display: none; }
|
|
|
.dlg-back {
|
|
.dlg-back {
|
|
@@ -263,7 +300,32 @@
|
|
|
"use strict";
|
|
"use strict";
|
|
|
const $ = (s) => document.querySelector(s);
|
|
const $ = (s) => document.querySelector(s);
|
|
|
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));
|
|
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() };
|
|
|
|
|
|
|
+const state = { snap: null, jobs: new Map(), recipes: [], active: localStorage.getItem("sm121-active") || "", panel: null, shown: new Set(), flags: {} };
|
|
|
|
|
+
|
|
|
|
|
+/*FLAGS-LT:START*/
|
|
|
|
|
+// Pure: build the POST /api/serve body from cluster + recipe + dry + the
|
|
|
|
|
+// per-cluster flags state. Absent keys are omitted (server defaults win):
|
|
|
|
|
+// fit "" = bare --fit (whole cluster), mirror "" = bare --mirror (default
|
|
|
|
|
+// path); a non-empty path wins over the bare checkbox.
|
|
|
|
|
+function serveBody(cluster, recipe, dry, f) {
|
|
|
|
|
+ const body = { cluster, recipe, dry_run: !!dry };
|
|
|
|
|
+ if (f.node) body.node = f.node;
|
|
|
|
|
+ if (f.solo) body.solo = true;
|
|
|
|
|
+ if (f.force) body.force = true;
|
|
|
|
|
+ if (f.clear) body.clear = true;
|
|
|
|
|
+ if (f.keep) body.keep = true;
|
|
|
|
|
+ const fit = String(f.fit || "").trim();
|
|
|
|
|
+ if (fit) body.fit = fit;
|
|
|
|
|
+ else if (f.fitAll) body.fit = "";
|
|
|
|
|
+ const mir = String(f.mirror || "").trim();
|
|
|
|
|
+ if (mir) body.mirror = mir;
|
|
|
|
|
+ else if (f.mirrorAll) body.mirror = "";
|
|
|
|
|
+ return body;
|
|
|
|
|
+}
|
|
|
|
|
+function flagsOf(cluster) {
|
|
|
|
|
+ return (state.flags[cluster] = state.flags[cluster] || { node: "", solo: false, force: false, clear: false, keep: false, fit: "", fitAll: false, mirror: "", mirrorAll: false });
|
|
|
|
|
+}
|
|
|
|
|
+/*FLAGS-LT:END*/
|
|
|
|
|
|
|
|
/*SNAP-LT:START*/
|
|
/*SNAP-LT:START*/
|
|
|
// Pure: same fleet + jobs? (`ts` changes on every probe, so it is
|
|
// Pure: same fleet + jobs? (`ts` changes on every probe, so it is
|
|
@@ -368,8 +430,57 @@ function renderDock() {
|
|
|
}
|
|
}
|
|
|
for (const w of logWins.values()) {
|
|
for (const w of logWins.values()) {
|
|
|
if (w.win.style.display === "none") chip("– logs: " + w.node, () => { w.win.style.display = ""; front(w.win); renderDock(); });
|
|
if (w.win.style.display === "none") chip("– logs: " + w.node, () => { w.win.style.display = ""; front(w.win); renderDock(); });
|
|
|
|
|
+ }
|
|
|
if (!rawWin) chip("+ raw config / recipe / effective", openRaw);
|
|
if (!rawWin) chip("+ raw config / recipe / effective", openRaw);
|
|
|
|
|
+ // New-window menu (plan): State / Logs / Jobs / Raw.
|
|
|
|
|
+ const plus = document.createElement("button");
|
|
|
|
|
+ plus.className = "dock-chip plus";
|
|
|
|
|
+ plus.textContent = "+";
|
|
|
|
|
+ plus.title = "new window";
|
|
|
|
|
+ plus.onclick = (e) => { e.stopPropagation(); toggleDockMenu(); };
|
|
|
|
|
+ dock.appendChild(plus);
|
|
|
|
|
+}
|
|
|
|
|
+let dockMenuEl = null;
|
|
|
|
|
+function closeDockMenu() { if (dockMenuEl) { dockMenuEl.remove(); dockMenuEl = null; } }
|
|
|
|
|
+function toggleDockMenu() {
|
|
|
|
|
+ if (dockMenuEl) { closeDockMenu(); return; }
|
|
|
|
|
+ const m = document.createElement("div");
|
|
|
|
|
+ m.className = "dockmenu";
|
|
|
|
|
+ const item = (label, fn) => {
|
|
|
|
|
+ const b = document.createElement("button");
|
|
|
|
|
+ b.className = "dock-chip";
|
|
|
|
|
+ b.textContent = label;
|
|
|
|
|
+ b.onclick = () => { closeDockMenu(); fn(); };
|
|
|
|
|
+ m.appendChild(b);
|
|
|
|
|
+ };
|
|
|
|
|
+ item("state", () => { (wins.state.isMinimized() ? wins.state.restore() : front(wins.state.win)); });
|
|
|
|
|
+ item("jobs", () => { (wins.jobs.isMinimized() ? wins.jobs.restore() : front(wins.jobs.win)); });
|
|
|
|
|
+ item("logs…", openLogsPicker);
|
|
|
|
|
+ item("raw", openRaw);
|
|
|
|
|
+ document.body.appendChild(m);
|
|
|
|
|
+ dockMenuEl = m;
|
|
|
|
|
+ setTimeout(() => addEventListener("click", closeDockMenu, { once: true }), 0);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// Open a logs window from the dock: pick a node (★ = running containers).
|
|
|
|
|
+function openLogsPicker() {
|
|
|
|
|
+ const snap = state.snap;
|
|
|
|
|
+ const body = document.createElement("div");
|
|
|
|
|
+ body.className = "body";
|
|
|
|
|
+ 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;";
|
|
|
|
|
+ if (snap) for (const c of snap.clusters) for (const n of c.nodes) {
|
|
|
|
|
+ const o = document.createElement("option");
|
|
|
|
|
+ o.value = c.name + "|" + n.name;
|
|
|
|
|
+ o.textContent = (n.containers.length ? "★ " : "") + c.name + " / " + n.name + (n.containers.length ? " (" + n.containers.length + ")" : "");
|
|
|
|
|
+ sel.appendChild(o);
|
|
|
}
|
|
}
|
|
|
|
|
+ body.innerHTML = `Open a logs window for a node (★ = has sm121 containers).`;
|
|
|
|
|
+ body.appendChild(sel);
|
|
|
|
|
+ openDialogHtml("open logs", body, () => {
|
|
|
|
|
+ const i = sel.value.indexOf("|");
|
|
|
|
|
+ openLog(sel.value.slice(0, i), sel.value.slice(i + 1));
|
|
|
|
|
+ });
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// --- SSE -------------------------------------------------------------------
|
|
// --- SSE -------------------------------------------------------------------
|
|
@@ -427,7 +538,9 @@ function renderCluster(c, prev, animate) {
|
|
|
const flip = prevNodes[n.name] && ledState(prevNodes[n.name]) !== cls ? " flip" : "";
|
|
const flip = prevNodes[n.name] && ledState(prevNodes[n.name]) !== cls ? " flip" : "";
|
|
|
row.innerHTML = `<span class="led ${cls}${flip}"></span>
|
|
row.innerHTML = `<span class="led ${cls}${flip}"></span>
|
|
|
<span class="nname">${esc(n.name)}</span>
|
|
<span class="nname">${esc(n.name)}</span>
|
|
|
- <span class="nmeta" title="${esc(n.ssh_ip)}">${esc(meta(n, c))}</span>`;
|
|
|
|
|
|
|
+ <span class="nmeta" title="${esc(n.ssh_ip)}">${esc(meta(n, c))}</span>
|
|
|
|
|
+ <button class="abtn logsbtn" title="open logs for ${esc(n.name)}">▤ logs</button>`;
|
|
|
|
|
+ row.querySelector(".logsbtn").onclick = (e) => { e.stopPropagation(); openLog(c.name, n.name); };
|
|
|
row.onclick = () => openLog(c.name, n.name);
|
|
row.onclick = () => openLog(c.name, n.name);
|
|
|
el.appendChild(row);
|
|
el.appendChild(row);
|
|
|
if (n.occupied && n.containers.length) {
|
|
if (n.occupied && n.containers.length) {
|
|
@@ -471,6 +584,41 @@ function actionBar(c, prevNodes) {
|
|
|
const dry = document.createElement("label");
|
|
const dry = document.createElement("label");
|
|
|
dry.innerHTML = `<input type="checkbox" id="dry-${esc(c.name)}"> dry-run`;
|
|
dry.innerHTML = `<input type="checkbox" id="dry-${esc(c.name)}"> dry-run`;
|
|
|
bar.appendChild(dry);
|
|
bar.appendChild(dry);
|
|
|
|
|
+ // Serve flags (CLI parity): solo / force / clear / keep / fit / mirror / node.
|
|
|
|
|
+ // Values persist in state.flags across re-renders.
|
|
|
|
|
+ const f = flagsOf(c.name);
|
|
|
|
|
+ const nodeSel = document.createElement("select");
|
|
|
|
|
+ nodeSel.className = "fnode";
|
|
|
|
|
+ nodeSel.title = "target node (default: cluster selection)";
|
|
|
|
|
+ nodeSel.innerHTML = `<option value=''>node: auto</option>` + c.nodes.map((n) => `<option value='${esc(n.name)}'>${esc(n.name)}</option>`).join("");
|
|
|
|
|
+ nodeSel.value = f.node || "";
|
|
|
|
|
+ nodeSel.onchange = () => { f.node = nodeSel.value; };
|
|
|
|
|
+ const det = document.createElement("details");
|
|
|
|
|
+ det.className = "flags";
|
|
|
|
|
+ det.innerHTML = `<summary title="solo · force · fit · clear · mirror · keep (keep containers after stop)">⚙ flags</summary>
|
|
|
|
|
+ <div class="flags-in">
|
|
|
|
|
+ <label><input type="checkbox" id="fsolo-${esc(c.name)}"> solo</label>
|
|
|
|
|
+ <label><input type="checkbox" id="fforce-${esc(c.name)}"> force</label>
|
|
|
|
|
+ <label><input type="checkbox" id="fclear-${esc(c.name)}" title="clear cgroup memory limits (sudo) on launch nodes"> clear</label>
|
|
|
|
|
+ <label><input type="checkbox" id="fkeep-${esc(c.name)}" title="keep containers after stop"> keep</label>
|
|
|
|
|
+ <label title="scale TP to N nodes / whole cluster">fit <input type="checkbox" id="ffitb-${esc(c.name)}" title="bare --fit: whole cluster">all <input type="text" id="ffit-${esc(c.name)}" placeholder="N" size="3"></label>
|
|
|
|
|
+ <label title="per-node HF mirror">mirror <input type="checkbox" id="fmirr-${esc(c.name)}" title="bare --mirror: ~/.cache/hf-models">~ <input type="text" id="fmirror-${esc(c.name)}" placeholder="~/.cache/hf-models" size="18"></label>
|
|
|
|
|
+ </div>`;
|
|
|
|
|
+ const wire = (id, key, isText) => {
|
|
|
|
|
+ const el = det.querySelector("#" + id);
|
|
|
|
|
+ if (isText) { el.value = f[key] || ""; el.oninput = () => { f[key] = el.value; }; }
|
|
|
|
|
+ else { el.checked = !!f[key]; el.onchange = () => { f[key] = el.checked; }; }
|
|
|
|
|
+ };
|
|
|
|
|
+ wire("fsolo-" + c.name, "solo");
|
|
|
|
|
+ wire("fforce-" + c.name, "force");
|
|
|
|
|
+ wire("fclear-" + c.name, "clear");
|
|
|
|
|
+ wire("fkeep-" + c.name, "keep");
|
|
|
|
|
+ wire("ffitb-" + c.name, "fitAll");
|
|
|
|
|
+ wire("ffit-" + c.name, "fit", true);
|
|
|
|
|
+ wire("fmirr-" + c.name, "mirrorAll");
|
|
|
|
|
+ wire("fmirror-" + c.name, "mirror", true);
|
|
|
|
|
+ bar.appendChild(det);
|
|
|
|
|
+ bar.appendChild(nodeSel);
|
|
|
bar.appendChild(abtn("serve-" + c.name, "serve", () => doServe(c.name)));
|
|
bar.appendChild(abtn("serve-" + c.name, "serve", () => doServe(c.name)));
|
|
|
bar.appendChild(abtn("stop-" + c.name, "stop", () => doStop(c.name), "danger"));
|
|
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("power-" + c.name, "power off", () => doPower("off", c.name), "danger"));
|
|
@@ -795,9 +943,8 @@ async function api(path, body) {
|
|
|
function doServe(cluster) {
|
|
function doServe(cluster) {
|
|
|
const recipe = recipeOf(cluster);
|
|
const recipe = recipeOf(cluster);
|
|
|
if (!recipe) { alert("pick a recipe first"); return; }
|
|
if (!recipe) { alert("pick a recipe first"); return; }
|
|
|
- const dry = dryOf(cluster);
|
|
|
|
|
- const body = { cluster, recipe, dry_run: dry };
|
|
|
|
|
- if (dry) {
|
|
|
|
|
|
|
+ const body = serveBody(cluster, recipe, dryOf(cluster), flagsOf(cluster));
|
|
|
|
|
+ if (body.dry_run) {
|
|
|
api("/api/serve", body).then((r) => openDialog("dry-run: " + r.job, "started (watch the jobs window)", () => {}));
|
|
api("/api/serve", body).then((r) => openDialog("dry-run: " + r.job, "started (watch the jobs window)", () => {}));
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
@@ -811,11 +958,19 @@ function doStop(cluster) {
|
|
|
const containers = c ? c.nodes.flatMap((n) => n.containers) : [];
|
|
const containers = c ? c.nodes.flatMap((n) => n.containers) : [];
|
|
|
const recipe = recipeOf(cluster);
|
|
const recipe = recipeOf(cluster);
|
|
|
if (!recipe) { alert("pick the recipe to stop first"); return; }
|
|
if (!recipe) { alert("pick the recipe to stop first"); return; }
|
|
|
|
|
+ const body = document.createElement("div");
|
|
|
|
|
+ body.className = "body";
|
|
|
|
|
+ const nodeSel = document.createElement("select");
|
|
|
|
|
+ nodeSel.id = "stopnode";
|
|
|
|
|
+ nodeSel.title = "stop on one node instead of all";
|
|
|
|
|
+ nodeSel.innerHTML = `<option value=''>all nodes</option>` + (c ? c.nodes.map((n) => `<option value='${esc(n.name)}'>${esc(n.name)}</option>`).join("") : "");
|
|
|
const list = containers.length
|
|
const list = containers.length
|
|
|
? `<ul class="cbox">${containers.map((x) => `<li><b>${esc(x.name)}</b> — ${esc(x.status)}</li>`).join("")}</ul>`
|
|
? `<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>`;
|
|
: `<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));
|
|
|
|
|
|
|
+ body.innerHTML = `Will run <code>sm121 stop</code> for <code>${esc(recipe)}</code> on <code>${esc(cluster)}</code>. Node: <span data-stopnode></span>. Currently running:${list}`;
|
|
|
|
|
+ body.querySelector("[data-stopnode]").replaceWith(nodeSel);
|
|
|
|
|
+ openDialogHtml(`stop ${recipe}`, body, () => {
|
|
|
|
|
+ api("/api/stop", { cluster, recipe, node: nodeSel.value || undefined }).catch((e) => openDialog("stop failed", esc(e.message), () => {}, true));
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|
|
|
function doPower(action, cluster) {
|
|
function doPower(action, cluster) {
|