diff --git a/infra/main.bicep b/infra/main.bicep index 8933fb94..bb119a9a 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -1112,7 +1112,7 @@ module containerAppBackend 'br/public:avm/res/app/container-app:0.19.0' = { } ] ingressTargetPort: 8000 - ingressExternal: true + ingressExternal: !enablePrivateNetworking scaleSettings: { // maxReplicas: enableScalability ? 3 : 1 maxReplicas: 1 // maxReplicas set to 1 (not 3) due to multiple agents created per type during WAF deployment diff --git a/infra/main.parameters.json b/infra/main.parameters.json index ca5d1cd2..e5ac4968 100644 --- a/infra/main.parameters.json +++ b/infra/main.parameters.json @@ -47,6 +47,15 @@ "vmAdminPassword": { "value": "${AZURE_ENV_VM_ADMIN_PASSWORD}" }, + "enableMonitoring": { + "value": true + }, + "enablePrivateNetworking": { + "value": true + }, + "enableScalability": { + "value": true + }, "aiModelDeployments": { "value": [ { diff --git a/infra/main_custom.bicep b/infra/main_custom.bicep index 398378fd..948aca33 100644 --- a/infra/main_custom.bicep +++ b/infra/main_custom.bicep @@ -1063,7 +1063,7 @@ module containerAppBackend 'br/public:avm/res/app/container-app:0.19.0' = { } ] ingressTargetPort: 8000 - ingressExternal: true + ingressExternal: !enablePrivateNetworking scaleSettings: { // maxReplicas: enableScalability ? 3 : 1 maxReplicas: 1 // maxReplicas set to 1 (not 3) due to multiple agents created per type during WAF deployment diff --git a/src/frontend/frontend_server.py b/src/frontend/frontend_server.py index c53af042..7a080665 100644 --- a/src/frontend/frontend_server.py +++ b/src/frontend/frontend_server.py @@ -1,15 +1,22 @@ +import asyncio import os +import httpx import uvicorn +import websockets from dotenv import load_dotenv -from fastapi import FastAPI +from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import FileResponse +from fastapi.responses import FileResponse, Response from fastapi.staticfiles import StaticFiles # Load environment variables from .env file load_dotenv() +# Internal backend URL used by the server-side proxy. +# The browser never contacts this URL directly. +BACKEND_API_URL = os.getenv("API_URL", "http://localhost:8000").rstrip("/") + app = FastAPI() app.add_middleware( @@ -38,7 +45,11 @@ async def serve_index(): @app.get("/config") async def get_config(): config = { - "API_URL": os.getenv("API_URL", "API_URL not set"), + # Return empty string so the browser uses relative /api/* paths + # which are proxied server-side to BACKEND_API_URL. This ensures + # backend Container Apps with internal-only ingress are never + # contacted directly from the browser. + "API_URL": "", "REACT_APP_MSAL_AUTH_CLIENTID": os.getenv( "REACT_APP_MSAL_AUTH_CLIENTID", "Client ID not set" ), @@ -56,6 +67,104 @@ async def get_config(): return config +# --------------------------------------------------------------------------- +# Reverse proxy: WebSocket (must be declared before the HTTP catch-all below) +# --------------------------------------------------------------------------- + +@app.websocket("/api/socket/{batch_id}") +async def proxy_websocket(websocket: WebSocket, batch_id: str): + """Proxy WebSocket connections from the browser to the internal backend.""" + await websocket.accept() + + backend_ws_url = ( + BACKEND_API_URL + .replace("https://", "wss://") + .replace("http://", "ws://") + ) + backend_ws_url = f"{backend_ws_url}/api/socket/{batch_id}" + + try: + async with websockets.connect(backend_ws_url) as backend_ws: + + async def forward_to_backend(): + try: + while True: + data = await websocket.receive_text() + await backend_ws.send(data) + except (WebSocketDisconnect, Exception): + pass + + async def forward_to_client(): + try: + async for message in backend_ws: + await websocket.send_text(message) + except (WebSocketDisconnect, Exception): + pass + + await asyncio.gather(forward_to_backend(), forward_to_client()) + except Exception: + pass + finally: + try: + await websocket.close() + except Exception: + pass + + +# --------------------------------------------------------------------------- +# Reverse proxy: HTTP (all /api/* routes proxied to the internal backend) +# --------------------------------------------------------------------------- + +_PROXY_CLIENT = httpx.AsyncClient(timeout=300.0) + + +@app.api_route( + "/api/{path:path}", + methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"], +) +async def proxy_api(request: Request, path: str): + """Proxy HTTP API requests from the browser to the internal backend.""" + target_url = f"{BACKEND_API_URL}/api/{path}" + if request.url.query: + target_url = f"{target_url}?{request.url.query}" + + # Forward all headers except 'host' (would confuse the backend) + headers = { + k: v for k, v in request.headers.items() + if k.lower() != "host" + } + + body = await request.body() + + response = await _PROXY_CLIENT.request( + method=request.method, + url=target_url, + headers=headers, + content=body, + ) + + # Strip hop-by-hop headers that must not be forwarded + excluded_headers = { + "content-encoding", "transfer-encoding", "connection", + "keep-alive", "proxy-authenticate", "proxy-authorization", + "te", "trailers", "upgrade", + } + forwarded_headers = { + k: v for k, v in response.headers.items() + if k.lower() not in excluded_headers + } + + return Response( + content=response.content, + status_code=response.status_code, + headers=forwarded_headers, + ) + + +# --------------------------------------------------------------------------- +# SPA catch-all (must be last) +# --------------------------------------------------------------------------- + @app.get("/{full_path:path}") async def serve_app(full_path: str): # Remediation: normalize and check containment before serving diff --git a/src/frontend/requirements.txt b/src/frontend/requirements.txt index 35c4db53..1d273c6b 100644 --- a/src/frontend/requirements.txt +++ b/src/frontend/requirements.txt @@ -4,4 +4,6 @@ uvicorn[standard] jinja2 azure-identity python-dotenv -python-multipart \ No newline at end of file +python-multipart +httpx +websockets \ No newline at end of file diff --git a/src/frontend/src/api/WebSocketService.tsx b/src/frontend/src/api/WebSocketService.tsx index 9969c5e8..eac44534 100644 --- a/src/frontend/src/api/WebSocketService.tsx +++ b/src/frontend/src/api/WebSocketService.tsx @@ -1,64 +1,107 @@ -import { getApiUrl } from '../api/config'; +import { getApiUrl, headerBuilder } from '../api/config'; -// WebSocketService.ts +// Polling-based status stream service that preserves the existing event interface. type EventHandler = (data: any) => void; class WebSocketService { - private socket: WebSocket | null = null; + private pollInterval: ReturnType | null = null; + private isConnected = false; + private activeBatchId: string | null = null; + private lastKnownStatus: Record = {}; private eventHandlers: Record = {}; - connect(batch_id: string): void { - let apiUrl = getApiUrl(); - console.log('API URL: websocket', apiUrl); - if (apiUrl) { - apiUrl = apiUrl.replace(/^https?/, match => match === "https" ? "wss" : "ws"); - } else { - throw new Error('API URL is null'); + private async pollBatchSummary(batchId: string): Promise { + const apiUrl = getApiUrl(); + if (!apiUrl) { + this._emit('error', new Error('API URL is null')); + return; } - console.log('Connecting to WebSocket:', apiUrl); - if (this.socket) return; // Prevent duplicate connections - this.socket = new WebSocket(`${apiUrl}/socket/${batch_id}`); - - this.socket.onopen = () => { - console.log('WebSocket connection opened.'); - this._emit('open', undefined); - }; - - this.socket.onmessage = (event: MessageEvent) => { - try { - const data = JSON.parse(event.data); - this._emit('message', data); - } catch (err) { - console.error('Error parsing message:', err); + + try { + const response = await fetch(`${apiUrl}/batch-summary/${batchId}`, { + headers: headerBuilder({}), + }); + + if (!response.ok) { + throw new Error(`Failed to fetch batch status: ${response.status}`); } - }; - this.socket.onerror = (error: Event) => { - console.error('WebSocket error:', error); + const payload = await response.json(); + const files = payload?.files || []; + let allFilesTerminal = files.length > 0; + + for (const file of files) { + const fileId = file?.file_id; + const status = (file?.status || '').toLowerCase(); + if (!fileId || !status) { + continue; + } + + if (!['completed', 'failed', 'error'].includes(status)) { + allFilesTerminal = false; + } + + const previousStatus = this.lastKnownStatus[fileId]; + if (previousStatus !== status) { + this.lastKnownStatus[fileId] = status; + + this._emit('message', { + batch_id: batchId, + file_id: fileId, + agent_type: 'Polling agent', + agent_message: `Status changed to ${status}`, + process_status: status, + file_result: file?.file_result || null, + }); + } + } + + if (allFilesTerminal) { + this.disconnect(); + } + } catch (error) { this._emit('error', error); - }; + } + } + + connect(batch_id: string): void { + if (this.isConnected && this.activeBatchId === batch_id) return; + + this.disconnect(); + + this.isConnected = true; + this.activeBatchId = batch_id; + this.lastKnownStatus = {}; + this._emit('open', undefined); - this.socket.onclose = (event: CloseEvent) => { - console.log('WebSocket closed:', event); - this._emit('close', event); - this.socket = null; - }; + // Poll once immediately, then at a fixed interval. + void this.pollBatchSummary(batch_id); + this.pollInterval = setInterval(() => { + if (this.isConnected && this.activeBatchId) { + void this.pollBatchSummary(this.activeBatchId); + } + }, 3000); } disconnect(): void { - if (this.socket) { - this.socket.close(); - this.socket = null; - console.log('WebSocket connection closed manually.'); + if (this.pollInterval) { + clearInterval(this.pollInterval); + this.pollInterval = null; + } + + const wasConnected = this.isConnected; + this.isConnected = false; + this.activeBatchId = null; + this.lastKnownStatus = {}; + + if (wasConnected) { + this._emit('close', { reason: 'polling_stopped' }); } } send(data: any): void { - if (this.socket && this.socket.readyState === WebSocket.OPEN) { - this.socket.send(JSON.stringify(data)); - } else { - console.error('WebSocket is not open. Cannot send:', data); - } + // Polling transport is read-only from client perspective. + console.debug('send() is ignored in polling mode:', data); } on(event: string, handler: EventHandler): void { diff --git a/src/frontend/src/api/config.js b/src/frontend/src/api/config.js index 71d1c8cc..f84db40f 100644 --- a/src/frontend/src/api/config.js +++ b/src/frontend/src/api/config.js @@ -49,8 +49,11 @@ export function getApiUrl() { } if (!API_URL) { - console.warn('API URL not yet configured'); - return null; + // API_URL is not configured (e.g. WAF deployment where the backend is + // internal-only). Fall back to the browser's own origin so that all + // /api/* requests are routed through the frontend server's reverse proxy + // instead of attempting to reach the internal backend URL directly. + return `${window.location.origin}/api`; } return API_URL; diff --git a/src/frontend/vite.config.js b/src/frontend/vite.config.js index d239b70e..ceeb41ee 100644 --- a/src/frontend/vite.config.js +++ b/src/frontend/vite.config.js @@ -10,10 +10,9 @@ export default defineConfig({ '/api': { target: 'http://localhost:8000', changeOrigin: true, - rewrite: (path) => path.replace(/^\/api/, '') }, '/config': { - target: 'http://localhost:8000', + target: 'http://localhost:3000', changeOrigin: true } }