aboutsummaryrefslogtreecommitdiffstats
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
commitc016fc783ff29e9069a446e17876046df64760a8 (patch)
tree036850a3e3a580dfa5b73f91be53b59c267ce82a
parent7c6716358503abee5fab960c45e5fc2de5f61203 (diff)
dashboard: clocks on the page
The page compares the clocks the reports on its map carry: this node's peers', and those of the nodes it subscribes to; the node no longer gathers them from LINKS (api/dashboard/clocks.rs goes). Auth plan M3 (preparing LINKS' removal).
-rw-r--r--docs/dashboard.md5
-rw-r--r--src/api/dashboard/clocks.rs127
-rw-r--r--src/api/dashboard/dashboard.html51
-rw-r--r--src/api/dashboard/mod.rs15
4 files changed, 53 insertions, 145 deletions
diff --git a/docs/dashboard.md b/docs/dashboard.md
index 80e9c19..5a66afd 100644
--- a/docs/dashboard.md
+++ b/docs/dashboard.md
@@ -77,8 +77,9 @@ Every member of the swarm, by alias (or a shortened node ID):
* **Red outline**: this node holds it dormant (it has been silent, or said farewell).
* **Dashed orange ring**: its clock is off the swarm's: it differs from the median of
the members' clocks by more than `--clock-skew-warn`, beyond what the measurements
- allow. The members report their peers' clocks with their links, so this works for
- members this node doesn't reach itself; it needs three members compared.
+ allow. The page compares the clocks the reports on the map carry: this node's peers',
+ and the peers' of the nodes you subscribe to, so subscribing reaches members this node
+ doesn't; it needs three members compared.
* **Amber**: it coordinates a remake of this node's epoch (then also with a golden
halo, below), or voted for one, while that remake is open: voting, or committed until
the member switched. A small amber breath as a remake is proposed (its coordinator)
diff --git a/src/api/dashboard/clocks.rs b/src/api/dashboard/clocks.rs
deleted file mode 100644
index 9f069be..0000000
--- a/src/api/dashboard/clocks.rs
+++ /dev/null
@@ -1,127 +0,0 @@
-//! Every member's clock against the swarm's, for the map: from the clocks each member
-//! reports of its peers (`Link::clock`), chained from this node along the narrowest
-//! bounds, then taken against their median. A member is off when it differs from the
-//! median beyond its chained bound and the limit (`--clock-skew-warn`); with fewer than
-//! three members compared, none is (two can't tell whose clock is off). An estimate for
-//! the eye: each node judges its own clock by its peers' (`swarm::skew`).
-
-use std::cmp::Reverse;
-use std::collections::{BTreeMap, BinaryHeap, HashMap};
-
-use serde::Serialize;
-
-use crate::transport::Link;
-
-/// A member's clock against the swarm's.
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
-#[serde(rename_all = "camelCase")]
-pub(super) struct MemberClock {
- /// How far it is ahead of the median (behind if negative).
- pub(super) offset_ms: i64,
- /// Give or take: the bounds chained to it from this node.
- pub(super) within_ms: u64,
- pub(super) off: bool,
-}
-
-/// From `local` and `reports` (each member's links), with `limit_ms`; by member.
-pub(super) fn swarm<'a>(
- local: &str,
- reports: impl IntoIterator<Item = (&'a str, &'a [Link])>,
- limit_ms: u64,
-) -> BTreeMap<String, MemberClock> {
- // Each way between two members: how far the far end's clock is ahead, give or take.
- let mut edges: HashMap<&str, Vec<(&str, i64, u64)>> = HashMap::new();
- for (reporter, links) in reports {
- for link in links {
- if let Some(clock) = link.clock {
- edges.entry(reporter).or_default().push((
- link.peer.as_str(),
- clock.offset_ms,
- clock.within_ms,
- ));
- edges.entry(link.peer.as_str()).or_default().push((
- reporter,
- -clock.offset_ms,
- clock.within_ms,
- ));
- }
- }
- }
- // Ahead of this node's, along the narrowest chained bounds (Dijkstra).
- let mut reached: HashMap<&str, (i64, u64)> = HashMap::new();
- let mut queue = BinaryHeap::from([Reverse((0_u64, local, 0_i64))]);
- while let Some(Reverse((within, member, offset))) = queue.pop() {
- if reached.contains_key(member) {
- continue;
- }
- reached.insert(member, (offset, within));
- for &(next, step, step_within) in edges.get(member).into_iter().flatten() {
- if !reached.contains_key(next) {
- queue.push(Reverse((
- within.saturating_add(step_within),
- next,
- offset.saturating_add(step),
- )));
- }
- }
- }
- let mut offsets: Vec<i64> = reached.values().map(|(offset, _)| *offset).collect();
- offsets.sort_unstable();
- let median = offsets.get(offsets.len() / 2).copied().unwrap_or(0);
- let judged = reached.len() >= 3 && limit_ms > 0;
- reached
- .into_iter()
- .map(|(member, (offset, within))| {
- let offset_ms = offset - median;
- let off = judged && offset_ms.unsigned_abs() > within.saturating_add(limit_ms);
- (
- member.to_owned(),
- MemberClock {
- offset_ms,
- within_ms: within,
- off,
- },
- )
- })
- .collect()
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use crate::transport::paths::ClockOffset;
-
- fn link(peer: &str, offset_ms: i64) -> Link {
- Link {
- peer: peer.into(),
- transport: None,
- relayed: false,
- down: false,
- rtt_us: None,
- dormant: false,
- others: Vec::new(),
- clock: Some(ClockOffset {
- offset_ms,
- within_ms: 20,
- }),
- }
- }
-
- /// Chained through the reports: the one clock 10 s ahead is off, wherever it is.
- #[test]
- fn the_odd_clock_out() {
- // a – b – c – d, d's clock 10 s ahead; a only knows b.
- let a = [link("b", 0)];
- let b = [link("a", 0), link("c", 5)];
- let c = [link("d", 10_000)];
- let clocks = swarm("a", [("a", &a[..]), ("b", &b[..]), ("c", &c[..])], 2_000);
- assert_eq!(clocks.len(), 4);
- assert!(clocks["d"].off);
- assert!(clocks["d"].offset_ms > 9_900);
- assert!(!clocks["a"].off && !clocks["b"].off && !clocks["c"].off);
- assert_eq!(clocks["d"].within_ms, 60);
- // Two alone: none.
- let two = swarm("a", [("a", &[link("b", 10_000)][..])], 2_000);
- assert!(two.values().all(|clock| !clock.off));
- }
-}
diff --git a/src/api/dashboard/dashboard.html b/src/api/dashboard/dashboard.html
index 3f02a64..5b776cd 100644
--- a/src/api/dashboard/dashboard.html
+++ b/src/api/dashboard/dashboard.html
@@ -546,6 +546,52 @@ function name(id) {
}
// Milliseconds as signed seconds: "+5.0 s".
const seconds = ms => (ms < 0 ? "−" : "+") + (Math.abs(ms) / 1000).toFixed(1) + " s";
+// Every member's clock against the swarm's, for the map: from the clocks the reports
+// carry of their peers (`clock` on each link: this node's own and subscribed nodes'),
+// chained from this node along the narrowest bounds, then taken against their median.
+// A member is off when it differs from the median beyond its chained bound and the
+// limit (`--clock-skew-warn`); with fewer than three members compared, none is (two
+// can't tell whose clock is off). An estimate for the eye: each node judges its own
+// clock by its peers' (`clock-warning`). By member: { offsetMs, withinMs, off }.
+function swarmClocks() {
+ // Each way between two members: how far the far end's clock is ahead, give or take.
+ const edges = new Map();
+ const edge = (from, to, offset, within) => {
+ if (!edges.has(from)) edges.set(from, []);
+ edges.get(from).push([to, offset, within]);
+ };
+ for (const [reporter, report] of Object.entries(state.graph.measured)) {
+ for (const link of report.links || []) {
+ if (!link.clock) continue;
+ edge(reporter, link.peer, link.clock.offsetMs, link.clock.withinMs);
+ edge(link.peer, reporter, -link.clock.offsetMs, link.clock.withinMs);
+ }
+ }
+ // Ahead of this node's, along the narrowest chained bounds (Dijkstra; the maps are small).
+ const reached = new Map(), queue = [[0, state.node.id, 0]];
+ while (queue.length) {
+ let next = 0;
+ for (let i = 1; i < queue.length; i++) if (queue[i][0] < queue[next][0]) next = i;
+ const [within, member, offset] = queue.splice(next, 1)[0];
+ if (reached.has(member)) continue;
+ reached.set(member, { offset, within });
+ for (const [to, step, stepWithin] of edges.get(member) || []) {
+ if (!reached.has(to)) queue.push([within + stepWithin, to, offset + step]);
+ }
+ }
+ const offsets = [...reached.values()].map(r => r.offset).sort((a, b) => a - b);
+ const median = offsets[Math.floor(offsets.length / 2)] || 0;
+ const limit = (state.stats.clock || {}).limitMs || 0;
+ const judged = reached.size >= 3 && limit > 0;
+ const clocks = {};
+ for (const [member, { offset, within }] of reached) {
+ const offsetMs = offset - median;
+ clocks[member] = { offsetMs, withinMs: within,
+ off: judged && Math.abs(offsetMs) > within + limit };
+ }
+ return clocks;
+}
+
// Whether `clock` (a peer's, `/peers`) differs from this node's beyond the limit for sure.
function clockOff(clock) {
const limit = (state.stats.clock || {}).limitMs || 0;
@@ -1391,6 +1437,7 @@ function renderGraph() {
// Above the lines and their labels, below the nodes.
children.push(...hits);
const { amber, coordinating } = inRemakes();
+ const clocks = swarmClocks();
for (const id of nodes) {
const p = positions.get(id), member = state.graph.members[id];
const reported = id in state.graph.measured;
@@ -1411,7 +1458,7 @@ function renderGraph() {
hoverable(element, id);
};
// A clock off the swarm's: a dashed orange ring.
- if (((state.graph.clocks || {})[id] || {}).off) {
+ if ((clocks[id] || {}).off) {
children.push(drawing("clock " + id, "circle", { cx: p.x, cy: p.y, r: 11, fill: "none",
stroke: "var(--warn)", "stroke-width": 1.5, "stroke-dasharray": "3 2" }));
}
@@ -1451,7 +1498,7 @@ function renderGraph() {
stroke: dormant ? "var(--bad)" : voting ? AMBER : ring ? (following.status === "live" ? "var(--self)" : "var(--muted)") : member.self ? "var(--self)" : "var(--fg)",
"stroke-width": dormant ? 2.5 : ring ? radius * 2 / 3 : 1.5 }, pressed);
// Its clock against the swarm's, as the members' reports chain it (the node's).
- const clock = ((state.graph.clocks || {})[id]);
+ const clock = clocks[id];
tooltip(circle, id + (reported ? "" : " (no links reported)") +
(following ? (following.status === "live" ? " (subscribed)" :
" (subscribed, not reached from here: " + following.why + ")") : "") +
diff --git a/src/api/dashboard/mod.rs b/src/api/dashboard/mod.rs
index e86475f..78e8352 100644
--- a/src/api/dashboard/mod.rs
+++ b/src/api/dashboard/mod.rs
@@ -15,10 +15,7 @@
//! 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;
+//! subscribing: fewer messages in the swarm, a map up to `LINKS_EVERY` behind.
use std::collections::{BTreeMap, BTreeSet};
use std::convert::Infallible;
@@ -396,15 +393,6 @@ async fn build(state: &AppState, graph: &Graph, live: bool) -> JsonValue {
"epoch": epoch_json(epochs.get(member).copied()) }),
);
}
- let clocks = clocks::swarm(
- local,
- std::iter::once((local, &own[..])).chain(
- replies
- .iter()
- .map(|(member, _, member_links)| (member.as_str(), &member_links[..])),
- ),
- u64::try_from(state.clock_skew_warn.as_millis()).unwrap_or(u64::MAX),
- );
// 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
// registration reaches this node, and before the graph is computed again
@@ -435,7 +423,6 @@ async fn build(state: &AppState, graph: &Graph, live: bool) -> JsonValue {
"members": members_json,
"intended": intended,
"measured": measured,
- "clocks": clocks,
"live": live,
},
"remakes": remakes,