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:
70
backend/src/api/admin/audit.rs
Normal file
70
backend/src/api/admin/audit.rs
Normal 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,
|
||||||
|
)))
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
//! `crate::auth::extractor::RequireAdmin`).
|
//! `crate::auth::extractor::RequireAdmin`).
|
||||||
|
|
||||||
pub mod analysis;
|
pub mod analysis;
|
||||||
|
pub mod audit;
|
||||||
pub mod crawler;
|
pub mod crawler;
|
||||||
pub mod mangas;
|
pub mod mangas;
|
||||||
pub mod overview;
|
pub mod overview;
|
||||||
@@ -26,6 +27,7 @@ pub fn routes() -> Router<AppState> {
|
|||||||
.merge(storage::routes())
|
.merge(storage::routes())
|
||||||
.merge(system::routes())
|
.merge(system::routes())
|
||||||
.merge(overview::routes())
|
.merge(overview::routes())
|
||||||
|
.merge(audit::routes())
|
||||||
.merge(crawler::routes())
|
.merge(crawler::routes())
|
||||||
.merge(analysis::routes())
|
.merge(analysis::routes())
|
||||||
.merge(settings::routes())
|
.merge(settings::routes())
|
||||||
|
|||||||
@@ -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
|
//! Insert is always called from inside the same transaction as the
|
||||||
//! action it audits — the executor parameter is `PgExecutor` so 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 uuid::Uuid;
|
||||||
|
|
||||||
use crate::error::AppResult;
|
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>>(
|
pub async fn insert<'e, E: PgExecutor<'e>>(
|
||||||
executor: E,
|
executor: E,
|
||||||
actor_user_id: Uuid,
|
actor_user_id: Uuid,
|
||||||
|
|||||||
175
backend/tests/api_admin_audit.rs
Normal file
175
backend/tests/api_admin_audit.rs
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
//! Integration tests for the admin audit-log viewer: the `repo::admin_audit`
|
||||||
|
//! list query (filters, username join, NULL actor) and the
|
||||||
|
//! `GET /v1/admin/audit` endpoint (admin-gating, shape, filter params).
|
||||||
|
|
||||||
|
mod common;
|
||||||
|
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::Router;
|
||||||
|
use serde_json::json;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use tower::ServiceExt;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use common::{body_json, get_with_cookie, harness, register_user};
|
||||||
|
use mangalord::repo::admin_audit::{self, AuditFilter};
|
||||||
|
|
||||||
|
async fn seed_admin(pool: &PgPool, app: &Router) -> (Uuid, String) {
|
||||||
|
let (username, cookie) = register_user(app).await;
|
||||||
|
let u = mangalord::repo::user::find_by_username(pool, &username)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
mangalord::repo::user::set_is_admin_unchecked(pool, u.id, true)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
(u.id, cookie)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert an audit row with an explicit `at` so ordering/since are testable.
|
||||||
|
async fn insert_audit(
|
||||||
|
pool: &PgPool,
|
||||||
|
actor: Option<Uuid>,
|
||||||
|
action: &str,
|
||||||
|
target_kind: &str,
|
||||||
|
at: &str,
|
||||||
|
) {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO admin_audit (actor_user_id, action, target_kind, target_id, payload, at) \
|
||||||
|
VALUES ($1, $2, $3, NULL, $4, $5::timestamptz)",
|
||||||
|
)
|
||||||
|
.bind(actor)
|
||||||
|
.bind(action)
|
||||||
|
.bind(target_kind)
|
||||||
|
.bind(json!({ "k": action }))
|
||||||
|
.bind(at)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn list_joins_username_and_orders_newest_first(pool: PgPool) {
|
||||||
|
let h = harness(pool.clone());
|
||||||
|
let (admin_id, _cookie) = seed_admin(&pool, &h.app).await;
|
||||||
|
insert_audit(&pool, Some(admin_id), "crawler_run", "crawler", "2026-06-01T00:00:00Z").await;
|
||||||
|
insert_audit(&pool, None, "manga_resync", "manga", "2026-06-02T00:00:00Z").await;
|
||||||
|
|
||||||
|
let (items, total) = admin_audit::list(&pool, AuditFilter::default(), 50, 0)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(total, 2);
|
||||||
|
// Newest first.
|
||||||
|
assert_eq!(items[0].action, "manga_resync");
|
||||||
|
assert_eq!(items[1].action, "crawler_run");
|
||||||
|
// Username join: the admin actor resolves; the NULL actor stays None.
|
||||||
|
assert_eq!(items[1].actor_user_id, Some(admin_id));
|
||||||
|
assert!(items[1].actor_username.is_some());
|
||||||
|
assert_eq!(items[0].actor_user_id, None);
|
||||||
|
assert_eq!(items[0].actor_username, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn list_filters_by_action_target_kind_actor_and_since(pool: PgPool) {
|
||||||
|
let h = harness(pool.clone());
|
||||||
|
let (admin_id, _cookie) = seed_admin(&pool, &h.app).await;
|
||||||
|
insert_audit(&pool, Some(admin_id), "crawler_run", "crawler", "2026-05-01T00:00:00Z").await;
|
||||||
|
insert_audit(&pool, Some(admin_id), "manga_resync", "manga", "2026-06-10T00:00:00Z").await;
|
||||||
|
insert_audit(&pool, None, "crawler_run", "crawler", "2026-06-11T00:00:00Z").await;
|
||||||
|
|
||||||
|
// Filter by action.
|
||||||
|
let (items, total) = admin_audit::list(
|
||||||
|
&pool,
|
||||||
|
AuditFilter { action: Some("crawler_run"), ..Default::default() },
|
||||||
|
50,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(total, 2);
|
||||||
|
assert!(items.iter().all(|r| r.action == "crawler_run"));
|
||||||
|
|
||||||
|
// Filter by target_kind.
|
||||||
|
let (_items, total) = admin_audit::list(
|
||||||
|
&pool,
|
||||||
|
AuditFilter { target_kind: Some("manga"), ..Default::default() },
|
||||||
|
50,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(total, 1);
|
||||||
|
|
||||||
|
// Filter by actor.
|
||||||
|
let (_items, total) = admin_audit::list(
|
||||||
|
&pool,
|
||||||
|
AuditFilter { actor_user_id: Some(admin_id), ..Default::default() },
|
||||||
|
50,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(total, 2);
|
||||||
|
|
||||||
|
// Filter by since (only the two June rows).
|
||||||
|
let since = chrono::DateTime::parse_from_rfc3339("2026-06-01T00:00:00Z")
|
||||||
|
.unwrap()
|
||||||
|
.with_timezone(&chrono::Utc);
|
||||||
|
let (_items, total) = admin_audit::list(
|
||||||
|
&pool,
|
||||||
|
AuditFilter { since: Some(since), ..Default::default() },
|
||||||
|
50,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(total, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn endpoint_requires_admin(pool: PgPool) {
|
||||||
|
let h = harness(pool.clone());
|
||||||
|
let (_u, cookie) = register_user(&h.app).await;
|
||||||
|
let resp = h
|
||||||
|
.app
|
||||||
|
.oneshot(get_with_cookie("/api/v1/admin/audit", &cookie))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn endpoint_returns_paged_shape_and_honors_filter(pool: PgPool) {
|
||||||
|
let h = harness(pool.clone());
|
||||||
|
let (admin_id, cookie) = seed_admin(&pool, &h.app).await;
|
||||||
|
insert_audit(&pool, Some(admin_id), "crawler_run", "crawler", "2026-06-01T00:00:00Z").await;
|
||||||
|
insert_audit(&pool, Some(admin_id), "manga_resync", "manga", "2026-06-02T00:00:00Z").await;
|
||||||
|
|
||||||
|
// Unfiltered: both rows + paged envelope.
|
||||||
|
let resp = h
|
||||||
|
.app
|
||||||
|
.clone()
|
||||||
|
.oneshot(get_with_cookie("/api/v1/admin/audit", &cookie))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
let body = body_json(resp).await;
|
||||||
|
assert_eq!(body["page"]["total"], 2);
|
||||||
|
assert_eq!(body["items"].as_array().unwrap().len(), 2);
|
||||||
|
assert!(body["items"][0]["actor_username"].is_string());
|
||||||
|
|
||||||
|
// Filter by action.
|
||||||
|
let resp = h
|
||||||
|
.app
|
||||||
|
.clone()
|
||||||
|
.oneshot(get_with_cookie(
|
||||||
|
"/api/v1/admin/audit?action=manga_resync",
|
||||||
|
&cookie,
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let body = body_json(resp).await;
|
||||||
|
assert_eq!(body["page"]["total"], 1);
|
||||||
|
assert_eq!(body["items"][0]["action"], "manga_resync");
|
||||||
|
assert_eq!(body["items"][0]["target_kind"], "manga");
|
||||||
|
}
|
||||||
@@ -42,7 +42,8 @@ import {
|
|||||||
listAnalysisHistory,
|
listAnalysisHistory,
|
||||||
getCrawlerMetrics,
|
getCrawlerMetrics,
|
||||||
listCrawlerOps,
|
listCrawlerOps,
|
||||||
getAnalysisMetrics
|
getAnalysisMetrics,
|
||||||
|
listAuditLog
|
||||||
} from './admin';
|
} from './admin';
|
||||||
|
|
||||||
function ok(body: unknown, status = 200): Response {
|
function ok(body: unknown, status = 200): Response {
|
||||||
@@ -471,6 +472,40 @@ describe('admin crawler api client', () => {
|
|||||||
expect(fetchSpy.mock.calls[0][0]).toMatch(/\/v1\/admin\/crawler\/covers$/);
|
expect(fetchSpy.mock.calls[0][0]).toMatch(/\/v1\/admin\/crawler\/covers$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('listAuditLog GETs /v1/admin/audit with the paged envelope', async () => {
|
||||||
|
fetchSpy.mockResolvedValueOnce(
|
||||||
|
ok({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'a-1',
|
||||||
|
actor_user_id: 'u-1',
|
||||||
|
actor_username: 'alice',
|
||||||
|
action: 'crawler_run',
|
||||||
|
target_kind: 'crawler',
|
||||||
|
target_id: null,
|
||||||
|
payload: {},
|
||||||
|
at: '2026-06-01T00:00:00Z'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
page: { limit: 25, offset: 0, total: 1 }
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const r = await listAuditLog({ limit: 25 });
|
||||||
|
expect(r.items[0].action).toBe('crawler_run');
|
||||||
|
expect(fetchSpy.mock.calls[0][0]).toMatch(/\/v1\/admin\/audit\?limit=25$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('listAuditLog forwards action / target_kind / days filters', async () => {
|
||||||
|
fetchSpy.mockResolvedValueOnce(
|
||||||
|
ok({ items: [], page: { limit: 25, offset: 0, total: 0 } })
|
||||||
|
);
|
||||||
|
await listAuditLog({ action: 'manga_resync', targetKind: 'manga', days: 7 });
|
||||||
|
const url = fetchSpy.mock.calls[0][0] as string;
|
||||||
|
expect(url).toContain('action=manga_resync');
|
||||||
|
expect(url).toContain('target_kind=manga');
|
||||||
|
expect(url).toContain('days=7');
|
||||||
|
});
|
||||||
|
|
||||||
it('runCrawlerPass POSTs /v1/admin/crawler/run', async () => {
|
it('runCrawlerPass POSTs /v1/admin/crawler/run', async () => {
|
||||||
fetchSpy.mockResolvedValueOnce(ok({ started: true }));
|
fetchSpy.mockResolvedValueOnce(ok({ started: true }));
|
||||||
const r = await runCrawlerPass();
|
const r = await runCrawlerPass();
|
||||||
|
|||||||
@@ -978,3 +978,43 @@ export async function updateAnalysisSettings(
|
|||||||
body: JSON.stringify(settings)
|
body: JSON.stringify(settings)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- admin audit log -------------------------------------------------------
|
||||||
|
|
||||||
|
export type AuditEntry = {
|
||||||
|
id: string;
|
||||||
|
actor_user_id: string | null;
|
||||||
|
/** Actor's current username; null when the account was deleted. */
|
||||||
|
actor_username: string | null;
|
||||||
|
action: string;
|
||||||
|
target_kind: string;
|
||||||
|
target_id: string | null;
|
||||||
|
payload: unknown;
|
||||||
|
at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AuditPage = { items: AuditEntry[]; page: Page };
|
||||||
|
|
||||||
|
export type ListAuditOptions = {
|
||||||
|
action?: string;
|
||||||
|
targetKind?: string;
|
||||||
|
actorUserId?: string;
|
||||||
|
days?: number;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function listAuditLog(
|
||||||
|
opts: ListAuditOptions = {},
|
||||||
|
init?: RequestInit
|
||||||
|
): Promise<AuditPage> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (opts.action) params.set('action', opts.action);
|
||||||
|
if (opts.targetKind) params.set('target_kind', opts.targetKind);
|
||||||
|
if (opts.actorUserId) params.set('actor_user_id', opts.actorUserId);
|
||||||
|
if (opts.days != null && opts.days > 0) params.set('days', String(opts.days));
|
||||||
|
if (opts.limit != null) params.set('limit', String(opts.limit));
|
||||||
|
if (opts.offset != null) params.set('offset', String(opts.offset));
|
||||||
|
const qs = params.toString();
|
||||||
|
return request<AuditPage>(`/v1/admin/audit${qs ? `?${qs}` : ''}`, init);
|
||||||
|
}
|
||||||
|
|||||||
235
frontend/src/lib/components/admin/AuditTable.svelte
Normal file
235
frontend/src/lib/components/admin/AuditTable.svelte
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import Pager from '$lib/components/Pager.svelte';
|
||||||
|
import { fmtAgo } from '$lib/format';
|
||||||
|
import { listAuditLog, type AuditEntry } from '$lib/api/admin';
|
||||||
|
|
||||||
|
const LIMIT = 25;
|
||||||
|
|
||||||
|
let rows = $state<AuditEntry[]>([]);
|
||||||
|
let total = $state(0);
|
||||||
|
let page = $state(1);
|
||||||
|
let targetKind = $state('');
|
||||||
|
let action = $state('');
|
||||||
|
let days = $state(0);
|
||||||
|
let loading = $state(true);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
let expanded = $state<string | null>(null);
|
||||||
|
// Cancels the in-flight request when a newer one starts, so a slow
|
||||||
|
// response can't overwrite a fresher one (out-of-order races).
|
||||||
|
let inflight: AbortController | null = null;
|
||||||
|
|
||||||
|
const totalPages = $derived(Math.max(1, Math.ceil(total / LIMIT)));
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
inflight?.abort();
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
inflight = ctrl;
|
||||||
|
loading = true;
|
||||||
|
error = null;
|
||||||
|
try {
|
||||||
|
const resp = await listAuditLog(
|
||||||
|
{
|
||||||
|
targetKind: targetKind || undefined,
|
||||||
|
action: action || undefined,
|
||||||
|
days: days || undefined,
|
||||||
|
limit: LIMIT,
|
||||||
|
offset: (page - 1) * LIMIT
|
||||||
|
},
|
||||||
|
{ signal: ctrl.signal }
|
||||||
|
);
|
||||||
|
rows = resp.items;
|
||||||
|
total = resp.page.total ?? resp.items.length;
|
||||||
|
} catch (e) {
|
||||||
|
if ((e as Error)?.name === 'AbortError') return; // superseded
|
||||||
|
error = e instanceof Error ? e.message : 'Failed to load audit log.';
|
||||||
|
} finally {
|
||||||
|
if (inflight === ctrl) loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(load);
|
||||||
|
|
||||||
|
function applyFilters() {
|
||||||
|
page = 1;
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
function onPageChange(p: number) {
|
||||||
|
page = p;
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
function actor(r: AuditEntry): string {
|
||||||
|
if (r.actor_username) return r.actor_username;
|
||||||
|
if (r.actor_user_id) return '(deleted user)';
|
||||||
|
return 'system';
|
||||||
|
}
|
||||||
|
function targetLabel(r: AuditEntry): string {
|
||||||
|
if (r.target_id) return `${r.target_kind} · ${r.target_id.slice(0, 8)}`;
|
||||||
|
return r.target_kind;
|
||||||
|
}
|
||||||
|
function pretty(payload: unknown): string {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(payload, null, 2);
|
||||||
|
} catch {
|
||||||
|
return String(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Closed vocab of target kinds; `action` is a free-text exact filter
|
||||||
|
// (the action set grows over time, so a text box stays future-proof).
|
||||||
|
const TARGET_KINDS = ['', 'crawler', 'manga', 'chapter', 'page', 'settings', 'user'];
|
||||||
|
const WINDOWS: { value: number; label: string }[] = [
|
||||||
|
{ value: 0, label: 'All time' },
|
||||||
|
{ value: 1, label: 'Last 24h' },
|
||||||
|
{ value: 7, label: 'Last 7d' },
|
||||||
|
{ value: 30, label: 'Last 30d' }
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="audit" data-testid="audit-table">
|
||||||
|
<div class="toolbar">
|
||||||
|
<select
|
||||||
|
bind:value={targetKind}
|
||||||
|
onchange={applyFilters}
|
||||||
|
aria-label="Filter by target kind"
|
||||||
|
data-testid="audit-target-kind"
|
||||||
|
>
|
||||||
|
{#each TARGET_KINDS as k (k)}
|
||||||
|
<option value={k}>{k === '' ? 'All targets' : k}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
bind:value={days}
|
||||||
|
onchange={applyFilters}
|
||||||
|
aria-label="Time window"
|
||||||
|
data-testid="audit-window"
|
||||||
|
>
|
||||||
|
{#each WINDOWS as w (w.value)}<option value={w.value}>{w.label}</option>{/each}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
class="action-filter"
|
||||||
|
type="search"
|
||||||
|
placeholder="Filter by action (exact)…"
|
||||||
|
bind:value={action}
|
||||||
|
onchange={applyFilters}
|
||||||
|
aria-label="Filter by action"
|
||||||
|
data-testid="audit-action"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<p class="error" role="alert">{error}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<p class="muted" data-testid="audit-loading">Loading…</p>
|
||||||
|
{:else if rows.length === 0}
|
||||||
|
<p class="muted" data-testid="audit-empty">No audit entries match.</p>
|
||||||
|
{:else}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>When</th>
|
||||||
|
<th>Actor</th>
|
||||||
|
<th>Action</th>
|
||||||
|
<th>Target</th>
|
||||||
|
<th class="actions"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each rows as r (r.id)}
|
||||||
|
<tr
|
||||||
|
class="clickable"
|
||||||
|
onclick={() => (expanded = expanded === r.id ? null : r.id)}
|
||||||
|
data-testid={`audit-row-${r.id}`}
|
||||||
|
>
|
||||||
|
<td title={new Date(r.at).toLocaleString()}>{fmtAgo(r.at)}</td>
|
||||||
|
<td>{actor(r)}</td>
|
||||||
|
<td><span class="action">{r.action}</span></td>
|
||||||
|
<td class="target">{targetLabel(r)}</td>
|
||||||
|
<td class="actions">
|
||||||
|
<span class="chev" aria-hidden="true"
|
||||||
|
>{expanded === r.id ? '▾' : '▸'}</span
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{#if expanded === r.id}
|
||||||
|
<tr class="payrow">
|
||||||
|
<td colspan="5"><pre>{pretty(r.payload)}</pre></td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<Pager {page} {totalPages} onChange={onPageChange} testid="audit-pager" />
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
select,
|
||||||
|
.action-filter {
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 var(--space-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
}
|
||||||
|
.action-filter {
|
||||||
|
min-width: 14rem;
|
||||||
|
}
|
||||||
|
.muted {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: var(--space-2);
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
}
|
||||||
|
.action {
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
}
|
||||||
|
.target {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
text-align: right;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
tr.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
tr.clickable:hover {
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
.payrow td {
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
.payrow pre {
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
color: var(--danger, #dc2626);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
62
frontend/src/lib/components/admin/AuditTable.svelte.test.ts
Normal file
62
frontend/src/lib/components/admin/AuditTable.svelte.test.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
import { render, screen, cleanup, waitFor } from '@testing-library/svelte';
|
||||||
|
import AuditTable from './AuditTable.svelte';
|
||||||
|
import * as adminApi from '$lib/api/admin';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
function entry(over: Partial<adminApi.AuditEntry> = {}): adminApi.AuditEntry {
|
||||||
|
return {
|
||||||
|
id: 'a-1',
|
||||||
|
actor_user_id: 'u-1',
|
||||||
|
actor_username: 'alice',
|
||||||
|
action: 'crawler_run',
|
||||||
|
target_kind: 'crawler',
|
||||||
|
target_id: null,
|
||||||
|
payload: { started: true },
|
||||||
|
at: '2026-06-01T00:00:00Z',
|
||||||
|
...over
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AuditTable', () => {
|
||||||
|
it('renders rows with actor + action and expands the payload on click', async () => {
|
||||||
|
vi.spyOn(adminApi, 'listAuditLog').mockResolvedValue({
|
||||||
|
items: [entry()],
|
||||||
|
page: { limit: 25, offset: 0, total: 1 }
|
||||||
|
});
|
||||||
|
render(AuditTable);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('crawler_run')).toBeTruthy());
|
||||||
|
expect(screen.getByText('alice')).toBeTruthy();
|
||||||
|
// Payload hidden until the row is clicked.
|
||||||
|
expect(screen.queryByText(/"started": true/)).toBeNull();
|
||||||
|
screen.getByTestId('audit-row-a-1').click();
|
||||||
|
await waitFor(() => expect(screen.getByText(/"started": true/)).toBeTruthy());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "system" actor when there is no actor, and "(deleted user)" when the id has no username', async () => {
|
||||||
|
vi.spyOn(adminApi, 'listAuditLog').mockResolvedValue({
|
||||||
|
items: [
|
||||||
|
entry({ id: 'a-sys', actor_user_id: null, actor_username: null }),
|
||||||
|
entry({ id: 'a-del', actor_user_id: 'u-x', actor_username: null })
|
||||||
|
],
|
||||||
|
page: { limit: 25, offset: 0, total: 2 }
|
||||||
|
});
|
||||||
|
render(AuditTable);
|
||||||
|
await waitFor(() => expect(screen.getByText('system')).toBeTruthy());
|
||||||
|
expect(screen.getByText('(deleted user)')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders an empty state when there are no entries', async () => {
|
||||||
|
vi.spyOn(adminApi, 'listAuditLog').mockResolvedValue({
|
||||||
|
items: [],
|
||||||
|
page: { limit: 25, offset: 0, total: 0 }
|
||||||
|
});
|
||||||
|
render(AuditTable);
|
||||||
|
await waitFor(() => expect(screen.getByTestId('audit-empty')).toBeTruthy());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,3 +12,17 @@ export function fmtDuration(ms: number | null | undefined): string {
|
|||||||
const rem = whole % 60;
|
const rem = whole % 60;
|
||||||
return `${mins}m ${String(rem).padStart(2, '0')}s`;
|
return `${mins}m ${String(rem).padStart(2, '0')}s`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Relative "time ago" from an ISO timestamp, falling back to a locale date
|
||||||
|
* once it's more than a day old. `now` is injectable for tests. */
|
||||||
|
export function fmtAgo(iso: string, now: number = Date.now()): string {
|
||||||
|
const then = new Date(iso).getTime();
|
||||||
|
const secs = Math.round((now - then) / 1000);
|
||||||
|
if (secs < 45) return 'just now';
|
||||||
|
if (secs < 90) return '1m ago';
|
||||||
|
const mins = Math.round(secs / 60);
|
||||||
|
if (mins < 60) return `${mins}m ago`;
|
||||||
|
const hrs = Math.round(mins / 60);
|
||||||
|
if (hrs < 24) return `${hrs}h ago`;
|
||||||
|
return new Date(iso).toLocaleDateString();
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
{ href: '/admin/crawler', label: 'Crawler' },
|
{ href: '/admin/crawler', label: 'Crawler' },
|
||||||
{ href: '/admin/analysis', label: 'Analysis' },
|
{ href: '/admin/analysis', label: 'Analysis' },
|
||||||
{ href: '/admin/settings', label: 'Settings' },
|
{ href: '/admin/settings', label: 'Settings' },
|
||||||
{ href: '/admin/system', label: 'System' }
|
{ href: '/admin/system', label: 'System' },
|
||||||
|
{ href: '/admin/audit', label: 'Audit' }
|
||||||
];
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
25
frontend/src/routes/admin/audit/+page.svelte
Normal file
25
frontend/src/routes/admin/audit/+page.svelte
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import AuditTable from '$lib/components/admin/AuditTable.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head><title>Audit log · Admin</title></svelte:head>
|
||||||
|
|
||||||
|
<h1>Audit log</h1>
|
||||||
|
<p class="lead">
|
||||||
|
Every admin action — crawler runs, resyncs, settings changes, session
|
||||||
|
updates — is recorded here. Click a row to inspect its payload.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<AuditTable />
|
||||||
|
|
||||||
|
<style>
|
||||||
|
h1 {
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
.lead {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
max-width: 44rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user