fix: gossip simulation dashboard
This commit is contained in:
parent
1a64f421bf
commit
9325573f4d
17 changed files with 615 additions and 991 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -216,7 +216,6 @@ version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"swactor",
|
|
||||||
"swactor-gossip",
|
"swactor-gossip",
|
||||||
"tiny_http",
|
"tiny_http",
|
||||||
"toml",
|
"toml",
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
swactor = { path = "../..", features = ["serde"] }
|
|
||||||
swactor-gossip = { path = "../swactor-gossip" }
|
swactor-gossip = { path = "../swactor-gossip" }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|
|
||||||
126
crates/gossip-dashboard/README.md
Normal file
126
crates/gossip-dashboard/README.md
Normal file
|
|
@ -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 <out-dir> # all bundled configs -> <out-dir>/
|
||||||
|
generate_traces <out-dir> <config.toml> [more.toml …] # specific configs -> <out-dir>/
|
||||||
|
```
|
||||||
|
|
||||||
|
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 <trace-dir> [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).
|
||||||
11
crates/gossip-dashboard/examples/configs/chain_8.toml
Normal file
11
crates/gossip-dashboard/examples/configs/chain_8.toml
Normal file
|
|
@ -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"
|
||||||
12
crates/gossip-dashboard/examples/configs/full_mesh_6.toml
Normal file
12
crates/gossip-dashboard/examples/configs/full_mesh_6.toml
Normal file
|
|
@ -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"
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
name = "Partitioned-8 Heal"
|
name = "Partition & Heal (8 nodes)"
|
||||||
topology = "partitioned"
|
topology = "partitioned"
|
||||||
num_nodes = 8
|
num_nodes = 8
|
||||||
num_rounds = 20
|
num_rounds = 20
|
||||||
ticks_per_round = 5
|
ticks_per_round = 5
|
||||||
num_threads = 2
|
num_threads = 1
|
||||||
heal_after_round = 10
|
heal_after_round = 10
|
||||||
port = 8080
|
|
||||||
|
|
||||||
[initial_data]
|
[initial_data]
|
||||||
color = "blue"
|
color = "blue"
|
||||||
11
crates/gossip-dashboard/examples/configs/ring_10.toml
Normal file
11
crates/gossip-dashboard/examples/configs/ring_10.toml
Normal file
|
|
@ -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"
|
||||||
11
crates/gossip-dashboard/examples/configs/star_7.toml
Normal file
11
crates/gossip-dashboard/examples/configs/star_7.toml
Normal file
|
|
@ -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"
|
||||||
|
|
@ -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<String> = 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}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
69
crates/gossip-dashboard/examples/generate_traces.rs
Normal file
69
crates/gossip-dashboard/examples/generate_traces.rs
Normal file
|
|
@ -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<String> = 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 <out-dir> → custom dir + all bundled configs
|
||||||
|
2 if !args[1].ends_with(".toml") => (args[1].clone(), collect_configs(CONFIGS_DIR)),
|
||||||
|
// generate_traces <out-dir> <config.toml ...>
|
||||||
|
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 <out-dir> # all configs -> out-dir/");
|
||||||
|
eprintln!(" generate_traces <out-dir> <config.toml> [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<PathBuf> {
|
||||||
|
let mut paths: Vec<PathBuf> = 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
|
||||||
|
}
|
||||||
|
|
@ -1,19 +1,15 @@
|
||||||
use gossip_dashboard::{load_trace, serve_replay};
|
use gossip_dashboard::serve_dashboard;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let args: Vec<String> = std::env::args().collect();
|
let args: Vec<String> = std::env::args().collect();
|
||||||
let path = args
|
let trace_dir = args
|
||||||
.get(1)
|
.get(1)
|
||||||
.expect("Usage: replay <trace.json>");
|
.expect("Usage: replay <trace-dir> [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!(
|
serve_dashboard(trace_dir, port);
|
||||||
"Loaded trace '{}': {} nodes, {} events",
|
|
||||||
trace.name,
|
|
||||||
trace.node_names.len(),
|
|
||||||
trace.events.len()
|
|
||||||
);
|
|
||||||
|
|
||||||
serve_replay(&trace, 8081);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,6 @@ use std::io;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use swactor_gossip::sim::{SimConfig, Topology};
|
use swactor_gossip::sim::{SimConfig, Topology};
|
||||||
|
|
||||||
use crate::server::DashboardConfig;
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct SimFileConfig {
|
pub struct SimFileConfig {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
|
@ -16,7 +14,6 @@ pub struct SimFileConfig {
|
||||||
pub ticks_per_round: usize,
|
pub ticks_per_round: usize,
|
||||||
pub num_threads: usize,
|
pub num_threads: usize,
|
||||||
pub heal_after_round: Option<usize>,
|
pub heal_after_round: Option<usize>,
|
||||||
pub port: Option<u16>,
|
|
||||||
pub initial_data: Option<BTreeMap<String, String>>,
|
pub initial_data: Option<BTreeMap<String, String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -26,7 +23,7 @@ impl SimFileConfig {
|
||||||
toml::from_str(&contents).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
|
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() {
|
let topology = match self.topology.to_lowercase().as_str() {
|
||||||
"ring" => Topology::Ring,
|
"ring" => Topology::Ring,
|
||||||
"star" => Topology::Star,
|
"star" => Topology::Star,
|
||||||
|
|
@ -43,7 +40,7 @@ impl SimFileConfig {
|
||||||
.map(|(k, v)| (k, v.into_bytes()))
|
.map(|(k, v)| (k, v.into_bytes()))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let sim = SimConfig {
|
SimConfig {
|
||||||
name: self.name,
|
name: self.name,
|
||||||
topology,
|
topology,
|
||||||
num_nodes: self.num_nodes,
|
num_nodes: self.num_nodes,
|
||||||
|
|
@ -52,12 +49,6 @@ impl SimFileConfig {
|
||||||
ticks_per_round: self.ticks_per_round,
|
ticks_per_round: self.ticks_per_round,
|
||||||
heal_after_round: self.heal_after_round,
|
heal_after_round: self.heal_after_round,
|
||||||
num_threads: self.num_threads,
|
num_threads: self.num_threads,
|
||||||
};
|
}
|
||||||
|
|
||||||
let dash = DashboardConfig {
|
|
||||||
port: self.port.unwrap_or(8080),
|
|
||||||
};
|
|
||||||
|
|
||||||
(sim, dash)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,24 @@ pub const DASHBOARD_HTML: &str = r##"<!DOCTYPE html>
|
||||||
padding: 12px 20px; background: #161822; border-bottom: 1px solid #2a2d3a;
|
padding: 12px 20px; background: #161822; border-bottom: 1px solid #2a2d3a;
|
||||||
}
|
}
|
||||||
.header h1 { font-size: 18px; font-weight: 600; color: #c0c6d4; }
|
.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 {
|
.status-badge {
|
||||||
display: flex; align-items: center; gap: 6px; font-size: 13px; color: #9ca3af;
|
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 { width: 8px; height: 8px; border-radius: 50%; background: #f59e0b; }
|
||||||
.status-dot.done { background: #22c55e; }
|
.status-dot.ready { background: #22c55e; }
|
||||||
.status-dot.replay { background: #f59e0b; }
|
.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); }
|
.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##"<!DOCTYPE html>
|
||||||
border-radius: 4px; padding: 2px 6px; font-size: 12px; text-align: right;
|
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; }
|
.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); } }
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<h1>Gossip Simulation Dashboard</h1>
|
<h1>Gossip Dashboard</h1>
|
||||||
|
<select id="traceSelect" class="trace-select" disabled>
|
||||||
|
<option value="">Loading traces...</option>
|
||||||
|
</select>
|
||||||
<div class="status-badge">
|
<div class="status-badge">
|
||||||
<div class="status-dot" id="statusDot"></div>
|
<div class="status-dot loading" id="statusDot"></div>
|
||||||
<span id="statusText">Connecting...</span>
|
<span id="statusText">Loading traces...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -141,23 +153,23 @@ pub const DASHBOARD_HTML: &str = r##"<!DOCTYPE html>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const MODE = "__DASHBOARD_MODE__"; // replaced by server: "live" or "replay"
|
|
||||||
const MAX_TABLE_ROWS = 2000;
|
const MAX_TABLE_ROWS = 2000;
|
||||||
|
|
||||||
// ── State ──────────────────────────────────────────────────────────
|
// ── State ──────────────────────────────────────────────────────────
|
||||||
let nodes = [];
|
let nodes = [];
|
||||||
let edges = [];
|
let edges = [];
|
||||||
|
let initialEdgeSet = new Set(); // "a->b" keys from topology_edges
|
||||||
|
let edgeAppearance = {}; // "a->b" -> event index (-1 = initial)
|
||||||
let nodePositions = {};
|
let nodePositions = {};
|
||||||
let allEvents = [];
|
let allEvents = [];
|
||||||
let replayCursor = 0;
|
let replayCursor = 0;
|
||||||
let workerLogs = {};
|
let workerLogs = {};
|
||||||
let isDone = false;
|
|
||||||
|
|
||||||
// ── Canvas / Graph ─────────────────────────────────────────────────
|
// ── Canvas / Graph ─────────────────────────────────────────────────
|
||||||
const canvas = document.getElementById('graphCanvas');
|
const canvas = document.getElementById('graphCanvas');
|
||||||
const ctx2d = canvas.getContext('2d');
|
const ctx2d = canvas.getContext('2d');
|
||||||
let nodeFlash = {}; // nodeName -> timestamp
|
let nodeFlash = {};
|
||||||
let edgeFlash = {}; // "a->b" -> timestamp
|
let edgeFlash = {};
|
||||||
|
|
||||||
function resizeCanvas() {
|
function resizeCanvas() {
|
||||||
const rect = canvas.parentElement.getBoundingClientRect();
|
const rect = canvas.parentElement.getBoundingClientRect();
|
||||||
|
|
@ -178,7 +190,6 @@ function initPositions() {
|
||||||
const angle = (2 * Math.PI * i) / n - Math.PI / 2;
|
const angle = (2 * Math.PI * i) / n - Math.PI / 2;
|
||||||
nodePositions[node.name] = { x: cx + r * Math.cos(angle), y: cy + r * Math.sin(angle) };
|
nodePositions[node.name] = { x: cx + r * Math.cos(angle), y: cy + r * Math.sin(angle) };
|
||||||
});
|
});
|
||||||
// Run force simulation
|
|
||||||
runForceLayout(rect.width, rect.height);
|
runForceLayout(rect.width, rect.height);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -217,10 +228,8 @@ function runForceLayout(w, h) {
|
||||||
|
|
||||||
// Gravity toward center
|
// Gravity toward center
|
||||||
nodes.forEach(n => {
|
nodes.forEach(n => {
|
||||||
let dx = pos[n.name].x - cx, dy = pos[n.name].y - cy;
|
disp[n.name].x -= (pos[n.name].x - cx) * 0.01;
|
||||||
let dist = Math.sqrt(dx * dx + dy * dy) || 0.01;
|
disp[n.name].y -= (pos[n.name].y - cy) * 0.01;
|
||||||
disp[n.name].x -= dx * 0.01;
|
|
||||||
disp[n.name].y -= dy * 0.01;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Apply with damping
|
// Apply with damping
|
||||||
|
|
@ -231,7 +240,6 @@ function runForceLayout(w, h) {
|
||||||
let cap = Math.min(dist, 10 * temp);
|
let cap = Math.min(dist, 10 * temp);
|
||||||
pos[n.name].x += (d.x / dist) * cap;
|
pos[n.name].x += (d.x / dist) * cap;
|
||||||
pos[n.name].y += (d.y / dist) * cap;
|
pos[n.name].y += (d.y / dist) * cap;
|
||||||
// Keep within bounds
|
|
||||||
pos[n.name].x = Math.max(40, Math.min(w - 40, pos[n.name].x));
|
pos[n.name].x = Math.max(40, Math.min(w - 40, pos[n.name].x));
|
||||||
pos[n.name].y = Math.max(40, Math.min(h - 40, pos[n.name].y));
|
pos[n.name].y = Math.max(40, Math.min(h - 40, pos[n.name].y));
|
||||||
});
|
});
|
||||||
|
|
@ -312,42 +320,20 @@ function makeRow(ev) {
|
||||||
return tr;
|
return tr;
|
||||||
}
|
}
|
||||||
|
|
||||||
function addEventToTable(ev) {
|
|
||||||
const tbody = document.getElementById('eventTableBody');
|
|
||||||
if (tbody.children.length >= MAX_TABLE_ROWS) {
|
|
||||||
tbody.removeChild(tbody.firstChild);
|
|
||||||
}
|
|
||||||
tbody.appendChild(makeRow(ev));
|
|
||||||
|
|
||||||
const wrap = document.getElementById('eventTableWrap');
|
|
||||||
wrap.scrollTop = wrap.scrollHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDetail(kind, detail) {
|
function formatDetail(kind, detail) {
|
||||||
if (!detail) return '';
|
if (!detail) return '';
|
||||||
switch (kind) {
|
switch (kind) {
|
||||||
case 'GossipRoundStarted': return '→ ' + detail.target;
|
case 'GossipRoundStarted': return '→ ' + (detail.target_name || detail.target);
|
||||||
case 'PushReceived': return '← ' + detail.from + ' (' + detail.keys_updated + ' keys)';
|
case 'PushReceived': return '← ' + (detail.from_name || detail.from) + ' (' + detail.keys_updated + ' keys)';
|
||||||
case 'LocalSet': return 'key=' + detail.key;
|
case 'LocalSet': return 'key=' + detail.key;
|
||||||
case 'PeerAdded': return '+ ' + detail.peer;
|
case 'PeerAdded': return '+ ' + (detail.peer_name || detail.peer);
|
||||||
case 'PeerRemoved': return '- ' + detail.peer;
|
case 'PeerRemoved': return '- ' + (detail.peer_name || detail.peer);
|
||||||
case 'QueryReceived': return 'key=' + detail.key;
|
case 'QueryReceived': return 'key=' + detail.key;
|
||||||
case 'StateSnapshot': return detail.entries + ' entries, ' + detail.peer_count + ' peers';
|
case 'StateSnapshot': return detail.entries + ' entries, ' + detail.peer_count + ' peers';
|
||||||
default: return JSON.stringify(detail);
|
default: return JSON.stringify(detail);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function addWorkerEntry(thread, text) {
|
|
||||||
if (!thread) return;
|
|
||||||
if (!workerLogs[thread]) {
|
|
||||||
workerLogs[thread] = [];
|
|
||||||
rebuildWorkerColumns();
|
|
||||||
}
|
|
||||||
workerLogs[thread].push(text);
|
|
||||||
if (workerLogs[thread].length > 50) workerLogs[thread].shift();
|
|
||||||
updateWorkerColumn(thread);
|
|
||||||
}
|
|
||||||
|
|
||||||
function rebuildWorkerColumns() {
|
function rebuildWorkerColumns() {
|
||||||
const container = document.getElementById('workerColumns');
|
const container = document.getElementById('workerColumns');
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
|
|
@ -364,7 +350,6 @@ function updateWorkerColumn(thread) {
|
||||||
const col = document.getElementById('worker-' + thread);
|
const col = document.getElementById('worker-' + thread);
|
||||||
if (!col) return;
|
if (!col) return;
|
||||||
const entries = workerLogs[thread];
|
const entries = workerLogs[thread];
|
||||||
// Keep header + entries
|
|
||||||
let html = '<div class="worker-col-header">' + thread + '</div>';
|
let html = '<div class="worker-col-header">' + thread + '</div>';
|
||||||
for (const e of entries) {
|
for (const e of entries) {
|
||||||
html += '<div class="worker-entry">' + e + '</div>';
|
html += '<div class="worker-entry">' + e + '</div>';
|
||||||
|
|
@ -373,82 +358,136 @@ function updateWorkerColumn(thread) {
|
||||||
col.scrollTop = col.scrollHeight;
|
col.scrollTop = col.scrollHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
function processEvent(ev) {
|
// ── Replay engine ──────────────────────────────────────────────────
|
||||||
addEventToTable(ev);
|
let replayRafId = 0;
|
||||||
|
let playTimer = null;
|
||||||
|
|
||||||
// Flash node
|
function stopPlayback() {
|
||||||
nodeFlash[ev.node] = performance.now();
|
if (playTimer !== null) {
|
||||||
|
clearInterval(playTimer);
|
||||||
// Flash edges on Push
|
playTimer = null;
|
||||||
if (ev.kind === 'GossipRoundStarted' && ev.detail && ev.detail.target) {
|
document.getElementById('btnPlay').innerHTML = '▶';
|
||||||
edgeFlash[ev.node + '->' + ev.detail.target] = performance.now();
|
document.getElementById('btnPlay').title = 'Play';
|
||||||
edgeFlash[ev.detail.target + '->' + ev.node] = performance.now();
|
|
||||||
}
|
|
||||||
if (ev.kind === 'PushReceived' && ev.detail && ev.detail.from) {
|
|
||||||
edgeFlash[ev.detail.from + '->' + ev.node] = performance.now();
|
|
||||||
edgeFlash[ev.node + '->' + ev.detail.from] = performance.now();
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Worker log
|
function startPlayback() {
|
||||||
|
stopPlayback();
|
||||||
|
if (replayCursor >= allEvents.length) return;
|
||||||
|
const speed = parseInt(document.getElementById('speedInput').value) || 100;
|
||||||
|
document.getElementById('btnPlay').innerHTML = '⏸';
|
||||||
|
document.getElementById('btnPlay').title = 'Pause';
|
||||||
|
playTimer = setInterval(() => {
|
||||||
|
if (replayCursor >= allEvents.length) {
|
||||||
|
stopPlayback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
replayTo(replayCursor + 1);
|
||||||
|
}, speed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function replayTo(pos) {
|
||||||
|
replayCursor = pos;
|
||||||
|
if (replayRafId) return;
|
||||||
|
replayRafId = requestAnimationFrame(() => {
|
||||||
|
replayRafId = 0;
|
||||||
|
replayToImpl(replayCursor);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function replayToImpl(pos) {
|
||||||
|
nodeFlash = {};
|
||||||
|
edgeFlash = {};
|
||||||
|
|
||||||
|
// Recompute visible edges based on replay position
|
||||||
|
edges = Object.entries(edgeAppearance)
|
||||||
|
.filter(([_, idx]) => idx < pos)
|
||||||
|
.map(([key]) => key.split('->'));
|
||||||
|
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
const start = Math.max(0, pos - MAX_TABLE_ROWS);
|
||||||
|
for (let i = start; i < pos && i < allEvents.length; i++) {
|
||||||
|
frag.appendChild(makeRow(allEvents[i]));
|
||||||
|
}
|
||||||
|
const tbody = document.getElementById('eventTableBody');
|
||||||
|
tbody.textContent = '';
|
||||||
|
tbody.appendChild(frag);
|
||||||
|
|
||||||
|
const wrap = document.getElementById('eventTableWrap');
|
||||||
|
wrap.scrollTop = wrap.scrollHeight;
|
||||||
|
|
||||||
|
// Rebuild worker logs
|
||||||
|
workerLogs = {};
|
||||||
|
const workerStart = Math.max(0, pos - 200);
|
||||||
|
for (let i = workerStart; i < pos && i < allEvents.length; i++) {
|
||||||
|
const ev = allEvents[i];
|
||||||
|
if (!ev.thread) continue;
|
||||||
|
if (!workerLogs[ev.thread]) workerLogs[ev.thread] = [];
|
||||||
let text = ev.node + ': ' + ev.kind;
|
let text = ev.node + ': ' + ev.kind;
|
||||||
if (ev.kind === 'GossipRoundStarted') text += ' -> ' + ev.detail.target;
|
if (ev.kind === 'GossipRoundStarted' && ev.detail) text += ' -> ' + (ev.detail.target_name || ev.detail.target);
|
||||||
if (ev.kind === 'PushReceived') text += ' <- ' + ev.detail.from;
|
if (ev.kind === 'PushReceived' && ev.detail) text += ' <- ' + (ev.detail.from_name || ev.detail.from);
|
||||||
addWorkerEntry(ev.thread, text);
|
workerLogs[ev.thread].push(text);
|
||||||
|
if (workerLogs[ev.thread].length > 50) workerLogs[ev.thread].shift();
|
||||||
requestAnimationFrame(drawGraph);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Live mode (SSE) ────────────────────────────────────────────────
|
|
||||||
function startLive() {
|
|
||||||
const dot = document.getElementById('statusDot');
|
|
||||||
const statusText = document.getElementById('statusText');
|
|
||||||
|
|
||||||
const es = new EventSource('/events');
|
|
||||||
|
|
||||||
es.addEventListener('init', (e) => {
|
|
||||||
const data = JSON.parse(e.data);
|
|
||||||
nodes = data.nodes;
|
|
||||||
edges = data.edges;
|
|
||||||
statusText.textContent = 'Live: ' + data.name;
|
|
||||||
dot.className = 'status-dot';
|
|
||||||
resizeCanvas();
|
|
||||||
initPositions();
|
|
||||||
drawGraph();
|
|
||||||
});
|
|
||||||
|
|
||||||
es.addEventListener('gossip', (e) => {
|
|
||||||
const ev = JSON.parse(e.data);
|
|
||||||
allEvents.push(ev);
|
|
||||||
processEvent(ev);
|
|
||||||
});
|
|
||||||
|
|
||||||
es.addEventListener('stats', (e) => {
|
|
||||||
const data = JSON.parse(e.data);
|
|
||||||
updateStats(data);
|
|
||||||
});
|
|
||||||
|
|
||||||
es.addEventListener('done', (e) => {
|
|
||||||
isDone = true;
|
|
||||||
dot.className = 'status-dot done';
|
|
||||||
statusText.textContent += ' (done)';
|
|
||||||
es.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
es.onerror = () => {
|
|
||||||
if (!isDone) {
|
|
||||||
statusText.textContent = 'Disconnected';
|
|
||||||
dot.style.background = '#ef4444';
|
|
||||||
}
|
}
|
||||||
};
|
rebuildWorkerColumns();
|
||||||
|
for (const thread of Object.keys(workerLogs)) {
|
||||||
|
updateWorkerColumn(thread);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flash the last event
|
||||||
|
if (pos > 0 && pos <= allEvents.length) {
|
||||||
|
const ev = allEvents[pos - 1];
|
||||||
|
nodeFlash[ev.node] = performance.now();
|
||||||
|
if (ev.kind === 'GossipRoundStarted' && ev.detail && (ev.detail.target_name || ev.detail.target)) {
|
||||||
|
const t = ev.detail.target_name || ev.detail.target;
|
||||||
|
edgeFlash[ev.node + '->' + t] = performance.now();
|
||||||
|
}
|
||||||
|
if (ev.kind === 'PushReceived' && ev.detail && (ev.detail.from_name || ev.detail.from)) {
|
||||||
|
const f = ev.detail.from_name || ev.detail.from;
|
||||||
|
edgeFlash[f + '->' + ev.node] = performance.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('replaySlider').value = pos;
|
||||||
|
updateReplayPos();
|
||||||
|
drawGraph();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Replay mode ────────────────────────────────────────────────────
|
function updateReplayPos() {
|
||||||
async function startReplay() {
|
document.getElementById('replayPos').textContent = replayCursor + '/' + allEvents.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Trace loading ──────────────────────────────────────────────────
|
||||||
|
function resetState() {
|
||||||
|
stopPlayback();
|
||||||
|
nodes = [];
|
||||||
|
edges = [];
|
||||||
|
initialEdgeSet = new Set();
|
||||||
|
edgeAppearance = {};
|
||||||
|
nodePositions = {};
|
||||||
|
allEvents = [];
|
||||||
|
replayCursor = 0;
|
||||||
|
workerLogs = {};
|
||||||
|
nodeFlash = {};
|
||||||
|
edgeFlash = {};
|
||||||
|
document.getElementById('eventTableBody').textContent = '';
|
||||||
|
document.getElementById('workerColumns').innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTrace(file) {
|
||||||
const dot = document.getElementById('statusDot');
|
const dot = document.getElementById('statusDot');
|
||||||
const statusText = document.getElementById('statusText');
|
const statusText = document.getElementById('statusText');
|
||||||
dot.className = 'status-dot replay';
|
dot.className = 'status-dot loading';
|
||||||
|
statusText.textContent = 'Loading...';
|
||||||
|
|
||||||
const resp = await fetch('/trace.json');
|
resetState();
|
||||||
|
|
||||||
|
const resp = await fetch('/trace.json?file=' + encodeURIComponent(file));
|
||||||
|
if (!resp.ok) {
|
||||||
|
dot.className = 'status-dot error';
|
||||||
|
statusText.textContent = 'Failed to load trace';
|
||||||
|
return;
|
||||||
|
}
|
||||||
const trace = await resp.json();
|
const trace = await resp.json();
|
||||||
|
|
||||||
nodes = trace.node_names.map((name, i) => ({
|
nodes = trace.node_names.map((name, i) => ({
|
||||||
|
|
@ -457,10 +496,16 @@ async function startReplay() {
|
||||||
}));
|
}));
|
||||||
edges = trace.topology_edges.map(e => [e[0], e[1]]);
|
edges = trace.topology_edges.map(e => [e[0], e[1]]);
|
||||||
|
|
||||||
|
// Precompute edge timeline: initial edges are always visible (-1),
|
||||||
|
// dynamically added edges (from PeerAdded) appear at their event index.
|
||||||
|
initialEdgeSet = new Set(edges.map(([a, b]) => a + '->' + b));
|
||||||
|
edgeAppearance = {};
|
||||||
|
edges.forEach(([a, b]) => { edgeAppearance[a + '->' + b] = -1; });
|
||||||
|
|
||||||
// Build events list (filter out StateSnapshot for display)
|
// Build events list (filter out StateSnapshot for display)
|
||||||
let seq = 0;
|
let seq = 0;
|
||||||
allEvents = trace.events
|
allEvents = trace.events
|
||||||
.filter(ev => ev.kind !== 'StateSnapshot')
|
.filter(ev => typeof ev.kind === 'object' ? !('StateSnapshot' in ev.kind) : ev.kind !== 'StateSnapshot')
|
||||||
.map(ev => {
|
.map(ev => {
|
||||||
const kind = typeof ev.kind === 'string' ? ev.kind : Object.keys(ev.kind)[0];
|
const kind = typeof ev.kind === 'string' ? ev.kind : Object.keys(ev.kind)[0];
|
||||||
const detail = typeof ev.kind === 'string' ? {} : ev.kind[kind] || {};
|
const detail = typeof ev.kind === 'string' ? {} : ev.kind[kind] || {};
|
||||||
|
|
@ -474,10 +519,24 @@ async function startReplay() {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
statusText.textContent = 'Replay: ' + trace.name + ' (' + allEvents.length + ' events)';
|
// Index PeerAdded events that introduce new edges (e.g. partition heal)
|
||||||
|
allEvents.forEach((ev, i) => {
|
||||||
|
if (ev.kind === 'PeerAdded' && ev.detail) {
|
||||||
|
const peer = ev.detail.peer_name || ev.detail.peer;
|
||||||
|
if (peer) {
|
||||||
|
const key = ev.node + '->' + peer;
|
||||||
|
if (!(key in edgeAppearance)) {
|
||||||
|
edgeAppearance[key] = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
dot.className = 'status-dot ready';
|
||||||
|
statusText.textContent = trace.name + ' (' + allEvents.length + ' events)';
|
||||||
updateStats({
|
updateStats({
|
||||||
total_nodes: trace.node_names.length,
|
total_nodes: trace.node_names.length,
|
||||||
total_edges: trace.topology_edges.length,
|
total_edges: Object.keys(edgeAppearance).length,
|
||||||
total_messages: allEvents.length,
|
total_messages: allEvents.length,
|
||||||
current_round: trace.num_rounds,
|
current_round: trace.num_rounds,
|
||||||
total_rounds: trace.num_rounds
|
total_rounds: trace.num_rounds
|
||||||
|
|
@ -487,7 +546,7 @@ async function startReplay() {
|
||||||
initPositions();
|
initPositions();
|
||||||
drawGraph();
|
drawGraph();
|
||||||
|
|
||||||
// Show replay controls
|
// Set up replay controls
|
||||||
const controls = document.getElementById('replayControls');
|
const controls = document.getElementById('replayControls');
|
||||||
controls.style.display = 'flex';
|
controls.style.display = 'flex';
|
||||||
const slider = document.getElementById('replaySlider');
|
const slider = document.getElementById('replaySlider');
|
||||||
|
|
@ -510,106 +569,48 @@ async function startReplay() {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let replayRafId = 0;
|
|
||||||
let playTimer = null;
|
|
||||||
|
|
||||||
function stopPlayback() {
|
|
||||||
if (playTimer !== null) {
|
|
||||||
clearInterval(playTimer);
|
|
||||||
playTimer = null;
|
|
||||||
document.getElementById('btnPlay').innerHTML = '▶';
|
|
||||||
document.getElementById('btnPlay').title = 'Play';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function startPlayback() {
|
|
||||||
stopPlayback();
|
|
||||||
if (replayCursor >= allEvents.length) return; // already at end
|
|
||||||
const speed = parseInt(document.getElementById('speedInput').value) || 100;
|
|
||||||
document.getElementById('btnPlay').innerHTML = '⏸';
|
|
||||||
document.getElementById('btnPlay').title = 'Pause';
|
|
||||||
playTimer = setInterval(() => {
|
|
||||||
if (replayCursor >= allEvents.length) {
|
|
||||||
stopPlayback();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
replayTo(replayCursor + 1);
|
|
||||||
}, speed);
|
|
||||||
}
|
|
||||||
|
|
||||||
function replayTo(pos) {
|
|
||||||
// Debounce: only run once per animation frame
|
|
||||||
replayCursor = pos;
|
|
||||||
if (replayRafId) return;
|
|
||||||
replayRafId = requestAnimationFrame(() => {
|
|
||||||
replayRafId = 0;
|
|
||||||
replayToImpl(replayCursor);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function replayToImpl(pos) {
|
|
||||||
nodeFlash = {};
|
|
||||||
edgeFlash = {};
|
|
||||||
|
|
||||||
// ── Build table rows into a fragment (no reflows) ──
|
|
||||||
const frag = document.createDocumentFragment();
|
|
||||||
const start = Math.max(0, pos - MAX_TABLE_ROWS);
|
|
||||||
for (let i = start; i < pos && i < allEvents.length; i++) {
|
|
||||||
frag.appendChild(makeRow(allEvents[i]));
|
|
||||||
}
|
|
||||||
const tbody = document.getElementById('eventTableBody');
|
|
||||||
tbody.textContent = ''; // fast clear
|
|
||||||
tbody.appendChild(frag);
|
|
||||||
|
|
||||||
const wrap = document.getElementById('eventTableWrap');
|
|
||||||
wrap.scrollTop = wrap.scrollHeight;
|
|
||||||
|
|
||||||
// ── Rebuild worker logs in one pass ──
|
|
||||||
workerLogs = {};
|
|
||||||
const workerStart = Math.max(0, pos - 200); // only last ~200 events for worker logs
|
|
||||||
for (let i = workerStart; i < pos && i < allEvents.length; i++) {
|
|
||||||
const ev = allEvents[i];
|
|
||||||
if (!ev.thread) continue;
|
|
||||||
if (!workerLogs[ev.thread]) workerLogs[ev.thread] = [];
|
|
||||||
let text = ev.node + ': ' + ev.kind;
|
|
||||||
if (ev.kind === 'GossipRoundStarted' && ev.detail) text += ' -> ' + ev.detail.target;
|
|
||||||
if (ev.kind === 'PushReceived' && ev.detail) text += ' <- ' + ev.detail.from;
|
|
||||||
workerLogs[ev.thread].push(text);
|
|
||||||
if (workerLogs[ev.thread].length > 50) workerLogs[ev.thread].shift();
|
|
||||||
}
|
|
||||||
rebuildWorkerColumns();
|
|
||||||
for (const thread of Object.keys(workerLogs)) {
|
|
||||||
updateWorkerColumn(thread);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Flash the last event
|
|
||||||
if (pos > 0 && pos <= allEvents.length) {
|
|
||||||
const ev = allEvents[pos - 1];
|
|
||||||
nodeFlash[ev.node] = performance.now();
|
|
||||||
if (ev.kind === 'GossipRoundStarted' && ev.detail && ev.detail.target) {
|
|
||||||
edgeFlash[ev.node + '->' + ev.detail.target] = performance.now();
|
|
||||||
}
|
|
||||||
if (ev.kind === 'PushReceived' && ev.detail && ev.detail.from) {
|
|
||||||
edgeFlash[ev.detail.from + '->' + ev.node] = performance.now();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('replaySlider').value = pos;
|
|
||||||
updateReplayPos();
|
|
||||||
drawGraph();
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateReplayPos() {
|
|
||||||
document.getElementById('replayPos').textContent = replayCursor + '/' + allEvents.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Boot ───────────────────────────────────────────────────────────
|
// ── Boot ───────────────────────────────────────────────────────────
|
||||||
resizeCanvas();
|
resizeCanvas();
|
||||||
if (MODE === 'replay') {
|
|
||||||
startReplay();
|
(async function boot() {
|
||||||
} else {
|
const select = document.getElementById('traceSelect');
|
||||||
startLive();
|
const dot = document.getElementById('statusDot');
|
||||||
}
|
const statusText = document.getElementById('statusText');
|
||||||
|
|
||||||
|
let traces;
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/traces');
|
||||||
|
traces = await resp.json();
|
||||||
|
} catch (e) {
|
||||||
|
dot.className = 'status-dot error';
|
||||||
|
statusText.textContent = 'Failed to fetch trace list';
|
||||||
|
select.innerHTML = '<option value="">Error</option>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (traces.length === 0) {
|
||||||
|
dot.className = 'status-dot error';
|
||||||
|
statusText.textContent = 'No traces found';
|
||||||
|
select.innerHTML = '<option value="">No traces found</option>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
select.innerHTML = '';
|
||||||
|
traces.forEach(t => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = t.file;
|
||||||
|
opt.textContent = t.name + ' (' + t.nodes + ' nodes, ' + t.events + ' events)';
|
||||||
|
select.appendChild(opt);
|
||||||
|
});
|
||||||
|
select.disabled = false;
|
||||||
|
|
||||||
|
select.onchange = () => {
|
||||||
|
if (select.value) loadTrace(select.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auto-load first trace
|
||||||
|
loadTrace(traces[0].file);
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ pub mod config;
|
||||||
mod dashboard_html;
|
mod dashboard_html;
|
||||||
mod server;
|
mod server;
|
||||||
|
|
||||||
pub use server::{DashboardConfig, run_with_dashboard, serve_replay};
|
pub use server::serve_dashboard;
|
||||||
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io;
|
use std::io;
|
||||||
|
|
|
||||||
|
|
@ -1,747 +1,138 @@
|
||||||
use std::collections::HashMap;
|
use std::fs;
|
||||||
use std::io::{self, Read as IoRead};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
|
||||||
use std::sync::{mpsc, Arc, Mutex};
|
|
||||||
use std::thread;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use serde::Serialize;
|
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;
|
use crate::dashboard_html::DASHBOARD_HTML;
|
||||||
|
|
||||||
// ── Configuration ──────────────────────────────────────────────────────
|
// ── Trace directory scanning ──────────────────────────────────────────
|
||||||
|
|
||||||
#[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<Option<InitData>>,
|
|
||||||
stats: Mutex<StatsSnapshot>,
|
|
||||||
done: AtomicBool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
struct InitData {
|
struct TraceEntry {
|
||||||
|
file: String,
|
||||||
name: String,
|
name: String,
|
||||||
nodes: Vec<NodeInfo>,
|
nodes: usize,
|
||||||
edges: Vec<[String; 2]>,
|
events: usize,
|
||||||
num_threads: usize,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
fn scan_traces(dir: &Path) -> Vec<TraceEntry> {
|
||||||
struct NodeInfo {
|
let mut entries = Vec::new();
|
||||||
name: String,
|
let Ok(read_dir) = fs::read_dir(dir) else {
|
||||||
addr: String,
|
return entries;
|
||||||
}
|
};
|
||||||
|
for entry in read_dir.flatten() {
|
||||||
#[derive(Debug, Clone, Default, Serialize)]
|
let path = entry.path();
|
||||||
struct StatsSnapshot {
|
let fname = path
|
||||||
total_nodes: usize,
|
.file_name()
|
||||||
total_edges: usize,
|
.map(|n| n.to_string_lossy().to_string())
|
||||||
total_messages: usize,
|
.unwrap_or_default();
|
||||||
current_round: u64,
|
if !fname.ends_with(".trace.json") {
|
||||||
total_rounds: usize,
|
continue;
|
||||||
}
|
|
||||||
|
|
||||||
// ── SSE channel adapter ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Adapts an `mpsc::Receiver<Vec<u8>>` to `std::io::Read` for tiny_http streaming.
|
|
||||||
struct ChannelReader {
|
|
||||||
rx: mpsc::Receiver<Vec<u8>>,
|
|
||||||
buf: Vec<u8>,
|
|
||||||
pos: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ChannelReader {
|
|
||||||
fn new(rx: mpsc::Receiver<Vec<u8>>) -> Self {
|
|
||||||
Self {
|
|
||||||
rx,
|
|
||||||
buf: Vec::new(),
|
|
||||||
pos: 0,
|
|
||||||
}
|
}
|
||||||
|
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::<serde_json::Value>(&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
|
||||||
}
|
}
|
||||||
|
|
||||||
impl IoRead for ChannelReader {
|
// ── HTTP server ───────────────────────────────────────────────────────
|
||||||
fn read(&mut self, out: &mut [u8]) -> io::Result<usize> {
|
|
||||||
// Drain current buffer first.
|
pub fn serve_dashboard(trace_dir: &str, port: u16) {
|
||||||
if self.pos < self.buf.len() {
|
let dir = PathBuf::from(trace_dir);
|
||||||
let n = std::cmp::min(out.len(), self.buf.len() - self.pos);
|
assert!(dir.is_dir(), "trace directory does not exist: {trace_dir}");
|
||||||
out[..n].copy_from_slice(&self.buf[self.pos..self.pos + n]);
|
|
||||||
self.pos += n;
|
let addr = format!("0.0.0.0:{port}");
|
||||||
return Ok(n);
|
let server = tiny_http::Server::http(&addr).expect("failed to bind HTTP server");
|
||||||
|
|
||||||
|
eprintln!("Dashboard at http://localhost:{port}");
|
||||||
|
eprintln!("Serving traces from: {trace_dir}");
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let request = match server.recv() {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(_) => break,
|
||||||
|
};
|
||||||
|
|
||||||
|
let url = request.url().to_string();
|
||||||
|
match url.as_str() {
|
||||||
|
"/" => {
|
||||||
|
let response = tiny_http::Response::from_string(DASHBOARD_HTML).with_header(
|
||||||
|
"Content-Type: text/html; charset=utf-8"
|
||||||
|
.parse::<tiny_http::Header>()
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
let _ = request.respond(response);
|
||||||
|
}
|
||||||
|
"/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::<tiny_http::Header>()
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for next chunk.
|
let path = dir.join(&file);
|
||||||
match self.rx.recv() {
|
match fs::read_to_string(&path) {
|
||||||
Ok(data) => {
|
Ok(data) => {
|
||||||
if data.is_empty() {
|
let response = tiny_http::Response::from_string(data).with_header(
|
||||||
return Ok(0); // EOF signal
|
"Content-Type: application/json"
|
||||||
}
|
|
||||||
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<u8> {
|
|
||||||
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<String>,
|
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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<DashboardState>, 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::<tiny_http::Header>()
|
.parse::<tiny_http::Header>()
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
);
|
);
|
||||||
let _ = request.respond(response);
|
let _ = request.respond(response);
|
||||||
}
|
}
|
||||||
"/events" => {
|
Err(_) => {
|
||||||
handle_sse(request, Arc::clone(&state));
|
|
||||||
}
|
|
||||||
"/trace.json" => {
|
|
||||||
handle_trace_json(request, Arc::clone(&state));
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
let response =
|
let response =
|
||||||
tiny_http::Response::from_string("Not Found").with_status_code(404);
|
tiny_http::Response::from_string("Not Found").with_status_code(404);
|
||||||
let _ = request.respond(response);
|
let _ = request.respond(response);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_sse(request: tiny_http::Request, state: Arc<DashboardState>) {
|
|
||||||
let (tx, rx) = mpsc::channel::<Vec<u8>>();
|
|
||||||
let reader = ChannelReader::new(rx);
|
|
||||||
|
|
||||||
// Send SSE headers via a streaming response.
|
|
||||||
let response = tiny_http::Response::new(
|
|
||||||
tiny_http::StatusCode(200),
|
|
||||||
vec![
|
|
||||||
"Content-Type: text/event-stream"
|
|
||||||
.parse::<tiny_http::Header>()
|
|
||||||
.unwrap(),
|
|
||||||
"Cache-Control: no-cache"
|
|
||||||
.parse::<tiny_http::Header>()
|
|
||||||
.unwrap(),
|
|
||||||
"Connection: keep-alive"
|
|
||||||
.parse::<tiny_http::Header>()
|
|
||||||
.unwrap(),
|
|
||||||
],
|
|
||||||
Box::new(reader) as Box<dyn IoRead + Send>,
|
|
||||||
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<DashboardState>) {
|
|
||||||
// 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<ActorAddress> = Vec::new();
|
|
||||||
if let Some(init) = &init {
|
|
||||||
// Reconstruct addrs from name_registry in node order.
|
|
||||||
let inv: HashMap<String, ActorAddress> =
|
|
||||||
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::<tiny_http::Header>()
|
|
||||||
.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<DashboardState>,
|
|
||||||
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<(String, NodeSnapshot)>> = 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<DashboardState>,
|
|
||||||
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<(String, NodeSnapshot)>> = 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<NodeInfo> = 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<NodeInfo> = 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),
|
|
||||||
});
|
|
||||||
|
|
||||||
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}");
|
|
||||||
|
|
||||||
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__", "replay");
|
|
||||||
let response = tiny_http::Response::from_string(html).with_header(
|
|
||||||
"Content-Type: text/html; charset=utf-8"
|
|
||||||
.parse::<tiny_http::Header>()
|
|
||||||
.unwrap(),
|
|
||||||
);
|
|
||||||
let _ = request.respond(response);
|
|
||||||
}
|
|
||||||
"/trace.json" => {
|
|
||||||
let json = serde_json::to_string(trace).unwrap();
|
|
||||||
let response = tiny_http::Response::from_string(json).with_header(
|
|
||||||
"Content-Type: application/json"
|
|
||||||
.parse::<tiny_http::Header>()
|
|
||||||
.unwrap(),
|
|
||||||
);
|
|
||||||
let _ = request.respond(response);
|
|
||||||
}
|
|
||||||
_ => {
|
_ => {
|
||||||
let response =
|
let response =
|
||||||
tiny_http::Response::from_string("Not Found").with_status_code(404);
|
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<u8> {
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -289,13 +289,14 @@ pub fn heal_partition_via_handle(
|
||||||
handle: &swactor::runtime::RuntimeHandle,
|
handle: &swactor::runtime::RuntimeHandle,
|
||||||
topology: &Topology,
|
topology: &Topology,
|
||||||
addrs: &[ActorAddress],
|
addrs: &[ActorAddress],
|
||||||
_names: &[String],
|
names: &[String],
|
||||||
) {
|
) -> Vec<(String, String)> {
|
||||||
if !matches!(topology, Topology::Partitioned) {
|
if !matches!(topology, Topology::Partitioned) {
|
||||||
return;
|
return Vec::new();
|
||||||
}
|
}
|
||||||
let n = addrs.len();
|
let n = addrs.len();
|
||||||
let half = n / 2;
|
let half = n / 2;
|
||||||
|
let mut new_edges = Vec::new();
|
||||||
if half > 0 && half < n {
|
if half > 0 && half < n {
|
||||||
handle
|
handle
|
||||||
.runtime
|
.runtime
|
||||||
|
|
@ -305,7 +306,10 @@ pub fn heal_partition_via_handle(
|
||||||
.runtime
|
.runtime
|
||||||
.send_to(addrs[half], GossipMessage::AddPeer(addrs[half - 1]))
|
.send_to(addrs[half], GossipMessage::AddPeer(addrs[half - 1]))
|
||||||
.unwrap();
|
.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 ──────────────────────────────────────────────────────
|
// ── Topology wiring ──────────────────────────────────────────────────────
|
||||||
|
|
@ -377,18 +381,22 @@ pub fn heal_partition(
|
||||||
rt: &Runtime,
|
rt: &Runtime,
|
||||||
topology: &Topology,
|
topology: &Topology,
|
||||||
addrs: &[ActorAddress],
|
addrs: &[ActorAddress],
|
||||||
_names: &[String],
|
names: &[String],
|
||||||
) {
|
) -> Vec<(String, String)> {
|
||||||
if !matches!(topology, Topology::Partitioned) {
|
if !matches!(topology, Topology::Partitioned) {
|
||||||
return;
|
return Vec::new();
|
||||||
}
|
}
|
||||||
let n = addrs.len();
|
let n = addrs.len();
|
||||||
let half = n / 2;
|
let half = n / 2;
|
||||||
|
let mut new_edges = Vec::new();
|
||||||
// Add bidirectional links between the two halves (bridge nodes).
|
// Add bidirectional links between the two halves (bridge nodes).
|
||||||
if half > 0 && half < n {
|
if half > 0 && half < n {
|
||||||
rt.send_to(addrs[half - 1], GossipMessage::AddPeer(addrs[half]))
|
rt.send_to(addrs[half - 1], GossipMessage::AddPeer(addrs[half]))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
rt.send_to(addrs[half], GossipMessage::AddPeer(addrs[half - 1]))
|
rt.send_to(addrs[half], GossipMessage::AddPeer(addrs[half - 1]))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
new_edges.push((names[half - 1].clone(), names[half].clone()));
|
||||||
|
new_edges.push((names[half].clone(), names[half - 1].clone()));
|
||||||
}
|
}
|
||||||
|
new_edges
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue