feat(builds): add /builds.html — build lifecycle hub (closes #1999)
New standalone page /builds.html consolidating rebuild queue, meta
inputs, and build log history into one place, with a new 'Builds' home
tile linking to it. Addresses mara's request: new sub-page with a new
home tile, all three items moved there.
Changes:
- builds.html: new page with three sub-tabs: R3BU1LD QU3U3, M3T4 1NPUTS,
BUILD L0GS. Same minimal-chrome header + createTabStrip pattern as core
and logs pages.
- builds.js: new bundle combining rebuild queue renderer (from core.js),
meta inputs renderer (from core.js), rebuild-live-log renderer (from
core.js), and build log history renderer (from logs.js). Deep-links to
/builds.html?id=N#buildlogs. Count pill id: builds-tab-count-rebuild.
BUILD L0GS tab lazy-loads on first activation.
- builds.css: @imports system-sections.css (rebuild queue + meta inputs +
live-log styles) and logs.css (build-logs-* component styles).
- build.mjs: register builds.js, builds.css, builds.html.
- core.html: remove R3BU1LD QU3U3 + M3T4 1NPUTS tabs (now on builds.html).
Default tab changes to K3PT ST4T3.
- core.js: remove renderMetaInputs, renderRebuildQueue + helpers,
renderRebuildLiveLog + live-log state, elapsed-time tickers,
updateRebuildCount, and the rebuild_queue/meta_inputs SSE handlers.
Remove openBuildLogStream + util imports no longer needed.
- core.css: remove .rebuild-live-log-* rules (moved to system-sections.css
so builds.css can share them via @import).
- system-sections.css: add .rebuild-live-log-* styles (moved from core.css);
update comment to mention builds.html.
- logs.html: remove BUILD tab + pane (moved to builds.html).
- logs.js: remove fetchBuild(), fmtTs, fmtDuration, openBuildLogStream
import, and rebuild_queue_changed SSE debounce. Default tab: 'agent'.
SSE stream retained for audit_entry_added live-appends.
- index.html: add Builds tile (🔨, rebuild queue · meta inputs · build
logs); update Core tile desc to 'kept state · container load'; update
Logs tile desc to remove 'build'.
This commit is contained in:
parent
75e49f7752
commit
beb28d5c37
11 changed files with 896 additions and 790 deletions
|
|
@ -5,25 +5,29 @@
|
||||||
// dist/dashboard.html the operator dashboard SPA — served
|
// dist/dashboard.html the operator dashboard SPA — served
|
||||||
// at GET /dashboard.html
|
// at GET /dashboard.html
|
||||||
// dist/flow.html served at GET /flow.html
|
// dist/flow.html served at GET /flow.html
|
||||||
// dist/logs.html served at GET /logs.html
|
// dist/logs.html served at GET /logs.html (AGENT/SYSTEM/AUDIT)
|
||||||
// dist/core.html served at GET /core.html (C0R3: rebuild
|
// dist/core.html served at GET /core.html (C0R3: kept
|
||||||
// queue / meta inputs / kept state /
|
// state / container load)
|
||||||
// container load — the old SYST3M tab)
|
// dist/builds.html served at GET /builds.html (BU1LDS:
|
||||||
|
// rebuild queue / meta inputs / build logs)
|
||||||
// dist/static/home.js index.html (H0M3) entry — menu tiles +
|
// dist/static/home.js index.html (H0M3) entry — menu tiles +
|
||||||
// matrix-tile gating + identity line
|
// matrix-tile gating + identity line
|
||||||
// dist/static/tabs.js dashboard.html entry — tab renderers +
|
// dist/static/tabs.js dashboard.html entry — tab renderers +
|
||||||
// tab routing + refreshState
|
// tab routing + refreshState
|
||||||
// dist/static/flow.js /flow.html entry — broker terminal +
|
// dist/static/flow.js /flow.html entry — broker terminal +
|
||||||
// @-mention composer
|
// @-mention composer
|
||||||
// dist/static/logs.js /logs.html entry — build/agent/system
|
// dist/static/logs.js /logs.html entry — agent/system/audit
|
||||||
// log viewer sub-tabs
|
// log viewer sub-tabs
|
||||||
// dist/static/{home,tabs,flow,logs}.js.map source map siblings
|
// dist/static/builds.js /builds.html entry — rebuild queue,
|
||||||
|
// meta inputs, build log history
|
||||||
|
// dist/static/{home,tabs,flow,logs,builds}.js.map source map siblings
|
||||||
// dist/static/common.css loaded by every page (@hive/shared
|
// dist/static/common.css loaded by every page (@hive/shared
|
||||||
// imports + shared typography/badges/
|
// imports + shared typography/badges/
|
||||||
// buttons/inbox/side-panel)
|
// buttons/inbox/side-panel)
|
||||||
// dist/static/dashboard.css dashboard.html only (dashboard-specific)
|
// dist/static/dashboard.css dashboard.html only (dashboard-specific)
|
||||||
// dist/static/flow.css /flow.html only (flow chrome + composer)
|
// dist/static/flow.css /flow.html only (flow chrome + composer)
|
||||||
// dist/static/logs.css /logs.html only (log viewer sub-tabs)
|
// dist/static/logs.css /logs.html only (log viewer sub-tabs)
|
||||||
|
// dist/static/builds.css /builds.html only (build lifecycle hub)
|
||||||
// dist/static/home.css index.html (H0M3) only (tile grid)
|
// dist/static/home.css index.html (H0M3) only (tile grid)
|
||||||
//
|
//
|
||||||
// Each JS entry bundles `./common.js` (DOM helpers, Panel singleton,
|
// Each JS entry bundles `./common.js` (DOM helpers, Panel singleton,
|
||||||
|
|
@ -55,7 +59,7 @@ mkdirSync(staticDir(''), { recursive: true });
|
||||||
// follow-up once asset sizes warrant it). esbuild writes each entry
|
// follow-up once asset sizes warrant it). esbuild writes each entry
|
||||||
// to `static/<name>.js` based on the entryPoint basename.
|
// to `static/<name>.js` based on the entryPoint basename.
|
||||||
await build({
|
await build({
|
||||||
entryPoints: [src('tabs.js'), src('flow.js'), src('logs.js'), src('home.js'), src('settings.js'), src('stats.js'), src('core.js'), src('matrix-accounts.js')],
|
entryPoints: [src('tabs.js'), src('flow.js'), src('logs.js'), src('home.js'), src('settings.js'), src('stats.js'), src('core.js'), src('builds.js'), src('matrix-accounts.js')],
|
||||||
outdir: staticDir(''),
|
outdir: staticDir(''),
|
||||||
bundle: true,
|
bundle: true,
|
||||||
format: 'esm',
|
format: 'esm',
|
||||||
|
|
@ -94,7 +98,7 @@ await build({
|
||||||
// so a swap replaces only it) + theme.css (the semantic derivation
|
// so a swap replaces only it) + theme.css (the semantic derivation
|
||||||
// layer) + common.css (shared typography, badges, buttons, inbox, side
|
// layer) + common.css (shared typography, badges, buttons, inbox, side
|
||||||
// panel) plus its own page-specific bundle.
|
// panel) plus its own page-specific bundle.
|
||||||
for (const entry of ['colors.css', 'theme.css', 'common.css', 'dashboard.css', 'flow.css', 'logs.css', 'home.css', 'settings.css', 'stats.css', 'core.css', 'matrix-accounts.css']) {
|
for (const entry of ['colors.css', 'theme.css', 'common.css', 'dashboard.css', 'flow.css', 'logs.css', 'home.css', 'settings.css', 'stats.css', 'core.css', 'builds.css', 'matrix-accounts.css']) {
|
||||||
await build({
|
await build({
|
||||||
entryPoints: [src(entry)],
|
entryPoints: [src(entry)],
|
||||||
outfile: staticDir(entry),
|
outfile: staticDir(entry),
|
||||||
|
|
@ -104,7 +108,7 @@ for (const entry of ['colors.css', 'theme.css', 'common.css', 'dashboard.css', '
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const html of ['index.html', 'dashboard.html', 'flow.html', 'logs.html', 'settings.html', 'stats.html', 'core.html', 'matrix-accounts.html']) {
|
for (const html of ['index.html', 'dashboard.html', 'flow.html', 'logs.html', 'settings.html', 'stats.html', 'core.html', 'builds.html', 'matrix-accounts.html']) {
|
||||||
copyFileSync(src(html), dist(html));
|
copyFileSync(src(html), dist(html));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
26
frontend/packages/dashboard/src/builds.css
Normal file
26
frontend/packages/dashboard/src/builds.css
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
/* ─── /builds.html — BU1LDS page ────────────────────────────────────
|
||||||
|
The build lifecycle hub: rebuild queue + live log, meta inputs, and
|
||||||
|
build log history on one page. Carved out of /core.html (rebuild
|
||||||
|
queue + meta inputs) and /logs.html (BUILD tab).
|
||||||
|
|
||||||
|
Imports system-sections.css for rebuild queue + meta inputs + live-log
|
||||||
|
component styles, and logs.css for the build-logs-* component styles.
|
||||||
|
Body-scoped `.logs-shell` rules in logs.css don't apply here
|
||||||
|
(`.builds-shell` body); only the build-logs-* component rules are used. */
|
||||||
|
@import "./system-sections.css";
|
||||||
|
@import "./logs.css";
|
||||||
|
|
||||||
|
body.builds-shell {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.builds-tabbar {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.builds-main {
|
||||||
|
padding: 1.2em 1.5em 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.builds-pane[hidden] { display: none; }
|
||||||
84
frontend/packages/dashboard/src/builds.html
Normal file
84
frontend/packages/dashboard/src/builds.html
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>hyperhive // BU1LDS</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
|
<link rel="stylesheet" href="/static/colors.css">
|
||||||
|
<link rel="stylesheet" href="/static/theme.css">
|
||||||
|
<link rel="stylesheet" href="/static/common.css">
|
||||||
|
<link rel="stylesheet" href="/static/builds.css">
|
||||||
|
</head>
|
||||||
|
<body class="builds-shell">
|
||||||
|
|
||||||
|
<!-- BU1LDS: the build lifecycle hub — rebuild queue, live build log,
|
||||||
|
meta inputs, and build log history on one page. Carved out of the
|
||||||
|
old /core.html (rebuild queue + meta inputs) and /logs.html (BUILD
|
||||||
|
tab) so the full build lifecycle is in one place. Same minimal
|
||||||
|
chrome as /core.html — a `← home` back-link + a createTabStrip
|
||||||
|
sub-tab nav. Default sub-tab is the rebuild queue. -->
|
||||||
|
<header class="page-header">
|
||||||
|
<a class="page-back" href="/">← home</a>
|
||||||
|
<nav class="hive-tabbar builds-tabbar" id="builds-tabbar" role="tablist">
|
||||||
|
<a class="hive-tab" id="builds-tab-rebuild" href="#rebuild" role="tab"
|
||||||
|
aria-controls="builds-pane-rebuild" data-tab="rebuild">
|
||||||
|
<span class="builds-tab-label">R3BU1LD QU3U3</span>
|
||||||
|
<span class="tab-count" id="builds-tab-count-rebuild" hidden></span>
|
||||||
|
</a>
|
||||||
|
<a class="hive-tab" id="builds-tab-meta" href="#meta" role="tab"
|
||||||
|
aria-controls="builds-pane-meta" data-tab="meta">
|
||||||
|
<span class="builds-tab-label">M3T4 1NPUTS</span>
|
||||||
|
</a>
|
||||||
|
<a class="hive-tab" id="builds-tab-buildlogs" href="#buildlogs" role="tab"
|
||||||
|
aria-controls="builds-pane-buildlogs" data-tab="buildlogs">
|
||||||
|
<span class="builds-tab-label">BUILD L0GS</span>
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="builds-main">
|
||||||
|
|
||||||
|
<!-- R3BU1LD QU3U3: pending + running rebuilds, meta-updates, and
|
||||||
|
first-spawns. Driven by /api/state.rebuild_queue cold-load +
|
||||||
|
live `rebuild_queue_changed` over /api/dashboard/stream. Default tab. -->
|
||||||
|
<section class="builds-pane" id="builds-pane-rebuild" data-tab-pane="rebuild"
|
||||||
|
role="tabpanel" aria-labelledby="builds-tab-rebuild">
|
||||||
|
<p class="meta">pending + running rebuilds, meta-updates, and first-spawns. one runs at a time; meta-update cascades nest under their parent. dedup: re-enqueueing a still-queued op collapses into the existing entry.</p>
|
||||||
|
<div id="rebuild-queue-section">
|
||||||
|
<p class="meta">loading…</p>
|
||||||
|
</div>
|
||||||
|
<!-- Live build log of the currently-running rebuild (one runs at a
|
||||||
|
time). Managed by renderRebuildLiveLog in builds.js, separate from
|
||||||
|
rebuild-queue-section so the queue's row re-render never disturbs
|
||||||
|
the open SSE stream. -->
|
||||||
|
<div id="rebuild-live-log" class="rebuild-live-log" hidden></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- M3T4 1NPUTS: select inputs to nix flake update in /meta/. -->
|
||||||
|
<section class="builds-pane" id="builds-pane-meta" data-tab-pane="meta"
|
||||||
|
role="tabpanel" aria-labelledby="builds-tab-meta">
|
||||||
|
<p class="meta">select inputs to <code>nix flake update</code> in <code>/meta/</code>. selected agents rebuild in sequence after the lock bump; manager learns each outcome via the usual <code>rebuilt</code> system event.</p>
|
||||||
|
<div id="meta-inputs-section">
|
||||||
|
<p class="meta">loading…</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- BUILD L0GS: all-agents build log history (moved from /logs.html).
|
||||||
|
Click a row to expand stdout + stderr. Live builds stream in real
|
||||||
|
time. Lazy-loaded on first tab activation; auto-refreshes when
|
||||||
|
rebuild_queue_changed fires. Deep-link: ?id=N#buildlogs. -->
|
||||||
|
<section class="builds-pane" id="builds-pane-buildlogs" data-tab-pane="buildlogs"
|
||||||
|
role="tabpanel" aria-labelledby="builds-tab-buildlogs">
|
||||||
|
<p class="meta">all-agents build log history. click a row to expand stdout + stderr. live builds stream in real time.</p>
|
||||||
|
<div class="logs-toolbar">
|
||||||
|
<button type="button" class="btn btn-restart" id="build-refresh">↻ refresh</button>
|
||||||
|
</div>
|
||||||
|
<div id="build-list"><p class="meta">loading…</p></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script type="module" src="/static/builds.js" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
632
frontend/packages/dashboard/src/builds.js
Normal file
632
frontend/packages/dashboard/src/builds.js
Normal file
|
|
@ -0,0 +1,632 @@
|
||||||
|
// 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 the shared createTabStrip (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 { $, el, form, fmtAgeSecs, openStream, openBuildLogStream, initServerWarnings, bindAsyncForms } from './common.js';
|
||||||
|
import { fmtAgo, fmtElapsed, fmtDuration, truncate } from './util.js';
|
||||||
|
import { createTabStrip } from '@hive/shared/tabs.js';
|
||||||
|
|
||||||
|
// ─── derived state ───────────────────────────────────────────────────────────
|
||||||
|
let metaInputsState = [];
|
||||||
|
let metaUpdateRunning = false;
|
||||||
|
let rebuildQueueState = [];
|
||||||
|
|
||||||
|
function syncFromSnapshot(s) {
|
||||||
|
metaInputsState = (s.meta_inputs || []).slice();
|
||||||
|
metaUpdateRunning = !!s.meta_update_running;
|
||||||
|
rebuildQueueState = (s.rebuild_queue || []).slice();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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 ────────────────────────────────────────────────────────────
|
||||||
|
const rebuildQueueRowCache = new Map();
|
||||||
|
const QUEUE_KIND_GLYPH = {
|
||||||
|
rebuild: '↻',
|
||||||
|
meta_update: '◆',
|
||||||
|
spawn: '✨',
|
||||||
|
destroy: '🗑',
|
||||||
|
restart: '↺',
|
||||||
|
startup_sweep: '⚡',
|
||||||
|
perm_change: '🔑',
|
||||||
|
};
|
||||||
|
const QUEUE_STATE_GLYPH = {
|
||||||
|
queued: '⏸',
|
||||||
|
running: '▶',
|
||||||
|
done: '✔',
|
||||||
|
failed: '✖',
|
||||||
|
cancelled: '⊘',
|
||||||
|
};
|
||||||
|
|
||||||
|
function rebuildQueueEntryFingerprint(entry, isChild) {
|
||||||
|
return JSON.stringify({
|
||||||
|
state: entry.state,
|
||||||
|
step: entry.step,
|
||||||
|
kind: entry.kind,
|
||||||
|
agent: entry.agent,
|
||||||
|
source: entry.source,
|
||||||
|
started_at: entry.started_at,
|
||||||
|
enqueued_at: entry.enqueued_at,
|
||||||
|
finished_at: entry.finished_at,
|
||||||
|
reason: entry.reason,
|
||||||
|
error: entry.error,
|
||||||
|
build_log_id: entry.build_log_id,
|
||||||
|
isChild,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRebuildQueue(s) {
|
||||||
|
const queue = s.rebuild_queue || [];
|
||||||
|
renderRebuildLiveLog(queue);
|
||||||
|
const root = $('rebuild-queue-section');
|
||||||
|
if (!root) return;
|
||||||
|
|
||||||
|
if (!queue.length) {
|
||||||
|
rebuildQueueRowCache.clear();
|
||||||
|
root.replaceChildren(el('p', { class: 'empty' }, 'queue is empty — nothing pending or in flight.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const byId = new Map(queue.map((e) => [e.id, e]));
|
||||||
|
const tops = queue.filter((e) => e.parent_id == null);
|
||||||
|
const childrenOf = new Map();
|
||||||
|
for (const e of queue) {
|
||||||
|
if (e.parent_id != null) {
|
||||||
|
if (!childrenOf.has(e.parent_id)) childrenOf.set(e.parent_id, []);
|
||||||
|
childrenOf.get(e.parent_id).push(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const orphans = queue.filter((e) => e.parent_id != null && !byId.has(e.parent_id));
|
||||||
|
|
||||||
|
const orderedLis = [];
|
||||||
|
function addEntry(entry, isChild) {
|
||||||
|
const fp = rebuildQueueEntryFingerprint(entry, isChild);
|
||||||
|
const cached = rebuildQueueRowCache.get(entry.id);
|
||||||
|
let li;
|
||||||
|
if (cached && cached.fingerprint === fp) {
|
||||||
|
li = cached.el;
|
||||||
|
} else {
|
||||||
|
li = renderQueueEntry(entry, byId, isChild);
|
||||||
|
rebuildQueueRowCache.set(entry.id, { el: li, fingerprint: fp });
|
||||||
|
}
|
||||||
|
orderedLis.push(li);
|
||||||
|
}
|
||||||
|
for (const top of tops) {
|
||||||
|
addEntry(top, false);
|
||||||
|
for (const child of childrenOf.get(top.id) || []) addEntry(child, true);
|
||||||
|
}
|
||||||
|
for (const o of orphans) addEntry(o, true);
|
||||||
|
|
||||||
|
const liveIds = new Set(queue.map((e) => e.id));
|
||||||
|
for (const [id, entry] of rebuildQueueRowCache) {
|
||||||
|
if (!liveIds.has(id)) {
|
||||||
|
entry.el.remove();
|
||||||
|
rebuildQueueRowCache.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let ul = root.querySelector('ul.rebuild-queue');
|
||||||
|
if (!ul) {
|
||||||
|
ul = el('ul', { class: 'rebuild-queue' });
|
||||||
|
root.replaceChildren(ul);
|
||||||
|
}
|
||||||
|
for (let i = 0; i < orderedLis.length; i++) {
|
||||||
|
if (ul.children[i] !== orderedLis[i]) {
|
||||||
|
ul.insertBefore(orderedLis[i], ul.children[i] ?? null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while (ul.children.length > orderedLis.length) ul.lastChild.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderQueueEntry(entry, _byId, isChild) {
|
||||||
|
const li = el('li', {
|
||||||
|
class: 'rebuild-queue-entry rqe-' + entry.state,
|
||||||
|
'data-id': String(entry.id),
|
||||||
|
});
|
||||||
|
if (isChild) li.classList.add('rqe-child');
|
||||||
|
li.append(
|
||||||
|
el('span', { class: 'rqe-state', title: entry.state }, QUEUE_STATE_GLYPH[entry.state] || '?'),
|
||||||
|
' ',
|
||||||
|
el('span', { class: 'rqe-kind', title: entry.kind },
|
||||||
|
(QUEUE_KIND_GLYPH[entry.kind] || '?') + ' ' + entry.kind),
|
||||||
|
' ',
|
||||||
|
el('code', { class: 'rqe-agent' }, entry.agent),
|
||||||
|
);
|
||||||
|
li.append(' ', el('span', { class: 'rqe-source rqe-source-' + entry.source }, entry.source));
|
||||||
|
if (entry.state === 'queued') {
|
||||||
|
li.append(' ', el('span', {
|
||||||
|
class: 'rqe-when',
|
||||||
|
'data-rqe-enqueued': String(entry.enqueued_at),
|
||||||
|
}, '· queued ' + fmtAgo(entry.enqueued_at)));
|
||||||
|
} else if (entry.state === 'running' && entry.started_at) {
|
||||||
|
const elapsed = Math.max(0, Math.floor(Date.now() / 1000 - entry.started_at));
|
||||||
|
li.append(' ', el('span', {
|
||||||
|
class: 'rqe-when',
|
||||||
|
'data-rqe-elapsed': String(entry.started_at),
|
||||||
|
}, '· ' + fmtElapsed(elapsed)));
|
||||||
|
} else if (entry.finished_at) {
|
||||||
|
li.append(' ', el('span', {
|
||||||
|
class: 'rqe-when',
|
||||||
|
'data-rqe-finished': String(entry.finished_at),
|
||||||
|
'data-rqe-state': entry.state,
|
||||||
|
}, '· ' + entry.state + ' ' + fmtAgo(entry.finished_at)));
|
||||||
|
}
|
||||||
|
if (entry.reason) {
|
||||||
|
const r = entry.reason.split('\n')[0];
|
||||||
|
li.append(' ', el('span', { class: 'rqe-reason', title: entry.reason }, '— ' + truncate(r, 60)));
|
||||||
|
}
|
||||||
|
if (entry.step) {
|
||||||
|
li.append(el('div', { class: 'rqe-step' }, '↳ ' + entry.step));
|
||||||
|
}
|
||||||
|
if (entry.build_log_id != null) {
|
||||||
|
li.append(
|
||||||
|
' ',
|
||||||
|
el('a', {
|
||||||
|
class: 'rqe-log-link',
|
||||||
|
href: '/builds.html?id=' + entry.build_log_id + '#buildlogs',
|
||||||
|
target: '_blank',
|
||||||
|
title: 'view build log #' + entry.build_log_id,
|
||||||
|
}, 'logs →'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (entry.error) {
|
||||||
|
li.append(el('pre', { class: 'rqe-error', title: entry.error }, truncate(entry.error, 200)));
|
||||||
|
}
|
||||||
|
if (entry.state === 'queued') {
|
||||||
|
const cancelForm = el('form', {
|
||||||
|
method: 'POST',
|
||||||
|
action: '/api/rebuild-queue/' + entry.id + '/cancel',
|
||||||
|
class: 'inline rqe-cancel',
|
||||||
|
'data-async': '',
|
||||||
|
'data-confirm':
|
||||||
|
`cancel ${entry.kind} for \`${entry.agent}\` (queue id ${entry.id})? ` +
|
||||||
|
`the row drops from the queue and never runs. running / done / failed entries can't be cancelled this way.`,
|
||||||
|
});
|
||||||
|
cancelForm.append(el('button', {
|
||||||
|
type: 'submit',
|
||||||
|
class: 'rqe-cancel-btn',
|
||||||
|
title: 'cancel this queued ' + entry.kind,
|
||||||
|
'aria-label': 'cancel queued ' + entry.kind + ' for ' + entry.agent,
|
||||||
|
}, '✗'));
|
||||||
|
li.append(cancelForm);
|
||||||
|
}
|
||||||
|
return li;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── running-rebuild live log ─────────────────────────────────────────────────
|
||||||
|
// One persistent live-log panel for the currently-running rebuild (the queue
|
||||||
|
// runs one build at a time). Keyed to the running entry's build_log_id and
|
||||||
|
// kept in its own container (#rebuild-live-log) so the queue's row re-render
|
||||||
|
// — which rebuilds rows as the build step advances — never tears down the open
|
||||||
|
// SSE stream.
|
||||||
|
let liveLogEs = null;
|
||||||
|
let liveLogId = null;
|
||||||
|
let liveLogDone = false;
|
||||||
|
let liveLogCollapsed = false;
|
||||||
|
|
||||||
|
function closeLiveLogStream() {
|
||||||
|
if (liveLogEs) { liveLogEs.close(); liveLogEs = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRebuildLiveLog(queue) {
|
||||||
|
const root = $('rebuild-live-log');
|
||||||
|
if (!root) return;
|
||||||
|
const running = (queue || []).find(
|
||||||
|
(e) => e.state === 'running' && e.build_log_id != null);
|
||||||
|
|
||||||
|
if (!running) {
|
||||||
|
closeLiveLogStream();
|
||||||
|
liveLogId = null;
|
||||||
|
liveLogDone = false;
|
||||||
|
if (!root.hidden) { root.hidden = true; root.replaceChildren(); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (running.build_log_id === liveLogId && (liveLogEs || liveLogDone)) return;
|
||||||
|
|
||||||
|
closeLiveLogStream();
|
||||||
|
liveLogId = running.build_log_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 header = el('div', { class: 'rebuild-live-log-header' },
|
||||||
|
toggle, ' ',
|
||||||
|
el('span', { class: 'rebuild-live-log-title' }, 'live build log — '),
|
||||||
|
el('code', { class: 'rqe-agent' }, running.agent),
|
||||||
|
' ', el('span', { class: 'rqe-kind' }, running.kind || 'rebuild'),
|
||||||
|
' ', badge, ' ',
|
||||||
|
el('a', {
|
||||||
|
class: 'rebuild-live-log-raw',
|
||||||
|
href: '/api/build-logs/id/' + running.build_log_id + '/raw',
|
||||||
|
download: 'build-log-' + running.build_log_id + '.txt',
|
||||||
|
}, '↓ raw'),
|
||||||
|
);
|
||||||
|
root.append(header, pre);
|
||||||
|
|
||||||
|
liveLogEs = openBuildLogStream(running.build_log_id, pre, {
|
||||||
|
onDone: (status) => {
|
||||||
|
liveLogDone = true;
|
||||||
|
badge.className = 'rebuild-live-log-badge ' + (status === 'ok' ? 'rll-ok' : 'rll-fail');
|
||||||
|
badge.textContent = status;
|
||||||
|
liveLogEs = null;
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
badge.className = 'rebuild-live-log-badge rll-fail';
|
||||||
|
badge.textContent = 'stream error';
|
||||||
|
liveLogEs = null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── rebuild-queue count pill ─────────────────────────────────────────────────
|
||||||
|
function updateRebuildCount() {
|
||||||
|
const pill = $('builds-tab-count-rebuild');
|
||||||
|
if (!pill) return;
|
||||||
|
let n = 0;
|
||||||
|
for (const e of rebuildQueueState) {
|
||||||
|
if (e.state === 'queued' || e.state === 'running') n++;
|
||||||
|
}
|
||||||
|
if (n > 0) { pill.textContent = String(n); pill.hidden = false; }
|
||||||
|
else { pill.hidden = true; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── render-all (cold load + any full re-render) ──────────────────────────────
|
||||||
|
function renderAll() {
|
||||||
|
renderRebuildQueue({ rebuild_queue: rebuildQueueState });
|
||||||
|
renderMetaInputs({ meta_inputs: metaInputsState });
|
||||||
|
updateRebuildCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── elapsed-time tickers ─────────────────────────────────────────────────────
|
||||||
|
setInterval(() => {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
for (const span of document.querySelectorAll('.rqe-when[data-rqe-elapsed]')) {
|
||||||
|
const started = parseInt(span.dataset.rqeElapsed, 10);
|
||||||
|
if (!started) continue;
|
||||||
|
span.textContent = '· ' + fmtElapsed(Math.max(0, now - started));
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
setInterval(() => {
|
||||||
|
for (const span of document.querySelectorAll('.rqe-when[data-rqe-enqueued]')) {
|
||||||
|
const enqueued = parseInt(span.dataset.rqeEnqueued, 10);
|
||||||
|
if (!enqueued) continue;
|
||||||
|
span.textContent = '· queued ' + fmtAgo(enqueued);
|
||||||
|
}
|
||||||
|
for (const span of document.querySelectorAll('.rqe-when[data-rqe-finished]')) {
|
||||||
|
const finished = parseInt(span.dataset.rqeFinished, 10);
|
||||||
|
if (!finished) continue;
|
||||||
|
const state = span.dataset.rqeState || '';
|
||||||
|
span.textContent = '· ' + state + ' ' + fmtAgo(finished);
|
||||||
|
}
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
// ─── 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 ? 'badge badge-running' : ok ? 'badge badge-ok' : 'badge 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 badge 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' ? 'badge badge-ok' : 'badge badge-fail';
|
||||||
|
badge.textContent = status;
|
||||||
|
streamEs = null;
|
||||||
|
detail.dataset.loaded = '1';
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
if (durTimer) { clearInterval(durTimer); durTimer = null; }
|
||||||
|
badge.textContent = 'stream error';
|
||||||
|
badge.className = 'badge 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(ev) {
|
||||||
|
rebuildQueueState = (ev.queue || []).slice();
|
||||||
|
renderRebuildQueue({ rebuild_queue: rebuildQueueState });
|
||||||
|
updateRebuildCount();
|
||||||
|
// 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());
|
||||||
|
|
||||||
|
buildTabs = createTabStrip(document.getElementById('builds-tabbar'), {
|
||||||
|
defaultId: 'rebuild',
|
||||||
|
onShow: (id) => {
|
||||||
|
if (id === 'buildlogs' && !buildLogsLoaded) {
|
||||||
|
buildLogsLoaded = true;
|
||||||
|
fetchBuild();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await refreshState();
|
||||||
|
|
||||||
|
const es = openStream('/api/dashboard/stream');
|
||||||
|
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();
|
||||||
|
|
@ -1,18 +1,21 @@
|
||||||
/* ─── /core.html — C0R3 page ───────────────────────────────────────
|
/* ─── /core.html — C0R3 page ───────────────────────────────────────
|
||||||
Standalone page carved out of the dashboard's old SYST3M tab. Same
|
Standalone page for kept state (tombstones) and container load.
|
||||||
minimal-chrome pattern as /logs.html (a `← home` back-link + a
|
Same minimal-chrome pattern as /logs.html (a `← home` back-link +
|
||||||
createTabStrip sub-tab strip; base tab + page-header styling comes
|
a createTabStrip sub-tab strip; base tab + page-header styling comes
|
||||||
from @hive/shared via common.css, linked by core.html).
|
from @hive/shared via common.css, linked by core.html).
|
||||||
|
|
||||||
The four section renderers (rebuild queue, meta inputs, kept state,
|
The section renderers (kept state, container load) emit the same
|
||||||
container load) are ported verbatim from the dashboard, so they emit
|
class names as before. Those section-internal rules live in
|
||||||
the exact same class names. Those section-internal rules live in
|
system-sections.css (shared with dashboard.css and builds.css, which
|
||||||
system-sections.css (shared with the dashboard, which references a
|
references `.rqe-source*` from the schedules view). The
|
||||||
couple of them — `.rqe-source*` — from its schedules view). The
|
|
||||||
`.hive-stats-table` the container-load table reuses lives in
|
`.hive-stats-table` the container-load table reuses lives in
|
||||||
common.css, linked directly by core.html. Importing just the section
|
common.css, linked directly by core.html. Importing just the section
|
||||||
rules keeps the C0R3 bundle small instead of dragging in all of
|
rules keeps the C0R3 bundle small instead of dragging in all of
|
||||||
dashboard.css. */
|
dashboard.css.
|
||||||
|
|
||||||
|
Rebuild queue + meta inputs have moved to /builds.html; the
|
||||||
|
`.rebuild-live-log-*` styles that used to live here have moved to
|
||||||
|
system-sections.css so builds.css can share them via @import. */
|
||||||
@import "./system-sections.css";
|
@import "./system-sections.css";
|
||||||
|
|
||||||
body.core-shell {
|
body.core-shell {
|
||||||
|
|
@ -33,70 +36,3 @@ body.core-shell {
|
||||||
/* createTabStrip toggles the native `hidden` attribute on inactive
|
/* createTabStrip toggles the native `hidden` attribute on inactive
|
||||||
panes; ensure it wins over any inherited display. */
|
panes; ensure it wins over any inherited display. */
|
||||||
.core-pane[hidden] { display: none; }
|
.core-pane[hidden] { display: none; }
|
||||||
|
|
||||||
/* ─── running-rebuild live log (renderRebuildLiveLog in core.js) ──────────
|
|
||||||
Streams the currently-running rebuild's build log inline under the queue.
|
|
||||||
Hidden (native `hidden` attr) when nothing is building. */
|
|
||||||
.rebuild-live-log {
|
|
||||||
margin-top: 0.8em;
|
|
||||||
border: 1px solid var(--purple-dim);
|
|
||||||
border-radius: 4px;
|
|
||||||
background: color-mix(in srgb, var(--crust) 70%, transparent);
|
|
||||||
}
|
|
||||||
.rebuild-live-log-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.4em;
|
|
||||||
padding: 0.4em 0.6em;
|
|
||||||
font-size: 0.85em;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
.rebuild-live-log-toggle {
|
|
||||||
background: transparent;
|
|
||||||
border: 0;
|
|
||||||
color: var(--muted);
|
|
||||||
cursor: pointer;
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 1em;
|
|
||||||
padding: 0 0.2em;
|
|
||||||
}
|
|
||||||
.rebuild-live-log-toggle:hover { color: var(--fg); }
|
|
||||||
.rebuild-live-log-title { color: var(--muted); }
|
|
||||||
.rebuild-live-log-raw {
|
|
||||||
margin-left: auto;
|
|
||||||
color: var(--purple);
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: 0.9em;
|
|
||||||
}
|
|
||||||
.rebuild-live-log-raw:hover { text-decoration: underline; }
|
|
||||||
.rebuild-live-log-badge {
|
|
||||||
font-size: 0.75em;
|
|
||||||
font-weight: bold;
|
|
||||||
padding: 0.05em 0.5em;
|
|
||||||
border-radius: 999px;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
}
|
|
||||||
.rebuild-live-log-badge.rll-running {
|
|
||||||
color: var(--amber);
|
|
||||||
background: color-mix(in srgb, var(--amber) 18%, transparent);
|
|
||||||
}
|
|
||||||
.rebuild-live-log-badge.rll-ok {
|
|
||||||
color: var(--green);
|
|
||||||
background: color-mix(in srgb, var(--green) 18%, transparent);
|
|
||||||
}
|
|
||||||
.rebuild-live-log-badge.rll-fail {
|
|
||||||
color: var(--red);
|
|
||||||
background: color-mix(in srgb, var(--red) 18%, transparent);
|
|
||||||
}
|
|
||||||
.rebuild-live-log-output {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0.5em 0.7em;
|
|
||||||
max-height: 22em;
|
|
||||||
overflow-y: auto;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-word;
|
|
||||||
font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", "Source Code Pro", monospace;
|
|
||||||
font-size: 0.8em;
|
|
||||||
line-height: 1.4;
|
|
||||||
color: var(--fg);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -16,23 +16,14 @@
|
||||||
/core.html) carved out of the dashboard's old SYST3M tab so the
|
/core.html) carved out of the dashboard's old SYST3M tab so the
|
||||||
dashboard tab strip stays lean. Same minimal chrome as
|
dashboard tab strip stays lean. Same minimal chrome as
|
||||||
/logs.html — a `← home` back-link to the H0M3 hub + a
|
/logs.html — a `← home` back-link to the H0M3 hub + a
|
||||||
createTabStrip sub-tab nav. Default sub-tab is the rebuild queue
|
createTabStrip sub-tab nav. Two sub-tabs: kept state (tombstones)
|
||||||
(the thing the operator actually watches). The section <div> ids
|
and container load. Rebuild queue + meta inputs have moved to
|
||||||
(rebuild-queue-section, meta-inputs-section, tombstones-section,
|
/builds.html (the build lifecycle hub). Default tab: K3PT ST4T3.
|
||||||
container-load-section) match what core.js's renderers target,
|
The section <div> ids (tombstones-section, container-load-section)
|
||||||
which are the same renderers the dashboard SYST3M tab used. -->
|
match what core.js's renderers target. -->
|
||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
<a class="page-back" href="/">← home</a>
|
<a class="page-back" href="/">← home</a>
|
||||||
<nav class="hive-tabbar core-tabbar" id="core-tabbar" role="tablist">
|
<nav class="hive-tabbar core-tabbar" id="core-tabbar" role="tablist">
|
||||||
<a class="hive-tab" id="core-tab-rebuild" href="#rebuild" role="tab"
|
|
||||||
aria-controls="core-pane-rebuild" data-tab="rebuild">
|
|
||||||
<span class="core-tab-label">R3BU1LD QU3U3</span>
|
|
||||||
<span class="tab-count" id="core-tab-count-rebuild" hidden></span>
|
|
||||||
</a>
|
|
||||||
<a class="hive-tab" id="core-tab-meta" href="#meta" role="tab"
|
|
||||||
aria-controls="core-pane-meta" data-tab="meta">
|
|
||||||
<span class="core-tab-label">M3T4 1NPUTS</span>
|
|
||||||
</a>
|
|
||||||
<a class="hive-tab" id="core-tab-kept" href="#kept" role="tab"
|
<a class="hive-tab" id="core-tab-kept" href="#kept" role="tab"
|
||||||
aria-controls="core-pane-kept" data-tab="kept">
|
aria-controls="core-pane-kept" data-tab="kept">
|
||||||
<span class="core-tab-label">K3PT ST4T3</span>
|
<span class="core-tab-label">K3PT ST4T3</span>
|
||||||
|
|
@ -46,31 +37,6 @@
|
||||||
|
|
||||||
<main class="core-main">
|
<main class="core-main">
|
||||||
|
|
||||||
<!-- R3BU1LD QU3U3: pending + running rebuilds, meta-updates, and
|
|
||||||
first-spawns. Driven by /api/state.rebuild_queue cold-load +
|
|
||||||
live `rebuild_queue_changed` over /api/dashboard/stream. Default tab. -->
|
|
||||||
<section class="core-pane" id="core-pane-rebuild" data-tab-pane="rebuild"
|
|
||||||
role="tabpanel" aria-labelledby="core-tab-rebuild">
|
|
||||||
<p class="meta">pending + running rebuilds, meta-updates, and first-spawns. one runs at a time; meta-update cascades nest under their parent. dedup: re-enqueueing a still-queued op collapses into the existing entry.</p>
|
|
||||||
<div id="rebuild-queue-section">
|
|
||||||
<p class="meta">loading…</p>
|
|
||||||
</div>
|
|
||||||
<!-- Live build log of the currently-running rebuild (one runs at a
|
|
||||||
time). Managed by renderRebuildLiveLog in core.js, separate from
|
|
||||||
rebuild-queue-section so the queue's row re-render never disturbs
|
|
||||||
the open SSE stream. -->
|
|
||||||
<div id="rebuild-live-log" class="rebuild-live-log" hidden></div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- M3T4 1NPUTS: select inputs to nix flake update in /meta/. -->
|
|
||||||
<section class="core-pane" id="core-pane-meta" data-tab-pane="meta"
|
|
||||||
role="tabpanel" aria-labelledby="core-tab-meta">
|
|
||||||
<p class="meta">select inputs to <code>nix flake update</code> in <code>/meta/</code>. selected agents rebuild in sequence after the lock bump; manager learns each outcome via the usual <code>rebuilt</code> system event.</p>
|
|
||||||
<div id="meta-inputs-section">
|
|
||||||
<p class="meta">loading…</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- K3PT ST4T3: tombstoned-agent kept state + stale permission entries.
|
<!-- K3PT ST4T3: tombstoned-agent kept state + stale permission entries.
|
||||||
tombstones-section shows destroyed agents (revive / purge buttons).
|
tombstones-section shows destroyed agents (revive / purge buttons).
|
||||||
tombstones-stale-perms is lazy-loaded on tab activation and shows
|
tombstones-stale-perms is lazy-loaded on tab activation and shows
|
||||||
|
|
|
||||||
|
|
@ -1,391 +1,22 @@
|
||||||
// C0R3 page entry (/core.html). The host/coordinator surface carved out
|
// C0R3 page entry (/core.html). The host/coordinator surface carved out
|
||||||
// of the dashboard's old SYST3M tab into its own standalone page so the
|
// of the dashboard's old SYST3M tab into its own standalone page so the
|
||||||
// dashboard tab strip stays lean. Four sub-tabs via the shared
|
// dashboard tab strip stays lean. Two sub-tabs via the shared
|
||||||
// createTabStrip (default = Rebuild Queue): rebuild queue, meta inputs,
|
// createTabStrip (default = K3PT ST4T3): kept state (tombstones) and
|
||||||
// kept state (tombstones), container load.
|
// container load.
|
||||||
//
|
//
|
||||||
// This page is its own esbuild bundle with its own runtime: it cold-loads
|
// Rebuild queue + meta inputs have moved to /builds.html (the build
|
||||||
// /api/state and subscribes to /dashboard/stream (the same broker event
|
// lifecycle hub). This page is its own esbuild bundle with its own
|
||||||
// channel the dashboard uses), maintaining its own copies of the derived
|
// runtime: it cold-loads /api/state and subscribes to /dashboard/stream
|
||||||
// state. The four section renderers below are ported from tabs.js (the
|
// (the same broker event channel the dashboard uses), maintaining its
|
||||||
// dashboard SYST3M tab); the dashboard keeps its own copies for now —
|
// own copy of the tombstones state.
|
||||||
// de-duplication + removing the SYST3M tab is a deliberate follow-up.
|
|
||||||
|
|
||||||
import { $, el, form, openStream, openBuildLogStream, initServerWarnings, bindAsyncForms } from './common.js';
|
import { $, el, form, openStream, initServerWarnings, bindAsyncForms } from './common.js';
|
||||||
import { fmtAgo, fmtElapsed, truncate } from './util.js';
|
|
||||||
import { createTabStrip } from '@hive/shared/tabs.js';
|
import { createTabStrip } from '@hive/shared/tabs.js';
|
||||||
|
|
||||||
// ─── derived state (own copies; this bundle has its own runtime) ──────────
|
// ─── derived state (own copies; this bundle has its own runtime) ──────────
|
||||||
let metaInputsState = [];
|
|
||||||
let metaUpdateRunning = false;
|
|
||||||
let tombstonesState = [];
|
let tombstonesState = [];
|
||||||
let rebuildQueueState = [];
|
|
||||||
function syncFromSnapshot(s) {
|
function syncFromSnapshot(s) {
|
||||||
metaInputsState = (s.meta_inputs || []).slice();
|
|
||||||
metaUpdateRunning = !!s.meta_update_running;
|
|
||||||
tombstonesState = (s.tombstones || []).slice();
|
tombstonesState = (s.tombstones || []).slice();
|
||||||
rebuildQueueState = (s.rebuild_queue || []).slice();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── 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 ──────────────────────────────────────────────────────
|
|
||||||
const rebuildQueueRowCache = new Map();
|
|
||||||
const QUEUE_KIND_GLYPH = {
|
|
||||||
rebuild: '↻',
|
|
||||||
meta_update: '◆',
|
|
||||||
spawn: '✨',
|
|
||||||
destroy: '🗑',
|
|
||||||
restart: '↺',
|
|
||||||
startup_sweep: '⚡',
|
|
||||||
perm_change: '🔑',
|
|
||||||
};
|
|
||||||
const QUEUE_STATE_GLYPH = {
|
|
||||||
queued: '⏸',
|
|
||||||
running: '▶',
|
|
||||||
done: '✔',
|
|
||||||
failed: '✖',
|
|
||||||
cancelled: '⊘',
|
|
||||||
};
|
|
||||||
|
|
||||||
function rebuildQueueEntryFingerprint(entry, isChild) {
|
|
||||||
return JSON.stringify({
|
|
||||||
state: entry.state,
|
|
||||||
step: entry.step,
|
|
||||||
kind: entry.kind,
|
|
||||||
agent: entry.agent,
|
|
||||||
source: entry.source,
|
|
||||||
started_at: entry.started_at,
|
|
||||||
enqueued_at: entry.enqueued_at,
|
|
||||||
finished_at: entry.finished_at,
|
|
||||||
reason: entry.reason,
|
|
||||||
error: entry.error,
|
|
||||||
build_log_id: entry.build_log_id,
|
|
||||||
isChild,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderRebuildQueue(s) {
|
|
||||||
const queue = s.rebuild_queue || [];
|
|
||||||
renderRebuildLiveLog(queue);
|
|
||||||
const root = $('rebuild-queue-section');
|
|
||||||
if (!root) return;
|
|
||||||
|
|
||||||
if (!queue.length) {
|
|
||||||
rebuildQueueRowCache.clear();
|
|
||||||
root.replaceChildren(el('p', { class: 'empty' }, 'queue is empty — nothing pending or in flight.'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const byId = new Map(queue.map((e) => [e.id, e]));
|
|
||||||
const tops = queue.filter((e) => e.parent_id == null);
|
|
||||||
const childrenOf = new Map();
|
|
||||||
for (const e of queue) {
|
|
||||||
if (e.parent_id != null) {
|
|
||||||
if (!childrenOf.has(e.parent_id)) childrenOf.set(e.parent_id, []);
|
|
||||||
childrenOf.get(e.parent_id).push(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const orphans = queue.filter((e) => e.parent_id != null && !byId.has(e.parent_id));
|
|
||||||
|
|
||||||
const orderedLis = [];
|
|
||||||
function addEntry(entry, isChild) {
|
|
||||||
const fp = rebuildQueueEntryFingerprint(entry, isChild);
|
|
||||||
const cached = rebuildQueueRowCache.get(entry.id);
|
|
||||||
let li;
|
|
||||||
if (cached && cached.fingerprint === fp) {
|
|
||||||
li = cached.el;
|
|
||||||
} else {
|
|
||||||
li = renderQueueEntry(entry, byId, isChild);
|
|
||||||
rebuildQueueRowCache.set(entry.id, { el: li, fingerprint: fp });
|
|
||||||
}
|
|
||||||
orderedLis.push(li);
|
|
||||||
}
|
|
||||||
for (const top of tops) {
|
|
||||||
addEntry(top, false);
|
|
||||||
for (const child of childrenOf.get(top.id) || []) addEntry(child, true);
|
|
||||||
}
|
|
||||||
for (const o of orphans) addEntry(o, true);
|
|
||||||
|
|
||||||
const liveIds = new Set(queue.map((e) => e.id));
|
|
||||||
for (const [id, entry] of rebuildQueueRowCache) {
|
|
||||||
if (!liveIds.has(id)) {
|
|
||||||
entry.el.remove();
|
|
||||||
rebuildQueueRowCache.delete(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let ul = root.querySelector('ul.rebuild-queue');
|
|
||||||
if (!ul) {
|
|
||||||
ul = el('ul', { class: 'rebuild-queue' });
|
|
||||||
root.replaceChildren(ul);
|
|
||||||
}
|
|
||||||
for (let i = 0; i < orderedLis.length; i++) {
|
|
||||||
if (ul.children[i] !== orderedLis[i]) {
|
|
||||||
ul.insertBefore(orderedLis[i], ul.children[i] ?? null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
while (ul.children.length > orderedLis.length) ul.lastChild.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderQueueEntry(entry, _byId, isChild) {
|
|
||||||
const li = el('li', {
|
|
||||||
class: 'rebuild-queue-entry rqe-' + entry.state,
|
|
||||||
'data-id': String(entry.id),
|
|
||||||
});
|
|
||||||
if (isChild) li.classList.add('rqe-child');
|
|
||||||
li.append(
|
|
||||||
el('span', { class: 'rqe-state', title: entry.state }, QUEUE_STATE_GLYPH[entry.state] || '?'),
|
|
||||||
' ',
|
|
||||||
el('span', { class: 'rqe-kind', title: entry.kind },
|
|
||||||
(QUEUE_KIND_GLYPH[entry.kind] || '?') + ' ' + entry.kind),
|
|
||||||
' ',
|
|
||||||
el('code', { class: 'rqe-agent' }, entry.agent),
|
|
||||||
);
|
|
||||||
li.append(' ', el('span', { class: 'rqe-source rqe-source-' + entry.source }, entry.source));
|
|
||||||
if (entry.state === 'queued') {
|
|
||||||
li.append(' ', el('span', {
|
|
||||||
class: 'rqe-when',
|
|
||||||
'data-rqe-enqueued': String(entry.enqueued_at),
|
|
||||||
}, '· queued ' + fmtAgo(entry.enqueued_at)));
|
|
||||||
} else if (entry.state === 'running' && entry.started_at) {
|
|
||||||
const elapsed = Math.max(0, Math.floor(Date.now() / 1000 - entry.started_at));
|
|
||||||
li.append(' ', el('span', {
|
|
||||||
class: 'rqe-when',
|
|
||||||
'data-rqe-elapsed': String(entry.started_at),
|
|
||||||
}, '· ' + fmtElapsed(elapsed)));
|
|
||||||
} else if (entry.finished_at) {
|
|
||||||
li.append(' ', el('span', {
|
|
||||||
class: 'rqe-when',
|
|
||||||
'data-rqe-finished': String(entry.finished_at),
|
|
||||||
'data-rqe-state': entry.state,
|
|
||||||
}, '· ' + entry.state + ' ' + fmtAgo(entry.finished_at)));
|
|
||||||
}
|
|
||||||
if (entry.reason) {
|
|
||||||
const r = entry.reason.split('\n')[0];
|
|
||||||
li.append(' ', el('span', { class: 'rqe-reason', title: entry.reason }, '— ' + truncate(r, 60)));
|
|
||||||
}
|
|
||||||
if (entry.step) {
|
|
||||||
li.append(el('div', { class: 'rqe-step' }, '↳ ' + entry.step));
|
|
||||||
}
|
|
||||||
if (entry.build_log_id != null) {
|
|
||||||
li.append(
|
|
||||||
' ',
|
|
||||||
el('a', {
|
|
||||||
class: 'rqe-log-link',
|
|
||||||
href: '/logs.html?id=' + entry.build_log_id + '#build',
|
|
||||||
target: '_blank',
|
|
||||||
title: 'view build log #' + entry.build_log_id,
|
|
||||||
}, 'logs →'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (entry.error) {
|
|
||||||
li.append(el('pre', { class: 'rqe-error', title: entry.error }, truncate(entry.error, 200)));
|
|
||||||
}
|
|
||||||
if (entry.state === 'queued') {
|
|
||||||
const cancelForm = el('form', {
|
|
||||||
method: 'POST',
|
|
||||||
action: '/api/rebuild-queue/' + entry.id + '/cancel',
|
|
||||||
class: 'inline rqe-cancel',
|
|
||||||
'data-async': '',
|
|
||||||
'data-confirm':
|
|
||||||
`cancel ${entry.kind} for \`${entry.agent}\` (queue id ${entry.id})? ` +
|
|
||||||
`the row drops from the queue and never runs. running / done / failed entries can't be cancelled this way.`,
|
|
||||||
});
|
|
||||||
cancelForm.append(el('button', {
|
|
||||||
type: 'submit',
|
|
||||||
class: 'rqe-cancel-btn',
|
|
||||||
title: 'cancel this queued ' + entry.kind,
|
|
||||||
'aria-label': 'cancel queued ' + entry.kind + ' for ' + entry.agent,
|
|
||||||
}, '✗'));
|
|
||||||
li.append(cancelForm);
|
|
||||||
}
|
|
||||||
return li;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── running-rebuild live log ─────────────────────────────────────────────
|
|
||||||
// One persistent live-log panel for the currently-running rebuild (the queue
|
|
||||||
// runs one build at a time). Keyed to the running entry's build_log_id and
|
|
||||||
// kept in its own container (#rebuild-live-log) so the queue's row re-render
|
|
||||||
// — which rebuilds rows as the build step advances — never tears down the open
|
|
||||||
// SSE stream. Reuses the build-log stream the logs page BUILD tab uses
|
|
||||||
// (GET /api/build-logs/id/{id}/stream; frames {stdout_append, stderr_append,
|
|
||||||
// done, status}); the stream replays accumulated output on connect, so
|
|
||||||
// attaching mid-build still shows the backlog.
|
|
||||||
let liveLogEs = null;
|
|
||||||
let liveLogId = null;
|
|
||||||
let liveLogDone = false;
|
|
||||||
let liveLogCollapsed = false;
|
|
||||||
|
|
||||||
function closeLiveLogStream() {
|
|
||||||
if (liveLogEs) { liveLogEs.close(); liveLogEs = null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderRebuildLiveLog(queue) {
|
|
||||||
const root = $('rebuild-live-log');
|
|
||||||
if (!root) return;
|
|
||||||
const running = (queue || []).find(
|
|
||||||
(e) => e.state === 'running' && e.build_log_id != null);
|
|
||||||
|
|
||||||
if (!running) {
|
|
||||||
// No running build with a log → tear down + hide.
|
|
||||||
closeLiveLogStream();
|
|
||||||
liveLogId = null;
|
|
||||||
liveLogDone = false;
|
|
||||||
if (!root.hidden) { root.hidden = true; root.replaceChildren(); }
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Same build still streaming (or already finished) → leave the panel as-is.
|
|
||||||
// Re-renders fire on every queue mutation; don't reconnect mid-build, and
|
|
||||||
// don't reopen a stream that already sent `done`.
|
|
||||||
if (running.build_log_id === liveLogId && (liveLogEs || liveLogDone)) return;
|
|
||||||
|
|
||||||
// New running build → (re)build the panel + open its stream.
|
|
||||||
closeLiveLogStream();
|
|
||||||
liveLogId = running.build_log_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 header = el('div', { class: 'rebuild-live-log-header' },
|
|
||||||
toggle, ' ',
|
|
||||||
el('span', { class: 'rebuild-live-log-title' }, 'live build log — '),
|
|
||||||
el('code', { class: 'rqe-agent' }, running.agent),
|
|
||||||
' ', el('span', { class: 'rqe-kind' }, running.kind || 'rebuild'),
|
|
||||||
' ', badge, ' ',
|
|
||||||
el('a', {
|
|
||||||
class: 'rebuild-live-log-raw',
|
|
||||||
href: '/api/build-logs/id/' + running.build_log_id + '/raw',
|
|
||||||
download: 'build-log-' + running.build_log_id + '.txt',
|
|
||||||
}, '↓ raw'),
|
|
||||||
);
|
|
||||||
root.append(header, pre);
|
|
||||||
|
|
||||||
// Stream via the shared helper (same path the L0GS BUILD tab uses). It owns
|
|
||||||
// the append / sticky-scroll / stderr-separator / reconnect-replay logic; we
|
|
||||||
// just wire the badge. onDone keeps liveLogId + liveLogDone set so a
|
|
||||||
// re-render before the entry leaves 'running' won't reopen the finished
|
|
||||||
// stream — the next render with no running entry clears the panel.
|
|
||||||
liveLogEs = openBuildLogStream(running.build_log_id, pre, {
|
|
||||||
onDone: (status) => {
|
|
||||||
liveLogDone = true;
|
|
||||||
badge.className = 'rebuild-live-log-badge ' + (status === 'ok' ? 'rll-ok' : 'rll-fail');
|
|
||||||
badge.textContent = status;
|
|
||||||
liveLogEs = null;
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
badge.className = 'rebuild-live-log-badge rll-fail';
|
|
||||||
badge.textContent = 'stream error';
|
|
||||||
liveLogEs = null;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── kept state (tombstones) ──────────────────────────────────────────────
|
// ─── kept state (tombstones) ──────────────────────────────────────────────
|
||||||
|
|
@ -611,64 +242,13 @@ function stopContainerLoadPolling() {
|
||||||
if (containerLoadTimer) { clearInterval(containerLoadTimer); containerLoadTimer = null; }
|
if (containerLoadTimer) { clearInterval(containerLoadTimer); containerLoadTimer = null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── rebuild-queue count pill ─────────────────────────────────────────────
|
|
||||||
function updateRebuildCount() {
|
|
||||||
const pill = $('core-tab-count-rebuild');
|
|
||||||
if (!pill) return;
|
|
||||||
let n = 0;
|
|
||||||
for (const e of rebuildQueueState) {
|
|
||||||
if (e.state === 'queued' || e.state === 'running') n++;
|
|
||||||
}
|
|
||||||
if (n > 0) { pill.textContent = String(n); pill.hidden = false; }
|
|
||||||
else { pill.hidden = true; }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── render-all (cold load + any full re-render) ──────────────────────────
|
// ─── render-all (cold load + any full re-render) ──────────────────────────
|
||||||
function renderAll() {
|
function renderAll() {
|
||||||
renderRebuildQueue({ rebuild_queue: rebuildQueueState });
|
|
||||||
renderMetaInputs({ meta_inputs: metaInputsState });
|
|
||||||
renderTombstones({ tombstones: tombstonesState });
|
renderTombstones({ tombstones: tombstonesState });
|
||||||
updateRebuildCount();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── elapsed-time tickers (same cadence as the dashboard) ─────────────────
|
// ─── live SSE handlers ───────────────────────────────────────────────────
|
||||||
setInterval(() => {
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
for (const span of document.querySelectorAll('.rqe-when[data-rqe-elapsed]')) {
|
|
||||||
const started = parseInt(span.dataset.rqeElapsed, 10);
|
|
||||||
if (!started) continue;
|
|
||||||
span.textContent = '· ' + fmtElapsed(Math.max(0, now - started));
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
setInterval(() => {
|
|
||||||
for (const span of document.querySelectorAll('.rqe-when[data-rqe-enqueued]')) {
|
|
||||||
const enqueued = parseInt(span.dataset.rqeEnqueued, 10);
|
|
||||||
if (!enqueued) continue;
|
|
||||||
span.textContent = '· queued ' + fmtAgo(enqueued);
|
|
||||||
}
|
|
||||||
for (const span of document.querySelectorAll('.rqe-when[data-rqe-finished]')) {
|
|
||||||
const finished = parseInt(span.dataset.rqeFinished, 10);
|
|
||||||
if (!finished) continue;
|
|
||||||
const state = span.dataset.rqeState || '';
|
|
||||||
span.textContent = '· ' + state + ' ' + fmtAgo(finished);
|
|
||||||
}
|
|
||||||
}, 30_000);
|
|
||||||
|
|
||||||
// ─── live SSE handlers (own copies; no SW4RM re-render on this page) ───────
|
|
||||||
const SSE_HANDLERS = {
|
const SSE_HANDLERS = {
|
||||||
rebuild_queue_changed(ev) {
|
|
||||||
rebuildQueueState = (ev.queue || []).slice();
|
|
||||||
renderRebuildQueue({ rebuild_queue: rebuildQueueState });
|
|
||||||
updateRebuildCount();
|
|
||||||
},
|
|
||||||
meta_inputs_changed(ev) {
|
|
||||||
metaInputsState = (ev.inputs || []).slice();
|
|
||||||
renderMetaInputs({ meta_inputs: metaInputsState });
|
|
||||||
},
|
|
||||||
meta_update_running(ev) {
|
|
||||||
metaUpdateRunning = !!ev.running;
|
|
||||||
renderMetaInputs({ meta_inputs: metaInputsState });
|
|
||||||
},
|
|
||||||
tombstones_changed(ev) {
|
tombstones_changed(ev) {
|
||||||
tombstonesState = (ev.tombstones || []).slice();
|
tombstonesState = (ev.tombstones || []).slice();
|
||||||
renderTombstones({ tombstones: tombstonesState });
|
renderTombstones({ tombstones: tombstonesState });
|
||||||
|
|
@ -706,11 +286,11 @@ async function init() {
|
||||||
// to the bare `ok` response page.
|
// to the bare `ok` response page.
|
||||||
bindAsyncForms(() => refreshState());
|
bindAsyncForms(() => refreshState());
|
||||||
|
|
||||||
// Hash-routed sub-tab strip; default Rebuild Queue. Container-load
|
// Hash-routed sub-tab strip; default K3PT ST4T3. Container-load
|
||||||
// polling runs only while the LOAD sub-tab is open (cpu is a short
|
// polling runs only while the LOAD sub-tab is open (cpu is a short
|
||||||
// two-sample read each refresh on the server).
|
// two-sample read each refresh on the server).
|
||||||
createTabStrip(document.getElementById('core-tabbar'), {
|
createTabStrip(document.getElementById('core-tabbar'), {
|
||||||
defaultId: 'rebuild',
|
defaultId: 'kept',
|
||||||
onShow: (id) => {
|
onShow: (id) => {
|
||||||
if (id === 'load') startContainerLoadPolling();
|
if (id === 'load') startContainerLoadPolling();
|
||||||
else stopContainerLoadPolling();
|
else stopContainerLoadPolling();
|
||||||
|
|
|
||||||
|
|
@ -51,12 +51,20 @@
|
||||||
<span class="home-tile-desc">live all-agents message firehose</span>
|
<span class="home-tile-desc">live all-agents message firehose</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<a class="home-tile" href="/builds.html">
|
||||||
|
<span class="home-tile-head">
|
||||||
|
<span class="home-tile-icon" aria-hidden="true">🔨</span>
|
||||||
|
<span class="home-tile-label">Builds</span>
|
||||||
|
</span>
|
||||||
|
<span class="home-tile-desc">rebuild queue · meta inputs · build logs</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
<a class="home-tile" href="/logs.html">
|
<a class="home-tile" href="/logs.html">
|
||||||
<span class="home-tile-head">
|
<span class="home-tile-head">
|
||||||
<span class="home-tile-icon" aria-hidden="true">📜</span>
|
<span class="home-tile-icon" aria-hidden="true">📜</span>
|
||||||
<span class="home-tile-label">Logs</span>
|
<span class="home-tile-label">Logs</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="home-tile-desc">build · agent · system logs · privileged-action audit</span>
|
<span class="home-tile-desc">agent · system logs · privileged-action audit</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a class="home-tile" href="/stats.html">
|
<a class="home-tile" href="/stats.html">
|
||||||
|
|
@ -80,7 +88,7 @@
|
||||||
<span class="home-tile-icon" aria-hidden="true">⬡</span>
|
<span class="home-tile-icon" aria-hidden="true">⬡</span>
|
||||||
<span class="home-tile-label">Core</span>
|
<span class="home-tile-label">Core</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="home-tile-desc">rebuild queue · meta inputs · kept state · container load</span>
|
<span class="home-tile-desc">kept state · container load</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a class="home-tile" href="/matrix-accounts.html">
|
<a class="home-tile" href="/matrix-accounts.html">
|
||||||
|
|
|
||||||
|
|
@ -14,14 +14,12 @@
|
||||||
|
|
||||||
<!-- Minimal chrome: back link + sub-tab strip.
|
<!-- Minimal chrome: back link + sub-tab strip.
|
||||||
Same pattern as flow.html — no full dashboard tabbar. Back link
|
Same pattern as flow.html — no full dashboard tabbar. Back link
|
||||||
points to the H0M3 hub (served at /), not the dashboard. -->
|
points to the H0M3 hub (served at /), not the dashboard.
|
||||||
|
Three sub-tabs: AGENT, SYSTEM, AUDIT. Build log history has moved
|
||||||
|
to /builds.html (the build lifecycle hub). -->
|
||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
<a class="page-back" href="/">← home</a>
|
<a class="page-back" href="/">← home</a>
|
||||||
<nav class="hive-tabbar logs-tabbar" id="logs-tabbar" role="tablist">
|
<nav class="hive-tabbar logs-tabbar" id="logs-tabbar" role="tablist">
|
||||||
<a class="hive-tab" id="logs-tab-build" href="#build" role="tab"
|
|
||||||
aria-controls="logs-pane-build" data-tab="build">
|
|
||||||
<span class="logs-tab-label">BUILD</span>
|
|
||||||
</a>
|
|
||||||
<a class="hive-tab" id="logs-tab-agent" href="#agent" role="tab"
|
<a class="hive-tab" id="logs-tab-agent" href="#agent" role="tab"
|
||||||
aria-controls="logs-pane-agent" data-tab="agent">
|
aria-controls="logs-pane-agent" data-tab="agent">
|
||||||
<span class="logs-tab-label">AGENT</span>
|
<span class="logs-tab-label">AGENT</span>
|
||||||
|
|
@ -39,18 +37,6 @@
|
||||||
|
|
||||||
<main class="logs-main">
|
<main class="logs-main">
|
||||||
|
|
||||||
<!-- BUILD: all-agents build log history. Lists recent nix build /
|
|
||||||
nixos-container invocations across all agents, with
|
|
||||||
click-to-expand full stdout+stderr. Backed by
|
|
||||||
GET /api/build-logs?limit=30. -->
|
|
||||||
<section class="logs-pane" id="logs-pane-build" data-tab-pane="build"
|
|
||||||
role="tabpanel" aria-labelledby="logs-tab-build">
|
|
||||||
<div class="logs-toolbar">
|
|
||||||
<button type="button" class="btn btn-restart" id="build-refresh">↻ refresh</button>
|
|
||||||
</div>
|
|
||||||
<div id="build-list"><p class="meta">loading…</p></div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- AGENT: journald viewer for a specific agent container.
|
<!-- AGENT: journald viewer for a specific agent container.
|
||||||
Agent selector + unit filter + line count. Backed by
|
Agent selector + unit filter + line count. Backed by
|
||||||
GET /api/journal/{agent}?unit=<unit>&lines=N. -->
|
GET /api/journal/{agent}?unit=<unit>&lines=N. -->
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,11 @@
|
||||||
// /logs.html entry point: log viewer with four sub-tabs (BUILD, AGENT,
|
// /logs.html entry point: log viewer with three sub-tabs (AGENT, SYSTEM,
|
||||||
// SYSTEM, AUDIT).
|
// AUDIT). Build log history has moved to /builds.html.
|
||||||
// BUILD — all-agents build log history, backed by GET /api/build-logs
|
|
||||||
// AGENT — per-container journald viewer, backed by GET /api/journal/{name}
|
// AGENT — per-container journald viewer, backed by GET /api/journal/{name}
|
||||||
// SYSTEM — host service logs, backed by GET /api/journal-host
|
// SYSTEM — host service logs, backed by GET /api/journal-host
|
||||||
// AUDIT — agent-initiated privileged-action trail, GET /api/audit-log;
|
// AUDIT — agent-initiated privileged-action trail, GET /api/audit-log;
|
||||||
// live-appends via the `audit_entry_added` /dashboard/stream event
|
// live-appends via the `audit_entry_added` /dashboard/stream event
|
||||||
//
|
//
|
||||||
// Tab routing via URL hash (#build, #agent, #system, #audit). Default: #build.
|
// Tab routing via URL hash (#agent, #system, #audit). Default: #agent.
|
||||||
//
|
|
||||||
// Live improvements:
|
|
||||||
// - Live build output auto-scrolls to the bottom (sticky-bottom) unless
|
|
||||||
// the operator has manually scrolled up.
|
|
||||||
// - Running builds show an elapsed-time counter in the row header that
|
|
||||||
// ticks every second.
|
|
||||||
// - The build list auto-refreshes (debounced 2s) when rebuild_queue_changed
|
|
||||||
// fires from the dashboard SSE stream, so new log entries appear without
|
|
||||||
// a manual page refresh.
|
|
||||||
//
|
//
|
||||||
// URL params `?agent=name` and `?unit=svc` pre-select the agent + unit on
|
// URL params `?agent=name` and `?unit=svc` pre-select the agent + unit on
|
||||||
// the AGENT tab (deep-link from other pages, e.g. the per-agent ⋮ menu).
|
// the AGENT tab (deep-link from other pages, e.g. the per-agent ⋮ menu).
|
||||||
|
|
@ -23,7 +13,7 @@
|
||||||
// SYSTEM tabs so the operator knows how stale the output is.
|
// SYSTEM tabs so the operator knows how stale the output is.
|
||||||
|
|
||||||
import {
|
import {
|
||||||
$, el, fmtAgeSecs, openStream, openBuildLogStream, initServerWarnings,
|
$, el, fmtAgeSecs, openStream, initServerWarnings,
|
||||||
} from './common.js';
|
} from './common.js';
|
||||||
import { createTabStrip } from '@hive/shared/tabs.js';
|
import { createTabStrip } from '@hive/shared/tabs.js';
|
||||||
|
|
||||||
|
|
@ -40,193 +30,6 @@ import { createTabStrip } from '@hive/shared/tabs.js';
|
||||||
|
|
||||||
// ─── helpers ──────────────────────────────────────────────────────────
|
// ─── helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// started_at / finished_at are unix seconds (i64), not milliseconds.
|
|
||||||
function fmtTs(unixSecs) {
|
|
||||||
if (!unixSecs) return '';
|
|
||||||
const age = Math.floor(Date.now() / 1000) - unixSecs;
|
|
||||||
return fmtAgeSecs(Math.max(0, age)) + ' ago';
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtDuration(secs) {
|
|
||||||
if (secs < 60) return secs + 's';
|
|
||||||
const m = Math.floor(secs / 60), s = secs % 60;
|
|
||||||
return m + 'm ' + s + 's';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── BUILD tab ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const buildList = $('build-list');
|
|
||||||
const buildRefresh = $('build-refresh');
|
|
||||||
|
|
||||||
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) });
|
|
||||||
// status is null while running, 'ok'/'fail' when finished.
|
|
||||||
const live = !h.status;
|
|
||||||
const ok = h.status === 'ok';
|
|
||||||
const statusClass = live ? 'badge badge-running' : ok ? 'badge badge-ok' : 'badge 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' }, '');
|
|
||||||
|
|
||||||
// For live builds, start an elapsed-time ticker immediately in the
|
|
||||||
// row header so the operator can see how long the build has been
|
|
||||||
// running without opening the log detail.
|
|
||||||
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(); // show initial value right away
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
// already loaded?
|
|
||||||
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 badge badge-running' }, 'live');
|
|
||||||
detail.append(badge, pre);
|
|
||||||
|
|
||||||
// Stream via the shared helper (also used by the C0R3 rebuild-queue
|
|
||||||
// live-log panel); it owns the append / sticky-scroll /
|
|
||||||
// stderr-separator / reconnect-replay logic. We wire the badge +
|
|
||||||
// the row's elapsed-time ticker here.
|
|
||||||
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' ? 'badge badge-ok' : 'badge badge-fail';
|
|
||||||
badge.textContent = status;
|
|
||||||
streamEs = null;
|
|
||||||
detail.dataset.loaded = '1';
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
if (durTimer) { clearInterval(durTimer); durTimer = null; }
|
|
||||||
badge.textContent = 'stream error';
|
|
||||||
badge.className = 'badge 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: if the URL contains ?id=N, auto-expand that log entry
|
|
||||||
// so a `logs →` link from the rebuild-queue panel drops the operator
|
|
||||||
// straight into the live output without extra clicks.
|
|
||||||
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);
|
|
||||||
|
|
||||||
// Auto-refresh the build list when the dashboard SSE stream signals that
|
|
||||||
// the rebuild queue changed — this surfaces new log entries and status
|
|
||||||
// changes without needing a manual page refresh. Debounce 2s so a burst
|
|
||||||
// of rapid queue mutations only triggers one fetch.
|
|
||||||
{
|
|
||||||
let buildRefreshTimer = null;
|
|
||||||
const debouncedRefreshBuild = () => {
|
|
||||||
if (logTabs.active() !== 'build') return;
|
|
||||||
if (buildRefreshTimer) clearTimeout(buildRefreshTimer);
|
|
||||||
buildRefreshTimer = setTimeout(fetchBuild, 2000);
|
|
||||||
};
|
|
||||||
const es = openStream('/api/dashboard/stream');
|
|
||||||
if (es) {
|
|
||||||
es.onmessage = (e) => {
|
|
||||||
let ev;
|
|
||||||
try { ev = JSON.parse(e.data); } catch { return; }
|
|
||||||
if (ev.kind === 'rebuild_queue_changed') debouncedRefreshBuild();
|
|
||||||
else if (ev.kind === 'audit_entry_added') onAuditEntryAdded(ev);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── AGENT tab ────────────────────────────────────────────────────────
|
// ─── AGENT tab ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const agentSelect = $('agent-select');
|
const agentSelect = $('agent-select');
|
||||||
|
|
@ -488,16 +291,28 @@ import { createTabStrip } from '@hive/shared/tabs.js';
|
||||||
|
|
||||||
// Wire the shared tab strip now that fetchSystem + the element refs it
|
// Wire the shared tab strip now that fetchSystem + the element refs it
|
||||||
// needs are defined. Its initial show() paints the active pane and, if
|
// needs are defined. Its initial show() paints the active pane and, if
|
||||||
// the deep-linked tab is SYSTEM, kicks off the lazy fetch via onShow.
|
// the deep-linked tab is SYSTEM/AUDIT, kicks off the lazy fetch via onShow.
|
||||||
|
// Default: AGENT (build logs moved to /builds.html).
|
||||||
logTabs = createTabStrip(document.getElementById('logs-tabbar'), {
|
logTabs = createTabStrip(document.getElementById('logs-tabbar'), {
|
||||||
defaultId: 'build',
|
defaultId: 'agent',
|
||||||
onShow: (id) => {
|
onShow: (id) => {
|
||||||
if (id === 'system') fetchSystem();
|
if (id === 'system') fetchSystem();
|
||||||
else if (id === 'audit') fetchAudit();
|
else if (id === 'audit') fetchAudit();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
loadAgentList();
|
loadAgentList();
|
||||||
fetchBuild();
|
|
||||||
|
// Subscribe to the dashboard SSE stream for audit live-appends.
|
||||||
|
{
|
||||||
|
const es = openStream('/api/dashboard/stream');
|
||||||
|
if (es) {
|
||||||
|
es.onmessage = (e) => {
|
||||||
|
let ev;
|
||||||
|
try { ev = JSON.parse(e.data); } catch { return; }
|
||||||
|
if (ev.kind === 'audit_entry_added') onAuditEntryAdded(ev);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Tick the last-fetched timestamps every 30s so "fetched 1m ago" stays
|
// Tick the last-fetched timestamps every 30s so "fetched 1m ago" stays
|
||||||
// accurate without a manual refresh.
|
// accurate without a manual refresh.
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,18 @@
|
||||||
/* ─── SYST3M section panels — shared by the dashboard + /core.html ──
|
/* ─── SYST3M/BU1LDS section panels — shared by dashboard, /core.html,
|
||||||
The four SYST3M panels (rebuild queue, meta inputs, kept state,
|
and /builds.html ──────────────────────────────────────────────────
|
||||||
container load) render on /core.html (their renderers live in
|
The rebuild queue, meta inputs, kept state, and container-load panels
|
||||||
core.js, carved out of the dashboard in a prior refactor). These
|
render on /core.html; rebuild queue + meta inputs + live-log also
|
||||||
section-internal rules used to live in dashboard.css and were
|
render on /builds.html (the build lifecycle hub). These
|
||||||
pulled in by /core.html via a wholesale `@import "./dashboard.css"`,
|
section-internal rules used to live in dashboard.css and were pulled
|
||||||
which dragged the entire ~39kb dashboard stylesheet into the C0R3
|
in by /core.html via a wholesale `@import "./dashboard.css"`, which
|
||||||
bundle.
|
dragged the entire ~39kb dashboard stylesheet into the bundle.
|
||||||
|
|
||||||
They now live here and are `@import`ed by BOTH core.css and
|
They now live here and are `@import`ed by core.css, dashboard.css,
|
||||||
dashboard.css. /core.html gets only this small file instead of all
|
and builds.css. Each page gets only this small file instead of all
|
||||||
of dashboard.css; the dashboard keeps the rules (a couple are still
|
of dashboard.css; the dashboard keeps the rules (a couple are still
|
||||||
referenced from dashboard JS — `.rqe-source*` by the schedules view,
|
referenced from dashboard JS — `.rqe-source*` by the schedules view,
|
||||||
and `.hive-stats-table`, which already lives in common.css and is
|
and `.hive-stats-table`, which already lives in common.css and is
|
||||||
loaded by both pages). Behaviour-preserving: identical rules, same
|
loaded by all pages). Behaviour-preserving: identical rules, same
|
||||||
custom properties from colors.css / theme.css / common.css.
|
custom properties from colors.css / theme.css / common.css.
|
||||||
|
|
||||||
The selectors here are uniquely named to these panels and are not
|
The selectors here are uniquely named to these panels and are not
|
||||||
|
|
@ -196,6 +196,75 @@
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── running-rebuild live log (renderRebuildLiveLog in builds.js) ──────────
|
||||||
|
Streams the currently-running rebuild's build log inline under the queue.
|
||||||
|
Hidden (native `hidden` attr) when nothing is building. Used on
|
||||||
|
/builds.html; lives here (not in builds.css) so it can be shared if
|
||||||
|
the dashboard ever gains an inline live-log view. */
|
||||||
|
.rebuild-live-log {
|
||||||
|
margin-top: 0.8em;
|
||||||
|
border: 1px solid var(--purple-dim);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: color-mix(in srgb, var(--crust) 70%, transparent);
|
||||||
|
}
|
||||||
|
.rebuild-live-log-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4em;
|
||||||
|
padding: 0.4em 0.6em;
|
||||||
|
font-size: 0.85em;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.rebuild-live-log-toggle {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 1em;
|
||||||
|
padding: 0 0.2em;
|
||||||
|
}
|
||||||
|
.rebuild-live-log-toggle:hover { color: var(--fg); }
|
||||||
|
.rebuild-live-log-title { color: var(--muted); }
|
||||||
|
.rebuild-live-log-raw {
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--purple);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
.rebuild-live-log-raw:hover { text-decoration: underline; }
|
||||||
|
.rebuild-live-log-badge {
|
||||||
|
font-size: 0.75em;
|
||||||
|
font-weight: bold;
|
||||||
|
padding: 0.05em 0.5em;
|
||||||
|
border-radius: 999px;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
.rebuild-live-log-badge.rll-running {
|
||||||
|
color: var(--amber);
|
||||||
|
background: color-mix(in srgb, var(--amber) 18%, transparent);
|
||||||
|
}
|
||||||
|
.rebuild-live-log-badge.rll-ok {
|
||||||
|
color: var(--green);
|
||||||
|
background: color-mix(in srgb, var(--green) 18%, transparent);
|
||||||
|
}
|
||||||
|
.rebuild-live-log-badge.rll-fail {
|
||||||
|
color: var(--red);
|
||||||
|
background: color-mix(in srgb, var(--red) 18%, transparent);
|
||||||
|
}
|
||||||
|
.rebuild-live-log-output {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.5em 0.7em;
|
||||||
|
max-height: 22em;
|
||||||
|
overflow-y: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", "Source Code Pro", monospace;
|
||||||
|
font-size: 0.8em;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── SYST3M › C0NT41N3R L04D: live cgroup cpu/mem ─────────────────────────────
|
/* ─── SYST3M › C0NT41N3R L04D: live cgroup cpu/mem ─────────────────────────────
|
||||||
Reuses the shared `.hive-stats-table` styling (in common.css); only
|
Reuses the shared `.hive-stats-table` styling (in common.css); only
|
||||||
the inline meter bar is new. */
|
the inline meter bar is new. */
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue