aboutsummaryrefslogtreecommitdiffstats
path: root/src/api
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-10-31 22:57:27 +0100
committertv <tv@krebsco.de>2026-10-31 22:57:27 +0100
commit2f18a3c0f3173a0f7c4a72308d5697fe42421354 (patch)
tree7fcbb3685488f4b4c06ba6b2ad09d5f52308a933 /src/api
parent056c83d523ddb1c5d5b643ae83b94a793f5fde4a (diff)
dashboard: show message flow
Diffstat (limited to 'src/api')
-rw-r--r--src/api/dashboard/dashboard.html96
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.