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
+30
View File
@@ -0,0 +1,30 @@
import React, { createContext, useContext, useMemo } from "react";
import { useEvents } from "../hooks/use-events";
import type { InstanceEventPayload } from "../types/events";
interface EventContextValue {
events: InstanceEventPayload[];
connected: boolean;
reconnectCount: number;
}
const EventContext = createContext<EventContextValue>({
events: [],
connected: false,
reconnectCount: 0,
});
export function EventProvider({ children }: { children: React.ReactNode }) {
const { events, connected, reconnectCount } = useEvents();
const value = useMemo(
() => ({ events, connected, reconnectCount }),
[events, connected, reconnectCount],
);
return (
<EventContext.Provider value={value}>{children}</EventContext.Provider>
);
}
export function useEventContext() {
return useContext(EventContext);
}
+206
View File
@@ -0,0 +1,206 @@
import React, {
createContext,
useContext,
useState,
useCallback,
useRef,
useEffect,
} from "react";
export type ToastSeverity = "info" | "success" | "warning" | "error";
export interface ToastItem {
id: string;
message: string;
severity: ToastSeverity;
duration: number | null;
createdAt: number;
}
interface ToastContextValue {
toasts: ToastItem[];
addToast: (
message: string,
severity: ToastSeverity,
duration?: number | null,
) => void;
removeToast: (id: string) => void;
}
const ToastContext = createContext<ToastContextValue | null>(null);
let globalToastId = 0;
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<ToastItem[]>([]);
const timersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
new Map(),
);
const removeToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
const timer = timersRef.current.get(id);
if (timer) {
clearTimeout(timer);
timersRef.current.delete(id);
}
}, []);
const addToast = useCallback(
(
message: string,
severity: ToastSeverity,
duration: number | null = 3000,
) => {
const id = `toast-${++globalToastId}`;
const toast: ToastItem = {
id,
message,
severity,
duration,
createdAt: Date.now(),
};
setToasts((prev) => [...prev, toast]);
if (duration !== null && duration > 0) {
const timer = setTimeout(() => {
removeToast(id);
}, duration);
timersRef.current.set(id, timer);
}
},
[removeToast],
);
useEffect(() => {
toast.info = (msg, opts) => addToast(msg, "info", opts?.duration ?? 3000);
toast.success = (msg, opts) =>
addToast(msg, "success", opts?.duration ?? 3000);
toast.warning = (msg, opts) =>
addToast(msg, "warning", opts?.duration ?? 5000);
toast.error = (msg, opts) =>
addToast(msg, "error", opts?.duration ?? 10000);
return () => {
toast.info = () => {};
toast.success = () => {};
toast.warning = () => {};
toast.error = () => {};
};
}, [addToast]);
return (
<ToastContext.Provider value={{ toasts, addToast, removeToast }}>
{children}
<ToastContainer toasts={toasts} onDismiss={removeToast} />
</ToastContext.Provider>
);
}
export function useToast() {
const ctx = useContext(ToastContext);
if (!ctx) {
throw new Error("useToast must be used within ToastProvider");
}
return ctx;
}
type ToastFn = (message: string, opts?: { duration?: number }) => void;
export const toast: {
info: ToastFn;
success: ToastFn;
warning: ToastFn;
error: ToastFn;
} = {
info: () => {
/* assigned by ToastProvider */
},
success: () => {
/* assigned by ToastProvider */
},
warning: () => {
/* assigned by ToastProvider */
},
error: () => {
/* assigned by ToastProvider */
},
};
function severityStyles(severity: ToastSeverity): React.CSSProperties {
switch (severity) {
case "success":
return { backgroundColor: "#16a34a", color: "#fff" };
case "warning":
return { backgroundColor: "#d97706", color: "#fff" };
case "error":
return { backgroundColor: "#dc2626", color: "#fff" };
case "info":
default:
return { backgroundColor: "#2563eb", color: "#fff" };
}
}
function ToastContainer({
toasts,
onDismiss,
}: {
toasts: ToastItem[];
onDismiss: (id: string) => void;
}) {
return (
<div
style={{
position: "fixed",
top: 16,
right: 16,
zIndex: 9999,
display: "flex",
flexDirection: "column",
gap: 8,
maxWidth: 360,
width: "100%",
pointerEvents: "none",
}}
>
{toasts.map((t) => (
<div
key={t.id}
style={{
pointerEvents: "auto",
padding: "12px 16px",
borderRadius: 8,
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
animation: "toastSlideIn 0.3s ease-out",
...severityStyles(t.severity),
}}
>
<span style={{ fontSize: 14, fontWeight: 500, lineHeight: 1.4 }}>
{t.message}
</span>
<button
onClick={() => onDismiss(t.id)}
style={{
background: "none",
border: "none",
color: "inherit",
cursor: "pointer",
fontSize: 18,
lineHeight: 1,
padding: 0,
margin: 0,
opacity: 0.8,
}}
aria-label="Dismiss toast"
type="button"
>
×
</button>
</div>
))}
</div>
);
}