feat(audit): P3-08 — audit log UI with filters, sort, CSV export, payload modal #12
Reference in New Issue
Block a user
Delete Branch "p3-08-audit-ui"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes Phase 3.
The audit_log table has been populated since Phase 1; this exposes it as a read-only
/auditsurface. Branch totals: 4 commits, +400/−40 lines.What's in
Page —
/auditwith filters: time-range pills (24h / 7d / 30d / all), User dropdown, Actor dropdown (user / agent / system), Target-kind dropdown, action substring search.Sort — Every column header (When / Actor / User / Action / Target) is a clickable link that toggles asc/desc. Active column shows a ↑/↓ glyph in the accent colour. Tie-break is always
ts DESCso equal sort keys (e.g. a run ofalert.acknowledge) come back newest-first within the group.OrderByis allowlisted in the store layer so?sort=DROP TABLEfalls back tots.Timestamps — Absolute
2026-05-05 06:32:50UTC, not relative — audit is forensic, exact times matter.Payload modal — Click
payload ↗to open a centred overlay (720px / 90vw, 80vh, internal scroll). Backdrop / × / Escape close. Copy button lifts the pretty-printed JSON to clipboard. Payload is base64'd onto adata-attribute to dodge html/template's contextual JS-string escaping inside<script type="application/json">blocks.CSV export —
GET /audit.csvhonours the same filter querystring (5000-row cap vs 500 for the table). Columns:timestamp_utc, actor, user, action, target_kind, target_name, payload.user_idandtarget_idare deliberately omitted — internal ULIDs carry no meaning to anyone reading the CSV. Content-Disposition: attachment with a UTC-stamped filename.JSON variant —
GET /api/auditfor programmatic access; same filter shape, 500-row cap.Implementation notes
ListAudit(AuditFilter),DistinctAuditActions,ListUsers.AuditFiltercovers user_id, actor, action (exact + substring), target_kind, target_id, time range, limit, sort.url.Values.Encode()and passed to the template as plain strings. Caught a bug mid-implementation: building the querystring inside<a href="...">in html/template URL-encodes the=chars (turnsrange=allintorange%3dall), which silently dropped every filter on sort click.Tests
TestListAuditFiltersAndOrdering— every filter + ordering defaultTestListAuditSort— asc/desc, unknown-column fallback, within-group tie-breakTestDistinctAuditActions— distinct list orderingTest plan
payload ↗→ modal opens with pretty-printed JSONgo test ./...Closes
P3-08 (Phase 3 — Audit log UI). With this merged, Phase 3 is complete.