Agents that `send(to: "operator")` were easy to miss — they only surfaced on the FL0W firehose with no read-state (#1469). Surface them on the Y3R C4LL ("things waiting on you") tab as a proper inbox. Backend: - broker: `unread_for_recipient(recipient, limit)` — unacked messages for a recipient, newest-first. Mirrors `mark_all_read`'s filter EXACTLY (`recipient = ?1 AND acked_at IS NULL`, no `delivered_at` condition) so everything listed is exactly what mark-read clears — operator rows never get `delivered_at` set (no agent-socket recv). - dashboard: `GET /api/operator-inbox` → `{ messages: [...] }` (id, from, body, at, in_reply_to, validated file_refs). Mark-read reuses the existing `POST /api/agent/operator/mark-all-read` (the route format-validates the name; "operator" passes; `mark_all_read` already acks `to="operator"` rows). Frontend (Y3R C4LL): - New ◆ 1NB0X ◆ section listing unread messages (sender · time · body, path-linkified) + a "✓ mark all read" button. - Cold-loaded on page load + on tab activation; appended live from the broker `sent` stream (deduped on row id); cleared on mark-all-read. - Unread count folds into the Y3R C4LL tab pill + the browser-title `(N)` prefix, so messages are visible from any tab. Removing the now-redundant FL0W operator-inbox UI is a clean follow-up (deferred to avoid a flow.js conflict with the in-flight #1473). Backend (broker + route) is host-side — @damocles to review per plan. Closes #1469.
4625 lines
192 KiB
JavaScript
4625 lines
192 KiB
JavaScript
// /index.html entry point: tab renderers + tab routing + refreshState
|
||
// + notification deltas. Reads /api/state on cold load and after every
|
||
// async-form submit; live updates run through `applyXxx` mutation
|
||
// handlers triggered by the dashboard event stream (subscribed via
|
||
// `openStream` from common.js). See docs/web-ui.md::Shape (shared by
|
||
// both) for the broader contract.
|
||
//
|
||
// Pure helpers (DOM, side panel, OS notifications, path linkification)
|
||
// live in `./common.js`; the flow-page surface (operator inbox, broker
|
||
// terminal, @-mention composer) lives in `./flow.js` — `flow.html` and
|
||
// `index.html` each load their own bundle.
|
||
|
||
import { marked } from 'marked';
|
||
import {
|
||
$, el, esc, form,
|
||
fmtAgeSecs,
|
||
Panel, NOTIF,
|
||
makePathLink, appendText, appendLinkified,
|
||
openStream,
|
||
} from './common.js';
|
||
|
||
// mdNode (in common.js) reads `window.marked` for the markdown side
|
||
// panel preview path. Set it here on the dashboard entry so file
|
||
// previews work; flow.js does the same for the flow-page entry.
|
||
window.marked = marked;
|
||
|
||
(() => {
|
||
// ─── constants ──────────────────────────────────────────────────────────
|
||
// Context-window badge thresholds. Preferred source is each container's
|
||
// `context_window_tokens` from /api/state (the real window for the model
|
||
// it last ran on) — thresholds are then 75% / 50% of it, matching the
|
||
// harness compaction watermarks (compact at 75%, auto-reset at 50%). The
|
||
// fixed token constants are the fallback for when that field is absent
|
||
// (agent has no turns yet, or no per-model config matched the model).
|
||
const CTX_WARN_FRACTION = 0.75; // ≥ this share of the window → red
|
||
const CTX_CAUTION_FRACTION = 0.50; // ≥ this share of the window → yellow
|
||
const CTX_WARN_TOKENS = 150_000; // fallback red threshold (≈ 75% of 200k)
|
||
const CTX_CAUTION_TOKENS = 100_000; // fallback yellow threshold (≈ 50% of 200k)
|
||
|
||
// Atomic-swap render helper: build into a DocumentFragment off-DOM,
|
||
// commit with one `replaceChildren`. See docs/web-ui.md::Atomic
|
||
// section repaint for why (no intermediate empty-state flash on
|
||
// poll cycles even when the builder allocates a lot of `el()`).
|
||
function paintAtomic(liveRoot, build) {
|
||
const buf = document.createDocumentFragment();
|
||
build(buf);
|
||
liveRoot.replaceChildren(buf);
|
||
}
|
||
|
||
// Track which items we've already notified about so a re-render
|
||
// doesn't re-fire for the same row. Keyed by stable ids; reset only
|
||
// when the page reloads.
|
||
const seenApprovals = new Set();
|
||
const seenQuestions = new Set();
|
||
let seededNotify = false;
|
||
|
||
function notifyDeltas(s) {
|
||
const approvals = s.approvals || [];
|
||
const questions = s.questions || [];
|
||
if (!seededNotify) {
|
||
// First render after page load — fill the "seen" sets without
|
||
// firing notifications. We only want to notify on NEW items
|
||
// that arrived while the page is open. The inbox no longer
|
||
// needs seeding here: it's derived from the broker stream which
|
||
// does its own per-event notification on live arrival, and
|
||
// history-replayed events are silent by virtue of `fromHistory`.
|
||
for (const a of approvals) seenApprovals.add(a.id);
|
||
for (const q of questions) seenQuestions.add(q.id);
|
||
seededNotify = true;
|
||
return;
|
||
}
|
||
for (const a of approvals) {
|
||
if (seenApprovals.has(a.id)) continue;
|
||
seenApprovals.add(a.id);
|
||
const verb = a.kind === 'spawn' ? 'spawn approval'
|
||
: a.kind === 'init_config' ? 'config-init approval'
|
||
: 'config commit';
|
||
NOTIF.show('◆ approval #' + a.id, `${verb} for ${a.agent}`,
|
||
'hyperhive:approval:' + a.id);
|
||
}
|
||
for (const q of questions) {
|
||
if (seenQuestions.has(q.id)) continue;
|
||
seenQuestions.add(q.id);
|
||
const targetLabel = q.target || 'operator';
|
||
NOTIF.show(`◆ ${q.asker} → ${targetLabel} asks`,
|
||
q.question.slice(0, 120),
|
||
'hyperhive:question:' + q.id);
|
||
}
|
||
}
|
||
|
||
// ─── async forms ────────────────────────────────────────────────────────
|
||
document.addEventListener('submit', async (e) => {
|
||
const f = e.target;
|
||
if (!(f instanceof HTMLFormElement) || !f.hasAttribute('data-async')) return;
|
||
e.preventDefault();
|
||
if (f.dataset.confirm && !confirm(f.dataset.confirm)) return;
|
||
if (f.dataset.prompt) {
|
||
const ans = prompt(f.dataset.prompt, '');
|
||
if (ans === null) return; // operator hit Cancel
|
||
// Drop into a hidden input named after `data-prompt-field` (or
|
||
// 'note' by default) so the value rides along on the POST.
|
||
const field = f.dataset.promptField || 'note';
|
||
let input = f.querySelector(`input[name="${field}"]`);
|
||
if (!input) {
|
||
input = document.createElement('input');
|
||
input.type = 'hidden';
|
||
input.name = field;
|
||
f.append(input);
|
||
}
|
||
input.value = ans;
|
||
}
|
||
const btn = f.querySelector('button[type="submit"], button:not([type]), .btn-inline');
|
||
const original = btn ? btn.innerHTML : '';
|
||
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="spinner">◐</span>'; }
|
||
try {
|
||
const resp = await fetch(f.action, {
|
||
method: f.method || 'POST',
|
||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: new URLSearchParams(new FormData(f)),
|
||
redirect: 'manual',
|
||
});
|
||
const ok = resp.ok || resp.type === 'opaqueredirect'
|
||
|| (resp.status >= 200 && resp.status < 400);
|
||
if (!ok) {
|
||
const text = await resp.text().catch(() => '');
|
||
alert('action failed: ' + resp.status + (text ? '\n\n' + text : ''));
|
||
if (btn) { btn.disabled = false; btn.innerHTML = original; }
|
||
return;
|
||
}
|
||
// Re-enable the button — refreshState() rebuilds most lists but
|
||
// skips forms that didn't change (e.g. the spawn form), so without
|
||
// this the spinner sticks and the button can't be clicked again.
|
||
if (btn) { btn.disabled = false; btn.innerHTML = original; }
|
||
// Clear text inputs whose value was just submitted.
|
||
f.querySelectorAll('input[type="text"], input:not([type]), textarea').forEach((i) => { i.value = ''; });
|
||
// Forms whose endpoint already emits a DashboardEvent that
|
||
// updates the derived store can opt out of the post-submit
|
||
// /api/state refetch (the event delivers the new row faster
|
||
// than the snapshot poll anyway). Container-lifecycle forms
|
||
// still rely on the refresh since `ContainerView` isn't yet
|
||
// event-derivable.
|
||
if (!f.hasAttribute('data-no-refresh')) {
|
||
refreshState();
|
||
}
|
||
} catch (err) {
|
||
alert('action failed: ' + err);
|
||
if (btn) { btn.disabled = false; btn.innerHTML = original; }
|
||
}
|
||
});
|
||
|
||
// Derived container state — cold-loaded from /api/state.containers,
|
||
// then mutated live by `container_state_changed` (upsert by name)
|
||
// and `container_removed` (drop by name). The coordinator's rescan
|
||
// helper fires these after every mutation site + on a periodic poll
|
||
// in crash_watch. Keyed by ContainerView.name so the lifecycle
|
||
// forms' POST → 200 → matching event flips the row without a
|
||
// snapshot refetch.
|
||
const containersState = new Map();
|
||
// Keyed container row cache. Maps agent name → { el: <li>, fingerprint }.
|
||
// Allows renderContainers to skip rebuilding rows whose displayed state
|
||
// hasn't changed — prevents full-wipe flicker + avoids redundant async
|
||
// dashboard-state fetches on every SSE event.
|
||
const containerRowCache = new Map();
|
||
function syncContainersFromSnapshot(s) {
|
||
containersState.clear();
|
||
for (const c of s.containers || []) containersState.set(c.name, c);
|
||
}
|
||
function applyContainerStateChanged(ev) {
|
||
if (!ev.container || !ev.container.name) return;
|
||
containersState.set(ev.container.name, ev.container);
|
||
renderContainersFromState();
|
||
}
|
||
function applyContainerRemoved(ev) {
|
||
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).
|
||
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.
|
||
// See docs/web-ui.md::Container row for the badge taxonomy.
|
||
renderContainersFromState();
|
||
}
|
||
function applySchedulesChanged(ev) {
|
||
schedulesState = (ev.schedules || []).slice();
|
||
renderSchedulesList();
|
||
}
|
||
function applyRemindersChanged(ev) {
|
||
renderReminders(ev.reminders || []);
|
||
}
|
||
// Map from agent name → highest-priority in-flight queue entry
|
||
// (`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.
|
||
function inFlightOpsByAgent() {
|
||
const out = new Map();
|
||
for (const e of rebuildQueueState) {
|
||
if (e.state !== 'queued' && e.state !== 'running') continue;
|
||
// spawn ops target an agent that doesn't exist yet as a
|
||
// container — the transient store already drives the
|
||
// pending row for that case. Skip here to avoid double-
|
||
// surfacing if the spawn op happens to land in the queue
|
||
// while the row exists transiently.
|
||
if (e.kind === 'spawn') continue;
|
||
const cur = out.get(e.agent);
|
||
// Prefer running over queued; otherwise keep the first match.
|
||
if (!cur || (cur.state === 'queued' && e.state === 'running')) {
|
||
out.set(e.agent, e);
|
||
}
|
||
}
|
||
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
|
||
// by agent name so add/remove are O(1). `since_unix` is wall-clock so
|
||
// the elapsed-seconds badge ticks without polling.
|
||
const transientsState = new Map();
|
||
function syncTransientsFromSnapshot(s) {
|
||
transientsState.clear();
|
||
for (const t of s.transients || []) {
|
||
// Snapshot ships `secs` (server-computed); reconstruct an
|
||
// approximate since_unix so the live ticker keeps progressing
|
||
// without surprising jumps when the next snapshot lands.
|
||
const nowUnix = Math.floor(Date.now() / 1000);
|
||
transientsState.set(t.name, {
|
||
kind: t.kind,
|
||
since_unix: t.since_unix ?? (nowUnix - (t.secs || 0)),
|
||
});
|
||
}
|
||
}
|
||
function applyTransientSet(ev) {
|
||
transientsState.set(ev.name, {
|
||
kind: ev.transient_kind,
|
||
since_unix: ev.since_unix,
|
||
});
|
||
renderContainersFromState();
|
||
}
|
||
function applyTransientCleared(ev) {
|
||
if (transientsState.delete(ev.name)) renderContainersFromState();
|
||
}
|
||
// Re-render using the last cached snapshot (containers come from
|
||
// /api/state, transients overlay from the derived map). The snapshot
|
||
// is stashed on window.__hyperhive_state by refreshState; on cold
|
||
// load before the first snapshot we just skip.
|
||
function renderContainersFromState() {
|
||
const s = window.__hyperhive_state;
|
||
if (s) renderContainers(s);
|
||
}
|
||
|
||
// ─── selection ──────────────────────────────────────────────────────
|
||
// In-memory set of selected agent logical names backing the sticky
|
||
// #selection-bar. See docs/web-ui.md::Selection bar for the
|
||
// interaction model (icon-click toggle, Esc/clear button drop,
|
||
// tab-gated visibility).
|
||
const selectionState = new Set();
|
||
function toggleSelection(name) {
|
||
if (selectionState.has(name)) selectionState.delete(name);
|
||
else selectionState.add(name);
|
||
renderContainersFromState();
|
||
}
|
||
function clearSelection() {
|
||
if (selectionState.size === 0) return;
|
||
selectionState.clear();
|
||
renderContainersFromState();
|
||
}
|
||
// Esc clears the current selection (operator escape hatch — mirrors
|
||
// the side-panel close pattern). Ignored when an editable element
|
||
// has focus so typing in compose / answer / journal-search isn't
|
||
// intercepted.
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.key !== 'Escape') return;
|
||
if (!selectionState.size) return;
|
||
const a = document.activeElement;
|
||
if (a && (a.isContentEditable
|
||
|| a.tagName === 'INPUT'
|
||
|| a.tagName === 'TEXTAREA'
|
||
|| a.tagName === 'SELECT')) return;
|
||
e.preventDefault();
|
||
clearSelection();
|
||
});
|
||
document.addEventListener('click', (e) => {
|
||
if (e.target && e.target.closest('#selection-clear')) {
|
||
clearSelection();
|
||
}
|
||
});
|
||
|
||
// ─── per-agent context menu ──────────────────────────────────────────
|
||
// Three-dot (⋮) button on each agent card for quick single-agent
|
||
// lifecycle actions without needing to select first. State-aware:
|
||
// restart/stop only shown when running, start only shown when stopped,
|
||
// destroy/purge hidden for the manager.
|
||
// The button is CSS-invisible until the row is hovered (or menu is
|
||
// open) so it doesn't clutter quiet rows.
|
||
|
||
let openAgentMenu = null; // currently open dropdown element, or null
|
||
|
||
function closeAllAgentMenus() {
|
||
if (!openAgentMenu) return;
|
||
openAgentMenu.hidden = true;
|
||
const wrap = openAgentMenu.closest('.agent-menu');
|
||
if (wrap) {
|
||
wrap.classList.remove('open');
|
||
const btn = wrap.querySelector('.agent-menu-btn');
|
||
if (btn) btn.setAttribute('aria-expanded', 'false');
|
||
}
|
||
openAgentMenu = null;
|
||
}
|
||
|
||
// Close on any click outside an agent-menu element.
|
||
document.addEventListener('click', (e) => {
|
||
if (!e.target.closest('.agent-menu')) closeAllAgentMenus();
|
||
}, true);
|
||
// Close on Escape. stopImmediatePropagation so the selection-clear
|
||
// handler on the same element doesn't also fire when a menu is open.
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape' && openAgentMenu) {
|
||
closeAllAgentMenus();
|
||
e.stopImmediatePropagation();
|
||
}
|
||
}, true);
|
||
|
||
// Single-agent POST helper shared by all menu items.
|
||
async function agentMenuPost(actionPath, name, body) {
|
||
const url = actionPath + encodeURIComponent(name);
|
||
try {
|
||
const resp = await fetch(url, {
|
||
method: 'POST',
|
||
headers: body ? { 'Content-Type': 'application/x-www-form-urlencoded' } : {},
|
||
body: body ? new URLSearchParams(body) : undefined,
|
||
redirect: 'manual',
|
||
});
|
||
const ok = resp.ok || resp.type === 'opaqueredirect'
|
||
|| (resp.status >= 200 && resp.status < 400);
|
||
if (!ok) {
|
||
const text = await resp.text().catch(() => '');
|
||
alert('action failed: ' + resp.status + (text ? '\n\n' + text : ''));
|
||
}
|
||
} catch (err) {
|
||
alert('action failed: ' + err);
|
||
}
|
||
}
|
||
|
||
function buildAgentMenu(c, forgeBase) {
|
||
const wrap = el('div', { class: 'agent-menu' });
|
||
const btn = el('button', {
|
||
type: 'button',
|
||
class: 'agent-menu-btn',
|
||
title: `actions for ${c.name}`,
|
||
'aria-label': `actions for ${c.name}`,
|
||
'aria-haspopup': 'menu',
|
||
'aria-expanded': 'false',
|
||
}, '⋮');
|
||
|
||
const dropdown = el('ul', { class: 'agent-menu-dropdown', hidden: true, role: 'menu' });
|
||
|
||
function menuItem(label, opts) {
|
||
const li = el('li', { role: 'presentation' });
|
||
const item = el('button', {
|
||
type: 'button',
|
||
class: 'agent-menu-item',
|
||
role: 'menuitem',
|
||
}, label);
|
||
item.addEventListener('click', async () => {
|
||
closeAllAgentMenus();
|
||
if (opts.confirm && !confirm(opts.confirm)) return;
|
||
await agentMenuPost(opts.action, c.name, opts.body || null);
|
||
});
|
||
li.append(item);
|
||
return li;
|
||
}
|
||
|
||
function menuSep() {
|
||
return el('li', { class: 'agent-menu-sep', role: 'separator' });
|
||
}
|
||
|
||
// Navigation link item (opens in same tab by default).
|
||
function menuLink(label, href, title) {
|
||
const li = el('li', { role: 'presentation' });
|
||
const a = el('a', {
|
||
class: 'agent-menu-item',
|
||
href,
|
||
role: 'menuitem',
|
||
title: title || '',
|
||
}, label);
|
||
a.addEventListener('click', () => closeAllAgentMenus());
|
||
li.append(a);
|
||
return li;
|
||
}
|
||
|
||
// Show only actions that are applicable in the current state.
|
||
if (c.running) {
|
||
dropdown.append(
|
||
menuItem('↺ R3ST4RT', { action: '/restart/', confirm: `restart ${c.name}?` }),
|
||
menuItem('■ ST0P', { action: '/kill/', confirm: `stop ${c.name}?` }),
|
||
);
|
||
} else {
|
||
dropdown.append(
|
||
menuItem('▶ ST4RT', { action: '/start/', confirm: `start ${c.name}?` }),
|
||
);
|
||
}
|
||
dropdown.append(
|
||
menuItem('↻ R3BU1LD', { action: '/rebuild/', confirm: `rebuild ${c.name}? hot-reloads the container.` }),
|
||
menuSep(),
|
||
// Deep-link to the AGENT log tab pre-filtered to this container.
|
||
// The ?agent= param is read by logs.js on load and pre-selects this
|
||
// agent's journal without extra clicks.
|
||
menuLink('journal logs →',
|
||
`/logs.html?agent=${encodeURIComponent(c.name)}#agent`,
|
||
`view ${c.name} journal logs`),
|
||
);
|
||
|
||
{
|
||
dropdown.append(
|
||
menuSep(),
|
||
menuItem('DESTR0Y', {
|
||
action: '/destroy/',
|
||
confirm: `destroy ${c.name}? container removed; state + creds kept.`,
|
||
}),
|
||
menuItem('PURG3', {
|
||
action: '/destroy/',
|
||
body: { purge: 'on' },
|
||
confirm: `PURGE ${c.name}? WIPES container, config history, claude creds, and notes. no undo.`,
|
||
}),
|
||
);
|
||
}
|
||
|
||
if (c.deployed_sha && forgeBase) {
|
||
const li = el('li', { role: 'presentation' });
|
||
const a = el('a', {
|
||
class: 'agent-menu-item',
|
||
href: `${forgeBase}/agent-configs/${encodeURIComponent(c.name)}/commit/${c.deployed_sha}`,
|
||
target: '_blank',
|
||
rel: 'noopener',
|
||
role: 'menuitem',
|
||
title: 'deployed config commit on forge',
|
||
}, `deployed:${c.deployed_sha} ↗`);
|
||
li.append(a);
|
||
dropdown.append(menuSep(), li);
|
||
}
|
||
|
||
btn.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
const wasHidden = dropdown.hidden;
|
||
closeAllAgentMenus();
|
||
if (wasHidden) {
|
||
dropdown.hidden = false;
|
||
btn.setAttribute('aria-expanded', 'true');
|
||
wrap.classList.add('open');
|
||
openAgentMenu = dropdown;
|
||
}
|
||
});
|
||
|
||
wrap.append(btn, dropdown);
|
||
return wrap;
|
||
}
|
||
|
||
// Re-derive port conflicts from the live containers map. Mirrors the
|
||
// server-side `build_port_conflicts` so the banner reacts to event
|
||
// updates instead of waiting for a /api/state refetch.
|
||
function derivePortConflicts(containers) {
|
||
const byPort = new Map();
|
||
for (const c of containers) {
|
||
if (!byPort.has(c.port)) byPort.set(c.port, []);
|
||
byPort.get(c.port).push(c.name);
|
||
}
|
||
const out = [];
|
||
for (const [port, agents] of byPort) {
|
||
if (agents.length > 1) {
|
||
agents.sort();
|
||
out.push({ port, agents });
|
||
}
|
||
}
|
||
out.sort((a, b) => a.port - b.port);
|
||
return out;
|
||
}
|
||
|
||
// ─── state rendering ────────────────────────────────────────────────────
|
||
// ─── agent topology ─────────────────────────────────────────────────
|
||
// See docs/web-ui.md::Topology tree for the rendering contract
|
||
// (forest walk, alphabetical sort, orphan + cycle handling).
|
||
function buildAgentTree(containers) {
|
||
// Close any open context menu before replacing the DOM tree — the
|
||
// previous dropdown element would otherwise be a stale reference.
|
||
closeAllAgentMenus();
|
||
const byName = new Map();
|
||
for (const c of containers) byName.set(c.name, c);
|
||
const children = new Map(); // parent_name → [child_name, …]
|
||
const roots = [];
|
||
for (const c of containers) {
|
||
const p = c.parent || null;
|
||
if (p == null || !byName.has(p)) {
|
||
roots.push(c.name);
|
||
} else {
|
||
const list = children.get(p) || [];
|
||
list.push(c.name);
|
||
children.set(p, list);
|
||
}
|
||
}
|
||
roots.sort();
|
||
for (const list of children.values()) list.sort();
|
||
const out = [];
|
||
const visited = new Set();
|
||
function visit(name, depth, ancestorIsLast, isLast) {
|
||
if (visited.has(name)) return;
|
||
visited.add(name);
|
||
const c = byName.get(name);
|
||
if (!c) return;
|
||
out.push({ container: c, depth, ancestorIsLast: [...ancestorIsLast], isLast });
|
||
const kids = children.get(name) || [];
|
||
kids.forEach((kid, i) =>
|
||
visit(kid, depth + 1, [...ancestorIsLast, isLast], i === kids.length - 1));
|
||
}
|
||
roots.forEach((name, i) => visit(name, 0, [], i === roots.length - 1));
|
||
// Cycle safety: anything not reached lands at root level so no
|
||
// agent silently disappears when a config is malformed.
|
||
for (const c of containers) {
|
||
if (!visited.has(c.name)) visit(c.name, 0, [], true);
|
||
}
|
||
return out;
|
||
}
|
||
// Builds the .tree-prefix DOM for a row at the given depth.
|
||
// See docs/web-ui.md::Topology tree for why this is DOM-painted
|
||
// (one positioned <span> per lane) rather than text-glyph-painted.
|
||
function treePrefixDom({ depth, ancestorIsLast, isLast }) {
|
||
if (depth === 0) return null;
|
||
const prefix = el('span', { class: 'tree-prefix', 'aria-hidden': 'true' });
|
||
// Ancestor columns (depth 1..depth-1). Skip depth 0 (root has no
|
||
// continuation column — top-level rows are separated visually as
|
||
// top-level rows already).
|
||
for (let d = 1; d < depth; d++) {
|
||
const cls = ancestorIsLast[d] ? 'tree-lane lane-blank' : 'tree-lane lane-line';
|
||
prefix.append(el('span', { class: cls }));
|
||
}
|
||
const jointCls = 'tree-lane lane-joint ' + (isLast ? 'lane-joint-last' : 'lane-joint-branch');
|
||
prefix.append(el('span', { class: jointCls }));
|
||
return prefix;
|
||
}
|
||
|
||
// Serialise the visible state of a container row into a stable string
|
||
// for change-detection. Includes everything that affects what the row
|
||
// renders — container fields, derived pending/selection state, tree
|
||
// position, and link-base context. The async dashboard-state (nav
|
||
// strip, ctx badge, status text) is intentionally excluded: it
|
||
// populates in-place and is preserved when a row is reused.
|
||
function containerRowFingerprint(c, node, pending, opRunning, selected,
|
||
askerCount, targetCount, gatewayLinks, hostname) {
|
||
return JSON.stringify({
|
||
running: c.running,
|
||
needs_login: c.needs_login,
|
||
needs_update: c.needs_update,
|
||
pending_reminders: c.pending_reminders,
|
||
port: c.port,
|
||
pending,
|
||
opRunning,
|
||
selected,
|
||
askerCount,
|
||
targetCount,
|
||
depth: node.depth,
|
||
isLast: node.isLast,
|
||
ancestorIsLast: node.ancestorIsLast,
|
||
gatewayLinks,
|
||
hostname,
|
||
});
|
||
}
|
||
|
||
// Build a single container-row <li> from scratch. Extracted so
|
||
// renderContainers can call this only for rows whose fingerprint
|
||
// changed (keyed cache), skipping the build + async dashboard-state
|
||
// fetch for stable rows.
|
||
function buildContainerLi(c, node, opts) {
|
||
const {
|
||
pending, opRunning, selected,
|
||
askerCount, targetCount, agentQCount,
|
||
url, containerBase, forgeBase, s,
|
||
} = opts;
|
||
const li = el('li', {
|
||
class: 'container-row'
|
||
+ (pending ? ' pending' : '')
|
||
+ (opRunning ? ' pending-running' : '')
|
||
+ (selected ? ' selected' : ''),
|
||
});
|
||
// Topology: depth contributes left-padding; the glyph string in
|
||
// the .tree-prefix span draws the ├─ / └─ joint + continuation
|
||
// lines (`│ `) for ancestors whose subtree extends below this
|
||
// row. Both are CSS-driven from the data attributes so the
|
||
// legacy flat layout (every container at depth 0) is bit-
|
||
// identical to today's render — no glyph, no indent.
|
||
if (node.depth > 0) li.dataset.depth = String(node.depth);
|
||
const prefix = treePrefixDom(node);
|
||
if (prefix) li.prepend(prefix);
|
||
|
||
// Agent icon: 5em square wrapper with an absolutely-positioned
|
||
// <img> + fire-and-forget load with /favicon.svg fallback. The
|
||
// wrapper doubles as the selection toggle (click / keyboard).
|
||
// See docs/web-ui.md::Container row for the layout + load-strategy
|
||
// rationale.
|
||
const iconImg = el('img', { class: 'container-icon-img', alt: '' });
|
||
const icon = el('div', {
|
||
class: 'container-icon',
|
||
role: 'button',
|
||
tabindex: '0',
|
||
'aria-pressed': selected ? 'true' : 'false',
|
||
title: selected
|
||
? `deselect ${c.name} (or press Esc to clear all)`
|
||
: `select ${c.name} for bulk actions`,
|
||
}, iconImg);
|
||
icon.addEventListener('click', (e) => {
|
||
e.preventDefault();
|
||
toggleSelection(c.name);
|
||
});
|
||
icon.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter' || e.key === ' ') {
|
||
e.preventDefault();
|
||
toggleSelection(c.name);
|
||
}
|
||
});
|
||
if (c.running) {
|
||
iconImg.src = `${url}icon`;
|
||
iconImg.addEventListener('error', () => {
|
||
if (iconImg.dataset.fallback) return; // guard: don't loop if the favicon itself 404s
|
||
iconImg.dataset.fallback = '1';
|
||
icon.classList.add('icon-unreachable');
|
||
iconImg.src = '/favicon.svg';
|
||
});
|
||
} else {
|
||
// Container stopped — skip the doomed `${url}icon` fetch and
|
||
// go straight to the dimmed hyperhive mark. Avoids a noisy
|
||
// failed request in the console + the brief broken-image flash.
|
||
icon.classList.add('icon-unreachable');
|
||
iconImg.src = '/favicon.svg';
|
||
}
|
||
// Card body: the three stacked content lines, right of the icon.
|
||
const body = el('div', { class: 'card-body' });
|
||
|
||
// ── identity ─────────────────────────────────────────────────
|
||
const head = el('div', { class: 'head' });
|
||
head.append(
|
||
el('a', { class: 'name', href: url, target: '_blank', rel: 'noopener' }, c.name),
|
||
);
|
||
// Icon-only nav strip — populated async from the agent's own
|
||
// `GET /api/dashboard-state` (via gateway when enabled, direct
|
||
// TCP otherwise). The agent is the single source of truth for its
|
||
// link list: stats / screen (GUI agents only — c0re's disk-based
|
||
// fallback cannot detect this) / forge profile / agent-configs /
|
||
// extras. DOM-built — link strings come from the agent's process
|
||
// and must never reach the HTML parser.
|
||
const navStrip = el('span', { class: 'nav-strip' });
|
||
head.append(navStrip);
|
||
if (c.running) {
|
||
// Fetch the lean dashboard-state snapshot from the agent directly.
|
||
// Populates: nav strip links (including the screen link that
|
||
// c0re's disk-based build cannot detect), rate_limited badge,
|
||
// ctx-window badge, and self-reported status text.
|
||
// Fails gracefully when the agent is starting up or the gateway
|
||
// is not yet routing to it — badges simply don't appear.
|
||
// Only runs when the row is first built (fingerprint changed) —
|
||
// reused rows keep their previously-fetched nav strip + badges.
|
||
fetch(`${containerBase}/api/dashboard-state`)
|
||
.then((r) => (r.ok ? r.json() : null))
|
||
.then((ds) => {
|
||
if (!ds) return;
|
||
// ── nav strip ───────────────────────────────────────────
|
||
if (Array.isArray(ds.links)) {
|
||
for (const lnk of ds.links) {
|
||
const href = lnk.kind === 'forge' ? forgeBase + (lnk.url || '')
|
||
: lnk.kind === 'external' ? (lnk.url || '')
|
||
: /* container */ containerBase + '/' + (lnk.url || '');
|
||
const a = el('a', {
|
||
class: 'nav-link',
|
||
href,
|
||
target: '_blank',
|
||
rel: 'noopener',
|
||
title: lnk.label || '',
|
||
});
|
||
// Plain text — agent-controlled strings stay out of innerHTML.
|
||
a.textContent = lnk.icon || lnk.label || '';
|
||
navStrip.append(a);
|
||
}
|
||
}
|
||
// ── agent-owned status badges ────────────────────────────
|
||
// rate_limited: only show when no other critical badge is
|
||
// already shown (pending / not-running already handled sync).
|
||
if (ds.rate_limited) {
|
||
head.append(el('span',
|
||
{ class: 'badge badge-rate-limited', title: 'API rate-limited — harness is parked, will retry automatically' },
|
||
'⊘ rate limited'));
|
||
}
|
||
// ctx-window badge
|
||
if (ds.ctx_tokens != null) {
|
||
const k = Math.round(ds.ctx_tokens / 1000);
|
||
const win = ds.context_window_tokens;
|
||
const warn = win != null ? win * CTX_WARN_FRACTION : CTX_WARN_TOKENS;
|
||
const caution = win != null ? win * CTX_CAUTION_FRACTION : CTX_CAUTION_TOKENS;
|
||
const ctxClass = ds.ctx_tokens >= warn ? 'badge-ctx-warn'
|
||
: ds.ctx_tokens >= caution ? 'badge-ctx-caution'
|
||
: 'badge-ctx-ok';
|
||
const title = win != null
|
||
? `last turn context: ${ds.ctx_tokens.toLocaleString()} / ${win.toLocaleString()} `
|
||
+ `tokens (${Math.round((ds.ctx_tokens / win) * 100)}% of the window)`
|
||
: `last turn context size: ${ds.ctx_tokens.toLocaleString()} tokens`;
|
||
head.append(el('span', { class: `badge ${ctxClass}`, title }, `ctx·${k}k`));
|
||
}
|
||
// ── agent status text (self-reported via set_status) ─────
|
||
if (ds.status_text) {
|
||
const nowUnix = Math.floor(Date.now() / 1000);
|
||
const ageStr = ds.status_set_at != null
|
||
? ` (set ${fmtAgeSecs(nowUnix - ds.status_set_at)} ago)` : '';
|
||
// Stamp data-set-at so the 30s ticker below keeps the age
|
||
// label current even when the row is reused from the cache.
|
||
const ageAttrs = ds.status_set_at != null
|
||
? { class: 'status-age', 'data-set-at': String(ds.status_set_at) }
|
||
: { class: 'status-age' };
|
||
body.append(el('div', {
|
||
class: 'agent-status',
|
||
title: `agent self-reported status${ageStr}`,
|
||
},
|
||
el('span', { class: 'status-icon' }, '◈ '),
|
||
ds.status_text,
|
||
el('span', ageAttrs, ageStr),
|
||
));
|
||
}
|
||
})
|
||
.catch(() => { /* graceful: agent starting / gateway miss → no data */ });
|
||
}
|
||
// Status / runtime badges. Pending transients always win
|
||
// (start / stop / restart / rebuild is in progress). Otherwise,
|
||
// when the container is stopped, surface a single `■ not running`
|
||
// badge. `needs_login` is still c0re-owned (reads auth sentinel
|
||
// files on the host). rate_limited / ctx / status_text are
|
||
// agent-owned and rendered by the async dashboard-state fetch above.
|
||
if (pending) {
|
||
head.append(el('span', { class: 'pending-state' },
|
||
el('span', { class: 'spinner' }, '◐'), ' ', pending + '…'));
|
||
} else if (!c.running) {
|
||
head.append(el('span',
|
||
{ class: 'badge badge-muted', title: 'container is shut down — start it to bring the harness back up' },
|
||
'■ not running'));
|
||
} else if (c.needs_login) {
|
||
head.append(el('a',
|
||
{ class: 'badge badge-warn', href: url, target: '_blank', rel: 'noopener' },
|
||
'needs login →'));
|
||
}
|
||
if (c.needs_update) {
|
||
head.append(form(
|
||
'/rebuild/' + c.name, 'badge badge-warn btn-inline', 'needs update ↻',
|
||
'rebuild ' + c.name + '? hot-reloads the container.',
|
||
{}, { noRefresh: true },
|
||
));
|
||
}
|
||
|
||
if (c.pending_reminders && c.pending_reminders > 0) {
|
||
head.append(el('span',
|
||
{
|
||
class: 'badge badge-reminder',
|
||
title: 'pending reminders queued for this agent — see the reminders section to view / cancel',
|
||
},
|
||
`⏰ ${c.pending_reminders}`));
|
||
}
|
||
// Pending questions where this agent is the asker (awaiting an
|
||
// answer) or the target (owes a reply). Derived live from
|
||
// questionsState so the badge updates instantly on QuestionAdded /
|
||
// QuestionResolved without a separate backend field.
|
||
if (agentQCount > 0) {
|
||
const parts = [];
|
||
if (askerCount > 0) parts.push(`${askerCount} asked`);
|
||
if (targetCount > 0) parts.push(`${targetCount} to answer`);
|
||
head.append(el('span',
|
||
{
|
||
class: 'badge badge-loose-ends',
|
||
title: `pending questions: ${parts.join(', ')} — see the Q33R1ES tab`,
|
||
},
|
||
`❓ ${agentQCount}`));
|
||
}
|
||
body.append(head);
|
||
|
||
// Per-card action buttons (R3ST4RT / ST0P / ST4RT / R3BU1LD /
|
||
// DESTR0Y / PURG3) moved to the selection bar — see
|
||
// docs/web-ui.md::Selection bar. The contextual `needs update ↻`
|
||
// chip in the head row stays — it's a state-hint, not an
|
||
// action button.
|
||
|
||
li.append(icon, body, buildAgentMenu(c, forgeBase));
|
||
return li;
|
||
}
|
||
|
||
function renderContainers(s) {
|
||
const root = $('containers-section');
|
||
// #containers-section only exists on /index.html. tabs.js is the
|
||
// bundle for that page only (flow.html loads flow.js instead).
|
||
// 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.
|
||
if (!root) return;
|
||
|
||
// Containers come from the derived map (event-driven) rather than
|
||
// `s.containers`; `s` still supplies hostname (for the web-ui
|
||
// link) and tombstones/meta_inputs (not event-derived yet). The
|
||
// tree builder handles the ordering — we don't pre-sort here.
|
||
const containers = Array.from(containersState.values());
|
||
const portConflicts = derivePortConflicts(containers);
|
||
const anyStale = containers.some((c) => c.needs_update);
|
||
|
||
// Preserve the keyed container list across the header-section
|
||
// rebuild. We wipe the banners/buttons above the list on every
|
||
// render (simple), but recycle <li> elements for unchanged rows
|
||
// (keyed) to avoid full-DOM thrash and redundant async fetches.
|
||
const existingUl = root.querySelector('ul.containers');
|
||
root.replaceChildren();
|
||
|
||
// Port-hash collisions: rename one of the listed agents and
|
||
// rebuild. The banner sits above the agent list so it's the
|
||
// first thing the operator sees when something's wedged.
|
||
if (portConflicts.length) {
|
||
const banner = el('div', { class: 'port-conflict' },
|
||
el('strong', {}, '⚠ port collision'), ' — ');
|
||
const groups = portConflicts.map((c) =>
|
||
`:${c.port} (${c.agents.join(' + ')})`).join('; ');
|
||
banner.append(groups + '. rename one of each and ↻ R3BU1LD.');
|
||
root.append(banner);
|
||
}
|
||
|
||
if (anyStale) {
|
||
root.append(form(
|
||
'/update-all', 'btn-rebuild', '↻ UPD4TE 4LL',
|
||
'rebuild every stale container?',
|
||
{}, { noRefresh: true },
|
||
));
|
||
}
|
||
|
||
if (transientsState.size) {
|
||
const ul = el('ul');
|
||
const nowUnix = Math.floor(Date.now() / 1000);
|
||
for (const [name, t] of transientsState) {
|
||
const secs = Math.max(0, nowUnix - t.since_unix);
|
||
ul.append(el('li', {},
|
||
el('span', { class: 'glyph spinner' }, '◐'), ' ',
|
||
el('span', { class: 'agent' }, name), ' ',
|
||
el('span', { class: 'role role-pending' }, t.kind + '…'), ' ',
|
||
el('span', { class: 'meta' }, `nixos-container create + start (${secs}s)`),
|
||
));
|
||
}
|
||
root.append(ul);
|
||
}
|
||
|
||
if (!containers.length && !transientsState.size) {
|
||
root.append(el('p', { class: 'empty' }, 'no managed containers'));
|
||
return;
|
||
}
|
||
|
||
// Drop stale selections (agent destroyed while selected). Defensive —
|
||
// the action bar would otherwise loop POST against a gone agent.
|
||
const liveNames = new Set(containers.map((c) => c.name));
|
||
for (const n of Array.from(selectionState)) {
|
||
if (!liveNames.has(n)) selectionState.delete(n);
|
||
}
|
||
|
||
const hostname = (s && s.hostname) || window.location.hostname;
|
||
// When hive-gateway is in front of the dashboard, build same-origin
|
||
// `/agent/<name>/` URLs instead of the direct `http://<host>:<port>/`
|
||
// TCP fallback — the gateway proxies the prefix to the per-agent
|
||
// harness (TCP via `agent-ports.json` or unix-domain via
|
||
// `agent-sockets.json`). See
|
||
// `docs/web-ui.md::Container row` + `docs/gateway.md::Vhost map`.
|
||
const gatewayLinks = !!(s && s.gateway_enabled);
|
||
// Forge public URL: prefer state.forge_public_url (set by the NixOS
|
||
// module when forge.behindGateway=true), fall back to
|
||
// "<hostname>:3000" for gateway-off / local-dev deploys.
|
||
const forgeBase = (s && s.forge_public_url) || `http://${hostname}:3000`;
|
||
const ul = existingUl ?? el('ul', { class: 'containers' });
|
||
const tree = buildAgentTree(containers);
|
||
// In-flight rebuild / meta-update / destroy ops per agent name —
|
||
// see docs/web-ui.md::Container row for the building… badge
|
||
// rationale (covers the SYST3M-shows-rebuild-but-SW4RM-shows-stopped
|
||
// gap when no operator transient is set).
|
||
const inFlight = inFlightOpsByAgent();
|
||
|
||
// Build the ordered list of <li> elements, reusing cached rows
|
||
// whose displayed state hasn't changed.
|
||
const orderedLis = [];
|
||
for (const node of tree) {
|
||
const c = node.container;
|
||
const url = gatewayLinks
|
||
? `/agent/${encodeURIComponent(c.name)}/`
|
||
: `http://${hostname}:${c.port}/`;
|
||
// Container nav-strip base: gateway prefix or direct TCP.
|
||
const containerBase = gatewayLinks
|
||
? `/agent/${encodeURIComponent(c.name)}`
|
||
: `http://${hostname}:${c.port}`;
|
||
// Pending-state derivation + queued-vs-running split — see
|
||
// docs/web-ui.md::Container row for the transient → in-flight
|
||
// queue priority order and the opRunning rationale.
|
||
const transientKind = transientsState.get(c.name)?.kind || null;
|
||
const op = !transientKind ? inFlight.get(c.name) : null;
|
||
const pending = transientKind
|
||
|| (op && (op.state === 'running'
|
||
? (op.kind === 'meta_update' ? 'meta-updating'
|
||
: op.kind === 'destroy' ? 'destroying'
|
||
: op.kind === 'restart' ? 'restarting'
|
||
: 'rebuilding')
|
||
: (op.kind === 'meta_update' ? 'meta-update queued'
|
||
: op.kind === 'destroy' ? 'destroy queued'
|
||
: op.kind === 'restart' ? 'restart queued'
|
||
: 'rebuild queued')));
|
||
const opRunning = transientKind != null
|
||
|| (op != null && op.state === 'running');
|
||
const selected = selectionState.has(c.name);
|
||
// Pending questions where this agent is the asker (awaiting an
|
||
// answer) or the target (owes a reply). Derived live from
|
||
// questionsState so the badge updates instantly on QuestionAdded /
|
||
// QuestionResolved without a separate backend field.
|
||
const askerCount = questionsState.pending.filter((q) => q.asker === c.name).length;
|
||
const targetCount = questionsState.pending.filter((q) => q.target === c.name).length;
|
||
const agentQCount = questionsState.pending.filter(
|
||
(q) => q.asker === c.name || q.target === c.name,
|
||
).length;
|
||
|
||
const fp = containerRowFingerprint(c, node, pending, opRunning, selected,
|
||
askerCount, targetCount, gatewayLinks, hostname);
|
||
const cached = containerRowCache.get(c.name);
|
||
|
||
let li;
|
||
if (cached && cached.fingerprint === fp) {
|
||
// Row unchanged — reuse the existing DOM node. The async
|
||
// dashboard-state (nav strip, ctx badge, status text) stays
|
||
// intact from the previous build, avoiding a redundant fetch.
|
||
li = cached.el;
|
||
} else {
|
||
li = buildContainerLi(c, node, {
|
||
pending, opRunning, selected,
|
||
askerCount, targetCount, agentQCount,
|
||
url, containerBase, forgeBase, s,
|
||
});
|
||
containerRowCache.set(c.name, { el: li, fingerprint: fp });
|
||
}
|
||
orderedLis.push(li);
|
||
}
|
||
|
||
// Remove cache entries for agents that no longer exist.
|
||
for (const [name, entry] of containerRowCache) {
|
||
if (!liveNames.has(name)) {
|
||
entry.el.remove();
|
||
containerRowCache.delete(name);
|
||
}
|
||
}
|
||
|
||
// Apply correct DOM order without a full wipe. insertBefore is a
|
||
// no-op when the node is already at position i, so stable sections
|
||
// of the list cause zero layout work.
|
||
for (let i = 0; i < orderedLis.length; i++) {
|
||
if (ul.children[i] !== orderedLis[i]) {
|
||
ul.insertBefore(orderedLis[i], ul.children[i] ?? null);
|
||
}
|
||
}
|
||
// Trim any excess children (defensive — shouldn't happen after the
|
||
// cache-removal pass above, but keeps the ul length exact).
|
||
while (ul.children.length > orderedLis.length) ul.lastChild.remove();
|
||
|
||
root.append(ul);
|
||
renderSelectionBar(containers);
|
||
}
|
||
|
||
// ─── selection bar ──────────────────────────────────────────────────
|
||
// Sticky-bottom strip; visible when ≥1 agent selected on the SW4RM
|
||
// tab. See docs/web-ui.md::Selection bar for the interaction model
|
||
// and the per-action availability rules (disabled-with-tooltip for
|
||
// actions that don't apply to the full selection). Actions POST per
|
||
// agent in a loop (endpoints are individually idempotent /
|
||
// event-covered, so no new bulk backend wire is needed).
|
||
function renderSelectionBar(containers) {
|
||
const bar = $('selection-bar');
|
||
if (!bar) return;
|
||
const countSpan = $('selection-count');
|
||
const namesSpan = $('selection-names');
|
||
const actions = $('selection-actions');
|
||
if (!countSpan || !namesSpan || !actions) return;
|
||
|
||
const selected = containers.filter((c) => selectionState.has(c.name));
|
||
// Tab-gate: bar only renders on SW4RM (the only tab with agent
|
||
// cards to cross-reference). Selection state lives in-memory and
|
||
// the bar reappears on return to SW4RM if still non-empty.
|
||
const onSwarmTab = (document.body.dataset.activeTab || 'swarm') === 'swarm';
|
||
if (!selected.length || !onSwarmTab) {
|
||
bar.hidden = true;
|
||
document.body.classList.remove('has-selection');
|
||
return;
|
||
}
|
||
bar.hidden = false;
|
||
document.body.classList.add('has-selection');
|
||
countSpan.textContent = selected.length === 1
|
||
? '1 agent selected'
|
||
: selected.length + ' agents selected';
|
||
namesSpan.textContent = '· ' + selected.map((c) => c.name).join(', ');
|
||
|
||
// Recompute action availability + tooltips per render. Each
|
||
// action declares which agents it CAN'T run on; the bar disables
|
||
// the button and surfaces the offending names in the tooltip.
|
||
actions.replaceChildren();
|
||
const allRunning = selected.every((c) => c.running);
|
||
const allStopped = selected.every((c) => !c.running);
|
||
|
||
const stoppedNames = selected.filter((c) => !c.running).map((c) => c.name);
|
||
const runningNames = selected.filter((c) => c.running).map((c) => c.name);
|
||
|
||
function why(label, blockers) {
|
||
if (!blockers.length) return null;
|
||
return `${label} not available — ${blockers.join(', ')} ${blockers.length === 1 ? 'is' : 'are'} blocking it`;
|
||
}
|
||
|
||
addBulkButton(actions, 'btn-restart', '↺ R3ST4RT', allRunning, selected, {
|
||
action: '/restart/',
|
||
confirm: (names) => `restart ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})?`,
|
||
disabledTitle: why('↺ R3ST4RT', stoppedNames.map((n) => `\`${n}\` is stopped`)),
|
||
});
|
||
addBulkButton(actions, 'btn-stop', '■ ST0P', allRunning, selected, {
|
||
action: '/kill/',
|
||
confirm: (names) => `stop ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})?`,
|
||
disabledTitle: why('■ ST0P', stoppedNames.map((n) => `\`${n}\` is already stopped`)),
|
||
});
|
||
addBulkButton(actions, 'btn-start', '▶ ST4RT', allStopped, selected, {
|
||
action: '/start/',
|
||
confirm: (names) => `start ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})?`,
|
||
disabledTitle: why('▶ ST4RT', runningNames.map((n) => `\`${n}\` is already running`)),
|
||
});
|
||
addBulkButton(actions, 'btn-rebuild', '↻ R3BU1LD', true, selected, {
|
||
action: '/rebuild/',
|
||
confirm: (names) => `rebuild ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})? hot-reloads each container.`,
|
||
});
|
||
addBulkButton(actions, 'btn-destroy', 'DESTR0Y', true, selected, {
|
||
action: '/destroy/',
|
||
confirm: (names) => `destroy ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})? containers are removed; state + creds kept.`,
|
||
});
|
||
addBulkButton(actions, 'btn-destroy', 'PURG3', true, selected, {
|
||
action: '/destroy/',
|
||
body: { purge: 'on' },
|
||
confirm: (names) => `PURGE ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})? containers, config history, claude creds, and notes are all WIPED. no undo.`,
|
||
});
|
||
|
||
// Move agent(s) in the topology tree — selecting an option in the
|
||
// M0V3 dropdown immediately confirms + executes the move. "(no parent)"
|
||
// promotes to root (empty new_parent on the backend). Cycle-safe:
|
||
// dropdown filters out self and descendants on the client side; the
|
||
// backend rechecks via `topology::set_parent`.
|
||
//
|
||
// Backend: POST /api/topology/set-parent (dashboard.rs),
|
||
// form-encoded `child=<name>&new_parent=<target-or-empty>`. Re-emits
|
||
// container snapshots on success so the tree repaints without a
|
||
// separate refresh.
|
||
addMoveActions(actions, selected, containers);
|
||
}
|
||
|
||
// Render the M0V3 picker in the selection bar. Selecting any real option
|
||
// (including "(no parent)") immediately fires a confirm + POST — no
|
||
// separate button. Backend `topology::set_parent` refuses invalid moves
|
||
// and the refusal surfaces in the alert roll-up.
|
||
function addMoveActions(parent, selected, containers) {
|
||
const candidates = validReparentCandidates(selected, containers);
|
||
const wrap = el('span', { class: 'move-picker' });
|
||
const selectTitle = selected.length === 1
|
||
? `change ${selected[0].name}'s parent`
|
||
: `change ${selected.length} agents' parent`;
|
||
const sel = el('select', { class: 'move-picker-select', title: selectTitle });
|
||
sel.append(el('option', { value: '' }, '⇢ M0V3 →'));
|
||
// "(no parent)" → empty new_parent on the backend (promotes to root).
|
||
sel.append(el('option', { value: '__root__' }, '(no parent)'));
|
||
for (const name of candidates) {
|
||
sel.append(el('option', { value: name }, name));
|
||
}
|
||
sel.addEventListener('change', async () => {
|
||
if (sel.selectedIndex === 0) return;
|
||
const newParent = sel.value === '__root__' ? '' : sel.value;
|
||
const newParentLabel = sel.value === '__root__' ? '(no parent)' : sel.value;
|
||
const names = selected.map((c) => c.name);
|
||
const promptMsg = names.length === 1
|
||
? `move ${names[0]} → ${newParentLabel}?`
|
||
: `move ${names.length} agents (${names.join(', ')}) → ${newParentLabel}?`;
|
||
if (!confirm(promptMsg)) {
|
||
sel.selectedIndex = 0;
|
||
return;
|
||
}
|
||
sel.disabled = true;
|
||
const failures = [];
|
||
if (names.length === 1) {
|
||
// Single agent — use the form-encoded endpoint (backwards compat).
|
||
try {
|
||
const body = new URLSearchParams({ child: names[0], new_parent: newParent });
|
||
const resp = await fetch('/api/topology/set-parent', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body,
|
||
redirect: 'manual',
|
||
});
|
||
const ok = resp.ok || resp.type === 'opaqueredirect'
|
||
|| (resp.status >= 200 && resp.status < 400);
|
||
if (!ok) {
|
||
const text = await resp.text().catch(() => '');
|
||
failures.push(`${names[0]}: http ${resp.status}${text ? ' — ' + text.slice(0, 200) : ''}`);
|
||
}
|
||
} catch (err) {
|
||
failures.push(`${names[0]}: ${err}`);
|
||
}
|
||
} else {
|
||
// Multiple agents — use the bulk endpoint so all moves land in
|
||
// a single git commit instead of one per agent.
|
||
try {
|
||
const payload = names.map((n) => ({ child: n, new_parent: newParent || null }));
|
||
const resp = await fetch('/api/topology/set-parent-bulk', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload),
|
||
redirect: 'manual',
|
||
});
|
||
const ok = resp.ok || resp.type === 'opaqueredirect'
|
||
|| (resp.status >= 200 && resp.status < 400);
|
||
if (!ok) {
|
||
const text = await resp.text().catch(() => '');
|
||
failures.push(`bulk: http ${resp.status}${text ? ' — ' + text.slice(0, 200) : ''}`);
|
||
}
|
||
} catch (err) {
|
||
failures.push(`bulk: ${err}`);
|
||
}
|
||
}
|
||
sel.disabled = false;
|
||
sel.selectedIndex = 0;
|
||
if (failures.length) {
|
||
alert(`M0V3 completed with ${failures.length} failure${failures.length === 1 ? '' : 's'}:\n\n` + failures.join('\n'));
|
||
}
|
||
});
|
||
wrap.append(sel);
|
||
parent.append(wrap);
|
||
}
|
||
|
||
// Filter the dashboard's container list to those that are valid
|
||
// re-parent targets for the `selected` agents: anyone who isn't IN
|
||
// the selection itself, isn't a descendant of any selected agent
|
||
// (cycle prevention across the whole batch). The backend re-checks
|
||
// per-agent via `topology::set_parent`; this client-side filter is
|
||
// purely UX so the operator can't pick an obviously-invalid option.
|
||
function validReparentCandidates(selected, containers) {
|
||
// Build child map once.
|
||
const childrenOf = new Map();
|
||
for (const c of containers) {
|
||
const p = c.parent || null;
|
||
if (!childrenOf.has(p)) childrenOf.set(p, []);
|
||
childrenOf.get(p).push(c.name);
|
||
}
|
||
// Union descendant set across every selected agent (each agent's
|
||
// descendants AND itself).
|
||
const blocked = new Set();
|
||
for (const t of selected) {
|
||
const queue = [t.name];
|
||
blocked.add(t.name);
|
||
while (queue.length) {
|
||
const n = queue.shift();
|
||
for (const child of (childrenOf.get(n) || [])) {
|
||
if (blocked.has(child)) continue;
|
||
blocked.add(child);
|
||
queue.push(child);
|
||
}
|
||
}
|
||
}
|
||
return containers
|
||
.filter((c) => !blocked.has(c.name))
|
||
.map((c) => c.name)
|
||
.sort();
|
||
}
|
||
|
||
function addBulkButton(parent, btnClass, label, enabled, selected, opts) {
|
||
const names = selected.map((c) => c.name);
|
||
const btn = el('button', {
|
||
type: 'button',
|
||
class: 'btn ' + btnClass,
|
||
}, label);
|
||
if (!enabled) {
|
||
btn.disabled = true;
|
||
if (opts.disabledTitle) btn.title = opts.disabledTitle;
|
||
}
|
||
btn.addEventListener('click', async () => {
|
||
if (btn.disabled) return;
|
||
const msg = opts.confirm(names);
|
||
if (msg && !confirm(msg)) return;
|
||
btn.disabled = true;
|
||
const original = btn.innerHTML;
|
||
btn.innerHTML = '<span class="spinner">◐</span> ' + label;
|
||
const failures = [];
|
||
// Sequential POSTs to keep server-side serialisation predictable
|
||
// (rebuild_queue dedups but other endpoints don't); the loop is
|
||
// short — bulk selections are typically a handful of agents.
|
||
//
|
||
// Two URL shapes:
|
||
// - `opts.action` is a path prefix and the agent name gets
|
||
// appended (lifecycle endpoints: /start/<name>, /rebuild/<name>).
|
||
// `opts.body` is a static object applied to every POST.
|
||
// - `opts.perAgentBodyFor(name)` is set: `opts.action` is the
|
||
// full URL (no name appended) and the per-agent body comes
|
||
// from the callback. Used by /api/topology/set-parent, where
|
||
// the agent name is a body field rather than a URL component.
|
||
for (const name of names) {
|
||
const body = opts.perAgentBodyFor
|
||
? new URLSearchParams(opts.perAgentBodyFor(name))
|
||
: new URLSearchParams(opts.body || {});
|
||
const url = opts.perAgentBodyFor
|
||
? opts.action
|
||
: opts.action + encodeURIComponent(name);
|
||
try {
|
||
const resp = await fetch(url, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body,
|
||
redirect: 'manual',
|
||
});
|
||
const ok = resp.ok || resp.type === 'opaqueredirect'
|
||
|| (resp.status >= 200 && resp.status < 400);
|
||
if (!ok) {
|
||
const text = await resp.text().catch(() => '');
|
||
failures.push(`${name}: http ${resp.status}${text ? ' — ' + text.slice(0, 200) : ''}`);
|
||
}
|
||
} catch (err) {
|
||
failures.push(`${name}: ${err}`);
|
||
}
|
||
}
|
||
btn.disabled = false;
|
||
btn.innerHTML = original;
|
||
if (failures.length) {
|
||
alert(`${label} completed with ${failures.length} failure${failures.length === 1 ? '' : 's'}:\n\n` + failures.join('\n'));
|
||
}
|
||
// Container-lifecycle events (ContainerStateChanged /
|
||
// ContainerRemoved / RebuildQueueChanged) flow over the existing
|
||
// SSE channel and update the derived stores live — no manual
|
||
// refresh needed.
|
||
});
|
||
parent.append(btn);
|
||
}
|
||
|
||
function renderTombstones(s) {
|
||
const root = $('tombstones-section');
|
||
// #tombstones-section only lives on /index.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);
|
||
}
|
||
|
||
// ── tool-groups (permissions) table ─────────────────────────────────────
|
||
// Fetched from GET /api/tool-groups on system tab activation and after
|
||
// each save. Groups (columns) come from the backend so the UI doesn't
|
||
// need updating when a new group is added. Live updates via
|
||
// `capabilities_changed` / `tool_groups_changed` SSE events fired
|
||
// after the rebuild-queue worker commits the perm JSON file.
|
||
function applyCapabilitiesChanged(ev) {
|
||
const root = $('capabilities-section');
|
||
if (!root) return;
|
||
// Skip re-render while operator has a checkbox focused in this
|
||
// section — the tab-activation re-fetch is the recovery path.
|
||
if (root.contains(document.activeElement)) return;
|
||
renderCapabilities(root, ev);
|
||
}
|
||
function applyToolGroupsChanged(ev) {
|
||
const root = $('tool-groups-section');
|
||
if (!root) return;
|
||
if (root.contains(document.activeElement)) return;
|
||
renderToolGroups(root, ev);
|
||
}
|
||
|
||
async function fetchAndRenderCapabilities() {
|
||
const root = $('capabilities-section');
|
||
if (!root) return;
|
||
root.replaceChildren();
|
||
root.append(el('p', { class: 'meta' }, 'loading…'));
|
||
try {
|
||
const resp = await fetch('/api/capabilities');
|
||
if (!resp.ok) throw new Error('http ' + resp.status);
|
||
const data = await resp.json();
|
||
renderCapabilities(root, data);
|
||
} catch (err) {
|
||
root.replaceChildren();
|
||
root.append(el('p', { class: 'meta' }, 'fetch failed: ' + err));
|
||
}
|
||
}
|
||
|
||
function renderCapabilities(root, data) {
|
||
root.replaceChildren();
|
||
const { caps, descriptions = {}, assignments } = data;
|
||
if (!caps || !caps.length) {
|
||
root.append(el('p', { class: 'meta' }, '(no capabilities defined)'));
|
||
return;
|
||
}
|
||
|
||
// Agent names: union of live containers + keys already in assignments.
|
||
const agentNames = [...new Set([
|
||
...Array.from(containersState.keys()),
|
||
...Object.keys(assignments),
|
||
])].sort();
|
||
|
||
if (!agentNames.length) {
|
||
root.append(el('p', { class: 'meta' }, '(no agents)'));
|
||
return;
|
||
}
|
||
|
||
const wrap = el('div', { class: 'cap-table-wrap' });
|
||
const table = el('table', { class: 'cap-table' });
|
||
|
||
// Header row.
|
||
const thead = el('thead');
|
||
const hrow = el('tr');
|
||
hrow.append(el('th', { class: 'cap-agent-col' }, 'agent'));
|
||
for (const c of caps) {
|
||
hrow.append(el('th', { class: 'cap-col', title: descriptions[c] || c }, c));
|
||
}
|
||
hrow.append(el('th', { class: 'cap-save-col' }, ''));
|
||
thead.append(hrow);
|
||
table.append(thead);
|
||
|
||
const tbody = el('tbody');
|
||
for (const name of agentNames) {
|
||
const assigned = assignments[name] || [];
|
||
const tr = el('tr', { class: 'cap-row' });
|
||
|
||
// Agent name cell.
|
||
tr.append(el('td', { class: 'cap-agent-col' },
|
||
el('span', { class: 'cap-agent-name' }, name)));
|
||
|
||
// One checkbox per capability.
|
||
const checkboxes = [];
|
||
for (const c of caps) {
|
||
const checked = assigned.includes(c);
|
||
const td = el('td', { class: 'cap-col' });
|
||
const cb = el('input', {
|
||
type: 'checkbox',
|
||
class: 'cap-cb',
|
||
'data-cap': c,
|
||
'aria-label': c,
|
||
});
|
||
cb.checked = checked;
|
||
td.append(cb);
|
||
tr.append(td);
|
||
checkboxes.push(cb);
|
||
}
|
||
|
||
// Save button cell.
|
||
const saveTd = el('td', { class: 'cap-save-col' });
|
||
const saveBtn = el('button', { type: 'button', class: 'btn cap-save-btn' }, 'save');
|
||
saveBtn.addEventListener('click', async () => {
|
||
const selectedCaps = checkboxes
|
||
.filter((cb) => cb.checked)
|
||
.map((cb) => cb.dataset.cap);
|
||
saveBtn.disabled = true;
|
||
saveBtn.textContent = '…';
|
||
try {
|
||
const r = await fetch('/api/capabilities/' + encodeURIComponent(name), {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ caps: selectedCaps }),
|
||
});
|
||
if (!r.ok) {
|
||
const txt = await r.text();
|
||
saveBtn.textContent = 'err';
|
||
saveBtn.title = txt;
|
||
} else {
|
||
saveBtn.textContent = '✓';
|
||
setTimeout(fetchAndRenderCapabilities, 800);
|
||
}
|
||
} catch (err) {
|
||
saveBtn.textContent = 'err';
|
||
saveBtn.title = String(err);
|
||
} finally {
|
||
saveBtn.disabled = false;
|
||
}
|
||
});
|
||
saveTd.append(saveBtn);
|
||
tr.append(saveTd);
|
||
|
||
tbody.append(tr);
|
||
}
|
||
table.append(tbody);
|
||
wrap.append(table);
|
||
root.append(wrap);
|
||
}
|
||
|
||
async function fetchAndRenderToolGroups() {
|
||
const root = $('tool-groups-section');
|
||
if (!root) return;
|
||
root.replaceChildren();
|
||
root.append(el('p', { class: 'meta' }, 'loading…'));
|
||
try {
|
||
const resp = await fetch('/api/tool-groups');
|
||
if (!resp.ok) throw new Error('http ' + resp.status);
|
||
const data = await resp.json();
|
||
renderToolGroups(root, data);
|
||
} catch (err) {
|
||
root.replaceChildren();
|
||
root.append(el('p', { class: 'meta' }, 'fetch failed: ' + err));
|
||
}
|
||
}
|
||
|
||
function renderToolGroups(root, data) {
|
||
root.replaceChildren();
|
||
const { groups, descriptions = {}, assignments } = data;
|
||
if (!groups || !groups.length) {
|
||
root.append(el('p', { class: 'meta' }, '(no tool groups defined)'));
|
||
return;
|
||
}
|
||
|
||
// Agent names: union of live containers + keys already in assignments,
|
||
// sorted alphabetically.
|
||
const agentNames = [...new Set([
|
||
...Array.from(containersState.keys()),
|
||
...Object.keys(assignments),
|
||
])].sort();
|
||
|
||
if (!agentNames.length) {
|
||
root.append(el('p', { class: 'meta' }, '(no agents)'));
|
||
return;
|
||
}
|
||
|
||
const wrap = el('div', { class: 'tg-table-wrap' });
|
||
const table = el('table', { class: 'tg-table' });
|
||
|
||
// Header row.
|
||
const thead = el('thead');
|
||
const hrow = el('tr');
|
||
hrow.append(el('th', { class: 'tg-agent-col' }, 'agent'));
|
||
for (const g of groups) {
|
||
hrow.append(el('th', { class: 'tg-group-col', title: descriptions[g] || g }, g));
|
||
}
|
||
hrow.append(el('th', { class: 'tg-save-col' }, ''));
|
||
thead.append(hrow);
|
||
table.append(thead);
|
||
|
||
const tbody = el('tbody');
|
||
for (const name of agentNames) {
|
||
// Explicit assignment or empty = using role default.
|
||
const assigned = assignments[name] || [];
|
||
const hasExplicit = Object.prototype.hasOwnProperty.call(assignments, name);
|
||
const tr = el('tr', { class: 'tg-row' });
|
||
|
||
// Agent name cell.
|
||
const nameTd = el('td', { class: 'tg-agent-col' });
|
||
nameTd.append(el('span', { class: 'tg-agent-name' }, name));
|
||
if (!hasExplicit) {
|
||
nameTd.append(el('span', { class: 'meta tg-default-label' }, '(default)'));
|
||
}
|
||
tr.append(nameTd);
|
||
|
||
// One checkbox per group.
|
||
const checkboxes = [];
|
||
for (const g of groups) {
|
||
const checked = assigned.includes(g);
|
||
const td = el('td', { class: 'tg-group-col' });
|
||
const cb = el('input', {
|
||
type: 'checkbox',
|
||
class: 'tg-cb',
|
||
'data-group': g,
|
||
'aria-label': g,
|
||
});
|
||
cb.checked = checked;
|
||
td.append(cb);
|
||
tr.append(td);
|
||
checkboxes.push(cb);
|
||
}
|
||
|
||
// Save button cell.
|
||
const saveTd = el('td', { class: 'tg-save-col' });
|
||
const saveBtn = el('button', { type: 'button', class: 'btn tg-save-btn' }, 'save');
|
||
saveBtn.addEventListener('click', async () => {
|
||
const selectedGroups = checkboxes
|
||
.filter((cb) => cb.checked)
|
||
.map((cb) => cb.dataset.group);
|
||
saveBtn.disabled = true;
|
||
saveBtn.textContent = '…';
|
||
try {
|
||
const r = await fetch('/api/tool-groups/' + encodeURIComponent(name), {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ groups: selectedGroups }),
|
||
});
|
||
if (!r.ok) {
|
||
const txt = await r.text();
|
||
saveBtn.textContent = 'err';
|
||
saveBtn.title = txt;
|
||
} else {
|
||
saveBtn.textContent = '✓';
|
||
setTimeout(fetchAndRenderToolGroups, 800);
|
||
}
|
||
} catch (err) {
|
||
saveBtn.textContent = 'err';
|
||
saveBtn.title = String(err);
|
||
} finally {
|
||
saveBtn.disabled = false;
|
||
}
|
||
});
|
||
saveTd.append(saveBtn);
|
||
tr.append(saveTd);
|
||
|
||
tbody.append(tr);
|
||
}
|
||
table.append(tbody);
|
||
wrap.append(table);
|
||
root.append(wrap);
|
||
}
|
||
|
||
// Derived question state — cold-loaded from /api/state, then mutated
|
||
// live by `question_added` / `question_resolved` dashboard events.
|
||
const QUESTION_HISTORY_LIMIT = 20;
|
||
const questionsState = { pending: [], history: [] };
|
||
// Keyed row cache: question id → {el, fingerprint}. Allows renderQuestions
|
||
// to reuse <li> elements whose state hasn't changed. The main benefit is
|
||
// preserving textarea draft text and radio/checkbox selections when an
|
||
// unrelated question arrives while the operator is composing a reply.
|
||
const questionRowCache = new Map();
|
||
function syncQuestionsFromSnapshot(s) {
|
||
questionsState.pending = (s.questions || []).slice();
|
||
questionsState.history = (s.question_history || []).slice();
|
||
}
|
||
function applyQuestionAdded(ev) {
|
||
if (questionsState.pending.some((q) => q.id === ev.id)) return;
|
||
questionsState.pending.push({
|
||
id: ev.id,
|
||
asker: ev.asker,
|
||
question: ev.question,
|
||
options: ev.options || [],
|
||
multi: !!ev.multi,
|
||
asked_at: ev.asked_at,
|
||
deadline_at: ev.deadline_at ?? null,
|
||
target: ev.target || null,
|
||
question_refs: ev.question_refs || [],
|
||
});
|
||
renderQuestions();
|
||
renderContainersFromState();
|
||
}
|
||
function applyQuestionResolved(ev) {
|
||
const idx = questionsState.pending.findIndex((q) => q.id === ev.id);
|
||
const existing = idx >= 0 ? questionsState.pending[idx] : null;
|
||
if (idx >= 0) questionsState.pending.splice(idx, 1);
|
||
// Idempotent: a snapshot re-sync (post-disconnect SSE catchup) can
|
||
// carry this same answered row in `question_history` while a live
|
||
// event also delivers it — guard the unshift so history can't
|
||
// double a row.
|
||
if (!questionsState.history.some((h) => h.id === ev.id)) {
|
||
questionsState.history.unshift({
|
||
id: ev.id,
|
||
asker: existing?.asker || '?',
|
||
question: existing?.question || '',
|
||
options: existing?.options || [],
|
||
multi: existing?.multi || false,
|
||
asked_at: existing?.asked_at || ev.answered_at,
|
||
answered_at: ev.answered_at,
|
||
answer: ev.answer,
|
||
answerer: ev.answerer,
|
||
target: existing?.target ?? ev.target ?? null,
|
||
question_refs: existing?.question_refs || [],
|
||
answer_refs: ev.answer_refs || [],
|
||
});
|
||
if (questionsState.history.length > QUESTION_HISTORY_LIMIT) {
|
||
questionsState.history.length = QUESTION_HISTORY_LIMIT;
|
||
}
|
||
}
|
||
renderQuestions();
|
||
renderContainersFromState();
|
||
}
|
||
// Filter selection for the questions section. Persisted so the
|
||
// operator's preferred view (all / operator-targeted / peer)
|
||
// survives a reload.
|
||
const QUESTIONS_FILTER_KEY = 'hyperhive:questions:filter';
|
||
function getQuestionsFilter() {
|
||
return localStorage.getItem(QUESTIONS_FILTER_KEY) || 'all';
|
||
}
|
||
function setQuestionsFilter(v) {
|
||
localStorage.setItem(QUESTIONS_FILTER_KEY, v);
|
||
renderQuestions();
|
||
}
|
||
function questionMatchesFilter(q, filter) {
|
||
if (filter === 'all') return true;
|
||
if (filter === 'operator') return !q.target;
|
||
if (filter === 'peer') return !!q.target;
|
||
// `agent:<name>` matches when the agent appears as asker OR target.
|
||
if (filter.startsWith('agent:')) {
|
||
const name = filter.slice('agent:'.length);
|
||
return q.asker === name || q.target === name;
|
||
}
|
||
return true;
|
||
}
|
||
// Serialise the fields that determine a pending-question <li>'s DOM
|
||
// structure. Used by questionRowCache to skip rebuilds when nothing
|
||
// visible has changed. deadline_at controls whether the TTL chip node
|
||
// exists at all (its text is kept current by the global 1s ticker).
|
||
function questionRowFingerprint(q) {
|
||
return JSON.stringify({
|
||
asker: q.asker, target: q.target, asked_at: q.asked_at,
|
||
deadline_at: q.deadline_at, question: q.question,
|
||
question_refs: q.question_refs, options: q.options, multi: q.multi,
|
||
});
|
||
}
|
||
|
||
// Build a single pending-question <li>. Extracted from renderQuestions so
|
||
// questionRowCache can reuse unchanged nodes without re-running this body.
|
||
// Event listeners attached here (keydown on textarea, submit on form) are
|
||
// preserved in the reused node — no re-attachment needed.
|
||
function buildQuestionLi(q) {
|
||
const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19);
|
||
const targetLabel = q.target || 'operator';
|
||
const li = el('li', { class: 'question' + (q.target ? ' question-peer' : '') });
|
||
const head = el('div', { class: 'q-head' },
|
||
el('span', { class: 'msg-ts' }, fmt(q.asked_at)), ' ',
|
||
el('span', { class: 'msg-from' }, q.asker), ' ',
|
||
el('span', { class: 'msg-sep' }, '→'), ' ',
|
||
el('span', { class: q.target ? 'msg-to msg-to-peer' : 'msg-to' }, targetLabel), ' ',
|
||
el('span', { class: 'msg-sep' }, 'asks:'),
|
||
);
|
||
if (q.deadline_at) {
|
||
// Tag the chip with its deadline so the global 1s ticker
|
||
// can refresh the text without re-rendering the questions section.
|
||
const ttlEl = el('span', {
|
||
class: 'q-ttl', 'data-deadline': String(q.deadline_at),
|
||
});
|
||
ttlEl.textContent = formatTtl(
|
||
q.deadline_at - Math.floor(Date.now() / 1000),
|
||
);
|
||
head.append(' ', ttlEl);
|
||
}
|
||
const qBody = el('div', { class: 'q-body' });
|
||
appendLinkified(qBody, q.question, q.question_refs);
|
||
li.append(head, qBody);
|
||
const f = el('form', {
|
||
method: 'POST', action: '/answer-question/' + q.id,
|
||
class: 'qform', 'data-async': '', 'data-no-refresh': '',
|
||
});
|
||
const hasOptions = q.options && q.options.length;
|
||
const isMulti = !!q.multi && hasOptions;
|
||
const freeText = el('textarea', {
|
||
name: 'answer-free', rows: '2', autocomplete: 'off',
|
||
placeholder: (hasOptions ? 'or type your own…' : 'your answer')
|
||
+ ' (shift+enter for newline)',
|
||
});
|
||
// Enter submits; shift+enter inserts a newline (textarea default).
|
||
freeText.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter' && !e.shiftKey) {
|
||
e.preventDefault();
|
||
f.requestSubmit();
|
||
}
|
||
});
|
||
const optionGroup = el('div', { class: 'q-options' });
|
||
if (hasOptions) {
|
||
for (const opt of q.options) {
|
||
const inputType = isMulti ? 'checkbox' : 'radio';
|
||
const id = 'q' + q.id + '-' + Math.random().toString(36).slice(2, 8);
|
||
const input = el('input', { type: inputType, name: 'choice', value: opt, id });
|
||
const label = el('label', { for: id }, ' ' + opt);
|
||
optionGroup.append(el('div', { class: 'q-option' }, input, label));
|
||
}
|
||
}
|
||
// On submit, build the final `answer` field from selected
|
||
// options + free-text, joined by ', '. This lets the operator
|
||
// pick options AND add free text in the same form.
|
||
f.addEventListener('submit', (ev) => {
|
||
const parts = [];
|
||
for (const cb of f.querySelectorAll('input[name="choice"]:checked')) {
|
||
parts.push(cb.value);
|
||
}
|
||
const ft = (freeText.value || '').trim();
|
||
if (ft) parts.push(ft);
|
||
const merged = parts.join(', ');
|
||
// Replace the existing hidden `answer` (if any) with the merged value.
|
||
const existing = f.querySelector('input[name="answer"]');
|
||
if (existing) existing.remove();
|
||
f.append(el('input', { type: 'hidden', name: 'answer', value: merged }));
|
||
if (!merged) { ev.preventDefault(); alert('pick an option or type an answer'); }
|
||
}, true);
|
||
if (hasOptions) f.append(optionGroup);
|
||
const buttons = el('div', { class: 'q-buttons' });
|
||
// On peer threads the operator's answer is an override —
|
||
// mark the button so it's clear what the click does (the
|
||
// backend permits it via OperatorQuestions::answer's
|
||
// answerer-auth rule).
|
||
const answerLabel = q.target
|
||
? (isMulti ? '⤿ 0V3RR1D3 · ' + q.options.length + ' opts' : '⤿ 0V3RR1D3')
|
||
: (isMulti ? '▸ ANSW3R · ' + q.options.length + ' opts' : '▸ ANSW3R');
|
||
buttons.append(
|
||
el('button', {
|
||
type: 'submit',
|
||
class: 'btn btn-approve' + (q.target ? ' btn-override' : ''),
|
||
title: q.target ? `override-answer on behalf of operator (target was ${q.target})` : '',
|
||
}, answerLabel),
|
||
);
|
||
f.append(
|
||
el('div', { class: 'q-free' }, freeText),
|
||
buttons,
|
||
);
|
||
li.append(f);
|
||
// Separate form so the cancel button doesn't get the answer
|
||
// merge-on-submit handler attached to the main form.
|
||
const cancelTargetLabel = q.target ? q.target : 'asker';
|
||
const cancelForm = el('form', {
|
||
method: 'POST', action: '/cancel-question/' + q.id,
|
||
class: 'qform-cancel', 'data-async': '', 'data-no-refresh': '',
|
||
'data-confirm': `cancel this question? ${cancelTargetLabel} will see `
|
||
+ '"[cancelled]" as the answer.',
|
||
});
|
||
cancelForm.append(
|
||
el('button', { type: 'submit', class: 'btn btn-deny' }, '✗ CANC3L'),
|
||
);
|
||
li.append(cancelForm);
|
||
return li;
|
||
}
|
||
|
||
function renderQuestions() {
|
||
const root = $('questions-section');
|
||
// #questions-section only lives on /index.html (Y3R C4LL tab);
|
||
// no-op when the section is missing. `question_added` /
|
||
// `question_resolved` SSE events route through here.
|
||
if (!root) return;
|
||
// Snapshot open <details> state so SSE-triggered re-renders restore
|
||
// any expanded sections. The keyed-cache approach reuses question
|
||
// <li> nodes (preserving textarea/checkbox state) and only rebuilds
|
||
// cache-miss rows, so we no longer wipe the DOM at the start.
|
||
const openDetails = snapshotOpenDetails();
|
||
const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19);
|
||
const allPending = questionsState.pending;
|
||
|
||
// Filter chips. Always include `all` / `operator` / `peer`; add
|
||
// per-agent chips for any agent that appears as asker or target
|
||
// in the pending list so the operator can isolate a single
|
||
// thread without typing.
|
||
const participants = new Set();
|
||
for (const q of allPending) {
|
||
participants.add(q.asker);
|
||
if (q.target) participants.add(q.target);
|
||
}
|
||
|
||
// Auto-reset a stale per-agent filter: if the operator had `agent:foo`
|
||
// selected and all of foo's questions resolved, foo's chip disappears
|
||
// from the row. Without a reset the section would show "no questions
|
||
// match this filter" with no active chip visible — confusing. Fall back
|
||
// to `all` whenever the stored value is no longer a valid chip value.
|
||
let activeFilter = getQuestionsFilter();
|
||
const validFilters = new Set(['all', 'operator', 'peer',
|
||
...Array.from(participants).map((n) => 'agent:' + n)]);
|
||
if (!validFilters.has(activeFilter)) {
|
||
activeFilter = 'all';
|
||
// Write directly to localStorage to avoid the re-render that
|
||
// setQuestionsFilter() triggers (we're already mid-render).
|
||
localStorage.setItem(QUESTIONS_FILTER_KEY, 'all');
|
||
}
|
||
|
||
const pending = allPending.filter((q) => questionMatchesFilter(q, activeFilter));
|
||
|
||
const filterRow = el('div', { class: 'questions-filters' });
|
||
const mkChip = (value, label) => {
|
||
const b = el('button', {
|
||
type: 'button',
|
||
class: 'q-filter-chip' + (activeFilter === value ? ' active' : ''),
|
||
}, label);
|
||
b.addEventListener('click', () => setQuestionsFilter(value));
|
||
return b;
|
||
};
|
||
// Count pending questions per filter value so each chip shows its
|
||
// own hit count — the operator can see "operator · 2 / peer · 3"
|
||
// at a glance without clicking through each tab.
|
||
const operatorCount = allPending.filter((q) => !q.target).length;
|
||
const peerCount = allPending.filter((q) => !!q.target).length;
|
||
const agentCount = (name) => allPending.filter(
|
||
(q) => q.asker === name || q.target === name).length;
|
||
filterRow.append(
|
||
mkChip('all', `all · ${allPending.length}`),
|
||
mkChip('operator', `@operator · ${operatorCount}`),
|
||
mkChip('peer', `@peer · ${peerCount}`),
|
||
);
|
||
for (const name of Array.from(participants).sort()) {
|
||
filterRow.append(mkChip('agent:' + name, `@${name} · ${agentCount(name)}`));
|
||
}
|
||
|
||
// Evict resolved/cancelled questions from the row cache.
|
||
const allPendingIds = new Set(allPending.map((q) => q.id));
|
||
for (const id of questionRowCache.keys()) {
|
||
if (!allPendingIds.has(id)) questionRowCache.delete(id);
|
||
}
|
||
|
||
// Build the ordered list of <li> elements, reusing cached nodes whose
|
||
// serialised state hasn't changed. This is what preserves textarea
|
||
// draft text and radio/checkbox selections across re-renders.
|
||
const orderedLis = pending.map((q) => {
|
||
const fp = questionRowFingerprint(q);
|
||
const cached = questionRowCache.get(q.id);
|
||
if (cached && cached.fingerprint === fp) return cached.el;
|
||
const li = buildQuestionLi(q);
|
||
questionRowCache.set(q.id, { el: li, fingerprint: fp });
|
||
return li;
|
||
});
|
||
|
||
// Save the history <details> open state before the DOM swap so it
|
||
// isn't collapsed every time a question arrives while it's open.
|
||
const historyWasOpen = root.querySelector('.q-history')?.open ?? false;
|
||
|
||
const children = [filterRow];
|
||
if (!pending.length) {
|
||
children.push(el('p', { class: 'empty' },
|
||
activeFilter === 'all' ? 'no pending questions' : 'no questions match this filter'));
|
||
} else {
|
||
const ul = el('ul', { class: 'questions' });
|
||
for (const li of orderedLis) ul.append(li);
|
||
children.push(ul);
|
||
}
|
||
|
||
// Answered question history (read-only, no inputs — built fresh each render).
|
||
const hist = questionsState.history;
|
||
if (hist.length) {
|
||
const details = el('details', { class: 'q-history', 'data-restore-key': 'q-history' });
|
||
details.append(el('summary', {}, '◆ answ3red (' + hist.length + ')'));
|
||
const hul = el('ul', { class: 'questions questions-answered' });
|
||
for (const q of hist) {
|
||
const targetLabel = q.target || 'operator';
|
||
const li = el('li', { class: 'question question-answered' + (q.target ? ' question-peer' : '') });
|
||
const head = el('div', { class: 'q-head' },
|
||
el('span', { class: 'msg-ts' }, fmt(q.answered_at)), ' ',
|
||
el('span', { class: 'msg-from' }, q.asker), ' ',
|
||
el('span', { class: 'msg-sep' }, '→'), ' ',
|
||
el('span', { class: q.target ? 'msg-to msg-to-peer' : 'msg-to' }, targetLabel), ' ',
|
||
el('span', { class: 'msg-sep' }, 'asked:'),
|
||
);
|
||
const histBody = el('div', { class: 'q-body' });
|
||
appendLinkified(histBody, q.question, q.question_refs);
|
||
const ansText = el('span', { class: 'q-answer-text' });
|
||
appendLinkified(ansText, q.answer || '(none)', q.answer_refs);
|
||
const ansLine = el('div', { class: 'q-answer' },
|
||
el('span', { class: 'msg-sep' }, `${q.answerer || '?'}: `),
|
||
ansText,
|
||
);
|
||
li.append(head, histBody, ansLine);
|
||
hul.append(li);
|
||
}
|
||
details.append(hul);
|
||
children.push(details);
|
||
}
|
||
|
||
root.replaceChildren(...children);
|
||
// Restore history open state after the DOM swap.
|
||
if (historyWasOpen) {
|
||
const histEl = root.querySelector('.q-history');
|
||
if (histEl) histEl.open = true;
|
||
}
|
||
restoreOpenDetails(openDetails);
|
||
}
|
||
|
||
// Format a remaining-seconds count as the `⏳ …` TTL chip text on a
|
||
// question card. Bucketed at minutes / hours so a long deadline stays
|
||
// readable; "expiring…" once the deadline has passed (the host-side
|
||
// ttl-watchdog will fire shortly).
|
||
function formatTtl(remaining) {
|
||
if (remaining <= 0) return 'expiring…';
|
||
if (remaining < 60) return '⏳ ' + remaining + 's';
|
||
if (remaining < 3600) {
|
||
return '⏳ ' + Math.floor(remaining / 60) + 'm '
|
||
+ (remaining % 60) + 's';
|
||
}
|
||
return '⏳ ' + Math.floor(remaining / 3600) + 'h '
|
||
+ Math.floor((remaining % 3600) / 60) + 'm';
|
||
}
|
||
|
||
// Single page-wide ticker that refreshes every TTL chip in place
|
||
// each second. Renderers stamp `data-deadline` on the
|
||
// chip; this just updates `textContent`, no re-render of the
|
||
// questions section. No-op when no chips are on screen, so the
|
||
// cost is negligible.
|
||
setInterval(() => {
|
||
const now = Math.floor(Date.now() / 1000);
|
||
document.querySelectorAll('.q-ttl[data-deadline]').forEach((node) => {
|
||
const deadline = Number(node.getAttribute('data-deadline'));
|
||
if (!Number.isFinite(deadline)) return;
|
||
node.textContent = formatTtl(deadline - now);
|
||
});
|
||
}, 1000);
|
||
|
||
// 30s ticker for agent status-age chips. Renderers stamp `data-set-at`
|
||
// (unix seconds) on the `.status-age` span. Keyed container rows persist
|
||
// across re-renders, so without this ticker the "(set N ago)" label would
|
||
// become stale as time passes. 30s granularity matches fmtAgeSecs precision
|
||
// (sub-minute values round to seconds, coarser above that).
|
||
setInterval(() => {
|
||
const now = Math.floor(Date.now() / 1000);
|
||
document.querySelectorAll('.status-age[data-set-at]').forEach((node) => {
|
||
const setAt = Number(node.dataset.setAt);
|
||
if (!Number.isFinite(setAt) || setAt === 0) return;
|
||
node.textContent = ` (set ${fmtAgeSecs(now - setAt)} ago)`;
|
||
});
|
||
}, 30_000);
|
||
|
||
// Live ticker for approval request-age chips. Approval cards only
|
||
// re-render on `approval_added`/`approval_resolved` SSE events, so
|
||
// a request pending for an hour could still show "0s ago" without
|
||
// this ticker. Also flips `.stale` (amber highlight) at exactly 1h
|
||
// rather than only at the next re-render.
|
||
// Live countdown for reminder due-at labels and schedule next-fire
|
||
// cells. Both renderers stamp `data-due-at` on the element so this
|
||
// single ticker keeps them fresh without triggering a full re-render.
|
||
// `.reminder-due` → "overdue X ago" / "in Y"
|
||
// `.sched-due` → same pattern
|
||
setInterval(() => {
|
||
const now = Math.floor(Date.now() / 1000);
|
||
document.querySelectorAll('.approval-ts[data-requested-at]').forEach((node) => {
|
||
const requestedAt = Number(node.getAttribute('data-requested-at'));
|
||
if (!Number.isFinite(requestedAt)) return;
|
||
const ageSec = Math.max(0, now - requestedAt);
|
||
node.textContent = 'requested ' + fmtAgo(requestedAt);
|
||
node.classList.toggle('stale', ageSec >= 3600);
|
||
});
|
||
document.querySelectorAll('.reminder-due[data-due-at], .sched-due[data-due-at]').forEach((node) => {
|
||
const dueAt = Number(node.getAttribute('data-due-at'));
|
||
if (!Number.isFinite(dueAt)) return;
|
||
const dueIn = dueAt - now;
|
||
node.textContent = dueIn <= 0
|
||
? 'overdue ' + fmtAgo(dueAt)
|
||
: (node.classList.contains('reminder-due') ? 'in ' : '') + fmtDuration(dueIn);
|
||
});
|
||
}, 1000);
|
||
|
||
const APPROVAL_TAB_KEY = 'hyperhive:approvals:tab';
|
||
// Derived approval state — cold-loaded from /api/state, then mutated
|
||
// live by `approval_added` / `approval_resolved` dashboard events.
|
||
// `pending` is the open queue (newest-first); `history` is the last
|
||
// 30 resolved rows.
|
||
const APPROVAL_HISTORY_LIMIT = 30;
|
||
const approvalsState = { pending: [], history: [] };
|
||
function syncApprovalsFromSnapshot(s) {
|
||
approvalsState.pending = (s.approvals || []).slice();
|
||
approvalsState.history = (s.approval_history || []).slice();
|
||
}
|
||
function applyApprovalAdded(ev) {
|
||
// Upsert by id so a snapshot that already included the row (cold
|
||
// load + event lands at the same tick) doesn't double it.
|
||
const existing = approvalsState.pending.findIndex((a) => a.id === ev.id);
|
||
const row = {
|
||
id: ev.id,
|
||
agent: ev.agent,
|
||
kind: ev.approval_kind,
|
||
sha_short: ev.sha_short || null,
|
||
diff: ev.diff || null,
|
||
description: ev.description || null,
|
||
// The ApprovalAdded event carries no requested_at; a live-added
|
||
// approval was queued just now, so client-now is accurate — and
|
||
// consistent with how fmtAgo compares everything to client-now.
|
||
// A later /api/state cold-load swaps in the server value.
|
||
requested_at: ev.requested_at != null
|
||
? ev.requested_at : Math.floor(Date.now() / 1000),
|
||
};
|
||
if (existing >= 0) approvalsState.pending[existing] = row;
|
||
else approvalsState.pending.push(row);
|
||
renderApprovals();
|
||
}
|
||
function applyApprovalResolved(ev) {
|
||
// Drop from pending; prepend to history (newest-first), cap at 30.
|
||
approvalsState.pending = approvalsState.pending.filter((a) => a.id !== ev.id);
|
||
// Idempotent: a snapshot re-sync (post-disconnect SSE catchup) can
|
||
// carry this same resolved row in `approval_history` while a live
|
||
// event also delivers it — guard the unshift so history can't
|
||
// double a row.
|
||
if (!approvalsState.history.some((h) => h.id === ev.id)) {
|
||
approvalsState.history.unshift({
|
||
id: ev.id,
|
||
agent: ev.agent,
|
||
kind: ev.approval_kind,
|
||
sha_short: ev.sha_short || null,
|
||
status: ev.status,
|
||
resolved_at: ev.resolved_at,
|
||
note: ev.note || null,
|
||
description: ev.description || null,
|
||
});
|
||
if (approvalsState.history.length > APPROVAL_HISTORY_LIMIT) {
|
||
approvalsState.history.length = APPROVAL_HISTORY_LIMIT;
|
||
}
|
||
}
|
||
renderApprovals();
|
||
}
|
||
// Classify each unified-diff line by its leading char so
|
||
// `.diff-add` / `.diff-del` / `.diff-hunk` / `.diff-file` /
|
||
// `.diff-ctx` colour the output. Built as text-only spans (no
|
||
// innerHTML) so there's no HTML-escape surface.
|
||
function buildDiffPre(text) {
|
||
const pre = el('pre', { class: 'diff' });
|
||
for (const raw of String(text).split('\n')) {
|
||
let cls = 'diff-ctx';
|
||
if (raw.startsWith('--- ') || raw.startsWith('+++ ')) cls = 'diff-file';
|
||
else if (raw.startsWith('@')) cls = 'diff-hunk';
|
||
else if (raw.startsWith('+')) cls = 'diff-add';
|
||
else if (raw.startsWith('-')) cls = 'diff-del';
|
||
const span = document.createElement('span');
|
||
span.className = cls;
|
||
span.textContent = raw + '\n';
|
||
pre.appendChild(span);
|
||
}
|
||
return pre;
|
||
}
|
||
|
||
// Open an approval's diff in the side panel with a 3-way base
|
||
// toggle: vs applied (running tree), vs last-approved, vs previous
|
||
// proposal. `applied` uses the diff already shipped on the approval
|
||
// for instant paint; the other two fetch /api/approval-diff.
|
||
function openDiffPanel(a) {
|
||
const bases = [
|
||
['applied', 'vs applied'],
|
||
['approved', 'vs last-approved'],
|
||
['previous', 'vs previous proposal'],
|
||
];
|
||
const tabs = el('div', { class: 'diff-base-tabs' });
|
||
const host = el('div', { class: 'diff-host' });
|
||
async function selectBase(base) {
|
||
for (const btn of tabs.children) {
|
||
btn.classList.toggle('active', btn.dataset.base === base);
|
||
}
|
||
if (base === 'applied' && a.diff != null) {
|
||
host.replaceChildren(buildDiffPre(a.diff));
|
||
return;
|
||
}
|
||
host.replaceChildren(el('div', { class: 'meta' }, 'loading…'));
|
||
try {
|
||
const resp = await fetch('/api/approval-diff/' + a.id + '?base=' + base);
|
||
const text = await resp.text();
|
||
host.replaceChildren(resp.ok
|
||
? buildDiffPre(text)
|
||
: el('div', { class: 'meta' }, 'error: ' + text));
|
||
} catch (e) {
|
||
host.replaceChildren(el('div', { class: 'meta' }, 'error: ' + e));
|
||
}
|
||
}
|
||
for (const [base, label] of bases) {
|
||
const btn = el('button',
|
||
{ type: 'button', class: 'diff-base-tab', 'data-base': base }, label);
|
||
btn.addEventListener('click', () => selectBase(base));
|
||
tabs.append(btn);
|
||
}
|
||
const wrap = el('div', { class: 'diff-panel' }, tabs, host);
|
||
Panel.open('diff · ' + a.agent + ' #' + a.id, wrap);
|
||
selectBase('applied');
|
||
}
|
||
|
||
// P33RS tab: render peer hive link cards from state.peer_hives.
|
||
// Called on every state refresh; hides the tab when the list is empty.
|
||
function renderPeerHives(peers) {
|
||
const root = $('peers-section');
|
||
if (!root) return;
|
||
root.replaceChildren();
|
||
if (!peers || !peers.length) {
|
||
root.append(el('p', { class: 'empty' }, 'no peer hives configured'));
|
||
return;
|
||
}
|
||
const ul = el('ul', { class: 'containers' });
|
||
for (const p of peers) {
|
||
const li = el('li', { class: 'container-row' });
|
||
const head = el('div', { class: 'head' });
|
||
const icon = el('span', { class: 'container-icon' }, '⬡');
|
||
const nameEl = el('span', { class: 'name' }, p.name || p.url);
|
||
const linkEl = el('a', {
|
||
href: p.url,
|
||
target: '_blank',
|
||
rel: 'noopener noreferrer',
|
||
class: 'meta',
|
||
title: 'open ' + p.name + ' dashboard',
|
||
}, p.url);
|
||
head.append(icon, nameEl);
|
||
li.append(head, el('div', { class: 'meta' }, linkEl));
|
||
ul.append(li);
|
||
}
|
||
root.append(ul);
|
||
}
|
||
|
||
function renderApprovals() {
|
||
const root = $('approvals-section');
|
||
// #approvals-section only lives on /index.html (Y3R C4LL tab);
|
||
// no-op elsewhere — `approval_added` / `approval_resolved` SSE
|
||
// events route through here on every page that loads the bundle.
|
||
if (!root) return;
|
||
// Save spawn form input + focus state before the DOM wipe so a live
|
||
// approval_added/resolved event doesn't erase a partially-typed name
|
||
// or steal focus from the operator.
|
||
const savedSpawnName = root.querySelector('.spawnform input[name="name"]')?.value ?? '';
|
||
const spawnHadFocus = document.activeElement === root.querySelector('.spawnform input[name="name"]');
|
||
root.replaceChildren();
|
||
|
||
// Spawn request form: submitting it queues a Spawn approval that
|
||
// lands in this same list, so the form belongs here rather than on
|
||
// the containers list (the agent doesn't exist yet).
|
||
const spawnNameInput = el('input', {
|
||
name: 'name',
|
||
placeholder: 'new agent name (≤9 chars)',
|
||
maxlength: '9', required: '', autocomplete: 'off',
|
||
});
|
||
if (savedSpawnName) spawnNameInput.value = savedSpawnName;
|
||
if (spawnHadFocus) spawnNameInput.focus();
|
||
const spawn = el('form', {
|
||
method: 'POST', action: '/request-spawn',
|
||
class: 'spawnform', 'data-async': '', 'data-no-refresh': '',
|
||
});
|
||
spawn.append(
|
||
spawnNameInput,
|
||
el('button', { type: 'submit', class: 'btn btn-spawn' }, '◆ R3QU3ST SP4WN'),
|
||
);
|
||
root.append(spawn);
|
||
|
||
const pending = approvalsState.pending;
|
||
const history = approvalsState.history;
|
||
const active = localStorage.getItem(APPROVAL_TAB_KEY) || 'pending';
|
||
const tabs = el('div', { class: 'approval-tabs' });
|
||
const pendingTab = el(
|
||
'button',
|
||
{
|
||
type: 'button',
|
||
class: 'approval-tab' + (active === 'pending' ? ' active' : ''),
|
||
},
|
||
`pending · ${pending.length}`,
|
||
);
|
||
const historyTab = el(
|
||
'button',
|
||
{
|
||
type: 'button',
|
||
class: 'approval-tab' + (active === 'history' ? ' active' : ''),
|
||
},
|
||
`history · ${history.length}`,
|
||
);
|
||
pendingTab.addEventListener('click', () => {
|
||
localStorage.setItem(APPROVAL_TAB_KEY, 'pending');
|
||
renderApprovals();
|
||
});
|
||
historyTab.addEventListener('click', () => {
|
||
localStorage.setItem(APPROVAL_TAB_KEY, 'history');
|
||
renderApprovals();
|
||
});
|
||
tabs.append(pendingTab, historyTab);
|
||
root.append(tabs);
|
||
|
||
if (active === 'history') {
|
||
renderApprovalHistory(root, history);
|
||
return;
|
||
}
|
||
|
||
if (!pending.length) {
|
||
root.append(el('p', { class: 'empty' }, 'queue empty'));
|
||
return;
|
||
}
|
||
// forge link base — only when the hive-forge container is up.
|
||
const fs = window.__hyperhive_state;
|
||
const hostname = (fs && fs.hostname) || window.location.hostname;
|
||
// Prefer state.forge_public_url (set when forge.behindGateway=true,
|
||
// e.g. "https://forge.pr1ma.darkest.space") over the direct :3000 port.
|
||
const forgeBase = (fs && fs.forge_present)
|
||
? (fs.forge_public_url || `http://${hostname}:3000`)
|
||
: null;
|
||
|
||
const ul = el('ul', { class: 'approvals' });
|
||
for (const a of pending) {
|
||
const isApply = a.kind === 'apply_commit';
|
||
const isInit = a.kind === 'init_config';
|
||
const li = el('li', { class: 'approval-card' });
|
||
|
||
// ── identity header ──────────────────────────────────────────
|
||
const head = el('div', { class: 'approval-head' },
|
||
el('span', { class: 'glyph' }, isApply ? '→' : '⊕'),
|
||
el('span', { class: 'id' }, '#' + a.id),
|
||
el('span', { class: 'agent' }, a.agent),
|
||
el('span', { class: 'kind' + (isApply ? '' : ' kind-spawn') },
|
||
isApply ? 'apply' : isInit ? 'init' : 'spawn'),
|
||
);
|
||
if (isApply && a.sha_short) head.append(el('code', {}, a.sha_short));
|
||
// When the approval was requested — relative time, right-aligned.
|
||
// Goes amber once it's been pending an hour so a stale request is
|
||
// obvious at a glance (see docs/web-ui.md::Approval card).
|
||
if (a.requested_at != null) {
|
||
const ageSec = Math.max(0, Math.floor(Date.now() / 1000 - a.requested_at));
|
||
head.append(el('span', {
|
||
class: 'approval-ts' + (ageSec >= 3600 ? ' stale' : ''),
|
||
title: 'requested ' + new Date(a.requested_at * 1000).toLocaleString(),
|
||
'data-requested-at': String(a.requested_at),
|
||
}, 'requested ' + fmtAgo(a.requested_at)));
|
||
}
|
||
li.append(head);
|
||
|
||
// ── what-changed body ────────────────────────────────────────
|
||
const body = el('div', { class: 'approval-body' });
|
||
if (a.description) {
|
||
body.append(el('div', { class: 'approval-description' }, a.description));
|
||
}
|
||
if (isApply) {
|
||
const drill = el('div', { class: 'drill-ins' });
|
||
const diffBtn = el('button', { type: 'button', class: 'panel-trigger' },
|
||
'↳ view diff');
|
||
diffBtn.addEventListener('click', () => openDiffPanel(a));
|
||
drill.append(diffBtn);
|
||
if (forgeBase && a.sha_short) {
|
||
drill.append(el('a', {
|
||
class: 'panel-trigger', target: '_blank', rel: 'noopener',
|
||
href: `${forgeBase}/agent-configs/${a.agent}/commit/${a.sha_short}`,
|
||
title: 'this proposal commit on the hive forge',
|
||
}, '↳ commit on forge ↗'));
|
||
}
|
||
body.append(drill);
|
||
} else {
|
||
body.append(el('span', { class: 'meta' },
|
||
isInit
|
||
? 'scaffold proposed config repo — manager customises agent.nix before spawn'
|
||
: 'new sub-agent — container will be created on approve'));
|
||
}
|
||
li.append(body);
|
||
|
||
// ── decision actions ─────────────────────────────────────────
|
||
// Deny prompts the operator for an optional reason; the submit
|
||
// handler stashes it into a hidden `note` input that rides along
|
||
// on the POST and is surfaced to the manager via
|
||
// HelperEvent::ApprovalResolved { note }.
|
||
const denyForm = el('form', {
|
||
method: 'POST', action: '/deny/' + a.id,
|
||
class: 'inline', 'data-async': '', 'data-no-refresh': '',
|
||
'data-prompt': 'reason for denying (optional, sent to manager):',
|
||
});
|
||
denyForm.append(el('button', { type: 'submit', class: 'btn btn-deny' }, 'DENY'));
|
||
li.append(el('div', { class: 'approval-actions' },
|
||
form('/approve/' + a.id, 'btn-approve', '◆ APPR0VE', null, {}, { noRefresh: true }),
|
||
denyForm,
|
||
));
|
||
|
||
ul.append(li);
|
||
}
|
||
root.append(ul);
|
||
}
|
||
|
||
function renderApprovalHistory(root, history) {
|
||
if (!history.length) {
|
||
root.append(el('p', { class: 'empty' }, 'no resolved approvals yet'));
|
||
return;
|
||
}
|
||
const ul = el('ul', { class: 'approvals approvals-history' });
|
||
for (const a of history) {
|
||
const li = el('li');
|
||
const row = el('div', { class: 'row' });
|
||
const glyph = a.status === 'approved' ? '✓'
|
||
: a.status === 'denied' ? '✗'
|
||
: a.status === 'cancelled' ? '⊘'
|
||
: '⚠';
|
||
row.append(
|
||
el('span', { class: 'glyph glyph-' + a.status }, glyph), ' ',
|
||
el('span', { class: 'id' }, '#' + a.id), ' ',
|
||
el('span', { class: 'agent' }, a.agent), ' ',
|
||
el('span', { class: 'kind' }, a.kind === 'apply_commit' ? 'apply' : a.kind === 'init_config' ? 'init' : 'spawn'), ' ',
|
||
);
|
||
if (a.sha_short) row.append(el('code', {}, a.sha_short), ' ');
|
||
row.append(
|
||
el('span', { class: 'status status-' + a.status }, a.status), ' ',
|
||
el('span', { class: 'msg-ts' }, fmtAgo(a.resolved_at)),
|
||
);
|
||
li.append(row);
|
||
if (a.note) {
|
||
li.append(el('div', { class: 'history-note' }, a.note));
|
||
}
|
||
ul.append(li);
|
||
}
|
||
root.append(ul);
|
||
}
|
||
|
||
// Relative time, anchored to now. resolved_at is unix seconds (server-
|
||
// authored), so we don't have to worry about client/server clock skew
|
||
// for sub-minute precision.
|
||
function fmtAgo(unixSecs) {
|
||
const ageSec = Math.max(0, Math.floor(Date.now() / 1000 - unixSecs));
|
||
if (ageSec < 60) return ageSec + 's ago';
|
||
if (ageSec < 3600) return Math.floor(ageSec / 60) + 'm ago';
|
||
if (ageSec < 86400) return Math.floor(ageSec / 3600) + 'h ago';
|
||
return Math.floor(ageSec / 86400) + 'd ago';
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
function truncate(s, n) {
|
||
return s.length <= n ? s : s.slice(0, n - 1) + '…';
|
||
}
|
||
|
||
// ─── 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;
|
||
}
|
||
|
||
function fmtElapsed(secs) {
|
||
if (secs < 60) return secs + 's running';
|
||
if (secs < 3600) return Math.floor(secs / 60) + 'm ' + (secs % 60) + 's running';
|
||
return Math.floor(secs / 3600) + 'h ' + Math.floor((secs % 3600) / 60) + 'm running';
|
||
}
|
||
|
||
// 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);
|
||
|
||
// ─── reminders ──────────────────────────────────────────────────────────
|
||
// Reminders aren't part of /api/state (separate sqlite table, separate
|
||
// mutation cadence). refreshReminders() is called from refreshState() for
|
||
// cold-load and reconnect recovery. Live mutations are covered by the
|
||
// `reminders_changed` SSE event → `applyRemindersChanged` so no periodic
|
||
// poll is needed.
|
||
async function refreshReminders() {
|
||
const liveRoot = $('reminders-section');
|
||
if (!liveRoot) return;
|
||
try {
|
||
const resp = await fetch('/api/reminders');
|
||
if (!resp.ok) {
|
||
paintAtomic(liveRoot, (root) => {
|
||
root.append(el('p', { class: 'empty' }, 'reminders unavailable: http ' + resp.status));
|
||
});
|
||
return;
|
||
}
|
||
const rows = await resp.json();
|
||
renderReminders(rows);
|
||
} catch (err) {
|
||
paintAtomic(liveRoot, (root) => {
|
||
root.append(el('p', { class: 'empty' }, 'reminders fetch failed: ' + err));
|
||
});
|
||
}
|
||
}
|
||
function renderReminders(rows) {
|
||
const liveRoot = $('reminders-section');
|
||
if (!liveRoot) return;
|
||
paintAtomic(liveRoot, (root) => {
|
||
if (!rows.length) {
|
||
root.append(el('p', { class: 'empty' }, 'no queued reminders'));
|
||
return;
|
||
}
|
||
const ul = el('ul', { class: 'reminders' });
|
||
for (const r of rows) {
|
||
const failed = (r.attempt_count || 0) > 0;
|
||
const li = el('li', { class: 'reminder-row' + (failed ? ' reminder-failed' : '') });
|
||
const dueIn = r.due_at - Math.floor(Date.now() / 1000);
|
||
const dueLabel = dueIn <= 0
|
||
? `overdue ${fmtAgo(r.due_at)}`
|
||
: `in ${fmtDuration(dueIn)}`;
|
||
const head = el('div', { class: 'reminder-head' },
|
||
el('span', { class: 'agent' }, r.agent), ' ',
|
||
el('span', {
|
||
class: 'meta reminder-due',
|
||
title: new Date(r.due_at * 1000).toISOString(),
|
||
'data-due-at': String(r.due_at),
|
||
}, dueLabel),
|
||
' ',
|
||
el('span', { class: 'meta' }, `· id ${r.id}`),
|
||
);
|
||
if (r.file_path) {
|
||
head.append(' ', el('span', { class: 'meta' }, '· payload → '));
|
||
appendLinkified(head, r.file_path);
|
||
}
|
||
if (failed) {
|
||
head.append(' ', el('span',
|
||
{
|
||
class: 'badge badge-warn',
|
||
title: 'consecutive failed delivery attempts (capped at 5; over the cap the scheduler stops retrying until you click R3TRY or cancel)',
|
||
},
|
||
`⚠ ${r.attempt_count} failed`));
|
||
}
|
||
const body = el('div', { class: 'reminder-body' });
|
||
appendLinkified(body, r.message);
|
||
li.append(head, body);
|
||
if (r.last_error) {
|
||
li.append(el('div', { class: 'reminder-error' },
|
||
el('span', { class: 'msg-sep' }, 'error: '),
|
||
r.last_error,
|
||
));
|
||
}
|
||
const actions = el('div', { class: 'reminder-actions' });
|
||
if (failed) {
|
||
// Retry resets the failure counters so the scheduler picks
|
||
// the row up again on its next 5s tick. No data-no-refresh
|
||
// — the resulting refreshState re-fires refreshReminders.
|
||
const retryForm = el('form', {
|
||
method: 'POST', action: '/retry-reminder/' + r.id,
|
||
class: 'inline', 'data-async': '',
|
||
});
|
||
retryForm.append(el('button',
|
||
{ type: 'submit', class: 'btn btn-restart' }, '↻ R3TRY'));
|
||
actions.append(retryForm);
|
||
}
|
||
const cancelForm = el('form', {
|
||
method: 'POST', action: '/cancel-reminder/' + r.id,
|
||
class: 'inline', 'data-async': '',
|
||
'data-confirm': `cancel reminder ${r.id} for ${r.agent}? this drops the queued delivery; no undo.`,
|
||
});
|
||
cancelForm.append(el('button', { type: 'submit', class: 'btn btn-deny' }, '✗ C4NC3L'));
|
||
actions.append(cancelForm);
|
||
li.append(actions);
|
||
ul.append(li);
|
||
}
|
||
root.append(ul);
|
||
});
|
||
}
|
||
function fmtDuration(secs) {
|
||
if (secs < 60) return secs + 's';
|
||
if (secs < 3600) return Math.floor(secs / 60) + 'm ' + (secs % 60) + 's';
|
||
if (secs < 86400) return Math.floor(secs / 3600) + 'h ' + Math.floor((secs % 3600) / 60) + 'm';
|
||
return Math.floor(secs / 86400) + 'd ' + Math.floor((secs % 86400) / 3600) + 'h';
|
||
}
|
||
|
||
// ─── scheduled prompts ─────────────────────────────────────────────────
|
||
// Backend exposes `/api/schedules` (snapshot), `/api/schedules`
|
||
// (POST, operator-direct submit), `/api/schedules/{id}/cancel`
|
||
// (whole or per-target), `/api/schedules/{id}` (PATCH edit),
|
||
// `/api/schedules/{id}/fire-now` (POST). Mutations now emit a
|
||
// `schedules_changed` SSE event so the list updates live;
|
||
// `applySchedulesChanged` handles it. Tab-activation re-fetch kept as
|
||
// a safety net for approval-path inserts and disconnect windows.
|
||
// Local cache lets `refreshTabCounts` show the active count without
|
||
// re-fetching every second.
|
||
let schedulesState = [];
|
||
// Schedule ids whose inline edit form is currently open. The set
|
||
// survives across refreshSchedules() calls so a state
|
||
// poll doesn't yank the form out from under the operator. Per-id
|
||
// mid-edit carry sits in `scheduleEditCarry` so unsaved typing
|
||
// also rides the refresh.
|
||
const editingSchedules = new Set();
|
||
const scheduleEditCarry = new Map();
|
||
async function refreshSchedules() {
|
||
const listRoot = $('schedules-section');
|
||
if (!listRoot) return;
|
||
try {
|
||
const resp = await fetch('/api/schedules');
|
||
if (!resp.ok) {
|
||
paintAtomic(listRoot, (root) => {
|
||
root.append(el('p', { class: 'empty' }, 'schedules unavailable: http ' + resp.status));
|
||
});
|
||
return;
|
||
}
|
||
schedulesState = await resp.json();
|
||
} catch (err) {
|
||
paintAtomic(listRoot, (root) => {
|
||
root.append(el('p', { class: 'empty' }, 'schedules fetch failed: ' + err));
|
||
});
|
||
return;
|
||
}
|
||
renderSchedulesList();
|
||
}
|
||
// Active = at least one target still alive (no `cancelled_at_unix`)
|
||
// AND the whole schedule isn't cancelled. Drives the tab pill count.
|
||
function activeScheduleCount() {
|
||
let n = 0;
|
||
for (const s of schedulesState) {
|
||
if (s.cancelled_at_unix) continue;
|
||
if ((s.targets || []).some((t) => !t.cancelled_at_unix)) n++;
|
||
}
|
||
return n;
|
||
}
|
||
// Label + caption wrapper shared by every field on the schedule
|
||
// forms — `<label class="schedule-field"><span
|
||
// class="schedule-field-label">…</span>…children…</label>`. Variadic
|
||
// children land inside the label after the caption span, which is
|
||
// what every caller wants (the input + any preset/parts/preview
|
||
// sub-rows live next to the caption).
|
||
function scheduleField(labelText, ...children) {
|
||
return el('label', { class: 'schedule-field' },
|
||
el('span', { class: 'schedule-field-label' }, labelText),
|
||
...children);
|
||
}
|
||
|
||
// Interval composer — shared between the new-schedule form and
|
||
// the edit-schedule form. Builds the preset chip row +
|
||
// d/h/m/s sub-fields + live preview, and returns helpers to read
|
||
// and write the value.
|
||
//
|
||
// The composer carries no opinion on what "0 / blank" means at the
|
||
// semantic layer: it just reports the integer total. Callers decide
|
||
// whether to treat 0 as `null` (one-shot) for their own submit path.
|
||
function buildIntervalComposer({
|
||
label = 'interval (blank / all-zero = one-shot)',
|
||
namePrefix = 'interval_',
|
||
initialSeconds = 0,
|
||
} = {}) {
|
||
const wrapper = scheduleField(label);
|
||
const presets = [
|
||
['1m', 60], ['5m', 300], ['15m', 900], ['30m', 1800],
|
||
['1h', 3600], ['6h', 21600], ['12h', 43200],
|
||
['1d', 86400], ['7d', 604800],
|
||
];
|
||
const presetsRow = el('div', { class: 'schedule-interval-presets' });
|
||
for (const [presetLabel, secs] of presets) {
|
||
presetsRow.append(el('button', {
|
||
type: 'button',
|
||
class: 'btn btn-interval-preset',
|
||
'data-secs': String(secs),
|
||
}, presetLabel));
|
||
}
|
||
presetsRow.append(el('button', {
|
||
type: 'button',
|
||
class: 'btn btn-interval-preset btn-interval-oneshot',
|
||
'data-secs': '0',
|
||
}, 'one-shot'));
|
||
wrapper.append(presetsRow);
|
||
|
||
const partsRow = el('div', { class: 'schedule-interval-parts' });
|
||
const mkPart = (suffix, unit) => {
|
||
const inp = el('input', {
|
||
type: 'number', name: namePrefix + suffix, min: '0', step: '1', placeholder: '0',
|
||
class: 'schedule-interval-num',
|
||
});
|
||
partsRow.append(inp, el('span', { class: 'schedule-interval-unit' }, unit));
|
||
return inp;
|
||
};
|
||
const dInp = mkPart('d', 'd');
|
||
const hInp = mkPart('h', 'h');
|
||
const mInp = mkPart('m', 'm');
|
||
const sInp = mkPart('s', 's');
|
||
wrapper.append(partsRow);
|
||
|
||
const preview = el('div', { class: 'schedule-interval-preview', 'aria-live': 'polite' });
|
||
wrapper.append(preview);
|
||
|
||
function getSeconds() {
|
||
const n = (v) => {
|
||
const x = parseInt(v, 10);
|
||
return Number.isFinite(x) && x > 0 ? x : 0;
|
||
};
|
||
return n(dInp.value) * 86400 + n(hInp.value) * 3600
|
||
+ n(mInp.value) * 60 + n(sInp.value);
|
||
}
|
||
function fillFromSeconds(total) {
|
||
const t = Math.max(0, Math.floor(total));
|
||
const d = Math.floor(t / 86400);
|
||
const h = Math.floor((t % 86400) / 3600);
|
||
const m = Math.floor((t % 3600) / 60);
|
||
const s = t % 60;
|
||
dInp.value = d > 0 ? String(d) : '';
|
||
hInp.value = h > 0 ? String(h) : '';
|
||
mInp.value = m > 0 ? String(m) : '';
|
||
sInp.value = s > 0 ? String(s) : '';
|
||
}
|
||
function setParts({ d, h, m, s }) {
|
||
if (d !== undefined) dInp.value = d;
|
||
if (h !== undefined) hInp.value = h;
|
||
if (m !== undefined) mInp.value = m;
|
||
if (s !== undefined) sInp.value = s;
|
||
}
|
||
function updatePreview() {
|
||
const total = getSeconds();
|
||
preview.textContent = total > 0
|
||
? '↻ every ' + fmtDuration(total)
|
||
: 'one-shot (fires once at first-fire time)';
|
||
preview.classList.toggle('schedule-interval-preview-oneshot', total === 0);
|
||
}
|
||
for (const inp of [dInp, hInp, mInp, sInp]) {
|
||
inp.addEventListener('input', updatePreview);
|
||
}
|
||
for (const b of presetsRow.querySelectorAll('button[data-secs]')) {
|
||
b.addEventListener('click', () => {
|
||
fillFromSeconds(parseInt(b.getAttribute('data-secs'), 10) || 0);
|
||
updatePreview();
|
||
});
|
||
}
|
||
if (initialSeconds > 0) fillFromSeconds(initialSeconds);
|
||
updatePreview();
|
||
|
||
return { wrapper, getSeconds, fillFromSeconds, setParts, updatePreview };
|
||
}
|
||
|
||
// Parse the d/h/m/s sub-fields of a `buildIntervalComposer` instance
|
||
// out of submitted FormData. Returns total seconds, or NaN if any
|
||
// field carries a non-integer / negative value — the caller alerts
|
||
// and bails. Total `0` means "one-shot" at the call site (wire is
|
||
// `interval_seconds: null`). `namePrefix` matches the prefix passed
|
||
// to `buildIntervalComposer` (e.g. `interval_` or `edit_interval_`).
|
||
function intervalSecondsFromFormData(fd, namePrefix) {
|
||
const part = (suffix, mult) => {
|
||
const raw = String(fd.get(namePrefix + suffix) || '').trim();
|
||
if (!raw) return 0;
|
||
const n = parseInt(raw, 10);
|
||
if (!Number.isFinite(n) || n < 0) return NaN;
|
||
return n * mult;
|
||
};
|
||
return part('d', 86400) + part('h', 3600) + part('m', 60) + part('s', 1);
|
||
}
|
||
|
||
// Targets multi-select chip box — shared between the new-schedule
|
||
// form and the edit-schedule form. Same DOM shape, same candidate
|
||
// list (containers + operator + root); only the chip element id
|
||
// prefix and checkbox field name vary. Used to be inlined twice in
|
||
// near-identical 18-line blocks; consolidated here so a future
|
||
// change (new chip kind, candidate-list source swap, etc.) lives in
|
||
// one place. Returns the wrapping `<label class="schedule-field">`
|
||
// ready to append to the form.
|
||
function buildTargetChips({ idPrefix, fieldName, checked, extraNames = [] }) {
|
||
const candidates = ['operator'];
|
||
const containerNames = Array.from(containersState.values())
|
||
.map((c) => c.name)
|
||
.filter((n) => n !== 'operator')
|
||
.sort();
|
||
for (const n of containerNames) candidates.push(n);
|
||
// `extraNames` lets the edit form keep showing an already-active
|
||
// target that's vanished from the live container list (operator's
|
||
// typo, container destroyed mid-schedule, etc.) so it's still
|
||
// explicitly uncheckable. New-schedule callers pass `[]`.
|
||
for (const n of extraNames) if (!candidates.includes(n)) candidates.push(n);
|
||
|
||
const box = el('div', { class: 'schedule-targets' });
|
||
for (const name of candidates) {
|
||
const id_ = idPrefix + name;
|
||
const cb = el('input', {
|
||
type: 'checkbox', name: fieldName, value: name, id: id_,
|
||
});
|
||
if (checked.has(name)) cb.checked = true;
|
||
box.append(el('label', { class: 'schedule-target-chip', for: id_ },
|
||
cb, el('span', {}, name)));
|
||
}
|
||
return scheduleField('targets', box);
|
||
}
|
||
|
||
// Inline-create carry-state — survives paintAtomic re-renders.
|
||
// The schedules table refreshes on tab activate + after every mutation,
|
||
// and each refresh rebuilds the DOM via `paintAtomic`. The bottom
|
||
// create row's inputs would lose mid-typing values without this
|
||
// carry. `readNewScheduleCarryFromDOM` is called BEFORE every
|
||
// re-render so the carry sees the latest user input; the row
|
||
// builders then pre-fill from `newScheduleCarry`.
|
||
const newScheduleCarry = {
|
||
targets: new Set(),
|
||
body: '',
|
||
description: '',
|
||
first_fire: '',
|
||
interval_d: '',
|
||
interval_h: '',
|
||
interval_m: '',
|
||
interval_s: '',
|
||
};
|
||
function readNewScheduleCarryFromDOM(tr) {
|
||
if (!tr) return;
|
||
const get = (sel) => tr.querySelector(sel);
|
||
const checked = Array.from(tr.querySelectorAll('input[name="new_targets"]:checked'))
|
||
.map((i) => i.value);
|
||
newScheduleCarry.targets = new Set(checked);
|
||
const setIfDefined = (key, sel) => {
|
||
const el_ = get(sel);
|
||
if (el_) newScheduleCarry[key] = el_.value || '';
|
||
};
|
||
setIfDefined('body', 'textarea[name="new_body"]');
|
||
setIfDefined('description', 'input[name="new_description"]');
|
||
setIfDefined('first_fire', 'input[name="new_first_fire"]');
|
||
setIfDefined('interval_d', 'input[name="new_interval_d"]');
|
||
setIfDefined('interval_h', 'input[name="new_interval_h"]');
|
||
setIfDefined('interval_m', 'input[name="new_interval_m"]');
|
||
setIfDefined('interval_s', 'input[name="new_interval_s"]');
|
||
}
|
||
function resetNewScheduleCarry() {
|
||
newScheduleCarry.targets = new Set();
|
||
newScheduleCarry.body = '';
|
||
newScheduleCarry.description = '';
|
||
newScheduleCarry.first_fire = '';
|
||
newScheduleCarry.interval_d = '';
|
||
newScheduleCarry.interval_h = '';
|
||
newScheduleCarry.interval_m = '';
|
||
newScheduleCarry.interval_s = '';
|
||
}
|
||
function isoForDatetimeLocal(date) {
|
||
// `<input type="datetime-local">` expects `YYYY-MM-DDTHH:MM`
|
||
// in the user's local timezone (with no trailing Z). Build it
|
||
// by hand rather than slicing toISOString which is always UTC.
|
||
const pad = (n) => String(n).padStart(2, '0');
|
||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
||
+ `T${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||
}
|
||
// Schedules render as a single table — one row per schedule,
|
||
// attribute columns + one ✓/✕ column per agent (tilted 45° header
|
||
// so a row of agents takes ~28px each instead of full word width),
|
||
// actions column on the right. Edit form expands into a colspan'd
|
||
// row underneath when its row's `✎` is toggled on.
|
||
function renderSchedulesList() {
|
||
const liveRoot = $('schedules-section');
|
||
if (!liveRoot) return;
|
||
// Capture any mid-typed inline-create state BEFORE paintAtomic
|
||
// blows the row away so a refresh doesn't yank the operator's
|
||
// half-filled form. The carry is replayed by
|
||
// `renderInlineCreateRow` below.
|
||
readNewScheduleCarryFromDOM(liveRoot.querySelector('.schedules-table-create-row'));
|
||
paintAtomic(liveRoot, (root) => {
|
||
const agents = schedulesTableAgentSet();
|
||
const table = el('table', { class: 'schedules-table' });
|
||
table.append(renderSchedulesTableHead(agents));
|
||
const tbody = el('tbody', {});
|
||
const sorted = schedulesState.slice().sort((a, b) => {
|
||
const aDone = a.cancelled_at_unix ? 1 : 0;
|
||
const bDone = b.cancelled_at_unix ? 1 : 0;
|
||
if (aDone !== bDone) return aDone - bDone;
|
||
return a.next_fire_at_unix - b.next_fire_at_unix;
|
||
});
|
||
for (const s of sorted) {
|
||
tbody.append(renderScheduleRow(s, agents));
|
||
if (editingSchedules.has(s.id) && !s.cancelled_at_unix) {
|
||
tbody.append(renderScheduleEditRow(s, agents));
|
||
}
|
||
}
|
||
// Always-visible inline create row at the bottom of the table
|
||
// — fill cells + click + to POST. See
|
||
// docs/web-ui.md::SCH3DUL3S tab for the layout rationale.
|
||
tbody.append(renderInlineCreateRow(agents));
|
||
table.append(tbody);
|
||
const wrap = el('div', { class: 'schedules-table-wrap' });
|
||
wrap.append(table);
|
||
root.append(wrap);
|
||
});
|
||
}
|
||
// Inline create row. Each column carries an input matching its
|
||
// display semantics (datetime-local for next-fire, mini d/h/m/s
|
||
// number inputs for every, textarea for body, checkbox per agent
|
||
// column for targets). Submitting POSTs `/api/schedules` and clears
|
||
// the carry on success; the next `refreshSchedules` redraws the
|
||
// table with the new schedule above.
|
||
function renderInlineCreateRow(agents) {
|
||
const tr = el('tr', { class: 'schedules-table-create-row' });
|
||
|
||
tr.append(el('td', { class: 'meta schedules-table-id' }, 'new'));
|
||
tr.append(el('td', { class: 'meta' }, '—'));
|
||
|
||
const nextInput = el('input', {
|
||
type: 'datetime-local',
|
||
name: 'new_first_fire',
|
||
class: 'schedules-table-inline-input schedules-table-inline-datetime',
|
||
required: 'required',
|
||
title: 'first fire time (defaults to 5 minutes from now)',
|
||
});
|
||
// Default: 5 minutes from now so a stale-clock or quick-submit
|
||
// accident doesn't fire immediately on `now()`.
|
||
nextInput.value = newScheduleCarry.first_fire
|
||
|| isoForDatetimeLocal(new Date(Date.now() + 5 * 60 * 1000));
|
||
tr.append(el('td', { class: 'schedules-table-create-cell' }, nextInput));
|
||
|
||
const intervalRow = el('div', {
|
||
class: 'schedules-table-inline-interval',
|
||
title: 'recurring every D days H hours M minutes S seconds (all blank / zero = one-shot)',
|
||
});
|
||
const mkUnit = (suffix, unit) => {
|
||
const inp = el('input', {
|
||
type: 'number',
|
||
name: 'new_interval_' + suffix,
|
||
min: '0',
|
||
step: '1',
|
||
placeholder: '0',
|
||
class: 'schedules-table-inline-num',
|
||
'aria-label': 'interval ' + suffix,
|
||
});
|
||
inp.value = newScheduleCarry['interval_' + suffix] || '';
|
||
intervalRow.append(inp, el('span', { class: 'schedules-table-inline-unit' }, unit));
|
||
};
|
||
mkUnit('d', 'd');
|
||
mkUnit('h', 'h');
|
||
mkUnit('m', 'm');
|
||
mkUnit('s', 's');
|
||
tr.append(el('td', { class: 'schedules-table-create-cell' }, intervalRow));
|
||
|
||
tr.append(el('td', { class: 'meta' }, 'operator'));
|
||
|
||
const bodyTa = el('textarea', {
|
||
name: 'new_body',
|
||
rows: '1',
|
||
required: 'required',
|
||
placeholder: 'prompt body (required, multi-line ok)',
|
||
class: 'schedules-table-inline-textarea',
|
||
});
|
||
bodyTa.value = newScheduleCarry.body;
|
||
const descInput = el('input', {
|
||
type: 'text',
|
||
name: 'new_description',
|
||
placeholder: 'description (optional)',
|
||
class: 'schedules-table-inline-input schedules-table-inline-desc',
|
||
});
|
||
descInput.value = newScheduleCarry.description;
|
||
tr.append(el('td', { class: 'schedules-table-create-cell schedules-table-create-body' },
|
||
bodyTa, descInput));
|
||
|
||
// Per-agent target checkboxes — one cell per agent column. Wrap
|
||
// each checkbox in a label so the whole cell area is clickable.
|
||
for (const a of agents) {
|
||
const td = el('td', { class: 'schedules-table-check schedules-table-create-cell' });
|
||
const id_ = 'new-target-' + a;
|
||
const cb = el('input', {
|
||
type: 'checkbox',
|
||
name: 'new_targets',
|
||
value: a,
|
||
id: id_,
|
||
class: 'schedules-table-inline-check',
|
||
});
|
||
if (newScheduleCarry.targets.has(a)) cb.checked = true;
|
||
const lbl = el('label', {
|
||
for: id_,
|
||
class: 'schedules-table-inline-check-lbl',
|
||
title: 'tick to target ' + a,
|
||
}, cb);
|
||
td.append(lbl);
|
||
tr.append(td);
|
||
}
|
||
|
||
// Actions cell — submit button + reset.
|
||
const actionsCell = el('td', { class: 'schedules-table-actions' });
|
||
const submitBtn = el('button', {
|
||
type: 'button',
|
||
class: 'btn btn-spawn btn-inline-small schedules-table-create-submit',
|
||
title: 'queue this new schedule',
|
||
}, '+');
|
||
submitBtn.addEventListener('click', () => submitNewScheduleInline(tr, submitBtn));
|
||
const resetBtn = el('button', {
|
||
type: 'button',
|
||
class: 'btn btn-inline-small schedules-table-create-reset',
|
||
title: 'clear all fields',
|
||
}, '⌫');
|
||
resetBtn.addEventListener('click', () => {
|
||
resetNewScheduleCarry();
|
||
renderSchedulesList();
|
||
});
|
||
actionsCell.append(submitBtn, resetBtn);
|
||
tr.append(actionsCell);
|
||
|
||
return tr;
|
||
}
|
||
async function submitNewScheduleInline(tr, submitBtn) {
|
||
const targets = Array.from(tr.querySelectorAll('input[name="new_targets"]:checked'))
|
||
.map((i) => i.value);
|
||
const body = String(tr.querySelector('textarea[name="new_body"]')?.value || '').trim();
|
||
const description = String(tr.querySelector('input[name="new_description"]')?.value || '').trim();
|
||
const firstFireStr = String(tr.querySelector('input[name="new_first_fire"]')?.value || '');
|
||
|
||
if (!targets.length) {
|
||
alert('schedule must have at least one target — tick at least one agent column');
|
||
return;
|
||
}
|
||
if (!body) { alert('prompt body must be non-empty'); return; }
|
||
if (!firstFireStr) { alert('first fire timestamp is required'); return; }
|
||
const firstFireDate = new Date(firstFireStr);
|
||
if (Number.isNaN(firstFireDate.getTime())) {
|
||
alert('first fire is not a valid datetime'); return;
|
||
}
|
||
const first_fire_at_unix = Math.floor(firstFireDate.getTime() / 1000);
|
||
|
||
// Parse the d/h/m/s parts inline — we don't use FormData since
|
||
// the inline row isn't wrapped in a <form>. Mirrors
|
||
// `intervalSecondsFromFormData` semantics: blank/0 = one-shot,
|
||
// anything non-integer or negative → NaN → alert.
|
||
const part = (suffix, mult) => {
|
||
const raw = String(tr.querySelector(`input[name="new_interval_${suffix}"]`)?.value || '').trim();
|
||
if (!raw) return 0;
|
||
const n = parseInt(raw, 10);
|
||
if (!Number.isFinite(n) || n < 0) return NaN;
|
||
return n * mult;
|
||
};
|
||
const intervalTotal = part('d', 86400) + part('h', 3600) + part('m', 60) + part('s', 1);
|
||
if (Number.isNaN(intervalTotal)) {
|
||
alert('interval fields must be non-negative integers (or blank for one-shot)');
|
||
return;
|
||
}
|
||
const interval_seconds = intervalTotal > 0 ? intervalTotal : null;
|
||
|
||
const payload = { targets, body, first_fire_at_unix };
|
||
if (interval_seconds != null) payload.interval_seconds = interval_seconds;
|
||
if (description) payload.description = description;
|
||
|
||
const originalLabel = submitBtn.innerHTML;
|
||
submitBtn.disabled = true;
|
||
submitBtn.innerHTML = '<span class="spinner">◐</span>';
|
||
try {
|
||
const resp = await fetch('/api/schedules', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
if (!resp.ok) {
|
||
const text = await resp.text().catch(() => '');
|
||
alert('schedule submit failed: http ' + resp.status + (text ? '\n\n' + text : ''));
|
||
return;
|
||
}
|
||
// Reset carry so the next render shows an empty row.
|
||
resetNewScheduleCarry();
|
||
await refreshSchedules();
|
||
} catch (err) {
|
||
alert('schedule submit failed: ' + err);
|
||
} finally {
|
||
submitBtn.disabled = false;
|
||
submitBtn.innerHTML = originalLabel;
|
||
}
|
||
}
|
||
// The set of agent columns in the schedules table: operator + root
|
||
// (manager) first, then live containers (sorted), then any extra names
|
||
// that appear as a schedule target but aren't in the live container list
|
||
// (operator typo, container destroyed mid-schedule, etc.) — same
|
||
// membership rule as `buildTargetChips` so the table and the new-/
|
||
// edit-form chip boxes agree on what's addressable.
|
||
function schedulesTableAgentSet() {
|
||
const seen = new Set();
|
||
const out = [];
|
||
const push = (n) => { if (!seen.has(n)) { seen.add(n); out.push(n); } };
|
||
push('operator');
|
||
const containerNames = Array.from(containersState.values())
|
||
.map((c) => c.name)
|
||
.filter((n) => n !== 'operator')
|
||
.sort();
|
||
for (const n of containerNames) push(n);
|
||
for (const s of schedulesState) {
|
||
for (const t of s.targets || []) push(t.target);
|
||
}
|
||
return out;
|
||
}
|
||
function renderSchedulesTableHead(agents) {
|
||
const thead = el('thead', {});
|
||
const headerRow = el('tr', {});
|
||
headerRow.append(
|
||
el('th', { class: 'schedules-table-id' }, '#'),
|
||
el('th', {}, 'src'),
|
||
el('th', { class: 'schedules-table-next-col' }, 'next'),
|
||
el('th', { class: 'schedules-table-every-col' }, 'every'),
|
||
el('th', {}, 'owner'),
|
||
el('th', { class: 'schedules-table-body-th' }, 'body'),
|
||
);
|
||
for (const a of agents) {
|
||
headerRow.append(el('th', { class: 'schedules-table-agent-th', title: a },
|
||
el('div', {}, el('span', {}, a))));
|
||
}
|
||
headerRow.append(el('th', { class: 'schedules-table-actions-th' }, ''));
|
||
thead.append(headerRow);
|
||
return thead;
|
||
}
|
||
function renderScheduleRow(s, agents) {
|
||
const cancelled = !!s.cancelled_at_unix;
|
||
const tr = el('tr', {
|
||
class: 'schedules-table-row' + (cancelled ? ' schedules-table-row-cancelled' : ''),
|
||
});
|
||
|
||
tr.append(el('td', { class: 'meta schedules-table-id' }, '#' + s.id));
|
||
|
||
const srcKind = s.source && s.source.kind === 'approval' ? 'approval' : 'manual';
|
||
tr.append(el('td', {},
|
||
el('span', { class: 'rqe-source rqe-source-' + srcKind },
|
||
srcKind === 'approval' ? 'approval' : 'operator')));
|
||
|
||
// "next" cell — relative due-in for active schedules, "cancelled"
|
||
// for cancelled ones. Both carry the absolute ISO in the title.
|
||
const nextCell = el('td', { class: 'meta schedules-table-next-col' });
|
||
if (cancelled) {
|
||
nextCell.title = 'cancelled ' + new Date(s.cancelled_at_unix * 1000).toISOString();
|
||
nextCell.textContent = 'cancelled';
|
||
} else {
|
||
const dueIn = s.next_fire_at_unix - Math.floor(Date.now() / 1000);
|
||
nextCell.title = new Date(s.next_fire_at_unix * 1000).toISOString();
|
||
nextCell.textContent = dueIn <= 0
|
||
? 'overdue ' + fmtAgo(s.next_fire_at_unix)
|
||
: fmtDuration(dueIn);
|
||
nextCell.classList.add('sched-due');
|
||
nextCell.dataset.dueAt = String(s.next_fire_at_unix);
|
||
}
|
||
tr.append(nextCell);
|
||
|
||
tr.append(el('td', { class: 'meta schedules-table-every-col' },
|
||
s.interval_seconds ? '↻ ' + fmtDuration(s.interval_seconds) : 'one-shot'));
|
||
|
||
tr.append(el('td', { class: 'meta' }, s.owner));
|
||
|
||
// Body cell — truncates with ellipsis; full body + description (if
|
||
// any) on hover. Description used to be a separate visible block on
|
||
// the card layout; the table compresses it into the title to keep
|
||
// row height tight. Mara nit-flag candidate if she actually wants
|
||
// it visible in-table.
|
||
const bodyCell = el('td', { class: 'schedules-table-body-cell' });
|
||
const bodyText = s.body || '';
|
||
bodyCell.title = (s.description ? s.description + '\n\n' : '') + bodyText;
|
||
bodyCell.textContent = bodyText;
|
||
tr.append(bodyCell);
|
||
|
||
// Per-agent target cells. Three states:
|
||
// - active target → ✓ button that cancels just that target on
|
||
// click (same affordance as the per-row ✕ on the old layout's
|
||
// targets table)
|
||
// - cancelled target → muted ✕ glyph (no button — backend
|
||
// re-add flows through the edit form's targets multi-select)
|
||
// - not a target → empty cell
|
||
const targetByName = new Map();
|
||
for (const t of s.targets || []) targetByName.set(t.target, t);
|
||
for (const a of agents) {
|
||
const t = targetByName.get(a);
|
||
const td = el('td', { class: 'schedules-table-check' });
|
||
if (!t) {
|
||
// empty — no button, no glyph
|
||
} else if (t.cancelled_at_unix) {
|
||
td.title = 'cancelled — '
|
||
+ (t.last_fired_at_unix
|
||
? 'last fired ' + fmtAgo(t.last_fired_at_unix) + ' ago'
|
||
: 'never fired')
|
||
+ (t.last_result ? ' · ' + t.last_result : '');
|
||
td.append(el('span', { class: 'schedules-table-check-cancelled' }, '✕'));
|
||
} else {
|
||
const lastFireDesc = t.last_fired_at_unix
|
||
? 'last fired ' + fmtAgo(t.last_fired_at_unix) + ' ago'
|
||
: 'never fired';
|
||
const lastResultDesc = t.last_result ? ' · ' + t.last_result : '';
|
||
const checkBtn = el('button', {
|
||
type: 'button',
|
||
class: 'schedules-table-check-btn',
|
||
}, '✓');
|
||
checkBtn.title = lastFireDesc + lastResultDesc
|
||
+ (cancelled ? '' : '\nclick to cancel this target');
|
||
if (cancelled) {
|
||
checkBtn.disabled = true;
|
||
} else {
|
||
checkBtn.addEventListener('click', () => cancelScheduleTargets(s.id, [a]));
|
||
}
|
||
td.append(checkBtn);
|
||
}
|
||
tr.append(td);
|
||
}
|
||
|
||
// Actions cell — fire / edit / cancel-all. Glyph-only to fit a
|
||
// compact column; the buttons keep their existing colour classes
|
||
// so the visual cue (mauve = fire, yellow = edit, red = cancel)
|
||
// carries over from the card layout.
|
||
const actionsCell = el('td', { class: 'schedules-table-actions' });
|
||
if (!cancelled) {
|
||
const activeTargets = (s.targets || []).filter((t) => !t.cancelled_at_unix);
|
||
const isOneShot = !s.interval_seconds;
|
||
const fireBtn = el('button', {
|
||
type: 'button',
|
||
class: 'btn btn-fire-now btn-inline-small',
|
||
}, '↯');
|
||
fireBtn.title = isOneShot
|
||
? 'fire once — one-shot, consumed after the manual fire'
|
||
: 'fire once now — recurring, next regular fire unaffected';
|
||
if (!activeTargets.length) {
|
||
fireBtn.disabled = true;
|
||
fireBtn.title = 'every target is cancelled — nothing to fire';
|
||
}
|
||
fireBtn.addEventListener('click', () =>
|
||
fireScheduleNow(s.id, isOneShot, activeTargets.map((t) => t.target), fireBtn));
|
||
actionsCell.append(fireBtn);
|
||
|
||
const editingThis = editingSchedules.has(s.id);
|
||
const editBtn = el('button', {
|
||
type: 'button',
|
||
class: 'btn btn-edit-schedule btn-inline-small',
|
||
}, editingThis ? '✎×' : '✎');
|
||
editBtn.title = editingThis
|
||
? 'close edit'
|
||
: 'edit body / description / interval / next-fire / targets';
|
||
editBtn.addEventListener('click', () => {
|
||
if (editingThis) {
|
||
editingSchedules.delete(s.id);
|
||
scheduleEditCarry.delete(s.id);
|
||
} else {
|
||
editingSchedules.add(s.id);
|
||
}
|
||
renderSchedulesList();
|
||
});
|
||
actionsCell.append(editBtn);
|
||
|
||
const cancelBtn = el('button', {
|
||
type: 'button',
|
||
class: 'btn btn-deny btn-inline-small',
|
||
}, '✕');
|
||
cancelBtn.title = 'cancel the whole schedule';
|
||
cancelBtn.addEventListener('click', () => cancelScheduleAll(s.id));
|
||
actionsCell.append(cancelBtn);
|
||
}
|
||
tr.append(actionsCell);
|
||
|
||
return tr;
|
||
}
|
||
function renderScheduleEditRow(s, agents) {
|
||
// colspan = 6 attribute cols + N agent cols + 1 actions col
|
||
const colCount = 7 + agents.length;
|
||
const tr = el('tr', { class: 'schedules-table-edit-row' });
|
||
const td = el('td', { colspan: String(colCount) });
|
||
td.append(renderScheduleEditForm(s));
|
||
tr.append(td);
|
||
return tr;
|
||
}
|
||
|
||
// Inline edit form. Renders inside the schedule row when the
|
||
// row's `✎ edit` button is toggled on. Pre-filled with current
|
||
// values; submit PATCHes /api/schedules/{id}. Targets stay
|
||
// immutable (per damocles's backend; the workaround for retargeting
|
||
// is cancel + new schedule). Mid-edit field values survive a
|
||
// state-poll refresh via `scheduleEditCarry`.
|
||
function renderScheduleEditForm(s) {
|
||
const wrapper = el('div', { class: 'schedule-edit-form-wrapper' });
|
||
const form_ = el('form', { class: 'schedule-edit-form' });
|
||
form_.addEventListener('submit', (e) => {
|
||
e.preventDefault();
|
||
submitEditSchedule(s, form_);
|
||
});
|
||
|
||
const carry = scheduleEditCarry.get(s.id) || {};
|
||
|
||
const bodyInput = el('textarea', { name: 'body', rows: '4', required: 'required' });
|
||
bodyInput.value = carry.body !== undefined ? carry.body : s.body;
|
||
form_.append(scheduleField('body', bodyInput));
|
||
|
||
const descInput = el('input', { type: 'text', name: 'description' });
|
||
descInput.value = carry.description !== undefined
|
||
? carry.description
|
||
: (s.description || '');
|
||
form_.append(scheduleField('description (blank to clear)', descInput));
|
||
|
||
const firstFireInput = el('input', {
|
||
type: 'datetime-local', name: 'next_fire', required: 'required',
|
||
});
|
||
firstFireInput.value = carry.next_fire !== undefined
|
||
? carry.next_fire
|
||
: isoForDatetimeLocal(new Date(s.next_fire_at_unix * 1000));
|
||
form_.append(scheduleField('next fire', firstFireInput));
|
||
|
||
const intervalCx = buildIntervalComposer({
|
||
label: 'interval (blank / all-zero = flip to one-shot)',
|
||
namePrefix: 'edit_interval_',
|
||
initialSeconds: 0,
|
||
});
|
||
form_.append(intervalCx.wrapper);
|
||
if (carry.interval_d !== undefined || carry.interval_h !== undefined
|
||
|| carry.interval_m !== undefined || carry.interval_s !== undefined) {
|
||
intervalCx.setParts({
|
||
d: carry.interval_d || '',
|
||
h: carry.interval_h || '',
|
||
m: carry.interval_m || '',
|
||
s: carry.interval_s || '',
|
||
});
|
||
} else if (s.interval_seconds) {
|
||
intervalCx.fillFromSeconds(s.interval_seconds);
|
||
}
|
||
intervalCx.updatePreview();
|
||
|
||
// Persist carry on every input + checkbox change so a refresh
|
||
// repaint preserves it.
|
||
const saveCarry = () => {
|
||
const fd = new FormData(form_);
|
||
scheduleEditCarry.set(s.id, {
|
||
body: String(fd.get('body') || ''),
|
||
description: String(fd.get('description') || ''),
|
||
next_fire: String(fd.get('next_fire') || ''),
|
||
interval_d: String(fd.get('edit_interval_d') || ''),
|
||
interval_h: String(fd.get('edit_interval_h') || ''),
|
||
interval_m: String(fd.get('edit_interval_m') || ''),
|
||
interval_s: String(fd.get('edit_interval_s') || ''),
|
||
targets: fd.getAll('edit_targets').map(String),
|
||
});
|
||
};
|
||
form_.addEventListener('input', saveCarry);
|
||
form_.addEventListener('change', saveCarry);
|
||
|
||
// Targets multi-select. Shares the chip-box pattern with the
|
||
// new-schedule form via `buildTargetChips`;
|
||
// cancelled tombstones aren't listed (re-adding them flows
|
||
// through `targets_add`, which the backend replace-on-conflict
|
||
// drops the tombstone for). Submit diffs against the original
|
||
// active set to populate `targets_add` / `targets_remove` on the
|
||
// PATCH body.
|
||
const originalActiveTargets = new Set(
|
||
(s.targets || []).filter((t) => !t.cancelled_at_unix).map((t) => t.target),
|
||
);
|
||
form_.append(buildTargetChips({
|
||
idPrefix: 'se-' + s.id + '-',
|
||
fieldName: 'edit_targets',
|
||
checked: carry.targets ? new Set(carry.targets) : originalActiveTargets,
|
||
extraNames: [...originalActiveTargets],
|
||
}));
|
||
form_.append(el('p', { class: 'meta schedule-edit-targets-note' },
|
||
're-adding a previously cancelled target drops its history and starts fresh; '
|
||
+ 'unchecking an active target cancels it.'));
|
||
|
||
const actions = el('div', { class: 'schedule-actions' });
|
||
const submit = el('button', { type: 'submit', class: 'btn btn-spawn' }, '✓ save changes');
|
||
const cancelEdit = el('button', { type: 'button', class: 'btn' }, 'cancel');
|
||
cancelEdit.addEventListener('click', () => {
|
||
editingSchedules.delete(s.id);
|
||
scheduleEditCarry.delete(s.id);
|
||
renderSchedulesList();
|
||
});
|
||
actions.append(submit, cancelEdit);
|
||
form_.append(actions);
|
||
|
||
wrapper.append(form_);
|
||
return wrapper;
|
||
}
|
||
|
||
async function submitEditSchedule(originalSchedule, form_) {
|
||
const s = originalSchedule;
|
||
const fd = new FormData(form_);
|
||
const newBody = String(fd.get('body') || '').trim();
|
||
const newDescription = String(fd.get('description') || '').trim();
|
||
const newNextFireStr = String(fd.get('next_fire') || '');
|
||
|
||
if (!newBody) { alert('body must be non-empty'); return; }
|
||
if (!newNextFireStr) { alert('next-fire timestamp is required'); return; }
|
||
const newNextFireDate = new Date(newNextFireStr);
|
||
if (Number.isNaN(newNextFireDate.getTime())) {
|
||
alert('next-fire is not a valid datetime'); return;
|
||
}
|
||
const newNextFireUnix = Math.floor(newNextFireDate.getTime() / 1000);
|
||
|
||
// Compute interval total from the d/h/m/s fields (composer uses
|
||
// `edit_interval_*` names in this form).
|
||
const intervalTotal = intervalSecondsFromFormData(fd, 'edit_interval_');
|
||
if (Number.isNaN(intervalTotal)) {
|
||
alert('interval fields must be non-negative integers');
|
||
return;
|
||
}
|
||
const newIntervalSeconds = intervalTotal > 0 ? intervalTotal : null;
|
||
|
||
// Compute target diff against the schedule's currently-active set
|
||
// (cancelled tombstones don't count). The backend treats
|
||
// `targets_add` as replace-on-conflict (re-adding a tombstoned
|
||
// target drops its history), so we can just blanket "send the
|
||
// checked list as add, send the unchecked-but-was-active list as
|
||
// remove."
|
||
const newTargets = new Set(fd.getAll('edit_targets').map(String));
|
||
const originalActive = new Set(
|
||
(s.targets || [])
|
||
.filter((t) => !t.cancelled_at_unix)
|
||
.map((t) => t.target),
|
||
);
|
||
if (!newTargets.size) {
|
||
alert('schedule must have at least one target — uncheck submit, or use ✕ cancel all instead');
|
||
return;
|
||
}
|
||
const targetsAdd = [...newTargets].filter((t) => !originalActive.has(t));
|
||
const targetsRemove = [...originalActive].filter((t) => !newTargets.has(t));
|
||
|
||
// Build the PATCH body. Only include keys for fields that
|
||
// actually changed; explicit `null` clears description / flips
|
||
// recurring→one-shot. `targets_add` / `targets_remove` only
|
||
// populated when non-empty so the wire body stays minimal.
|
||
const patch = {};
|
||
if (newBody !== s.body) patch.body = newBody;
|
||
if (newDescription !== (s.description || '')) {
|
||
patch.description = newDescription || null;
|
||
}
|
||
if (newNextFireUnix !== s.next_fire_at_unix) {
|
||
patch.next_fire_at_unix = newNextFireUnix;
|
||
}
|
||
if (newIntervalSeconds !== (s.interval_seconds || null)) {
|
||
patch.interval_seconds = newIntervalSeconds;
|
||
}
|
||
if (targetsAdd.length) patch.targets_add = targetsAdd;
|
||
if (targetsRemove.length) patch.targets_remove = targetsRemove;
|
||
if (!Object.keys(patch).length) {
|
||
// No-op submit. Treat as "close edit form".
|
||
editingSchedules.delete(s.id);
|
||
scheduleEditCarry.delete(s.id);
|
||
renderSchedulesList();
|
||
return;
|
||
}
|
||
|
||
const submitBtn = form_.querySelector('button[type="submit"]');
|
||
const originalLabel = submitBtn ? submitBtn.textContent : '';
|
||
if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = 'saving…'; }
|
||
try {
|
||
const resp = await fetch('/api/schedules/' + encodeURIComponent(s.id), {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(patch),
|
||
});
|
||
if (!resp.ok) {
|
||
const text = await resp.text().catch(() => '');
|
||
alert('edit failed: http ' + resp.status + (text ? '\n\n' + text : ''));
|
||
return;
|
||
}
|
||
editingSchedules.delete(s.id);
|
||
scheduleEditCarry.delete(s.id);
|
||
await refreshSchedules();
|
||
} catch (err) {
|
||
alert('edit failed: ' + err);
|
||
} finally {
|
||
if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = originalLabel; }
|
||
}
|
||
}
|
||
async function fireScheduleNow(id, isOneShot, targets, btn) {
|
||
const targetList = targets.length ? targets.join(', ') : '(no active targets)';
|
||
const prompt = isOneShot
|
||
? `fire schedule #${id} now to ${targetList}?\n\n`
|
||
+ 'this is a ONE-SHOT — firing now consumes the schedule. '
|
||
+ 'the scheduled fire time will no longer trigger.'
|
||
: `fire schedule #${id} now to ${targetList}?\n\n`
|
||
+ 'this is RECURRING — sends an extra pulse out-of-band. '
|
||
+ 'the regular cadence keeps firing on schedule.';
|
||
if (!confirm(prompt)) return;
|
||
// Capture child nodes so we can restore on error, then replace
|
||
// with DOM-built content (textContent + element children rather
|
||
// than innerHTML — the format string only carries server-side
|
||
// ints/bool today but textContent is the safer pattern if a
|
||
// stringy field ever lands).
|
||
const originalChildren = btn ? Array.from(btn.childNodes) : [];
|
||
const restoreBtn = () => {
|
||
if (!btn) return;
|
||
btn.disabled = false;
|
||
while (btn.firstChild) btn.removeChild(btn.firstChild);
|
||
for (const n of originalChildren) btn.appendChild(n);
|
||
};
|
||
if (btn) {
|
||
btn.disabled = true;
|
||
while (btn.firstChild) btn.removeChild(btn.firstChild);
|
||
btn.append(el('span', { class: 'spinner' }, '◐'), ' firing…');
|
||
}
|
||
try {
|
||
const resp = await fetch('/api/schedules/' + encodeURIComponent(id) + '/fire-now', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
if (!resp.ok) {
|
||
const text = await resp.text().catch(() => '');
|
||
alert('fire-now failed: http ' + resp.status + (text ? '\n\n' + text : ''));
|
||
restoreBtn();
|
||
return;
|
||
}
|
||
// Backend returns FireNowReport { ok, failed, missing, one_shot_consumed }.
|
||
// Flash the per-target outcome on the button itself so the operator
|
||
// sees the result immediately, then refresh to pick up the
|
||
// authoritative per-target `last_result` annotations.
|
||
let report = null;
|
||
try { report = await resp.json(); } catch { /* shape drift / empty body — ignore */ }
|
||
if (btn && report) {
|
||
const bits = [];
|
||
if (report.ok) bits.push(report.ok + ' ok');
|
||
if (report.failed) bits.push(report.failed + ' failed');
|
||
if (report.missing) bits.push(report.missing + ' missing');
|
||
const suffix = report.one_shot_consumed ? ' — consumed' : '';
|
||
while (btn.firstChild) btn.removeChild(btn.firstChild);
|
||
btn.textContent = '↯ fired: ' + (bits.join(', ') || 'no targets') + suffix;
|
||
btn.classList.add('btn-fire-now-flashed');
|
||
}
|
||
// Hold the flash briefly so the operator can read it before the
|
||
// refresh wipes the row in place.
|
||
setTimeout(refreshSchedules, 1500);
|
||
} catch (err) {
|
||
alert('fire-now failed: ' + err);
|
||
restoreBtn();
|
||
}
|
||
}
|
||
async function cancelScheduleAll(id) {
|
||
if (!confirm(`cancel schedule #${id}? this stops all future fires for every target.`)) return;
|
||
await postScheduleCancel(id, null);
|
||
}
|
||
async function cancelScheduleTargets(id, targets) {
|
||
if (!confirm(`cancel schedule #${id} for ${targets.join(', ')}? other targets keep firing.`)) return;
|
||
await postScheduleCancel(id, targets);
|
||
}
|
||
async function postScheduleCancel(id, targets) {
|
||
try {
|
||
const opts = {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
};
|
||
if (targets) opts.body = JSON.stringify({ targets });
|
||
const resp = await fetch('/api/schedules/' + encodeURIComponent(id) + '/cancel', opts);
|
||
if (!resp.ok) {
|
||
const text = await resp.text().catch(() => '');
|
||
alert('cancel failed: http ' + resp.status + (text ? '\n\n' + text : ''));
|
||
return;
|
||
}
|
||
await refreshSchedules();
|
||
} catch (err) {
|
||
alert('cancel failed: ' + err);
|
||
}
|
||
}
|
||
|
||
// ─── state polling ──────────────────────────────────────────────────────
|
||
let pollTimer = null;
|
||
// Sections whose innerHTML gets blown away on each refresh. If the
|
||
// operator is typing in one of them, skip the refresh — the next
|
||
// 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',
|
||
'tool-groups-section',
|
||
];
|
||
// <details> sections that should survive a refresh need a stable
|
||
// `data-restore-key` attribute. snapshotOpenDetails walks managed
|
||
// sections and records which keys are currently open; restoreOpenDetails
|
||
// re-applies after the render. (Long-content drill-ins — file
|
||
// previews, diffs, logs, config — open in the side panel instead,
|
||
// which lives outside the managed sections and survives re-render
|
||
// on its own.)
|
||
function snapshotOpenDetails() {
|
||
const open = new Set();
|
||
for (const id of MANAGED_SECTION_IDS) {
|
||
const sect = document.getElementById(id);
|
||
if (!sect) continue;
|
||
for (const d of sect.querySelectorAll('details[data-restore-key]')) {
|
||
if (d.open) open.add(d.dataset.restoreKey);
|
||
}
|
||
}
|
||
return open;
|
||
}
|
||
function restoreOpenDetails(open) {
|
||
if (!open.size) return;
|
||
for (const id of MANAGED_SECTION_IDS) {
|
||
const sect = document.getElementById(id);
|
||
if (!sect) continue;
|
||
for (const d of sect.querySelectorAll('details[data-restore-key]')) {
|
||
if (open.has(d.dataset.restoreKey)) d.open = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
function operatorIsTyping() {
|
||
const el_ = document.activeElement;
|
||
if (!el_ || el_ === document.body) return false;
|
||
const tag = el_.tagName;
|
||
if (tag !== 'INPUT' && tag !== 'TEXTAREA' && tag !== 'SELECT') return false;
|
||
return MANAGED_SECTION_IDS.some((id) => {
|
||
const sect = document.getElementById(id);
|
||
return sect && sect.contains(el_);
|
||
});
|
||
}
|
||
async function refreshState() {
|
||
// Don't yank the form out from under the operator. Try again
|
||
// shortly on the next tick; eventually they'll blur and the
|
||
// refresh lands.
|
||
if (operatorIsTyping()) {
|
||
if (pollTimer) clearTimeout(pollTimer);
|
||
pollTimer = setTimeout(refreshState, 2000);
|
||
return;
|
||
}
|
||
try {
|
||
const resp = await fetch('/api/state');
|
||
if (!resp.ok) throw new Error('http ' + resp.status);
|
||
const s = await resp.json();
|
||
// Stash the latest snapshot for any sub-widget that wants a
|
||
// synchronous read (e.g. the compose autocomplete pulls agent
|
||
// names from here instead of refetching on every keystroke).
|
||
window.__hyperhive_state = s;
|
||
// Gate the P33RS tab — hidden when no peers are configured.
|
||
const peersTab = $('tab-peers');
|
||
const peers = s.peer_hives || [];
|
||
if (peersTab) peersTab.hidden = peers.length === 0;
|
||
renderPeerHives(peers);
|
||
// Gate the M4TR1X → tab strip entry — see
|
||
// docs/web-ui.md::Tab strip for the matrix_gui_enabled rationale.
|
||
const matrixTab = $('tab-matrix');
|
||
if (matrixTab) matrixTab.hidden = !s.matrix_gui_enabled;
|
||
// Hive identity: update the chrome banner + page title once the
|
||
// server-side display names are known. `hive_name` / `swarm_name`
|
||
// come from HYPERHIVE_HIVE_NAME / HYPERHIVE_SWARM_NAME env vars
|
||
// (set by services.hyperhive.{hiveName,swarmName} nix options).
|
||
// When unset we fall back gracefully — no chrome change.
|
||
const hiveId = $('hive-identity');
|
||
if (hiveId) {
|
||
const hive = s.hive_name;
|
||
const swarm = s.swarm_name;
|
||
if (hive || swarm) {
|
||
const label = swarm && hive ? `${swarm} / ${hive}`
|
||
: hive || swarm;
|
||
hiveId.textContent = label;
|
||
hiveId.hidden = false;
|
||
// Preserve any (N) call-count prefix already applied by
|
||
// refreshTabCounts so the title doesn't flicker on reload.
|
||
const existingPrefix = document.title.match(/^(\(\d+\) )/)?.[1] || '';
|
||
document.title = existingPrefix + label + ' // h1ve-c0re';
|
||
}
|
||
}
|
||
const openDetails = snapshotOpenDetails();
|
||
// Sync transients + containers first so renderContainers below
|
||
// sees the current derived maps (it reads from
|
||
// `transientsState` + `containersState`, not from `s.*`).
|
||
syncTransientsFromSnapshot(s);
|
||
syncContainersFromSnapshot(s);
|
||
syncTombstonesFromSnapshot(s);
|
||
syncMetaInputsFromSnapshot(s);
|
||
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
|
||
// refetch.
|
||
syncQuestionsFromSnapshot(s);
|
||
renderQuestions();
|
||
// (renderInbox now lives in ./flow.js — dashboard has no
|
||
// #inbox-section element to render into.)
|
||
syncApprovalsFromSnapshot(s);
|
||
renderApprovals();
|
||
renderMetaInputs(s);
|
||
renderRebuildQueue(s);
|
||
refreshReminders();
|
||
refreshSchedules();
|
||
restoreOpenDetails(openDetails);
|
||
notifyDeltas(s);
|
||
// No periodic refresh timer. Phase 6 covers every container
|
||
// mutation with `ContainerStateChanged` / `ContainerRemoved`
|
||
// (lifecycle ops, destroy, rebuild, crash_watch's 10s poll);
|
||
// approvals + questions + transients have their own events;
|
||
// broker traffic flows through the SSE channel. The only
|
||
// /api/state fetches are the initial cold load and the
|
||
// post-submit refetch on forms without `data-no-refresh`
|
||
// (tombstones, meta-input updates).
|
||
if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
|
||
} catch (err) {
|
||
console.error('refreshState failed', err);
|
||
// Schedule a single retry on transient errors so the page
|
||
// recovers from a brief network blip without making the
|
||
// operator reload.
|
||
pollTimer = setTimeout(refreshState, 5000);
|
||
}
|
||
}
|
||
refreshState();
|
||
// Cold-load the operator inbox (#1469) so the Y3R C4LL pill + browser
|
||
// title reflect unread agent→operator messages immediately, before the
|
||
// operator opens the tab. Live updates arrive via the broker stream.
|
||
refreshOperatorInbox();
|
||
NOTIF.bind();
|
||
Panel.bind();
|
||
|
||
// ─── live updates: dashboard event stream ──────────────────────────────
|
||
// The dashboard subscribes to /dashboard/stream for live mutation
|
||
// events so the SW4RM / Y3R C4LL / SYST3M panes update without an
|
||
// operator action triggering a refreshState.
|
||
//
|
||
// Bare `EventSource` (no terminal infrastructure needed — the
|
||
// dashboard doesn't render broker rows). Each event's `kind` is
|
||
// looked up against `MUTATION_HANDLERS`; unknown kinds (broker
|
||
// `sent` / `delivered`, anything new the backend adds) silently
|
||
// no-op. On (re)connect we kick a refreshState() to recover events
|
||
// lost during the disconnect window (same pattern as flow.js's
|
||
// onStreamOpen).
|
||
//
|
||
// Both /index.html and /flow.html subscribe to `/dashboard/stream`
|
||
// and filter client-side — the dashboard ignores broker traffic
|
||
// and the inbox ignores mutation events.
|
||
const MUTATION_HANDLERS = {
|
||
approval_added: applyApprovalAdded,
|
||
approval_resolved: applyApprovalResolved,
|
||
question_added: applyQuestionAdded,
|
||
question_resolved: applyQuestionResolved,
|
||
transient_set: applyTransientSet,
|
||
transient_cleared: applyTransientCleared,
|
||
container_state_changed: applyContainerStateChanged,
|
||
container_removed: applyContainerRemoved,
|
||
tombstones_changed: applyTombstonesChanged,
|
||
meta_inputs_changed: applyMetaInputsChanged,
|
||
meta_update_running: applyMetaUpdateRunning,
|
||
rebuild_queue_changed: applyRebuildQueueChanged,
|
||
schedules_changed: applySchedulesChanged,
|
||
reminders_changed: applyRemindersChanged,
|
||
capabilities_changed: applyCapabilitiesChanged,
|
||
tool_groups_changed: applyToolGroupsChanged,
|
||
};
|
||
(function bindDashboardStream() {
|
||
// Route through the SharedWorker so all open hyperhive tabs share
|
||
// one upstream SSE connection — see docs/web-ui.md (SSE multiplexing
|
||
// paragraph) for the design + Firefox throttling motivation.
|
||
// `openStream` returns an EventSource-shaped facade with a graceful
|
||
// direct-EventSource fallback when SharedWorker isn't supported.
|
||
const es = openStream('/dashboard/stream');
|
||
es.onmessage = (e) => {
|
||
let ev;
|
||
try { ev = JSON.parse(e.data); } catch { return; }
|
||
// Broker `sent` frames aren't mutation events, but the operator
|
||
// inbox (#1469) cares about ones addressed to "operator".
|
||
if (ev.kind === 'sent' && ev.to === 'operator') {
|
||
operatorInboxAppendFromEvent(ev);
|
||
return;
|
||
}
|
||
const h = MUTATION_HANDLERS[ev.kind];
|
||
if (!h) return; // broker rows + future kinds — dashboard doesn't care
|
||
try { h(ev); }
|
||
catch (err) { console.error('dashboard SSE handler', ev.kind, err); }
|
||
};
|
||
es.onopen = () => {
|
||
// Re-sync to recover events that fired during the SSE disconnect
|
||
// window. Initial connect also fires onopen — the first
|
||
// refreshState() above and this one race, but refreshState is
|
||
// idempotent so the second call just overwrites with the
|
||
// freshest snapshot. Cheap on a quiescent server, fine to repeat.
|
||
refreshState();
|
||
};
|
||
es.onerror = () => {
|
||
// EventSource auto-reconnects; nothing to do beyond logging.
|
||
console.debug('dashboard SSE error, will retry');
|
||
};
|
||
})();
|
||
|
||
// ─── tab routing ───────────────────────────────────────────────────────
|
||
// Hash-based: `#swarm` / `#call` / `#system` / `#schedules` /
|
||
// `#settings` activate the matching pane on the dashboard. Empty
|
||
// hash defaults to SW4RM. FL0W is NOT a tab — it's a separate page
|
||
// (`/flow.html`) reached via the tab-strip link. Tab routing only
|
||
// applies when the tab DOM is present (e.g. not on the flow page
|
||
// itself, where these elements don't exist and the loop no-ops).
|
||
const TABS = ['swarm', 'call', 'system', 'permissions', 'schedules', 'stats', 'peers', 'settings'];
|
||
function activateTab(name) {
|
||
const target = TABS.includes(name) ? name : TABS[0];
|
||
for (const t of TABS) {
|
||
const tab = $('tab-' + t);
|
||
const pane = $('tab-pane-' + t);
|
||
if (tab) tab.classList.toggle('active', t === target);
|
||
if (pane) pane.classList.toggle('tab-pane-active', t === target);
|
||
}
|
||
// Track active tab on the body so renderSelectionBar can gate
|
||
// visibility (bar only belongs on SW4RM where agent cards live).
|
||
// Re-render the bar so the toggle takes effect immediately
|
||
// on hashchange without waiting for the next SSE update.
|
||
document.body.dataset.activeTab = target;
|
||
renderSelectionBar(Array.from(containersState.values()));
|
||
// Keep overflow button active state in sync after tab change.
|
||
updateTabbarOverflow();
|
||
// Re-fetch schedules on activation as a safety net (SSE covers
|
||
// live mutations but re-sync ensures consistency after disconnect
|
||
// windows or approval-path inserts that don't yet emit). Also
|
||
// re-fetch reminders on SCH3DUL3S activation since both sections
|
||
// live on the same tab.
|
||
if (target === 'schedules') { refreshSchedules(); refreshReminders(); }
|
||
// Permissions tables: SSE covers worker-applied changes
|
||
// (capabilities_changed / tool_groups_changed); re-fetch on
|
||
// activation as a safety net for any gap between SSE events and
|
||
// the cold-load snapshot.
|
||
if (target === 'permissions') {
|
||
fetchAndRenderCapabilities();
|
||
fetchAndRenderToolGroups();
|
||
}
|
||
// ST4TS: hive-wide rollup is a pull (no SSE) — fetch on activation.
|
||
if (target === 'stats') { refreshHiveStats(); }
|
||
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(); }
|
||
}
|
||
// ─── tabbar overflow menu ────────────────────────────────────────────────
|
||
// Tabs with `data-overflow="default"` (LOGS, SETTINGS) always live in
|
||
// the ⋮ dropdown. When the bar is too narrow to show all remaining tabs,
|
||
// rightmost tabs spill into the dropdown too (right-to-left).
|
||
//
|
||
// Implementation: all tabs stay in the DOM. The ⋮ wrapper sits at the
|
||
// flex end. `tab-overflowed` hides a tab from the bar (display:none).
|
||
// The dropdown is rebuilt from scratch on every call — it holds cloned
|
||
// <a>/<button> items, not the originals.
|
||
//
|
||
// IMPORTANT: these must be declared before syncTabFromHash() is called
|
||
// below — activateTab() calls updateTabbarOverflow() which closes over
|
||
// these variables, and const/let are not accessible before their
|
||
// declaration (TDZ).
|
||
|
||
const overflowWrap = $('tabbar-overflow');
|
||
const overflowBtn = $('tabbar-overflow-btn');
|
||
const overflowDrop = $('tabbar-overflow-dropdown');
|
||
let overflowOpen = false;
|
||
|
||
// ─── ST4TS: hive-wide turn-stats rollup ──────────────────────────────────
|
||
// Pull-only (no SSE): fetched from /api/stats-hive on tab activation and
|
||
// on window change. Plain tables/bars — the dashboard bundle has no chart
|
||
// lib, and per-agent trend charts live on each agent's own /stats page.
|
||
let hiveStatsWindow = '24h';
|
||
|
||
function hsFmtInt(n) {
|
||
return Number.isFinite(n) ? new Intl.NumberFormat().format(Math.round(n)) : '0';
|
||
}
|
||
function hsFmtTokens(n) {
|
||
if (!Number.isFinite(n)) return '0';
|
||
if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B';
|
||
if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M';
|
||
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k';
|
||
return String(Math.round(n));
|
||
}
|
||
function hsFmtUsd(n) {
|
||
if (!Number.isFinite(n)) return '$0';
|
||
if (n >= 100) return '$' + n.toFixed(0);
|
||
if (n >= 1) return '$' + n.toFixed(2);
|
||
return '$' + n.toFixed(3);
|
||
}
|
||
function hsChip(parent, label, value, est) {
|
||
const c = document.createElement('span');
|
||
c.className = 'hive-stats-chip' + (est ? ' est' : '');
|
||
const k = document.createElement('span'); k.className = 'k'; k.textContent = label;
|
||
const v = document.createElement('span'); v.className = 'v'; v.textContent = value;
|
||
c.append(k, v);
|
||
parent.append(c);
|
||
}
|
||
function hsMeta(parent, text) {
|
||
parent.replaceChildren();
|
||
const p = document.createElement('p');
|
||
p.className = 'meta';
|
||
p.textContent = text;
|
||
parent.append(p);
|
||
}
|
||
|
||
function renderHiveStats(s) {
|
||
const sum = $('hive-stats-summary');
|
||
if (sum) {
|
||
sum.replaceChildren();
|
||
hsChip(sum, 'window', s.window);
|
||
hsChip(sum, 'active agents', hsFmtInt(s.active_agents));
|
||
hsChip(sum, 'turns', hsFmtInt(s.total_turns));
|
||
const totalTok = (s.total_input_tokens || 0) + (s.total_output_tokens || 0)
|
||
+ (s.total_cache_read_tokens || 0) + (s.total_cache_creation_tokens || 0);
|
||
hsChip(sum, 'tokens', hsFmtTokens(totalTok));
|
||
hsChip(sum, 'input', hsFmtTokens(s.total_input_tokens));
|
||
hsChip(sum, 'output', hsFmtTokens(s.total_output_tokens));
|
||
hsChip(sum, 'cache read', hsFmtTokens(s.total_cache_read_tokens));
|
||
hsChip(sum, 'est cost', hsFmtUsd(s.est_cost_usd), true);
|
||
}
|
||
|
||
const at = $('hive-stats-agents');
|
||
if (at) {
|
||
const agents = s.agents || [];
|
||
if (!agents.length) {
|
||
hsMeta(at, 'no turns in window');
|
||
} else {
|
||
at.replaceChildren();
|
||
const table = document.createElement('table');
|
||
table.className = 'hive-stats-table';
|
||
table.innerHTML = '<thead><tr><th>agent</th><th>turns</th><th>input</th>'
|
||
+ '<th>output</th><th>cache read</th><th>est cost</th></tr></thead>';
|
||
const tb = document.createElement('tbody');
|
||
for (const a of agents) {
|
||
const tr = document.createElement('tr');
|
||
const cells = [
|
||
a.name, hsFmtInt(a.turns), hsFmtTokens(a.input_tokens),
|
||
hsFmtTokens(a.output_tokens), hsFmtTokens(a.cache_read_tokens),
|
||
hsFmtUsd(a.est_cost_usd),
|
||
];
|
||
cells.forEach((txt, i) => {
|
||
const td = document.createElement('td');
|
||
if (i > 0) td.className = 'num';
|
||
td.textContent = txt;
|
||
tr.append(td);
|
||
});
|
||
tb.append(tr);
|
||
}
|
||
table.append(tb);
|
||
at.append(table);
|
||
}
|
||
}
|
||
|
||
const mm = $('hive-stats-models');
|
||
if (mm) {
|
||
const mix = s.model_mix || [];
|
||
if (!mix.length) {
|
||
hsMeta(mm, 'no turns in window');
|
||
} else {
|
||
mm.replaceChildren();
|
||
const max = mix[0].count || 1;
|
||
for (const kc of mix) {
|
||
const row = document.createElement('div'); row.className = 'hive-stats-bar';
|
||
const lbl = document.createElement('span'); lbl.className = 'lbl'; lbl.textContent = kc.key;
|
||
const track = document.createElement('span'); track.className = 'track';
|
||
const fill = document.createElement('span'); fill.className = 'fill';
|
||
fill.style.width = Math.max(2, Math.round(100 * kc.count / max)) + '%';
|
||
track.append(fill);
|
||
const cnt = document.createElement('span'); cnt.className = 'cnt'; cnt.textContent = hsFmtInt(kc.count);
|
||
row.append(lbl, track, cnt);
|
||
mm.append(row);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
async function refreshHiveStats() {
|
||
try {
|
||
const resp = await fetch('/api/stats-hive?window=' + encodeURIComponent(hiveStatsWindow));
|
||
if (!resp.ok) throw new Error('http ' + resp.status);
|
||
renderHiveStats(await resp.json());
|
||
} catch (e) {
|
||
const at = $('hive-stats-agents');
|
||
if (at) hsMeta(at, 'stats fetch failed: ' + e);
|
||
}
|
||
}
|
||
|
||
function bindHiveStatsWindows() {
|
||
const tabs = $('hive-stats-windows');
|
||
if (!tabs) return;
|
||
tabs.addEventListener('click', (ev) => {
|
||
const btn = ev.target.closest('button[data-w]');
|
||
if (!btn) return;
|
||
hiveStatsWindow = btn.dataset.w;
|
||
for (const b of tabs.querySelectorAll('button')) b.classList.toggle('active', b === btn);
|
||
refreshHiveStats();
|
||
});
|
||
}
|
||
bindHiveStatsWindows();
|
||
|
||
// ─── 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; }
|
||
}
|
||
|
||
function syncTabFromHash() {
|
||
const h = (window.location.hash || '#swarm').replace(/^#/, '');
|
||
activateTab(h);
|
||
}
|
||
window.addEventListener('hashchange', () => {
|
||
syncTabFromHash();
|
||
updateTabbarOverflow();
|
||
});
|
||
syncTabFromHash();
|
||
|
||
function closeOverflowMenu() {
|
||
if (!overflowOpen) return;
|
||
overflowOpen = false;
|
||
if (overflowDrop) overflowDrop.hidden = true;
|
||
if (overflowBtn) overflowBtn.setAttribute('aria-expanded', 'false');
|
||
}
|
||
|
||
if (overflowBtn) {
|
||
overflowBtn.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
overflowOpen = !overflowOpen;
|
||
overflowDrop.hidden = !overflowOpen;
|
||
overflowBtn.setAttribute('aria-expanded', String(overflowOpen));
|
||
});
|
||
}
|
||
document.addEventListener('click', (e) => {
|
||
if (overflowOpen && !overflowWrap?.contains(e.target)) closeOverflowMenu();
|
||
});
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape' && overflowOpen) { closeOverflowMenu(); e.stopImmediatePropagation(); }
|
||
}, true);
|
||
|
||
function updateTabbarOverflow() {
|
||
const tabbar = $('tabbar');
|
||
if (!tabbar || !overflowBtn || !overflowDrop) return;
|
||
// Skip dropdown rebuild while it is open — the 1s badge tick would
|
||
// replace DOM nodes and cause a flicker mid-interaction.
|
||
if (overflowOpen) return;
|
||
|
||
// Collect all tabs that are not JS-hidden (P33RS/M4TR1X may be hidden
|
||
// by feature-gating) and not already removed from the DOM.
|
||
const allTabs = [...tabbar.querySelectorAll('.tab')];
|
||
|
||
// Separate default-overflow tabs from dynamic ones.
|
||
const defaultOverflow = allTabs.filter(t => t.dataset.overflow === 'default');
|
||
const dynamic = allTabs.filter(t => t.dataset.overflow !== 'default' && !t.hidden);
|
||
|
||
// Step 1: un-overflow all dynamic tabs so we can measure natural widths.
|
||
dynamic.forEach(t => t.classList.remove('tab-overflowed'));
|
||
|
||
// Step 2: compute the right boundary where visible tabs must end.
|
||
// getBoundingClientRect is used instead of clientWidth because
|
||
// clientWidth includes the tabbar's horizontal padding (~2em total),
|
||
// causing an over-allocation of ~2em; flex gap between tabs is also
|
||
// not captured by offsetWidth accumulation. Together these pushed
|
||
// the ⋮ button off the right edge of the screen.
|
||
const tabbarRect = tabbar.getBoundingClientRect();
|
||
const padR = parseFloat(getComputedStyle(tabbar).paddingRight) || 0;
|
||
// Right edge of the flex content area (inside right padding).
|
||
// Fall back to clientWidth-based estimate when the rect is zero
|
||
// (element not in layout, e.g. display:none ancestor).
|
||
const contentRight = tabbarRect.width > 0
|
||
? (tabbarRect.right - padR)
|
||
: (tabbar.clientWidth - padR);
|
||
// Space to reserve for the overflow wrapper. Use its actual offsetWidth
|
||
// when available; fall back to 40px on the first call (button hidden).
|
||
const btnReserve = (overflowWrap.offsetWidth || 40) + 4;
|
||
const cutoffRight = contentRight - btnReserve;
|
||
|
||
// Step 3: any tab whose right edge exceeds the cutoff is overflowed,
|
||
// along with all subsequent tabs (keeps the visible set contiguous
|
||
// and left-anchored). Once the first offending tab is found, all
|
||
// following tabs are also overflowed without re-measuring.
|
||
const dynamicOverflow = [];
|
||
for (const tab of dynamic) {
|
||
if (dynamicOverflow.length > 0 || tab.getBoundingClientRect().right > cutoffRight) {
|
||
dynamicOverflow.push(tab);
|
||
}
|
||
}
|
||
dynamicOverflow.forEach(t => t.classList.add('tab-overflowed'));
|
||
|
||
// Step 4: rebuild the dropdown from the two overflow sets.
|
||
const overflowedTabs = [...dynamicOverflow, ...defaultOverflow];
|
||
overflowDrop.replaceChildren();
|
||
for (const tab of overflowedTabs) {
|
||
const li = document.createElement('li');
|
||
li.setAttribute('role', 'presentation');
|
||
// Build a menu item that mirrors the tab's link/button behaviour.
|
||
const href = tab.getAttribute('href');
|
||
const item = href
|
||
? document.createElement('a')
|
||
: document.createElement('button');
|
||
if (href) {
|
||
item.href = href;
|
||
} else {
|
||
item.type = 'button';
|
||
item.addEventListener('click', () => {
|
||
closeOverflowMenu();
|
||
window.location.hash = tab.dataset.tab || '';
|
||
});
|
||
}
|
||
item.className = 'tabbar-overflow-item';
|
||
item.setAttribute('role', 'menuitem');
|
||
// Copy the tab label text.
|
||
const labelEl = tab.querySelector('.tab-label');
|
||
item.textContent = labelEl ? labelEl.textContent : (tab.textContent || '').trim();
|
||
// Active state: hash tabs match current route.
|
||
if (tab.dataset.tab && tab.classList.contains('active')) {
|
||
item.classList.add('tabbar-overflow-item-active');
|
||
}
|
||
// Count badge: copy from the tab's count pill if non-zero.
|
||
const countEl = tab.querySelector('.tab-count');
|
||
if (countEl && !countEl.hidden && countEl.textContent) {
|
||
const badge = document.createElement('span');
|
||
badge.className = 'tabbar-overflow-badge' + (countEl.classList.contains('tab-count-attn') ? ' tab-count-attn' : '');
|
||
badge.textContent = countEl.textContent;
|
||
item.append(badge);
|
||
}
|
||
if (href) {
|
||
item.addEventListener('click', closeOverflowMenu);
|
||
}
|
||
li.append(item);
|
||
overflowDrop.append(li);
|
||
}
|
||
|
||
// Step 5: show/hide the overflow button.
|
||
const hasItems = overflowedTabs.length > 0;
|
||
overflowBtn.hidden = !hasItems;
|
||
if (!hasItems) closeOverflowMenu();
|
||
|
||
// Step 6: mark the button active when the current tab is overflowed.
|
||
const activeTab = document.body.dataset.activeTab;
|
||
const activeIsOverflowed = overflowedTabs.some(t => t.dataset.tab === activeTab);
|
||
overflowBtn.classList.toggle('tabbar-overflow-active', activeIsOverflowed);
|
||
}
|
||
|
||
// Call on init and whenever the bar width changes.
|
||
updateTabbarOverflow();
|
||
if (typeof ResizeObserver !== 'undefined') {
|
||
const ro = new ResizeObserver(() => updateTabbarOverflow());
|
||
const tabbar = $('tabbar');
|
||
if (tabbar) ro.observe(tabbar);
|
||
}
|
||
// Also refresh after P33RS/M4TR1X visibility changes (they're toggled by
|
||
// refreshState). A MutationObserver on the tabbar catches attribute changes
|
||
// (hidden attr) on child tabs without coupling to specific render paths.
|
||
const tabbarEl = $('tabbar');
|
||
if (tabbarEl && typeof MutationObserver !== 'undefined') {
|
||
new MutationObserver(() => updateTabbarOverflow()).observe(tabbarEl, {
|
||
attributes: true, subtree: true, attributeFilter: ['hidden'],
|
||
});
|
||
}
|
||
|
||
// Tab count pills — pure derived data from the existing state
|
||
// stores so SSE-driven updates flow through without extra plumbing.
|
||
// Set `hidden` when the count is zero so the pill doesn't draw
|
||
// attention to an empty room.
|
||
// ─── operator inbox (#1469) — unread agent→operator messages ────────────
|
||
// The Y3R C4LL tab surfaces messages agents `send(to: "operator")` so
|
||
// the operator stops missing them. Unread = broker rows to "operator"
|
||
// with `acked_at IS NULL`; cold-loaded from `/api/operator-inbox`,
|
||
// appended live from the broker `sent` stream, and cleared via the
|
||
// existing per-recipient ack (`POST /api/agent/operator/mark-all-read`).
|
||
// Count folds into the Y3R C4LL pill + browser-title prefix.
|
||
let operatorInbox = []; // [{ id, from, body, at, file_refs }], newest-first
|
||
async function refreshOperatorInbox() {
|
||
try {
|
||
const r = await fetch('/api/operator-inbox');
|
||
if (r.ok) {
|
||
const data = await r.json();
|
||
operatorInbox = Array.isArray(data.messages) ? data.messages : [];
|
||
}
|
||
} catch { /* keep prior list on transient failure */ }
|
||
renderOperatorInbox();
|
||
refreshTabCounts();
|
||
}
|
||
function renderOperatorInbox() {
|
||
const root = $('operator-inbox-section');
|
||
if (!root) return;
|
||
root.replaceChildren();
|
||
if (!operatorInbox.length) {
|
||
root.append(el('p', { class: 'meta' }, 'no unread messages'));
|
||
return;
|
||
}
|
||
const mark = el('button', { type: 'button', class: 'btn', id: 'op-inbox-mark-read' },
|
||
`✓ mark all read (${operatorInbox.length})`);
|
||
mark.addEventListener('click', markOperatorInboxRead);
|
||
root.append(el('div', { class: 'inbox-toolbar' }, mark));
|
||
const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19);
|
||
const ul = el('ul', { class: 'inbox' });
|
||
for (const m of operatorInbox) {
|
||
const body = el('span', { class: 'msg-body' });
|
||
appendLinkified(body, m.body, m.file_refs);
|
||
ul.append(el('li', {},
|
||
el('span', { class: 'msg-ts' }, fmt(m.at)), ' ',
|
||
el('span', { class: 'msg-from' }, m.from), ' ',
|
||
el('span', { class: 'msg-sep' }, '→ '),
|
||
body,
|
||
));
|
||
}
|
||
root.append(ul);
|
||
}
|
||
async function markOperatorInboxRead() {
|
||
try { await fetch('/api/agent/operator/mark-all-read', { method: 'POST' }); }
|
||
catch { /* best-effort; the next refresh reconciles */ }
|
||
operatorInbox = [];
|
||
renderOperatorInbox();
|
||
refreshTabCounts();
|
||
}
|
||
// Live append from the broker stream — a `sent` frame addressed to
|
||
// "operator". De-dupes on broker row id so a history/live overlap or
|
||
// a refresh racing the stream doesn't double-list.
|
||
function operatorInboxAppendFromEvent(ev) {
|
||
if (ev.id != null && operatorInbox.some((m) => m.id === ev.id)) return;
|
||
operatorInbox.unshift({
|
||
id: ev.id, from: ev.from, body: ev.body, at: ev.at,
|
||
file_refs: ev.file_refs || [],
|
||
});
|
||
if (operatorInbox.length > 100) operatorInbox.length = 100;
|
||
renderOperatorInbox();
|
||
refreshTabCounts();
|
||
}
|
||
|
||
function setTabCount(tab, n) {
|
||
const el_ = $('tab-count-' + tab);
|
||
if (!el_) return;
|
||
el_.textContent = String(n);
|
||
el_.hidden = n <= 0;
|
||
}
|
||
/** Recompute every tab's count from the current state. Called on
|
||
* every renderXxx that's tab-relevant. */
|
||
function refreshTabCounts() {
|
||
// SW4RM — flag any container that's stale (needs_update). Empty
|
||
// when everyone's current. Container-row pulse signals state
|
||
// transitions; the pill catches "deploy-pending" specifically.
|
||
let swarm = 0;
|
||
for (const c of containersState.values()) {
|
||
if (c.needs_update) swarm++;
|
||
}
|
||
setTabCount('swarm', swarm);
|
||
// Y3R C4LL — pending approvals + operator-targeted questions +
|
||
// unread agent→operator messages (#1469).
|
||
const callCount =
|
||
(approvalsState?.pending?.length ?? 0) +
|
||
(questionsState?.pending?.length ?? 0) +
|
||
operatorInbox.length;
|
||
setTabCount('call', callCount);
|
||
// Browser tab title prefix — lets the operator see the pending
|
||
// call count without switching to the window. Strips any existing
|
||
// `(N) ` prefix before re-applying so identity-title updates
|
||
// (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).
|
||
setTabCount('schedules', activeScheduleCount());
|
||
// FL0W pill count: lives in ./flow.js now (it has the inbox
|
||
// derived store). Dashboard tab strip's `#tab-count-flow` slot
|
||
// stays hidden by default; future plumbing could broadcast the
|
||
// count via localStorage / BroadcastChannel if both pages are
|
||
// open.
|
||
|
||
// Re-render overflow dropdown so badges stay in sync.
|
||
updateTabbarOverflow();
|
||
}
|
||
// Poll the state stores on a 1s tick to keep the pill counts in
|
||
// sync. The state stores are mutated synchronously by every SSE
|
||
// event + refreshState call, so polling them is correct and cheap
|
||
// — no per-renderer hookup needed.
|
||
refreshTabCounts();
|
||
setInterval(refreshTabCounts, 1000);
|
||
|
||
// Flow-specific code (inbox-pill wiring, broker terminal init, the
|
||
// @-mention composer) lives in ./flow.js — loaded only by /flow.html.
|
||
})();
|