feat(portal-admin): audit log tabs (Table / Charts) + server-side stats consumption
PR 2 of the tabs + full-result-charts chantier — closes the loop on the BFF endpoint shipped in #173. Two changes: 1. Wraps the audit page content in two tabs — `Table` (default) and `Charts`. WAI-ARIA pattern: `role="tablist"` on the container, `role="tab"` on each button, roving tabindex (the active tab reachable via Tab, inactive ones via arrow keys). The Charts panel hides until the user switches to it. 2. Charts now consume the BFF stats endpoint (full filtered set, Redis-cached 5 min) instead of the per-page aggregations the previous PR shipped. The three client-side computeds (`dailyVolume`, `outcomeBreakdown`, `dailyByEventType`, `totalOnPage`) are replaced by: * `stats: signal<AdminAuditStats | null>` — filled by the `GET /api/admin/audit/stats` call. * `statsLoading` + `statsError` — own loading / error state, distinct from the table's so a failed stats call doesn't mask the table view. * `fetchStats()` — strips pagination from the filter shape, calls the new `AuditEventsService.stats()` method. Lazy fetch policy: * Default tab Table → no stats call on first render. Saves a DB round-trip when the admin only wanted to scan rows. * Switch to Charts → fetch stats if not already loaded. * Filter change (Apply / Reset / actor pivot) → invalidate stats (set to null). If Charts tab is currently active, re-fetch immediately. Otherwise wait for the user to open it. * Pagination (next / previous) → does NOT invalidate stats. The underlying filtered set is unchanged, so re-opening Charts after paginating is free. 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." — honest disclosure of the new scope + cache. Audit chunk: 84.5 KB gzip (essentially unchanged from #172's 84 KB; the tab markup + stats branch add ~50 LOC of plumbing offset by removal of the per-page aggregation computeds). audit.scss ticks up to 7.5 KB which exceeds the 6 KB warning threshold but stays under the 8 KB error threshold — acceptable for a single- PR add of the tab styling. Verification: * 62 portal-admin specs pass (was 57; +5 net: 7 new tabs / stats tests, 3 old per-page chart tests dropped, 1 prior test reframed). * `pnpm nx run-many -t lint test build --projects=portal-shell, portal-admin,shared-charts,portal-bff` — green.
This commit is contained in:
@@ -28,6 +28,24 @@ export interface AdminAuditPage {
|
||||
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
|
||||
* 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);
|
||||
|
||||
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();
|
||||
for (const [key, value] of Object.entries(filters)) {
|
||||
// Drop empty strings — Nest's ValidationPipe treats `?foo=`
|
||||
@@ -77,6 +111,6 @@ export class AuditEventsService {
|
||||
}
|
||||
params = params.set(key, String(value));
|
||||
}
|
||||
return this.http.get<AdminAuditPage>(`${this.bffBaseUrl}/admin/audit`, { params });
|
||||
return params;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,63 +103,118 @@
|
||||
</div>
|
||||
</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">
|
||||
@if (loading()) {
|
||||
@if (tab() === 'table') { @if (loading()) {
|
||||
<span class="status-line">Loading…</span>
|
||||
} @else if (error(); as msg) {
|
||||
<span class="status-line status-line--error">{{ msg }}</span>
|
||||
} @else if (page(); as p) {
|
||||
<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>
|
||||
|
||||
@if (hasChartData()) {
|
||||
<section class="charts" aria-labelledby="charts-h">
|
||||
<h2 id="charts-h" class="charts-heading">At a glance — current page</h2>
|
||||
<section
|
||||
id="panel-charts"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-charts"
|
||||
class="tab-panel"
|
||||
[hidden]="tab() !== 'charts'"
|
||||
>
|
||||
@if (stats(); as s) { @if (hasChartData()) {
|
||||
<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.
|
||||
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.
|
||||
</p>
|
||||
<div class="charts-grid">
|
||||
<div class="chart-tile">
|
||||
<lib-bar-chart
|
||||
[data]="dailyVolume()"
|
||||
[data]="s.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."
|
||||
description="Bar chart of audit event volume per calendar day across the full filtered set."
|
||||
ariaLabel="Events per day — bar chart"
|
||||
/>
|
||||
</div>
|
||||
<div class="chart-tile">
|
||||
<lib-donut-chart
|
||||
[data]="outcomeBreakdown()"
|
||||
[data]="s.outcomeBreakdown"
|
||||
categoryKey="outcome"
|
||||
valueKey="count"
|
||||
[centerLabel]="totalOnPage().toString()"
|
||||
[centerLabel]="s.total.toString()"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div class="chart-tile chart-tile--wide">
|
||||
<lib-stacked-bar-chart
|
||||
[data]="dailyByEventType()"
|
||||
[data]="s.eventTypeByDay"
|
||||
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."
|
||||
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"
|
||||
/>
|
||||
</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>
|
||||
} @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>
|
||||
} @else {
|
||||
<div class="table-wrap">
|
||||
@@ -252,4 +307,5 @@
|
||||
</button>
|
||||
</nav>
|
||||
} }
|
||||
</section>
|
||||
</section>
|
||||
|
||||
@@ -194,8 +194,75 @@
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
// Chart row above the table — same surface tokens as `.filters`
|
||||
// + `.table-wrap` so the page reads as one stack of related blocks.
|
||||
// Tabs above the content. WAI-ARIA pattern: `role="tablist"` on the
|
||||
// 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 {
|
||||
margin: 0 0 1rem;
|
||||
padding: 1rem;
|
||||
|
||||
@@ -5,8 +5,27 @@ import {
|
||||
AuditEventsService,
|
||||
type AdminAuditPage,
|
||||
type AdminAuditQuery,
|
||||
type AdminAuditStats,
|
||||
type AdminAuditStatsQuery,
|
||||
} 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 = {
|
||||
items: [
|
||||
{
|
||||
@@ -40,22 +59,34 @@ const PAGE: AdminAuditPage = {
|
||||
interface Fixture {
|
||||
fixture: ReturnType<typeof TestBed.createComponent<AuditPage>>;
|
||||
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 initialStats = opts?.initialStats ?? STATS;
|
||||
const query = vi.fn().mockImplementation(() => {
|
||||
if (initial === 'error') {
|
||||
return throwError(() => ({ status: opts?.status ?? 500 }));
|
||||
}
|
||||
return of(initial);
|
||||
});
|
||||
const stats = vi.fn().mockImplementation(() => {
|
||||
if (initialStats === 'error') {
|
||||
return throwError(() => ({ status: opts?.status ?? 500 }));
|
||||
}
|
||||
return of(initialStats);
|
||||
});
|
||||
TestBed.configureTestingModule({
|
||||
imports: [AuditPage],
|
||||
providers: [{ provide: AuditEventsService, useValue: { query } }],
|
||||
providers: [{ provide: AuditEventsService, useValue: { query, stats } }],
|
||||
});
|
||||
const fixture = TestBed.createComponent(AuditPage);
|
||||
return { fixture, query };
|
||||
return { fixture, query, stats };
|
||||
}
|
||||
|
||||
async function flush(fixture: Fixture['fixture']): Promise<void> {
|
||||
@@ -246,34 +277,132 @@ describe('AuditPage', () => {
|
||||
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();
|
||||
describe('tabs + charts', () => {
|
||||
it('defaults to the Table tab on first render — does NOT call the stats endpoint', async () => {
|
||||
const { fixture, stats } = setup();
|
||||
await flush(fixture);
|
||||
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-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 });
|
||||
it('does NOT pass pagination knobs to the stats endpoint', async () => {
|
||||
const { fixture, stats } = setup();
|
||||
await flush(fixture);
|
||||
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();
|
||||
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.
|
||||
Array.from(root.querySelectorAll<HTMLButtonElement>('.tab'))
|
||||
.find((b) => b.textContent?.trim() === 'Charts')
|
||||
?.click();
|
||||
await flush(fixture);
|
||||
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,
|
||||
type AdminAuditPage,
|
||||
type AdminAuditQuery,
|
||||
type AdminAuditStats,
|
||||
type AdminAuditStatsQuery,
|
||||
} 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). */
|
||||
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
|
||||
// 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).
|
||||
// Tabs + server-side stats — Charts tab consumes
|
||||
// `GET /api/admin/audit/stats` (full filtered set, Redis-cached
|
||||
// 5 min per filter-hash per ADR-0013 §"Reader endpoints"). Fetch
|
||||
// is lazy: only fires when the Charts tab is opened, then again
|
||||
// when filters change with the Charts tab active.
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
/** Total event count for the current page — drives the donut's centre label. */
|
||||
protected readonly totalOnPage = computed(() => this.page()?.items.length ?? 0);
|
||||
protected readonly tab = signal<AuditTab>('table');
|
||||
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. */
|
||||
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);
|
||||
/** True when the loaded stats are non-empty (total > 0). */
|
||||
protected readonly hasChartData = computed(() => (this.stats()?.total ?? 0) > 0);
|
||||
|
||||
/**
|
||||
* Run a new query with the current filter signals at offset 0.
|
||||
* Called by the "Apply filters" button + the initial render via
|
||||
* 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> {
|
||||
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. */
|
||||
@@ -177,7 +165,12 @@ export class AuditPage {
|
||||
this.createdAtFrom.set('');
|
||||
this.createdAtTo.set('');
|
||||
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. */
|
||||
@@ -223,7 +216,12 @@ export class AuditPage {
|
||||
if (!hash) return;
|
||||
this.actorIdHash.set(hash);
|
||||
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> {
|
||||
@@ -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 {
|
||||
// Fire the initial unfiltered query so the table renders with
|
||||
// the most recent events on first paint. Fire-and-forget — the
|
||||
|
||||
Reference in New Issue
Block a user