diff options
| author | tv <tv@krebsco.de> | 2026-10-31 05:58:32 +0100 |
|---|---|---|
| committer | tv <tv@krebsco.de> | 2026-10-31 05:58:32 +0100 |
| commit | 179f408b2a90b7ba5971ac178c357920d94af176 (patch) | |
| tree | 1200585c081233c0feb9296f5e684c27407eb445 /src | |
| parent | 197cee7f4da41a8f5181e314a90a26ae93e23a63 (diff) | |
dashboard: update graph in place
Diffstat (limited to 'src')
| -rw-r--r-- | src/api/dashboard/dashboard.html | 129 |
1 files changed, 82 insertions, 47 deletions
diff --git a/src/api/dashboard/dashboard.html b/src/api/dashboard/dashboard.html index dd764b6..9f191be 100644 --- a/src/api/dashboard/dashboard.html +++ b/src/api/dashboard/dashboard.html @@ -429,8 +429,8 @@ function layout(nodes, pairs, width, height) { } } -// 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. +// The node or link (its pair key) under the pointer, if any. Kept here rather than +// left to :hover: its halo is an element of its own, drawn behind it. let hovered = null; function hover(id) { if (hovered === id) return; @@ -442,6 +442,45 @@ function hoverable(element, id) { element.addEventListener("mouseleave", () => hover(null)); } +// The map's elements by what they show (a key), kept across renders and updated in +// place: the one under the pointer stays itself. +const drawn = new Map(); +// The element `key` names, with exactly `attrs`; `made` sets up a new one (its +// listeners). +function drawing(key, tag, attrs, made) { + let node = drawn.get(key); + if (!node) { + node = svg(tag); + if (made) made(node); + drawn.set(key, node); + } + for (const attr of node.getAttributeNames()) if (!(attr in attrs)) node.removeAttribute(attr); + for (const [attr, value] of Object.entries(attrs)) { + if (node.getAttribute(attr) !== String(value)) node.setAttribute(attr, value); + } + return node; +} +function text(node, content) { + if (node.textContent !== content) node.textContent = content; +} +function tooltip(node, content) { + let title = node.firstElementChild; + if (!title) node.append(title = svg("title")); + text(title, content); +} +// `root` showing `children`, in order: the others go, and only what is out of place +// moves. +function show(root, children) { + const shown = new Set(children); + for (const [key, node] of drawn) { + if (!shown.has(node)) { node.remove(); drawn.delete(key); } + } + let at = root.firstChild; + for (const node of children) { + if (node === at) at = at.nextSibling; else root.insertBefore(node, at); + } +} + let lastShape = ""; function renderGraph() { const root = document.getElementById("graph"); @@ -462,19 +501,19 @@ function renderGraph() { // 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" })); + children.push(drawing("halo " + key, "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"; - // On press: the map may be drawn anew before a click would complete. - line.addEventListener("pointerdown", event => { event.stopPropagation(); focus(key); }); - hoverable(line, key); + const line = drawing("hit " + key, "line", { x1: p.x, y1: p.y, x2: q.x, y2: q.y, + stroke: "transparent", "stroke-width": width + 8, cursor: "pointer" }, line => { + // On press: the map may be drawn anew before a click would complete. + line.addEventListener("pointerdown", event => { event.stopPropagation(); focus(key); }); + hoverable(line, key); + }); hits.push(line); return line; }; @@ -482,11 +521,9 @@ function renderGraph() { 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, + children.push(drawing("intended " + key, "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); + tooltip(hit(key, p, q, 1), name(a) + " – " + name(b) + ": intended (member list), not measured"); } const ms = rtt => rtt < 10 ? rtt.toFixed(1) : String(Math.round(rtt)); for (const [key, { a, b, link, secsAgo }] of measured) { @@ -507,49 +544,49 @@ function renderGraph() { const silent = !(a in state.graph.measured) || !(b in state.graph.measured); // Dormant: a red underlay as wide as the lines, as a dormant node's red outline. if (link.dormant) { - children.push(svg("line", { x1: p.x, y1: p.y, x2: q.x, y2: q.y, stroke: "var(--bad)", - "stroke-opacity": .5, "stroke-width": width + 4, "stroke-linecap": "round" })); + children.push(drawing("underlay " + key, "line", { x1: p.x, y1: p.y, x2: q.x, y2: q.y, + stroke: "var(--bad)", "stroke-opacity": .5, "stroke-width": width + 4, + "stroke-linecap": "round" })); } lines.forEach((line, i) => { // 0, then +1, -1, +2, -2 … steps of 6 px to the side. const side = i === 0 ? 0 : (i % 2 ? 1 : -1) * Math.ceil(i / 2) * 6; const ox = nx * side, oy = ny * side; - const drawn = svg("line", { x1: p.x + ox, y1: p.y + oy, x2: q.x + ox, y2: q.y + oy, + const attrs = { x1: p.x + ox, y1: p.y + oy, x2: q.x + ox, y2: q.y + oy, stroke: colourOf(line.transport), // Narrow where dormant (over its underlay). - "stroke-width": link.dormant ? (line.used ? 1.5 : 0.8) : line.used ? 3 : 1.2 }); + "stroke-width": link.dormant ? (line.used ? 1.5 : 0.8) : line.used ? 3 : 1.2 }; // Dotted where an end reports nothing, dashed where relayed, solid where direct. if (silent) { - drawn.setAttribute("stroke-dasharray", line.used ? "1 5" : "1 3"); - drawn.setAttribute("stroke-linecap", "round"); + attrs["stroke-dasharray"] = line.used ? "1 5" : "1 3"; + attrs["stroke-linecap"] = "round"; } else if (line.used ? link.relayed : line.relayed) { - drawn.setAttribute("stroke-dasharray", "7 4"); + attrs["stroke-dasharray"] = "7 4"; } - const title = svg("title"); - title.textContent = name(a) + " – " + name(b) + + const drawnLine = drawing("line " + key + " " + i, "line", attrs); + const description = name(a) + " – " + name(b) + (line.transport ? " via " + line.transport : "") + (line.used ? " (in use)" : "") + ((line.used ? link.relayed : line.relayed) ? ", relayed" : "") + (link.dormant ? ", dormant" : "") + (silent ? ", one end reports no links" : "") + (typeof line.rttMs === "number" ? ", " + line.rttMs.toFixed(1) + " ms" : "") + ", measured " + age(secsAgo + elapsed()) + " ago"; - descriptions.push(title.textContent); - children.push(drawn); + tooltip(drawnLine, description); + descriptions.push(description); + children.push(drawnLine); if (typeof line.rttMs === "number") { // The one in use at the middle, larger; the others further along, beside theirs. const t = line.used ? .5 : .3; const out = line.used ? -4 : Math.sign(side) * 9; - const label = svg("text", { + const label = drawing("rtt " + key + " " + i, "text", { x: p.x + (q.x - p.x) * t + ox + nx * out, y: p.y + (q.y - p.y) * t + oy + ny * out + (line.used ? 0 : 3), "text-anchor": "middle", "font-size": line.used ? 11 : 9, fill: line.used ? "var(--fg)" : "var(--muted)" }); - label.textContent = ms(line.rttMs); + text(label, ms(line.rttMs)); children.push(label); } }); - const tooltip = svg("title"); - tooltip.textContent = descriptions.join("\n"); - covering.append(tooltip); + tooltip(covering, descriptions.join("\n")); } // Above the lines and their labels, below the nodes. children.push(...hits); @@ -560,29 +597,27 @@ function renderGraph() { // A link in focus: both ends ringed. // A halo as links have: light under the pointer, stronger in focus. if (shown || id === hovered) { - children.push(svg("circle", { cx: p.x, cy: p.y, r: 13, fill: "var(--self)", - "fill-opacity": shown ? .35 : .15, stroke: "none" })); + children.push(drawing("ring " + id, "circle", { cx: p.x, cy: p.y, r: 13, + fill: "var(--self)", "fill-opacity": shown ? .35 : .15, stroke: "none" })); } + const pressed = element => { + element.addEventListener("pointerdown", event => { event.stopPropagation(); focus(id); }); + hoverable(element, id); + }; // Dormant as this node sees it (`/peers`): a red outline. const dormant = (state.peers.peers[id] || {}).status === "dormant"; - const circle = svg("circle", { cx: p.x, cy: p.y, r: member.self ? 8 : 6, + const circle = drawing("node " + id, "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)" : dormant ? "var(--bad)" : "var(--fg)", - "stroke-width": dormant ? 2.5 : 1.5 }); - const title = svg("title"); - title.textContent = id + (reported ? "" : " (no links reported)") + - (dormant ? " (dormant)" : ""); - circle.append(title); - circle.addEventListener("pointerdown", event => { event.stopPropagation(); focus(id); }); - hoverable(circle, 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("pointerdown", event => { event.stopPropagation(); focus(id); }); - hoverable(label, id); + "stroke-width": dormant ? 2.5 : 1.5 }, pressed); + tooltip(circle, id + (reported ? "" : " (no links reported)") + + (dormant ? " (dormant)" : "")); + const label = drawing("name " + id, "text", { x: p.x + 10, y: p.y + 4, "font-size": 12, + fill: "var(--fg)", cursor: "pointer" }, pressed); + text(label, name(id)); children.push(circle, label); } - root.replaceChildren(...children); + show(root, children); } // --- Document: read a level at a time (`/store?shallow=true`) as it is opened, and |
