From 8de181ace16647b4fa2181b41a2f8f4fd03d68de Mon Sep 17 00:00:00 2001 From: tv Date: Fri, 30 Oct 2026 19:44:59 +0100 Subject: dashboard: document follows changes --- src/api/dashboard/dashboard.html | 91 ++++++++++++++++++++++++++++------------ src/api/dashboard/mod.rs | 75 +++++++++++++++++++++++++++++---- 2 files changed, 132 insertions(+), 34 deletions(-) (limited to 'src/api/dashboard') diff --git a/src/api/dashboard/dashboard.html b/src/api/dashboard/dashboard.html index 53a8282..0b15450 100644 --- a/src/api/dashboard/dashboard.html +++ b/src/api/dashboard/dashboard.html @@ -79,9 +79,6 @@ border: 2px solid var(--self); opacity: .5; } .scroll { overflow: auto; min-height: 0; } #figures-box .scroll, #document-box .scroll { flex: 1; } - h2 button { float: right; font: inherit; text-transform: none; letter-spacing: 0; - color: var(--muted); background: none; border: 1px solid var(--line); border-radius: 4px; - padding: 0 6px; cursor: pointer; } .tree pre { margin: 2px 0 4px 1em; white-space: pre-wrap; word-break: break-all; max-height: 20em; overflow: auto; } .note { color: var(--muted); font-style: italic; } @@ -128,7 +125,7 @@
-

Document

+

Document

@@ -538,16 +535,21 @@ function renderGraph() { root.replaceChildren(...children); } -// --- Document: read a level at a time (`/store?shallow=true`) as it is opened --- -// What is open, by path (as JSON), kept across reloads of the tree: the first level -// to begin with. +// --- Document: read a level at a time (`/store?shallow=true`) as it is opened, and +// read again where the node says it changed (`document` events) --- +// What is open, by path (as JSON): the first level to begin with. const documentOpen = new Set([JSON.stringify([])]); +// The open branches read, by path (as JSON): to read again on a change. +const documentBranches = new Map(); function storeUrl(path, shallow) { const where = path.length ? "../store/" + path.map(encodeURIComponent).join("/") : "../store"; return where + (shallow ? "?shallow=true" : ""); } // Store paths split at "/": a key with one (or an empty key) can't be read on its own. const addressable = path => path.every(segment => segment !== "" && !segment.includes("/")); +// Either is within the other. +const related = (a, b) => a.length <= b.length + ? a.every((segment, i) => segment === b[i]) : related(b, a); function describe(summary) { switch (summary.type) { case "map": return "{" + summary.size + (summary.size === 1 ? " key}" : " keys}"); @@ -561,51 +563,85 @@ async function readStore(path, shallow) { if (!response.ok) throw new Error("HTTP " + response.status); return response.json(); } -// One entry: a short scalar as it is; anything else a branch read when opened. +// One entry: a short scalar as it is; anything else a branch read when opened. A +// branch's entries are kept across reads, so what's open inside stays open. function documentEntry(key, summary, path) { if ("value" in summary) { return el("div", {}, el("span", { class: "key" }, key + ": "), JSON.stringify(summary.value)); } const details = el("details", {}); - details.append(el("summary", {}, el("span", { class: "key" }, key), " ", describe(summary))); + const described = document.createTextNode(describe(summary)); + details.append(el("summary", {}, el("span", { class: "key" }, key), " ", described)); + // A newer summary from the parent: a branch of the same kind stays as it is. + details.updated = newer => { + summary = newer; + described.textContent = describe(newer); + }; + details.sameKind = newer => !("value" in newer) && newer.type === summary.type; if (!addressable(path)) { details.append(el("div", { class: "note" }, "Its path can't be read on its own (a key with \"/\").")); return details; } const id = JSON.stringify(path); + const children = new Map(); let read = false; - const load = async () => { - if (read) return; - read = true; + const fill = async () => { try { if (summary.type === "map") { const shallow = await readStore(path, true); - // As it is now (the root's is unknown until read). - details.querySelector("summary").lastChild.textContent = describe(shallow); - const entries = Object.entries(shallow.entries || {}); - if (!entries.length) details.append(el("div", { class: "note" }, "empty")); - for (const [k, child] of entries) details.append(documentEntry(k, child, [...path, k])); + details.updated(shallow); + const entries = shallow.entries || {}; + for (const [k, child] of children) { + if (!(k in entries)) { child.element.remove(); children.delete(k); } + } + for (const [k, newer] of Object.entries(entries)) { + const known = children.get(k); + if (known && JSON.stringify(known.summary) === JSON.stringify(newer)) continue; + if (known && known.element.sameKind && known.element.sameKind(newer)) { + known.element.updated(newer); + known.summary = newer; + continue; + } + const element = documentEntry(k, newer, [...path, k]); + if (known) known.element.replaceWith(element); + children.set(k, { element, summary: newer }); + } + // In the node's order (sorted); moving keeps their state. + details.querySelectorAll(":scope > .note").forEach(note => note.remove()); + if (!children.size) details.append(el("div", { class: "note" }, "empty")); + for (const k of Object.keys(entries)) details.append(children.get(k).element); } else { const value = await readStore(path, false); - details.append(el("pre", {}, - typeof value === "string" ? value : JSON.stringify(value, null, 2))); + const shown = el("pre", {}, typeof value === "string" ? value : JSON.stringify(value, null, 2)); + const before = details.querySelector(":scope > pre"); + if (before) before.replaceWith(shown); else details.append(shown); } } catch (error) { + details.querySelectorAll(":scope > .note").forEach(note => note.remove()); details.append(el("div", { class: "note" }, "Not read: " + error.message)); } }; + const open = () => { + documentOpen.add(id); + documentBranches.set(id, { path, element: details, refresh: fill }); + if (!read) { read = true; fill(); } + }; details.addEventListener("toggle", () => { - if (details.open) { documentOpen.add(id); load(); } else documentOpen.delete(id); + if (details.open) open(); + else { documentOpen.delete(id); documentBranches.delete(id); } }); - if (documentOpen.has(id)) { details.open = true; load(); } + if (documentOpen.has(id)) { details.open = true; open(); } return details; } -function renderDocument() { - const root = documentEntry("store", { type: "map", size: "…" }, []); - document.getElementById("document").replaceChildren(root); +// The node says what changed (paths), or that anything may have (`all`). +function documentChanged(change) { + for (const [id, branch] of documentBranches) { + if (!branch.element.isConnected) { documentBranches.delete(id); continue; } + if (change.all || change.paths.some(path => related(path, branch.path))) branch.refresh(); + } } -document.getElementById("document-reload").addEventListener("click", renderDocument); -renderDocument(); +document.getElementById("document").replaceChildren( + documentEntry("store", { type: "map", size: "…" }, [])); function render() { if (!state) return; @@ -650,6 +686,8 @@ function connect() { location.reload(); return; } + // Changes while not connected went unseen: what is open is read again. + if (state !== null) documentChanged({ all: true }); state = snapshot; stateAt = Date.now(); live = true; status("live", true); pulse(); render(); }); @@ -657,6 +695,7 @@ function connect() { if (!state) return; state = mergePatch(state, JSON.parse(event.data)); stateAt = Date.now(); pulse(); render(); }); + events.addEventListener("document", event => { pulse(); documentChanged(JSON.parse(event.data)); }); // The node stops: it says so, and ends the stream. Back once it is. events.addEventListener("offline", () => { events.close(); live = false; diff --git a/src/api/dashboard/mod.rs b/src/api/dashboard/mod.rs index 99a0b83..7679787 100644 --- a/src/api/dashboard/mod.rs +++ b/src/api/dashboard/mod.rs @@ -56,6 +56,9 @@ struct Hub { latest: watch::Sender>>, /// Each patch, to the clients connected. patches: broadcast::Sender>, + /// Where the document changed since the last tick, to the clients connected: + /// `{"paths": [...]}`, or `{"all": true}` if too many or not known. + documents: broadcast::Sender>, control: Mutex, /// 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). @@ -72,10 +75,12 @@ impl Hub { fn new(state: AppState, closing: watch::Receiver) -> Arc { let (latest, _) = watch::channel(None); let (patches, _) = broadcast::channel(64); + let (documents, _) = broadcast::channel(64); Arc::new(Self { state, latest, patches, + documents, control: Mutex::new(Control { running: false, wanted_until: Instant::now(), @@ -120,6 +125,7 @@ impl Hub { let mut seq = 0u64; let mut graph = Graph::default(); let mut graph_due = Instant::now(); + let mut changes = self.state.change_events.subscribe(); loop { if Instant::now() >= graph_due { graph.refresh(&self.state).await; @@ -146,6 +152,9 @@ impl Hub { last = Some(snapshot); } tokio::time::sleep(TICK).await; + if let Some(changed) = document_changes(&mut changes) { + let _ = self.documents.send(Arc::new(changed.to_string())); + } { let mut control = self.control.lock_or_recover(); let unwanted = @@ -159,6 +168,41 @@ impl Hub { } } +/// Paths changed in a tick listed at most; more: `all`. +const MAX_CHANGED: usize = 256; + +/// Where the document changed since last asked (`Hub::documents`), if anywhere. +fn document_changes( + changes: &mut broadcast::Receiver>, +) -> Option { + let mut paths = BTreeSet::new(); + let mut all = false; + loop { + match changes.try_recv() { + Ok(set) => { + paths.extend(set.paths.iter().cloned()); + all |= paths.len() > MAX_CHANGED; + } + Err(broadcast::error::TryRecvError::Lagged(_)) => all = true, + Err(_) => break, + } + } + if all { + Some(json!({ "all": true })) + } else if paths.is_empty() { + None + } else { + Some(json!({ "paths": paths })) + } +} + +/// What a client's stream waits for. +enum Next { + Patch(Result, broadcast::error::RecvError>), + Document(Result, broadcast::error::RecvError>), + Closing, +} + /// The intended graph, recomputed every `LINKS_EVERY` (it costs about `members²`), when /// members are asked for their links too. #[derive(Default)] @@ -360,11 +404,13 @@ async fn current_state(Extension(hub): Extension>) -> Response { /// `GET /admin/dashboard/events`: Server-Sent Events: a `snapshot` first, then a /// `patch` whenever something changed (each with its number as the event's ID; a -/// client that fell behind gets a `snapshot` again), and `offline` when the node -/// stops, which ends the stream. +/// client that fell behind gets a `snapshot` again), a `document` where the document +/// changed (`Hub::documents`, at most every tick), and `offline` when the node stops, +/// which ends the stream. async fn events(Extension(hub): Extension>) -> impl IntoResponse { // Subscribed before the snapshot is read: no patch after it is missed. let mut patches = hub.patches.subscribe(); + let mut documents = hub.documents.subscribe(); hub.want(); let first = hub.current().await; let stream = async_stream::stream! { @@ -375,13 +421,26 @@ async fn events(Extension(hub): Extension>) -> impl IntoResponse { let mut seen = first.seq; yield Ok(snapshot_event(&first)); loop { - let received = tokio::select! { - received = patches.recv() => Some(received), - () = hub.closed() => None, + let next = tokio::select! { + received = patches.recv() => Next::Patch(received), + changed = documents.recv() => Next::Document(changed), + () = hub.closed() => Next::Closing, }; - let Some(received) = received else { - yield Ok(offline_event()); - break; + let received = match next { + Next::Patch(received) => received, + Next::Document(Ok(changed)) => { + yield Ok(Event::default().event("document").data(changed.as_str())); + continue; + } + Next::Document(Err(broadcast::error::RecvError::Lagged(_))) => { + yield Ok(Event::default().event("document").data(r#"{"all":true}"#)); + continue; + } + Next::Document(Err(broadcast::error::RecvError::Closed)) => break, + Next::Closing => { + yield Ok(offline_event()); + break; + } }; match received { Ok(patch) if patch.seq <= seen => {} -- cgit v1.3.1