aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-11-02 22:05:50 +0100
committertv <tv@krebsco.de>2026-11-02 22:05:50 +0100
commitdf314df4793765ea5334ac75b97d415286cc7fa3 (patch)
tree1ed6c10c066fc5b273c7cf04a1b341e70e38c4a8 /src
parent627efc3b2620d75fcf2deab170187ba2e082417c (diff)
dashboard: double-click opens peer dashboard
Diffstat (limited to 'src')
-rw-r--r--src/api/dashboard/dashboard.html67
-rw-r--r--src/api/dashboard/mod.rs11
-rw-r--r--src/config/mod.rs20
-rw-r--r--src/main.rs1
-rw-r--r--src/node.rs3
-rw-r--r--src/simtest/mod.rs1
-rw-r--r--src/state/mod.rs3
-rw-r--r--src/swarm/members.rs65
8 files changed, 158 insertions, 13 deletions
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</button></div>
<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</span>
+ drag a node to move it, middle-click it to pin it there or let it go,
+ double-click it for its dashboard</span>
</div>
</div>
- <svg id="graph"><g id="view"><g id="flows" pointer-events="none"></g></g></svg>
+ <svg id="graph"><g id="view"><g id="flows" pointer-events="none"></g><g id="flashes" pointer-events="none"></g></g></svg>
</section>
<section id="peers-box">
<h2>Peers</h2>
@@ -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<String, String> = 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<String, JsonValue> = 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
@@ -243,6 +243,13 @@ pub(crate) const OPTIONS: &[OptionSpec] = &[
"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,
"DURATION",
@@ -537,6 +544,7 @@ pub(crate) struct Settings {
#[serde(default)]
pub(crate) swarm_key_create: bool,
pub(crate) alias: Option<String>,
+ pub(crate) dashboard_url: Option<String>,
#[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<String>,
+ /// `--dashboard-url`.
+ pub(crate) dashboard_url: Option<String>,
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<String>,
+ /// Where people reach our dashboard (`--dashboard-url`), published in the member list
+ /// (`swarm::members`).
+ pub(crate) dashboard_url: Option<String>,
/// 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<String> {
+ 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<String> {
+ 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<String> {
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;
}