swactor/crates/dashboard/src/datastream_source.rs

871 lines
35 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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::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<IdentityRecord>,
resource: Option<ResourceSample>,
runtime: Option<DsRuntimeStats>,
transport: Option<TransportInternals>,
lifecycle: Option<LifecycleCost>,
/// peer node-id → latest liveness state.
membership: HashMap<String, String>,
/// last membership transition, formatted for display.
last_transition: Option<String>,
/// proc label → (line count, last line).
procs: HashMap<String, (u64, String)>,
first_seen: Option<Instant>,
last_seen: Option<Instant>,
}
/// 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<LogEvent> {
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.<label>.{stdout,stderr}`.
_ if channel.starts_with("proc.") => {
let line = String::from_utf8_lossy(payload).into_owned();
if let Some((label, is_err)) = parse_proc_channel(channel) {
let entry = self.procs.entry(label.to_string()).or_default();
entry.0 += 1;
entry.1 = line.clone();
let tagged = format!("[{label}] {line}");
events.push(if is_err {
LogEvent::Warn(tagged)
} else {
LogEvent::Info(tagged)
});
}
}
// Unknown channel — ignored for display (still demuxed cleanly).
_ => {}
}
events
}
/// Has this node streamed a frame within [`NODE_TTL`]?
fn is_live(&self, now: Instant) -> bool {
self.last_seen
.map(|t| now.duration_since(t) < NODE_TTL)
.unwrap_or(false)
}
/// A friendly label for this node: `region · short-id` once its identity is
/// known, else just the short id.
fn label(&self, node_id: &str) -> String {
match &self.identity {
Some(idr) if !idr.region.is_empty() => {
format!("{} · {}", idr.region, short_id(node_id))
}
_ => short_id(node_id),
}
}
/// Total process-output lines seen across all labels.
fn total_proc_lines(&self) -> u64 {
self.procs.values().map(|(n, _)| *n).sum()
}
/// The peers this node currently sees that are themselves still live, as
/// `(peer_id, state)`. Filtering by liveness drops stale incarnations a
/// lost SWIM `dead` transition would otherwise leave stuck at `alive`.
fn live_peers<'a>(&'a self, live: &HashSet<String>) -> Vec<(&'a String, &'a String)> {
self.membership
.iter()
.filter(|(peer, _)| live.contains(peer.as_str()))
.collect()
}
/// Synthesize the dashboard's native stats from the accumulated datastream.
fn to_runtime_stats(&self) -> RuntimeStats {
let ds_rt = self.runtime.clone().unwrap_or(DsRuntimeStats {
actors_live: 0,
mailbox_depth: 0,
scheduled_tasks: 0,
});
// One synthetic worker = this node.
let worker = WorkerInfo {
id: 0,
num_actors: ds_rt.actors_live as usize,
mailbox_depth: ds_rt.mailbox_depth as usize,
messages_processed: self.total_proc_lines(),
local_sends: 0,
cross_sends: 0,
inbox_sends: 0,
type_mismatches: 0,
panics: 0,
messages_dropped: 0,
restarts: 0,
stops: 0,
};
// Each datastream channel becomes one synthetic actor row, with columns
// and the per-actor breakdown chart repurposed to show its values.
let mut actor_details: Vec<ActorInfo> = Vec::new();
if let Some(r) = &self.resource {
actor_details.push(synth_actor(
"host.resource",
Some(format!(
"cpu {:.0}% mem {}/{}MB",
r.cpu_pct, r.mem_used_mb, r.mem_total_mb
)),
self.total_proc_lines(),
vec![
("cpu_pct".to_string(), r.cpu_pct.round() as u64),
("mem_used_mb".to_string(), r.mem_used_mb as u64),
("mem_total_mb".to_string(), r.mem_total_mb as u64),
("gpu_pct".to_string(), r.gpu_pct.round() as u64),
("disk_used_gb".to_string(), r.disk_used_gb as u64),
("net_rx_kbps".to_string(), r.net_rx_kbps as u64),
("net_tx_kbps".to_string(), r.net_tx_kbps as u64),
],
));
}
if let Some(t) = &self.transport {
actor_details.push(synth_actor(
"transport.internals",
Some(format!(
"relay {} · {} direct · {} relayed",
if t.relay_connected { "up" } else { "down" },
t.direct_peers,
t.relay_peers
)),
0,
vec![
("relay_connected".to_string(), t.relay_connected as u64),
("direct_peers".to_string(), t.direct_peers as u64),
("relay_peers".to_string(), t.relay_peers as u64),
("rtt_ms_p50".to_string(), t.rtt_ms_p50 as u64),
],
));
}
if !self.membership.is_empty() || self.last_transition.is_some() {
let mut counts: HashMap<&str, u64> = HashMap::new();
for state in self.membership.values() {
*counts.entry(state.as_str()).or_default() += 1;
}
let breakdown = counts
.into_iter()
.map(|(k, v)| (k.to_string(), v))
.collect();
actor_details.push(synth_actor(
"membership",
self.last_transition.clone(),
self.membership.len() as u64,
breakdown,
));
}
// One row per process label, newest line as "last message".
let mut procs: Vec<(&String, &(u64, String))> = self.procs.iter().collect();
procs.sort_by(|a, b| a.0.cmp(b.0));
for (label, (count, last)) in procs {
actor_details.push(synth_actor(
&format!("proc.{label}"),
Some(last.clone()),
*count,
vec![("lines".to_string(), *count)],
));
}
let actors = actor_details
.iter()
.map(|a| (a.address, a.worker_id))
.collect();
let uptime_ms = self
.lifecycle
.as_ref()
.map(|l| l.uptime_s * 1000)
.unwrap_or_else(|| {
self.first_seen
.map(|t| t.elapsed().as_millis() as u64)
.unwrap_or(0)
});
RuntimeStats {
num_workers: 1,
uptime_ms,
actors,
workers: vec![worker],
actor_details,
// No per-tick phase timing on the wire; one empty worker entry keeps
// the phase bars degrading to a single fill rather than panicking.
tick_timings: vec![Vec::<TickTiming>::new()],
}
}
/// Does this node match the optional selection filter? Matches against the
/// node id and (once known) the identity region/role.
fn matches(&self, node_id: &str, filter: &str) -> bool {
if node_id.contains(filter) {
return true;
}
match &self.identity {
Some(id) => {
id.region == filter || format!("{:?}", id.role).eq_ignore_ascii_case(filter)
}
None => false,
}
}
/// Rebuild a [`DistributionNodeSnapshot`] for this node from the demuxed
/// stream, so the canonical Distribution page renders its SWIM connection
/// graph exactly as it would for a live node. Only live peers are included
/// (`peer_labels`/`live` are the fleet-wide label map and live set).
///
/// Fields the datastream does not carry (routing table, cache, directory,
/// registry) are honest zeros — the graph and membership panel, which is all
/// this view exists to show, are driven entirely by `members`.
fn dist_snapshot(
&self,
node_id: &str,
peer_labels: &HashMap<String, String>,
live: &HashSet<String>,
) -> DistributionNodeSnapshot {
let mut members: Vec<MemberInfo> = self
.live_peers(live)
.into_iter()
.map(|(peer, state)| MemberInfo {
node_id: peer.clone(),
addr: None,
state: state.clone(),
incarnation: 0,
is_authorized: None,
label: None,
relay_url: None,
node_name: Some(peer_labels.get(peer).cloned().unwrap_or_else(|| short_id(peer))),
})
.collect();
members.sort_by(|a, b| a.node_id.cmp(&b.node_id));
let count = |want: &str| members.iter().filter(|m| m.state == want).count();
let (alive_count, suspect_count, dead_count) =
(count("alive"), count("suspect"), count("dead"));
DistributionNodeSnapshot {
node_id: node_id.to_string(),
listen_addr: None,
members,
alive_count,
suspect_count,
dead_count,
routing_table_size: 0,
routing_buckets: Vec::new(),
routing_neighbors: Vec::new(),
cache_size: 0,
cache_entries: Vec::new(),
directory_entry_count: 0,
repair_queue_size: 0,
registry_size: 0,
registry_tombstones: 0,
registry_entries: Vec::new(),
recent_probe_targets: Vec::new(),
peer_auth_mode: "open".into(),
authorized_peer_count: None,
node_name: Some(peer_labels.get(node_id).cloned().unwrap_or_else(|| short_id(node_id))),
invite_code: None,
// `relay_url` means "this node runs an embedded relay server" (it
// draws a teal ring in the graph) — not "is connected to a relay".
// The datastream doesn't carry that, so leave it unset.
relay_url: None,
version: None,
join_statuses: Vec::new(),
}
}
/// A compact per-node summary row for the fleet table.
fn node_summary(
&self,
node_id: &str,
expected_peers: usize,
selected: bool,
live: &HashSet<String>,
) -> serde_json::Value {
let peers = self.live_peers(live);
let count = |want: &str| peers.iter().filter(|(_, s)| s.as_str() == want).count() as u32;
let (alive, suspect, dead) = (count("alive"), count("suspect"), count("dead"));
let seen = peers.len();
// "Converged" from this node's vantage: it sees every other live node,
// all alive (no suspect/dead).
let converged = suspect == 0 && dead == 0 && seen >= expected_peers && expected_peers > 0;
let r = self.resource.as_ref();
let t = self.transport.as_ref();
let rt = self.runtime.as_ref();
let id = self.identity.as_ref();
let last_proc = self
.procs
.values()
.map(|(_, line)| line.clone())
.last()
.unwrap_or_default();
serde_json::json!({
"id": node_id,
"short": short_id(node_id),
"region": id.map(|i| i.region.clone()).unwrap_or_default(),
"role": id.map(|i| format!("{:?}", i.role).to_lowercase()).unwrap_or_default(),
"selected": selected,
"cpu_pct": r.map(|r| r.cpu_pct.round() as u32).unwrap_or(0),
"mem_used_mb": r.map(|r| r.mem_used_mb).unwrap_or(0),
"mem_total_mb": r.map(|r| r.mem_total_mb).unwrap_or(0),
"gpu_pct": r.map(|r| r.gpu_pct.round() as u32).unwrap_or(0),
"actors_live": rt.map(|r| r.actors_live).unwrap_or(0),
"mailbox_depth": rt.map(|r| r.mailbox_depth).unwrap_or(0),
"relay_connected": t.map(|t| t.relay_connected).unwrap_or(false),
"direct_peers": t.map(|t| t.direct_peers).unwrap_or(0),
"relay_peers": t.map(|t| t.relay_peers).unwrap_or(0),
"rtt_ms_p50": t.map(|t| t.rtt_ms_p50).unwrap_or(0),
"alive": alive,
"suspect": suspect,
"dead": dead,
"converged": converged,
"proc_lines": self.total_proc_lines(),
"last_proc": last_proc,
})
}
}
/// `node-id → "region · short-id"` for every demuxed node.
fn build_labels(models: &HashMap<String, DatastreamModel>) -> HashMap<String, String> {
models.iter().map(|(id, m)| (id.clone(), m.label(id))).collect()
}
/// The set of node ids currently live (streamed within [`NODE_TTL`]).
fn live_set(models: &HashMap<String, DatastreamModel>, now: Instant) -> HashSet<String> {
models
.iter()
.filter(|(_, m)| m.is_live(now))
.map(|(id, _)| id.clone())
.collect()
}
/// Build the Fleet-table JSON model from every *live* demuxed node.
fn fleet_json(
models: &HashMap<String, DatastreamModel>,
selected: Option<&str>,
live: &HashSet<String>,
) -> String {
let node_count = live.len();
let expected_peers = node_count.saturating_sub(1);
let mut nodes: Vec<serde_json::Value> = models
.iter()
.filter(|(id, _)| live.contains(id.as_str()))
.map(|(id, m)| m.node_summary(id, expected_peers, selected == Some(id.as_str()), live))
.collect();
// Stable order: region then short id, so rows don't jump around.
nodes.sort_by(|a, b| {
(a["region"].as_str(), a["short"].as_str())
.cmp(&(b["region"].as_str(), b["short"].as_str()))
});
// Whole fleet converged once every node has converged and there's >1 node.
let converged =
node_count > 1 && nodes.iter().all(|n| n["converged"].as_bool() == Some(true));
serde_json::json!({
"node_count": node_count,
"converged": converged,
"nodes": nodes,
})
.to_string()
}
/// Plugin backed by a shared cache string: serves a fixed HTML page, emits its
/// cache on the SSE stream under `name`, and answers `GET /api/plugin/{name}`.
/// Used for both the Fleet table (`vastai`) and the Distribution graph
/// (`distribution`); each is fed by [`run_datastream_ingest`].
struct CachePlugin {
name: &'static str,
page: &'static str,
cache: Arc<Mutex<Option<String>>>,
}
impl CachePlugin {
fn new(name: &'static str, page: &'static str, cache: Arc<Mutex<Option<String>>>) -> Self {
Self { name, page, cache }
}
}
impl DashboardPlugin for CachePlugin {
fn name(&self) -> &str {
self.name
}
fn snapshot_json(&self) -> Option<String> {
self.cache.lock().unwrap().clone()
}
fn handle_request(
&self,
method: &str,
path: &str,
_query: &HashMap<String, String>,
_body: &[u8],
) -> PluginResponse {
match (method, path) {
("GET", "" | "model" | "snapshot") => {
PluginResponse::json(self.cache.lock().unwrap().clone().unwrap_or_else(|| "{}".into()))
}
// The Distribution page has Re-peer / dismiss buttons; in this
// read-only datastream view they are inert (acknowledged, no-op).
("POST", "rejoin" | "clear_status") => PluginResponse::json(r#"{"ok":true}"#.into()),
_ => PluginResponse::not_found(),
}
}
fn html_page(&self) -> Option<&str> {
Some(self.page)
}
}
/// Minimal `peers` plugin so the Distribution page's `fetch('/api/plugin/peers')`
/// resolves cleanly (open auth, no managed peer list) instead of 404ing. Silent
/// on the SSE stream.
struct PeersStub;
impl DashboardPlugin for PeersStub {
fn name(&self) -> &str {
"peers"
}
fn snapshot_json(&self) -> Option<String> {
None
}
fn handle_request(
&self,
method: &str,
path: &str,
_query: &HashMap<String, String>,
_body: &[u8],
) -> PluginResponse {
match (method, path) {
("GET", "" | "list") => PluginResponse::json(r#"{"mode":"open","peers":[]}"#.into()),
_ => PluginResponse::not_found(),
}
}
}
/// Build one synthetic actor row with a deterministic address from its name.
///
/// `mailbox_depth` is always 0: a datastream channel is not a real mailbox, and
/// the dashboard's WarningDetector flags any row with `depth > 0` whose
/// `messages_processed` is flat as a "stalled actor". The channel's real values
/// live in `last_msg_type` and the `message_type_counts` breakdown instead.
fn synth_actor(
name: &str,
last_msg_type: Option<String>,
messages_processed: u64,
message_type_counts: Vec<(String, u64)>,
) -> ActorInfo {
ActorInfo {
address: addr_from(name),
worker_id: 0,
mailbox_depth: 0,
last_msg_type,
messages_processed,
poisoned: false,
name: Some(name.to_string()),
message_type_counts,
}
}
/// Deterministic 32-byte address from a channel name (FNV-1a in the first 8
/// bytes — which is what the address Hash/Display use — plus the name splatted
/// after for readability). Stable across frames so sparklines accumulate.
fn addr_from(name: &str) -> ActorAddress {
let mut bytes = [0u8; 32];
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for b in name.bytes() {
hash ^= b as u64;
hash = hash.wrapping_mul(0x0100_0000_01b3);
}
bytes[..8].copy_from_slice(&hash.to_le_bytes());
for (i, b) in name.bytes().take(24).enumerate() {
bytes[8 + i] = b;
}
ActorAddress(bytes)
}
/// Split `proc.<label>.stdout` / `proc.<label>.stderr` into `(label, is_stderr)`.
fn parse_proc_channel(channel: &str) -> Option<(&str, bool)> {
let rest = channel.strip_prefix("proc.")?;
if let Some(label) = rest.strip_suffix(".stderr") {
Some((label, true))
} else {
rest.strip_suffix(".stdout").map(|label| (label, false))
}
}
/// First 8 chars of an id — the readable short form used throughout the views.
fn short_id(id: &str) -> String {
id[..id.len().min(8)].to_string()
}
/// Bind the UDP datastream sink, demux frames, and drive the dashboard's views.
/// Blocks forever, like the dumb collector's `main`.
///
/// Registers three plugins fed off the demuxed stream:
/// * `distribution` — the canonical SWIM connection-graph page, snapshotting
/// the selected node;
/// * `vastai` (the nav's "Fleet") — the cross-node telemetry table;
/// * `peers` — an open-auth stub so the graph page's peer fetch resolves.
///
/// The single-node Overview/Actors page is driven via `handle.set_stats`.
pub fn run_datastream_ingest(
bind: &str,
node_filter: Option<&str>,
handle: &DashboardHandle,
) -> std::io::Result<()> {
let sock = UdpSocket::bind(bind)?;
eprintln!("datastream dashboard: listening on {bind} (one frame per datagram)");
// Distribution graph (selected node) and Fleet table (all live nodes), each
// a shared cache the ingest loop refreshes and the SSE loop fans out.
let dist_cache: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
let fleet_cache: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
handle.register_plugin(Arc::new(CachePlugin::new(
"distribution",
DISTRIBUTION_PAGE_HTML,
Arc::clone(&dist_cache),
)) as Arc<dyn DashboardPlugin>);
handle.register_plugin(Arc::new(CachePlugin::new(
"vastai",
FLEET_HTML,
Arc::clone(&fleet_cache),
)) as Arc<dyn DashboardPlugin>);
// The base dashboard nav has a Datastore link; the datastream carries no
// datastore, so serve an honest "not available" page in-chrome rather than
// 404ing. Empty cache → silent on the SSE stream.
handle.register_plugin(Arc::new(CachePlugin::new(
"datastore",
DATASTORE_HTML,
Arc::new(Mutex::new(None)),
)) as Arc<dyn DashboardPlugin>);
handle.register_plugin(Arc::new(PeersStub) as Arc<dyn DashboardPlugin>);
let mut models: HashMap<String, DatastreamModel> = HashMap::new();
let mut selected: Option<String> = None;
// 64 KiB comfortably exceeds a UDP datagram; a frame never spans datagrams.
let mut buf = vec![0u8; 64 * 1024];
loop {
let (n, _src) = match sock.recv_from(&mut buf) {
Ok(v) => v,
Err(e) => {
eprintln!("datastream dashboard: recv error: {e}");
continue;
}
};
let (stream, frame) = match decode_delivery(&buf[..n]) {
Ok(v) => v,
Err(e) => {
eprintln!("datastream dashboard: dropped malformed datagram ({n} B): {e:?}");
continue;
}
};
let node = stream.node.as_str().to_string();
let model = models.entry(node.clone()).or_default();
let events = model.update(frame.channel.as_str(), &frame.payload);
// Pick the display node: first matching the filter, else first seen.
if selected.is_none() {
let qualifies = match node_filter {
Some(f) => model.matches(&node, f),
None => true,
};
if qualifies {
eprintln!("datastream dashboard: displaying node {}", short_id(&node));
selected = Some(node.clone());
}
}
if selected.as_deref() == Some(node.as_str()) {
// Surface process output / membership through the activity log.
for ev in events {
match ev {
LogEvent::Info(m) => tracing::info!(target: "datastream", "{m}"),
LogEvent::Warn(m) => tracing::warn!(target: "datastream", "{m}"),
}
}
handle.set_stats(model.to_runtime_stats());
}
// Refresh both views from the current live nodes on each frame.
let now = Instant::now();
let live = live_set(&models, now);
let labels = build_labels(&models);
*fleet_cache.lock().unwrap() = Some(fleet_json(&models, selected.as_deref(), &live));
if let Some(sel) = selected.as_deref() {
if let Some(m) = models.get(sel) {
let snap = m.dist_snapshot(sel, &labels, &live);
if let Ok(json) = serde_json::to_string(&snap) {
*dist_cache.lock().unwrap() = Some(json);
}
}
}
}
}
/// Cross-node **Fleet** table, served in the dashboard's own chrome (the same
/// header / nav bar / palette as the Overview and Distribution pages, so it is a
/// section of the one app — not a separate UI). Renders the `vastai` SSE event
/// built by [`fleet_json`]: one row per live node with its resource / runtime /
/// transport telemetry and a fleet-wide SWIM-convergence pill.
const FLEET_HTML: &str = r#"<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Swactor Runtime – Fleet</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Menlo', 'Consolas', 'Monaco', monospace; background: #0f1117; color: #e0e0e0; font-size: 13px; }
.header { display: flex; align-items: center; justify-content: space-between;
padding: 12px 20px; background: #161822; border-bottom: 1px solid #2a2d3e; }
.header-left { display: flex; align-items: center; }
.header h1 { font-size: 16px; font-weight: 600; color: #fff; }
.status-dot { width: 10px; height: 10px; border-radius: 50%; background: #4caf50;
display: inline-block; margin-left: 8px; vertical-align: middle; }
.status-dot.disconnected { background: #f44336; }
.status-dot.done { background: #ff9800; }
.nav-links { display: flex; gap: 4px; margin-left: 20px; }
.nav-link { color: #888; text-decoration: none; font-size: 12px;
padding: 4px 10px; border-radius: 3px; transition: color 0.2s; }
.nav-link:hover { color: #e0e0e0; }
.nav-link.active { color: #fff; background: #2a2d3e; }
.header-right { display: flex; align-items: center; gap: 12px; }
.pill { padding: 3px 12px; border-radius: 999px; font-size: 12px; font-weight: 600; }
.pill.ok { background: #14361f; color: #4ade80; }
.pill.warn { background: #3a2d12; color: #fbbf24; }
.content { padding: 16px 20px; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 7px 12px; border-bottom: 1px solid #1f2230; white-space: nowrap; }
th { color: #888; font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: .5px; }
td.num { text-align: right; font-variant-numeric: tabular-nums; }
tr.sel td { background: #161e2e; }
.tag { padding: 1px 8px; border-radius: 4px; background: #1c1f2e; color: #888; font-size: 11px; }
.tag.coordinator { background: #1c2d4a; color: #79c0ff; }
.tag.worker { background: #1a2e2e; color: #56d4dd; }
.bar { display: inline-block; width: 64px; height: 7px; background: #1c1f2e; border-radius: 4px;
overflow: hidden; vertical-align: middle; margin-right: 6px; }
.bar > i { display: block; height: 100%; background: #4ade80; }
.bar > i.hi { background: #fbbf24; } .bar > i.crit { background: #f87171; }
.st { padding: 1px 6px; border-radius: 4px; font-weight: 600; font-size: 11px; }
.st.suspect { background: #3a2d12; color: #fbbf24; } .st.dead { background: #3a1518; color: #f87171; }
.muted { color: #666; }
.last { max-width: 380px; overflow: hidden; text-overflow: ellipsis; color: #999; }
</style>
</head>
<body>
<div class="header">
<div class="header-left">
<h1>Swactor Runtime Dashboard <span id="statusDot" class="status-dot disconnected"></span></h1>
<nav class="nav-links">
<a href="/" class="nav-link">Overview</a>
<a href="/actors" class="nav-link">Actors</a>
<a href="/plugin/distribution" class="nav-link">Distribution</a>
<a href="/plugin/datastore" class="nav-link">Datastore</a>
<a href="/plugin/vastai" class="nav-link active">Fleet</a>
</nav>
</div>
<div class="header-right">
<span id="conv" class="pill warn">SWIM: …</span>
</div>
</div>
<div class="content">
<table>
<thead><tr>
<th>node</th><th>region</th><th>role</th><th>CPU</th><th>mem</th>
<th class="num">actors</th><th class="num">mbox</th><th>transport</th>
<th class="num">peers</th><th class="num">proc</th><th>last line</th>
</tr></thead>
<tbody id="rows"></tbody>
</table>
<p id="empty" class="muted" style="margin-top:12px;">waiting for telemetry…</p>
</div>
<script>
(function () {
var state = { node_count: 0, converged: false, nodes: [] };
function esc(s){ return String(s==null?"":s).replace(/[&<>]/g, function(c){
return {"&":"&amp;","<":"&lt;",">":"&gt;"}[c]; }); }
function bar(pct){ var c = pct>=90?"crit":pct>=70?"hi":"";
return '<span class="bar"><i class="'+c+'" style="width:'+Math.min(100,pct)+'%"></i></span>'+pct+'%'; }
function render(){
var conv = document.getElementById("conv");
if (state.node_count > 1 && state.converged){ conv.className="pill ok"; conv.textContent="SWIM: converged ("+state.node_count+" nodes)"; }
else { conv.className="pill warn"; conv.textContent="SWIM: converging ("+state.node_count+" nodes)"; }
var rows = state.nodes.map(function(n){
var peers = n.alive + (n.suspect?(' <span class="st suspect">'+n.suspect+'</span>'):'')
+ (n.dead?(' <span class="st dead">'+n.dead+'</span>'):'');
return '<tr class="'+(n.selected?"sel":"")+'">'
+ '<td>'+esc(n.short)+(n.selected?' <span class="muted">(shown)</span>':'')+'</td>'
+ '<td>'+esc(n.region)+'</td>'
+ '<td><span class="tag '+esc(n.role)+'">'+esc(n.role||"?")+'</span></td>'
+ '<td>'+bar(n.cpu_pct)+'</td>'
+ '<td>'+n.mem_used_mb+'/'+n.mem_total_mb+'MB</td>'
+ '<td class="num">'+n.actors_live+'</td>'
+ '<td class="num">'+n.mailbox_depth+'</td>'
+ '<td>'+(n.relay_connected?'relay ':'')+n.direct_peers+'d/'+n.relay_peers+'r</td>'
+ '<td class="num">'+peers+'</td>'
+ '<td class="num">'+n.proc_lines+'</td>'
+ '<td class="last">'+esc(n.last_proc)+'</td>'
+ '</tr>';
}).join("");
document.getElementById("rows").innerHTML = rows;
document.getElementById("empty").style.display = state.nodes.length ? "none" : "block";
}
function onModel(m){ if(!m||!m.nodes) return; state = m; render(); }
fetch("/api/plugin/vastai/model").then(function(r){return r.json();}).then(onModel).catch(function(){});
var dot = document.getElementById("statusDot");
var es = new EventSource("/events");
es.onopen = function(){ dot.className = "status-dot"; };
es.onerror = function(){ dot.className = "status-dot disconnected"; };
es.addEventListener("vastai", function(e){ try { onModel(JSON.parse(e.data)); } catch(err){} });
es.addEventListener("done", function(){ dot.className = "status-dot done"; es.close(); });
window.addEventListener("beforeunload", function(){ es.close(); });
})();
</script>
</body>
</html>"#;
/// Honest in-chrome placeholder for the Datastore nav link: the datastream demo
/// ships no datastore telemetry, so rather than 404 the link, explain that.
const DATASTORE_HTML: &str = r#"<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Swactor Runtime – Datastore</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Menlo', 'Consolas', 'Monaco', monospace; background: #0f1117; color: #e0e0e0; font-size: 13px; }
.header { display: flex; align-items: center; padding: 12px 20px; background: #161822; border-bottom: 1px solid #2a2d3e; }
.header h1 { font-size: 16px; font-weight: 600; color: #fff; }
.nav-links { display: flex; gap: 4px; margin-left: 20px; }
.nav-link { color: #888; text-decoration: none; font-size: 12px; padding: 4px 10px; border-radius: 3px; }
.nav-link:hover { color: #e0e0e0; }
.nav-link.active { color: #fff; background: #2a2d3e; }
.note { margin: 80px auto; max-width: 520px; text-align: center; color: #888; line-height: 1.6; }
.note b { color: #cbd5e1; }
</style>
</head>
<body>
<div class="header">
<h1>Swactor Runtime Dashboard</h1>
<nav class="nav-links">
<a href="/" class="nav-link">Overview</a>
<a href="/actors" class="nav-link">Actors</a>
<a href="/plugin/distribution" class="nav-link">Distribution</a>
<a href="/plugin/datastore" class="nav-link active">Datastore</a>
<a href="/plugin/vastai" class="nav-link">Fleet</a>
</nav>
</div>
<div class="note">
<p><b>No datastore in this view.</b></p>
<p>This dashboard is fed by the per-node telemetry <b>datastream</b>, which does not
carry datastore contents. See <a href="/plugin/distribution" class="nav-link">Distribution</a>
for the cluster connection graph or <a href="/plugin/vastai" class="nav-link">Fleet</a> for per-node telemetry.</p>
</div>
</body>
</html>"#;