feat(portal-admin): jaeger deep link on trace_id + actor-pivot on actor_id_hash #166
@@ -144,12 +144,33 @@
|
||||
>
|
||||
</td>
|
||||
<td class="cell-actor">
|
||||
<div class="actor-hash">{{ event.actorIdHash ?? '(anonymous)' }}</div>
|
||||
@if (event.subject; as subject) {
|
||||
@if (event.actorIdHash; as hash) {
|
||||
<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>
|
||||
}
|
||||
</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">
|
||||
@if (event.payload) {
|
||||
<details>
|
||||
|
||||
@@ -273,6 +273,65 @@
|
||||
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,
|
||||
.outcome-badge {
|
||||
display: inline-block;
|
||||
|
||||
@@ -197,4 +197,52 @@ describe('AuditPage', () => {
|
||||
expect(rows[0]?.querySelector('details')).not.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 { FormsModule } from '@angular/forms';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import {
|
||||
AuditEventsService,
|
||||
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> {
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
|
||||
@@ -43,4 +43,16 @@ export const environment = {
|
||||
* not an Angular route.
|
||||
*/
|
||||
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