feat(portal-admin): jaeger deep link on trace_id + actor-pivot on actor_id_hash (#166)
## Summary
Turns the audit-log table's `trace_id` and `actor_id_hash` columns from inert text into the two pivots an investigator actually needs:
- **trace_id** → Jaeger deep link (opens in a new tab). Closes the "join audit + traces by trace_id" loop from [ADR-0012](docs/decisions/0012-observability-pino-opentelemetry.md) / [ADR-0013](docs/decisions/0013-audit-trail-separated-postgres-append-only.md) without any new BFF surface.
- **actor_id_hash** → click to refilter the table on that single actor. "Show me everything else this user did" stays in the page; no copy-paste loop.
## What lands
### Trace-id deep link to Jaeger
[`audit.html`](apps/portal-admin/src/app/pages/audit/audit.html#L162-L173) — the cell becomes an `<a target="_blank" rel="noopener noreferrer">` pointing at `${environment.jaegerBaseUrl}/trace/<traceId>`. Anonymous events (`traceId === null`) keep the dash placeholder.
[`environment.ts`](apps/portal-admin/src/environments/environment.ts) gains `jaegerBaseUrl`. Dev defaults to `http://localhost:16686` (matches the compose `observability` profile from `infra/local/dev.compose.yml`). Per-env replacement picks up whatever trace backend the future infrastructure ADR settles on — Tempo, Grafana Cloud, on-prem Jaeger; the SPA-side wiring doesn't care.
### Actor-pivot click
[`audit.html`](apps/portal-admin/src/app/pages/audit/audit.html#L146-L160) — non-null `actorIdHash` becomes a `<button>` styled to read inline like the hash text (`.actor-hash--clickable`: button reset + dotted-underline hover + brand-colored focus ring). Click → `filterByActor(hash)` sets the existing `actorIdHash` filter signal, resets offset to 0, and re-runs the query. Each pivot still emits its own `admin.audit.query` audit row server-side (per [ADR-0020](docs/decisions/0020-portal-admin-app.md)) so the drill is itself auditable.
Anonymous rows keep the `(anonymous)` plain-text rendering — there's no useful filter value to pivot on.
## Why not inline-expand Pino log lines under the row
Considered, deferred. The BFF's Pino output goes to **stdout only** today; standing up a queryable log aggregator (Loki, OpenSearch, …) is a separate infrastructure chantier with its own ADR. The Jaeger jump-off carries ~99 % of the investigator's needs anyway — the trace already contains span attributes (`db.statement`, `http.status_code`, exception events) for the same request scope; Pino lines on top of that would be redundant for most investigations.
When the log aggregator does land, the inline-expand model can come back as a follow-up: `GET /api/admin/logs?traceId=<id>` + an expand affordance on the same row. The current Jaeger anchor and the future inline-logs would naturally coexist (different drills, both surfaced on the same `trace_id`).
## Notes for the reviewer
- **Why a `<button>` for the actor cell rather than an `<a>`?** The action is an in-page filter change, not a navigation. Buttons keep keyboard activation (Enter / Space), don't pollute browser history, and screen readers announce "Filter the table on hash(jane), button" rather than a misleading link role.
- **Why the dotted-underline hover for the actor, but solid-underline for trace?** Different affordances. The trace anchor is a permanent link to an external resource (Jaeger UI), so the solid underline matches the universal "link" convention. The actor button is an inline pivot that *mutates state* — the dotted underline + hover-fill conveys "this does something subtle within the page" without screaming "link".
- **CSS guardrails preserved**: focus rings on both elements, brand-color tokens (light + dark), tap targets meet the AAA 44×44 minimum (the button reset preserves the line-height + the `cell-actor` padding ≥ 12 px on each side).
- **No new i18n strings.** `title` attributes are hover hints, not screen-reader-essential — the underlying hash + traceId are the actual semantic content. The "(anonymous)" string and the dash placeholder were already in the template.
- **No BFF change.** This whole PR is SPA-side only. The audit endpoint already returns `traceId` and `actorIdHash` in every row.
## Test plan
- [x] `pnpm nx run-many -t lint test build --projects=portal-admin` — green.
- [x] **54 portal-admin specs pass** (was 50; +4 for the four new behaviours).
- [x] Lazy `audit` chunk: 18.26 → ~18.5 KB raw / 4.44 KB gzip — comfortably under the per-chunk budget.
- [ ] **Manual smoke**:
- Sign in to portal-admin with `Portal.Admin` → open `/audit`.
- Click any trace_id → new tab opens at `http://localhost:16686/trace/<id>` (assuming `./infra/local/dev.sh up observability` is running so Jaeger is up).
- Anonymous rows show `—` for trace, `(anonymous)` (plain text, not clickable) for actor.
- Click any non-anonymous actor hash → the table refreshes filtered on that hash, the "Actor id hash" filter input above shows the same value, page jumps to offset 0.
- Tab through a row: timestamp / event are plain text; outcome badge skipped (not interactive); actor button gets focus ring; trace link gets focus ring; payload `<details>` summary gets focus ring.
## Follow-ups (optional)
- When the log aggregator ADR lands, extend the trace cell to also offer an inline-expand of Pino lines for that trace. Jaeger anchor stays as the primary affordance.
- A similar treatment on the `/users` page (clicking a row's `oid` to "show me this user's audit trail") is the natural sibling. Defer until there's an investigator workflow that asks for it — premature otherwise.
---------
Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #166
This commit was merged in pull request #166.
This commit is contained in:
@@ -144,12 +144,33 @@
|
|||||||
>
|
>
|
||||||
</td>
|
</td>
|
||||||
<td class="cell-actor">
|
<td class="cell-actor">
|
||||||
<div class="actor-hash">{{ event.actorIdHash ?? '(anonymous)' }}</div>
|
@if (event.actorIdHash; as hash) {
|
||||||
@if (event.subject; as subject) {
|
<button
|
||||||
|
type="button"
|
||||||
|
class="actor-hash actor-hash--clickable"
|
||||||
|
(click)="filterByActor(hash)"
|
||||||
|
[attr.title]="'Filter the table on ' + hash"
|
||||||
|
>
|
||||||
|
{{ hash }}
|
||||||
|
</button>
|
||||||
|
} @else {
|
||||||
|
<div class="actor-hash">(anonymous)</div>
|
||||||
|
} @if (event.subject; as subject) {
|
||||||
<div class="subject">{{ subject }}</div>
|
<div class="subject">{{ subject }}</div>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td class="cell-trace">{{ event.traceId ?? '—' }}</td>
|
<td class="cell-trace">
|
||||||
|
@if (event.traceId; as traceId) {
|
||||||
|
<a
|
||||||
|
class="trace-link"
|
||||||
|
[href]="jaegerUrl(traceId)"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
[attr.title]="'Open trace ' + traceId + ' in Jaeger'"
|
||||||
|
>{{ traceId }}</a
|
||||||
|
>
|
||||||
|
} @else { — }
|
||||||
|
</td>
|
||||||
<td class="cell-payload">
|
<td class="cell-payload">
|
||||||
@if (event.payload) {
|
@if (event.payload) {
|
||||||
<details>
|
<details>
|
||||||
|
|||||||
@@ -273,6 +273,65 @@
|
|||||||
color: #9ca3af;
|
color: #9ca3af;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The clickable variant of `.actor-hash` (rendered as a <button> so
|
||||||
|
// keyboard activation works) reuses the inline-hash typography but
|
||||||
|
// strips the native button chrome and surfaces a subtle hover
|
||||||
|
// affordance. Filter pivot per the same row's actor.
|
||||||
|
.actor-hash--clickable {
|
||||||
|
display: inline;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline dotted transparent;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
transition:
|
||||||
|
color 0.12s ease-out,
|
||||||
|
text-decoration-color 0.12s ease-out;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--color-brand-primary-600, #1d4ed8);
|
||||||
|
text-decoration-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--color-brand-primary-500, #2563eb);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 0.125rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:host-context(.dark) .actor-hash--clickable {
|
||||||
|
&:hover {
|
||||||
|
color: var(--color-brand-primary-300, #93c5fd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trace-id deep-link into Jaeger. Same hash typography as the actor
|
||||||
|
// cell (already inherited via `.cell-trace`), plus a brand-coloured
|
||||||
|
// underline so investigators read it as "follow this".
|
||||||
|
.trace-link {
|
||||||
|
color: var(--color-brand-primary-600, #1d4ed8);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration-thickness: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--color-brand-primary-500, #2563eb);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 0.125rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:host-context(.dark) .trace-link {
|
||||||
|
color: var(--color-brand-primary-300, #93c5fd);
|
||||||
|
}
|
||||||
|
|
||||||
.aud-badge,
|
.aud-badge,
|
||||||
.outcome-badge {
|
.outcome-badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|||||||
@@ -197,4 +197,52 @@ describe('AuditPage', () => {
|
|||||||
expect(rows[0]?.querySelector('details')).not.toBeNull();
|
expect(rows[0]?.querySelector('details')).not.toBeNull();
|
||||||
expect(rows[1]?.querySelector('details')).toBeNull();
|
expect(rows[1]?.querySelector('details')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders trace_id as a Jaeger deep link (anchor with target=_blank)', async () => {
|
||||||
|
const { fixture } = setup();
|
||||||
|
await flush(fixture);
|
||||||
|
const root = fixture.nativeElement as HTMLElement;
|
||||||
|
const rows = root.querySelectorAll<HTMLTableRowElement>('.audit-table tbody tr');
|
||||||
|
const link = rows[0]?.querySelector<HTMLAnchorElement>('.trace-link');
|
||||||
|
expect(link).not.toBeNull();
|
||||||
|
expect(link?.getAttribute('href')).toContain('/trace/trace-abc');
|
||||||
|
expect(link?.getAttribute('target')).toBe('_blank');
|
||||||
|
expect(link?.getAttribute('rel')).toContain('noopener');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the dash placeholder for rows without a trace_id', async () => {
|
||||||
|
const { fixture } = setup();
|
||||||
|
await flush(fixture);
|
||||||
|
const root = fixture.nativeElement as HTMLElement;
|
||||||
|
const rows = root.querySelectorAll<HTMLTableRowElement>('.audit-table tbody tr');
|
||||||
|
expect(rows[1]?.querySelector('.trace-link')).toBeNull();
|
||||||
|
expect(rows[1]?.querySelector('.cell-trace')?.textContent?.trim()).toBe('—');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking an actor hash re-runs the query filtered on that hash + resets offset', async () => {
|
||||||
|
const { fixture, query } = setup();
|
||||||
|
await flush(fixture);
|
||||||
|
query.mockClear();
|
||||||
|
const root = fixture.nativeElement as HTMLElement;
|
||||||
|
const rows = root.querySelectorAll<HTMLTableRowElement>('.audit-table tbody tr');
|
||||||
|
rows[0]?.querySelector<HTMLButtonElement>('.actor-hash--clickable')?.click();
|
||||||
|
await flush(fixture);
|
||||||
|
expect(query).toHaveBeenCalledTimes(1);
|
||||||
|
const filters = query.mock.calls[0]?.[0] as AdminAuditQuery;
|
||||||
|
expect(filters.actorIdHash).toBe('hash(jane)');
|
||||||
|
expect(filters.offset).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the anonymous actor as plain text (not a button)', async () => {
|
||||||
|
const anonymousPage: AdminAuditPage = {
|
||||||
|
...PAGE,
|
||||||
|
items: [{ ...PAGE.items[0]!, actorIdHash: null }],
|
||||||
|
};
|
||||||
|
const { fixture } = setup({ initial: anonymousPage });
|
||||||
|
await flush(fixture);
|
||||||
|
const root = fixture.nativeElement as HTMLElement;
|
||||||
|
const row = root.querySelector<HTMLTableRowElement>('.audit-table tbody tr');
|
||||||
|
expect(row?.querySelector('.actor-hash--clickable')).toBeNull();
|
||||||
|
expect(row?.querySelector('.actor-hash')?.textContent?.trim()).toBe('(anonymous)');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 { environment } from '../../../environments/environment';
|
||||||
import {
|
import {
|
||||||
AuditEventsService,
|
AuditEventsService,
|
||||||
type AdminAuditPage,
|
type AdminAuditPage,
|
||||||
@@ -143,6 +144,31 @@ export class AuditPage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a Jaeger-UI deep link for the given trace id — the audit
|
||||||
|
* table's `trace_id` cell becomes a clickable link to the
|
||||||
|
* trace-explorer per ADR-0012's "join audit and app logs by
|
||||||
|
* trace_id" promise. Anonymous events (`traceId === null`) don't
|
||||||
|
* get a link; the cell stays a dash.
|
||||||
|
*/
|
||||||
|
protected jaegerUrl(traceId: string): string {
|
||||||
|
return `${environment.jaegerBaseUrl}/trace/${encodeURIComponent(traceId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pivot the table on a single actor: set the `actorIdHash` filter
|
||||||
|
* to the clicked hash, reset paging to 0, and re-query. Lets an
|
||||||
|
* investigator click any row's actor cell to "show me everything
|
||||||
|
* else this actor did". Per-query audit row (`admin.audit.query`)
|
||||||
|
* is still emitted server-side so the pivot is itself auditable.
|
||||||
|
*/
|
||||||
|
async filterByActor(hash: string): Promise<void> {
|
||||||
|
if (!hash) return;
|
||||||
|
this.actorIdHash.set(hash);
|
||||||
|
this.offset.set(0);
|
||||||
|
await this.fetch();
|
||||||
|
}
|
||||||
|
|
||||||
private async fetch(): Promise<void> {
|
private async fetch(): Promise<void> {
|
||||||
this.loading.set(true);
|
this.loading.set(true);
|
||||||
this.error.set(null);
|
this.error.set(null);
|
||||||
|
|||||||
@@ -43,4 +43,16 @@ export const environment = {
|
|||||||
* not an Angular route.
|
* not an Angular route.
|
||||||
*/
|
*/
|
||||||
shellAppUrl: 'http://localhost:4200',
|
shellAppUrl: 'http://localhost:4200',
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base origin of the Jaeger UI — the trace-explorer the admin
|
||||||
|
* audit-log viewer links each `trace_id` to. Dev points at the
|
||||||
|
* compose-managed Jaeger from the `observability` profile (see
|
||||||
|
* `infra/local/dev.compose.yml`). Prod will switch to whatever
|
||||||
|
* trace backend the future infrastructure ADR picks (Tempo,
|
||||||
|
* Grafana Cloud, on-prem Jaeger…) via the per-env file replacement.
|
||||||
|
*
|
||||||
|
* Trace URLs are built as `${jaegerBaseUrl}/trace/<id>`.
|
||||||
|
*/
|
||||||
|
jaegerBaseUrl: 'http://localhost:16686',
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user