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:
parent
f153639cb4
commit
e7ce35c503
11 changed files with 396 additions and 195 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue