aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-10-30 18:56:38 +0100
committertv <tv@krebsco.de>2026-10-30 18:56:38 +0100
commitf5fa9b16fe5505e0058723835d550d73c11391f7 (patch)
treeaf1f34108780a9aba48eb3ca489fd974009473e0 /src
parent92f4a4548d0499a5ea32676ef04008135ecf6361 (diff)
dashboard: add hover and focus over links
Diffstat (limited to 'src')
-rw-r--r--src/api/dashboard/dashboard.html77
1 files changed, 68 insertions, 9 deletions
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 }));
}