|
|
@@ -272,6 +272,30 @@
|
|
|
}
|
|
|
.setstat { font-size: 12px; color: var(--muted); }
|
|
|
|
|
|
+ /* --- bench window: parameters + live harness log ------------------------- */
|
|
|
+ .benchwin .win-body { display: flex; flex-direction: column; padding: 0; }
|
|
|
+ .benchbar {
|
|
|
+ display: flex; gap: 8px; align-items: center; padding: 8px 10px;
|
|
|
+ border-bottom: 1px solid var(--border); flex-wrap: wrap; font-size: 12px;
|
|
|
+ }
|
|
|
+ .benchbar .adv { color: var(--muted); flex: 1; min-width: 100px; }
|
|
|
+ .benchbar .abtn { height: 26px; }
|
|
|
+ .benchform {
|
|
|
+ display: grid; grid-template-columns: 92px minmax(0, 1fr); gap: 6px 10px;
|
|
|
+ padding: 8px 10px; border-bottom: 1px solid var(--border); flex-shrink: 0;
|
|
|
+ font-size: 12px;
|
|
|
+ }
|
|
|
+ .benchform label { display: flex; align-items: center; gap: 6px; color: var(--muted); }
|
|
|
+ .benchform input[type=text], .benchform input[type=number], .benchform select {
|
|
|
+ width: 100%; height: 25px; box-sizing: border-box; background: var(--btn);
|
|
|
+ color: var(--fg); border: 1px solid var(--border); border-radius: 5px; padding: 2px 6px;
|
|
|
+ font: inherit;
|
|
|
+ }
|
|
|
+ .benchform .ckrow { display: flex; gap: 14px; flex-wrap: wrap; }
|
|
|
+ .benchform .ckrow label { color: var(--fg); }
|
|
|
+ .benchstatus { padding: 6px 10px; font-size: 12px; color: var(--muted); border-bottom: 1px solid var(--border); }
|
|
|
+ .benchlog { margin: 0; flex: 1; min-height: 80px; }
|
|
|
+
|
|
|
/* --- polish: skeleton + attention ------------------------------------------ */
|
|
|
.skeleton { height: 14px; border-radius: 6px; margin: 10px 0;
|
|
|
background: linear-gradient(90deg, var(--btn) 25%, var(--btn-hover) 50%, var(--btn) 75%);
|
|
|
@@ -856,6 +880,7 @@ function toggleDockMenu() {
|
|
|
item("logs…", openLogsPicker);
|
|
|
item("raw", openRaw);
|
|
|
item("cache…", openCache);
|
|
|
+ item("bench…", () => openBench(readBenchParams().recipe || ""));
|
|
|
item("settings…", openSettings);
|
|
|
document.body.appendChild(m);
|
|
|
// Anchor the menu to the + button that opened it (the chips flow left,
|
|
|
@@ -915,6 +940,7 @@ function openLogsForRecipe(recipe) {
|
|
|
window.addEventListener("message", (e) => {
|
|
|
const d = e.data;
|
|
|
if (d && d.sm121 === "openLogs" && typeof d.recipe === "string") openLogsForRecipe(d.recipe);
|
|
|
+ if (d && d.sm121 === "openBench" && typeof d.recipe === "string") openBench(d.recipe);
|
|
|
});
|
|
|
|
|
|
// --- SSE -------------------------------------------------------------------
|
|
|
@@ -2139,6 +2165,251 @@ async function openSettings(g) {
|
|
|
return handle;
|
|
|
}
|
|
|
|
|
|
+// --- bench window: parameters, target node, live harness log, cancel --------
|
|
|
+const BENCH_PARAMS_KEY = "sm121-bench-params";
|
|
|
+const BENCH_JOB_KEY = "sm121-bench-job";
|
|
|
+let benchWin = null;
|
|
|
+let benchTimer = null;
|
|
|
+let benchJobId = null;
|
|
|
+function readBenchParams() {
|
|
|
+ try { return JSON.parse(localStorage.getItem(BENCH_PARAMS_KEY) || "{}"); } catch { return {}; }
|
|
|
+}
|
|
|
+function saveBenchParams(p) {
|
|
|
+ try { localStorage.setItem(BENCH_PARAMS_KEY, JSON.stringify(p)); } catch {}
|
|
|
+}
|
|
|
+function saveBenchJob(job, recipe) {
|
|
|
+ benchJobId = job || null;
|
|
|
+ try { localStorage.setItem(BENCH_JOB_KEY, JSON.stringify({ job: benchJobId, recipe })); } catch {}
|
|
|
+}
|
|
|
+function readBenchJob() {
|
|
|
+ try { return JSON.parse(localStorage.getItem(BENCH_JOB_KEY) || "{}"); } catch { return {}; }
|
|
|
+}
|
|
|
+function stopBenchPoll() { if (benchTimer) { clearInterval(benchTimer); benchTimer = null; } }
|
|
|
+async function openBench(recipeArg) {
|
|
|
+ if (benchWin) {
|
|
|
+ activateWindow(benchWin);
|
|
|
+ if (recipeArg) {
|
|
|
+ const sel = benchWin.body.querySelector("#bRecipe");
|
|
|
+ if (sel) sel.value = recipeArg;
|
|
|
+ }
|
|
|
+ if (benchJobId) startBenchPoll();
|
|
|
+ return benchWin;
|
|
|
+ }
|
|
|
+ const saved = readBenchParams();
|
|
|
+ const jobSaved = readBenchJob();
|
|
|
+ const snap = cur();
|
|
|
+ const clusters = snap ? snap.clusters : [];
|
|
|
+ // Recipe: arg from the recipe row, else the last persisted one.
|
|
|
+ const recipe = recipeArg || saved.recipe || "";
|
|
|
+ // Cluster: prefer where the recipe is live, then the saved choice, then first.
|
|
|
+ let cluster = saved.cluster || (clusters[0] ? clusters[0].name : "");
|
|
|
+ let node = saved.node || "";
|
|
|
+ if (recipe && snap) {
|
|
|
+ for (const c of snap.clusters) {
|
|
|
+ for (const n of c.nodes) {
|
|
|
+ if ((n.containers || []).some((x) => x.recipe === recipe)) {
|
|
|
+ cluster = c.name; node = n.name;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ const handle = makeWin({
|
|
|
+ title: "bench", short: "bench",
|
|
|
+ x: 70, y: 60, w: Math.min(820, innerWidth - 50), h: Math.min(620, innerHeight * 0.8),
|
|
|
+ bodyHtml: `<div class="benchbar">
|
|
|
+ <span class="adv benchcmd">bench</span>
|
|
|
+ <span class="benchstatus"></span>
|
|
|
+ <button class="abtn bstart">▶ run</button>
|
|
|
+ <button class="abtn bcancel" disabled>⏹ cancel</button>
|
|
|
+ </div>
|
|
|
+ <div class="benchform">
|
|
|
+ <label>recipe</label><select id="bRecipe"></select>
|
|
|
+ <label>cluster</label><select id="bCluster"></select>
|
|
|
+ <label>node</label><select id="bNode"></select>
|
|
|
+ <label>run host</label><input id="bHost" type="text" placeholder="pluto.lan (ssh_host, optional)">
|
|
|
+ <label>label</label><input id="bLabel" type="text" placeholder="blank = web-<UTC stamp>">
|
|
|
+ <label>seed</label><input id="bSeed" type="number" min="0" value="42">
|
|
|
+ <label>concurrency</label><input id="bConc" type="text" value="1,2">
|
|
|
+ <label>parallel</label><input id="bPar" type="number" min="1" placeholder="serial">
|
|
|
+ <label>timeout</label><input id="bTimeout" type="number" min="1" placeholder="120">
|
|
|
+ <label>tokenizer</label><input id="bTokenizer" type="text" placeholder="tokenizer.json (optional)">
|
|
|
+ <div class="ckrow">
|
|
|
+ <label><input id="bPerf" type="checkbox"> perf only</label>
|
|
|
+ <label><input id="bDepth" type="checkbox"> depth suite</label>
|
|
|
+ <label><input id="bNoThink" type="checkbox"> no think</label>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <pre class="logpre benchlog"></pre>`,
|
|
|
+ extraCls: "benchwin", removeOnClose: false,
|
|
|
+ onClosed: () => { stopBenchPoll(); },
|
|
|
+ });
|
|
|
+ wins.bench = handle;
|
|
|
+ benchWin = handle;
|
|
|
+ const body = handle.body;
|
|
|
+ const recipeSel = body.querySelector("#bRecipe");
|
|
|
+ const clusterSel = body.querySelector("#bCluster");
|
|
|
+ const nodeSel = body.querySelector("#bNode");
|
|
|
+ const status = body.querySelector(".benchstatus");
|
|
|
+ const pre = body.querySelector(".benchlog");
|
|
|
+ const startBtn = body.querySelector(".bstart");
|
|
|
+ const cancelBtn = body.querySelector(".bcancel");
|
|
|
+ // Recipe list
|
|
|
+ const recipes = state.recipes || [];
|
|
|
+ recipeSel.innerHTML = "";
|
|
|
+ const seen = new Set();
|
|
|
+ for (const r of recipes) {
|
|
|
+ if (seen.has(r.name)) continue;
|
|
|
+ seen.add(r.name);
|
|
|
+ const o = document.createElement("option");
|
|
|
+ o.value = r.name;
|
|
|
+ o.textContent = r.name;
|
|
|
+ recipeSel.appendChild(o);
|
|
|
+ }
|
|
|
+ if (recipe && !seen.has(recipe)) {
|
|
|
+ const o = document.createElement("option");
|
|
|
+ o.value = recipe; o.textContent = recipe;
|
|
|
+ recipeSel.appendChild(o);
|
|
|
+ }
|
|
|
+ recipeSel.value = recipe;
|
|
|
+ function fillClusters() {
|
|
|
+ clusterSel.innerHTML = "";
|
|
|
+ for (const c of clusters) {
|
|
|
+ const o = document.createElement("option");
|
|
|
+ o.value = c.name; o.textContent = c.name + (c.up ? "" : " (offline)");
|
|
|
+ clusterSel.appendChild(o);
|
|
|
+ }
|
|
|
+ if (!clusters.length) {
|
|
|
+ const o = document.createElement("option");
|
|
|
+ o.value = ""; o.textContent = "no clusters";
|
|
|
+ clusterSel.appendChild(o);
|
|
|
+ }
|
|
|
+ clusterSel.value = cluster || (clusters[0] ? clusters[0].name : "");
|
|
|
+ }
|
|
|
+ function fillNodes() {
|
|
|
+ const cname = clusterSel.value;
|
|
|
+ const c = clusters.find((x) => x.name === cname);
|
|
|
+ nodeSel.innerHTML = "<option value=''>auto (rank 0)</option>";
|
|
|
+ if (c) for (const n of c.nodes) {
|
|
|
+ const o = document.createElement("option");
|
|
|
+ o.value = n.name; o.textContent = n.name + (n.containers.length ? " ★" : "");
|
|
|
+ nodeSel.appendChild(o);
|
|
|
+ }
|
|
|
+ nodeSel.value = node || "";
|
|
|
+ }
|
|
|
+ fillClusters(); fillNodes();
|
|
|
+ // Restore the persisted fields (recipe/cluster/node already; the rest)
|
|
|
+ body.querySelector("#bHost").value = saved.host || "";
|
|
|
+ body.querySelector("#bLabel").value = saved.label || "";
|
|
|
+ body.querySelector("#bSeed").value = saved.seed ?? 42;
|
|
|
+ body.querySelector("#bConc").value = saved.concurrency || "1,2";
|
|
|
+ body.querySelector("#bPar").value = saved.parallel || "";
|
|
|
+ body.querySelector("#bTimeout").value = saved.timeout || "";
|
|
|
+ body.querySelector("#bTokenizer").value = saved.tokenizer || "";
|
|
|
+ body.querySelector("#bPerf").checked = !!saved.perf_only;
|
|
|
+ body.querySelector("#bDepth").checked = !!saved.depth;
|
|
|
+ body.querySelector("#bNoThink").checked = !!saved.no_think;
|
|
|
+ clusterSel.onchange = () => { node = ""; fillNodes(); persist(); };
|
|
|
+ recipeSel.onchange = () => persist();
|
|
|
+ nodeSel.onchange = () => persist();
|
|
|
+ function persist() {
|
|
|
+ saveBenchParams({
|
|
|
+ recipe: recipeSel.value,
|
|
|
+ cluster: clusterSel.value,
|
|
|
+ node: nodeSel.value,
|
|
|
+ host: body.querySelector("#bHost").value.trim(),
|
|
|
+ label: body.querySelector("#bLabel").value.trim(),
|
|
|
+ seed: Number(body.querySelector("#bSeed").value) || 42,
|
|
|
+ concurrency: body.querySelector("#bConc").value.trim() || "1,2",
|
|
|
+ parallel: body.querySelector("#bPar").value ? Number(body.querySelector("#bPar").value) : null,
|
|
|
+ timeout: body.querySelector("#bTimeout").value ? Number(body.querySelector("#bTimeout").value) : null,
|
|
|
+ tokenizer: body.querySelector("#bTokenizer").value.trim() || null,
|
|
|
+ perf_only: body.querySelector("#bPerf").checked,
|
|
|
+ depth: body.querySelector("#bDepth").checked,
|
|
|
+ no_think: body.querySelector("#bNoThink").checked,
|
|
|
+ });
|
|
|
+ }
|
|
|
+ body.querySelectorAll("input, select").forEach((el) => el.addEventListener("change", persist));
|
|
|
+ async function startBench() {
|
|
|
+ const recipe = recipeSel.value;
|
|
|
+ const cluster = clusterSel.value;
|
|
|
+ if (!recipe || !cluster) { status.textContent = "pick recipe and cluster"; return; }
|
|
|
+ const payload = {
|
|
|
+ cluster, recipe,
|
|
|
+ node: nodeSel.value || null,
|
|
|
+ ssh_host: body.querySelector("#bHost").value.trim() || null,
|
|
|
+ seed: Number(body.querySelector("#bSeed").value) || 42,
|
|
|
+ concurrency: body.querySelector("#bConc").value.trim() || "1,2",
|
|
|
+ parallel: body.querySelector("#bPar").value ? Number(body.querySelector("#bPar").value) : null,
|
|
|
+ timeout: body.querySelector("#bTimeout").value ? Number(body.querySelector("#bTimeout").value) : null,
|
|
|
+ tokenizer: body.querySelector("#bTokenizer").value.trim() || null,
|
|
|
+ perf_only: body.querySelector("#bPerf").checked,
|
|
|
+ depth: body.querySelector("#bDepth").checked,
|
|
|
+ no_think: body.querySelector("#bNoThink").checked,
|
|
|
+ };
|
|
|
+ const label = body.querySelector("#bLabel").value.trim();
|
|
|
+ if (label) payload.label = label;
|
|
|
+ persist();
|
|
|
+ startBtn.disabled = true;
|
|
|
+ status.textContent = "starting…";
|
|
|
+ try {
|
|
|
+ const j = await api("/api/bench", payload);
|
|
|
+ saveBenchJob(j.job, recipe);
|
|
|
+ status.textContent = "job " + j.job + " running — live log below";
|
|
|
+ cancelBtn.disabled = false;
|
|
|
+ startBenchPoll();
|
|
|
+ } catch (e) {
|
|
|
+ status.textContent = "error: " + e.message;
|
|
|
+ startBtn.disabled = false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ cancelBtn.onclick = async () => {
|
|
|
+ if (!benchJobId) return;
|
|
|
+ cancelBtn.disabled = true;
|
|
|
+ status.textContent = "cancelling…";
|
|
|
+ try {
|
|
|
+ const res = await fetch("/api/bench/cancel", {
|
|
|
+ method: "POST", headers: { ...AUTH(), "content-type": "application/json" },
|
|
|
+ body: JSON.stringify({ job: benchJobId }),
|
|
|
+ });
|
|
|
+ const j = await res.json().catch(() => ({}));
|
|
|
+ if (!res.ok) status.textContent = "cancel error: " + (j.error || res.status);
|
|
|
+ } catch (e) { status.textContent = "cancel error: " + e.message; }
|
|
|
+ };
|
|
|
+ async function pollBenchLog() {
|
|
|
+ if (!recipeSel.value) return;
|
|
|
+ try {
|
|
|
+ const res = await fetch("/api/bench/log?recipe=" + encodeURIComponent(recipeSel.value) + "&lines=220", { headers: AUTH() });
|
|
|
+ if (res.status === 404) { status.textContent = "waiting for the first log line…"; return; }
|
|
|
+ const j = await res.json();
|
|
|
+ if (!res.ok) { status.textContent = "log error: " + (j.error || res.status); return; }
|
|
|
+ if (j.job && !benchJobId) { benchJobId = j.job; saveBenchJob(j.job, recipeSel.value); }
|
|
|
+ pre.textContent = (j.lines || []).join("\n");
|
|
|
+ const atBottom = pre.scrollTop + pre.clientHeight >= pre.scrollHeight - 24;
|
|
|
+ if (atBottom) pre.scrollTop = pre.scrollHeight;
|
|
|
+ status.textContent = j.running ? "running — " + j.path : "finished — " + j.path;
|
|
|
+ if (j.running) {
|
|
|
+ cancelBtn.disabled = false;
|
|
|
+ } else {
|
|
|
+ cancelBtn.disabled = true;
|
|
|
+ startBtn.disabled = false;
|
|
|
+ stopBenchPoll();
|
|
|
+ }
|
|
|
+ } catch (e) { status.textContent = "log error: " + e.message; }
|
|
|
+ }
|
|
|
+ function startBenchPoll() {
|
|
|
+ if (benchTimer) return;
|
|
|
+ benchTimer = setInterval(pollBenchLog, 2000);
|
|
|
+ pollBenchLog();
|
|
|
+ }
|
|
|
+ startBtn.onclick = startBench;
|
|
|
+ // Reopening a closed window with a running job restores the live view.
|
|
|
+ const savedJob = jobSaved.job;
|
|
|
+ if (savedJob) { benchJobId = savedJob; cancelBtn.disabled = true; startBenchPoll(); }
|
|
|
+ saveWinState();
|
|
|
+ return handle;
|
|
|
+}
|
|
|
+
|
|
|
async function openRaw(g) {
|
|
|
if (rawWin) { activateWindow(rawWin); return rawWin; }
|
|
|
const r = (k, d) => (g && Number.isFinite(parseFloat(g[k])) ? parseFloat(g[k]) : d);
|
|
|
@@ -2464,6 +2735,7 @@ async function boot() {
|
|
|
// optional windows (their own SSE/API calls are cheap and idempotent)
|
|
|
restoreWindow("recipes", openRecipes);
|
|
|
restoreWindow("raw", openRaw);
|
|
|
+ restoreWindow("bench", () => openBench(readBenchParams().recipe || ""));
|
|
|
setInterval(async () => {
|
|
|
try {
|
|
|
const res = await fetch("/api/state", { headers: AUTH() });
|