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

@@ -42,7 +42,8 @@ import {
listAnalysisHistory,
getCrawlerMetrics,
listCrawlerOps,
getAnalysisMetrics
getAnalysisMetrics,
listAuditLog
} from './admin';
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$/);
});
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 () => {
fetchSpy.mockResolvedValueOnce(ok({ started: true }));
const r = await runCrawlerPass();