dashboard: tombstones + meta_inputs events — last /api/state refetches drop

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.
This commit is contained in:
müde 2026-05-17 23:52:12 +02:00
parent 76e4034e01
commit aed43ce4df
5 changed files with 123 additions and 24 deletions

View file

@ -335,6 +335,32 @@
if (containersState.delete(ev.name)) renderContainersFromState(); 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, // Derived transient state — cold-loaded from /api/state.transients,
// then mutated live by `transient_set` / `transient_cleared`. Keyed // then mutated live by `transient_set` / `transient_cleared`. Keyed
// by agent name so add/remove are O(1). `since_unix` is wall-clock so // by agent name so add/remove are O(1). `since_unix` is wall-clock so
@ -512,14 +538,16 @@
if (!c.is_manager) { if (!c.is_manager) {
// DESTR0Y is event-covered (ContainerRemoved); PURG3 also // DESTR0Y is event-covered (ContainerRemoved); PURG3 also
// wipes tombstone state which isn't event-derived yet, so it // 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( actions.append(
form('/destroy/' + c.name, 'btn-destroy', 'DESTR0Y', 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 }), {}, { noRefresh: true }),
form('/destroy/' + c.name, 'btn-destroy', 'PURG3', form('/destroy/' + c.name, 'btn-destroy', 'PURG3',
'PURGE ' + c.name + '? container, config history, claude creds, ' '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); li.append(actions);
@ -683,8 +711,9 @@
actions.append(respawn); actions.append(respawn);
actions.append(form( actions.append(form(
'/purge-tombstone/' + t.name, 'btn-destroy', 'PURG3', '/purge-tombstone/' + t.name, 'btn-destroy', 'PURG3',
'PURGE ' + t.name + '? config history, claude creds, /state/ notes ' 'PURGE ' + t.name + '? config history, claude creds, '
+ 'are all WIPED. no undo.', + 'and notes are all WIPED. no undo.',
{}, { noRefresh: true },
)); ));
li.append(actions); li.append(actions);
ul.append(li); ul.append(li);
@ -1214,6 +1243,10 @@
action: '/meta-update', action: '/meta-update',
class: 'meta-inputs-form', class: 'meta-inputs-form',
'data-async': '', '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?', 'data-confirm': 'update selected meta flake inputs + rebuild affected agents?',
}); });
const ul = el('ul', { class: 'meta-inputs' }); const ul = el('ul', { class: 'meta-inputs' });
@ -1419,6 +1452,8 @@
// `transientsState` + `containersState`, not from `s.*`). // `transientsState` + `containersState`, not from `s.*`).
syncTransientsFromSnapshot(s); syncTransientsFromSnapshot(s);
syncContainersFromSnapshot(s); syncContainersFromSnapshot(s);
syncTombstonesFromSnapshot(s);
syncMetaInputsFromSnapshot(s);
renderContainers(s); renderContainers(s);
renderTombstones(s); renderTombstones(s);
// Sync the derived approvals + questions stores from the // Sync the derived approvals + questions stores from the
@ -1513,6 +1548,8 @@
transient_cleared: (ev) => { applyTransientCleared(ev); }, transient_cleared: (ev) => { applyTransientCleared(ev); },
container_state_changed: (ev) => { applyContainerStateChanged(ev); }, container_state_changed: (ev) => { applyContainerStateChanged(ev); },
container_removed: (ev) => { applyContainerRemoved(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 // Both history backfill and live frames flow through here, so the
// inbox section ends up populated correctly on first paint and // inbox section ends up populated correctly on first paint and

View file

@ -87,8 +87,12 @@ pub async fn approve(coord: Arc<Coordinator>, id: i64) -> Result<()> {
} }
// New container row appeared (or didn't, on failure // New container row appeared (or didn't, on failure
// before nixos-container create completed) — rescan so // 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; coord_bg.rescan_containers_and_emit().await;
crate::dashboard::emit_tombstones_snapshot(&coord_bg).await;
}); });
Ok(()) Ok(())
} }
@ -360,8 +364,11 @@ pub async fn destroy(coord: &Arc<Coordinator>, name: &str, purge: bool) -> Resul
agent: name.to_owned(), agent: name.to_owned(),
}); });
// Container row disappeared — rescan so the dashboard fires // 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; coord.rescan_containers_and_emit().await;
crate::dashboard::emit_tombstones_snapshot(coord).await;
Ok(()) Ok(())
} }

View file

@ -99,6 +99,8 @@ pub async fn rebuild_agent(coord: &Arc<Coordinator>, name: &str, current_rev: &s
// shifted — rescan so dashboards drop the "needs update" // shifted — rescan so dashboards drop the "needs update"
// chip without waiting for the next /api/state poll. // chip without waiting for the next /api/state poll.
coord.rescan_containers_and_emit().await; coord.rescan_containers_and_emit().await;
// Lock bump → meta-inputs panel needs to re-render.
crate::dashboard::emit_meta_inputs_snapshot(coord);
} }
Err(e) => { Err(e) => {
coord.notify_manager(&hive_sh4re::HelperEvent::Rebuilt { coord.notify_manager(&hive_sh4re::HelperEvent::Rebuilt {

View file

@ -193,15 +193,15 @@ struct PortConflict {
agents: Vec<String>, agents: Vec<String>,
} }
#[derive(Serialize)] #[derive(Serialize, Clone, Debug)]
struct TombstoneView { pub(crate) struct TombstoneView {
name: String, pub name: String,
/// Bytes used by the state dir tree. Cheap-ish to compute; let the /// Bytes used by the state dir tree. Cheap-ish to compute; let the
/// operator know how much they're holding onto. /// 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". /// Mtime (unix seconds) of the state dir; rough "last seen".
last_seen: i64, pub last_seen: i64,
has_creds: bool, pub has_creds: bool,
} }
#[derive(Serialize)] #[derive(Serialize)]
@ -356,19 +356,19 @@ fn build_port_conflicts(containers: &[ContainerView]) -> Vec<PortConflict> {
.collect() .collect()
} }
#[derive(Serialize, Clone)] #[derive(Serialize, Clone, Debug)]
struct MetaInputView { pub(crate) struct MetaInputView {
/// Input key in meta's `flake.nix` — `hyperhive`, `agent-<n>`, etc. /// Input key in meta's `flake.nix` — `hyperhive`, `agent-<n>`, etc.
name: String, pub name: String,
/// Full locked sha. Not displayed verbatim; the dashboard /// Full locked sha. Not displayed verbatim; the dashboard
/// truncates to the first 12 chars for the chip. /// truncates to the first 12 chars for the chip.
rev: String, pub rev: String,
/// Unix seconds — `locked.lastModified`. Drives the relative /// Unix seconds — `locked.lastModified`. Drives the relative
/// "2h ago" timestamp on each input row. /// "2h ago" timestamp on each input row.
last_modified: i64, pub last_modified: i64,
/// `original.url` if available, for the tooltip / row meta text. /// `original.url` if available, for the tooltip / row meta text.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
url: Option<String>, pub url: Option<String>,
} }
/// Walk `flake.lock`'s `nodes` graph from `root` and emit one /// Walk `flake.lock`'s `nodes` graph from `root` and emit one
@ -956,6 +956,36 @@ fn resolve_state_path(raw: &str) -> std::result::Result<std::path::PathBuf, Stri
Ok(canonical) Ok(canonical)
} }
/// Snapshot the current tombstone list and emit a
/// `TombstonesChanged` event. Call after any mutation that could
/// add or remove a tombstone (`actions::destroy`,
/// `post_purge_tombstone`, spawn finalisation). Cheap — the list
/// is tiny.
pub(crate) async fn emit_tombstones_snapshot(coord: &Arc<Coordinator>) {
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 /// Scan `body` for path-shaped tokens, validate each against the
/// allow-list, return the unique set of tokens that resolve to a /// allow-list, return the unique set of tokens that resolve to a
/// regular file. Called at broker-message ingest time so the /// 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"); .fail_pending_for_agent(&name, "agent state purged");
if errors.is_empty() { if errors.is_empty() {
tracing::info!(%name, "tombstone purged"); tracing::info!(%name, "tombstone purged");
// Tombstones aren't event-derived yet, so the client still // Fire the post-purge tombstones snapshot so dashboards
// refetches /api/state to see this one disappear (matching // drop the row live; matching form carries
// form omits `data-no-refresh`). // `data-no-refresh`.
emit_tombstones_snapshot(&state.coord).await;
(StatusCode::OK, "ok").into_response() (StatusCode::OK, "ok").into_response()
} else { } else {
error_response(&format!("purge {name} partial: {}", errors.join(", "))) error_response(&format!("purge {name} partial: {}", errors.join(", ")))
@ -1148,10 +1179,10 @@ async fn post_meta_update(
let inputs_clone = inputs.clone(); let inputs_clone = inputs.clone();
tokio::spawn(async move { tokio::spawn(async move {
run_meta_update(&coord, &inputs_clone).await; 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() (StatusCode::OK, "ok").into_response()
} }

View file

@ -26,6 +26,7 @@
use serde::Serialize; use serde::Serialize;
use crate::container_view::ContainerView; use crate::container_view::ContainerView;
use crate::dashboard::{MetaInputView, TombstoneView};
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case", tag = "kind")] #[serde(rename_all = "snake_case", tag = "kind")]
@ -156,4 +157,25 @@ pub enum DashboardEvent {
/// `nixos-container destroy` (operator-driven or otherwise) on the /// `nixos-container destroy` (operator-driven or otherwise) on the
/// next rescan. /// next rescan.
ContainerRemoved { seq: u64, name: String }, 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<TombstoneView>,
},
/// 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<MetaInputView>,
},
} }