aboutsummaryrefslogtreecommitdiffstats
path: root/src/api/dashboard
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-10-30 19:10:11 +0100
committertv <tv@krebsco.de>2026-10-30 19:10:11 +0100
commit7d3d8031d2301f24b32ee08215a8d004fb81493b (patch)
tree5435598c930e2fae34feedca524ce55322615f07 /src/api/dashboard
parentf5fa9b16fe5505e0058723835d550d73c11391f7 (diff)
dashboard: reload new version
Diffstat (limited to 'src/api/dashboard')
-rw-r--r--src/api/dashboard/dashboard.html21
-rw-r--r--src/api/dashboard/mod.rs30
2 files changed, 45 insertions, 6 deletions
diff --git a/src/api/dashboard/dashboard.html b/src/api/dashboard/dashboard.html
index 11e53d2..a5181ca 100644
--- a/src/api/dashboard/dashboard.html
+++ b/src/api/dashboard/dashboard.html
@@ -113,6 +113,9 @@
</main>
<script>
"use strict";
+// Which page this is (the node fills it in); a snapshot naming another came from
+// another build: reloaded then, to get its page.
+const PAGE_VERSION = "__PAGE_VERSION__";
// The state as the node published it, and when it did (the last event).
let state = null;
let stateAt = 0;
@@ -167,11 +170,18 @@ function age(secs) {
// What is in focus, its figures shown: a node's ID, or a link's pair key (`pairKey`,
// two IDs). null: this node's (`/admin/stats`).
-let focused = null;
+let focused = (() => {
+ try { return decodeURIComponent(location.hash.slice(1)) || null; } catch { return null; }
+})();
const pairKey = (a, b) => a < b ? a + " " + b : b + " " + a;
const isPair = key => key !== null && key.includes(" ");
function focus(id) {
focused = id === null || id === state.node.id ? null : id;
+ // In the URL too, so a reload (a new version) keeps it.
+ try {
+ history.replaceState(null, "", focused === null ? location.pathname
+ : "#" + encodeURIComponent(focused));
+ } catch { /* not where history works */ }
render();
}
@@ -536,7 +546,14 @@ let events = null;
function connect() {
events = new EventSource("dashboard/events");
events.addEventListener("snapshot", event => {
- state = JSON.parse(event.data); stateAt = Date.now(); live = true;
+ const snapshot = JSON.parse(event.data);
+ if (snapshot.page !== PAGE_VERSION) {
+ events.close();
+ status("new version; reloading…", false);
+ location.reload();
+ return;
+ }
+ state = snapshot; stateAt = Date.now(); live = true;
status("live", true); render();
});
events.addEventListener("patch", event => {
diff --git a/src/api/dashboard/mod.rs b/src/api/dashboard/mod.rs
index 1de21cd..99a0b83 100644
--- a/src/api/dashboard/mod.rs
+++ b/src/api/dashboard/mod.rs
@@ -15,7 +15,7 @@
use std::collections::{BTreeMap, BTreeSet};
use std::convert::Infallible;
-use std::sync::{Arc, Mutex};
+use std::sync::{Arc, Mutex, OnceLock};
use std::time::Duration;
use axum::http::{HeaderValue, StatusCode, header::CONTENT_TYPE};
@@ -221,6 +221,7 @@ async fn build(state: &AppState, graph: &Graph) -> JsonValue {
}
json!({
"node": { "id": local, "alias": state.alias },
+ "page": page().0,
"stats": stats,
"peers": peers,
"graph": { "members": members_json, "intended": intended, "measured": measured },
@@ -312,15 +313,36 @@ fn merge_patch(old: &JsonValue, new: &JsonValue) -> Option<JsonValue> {
/// `closing` turns true when the node stops (`Hub::closing`).
pub(crate) fn router(state: AppState, closing: watch::Receiver<bool>) -> Router<AppState> {
Router::new()
- .route("/admin/dashboard", get(page))
+ .route("/admin/dashboard", get(page_html))
.route("/admin/dashboard/state", get(current_state))
.route("/admin/dashboard/events", get(events))
.layer(Extension(Hub::new(state, closing)))
}
+/// The page as built into this binary.
+const PAGE: &str = include_str!("dashboard.html");
+/// Where the page says which version of it it is.
+const PAGE_VERSION_MARK: &str = "__PAGE_VERSION__";
+
+/// The page's version (a hash of it), and the page saying so: a page whose version
+/// differs from the snapshot's `page` was served by another build, and reloads.
+fn page() -> &'static (String, String) {
+ static PAGE_SERVED: OnceLock<(String, String)> = OnceLock::new();
+ PAGE_SERVED.get_or_init(|| {
+ use sha2::{Digest, Sha256};
+ let version: String = Sha256::digest(PAGE.as_bytes())
+ .iter()
+ .take(8)
+ .map(|byte| format!("{byte:02x}"))
+ .collect();
+ let served = PAGE.replace(PAGE_VERSION_MARK, &version);
+ (version, served)
+ })
+}
+
/// `GET /admin/dashboard`: the page.
-async fn page() -> Html<&'static str> {
- Html(include_str!("dashboard.html"))
+async fn page_html() -> Html<&'static str> {
+ Html(page().1.as_str())
}
/// `GET /admin/dashboard/state`: the latest snapshot, for a client that wants it whole.