dashboard: transient_set / transient_cleared mutation events + client derived state

This commit is contained in:
müde 2026-05-17 14:20:51 +02:00
parent 1879b2f485
commit 7956e1c627
3 changed files with 103 additions and 7 deletions

View file

@ -202,6 +202,43 @@
}
});
// 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);
}
// ─── state rendering ────────────────────────────────────────────────────
function renderContainers(s) {
const root = $('containers-section');
@ -226,20 +263,22 @@
));
}
if (s.transients.length) {
if (transientsState.size) {
const ul = el('ul');
for (const t of s.transients) {
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' }, t.name), ' ',
el('span', { class: 'agent' }, name), ' ',
el('span', { class: 'role role-pending' }, t.kind + '…'), ' ',
el('span', { class: 'meta' }, `nixos-container create + start (${t.secs}s)`),
el('span', { class: 'meta' }, `nixos-container create + start (${secs}s)`),
));
}
root.append(ul);
}
if (!s.containers.length && !s.transients.length) {
if (!s.containers.length && !transientsState.size) {
root.append(el('p', { class: 'empty' }, 'no managed containers'));
return;
}
@ -1037,6 +1076,10 @@
// 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`).
syncTransientsFromSnapshot(s);
renderContainers(s);
renderTombstones(s);
// Sync the derived approvals + questions stores from the
@ -1058,7 +1101,7 @@
// refresh on operator-bound messages; this catches the rest
// (approvals, tombstones, questions).
const anyPending = s.containers.some((c) => c.pending);
const next = (s.transients.length || anyPending) ? 2000 : 5000;
const next = (transientsState.size || anyPending) ? 2000 : 5000;
if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
if (next) pollTimer = setTimeout(refreshState, next);
} catch (err) {
@ -1114,6 +1157,8 @@
approval_resolved: (ev) => { applyApprovalResolved(ev); },
question_added: (ev) => { applyQuestionAdded(ev); },
question_resolved: (ev) => { applyQuestionResolved(ev); },
transient_set: (ev) => { applyTransientSet(ev); },
transient_cleared: (ev) => { applyTransientCleared(ev); },
},
// Both history backfill and live frames flow through here, so the
// inbox section ends up populated correctly on first paint and