diff options
| author | tv <tv@krebsco.de> | 2026-11-07 22:35:10 +0100 |
|---|---|---|
| committer | tv <tv@krebsco.de> | 2026-11-07 22:35:10 +0100 |
| commit | 0e03a77bcb1d39f1fbda23d8e704ac40302afb93 (patch) | |
| tree | 79245416e9721cdccb1b6879b8f1cfc43629cc74 /src/api/dashboard | |
| parent | c016fc783ff29e9069a446e17876046df64760a8 (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.html | 143 | ||||
| -rw-r--r-- | src/api/dashboard/links.rs | 216 | ||||
| -rw-r--r-- | src/api/dashboard/mod.rs | 132 |
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, } |
