From 932e55610e4bee6fe141291bba0267877d9c57b6 Mon Sep 17 00:00:00 2001 From: Remylus Losius Date: Sat, 20 Jun 2026 15:52:54 -0400 Subject: [PATCH] feat(activity): shared eventDisplay helper; kill raw source/severity leaks (Phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/src/api/eventDisplay.ts | 73 ++++++++++++++++++ frontend/src/pages/HostDetailPage.tsx | 24 +----- .../src/pages/activity/ActivityDrawer.tsx | 7 +- frontend/src/pages/activity/ActivityPage.tsx | 12 +-- frontend/src/pages/dashboard/widgets.tsx | 25 +----- frontend/tests/api/event-display.test.ts | 76 +++++++++++++++++++ specs/frontend/activity.spec.yaml | 32 +++++++- 7 files changed, 189 insertions(+), 60 deletions(-) create mode 100644 frontend/src/api/eventDisplay.ts create mode 100644 frontend/tests/api/event-display.test.ts diff --git a/frontend/src/api/eventDisplay.ts b/frontend/src/api/eventDisplay.ts new file mode 100644 index 00000000..e6414ecb --- /dev/null +++ b/frontend/src/api/eventDisplay.ts @@ -0,0 +1,73 @@ +// Shared display helpers for the unified activity feed (the +// /api/v1/activity row shape). One source of truth so every surface +// renders source, severity, and time the same way instead of each one +// rolling its own — and never leaking a raw enum to the UI. The row's +// title/summary are already human-readable from the backend +// (system-activity v1.2.0); these helpers cover the surrounding chrome. +// +// Spec: frontend-activity v1.1.0. + +export type ActivitySource = 'alert' | 'transaction' | 'intelligence' | 'audit' | 'monitoring'; +export type ActivitySeverity = 'critical' | 'high' | 'medium' | 'low' | 'info'; +export type Tone = 'crit' | 'warn' | 'info'; + +const SOURCE_LABEL: Record = { + alert: 'Alert', + transaction: 'Compliance', + intelligence: 'Intelligence', + audit: 'Audit', + monitoring: 'Monitoring', +}; + +// sourceLabel turns the feed source enum into a friendly word. An unknown +// source title-cases gracefully so a new source never renders raw. +export function sourceLabel(source: string): string { + return SOURCE_LABEL[source] ?? titleCase(source); +} + +const SEVERITY_LABEL: Record = { + critical: 'Critical', + high: 'High', + medium: 'Medium', + low: 'Low', + info: 'Info', +}; + +export function severityLabel(severity: string): string { + return SEVERITY_LABEL[severity] ?? titleCase(severity); +} + +// severityTone buckets a severity onto the three display tones used across +// the app (crit/warn/info). Canonical: critical+high -> crit, medium -> +// warn, low+info -> info. Replaces the per-surface copies that disagreed on +// where "low" landed. +export function severityTone(severity: string): Tone { + if (severity === 'critical' || severity === 'high') return 'crit'; + if (severity === 'medium') return 'warn'; + return 'info'; +} + +// relativeTime renders a compact "time ago" for a row's occurred_at, with an +// absolute-date fallback beyond 30 days. Avoids the em-dash in the invalid +// case (UI copy uses none). +export function relativeTime(iso: string): string { + const t = new Date(iso).getTime(); + if (Number.isNaN(t)) return ''; + const minutes = Math.max(0, Math.round((Date.now() - t) / 60_000)); + if (minutes < 1) return 'just now'; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.round(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.round(hours / 24); + if (days <= 30) return `${days}d ago`; + return new Date(iso).toLocaleDateString(undefined, { + month: 'numeric', + day: 'numeric', + year: 'numeric', + }); +} + +function titleCase(s: string): string { + if (!s) return s; + return s.charAt(0).toUpperCase() + s.slice(1); +} diff --git a/frontend/src/pages/HostDetailPage.tsx b/frontend/src/pages/HostDetailPage.tsx index 3ddf5cfd..b3a1e1ff 100644 --- a/frontend/src/pages/HostDetailPage.tsx +++ b/frontend/src/pages/HostDetailPage.tsx @@ -30,6 +30,7 @@ import { useHostExceptions } from '@/hooks/useHostExceptions'; import { useHostRemediations } from '@/hooks/useHostRemediations'; import { formatLift } from '@/components/hosts/RequestRemediationModal'; import { apiErrorCode, apiErrorMessage } from '@/api/errors'; +import { relativeTime } from '@/api/eventDisplay'; import { EditHostModal } from '@/components/hosts/EditHostModal'; import { HostCredentialModal } from '@/components/hosts/HostCredentialModal'; import { HostActionsMenu } from '@/components/hosts/HostActionsMenu'; @@ -2538,7 +2539,7 @@ function ActivityRow({ item }: { item: ActivityItem }) { ) : null}
- {activityRelativeTime(item.occurred_at)} + {relativeTime(item.occurred_at)}
); @@ -2593,27 +2594,6 @@ function activityIconFor(item: ActivityItem): { Icon: LucideIcon; color: string } } -// activityRelativeTime renders the right-side timestamp. The mockup -// uses relative wording for fresh events (m / h / d ago) and an -// absolute date for events older than 30 days — a sensible cutoff -// that prevents "412d ago" cells while keeping the recent feed -// chatty. -function activityRelativeTime(iso: string): string { - const t = new Date(iso).getTime(); - if (Number.isNaN(t)) return '—'; - const minutes = Math.max(0, Math.round((Date.now() - t) / 60_000)); - if (minutes < 1) return 'just now'; - if (minutes < 60) return `${minutes}m ago`; - const hours = Math.round(minutes / 60); - if (hours < 24) return `${hours}h ago`; - const days = Math.round(hours / 24); - if (days <= 30) return `${days}d ago`; - return new Date(iso).toLocaleDateString(undefined, { - month: 'numeric', - day: 'numeric', - year: 'numeric', - }); -} // ───────────────────────────────────────────────────────────────────────── // Reusable bits (cards, kv rows, empty states, etc.) diff --git a/frontend/src/pages/activity/ActivityDrawer.tsx b/frontend/src/pages/activity/ActivityDrawer.tsx index 830bce72..5cd1f375 100644 --- a/frontend/src/pages/activity/ActivityDrawer.tsx +++ b/frontend/src/pages/activity/ActivityDrawer.tsx @@ -4,6 +4,7 @@ import api from '@/api/client'; import { apiErrorMessage } from '@/api/errors'; import type { components } from '@/api/schema'; import { useAlertActions, type AlertAction } from './useAlertActions'; +import { severityTone } from '@/api/eventDisplay'; type Activity = components['schemas']['Activity']; @@ -18,12 +19,6 @@ type Activity = components['schemas']['Activity']; // // Spec: frontend-activity. -export function severityTone(sev: string): 'crit' | 'warn' | 'info' { - if (sev === 'critical' || sev === 'high') return 'crit'; - if (sev === 'medium' || sev === 'low') return 'warn'; - return 'info'; -} - const TONE: Record = { crit: 'var(--ow-crit)', warn: 'var(--ow-warn)', diff --git a/frontend/src/pages/activity/ActivityPage.tsx b/frontend/src/pages/activity/ActivityPage.tsx index 0595fccc..4a4deec5 100644 --- a/frontend/src/pages/activity/ActivityPage.tsx +++ b/frontend/src/pages/activity/ActivityPage.tsx @@ -6,7 +6,8 @@ import { useBreadcrumbStore } from '@/store/useBreadcrumbStore'; import { apiErrorMessage } from '@/api/errors'; import { useAuthStore } from '@/store/useAuthStore'; import type { components } from '@/api/schema'; -import { ActivityDrawer, severityTone } from './ActivityDrawer'; +import { ActivityDrawer } from './ActivityDrawer'; +import { severityTone } from '@/api/eventDisplay'; import { useAlertActions } from './useAlertActions'; type Activity = components['schemas']['Activity']; @@ -557,15 +558,6 @@ function Stream({ /> )} - - {a.source} - diff --git a/frontend/src/pages/dashboard/widgets.tsx b/frontend/src/pages/dashboard/widgets.tsx index de0b9f41..09a5352d 100644 --- a/frontend/src/pages/dashboard/widgets.tsx +++ b/frontend/src/pages/dashboard/widgets.tsx @@ -3,6 +3,7 @@ import { Link } from '@tanstack/react-router'; import api from '@/api/client'; import { apiErrorMessage } from '@/api/errors'; import { KpiValue, KpiSub, Sparkline, WidgetCard, WidgetState, toneVar } from './primitives'; +import { relativeTime, severityLabel, severityTone, sourceLabel } from '@/api/eventDisplay'; // Dashboard widgets — each is a lens into a fleet endpoint, owning its // own query so loading/empty/error states are independent. All read-only @@ -18,23 +19,6 @@ function scoreTone(pct: number): 'crit' | 'warn' | 'ok' { return 'ok'; } -function sevTone(sev: string): 'crit' | 'warn' | 'info' { - if (sev === 'critical' || sev === 'high') return 'crit'; - if (sev === 'medium') return 'warn'; - return 'info'; -} - -// Compact relative time for activity rows. -function timeAgo(iso: string, nowMs: number): string { - const then = new Date(iso).getTime(); - const s = Math.max(0, Math.round((nowMs - then) / 1000)); - if (s < 60) return `${s}s ago`; - const m = Math.round(s / 60); - if (m < 60) return `${m}m ago`; - const h = Math.round(m / 60); - if (h < 24) return `${h}h ago`; - return `${Math.round(h / 24)}d ago`; -} // ── KPI: Hosts online ────────────────────────────────────────────── export function KpiHostsOnline() { @@ -300,7 +284,6 @@ export function WidgetRecentActivity() { return data!; }, }); - const now = Date.now(); return ( {q.isPending ? ( @@ -316,9 +299,9 @@ export function WidgetRecentActivity() { key={a.id} first={i === 0} label={a.title} - sub={`${a.source} · ${timeAgo(a.occurred_at, now)}`} - value={a.severity} - dot={sevTone(a.severity)} + sub={`${sourceLabel(a.source)} · ${relativeTime(a.occurred_at)}`} + value={severityLabel(a.severity)} + dot={severityTone(a.severity)} /> ))} diff --git a/frontend/tests/api/event-display.test.ts b/frontend/tests/api/event-display.test.ts new file mode 100644 index 00000000..7c6232ac --- /dev/null +++ b/frontend/tests/api/event-display.test.ts @@ -0,0 +1,76 @@ +// @spec frontend-activity +// +// AC-06 shared eventDisplay helpers + adoption across surfaces + +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, test } from 'vitest'; + +import { + relativeTime, + severityLabel, + severityTone, + sourceLabel, +} from '@/api/eventDisplay'; + +const read = (p: string) => readFileSync(resolve(process.cwd(), p), 'utf8'); + +describe('frontend-activity — shared event-display helpers', () => { + // @ac AC-06 + test('frontend-activity/AC-06 — sourceLabel/severityLabel/severityTone map known + title-case unknown', () => { + expect(sourceLabel('transaction')).toBe('Compliance'); + expect(sourceLabel('alert')).toBe('Alert'); + expect(sourceLabel('monitoring')).toBe('Monitoring'); + expect(sourceLabel('intelligence')).toBe('Intelligence'); + expect(sourceLabel('audit')).toBe('Audit'); + // graceful: unknown source never renders raw lowercase. + expect(sourceLabel('newfangled')).toBe('Newfangled'); + + expect(severityLabel('critical')).toBe('Critical'); + expect(severityLabel('info')).toBe('Info'); + expect(severityTone('critical')).toBe('crit'); + expect(severityTone('high')).toBe('crit'); + expect(severityTone('medium')).toBe('warn'); + expect(severityTone('low')).toBe('info'); + expect(severityTone('info')).toBe('info'); + }); + + // @ac AC-06 + test('frontend-activity/AC-06 — relativeTime is human + em-dash-free', () => { + expect(relativeTime(new Date().toISOString())).toBe('just now'); + const twoH = new Date(Date.now() - 2 * 3_600_000).toISOString(); + expect(relativeTime(twoH)).toBe('2h ago'); + // invalid date -> empty string, never an em-dash. + expect(relativeTime('not-a-date')).toBe(''); + expect(relativeTime('not-a-date')).not.toContain('—'); + }); + + // @ac AC-06 + test('frontend-activity/AC-06 — no surface renders a raw source/severity enum; per-surface copies removed', () => { + const widgets = read('src/pages/dashboard/widgets.tsx'); + // dashboard widget adopts the shared helpers, not raw fields. + expect(widgets).toContain('sourceLabel(a.source)'); + expect(widgets).toContain('severityLabel(a.severity)'); + expect(widgets).toContain('severityTone(a.severity)'); + expect(widgets).not.toMatch(/\$\{a\.source\} ·/); // old raw sub + expect(widgets).not.toMatch(/function sevTone/); + expect(widgets).not.toMatch(/function timeAgo/); + + // ActivityPage no longer RENDERS the bare {a.source} as a JSX child. + // (The client-side search haystack still references ${a.source} in a + // template string — that is filtering, not a UI render, so we exclude + // the `$`-prefixed template usage from the check.) + const page = read('src/pages/activity/ActivityPage.tsx'); + expect(page).not.toMatch(/[^$]\{a\.source\}/); + expect(page).toContain("from '@/api/eventDisplay'"); + + // The duplicate helpers are gone; the canonical ones are imported. + const drawer = read('src/pages/activity/ActivityDrawer.tsx'); + expect(drawer).not.toMatch(/export function severityTone/); + expect(drawer).toContain("from '@/api/eventDisplay'"); + const host = read('src/pages/HostDetailPage.tsx'); + expect(host).not.toMatch(/function activityRelativeTime/); + expect(host).toContain("relativeTime(item.occurred_at)"); + }); +}); diff --git a/specs/frontend/activity.spec.yaml b/specs/frontend/activity.spec.yaml index 47257790..e096ce16 100644 --- a/specs/frontend/activity.spec.yaml +++ b/specs/frontend/activity.spec.yaml @@ -1,7 +1,7 @@ spec: id: frontend-activity title: Activity feed (unified event stream) - version: "1.0.0" + version: "1.1.0" status: approved tier: 2 @@ -101,6 +101,20 @@ spec: category chip is a cosmetic per-source label, not a backend filter type: technical enforcement: error + - id: C-06 + description: >- + v1.1.0 — Every surface that renders unified activity rows + (ActivityPage, the dashboard Recent-activity widget, the host-detail + Recent-activity card, and the ActivityDrawer) MUST render the source + and severity through the shared src/api/eventDisplay helpers + (sourceLabel / severityLabel / severityTone / relativeTime) rather + than printing the raw enum. No activity surface may render a bare + {a.source} or {a.severity} enum to the user. The helpers are the + single source of truth — per-surface copies of the severity-tone and + relative-time logic are removed. Unknown enum values title-case + gracefully; copy carries no em-dash. + type: technical + enforcement: error acceptance_criteria: - id: AC-01 @@ -129,3 +143,19 @@ spec: Status/Group facets are absent. priority: high references_constraints: [C-05] + - id: AC-06 + description: >- + v1.1.0 — Behavioral: eventDisplay.sourceLabel maps each source enum + (alert/transaction/intelligence/audit/monitoring) to a friendly word + (e.g. transaction -> "Compliance") and title-cases an unknown source; + severityLabel maps the severity enum to a capitalized word; + severityTone buckets critical/high -> crit, medium -> warn, + low/info -> info; relativeTime renders "just now"/"Nm ago"/"Nh ago"/ + "Nd ago" and never an em-dash for an invalid date. Source-inspection: + the dashboard widget's Recent-activity Row renders sourceLabel(a.source) + + severityLabel(a.severity) (no bare a.source / a.severity), and the + per-surface sevTone/timeAgo/activityRelativeTime/severityTone copies in + widgets.tsx, HostDetailPage.tsx, and ActivityDrawer.tsx are gone in + favour of the eventDisplay imports. + priority: high + references_constraints: [C-06]