From df314df4793765ea5334ac75b97d415286cc7fa3 Mon Sep 17 00:00:00 2001 From: tv Date: Mon, 2 Nov 2026 22:05:50 +0100 Subject: dashboard: double-click opens peer dashboard --- docs/dashboard.md | 5 +++ docs/example.toml | 5 +++ docs/openapi.yaml | 2 +- src/api/dashboard/dashboard.html | 67 ++++++++++++++++++++++++++++++++++++---- src/api/dashboard/mod.rs | 11 +++++-- src/config/mod.rs | 20 ++++++++++++ src/main.rs | 1 + src/node.rs | 3 ++ src/simtest/mod.rs | 1 + src/state/mod.rs | 3 ++ src/swarm/members.rs | 65 +++++++++++++++++++++++++++++++++++--- 11 files changed, 169 insertions(+), 14 deletions(-) diff --git a/docs/dashboard.md b/docs/dashboard.md index d9bbaa5..d39022d 100644 --- a/docs/dashboard.md +++ b/docs/dashboard.md @@ -149,6 +149,11 @@ measured. * **Drag a node**: move it. On a dynamic map, the others follow at once, and once let go of, it moves back among them; on a static map it stays where it is put until the map is laid out again. +* **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. * **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/docs/example.toml b/docs/example.toml index 67f2cfd..b6a6ad0 100644 --- a/docs/example.toml +++ b/docs/example.toml @@ -37,6 +37,11 @@ node-key-file = "/var/lib/continuum/node.key" # member list; not unique, never an identity. # alias = "node-${env:HOSTNAME}" +# Where people reach this node's dashboard (http:// or https://, e.g. behind a reverse +# proxy). Published in the member list: the other nodes' dashboards open it on a +# double-click on this node. +# dashboard-url = "https://node-1.example.com/admin/dashboard" + # Only relay (libp2p circuit relay), without being a node: no document, no swarm, no # node key. Needs libp2p alone (libp2p's enable, no other), its listen and # key-file; options about a swarm or a document (node-key-file, seeds, genesis, diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 5460a9c..8ffc8d7 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -749,7 +749,7 @@ paths: The dashboard's latest snapshot: `node` (`id`, `alias`), `page` (the page's version, a hash: a page showing another reloads), `stats` (as `/admin/stats`), `peers` (as `/peers`) and `graph`: `members` (by node ID: - `alias`, `self`), `intended` (by node ID: the neighbours the member list gives + `alias`, `self`, `dashboard`: its `--dashboard-url`), `intended` (by node ID: the neighbours the member list gives it, as this node computes them), `measured` (by node ID: `secsAgo` and `links`, each with `peer`, `transport`, `relayed`, `down`, `rttMs`, `dormant` and `others` (the peer's other transports: `transport`, `rttMs`, `relayed`), as the member diff --git a/src/api/dashboard/dashboard.html b/src/api/dashboard/dashboard.html index 7125ad9..e645b02 100644 --- a/src/api/dashboard/dashboard.html +++ b/src/api/dashboard/dashboard.html @@ -252,10 +252,11 @@ class="toggle">messages: on
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 + drag a node to move it, middle-click it to pin it there or let it go, + double-click it for its dashboard
- +

Peers

@@ -1613,10 +1614,13 @@ 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 middle click pins it where it is (`pinned`: the layout leaves it +// it is in focus; a second one soon after opens its dashboard (`openDashboard`). 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 // (`pressing`), so the browser's clicks go to the map, not the node. +let lastNodeClick = { id: null, at: 0 }; +const DOUBLE_CLICK_MS = 400; let grabbed = null; // Of the press under way on a node: whether it's the middle button's, and whether it // moved the node. @@ -1638,11 +1642,62 @@ function drag(event, id) { // Dynamic: the others follow it at once. if (byRtt) settle(); }, () => { - if (!middlePress) focus(id); - else if (pinned.has(id)) unpin(id); - else pin(id); + if (middlePress) { + if (pinned.has(id)) unpin(id); else pin(id); + return; + } + const now = performance.now(); + if (lastNodeClick.id === id && now - lastNodeClick.at < DOUBLE_CLICK_MS) { + lastNodeClick = { id: null, at: 0 }; + openDashboard(id); + } else { + lastNodeClick = { id, at: now }; + focus(id); + } }); } +// 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. +const FLASH_MS = 1200; +function openDashboard(id) { + const member = state.graph.members[id]; + if (!member || member.self) return; + if (member.dashboard) { + location.assign(member.dashboard); + return; + } + const layer = document.getElementById("flashes"); + const halo = svg("circle", { r: 17, fill: "var(--bad)", "fill-opacity": .5 }); + const words = svg("text", { "text-anchor": "middle", "font-size": 12, "font-weight": 600, + fill: "var(--bad)", "fill-opacity": .5 }); + words.textContent = "no dashboard"; + layer.append(halo, words); + const started = performance.now(); + const step = now => { + const p = positions.get(id); + const t = Math.min(1, Math.max(0, (now - started) / FLASH_MS)); + if (!p || t >= 1) { + halo.remove(); + words.remove(); + return; + } + const opacity = String(1 - t); + halo.setAttribute("cx", p.x); + halo.setAttribute("cy", p.y); + halo.setAttribute("opacity", opacity); + // Up on the screen, however the map is turned or zoomed: from above the node, slowly. + const [x, y] = turned(0, -(22 + (calm.matches ? 0 : 30 * t)), -view.turn); + const attrs = upright({ x: p.x + x / view.k, y: p.y + y / view.k }); + words.setAttribute("x", attrs.x); + words.setAttribute("y", attrs.y); + if (attrs.transform) words.setAttribute("transform", attrs.transform); + else words.removeAttribute("transform"); + words.setAttribute("opacity", opacity); + requestAnimationFrame(step); + }; + requestAnimationFrame(step); +} // A dragged node let go of: dynamic, it moves to its place among the others again; // dragged with the middle button, it is pinned there. function dropped() { diff --git a/src/api/dashboard/mod.rs b/src/api/dashboard/mod.rs index db7efa3..91b81d4 100644 --- a/src/api/dashboard/mod.rs +++ b/src/api/dashboard/mod.rs @@ -320,9 +320,13 @@ async fn build(state: &AppState, graph: &Graph, live: bool) -> JsonValue { let list = super::peers::peer_list(state).await; let stats = serde_json::to_value(super::stats::collect(state, &list).await).unwrap_or_default(); let peers = serde_json::to_value(&list).unwrap_or_default(); - let aliases = { + let (aliases, dashboards) = { let replica = state.doc.read().await; - members::aliases(&replica.doc) + let dashboards: std::collections::HashMap = members::members(&replica.doc) + .into_iter() + .filter_map(|pk| members::dashboard(&replica.doc, &pk).map(|url| (pk, url))) + .collect(); + (members::aliases(&replica.doc), dashboards) }; let local = state.local_id(); let intended: Map = graph @@ -362,7 +366,8 @@ async fn build(state: &AppState, graph: &Graph, live: bool) -> JsonValue { for member in graph.intended.keys().chain(measured.keys()) { members_json.insert( member.clone(), - json!({ "alias": aliases.get(member), "self": member == local }), + json!({ "alias": aliases.get(member), "self": member == local, + "dashboard": dashboards.get(member) }), ); } json!({ diff --git a/src/config/mod.rs b/src/config/mod.rs index 37c010e..527dced 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -242,6 +242,13 @@ pub(crate) const OPTIONS: &[OptionSpec] = &[ None, "A name for this node, shown next to its node ID (/peers, metrics). Published in the swarm's member list; not unique, never an identity.", ), + option( + "dashboard-url", + Kind::Text, + "URL", + None, + "Where people reach this node's dashboard (http:// or https://, e.g. behind a reverse proxy). Published in the swarm's member list: the other nodes' dashboards open it on a double-click on this node.", + ), option( "keepalive", Kind::Duration, @@ -537,6 +544,7 @@ pub(crate) struct Settings { #[serde(default)] pub(crate) swarm_key_create: bool, pub(crate) alias: Option, + pub(crate) dashboard_url: Option, #[serde(default = "default_keepalive", with = "crate::units::duration_option")] pub(crate) keepalive: Duration, #[serde( @@ -788,6 +796,17 @@ impl Settings { if let Some(alias) = &self.alias { check_alias(alias)?; } + if let Some(url) = &self.dashboard_url { + let scheme = url.starts_with("http://") || url.starts_with("https://"); + if !scheme + || url.len() > 2048 + || url.chars().any(|c| c.is_whitespace() || c.is_control()) + { + bail!( + "dashboard-url: {url:?} is no http:// or https:// URL (of at most 2048 characters)" + ); + } + } Ok(()) } } @@ -842,6 +861,7 @@ impl Settings { ("swarm-key-create", self.swarm_key_create), ("protocol-writers", !self.protocol_writers.is_empty()), ("alias", self.alias.is_some()), + ("dashboard-url", self.dashboard_url.is_some()), ("debug-api", self.debug_api), ]; let set: Vec<&str> = swarm_options diff --git a/src/main.rs b/src/main.rs index b27db27..c474d45 100644 --- a/src/main.rs +++ b/src/main.rs @@ -220,6 +220,7 @@ async fn main() -> Result<()> { .with_probe_max(args.dormant_probe_max), neighbours_k: args.neighbours, alias: args.alias.clone(), + dashboard_url: args.dashboard_url.clone(), electorate_window: args.electorate_window, clock_skew_warn: args.clock_skew_warn, superseded_writes: args.superseded_writes, diff --git a/src/node.rs b/src/node.rs index 4597e66..c1b0aea 100644 --- a/src/node.rs +++ b/src/node.rs @@ -45,6 +45,8 @@ pub(crate) struct NodeConfig { pub(crate) timing: Timing, pub(crate) neighbours_k: usize, pub(crate) alias: Option, + /// `--dashboard-url`. + pub(crate) dashboard_url: Option, pub(crate) electorate_window: Duration, pub(crate) clock_skew_warn: Duration, /// `--superseded-writes`. @@ -116,6 +118,7 @@ impl Node { handshakes: Default::default(), neighbours_k: config.neighbours_k, alias: config.alias, + dashboard_url: config.dashboard_url, electorate_window: config.electorate_window, clock_skew_warn: config.clock_skew_warn, superseded_writes: config.superseded_writes, diff --git a/src/simtest/mod.rs b/src/simtest/mod.rs index 61c81d1..a79bd2b 100644 --- a/src/simtest/mod.rs +++ b/src/simtest/mod.rs @@ -112,6 +112,7 @@ impl Sim { timing: Timing::new(KEEPALIVE, SYNC_REPLY_TIMEOUT, DELIVERY_LIMIT), neighbours_k: 8, alias: Some(id.to_owned()), + dashboard_url: None, electorate_window: Duration::from_secs(30 * 24 * 3600), clock_skew_warn: Duration::from_secs(2), superseded_writes: Default::default(), diff --git a/src/state/mod.rs b/src/state/mod.rs index aebc59a..36b1132 100644 --- a/src/state/mod.rs +++ b/src/state/mod.rs @@ -107,6 +107,9 @@ pub(crate) struct AppState { pub(crate) neighbours_k: usize, /// Our alias (`--alias`), published in the member list (`swarm::members`). pub(crate) alias: Option, + /// Where people reach our dashboard (`--dashboard-url`), published in the member list + /// (`swarm::members`). + pub(crate) dashboard_url: Option, /// Members alive within this window vote on remakes (`--electorate-window`). pub(crate) electorate_window: Duration, /// This node's clock is off when it differs from most of its peers' by more than diff --git a/src/swarm/members.rs b/src/swarm/members.rs index 05c6501..a534c04 100644 --- a/src/swarm/members.rs +++ b/src/swarm/members.rs @@ -10,7 +10,8 @@ //! //! Besides its tier, an entry holds the alias a node gives itself (`--alias`), a name //! for people reading /peers and metrics (not unique and never an identity: the public -//! key stays that), where it can be reached (`addresses`, as its transport gives them: +//! key stays that), where people reach its dashboard (`--dashboard-url`, `dashboard`), +//! where it can be reached (`addresses`, as its transport gives them: //! hints for the others' transports), and an `alive` stamp the node renews now and //! then: the members renewed within `--electorate-window` are the electorate of a //! remake. @@ -114,6 +115,34 @@ fn set_alias(doc: &mut impl Transactable, pk: &str, alias: Option<&str>) -> Resu } } +fn dashboard_path(pk: &str) -> Vec { + let mut path = member_path(pk); + path.push("dashboard".to_owned()); + path +} + +/// Where people reach `pk`'s dashboard, if it says. +pub(crate) fn dashboard(doc: &Document, pk: &str) -> Option { + match store::read_path(doc, &dashboard_path(pk)) { + Ok(Some(JsonValue::String(url))) => Some(url), + _ => None, + } +} + +fn set_dashboard( + doc: &mut impl Transactable, + pk: &str, + url: Option<&str>, +) -> Result<(), StoreError> { + match url { + Some(url) => store::write_path(doc, &dashboard_path(pk), &json!(url)), + None => match store::delete_path(doc, &dashboard_path(pk), false) { + Err(StoreError::NotFound) => Ok(()), + result => result, + }, + } +} + fn addresses_path(pk: &str) -> Vec { let mut path = member_path(pk); path.push("addresses".to_owned()); @@ -260,8 +289,9 @@ const RENEWAL_MOST_OFTEN: Duration = Duration::from_secs(3600); pub(crate) const MIN_ELECTORATE_WINDOW: Duration = Duration::from_secs(7 * RENEWAL_MOST_OFTEN.as_secs()); -/// Registers this node if it is ready and its entry is missing, keeps its alias as -/// configured (set, changed or removed after a restart with another `--alias`) and its +/// Registers this node if it is ready and its entry is missing, keeps its alias and its +/// dashboard's URL as configured (set, changed or removed after a restart with another +/// `--alias` or `--dashboard-url`) and its /// addresses as the transport gives them, and renews its `alive` stamp now and then /// (see `electorate`). pub(crate) async fn ensure_registered(state: &AppState) { @@ -271,7 +301,15 @@ pub(crate) async fn ensure_registered(state: &AppState) { let local = state.local_id().to_owned(); let own = member_path(&local); let window = state.electorate_window.as_millis() as u64; - let (registered, recorded_alias, recorded_addresses, alive, recorded_window, shortest) = { + let ( + registered, + recorded_alias, + recorded_dashboard, + recorded_addresses, + alive, + recorded_window, + shortest, + ) = { let replica = state.doc.read().await; let registered = store::read_path(&replica.doc, &own) .ok() @@ -280,6 +318,7 @@ pub(crate) async fn ensure_registered(state: &AppState) { ( registered, alias(&replica.doc, &local), + dashboard(&replica.doc, &local), addresses(&replica.doc, &local), millis_at(&replica.doc, &alive_path(&local)), millis_at(&replica.doc, &window_path(&local)), @@ -301,6 +340,9 @@ pub(crate) async fn ensure_registered(state: &AppState) { if let Some(alias) = &wanted { entry["alias"] = json!(alias); } + if let Some(url) = &state.dashboard_url { + entry["dashboard"] = json!(url); + } match state .mutate_doc(move |doc| store::write_path(doc, &own, &entry)) .await @@ -352,6 +394,21 @@ pub(crate) async fn ensure_registered(state: &AppState) { } } + if recorded_dashboard != state.dashboard_url { + let url = state.dashboard_url.clone(); + let local = local.clone(); + match state + .mutate_doc(move |doc| set_dashboard(doc, &local, url.as_deref())) + .await + { + Ok(()) => info!( + url = state.dashboard_url.as_deref(), + "Dashboard URL updated in the member list" + ), + Err(error) => warn!(error = %error, "Failed to record our dashboard URL"), + } + } + if recorded_alias == wanted { return; } -- cgit v1.3.1