263 lines
9.3 KiB
TypeScript
263 lines
9.3 KiB
TypeScript
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<string, unknown>;
|
||
|
|
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<void>;
|
||
|
|
markAllRead: () => Promise<void>;
|
||
|
|
reload: () => Promise<void>;
|
||
|
|
}
|
||
|
|
|
||
|
|
const NotificationContext = createContext<NotificationState | null>(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<AdminEvent[]>([]);
|
||
|
|
const [unread, setUnread] = useState(0);
|
||
|
|
const [types, setTypes] = useState<EventTypeCount[]>([]);
|
||
|
|
const [connected, setConnected] = useState(false);
|
||
|
|
const [error, setError] = useState('');
|
||
|
|
const latestId = useRef(0);
|
||
|
|
|
||
|
|
const reload = useCallback(async () => {
|
||
|
|
try {
|
||
|
|
const feed = await api.get<FeedResponse>(`/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<string>).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<NotificationState>(
|
||
|
|
() => ({ events, unread, types, connected, error, markRead, markAllRead, reload }),
|
||
|
|
[events, unread, types, connected, error, markRead, markAllRead, reload],
|
||
|
|
);
|
||
|
|
|
||
|
|
return <NotificationContext.Provider value={value}>{children}</NotificationContext.Provider>;
|
||
|
|
}
|
||
|
|
|
||
|
|
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<AdminEvent, 'severity'>): 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<string, string> = {
|
||
|
|
'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, ' ');
|
||
|
|
}
|