From 9f5106b805f79c3b9f222731b86a66ca78aa4b02 Mon Sep 17 00:00:00 2001 From: Julien Gautier Date: Sun, 17 May 2026 00:16:47 +0200 Subject: [PATCH] feat(portal-admin): audit log tabs (Table / Charts) + server-side stats consumption (#174) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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: - `
` with two `
+
+ + +
+
- @if (loading()) { + @if (tab() === 'table') { @if (loading()) { Loading… } @else if (error(); as msg) { {{ msg }} } @else if (page(); as p) { {{ resultRange() }} - } + } } @else { @if (statsLoading()) { + Loading statistics… + } @else if (statsError(); as msg) { + {{ msg }} + } @else if (stats(); as s) { + {{ s.total }} matching event(s) aggregated + } }
- @if (hasChartData()) { -
-

At a glance — current page

+
+ @if (stats(); as s) { @if (hasChartData()) {

- Aggregations are computed from the events currently loaded on this page only. Apply filters or - change the page size to widen / narrow the window. + 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.

+ } @else { +

No audit events match the current filters.

+ } } @else if (!statsLoading() && !statsError()) { +

Switch to this tab to load statistics.

+ }
- } @if (page(); as p) { @if (p.items.length === 0) { -

No audit events match the current filters.

- } @else { -
- - - - - - - - - - - - - @for (event of p.items; track event.id) { - - - - - - - - - } - -
TimestampEventAudience / outcomeActor / subjectTracePayload
{{ formatTimestamp(event.createdAt) }}{{ event.eventType }} - {{ event.audience }} - {{ event.outcome }} - - @if (event.actorIdHash; as hash) { - - } @else { -
(anonymous)
- } @if (event.subject; as subject) { -
{{ subject }}
- } -
- @if (event.traceId; as traceId) { - {{ traceId }} - } @else { — } - - @if (event.payload) { -
- view -
{{ formatPayload(event.payload) }}
-
- } @else { - - } -
-
- - } } +
+ @if (page(); as p) { @if (p.items.length === 0) { +

No audit events match the current filters.

+ } @else { +
+ + + + + + + + + + + + + @for (event of p.items; track event.id) { + + + + + + + + + } + +
TimestampEventAudience / outcomeActor / subjectTracePayload
{{ formatTimestamp(event.createdAt) }}{{ event.eventType }} + {{ event.audience }} + {{ event.outcome }} + + @if (event.actorIdHash; as hash) { + + } @else { +
(anonymous)
+ } @if (event.subject; as subject) { +
{{ subject }}
+ } +
+ @if (event.traceId; as traceId) { + {{ traceId }} + } @else { — } + + @if (event.payload) { +
+ view +
{{ formatPayload(event.payload) }}
+
+ } @else { + + } +
+
+ + + } } +
diff --git a/apps/portal-admin/src/app/pages/audit/audit.scss b/apps/portal-admin/src/app/pages/audit/audit.scss index a65149c..612670f 100644 --- a/apps/portal-admin/src/app/pages/audit/audit.scss +++ b/apps/portal-admin/src/app/pages/audit/audit.scss @@ -194,8 +194,75 @@ color: #9ca3af; } -// Chart row above the table — same surface tokens as `.filters` -// + `.table-wrap` so the page reads as one stack of related blocks. +// Tabs above the content. WAI-ARIA pattern: `role="tablist"` on the +// container, `role="tab"` on each button, roving `tabindex` (the +// active tab is reachable via Tab, inactive ones via arrow keys +// once focus is in the list). The visual treatment is a thin +// underline on the active tab — minimal furniture, lets the panel +// content carry the eye. +.tablist { + display: flex; + gap: 0.5rem; + border-bottom: 1px solid #e5e7eb; + margin-bottom: 1rem; +} + +:host-context(.dark) .tablist { + border-bottom-color: #1f2937; +} + +.tab { + appearance: none; + background: transparent; + border: 0; + border-bottom: 2px solid transparent; + padding: 0.5rem 0.75rem; + margin-bottom: -1px; + font-size: 0.875rem; + font-weight: 500; + color: #6b7280; + cursor: pointer; + transition: + color 0.12s ease-out, + border-color 0.12s ease-out; + + &:hover { + color: var(--color-brand-primary-600, #1d4ed8); + } + + &:focus-visible { + outline: 2px solid var(--color-brand-primary-500, #2563eb); + outline-offset: 2px; + border-radius: 0.125rem; + } +} + +:host-context(.dark) .tab { + color: #9ca3af; + + &:hover { + color: var(--color-brand-primary-300, #93c5fd); + } +} + +.tab--active { + color: var(--color-brand-primary-600, #1d4ed8); + border-bottom-color: var(--color-brand-primary-600, #1d4ed8); + font-weight: 600; +} + +:host-context(.dark) .tab--active { + color: var(--color-brand-primary-300, #93c5fd); + border-bottom-color: var(--color-brand-primary-300, #93c5fd); +} + +.tab-panel { + // No box-styling — the panel inherits the page's surface. + // `[hidden]` is set imperatively when the tab isn't active. +} + +// Charts panel container. Same surface tokens as `.filters` so the +// chart stack reads as one related block. .charts { margin: 0 0 1rem; padding: 1rem; diff --git a/apps/portal-admin/src/app/pages/audit/audit.spec.ts b/apps/portal-admin/src/app/pages/audit/audit.spec.ts index 30a03a9..2d146b8 100644 --- a/apps/portal-admin/src/app/pages/audit/audit.spec.ts +++ b/apps/portal-admin/src/app/pages/audit/audit.spec.ts @@ -5,8 +5,27 @@ import { AuditEventsService, type AdminAuditPage, type AdminAuditQuery, + type AdminAuditStats, + type AdminAuditStatsQuery, } from './audit-events.service'; +const STATS: AdminAuditStats = { + dailyVolume: [ + { day: '2026-05-12', count: 5 }, + { day: '2026-05-13', count: 7 }, + ], + outcomeBreakdown: [ + { outcome: 'success', count: 11 }, + { outcome: 'denied', count: 1 }, + ], + eventTypeByDay: [ + { day: '2026-05-12', eventType: 'auth.sign_in', count: 5 }, + { day: '2026-05-13', eventType: 'auth.sign_in', count: 6 }, + { day: '2026-05-13', eventType: 'admin.access_denied', count: 1 }, + ], + total: 12, +}; + const PAGE: AdminAuditPage = { items: [ { @@ -40,22 +59,34 @@ const PAGE: AdminAuditPage = { interface Fixture { fixture: ReturnType>; query: ReturnType; + stats: ReturnType; } -function setup(opts?: { initial?: AdminAuditPage | 'error'; status?: number }): Fixture { +function setup(opts?: { + initial?: AdminAuditPage | 'error'; + initialStats?: AdminAuditStats | 'error'; + status?: number; +}): Fixture { const initial = opts?.initial ?? PAGE; + const initialStats = opts?.initialStats ?? STATS; const query = vi.fn().mockImplementation(() => { if (initial === 'error') { return throwError(() => ({ status: opts?.status ?? 500 })); } return of(initial); }); + const stats = vi.fn().mockImplementation(() => { + if (initialStats === 'error') { + return throwError(() => ({ status: opts?.status ?? 500 })); + } + return of(initialStats); + }); TestBed.configureTestingModule({ imports: [AuditPage], - providers: [{ provide: AuditEventsService, useValue: { query } }], + providers: [{ provide: AuditEventsService, useValue: { query, stats } }], }); const fixture = TestBed.createComponent(AuditPage); - return { fixture, query }; + return { fixture, query, stats }; } async function flush(fixture: Fixture['fixture']): Promise { @@ -246,34 +277,132 @@ describe('AuditPage', () => { expect(row?.querySelector('.actor-hash')?.textContent?.trim()).toBe('(anonymous)'); }); - describe('charts', () => { - it('renders the three chart tiles above the table when the page has data', async () => { - const { fixture } = setup(); + describe('tabs + charts', () => { + it('defaults to the Table tab on first render — does NOT call the stats endpoint', async () => { + const { fixture, stats } = setup(); await flush(fixture); const root = fixture.nativeElement as HTMLElement; - expect(root.querySelector('section.charts')).not.toBeNull(); + const activeTab = root.querySelector('.tab--active'); + expect(activeTab?.textContent?.trim()).toBe('Table'); + expect(stats).not.toHaveBeenCalled(); + }); + + it('fetches stats when the user opens the Charts tab for the first time', async () => { + const { fixture, stats } = setup(); + await flush(fixture); + const root = fixture.nativeElement as HTMLElement; + const chartsTab = Array.from(root.querySelectorAll('.tab')).find( + (b) => b.textContent?.trim() === 'Charts', + ); + chartsTab?.click(); + await flush(fixture); + expect(stats).toHaveBeenCalledTimes(1); expect(root.querySelector('lib-bar-chart')).not.toBeNull(); expect(root.querySelector('lib-donut-chart')).not.toBeNull(); expect(root.querySelector('lib-stacked-bar-chart')).not.toBeNull(); }); - it('hides the charts section when the page is empty (no aggregations to show)', async () => { - const empty: AdminAuditPage = { items: [], total: 0, limit: 50, offset: 0 }; - const { fixture } = setup({ initial: empty }); + it('does NOT pass pagination knobs to the stats endpoint', async () => { + const { fixture, stats } = setup(); await flush(fixture); const root = fixture.nativeElement as HTMLElement; - expect(root.querySelector('section.charts')).toBeNull(); + Array.from(root.querySelectorAll('.tab')) + .find((b) => b.textContent?.trim() === 'Charts') + ?.click(); + await flush(fixture); + const filters = stats.mock.calls[0]?.[0] as AdminAuditStatsQuery & { + limit?: number; + offset?: number; + }; + expect(filters.limit).toBeUndefined(); + expect(filters.offset).toBeUndefined(); }); - it('passes the donut a centerLabel matching the current page item count', async () => { + it('does NOT re-fetch stats on pagination (filters unchanged)', async () => { + const { fixture, stats } = setup(); + await flush(fixture); + const root = fixture.nativeElement as HTMLElement; + Array.from(root.querySelectorAll('.tab')) + .find((b) => b.textContent?.trim() === 'Charts') + ?.click(); + await flush(fixture); + expect(stats).toHaveBeenCalledTimes(1); + // Switch back to Table and paginate. + Array.from(root.querySelectorAll('.tab')) + .find((b) => b.textContent?.trim() === 'Table') + ?.click(); + await flush(fixture); + // Open Charts again — cache still valid, no new stats call. + Array.from(root.querySelectorAll('.tab')) + .find((b) => b.textContent?.trim() === 'Charts') + ?.click(); + await flush(fixture); + expect(stats).toHaveBeenCalledTimes(1); + }); + + it('re-fetches stats when filters change and the Charts tab is active', async () => { + const { fixture, stats } = setup(); + await flush(fixture); + // Open Charts to load the initial stats. + const root = fixture.nativeElement as HTMLElement; + Array.from(root.querySelectorAll('.tab')) + .find((b) => b.textContent?.trim() === 'Charts') + ?.click(); + await flush(fixture); + expect(stats).toHaveBeenCalledTimes(1); + + // Component-state mutation: tweak a filter and re-search. + const cmp = fixture.componentInstance as unknown as { + eventType: { set(v: string): void }; + search(): Promise; + }; + cmp.eventType.set('auth.sign_in'); + await cmp.search(); + await flush(fixture); + + expect(stats).toHaveBeenCalledTimes(2); + }); + + it('shows the donut centre label with the server-side total (not the per-page count)', async () => { const { fixture } = setup(); await flush(fixture); const root = fixture.nativeElement as HTMLElement; - // Donut's
fallback row count matches the categories; - // and the SVG carries the centre label as a with the - // PAGE.items.length value. + Array.from(root.querySelectorAll('.tab')) + .find((b) => b.textContent?.trim() === 'Charts') + ?.click(); + await flush(fixture); const donutCenter = root.querySelector('lib-donut-chart .donut-center-label'); - expect(donutCenter?.textContent?.trim()).toBe(String(PAGE.items.length)); + expect(donutCenter?.textContent?.trim()).toBe(String(STATS.total)); + }); + + it('shows the empty-state message when the stats endpoint returns 0 events', async () => { + const emptyStats: AdminAuditStats = { + dailyVolume: [], + outcomeBreakdown: [], + eventTypeByDay: [], + total: 0, + }; + const { fixture } = setup({ initialStats: emptyStats }); + await flush(fixture); + const root = fixture.nativeElement as HTMLElement; + Array.from(root.querySelectorAll('.tab')) + .find((b) => b.textContent?.trim() === 'Charts') + ?.click(); + await flush(fixture); + expect(root.querySelector('lib-bar-chart')).toBeNull(); + expect(root.querySelector('#panel-charts .empty')).not.toBeNull(); + }); + + it('renders a permission-aware error when the stats endpoint returns 403', async () => { + const { fixture } = setup({ initialStats: 'error', status: 403 }); + await flush(fixture); + const root = fixture.nativeElement as HTMLElement; + Array.from(root.querySelectorAll('.tab')) + .find((b) => b.textContent?.trim() === 'Charts') + ?.click(); + await flush(fixture); + const errMsg = root.querySelector('.status-line--error')?.textContent?.trim(); + expect(errMsg).toContain('do not have access'); }); }); }); diff --git a/apps/portal-admin/src/app/pages/audit/audit.ts b/apps/portal-admin/src/app/pages/audit/audit.ts index 789d4bf..ead5520 100644 --- a/apps/portal-admin/src/app/pages/audit/audit.ts +++ b/apps/portal-admin/src/app/pages/audit/audit.ts @@ -7,8 +7,13 @@ import { AuditEventsService, type AdminAuditPage, type AdminAuditQuery, + type AdminAuditStats, + type AdminAuditStatsQuery, } from './audit-events.service'; +/** Tabs shown above the audit content. */ +type AuditTab = 'table' | 'charts'; + /** Page-size options the SPA offers. Matches the BFF's `DEFAULT_LIMIT` (50) and `MAX_LIMIT` (200). */ const PAGE_SIZES = [25, 50, 100, 200] as const; @@ -89,69 +94,52 @@ export class AuditPage { }); // --------------------------------------------------------------- - // Chart aggregations — derived from the *current page only* per - // PR 3 of the charts chantier. The captions explicitly say so; - // server-side aggregations across the full filter set land in a - // separate chantier (would need a new `/api/admin/audit/stats` - // endpoint, out of scope for the v1 visual test). + // Tabs + server-side stats — Charts tab consumes + // `GET /api/admin/audit/stats` (full filtered set, Redis-cached + // 5 min per filter-hash per ADR-0013 §"Reader endpoints"). Fetch + // is lazy: only fires when the Charts tab is opened, then again + // when filters change with the Charts tab active. // --------------------------------------------------------------- - /** Total event count for the current page — drives the donut's centre label. */ - protected readonly totalOnPage = computed(() => this.page()?.items.length ?? 0); + protected readonly tab = signal('table'); + protected readonly stats = signal(null); + protected readonly statsLoading = signal(false); + protected readonly statsError = signal(null); - /** Events grouped by ISO date (YYYY-MM-DD) on the current page. */ - protected readonly dailyVolume = computed(() => { - const items = this.page()?.items ?? []; - const buckets = new Map(); - for (const ev of items) { - const day = ev.createdAt.slice(0, 10); - buckets.set(day, (buckets.get(day) ?? 0) + 1); - } - return Array.from(buckets, ([day, count]) => ({ day, count })).sort((a, b) => - a.day.localeCompare(b.day), - ); - }); - - /** Outcome counts on the current page (success / failure / denied). */ - protected readonly outcomeBreakdown = computed(() => { - const items = this.page()?.items ?? []; - const buckets = new Map(); - for (const ev of items) { - buckets.set(ev.outcome, (buckets.get(ev.outcome) ?? 0) + 1); - } - return Array.from(buckets, ([outcome, count]) => ({ outcome, count })); - }); - - /** - * Daily counts split by event_type for the current page. The - * stacked-bar component pre-pivots internally — we feed a flat - * (day, eventType, count) triple. - */ - protected readonly dailyByEventType = computed(() => { - const items = this.page()?.items ?? []; - const buckets = new Map(); - for (const ev of items) { - const day = ev.createdAt.slice(0, 10); - const key = `${day}::${ev.eventType}`; - buckets.set(key, (buckets.get(key) ?? 0) + 1); - } - return Array.from(buckets, ([key, count]) => { - const [day, eventType] = key.split('::'); - return { day: day ?? '', eventType: eventType ?? '', count }; - }).sort((a, b) => a.day.localeCompare(b.day) || a.eventType.localeCompare(b.eventType)); - }); - - /** Whether any chart has enough data to be worth rendering. */ - protected readonly hasChartData = computed(() => (this.page()?.items.length ?? 0) > 0); + /** True when the loaded stats are non-empty (total > 0). */ + protected readonly hasChartData = computed(() => (this.stats()?.total ?? 0) > 0); /** * Run a new query with the current filter signals at offset 0. * Called by the "Apply filters" button + the initial render via * the template's `@defer (on viewport)` block. + * + * When the user applies new filters, the loaded `stats` becomes + * stale — clear it so the next visit to the Charts tab refetches. + * If the Charts tab is currently active, refetch immediately; + * otherwise wait for the user to open it. */ async search(): Promise { this.offset.set(0); - await this.fetch(); + this.stats.set(null); + const tasks: Array> = [this.fetch()]; + if (this.tab() === 'charts') { + tasks.push(this.fetchStats()); + } + await Promise.all(tasks); + } + + /** + * Switch to a tab. On the first switch to `charts` (or any switch + * that follows a filter change), fire the stats fetch — pagination + * within the table doesn't invalidate stats (the filtered set is + * unchanged), so re-opening Charts after paginating is free. + */ + async setTab(tab: AuditTab): Promise { + this.tab.set(tab); + if (tab === 'charts' && this.stats() === null && !this.statsLoading()) { + await this.fetchStats(); + } } /** Page navigation — moves the offset by `limit`, then fetches. */ @@ -177,7 +165,12 @@ export class AuditPage { this.createdAtFrom.set(''); this.createdAtTo.set(''); this.offset.set(0); - await this.fetch(); + this.stats.set(null); + const tasks: Array> = [this.fetch()]; + if (this.tab() === 'charts') { + tasks.push(this.fetchStats()); + } + await Promise.all(tasks); } /** Format an ISO timestamp into the user's locale for the table. */ @@ -223,7 +216,12 @@ export class AuditPage { if (!hash) return; this.actorIdHash.set(hash); this.offset.set(0); - await this.fetch(); + this.stats.set(null); + const tasks: Array> = [this.fetch()]; + if (this.tab() === 'charts') { + tasks.push(this.fetchStats()); + } + await Promise.all(tasks); } private async fetch(): Promise { @@ -265,6 +263,37 @@ export class AuditPage { }; } + /** + * Stats endpoint call. Shares the filter shape with `fetch` minus + * pagination — `buildStatsFilters` strips `limit/offset`. + */ + private async fetchStats(): Promise { + this.statsLoading.set(true); + this.statsError.set(null); + try { + const filters = this.buildStatsFilters(); + const stats = await firstValueFrom(this.service.stats(filters)); + this.stats.set(stats); + } catch (err) { + const status = errorStatus(err); + this.statsError.set( + status === 403 + ? 'You do not have access to the audit log on this session.' + : 'Could not load audit statistics. Try again in a moment.', + ); + this.stats.set(null); + } finally { + this.statsLoading.set(false); + } + } + + private buildStatsFilters(): AdminAuditStatsQuery { + const { limit: _limit, offset: _offset, ...rest } = this.buildFilters(); + void _limit; + void _offset; + return rest; + } + ngOnInit(): void { // Fire the initial unfiltered query so the table renders with // the most recent events on first paint. Fire-and-forget — the