treefmt: apply prettier
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.
This commit is contained in:
parent
5d24bedd60
commit
39b95c2ede
203 changed files with 10090 additions and 6085 deletions
|
|
@ -10,14 +10,20 @@
|
|||
// 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';
|
||||
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 = [];
|
||||
|
|
@ -36,93 +42,122 @@ function syncFromSnapshot(s) {
|
|||
|
||||
// ─── meta inputs ─────────────────────────────────────────────────────────────
|
||||
function renderMetaInputs(s) {
|
||||
const root = $('meta-inputs-section');
|
||||
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),
|
||||
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'));
|
||||
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.'));
|
||||
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 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);
|
||||
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' });
|
||||
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,
|
||||
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,
|
||||
"data-meta-input": inp.name,
|
||||
});
|
||||
if (checkedInputs.has(inp.name)) cb.checked = true;
|
||||
const label = el('label', { for: id, title: inp.name });
|
||||
const label = el("label", { for: id, title: inp.name });
|
||||
label.append(cb);
|
||||
if (depth > 0) label.append(el('span', { class: 'meta-input-twig' }, '└ '));
|
||||
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)),
|
||||
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)));
|
||||
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: '' });
|
||||
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');
|
||||
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', '');
|
||||
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);
|
||||
f.addEventListener("change", refreshDisabled);
|
||||
function setAllChecked(val) {
|
||||
for (const b of f.querySelectorAll('input[data-meta-input]')) b.checked = 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(',');
|
||||
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);
|
||||
}
|
||||
|
|
@ -150,41 +185,50 @@ function renderMetaInputs(s) {
|
|||
// tracked children out from under its diffing rather than let it
|
||||
// update them minimally).
|
||||
function mountRebuildQueue() {
|
||||
const root = $('rebuild-queue-section');
|
||||
const root = $("rebuild-queue-section");
|
||||
if (!root) return;
|
||||
root.replaceChildren();
|
||||
renderRebuildQueue();
|
||||
}
|
||||
function renderRebuildQueue() {
|
||||
const root = $('rebuild-queue-section');
|
||||
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);
|
||||
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 ─────────────────────────────────────────────────
|
||||
|
|
@ -196,13 +240,16 @@ function renderRebuildQueue() {
|
|||
// {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 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; }
|
||||
if (liveLogPollTimer) {
|
||||
clearInterval(liveLogPollTimer);
|
||||
liveLogPollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// First running node with a log, in wire order (root-then-subtree per
|
||||
|
|
@ -210,26 +257,39 @@ function clearLiveLogPoll() {
|
|||
// 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;
|
||||
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);
|
||||
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');
|
||||
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 */ }
|
||||
} catch {
|
||||
/* network blip — ignore, next poll will retry */
|
||||
}
|
||||
}
|
||||
|
||||
function renderRebuildLiveLog() {
|
||||
const root = $('rebuild-live-log');
|
||||
const root = $("rebuild-live-log");
|
||||
if (!root) return;
|
||||
const liveNode = findLiveBuild();
|
||||
|
||||
|
|
@ -237,22 +297,26 @@ function renderRebuildLiveLog() {
|
|||
clearLiveLogPoll();
|
||||
liveLogId = null;
|
||||
liveLogDone = false;
|
||||
if (!root.hidden) { root.hidden = true; root.replaceChildren(); }
|
||||
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') {
|
||||
if (!liveLogDone && liveNode.state !== "Running") {
|
||||
clearLiveLogPoll();
|
||||
liveLogDone = true;
|
||||
const pre = root.querySelector('.rebuild-live-log-output');
|
||||
const badge = root.querySelector('.rebuild-live-log-badge');
|
||||
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');
|
||||
const ok = liveNode.state !== "Failed";
|
||||
badge.className =
|
||||
"rebuild-live-log-badge " + (ok ? "rll-ok" : "rll-fail");
|
||||
badge.textContent = liveNode.state;
|
||||
}
|
||||
}
|
||||
|
|
@ -266,54 +330,84 @@ function renderRebuildLiveLog() {
|
|||
root.hidden = false;
|
||||
root.replaceChildren();
|
||||
|
||||
const pre = el('pre', { class: 'rebuild-live-log-output' }, '');
|
||||
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', () => {
|
||||
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';
|
||||
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 — '),
|
||||
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'),
|
||||
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);
|
||||
liveLogPollTimer = setInterval(
|
||||
() => fetchAndRenderLiveLog(liveNode.id, pre),
|
||||
2000,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── rebuild-queue count pill ─────────────────────────────────────────────────
|
||||
function updateRebuildCount() {
|
||||
const pill = $('builds-tab-count-rebuild');
|
||||
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; }
|
||||
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) ──────────────────────────────
|
||||
|
|
@ -330,43 +424,63 @@ function renderAll() {
|
|||
// 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');
|
||||
const buildList = $("build-list");
|
||||
const buildRefresh = $("build-refresh");
|
||||
let buildLogsLoaded = false;
|
||||
let buildTabs; // set in init
|
||||
|
||||
function fmtTs(unixSecs) {
|
||||
if (!unixSecs) return '';
|
||||
if (!unixSecs) return "";
|
||||
const age = Math.floor(Date.now() / 1000) - unixSecs;
|
||||
return fmtAgeSecs(Math.max(0, age)) + ' ago';
|
||||
return fmtAgeSecs(Math.max(0, age)) + " ago";
|
||||
}
|
||||
|
||||
async function fetchBuild() {
|
||||
if (!buildList) return;
|
||||
buildList.replaceChildren();
|
||||
buildList.append(el('p', { class: 'meta' }, 'loading…'));
|
||||
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 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)'));
|
||||
buildList.append(el("p", { class: "meta" }, "(no build logs yet)"));
|
||||
return;
|
||||
}
|
||||
const ul = el('ul', { class: 'build-logs-list' });
|
||||
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 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)))
|
||||
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
|
||||
? el('span', { class: 'build-logs-runtime meta build-logs-live-dur' }, '…')
|
||||
: el('span', { class: 'build-logs-runtime meta' }, '');
|
||||
? ""
|
||||
: 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) {
|
||||
|
|
@ -378,78 +492,106 @@ async function fetchBuild() {
|
|||
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),
|
||||
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),
|
||||
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' });
|
||||
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));
|
||||
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; }
|
||||
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'),
|
||||
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');
|
||||
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 (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.className =
|
||||
status === "ok"
|
||||
? "hive-pill-sm badge-ok"
|
||||
: "hive-pill-sm badge-fail";
|
||||
badge.textContent = status;
|
||||
streamEs = null;
|
||||
detail.dataset.loaded = '1';
|
||||
detail.dataset.loaded = "1";
|
||||
},
|
||||
onError: () => {
|
||||
if (durTimer) { clearInterval(durTimer); durTimer = null; }
|
||||
badge.textContent = 'stream error';
|
||||
badge.className = 'hive-pill-sm badge-fail';
|
||||
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…');
|
||||
const pre = el("pre", { class: "build-logs-output" }, "fetching…");
|
||||
detail.append(pre);
|
||||
try {
|
||||
const r2 = await fetch('/api/build-logs/id/' + h.id);
|
||||
const r2 = await fetch("/api/build-logs/id/" + h.id);
|
||||
if (!r2.ok) {
|
||||
pre.textContent = 'error ' + r2.status;
|
||||
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)';
|
||||
const out = [full.stdout, full.stderr]
|
||||
.filter(Boolean)
|
||||
.join("\n--- stderr ---\n");
|
||||
pre.textContent = out || "(empty)";
|
||||
}
|
||||
detail.dataset.loaded = '1';
|
||||
detail.dataset.loaded = "1";
|
||||
} catch (err) {
|
||||
pre.textContent = 'fetch failed: ' + err;
|
||||
pre.textContent = "fetch failed: " + err;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -458,24 +600,24 @@ async function fetchBuild() {
|
|||
}
|
||||
buildList.append(ul);
|
||||
// Deep-link: ?id=N auto-expands the target row.
|
||||
const deepId = new URLSearchParams(location.search).get('id');
|
||||
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');
|
||||
const btn = target.querySelector(".build-logs-row-btn");
|
||||
if (btn) {
|
||||
btn.click();
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
target.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
buildList.replaceChildren();
|
||||
buildList.append(el('p', { class: 'meta' }, 'fetch failed: ' + err));
|
||||
buildList.append(el("p", { class: "meta" }, "fetch failed: " + err));
|
||||
}
|
||||
}
|
||||
|
||||
if (buildRefresh) buildRefresh.addEventListener('click', fetchBuild);
|
||||
if (buildRefresh) buildRefresh.addEventListener("click", fetchBuild);
|
||||
|
||||
// ─── SSE handlers ─────────────────────────────────────────────────────────────
|
||||
let buildRefreshTimer = null;
|
||||
|
|
@ -488,7 +630,7 @@ const SSE_HANDLERS = {
|
|||
jobqGraphToken += 1;
|
||||
renderRebuildQueue();
|
||||
// Auto-refresh build log list when the queue changes and BUILD L0GS is active.
|
||||
if (buildTabs && buildTabs.active() === 'buildlogs') {
|
||||
if (buildTabs && buildTabs.active() === "buildlogs") {
|
||||
if (buildRefreshTimer) clearTimeout(buildRefreshTimer);
|
||||
buildRefreshTimer = setTimeout(fetchBuild, 2000);
|
||||
}
|
||||
|
|
@ -506,7 +648,7 @@ const SSE_HANDLERS = {
|
|||
// ─── boot ─────────────────────────────────────────────────────────────────────
|
||||
async function refreshState() {
|
||||
try {
|
||||
const resp = await fetch('/api/state');
|
||||
const resp = await fetch("/api/state");
|
||||
if (resp.ok) syncFromSnapshot(await resp.json());
|
||||
} catch {
|
||||
// best-effort
|
||||
|
|
@ -521,15 +663,19 @@ async function init() {
|
|||
// fetch needed here, unlike meta inputs below.
|
||||
mountRebuildQueue();
|
||||
|
||||
buildTabs = document.getElementById('builds-tabbar').configure({
|
||||
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' },
|
||||
{
|
||||
id: "rebuild",
|
||||
label: "R3BU1LD QU3U3",
|
||||
badgeId: "builds-tab-count-rebuild",
|
||||
},
|
||||
{ id: "meta", label: "M3T4 1NPUTS" },
|
||||
{ id: "buildlogs", label: "BUILD L0GS" },
|
||||
],
|
||||
defaultId: 'rebuild',
|
||||
defaultId: "rebuild",
|
||||
onShow: (id) => {
|
||||
if (id === 'buildlogs' && !buildLogsLoaded) {
|
||||
if (id === "buildlogs" && !buildLogsLoaded) {
|
||||
buildLogsLoaded = true;
|
||||
fetchBuild();
|
||||
}
|
||||
|
|
@ -544,12 +690,16 @@ async function init() {
|
|||
// (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',
|
||||
"/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; }
|
||||
try {
|
||||
ev = JSON.parse(e.data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const h = SSE_HANDLERS[ev.kind];
|
||||
if (h) h(ev);
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue