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'}
/>