Pure `nix fmt` output from the commit before this one — no hand edits. 203 files: 52 md, 42 tsx, 32 js, 32 css, 21 ts, 13 html, 8 json, 3 mjs. Reproduce with `nix develop -c nix fmt` on the parent commit; the result should be byte-identical to this tree. None of the 13 `.prettierignore` entries appears here — verified by intersecting the changed-file list against the ignore file, with a control proving the intersection finds a match when one exists.
709 lines
24 KiB
JavaScript
709 lines
24 KiB
JavaScript
// BU1LDS page entry (/builds.html). The build lifecycle hub — rebuild
|
|
// queue, live build log, meta inputs, and build log history on one page.
|
|
// Three sub-tabs via <hive-tab-strip> (default = Rebuild Queue):
|
|
// - R3BU1LD QU3U3: pending + running rebuilds + live build log inline
|
|
// - M3T4 1NPUTS: nix flake input selector + meta-update trigger
|
|
// - BUILD L0GS: all-agents build log history (lazy-loaded on tab show)
|
|
//
|
|
// Carved out of /core.html (rebuild queue + meta inputs) and /logs.html
|
|
// (BUILD tab) so the full build lifecycle is in one place. The section
|
|
// renderers here are direct copies from core.js / logs.js with only the
|
|
// deep-link URL and count-pill id adjusted.
|
|
|
|
import {
|
|
$,
|
|
fmtAgeSecs,
|
|
openStream,
|
|
openBuildLogStream,
|
|
initServerWarnings,
|
|
} from "./common.js";
|
|
import { el } from "@hive/shared/dom.js";
|
|
import { bindAsyncForms } from "@hive/shared/forms.js";
|
|
import { themedConfirm } from "@hive/shared/modal.js";
|
|
import { h, render } from "preact";
|
|
import { fmtAgo, fmtDuration, truncate } from "./util.js";
|
|
import "@hive/shared/hive-tab-strip.js";
|
|
import { JobqGraph } from "@hive/shared/jobq-graph.js";
|
|
|
|
// ─── derived state ───────────────────────────────────────────────────────────
|
|
let metaInputsState = [];
|
|
let metaUpdateRunning = false;
|
|
// Kept in sync from JobqGraph's `onUpdate` callback (see mountRebuildQueue
|
|
// below) — the component owns fetching GET /api/jobq/graph and renders
|
|
// the tree itself; this flat array exists only for the two things it
|
|
// doesn't render: the count-pill and the live-log panel.
|
|
let jobqNodes = [];
|
|
let jobqGraphToken = 0;
|
|
|
|
function syncFromSnapshot(s) {
|
|
metaInputsState = (s.meta_inputs || []).slice();
|
|
metaUpdateRunning = !!s.meta_update_running;
|
|
}
|
|
|
|
// ─── meta inputs ─────────────────────────────────────────────────────────────
|
|
function renderMetaInputs(s) {
|
|
const root = $("meta-inputs-section");
|
|
if (!root) return;
|
|
// Snapshot ticked checkboxes before wiping so a concurrent
|
|
// MetaInputsChanged doesn't silently clear a pending selection.
|
|
const checkedInputs = new Set(
|
|
Array.from(
|
|
root.querySelectorAll('input[type="checkbox"][data-meta-input]:checked'),
|
|
).map((cb) => cb.dataset.metaInput),
|
|
);
|
|
root.replaceChildren();
|
|
const inputs = s.meta_inputs || [];
|
|
if (!inputs.length) {
|
|
root.append(el("p", { class: "empty" }, "meta repo not seeded yet"));
|
|
return;
|
|
}
|
|
if (metaUpdateRunning) {
|
|
root.append(
|
|
el(
|
|
"p",
|
|
{ class: "meta-update-running" },
|
|
"⏳ meta-update running — flake lock bump + affected agents rebuilding. " +
|
|
"watch the agent cards for per-rebuild progress.",
|
|
),
|
|
);
|
|
}
|
|
const f = el("form", {
|
|
method: "POST",
|
|
action: "/api/meta-update",
|
|
class: "meta-inputs-form",
|
|
"data-async": "",
|
|
"data-no-refresh": "",
|
|
"data-confirm":
|
|
"update selected meta flake inputs + rebuild affected agents?",
|
|
});
|
|
const bulk = el("div", { class: "meta-inputs-bulk" });
|
|
const selAll = el(
|
|
"button",
|
|
{ type: "button", class: "meta-bulk-btn" },
|
|
"select all",
|
|
);
|
|
const selNone = el(
|
|
"button",
|
|
{ type: "button", class: "meta-bulk-btn" },
|
|
"select none",
|
|
);
|
|
bulk.append("bulk: ", selAll, " ", selNone);
|
|
f.append(bulk);
|
|
const ul = el("ul", { class: "meta-inputs" });
|
|
for (const inp of inputs) {
|
|
const depth = (inp.name.match(/\//g) || []).length;
|
|
const leaf = inp.name.slice(inp.name.lastIndexOf("/") + 1);
|
|
const li = el("li");
|
|
if (depth > 0) li.style.marginLeft = depth * 1.3 + "em";
|
|
const id = "meta-input-" + inp.name.replace(/[^a-z0-9-]/gi, "_");
|
|
const cb = el("input", {
|
|
type: "checkbox",
|
|
name: "meta_input_" + inp.name,
|
|
id,
|
|
value: inp.name,
|
|
"data-meta-input": inp.name,
|
|
});
|
|
if (checkedInputs.has(inp.name)) cb.checked = true;
|
|
const label = el("label", { for: id, title: inp.name });
|
|
label.append(cb);
|
|
if (depth > 0) label.append(el("span", { class: "meta-input-twig" }, "└ "));
|
|
label.append(
|
|
el("span", { class: "meta-input-name" }, leaf),
|
|
" ",
|
|
el("code", { class: "meta-input-rev" }, inp.rev.slice(0, 12)),
|
|
" ",
|
|
el("span", { class: "meta-input-ts" }, fmtAgo(inp.last_modified)),
|
|
);
|
|
if (inp.url) {
|
|
label.append(
|
|
" ",
|
|
el(
|
|
"span",
|
|
{ class: "meta-input-url", title: inp.url },
|
|
"· " + truncate(inp.url, 48),
|
|
),
|
|
);
|
|
}
|
|
li.append(label);
|
|
ul.append(li);
|
|
}
|
|
f.append(ul);
|
|
const hidden = el("input", { type: "hidden", name: "inputs", value: "" });
|
|
f.append(hidden);
|
|
const btn = el(
|
|
"button",
|
|
{
|
|
type: "submit",
|
|
class: "btn btn-meta-update",
|
|
disabled: "",
|
|
},
|
|
metaUpdateRunning ? "⏳ UPD4T1NG…" : "◆ UPD4TE & R3BU1LD",
|
|
);
|
|
f.append(btn);
|
|
function refreshDisabled() {
|
|
const any = f.querySelectorAll("input[data-meta-input]:checked").length > 0;
|
|
if (any && !metaUpdateRunning) btn.removeAttribute("disabled");
|
|
else btn.setAttribute("disabled", "");
|
|
}
|
|
f.addEventListener("change", refreshDisabled);
|
|
function setAllChecked(val) {
|
|
for (const b of f.querySelectorAll("input[data-meta-input]"))
|
|
b.checked = val;
|
|
refreshDisabled();
|
|
}
|
|
selAll.addEventListener("click", () => setAllChecked(true));
|
|
selNone.addEventListener("click", () => setAllChecked(false));
|
|
f.addEventListener("submit", () => {
|
|
const selected = Array.from(
|
|
f.querySelectorAll("input[data-meta-input]:checked"),
|
|
).map((b) => b.dataset.metaInput);
|
|
hidden.value = selected.join(",");
|
|
});
|
|
root.append(f);
|
|
}
|
|
|
|
// ─── rebuild queue ────────────────────────────────────────────────────────────
|
|
// R3BU1LD QU3U3 is `JobqGraph` (Preact, rendered via plain `render(h(...))`
|
|
// — no JSX pragma needed for either call, no mount wrapper: `render` is
|
|
// already the re-render/diff entry point, per mara on review) directly —
|
|
// no hand-rolled tree/roll-up rendering here anymore. The component owns
|
|
// fetching GET /api/jobq/graph and its own refetch (bump `jobqGraphToken`
|
|
// and render again); this page just reads its `onUpdate` callback to keep
|
|
// `jobqNodes` (the flat array) in sync for the two things the generic
|
|
// view doesn't render itself: the count-pill and the live-log panel below.
|
|
//
|
|
// Cancel is the one action this page *does* wire up: the `cancellable`
|
|
// prop turns on the component's own per-node cancel button, which calls
|
|
// `onCancel(id)` rather than posting anything — the endpoint
|
|
// (`/api/rebuild-queue/{id}/cancel`) is this page's domain concept, not
|
|
// the generic component's.
|
|
// Split from `renderRebuildQueue` below: `replaceChildren()` only
|
|
// belongs on the *first* render, to clear the static "loading…"
|
|
// placeholder `#rebuild-queue-section` starts with in `builds.html` —
|
|
// Preact doesn't know that placeholder is there, so a later render
|
|
// call must NOT repeat `replaceChildren()` (it would wipe Preact's own
|
|
// tracked children out from under its diffing rather than let it
|
|
// update them minimally).
|
|
function mountRebuildQueue() {
|
|
const root = $("rebuild-queue-section");
|
|
if (!root) return;
|
|
root.replaceChildren();
|
|
renderRebuildQueue();
|
|
}
|
|
function renderRebuildQueue() {
|
|
const root = $("rebuild-queue-section");
|
|
if (!root) return;
|
|
render(
|
|
h(JobqGraph, {
|
|
endpoint: "/api/jobq/graph",
|
|
cancellable: true,
|
|
refreshToken: jobqGraphToken,
|
|
onUpdate: (nodes) => {
|
|
jobqNodes = nodes || [];
|
|
renderRebuildLiveLog();
|
|
updateRebuildCount();
|
|
},
|
|
onCancel: async (id) => {
|
|
const node = jobqNodes.find((n) => n.id === id);
|
|
const label = node ? node.payload.label : "node " + id;
|
|
if (
|
|
!(await themedConfirm({
|
|
message: `cancel ${label}? a group root cancels the whole subtree; a mid-tree node cancels just that branch.`,
|
|
danger: true,
|
|
confirmLabel: "✕ cancel",
|
|
}))
|
|
)
|
|
return;
|
|
try {
|
|
const r = await fetch("/api/rebuild-queue/" + id + "/cancel", {
|
|
method: "POST",
|
|
});
|
|
if (!r.ok) throw new Error("http " + r.status);
|
|
// No manual refresh: cancel flips node state, which fires
|
|
// rebuild_queue_changed over SSE — the existing handler below
|
|
// already bumps jobqGraphToken and re-renders on that tick.
|
|
} catch (err) {
|
|
console.error("cancel failed", err);
|
|
}
|
|
},
|
|
}),
|
|
root,
|
|
);
|
|
}
|
|
|
|
// ─── running-rebuild live log ─────────────────────────────────────────────────
|
|
// One persistent live-log panel for the first currently-building node.
|
|
// Keyed to that node's id and kept in its own container (#rebuild-live-log)
|
|
// so the queue's row re-render never tears down an open poll.
|
|
//
|
|
// Build logs are fetched by polling GET /api/build-log/<node_id> (returns
|
|
// {stdout, stderr}) rather than SSE — a running node's log re-fetches on the
|
|
// rebuild_queue_changed tick; a terminal node's log is static (one last fetch
|
|
// on transition, then done).
|
|
let liveLogId = null; // current node id being shown
|
|
let liveLogDone = false; // true once the node left 'Running'
|
|
let liveLogCollapsed = false;
|
|
let liveLogPollTimer = null;
|
|
|
|
function clearLiveLogPoll() {
|
|
if (liveLogPollTimer) {
|
|
clearInterval(liveLogPollTimer);
|
|
liveLogPollTimer = null;
|
|
}
|
|
}
|
|
|
|
// First running node with a log, in wire order (root-then-subtree per
|
|
// root, roots in enqueue order — see hive-jobq-wire's wire_snapshot doc).
|
|
// Gate on build_log_id so lock/noop/store-only nodes don't open a blank
|
|
// panel.
|
|
function findLiveBuild() {
|
|
return (
|
|
jobqNodes.find(
|
|
(n) =>
|
|
n.state === "Running" &&
|
|
n.payload.data &&
|
|
n.payload.data.build_log_id != null,
|
|
) || null
|
|
);
|
|
}
|
|
|
|
async function fetchAndRenderLiveLog(nodeId, pre) {
|
|
try {
|
|
const r = await fetch("/api/build-log/" + nodeId);
|
|
if (!r.ok) return;
|
|
const data = await r.json();
|
|
const text = [
|
|
data.stdout,
|
|
data.stderr ? "--- stderr ---\n" + data.stderr : "",
|
|
]
|
|
.filter(Boolean)
|
|
.join("\n");
|
|
if (pre.textContent !== text) {
|
|
const atBottom = pre.scrollHeight - pre.scrollTop - pre.clientHeight < 40;
|
|
pre.textContent = text;
|
|
if (atBottom) pre.scrollTop = pre.scrollHeight;
|
|
}
|
|
} catch {
|
|
/* network blip — ignore, next poll will retry */
|
|
}
|
|
}
|
|
|
|
function renderRebuildLiveLog() {
|
|
const root = $("rebuild-live-log");
|
|
if (!root) return;
|
|
const liveNode = findLiveBuild();
|
|
|
|
if (!liveNode) {
|
|
clearLiveLogPoll();
|
|
liveLogId = null;
|
|
liveLogDone = false;
|
|
if (!root.hidden) {
|
|
root.hidden = true;
|
|
root.replaceChildren();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Same node, already polling — just let the timer tick (or do a final
|
|
// fetch if the node just went non-running and we haven't marked done yet).
|
|
if (liveNode.id === liveLogId) {
|
|
if (!liveLogDone && liveNode.state !== "Running") {
|
|
clearLiveLogPoll();
|
|
liveLogDone = true;
|
|
const pre = root.querySelector(".rebuild-live-log-output");
|
|
const badge = root.querySelector(".rebuild-live-log-badge");
|
|
if (pre) fetchAndRenderLiveLog(liveNode.id, pre);
|
|
if (badge) {
|
|
const ok = liveNode.state !== "Failed";
|
|
badge.className =
|
|
"rebuild-live-log-badge " + (ok ? "rll-ok" : "rll-fail");
|
|
badge.textContent = liveNode.state;
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
// New node — tear down old poll, rebuild the panel.
|
|
clearLiveLogPoll();
|
|
liveLogId = liveNode.id;
|
|
liveLogDone = false;
|
|
root.hidden = false;
|
|
root.replaceChildren();
|
|
|
|
const pre = el("pre", { class: "rebuild-live-log-output" }, "");
|
|
pre.hidden = liveLogCollapsed;
|
|
const badge = el(
|
|
"span",
|
|
{ class: "rebuild-live-log-badge rll-running" },
|
|
"live",
|
|
);
|
|
const toggle = el(
|
|
"button",
|
|
{
|
|
type: "button",
|
|
class: "rebuild-live-log-toggle",
|
|
"aria-expanded": String(!liveLogCollapsed),
|
|
title: liveLogCollapsed ? "expand live log" : "collapse live log",
|
|
},
|
|
liveLogCollapsed ? "▸" : "▾",
|
|
);
|
|
toggle.addEventListener("click", () => {
|
|
liveLogCollapsed = !liveLogCollapsed;
|
|
pre.hidden = liveLogCollapsed;
|
|
toggle.textContent = liveLogCollapsed ? "▸" : "▾";
|
|
toggle.setAttribute("aria-expanded", String(!liveLogCollapsed));
|
|
toggle.title = liveLogCollapsed ? "expand live log" : "collapse live log";
|
|
});
|
|
const liveAgent = liveNode.payload.data && liveNode.payload.data.agent;
|
|
const header = el(
|
|
"div",
|
|
{ class: "rebuild-live-log-header" },
|
|
toggle,
|
|
" ",
|
|
el("span", { class: "rebuild-live-log-title" }, "live build log — "),
|
|
// Label the specific node's agent, not the whole group's agent set.
|
|
el("code", { class: "rqe-agent" }, liveAgent || ""),
|
|
" ",
|
|
el("span", { class: "rqe-kind" }, liveNode.payload.label),
|
|
" ",
|
|
badge,
|
|
" ",
|
|
el(
|
|
"a",
|
|
{
|
|
class: "rebuild-live-log-raw",
|
|
href: "/api/build-log/" + liveNode.id + "/raw",
|
|
download: "build-log-" + liveNode.id + ".txt",
|
|
},
|
|
"↓ raw",
|
|
),
|
|
);
|
|
root.append(header, pre);
|
|
|
|
// Start polling.
|
|
fetchAndRenderLiveLog(liveNode.id, pre);
|
|
liveLogPollTimer = setInterval(
|
|
() => fetchAndRenderLiveLog(liveNode.id, pre),
|
|
2000,
|
|
);
|
|
}
|
|
|
|
// ─── rebuild-queue count pill ─────────────────────────────────────────────────
|
|
function updateRebuildCount() {
|
|
const pill = $("builds-tab-count-rebuild");
|
|
if (!pill) return;
|
|
// Pending/Running/Finishing = in flight (Finishing = own work done, a
|
|
// sub-node still running — still counts). Root nodes only: each is one
|
|
// queue entry.
|
|
const n = jobqNodes.filter(
|
|
(n) =>
|
|
n.parent == null &&
|
|
(n.state === "Pending" ||
|
|
n.state === "Running" ||
|
|
n.state === "Finishing"),
|
|
).length;
|
|
if (n > 0) {
|
|
pill.textContent = String(n);
|
|
pill.hidden = false;
|
|
} else {
|
|
pill.hidden = true;
|
|
}
|
|
}
|
|
|
|
// ─── render-all (cold load + any full re-render) ──────────────────────────────
|
|
function renderAll() {
|
|
renderMetaInputs({ meta_inputs: metaInputsState });
|
|
// Rebuild-queue rendering + the count-pill/live-log it drives all happen
|
|
// off JobqGraph's own onUpdate callback (see mountRebuildQueue) —
|
|
// nothing to render here directly.
|
|
}
|
|
|
|
// ─── BUILD L0GS tab ───────────────────────────────────────────────────────────
|
|
// All-agents build log history with expand-to-detail. Live builds stream in
|
|
// real time. Lazy-loaded on first tab activation; auto-refreshes (debounced
|
|
// 2s) when rebuild_queue_changed fires while the BUILD L0GS tab is active.
|
|
// Deep-link: ?id=N#buildlogs auto-expands the target row.
|
|
|
|
const buildList = $("build-list");
|
|
const buildRefresh = $("build-refresh");
|
|
let buildLogsLoaded = false;
|
|
let buildTabs; // set in init
|
|
|
|
function fmtTs(unixSecs) {
|
|
if (!unixSecs) return "";
|
|
const age = Math.floor(Date.now() / 1000) - unixSecs;
|
|
return fmtAgeSecs(Math.max(0, age)) + " ago";
|
|
}
|
|
|
|
async function fetchBuild() {
|
|
if (!buildList) return;
|
|
buildList.replaceChildren();
|
|
buildList.append(el("p", { class: "meta" }, "loading…"));
|
|
try {
|
|
const resp = await fetch("/api/build-logs?limit=30");
|
|
if (!resp.ok) throw new Error("http " + resp.status);
|
|
const rows = await resp.json();
|
|
buildList.replaceChildren();
|
|
if (!rows || rows.length === 0) {
|
|
buildList.append(el("p", { class: "meta" }, "(no build logs yet)"));
|
|
return;
|
|
}
|
|
const ul = el("ul", { class: "build-logs-list" });
|
|
for (const h of rows) {
|
|
const li = el("li", {
|
|
class: "build-logs-item",
|
|
"data-log-id": String(h.id),
|
|
});
|
|
const live = !h.status;
|
|
const ok = h.status === "ok";
|
|
const statusClass = live
|
|
? "hive-pill-sm badge-running"
|
|
: ok
|
|
? "hive-pill-sm badge-ok"
|
|
: "hive-pill-sm badge-fail";
|
|
const statusLabel = live ? "live" : ok ? "ok" : "fail";
|
|
const age = h.finished_at
|
|
? fmtTs(h.finished_at)
|
|
: live
|
|
? ""
|
|
: fmtTs(h.started_at);
|
|
const runtime =
|
|
h.runtime_secs != null
|
|
? el(
|
|
"span",
|
|
{ class: "build-logs-runtime meta" },
|
|
fmtDuration(Math.max(0, h.runtime_secs)),
|
|
)
|
|
: live
|
|
? el(
|
|
"span",
|
|
{ class: "build-logs-runtime meta build-logs-live-dur" },
|
|
"…",
|
|
)
|
|
: el("span", { class: "build-logs-runtime meta" }, "");
|
|
|
|
let durTimer = null;
|
|
if (live && h.started_at) {
|
|
const updateDur = () => {
|
|
const elapsed = Math.floor(Date.now() / 1000) - h.started_at;
|
|
runtime.textContent = fmtDuration(Math.max(0, elapsed));
|
|
};
|
|
updateDur();
|
|
durTimer = setInterval(updateDur, 1000);
|
|
}
|
|
|
|
const rowBtn = el(
|
|
"button",
|
|
{
|
|
type: "button",
|
|
class: "build-logs-row-btn",
|
|
"aria-expanded": "false",
|
|
},
|
|
el("span", { class: statusClass }, statusLabel),
|
|
el("span", { class: "build-logs-agent" }, h.agent),
|
|
runtime,
|
|
el("span", { class: "build-logs-kind" }, h.kind),
|
|
el("span", { class: "build-logs-age meta" }, age),
|
|
el("span", { class: "build-logs-cmdline meta" }, h.cmdline),
|
|
);
|
|
const detail = el("div", { class: "build-logs-detail" });
|
|
detail.hidden = true;
|
|
|
|
let streamEs = null;
|
|
rowBtn.addEventListener("click", async () => {
|
|
const expanded = rowBtn.getAttribute("aria-expanded") === "true";
|
|
rowBtn.setAttribute("aria-expanded", String(!expanded));
|
|
detail.hidden = expanded;
|
|
if (expanded) {
|
|
if (streamEs) {
|
|
streamEs.close();
|
|
streamEs = null;
|
|
}
|
|
return;
|
|
}
|
|
if (detail.dataset.loaded) return;
|
|
detail.replaceChildren();
|
|
detail.append(
|
|
el(
|
|
"a",
|
|
{
|
|
href: "/api/build-logs/id/" + h.id + "/raw",
|
|
download: "build-log-" + h.id + ".txt",
|
|
class: "build-logs-dl",
|
|
},
|
|
"↓ download raw",
|
|
),
|
|
);
|
|
if (live) {
|
|
const pre = el(
|
|
"pre",
|
|
{ class: "build-logs-output build-logs-live" },
|
|
"",
|
|
);
|
|
const badge = el(
|
|
"span",
|
|
{ class: "build-logs-live-badge hive-pill-sm badge-running" },
|
|
"live",
|
|
);
|
|
detail.append(badge, pre);
|
|
|
|
streamEs = openBuildLogStream(h.id, pre, {
|
|
onDone: (status) => {
|
|
if (durTimer) {
|
|
clearInterval(durTimer);
|
|
durTimer = null;
|
|
}
|
|
if (h.started_at) {
|
|
const elapsed = Math.floor(Date.now() / 1000) - h.started_at;
|
|
runtime.textContent = fmtDuration(Math.max(0, elapsed));
|
|
}
|
|
badge.className =
|
|
status === "ok"
|
|
? "hive-pill-sm badge-ok"
|
|
: "hive-pill-sm badge-fail";
|
|
badge.textContent = status;
|
|
streamEs = null;
|
|
detail.dataset.loaded = "1";
|
|
},
|
|
onError: () => {
|
|
if (durTimer) {
|
|
clearInterval(durTimer);
|
|
durTimer = null;
|
|
}
|
|
badge.textContent = "stream error";
|
|
badge.className = "hive-pill-sm badge-fail";
|
|
streamEs = null;
|
|
},
|
|
});
|
|
} else {
|
|
const pre = el("pre", { class: "build-logs-output" }, "fetching…");
|
|
detail.append(pre);
|
|
try {
|
|
const r2 = await fetch("/api/build-logs/id/" + h.id);
|
|
if (!r2.ok) {
|
|
pre.textContent = "error " + r2.status;
|
|
} else {
|
|
const full = await r2.json();
|
|
const out = [full.stdout, full.stderr]
|
|
.filter(Boolean)
|
|
.join("\n--- stderr ---\n");
|
|
pre.textContent = out || "(empty)";
|
|
}
|
|
detail.dataset.loaded = "1";
|
|
} catch (err) {
|
|
pre.textContent = "fetch failed: " + err;
|
|
}
|
|
}
|
|
});
|
|
li.append(rowBtn, detail);
|
|
ul.append(li);
|
|
}
|
|
buildList.append(ul);
|
|
// Deep-link: ?id=N auto-expands the target row.
|
|
const deepId = new URLSearchParams(location.search).get("id");
|
|
if (deepId) {
|
|
const target = ul.querySelector('[data-log-id="' + deepId + '"]');
|
|
if (target) {
|
|
const btn = target.querySelector(".build-logs-row-btn");
|
|
if (btn) {
|
|
btn.click();
|
|
target.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
}
|
|
}
|
|
}
|
|
} catch (err) {
|
|
buildList.replaceChildren();
|
|
buildList.append(el("p", { class: "meta" }, "fetch failed: " + err));
|
|
}
|
|
}
|
|
|
|
if (buildRefresh) buildRefresh.addEventListener("click", fetchBuild);
|
|
|
|
// ─── SSE handlers ─────────────────────────────────────────────────────────────
|
|
let buildRefreshTimer = null;
|
|
const SSE_HANDLERS = {
|
|
rebuild_queue_changed() {
|
|
// No `ev.queue` payload read anymore — this event still carries its own
|
|
// queue snapshot on the wire (tabs.js/SW4RM still consumes it for the
|
|
// swarm badges, untouched by this), this page just treats it as a
|
|
// refetch trigger. `JobqGraph` owns the actual fetch.
|
|
jobqGraphToken += 1;
|
|
renderRebuildQueue();
|
|
// Auto-refresh build log list when the queue changes and BUILD L0GS is active.
|
|
if (buildTabs && buildTabs.active() === "buildlogs") {
|
|
if (buildRefreshTimer) clearTimeout(buildRefreshTimer);
|
|
buildRefreshTimer = setTimeout(fetchBuild, 2000);
|
|
}
|
|
},
|
|
meta_inputs_changed(ev) {
|
|
metaInputsState = (ev.inputs || []).slice();
|
|
renderMetaInputs({ meta_inputs: metaInputsState });
|
|
},
|
|
meta_update_running(ev) {
|
|
metaUpdateRunning = !!ev.running;
|
|
renderMetaInputs({ meta_inputs: metaInputsState });
|
|
},
|
|
};
|
|
|
|
// ─── boot ─────────────────────────────────────────────────────────────────────
|
|
async function refreshState() {
|
|
try {
|
|
const resp = await fetch("/api/state");
|
|
if (resp.ok) syncFromSnapshot(await resp.json());
|
|
} catch {
|
|
// best-effort
|
|
}
|
|
renderAll();
|
|
}
|
|
|
|
async function init() {
|
|
initServerWarnings();
|
|
bindAsyncForms(() => refreshState());
|
|
// Self-fetches on mount (JobqGraph's own effect) — no explicit initial
|
|
// fetch needed here, unlike meta inputs below.
|
|
mountRebuildQueue();
|
|
|
|
buildTabs = document.getElementById("builds-tabbar").configure({
|
|
tabs: [
|
|
{
|
|
id: "rebuild",
|
|
label: "R3BU1LD QU3U3",
|
|
badgeId: "builds-tab-count-rebuild",
|
|
},
|
|
{ id: "meta", label: "M3T4 1NPUTS" },
|
|
{ id: "buildlogs", label: "BUILD L0GS" },
|
|
],
|
|
defaultId: "rebuild",
|
|
onShow: (id) => {
|
|
if (id === "buildlogs" && !buildLogsLoaded) {
|
|
buildLogsLoaded = true;
|
|
fetchBuild();
|
|
}
|
|
},
|
|
});
|
|
|
|
await refreshState();
|
|
|
|
// kinds= matches SSE_HANDLERS below verbatim — narrows this from all
|
|
// 17 wire kinds down to the 3 this page acts on. This page was one
|
|
// of 4 unfiltered `/api/dashboard/stream` subscribers before this
|
|
// (subscription discipline, part 1 of the dashboard-event-stream-
|
|
// split issue).
|
|
const es = openStream(
|
|
"/api/dashboard/stream?kinds=rebuild_queue_changed,meta_inputs_changed,meta_update_running",
|
|
);
|
|
if (es) {
|
|
es.onmessage = (e) => {
|
|
let ev;
|
|
try {
|
|
ev = JSON.parse(e.data);
|
|
} catch {
|
|
return;
|
|
}
|
|
const h = SSE_HANDLERS[ev.kind];
|
|
if (h) h(ev);
|
|
};
|
|
}
|
|
}
|
|
|
|
init();
|