feat: add mesh network visualizer

- D3.js force-directed graph for real-time mesh visualization
- WebSocket server (mesh-viz-bridge crate) for live updates
- Demo mode with simulated topology
- JSONL file upload for offline analysis
- Optional viz logging in mesh_node forwarding
This commit is contained in:
2026-04-06 21:43:28 +02:00
parent 99d36679c8
commit 95ce8898fd
9 changed files with 831 additions and 1 deletions

12
Cargo.lock generated
View File

@@ -3202,6 +3202,18 @@ version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "mesh-viz-bridge"
version = "0.1.0"
dependencies = [
"anyhow",
"clap",
"futures-util",
"serde_json",
"tokio",
"tokio-tungstenite",
]
[[package]]
name = "meshservice"
version = "0.1.0"

View File

@@ -14,6 +14,8 @@ members = [
"crates/quicprochat-p2p",
# Generic decentralized service layer (FAPP, Housing, etc.)
"crates/meshservice",
# WebSocket bridge for viz/mesh-graph.html (tails NDJSON → browsers)
"viz/bridge",
]
[workspace.package]

View File

@@ -42,6 +42,7 @@ pub mod transport_iroh;
pub mod transport_manager;
pub mod transport_tcp;
pub mod transport_lora;
pub mod viz_log;
#[cfg(feature = "traffic-resistance")]
pub mod traffic_resistance;

View File

@@ -352,8 +352,14 @@ impl MeshNode {
IncomingAction::Deliver(_) => {
self.metrics.store.messages_delivered.inc();
}
IncomingAction::Forward { .. } => {
IncomingAction::Forward {
envelope: _,
next_hop,
} => {
self.metrics.routing.announcements_forwarded.inc();
let from = format!("{sender}");
let to = next_hop.to_string();
crate::viz_log::log_forward_hop(&from, &to, 0);
}
IncomingAction::Store(_) => {
self.metrics.store.messages_stored.inc();

View File

@@ -0,0 +1,45 @@
//! Optional NDJSON events for the mesh graph visualizer (`viz/mesh-graph.html`).
//!
//! When the environment variable `QPC_MESH_VIZ_LOG` is set to a file path, one JSON object
//! per line is appended for selected mesh events. The `viz/bridge` binary can tail this file
//! and forward lines to the browser over WebSocket.
use serde::Serialize;
#[derive(Serialize)]
struct HopEvent<'a> {
#[serde(rename = "type")]
kind: &'static str,
from: &'a str,
to: &'a str,
ms: u64,
}
/// Log a relay hop (forwarding to `next_hop`). No-op unless `QPC_MESH_VIZ_LOG` is set.
pub fn log_forward_hop(from_sender: &str, next_hop: &str, latency_ms: u64) {
let Ok(path) = std::env::var("QPC_MESH_VIZ_LOG") else {
return;
};
let ev = HopEvent {
kind: "hop",
from: from_sender,
to: next_hop,
ms: latency_ms,
};
let Ok(line) = serde_json::to_string(&ev) else {
return;
};
append_line(&path, &line);
}
fn append_line(path: &str, line: &str) {
use std::io::Write;
let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
else {
return;
};
let _ = writeln!(f, "{line}");
}

14
viz/bridge/Cargo.toml Normal file
View File

@@ -0,0 +1,14 @@
[package]
name = "mesh-viz-bridge"
version = "0.1.0"
edition = "2021"
description = "WebSocket bridge: tails NDJSON mesh viz events to browser clients"
license = "Apache-2.0 OR MIT"
[dependencies]
anyhow = "1"
clap = { version = "4", features = ["derive"] }
futures-util = "0.3"
serde_json = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal", "time", "fs", "io-util", "net", "sync"] }
tokio-tungstenite = "0.26"

250
viz/bridge/src/main.rs Normal file
View File

@@ -0,0 +1,250 @@
//! Broadcasts newline-delimited JSON mesh events to all connected WebSocket clients.
//!
//! Sources:
//! - `--demo`: synthetic topology + hops (no file needed)
//! - `--file`: poll a JSONL file for appended lines (e.g. written by `QPC_MESH_VIZ_LOG`)
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::Arc;
use clap::Parser;
use futures_util::{SinkExt, StreamExt};
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::broadcast;
use tokio_tungstenite::tungstenite::Message;
#[derive(Parser, Debug)]
#[command(name = "mesh-viz-bridge")]
struct Args {
/// Listen address (WebSocket upgrade is raw TCP; use mesh-graph.html connect URL).
#[arg(long, default_value = "127.0.0.1:8765")]
listen: String,
/// Poll this file for new NDJSON lines (append-only).
#[arg(long)]
file: Option<PathBuf>,
/// Emit synthetic events for UI development.
#[arg(long)]
demo: bool,
/// Milliseconds between file polls when using `--file`.
#[arg(long, default_value = "250")]
poll_ms: u64,
/// Milliseconds between demo events.
#[arg(long, default_value = "900")]
demo_interval_ms: u64,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let args = Args::parse();
if args.file.is_some() && args.demo {
eprintln!("Use either --file or --demo, not both. Preferring --file.");
}
let (tx, _rx) = broadcast::channel::<String>(256);
let tx = Arc::new(tx);
if args.demo && args.file.is_none() {
let txd = Arc::clone(&tx);
let interval = args.demo_interval_ms;
tokio::spawn(async move {
demo_loop(txd, interval).await;
});
} else if let Some(ref path) = args.file {
let path = path.clone();
let txf = Arc::clone(&tx);
let poll = args.poll_ms;
tokio::spawn(async move {
tail_file_loop(path, txf, poll).await;
});
} else {
eprintln!("No --file or --demo: only WebSocket clients that receive externally pushed data would work.");
eprintln!("Start with: mesh-viz-bridge --demo OR mesh-viz-bridge --file ./mesh-viz-events.jsonl");
}
let listener = TcpListener::bind(&args.listen).await?;
eprintln!("mesh-viz-bridge WebSocket listening on ws://{}", args.listen);
loop {
let (stream, addr) = listener.accept().await?;
let txc = Arc::clone(&tx);
tokio::spawn(async move {
if let Err(e) = handle_client(stream, txc).await {
eprintln!("client {} error: {}", addr, e);
}
});
}
}
async fn handle_client(stream: TcpStream, tx: Arc<broadcast::Sender<String>>) -> anyhow::Result<()> {
let ws = tokio_tungstenite::accept_async(stream).await?;
let (mut write, mut read) = ws.split();
let mut rx = tx.subscribe();
loop {
tokio::select! {
msg = read.next() => {
match msg {
Some(Ok(Message::Close(_))) | None => break,
Some(Ok(Message::Ping(p))) => {
let _ = write.send(Message::Pong(p)).await;
}
Some(Err(e)) => return Err(e.into()),
_ => {}
}
}
line = rx.recv() => {
match line {
Ok(s) => write.send(Message::Text(s.into())).await?,
Err(broadcast::error::RecvError::Lagged(_)) => continue,
Err(broadcast::error::RecvError::Closed) => break,
}
}
}
}
Ok(())
}
async fn tail_file_loop(path: PathBuf, tx: Arc<broadcast::Sender<String>>, poll_ms: u64) {
let mut offset: u64 = 0;
loop {
match tokio::fs::File::open(&path).await {
Ok(file) => {
use tokio::io::{AsyncReadExt, AsyncSeekExt};
let mut file = file;
if let Ok(meta) = file.metadata().await {
let len = meta.len();
if len < offset {
offset = 0;
}
}
if file.seek(std::io::SeekFrom::Start(offset)).await.is_ok() {
let mut buf = Vec::new();
if file.read_to_end(&mut buf).await.is_ok() {
offset = match file.metadata().await {
Ok(m) => m.len(),
Err(_) => offset + buf.len() as u64,
};
let text = String::from_utf8_lossy(&buf);
for line in text.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let _ = tx.send(line.to_string());
}
}
}
}
Err(_) => {
// Wait until file exists
}
}
tokio::time::sleep(std::time::Duration::from_millis(poll_ms)).await;
}
}
async fn demo_loop(tx: Arc<broadcast::Sender<String>>, interval_ms: u64) {
let nodes = [
("n1", "alpha", "active", 12u64),
("n2", "beta", "active", 18),
("n3", "gamma", "idle", 45),
("n4", "delta", "active", 22),
];
let mut tick: u64 = 0;
let mut present: HashSet<&'static str> = HashSet::new();
loop {
// Simulate join/leave
if tick % 14 == 0 {
present.clear();
present.insert("n1");
present.insert("n2");
} else if tick % 14 == 3 {
present.insert("n3");
} else if tick % 14 == 7 {
present.insert("n4");
} else if tick % 14 == 10 {
present.remove("n3");
} else if tick % 14 == 12 {
let _ = tx.send(
serde_json::json!({
"type": "node_status",
"id": "n2",
"status": "error",
"latency_ms": 999u64
})
.to_string(),
);
}
if tick % 14 != 12 {
let snap_nodes: Vec<_> = nodes
.iter()
.filter(|(id, _, _, _)| present.contains(id))
.map(|(id, label, status, lat)| {
serde_json::json!({
"id": id,
"label": label,
"status": status,
"latency_ms": lat
})
})
.collect();
let links: Vec<_> = {
let mut v = vec![];
if present.contains("n1") && present.contains("n2") {
v.push(serde_json::json!({"source": "n1", "target": "n2"}));
}
if present.contains("n2") && present.contains("n3") {
v.push(serde_json::json!({"source": "n2", "target": "n3"}));
}
if present.contains("n3") && present.contains("n4") {
v.push(serde_json::json!({"source": "n3", "target": "n4"}));
}
if present.contains("n2") && present.contains("n4") {
v.push(serde_json::json!({"source": "n2", "target": "n4"}));
}
v
};
let _ = tx.send(
serde_json::json!({
"type": "snapshot",
"nodes": snap_nodes,
"links": links
})
.to_string(),
);
}
// Message hop animation
let hop_pairs = [
("n1", "n2"),
("n2", "n3"),
("n2", "n4"),
("n3", "n4"),
];
let (a, b) = hop_pairs[(tick as usize) % hop_pairs.len()];
if present.contains(a) && present.contains(b) {
let ms = 8 + (tick % 40);
let _ = tx.send(
serde_json::json!({
"type": "hop",
"from": a,
"to": b,
"ms": ms
})
.to_string(),
);
}
tick = tick.wrapping_add(1);
tokio::time::sleep(std::time::Duration::from_millis(interval_ms)).await;
}
}

493
viz/mesh-graph.html Normal file
View File

@@ -0,0 +1,493 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>QuicProQuo mesh visualizer</title>
<script src="https://cdn.jsdelivr.net/npm/d3@7.9.0/dist/d3.min.js"></script>
<style>
:root {
--bg: #0f1419;
--panel: #1a2332;
--text: #e7ecf3;
--muted: #8b9cb3;
--edge: #3d4f66;
--active: #22c55e;
--idle: #eab308;
--error: #ef4444;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "JetBrains Mono", "Fira Code", ui-monospace, monospace;
background: var(--bg);
color: var(--text);
min-height: 100vh;
display: flex;
flex-direction: column;
}
header {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
padding: 0.6rem 1rem;
background: var(--panel);
border-bottom: 1px solid #2a3544;
}
header h1 {
margin: 0;
font-size: 1rem;
font-weight: 600;
letter-spacing: 0.02em;
}
header .badge {
font-size: 0.7rem;
padding: 0.2rem 0.5rem;
border-radius: 4px;
background: #243044;
color: var(--muted);
}
header .badge.live { color: var(--active); }
header .badge.demo { color: var(--idle); }
header .badge.file { color: #38bdf8; }
label { font-size: 0.75rem; color: var(--muted); }
input[type="text"] {
width: 220px;
padding: 0.35rem 0.5rem;
border: 1px solid #2a3544;
border-radius: 4px;
background: var(--bg);
color: var(--text);
font-family: inherit;
font-size: 0.75rem;
}
button {
padding: 0.35rem 0.65rem;
border-radius: 4px;
border: 1px solid #3d4f66;
background: #243044;
color: var(--text);
font-family: inherit;
font-size: 0.75rem;
cursor: pointer;
}
button:hover { background: #2c3c55; }
button.primary { border-color: var(--active); color: var(--active); }
#chart-wrap {
flex: 1;
position: relative;
min-height: 400px;
}
svg#mesh {
width: 100%;
height: 100%;
display: block;
}
.links line {
stroke: var(--edge);
stroke-opacity: 0.65;
stroke-width: 1.5px;
}
.links line.hop-flash {
stroke: #7dd3fc;
stroke-width: 3px;
stroke-opacity: 1;
filter: drop-shadow(0 0 4px #38bdf8);
}
.nodes circle {
stroke: #1a2332;
stroke-width: 2px;
}
.nodes circle.status-active { fill: var(--active); }
.nodes circle.status-idle { fill: var(--idle); }
.nodes circle.status-error { fill: var(--error); }
.nodes text {
fill: var(--text);
font-size: 11px;
pointer-events: none;
text-shadow: 0 0 4px var(--bg), 0 0 6px var(--bg);
}
#tooltip {
position: fixed;
pointer-events: none;
z-index: 20;
background: rgba(26, 35, 50, 0.95);
border: 1px solid #3d4f66;
padding: 0.5rem 0.65rem;
border-radius: 6px;
font-size: 0.72rem;
max-width: 280px;
display: none;
}
#tooltip.visible { display: block; }
#log {
max-height: 88px;
overflow-y: auto;
font-size: 0.65rem;
color: var(--muted);
padding: 0.35rem 1rem;
border-top: 1px solid #2a3544;
background: #0c1016;
}
</style>
</head>
<body>
<header>
<h1>QuicProQuo mesh</h1>
<span id="mode-badge" class="badge">disconnected</span>
<label>WS <input id="ws-url" type="text" value="ws://127.0.0.1:8765" /></label>
<button type="button" id="btn-connect" class="primary">Connect</button>
<button type="button" id="btn-disconnect">Disconnect</button>
<button type="button" id="btn-demo">Demo mode</button>
<label style="display:flex;align-items:center;gap:0.35rem;">
<span>JSONL</span>
<input id="file-jsonl" type="file" accept=".jsonl,.ndjson,.json,.txt" />
</label>
</header>
<div id="chart-wrap">
<svg id="mesh"></svg>
<div id="tooltip"></div>
</div>
<div id="log"></div>
<script>
(function () {
let mode = "off"; // off | demo | ws | file
let ws = null;
let demoTimer = null;
let nodes = [];
let links = [];
let simulation = null;
let linkSel = null;
let nodeSel = null;
let labelSel = null;
const svg = d3.select("#mesh");
const tooltip = d3.select("#tooltip");
const logEl = document.getElementById("log");
const modeBadge = document.getElementById("mode-badge");
function log(msg) {
const t = new Date().toISOString().slice(11, 19);
logEl.textContent = `[${t}] ${msg}\n` + logEl.textContent.split("\n").slice(0, 12).join("\n");
}
function setMode(m) {
mode = m;
modeBadge.className = "badge";
if (m === "demo") { modeBadge.textContent = "demo"; modeBadge.classList.add("demo"); }
else if (m === "ws") { modeBadge.textContent = "live (WebSocket)"; modeBadge.classList.add("live"); }
else if (m === "file") { modeBadge.textContent = "file JSONL"; modeBadge.classList.add("file"); }
else { modeBadge.textContent = "disconnected"; }
}
function resize() {
const wrap = document.getElementById("chart-wrap");
const w = wrap.clientWidth;
const h = Math.max(400, window.innerHeight - wrap.offsetTop - 120);
svg.attr("width", w).attr("height", h);
if (simulation) {
simulation.force("center", d3.forceCenter(w / 2, h / 2));
simulation.alpha(0.35).restart();
}
}
function ensureSimulation() {
const w = +svg.attr("width") || 800;
const h = +svg.attr("height") || 500;
const root = svg.selectAll("g.root").data([0]).join("g").attr("class", "root");
const linkLayer = root.selectAll("g.links").data([0]).join("g").attr("class", "links");
const nodeLayer = root.selectAll("g.nodes").data([0]).join("g").attr("class", "nodes");
const labelLayer = root.selectAll("g.labels").data([0]).join("g").attr("class", "labels");
linkSel = linkLayer.selectAll("line");
nodeSel = nodeLayer.selectAll("circle");
labelSel = labelLayer.selectAll("text");
simulation = d3.forceSimulation(nodes)
.force("link", d3.forceLink(links).id(d => d.id).distance(90).strength(0.45))
.force("charge", d3.forceManyBody().strength(-220))
.force("center", d3.forceCenter(w / 2, h / 2))
.on("tick", () => {
linkSel
.attr("x1", d => d.source.x)
.attr("y1", d => d.source.y)
.attr("x2", d => d.target.x)
.attr("y2", d => d.target.y);
nodeSel.attr("cx", d => d.x).attr("cy", d => d.y);
labelSel.attr("x", d => d.x).attr("y", d => d.y + 4);
});
}
function syncGraph() {
if (!simulation) ensureSimulation();
linkSel = svg.select("g.links").selectAll("line")
.data(links, d => {
const s = d.source.id ?? d.source;
const t = d.target.id ?? d.target;
return `${s}${t}`;
});
linkSel.exit().remove();
const linkEnter = linkSel.enter().append("line");
linkSel = linkEnter.merge(linkSel);
nodeSel = svg.select("g.nodes").selectAll("circle")
.data(nodes, d => d.id);
nodeSel.exit()
.transition().duration(400)
.attr("r", 0)
.remove();
const nodeEnter = nodeSel.enter().append("circle")
.attr("r", 0)
.attr("class", d => `status-${d.status || "idle"}`)
.call(d3.drag()
.on("start", (ev, d) => {
if (!ev.active) simulation.alphaTarget(0.35).restart();
d.fx = d.x; d.fy = d.y;
})
.on("drag", (ev, d) => { d.fx = ev.x; d.fy = ev.y; })
.on("end", (ev, d) => {
if (!ev.active) simulation.alphaTarget(0);
d.fx = null; d.fy = null;
}));
nodeEnter.transition().duration(500).attr("r", 10);
nodeSel = nodeEnter.merge(nodeSel)
.attr("class", d => `status-${d.status || "idle"}`)
.on("mouseenter", (ev, d) => {
tooltip.classed("visible", true)
.html(`<strong>${escapeHtml(d.label || d.id)}</strong><br/>
id: ${escapeHtml(d.id)}<br/>
status: ${escapeHtml(d.status || "idle")}<br/>
latency: ${d.latency_ms != null ? d.latency_ms + " ms" : "—"}`);
})
.on("mousemove", (ev) => {
tooltip.style("left", (ev.clientX + 14) + "px").style("top", (ev.clientY + 10) + "px");
})
.on("mouseleave", () => tooltip.classed("visible", false));
labelSel = svg.select("g.labels").selectAll("text")
.data(nodes, d => d.id);
labelSel.exit().remove();
const labelEnter = labelSel.enter().append("text")
.attr("text-anchor", "middle")
.text(d => d.label || d.id.slice(0, 8));
labelSel = labelEnter.merge(labelSel).text(d => d.label || d.id.slice(0, 8));
simulation.nodes(nodes);
simulation.force("link").links(links);
simulation.alpha(1).restart();
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
function resolveLinkEnds(link) {
const sid = typeof link.source === "object" ? link.source.id : link.source;
const tid = typeof link.target === "object" ? link.target.id : link.target;
const s = nodes.find(n => n.id === sid);
const t = nodes.find(n => n.id === tid);
if (!s || !t) return null;
return { source: s, target: t };
}
function flashHop(fromId, toId) {
svg.select("g.links").selectAll("line").each(function (d) {
const sid = d.source.id ?? d.source;
const tid = d.target.id ?? d.target;
if ((sid === fromId && tid === toId) || (sid === toId && tid === fromId)) {
const el = d3.select(this);
el.classed("hop-flash", true);
setTimeout(() => el.classed("hop-flash", false), 420);
}
});
}
function applyEvent(obj) {
if (!obj || typeof obj.type !== "string") return;
if (obj.type === "snapshot") {
nodes = (obj.nodes || []).map(n => ({
id: n.id,
label: n.label || n.id,
status: n.status || "idle",
latency_ms: n.latency_ms
}));
const rawLinks = obj.links || [];
links = rawLinks
.map(L => resolveLinkEnds({ source: L.source, target: L.target }))
.filter(Boolean);
syncGraph();
return;
}
if (obj.type === "node_join") {
const i = nodes.findIndex(n => n.id === obj.id);
const rec = {
id: obj.id,
label: obj.label || obj.id,
status: obj.status || "active",
latency_ms: obj.latency_ms
};
if (i >= 0) nodes[i] = rec;
else nodes.push(rec);
syncGraph();
return;
}
if (obj.type === "node_leave") {
nodes = nodes.filter(n => n.id !== obj.id);
links = links.filter(l => {
const a = l.source.id || l.source;
const b = l.target.id || l.target;
return a !== obj.id && b !== obj.id;
});
syncGraph();
return;
}
if (obj.type === "node_status") {
const n = nodes.find(x => x.id === obj.id);
if (n) {
if (obj.status) n.status = obj.status;
if (obj.latency_ms != null) n.latency_ms = obj.latency_ms;
syncGraph();
}
return;
}
if (obj.type === "hop") {
flashHop(obj.from, obj.to);
return;
}
}
function handleLine(line) {
line = line.trim();
if (!line || line[0] === "#") return;
try {
applyEvent(JSON.parse(line));
} catch (e) {
log("bad JSON: " + line.slice(0, 80));
}
}
function stopDemo() {
if (demoTimer) {
clearInterval(demoTimer);
demoTimer = null;
}
}
function startDemo() {
stopDemo();
disconnectWs();
setMode("demo");
log("Demo mode: synthetic joins/leaves and hops");
let tick = 0;
const pool = [
{ id: "n1", label: "alpha", status: "active", latency_ms: 11 },
{ id: "n2", label: "beta", status: "active", latency_ms: 19 },
{ id: "n3", label: "gamma", status: "idle", latency_ms: 52 },
{ id: "n4", label: "delta", status: "active", latency_ms: 27 }
];
let present = new Set(["n1", "n2"]);
function emitSnapshot() {
const snapNodes = pool.filter(n => present.has(n.id));
const L = [];
if (present.has("n1") && present.has("n2")) L.push({ source: "n1", target: "n2" });
if (present.has("n2") && present.has("n3")) L.push({ source: "n2", target: "n3" });
if (present.has("n3") && present.has("n4")) L.push({ source: "n3", target: "n4" });
if (present.has("n2") && present.has("n4")) L.push({ source: "n2", target: "n4" });
applyEvent({ type: "snapshot", nodes: snapNodes, links: L });
}
emitSnapshot();
demoTimer = setInterval(() => {
tick++;
if (tick % 12 === 2) present.add("n3");
if (tick % 12 === 5) present.add("n4");
if (tick % 12 === 8) present.delete("n3");
if (tick % 12 === 10) {
applyEvent({ type: "node_status", id: "n2", status: "error", latency_ms: 800 });
} else if (tick % 12 === 11) {
applyEvent({ type: "node_status", id: "n2", status: "active", latency_ms: 19 });
}
emitSnapshot();
const pairs = [["n1", "n2"], ["n2", "n3"], ["n2", "n4"], ["n3", "n4"]];
const [a, b] = pairs[tick % pairs.length];
if (present.has(a) && present.has(b)) {
applyEvent({ type: "hop", from: a, to: b, ms: 10 + (tick % 35) });
}
}, 850);
}
function disconnectWs() {
if (ws) {
ws.close();
ws = null;
}
if (mode === "ws") setMode("off");
}
function connectWs() {
stopDemo();
disconnectWs();
const url = document.getElementById("ws-url").value.trim();
try {
ws = new WebSocket(url);
} catch (e) {
log("WebSocket error: " + e);
return;
}
setMode("ws");
ws.onopen = () => log("WebSocket open " + url);
ws.onclose = () => { log("WebSocket closed"); if (mode === "ws") setMode("off"); };
ws.onerror = () => log("WebSocket error");
ws.onmessage = (ev) => handleLine(ev.data);
}
document.getElementById("btn-connect").onclick = connectWs;
document.getElementById("btn-disconnect").onclick = () => { stopDemo(); disconnectWs(); setMode("off"); };
document.getElementById("btn-demo").onclick = startDemo;
document.getElementById("file-jsonl").onchange = (ev) => {
const f = ev.target.files[0];
if (!f) return;
stopDemo();
disconnectWs();
setMode("file");
const r = new FileReader();
r.onload = () => {
String(r.result).split("\n").forEach(handleLine);
log("Loaded file " + f.name);
};
r.readAsText(f);
};
window.addEventListener("resize", resize);
resize();
ensureSimulation();
startDemo();
})();
</script>
</body>
</html>

7
viz/sample-feed.jsonl Normal file
View File

@@ -0,0 +1,7 @@
{"type":"snapshot","nodes":[{"id":"relay-a","label":"relay-a","status":"active","latency_ms":14},{"id":"relay-b","label":"relay-b","status":"active","latency_ms":21},{"id":"edge-c","label":"edge-c","status":"idle","latency_ms":48}],"links":[{"source":"relay-a","target":"relay-b"},{"source":"relay-b","target":"edge-c"}]}
{"type":"hop","from":"relay-a","to":"relay-b","ms":18}
{"type":"hop","from":"relay-b","to":"edge-c","ms":33}
{"type":"node_status","id":"edge-c","status":"error","latency_ms":500}
{"type":"node_status","id":"edge-c","status":"idle","latency_ms":55}
{"type":"node_leave","id":"edge-c"}
{"type":"snapshot","nodes":[{"id":"relay-a","label":"relay-a","status":"active","latency_ms":14},{"id":"relay-b","label":"relay-b","status":"active","latency_ms":21}],"links":[{"source":"relay-a","target":"relay-b"}]}