feat(audit): server-side paging, search and category filter; one event format
Chart Release / chart (push) Successful in 12s
Server Deploy / deploy (push) Successful in 8m2s

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
+26 -3
View File
@@ -164,6 +164,24 @@ export interface AuditEvent {
created_at: string;
}
export interface AuditQuery {
q?: string;
category?: string;
limit?: number;
skip?: number;
}
/*
* The audit log pages on the server, unlike the fleet endpoints that answer
* with everything and slice in the browser. It is kept for months and read to
* answer questions about the past, so a search that only saw the most recent
* page would report "no results" for events that exist.
*/
export interface AuditPage {
events: AuditEvent[];
total: number;
}
export interface AlertSettings {
offline_threshold_minutes: number;
offline_channel_ids: string[] | null;
@@ -651,9 +669,14 @@ export const api = {
});
},
listAuditEvents(limit?: number): Promise<AuditEvent[]> {
const qs = limit ? `?limit=${limit}` : "";
return request<AuditEvent[]>(`/audit${qs}`);
listAuditEvents(params: AuditQuery = {}): Promise<AuditPage> {
const qs = new URLSearchParams();
if (params.q) qs.set("q", params.q);
if (params.category) qs.set("category", params.category);
if (params.limit) qs.set("limit", String(params.limit));
if (params.skip) qs.set("skip", String(params.skip));
const suffix = qs.toString();
return request<AuditPage>(`/audit${suffix ? `?${suffix}` : ""}`);
},
getSettings(): Promise<Settings> {