Files
headquarter/apps/web/src/hooks/use-events.ts
T
Developer ad5a4b5000 fix: listen for named SSE events in progress panel
The backend sends named lifecycle events (event: instance.health_changed),
but useEvents only set es.onmessage, which only receives unnamed message
events. Add explicit addEventListener registrations for all lifecycle
event types so the progress panel receives updates and completes.

Quality gates: npm run typecheck, npm run lint, npm test -- --run
(87 passed).
2026-06-13 09:30:45 +00:00

124 lines
3.1 KiB
TypeScript

import { useEffect, useRef, useState, useCallback } from "react";
import { createEventSource, probeEventStreamStatus } from "../api/events";
import type { InstanceEventPayload } from "../types/events";
export interface UseEventsReturn {
events: InstanceEventPayload[];
connected: boolean;
reconnectCount: number;
error: Error | null;
}
const MAX_DELAY = 30000;
const BASE_DELAY = 1000;
const LIFECYCLE_EVENT_TYPES = [
"instance.created",
"instance.started",
"instance.stopped",
"instance.restarted",
"instance.deleted",
"instance.error",
"instance.health_changed",
] as const;
export function useEvents(): UseEventsReturn {
const [events, setEvents] = useState<InstanceEventPayload[]>([]);
const [connected, setConnected] = useState(false);
const [reconnectCount, setReconnectCount] = useState(0);
const [error, setError] = useState<Error | null>(null);
const reconnectAttemptsRef = useRef(0);
const esRef = useRef<EventSource | null>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isMountedRef = useRef(true);
const connect = useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
const es = createEventSource();
esRef.current = es;
es.onopen = () => {
if (!isMountedRef.current) return;
setConnected(true);
setError(null);
reconnectAttemptsRef.current = 0;
setReconnectCount(0);
};
const handleEventMessage = (e: MessageEvent) => {
if (!isMountedRef.current) return;
try {
const payload: InstanceEventPayload = JSON.parse(e.data);
setEvents((prev) => [...prev, payload]);
} catch {
// ignore malformed events
}
};
es.addEventListener("message", handleEventMessage);
for (const eventType of LIFECYCLE_EVENT_TYPES) {
es.addEventListener(eventType, handleEventMessage);
}
es.onerror = () => {
if (!isMountedRef.current) return;
setConnected(false);
es.close();
esRef.current = null;
const attempts = reconnectAttemptsRef.current;
const delay =
Math.min(MAX_DELAY, BASE_DELAY * Math.pow(2, attempts)) *
(0.8 + Math.random() * 0.4);
reconnectAttemptsRef.current = attempts + 1;
setReconnectCount(reconnectAttemptsRef.current);
timeoutRef.current = setTimeout(async () => {
if (!isMountedRef.current) return;
const status = await probeEventStreamStatus();
if (status === 401) {
const baseUrl =
import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
window.location.assign(`${baseUrl}/auth/login`);
return;
}
if (status === 429) {
const penaltyDelay = delay + 5000;
timeoutRef.current = setTimeout(() => {
if (isMountedRef.current) {
connect();
}
}, penaltyDelay);
return;
}
connect();
}, delay);
};
}, []);
useEffect(() => {
isMountedRef.current = true;
connect();
return () => {
isMountedRef.current = false;
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
if (esRef.current) {
esRef.current.close();
esRef.current = null;
}
};
}, [connect]);
return { events, connected, reconnectCount, error };
}