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"
},