//! 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 //! [`DistributionNodeSnapshot`] rebuilt from the selected node's membership — //! 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}; use std::net::UdpSocket; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use distribution::datastream::catalog::{ self, IdentityRecord, LifecycleCost, MembershipTransition, Record, ResourceSample, RuntimeStats as DsRuntimeStats, TransportInternals, }; use distribution::datastream::frame::{Frame, StreamId}; use distribution::datastream::wire::decode_delivery; use distribution::snapshot::{DistributionNodeSnapshot, MemberInfo}; use swactor::actor::ActorAddress; use swactor::stats::{ActorInfo, RuntimeStats, TickTiming, WorkerInfo}; use crate::plugin::{DashboardPlugin, PluginResponse}; use crate::{DashboardHandle, DISTRIBUTION_PAGE_HTML}; /// 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, /// 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::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.