Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions frontend/src/api/eventDisplay.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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<string, string> = {
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);
}
24 changes: 2 additions & 22 deletions frontend/src/pages/HostDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -2538,7 +2539,7 @@ function ActivityRow({ item }: { item: ActivityItem }) {
) : null}
</div>
<div style={{ color: 'var(--ow-fg-3)', fontSize: 11, whiteSpace: 'nowrap' }}>
{activityRelativeTime(item.occurred_at)}
{relativeTime(item.occurred_at)}
</div>
</li>
);
Expand Down Expand Up @@ -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.)
Expand Down
7 changes: 1 addition & 6 deletions frontend/src/pages/activity/ActivityDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'];

Expand All @@ -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<string, string> = {
crit: 'var(--ow-crit)',
warn: 'var(--ow-warn)',
Expand Down
12 changes: 2 additions & 10 deletions frontend/src/pages/activity/ActivityPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
Expand Down Expand Up @@ -557,15 +558,6 @@ function Stream({
/>
</div>
)}
<span
style={{
color: 'var(--ow-fg-3)',
fontSize: 11,
fontFamily: 'var(--ow-font-mono, monospace)',
}}
>
{a.source}
</span>
</div>
</div>
</div>
Expand Down
25 changes: 4 additions & 21 deletions frontend/src/pages/dashboard/widgets.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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() {
Expand Down Expand Up @@ -300,7 +284,6 @@ export function WidgetRecentActivity() {
return data!;
},
});
const now = Date.now();
return (
<WidgetCard title="Recent activity" to="/activity">
{q.isPending ? (
Expand All @@ -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)}
/>
))}
</div>
Expand Down
76 changes: 76 additions & 0 deletions frontend/tests/api/event-display.test.ts
Original file line number Diff line number Diff line change
@@ -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)");
});
});
32 changes: 31 additions & 1 deletion specs/frontend/activity.spec.yaml
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]
Loading