0.1.38 gateway

This commit is contained in:
ponzischeme89
2026-08-14 09:40:03 +12:00
parent abc392d30b
commit 5e2ed3d12e
2847 changed files with 1072928 additions and 3783 deletions
+262
View File
@@ -0,0 +1,262 @@
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, ' ');
}