From b2c5096744c99453856f7281251b3f0cb4b9baae Mon Sep 17 00:00:00 2001 From: tv Date: Mon, 9 Nov 2026 05:22:14 +0000 Subject: dashboard: a subscribed node's message written out, read from it when shown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A subscribed node's messages come without being written out (their stream leaves the text out: up to 8 KiB each, 500 at a time), so the figures box said only when, both ends, type and size of one. Now the node answers for one message by its number, `GET /admin/dashboard/messages/own/{seq}` (`{"text": …}`, `404` once it isn't kept), which pages of other origins may call as they do its streams (`cors::open`), and the page reads it when the message is shown, with its narrow token, and keeps it on the message. Where it can't be read, the box says why. --- src/api/cors.rs | 25 ++++++++++++++++--------- src/api/dashboard/dashboard.html | 35 +++++++++++++++++++++++++++++------ src/api/dashboard/mod.rs | 15 +++++++++++++++ src/transport/traffic.rs | 10 ++++++++++ 4 files changed, 70 insertions(+), 15 deletions(-) (limited to 'src') diff --git a/src/api/cors.rs b/src/api/cors.rs index 406b7be..fc8270d 100644 --- a/src/api/cors.rs +++ b/src/api/cors.rs @@ -1,6 +1,6 @@ //! Pages of other origins reading this node: the dashboards of other members, which a //! page subscribes to for their own links and messages (`/admin/dashboard/links/own`, -//! `/admin/dashboard/messages/own/events`), refreshing its +//! `/admin/dashboard/messages/own/events`, and one written out), refreshing its //! token on any node (`/auth/refresh`, `/auth/whoami`). Only those endpoints, only for //! the origins of the dashboards the member list names and those of `--cors-origins`, //! and without credentials: a page sends its token itself (`Authorization: Bearer`), @@ -18,14 +18,19 @@ use crate::swarm::members; /// The endpoints other origins may call. fn open(path: &str) -> bool { - matches!( - path, - "/admin/dashboard/links/own" - | "/admin/dashboard/links/own/events" - | "/admin/dashboard/messages/own/events" - | "/auth/refresh" - | "/auth/whoami" - ) + // A message written out (`/admin/dashboard/messages/own/{seq}`). + let message = path + .strip_prefix("/admin/dashboard/messages/own/") + .is_some_and(|seq| !seq.is_empty() && seq.bytes().all(|byte| byte.is_ascii_digit())); + message + || matches!( + path, + "/admin/dashboard/links/own" + | "/admin/dashboard/links/own/events" + | "/admin/dashboard/messages/own/events" + | "/auth/refresh" + | "/auth/whoami" + ) } /// `url`'s origin (`scheme://host[:port]`), if it is a URL with one. @@ -138,6 +143,8 @@ mod tests { #[test] fn only_some_endpoints() { assert!(open("/admin/dashboard/links/own")); + assert!(open("/admin/dashboard/messages/own/42")); + assert!(!open("/admin/dashboard/messages/own/x")); assert!(open("/auth/refresh")); assert!(!open("/auth/login")); assert!(!open("/store")); diff --git a/src/api/dashboard/dashboard.html b/src/api/dashboard/dashboard.html index 9cfb384..6d32d55 100644 --- a/src/api/dashboard/dashboard.html +++ b/src/api/dashboard/dashboard.html @@ -876,8 +876,9 @@ function renderStats() { el("span", { class: "key" }, key + ": "), value); title.replaceChildren("Message: ", what); retitle(title, title.textContent); - // A subscribed node's: logged there, the message itself not read from it. + // A subscribed node's: logged there, written out as read from it. if (message.node) { + const text = el("pre", { class: "message" }, "reading it from " + name(message.node) + "…"); document.getElementById("stats").replaceChildren( field("at", clockTime(new Date(message.at))), field("from", message.from), @@ -886,7 +887,12 @@ function renderStats() { ...(message.address ? [field("address", message.address)] : []), field("type", message.type), field("bytes", String(message.bytes)), - el("div", { class: "note" }, "Written out only on the node that logged it (its console).")); + text); + writtenOut(message).then(written => { text.textContent = written; }, + error => { + text.replaceWith(el("div", { class: "note" }, "Not read from " + name(message.node) + + ": " + (error.message || String(error)))); + }); return; } document.getElementById("stats").replaceChildren( @@ -2842,9 +2848,7 @@ function follow(id, delay = 2000) { const member = state && state.graph.members[id]; if (!member || !member.dashboard) return fail("it publishes no dashboard (--dashboard-url)"); if (!narrowToken) return fail(tokenWhy); - // Its endpoints, beside its page (`…/admin/dashboard` → `…/admin/dashboard/…`). - const base = member.dashboard.split("#")[0].replace(/\/+$/, ""); - const at = path => new URL(path, base).href; + const at = path => endpointOf(id, path); let url; try { url = at("dashboard/links/own/events"); @@ -2881,6 +2885,25 @@ function follow(id, delay = 2000) { }; followMessages(); } +// A member's endpoint, beside the page it publishes (`…/admin/dashboard` → +// `…/admin/dashboard/`); throws where its address isn't one. +function endpointOf(id, path) { + const member = state && state.graph.members[id]; + const base = ((member && member.dashboard) || "").split("#")[0].replace(/\/+$/, ""); + return new URL(path, base).href; +} +// A subscribed node's message written out, read from it (its console's +// `messages/own/{seq}`) with this page's narrow token, kept on the message once read. +async function writtenOut(message) { + if (typeof message.text === "string") return message.text; + if (!narrowToken) throw new Error(tokenWhy); + const response = await fetch(endpointOf(message.node, "dashboard/messages/own/" + message.ownSeq), + { cache: "no-store", headers: { Authorization: "Bearer " + narrowToken.token } }); + const answer = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(response.status + " " + (answer.error || response.statusText)); + message.text = String(answer.text); + return message.text; +} // Server-sent events from `url`, read with this page's narrow token (`EventSource` // can't send one): `opened` once answered, `event(kind, data)` for each; rejects when it // fails or ends (`offline`: the node stopped). @@ -3581,7 +3604,7 @@ function remoteMessagesArrived(id, entry, list, sent) { for (const message of list) { if (message.way === "refused") continue; const [from, to] = message.way === "sent" ? [id, message.peer] : [message.peer, id]; - const made = { ...message, seq: id + ":" + message.seq, node: id, from, to, + const made = { ...message, seq: id + ":" + message.seq, ownSeq: message.seq, node: id, from, to, related: message.related ? id + ":" + message.related : null }; if (message.way === "sent") noteDelivery(from, to, message.counted, "sent"); else noteDelivery(from, to, message.counted, "got"); diff --git a/src/api/dashboard/mod.rs b/src/api/dashboard/mod.rs index 7a643d0..3a50ca6 100644 --- a/src/api/dashboard/mod.rs +++ b/src/api/dashboard/mod.rs @@ -509,6 +509,7 @@ pub(crate) fn router(state: AppState) -> Router { "/admin/dashboard/messages/own/events", get(own_messages_events), ) + .route("/admin/dashboard/messages/own/{seq}", get(own_message)) .layer(Extension(Hub::new(state, closing))) } @@ -573,6 +574,20 @@ impl Drop for Watching { } } +/// `GET /admin/dashboard/messages/own/{seq}`: the message `seq` of this node's +/// (`messages/own/events`, which leaves it out) written out, `{"text": …}`, for a page +/// subscribed to this node showing it; `404` once it isn't kept any more (the latest +/// 1000, while someone looks). +async fn own_message( + Extension(hub): Extension>, + axum::extract::Path(seq): axum::extract::Path, +) -> axum::response::Response { + match hub.state.traffic.log.text_of(seq) { + Some(text) => axum::Json(json!({ "text": text })).into_response(), + None => super::error_response(axum::http::StatusCode::NOT_FOUND, "not kept any more", None), + } +} + /// `GET /admin/dashboard/messages/own/events`: server-sent events, `messages` with the /// messages this node sent and received since the last, as they come (gathered for /// `MESSAGES_GATHERED` after the first), as the page's own `messages` events have them, diff --git a/src/transport/traffic.rs b/src/transport/traffic.rs index bac0da7..5ff588e 100644 --- a/src/transport/traffic.rs +++ b/src/transport/traffic.rs @@ -325,6 +325,16 @@ impl MessageLog { } /// The messages after `seq`, the latest `most` of them. + /// The message `seq` written out, while it is kept. + pub(crate) fn text_of(&self, seq: u64) -> Option { + let inner = self.inner.lock_or_recover(); + inner + .entries + .iter() + .find(|entry| entry.seq == seq) + .map(|entry| entry.text.clone()) + } + pub(crate) fn after(&self, seq: u64, most: usize) -> Vec { let inner = self.inner.lock_or_recover(); let newer: Vec<&Logged> = inner.entries.iter().filter(|e| e.seq > seq).collect(); -- cgit v1.3.1