Skip to content

Commit 022de24

Browse files
feat(activity): shared eventDisplay helper; kill raw source/severity leaks (Phase 1) (#617)
frontend-activity v1.1.0 (C-06, AC-06). Phase 1 of the activity readability initiative (docs/engineering/activity_readability_plan.md). There was no shared event-formatting layer: each surface rolled its own source/severity/time rendering, so the same raw enum leaked differently in each place (the dashboard widget was worst, printing 'alert · info'). New src/api/eventDisplay.ts is the single source of truth: sourceLabel (transaction -> 'Compliance'), severityLabel, severityTone, relativeTime. Adopted on every activity surface: - Dashboard Recent-activity widget: now sourceLabel + severityLabel (was raw a.source / a.severity). - ActivityPage: dropped the redundant bare {a.source} on the row (the category chip already labels it); severityTone now imported from the helper. - ActivityDrawer + HostDetailPage: deleted their private severityTone / activityRelativeTime copies in favour of the shared ones (this also fixes a drift where the drawer mapped 'low' to warn vs widgets' info, and removes an em-dash from the invalid-date fallback). Backend already makes the title/summary human (Phase 0); this makes the surrounding chrome consistent and enum-free. Verified live on the dashboard. Full frontend suite (320) + specter (111) green.
1 parent a549131 commit 022de24

7 files changed

Lines changed: 189 additions & 60 deletions

File tree

frontend/src/api/eventDisplay.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
// Shared display helpers for the unified activity feed (the
2+
// /api/v1/activity row shape). One source of truth so every surface
3+
// renders source, severity, and time the same way instead of each one
4+
// rolling its own — and never leaking a raw enum to the UI. The row's
5+
// title/summary are already human-readable from the backend
6+
// (system-activity v1.2.0); these helpers cover the surrounding chrome.
7+
//
8+
// Spec: frontend-activity v1.1.0.
9+
10+
export type ActivitySource = 'alert' | 'transaction' | 'intelligence' | 'audit' | 'monitoring';
11+
export type ActivitySeverity = 'critical' | 'high' | 'medium' | 'low' | 'info';
12+
export type Tone = 'crit' | 'warn' | 'info';
13+
14+
const SOURCE_LABEL: Record<string, string> = {
15+
alert: 'Alert',
16+
transaction: 'Compliance',
17+
intelligence: 'Intelligence',
18+
audit: 'Audit',
19+
monitoring: 'Monitoring',
20+
};
21+
22+
// sourceLabel turns the feed source enum into a friendly word. An unknown
23+
// source title-cases gracefully so a new source never renders raw.
24+
export function sourceLabel(source: string): string {
25+
return SOURCE_LABEL[source] ?? titleCase(source);
26+
}
27+
28+
const SEVERITY_LABEL: Record<string, string> = {
29+
critical: 'Critical',
30+
high: 'High',
31+
medium: 'Medium',
32+
low: 'Low',
33+
info: 'Info',
34+
};
35+
36+
export function severityLabel(severity: string): string {
37+
return SEVERITY_LABEL[severity] ?? titleCase(severity);
38+
}
39+
40+
// severityTone buckets a severity onto the three display tones used across
41+
// the app (crit/warn/info). Canonical: critical+high -> crit, medium ->
42+
// warn, low+info -> info. Replaces the per-surface copies that disagreed on
43+
// where "low" landed.
44+
export function severityTone(severity: string): Tone {
45+
if (severity === 'critical' || severity === 'high') return 'crit';
46+
if (severity === 'medium') return 'warn';
47+
return 'info';
48+
}
49+
50+
// relativeTime renders a compact "time ago" for a row's occurred_at, with an
51+
// absolute-date fallback beyond 30 days. Avoids the em-dash in the invalid
52+
// case (UI copy uses none).
53+
export function relativeTime(iso: string): string {
54+
const t = new Date(iso).getTime();
55+
if (Number.isNaN(t)) return '';
56+
const minutes = Math.max(0, Math.round((Date.now() - t) / 60_000));
57+
if (minutes < 1) return 'just now';
58+
if (minutes < 60) return `${minutes}m ago`;
59+
const hours = Math.round(minutes / 60);
60+
if (hours < 24) return `${hours}h ago`;
61+
const days = Math.round(hours / 24);
62+
if (days <= 30) return `${days}d ago`;
63+
return new Date(iso).toLocaleDateString(undefined, {
64+
month: 'numeric',
65+
day: 'numeric',
66+
year: 'numeric',
67+
});
68+
}
69+
70+
function titleCase(s: string): string {
71+
if (!s) return s;
72+
return s.charAt(0).toUpperCase() + s.slice(1);
73+
}

frontend/src/pages/HostDetailPage.tsx

Lines changed: 2 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { useHostExceptions } from '@/hooks/useHostExceptions';
3030
import { useHostRemediations } from '@/hooks/useHostRemediations';
3131
import { formatLift } from '@/components/hosts/RequestRemediationModal';
3232
import { apiErrorCode, apiErrorMessage } from '@/api/errors';
33+
import { relativeTime } from '@/api/eventDisplay';
3334
import { EditHostModal } from '@/components/hosts/EditHostModal';
3435
import { HostCredentialModal } from '@/components/hosts/HostCredentialModal';
3536
import { HostActionsMenu } from '@/components/hosts/HostActionsMenu';
@@ -2538,7 +2539,7 @@ function ActivityRow({ item }: { item: ActivityItem }) {
25382539
) : null}
25392540
</div>
25402541
<div style={{ color: 'var(--ow-fg-3)', fontSize: 11, whiteSpace: 'nowrap' }}>
2541-
{activityRelativeTime(item.occurred_at)}
2542+
{relativeTime(item.occurred_at)}
25422543
</div>
25432544
</li>
25442545
);
@@ -2593,27 +2594,6 @@ function activityIconFor(item: ActivityItem): { Icon: LucideIcon; color: string
25932594
}
25942595
}
25952596

2596-
// activityRelativeTime renders the right-side timestamp. The mockup
2597-
// uses relative wording for fresh events (m / h / d ago) and an
2598-
// absolute date for events older than 30 days — a sensible cutoff
2599-
// that prevents "412d ago" cells while keeping the recent feed
2600-
// chatty.
2601-
function activityRelativeTime(iso: string): string {
2602-
const t = new Date(iso).getTime();
2603-
if (Number.isNaN(t)) return '—';
2604-
const minutes = Math.max(0, Math.round((Date.now() - t) / 60_000));
2605-
if (minutes < 1) return 'just now';
2606-
if (minutes < 60) return `${minutes}m ago`;
2607-
const hours = Math.round(minutes / 60);
2608-
if (hours < 24) return `${hours}h ago`;
2609-
const days = Math.round(hours / 24);
2610-
if (days <= 30) return `${days}d ago`;
2611-
return new Date(iso).toLocaleDateString(undefined, {
2612-
month: 'numeric',
2613-
day: 'numeric',
2614-
year: 'numeric',
2615-
});
2616-
}
26172597

26182598
// ─────────────────────────────────────────────────────────────────────────
26192599
// Reusable bits (cards, kv rows, empty states, etc.)

frontend/src/pages/activity/ActivityDrawer.tsx

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import api from '@/api/client';
44
import { apiErrorMessage } from '@/api/errors';
55
import type { components } from '@/api/schema';
66
import { useAlertActions, type AlertAction } from './useAlertActions';
7+
import { severityTone } from '@/api/eventDisplay';
78

89
type Activity = components['schemas']['Activity'];
910

@@ -18,12 +19,6 @@ type Activity = components['schemas']['Activity'];
1819
//
1920
// Spec: frontend-activity.
2021

21-
export function severityTone(sev: string): 'crit' | 'warn' | 'info' {
22-
if (sev === 'critical' || sev === 'high') return 'crit';
23-
if (sev === 'medium' || sev === 'low') return 'warn';
24-
return 'info';
25-
}
26-
2722
const TONE: Record<string, string> = {
2823
crit: 'var(--ow-crit)',
2924
warn: 'var(--ow-warn)',

frontend/src/pages/activity/ActivityPage.tsx

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ import { useBreadcrumbStore } from '@/store/useBreadcrumbStore';
66
import { apiErrorMessage } from '@/api/errors';
77
import { useAuthStore } from '@/store/useAuthStore';
88
import type { components } from '@/api/schema';
9-
import { ActivityDrawer, severityTone } from './ActivityDrawer';
9+
import { ActivityDrawer } from './ActivityDrawer';
10+
import { severityTone } from '@/api/eventDisplay';
1011
import { useAlertActions } from './useAlertActions';
1112

1213
type Activity = components['schemas']['Activity'];
@@ -557,15 +558,6 @@ function Stream({
557558
/>
558559
</div>
559560
)}
560-
<span
561-
style={{
562-
color: 'var(--ow-fg-3)',
563-
fontSize: 11,
564-
fontFamily: 'var(--ow-font-mono, monospace)',
565-
}}
566-
>
567-
{a.source}
568-
</span>
569561
</div>
570562
</div>
571563
</div>

frontend/src/pages/dashboard/widgets.tsx

Lines changed: 4 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Link } from '@tanstack/react-router';
33
import api from '@/api/client';
44
import { apiErrorMessage } from '@/api/errors';
55
import { KpiValue, KpiSub, Sparkline, WidgetCard, WidgetState, toneVar } from './primitives';
6+
import { relativeTime, severityLabel, severityTone, sourceLabel } from '@/api/eventDisplay';
67

78
// Dashboard widgets — each is a lens into a fleet endpoint, owning its
89
// own query so loading/empty/error states are independent. All read-only
@@ -18,23 +19,6 @@ function scoreTone(pct: number): 'crit' | 'warn' | 'ok' {
1819
return 'ok';
1920
}
2021

21-
function sevTone(sev: string): 'crit' | 'warn' | 'info' {
22-
if (sev === 'critical' || sev === 'high') return 'crit';
23-
if (sev === 'medium') return 'warn';
24-
return 'info';
25-
}
26-
27-
// Compact relative time for activity rows.
28-
function timeAgo(iso: string, nowMs: number): string {
29-
const then = new Date(iso).getTime();
30-
const s = Math.max(0, Math.round((nowMs - then) / 1000));
31-
if (s < 60) return `${s}s ago`;
32-
const m = Math.round(s / 60);
33-
if (m < 60) return `${m}m ago`;
34-
const h = Math.round(m / 60);
35-
if (h < 24) return `${h}h ago`;
36-
return `${Math.round(h / 24)}d ago`;
37-
}
3822

3923
// ── KPI: Hosts online ──────────────────────────────────────────────
4024
export function KpiHostsOnline() {
@@ -300,7 +284,6 @@ export function WidgetRecentActivity() {
300284
return data!;
301285
},
302286
});
303-
const now = Date.now();
304287
return (
305288
<WidgetCard title="Recent activity" to="/activity">
306289
{q.isPending ? (
@@ -316,9 +299,9 @@ export function WidgetRecentActivity() {
316299
key={a.id}
317300
first={i === 0}
318301
label={a.title}
319-
sub={`${a.source} · ${timeAgo(a.occurred_at, now)}`}
320-
value={a.severity}
321-
dot={sevTone(a.severity)}
302+
sub={`${sourceLabel(a.source)} · ${relativeTime(a.occurred_at)}`}
303+
value={severityLabel(a.severity)}
304+
dot={severityTone(a.severity)}
322305
/>
323306
))}
324307
</div>
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
// @spec frontend-activity
2+
//
3+
// AC-06 shared eventDisplay helpers + adoption across surfaces
4+
5+
import { readFileSync } from 'node:fs';
6+
import { resolve } from 'node:path';
7+
8+
import { describe, expect, test } from 'vitest';
9+
10+
import {
11+
relativeTime,
12+
severityLabel,
13+
severityTone,
14+
sourceLabel,
15+
} from '@/api/eventDisplay';
16+
17+
const read = (p: string) => readFileSync(resolve(process.cwd(), p), 'utf8');
18+
19+
describe('frontend-activity — shared event-display helpers', () => {
20+
// @ac AC-06
21+
test('frontend-activity/AC-06 — sourceLabel/severityLabel/severityTone map known + title-case unknown', () => {
22+
expect(sourceLabel('transaction')).toBe('Compliance');
23+
expect(sourceLabel('alert')).toBe('Alert');
24+
expect(sourceLabel('monitoring')).toBe('Monitoring');
25+
expect(sourceLabel('intelligence')).toBe('Intelligence');
26+
expect(sourceLabel('audit')).toBe('Audit');
27+
// graceful: unknown source never renders raw lowercase.
28+
expect(sourceLabel('newfangled')).toBe('Newfangled');
29+
30+
expect(severityLabel('critical')).toBe('Critical');
31+
expect(severityLabel('info')).toBe('Info');
32+
expect(severityTone('critical')).toBe('crit');
33+
expect(severityTone('high')).toBe('crit');
34+
expect(severityTone('medium')).toBe('warn');
35+
expect(severityTone('low')).toBe('info');
36+
expect(severityTone('info')).toBe('info');
37+
});
38+
39+
// @ac AC-06
40+
test('frontend-activity/AC-06 — relativeTime is human + em-dash-free', () => {
41+
expect(relativeTime(new Date().toISOString())).toBe('just now');
42+
const twoH = new Date(Date.now() - 2 * 3_600_000).toISOString();
43+
expect(relativeTime(twoH)).toBe('2h ago');
44+
// invalid date -> empty string, never an em-dash.
45+
expect(relativeTime('not-a-date')).toBe('');
46+
expect(relativeTime('not-a-date')).not.toContain('—');
47+
});
48+
49+
// @ac AC-06
50+
test('frontend-activity/AC-06 — no surface renders a raw source/severity enum; per-surface copies removed', () => {
51+
const widgets = read('src/pages/dashboard/widgets.tsx');
52+
// dashboard widget adopts the shared helpers, not raw fields.
53+
expect(widgets).toContain('sourceLabel(a.source)');
54+
expect(widgets).toContain('severityLabel(a.severity)');
55+
expect(widgets).toContain('severityTone(a.severity)');
56+
expect(widgets).not.toMatch(/\$\{a\.source\} ·/); // old raw sub
57+
expect(widgets).not.toMatch(/function sevTone/);
58+
expect(widgets).not.toMatch(/function timeAgo/);
59+
60+
// ActivityPage no longer RENDERS the bare {a.source} as a JSX child.
61+
// (The client-side search haystack still references ${a.source} in a
62+
// template string — that is filtering, not a UI render, so we exclude
63+
// the `$`-prefixed template usage from the check.)
64+
const page = read('src/pages/activity/ActivityPage.tsx');
65+
expect(page).not.toMatch(/[^$]\{a\.source\}/);
66+
expect(page).toContain("from '@/api/eventDisplay'");
67+
68+
// The duplicate helpers are gone; the canonical ones are imported.
69+
const drawer = read('src/pages/activity/ActivityDrawer.tsx');
70+
expect(drawer).not.toMatch(/export function severityTone/);
71+
expect(drawer).toContain("from '@/api/eventDisplay'");
72+
const host = read('src/pages/HostDetailPage.tsx');
73+
expect(host).not.toMatch(/function activityRelativeTime/);
74+
expect(host).toContain("relativeTime(item.occurred_at)");
75+
});
76+
});

specs/frontend/activity.spec.yaml

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
spec:
22
id: frontend-activity
33
title: Activity feed (unified event stream)
4-
version: "1.0.0"
4+
version: "1.1.0"
55
status: approved
66
tier: 2
77

@@ -101,6 +101,20 @@ spec:
101101
category chip is a cosmetic per-source label, not a backend filter
102102
type: technical
103103
enforcement: error
104+
- id: C-06
105+
description: >-
106+
v1.1.0 — Every surface that renders unified activity rows
107+
(ActivityPage, the dashboard Recent-activity widget, the host-detail
108+
Recent-activity card, and the ActivityDrawer) MUST render the source
109+
and severity through the shared src/api/eventDisplay helpers
110+
(sourceLabel / severityLabel / severityTone / relativeTime) rather
111+
than printing the raw enum. No activity surface may render a bare
112+
{a.source} or {a.severity} enum to the user. The helpers are the
113+
single source of truth — per-surface copies of the severity-tone and
114+
relative-time logic are removed. Unknown enum values title-case
115+
gracefully; copy carries no em-dash.
116+
type: technical
117+
enforcement: error
104118

105119
acceptance_criteria:
106120
- id: AC-01
@@ -129,3 +143,19 @@ spec:
129143
Status/Group facets are absent.
130144
priority: high
131145
references_constraints: [C-05]
146+
- id: AC-06
147+
description: >-
148+
v1.1.0 — Behavioral: eventDisplay.sourceLabel maps each source enum
149+
(alert/transaction/intelligence/audit/monitoring) to a friendly word
150+
(e.g. transaction -> "Compliance") and title-cases an unknown source;
151+
severityLabel maps the severity enum to a capitalized word;
152+
severityTone buckets critical/high -> crit, medium -> warn,
153+
low/info -> info; relativeTime renders "just now"/"Nm ago"/"Nh ago"/
154+
"Nd ago" and never an em-dash for an invalid date. Source-inspection:
155+
the dashboard widget's Recent-activity Row renders sourceLabel(a.source)
156+
+ severityLabel(a.severity) (no bare a.source / a.severity), and the
157+
per-surface sevTone/timeAgo/activityRelativeTime/severityTone copies in
158+
widgets.tsx, HostDetailPage.tsx, and ActivityDrawer.tsx are gone in
159+
favour of the eventDisplay imports.
160+
priority: high
161+
references_constraints: [C-06]

0 commit comments

Comments
 (0)