aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-11-02 23:58:22 +0100
committertv <tv@krebsco.de>2026-11-02 23:58:22 +0100
commit3663da2cf80c94d3303b93a689cb873c58be47aa (patch)
treef22b17e735f1b6b520fddab54e6780ce6199224a
parent445bafbfcfcdf3761fd065dbc8b5073f1a31c1d1 (diff)
dashboard: pass state to next dashboard
-rw-r--r--docs/dashboard.md12
-rw-r--r--src/api/dashboard/dashboard.html100
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;