aboutsummaryrefslogtreecommitdiffstats
path: root/src/api/dashboard/dashboard.html
diff options
context:
space:
mode:
Diffstat (limited to 'src/api/dashboard/dashboard.html')
-rw-r--r--src/api/dashboard/dashboard.html497
1 files changed, 446 insertions, 51 deletions
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;