aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/openapi.yaml107
-rw-r--r--src/api/changes.rs137
-rw-r--r--src/api/mod.rs3
-rw-r--r--src/document/replica.rs2
4 files changed, 248 insertions, 1 deletions
diff --git a/docs/openapi.yaml b/docs/openapi.yaml
index 662b46b..0c24110 100644
--- a/docs/openapi.yaml
+++ b/docs/openapi.yaml
@@ -458,6 +458,113 @@ paths:
'503':
description: Node is not ready.
+ /admin/changes:
+ get:
+ tags: [Admin]
+ summary: Who wrote what
+ description: |
+ The document's changes, newest first: each with when (its writer's clock, Unix
+ ms; 0 for a remake's snapshot), the node that made it (`node`, its node ID) and,
+ for a write through the API, the user it was made for (`user`), and the store
+ paths it touched (a list's items by index). Every node holds every change, so
+ any node answers for the whole swarm, as far as it has synced; history before
+ the last remake is gone with it (`remakes.md`). Changes made before nodes
+ recorded their writers have neither `node` nor `user`.
+ parameters:
+ - name: limit
+ in: query
+ description: How many at most (default 100, at most 1000).
+ schema:
+ type: integer
+ minimum: 1
+ responses:
+ '200':
+ description: The changes.
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [changes, more]
+ properties:
+ changes:
+ type: array
+ items:
+ type: object
+ required: [hash, at, paths]
+ properties:
+ hash:
+ type: string
+ at:
+ type: integer
+ node:
+ type: string
+ user:
+ type: string
+ paths:
+ type: array
+ items:
+ type: string
+ more:
+ type: boolean
+ description: Older changes weren't looked at.
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '503':
+ description: Node is not ready.
+
+ /admin/changes/{key}:
+ get:
+ tags: [Admin]
+ summary: Who wrote what, at a path
+ description: |
+ As `/admin/changes`, but only the changes that touched what is at the path (as
+ `/store/{key}` names it), inside it or above it. At most 10000 changes are
+ looked at for it.
+ parameters:
+ - $ref: '#/components/parameters/key'
+ - name: limit
+ in: query
+ description: How many at most (default 100, at most 1000).
+ schema:
+ type: integer
+ minimum: 1
+ responses:
+ '200':
+ description: The changes.
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [changes, more]
+ properties:
+ changes:
+ type: array
+ items:
+ type: object
+ required: [hash, at, paths]
+ properties:
+ hash:
+ type: string
+ at:
+ type: integer
+ node:
+ type: string
+ user:
+ type: string
+ paths:
+ type: array
+ items:
+ type: string
+ more:
+ type: boolean
+ description: Older changes weren't looked at.
+ '400':
+ description: A path with an empty segment.
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '503':
+ description: Node is not ready.
+
/admin/config:
get:
tags: [Admin]
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 }
+}
diff --git a/src/api/mod.rs b/src/api/mod.rs
index 31f4fea..b1f865a 100644
--- a/src/api/mod.rs
+++ b/src/api/mod.rs
@@ -2,6 +2,7 @@
//! own are in `store`).
pub(crate) mod auth;
+mod changes;
mod config;
#[cfg(feature = "dashboard")]
mod dashboard;
@@ -55,6 +56,8 @@ pub(crate) fn router(state: AppState, auth_state: AuthState, debug_api: bool) ->
)
.route("/admin/stats", get(stats::get_stats))
.route("/admin/document", get(document::get_document))
+ .route("/admin/changes", get(changes::get_changes))
+ .route("/admin/changes/{*key}", get(changes::get_changes_at))
.route("/admin/config", get(config::get_config))
.merge(crate::remake::router::<AppState>());
diff --git a/src/document/replica.rs b/src/document/replica.rs
index 3717865..a6fc504 100644
--- a/src/document/replica.rs
+++ b/src/document/replica.rs
@@ -269,7 +269,7 @@ mod tests {
/// Who makes a change, as it records: the node, as the change's author (its node ID),
/// and the user it wrote for through the API, if any, as its message. Every node holds
-/// every change, so who wrote what is known swarm-wide.
+/// every change, so who wrote what is known swarm-wide (`GET /admin/changes`).
pub(crate) struct Writer<'a> {
pub(crate) node: &'a str,
pub(crate) user: Option<&'a str>,