aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-11-09 05:22:14 +0000
committertv <tv@krebsco.de>2026-11-09 06:30:44 +0100
commitb2c5096744c99453856f7281251b3f0cb4b9baae (patch)
treecf1ff79be48513eda4b901c7197b452c557547d7 /src
parentd7e3d9f8310aa40903f43272fbb30f37a1f68c2b (diff)
dashboard: a subscribed node's message written out, read from it when shown
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.
Diffstat (limited to 'src')
-rw-r--r--src/api/cors.rs25
-rw-r--r--src/api/dashboard/dashboard.html35
-rw-r--r--src/api/dashboard/mod.rs15
-rw-r--r--src/transport/traffic.rs10
4 files changed, 70 insertions, 15 deletions
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/<path>`); 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<AppState> {
"/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<Arc<Hub>>,
+ axum::extract::Path(seq): axum::extract::Path<u64>,
+) -> 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<String> {
+ 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<Logged> {
let inner = self.inner.lock_or_recover();
let newer: Vec<&Logged> = inner.entries.iter().filter(|e| e.seq > seq).collect();