99 lines
3.0 KiB
TypeScript
99 lines
3.0 KiB
TypeScript
/**
|
|
* Orchestrator event stream — opens an SSE connection to
|
|
* `/orchestrator/events/stream` and dispatches typed events to the
|
|
* caller. Mirror of `useCampaignStream`.
|
|
*/
|
|
import { useEffect, useRef } from 'react';
|
|
|
|
export type OrchestratorStreamEventName =
|
|
| 'snapshot'
|
|
| 'traffic'
|
|
| 'file'
|
|
| 'email';
|
|
|
|
export interface OrchestratorStreamEvent {
|
|
name: OrchestratorStreamEventName | string;
|
|
topic?: string;
|
|
type?: string;
|
|
ts?: string;
|
|
payload: Record<string, unknown>;
|
|
}
|
|
|
|
export interface UseOrchestratorStreamOptions {
|
|
enabled: boolean;
|
|
onEvent: (event: OrchestratorStreamEvent) => void;
|
|
onStatus?: (status: 'connecting' | 'live' | 'error') => void;
|
|
}
|
|
|
|
// Must include every leaf the SSE endpoint emits — the EventSource
|
|
// silently drops frames whose `event:` name has no listener registered.
|
|
// New leaves on the bus need a corresponding entry here or the
|
|
// dashboard ignores them despite the SSE pipe carrying them through.
|
|
const NAMED_EVENTS: OrchestratorStreamEventName[] = [
|
|
'snapshot', 'traffic', 'file', 'email',
|
|
];
|
|
|
|
export function useOrchestratorStream({
|
|
enabled,
|
|
onEvent,
|
|
onStatus,
|
|
}: UseOrchestratorStreamOptions): void {
|
|
const esRef = useRef<EventSource | null>(null);
|
|
const reconnectRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const onEventRef = useRef(onEvent);
|
|
const onStatusRef = useRef(onStatus);
|
|
useEffect(() => { onEventRef.current = onEvent; }, [onEvent]);
|
|
useEffect(() => { onStatusRef.current = onStatus; }, [onStatus]);
|
|
|
|
useEffect(() => {
|
|
if (!enabled) return;
|
|
|
|
const connect = () => {
|
|
if (esRef.current) esRef.current.close();
|
|
onStatusRef.current?.('connecting');
|
|
const token = localStorage.getItem('token') ?? '';
|
|
const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000/api/v1';
|
|
const url = `${baseUrl}/orchestrator/events/stream?token=${encodeURIComponent(token)}`;
|
|
|
|
const es = new EventSource(url);
|
|
esRef.current = es;
|
|
|
|
es.onopen = () => onStatusRef.current?.('live');
|
|
|
|
const dispatch = (name: string) => (event: MessageEvent) => {
|
|
try {
|
|
const parsed = JSON.parse(event.data) as Partial<OrchestratorStreamEvent>;
|
|
onEventRef.current({
|
|
name,
|
|
topic: parsed.topic,
|
|
type: parsed.type,
|
|
ts: parsed.ts,
|
|
payload: (parsed.payload ?? {}) as Record<string, unknown>,
|
|
});
|
|
} catch (err) {
|
|
console.error('useOrchestratorStream: parse failed', err);
|
|
}
|
|
};
|
|
|
|
for (const name of NAMED_EVENTS) {
|
|
es.addEventListener(name, dispatch(name) as EventListener);
|
|
}
|
|
|
|
es.onerror = () => {
|
|
es.close();
|
|
esRef.current = null;
|
|
onStatusRef.current?.('error');
|
|
reconnectRef.current = setTimeout(connect, 3000);
|
|
};
|
|
};
|
|
|
|
connect();
|
|
|
|
return () => {
|
|
if (reconnectRef.current) clearTimeout(reconnectRef.current);
|
|
if (esRef.current) esRef.current.close();
|
|
esRef.current = null;
|
|
};
|
|
}, [enabled]);
|
|
}
|