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:
14
viz/bridge/Cargo.toml
Normal file
14
viz/bridge/Cargo.toml
Normal 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
250
viz/bridge/src/main.rs
Normal 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
493
viz/mesh-graph.html
Normal 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 => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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
7
viz/sample-feed.jsonl
Normal 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"}]}
|
||||
Reference in New Issue
Block a user