diff --git a/src/web/frontend/src/App.tsx b/src/web/frontend/src/App.tsx index 87475fa..c1aac9f 100644 --- a/src/web/frontend/src/App.tsx +++ b/src/web/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback, useRef } from 'react'; import { LogEvent, WorkerInfo, WebSocketMessage, CollisionAlert as CollisionAlertData, RecoverySuggestion } from './types'; import { ThemeProvider, useTheme } from './ThemeContext'; import WorkerGrid from './components/WorkerGrid'; @@ -12,6 +12,183 @@ import FileContextPanel from './components/FileContextPanel'; const FOCUS_MODE_STORAGE_KEY = 'fabric-focus-mode'; +// WebSocket reconnection configuration +const RECONNECT_BASE_DELAY = 1000; // 1 second +const RECONNECT_MAX_DELAY = 30000; // 30 seconds +const RECONNECT_MAX_RETRIES = 10; // Max retries before manual intervention + +// Connection states +type ConnectionState = 'connected' | 'reconnecting' | 'disconnected'; + +interface ReconnectState { + state: ConnectionState; + attemptCount: number; + nextRetryIn: number | null; +} + +/** + * Custom hook for WebSocket with auto-reconnect and exponential backoff + */ +function useWebSocketReconnect( + onMessage: (message: WebSocketMessage) => void +): { + reconnectState: ReconnectState; + connect: () => void; + disconnect: () => void; + resetAndReconnect: () => void; +} { + const wsRef = useRef(null); + const reconnectTimeoutRef = useRef | null>(null); + const countdownIntervalRef = useRef | null>(null); + const attemptCountRef = useRef(0); + + const [reconnectState, setReconnectState] = useState({ + state: 'disconnected', + attemptCount: 0, + nextRetryIn: null, + }); + + const getReconnectDelay = useCallback((attempt: number): number => { + // Exponential backoff: 1s, 2s, 4s, 8s, 16s, 30s (max) + const delay = Math.min(RECONNECT_BASE_DELAY * Math.pow(2, attempt), RECONNECT_MAX_DELAY); + return delay; + }, []); + + const clearTimers = useCallback(() => { + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } + if (countdownIntervalRef.current) { + clearInterval(countdownIntervalRef.current); + countdownIntervalRef.current = null; + } + }, []); + + const scheduleReconnect = useCallback(() => { + clearTimers(); + + if (attemptCountRef.current >= RECONNECT_MAX_RETRIES) { + // Max retries reached - require manual intervention + setReconnectState({ + state: 'disconnected', + attemptCount: attemptCountRef.current, + nextRetryIn: null, + }); + return; + } + + const delay = getReconnectDelay(attemptCountRef.current); + const targetTime = Date.now() + delay; + + setReconnectState(prev => ({ + ...prev, + state: 'reconnecting', + attemptCount: attemptCountRef.current, + nextRetryIn: Math.ceil(delay / 1000), + })); + + // Countdown interval + countdownIntervalRef.current = setInterval(() => { + const remaining = Math.max(0, Math.ceil((targetTime - Date.now()) / 1000)); + setReconnectState(prev => ({ + ...prev, + nextRetryIn: remaining, + })); + }, 1000); + + // Schedule reconnect + reconnectTimeoutRef.current = setTimeout(() => { + attemptCountRef.current++; + connectInternal(); + }, delay); + }, [getReconnectDelay, clearTimers]); + + const connectInternal = useCallback(() => { + clearTimers(); + + // Close existing connection if any + if (wsRef.current) { + wsRef.current.close(); + wsRef.current = null; + } + + const wsUrl = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws`; + const ws = new WebSocket(wsUrl); + wsRef.current = ws; + + ws.onopen = () => { + attemptCountRef.current = 0; + setReconnectState({ + state: 'connected', + attemptCount: 0, + nextRetryIn: null, + }); + console.log('WebSocket connected'); + }; + + ws.onclose = (event) => { + console.log('WebSocket disconnected', event.code, event.reason); + // Only attempt reconnect if not manually closed (1000 = normal closure) + if (event.code !== 1000) { + scheduleReconnect(); + } else { + setReconnectState({ + state: 'disconnected', + attemptCount: attemptCountRef.current, + nextRetryIn: null, + }); + } + }; + + ws.onerror = (err) => { + console.error('WebSocket error:', err); + }; + + ws.onmessage = (event) => { + try { + const message = JSON.parse(event.data) as WebSocketMessage; + onMessage(message); + } catch (err) { + console.error('Failed to parse message:', err); + } + }; + }, [onMessage, clearTimers, scheduleReconnect]); + + const connect = useCallback(() => { + connectInternal(); + }, [connectInternal]); + + const disconnect = useCallback(() => { + clearTimers(); + if (wsRef.current) { + wsRef.current.close(1000, 'Manual disconnect'); + wsRef.current = null; + } + setReconnectState({ + state: 'disconnected', + attemptCount: 0, + nextRetryIn: null, + }); + }, [clearTimers]); + + const resetAndReconnect = useCallback(() => { + clearTimers(); + attemptCountRef.current = 0; + connectInternal(); + }, [clearTimers, connectInternal]); + + // Auto-connect on mount + useEffect(() => { + connectInternal(); + return () => { + disconnect(); + }; + }, [connectInternal, disconnect]); + + return { reconnectState, connect, disconnect, resetAndReconnect }; +} + interface FocusModeState { enabled: boolean; pinnedWorkers: string[]; @@ -40,7 +217,6 @@ const App: React.FC = () => { const [workers, setWorkers] = useState([]); const [events, setEvents] = useState([]); const [selectedWorker, setSelectedWorker] = useState(null); - const [connected, setConnected] = useState(false); const [collisionAlerts, setCollisionAlerts] = useState([]); const [showCollisionPanel, setShowCollisionPanel] = useState(false); const [showFileHeatmap, setShowFileHeatmap] = useState(false); @@ -124,36 +300,8 @@ const App: React.FC = () => { } }, []); - useEffect(() => { - const ws = new WebSocket(`${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws`); - - ws.onopen = () => { - setConnected(true); - console.log('WebSocket connected'); - }; - - ws.onclose = () => { - setConnected(false); - console.log('WebSocket disconnected'); - }; - - ws.onerror = (err) => { - console.error('WebSocket error:', err); - }; - - ws.onmessage = (event) => { - try { - const message = JSON.parse(event.data) as WebSocketMessage; - handleWebSocketMessage(message); - } catch (err) { - console.error('Failed to parse message:', err); - } - }; - - return () => { - ws.close(); - }; - }, [handleWebSocketMessage]); + // Use the auto-reconnect hook + const { reconnectState, resetAndReconnect } = useWebSocketReconnect(handleWebSocketMessage); const filteredEvents = selectedWorker ? filteredEventsByFocusMode.filter(e => e.worker === selectedWorker) @@ -280,9 +428,30 @@ const App: React.FC = () => { {unacknowledgedAlertCount} )} -
- - {connected ? 'Connected' : 'Disconnected'} +
+ + {reconnectState.state === 'connected' && 'Connected'} + {reconnectState.state === 'reconnecting' && ( + + Reconnecting... + {reconnectState.nextRetryIn !== null && ( + ({reconnectState.nextRetryIn}s) + )} + [{reconnectState.attemptCount + 1}] + + )} + {reconnectState.state === 'disconnected' && ( + <> + Disconnected + + + )}
diff --git a/src/web/frontend/src/index.css b/src/web/frontend/src/index.css index dec8b49..005755e 100644 --- a/src/web/frontend/src/index.css +++ b/src/web/frontend/src/index.css @@ -164,15 +164,84 @@ body { color: var(--text-secondary); } +.connection-status.connected { + color: var(--success); +} + +.connection-status.reconnecting { + color: var(--warning); +} + +.connection-status.disconnected { + color: var(--error); +} + .status-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--error); + transition: background 0.3s ease; } .status-dot.connected { background: var(--success); + box-shadow: 0 0 6px var(--success); +} + +.status-dot.reconnecting { + background: var(--warning); + box-shadow: 0 0 6px var(--warning); + animation: pulse 1.5s ease-in-out infinite; +} + +.status-dot.disconnected { + background: var(--error); +} + +@keyframes pulse { + 0%, 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.6; + transform: scale(1.2); + } +} + +.reconnecting-text { + display: flex; + align-items: center; + gap: 0.25rem; +} + +.retry-countdown { + font-size: 0.75rem; + opacity: 0.8; +} + +.attempt-count { + font-size: 0.75rem; + opacity: 0.7; + margin-left: 0.25rem; +} + +.reconnect-button { + margin-left: 0.5rem; + padding: 0.25rem 0.5rem; + font-size: 0.75rem; + background: var(--accent); + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s ease; +} + +.reconnect-button:hover { + background: var(--accent-dim); + transform: scale(1.05); } /* Theme toggle button */