feat(portal-admin): audit log tabs (Table / Charts) + server-side stats consumption #174

Merged
julien merged 1 commits from feat/portal-admin-audit-tabs into main 2026-05-17 00:16:48 +02:00
Owner

Summary

PR 2 of the tabs + full-result-charts chantier — closes the loop opened in #173. The audit log page now splits into a Table tab (existing behaviour) and a Charts tab fed by the server-side stats endpoint shipped in PR 1.

PR 1 (#173, merged) — BFF GET /api/admin/audit/stats + Redis 5min cache
                      + admin.audit.stats.query audit event + ADR-0013 amendment.
PR 2 (this one)     — SPA Tabs UX (Table / Charts) + consume the stats endpoint,
                      replacing the per-page client-side aggregations from #172.

What lands

Tabs UX — WAI-ARIA tab pattern

The two tabs sit between the filter card and the content area. Visual treatment is intentionally minimal: thin brand-coloured underline on the active tab, focus rings on :focus-visible, no surrounding chrome. The panel below inherits the page surface so each tab swap reads as a content-only change.

ARIA wiring:

  • <div role="tablist"> with two <button role="tab"> children.
  • aria-selected mirrors the active tab; aria-controls points each tab at its panel id; roving tabindex (active = 0, inactive = -1) keeps Tab linear.
  • Arrow-key navigation between tabs is bound on the individual buttons (not the tablist div) — focusable elements only, satisfies the template/click-events-have-key-events lint without an artificial tabindex="-1" on the container.
  • Two <section role="tabpanel"> with [hidden] binding, aria-labelledby pointing back at the tab id.

Stats consumption — replaces the per-page computeds

AuditEventsService grows a stats(filters) method that calls GET /api/admin/audit/stats and returns AdminAuditStats (mirror of the BFF DTO). The audit page replaces the four per-page computed()s — totalOnPage, dailyVolume, outcomeBreakdown, dailyByEventType — with four signals:

readonly stats        = signal<AdminAuditStats | null>(null);
readonly statsLoading = signal(false);
readonly statsError   = signal<string | null>(null);
readonly hasChartData = computed(() => (this.stats()?.total ?? 0) > 0);

The chart-tile components on the Charts panel consume stats()?.dailyVolume, stats()?.outcomeBreakdown, stats()?.eventTypeByDay, and stats()?.total unchanged — same shape as the old per-page projections, just sourced server-side.

Lazy fetch policy

Interaction Action on stats
Page load (Table active) No call — default tab is Table, stats untouched
Click Charts (first time) Fetch
Click Table → Charts Fetch only if cleared by a filter change
Apply / clear filters Clear stats, re-fetch only if Charts active
Filter by row's actor Clear stats, re-fetch if Charts active
Next / previous page Do not invalidate stats (pagination is presentation, the aggregated set is unchanged)

The Redis cache in PR 1 absorbs repeated identical fetches at ~5 ms; the policy here just makes sure we don't fire a stats call when nobody's looking at it.

Honest panel copy

The Charts panel's note now reads:

Aggregations are computed across the full filtered set (server-side), not just the events on the current page. Results are cached for 5 minutes per filter combination.

Replaces the previous "this only reflects the current page" disclaimer that #172 carried as a temporary truth.

Notes for the reviewer

  • Why a hand-coded tablist instead of a shared libs/shared/ui/tabs primitive? Two tabs, one consumer, ~30 LOC of template + ~25 LOC of SCSS. The two-occurrences-is-duplication rule says wait until a second consumer appears (cf. CMS module or user-list module slated for the next chantier), then extract with the shape both consumers have actually demanded.
  • Why is setTab() async? It triggers fetchStats() on the first switch to Charts, and the spec exercises that flow with await. Keeping it async lets the test sequence assertions deterministically without tick()/fakeAsync.
  • Why does search() clear stats before the fetch even though fetchStats() clears it again at the top? So the empty-state never flickers through a "stale 1000-event" total while the new fetch is in flight on a slow link. The double-clear is intentional.
  • Bundle impact. The audit chunk grew from ~82 KB to ~84.5 KB gzip (two new signals, a stats-mapping HTTP call, the tablist template + SCSS). Well under the lazy-chunk 100 KB ceiling from ADR-0017.
  • audit.scss is 7.5 KB — over the 6 KB component-style warning, under the 8 KB error. The new .tablist / .tab rules account for the bump. If a third consumer of the tab pattern lands, the SCSS extraction comes with it.

Test plan

  • pnpm nx test portal-admin62 specs pass (was 58: removed 3 obsolete "per-page chart" cases, added 7 new tabs/stats cases).
  • pnpm nx run portal-admin:lint — clean (the two pre-existing use-lifecycle-interface warnings on audit.ts / users.ts and the spec non-null assertion are untouched, not regressions).
  • pnpm nx build portal-admin — clean, audit chunk 84.5 KB gzip.
  • pnpm nx run-many -t lint test build -p portal-shell,portal-admin,portal-bff,shared-charts — clean.
  • Manual smokepnpm nx serve portal-admin + pnpm nx serve portal-bff, sign in with Portal.Admin, navigate to /admin/audit:
    • Page loads on the Table tab — DevTools Network shows GET /api/admin/audit only, no /audit/stats call.
    • Click Charts → spinner state, then three chart tiles render with server-side totals.
    • Apply a filter (e.g. eventType=auth.sign_in) while on Charts → tiles re-render with the filtered aggregates; donut centre matches the server total.
    • Switch back to Table, page through results → no new /audit/stats calls (pagination doesn't invalidate).
    • Arrow-Right / Arrow-Left between the two tabs with keyboard — focus moves, ARIA selection follows, Tab skips past the inactive tab to the panel content.
    • Toggle dark mode → tablist + active underline + tab focus rings all read correctly in both modes.

What's next

  • Persist the active tab in the URL (?tab=charts) so deep-linking and Back/Forward survive the swap. Out of scope for this chantier — once the user-list module lands and we have a second tab consumer, persistence becomes a shared concern.
  • Extract libs/shared/ui/tabs when a second consumer materialises (CMS or user-list module).
  • Surface cache observability — the stats endpoint Redis cache is silent in v1. A future ADR-0024 follow-up could add a X-Audit-Stats-Cache: hit|miss header for ops or an OTel attribute on the BFF span. Out of scope here, but worth flagging while the design is fresh.
## Summary PR 2 of the tabs + full-result-charts chantier — closes the loop opened in #173. The audit log page now splits into a **Table** tab (existing behaviour) and a **Charts** tab fed by the server-side stats endpoint shipped in PR 1. ``` PR 1 (#173, merged) — BFF GET /api/admin/audit/stats + Redis 5min cache + admin.audit.stats.query audit event + ADR-0013 amendment. PR 2 (this one) — SPA Tabs UX (Table / Charts) + consume the stats endpoint, replacing the per-page client-side aggregations from #172. ``` ## What lands ### Tabs UX — WAI-ARIA tab pattern The two tabs sit between the filter card and the content area. Visual treatment is intentionally minimal: thin brand-coloured underline on the active tab, focus rings on `:focus-visible`, no surrounding chrome. The panel below inherits the page surface so each tab swap reads as a content-only change. ARIA wiring: - `<div role="tablist">` with two `<button role="tab">` children. - `aria-selected` mirrors the active tab; `aria-controls` points each tab at its panel id; roving `tabindex` (active = `0`, inactive = `-1`) keeps Tab linear. - Arrow-key navigation between tabs is bound on the individual buttons (not the tablist div) — focusable elements only, satisfies the `template/click-events-have-key-events` lint without an artificial `tabindex="-1"` on the container. - Two `<section role="tabpanel">` with `[hidden]` binding, `aria-labelledby` pointing back at the tab id. ### Stats consumption — replaces the per-page computeds `AuditEventsService` grows a `stats(filters)` method that calls `GET /api/admin/audit/stats` and returns `AdminAuditStats` (mirror of the BFF DTO). The audit page replaces the four per-page `computed()`s — `totalOnPage`, `dailyVolume`, `outcomeBreakdown`, `dailyByEventType` — with four signals: ```ts readonly stats = signal<AdminAuditStats | null>(null); readonly statsLoading = signal(false); readonly statsError = signal<string | null>(null); readonly hasChartData = computed(() => (this.stats()?.total ?? 0) > 0); ``` The chart-tile components on the Charts panel consume `stats()?.dailyVolume`, `stats()?.outcomeBreakdown`, `stats()?.eventTypeByDay`, and `stats()?.total` unchanged — same shape as the old per-page projections, just sourced server-side. ### Lazy fetch policy | Interaction | Action on `stats` | | ------------------------ | ---------------------------------------------- | | Page load (Table active) | No call — default tab is Table, stats untouched | | Click Charts (first time) | Fetch | | Click Table → Charts | Fetch only if cleared by a filter change | | Apply / clear filters | Clear `stats`, re-fetch **only if Charts active** | | Filter by row's actor | Clear `stats`, re-fetch if Charts active | | Next / previous page | Do **not** invalidate stats (pagination is presentation, the aggregated set is unchanged) | The Redis cache in PR 1 absorbs repeated identical fetches at ~5 ms; the policy here just makes sure we don't fire a stats call when nobody's looking at it. ### Honest panel copy The Charts panel's note now reads: > Aggregations are computed across the full filtered set (server-side), not just the events on the current page. Results are cached for 5 minutes per filter combination. Replaces the previous "this only reflects the current page" disclaimer that #172 carried as a temporary truth. ## Notes for the reviewer - **Why a hand-coded tablist instead of a shared `libs/shared/ui/tabs` primitive?** Two tabs, one consumer, ~30 LOC of template + ~25 LOC of SCSS. The two-occurrences-is-duplication rule says wait until a second consumer appears (cf. CMS module or user-list module slated for the next chantier), then extract with the shape both consumers have actually demanded. - **Why is `setTab()` `async`?** It triggers `fetchStats()` on the first switch to Charts, and the spec exercises that flow with `await`. Keeping it `async` lets the test sequence assertions deterministically without `tick()`/fakeAsync. - **Why does `search()` clear `stats` *before* the fetch even though `fetchStats()` clears it again at the top?** So the empty-state never flickers through a "stale 1000-event" total while the new fetch is in flight on a slow link. The double-clear is intentional. - **Bundle impact.** The audit chunk grew from ~82 KB to ~84.5 KB gzip (two new signals, a stats-mapping HTTP call, the tablist template + SCSS). Well under the lazy-chunk 100 KB ceiling from [ADR-0017](docs/decisions/0017-performance-budgets-lighthouse-ci.md). - **`audit.scss` is 7.5 KB** — over the 6 KB component-style warning, under the 8 KB error. The new `.tablist` / `.tab` rules account for the bump. If a third consumer of the tab pattern lands, the SCSS extraction comes with it. ## Test plan - [x] `pnpm nx test portal-admin` — **62 specs pass** (was 58: removed 3 obsolete "per-page chart" cases, added 7 new tabs/stats cases). - [x] `pnpm nx run portal-admin:lint` — clean (the two pre-existing `use-lifecycle-interface` warnings on `audit.ts` / `users.ts` and the spec non-null assertion are untouched, not regressions). - [x] `pnpm nx build portal-admin` — clean, audit chunk 84.5 KB gzip. - [x] `pnpm nx run-many -t lint test build -p portal-shell,portal-admin,portal-bff,shared-charts` — clean. - [ ] **Manual smoke** — `pnpm nx serve portal-admin` + `pnpm nx serve portal-bff`, sign in with `Portal.Admin`, navigate to `/admin/audit`: - Page loads on the Table tab — DevTools Network shows `GET /api/admin/audit` only, **no** `/audit/stats` call. - Click **Charts** → spinner state, then three chart tiles render with server-side totals. - Apply a filter (e.g. `eventType=auth.sign_in`) while on Charts → tiles re-render with the filtered aggregates; donut centre matches the server `total`. - Switch back to Table, page through results → no new `/audit/stats` calls (pagination doesn't invalidate). - Arrow-Right / Arrow-Left between the two tabs with keyboard — focus moves, ARIA selection follows, `Tab` skips past the inactive tab to the panel content. - Toggle dark mode → tablist + active underline + tab focus rings all read correctly in both modes. ## What's next - Persist the active tab in the URL (`?tab=charts`) so deep-linking and Back/Forward survive the swap. Out of scope for this chantier — once the user-list module lands and we have a second tab consumer, persistence becomes a shared concern. - Extract `libs/shared/ui/tabs` when a second consumer materialises (CMS or user-list module). - Surface cache observability — the stats endpoint Redis cache is silent in v1. A future ADR-0024 follow-up could add a `X-Audit-Stats-Cache: hit|miss` header for ops or an OTel attribute on the BFF span. Out of scope here, but worth flagging while the design is fresh.
julien added 1 commit 2026-05-17 00:15:55 +02:00
feat(portal-admin): audit log tabs (Table / Charts) + server-side stats consumption
CI / commits (pull_request) Successful in 3m41s
CI / scan (pull_request) Successful in 3m43s
CI / check (pull_request) Successful in 4m23s
CI / a11y (pull_request) Successful in 1m57s
CI / perf (pull_request) Successful in 3m38s
299e66fde5
PR 2 of the tabs + full-result-charts chantier — closes the loop on
the BFF endpoint shipped in #173.

Two changes:

1. Wraps the audit page content in two tabs — `Table` (default) and
   `Charts`. WAI-ARIA pattern: `role="tablist"` on the container,
   `role="tab"` on each button, roving tabindex (the active tab
   reachable via Tab, inactive ones via arrow keys). The Charts
   panel hides until the user switches to it.

2. Charts now consume the BFF stats endpoint (full filtered set,
   Redis-cached 5 min) instead of the per-page aggregations the
   previous PR shipped. The three client-side computeds
   (`dailyVolume`, `outcomeBreakdown`, `dailyByEventType`,
   `totalOnPage`) are replaced by:

     * `stats: signal<AdminAuditStats | null>` — filled by the
       `GET /api/admin/audit/stats` call.
     * `statsLoading` + `statsError` — own loading / error state,
       distinct from the table's so a failed stats call doesn't
       mask the table view.
     * `fetchStats()` — strips pagination from the filter shape,
       calls the new `AuditEventsService.stats()` method.

Lazy fetch policy:
  * Default tab Table → no stats call on first render. Saves a DB
    round-trip when the admin only wanted to scan rows.
  * Switch to Charts → fetch stats if not already loaded.
  * Filter change (Apply / Reset / actor pivot) → invalidate stats
    (set to null). If Charts tab is currently active, re-fetch
    immediately. Otherwise wait for the user to open it.
  * Pagination (next / previous) → does NOT invalidate stats. The
    underlying filtered set is unchanged, so re-opening Charts
    after paginating is free.

The Charts panel's note now reads "Aggregations are computed across
the full filtered set (server-side), not just the events on the
current page. Results are cached for 5 minutes per filter
combination." — honest disclosure of the new scope + cache.

Audit chunk: 84.5 KB gzip (essentially unchanged from #172's 84
KB; the tab markup + stats branch add ~50 LOC of plumbing offset
by removal of the per-page aggregation computeds). audit.scss
ticks up to 7.5 KB which exceeds the 6 KB warning threshold but
stays under the 8 KB error threshold — acceptable for a single-
PR add of the tab styling.

Verification:
  * 62 portal-admin specs pass (was 57; +5 net: 7 new tabs / stats
    tests, 3 old per-page chart tests dropped, 1 prior test reframed).
  * `pnpm nx run-many -t lint test build --projects=portal-shell,
    portal-admin,shared-charts,portal-bff` — green.
julien merged commit 9f5106b805 into main 2026-05-17 00:16:48 +02:00
julien deleted branch feat/portal-admin-audit-tabs 2026-05-17 00:16:50 +02: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: julien/apf_portal#174