feat(admin): audit-log viewer

Surface the admin_audit table (written by every mutating admin action but
previously unreadable) as a new /admin/audit tab. New repo::admin_audit::list
(LEFT JOIN users for the actor's username, filter by action/target_kind/actor/
since, at DESC via admin_audit_at_idx) behind GET /v1/admin/audit, mirroring the
crawler history endpoint's paged/clamped idiom.

Frontend: a self-contained AuditTable (target-kind + window + action filters,
expandable JSON payload, AbortController-cancelled fetches, loading/error/empty
states) and shared fmtAgo() in format.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-19 11:26:09 +02:00
parent dd300a150c
commit 31013cc893
11 changed files with 748 additions and 5 deletions

View File

@@ -0,0 +1,70 @@
//! GET /admin/audit — paginated, filterable admin-action audit log.
//!
//! A pure DB read over the `admin_audit` table joined to the actor's
//! username. Drives the "Audit" admin tab: who did what, when. Every
//! mutating admin action writes a row here; this is the only reader.
use axum::extract::{Query, State};
use axum::routing::get;
use axum::{Json, Router};
use serde::Deserialize;
use uuid::Uuid;
use crate::app::AppState;
use crate::auth::extractor::RequireAdmin;
use crate::error::AppResult;
use crate::repo;
use crate::repo::admin_audit::{AuditEntryRow, AuditFilter};
// Reuse the analysis window helper so `days` clamps identically everywhere.
use crate::api::admin::analysis::window_since;
pub fn routes() -> Router<AppState> {
Router::new().route("/admin/audit", get(list_audit))
}
fn default_limit() -> i64 {
50
}
#[derive(Debug, Deserialize, Default)]
struct AuditParams {
#[serde(default)]
action: Option<String>,
#[serde(default)]
target_kind: Option<String>,
#[serde(default)]
actor_user_id: Option<Uuid>,
#[serde(default)]
days: i64,
#[serde(default = "default_limit")]
limit: i64,
#[serde(default)]
offset: i64,
}
async fn list_audit(
State(state): State<AppState>,
_admin: RequireAdmin,
Query(params): Query<AuditParams>,
) -> AppResult<Json<crate::api::pagination::PagedResponse<AuditEntryRow>>> {
let limit = params.limit.clamp(1, 200);
let offset = params.offset.max(0);
let action = params.action.filter(|s| !s.trim().is_empty());
let target_kind = params.target_kind.filter(|s| !s.trim().is_empty());
let (items, total) = repo::admin_audit::list(
&state.db,
AuditFilter {
action: action.as_deref(),
target_kind: target_kind.as_deref(),
actor_user_id: params.actor_user_id,
since: window_since(params.days),
},
limit,
offset,
)
.await?;
Ok(Json(crate::api::pagination::PagedResponse::with_total(
items, limit, offset, total,
)))
}

View File

@@ -5,6 +5,7 @@
//! `crate::auth::extractor::RequireAdmin`).
pub mod analysis;
pub mod audit;
pub mod crawler;
pub mod mangas;
pub mod overview;
@@ -26,6 +27,7 @@ pub fn routes() -> Router<AppState> {
.merge(storage::routes())
.merge(system::routes())
.merge(overview::routes())
.merge(audit::routes())
.merge(crawler::routes())
.merge(analysis::routes())
.merge(settings::routes())

View File

@@ -1,14 +1,98 @@
//! Admin-action audit log writes.
//! Admin-action audit log writes + the admin-facing read query.
//!
//! Insert is always called from inside the same transaction as the
//! action it audits — the executor parameter is `PgExecutor` so the
//! caller passes `&mut *tx` directly.
//! caller passes `&mut *tx` directly. [`list`] backs the audit-log viewer.
use sqlx::PgExecutor;
use chrono::{DateTime, Utc};
use serde::Serialize;
use sqlx::{FromRow, PgExecutor, PgPool};
use uuid::Uuid;
use crate::error::AppResult;
/// One audit row joined to the actor's current username (NULL when the
/// actor account was deleted — `actor_user_id` is `ON DELETE SET NULL`).
#[derive(Debug, Clone, Serialize, FromRow)]
pub struct AuditEntryRow {
pub id: Uuid,
pub actor_user_id: Option<Uuid>,
pub actor_username: Option<String>,
pub action: String,
pub target_kind: String,
pub target_id: Option<Uuid>,
pub payload: serde_json::Value,
pub at: DateTime<Utc>,
}
/// Optional filters for [`list`]. `None`/absent widens the scope; an
/// empty-string action/target_kind is treated as absent by the caller.
#[derive(Debug, Default, Clone)]
pub struct AuditFilter<'a> {
pub action: Option<&'a str>,
pub target_kind: Option<&'a str>,
pub actor_user_id: Option<Uuid>,
pub since: Option<DateTime<Utc>>,
}
/// Paginated, newest-first audit log. Returns the page slice plus the
/// filtered total. Ordering by `at DESC` uses `admin_audit_at_idx`.
pub async fn list(
pool: &PgPool,
filter: AuditFilter<'_>,
limit: i64,
offset: i64,
) -> AppResult<(Vec<AuditEntryRow>, i64)> {
let items = sqlx::query_as::<_, AuditEntryRow>(
r#"
SELECT
a.id,
a.actor_user_id,
u.username AS actor_username,
a.action,
a.target_kind,
a.target_id,
a.payload,
a.at
FROM admin_audit a
LEFT JOIN users u ON u.id = a.actor_user_id
WHERE ($1::text IS NULL OR a.action = $1)
AND ($2::text IS NULL OR a.target_kind = $2)
AND ($3::uuid IS NULL OR a.actor_user_id = $3)
AND ($4::timestamptz IS NULL OR a.at >= $4)
ORDER BY a.at DESC
LIMIT $5 OFFSET $6
"#,
)
.bind(filter.action)
.bind(filter.target_kind)
.bind(filter.actor_user_id)
.bind(filter.since)
.bind(limit)
.bind(offset)
.fetch_all(pool)
.await?;
let (total,): (i64,) = sqlx::query_as(
r#"
SELECT COUNT(*)
FROM admin_audit a
WHERE ($1::text IS NULL OR a.action = $1)
AND ($2::text IS NULL OR a.target_kind = $2)
AND ($3::uuid IS NULL OR a.actor_user_id = $3)
AND ($4::timestamptz IS NULL OR a.at >= $4)
"#,
)
.bind(filter.action)
.bind(filter.target_kind)
.bind(filter.actor_user_id)
.bind(filter.since)
.fetch_one(pool)
.await?;
Ok((items, total))
}
pub async fn insert<'e, E: PgExecutor<'e>>(
executor: E,
actor_user_id: Uuid,