feat(audit): P3-08 — audit log UI with filters, sort, CSV export, payload modal #12

Merged
steve merged 4 commits from p3-08-audit-ui into main 2026-05-05 08:17:25 +01:00
Owner

Closes Phase 3.

The audit_log table has been populated since Phase 1; this exposes it as a read-only /audit surface. Branch totals: 4 commits, +400/−40 lines.

What's in

Page/audit with 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 DESC so equal sort keys (e.g. a run of alert.acknowledge) come back newest-first within the group. OrderBy is allowlisted in the store layer so ?sort=DROP TABLE falls back to ts.

Timestamps — Absolute 2026-05-05 06:32:50 UTC, 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 a data- attribute to dodge html/template's contextual JS-string escaping inside <script type="application/json"> blocks.

CSV exportGET /audit.csv honours the same filter querystring (5000-row cap vs 500 for the table). Columns: timestamp_utc, actor, user, action, target_kind, target_name, payload. user_id and target_id are deliberately omitted — internal ULIDs carry no meaning to anyone reading the CSV. Content-Disposition: attachment with a UTC-stamped filename.

JSON variantGET /api/audit for programmatic access; same filter shape, 500-row cap.

Implementation notes

  • New store API: ListAudit(AuditFilter), DistinctAuditActions, ListUsers. AuditFilter covers user_id, actor, action (exact + substring), target_kind, target_id, time range, limit, sort.
  • Hrefs in the page (sort headers + CSV export) are pre-built in the handler with 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 (turns range=all into range%3dall), which silently dropped every filter on sort click.

Tests

  • TestListAuditFiltersAndOrdering — every filter + ordering default
  • TestListAuditSort — asc/desc, unknown-column fallback, within-group tie-break
  • TestDistinctAuditActions — distinct list ordering

Test plan

  • /audit loads in the default 24h range, shows N rows
  • Click 24h/7d/30d/All → row count updates
  • Filter by Actor=system → only system rows show
  • Filter by action='alert.' → only alert.* rows show
  • Click a column header → sort reverses, glyph appears
  • Click the same header again → flips asc↔desc
  • Click a row's payload ↗ → modal opens with pretty-printed JSON
  • Modal × / Escape / backdrop click → closes
  • Copy button in modal → JSON in clipboard
  • Export CSV → file downloads, opens in Excel, has 7 columns
  • CSV contents reflect the current filter
  • go test ./...

Closes

P3-08 (Phase 3 — Audit log UI). With this merged, Phase 3 is complete.

Closes Phase 3. The audit_log table has been populated since Phase 1; this exposes it as a read-only `/audit` surface. Branch totals: 4 commits, +400/−40 lines. ## What's in **Page** — `/audit` with 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 DESC` so equal sort keys (e.g. a run of `alert.acknowledge`) come back newest-first within the group. `OrderBy` is allowlisted in the store layer so `?sort=DROP TABLE` falls back to `ts`. **Timestamps** — Absolute `2026-05-05 06:32:50` UTC, 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 a `data-` attribute to dodge html/template's contextual JS-string escaping inside `<script type="application/json">` blocks. **CSV export** — `GET /audit.csv` honours the same filter querystring (5000-row cap vs 500 for the table). Columns: `timestamp_utc, actor, user, action, target_kind, target_name, payload`. `user_id` and `target_id` are deliberately omitted — internal ULIDs carry no meaning to anyone reading the CSV. Content-Disposition: attachment with a UTC-stamped filename. **JSON variant** — `GET /api/audit` for programmatic access; same filter shape, 500-row cap. ## Implementation notes - New store API: `ListAudit(AuditFilter)`, `DistinctAuditActions`, `ListUsers`. `AuditFilter` covers user_id, actor, action (exact + substring), target_kind, target_id, time range, limit, sort. - Hrefs in the page (sort headers + CSV export) are pre-built in the handler with `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 (turns `range=all` into `range%3dall`), which silently dropped every filter on sort click. ## Tests - `TestListAuditFiltersAndOrdering` — every filter + ordering default - `TestListAuditSort` — asc/desc, unknown-column fallback, within-group tie-break - `TestDistinctAuditActions` — distinct list ordering ## Test plan - [ ] /audit loads in the default 24h range, shows N rows - [ ] Click 24h/7d/30d/All → row count updates - [ ] Filter by Actor=system → only system rows show - [ ] Filter by action='alert.' → only alert.* rows show - [ ] Click a column header → sort reverses, glyph appears - [ ] Click the same header again → flips asc↔desc - [ ] Click a row's `payload ↗` → modal opens with pretty-printed JSON - [ ] Modal × / Escape / backdrop click → closes - [ ] Copy button in modal → JSON in clipboard - [ ] Export CSV → file downloads, opens in Excel, has 7 columns - [ ] CSV contents reflect the *current filter* - [ ] `go test ./...` ## Closes P3-08 (Phase 3 — Audit log UI). With this merged, **Phase 3 is complete**.
steve added 4 commits 2026-05-05 08:17:07 +01:00
feat(audit): clickable column headers with asc/desc sort
CI / Build (windows/amd64) (pull_request) Successful in 23s
CI / Lint (pull_request) Successful in 34s
CI / Build (linux/amd64) (pull_request) Successful in 23s
CI / Build (linux/arm64) (pull_request) Successful in 21s
CI / Test (linux/amd64) (pull_request) Successful in 3m41s
ba425c9766
steve merged commit 6fd16ace81 into main 2026-05-05 08:17:25 +01:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: steve/restic-manager#12