feat: container monitoring frontend UI (PR-2)
- Custom ToastContext + ToastProvider + ToastContainer (~170 lines, no deps) - useEvents() SSE hook with exponential backoff reconnect - EventProvider context for app-wide SSE stream sharing - Event-to-toast bridge with severity mapping and deduplication - Real-time status badge updates replacing 30s polling - EventSource auth probe (401/429 detection via fetch) - 14 frontend tests (useEvents + toast-rules) Quality gates: vitest 14 passed, tsc clean, eslint clean
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, waitFor, act } from "@testing-library/react";
|
||||
import { useEvents } from "./use-events";
|
||||
|
||||
// Mock the API client
|
||||
vi.mock("../api/events", () => ({
|
||||
createEventSource: vi.fn(),
|
||||
probeEventStreamStatus: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
import { createEventSource, probeEventStreamStatus } from "../api/events";
|
||||
|
||||
const mockedCreateEventSource = vi.mocked(createEventSource);
|
||||
const mockedProbeEventStreamStatus = vi.mocked(probeEventStreamStatus);
|
||||
|
||||
describe("useEvents", () => {
|
||||
let mockEs: EventSource;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
mockEs = {
|
||||
close: vi.fn(),
|
||||
onopen: null,
|
||||
onmessage: null,
|
||||
onerror: null,
|
||||
get readyState() {
|
||||
return EventSource.OPEN;
|
||||
},
|
||||
url: "http://localhost:8000/events/stream",
|
||||
} as unknown as EventSource;
|
||||
mockedCreateEventSource.mockReturnValue(mockEs);
|
||||
mockedProbeEventStreamStatus.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("parses sse event and adds to state", async () => {
|
||||
const { result } = renderHook(() => useEvents());
|
||||
|
||||
// Simulate connection open
|
||||
act(() => {
|
||||
mockEs.onopen?.({} as Event);
|
||||
});
|
||||
|
||||
const payload = {
|
||||
event: "instance.started",
|
||||
instance_id: "abc-123",
|
||||
status: "starting",
|
||||
message: "Container starting...",
|
||||
metadata: {},
|
||||
timestamp: "2026-05-28T12:00:00Z",
|
||||
correlation_id: "corr-1",
|
||||
};
|
||||
|
||||
act(() => {
|
||||
mockEs.onmessage?.({
|
||||
data: JSON.stringify(payload),
|
||||
} as MessageEvent);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.events).toHaveLength(1);
|
||||
expect(result.current.events[0].instance_id).toBe("abc-123");
|
||||
});
|
||||
expect(result.current.connected).toBe(true);
|
||||
});
|
||||
|
||||
it("reconnects with exponential backoff on error", async () => {
|
||||
renderHook(() => useEvents());
|
||||
|
||||
act(() => {
|
||||
mockEs.onerror?.({} as Event);
|
||||
});
|
||||
|
||||
expect(mockEs.close).toHaveBeenCalled();
|
||||
expect(mockedCreateEventSource).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Advance past first backoff (should be ~1000ms)
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1200);
|
||||
});
|
||||
|
||||
expect(mockedCreateEventSource).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Trigger another error
|
||||
const secondEs = mockedCreateEventSource.mock.results[1]
|
||||
.value as EventSource;
|
||||
act(() => {
|
||||
secondEs.onerror?.({} as Event);
|
||||
});
|
||||
|
||||
// Advance past second backoff (should be ~2000ms)
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(2500);
|
||||
});
|
||||
|
||||
expect(mockedCreateEventSource).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("caps reconnect delay at 30 seconds", async () => {
|
||||
renderHook(() => useEvents());
|
||||
|
||||
// Trigger 6 errors to get past 1s, 2s, 4s, 8s, 16s
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const currentEs =
|
||||
i === 0
|
||||
? mockEs
|
||||
: (mockedCreateEventSource.mock.results[i]?.value as EventSource);
|
||||
act(() => {
|
||||
currentEs.onerror?.({} as Event);
|
||||
});
|
||||
|
||||
// Advance enough to trigger next reconnect
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(35000);
|
||||
});
|
||||
}
|
||||
|
||||
expect(mockedCreateEventSource.mock.calls.length).toBeGreaterThan(5);
|
||||
});
|
||||
|
||||
it("stops reconnecting and redirects on 401", async () => {
|
||||
mockedProbeEventStreamStatus.mockResolvedValue(401);
|
||||
const originalLocation = window.location;
|
||||
// @ts-expect-error - overriding readonly location for test
|
||||
delete window.location;
|
||||
// @ts-expect-error - mock location
|
||||
window.location = { ...originalLocation, assign: vi.fn() };
|
||||
|
||||
renderHook(() => useEvents());
|
||||
|
||||
act(() => {
|
||||
mockEs.onerror?.({} as Event);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1500);
|
||||
});
|
||||
|
||||
expect(window.location.assign).toHaveBeenCalled();
|
||||
// @ts-expect-error - restoring location
|
||||
window.location = originalLocation;
|
||||
});
|
||||
|
||||
it("adds 5s penalty on 429", async () => {
|
||||
mockedProbeEventStreamStatus.mockResolvedValue(429);
|
||||
|
||||
renderHook(() => useEvents());
|
||||
|
||||
act(() => {
|
||||
mockEs.onerror?.({} as Event);
|
||||
});
|
||||
|
||||
// First timeout fires (~1s), detects 429, schedules penalty timeout (~7s later)
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(2000);
|
||||
});
|
||||
|
||||
expect(mockedCreateEventSource).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Advance past penalty delay (need enough for delay + 5000)
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(10000);
|
||||
});
|
||||
|
||||
expect(mockedCreateEventSource).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("cleans up EventSource on unmount", () => {
|
||||
const { unmount } = renderHook(() => useEvents());
|
||||
unmount();
|
||||
expect(mockEs.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("exposes reconnectCount", async () => {
|
||||
const { result } = renderHook(() => useEvents());
|
||||
|
||||
act(() => {
|
||||
mockEs.onerror?.({} as Event);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.reconnectCount).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
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;
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
es.onmessage = (e) => {
|
||||
if (!isMountedRef.current) return;
|
||||
try {
|
||||
const payload: InstanceEventPayload = JSON.parse(e.data);
|
||||
setEvents((prev) => [...prev, payload]);
|
||||
} catch {
|
||||
// ignore malformed events
|
||||
}
|
||||
};
|
||||
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user