Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 43 additions & 19 deletions api/internal/websocket/hub.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ package websocket
import (
"log"
"sync"
"time"

"github.com/gorilla/websocket"
)
Expand Down Expand Up @@ -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
}
}
}
}
Expand All @@ -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 {
Expand All @@ -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)
Expand Down
42 changes: 16 additions & 26 deletions ui/src/components/EnhancedWebSocketStatus.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
*
* @component
*/
import { useState, useEffect, memo } from 'react';
import { useState, useEffect, useMemo, memo } from 'react';
import {
Box,
Chip,
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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';
Expand All @@ -130,20 +119,22 @@ 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 <ConnectedIcon />;
if (reconnectAttempts >= maxReconnectAttempts) return <ErrorIcon />;
if (reconnectAttempts > 0) return <CircularProgress size={16} />;
return <DisconnectedIcon />;
};
}, [isConnected, reconnectAttempts, maxReconnectAttempts]);

const quality = getConnectionQuality(latency);
const open = Boolean(anchorEl);

return (
<>
<Chip
icon={getStatusIcon()}
icon={statusIcon}
label={getStatusLabel()}
size={size}
color={getStatusColor()}
Expand Down Expand Up @@ -177,7 +168,7 @@ function EnhancedWebSocketStatus({

{/* Connection State */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
{getStatusIcon()}
{statusIcon}
<Box sx={{ flex: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{isConnected ? 'Connected' : reconnectAttempts > 0 ? 'Reconnecting' : 'Disconnected'}
Expand All @@ -197,13 +188,12 @@ function EnhancedWebSocketStatus({
</Typography>
{countdown !== null && (
<Typography variant="caption" color="text.secondary">
{countdown}s
Next retry in ~{countdown}s
</Typography>
)}
</Box>
<LinearProgress
variant={countdown !== null ? 'determinate' : 'indeterminate'}
value={countdown !== null ? ((getReconnectDelay(reconnectAttempts - 1) - countdown) / getReconnectDelay(reconnectAttempts - 1)) * 100 : undefined}
variant="indeterminate"
color={reconnectAttempts >= maxReconnectAttempts ? 'error' : 'primary'}
/>
</Box>
Expand Down
Loading