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 --- src/api/dashboard/dashboard.html | 72 ++++++++++++++++++++++++++++++++++++++++ src/api/dashboard/mod.rs | 47 ++++++++++++++++++++++++++ 2 files changed, 119 insertions(+) (limited to 'src/api/dashboard') 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(); -- cgit v1.3.1