From e274c8b971b33849d1f96d9c54f9d6ae17570c25 Mon Sep 17 00:00:00 2001 From: "Ashwal Vishwanath (Persistent Systems Inc)" Date: Mon, 20 Apr 2026 18:22:25 +0530 Subject: [PATCH 1/4] Fix WAF frontend-to-backend routing and config for internal backend access --- infra/main.parameters.json | 9 +++ infra/main.waf.parameters copy.json | 75 ++++++++++++++++++ src/frontend/frontend_server.py | 115 +++++++++++++++++++++++++++- src/frontend/requirements.txt | 4 +- src/frontend/src/api/config.js | 7 +- src/frontend/vite.config.js | 3 +- 6 files changed, 205 insertions(+), 8 deletions(-) create mode 100644 infra/main.waf.parameters copy.json 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.waf.parameters copy.json b/infra/main.waf.parameters copy.json new file mode 100644 index 00000000..e5ac4968 --- /dev/null +++ b/infra/main.waf.parameters copy.json @@ -0,0 +1,75 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "solutionName": { + "value": "${AZURE_ENV_NAME}" + }, + "location": { + "value": "${AZURE_LOCATION}" + }, + "deploymentType": { + "value": "${AZURE_ENV_MODEL_DEPLOYMENT_TYPE}" + }, + "gptModelName": { + "value": "${AZURE_ENV_GPT_MODEL_NAME}" + }, + "gptDeploymentCapacity": { + "value": "${AZURE_ENV_GPT_MODEL_CAPACITY}" + }, + "gptModelVersion": { + "value": "${AZURE_ENV_GPT_MODEL_VERSION}" + }, + "imageTag": { + "value": "${AZURE_ENV_IMAGE_TAG=latest}" + }, + "containerRegistryEndpoint": { + "value": "${AZURE_ENV_CONTAINER_REGISTRY_ENDPOINT=cmsacontainerreg.azurecr.io}" + }, + "existingLogAnalyticsWorkspaceId": { + "value": "${AZURE_ENV_EXISTING_LOG_ANALYTICS_WORKSPACE_RID}" + }, + "existingFoundryProjectResourceId": { + "value": "${AZURE_EXISTING_AIPROJECT_RESOURCE_ID}" + }, + "secondaryLocation": { + "value": "${AZURE_ENV_SECONDARY_LOCATION}" + }, + "azureAiServiceLocation": { + "value": "${AZURE_ENV_AI_SERVICE_LOCATION}" + }, + "vmSize": { + "value": "${AZURE_ENV_VM_SIZE}" + }, + "vmAdminUsername": { + "value": "${AZURE_ENV_VM_ADMIN_USERNAME}" + }, + "vmAdminPassword": { + "value": "${AZURE_ENV_VM_ADMIN_PASSWORD}" + }, + "enableMonitoring": { + "value": true + }, + "enablePrivateNetworking": { + "value": true + }, + "enableScalability": { + "value": true + }, + "aiModelDeployments": { + "value": [ + { + "name": "${AZURE_ENV_GPT_MODEL_NAME}", + "model": { + "name": "${AZURE_ENV_GPT_MODEL_NAME}", + "version": "${AZURE_ENV_GPT_MODEL_VERSION}" + }, + "sku": { + "name": "${AZURE_ENV_MODEL_DEPLOYMENT_TYPE}", + "capacity": "${AZURE_ENV_GPT_MODEL_CAPACITY}" + } + } + ] + } + } +} \ No newline at end of file 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/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 } } From 266de042a845e1666371adbc1f4e263ca6ee011d Mon Sep 17 00:00:00 2001 From: "Ashwal Vishwanath (Persistent Systems Inc)" Date: Mon, 20 Apr 2026 18:35:21 +0530 Subject: [PATCH 2/4] Set backend Container App ingress internal for WAF deployments --- infra/main.bicep | 2 +- infra/main_custom.bicep | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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_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 From 120a9b55b32d0dc5e80a342d7c72d7528ff53633 Mon Sep 17 00:00:00 2001 From: "Ashwal Vishwanath (Persistent Systems Inc)" Date: Tue, 21 Apr 2026 12:45:49 +0530 Subject: [PATCH 3/4] Remove temporary WAF parameters file --- infra/main.waf.parameters copy.json | 75 ----------------------------- 1 file changed, 75 deletions(-) delete mode 100644 infra/main.waf.parameters copy.json diff --git a/infra/main.waf.parameters copy.json b/infra/main.waf.parameters copy.json deleted file mode 100644 index e5ac4968..00000000 --- a/infra/main.waf.parameters copy.json +++ /dev/null @@ -1,75 +0,0 @@ -{ - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", - "contentVersion": "1.0.0.0", - "parameters": { - "solutionName": { - "value": "${AZURE_ENV_NAME}" - }, - "location": { - "value": "${AZURE_LOCATION}" - }, - "deploymentType": { - "value": "${AZURE_ENV_MODEL_DEPLOYMENT_TYPE}" - }, - "gptModelName": { - "value": "${AZURE_ENV_GPT_MODEL_NAME}" - }, - "gptDeploymentCapacity": { - "value": "${AZURE_ENV_GPT_MODEL_CAPACITY}" - }, - "gptModelVersion": { - "value": "${AZURE_ENV_GPT_MODEL_VERSION}" - }, - "imageTag": { - "value": "${AZURE_ENV_IMAGE_TAG=latest}" - }, - "containerRegistryEndpoint": { - "value": "${AZURE_ENV_CONTAINER_REGISTRY_ENDPOINT=cmsacontainerreg.azurecr.io}" - }, - "existingLogAnalyticsWorkspaceId": { - "value": "${AZURE_ENV_EXISTING_LOG_ANALYTICS_WORKSPACE_RID}" - }, - "existingFoundryProjectResourceId": { - "value": "${AZURE_EXISTING_AIPROJECT_RESOURCE_ID}" - }, - "secondaryLocation": { - "value": "${AZURE_ENV_SECONDARY_LOCATION}" - }, - "azureAiServiceLocation": { - "value": "${AZURE_ENV_AI_SERVICE_LOCATION}" - }, - "vmSize": { - "value": "${AZURE_ENV_VM_SIZE}" - }, - "vmAdminUsername": { - "value": "${AZURE_ENV_VM_ADMIN_USERNAME}" - }, - "vmAdminPassword": { - "value": "${AZURE_ENV_VM_ADMIN_PASSWORD}" - }, - "enableMonitoring": { - "value": true - }, - "enablePrivateNetworking": { - "value": true - }, - "enableScalability": { - "value": true - }, - "aiModelDeployments": { - "value": [ - { - "name": "${AZURE_ENV_GPT_MODEL_NAME}", - "model": { - "name": "${AZURE_ENV_GPT_MODEL_NAME}", - "version": "${AZURE_ENV_GPT_MODEL_VERSION}" - }, - "sku": { - "name": "${AZURE_ENV_MODEL_DEPLOYMENT_TYPE}", - "capacity": "${AZURE_ENV_GPT_MODEL_CAPACITY}" - } - } - ] - } - } -} \ No newline at end of file From 965f2d34c862921f88bde05710fbaf54de9cc80c Mon Sep 17 00:00:00 2001 From: "Ashwal Vishwanath (Persistent Systems Inc)" Date: Tue, 21 Apr 2026 14:27:18 +0530 Subject: [PATCH 4/4] fix: replace websocket client transport with polling --- src/frontend/src/api/WebSocketService.tsx | 129 ++++++++++++++-------- 1 file changed, 86 insertions(+), 43 deletions(-) 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 {