From aed43ce4df3386ecbc39aff8fcce6b32a0bc7fdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Sun, 17 May 2026 23:52:12 +0200 Subject: [PATCH] =?UTF-8?q?dashboard:=20tombstones=20+=20meta=5Finputs=20e?= =?UTF-8?q?vents=20=E2=80=94=20last=20/api/state=20refetches=20drop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit new DashboardEvent::TombstonesChanged + MetaInputsChanged carry full snapshots (lists are tiny; snapshot beats diff for race avoidance). Coordinator-side helpers emit_tombstones_snapshot + emit_meta_inputs_snapshot fire from every mutation site: actions::destroy + post_purge_tombstone + actions::approve (spawn finalise consumes tombstone) + run_meta_update + auto_update::rebuild_agent (lock bumps). client adds derived stores + apply* handlers + drops the post-submit refetch on PURG3 (container row + tombstone row) and meta-update. after this commit /api/state is fetched exactly once per page session (cold load); every other change rides the SSE channel. --- hive-c0re/assets/app.js | 45 +++++++++++++++++++-- hive-c0re/src/actions.rs | 11 ++++- hive-c0re/src/auto_update.rs | 2 + hive-c0re/src/dashboard.rs | 67 ++++++++++++++++++++++--------- hive-c0re/src/dashboard_events.rs | 22 ++++++++++ 5 files changed, 123 insertions(+), 24 deletions(-) diff --git a/hive-c0re/assets/app.js b/hive-c0re/assets/app.js index bd4a3e3..39facb8 100644 --- a/hive-c0re/assets/app.js +++ b/hive-c0re/assets/app.js @@ -335,6 +335,32 @@ if (containersState.delete(ev.name)) renderContainersFromState(); } + // Derived tombstones + meta_inputs. Both are emitted as full + // snapshots (not diffs) — the lists are tiny and recomputing + // avoids ordering races between a same-tick destroy + purge. + let tombstonesState = []; + let metaInputsState = []; + function syncTombstonesFromSnapshot(s) { + tombstonesState = (s.tombstones || []).slice(); + } + function syncMetaInputsFromSnapshot(s) { + metaInputsState = (s.meta_inputs || []).slice(); + } + function applyTombstonesChanged(ev) { + tombstonesState = (ev.tombstones || []).slice(); + renderTombstonesFromState(); + } + function applyMetaInputsChanged(ev) { + metaInputsState = (ev.inputs || []).slice(); + renderMetaInputsFromState(); + } + function renderTombstonesFromState() { + renderTombstones({ tombstones: tombstonesState }); + } + function renderMetaInputsFromState() { + renderMetaInputs({ meta_inputs: metaInputsState }); + } + // 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 @@ -512,14 +538,16 @@ 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. + // Both event-covered now (ContainerRemoved + + // TombstonesChanged); no /api/state refetch needed. actions.append( form('/destroy/' + c.name, 'btn-destroy', 'DESTR0Y', '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 notes are all WIPED. no undo.', { purge: 'on' }), + + 'and notes are all WIPED. no undo.', + { purge: 'on' }, { noRefresh: true }), ); } li.append(actions); @@ -683,8 +711,9 @@ actions.append(respawn); actions.append(form( '/purge-tombstone/' + t.name, 'btn-destroy', 'PURG3', - 'PURGE ' + t.name + '? config history, claude creds, /state/ notes ' - + 'are all WIPED. no undo.', + 'PURGE ' + t.name + '? config history, claude creds, ' + + 'and notes are all WIPED. no undo.', + {}, { noRefresh: true }, )); li.append(actions); ul.append(li); @@ -1214,6 +1243,10 @@ action: '/meta-update', class: 'meta-inputs-form', 'data-async': '', + // run_meta_update emits MetaInputsChanged once the lock + // bump finishes; per-agent rebuilds fire their own + // ContainerStateChanged. No /api/state refetch needed. + 'data-no-refresh': '', 'data-confirm': 'update selected meta flake inputs + rebuild affected agents?', }); const ul = el('ul', { class: 'meta-inputs' }); @@ -1419,6 +1452,8 @@ // `transientsState` + `containersState`, not from `s.*`). syncTransientsFromSnapshot(s); syncContainersFromSnapshot(s); + syncTombstonesFromSnapshot(s); + syncMetaInputsFromSnapshot(s); renderContainers(s); renderTombstones(s); // Sync the derived approvals + questions stores from the @@ -1513,6 +1548,8 @@ transient_cleared: (ev) => { applyTransientCleared(ev); }, container_state_changed: (ev) => { applyContainerStateChanged(ev); }, container_removed: (ev) => { applyContainerRemoved(ev); }, + tombstones_changed: (ev) => { applyTombstonesChanged(ev); }, + meta_inputs_changed: (ev) => { applyMetaInputsChanged(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/actions.rs b/hive-c0re/src/actions.rs index 32ce5af..edc837e 100644 --- a/hive-c0re/src/actions.rs +++ b/hive-c0re/src/actions.rs @@ -87,8 +87,12 @@ pub async fn approve(coord: Arc, id: i64) -> Result<()> { } // New container row appeared (or didn't, on failure // before nixos-container create completed) — rescan so - // dashboards reflect the post-spawn state. + // dashboards reflect the post-spawn state. Spawn can + // also consume a tombstone of the same name; emit the + // fresh list so the operator's dormant-state pane + // updates without a refetch. coord_bg.rescan_containers_and_emit().await; + crate::dashboard::emit_tombstones_snapshot(&coord_bg).await; }); Ok(()) } @@ -360,8 +364,11 @@ pub async fn destroy(coord: &Arc, name: &str, purge: bool) -> Resul agent: name.to_owned(), }); // Container row disappeared — rescan so the dashboard fires - // `ContainerRemoved` for the gone row. + // `ContainerRemoved` for the gone row, then emit the + // tombstones snapshot (gained one on destroy, lost one on + // purge — recompute either way). coord.rescan_containers_and_emit().await; + crate::dashboard::emit_tombstones_snapshot(coord).await; Ok(()) } diff --git a/hive-c0re/src/auto_update.rs b/hive-c0re/src/auto_update.rs index 4c714d3..36dc72c 100644 --- a/hive-c0re/src/auto_update.rs +++ b/hive-c0re/src/auto_update.rs @@ -99,6 +99,8 @@ pub async fn rebuild_agent(coord: &Arc, name: &str, current_rev: &s // shifted — rescan so dashboards drop the "needs update" // chip without waiting for the next /api/state poll. coord.rescan_containers_and_emit().await; + // Lock bump → meta-inputs panel needs to re-render. + crate::dashboard::emit_meta_inputs_snapshot(coord); } Err(e) => { coord.notify_manager(&hive_sh4re::HelperEvent::Rebuilt { diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index c23acb7..6c1d2db 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -193,15 +193,15 @@ struct PortConflict { agents: Vec, } -#[derive(Serialize)] -struct TombstoneView { - name: String, +#[derive(Serialize, Clone, Debug)] +pub(crate) struct TombstoneView { + pub name: String, /// Bytes used by the state dir tree. Cheap-ish to compute; let the /// operator know how much they're holding onto. - state_bytes: u64, + pub state_bytes: u64, /// Mtime (unix seconds) of the state dir; rough "last seen". - last_seen: i64, - has_creds: bool, + pub last_seen: i64, + pub has_creds: bool, } #[derive(Serialize)] @@ -356,19 +356,19 @@ fn build_port_conflicts(containers: &[ContainerView]) -> Vec { .collect() } -#[derive(Serialize, Clone)] -struct MetaInputView { +#[derive(Serialize, Clone, Debug)] +pub(crate) struct MetaInputView { /// Input key in meta's `flake.nix` — `hyperhive`, `agent-`, etc. - name: String, + pub name: String, /// Full locked sha. Not displayed verbatim; the dashboard /// truncates to the first 12 chars for the chip. - rev: String, + pub rev: String, /// Unix seconds — `locked.lastModified`. Drives the relative /// "2h ago" timestamp on each input row. - last_modified: i64, + pub last_modified: i64, /// `original.url` if available, for the tooltip / row meta text. #[serde(skip_serializing_if = "Option::is_none")] - url: Option, + pub url: Option, } /// Walk `flake.lock`'s `nodes` graph from `root` and emit one @@ -956,6 +956,36 @@ fn resolve_state_path(raw: &str) -> std::result::Result) { + let containers = coord.containers_snapshot().await; + let transient_snapshot = coord.transient_snapshot(); + let tombstones = build_tombstone_views(coord, &containers, &transient_snapshot); + coord.emit_dashboard_event( + crate::dashboard_events::DashboardEvent::TombstonesChanged { + seq: coord.next_seq(), + tombstones, + }, + ); +} + +/// Snapshot meta/flake.lock's root inputs + emit +/// `MetaInputsChanged`. Call after any mutation that bumps a lock +/// (`run_meta_update`, `auto_update::rebuild_agent`). +pub(crate) fn emit_meta_inputs_snapshot(coord: &Coordinator) { + let inputs = read_meta_inputs(); + coord.emit_dashboard_event( + crate::dashboard_events::DashboardEvent::MetaInputsChanged { + seq: coord.next_seq(), + inputs, + }, + ); +} + /// Scan `body` for path-shaped tokens, validate each against the /// allow-list, return the unique set of tokens that resolve to a /// regular file. Called at broker-message ingest time so the @@ -1094,9 +1124,10 @@ async fn post_purge_tombstone( .fail_pending_for_agent(&name, "agent state purged"); if errors.is_empty() { tracing::info!(%name, "tombstone purged"); - // Tombstones aren't event-derived yet, so the client still - // refetches /api/state to see this one disappear (matching - // form omits `data-no-refresh`). + // Fire the post-purge tombstones snapshot so dashboards + // drop the row live; matching form carries + // `data-no-refresh`. + emit_tombstones_snapshot(&state.coord).await; (StatusCode::OK, "ok").into_response() } else { error_response(&format!("purge {name} partial: {}", errors.join(", "))) @@ -1148,10 +1179,10 @@ async fn post_meta_update( let inputs_clone = inputs.clone(); tokio::spawn(async move { run_meta_update(&coord, &inputs_clone).await; + // Lock file changed — emit so dashboards refresh the + // meta-inputs panel without a snapshot poll. + emit_meta_inputs_snapshot(&coord); }); - // Background task — each per-agent rebuild emits its own - // `ContainerStateChanged`; the meta inputs panel still relies on - // /api/state freshness (matching form omits `data-no-refresh`). (StatusCode::OK, "ok").into_response() } diff --git a/hive-c0re/src/dashboard_events.rs b/hive-c0re/src/dashboard_events.rs index 4e531c2..86f7e94 100644 --- a/hive-c0re/src/dashboard_events.rs +++ b/hive-c0re/src/dashboard_events.rs @@ -26,6 +26,7 @@ use serde::Serialize; use crate::container_view::ContainerView; +use crate::dashboard::{MetaInputView, TombstoneView}; #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "snake_case", tag = "kind")] @@ -156,4 +157,25 @@ pub enum DashboardEvent { /// `nixos-container destroy` (operator-driven or otherwise) on the /// next rescan. ContainerRemoved { seq: u64, name: String }, + /// Full snapshot of the tombstones list. Emitted on every + /// mutation that could add / remove a tombstone: destroy + /// (with or without purge), purge-tombstone, spawn approval + /// (which can consume a tombstone of the same name). Snapshot + /// shape (not diff) because the list is tiny (single-digit + /// typical) and recomputing avoids the add/remove races a + /// per-row event would have. + TombstonesChanged { + seq: u64, + tombstones: Vec, + }, + /// Full snapshot of `meta/flake.lock`'s root inputs. Emitted + /// after every operation that bumps a lock: `meta-update`, + /// `rebuild_agent` (lock bumps via two-phase staging), + /// `update-all`. Same snapshot-shape rationale as + /// `TombstonesChanged` — the list is small (one row per agent + /// plus their fetched inputs). + MetaInputsChanged { + seq: u64, + inputs: Vec, + }, }