diff --git a/ui/src/components/EnhancedWebSocketStatus.tsx b/ui/src/components/EnhancedWebSocketStatus.tsx index 27ddc4b9..ea557e65 100644 --- a/ui/src/components/EnhancedWebSocketStatus.tsx +++ b/ui/src/components/EnhancedWebSocketStatus.tsx @@ -51,9 +51,13 @@ export default function EnhancedWebSocketStatus({ const [anchorEl, setAnchorEl] = useState(null); const [countdown, setCountdown] = useState(null); - // Calculate reconnection delay (exponential backoff: 2^attempt seconds, max 30s) + // Calculate reconnection delay - matches the pattern in WebSocket hooks + // 30s, 15s, 15s, then 60s for all subsequent attempts const getReconnectDelay = (attempt: number) => { - return Math.min(Math.pow(2, attempt), 30); + if (attempt === 0) return 30; // 30 seconds for first retry + if (attempt === 1) return 15; // 15 seconds for second retry + if (attempt === 2) return 15; // 15 seconds for third retry + return 60; // 60 seconds for all subsequent retries }; // Countdown timer for reconnection diff --git a/ui/src/components/WebSocketErrorBoundary.tsx b/ui/src/components/WebSocketErrorBoundary.tsx index 1d76b0dc..4a682dbf 100644 --- a/ui/src/components/WebSocketErrorBoundary.tsx +++ b/ui/src/components/WebSocketErrorBoundary.tsx @@ -34,14 +34,18 @@ interface State { dismissed: boolean; // Track if user has dismissed the error } +const WS_ERROR_DISMISSED_KEY = 'streamspace_ws_error_dismissed'; + export default class WebSocketErrorBoundary extends Component { constructor(props: Props) { super(props); + // Check if error was previously dismissed (persists across page navigation) + const wasDismissed = localStorage.getItem(WS_ERROR_DISMISSED_KEY) === 'true'; this.state = { hasError: false, error: null, errorInfo: null, - dismissed: false, + dismissed: wasDismissed, }; } @@ -67,6 +71,8 @@ export default class WebSocketErrorBoundary extends Component { } handleReset = () => { + // Store dismissed state in localStorage so it persists across page navigation + localStorage.setItem(WS_ERROR_DISMISSED_KEY, 'true'); this.setState({ hasError: false, error: null, diff --git a/ui/src/hooks/useEnterpriseWebSocket.ts b/ui/src/hooks/useEnterpriseWebSocket.ts index 3b89f94e..3a4785ee 100644 --- a/ui/src/hooks/useEnterpriseWebSocket.ts +++ b/ui/src/hooks/useEnterpriseWebSocket.ts @@ -40,10 +40,18 @@ export function useEnterpriseWebSocket( onClose, onOpen, autoReconnect = true, - reconnectInterval = 3000, + reconnectInterval = 3000, // Not used with custom backoff maxReconnectAttempts = 10, } = options; + // Custom backoff pattern: 30s, 15s, 15s, then 60s for all subsequent attempts + const getReconnectDelay = (attemptNumber: number): number => { + if (attemptNumber === 0) return 30000; // 30 seconds for first retry + if (attemptNumber === 1) return 15000; // 15 seconds for second retry + if (attemptNumber === 2) return 15000; // 15 seconds for third retry + return 60000; // 60 seconds for all subsequent retries + }; + const [isConnected, setIsConnected] = useState(false); const [lastMessage, setLastMessage] = useState(null); const [reconnectAttempts, setReconnectAttempts] = useState(0); @@ -153,15 +161,16 @@ export function useEnterpriseWebSocket( autoReconnect && currentAttempts < maxReconnectAttempts ) { - // console.log( - // `[WebSocket] Attempting reconnection in ${reconnectInterval}ms (attempt ${currentAttempts + 1}/${maxReconnectAttempts})` - // ); + const delay = getReconnectDelay(currentAttempts); + console.log( + `[WebSocket] Attempting reconnection in ${delay / 1000}s (attempt ${currentAttempts + 1}/${maxReconnectAttempts})` + ); reconnectTimeoutRef.current = setTimeout(() => { reconnectAttemptsRef.current += 1; setReconnectAttempts((prev) => prev + 1); connect(); - }, reconnectInterval); + }, delay); } else if (currentAttempts >= maxReconnectAttempts) { console.error('[WebSocket] Max reconnection attempts reached'); } @@ -173,9 +182,8 @@ export function useEnterpriseWebSocket( }, [ getWebSocketUrl, autoReconnect, - reconnectInterval, maxReconnectAttempts, - ]); + ]); // Removed reconnectInterval since we use getReconnectDelay const disconnect = useCallback(() => { // console.log('[WebSocket] Disconnecting...'); @@ -265,11 +273,18 @@ export function useWebSocketEvent( autoReconnect: true, }); + // Store handler in ref to avoid re-running effect when handler changes + const handlerRef = useRef(handler); + + useEffect(() => { + handlerRef.current = handler; + }, [handler]); + useEffect(() => { if (enabled && lastMessage && lastMessage.type === eventType) { - handler(lastMessage.data); + handlerRef.current(lastMessage.data); } - }, [lastMessage, eventType, handler, enabled]); + }, [lastMessage, eventType, enabled]); } // Predefined hooks for enterprise events diff --git a/ui/src/hooks/useWebSocket.ts b/ui/src/hooks/useWebSocket.ts index e6f53bf5..b4bad98b 100644 --- a/ui/src/hooks/useWebSocket.ts +++ b/ui/src/hooks/useWebSocket.ts @@ -29,7 +29,7 @@ export function useWebSocket({ onError, onOpen, onClose, - reconnectInterval = 3000, + reconnectInterval = 3000, // Not used with custom backoff maxReconnectAttempts = 10, }: UseWebSocketOptions): UseWebSocketReturn { const [isConnected, setIsConnected] = useState(false); @@ -40,6 +40,14 @@ export function useWebSocket({ const shouldReconnectRef = useRef(true); const reconnectAttemptsRef = useRef(0); + // Custom backoff pattern: 30s, 15s, 15s, then 60s for all subsequent attempts + const getReconnectDelay = (attemptNumber: number): number => { + if (attemptNumber === 0) return 30000; // 30 seconds for first retry + if (attemptNumber === 1) return 15000; // 15 seconds for second retry + if (attemptNumber === 2) return 15000; // 15 seconds for third retry + return 60000; // 60 seconds for all subsequent retries + }; + // Store callbacks in refs to avoid reconnection when they change const onMessageRef = useRef(onMessage); const onErrorRef = useRef(onError); @@ -94,11 +102,11 @@ export function useWebSocket({ setIsConnected(false); onCloseRef.current?.(); - // Attempt reconnection with exponential backoff + // Attempt reconnection with custom backoff pattern const currentAttempts = reconnectAttemptsRef.current; if (shouldReconnectRef.current && currentAttempts < maxReconnectAttempts) { - const delay = Math.min(reconnectInterval * Math.pow(1.5, currentAttempts), 30000); - // console.log(`Reconnecting in ${delay}ms (attempt ${currentAttempts + 1}/${maxReconnectAttempts})`); + const delay = getReconnectDelay(currentAttempts); + console.log(`Reconnecting in ${delay / 1000}s (attempt ${currentAttempts + 1}/${maxReconnectAttempts})`); reconnectTimeoutRef.current = setTimeout(() => { reconnectAttemptsRef.current += 1; @@ -114,7 +122,7 @@ export function useWebSocket({ } catch (error) { console.error('Failed to create WebSocket connection:', error); } - }, [url, reconnectInterval, maxReconnectAttempts]); + }, [url, maxReconnectAttempts]); // Removed reconnectInterval since we use getReconnectDelay const sendMessage = useCallback((message: any) => { if (wsRef.current?.readyState === WebSocket.OPEN) { diff --git a/ui/src/pages/SharedSessions.tsx b/ui/src/pages/SharedSessions.tsx index f428ec6a..90dfcfd3 100644 --- a/ui/src/pages/SharedSessions.tsx +++ b/ui/src/pages/SharedSessions.tsx @@ -114,38 +114,43 @@ export default function SharedSessions() { // Real-time session updates via WebSocket with notifications // Wrap callback in useCallback to prevent reconnection loop const handleSessionsUpdate = useCallback((updatedSessions: any[]) => { - if (!currentUser?.id || sessions.length === 0) return; + if (!currentUser?.id) return; - // Update shared sessions with real-time data and show notifications for changes - const updatedSharedSessions = sessions.map((sharedSession) => { - const updated = updatedSessions.find((s: any) => s.id === sharedSession.id); - if (updated) { - // Check if state changed - const prevState = prevStatesRef.current.get(sharedSession.id); - if (updated.state !== prevState && prevState !== undefined) { - // Show notification for state changes - addNotification({ - message: `${sharedSession.templateName} (${sharedSession.ownerUsername}): ${prevState} → ${updated.state}`, - severity: updated.state === 'running' ? 'success' : updated.state === 'hibernated' ? 'warning' : 'error', - priority: updated.state === 'terminated' ? 'high' : 'medium', - title: 'Shared Session Updated', - }); - } + // Update shared sessions using the callback form of setState to avoid dependency on sessions state + setSessions((currentSessions) => { + if (currentSessions.length === 0) return currentSessions; - // Update state tracking - prevStatesRef.current.set(sharedSession.id, updated.state); + // Update shared sessions with real-time data and show notifications for changes + const updatedSharedSessions = currentSessions.map((sharedSession) => { + const updated = updatedSessions.find((s: any) => s.id === sharedSession.id); + if (updated) { + // Check if state changed + const prevState = prevStatesRef.current.get(sharedSession.id); + if (updated.state !== prevState && prevState !== undefined) { + // Show notification for state changes + addNotification({ + message: `${sharedSession.templateName} (${sharedSession.ownerUsername}): ${prevState} → ${updated.state}`, + severity: updated.state === 'running' ? 'success' : updated.state === 'hibernated' ? 'warning' : 'error', + priority: updated.state === 'terminated' ? 'high' : 'medium', + title: 'Shared Session Updated', + }); + } - return { - ...sharedSession, - state: updated.state, - url: updated.status?.url || sharedSession.url, - }; - } - return sharedSession; - }); + // Update state tracking + prevStatesRef.current.set(sharedSession.id, updated.state); - setSessions(updatedSharedSessions); - }, [currentUser?.id, sessions, addNotification]); + return { + ...sharedSession, + state: updated.state, + url: updated.status?.url || sharedSession.url, + }; + } + return sharedSession; + }); + + return updatedSharedSessions; + }); + }, [currentUser?.id, addNotification]); const baseWebSocket = useSessionsWebSocket(handleSessionsUpdate);