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
17 changes: 12 additions & 5 deletions docs/engineering/reports_design.md
Original file line number Diff line number Diff line change
Expand Up @@ -566,11 +566,18 @@ first producer**. Async is an optimization, not a correctness gate: `Export`
stays the lazy fallback so a download before the job runs still renders
inline. Spec: `api-reports` v1.10.0 (C-16 / AC-22) + the new eventbus kind.

**B3c — Notification bell (frontend).** *(REMAINING; needs product input.)*
Turn the stubbed TopBar bell into a real consumer of `report.ready` (and
later other events) over SSE — unread state, what the feed shows, whether
notifications persist. This is the product-design surface of B3 and is
held for a direction decision rather than guessed.
**B3c — Notification bell (frontend).** *(SHIPPED 2026-06-21, PR #647 —
conservative MVP.)* The stubbed TopBar bell is now a real consumer of
`report.ready`: `useLiveEvents` subscribes to the topic and bumps a
session-scoped unread counter in a small Zustand store
(`useNotificationStore`); the bell renders that count as a badge and, on
click, opens `/reports` and clears it. MVP scope is deliberately small and
honest — the counter is session-scoped (a refresh resets it), there is no
dropdown feed of individual notifications, and `report.ready` is the only
event type. A durable per-user feed (a dropdown list, multiple event types,
cross-session persistence) is the deferred follow-on and is NOT faked. Spec:
`frontend-live-events` v1.3.0 (C-08 / AC-10) + new `frontend-notifications`
v1.0.0.

### Recommended order
B0 → B1 → B2 → B3b → B3a → B3c. B0 unblocks attestation scoping + the
Expand Down
57 changes: 39 additions & 18 deletions frontend/src/components/shell/TopBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import api from '@/api/client';
import { useAuthStore } from '@/store/useAuthStore';
import { useBreadcrumbStore } from '@/store/useBreadcrumbStore';
import { useColorSchemeStore, type ColorScheme } from '@/store/useColorSchemeStore';
import { useNotificationStore } from '@/store/useNotificationStore';

// TopBar — sticky header with breadcrumb, theme toggle, notifications,
// and the account menu (button + dropdown with Sign out).
Expand Down Expand Up @@ -243,29 +244,49 @@ function ThemeIconToggle() {
}

function NotificationBell() {
// The /activity route is deferred (see app/docs/activity_and_os_intelligence.md).
// Render the bell with the unread indicator; click is a no-op for now.
// The bell's first (and currently only) producer is report.ready: when a
// generated attestation's bulk faces finish rendering async, useLiveEvents
// bumps the unread counter. Clicking opens Reports and clears the count.
// Session-scoped, no dropdown feed yet (spec frontend-notifications).
const navigate = useNavigate();
const unread = useNotificationStore((s) => s.unreadReports);
const clearReports = useNotificationStore((s) => s.clearReports);
const label = unread > 0 ? `${unread} report${unread > 1 ? 's' : ''} ready` : 'Notifications';
return (
<button
type="button"
aria-label="Notifications (coming soon)"
title="Notifications"
style={{ ...iconBtn, position: 'relative', cursor: 'not-allowed', opacity: 0.85 }}
disabled
aria-label={label}
title={label}
onClick={() => {
clearReports();
navigate({ to: '/reports' });
}}
style={{ ...iconBtn, position: 'relative' }}
>
<Bell size={14} />
<span
style={{
position: 'absolute',
top: 6,
right: 6,
width: 7,
height: 7,
background: 'var(--ow-crit)',
borderRadius: '50%',
boxShadow: '0 0 0 2px var(--ow-bg-0)',
}}
/>
{unread > 0 && (
<span
aria-hidden
style={{
position: 'absolute',
top: -3,
right: -3,
minWidth: 14,
height: 14,
padding: '0 3px',
background: 'var(--ow-crit)',
color: '#fff',
borderRadius: 7,
fontSize: 9,
fontWeight: 700,
lineHeight: '14px',
textAlign: 'center',
boxShadow: '0 0 0 2px var(--ow-bg-0)',
}}
>
{unread > 9 ? '9+' : unread}
</span>
)}
</button>
);
}
Expand Down
20 changes: 17 additions & 3 deletions frontend/src/hooks/useLiveEvents.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useEffect, useRef } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useAuthStore } from '@/store/useAuthStore';
import { useNotificationStore } from '@/store/useNotificationStore';

// useLiveEvents — opens one SSE connection to /api/v1/events?topics=…
// and dispatches each incoming event to the right TanStack Query
Expand All @@ -22,16 +23,18 @@ import { useAuthStore } from '@/store/useAuthStore';
// 3-5s backoff. If we ever need explicit backoff control we'll switch
// to a manual fetch + ReadableStream loop.

// Closed set of topics this hook subscribes to (v1.1.0: + scan.completed).
// Each MUST exist in backend eventbus.AllEventKinds (Go-side closed
// enum). Spec frontend-live-events C-01 + AC-01 enforce.
// Closed set of topics this hook subscribes to (v1.1.0: + scan.completed;
// v1.2.0: + remediation.completed; v1.3.0: + report.ready). Each MUST
// exist in backend eventbus.AllEventKinds (Go-side closed enum). Spec
// frontend-live-events C-01 + AC-01 enforce.
export const ALL_TOPICS = [
'host.changed',
'monitoring.band.changed',
'host.discovered',
'intelligence.event',
'scan.completed',
'remediation.completed',
'report.ready',
] as const;

type Topic = (typeof ALL_TOPICS)[number];
Expand Down Expand Up @@ -156,6 +159,17 @@ export function useLiveEvents(options: UseLiveEventsOptions = {}) {
queryClient.invalidateQueries({ queryKey: ['host', hostId] });
}
},
// report.ready -> the report's bulk faces finished rendering async
// (spec api-reports B3a). Bump the notification bell's unread counter
// (the first producer of the in-app bell) and refresh the Reports
// library so the new report's faces are downloadable without a manual
// refresh. No host id: a report is fleet-scoped, not per-host.
'report.ready': (e) => {
const env = parseEnvelope(e);
if (!env) return;
useNotificationStore.getState().bumpReportReady();
queryClient.invalidateQueries({ queryKey: ['reports'] });
},
};

for (const k of topics) {
Expand Down
27 changes: 27 additions & 0 deletions frontend/src/store/useNotificationStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { create } from 'zustand';

// useNotificationStore — the in-app notification bell's state.
//
// MVP scope (deliberately small, honest): a SESSION-SCOPED unread counter
// for "report ready" events. useLiveEvents bumps it when a report.ready
// SSE event arrives (the first, and currently only, producer); the TopBar
// bell renders the count as a badge and clears it when the operator opens
// Reports. There is no server-side notification feed, no per-item read
// state, and no cross-session persistence yet — a refresh resets the
// counter. A richer feed (a dropdown list, multiple event types, durable
// per-user notifications) is a deferred follow-on, not faked here.

interface NotificationState {
/** Count of report.ready events received this session and not yet seen. */
unreadReports: number;
/** Increment the unread counter (one report finished rendering). */
bumpReportReady: () => void;
/** Clear the unread counter (the operator opened Reports). */
clearReports: () => void;
}

export const useNotificationStore = create<NotificationState>((set) => ({
unreadReports: 0,
bumpReportReady: () => set((s) => ({ unreadReports: s.unreadReports + 1 })),
clearReports: () => set({ unreadReports: 0 }),
}));
51 changes: 51 additions & 0 deletions frontend/tests/components/notification-bell.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// @spec frontend-notifications
//
// AC traceability (source inspection):
// AC-01 useNotificationStore: zustand store with unreadReports (init 0),
// bumpReportReady (increments), clearReports (resets to 0)
// AC-02 TopBar NotificationBell renders a badge only when unreadReports > 0,
// clears + navigates to /reports on click, is not the disabled stub,
// no em-dash

import { describe, expect, test } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';

const STORE_SRC = readFileSync(resolve(process.cwd(), 'src/store/useNotificationStore.ts'), 'utf8');
const TOPBAR_SRC = readFileSync(resolve(process.cwd(), 'src/components/shell/TopBar.tsx'), 'utf8');

function stripComments(s: string): string {
return s.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
}

describe('frontend-notifications — notification bell', () => {
// @ac AC-01
test('frontend-notifications/AC-01 — useNotificationStore shape (counter + bump + clear)', () => {
// A zustand store.
expect(STORE_SRC).toMatch(/create<NotificationState>\(/);
// unreadReports initialised to 0.
expect(STORE_SRC).toMatch(/unreadReports:\s*0/);
// bumpReportReady increments the counter.
expect(STORE_SRC).toMatch(
/bumpReportReady:\s*\(\)\s*=>\s*set\(\(s\)\s*=>\s*\(\{\s*unreadReports:\s*s\.unreadReports\s*\+\s*1/,
);
// clearReports resets it to 0.
expect(STORE_SRC).toMatch(/clearReports:\s*\(\)\s*=>\s*set\(\{\s*unreadReports:\s*0/);
});

// @ac AC-02
test('frontend-notifications/AC-02 — TopBar bell renders badge, clears + navigates on click', () => {
// Reads the unread counter from the store.
expect(TOPBAR_SRC).toContain('useNotificationStore');
expect(TOPBAR_SRC).toMatch(/useNotificationStore\(\(s\)\s*=>\s*s\.unreadReports\)/);
// Badge renders only when unread > 0.
expect(TOPBAR_SRC).toMatch(/unread > 0 &&/);
// Click clears the counter and navigates to /reports.
expect(TOPBAR_SRC).toMatch(/clearReports\(\)/);
expect(TOPBAR_SRC).toMatch(/navigate\(\{\s*to:\s*'\/reports'\s*\}\)/);
// The bell is no longer the disabled "coming soon" stub.
expect(TOPBAR_SRC).not.toContain('Notifications (coming soon)');
// No em-dash in the bell copy.
expect(stripComments(TOPBAR_SRC).includes('—')).toBe(false);
});
});
22 changes: 20 additions & 2 deletions frontend/tests/hooks/useLiveEvents.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
// AC-06 test('frontend-live-events/AC-06 — missing host_id falls back to list-only')
// AC-07 test('frontend-live-events/AC-07 — source-inspect: exactly one new EventSource(...) call')
// AC-08 test('frontend-live-events/AC-08 — scan.completed invalidates [hosts] + [host, id]')
// AC-10 test('frontend-live-events/AC-10 — report.ready invalidates [reports] + bumps the bell')

import { expect, test, beforeEach, vi } from 'vitest';
import { renderHook } from '@testing-library/react';
Expand All @@ -19,6 +20,7 @@ import { resolve } from 'node:path';
import type { ReactNode } from 'react';
import { ALL_TOPICS, useLiveEvents } from '@/hooks/useLiveEvents';
import { useAuthStore } from '@/store/useAuthStore';
import { useNotificationStore } from '@/store/useNotificationStore';

// ---------- EventSource stub --------------------------------------------

Expand Down Expand Up @@ -84,7 +86,7 @@ beforeEach(() => {

// @ac AC-01
// AC-01: ALL_TOPICS exported as the closed set (v1.1.0 adds scan.completed;
// v1.2.0 adds remediation.completed).
// v1.2.0 adds remediation.completed; v1.3.0 adds report.ready).
test('frontend-live-events/AC-01 — ALL_TOPICS is the closed v1.0 set', () => {
const want = [
'host.changed',
Expand All @@ -93,9 +95,10 @@ test('frontend-live-events/AC-01 — ALL_TOPICS is the closed v1.0 set', () => {
'intelligence.event',
'scan.completed',
'remediation.completed',
'report.ready',
];
expect([...ALL_TOPICS]).toEqual(want);
expect(ALL_TOPICS.length).toBe(6);
expect(ALL_TOPICS.length).toBe(7);
});

// Helper to mount the hook and return the stub + spies.
Expand Down Expand Up @@ -185,6 +188,21 @@ test('frontend-live-events/AC-06 — missing host_id falls back to list-only', (
expect(hostKeyed).toEqual([]);
});

// @ac AC-10
// AC-10: report.ready invalidates ["reports"] and bumps the notification
// store's unread counter; a report is fleet-scoped, so NO ["host", ...]
// invalidation fires.
test('frontend-live-events/AC-10 — report.ready invalidates [reports] + bumps the bell', () => {
useNotificationStore.setState({ unreadReports: 0 });
const { es, spy } = mountHook();
es.fire('report.ready', { SnapshotID: 'rep-1', ReportKind: 'attestation', Faces: ['csv'] });
const calls = spy.mock.calls.map((c) => c[0]?.queryKey);
expect(calls).toContainEqual(['reports']);
const hostKeyed = calls.filter((k) => Array.isArray(k) && k[0] === 'host');
expect(hostKeyed).toEqual([]);
expect(useNotificationStore.getState().unreadReports).toBe(1);
});

// @ac AC-07
test('frontend-live-events/AC-07 — source-inspect: exactly one new EventSource(...) call', () => {
const src = readFileSync(resolve(process.cwd(), 'src/hooks/useLiveEvents.ts'), 'utf8');
Expand Down
14 changes: 11 additions & 3 deletions specs/frontend/live-events.spec.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
spec:
id: frontend-live-events
title: useLiveEvents SSE hook — topic ↔ query-key invalidation contract
version: "1.1.0"
version: "1.3.0"
status: approved
tier: 2

Expand Down Expand Up @@ -66,7 +66,7 @@ spec:

constraints:
- id: C-01
description: 'ALL_TOPICS MUST be a closed const-tuple containing exactly the six kinds: host.changed, monitoring.band.changed, host.discovered, intelligence.event, scan.completed, remediation.completed (v1.2.0). Adding another requires bumping this spec''s version and updating the test'
description: 'ALL_TOPICS MUST be a closed const-tuple containing exactly the seven kinds: host.changed, monitoring.band.changed, host.discovered, intelligence.event, scan.completed, remediation.completed (v1.2.0), report.ready (v1.3.0). Adding another requires bumping this spec''s version and updating the test'
type: technical
enforcement: error
- id: C-02
Expand All @@ -93,10 +93,14 @@ spec:
description: 'scan.completed handler MUST invalidate [hosts] AND when the envelope carries a host id also [host, id] — a completed scan changes compliance_summary on both the list and the detail hero card. This is the no-polling refresh path for the Run scan flow'
type: technical
enforcement: error
- id: C-08
description: 'v1.3.0 — the report.ready handler MUST bump the notification store (useNotificationStore.getState().bumpReportReady()) so the bell badge increments, AND invalidate ["reports"] so the Reports library refreshes. A report is fleet-scoped, so the handler does NOT read a host id or invalidate any ["host", id] key. Spec frontend-notifications owns the bell that reads the store'
type: technical
enforcement: error

acceptance_criteria:
- id: AC-01
description: 'ALL_TOPICS as exported from useLiveEvents.ts equals exactly the closed set ["host.changed", "monitoring.band.changed", "host.discovered", "intelligence.event", "scan.completed", "remediation.completed"]. Verified by importing the const and asserting the array contents + length'
description: 'ALL_TOPICS as exported from useLiveEvents.ts equals exactly the closed set ["host.changed", "monitoring.band.changed", "host.discovered", "intelligence.event", "scan.completed", "remediation.completed", "report.ready"]. Verified by importing the const and asserting the array contents + length'
priority: critical
references_constraints: [C-01]
- id: AC-02
Expand Down Expand Up @@ -131,3 +135,7 @@ spec:
description: 'Firing remediation.completed with HostID=H invalidates ["host", H, "remediations"] AND ["host", H] — the Remediation tab and the compliance score refresh without a reload when a queued fix or rollback finishes (a committed fix flips a rule to pass)'
priority: high
references_constraints: [C-07]
- id: AC-10
description: 'v1.3.0 — Firing report.ready invalidates ["reports"] and increments the notification store (useNotificationStore.unreadReports goes up by one per event). Spies observe ZERO ["host", ...] invalidation (a report is fleet-scoped, not per-host)'
priority: high
references_constraints: [C-08]
Loading
Loading