Folds the SYST3M-tab removal into the same change that adds /core.html (per operator review — it should be one PR, not an additive page + a follow-up). Drops the tab + pane from dashboard.html and ~600 lines of now-dead renderers/wiring from tabs.js: renderMetaInputs, renderRebuildQueue + renderQueueEntry + the rebuild-queue row cache / glyph maps / fingerprint, renderTombstones, the container-load poll (cload* + start/stopContainerLoadPolling), the two rqe-* elapsed tickers, the meta/tombstone state + sync + apply handlers, their SSE dispatch entries, and the SYST3M tab-count. Deliberately KEPT in tabs.js: rebuildQueueState + syncRebuildQueueFromSnapshot + applyRebuildQueueChanged + inFlightOpsByAgent — these don't render the SYST3M panel, they drive the "building…" / "meta-updating…" badges on the SW4RM agent cards, so the dashboard still subscribes to rebuild_queue_changed to refresh those badges (applyRebuildQueueChanged now only re-renders containers, no panel render). core.css still imports dashboard.css for the shared section styles; splitting those out + de-duping the renderers into a shared module remains a follow-up.
2377 lines
103 KiB
JavaScript
2377 lines
103 KiB
JavaScript
// /dashboard.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
|
|
// `dashboard.html` each load their own bundle.
|
|
|
|
import { marked } from 'marked';
|
|
import {
|
|
$, el, esc, form,
|
|
fmtAgeSecs,
|
|
Panel, NOTIF,
|
|
makePathLink, appendText, appendLinkified,
|
|
openStream, renderServerWarnings,
|
|
} from './common.js';
|
|
import { createTabStrip } from '@hive/shared/tabs.js';
|
|
import { containersState, syncContainersFromSnapshot } from './state.js';
|
|
import { fmtAgo, truncate, fmtElapsed, fmtDuration } from './util.js';
|
|
import {
|
|
applyCapabilitiesChanged, applyToolGroupsChanged,
|
|
fetchAndRenderCapabilities, fetchAndRenderToolGroups,
|
|
} from './permissions.js';
|
|
import {
|
|
applySchedulesChanged, applyRemindersChanged,
|
|
refreshSchedules, refreshReminders, activeScheduleCount,
|
|
} from './schedules.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)
|
|
|
|
// 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; }
|
|
}
|
|
});
|
|
|
|
// The live agent roster (`containersState`) + its snapshot-sync now
|
|
// live in `./state.js` — it's the one piece of cross-domain state, read
|
|
// by nearly every tab. It's still mutated live by `container_state_changed`
|
|
// (upsert by name) and `container_removed` (drop by name) via the apply
|
|
// handlers just below; the imported binding reflects those in place.
|
|
//
|
|
// 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. Container-domain only,
|
|
// so it stays here (not in state.js).
|
|
const containerRowCache = new Map();
|
|
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 rebuild queue state — cold-loaded from
|
|
// `/api/state.rebuild_queue`, then mutated live by the
|
|
// `rebuild_queue_changed` snapshot event. The SYST3M tab that used to
|
|
// render this list moved to the standalone /core.html page; the
|
|
// dashboard keeps the STATE because it drives the "building…" /
|
|
// "meta-updating…" badges on the SW4RM agent cards (see
|
|
// inFlightOpsByAgent + docs/web-ui.md::Container row).
|
|
let rebuildQueueState = [];
|
|
function syncRebuildQueueFromSnapshot(s) {
|
|
rebuildQueueState = (s.rebuild_queue || []).slice();
|
|
}
|
|
function applyRebuildQueueChanged(ev) {
|
|
rebuildQueueState = (ev.queue || []).slice();
|
|
// 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();
|
|
}
|
|
// 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.
|
|
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;
|
|
}
|
|
|
|
// 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 /dashboard.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
|
|
// (renderQuestions, renderApprovals, 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);
|
|
}
|
|
|
|
// 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 /dashboard.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');
|
|
}
|
|
|
|
// Peer hives: render link cards as a headline section under SW4RM
|
|
// (state.peer_hives). Called on every state refresh. When nothing is
|
|
// federated, the "P33R H1V3S" headline block is hidden entirely and a
|
|
// quiet grey note replaces the cards.
|
|
function renderPeerHives(peers) {
|
|
const block = $('peers-block');
|
|
const root = $('peers-section');
|
|
if (!root) return;
|
|
root.replaceChildren();
|
|
const hasPeers = Array.isArray(peers) && peers.length > 0;
|
|
if (block) block.hidden = !hasPeers;
|
|
if (!hasPeers) {
|
|
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 /dashboard.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);
|
|
}
|
|
|
|
// ─── 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',
|
|
'questions-section',
|
|
'inbox-section',
|
|
'approvals-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;
|
|
// Peer hives render as a headline section under SW4RM (no longer
|
|
// a tab); renderPeerHives shows/hides its own headline block.
|
|
renderPeerHives(s.peer_hives || []);
|
|
renderServerWarnings(s.server_warnings);
|
|
// (The M4TR1X surface is reachable from the H0M3 hub now, not the
|
|
// dashboard tab strip — home.js gates its tile on matrix_gui_enabled.)
|
|
// Hive identity: render the swarm/hive name as a headline at the
|
|
// top of the SW4RM pane + update the 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 — the headline stays hidden.
|
|
const hiveId = $('swarm-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);
|
|
// Rebuild-queue state feeds the SW4RM agent-card badges
|
|
// (inFlightOpsByAgent); its own panel now lives on /core.html.
|
|
syncRebuildQueueFromSnapshot(s);
|
|
renderContainers(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();
|
|
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 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() (the enable/mute/unmute toggle UI) moved to /settings.html
|
|
// with the S3TT1NGS page. The dashboard keeps NOTIF.show() for approval/
|
|
// question notifications — it reads the browser permission + localStorage
|
|
// mute flag the settings page sets, so no bind() is needed here.
|
|
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 /dashboard.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 / meta_inputs_changed / meta_update_running are
|
|
// handled on /core.html now (the SYST3M panels moved there).
|
|
// rebuild_queue_changed stays: it refreshes the SW4RM badges.
|
|
rebuild_queue_changed: applyRebuildQueueChanged,
|
|
schedules_changed: applySchedulesChanged,
|
|
reminders_changed: applyRemindersChanged,
|
|
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 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` / `#permissions` /
|
|
// `#schedules` activate the matching pane on the dashboard. Empty
|
|
// hash defaults to SW4RM. FL0W, S3TT1NGS, and ST4TS are NOT tabs —
|
|
// they're separate pages (`/flow.html`, `/settings.html`,
|
|
// `/stats.html`) reached from the H0M3 hub. 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).
|
|
// The shared hash-routed tab strip (@hive/shared/tabs.js) owns the
|
|
// tab/pane toggle + aria-selected + routing; `activateTab` runs the
|
|
// per-tab side-effects via the strip's onShow. Constructed below (after
|
|
// the lazy-load fns it calls are defined). The responsive overflow ⋮
|
|
// menu was dropped — if the strip ever runs out of room we move panes
|
|
// to their own pages rather than hide them in a dropdown.
|
|
function activateTab(target) {
|
|
// Track active tab on the body so renderSelectionBar can gate
|
|
// visibility (bar only belongs on SW4RM where agent cards live).
|
|
document.body.dataset.activeTab = target;
|
|
renderSelectionBar(Array.from(containersState.values()));
|
|
// Re-fetch on activation as a safety net: SSE covers live mutations,
|
|
// re-sync covers disconnect windows / approval-path inserts that
|
|
// don't yet emit. SCH3DUL3S also re-fetches reminders (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();
|
|
}
|
|
if (target === 'call') { refreshOperatorInbox(); }
|
|
}
|
|
|
|
// ST4TS (hive-wide turn-stats rollup) moved to its own page,
|
|
// `/stats.html` — the render JS + the window selector live in
|
|
// stats.js now. The dashboard no longer fetches
|
|
// /api/stats-hive.
|
|
|
|
// Wire the shared tab strip now that activateTab + the lazy-load fns it
|
|
// calls are defined. The strip resolves the active tab from the hash
|
|
// (default SW4RM), toggles the active tab/pane + aria-selected, and
|
|
// fires activateTab for the per-tab side-effects on every change.
|
|
createTabStrip($('tabbar'), { defaultId: 'swarm', onShow: activateTab });
|
|
|
|
|
|
// 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 — 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.
|
|
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;
|
|
// 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());
|
|
}
|
|
// 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 (broker terminal init + the @-mention composer)
|
|
// lives in ./flow.js — loaded only by /flow.html.
|
|
})();
|