aboutsummaryrefslogtreecommitdiffstats
path: root/src/api/dashboard
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-11-03 07:30:40 +0100
committertv <tv@krebsco.de>2026-11-03 07:30:40 +0100
commit09d6278fd5ce885540ff50f412fc9728322acef7 (patch)
tree9a6132bd5c3f741f228534b7e6d2e231d377acc0 /src/api/dashboard
parent9c080d623f2cf2d31c9aadb5107b4a9ee7847ebc (diff)
dashboard: show writer trees
Diffstat (limited to 'src/api/dashboard')
-rw-r--r--src/api/dashboard/dashboard.html72
-rw-r--r--src/api/dashboard/mod.rs47
2 files changed, 119 insertions, 0 deletions
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 @@
<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>
</div>
<div id="legends">
@@ -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<AppState> {
.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<Arc<Hub>>,
+ Query(query): Query<TreeQuery>,
+) -> Response {
+ let state = &hub.state;
+ let mut reaches: BTreeMap<String, BTreeSet<String>> = 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<String> {
+ 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<Arc<Hub>>) -> Response {
hub.want();