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
+108
View File
@@ -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 };
}