aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-11-01 05:11:48 +0100
committertv <tv@krebsco.de>2026-11-01 05:11:48 +0100
commit0a78f9223668831b0887bfead978017574764bd9 (patch)
tree745ae1119c1ee2aaab2d0ff3ad6b57179e911605
parent67a476525bf23fec7aa10a962668cc77cce6c685 (diff)
dashboard: allow delayed links
-rw-r--r--docs/openapi.yaml24
-rw-r--r--src/api/dashboard/dashboard.html19
-rw-r--r--src/api/dashboard/mod.rs55
3 files changed, 86 insertions, 12 deletions
diff --git a/docs/openapi.yaml b/docs/openapi.yaml
index 1f3a71a..35b9b2b 100644
--- a/docs/openapi.yaml
+++ b/docs/openapi.yaml
@@ -546,10 +546,11 @@ paths:
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
- it, as this node computes them) and `measured` (by node ID: `secsAgo` and
+ 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
- answered a LINKS; ours always, the others' while fresh). One producer
+ answered a LINKS; ours always, the others' while fresh) and `live` (whether the
+ members' links are subscribed to, `/admin/dashboard/links`). One producer
per node makes it every 2 s while anyone looks (and for a minute after this
request), subscribed to the members' links (each tells of a change as it
happens; renewed every 30 s); every client gets the same. Only with the `dashboard` cargo feature.
@@ -573,6 +574,25 @@ paths:
responses:
'204':
description: Done.
+ /admin/dashboard/links:
+ post:
+ tags: [Admin]
+ summary: Dashboard links, live or asked
+ description: |
+ Whether the dashboard's producer subscribes to the members' links (`live=true`,
+ the default: each member tells of a change as it happens) or only asks for them
+ every 30 s (`live=false`: fewer messages in the swarm, a map up to 30 s
+ behind). For every client of this node, until set again or the node restarts;
+ the state's `graph.live` says which. Only with the `dashboard` cargo feature.
+ parameters:
+ - name: live
+ in: query
+ required: true
+ schema:
+ type: boolean
+ responses:
+ '204':
+ description: Done.
/admin/dashboard/events:
get:
tags: [Admin]
diff --git a/src/api/dashboard/dashboard.html b/src/api/dashboard/dashboard.html
index 3236973..d8b8f6c 100644
--- a/src/api/dashboard/dashboard.html
+++ b/src/api/dashboard/dashboard.html
@@ -193,7 +193,8 @@
<main>
<section id="map">
<h2>Swarm <button id="legend-toggle" class="toggle">hide legend</button>
- <button id="rtt-toggle" class="toggle">layout: static</button></h2>
+ <button id="rtt-toggle" class="toggle">layout: static</button>
+ <button id="links-toggle" class="toggle">links: live</button></h2>
<div id="legends">
<div class="legend">
<span class="node self">this node</span>
@@ -1301,6 +1302,7 @@ function render(ages = false) {
const skew = state.stats.clock;
document.getElementById("clock-warning").textContent = skew && skew.off
? "clock " + seconds(skew.offsetMs) + " off its peers'" : "";
+ showLinksToggle();
held = false;
const boxes = [["peers-box", renderPeers], ["figures-box", renderStats]];
// Its ages are in tooltips only: drawn on events, not every second.
@@ -1328,6 +1330,21 @@ legendToggle.addEventListener("click", () => {
legendToggle.textContent = legends.hidden ? "show legend" : "hide legend";
if (state) renderGraph();
});
+// 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.
+const linksToggle = document.getElementById("links-toggle");
+function showLinksToggle() {
+ const live = !state || state.graph.live !== false;
+ linksToggle.textContent = live ? "links: live" : "links: every 30 s";
+ linksToggle.title = live
+ ? "The members tell of their links' changes as they happen. Click to only ask them every 30 s (fewer messages; for every page on this node)"
+ : "The members are asked for their links every 30 s. Click to have them tell of changes as they happen (for every page on this node)";
+}
+linksToggle.addEventListener("click", () => {
+ const live = !state || state.graph.live !== false;
+ fetch("dashboard/links?live=" + !live, { method: "POST" }).catch(() => {});
+});
// Links as long as their round trips, and back.
const rttToggle = document.getElementById("rtt-toggle");
function showRttToggle() {
diff --git a/src/api/dashboard/mod.rs b/src/api/dashboard/mod.rs
index 0e7b442..732c95b 100644
--- a/src/api/dashboard/mod.rs
+++ b/src/api/dashboard/mod.rs
@@ -12,16 +12,21 @@
//! The map: the graph the member list makes (`topology::intended_graph`, as this node
//! sees it), and the links every member measures: subscribed to with LINKS while anyone
//! looks (renewed every `LINKS_EVERY`, ended when nobody does; `swarm::links`), so a
-//! member tells of a change as it happens; the same answers for all clients. With the
-//! clocks the links carry, every member's clock against the swarm's (`clocks`).
+//! member tells of a change as it happens; the same answers for all clients. Or, if
+//! asked (`POST /admin/dashboard/links?live=false`, for every client, until asked
+//! otherwise or the node restarts), only asked every `LINKS_EVERY`, without
+//! subscribing: fewer messages in the swarm, a map up to `LINKS_EVERY` behind. With
+//! the clocks the links carry, every member's clock against the swarm's (`clocks`).
mod clocks;
use std::collections::{BTreeMap, BTreeSet};
use std::convert::Infallible;
+use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::Duration;
+use axum::extract::Query;
use axum::http::{HeaderValue, StatusCode, header::CONTENT_TYPE};
use axum::response::sse::{Event, KeepAlive, Sse};
use axum::response::{Html, IntoResponse, Response};
@@ -70,6 +75,9 @@ struct Hub {
control: Mutex<Control>,
/// Someone asked for it now (`refresh`): a tick at once, members asked again.
poke: Notify,
+ /// The members' links subscribed to (true, at first), or only asked for every
+ /// `LINKS_EVERY` (`set_links`).
+ live: AtomicBool,
/// True once the node stops: every client gets an `offline` event, and its stream
/// ends (so the HTTP server needn't wait for clients to go).
closing: watch::Receiver<bool>,
@@ -92,6 +100,7 @@ impl Hub {
patches,
documents,
poke: Notify::new(),
+ live: AtomicBool::new(true),
control: Mutex::new(Control {
running: false,
wanted_until: Instant::now(),
@@ -138,11 +147,12 @@ impl Hub {
let mut graph_due = Instant::now();
let mut changes = self.state.change_events.subscribe();
loop {
+ let live = self.live.load(Ordering::Relaxed);
if Instant::now() >= graph_due {
- graph.refresh(&self.state).await;
+ graph.refresh(&self.state, live).await;
graph_due = Instant::now() + LINKS_EVERY;
}
- let snapshot = build(&self.state, &graph).await;
+ let snapshot = build(&self.state, &graph, live).await;
let changed = last
.as_ref()
.is_none_or(|old| without_ages(old) != without_ages(&snapshot));
@@ -234,14 +244,15 @@ enum Next {
}
/// The intended graph, recomputed every `LINKS_EVERY` (it costs about `members²`), when
-/// members are asked for their links too.
+/// members are asked for their links too: subscribed to, if `live`.
#[derive(Default)]
struct Graph {
intended: BTreeMap<String, BTreeSet<String>>,
}
impl Graph {
- async fn refresh(&mut self, state: &AppState) {
+ async fn refresh(&mut self, state: &AppState, live: bool) {
+ let lease = if live { LINKS_LEASE } else { Duration::ZERO };
self.intended = topology::intended_graph(state).unwrap_or_default();
let members: Vec<String> = {
let replica = state.doc.read().await;
@@ -253,13 +264,14 @@ impl Graph {
.take(MAX_ASKED)
{
let state = state.clone();
- tokio::spawn(async move { links::ask(&state, &member, LINKS_LEASE).await });
+ tokio::spawn(async move { links::ask(&state, &member, lease).await });
}
}
}
-/// The snapshot: the node, its figures, its peers, and the map.
-async fn build(state: &AppState, graph: &Graph) -> JsonValue {
+/// The snapshot: the node, its figures, its peers, and the map (`live`: its links
+/// subscribed to).
+async fn build(state: &AppState, graph: &Graph, live: bool) -> JsonValue {
// The peers once, for their list and the figures' totals over them.
let list = super::peers::peer_list(state).await;
let stats = serde_json::to_value(super::stats::collect(state, &list).await).unwrap_or_default();
@@ -319,6 +331,7 @@ async fn build(state: &AppState, graph: &Graph) -> JsonValue {
"intended": intended,
"measured": measured,
"clocks": clocks,
+ "live": live,
},
})
}
@@ -416,6 +429,7 @@ pub(crate) fn router(state: AppState) -> Router<AppState> {
.route("/admin/dashboard/state", get(current_state))
.route("/admin/dashboard/events", get(events))
.route("/admin/dashboard/refresh", post(refresh))
+ .route("/admin/dashboard/links", post(set_links))
.layer(Extension(Hub::new(state, closing)))
}
@@ -454,6 +468,29 @@ async fn refresh(Extension(hub): Extension<Arc<Hub>>) -> StatusCode {
StatusCode::NO_CONTENT
}
+#[derive(serde::Deserialize)]
+struct LinksQuery {
+ live: bool,
+}
+
+/// `POST /admin/dashboard/links?live=…`: the members' links subscribed to (`true`), or
+/// only asked for every `LINKS_EVERY` (`false`), for every client; the subscriptions
+/// ended at once, and the members asked again now.
+async fn set_links(
+ Extension(hub): Extension<Arc<Hub>>,
+ Query(query): Query<LinksQuery>,
+) -> StatusCode {
+ hub.live.store(query.live, Ordering::Relaxed);
+ if !query.live {
+ for member in hub.state.links.subscribed() {
+ links::unsubscribe(&hub.state, &member).await;
+ }
+ }
+ hub.want();
+ hub.poke.notify_one();
+ StatusCode::NO_CONTENT
+}
+
/// `GET /admin/dashboard/state`: the latest snapshot, for a client that wants it whole.
async fn current_state(Extension(hub): Extension<Arc<Hub>>) -> Response {
hub.want();