//! Fused control-plane view: machine stats and actor stats per node stream. //! //! swactor is the control plane for data and hardware; this page surfaces //! both. Every telemetry stream (`node#life`) folds into one [`FusedNode`] //! holding the machine side ([`NodeHardwareState`]: `host.*` samples, //! managed-process lifecycle) and the actor side ([`RuntimeState`]: //! `runtime.actors` / `runtime.stats` frames). The page renders three //! levels: node cards → per-node roster → per-actor dossier (via the //! `/api/view/fleet/detail` endpoint). //! //! Stale streams (silent beyond [`LIVE_TTL`]) leave the live pool: they //! render in a separate collapsed section and are physically capped at //! [`STALE_POOL_CAP`]. A newer `life` generation for the same node evicts //! older generations immediately — restarts stop accumulating. use std::collections::BTreeMap; use std::time::{Duration, Instant}; use telemetry::frame::{Frame, StreamId}; use parking_lot::RwLock; use serde::Serialize; use serde_json::{Value, json}; use crate::hardware_view::{ CpuSnapshot, GpuSnapshot, HardwareHistorySnapshot, NodeHardwareState, duration_ms, saturating_u32, }; use crate::swactor::{ActorState, RuntimeState}; use crate::view::DashboardView; use crate::{FrameEvent, StreamEvent}; const CONTROL_PLANE_HTML: &str = include_str!("control_plane_page.html"); /// A stream is live while any frame arrived within this window. const LIVE_TTL: Duration = Duration::from_secs(8); /// Hard cap on retained stale streams; oldest are evicted. const STALE_POOL_CAP: usize = 50; /// Fused control-plane view serving `/` and `/view/fleet`. #[derive(Default)] pub struct ControlPlaneView { state: RwLock, } #[derive(Default)] struct FusedState { streams: BTreeMap, } struct FusedNode { stream: StreamEvent, last_seen: Instant, hardware: NodeHardwareState, actors: RuntimeState, } impl DashboardView for ControlPlaneView { fn id(&self) -> &'static str { "fleet" } fn title(&self) -> &'static str { "Fleet" } fn path(&self) -> &'static str { "fleet" } fn channels(&self) -> &'static [&'static str] { // All channels: the machine side and the actor side each ignore what // does not concern them. &[] } fn ingest(&self, _stream: &StreamId, _frame: &Frame, event: &FrameEvent) { let now = Instant::now(); let mut state = self.state.write(); let key = stream_key(&event.stream); let node = state.streams.entry(key).or_insert_with(|| FusedNode { stream: event.stream.clone(), last_seen: now, hardware: NodeHardwareState::new(now), actors: RuntimeState::new(now), }); node.last_seen = now; node.hardware.update(&event.channel, &event.payload, now); node.actors.update(&event.channel, &event.payload, now); prune(&mut state.streams, &event.stream, now); } fn snapshot_json(&self) -> Value { let now = Instant::now(); let state = self.state.read(); let mut live = Vec::new(); let mut stale = Vec::new(); for node in state.streams.values() { let card = node_card(node, now); if card.live { live.push(card); } else { stale.push(card); } } live.sort_by(|left, right| { left.stream .node .cmp(&right.stream.node) .then_with(|| left.stream.life.cmp(&right.stream.life)) }); // Stale pool: most recently seen first, bounded by the physical cap. stale.sort_by_key(|right| std::cmp::Reverse(right.last_seen_ms_ago)); stale.truncate(STALE_POOL_CAP); let totals = fused_totals(&live, stale.len()); let snapshot = FusedSnapshot { totals, live, stale }; serde_json::to_value(snapshot).unwrap_or_else(|_| { json!({ "totals": FusedTotals::default(), "live": [], "stale": [] }) }) } fn detail_json(&self, query: &str) -> Option { let stream = query_param(query, "stream")?; let actor_key = query_param(query, "actor")?; let now = Instant::now(); let state = self.state.read(); let node = state.streams.get(&stream)?; let actor = node.actors.actors.get(&actor_key)?; Some(actor_detail(actor, &node.stream, &stream, now)) } fn html(&self) -> Option<&'static str> { Some(CONTROL_PLANE_HTML) } } /// Evict superseded life generations and enforce the stale-pool cap. fn prune(streams: &mut BTreeMap, fresh: &StreamEvent, now: Instant) { // A newer life generation for the same node replaces older ones: the old // process is gone by construction once its successor publishes. let superseded: Vec = streams .iter() .filter(|(_, node)| { node.stream.node == fresh.node && node.stream.life < fresh.life }) .map(|(key, _)| key.clone()) .collect(); for key in superseded { streams.remove(&key); } let mut stale: Vec<(String, Instant)> = streams .iter() .filter(|(_, node)| now.duration_since(node.last_seen) > LIVE_TTL) .map(|(key, node)| (key.clone(), node.last_seen)) .collect(); if stale.len() > STALE_POOL_CAP { stale.sort_by_key(|(_, seen)| *seen); let excess = stale.len() - STALE_POOL_CAP; for (key, _) in stale.into_iter().take(excess) { streams.remove(&key); } } } #[derive(Serialize)] struct FusedSnapshot { totals: FusedTotals, live: Vec, stale: Vec, } #[derive(Default, Serialize)] struct FusedTotals { nodes: u32, live_nodes: u32, stale_nodes: u32, actors: u32, msg_per_sec: f64, mailbox_depth: u32, poisoned: u32, gpu_count: u32, cpu_avg_percent: Option, gpu_max_percent: Option, gpu_memory_used_mib: u64, gpu_memory_total_mib: u64, net_rx_bps: f64, net_tx_bps: f64, errors: u32, } #[derive(Serialize)] struct NodeCard { stream: StreamKeySnapshot, live: bool, last_seen_ms_ago: u64, last_sample_unix_ms: Option, errors: Vec, cpu: Option, gpu: Option, net: Option, process: Option, history: Vec, actor_summary: ActorSummarySnapshot, /// Aggregate-only roster rows; heavy per-actor detail lives behind the /// detail endpoint so snapshot payload stays independent of ring sizes. roster: Vec, } #[derive(Serialize)] struct StreamKeySnapshot { key: String, node: String, life: u64, } #[derive(Default, Serialize)] struct ActorSummarySnapshot { actors: u32, msg_per_sec: f64, mailbox_depth: u32, poisoned: u32, uptime_ms: Option, num_workers: Option, } #[derive(Serialize)] struct RosterRow { address: String, name: Option, actor_type: Option, message_type: Option, state: &'static str, mailbox_depth: u32, messages_processed: u64, msg_per_sec: f64, worker_id: Option, poisoned: bool, last_msg_type: Option, } #[derive(Serialize)] struct ActorDetail { stream: StreamKeySnapshot, address: String, name: Option, actor_type: Option, message_type: Option, worker_id: Option, state: &'static str, poisoned: bool, mailbox_depth: u32, mailbox_growth: f64, messages_processed: u64, msg_per_sec: f64, last_msg_type: Option, message_type_counts: Vec, history: Vec, receipts: Vec, sampled_out: u64, } #[derive(Serialize)] struct MessageTypeCountSnapshot { message_type: String, count: u64, } #[derive(Serialize)] struct ActorHistoryPoint { ms_ago: u64, mailbox_depth: u32, msg_per_sec: f64, } #[derive(Serialize)] struct ReceiptSnapshot { ms_ago: u64, ty: String, folded: u64, } fn stream_key(stream: &StreamEvent) -> String { format!("{}#{}", stream.node, stream.life) } fn node_card(node: &FusedNode, now: Instant) -> NodeCard { let summary = node.hardware.summary(); let totals = node.actors.totals(); let mut roster: Vec = node .actors .actors .values() .map(roster_row) .collect(); // Busiest actors first; ties fall back to address for stable rendering. roster.sort_by(|left, right| { right .msg_per_sec .partial_cmp(&left.msg_per_sec) .map_or(std::cmp::Ordering::Equal, |order| order) .then_with(|| left.address.cmp(&right.address)) }); NodeCard { stream: StreamKeySnapshot { key: stream_key(&node.stream), node: node.stream.node.clone(), life: node.stream.life, }, live: now.duration_since(node.last_seen) <= LIVE_TTL, last_seen_ms_ago: duration_ms(now.duration_since(node.last_seen)), last_sample_unix_ms: summary.sample_unix_ms, errors: node.hardware.errors(), cpu: node.hardware.cpu.as_ref().map(CpuSnapshot::from), gpu: node.hardware.gpu.as_ref().map(GpuSnapshot::from), net: node.hardware.net.clone(), process: node.hardware.process.clone(), history: node .hardware .history .iter() .map(|sample| HardwareHistorySnapshot { ms_ago: duration_ms(now.duration_since(sample.at)), sample_unix_ms: sample.sample_unix_ms, cpu_total_percent: sample.cpu_total_percent, gpu_max_percent: sample.gpu_max_percent, gpu_memory_used_mib: sample.gpu_memory_used_mib, gpu_memory_total_mib: sample.gpu_memory_total_mib, net_rx_bps: sample.net_rx_bps, net_tx_bps: sample.net_tx_bps, }) .collect(), actor_summary: ActorSummarySnapshot { actors: totals.actors, msg_per_sec: totals.msg_per_sec, mailbox_depth: totals.mailbox_depth, poisoned: totals.poisoned, uptime_ms: node.actors.uptime_ms, num_workers: node.actors.num_workers, }, roster, } } fn roster_row(actor: &ActorState) -> RosterRow { RosterRow { address: actor.address.clone(), name: actor.name.clone(), actor_type: actor.actor_type.clone(), message_type: actor.message_type.clone(), state: if actor.poisoned { "poisoned" } else { "running" }, mailbox_depth: actor.mailbox_depth, messages_processed: actor.messages_processed, msg_per_sec: actor.msg_per_sec, worker_id: actor.worker_id, poisoned: actor.poisoned, last_msg_type: actor.last_msg_type.clone(), } } fn actor_detail( actor: &ActorState, stream: &StreamEvent, stream_key_value: &str, now: Instant, ) -> Value { let detail = ActorDetail { stream: StreamKeySnapshot { key: stream_key_value.to_owned(), node: stream.node.clone(), life: stream.life, }, address: actor.address.clone(), name: actor.name.clone(), actor_type: actor.actor_type.clone(), message_type: actor.message_type.clone(), worker_id: actor.worker_id, state: if actor.poisoned { "poisoned" } else { "running" }, poisoned: actor.poisoned, mailbox_depth: actor.mailbox_depth, mailbox_growth: actor.mailbox_growth, messages_processed: actor.messages_processed, msg_per_sec: actor.msg_per_sec, last_msg_type: actor.last_msg_type.clone(), message_type_counts: actor .message_type_counts .iter() .map(|(message_type, count)| MessageTypeCountSnapshot { message_type: message_type.clone(), count: *count, }) .collect(), history: actor .history .iter() .map(|sample| ActorHistoryPoint { ms_ago: duration_ms(now.duration_since(sample.at)), mailbox_depth: sample.mailbox_depth, msg_per_sec: sample.msg_per_sec, }) .collect(), receipts: actor .receipts .iter() .map(|receipt| ReceiptSnapshot { ms_ago: duration_ms(now.duration_since(receipt.at)), ty: receipt.ty.clone(), folded: receipt.folded, }) .collect(), sampled_out: actor.sampled_out, }; serde_json::to_value(detail).unwrap_or_else(|_| json!({})) } fn fused_totals(live: &[NodeCard], stale_len: usize) -> FusedTotals { let mut totals = FusedTotals { nodes: saturating_u32(live.len() + stale_len), live_nodes: saturating_u32(live.len()), stale_nodes: saturating_u32(stale_len), ..FusedTotals::default() }; let mut cpu_total = 0.0; let mut cpu_count = 0_u32; for node in live { let summary = &node.actor_summary; totals.actors = totals.actors.saturating_add(summary.actors); totals.msg_per_sec += summary.msg_per_sec; totals.mailbox_depth = totals.mailbox_depth.saturating_add(summary.mailbox_depth); totals.poisoned = totals.poisoned.saturating_add(summary.poisoned); totals.errors = totals.errors.saturating_add(saturating_u32(node.errors.len())); if let Some(cpu_percent) = node .cpu .as_ref() .and_then(|cpu| cpu.host.as_ref()) .and_then(|host| host.total_percent) { cpu_total += cpu_percent; cpu_count = cpu_count.saturating_add(1); } if let Some(gpu) = &node.gpu { totals.gpu_count = totals.gpu_count.saturating_add(saturating_u32(gpu.gpus.len())); for device in &gpu.gpus { if let Some(percent) = device.utilization_gpu_percent { totals.gpu_max_percent = Some( totals .gpu_max_percent .map_or(percent, |current| current.max(percent)), ); } totals.gpu_memory_used_mib = totals .gpu_memory_used_mib .saturating_add(device.memory_used_mib.unwrap_or_default()); totals.gpu_memory_total_mib = totals .gpu_memory_total_mib .saturating_add(device.memory_total_mib.unwrap_or_default()); } } if let Some(net) = &node.net { for interface in &net.interfaces { totals.net_rx_bps += interface.rx_bps.unwrap_or_default(); totals.net_tx_bps += interface.tx_bps.unwrap_or_default(); } } } if cpu_count > 0 { totals.cpu_avg_percent = Some(cpu_total / f64::from(cpu_count)); } totals } /// Minimal `application/x-www-form-urlencoded` reader with percent-decoding /// (stream keys contain `#`, encoded as `%23`). fn query_param(query: &str, key: &str) -> Option { for pair in query.split('&') { let mut parts = pair.splitn(2, '='); if parts.next() == Some(key) { return parts.next().map(percent_decode); } } None } fn percent_decode(value: &str) -> String { let bytes = value.as_bytes(); let mut out = Vec::with_capacity(bytes.len()); let mut index = 0; while index < bytes.len() { if bytes[index] == b'%' { let hex = bytes .get(index + 1..index + 3) .and_then(|slice| std::str::from_utf8(slice).ok()) .and_then(|slice| u8::from_str_radix(slice, 16).ok()); if let Some(byte) = hex { out.push(byte); index += 3; continue; } } out.push(bytes[index]); index += 1; } String::from_utf8_lossy(&out).into_owned() } #[cfg(test)] mod tests { use super::*; use telemetry::frame::{ChannelId, Lifetime, NodeId, Position}; fn ingest_json( view: &ControlPlaneView, stream: &StreamId, position: u64, channel: &str, payload: Vec, ) { let frame = Frame::new(ChannelId(1), Position(position), payload); let event = FrameEvent { stream: crate::StreamEvent { node: stream.node.as_str().to_string(), life: stream.life.0, }, channel: channel.to_string(), position, payload: frame.payload.clone(), }; view.ingest(stream, &frame, &event); } fn actors_payload(worker: u32, actors: serde_json::Value) -> Vec { serde_json::json!({ "worker_id": worker, "actors": actors }) .to_string() .into_bytes() } #[test] fn live_and_stale_streams_render_in_separate_pools() { let view = ControlPlaneView::default(); let live = StreamId::new(NodeId::new("live-node"), Lifetime(1)); let stale = StreamId::new(NodeId::new("stale-node"), Lifetime(1)); ingest_json( &view, &live, 0, "runtime.actors", actors_payload( 0, serde_json::json!([{ "address": "aa", "actor_type": "PingActor", "messages_processed": 3, "last_msg_type": "Ping" }]), ), ); ingest_json( &view, &stale, 0, "runtime.actors", actors_payload(0, serde_json::json!([{ "address": "bb" }])), ); let snapshot = view.snapshot_json(); // Freshly ingested: both live. assert_eq!(snapshot["live"].as_array().map(Vec::len), Some(2)); assert_eq!(snapshot["totals"]["live_nodes"].as_u64(), Some(2)); // The stale pool split is time-based; simulate by direct state surgery // because Instant cannot be rewound through the public API. { let mut state = view.state.write(); let node = state.streams.get_mut("stale-node#1").expect("stale node"); node.last_seen -= LIVE_TTL + Duration::from_secs(1); } let snapshot = view.snapshot_json(); let live_nodes = snapshot["live"].as_array().expect("live array"); let stale_nodes = snapshot["stale"].as_array().expect("stale array"); assert_eq!(live_nodes.len(), 1); assert_eq!(stale_nodes.len(), 1); assert_eq!(live_nodes[0]["stream"]["node"], json!("live-node")); assert_eq!(stale_nodes[0]["stream"]["node"], json!("stale-node")); assert_eq!(snapshot["totals"]["stale_nodes"].as_u64(), Some(1)); } #[test] fn newer_life_generation_evicts_superseded_stream() { let view = ControlPlaneView::default(); let old = StreamId::new(NodeId::new("node"), Lifetime(1)); let new = StreamId::new(NodeId::new("node"), Lifetime(2)); ingest_json(&view, &old, 0, "runtime.actors", actors_payload(0, json!([]))); ingest_json(&view, &new, 0, "runtime.actors", actors_payload(0, json!([]))); let snapshot = view.snapshot_json(); let nodes: Vec<&str> = snapshot["live"] .as_array() .expect("live array") .iter() .map(|node| node["stream"]["key"].as_str().expect("key")) .collect(); assert_eq!(nodes, vec!["node#2"]); } #[test] fn stale_pool_is_hard_capped() { let view = ControlPlaneView::default(); for index in 0..(STALE_POOL_CAP as u64 + 5) { let stream = StreamId::new(NodeId::new(&format!("old-{index}")), Lifetime(1)); ingest_json(&view, &stream, 0, "runtime.actors", actors_payload(0, json!([]))); } { let mut state = view.state.write(); for node in state.streams.values_mut() { node.last_seen -= LIVE_TTL + Duration::from_secs(1); } } // Physical cap applies on the next ingest… let fresh = StreamId::new(NodeId::new("fresh"), Lifetime(1)); ingest_json(&view, &fresh, 0, "runtime.actors", actors_payload(0, json!([]))); let state = view.state.read(); assert!(state.streams.len() <= STALE_POOL_CAP + 1); } #[test] fn detail_returns_actor_diet_and_receipts() { let view = ControlPlaneView::default(); let stream = StreamId::new(NodeId::new("node"), Lifetime(4)); ingest_json( &view, &stream, 0, "runtime.actors", actors_payload( 7, serde_json::json!([{ "address": "ff00", "actor_type": "OrchestratorActor", "message_type": "OrchestratorMsg", "mailbox_depth": 2, "messages_processed": 10, "last_msg_type": "Ping", "message_type_counts": [ { "ty": "Ping", "count": 6 }, { "ty": "Pong", "count": 4 } ] }]), ), ); let detail = view .detail_json("stream=node%234&actor=ff00") .expect("detail"); assert_eq!(detail["address"], json!("ff00")); assert_eq!(detail["actor_type"], json!("OrchestratorActor")); assert_eq!(detail["worker_id"], json!(7)); assert_eq!(detail["message_type_counts"][0]["message_type"], json!("Ping")); assert_eq!(detail["receipts"].as_array().map(Vec::len), Some(1)); assert_eq!(detail["receipts"][0]["ty"], json!("Ping")); } #[test] fn message_jump_folds_into_one_receipt_with_sampled_count() { let view = ControlPlaneView::default(); let stream = StreamId::new(NodeId::new("node"), Lifetime(1)); ingest_json( &view, &stream, 0, "runtime.actors", actors_payload( 0, serde_json::json!([{ "address": "aa", "messages_processed": 1, "last_msg_type": "Ping" }]), ), ); // Same-tick follow-up: a jump of 4 messages within the interval. ingest_json( &view, &stream, 1, "runtime.actors", actors_payload( 0, serde_json::json!([{ "address": "aa", "messages_processed": 5, "last_msg_type": "Ping" }]), ), ); let detail = view.detail_json("stream=node%231&actor=aa").expect("detail"); let receipts = detail["receipts"].as_array().expect("receipts"); assert_eq!(receipts.len(), 1); assert_eq!(detail["sampled_out"].as_u64(), Some(4)); } #[test] fn detail_requires_stream_and_actor_params() { let view = ControlPlaneView::default(); assert!(view.detail_json("").is_none()); assert!(view.detail_json("stream=node%231").is_none()); } #[test] fn percent_decoding_handles_hash_and_plus() { assert_eq!(query_param("stream=node%234&actor=ab", "stream").as_deref(), Some("node#4")); assert_eq!(query_param("a=1&actor=cd", "actor").as_deref(), Some("cd")); assert_eq!(query_param("stream=x", "actor"), None); } }