import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode, } from 'react'; import { api } from '../api/client'; import type { Tone } from './format'; import type { IconName } from '../components/Icon'; /* The administrative feed, shared by the bell in the top bar and the activity page. * * One provider rather than a hook per consumer, because there must be exactly one live * connection: the stream is a held-open HTTP request, and a bell and a page each opening * their own would double it — and would let the badge and the list disagree about what * has arrived, which is the one thing a notification count must never do. */ export interface AdminEvent { id: number; occurredAt: string; type: string; severity: 'info' | 'warning' | 'error'; title: string; summary: string; actor?: string; target?: string; link?: string; metadata?: Record; readAt?: string; } export interface EventTypeCount { type: string; count: number; } interface NotificationState { events: AdminEvent[]; unread: number; types: EventTypeCount[]; /** connected is whether the live stream is attached. It is shown, because "nothing has * happened" and "we stopped being told" look identical otherwise. */ connected: boolean; error: string; markRead: (ids: number[]) => Promise; markAllRead: () => Promise; reload: () => Promise; } const NotificationContext = createContext(null); /* How many events the bell holds. The activity page fetches its own window with filters; this is the dropdown's list and the source of the badge. */ const FEED_LIMIT = 50; /* If the stream cannot be established, fall back to polling. An SSE connection can be defeated by an intermediary the console knows nothing about, and a bell that silently stops working is worse than one that is a little late. */ const FALLBACK_POLL_MS = 30_000; interface FeedResponse { events: AdminEvent[]; total: number; unread: number; types: EventTypeCount[]; subscribers: number; } export function NotificationProvider({ children }: { children: ReactNode }) { const [events, setEvents] = useState([]); const [unread, setUnread] = useState(0); const [types, setTypes] = useState([]); const [connected, setConnected] = useState(false); const [error, setError] = useState(''); const latestId = useRef(0); const reload = useCallback(async () => { try { const feed = await api.get(`/admin/api/notifications?limit=${FEED_LIMIT}`); setEvents(feed.events); setUnread(feed.unread); setTypes(feed.types); latestId.current = Math.max(latestId.current, feed.events[0]?.id ?? 0); setError(''); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } }, []); /* Merge is where the two sources meet. An event can arrive twice — once in the stream's opening replay and once live — so it is keyed by id rather than appended, and the list is re-sorted rather than assumed ordered: the replay is oldest-first and the live feed is not. */ const merge = useCallback((incoming: AdminEvent) => { latestId.current = Math.max(latestId.current, incoming.id); setEvents((current) => { if (current.some((event) => event.id === incoming.id)) return current; return [incoming, ...current].sort((a, b) => b.id - a.id).slice(0, FEED_LIMIT); }); if (!incoming.readAt) setUnread((count) => count + 1); // A type that has never been seen before should appear in the filter immediately // rather than after the next full fetch. setTypes((current) => current.some((entry) => entry.type === incoming.type) ? current.map((entry) => entry.type === incoming.type ? { ...entry, count: entry.count + 1 } : entry, ) : [...current, { type: incoming.type, count: 1 }], ); }, []); useEffect(() => { void reload(); }, [reload]); useEffect(() => { let source: EventSource | null = null; let fallback: number | undefined; let closed = false; const openStream = () => { if (closed) return; // `after` is what makes a reconnection lossless: the gateway replays everything // published while the connection was down, so an event dropped by a slow reader or // by a sleeping laptop is caught up rather than lost. source = new EventSource(`/admin/api/notifications/stream?after=${latestId.current}`); source.addEventListener('open', () => { setConnected(true); window.clearInterval(fallback); fallback = undefined; }); source.addEventListener('admin', (event) => { try { merge(JSON.parse((event as MessageEvent).data) as AdminEvent); } catch { // A malformed frame is not worth breaking the feed over. } }); source.addEventListener('error', () => { setConnected(false); // EventSource reconnects by itself, but only while the endpoint is answering at // all. The poll covers the case where it never will. if (fallback === undefined) { fallback = window.setInterval(() => void reload(), FALLBACK_POLL_MS); } }); }; openStream(); return () => { closed = true; source?.close(); window.clearInterval(fallback); }; }, [merge, reload]); const markRead = useCallback(async (ids: number[]) => { const unreadIds = ids.filter((id) => id > 0); if (unreadIds.length === 0) return; // Optimistic: the badge is the thing an operator watches, and a count that lags a // click by a round trip reads as the click not having worked. setEvents((current) => current.map((event) => unreadIds.includes(event.id) && !event.readAt ? { ...event, readAt: new Date().toISOString() } : event, ), ); try { const result = await api.post<{ unread: number }>('/admin/api/notifications/read', { ids: unreadIds, }); setUnread(result.unread); } catch { // The optimistic change stands until the next reload corrects it; a failed read // receipt is not worth a banner over somebody else's page. void reload(); } }, [reload]); const markAllRead = useCallback(async () => { setUnread(0); setEvents((current) => current.map((event) => (event.readAt ? event : { ...event, readAt: new Date().toISOString() })), ); try { const result = await api.post<{ unread: number }>('/admin/api/notifications/read', { all: true }); setUnread(result.unread); } catch { void reload(); } }, [reload]); const value = useMemo( () => ({ events, unread, types, connected, error, markRead, markAllRead, reload }), [events, unread, types, connected, error, markRead, markAllRead, reload], ); return {children}; } export function useNotifications(): NotificationState { const value = useContext(NotificationContext); if (!value) throw new Error('useNotifications used outside NotificationProvider'); return value; } /* ---------- presentation ---------- */ /** eventTone is the colour an event wears. * * It is derived from the *severity* the publisher chose rather than from the type, * because the type list is open — a service added tomorrow publishes a type this console * has never heard of, and it must still be coloured correctly. */ export function eventTone(event: Pick): Tone { if (event.severity === 'error') return 'bad'; if (event.severity === 'warning') return 'warn'; return 'info'; } /** eventIcon is the mark beside an event. Unknown types get a bell, which is honest: it * says "something happened" without pretending to categorise it. */ export function eventIcon(type: string): IconName { if (type.startsWith('auth.')) return 'key'; if (type.startsWith('device.')) return 'tv'; if (type.startsWith('admin.')) return 'shield'; if (type.startsWith('task.')) return 'clock'; if (type.startsWith('integration.')) return 'plug'; if (type.startsWith('library.')) return 'library'; if (type.startsWith('emby.')) return 'globe'; if (type.startsWith('server.')) return 'power'; return 'bell'; } /** eventTypeLabel turns a routing key into words. An unrecognised type falls back to the * key itself with its punctuation softened, so it is readable rather than absent — the * same stance the client takes towards a row kind it does not know. */ export function eventTypeLabel(type: string): string { const known: Record = { 'auth.login': 'Signed in', 'auth.login_failed': 'Sign-in refused', 'auth.logout': 'Signed out', 'device.registered': 'New device', 'device.removed': 'Device removed', 'device.renamed': 'Device renamed', 'admin.sign_in': 'Admin sign-in', 'server.started': 'Server started', 'server.maintenance': 'Maintenance', 'task.completed': 'Task finished', 'task.failed': 'Task failed', 'integration.failed': 'Integration failed', 'integration.test': 'Integration test', 'library.sync': 'Library sync', 'emby.unreachable': 'Emby unreachable', 'emby.recovered': 'Emby recovered', }; return known[type] ?? type.replace(/[._]/g, ' '); }