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,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