feat(portal-admin): audit dashboard — bar / donut / stacked-bar above the table #172

Merged
julien merged 1 commits from feat/portal-admin-audit-charts into main 2026-05-16 22:18:23 +02:00
6 changed files with 193 additions and 4 deletions
+2 -2
View File
@@ -52,8 +52,8 @@
}, },
{ {
"type": "anyComponentStyle", "type": "anyComponentStyle",
"maximumWarning": "5kb", "maximumWarning": "6kb",
"maximumError": "6kb" "maximumError": "8kb"
}, },
{ {
"type": "bundle", "type": "bundle",
@@ -113,7 +113,53 @@
} }
</div> </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> <p class="empty">No audit events match the current filters.</p>
} @else { } @else {
<div class="table-wrap"> <div class="table-wrap">
@@ -194,6 +194,58 @@
color: #9ca3af; 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 { .table-wrap {
overflow-x: auto; overflow-x: auto;
background-color: #ffffff; background-color: #ffffff;
@@ -245,4 +245,35 @@ describe('AuditPage', () => {
expect(row?.querySelector('.actor-hash--clickable')).toBeNull(); expect(row?.querySelector('.actor-hash--clickable')).toBeNull();
expect(row?.querySelector('.actor-hash')?.textContent?.trim()).toBe('(anonymous)'); 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));
});
});
}); });
+58 -1
View File
@@ -1,6 +1,7 @@
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'; import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { firstValueFrom } from 'rxjs'; import { firstValueFrom } from 'rxjs';
import { BarChart, DonutChart, StackedBarChart } from 'shared-charts';
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
import { import {
AuditEventsService, AuditEventsService,
@@ -31,7 +32,7 @@ const PAGE_SIZES = [25, 50, 100, 200] as const;
*/ */
@Component({ @Component({
selector: 'app-audit-page', selector: 'app-audit-page',
imports: [FormsModule], imports: [FormsModule, BarChart, DonutChart, StackedBarChart],
templateUrl: './audit.html', templateUrl: './audit.html',
styleUrl: './audit.scss', styleUrl: './audit.scss',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@@ -87,6 +88,62 @@ export class AuditPage {
return `${first}${last} of ${p.total}`; 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. * Run a new query with the current filter signals at offset 0.
* Called by the "Apply filters" button + the initial render via * Called by the "Apply filters" button + the initial render via
+3
View File
@@ -7,6 +7,9 @@
"include": ["src/**/*.ts"], "include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"], "exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"],
"references": [ "references": [
{
"path": "../../libs/shared/charts/tsconfig.lib.json"
},
{ {
"path": "../../libs/shared/ui/tsconfig.lib.json" "path": "../../libs/shared/ui/tsconfig.lib.json"
}, },