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:
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());
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user