aboutsummaryrefslogtreecommitdiffstats
path: root/src/api/dashboard
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-11-07 22:35:10 +0100
committertv <tv@krebsco.de>2026-11-07 22:35:10 +0100
commit0e03a77bcb1d39f1fbda23d8e704ac40302afb93 (patch)
tree79245416e9721cdccb1b6879b8f1cfc43629cc74 /src/api/dashboard
parentc016fc783ff29e9069a446e17876046df64760a8 (diff)
dashboard: without LINKS; LINKS removed
The dashboard reads every member's links from the members themselves; LINKS and LINKS_REPLY, swarm::links and their wire tags go (wire version bumped). Nodes the browser can't reach are drawn from what the reachable ones measure. Auth plan M3.
Diffstat (limited to 'src/api/dashboard')
-rw-r--r--src/api/dashboard/dashboard.html143
-rw-r--r--src/api/dashboard/links.rs216
-rw-r--r--src/api/dashboard/mod.rs132
3 files changed, 272 insertions, 219 deletions
diff --git a/src/api/dashboard/dashboard.html b/src/api/dashboard/dashboard.html
index 5b776cd..57a3a0f 100644
--- a/src/api/dashboard/dashboard.html
+++ b/src/api/dashboard/dashboard.html
@@ -197,11 +197,6 @@
/* After the sliders, as tall; the kinds and the sliders greyed while the dots are off. */
.legend button.toggle { float: none; margin-left: 0; line-height: 1em; }
.legend.off .message, .legend.off .slider { opacity: .45; }
- /* The LINKS kind, a toggle: shown (in the console, as dots, in the rates) or not. */
- .legend button.message::before { content: ""; display: inline-block; vertical-align: middle;
- margin-right: 4px; }
- .legend button.message.hidden-kind { text-decoration: line-through; }
- #console.no-links > div.links-kind { display: none; }
/* The map and, over its top, its controls (links, layout, legend) and the legend:
they take no room from the map, which stays as it is, the legend shown or hidden.
What is under them shows through; their contents are on an underlay of their own
@@ -316,7 +311,6 @@
<div class="map-area">
<div class="map-overlay">
<div class="map-controls">
- <button id="links-toggle" class="toggle">links: live</button>
<button id="rtt-toggle" class="toggle">layout: static</button>
<button id="tree-toggle" class="toggle">tree: off</button>
<button id="legend-toggle" class="toggle">hide legend</button>
@@ -324,8 +318,8 @@
<div id="legends">
<div class="legend">
<span class="node self">this node</span>
- <span class="node">reports its links</span>
- <span class="node silent">reports none (no LINKS_REPLY)</span>
+ <span class="node">its links known</span>
+ <span class="node silent">its links unknown (not subscribed)</span>
<span class="node dormant-node">dormant (to this node)</span>
<span class="node clock-off">clock off the swarm's</span>
<span class="node focus">in focus (click a node)</span>
@@ -398,7 +392,7 @@
<div id="column-hint" class="note">Drag the space left of these boxes to the right to
hide the column.</div>
<section id="console-box">
- <h2>Console <label class="choice" title="Only the messages with the node or over the link in focus (clicked on the map or in the peers); all while nothing is"><input type="checkbox" id="console-filter">filter selected</label><span id="console-following" class="following" title="Scrolled to the bottom: new messages are scrolled to as they come; scroll up to stop">following…</span><button id="console-clear" class="toggle inline" title="Empty this page's console: what comes from now on fills it again">clear</button><span id="console-rates" class="rates" title="Messages and bytes per second sent and received, as load averages are: over the last 1, 5 and 15 minutes (exponentially weighted), since the page was opened; with the filter, those with the node or over the link in focus; without LINKS while their kind is off in the map's legend (they go only while a dashboard asks)"></span></h2>
+ <h2>Console <label class="choice" title="Only the messages with the node or over the link in focus (clicked on the map or in the peers); all while nothing is"><input type="checkbox" id="console-filter">filter selected</label><span id="console-following" class="following" title="Scrolled to the bottom: new messages are scrolled to as they come; scroll up to stop">following…</span><button id="console-clear" class="toggle inline" title="Empty this page's console: what comes from now on fills it again">clear</button><span id="console-rates" class="rates" title="Messages and bytes per second sent and received, as load averages are: over the last 1, 5 and 15 minutes (exponentially weighted), since the page was opened; with the filter, those with the node or over the link in focus"></span></h2>
<div class="scroll" id="console-scroll"><div id="console" class="console"></div>
<div id="console-none" class="note" hidden>No information available</div></div>
</section>
@@ -1953,7 +1947,6 @@ 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.
@@ -1994,21 +1987,6 @@ legendToggle.addEventListener("click", () => {
});
// Hidden as on the page it was opened from (`handOver`).
if (handOver && handOver.legend === false) showLegend(false);
-// 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(() => {});
-});
// A writer's tree, shown and hidden (`treeShown`).
const treeToggle = document.getElementById("tree-toggle");
function showTreeToggle() {
@@ -2089,21 +2067,10 @@ function handOff(id) {
focused: focused === id ? null : focused,
settings,
legend: !document.getElementById("legends").hidden,
- links: state ? state.graph.live !== false : undefined,
figures: [...open],
document: [...documentOpen],
}));
}
-// Links live or every 30 s as on the page it was opened from (`handOver`), once its
-// first state says otherwise. The node's choice: for every page open on it.
-let linksHandedOver = false;
-function handOverLinks() {
- if (linksHandedOver || !handOver || typeof handOver.links !== "boolean") return;
- linksHandedOver = true;
- if ((state.graph.live !== false) !== handOver.links) {
- fetch("dashboard/links?live=" + handOver.links, { method: "POST" }).catch(() => {});
- }
-}
// `[x, y]` turned by `angle`.
function turned(x, y, angle) {
const c = Math.cos(angle), s = Math.sin(angle);
@@ -2744,8 +2711,7 @@ function messages(byKind) {
// The colour of a kind of message (`traffic`'s kinds), on the map and in the console;
// the replies as their requests.
const KIND_COLOURS = [["sync", "#ea580c"], ["blob", "#a16207"], ["hello", "#16a34a"],
- ["status", "#e11d48"], ["identity", "#0d9488"], ["paths", "var(--muted)"],
- ["links", "#db2777"]];
+ ["status", "#e11d48"], ["identity", "#0d9488"], ["paths", "var(--muted)"]];
const kindColour = kind => (KIND_COLOURS.find(([name]) => kind === name ||
kind === name + "Reply") || [null, "var(--fg)"])[1];
// How large the dots are drawn, times `dotRadius` (the slider after their legend, kept
@@ -2759,39 +2725,6 @@ let flowSpeed = .3;
try { flowSpeed = Number(localStorage.getItem("continuum-dot-speed")) || .3; } catch (_) {}
flowSpeed = Math.min(.9, Math.max(.1, flowSpeed));
let dotSlider = null, speedSlider = null;
-// LINKS and their replies (`traffic`'s `links`), and whether they are shown
-// (`linksToggleInLegend`).
-const isLinks = kind => kind === "links";
-let linksShown = true;
-try { linksShown = localStorage.getItem("continuum-links-shown") !== "off"; } catch (_) {}
-// The LINKS kind in the legend, a toggle: whether LINKS and their replies, which go only
-// while a dashboard asks for the members' links, are shown: in the console, as dots on
-// the map, and counted in the console's rates. Off, what is shown is the swarm's own
-// traffic (kept in this browser).
-function linksToggleInLegend(colour) {
- const button = el("button", { class: "toggle message", style: "--colour: " + colour }, "links");
- const show = () => {
- button.classList.toggle("hidden-kind", !linksShown);
- document.getElementById("console").classList.toggle("no-links", !linksShown);
- button.title = linksShown
- ? "LINKS go only while a dashboard asks for the members' links. Click to leave them out: of the console, the dots and the rates"
- : "Left out of the console, the dots and the rates. Click to show them again";
- };
- button.addEventListener("click", () => {
- linksShown = !linksShown;
- try { localStorage.setItem("continuum-links-shown", linksShown ? "on" : "off"); } catch (_) {}
- if (!linksShown) {
- for (let i = flows.length - 1; i >= 0; i--) if (isLinks(flows[i].kind)) flows.splice(i, 1);
- }
- show();
- showRates();
- filterConsole();
- const scroll = document.getElementById("console-scroll");
- scroll.scrollTop = scroll.scrollHeight;
- });
- show();
- return button;
-}
function showMessagesLegend() {
const legend = document.getElementById("messages-legend");
legend.classList.toggle("off", !flowsShown);
@@ -2811,9 +2744,8 @@ function showMessagesLegend() {
try { localStorage.setItem("continuum-dot-speed", String(flowSpeed)); } catch (_) {}
});
// Before the toggle, which ends the line.
- legend.prepend(...KIND_COLOURS.map(([kind, colour]) => kind === "links"
- ? linksToggleInLegend(colour)
- : el("span", { class: "message", style: "--colour: " + colour },
+ legend.prepend(...KIND_COLOURS.map(([kind, colour]) =>
+ el("span", { class: "message", style: "--colour: " + colour },
kind === "paths" ? "paths (pings)" : kind)),
el("label", { class: "slider" }, "size", slider),
el("label", { class: "slider" }, "speed", speed));
@@ -2876,7 +2808,7 @@ function addFlows(node, current, before, skip) {
for (const [kind, count] of Object.entries(counts[way])) {
const earlier = was[way][kind] || { messages: 0, bytes: 0 };
const grown = Math.max(0, count.messages - earlier.messages);
- if (grown > 0 && (linksShown || !isLinks(kind))) {
+ if (grown > 0) {
kinds[kind] = { messages: grown, bytes: Math.max(0, count.bytes - earlier.bytes) };
total += grown;
}
@@ -2923,7 +2855,6 @@ function remoteMessagesArrived(id, entry, list) {
const now = performance.now();
const links = new Map();
for (const message of kept) {
- if (!linksShown && isLinks(message.counted)) continue;
const key = message.from + " " + message.to;
const link = links.get(key) || { from: message.from, to: message.to, kinds: {}, total: 0 };
const kind = link.kinds[message.counted] || (link.kinds[message.counted] = { messages: 0, bytes: 0 });
@@ -2949,44 +2880,37 @@ function remoteMessagesArrived(id, entry, list) {
// weighs in by how long ago it was, `e^(-age/window)`), from the counts each update
// brings. The first sample is taken as it is, so they needn't climb from 0 first. ---
const RATE_WINDOWS_S = [60, 300, 900];
-// By peer, and in all (`null`), with LINKS (`all`) and without (`withoutLinks`): the
-// averages (`messages`, `bytes`: per window); and when the counts were last taken.
+// By peer, and in all (`null`): the averages (`messages`, `bytes`: per window); and when
+// the counts were last taken.
const rates = new Map();
let ratesAt = null;
function measureRates(current) {
const now = performance.now();
if (lastTraffic !== null && ratesAt !== null && now > ratesAt) {
const secs = (now - ratesAt) / 1000;
- // Of `what` (`messages`, `bytes`), sent and received, by how much each grew; LINKS
- // left out if `withoutLinks`.
- const grown = (counts, before, what, withoutLinks) => ["sent", "received"].reduce((sum, way) => {
- const total = of => Object.entries(of[way]).reduce((sum, [kind, count]) =>
- withoutLinks && isLinks(kind) ? sum : sum + count[what], 0);
+ // Of `what` (`messages`, `bytes`), sent and received, by how much each grew.
+ const grown = (counts, before, what) => ["sent", "received"].reduce((sum, way) => {
+ const total = of => Object.values(of[way]).reduce((sum, count) => sum + count[what], 0);
return sum + Math.max(0, total(counts) - total(before));
}, 0);
const average = (known, rate) => known ? known.map((average, i) =>
rate + (average - rate) * Math.exp(-secs / RATE_WINDOWS_S[i])) : RATE_WINDOWS_S.map(() => rate);
- const sample = (key, variant, messages, bytes) => {
- const known = rates.get(key) || {};
- const was = known[variant];
- known[variant] = { messages: average(was && was.messages, messages / secs),
- bytes: average(was && was.bytes, bytes / secs) };
- rates.set(key, known);
+ const sample = (key, messages, bytes) => {
+ const was = rates.get(key);
+ rates.set(key, { messages: average(was && was.messages, messages / secs),
+ bytes: average(was && was.bytes, bytes / secs) });
};
- for (const variant of ["all", "withoutLinks"]) {
- const withoutLinks = variant === "withoutLinks";
- let messagesInAll = 0, bytesInAll = 0;
- for (const [id, counts] of current) {
- const before = lastTraffic.get(id);
- if (!before) continue;
- const messages = grown(counts, before, "messages", withoutLinks);
- const bytes = grown(counts, before, "bytes", withoutLinks);
- sample(id, variant, messages, bytes);
- messagesInAll += messages;
- bytesInAll += bytes;
- }
- sample(null, variant, messagesInAll, bytesInAll);
+ let messagesInAll = 0, bytesInAll = 0;
+ for (const [id, counts] of current) {
+ const before = lastTraffic.get(id);
+ if (!before) continue;
+ const messages = grown(counts, before, "messages");
+ const bytes = grown(counts, before, "bytes");
+ sample(id, messages, bytes);
+ messagesInAll += messages;
+ bytesInAll += bytes;
}
+ sample(null, messagesInAll, bytesInAll);
// Peers gone from the list: their averages too.
for (const key of [...rates.keys()]) if (key !== null && !current.has(key)) rates.delete(key);
}
@@ -2994,13 +2918,11 @@ function measureRates(current) {
showRates();
}
// The averages shown: in all, or with the filter, of the peer in focus; none (–) for a
-// link between other nodes, a node that is no peer, or before two updates came. With
-// LINKS or without (`linksShown`).
+// link between other nodes, a node that is no peer, or before two updates came.
function showRates() {
const filtering = consoleFiltered && focused !== null;
const peer = filtering ? peerOf(focused) : null;
- const both = filtering && peer === null ? undefined : rates.get(peer);
- const shown = both && both[linksShown ? "all" : "withoutLinks"];
+ const shown = filtering && peer === null ? undefined : rates.get(peer);
const format = rate => rate < 100 ? rate.toFixed(2) : String(Math.round(rate));
// Bytes in the unit the largest of the three needs, all three alike.
const largest = shown ? Math.max(...shown.bytes) : 0;
@@ -3109,8 +3031,7 @@ function filterConsole() {
const filtering = consoleFiltered && focused !== null;
log.classList.toggle("filtering", filtering);
document.getElementById("console-none").hidden =
- !filtering || log.querySelector(":scope > div.peer-focused" +
- (linksShown ? "" : ":not(.links-kind)")) !== null;
+ !filtering || log.querySelector(":scope > div.peer-focused") !== null;
const view = filtering ? focused : null;
if (view !== consoleView) {
consoleView = view;
@@ -3128,7 +3049,6 @@ function consoleLine(message) {
"data-peer": message.node ? "" : message.peer,
"data-from": message.from || "", "data-to": message.to || "",
class: (shownMessage && shownMessage.seq === message.seq ? "focused" : "") +
- (isLinks(message.counted) ? " links-kind" : "") +
(relatedMarked !== null && message.related === relatedMarked ? " related" : "") +
peerClasses(message),
title: message.way === "refused" ? "refused: the transport wouldn't send it"
@@ -3273,7 +3193,6 @@ function connect() {
status("live", true); pulse(); render(); trafficArrived(); epochsArrived();
remakesArrived();
if (treeShown && writerTree === null) loadTree();
- handOverLinks();
if (token === null && tokenTimer === null) obtainTokens();
for (const id of subscriptions) if (!subscribed.has(id)) follow(id);
});
@@ -3451,8 +3370,8 @@ document.addEventListener("visibilitychange", () => {
}
}
});
-// A click on the status: live, everything now (a tick on the node, the members asked
-// again, what's open of the document read again); otherwise, reconnecting now.
+// A click on the status: live, everything now (a tick on the node, what's open of the
+// document read again); otherwise, reconnecting now.
connection.title = "Click to refresh now";
connection.style.cursor = "pointer";
connection.addEventListener("click", () => {
diff --git a/src/api/dashboard/links.rs b/src/api/dashboard/links.rs
new file mode 100644
index 0000000..1f0b886
--- /dev/null
+++ b/src/api/dashboard/links.rs
@@ -0,0 +1,216 @@
+//! This node's links, as it measures them, for the dashboard's map (`own_links`): its
+//! own in the state, and to any page that subscribes to them
+//! (`GET /admin/dashboard/links/own/events`, also from another member's page).
+
+use crate::state::AppState;
+use crate::swarm::topology;
+use crate::transport::paths::{self, ClockOffset};
+use crate::transport::{Connection, Destination};
+
+/// Links listed at most (a node has about `--neighbours`, more if others prefer it).
+const MAX_LINKS: usize = 256;
+/// Other transports in a link at most.
+const MAX_OTHERS: usize = 8;
+/// One of this node's links.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) struct Link {
+ /// The peer's node ID.
+ pub(crate) peer: String,
+ /// The transport its messages go over now (the scheme of the path in use), if any.
+ pub(crate) transport: Option<String>,
+ /// Only through a relay (libp2p).
+ pub(crate) relayed: bool,
+ /// The path in use is down: none to the peer answers (`paths`), whatever its
+ /// connection was.
+ pub(crate) down: bool,
+ /// The round trip on that path, in microseconds, if measured.
+ pub(crate) rtt_us: Option<u32>,
+ /// Silent for long (`peers.md`, dormancy).
+ pub(crate) dormant: bool,
+ /// The peer's other transports (`MAX_OTHERS` at most): where else it proved
+ /// itself, those paths up.
+ pub(crate) others: Vec<OtherPath>,
+ /// How far the peer's clock is ahead of this node's (`paths::ClockOffset`), if
+ /// measured: with other members', the page shows whose clock is off.
+ pub(crate) clock: Option<ClockOffset>,
+}
+
+/// A transport of a link besides the one in use.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) struct OtherPath {
+ /// Its scheme (`mycelium`, `libp2p`, `tor`).
+ pub(crate) transport: String,
+ /// The round trip of its fastest path, in microseconds, if any is measured.
+ pub(crate) rtt_us: Option<u32>,
+ /// Only through a relay (libp2p): no direct connection at any of its paths.
+ pub(crate) relayed: bool,
+}
+
+/// This node's links: its neighbours, and the peers it holds dormant, as it measures
+/// them. A neighbour that goes dormant is one no more (it is unreachable now), but its
+/// link stays listed, dormant, until it is heard from again: left out, the link would
+/// show only as that peer last reported it, from before.
+pub(crate) async fn own_links(state: &AppState) -> Vec<Link> {
+ let mut links = Vec::new();
+ for (peer, peer_state) in state.peers_snapshot().await {
+ let dormant = peer_state.lock().await.liveness.is_dormant();
+ if !dormant && !topology::is_neighbour(state, &peer) {
+ continue;
+ }
+ let destination = state.transport.destination(&peer);
+ let transport = destination
+ .as_ref()
+ .and_then(|destination| scheme_of(&destination.address));
+ links.push(Link {
+ others: others(destination.as_ref(), transport.as_deref()),
+ transport,
+ relayed: destination
+ .as_ref()
+ .is_some_and(|destination| destination.connection == Some(Connection::Relayed)),
+ // Ranked up before down: the first is down only if all are.
+ down: destination
+ .as_ref()
+ .and_then(|destination| destination.paths.first())
+ .is_some_and(|path| path.down),
+ // The path in use's (`Transport::rtt`, the first of `destination.paths`).
+ rtt_us: state
+ .transport
+ .rtt(&peer)
+ .map(|rtt| u32::try_from(rtt.as_micros()).unwrap_or(u32::MAX)),
+ dormant,
+ clock: state.transport.clock_offset(&peer),
+ peer,
+ });
+ }
+ links.sort_by(|a, b| a.peer.cmp(&b.peer));
+ links.truncate(MAX_LINKS);
+ links
+}
+
+/// The scheme of `address` (`mycelium:…`): its transport (`paths::scheme_of`), if it
+/// has one.
+fn scheme_of(address: &str) -> Option<String> {
+ Some(paths::scheme_of(address))
+ .filter(|scheme| !scheme.is_empty())
+ .map(str::to_owned)
+}
+
+/// The transports besides `used` the peer proved itself at, with a path up: each with
+/// the round trip of its fastest path, if any is measured, and whether all of them go
+/// through a relay.
+fn others(destination: Option<&Destination>, used: Option<&str>) -> Vec<OtherPath> {
+ let mut others: Vec<OtherPath> = Vec::new();
+ for path in destination.map_or(&[][..], |destination| &destination.paths[..]) {
+ let Some(transport) = scheme_of(&path.address) else {
+ continue;
+ };
+ if path.down || used == Some(transport.as_str()) {
+ continue;
+ }
+ let rtt_us = path
+ .rtt_ms
+ .filter(|ms| ms.is_finite() && *ms >= 0.0)
+ .map(|ms| (ms * 1000.0).min(f64::from(u32::MAX)) as u32);
+ let relayed = path.connection == Some(Connection::Relayed);
+ match others.iter_mut().find(|other| other.transport == transport) {
+ Some(other) => {
+ other.rtt_us = match (other.rtt_us, rtt_us) {
+ (Some(a), Some(b)) => Some(a.min(b)),
+ (a, b) => a.or(b),
+ };
+ other.relayed &= relayed;
+ }
+ None => others.push(OtherPath {
+ transport,
+ rtt_us,
+ relayed,
+ }),
+ }
+ }
+ others.sort_by(|a, b| a.transport.cmp(&b.transport));
+ others.truncate(MAX_OTHERS);
+ others
+}
+
+/// Whether `new` says something `old` didn't: another peer, transport, relay or
+/// dormancy, a round trip measured or moved by more than a fifth (and 1 ms), or a clock
+/// measured or moved beyond both bounds. Round trips and clock samples wobble; small
+/// moves aren't news.
+pub(crate) fn changed(old: &[Link], new: &[Link]) -> bool {
+ let clock_moved = |a: Option<ClockOffset>, b: Option<ClockOffset>| match (a, b) {
+ (Some(a), Some(b)) => {
+ a.offset_ms.abs_diff(b.offset_ms) > a.within_ms.saturating_add(b.within_ms)
+ }
+ (a, b) => a.is_some() != b.is_some(),
+ };
+ let moved = |a: Option<u32>, b: Option<u32>| match (a, b) {
+ (Some(a), Some(b)) => {
+ let by = a.abs_diff(b);
+ by > 1000 && by > a.max(b) / 5
+ }
+ (a, b) => a.is_some() != b.is_some(),
+ };
+ old.len() != new.len()
+ || old.iter().zip(new).any(|(a, b)| {
+ a.peer != b.peer
+ || a.transport != b.transport
+ || a.relayed != b.relayed
+ || a.down != b.down
+ || a.dormant != b.dormant
+ || moved(a.rtt_us, b.rtt_us)
+ || clock_moved(a.clock, b.clock)
+ || a.others.len() != b.others.len()
+ || a.others.iter().zip(&b.others).any(|(x, y)| {
+ x.transport != y.transport
+ || x.relayed != y.relayed
+ || moved(x.rtt_us, y.rtt_us)
+ })
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn link(peer: &str, rtt_us: Option<u32>) -> Link {
+ Link {
+ peer: peer.into(),
+ transport: Some("libp2p".into()),
+ relayed: false,
+ down: false,
+ rtt_us,
+ dormant: false,
+ others: Vec::new(),
+ clock: None,
+ }
+ }
+
+ #[test]
+ fn only_news_is_a_change() {
+ let old = [link("a", Some(10_000))];
+ assert!(!changed(&old, &[link("a", Some(11_000))]));
+ assert!(changed(&old, &[link("a", Some(13_000))]));
+ assert!(changed(&old, &[link("a", None)]));
+ assert!(changed(&old, &[link("b", Some(10_000))]));
+ assert!(changed(&old, &[]));
+ let mut dormant = link("a", Some(10_000));
+ dormant.dormant = true;
+ assert!(changed(&old, &[dormant]));
+ let mut down = link("a", Some(10_000));
+ down.down = true;
+ assert!(changed(&old, &[down]));
+ // Small round trips: a millisecond's wobble isn't news.
+ assert!(!changed(&[link("a", Some(400))], &[link("a", Some(1_300))]));
+ // A clock: news when measured, and when it moved beyond both bounds.
+ let clocked = |offset_ms| Link {
+ clock: Some(ClockOffset {
+ offset_ms,
+ within_ms: 50,
+ }),
+ ..link("a", Some(10_000))
+ };
+ assert!(changed(&old, &[clocked(0)]));
+ assert!(!changed(&[clocked(0)], &[clocked(80)]));
+ assert!(changed(&[clocked(0)], &[clocked(5_000)]));
+ }
+}
diff --git a/src/api/dashboard/mod.rs b/src/api/dashboard/mod.rs
index 78e8352..9cb4ea4 100644
--- a/src/api/dashboard/mod.rs
+++ b/src/api/dashboard/mod.rs
@@ -10,16 +10,14 @@
//! themselves: they alone changing is no change, and the page counts them on.
//!
//! 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. 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.
+//! sees it), and the links this node measures (`own_links`). Other members' links the
+//! page reads from them itself, from the nodes it subscribes to
+//! (`GET /admin/dashboard/links/own/events`, there).
+
+mod links;
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;
@@ -35,23 +33,16 @@ use tokio::time::Instant;
use crate::locks::MutexExt;
use crate::state::AppState;
-use crate::swarm::{links, members, topology};
-use crate::transport::{Hex, Link};
+use crate::swarm::{members, topology};
+use crate::transport::Hex;
+use links::Link;
/// How often the snapshot is built and compared, while anyone looks.
const TICK: Duration = Duration::from_secs(2);
-/// How often the subscriptions to the members' links are renewed (each renewal is
-/// answered: a reply at least this often), and the graph recomputed; changes come in
-/// between, as they happen (`swarm::links`).
-const LINKS_EVERY: Duration = Duration::from_secs(30);
-/// How long a subscription lasts unless renewed: a node that is gone stops it.
-const LINKS_LEASE: Duration = Duration::from_secs(90);
-/// A member's links older than this are left out (it didn't answer since).
-const LINKS_KEPT_FOR: Duration = Duration::from_secs(90);
+/// How often the member list's graph is recomputed (it costs about `members²`).
+const GRAPH_EVERY: Duration = Duration::from_secs(30);
/// The producer runs on this long after the last request for the state.
const LINGER: Duration = Duration::from_secs(60);
-/// Members asked for their links at most, each round.
-const MAX_ASKED: usize = 512;
/// A snapshot or a patch as published: its number, and its JSON. A snapshot's text is
/// made when first asked for (a client connecting, or one that fell behind), not every
@@ -104,11 +95,8 @@ struct Hub {
/// to the clients connected: a JSON list.
messages: broadcast::Sender<Arc<String>>,
control: Mutex<Control>,
- /// Someone asked for it now (`refresh`): a tick at once, members asked again.
+ /// Someone asked for it now (`refresh`): a tick at once, the graph recomputed.
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>,
@@ -133,7 +121,6 @@ impl Hub {
documents,
messages,
poke: Notify::new(),
- live: AtomicBool::new(true),
control: Mutex::new(Control {
running: false,
wanted_until: Instant::now(),
@@ -186,12 +173,11 @@ impl Hub {
log.watch(true);
let mut logged = 0u64;
loop {
- let live = self.live.load(Ordering::Relaxed);
if Instant::now() >= graph_due {
- graph.refresh(&self.state, live).await;
- graph_due = Instant::now() + LINKS_EVERY;
+ graph.refresh(&self.state);
+ graph_due = Instant::now() + GRAPH_EVERY;
}
- let snapshot = build(&self.state, &graph, live).await;
+ let snapshot = build(&self.state, &graph).await;
let stripped = without_ages(&snapshot);
let changed = last.as_ref().is_none_or(|(_, old)| *old != stripped);
if changed {
@@ -236,11 +222,7 @@ impl Hub {
return;
}
if unwanted() {
- // Nobody looks: the members needn't tell us any more. Unless someone
- // came meanwhile, then it goes on (and subscribes again).
- for member in self.state.links.subscribed() {
- links::unsubscribe(&self.state, &member).await;
- }
+ // Nobody looks: it stops. Unless someone came meanwhile, then it goes on.
let mut control = self.control.lock_or_recover();
if self.patches.receiver_count() == 0 && Instant::now() >= control.wanted_until {
log.watch(false);
@@ -314,34 +296,20 @@ enum Next {
Closing,
}
-/// The intended graph, recomputed every `LINKS_EVERY` (it costs about `members²`), when
-/// members are asked for their links too: subscribed to, if `live`.
+/// The intended graph, recomputed every `GRAPH_EVERY`.
#[derive(Default)]
struct Graph {
intended: BTreeMap<String, BTreeSet<String>>,
}
impl Graph {
- async fn refresh(&mut self, state: &AppState, live: bool) {
- let lease = if live { LINKS_LEASE } else { Duration::ZERO };
+ fn refresh(&mut self, state: &AppState) {
self.intended = topology::intended_graph(state).unwrap_or_default();
- let members: Vec<String> = {
- let replica = state.doc.read().await;
- members::members(&replica.doc)
- };
- let members: Vec<String> = members
- .into_iter()
- .filter(|member| member != state.local_id())
- .take(MAX_ASKED)
- .collect();
- // Those we can't reach, through peers that can (`links::ask_all`).
- links::ask_all(state, members, lease).await;
}
}
-/// 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 snapshot: the node, its figures, its peers, and the map.
+async fn build(state: &AppState, graph: &Graph) -> 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();
@@ -369,43 +337,18 @@ async fn build(state: &AppState, graph: &Graph, live: bool) -> JsonValue {
.map(|(member, neighbours)| (member.clone(), json!(neighbours)))
.collect();
let own = links::own_links(state).await;
- let replies: Vec<_> = state
- .links
- .replies()
- .into_iter()
- .filter(|(_, age, _)| *age <= LINKS_KEPT_FOR)
- .collect();
- // With each its epoch: the page shows who entered a new one.
- let epochs = state.links.epochs();
- let epoch_json = |epoch: Option<crate::transport::Epoch>| {
- epoch.map(|epoch| json!(crate::document::replica::EpochRecord::from(epoch)))
- };
+ // With its epoch: the page shows who entered a new one.
let mut measured = Map::new();
measured.insert(
local.to_owned(),
json!({ "secsAgo": 0, "links": links_json(&own),
- "epoch": epoch_json(Some(state.local_epoch())) }),
+ "epoch": crate::document::replica::EpochRecord::from(state.local_epoch()) }),
);
- for (member, age, member_links) in &replies {
- measured.insert(
- member.clone(),
- json!({ "secsAgo": age.as_secs(), "links": links_json(member_links),
- "epoch": epoch_json(epochs.get(member).copied()) }),
- );
- }
// Everyone on the map: in the member list's graph, measured, or at the other end of
- // a link measured. A node that joined through others is linked to them before its
+ // a link measured. A node that joined through this one is linked to it before its
// registration reaches this node, and before the graph is computed again
- // (`LINKS_EVERY`); it shows then, hollow (no links of its own reported).
- let linked: BTreeSet<&String> = own
- .iter()
- .chain(
- replies
- .iter()
- .flat_map(|(_, _, member_links)| member_links.iter()),
- )
- .map(|link| &link.peer)
- .collect();
+ // (`GRAPH_EVERY`); it shows then, hollow (no links of its own reported).
+ let linked: BTreeSet<&String> = own.iter().map(|link| &link.peer).collect();
let mut members_json = Map::new();
for member in graph.intended.keys().chain(measured.keys()).chain(linked) {
members_json.insert(
@@ -423,7 +366,6 @@ async fn build(state: &AppState, graph: &Graph, live: bool) -> JsonValue {
"members": members_json,
"intended": intended,
"measured": measured,
- "live": live,
},
"remakes": remakes,
})
@@ -522,7 +464,6 @@ 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))
.route("/admin/dashboard/tree", get(writer_tree))
.route("/admin/dashboard/links/own", get(own_links_now))
.route("/admin/dashboard/links/own/events", get(own_links_events))
@@ -667,7 +608,7 @@ async fn page_html() -> Html<&'static str> {
}
/// `POST /admin/dashboard/refresh`: a tick now rather than at the next `TICK`, and the
-/// members asked for their links again; what changed comes as events, as ever.
+/// graph recomputed; what changed comes as events, as ever.
async fn refresh(Extension(hub): Extension<Arc<Hub>>) -> StatusCode {
hub.want();
hub.poke.notify_one();
@@ -675,29 +616,6 @@ async fn refresh(Extension(hub): Extension<Arc<Hub>>) -> StatusCode {
}
#[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
-}
-
-#[derive(serde::Deserialize)]
struct TreeQuery {
writer: String,
}