Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/frontend/packages/dashboard/src/builds.js
atlas 884fb3e08b dashboard: remove the L0GS page and its journal backend
Logs live at swarm level now (VictoriaLogs, read through Grafana
Explore). The hive dashboard's own journal viewer goes, with
everything that existed only to serve it:

- frontend: /logs.html, logs.js, logs.css, the H0M3 "Logs" tile and
  the per-agent menu's "journal logs ->" link. The build-log list
  rules BU1LDS used from logs.css move into builds.css.
- hive-c0re: GET /api/journal/{name} and GET /api/journal-host, and
  priv_client::read_container_journal.
- hive-priv / hive-priv-sock: the ReadContainerJournal request, its
  JournalQuery/JournalOutput wire types and the journalctl -M executor.
- nix: hive-c0re's systemd-journal supplementary group, whose only
  reader was the journal-host handler.
- docs: the L0GS section, the API entry and the security table row.

Closes #5113
Closes #3819
2026-10-12 01:26:33 +02:00

704 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)
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
// 13 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();