diff options
| author | tv <tv@krebsco.de> | 2026-10-30 21:45:22 +0100 |
|---|---|---|
| committer | tv <tv@krebsco.de> | 2026-10-30 21:45:22 +0100 |
| commit | db6e1ad6592aefce8a99db8478fac0baae09b228 (patch) | |
| tree | 7f7f133eba4dbc5e8bc18effab81868cf16dc75f | |
| parent | 114c9cb6f45abedb9b90ef6c9194570ff84fbda2 (diff) | |
dashboard: click on live/reconnecting triggers
| -rw-r--r-- | docs/openapi.yaml | 11 | ||||
| -rw-r--r-- | src/api/dashboard/dashboard.html | 23 | ||||
| -rw-r--r-- | src/api/dashboard/mod.rs | 24 |
3 files changed, 53 insertions, 5 deletions
diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 66ff2d3..664579a 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -559,6 +559,17 @@ paths: type: object required: [node, stats, peers, graph] additionalProperties: true + /admin/dashboard/refresh: + post: + tags: [Admin] + summary: Dashboard refresh + description: | + The dashboard's producer builds the state now rather than at its next tick, and + asks the members for their links again; what changed comes as events. Only with + the `dashboard` cargo feature. + responses: + '204': + description: Done. /admin/dashboard/events: get: tags: [Admin] diff --git a/src/api/dashboard/dashboard.html b/src/api/dashboard/dashboard.html index b3f8986..c9aaf4c 100644 --- a/src/api/dashboard/dashboard.html +++ b/src/api/dashboard/dashboard.html @@ -965,7 +965,11 @@ function status(text, live) { // While not live, what is shown stays as it was last (ages don't count on). let live = false; let events = null; +// The next attempt, while one is waited for: a connect now replaces it. +let retry = null; function connect() { + clearTimeout(retry); + retry = null; events = new EventSource("dashboard/events"); events.addEventListener("snapshot", event => { const snapshot = JSON.parse(event.data); @@ -989,7 +993,7 @@ function connect() { events.addEventListener("offline", () => { events.close(); live = false; status("node stopped; reconnecting…", false); - setTimeout(connect, 3000); + retry = setTimeout(connect, 3000); }); events.addEventListener("error", () => { // Each attempt that failed: a breath too, so it shows it keeps trying. @@ -998,7 +1002,7 @@ function connect() { if (events.readyState === EventSource.CLOSED) { // Given up (an HTTP error, e.g. the node is up but not serving the page yet). status("disconnected; retrying…", false); - setTimeout(connect, 5000); + retry = setTimeout(connect, 5000); } else { // EventSource reconnects by itself; the node sends a snapshot first again. status("reconnecting…", false); @@ -1006,6 +1010,21 @@ function connect() { }); } connect(); +// A click on the status: live, everything now (a tick on the node, the members asked +// again, what's open of the document read again); otherwise, reconnecting now. +connection.title = "Click to refresh now"; +connection.style.cursor = "pointer"; +connection.addEventListener("click", () => { + pulse(); + if (live) { + fetch("dashboard/refresh", { method: "POST" }).catch(() => {}); + documentChanged({ all: true }); + } else { + if (events) events.close(); + status("reconnecting…", false); + connect(); + } +}); // Ages count on between events; nothing else changes without one. setInterval(() => { if (live) render(true); }, 1000); window.addEventListener("resize", () => { lastShape = ""; render(); }); diff --git a/src/api/dashboard/mod.rs b/src/api/dashboard/mod.rs index 7679787..dd454ec 100644 --- a/src/api/dashboard/mod.rs +++ b/src/api/dashboard/mod.rs @@ -21,10 +21,10 @@ use std::time::Duration; use axum::http::{HeaderValue, StatusCode, header::CONTENT_TYPE}; use axum::response::sse::{Event, KeepAlive, Sse}; use axum::response::{Html, IntoResponse, Response}; -use axum::routing::get; +use axum::routing::{get, post}; use axum::{Extension, Router}; use serde_json::{Map, Value as JsonValue, json}; -use tokio::sync::{broadcast, watch}; +use tokio::sync::{Notify, broadcast, watch}; use tokio::time::Instant; use crate::locks::MutexExt; @@ -60,6 +60,8 @@ struct Hub { /// `{"paths": [...]}`, or `{"all": true}` if too many or not known. documents: broadcast::Sender<Arc<String>>, control: Mutex<Control>, + /// Someone asked for it now (`refresh`): a tick at once, members asked again. + poke: Notify, /// True once the node stops: every client gets an `offline` event, and its stream /// ends (so the HTTP server needn't wait for clients to go). closing: watch::Receiver<bool>, @@ -81,6 +83,7 @@ impl Hub { latest, patches, documents, + poke: Notify::new(), control: Mutex::new(Control { running: false, wanted_until: Instant::now(), @@ -151,7 +154,13 @@ impl Hub { }))); last = Some(snapshot); } - tokio::time::sleep(TICK).await; + let poked = tokio::select! { + () = tokio::time::sleep(TICK) => false, + () = self.poke.notified() => true, + }; + if poked { + graph_due = Instant::now(); + } if let Some(changed) = document_changes(&mut changes) { let _ = self.documents.send(Arc::new(changed.to_string())); } @@ -360,6 +369,7 @@ pub(crate) fn router(state: AppState, closing: watch::Receiver<bool>) -> Router< .route("/admin/dashboard", get(page_html)) .route("/admin/dashboard/state", get(current_state)) .route("/admin/dashboard/events", get(events)) + .route("/admin/dashboard/refresh", post(refresh)) .layer(Extension(Hub::new(state, closing))) } @@ -389,6 +399,14 @@ async fn page_html() -> Html<&'static str> { Html(page().1.as_str()) } +/// `POST /admin/dashboard/refresh`: a tick now rather than at the next `TICK`, and the +/// members asked for their links again; what changed comes as events, as ever. +async fn refresh(Extension(hub): Extension<Arc<Hub>>) -> StatusCode { + hub.want(); + hub.poke.notify_one(); + StatusCode::NO_CONTENT +} + /// `GET /admin/dashboard/state`: the latest snapshot, for a client that wants it whole. async fn current_state(Extension(hub): Extension<Arc<Hub>>) -> Response { hub.want(); |
