diff --git a/frontend/src/components/AppHeader.vue b/frontend/src/components/AppHeader.vue index 17ba1a6..0271bc2 100644 --- a/frontend/src/components/AppHeader.vue +++ b/frontend/src/components/AppHeader.vue @@ -2,12 +2,40 @@ Advertisement + + + + {{ connectionStatusText }} + + @@ -27,4 +55,69 @@ header { .logo > img { max-height: 50px; } + +.connection-status { + display: flex; + align-items: center; + margin-left: 10px; +} + +.status-indicator { + display: flex; + align-items: center; + padding: 4px 8px; + border-radius: 4px; + font-size: 12px; + font-weight: 500; +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + margin-right: 6px; + animation: pulse 2s infinite; +} + +.status-connected .status-dot { + background-color: #4caf50; +} + +.status-connecting .status-dot { + background-color: #ff9800; +} + +.status-disconnected .status-dot { + background-color: #f44336; +} + +.status-error .status-dot { + background-color: #f44336; +} + +.status-connected { + background-color: rgba(76, 175, 80, 0.1); + color: #2e7d32; +} + +.status-connecting { + background-color: rgba(255, 152, 0, 0.1); + color: #f57c00; +} + +.status-disconnected { + background-color: rgba(244, 67, 54, 0.1); + color: #d32f2f; +} + +.status-error { + background-color: rgba(244, 67, 54, 0.1); + color: #d32f2f; +} + +@keyframes pulse { + 0% { opacity: 1; } + 50% { opacity: 0.5; } + 100% { opacity: 1; } +} diff --git a/frontend/src/dialogues/chat/MultiChatDialog.vue b/frontend/src/dialogues/chat/MultiChatDialog.vue index 4a8e4b7..07f9690 100644 --- a/frontend/src/dialogues/chat/MultiChatDialog.vue +++ b/frontend/src/dialogues/chat/MultiChatDialog.vue @@ -180,7 +180,11 @@ export default { isPicking: false, maxLightness: 92, // exclude extremely light colors (>% L) // Reconnect control - reconnectIntervalMs: 5000, + reconnectIntervalMs: 3000, + reconnectAttempts: 0, + maxReconnectAttempts: 20, + heartbeatInterval: null, + heartbeatIntervalMs: 30000, // 30 seconds connectAttemptTimeout: null, joinFallbackTimer: null, // Faster handshake watchdog separate from reconnect interval @@ -258,6 +262,9 @@ export default { this.opened = false; console.log('[Chat WS] dialog close β closing websocket'); this.disconnectChatSocket(); + // Remove network event listeners + window.removeEventListener('online', this.onOnline); + window.removeEventListener('offline', this.onOffline); }, onOptionsToggle(e) { e?.stopPropagation?.(); @@ -270,11 +277,18 @@ export default { this.showOptions = false; }, onOnline() { + console.log('[Chat WS] Network online detected'); if (this.opened && !this.chatConnected && !this.connectRacing && (!this.chatWs || this.chatWs.readyState !== WebSocket.OPEN)) { console.log('[Chat WS] online β attempting reconnect'); + this.reconnectAttempts = 0; // Reset attempts on network recovery + this.reconnectIntervalMs = 3000; // Reset to base interval this.connectChatSocket(); } }, + onOffline() { + console.log('[Chat WS] Network offline detected'); + this.setStatus('disconnected'); + }, async loadRooms() { try { const data = await fetchPublicRooms(); @@ -315,6 +329,9 @@ export default { // Stelle die WS-Verbindung her, wenn der Dialog geΓΆffnet wird this.opened = true; this.connectChatSocket(); + // Add network event listeners + window.addEventListener('online', this.onOnline); + window.addEventListener('offline', this.onOffline); }, connectChatSocket() { if (this.connectRacing) return; // avoid overlapping races @@ -413,6 +430,9 @@ export default { this.wsUrl = url; this.chatWs = ws; this.transportConnected = true; + this.reconnectAttempts = 0; // Reset reconnect counter on successful connection + this.reconnectIntervalMs = 3000; // Reset to base interval + this.startHeartbeat(); // Start heartbeat to keep connection alive console.log('[Chat WS] open in', dt, 'ms', '| protocol:', ws.protocol || '(none)', '| url:', url); // Close all other candidates this.pendingWs.forEach(r => { @@ -569,7 +589,21 @@ export default { }, scheduleReconnect() { if (this.reconnectTimer) return; - console.log('[Chat WS] scheduleReconnect in', this.reconnectIntervalMs, 'ms', '| opened:', this.opened); + + // Check if we've exceeded max attempts + if (this.reconnectAttempts >= this.maxReconnectAttempts) { + console.error('[Chat WS] Max reconnect attempts reached, waiting longer before retry'); + this.reconnectAttempts = 0; // Reset counter + this.reconnectIntervalMs = 30000; // Wait 30 seconds + } else { + this.reconnectAttempts++; + // Use exponential backoff with jitter + const baseDelay = Math.min(3000 * Math.pow(1.5, this.reconnectAttempts - 1), 15000); + const jitter = Math.random() * 1000; // Add up to 1 second jitter + this.reconnectIntervalMs = baseDelay + jitter; + } + + console.log('[Chat WS] scheduleReconnect in', Math.round(this.reconnectIntervalMs), 'ms', '| opened:', this.opened, '| attempt:', this.reconnectAttempts); this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null; if (this.opened && !this.chatConnected) { @@ -594,6 +628,7 @@ export default { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } + this.stopHeartbeat(); // Stop heartbeat when disconnecting this.cleanupPendingSockets(); if (this.connectOpenTimer) { clearTimeout(this.connectOpenTimer); this.connectOpenTimer = null; } if (this.connectAttemptTimeout) { clearTimeout(this.connectAttemptTimeout); this.connectAttemptTimeout = null; } @@ -613,6 +648,35 @@ export default { this.usersInRoom = []; this.selectedTargetUser = null; }, + startHeartbeat() { + this.stopHeartbeat(); // Clear any existing heartbeat + if (!this.opened || !this.chatConnected) return; + + this.heartbeatInterval = setInterval(() => { + if (!this.chatWs || this.chatWs.readyState !== WebSocket.OPEN) { + console.warn('[Chat WS] Heartbeat failed - connection not open, attempting reconnect'); + this.stopHeartbeat(); + this.scheduleReconnect(); + return; + } + + try { + // Send a ping message to keep connection alive + this.wsSend({ type: 'ping' }); + console.log('[Chat WS] Heartbeat sent'); + } catch (error) { + console.warn('[Chat WS] Heartbeat failed:', error); + this.stopHeartbeat(); + this.scheduleReconnect(); + } + }, this.heartbeatIntervalMs); + }, + stopHeartbeat() { + if (this.heartbeatInterval) { + clearInterval(this.heartbeatInterval); + this.heartbeatInterval = null; + } + }, getSelectedRoomName() { const r = this.rooms.find(x => x.id === this.selectedRoom); return r?.title || r?.name || ''; diff --git a/frontend/src/store/index.js b/frontend/src/store/index.js index b00f410..e902a78 100644 --- a/frontend/src/store/index.js +++ b/frontend/src/store/index.js @@ -8,6 +8,7 @@ import { io } from 'socket.io-client'; const store = createStore({ state: { isLoggedIn: localStorage.getItem('isLoggedIn') === 'true', + connectionStatus: 'disconnected', // 'connected', 'connecting', 'disconnected', 'error' user: JSON.parse(localStorage.getItem('user')) || null, language: navigator.language.startsWith('de') ? 'de' : 'en', menu: JSON.parse(localStorage.getItem('menu')) || [], @@ -45,6 +46,9 @@ const store = createStore({ setSocket(state, socket) { state.socket = socket; }, + setConnectionStatus(state, status) { + state.connectionStatus = status; + }, clearSocket(state) { if (state.socket) { state.socket.disconnect(); @@ -86,6 +90,7 @@ const store = createStore({ if (currentSocket) { currentSocket.disconnect(); } + commit('setConnectionStatus', 'connecting'); const socketIoUrl = import.meta.env.VITE_SOCKET_IO_URL || import.meta.env.VITE_API_BASE_URL; console.log('π Initializing Socket.io connection to:', socketIoUrl); const socket = io(socketIoUrl, { @@ -95,28 +100,40 @@ const store = createStore({ socket.on('connect', () => { console.log('β Socket.io connected successfully'); + retryCount = 0; // Reset retry counter on successful connection + commit('setConnectionStatus', 'connected'); const idForSocket = state.user?.hashedId || state.user?.id; if (idForSocket) socket.emit('setUserId', idForSocket); }); socket.on('disconnect', (reason) => { console.warn('β Socket.io disconnected:', reason); + commit('setConnectionStatus', 'disconnected'); retryConnection(connectSocket); }); socket.on('connect_error', (error) => { console.error('β Socket.io connection error:', error); console.error('β URL attempted:', import.meta.env.VITE_API_BASE_URL); + commit('setConnectionStatus', 'error'); }); commit('setSocket', socket); }; + let retryCount = 0; + const maxRetries = 10; const retryConnection = (reconnectFn) => { + if (retryCount >= maxRetries) { + console.error('β Max retry attempts reached for Socket.io'); + return; + } + retryCount++; + const delay = Math.min(1000 * Math.pow(1.5, retryCount - 1), 30000); // Exponential backoff, max 30s + console.log(`π Retrying Socket.io connection in ${delay}ms (attempt ${retryCount}/${maxRetries})`); setTimeout(() => { - console.log('Retrying Socket.io connection...'); reconnectFn(); - }, 1000); // Retry every second + }, delay); }; connectSocket(); @@ -236,14 +253,20 @@ const store = createStore({ }; let retryCount = 0; - const maxRetries = 5; + const maxRetries = 15; // Increased max retries const retryConnection = (reconnectFn) => { if (retryCount >= maxRetries) { console.error('β Max retry attempts reached for Daemon WebSocket'); + // Reset counter after a longer delay to allow for network recovery + setTimeout(() => { + retryCount = 0; + console.log('π Resetting Daemon WebSocket retry counter - attempting reconnection'); + reconnectFn(); + }, 60000); // Wait 1 minute before resetting return; } retryCount++; - const delay = Math.min(1000 * Math.pow(2, retryCount - 1), 10000); // Exponential backoff, max 10s + const delay = Math.min(1000 * Math.pow(1.5, retryCount - 1), 30000); // Exponential backoff, max 30s console.log(`π Retrying Daemon WebSocket connection in ${delay}ms (attempt ${retryCount}/${maxRetries})`); setTimeout(() => { reconnectFn(); @@ -272,6 +295,7 @@ const store = createStore({ socket: state => state.socket, daemonSocket: state => state.daemonSocket, menuNeedsUpdate: state => state.menuNeedsUpdate, + connectionStatus: state => state.connectionStatus, }, modules: { dialogs,