From f5fa9b16fe5505e0058723835d550d73c11391f7 Mon Sep 17 00:00:00 2001 From: tv Date: Fri, 30 Oct 2026 18:56:38 +0100 Subject: dashboard: add hover and focus over links --- src/api/dashboard/dashboard.html | 77 +++++++++++++++++++++++++++++++++++----- 1 file changed, 68 insertions(+), 9 deletions(-) (limited to 'src/api/dashboard/dashboard.html') diff --git a/src/api/dashboard/dashboard.html b/src/api/dashboard/dashboard.html index 65fa95b..11e53d2 100644 --- a/src/api/dashboard/dashboard.html +++ b/src/api/dashboard/dashboard.html @@ -165,8 +165,11 @@ function age(secs) { return Math.floor(secs / 86400) + " d"; } -// The node in focus: its figures are shown. null: this node's (`/admin/stats`). +// What is in focus, its figures shown: a node's ID, or a link's pair key (`pairKey`, +// two IDs). null: this node's (`/admin/stats`). let focused = null; +const pairKey = (a, b) => a < b ? a + " " + b : b + " " + a; +const isPair = key => key !== null && key.includes(" "); function focus(id) { focused = id === null || id === state.node.id ? null : id; render(); @@ -205,7 +208,7 @@ function renderPeers() { } // --- Figures: a tree, its open branches kept across renders --- -const open = new Set(["stats", "peer", "links"]); +const open = new Set(["stats", "peer", "links", "from", "to"]); function tree(key, value, path) { if (value !== null && typeof value === "object") { const details = el("details", {}); @@ -226,6 +229,28 @@ function renderStats() { const title = document.getElementById("figures-title"); const what = el("span", { class: "what" }); let shown; + if (isPair(focused)) { + const [a, b] = focused.split(" "); + what.textContent = name(a) + " – " + name(b); + what.title = focused; + const listed = (x, y) => (state.graph.intended[x] || []).includes(y); + shown = [el("div", {}, el("span", { class: "key" }, "intended: "), + listed(a, b) || listed(b, a) ? "yes (member list)" : "no")]; + // Each end's view of it, as it reported it. + for (const [x, y, path] of [[a, b, "from"], [b, a, "to"]]) { + const report = state.graph.measured[x]; + const link = report && report.links.find(link => link.peer === y); + if (link) { + shown.push(tree(name(x) + " reports", { secsAgo: report.secsAgo, ...link }, path)); + } else { + shown.push(el("div", { class: "key" }, name(x) + (report + ? " reports no link to " + name(y) + "." : " reports no links."))); + } + } + title.replaceChildren("Link: ", what); + document.getElementById("stats").replaceChildren(...shown); + return; + } if (focused === null) { what.textContent = name(state.node.id) + " (this node)"; shown = [tree("stats", state.stats, "stats")]; @@ -280,15 +305,14 @@ 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]); + intended.set(pairKey(a, b), [a, b]); } } // One measured edge per pair: a report with an RTT, then the fresher. 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 key = pairKey(a, link.peer); const known = measured.get(key); const timed = typeof link.rttMs === "number"; const knownTimed = known && typeof known.link.rttMs === "number"; @@ -336,7 +360,7 @@ function layout(nodes, pairs, width, height) { } } -// The node under the pointer, if any. The map is drawn anew every second, so it is +// The node or link (its pair key) under the pointer, if any. The map is drawn anew every second, so it is // kept here rather than left to :hover. let hovered = null; function hover(id) { @@ -366,19 +390,47 @@ function renderGraph() { lastShape = shape; } const children = []; + // Behind a link: a halo while it's hovered or in focus (`width`: of its lines). + const halo = (key, p, q, width) => { + if (key !== hovered && key !== focused) return; + children.push(svg("line", { x1: p.x, y1: p.y, x2: q.x, y2: q.y, stroke: "var(--self)", + "stroke-opacity": key === focused ? .35 : .15, "stroke-width": width + 8, + "stroke-linecap": "round" })); + }; + // Over a link: an invisible, wider line to point at and click. + const hits = []; + const hit = (key, p, q, width) => { + const line = svg("line", { x1: p.x, y1: p.y, x2: q.x, y2: q.y, stroke: "transparent", + "stroke-width": width + 8 }); + line.style.cursor = "pointer"; + line.addEventListener("click", event => { event.stopPropagation(); focus(key); }); + hoverable(line, key); + hits.push(line); + return line; + }; for (const [key, [a, b]] of intended) { if (measured.has(key)) continue; const p = positions.get(a), q = positions.get(b); + halo(key, p, q, 1); 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 })); + const tooltip = svg("title"); + tooltip.textContent = name(a) + " – " + name(b) + ": intended (member list), not measured"; + hit(key, p, q, 1).append(tooltip); } const ms = rtt => rtt < 10 ? rtt.toFixed(1) : String(Math.round(rtt)); - for (const { a, b, link, secsAgo } of measured.values()) { + for (const [key, { a, b, link, secsAgo }] of measured) { const p = positions.get(a), q = positions.get(b); if (!p || !q) continue; // The transport in use thick, in the middle; the others thin, beside it. const lines = [{ transport: link.transport, rttMs: link.rttMs, used: true }]; for (const other of link.others || []) lines.push({ ...other, used: false }); + // As wide as the lines side by side. + const width = 3 + 12 * Math.ceil((lines.length - 1) / 2); + halo(key, p, q, width); + // It covers the lines: their descriptions are its tooltip. + const covering = hit(key, p, q, width); + const descriptions = []; const length = Math.max(Math.hypot(q.x - p.x, q.y - p.y), 1); const nx = -(q.y - p.y) / length, ny = (q.x - p.x) / length; lines.forEach((line, i) => { @@ -401,7 +453,7 @@ function renderGraph() { ((line.used ? link.relayed : line.relayed) ? ", relayed" : "") + (link.dormant ? ", dormant" : "") + (typeof line.rttMs === "number" ? ", " + line.rttMs.toFixed(1) + " ms" : "") + ", measured " + age(secsAgo + elapsed()) + " ago"; - drawn.append(title); + descriptions.push(title.textContent); children.push(drawn); if (typeof line.rttMs === "number") { // The one in use at the middle, larger; the others further along, beside theirs. @@ -416,17 +468,24 @@ function renderGraph() { children.push(label); } }); + const tooltip = svg("title"); + tooltip.textContent = descriptions.join("\n"); + covering.append(tooltip); } + // Above the lines and their labels, below the nodes. + children.push(...hits); 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; + // A link in focus: both ends ringed. + const end = isPair(focused) && focused.split(" ").includes(id); if (id === hovered) { // Under the pointer: lighter than the focus ring. children.push(svg("circle", { cx: p.x, cy: p.y, r: 12, fill: "var(--self)", "fill-opacity": .12, stroke: "none" })); } - if (shown) { + if (shown || end) { children.push(svg("circle", { cx: p.x, cy: p.y, r: 13, fill: "none", stroke: "var(--self)", "stroke-width": 2, "stroke-opacity": .5 })); } -- cgit v1.3.1