diff options
Diffstat (limited to 'src/api/dashboard/dashboard.html')
| -rw-r--r-- | src/api/dashboard/dashboard.html | 422 |
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> |
