From 7956e1c627c00078bb698b18ed5b8e6a194fd5c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Sun, 17 May 2026 14:20:51 +0200 Subject: [PATCH] dashboard: transient_set / transient_cleared mutation events + client derived state --- hive-c0re/assets/app.js | 57 +++++++++++++++++++++++++++---- hive-c0re/src/coordinator.rs | 37 +++++++++++++++++++- hive-c0re/src/dashboard_events.rs | 16 +++++++++ 3 files changed, 103 insertions(+), 7 deletions(-) diff --git a/hive-c0re/assets/app.js b/hive-c0re/assets/app.js index 7c2a0b7..5fd0030 100644 --- a/hive-c0re/assets/app.js +++ b/hive-c0re/assets/app.js @@ -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 diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index d9d6575..35c486f 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -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. diff --git a/hive-c0re/src/dashboard_events.rs b/hive-c0re/src/dashboard_events.rs index 0075bf2..4426108 100644 --- a/hive-c0re/src/dashboard_events.rs +++ b/hive-c0re/src/dashboard_events.rs @@ -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 }, }