diff options
Diffstat (limited to 'src/api/dashboard/dashboard.html')
| -rw-r--r-- | src/api/dashboard/dashboard.html | 92 |
1 files changed, 83 insertions, 9 deletions
diff --git a/src/api/dashboard/dashboard.html b/src/api/dashboard/dashboard.html index 1f27544..5180038 100644 --- a/src/api/dashboard/dashboard.html +++ b/src/api/dashboard/dashboard.html @@ -1208,6 +1208,7 @@ function renderGraph() { children.push(...labels); // Above the lines and their labels, below the nodes. children.push(...hits); + const { amber, coordinating } = inRemakes(); for (const id of nodes) { const p = positions.get(id), member = state.graph.members[id]; const reported = id in state.graph.measured; @@ -1232,14 +1233,21 @@ function renderGraph() { const dormant = (state.peers.peers[id] || {}).status === "dormant"; const radius = member.self ? 8 : 6; mark(id, "circle", { cx: p.x, cy: p.y, r: radius + 6, fill: "rgb(127 127 127)" }); + // In a remake (`inRemakes`): amber; its coordinator also ringed. + const voting = amber.has(id); + if (coordinating.has(id)) { + children.push(drawing("coordinator " + id, "circle", { cx: p.x, cy: p.y, r: radius + 4, + fill: "none", stroke: AMBER, "stroke-width": 1, "pointer-events": "none" })); + } 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)", + fill: voting ? AMBER : member.self ? "var(--self)" : reported ? "var(--fg)" : "var(--panel)", + stroke: dormant ? "var(--bad)" : voting ? AMBER : member.self ? "var(--self)" : "var(--fg)", "stroke-width": dormant ? 2.5 : 1.5 }, pressed); // Its clock against the swarm's, as the members' reports chain it (the node's). const clock = ((state.graph.clocks || {})[id]); tooltip(circle, id + (reported ? "" : " (no links reported)") + (dormant ? " (dormant)" : "") + + (coordinating.has(id) ? " (coordinates a remake)" : voting ? " (voted for a remake)" : "") + (clock && clock.off ? " (clock " + seconds(clock.offsetMs) + " off the swarm's, ±" + clock.withinMs + " ms)" : "")); // Pinned (middle-clicked): a dot in the middle. @@ -2042,29 +2050,93 @@ function epochsArrived() { } } function shine(id) { + breathe(id, { fill: "#f59e0b", rim: "#fbbf24", to: 46, ms: SHINE_MS, opacity: .55 }); +} +// A halo growing from node `id` from about its size `to` a radius, in and out as a +// breath over `ms`, at most `opacity` (the rim more); staying its size where motion is +// to be calm. Above the nodes (`flashes`). +function breathe(id, { fill, rim, to, ms, opacity }) { if (document.hidden || !positions.has(id)) return; const layer = document.getElementById("flashes"); - const halo = svg("circle", { fill: "#f59e0b", stroke: "#fbbf24", "stroke-width": 3 }); + const halo = svg("circle", { fill, stroke: rim, "stroke-width": 3 }); layer.append(halo); const started = performance.now(); const step = now => { const p = positions.get(id); - const t = Math.min(1, Math.max(0, (now - started) / SHINE_MS)); + const t = Math.min(1, Math.max(0, (now - started) / ms)); if (!p || t >= 1) { halo.remove(); return; } - // In and out as a breath; growing from the node to about four times its size - // (staying as it is where motion is to be calm). halo.setAttribute("cx", p.x); halo.setAttribute("cy", p.y); - halo.setAttribute("r", calm.matches ? 22 : 10 + 36 * Math.sqrt(t)); - halo.setAttribute("fill-opacity", (.55 * Math.sin(Math.PI * t)).toFixed(2)); - halo.setAttribute("stroke-opacity", (.9 * Math.sin(Math.PI * t)).toFixed(2)); + halo.setAttribute("r", calm.matches ? (10 + to) / 2 : 10 + (to - 10) * Math.sqrt(t)); + halo.setAttribute("fill-opacity", (opacity * Math.sin(Math.PI * t)).toFixed(2)); + halo.setAttribute("stroke-opacity", (Math.min(1, opacity * 1.6) * Math.sin(Math.PI * t)).toFixed(2)); requestAnimationFrame(step); }; requestAnimationFrame(step); } + +// Remakes on the map (`remakes` in the snapshot: those of this node's epoch, with their +// coordinator, phase and voters). While one is open (proposed, or committed until they +// switch), its coordinator and voters are amber, the coordinator also ringed; a small +// amber breath as one is proposed (its coordinator) and as each vote comes. Decided, a +// pop over all of them: green if committed, red if aborted or expired, and they turn +// back. Only changes seen while the page is open: those of before show as they are. +const AMBER = "#f59e0b"; +const knownRemakes = new Map(); +let remakesKnown = false; +function remakesArrived() { + if (!state) return; + const remakes = state.remakes || []; + const seen = new Set(); + const pop = (remake, fill) => { + for (const id of new Set([remake.coordinator, ...remake.voters])) { + breathe(id, { fill, rim: fill, to: 26, ms: 600, opacity: .6 }); + } + }; + for (const remake of remakes) { + seen.add(remake.id); + const was = knownRemakes.get(remake.id); + if (remakesKnown) { + const breath = id => breathe(id, { fill: AMBER, rim: AMBER, to: 20, ms: 900, opacity: .45 }); + if (!was && remake.phase === "proposed") breath(remake.coordinator); + // The coordinator's own vote with its proposal: one breath. + const before = new Set(was ? was.voters : [remake.coordinator]); + if (remake.phase === "proposed" || (was && was.phase === "proposed")) { + for (const voter of remake.voters) if (!before.has(voter)) breath(voter); + } + if (was && was.phase === "proposed" && remake.phase !== "proposed") { + pop(remake, remake.phase === "committed" ? "#16a34a" : "#dc2626"); + } + } + knownRemakes.set(remake.id, remake); + } + // Gone from the snapshot: this node switched to the next epoch (the records stay in + // the old one's document). Committed then, if it was still open. + for (const [id, remake] of knownRemakes) { + if (seen.has(id)) continue; + if (remake.phase === "proposed" && + (knownEpochs.get(state.node.id) || 0) > remake.fromGeneration) { + pop(remake, "#16a34a"); + } + knownRemakes.delete(id); + } + remakesKnown = true; +} +// Who is amber now (`remakesArrived`), and who of them coordinates: of the open +// remakes, those not past the epoch yet (`knownEpochs`). +function inRemakes() { + const amber = new Set(), coordinating = new Set(); + for (const remake of (state && state.remakes) || []) { + if (remake.phase !== "proposed" && remake.phase !== "committed") continue; + const still = id => (knownEpochs.get(id) || 0) <= remake.fromGeneration; + for (const id of [remake.coordinator, ...remake.voters]) if (still(id)) amber.add(id); + if (still(remake.coordinator)) coordinating.add(remake.coordinator); + } + return { amber, coordinating }; +} // A dragged node let go of: dynamic, it moves to its place among the others again; // dragged with the middle button, it is pinned there. function dropped() { @@ -2538,6 +2610,7 @@ function connect() { if (state !== null) documentChanged({ all: true }); state = snapshot; stateAt = Date.now(); live = true; status("live", true); pulse(); render(); trafficArrived(); epochsArrived(); + remakesArrived(); if (treeShown && writerTree === null) loadTree(); handOverLinks(); }); @@ -2546,6 +2619,7 @@ function connect() { state = mergePatch(state, JSON.parse(event.data)); stateAt = Date.now(); pulse(); render(); trafficArrived(); epochsArrived(); + remakesArrived(); }); events.addEventListener("document", event => { pulse(); documentChanged(JSON.parse(event.data)); }); events.addEventListener("messages", event => messagesArrived(JSON.parse(event.data))); |
