diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 4f2f2d7..1d8c73d 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -113,30 +113,72 @@ export function App() { }, []); useEffect(() => { - const wsUrl = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/api/v1/control/alerts/ws`; - const ws = new WebSocket(wsUrl); + let socket: WebSocket | null = null; + let disposed = false; + let attempt = 0; + let openedAt = 0; + let wsGeneration = 0; + let reconnectTimer: number | undefined; - ws.onmessage = (evt) => { - try { - const payload = JSON.parse(String(evt.data)) as { - alerts?: Alert[]; - recent_transitions?: AlertTransition[]; - }; - if (payload.alerts) setAlerts(payload.alerts); - if (payload.recent_transitions) setHistory(payload.recent_transitions); - } catch { - // Ignore malformed stream payloads and keep current UI state. - } + const resync = () => { + const generation = wsGeneration; + void Promise.all([fetchAlerts(), fetchAlertHistory(20)]) + .then(([nextAlerts, nextHistory]) => { + // A WS snapshot delivered while this request was in flight is newer. + if (generation !== wsGeneration) return; + setAlerts(nextAlerts); + setHistory(nextHistory); + }) + .catch(() => undefined); }; - ws.onerror = () => { - const id = window.setInterval(() => { - void refreshAlertsAndHistory().catch(() => undefined); - }, 8000); - ws.onclose = () => window.clearInterval(id); + const connect = () => { + if (disposed) return; + const wsUrl = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/api/v1/control/alerts/ws`; + socket = new WebSocket(wsUrl); + + socket.onopen = () => { + openedAt = Date.now(); + // Pull anything missed while the stream was down. + resync(); + }; + + socket.onmessage = (evt) => { + wsGeneration += 1; + try { + const payload = JSON.parse(String(evt.data)) as { + alerts?: Alert[]; + recent_transitions?: AlertTransition[]; + }; + if (payload.alerts) setAlerts(payload.alerts); + if (payload.recent_transitions) + setHistory(payload.recent_transitions); + } catch { + // Ignore malformed stream payloads and keep current UI state. + } + }; + + socket.onclose = () => { + socket = null; + if (disposed) return; + // Only a connection that stayed up proves the stream is healthy; an + // accept-then-close server must not pin us to the first backoff step. + if (openedAt && Date.now() - openedAt > 10_000) attempt = 0; + // Browsers never reconnect a WebSocket on their own. + const delay = + Math.min(30_000, 1000 * 2 ** attempt) + Math.random() * 1000; + attempt += 1; + reconnectTimer = window.setTimeout(connect, delay); + }; }; - return () => ws.close(); + connect(); + + return () => { + disposed = true; + if (reconnectTimer !== undefined) window.clearTimeout(reconnectTimer); + socket?.close(); + }; }, []); return ( diff --git a/ui/src/pages/DevicesPage.tsx b/ui/src/pages/DevicesPage.tsx index 7ae9425..eaf1d8b 100644 --- a/ui/src/pages/DevicesPage.tsx +++ b/ui/src/pages/DevicesPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Inbox, MoreHorizontal, RefreshCw, Search, Zap } from "lucide-react"; import { toast } from "sonner"; @@ -69,8 +69,12 @@ export function DevicesPage({ agents, onAfterWake, onRefresh }: Props) { const [refreshing, setRefreshing] = useState(false); const [wakeBusyKey, setWakeBusyKey] = useState(""); const [details, setDetails] = useState(null); + const loadSeqRef = useRef(0); async function loadFleet() { + // Latest request wins: filter changes and debounced searches overlap, and + // an older response must never overwrite fresher state. + const seq = ++loadSeqRef.current; setLoading(true); try { const [nextDevices, nextKnownDevices] = await Promise.all([ @@ -84,12 +88,14 @@ export function DevicesPage({ agents, onAfterWake, onRefresh }: Props) { }), fetchKnownDevices(), ]); + if (seq !== loadSeqRef.current) return; setDevices(nextDevices); setKnownDevices(nextKnownDevices); } catch (err) { + if (seq !== loadSeqRef.current) return; toast.error("Failed to load fleet", { description: String(err) }); } finally { - setLoading(false); + if (seq === loadSeqRef.current) setLoading(false); } } @@ -152,7 +158,13 @@ export function DevicesPage({ agents, onAfterWake, onRefresh }: Props) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [presence, known, agentId]); + // The filter effect above already loads on mount; only react to query edits. + const mountedRef = useRef(false); useEffect(() => { + if (!mountedRef.current) { + mountedRef.current = true; + return; + } const timer = window.setTimeout(() => void loadFleet(), 220); return () => window.clearTimeout(timer); // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/ui/src/pages/TerminalPage.tsx b/ui/src/pages/TerminalPage.tsx index 0f6e889..29182c2 100644 --- a/ui/src/pages/TerminalPage.tsx +++ b/ui/src/pages/TerminalPage.tsx @@ -105,6 +105,7 @@ export function TerminalPage({ const modifiersRef = useRef(NO_TERMINAL_MODIFIERS); const terminalTouchRef = useRef(null); const terminalInputFocusedBeforeAccessoryRef = useRef(false); + const refreshSessionsRef = useRef<() => void>(() => {}); const [sessions, setSessions] = useState([]); const [session, setSession] = useState(null); const [connection, setConnection] = useState("idle"); @@ -348,6 +349,7 @@ export function TerminalPage({ }; if (control.type === "ready") { setConnection("ready"); + refreshSessionsRef.current(); window.requestAnimationFrame(() => { fitRef.current?.fit(); lastTerminalSizeRef.current = { rows: 0, cols: 0 }; @@ -387,6 +389,8 @@ export function TerminalPage({ if (current === "exited") return current; return "disconnected"; }); + // The agent may have reaped or reported sessions while we were away. + refreshSessionsRef.current(); }; }, [detachTransport, operatorId, sendResize], @@ -572,7 +576,7 @@ export function TerminalPage({ }); resizeObserver.observe(hostRef.current); void restoreTerminalSession(); - const sessionRefresh = window.setInterval(() => { + const refreshSessions = () => { void listTerminals() .then((listed) => { if (!cancelled) { @@ -585,15 +589,20 @@ export function TerminalPage({ // The attached terminal transport remains authoritative while a // background list refresh is temporarily unavailable. }); - }, 5000); + }; + refreshSessionsRef.current = refreshSessions; + // Session lifecycle only changes at connect/disconnect moments or when the + // operator returns to the tab; refresh on those instead of polling. + window.addEventListener("focus", refreshSessions); return () => { cancelled = true; input.dispose(); titleChange.dispose(); resizeObserver.disconnect(); + window.removeEventListener("focus", refreshSessions); + refreshSessionsRef.current = () => {}; window.cancelAnimationFrame(resizeFrame); - window.clearInterval(sessionRefresh); detachTransport(); terminal.dispose(); terminalRef.current = null; diff --git a/ui/src/pages/terminal/TerminalSessionTabs.tsx b/ui/src/pages/terminal/TerminalSessionTabs.tsx index 9a2f8cd..9cc54b7 100644 --- a/ui/src/pages/terminal/TerminalSessionTabs.tsx +++ b/ui/src/pages/terminal/TerminalSessionTabs.tsx @@ -150,10 +150,14 @@ export function TerminalSessionTabs({