//! Demo-only fleet control view (`demo-control` feature). //! //! A k8s-style control panel beside the fleet view: one row per provisioned //! process (streamed on `proc..lifecycle`), each with a kill action, //! plus a provision action that asks the reconciler for more nodes. Inert in //! regular builds — this module compiles only under `demo-control`. use std::collections::BTreeMap; use std::time::Instant; use parking_lot::Mutex; use serde::Serialize; use serde_json::Value; use telemetry::frame::{Frame, StreamId}; use crate::view::DashboardView; use crate::FrameEvent; #[derive(Clone, Serialize)] struct ProcessEntry { node: String, pid: Option, state: String, seen_ms_ago: u64, } #[derive(Serialize)] struct ControlSnapshot { nodes: Vec, } #[derive(Default)] struct ProcessState { pid: Option, state: String, seen: Option, } #[derive(Default)] pub struct DemoControlView { processes: Mutex>, } fn is_lifecycle_channel(channel: &str) -> bool { channel.starts_with("proc.") && channel.ends_with(".lifecycle") } impl DashboardView for DemoControlView { fn id(&self) -> &'static str { "demo-control" } fn title(&self) -> &'static str { "Fleet Control" } fn path(&self) -> &'static str { "demo-control" } fn channels(&self) -> &'static [&'static str] { &[] } fn ingest(&self, _stream: &StreamId, _frame: &Frame, event: &FrameEvent) { if !is_lifecycle_channel(&event.channel) && event.channel != "node.status" { return; } let Ok(payload) = serde_json::from_slice::(&event.payload) else { return; }; let node = event.stream.node.clone(); let mut processes = self.processes.lock(); let entry = processes.entry(node).or_default(); entry.seen = Some(Instant::now()); if let Some(pid) = payload.get("pid").and_then(Value::as_u64) { entry.pid = Some(pid as u32); } if let Some(kind) = payload.get("event").and_then(Value::as_str) { entry.state = match kind { "started" => "running".to_owned(), "exited" => "exited".to_owned(), "spawn_failed" | "error" => "failed".to_owned(), other => other.to_owned(), }; } } fn snapshot_json(&self) -> Value { let now = Instant::now(); let nodes: Vec = self .processes .lock() .iter() .map(|(node, state)| ProcessEntry { node: node.clone(), pid: state.pid, state: state.state.clone(), seen_ms_ago: state .seen .map(|seen| now.duration_since(seen).as_millis() as u64) .unwrap_or(u64::MAX), }) .collect(); serde_json::to_value(ControlSnapshot { nodes }) .unwrap_or_else(|_| serde_json::json!({ "nodes": [] })) } fn html(&self) -> Option<&'static str> { Some(include_str!("demo_control_page.html")) } }