From cea3bc542987b4277e70e92898e2b1fd5202d4ce Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Nov 2025 03:28:07 +0000 Subject: [PATCH 1/6] fix(websocket): enable WebSocket authentication via query parameter PROBLEM: - WebSocket connections were failing with authentication errors - Frontend could not establish real-time connections - Users saw "real-time connection error" and lost live updates ROOT CAUSE: - Browser WebSocket API cannot send custom HTTP headers (e.g., Authorization) - Auth middleware only checked Authorization header, which wasn't available - Frontend had token but no way to send it during WebSocket handshake SOLUTION: Backend (api/internal/auth/middleware.go): - Modified auth middleware to accept token from query parameter for WebSocket connections - Maintains backward compatibility with Authorization header for regular HTTP requests - Checks query parameter first for WebSocket upgrades, falls back to header Frontend (ui/src/hooks/useEnterpriseWebSocket.ts): - Updated WebSocket URL to include token as query parameter - Removed redundant token check (now handled in URL construction) - Uses encodeURIComponent for proper URL encoding SECURITY: - Query parameter auth only used for WebSocket upgrade requests - Token still validated with same JWT verification process - No change to security model, just different transport mechanism TESTING: - WebSocket connections should now authenticate successfully - Real-time updates should work (session status, notifications, etc.) - Frontend should show "Real-time updates connected" notification Fixes: WebSocket authentication and real-time connection issues --- api/internal/auth/middleware.go | 57 +++++++++++++++----------- ui/src/hooks/useEnterpriseWebSocket.ts | 14 ++++--- 2 files changed, 41 insertions(+), 30 deletions(-) diff --git a/api/internal/auth/middleware.go b/api/internal/auth/middleware.go index 63ed3cf7..e10227de 100644 --- a/api/internal/auth/middleware.go +++ b/api/internal/auth/middleware.go @@ -146,36 +146,45 @@ func Middleware(jwtManager *JWTManager, userDB *db.UserDB) gin.HandlerFunc { // Check if this is a WebSocket upgrade request isWebSocket := c.GetHeader("Upgrade") == "websocket" && c.GetHeader("Connection") == "Upgrade" - // Extract token from Authorization header - authHeader := c.GetHeader("Authorization") - if authHeader == "" { - // For WebSocket, abort without writing response (let upgrader handle it) - if isWebSocket { - c.AbortWithStatus(http.StatusUnauthorized) + var tokenString string + + // For WebSocket connections, try query parameter first (browsers can't send custom headers) + if isWebSocket { + tokenString = c.Query("token") + } + + // If no token from query parameter, try Authorization header + if tokenString == "" { + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + // For WebSocket, abort without writing response (let upgrader handle it) + if isWebSocket { + c.AbortWithStatus(http.StatusUnauthorized) + return + } + c.JSON(http.StatusUnauthorized, gin.H{ + "error": "Authorization header required", + }) + c.Abort() return } - c.JSON(http.StatusUnauthorized, gin.H{ - "error": "Authorization header required", - }) - c.Abort() - return - } - // Check Bearer prefix - parts := strings.SplitN(authHeader, " ", 2) - if len(parts) != 2 || parts[0] != "Bearer" { - if isWebSocket { - c.AbortWithStatus(http.StatusUnauthorized) + // Check Bearer prefix + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) != 2 || parts[0] != "Bearer" { + if isWebSocket { + c.AbortWithStatus(http.StatusUnauthorized) + return + } + c.JSON(http.StatusUnauthorized, gin.H{ + "error": "Invalid authorization header format. Use: Bearer ", + }) + c.Abort() return } - c.JSON(http.StatusUnauthorized, gin.H{ - "error": "Invalid authorization header format. Use: Bearer ", - }) - c.Abort() - return - } - tokenString := parts[1] + tokenString = parts[1] + } // Validate token claims, err := jwtManager.ValidateToken(tokenString) diff --git a/ui/src/hooks/useEnterpriseWebSocket.ts b/ui/src/hooks/useEnterpriseWebSocket.ts index 6372735c..a4f965ef 100644 --- a/ui/src/hooks/useEnterpriseWebSocket.ts +++ b/ui/src/hooks/useEnterpriseWebSocket.ts @@ -55,6 +55,14 @@ export function useEnterpriseWebSocket( const getWebSocketUrl = useCallback(() => { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const host = window.location.host; + const token = localStorage.getItem('token'); + + // Include token as query parameter for WebSocket authentication + // Browsers cannot send custom headers in WebSocket connections + if (token) { + return `${protocol}//${host}/api/v1/ws/enterprise?token=${encodeURIComponent(token)}`; + } + return `${protocol}//${host}/api/v1/ws/enterprise`; }, []); @@ -65,12 +73,6 @@ export function useEnterpriseWebSocket( } try { - const token = localStorage.getItem('token'); - if (!token) { - console.error('No authentication token found'); - return; - } - const wsUrl = getWebSocketUrl(); // console.log(`[WebSocket] Connecting to ${wsUrl}`); From 651c4ce00868b419088edce15d232fa9845e6ed6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Nov 2025 03:37:01 +0000 Subject: [PATCH 2/6] fix(ui): replace 'Try Again' with 'Continue Without Live Updates' in WebSocket error dialog - Changed primary button from 'Reload Page' to 'Continue Without Live Updates' - Moved 'Reload Page' to secondary button position - Makes it easier to continue using the app when WebSocket is unavailable - Removes confusing suggestion to try refreshing --- ui/src/components/WebSocketErrorBoundary.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/ui/src/components/WebSocketErrorBoundary.tsx b/ui/src/components/WebSocketErrorBoundary.tsx index f4807706..9f7a60a8 100644 --- a/ui/src/components/WebSocketErrorBoundary.tsx +++ b/ui/src/components/WebSocketErrorBoundary.tsx @@ -96,7 +96,7 @@ export default class WebSocketErrorBoundary extends Component { WebSocket Connection Error There was an error with the real-time connection. The page will continue to work, - but live updates may be unavailable. You can try refreshing the page or reconnecting. + but live updates may be unavailable. {this.props.showErrorDetails && this.state.error && ( @@ -110,18 +110,18 @@ export default class WebSocketErrorBoundary extends Component { From 43cf9dfab07a5aad542ee440c480fe9d8d0551d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Nov 2025 03:40:57 +0000 Subject: [PATCH 3/6] fix(vite): enable WebSocket proxying in dev server - Added 'ws: true' to Vite proxy config for /api route - Fixes NS_ERROR_CONNECTION_REFUSED for WebSocket connections - WebSocket connections were failing because Vite wasn't upgrading HTTP to WS Without this option, Vite proxy blocks WebSocket upgrade requests, causing all WebSocket connections to fail with CONNECTION_REFUSED. --- ui/vite.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 06922330..66c0597c 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -10,6 +10,7 @@ export default defineConfig({ '/api': { target: 'http://localhost:8000', changeOrigin: true, + ws: true, // Enable WebSocket proxying }, '/webhooks': { target: 'http://localhost:8000', From eaecd0ca059eaf025ba6c068208afee5f4ae6c38 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Nov 2025 03:42:28 +0000 Subject: [PATCH 4/6] fix(websocket): fix all WebSocket connections to use Vite proxy and authentication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PROBLEM: - WebSocket connections for /ws/sessions and /ws/cluster were failing - Connections were trying to reach ws://localhost:8000 directly - Should connect through Vite proxy at ws://localhost:3000 - All WebSocket endpoints require authentication CHANGES: 1. Updated useSessionsWebSocket to use window.location.host (not hardcoded) 2. Updated useMetricsWebSocket to use window.location.host 3. Updated useLogsWebSocket to use window.location.host 4. Added token authentication via query parameter to all three hooks This ensures: - Development: Connects to ws://localhost:3000 → Vite proxy → localhost:8000 - Production: Connects to wss://yourdomain.com directly - All connections authenticated with JWT token from localStorage Previously these hooks used VITE_API_URL environment variable which defaulted to http://localhost:8000, bypassing the Vite proxy entirely. --- ui/src/hooks/useWebSocket.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/ui/src/hooks/useWebSocket.ts b/ui/src/hooks/useWebSocket.ts index 18931744..1792615a 100644 --- a/ui/src/hooks/useWebSocket.ts +++ b/ui/src/hooks/useWebSocket.ts @@ -132,8 +132,10 @@ export function useWebSocket({ * Hook for subscribing to session updates via WebSocket */ export function useSessionsWebSocket(onUpdate: (sessions: any[]) => void) { - const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000'; - const wsUrl = apiUrl.replace(/^http/, 'ws') + '/api/v1/ws/sessions'; + // Use window.location to connect through Vite proxy in dev, or directly in production + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const token = localStorage.getItem('token'); + const wsUrl = `${protocol}//${window.location.host}/api/v1/ws/sessions${token ? `?token=${encodeURIComponent(token)}` : ''}`; return useWebSocket({ url: wsUrl, @@ -151,8 +153,10 @@ export function useSessionsWebSocket(onUpdate: (sessions: any[]) => void) { * Hook for subscribing to cluster metrics via WebSocket */ export function useMetricsWebSocket(onUpdate: (metrics: any) => void) { - const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000'; - const wsUrl = apiUrl.replace(/^http/, 'ws') + '/api/v1/ws/cluster'; + // Use window.location to connect through Vite proxy in dev, or directly in production + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const token = localStorage.getItem('token'); + const wsUrl = `${protocol}//${window.location.host}/api/v1/ws/cluster${token ? `?token=${encodeURIComponent(token)}` : ''}`; return useWebSocket({ url: wsUrl, @@ -170,8 +174,10 @@ export function useMetricsWebSocket(onUpdate: (metrics: any) => void) { * Hook for subscribing to pod logs via WebSocket */ export function useLogsWebSocket(namespace: string, podName: string, onLog: (log: string) => void) { - const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000'; - const wsUrl = apiUrl.replace(/^http/, 'ws') + `/api/v1/ws/logs/${namespace}/${podName}`; + // Use window.location to connect through Vite proxy in dev, or directly in production + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const token = localStorage.getItem('token'); + const wsUrl = `${protocol}//${window.location.host}/api/v1/ws/logs/${namespace}/${podName}${token ? `?token=${encodeURIComponent(token)}` : ''}`; return useWebSocket({ url: wsUrl, From 83992a60473f97e22f7515677c6ca7474fb140a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Nov 2025 14:46:54 +0000 Subject: [PATCH 5/6] fix(ui): prevent WebSocket error dialog from reappearing after dismissal - Added 'dismissed' state to track if user has dismissed the error - After dismissal, subsequent WebSocket errors are logged to console only - Prevents full-screen error dialog from taking over the screen repeatedly - User can continue using the app without constant interruptions --- ui/src/components/WebSocketErrorBoundary.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/ui/src/components/WebSocketErrorBoundary.tsx b/ui/src/components/WebSocketErrorBoundary.tsx index 9f7a60a8..1d76b0dc 100644 --- a/ui/src/components/WebSocketErrorBoundary.tsx +++ b/ui/src/components/WebSocketErrorBoundary.tsx @@ -31,6 +31,7 @@ interface State { hasError: boolean; error: Error | null; errorInfo: React.ErrorInfo | null; + dismissed: boolean; // Track if user has dismissed the error } export default class WebSocketErrorBoundary extends Component { @@ -40,14 +41,14 @@ export default class WebSocketErrorBoundary extends Component { hasError: false, error: null, errorInfo: null, + dismissed: false, }; } - static getDerivedStateFromError(error: Error): State { + static getDerivedStateFromError(error: Error): Partial { return { hasError: true, error, - errorInfo: null, }; } @@ -70,10 +71,17 @@ export default class WebSocketErrorBoundary extends Component { hasError: false, error: null, errorInfo: null, + dismissed: true, // Mark as dismissed }); }; render() { + // If error was already dismissed, just render children without showing error UI + if (this.state.hasError && this.state.dismissed) { + console.warn('WebSocket error (dismissed):', this.state.error?.message); + return this.props.children; + } + if (this.state.hasError) { // Use custom fallback if provided if (this.props.fallback) { From e8571a7669c3274dcda79e93415e2155f0d30d17 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Nov 2025 15:22:12 +0000 Subject: [PATCH 6/6] fix(ui): make WebSocket reconnection banner dismissible and non-intrusive - Moved reconnection banner from top-center to bottom-left - Changed from warning (filled) to info (outlined) style - Added dismiss button to permanently hide the banner - Made it less visually aggressive so it doesn't block the UI - Users can now continue using the app while WebSocket reconnects --- .../components/EnterpriseWebSocketProvider.tsx | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/ui/src/components/EnterpriseWebSocketProvider.tsx b/ui/src/components/EnterpriseWebSocketProvider.tsx index 91669677..4c2e6884 100644 --- a/ui/src/components/EnterpriseWebSocketProvider.tsx +++ b/ui/src/components/EnterpriseWebSocketProvider.tsx @@ -69,6 +69,7 @@ export default function EnterpriseWebSocketProvider({ enableNotifications = true, }: EnterpriseWebSocketProviderProps) { const [notifications, setNotifications] = useState([]); + const [reconnectDismissed, setReconnectDismissed] = useState(false); // Track if reconnect banner was dismissed const addNotification = useCallback((message: string, severity: Notification['severity']) => { const id = `${Date.now()}-${Math.random()}`; @@ -213,12 +214,21 @@ export default function EnterpriseWebSocketProvider({ ))} {/* Connection status indicator (optional) */} - {!isConnected && reconnectAttempts > 0 && ( + {!isConnected && reconnectAttempts > 0 && !reconnectDismissed && ( setReconnectDismissed(true)} > - + setReconnectDismissed(true)} + sx={{ + backgroundColor: 'background.paper', + boxShadow: 1, + }} + > Reconnecting... (Attempt {reconnectAttempts}/10)