diff options
| author | tv <tv@krebsco.de> | 2026-11-01 05:11:48 +0100 |
|---|---|---|
| committer | tv <tv@krebsco.de> | 2026-11-01 05:11:48 +0100 |
| commit | ab880c042707877f2d5adb57a706cef59dee0810 (patch) | |
| tree | 42bf41beb4248f77da7dc997f1b65e1843d23923 | |
| parent | ab6a83c5c666dfc8603f0eae1537c30f7ee61ee1 (diff) | |
dashboard: allow delayed links
| -rw-r--r-- | docs/openapi.yaml | 24 | ||||
| -rw-r--r-- | src/api/dashboard/dashboard.html | 19 | ||||
| -rw-r--r-- | src/api/dashboard/mod.rs | 55 |
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(); |
