aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-11-05 02:58:27 +0100
committertv <tv@krebsco.de>2026-11-05 02:58:27 +0100
commitc5f67f56f46ea5c0b300a2e0eb00a7368efe4f2a (patch)
treee4a9235173e546b008fb222be12862bd0075b96e /src
parentc10f9144b1436d4918855a657715b453a6228a44 (diff)
dashboard: add epoch switch indicator
Diffstat (limited to 'src')
-rw-r--r--src/api/dashboard/dashboard.html44
-rw-r--r--src/api/dashboard/mod.rs1
-rw-r--r--src/remake/local.rs6
-rw-r--r--src/remake/mod.rs34
4 files changed, 77 insertions, 8 deletions
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<std::time::Duration> {
+ self.lock().first_seen.get(id).map(|at| at.elapsed())
+ }
+
pub(crate) fn status(&self, id: &str) -> Option<LocalStatus> {
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<String>,
/// 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<u64>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub(crate) switch_in_ms: Option<u64>,
}
/// 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()
}