From 3663da2cf80c94d3303b93a689cb873c58be47aa Mon Sep 17 00:00:00 2001 From: tv Date: Mon, 2 Nov 2026 23:58:22 +0100 Subject: dashboard: pass state to next dashboard --- docs/dashboard.md | 12 ++++- src/api/dashboard/dashboard.html | 100 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 105 insertions(+), 7 deletions(-) diff --git a/docs/dashboard.md b/docs/dashboard.md index c614aab..ca58235 100644 --- a/docs/dashboard.md +++ b/docs/dashboard.md @@ -152,8 +152,16 @@ measured. * **Double-click a node**: open its dashboard, in this page, where the node publishes one (`--dashboard-url`, in the member list; e.g. behind a reverse proxy, [`operations.md`](operations.md)). The other dashboard asks for its own credentials - where its proxy or node does. Where it publishes none, its halo flashes red and - "no dashboard" rises from it, fading out. + where its proxy or node does. It opens as this one was left: the map's nodes where + they were (on a dynamic map they then move to their places among the other node's + links, smoothly; new ones are placed among them), the view, the pins, what is in + focus, the legend shown or hidden, what is open in the figures and in the document, + `links: live` or `every 30 s` (the other node's choice, for every page open on it), + and this browser's settings for the page (sizes, collapsed boxes, the layout, the + messages' size and speed, the console's filter), which are per host otherwise. + They travel in the URL's fragment, which no request carries, and the page removes it + at once. Links are drawn as the other node sees them. Where it publishes none, its + halo flashes red and "no dashboard" rises from it, fading out. * **Middle-click a node**: pin it where it is (a dot in its middle): the layout leaves it there. Dragged with the middle button, it is pinned where it's dropped. Middle-clicked again, it's let go, and moves to its place among the others (on a diff --git a/src/api/dashboard/dashboard.html b/src/api/dashboard/dashboard.html index e645b02..f286615 100644 --- a/src/api/dashboard/dashboard.html +++ b/src/api/dashboard/dashboard.html @@ -287,6 +287,31 @@ const PAGE_VERSION = "__PAGE_VERSION__"; let state = null; let stateAt = 0; +// --- Hand-off: a double click on a node opens its dashboard, on another host, as this +// page was left (`handOff`): the map where it was, the view, the pins, the focus and the +// settings this browser keeps for the page (another host's are its own). It comes in +// the URL's fragment, which no request carries: taken here, before anything reads the +// settings, and gone from the URL at once. --- +const HAND_OFF = "#continuum="; +const handOver = (() => { + if (!location.hash.startsWith(HAND_OFF)) return null; + try { + const handed = JSON.parse(decodeURIComponent(location.hash.slice(HAND_OFF.length))); + for (const [key, value] of Object.entries(handed.settings || {})) { + if (key.startsWith("continuum-") && typeof value === "string") { + try { localStorage.setItem(key, value); } catch (_) {} + } + } + return handed; + } catch (_) { + return null; + } finally { + try { + history.replaceState(null, "", location.pathname + location.search); + } catch (_) { /* not where history works */ } + } +})(); + // Fields that grow by themselves: the node's list (`GROWING` and `is_age` in its // dashboard), filled in when it serves the page. const GROWING = new Set(__GROWING__); @@ -344,7 +369,7 @@ function age(secs) { // 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 = (() => { +let focused = handOver ? handOver.focused || null : (() => { try { return decodeURIComponent(location.hash.slice(1)) || null; } catch { return null; } })(); const pairKey = (a, b) => a < b ? a + " " + b : b + " " + a; @@ -406,6 +431,11 @@ function renderPeers() { // --- Figures: a tree, its open branches kept across renders --- const open = new Set(["stats", "peer", "links", "from", "to", "paths", "paths.destination"]); +// As the page it was opened from had them (`handOver`). +if (handOver && Array.isArray(handOver.figures)) { + open.clear(); + for (const path of handOver.figures) if (typeof path === "string") open.add(path); +} function tree(key, value, path) { // Empty: nothing to open. if (value !== null && typeof value === "object" && Object.keys(value).length === 0) { @@ -1026,6 +1056,11 @@ function renderGraph() { // read again where the node says it changed (`document` events) --- // What is open, by path (as JSON): the first level to begin with. const documentOpen = new Set([JSON.stringify([])]); +// As the page it was opened from had them (`handOver`): the same document, so the same +// paths, opened as the tree is drawn. +if (handOver && Array.isArray(handOver.document)) { + for (const path of handOver.document) if (typeof path === "string") documentOpen.add(path); +} // The open branches read, by path (as JSON): to read again on a change. const documentBranches = new Map(); function storeUrl(path, shallow) { @@ -1451,12 +1486,17 @@ document.addEventListener("selectionchange", release); // The legend, hidden and shown again; the map takes the room. const legendToggle = document.getElementById("legend-toggle"); -legendToggle.addEventListener("click", () => { +function showLegend(shown) { const legends = document.getElementById("legends"); - legends.hidden = !legends.hidden; - legendToggle.textContent = legends.hidden ? "show legend" : "hide legend"; + legends.hidden = !shown; + legendToggle.textContent = shown ? "hide legend" : "show legend"; +} +legendToggle.addEventListener("click", () => { + showLegend(document.getElementById("legends").hidden); if (state) renderGraph(); }); +// Hidden as on the page it was opened from (`handOver`). +if (handOver && handOver.legend === false) showLegend(false); // The members' links subscribed to (`live`: each tells of a change as it happens), or // only asked for every 30 s; the node's choice, for every page (`POST // /admin/dashboard/links`), shown as the state has it. @@ -1500,6 +1540,55 @@ function moveView() { "translate(" + view.x + " " + view.y + ") rotate(" + degrees(view.turn) + ") scale(" + view.k + ")"); } +// The map as the page it was opened from left it (`handOver`): its nodes where they +// were (new ones are placed among them; on a dynamic map all then move to their places +// smoothly, `settle`), the view and the pins. +if (handOver) { + for (const [id, at] of Object.entries(handOver.positions || {})) { + const [x, y] = Array.isArray(at) ? at : []; + if (Number.isFinite(x) && Number.isFinite(y)) positions.set(id, { x, y }); + } + for (const id of handOver.pinned || []) if (positions.has(id)) pinned.add(id); + if (positions.size > 0) relayout = false; + const handed = handOver.view || {}; + for (const key of ["x", "y", "k", "turn"]) { + if (Number.isFinite(handed[key])) view[key] = handed[key]; + } + moveView(); +} +// The fragment that opens another node's dashboard (`id`'s) as this page is now +// (`handOver`). What is in focus stays, but `id` itself: its page is its own. +function handOff(id) { + const settings = {}; + try { + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key && key.startsWith("continuum-")) settings[key] = localStorage.getItem(key); + } + } catch (_) {} + const round = value => Math.round(value * 10) / 10; + return HAND_OFF + encodeURIComponent(JSON.stringify({ + positions: Object.fromEntries([...positions].map(([node, p]) => [node, [round(p.x), round(p.y)]])), + pinned: [...pinned], + view: { ...view }, + focused: focused === id ? null : focused, + settings, + legend: !document.getElementById("legends").hidden, + links: state.graph.live !== false, + figures: [...open], + document: [...documentOpen], + })); +} +// Links live or every 30 s as on the page it was opened from (`handOver`), once its +// first state says otherwise. The node's choice: for every page open on it. +let linksHandedOver = false; +function handOverLinks() { + if (linksHandedOver || !handOver || typeof handOver.links !== "boolean") return; + linksHandedOver = true; + if ((state.graph.live !== false) !== handOver.links) { + fetch("dashboard/links?live=" + handOver.links, { method: "POST" }).catch(() => {}); + } +} // `[x, y]` turned by `angle`. function turned(x, y, angle) { const c = Math.cos(angle), s = Math.sin(angle); @@ -1664,7 +1753,7 @@ function openDashboard(id) { const member = state.graph.members[id]; if (!member || member.self) return; if (member.dashboard) { - location.assign(member.dashboard); + location.assign(member.dashboard.split("#")[0] + handOff(id)); return; } const layer = document.getElementById("flashes"); @@ -2142,6 +2231,7 @@ function connect() { if (state !== null) documentChanged({ all: true }); state = snapshot; stateAt = Date.now(); live = true; status("live", true); pulse(); render(); trafficArrived(); + handOverLinks(); }); events.addEventListener("patch", event => { if (!state) return; -- cgit v1.3.1