swactor/crates/dashboard/src/demo_control.rs

113 lines
3.2 KiB
Rust
Raw Normal View History

//! 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.<node>.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<u32>,
state: String,
seen_ms_ago: u64,
}
#[derive(Serialize)]
struct ControlSnapshot {
nodes: Vec<ProcessEntry>,
}
#[derive(Default)]
struct ProcessState {
pid: Option<u32>,
state: String,
seen: Option<Instant>,
}
#[derive(Default)]
pub struct DemoControlView {
processes: Mutex<BTreeMap<String, ProcessState>>,
}
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::<Value>(&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<ProcessEntry> = 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"))
}
}