aboutsummaryrefslogtreecommitdiffstats
path: root/src/api/dashboard/dashboard.html
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-10-30 17:42:26 +0100
committertv <tv@krebsco.de>2026-10-30 17:42:26 +0100
commitf65ecfdf313658a3979a2d0add53e342ce4b97df (patch)
tree57320539da53c39c3d0d37870f0f65ba33ca7fc8 /src/api/dashboard/dashboard.html
parentb224eee76a9314992ec7a83321803e11a0b0b37f (diff)
add dashboard
Diffstat (limited to 'src/api/dashboard/dashboard.html')
-rw-r--r--src/api/dashboard/dashboard.html422
1 files changed, 422 insertions, 0 deletions
diff --git a/src/api/dashboard/dashboard.html b/src/api/dashboard/dashboard.html
new file mode 100644
index 0000000..c87341f
--- /dev/null
+++ b/src/api/dashboard/dashboard.html
@@ -0,0 +1,422 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>continuum</title>
+<style>
+ :root {
+ --bg: #fafaf9; --fg: #1c1917; --muted: #78716c; --line: #e7e5e4; --panel: #ffffff;
+ --ok: #15803d; --warn: #c2410c; --bad: #b91c1c; --intended: #a8a29e; --self: #1d4ed8;
+ }
+ @media (prefers-color-scheme: dark) {
+ :root {
+ --bg: #1c1917; --fg: #f5f5f4; --muted: #a8a29e; --line: #44403c; --panel: #292524;
+ --ok: #4ade80; --warn: #fb923c; --bad: #f87171; --intended: #57534e; --self: #60a5fa;
+ }
+ }
+ * { box-sizing: border-box; }
+ html { background: var(--bg); }
+ /* The window's height, unless it is way too small: then the page scrolls. */
+ body { margin: 0; background: var(--bg); color: var(--fg);
+ font: 14px/1.4 system-ui, sans-serif;
+ height: 100vh; min-height: 480px; display: flex; flex-direction: column; }
+ header { display: flex; gap: 1em; align-items: baseline; padding: 12px 16px;
+ border-bottom: 1px solid var(--line); flex-wrap: wrap; }
+ header h1 { font-size: 16px; margin: 0; }
+ #connection { margin-left: auto; color: var(--muted); }
+ #connection.live { color: var(--ok); }
+ #connection.down { color: var(--bad); }
+ /* Swarm on the left, the full height; peers (as tall as their rows, up to 8) above
+ the figures (the rest) on the right. */
+ main { flex: 1; min-height: 0; display: grid; gap: 16px; padding: 16px;
+ grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
+ grid-template-rows: auto minmax(0, 1fr);
+ grid-template-areas: "map peers" "map figures"; }
+ @media (max-width: 900px) {
+ body { height: auto; }
+ main { grid-template-columns: minmax(0, 1fr); grid-template-rows: 70vh auto 70vh;
+ grid-template-areas: "map" "peers" "figures"; }
+ }
+ section { background: var(--panel); border: 1px solid var(--line); border-radius: 6px;
+ padding: 12px; min-width: 0; min-height: 0; display: flex; flex-direction: column; }
+ section h2 { font-size: 13px; text-transform: uppercase; letter-spacing: .04em;
+ color: var(--muted); margin: 0 0 8px; flex: none; }
+ section h2 .what { text-transform: none; letter-spacing: 0; color: var(--fg); }
+ #map { grid-area: map; }
+ #peers-box { grid-area: peers; }
+ #figures-box { grid-area: figures; }
+ svg { flex: 1; min-height: 0; width: 100%; display: block; cursor: default; }
+ svg circle { cursor: pointer; }
+ .legend { color: var(--muted); font-size: 12px; display: flex; gap: 1em; flex-wrap: wrap;
+ flex: none; }
+ .legend span::before { content: ""; display: inline-block; width: 18px; height: 0;
+ vertical-align: middle; margin-right: 4px; border-top: 2px solid var(--colour, currentColor); }
+ .legend .intended::before { border-top-style: dashed; --colour: var(--intended); }
+ .legend .direct::before { --colour: var(--muted); }
+ .legend .relayed::before { border-top-style: dashed; --colour: var(--muted); }
+ .legend .dormant::before { border-top-style: dotted; --colour: var(--bad); }
+ .scroll { overflow: auto; min-height: 0; }
+ #figures-box .scroll { flex: 1; }
+ table { border-collapse: collapse; width: 100%; font-variant-numeric: tabular-nums; }
+ th, td { text-align: left; padding: 3px 8px 3px 0; border-bottom: 1px solid var(--line);
+ white-space: nowrap; }
+ th { color: var(--muted); font-weight: 500; position: sticky; top: 0;
+ background: var(--panel); }
+ #peers tr[data-peer] { cursor: pointer; }
+ #peers tr.focused td { background: var(--line); }
+ .id { font-family: ui-monospace, monospace; font-size: 12px; }
+ .active { color: var(--ok); } .dormant { color: var(--bad); } .unknown { color: var(--muted); }
+ details { margin-left: 1em; }
+ summary { cursor: pointer; }
+ .tree { font-family: ui-monospace, monospace; font-size: 12px; }
+ .tree > details { margin-left: 0; }
+ .key { color: var(--muted); }
+</style>
+</head>
+<body>
+<header>
+ <h1>continuum</h1>
+ <span id="node" class="id"></span>
+ <span id="connection">connecting…</span>
+</header>
+<main>
+ <section id="map">
+ <h2>Swarm</h2>
+ <div class="legend" id="legend"></div>
+ <svg id="graph"></svg>
+ </section>
+ <section id="peers-box">
+ <h2>Peers</h2>
+ <div class="scroll" id="peers-scroll"><table id="peers"></table></div>
+ </section>
+ <section id="figures-box">
+ <h2 id="figures-title">Figures</h2>
+ <div class="scroll"><div id="stats" class="tree"></div></div>
+ </section>
+</main>
+<script>
+"use strict";
+// The state as the node published it, and when it did (the last event).
+let state = null;
+let stateAt = 0;
+
+// Fields that grow by themselves (`GROWING` and `is_age` in the node's dashboard).
+const GROWING = new Set(["uptimeSecs", "dormantForSecs", "observedSecs",
+ "lastFromNeighbourSecs", "oldestQuarantineSecs"]);
+const isAge = key => key === "secsAgo" || key.endsWith("SecsAgo") || GROWING.has(key);
+const elapsed = () => Math.floor((Date.now() - stateAt) / 1000);
+const aged = (key, value) =>
+ isAge(key) && typeof value === "number" ? value + elapsed() : value;
+
+// RFC 7386.
+function mergePatch(target, patch) {
+ if (patch === null || typeof patch !== "object" || Array.isArray(patch)) return patch;
+ const result = target !== null && typeof target === "object" && !Array.isArray(target)
+ ? target : {};
+ for (const [key, value] of Object.entries(patch)) {
+ if (value === null) delete result[key];
+ else result[key] = mergePatch(result[key], value);
+ }
+ return result;
+}
+
+function el(tag, attrs, ...children) {
+ const node = document.createElement(tag);
+ for (const [key, value] of Object.entries(attrs || {})) node.setAttribute(key, value);
+ for (const child of children) {
+ node.append(child instanceof Node ? child : document.createTextNode(String(child)));
+ }
+ return node;
+}
+const SVG = "http://www.w3.org/2000/svg";
+function svg(tag, attrs) {
+ const node = document.createElementNS(SVG, tag);
+ for (const [key, value] of Object.entries(attrs || {})) node.setAttribute(key, value);
+ return node;
+}
+
+const short = id => id.length > 14 ? id.slice(0, 6) + "…" + id.slice(-6) : id;
+function name(id) {
+ const member = state.graph.members[id];
+ return (member && member.alias) || (state.peers.peers[id] || {}).alias || short(id);
+}
+function age(secs) {
+ if (secs === null || secs === undefined) return "";
+ if (secs < 120) return secs + " s";
+ if (secs < 7200) return Math.floor(secs / 60) + " min";
+ if (secs < 172800) return Math.floor(secs / 3600) + " h";
+ return Math.floor(secs / 86400) + " d";
+}
+
+// The node in focus: its figures are shown. null: this node's (`/admin/stats`).
+let focused = null;
+function focus(id) {
+ focused = id === null || id === state.node.id ? null : id;
+ render();
+}
+
+// --- Peers ---
+function renderPeers() {
+ const table = document.getElementById("peers");
+ const rows = [el("tr", {}, ...["peer", "status", "seen", "via", "rtt", "neighbour", "same epoch"]
+ .map(h => el("th", {}, h)))];
+ for (const [id, peer] of Object.entries(state.peers.peers)) {
+ const destination = peer.destination;
+ const path = destination && destination.paths && destination.paths[0];
+ const rtt = path && typeof path.rttMs === "number" ? path.rttMs.toFixed(1) + " ms" : "";
+ const via = destination
+ ? destination.address + (destination.connection ? " (" + destination.connection + ")" : "")
+ : "";
+ const row = el("tr", { "data-peer": id, class: id === focused ? "focused" : "" },
+ el("td", { title: id }, peer.alias || el("span", { class: "id" }, short(id))),
+ el("td", { class: peer.status }, peer.status +
+ (peer.status === "dormant" ? " " + age(aged("dormantForSecs", peer.dormantForSecs)) : "")),
+ el("td", {}, age(aged("lastSeenSecsAgo", peer.lastSeenSecsAgo))),
+ el("td", { class: "id" }, via),
+ el("td", {}, rtt),
+ el("td", {}, peer.neighbour ? "yes" : ""),
+ el("td", {}, peer.sameEpoch === null || peer.sameEpoch === undefined
+ ? "" : peer.sameEpoch ? "yes" : "no"));
+ row.addEventListener("click", () => focus(id === focused ? null : id));
+ rows.push(row);
+ }
+ table.replaceChildren(...rows);
+ // As tall as the rows, up to 8 of them (and the header); then it scrolls.
+ const scroll = document.getElementById("peers-scroll");
+ scroll.style.maxHeight = rows.length > 9
+ ? (rows[8].offsetTop + rows[8].offsetHeight) + "px" : "";
+}
+
+// --- Figures: a tree, its open branches kept across renders ---
+const open = new Set(["stats", "peer", "links"]);
+function tree(key, value, path) {
+ if (value !== null && typeof value === "object") {
+ const details = el("details", {});
+ if (open.has(path)) details.open = true;
+ details.addEventListener("toggle", () => {
+ if (details.open) open.add(path); else open.delete(path);
+ });
+ const entries = Array.isArray(value) ? value.map((v, i) => [String(i), v]) : Object.entries(value);
+ details.append(el("summary", {}, el("span", { class: "key" }, key),
+ Array.isArray(value) ? " [" + value.length + "]" : ""));
+ for (const [k, v] of entries) details.append(tree(k, v, path + "." + k));
+ return details;
+ }
+ return el("div", {}, el("span", { class: "key" }, key + ": "),
+ JSON.stringify(aged(key, value)));
+}
+function renderStats() {
+ const title = document.getElementById("figures-title");
+ const what = el("span", { class: "what" });
+ let shown;
+ if (focused === null) {
+ what.textContent = name(state.node.id) + " (this node)";
+ shown = [tree("stats", state.stats, "stats")];
+ } else {
+ what.textContent = name(focused);
+ what.title = focused;
+ const peer = state.peers.peers[focused];
+ const report = state.graph.measured[focused];
+ shown = [];
+ if (peer) shown.push(tree("peer", peer, "peer"));
+ else shown.push(el("div", { class: "key" }, "Not a peer of this node."));
+ if (report) shown.push(tree("links", report, "links"));
+ }
+ title.replaceChildren(focused === null ? "Figures: " : "Peer: ", what);
+ document.getElementById("stats").replaceChildren(...shown);
+}
+
+// --- Swarm map: a small force layout, positions kept across renders ---
+const positions = new Map();
+const transportColour = new Map([["mycelium", "#2563eb"], ["libp2p", "#9333ea"],
+ ["tor", "#0891b2"]]);
+const SPARE = ["#ca8a04", "#db2777", "#65a30d"];
+function colourOf(transport) {
+ if (!transport) return "var(--muted)";
+ if (!transportColour.has(transport)) {
+ transportColour.set(transport, SPARE[(transportColour.size - 3) % SPARE.length]);
+ }
+ return transportColour.get(transport);
+}
+
+function renderLegend(measured) {
+ const transports = new Set();
+ for (const { link } of measured.values()) transports.add(link.transport || "");
+ const items = [el("span", { class: "intended" }, "intended (member list)")];
+ for (const transport of [...transports].sort()) {
+ const item = el("span", {}, transport || "unknown transport");
+ item.style.setProperty("--colour", colourOf(transport || null));
+ items.push(item);
+ }
+ items.push(el("span", { class: "direct" }, "direct"),
+ el("span", { class: "relayed" }, "relayed"),
+ el("span", { class: "dormant" }, "dormant"));
+ document.getElementById("legend").replaceChildren(...items);
+}
+
+function edges() {
+ const intended = new Map();
+ for (const [a, neighbours] of Object.entries(state.graph.intended)) {
+ for (const b of neighbours) {
+ const key = a < b ? a + " " + b : b + " " + a;
+ intended.set(key, [a, b]);
+ }
+ }
+ // One measured edge per pair: the fresher report wins.
+ const measured = new Map();
+ for (const [a, report] of Object.entries(state.graph.measured)) {
+ for (const link of report.links) {
+ const key = a < link.peer ? a + " " + link.peer : link.peer + " " + a;
+ const known = measured.get(key);
+ if (!known || report.secsAgo < known.secsAgo) {
+ measured.set(key, { a, b: link.peer, link, secsAgo: report.secsAgo });
+ }
+ }
+ }
+ return { intended, measured };
+}
+
+function layout(nodes, pairs, width, height) {
+ for (const id of nodes) {
+ if (!positions.has(id)) {
+ positions.set(id, { x: width / 2 + (Math.random() - .5) * width / 2,
+ y: height / 2 + (Math.random() - .5) * height / 2 });
+ }
+ }
+ const ideal = Math.min(width, height) / Math.max(2, Math.sqrt(nodes.length) * 1.5);
+ for (let round = 0; round < 120; round++) {
+ const force = new Map(nodes.map(id => [id, { x: 0, y: 0 }]));
+ for (let i = 0; i < nodes.length; i++) {
+ for (let j = i + 1; j < nodes.length; j++) {
+ const p = positions.get(nodes[i]), q = positions.get(nodes[j]);
+ const dx = p.x - q.x, dy = p.y - q.y, d2 = Math.max(dx * dx + dy * dy, 1);
+ const push = ideal * ideal / d2;
+ force.get(nodes[i]).x += dx * push / 10; force.get(nodes[i]).y += dy * push / 10;
+ force.get(nodes[j]).x -= dx * push / 10; force.get(nodes[j]).y -= dy * push / 10;
+ }
+ }
+ for (const [a, b] of pairs) {
+ const p = positions.get(a), q = positions.get(b);
+ if (!p || !q) continue;
+ const dx = q.x - p.x, dy = q.y - p.y, d = Math.max(Math.hypot(dx, dy), 1);
+ const pull = (d - ideal) / d / 4;
+ force.get(a).x += dx * pull; force.get(a).y += dy * pull;
+ force.get(b).x -= dx * pull; force.get(b).y -= dy * pull;
+ }
+ for (const id of nodes) {
+ const p = positions.get(id), f = force.get(id);
+ p.x = Math.min(width - 40, Math.max(40, p.x + Math.max(-10, Math.min(10, f.x))));
+ p.y = Math.min(height - 20, Math.max(20, p.y + Math.max(-10, Math.min(10, f.y))));
+ }
+ }
+}
+
+let lastShape = "";
+function renderGraph() {
+ const root = document.getElementById("graph");
+ const width = root.clientWidth || 800, height = root.clientHeight || 560;
+ root.setAttribute("viewBox", "0 0 " + width + " " + height);
+ const nodes = Object.keys(state.graph.members).sort();
+ const { intended, measured } = edges();
+ renderLegend(measured);
+ const pairs = [...intended.values(), ...[...measured.values()].map(m => [m.a, m.b])];
+ // Laid out anew only when who's there or who links to whom changed.
+ const shape = nodes.join(",") + "|" + [...intended.keys(), ...measured.keys()].sort().join(",");
+ if (shape !== lastShape) {
+ for (const id of [...positions.keys()]) if (!nodes.includes(id)) positions.delete(id);
+ layout(nodes, pairs, width, height);
+ lastShape = shape;
+ }
+ const children = [];
+ for (const [key, [a, b]] of intended) {
+ if (measured.has(key)) continue;
+ const p = positions.get(a), q = positions.get(b);
+ children.push(svg("line", { x1: p.x, y1: p.y, x2: q.x, y2: q.y,
+ stroke: "var(--intended)", "stroke-dasharray": "4 4", "stroke-width": 1 }));
+ }
+ for (const { a, b, link, secsAgo } of measured.values()) {
+ const p = positions.get(a), q = positions.get(b);
+ if (!p || !q) continue;
+ const line = svg("line", { x1: p.x, y1: p.y, x2: q.x, y2: q.y,
+ stroke: link.dormant ? "var(--bad)" : colourOf(link.transport), "stroke-width": 2 });
+ // Dotted where dormant, dashed where relayed, solid where direct.
+ if (link.dormant) {
+ line.setAttribute("stroke-dasharray", "1 4");
+ line.setAttribute("stroke-linecap", "round");
+ } else if (link.relayed) {
+ line.setAttribute("stroke-dasharray", "7 4");
+ }
+ const title = svg("title");
+ title.textContent = name(a) + " – " + name(b) + (link.transport ? " via " + link.transport : "") +
+ (link.relayed ? ", relayed" : "") + (link.dormant ? ", dormant" : "") +
+ (typeof link.rttMs === "number" ? ", " + link.rttMs.toFixed(1) + " ms" : "") +
+ ", measured " + age(secsAgo + elapsed()) + " ago";
+ line.append(title);
+ children.push(line);
+ if (typeof link.rttMs === "number") {
+ const label = svg("text", { x: (p.x + q.x) / 2, y: (p.y + q.y) / 2 - 3,
+ "text-anchor": "middle", "font-size": 10, fill: "var(--muted)" });
+ label.textContent = link.rttMs < 10 ? link.rttMs.toFixed(1) : Math.round(link.rttMs);
+ children.push(label);
+ }
+ }
+ for (const id of nodes) {
+ const p = positions.get(id), member = state.graph.members[id];
+ const reported = id in state.graph.measured;
+ const shown = focused === null ? member.self : id === focused;
+ if (shown) {
+ children.push(svg("circle", { cx: p.x, cy: p.y, r: 13, fill: "none",
+ stroke: "var(--self)", "stroke-width": 2, "stroke-opacity": .5 }));
+ }
+ const circle = svg("circle", { cx: p.x, cy: p.y, r: member.self ? 8 : 6,
+ fill: member.self ? "var(--self)" : reported ? "var(--fg)" : "var(--panel)",
+ stroke: member.self ? "var(--self)" : "var(--fg)", "stroke-width": 1.5 });
+ const title = svg("title");
+ title.textContent = id + (reported ? "" : " (no links reported)");
+ circle.append(title);
+ circle.addEventListener("click", event => { event.stopPropagation(); focus(id); });
+ const label = svg("text", { x: p.x + 10, y: p.y + 4, "font-size": 12, fill: "var(--fg)" });
+ label.textContent = name(id);
+ label.style.cursor = "pointer";
+ label.addEventListener("click", event => { event.stopPropagation(); focus(id); });
+ children.push(circle, label);
+ }
+ root.replaceChildren(...children);
+}
+
+function render() {
+ if (!state) return;
+ document.getElementById("node").textContent =
+ (state.node.alias ? state.node.alias + " · " : "") + state.node.id;
+ renderPeers();
+ renderStats();
+ renderGraph();
+}
+
+// A click beside the nodes: back to this node.
+document.getElementById("graph").addEventListener("click", () => focus(null));
+
+// --- Events ---
+const connection = document.getElementById("connection");
+const events = new EventSource("dashboard/events");
+events.addEventListener("open", () => {
+ connection.textContent = "live"; connection.className = "live";
+});
+events.addEventListener("error", () => {
+ // EventSource reconnects by itself; the node sends a snapshot first again.
+ connection.textContent = "reconnecting…"; connection.className = "down";
+});
+events.addEventListener("snapshot", event => {
+ state = JSON.parse(event.data); stateAt = Date.now(); render();
+});
+events.addEventListener("patch", event => {
+ if (!state) return;
+ state = mergePatch(state, JSON.parse(event.data)); stateAt = Date.now(); render();
+});
+// Ages count on between events; nothing else changes without one.
+setInterval(render, 1000);
+window.addEventListener("resize", () => { lastShape = ""; render(); });
+</script>
+</body>
+</html>