The hive no longer renders, locks or commits a meta flake. A rebuild's MetaSync node picks the internal/meta commit and records it with the hyperhive and config pins it holds; Prebuild, Swap and Create build from it, and a head that does not pin the agent's current config fails the build naming the agent. hive-priv's meta_rev is required. Removed: meta.rs sync/lock/render, the manual-rebuild relock, the boot sweep's hyperhive bump (MetaLock), the update-meta-inputs form and its panel, MetaWindow, the hive's hyperhive_rev setting. Kept: agent_set::refresh holds what sync_agents also did (topology.json, the gateway's agent-socket map and nginx include), called from spawn, destroy, rebuild and the migration. The boot reconcile and the stale-start upgrade compare the agent's marker with the hyperhive pin of internal/meta; the dashboard's deployed chip reads the config marker a successful build writes.
213 lines
8.7 KiB
Rust
213 lines
8.7 KiB
Rust
//! Unified dashboard event channel — all near-real-time browser events
|
||
//! flow through `Coordinator.dashboard_events`. Each event carries a
|
||
//! monotonic `seq` for client-side dedupe against `/api/state` snapshots.
|
||
//! Design rationale (single channel, broker forwarder isolation):
|
||
//! `docs/web-ui/shape.md::One unified channel`.
|
||
|
||
use serde::Serialize;
|
||
|
||
use crate::container_view::ContainerView;
|
||
use crate::dashboard::TombstoneView;
|
||
use chrono::{DateTime, Utc};
|
||
|
||
#[derive(Debug, Clone, Serialize)]
|
||
#[serde(rename_all = "snake_case", tag = "kind")]
|
||
pub enum DashboardEvent {
|
||
/// Broker `Sent` event mirrored onto the dashboard channel.
|
||
Sent {
|
||
seq: u64,
|
||
/// Broker row id. Allows the dashboard to track reply threads.
|
||
id: i64,
|
||
from: String,
|
||
to: String,
|
||
body: String,
|
||
at: DateTime<Utc>,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
in_reply_to: Option<i64>,
|
||
},
|
||
/// Broker `Delivered` event mirrored onto the dashboard channel.
|
||
Delivered {
|
||
seq: u64,
|
||
/// Broker row id. Allows the dashboard to track reply threads.
|
||
id: i64,
|
||
from: String,
|
||
to: String,
|
||
body: String,
|
||
at: DateTime<Utc>,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
in_reply_to: Option<i64>,
|
||
},
|
||
/// 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,
|
||
/// What the pill renders. For queue-driven work this is the running
|
||
/// node's own wire tag (`"swap"`, `"create"`, `"stop_for_update"`, …) —
|
||
/// the same vocabulary the DAG view ships. Work with no node behind it
|
||
/// (destroy, migration) supplies its own (`"destroying"`,
|
||
/// `"rebuilding"`).
|
||
///
|
||
/// Owned rather than `&'static str`: a label now comes from the node
|
||
/// that happens to be running, not from a fixed set.
|
||
transient_kind: String,
|
||
since_unix: i64,
|
||
},
|
||
/// The matching lifecycle action resolved (success or failure).
|
||
/// Clients drop the spinner row.
|
||
///
|
||
/// `transient_kind` matches the `TransientSet` that opened this pill, and is
|
||
/// what makes the pair addressable: an agent can hold **several** pills at
|
||
/// once (the transient set tests status alone, so a lease-exempt `Prebuild`
|
||
/// and a lease-holding `StopForUpdate` are both live). Without it a client
|
||
/// holding two open pills for one agent cannot tell which one cleared, and
|
||
/// has to drop both or guess.
|
||
TransientCleared {
|
||
seq: u64,
|
||
name: String,
|
||
transient_kind: String,
|
||
},
|
||
/// One container row changed — a new container appeared, an existing
|
||
/// one flipped `running` / `needs_update` /
|
||
/// `sha`, etc. Clients upsert by `container.name`. Payload carries
|
||
/// the full row so cold-loaded clients and event-driven clients
|
||
/// converge on the same render.
|
||
///
|
||
/// Fired by `Coordinator::rescan_containers_and_emit`, which diffs
|
||
/// a fresh `nixos-container list`–derived snapshot against the
|
||
/// last one cached on the coordinator. Mutation sites (lifecycle
|
||
/// endpoints, `actions::destroy`, `crash_watch`'s poll loop)
|
||
/// call the rescan after their work lands.
|
||
ContainerStateChanged { seq: u64, container: ContainerView },
|
||
/// A container that was in the previous snapshot is gone. Clients
|
||
/// drop the row by name. Fired alongside any
|
||
/// `nixos-container destroy` (operator-driven or otherwise) on the
|
||
/// next rescan.
|
||
ContainerRemoved { seq: u64, name: String },
|
||
/// Full snapshot of the tombstones list. Emitted by destroy (with
|
||
/// or without purge) and purge-tombstone. 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>,
|
||
},
|
||
|
||
/// The job queue changed — **a bare trigger, no payload.**
|
||
///
|
||
/// It used to carry a full typed snapshot of the queue. Nothing reads
|
||
/// that any more: the dashboard renders the queue from
|
||
/// `GET /api/jobq/graph`, so the event's whole job is telling a client
|
||
/// *when* to re-fetch. Shipping the graph twice — once here, once on
|
||
/// the endpoint — is two projections to keep in agreement for a client
|
||
/// that only ever used one.
|
||
///
|
||
/// Kept as an event rather than deleted in favour of polling because
|
||
/// push-on-change is what every other live surface here does
|
||
/// (transients, container state), and a poll loop would be
|
||
/// slower for the same information.
|
||
RebuildQueueChanged { seq: u64 },
|
||
}
|
||
|
||
impl DashboardEvent {
|
||
/// Snake-case identifier matching this variant's serde `tag`
|
||
/// (e.g. `Sent` → `"sent"`, `ContainerStateChanged` →
|
||
/// `"container_state_changed"`). Lets `/dashboard/stream`'s
|
||
/// `?kinds=` filter decide whether to forward a frame without
|
||
/// paying the JSON-serialise cost first.
|
||
///
|
||
/// Keep in sync with `#[serde(rename_all = "snake_case", tag =
|
||
/// "kind")]` on `DashboardEvent` — if a new variant lands above,
|
||
/// add it here too. `cargo test` covers this via the
|
||
/// `kind_tag_matches_serde_kind_field` round-trip test.
|
||
#[must_use]
|
||
pub fn kind_tag(&self) -> &'static str {
|
||
match self {
|
||
DashboardEvent::Sent { .. } => "sent",
|
||
DashboardEvent::Delivered { .. } => "delivered",
|
||
DashboardEvent::TransientSet { .. } => "transient_set",
|
||
DashboardEvent::TransientCleared { .. } => "transient_cleared",
|
||
DashboardEvent::ContainerStateChanged { .. } => "container_state_changed",
|
||
DashboardEvent::ContainerRemoved { .. } => "container_removed",
|
||
DashboardEvent::TombstonesChanged { .. } => "tombstones_changed",
|
||
|
||
DashboardEvent::RebuildQueueChanged { .. } => "rebuild_queue_changed",
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
/// Round-trip representative variants through serde and confirm
|
||
/// the `kind` JSON field matches `kind_tag()`. The exhaustive
|
||
/// `match` in `kind_tag` already provides compile-time variant
|
||
/// coverage — this test is the value-side guard against
|
||
/// typos in the `snake_case` strings vs serde's `rename_all`
|
||
/// output. `ContainerStateChanged` is omitted from the sample
|
||
/// list only because `ContainerView` has no `Default` impl and
|
||
/// constructing one inline here is more boilerplate than the
|
||
/// test is worth; the variant is still covered by the
|
||
/// `kind_tag` match arm.
|
||
#[test]
|
||
#[allow(
|
||
clippy::too_many_lines,
|
||
reason = "exhaustive coverage of every DashboardEvent variant — the \
|
||
length is the point"
|
||
)]
|
||
fn kind_tag_matches_serde_kind_field() {
|
||
let samples: Vec<DashboardEvent> = vec![
|
||
DashboardEvent::Sent {
|
||
seq: 1,
|
||
id: 1,
|
||
from: "a".into(),
|
||
to: "b".into(),
|
||
body: String::new(),
|
||
at: hive_sh4re::wire_time::from_secs(0),
|
||
in_reply_to: None,
|
||
},
|
||
DashboardEvent::Delivered {
|
||
seq: 1,
|
||
id: 1,
|
||
from: "a".into(),
|
||
to: "b".into(),
|
||
body: String::new(),
|
||
at: hive_sh4re::wire_time::from_secs(0),
|
||
in_reply_to: None,
|
||
},
|
||
DashboardEvent::TransientSet {
|
||
seq: 1,
|
||
name: "x".into(),
|
||
transient_kind: "rebuilding".into(),
|
||
since_unix: 0,
|
||
},
|
||
DashboardEvent::TransientCleared {
|
||
seq: 1,
|
||
name: "x".into(),
|
||
transient_kind: "rebuilding".into(),
|
||
},
|
||
DashboardEvent::ContainerRemoved {
|
||
seq: 1,
|
||
name: "x".into(),
|
||
},
|
||
DashboardEvent::TombstonesChanged {
|
||
seq: 1,
|
||
tombstones: Vec::new(),
|
||
},
|
||
DashboardEvent::RebuildQueueChanged { seq: 1 },
|
||
];
|
||
for ev in samples {
|
||
let v: serde_json::Value = serde_json::to_value(&ev).expect("serialise");
|
||
let serde_kind = v
|
||
.get("kind")
|
||
.and_then(|k| k.as_str())
|
||
.expect("kind field present");
|
||
assert_eq!(ev.kind_tag(), serde_kind, "kind_tag() drift on {ev:?}");
|
||
}
|
||
}
|
||
}
|