feat(audit): server-side paging, search and category filter; one event format

The page rendered a map of eleven event types to labels and seven to colours.
The server emits forty-seven. Everything unmapped fell through to its raw
string, so "Key Assigned" in green sat above "workflow.schedule_updated" in
grey — the same kind of fact in two formats, which made the column look like it
carried a meaning it did not.

Presentation is now derived rather than enumerated. Event types are named
<category>.<action> by every call site, so the category becomes a chip, the
action is humanised, and the tone comes from the verb. A type added to the
server tomorrow gets a sensible label and colour with no second list to update;
the override table holds only the dozen the rule reads badly for. Every row is
one treatment, and colour never carries meaning alone — the sentence beside it
says the same thing in words.

Paging and filtering are server-side, unlike the fleet lists that answer with
everything and slice in the browser. audit_retention_days is a licensed
entitlement measured in months, and this log is read to answer questions about
the past, so a browser filtering the most recent page would report "no results"
for events that exist. GET /api/audit now takes q, category, limit and skip and
answers {events, total} — a short page is not evidence of the end of the log,
which is why the total is counted rather than inferred.

audit_logs had no indexes at all: every read was a collection scan with an
in-memory sort over an append-only collection. Adds (instance_id, created_at)
and warns rather than failing, matching EnsureSecretIndexes.

Two bugs found by running the deriver over all forty-seven real types rather
than eyeballing it: the tone rules matched only past-tense verbs, leaving
auth_provider.delete drawn as neutral beside key.deleted in red; and
"unaccepted" matched "accepted", so withdrawing an acceptance read as the same
caution as granting one.
This commit is contained in:
2026-08-10 15:25:48 +01:00
parent 42f3f3e640
commit 675689a458
8 changed files with 465 additions and 107 deletions
+166 -91
View File
@@ -1,108 +1,183 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { api, AuditEvent } from "@/lib/api";
import { AsyncBoundary, Card, EmptyState, TableSkeleton } from "@/components/ui";
import { AsyncBoundary, Badge, Card, EmptyState, Pagination, TableSkeleton } from "@/components/ui";
import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
import { AUDIT_CATEGORIES, describeAuditEvent, type AuditTone } from "@/lib/auditEvents";
const EVENT_LABELS: Record<string, string> = {
"server.created": "Server Created",
"server.deleted": "Server Deleted",
"server.offline": "Server Offline",
"key.uploaded": "Key Uploaded",
"key.deleted": "Key Deleted",
"key.assigned": "Key Assigned",
"key.revoked": "Key Revoked",
"key.generation_dispatched": "Key Generation",
"agent.update_dispatched": "Agent Updated",
"updates.applied": "Updates Applied",
"settings.updated": "Settings Updated",
};
const inputClass =
"w-full rounded border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30";
const EVENT_COLOURS: Record<string, string> = {
"server.offline": "text-danger",
"server.deleted": "text-danger",
"key.deleted": "text-danger",
"key.revoked": "text-warning",
"server.created": "text-success",
"key.uploaded": "text-success",
"key.assigned": "text-success",
const TONE_CLASSES: Record<AuditTone, string> = {
danger: "text-danger",
warning: "text-warning",
success: "text-success",
neutral: "text-text-secondary",
};
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleString();
return new Date(dateStr).toLocaleString();
}
function EventTypeBadge({ type }: { type: string }) {
const label = EVENT_LABELS[type] ?? type;
const colour = EVENT_COLOURS[type] ?? "text-text-secondary";
return (
<span className={`font-mono text-xs font-medium ${colour}`}>{label}</span>
);
/*
* One treatment for every event, whatever it is: the subsystem as a chip, then
* what happened, in the same weight and size on every row. Colour carries the
* severity of the act and nothing else, and never carries it alone — the
* sentence beside it says the same thing in words.
*/
function EventCell({ type }: { type: string }) {
const { category, action, tone } = describeAuditEvent(type);
return (
<div className="flex flex-wrap items-center gap-2">
<Badge variant="neutral">{category}</Badge>
<span className={`text-sm font-medium ${TONE_CLASSES[tone]}`}>{action}</span>
</div>
);
}
export default function AuditPage() {
const { data: events, isLoading, error, refetch } = useQuery({
queryKey: ["audit"],
queryFn: () => api.listAuditEvents(200),
refetchInterval: 30_000,
});
const [search, setSearch] = useState("");
const [category, setCategory] = useState("");
const [page, setPage] = useState(1);
const [size, setSize] = useState(50);
return (
<div className="p-4 sm:p-6 lg:p-8">
<div className="mb-6">
<h1 className="text-2xl font-bold text-text-primary">Audit Log</h1>
<p className="mt-1 text-sm text-text-secondary">
All administrative actions and server status changes
</p>
</div>
// Debounced so a query is not issued per keystroke against a collection
// that is scanned by regex.
const [debounced, setDebounced] = useState("");
useEffect(() => {
const t = setTimeout(() => setDebounced(search), 300);
return () => clearTimeout(t);
}, [search]);
<Card padding={false}>
<AsyncBoundary
isLoading={isLoading}
error={error}
onRetry={refetch}
skeleton={<TableSkeleton columns={5} />}
isEmpty={!events || events.length === 0}
empty={
<EmptyState
title="No audit events recorded yet."
description="Every mutating action — a key assigned, a workflow run, a member added — is written here as it happens."
/>
}
>
<Table>
<Thead>
<Tr>
<Th>Time</Th>
<Th>Event</Th>
<Th>Actor</Th>
<Th>Details</Th>
</Tr>
</Thead>
<Tbody>
{events?.map((e: AuditEvent) => (
<Tr key={e.id}>
<Td label="Time">
<span className="whitespace-nowrap font-mono text-xs text-text-secondary">
{formatDate(e.created_at)}
</span>
</Td>
<Td label="Event">
<EventTypeBadge type={e.event_type} />
</Td>
<Td label="Actor">
<span className="text-sm text-text-primary">{e.actor}</span>
</Td>
<Td label="Details">
<span className="text-sm text-text-secondary">{e.details}</span>
</Td>
</Tr>
))}
</Tbody>
</Table>
</AsyncBoundary>
</Card>
</div>
);
// Any change to what is being asked for returns to the first page. Staying
// on page 7 of a narrower result set shows an empty table that reads as
// "no matches" when the matches are three pages back.
useEffect(() => {
setPage(1);
}, [debounced, category, size]);
const { data, isLoading, isFetching, error, refetch } = useQuery({
queryKey: ["audit", debounced, category, page, size],
queryFn: () => api.listAuditEvents({ q: debounced, category, limit: size, skip: (page - 1) * size }),
refetchInterval: 30_000,
// The table holds its previous contents while the next page loads,
// rather than collapsing to a skeleton on every keystroke and page turn.
placeholderData: keepPreviousData,
});
const events = data?.events ?? [];
const total = data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / size));
const filtered = debounced !== "" || category !== "";
return (
<div className="p-4 sm:p-6 lg:p-8">
<div className="mb-6">
<h1 className="text-2xl font-bold text-text-primary">Audit Log</h1>
<p className="mt-1 text-sm text-text-secondary">All administrative actions and server status changes</p>
</div>
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center">
<input
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search actor, details or event type…"
aria-label="Search the audit log"
className={`${inputClass} sm:max-w-sm`}
/>
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
aria-label="Filter by category"
className={`${inputClass} sm:w-56`}
>
<option value="">All categories</option>
{AUDIT_CATEGORIES.map((c) => (
<option key={c.value} value={c.value}>
{c.label}
</option>
))}
</select>
{/* Reserved width, so the row does not reflow each time a
background refetch starts and stops. */}
<span className="min-w-24 font-mono text-xs text-text-tertiary" aria-live="polite">
{isFetching && !isLoading ? "Searching…" : total > 0 ? `${total.toLocaleString()} events` : ""}
</span>
</div>
<Card padding={false}>
<AsyncBoundary
isLoading={isLoading}
error={error}
onRetry={refetch}
skeleton={<TableSkeleton columns={4} />}
isEmpty={events.length === 0}
empty={
filtered ? (
<EmptyState
title="No events match this search."
description="Searching covers the whole retained log, not just the page on screen, so this means there is nothing to find. Try a shorter term or a different category."
action={{
label: "Clear filters",
onClick: () => {
setSearch("");
setCategory("");
},
}}
/>
) : (
<EmptyState
title="No audit events recorded yet."
description="Every mutating action — a key assigned, a workflow run, a member added — is written here as it happens."
/>
)
}
>
<Table>
<Thead>
<Tr>
<Th>Time</Th>
<Th>Event</Th>
<Th>Actor</Th>
<Th>Details</Th>
</Tr>
</Thead>
<Tbody>
{events.map((e: AuditEvent) => (
<Tr key={e.id}>
<Td label="Time">
<span className="whitespace-nowrap font-mono text-xs text-text-secondary">
{formatDate(e.created_at)}
</span>
</Td>
<Td label="Event">
<EventCell type={e.event_type} />
</Td>
<Td label="Actor">
<span className="text-sm text-text-primary">{e.actor}</span>
</Td>
<Td label="Details">
<span className="text-sm text-text-secondary">{e.details}</span>
</Td>
</Tr>
))}
</Tbody>
</Table>
<Pagination
page={page}
pageCount={pageCount}
size={size}
total={total}
onPage={setPage}
onSize={setSize}
unit="events"
/>
</AsyncBoundary>
</Card>
</div>
);
}