From 9325573f4d981eb6c7e3e602cfd703c322070349 Mon Sep 17 00:00:00 2001 From: Zachery Aaron Shores-Chmielewski Date: Mon, 9 Feb 2026 14:11:43 +0700 Subject: [PATCH 1/3] fix: gossip simulation dashboard --- Cargo.lock | 1 - crates/gossip-dashboard/Cargo.toml | 1 - crates/gossip-dashboard/README.md | 126 +++ .../examples/configs/chain_8.toml | 11 + .../examples/configs/full_mesh_6.toml | 12 + .../configs/partitioned_800_heal.toml | 11 + .../partitioned_8_heal.toml} | 5 +- .../examples/configs/ring_10.toml | 11 + .../examples/configs/star_7.toml | 11 + crates/gossip-dashboard/examples/dashboard.rs | 40 - .../examples/generate_traces.rs | 69 ++ crates/gossip-dashboard/examples/replay.rs | 20 +- crates/gossip-dashboard/src/config.rs | 15 +- crates/gossip-dashboard/src/dashboard_html.rs | 429 ++++----- crates/gossip-dashboard/src/lib.rs | 2 +- crates/gossip-dashboard/src/server.rs | 822 +++--------------- crates/swactor-gossip/src/sim.rs | 20 +- 17 files changed, 615 insertions(+), 991 deletions(-) create mode 100644 crates/gossip-dashboard/README.md create mode 100644 crates/gossip-dashboard/examples/configs/chain_8.toml create mode 100644 crates/gossip-dashboard/examples/configs/full_mesh_6.toml create mode 100644 crates/gossip-dashboard/examples/configs/partitioned_800_heal.toml rename crates/gossip-dashboard/examples/{sim.toml => configs/partitioned_8_heal.toml} (71%) create mode 100644 crates/gossip-dashboard/examples/configs/ring_10.toml create mode 100644 crates/gossip-dashboard/examples/configs/star_7.toml delete mode 100644 crates/gossip-dashboard/examples/dashboard.rs create mode 100644 crates/gossip-dashboard/examples/generate_traces.rs diff --git a/Cargo.lock b/Cargo.lock index 97b5b8c..701d83d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -216,7 +216,6 @@ version = "0.1.0" dependencies = [ "serde", "serde_json", - "swactor", "swactor-gossip", "tiny_http", "toml", diff --git a/crates/gossip-dashboard/Cargo.toml b/crates/gossip-dashboard/Cargo.toml index 0c0df74..aba86c8 100644 --- a/crates/gossip-dashboard/Cargo.toml +++ b/crates/gossip-dashboard/Cargo.toml @@ -4,7 +4,6 @@ version = "0.1.0" edition = "2024" [dependencies] -swactor = { path = "../..", features = ["serde"] } swactor-gossip = { path = "../swactor-gossip" } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/crates/gossip-dashboard/README.md b/crates/gossip-dashboard/README.md new file mode 100644 index 0000000..a6b9cd5 --- /dev/null +++ b/crates/gossip-dashboard/README.md @@ -0,0 +1,126 @@ +# gossip-dashboard + +Interactive web dashboard for visualizing gossip protocol simulations. + +The workflow has two steps: + +1. **Generate traces** -- run simulations against TOML config files, producing `.trace.json` files. +2. **Replay traces** -- start the dashboard server, point it at a directory of traces, and explore them in the browser. + +## Quick start + +```bash +# 1. Generate traces from the bundled configs (outputs to traces/) +cargo run -p gossip-dashboard --example generate_traces + +# 2. Launch the dashboard +cargo run -p gossip-dashboard --example replay -- traces +# => open http://localhost:8080 +``` + +## Commands + +### `generate_traces` + +Runs gossip simulations and writes `.trace.json` files. + +``` +generate_traces # all bundled configs -> traces/ +generate_traces # all bundled configs -> / +generate_traces [more.toml …] # specific configs -> / +``` + +Bundled configs live in `examples/configs/`. When no config paths are given, every `.toml` in that directory is run. + +Output filenames are derived from the simulation name (lowercased, spaces to underscores). Example output: + +``` +traces/ + ring_10_nodes.trace.json + star_7_nodes.trace.json + chain_8_nodes.trace.json + full_mesh_6_nodes.trace.json + partition_&_heal_8_nodes.trace.json +``` + +### `replay` + +Starts an HTTP server that serves the dashboard UI and the trace data. + +``` +replay [port] +``` + +| Argument | Required | Default | Description | +|-------------|----------|---------|------------------------------------------| +| `trace-dir` | yes | -- | Directory containing `.trace.json` files | +| `port` | no | 8080 | Port to bind on | + +The server exposes three endpoints: + +| Route | Description | +|--------------------------|------------------------------------| +| `GET /` | Dashboard HTML | +| `GET /traces` | JSON list of available trace files | +| `GET /trace.json?file=…` | Fetch a specific trace | + +## Configuration (TOML) + +Each simulation is defined by a TOML file. Example (`ring_10.toml`): + +```toml +name = "Ring (10 nodes)" +topology = "ring" +num_nodes = 10 +num_rounds = 15 +ticks_per_round = 5 +num_threads = 1 + +[initial_data] +color = "blue" +version = "1" +status = "active" +``` + +### Fields + +| Field | Type | Required | Description | +|--------------------|-------------------|----------|-------------------------------------------------------------------| +| `name` | string | yes | Display name for the simulation | +| `topology` | string | yes | Network topology (see below) | +| `num_nodes` | integer | yes | Number of gossip nodes | +| `num_rounds` | integer | yes | Number of gossip rounds to run | +| `ticks_per_round` | integer | yes | Simulation ticks per round | +| `num_threads` | integer | yes | Worker threads (`1` = deterministic single-threaded) | +| `heal_after_round` | integer | no | Round after which partitioned halves are bridged | +| `initial_data` | table of strings | no | Key-value pairs seeded on node 0 before gossip begins | + +### Topologies + +| Value | Shape | +|---------------|--------------------------------------------------------------------------| +| `ring` | Each node connects to the next, forming a circle | +| `star` | Node 0 is a hub with bidirectional links to every other node | +| `full_mesh` | Every node connects bidirectionally to every other node | +| `chain` | Unidirectional chain: node 0 -> 1 -> 2 -> ... -> N-1 | +| `partitioned` | Two isolated full-mesh halves; use `heal_after_round` to bridge them | + +## Bundled configs + +| File | Topology | Nodes | Rounds | Notes | +|---------------------------|-------------|-------|--------|----------------------------| +| `ring_10.toml` | ring | 10 | 15 | | +| `star_7.toml` | star | 7 | 10 | | +| `full_mesh_6.toml` | full_mesh | 6 | 8 | | +| `chain_8.toml` | chain | 8 | 20 | | +| `partitioned_8_heal.toml` | partitioned | 8 | 20 | Heals after round 10 | + +## Dashboard UI + +Once a trace is loaded in the browser: + +- **Graph canvas** -- nodes arranged in a circle then refined with force-directed layout. Nodes and edges flash as events are replayed. +- **Stats panel** -- total nodes, edges, messages, current round. +- **Worker logs** -- per-thread activity feed. +- **Event table** -- full event log with columns: Seq, Round, Thread, Node, Event, Details. +- **Playback controls** -- First / Prev / Play / Pause / Next / Last, timeline slider, speed adjustment (10 ms -- 2000 ms per event). diff --git a/crates/gossip-dashboard/examples/configs/chain_8.toml b/crates/gossip-dashboard/examples/configs/chain_8.toml new file mode 100644 index 0000000..7d05be4 --- /dev/null +++ b/crates/gossip-dashboard/examples/configs/chain_8.toml @@ -0,0 +1,11 @@ +name = "Chain (8 nodes)" +topology = "chain" +num_nodes = 8 +num_rounds = 20 +ticks_per_round = 4 +num_threads = 1 + +[initial_data] +color = "yellow" +version = "1" +status = "pending" diff --git a/crates/gossip-dashboard/examples/configs/full_mesh_6.toml b/crates/gossip-dashboard/examples/configs/full_mesh_6.toml new file mode 100644 index 0000000..4ccd806 --- /dev/null +++ b/crates/gossip-dashboard/examples/configs/full_mesh_6.toml @@ -0,0 +1,12 @@ +name = "Full Mesh (6 nodes)" +topology = "full_mesh" +num_nodes = 6 +num_rounds = 8 +ticks_per_round = 4 +num_threads = 1 + +[initial_data] +color = "green" +version = "3" +status = "ok" +region = "us-east" diff --git a/crates/gossip-dashboard/examples/configs/partitioned_800_heal.toml b/crates/gossip-dashboard/examples/configs/partitioned_800_heal.toml new file mode 100644 index 0000000..d376853 --- /dev/null +++ b/crates/gossip-dashboard/examples/configs/partitioned_800_heal.toml @@ -0,0 +1,11 @@ +name = "Partition & Heal" +topology = "partitioned" +num_nodes = 800 +num_rounds = 10 +ticks_per_round = 5 +num_threads = 4 +heal_after_round = 5 + +[initial_data] +color = "blue" +version = "1" diff --git a/crates/gossip-dashboard/examples/sim.toml b/crates/gossip-dashboard/examples/configs/partitioned_8_heal.toml similarity index 71% rename from crates/gossip-dashboard/examples/sim.toml rename to crates/gossip-dashboard/examples/configs/partitioned_8_heal.toml index f5a84ac..c6a6a49 100644 --- a/crates/gossip-dashboard/examples/sim.toml +++ b/crates/gossip-dashboard/examples/configs/partitioned_8_heal.toml @@ -1,11 +1,10 @@ -name = "Partitioned-8 Heal" +name = "Partition & Heal (8 nodes)" topology = "partitioned" num_nodes = 8 num_rounds = 20 ticks_per_round = 5 -num_threads = 2 +num_threads = 1 heal_after_round = 10 -port = 8080 [initial_data] color = "blue" diff --git a/crates/gossip-dashboard/examples/configs/ring_10.toml b/crates/gossip-dashboard/examples/configs/ring_10.toml new file mode 100644 index 0000000..25eee20 --- /dev/null +++ b/crates/gossip-dashboard/examples/configs/ring_10.toml @@ -0,0 +1,11 @@ +name = "Ring (10 nodes)" +topology = "ring" +num_nodes = 10 +num_rounds = 15 +ticks_per_round = 5 +num_threads = 1 + +[initial_data] +color = "blue" +version = "1" +status = "active" diff --git a/crates/gossip-dashboard/examples/configs/star_7.toml b/crates/gossip-dashboard/examples/configs/star_7.toml new file mode 100644 index 0000000..bf9ec2b --- /dev/null +++ b/crates/gossip-dashboard/examples/configs/star_7.toml @@ -0,0 +1,11 @@ +name = "Star (7 nodes)" +topology = "star" +num_nodes = 7 +num_rounds = 10 +ticks_per_round = 4 +num_threads = 1 + +[initial_data] +color = "red" +version = "2" +status = "ready" diff --git a/crates/gossip-dashboard/examples/dashboard.rs b/crates/gossip-dashboard/examples/dashboard.rs deleted file mode 100644 index 99d42c0..0000000 --- a/crates/gossip-dashboard/examples/dashboard.rs +++ /dev/null @@ -1,40 +0,0 @@ -use gossip_dashboard::{DashboardConfig, config::SimFileConfig, run_with_dashboard, save_trace}; -use swactor_gossip::sim::{SimConfig, Topology}; - -fn main() { - let args: Vec = std::env::args().collect(); - - let (config, dash) = if let Some(path) = args.get(1) { - let file_config = SimFileConfig::load(path).expect("failed to load config file"); - file_config.into_sim_config() - } else { - let config = SimConfig { - name: "Ring-10 Demo".to_string(), - topology: Topology::Ring, - num_nodes: 10, - initial_data: vec![ - ("color".into(), b"blue".to_vec()), - ("version".into(), b"1".to_vec()), - ("status".into(), b"active".to_vec()), - ], - num_rounds: 15, - ticks_per_round: 5, - heal_after_round: None, - num_threads: 2, - }; - let dash = DashboardConfig { port: 8080 }; - (config, dash) - }; - - eprintln!("Starting gossip dashboard at http://localhost:{}", dash.port); - eprintln!("Open in your browser to see the simulation live."); - - let trace = run_with_dashboard(config, dash); - - let path = "demo.trace.json"; - save_trace(&trace, path).expect("failed to save trace"); - eprintln!("Trace saved to {path}"); - eprintln!( - "Replay with: cargo run -p gossip-dashboard --example replay -- {path}" - ); -} diff --git a/crates/gossip-dashboard/examples/generate_traces.rs b/crates/gossip-dashboard/examples/generate_traces.rs new file mode 100644 index 0000000..c2c6248 --- /dev/null +++ b/crates/gossip-dashboard/examples/generate_traces.rs @@ -0,0 +1,69 @@ +use std::path::PathBuf; + +use gossip_dashboard::config::SimFileConfig; +use gossip_dashboard::save_trace; +use swactor_gossip::sim::run_simulation; + +const CONFIGS_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/examples/configs"); + +fn main() { + let args: Vec = std::env::args().collect(); + + let (out_dir, configs) = match args.len() { + // generate_traces → traces/ + all bundled configs + 1 => ("traces".to_string(), collect_configs(CONFIGS_DIR)), + // generate_traces → custom dir + all bundled configs + 2 if !args[1].ends_with(".toml") => (args[1].clone(), collect_configs(CONFIGS_DIR)), + // generate_traces + n if n >= 3 => (args[1].clone(), args[2..].iter().map(PathBuf::from).collect()), + _ => { + eprintln!("Usage:"); + eprintln!(" generate_traces # all configs -> traces/"); + eprintln!(" generate_traces # all configs -> out-dir/"); + eprintln!(" generate_traces [more.toml ...]"); + std::process::exit(1); + } + }; + + if configs.is_empty() { + eprintln!("No .toml configs found in {CONFIGS_DIR}"); + std::process::exit(1); + } + + std::fs::create_dir_all(&out_dir).expect("failed to create output directory"); + + for path in &configs { + let path_str = path.to_string_lossy(); + let file_config = SimFileConfig::load(&path_str) + .unwrap_or_else(|e| panic!("failed to load {path_str}: {e}")); + let config = file_config.into_sim_config(); + + eprintln!("Running: {} ...", config.name); + let trace = run_simulation(config); + + let filename = format!( + "{}/{}.trace.json", + out_dir, + trace.name.to_lowercase().replace(' ', "_").replace(['(', ')'], "") + ); + save_trace(&trace, &filename).expect("failed to save trace"); + eprintln!( + " -> {} ({} nodes, {} events)", + filename, + trace.node_names.len(), + trace.events.len() + ); + } + eprintln!("Done. View with: cargo run -p gossip-dashboard --example replay -- {out_dir}"); +} + +fn collect_configs(dir: &str) -> Vec { + let mut paths: Vec = std::fs::read_dir(dir) + .unwrap_or_else(|e| panic!("cannot read configs dir {dir}: {e}")) + .flatten() + .map(|e| e.path()) + .filter(|p| p.extension().is_some_and(|ext| ext == "toml")) + .collect(); + paths.sort(); + paths +} diff --git a/crates/gossip-dashboard/examples/replay.rs b/crates/gossip-dashboard/examples/replay.rs index f5f5482..bceb1fa 100644 --- a/crates/gossip-dashboard/examples/replay.rs +++ b/crates/gossip-dashboard/examples/replay.rs @@ -1,19 +1,15 @@ -use gossip_dashboard::{load_trace, serve_replay}; +use gossip_dashboard::serve_dashboard; fn main() { let args: Vec = std::env::args().collect(); - let path = args + let trace_dir = args .get(1) - .expect("Usage: replay "); + .expect("Usage: replay [port]"); - let trace = load_trace(path).expect("failed to load trace"); + let port: u16 = args + .get(2) + .and_then(|s| s.parse().ok()) + .unwrap_or(8080); - eprintln!( - "Loaded trace '{}': {} nodes, {} events", - trace.name, - trace.node_names.len(), - trace.events.len() - ); - - serve_replay(&trace, 8081); + serve_dashboard(trace_dir, port); } diff --git a/crates/gossip-dashboard/src/config.rs b/crates/gossip-dashboard/src/config.rs index a0ff4af..e209fc7 100644 --- a/crates/gossip-dashboard/src/config.rs +++ b/crates/gossip-dashboard/src/config.rs @@ -5,8 +5,6 @@ use std::io; use serde::Deserialize; use swactor_gossip::sim::{SimConfig, Topology}; -use crate::server::DashboardConfig; - #[derive(Deserialize)] pub struct SimFileConfig { pub name: String, @@ -16,7 +14,6 @@ pub struct SimFileConfig { pub ticks_per_round: usize, pub num_threads: usize, pub heal_after_round: Option, - pub port: Option, pub initial_data: Option>, } @@ -26,7 +23,7 @@ impl SimFileConfig { toml::from_str(&contents).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e)) } - pub fn into_sim_config(self) -> (SimConfig, DashboardConfig) { + pub fn into_sim_config(self) -> SimConfig { let topology = match self.topology.to_lowercase().as_str() { "ring" => Topology::Ring, "star" => Topology::Star, @@ -43,7 +40,7 @@ impl SimFileConfig { .map(|(k, v)| (k, v.into_bytes())) .collect(); - let sim = SimConfig { + SimConfig { name: self.name, topology, num_nodes: self.num_nodes, @@ -52,12 +49,6 @@ impl SimFileConfig { ticks_per_round: self.ticks_per_round, heal_after_round: self.heal_after_round, num_threads: self.num_threads, - }; - - let dash = DashboardConfig { - port: self.port.unwrap_or(8080), - }; - - (sim, dash) + } } } diff --git a/crates/gossip-dashboard/src/dashboard_html.rs b/crates/gossip-dashboard/src/dashboard_html.rs index c9eb6b0..5d3fc90 100644 --- a/crates/gossip-dashboard/src/dashboard_html.rs +++ b/crates/gossip-dashboard/src/dashboard_html.rs @@ -13,12 +13,24 @@ pub const DASHBOARD_HTML: &str = r##" padding: 12px 20px; background: #161822; border-bottom: 1px solid #2a2d3a; } .header h1 { font-size: 18px; font-weight: 600; color: #c0c6d4; } + + .trace-select { + background: #1a1d2e; color: #e0e0e0; border: 1px solid #2a2d3a; + border-radius: 4px; padding: 6px 12px; font-size: 13px; + cursor: pointer; min-width: 240px; max-width: 420px; + } + .trace-select:hover { border-color: #4f46e5; } + .trace-select:focus { outline: none; border-color: #6366f1; } + .trace-select:disabled { cursor: default; opacity: 0.5; } + .status-badge { display: flex; align-items: center; gap: 6px; font-size: 13px; color: #9ca3af; } - .status-dot { width: 8px; height: 8px; border-radius: 50%; background: #3b82f6; } - .status-dot.done { background: #22c55e; } - .status-dot.replay { background: #f59e0b; } + .status-dot { width: 8px; height: 8px; border-radius: 50%; background: #f59e0b; } + .status-dot.ready { background: #22c55e; } + .status-dot.loading { background: #3b82f6; animation: pulse 1s infinite; } + .status-dot.error { background: #ef4444; } + @keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } } .main { display: grid; grid-template-columns: 1fr 1fr; grid-template-rows: auto 1fr; height: calc(100vh - 48px); } @@ -82,18 +94,18 @@ pub const DASHBOARD_HTML: &str = r##" border-radius: 4px; padding: 2px 6px; font-size: 12px; text-align: right; } .replay-controls .speed-group label { font-size: 11px; color: #6b7280; white-space: nowrap; } - - .node-flash { animation: flash 0.4s ease-out; } - @keyframes flash { 0% { filter: brightness(2); } 100% { filter: brightness(1); } }
-

Gossip Simulation Dashboard

+

Gossip Dashboard

+
-
- Connecting... +
+ Loading traces...
@@ -141,23 +153,23 @@ pub const DASHBOARD_HTML: &str = r##" diff --git a/crates/gossip-dashboard/src/lib.rs b/crates/gossip-dashboard/src/lib.rs index 7b1da6d..2be0444 100644 --- a/crates/gossip-dashboard/src/lib.rs +++ b/crates/gossip-dashboard/src/lib.rs @@ -2,7 +2,7 @@ pub mod config; mod dashboard_html; mod server; -pub use server::{DashboardConfig, run_with_dashboard, serve_replay}; +pub use server::serve_dashboard; use std::fs; use std::io; diff --git a/crates/gossip-dashboard/src/server.rs b/crates/gossip-dashboard/src/server.rs index e4cf230..10fda6e 100644 --- a/crates/gossip-dashboard/src/server.rs +++ b/crates/gossip-dashboard/src/server.rs @@ -1,720 +1,78 @@ -use std::collections::HashMap; -use std::io::{self, Read as IoRead}; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::{mpsc, Arc, Mutex}; -use std::thread; -use std::time::Duration; +use std::fs; +use std::path::{Path, PathBuf}; use serde::Serialize; -use swactor::actor::ActorAddress; -use swactor::config::RuntimeConfig; -use swactor::runtime::Runtime; -use swactor_gossip::protocol::{GossipActor, GossipMessage}; -use swactor_gossip::sim::{heal_partition_via_handle, wire_topology, SimConfig}; -use swactor_gossip::trace::{ - EventLog, GossipEvent, GossipEventKind, NameRegistry, NodeSnapshot, SimulationTrace, - TickCounter, -}; use crate::dashboard_html::DASHBOARD_HTML; -// ── Configuration ────────────────────────────────────────────────────── - -#[derive(Debug, Clone)] -pub struct DashboardConfig { - pub port: u16, -} - -impl Default for DashboardConfig { - fn default() -> Self { - Self { port: 8080 } - } -} - -// ── Shared dashboard state ───────────────────────────────────────────── - -struct DashboardState { - event_log: EventLog, - name_registry: NameRegistry, - init_data: Mutex>, - stats: Mutex, - done: AtomicBool, -} +// ── Trace directory scanning ────────────────────────────────────────── #[derive(Debug, Clone, Serialize)] -struct InitData { +struct TraceEntry { + file: String, name: String, - nodes: Vec, - edges: Vec<[String; 2]>, - num_threads: usize, + nodes: usize, + events: usize, } -#[derive(Debug, Clone, Serialize)] -struct NodeInfo { - name: String, - addr: String, -} - -#[derive(Debug, Clone, Default, Serialize)] -struct StatsSnapshot { - total_nodes: usize, - total_edges: usize, - total_messages: usize, - current_round: u64, - total_rounds: usize, -} - -// ── SSE channel adapter ──────────────────────────────────────────────── - -/// Adapts an `mpsc::Receiver>` to `std::io::Read` for tiny_http streaming. -struct ChannelReader { - rx: mpsc::Receiver>, - buf: Vec, - pos: usize, -} - -impl ChannelReader { - fn new(rx: mpsc::Receiver>) -> Self { - Self { - rx, - buf: Vec::new(), - pos: 0, - } - } -} - -impl IoRead for ChannelReader { - fn read(&mut self, out: &mut [u8]) -> io::Result { - // Drain current buffer first. - if self.pos < self.buf.len() { - let n = std::cmp::min(out.len(), self.buf.len() - self.pos); - out[..n].copy_from_slice(&self.buf[self.pos..self.pos + n]); - self.pos += n; - return Ok(n); - } - - // Wait for next chunk. - match self.rx.recv() { - Ok(data) => { - if data.is_empty() { - return Ok(0); // EOF signal - } - let n = std::cmp::min(out.len(), data.len()); - out[..n].copy_from_slice(&data[..n]); - if n < data.len() { - self.buf = data; - self.pos = n; - } else { - self.buf.clear(); - self.pos = 0; - } - Ok(n) - } - Err(_) => Ok(0), // channel closed - } - } -} - -// ── SSE formatting helpers ───────────────────────────────────────────── - -fn format_sse(event: &str, data: &str) -> Vec { - format!("event: {event}\ndata: {data}\n\n").into_bytes() -} - -fn event_kind_name(kind: &GossipEventKind) -> &'static str { - match kind { - GossipEventKind::LocalSet { .. } => "LocalSet", - GossipEventKind::GossipRoundStarted { .. } => "GossipRoundStarted", - GossipEventKind::GossipRoundNoPeers => "GossipRoundNoPeers", - GossipEventKind::PushReceived { .. } => "PushReceived", - GossipEventKind::QueryReceived { .. } => "QueryReceived", - GossipEventKind::PeerAdded { .. } => "PeerAdded", - GossipEventKind::PeerRemoved { .. } => "PeerRemoved", - GossipEventKind::StateSnapshot { .. } => "StateSnapshot", - } -} - -#[derive(Serialize)] -struct SseGossipEvent { - seq: usize, - tick: u64, - node: String, - thread: Option, - kind: String, - detail: serde_json::Value, -} - -fn gossip_event_to_sse(seq: usize, ev: &GossipEvent) -> SseGossipEvent { - let detail = match &ev.kind { - GossipEventKind::LocalSet { key } => { - serde_json::json!({ "key": key }) - } - GossipEventKind::GossipRoundStarted { target_name } => { - serde_json::json!({ "target": target_name }) - } - GossipEventKind::GossipRoundNoPeers => serde_json::json!({}), - GossipEventKind::PushReceived { - from_name, - keys_updated, - } => { - serde_json::json!({ "from": from_name, "keys_updated": keys_updated }) - } - GossipEventKind::QueryReceived { key } => { - serde_json::json!({ "key": key }) - } - GossipEventKind::PeerAdded { peer_name } => { - serde_json::json!({ "peer": peer_name }) - } - GossipEventKind::PeerRemoved { peer_name } => { - serde_json::json!({ "peer": peer_name }) - } - GossipEventKind::StateSnapshot { snapshot } => { - serde_json::json!({ - "entries": snapshot.entries.len(), - "peer_count": snapshot.peer_count, - }) - } +fn scan_traces(dir: &Path) -> Vec { + let mut entries = Vec::new(); + let Ok(read_dir) = fs::read_dir(dir) else { + return entries; }; - - SseGossipEvent { - seq, - tick: ev.tick, - node: ev.node_name.clone(), - thread: ev.thread_name.clone(), - kind: event_kind_name(&ev.kind).to_string(), - detail, - } -} - -// ── HTTP server ──────────────────────────────────────────────────────── - -fn spawn_http_server(state: Arc, port: u16, mode: &str) { - let addr = format!("0.0.0.0:{port}"); - let server = tiny_http::Server::http(&addr).expect("failed to bind HTTP server"); - let server = Arc::new(server); - let mode = mode.to_string(); - - // Spawn a pool of handler threads. - for _ in 0..4 { - let server = Arc::clone(&server); - let state = Arc::clone(&state); - let mode = mode.clone(); - thread::spawn(move || { - loop { - let request = match server.recv() { - Ok(r) => r, - Err(_) => break, - }; - - let url = request.url().to_string(); - match url.as_str() { - "/" => { - let html = DASHBOARD_HTML.replace("__DASHBOARD_MODE__", &mode); - let response = tiny_http::Response::from_string(html) - .with_header( - "Content-Type: text/html; charset=utf-8" - .parse::() - .unwrap(), - ); - let _ = request.respond(response); - } - "/events" => { - handle_sse(request, Arc::clone(&state)); - } - "/trace.json" => { - handle_trace_json(request, Arc::clone(&state)); - } - _ => { - let response = - tiny_http::Response::from_string("Not Found").with_status_code(404); - let _ = request.respond(response); - } - } - } + for entry in read_dir.flatten() { + let path = entry.path(); + let fname = path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + if !fname.ends_with(".trace.json") { + continue; + } + let Ok(data) = fs::read_to_string(&path) else { + continue; + }; + // Parse as generic JSON to extract metadata without full deserialization. + let Ok(val) = serde_json::from_str::(&data) else { + continue; + }; + let name = val + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or(&fname) + .to_string(); + let nodes = val + .get("node_names") + .and_then(|v| v.as_array()) + .map(|a| a.len()) + .unwrap_or(0); + let events = val + .get("events") + .and_then(|v| v.as_array()) + .map(|a| a.len()) + .unwrap_or(0); + entries.push(TraceEntry { + file: fname, + name, + nodes, + events, }); } + entries.sort_by(|a, b| a.file.cmp(&b.file)); + entries } -fn handle_sse(request: tiny_http::Request, state: Arc) { - let (tx, rx) = mpsc::channel::>(); - let reader = ChannelReader::new(rx); +// ── HTTP server ─────────────────────────────────────────────────────── - // Send SSE headers via a streaming response. - let response = tiny_http::Response::new( - tiny_http::StatusCode(200), - vec![ - "Content-Type: text/event-stream" - .parse::() - .unwrap(), - "Cache-Control: no-cache" - .parse::() - .unwrap(), - "Connection: keep-alive" - .parse::() - .unwrap(), - ], - Box::new(reader) as Box, - None, - None, - ); - - // Spawn producer thread that polls for new events. - thread::spawn(move || { - let mut cursor: usize = 0; - - // Wait for init data. - loop { - if let Some(init) = state.init_data.lock().unwrap().as_ref() { - let json = serde_json::to_string(init).unwrap(); - if tx.send(format_sse("init", &json)).is_err() { - return; - } - break; - } - thread::sleep(Duration::from_millis(50)); - } - - // Poll for events. - loop { - { - let log = state.event_log.lock().unwrap(); - while cursor < log.len() { - let ev = &log[cursor]; - // Filter out StateSnapshot events from SSE stream. - if !matches!(ev.kind, GossipEventKind::StateSnapshot { .. }) { - let sse_ev = gossip_event_to_sse(cursor, ev); - let json = serde_json::to_string(&sse_ev).unwrap(); - if tx.send(format_sse("gossip", &json)).is_err() { - return; - } - } - cursor += 1; - } - } - - // Send stats update. - { - let stats = state.stats.lock().unwrap().clone(); - let json = serde_json::to_string(&stats).unwrap(); - if tx.send(format_sse("stats", &json)).is_err() { - return; - } - } - - if state.done.load(Ordering::Relaxed) { - let _ = tx.send(format_sse("done", "{}")); - let _ = tx.send(Vec::new()); // EOF - return; - } - - thread::sleep(Duration::from_millis(50)); - } - }); - - // This blocks until the reader is consumed / connection closes. - let _ = request.respond(response); -} - -fn handle_trace_json(request: tiny_http::Request, state: Arc) { - // Build a partial trace from current state. - let events = state.event_log.lock().unwrap().clone(); - let names_map = state.name_registry.lock().unwrap().clone(); - let init = state.init_data.lock().unwrap().clone(); - - let trace = SimulationTrace { - name: init.as_ref().map(|i| i.name.clone()).unwrap_or_default(), - node_names: init - .as_ref() - .map(|i| i.nodes.iter().map(|n| n.name.clone()).collect()) - .unwrap_or_default(), - node_addrs: { - let mut addrs: Vec = Vec::new(); - if let Some(init) = &init { - // Reconstruct addrs from name_registry in node order. - let inv: HashMap = - names_map.into_iter().map(|(a, n)| (n, a)).collect(); - for node in &init.nodes { - if let Some(&addr) = inv.get(&node.name) { - addrs.push(addr); - } - } - } - addrs - }, - topology_edges: init - .as_ref() - .map(|i| { - i.edges - .iter() - .map(|e| (e[0].clone(), e[1].clone())) - .collect() - }) - .unwrap_or_default(), - events, - snapshots_per_round: Vec::new(), - num_rounds: init - .as_ref() - .map(|_| { - state - .stats - .lock() - .unwrap() - .total_rounds - }) - .unwrap_or(0), - total_keys: 0, - }; - - let json = serde_json::to_string(&trace).unwrap(); - let response = tiny_http::Response::from_string(json).with_header( - "Content-Type: application/json" - .parse::() - .unwrap(), - ); - let _ = request.respond(response); -} - -// ── Public API: run_with_dashboard ───────────────────────────────────── - -pub fn run_with_dashboard(config: SimConfig, dash: DashboardConfig) -> SimulationTrace { - let num_threads = config.num_threads.max(1); - let event_log: EventLog = Arc::new(Mutex::new(Vec::new())); - let tick_counter: TickCounter = Arc::new(AtomicU64::new(0)); - let name_registry: NameRegistry = Arc::new(Mutex::new(HashMap::new())); - - let state = Arc::new(DashboardState { - event_log: Arc::clone(&event_log), - name_registry: Arc::clone(&name_registry), - init_data: Mutex::new(None), - stats: Mutex::new(StatsSnapshot::default()), - done: AtomicBool::new(false), - }); - - // Start HTTP server. - spawn_http_server(Arc::clone(&state), dash.port, "live"); - - if num_threads < 2 { - run_dashboard_single_threaded(config, state, event_log, tick_counter, name_registry) - } else { - run_dashboard_multi_threaded(config, state, event_log, tick_counter, name_registry) - } -} - -fn run_dashboard_single_threaded( - config: SimConfig, - state: Arc, - event_log: EventLog, - tick_counter: TickCounter, - name_registry: NameRegistry, -) -> SimulationTrace { - let rt = Runtime::new(RuntimeConfig { - num_threads: 1, - max_actors: (config.num_nodes + 64).next_power_of_two(), - actor_max_messages: (config.num_nodes * 4).max(1_000), - ..Default::default() - }); - - let mut addrs = Vec::with_capacity(config.num_nodes); - let mut names = Vec::with_capacity(config.num_nodes); - for i in 0..config.num_nodes { - let name = format!("node-{i}"); - let actor = GossipActor::traced( - Arc::clone(&event_log), - Arc::clone(&tick_counter), - Arc::clone(&name_registry), - ); - let addr = rt.spawn(actor).unwrap(); - name_registry.lock().unwrap().insert(addr, name.clone()); - addrs.push(addr); - names.push(name); - } - - let edges = wire_topology(&rt, &config.topology, &addrs, &names); - for _ in 0..3 { - rt.tick(); - } - - // Publish init data. - publish_init(&state, &config, &addrs, &names, &edges); - - let total_keys = config.initial_data.len(); - for (key, value) in &config.initial_data { - rt.send_to( - addrs[0], - GossipMessage::Set { - key: key.clone(), - value: value.clone(), - }, - ) - .unwrap(); - } - rt.tick(); - - let mut snapshots_per_round: Vec> = Vec::new(); - - for round in 0..config.num_rounds { - if config.heal_after_round == Some(round) { - swactor_gossip::sim::heal_partition(&rt, &config.topology, &addrs, &names); - for _ in 0..3 { - rt.tick(); - } - } - - tick_counter.store((round + 1) as u64, Ordering::Relaxed); - update_stats(&state, &config, round, &event_log); - - for &addr in &addrs { - rt.send_to(addr, GossipMessage::DoGossipRound).unwrap(); - } - for _ in 0..config.ticks_per_round { - rt.tick(); - } - - for &addr in &addrs { - rt.send_to(addr, GossipMessage::TakeSnapshot).unwrap(); - } - for _ in 0..3 { - rt.tick(); - } - - let current_round_tick = (round + 1) as u64; - let log = event_log.lock().unwrap(); - let mut round_snapshots: Vec<(String, NodeSnapshot)> = Vec::new(); - for event in log.iter().rev() { - if event.tick != current_round_tick { - break; - } - if let GossipEventKind::StateSnapshot { ref snapshot } = event.kind { - round_snapshots.push((event.node_name.clone(), snapshot.clone())); - } - } - round_snapshots.reverse(); - snapshots_per_round.push(round_snapshots); - } - - state.done.store(true, Ordering::Relaxed); - - let events = event_log.lock().unwrap().clone(); - SimulationTrace { - name: config.name, - node_names: names, - node_addrs: addrs, - topology_edges: edges, - events, - snapshots_per_round, - num_rounds: config.num_rounds, - total_keys, - } -} - -fn run_dashboard_multi_threaded( - config: SimConfig, - state: Arc, - event_log: EventLog, - tick_counter: TickCounter, - name_registry: NameRegistry, -) -> SimulationTrace { - let ticks_per_round = config.ticks_per_round; - let settle_ms = (ticks_per_round as u64 * 2).max(10); - - let rt = Runtime::new(RuntimeConfig { - num_threads: config.num_threads, - max_actors: (config.num_nodes + 64).next_power_of_two(), - actor_max_messages: (config.num_nodes * 4).max(1_000), - ..Default::default() - }); - - let mut addrs = Vec::with_capacity(config.num_nodes); - let mut names = Vec::with_capacity(config.num_nodes); - for i in 0..config.num_nodes { - let name = format!("node-{i}"); - let actor = GossipActor::traced( - Arc::clone(&event_log), - Arc::clone(&tick_counter), - Arc::clone(&name_registry), - ); - let addr = rt.spawn(actor).unwrap(); - name_registry.lock().unwrap().insert(addr, name.clone()); - addrs.push(addr); - names.push(name); - } - - let edges = wire_topology(&rt, &config.topology, &addrs, &names); - - // Publish init data. - publish_init(&state, &config, &addrs, &names, &edges); - - let total_keys = config.initial_data.len(); - for (key, value) in &config.initial_data { - rt.send_to( - addrs[0], - GossipMessage::Set { - key: key.clone(), - value: value.clone(), - }, - ) - .unwrap(); - } - - let handle = rt.run().expect("failed to start multi-threaded runtime"); - thread::sleep(Duration::from_millis(settle_ms * 2)); - - let mut snapshots_per_round: Vec> = Vec::new(); - - for round in 0..config.num_rounds { - if config.heal_after_round == Some(round) { - heal_partition_via_handle(&handle, &config.topology, &addrs, &names); - thread::sleep(Duration::from_millis(settle_ms)); - } - - tick_counter.store((round + 1) as u64, Ordering::Relaxed); - update_stats(&state, &config, round, &event_log); - - for &addr in &addrs { - handle - .runtime - .send_to(addr, GossipMessage::DoGossipRound) - .unwrap(); - } - thread::sleep(Duration::from_millis(settle_ms)); - - for &addr in &addrs { - handle - .runtime - .send_to(addr, GossipMessage::TakeSnapshot) - .unwrap(); - } - thread::sleep(Duration::from_millis(settle_ms / 2)); - - let current_round_tick = (round + 1) as u64; - let log = event_log.lock().unwrap(); - let mut round_snapshots: Vec<(String, NodeSnapshot)> = Vec::new(); - for event in log.iter().rev() { - if event.tick != current_round_tick { - break; - } - if let GossipEventKind::StateSnapshot { ref snapshot } = event.kind { - round_snapshots.push((event.node_name.clone(), snapshot.clone())); - } - } - round_snapshots.reverse(); - snapshots_per_round.push(round_snapshots); - } - - handle.shutdown(); - handle.join(); - - state.done.store(true, Ordering::Relaxed); - - let events = event_log.lock().unwrap().clone(); - SimulationTrace { - name: config.name, - node_names: names, - node_addrs: addrs, - topology_edges: edges, - events, - snapshots_per_round, - num_rounds: config.num_rounds, - total_keys, - } -} - -// ── Helpers ──────────────────────────────────────────────────────────── - -fn publish_init( - state: &DashboardState, - config: &SimConfig, - addrs: &[ActorAddress], - names: &[String], - edges: &[(String, String)], -) { - let nodes: Vec = names - .iter() - .zip(addrs.iter()) - .map(|(name, addr)| NodeInfo { - name: name.clone(), - addr: format!("{addr}"), - }) - .collect(); - let edge_pairs: Vec<[String; 2]> = edges - .iter() - .map(|(a, b)| [a.clone(), b.clone()]) - .collect(); - *state.init_data.lock().unwrap() = Some(InitData { - name: config.name.clone(), - nodes, - edges: edge_pairs, - num_threads: config.num_threads, - }); - *state.stats.lock().unwrap() = StatsSnapshot { - total_nodes: config.num_nodes, - total_edges: edges.len(), - total_messages: 0, - current_round: 0, - total_rounds: config.num_rounds, - }; -} - -fn update_stats(state: &DashboardState, config: &SimConfig, round: usize, event_log: &EventLog) { - let msg_count = event_log.lock().unwrap().len(); - let mut stats = state.stats.lock().unwrap(); - stats.current_round = (round + 1) as u64; - stats.total_messages = msg_count; - stats.total_rounds = config.num_rounds; -} - -// ── Replay mode ──────────────────────────────────────────────────────── - -pub fn serve_replay(trace: &SimulationTrace, port: u16) { - let event_log: EventLog = Arc::new(Mutex::new(trace.events.clone())); - let name_registry: NameRegistry = Arc::new(Mutex::new( - trace - .node_names - .iter() - .zip(trace.node_addrs.iter()) - .map(|(n, a)| (*a, n.clone())) - .collect(), - )); - - let edges: Vec<[String; 2]> = trace - .topology_edges - .iter() - .map(|(a, b)| [a.clone(), b.clone()]) - .collect(); - let nodes: Vec = trace - .node_names - .iter() - .zip(trace.node_addrs.iter()) - .map(|(name, addr)| NodeInfo { - name: name.clone(), - addr: format!("{addr}"), - }) - .collect(); - - // Keep state alive for potential future SSE support in replay mode. - let _state = Arc::new(DashboardState { - event_log, - name_registry, - init_data: Mutex::new(Some(InitData { - name: trace.name.clone(), - nodes, - edges, - num_threads: 1, - })), - stats: Mutex::new(StatsSnapshot { - total_nodes: trace.node_names.len(), - total_edges: trace.topology_edges.len(), - total_messages: trace.events.len(), - current_round: trace.num_rounds as u64, - total_rounds: trace.num_rounds, - }), - done: AtomicBool::new(true), - }); +pub fn serve_dashboard(trace_dir: &str, port: u16) { + let dir = PathBuf::from(trace_dir); + assert!(dir.is_dir(), "trace directory does not exist: {trace_dir}"); let addr = format!("0.0.0.0:{port}"); let server = tiny_http::Server::http(&addr).expect("failed to bind HTTP server"); - eprintln!("Replay dashboard at http://localhost:{port}"); + eprintln!("Dashboard at http://localhost:{port}"); + eprintln!("Serving traces from: {trace_dir}"); loop { let request = match server.recv() { @@ -725,16 +83,16 @@ pub fn serve_replay(trace: &SimulationTrace, port: u16) { let url = request.url().to_string(); match url.as_str() { "/" => { - let html = DASHBOARD_HTML.replace("__DASHBOARD_MODE__", "replay"); - let response = tiny_http::Response::from_string(html).with_header( + let response = tiny_http::Response::from_string(DASHBOARD_HTML).with_header( "Content-Type: text/html; charset=utf-8" .parse::() .unwrap(), ); let _ = request.respond(response); } - "/trace.json" => { - let json = serde_json::to_string(trace).unwrap(); + "/traces" => { + let entries = scan_traces(&dir); + let json = serde_json::to_string(&entries).unwrap(); let response = tiny_http::Response::from_string(json).with_header( "Content-Type: application/json" .parse::() @@ -742,6 +100,39 @@ pub fn serve_replay(trace: &SimulationTrace, port: u16) { ); let _ = request.respond(response); } + _ if url.starts_with("/trace.json?file=") => { + let raw = url.strip_prefix("/trace.json?file=").unwrap(); + let file = percent_decode(raw); + + // Reject path traversal attempts. + if file.contains('/') + || file.contains('\\') + || file.contains("..") + || file.is_empty() + { + let response = + tiny_http::Response::from_string("Bad Request").with_status_code(400); + let _ = request.respond(response); + continue; + } + + let path = dir.join(&file); + match fs::read_to_string(&path) { + Ok(data) => { + let response = tiny_http::Response::from_string(data).with_header( + "Content-Type: application/json" + .parse::() + .unwrap(), + ); + let _ = request.respond(response); + } + Err(_) => { + let response = + tiny_http::Response::from_string("Not Found").with_status_code(404); + let _ = request.respond(response); + } + } + } _ => { let response = tiny_http::Response::from_string("Not Found").with_status_code(404); @@ -750,3 +141,32 @@ pub fn serve_replay(trace: &SimulationTrace, port: u16) { } } } + +// ── Helpers ─────────────────────────────────────────────────────────── + +fn percent_decode(s: &str) -> String { + let mut result = Vec::new(); + let bytes = s.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' && i + 2 < bytes.len() { + if let (Some(h), Some(l)) = (hex_val(bytes[i + 1]), hex_val(bytes[i + 2])) { + result.push(h << 4 | l); + i += 3; + continue; + } + } + result.push(bytes[i]); + i += 1; + } + String::from_utf8_lossy(&result).to_string() +} + +fn hex_val(b: u8) -> Option { + match b { + b'0'..=b'9' => Some(b - b'0'), + b'a'..=b'f' => Some(b - b'a' + 10), + b'A'..=b'F' => Some(b - b'A' + 10), + _ => None, + } +} diff --git a/crates/swactor-gossip/src/sim.rs b/crates/swactor-gossip/src/sim.rs index 3defecf..9e1846f 100644 --- a/crates/swactor-gossip/src/sim.rs +++ b/crates/swactor-gossip/src/sim.rs @@ -289,13 +289,14 @@ pub fn heal_partition_via_handle( handle: &swactor::runtime::RuntimeHandle, topology: &Topology, addrs: &[ActorAddress], - _names: &[String], -) { + names: &[String], +) -> Vec<(String, String)> { if !matches!(topology, Topology::Partitioned) { - return; + return Vec::new(); } let n = addrs.len(); let half = n / 2; + let mut new_edges = Vec::new(); if half > 0 && half < n { handle .runtime @@ -305,7 +306,10 @@ pub fn heal_partition_via_handle( .runtime .send_to(addrs[half], GossipMessage::AddPeer(addrs[half - 1])) .unwrap(); + new_edges.push((names[half - 1].clone(), names[half].clone())); + new_edges.push((names[half].clone(), names[half - 1].clone())); } + new_edges } // ── Topology wiring ────────────────────────────────────────────────────── @@ -377,18 +381,22 @@ pub fn heal_partition( rt: &Runtime, topology: &Topology, addrs: &[ActorAddress], - _names: &[String], -) { + names: &[String], +) -> Vec<(String, String)> { if !matches!(topology, Topology::Partitioned) { - return; + return Vec::new(); } let n = addrs.len(); let half = n / 2; + let mut new_edges = Vec::new(); // Add bidirectional links between the two halves (bridge nodes). if half > 0 && half < n { rt.send_to(addrs[half - 1], GossipMessage::AddPeer(addrs[half])) .unwrap(); rt.send_to(addrs[half], GossipMessage::AddPeer(addrs[half - 1])) .unwrap(); + new_edges.push((names[half - 1].clone(), names[half].clone())); + new_edges.push((names[half].clone(), names[half - 1].clone())); } + new_edges } -- 2.45.2 From 4da077df86aff20a4d119d57a0eb3a2f65258ec6 Mon Sep 17 00:00:00 2001 From: Zachery Aaron Shores-Chmielewski Date: Mon, 9 Feb 2026 14:48:42 +0700 Subject: [PATCH 2/3] fix: dashboard issues with large graphs --- crates/gossip-dashboard/src/dashboard_html.rs | 865 ++++++++++-------- 1 file changed, 492 insertions(+), 373 deletions(-) diff --git a/crates/gossip-dashboard/src/dashboard_html.rs b/crates/gossip-dashboard/src/dashboard_html.rs index 5d3fc90..0c22a2a 100644 --- a/crates/gossip-dashboard/src/dashboard_html.rs +++ b/crates/gossip-dashboard/src/dashboard_html.rs @@ -7,111 +7,70 @@ pub const DASHBOARD_HTML: &str = r##" -

Gossip Dashboard

- +
Loading traces...
-
+
+
Computing layout...
+
+
@@ -155,169 +114,351 @@ pub const DASHBOARD_HTML: &str = r##" -- 2.45.2 From 3fb02c5ff0d71d6f7244a1bb5b402a9f264615ad Mon Sep 17 00:00:00 2001 From: Zachery Aaron Shores-Chmielewski Date: Mon, 9 Feb 2026 16:03:16 +0700 Subject: [PATCH 3/3] fix: gossip dashboard Better display and more metrics for larger node graphs. --- .../configs/partitioned_800_heal.toml | 4 +- crates/gossip-dashboard/src/dashboard_html.rs | 813 ++++++++++++++++-- 2 files changed, 759 insertions(+), 58 deletions(-) diff --git a/crates/gossip-dashboard/examples/configs/partitioned_800_heal.toml b/crates/gossip-dashboard/examples/configs/partitioned_800_heal.toml index d376853..6943d35 100644 --- a/crates/gossip-dashboard/examples/configs/partitioned_800_heal.toml +++ b/crates/gossip-dashboard/examples/configs/partitioned_800_heal.toml @@ -1,6 +1,6 @@ -name = "Partition & Heal" +name = "Partition & Heal (100 nodes)" topology = "partitioned" -num_nodes = 800 +num_nodes = 100 num_rounds = 10 ticks_per_round = 5 num_threads = 4 diff --git a/crates/gossip-dashboard/src/dashboard_html.rs b/crates/gossip-dashboard/src/dashboard_html.rs index 0c22a2a..fb5fb84 100644 --- a/crates/gossip-dashboard/src/dashboard_html.rs +++ b/crates/gossip-dashboard/src/dashboard_html.rs @@ -53,6 +53,35 @@ pub const DASHBOARD_HTML: &str = r##" .replay-controls .speed-group { display: flex; align-items: center; gap: 4px; margin-left: 8px; } .replay-controls .speed-group input { width: 64px; background: #1a1d2e; color: #e0e0e0; border: 1px solid #2a2d3a; border-radius: 4px; padding: 2px 6px; font-size: 12px; text-align: right; } .replay-controls .speed-group label { font-size: 11px; color: #6b7280; white-space: nowrap; } + .tab-bar { display: flex; border-bottom: 1px solid #2a2d3a; background: #161822; flex-shrink: 0; } + .tab-btn { flex: 1; padding: 8px 0; font-size: 13px; font-weight: 500; color: #6b7280; background: none; border: none; border-bottom: 2px solid transparent; cursor: pointer; text-align: center; } + .tab-btn:hover { color: #9ca3af; } + .tab-btn.active { color: #818cf8; border-bottom-color: #818cf8; } + .tab-content { display: none; flex: 1; flex-direction: column; overflow: hidden; min-height: 0; } + .tab-content.active { display: flex; } + .analytics-scroll { flex: 1; overflow-y: auto; padding: 12px 16px; } + .badge-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 16px; } + .badge { background: #1a1d2e; border-radius: 6px; padding: 10px 12px; border-left: 3px solid #6b7280; } + .badge.green { border-left-color: #22c55e; } + .badge.yellow { border-left-color: #f59e0b; } + .badge.red { border-left-color: #ef4444; } + .badge .badge-val { font-size: 18px; font-weight: 700; color: #e5e7eb; } + .badge .badge-lbl { font-size: 11px; color: #6b7280; text-transform: uppercase; } + .chart-section { margin-bottom: 16px; } + .chart-section h3 { font-size: 12px; color: #6b7280; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 6px; } + .chart-section canvas { width: 100%; border-radius: 4px; background: #1a1d2e; display: block; } + .chart-empty { padding: 24px; text-align: center; color: #4b5563; font-size: 13px; } + .node-detail { position: absolute; top: 60px; left: 20px; width: 280px; background: rgba(22,24,34,0.95); border: 1px solid #2a2d3a; border-radius: 8px; padding: 14px; z-index: 20; display: none; font-size: 12px; } + .node-detail h3 { font-size: 14px; color: #c7d2fe; margin-bottom: 8px; } + .node-detail .close-btn { position: absolute; top: 8px; right: 10px; background: none; border: none; color: #6b7280; cursor: pointer; font-size: 16px; } + .node-detail .close-btn:hover { color: #e0e0e0; } + .node-detail .nd-row { display: flex; justify-content: space-between; padding: 3px 0; border-bottom: 1px solid #1a1d2e; } + .node-detail .nd-key { color: #6b7280; } + .node-detail .nd-val { color: #e5e7eb; font-weight: 500; } + .node-detail .nd-events { max-height: 160px; overflow-y: auto; margin-top: 8px; } + .node-detail .nd-ev { color: #9ca3af; padding: 1px 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 11px; } + .heatmap-wrap { position: relative; } + .heatmap-tooltip { position: absolute; display: none; background: rgba(22,24,34,0.95); border: 1px solid #2a2d3a; border-radius: 4px; padding: 4px 8px; font-size: 11px; color: #e0e0e0; white-space: nowrap; pointer-events: none; z-index: 5; } @@ -71,41 +100,73 @@ pub const DASHBOARD_HTML: &str = r##"
Computing layout...
+
+ +

+
+
+
-
-

Stats

-
-
0
Nodes
-
0
Edges
-
0
Messages
-
0/0
Round
+ +
+ + +
+
+
+

Stats

+
+
0
Nodes
+
0
Edges
+
0
Messages
+
0/0
Round
+
+
+
+

Worker Logs

+
+
+
+

Event Log

+
+ + + +
SeqRoundThreadNodeEventDetails
+
-
-

Worker Logs

-
-
-
-

Event Log

- -
- - - -
SeqRoundThreadNodeEventDetails
+
+
+
+
+

Convergence Curve

+ +
+
+

Propagation Heatmap

+
+ +
+
+
+
+

Load Distribution

+ +
@@ -138,10 +199,45 @@ let replayCursor = 0; let workerLogs = {}; let lastTablePos = -1, lastWorkerPos = -1; +// ── Analytics data ─────────────────────────────────────────────── +let snapRounds = []; // unique tick numbers that have snapshots +let snapEntries = []; // snapEntries[roundIdx][nodeIdx] = entry count +let snapPeerCount = []; // snapPeerCount[roundIdx][nodeIdx] = peer count +let totalKeys = 0; // max entries seen across all snapshots +let numRounds = 0; // total rounds from trace + +let metricPushesSent = new Int32Array(0); +let metricPushesRecv = new Int32Array(0); +let metricRedundant = 0; +let metricTotalPushes = 0; +let metricNoPeers = 0; + +// cumulative push-recv per round: cumulPushRecv[roundIdx][nodeIdx] +let cumulPushRecv = []; +// round number for each event index: eventRoundIdx[evtIdx] = roundIdx into snapRounds +let eventRoundMap = []; // eventRoundMap[evtIdx] = tick + +// ── Community detection ────────────────────────────────────────── +let community = new Int32Array(0); // community[nodeIdx] = community id +let numCommunities = 0; +let communityHulls = []; // communityHulls[cid] = [[x,y], ...] +let communityColors = ['#6366f1','#22c55e','#f59e0b','#ef4444','#3b82f6','#a855f7','#ec4899','#14b8a6']; + // ── View transform ─────────────────────────────────────────────── let vx = 0, vy = 0, vs = 1; // view x, y, scale let isDragging = false, dragX = 0, dragY = 0, dragVx = 0, dragVy = 0; +// ── Tab switching ──────────────────────────────────────────────── +document.querySelectorAll('.tab-btn').forEach(btn => { + btn.addEventListener('click', () => { + document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active')); + document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); + btn.classList.add('active'); + document.getElementById('tab-' + btn.dataset.tab).classList.add('active'); + if (btn.dataset.tab === 'analytics') drawAllCharts(); + }); +}); + // ── Quadtree pool (flat Float64Array, reused across iterations) ── // Per node: [ox, oy, size, cx, cy, mass, c0, c1, c2, c3, body] const QF = 11; @@ -178,10 +274,12 @@ canvas.addEventListener('wheel', (e) => { drawGraph(); }, { passive: false }); +let clickStartX = 0, clickStartY = 0; canvas.addEventListener('mousedown', (e) => { if (e.button !== 0) return; isDragging = true; dragX = e.clientX; dragY = e.clientY; + clickStartX = e.clientX; clickStartY = e.clientY; dragVx = vx; dragVy = vy; canvas.style.cursor = 'grabbing'; }); @@ -191,8 +289,12 @@ window.addEventListener('mousemove', (e) => { vy = dragVy + (e.clientY - dragY); drawGraph(); }); -window.addEventListener('mouseup', () => { - if (isDragging) { isDragging = false; canvas.style.cursor = ''; } +window.addEventListener('mouseup', (e) => { + if (isDragging) { + const wasDrag = Math.abs(e.clientX - clickStartX) > 3 || Math.abs(e.clientY - clickStartY) > 3; + isDragging = false; canvas.style.cursor = ''; + if (!wasDrag) handleNodeClick(e); + } }); canvas.addEventListener('dblclick', () => { resetView(); drawGraph(); }); @@ -357,6 +459,452 @@ function runForceLayout(w, h) { }); } +// ── Node click → detail panel ──────────────────────────────────── +function handleNodeClick(e) { + const r = canvas.getBoundingClientRect(); + const mx = e.clientX - r.left, my = e.clientY - r.top; + const wx = (mx - vx) / vs, wy = (my - vy) / vs; + const baseR = Math.max(2, Math.min(8, 400/Math.sqrt(Math.max(N, 1)))); + const hitR = baseR * 2; + let best = -1, bestD = hitR * hitR; + for (let i = 0; i < N; i++) { + const dx = posX[i] - wx, dy = posY[i] - wy; + const d2 = dx*dx + dy*dy; + if (d2 < bestD) { bestD = d2; best = i; } + } + const panel = document.getElementById('nodeDetail'); + if (best < 0) { panel.style.display = 'none'; return; } + showNodeDetail(best); +} + +function showNodeDetail(ni) { + const panel = document.getElementById('nodeDetail'); + document.getElementById('ndName').textContent = nodeNames[ni]; + + // Find current round from replay position + let curRound = 0; + if (replayCursor > 0 && replayCursor <= allEvents.length) curRound = allEvents[replayCursor - 1].tick; + + // Key count from latest snapshot ≤ current round + let keyCount = 0, peerCount = 0; + for (let r = snapRounds.length - 1; r >= 0; r--) { + if (snapRounds[r] <= curRound) { keyCount = snapEntries[r][ni] || 0; peerCount = snapPeerCount[r][ni] || 0; break; } + } + + let html = ''; + html += '
Community' + (community[ni] !== undefined ? community[ni] : '-') + '
'; + html += '
Pushes Sent' + (metricPushesSent[ni] || 0) + '
'; + html += '
Pushes Recv' + (metricPushesRecv[ni] || 0) + '
'; + html += '
Keys' + keyCount + '/' + totalKeys + '
'; + html += '
Peers' + peerCount + '
'; + document.getElementById('ndStats').innerHTML = html; + + // Mini event log: last 20 events for this node up to cursor + const name = nodeNames[ni]; + let evHtml = '
Recent events:
'; + let count = 0; + for (let i = Math.min(replayCursor, allEvents.length) - 1; i >= 0 && count < 20; i--) { + if (allEvents[i].node === name) { + evHtml += '
' + allEvents[i].kind + (allEvents[i].detail ? ': ' + formatDetail(allEvents[i].kind, allEvents[i].detail).replace(/&[lr]arr;/g, '→') : '') + '
'; + count++; + } + } + document.getElementById('ndEvents').innerHTML = evHtml; + + panel.style.display = 'block'; +} + +document.getElementById('nodeDetailClose').addEventListener('click', () => { + document.getElementById('nodeDetail').style.display = 'none'; +}); + +// ── Community detection (BFS on visible edges) ────────────────── +function detectCommunities() { + community = new Int32Array(N).fill(-1); + numCommunities = 0; + const adj = new Array(N); + for (let i = 0; i < N; i++) adj[i] = []; + for (let i = 0; i < totalEdges; i++) { + adj[edgeSrc[i]].push(edgeDst[i]); + adj[edgeDst[i]].push(edgeSrc[i]); + } + const queue = []; + for (let i = 0; i < N; i++) { + if (community[i] >= 0) continue; + const cid = numCommunities++; + community[i] = cid; + queue.push(i); + while (queue.length > 0) { + const u = queue.pop(); + for (const v of adj[u]) { + if (community[v] < 0) { community[v] = cid; queue.push(v); } + } + } + } +} + +// ── Convex hull (Graham scan) ─────────────────────────────────── +function convexHull(points) { + if (points.length < 3) return points.slice(); + points.sort((a, b) => a[0] - b[0] || a[1] - b[1]); + const cross = (o, a, b) => (a[0]-o[0])*(b[1]-o[1]) - (a[1]-o[1])*(b[0]-o[0]); + const lower = []; + for (const p of points) { + while (lower.length >= 2 && cross(lower[lower.length-2], lower[lower.length-1], p) <= 0) lower.pop(); + lower.push(p); + } + const upper = []; + for (let i = points.length - 1; i >= 0; i--) { + const p = points[i]; + while (upper.length >= 2 && cross(upper[upper.length-2], upper[upper.length-1], p) <= 0) upper.pop(); + upper.push(p); + } + lower.pop(); upper.pop(); + return lower.concat(upper); +} + +function computeCommunityHulls() { + communityHulls = []; + for (let c = 0; c < numCommunities; c++) { + const pts = []; + for (let i = 0; i < N; i++) { + if (community[i] === c) pts.push([posX[i], posY[i]]); + } + communityHulls.push(pts.length >= 3 ? convexHull(pts) : pts); + } +} + +// ── Analytics charts ──────────────────────────────────────────── +function drawAllCharts() { + drawBadges(); + drawConvergenceChart(); + drawHeatmap(); + drawLoadHistogram(); +} + +function drawBadges() { + const grid = document.getElementById('badgeGrid'); + const hasSnaps = snapRounds.length > 0; + + // Delivery ratio + let delivery = 1.0; + if (hasSnaps) { + const last = snapEntries[snapEntries.length - 1]; + let full = 0; + for (let i = 0; i < N; i++) if (last[i] >= totalKeys && totalKeys > 0) full++; + delivery = N > 0 ? full / N : 1; + } + + // Convergence round + let convRound = hasSnaps ? -1 : 0; + if (hasSnaps && totalKeys > 0) { + for (let r = 0; r < snapRounds.length; r++) { + let allFull = true; + for (let i = 0; i < N; i++) { if (snapEntries[r][i] < totalKeys) { allFull = false; break; } } + if (allFull) { convRound = snapRounds[r]; break; } + } + } + + // Redundancy + const redundancy = metricTotalPushes > 0 ? metricRedundant / metricTotalPushes : 0; + + // Load balance CV + let mean = 0, variance = 0; + if (N > 0) { + for (let i = 0; i < N; i++) mean += metricPushesRecv[i]; + mean /= N; + for (let i = 0; i < N; i++) { const d = metricPushesRecv[i] - mean; variance += d * d; } + variance /= N; + } + const cv = mean > 0 ? Math.sqrt(variance) / mean : 0; + + // Amplification + const amp = N > 0 ? metricTotalPushes / N : 0; + + const logN = N > 1 ? Math.log2(N) : 1; + const delColor = delivery >= 0.99 ? 'green' : delivery >= 0.9 ? 'yellow' : 'red'; + const convColor = convRound < 0 ? 'red' : convRound <= 2 * logN ? 'green' : convRound <= 3 * logN ? 'yellow' : 'red'; + const redColor = redundancy < 0.2 ? 'green' : redundancy < 0.4 ? 'yellow' : 'red'; + const cvColor = cv < 0.3 ? 'green' : cv < 0.6 ? 'yellow' : 'red'; + + grid.innerHTML = + badge(delColor, (delivery * 100).toFixed(1) + '%', 'Delivery') + + badge(convColor, convRound < 0 ? 'Never' : 'Round ' + convRound, 'Convergence') + + badge(redColor, (redundancy * 100).toFixed(1) + '%', 'Redundancy') + + badge(cvColor, 'CV ' + cv.toFixed(2), 'Load Balance') + + badge('', metricTotalPushes.toLocaleString(), 'Total Pushes') + + badge('', amp.toFixed(2) + '\u00d7', 'Amplification'); +} + +function badge(color, val, label) { + return '
' + val + '
' + label + '
'; +} + +function drawConvergenceChart() { + const cv = document.getElementById('convCanvas'); + if (!snapRounds.length || totalKeys === 0) { + cv.style.display = 'none'; + const sec = document.getElementById('convSection'); + if (!sec.querySelector('.chart-empty')) { const d = document.createElement('div'); d.className = 'chart-empty'; d.textContent = 'No snapshot data'; sec.appendChild(d); } + return; + } + cv.style.display = 'block'; + const sec = document.getElementById('convSection'); + const emp = sec.querySelector('.chart-empty'); + if (emp) emp.remove(); + + const dpr = devicePixelRatio; + const w = cv.parentElement.clientWidth, h = 180; + cv.width = w * dpr; cv.height = h * dpr; + cv.style.width = w + 'px'; cv.style.height = h + 'px'; + const c = cv.getContext('2d'); + c.setTransform(dpr, 0, 0, dpr, 0, 0); + + const pad = { l: 45, r: 12, t: 12, b: 28 }; + const cw = w - pad.l - pad.r, ch = h - pad.t - pad.b; + const maxRound = snapRounds[snapRounds.length - 1] || 1; + + // Background + c.fillStyle = '#1a1d2e'; c.fillRect(0, 0, w, h); + + // Grid + c.strokeStyle = '#2a2d3a'; c.lineWidth = 1; + for (let pct = 0; pct <= 100; pct += 25) { + const y = pad.t + ch * (1 - pct / 100); + c.beginPath(); c.moveTo(pad.l, y); c.lineTo(pad.l + cw, y); c.stroke(); + } + + // Compute data points + const pts = []; + for (let r = 0; r < snapRounds.length; r++) { + let full = 0; + for (let i = 0; i < N; i++) if (snapEntries[r][i] >= totalKeys) full++; + pts.push({ round: snapRounds[r], pct: N > 0 ? full / N * 100 : 0 }); + } + + // Draw fill + c.beginPath(); + c.moveTo(pad.l, pad.t + ch); + for (const p of pts) { + const x = pad.l + (p.round / maxRound) * cw; + const y = pad.t + ch * (1 - p.pct / 100); + c.lineTo(x, y); + } + c.lineTo(pad.l + (pts[pts.length-1].round / maxRound) * cw, pad.t + ch); + c.closePath(); + c.fillStyle = 'rgba(99,102,241,0.2)'; c.fill(); + + // Draw line + c.beginPath(); + for (let i = 0; i < pts.length; i++) { + const x = pad.l + (pts[i].round / maxRound) * cw; + const y = pad.t + ch * (1 - pts[i].pct / 100); + i === 0 ? c.moveTo(x, y) : c.lineTo(x, y); + } + c.strokeStyle = '#6366f1'; c.lineWidth = 2; c.stroke(); + + // Current replay round marker + let curRound = 0; + if (replayCursor > 0 && replayCursor <= allEvents.length) curRound = allEvents[replayCursor - 1].tick; + const mx = pad.l + (curRound / maxRound) * cw; + c.setLineDash([4, 3]); c.strokeStyle = '#f59e0b'; c.lineWidth = 1; + c.beginPath(); c.moveTo(mx, pad.t); c.lineTo(mx, pad.t + ch); c.stroke(); + c.setLineDash([]); + + // Axes labels + c.fillStyle = '#6b7280'; c.font = '10px system-ui,sans-serif'; + c.textAlign = 'right'; + for (let pct = 0; pct <= 100; pct += 25) { + c.fillText(pct + '%', pad.l - 4, pad.t + ch * (1 - pct / 100) + 3); + } + c.textAlign = 'center'; + const step = Math.max(1, Math.ceil(maxRound / 8)); + for (let r = 0; r <= maxRound; r += step) { + c.fillText(r, pad.l + (r / maxRound) * cw, h - 6); + } +} + +function drawHeatmap() { + const cv = document.getElementById('heatCanvas'); + const tooltip = document.getElementById('heatTooltip'); + if (!snapRounds.length || totalKeys === 0) { + cv.style.display = 'none'; + const sec = document.getElementById('heatSection'); + if (!sec.querySelector('.chart-empty')) { const d = document.createElement('div'); d.className = 'chart-empty'; d.textContent = 'No snapshot data'; sec.appendChild(d); } + return; + } + cv.style.display = 'block'; + const sec = document.getElementById('heatSection'); + const emp = sec.querySelector('.chart-empty'); + if (emp) emp.remove(); + + const dpr = devicePixelRatio; + const w = cv.parentElement.clientWidth; + const padL = 60, padR = 8, padT = 4, padB = 24; + const cols = snapRounds.length, rows = N; + const cellW = Math.max(1, Math.floor((w - padL - padR) / Math.max(cols, 1))); + const cellH = N <= 50 ? 14 : Math.max(1, Math.min(4, Math.floor(220 / N))); + const h = padT + rows * cellH + padB; + cv.width = w * dpr; cv.height = h * dpr; + cv.style.width = w + 'px'; cv.style.height = h + 'px'; + const c = cv.getContext('2d'); + c.setTransform(dpr, 0, 0, dpr, 0, 0); + c.fillStyle = '#1a1d2e'; c.fillRect(0, 0, w, h); + + // Sort nodes by name + const sortedIdx = Array.from({length: N}, (_, i) => i); + sortedIdx.sort((a, b) => nodeNames[a].localeCompare(nodeNames[b])); + + // Draw cells + for (let ri = 0; ri < rows; ri++) { + const ni = sortedIdx[ri]; + for (let ci = 0; ci < cols; ci++) { + const frac = totalKeys > 0 ? (snapEntries[ci][ni] || 0) / totalKeys : 0; + const g = Math.round(frac * 200); + c.fillStyle = 'rgb(' + (255 - g) + ',' + (255 - Math.round(frac * 55)) + ',' + (255 - g) + ')'; + if (frac > 0) c.fillStyle = 'rgb(' + Math.round(30 + (1-frac)*225) + ',' + Math.round(80 + (1-frac)*175) + ',' + Math.round(30 + (1-frac)*225) + ')'; + else c.fillStyle = '#2a2d3a'; + c.fillRect(padL + ci * cellW, padT + ri * cellH, cellW - (cellW > 2 ? 1 : 0), cellH - (cellH > 2 ? 1 : 0)); + } + } + + // Node labels (only if space) + if (cellH >= 10) { + c.fillStyle = '#9ca3af'; c.font = '9px system-ui,sans-serif'; c.textAlign = 'right'; + for (let ri = 0; ri < rows; ri++) { + c.fillText(nodeNames[sortedIdx[ri]], padL - 3, padT + ri * cellH + cellH - 2); + } + } + + // Round labels + c.fillStyle = '#6b7280'; c.font = '9px system-ui,sans-serif'; c.textAlign = 'center'; + const labelStep = Math.max(1, Math.ceil(cols / 10)); + for (let ci = 0; ci < cols; ci += labelStep) { + c.fillText(snapRounds[ci], padL + ci * cellW + cellW / 2, h - 6); + } + + // Current round marker + let curRound = 0; + if (replayCursor > 0 && replayCursor <= allEvents.length) curRound = allEvents[replayCursor - 1].tick; + let markerCol = 0; + for (let ci = 0; ci < cols; ci++) { if (snapRounds[ci] <= curRound) markerCol = ci; } + const mx = padL + markerCol * cellW + cellW / 2; + c.setLineDash([3, 2]); c.strokeStyle = '#f59e0b'; c.lineWidth = 1; + c.beginPath(); c.moveTo(mx, padT); c.lineTo(mx, padT + rows * cellH); c.stroke(); + c.setLineDash([]); + + // Hover tooltip + cv.onmousemove = (e) => { + const rect = cv.getBoundingClientRect(); + const ex = e.clientX - rect.left, ey = e.clientY - rect.top; + const col = Math.floor((ex - padL) / cellW); + const row = Math.floor((ey - padT) / cellH); + if (col >= 0 && col < cols && row >= 0 && row < rows) { + const ni = sortedIdx[row]; + const entries = snapEntries[col][ni] || 0; + const pct = totalKeys > 0 ? Math.round(entries / totalKeys * 100) : 0; + tooltip.textContent = nodeNames[ni] + ' at round ' + snapRounds[col] + ': ' + entries + '/' + totalKeys + ' keys (' + pct + '%)'; + tooltip.style.display = 'block'; + tooltip.style.left = (ex + 12) + 'px'; tooltip.style.top = (ey - 20) + 'px'; + } else { tooltip.style.display = 'none'; } + }; + cv.onmouseleave = () => { tooltip.style.display = 'none'; }; +} + +function drawLoadHistogram() { + const cv = document.getElementById('loadCanvas'); + const dpr = devicePixelRatio; + const w = cv.parentElement.clientWidth, h = 150; + cv.width = w * dpr; cv.height = h * dpr; + cv.style.width = w + 'px'; cv.style.height = h + 'px'; + const c = cv.getContext('2d'); + c.setTransform(dpr, 0, 0, dpr, 0, 0); + c.fillStyle = '#1a1d2e'; c.fillRect(0, 0, w, h); + + if (N === 0) return; + + const pad = { l: 40, r: 8, t: 8, b: 24 }; + const cw = w - pad.l - pad.r, ch = h - pad.t - pad.b; + + // Get data up to current replay pos from cumulative + let data; + let curRound = 0; + if (replayCursor > 0 && replayCursor <= allEvents.length) curRound = allEvents[replayCursor - 1].tick; + + // Find the closest round in cumulPushRecv + let bestR = -1; + for (let r = 0; r < snapRounds.length; r++) { + if (snapRounds[r] <= curRound) bestR = r; + } + + if (bestR >= 0 && cumulPushRecv.length > bestR) { + data = cumulPushRecv[bestR]; + } else { + data = metricPushesRecv; // fallback: total + } + + // Compute stats + let maxVal = 0, mean = 0; + for (let i = 0; i < N; i++) { if (data[i] > maxVal) maxVal = data[i]; mean += data[i]; } + mean /= N; + if (maxVal === 0) maxVal = 1; + let std = 0; + for (let i = 0; i < N; i++) { const d = data[i] - mean; std += d * d; } + std = Math.sqrt(std / N); + + // Bin for large N + const useBins = N > 100; + let barData, barCount; + if (useBins) { + barCount = Math.min(50, N); + barData = new Float64Array(barCount); + const binCounts = new Int32Array(barCount); + for (let i = 0; i < N; i++) { + const bin = Math.min(barCount - 1, Math.floor(i / N * barCount)); + barData[bin] += data[i]; binCounts[bin]++; + } + for (let i = 0; i < barCount; i++) if (binCounts[i] > 0) barData[i] /= binCounts[i]; + // Recompute max + maxVal = 0; + for (let i = 0; i < barCount; i++) if (barData[i] > maxVal) maxVal = barData[i]; + if (maxVal === 0) maxVal = 1; + } else { + barCount = N; + barData = data; + } + + const barW = Math.max(1, cw / barCount - (barCount < 50 ? 1 : 0)); + + for (let i = 0; i < barCount; i++) { + const val = barData[i]; + const barH = (val / maxVal) * ch; + const dev = std > 0 ? Math.abs(val - mean) / std : 0; + // Color by deviation: green near mean, yellow moderate, red outlier + let r, g, b; + if (dev < 1) { r = 34; g = 197; b = 94; } + else if (dev < 2) { r = 245; g = 158; b = 11; } + else { r = 239; g = 68; b = 68; } + c.fillStyle = 'rgb(' + r + ',' + g + ',' + b + ')'; + c.fillRect(pad.l + i * (cw / barCount), pad.t + ch - barH, barW, barH); + } + + // Mean line + const meanY = pad.t + ch - (mean / maxVal) * ch; + c.setLineDash([4, 3]); c.strokeStyle = '#e0e0e0'; c.lineWidth = 1; + c.beginPath(); c.moveTo(pad.l, meanY); c.lineTo(pad.l + cw, meanY); c.stroke(); + c.setLineDash([]); + + // Labels + c.fillStyle = '#6b7280'; c.font = '10px system-ui,sans-serif'; + c.textAlign = 'right'; + c.fillText(maxVal, pad.l - 4, pad.t + 10); + c.fillText('0', pad.l - 4, pad.t + ch); + c.textAlign = 'left'; + c.fillText('mean: ' + mean.toFixed(1), pad.l + 4, meanY - 4); + c.textAlign = 'center'; + c.fillText(useBins ? 'nodes (binned)' : 'node index', pad.l + cw / 2, h - 4); +} + // ── Drawing ────────────────────────────────────────────────────── function drawGraph() { const r = canvas.parentElement.getBoundingClientRect(); @@ -371,20 +919,62 @@ function drawGraph() { // Adaptive sizing const baseR = Math.max(2, Math.min(8, 400/Math.sqrt(Math.max(N, 1)))); - const showLabels = vs * baseR > 6, showStroke = N <= 200; + const showStroke = N <= 200; + + // Semantic zoom levels + const showHulls = vs < 0.5 && numCommunities > 1; + const showNodes = vs >= 0.2; + const showEdges = vs >= 0.2 && !(vs < 0.5 && N > 1000); + const showLabels = vs * baseR > 6; + const thickDetail = vs > 1.5; // Flash state const hasNodeFlash = flashNode >= 0 && now - flashNodeT < 400; const hasEdgeFlash = flashSrc >= 0 && now - flashEdgeT < 600; + // ── Community hulls (zoomed out) ── + if (showHulls) { + for (let c = 0; c < numCommunities; c++) { + const hull = communityHulls[c]; + if (!hull || hull.length < 2) continue; + const color = communityColors[c % communityColors.length]; + // Expand hull slightly for padding + let cx = 0, cy = 0; + for (const p of hull) { cx += p[0]; cy += p[1]; } + cx /= hull.length; cy /= hull.length; + const pad = 20 / vs; + + ctx2d.beginPath(); + for (let i = 0; i < hull.length; i++) { + const dx = hull[i][0] - cx, dy = hull[i][1] - cy; + const d = Math.sqrt(dx*dx + dy*dy) || 1; + const px = hull[i][0] + dx/d * pad, py = hull[i][1] + dy/d * pad; + i === 0 ? ctx2d.moveTo(px, py) : ctx2d.lineTo(px, py); + } + ctx2d.closePath(); + ctx2d.fillStyle = color + '18'; ctx2d.fill(); + ctx2d.strokeStyle = color + '60'; ctx2d.lineWidth = 2/vs; ctx2d.stroke(); + + // Label + if (vs < 0.5) { + let count = 0; + for (let i = 0; i < N; i++) if (community[i] === c) count++; + ctx2d.fillStyle = color; + ctx2d.font = Math.max(12, 16/vs) + 'px system-ui,sans-serif'; + ctx2d.textAlign = 'center'; + ctx2d.fillText('Community ' + c + ' (' + count + ' nodes)', cx, cy); + } + } + } + // ── Edges ── - if (!(vs < 0.15 && visEdges > 10000)) { + if (showEdges && !(vs < 0.15 && visEdges > 10000)) { const baseAlpha = Math.min(0.25, 40/Math.sqrt(Math.max(visEdges, 1))); let flashEdgeI = -1; ctx2d.beginPath(); ctx2d.strokeStyle = 'rgba(99,102,241,' + baseAlpha + ')'; - ctx2d.lineWidth = 1/vs; + ctx2d.lineWidth = (thickDetail ? 1.5 : 1)/vs; let batched = 0; for (let i = 0; i < visEdges; i++) { @@ -408,30 +998,50 @@ function drawGraph() { } } - // ── Nodes (batched) ── - let flashNodeI = -1; - ctx2d.beginPath(); - for (let i = 0; i < N; i++) { - const px = posX[i], py = posY[i]; - if (px+baseR < v0x || px-baseR > v1x || py+baseR < v0y || py-baseR > v1y) continue; - if (hasNodeFlash && i === flashNode) { flashNodeI = i; continue; } - ctx2d.moveTo(px+baseR, py); ctx2d.arc(px, py, baseR, 0, 2*Math.PI); - } - ctx2d.fillStyle = '#6366f1'; ctx2d.fill(); - if (showStroke) { ctx2d.strokeStyle = '#4f46e5'; ctx2d.lineWidth = 1.5/vs; ctx2d.stroke(); } + // ── Nodes (community-colored) ── + if (showNodes) { + const useCommunityColor = numCommunities > 1; + let flashNodeI = -1; - if (flashNodeI >= 0) { - const t = 1-(now-flashNodeT)/400, rad = baseR+6*t; - ctx2d.beginPath(); - ctx2d.arc(posX[flashNodeI], posY[flashNodeI], rad, 0, 2*Math.PI); - ctx2d.fillStyle = '#818cf8'; ctx2d.fill(); - if (showStroke) { ctx2d.strokeStyle = '#4f46e5'; ctx2d.lineWidth = 1.5/vs; ctx2d.stroke(); } + if (useCommunityColor) { + // Batch by community color + for (let c = 0; c < numCommunities; c++) { + ctx2d.beginPath(); + for (let i = 0; i < N; i++) { + if (community[i] !== c) continue; + const px = posX[i], py = posY[i]; + if (px+baseR < v0x || px-baseR > v1x || py+baseR < v0y || py-baseR > v1y) continue; + if (hasNodeFlash && i === flashNode) { flashNodeI = i; continue; } + ctx2d.moveTo(px+baseR, py); ctx2d.arc(px, py, baseR, 0, 2*Math.PI); + } + ctx2d.fillStyle = communityColors[c % communityColors.length]; ctx2d.fill(); + if (showStroke) { ctx2d.strokeStyle = '#1a1d2e'; ctx2d.lineWidth = 1/vs; ctx2d.stroke(); } + } + } else { + ctx2d.beginPath(); + for (let i = 0; i < N; i++) { + const px = posX[i], py = posY[i]; + if (px+baseR < v0x || px-baseR > v1x || py+baseR < v0y || py-baseR > v1y) continue; + if (hasNodeFlash && i === flashNode) { flashNodeI = i; continue; } + ctx2d.moveTo(px+baseR, py); ctx2d.arc(px, py, baseR, 0, 2*Math.PI); + } + ctx2d.fillStyle = '#6366f1'; ctx2d.fill(); + if (showStroke) { ctx2d.strokeStyle = '#4f46e5'; ctx2d.lineWidth = 1.5/vs; ctx2d.stroke(); } + } + + if (flashNodeI >= 0) { + const t = 1-(now-flashNodeT)/400, rad = baseR+6*t; + ctx2d.beginPath(); + ctx2d.arc(posX[flashNodeI], posY[flashNodeI], rad, 0, 2*Math.PI); + ctx2d.fillStyle = '#818cf8'; ctx2d.fill(); + if (showStroke) { ctx2d.strokeStyle = '#4f46e5'; ctx2d.lineWidth = 1.5/vs; ctx2d.stroke(); } + } } // ── Labels ── if (showLabels) { ctx2d.fillStyle = '#c7d2fe'; - ctx2d.font = Math.max(8, Math.min(11, 11/vs)) + 'px system-ui,sans-serif'; + ctx2d.font = Math.max(8, Math.min(thickDetail ? 13 : 11, (thickDetail ? 13 : 11)/vs)) + 'px system-ui,sans-serif'; ctx2d.textAlign = 'center'; for (let i = 0; i < N; i++) { const px = posX[i], py = posY[i]; @@ -595,6 +1205,12 @@ function replayToImpl(pos) { document.getElementById('replaySlider').value = pos; document.getElementById('replayPos').textContent = replayCursor + '/' + allEvents.length; drawGraph(); + // Update analytics charts if visible + if (document.getElementById('tab-analytics').classList.contains('active')) { + drawConvergenceChart(); + drawHeatmap(); + drawLoadHistogram(); + } } // ── Trace loading ──────────────────────────────────────────────── @@ -609,9 +1225,16 @@ function resetState() { allEvents = []; replayCursor = 0; workerLogs = {}; lastTablePos = -1; lastWorkerPos = -1; vx = 0; vy = 0; vs = 1; + snapRounds = []; snapEntries = []; snapPeerCount = []; totalKeys = 0; numRounds = 0; + metricPushesSent = new Int32Array(0); metricPushesRecv = new Int32Array(0); + metricRedundant = 0; metricTotalPushes = 0; metricNoPeers = 0; + cumulPushRecv = []; eventRoundMap = []; + community = new Int32Array(0); numCommunities = 0; communityHulls = []; document.getElementById('eventTableBody').textContent = ''; document.getElementById('workerColumns').innerHTML = ''; document.getElementById('layoutProgress').style.display = 'none'; + document.getElementById('nodeDetail').style.display = 'none'; + document.getElementById('badgeGrid').innerHTML = ''; } async function loadTrace(file) { @@ -643,12 +1266,26 @@ async function loadTrace(file) { if (!edgeKeys.has(key)) { edgeKeys.add(key); tempEdges.push(si, di, -1); } } - // Build events + // Build events + extract snapshots let seq = 0; allEvents = []; + numRounds = trace.num_rounds || 0; + + // First pass: collect snapshots grouped by tick + const snapByTick = new Map(); // tick -> Map(nodeIdx -> {entries, peer_count}) for (const ev of trace.events) { const isObj = typeof ev.kind === 'object'; - if (isObj && 'StateSnapshot' in ev.kind) continue; + if (isObj && 'StateSnapshot' in ev.kind) { + const snap = ev.kind.StateSnapshot.snapshot || ev.kind.StateSnapshot; + const ni = nodeIdx.get(ev.node_name); + if (ni === undefined) continue; + const entriesCount = snap.entries ? Object.keys(snap.entries).length : 0; + const peerCount = snap.peer_count || 0; + if (!snapByTick.has(ev.tick)) snapByTick.set(ev.tick, new Map()); + snapByTick.get(ev.tick).set(ni, { entries: entriesCount, peer_count: peerCount }); + if (entriesCount > totalKeys) totalKeys = entriesCount; + continue; + } if (!isObj && ev.kind === 'StateSnapshot') continue; let kind, detail; if (isObj) { for (kind in ev.kind) break; detail = ev.kind[kind] || null; } @@ -656,6 +1293,65 @@ async function loadTrace(file) { allEvents.push({ seq: seq++, tick: ev.tick, node: ev.node_name, thread: ev.thread_name, kind, detail }); } + // Build snapshot arrays sorted by round + snapRounds = Array.from(snapByTick.keys()).sort((a, b) => a - b); + snapEntries = []; snapPeerCount = []; + for (const tick of snapRounds) { + const eArr = new Int32Array(N); + const pArr = new Int32Array(N); + const m = snapByTick.get(tick); + for (const [ni, d] of m) { eArr[ni] = d.entries; pArr[ni] = d.peer_count; } + snapEntries.push(eArr); + snapPeerCount.push(pArr); + } + + // Compute per-node metrics from events + metricPushesSent = new Int32Array(N); + metricPushesRecv = new Int32Array(N); + metricRedundant = 0; metricTotalPushes = 0; metricNoPeers = 0; + + // Also build cumulative push-recv per round + const roundSet = new Set(snapRounds); + cumulPushRecv = []; + let runningRecv = new Int32Array(N); + + for (let i = 0; i < allEvents.length; i++) { + const ev = allEvents[i]; + const ni = nodeIdx.get(ev.node); + if (ni === undefined) continue; + if (ev.kind === 'GossipRoundStarted') { + metricPushesSent[ni]++; + metricTotalPushes++; + } + if (ev.kind === 'PushReceived') { + metricPushesRecv[ni]++; + runningRecv[ni]++; + if (ev.detail && ev.detail.keys_updated === 0) metricRedundant++; + } + if (ev.kind === 'GossipRoundNoPeers') { metricNoPeers++; } + } + + // Build cumulative recv snapshots aligned to snap rounds + // Re-scan to build per-round cumulative + if (snapRounds.length > 0) { + const cumRecv = new Int32Array(N); + let sri = 0; + for (let i = 0; i < allEvents.length && sri < snapRounds.length; i++) { + const ev = allEvents[i]; + if (ev.kind === 'PushReceived') { + const ni = nodeIdx.get(ev.node); + if (ni !== undefined) cumRecv[ni]++; + } + // When we pass a snapshot round boundary, save + while (sri < snapRounds.length && ev.tick >= snapRounds[sri]) { + cumulPushRecv.push(new Int32Array(cumRecv)); + sri++; + } + } + // Fill remaining + while (sri < snapRounds.length) { cumulPushRecv.push(new Int32Array(cumRecv)); sri++; } + } + // Index PeerAdded edges for (let i = 0; i < allEvents.length; i++) { const ev = allEvents[i]; @@ -694,9 +1390,14 @@ async function loadTrace(file) { const rect = canvas.parentElement.getBoundingClientRect(); await runForceLayout(rect.width, rect.height); + // Community detection + hulls + detectCommunities(); + computeCommunityHulls(); + dot.className = 'status-dot ready'; statusText.textContent = trace.name + ' (' + allEvents.length + ' events)'; drawGraph(); + drawAllCharts(); // Replay controls document.getElementById('replayControls').style.display = 'flex'; -- 2.45.2