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>
|
||||
);
|
||||
}
|
||||
|
||||
+26
-3
@@ -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> {
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
* Audit event types, presented.
|
||||
*
|
||||
* The page used to hold a map of eleven event types to labels and a second map
|
||||
* of seven to colours. The server emits forty-seven. Everything unmapped fell
|
||||
* through to the raw string, so one row read "Key Assigned" in green and the
|
||||
* next "workflow.schedule_updated" in grey — the same kind of fact in two
|
||||
* different formats, which made the column look like it carried a meaning it
|
||||
* did not.
|
||||
*
|
||||
* So this derives rather than enumerates. Event types are named
|
||||
* `<category>.<action>` consistently by every call site, which is a convention
|
||||
* worth leaning on: the category names the subsystem, the action is
|
||||
* humanised, and the tone comes from the verb. A type added to the server
|
||||
* tomorrow gets a sensible label and colour here today, with no second list to
|
||||
* remember. OVERRIDES exists only for the handful the rule reads badly for.
|
||||
*/
|
||||
|
||||
export type AuditTone = "danger" | "warning" | "success" | "neutral";
|
||||
|
||||
/*
|
||||
* The categories, in the order the sidebar presents their subsystems. Names
|
||||
* are what the operator calls them, so `secretgroup` and `secret` collapse to
|
||||
* one entry and `auth_provider` is "Single sign-on" rather than its identifier.
|
||||
*/
|
||||
export const AUDIT_CATEGORIES: { value: string; label: string }[] = [
|
||||
{ value: "server", label: "Servers" },
|
||||
{ value: "key", label: "SSH keys" },
|
||||
{ value: "workflow", label: "Workflows" },
|
||||
{ value: "monitor", label: "Monitors" },
|
||||
{ value: "secret", label: "Secrets" },
|
||||
{ value: "secretgroup", label: "Secret groups" },
|
||||
{ value: "secrets", label: "Secrets access" },
|
||||
{ value: "vuln", label: "Vulnerabilities" },
|
||||
{ value: "workload", label: "Workloads" },
|
||||
{ value: "console", label: "Console" },
|
||||
{ value: "agent", label: "Agents" },
|
||||
{ value: "updates", label: "OS updates" },
|
||||
{ value: "auth_provider", label: "Single sign-on" },
|
||||
{ value: "settings", label: "Settings" },
|
||||
{ value: "license", label: "Licence" },
|
||||
{ value: "instance", label: "Instance" },
|
||||
];
|
||||
|
||||
const CATEGORY_LABELS = new Map(AUDIT_CATEGORIES.map((c) => [c.value, c.label]));
|
||||
|
||||
/*
|
||||
* Tone is taken from the action verb, not the category: deleting a key and
|
||||
* deleting a workflow are the same weight of act.
|
||||
*
|
||||
* Stems match with or without their past tense, because the two spellings both
|
||||
* occur — `auth_provider.delete` beside `key.deleted`, `workload.stop` beside
|
||||
* `workflow.schedule_disabled`. Matching only the past tense left half the
|
||||
* destructive events drawn in the same grey as a settings change.
|
||||
*
|
||||
* `accepted` is anchored because `unaccepted` contains it: unanchored, the
|
||||
* negation matched its own root and withdrawing an acceptance was drawn as the
|
||||
* same caution as granting one.
|
||||
*/
|
||||
const TONE_RULES: [RegExp, AuditTone][] = [
|
||||
[/(delete|revoke|fail|offline|reap|cancel|destroy|remove)/, "danger"],
|
||||
// Accepting a finding is a decision to live with a known risk, so it reads
|
||||
// as a caution rather than an achievement. Withdrawing one falls through to
|
||||
// neutral: it puts the finding back where it started.
|
||||
[/(skip|disable|expire|stop|(^|_)accepted)/, "warning"],
|
||||
[/(create|upload|assign|appl|open|enable|import|start|restart|sync)/, "success"],
|
||||
];
|
||||
|
||||
/*
|
||||
* Only where the derived text is wrong or reads clumsily. Anything absent is
|
||||
* derived, which is the point — this list should stay short.
|
||||
*/
|
||||
const OVERRIDES: Record<string, string> = {
|
||||
"key.generation_dispatched": "Key generation requested",
|
||||
// These three arrive as `"workload." + action`, so the action really is a
|
||||
// bare imperative rather than a name anyone chose.
|
||||
"workload.start": "Workload started",
|
||||
"workload.stop": "Workload stopped",
|
||||
"workload.restart": "Workload restarted",
|
||||
// The provider events are named in the imperative where every other
|
||||
// subsystem uses the past tense; say what happened, like the rest.
|
||||
"auth_provider.create": "Provider added",
|
||||
"auth_provider.update": "Provider updated",
|
||||
"auth_provider.delete": "Provider removed",
|
||||
"agent.update_dispatched": "Agent update sent",
|
||||
"updates.applied": "OS updates applied",
|
||||
"secrets.token_rotated": "Read token rotated",
|
||||
"secret.revealed": "Secret revealed",
|
||||
"secretgroup.deleted": "Secret group deleted",
|
||||
"workflow.defaults_synced": "Default steps synced",
|
||||
"workflow.run_triggered": "Run started",
|
||||
"workflow.run_cancelled": "Run cancelled",
|
||||
"workflow.scheduled_run": "Scheduled run started",
|
||||
"workflow.schedule_skipped": "Scheduled run skipped",
|
||||
"auth_provider.ack_notice": "Callback change acknowledged",
|
||||
"console.proxy_failed": "Console relay failed",
|
||||
"console.proxy_opened": "Console relay opened",
|
||||
"instance.reaped": "Instance deleted",
|
||||
"vuln.rescan": "Rescan requested",
|
||||
"workload.logs_read": "Workload logs read",
|
||||
};
|
||||
|
||||
export interface AuditEventDisplay {
|
||||
/** The subsystem, for the chip: "Workflows". */
|
||||
category: string;
|
||||
/** What happened, sentence case: "Schedule updated". */
|
||||
action: string;
|
||||
tone: AuditTone;
|
||||
}
|
||||
|
||||
export function describeAuditEvent(eventType: string): AuditEventDisplay {
|
||||
const dot = eventType.indexOf(".");
|
||||
const prefix = dot === -1 ? "" : eventType.slice(0, dot);
|
||||
const rest = dot === -1 ? eventType : eventType.slice(dot + 1);
|
||||
|
||||
const tone = TONE_RULES.find(([re]) => re.test(rest))?.[1] ?? "neutral";
|
||||
|
||||
const override = OVERRIDES[eventType];
|
||||
const action = override ?? sentenceCase(rest);
|
||||
|
||||
return {
|
||||
// An unknown prefix is shown as itself rather than hidden: a category
|
||||
// this file has not been taught about is still better named by the
|
||||
// server's own word for it than by nothing.
|
||||
category: CATEGORY_LABELS.get(prefix) ?? sentenceCase(prefix || "Event"),
|
||||
action,
|
||||
tone,
|
||||
};
|
||||
}
|
||||
|
||||
function sentenceCase(s: string): string {
|
||||
const words = s.replace(/[._]/g, " ").trim();
|
||||
if (!words) return "";
|
||||
return words.charAt(0).toUpperCase() + words.slice(1);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user