feat(portal-admin): audit dashboard — bar / donut / stacked-bar above the table
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 `<section class="charts">` 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.
This commit is contained in:
@@ -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));
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user