//! Visual replay viewer for diagnostics bundles. //! //! Loads a finalized deployment bundle (`vastai-N3-*.tar.gz`), an //! uncompressed collector spool dir (`vastai-N3-*/`), or a simulation //! bundle dir (`manifest.json` + `events.ndjson` + `snapshots/...`), //! normalizes both formats into a single in-memory event timeline, and //! serves a one-page HTML viewer on localhost. //! //! Run: //! cargo run --example replay_viewer -p dashboard \ //! --features replay-viewer -- use std::collections::BTreeMap; use std::fs; use std::io::{BufRead, BufReader}; use std::net::SocketAddr; use std::path::{Path, PathBuf}; use std::sync::Arc; use anyhow::{Context, Result, anyhow, bail}; use axum::Router; use axum::extract::State; use axum::http::header; use axum::response::{Html, IntoResponse}; use axum::routing::get; use serde::Serialize; use serde_json::Value; use distribution::diagnostics::postproc::Bundle; // ─── wire types ──────────────────────────────────────────────────────────── #[derive(Clone, Copy, Serialize)] #[serde(rename_all = "lowercase")] enum SourceKind { Deployment, Sim, } #[derive(Clone, Copy, Serialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] enum Severity { Info, Notable, Error, } #[derive(Serialize)] struct UnifiedEvent { t_ms: f64, node_label: String, kind: String, severity: Severity, fields: Value, } #[derive(Serialize)] struct NodeInfo { label: String, role: Option, color: String, } #[derive(Serialize)] struct KindInfo { kind: String, count: u64, color: String, } #[derive(Serialize)] struct SnapshotInfo { t_ms: f64, node_label: String, summary: String, } #[derive(Serialize)] struct BundleView { run_id: String, source_kind: SourceKind, t_start_ms: f64, t_end_ms: f64, nodes: Vec, event_kinds: Vec, events: Vec, snapshots: Vec, } // ─── format detection ────────────────────────────────────────────────────── enum DetectedFormat { DeploymentTar(PathBuf), DeploymentDir(PathBuf), Sim(PathBuf), } fn detect_format(path: &Path) -> Result { let md = fs::metadata(path) .with_context(|| format!("stat {}", path.display()))?; if md.is_file() { let name = path.file_name().and_then(|s| s.to_str()).unwrap_or(""); if name.ends_with(".tar.gz") || name.ends_with(".tgz") { return Ok(DetectedFormat::DeploymentTar(path.to_path_buf())); } bail!("unrecognized file: {} (expected .tar.gz)", path.display()); } if path.join("MANIFEST.json").is_file() { return Ok(DetectedFormat::DeploymentDir(path.to_path_buf())); } if path.join("manifest.json").is_file() && path.join("events.ndjson").is_file() { return Ok(DetectedFormat::Sim(path.to_path_buf())); } bail!( "could not classify {} — expected .tar.gz, dir with MANIFEST.json, or sim dir with manifest.json+events.ndjson", path.display() ) } // ─── deployment loaders ──────────────────────────────────────────────────── fn load_deployment_tar(path: &Path) -> Result { let bundle = Bundle::parse_path(path) .map_err(|e| anyhow!("parse {}: {e}", path.display()))?; let nodes_meta: Vec<(String, Option)> = bundle .manifest .nodes .iter() .map(|n| (n.label.clone(), n.role.clone())) .collect(); let mut events: Vec = Vec::new(); let mut snapshots: Vec = Vec::new(); let mut t0: u64 = bundle.manifest.run_start_collector_ms.unwrap_or(u64::MAX); for node in bundle.nodes.values() { for ev in &node.events { if ev.wall_ms < t0 { t0 = ev.wall_ms; } } for snap in &node.snapshots { if snap.wall_ms < t0 { t0 = snap.wall_ms; } } } if t0 == u64::MAX { t0 = 0; } let mut t_end: f64 = 0.0; for node in bundle.nodes.values() { for ev in &node.events { let value = serde_json::to_value(&ev.event).unwrap_or(Value::Null); let kind = unified_kind_from_value(&value); let severity = severity_for(&kind, &value); let t_ms = (ev.wall_ms.saturating_sub(t0)) as f64; if t_ms > t_end { t_end = t_ms; } events.push(UnifiedEvent { t_ms, node_label: node.label.clone(), kind, severity, fields: value, }); } for snap in &node.snapshots { let t_ms = (snap.wall_ms.saturating_sub(t0)) as f64; if t_ms > t_end { t_end = t_ms; } let body = serde_json::to_value(&snap.body).unwrap_or(Value::Null); snapshots.push(SnapshotInfo { t_ms, node_label: node.label.clone(), summary: summarize_snapshot(&body), }); } } events.sort_by(|a, b| a.t_ms.partial_cmp(&b.t_ms).unwrap_or(std::cmp::Ordering::Equal)); snapshots.sort_by(|a, b| a.t_ms.partial_cmp(&b.t_ms).unwrap_or(std::cmp::Ordering::Equal)); Ok(BundleView { run_id: bundle.run_id, source_kind: SourceKind::Deployment, t_start_ms: 0.0, t_end_ms: t_end, nodes: assign_node_colors(nodes_meta), event_kinds: tally_event_kinds(&events), events, snapshots, }) } #[derive(serde::Deserialize)] struct DirManifestNode { node_id_hex: String, label: String, #[serde(default)] role: Option, } #[derive(serde::Deserialize)] struct DirManifest { run_id: String, #[serde(default)] run_start_collector_ms: Option, #[serde(default)] nodes: Vec, } fn load_deployment_dir(root: &Path) -> Result { let manifest_bytes = fs::read(root.join("MANIFEST.json")) .with_context(|| format!("read MANIFEST.json under {}", root.display()))?; let manifest: DirManifest = serde_json::from_slice(&manifest_bytes) .context("parse MANIFEST.json")?; let hex_to_label: BTreeMap = manifest .nodes .iter() .map(|n| (n.node_id_hex.to_lowercase(), n.label.clone())) .collect(); let nodes_meta: Vec<(String, Option)> = manifest .nodes .iter() .map(|n| (n.label.clone(), n.role.clone())) .collect(); let mut events: Vec = Vec::new(); let mut snapshots: Vec = Vec::new(); let mut t0: u64 = manifest.run_start_collector_ms.unwrap_or(u64::MAX); let mut raw_events: Vec<(String, Value)> = Vec::new(); let mut raw_snapshots: Vec<(String, Value)> = Vec::new(); for entry in fs::read_dir(root).with_context(|| format!("readdir {}", root.display()))? { let entry = entry?; if !entry.file_type()?.is_dir() { continue; } let dir_name = entry.file_name().to_string_lossy().into_owned(); let label = hex_to_label .get(&dir_name.to_lowercase()) .cloned() .unwrap_or_else(|| { if dir_name.len() >= 8 { format!("node-{}", &dir_name[..8]) } else { dir_name.clone() } }); for f in fs::read_dir(entry.path())? { let f = f?; let fname = f.file_name().to_string_lossy().into_owned(); if fname.starts_with("events-") && fname.ends_with(".json") { let bytes = fs::read(f.path()) .with_context(|| format!("read {}", f.path().display()))?; let batch: Value = serde_json::from_slice(&bytes) .with_context(|| format!("parse {}", f.path().display()))?; if let Some(arr) = batch.as_array() { for ev in arr { if let Some(wall) = ev.get("wall_ms").and_then(Value::as_u64) { if wall < t0 { t0 = wall; } } raw_events.push((label.clone(), ev.clone())); } } } else if fname.starts_with("snapshot-") && fname.ends_with(".json") { let bytes = fs::read(f.path())?; let snap: Value = serde_json::from_slice(&bytes) .with_context(|| format!("parse {}", f.path().display()))?; if let Some(wall) = snap.get("wall_ms").and_then(Value::as_u64) { if wall < t0 { t0 = wall; } } raw_snapshots.push((label.clone(), snap)); } } } if t0 == u64::MAX { t0 = 0; } let mut t_end: f64 = 0.0; for (label, ev) in raw_events { let wall = ev.get("wall_ms").and_then(Value::as_u64).unwrap_or(t0); let t_ms = wall.saturating_sub(t0) as f64; if t_ms > t_end { t_end = t_ms; } let kind = unified_kind_from_value(&ev); let severity = severity_for(&kind, &ev); events.push(UnifiedEvent { t_ms, node_label: label, kind, severity, fields: ev, }); } for (label, snap) in raw_snapshots { let wall = snap.get("wall_ms").and_then(Value::as_u64).unwrap_or(t0); let t_ms = wall.saturating_sub(t0) as f64; if t_ms > t_end { t_end = t_ms; } let body = snap.get("body").cloned().unwrap_or(Value::Null); snapshots.push(SnapshotInfo { t_ms, node_label: label, summary: summarize_snapshot(&body), }); } events.sort_by(|a, b| a.t_ms.partial_cmp(&b.t_ms).unwrap_or(std::cmp::Ordering::Equal)); snapshots.sort_by(|a, b| a.t_ms.partial_cmp(&b.t_ms).unwrap_or(std::cmp::Ordering::Equal)); Ok(BundleView { run_id: manifest.run_id, source_kind: SourceKind::Deployment, t_start_ms: 0.0, t_end_ms: t_end, nodes: assign_node_colors(nodes_meta), event_kinds: tally_event_kinds(&events), events, snapshots, }) } // ─── sim loader ──────────────────────────────────────────────────────────── fn load_sim(dir: &Path) -> Result { let manifest_bytes = fs::read(dir.join("manifest.json")) .with_context(|| format!("read manifest.json under {}", dir.display()))?; let manifest: Value = serde_json::from_slice(&manifest_bytes).context("parse manifest.json")?; let run_id = manifest .get("scenario_name") .and_then(Value::as_str) .or_else(|| manifest.get("name").and_then(Value::as_str)) .map(|s| s.to_string()) .unwrap_or_else(|| dir.file_name().and_then(|s| s.to_str()).unwrap_or("sim").to_string()); let f = fs::File::open(dir.join("events.ndjson")) .with_context(|| format!("open events.ndjson under {}", dir.display()))?; let reader = BufReader::new(f); let mut hosts: BTreeMap = BTreeMap::new(); let mut events: Vec = Vec::new(); let mut t_end: f64 = 0.0; for line in reader.lines() { let line = line?; if line.trim().is_empty() { continue; } let v: Value = match serde_json::from_str(&line) { Ok(v) => v, Err(_) => continue, }; let t_ns = v.get("virtual_time_ns").and_then(Value::as_u64).unwrap_or(0); let host = v .get("host_id") .and_then(Value::as_str) .unwrap_or("unknown") .to_string(); let kind = v .get("kind_tag") .and_then(Value::as_str) .unwrap_or("event") .to_string(); let payload = v.get("event").cloned().unwrap_or_else(|| v.clone()); let severity = severity_for(&kind, &payload); let t_ms = (t_ns as f64) / 1.0e6; if t_ms > t_end { t_end = t_ms; } hosts.entry(host.clone()).or_insert(()); events.push(UnifiedEvent { t_ms, node_label: host, kind, severity, fields: payload, }); } events.sort_by(|a, b| a.t_ms.partial_cmp(&b.t_ms).unwrap_or(std::cmp::Ordering::Equal)); let mut snapshots: Vec = Vec::new(); let snap_root = dir.join("snapshots"); if snap_root.is_dir() { for host_entry in fs::read_dir(&snap_root)? { let host_entry = host_entry?; if !host_entry.file_type()?.is_dir() { continue; } let host = host_entry.file_name().to_string_lossy().into_owned(); for f in fs::read_dir(host_entry.path())? { let f = f?; let bytes = fs::read(f.path())?; let snap: Value = match serde_json::from_slice(&bytes) { Ok(v) => v, Err(_) => continue, }; let t_ns = snap.get("virtual_time_ns").and_then(Value::as_u64).unwrap_or(0); let t_ms = (t_ns as f64) / 1.0e6; if t_ms > t_end { t_end = t_ms; } snapshots.push(SnapshotInfo { t_ms, node_label: host.clone(), summary: summarize_snapshot(&snap), }); } } snapshots.sort_by(|a, b| a.t_ms.partial_cmp(&b.t_ms).unwrap_or(std::cmp::Ordering::Equal)); } let nodes_meta: Vec<(String, Option)> = hosts.into_keys().map(|h| (h, None)).collect(); Ok(BundleView { run_id, source_kind: SourceKind::Sim, t_start_ms: 0.0, t_end_ms: t_end, nodes: assign_node_colors(nodes_meta), event_kinds: tally_event_kinds(&events), events, snapshots, }) } // ─── helpers ─────────────────────────────────────────────────────────────── fn unified_kind_from_value(v: &Value) -> String { // Deployment event records use serde tag `type`; collector spool // files use a flat `kind` string. The `Custom` variant carries an // inner `kind` field we want to surface as `Custom:`. if let Some(tag) = v.get("type").and_then(Value::as_str) { if tag == "Custom" { if let Some(inner) = v.get("kind").and_then(Value::as_str) { return format!("Custom:{inner}"); } } return tag.to_string(); } if let Some(k) = v.get("kind").and_then(Value::as_str) { return k.to_string(); } "event".to_string() } fn severity_for(kind: &str, fields: &Value) -> Severity { if kind == "Error" || kind.starts_with("error") { return Severity::Error; } if kind == "DialOutcome" { if let Some(outcome) = fields.get("outcome") { let ok = outcome .as_str() .map(|s| s == "Success") .or_else(|| { outcome .as_object() .map(|m| m.keys().next().map(|k| k == "Success").unwrap_or(false)) }) .unwrap_or(false); return if ok { Severity::Info } else { Severity::Error }; } } if kind == "SwimTransition" { if fields.get("to").and_then(Value::as_str) == Some("Dead") { return Severity::Error; } return Severity::Notable; } if kind == "ConnectionCacheInvalidated" || kind == "RelayChanged" || kind == "IrohConnTypeChanged" { return Severity::Notable; } if kind.to_ascii_lowercase().contains("drop") { return Severity::Notable; } Severity::Info } fn summarize_snapshot(body: &Value) -> String { let reach = body .get("reachability") .and_then(Value::as_array) .map(|a| a.len()) .unwrap_or(0); let tail = body .get("events") .and_then(Value::as_array) .map(|a| a.len()) .unwrap_or(0); let mut parts = vec![format!("peers={reach}"), format!("tail={tail}")]; if body.get("iroh").is_some() && !body.get("iroh").unwrap().is_null() { parts.push("iroh".into()); } if body.get("swim").is_some() && !body.get("swim").unwrap().is_null() { parts.push("swim".into()); } if body.get("host").is_some() && !body.get("host").unwrap().is_null() { parts.push("host".into()); } parts.join(" ") } const NODE_PALETTE: &[&str] = &[ "#4a90e2", "#f06292", "#81c784", "#ffb74d", "#ba68c8", "#4dd0e1", "#aed581", "#ff8a65", ]; fn assign_node_colors(meta: Vec<(String, Option)>) -> Vec { meta.into_iter() .enumerate() .map(|(i, (label, role))| NodeInfo { label, role, color: NODE_PALETTE[i % NODE_PALETTE.len()].to_string(), }) .collect() } const KIND_PALETTE: &[&str] = &[ "#4a90e2", "#ffb74d", "#81c784", "#f06292", "#ba68c8", "#4dd0e1", "#aed581", "#ff8a65", "#e57373", "#9575cd", "#64b5f6", "#ffd54f", ]; fn tally_event_kinds(events: &[UnifiedEvent]) -> Vec { let mut counts: BTreeMap = BTreeMap::new(); for ev in events { *counts.entry(ev.kind.clone()).or_insert(0) += 1; } let mut pairs: Vec<(String, u64)> = counts.into_iter().collect(); pairs.sort_by(|a, b| b.1.cmp(&a.1)); pairs .into_iter() .enumerate() .map(|(i, (kind, count))| KindInfo { kind, count, color: KIND_PALETTE[i % KIND_PALETTE.len()].to_string(), }) .collect() } // ─── routes ──────────────────────────────────────────────────────────────── async fn get_index() -> Html<&'static str> { Html(INDEX_HTML) } async fn get_bundle(State(view): State>) -> impl IntoResponse { ( [(header::CONTENT_TYPE, "application/json")], view.as_str().to_owned(), ) } const INDEX_HTML: &str = include_str!("replay_viewer.html"); // ─── main ────────────────────────────────────────────────────────────────── #[tokio::main(flavor = "multi_thread")] async fn main() -> Result<()> { let path = std::env::args().nth(1).ok_or_else(|| { anyhow!("usage: replay_viewer ") })?; let path = PathBuf::from(path); let view = match detect_format(&path)? { DetectedFormat::DeploymentTar(p) => load_deployment_tar(&p)?, DetectedFormat::DeploymentDir(p) => load_deployment_dir(&p)?, DetectedFormat::Sim(p) => load_sim(&p)?, }; let n_events = view.events.len(); let n_nodes = view.nodes.len(); let span_s = view.t_end_ms / 1000.0; let run_id = view.run_id.clone(); let payload = Arc::new(serde_json::to_string(&view).context("serialize bundle view")?); let app = Router::new() .route("/", get(get_index)) .route("/api/bundle", get(get_bundle)) .with_state(payload); let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .context("bind 127.0.0.1:0")?; let addr: SocketAddr = listener.local_addr()?; println!( "replay-viewer: run={run_id} nodes={n_nodes} events={n_events} span={span_s:.1}s" ); println!(" open: http://{addr}"); axum::serve(listener, app).await.context("axum serve")?; Ok(()) }