//! Datastream → dashboard adapter. //! //! Binds the UDP sink the demo cluster ships to (the same wire the dumb //! `swactor-datastream-collector` reads), **demultiplexes** the per-node frames, //! and drives the dashboard's *existing* views from them — no bespoke UI: //! //! * the single-node **Overview / Actors** page (`/`) via a synthesized //! [`RuntimeStats`] (each datastream channel becomes one synthetic actor row); //! * the canonical **Distribution** connection-graph page //! (`/plugin/distribution`, [`crate::DISTRIBUTION_PAGE_HTML`]) via a //! distribution-page JSON rebuilt from the selected node's membership and //! `dist.state` — so it renders the exact SWIM graph a live node shows; //! * a cross-node **Fleet** table (`/plugin/vastai`) served in the same //! dashboard chrome (nav bar + palette), not a separate app. //! //! Process output and membership transitions are emitted as `tracing` events so //! they flow through the dashboard's activity-log path. //! //! A node is only shown while it is *live* (has shipped a frame within //! [`NODE_TTL`]); a node that stops streaming drops out of every view, so a //! restarted/departed node leaves no ghost in the graph or the fleet table. use std::collections::{HashMap, HashSet, VecDeque}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use datastream::catalog::{ self, ActorRuntimeDetail, DatastoreState, DistributionState, IdentityRecord, LifecycleCost, MembershipTransition, Record, ResourceSample, RuntimeStats as DsRuntimeStats, TransportInternals, }; use datastream::frame::{Frame, StreamId}; use swactor::actor::ActorAddress; use swactor::stats::{ActorInfo, RuntimeStats, TickTiming, WorkerInfo}; /// How many recent datastore operation events to retain per node for the /// reconstructed timeline (the streaming successor of the old fixed event ring). const DATASTORE_EVENT_CAP: usize = 200; use crate::plugin::{DashboardPlugin, PluginResponse}; /// A node counts as live — shown in the graph and fleet table — if it has /// streamed a frame within this window. Nodes ship resource/runtime/transport /// samples every ~1s, so a node silent past this has left the cluster; its /// `models` entry lingers but is filtered out of every view (no ghost nodes). const NODE_TTL: Duration = Duration::from_secs(8); /// Per-node accumulator: the latest value seen on each typed channel plus /// running membership/process state. One of these drives the display. #[derive(Default)] struct DatastreamModel { identity: Option, resource: Option, runtime: Option, transport: Option, lifecycle: Option, /// Consolidated distribution-subsystem state (cache/registry/directory/...). dist_state: Option, /// Datastore steady metrics. datastore_state: Option, /// Per-actor runtime detail (the real actor table). actor_detail: Option, /// Recent datastore op events (newest last), capped at [`DATASTORE_EVENT_CAP`]. datastore_events: VecDeque, /// peer node-id → latest liveness state. membership: HashMap, /// last membership transition, formatted for display. last_transition: Option, /// proc label → (line count, last line). procs: HashMap, first_seen: Option, last_seen: Option, } /// A log line to surface through the dashboard's activity path. enum LogEvent { Info(String), Warn(String), } impl DatastreamModel { /// Fold one frame's channel/payload into the model, returning any activity /// log events it produced (process output, membership transitions). fn update(&mut self, channel: &str, payload: &[u8]) -> Vec { let now = Instant::now(); self.first_seen.get_or_insert(now); self.last_seen = Some(now); let mut events = Vec::new(); match channel { catalog::IDENTITY => { if let Ok(r) = IdentityRecord::decode(payload) { self.identity = Some(r); } } catalog::HOST_RESOURCE => { if let Ok(r) = ResourceSample::decode(payload) { self.resource = Some(r); } } catalog::RUNTIME_STATS => { if let Ok(r) = DsRuntimeStats::decode(payload) { self.runtime = Some(r); } } catalog::TRANSPORT_INTERNALS => { if let Ok(r) = TransportInternals::decode(payload) { self.transport = Some(r); } } catalog::LIFECYCLE_COST => { if let Ok(r) = LifecycleCost::decode(payload) { self.lifecycle = Some(r); } } catalog::DIST_STATE => { if let Ok(r) = DistributionState::decode(payload) { self.dist_state = Some(r); } } catalog::DATASTORE_STATE => { if let Ok(r) = DatastoreState::decode(payload) { self.datastore_state = Some(r); } } catalog::RUNTIME_ACTORS => { if let Ok(r) = ActorRuntimeDetail::decode(payload) { self.actor_detail = Some(r); } } // Datastore op events: structured text lines, tailed into a capped // ring so the datastore page can show a recent-operations timeline. catalog::DATASTORE_EVENTS => { if let Ok(v) = serde_json::from_slice::(payload) { let kind = v.get("kind").and_then(|x| x.as_str()).unwrap_or("?"); let hash = v.get("hash").and_then(|x| x.as_str()).unwrap_or(""); events.push(LogEvent::Info(format!("datastore {kind} {}", short_id(hash)))); if self.datastore_events.len() >= DATASTORE_EVENT_CAP { self.datastore_events.pop_front(); } self.datastore_events.push_back(v); } } catalog::MEMBERSHIP => { if let Ok(t) = MembershipTransition::decode(payload) { // Display the short id; key the membership map by the full // id so the views can resolve it to a friendly label. let line = format!("{}: {} → {}", short_id(&t.peer), t.from, t.to); self.membership.insert(t.peer.clone(), t.to.clone()); self.last_transition = Some(line.clone()); events.push(LogEvent::Info(format!("membership {line}"))); } } // Raw-text process output: `proc.