Files
quicproquo/viz/mesh-graph.html
Christian Nennemann 95ce8898fd 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
2026-04-06 21:43:28 +02:00

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 => ({ "&": "&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>