diff --git a/backend/app/main.py b/backend/app/main.py
index 61320f1..17f9971 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -2,9 +2,20 @@ from fastapi import FastAPI, Depends, HTTPException, WebSocket, WebSocketDisconn
from fastapi.middleware.cors import CORSMiddleware
from typing import List
import json
+import logging
+import asyncio
+import redis.asyncio as redis
from app.core.config import settings
from app.api.api import api_router
+# Set up logging
+logging.basicConfig(level=logging.INFO)
+logger = logging.getLogger(__name__)
+
+# Redis client for pub/sub (cross-worker communication)
+redis_client = None
+pubsub = None
+
app = FastAPI(
title=settings.project_name,
openapi_url=f"{settings.api_v1_str}/openapi.json",
@@ -25,28 +36,117 @@ app.add_middleware(
class ConnectionManager:
def __init__(self):
self.active_connections: List[WebSocket] = []
+ self.redis_listener_task = None
async def connect(self, websocket: WebSocket):
await websocket.accept()
self.active_connections.append(websocket)
+ logger.info(f"WebSocket connected. Total connections: {len(self.active_connections)}")
def disconnect(self, websocket: WebSocket):
- self.active_connections.remove(websocket)
+ if websocket in self.active_connections:
+ self.active_connections.remove(websocket)
+ logger.info(f"WebSocket disconnected. Total connections: {len(self.active_connections)}")
async def send_personal_message(self, message: str, websocket: WebSocket):
await websocket.send_text(message)
- async def broadcast(self, message: dict):
- message_str = json.dumps(message)
+ async def broadcast_local(self, message_str: str):
+ """Broadcast to connections on this worker only"""
+ dead_connections = []
for connection in self.active_connections:
try:
await connection.send_text(message_str)
- except:
- # Connection is dead, remove it
+ except Exception as e:
+ logger.warning(f"Failed to send to connection: {e}")
+ dead_connections.append(connection)
+
+ # Remove dead connections
+ for connection in dead_connections:
+ if connection in self.active_connections:
self.active_connections.remove(connection)
+
+ if dead_connections:
+ logger.info(f"Removed {len(dead_connections)} dead connections")
+
+ async def broadcast(self, message: dict):
+ """Broadcast via Redis pub/sub to all workers"""
+ message_str = json.dumps(message)
+ print(f"Publishing message to Redis channel: {message.get('type', 'unknown')}")
+ logger.info(f"Publishing message to Redis channel: {message.get('type', 'unknown')}")
+
+ try:
+ if redis_client:
+ await redis_client.publish('ppr_updates', message_str)
+ print(f"✓ Message published to Redis")
+ else:
+ # Fallback to local broadcast if Redis not available
+ print("⚠ Redis not available, falling back to local broadcast")
+ logger.warning("Redis not available, falling back to local broadcast")
+ await self.broadcast_local(message_str)
+ except Exception as e:
+ print(f"✗ Failed to publish to Redis: {e}")
+ logger.error(f"Failed to publish to Redis: {e}")
+ # Fallback to local broadcast
+ await self.broadcast_local(message_str)
+
+ async def start_redis_listener(self):
+ """Listen for Redis pub/sub messages and broadcast to local connections"""
+ global redis_client, pubsub
+
+ try:
+ # Connect to Redis
+ redis_url = settings.redis_url or "redis://redis:6379"
+ print(f"Connecting to Redis at: {redis_url}")
+ redis_client = await redis.from_url(redis_url, encoding="utf-8", decode_responses=True)
+ pubsub = redis_client.pubsub()
+ await pubsub.subscribe('ppr_updates')
+
+ print("✓ Redis listener started for PPR updates")
+ logger.info("Redis listener started for PPR updates")
+
+ async for message in pubsub.listen():
+ if message['type'] == 'message':
+ message_data = message['data']
+ print(f"Received Redis message, broadcasting to {len(self.active_connections)} local connections")
+ logger.info(f"Received Redis message, broadcasting to {len(self.active_connections)} local connections")
+ await self.broadcast_local(message_data)
+
+ except Exception as e:
+ print(f"Redis listener error: {e}")
+ logger.error(f"Redis listener error: {e}")
+ await asyncio.sleep(5) # Wait before retry
+ # Retry connection
+ if self.redis_listener_task and not self.redis_listener_task.done():
+ asyncio.create_task(self.start_redis_listener())
manager = ConnectionManager()
+@app.on_event("startup")
+async def startup_event():
+ """Start Redis listener when application starts"""
+ print("=" * 50)
+ print("STARTUP: Starting application and Redis listener...")
+ print("=" * 50)
+ logger.info("Starting application and Redis listener...")
+ manager.redis_listener_task = asyncio.create_task(manager.start_redis_listener())
+
+@app.on_event("shutdown")
+async def shutdown_event():
+ """Clean up Redis connections on shutdown"""
+ logger.info("Shutting down application...")
+ global redis_client, pubsub
+
+ if manager.redis_listener_task:
+ manager.redis_listener_task.cancel()
+
+ if pubsub:
+ await pubsub.unsubscribe('ppr_updates')
+ await pubsub.close()
+
+ if redis_client:
+ await redis_client.close()
+
@app.websocket("/ws/tower-updates")
async def websocket_endpoint(websocket: WebSocket):
await manager.connect(websocket)
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
index ebcb26a..127a7b9 100644
--- a/docker-compose.prod.yml
+++ b/docker-compose.prod.yml
@@ -71,10 +71,18 @@ services:
image: nginx:alpine
container_name: ppr_prod_web
restart: always
+ environment:
+ BASE_URL: ${BASE_URL}
+ command: >
+ sh -c "cp /usr/share/nginx/html/ppr.html /tmp/ppr.html.orig &&
+ sed 's|__BASE_URL__|'"\$BASE_URL"'|g' /tmp/ppr.html.orig > /usr/share/nginx/html/ppr.html &&
+ cp /usr/share/nginx/html/index.html /tmp/index.html.orig &&
+ sed 's|__BASE_URL__|'"\$BASE_URL"'|g' /tmp/index.html.orig > /usr/share/nginx/html/index.html &&
+ nginx -g 'daemon off;'"
ports:
- "${WEB_PORT_EXTERNAL}:80"
volumes:
- - ./web:/usr/share/nginx/html:ro
+ - ./web:/usr/share/nginx/html
- ./nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
- api
diff --git a/docker-compose.yml b/docker-compose.yml
index a921a05..6d628a8 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -62,6 +62,14 @@ services:
image: nginx:alpine
container_name: ppr_nextgen_web
restart: unless-stopped
+ environment:
+ BASE_URL: ${BASE_URL}
+ command: >
+ sh -c "sed 's|__BASE_URL__|'"\$BASE_URL"'|g' /usr/share/nginx/html/ppr.html > /tmp/ppr.html &&
+ mv /tmp/ppr.html /usr/share/nginx/html/ppr.html &&
+ sed 's|__BASE_URL__|'"\$BASE_URL"'|g' /usr/share/nginx/html/index.html > /tmp/index.html &&
+ mv /tmp/index.html /usr/share/nginx/html/index.html &&
+ nginx -g 'daemon off;'"
ports:
- "${WEB_PORT_EXTERNAL}:80" # Public web interface
volumes:
diff --git a/nginx.conf b/nginx.conf
index 8684f39..95eec5f 100644
--- a/nginx.conf
+++ b/nginx.conf
@@ -29,9 +29,19 @@ http {
root /usr/share/nginx/html;
index index.html;
+ # Allow ppr.html to be embedded in iframes from any origin
+ location = /ppr.html {
+ add_header X-Frame-Options "ALLOWALL" always;
+ add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'; frame-ancestors *" always;
+ add_header X-Content-Type-Options "nosniff" always;
+ try_files $uri =404;
+ }
+
# Serve static files
location / {
try_files $uri $uri/ /index.html;
+ # Apply X-Frame-Options to other files
+ add_header X-Frame-Options "SAMEORIGIN" always;
}
# Proxy API requests to FastAPI backend
@@ -56,10 +66,10 @@ http {
}
# Security headers
- add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
- add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always;
+ # Default CSP for other files
+ add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'; frame-ancestors 'self'" always;
}
}
\ No newline at end of file
diff --git a/web/admin.html b/web/admin.html
index 663915b..044cd3e 100644
--- a/web/admin.html
+++ b/web/admin.html
@@ -2308,20 +2308,9 @@
if (matches.length === 0) {
resultsDiv.innerHTML = '
No matches found - will use as entered
';
- } else if (matches.length === 1) {
- // Unique match found - auto-populate with ICAO code
- const airport = matches[0];
- resultsDiv.innerHTML = `
-
- ✓ ${airport.name} (${airport.icao})
-
- `;
-
- // Auto-populate with ICAO code
- document.getElementById('in_from').value = airport.icao;
-
} else {
- // Multiple matches - show clickable list
+ // Show matches as clickable options (single or multiple)
+ const matchText = matches.length === 1 ? 'Match found - click to select:' : 'Multiple matches found - select one:';
const listHtml = matches.map(airport => `
@@ -2334,7 +2323,7 @@
resultsDiv.innerHTML = `
- Multiple matches found - select one:
+ ${matchText}
${listHtml}
@@ -2348,20 +2337,9 @@
if (matches.length === 0) {
resultsDiv.innerHTML = '
No matches found - will use as entered
';
- } else if (matches.length === 1) {
- // Unique match found - auto-populate with ICAO code
- const airport = matches[0];
- resultsDiv.innerHTML = `
-
- ✓ ${airport.name} (${airport.icao})
-
- `;
-
- // Auto-populate with ICAO code
- document.getElementById('out_to').value = airport.icao;
-
} else {
- // Multiple matches - show clickable list
+ // Show matches as clickable options (single or multiple)
+ const matchText = matches.length === 1 ? 'Match found - click to select:' : 'Multiple matches found - select one:';
const listHtml = matches.map(airport => `
@@ -2374,7 +2352,7 @@
resultsDiv.innerHTML = `
- Multiple matches found - select one:
+ ${matchText}
${listHtml}
diff --git a/web/assets/flightImg.png b/web/assets/flightImg.png
new file mode 100644
index 0000000..52c93f1
Binary files /dev/null and b/web/assets/flightImg.png differ
diff --git a/web/assets/logo.png b/web/assets/logo.png
new file mode 100644
index 0000000..1a16a89
Binary files /dev/null and b/web/assets/logo.png differ
diff --git a/web/index.html b/web/index.html
index af05fb0..63178f9 100644
--- a/web/index.html
+++ b/web/index.html
@@ -2,431 +2,354 @@
+
-
Airfield PPR - Arrivals & Departures
+
Swansea Airport - Arrivals & Departures
-
-