aboutsummaryrefslogtreecommitdiffstats
path: root/src
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
commitab880c042707877f2d5adb57a706cef59dee0810 (patch)
tree42bf41beb4248f77da7dc997f1b65e1843d23923 /src
parentab6a83c5c666dfc8603f0eae1537c30f7ee61ee1 (diff)
dashboard: allow delayed links
Diffstat (limited to 'src')
-rw-r--r--src/api/dashboard/dashboard.html19
-rw-r--r--src/api/dashboard/mod.rs55
2 files changed, 64 insertions, 10 deletions
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();