Skip to content

Commit 02007b5

Browse files
feat(settings): readable Audit log rows (Phase 3a) (#622)
frontend-settings v1.12.0 (AC-31). Phase 3a of the activity readability initiative. The settings Audit log rendered the raw dotted action code as the primary column and actor/resource UUIDs inline. Now it consumes the server-rendered readable message (added in Phase 2b): - EVENT column: ev.message (the sentence, e.g. 'The system ran a scheduled tick') as the primary line, with the raw ev.action as a small mono sub-line for auditors who need the code. - ACTOR: ev.actor_label (the name), falling back to a readable actor-type word — never a raw UUID. - RESOURCE: the capitalized resource_type only; the raw actor_id/resource_id UUIDs move to the expand drawer. - Time + severity now use the shared eventDisplay helpers (relativeTime, severityLabel); the local relTime + its em-dash are gone. Read-only + audit:read gating unchanged. Verified live. Full frontend suite (322) + specter (111, structural 100%) green.
1 parent a06010f commit 02007b5

3 files changed

Lines changed: 75 additions & 40 deletions

File tree

frontend/src/pages/settings/AuditPage.tsx

Lines changed: 50 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
StatusPill,
1515
} from '@/components/settings/primitives';
1616
import { ForbiddenPage } from '@/pages/ForbiddenPage';
17+
import { relativeTime, severityLabel } from '@/api/eventDisplay';
1718
import type { components } from '@/api/schema';
1819

1920
type AuditEvent = components['schemas']['AuditEvent'];
@@ -43,18 +44,6 @@ function severityTier(severity?: string): 'ok' | 'warn' | 'crit' {
4344
return 'ok';
4445
}
4546

46-
function relTime(iso: string): string {
47-
const then = new Date(iso).getTime();
48-
if (Number.isNaN(then)) return iso;
49-
const secs = Math.max(0, Math.floor((Date.now() - then) / 1000));
50-
if (secs < 60) return `${secs}s ago`;
51-
const mins = Math.floor(secs / 60);
52-
if (mins < 60) return `${mins}m ago`;
53-
const hrs = Math.floor(mins / 60);
54-
if (hrs < 24) return `${hrs}h ago`;
55-
return `${Math.floor(hrs / 24)}d ago`;
56-
}
57-
5847
export function AuditPage() {
5948
const setCrumbs = useBreadcrumbStore((s) => s.setCrumbs);
6049
const canRead = useAuthStore((s) => s.hasPermission)('audit:read');
@@ -233,7 +222,7 @@ export function AuditPage() {
233222
}}
234223
>
235224
<span role="columnheader">When</span>
236-
<span role="columnheader">Action</span>
225+
<span role="columnheader">Event</span>
237226
<span role="columnheader">Severity</span>
238227
<span role="columnheader">Actor</span>
239228
<span role="columnheader">Resource</span>
@@ -279,9 +268,12 @@ function AuditRow({ ev }: { ev: AuditEvent }) {
279268
}}
280269
>
281270
<span style={{ fontSize: 12, color: 'var(--ow-fg-2)' }} title={ev.occurred_at}>
282-
{relTime(ev.occurred_at)}
271+
{relativeTime(ev.occurred_at)}
283272
</span>
284273
<span style={{ minWidth: 0 }}>
274+
{/* Primary: the server-rendered readable sentence; the raw action
275+
code sits beneath as a small mono line for auditors who need it.
276+
The full envelope (UUIDs, detail) is in the expandable drawer. */}
285277
{hasDetail ? (
286278
<button
287279
type="button"
@@ -292,44 +284,30 @@ function AuditRow({ ev }: { ev: AuditEvent }) {
292284
border: 0,
293285
padding: 0,
294286
color: 'var(--ow-fg-0)',
295-
fontFamily: 'var(--ow-font-mono)',
296-
fontSize: 12,
287+
fontSize: 13,
297288
cursor: 'pointer',
298289
textAlign: 'left',
299290
}}
300291
>
301-
{open ? '▾' : '▸'} {ev.action}
292+
{open ? '▾' : '▸'} {ev.message || ev.action}
302293
</button>
303294
) : (
304-
<span style={{ fontFamily: 'var(--ow-font-mono)', fontSize: 12 }}>{ev.action}</span>
295+
<span style={{ fontSize: 13, color: 'var(--ow-fg-0)' }}>{ev.message || ev.action}</span>
305296
)}
297+
<div style={{ fontFamily: 'var(--ow-font-mono)', fontSize: 11, color: 'var(--ow-fg-3)' }}>
298+
{ev.action}
299+
</div>
306300
</span>
307301
<span>
308-
<StatusPill tier={severityTier(ev.severity)}>{ev.severity || 'info'}</StatusPill>
302+
<StatusPill tier={severityTier(ev.severity)}>
303+
{severityLabel(ev.severity || 'info')}
304+
</StatusPill>
309305
</span>
310306
<span style={{ fontSize: 12, color: 'var(--ow-fg-1)', minWidth: 0 }}>
311-
{ev.actor_type}
312-
{ev.actor_id ? (
313-
<span style={{ color: 'var(--ow-fg-3)', fontFamily: 'var(--ow-font-mono)' }}>
314-
{' '}
315-
{ev.actor_id}
316-
</span>
317-
) : null}
307+
{ev.actor_label || actorTypeWord(ev.actor_type)}
318308
</span>
319309
<span style={{ fontSize: 12, color: 'var(--ow-fg-2)', minWidth: 0 }}>
320-
{ev.resource_type ? (
321-
<>
322-
{ev.resource_type}
323-
{ev.resource_id ? (
324-
<span style={{ color: 'var(--ow-fg-3)', fontFamily: 'var(--ow-font-mono)' }}>
325-
{' '}
326-
{ev.resource_id}
327-
</span>
328-
) : null}
329-
</>
330-
) : (
331-
<span style={{ color: 'var(--ow-fg-3)' }}>{'—'}</span>
332-
)}
310+
{ev.resource_type ? capitalize(ev.resource_type) : ''}
333311
</span>
334312
</div>
335313
{open && hasDetail && (
@@ -341,6 +319,17 @@ function AuditRow({ ev }: { ev: AuditEvent }) {
341319
correlation:{' '}
342320
<span style={{ fontFamily: 'var(--ow-font-mono)' }}>{ev.correlation_id}</span>
343321
</div>
322+
{ev.actor_id ? (
323+
<div style={{ fontSize: 11, color: 'var(--ow-fg-3)', marginBottom: 2 }}>
324+
actor id: <span style={{ fontFamily: 'var(--ow-font-mono)' }}>{ev.actor_id}</span>
325+
</div>
326+
) : null}
327+
{ev.resource_id ? (
328+
<div style={{ fontSize: 11, color: 'var(--ow-fg-3)', marginBottom: 6 }}>
329+
resource id:{' '}
330+
<span style={{ fontFamily: 'var(--ow-font-mono)' }}>{ev.resource_id}</span>
331+
</div>
332+
) : null}
344333
{ev.redactions && ev.redactions.length > 0 && (
345334
<div style={{ fontSize: 11, color: 'var(--ow-warn)', marginBottom: 6 }}>
346335
redacted fields: {ev.redactions.join(', ')}
@@ -370,6 +359,28 @@ function AuditRow({ ev }: { ev: AuditEvent }) {
370359
);
371360
}
372361

362+
// capitalize a single word; actorTypeWord renders a readable actor type
363+
// (the fallback when an event carries no actor_label).
364+
function capitalize(s: string): string {
365+
return s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
366+
}
367+
function actorTypeWord(actorType: string): string {
368+
switch (actorType) {
369+
case 'system':
370+
case '':
371+
return 'The system';
372+
case 'scheduler':
373+
return 'The scheduler';
374+
case 'user':
375+
return 'A user';
376+
case 'api_key':
377+
case 'api_token':
378+
return 'An API token';
379+
default:
380+
return capitalize(actorType);
381+
}
382+
}
383+
373384
const labelStyle = { fontSize: 11, color: 'var(--ow-fg-3)' } as const;
374385
const dateInputStyle = {
375386
height: 32,

frontend/tests/pages/settings.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -496,4 +496,23 @@ describe('frontend-settings — structural', () => {
496496
expect(FRAME_SRC).toContain('hydrateFromServer');
497497
expect(FRAME_SRC).toMatch(/useEffect\(/);
498498
});
499+
500+
// @ac AC-31
501+
test('frontend-settings/AC-31 — Audit log rows are readable: message primary, actor name not UUID', () => {
502+
// Primary line = server message (fallback to raw action); raw action as a sub-line.
503+
expect(AUDIT_SRC).toMatch(/ev\.message \|\| ev\.action/);
504+
// Actor shows the label (name), not the raw UUID, in the main row.
505+
expect(AUDIT_SRC).toMatch(/ev\.actor_label \|\| actorTypeWord\(ev\.actor_type\)/);
506+
// The raw UUIDs are not rendered inline in the row's actor/resource cells
507+
// (they moved to the expanded detail under "actor id" / "resource id").
508+
expect(AUDIT_SRC).toContain('actor id:');
509+
expect(AUDIT_SRC).toContain('resource id:');
510+
// Shared display helpers; the local relTime + em-dash are gone.
511+
expect(AUDIT_SRC).toContain("from '@/api/eventDisplay'");
512+
expect(AUDIT_SRC).toMatch(/relativeTime\(ev\.occurred_at\)/);
513+
expect(AUDIT_SRC).not.toMatch(/function relTime/);
514+
expect(AUDIT_SRC).not.toContain('—');
515+
// Still read-only.
516+
expect(AUDIT_SRC).not.toMatch(/api\.(POST|PATCH|PUT|DELETE)\(/);
517+
});
499518
});

specs/frontend/settings.spec.yaml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
spec:
22
id: frontend-settings
33
title: Settings — two-pane shell with 11 sub-pages
4-
version: "1.11.0"
4+
version: "1.12.0"
55
status: draft
66
tier: 2
77

@@ -368,3 +368,8 @@ spec:
368368
description: "v1.11.0 source-inspection: usePreferencesStore keeps the Zustand persist('ow-preferences') localStorage cache AND syncs server-side — every setter calls push() which PATCHes /api/v1/users/me/preferences (best-effort, errors swallowed), and hydrateFromServer() GETs /api/v1/users/me/preferences and applies present keys to state. AppFrame calls hydrateFromServer once on mount via useEffect, so the authenticated shell reconciles preferences with the account."
369369
priority: high
370370
references_constraints: [C-06]
371+
372+
- id: AC-31
373+
description: "v1.12.0 source-inspection: the Audit log row renders the server-rendered readable message (ev.message || ev.action) as the primary line with the raw ev.action as a small mono sub-line; the Actor column shows ev.actor_label (falling back to a readable actorTypeWord, never a raw UUID); the Resource column shows the capitalized resource_type only (no UUID); the raw actor_id and resource_id UUIDs appear only in the expanded detail drawer. Time uses the shared relativeTime and severity the shared severityLabel from @/api/eventDisplay (no local relTime, no em-dash). The page remains read-only and audit:read-gated (AC-19)."
374+
priority: high
375+
references_constraints: [C-13]

0 commit comments

Comments
 (0)