- 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
494 lines
14 KiB
HTML
494 lines
14 KiB
HTML
<!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>
|