Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1510071e42 | ||
|
|
be8d97ded7 |
7 changed files with 703 additions and 650 deletions
|
|
@ -6,6 +6,9 @@
|
|||
// at GET /dashboard.html
|
||||
// dist/flow.html served at GET /flow.html
|
||||
// dist/logs.html served at GET /logs.html
|
||||
// dist/core.html served at GET /core.html (C0R3: rebuild
|
||||
// queue / meta inputs / kept state /
|
||||
// container load — the old SYST3M tab)
|
||||
// dist/static/home.js index.html (H0M3) entry — menu tiles +
|
||||
// matrix-tile gating + identity line
|
||||
// dist/static/tabs.js dashboard.html entry — tab renderers +
|
||||
|
|
@ -52,7 +55,7 @@ mkdirSync(staticDir(''), { recursive: true });
|
|||
// follow-up once asset sizes warrant it). esbuild writes each entry
|
||||
// to `static/<name>.js` based on the entryPoint basename.
|
||||
await build({
|
||||
entryPoints: [src('tabs.js'), src('flow.js'), src('logs.js'), src('home.js'), src('settings.js'), src('stats.js')],
|
||||
entryPoints: [src('tabs.js'), src('flow.js'), src('logs.js'), src('home.js'), src('settings.js'), src('stats.js'), src('core.js')],
|
||||
outdir: staticDir(''),
|
||||
bundle: true,
|
||||
format: 'esm',
|
||||
|
|
@ -91,7 +94,7 @@ await build({
|
|||
// so a swap replaces only it) + theme.css (the semantic derivation
|
||||
// layer) + common.css (shared typography, badges, buttons, inbox, side
|
||||
// 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']) {
|
||||
for (const entry of ['colors.css', 'theme.css', 'common.css', 'dashboard.css', 'flow.css', 'logs.css', 'home.css', 'settings.css', 'stats.css', 'core.css']) {
|
||||
await build({
|
||||
entryPoints: [src(entry)],
|
||||
outfile: staticDir(entry),
|
||||
|
|
@ -101,7 +104,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']) {
|
||||
for (const html of ['index.html', 'dashboard.html', 'flow.html', 'logs.html', 'settings.html', 'stats.html', 'core.html']) {
|
||||
copyFileSync(src(html), dist(html));
|
||||
}
|
||||
|
||||
|
|
|
|||
36
frontend/packages/dashboard/src/core.css
Normal file
36
frontend/packages/dashboard/src/core.css
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
/* ─── /core.html — C0R3 page ───────────────────────────────────────
|
||||
Standalone page carved out of the dashboard's old SYST3M tab. Same
|
||||
minimal-chrome pattern as /logs.html (a `← home` back-link + a
|
||||
createTabStrip sub-tab strip; base tab + page-header styling comes
|
||||
from @hive/shared via common.css, linked by core.html).
|
||||
|
||||
The four section renderers (rebuild queue, meta inputs, kept state,
|
||||
container load) are ported verbatim from the dashboard, so they emit
|
||||
the exact same class names. Those section-internal rules live in
|
||||
dashboard.css; rather than duplicate the (scattered) blocks here, we
|
||||
@import dashboard.css wholesale — transitional. dashboard.css does
|
||||
NOT @import common.css (it relies on link order), so this doesn't
|
||||
double-load anything; the dashboard-only chrome rules it carries
|
||||
(`body.dashboard-shell`, `.dashboard-chrome`, `.tabbar`) simply match
|
||||
nothing on /core.html. The follow-up that removes the SYST3M tab can
|
||||
split the shared section rules into their own stylesheet. */
|
||||
@import "./dashboard.css";
|
||||
|
||||
body.core-shell {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Fill the header row next to the ← home back-link, same as the logs
|
||||
sub-tab strip. */
|
||||
.core-tabbar {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.core-main {
|
||||
padding: 1.2em 1.5em 2em;
|
||||
}
|
||||
|
||||
/* createTabStrip toggles the native `hidden` attribute on inactive
|
||||
panes; ensure it wins over any inherited display. */
|
||||
.core-pane[hidden] { display: none; }
|
||||
92
frontend/packages/dashboard/src/core.html
Normal file
92
frontend/packages/dashboard/src/core.html
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>hyperhive // C0R3</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/core.css">
|
||||
</head>
|
||||
<body class="core-shell">
|
||||
|
||||
<!-- C0R3: the host/coordinator surface. Standalone page (served at
|
||||
/core.html) carved out of the dashboard's old SYST3M tab so the
|
||||
dashboard tab strip stays lean. Same minimal chrome as
|
||||
/logs.html — a `← home` back-link to the H0M3 hub + a
|
||||
createTabStrip sub-tab nav. Default sub-tab is the rebuild queue
|
||||
(the thing the operator actually watches). The section <div> ids
|
||||
(rebuild-queue-section, meta-inputs-section, tombstones-section,
|
||||
container-load-section) match what core.js's renderers target,
|
||||
which are the same renderers the dashboard SYST3M tab used. -->
|
||||
<header class="page-header">
|
||||
<a class="page-back" href="/">← home</a>
|
||||
<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"
|
||||
aria-controls="core-pane-kept" data-tab="kept">
|
||||
<span class="core-tab-label">K3PT ST4T3</span>
|
||||
</a>
|
||||
<a class="hive-tab" id="core-tab-load" href="#load" role="tab"
|
||||
aria-controls="core-pane-load" data-tab="load">
|
||||
<span class="core-tab-label">C0NT41N3R L04D</span>
|
||||
</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<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 /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>
|
||||
</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. -->
|
||||
<section class="core-pane" id="core-pane-kept" data-tab-pane="kept"
|
||||
role="tabpanel" aria-labelledby="core-tab-kept">
|
||||
<p class="meta">kept state from previously tombstoned agents — recreating an agent with the same name reuses it.</p>
|
||||
<div id="tombstones-section">
|
||||
<p class="meta">loading…</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- C0NT41N3R L04D: live cpu + memory per agent container, from
|
||||
cgroup v2 on the host. Polled only while this sub-tab is active. -->
|
||||
<section class="core-pane" id="core-pane-load" data-tab-pane="load"
|
||||
role="tabpanel" aria-labelledby="core-tab-load">
|
||||
<p class="meta">live cpu + memory per agent container, from cgroup v2 on the host. cpu is % of total host capacity (all cores), sampled over ~200ms each refresh; polled every 5s while this tab is open. network is omitted on purpose — agents share the host netns, so there is no per-container counter (see <code>docs/network.md</code>).</p>
|
||||
<div id="container-load-section">
|
||||
<p class="meta">loading…</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<script type="module" src="/static/core.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
541
frontend/packages/dashboard/src/core.js
Normal file
541
frontend/packages/dashboard/src/core.js
Normal file
|
|
@ -0,0 +1,541 @@
|
|||
// 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
|
||||
// dashboard tab strip stays lean. Four sub-tabs via the shared
|
||||
// createTabStrip (default = Rebuild Queue): rebuild queue, meta inputs,
|
||||
// kept state (tombstones), container load.
|
||||
//
|
||||
// This page is its own esbuild bundle with its own runtime: it cold-loads
|
||||
// /api/state and subscribes to /dashboard/stream (the same broker event
|
||||
// channel the dashboard uses), maintaining its own copies of the derived
|
||||
// state. The four section renderers below are ported from tabs.js (the
|
||||
// dashboard SYST3M tab); the dashboard keeps its own copies for now —
|
||||
// de-duplication + removing the SYST3M tab is a deliberate follow-up.
|
||||
|
||||
import { $, el, form, openStream, initServerWarnings } from './common.js';
|
||||
import { fmtAgo, fmtElapsed, truncate } from './util.js';
|
||||
import { createTabStrip } from '@hive/shared/tabs.js';
|
||||
|
||||
// ─── derived state (own copies; this bundle has its own runtime) ──────────
|
||||
let metaInputsState = [];
|
||||
let metaUpdateRunning = false;
|
||||
let tombstonesState = [];
|
||||
let rebuildQueueState = [];
|
||||
|
||||
function syncFromSnapshot(s) {
|
||||
metaInputsState = (s.meta_inputs || []).slice();
|
||||
metaUpdateRunning = !!s.meta_update_running;
|
||||
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: '/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 root = $('rebuild-queue-section');
|
||||
if (!root) return;
|
||||
const queue = s.rebuild_queue || [];
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// ─── kept state (tombstones) ──────────────────────────────────────────────
|
||||
function renderTombstones(s) {
|
||||
const root = $('tombstones-section');
|
||||
if (!root) return;
|
||||
root.replaceChildren();
|
||||
if (!s.tombstones || !s.tombstones.length) {
|
||||
root.append(el('p', { class: 'empty' }, 'no kept state — clean'));
|
||||
return;
|
||||
}
|
||||
const fmtBytes = (n) => {
|
||||
if (n < 1024) return n + ' B';
|
||||
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
|
||||
if (n < 1024 * 1024 * 1024) return (n / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
return (n / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
|
||||
};
|
||||
const fmtAgeDays = (ts) => {
|
||||
if (!ts) return '?';
|
||||
const d = Math.floor((Date.now() / 1000 - ts) / 86400);
|
||||
if (d <= 0) return 'today';
|
||||
if (d === 1) return '1 day ago';
|
||||
return d + ' days ago';
|
||||
};
|
||||
const ul = el('ul', { class: 'containers' });
|
||||
for (const t of s.tombstones) {
|
||||
const li = el('li', { class: 'container-row tombstone' });
|
||||
const head = el('div', { class: 'head' });
|
||||
head.append(
|
||||
el('span', { class: 'name' }, t.name),
|
||||
el('span', { class: 'badge badge-muted' }, 'destroyed'),
|
||||
);
|
||||
if (t.has_creds) head.append(el('span', { class: 'badge badge-muted' }, 'creds kept'));
|
||||
head.append(el('span', { class: 'meta' },
|
||||
`${fmtBytes(t.state_bytes)} · ${fmtAgeDays(t.last_seen)}`));
|
||||
li.append(head);
|
||||
|
||||
const actions = el('div', { class: 'actions' });
|
||||
const respawn = el('form', {
|
||||
method: 'POST', action: '/request-spawn',
|
||||
class: 'inline', 'data-async': '',
|
||||
'data-confirm': 'queue spawn approval for ' + t.name + '? state will be reused.',
|
||||
});
|
||||
respawn.append(
|
||||
el('input', { type: 'hidden', name: 'name', value: t.name }),
|
||||
el('button', { type: 'submit', class: 'btn btn-start' }, '⊕ R3V1V3'),
|
||||
);
|
||||
actions.append(respawn);
|
||||
actions.append(form(
|
||||
'/purge-tombstone/' + t.name, 'btn-destroy', 'PURG3',
|
||||
'PURGE ' + t.name + '? config history, claude creds, '
|
||||
+ 'and notes are all WIPED. no undo.',
|
||||
{}, { noRefresh: true },
|
||||
));
|
||||
li.append(actions);
|
||||
ul.append(li);
|
||||
}
|
||||
root.append(ul);
|
||||
}
|
||||
|
||||
// ─── container load (live cgroup poll while the LOAD sub-tab is open) ──────
|
||||
let containerLoadTimer = null;
|
||||
|
||||
function cloadFmtBytes(n) {
|
||||
if (!Number.isFinite(n) || n <= 0) return '0';
|
||||
const u = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
|
||||
let i = 0; let v = n;
|
||||
while (v >= 1024 && i < u.length - 1) { v /= 1024; i += 1; }
|
||||
return v.toFixed(v < 10 && i > 0 ? 1 : 0) + ' ' + u[i];
|
||||
}
|
||||
function cloadMeter(pct) {
|
||||
const p = Math.max(0, Math.min(100, pct));
|
||||
const cls = p >= 90 ? 'hot' : (p >= 70 ? 'warn' : '');
|
||||
const m = document.createElement('span');
|
||||
m.className = 'cload-meter';
|
||||
m.title = p.toFixed(0) + '%';
|
||||
const fill = document.createElement('span');
|
||||
fill.className = 'fill' + (cls ? ' ' + cls : '');
|
||||
fill.style.width = p + '%';
|
||||
m.append(fill);
|
||||
return m;
|
||||
}
|
||||
|
||||
function renderContainerLoad(rows) {
|
||||
const root = $('container-load-section');
|
||||
if (!root) return;
|
||||
if (!Array.isArray(rows) || rows.length === 0) {
|
||||
root.replaceChildren();
|
||||
const p = document.createElement('p'); p.className = 'meta';
|
||||
p.textContent = 'no running agent containers'; root.append(p);
|
||||
return;
|
||||
}
|
||||
const table = document.createElement('table');
|
||||
table.className = 'hive-stats-table';
|
||||
table.innerHTML = '<thead><tr><th>agent</th><th>cpu</th><th>memory</th>'
|
||||
+ '<th>peak</th><th>limit</th></tr></thead>';
|
||||
const tb = document.createElement('tbody');
|
||||
for (const r of rows) {
|
||||
const tr = document.createElement('tr');
|
||||
const name = document.createElement('td'); name.textContent = r.name; tr.append(name);
|
||||
const cpu = document.createElement('td'); cpu.className = 'num';
|
||||
cpu.append((Number(r.cpu_pct) || 0).toFixed(1) + '%', cloadMeter(Number(r.cpu_pct) || 0));
|
||||
tr.append(cpu);
|
||||
const mem = document.createElement('td'); mem.className = 'num';
|
||||
const memCur = Number(r.mem_current_bytes) || 0;
|
||||
if (r.mem_max_bytes) {
|
||||
mem.append(cloadFmtBytes(memCur), cloadMeter(100 * memCur / r.mem_max_bytes));
|
||||
} else {
|
||||
mem.textContent = cloadFmtBytes(memCur);
|
||||
}
|
||||
tr.append(mem);
|
||||
const peak = document.createElement('td'); peak.className = 'num';
|
||||
peak.textContent = r.mem_peak_bytes ? cloadFmtBytes(Number(r.mem_peak_bytes)) : '—';
|
||||
tr.append(peak);
|
||||
const lim = document.createElement('td'); lim.className = 'num';
|
||||
lim.textContent = r.mem_max_bytes ? cloadFmtBytes(Number(r.mem_max_bytes)) : '∞';
|
||||
tr.append(lim);
|
||||
tb.append(tr);
|
||||
}
|
||||
table.append(tb);
|
||||
root.replaceChildren(table);
|
||||
}
|
||||
|
||||
async function refreshContainerLoad() {
|
||||
try {
|
||||
const resp = await fetch('/api/container-resources');
|
||||
if (!resp.ok) throw new Error('http ' + resp.status);
|
||||
renderContainerLoad(await resp.json());
|
||||
} catch (e) {
|
||||
const root = $('container-load-section');
|
||||
if (root) {
|
||||
root.replaceChildren();
|
||||
const p = document.createElement('p'); p.className = 'meta';
|
||||
p.textContent = 'container load fetch failed: ' + e; root.append(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
function startContainerLoadPolling() {
|
||||
refreshContainerLoad();
|
||||
if (containerLoadTimer) return;
|
||||
containerLoadTimer = setInterval(refreshContainerLoad, 5000);
|
||||
}
|
||||
function stopContainerLoadPolling() {
|
||||
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) ──────────────────────────
|
||||
function renderAll() {
|
||||
renderRebuildQueue({ rebuild_queue: rebuildQueueState });
|
||||
renderMetaInputs({ meta_inputs: metaInputsState });
|
||||
renderTombstones({ tombstones: tombstonesState });
|
||||
updateRebuildCount();
|
||||
}
|
||||
|
||||
// ─── elapsed-time tickers (same cadence as the dashboard) ─────────────────
|
||||
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 = {
|
||||
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) {
|
||||
tombstonesState = (ev.tombstones || []).slice();
|
||||
renderTombstones({ tombstones: tombstonesState });
|
||||
},
|
||||
};
|
||||
|
||||
// ─── boot ─────────────────────────────────────────────────────────────────
|
||||
async function init() {
|
||||
initServerWarnings();
|
||||
|
||||
// Hash-routed sub-tab strip; default Rebuild Queue. Container-load
|
||||
// polling runs only while the LOAD sub-tab is open (cpu is a short
|
||||
// two-sample read each refresh on the server).
|
||||
createTabStrip(document.getElementById('core-tabbar'), {
|
||||
defaultId: 'rebuild',
|
||||
onShow: (id) => {
|
||||
if (id === 'load') startContainerLoadPolling();
|
||||
else stopContainerLoadPolling();
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/state');
|
||||
if (resp.ok) {
|
||||
syncFromSnapshot(await resp.json());
|
||||
}
|
||||
} catch {
|
||||
// best-effort: the page still renders its empty states
|
||||
}
|
||||
renderAll();
|
||||
|
||||
const es = openStream('/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();
|
||||
|
|
@ -35,12 +35,11 @@
|
|||
<span class="tab-label">◆ Y3R C4LL ◆</span>
|
||||
<span class="tab-count tab-count-attn" id="tab-count-call" hidden></span>
|
||||
</a>
|
||||
<a class="tab" id="tab-system" href="#system" role="tab"
|
||||
aria-controls="tab-pane-system"
|
||||
data-tab="system">
|
||||
<span class="tab-label">◆ SYST3M ◆</span>
|
||||
<span class="tab-count" id="tab-count-system" hidden></span>
|
||||
</a>
|
||||
<!-- SYST3M moved to its own standalone page (/core.html, "C0R3"),
|
||||
reached from the H0M3 hub — keeps the dashboard tab strip lean.
|
||||
The rebuild-queue STATE still lives in tabs.js (it drives the
|
||||
"building…" badges on SW4RM agent cards); only the SYST3M tab +
|
||||
its section renderers moved out. -->
|
||||
<!-- P3RM1SS10NS: per-agent capability grants and tool-group
|
||||
assignments. Both tables are fetched on tab activation. -->
|
||||
<a class="tab" id="tab-permissions" href="#permissions" role="tab"
|
||||
|
|
@ -141,43 +140,9 @@
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<!-- SYST3M: passive / rare-interaction state. Meta inputs (lock
|
||||
bumps), rebuild queue (watch only), kept state from previous
|
||||
tombstoned agents. Per-section content auto-compresses to a
|
||||
one-line summary when empty (JS toggle). -->
|
||||
<section class="tab-pane" id="tab-pane-system" data-tab-pane="system" hidden
|
||||
role="tabpanel" aria-labelledby="tab-system">
|
||||
<h2>◆ M3T4 1NPUTS ◆</h2>
|
||||
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
||||
<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>
|
||||
|
||||
<h2>◆ R3BU1LD QU3U3 ◆</h2>
|
||||
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
||||
<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>
|
||||
|
||||
<h2>◆ K3PT ST4T3 ◆</h2>
|
||||
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
||||
<div id="tombstones-section">
|
||||
<p class="meta">loading…</p>
|
||||
</div>
|
||||
|
||||
<!-- C0NT41N3R L04D: live cpu + memory per agent container, read
|
||||
from cgroup v2 on the host. Polled only while this tab is
|
||||
active (cpu needs a short two-sample read each refresh). -->
|
||||
<h2>◆ C0NT41N3R L04D ◆</h2>
|
||||
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
||||
<p class="meta">live cpu + memory per agent container, from cgroup v2 on the host. cpu is % of total host capacity (all cores), sampled over ~200ms each refresh; polled every 5s while this tab is open. network is omitted on purpose — agents share the host netns, so there is no per-container counter (see <code>docs/network.md</code>).</p>
|
||||
<div id="container-load-section">
|
||||
<p class="meta">loading…</p>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
<!-- SYST3M pane moved to /core.html (the standalone "C0R3" page):
|
||||
meta inputs, rebuild queue, kept state, container load. Reached
|
||||
from the H0M3 hub. -->
|
||||
|
||||
<!-- P3RM1SS10NS: per-agent capability grants + tool-group
|
||||
assignments. Both tables are column-driven from the backend
|
||||
|
|
|
|||
|
|
@ -68,6 +68,14 @@
|
|||
<span class="home-tile-desc">operator-local prefs · browser notifications</span>
|
||||
</a>
|
||||
|
||||
<a class="home-tile" href="/core.html">
|
||||
<span class="home-tile-head">
|
||||
<span class="home-tile-icon" aria-hidden="true">⬡</span>
|
||||
<span class="home-tile-label">Core</span>
|
||||
</span>
|
||||
<span class="home-tile-desc">rebuild queue · meta inputs · kept state · container load</span>
|
||||
</a>
|
||||
|
||||
<!-- Forge tile: hidden until home.js confirms the hive-forge
|
||||
container is up (state.forge_present); home.js also fills the
|
||||
href from state.forge_public_url (or the :3000 fallback), so
|
||||
|
|
|
|||
|
|
@ -170,54 +170,19 @@ window.marked = marked;
|
|||
if (containersState.delete(ev.name)) renderContainersFromState();
|
||||
}
|
||||
|
||||
// Derived tombstones + meta_inputs. Both are emitted as full
|
||||
// snapshots (not diffs) — the lists are tiny and recomputing
|
||||
// avoids ordering races between a same-tick destroy + purge.
|
||||
let tombstonesState = [];
|
||||
let metaInputsState = [];
|
||||
// True while a dashboard-triggered meta-update (flake lock bump +
|
||||
// agent rebuild ripple) runs in the background. Cold-loaded from
|
||||
// `s.meta_update_running`, then flipped live by the
|
||||
// `meta_update_running` event. Drives the META INPUTS panel's
|
||||
// disabled "updating…" state.
|
||||
let metaUpdateRunning = false;
|
||||
function syncTombstonesFromSnapshot(s) {
|
||||
tombstonesState = (s.tombstones || []).slice();
|
||||
}
|
||||
function syncMetaInputsFromSnapshot(s) {
|
||||
metaInputsState = (s.meta_inputs || []).slice();
|
||||
metaUpdateRunning = !!s.meta_update_running;
|
||||
}
|
||||
function applyTombstonesChanged(ev) {
|
||||
tombstonesState = (ev.tombstones || []).slice();
|
||||
renderTombstonesFromState();
|
||||
}
|
||||
function applyMetaInputsChanged(ev) {
|
||||
metaInputsState = (ev.inputs || []).slice();
|
||||
renderMetaInputsFromState();
|
||||
}
|
||||
function applyMetaUpdateRunning(ev) {
|
||||
metaUpdateRunning = !!ev.running;
|
||||
renderMetaInputsFromState();
|
||||
}
|
||||
function renderTombstonesFromState() {
|
||||
renderTombstones({ tombstones: tombstonesState });
|
||||
}
|
||||
function renderMetaInputsFromState() {
|
||||
renderMetaInputs({ meta_inputs: metaInputsState });
|
||||
}
|
||||
|
||||
// Derived rebuild queue state — cold-loaded from
|
||||
// `/api/state.rebuild_queue`, then mutated live by the
|
||||
// `rebuild_queue_changed` snapshot event. Same shape as the meta-
|
||||
// inputs panel (full snapshot per change, no diff).
|
||||
// `rebuild_queue_changed` snapshot event. The SYST3M tab that used to
|
||||
// render this list moved to the standalone /core.html page; the
|
||||
// dashboard keeps the STATE because it drives the "building…" /
|
||||
// "meta-updating…" badges on the SW4RM agent cards (see
|
||||
// inFlightOpsByAgent + docs/web-ui.md::Container row).
|
||||
let rebuildQueueState = [];
|
||||
function syncRebuildQueueFromSnapshot(s) {
|
||||
rebuildQueueState = (s.rebuild_queue || []).slice();
|
||||
}
|
||||
function applyRebuildQueueChanged(ev) {
|
||||
rebuildQueueState = (ev.queue || []).slice();
|
||||
renderRebuildQueueFromState();
|
||||
// Re-render the SW4RM tab so newly-queued / newly-running
|
||||
// rebuild-queue ops light up the right card with a building…
|
||||
// badge, and finished ops fall back to the regular state badges.
|
||||
|
|
@ -228,8 +193,7 @@ window.marked = marked;
|
|||
// (`running` beats `queued`). Used by the container row renderer
|
||||
// to surface "building..." / "meta-updating..." badges on the
|
||||
// SW4RM tab when an op is still in the rebuild queue but no
|
||||
// operator-initiated transient is set — see docs/web-ui.md::Container
|
||||
// row for the badge cross-reference between SW4RM and SYST3M.
|
||||
// operator-initiated transient is set.
|
||||
function inFlightOpsByAgent() {
|
||||
const out = new Map();
|
||||
for (const e of rebuildQueueState) {
|
||||
|
|
@ -248,9 +212,6 @@ window.marked = marked;
|
|||
}
|
||||
return out;
|
||||
}
|
||||
function renderRebuildQueueFromState() {
|
||||
renderRebuildQueue({ rebuild_queue: rebuildQueueState });
|
||||
}
|
||||
|
||||
// Derived transient state — cold-loaded from /api/state.transients,
|
||||
// then mutated live by `transient_set` / `transient_cleared`. Keyed
|
||||
|
|
@ -832,7 +793,7 @@ window.marked = marked;
|
|||
// Belt-and-suspenders for any future page that adds tabs.js
|
||||
// without a #containers-section — matches the
|
||||
// no-op-when-target-absent convention the other renderers
|
||||
// (renderTombstones, etc.) follow.
|
||||
// (renderQuestions, renderApprovals, etc.) follow.
|
||||
if (!root) return;
|
||||
|
||||
// Containers come from the derived map (event-driven) rather than
|
||||
|
|
@ -1274,71 +1235,6 @@ window.marked = marked;
|
|||
parent.append(btn);
|
||||
}
|
||||
|
||||
function renderTombstones(s) {
|
||||
const root = $('tombstones-section');
|
||||
// #tombstones-section only lives on /dashboard.html (SYST3M tab);
|
||||
// no-op on /flow.html and any other page that loads the shared
|
||||
// bundle without the dashboard's tab panes.
|
||||
if (!root) return;
|
||||
root.replaceChildren();
|
||||
if (!s.tombstones || !s.tombstones.length) {
|
||||
root.append(el('p', { class: 'empty' }, 'no kept state — clean'));
|
||||
return;
|
||||
}
|
||||
const fmtBytes = (n) => {
|
||||
if (n < 1024) return n + ' B';
|
||||
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
|
||||
if (n < 1024 * 1024 * 1024) return (n / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
return (n / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
|
||||
};
|
||||
const fmtAge = (ts) => {
|
||||
if (!ts) return '?';
|
||||
const d = Math.floor((Date.now() / 1000 - ts) / 86400);
|
||||
if (d <= 0) return 'today';
|
||||
if (d === 1) return '1 day ago';
|
||||
return d + ' days ago';
|
||||
};
|
||||
const ul = el('ul', { class: 'containers' });
|
||||
for (const t of s.tombstones) {
|
||||
const li = el('li', { class: 'container-row tombstone' });
|
||||
const head = el('div', { class: 'head' });
|
||||
head.append(
|
||||
el('span', { class: 'name' }, t.name),
|
||||
el('span', { class: 'badge badge-muted' }, 'destroyed'),
|
||||
);
|
||||
if (t.has_creds) {
|
||||
head.append(el('span', { class: 'badge badge-muted' }, 'creds kept'));
|
||||
}
|
||||
head.append(el('span', { class: 'meta' },
|
||||
`${fmtBytes(t.state_bytes)} · ${fmtAge(t.last_seen)}`));
|
||||
li.append(head);
|
||||
|
||||
const actions = el('div', { class: 'actions' });
|
||||
// Reuse the existing spawn form pattern via /request-spawn — operator
|
||||
// can queue an approval that recreates the agent with the same name
|
||||
// and reuses the kept state.
|
||||
const respawn = el('form', {
|
||||
method: 'POST', action: '/request-spawn',
|
||||
class: 'inline', 'data-async': '',
|
||||
'data-confirm': 'queue spawn approval for ' + t.name + '? state will be reused.',
|
||||
});
|
||||
respawn.append(
|
||||
el('input', { type: 'hidden', name: 'name', value: t.name }),
|
||||
el('button', { type: 'submit', class: 'btn btn-start' }, '⊕ R3V1V3'),
|
||||
);
|
||||
actions.append(respawn);
|
||||
actions.append(form(
|
||||
'/purge-tombstone/' + t.name, 'btn-destroy', 'PURG3',
|
||||
'PURGE ' + t.name + '? config history, claude creds, '
|
||||
+ 'and notes are all WIPED. no undo.',
|
||||
{}, { noRefresh: true },
|
||||
));
|
||||
li.append(actions);
|
||||
ul.append(li);
|
||||
}
|
||||
root.append(ul);
|
||||
}
|
||||
|
||||
// Derived question state — cold-loaded from /api/state, then mutated
|
||||
// live by `question_added` / `question_resolved` dashboard events.
|
||||
const QUESTION_HISTORY_LIMIT = 20;
|
||||
|
|
@ -2098,382 +1994,6 @@ window.marked = marked;
|
|||
root.append(ul);
|
||||
}
|
||||
|
||||
function renderMetaInputs(s) {
|
||||
const root = $('meta-inputs-section');
|
||||
if (!root) return;
|
||||
// Snapshot which checkboxes the operator has ticked before wiping the
|
||||
// DOM. A MetaInputsChanged event (e.g. triggered by a concurrent
|
||||
// meta-update completing) would otherwise silently clear pending
|
||||
// selections mid-flight. We restore them after rebuilding the list.
|
||||
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 form = el('form', {
|
||||
method: 'POST',
|
||||
action: '/meta-update',
|
||||
class: 'meta-inputs-form',
|
||||
'data-async': '',
|
||||
// run_meta_update emits MetaInputsChanged once the lock
|
||||
// bump finishes; per-agent rebuilds fire their own
|
||||
// ContainerStateChanged. No /api/state refetch needed.
|
||||
'data-no-refresh': '',
|
||||
'data-confirm': 'update selected meta flake inputs + rebuild affected agents?',
|
||||
});
|
||||
// Bulk select — the full input tree gets long; ticking each box
|
||||
// one by one is tedious.
|
||||
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);
|
||||
form.append(bulk);
|
||||
const ul = el('ul', { class: 'meta-inputs' });
|
||||
for (const inp of inputs) {
|
||||
// `name` is a slash-path from the meta root. Indent depth = its
|
||||
// segment count; the row label shows just the leaf segment, the
|
||||
// full path stays as the checkbox value + the label title.
|
||||
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);
|
||||
}
|
||||
form.append(ul);
|
||||
// Hidden input the POST handler reads — populated at submit
|
||||
// time from the checkbox states. axum's Form extractor doesn't
|
||||
// natively decode repeated keys, so we join into one CSV.
|
||||
const hidden = el('input', { type: 'hidden', name: 'inputs', value: '' });
|
||||
form.append(hidden);
|
||||
const btn = el('button', {
|
||||
type: 'submit',
|
||||
class: 'btn btn-meta-update',
|
||||
disabled: '',
|
||||
}, metaUpdateRunning ? '⏳ UPD4T1NG…' : '◆ UPD4TE & R3BU1LD');
|
||||
form.append(btn);
|
||||
function refreshDisabled() {
|
||||
const any = form.querySelectorAll('input[data-meta-input]:checked').length > 0;
|
||||
// Stay disabled while an update is already in flight — no
|
||||
// stacking a second run on top of the rebuild ripple.
|
||||
if (any && !metaUpdateRunning) btn.removeAttribute('disabled');
|
||||
else btn.setAttribute('disabled', '');
|
||||
}
|
||||
form.addEventListener('change', refreshDisabled);
|
||||
function setAllChecked(val) {
|
||||
for (const b of form.querySelectorAll('input[data-meta-input]')) {
|
||||
b.checked = val;
|
||||
}
|
||||
refreshDisabled();
|
||||
}
|
||||
selAll.addEventListener('click', () => setAllChecked(true));
|
||||
selNone.addEventListener('click', () => setAllChecked(false));
|
||||
form.addEventListener('submit', () => {
|
||||
const selected = Array.from(form.querySelectorAll('input[data-meta-input]:checked'))
|
||||
.map((b) => b.dataset.metaInput);
|
||||
hidden.value = selected.join(',');
|
||||
});
|
||||
root.append(form);
|
||||
}
|
||||
|
||||
// ─── rebuild queue ──────────────────────────────────────────────────────
|
||||
// Keyed row cache for the rebuild-queue list. Maps entry.id → { el, fingerprint }.
|
||||
// Same pattern as containerRowCache: reuse <li> nodes whose state hasn't
|
||||
// changed rather than replacing the entire list on every snapshot event.
|
||||
// The elapsed-time ticker (data-rqe-elapsed + 1s setInterval below) already
|
||||
// updates running-entry timestamps in-place, so started_at doesn't need to
|
||||
// invalidate — including it in the fingerprint only matters for the initial
|
||||
// render of a newly-running entry.
|
||||
const rebuildQueueRowCache = new Map();
|
||||
|
||||
// Glyph + verb per QueueKind. Mirrors the labels used in
|
||||
// hive-c0re::rebuild_queue::QueueKind::as_str.
|
||||
const QUEUE_KIND_GLYPH = {
|
||||
rebuild: '↻',
|
||||
meta_update: '◆',
|
||||
spawn: '✨',
|
||||
destroy: '🗑',
|
||||
restart: '↺',
|
||||
startup_sweep: '⚡',
|
||||
perm_change: '🔑',
|
||||
};
|
||||
const QUEUE_STATE_GLYPH = {
|
||||
queued: '⏸',
|
||||
running: '▶',
|
||||
done: '✔',
|
||||
failed: '✖',
|
||||
cancelled: '⊘',
|
||||
};
|
||||
|
||||
// Fingerprint for a single rebuild-queue row. Everything visible in the
|
||||
// row except the ticking elapsed seconds (handled by data-rqe-elapsed
|
||||
// ticker, not by re-rendering).
|
||||
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 root = $('rebuild-queue-section');
|
||||
if (!root) return;
|
||||
const queue = s.rebuild_queue || [];
|
||||
|
||||
if (!queue.length) {
|
||||
// Queue drained — show placeholder and purge cache.
|
||||
rebuildQueueRowCache.clear();
|
||||
root.replaceChildren(el('p', { class: 'empty' }, 'queue is empty — nothing pending or in flight.'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Index by id for parent lookup.
|
||||
const byId = new Map(queue.map((e) => [e.id, e]));
|
||||
// Top-level entries first; children render nested under their parent.
|
||||
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);
|
||||
}
|
||||
}
|
||||
// Children whose parent isn't in the snapshot (history-evicted) still render flat.
|
||||
const orphans = queue.filter(
|
||||
(e) => e.parent_id != null && !byId.has(e.parent_id),
|
||||
);
|
||||
|
||||
// Build ordered list of <li>, reusing cached nodes for unchanged entries.
|
||||
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);
|
||||
}
|
||||
|
||||
// Drop cache entries for IDs no longer in the snapshot.
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// Get or create the <ul>; remove any "empty" placeholder if present.
|
||||
let ul = root.querySelector('ul.rebuild-queue');
|
||||
if (!ul) {
|
||||
ul = el('ul', { class: 'rebuild-queue' });
|
||||
root.replaceChildren(ul);
|
||||
}
|
||||
|
||||
// Reconcile DOM order without a wipe.
|
||||
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');
|
||||
// State glyph + kind + agent.
|
||||
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),
|
||||
);
|
||||
// Source chip (manual / meta_update / auto_update / crash_recover).
|
||||
li.append(' ', el('span', { class: 'rqe-source rqe-source-' + entry.source }, entry.source));
|
||||
// Timing: queued Xs ago when pending, elapsed when running,
|
||||
// finished Xs ago for terminal. Queued + terminal stamps use
|
||||
// data-rqe-enqueued / data-rqe-finished so the 30s ticker below
|
||||
// can keep them fresh — keyed rows persist across snapshots, so
|
||||
// without a ticker "queued 2m ago" would never advance.
|
||||
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)));
|
||||
}
|
||||
// Reason (truncated; full text on hover).
|
||||
if (entry.reason) {
|
||||
const r = entry.reason.split('\n')[0];
|
||||
li.append(' ', el('span', { class: 'rqe-reason', title: entry.reason }, '— ' + truncate(r, 60)));
|
||||
}
|
||||
// Current step: backend annotates the in-flight phase on
|
||||
// `running` entries — sub-line below the main row so the
|
||||
// operator can see "what's happening right now" inside a long
|
||||
// build. Terminal transitions clear `step` on the backend so this
|
||||
// doesn't render stale labels on Done / Failed rows.
|
||||
if (entry.step) {
|
||||
li.append(el('div', { class: 'rqe-step' }, '↳ ' + entry.step));
|
||||
}
|
||||
// Live-log link: when a build_log_id is present the update/create op
|
||||
// opened a build_logs row; the SSE stream endpoint lets the operator
|
||||
// follow output in real time without polling.
|
||||
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 →'),
|
||||
);
|
||||
}
|
||||
// Error block, when failed.
|
||||
if (entry.error) {
|
||||
li.append(el('pre', { class: 'rqe-error', title: entry.error }, truncate(entry.error, 200)));
|
||||
}
|
||||
// Cancel-X for queued entries. Backend
|
||||
// `POST /api/rebuild-queue/{id}/cancel` refuses Running /
|
||||
// terminal entries, so we surface it only on `queued` rows here
|
||||
// — the operator never sees a dead button. Uses the same
|
||||
// `data-async` form + `data-confirm` pattern as the reminder
|
||||
// cancel, so the global async-form handler takes care of POST +
|
||||
// spinner + error toast. A successful cancel flips `state` from
|
||||
// `queued` → `cancelled` via the live RebuildQueueChanged
|
||||
// snapshot and the row re-renders without this button.
|
||||
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;
|
||||
}
|
||||
|
||||
// Tick once per second to refresh "running Xs" badges in place
|
||||
// (mirrors the question-TTL ticker pattern above).
|
||||
// Tick rebuild-queue elapsed-time badges once per second.
|
||||
// `.rqe-when[data-rqe-elapsed]` is set on running queue entries by
|
||||
// `renderQueueEntry`; the ticker avoids a full re-render just for the
|
||||
// wall-clock update.
|
||||
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;
|
||||
const elapsed = Math.max(0, now - started);
|
||||
span.textContent = '· ' + fmtElapsed(elapsed);
|
||||
}
|
||||
for (const span of document.querySelectorAll('.build-logs-runtime[data-bl-elapsed]')) {
|
||||
const started = parseInt(span.dataset.blElapsed, 10);
|
||||
if (!started) continue;
|
||||
const elapsed = Math.max(0, now - started);
|
||||
span.textContent = fmtElapsed(elapsed);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// 30s ticker for queued-age and terminal-age labels. Keyed rows
|
||||
// persist across rebuild_queue_changed snapshots, so without this
|
||||
// "queued 1m ago" / "done 5m ago" labels would never advance.
|
||||
setInterval(() => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
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);
|
||||
|
||||
// ─── state polling ──────────────────────────────────────────────────────
|
||||
let pollTimer = null;
|
||||
// Sections whose innerHTML gets blown away on each refresh. If the
|
||||
|
|
@ -2481,12 +2001,9 @@ window.marked = marked;
|
|||
// tick (or a manual action) will pick it up after they blur.
|
||||
const MANAGED_SECTION_IDS = [
|
||||
'containers-section',
|
||||
'tombstones-section',
|
||||
'questions-section',
|
||||
'inbox-section',
|
||||
'approvals-section',
|
||||
'meta-inputs-section',
|
||||
'rebuild-queue-section',
|
||||
'reminders-section',
|
||||
'schedules-section',
|
||||
'capabilities-section',
|
||||
|
|
@ -2582,11 +2099,10 @@ window.marked = marked;
|
|||
// `transientsState` + `containersState`, not from `s.*`).
|
||||
syncTransientsFromSnapshot(s);
|
||||
syncContainersFromSnapshot(s);
|
||||
syncTombstonesFromSnapshot(s);
|
||||
syncMetaInputsFromSnapshot(s);
|
||||
// Rebuild-queue state feeds the SW4RM agent-card badges
|
||||
// (inFlightOpsByAgent); its own panel now lives on /core.html.
|
||||
syncRebuildQueueFromSnapshot(s);
|
||||
renderContainers(s);
|
||||
renderTombstones(s);
|
||||
// Sync the derived approvals + questions stores from the
|
||||
// snapshot, then render. Live `*_added` / `*_resolved` events
|
||||
// mutate the stores directly and re-render without a snapshot
|
||||
|
|
@ -2597,8 +2113,6 @@ window.marked = marked;
|
|||
// #inbox-section element to render into.)
|
||||
syncApprovalsFromSnapshot(s);
|
||||
renderApprovals();
|
||||
renderMetaInputs(s);
|
||||
renderRebuildQueue(s);
|
||||
refreshReminders();
|
||||
refreshSchedules();
|
||||
restoreOpenDetails(openDetails);
|
||||
|
|
@ -2656,9 +2170,9 @@ window.marked = marked;
|
|||
transient_cleared: applyTransientCleared,
|
||||
container_state_changed: applyContainerStateChanged,
|
||||
container_removed: applyContainerRemoved,
|
||||
tombstones_changed: applyTombstonesChanged,
|
||||
meta_inputs_changed: applyMetaInputsChanged,
|
||||
meta_update_running: applyMetaUpdateRunning,
|
||||
// tombstones_changed / meta_inputs_changed / meta_update_running are
|
||||
// handled on /core.html now (the SYST3M panels moved there).
|
||||
// rebuild_queue_changed stays: it refreshes the SW4RM badges.
|
||||
rebuild_queue_changed: applyRebuildQueueChanged,
|
||||
schedules_changed: applySchedulesChanged,
|
||||
reminders_changed: applyRemindersChanged,
|
||||
|
|
@ -2732,9 +2246,6 @@ window.marked = marked;
|
|||
fetchAndRenderToolGroups();
|
||||
}
|
||||
if (target === 'call') { refreshOperatorInbox(); }
|
||||
// SYST3M › C0NT41N3R L04D: live cgroup poll only while the tab is
|
||||
// open (cpu needs a short two-sample read each refresh).
|
||||
if (target === 'system') { startContainerLoadPolling(); } else { stopContainerLoadPolling(); }
|
||||
}
|
||||
|
||||
// ST4TS (hive-wide turn-stats rollup) moved to its own page,
|
||||
|
|
@ -2742,100 +2253,6 @@ window.marked = marked;
|
|||
// stats.js now. The dashboard no longer fetches
|
||||
// /api/stats-hive.
|
||||
|
||||
// ─── SYST3M › C0NT41N3R L04D: live per-container cgroup cpu/mem ────────────
|
||||
// Pull-only, polled at 5s ONLY while the SYST3M tab is active (cpu is a
|
||||
// short two-sample read on the server). Data from /api/container-resources.
|
||||
let containerLoadTimer = null;
|
||||
|
||||
function cloadFmtBytes(n) {
|
||||
if (!Number.isFinite(n) || n <= 0) return '0';
|
||||
const u = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
|
||||
let i = 0; let v = n;
|
||||
while (v >= 1024 && i < u.length - 1) { v /= 1024; i += 1; }
|
||||
return v.toFixed(v < 10 && i > 0 ? 1 : 0) + ' ' + u[i];
|
||||
}
|
||||
function cloadMeter(pct) {
|
||||
const p = Math.max(0, Math.min(100, pct));
|
||||
const cls = p >= 90 ? 'hot' : (p >= 70 ? 'warn' : '');
|
||||
const m = document.createElement('span');
|
||||
m.className = 'cload-meter';
|
||||
m.title = p.toFixed(0) + '%';
|
||||
const f = document.createElement('span');
|
||||
f.className = 'fill' + (cls ? ' ' + cls : '');
|
||||
f.style.width = p + '%';
|
||||
m.append(f);
|
||||
return m;
|
||||
}
|
||||
|
||||
function renderContainerLoad(rows) {
|
||||
const root = $('container-load-section');
|
||||
if (!root) return;
|
||||
if (!Array.isArray(rows) || rows.length === 0) {
|
||||
root.replaceChildren();
|
||||
const p = document.createElement('p'); p.className = 'meta';
|
||||
p.textContent = 'no running agent containers'; root.append(p);
|
||||
return;
|
||||
}
|
||||
const table = document.createElement('table');
|
||||
table.className = 'hive-stats-table';
|
||||
table.innerHTML = '<thead><tr><th>agent</th><th>cpu</th><th>memory</th>'
|
||||
+ '<th>peak</th><th>limit</th></tr></thead>';
|
||||
const tb = document.createElement('tbody');
|
||||
for (const r of rows) {
|
||||
const tr = document.createElement('tr');
|
||||
|
||||
const name = document.createElement('td'); name.textContent = r.name; tr.append(name);
|
||||
|
||||
const cpu = document.createElement('td'); cpu.className = 'num';
|
||||
cpu.append((Number(r.cpu_pct) || 0).toFixed(1) + '%', cloadMeter(Number(r.cpu_pct) || 0));
|
||||
tr.append(cpu);
|
||||
|
||||
const mem = document.createElement('td'); mem.className = 'num';
|
||||
const memCur = Number(r.mem_current_bytes) || 0;
|
||||
if (r.mem_max_bytes) {
|
||||
mem.append(cloadFmtBytes(memCur), cloadMeter(100 * memCur / r.mem_max_bytes));
|
||||
} else {
|
||||
mem.textContent = cloadFmtBytes(memCur);
|
||||
}
|
||||
tr.append(mem);
|
||||
|
||||
const peak = document.createElement('td'); peak.className = 'num';
|
||||
peak.textContent = r.mem_peak_bytes ? cloadFmtBytes(Number(r.mem_peak_bytes)) : '—';
|
||||
tr.append(peak);
|
||||
|
||||
const lim = document.createElement('td'); lim.className = 'num';
|
||||
lim.textContent = r.mem_max_bytes ? cloadFmtBytes(Number(r.mem_max_bytes)) : '∞';
|
||||
tr.append(lim);
|
||||
|
||||
tb.append(tr);
|
||||
}
|
||||
table.append(tb);
|
||||
root.replaceChildren(table);
|
||||
}
|
||||
|
||||
async function refreshContainerLoad() {
|
||||
try {
|
||||
const resp = await fetch('/api/container-resources');
|
||||
if (!resp.ok) throw new Error('http ' + resp.status);
|
||||
renderContainerLoad(await resp.json());
|
||||
} catch (e) {
|
||||
const root = $('container-load-section');
|
||||
if (root) {
|
||||
root.replaceChildren();
|
||||
const p = document.createElement('p'); p.className = 'meta';
|
||||
p.textContent = 'container load fetch failed: ' + e; root.append(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
function startContainerLoadPolling() {
|
||||
refreshContainerLoad();
|
||||
if (containerLoadTimer) return;
|
||||
containerLoadTimer = setInterval(refreshContainerLoad, 5000);
|
||||
}
|
||||
function stopContainerLoadPolling() {
|
||||
if (containerLoadTimer) { clearInterval(containerLoadTimer); containerLoadTimer = null; }
|
||||
}
|
||||
|
||||
// Wire the shared tab strip now that activateTab + the lazy-load fns it
|
||||
// calls are defined. The strip resolves the active tab from the hash
|
||||
// (default SW4RM), toggles the active tab/pane + aria-selected, and
|
||||
|
|
@ -2943,15 +2360,6 @@ window.marked = marked;
|
|||
// (which run once on state load, not every tick) compose cleanly.
|
||||
const rawTitle = document.title.replace(/^\(\d+\) /, '');
|
||||
document.title = callCount > 0 ? `(${callCount}) ${rawTitle}` : rawTitle;
|
||||
// SYST3M — queued + running rebuild_queue entries (terminal
|
||||
// entries are kept for history but aren't 'attention').
|
||||
let sysCount = 0;
|
||||
if (rebuildQueueState) {
|
||||
for (const e of rebuildQueueState) {
|
||||
if (e.state === 'queued' || e.state === 'running') sysCount++;
|
||||
}
|
||||
}
|
||||
setTabCount('system', sysCount);
|
||||
// SCH3DUL3S — count of schedules with at least one still-active
|
||||
// target (whole-schedule cancellation or all-targets-cancelled
|
||||
// means "not waiting on the worker"; those don't pull attention).
|
||||
|
|
|
|||
Loading…
Reference in a new issue