feat: implement Phase 11 - Real-time Updates with SSE
All checks were successful
Build and Push Docker Image to Gitea / build-and-push (push) Successful in 1m11s
All checks were successful
Build and Push Docker Image to Gitea / build-and-push (push) Successful in 1m11s
Features added: - Created SSE (Server-Sent Events) endpoint at /api/supervisor/events - Polls supervisor every 2 seconds for state changes - Sends process-update events when state changes detected - Sends heartbeat events to keep connection alive - Includes error handling with error events - Created useEventSource hook for managing SSE connections - Automatic reconnection with exponential backoff - Configurable max reconnection attempts (default 10) - Connection status tracking (connecting, connected, disconnected, error) - Clean event listener management with proper cleanup - Heartbeat monitoring for connection health - Created ConnectionStatus component - Visual status indicator with icons (Wifi, WifiOff, Loader, AlertCircle) - Color-coded states (green=connected, yellow=connecting, red=error) - Shows reconnection attempt count - Manual reconnect button when disconnected/error - Integrated real-time updates into dashboard and processes pages - Auto-refresh process data when state changes occur - Connection status indicator in page headers - No manual refresh needed for live updates - Implemented proper cleanup on unmount - EventSource properly closed - Reconnection timeouts cleared - No memory leaks 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
144
lib/hooks/useEventSource.ts
Normal file
144
lib/hooks/useEventSource.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
|
||||
export type ConnectionStatus = 'connecting' | 'connected' | 'disconnected' | 'error';
|
||||
|
||||
export interface EventSourceMessage<T = any> {
|
||||
event: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export interface UseEventSourceOptions {
|
||||
enabled?: boolean;
|
||||
reconnectInterval?: number;
|
||||
maxReconnectAttempts?: number;
|
||||
onMessage?: (message: EventSourceMessage) => void;
|
||||
onError?: (error: Event) => void;
|
||||
onConnect?: () => void;
|
||||
onDisconnect?: () => void;
|
||||
}
|
||||
|
||||
export function useEventSource(url: string, options: UseEventSourceOptions = {}) {
|
||||
const {
|
||||
enabled = true,
|
||||
reconnectInterval = 3000,
|
||||
maxReconnectAttempts = 10,
|
||||
onMessage,
|
||||
onError,
|
||||
onConnect,
|
||||
onDisconnect,
|
||||
} = options;
|
||||
|
||||
const [status, setStatus] = useState<ConnectionStatus>('disconnected');
|
||||
const [reconnectAttempts, setReconnectAttempts] = useState(0);
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (!enabled || eventSourceRef.current) return;
|
||||
|
||||
setStatus('connecting');
|
||||
|
||||
try {
|
||||
const eventSource = new EventSource(url);
|
||||
eventSourceRef.current = eventSource;
|
||||
|
||||
eventSource.addEventListener('connected', () => {
|
||||
setStatus('connected');
|
||||
setReconnectAttempts(0);
|
||||
onConnect?.();
|
||||
});
|
||||
|
||||
eventSource.addEventListener('heartbeat', (event) => {
|
||||
// Keep connection alive
|
||||
if (status !== 'connected') {
|
||||
setStatus('connected');
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.addEventListener('process-update', (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
onMessage?.({ event: 'process-update', data });
|
||||
} catch (error) {
|
||||
console.error('Failed to parse SSE message:', error);
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.addEventListener('error', (event) => {
|
||||
try {
|
||||
const data = JSON.parse((event as MessageEvent).data);
|
||||
onMessage?.({ event: 'error', data });
|
||||
} catch (error) {
|
||||
// Not a message error, connection error
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.onerror = (event) => {
|
||||
console.error('EventSource error:', event);
|
||||
setStatus('error');
|
||||
onError?.(event);
|
||||
|
||||
// Close current connection
|
||||
eventSource.close();
|
||||
eventSourceRef.current = null;
|
||||
|
||||
// Attempt reconnection with exponential backoff
|
||||
if (reconnectAttempts < maxReconnectAttempts) {
|
||||
const delay = Math.min(reconnectInterval * Math.pow(2, reconnectAttempts), 30000);
|
||||
console.log(`Reconnecting in ${delay}ms (attempt ${reconnectAttempts + 1}/${maxReconnectAttempts})`);
|
||||
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
setReconnectAttempts((prev) => prev + 1);
|
||||
connect();
|
||||
}, delay);
|
||||
} else {
|
||||
setStatus('disconnected');
|
||||
onDisconnect?.();
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to create EventSource:', error);
|
||||
setStatus('error');
|
||||
}
|
||||
}, [url, enabled, status, reconnectAttempts, maxReconnectAttempts, reconnectInterval, onMessage, onError, onConnect, onDisconnect]);
|
||||
|
||||
const disconnect = useCallback(() => {
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
|
||||
setStatus('disconnected');
|
||||
setReconnectAttempts(0);
|
||||
}, []);
|
||||
|
||||
const reconnect = useCallback(() => {
|
||||
disconnect();
|
||||
setReconnectAttempts(0);
|
||||
connect();
|
||||
}, [disconnect, connect]);
|
||||
|
||||
useEffect(() => {
|
||||
if (enabled) {
|
||||
connect();
|
||||
} else {
|
||||
disconnect();
|
||||
}
|
||||
|
||||
return () => {
|
||||
disconnect();
|
||||
};
|
||||
}, [enabled, url]);
|
||||
|
||||
return {
|
||||
status,
|
||||
reconnectAttempts,
|
||||
reconnect,
|
||||
disconnect,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user