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 -
-
-

✈️ Airfield PPR System

-

Real-time Arrivals & Departures Board

+
+ EGFH Logo +

Arrivals/Departures Information

+ EGFH Logo +
+ +
+ +
+

Arrivals

+ + + + + + + + + + + + + + +
RegistrationAircraft TypeFromDue
Loading arrivals...
-
- -
-
-

🛬 Today's Arrivals

-
Loading...
-
-
-
-
- Loading arrivals... -
- -
-
+ +
+

Departures

+ + + + + + + + + + + + + + +
RegistrationAircraft TypeToDue
Loading departures...
+
+
- -
-
-

🛫 Today's Departures

-
Loading...
-
-
-
-
- Loading departures... -
- -
+
+ +
+
+
- -
-
Last updated: Never
-
🔄 Auto-refresh every 30 seconds
-
-
+ - \ No newline at end of file + diff --git a/web/ppr.html b/web/ppr.html index 1dd8d63..7cdf335 100644 --- a/web/ppr.html +++ b/web/ppr.html @@ -3,7 +3,7 @@ - Swansea PPR Request + Swansea PPR
-

✈️ Swansea Airport PPR Request

-

Please fill out the form below to submit a Prior Permission Required (PPR) request for Swansea Airport.

+

✈️ PPR Request

+

Please fill out the form below to submit a PPR request for Swansea Airport.

+

Note that this is a new form, and is under test. Please email james.pattinson@sasalliance.org if you have any issues with the form.

@@ -354,9 +432,9 @@
-

✅ PPR Request Submitted Successfully!

-

Your Prior Permission Required request has been submitted and will be reviewed by airport operations. You will receive confirmation via email if provided.

-

Please note: This is not confirmation of approval. Airport operations will contact you if additional information is required.

+

PPR Request Submitted.

+

Your PPR request has been submitted. You will receive confirmation via email if provided.

+

Please note: PPR requests are accepted by default. We will contact you if additional information is required. Remember to check NOTAMs before your flight.

@@ -364,6 +442,29 @@