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

@ -193,15 +193,15 @@ struct PortConflict {
agents: Vec<String>,
}
#[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<PortConflict> {
.collect()
}
#[derive(Serialize, Clone)]
struct MetaInputView {
#[derive(Serialize, Clone, Debug)]
pub(crate) struct MetaInputView {
/// Input key in meta's `flake.nix` — `hyperhive`, `agent-<n>`, 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<String>,
pub url: Option<String>,
}
/// 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)
}
/// 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
/// 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()
}