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();

View File

@@ -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);
}

View 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>

View 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());
});
});

View File

@@ -12,3 +12,17 @@ export function fmtDuration(ms: number | null | undefined): string {
const rem = whole % 60;
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();
}

View File

@@ -9,7 +9,8 @@
{ href: '/admin/crawler', label: 'Crawler' },
{ href: '/admin/analysis', label: 'Analysis' },
{ href: '/admin/settings', label: 'Settings' },
{ href: '/admin/system', label: 'System' }
{ href: '/admin/system', label: 'System' },
{ href: '/admin/audit', label: 'Audit' }
];
</script>

View 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>