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:
@@ -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();
|
||||
|
||||
@@ -978,3 +978,43 @@ export async function updateAnalysisSettings(
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user