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

View file

@ -105,6 +105,21 @@ pub enum TransientKind {
Destroying,
}
impl TransientKind {
/// Wire/UI label. Matches the strings the dashboard already
/// renders in the transient spinner.
pub fn as_str(self) -> &'static str {
match self {
TransientKind::Spawning => "spawning",
TransientKind::Starting => "starting",
TransientKind::Stopping => "stopping",
TransientKind::Restarting => "restarting",
TransientKind::Rebuilding => "rebuilding",
TransientKind::Destroying => "destroying",
}
}
}
impl Coordinator {
pub fn open(
db_path: &Path,
@ -318,10 +333,30 @@ impl Coordinator {
since: std::time::Instant::now(),
},
);
// Live-update dashboards. `since_unix` is wall-clock so the
// browser can tick "Ns spawning…" without polling. The
// intra-process map keeps using `Instant` for monotonicity.
let since_unix = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()
.and_then(|d| i64::try_from(d.as_secs()).ok())
.unwrap_or(0);
self.emit_dashboard_event(DashboardEvent::TransientSet {
seq: self.next_seq(),
name: name.to_owned(),
transient_kind: kind.as_str(),
since_unix,
});
}
pub fn clear_transient(&self, name: &str) {
self.transient.lock().unwrap().remove(name);
let removed = self.transient.lock().unwrap().remove(name).is_some();
if removed {
self.emit_dashboard_event(DashboardEvent::TransientCleared {
seq: self.next_seq(),
name: name.to_owned(),
});
}
}
/// Set a transient state and return a guard that clears it on drop.

View file

@ -105,4 +105,20 @@ pub enum DashboardEvent {
answered_at: i64,
cancelled: bool,
},
/// A lifecycle action started for an agent (spawn / start / stop
/// / restart / rebuild / destroy). Clients render a spinner next
/// to the row; the client computes "seconds in this state"
/// locally from `since_unix` so a slow rebuild's elapsed time
/// ticks without polling.
TransientSet {
seq: u64,
name: String,
/// Lifecycle kind: `"spawning"` / `"starting"` / `"stopping"` /
/// `"restarting"` / `"rebuilding"` / `"destroying"`.
transient_kind: &'static str,
since_unix: i64,
},
/// The matching lifecycle action resolved (success or failure).
/// Clients drop the spinner row.
TransientCleared { seq: u64, name: String },
}