From c5f67f56f46ea5c0b300a2e0eb00a7368efe4f2a Mon Sep 17 00:00:00 2001 From: tv Date: Thu, 5 Nov 2026 02:58:27 +0100 Subject: dashboard: add epoch switch indicator --- docs/dashboard.md | 4 +++- docs/openapi.yaml | 4 +++- src/api/dashboard/dashboard.html | 44 +++++++++++++++++++++++++++++++++++++++- src/api/dashboard/mod.rs | 1 + src/remake/local.rs | 6 ++++++ src/remake/mod.rs | 34 ++++++++++++++++++++++++------- 6 files changed, 83 insertions(+), 10 deletions(-) diff --git a/docs/dashboard.md b/docs/dashboard.md index 7cdc7b7..4067da5 100644 --- a/docs/dashboard.md +++ b/docs/dashboard.md @@ -80,7 +80,9 @@ Every member of the swarm, by alias (or a shortened node ID): around it), or voted for one, while that remake is open: voting, or committed until the member switched. A small amber breath as a remake is proposed (its coordinator) and as each vote comes; a short pop once it's decided, green if committed, red if - aborted (or expired), and the nodes turn back. Of the remakes in this node's document: + aborted (or expired), and the nodes turn back. Committed, the coordinator's ring + counts down this node's wait to switch (`switchDelay`): from the top, it goes + counterclockwise, and is gone as this node switches. Of the remakes in this node's document: every member's vote shows, as it reaches this node. * **A golden glow**, growing from it and fading, once: it just entered a new epoch (a remake, [`remakes.md`](remakes.md)). For every member that reports its links (they diff --git a/docs/openapi.yaml b/docs/openapi.yaml index bfad909..d9d6c04 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -758,7 +758,9 @@ paths: answered a LINKS; ours always, the others' while fresh) and `live` (whether the members' links are subscribed to, `/admin/dashboard/links`), and `remakes` (those of this node's epoch: `id`, `coordinator`, `phase` as in - `/admin/remakes`, `voters` (node IDs) and `fromGeneration`, the epoch's). One producer + `/admin/remakes`, `voters` (node IDs), `fromGeneration`, the epoch's, and + once committed `switchDelayMs` and `switchInMs`, this node's wait to switch + and what is left of it). One producer per node makes it every 2 s while anyone looks (and for a minute after this request), subscribed to the members' links (each tells of a change as it happens; renewed every 30 s); every client gets the same. Only with the `dashboard` cargo feature. diff --git a/src/api/dashboard/dashboard.html b/src/api/dashboard/dashboard.html index 5180038..ede191d 100644 --- a/src/api/dashboard/dashboard.html +++ b/src/api/dashboard/dashboard.html @@ -1236,8 +1236,13 @@ function renderGraph() { // In a remake (`inRemakes`): amber; its coordinator also ringed. const voting = amber.has(id); if (coordinating.has(id)) { + // Counting down once committed (`countdowns`): from the top, as a clock's hand. + const dash = countdownDash(id, radius + 4); 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" })); + fill: "none", stroke: AMBER, "stroke-width": 1, "pointer-events": "none", + ...(dash ? { "stroke-dasharray": dash, + transform: "rotate(-90 " + p.x + " " + p.y + ")" } : {}) })); + if (dash && countdownFrame === null) countdownFrame = requestAnimationFrame(tickCountdowns); } const circle = drawing("node " + id, "circle", { cx: p.x, cy: p.y, r: radius, fill: voting ? AMBER : member.self ? "var(--self)" : reported ? "var(--fg)" : "var(--panel)", @@ -2096,8 +2101,15 @@ function remakesArrived() { breathe(id, { fill, rim: fill, to: 26, ms: 600, opacity: .6 }); } }; + countdowns.clear(); for (const remake of remakes) { seen.add(remake.id); + // Committed: this node's wait to switch, as of now (re-anchored with every update). + if (remake.phase === "committed" && typeof remake.switchInMs === "number" && + remake.switchDelayMs > 0) { + countdowns.set(remake.coordinator, { until: performance.now() + remake.switchInMs, + total: remake.switchDelayMs }); + } const was = knownRemakes.get(remake.id); if (remakesKnown) { const breath = id => breathe(id, { fill: AMBER, rim: AMBER, to: 20, ms: 900, opacity: .45 }); @@ -2113,6 +2125,9 @@ function remakesArrived() { } knownRemakes.set(remake.id, remake); } + if (countdowns.size > 0 && countdownFrame === null) { + countdownFrame = requestAnimationFrame(tickCountdowns); + } // 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) { @@ -2125,6 +2140,33 @@ function remakesArrived() { } remakesKnown = true; } +// Committed remakes' coordinators, by node ID: when this node switches (by +// `performance.now()`) and the whole wait (ms). Their ring counts down: a full circle +// from the top, going counterclockwise as the wait runs out, gone as the epoch switches. +const countdowns = new Map(); +let countdownFrame = null; +// The ring's dashes for `id` (radius `r`), if it counts down: the part left. +function countdownDash(id, r) { + const countdown = countdowns.get(id); + if (!countdown) return null; + const left = Math.min(1, Math.max(0, (countdown.until - performance.now()) / countdown.total)); + const around = 2 * Math.PI * r; + return (around * left).toFixed(1) + " " + around.toFixed(1); +} +// Every frame while a ring counts down: its dashes, and nothing else drawn anew. +function tickCountdowns() { + countdownFrame = null; + const now = performance.now(); + if (document.hidden || ![...countdowns.values()].some(c => c.until > now - 100)) return; + for (const id of countdowns.keys()) { + const ring = drawn.get("coordinator " + id); + if (ring) { + const dash = countdownDash(id, Number(ring.getAttribute("r"))); + if (dash) ring.setAttribute("stroke-dasharray", dash); + } + } + countdownFrame = requestAnimationFrame(tickCountdowns); +} // Who is amber now (`remakesArrived`), and who of them coordinates: of the open // remakes, those not past the epoch yet (`knownEpochs`). function inRemakes() { diff --git a/src/api/dashboard/mod.rs b/src/api/dashboard/mod.rs index e83f485..831df6f 100644 --- a/src/api/dashboard/mod.rs +++ b/src/api/dashboard/mod.rs @@ -323,6 +323,7 @@ async fn build(state: &AppState, graph: &Graph, live: bool) -> JsonValue { let replica = state.doc.read().await; // The remakes of our epoch, with who voted: the map shows them. let remakes = crate::remake::current( + &state.remake, &replica.doc, replica.epoch, state.clock.now_millis(), diff --git a/src/remake/local.rs b/src/remake/local.rs index c80a5a5..e13c6fd 100644 --- a/src/remake/local.rs +++ b/src/remake/local.rs @@ -253,6 +253,12 @@ impl RemakeState { .or_insert_with(Instant::now) } + /// How long ago this node first saw remake `id` committed (`first_seen`), if it did. + #[cfg_attr(not(feature = "dashboard"), allow(dead_code))] + pub(crate) fn seen_for(&self, id: &str) -> Option { + self.lock().first_seen.get(id).map(|at| at.elapsed()) + } + pub(crate) fn status(&self, id: &str) -> Option { self.lock().status.get(id).cloned() } diff --git a/src/remake/mod.rs b/src/remake/mod.rs index 2273456..b16b8a3 100644 --- a/src/remake/mod.rs +++ b/src/remake/mod.rs @@ -88,12 +88,21 @@ pub(crate) struct Current { pub(crate) voters: Vec, /// The epoch's generation it remakes: a member past it switched. pub(crate) from_generation: u64, + /// Committed: the wait between seeing the commit and switching + /// (`Proposal::switch_delay_ms`), and how much of it is left on this node (ms). + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) switch_delay_ms: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) switch_in_ms: Option, } /// The remakes of `epoch` in `doc`, with their phase at `now` (expiry by `grace`, this -/// node's for records without their own: `Record::phase`). +/// node's `dormant_after`, for records without their own: `Record::phase`), and for one +/// committed, this node's wait to switch (`remake`: when it saw the commit; as +/// `switch::check` waits: the proposal's delay, or a quarter of `grace`). #[cfg_attr(not(feature = "dashboard"), allow(dead_code))] pub(crate) fn current( + remake: &RemakeState, doc: &Document, epoch: Epoch, now: crate::clock::UnixMillis, @@ -103,12 +112,23 @@ pub(crate) fn current( records::load_all(doc) .into_iter() .filter(|(_, record)| record.proposal.from_epoch == local) - .map(|(id, record)| Current { - phase: record.phase(now, grace), - coordinator: record.proposal.coordinator.clone(), - voters: record.votes.keys().cloned().collect(), - from_generation: local.generation, - id, + .map(|(id, record)| { + let phase = record.phase(now, grace); + let delay = (phase == records::Phase::Committed) + .then(|| record.proposal.switch_delay_ms.unwrap_or(grace / 4)); + let left = delay.and_then(|delay| { + let seen = remake.seen_for(&id)?; + Some(delay.saturating_sub(seen.as_millis() as u64)) + }); + Current { + phase, + coordinator: record.proposal.coordinator.clone(), + voters: record.votes.keys().cloned().collect(), + from_generation: local.generation, + switch_delay_ms: delay, + switch_in_ms: left, + id, + } }) .collect() } -- cgit v1.3.1