Every sub-page tabbar (logs.html, credentials.html, core.html,
builds.html) hand-wrote the same <nav class="hive-tabbar"><a
class="hive-tab">...</a></nav> boilerplate and then called
createTabStrip() on it after the fact. Add <hive-tab-strip>, a
markup-owning custom element (same reuse-boundary pattern as
<hive-menu>/<hive-side-panel>) that renders that markup from a
declarative tabs list, then wires the existing createTabStrip()
behaviour over what it just rendered — no behaviour duplication.
Convert all four sites to use it: each page's JS now calls
`.configure({ tabs, defaultId, onShow })` on the tabbar element instead
of `createTabStrip(el, opts)`, and configure() returns the identical
{ show, active } shape so nothing downstream changes. builds.js's
rebuild-queue count pill (builds-tab-count-rebuild) is expressed as a
tab's `badgeId` and renders nested in the same spot.
The dashboard's own tabbar and the two no-pane stats time-range
pickers are a different markup/behaviour shape and are intentionally
left alone.
523 lines
21 KiB
JavaScript
523 lines
21 KiB
JavaScript
// 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. Two sub-tabs via <hive-tab-strip>
|
|
// (default = K3PT ST4T3): kept state (tombstones) and container load.
|
|
//
|
|
// Rebuild queue + meta inputs have moved to /builds.html (the build
|
|
// lifecycle hub). 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 copy of the tombstones state.
|
|
|
|
import { $, form, openStream, initServerWarnings } from './common.js';
|
|
import { el } from '@hive/shared/dom.js';
|
|
import { asyncBtn, bindAsyncForms } from '@hive/shared/forms.js';
|
|
import '@hive/shared/hive-tab-strip.js';
|
|
|
|
// ─── derived state (own copies; this bundle has its own runtime) ──────────
|
|
let tombstonesState = [];
|
|
// ContainerView list from the last /api/state snapshot — used by renderContainerLoad
|
|
// to splice in the configured cpu_quota/memory_max limits alongside live cgroup data.
|
|
let containersState = [];
|
|
function syncFromSnapshot(s) {
|
|
tombstonesState = (s.tombstones || []).slice();
|
|
containersState = (s.containers || []).slice();
|
|
}
|
|
|
|
// ─── 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: '/api/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(
|
|
'/api/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);
|
|
}
|
|
|
|
// ─── stale permission entries (K3PT ST4T3 pane sub-section) ──────────────
|
|
// Agents with explicit capability / tool-group JSON entries but no live
|
|
// container AND no kept-state tombstone (e.g. renamed agents like the old
|
|
// "root" manager name). Ghost detection is server-side via
|
|
// GET /api/permissions/stale so the client doesn't need to maintain a
|
|
// container-roster cache or perform set arithmetic. Lazy-loaded on first
|
|
// "kept" tab activation; refreshed when perm data changes via SSE.
|
|
let stalePermsLoaded = false;
|
|
|
|
function renderStalePerms(root, ghosts) {
|
|
root.replaceChildren();
|
|
if (!ghosts.length) return;
|
|
root.append(el('p', { class: 'tombstones-stale-heading' }, 'stale permission entries'));
|
|
root.append(el('p', { class: 'meta' },
|
|
'agents with explicit capability or tool-group entries but no live container '
|
|
+ 'or kept state (typically renamed or manually-deleted agents whose JSON entries persisted).'));
|
|
const errP = el('p', { class: 'tombstones-stale-err', hidden: true });
|
|
const ul = el('ul', { class: 'tombstones-stale-list' });
|
|
for (const name of ghosts) {
|
|
const li = el('li', { class: 'tombstones-stale-row' });
|
|
li.append(el('span', { class: 'tombstones-stale-name' }, name));
|
|
li.append(el('span', { class: 'badge badge-muted' }, 'stale perms'));
|
|
const btn = el('button', {
|
|
type: 'button',
|
|
class: 'btn btn-destroy',
|
|
title: 'remove explicit capability and tool-group entries for ' + name,
|
|
}, '✕ clear perms');
|
|
btn.addEventListener('click', () => asyncBtn(btn, async () => {
|
|
errP.hidden = true;
|
|
try {
|
|
const resp = await fetch('/api/permissions/' + encodeURIComponent(name), { method: 'DELETE' });
|
|
if (!resp.ok) {
|
|
const msg = await resp.text().catch(() => String(resp.status));
|
|
errP.textContent = 'failed to clear perms for ' + name + ': ' + msg;
|
|
errP.hidden = false;
|
|
return;
|
|
}
|
|
await fetchAndRenderStalePerms();
|
|
} catch (err) {
|
|
errP.textContent = 'failed to clear perms for ' + name + ': ' + err;
|
|
errP.hidden = false;
|
|
}
|
|
}));
|
|
li.append(btn);
|
|
ul.append(li);
|
|
}
|
|
root.append(ul, errP);
|
|
}
|
|
|
|
// Ghost detection is entirely server-side: GET /api/permissions/stale
|
|
// returns the computed list of agent names that have explicit JSON entries
|
|
// but are absent from both the live roster and the kept-state tombstones.
|
|
// One call, no client-side roster cache, always authoritative.
|
|
async function fetchAndRenderStalePerms() {
|
|
const root = $('tombstones-stale-perms');
|
|
if (!root) return;
|
|
try {
|
|
const resp = await fetch('/api/permissions/stale');
|
|
if (!resp.ok) throw new Error('http ' + resp.status);
|
|
const data = await resp.json();
|
|
renderStalePerms(root, data.stale || []);
|
|
} catch (err) {
|
|
root.replaceChildren();
|
|
root.append(el('p', { class: 'meta' }, 'failed to load stale perm data: ' + err));
|
|
}
|
|
stalePermsLoaded = true;
|
|
}
|
|
|
|
// ─── 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;
|
|
}
|
|
|
|
// Stash the last load rows so re-renders triggered by SSE state updates can
|
|
// call renderContainerLoad(lastLoadRows) without waiting for the next poll.
|
|
let lastLoadRows = [];
|
|
|
|
function renderContainerLoad(rows) {
|
|
lastLoadRows = 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;
|
|
}
|
|
|
|
// Build a name→ContainerView lookup so we can splice in the configured
|
|
// cpu_quota / memory_max limits (set in meta/resource-limits.json and
|
|
// resolved by the server) next to the live cgroup readings.
|
|
const cvByName = new Map(containersState.map((c) => [c.name, c]));
|
|
|
|
const table = document.createElement('table');
|
|
table.className = 'hive-stats-table';
|
|
// "cpu cap" / "mem cap" show the configured ceilings from ContainerView
|
|
// (effective drop-in values, take effect on next start/restart).
|
|
// "limit" remains the live cgroup memory ceiling from /api/container-resources.
|
|
table.innerHTML = '<thead><tr><th>agent</th><th>cpu</th><th>memory</th>'
|
|
+ '<th>peak</th><th>limit</th><th>disk</th>'
|
|
+ '<th class="cload-cap-th" title="configured ceiling — takes effect on next start">cpu cap</th>'
|
|
+ '<th class="cload-cap-th" title="configured ceiling — takes effect on next start">mem cap</th>'
|
|
+ '<th></th></tr></thead>';
|
|
const tb = document.createElement('tbody');
|
|
for (const r of rows) {
|
|
const cv = cvByName.get(r.name);
|
|
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);
|
|
// Disk: on-disk footprint (state dir + container writable rootfs, shared
|
|
// nix store excluded). Sampled out-of-band every few minutes server-side,
|
|
// so it's null until the first sample lands — show an em-dash then.
|
|
const disk = document.createElement('td'); disk.className = 'num';
|
|
disk.title = 'state dir + container writable rootfs (shared nix store excluded); sampled every few minutes';
|
|
disk.textContent = (r.disk_bytes != null) ? cloadFmtBytes(Number(r.disk_bytes)) : '—';
|
|
tr.append(disk);
|
|
|
|
// Configured CPU / memory caps from ContainerView (resolved effective
|
|
// values: per-agent override when set, hive-wide default otherwise).
|
|
const cpuCap = document.createElement('td'); cpuCap.className = 'num cload-cap';
|
|
cpuCap.title = 'configured ceiling — takes effect on next start';
|
|
cpuCap.textContent = cv?.cpu_quota || '—';
|
|
tr.append(cpuCap);
|
|
const memCap = document.createElement('td'); memCap.className = 'num cload-cap';
|
|
memCap.title = 'configured ceiling — takes effect on next start';
|
|
memCap.textContent = cv?.memory_max || '—';
|
|
tr.append(memCap);
|
|
|
|
// S3T button toggles the inline edit row for this agent.
|
|
const actTd = document.createElement('td');
|
|
const setBtn = document.createElement('button');
|
|
setBtn.type = 'button';
|
|
setBtn.className = 'btn btn-sm cload-set-btn';
|
|
setBtn.textContent = 'S3T';
|
|
setBtn.title = 'set CPU / memory cap for ' + r.name;
|
|
tr.append(actTd);
|
|
actTd.append(setBtn);
|
|
tb.append(tr);
|
|
|
|
// Inline edit row (hidden by default, toggled by the S3T button).
|
|
const editRow = document.createElement('tr');
|
|
editRow.className = 'cload-edit-row';
|
|
editRow.hidden = true;
|
|
const editTd = document.createElement('td');
|
|
editTd.colSpan = 9;
|
|
editTd.className = 'cload-edit-cell';
|
|
|
|
const editForm = document.createElement('form');
|
|
editForm.className = 'cload-edit-form';
|
|
editForm.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const cpuInput = editForm.querySelector('.cload-cpu-input');
|
|
const memInput = editForm.querySelector('.cload-mem-input');
|
|
const errSpan = editForm.querySelector('.cload-edit-err');
|
|
const submitBtn = editForm.querySelector('[type="submit"]');
|
|
errSpan.hidden = true;
|
|
submitBtn.disabled = true;
|
|
try {
|
|
const body = new URLSearchParams({
|
|
cpu_quota: cpuInput.value.trim(),
|
|
memory_max: memInput.value.trim(),
|
|
});
|
|
const resp = await fetch(
|
|
'/api/resource-limits/' + encodeURIComponent(r.name),
|
|
{ method: 'POST', body },
|
|
);
|
|
if (!resp.ok) {
|
|
errSpan.textContent = await resp.text().catch(() => 'error ' + resp.status);
|
|
errSpan.hidden = false;
|
|
return;
|
|
}
|
|
// Success: collapse the edit row. The SSE rescan will push updated
|
|
// ContainerView data (cpu_quota/memory_max) to containersState,
|
|
// triggering a re-render of the cap columns via refreshContainerLoad.
|
|
editRow.hidden = true;
|
|
setBtn.textContent = 'S3T';
|
|
} catch (err) {
|
|
errSpan.textContent = String(err);
|
|
errSpan.hidden = false;
|
|
} finally {
|
|
submitBtn.disabled = false;
|
|
}
|
|
});
|
|
|
|
const cpuLabel = document.createElement('label');
|
|
cpuLabel.className = 'cload-edit-label';
|
|
cpuLabel.textContent = 'cpu quota';
|
|
const cpuInput = document.createElement('input');
|
|
cpuInput.type = 'text'; cpuInput.className = 'cload-cpu-input';
|
|
cpuInput.placeholder = cv ? cv.cpu_quota : 'e.g. 200%';
|
|
cpuInput.title = 'systemd CPUQuota= value (e.g. "400%"). empty = use hive default';
|
|
cpuLabel.append(cpuInput);
|
|
|
|
const memLabel = document.createElement('label');
|
|
memLabel.className = 'cload-edit-label';
|
|
memLabel.textContent = 'mem max';
|
|
const memInput = document.createElement('input');
|
|
memInput.type = 'text'; memInput.className = 'cload-mem-input';
|
|
memInput.placeholder = cv ? cv.memory_max : 'e.g. 8G';
|
|
memInput.title = 'systemd MemoryMax= value (e.g. "8G", "50%", "infinity"). empty = use hive default';
|
|
memLabel.append(memInput);
|
|
|
|
const submitBtn = document.createElement('button');
|
|
submitBtn.type = 'submit'; submitBtn.className = 'btn btn-restart cload-save-btn';
|
|
submitBtn.textContent = 'S4V3';
|
|
|
|
const hintSpan = document.createElement('span');
|
|
hintSpan.className = 'meta cload-edit-hint';
|
|
hintSpan.textContent = '↺ restart to apply to a running container';
|
|
|
|
const errSpan = document.createElement('span');
|
|
errSpan.className = 'cload-edit-err'; errSpan.hidden = true;
|
|
|
|
editForm.append(cpuLabel, memLabel, submitBtn, hintSpan, errSpan);
|
|
editTd.append(editForm);
|
|
editRow.append(editTd);
|
|
tb.append(editRow);
|
|
|
|
setBtn.addEventListener('click', () => {
|
|
const open = !editRow.hidden;
|
|
editRow.hidden = open;
|
|
setBtn.textContent = open ? 'S3T' : '✕';
|
|
});
|
|
}
|
|
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; }
|
|
}
|
|
|
|
// ─── infra containers (start/stop/restart the 4 hive infra containers) ────
|
|
let infraTimer = null;
|
|
|
|
function renderInfraContainers(rows) {
|
|
const root = $('infra-containers-section');
|
|
if (!root) return;
|
|
root.replaceChildren();
|
|
if (!Array.isArray(rows) || !rows.length) {
|
|
root.append(el('p', { class: 'meta' }, 'no infra container data'));
|
|
return;
|
|
}
|
|
const ul = el('ul', { class: 'containers' });
|
|
for (const c of rows) {
|
|
const li = el('li', { class: 'container-row' });
|
|
const head = el('div', { class: 'head' });
|
|
head.append(
|
|
el('span', { class: 'name' }, c.name),
|
|
el('span', { class: 'badge ' + (c.running ? 'badge-ok' : 'badge-fail') },
|
|
c.running ? 'running' : 'stopped'),
|
|
);
|
|
li.append(head);
|
|
|
|
const actions = el('div', { class: 'actions' });
|
|
const base = '/api/infra-container/' + encodeURIComponent(c.name) + '/';
|
|
if (c.running) {
|
|
actions.append(form(base + 'restart', 'btn-restart', '↺ R3ST4RT',
|
|
'restart ' + c.name + '?'));
|
|
actions.append(form(base + 'stop', 'btn-stop', '■ ST0P',
|
|
'stop ' + c.name + '?'));
|
|
} else {
|
|
actions.append(form(base + 'start', 'btn-start', '▶ ST4RT',
|
|
'start ' + c.name + '?'));
|
|
}
|
|
li.append(actions);
|
|
ul.append(li);
|
|
}
|
|
root.append(ul);
|
|
}
|
|
|
|
async function refreshInfraContainers() {
|
|
try {
|
|
const resp = await fetch('/api/state');
|
|
if (!resp.ok) throw new Error('http ' + resp.status);
|
|
const s = await resp.json();
|
|
renderInfraContainers(s.infra_containers || []);
|
|
} catch (e) {
|
|
const root = $('infra-containers-section');
|
|
if (root) {
|
|
root.replaceChildren();
|
|
root.append(el('p', { class: 'meta' }, 'infra container fetch failed: ' + e));
|
|
}
|
|
}
|
|
}
|
|
function startInfraPolling() {
|
|
refreshInfraContainers();
|
|
if (infraTimer) return;
|
|
infraTimer = setInterval(refreshInfraContainers, 5000);
|
|
}
|
|
function stopInfraPolling() {
|
|
if (infraTimer) { clearInterval(infraTimer); infraTimer = null; }
|
|
}
|
|
|
|
// ─── render-all (cold load + any full re-render) ──────────────────────────
|
|
function renderAll() {
|
|
renderTombstones({ tombstones: tombstonesState });
|
|
}
|
|
|
|
// ─── live SSE handlers ───────────────────────────────────────────────────
|
|
const SSE_HANDLERS = {
|
|
tombstones_changed(ev) {
|
|
tombstonesState = (ev.tombstones || []).slice();
|
|
renderTombstones({ tombstones: tombstonesState });
|
|
},
|
|
// When a container's state changes (e.g. after resource-limits update
|
|
// triggers rescan_containers_and_emit), update containersState in place
|
|
// so the cap columns in the LOAD table reflect the new configured values.
|
|
container_state_changed(ev) {
|
|
if (!ev.container) return;
|
|
const idx = containersState.findIndex((c) => c.name === ev.container.name);
|
|
if (idx >= 0) {
|
|
containersState[idx] = ev.container;
|
|
} else {
|
|
containersState.push(ev.container);
|
|
}
|
|
if (lastLoadRows.length) renderContainerLoad(lastLoadRows);
|
|
},
|
|
// Refresh the stale-perms sub-section when perm data changes (a ghost
|
|
// was cleared, or perms were saved for an agent whose name collides).
|
|
capabilities_changed(_ev) {
|
|
if (stalePermsLoaded) fetchAndRenderStalePerms();
|
|
},
|
|
tool_groups_changed(_ev) {
|
|
if (stalePermsLoaded) fetchAndRenderStalePerms();
|
|
},
|
|
};
|
|
|
|
// ─── boot ─────────────────────────────────────────────────────────────────
|
|
// Re-fetch /api/state, re-sync the derived state, re-render. Used for the
|
|
// cold load and as the post-submit refresh for `data-async` forms whose
|
|
// mutation doesn't arrive via an SSE event (e.g. the meta-update / spawn /
|
|
// purge actions that opt out of `data-no-refresh`).
|
|
async function refreshState() {
|
|
try {
|
|
const resp = await fetch('/api/state');
|
|
if (resp.ok) syncFromSnapshot(await resp.json());
|
|
} catch {
|
|
// best-effort: the page keeps its last-rendered state
|
|
}
|
|
renderAll();
|
|
}
|
|
|
|
async function init() {
|
|
initServerWarnings();
|
|
|
|
// `data-async` form submit interceptor — without this the meta-update /
|
|
// cancel / respawn / purge buttons POST natively and the browser navigates
|
|
// to the bare `ok` response page.
|
|
bindAsyncForms(() => refreshState());
|
|
|
|
// Hash-routed sub-tab strip; default K3PT ST4T3. Container-load
|
|
// polling runs only while the LOAD sub-tab is open (cpu is a short
|
|
// two-sample read each refresh on the server).
|
|
document.getElementById('core-tabbar').configure({
|
|
tabs: [
|
|
{ id: 'kept', label: 'K3PT ST4T3' },
|
|
{ id: 'load', label: 'C0NT41N3R L04D' },
|
|
{ id: 'infra', label: '1NFR4' },
|
|
],
|
|
defaultId: 'kept',
|
|
onShow: (id) => {
|
|
if (id === 'load') startContainerLoadPolling();
|
|
else stopContainerLoadPolling();
|
|
if (id === 'infra') startInfraPolling();
|
|
else stopInfraPolling();
|
|
// Lazy-load stale-perms on first K3PT ST4T3 activation; always
|
|
// re-fetch on subsequent visits in case perms changed.
|
|
if (id === 'kept') fetchAndRenderStalePerms();
|
|
},
|
|
});
|
|
|
|
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();
|