aboutsummaryrefslogtreecommitdiffstats
path: root/src/api
diff options
context:
space:
mode:
Diffstat (limited to 'src/api')
-rw-r--r--src/api/auth.rs200
-rw-r--r--src/api/dashboard/dashboard.html497
-rw-r--r--src/api/dashboard/mod.rs42
-rw-r--r--src/api/listen.rs220
-rw-r--r--src/api/mod.rs83
-rw-r--r--src/api/peers.rs4
-rw-r--r--src/api/store.rs27
7 files changed, 991 insertions, 82 deletions
diff --git a/src/api/auth.rs b/src/api/auth.rs
index d58640e..400feed 100644
--- a/src/api/auth.rs
+++ b/src/api/auth.rs
@@ -1,10 +1,12 @@
use anyhow::Context;
use argon2::{Argon2, password_hash::PasswordVerifier};
-use axum::extract::State;
+use axum::extract::{ConnectInfo, State};
+
+use super::listen::Peer;
use axum::{
body::Body,
http::{
- HeaderMap, Request, StatusCode,
+ HeaderMap, HeaderName, Request, StatusCode,
header::{AUTHORIZATION, WWW_AUTHENTICATE},
},
response::{IntoResponse, Response},
@@ -16,6 +18,7 @@ use std::{
collections::HashMap,
fs::File,
io::{BufRead, BufReader},
+ net::IpAddr,
sync::Arc,
time::{Duration, Instant},
};
@@ -49,26 +52,136 @@ fn load_users(path: &str) -> anyhow::Result<HashMap<String, String>> {
Ok(users)
}
+/// Who may use the HTTP API: the users of an htpasswd file (basic authentication),
+/// and the user a reverse proxy in front names (`--user-header`), if it is one of
+/// `--trusted-proxies`.
#[derive(Clone)]
pub(crate) struct AuthState {
idle_timeout: Duration,
- users: Arc<HashMap<String, String>>, // user -> PHC hash string
+ /// None without an htpasswd file: then only the proxy's users.
+ users: Option<Arc<HashMap<String, String>>>, // user -> PHC hash string
+ proxy: Option<Arc<ProxyAuth>>,
cache: Arc<DashMap<[u8; 32], Instant>>,
/// Bounds concurrent Argon2 verifications (CPU and ~19 MiB each).
verify_slots: Arc<Semaphore>,
}
-impl AuthState {
- pub(crate) fn new(path: &str) -> anyhow::Result<Self> {
- let users = load_users(path)?;
+/// A reverse proxy authenticates, and names the user in `header`.
+struct ProxyAuth {
+ header: HeaderName,
+ trusted: Vec<Trusted>,
+}
+
+/// Where a proxy is believed from (`--trusted-proxies`).
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum Trusted {
+ Addresses(Range),
+ /// The API's Unix sockets (`unix`).
+ Unix,
+}
- Ok(Self {
+impl Trusted {
+ fn parse(text: &str) -> anyhow::Result<Self> {
+ if text.trim() == "unix" {
+ return Ok(Self::Unix);
+ }
+ Range::parse(text).map(Self::Addresses)
+ }
+
+ fn holds(&self, peer: Peer) -> bool {
+ match (self, peer) {
+ (Self::Addresses(range), Peer::Tcp(address)) => range.contains(address.ip()),
+ (Self::Unix, Peer::Unix) => true,
+ _ => false,
+ }
+ }
+}
+
+/// An address, or the addresses sharing its first `bits` bits.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+struct Range {
+ address: IpAddr,
+ bits: u32,
+}
+
+impl Range {
+ fn parse(text: &str) -> anyhow::Result<Self> {
+ let (address, bits) = match text.trim().split_once('/') {
+ Some((address, bits)) => (address, Some(bits)),
+ None => (text.trim(), None),
+ };
+ let address: IpAddr = address.parse().with_context(|| {
+ format!("trusted-proxies: {text:?} is neither an IP address nor unix")
+ })?;
+ let all = if address.is_ipv4() { 32 } else { 128 };
+ let bits = match bits {
+ None => all,
+ Some(bits) => bits
+ .parse()
+ .ok()
+ .filter(|bits| *bits <= all)
+ .with_context(|| format!("trusted-proxies: {text:?}: not 0 to {all} bits"))?,
+ };
+ Ok(Self { address, bits })
+ }
+
+ fn contains(&self, address: IpAddr) -> bool {
+ // An IPv4 address as IPv6 (`::ffff:a.b.c.d`) is that IPv4 address.
+ let address = match address {
+ IpAddr::V6(v6) => v6.to_ipv4_mapped().map_or(address, IpAddr::V4),
+ v4 => v4,
+ };
+ let leading =
+ |a: u128, b: u128, width: u32| self.bits == 0 || (a ^ b) >> (width - self.bits) == 0;
+ match (self.address, address) {
+ (IpAddr::V4(range), IpAddr::V4(address)) => {
+ leading(u32::from(range).into(), u32::from(address).into(), 32)
+ }
+ (IpAddr::V6(range), IpAddr::V6(address)) => {
+ leading(u128::from(range), u128::from(address), 128)
+ }
+ _ => false,
+ }
+ }
+}
+
+impl AuthState {
+ /// From the options; None if there is neither an htpasswd file nor a user header
+ /// (no users: no HTTP API, for a relay).
+ pub(crate) fn from_settings(
+ settings: &crate::config::Settings,
+ ) -> anyhow::Result<Option<Self>> {
+ let users = settings
+ .htpasswd
+ .as_deref()
+ .map(load_users)
+ .transpose()?
+ .map(Arc::new);
+ let proxy = settings
+ .user_header
+ .as_deref()
+ .map(|header| {
+ let header = HeaderName::try_from(header.trim())
+ .with_context(|| format!("user-header: {header:?} is no header name"))?;
+ let trusted = settings
+ .trusted_proxies
+ .iter()
+ .map(|trusted| Trusted::parse(trusted))
+ .collect::<anyhow::Result<Vec<_>>>()?;
+ Ok::<_, anyhow::Error>(Arc::new(ProxyAuth { header, trusted }))
+ })
+ .transpose()?;
+ if users.is_none() && proxy.is_none() {
+ return Ok(None);
+ }
+ Ok(Some(Self {
idle_timeout: Duration::from_secs(300),
cache: Arc::new(DashMap::new()),
- users: Arc::new(users),
+ users,
+ proxy,
verify_slots: Arc::new(Semaphore::new(2)),
- })
+ }))
}
}
@@ -87,10 +200,25 @@ fn parse_basic(headers: &HeaderMap) -> Option<(String, String)> {
#[derive(Clone, Debug)]
pub(crate) struct AuthenticatedUser(pub(crate) String);
+/// The user the trusted proxy the request came from names, if it does.
+fn proxied_user(state: &AuthState, headers: &HeaderMap, from: Option<Peer>) -> Option<String> {
+ let proxy = state.proxy.as_ref()?;
+ let user = headers.get(&proxy.header)?.to_str().ok()?.trim();
+ if user.is_empty() {
+ return None;
+ }
+ let trusted = from.is_some_and(|from| proxy.trusted.iter().any(|trusted| trusted.holds(from)));
+ if !trusted {
+ tracing::debug!(from = ?from, header = %proxy.header, "User header from an address not in trusted-proxies; ignored");
+ return None;
+ }
+ Some(user.to_owned())
+}
+
/// The user, if the credentials are valid.
async fn authenticate(state: &AuthState, headers: &HeaderMap) -> Option<String> {
let (user, pass) = parse_basic(headers)?;
- let hash = state.users.get(&user).cloned()?;
+ let hash = state.users.as_ref()?.get(&user).cloned()?;
verify(state, &user, pass, hash).await.then_some(user)
}
@@ -136,22 +264,60 @@ async fn verify(state: &AuthState, user: &str, pass: String, hash: String) -> bo
verified
}
+/// The user of a request: as the trusted proxy it came from names it, else by basic
+/// authentication; `401` if neither.
pub(crate) async fn basic_auth(
State(state): State<AuthState>,
headers: HeaderMap,
mut request: Request<Body>,
next: axum::middleware::Next,
) -> Response {
- let Some(user) = authenticate(&state, &headers).await else {
+ let from = request
+ .extensions()
+ .get::<ConnectInfo<Peer>>()
+ .map(|ConnectInfo(peer)| *peer);
+ let user = match proxied_user(&state, &headers, from) {
+ Some(user) => Some(user),
+ None => authenticate(&state, &headers).await,
+ };
+ let Some(user) = user else {
sleep(Duration::from_millis(1500)).await;
- return (
- StatusCode::UNAUTHORIZED,
- [(WWW_AUTHENTICATE, r#"Basic realm="Restricted""#)],
- "Unauthorized",
- )
- .into_response();
+ // A challenge only where basic authentication can answer it.
+ return match state.users {
+ Some(_) => (
+ StatusCode::UNAUTHORIZED,
+ [(WWW_AUTHENTICATE, r#"Basic realm="Restricted""#)],
+ "Unauthorized",
+ )
+ .into_response(),
+ None => (StatusCode::UNAUTHORIZED, "Unauthorized").into_response(),
+ };
};
request.extensions_mut().insert(AuthenticatedUser(user));
next.run(request).await
}
+
+#[cfg(test)]
+mod tests {
+ use super::Range;
+
+ #[test]
+ fn ranges_hold_their_addresses() {
+ let range = |text: &str| Range::parse(text).unwrap();
+ let ip = |text: &str| text.parse().unwrap();
+ assert!(range("127.0.0.1").contains(ip("127.0.0.1")));
+ assert!(!range("127.0.0.1").contains(ip("127.0.0.2")));
+ assert!(range("10.0.0.0/8").contains(ip("10.1.2.3")));
+ assert!(!range("10.0.0.0/8").contains(ip("11.0.0.1")));
+ assert!(range("::1").contains(ip("::1")));
+ assert!(range("fd00::/8").contains(ip("fd12::1")));
+ assert!(range("0.0.0.0/0").contains(ip("192.0.2.1")));
+ // IPv4 as IPv6, as a dual-stack listener sees it.
+ assert!(range("127.0.0.1").contains(ip("::ffff:127.0.0.1")));
+ assert!(!range("127.0.0.1").contains(ip("::1")));
+ for bad in ["localhost", "10.0.0.0/33", "::/129", "10.0.0.0/x"] {
+ assert!(Range::parse(bad).is_err(), "{bad}");
+ }
+ }
+}
diff --git a/src/api/dashboard/dashboard.html b/src/api/dashboard/dashboard.html
index ba2b893..25837c8 100644
--- a/src/api/dashboard/dashboard.html
+++ b/src/api/dashboard/dashboard.html
@@ -8,6 +8,12 @@
: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 {
@@ -68,6 +74,28 @@
section.collapsed { align-self: start; }
section.collapsed h2 { margin: 0; }
section.collapsed h2 .toggle { display: none; }
+ /* A choice beside a box's title (the console's filter). */
+ section h2 label.choice { font-size: 12px; text-transform: none; letter-spacing: 0;
+ margin-left: 1em; cursor: pointer; }
+ section h2 label.choice input { vertical-align: -2px; margin: 0 3px 0 0; }
+ section.collapsed h2 label.choice { display: none; }
+ /* The console's rates, at the right of its title: messages and bytes per second, a
+ row each, small enough for both to fit the title's line, centred on it. Columns as
+ wide as their widest content will be, so the numbers stay put as they change. */
+ section h2 .rates { float: right; display: grid;
+ grid-template-columns: repeat(3, minmax(5ch, auto)) minmax(5.5ch, auto);
+ column-gap: 5px; height: 18px; align-content: center;
+ font-size: 9px; line-height: 9px; text-transform: none; letter-spacing: 0;
+ margin-left: 1em; font-variant-numeric: tabular-nums; }
+ section h2 .rates span { text-align: right; }
+ section h2 .rates span.unit { text-align: left; }
+ section.collapsed h2 .rates { display: none; }
+ /* After the console's filter: that it follows new messages (scrolled to the bottom). */
+ section h2 .following { font-size: 12px; text-transform: none; letter-spacing: 0;
+ margin-left: 1em; font-style: italic; }
+ section.collapsed h2 .following { display: none; }
+ /* A button within a title's line, where it comes (not at its end, as toggles are). */
+ section h2 button.inline { float: none; margin-left: 1em; line-height: 1em; }
/* 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; }
@@ -86,6 +114,9 @@
#map { grid-area: map; }
#peers-box { grid-area: peers; }
#figures-box { grid-area: figures; }
+ /* Its title on one line: what doesn't fit ends in an ellipsis (the whole of it its
+ tooltip). */
+ #figures-box h2 { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
#document-box { grid-area: document; }
#console-box { grid-area: console; }
#console-box .scroll { flex: 1; }
@@ -95,9 +126,17 @@
.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); }
+ /* Filtered (`filter selected`): only the messages with what is in focus. */
+ .console.filtering > div:not(.peer-focused) { display: none; }
.console > div.related { background: rgba(127, 127, 127, .08); border-left-color: var(--self); }
- .console > div:hover { background: var(--hover, rgba(127, 127, 127, .12)); }
- .console > div.focused { background: rgba(127, 127, 127, .22); }
+ .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; }
@@ -126,14 +165,23 @@
/* 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. */
+ /* The dots' size and speed, right after their legend, no taller than its text. */
+ .legend .slider { display: inline-flex; align-items: center; gap: 4px; }
+ .legend .slider::before { display: none; }
+ .legend .slider input { width: 6em; height: 1em; margin: 0; }
+ /* After the sliders, as tall; the kinds and the sliders 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; }
+ .legend.off .message, .legend.off .slider { opacity: .45; }
+ /* The map and, over its top, the legend: shown or hidden, it takes no room from the
+ map, which stays as it is. What is under it shows through; its contents are on an
+ underlay of their own shape (as the map's labels), and only its controls take the
+ pointer: the map under the rest is dragged as anywhere else. */
+ .map-area { position: relative; flex: 1; min-height: 0; display: flex; }
+ #legends { position: absolute; top: 0; left: 0; right: 0; pointer-events: none; }
+ #legends .legend > * { filter: drop-shadow(2px 0 0 var(--panel))
+ drop-shadow(-2px 0 0 var(--panel)) drop-shadow(0 2px 0 var(--panel))
+ drop-shadow(0 -2px 0 var(--panel)); }
+ #legends button, #legends input, #legends label { pointer-events: auto; }
/* 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;
@@ -205,6 +253,7 @@
<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 class="map-area">
<div id="legends">
<div class="legend">
<span class="node self">this node</span>
@@ -219,10 +268,12 @@
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>
+ drag a node to move it, middle-click it to pin it there or let it go,
+ double-click it for its dashboard</span>
</div>
</div>
- <svg id="graph"><g id="view"><g id="flows" pointer-events="none"></g></g></svg>
+ <svg id="graph"><g id="view"><g id="flows" pointer-events="none"></g><g id="flashes" pointer-events="none"></g></g></svg>
+ </div>
</section>
<section id="peers-box">
<h2>Peers</h2>
@@ -239,8 +290,9 @@
<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>
+ <h2>Console <label class="choice" title="Only the messages with the node or over the link in focus (clicked on the map or in the peers); all while nothing is"><input type="checkbox" id="console-filter">filter selected</label><span id="console-following" class="following" title="Scrolled to the bottom: new messages are scrolled to as they come; scroll up to stop">following…</span><button id="console-clear" class="toggle inline" title="Empty this page's console: what comes from now on fills it again">clear</button><span id="console-rates" class="rates" title="Messages and bytes per second sent and received, as load averages are: over the last 1, 5 and 15 minutes (exponentially weighted), since the page was opened; with the filter, those with the node or over the link in focus"></span></h2>
+ <div class="scroll" id="console-scroll"><div id="console" class="console"></div>
+ <div id="console-none" class="note" hidden>No information available</div></div>
</section>
</main>
<script>
@@ -252,6 +304,31 @@ const PAGE_VERSION = "__PAGE_VERSION__";
let state = null;
let stateAt = 0;
+// --- Hand-off: a double click on a node opens its dashboard, on another host, as this
+// page was left (`handOff`): the map where it was, the view, the pins, the focus and the
+// settings this browser keeps for the page (another host's are its own). It comes in
+// the URL's fragment, which no request carries: taken here, before anything reads the
+// settings, and gone from the URL at once. ---
+const HAND_OFF = "#continuum=";
+const handOver = (() => {
+ if (!location.hash.startsWith(HAND_OFF)) return null;
+ try {
+ const handed = JSON.parse(decodeURIComponent(location.hash.slice(HAND_OFF.length)));
+ for (const [key, value] of Object.entries(handed.settings || {})) {
+ if (key.startsWith("continuum-") && typeof value === "string") {
+ try { localStorage.setItem(key, value); } catch (_) {}
+ }
+ }
+ return handed;
+ } catch (_) {
+ return null;
+ } finally {
+ try {
+ history.replaceState(null, "", location.pathname + location.search);
+ } catch (_) { /* not where history works */ }
+ }
+})();
+
// 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__);
@@ -309,7 +386,7 @@ function age(secs) {
// 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 = (() => {
+let focused = handOver ? handOver.focused || null : (() => {
try { return decodeURIComponent(location.hash.slice(1)) || null; } catch { return null; }
})();
const pairKey = (a, b) => a < b ? a + " " + b : b + " " + a;
@@ -327,6 +404,7 @@ function focus(id) {
: "#" + encodeURIComponent(focused));
} catch { /* not where history works */ }
render();
+ markPeerMessages();
}
// --- Peers ---
@@ -370,6 +448,11 @@ function renderPeers() {
// --- Figures: a tree, its open branches kept across renders ---
const open = new Set(["stats", "peer", "links", "from", "to", "paths", "paths.destination"]);
+// As the page it was opened from had them (`handOver`).
+if (handOver && Array.isArray(handOver.figures)) {
+ open.clear();
+ for (const path of handOver.figures) if (typeof path === "string") open.add(path);
+}
function tree(key, value, path) {
// Empty: nothing to open.
if (value !== null && typeof value === "object" && Object.keys(value).length === 0) {
@@ -417,6 +500,7 @@ function renderStats() {
const field = (key, value) => el("div", { class: "leaf" },
el("span", { class: "key" }, key + ": "), value);
title.replaceChildren("Message: ", what);
+ title.title = title.textContent;
document.getElementById("stats").replaceChildren(
field("at", clockTime(new Date(message.at))),
field(message.way === "received" ? "from" : "to", message.peer),
@@ -455,6 +539,7 @@ function renderStats() {
destination: peer.destination, addresses: peer.addresses }, "paths"));
}
title.replaceChildren("Link: ", what);
+ title.title = title.textContent;
document.getElementById("stats").replaceChildren(...shown);
return;
}
@@ -474,6 +559,7 @@ function renderStats() {
if (report) shown.push(tree("links", report, "links"));
}
title.replaceChildren(focused === null ? "Figures: " : "Peer: ", what);
+ title.title = title.textContent;
document.getElementById("stats").replaceChildren(...shown);
}
@@ -666,6 +752,7 @@ function hover(id) {
if (hovered === id) return;
hovered = id;
if (state) renderGraph();
+ markPeerMessages();
}
function hoverable(element, id) {
element.addEventListener("mouseenter", () => hover(id));
@@ -800,11 +887,35 @@ function renderGraph() {
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)", "stroke-opacity": key === focused ? .35 : .15,
+ 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.
@@ -825,6 +936,7 @@ function renderGraph() {
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");
@@ -874,6 +986,7 @@ function renderGraph() {
} 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)" : "") +
@@ -916,7 +1029,8 @@ function renderGraph() {
// 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)", "fill-opacity": shown ? .35 : .15, stroke: "none" }));
+ 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); });
@@ -929,7 +1043,9 @@ function renderGraph() {
}
// Dormant as this node sees it (`/peers`): a red outline.
const dormant = (state.peers.peers[id] || {}).status === "dormant";
- const circle = drawing("node " + id, "circle", { cx: p.x, cy: p.y, r: member.self ? 8 : 6,
+ 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);
@@ -939,7 +1055,7 @@ function renderGraph() {
(dormant ? " (dormant)" : "") +
(clock && clock.off ? " (clock " + seconds(clock.offsetMs) + " off the swarm's, ±" +
clock.withinMs + " ms)" : ""));
- // Pinned (double-clicked): a dot in the middle.
+ // Pinned (middle-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" })] : [];
@@ -948,6 +1064,8 @@ function renderGraph() {
label(nameLabel, name(id));
children.push(circle, ...pin, nameLabel);
}
+ marks.pointed.replaceChildren(...marked.pointed);
+ marks.shown.replaceChildren(...marked.shown);
show(document.getElementById("view"), children);
}
@@ -955,6 +1073,11 @@ function renderGraph() {
// 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([])]);
+// As the page it was opened from had them (`handOver`): the same document, so the same
+// paths, opened as the tree is drawn.
+if (handOver && Array.isArray(handOver.document)) {
+ for (const path of handOver.document) if (typeof path === "string") documentOpen.add(path);
+}
// The open branches read, by path (as JSON): to read again on a change.
const documentBranches = new Map();
function storeUrl(path, shallow) {
@@ -1378,14 +1501,18 @@ function release() {
}
document.addEventListener("selectionchange", release);
-// The legend, hidden and shown again; the map takes the room.
+// The legend, hidden and shown again, over the map: the map stays as it is.
const legendToggle = document.getElementById("legend-toggle");
-legendToggle.addEventListener("click", () => {
+function showLegend(shown) {
const legends = document.getElementById("legends");
- legends.hidden = !legends.hidden;
- legendToggle.textContent = legends.hidden ? "show legend" : "hide legend";
- if (state) renderGraph();
+ legends.hidden = !shown;
+ legendToggle.textContent = shown ? "hide legend" : "show legend";
+}
+legendToggle.addEventListener("click", () => {
+ showLegend(document.getElementById("legends").hidden);
});
+// Hidden as on the page it was opened from (`handOver`).
+if (handOver && handOver.legend === false) showLegend(false);
// 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.
@@ -1429,6 +1556,55 @@ function moveView() {
"translate(" + view.x + " " + view.y + ") rotate(" + degrees(view.turn) + ") scale(" +
view.k + ")");
}
+// The map as the page it was opened from left it (`handOver`): its nodes where they
+// were (new ones are placed among them; on a dynamic map all then move to their places
+// smoothly, `settle`), the view and the pins.
+if (handOver) {
+ for (const [id, at] of Object.entries(handOver.positions || {})) {
+ const [x, y] = Array.isArray(at) ? at : [];
+ if (Number.isFinite(x) && Number.isFinite(y)) positions.set(id, { x, y });
+ }
+ for (const id of handOver.pinned || []) if (positions.has(id)) pinned.add(id);
+ if (positions.size > 0) relayout = false;
+ const handed = handOver.view || {};
+ for (const key of ["x", "y", "k", "turn"]) {
+ if (Number.isFinite(handed[key])) view[key] = handed[key];
+ }
+ moveView();
+}
+// The fragment that opens another node's dashboard (`id`'s) as this page is now
+// (`handOver`). What is in focus stays, but `id` itself: its page is its own.
+function handOff(id) {
+ const settings = {};
+ try {
+ for (let i = 0; i < localStorage.length; i++) {
+ const key = localStorage.key(i);
+ if (key && key.startsWith("continuum-")) settings[key] = localStorage.getItem(key);
+ }
+ } catch (_) {}
+ const round = value => Math.round(value * 10) / 10;
+ return HAND_OFF + encodeURIComponent(JSON.stringify({
+ positions: Object.fromEntries([...positions].map(([node, p]) => [node, [round(p.x), round(p.y)]])),
+ pinned: [...pinned],
+ view: { ...view },
+ focused: focused === id ? null : focused,
+ settings,
+ legend: !document.getElementById("legends").hidden,
+ links: state.graph.live !== false,
+ figures: [...open],
+ document: [...documentOpen],
+ }));
+}
+// Links live or every 30 s as on the page it was opened from (`handOver`), once its
+// first state says otherwise. The node's choice: for every page open on it.
+let linksHandedOver = false;
+function handOverLinks() {
+ if (linksHandedOver || !handOver || typeof handOver.links !== "boolean") return;
+ linksHandedOver = true;
+ if ((state.graph.live !== false) !== handOver.links) {
+ fetch("dashboard/links?live=" + handOver.links, { method: "POST" }).catch(() => {});
+ }
+}
// `[x, y]` turned by `angle`.
function turned(x, y, angle) {
const c = Math.cos(angle), s = Math.sin(angle);
@@ -1543,21 +1719,23 @@ graph.addEventListener("pointerdown", event => {
// 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.
+// it is in focus; a second one soon after opens its dashboard (`openDashboard`). A
+// middle click pins it where it is (`pinned`: the layout leaves it
+// there), or lets it go if pinned (`unpin`); dragged with the middle button, it is
+// pinned where it's dropped. Told apart here: the map holds the pointer while pressed
+// (`pressing`), so the browser's 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;
+// Of the press under way on a node: whether it's the middle button's, and whether it
+// moved the node.
+let middlePress = 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;
+ middlePress = event.button === 1;
+ // Not the browser's scrolling by the middle button.
+ if (middlePress) event.preventDefault();
dragMoved = false;
pressing(event, (dx, dy) => {
dragMoved = true;
@@ -1569,25 +1747,70 @@ function drag(event, id) {
// Dynamic: the others follow it at once.
if (byRtt) settle();
}, () => {
+ if (middlePress) {
+ if (pinned.has(id)) unpin(id); else pin(id);
+ return;
+ }
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);
+ lastNodeClick = { id: null, at: 0 };
+ openDashboard(id);
} else {
lastNodeClick = { id, at: now };
focus(id);
}
});
}
+// A node's dashboard, where it publishes one (`--dashboard-url`, in the member list),
+// opened in this page; else its halo flashes red, larger than usual, and "no dashboard"
+// rises from it in the same red, both fading out. This node's own: here already.
+const FLASH_MS = 1200;
+function openDashboard(id) {
+ const member = state.graph.members[id];
+ if (!member || member.self) return;
+ if (member.dashboard) {
+ location.assign(member.dashboard.split("#")[0] + handOff(id));
+ return;
+ }
+ const layer = document.getElementById("flashes");
+ const halo = svg("circle", { r: 17, fill: "var(--bad)", "fill-opacity": .5 });
+ const words = svg("text", { "text-anchor": "middle", "font-size": 12, "font-weight": 600,
+ fill: "var(--bad)", "fill-opacity": .5 });
+ words.textContent = "no dashboard";
+ layer.append(halo, words);
+ const started = performance.now();
+ const step = now => {
+ const p = positions.get(id);
+ const t = Math.min(1, Math.max(0, (now - started) / FLASH_MS));
+ if (!p || t >= 1) {
+ halo.remove();
+ words.remove();
+ return;
+ }
+ const opacity = String(1 - t);
+ halo.setAttribute("cx", p.x);
+ halo.setAttribute("cy", p.y);
+ halo.setAttribute("opacity", opacity);
+ // Up on the screen, however the map is turned or zoomed: from above the node, slowly.
+ const [x, y] = turned(0, -(22 + (calm.matches ? 0 : 30 * t)), -view.turn);
+ const attrs = upright({ x: p.x + x / view.k, y: p.y + y / view.k });
+ words.setAttribute("x", attrs.x);
+ words.setAttribute("y", attrs.y);
+ if (attrs.transform) words.setAttribute("transform", attrs.transform);
+ else words.removeAttribute("transform");
+ words.setAttribute("opacity", opacity);
+ requestAnimationFrame(step);
+ };
+ requestAnimationFrame(step);
+}
// 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.
+// dragged with the middle button, it is pinned there.
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);
+ if (dragMoved && middlePress) {
+ pin(id);
return;
}
if (byRtt) settle();
@@ -1613,9 +1836,9 @@ graph.addEventListener("mouseleave", () => hover(null));
// 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;
+// record of it. All at one speed (`flowSpeed`), so over a longer link they arrive
+// later. Kept above everything else on the map (`flows`, after what `show` places). ---
+const 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 (_) {}
@@ -1656,7 +1879,12 @@ const kindColour = kind => (KIND_COLOURS.find(([name]) => kind === name ||
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;
+// How fast the dots run, in map units per ms (the slider after the size's, kept in this
+// browser): .3 crosses a link of 200 in about 0.7 s.
+let flowSpeed = .3;
+try { flowSpeed = Number(localStorage.getItem("continuum-dot-speed")) || .3; } catch (_) {}
+flowSpeed = Math.min(.9, Math.max(.1, flowSpeed));
+let dotSlider = null, speedSlider = null;
function showMessagesLegend() {
const legend = document.getElementById("messages-legend");
legend.classList.toggle("off", !flowsShown);
@@ -1668,13 +1896,21 @@ function showMessagesLegend() {
dotScale = Number(slider.value);
try { localStorage.setItem("continuum-dot-scale", String(dotScale)); } catch (_) {}
});
+ const speed = speedSlider = el("input", { type: "range", min: ".1", max: ".9", step: ".1",
+ title: "How fast the dots run" });
+ speed.value = String(flowSpeed);
+ speed.addEventListener("input", () => {
+ flowSpeed = Number(speed.value);
+ try { localStorage.setItem("continuum-dot-speed", String(flowSpeed)); } 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));
+ el("label", { class: "slider" }, "size", slider),
+ el("label", { class: "slider" }, "speed", speed));
}
- dotSlider.disabled = !flowsShown;
+ dotSlider.disabled = speedSlider.disabled = !flowsShown;
}
showMessagesLegend();
// A dot's radius for a message of `bytes`: grows with the size's logarithm, from 1.5
@@ -1734,10 +1970,73 @@ function trafficArrived() {
}
}
}
+ measureRates(current);
lastTraffic = current;
if (flows.length > 0 && flowFrame === null) flowFrame = requestAnimationFrame(drawFlows);
}
+// --- The console's rates: messages and bytes per second sent and received, per peer
+// and in all, averaged as load averages are, over 1, 5 and 15 minutes (each sample
+// weighs in by how long ago it was, `e^(-age/window)`), from the counts each update
+// brings. The first sample is taken as it is, so they needn't climb from 0 first. ---
+const RATE_WINDOWS_S = [60, 300, 900];
+// By peer, and in all (`null`): the averages (`messages`, `bytes`: per window); and when
+// the counts were last taken.
+const rates = new Map();
+let ratesAt = null;
+function measureRates(current) {
+ const now = performance.now();
+ if (lastTraffic !== null && ratesAt !== null && now > ratesAt) {
+ const secs = (now - ratesAt) / 1000;
+ // Of `what` (`messages`, `bytes`), sent and received, by how much each grew.
+ const grown = (counts, before, what) => ["sent", "received"].reduce((sum, way) => {
+ const total = of => Object.values(of[way]).reduce((sum, count) => sum + count[what], 0);
+ return sum + Math.max(0, total(counts) - total(before));
+ }, 0);
+ const average = (known, rate) => known ? known.map((average, i) =>
+ rate + (average - rate) * Math.exp(-secs / RATE_WINDOWS_S[i])) : RATE_WINDOWS_S.map(() => rate);
+ const sample = (key, messages, bytes) => {
+ const known = rates.get(key);
+ rates.set(key, { messages: average(known && known.messages, messages / secs),
+ bytes: average(known && known.bytes, bytes / secs) });
+ };
+ let messagesInAll = 0, bytesInAll = 0;
+ for (const [id, counts] of current) {
+ const before = lastTraffic.get(id);
+ if (!before) continue;
+ const messages = grown(counts, before, "messages"), bytes = grown(counts, before, "bytes");
+ sample(id, messages, bytes);
+ messagesInAll += messages;
+ bytesInAll += bytes;
+ }
+ sample(null, messagesInAll, bytesInAll);
+ // Peers gone from the list: their averages too.
+ for (const key of [...rates.keys()]) if (key !== null && !current.has(key)) rates.delete(key);
+ }
+ ratesAt = now;
+ showRates();
+}
+// The averages shown: in all, or with the filter, of the peer in focus; none (–) for a
+// link between other nodes, a node that is no peer, or before two updates came.
+function showRates() {
+ const filtering = consoleFiltered && focused !== null;
+ const peer = filtering ? peerOf(focused) : null;
+ const shown = filtering && peer === null ? undefined : rates.get(peer);
+ const format = rate => rate < 100 ? rate.toFixed(2) : String(Math.round(rate));
+ // Bytes in the unit the largest of the three needs, all three alike.
+ const largest = shown ? Math.max(...shown.bytes) : 0;
+ const [unit, per] = largest < 1024 ? ["B/s", 1] : largest < 1048576 ? ["KiB/s", 1024]
+ : ["MiB/s", 1048576];
+ const row = (values, unit) => [...(values ? values.map(format) : ["–", "", ""]), unit];
+ const cells = [...row(shown && shown.messages, "msg/s"),
+ ...row(shown && shown.bytes.map(bytes => bytes / per), unit)];
+ const box = document.getElementById("console-rates");
+ if (box.childElementCount !== cells.length) {
+ box.replaceChildren(...cells.map((_, i) => el("span", { class: i % 4 === 3 ? "unit" : "" })));
+ }
+ cells.forEach((cell, i) => text(box.children[i], cell));
+}
+
// --- 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
@@ -1758,6 +2057,14 @@ 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) {
@@ -1765,12 +2072,68 @@ function markRelated(key) {
}
}
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);
+ }
+ filterConsole();
+}
+// `filter selected`: only the messages with the node or over the link in focus (those
+// tinted for it, `peer-focused`), all while nothing is; where there are none (a link
+// between other nodes, a node this one exchanges nothing with), a note. Pointing at
+// one doesn't count. Kept in this browser.
+let consoleFiltered = false;
+try { consoleFiltered = localStorage.getItem("continuum-console-filter") === "1"; } catch (_) {}
+const consoleFilter = document.getElementById("console-filter");
+consoleFilter.checked = consoleFiltered;
+consoleFilter.addEventListener("change", () => {
+ consoleFiltered = consoleFilter.checked;
+ try { localStorage.setItem("continuum-console-filter", consoleFiltered ? "1" : "0"); } catch (_) {}
+ filterConsole();
+});
+// What the console is filtered by (what is in focus), null: nothing. When it changes
+// (filtered or not any more, or another focus), the console goes to the bottom, and
+// follows from there.
+let consoleView = null;
+function filterConsole() {
+ showRates();
+ const log = document.getElementById("console");
+ const filtering = consoleFiltered && focused !== null;
+ log.classList.toggle("filtering", filtering);
+ document.getElementById("console-none").hidden =
+ !filtering || log.querySelector(":scope > div.peer-focused") !== null;
+ const view = filtering ? focused : null;
+ if (view !== consoleView) {
+ consoleView = view;
+ const scroll = document.getElementById("console-scroll");
+ scroll.scrollTop = scroll.scrollHeight;
+ }
+}
// 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" : ""),
+ (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) + " ",
@@ -1782,8 +2145,8 @@ function consoleLine(message) {
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", () => markRelated(message.related));
- line.addEventListener("pointerleave", () => markRelated(shownRelated()));
+ line.addEventListener("pointerenter", () => pointAt(message));
+ line.addEventListener("pointerleave", () => pointAt(null));
return line;
}
function showMessage(message) {
@@ -1804,25 +2167,56 @@ function messagesArrived(messages) {
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();
+ filterConsole();
if (following) scroll.scrollTop = scroll.scrollHeight;
+ showFollowing();
}
+// `following…` while the console is at its bottom: it scrolls to new messages then
+// (`messagesArrived`); scrolled up, it stays where it is.
+const consoleScroll = document.getElementById("console-scroll");
+function showFollowing() {
+ const scroll = consoleScroll;
+ document.getElementById("console-following").hidden =
+ scroll.scrollTop + scroll.clientHeight < scroll.scrollHeight - 4;
+}
+consoleScroll.addEventListener("scroll", showFollowing, { passive: true });
+// `clear`: the console emptied; it fills again with what comes from now on.
+document.getElementById("console-clear").addEventListener("click", () => {
+ consoleMessages.length = 0;
+ pointedMessage = null;
+ if (shownMessage !== null) showMessage(null);
+ markRelated(null);
+ document.getElementById("console").replaceChildren();
+ filterConsole();
+ showFollowing();
+});
function showConsole() {
document.getElementById("console").replaceChildren(...consoleMessages.map(consoleLine));
+ filterConsole();
const scroll = document.getElementById("console-scroll");
scroll.scrollTop = scroll.scrollHeight;
+ showFollowing();
}
function drawFlows() {
flowFrame = null;
const layer = document.getElementById("flows");
const now = performance.now();
+ // How far along its link each is (0 to 1), as long as the link is now: arrived, or
+ // its link gone, it goes.
+ const along = flow => {
+ const p = positions.get(flow.from), q = positions.get(flow.to);
+ if (!p || !q) return null;
+ const length = Math.max(Math.hypot(q.x - p.x, q.y - p.y), 1);
+ return { p, q, t: (now - flow.start) * flowSpeed / length };
+ };
for (let i = flows.length - 1; i >= 0; i--) {
- if (now > flows[i].start + FLOW_MS) flows.splice(i, 1);
+ const at = along(flows[i]);
+ if (at === null || at.t >= 1) 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;
+ if (now < flow.start) continue;
+ const { p, q, t } = along(flow);
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) });
}
@@ -1874,6 +2268,7 @@ function connect() {
if (state !== null) documentChanged({ all: true });
state = snapshot; stateAt = Date.now(); live = true;
status("live", true); pulse(); render(); trafficArrived();
+ handOverLinks();
});
events.addEventListener("patch", event => {
if (!state) return;
diff --git a/src/api/dashboard/mod.rs b/src/api/dashboard/mod.rs
index 2557906..8d954c0 100644
--- a/src/api/dashboard/mod.rs
+++ b/src/api/dashboard/mod.rs
@@ -302,14 +302,13 @@ impl Graph {
let replica = state.doc.read().await;
members::members(&replica.doc)
};
- for member in members
+ let members: Vec<String> = 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 });
- }
+ .collect();
+ // Those we can't reach, through peers that can (`links::ask_all`).
+ links::ask_all(state, members, lease).await;
}
}
@@ -320,9 +319,13 @@ async fn build(state: &AppState, graph: &Graph, live: bool) -> JsonValue {
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 (aliases, dashboards) = {
let replica = state.doc.read().await;
- members::aliases(&replica.doc)
+ let dashboards: std::collections::HashMap<String, String> = members::members(&replica.doc)
+ .into_iter()
+ .filter_map(|pk| members::dashboard(&replica.doc, &pk).map(|url| (pk, url)))
+ .collect();
+ (members::aliases(&replica.doc), dashboards)
};
let local = state.local_id();
let intended: Map<String, JsonValue> = graph
@@ -357,12 +360,25 @@ async fn build(state: &AppState, graph: &Graph, live: bool) -> JsonValue {
),
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.
+ // Everyone on the map: in the member list's graph, measured, or at the other end of
+ // a link measured. A node that joined through others is linked to them before its
+ // registration reaches this node, and before the graph is computed again
+ // (`LINKS_EVERY`); it shows then, hollow (no links of its own reported).
+ let linked: BTreeSet<&String> = own
+ .iter()
+ .chain(
+ replies
+ .iter()
+ .flat_map(|(_, _, member_links)| member_links.iter()),
+ )
+ .map(|link| &link.peer)
+ .collect();
let mut members_json = Map::new();
- for member in graph.intended.keys().chain(measured.keys()) {
+ for member in graph.intended.keys().chain(measured.keys()).chain(linked) {
members_json.insert(
member.clone(),
- json!({ "alias": aliases.get(member), "self": member == local }),
+ json!({ "alias": aliases.get(member), "self": member == local,
+ "dashboard": dashboards.get(member) }),
);
}
json!({
@@ -619,7 +635,11 @@ async fn events(Extension(hub): Extension<Arc<Hub>>) -> impl IntoResponse {
}
}
};
- Sse::new(stream).keep_alive(KeepAlive::default())
+ (
+ // Through nginx too as it comes, not buffered (`operations.md`).
+ [(super::UNBUFFERED.0, super::UNBUFFERED.1)],
+ Sse::new(stream).keep_alive(KeepAlive::default()),
+ )
}
/// The node stops: the client may reconnect once it is back.
diff --git a/src/api/listen.rs b/src/api/listen.rs
new file mode 100644
index 0000000..df2df22
--- /dev/null
+++ b/src/api/listen.rs
@@ -0,0 +1,220 @@
+//! Where the HTTP API listens (`--bind-addr`): TCP addresses, and Unix sockets
+//! (`unix:PATH`), any number of them at once, all serving the same API. A request
+//! carries where it came from (`Peer`), which decides whether a reverse proxy's user
+//! header is believed (`api::auth`, `--trusted-proxies`): on a Unix socket, its file's
+//! permissions decide who may connect at all, which on one host is what an address
+//! can't.
+
+use std::future::Future;
+use std::net::SocketAddr;
+use std::path::PathBuf;
+use std::str::FromStr;
+
+use anyhow::{Context, Result};
+use axum::Router;
+use axum::extract::connect_info::Connected;
+use axum::serve::IncomingStream;
+use serde::{Deserialize, Deserializer, Serialize, Serializer};
+use tracing::{info, warn};
+
+/// One place the API listens.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) enum Bind {
+ Tcp(SocketAddr),
+ /// A Unix socket at this path, made by the node (readable and writable by its user
+ /// and group only), and removed when it stops.
+ Unix(PathBuf),
+}
+
+const UNIX: &str = "unix:";
+
+impl FromStr for Bind {
+ type Err = anyhow::Error;
+
+ fn from_str(text: &str) -> Result<Self> {
+ let text = text.trim();
+ match text.strip_prefix(UNIX) {
+ Some(path) if !path.is_empty() => Ok(Self::Unix(PathBuf::from(path))),
+ Some(_) => {
+ anyhow::bail!("bind-addr: unix: needs a path (unix:/run/continuum/api.sock)")
+ }
+ None => text.parse().map(Self::Tcp).with_context(|| {
+ format!("bind-addr: {text:?} is neither ADDRESS:PORT nor unix:PATH")
+ }),
+ }
+ }
+}
+
+impl std::fmt::Display for Bind {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::Tcp(address) => write!(f, "{address}"),
+ Self::Unix(path) => write!(f, "{UNIX}{}", path.display()),
+ }
+ }
+}
+
+impl Serialize for Bind {
+ fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
+ serializer.collect_str(self)
+ }
+}
+
+impl<'de> Deserialize<'de> for Bind {
+ fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
+ let text = String::deserialize(deserializer)?;
+ text.parse()
+ .map_err(|error: anyhow::Error| serde::de::Error::custom(format!("{error:#}")))
+ }
+}
+
+/// `--bind-addr`: one place (as before it took more), or several.
+pub(crate) fn one_or_more<'de, D: Deserializer<'de>>(
+ deserializer: D,
+) -> Result<Vec<Bind>, D::Error> {
+ #[derive(Deserialize)]
+ #[serde(untagged)]
+ enum OneOrMore {
+ One(Bind),
+ More(Vec<Bind>),
+ }
+ Ok(match OneOrMore::deserialize(deserializer)? {
+ OneOrMore::One(bind) => vec![bind],
+ OneOrMore::More(binds) => binds,
+ })
+}
+
+/// Where a request came from (`axum::extract::ConnectInfo`).
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum Peer {
+ Tcp(SocketAddr),
+ /// Over one of the API's Unix sockets.
+ Unix,
+}
+
+impl Connected<IncomingStream<'_, tokio::net::TcpListener>> for Peer {
+ fn connect_info(stream: IncomingStream<'_, tokio::net::TcpListener>) -> Self {
+ Self::Tcp(*stream.remote_addr())
+ }
+}
+
+#[cfg(unix)]
+impl Connected<IncomingStream<'_, tokio::net::UnixListener>> for Peer {
+ fn connect_info(_stream: IncomingStream<'_, tokio::net::UnixListener>) -> Self {
+ Self::Unix
+ }
+}
+
+/// Serves `app` at every one of `binds` until `stop` resolves (each server then drains
+/// its connections); resolves once all have stopped, with the first error if any did.
+/// Binds them all before it returns: one that can't be bound is an error at once.
+pub(crate) async fn serve<F>(
+ binds: &[Bind],
+ app: Router,
+ stop: impl Fn() -> F,
+) -> Result<std::pin::Pin<Box<dyn Future<Output = Result<()>> + Send>>>
+where
+ F: Future<Output = ()> + Send + 'static,
+{
+ let mut servers = tokio::task::JoinSet::new();
+ for bind in binds {
+ let service = app.clone().into_make_service_with_connect_info::<Peer>();
+ let stopped = stop();
+ match bind {
+ Bind::Tcp(address) => {
+ let listener = tokio::net::TcpListener::bind(address)
+ .await
+ .with_context(|| format!("Binding the HTTP API to {address}"))?;
+ info!(address = %listener.local_addr()?, "HTTP API listening");
+ servers.spawn(async move {
+ axum::serve(listener, service)
+ .with_graceful_shutdown(stopped)
+ .await
+ .map_err(anyhow::Error::from)
+ });
+ }
+ #[cfg(unix)]
+ Bind::Unix(path) => {
+ let listener = bind_unix(path)?;
+ info!(socket = %path.display(), "HTTP API listening");
+ let path = path.clone();
+ servers.spawn(async move {
+ let served = axum::serve(listener, service)
+ .with_graceful_shutdown(stopped)
+ .await;
+ if let Err(error) = std::fs::remove_file(&path) {
+ warn!(socket = %path.display(), error = %error, "Couldn't remove the HTTP API's socket");
+ }
+ served.map_err(anyhow::Error::from)
+ });
+ }
+ #[cfg(not(unix))]
+ Bind::Unix(_) => anyhow::bail!("bind-addr: Unix sockets need a Unix"),
+ }
+ }
+ Ok(Box::pin(async move {
+ let mut first = Ok(());
+ while let Some(served) = servers.join_next().await {
+ let served = served
+ .map_err(anyhow::Error::from)
+ .and_then(|served| served);
+ if let Err(error) = served
+ && first.is_ok()
+ {
+ first = Err(error);
+ }
+ }
+ first
+ }))
+}
+
+/// A Unix socket at `path`: one left there by an earlier run is replaced, anything else
+/// there is an error. Its user and group may connect, nobody else.
+#[cfg(unix)]
+fn bind_unix(path: &std::path::Path) -> Result<tokio::net::UnixListener> {
+ use std::os::unix::fs::{FileTypeExt, PermissionsExt};
+ match std::fs::symlink_metadata(path) {
+ Ok(metadata) if metadata.file_type().is_socket() => std::fs::remove_file(path)
+ .with_context(|| format!("Removing the old socket {}", path.display()))?,
+ Ok(_) => anyhow::bail!(
+ "bind-addr: {} exists and is no socket; not replacing it",
+ path.display()
+ ),
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
+ Err(error) => {
+ return Err(error).with_context(|| format!("Looking at {}", path.display()));
+ }
+ }
+ let listener = tokio::net::UnixListener::bind(path)
+ .with_context(|| format!("Binding the HTTP API to {}", path.display()))?;
+ std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o660))
+ .with_context(|| format!("Setting who may use {}", path.display()))?;
+ Ok(listener)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::Bind;
+
+ #[test]
+ fn binds_parse() {
+ assert_eq!(
+ "127.0.0.1:3000".parse::<Bind>().unwrap(),
+ Bind::Tcp("127.0.0.1:3000".parse().unwrap())
+ );
+ assert_eq!(
+ "unix:/run/continuum/api.sock".parse::<Bind>().unwrap(),
+ Bind::Unix("/run/continuum/api.sock".into())
+ );
+ assert_eq!(
+ "unix:/run/continuum/api.sock"
+ .parse::<Bind>()
+ .unwrap()
+ .to_string(),
+ "unix:/run/continuum/api.sock"
+ );
+ for bad in ["unix:", "localhost", "3000"] {
+ assert!(bad.parse::<Bind>().is_err(), "{bad}");
+ }
+ }
+}
diff --git a/src/api/mod.rs b/src/api/mod.rs
index 9bb2bed..e4ccee6 100644
--- a/src/api/mod.rs
+++ b/src/api/mod.rs
@@ -9,6 +9,7 @@ mod dashboard;
mod debug;
mod document;
mod health;
+pub(crate) mod listen;
mod log;
mod peers;
mod stats;
@@ -77,13 +78,17 @@ pub(crate) fn router(state: AppState, auth_state: AuthState, debug_api: bool) ->
api.layer(middleware::from_fn_with_state(auth_state, basic_auth))
// The hand-written spec (`docs/openapi.yaml`), and the Swagger UI showing it.
.merge(
- SwaggerUi::new("/swagger-ui").config(utoipa_swagger_ui::Config::from("/openapi.yaml")),
+ // Relative to the UI's page (`/swagger-ui/`), so it is found behind a proxy that
+ // serves the API under a path of its own too.
+ SwaggerUi::new("/swagger-ui")
+ .config(utoipa_swagger_ui::Config::from("../openapi.yaml")),
)
.route("/openapi.yaml", get(openapi_spec))
.route("/health", get(health::node_health))
.route("/ready", get(health::ready))
// Outermost, so authentication failures get the error body too.
.layer(middleware::from_fn(json_errors))
+ .layer(middleware::from_fn(relative_redirects))
.with_state(state)
}
@@ -107,9 +112,17 @@ pub(crate) fn relay_only_router(
.layer(middleware::from_fn_with_state(auth_state, basic_auth))
.route("/health", get(health::health))
.layer(middleware::from_fn(json_errors))
+ .layer(middleware::from_fn(relative_redirects))
.with_state(relay)
}
+/// On a response that streams: a reverse proxy passes it on as it comes, not once it
+/// has buffered enough (nginx's `X-Accel-Buffering`).
+pub(crate) const UNBUFFERED: (axum::http::HeaderName, &str) = (
+ axum::http::HeaderName::from_static("x-accel-buffering"),
+ "no",
+);
+
/// An error answer: `{"error": message}` with `details`' fields added (`docs/openapi.yaml`,
/// `Error`).
pub(crate) fn error_response(
@@ -124,6 +137,56 @@ pub(crate) fn error_response(
(status, Json(body)).into_response()
}
+/// Redirects to a path of this API (`Location: /store/…`) made relative to the request's
+/// (`3`, `swagger-ui/`): they then lead to the same place behind a reverse proxy
+/// that serves the API under a path of its own (`/continuum/store/…`).
+async fn relative_redirects(request: Request, next: Next) -> Response {
+ let from = request.uri().path().to_owned();
+ let mut response = next.run(request).await;
+ let relative = response
+ .headers()
+ .get(axum::http::header::LOCATION)
+ .and_then(|location| location.to_str().ok())
+ .filter(|location| location.starts_with('/') && !location.starts_with("//"))
+ .map(|location| relative_to(&from, location));
+ if let Some(location) = relative.and_then(|location| HeaderValue::from_str(&location).ok()) {
+ response
+ .headers_mut()
+ .insert(axum::http::header::LOCATION, location);
+ }
+ response
+}
+
+/// `to` (a path, with its query if any) as a reference relative to the page at `from`
+/// (a path).
+fn relative_to(from: &str, to: &str) -> String {
+ let (path, query) = match to.split_once('?') {
+ Some((path, query)) => (path, Some(query)),
+ None => (to, None),
+ };
+ // The page's directory, and the target's segments (the last its name).
+ let from: Vec<&str> = from.split('/').collect();
+ let directory = &from[..from.len() - 1];
+ let target: Vec<&str> = path.split('/').collect();
+ let common = directory
+ .iter()
+ .zip(&target[..target.len() - 1])
+ .take_while(|(a, b)| a == b)
+ .count();
+ let mut relative = "../".repeat(directory.len() - common);
+ relative.push_str(&target[common..].join("/"));
+ // Not empty, and not taken for a scheme (`a:b`).
+ let first = relative.split('/').next().unwrap_or("");
+ if relative.is_empty() || first.contains(':') {
+ relative.insert_str(0, "./");
+ }
+ if let Some(query) = query {
+ relative.push('?');
+ relative.push_str(query);
+ }
+ relative
+}
+
/// Gives every error answer the same body (`error_response`). Handlers that fail with
/// a bare status or a text message, and axum's own rejections (a malformed query or
/// body), get it here: the text becomes the message, or the status's reason if there
@@ -229,3 +292,21 @@ pub(crate) fn store_status(error: StoreError, context: &str) -> ApiError {
pub(crate) fn media_type(value: &str) -> &str {
value.split(';').next().unwrap_or("").trim()
}
+
+#[cfg(test)]
+mod redirect_tests {
+ use super::relative_to;
+
+ #[test]
+ fn redirects_are_relative_to_the_request() {
+ assert_eq!(relative_to("/store/queue/-1", "/store/queue/3"), "3");
+ assert_eq!(
+ relative_to("/store/a/-1/b", "/store/a/0/b?x=1"),
+ "../0/b?x=1"
+ );
+ assert_eq!(relative_to("/store/-1", "/store/2"), "2");
+ assert_eq!(relative_to("/swagger-ui", "/swagger-ui/"), "swagger-ui/");
+ assert_eq!(relative_to("/store/a/b", "/store/a/"), "./");
+ assert_eq!(relative_to("/store/x", "/store/a:b"), "./a:b");
+ }
+}
diff --git a/src/api/peers.rs b/src/api/peers.rs
index 3fe4c39..537ad98 100644
--- a/src/api/peers.rs
+++ b/src/api/peers.rs
@@ -121,7 +121,9 @@ pub(crate) async fn peer_list(state: &AppState) -> PeerListResponse {
for (peer, peer_state_mutex) in snapshot {
let neighbour = crate::swarm::topology::is_neighbour(&state, &peer);
- let traffic = state.traffic.peer(&peer).unwrap_or_default();
+ let traffic = state
+ .traffic
+ .node(&peer, &state.transport.identities_of(&peer));
let sync_throughput = state
.throughput
.lock_or_recover()
diff --git a/src/api/store.rs b/src/api/store.rs
index 2b21c83..d419d37 100644
--- a/src/api/store.rs
+++ b/src/api/store.rs
@@ -861,6 +861,7 @@ async fn stream(state: AppState, key: String, path: Vec<String>) -> Result<Respo
let stream_state = state.clone();
let stream_key = key.clone();
+ let heartbeat = state.stream_heartbeat;
let output = async_stream::stream! {
let (mut last_value, mut conflicted) = initial;
@@ -872,9 +873,27 @@ async fn stream(state: AppState, key: String, path: Vec<String>) -> Result<Respo
return;
}
}
+ // When the last line went out: a blank one follows after `heartbeat` without
+ // another, so proxies in front don't take the stream for dead.
+ let mut sent = tokio::time::Instant::now();
loop {
- let relevant = match changes.recv().await {
+ let beat = async {
+ match sent.checked_add(heartbeat).filter(|_| !heartbeat.is_zero()) {
+ Some(at) => tokio::time::sleep_until(at).await,
+ None => std::future::pending::<()>().await,
+ }
+ };
+ let received = tokio::select! {
+ received = changes.recv() => Some(received),
+ () = beat => None,
+ };
+ let Some(received) = received else {
+ sent = tokio::time::Instant::now();
+ yield Ok(Bytes::from_static(b"\n"));
+ continue;
+ };
+ let relevant = match received {
// Conflicted paths can change without a patch on their path; re-read always.
Ok(set) => conflicted || set.paths.iter().any(|changed| paths_related(changed, &path)),
Err(broadcast::error::RecvError::Lagged(skipped)) => {
@@ -910,6 +929,7 @@ async fn stream(state: AppState, key: String, path: Vec<String>) -> Result<Respo
return;
}
}
+ sent = tokio::time::Instant::now();
last_value = next_value;
}
};
@@ -923,6 +943,11 @@ async fn stream(state: AppState, key: String, path: Vec<String>) -> Result<Respo
axum::http::header::CACHE_CONTROL,
HeaderValue::from_static("no-cache, no-transform"),
);
+ // Through nginx too as it comes, not buffered (`operations.md`).
+ response.headers_mut().insert(
+ super::UNBUFFERED.0,
+ HeaderValue::from_static(super::UNBUFFERED.1),
+ );
Ok(response)
}