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:
+166
-91
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user