diff options
| author | tv <tv@krebsco.de> | 2026-10-31 22:57:27 +0100 |
|---|---|---|
| committer | tv <tv@krebsco.de> | 2026-10-31 22:57:27 +0100 |
| commit | d88e539993edca4b0604b7e3819e84eaa1968d2c (patch) | |
| tree | 9be94724c8bfe963fd7fe8143a835153c03a783d /src/api | |
| parent | 1296a27e2ff6d04c3b45f08faef3e5132a330af0 (diff) | |
dashboard: show message flow
Diffstat (limited to 'src/api')
| -rw-r--r-- | src/api/dashboard/dashboard.html | 96 |
1 files changed, 93 insertions, 3 deletions
diff --git a/src/api/dashboard/dashboard.html b/src/api/dashboard/dashboard.html index 3e11524..db11345 100644 --- a/src/api/dashboard/dashboard.html +++ b/src/api/dashboard/dashboard.html @@ -156,7 +156,8 @@ <main> <section id="map"> <h2>Swarm <button id="legend-toggle" class="toggle">hide legend</button> - <button id="rtt-toggle" class="toggle">layout: static</button></h2> + <button id="rtt-toggle" class="toggle">layout: static</button> + <button id="flows-toggle" class="toggle">messages: on</button></h2> <div id="legends"> <div class="legend"> <span class="node self">this node</span> @@ -172,7 +173,7 @@ drag a node to move it, double-click it to pin it there or let it go</span> </div> </div> - <svg id="graph"><g id="view"></g></svg> + <svg id="graph"><g id="view"><g id="flows" pointer-events="none"></g></g></svg> </section> <section id="peers-box"> <h2>Peers</h2> @@ -1409,6 +1410,94 @@ function unpin(id) { graph.addEventListener("mousemove", event => { if (event.target === graph) hover(null); }); graph.addEventListener("mouseleave", () => hover(null)); +// --- Messages on the map: this node's traffic with each peer (`/peers`, `traffic`), +// as it grew since the last update, drawn as dots running along the link the way the +// messages went. Counts come with each update (every 2 s), so the dots are spread +// over the next 2 s, a few at most per link and way: a picture of the traffic, not a +// record of it. Kept above everything else on the map (`flows`, after what `show` +// places). --- +const FLOW_MS = 700, FLOW_SPREAD_MS = 2000, FLOWS_AT_MOST = 6; +// Shown or not (`flows-toggle`, kept in this browser; shown at first). +let flowsShown = true; +try { flowsShown = localStorage.getItem("continuum-flows") !== "0"; } catch (_) {} +const flowsToggle = document.getElementById("flows-toggle"); +function showFlowsToggle() { + flowsToggle.textContent = flowsShown ? "messages: on" : "messages: off"; + flowsToggle.title = "Click to " + (flowsShown ? "hide" : "show") + + " the messages running along this node's links"; +} +showFlowsToggle(); +flowsToggle.addEventListener("click", () => { + flowsShown = !flowsShown; + try { localStorage.setItem("continuum-flows", flowsShown ? "1" : "0"); } catch (_) {} + showFlowsToggle(); + if (!flowsShown) { + flows.length = 0; + lastTraffic = null; + document.getElementById("flows").replaceChildren(); + } +}); +let lastTraffic = null; +const flows = []; +let flowFrame = null; +function messages(byKind) { + return Object.values(byKind || {}).reduce((sum, counter) => sum + counter.messages, 0); +} +function trafficArrived() { + if (!state || !flowsShown || calm.matches || document.hidden) { + lastTraffic = null; + return; + } + const self = state.node.id, now = performance.now(); + const current = new Map(); + for (const [id, peer] of Object.entries(state.peers.peers)) { + if (peer.traffic) { + current.set(id, { sent: messages(peer.traffic.sent), + received: messages(peer.traffic.received) }); + } + } + if (lastTraffic) { + for (const [id, counts] of current) { + const before = lastTraffic.get(id); + if (!before) continue; + for (const [way, from, to] of [["sent", self, id], ["received", id, self]]) { + const count = Math.min(FLOWS_AT_MOST, Math.max(0, counts[way] - before[way])); + for (let i = 0; i < count; i++) { + flows.push({ from, to, start: now + i * FLOW_SPREAD_MS / count }); + } + } + } + } + lastTraffic = current; + if (flows.length > 0 && flowFrame === null) flowFrame = requestAnimationFrame(drawFlows); +} +function drawFlows() { + flowFrame = null; + const layer = document.getElementById("flows"); + const now = performance.now(); + for (let i = flows.length - 1; i >= 0; i--) { + if (now > flows[i].start + FLOW_MS) flows.splice(i, 1); + } + const dots = []; + for (const flow of flows) { + const p = positions.get(flow.from), q = positions.get(flow.to); + if (now < flow.start || !p || !q) continue; + const t = (now - flow.start) / FLOW_MS; + dots.push({ x: p.x + (q.x - p.x) * t, y: p.y + (q.y - p.y) * t, opacity: Math.sin(Math.PI * t) }); + } + while (layer.children.length < dots.length) layer.append(svg("circle")); + while (layer.children.length > dots.length) layer.lastChild.remove(); + dots.forEach((dot, i) => { + const circle = layer.children[i]; + circle.setAttribute("cx", dot.x); + circle.setAttribute("cy", dot.y); + circle.setAttribute("r", 3); + circle.setAttribute("fill", "var(--self)"); + circle.setAttribute("opacity", dot.opacity.toFixed(2)); + }); + if (flows.length > 0) flowFrame = requestAnimationFrame(drawFlows); +} + // --- Events --- const connection = document.getElementById("connection"); const dot = document.getElementById("dot"); @@ -1443,11 +1532,12 @@ function connect() { // Changes while not connected went unseen: what is open is read again. if (state !== null) documentChanged({ all: true }); state = snapshot; stateAt = Date.now(); live = true; - status("live", true); pulse(); render(); + status("live", true); pulse(); render(); trafficArrived(); }); events.addEventListener("patch", event => { if (!state) return; state = mergePatch(state, JSON.parse(event.data)); stateAt = Date.now(); pulse(); render(); + trafficArrived(); }); events.addEventListener("document", event => { pulse(); documentChanged(JSON.parse(event.data)); }); // The node stops: it says so, and ends the stream. Back once it is. |
