diff --git a/frontend-modern/src/components/Dashboard/Dashboard.tsx b/frontend-modern/src/components/Dashboard/Dashboard.tsx index 02aad88ca..80e6a55b0 100644 --- a/frontend-modern/src/components/Dashboard/Dashboard.tsx +++ b/frontend-modern/src/components/Dashboard/Dashboard.tsx @@ -26,7 +26,7 @@ type GroupingMode = 'grouped' | 'flat'; export function Dashboard(props: DashboardProps) { - const { connected, activeAlerts, initialDataReceived } = useWebSocket(); + const { connected, activeAlerts, initialDataReceived, reconnecting, reconnect } = useWebSocket(); const [search, setSearch] = createSignal(''); const [isSearchLocked, setIsSearchLocked] = createSignal(false); const [selectedNode, setSelectedNode] = createSignal(null); @@ -647,7 +647,17 @@ export function Dashboard(props: DashboardProps) {

Loading dashboard data...

-

Connecting to monitoring service

+

+ {reconnecting() ? 'Reconnecting to monitoring service...' : 'Connecting to monitoring service'} +

+ + + @@ -682,7 +692,17 @@ export function Dashboard(props: DashboardProps) {

Connection Lost

-

Unable to connect to the backend server. Attempting to reconnect...

+

+ {reconnecting() ? 'Attempting to reconnect...' : 'Unable to connect to the backend server'} +

+ + + diff --git a/frontend-modern/src/stores/websocket.ts b/frontend-modern/src/stores/websocket.ts index 716902516..a6cd2b14d 100644 --- a/frontend-modern/src/stores/websocket.ts +++ b/frontend-modern/src/stores/websocket.ts @@ -55,28 +55,90 @@ export function createWebSocketStore(url: string) { let ws: WebSocket | null = null; let reconnectTimeout: number; + let heartbeatInterval: number; let reconnectAttempt = 0; let isReconnecting = false; const maxReconnectDelay = POLLING_INTERVALS.RECONNECT_MAX; const initialReconnectDelay = POLLING_INTERVALS.RECONNECT_BASE; + const heartbeatIntervalMs = 30000; // Send heartbeat every 30 seconds const connect = () => { try { // Close existing connection if any - if (ws && ws.readyState !== WebSocket.CLOSED) { - ws.close(); + if (ws) { + if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { + ws.close(1000, 'Reconnecting'); + } + ws = null; + } + + // Add a small delay before reconnecting to avoid rapid reconnect loops + if (reconnectAttempt > 0) { + const delay = Math.min(100 * reconnectAttempt, 1000); + setTimeout(() => { + ws = new WebSocket(url); + setupWebSocket(); + }, delay); + return; } ws = new WebSocket(url); + setupWebSocket(); + } catch (err) { + logger.error('Failed to create WebSocket', err); + handleReconnect(); + } + }; - ws.onopen = () => { - logger.debug('connect'); - setConnected(true); - setReconnecting(false); // Clear reconnecting state - reconnectAttempt = 0; // Reset reconnect attempts on successful connection - - // Alerts will come with the initial state broadcast - }; + const handleReconnect = () => { + if (isReconnecting) return; + + isReconnecting = true; + setReconnecting(true); + + // Clear any existing timeout + if (reconnectTimeout) { + window.clearTimeout(reconnectTimeout); + reconnectTimeout = 0; + } + + // Calculate exponential backoff delay + const delay = Math.min( + initialReconnectDelay * Math.pow(2, reconnectAttempt), + maxReconnectDelay + ); + + logger.info(`Reconnecting in ${delay}ms (attempt ${reconnectAttempt + 1})`); + reconnectAttempt++; + + reconnectTimeout = window.setTimeout(() => { + isReconnecting = false; + connect(); + }, delay); + }; + + const setupWebSocket = () => { + if (!ws) return; + + ws.onopen = () => { + logger.debug('connect'); + setConnected(true); + setReconnecting(false); // Clear reconnecting state + reconnectAttempt = 0; // Reset reconnect attempts on successful connection + isReconnecting = false; + + // Start heartbeat to keep connection alive + if (heartbeatInterval) { + window.clearInterval(heartbeatInterval); + } + heartbeatInterval = window.setInterval(() => { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'ping', data: { timestamp: Date.now() } })); + } + }, heartbeatIntervalMs); + + // Alerts will come with the initial state broadcast + }; ws.onmessage = (event) => { let data; @@ -303,71 +365,32 @@ export function createWebSocketStore(url: string) { } }; - ws.onclose = (event) => { - logger.debug('disconnect', { code: event.code, reason: event.reason }); - setConnected(false); - setInitialDataReceived(false); - - // Don't reconnect if we're already trying - if (isReconnecting) { - return; - } - - // Clear any existing timeout to prevent multiple reconnections - if (reconnectTimeout) { - window.clearTimeout(reconnectTimeout); - reconnectTimeout = 0; - } - - isReconnecting = true; - setReconnecting(true); - - // Calculate exponential backoff delay - const delay = Math.min( - initialReconnectDelay * Math.pow(2, reconnectAttempt), - maxReconnectDelay - ); - - logger.info(`Reconnecting in ${delay}ms (attempt ${reconnectAttempt + 1})`); - reconnectAttempt++; - - reconnectTimeout = window.setTimeout(() => { - isReconnecting = false; - setReconnecting(false); - connect(); - }, delay); - }; - - ws.onerror = (error) => { - // Don't log connection errors if we're already connected - // Browser may show errors for initial connection attempts even after success - if (!connected()) { - logger.debug('error', error); - } - }; - } catch (err) { - logger.error('Failed to connect', err); + ws.onclose = (event) => { + logger.debug('disconnect', { code: event.code, reason: event.reason }); setConnected(false); + setInitialDataReceived(false); - // Don't reconnect if we're already trying - if (isReconnecting) return; + // Clear heartbeat interval + if (heartbeatInterval) { + window.clearInterval(heartbeatInterval); + heartbeatInterval = 0; + } - isReconnecting = true; - setReconnecting(true); + // Don't try to reconnect if the close was intentional (code 1000) + if (event.code === 1000 && event.reason === 'Reconnecting') { + return; + } - // Use exponential backoff for connection errors too - const delay = Math.min( - initialReconnectDelay * Math.pow(2, reconnectAttempt), - maxReconnectDelay - ); - - reconnectAttempt++; - reconnectTimeout = window.setTimeout(() => { - isReconnecting = false; - setReconnecting(false); - connect(); - }, delay); - } + handleReconnect(); + }; + + ws.onerror = (error) => { + // Don't log connection errors if we're already connected + // Browser may show errors for initial connection attempts even after success + if (!connected()) { + logger.debug('error', error); + } + }; }; // Connect immediately @@ -376,7 +399,10 @@ export function createWebSocketStore(url: string) { // Cleanup on unmount onCleanup(() => { window.clearTimeout(reconnectTimeout); - ws?.close(); + window.clearInterval(heartbeatInterval); + if (ws) { + ws.close(1000, 'Component unmounting'); + } }); return { diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go index 6b082d658..0f9dd8764 100644 --- a/internal/monitoring/monitor.go +++ b/internal/monitoring/monitor.go @@ -1041,7 +1041,7 @@ func (m *Monitor) pollPVEInstance(ctx context.Context, instanceName string, clie } // Preserve existing disk data for nodes that weren't polled (offline or error) - for diskID, existingDisk := range existingDisksMap { + for _, existingDisk := range existingDisksMap { // Only preserve if we didn't poll this node if !polledNodes[existingDisk.Node] { // Keep the existing disk data but update the LastChecked to indicate it's stale diff --git a/internal/monitoring/monitor_optimized.go b/internal/monitoring/monitor_optimized.go index 2e072d3a0..0d8fb3e9c 100644 --- a/internal/monitoring/monitor_optimized.go +++ b/internal/monitoring/monitor_optimized.go @@ -941,7 +941,7 @@ func (m *Monitor) pollStorageWithNodesOptimized(ctx context.Context, instanceNam // Preserve existing storage data for nodes that weren't polled (offline or error) preservedCount := 0 - for storageID, existingStorage := range existingStorageMap { + for _, existingStorage := range existingStorageMap { // Only preserve if we didn't poll this node if !polledNodes[existingStorage.Node] && existingStorage.Node != "cluster" { allStorage = append(allStorage, existingStorage) diff --git a/internal/websocket/hub.go b/internal/websocket/hub.go index 89f89de79..ccfbabc64 100644 --- a/internal/websocket/hub.go +++ b/internal/websocket/hub.go @@ -2,6 +2,7 @@ package websocket import ( "encoding/json" + "fmt" "math" "net" "net/http" @@ -198,9 +199,11 @@ func (h *Hub) Run() { log.Info().Str("client", client.id).Msg("WebSocket client connected") // Send initial state to the new client immediately + log.Debug().Bool("hasGetState", h.getState != nil).Msg("Checking getState function for new client") if h.getState != nil { // Add a small delay to ensure client is ready go func() { + log.Debug().Str("client", client.id).Msg("Starting initial state goroutine") time.Sleep(500 * time.Millisecond) // First send a small welcome message @@ -225,10 +228,15 @@ func (h *Hub) Run() { // Then send the initial state after another delay time.Sleep(100 * time.Millisecond) + log.Debug().Str("client", client.id).Msg("About to get state") + + // Get the state + stateData := h.getState() + log.Debug().Str("client", client.id).Interface("stateType", fmt.Sprintf("%T", stateData)).Msg("Got state for initial message") initialMsg := Message{ Type: "initialState", - Data: sanitizeData(h.getState()), + Data: sanitizeData(stateData), } if data, err := json.Marshal(initialMsg); err == nil { // Check if client is still registered before sending @@ -299,8 +307,8 @@ func (h *Hub) HandleWebSocket(w http.ResponseWriter, r *http.Request) { // Create upgrader with our origin check upgrader := websocket.Upgrader{ - ReadBufferSize: 1024 * 64, // 64KB to handle large state messages - WriteBufferSize: 1024 * 64, // 64KB to handle large state messages + ReadBufferSize: 1024 * 1024 * 4, // 4MB to handle large state messages + WriteBufferSize: 1024 * 1024 * 4, // 4MB to handle large state messages CheckOrigin: h.checkOrigin, }