feat(portal-admin): audit log tabs (Table / Charts) + server-side stats consumption (#174)
## Summary PR 2 of the tabs + full-result-charts chantier — closes the loop opened in #173. The audit log page now splits into a **Table** tab (existing behaviour) and a **Charts** tab fed by the server-side stats endpoint shipped in PR 1. ``` PR 1 (#173, merged) — BFF GET /api/admin/audit/stats + Redis 5min cache + admin.audit.stats.query audit event + ADR-0013 amendment. PR 2 (this one) — SPA Tabs UX (Table / Charts) + consume the stats endpoint, replacing the per-page client-side aggregations from #172. ``` ## What lands ### Tabs UX — WAI-ARIA tab pattern The two tabs sit between the filter card and the content area. Visual treatment is intentionally minimal: thin brand-coloured underline on the active tab, focus rings on `:focus-visible`, no surrounding chrome. The panel below inherits the page surface so each tab swap reads as a content-only change. ARIA wiring: - `<div role="tablist">` with two `<button role="tab">` children. - `aria-selected` mirrors the active tab; `aria-controls` points each tab at its panel id; roving `tabindex` (active = `0`, inactive = `-1`) keeps Tab linear. - Arrow-key navigation between tabs is bound on the individual buttons (not the tablist div) — focusable elements only, satisfies the `template/click-events-have-key-events` lint without an artificial `tabindex="-1"` on the container. - Two `<section role="tabpanel">` with `[hidden]` binding, `aria-labelledby` pointing back at the tab id. ### Stats consumption — replaces the per-page computeds `AuditEventsService` grows a `stats(filters)` method that calls `GET /api/admin/audit/stats` and returns `AdminAuditStats` (mirror of the BFF DTO). The audit page replaces the four per-page `computed()`s — `totalOnPage`, `dailyVolume`, `outcomeBreakdown`, `dailyByEventType` — with four signals: ```ts readonly stats = signal<AdminAuditStats | null>(null); readonly statsLoading = signal(false); readonly statsError = signal<string | null>(null); readonly hasChartData = computed(() => (this.stats()?.total ?? 0) > 0); ``` The chart-tile components on the Charts panel consume `stats()?.dailyVolume`, `stats()?.outcomeBreakdown`, `stats()?.eventTypeByDay`, and `stats()?.total` unchanged — same shape as the old per-page projections, just sourced server-side. ### Lazy fetch policy | Interaction | Action on `stats` | | ------------------------ | ---------------------------------------------- | | Page load (Table active) | No call — default tab is Table, stats untouched | | Click Charts (first time) | Fetch | | Click Table → Charts | Fetch only if cleared by a filter change | | Apply / clear filters | Clear `stats`, re-fetch **only if Charts active** | | Filter by row's actor | Clear `stats`, re-fetch if Charts active | | Next / previous page | Do **not** invalidate stats (pagination is presentation, the aggregated set is unchanged) | The Redis cache in PR 1 absorbs repeated identical fetches at ~5 ms; the policy here just makes sure we don't fire a stats call when nobody's looking at it. ### Honest panel copy The Charts panel's note now reads: > Aggregations are computed across the full filtered set (server-side), not just the events on the current page. Results are cached for 5 minutes per filter combination. Replaces the previous "this only reflects the current page" disclaimer that #172 carried as a temporary truth. ## Notes for the reviewer - **Why a hand-coded tablist instead of a shared `libs/shared/ui/tabs` primitive?** Two tabs, one consumer, ~30 LOC of template + ~25 LOC of SCSS. The two-occurrences-is-duplication rule says wait until a second consumer appears (cf. CMS module or user-list module slated for the next chantier), then extract with the shape both consumers have actually demanded. - **Why is `setTab()` `async`?** It triggers `fetchStats()` on the first switch to Charts, and the spec exercises that flow with `await`. Keeping it `async` lets the test sequence assertions deterministically without `tick()`/fakeAsync. - **Why does `search()` clear `stats` *before* the fetch even though `fetchStats()` clears it again at the top?** So the empty-state never flickers through a "stale 1000-event" total while the new fetch is in flight on a slow link. The double-clear is intentional. - **Bundle impact.** The audit chunk grew from ~82 KB to ~84.5 KB gzip (two new signals, a stats-mapping HTTP call, the tablist template + SCSS). Well under the lazy-chunk 100 KB ceiling from [ADR-0017](docs/decisions/0017-performance-budgets-lighthouse-ci.md). - **`audit.scss` is 7.5 KB** — over the 6 KB component-style warning, under the 8 KB error. The new `.tablist` / `.tab` rules account for the bump. If a third consumer of the tab pattern lands, the SCSS extraction comes with it. ## Test plan - [x] `pnpm nx test portal-admin` — **62 specs pass** (was 58: removed 3 obsolete "per-page chart" cases, added 7 new tabs/stats cases). - [x] `pnpm nx run portal-admin:lint` — clean (the two pre-existing `use-lifecycle-interface` warnings on `audit.ts` / `users.ts` and the spec non-null assertion are untouched, not regressions). - [x] `pnpm nx build portal-admin` — clean, audit chunk 84.5 KB gzip. - [x] `pnpm nx run-many -t lint test build -p portal-shell,portal-admin,portal-bff,shared-charts` — clean. - [ ] **Manual smoke** — `pnpm nx serve portal-admin` + `pnpm nx serve portal-bff`, sign in with `Portal.Admin`, navigate to `/admin/audit`: - Page loads on the Table tab — DevTools Network shows `GET /api/admin/audit` only, **no** `/audit/stats` call. - Click **Charts** → spinner state, then three chart tiles render with server-side totals. - Apply a filter (e.g. `eventType=auth.sign_in`) while on Charts → tiles re-render with the filtered aggregates; donut centre matches the server `total`. - Switch back to Table, page through results → no new `/audit/stats` calls (pagination doesn't invalidate). - Arrow-Right / Arrow-Left between the two tabs with keyboard — focus moves, ARIA selection follows, `Tab` skips past the inactive tab to the panel content. - Toggle dark mode → tablist + active underline + tab focus rings all read correctly in both modes. ## What's next - Persist the active tab in the URL (`?tab=charts`) so deep-linking and Back/Forward survive the swap. Out of scope for this chantier — once the user-list module lands and we have a second tab consumer, persistence becomes a shared concern. - Extract `libs/shared/ui/tabs` when a second consumer materialises (CMS or user-list module). - Surface cache observability — the stats endpoint Redis cache is silent in v1. A future ADR-0024 follow-up could add a `X-Audit-Stats-Cache: hit|miss` header for ops or an OTel attribute on the BFF span. Out of scope here, but worth flagging while the design is fresh. --------- Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr> Reviewed-on: #174
This commit was merged in pull request #174.
This commit is contained in:
@@ -28,6 +28,24 @@ export interface AdminAuditPage {
|
|||||||
readonly offset: number;
|
readonly offset: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregated audit-event stats — mirrors `AdminAuditStats` on the
|
||||||
|
* BFF side. Powers the Charts tab of the audit viewer.
|
||||||
|
*/
|
||||||
|
export interface AdminAuditStats {
|
||||||
|
readonly dailyVolume: ReadonlyArray<{ day: string; count: number }>;
|
||||||
|
readonly outcomeBreakdown: ReadonlyArray<{ outcome: string; count: number }>;
|
||||||
|
readonly eventTypeByDay: ReadonlyArray<{ day: string; eventType: string; count: number }>;
|
||||||
|
readonly total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter shape sent as query params on the stats endpoint. Same as
|
||||||
|
* {@link AdminAuditQuery} minus pagination — the stats endpoint
|
||||||
|
* always aggregates the whole filtered set.
|
||||||
|
*/
|
||||||
|
export type AdminAuditStatsQuery = Omit<AdminAuditQuery, 'limit' | 'offset'>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filter+pagination shape sent as query params. All fields are
|
* Filter+pagination shape sent as query params. All fields are
|
||||||
* optional; missing values fall back to the BFF defaults (limit=50,
|
* optional; missing values fall back to the BFF defaults (limit=50,
|
||||||
@@ -65,6 +83,22 @@ export class AuditEventsService {
|
|||||||
private readonly bffBaseUrl = inject(AUTH_BFF_BASE_URL);
|
private readonly bffBaseUrl = inject(AUTH_BFF_BASE_URL);
|
||||||
|
|
||||||
query(filters: AdminAuditQuery): Observable<AdminAuditPage> {
|
query(filters: AdminAuditQuery): Observable<AdminAuditPage> {
|
||||||
|
const params = this.toHttpParams({ ...filters });
|
||||||
|
return this.http.get<AdminAuditPage>(`${this.bffBaseUrl}/admin/audit`, { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calls `GET /api/admin/audit/stats` with the same filter shape
|
||||||
|
* minus pagination. The endpoint Redis-caches results for 5
|
||||||
|
* minutes per filter-hash (see ADR-0013 §"Reader endpoints"); the
|
||||||
|
* SPA itself doesn't try to cache.
|
||||||
|
*/
|
||||||
|
stats(filters: AdminAuditStatsQuery): Observable<AdminAuditStats> {
|
||||||
|
const params = this.toHttpParams({ ...filters });
|
||||||
|
return this.http.get<AdminAuditStats>(`${this.bffBaseUrl}/admin/audit/stats`, { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
private toHttpParams(filters: Record<string, unknown>): HttpParams {
|
||||||
let params = new HttpParams();
|
let params = new HttpParams();
|
||||||
for (const [key, value] of Object.entries(filters)) {
|
for (const [key, value] of Object.entries(filters)) {
|
||||||
// Drop empty strings — Nest's ValidationPipe treats `?foo=`
|
// Drop empty strings — Nest's ValidationPipe treats `?foo=`
|
||||||
@@ -77,6 +111,6 @@ export class AuditEventsService {
|
|||||||
}
|
}
|
||||||
params = params.set(key, String(value));
|
params = params.set(key, String(value));
|
||||||
}
|
}
|
||||||
return this.http.get<AdminAuditPage>(`${this.bffBaseUrl}/admin/audit`, { params });
|
return params;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,63 +103,118 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<div class="tablist" role="tablist" aria-label="Audit log views">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
id="tab-table"
|
||||||
|
class="tab"
|
||||||
|
[class.tab--active]="tab() === 'table'"
|
||||||
|
[attr.aria-selected]="tab() === 'table'"
|
||||||
|
[attr.aria-controls]="'panel-table'"
|
||||||
|
[attr.tabindex]="tab() === 'table' ? 0 : -1"
|
||||||
|
(click)="setTab('table')"
|
||||||
|
(keydown.arrowRight)="setTab('charts')"
|
||||||
|
>
|
||||||
|
Table
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
id="tab-charts"
|
||||||
|
class="tab"
|
||||||
|
[class.tab--active]="tab() === 'charts'"
|
||||||
|
[attr.aria-selected]="tab() === 'charts'"
|
||||||
|
[attr.aria-controls]="'panel-charts'"
|
||||||
|
[attr.tabindex]="tab() === 'charts' ? 0 : -1"
|
||||||
|
(click)="setTab('charts')"
|
||||||
|
(keydown.arrowLeft)="setTab('table')"
|
||||||
|
>
|
||||||
|
Charts
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="status-bar" role="status" aria-live="polite">
|
<div class="status-bar" role="status" aria-live="polite">
|
||||||
@if (loading()) {
|
@if (tab() === 'table') { @if (loading()) {
|
||||||
<span class="status-line">Loading…</span>
|
<span class="status-line">Loading…</span>
|
||||||
} @else if (error(); as msg) {
|
} @else if (error(); as msg) {
|
||||||
<span class="status-line status-line--error">{{ msg }}</span>
|
<span class="status-line status-line--error">{{ msg }}</span>
|
||||||
} @else if (page(); as p) {
|
} @else if (page(); as p) {
|
||||||
<span class="status-line">{{ resultRange() }}</span>
|
<span class="status-line">{{ resultRange() }}</span>
|
||||||
}
|
} } @else { @if (statsLoading()) {
|
||||||
|
<span class="status-line">Loading statistics…</span>
|
||||||
|
} @else if (statsError(); as msg) {
|
||||||
|
<span class="status-line status-line--error">{{ msg }}</span>
|
||||||
|
} @else if (stats(); as s) {
|
||||||
|
<span class="status-line">{{ s.total }} matching event(s) aggregated</span>
|
||||||
|
} }
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (hasChartData()) {
|
<section
|
||||||
<section class="charts" aria-labelledby="charts-h">
|
id="panel-charts"
|
||||||
<h2 id="charts-h" class="charts-heading">At a glance — current page</h2>
|
role="tabpanel"
|
||||||
|
aria-labelledby="tab-charts"
|
||||||
|
class="tab-panel"
|
||||||
|
[hidden]="tab() !== 'charts'"
|
||||||
|
>
|
||||||
|
@if (stats(); as s) { @if (hasChartData()) {
|
||||||
<p class="charts-note">
|
<p class="charts-note">
|
||||||
Aggregations are computed from the events currently loaded on this page only. Apply filters or
|
Aggregations are computed across the full filtered set (server-side), not just the events on
|
||||||
change the page size to widen / narrow the window.
|
the current page. Results are cached for 5 minutes per filter combination.
|
||||||
</p>
|
</p>
|
||||||
<div class="charts-grid">
|
<div class="charts-grid">
|
||||||
<div class="chart-tile">
|
<div class="chart-tile">
|
||||||
<lib-bar-chart
|
<lib-bar-chart
|
||||||
[data]="dailyVolume()"
|
[data]="s.dailyVolume"
|
||||||
xKey="day"
|
xKey="day"
|
||||||
yKey="count"
|
yKey="count"
|
||||||
xLabel="Date (UTC)"
|
xLabel="Date (UTC)"
|
||||||
yLabel="Events"
|
yLabel="Events"
|
||||||
caption="Events per day"
|
caption="Events per day"
|
||||||
description="Bar chart of audit event volume per calendar day for the events on this page."
|
description="Bar chart of audit event volume per calendar day across the full filtered set."
|
||||||
ariaLabel="Events per day — bar chart"
|
ariaLabel="Events per day — bar chart"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="chart-tile">
|
<div class="chart-tile">
|
||||||
<lib-donut-chart
|
<lib-donut-chart
|
||||||
[data]="outcomeBreakdown()"
|
[data]="s.outcomeBreakdown"
|
||||||
categoryKey="outcome"
|
categoryKey="outcome"
|
||||||
valueKey="count"
|
valueKey="count"
|
||||||
[centerLabel]="totalOnPage().toString()"
|
[centerLabel]="s.total.toString()"
|
||||||
caption="Outcome breakdown"
|
caption="Outcome breakdown"
|
||||||
description="Donut chart of audit event outcomes (success, failure, denied) on this page."
|
description="Donut chart of audit event outcomes (success, failure, denied) across the full filtered set."
|
||||||
ariaLabel="Outcome breakdown — donut chart"
|
ariaLabel="Outcome breakdown — donut chart"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="chart-tile chart-tile--wide">
|
<div class="chart-tile chart-tile--wide">
|
||||||
<lib-stacked-bar-chart
|
<lib-stacked-bar-chart
|
||||||
[data]="dailyByEventType()"
|
[data]="s.eventTypeByDay"
|
||||||
xKey="day"
|
xKey="day"
|
||||||
yKey="count"
|
yKey="count"
|
||||||
seriesKey="eventType"
|
seriesKey="eventType"
|
||||||
xLabel="Date (UTC)"
|
xLabel="Date (UTC)"
|
||||||
yLabel="Events"
|
yLabel="Events"
|
||||||
caption="Events per day, by event type"
|
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."
|
description="Stacked bar chart of audit event volume per calendar day, broken down by event_type, across the full filtered set."
|
||||||
ariaLabel="Events per day, by event type — stacked bar chart"
|
ariaLabel="Events per day, by event type — stacked bar chart"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
} @else {
|
||||||
|
<p class="empty">No audit events match the current filters.</p>
|
||||||
|
} } @else if (!statsLoading() && !statsError()) {
|
||||||
|
<p class="charts-note">Switch to this tab to load statistics.</p>
|
||||||
|
}
|
||||||
</section>
|
</section>
|
||||||
} @if (page(); as p) { @if (p.items.length === 0) {
|
|
||||||
|
<section
|
||||||
|
id="panel-table"
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby="tab-table"
|
||||||
|
class="tab-panel"
|
||||||
|
[hidden]="tab() !== 'table'"
|
||||||
|
>
|
||||||
|
@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">
|
||||||
@@ -252,4 +307,5 @@
|
|||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
} }
|
} }
|
||||||
|
</section>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -194,8 +194,75 @@
|
|||||||
color: #9ca3af;
|
color: #9ca3af;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Chart row above the table — same surface tokens as `.filters`
|
// Tabs above the content. WAI-ARIA pattern: `role="tablist"` on the
|
||||||
// + `.table-wrap` so the page reads as one stack of related blocks.
|
// container, `role="tab"` on each button, roving `tabindex` (the
|
||||||
|
// active tab is reachable via Tab, inactive ones via arrow keys
|
||||||
|
// once focus is in the list). The visual treatment is a thin
|
||||||
|
// underline on the active tab — minimal furniture, lets the panel
|
||||||
|
// content carry the eye.
|
||||||
|
.tablist {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host-context(.dark) .tablist {
|
||||||
|
border-bottom-color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
appearance: none;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
margin-bottom: -1px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #6b7280;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
color 0.12s ease-out,
|
||||||
|
border-color 0.12s ease-out;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--color-brand-primary-600, #1d4ed8);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--color-brand-primary-500, #2563eb);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 0.125rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:host-context(.dark) .tab {
|
||||||
|
color: #9ca3af;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--color-brand-primary-300, #93c5fd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab--active {
|
||||||
|
color: var(--color-brand-primary-600, #1d4ed8);
|
||||||
|
border-bottom-color: var(--color-brand-primary-600, #1d4ed8);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host-context(.dark) .tab--active {
|
||||||
|
color: var(--color-brand-primary-300, #93c5fd);
|
||||||
|
border-bottom-color: var(--color-brand-primary-300, #93c5fd);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-panel {
|
||||||
|
// No box-styling — the panel inherits the page's surface.
|
||||||
|
// `[hidden]` is set imperatively when the tab isn't active.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Charts panel container. Same surface tokens as `.filters` so the
|
||||||
|
// chart stack reads as one related block.
|
||||||
.charts {
|
.charts {
|
||||||
margin: 0 0 1rem;
|
margin: 0 0 1rem;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
|
|||||||
@@ -5,8 +5,27 @@ import {
|
|||||||
AuditEventsService,
|
AuditEventsService,
|
||||||
type AdminAuditPage,
|
type AdminAuditPage,
|
||||||
type AdminAuditQuery,
|
type AdminAuditQuery,
|
||||||
|
type AdminAuditStats,
|
||||||
|
type AdminAuditStatsQuery,
|
||||||
} from './audit-events.service';
|
} from './audit-events.service';
|
||||||
|
|
||||||
|
const STATS: AdminAuditStats = {
|
||||||
|
dailyVolume: [
|
||||||
|
{ day: '2026-05-12', count: 5 },
|
||||||
|
{ day: '2026-05-13', count: 7 },
|
||||||
|
],
|
||||||
|
outcomeBreakdown: [
|
||||||
|
{ outcome: 'success', count: 11 },
|
||||||
|
{ outcome: 'denied', count: 1 },
|
||||||
|
],
|
||||||
|
eventTypeByDay: [
|
||||||
|
{ day: '2026-05-12', eventType: 'auth.sign_in', count: 5 },
|
||||||
|
{ day: '2026-05-13', eventType: 'auth.sign_in', count: 6 },
|
||||||
|
{ day: '2026-05-13', eventType: 'admin.access_denied', count: 1 },
|
||||||
|
],
|
||||||
|
total: 12,
|
||||||
|
};
|
||||||
|
|
||||||
const PAGE: AdminAuditPage = {
|
const PAGE: AdminAuditPage = {
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
@@ -40,22 +59,34 @@ const PAGE: AdminAuditPage = {
|
|||||||
interface Fixture {
|
interface Fixture {
|
||||||
fixture: ReturnType<typeof TestBed.createComponent<AuditPage>>;
|
fixture: ReturnType<typeof TestBed.createComponent<AuditPage>>;
|
||||||
query: ReturnType<typeof vi.fn>;
|
query: ReturnType<typeof vi.fn>;
|
||||||
|
stats: ReturnType<typeof vi.fn>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setup(opts?: { initial?: AdminAuditPage | 'error'; status?: number }): Fixture {
|
function setup(opts?: {
|
||||||
|
initial?: AdminAuditPage | 'error';
|
||||||
|
initialStats?: AdminAuditStats | 'error';
|
||||||
|
status?: number;
|
||||||
|
}): Fixture {
|
||||||
const initial = opts?.initial ?? PAGE;
|
const initial = opts?.initial ?? PAGE;
|
||||||
|
const initialStats = opts?.initialStats ?? STATS;
|
||||||
const query = vi.fn().mockImplementation(() => {
|
const query = vi.fn().mockImplementation(() => {
|
||||||
if (initial === 'error') {
|
if (initial === 'error') {
|
||||||
return throwError(() => ({ status: opts?.status ?? 500 }));
|
return throwError(() => ({ status: opts?.status ?? 500 }));
|
||||||
}
|
}
|
||||||
return of(initial);
|
return of(initial);
|
||||||
});
|
});
|
||||||
|
const stats = vi.fn().mockImplementation(() => {
|
||||||
|
if (initialStats === 'error') {
|
||||||
|
return throwError(() => ({ status: opts?.status ?? 500 }));
|
||||||
|
}
|
||||||
|
return of(initialStats);
|
||||||
|
});
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
imports: [AuditPage],
|
imports: [AuditPage],
|
||||||
providers: [{ provide: AuditEventsService, useValue: { query } }],
|
providers: [{ provide: AuditEventsService, useValue: { query, stats } }],
|
||||||
});
|
});
|
||||||
const fixture = TestBed.createComponent(AuditPage);
|
const fixture = TestBed.createComponent(AuditPage);
|
||||||
return { fixture, query };
|
return { fixture, query, stats };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function flush(fixture: Fixture['fixture']): Promise<void> {
|
async function flush(fixture: Fixture['fixture']): Promise<void> {
|
||||||
@@ -246,34 +277,132 @@ describe('AuditPage', () => {
|
|||||||
expect(row?.querySelector('.actor-hash')?.textContent?.trim()).toBe('(anonymous)');
|
expect(row?.querySelector('.actor-hash')?.textContent?.trim()).toBe('(anonymous)');
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('charts', () => {
|
describe('tabs + charts', () => {
|
||||||
it('renders the three chart tiles above the table when the page has data', async () => {
|
it('defaults to the Table tab on first render — does NOT call the stats endpoint', async () => {
|
||||||
const { fixture } = setup();
|
const { fixture, stats } = setup();
|
||||||
await flush(fixture);
|
await flush(fixture);
|
||||||
const root = fixture.nativeElement as HTMLElement;
|
const root = fixture.nativeElement as HTMLElement;
|
||||||
expect(root.querySelector('section.charts')).not.toBeNull();
|
const activeTab = root.querySelector('.tab--active');
|
||||||
|
expect(activeTab?.textContent?.trim()).toBe('Table');
|
||||||
|
expect(stats).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetches stats when the user opens the Charts tab for the first time', async () => {
|
||||||
|
const { fixture, stats } = setup();
|
||||||
|
await flush(fixture);
|
||||||
|
const root = fixture.nativeElement as HTMLElement;
|
||||||
|
const chartsTab = Array.from(root.querySelectorAll<HTMLButtonElement>('.tab')).find(
|
||||||
|
(b) => b.textContent?.trim() === 'Charts',
|
||||||
|
);
|
||||||
|
chartsTab?.click();
|
||||||
|
await flush(fixture);
|
||||||
|
expect(stats).toHaveBeenCalledTimes(1);
|
||||||
expect(root.querySelector('lib-bar-chart')).not.toBeNull();
|
expect(root.querySelector('lib-bar-chart')).not.toBeNull();
|
||||||
expect(root.querySelector('lib-donut-chart')).not.toBeNull();
|
expect(root.querySelector('lib-donut-chart')).not.toBeNull();
|
||||||
expect(root.querySelector('lib-stacked-bar-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 () => {
|
it('does NOT pass pagination knobs to the stats endpoint', async () => {
|
||||||
const empty: AdminAuditPage = { items: [], total: 0, limit: 50, offset: 0 };
|
const { fixture, stats } = setup();
|
||||||
const { fixture } = setup({ initial: empty });
|
|
||||||
await flush(fixture);
|
await flush(fixture);
|
||||||
const root = fixture.nativeElement as HTMLElement;
|
const root = fixture.nativeElement as HTMLElement;
|
||||||
expect(root.querySelector('section.charts')).toBeNull();
|
Array.from(root.querySelectorAll<HTMLButtonElement>('.tab'))
|
||||||
|
.find((b) => b.textContent?.trim() === 'Charts')
|
||||||
|
?.click();
|
||||||
|
await flush(fixture);
|
||||||
|
const filters = stats.mock.calls[0]?.[0] as AdminAuditStatsQuery & {
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
};
|
||||||
|
expect(filters.limit).toBeUndefined();
|
||||||
|
expect(filters.offset).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('passes the donut a centerLabel matching the current page item count', async () => {
|
it('does NOT re-fetch stats on pagination (filters unchanged)', async () => {
|
||||||
|
const { fixture, stats } = setup();
|
||||||
|
await flush(fixture);
|
||||||
|
const root = fixture.nativeElement as HTMLElement;
|
||||||
|
Array.from(root.querySelectorAll<HTMLButtonElement>('.tab'))
|
||||||
|
.find((b) => b.textContent?.trim() === 'Charts')
|
||||||
|
?.click();
|
||||||
|
await flush(fixture);
|
||||||
|
expect(stats).toHaveBeenCalledTimes(1);
|
||||||
|
// Switch back to Table and paginate.
|
||||||
|
Array.from(root.querySelectorAll<HTMLButtonElement>('.tab'))
|
||||||
|
.find((b) => b.textContent?.trim() === 'Table')
|
||||||
|
?.click();
|
||||||
|
await flush(fixture);
|
||||||
|
// Open Charts again — cache still valid, no new stats call.
|
||||||
|
Array.from(root.querySelectorAll<HTMLButtonElement>('.tab'))
|
||||||
|
.find((b) => b.textContent?.trim() === 'Charts')
|
||||||
|
?.click();
|
||||||
|
await flush(fixture);
|
||||||
|
expect(stats).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-fetches stats when filters change and the Charts tab is active', async () => {
|
||||||
|
const { fixture, stats } = setup();
|
||||||
|
await flush(fixture);
|
||||||
|
// Open Charts to load the initial stats.
|
||||||
|
const root = fixture.nativeElement as HTMLElement;
|
||||||
|
Array.from(root.querySelectorAll<HTMLButtonElement>('.tab'))
|
||||||
|
.find((b) => b.textContent?.trim() === 'Charts')
|
||||||
|
?.click();
|
||||||
|
await flush(fixture);
|
||||||
|
expect(stats).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Component-state mutation: tweak a filter and re-search.
|
||||||
|
const cmp = fixture.componentInstance as unknown as {
|
||||||
|
eventType: { set(v: string): void };
|
||||||
|
search(): Promise<void>;
|
||||||
|
};
|
||||||
|
cmp.eventType.set('auth.sign_in');
|
||||||
|
await cmp.search();
|
||||||
|
await flush(fixture);
|
||||||
|
|
||||||
|
expect(stats).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the donut centre label with the server-side total (not the per-page count)', async () => {
|
||||||
const { fixture } = setup();
|
const { fixture } = setup();
|
||||||
await flush(fixture);
|
await flush(fixture);
|
||||||
const root = fixture.nativeElement as HTMLElement;
|
const root = fixture.nativeElement as HTMLElement;
|
||||||
// Donut's <details> fallback row count matches the categories;
|
Array.from(root.querySelectorAll<HTMLButtonElement>('.tab'))
|
||||||
// and the SVG carries the centre label as a <text> with the
|
.find((b) => b.textContent?.trim() === 'Charts')
|
||||||
// PAGE.items.length value.
|
?.click();
|
||||||
|
await flush(fixture);
|
||||||
const donutCenter = root.querySelector('lib-donut-chart .donut-center-label');
|
const donutCenter = root.querySelector('lib-donut-chart .donut-center-label');
|
||||||
expect(donutCenter?.textContent?.trim()).toBe(String(PAGE.items.length));
|
expect(donutCenter?.textContent?.trim()).toBe(String(STATS.total));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the empty-state message when the stats endpoint returns 0 events', async () => {
|
||||||
|
const emptyStats: AdminAuditStats = {
|
||||||
|
dailyVolume: [],
|
||||||
|
outcomeBreakdown: [],
|
||||||
|
eventTypeByDay: [],
|
||||||
|
total: 0,
|
||||||
|
};
|
||||||
|
const { fixture } = setup({ initialStats: emptyStats });
|
||||||
|
await flush(fixture);
|
||||||
|
const root = fixture.nativeElement as HTMLElement;
|
||||||
|
Array.from(root.querySelectorAll<HTMLButtonElement>('.tab'))
|
||||||
|
.find((b) => b.textContent?.trim() === 'Charts')
|
||||||
|
?.click();
|
||||||
|
await flush(fixture);
|
||||||
|
expect(root.querySelector('lib-bar-chart')).toBeNull();
|
||||||
|
expect(root.querySelector('#panel-charts .empty')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a permission-aware error when the stats endpoint returns 403', async () => {
|
||||||
|
const { fixture } = setup({ initialStats: 'error', status: 403 });
|
||||||
|
await flush(fixture);
|
||||||
|
const root = fixture.nativeElement as HTMLElement;
|
||||||
|
Array.from(root.querySelectorAll<HTMLButtonElement>('.tab'))
|
||||||
|
.find((b) => b.textContent?.trim() === 'Charts')
|
||||||
|
?.click();
|
||||||
|
await flush(fixture);
|
||||||
|
const errMsg = root.querySelector('.status-line--error')?.textContent?.trim();
|
||||||
|
expect(errMsg).toContain('do not have access');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,8 +7,13 @@ import {
|
|||||||
AuditEventsService,
|
AuditEventsService,
|
||||||
type AdminAuditPage,
|
type AdminAuditPage,
|
||||||
type AdminAuditQuery,
|
type AdminAuditQuery,
|
||||||
|
type AdminAuditStats,
|
||||||
|
type AdminAuditStatsQuery,
|
||||||
} from './audit-events.service';
|
} from './audit-events.service';
|
||||||
|
|
||||||
|
/** Tabs shown above the audit content. */
|
||||||
|
type AuditTab = 'table' | 'charts';
|
||||||
|
|
||||||
/** Page-size options the SPA offers. Matches the BFF's `DEFAULT_LIMIT` (50) and `MAX_LIMIT` (200). */
|
/** Page-size options the SPA offers. Matches the BFF's `DEFAULT_LIMIT` (50) and `MAX_LIMIT` (200). */
|
||||||
const PAGE_SIZES = [25, 50, 100, 200] as const;
|
const PAGE_SIZES = [25, 50, 100, 200] as const;
|
||||||
|
|
||||||
@@ -89,69 +94,52 @@ export class AuditPage {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
// Chart aggregations — derived from the *current page only* per
|
// Tabs + server-side stats — Charts tab consumes
|
||||||
// PR 3 of the charts chantier. The captions explicitly say so;
|
// `GET /api/admin/audit/stats` (full filtered set, Redis-cached
|
||||||
// server-side aggregations across the full filter set land in a
|
// 5 min per filter-hash per ADR-0013 §"Reader endpoints"). Fetch
|
||||||
// separate chantier (would need a new `/api/admin/audit/stats`
|
// is lazy: only fires when the Charts tab is opened, then again
|
||||||
// endpoint, out of scope for the v1 visual test).
|
// when filters change with the Charts tab active.
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
|
|
||||||
/** Total event count for the current page — drives the donut's centre label. */
|
protected readonly tab = signal<AuditTab>('table');
|
||||||
protected readonly totalOnPage = computed(() => this.page()?.items.length ?? 0);
|
protected readonly stats = signal<AdminAuditStats | null>(null);
|
||||||
|
protected readonly statsLoading = signal(false);
|
||||||
|
protected readonly statsError = signal<string | null>(null);
|
||||||
|
|
||||||
/** Events grouped by ISO date (YYYY-MM-DD) on the current page. */
|
/** True when the loaded stats are non-empty (total > 0). */
|
||||||
protected readonly dailyVolume = computed(() => {
|
protected readonly hasChartData = computed(() => (this.stats()?.total ?? 0) > 0);
|
||||||
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
|
||||||
* the template's `@defer (on viewport)` block.
|
* the template's `@defer (on viewport)` block.
|
||||||
|
*
|
||||||
|
* When the user applies new filters, the loaded `stats` becomes
|
||||||
|
* stale — clear it so the next visit to the Charts tab refetches.
|
||||||
|
* If the Charts tab is currently active, refetch immediately;
|
||||||
|
* otherwise wait for the user to open it.
|
||||||
*/
|
*/
|
||||||
async search(): Promise<void> {
|
async search(): Promise<void> {
|
||||||
this.offset.set(0);
|
this.offset.set(0);
|
||||||
await this.fetch();
|
this.stats.set(null);
|
||||||
|
const tasks: Array<Promise<void>> = [this.fetch()];
|
||||||
|
if (this.tab() === 'charts') {
|
||||||
|
tasks.push(this.fetchStats());
|
||||||
|
}
|
||||||
|
await Promise.all(tasks);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switch to a tab. On the first switch to `charts` (or any switch
|
||||||
|
* that follows a filter change), fire the stats fetch — pagination
|
||||||
|
* within the table doesn't invalidate stats (the filtered set is
|
||||||
|
* unchanged), so re-opening Charts after paginating is free.
|
||||||
|
*/
|
||||||
|
async setTab(tab: AuditTab): Promise<void> {
|
||||||
|
this.tab.set(tab);
|
||||||
|
if (tab === 'charts' && this.stats() === null && !this.statsLoading()) {
|
||||||
|
await this.fetchStats();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Page navigation — moves the offset by `limit`, then fetches. */
|
/** Page navigation — moves the offset by `limit`, then fetches. */
|
||||||
@@ -177,7 +165,12 @@ export class AuditPage {
|
|||||||
this.createdAtFrom.set('');
|
this.createdAtFrom.set('');
|
||||||
this.createdAtTo.set('');
|
this.createdAtTo.set('');
|
||||||
this.offset.set(0);
|
this.offset.set(0);
|
||||||
await this.fetch();
|
this.stats.set(null);
|
||||||
|
const tasks: Array<Promise<void>> = [this.fetch()];
|
||||||
|
if (this.tab() === 'charts') {
|
||||||
|
tasks.push(this.fetchStats());
|
||||||
|
}
|
||||||
|
await Promise.all(tasks);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Format an ISO timestamp into the user's locale for the table. */
|
/** Format an ISO timestamp into the user's locale for the table. */
|
||||||
@@ -223,7 +216,12 @@ export class AuditPage {
|
|||||||
if (!hash) return;
|
if (!hash) return;
|
||||||
this.actorIdHash.set(hash);
|
this.actorIdHash.set(hash);
|
||||||
this.offset.set(0);
|
this.offset.set(0);
|
||||||
await this.fetch();
|
this.stats.set(null);
|
||||||
|
const tasks: Array<Promise<void>> = [this.fetch()];
|
||||||
|
if (this.tab() === 'charts') {
|
||||||
|
tasks.push(this.fetchStats());
|
||||||
|
}
|
||||||
|
await Promise.all(tasks);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async fetch(): Promise<void> {
|
private async fetch(): Promise<void> {
|
||||||
@@ -265,6 +263,37 @@ export class AuditPage {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stats endpoint call. Shares the filter shape with `fetch` minus
|
||||||
|
* pagination — `buildStatsFilters` strips `limit/offset`.
|
||||||
|
*/
|
||||||
|
private async fetchStats(): Promise<void> {
|
||||||
|
this.statsLoading.set(true);
|
||||||
|
this.statsError.set(null);
|
||||||
|
try {
|
||||||
|
const filters = this.buildStatsFilters();
|
||||||
|
const stats = await firstValueFrom(this.service.stats(filters));
|
||||||
|
this.stats.set(stats);
|
||||||
|
} catch (err) {
|
||||||
|
const status = errorStatus(err);
|
||||||
|
this.statsError.set(
|
||||||
|
status === 403
|
||||||
|
? 'You do not have access to the audit log on this session.'
|
||||||
|
: 'Could not load audit statistics. Try again in a moment.',
|
||||||
|
);
|
||||||
|
this.stats.set(null);
|
||||||
|
} finally {
|
||||||
|
this.statsLoading.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildStatsFilters(): AdminAuditStatsQuery {
|
||||||
|
const { limit: _limit, offset: _offset, ...rest } = this.buildFilters();
|
||||||
|
void _limit;
|
||||||
|
void _offset;
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
// Fire the initial unfiltered query so the table renders with
|
// Fire the initial unfiltered query so the table renders with
|
||||||
// the most recent events on first paint. Fire-and-forget — the
|
// the most recent events on first paint. Fire-and-forget — the
|
||||||
|
|||||||
Reference in New Issue
Block a user