From 09d6278fd5ce885540ff50f412fc9728322acef7 Mon Sep 17 00:00:00 2001 From: tv Date: Tue, 3 Nov 2026 07:30:40 +0100 Subject: dashboard: show writer trees --- docs/dashboard.md | 9 ++++ docs/openapi.yaml | 35 +++++++++++++++ src/api/dashboard/dashboard.html | 72 ++++++++++++++++++++++++++++++ src/api/dashboard/mod.rs | 47 ++++++++++++++++++++ src/swarm/tree.rs | 96 +++++++++++++++++++++++++++++----------- 5 files changed, 233 insertions(+), 26 deletions(-) diff --git a/docs/dashboard.md b/docs/dashboard.md index 1fe51dc..fa74104 100644 --- a/docs/dashboard.md +++ b/docs/dashboard.md @@ -138,6 +138,15 @@ pulls its ends together only a little when stretched, and doesn't push them apar round trips change, the map moves to the new lengths smoothly. Clicked again, it's static, the nodes staying where they are. The choice is kept in the browser. +`tree: off`, next, switched to `tree: on`, shows a writer's tree ([`peers.md`](peers.md)): of the node in +focus, else of this node. A chevron on the line from each member's parent to it, at its +middle and in the colour of the line in use, points the way the writer's changes go; a member without one gets them by +announcement. The node computes the tree as every node does, from the member list and +whom each member reaches, which it takes from the links members report (a member that +reports none counts as reaching its neighbours by the member list): asked for when the +writer in focus changes and every 10 s (`GET /admin/dashboard/tree`). The choice is +kept in the browser. + Hovering over a node or a link highlights it and shows a tooltip: for a link, each of its lines with transport, direct or relayed, round trip, and how long ago it was measured. diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 03f75d9..a15339a 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -799,6 +799,41 @@ paths: responses: '204': description: Done. + /admin/dashboard/tree: + get: + tags: [Admin] + summary: A writer's tree + description: | + The tree a writer's changes pass on along (`swarm::tree`), as this node computes + it from the member list and the links members report (whom each reaches; one + that reports none counts as reaching its neighbours by the member list): each + member's parent, `null` for one without (it gets the writer's changes by + announcement). Only with the `dashboard` cargo feature. + parameters: + - name: writer + in: query + required: true + schema: + type: string + description: The writer's node ID. + responses: + '200': + description: The tree. + content: + application/json: + schema: + type: object + required: [writer, parents] + properties: + writer: + type: string + parents: + type: object + description: By member's node ID, its parent's, or null. + additionalProperties: + type: [string, 'null'] + '404': + description: The writer isn't in the member list (or it isn't known yet). /admin/dashboard/events: get: tags: [Admin] diff --git a/src/api/dashboard/dashboard.html b/src/api/dashboard/dashboard.html index 9132275..b1aa821 100644 --- a/src/api/dashboard/dashboard.html +++ b/src/api/dashboard/dashboard.html @@ -161,6 +161,9 @@ .legend .dormant::before { border-top-width: 1.5px; --colour: var(--muted); box-shadow: 0 0 0 2.5px color-mix(in srgb, var(--bad) 50%, transparent); } .legend .silent::before { border-top-style: dotted; --colour: var(--muted); } + /* A chevron on a link, as on the map: a writer's tree, the way its changes go. */ + .legend .writer-tree::before { content: "\203A"; width: auto; height: auto; border: none; + font-weight: bold; font-size: 15px; line-height: 0; color: var(--muted); } .legend .hint::before { display: none; } /* A kind of message: a dot in its colour, as on the map. */ .legend .message::before { width: 8px; height: 8px; border: none; border-radius: 50%; @@ -263,6 +266,7 @@
+
@@ -417,6 +421,7 @@ function focus(id) { } catch { /* not where history works */ } render(); markPeerMessages(); + loadTree(); } // --- Peers --- @@ -608,6 +613,10 @@ function renderLegend(measured) { el("span", { class: "relayed" }, "relayed"), el("span", { class: "dormant" }, "dormant or down"), el("span", { class: "silent" }, "an end reports no links")); + if (treeShown) { + items.push(el("span", { class: "writer-tree" }, name(treeWriter()) + + "'s tree: the way its changes go (of the node in focus)")); + } document.getElementById("legend").replaceChildren(...items); } @@ -711,6 +720,27 @@ function relax(nodes, pairs, width, height, gain, most, only) { // the median one's, within bounds. Then, when the lengths change, the map moves to // them a little every frame (`settle`), not at once. let byRtt = false; +// A writer's tree on the map (`swarm::tree`, `GET /admin/dashboard/tree`): of the node in +// focus, else of this node; asked for anew when the writer changes and every 10 s. +let treeShown = false; +try { treeShown = localStorage.getItem("continuum-tree") === "1"; } catch (_) {} +let writerTree = null; +const treeWriter = () => focused !== null && !isPair(focused) ? focused + : state ? state.node.id : null; +function loadTree() { + const writer = treeWriter(); + if (!treeShown || writer === null) { writerTree = null; return; } + fetch("dashboard/tree?writer=" + encodeURIComponent(writer)) + .then(response => response.ok ? response.json() : null) + .then(loaded => { + if (loaded && loaded.writer === treeWriter()) { + writerTree = loaded; + if (state) renderGraph(); + } + }) + .catch(() => {}); +} +setInterval(() => { if (treeShown) loadTree(); }, 10000); try { byRtt = localStorage.getItem("continuum-by-rtt") === "1"; } catch (_) {} // One spring per pair of nodes, intended or measured or both: [a, b, length, loose]. // Measured links shape the map; a link only intended (nothing measured backs it) is @@ -1030,6 +1060,30 @@ function renderGraph() { tooltip(covering, descriptions.join("\n")); } // Above every line, so none crosses them. + // The tree of the writer in focus (`treeShown`): a chevron on each link from parent to + // child, at its middle, pointing the way changes go, in the colour of the link. + if (treeShown && writerTree && writerTree.writer === treeWriter()) { + for (const [child, parent] of Object.entries(writerTree.parents)) { + const p = parent && positions.get(parent), q = positions.get(child); + if (!p || !q) continue; + const length = Math.max(Math.hypot(q.x - p.x, q.y - p.y), 1); + const ux = (q.x - p.x) / length, uy = (q.y - p.y) / length; + const mx = (p.x + q.x) / 2, my = (p.y + q.y) / 2; + const d = "M " + (mx - ux * 5 - uy * 5) + " " + (my - uy * 5 + ux * 5) + " L " + + (mx + ux * 5) + " " + (my + uy * 5) + " L " + (mx - ux * 5 + uy * 5) + " " + + (my - uy * 5 - ux * 5); + const shape = { d, fill: "none", "stroke-linecap": "round", "stroke-linejoin": "round" }; + // The colour of the link's line in use (its transport's), as the line it is on. + const edge = measured.get(pairKey(parent, child)); + const colour = edge ? colourOf(edge.link.transport) : "var(--intended)"; + labels.push(drawing("tree under " + child, "path", + { ...shape, stroke: "var(--panel)", "stroke-width": 6 })); + const chevron = drawing("tree " + child, "path", + { ...shape, stroke: colour, "stroke-width": 2.5 }); + tooltip(chevron, "in " + name(writerTree.writer) + "'s tree: " + name(parent) + " → " + name(child)); + labels.push(chevron); + } + } children.push(...labels); // Above the lines and their labels, below the nodes. children.push(...hits); @@ -1549,6 +1603,23 @@ 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() { + treeToggle.textContent = treeShown ? "tree: on" : "tree: off"; + treeToggle.title = treeShown + ? "The tree of the node in focus (or this node) as a writer: the way its changes go. Click to hide it" + : "Click to show the tree of the node in focus (or this node) as a writer: the way its changes go"; +} +showTreeToggle(); +treeToggle.addEventListener("click", () => { + treeShown = !treeShown; + try { localStorage.setItem("continuum-tree", treeShown ? "1" : "0"); } catch (_) {} + showTreeToggle(); + writerTree = null; + loadTree(); + if (state) renderGraph(); +}); // Links as long as their round trips, and back. const rttToggle = document.getElementById("rtt-toggle"); function showRttToggle() { @@ -2289,6 +2360,7 @@ function connect() { if (state !== null) documentChanged({ all: true }); state = snapshot; stateAt = Date.now(); live = true; status("live", true); pulse(); render(); trafficArrived(); + if (treeShown && writerTree === null) loadTree(); handOverLinks(); }); events.addEventListener("patch", event => { diff --git a/src/api/dashboard/mod.rs b/src/api/dashboard/mod.rs index 8d954c0..6682cbd 100644 --- a/src/api/dashboard/mod.rs +++ b/src/api/dashboard/mod.rs @@ -490,6 +490,7 @@ pub(crate) fn router(state: AppState) -> Router { .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)) .layer(Extension(Hub::new(state, closing))) } @@ -551,6 +552,52 @@ async fn set_links( StatusCode::NO_CONTENT } +#[derive(serde::Deserialize)] +struct TreeQuery { + writer: String, +} + +/// `GET /admin/dashboard/tree?writer=…`: the writer's tree (`swarm::tree`), as this node +/// computes it from the member list and the links members report (whom each reaches; +/// one that reports none counts as reaching its neighbours by the member list): every +/// member's parent, `null` for one without. `404` if the writer isn't a member. +async fn writer_tree( + Extension(hub): Extension>, + Query(query): Query, +) -> Response { + let state = &hub.state; + let mut reaches: BTreeMap> = state + .links + .replies() + .into_iter() + .map(|(member, _, links)| (member, reached_by(&links))) + .collect(); + reaches.insert( + state.local_id().to_owned(), + reached_by(&links::own_links(state).await), + ); + let reached = |by: &str, member: &str| { + reaches + .get(by) + .is_none_or(|reached| reached.contains(member)) + }; + match crate::swarm::tree::whole(state, &query.writer, &reached) { + Some(parents) => { + axum::Json(json!({ "writer": query.writer, "parents": parents })).into_response() + } + None => StatusCode::NOT_FOUND.into_response(), + } +} + +/// Whom a member reaches, by its links: those neither dormant nor down. +fn reached_by(links: &[Link]) -> BTreeSet { + links + .iter() + .filter(|link| !link.dormant && !link.down) + .map(|link| link.peer.clone()) + .collect() +} + /// `GET /admin/dashboard/state`: the latest snapshot, for a client that wants it whole. async fn current_state(Extension(hub): Extension>) -> Response { hub.want(); diff --git a/src/swarm/tree.rs b/src/swarm/tree.rs index 8093c91..b21908e 100644 --- a/src/swarm/tree.rs +++ b/src/swarm/tree.rs @@ -105,6 +105,73 @@ pub(crate) async fn children(state: &AppState, writer: u64) -> Vec { .unwrap_or_default() } +/// Where `member` comes in the writer's tree (by `distance` from it over `graph`): +/// its distance, then whether it reaches a member a hop closer (`reached(by, other)`: +/// whether `by` reaches `other`, as known; the writer does), then its pair key. None if +/// the graph doesn't have it. +fn rank( + graph: &Graph, + distance: &HashMap, + reached: &dyn Fn(&str, &str) -> bool, + member: &str, +) -> Option<(u32, bool, u64)> { + let d = *distance.get(member)?; + let closer = + d == 0 + || graph.get(member).into_iter().flatten().any(|other| { + distance.get(other.as_str()) == Some(&(d - 1)) && reached(member, other) + }); + Some((d, !closer, topology::pair_key(member))) +} + +/// `member`'s parent in the writer's tree (`rank`): the neighbour it reaches that comes +/// before it, the closest to the writer first, then one that reaches closer, then by +/// pair score; None if none does (or it is the writer). +fn parent<'g>( + graph: &'g Graph, + distance: &HashMap, + reached: &dyn Fn(&str, &str) -> bool, + member: &str, +) -> Option<&'g str> { + let its_rank = rank(graph, distance, reached, member)?; + graph + .get(member) + .into_iter() + .flatten() + .map(String::as_str) + .filter(|candidate| { + rank(graph, distance, reached, candidate).is_some_and(|r| r < its_rank) + && reached(member, candidate) + }) + .min_by_key(|candidate| { + let (d, far, _) = rank(graph, distance, reached, candidate).expect("ranked"); + (d, far, topology::pair_score(candidate, member)) + }) +} + +/// `writer`'s whole tree, as far as `reached` knows whom each member reaches (the +/// dashboard's map, from the links members report): every member's parent, None for +/// those without one (they get the writer's changes by announcement). None while the +/// member list isn't known, or the writer isn't in it. +#[cfg_attr(not(feature = "dashboard"), allow(dead_code))] +pub(crate) fn whole( + state: &AppState, + writer: &str, + reached: &dyn Fn(&str, &str) -> bool, +) -> Option>> { + let (graph, distance) = distances(state, topology::pair_key(writer))?; + Some( + graph + .keys() + .filter(|member| distance.get(member.as_str()).is_some_and(|d| *d > 0)) + .map(|member| { + let parent = parent(&graph, &distance, reached, member).map(str::to_owned); + (member.clone(), parent) + }) + .collect(), + ) +} + /// What `writer`'s tree means for us: our children in it, and our neighbours it leaves /// out (`uncovered`). pub(crate) struct Plan { @@ -137,40 +204,17 @@ pub(crate) async fn plan(state: &AppState, writer: u64) -> Option { .get(by) .is_none_or(|keys| keys.contains(&topology::pair_key(member))) }; - // Whether a member reaches one a hop closer to the writer (the writer: yes). - let closer = |member: &str| { - let d = distance[member]; - d == 0 - || graph.get(member).into_iter().flatten().any(|other| { - distance.get(other.as_str()) == Some(&(d - 1)) && reached(member, other) - }) - }; - let rank = |member: &str| { - distance - .get(member) - .map(|d| (*d, !closer(member), topology::pair_key(member))) - }; - let my_rank = rank(me)?; + let my_rank = rank(&graph, &distance, &reached, me)?; let mut children = Vec::new(); let mut uncovered = Vec::new(); for member in graph.get(me).into_iter().flatten() { - let Some(its_rank) = rank(member) else { + let Some(its_rank) = rank(&graph, &distance, &reached, member) else { continue; }; if distance[member.as_str()] == 0 { continue; } - let parent = graph - .get(member) - .into_iter() - .flatten() - .filter(|candidate| rank(candidate).is_some_and(|r| r < its_rank)) - .filter(|candidate| reached(member, candidate)) - .min_by_key(|candidate| { - let (d, far, _) = rank(candidate).expect("ranked"); - (d, far, topology::pair_score(candidate, member)) - }); - match parent { + match parent(&graph, &distance, &reached, member) { None => uncovered.push(member.clone()), Some(parent) if parent == me && its_rank > my_rank && topology::reaches(state, member).await => -- cgit v1.3.1