// 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/ (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 ───────────────────────────────────────────────── // The queue's in-flight count, for the R3BU1LD QU3U3 tab's pill (tabs.js // reads it on its refresh tick). Pending/Running/Finishing = in flight // (Finishing = own work done, a sub-node still running — still counts). // Root nodes only: each is one queue entry. export function rebuildCount() { return jobqNodes.filter( (n) => n.parent == null && (n.state === "Pending" || n.state === "Running" || n.state === "Finishing"), ).length; } // ─── 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); } }