The pills on 4G3NTS, R3BU1LD QU3U3 and K3PT ST4T3, the poll that fed them, the two count helpers, their styles, and the tab strip's unused badge option.
480 lines
17 KiB
JavaScript
480 lines
17 KiB
JavaScript
// The build lifecycle tabs of the dashboard — rebuild queue, live build
|
|
// log, and build log history. Not an entry point: tabs.js
|
|
// imports it, owns the tab strip and the dashboard event stream, and calls
|
|
// the exports below. Two tabs, the panes' ids matching the tab ids:
|
|
// - rebuild (R3BU1LD QU3U3): pending + running rebuilds + live build log
|
|
// - buildlogs (BUILD L0GS): all-agents build log history, lazy-loaded
|
|
// the first time the tab is shown
|
|
|
|
import { $, fmtAgeSecs, openBuildLogStream } from "./common.js";
|
|
import { el } from "@hive/shared/dom.js";
|
|
import { themedConfirm } from "@hive/shared/modal.js";
|
|
import { h, render } from "preact";
|
|
import { fmtDuration } from "./util.js";
|
|
import { JobqGraph } from "@hive/shared/jobq-graph.js";
|
|
|
|
// ─── derived state ───────────────────────────────────────────────────────────
|
|
|
|
// 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;
|
|
|
|
// ─── 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 `index.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();
|
|
},
|
|
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,
|
|
);
|
|
}
|
|
|
|
// ─── 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.
|
|
|
|
let buildLogsLoaded = false;
|
|
|
|
function fmtTs(unixSecs) {
|
|
if (!unixSecs) return "";
|
|
const age = Math.floor(Date.now() / 1000) - unixSecs;
|
|
return fmtAgeSecs(Math.max(0, age)) + " ago";
|
|
}
|
|
|
|
async function fetchBuild() {
|
|
const buildList = $("build-list");
|
|
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));
|
|
}
|
|
}
|
|
|
|
// ─── entry points for tabs.js ─────────────────────────────────────────────────
|
|
// Everything below is called by tabs.js, which owns the tab strip, the
|
|
// `/api/state` snapshot and the dashboard event stream.
|
|
|
|
/** Mount the rebuild queue and wire the build-log refresh button. Called
|
|
* once, after the tab strip exists. The queue self-fetches on mount
|
|
* (JobqGraph's own effect). */
|
|
export function initBuilds() {
|
|
const refresh = $("build-refresh");
|
|
if (refresh) refresh.addEventListener("click", fetchBuild);
|
|
mountRebuildQueue();
|
|
}
|
|
|
|
/** A tab was shown: BUILD L0GS loads its list the first time. */
|
|
export function onBuildsTabShown(tab) {
|
|
if (tab === "buildlogs" && !buildLogsLoaded) {
|
|
buildLogsLoaded = true;
|
|
fetchBuild();
|
|
}
|
|
}
|
|
|
|
let buildRefreshTimer = null;
|
|
|
|
/** `rebuild_queue_changed`: a payload-less refetch trigger — `JobqGraph`
|
|
* owns the actual fetch. */
|
|
export function onRebuildQueueChanged() {
|
|
jobqGraphToken += 1;
|
|
renderRebuildQueue();
|
|
// Auto-refresh the build log list while BUILD L0GS is the active tab.
|
|
if (document.body.dataset.activeTab === "buildlogs") {
|
|
if (buildRefreshTimer) clearTimeout(buildRefreshTimer);
|
|
buildRefreshTimer = setTimeout(fetchBuild, 2000);
|
|
}
|
|
}
|