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 --- src/api/dashboard/dashboard.html | 44 +++++++++++++++++++++++++++++++++++++++- src/api/dashboard/mod.rs | 1 + src/remake/local.rs | 6 ++++++ src/remake/mod.rs | 34 ++++++++++++++++++++++++------- 4 files changed, 77 insertions(+), 8 deletions(-) (limited to 'src') 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