feat(portal-admin): audit dashboard — bar / donut / stacked-bar above the table (#172)
## Summary PR 3 (final) of the charts chantier — closes the loop on [ADR-0023](docs/decisions/0023-charts-d3-observable-plot.md) by wiring the three starter components shipped in #171 onto the `/audit` page. ``` PR 1 ✅ — ADR-0023 (decision + a11y contract + bundle plan). PR 2 ✅ — libs/shared/charts/ foundations + bar / donut / stacked-bar. PR 3 (this one) — /audit page integration: three charts above the existing table. ``` ## What lands ### Three computed aggregations on `AuditPage` ```ts dailyVolume() // (day: 'YYYY-MM-DD', count: number)[] outcomeBreakdown() // (outcome: string, count: number)[] dailyByEventType() // (day, eventType, count)[] — flat, the chart pre-pivots totalOnPage() // number for the donut centre label hasChartData() // gate the section out when the page is empty ``` All four derive from the **current page only** (`page()?.items`). No new BFF endpoint — server-side aggregations across the full filter set would need a `/api/admin/audit/stats` resource that's worth its own ADR + chantier when the use case appears. ### Section markup — [`audit.html`](apps/portal-admin/src/app/pages/audit/audit.html) A new `<section class="charts">` between the status bar and the existing table, gated on `hasChartData()`: ``` [At a glance — current page] [charts-note: "Aggregations are computed from the events currently loaded on this page only…"] ┌─ Events per day (bar) ─┬─ Outcome breakdown (donut) ─┐ ├─ Events per day, by event type (stacked bar) — wide ┤ └──────────────────────────────────────────────────────┘ ``` The stacked-bar tile gets `.chart-tile--wide` (spans both grid columns) since its legend benefits from the horizontal space. The two-column grid collapses to one column under 800 px viewports. ### SCSS — [`audit.scss`](apps/portal-admin/src/app/pages/audit/audit.scss) `.charts` shares the same surface tokens as `.filters` + `.table-wrap` (white / gray-800 background, 1 px border, 0.5 rem radius) so the page reads as one stack of related blocks. `.charts-grid` is `display: grid; grid-template-columns: repeat(2, 1fr)` with a `@media (max-width: 800px)` fallback to single-column. ### Project budget — [`apps/portal-admin/project.json`](apps/portal-admin/project.json) `anyComponentStyle` budget bumped from 5/6 KB warn/error to **6/8 KB**. `audit.scss` lands at ~6.5 KB after the charts grid additions, comfortably under the new ceiling. The old 5/6 KB threshold predated the charts row; this is a one-time accommodation, not a global relaxation. ### Tsconfig wiring — [`apps/portal-admin/tsconfig.app.json`](apps/portal-admin/tsconfig.app.json) `nx sync` added the new project reference to `libs/shared/charts/tsconfig.lib.json` after the `import { BarChart, … } from 'shared-charts'` in `audit.ts`. Standard plumbing. ### Spec — [`audit.spec.ts`](apps/portal-admin/src/app/pages/audit/audit.spec.ts) Three new assertions under a `charts` describe block: 1. The three `<lib-*-chart>` elements render when the page has data. 2. `<section class="charts">` is **absent** when the page is empty (no half-rendered donut on `{ total: 0, items: [] }`). 3. The donut's `.donut-center-label` reads the page's item count — verifies the `[centerLabel]` binding wired correctly. ## Notes for the reviewer - **Why charts only on the current page, not on the full result set?** Per the "Don't add features beyond what the task requires" rule from CLAUDE.md. The user's brief was "tester les composants sur la page Audit log", not "design a full analytics dashboard". The `.charts-note` paragraph explicitly says "computed from the events currently loaded on this page only" so the limitation is **disclosed**, not hidden. A future server-side `/api/admin/audit/stats` (with `audit_reader`-scoped queries + caching) is the natural follow-up if real investigative use exposes the per-page scope as a friction point. - **Why `<section>` with its own `<h2>` rather than just inline tiles?** A11y. Per [ADR-0016](docs/decisions/0016-accessibility-baseline-wcag-aa-targeted-aaa.md), document structure is a first-class concern. The charts are a meaningful sub-region of the page; landmark + heading help screen-reader users navigate. - **Why no i18n marks on the captions?** Portal-admin chrome stays in source locale per ADR-0020. The audit page has zero i18n markers today (filter labels, table headers, error messages are all plain English); the chart captions land in the same posture for consistency. If portal-admin grows i18n later, the captions get marked in the same sweep as the rest of the page. - **Bundle impact reality-check**: the chart-bearing lazy `audit` chunk grew from ~4.4 KB gzip to **84 KB gzip** with the chart deps loaded. ADR-0023 estimated ~65 KB; the actual is higher because Plot pulls in more `d3-scale-chromatic` interpolators than I projected. Still well under the 100 KB cap from [ADR-0017](docs/decisions/0017-performance-budgets-lighthouse-ci.md), but worth noting if a fourth chart type with its own d3 submodule lands (line / scatter / heatmap will push it further). - **No anonymous-state regression on the table**: the existing trace-link + actor-pivot behaviours from #163 are unaffected — the charts section sits between the status bar and the table, doesn't share any markup. ## Test plan - [x] `pnpm nx test portal-admin` — **57 specs pass** (was 54; +3 for the new charts assertions). - [x] `pnpm nx run-many -t lint test build --projects=portal-shell,portal-admin,shared-charts` — 9/9 tasks green. - [x] Audit lazy chunk: **84 KB gzip** (was 4.4 KB before the chart deps loaded); under the 100 KB lazy-chunk budget. - [ ] **Manual visual smoke** — sign in to portal-admin with `Portal.Admin` → navigate `/audit`: - The three charts render above the filter form, in a 2-column grid (stacked-bar spanning both). - The donut centre label reads the current page's event count. - Apply a filter that narrows the page to ~3 events → charts re-render with the narrowed dataset. - Click the `<details>` "Data table" disclosure under each chart → the tabular fallback expands with the exact rows. - Toggle dark mode in the footer → axis text + chart envelope swap; the donut's Cividis-equivalent palette kicks in on the categorical chart too (palette already swapped by `resolveTheme()` in the lib). - Filter to an empty result set → the `.charts` section disappears, the "No audit events match the current filters" empty-state stays. ## What's next Chantier closed. Three light follow-ups stay open but optional: - **Server-side aggregations** if the per-page scope becomes a real friction point. New BFF endpoint + likely a fourth `time-range` selector on the SPA. - **More chart types** (line / scatter / heatmap) when business modules ask for them. The lib's `_internal/` carries the a11y plumbing already; each new component is the ~50 LOC Plot wrapper + spec. - **Promote shared chart styling** to a fourth shared concern in `libs/shared/ui/` if a third app ever joins. Not urgent. --------- Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr> Reviewed-on: #172
This commit was merged in pull request #172.
This commit is contained in:
@@ -52,8 +52,8 @@
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "5kb",
|
||||
"maximumError": "6kb"
|
||||
"maximumWarning": "6kb",
|
||||
"maximumError": "8kb"
|
||||
},
|
||||
{
|
||||
"type": "bundle",
|
||||
|
||||
@@ -113,7 +113,53 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (page(); as p) { @if (p.items.length === 0) {
|
||||
@if (hasChartData()) {
|
||||
<section class="charts" aria-labelledby="charts-h">
|
||||
<h2 id="charts-h" class="charts-heading">At a glance — current page</h2>
|
||||
<p class="charts-note">
|
||||
Aggregations are computed from the events currently loaded on this page only. Apply filters or
|
||||
change the page size to widen / narrow the window.
|
||||
</p>
|
||||
<div class="charts-grid">
|
||||
<div class="chart-tile">
|
||||
<lib-bar-chart
|
||||
[data]="dailyVolume()"
|
||||
xKey="day"
|
||||
yKey="count"
|
||||
xLabel="Date (UTC)"
|
||||
yLabel="Events"
|
||||
caption="Events per day"
|
||||
description="Bar chart of audit event volume per calendar day for the events on this page."
|
||||
ariaLabel="Events per day — bar chart"
|
||||
/>
|
||||
</div>
|
||||
<div class="chart-tile">
|
||||
<lib-donut-chart
|
||||
[data]="outcomeBreakdown()"
|
||||
categoryKey="outcome"
|
||||
valueKey="count"
|
||||
[centerLabel]="totalOnPage().toString()"
|
||||
caption="Outcome breakdown"
|
||||
description="Donut chart of audit event outcomes (success, failure, denied) on this page."
|
||||
ariaLabel="Outcome breakdown — donut chart"
|
||||
/>
|
||||
</div>
|
||||
<div class="chart-tile chart-tile--wide">
|
||||
<lib-stacked-bar-chart
|
||||
[data]="dailyByEventType()"
|
||||
xKey="day"
|
||||
yKey="count"
|
||||
seriesKey="eventType"
|
||||
xLabel="Date (UTC)"
|
||||
yLabel="Events"
|
||||
caption="Events per day, by event type"
|
||||
description="Stacked bar chart of audit event volume per calendar day, broken down by event_type, for events on this page."
|
||||
ariaLabel="Events per day, by event type — stacked bar chart"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
} @if (page(); as p) { @if (p.items.length === 0) {
|
||||
<p class="empty">No audit events match the current filters.</p>
|
||||
} @else {
|
||||
<div class="table-wrap">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 <details> fallback row count matches the categories;
|
||||
// and the SVG carries the centre label as a <text> 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));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, number>();
|
||||
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<string, number>();
|
||||
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<string, number>();
|
||||
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
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user