diff options
Diffstat (limited to 'src/api/dashboard')
| -rw-r--r-- | src/api/dashboard/dashboard.html | 497 | ||||
| -rw-r--r-- | src/api/dashboard/mod.rs | 42 |
2 files changed, 477 insertions, 62 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; 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. |
