|
|
@@ -20,6 +20,14 @@
|
|
|
tr.g1 td { font-weight: 700; background: color-mix(in srgb, currentColor 8%, canvas); cursor: pointer; }
|
|
|
tr.g2 td { font-style: italic; background: color-mix(in srgb, currentColor 4%, canvas); cursor: pointer; }
|
|
|
tr.hist td { font-family: ui-monospace, monospace; font-size: 12px; background: color-mix(in srgb, currentColor 3%, canvas); }
|
|
|
+ tr.hist .actbar { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; margin: 2px 0 6px; }
|
|
|
+ tr.hist .actbar button.act { padding: 2px 8px; font-size: 12px; }
|
|
|
+ tr.hist .actnote { font-size: 11px; color: gray; }
|
|
|
+ tr.hist .logbox { display: flex; gap: 8px; align-items: flex-start; margin: 6px 0; }
|
|
|
+ tr.hist .logbox select { font: 11px ui-monospace, monospace; }
|
|
|
+ tr.hist .logbox pre { flex: 1; min-width: 0; font: 11px/1.4 ui-monospace, monospace;
|
|
|
+ background: color-mix(in srgb, currentColor 5%, canvas); border: 1px solid color-mix(in srgb, currentColor 15%, transparent);
|
|
|
+ border-radius: 3px; padding: 6px; max-height: 240px; overflow: auto; white-space: pre-wrap; word-break: break-word; }
|
|
|
a.open { color: inherit; text-decoration: none; border: 1px solid color-mix(in srgb, currentColor 30%, transparent); border-radius: 3px; padding: 0 4px; }
|
|
|
.prov { font-family: ui-monospace, monospace; font-size: 10px; opacity: .8; white-space: nowrap; }
|
|
|
a.open:hover { background: color-mix(in srgb, currentColor 12%, canvas); }
|
|
|
@@ -472,8 +480,7 @@ function render() {
|
|
|
: `<span title="up since ${fmtSince(r.up)} UTC"><span style="color:green">●</span><span class="dim"> up</span></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);
|
|
|
+ : '<span class="dim" title="not running">○</span>');
|
|
|
} else if (c[0] === "uptime") {
|
|
|
body = r.up != null ? fmtUp(v) : '<span class="dim">—</span>';
|
|
|
} else if (c[0] === "runs") {
|
|
|
@@ -508,10 +515,22 @@ function render() {
|
|
|
`<a class="open" href="${auth("/r/" + encodeURIComponent(r.name) + "/" + encodeURIComponent(f))}" target="_blank" title="open ${esc(f)}">${esc(f.split('.').pop())}</a>`).join(" ")}</span> ` +
|
|
|
`${esc(e.date)}${e.time ? "·" + esc(e.time) : ""} ${esc(e.label)} — ${e.pct != null ? esc(e.pct) + "/100" : '<span class="dim">no score</span>'}${e.points ? " (" + esc(e.points) + ")" : ""}${ts}${chip}`;
|
|
|
}).join("<br>");
|
|
|
- out.push(`<tr class="hist"><td colspan="${vis.length}">${toml}${r.reports.length ? " " + lines : ' <span class="dim">no reports</span>'}</td></tr>`);
|
|
|
+ const logbox = EMBED && LOG.recipe === r.name && LOG.opts.length
|
|
|
+ ? `<div class="logbox"><select class="lognode" title="node streaming">${LOG.opts.map((o, i) => `<option value="${i}"${i === LOG.cur ? " selected" : ""}>${esc(o.cluster)}/${esc(o.node)}</option>`).join("")}</select><pre class="logpre">${esc(LOG.raw)}</pre></div>`
|
|
|
+ : "";
|
|
|
+ out.push(`<tr class="hist"><td colspan="${vis.length}">${actionBar(r)}${logbox}<div>${toml}${r.reports.length ? " " + lines : ' <span class="dim">no reports</span>'}</div></td></tr>`);
|
|
|
}
|
|
|
}
|
|
|
document.querySelector("tbody").innerHTML = out.join("");
|
|
|
+ // Rebind the live log box (if any): the DOM was rebuilt, so the <pre>/
|
|
|
+ // select re-attach to the live stream state (the stream itself keeps
|
|
|
+ // running in LOG.raw between renders).
|
|
|
+ if (EMBED && LOG.recipe && S.openRow === LOG.recipe) {
|
|
|
+ LOG.pre = document.querySelector("tr.hist .logpre");
|
|
|
+ if (LOG.pre) { LOG.pre.textContent = LOG.raw; LOG.pre.scrollTop = LOG.pre.scrollHeight; }
|
|
|
+ const sel = document.querySelector("tr.hist .lognode");
|
|
|
+ if (sel) sel.onchange = e => { LOG.cur = +e.target.value; attachLog(); };
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
document.querySelector("thead").addEventListener("click", e => {
|
|
|
@@ -542,7 +561,13 @@ document.querySelector("tbody").addEventListener("click", e => {
|
|
|
const g = e.target.closest("tr.g1,tr.g2");
|
|
|
if (g) { const k = decodeURIComponent(g.dataset.g); S.closed.has(k) ? S.closed.delete(k) : S.closed.add(k); render(); return; }
|
|
|
const row = e.target.closest("tr.row");
|
|
|
- if (row) { S.openRow = S.openRow === row.dataset.n ? null : row.dataset.n; render(); }
|
|
|
+ if (row) {
|
|
|
+ const n = row.dataset.n;
|
|
|
+ const next = S.openRow === n ? null : n;
|
|
|
+ if (LOG.recipe && next !== LOG.recipe) closeLog(); // switching/collapsing drops the stream
|
|
|
+ S.openRow = next;
|
|
|
+ render();
|
|
|
+ }
|
|
|
});
|
|
|
document.getElementById("q").addEventListener("input", e => { S.q = e.target.value; S.page = 0; render(); });
|
|
|
document.getElementById("group").addEventListener("change", e => { S.group = e.target.value; S.closed.clear(); S.page = 0; render(); });
|
|
|
@@ -556,36 +581,111 @@ document.getElementById("size").addEventListener("change", () => { S.page = 0; r
|
|
|
document.getElementById("prev").addEventListener("click", () => { if (S.page > 0) { 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
|
|
|
+// The standalone `report --serve` has no job/repo API — the controls 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) {
|
|
|
+const NOTES = {};
|
|
|
+function setNote(recipe, text) {
|
|
|
+ NOTES[recipe] = text;
|
|
|
+ const n = document.querySelector(`.actbar .actnote[data-r="${CSS.escape(recipe)}"]`);
|
|
|
+ if (n) n.textContent = text;
|
|
|
+}
|
|
|
+// The expanded row carries the controls before the toml link: serve/stop
|
|
|
+// (tracked dashboard jobs), a live log tail for a running recipe (the
|
|
|
+// dashboard's /api/logs attach + the /events log stream), and a bench
|
|
|
+// launch (POST /api/bench — the standard campaign as a job; the report
|
|
|
+// lands in the shared recipe repo and re-appears in this table via the
|
|
|
+// live stream).
|
|
|
+function actionBar(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>`;
|
|
|
+ let b = running
|
|
|
+ ? `<button class="act" data-act="stop" data-r="${esc(r.name)}" title="stop (dashboard job)">⏹ stop</button>`
|
|
|
+ : `<button class="act" data-act="serve" data-r="${esc(r.name)}" title="serve (dashboard job, cluster auto)">▶ serve</button>`;
|
|
|
+ if (running) b += `<button class="act" data-act="logs" data-r="${esc(r.name)}" title="live container logs">▤ logs</button>`;
|
|
|
+ b += `<button class="act" data-act="bench" data-r="${esc(r.name)}" title="standard bench (seed 42, hardmode, perf c1,2) as a dashboard job — the report lands in the recipe repo">⚡ bench</button>`;
|
|
|
+ return `<div class="actbar">${b}<span class="actnote" data-r="${esc(r.name)}">${esc(NOTES[r.name] || "")}</span></div>`;
|
|
|
+}
|
|
|
+// ---- live log tail (one active attach at a time — one row expands at a time)
|
|
|
+const LOG = { attach: null, es: null, raw: "", buf: "", pre: null, opts: [], cur: 0, recipe: "" };
|
|
|
+function paintLog() {
|
|
|
+ if (LOG.buf) { LOG.raw += LOG.buf; LOG.buf = ""; }
|
|
|
+ const lines = LOG.raw.split("\n");
|
|
|
+ if (lines.length > 2000) LOG.raw = lines.slice(-2000).join("\n");
|
|
|
+ if (LOG.pre && LOG.pre.isConnected) { LOG.pre.textContent = LOG.raw; LOG.pre.scrollTop = LOG.pre.scrollHeight; }
|
|
|
+}
|
|
|
+function ensureLogStream() {
|
|
|
+ if (LOG.es) return;
|
|
|
+ LOG.es = new EventSource("/events");
|
|
|
+ LOG.es.onmessage = e => {
|
|
|
+ let ev; try { ev = JSON.parse(e.data); } catch { return; }
|
|
|
+ if (ev.type !== "log" || ev.attach !== LOG.attach) return;
|
|
|
+ LOG.buf += ev.line + "\n";
|
|
|
+ paintLog();
|
|
|
+ };
|
|
|
+}
|
|
|
+async function attachLog() {
|
|
|
+ const o = LOG.opts[LOG.cur];
|
|
|
+ if (!o) return;
|
|
|
+ if (LOG.attach) {
|
|
|
+ fetch("/api/logs", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ attach_id: LOG.attach }) }).catch(() => {});
|
|
|
+ LOG.attach = null;
|
|
|
+ }
|
|
|
+ const res = await fetch("/api/logs", {
|
|
|
+ method: "POST",
|
|
|
+ headers: { "Content-Type": "application/json" },
|
|
|
+ body: JSON.stringify({ cluster: o.cluster, node: o.node, recipe: LOG.recipe, rank: o.rank }),
|
|
|
+ });
|
|
|
+ const j = await res.json().catch(() => ({}));
|
|
|
+ if (!res.ok || !j.attach_id) { setNote(LOG.recipe, "log attach failed: " + (j.error || res.status)); return; }
|
|
|
+ LOG.attach = j.attach_id;
|
|
|
+ LOG.raw = (j.history || []).join("\n");
|
|
|
+ ensureLogStream();
|
|
|
+ paintLog();
|
|
|
+}
|
|
|
+function closeLog() {
|
|
|
+ if (LOG.attach) fetch("/api/logs", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ attach_id: LOG.attach }) }).catch(() => {});
|
|
|
+ LOG.attach = null;
|
|
|
+ if (LOG.es) { LOG.es.close(); LOG.es = null; }
|
|
|
+ LOG.raw = ""; LOG.buf = ""; LOG.pre = null; LOG.opts = []; LOG.cur = 0; LOG.recipe = "";
|
|
|
+}
|
|
|
+async function openLogs(recipe) {
|
|
|
+ LOG.recipe = recipe;
|
|
|
+ setNote(recipe, "opening logs…");
|
|
|
+ let st;
|
|
|
+ try { st = await (await fetch("/api/state")).json(); }
|
|
|
+ catch (err) { setNote(recipe, "✗ " + err); return; }
|
|
|
+ const opts = [];
|
|
|
+ for (const c of st.clusters || [])
|
|
|
+ for (const n of c.nodes || [])
|
|
|
+ if ((n.containers || []).some(x => x.recipe === recipe))
|
|
|
+ opts.push({ cluster: c.name, node: n.name, rank: n.rank });
|
|
|
+ LOG.opts = opts; LOG.cur = 0;
|
|
|
+ if (!opts.length) { setNote(recipe, "no live container found — probe lag, or the recipe just stopped"); render(); return; }
|
|
|
+ render(); // the log box (select + pre) renders for LOG.recipe
|
|
|
+ await attachLog();
|
|
|
}
|
|
|
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;
|
|
|
+ if (kind === "logs") { openLogs(recipe); return; }
|
|
|
+ const path = kind === "serve" ? "/api/serve" : kind === "stop" ? "/api/stop" : "/api/bench";
|
|
|
b.disabled = true;
|
|
|
- const label0 = b.textContent;
|
|
|
try {
|
|
|
- const res = await fetch(kind, { // "/api/serve" | "/api/stop"
|
|
|
+ const res = await fetch(path, { // "/api/serve" | "/api/stop" | "/api/bench"
|
|
|
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);
|
|
|
+ if (j.job != null) setNote(recipe, `${kind} job ${j.job} running — the state updates when the fleet probe sees it`);
|
|
|
+ else setNote(recipe, `✗ ${kind}: ` + (j.error || res.status));
|
|
|
+ } catch (err) { setNote(recipe, `✗ ${kind}: ` + err); }
|
|
|
+ setTimeout(() => { b.disabled = false; }, 3000);
|
|
|
});
|
|
|
const syncBtn = document.getElementById("syncbtn");
|
|
|
if (EMBED) syncBtn.style.display = "";
|