ad5a4b5000
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).
124 lines
3.1 KiB
TypeScript
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 };
|
|
}
|