From ad5a4b5000d34ef502ba61b9e3cb9d4f9bddc1f1 Mon Sep 17 00:00:00 2001 From: Developer Date: Sat, 13 Jun 2026 09:30:45 +0000 Subject: [PATCH] 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). --- apps/web/src/hooks/use-events.test.ts | 21 ++++++++++++++++----- apps/web/src/hooks/use-events.ts | 17 ++++++++++++++++- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/apps/web/src/hooks/use-events.test.ts b/apps/web/src/hooks/use-events.test.ts index b41c409..99081ef 100644 --- a/apps/web/src/hooks/use-events.test.ts +++ b/apps/web/src/hooks/use-events.test.ts @@ -14,10 +14,13 @@ const mockedCreateEventSource = vi.mocked(createEventSource); const mockedProbeEventStreamStatus = vi.mocked(probeEventStreamStatus); describe("useEvents", () => { - let mockEs: EventSource; + let mockEs: EventSource & { + emit: (type: string, data: string) => void; + }; beforeEach(() => { vi.useFakeTimers({ shouldAdvanceTime: true }); + const listeners = new Map void>>(); mockEs = { close: vi.fn(), onopen: null, @@ -27,7 +30,17 @@ describe("useEvents", () => { return EventSource.OPEN; }, url: "http://localhost:8000/events/stream", - } as unknown as EventSource; + addEventListener: vi.fn((type: string, handler: (e: MessageEvent) => void) => { + if (!listeners.has(type)) listeners.set(type, new Set()); + listeners.get(type)!.add(handler); + }), + removeEventListener: vi.fn((type: string, handler: (e: MessageEvent) => void) => { + listeners.get(type)?.delete(handler); + }), + emit: (type: string, data: string) => { + listeners.get(type)?.forEach((handler) => handler({ data } as MessageEvent)); + }, + } as unknown as EventSource & { emit: (type: string, data: string) => void }; mockedCreateEventSource.mockReturnValue(mockEs); mockedProbeEventStreamStatus.mockResolvedValue(null); }); @@ -56,9 +69,7 @@ describe("useEvents", () => { }; act(() => { - mockEs.onmessage?.({ - data: JSON.stringify(payload), - } as MessageEvent); + mockEs.emit("instance.started", JSON.stringify(payload)); }); await waitFor(() => { diff --git a/apps/web/src/hooks/use-events.ts b/apps/web/src/hooks/use-events.ts index 35cf6ae..b83171d 100644 --- a/apps/web/src/hooks/use-events.ts +++ b/apps/web/src/hooks/use-events.ts @@ -12,6 +12,16 @@ export interface UseEventsReturn { 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([]); const [connected, setConnected] = useState(false); @@ -39,7 +49,7 @@ export function useEvents(): UseEventsReturn { setReconnectCount(0); }; - es.onmessage = (e) => { + const handleEventMessage = (e: MessageEvent) => { if (!isMountedRef.current) return; try { const payload: InstanceEventPayload = JSON.parse(e.data); @@ -49,6 +59,11 @@ export function useEvents(): UseEventsReturn { } }; + es.addEventListener("message", handleEventMessage); + for (const eventType of LIFECYCLE_EVENT_TYPES) { + es.addEventListener(eventType, handleEventMessage); + } + es.onerror = () => { if (!isMountedRef.current) return; setConnected(false);