diff options
| author | tv <tv@krebsco.de> | 2026-11-01 22:27:22 +0100 |
|---|---|---|
| committer | tv <tv@krebsco.de> | 2026-11-01 22:27:22 +0100 |
| commit | 79dfdd35c36ade1f151e0731b217cabbc1b684f1 (patch) | |
| tree | 7905aa268d52e18c7bf6ec3d1c8216fc3bc90160 /src/api/changes.rs | |
| parent | 656cd21ce2625a8a2931b5b03d1d84227dd7e3e8 (diff) | |
api: introduce /admin/changes
Diffstat (limited to 'src/api/changes.rs')
| -rw-r--r-- | src/api/changes.rs | 137 |
1 files changed, 137 insertions, 0 deletions
diff --git a/src/api/changes.rs b/src/api/changes.rs new file mode 100644 index 0000000..1dd1f91 --- /dev/null +++ b/src/api/changes.rs @@ -0,0 +1,137 @@ +//! `GET /admin/changes[/<path>]`: who wrote what, newest first, of the whole document +//! or of what is at a path (as `/store/<path>` names it). Every change names the node that +//! made it (its author) and, for a write through the API, the user it was made for (its +//! message), with when (its writer's clock) and the store paths it touched +//! (`document::replica::Writer`). Every node holds every change, so any node answers +//! for the whole swarm, as far as it has synced. + +use axum::{ + Json, + extract::{Path, Query, State}, + http::StatusCode, +}; +use serde::{Deserialize, Serialize}; + +use crate::document::store::{self, InLists}; +use crate::state::AppState; + +/// Changes listed at most. +const MAX_LIMIT: usize = 1000; +/// Changes looked at at most, newest first, for those touching `path`: each costs a +/// diff, under the document's read lock. +const MAX_SCANNED: usize = 10_000; + +#[derive(Debug, Deserialize)] +pub(crate) struct ChangesQuery { + /// How many, at most (100 by default, up to `MAX_LIMIT`). + limit: Option<usize>, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ChangeReport { + hash: String, + /// When, by its writer's clock (Unix milliseconds); 0 for a remake's snapshot. + at: i64, + /// The node that made it (its node ID), if it says. + #[serde(skip_serializing_if = "Option::is_none")] + node: Option<String>, + /// The user it was written for through the API, if any. + #[serde(skip_serializing_if = "Option::is_none")] + user: Option<String>, + /// The store paths it touched (a list's items by index). + paths: Vec<String>, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ChangesReport { + changes: Vec<ChangeReport>, + /// Older ones weren't looked at (`MAX_SCANNED`, or `limit` reached). + more: bool, +} + +/// `GET /admin/changes`: all of them. +pub(crate) async fn get_changes( + State(state): State<AppState>, + Query(query): Query<ChangesQuery>, +) -> Result<Json<ChangesReport>, StatusCode> { + report(state, query, None).await +} + +/// `GET /admin/changes/{key}`: those that touched what is at the path, inside it, or +/// above it. +pub(crate) async fn get_changes_at( + State(state): State<AppState>, + Path(key): Path<String>, + Query(query): Query<ChangesQuery>, +) -> Result<Json<ChangesReport>, StatusCode> { + let segments: Vec<String> = key.split('/').map(str::to_owned).collect(); + if segments.iter().any(String::is_empty) { + return Err(StatusCode::BAD_REQUEST); + } + report(state, query, Some(segments)).await +} + +async fn report( + state: AppState, + query: ChangesQuery, + path: Option<Vec<String>>, +) -> Result<Json<ChangesReport>, StatusCode> { + if !state.is_ready().await { + return Err(StatusCode::SERVICE_UNAVAILABLE); + } + let limit = query.limit.unwrap_or(100).clamp(1, MAX_LIMIT); + Ok(Json( + state + .render( + move |doc| changes(doc, limit, path.as_deref()), + |report| report, + ) + .await, + )) +} + +fn changes( + doc: &crate::document::Document, + limit: usize, + path: Option<&[String]>, +) -> ChangesReport { + let all = doc.get_changes_meta(&[]); + let mut changes = Vec::new(); + let mut scanned = 0; + let mut more = false; + for meta in all.iter().rev() { + if changes.len() == limit || scanned == MAX_SCANNED { + more = true; + break; + } + scanned += 1; + // What this change did: its history without it, to its history with it. + let mut paths: Vec<Vec<String>> = + store::changed(doc, &meta.deps, &[meta.hash], InLists::Index) + .into_iter() + .map(|changed| changed.path) + .collect(); + paths.sort(); + paths.dedup(); + if let Some(path) = path + && !paths + .iter() + .any(|touched| touched.starts_with(path) || path.starts_with(touched)) + { + continue; + } + changes.push(ChangeReport { + hash: meta.hash.to_string(), + at: meta.timestamp, + node: meta + .author + .as_ref() + .and_then(|author| String::from_utf8(author.as_bytes().to_vec()).ok()), + user: meta.message.as_ref().map(|message| message.to_string()), + paths: paths.iter().map(|path| path.join("/")).collect(), + }); + } + ChangesReport { changes, more } +} |
