diff --git a/api/internal/websocket/hub.go b/api/internal/websocket/hub.go index af9e8e1b..03d4565a 100644 --- a/api/internal/websocket/hub.go +++ b/api/internal/websocket/hub.go @@ -42,6 +42,7 @@ package websocket import ( "log" "sync" + "time" "github.com/gorilla/websocket" ) @@ -199,33 +200,46 @@ func (h *Hub) ClientCount() int { // writePump pumps messages from the hub to the websocket connection func (c *Client) writePump() { + ticker := time.NewTicker(30 * time.Second) // Send ping every 30 seconds defer func() { + ticker.Stop() c.conn.Close() }() for { - message, ok := <-c.send - if !ok { - // Hub closed the channel - c.conn.WriteMessage(websocket.CloseMessage, []byte{}) - return - } + select { + case message, ok := <-c.send: + // Set write deadline to prevent hanging on slow connections + c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) + if !ok { + // Hub closed the channel + c.conn.WriteMessage(websocket.CloseMessage, []byte{}) + return + } - w, err := c.conn.NextWriter(websocket.TextMessage) - if err != nil { - return - } - w.Write(message) + w, err := c.conn.NextWriter(websocket.TextMessage) + if err != nil { + return + } + w.Write(message) - // Add queued messages to the current websocket message - n := len(c.send) - for i := 0; i < n; i++ { - w.Write([]byte{'\n'}) - w.Write(<-c.send) - } + // Add queued messages to the current websocket message + n := len(c.send) + for i := 0; i < n; i++ { + w.Write([]byte{'\n'}) + w.Write(<-c.send) + } + + if err := w.Close(); err != nil { + return + } - if err := w.Close(); err != nil { - return + case <-ticker.C: + // Send ping to keep connection alive + c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) + if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil { + return + } } } } @@ -237,6 +251,13 @@ func (c *Client) readPump() { c.conn.Close() }() + // Set read deadline and pong handler to keep connection alive + c.conn.SetReadDeadline(time.Now().Add(60 * time.Second)) + c.conn.SetPongHandler(func(string) error { + c.conn.SetReadDeadline(time.Now().Add(60 * time.Second)) + return nil + }) + for { _, message, err := c.conn.ReadMessage() if err != nil { @@ -246,6 +267,9 @@ func (c *Client) readPump() { break } + // Reset read deadline on any message + c.conn.SetReadDeadline(time.Now().Add(60 * time.Second)) + // For now, we just log received messages // In the future, we could handle client->server messages log.Printf("Received message from client %s: %s", c.id, message) diff --git a/ui/src/components/EnhancedWebSocketStatus.tsx b/ui/src/components/EnhancedWebSocketStatus.tsx index 84d1243a..af1a8227 100644 --- a/ui/src/components/EnhancedWebSocketStatus.tsx +++ b/ui/src/components/EnhancedWebSocketStatus.tsx @@ -9,7 +9,7 @@ * * @component */ -import { useState, useEffect, memo } from 'react'; +import { useState, useEffect, useMemo, memo } from 'react'; import { Box, Chip, @@ -60,24 +60,14 @@ function EnhancedWebSocketStatus({ return 60; // 60 seconds for all subsequent retries }; - // Countdown timer for reconnection + // Countdown timer for reconnection - DISABLED to prevent sidebar flickering + // The countdown state update every second was causing the entire component tree to re-render, + // which made the sidebar flicker. Instead, we show a static "Reconnecting..." message. useEffect(() => { if (reconnectAttempts > 0 && !isConnected) { + // Calculate next retry time but don't update state every second const delay = getReconnectDelay(reconnectAttempts - 1); - let remainingTime = delay; - setCountdown(remainingTime); - - const interval = setInterval(() => { - remainingTime -= 1; - if (remainingTime <= 0) { - clearInterval(interval); - setCountdown(null); - } else { - setCountdown(remainingTime); - } - }, 1000); - - return () => clearInterval(interval); + setCountdown(delay); // Set once, don't update every second } else { setCountdown(null); } @@ -113,9 +103,8 @@ function EnhancedWebSocketStatus({ return latency ? `Live • ${latency}ms` : 'Live Updates'; } if (reconnectAttempts > 0) { - return countdown !== null - ? `Reconnecting in ${countdown}s...` - : `Reconnecting... (${reconnectAttempts}/${maxReconnectAttempts})`; + // Show attempt count only, not countdown (to prevent re-renders) + return `Reconnecting... (${reconnectAttempts}/${maxReconnectAttempts})`; } if (reconnectAttempts >= maxReconnectAttempts) { return 'Connection Failed'; @@ -130,12 +119,14 @@ function EnhancedWebSocketStatus({ return 'default' as const; }; - const getStatusIcon = () => { + // Memoize the status icon to prevent CircularProgress from restarting on every render + // Only recreate when connection status actually changes, not when latency updates + const statusIcon = useMemo(() => { if (isConnected) return ; if (reconnectAttempts >= maxReconnectAttempts) return ; if (reconnectAttempts > 0) return ; return ; - }; + }, [isConnected, reconnectAttempts, maxReconnectAttempts]); const quality = getConnectionQuality(latency); const open = Boolean(anchorEl); @@ -143,7 +134,7 @@ function EnhancedWebSocketStatus({ return ( <> - {getStatusIcon()} + {statusIcon} {isConnected ? 'Connected' : reconnectAttempts > 0 ? 'Reconnecting' : 'Disconnected'} @@ -197,13 +188,12 @@ function EnhancedWebSocketStatus({ {countdown !== null && ( - {countdown}s + Next retry in ~{countdown}s )} = maxReconnectAttempts ? 'error' : 'primary'} />