phase 6: container events + drop the 5s /api/state poll

new DashboardEvent::ContainerStateChanged + ContainerRemoved
close the last refetch loop on the dashboard. Coordinator's
rescan_containers_and_emit diffs a fresh container_view::build_all
against a cached last_containers map and fires per-row events.
called from actions::approve (post-spawn), actions::destroy,
the lifecycle_action wrapper, auto_update::rebuild_agent, and
the existing 10s crash_watch poll.

ContainerView extracted to its own module so coordinator and
dashboard can both build it. dashboard endpoints flip to 200;
container-lifecycle forms carry data-no-refresh. client drops
the periodic poll entirely — initial cold load + SSE for
everything afterwards. pending overlay reads from the existing
transientsState since the new event payload doesn't carry it.

PURG3 + meta-update keep the post-submit refetch since
tombstones + meta_inputs aren't event-derived yet; tracked in
TODO.md.
This commit is contained in:
müde 2026-05-17 22:01:15 +02:00
parent f153639cb4
commit e7ce35c503
11 changed files with 396 additions and 195 deletions

View file

@ -214,6 +214,27 @@
}
});
// Derived container state — cold-loaded from /api/state.containers,
// then mutated live by `container_state_changed` (upsert by name)
// and `container_removed` (drop by name). The coordinator's rescan
// helper fires these after every mutation site + on a periodic poll
// in crash_watch. Keyed by ContainerView.name so the lifecycle
// forms' POST → 200 → matching event flips the row without a
// snapshot refetch.
const containersState = new Map();
function syncContainersFromSnapshot(s) {
containersState.clear();
for (const c of s.containers || []) containersState.set(c.name, c);
}
function applyContainerStateChanged(ev) {
if (!ev.container || !ev.container.name) return;
containersState.set(ev.container.name, ev.container);
renderContainersFromState();
}
function applyContainerRemoved(ev) {
if (containersState.delete(ev.name)) renderContainersFromState();
}
// Derived 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
@ -251,27 +272,56 @@
if (s) renderContainers(s);
}
// 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 ────────────────────────────────────────────────────
function renderContainers(s) {
const root = $('containers-section');
root.innerHTML = '';
// 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).
const containers = Array.from(containersState.values())
.sort((a, b) => a.name.localeCompare(b.name));
const portConflicts = derivePortConflicts(containers);
const anyStale = containers.some((c) => c.needs_update);
// 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 (s.port_conflicts && s.port_conflicts.length) {
if (portConflicts.length) {
const banner = el('div', { class: 'port-conflict' },
el('strong', {}, '⚠ port collision'), ' — ');
const groups = s.port_conflicts.map((c) =>
const groups = portConflicts.map((c) =>
`:${c.port} (${c.agents.join(' + ')})`).join('; ');
banner.append(groups + '. rename one of each and ↻ R3BU1LD.');
root.append(banner);
}
if (s.any_stale) {
if (anyStale) {
root.append(form(
'/update-all', 'btn-rebuild', '↻ UPD4TE 4LL',
'rebuild every stale container?',
{}, { noRefresh: true },
));
}
@ -290,15 +340,20 @@
root.append(ul);
}
if (!s.containers.length && !transientsState.size) {
if (!containers.length && !transientsState.size) {
root.append(el('p', { class: 'empty' }, 'no managed containers'));
return;
}
const hostname = (s && s.hostname) || window.location.hostname;
const ul = el('ul', { class: 'containers' });
for (const c of s.containers) {
const url = `http://${s.hostname}:${c.port}/`;
const li = el('li', { class: 'container-row' + (c.pending ? ' pending' : '') });
for (const c of containers) {
const url = `http://${hostname}:${c.port}/`;
// Pending state is overlaid from the transient store, not from
// the container row — `ContainerStateChanged` doesn't carry it,
// `TransientSet` / `TransientCleared` do.
const pending = transientsState.get(c.name)?.kind || null;
const li = el('li', { class: 'container-row' + (pending ? ' pending' : '') });
// ── line 1: identity ─────────────────────────────────────────
const head = el('div', { class: 'head' });
@ -307,9 +362,9 @@
el('span', { class: c.is_manager ? 'role role-m1nd' : 'role role-ag3nt' },
c.is_manager ? 'm1nd' : 'ag3nt'),
);
if (c.pending) {
if (pending) {
head.append(el('span', { class: 'pending-state' },
el('span', { class: 'spinner' }, '◐'), ' ', c.pending + '…'));
el('span', { class: 'spinner' }, '◐'), ' ', pending + '…'));
} else if (c.needs_login) {
head.append(el('a',
{ class: 'badge badge-warn', href: url, target: '_blank', rel: 'noopener' },
@ -319,6 +374,7 @@
head.append(form(
'/rebuild/' + c.name, 'badge badge-warn btn-inline', 'needs update ↻',
'rebuild ' + c.name + '? hot-reloads the container.',
{}, { noRefresh: true },
));
}
head.append(el('span', { class: 'meta' }, `${c.container} :${c.port}`));
@ -333,29 +389,37 @@
const actions = el('div', { class: 'actions' });
if (c.running) {
actions.append(
form('/restart/' + c.name, 'btn-restart', '↺ R3ST4RT', 'restart ' + c.name + '?'),
form('/restart/' + c.name, 'btn-restart', '↺ R3ST4RT',
'restart ' + c.name + '?', {}, { noRefresh: true }),
);
if (!c.is_manager) {
actions.append(
form('/kill/' + c.name, 'btn-stop', '■ ST0P', 'stop ' + c.name + '?'),
form('/kill/' + c.name, 'btn-stop', '■ ST0P',
'stop ' + c.name + '?', {}, { noRefresh: true }),
);
}
} else {
actions.append(
form('/start/' + c.name, 'btn-start', '▶ ST4RT', 'start ' + c.name + '?'),
form('/start/' + c.name, 'btn-start', '▶ ST4RT',
'start ' + c.name + '?', {}, { noRefresh: true }),
);
}
actions.append(
form('/rebuild/' + c.name, 'btn-rebuild', '↻ R3BU1LD',
'rebuild ' + c.name + '? hot-reloads the container.'),
'rebuild ' + c.name + '? hot-reloads the container.',
{}, { noRefresh: true }),
);
if (!c.is_manager) {
// DESTR0Y is event-covered (ContainerRemoved); PURG3 also
// wipes tombstone state which isn't event-derived yet, so it
// keeps the post-submit refetch.
actions.append(
form('/destroy/' + c.name, 'btn-destroy', 'DESTR0Y',
'destroy ' + c.name + '? container is removed; state + creds kept.'),
'destroy ' + c.name + '? container is removed; state + creds kept.',
{}, { noRefresh: true }),
form('/destroy/' + c.name, 'btn-destroy', 'PURG3',
'PURGE ' + c.name + '? container, config history, claude creds, '
+ 'and /state/ notes are all WIPED. no undo.', { purge: 'on' }),
+ 'and notes are all WIPED. no undo.', { purge: 'on' }),
);
}
li.append(actions);
@ -1088,10 +1152,11 @@
// names from here instead of refetching on every keystroke).
window.__hyperhive_state = s;
const openDetails = snapshotOpenDetails();
// Sync transients first so renderContainers below sees the
// current derived map (it reads from `transientsState`, not
// from `s.transients`).
// Sync transients + containers first so renderContainers below
// sees the current derived maps (it reads from
// `transientsState` + `containersState`, not from `s.*`).
syncTransientsFromSnapshot(s);
syncContainersFromSnapshot(s);
renderContainers(s);
renderTombstones(s);
// Sync the derived approvals + questions stores from the
@ -1106,18 +1171,20 @@
renderMetaInputs(s);
restoreOpenDetails(openDetails);
notifyDeltas(s);
// Auto-refresh: fast (2s) while a spawn or a per-container
// action is in flight, otherwise heartbeat (5s) so newly-queued
// approvals from the manager show up without the operator
// having to reload the page. Broker SSE already triggers a
// refresh on operator-bound messages; this catches the rest
// (approvals, tombstones, questions).
const anyPending = s.containers.some((c) => c.pending);
const next = (transientsState.size || anyPending) ? 2000 : 5000;
// 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; }
if (next) pollTimer = setTimeout(refreshState, next);
} 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);
}
}
@ -1171,6 +1238,8 @@
question_resolved: (ev) => { applyQuestionResolved(ev); },
transient_set: (ev) => { applyTransientSet(ev); },
transient_cleared: (ev) => { applyTransientCleared(ev); },
container_state_changed: (ev) => { applyContainerStateChanged(ev); },
container_removed: (ev) => { applyContainerRemoved(ev); },
},
// Both history backfill and live frames flow through here, so the
// inbox section ends up populated correctly on first paint and
@ -1208,15 +1277,14 @@
prompt.textContent = stickyTo ? `@${stickyTo}>` : '@—>';
}
function knownAgents() {
const s = window.__hyperhive_state;
if (!s || !Array.isArray(s.containers)) return [];
// The broker uses the literal recipient `manager` for the
// manager's inbox, not the container name `hm1nd`. Swap on
// suggestion so `@manager` Just Works.
const names = s.containers.map((c) => (c.is_manager ? 'manager' : c.name));
// `*` fans out the message to every registered agent (server-side
// broadcast_send). Surface it as a suggestion so operators can
// type `@*` from the dashboard the same way the manager does.
// Read live from the derived containers map so newly-spawned
// agents become addressable without an /api/state refetch.
// Broker uses the literal recipient `manager` for the manager's
// inbox, not the container name `hm1nd`.
const names = Array.from(containersState.values())
.map((c) => (c.is_manager ? 'manager' : c.name));
// `*` fans out to every registered agent (server-side
// broadcast_send).
names.unshift('*');
return names;
}