From 7d56802e1d581585ec643f6046d86ef5170f73fc Mon Sep 17 00:00:00 2001 From: Julien Gautier Date: Sat, 16 May 2026 22:14:21 +0200 Subject: [PATCH] =?UTF-8?q?feat(portal-admin):=20audit=20dashboard=20?= =?UTF-8?q?=E2=80=94=20bar=20/=20donut=20/=20stacked-bar=20above=20the=20t?= =?UTF-8?q?able?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR 3 of the charts chantier — closes the loop on ADR-0023 by wiring the three starter components from #171 onto the /audit page. Three computed signals derive aggregations from the *current page's* items (no new BFF endpoint — server-side stats land separately if ever needed): * `dailyVolume` — events grouped by ISO date (YYYY-MM-DD). * `outcomeBreakdown` — counts per outcome. * `dailyByEventType` — flat (day, eventType, count) triples. A new `
` renders above the existing filter form, behind a `hasChartData()` guard so the section disappears entirely on empty pages (no "0 events" donut, no half-rendered bars). Each chart sits in a `.chart-tile`; the stacked-bar gets `.chart-tile--wide` to span both grid columns since its legend benefits from the extra horizontal space. A `.charts-note` paragraph above the grid says explicitly that the aggregations are "computed from the events currently loaded on this page only" — honest disclosure of the per-page scope, no misleading "all-time" framing. Captions / descriptions / aria-labels live in plain English in the template per ADR-0020's source-locale-only chrome posture. No new i18n strings. Bundle impact: the lazy `audit` chunk grows from ~4.4 KB to ~84 KB gzip with the chart deps loaded — comfortable under [ADR-0017]'s 100 KB cap. The estimate in ADR-0023 was ~65 KB; Plot's full mark set + d3-scale-chromatic interpolators add a bit more than projected but still fit the budget. Side tweaks: * `apps/portal-admin/project.json` bumps the `anyComponentStyle` budget from 5/6 KB to 6/8 KB to accommodate the charts grid SCSS (audit.scss lands at ~6.5 KB). * `apps/portal-admin/tsconfig.app.json` references the new `libs/shared/charts/tsconfig.lib.json` (auto-added by `nx sync` after the import in audit.ts). Verification: * 57 portal-admin specs pass (was 54; +3 for the charts section rendering, the empty-page hide guard, and the donut center label binding). * `pnpm nx run-many -t lint test build --projects=portal-shell, portal-admin,shared-charts` — green. * Audit chunk gzip = 84 KB, under the budget. --- apps/portal-admin/project.json | 4 +- .../src/app/pages/audit/audit.html | 48 ++++++++++++++- .../src/app/pages/audit/audit.scss | 52 ++++++++++++++++ .../src/app/pages/audit/audit.spec.ts | 31 ++++++++++ .../portal-admin/src/app/pages/audit/audit.ts | 59 ++++++++++++++++++- apps/portal-admin/tsconfig.app.json | 3 + 6 files changed, 193 insertions(+), 4 deletions(-) diff --git a/apps/portal-admin/project.json b/apps/portal-admin/project.json index 6b61193..37b54e3 100644 --- a/apps/portal-admin/project.json +++ b/apps/portal-admin/project.json @@ -52,8 +52,8 @@ }, { "type": "anyComponentStyle", - "maximumWarning": "5kb", - "maximumError": "6kb" + "maximumWarning": "6kb", + "maximumError": "8kb" }, { "type": "bundle", diff --git a/apps/portal-admin/src/app/pages/audit/audit.html b/apps/portal-admin/src/app/pages/audit/audit.html index fd000fb..00434af 100644 --- a/apps/portal-admin/src/app/pages/audit/audit.html +++ b/apps/portal-admin/src/app/pages/audit/audit.html @@ -113,7 +113,53 @@ } - @if (page(); as p) { @if (p.items.length === 0) { + @if (hasChartData()) { +
+

At a glance — current page

+

+ Aggregations are computed from the events currently loaded on this page only. Apply filters or + change the page size to widen / narrow the window. +

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

No audit events match the current filters.

} @else {
diff --git a/apps/portal-admin/src/app/pages/audit/audit.scss b/apps/portal-admin/src/app/pages/audit/audit.scss index f3fcd65..a65149c 100644 --- a/apps/portal-admin/src/app/pages/audit/audit.scss +++ b/apps/portal-admin/src/app/pages/audit/audit.scss @@ -194,6 +194,58 @@ color: #9ca3af; } +// Chart row above the table — same surface tokens as `.filters` +// + `.table-wrap` so the page reads as one stack of related blocks. +.charts { + margin: 0 0 1rem; + padding: 1rem; + background-color: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 0.5rem; +} + +:host-context(.dark) .charts { + background-color: #111827; + border-color: #1f2937; +} + +.charts-heading { + margin: 0; + font-size: 0.875rem; + font-weight: 600; + color: #1f2937; + + :where(.dark) & { + color: #f3f4f6; + } +} + +.charts-note { + margin: 0.25rem 0 1rem; + font-size: 0.75rem; + color: #6b7280; + + :where(.dark) & { + color: #9ca3af; + } +} + +.charts-grid { + display: grid; + gap: 1rem; + grid-template-columns: repeat(2, minmax(0, 1fr)); + + @media (max-width: 800px) { + grid-template-columns: 1fr; + } +} + +// The stacked-bar chart carries a legend and benefits from the +// full width; flag it via a modifier and span both columns. +.chart-tile--wide { + grid-column: 1 / -1; +} + .table-wrap { overflow-x: auto; background-color: #ffffff; 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 f3401af..30a03a9 100644 --- a/apps/portal-admin/src/app/pages/audit/audit.spec.ts +++ b/apps/portal-admin/src/app/pages/audit/audit.spec.ts @@ -245,4 +245,35 @@ describe('AuditPage', () => { expect(row?.querySelector('.actor-hash--clickable')).toBeNull(); 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(); + await flush(fixture); + const root = fixture.nativeElement as HTMLElement; + expect(root.querySelector('section.charts')).not.toBeNull(); + 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 }); + await flush(fixture); + const root = fixture.nativeElement as HTMLElement; + expect(root.querySelector('section.charts')).toBeNull(); + }); + + it('passes the donut a centerLabel matching the current page item 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. + const donutCenter = root.querySelector('lib-donut-chart .donut-center-label'); + expect(donutCenter?.textContent?.trim()).toBe(String(PAGE.items.length)); + }); + }); }); diff --git a/apps/portal-admin/src/app/pages/audit/audit.ts b/apps/portal-admin/src/app/pages/audit/audit.ts index 1c5c7d8..789d4bf 100644 --- a/apps/portal-admin/src/app/pages/audit/audit.ts +++ b/apps/portal-admin/src/app/pages/audit/audit.ts @@ -1,6 +1,7 @@ import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { firstValueFrom } from 'rxjs'; +import { BarChart, DonutChart, StackedBarChart } from 'shared-charts'; import { environment } from '../../../environments/environment'; import { AuditEventsService, @@ -31,7 +32,7 @@ const PAGE_SIZES = [25, 50, 100, 200] as const; */ @Component({ selector: 'app-audit-page', - imports: [FormsModule], + imports: [FormsModule, BarChart, DonutChart, StackedBarChart], templateUrl: './audit.html', styleUrl: './audit.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -87,6 +88,62 @@ export class AuditPage { return `${first}–${last} of ${p.total}`; }); + // --------------------------------------------------------------- + // 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). + // --------------------------------------------------------------- + + /** Total event count for the current page — drives the donut's centre label. */ + protected readonly totalOnPage = computed(() => this.page()?.items.length ?? 0); + + /** 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); + /** * Run a new query with the current filter signals at offset 0. * Called by the "Apply filters" button + the initial render via diff --git a/apps/portal-admin/tsconfig.app.json b/apps/portal-admin/tsconfig.app.json index 4ff1d86..4fab7be 100644 --- a/apps/portal-admin/tsconfig.app.json +++ b/apps/portal-admin/tsconfig.app.json @@ -7,6 +7,9 @@ "include": ["src/**/*.ts"], "exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"], "references": [ + { + "path": "../../libs/shared/charts/tsconfig.lib.json" + }, { "path": "../../libs/shared/ui/tsconfig.lib.json" }, -- 2.30.2