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:
2026-05-28 23:43:00 +02:00
parent 4a7f24348c
commit f13a63dc2f
16 changed files with 1979 additions and 1368 deletions
+189
View File
@@ -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);
});
});
});