|
@@ -93,6 +93,7 @@
|
|
|
<select id="size"><option>25</option><option selected>50</option><option>100</option><option value="0">All</option></select>
|
|
<select id="size"><option>25</option><option selected>50</option><option>100</option><option value="0">All</option></select>
|
|
|
</label>
|
|
</label>
|
|
|
<button id="prev">‹</button><span id="count"></span><button id="next">›</button>
|
|
<button id="prev">‹</button><span id="count"></span><button id="next">›</button>
|
|
|
|
|
+ <button id="syncbtn" title="sync the recipe repos now (dashboard job; new recipes appear via the live stream)" style="display:none">⟳ sync</button>
|
|
|
<span id="live" class="dim" title="live updates">● off</span>
|
|
<span id="live" class="dim" title="live updates">● off</span>
|
|
|
<button id="bell" title="updates" aria-label="updates">
|
|
<button id="bell" title="updates" aria-label="updates">
|
|
|
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/></svg>
|
|
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/></svg>
|
|
@@ -182,7 +183,7 @@ function cell(r, k) {
|
|
|
if (k === "pct") { const e = latest(r, e => e.pct != null); return e ? e.pct : ""; }
|
|
if (k === "pct") { const e = latest(r, e => e.pct != null); return e ? e.pct : ""; }
|
|
|
if (k === "tps1" || k === "tps2") { const e = latest(r, e => e.perf && e.perf.length); return tg(e, 0, k === "tps1" ? 1 : 2) ?? ""; }
|
|
if (k === "tps1" || k === "tps2") { const e = latest(r, e => e.perf && e.perf.length); return tg(e, 0, k === "tps1" ? 1 : 2) ?? ""; }
|
|
|
if (k === "stars") { const si = starInfo(r); return si ? si.pct : -1; } // sort by the absolute score
|
|
if (k === "stars") { const si = starInfo(r); return si ? si.pct : -1; } // sort by the absolute score
|
|
|
- if (k === "state" || k === "uptime") return r.up != null ? nowSecs() - r.up : -1; // live uptime secs
|
|
|
|
|
|
|
+ if (k === "state" || k === "uptime") return r.up != null ? nowSecs() - r.up : r.fleet_up ? 0 : -1; // live uptime secs (fleet-up: unknown start)
|
|
|
if (k === "runs") return r.runs ?? 0;
|
|
if (k === "runs") return r.runs ?? 0;
|
|
|
return r[k];
|
|
return r[k];
|
|
|
}
|
|
}
|
|
@@ -378,7 +379,7 @@ const esc = s => String(s).replace(/[&<>"]/g, c => ({"&":"&","<":"<",">":
|
|
|
function filtered() {
|
|
function filtered() {
|
|
|
const q = S.q.toLowerCase();
|
|
const q = S.q.toLowerCase();
|
|
|
return DATA.filter(r => (!S.tp || String(r.tp) === S.tp) &&
|
|
return DATA.filter(r => (!S.tp || String(r.tp) === S.tp) &&
|
|
|
- (!S.uponly || r.up != null) &&
|
|
|
|
|
|
|
+ (!S.uponly || r.up != null || r.fleet_up) &&
|
|
|
(!S.medal || (S.medal === "m" ? (r.medals && (r.medals.p || r.medals.m || r.medals.t)) : r.medals && String(r.medals.p) === S.medal)) &&
|
|
(!S.medal || (S.medal === "m" ? (r.medals && (r.medals.p || r.medals.m || r.medals.t)) : r.medals && String(r.medals.p) === S.medal)) &&
|
|
|
(!q || (r.name + " " + r.title + " " + r.model + " " + r.parent + " " + r.quant + " " +
|
|
(!q || (r.name + " " + r.title + " " + r.model + " " + r.parent + " " + r.quant + " " +
|
|
|
r.engine + " " + r.image + " " + r.deploy + " " +
|
|
r.engine + " " + r.image + " " + r.deploy + " " +
|
|
@@ -469,11 +470,15 @@ function render() {
|
|
|
? (r.stale
|
|
? (r.stale
|
|
|
? `<span title="ledger says running but no live container found (node down? killed serve?) — verify and close: sm121 runlock close ${esc(r.name)}"><span style="color:#d2822a">●</span><span class="dim"> stale</span></span>`
|
|
? `<span title="ledger says running but no live container found (node down? killed serve?) — verify and close: sm121 runlock close ${esc(r.name)}"><span style="color:#d2822a">●</span><span class="dim"> stale</span></span>`
|
|
|
: `<span title="up since ${fmtSince(r.up)} UTC"><span style="color:green">●</span><span class="dim"> up</span></span>`)
|
|
: `<span title="up since ${fmtSince(r.up)} UTC"><span style="color:green">●</span><span class="dim"> up</span></span>`)
|
|
|
- : '<span class="dim" title="not running">○</span>';
|
|
|
|
|
|
|
+ : (r.fleet_up
|
|
|
|
|
+ ? `<span title="running in the fleet — a container was found on a cluster node, but this machine has no local run ledger (served by another launcher)"><span style="color:green">●</span><span class="dim"> up</span><span class="dim" style="font-size:.8em">(fleet)</span></span>`
|
|
|
|
|
+ : '<span class="dim" title="not running">○</span>')
|
|
|
|
|
+ + actions(r);
|
|
|
} else if (c[0] === "uptime") {
|
|
} else if (c[0] === "uptime") {
|
|
|
body = r.up != null ? fmtUp(v) : '<span class="dim">—</span>';
|
|
body = r.up != null ? fmtUp(v) : '<span class="dim">—</span>';
|
|
|
} else if (c[0] === "runs") {
|
|
} else if (c[0] === "runs") {
|
|
|
- body = v > 0 ? esc(v) : '<span class="dim">0</span>';
|
|
|
|
|
|
|
+ body = v > 0 ? esc(v)
|
|
|
|
|
+ : '<span class="dim" title="no launch ledger on this machine (or never launched)">—</span>';
|
|
|
} else if (v === "" || v == null) {
|
|
} else if (v === "" || v == null) {
|
|
|
body = '<span class="dim">—</span>';
|
|
body = '<span class="dim">—</span>';
|
|
|
} else if (c[0] === "name") {
|
|
} else if (c[0] === "name") {
|
|
@@ -546,10 +551,54 @@ document.getElementById("medal").addEventListener("change", e => { S.medal = e.t
|
|
|
document.getElementById("uponly").addEventListener("change", e => { S.uponly = e.target.checked; S.page = 0; render(); });
|
|
document.getElementById("uponly").addEventListener("change", e => { S.uponly = e.target.checked; S.page = 0; render(); });
|
|
|
// live uptime: re-render while anything is running (the cell values are
|
|
// live uptime: re-render while anything is running (the cell values are
|
|
|
// computed at render time)
|
|
// computed at render time)
|
|
|
-setInterval(() => { if (DATA.some(r => r.up != null)) render(); }, 30000);
|
|
|
|
|
|
|
+setInterval(() => { if (DATA.some(r => r.up != null || r.fleet_up)) render(); }, 30000);
|
|
|
document.getElementById("size").addEventListener("change", () => { S.page = 0; render(); });
|
|
document.getElementById("size").addEventListener("change", () => { S.page = 0; render(); });
|
|
|
document.getElementById("prev").addEventListener("click", () => { if (S.page > 0) { S.page--; render(); } });
|
|
document.getElementById("prev").addEventListener("click", () => { if (S.page > 0) { S.page--; render(); } });
|
|
|
document.getElementById("next").addEventListener("click", () => { S.page++; render(); });
|
|
document.getElementById("next").addEventListener("click", () => { S.page++; render(); });
|
|
|
|
|
+// ---- dashboard actions (hosted/embed mode only) --------------------------
|
|
|
|
|
+// The standalone `report --serve` has no job/repo API — the buttons exist
|
|
|
|
|
+// only when the page is mounted by the web dashboard (body.embed), and they
|
|
|
|
|
+// call the dashboard's same-origin endpoints (the session cookie rides
|
|
|
|
|
+// along). State flips come back through the fleet-probe live stream.
|
|
|
|
|
+const EMBED = document.body.classList.contains("embed");
|
|
|
|
|
+function actions(r) {
|
|
|
|
|
+ if (!EMBED) return "";
|
|
|
|
|
+ const running = r.up != null || r.fleet_up;
|
|
|
|
|
+ return running
|
|
|
|
|
+ ? `<button class="act" data-act="stop" data-r="${esc(r.name)}" title="stop (dashboard job)">⏹</button>`
|
|
|
|
|
+ : `<button class="act" data-act="serve" data-r="${esc(r.name)}" title="serve (dashboard job, cluster auto)">▶</button>`;
|
|
|
|
|
+}
|
|
|
|
|
+document.addEventListener("click", async (e) => {
|
|
|
|
|
+ const b = e.target.closest("button.act");
|
|
|
|
|
+ if (!b) return;
|
|
|
|
|
+ e.stopPropagation(); // no row toggle under the action button
|
|
|
|
|
+ const kind = b.dataset.act, recipe = b.dataset.r;
|
|
|
|
|
+ b.disabled = true;
|
|
|
|
|
+ const label0 = b.textContent;
|
|
|
|
|
+ try {
|
|
|
|
|
+ const res = await fetch(kind, { // "/api/serve" | "/api/stop"
|
|
|
|
|
+ method: "POST",
|
|
|
|
|
+ headers: { "Content-Type": "application/json" },
|
|
|
|
|
+ body: JSON.stringify({ cluster: "auto", recipe }),
|
|
|
|
|
+ });
|
|
|
|
|
+ const j = await res.json().catch(() => ({}));
|
|
|
|
|
+ if (j.job != null) { b.textContent = "…"; b.title = `job ${j.job} running — the state cell updates when the fleet probe sees it`; }
|
|
|
|
|
+ else { b.textContent = "✗"; b.title = j.error || String(res.status); }
|
|
|
|
|
+ } catch (err) { b.textContent = "✗"; b.title = String(err); }
|
|
|
|
|
+ setTimeout(() => { b.disabled = false; b.textContent = label0; b.title = kind === "stop" ? "stop (dashboard job)" : "serve (dashboard job, cluster auto)"; }, 5000);
|
|
|
|
|
+});
|
|
|
|
|
+const syncBtn = document.getElementById("syncbtn");
|
|
|
|
|
+if (EMBED) syncBtn.style.display = "";
|
|
|
|
|
+syncBtn.addEventListener("click", async () => {
|
|
|
|
|
+ syncBtn.disabled = true; syncBtn.textContent = "⟳ …";
|
|
|
|
|
+ try {
|
|
|
|
|
+ const res = await fetch("/api/repos/sync", { method: "POST" });
|
|
|
|
|
+ const j = await res.json().catch(() => ({}));
|
|
|
|
|
+ syncBtn.textContent = j.ok ? `⟳ ${j.repos} ok` : "⟳ ✗";
|
|
|
|
|
+ syncBtn.title = j.detail || (j.ok ? "recipe repos synced" : String(res.status));
|
|
|
|
|
+ } catch (err) { syncBtn.textContent = "⟳ ✗"; syncBtn.title = String(err); }
|
|
|
|
|
+ setTimeout(() => { syncBtn.disabled = false; syncBtn.textContent = "⟳ sync"; }, 6000);
|
|
|
|
|
+});
|
|
|
// column checkboxes
|
|
// column checkboxes
|
|
|
document.getElementById("colbox").innerHTML = COLS.map(c =>
|
|
document.getElementById("colbox").innerHTML = COLS.map(c =>
|
|
|
`<label><input type="checkbox" data-k="${c[0]}" ${S.hidden.has(c[0]) ? "" : "checked"}> ${esc(c[1])}</label>`).join("");
|
|
`<label><input type="checkbox" data-k="${c[0]}" ${S.hidden.has(c[0]) ? "" : "checked"}> ${esc(c[1])}</label>`).join("");
|