From 8a7df0caa51fc1b01e2a744c9e48dd3a1af39296 Mon Sep 17 00:00:00 2001 From: tv Date: Mon, 9 Nov 2026 05:36:38 +0000 Subject: dashboard: a subscribed node's own figures when it is in focus A node in focus showed this node's `/peers` entry for it and the links it reports: what this node knows of it, never what it says of itself, though the page subscribes to it. Now a subscribed node in focus shows its own figures first (`Node:`), as this node's are (its `/admin/stats`), then this node's entry for it (`as this node sees it`) and its links, each a branch. A link in focus shows each end's paths to the other as that end has them: this node's, and a subscribed node's from its `/peers`. The page reads them from the node every 2 s while it is in focus (`readFigures`, `figuresOf`), with its narrow token, which may look at a node as any user with a capability may; pages of other origins may now call `/admin/stats` and `/peers` (`cors::open`), as they do a node's links and messages. Where they can't be read, the box says why. --- docs/access.md | 3 +- docs/dashboard.md | 17 +++++---- src/api/cors.rs | 7 +++- src/api/dashboard/dashboard.html | 76 +++++++++++++++++++++++++++++++++++++--- 4 files changed, 90 insertions(+), 13 deletions(-) diff --git a/docs/access.md b/docs/access.md index 38ae180..50c0265 100644 --- a/docs/access.md +++ b/docs/access.md @@ -173,7 +173,8 @@ it may do. Pages of other origins, the dashboards of other members, may call a node with a token: its own links (`/admin/dashboard/links/own`, and its events) and messages (`/admin/dashboard/messages/own/events`, and one written out, -`/admin/dashboard/messages/own/{seq}`), `/auth/refresh` and `/auth/whoami`, nothing +`/admin/dashboard/messages/own/{seq}`), its figures and peers (`/admin/stats`, +`/peers`), `/auth/refresh` and `/auth/whoami`, nothing else. A node allows the origins of the dashboards its member list names (`--dashboard-url`) and those of `--cors-origins`; without credentials (no cookie or password crosses origins: the page sends its token). diff --git a/docs/dashboard.md b/docs/dashboard.md index 590f72f..da69a16 100644 --- a/docs/dashboard.md +++ b/docs/dashboard.md @@ -287,14 +287,18 @@ What the Figures box shows is what is in focus: * **Nothing** (the default): this node's figures. * **A node** (click it on the map, or its row in Peers): this node's entry for that - peer (as `/peers` has it), and the links that member reports. Ringed on the map, + peer (as `/peers` has it), and the links that member reports. A node the page + subscribes to: its own figures first, as this node's are (its `/admin/stats`, read + from it every 2 s while in focus, with the page's token), then this node's entry for + it (`as this node sees it`) and its links, each a branch. Ringed on the map, highlighted in Peers. A second click on its row, a click beside the nodes or a click on this node goes back. * **A link** (click it): whether the member list intends it, each end's report of it (transport, relayed, round trip, dormant, its other transports, and how old the - report is), and, where this node is an end, the link as this node has it in `/peers`: - where messages go, its paths (each with its connection and round trip), and the - addresses known of the other end. Both ends are ringed. + report is), and, where this node or a node the page subscribes to is an end, the link + as that end has it in its `/peers` (a subscribed node's read from it, as its + figures): where messages go, its paths (each with its connection and round trip), + and the addresses known of the other end. Both ends are ringed. The focus is in the page's address (`#`, or the two IDs of a link), so a reload keeps it and a link to the page can name it. @@ -321,8 +325,9 @@ The box is as tall as its rows, up to eight; then it scrolls. ## Figures The figures of what is in focus, as a tree, its keys at the top level and how many -beside the title: this node's `/admin/stats`, or a peer's `/peers` entry and, after it, -the links it reports (`links`), where the page has them. +beside the title: this node's `/admin/stats`; a subscribed node's own (`Node:`), with +this node's entry for it and its links after; or a peer's `/peers` entry and, after +it, the links it reports (`links`), where the page has them. Branches stay as opened across updates, and ages count on between them. What each figure means: [`measurements.md`](measurements.md). Some values get a reading after them, muted, in parentheses: a node ID its alias diff --git a/src/api/cors.rs b/src/api/cors.rs index fc8270d..287daf1 100644 --- a/src/api/cors.rs +++ b/src/api/cors.rs @@ -1,6 +1,7 @@ //! Pages of other origins reading this node: the dashboards of other members, which a //! page subscribes to for their own links and messages (`/admin/dashboard/links/own`, -//! `/admin/dashboard/messages/own/events`, and one written out), refreshing its +//! `/admin/dashboard/messages/own/events`, and one written out) and figures +//! (`/admin/stats`, `/peers`), refreshing its //! token on any node (`/auth/refresh`, `/auth/whoami`). Only those endpoints, only for //! the origins of the dashboards the member list names and those of `--cors-origins`, //! and without credentials: a page sends its token itself (`Authorization: Bearer`), @@ -26,6 +27,8 @@ fn open(path: &str) -> bool { || matches!( path, "/admin/dashboard/links/own" + | "/admin/stats" + | "/peers" | "/admin/dashboard/links/own/events" | "/admin/dashboard/messages/own/events" | "/auth/refresh" @@ -145,6 +148,8 @@ mod tests { assert!(open("/admin/dashboard/links/own")); assert!(open("/admin/dashboard/messages/own/42")); assert!(!open("/admin/dashboard/messages/own/x")); + assert!(open("/admin/stats") && open("/peers")); + assert!(!open("/peers/continuum:00")); assert!(open("/auth/refresh")); assert!(!open("/auth/login")); assert!(!open("/store")); diff --git a/src/api/dashboard/dashboard.html b/src/api/dashboard/dashboard.html index 6d32d55..d6f034b 100644 --- a/src/api/dashboard/dashboard.html +++ b/src/api/dashboard/dashboard.html @@ -655,6 +655,14 @@ let focused = handOver ? handOver.focused || null : (() => { })(); const pairKey = (a, b) => a < b ? a + " " + b : b + " " + a; const isPair = key => key !== null && key.includes(" "); +// What subscribed nodes in focus say of themselves (`renderStats`): a node's figures +// (`/admin/stats`) and peers (`/peers`), or a link's ends' peers, read from each every +// 2 s while in focus, with the page's narrow token (`readFigures`). By node ID: +// `stats`, `peers`, and `why` where they weren't read. +const figuresOf = new Map(); +// The latest reading's number: an earlier one still on its way neither draws nor reads +// again. +let figuresTimer = null, figuresRound = 0; function focus(id) { focused = id === null || id === state.node.id ? null : id; if (shownMessage !== null) { @@ -670,6 +678,7 @@ function focus(id) { render(); markPeerMessages(); loadTree(); + readFigures(); } // --- Peers --- @@ -859,6 +868,36 @@ function tree(key, value, path) { ...glossed(aliasOf(key)), ": "), JSON.stringify(aged(key, value)), ...glossed(gloss(key, value))); } +// Read every 2 s while in focus (`figuresOf`, `figuresTimer` with `focused`). +async function readFigures() { + clearTimeout(figuresTimer); + figuresTimer = null; + const round = ++figuresRound; + if (focused === null || !state) return; + const reading = focused; + const ids = (isPair(reading) ? reading.split(" ") : [reading]) + .filter(id => id !== state.node.id && subscriptions.has(id)); + await Promise.all(ids.map(async id => { + const get = async path => { + const response = await fetch(endpointOf(id, path), { cache: "no-store", + headers: { Authorization: "Bearer " + narrowToken.token, Accept: "application/json" } }); + const answer = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(response.status + " " + (answer.error || response.statusText)); + return answer; + }; + try { + if (!narrowToken) throw new Error(tokenWhy); + const [stats, peers] = await Promise.all([isPair(reading) ? null : get("stats"), get("../peers")]); + const known = figuresOf.get(id) || {}; + figuresOf.set(id, { stats: stats || known.stats || null, peers: peers.peers || peers }); + } catch (error) { + figuresOf.set(id, { ...(figuresOf.get(id) || {}), why: error.message || String(error) }); + } + })); + if (round !== figuresRound || focused !== reading) return; + if (ids.length > 0) render(); + figuresTimer = setTimeout(readFigures, 2000); +} function renderStats() { const title = document.getElementById("figures-title"); const what = el("span", { class: "what" }); @@ -926,12 +965,16 @@ function renderStats() { } // Where this node is an end: the link itself, as `/peers` has the other end (where // messages go, the paths, the addresses). The whole entry is the node's focus. + // Each end's paths to the other: this node's, and a subscribed node's as it says + // (`figuresOf`). const self = state.node.id; - const other = a === self ? b : b === self ? a : null; - const peer = other !== null && state.peers.peers[other]; - if (peer) { - shown.push(tree("this node's paths", { - destination: peer.destination, addresses: peer.addresses }, "paths")); + for (const [x, y] of [[a, b], [b, a]]) { + const peers = x === self ? state.peers.peers : (figuresOf.get(x) || {}).peers; + const peer = peers && peers[y]; + if (peer) { + shown.push(tree(x === self ? "this node's paths" : name(x) + "'s paths", { + destination: peer.destination, addresses: peer.addresses }, "paths." + (x === self ? "self" : x))); + } } title.replaceChildren("Link: ", what); retitle(title, title.textContent); @@ -955,6 +998,26 @@ function renderStats() { what.replaceChildren(...(alias ? [alias, " "] : []), el("span", { class: "id" }, focused)); const peer = state.peers.peers[focused]; const report = state.graph.measured[focused]; + // A subscribed node: its own figures at the top level, as this node's are, then this + // node's `/peers` entry for it and the links it reports, each a branch. + if (subscriptions.has(focused)) { + const known = figuresOf.get(focused) || {}; + const figures = Object.entries(known.stats || {}); + shown = figures.map(([k, v]) => tree(k, v, "stats." + k)); + if (known.why) { + shown.push(el("div", { class: "note" }, "Its figures not read: " + known.why)); + } else if (!known.stats) { + shown.push(el("div", { class: "note" }, "Reading its figures…")); + } + shown.push(peer ? tree("as this node sees it", peer, "seen") + : el("div", { class: "key leaf" }, "Not a peer of this node.")); + if (report) shown.push(tree("links", report, "links")); + title.replaceChildren("Node: ", what, el("span", { class: "count" }, + describe({ type: "map", size: figures.length + 1 + (report ? 1 : 0) }))); + retitle(title, title.textContent); + document.getElementById("stats").replaceChildren(...shown); + return; + } // Its `/peers` entry is the box, as this node's stats are: its keys at the top level, // and after them the links the member reports. const entries = Object.entries(peer || {}); @@ -2532,6 +2595,7 @@ function toggleSubscription(id) { } keepSubscriptions(); render(); + readFigures(); } // The subscriptions' reports on the map, over what this node has of them: fresher. // What they cover of the node's state is kept aside (`overlaid`: by node, the node's @@ -4089,9 +4153,11 @@ function connect() { } // Changes while not connected went unseen: what is open is read again. if (state !== null) documentChanged({ all: true }); + const first = state === null; state = snapshot; stateAt = Date.now(); live = true; overlaid.clear(); status("live", true); pulse(); render(); trafficArrived(); epochsArrived(); remakesArrived(); + if (first) readFigures(); if (treeShown && writerTree === null) loadTree(); if (token === null && tokenTimer === null) obtainTokens(); // Followed before the page knew which node it is: not any more. -- cgit v1.3.1