diff options
Diffstat (limited to 'src/api/dashboard')
| -rw-r--r-- | src/api/dashboard/clocks.rs | 127 | ||||
| -rw-r--r-- | src/api/dashboard/dashboard.html | 2198 | ||||
| -rw-r--r-- | src/api/dashboard/mod.rs | 667 |
3 files changed, 2992 insertions, 0 deletions
diff --git a/src/api/dashboard/clocks.rs b/src/api/dashboard/clocks.rs new file mode 100644 index 0000000..9f069be --- /dev/null +++ b/src/api/dashboard/clocks.rs @@ -0,0 +1,127 @@ +//! 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 new file mode 100644 index 0000000..1b9a8cb --- /dev/null +++ b/src/api/dashboard/dashboard.html @@ -0,0 +1,2198 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>continuum</title> +<style> + :root { + --bg: #fafaf9; --fg: #1c1917; --muted: #78716c; --line: #e7e5e4; --panel: #ffffff; + --ok: #15803d; --warn: #c2410c; --bad: #b91c1c; --intended: #a8a29e; --self: #1d4ed8; + /* How strong the grey under a console message is, pointed at or shown, and under + its nodes and link on the map. */ + --pointed: .12; --shown: .22; + /* How strong the halo of a node or link is, pointed at or in focus, and the tint of + the console messages exchanged with it. */ + --halo-hovered: .15; --halo-focused: .35; + } + @media (prefers-color-scheme: dark) { + :root { + --bg: #1c1917; --fg: #f5f5f4; --muted: #a8a29e; --line: #44403c; --panel: #292524; + --ok: #4ade80; --warn: #fb923c; --bad: #f87171; --intended: #57534e; --self: #60a5fa; + } + } + * { box-sizing: border-box; } + html { background: var(--bg); } + /* The window's height, unless it is way too small: then the page scrolls. */ + body { margin: 0; background: var(--bg); color: var(--fg); + font: 14px/1.4 system-ui, sans-serif; + height: 100vh; min-height: 480px; display: flex; flex-direction: column; } + header { display: flex; gap: 1em; align-items: baseline; padding: 12px 16px; + border-bottom: 1px solid var(--line); flex-wrap: wrap; } + header h1 { font-size: 16px; margin: 0; } + #connection { margin-left: auto; color: var(--muted); } + #clock-warning, #peers .clock-off { color: var(--warn); } + #connection.live { color: var(--ok); } + #connection.down { color: var(--bad); } + /* A dot in the status's colour that breathes once with every event. */ + #dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; + background: currentColor; margin-right: 6px; vertical-align: 1px; opacity: .45; } + #dot.pulse { animation: breath .9s ease-in-out; } + @keyframes breath { + 0% { opacity: .45; transform: scale(1); } + 30% { opacity: 1; transform: scale(1.35); box-shadow: 0 0 6px currentColor; } + 100% { opacity: .45; transform: scale(1); } + } + @media (prefers-reduced-motion: reduce) { #dot.pulse { animation-duration: .01s; } } + body.stale main { opacity: .55; } + /* Swarm on the left, the full height; peers (as tall as their rows, up to 8) above + the figures (the rest) on the right. */ + /* The columns and rows as dragged (`--columns`, `--rows`, set by `sizes`). */ + main { flex: 1; min-height: 0; display: grid; gap: 16px; padding: 16px; + grid-template-columns: var(--columns, minmax(0, 3fr) minmax(0, 2fr)); + grid-template-rows: var(--rows, auto minmax(0, .4fr) minmax(0, .4fr) minmax(0, .2fr)); + grid-template-areas: "map peers" "map figures" "map document" "map console"; } + @media (max-width: 900px) { + body { height: auto; } + /* Stacked: each box as tall as set here, a collapsed one as its title. */ + main { grid-template-columns: minmax(0, 1fr); grid-template-rows: none; + grid-auto-rows: auto; grid-template-areas: "map" "peers" "figures" "document" "console"; } + #map, #figures-box, #document-box { height: 70vh; } + #console-box { height: 40vh; } + main > section.collapsed { height: auto; } + } + section { background: var(--panel); border: 1px solid var(--line); border-radius: 6px; + padding: 12px; min-width: 0; min-height: 0; display: flex; flex-direction: column; } + section h2 { font-size: 13px; text-transform: uppercase; letter-spacing: .04em; + color: var(--muted); margin: 0 0 8px; flex: none; } + section h2 .what { text-transform: none; letter-spacing: 0; color: var(--fg); } + /* Collapsed: the title only, and nothing drawn in it (`busy`). */ + button.collapse { font: inherit; padding: 0 4px 0 0; color: var(--muted); background: none; + border: none; cursor: pointer; } + button.collapse:hover { color: var(--fg); } + section.collapsed > :not(h2) { display: none; } + section.collapsed { align-self: start; } + section.collapsed h2 { margin: 0; } + section.collapsed h2 .toggle { display: none; } + /* The right column dragged off to the side: the map takes the width. */ + @media (min-width: 901px) { + main.right-hidden > section:not(#map) { display: none; } + /* The right column all collapsed: its titles at the top, a hint below them. */ + main.right-shut { grid-template-rows: auto auto auto auto minmax(0, 1fr); + grid-template-areas: "map peers" "map figures" "map document" "map console" "map hint"; } + main.right-shut #column-hint { display: block; } + } + #column-hint { grid-area: hint; display: none; font-size: 12px; } + section h2 .what .id { color: var(--muted); font-weight: normal; word-break: break-all; } + /* The space between boxes, where they are resized. */ + main.resize-columns, main.resize-columns * { cursor: col-resize; } + main.resize-rows, main.resize-rows * { cursor: row-resize; } + /* The peers dragged to a height: the table fills it, and scrolls. */ + main.peers-sized #peers-scroll { flex: 1; max-height: none !important; } + #map { grid-area: map; } + #peers-box { grid-area: peers; } + #figures-box { grid-area: figures; } + #document-box { grid-area: document; } + #console-box { grid-area: console; } + #console-box .scroll { flex: 1; } + .console { font-family: ui-monospace, monospace; font-size: 12px; white-space: pre-wrap; } + .console .when { color: var(--muted); } + /* Room on the left for the bar of a related message, so it covers no text. */ + .console > div { cursor: pointer; border-left: 3px solid transparent; padding-left: 4px; } + /* Of one exchange with the message pointed at or shown (`related`): a lighter grey, and + the bar. */ + /* Exchanged with the node or over the link pointed at on the map, or in focus: the + colour of its halo; in focus wins. */ + .console > div.peer-hovered { + background: color-mix(in srgb, var(--self) calc(var(--halo-hovered) * 100%), transparent); } + .console > div.peer-focused { + background: color-mix(in srgb, var(--self) calc(var(--halo-focused) * 100%), transparent); } + .console > div.related { background: rgba(127, 127, 127, .08); border-left-color: var(--self); } + .console > div:hover { background: rgb(127 127 127 / var(--pointed)); } + .console > div.focused { background: rgb(127 127 127 / var(--shown)); } + /* The message takes the box's width and scrolls with it, not in a box of its own. */ + #stats pre.message { margin: 4px 0 0; font-size: 12px; white-space: pre-wrap; + max-height: none; overflow: visible; } + svg { flex: 1; min-height: 0; width: 100%; display: block; cursor: default; } + /* Dragged to pan or to move a node, also by touch. */ + /* Dragged, not selected: neither its labels' text nor the picture itself. */ + #graph { touch-action: none; user-select: none; -webkit-user-select: none; } + #graph.dragging, #graph.dragging * { cursor: grabbing; } + svg circle { cursor: pointer; } + /* Labels on an underlay of their own shape, so they read where they lie on a line. */ + #graph text { paint-order: stroke; stroke: var(--panel); stroke-width: 4px; + stroke-linejoin: round; } + .legend { color: var(--muted); font-size: 12px; display: flex; gap: 1em; flex-wrap: wrap; + flex: none; } + .legend span::before { content: ""; display: inline-block; width: 18px; height: 0; + vertical-align: middle; margin-right: 4px; border-top: 2px solid var(--colour, currentColor); } + .legend .intended::before { border-top-style: dashed; --colour: var(--intended); } + .legend .used::before { border-top-width: 3px; --colour: var(--muted); } + .legend .other::before { border-top-width: 1px; --colour: var(--muted); } + .legend .direct::before { --colour: var(--muted); } + .legend .relayed::before { border-top-style: dashed; --colour: var(--muted); } + .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); } + .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%; + background: var(--colour); } + /* The dots' size, right after their legend, no taller than its text. */ + .legend .dot-size { display: inline-flex; align-items: center; gap: 4px; } + .legend .dot-size::before { display: none; } + .legend .dot-size input { width: 6em; height: 1em; margin: 0; } + /* After the slider, as tall; the kinds and the slider greyed while the dots are off. */ + .legend button.toggle { float: none; margin-left: 0; line-height: 1em; } + .legend.off .message, .legend.off .dot-size { opacity: .45; } + #legends { flex: none; } + /* Hides the legend, and shows it again. */ + button.toggle { font: inherit; font-size: 12px; text-transform: none; letter-spacing: 0; + float: right; margin-left: 6px; padding: 0 6px; color: var(--muted); background: none; + border: 1px solid var(--line); border-radius: 3px; cursor: pointer; } + button.toggle:hover { color: var(--fg); border-color: var(--muted); } + /* Nodes: circles, as on the map. */ + .legend .node::before { width: 10px; height: 10px; border: 1.5px solid var(--fg); + border-radius: 50%; background: var(--fg); } + .legend .node.silent::before { background: var(--panel); } + .legend .node.self::before { border-color: var(--self); background: var(--self); } + .legend .node.dormant-node::before { border: 2.5px solid var(--bad); } + .legend .node.clock-off::before { width: 14px; height: 14px; background: none; + border: 1.5px dashed var(--warn); } + .legend .node.focus::before { width: 14px; height: 14px; background: var(--self); + border: none; opacity: .35; } + .scroll { overflow: auto; min-height: 0; } + #figures-box .scroll, #document-box .scroll { flex: 1; } + .tree pre { margin: 2px 0 4px 1.1em; white-space: pre-wrap; word-break: break-all; + max-height: 20em; overflow: auto; } + .note { color: var(--muted); font-style: italic; } + .error { color: var(--bad); } + .tree button.add { font: inherit; line-height: 1; padding: 0 4px; margin-left: 4px; + color: var(--muted); background: none; border: 1px solid var(--line); border-radius: 3px; + cursor: pointer; } + .tree button.add:hover { color: var(--fg); border-color: var(--muted); } + .tree .nested { padding-left: 1.1em; } + .editor input.extra[type=text] { width: 12em; margin-bottom: 2px; display: block; } + .editor { margin: 2px 0 4px; } + .tree details > .editor { margin-left: 1.1em; } + .editor input[type=text], .editor textarea { width: 100%; font: inherit; color: var(--fg); + background: var(--bg); border: 1px solid var(--line); border-radius: 4px; padding: 2px 4px; } + .editor button { font: inherit; } + .editor button.delete { color: #fff; background: var(--bad); border: 1px solid var(--bad); + border-radius: 3px; } + .tree details.editing > :not(summary):not(.editor) { display: none; } + .tree .leaf[title] { cursor: text; } + table { border-collapse: collapse; width: 100%; font-variant-numeric: tabular-nums; } + th, td { text-align: left; padding: 3px 8px 3px 0; border-bottom: 1px solid var(--line); + white-space: nowrap; } + th { color: var(--muted); font-weight: 500; position: sticky; top: 0; + background: var(--panel); } + #peers tr[data-peer] { cursor: pointer; } + #peers tr.focused td { background: var(--line); } + .id { font-family: ui-monospace, monospace; font-size: 12px; } + /* A peer's status (not the legend's "dormant"). */ + #peers .active { color: var(--ok); } #peers .dormant { color: var(--bad); } + #peers .unknown { color: var(--muted); } + .tree { font-family: ui-monospace, monospace; font-size: 12px; } + /* Every entry indented alike: a branch's triangle sits in the indentation, left of + where the text of every entry begins. */ + .tree details, .tree .leaf { position: relative; padding-left: 1.1em; } + .tree summary { cursor: pointer; list-style: none; } + .tree summary::-webkit-details-marker { display: none; } + .tree summary::before { content: "▸"; position: absolute; left: .1em; color: var(--muted); } + .tree details[open] > summary::before { content: "▾"; } + .tree details > .note { padding-left: 1.1em; } + .key { color: var(--muted); } +</style> +</head> +<body> +<header> + <h1>continuum</h1> + <span id="node" class="id"></span> + <span id="clock-warning"></span> + <span id="connection"><span id="dot"></span><span id="connection-text">connecting…</span></span> +</header> +<main> + <section id="map"> + <h2>Swarm <button id="legend-toggle" class="toggle">hide legend</button> + <button id="rtt-toggle" class="toggle">layout: static</button> + <button id="links-toggle" class="toggle">links: live</button></h2> + <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 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> + </div> + <div class="legend" id="legend"></div> + <div class="legend" id="messages-legend"><button id="flows-toggle" + class="toggle">messages: on</button></div> + <div class="legend"> + <span class="hint">drag to pan, wheel or pinch to zoom, turn with two fingers; + drag a node to move it, double-click it to pin it there or let it go</span> + </div> + </div> + <svg id="graph"><g id="view"><g id="flows" pointer-events="none"></g></g></svg> + </section> + <section id="peers-box"> + <h2>Peers</h2> + <div class="scroll" id="peers-scroll"><table id="peers"></table></div> + </section> + <section id="figures-box"> + <h2><span id="figures-title">Figures</span></h2> + <div class="scroll"><div id="stats" class="tree"></div></div> + </section> + <section id="document-box"> + <h2>Document</h2> + <div class="scroll"><div id="document" class="tree"></div></div> + </section> + <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</h2> + <div class="scroll" id="console-scroll"><div id="console" class="console"></div></div> + </section> +</main> +<script> +"use strict"; +// Which page this is (the node fills it in); a snapshot naming another came from +// another build: reloaded then, to get its page. +const PAGE_VERSION = "__PAGE_VERSION__"; +// The state as the node published it, and when it did (the last event). +let state = null; +let stateAt = 0; + +// Fields that grow by themselves: the node's list (`GROWING` and `is_age` in its +// dashboard), filled in when it serves the page. +const GROWING = new Set(__GROWING__); +const isAge = key => key === "secsAgo" || key.endsWith("SecsAgo") || GROWING.has(key); +const elapsed = () => Math.floor((Date.now() - stateAt) / 1000); +const aged = (key, value) => + isAge(key) && typeof value === "number" ? value + elapsed() : value; + +// RFC 7386. +function mergePatch(target, patch) { + if (patch === null || typeof patch !== "object" || Array.isArray(patch)) return patch; + const result = target !== null && typeof target === "object" && !Array.isArray(target) + ? target : {}; + for (const [key, value] of Object.entries(patch)) { + if (value === null) delete result[key]; + else result[key] = mergePatch(result[key], value); + } + return result; +} + +function el(tag, attrs, ...children) { + const node = document.createElement(tag); + for (const [key, value] of Object.entries(attrs || {})) node.setAttribute(key, value); + for (const child of children) { + node.append(child instanceof Node ? child : document.createTextNode(String(child))); + } + return node; +} +const SVG = "http://www.w3.org/2000/svg"; +function svg(tag, attrs) { + const node = document.createElementNS(SVG, tag); + for (const [key, value] of Object.entries(attrs || {})) node.setAttribute(key, value); + return node; +} + +const short = id => id.length > 14 ? id.slice(0, 6) + "…" + id.slice(-6) : id; +function name(id) { + const member = state.graph.members[id]; + return (member && member.alias) || (state.peers.peers[id] || {}).alias || short(id); +} +// Milliseconds as signed seconds: "+5.0 s". +const seconds = ms => (ms < 0 ? "−" : "+") + (Math.abs(ms) / 1000).toFixed(1) + " s"; +// 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; + return !!clock && limit > 0 && Math.abs(clock.offsetMs) > clock.withinMs + limit; +} +function age(secs) { + if (secs === null || secs === undefined) return ""; + if (secs < 120) return secs + " s"; + if (secs < 7200) return Math.floor(secs / 60) + " min"; + if (secs < 172800) return Math.floor(secs / 3600) + " h"; + return Math.floor(secs / 86400) + " d"; +} + +// What is in focus, its figures shown: a node's ID, or a link's pair key (`pairKey`, +// two IDs). null: this node's (`/admin/stats`). +let focused = (() => { + try { return decodeURIComponent(location.hash.slice(1)) || null; } catch { return null; } +})(); +const pairKey = (a, b) => a < b ? a + " " + b : b + " " + a; +const isPair = key => key !== null && key.includes(" "); +function focus(id) { + focused = id === null || id === state.node.id ? null : id; + if (shownMessage !== null) { + shownMessage = null; + document.querySelectorAll("#console > div.focused").forEach(line => line.classList.remove("focused")); + markRelated(null); + } + // In the URL too, so a reload (a new version) keeps it. + try { + history.replaceState(null, "", focused === null ? location.pathname + : "#" + encodeURIComponent(focused)); + } catch { /* not where history works */ } + render(); + markPeerMessages(); +} + +// --- Peers --- +function renderPeers() { + const table = document.getElementById("peers"); + const rows = [el("tr", {}, ...["peer", "status", "seen", "via", "rtt", "clock", "neighbour", + "same epoch"] + .map(h => el("th", {}, h)))]; + for (const [id, peer] of Object.entries(state.peers.peers)) { + const destination = peer.destination; + const path = destination && destination.paths && destination.paths[0]; + const rtt = path && typeof path.rttMs === "number" ? path.rttMs.toFixed(1) + " ms" : ""; + const via = destination + ? destination.address + (destination.connection ? " (" + destination.connection + ")" : "") + : ""; + const row = el("tr", { "data-peer": id, class: id === focused ? "focused" : "" }, + el("td", { title: id }, peer.alias || el("span", { class: "id" }, short(id))), + // How long dormant is about how long unseen ("seen"): in the tooltip only. + el("td", { class: peer.status, title: peer.status === "dormant" + ? "dormant for " + age(aged("dormantForSecs", peer.dormantForSecs)) : "" }, peer.status), + el("td", {}, age(aged("lastSeenSecsAgo", peer.lastSeenSecsAgo))), + el("td", { class: "id" }, via), + el("td", {}, rtt), + // Its clock against this node's, flagged beyond the limit (`--clock-skew-warn`). + el("td", { class: clockOff(peer.clock) ? "clock-off" : "", title: peer.clock + ? "ahead of this node's by " + peer.clock.offsetMs + " ms, give or take " + + peer.clock.withinMs + " ms" : "" }, peer.clock ? seconds(peer.clock.offsetMs) : ""), + el("td", {}, peer.neighbour ? "yes" : ""), + el("td", {}, peer.sameEpoch === null || peer.sameEpoch === undefined + ? "" : peer.sameEpoch ? "yes" : "no")); + // On press: the table may be drawn anew (ages) before a click would complete. + row.addEventListener("pointerdown", () => focus(id === focused ? null : id)); + rows.push(row); + } + table.replaceChildren(...rows); + // As tall as the rows, up to 8 of them (and the header); then it scrolls. + const scroll = document.getElementById("peers-scroll"); + scroll.style.maxHeight = rows.length > 9 + ? (rows[8].offsetTop + rows[8].offsetHeight) + "px" : ""; +} + +// --- Figures: a tree, its open branches kept across renders --- +const open = new Set(["stats", "peer", "links", "from", "to", "paths", "paths.destination"]); +function tree(key, value, path) { + // Empty: nothing to open. + if (value !== null && typeof value === "object" && Object.keys(value).length === 0) { + return el("div", { class: "leaf" }, el("span", { class: "key" }, key + ": "), + Array.isArray(value) ? "[]" : "{}"); + } + if (value !== null && typeof value === "object") { + const details = el("details", {}); + if (open.has(path)) details.open = true; + details.addEventListener("toggle", () => { + if (details.open) open.add(path); else open.delete(path); + }); + const entries = Array.isArray(value) ? value.map((v, i) => [String(i), v]) : Object.entries(value); + const summary = el("summary", {}, el("span", { class: "key" }, key), + Array.isArray(value) ? " [" + value.length + "]" : ""); + // Opened or closed on press, as the peers' rows are focused: the tree is drawn anew + // every second (ages), and a click whose press and release land on two drawings is + // lost. Keys (Enter, space) still click. + summary.addEventListener("pointerdown", event => { + if (event.button !== 0) return; + event.preventDefault(); + details.open = !details.open; + }); + summary.addEventListener("click", event => { + if (event.detail !== 0) event.preventDefault(); + }); + details.append(summary); + for (const [k, v] of entries) details.append(tree(k, v, path + "." + k)); + return details; + } + return el("div", { class: "leaf" }, el("span", { class: "key" }, key + ": "), + JSON.stringify(aged(key, value))); +} +function renderStats() { + const title = document.getElementById("figures-title"); + const what = el("span", { class: "what" }); + let shown; + const stats = document.getElementById("stats"); + if (shownMessage !== null) { + const message = shownMessage; + // Drawn once: a message doesn't change, and drawing it anew would scroll it back up. + if (stats.dataset.message === String(message.seq)) return; + stats.dataset.message = String(message.seq); + what.textContent = message.type + " " + WAYS[message.way] + " " + name(message.peer); + const field = (key, value) => el("div", { class: "leaf" }, + el("span", { class: "key" }, key + ": "), value); + title.replaceChildren("Message: ", what); + document.getElementById("stats").replaceChildren( + field("at", clockTime(new Date(message.at))), + field(message.way === "received" ? "from" : "to", message.peer), + field("way", message.way), + field("type", message.type), + field("bytes", String(message.bytes)), + el("pre", { class: "message" }, message.text)); + return; + } + delete stats.dataset.message; + if (isPair(focused)) { + const [a, b] = focused.split(" "); + what.textContent = name(a) + " – " + name(b); + what.title = focused; + const listed = (x, y) => (state.graph.intended[x] || []).includes(y); + shown = [el("div", { class: "leaf" }, el("span", { class: "key" }, "intended: "), + listed(a, b) || listed(b, a) ? "yes (member list)" : "no")]; + // Each end's view of it, as it reported it. + for (const [x, y, path] of [[a, b, "from"], [b, a, "to"]]) { + const report = state.graph.measured[x]; + const link = report && report.links.find(link => link.peer === y); + if (link) { + shown.push(tree(name(x) + " reports", { secsAgo: report.secsAgo, ...link }, path)); + } else { + shown.push(el("div", { class: "key leaf" }, name(x) + (report + ? " reports no link to " + name(y) + "." : " reports no links."))); + } + } + // Where this node is an end: the link itself, as `/peers` has the other end (where + // messages go, the paths, the addresses). The whole entry is the node's focus. + const self = state.node.id; + const other = a === self ? b : b === self ? a : null; + const peer = other !== null && state.peers.peers[other]; + if (peer) { + shown.push(tree("this node's paths", { + destination: peer.destination, addresses: peer.addresses }, "paths")); + } + title.replaceChildren("Link: ", what); + document.getElementById("stats").replaceChildren(...shown); + return; + } + if (focused === null) { + what.textContent = name(state.node.id) + " (this node)"; + shown = [tree("stats", state.stats, "stats")]; + } else { + // Its alias (if it has one) and its node ID, in full. + const alias = (state.graph.members[focused] || {}).alias || + (state.peers.peers[focused] || {}).alias; + what.replaceChildren(...(alias ? [alias, " "] : []), el("span", { class: "id" }, focused)); + const peer = state.peers.peers[focused]; + const report = state.graph.measured[focused]; + shown = []; + if (peer) shown.push(tree("peer", peer, "peer")); + else shown.push(el("div", { class: "key leaf" }, "Not a peer of this node.")); + if (report) shown.push(tree("links", report, "links")); + } + title.replaceChildren(focused === null ? "Figures: " : "Peer: ", what); + document.getElementById("stats").replaceChildren(...shown); +} + +// --- Swarm map: a small force layout, positions kept across renders --- +const positions = new Map(); +// Nodes put somewhere by hand (dragged): the layout leaves them there. +const pinned = new Set(); +const transportColour = new Map([["mycelium", "#2563eb"], ["libp2p", "#9333ea"], + ["tor", "#0891b2"]]); +const SPARE = ["#ca8a04", "#db2777", "#65a30d"]; +function colourOf(transport) { + if (!transport) return "var(--muted)"; + if (!transportColour.has(transport)) { + transportColour.set(transport, SPARE[(transportColour.size - 3) % SPARE.length]); + } + return transportColour.get(transport); +} + +function renderLegend(measured) { + const transports = new Set(); + for (const { link } of measured.values()) { + transports.add(link.transport || ""); + for (const other of link.others || []) transports.add(other.transport); + } + const items = [el("span", { class: "intended" }, "intended (member list)")]; + for (const transport of [...transports].sort()) { + const item = el("span", {}, transport || "unknown transport"); + item.style.setProperty("--colour", colourOf(transport || null)); + items.push(item); + } + items.push(el("span", { class: "used" }, "in use"), + el("span", { class: "other" }, "other transport"), + el("span", { class: "direct" }, "direct"), + el("span", { class: "relayed" }, "relayed"), + el("span", { class: "dormant" }, "dormant or down"), + el("span", { class: "silent" }, "an end reports no links")); + document.getElementById("legend").replaceChildren(...items); +} + +function edges() { + const intended = new Map(); + for (const [a, neighbours] of Object.entries(state.graph.intended)) { + for (const b of neighbours) { + intended.set(pairKey(a, b), [a, b]); + } + } + // One measured edge per pair: a report with an RTT, then the fresher. + const measured = new Map(); + for (const [a, report] of Object.entries(state.graph.measured)) { + for (const link of report.links) { + const key = pairKey(a, link.peer); + const known = measured.get(key); + const timed = typeof link.rttMs === "number"; + const knownTimed = known && typeof known.link.rttMs === "number"; + if (!known || (timed && !knownTimed) || + (timed === knownTimed && report.secsAgo < known.secsAgo)) { + measured.set(key, { a, b: link.peer, link, secsAgo: report.secsAgo, + dormant: link.dormant || (known && known.dormant), + down: link.down || (known && known.down) }); + } else { + if (link.dormant) known.dormant = true; + if (link.down) known.down = true; + } + } + } + // Dormant, or down, if either end says so, whichever report is drawn. + for (const edge of measured.values()) { + if ((edge.dormant && !edge.link.dormant) || (edge.down && !edge.link.down)) { + edge.link = { ...edge.link, dormant: !!edge.dormant, down: !!edge.down }; + } + } + return { intended, measured }; +} + +// How long a link is, ideally, for `count` nodes on a map of that size. +function idealLength(count, width, height) { + return Math.min(width, height) / Math.max(2, Math.sqrt(count) * 1.5); +} +// `pairs`: [a, b, length, loose], the length the link pulls towards (else +// `idealLength`), and whether it is loose (`lengths`). +// How hard a loose link pulls, against a measured one. +const LOOSE = .1; +// `only`, if given: the nodes laid out; the others stay where they are. +function layout(nodes, pairs, width, height, only) { + for (const id of nodes) { + if (!positions.has(id)) { + positions.set(id, { x: width / 2 + (Math.random() - .5) * width / 2, + y: height / 2 + (Math.random() - .5) * height / 2 }); + } + } + for (let round = 0; round < 120; round++) relax(nodes, pairs, width, height, 1, 10, only); +} +// One round of the layout: every node moved by `gain` of the forces on it, at most +// `most` px. Returns how far the furthest moved. A gain of 1 overshoots where a node +// has several links (the pulls add up): it gets close fast, then swings about its +// place; well below 1, it comes to rest. `only`, if given: the nodes that move. +function relax(nodes, pairs, width, height, gain, most, only) { + const ideal = idealLength(nodes.length, width, height); + { + const force = new Map(nodes.map(id => [id, { x: 0, y: 0 }])); + for (let i = 0; i < nodes.length; i++) { + for (let j = i + 1; j < nodes.length; j++) { + const p = positions.get(nodes[i]), q = positions.get(nodes[j]); + const dx = p.x - q.x, dy = p.y - q.y, d2 = Math.max(dx * dx + dy * dy, 1); + const push = ideal * ideal / d2; + force.get(nodes[i]).x += dx * push / 10; force.get(nodes[i]).y += dy * push / 10; + force.get(nodes[j]).x -= dx * push / 10; force.get(nodes[j]).y -= dy * push / 10; + } + } + for (const [a, b, length, loose] of pairs) { + const p = positions.get(a), q = positions.get(b); + if (!p || !q) continue; + const dx = q.x - p.x, dy = q.y - p.y, d = Math.max(Math.hypot(dx, dy), 1); + const stretch = d - (length || ideal); + // A loose link pulls a little, and never pushes. + const pull = loose ? Math.max(0, stretch) / d / 4 * LOOSE : stretch / d / 4; + force.get(a).x += dx * pull; force.get(a).y += dy * pull; + force.get(b).x -= dx * pull; force.get(b).y -= dy * pull; + } + let moved = 0; + for (const id of nodes) { + if (pinned.has(id) || id === grabbed || (only && !only.has(id))) continue; + const p = positions.get(id), f = force.get(id); + const fx = f.x * gain, fy = f.y * gain; + const x = Math.min(width - 40, Math.max(40, p.x + Math.max(-most, Math.min(most, fx)))); + const y = Math.min(height - 20, Math.max(20, p.y + Math.max(-most, Math.min(most, fy)))); + moved = Math.max(moved, Math.abs(x - p.x), Math.abs(y - p.y)); + p.x = x; p.y = y; + } + return moved; + } +} + +// The layout (`rtt-toggle`, kept in this browser): static by default, nodes moving only +// when laid out anew (members or links come or go); or dynamic, resized by round trip: +// each measured link's length grows with the square root of its round trip against +// 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; +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 +// `loose`: it gives way to them, pulling only a little where stretched and never pushing +// where they hold its ends closer (`LOOSE`). +function lengths(intended, measured, count, width, height) { + const ideal = idealLength(count, width, height); + const rtts = [...measured.values()].map(edge => edge.link.rttMs) + .filter(rtt => typeof rtt === "number" && rtt > 0).sort((x, y) => x - y); + const median = rtts[Math.floor(rtts.length / 2)]; + const pairs = new Map([...intended].map(([key, [a, b]]) => [key, [a, b, ideal, true]])); + for (const [key, { a, b, link }] of measured) { + const rtt = link.rttMs; + const length = byRtt && median && typeof rtt === "number" && rtt > 0 + ? ideal * Math.min(2.5, Math.max(.4, Math.sqrt(rtt / median))) : ideal; + pairs.set(key, [a, b, Math.round(length), false]); + } + return [...pairs.values()]; +} +// What `settle` moves the map towards: the latest render's nodes and lengths. +let settling = false, settleTo = null, lastLengths = ""; +// Nodes let go (`unpin`) on a static map: the only ones `settle` moves there, to +// their places among the others. +const freed = new Set(); +function settle() { + if (settling) return; + settling = true; + let frames = 0; + const step = () => { + // Static again: where they are now (but those let go). + if (!byRtt && freed.size === 0) { settling = false; return; } + const { nodes, pairs, width, height } = settleTo; + // Damped: the swings of the layout's rounds die down instead of going on. + const moved = relax(nodes, pairs, width, height, .15, 2, byRtt ? null : freed); + renderGraph(); + if (moved > .1 && ++frames < 600 && !document.hidden) requestAnimationFrame(step); + else { + settling = false; + freed.clear(); + // Hidden: on again with the next render. + if (document.hidden) lastLengths = ""; + } + }; + requestAnimationFrame(step); +} + +// The node or link (its pair key) under the pointer, if any. Kept here rather than +// left to :hover: its halo is an element of its own, drawn behind it. +let hovered = null; +function hover(id) { + if (hovered === id) return; + hovered = id; + if (state) renderGraph(); + markPeerMessages(); +} +function hoverable(element, id) { + element.addEventListener("mouseenter", () => hover(id)); + element.addEventListener("mouseleave", () => hover(null)); +} + +// The map's elements by what they show (a key), kept across renders and updated in +// place: the one under the pointer stays itself. +const drawn = new Map(); +// The element `key` names, with exactly `attrs`; `made` sets up a new one (its +// listeners). +function drawing(key, tag, attrs, made) { + let node = drawn.get(key); + if (!node) { + node = svg(tag); + if (made) made(node); + drawn.set(key, node); + } + for (const attr of node.getAttributeNames()) if (!(attr in attrs)) node.removeAttribute(attr); + for (const [attr, value] of Object.entries(attrs)) { + if (node.getAttribute(attr) !== String(value)) node.setAttribute(attr, value); + } + return node; +} +// A label's `attrs`, turned back where the map is turned (`view`): upright, about where +// it is anchored. +function upright(attrs) { + if (!view.turn) return attrs; + return { ...attrs, transform: "rotate(" + -degrees(view.turn) + " " + attrs.x + " " + + attrs.y + ")" }; +} +function text(node, content) { + if (node.textContent !== content) node.textContent = content; +} +// A label on the map: its text, breathing once when it changed (not when new), as the +// connection's dot does: its underlay, in the shape of the new text, glows and fades. +// An animation of its own, not a class: every render sets the label's attributes. +const calm = matchMedia("(prefers-reduced-motion: reduce)"); +function label(node, content) { + if (node.textContent === content) return; + const before = node.textContent; + node.textContent = content; + if (!before || calm.matches || !node.animate) return; + const colours = getComputedStyle(document.documentElement); + const panel = colours.getPropertyValue("--panel").trim(); + const glow = "color-mix(in srgb, " + colours.getPropertyValue("--self").trim() + " 45%, " + + panel + ")"; + node.animate([ + { stroke: panel, strokeWidth: "4px" }, + { stroke: glow, strokeWidth: "7px", offset: .3 }, + { stroke: panel, strokeWidth: "4px" }, + ], { duration: 900, easing: "ease-in-out" }); +} +// A tooltip whose text changes under the pointer goes and comes back (the ages in it +// change on every render): its new text waits until the pointer leaves. +const tooltipsDue = new WeakMap(); +function tooltip(node, content) { + let title = node.firstElementChild; + if (!title) node.append(title = svg("title")); + if (title.textContent === content || !title.textContent || !node.matches(":hover")) { + tooltipsDue.delete(node); + text(title, content); + return; + } + if (!tooltipsDue.has(node)) { + node.addEventListener("mouseleave", () => { + const due = tooltipsDue.get(node); + tooltipsDue.delete(node); + if (due !== undefined) text(title, due); + }, { once: true }); + } + tooltipsDue.set(node, content); +} +// `root` showing `children`, in order: the others go, and only what is out of place +// moves. +function show(root, children) { + const shown = new Set(children); + for (const [key, node] of drawn) { + if (!shown.has(node)) { node.remove(); drawn.delete(key); } + } + let at = root.firstChild; + for (const node of children) { + if (node === at) at = at.nextSibling; else root.insertBefore(node, at); + } +} + +let lastShape = ""; +// The whole map laid out again with the next render (the window or the box resized), +// not only nodes new to it. +let relayout = true; +function layoutAgain() { + relayout = true; + lastShape = ""; +} +function renderGraph() { + if (document.getElementById("map").classList.contains("collapsed")) return; + const root = document.getElementById("graph"); + const width = root.clientWidth || 800, height = root.clientHeight || 560; + root.setAttribute("viewBox", "0 0 " + width + " " + height); + const nodes = Object.keys(state.graph.members).sort(); + const { intended, measured } = edges(); + renderLegend(measured); + const pairs = lengths(intended, measured, nodes.length, width, height); + // Laid out when who's there or who links to whom changed: only the nodes new to the + // map, the others staying where they are (links that came, after the first reports, + // don't move them); all of them at first, and when the map was resized. + const shape = nodes.join(",") + "|" + [...intended.keys(), ...measured.keys()].sort().join(","); + if (shape !== lastShape) { + for (const id of [...positions.keys()]) { + if (!nodes.includes(id)) { positions.delete(id); pinned.delete(id); } + } + const fresh = new Set(nodes.filter(id => !positions.has(id))); + if (relayout) { + layout(nodes, pairs, width, height); + // Dynamic: settled at once, not moving into place before one's eyes. + if (byRtt) { + for (let round = 0; round < 3000; round++) { + if (relax(nodes, pairs, width, height, .15, 2) <= .1) break; + } + lastLengths = pairs.map(pair => pair.join(" ")).join(","); + } + } else if (fresh.size > 0) { + layout(nodes, pairs, width, height, fresh); + } + relayout = false; + lastShape = shape; + } + settleTo = { nodes, pairs, width, height }; + const wanted = pairs.map(pair => pair.join(" ")).join(","); + if (byRtt && wanted !== lastLengths) { + lastLengths = wanted; + settle(); + } + const children = []; + // Under everything: the nodes and the link of the message shown in the console, and + // of the one pointed at, each in the grey its line has there (the shown one's where + // both mark the same), in its own shape (the lines of a link as they bow, a node's + // circle). A group per grey, so where its shapes overlap it is no darker. + const marking = new Map(); + for (const [message, how] of [[pointedMessage, "pointed"], [shownMessage, "shown"]]) { + if (message === null) continue; + for (const key of [state.node.id, message.peer, pairKey(state.node.id, message.peer)]) { + marking.set(key, how); + } + } + const marks = Object.fromEntries(["pointed", "shown"].map(how => [how, + drawing("marks " + how, "g", { "pointer-events": "none", + style: "opacity: var(--" + how + ")" })])); + const marked = { pointed: [], shown: [] }; + // A shape under `key` (a node's ID, a link's pair key), if it is marked. + const mark = (key, tag, attrs) => { + const how = marking.get(key); + if (how) marked[how].push(svg(tag, attrs)); + }; + const markLine = (key, d, width) => mark(key, "path", { d, fill: "none", + stroke: "rgb(127 127 127)", "stroke-width": width + 8, "stroke-linecap": "round" }); + children.push(marks.pointed, marks.shown); + // Behind a link: a halo while it's hovered or in focus (`width`: of its lines). + const halo = (key, p, q, width) => { + if (key !== hovered && key !== focused) return; + children.push(drawing("halo " + key, "line", { x1: p.x, y1: p.y, x2: q.x, y2: q.y, + stroke: "var(--self)", + style: "stroke-opacity: var(" + (key === focused ? "--halo-focused" : "--halo-hovered") + ")", + "stroke-width": width + 8, "stroke-linecap": "round" })); + }; + // Over a link: an invisible, wider line to point at and click. + const hits = []; + // The links' round trips. + const labels = []; + const hit = (key, p, q, width) => { + const line = drawing("hit " + key, "line", { x1: p.x, y1: p.y, x2: q.x, y2: q.y, + stroke: "transparent", "stroke-width": width + 8, cursor: "pointer" }, line => { + // Dragged, it pans, as beside the nodes; a click, it is in focus. + line.addEventListener("pointerdown", event => { event.stopPropagation(); pan(event, () => focus(key)); }); + hoverable(line, key); + }); + hits.push(line); + return line; + }; + for (const [key, [a, b]] of intended) { + if (measured.has(key)) continue; + const p = positions.get(a), q = positions.get(b); + halo(key, p, q, 1); + markLine(key, "M " + p.x + " " + p.y + " L " + q.x + " " + q.y, 1); + children.push(drawing("intended " + key, "line", { x1: p.x, y1: p.y, x2: q.x, y2: q.y, + stroke: "var(--intended)", "stroke-dasharray": "4 4", "stroke-width": 1 })); + tooltip(hit(key, p, q, 1), name(a) + " – " + name(b) + ": intended (member list), not measured"); + } + const ms = rtt => rtt < 10 ? rtt.toFixed(1) : String(Math.round(rtt)); + for (const [key, { a, b, link, secsAgo }] of measured) { + const p = positions.get(a), q = positions.get(b); + if (!p || !q) continue; + // The transport in use thick, in the middle; the others thin, beside it. + const lines = [{ transport: link.transport, rttMs: link.rttMs, used: true }]; + for (const other of link.others || []) lines.push({ ...other, used: false }); + // As wide as the lines side by side. + const width = 3 + 12 * Math.ceil((lines.length - 1) / 2); + halo(key, p, q, width); + // It covers the lines: their descriptions are its tooltip. + const covering = hit(key, p, q, width); + const descriptions = []; + const length = Math.max(Math.hypot(q.x - p.x, q.y - p.y), 1); + const nx = -(q.y - p.y) / length, ny = (q.x - p.x) / length; + // An end that reports no links (any more): what's drawn may be out of date. + const silent = !(a in state.graph.measured) || !(b in state.graph.measured); + // Dormant, or down (no path answers, whatever its connection was): a red underlay + // as wide as the lines, as a dormant node's red outline. + const broken = link.dormant || link.down; + if (broken) { + children.push(drawing("underlay " + key, "line", { x1: p.x, y1: p.y, x2: q.x, y2: q.y, + stroke: "var(--bad)", "stroke-opacity": .5, "stroke-width": width + 4, + "stroke-linecap": "round" })); + } + // The one in use drawn last: on top of the others where they meet, at the nodes. + const drawnLines = []; + lines.forEach((line, i) => { + // 0, then +1, -1, +2, -2 … steps of 6 px to the side, at the middle: from node to + // node, each bowing out that far (a quadratic curve, its control point twice as + // far out); `aside(t)`, how far out at `t` along. + const side = i === 0 ? 0 : (i % 2 ? 1 : -1) * Math.ceil(i / 2) * 6; + const aside = t => 4 * t * (1 - t) * side; + const cx = (p.x + q.x) / 2 + nx * side * 2, cy = (p.y + q.y) / 2 + ny * side * 2; + const attrs = { d: "M " + p.x + " " + p.y + " Q " + cx + " " + cy + " " + q.x + " " + q.y, + fill: "none", stroke: colourOf(line.transport), + // Narrow where dormant or down (over its underlay). + "stroke-width": broken ? (line.used ? 1.5 : 0.8) : line.used ? 3 : 1.2 }; + // Dotted where an end reports nothing, dashed where relayed, solid where direct. + if (silent) { + attrs["stroke-dasharray"] = line.used ? "1 5" : "1 3"; + attrs["stroke-linecap"] = "round"; + } else if (line.used ? link.relayed : line.relayed) { + attrs["stroke-dasharray"] = "7 4"; + } + markLine(key, attrs.d, attrs["stroke-width"]); + const drawnLine = drawing("path " + key + " " + i, "path", attrs); + const description = name(a) + " – " + name(b) + + (line.transport ? " via " + line.transport : "") + (line.used ? " (in use)" : "") + + ((line.used ? link.relayed : line.relayed) ? ", relayed" : "") + (link.dormant ? ", dormant" : "") + + (line.used && link.down ? ", down" : "") + + (silent ? ", one end reports no links" : "") + + (typeof line.rttMs === "number" ? ", " + line.rttMs.toFixed(1) + " ms" : "") + + ", measured " + age(secsAgo + elapsed()) + " ago"; + tooltip(drawnLine, description); + descriptions.push(description); + drawnLines.push(drawnLine); + if (typeof line.rttMs === "number") { + // Near the end that measured it (`a`, whose report is drawn), clear of its + // node and name, and short of the middle: the one in use larger, the others + // closer still, beside theirs. + const along = Math.min(length / 2, Math.max(36, length * .3)); + const t = (line.used ? along : Math.max(24, along * .6)) / length; + const out = line.used ? -4 : Math.sign(side) * 9; + const rtt = drawing("rtt " + key + " " + i, "text", upright({ + x: p.x + (q.x - p.x) * t + nx * (aside(t) + out), + y: p.y + (q.y - p.y) * t + ny * (aside(t) + out) + (line.used ? 0 : 3), + "text-anchor": "middle", "font-size": line.used ? 11 : 9, + fill: line.used ? "var(--fg)" : "var(--muted)" })); + label(rtt, ms(line.rttMs)); + labels.push(rtt); + } + }); + children.push(...drawnLines.reverse()); + tooltip(covering, descriptions.join("\n")); + } + // Above every line, so none crosses them. + children.push(...labels); + // Above the lines and their labels, below the nodes. + children.push(...hits); + for (const id of nodes) { + const p = positions.get(id), member = state.graph.members[id]; + const reported = id in state.graph.measured; + const shown = focused === null ? member.self : id === focused; + // A link in focus: both ends ringed. + // A halo as links have: light under the pointer, stronger in focus. + if (shown || id === hovered) { + children.push(drawing("ring " + id, "circle", { cx: p.x, cy: p.y, r: 13, + fill: "var(--self)", stroke: "none", + style: "fill-opacity: var(" + (shown ? "--halo-focused" : "--halo-hovered") + ")" })); + } + const pressed = element => { + element.addEventListener("pointerdown", event => { event.stopPropagation(); drag(event, id); }); + hoverable(element, id); + }; + // A clock off the swarm's: a dashed orange ring. + if (((state.graph.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" })); + } + // Dormant as this node sees it (`/peers`): a red outline. + const dormant = (state.peers.peers[id] || {}).status === "dormant"; + const radius = member.self ? 8 : 6; + mark(id, "circle", { cx: p.x, cy: p.y, r: radius + 6, fill: "rgb(127 127 127)" }); + const circle = drawing("node " + id, "circle", { cx: p.x, cy: p.y, r: radius, + fill: member.self ? "var(--self)" : reported ? "var(--fg)" : "var(--panel)", + stroke: member.self ? "var(--self)" : dormant ? "var(--bad)" : "var(--fg)", + "stroke-width": dormant ? 2.5 : 1.5 }, pressed); + // Its clock against the swarm's, as the members' reports chain it (the node's). + const clock = ((state.graph.clocks || {})[id]); + tooltip(circle, id + (reported ? "" : " (no links reported)") + + (dormant ? " (dormant)" : "") + + (clock && clock.off ? " (clock " + seconds(clock.offsetMs) + " off the swarm's, ±" + + clock.withinMs + " ms)" : "")); + // Pinned (double-clicked): a dot in the middle. + const pin = pinned.has(id) ? [drawing("pin " + id, "circle", { cx: p.x, cy: p.y, r: 2, + fill: member.self || reported ? "var(--panel)" : "var(--fg)", + "pointer-events": "none" })] : []; + const nameLabel = drawing("name " + id, "text", upright({ x: p.x + 10, y: p.y + 4, + "font-size": 12, fill: "var(--fg)", cursor: "pointer" }), pressed); + label(nameLabel, name(id)); + children.push(circle, ...pin, nameLabel); + } + marks.pointed.replaceChildren(...marked.pointed); + marks.shown.replaceChildren(...marked.shown); + show(document.getElementById("view"), children); +} + +// --- Document: read a level at a time (`/store?shallow=true`) as it is opened, and +// read again where the node says it changed (`document` events) --- +// What is open, by path (as JSON): the first level to begin with. +const documentOpen = new Set([JSON.stringify([])]); +// The open branches read, by path (as JSON): to read again on a change. +const documentBranches = new Map(); +function storeUrl(path, shallow) { + const where = path.length ? "../store/" + path.map(encodeURIComponent).join("/") : "../store"; + return where + (shallow ? "?shallow=true" : ""); +} +// Store paths split at "/": a key with one (or an empty key) can't be read on its own. +const addressable = path => path.every(segment => segment !== "" && !segment.includes("/")); +// Either is within the other. +const related = (a, b) => a.length <= b.length + ? a.every((segment, i) => segment === b[i]) : related(b, a); +function describe(summary) { + switch (summary.type) { + case "map": return "{" + summary.size + (summary.size === 1 ? " key}" : " keys}"); + case "list": return "[" + summary.size + (summary.size === 1 ? " item]" : " items]"); + case "text": return "text, " + summary.size + " characters"; + default: return summary.type + ("size" in summary ? ", " + summary.size + " bytes" : ""); + } +} +async function readStore(path, shallow) { + const response = await fetch(storeUrl(path, shallow), { headers: { Accept: "application/json" } }); + if (!response.ok) throw new Error("HTTP " + response.status); + return response.json(); +} +// An empty map, list or text: shown as it is, nothing to open. +const EMPTY = { map: "{}", list: "[]", text: "\"\"" }; +const EMPTY_VALUE = { map: () => ({}), list: () => [], text: () => "" }; + +// --- Editing: a double click on an entry turns it into a field holding its contents; +// saved with PUT /store/<path> (the whole value, objects too) --- +// A string is edited as text (sent as text/plain), anything else as JSON; "JSON" +// switches. `done(saved, response)` when saved or cancelled. +// `options` for adding rather than replacing: `extra`, a short field before the value +// (`{ label, initial, hint }`: the key, or the index); `check(extra)`, an error text if +// it won't do; `send(body, type, extra)`, the request (PUT to `path` without). +function editor(path, value, done, options = {}) { + const message = el("span", { class: "note" }); + const fail = text => { message.className = "error"; message.textContent = text; }; + const asText = typeof value === "string"; + const text = asText ? value : JSON.stringify(value, null, 2); + const field = el("textarea", { spellcheck: "false", + rows: String(Math.max(2, Math.min(20, text.split("\n").length + 1))) }); + field.value = text; + const json = el("input", { type: "checkbox" }); + json.checked = !asText; + json.addEventListener("change", () => { + if (json.checked) { field.value = JSON.stringify(field.value); return; } + try { + const parsed = JSON.parse(field.value); + if (typeof parsed !== "string") throw new Error("not a string"); + field.value = parsed; + } catch { + json.checked = true; + fail("Only a JSON string can be edited as text."); + } + }); + const extra = options.extra + ? el("input", { type: "text", spellcheck: "false", class: "extra", + placeholder: options.extra.label, title: options.extra.hint || options.extra.label }) + : null; + if (extra) extra.value = options.extra.initial || ""; + const send = options.send || ((body, type) => fetch(storeUrl(path, false), + { method: "PUT", body, headers: { "Content-Type": type } })); + const save = el("button", { type: "button" }, options.extra ? "Add" : "Save"); + const cancel = el("button", { type: "button" }, "Cancel"); + const submit = async () => { + const body = field.value; + const problem = extra && options.check ? options.check(extra.value) : null; + if (problem) { fail(problem); return; } + if (json.checked) { + try { JSON.parse(body); } catch (error) { + fail("Not JSON: " + error.message); return; + } + } + save.disabled = true; + message.className = "note"; + message.textContent = "Saving…"; + try { + const response = await send(body, json.checked ? "application/json" : "text/plain", + extra ? extra.value : null); + if (!response.ok) { + // The node says why (`{"error": …}`), where it does. + let why = response.status === 403 ? "not allowed" : ""; + try { why = (await response.json()).error || why; } catch { /* no body */ } + throw new Error("HTTP " + response.status + (why ? ": " + why : "")); + } + done(true, response); + } catch (error) { + save.disabled = false; + fail("Not saved: " + error.message); + } + }; + save.addEventListener("click", submit); + cancel.addEventListener("click", () => done(false)); + // Editing what's there: it can be deleted too (DELETE /store/<path>), after a second + // click to be sure, not too soon after the first: a double-click doesn't delete. + const remove = options.extra ? null : el("button", { type: "button", class: "delete" }, + "Delete"); + let sure = null, askedAt = 0; + remove?.addEventListener("click", async () => { + if (sure === null) { + remove.textContent = "Delete: sure?"; + askedAt = performance.now(); + sure = setTimeout(() => { sure = null; remove.textContent = "Delete"; }, 3000); + return; + } + if (performance.now() - askedAt < 500) return; + clearTimeout(sure); + remove.disabled = save.disabled = true; + message.className = "note"; + message.textContent = "Deleting…"; + try { + const response = await fetch(storeUrl(path, false), { method: "DELETE" }); + if (!response.ok) { + let why = response.status === 403 ? "not allowed" : ""; + try { why = (await response.json()).error || why; } catch { /* no body */ } + throw new Error("HTTP " + response.status + (why ? ": " + why : "")); + } + done("deleted", response); + } catch (error) { + sure = null; + remove.textContent = "Delete"; + remove.disabled = save.disabled = false; + fail("Not deleted: " + error.message); + } + }); + // Enter in the short field saves; in the area, Ctrl or ⌘ and Enter does. + const keys = (input, line) => input.addEventListener("keydown", event => { + if (event.key === "Escape") { event.preventDefault(); done(false); } + else if (event.key === "Enter" && (line || event.ctrlKey || event.metaKey)) { + event.preventDefault(); submit(); + } + }); + keys(field, false); + if (extra) keys(extra, true); + setTimeout(() => (extra && !extra.value ? extra : field).focus()); + return el("div", { class: "editor" }, ...(extra ? [extra] : []), field, + el("div", {}, save, " ", cancel, " ", ...(remove ? [remove, " "] : []), + el("label", {}, json, " JSON"), " ", + el("span", { class: "note" }, "Ctrl+Enter saves, Esc cancels"), " ", message)); +} + +// Store paths name keys below the root; the root itself isn't written whole. +const editable = path => path.length > 0 && addressable(path); +// Branches whose reading again waited for an edit in them to end, by path (as JSON). +const documentDeferred = new Set(); +// After an edit: what changed is read again (the node's `document` event follows), +// and what was waiting for the edit; `opened`, what it made, shown open. `saved`: +// true, false (cancelled), or "deleted" (then its parent is read again). +function edited(path, saved, opened = path) { + if (saved === "deleted") path = path.slice(0, -1); + else if (saved) documentOpen.add(JSON.stringify(opened)); + release(); + const waiting = [...documentDeferred].map(id => JSON.parse(id)); + documentDeferred.clear(); + if (saved || waiting.length) documentChanged({ paths: saved ? [path, ...waiting] : waiting }); +} +// Whether `element` holds a field being edited: left as it is until it's done. +const editing = element => element.querySelector(".editor") !== null; + +// Adding to a map (a key and its value, PUT /store/<path>/<key>) or a list (an index +// and the value, POST /store/<path>/<index>: the items from there on move up; -1 is the +// end), without writing it whole: a "+" on the row, the field below it. `known()`: a +// map's keys as last read, so an existing one isn't overwritten. +function addButton(kind, path, known, place) { + const map = kind === "map"; + const button = el("button", { type: "button", class: "add", + title: map ? "Add a key" : "Insert an item" }, "+"); + button.addEventListener("click", event => { + // Not a click on the row: it would open or close it. + event.preventDefault(); + event.stopPropagation(); + const area = place(); + if (editing(area)) return; + const form = editor(path, "", async (saved, response) => { + form.remove(); + let added = path; + if (saved) { + if (map) added = [...path, form.dataset.extra]; + else { + try { added = [...path, String((await response.json()).index)]; } catch { /* path */ } + } + } + edited(path, saved, added); + }, { + extra: map + ? { label: "key", initial: "" } + : { label: "index", initial: "-1", hint: "where: 0 is the first; -1 the end" }, + check: extra => { + form.dataset.extra = extra; + return map + ? (extra === "" ? "A key, please." + : extra.includes("/") ? "A key with \"/\" can't be named in a path." + : known().includes(extra) ? "\"" + extra + "\" exists already: edit it instead." + : null) + : (/^(-1|\d+)$/.test(extra.trim()) ? null : "The index: 0 or more, or -1 for the end."); + }, + send: (body, type, extra) => fetch(storeUrl([...path, map ? extra : extra.trim()], false), + { method: map ? "PUT" : "POST", body, headers: { "Content-Type": type } }), + }); + area.prepend(form); + }); + return button; +} + +function editableLeaf(key, shown, value, path) { + const leaf = el("div", { class: "leaf" }, el("span", { class: "key" }, key + ": "), shown); + if (!editable(path)) return leaf; + leaf.title = "Double-click to edit"; + leaf.addEventListener("dblclick", event => { + if (editing(leaf)) return; + event.preventDefault(); + window.getSelection().removeAllRanges(); + const before = [...leaf.childNodes]; + leaf.replaceChildren(before[0], editor(path, value, saved => { + leaf.replaceChildren(...before); + edited(path, saved); + })); + }); + return leaf; +} + +// An entry that changed in the document breathes once, as the map's labels do: its +// row glows and fades. +function glow(entry) { + if (calm.matches || !entry.animate) return; + const row = entry.tagName === "DETAILS" ? entry.querySelector(":scope > summary") + : entry.classList.contains("leaf") ? entry : entry.firstElementChild || entry; + const colour = getComputedStyle(document.documentElement).getPropertyValue("--self").trim(); + row.animate([ + { backgroundColor: "transparent" }, + { backgroundColor: "color-mix(in srgb, " + colour + " 30%, transparent)", offset: .3 }, + { backgroundColor: "transparent" }, + ], { duration: 900, easing: "ease-in-out" }); +} + +// One entry: a short scalar as it is; anything else a branch read when opened. A +// branch's entries are kept across reads, so what's open inside stays open. +function documentEntry(key, summary, path) { + if (summary.size === 0 && summary.type in EMPTY) { + const leaf = editableLeaf(key, EMPTY[summary.type], EMPTY_VALUE[summary.type](), path); + // An empty map or list takes its first entry here: "+" on its row, the field below. + if ((summary.type === "map" || summary.type === "list") && editable(path)) { + const below = el("div", { class: "nested" }); + leaf.append(" ", addButton(summary.type, path, () => [], () => below)); + return el("div", {}, leaf, below); + } + return leaf; + } + if ("value" in summary) { + return editableLeaf(key, JSON.stringify(summary.value), summary.value, path); + } + const details = el("details", {}); + const described = document.createTextNode(describe(summary)); + const head = el("summary", {}, el("span", { class: "key" }, key), " ", described); + details.append(head); + // A double click edits the whole of it: read, then shown in a field. + if (editable(path)) { + head.title = "Double-click to edit"; + head.addEventListener("mousedown", event => { if (event.detail > 1) event.preventDefault(); }); + head.addEventListener("dblclick", async event => { + event.preventDefault(); + if (details.querySelector(":scope > .editor")) return; + let value; + try { value = await readStore(path, false); } catch (error) { + described.textContent = describe(summary) + " (not read: " + error.message + ")"; + return; + } + details.open = true; + details.classList.add("editing"); + head.after(editor(path, value, saved => { + details.querySelector(":scope > .editor").remove(); + details.classList.remove("editing"); + edited(path, saved); + })); + }); + } + // A newer summary from the parent: a branch of the same kind stays as it is. + details.updated = newer => { + summary = newer; + described.textContent = describe(newer); + }; + details.sameKind = newer => !("value" in newer) && newer.type === summary.type; + if (!addressable(path)) { + details.append(el("div", { class: "note" }, "Its path can't be read on its own (a key with \"/\").")); + return details; + } + const id = JSON.stringify(path); + const children = new Map(); + const browsed = summary.type === "map" || summary.type === "list"; + // Adding: on the row, the field just below it (the branch opened for it). + if (browsed && (editable(path) || path.length === 0)) { + head.append(" ", addButton(summary.type, path, () => [...children.keys()], () => { + details.open = true; + return { prepend: form => head.after(form), querySelector: q => details.querySelector(":scope > " + q) }; + })); + } + let read = false; + // Read once at least: what's new or changed from then on breathes (`glow`). + let filled = false; + const fill = async () => { + // Being edited as a whole: read again once that's done. + if (details.classList.contains("editing")) { documentDeferred.add(id); return; } + try { + if (browsed) { + const shallow = await readStore(path, true); + details.updated(shallow); + const entries = shallow.entries || {}; + // A list's items by index (the node sends them sorted as text). + const keys = Object.keys(entries); + if (summary.type === "list") keys.sort((a, b) => Number(a) - Number(b)); + for (const [k, child] of children) { + if (k in entries) continue; + // Being edited: kept until that's done. + if (editing(child.element)) { documentDeferred.add(id); continue; } + child.element.remove(); + children.delete(k); + } + for (const k of keys) { + const newer = entries[k]; + const known = children.get(k); + if (known && JSON.stringify(known.summary) === JSON.stringify(newer)) continue; + if (known && editing(known.element)) { documentDeferred.add(id); continue; } + if (known && known.element.sameKind && known.element.sameKind(newer)) { + known.element.updated(newer); + known.summary = newer; + glow(known.element); + continue; + } + const element = documentEntry(k, newer, [...path, k]); + if (known) known.element.replaceWith(element); + children.set(k, { element, summary: newer }); + if (filled) glow(element); + } + // In order; moving keeps their state. + details.querySelectorAll(":scope > .note").forEach(note => note.remove()); + if (!children.size) details.append(el("div", { class: "note" }, "empty")); + for (const k of keys) if (children.has(k)) details.append(children.get(k).element); + } else { + const value = await readStore(path, false); + const shown = el("pre", {}, typeof value === "string" ? value : JSON.stringify(value, null, 2)); + const before = details.querySelector(":scope > pre"); + if (before) before.replaceWith(shown); else details.append(shown); + if (before && before.textContent !== shown.textContent) glow(shown); + } + filled = true; + } catch (error) { + details.querySelectorAll(":scope > .note").forEach(note => note.remove()); + details.append(el("div", { class: "note error" }, "Not read: " + error.message)); + } + }; + const open = () => { + documentOpen.add(id); + documentBranches.set(id, { path, element: details, refresh: fill }); + if (!read) { read = true; fill(); } + }; + details.addEventListener("toggle", () => { + if (details.open) open(); + else { documentOpen.delete(id); documentBranches.delete(id); } + }); + if (documentOpen.has(id)) { details.open = true; open(); } + return details; +} +// The node says what changed (paths), or that anything may have (`all`). +// Changes waiting while text in the document box is selected (`busy`): merged, applied +// after. A field being edited holds only what it is in (`editing`, `documentDeferred`). +let documentPending = null; +function documentChanged(change) { + if (busy(document.getElementById("document-box"))) { + documentPending = documentPending === null ? change + : documentPending.all || change.all ? { all: true } + : { paths: [...documentPending.paths, ...change.paths] }; + return; + } + for (const [id, branch] of documentBranches) { + if (!branch.element.isConnected) { documentBranches.delete(id); continue; } + if (change.all || change.paths.some(path => related(path, branch.path))) branch.refresh(); + } +} +document.getElementById("document").replaceChildren( + documentEntry("store", { type: "map", size: "…" }, [])); + +// A box is busy while text in it is selected: drawing it anew would lose the selection, +// so it waits (`held`) until it's gone. +// Collapsed, or not shown at all (the right column dragged off): drawn when opened +// (`release`). +function hidden(box) { + return box.classList.contains("collapsed") || box.getClientRects().length === 0; +} +function busy(box) { + if (hidden(box)) return true; + const selection = window.getSelection(); + if (!selection || selection.isCollapsed || !selection.rangeCount) return false; + return box.contains(selection.getRangeAt(0).commonAncestorContainer); +} +let held = false; +function render(ages = false) { + if (!state) return; + document.getElementById("node").textContent = + (state.node.alias ? state.node.alias + " · " : "") + state.node.id; + 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. + if (!ages) boxes.push(["map", renderGraph]); + for (const [box, draw] of boxes) { + if (busy(document.getElementById(box))) held = true; else draw(); + } +} +// What waited for a box to be free: drawn, or applied, now. +function release() { + if (held) render(); + if (documentPending !== null && !busy(document.getElementById("document-box"))) { + const change = documentPending; + documentPending = null; + documentChanged(change); + } +} +document.addEventListener("selectionchange", release); + +// The legend, hidden and shown again; the map takes the room. +const legendToggle = document.getElementById("legend-toggle"); +legendToggle.addEventListener("click", () => { + const legends = document.getElementById("legends"); + legends.hidden = !legends.hidden; + legendToggle.textContent = legends.hidden ? "show legend" : "hide legend"; + if (state) renderGraph(); +}); +// 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(() => {}); +}); +// Links as long as their round trips, and back. +const rttToggle = document.getElementById("rtt-toggle"); +function showRttToggle() { + rttToggle.textContent = byRtt ? "layout: dynamic" : "layout: static"; + rttToggle.title = "Click for " + (byRtt ? "a static layout" + : "a dynamic one: links as long as their round trips"); +} +showRttToggle(); +rttToggle.addEventListener("click", () => { + byRtt = !byRtt; + try { localStorage.setItem("continuum-by-rtt", byRtt ? "1" : "0"); } catch (_) {} + showRttToggle(); + // To the lengths afresh, when resized again. + lastLengths = ""; + if (state) renderGraph(); +}); + +// --- Pan, zoom and turn: the map's group moved, scaled and turned (`turn`, in +// radians). Lines, labels and nodes scale with it, as on a map; labels stay upright +// (`upright`). --- +const graph = document.getElementById("graph"); +const view = { x: 0, y: 0, k: 1, turn: 0 }; +const degrees = radians => radians * 180 / Math.PI; +function moveView() { + document.getElementById("view").setAttribute("transform", + "translate(" + view.x + " " + view.y + ") rotate(" + degrees(view.turn) + ") scale(" + + view.k + ")"); +} +// `[x, y]` turned by `angle`. +function turned(x, y, angle) { + const c = Math.cos(angle), s = Math.sin(angle); + return [x * c - y * s, x * s + y * c]; +} +// Where the pointer is, in the map's own units (its `viewBox` is its size). +function pointer(event) { + const box = graph.getBoundingClientRect(); + return { x: event.clientX - box.left, y: event.clientY - box.top }; +} +// How far it zooms: from a quarter to eight times. +const zoomed = k => Math.min(8, Math.max(.25, k)); +// The wheel zooms around the pointer. +graph.addEventListener("wheel", event => { + event.preventDefault(); + const at = pointer(event); + const k = zoomed(view.k * Math.exp(-event.deltaY * .002)); + view.x = at.x - (at.x - view.x) * k / view.k; + view.y = at.y - (at.y - view.y) * k / view.k; + view.k = k; + moveView(); +}, { passive: false }); +// A press on the map: dragged (more than a few pixels), it `move`s by as much as the +// pointer did; let go where pressed, it's a `click`. +let press = null; +function pressing(event, move, click) { + press = { from: pointer(event), moved: false, move, click }; + graph.setPointerCapture(event.pointerId); +} +// Fingers on the map, by pointer. Two pinch: the map zooms around the point between +// them, moves with it, and turns as they do. A press (`pressing`) is given up when the +// second comes. +const touching = new Map(); +let pinch = null; +const between = (a, b) => ({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }); +// From the first two fingers as they are now. +function pinchFrom() { + const [one, two] = touching.values(); + pinch = { view: { ...view }, at: between(one, two), + distance: Math.max(Math.hypot(two.x - one.x, two.y - one.y), 1), + angle: Math.atan2(two.y - one.y, two.x - one.x) }; +} +function pinched() { + const [one, two] = touching.values(); + const at = between(one, two); + const k = zoomed(pinch.view.k * Math.hypot(two.x - one.x, two.y - one.y) / pinch.distance); + const turn = pinch.view.turn + Math.atan2(two.y - one.y, two.x - one.x) - pinch.angle; + // What was between the fingers (on the map, unturned and unscaled) stays between them. + const [x, y] = turned(pinch.at.x - pinch.view.x, pinch.at.y - pinch.view.y, -pinch.view.turn); + const [dx, dy] = turned(x * k / pinch.view.k, y * k / pinch.view.k, turn); + Object.assign(view, { x: at.x - dx, y: at.y - dy, k, turn }); + moveView(); + // Its labels upright again. + if (state) renderGraph(); +} +// Before the map's own handlers (a node's included): a second finger takes over. +graph.addEventListener("pointerdown", event => { + if (event.pointerType !== "touch") return; + touching.set(event.pointerId, pointer(event)); + graph.setPointerCapture(event.pointerId); + if (touching.size < 2) return; + press = null; + graph.classList.remove("dragging"); + pinchFrom(); + event.stopImmediatePropagation(); +}, true); +// A finger lifted: the others pinch on from where they are, or (one left) do nothing +// until lifted too. +function untouched(event) { + if (!touching.delete(event.pointerId)) return; + if (touching.size >= 2) pinchFrom(); else pinch = null; +} +graph.addEventListener("pointermove", event => { + if (touching.has(event.pointerId)) touching.set(event.pointerId, pointer(event)); + if (pinch) { pinched(); return; } + if (!press) return; + const at = pointer(event); + const dx = at.x - press.from.x, dy = at.y - press.from.y; + if (!press.moved && Math.hypot(dx, dy) < 4) return; + press.moved = true; + graph.classList.add("dragging"); + press.move(dx, dy); +}); +graph.addEventListener("pointerup", event => { + untouched(event); + const click = press && !press.moved ? press.click : null; + press = null; + graph.classList.remove("dragging"); + dropped(); + if (click) click(); +}); +graph.addEventListener("pointercancel", event => { + untouched(event); + press = null; + graph.classList.remove("dragging"); + dropped(); +}); +// The map dragged: it pans; a click, `click`. +function pan(event, click) { + const from = { ...view }; + pressing(event, (dx, dy) => { + view.x = from.x + dx; + view.y = from.y + dy; + moveView(); + }, click); +} +// Beside the nodes: dragged, it pans; a click, back to this node. +graph.addEventListener("pointerdown", event => { + if (event.target !== graph) return; + pan(event, () => focus(null)); +}); +// Not the browser's drag and drop of the picture. +graph.addEventListener("dragstart", event => event.preventDefault()); +// A node: dragged, it moves, held where the pointer is (`grabbed`) until let go; a click, +// it is in focus; a second one soon after pins it where it is (`pinned`: the layout +// leaves it there), or lets it go if pinned (`unpin`), and so does a second press soon +// after that drags it (pinned where it's dropped). Told apart here: the map holds the +// pointer while pressed (`pressing`), so the browser's clicks and double-clicks go to +// the map, not the node. +let lastNodeClick = { id: null, at: 0 }; +const DOUBLE_CLICK_MS = 400; +let grabbed = null; +// Of the press under way on a node: whether it's the second of a double-click, and +// whether it moved the node. +let secondPress = false, dragMoved = false; +function drag(event, id) { + const p = positions.get(id), from = { x: p.x, y: p.y }; + grabbed = id; + secondPress = lastNodeClick.id === id && performance.now() - lastNodeClick.at < DOUBLE_CLICK_MS; + dragMoved = false; + pressing(event, (dx, dy) => { + dragMoved = true; + // The pointer's move, on the map (unturned and unscaled). + const [x, y] = turned(dx, dy, -view.turn); + p.x = from.x + x / view.k; + p.y = from.y + y / view.k; + renderGraph(); + // Dynamic: the others follow it at once. + if (byRtt) settle(); + }, () => { + const now = performance.now(); + if (lastNodeClick.id === id && now - lastNodeClick.at < DOUBLE_CLICK_MS) { + lastNodeClick = { id: null, at: now }; + if (pinned.has(id)) unpin(id); else pin(id); + } else { + lastNodeClick = { id, at: now }; + focus(id); + } + }); +} +// A dragged node let go of: dynamic, it moves to its place among the others again; +// dragged by a double-click's second press, its pin toggled. +function dropped() { + if (grabbed === null) return; + const id = grabbed; + grabbed = null; + if (dragMoved && secondPress) { + lastNodeClick = { id: null, at: 0 }; + if (pinned.has(id)) unpin(id); else pin(id); + return; + } + if (byRtt) settle(); +} +// A node pinned where it is: the layout leaves it there. +function pin(id) { + pinned.add(id); + renderGraph(); +} +// A node let go: it moves to its place among the others, smoothly (`settle`); on a +// static map, only it moves. +function unpin(id) { + if (!pinned.delete(id)) return; + freed.add(id); + renderGraph(); + settle(); +} +// Beside the nodes, or off the map: nothing hovered (the element left may be gone). +graph.addEventListener("mousemove", event => { if (event.target === graph) hover(null); }); +graph.addEventListener("mouseleave", () => hover(null)); + +// --- Messages on the map: this node's traffic with each peer (`/peers`, `traffic`), +// as it grew since the last update, drawn as dots running along the link the way the +// messages went. Counts come with each update (every 2 s), so the dots are spread +// over the next 2 s, a few at most per link and way: a picture of the traffic, not a +// record of it. Kept above everything else on the map (`flows`, after what `show` +// places). --- +const FLOW_MS = 700, FLOW_SPREAD_MS = 2000, FLOWS_AT_MOST = 6; +// Shown or not (`flows-toggle`, kept in this browser; shown at first). +let flowsShown = true; +try { flowsShown = localStorage.getItem("continuum-flows") !== "0"; } catch (_) {} +const flowsToggle = document.getElementById("flows-toggle"); +function showFlowsToggle() { + flowsToggle.textContent = flowsShown ? "messages: on" : "messages: off"; + flowsToggle.title = "Click to " + (flowsShown ? "hide" : "show") + + " the messages running along this node's links"; +} +showFlowsToggle(); +flowsToggle.addEventListener("click", () => { + flowsShown = !flowsShown; + try { localStorage.setItem("continuum-flows", flowsShown ? "1" : "0"); } catch (_) {} + showFlowsToggle(); + showMessagesLegend(); + if (!flowsShown) { + flows.length = 0; + document.getElementById("flows").replaceChildren(); + } +}); +let lastTraffic = null; +const flows = []; +let flowFrame = null; +// Messages and bytes by kind, of a peer's `traffic.sent` or `.received`. +function messages(byKind) { + return Object.fromEntries(Object.entries(byKind || {}) + .map(([kind, counter]) => [kind, { messages: counter.messages, bytes: counter.bytes }])); +} +// 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"]]; +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 +// in this browser). +let dotScale = 2; +try { dotScale = Number(localStorage.getItem("continuum-dot-scale")) || 2; } catch (_) {} +dotScale = Math.min(4, Math.max(1.25, dotScale)); +let dotSlider = null; +function showMessagesLegend() { + const legend = document.getElementById("messages-legend"); + legend.classList.toggle("off", !flowsShown); + if (dotSlider === null) { + const slider = dotSlider = el("input", { type: "range", min: "1.25", max: "4", step: ".25", + title: "How large the dots are" }); + slider.value = String(dotScale); + slider.addEventListener("input", () => { + dotScale = Number(slider.value); + try { localStorage.setItem("continuum-dot-scale", String(dotScale)); } catch (_) {} + }); + // Before the toggle, which ends the line. + legend.prepend(...KIND_COLOURS.map(([kind, colour]) => + el("span", { class: "message", style: "--colour: " + colour }, + kind === "paths" ? "paths (pings)" : kind)), + el("label", { class: "dot-size" }, "size", slider)); + } + dotSlider.disabled = !flowsShown; +} +showMessagesLegend(); +// A dot's radius for a message of `bytes`: grows with the size's logarithm, from 1.5 +// (a ping) to 8. +const dotRadius = bytes => Math.min(8, Math.max(1.5, 1.5 + 1.1 * Math.log10(1 + bytes / 32))); +// The dots for messages that grew by `kinds` (kind: messages, bytes): `FLOWS_AT_MOST` +// at most, shared out by kind (one each at least, the busiest first), each as large as +// its kind's messages on average; interleaved. +function dotsFor(kinds, total) { + const byKind = Object.entries(kinds).sort(([, a], [, b]) => b.messages - a.messages) + .map(([kind, grown]) => ({ kind, bytes: grown.bytes / grown.messages, + count: Math.min(grown.messages, Math.max(1, Math.round(FLOWS_AT_MOST * grown.messages / total))) })); + const dots = []; + for (let round = 0; dots.length < FLOWS_AT_MOST && byKind.some(k => k.count > round); round++) { + for (const kind of byKind) { + if (kind.count > round && dots.length < FLOWS_AT_MOST) dots.push(kind); + } + } + return dots; +} +// The messages exchanged with each peer since the last update, on the map (`flows`, if +// shown). +function trafficArrived() { + if (!state) return; + const self = state.node.id, now = performance.now(); + const dots = flowsShown && !calm.matches && !document.hidden && + !document.getElementById("map").classList.contains("collapsed"); + const current = new Map(); + for (const [id, peer] of Object.entries(state.peers.peers)) { + if (peer.traffic) { + current.set(id, { sent: messages(peer.traffic.sent), + received: messages(peer.traffic.received) }); + } + } + if (lastTraffic) { + for (const [id, counts] of current) { + const before = lastTraffic.get(id); + if (!before) continue; + for (const [way, from, to] of [["sent", self, id], ["received", id, self]]) { + const kinds = {}; + let total = 0; + for (const [kind, count] of Object.entries(counts[way])) { + const was = before[way][kind] || { messages: 0, bytes: 0 }; + const grown = Math.max(0, count.messages - was.messages); + if (grown > 0) { + kinds[kind] = { messages: grown, bytes: Math.max(0, count.bytes - was.bytes) }; + total += grown; + } + } + if (total === 0) continue; + if (!dots) continue; + const shown = dotsFor(kinds, total); + shown.forEach((dot, i) => { + flows.push({ from, to, start: now + i * FLOW_SPREAD_MS / shown.length, + kind: dot.kind, r: dotRadius(dot.bytes) }); + }); + } + } + } + lastTraffic = current; + if (flows.length > 0 && flowFrame === null) flowFrame = requestAnimationFrame(drawFlows); +} + +// --- Console: the messages this node exchanged with its peers, one by one, as the +// node logs them (`messages` events, `traffic::MessageLog`), the latest at the bottom, +// `CONSOLE_LINES` at most. Scrolled along while at the bottom. A click on one shows it +// in the figures box (`shownMessage`). --- +const CONSOLE_LINES = 500; +// A time of day on the 24-hour clock, whatever the browser's locale: 18:04:05.123. +const clockTime = date => [date.getHours(), date.getMinutes(), date.getSeconds()] + .map(part => String(part).padStart(2, "0")).join(":") + "." + + String(date.getMilliseconds()).padStart(3, "0"); +const size = bytes => bytes < 1024 ? bytes + " B" : bytes < 1048576 + ? (bytes / 1024).toFixed(1) + " KiB" : (bytes / 1048576).toFixed(1) + " MiB"; +// The messages, kept while the box is collapsed and drawn when it's opened +// (`showConsole`). +const consoleMessages = []; +// The message shown in the figures box instead of what is in focus; null: none. +let shownMessage = null; +const WAYS = { sent: "→", received: "←", refused: "↛" }; +// The exchange marked in the console (a message's `related`): the one of the message +// pointed at, else of the one shown; null: none. +let relatedMarked = null; +// The message pointed at in the console, if any: its nodes and link are marked on the +// map (`renderGraph`), else the shown one's. +let pointedMessage = null; +function pointAt(message) { + pointedMessage = message; + markRelated(message ? message.related : shownRelated()); + if (state) renderGraph(); +} +function markRelated(key) { + relatedMarked = key || null; + for (const line of document.getElementById("console").children) { + line.classList.toggle("related", relatedMarked !== null && line.dataset.related === relatedMarked); + } +} +const shownRelated = () => shownMessage && shownMessage.related; +// The peer whose messages `key` (a node, or a link's pair key) stands for: the node, +// or the other end of one of this node's links; null: none (this node itself, whose +// every message it would be, or a link between others). +function peerOf(key) { + if (key === null || !state) return null; + const self = state.node.id; + if (!isPair(key)) return key === self ? null : key; + const [a, b] = key.split(" "); + return a === self ? b : b === self ? a : null; +} +// A console line's tint for the node or link pointed at on the map, or in focus. +function peerClasses(peer) { + return (peer === peerOf(focused) ? " peer-focused" : "") + + (peer === peerOf(hovered) ? " peer-hovered" : ""); +} +function markPeerMessages() { + const focusedPeer = peerOf(focused), hoveredPeer = peerOf(hovered); + for (const line of document.getElementById("console").children) { + line.classList.toggle("peer-focused", focusedPeer !== null && line.dataset.peer === focusedPeer); + line.classList.toggle("peer-hovered", hoveredPeer !== null && line.dataset.peer === hoveredPeer); + } +} +// A line: when, which way, with whom, the type (in its kind's colour) and the size. +function consoleLine(message) { + const line = el("div", { "data-seq": String(message.seq), + "data-related": message.related || "", + "data-peer": message.peer, + class: (shownMessage && shownMessage.seq === message.seq ? "focused" : "") + + (relatedMarked !== null && message.related === relatedMarked ? " related" : "") + + peerClasses(message.peer), + title: message.way === "refused" ? "refused: the transport wouldn't send it" : "" }, + el("span", { class: "when" }, clockTime(new Date(message.at)) + " "), + WAYS[message.way] + " " + name(message.peer) + " ", + el("span", { style: "color: " + kindColour(message.counted) }, message.type), + " " + size(message.bytes)); + // On press, as the peers' rows: lines come and go under the pointer. + line.addEventListener("pointerdown", event => { + if (event.button !== 0) return; + showMessage(shownMessage && shownMessage.seq === message.seq ? null : message); + }); + // The messages of its exchange marked while pointed at; then the shown one's again. + line.addEventListener("pointerenter", () => pointAt(message)); + line.addEventListener("pointerleave", () => pointAt(null)); + return line; +} +function showMessage(message) { + shownMessage = message; + const log = document.getElementById("console"); + for (const line of log.children) { + line.classList.toggle("focused", message !== null && line.dataset.seq === String(message.seq)); + } + markRelated(message !== null ? message.related : null); + render(); +} +function messagesArrived(messages) { + consoleMessages.push(...messages); + consoleMessages.splice(0, Math.max(0, consoleMessages.length - CONSOLE_LINES)); + if (hidden(document.getElementById("console-box"))) return; + const log = document.getElementById("console"); + const scroll = document.getElementById("console-scroll"); + const following = scroll.scrollTop + scroll.clientHeight >= scroll.scrollHeight - 4; + log.append(...messages.slice(-CONSOLE_LINES).map(consoleLine)); + while (log.childElementCount > CONSOLE_LINES) log.firstElementChild.remove(); + if (following) scroll.scrollTop = scroll.scrollHeight; +} +function showConsole() { + document.getElementById("console").replaceChildren(...consoleMessages.map(consoleLine)); + const scroll = document.getElementById("console-scroll"); + scroll.scrollTop = scroll.scrollHeight; +} +function drawFlows() { + flowFrame = null; + const layer = document.getElementById("flows"); + const now = performance.now(); + for (let i = flows.length - 1; i >= 0; i--) { + if (now > flows[i].start + FLOW_MS) flows.splice(i, 1); + } + const dots = []; + for (const flow of flows) { + const p = positions.get(flow.from), q = positions.get(flow.to); + if (now < flow.start || !p || !q) continue; + const t = (now - flow.start) / FLOW_MS; + dots.push({ x: p.x + (q.x - p.x) * t, y: p.y + (q.y - p.y) * t, r: flow.r, kind: flow.kind, + opacity: Math.sin(Math.PI * t) }); + } + while (layer.children.length < dots.length) layer.append(svg("circle")); + while (layer.children.length > dots.length) layer.lastChild.remove(); + dots.forEach((dot, i) => { + const circle = layer.children[i]; + circle.setAttribute("cx", dot.x); + circle.setAttribute("cy", dot.y); + circle.setAttribute("r", (dot.r * dotScale).toFixed(1)); + circle.setAttribute("fill", kindColour(dot.kind)); + circle.setAttribute("opacity", dot.opacity.toFixed(2)); + }); + if (flows.length > 0) flowFrame = requestAnimationFrame(drawFlows); +} + +// --- Events --- +const connection = document.getElementById("connection"); +const dot = document.getElementById("dot"); +// One breath per event from the node. +function pulse() { + dot.classList.remove("pulse"); + void dot.offsetWidth; // restarts the animation + dot.classList.add("pulse"); +} +function status(text, live) { + document.getElementById("connection-text").textContent = text; + connection.className = live ? "live" : "down"; + document.body.classList.toggle("stale", !live && state !== null); +} +// While not live, what is shown stays as it was last (ages don't count on). +let live = false; +let events = null; +// The next attempt, while one is waited for: a connect now replaces it. +let retry = null; +function connect() { + clearTimeout(retry); + retry = null; + events = new EventSource("dashboard/events"); + events.addEventListener("snapshot", event => { + const snapshot = JSON.parse(event.data); + if (snapshot.page !== PAGE_VERSION) { + events.close(); + status("new version; reloading…", false); + location.reload(); + return; + } + // Changes while not connected went unseen: what is open is read again. + if (state !== null) documentChanged({ all: true }); + state = snapshot; stateAt = Date.now(); live = true; + status("live", true); pulse(); render(); trafficArrived(); + }); + events.addEventListener("patch", event => { + if (!state) return; + state = mergePatch(state, JSON.parse(event.data)); stateAt = Date.now(); pulse(); render(); + trafficArrived(); + }); + events.addEventListener("document", event => { pulse(); documentChanged(JSON.parse(event.data)); }); + events.addEventListener("messages", event => messagesArrived(JSON.parse(event.data))); + // The node stops: it says so, and ends the stream. Back once it is. + events.addEventListener("offline", () => { + events.close(); live = false; + status("node stopped; reconnecting…", false); + retry = setTimeout(connect, 3000); + }); + events.addEventListener("error", () => { + // Each attempt that failed: a breath too, so it shows it keeps trying. + pulse(); + live = false; + if (events.readyState === EventSource.CLOSED) { + // Given up (an HTTP error, e.g. the node is up but not serving the page yet). + status("disconnected; retrying…", false); + retry = setTimeout(connect, 5000); + } else { + // EventSource reconnects by itself; the node sends a snapshot first again. + status("reconnecting…", false); + } + }); +} +connect(); +// A page not shown (another tab, a minimised window) is paused: hidden for a while, it +// closes its stream, so the node stops building updates and asking members for their +// links for it; shown again, it reconnects. Briefly hidden, nothing changes. +const PAUSE_AFTER = 30000; +let paused = false; +let pausing = null; +document.addEventListener("visibilitychange", () => { + if (document.hidden) { + pausing = setTimeout(() => { + pausing = null; + paused = true; + clearTimeout(retry); + retry = null; + if (events) events.close(); + live = false; + status("paused while hidden", false); + }, PAUSE_AFTER); + } else { + clearTimeout(pausing); + pausing = null; + if (paused) { + paused = false; + status("reconnecting…", false); + connect(); + } + } +}); +// 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. +connection.title = "Click to refresh now"; +connection.style.cursor = "pointer"; +connection.addEventListener("click", () => { + pulse(); + if (live) { + fetch("dashboard/refresh", { method: "POST" }).catch(() => {}); + documentChanged({ all: true }); + } else { + if (events) events.close(); + status("reconnecting…", false); + connect(); + } +}); +// Ages count on between events; nothing else changes without one. +setInterval(() => { if (live) render(true); }, 1000); +window.addEventListener("resize", () => { layoutAgain(); render(); }); + +// --- Sizes: the space between boxes dragged to resize them, double-clicked to reset; +// kept in this browser --- +const main = document.querySelector("main"); +// The map's share of the width (1: the right column dragged off to the side, hidden); +// the peers' height in px; the console's share of what the peers leave, and the +// figures' share of the rest. Null: as laid out by default. +let sizes = { columns: null, peers: null, split: null, console: null }; +try { sizes = { ...sizes, ...JSON.parse(localStorage.getItem("continuum-sizes") || "{}") }; } +catch (_) {} +function applySizes() { + const style = main.style; + const shut = id => document.getElementById(id).classList.contains("collapsed"); + const right = ["peers-box", "figures-box", "document-box", "console-box"]; + const wasHidden = main.classList.contains("right-hidden"); + main.classList.toggle("right-hidden", sizes.columns === 1); + main.classList.toggle("right-shut", sizes.columns !== 1 && right.every(shut)); + // What's collapsed gives its room to what isn't: the map collapsed, the right column + // takes the width but the map's title. The right column all collapsed keeps its + // width, resized as ever (dragged to the side, it goes). + const mapShut = shut("map"), rightShut = right.every(shut); + if (sizes.columns === 1) style.setProperty("--columns", "minmax(0, 1fr) 0"); + else if (mapShut && !rightShut) style.setProperty("--columns", "max-content minmax(0, 1fr)"); + else if (sizes.columns === null) style.removeProperty("--columns"); + else style.setProperty("--columns", "minmax(0, " + sizes.columns + "fr) minmax(0, " + + (1 - sizes.columns) + "fr)"); + // Back: what waited is drawn. + if (wasHidden && sizes.columns !== 1) { + if (!hidden(document.getElementById("console-box"))) showConsole(); + release(); + } + // A collapsed box's row as tall as its title; the open ones share the rest, as they + // did, and the peers take it all when they're the only one open. Their fractions + // add up to 1: fewer would leave room unused (CSS grid gives fractions adding up to + // less than 1 only that part of the room). + const split = sizes.split === null ? .5 : sizes.split; + const consoleShare = sizes.console === null ? .2 : sizes.console; + const rest = 1 - consoleShare; + const fractions = { "figures-box": split * rest, "document-box": (1 - split) * rest, + "console-box": consoleShare }; + const openShare = Object.keys(fractions).filter(id => !shut(id)) + .reduce((sum, id) => sum + fractions[id], 0); + const share = id => shut(id) ? "auto" : "minmax(0, " + fractions[id] / openShare + "fr)"; + const peersOnly = !shut("peers-box") && right.slice(1).every(shut); + const peers = shut("peers-box") ? "auto" : peersOnly ? "minmax(0, 1fr)" + : sizes.peers === null ? "auto" : sizes.peers + "px"; + style.setProperty("--rows", peers + " " + share("figures-box") + " " + + share("document-box") + " " + share("console-box")); + main.classList.toggle("peers-sized", sizes.peers !== null || peersOnly); + try { localStorage.setItem("continuum-sizes", JSON.stringify(sizes)); } catch (_) {} +} +const box = id => document.getElementById(id).getBoundingClientRect(); +// Which space between boxes `event` is over, if any: "columns", "peers" (below the +// peers), "split" (between figures and document) or "console" (above the console). +// Only side by side; with the right column hidden, right of the map: "columns". +function gapAt(event) { + if (event.target !== main) return null; + if (sizes.columns === 1 && main.classList.contains("right-hidden")) { + return event.clientX >= box("map").right ? "columns" : null; + } + const map = box("map"), peers = box("peers-box"), figures = box("figures-box"), + documentBox = box("document-box"), consoleBox = box("console-box"); + if (map.right >= peers.left) return null; + if (event.clientX >= map.right && event.clientX <= peers.left) return "columns"; + if (event.clientX < peers.left || event.clientX > peers.right) return null; + if (event.clientY >= peers.bottom && event.clientY <= figures.top) return "peers"; + if (event.clientY >= figures.bottom && event.clientY <= documentBox.top) return "split"; + if (event.clientY >= documentBox.bottom && event.clientY <= consoleBox.top) return "console"; + return null; +} +function resizeTo(gap, event) { + const clamp = (value, low, high) => Math.min(high, Math.max(low, value)); + // The space between boxes (`main`'s gap), half of it each side. + const half = parseFloat(getComputedStyle(main).columnGap) / 2; + if (gap === "columns") { + // Across the whole width: the right column's right edge is `main`'s, inside. + const map = box("map"); + const right = main.getBoundingClientRect().right - + parseFloat(getComputedStyle(main).paddingRight); + const share = (event.clientX - half - map.left) / (right - map.left - 2 * half); + // Dragged to the side: hidden. + sizes.columns = share > .9 ? 1 : clamp(share, .2, .8); + } else if (gap === "peers") { + const peers = box("peers-box"), consoleBox = box("console-box"); + sizes.peers = Math.round(clamp(event.clientY - half - peers.top, 60, + consoleBox.bottom - peers.top - 6 * half - 200)); + } else if (gap === "console") { + const figures = box("figures-box"), consoleBox = box("console-box"); + sizes.console = clamp((consoleBox.bottom - event.clientY - half) / + (consoleBox.bottom - figures.top - 4 * half), .05, .8); + } else { + const figures = box("figures-box"), documentBox = box("document-box"); + sizes.split = clamp((event.clientY - half - figures.top) / + (documentBox.bottom - figures.top - 2 * half), .1, .9); + } + applySizes(); +} +let resizing = null; +let resizeFrame = null; +main.addEventListener("pointermove", event => { + if (resizing) { + resizeTo(resizing, event); + // The map's view box follows its size: drawn once a frame. + if (resizeFrame === null) resizeFrame = requestAnimationFrame(() => { + resizeFrame = null; + render(); + }); + return; + } + const gap = gapAt(event); + main.classList.toggle("resize-columns", gap === "columns"); + main.classList.toggle("resize-rows", gap === "peers" || gap === "split" || gap === "console"); +}); +main.addEventListener("pointerdown", event => { + resizing = gapAt(event); + if (!resizing) return; + event.preventDefault(); + main.setPointerCapture(event.pointerId); +}); +const resized = () => { + if (!resizing) return; + resizing = null; + main.classList.remove("resize-columns", "resize-rows"); + layoutAgain(); + render(); +}; +main.addEventListener("pointerup", resized); +main.addEventListener("pointercancel", resized); +// --- Boxes collapsed to their titles, and opened again; kept in this browser. A +// collapsed box isn't drawn (`busy`), so it costs nothing while the node updates. --- +const collapsed = new Set(); +try { + for (const id of JSON.parse(localStorage.getItem("continuum-collapsed") || "[]")) { + collapsed.add(id); + } +} catch (_) {} +for (const section of document.querySelectorAll("main > section")) { + const button = el("button", { class: "collapse" }); + section.querySelector("h2").prepend(button); + const show = () => { + const shut = collapsed.has(section.id); + section.classList.toggle("collapsed", shut); + button.textContent = shut ? "▸" : "▾"; + button.title = shut ? "Open" : "Collapse"; + }; + show(); + button.addEventListener("click", () => { + if (collapsed.has(section.id)) collapsed.delete(section.id); else collapsed.add(section.id); + try { localStorage.setItem("continuum-collapsed", JSON.stringify([...collapsed])); } + catch (_) {} + show(); + applySizes(); + if (collapsed.has(section.id)) return; + if (section.id === "console-box") showConsole(); + if (section.id === "map") layoutAgain(); + release(); + render(); + }); +} +applySizes(); +main.addEventListener("dblclick", event => { + const gap = gapAt(event); + if (!gap) return; + sizes[gap] = null; + applySizes(); + layoutAgain(); + render(); +}); +</script> +</body> +</html> diff --git a/src/api/dashboard/mod.rs b/src/api/dashboard/mod.rs new file mode 100644 index 0000000..2557906 --- /dev/null +++ b/src/api/dashboard/mod.rs @@ -0,0 +1,667 @@ +//! The dashboard (`--features dashboard`): a page showing the node's figures +//! (`/admin/stats`), its peers (`/peers`) and a map of the swarm, kept up to date +//! through Server-Sent Events. +//! +//! One producer per node serves every client: while at least one is connected (or +//! asked for the state within `LINGER`), it builds the snapshot every `TICK` and, if +//! something changed, publishes it, and a JSON merge patch (RFC 7386) from the one +//! before to every client connected. Nothing changed, nothing is sent (but SSE's +//! keep-alive comments). Ages (`…SecsAgo`, `uptimeSecs` and the like, `GROWING`) grow by +//! 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. With +//! the clocks the links carry, every member's clock against the swarm's (`clocks`). + +mod clocks; + +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; + +use axum::extract::Query; +use axum::http::{HeaderValue, StatusCode, header::CONTENT_TYPE}; +use axum::response::sse::{Event, KeepAlive, Sse}; +use axum::response::{Html, IntoResponse, Response}; +use axum::routing::{get, post}; +use axum::{Extension, Router}; +use serde_json::{Map, Value as JsonValue, json}; +use tokio::sync::{Notify, broadcast, watch}; +use tokio::time::Instant; + +use crate::locks::MutexExt; +use crate::state::AppState; +use crate::swarm::{links, members, topology}; +use crate::transport::{Hex, 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); +/// 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. +struct Published { + seq: u64, + json: String, +} + +/// The producer and what it published, shared by all clients. +struct Hub { + state: AppState, + /// The latest snapshot; None while the producer hasn't made one (since it started). + latest: watch::Sender<Option<Arc<Published>>>, + /// Each patch, to the clients connected. + patches: broadcast::Sender<Arc<Published>>, + /// Where the document changed since the last tick, to the clients connected: + /// `{"paths": [...]}`, or `{"all": true}` if too many or not known. + documents: broadcast::Sender<Arc<String>>, + /// The messages exchanged since the last tick, one by one (`traffic::MessageLog`), + /// 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. + 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>, +} + +struct Control { + running: bool, + /// The producer stops once no client is connected and this is past. + wanted_until: Instant, +} + +impl Hub { + fn new(state: AppState, closing: watch::Receiver<bool>) -> Arc<Self> { + let (latest, _) = watch::channel(None); + let (patches, _) = broadcast::channel(64); + let (documents, _) = broadcast::channel(64); + let (messages, _) = broadcast::channel(64); + Arc::new(Self { + state, + latest, + patches, + documents, + messages, + poke: Notify::new(), + live: AtomicBool::new(true), + control: Mutex::new(Control { + running: false, + wanted_until: Instant::now(), + }), + closing, + }) + } + + /// Someone looks: the producer runs (started if it wasn't), for `LINGER` at least. + fn want(self: &Arc<Self>) { + let mut control = self.control.lock_or_recover(); + control.wanted_until = Instant::now() + LINGER; + if !control.running && !*self.closing.borrow() { + control.running = true; + // What it published before it stopped is stale. + self.latest.send_replace(None); + tokio::spawn(Arc::clone(self).run()); + } + } + + /// The latest snapshot, once there is one; None if the node stops first. + async fn current(&self) -> Option<Arc<Published>> { + let mut latest = self.latest.subscribe(); + let mut closing = self.closing.clone(); + // The borrows (lock guards, not Send) end within the arms. + tokio::select! { + published = latest.wait_for(Option::is_some) => { + published.ok().and_then(|current| current.clone()) + } + _ = closing.wait_for(|closing| *closing) => None, + } + } + + /// Resolves once the node stops (or its signal is gone). + async fn closed(&self) { + let mut closing = self.closing.clone(); + let _ = closing.wait_for(|closing| *closing).await; + } + + async fn run(self: Arc<Self>) { + let mut last: Option<JsonValue> = None; + let mut seq = 0u64; + let mut graph = Graph::default(); + let mut graph_due = Instant::now(); + let mut changes = self.state.change_events.subscribe(); + // The messages one by one, from now on, while it runs. + let log = &self.state.traffic.log; + 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; + } + let snapshot = build(&self.state, &graph, live).await; + let changed = last + .as_ref() + .is_none_or(|old| without_ages(old) != without_ages(&snapshot)); + if changed { + seq += 1; + if let Some(old) = &last + && let Some(patch) = merge_patch(old, &snapshot) + { + let _ = self.patches.send(Arc::new(Published { + seq, + json: patch.to_string(), + })); + } + self.latest.send_replace(Some(Arc::new(Published { + seq, + json: snapshot.to_string(), + }))); + last = Some(snapshot); + } + let poked = tokio::select! { + () = tokio::time::sleep(TICK) => false, + () = self.poke.notified() => true, + }; + if poked { + graph_due = Instant::now(); + } + if let Some(changed) = document_changes(&mut changes) { + let _ = self.documents.send(Arc::new(changed.to_string())); + } + let mut messages = log.after(logged, MESSAGES_AT_MOST); + by_node(&self.state, &mut messages).await; + if let Some(last) = messages.last() { + logged = last.seq; + if let Ok(json) = serde_json::to_string(&messages) { + let _ = self.messages.send(Arc::new(json)); + } + } + let unwanted = || { + self.patches.receiver_count() == 0 + && Instant::now() >= self.control.lock_or_recover().wanted_until + }; + if *self.closing.borrow() { + log.watch(false); + self.control.lock_or_recover().running = false; + 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; + } + let mut control = self.control.lock_or_recover(); + if self.patches.receiver_count() == 0 && Instant::now() >= control.wanted_until { + log.watch(false); + control.running = false; + return; + } + drop(control); + graph_due = Instant::now(); + } + } + } +} + +/// Paths changed in a tick listed at most; more: `all`. +/// The messages the paths layer logged under an address (its own PINGs and PONGs) under +/// the node there instead, where one is known: the address it proved itself at, or is +/// said to be at (`Transport::identities_of`), named in `address`. +async fn by_node(state: &AppState, messages: &mut [crate::transport::traffic::Logged]) { + let peers = state.peers_snapshot().await; + let known = |peer: &str| peers.iter().any(|(known, _)| known == peer); + if messages.iter().all(|message| known(&message.peer)) { + return; + } + let mut nodes: std::collections::HashMap<String, String> = std::collections::HashMap::new(); + for (peer, _) in &peers { + for address in state.transport.identities_of(peer) { + nodes.entry(address).or_insert_with(|| peer.clone()); + } + } + for message in messages { + if let Some(node) = nodes.get(&message.peer) { + message.address = Some(std::mem::replace(&mut message.peer, node.clone())); + } + } +} + +const MAX_CHANGED: usize = 256; +/// Messages sent to the clients per tick at most, the latest (the console keeps fewer). +const MESSAGES_AT_MOST: usize = 500; + +/// Where the document changed since last asked (`Hub::documents`), if anywhere. +fn document_changes( + changes: &mut broadcast::Receiver<Arc<crate::document::store::ChangeSet>>, +) -> Option<JsonValue> { + let mut paths = BTreeSet::new(); + let mut all = false; + loop { + match changes.try_recv() { + Ok(set) => { + paths.extend(set.paths.iter().cloned()); + all |= paths.len() > MAX_CHANGED; + } + Err(broadcast::error::TryRecvError::Lagged(_)) => all = true, + Err(_) => break, + } + } + if all { + Some(json!({ "all": true })) + } else if paths.is_empty() { + None + } else { + Some(json!({ "paths": paths })) + } +} + +/// What a client's stream waits for. +enum Next { + Patch(Result<Arc<Published>, broadcast::error::RecvError>), + Document(Result<Arc<String>, broadcast::error::RecvError>), + Messages(Result<Arc<String>, broadcast::error::RecvError>), + Closing, +} + +/// The intended graph, recomputed every `LINKS_EVERY` (it costs about `members²`), when +/// members are asked for their links too: subscribed to, if `live`. +#[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 }; + self.intended = topology::intended_graph(state).unwrap_or_default(); + let members: Vec<String> = { + let replica = state.doc.read().await; + members::members(&replica.doc) + }; + for member in members + .into_iter() + .filter(|member| member != state.local_id()) + .take(MAX_ASKED) + { + let state = state.clone(); + tokio::spawn(async move { links::ask(&state, &member, 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 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(); + let peers = serde_json::to_value(&list).unwrap_or_default(); + let aliases = { + let replica = state.doc.read().await; + members::aliases(&replica.doc) + }; + let local = state.local_id(); + let intended: Map<String, JsonValue> = graph + .intended + .iter() + .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(); + let mut measured = Map::new(); + measured.insert( + local.to_owned(), + json!({ "secsAgo": 0, "links": links_json(&own) }), + ); + for (member, age, member_links) in &replies { + measured.insert( + member.clone(), + json!({ "secsAgo": age.as_secs(), "links": links_json(member_links) }), + ); + } + 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, or measured. + let mut members_json = Map::new(); + for member in graph.intended.keys().chain(measured.keys()) { + members_json.insert( + member.clone(), + json!({ "alias": aliases.get(member), "self": member == local }), + ); + } + json!({ + "node": { "id": local, "alias": state.alias }, + "page": page().0, + "stats": stats, + "peers": peers, + "graph": { + "members": members_json, + "intended": intended, + "measured": measured, + "clocks": clocks, + "live": live, + }, + }) +} + +fn links_json(links: &[Link]) -> JsonValue { + links + .iter() + .map(|link| { + json!({ + "peer": link.peer, + "transport": link.transport, + "relayed": link.relayed, + "down": link.down, + "rttMs": link.rtt_us.map(|us| f64::from(us) / 1000.0), + "dormant": link.dormant, + "clock": link.clock, + "others": link.others.iter().map(|other| json!({ + "transport": other.transport, + "rttMs": other.rtt_us.map(|us| f64::from(us) / 1000.0), + "relayed": other.relayed, + })).collect::<Vec<_>>(), + }) + }) + .collect() +} + +/// Fields that grow by themselves besides those named `…SecsAgo`; the page gets the +/// list when served (`GROWING_MARK`). +const GROWING: &[&str] = &[ + "uptimeSecs", + "dormantForSecs", + "divergedForSecs", + "observedSecs", + "lastFromNeighbourSecs", + "oldestQuarantineSecs", +]; + +/// Whether the field `key` is an age, which grows by itself. +fn is_age(key: &str) -> bool { + key == "secsAgo" || key.ends_with("SecsAgo") || GROWING.contains(&key) +} + +/// `value` without its ages, to tell whether anything else changed. +fn without_ages(value: &JsonValue) -> JsonValue { + match value { + JsonValue::Object(object) => JsonValue::Object( + object + .iter() + .filter(|(key, _)| !is_age(key)) + .map(|(key, value)| (key.clone(), without_ages(value))) + .collect(), + ), + JsonValue::Array(items) => JsonValue::Array(items.iter().map(without_ages).collect()), + other => other.clone(), + } +} + +/// The JSON merge patch (RFC 7386) from `old` to `new`, if they differ: objects field +/// by field (a field gone is null), anything else replaced whole. A null in `new` reads +/// as the field gone, as a merge patch can't tell them apart. +fn merge_patch(old: &JsonValue, new: &JsonValue) -> Option<JsonValue> { + match (old, new) { + (JsonValue::Object(old), JsonValue::Object(new)) => { + let mut patch = Map::new(); + for key in old.keys() { + if !new.contains_key(key) { + patch.insert(key.clone(), JsonValue::Null); + } + } + for (key, value) in new { + match old.get(key) { + Some(previous) => { + if let Some(change) = merge_patch(previous, value) { + patch.insert(key.clone(), change); + } + } + None => { + patch.insert(key.clone(), value.clone()); + } + } + } + (!patch.is_empty()).then_some(JsonValue::Object(patch)) + } + (old, new) if old == new => None, + (_, new) => Some(new.clone()), + } +} + +/// The dashboard's routes (authenticated, as the rest of `/admin`), with their hub. +pub(crate) fn router(state: AppState) -> Router<AppState> { + // Its streams end when the node shuts down (`AppState::closing`). + let closing = state.closing.subscribe(); + Router::new() + .route("/admin/dashboard", get(page_html)) + .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)) + .layer(Extension(Hub::new(state, closing))) +} + +/// The page as built into this binary. +const PAGE: &str = include_str!("dashboard.html"); +/// Where the page says which version of it it is. +const PAGE_VERSION_MARK: &str = "__PAGE_VERSION__"; +/// Where the page gets the age fields (`GROWING`), so the list is kept once. +const GROWING_MARK: &str = "__GROWING__"; + +/// The page's version (a hash of it), and the page saying so: a page whose version +/// differs from the snapshot's `page` was served by another build, and reloads. +fn page() -> &'static (String, String) { + static PAGE_SERVED: OnceLock<(String, String)> = OnceLock::new(); + PAGE_SERVED.get_or_init(|| { + use sha2::{Digest, Sha256}; + let growing = serde_json::to_string(GROWING).unwrap_or_else(|_| "[]".to_owned()); + let page = PAGE.replace(GROWING_MARK, &growing); + // Of the page with its list: a new list is a new version too. + let version = Hex(&Sha256::digest(page.as_bytes())[..8]).to_string(); + let served = page.replace(PAGE_VERSION_MARK, &version); + (version, served) + }) +} + +/// `GET /admin/dashboard`: the page. +async fn page_html() -> Html<&'static str> { + Html(page().1.as_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. +async fn refresh(Extension(hub): Extension<Arc<Hub>>) -> StatusCode { + hub.want(); + hub.poke.notify_one(); + StatusCode::NO_CONTENT +} + +#[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 +} + +/// `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(); + let Some(current) = hub.current().await else { + return StatusCode::SERVICE_UNAVAILABLE.into_response(); + }; + ( + [(CONTENT_TYPE, HeaderValue::from_static("application/json"))], + current.json.clone(), + ) + .into_response() +} + +/// `GET /admin/dashboard/events`: Server-Sent Events: a `snapshot` first, then a +/// `patch` whenever something changed (each with its number as the event's ID; a +/// client that fell behind gets a `snapshot` again), a `document` where the document +/// changed (`Hub::documents`, at most every tick), `messages` with the messages since +/// the last tick (`Hub::messages`), and `offline` when the node stops, which ends the +/// stream. +async fn events(Extension(hub): Extension<Arc<Hub>>) -> impl IntoResponse { + // Subscribed before the snapshot is read: no patch after it is missed. + let mut patches = hub.patches.subscribe(); + let mut documents = hub.documents.subscribe(); + let mut messages = hub.messages.subscribe(); + hub.want(); + let first = hub.current().await; + let stream = async_stream::stream! { + let Some(first) = first else { + yield Ok::<_, Infallible>(offline_event()); + return; + }; + let mut seen = first.seq; + yield Ok(snapshot_event(&first)); + loop { + let next = tokio::select! { + received = patches.recv() => Next::Patch(received), + changed = documents.recv() => Next::Document(changed), + logged = messages.recv() => Next::Messages(logged), + () = hub.closed() => Next::Closing, + }; + let received = match next { + Next::Patch(received) => received, + Next::Document(Ok(changed)) => { + yield Ok(Event::default().event("document").data(changed.as_str())); + continue; + } + Next::Document(Err(broadcast::error::RecvError::Lagged(_))) => { + yield Ok(Event::default().event("document").data(r#"{"all":true}"#)); + continue; + } + Next::Document(Err(broadcast::error::RecvError::Closed)) => break, + Next::Messages(Ok(logged)) => { + yield Ok(Event::default().event("messages").data(logged.as_str())); + continue; + } + // Missed ones are only missed in the console. + Next::Messages(Err(broadcast::error::RecvError::Lagged(_))) => continue, + Next::Messages(Err(broadcast::error::RecvError::Closed)) => break, + Next::Closing => { + yield Ok(offline_event()); + break; + } + }; + match received { + Ok(patch) if patch.seq <= seen => {} + Ok(patch) => { + seen = patch.seq; + yield Ok(Event::default() + .event("patch") + .id(patch.seq.to_string()) + .data(patch.json.clone())); + } + Err(broadcast::error::RecvError::Lagged(_)) => { + let Some(current) = hub.current().await else { + yield Ok(offline_event()); + break; + }; + seen = current.seq; + yield Ok(snapshot_event(¤t)); + } + Err(broadcast::error::RecvError::Closed) => break, + } + } + }; + Sse::new(stream).keep_alive(KeepAlive::default()) +} + +/// The node stops: the client may reconnect once it is back. +fn offline_event() -> Event { + Event::default().event("offline").data("{}") +} + +fn snapshot_event(published: &Published) -> Event { + Event::default() + .event("snapshot") + .id(published.seq.to_string()) + .data(published.json.clone()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn merge_patches() { + let old = json!({ "a": 1, "b": { "c": 2, "d": [1, 2] }, "gone": true }); + let new = json!({ "a": 1, "b": { "c": 3, "d": [1, 2] }, "added": "x" }); + assert_eq!( + merge_patch(&old, &new), + Some(json!({ "b": { "c": 3 }, "gone": null, "added": "x" })) + ); + assert_eq!(merge_patch(&old, &old), None); + assert_eq!( + merge_patch(&json!([1]), &json!([1, 2])), + Some(json!([1, 2])) + ); + } + + #[test] + fn ages_alone_are_no_change() { + let old = + json!({ "uptimeSecs": 1, "peers": { "p": { "lastSeenSecsAgo": 3, "rttMs": 2 } } }); + let older = + json!({ "uptimeSecs": 9, "peers": { "p": { "lastSeenSecsAgo": 7, "rttMs": 2 } } }); + assert_eq!(without_ages(&old), without_ages(&older)); + let moved = + json!({ "uptimeSecs": 9, "peers": { "p": { "lastSeenSecsAgo": 7, "rttMs": 5 } } }); + assert_ne!(without_ages(&old), without_ages(&moved)); + } +} |
