From 299e66fde5316f94de5cf8791a0d2e111456b393 Mon Sep 17 00:00:00 2001 From: Julien Gautier Date: Sat, 16 May 2026 23:47:16 +0200 Subject: [PATCH] feat(portal-admin): audit log tabs (Table / Charts) + server-side stats consumption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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` — 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. --- .../app/pages/audit/audit-events.service.ts | 36 ++- .../src/app/pages/audit/audit.html | 268 +++++++++++------- .../src/app/pages/audit/audit.scss | 71 ++++- .../src/app/pages/audit/audit.spec.ts | 161 +++++++++-- .../portal-admin/src/app/pages/audit/audit.ts | 137 +++++---- 5 files changed, 494 insertions(+), 179 deletions(-) diff --git a/apps/portal-admin/src/app/pages/audit/audit-events.service.ts b/apps/portal-admin/src/app/pages/audit/audit-events.service.ts index 385a4ac..39f5994 100644 --- a/apps/portal-admin/src/app/pages/audit/audit-events.service.ts +++ b/apps/portal-admin/src/app/pages/audit/audit-events.service.ts @@ -28,6 +28,24 @@ export interface AdminAuditPage { readonly offset: number; } +/** + * Aggregated audit-event stats — mirrors `AdminAuditStats` on the + * BFF side. Powers the Charts tab of the audit viewer. + */ +export interface AdminAuditStats { + readonly dailyVolume: ReadonlyArray<{ day: string; count: number }>; + readonly outcomeBreakdown: ReadonlyArray<{ outcome: string; count: number }>; + readonly eventTypeByDay: ReadonlyArray<{ day: string; eventType: string; count: number }>; + readonly total: number; +} + +/** + * Filter shape sent as query params on the stats endpoint. Same as + * {@link AdminAuditQuery} minus pagination — the stats endpoint + * always aggregates the whole filtered set. + */ +export type AdminAuditStatsQuery = Omit; + /** * Filter+pagination shape sent as query params. All fields are * optional; missing values fall back to the BFF defaults (limit=50, @@ -65,6 +83,22 @@ export class AuditEventsService { private readonly bffBaseUrl = inject(AUTH_BFF_BASE_URL); query(filters: AdminAuditQuery): Observable { + const params = this.toHttpParams({ ...filters }); + return this.http.get(`${this.bffBaseUrl}/admin/audit`, { params }); + } + + /** + * Calls `GET /api/admin/audit/stats` with the same filter shape + * minus pagination. The endpoint Redis-caches results for 5 + * minutes per filter-hash (see ADR-0013 §"Reader endpoints"); the + * SPA itself doesn't try to cache. + */ + stats(filters: AdminAuditStatsQuery): Observable { + const params = this.toHttpParams({ ...filters }); + return this.http.get(`${this.bffBaseUrl}/admin/audit/stats`, { params }); + } + + private toHttpParams(filters: Record): HttpParams { let params = new HttpParams(); for (const [key, value] of Object.entries(filters)) { // Drop empty strings — Nest's ValidationPipe treats `?foo=` @@ -77,6 +111,6 @@ export class AuditEventsService { } params = params.set(key, String(value)); } - return this.http.get(`${this.bffBaseUrl}/admin/audit`, { params }); + return params; } } diff --git a/apps/portal-admin/src/app/pages/audit/audit.html b/apps/portal-admin/src/app/pages/audit/audit.html index 00434af..a9eb355 100644 --- a/apps/portal-admin/src/app/pages/audit/audit.html +++ b/apps/portal-admin/src/app/pages/audit/audit.html @@ -103,153 +103,209 @@ +
+ + +
+
- @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 -- 2.30.2