questions pane now shows both operator-targeted threads (target IS NULL) and agent-to-agent threads (target = some agent). filter chips above the list: all / @operator / @peer / per-participant. peer rows get a mauve left rule + a 0V3RR1D3 button that POSTs the same /answer-question endpoint (OperatorQuestions::answer already permits the operator as answerer on any target). wire changes: OperatorQuestions gains pending_all + recent_answered_all; QuestionAdded + QuestionResolved events carry target: Option<String>; emit sites drop their target.is_none() guard. answered-history rows show the answerer prefix so override answers are auditable at a glance.
148 lines
5.7 KiB
Rust
148 lines
5.7 KiB
Rust
//! Unified dashboard event channel.
|
||
//!
|
||
//! Anything the browser wants to react to in near-real-time flows through
|
||
//! `Coordinator.dashboard_events`. Each event is stamped with a monotonic
|
||
//! per-process `seq` so the client can dedupe its buffered live traffic
|
||
//! against snapshot/history responses (drop frames with
|
||
//! `seq <= snapshot.seq`).
|
||
//!
|
||
//! Why one channel instead of one-per-domain: browsers cap concurrent
|
||
//! SSE connections per origin (~6 in chrome) and dispatch-by-kind on the
|
||
//! client is a one-liner. Splits get reserved for high-volume sub-streams
|
||
//! that most consumers don't care about (none yet).
|
||
//!
|
||
//! Message-broker traffic (`Sent` / `Delivered`) lives on this channel
|
||
//! too. A background forwarder task in `main.rs` subscribes to the broker
|
||
//! and re-emits each `MessageEvent` as a `DashboardEvent::Sent` /
|
||
//! `DashboardEvent::Delivered` with a freshly-stamped seq. Keeping the
|
||
//! broker's intra-process channel separate avoids coupling the broker
|
||
//! (used by `recv_blocking` inside the harness loop) to dashboard
|
||
//! presentation concerns.
|
||
//!
|
||
//! New mutation kinds (approval added/resolved, question added/answered,
|
||
//! transient changed, etc.) land here as additional variants. The client
|
||
//! dispatches by `kind` and updates the relevant section.
|
||
|
||
use serde::Serialize;
|
||
|
||
use crate::container_view::ContainerView;
|
||
|
||
#[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,
|
||
from: String,
|
||
to: String,
|
||
body: String,
|
||
at: i64,
|
||
},
|
||
/// Broker `Delivered` event mirrored onto the dashboard channel.
|
||
Delivered {
|
||
seq: u64,
|
||
from: String,
|
||
to: String,
|
||
body: String,
|
||
at: i64,
|
||
},
|
||
/// A new approval landed in the pending queue. Payload carries
|
||
/// enough to render the dashboard row without a `/api/state`
|
||
/// refetch (`diff` is the raw unified diff text, same shape the
|
||
/// snapshot ships).
|
||
///
|
||
/// The approval's own kind (`"apply_commit"` / `"spawn"`) lives on
|
||
/// `approval_kind` rather than `kind` because the latter is taken
|
||
/// by the serde tag identifying which `DashboardEvent` variant
|
||
/// this is.
|
||
ApprovalAdded {
|
||
seq: u64,
|
||
id: i64,
|
||
agent: String,
|
||
approval_kind: &'static str,
|
||
sha_short: Option<String>,
|
||
diff: Option<String>,
|
||
description: Option<String>,
|
||
},
|
||
/// A pending approval transitioned to a terminal state
|
||
/// (approved / denied / failed). Clients move the row out of the
|
||
/// pending list and into history.
|
||
ApprovalResolved {
|
||
seq: u64,
|
||
id: i64,
|
||
agent: String,
|
||
approval_kind: &'static str,
|
||
sha_short: Option<String>,
|
||
/// `"approved"` / `"denied"` / `"failed"`.
|
||
status: &'static str,
|
||
resolved_at: i64,
|
||
note: Option<String>,
|
||
description: Option<String>,
|
||
},
|
||
/// A question landed in the queue. `target = None` means
|
||
/// operator-targeted (`Ask { to: None | Some("operator") }`);
|
||
/// `target = Some(<agent>)` means a peer-to-peer question. Both
|
||
/// are surfaced on the dashboard so the operator can monitor /
|
||
/// override-answer stuck threads.
|
||
QuestionAdded {
|
||
seq: u64,
|
||
id: i64,
|
||
asker: String,
|
||
question: String,
|
||
options: Vec<String>,
|
||
multi: bool,
|
||
asked_at: i64,
|
||
deadline_at: Option<i64>,
|
||
target: Option<String>,
|
||
},
|
||
/// A question was answered (operator answer, peer answer,
|
||
/// operator override on a peer thread, or ttl watchdog
|
||
/// `[expired]`). Clients move the row from pending to history.
|
||
/// `cancelled = true` when the operator dismissed via the cancel
|
||
/// button.
|
||
QuestionResolved {
|
||
seq: u64,
|
||
id: i64,
|
||
answer: String,
|
||
answerer: String,
|
||
answered_at: i64,
|
||
cancelled: bool,
|
||
target: Option<String>,
|
||
},
|
||
/// 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 },
|
||
/// One container row changed — new container appeared (post-spawn
|
||
/// finalise), 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 / approve, 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 },
|
||
}
|