diff options
| author | tv <tv@krebsco.de> | 2026-11-07 22:35:09 +0100 |
|---|---|---|
| committer | tv <tv@krebsco.de> | 2026-11-07 22:35:09 +0100 |
| commit | 6fbcb1d3ad9360d7a05f1dcece383cfb095ba1d4 (patch) | |
| tree | 0f5ba19054a7b09e50a3063edbef02906e9ea75b /src | |
| parent | 1458ea92a41788a0f33e86e621817613f46055eb (diff) | |
dashboard: subscriptions
Double-click a node to subscribe: the page reads its own links from it
directly with a narrowed token (reading the member list only), drawn
over what this node knows; kept per swarm in the browser.
Auth plan M3.
Diffstat (limited to 'src')
| -rw-r--r-- | src/api/dashboard/dashboard.html | 225 |
1 files changed, 218 insertions, 7 deletions
diff --git a/src/api/dashboard/dashboard.html b/src/api/dashboard/dashboard.html index 9390af4..8e57363 100644 --- a/src/api/dashboard/dashboard.html +++ b/src/api/dashboard/dashboard.html @@ -235,6 +235,19 @@ border: 1.5px dashed var(--warn); } .legend .node.focus::before { width: 14px; height: 14px; background: var(--self); border: none; opacity: .35; } + .legend .node.subscribed::before { width: 6px; height: 6px; background: #000; + border: 4px solid var(--self); } + .legend .node.unreachable::before { width: 6px; height: 6px; background: #000; + border: 4px solid var(--muted); } + /* A node's menu (right-click): subscribe, its dashboard. */ + #node-menu { position: fixed; z-index: 10; background: var(--panel); + border: 1px solid var(--line); border-radius: 6px; padding: 4px 0; + box-shadow: 0 4px 16px rgb(0 0 0 / .15); font-size: 12px; } + #node-menu[hidden] { display: none; } + #node-menu button { display: block; width: 100%; text-align: left; font: inherit; + padding: 4px 12px; background: none; border: none; color: var(--fg); cursor: pointer; } + #node-menu button:hover:not(:disabled) { background: var(--bg); } + #node-menu button:disabled { color: var(--muted); cursor: default; } .scroll { overflow: auto; min-height: 0; } #figures-box .scroll, #document-box .scroll { flex: 1; } .tree pre { margin: 2px 0 4px 1.1em; white-space: pre-wrap; word-break: break-all; @@ -316,6 +329,8 @@ <span class="node dormant-node">dormant (to this node)</span> <span class="node clock-off">clock off the swarm's</span> <span class="node focus">in focus (click a node)</span> + <span class="node subscribed">subscribed (its links read from it)</span> + <span class="node unreachable">subscribed, not reached from here</span> </div> <div class="legend" id="legend"></div> <div class="legend" id="messages-legend"><button id="flows-toggle" @@ -323,10 +338,12 @@ <div class="legend"> <span class="hint">drag to pan, wheel or pinch to zoom, turn with two fingers; drag a node to move it, middle-click it to pin it there or let it go, - double-click it for its dashboard</span> + double-click it to subscribe to its links (or not), right-click it for its + dashboard</span> </div> </div> </div> + <div id="node-menu" hidden></div> <svg id="graph"><g id="view"><g id="flows" pointer-events="none"></g><g id="flashes" pointer-events="none"></g></g></svg> </div> </section> @@ -420,6 +437,21 @@ const handOver = (() => { } })(); +// The nodes whose own links this page reads from them (`follow`), kept in the browser, +// and how each goes: declared before anything draws. +const SUBSCRIPTIONS = "continuum-subscriptions"; +const subscriptions = (() => { + try { + const kept = JSON.parse(localStorage.getItem(SUBSCRIPTIONS) || "[]"); + return new Set(Array.isArray(kept) ? kept.filter(id => typeof id === "string") : []); + } catch (_) { + return new Set(); + } +})(); +// Each subscription followed: `status` (`connecting`, `live`, `unreachable`), `why` +// not, its latest `report` and when it came (`at`), and how to stop it (`stop`). +const subscribed = new Map(); + // 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__); @@ -1357,6 +1389,10 @@ function renderGraph() { } const pressed = element => { element.addEventListener("pointerdown", event => { event.stopPropagation(); drag(event, id); }); + element.addEventListener("contextmenu", event => { + event.preventDefault(); + nodeMenu(id, event.clientX, event.clientY); + }); hoverable(element, id); }; // A clock off the swarm's: a dashed orange ring. @@ -1390,13 +1426,20 @@ function renderGraph() { if (dash && countdownFrame === null) countdownFrame = requestAnimationFrame(tickCountdowns); } // Deciding: hollow, so its question mark shows. - const circle = drawing("node " + id, "circle", { cx: p.x, cy: p.y, r: radius, - fill: deciding ? "var(--panel)" : voting ? AMBER : member.self ? "var(--self)" : reported ? "var(--fg)" : "var(--panel)", - stroke: dormant ? "var(--bad)" : voting ? AMBER : member.self ? "var(--self)" : "var(--fg)", - "stroke-width": dormant ? 2.5 : 1.5 }, pressed); + // Subscribed (`subscriptions`): a black centre, and a ring of this node's colour a + // third of its diameter wide; grey while not reached from here. + const following = !member.self && subscriptions.has(id) ? subscribed.get(id) : undefined; + const ring = following && !dormant && !voting && !deciding; + const circle = drawing("node " + id, "circle", { cx: p.x, cy: p.y, + r: ring ? radius - radius / 3 : radius, + fill: deciding ? "var(--panel)" : voting ? AMBER : ring ? "#000" : member.self ? "var(--self)" : reported ? "var(--fg)" : "var(--panel)", + stroke: dormant ? "var(--bad)" : voting ? AMBER : ring ? (following.status === "live" ? "var(--self)" : "var(--muted)") : member.self ? "var(--self)" : "var(--fg)", + "stroke-width": dormant ? 2.5 : ring ? radius * 2 / 3 : 1.5 }, pressed); // Its clock against the swarm's, as the members' reports chain it (the node's). const clock = ((state.graph.clocks || {})[id]); tooltip(circle, id + (reported ? "" : " (no links reported)") + + (following ? (following.status === "live" ? " (subscribed)" : + " (subscribed, not reached from here: " + following.why + ")") : "") + (dormant ? " (dormant)" : "") + (coordinating.has(id) ? " (coordinates a remake)" : voting ? " (voted for a remake)" : "") + (clock && clock.off ? " (clock " + seconds(clock.offsetMs) + " off the swarm's, ±" + @@ -1842,6 +1885,7 @@ function busy(box) { let held = false; function render(ages = false) { if (!state) return; + applySubscriptions(); document.getElementById("node").textContent = (state.node.alias ? state.node.alias + " · " : "") + state.node.id; const skew = state.stats.clock; @@ -2112,7 +2156,8 @@ graph.addEventListener("pointerdown", event => { // Not the browser's drag and drop of the picture. graph.addEventListener("dragstart", event => event.preventDefault()); // A node: dragged, it moves, held where the pointer is (`grabbed`) until let go; a click, -// it is in focus; a second one soon after opens its dashboard (`openDashboard`). A +// it is in focus; a second one soon after subscribes to it, or no longer +// (`toggleSubscription`); a right click shows its menu (`nodeMenu`). A // middle click pins it where it is (`pinned`: the layout leaves it // there), or lets it go if pinned (`unpin`); dragged with the middle button, it is // pinned where it's dropped. Told apart here: the map holds the pointer while pressed @@ -2147,13 +2192,177 @@ function drag(event, id) { const now = performance.now(); if (lastNodeClick.id === id && now - lastNodeClick.at < DOUBLE_CLICK_MS) { lastNodeClick = { id: null, at: 0 }; - openDashboard(id); + toggleSubscription(id); } else { lastNodeClick = { id, at: now }; focus(id); } }); } +// --- Subscriptions: other nodes' own links, read from them by this page directly +// (their `dashboard/links/own/events`, with a token: `access.md`, Tokens), for the map. +// Kept in the browser (handed over with the rest), each followed while the page is +// open; a node the browser can't reach shows grey, with why in its tooltip. --- +function keepSubscriptions() { + try { localStorage.setItem(SUBSCRIPTIONS, JSON.stringify([...subscriptions])); } catch (_) {} +} +function toggleSubscription(id) { + if (!state || id === state.node.id) return; + if (subscriptions.has(id)) { + subscriptions.delete(id); + const entry = subscribed.get(id); + if (entry) entry.stop(); + subscribed.delete(id); + // Its report as this node has it, if any, at the next update. + if (state.graph.measured[id] && state.graph.measured[id].subscribed) { + delete state.graph.measured[id]; + } + } else { + subscriptions.add(id); + follow(id); + } + keepSubscriptions(); + render(); +} +// The subscriptions' reports on the map, over what this node has of them: fresher. +function applySubscriptions() { + for (const [id, entry] of subscribed) { + if (!entry.report || !state.graph.members[id]) continue; + state.graph.measured[id] = { + secsAgo: Math.max(0, Math.floor((stateAt - entry.at) / 1000)), + links: entry.report.links || [], + epoch: entry.report.epoch, + subscribed: true, + }; + } +} +// Tokens: this page's, from logging in on this node (the browser's password for it, or +// the proxy's), and a narrower one, reading the member list only, to send to others. +// Each refreshed half way through its life. +let token = null, narrowToken = null, tokenWhy = "not logged in yet", tokenTimer = null; +const READ_MEMBERS = [{ path: ["_continuum", "members"], rights: ["read"] }]; +async function tokenCall(url, bearer, body) { + const headers = { "Content-Type": "application/json" }; + if (bearer) headers.Authorization = "Bearer " + bearer; + const response = await fetch(url, { method: "POST", headers, + body: JSON.stringify(body || {}) }); + const answer = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(response.status + " " + (answer.error || response.statusText)); + return answer; +} +async function obtainTokens() { + clearTimeout(tokenTimer); + try { + token = token && token.expiresAt > Date.now() + 60000 + ? await tokenCall("../auth/refresh", token.token) + : await tokenCall("../auth/login"); + narrowToken = await tokenCall("../auth/refresh", token.token, { limit: READ_MEMBERS }); + tokenWhy = null; + const life = Math.min(token.expiresAt, narrowToken.expiresAt) - Date.now(); + tokenTimer = setTimeout(obtainTokens, Math.max(30000, life / 2)); + // Those that waited for one. + for (const id of subscriptions) { + const entry = subscribed.get(id); + if (!entry || entry.status === "unreachable") follow(id); + } + } catch (error) { + token = narrowToken = null; + tokenWhy = "no token: " + error.message; + tokenTimer = setTimeout(obtainTokens, 30000); + } +} +// Follows `id`'s own links, again after a while if it fails (2 s, doubling to a minute). +function follow(id, delay = 2000) { + const old = subscribed.get(id); + if (old) old.stop(); + const controller = new AbortController(); + let retry = null; + const entry = { status: "connecting", why: "connecting", report: old ? old.report : null, + at: old ? old.at : 0, stop: () => { clearTimeout(retry); controller.abort(); } }; + subscribed.set(id, entry); + const fail = why => { + if (controller.signal.aborted || subscribed.get(id) !== entry) return; + entry.status = "unreachable"; + entry.why = why; + render(); + retry = setTimeout(() => { + if (subscriptions.has(id) && subscribed.get(id) === entry) follow(id, Math.min(60000, delay * 2)); + }, delay); + }; + const member = state && state.graph.members[id]; + if (!member || !member.dashboard) return fail("it publishes no dashboard (--dashboard-url)"); + if (!narrowToken) return fail(tokenWhy); + let url; + try { + url = new URL("dashboard/links/own/events", member.dashboard.split("#")[0].replace(/\/+$/, "")).href; + } catch (_) { + return fail("its dashboard's address isn't one"); + } + (async () => { + const response = await fetch(url, { signal: controller.signal, cache: "no-store", + headers: { Authorization: "Bearer " + narrowToken.token, Accept: "text/event-stream" } }); + if (!response.ok) { + const answer = await response.json().catch(() => ({})); + throw new Error(response.status + " " + (answer.error || response.statusText)); + } + entry.status = "live"; + entry.why = null; + delay = 2000; + render(); + const reader = response.body.pipeThrough(new TextDecoderStream()).getReader(); + let buffer = ""; + for (;;) { + const { value, done } = await reader.read(); + if (done) throw new Error("its stream ended"); + buffer += value.replace(/\r/g, ""); + let end; + while ((end = buffer.indexOf("\n\n")) >= 0) { + const block = buffer.slice(0, end); + buffer = buffer.slice(end + 2); + let kind = "message"; + const data = []; + for (const line of block.split("\n")) { + if (line.startsWith("event:")) kind = line.slice(6).trim(); + else if (line.startsWith("data:")) data.push(line.slice(5).replace(/^ /, "")); + } + if (kind === "links") { + entry.report = JSON.parse(data.join("\n")); + entry.at = Date.now(); + render(); + } else if (kind === "offline") { + throw new Error("it stopped"); + } + } + } + })().catch(error => fail(error.message || String(error))); +} +// A node's menu (right-click): subscribing to its links, or no longer; its dashboard. +function nodeMenu(id, x, y) { + const menu = document.getElementById("node-menu"); + const member = state.graph.members[id] || {}; + const item = (label, enabled, act) => { + const button = el("button", { type: "button" }, label); + button.disabled = !enabled; + button.addEventListener("click", () => { menu.hidden = true; act(); }); + return button; + }; + menu.replaceChildren( + item(subscriptions.has(id) ? "unsubscribe" : "subscribe to its links", !member.self, + () => toggleSubscription(id)), + item(member.dashboard ? "open its dashboard" : "no dashboard published", !member.self && !!member.dashboard, + () => openDashboard(id))); + menu.hidden = false; + menu.style.left = Math.min(x, innerWidth - menu.offsetWidth - 8) + "px"; + menu.style.top = Math.min(y, innerHeight - menu.offsetHeight - 8) + "px"; +} +addEventListener("pointerdown", event => { + const menu = document.getElementById("node-menu"); + if (!menu.hidden && !menu.contains(event.target)) menu.hidden = true; +}, true); +addEventListener("keydown", event => { + if (event.key === "Escape") document.getElementById("node-menu").hidden = true; +}); + // A node's dashboard, where it publishes one (`--dashboard-url`, in the member list), // opened in this page; else its halo flashes red, larger than usual, and "no dashboard" // rises from it in the same red, both fading out. This node's own: here already. @@ -2907,6 +3116,8 @@ function connect() { remakesArrived(); if (treeShown && writerTree === null) loadTree(); handOverLinks(); + if (token === null && tokenTimer === null) obtainTokens(); + for (const id of subscriptions) if (!subscribed.has(id)) follow(id); }); events.addEventListener("patch", event => { if (!state) return; |
