Gone live

This commit is contained in:
James Pattinson
2025-12-07 15:02:51 +00:00
parent 3780b3cf2f
commit 4d71d59d90
10 changed files with 542 additions and 401 deletions

View File

@@ -2,9 +2,20 @@ from fastapi import FastAPI, Depends, HTTPException, WebSocket, WebSocketDisconn
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from typing import List from typing import List
import json import json
import logging
import asyncio
import redis.asyncio as redis
from app.core.config import settings from app.core.config import settings
from app.api.api import api_router 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( app = FastAPI(
title=settings.project_name, title=settings.project_name,
openapi_url=f"{settings.api_v1_str}/openapi.json", openapi_url=f"{settings.api_v1_str}/openapi.json",
@@ -25,28 +36,117 @@ app.add_middleware(
class ConnectionManager: class ConnectionManager:
def __init__(self): def __init__(self):
self.active_connections: List[WebSocket] = [] self.active_connections: List[WebSocket] = []
self.redis_listener_task = None
async def connect(self, websocket: WebSocket): async def connect(self, websocket: WebSocket):
await websocket.accept() await websocket.accept()
self.active_connections.append(websocket) self.active_connections.append(websocket)
logger.info(f"WebSocket connected. Total connections: {len(self.active_connections)}")
def disconnect(self, websocket: WebSocket): 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): async def send_personal_message(self, message: str, websocket: WebSocket):
await websocket.send_text(message) await websocket.send_text(message)
async def broadcast(self, message: dict): async def broadcast_local(self, message_str: str):
message_str = json.dumps(message) """Broadcast to connections on this worker only"""
dead_connections = []
for connection in self.active_connections: for connection in self.active_connections:
try: try:
await connection.send_text(message_str) await connection.send_text(message_str)
except: except Exception as e:
# Connection is dead, remove it 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) 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() 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") @app.websocket("/ws/tower-updates")
async def websocket_endpoint(websocket: WebSocket): async def websocket_endpoint(websocket: WebSocket):
await manager.connect(websocket) await manager.connect(websocket)

View File

@@ -71,10 +71,18 @@ services:
image: nginx:alpine image: nginx:alpine
container_name: ppr_prod_web container_name: ppr_prod_web
restart: always 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: ports:
- "${WEB_PORT_EXTERNAL}:80" - "${WEB_PORT_EXTERNAL}:80"
volumes: volumes:
- ./web:/usr/share/nginx/html:ro - ./web:/usr/share/nginx/html
- ./nginx.conf:/etc/nginx/nginx.conf:ro - ./nginx.conf:/etc/nginx/nginx.conf:ro
depends_on: depends_on:
- api - api

View File

@@ -62,6 +62,14 @@ services:
image: nginx:alpine image: nginx:alpine
container_name: ppr_nextgen_web container_name: ppr_nextgen_web
restart: unless-stopped 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: ports:
- "${WEB_PORT_EXTERNAL}:80" # Public web interface - "${WEB_PORT_EXTERNAL}:80" # Public web interface
volumes: volumes:

View File

@@ -29,9 +29,19 @@ http {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.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 # Serve static files
location / { location / {
try_files $uri $uri/ /index.html; 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 # Proxy API requests to FastAPI backend
@@ -56,10 +66,10 @@ http {
} }
# Security headers # Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always; add_header X-XSS-Protection "1; mode=block" always;
add_header X-Content-Type-Options "nosniff" always; add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer-when-downgrade" 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;
} }
} }

View File

@@ -2308,20 +2308,9 @@
if (matches.length === 0) { if (matches.length === 0) {
resultsDiv.innerHTML = '<div class="airport-no-match">No matches found - will use as entered</div>'; resultsDiv.innerHTML = '<div class="airport-no-match">No matches found - will use as entered</div>';
} else if (matches.length === 1) {
// Unique match found - auto-populate with ICAO code
const airport = matches[0];
resultsDiv.innerHTML = `
<div class="airport-match">
${airport.name} (${airport.icao})
</div>
`;
// Auto-populate with ICAO code
document.getElementById('in_from').value = airport.icao;
} else { } 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 => ` const listHtml = matches.map(airport => `
<div class="airport-option" onclick="selectArrivalAirport('${airport.icao}')"> <div class="airport-option" onclick="selectArrivalAirport('${airport.icao}')">
<div> <div>
@@ -2334,7 +2323,7 @@
resultsDiv.innerHTML = ` resultsDiv.innerHTML = `
<div class="airport-no-match" style="margin-bottom: 0.5rem;"> <div class="airport-no-match" style="margin-bottom: 0.5rem;">
Multiple matches found - select one: ${matchText}
</div> </div>
<div class="airport-list"> <div class="airport-list">
${listHtml} ${listHtml}
@@ -2348,20 +2337,9 @@
if (matches.length === 0) { if (matches.length === 0) {
resultsDiv.innerHTML = '<div class="airport-no-match">No matches found - will use as entered</div>'; resultsDiv.innerHTML = '<div class="airport-no-match">No matches found - will use as entered</div>';
} else if (matches.length === 1) {
// Unique match found - auto-populate with ICAO code
const airport = matches[0];
resultsDiv.innerHTML = `
<div class="airport-match">
${airport.name} (${airport.icao})
</div>
`;
// Auto-populate with ICAO code
document.getElementById('out_to').value = airport.icao;
} else { } 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 => ` const listHtml = matches.map(airport => `
<div class="airport-option" onclick="selectDepartureAirport('${airport.icao}')"> <div class="airport-option" onclick="selectDepartureAirport('${airport.icao}')">
<div> <div>
@@ -2374,7 +2352,7 @@
resultsDiv.innerHTML = ` resultsDiv.innerHTML = `
<div class="airport-no-match" style="margin-bottom: 0.5rem;"> <div class="airport-no-match" style="margin-bottom: 0.5rem;">
Multiple matches found - select one: ${matchText}
</div> </div>
<div class="airport-list"> <div class="airport-list">
${listHtml} ${listHtml}

BIN
web/assets/flightImg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

BIN
web/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -2,431 +2,354 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta http-equiv="refresh" content="300">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Airfield PPR - Arrivals & Departures</title> <title>Swansea Airport - Arrivals & Departures</title>
<style> <style>
* { /* Overall page styling */
margin: 0;
padding: 0;
box-sizing: border-box;
}
body { body {
font-family: 'Arial', sans-serif; margin: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); font-family: Arial, sans-serif;
color: #333;
min-height: 100vh;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.header {
text-align: center;
color: white;
margin-bottom: 30px;
}
.header h1 {
font-size: 2.5rem;
margin-bottom: 10px;
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
}
.header p {
font-size: 1.2rem;
opacity: 0.9;
}
.boards {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-rows: auto 1fr auto;
gap: 30px; grid-template-columns: 1fr;
margin-bottom: 20px; height: 100vh;
font-size: 30px; /* Increased font size */
} }
@media (max-width: 768px) { /* Header styles */
.boards { header {
grid-template-columns: 1fr; background-color: #333;
}
}
.board {
background: white;
border-radius: 15px;
overflow: hidden;
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
}
.board-header {
background: linear-gradient(45deg, #28a745, #20c997);
color: white; color: white;
padding: 20px; padding: 20px;
text-align: center; text-align: center;
}
.board-header.departures {
background: linear-gradient(45deg, #dc3545, #fd7e14);
}
.board-header h2 {
font-size: 1.5rem;
margin-bottom: 5px;
}
.board-content {
min-height: 400px;
position: relative; position: relative;
} }
.loading { header img.left-image {
display: flex; position: absolute;
justify-content: center; top: 0;
align-items: center; left: 0;
height: 400px; height: auto;
font-size: 1.1rem;
color: #666;
} }
.spinner { header img.right-image {
border: 4px solid #f3f3f3; position: absolute;
border-top: 4px solid #667eea; top: 0;
border-radius: 50%; right: 0;
width: 40px; width: 9%;
height: 40px; height: auto;
animation: spin 1s linear infinite;
margin-right: 10px;
} }
@keyframes spin { /* Main section styles */
0% { transform: rotate(0deg); } main {
100% { transform: rotate(360deg); }
}
.ppr-list {
padding: 0;
}
.ppr-item {
display: grid; display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr; grid-template-columns: 1fr 1fr; /* Two equal-width columns */
gap: 15px; gap: 20px;
padding: 15px 20px; padding: 20px;
border-bottom: 1px solid #eee; overflow-y: auto;
align-items: center;
} }
.ppr-item:last-child { /* Table styles */
border-bottom: none; table {
width: 100%;
border-collapse: collapse;
margin: 0;
border: 1px solid #ccc;
} }
.ppr-item:hover { th, td {
background: #f8f9fa; padding: 12px;
text-align: left;
border: 1px solid #ccc;
} }
.ppr-field { th {
font-size: 0.9rem; background-color: #f4f4f4;
} }
.ppr-field strong { tr:nth-child(even) {
display: block; background-color: #d3d3d3;
color: #495057;
font-size: 0.8rem;
margin-bottom: 2px;
text-transform: uppercase;
font-weight: 600;
} }
.ppr-field span { /* Footer styles */
font-size: 1rem; footer {
color: #212529; background-color: #333;
} color: white;
.status {
display: inline-block;
padding: 4px 8px;
border-radius: 12px;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
}
.status.new { background: #e3f2fd; color: #1565c0; }
.status.confirmed { background: #e8f5e8; color: #2e7d32; }
.status.landed { background: #fff3e0; color: #ef6c00; }
.status.departed { background: #fce4ec; color: #c2185b; }
.no-data {
text-align: center; text-align: center;
padding: 40px 20px; padding: 10px 0;
position: relative;
overflow: hidden;
}
/* Marquee container */
.marquee {
display: inline-block;
white-space: nowrap;
padding-right: 100%; /* This makes the text start out of view */
animation: scroll-left 20s linear infinite;
}
/* Keyframes for scrolling animation */
@keyframes scroll-left {
from {
transform: translateX(100%);
}
to {
transform: translateX(-100%);
}
}
/* Marquee text styling */
.marquee-text {
font-size: 18px;
font-weight: bold;
color: #f4f4f4;
padding-left: 50px;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.2);
}
/* Loading indicator */
.loading {
text-align: center;
padding: 20px;
color: #666; color: #666;
} }
.no-data i { /* Error message */
font-size: 3rem; .error {
margin-bottom: 15px;
opacity: 0.3;
}
.last-updated {
text-align: center; text-align: center;
color: white; padding: 20px;
opacity: 0.8; color: #e74c3c;
margin-top: 20px;
} }
.auto-refresh { /* Responsive adjustments */
background: rgba(255,255,255,0.1); @media (max-width: 768px) {
color: white; main {
border: 1px solid rgba(255,255,255,0.2); grid-template-columns: 1fr; /* Stack columns on smaller screens */
padding: 8px 16px; }
border-radius: 20px;
font-size: 0.9rem;
margin-top: 10px;
} }
</style> </style>
</head> </head>
<body> <body>
<div class="container"> <header>
<div class="header"> <img src="assets/logo.png" alt="EGFH Logo" class="left-image">
<h1>✈️ Airfield PPR System</h1> <h1>Arrivals/Departures Information</h1>
<p>Real-time Arrivals & Departures Board</p> <img src="assets/flightImg.png" alt="EGFH Logo" class="right-image">
</header>
<main>
<!-- Left column with arrivals table -->
<div>
<h2><center>Arrivals</center></h2>
<table>
<thead>
<tr>
<th>Registration</th>
<th>Aircraft Type</th>
<th>From</th>
<th>Due</th>
</tr>
</thead>
<tbody id="arrivals-tbody">
<tr>
<td colspan="4" class="loading">Loading arrivals...</td>
</tr>
</tbody>
</table>
</div> </div>
<div class="boards"> <!-- Right column with departures table -->
<!-- Arrivals Board --> <div>
<div class="board"> <h2><center>Departures</center></h2>
<div class="board-header"> <table>
<h2>🛬 Today's Arrivals</h2> <thead>
<div id="arrivals-count">Loading...</div> <tr>
</div> <th>Registration</th>
<div class="board-content"> <th>Aircraft Type</th>
<div id="arrivals-loading" class="loading"> <th>To</th>
<div class="spinner"></div> <th>Due</th>
Loading arrivals... </tr>
</div> </thead>
<div id="arrivals-list" class="ppr-list" style="display: none;"></div> <tbody id="departures-tbody">
</div> <tr>
</div> <td colspan="4" class="loading">Loading departures...</td>
</tr>
</tbody>
</table>
</div>
</main>
<!-- Departures Board --> <footer>
<div class="board"> <!-- Footer content -->
<div class="board-header departures"> <div class="iso-marquee-linkwrap">
<h2>🛫 Today's Departures</h2> <div class="iso-marquee--long iso-marquee">
<div id="departures-count">Loading...</div> <!-- Add marquee content here -->
</div>
<div class="board-content">
<div id="departures-loading" class="loading">
<div class="spinner"></div>
Loading departures...
</div>
<div id="departures-list" class="ppr-list" style="display: none;"></div>
</div>
</div> </div>
</div> </div>
</footer>
<div class="last-updated">
<div>Last updated: <span id="last-updated">Never</span></div>
<div class="auto-refresh">🔄 Auto-refresh every 30 seconds</div>
</div>
</div>
<script> <script>
let refreshInterval; let wsConnection = null;
// Format datetime for display // WebSocket connection for real-time updates
function formatDateTime(dateStr) { function connectWebSocket() {
if (!dateStr) return 'N/A'; if (wsConnection && wsConnection.readyState === WebSocket.OPEN) {
const date = new Date(dateStr); return; // Already connected
return date.toLocaleTimeString('en-GB', { }
hour: '2-digit',
minute: '2-digit'
});
}
// Format status for display const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
function formatStatus(status) { const wsUrl = `${protocol}//${window.location.host}/ws/tower-updates`;
return `<span class="status ${status.toLowerCase()}">${status}</span>`;
}
// ICAO code to airport name cache wsConnection = new WebSocket(wsUrl);
const airportNameCache = {};
async function getAirportDisplay(code) { wsConnection.onopen = function(event) {
if (!code || code.length !== 4 || !/^[A-Z]{4}$/.test(code)) return code; console.log('WebSocket connected for real-time updates');
if (airportNameCache[code]) return `${code}<br><span style="font-size:0.8em;color:#888;font-style:italic;">${airportNameCache[code]}</span>`; };
try {
const resp = await fetch(`/api/v1/airport/lookup/${code}`); wsConnection.onmessage = function(event) {
if (resp.ok) { try {
const data = await resp.json(); const data = JSON.parse(event.data);
if (data && data.length && data[0].name) { console.log('WebSocket message received:', data);
airportNameCache[code] = data[0].name;
return `${code}<br><span style="font-size:0.8em;color:#888;font-style:italic;">${data[0].name}</span>`; // Refresh display when any PPR-related event occurs
if (data.type && (data.type.includes('ppr_') || data.type === 'status_update')) {
console.log('PPR update detected, refreshing display...');
loadArrivals();
loadDepartures();
} }
} catch (error) {
console.error('Error parsing WebSocket message:', error);
} }
} catch {} };
return code;
wsConnection.onclose = function(event) {
console.log('WebSocket disconnected');
// Attempt to reconnect after 5 seconds
setTimeout(() => {
console.log('Attempting to reconnect WebSocket...');
connectWebSocket();
}, 5000);
};
wsConnection.onerror = function(error) {
console.error('WebSocket error:', error);
};
} }
// Create PPR item HTML (async) // Convert UTC time to local time (Europe/London)
async function createPPRItem(ppr) { function convertToLocalTime(utcTimeString) {
// Display callsign as main item if present, registration below; otherwise show registration if (!utcTimeString) return '';
const aircraftDisplay = ppr.ac_call && ppr.ac_call.trim() ?
`${ppr.ac_call}<br><span style="font-size: 0.85em; color: #888; font-style: italic;">${ppr.ac_reg}</span>` : // Parse the time string (format: HH:MM or HH:MM:SS)
ppr.ac_reg; const timeParts = utcTimeString.split(':');
// Lookup airport name for in_from/out_to if (timeParts.length < 2) return utcTimeString;
let fromDisplay = ppr.in_from;
if (ppr.in_from && ppr.in_from.length === 4 && /^[A-Z]{4}$/.test(ppr.in_from)) { // Create a date object with today's date and the UTC time
fromDisplay = await getAirportDisplay(ppr.in_from); const now = new Date();
} const utcDate = new Date(Date.UTC(
let toDisplay = ppr.out_to; now.getUTCFullYear(),
if (ppr.out_to && ppr.out_to.length === 4 && /^[A-Z]{4}$/.test(ppr.out_to)) { now.getUTCMonth(),
toDisplay = await getAirportDisplay(ppr.out_to); now.getUTCDate(),
} parseInt(timeParts[0]),
return ` parseInt(timeParts[1]),
<div class="ppr-item"> timeParts.length > 2 ? parseInt(timeParts[2]) : 0
<div class="ppr-field"> ));
<strong>Aircraft</strong>
<span>${aircraftDisplay}</span> // Convert to local time
</div> const localHours = utcDate.getHours().toString().padStart(2, '0');
<div class="ppr-field"> const localMinutes = utcDate.getMinutes().toString().padStart(2, '0');
<strong>Type</strong>
<span>${ppr.ac_type}</span> return `${localHours}:${localMinutes}`;
</div>
<div class="ppr-field">
<strong>From</strong>
<span>${fromDisplay || '-'}</span>
</div>
<div class="ppr-field">
<strong>To</strong>
<span>${toDisplay || '-'}</span>
</div>
<div class="ppr-field">
<strong>Time</strong>
<span>${formatDateTime(ppr.eta || ppr.etd)}</span>
</div>
<div class="ppr-field">
<strong>Status</strong>
<span>${formatStatus(ppr.status)}</span>
</div>
</div>
`;
}
// Create no data message
function createNoDataMessage(type) {
return `
<div class="no-data">
<div style="font-size: 3rem; margin-bottom: 15px; opacity: 0.3;">
${type === 'arrivals' ? '🛬' : '🛫'}
</div>
<div>No ${type} scheduled for today</div>
</div>
`;
} }
// Fetch and display arrivals // Fetch and display arrivals
async function loadArrivals() { async function loadArrivals() {
const tbody = document.getElementById('arrivals-tbody');
try { try {
const response = await fetch('/api/v1/public/arrivals'); const response = await fetch('/api/v1/public/arrivals');
const arrivals = await response.json();
const loadingEl = document.getElementById('arrivals-loading'); if (!response.ok) {
const listEl = document.getElementById('arrivals-list'); throw new Error('Failed to fetch arrivals');
const countEl = document.getElementById('arrivals-count');
loadingEl.style.display = 'none';
listEl.style.display = 'block';
if (arrivals.length === 0) {
listEl.innerHTML = createNoDataMessage('arrivals');
countEl.textContent = '0 flights';
} else {
// Render each item async
const htmlArr = [];
for (const ppr of arrivals) {
htmlArr.push(await createPPRItem(ppr));
}
listEl.innerHTML = htmlArr.join('');
countEl.textContent = `${arrivals.length} flight${arrivals.length !== 1 ? 's' : ''}`;
} }
const arrivals = await response.json();
if (arrivals.length === 0) {
tbody.innerHTML = '<tr><td colspan="4">No arrivals found.</td></tr>';
return;
}
tbody.innerHTML = arrivals.map(arrival => `
<tr>
<td>${escapeHtml(arrival.ac_reg || '')}</td>
<td>${escapeHtml(arrival.ac_type || '')}</td>
<td>${escapeHtml(arrival.in_from || '')}</td>
<td>${convertToLocalTime(arrival.eta)}</td>
</tr>
`).join('');
} catch (error) { } catch (error) {
console.error('Error loading arrivals:', error); console.error('Error loading arrivals:', error);
document.getElementById('arrivals-list').innerHTML = ` tbody.innerHTML = '<tr><td colspan="4" class="error">Error loading arrivals</td></tr>';
<div class="no-data">
<div style="color: #dc3545;">❌ Error loading arrivals</div>
</div>
`;
document.getElementById('arrivals-loading').style.display = 'none';
document.getElementById('arrivals-list').style.display = 'block';
} }
} }
// Fetch and display departures // Fetch and display departures
async function loadDepartures() { async function loadDepartures() {
const tbody = document.getElementById('departures-tbody');
try { try {
const response = await fetch('/api/v1/public/departures'); const response = await fetch('/api/v1/public/departures');
const departures = await response.json();
const loadingEl = document.getElementById('departures-loading'); if (!response.ok) {
const listEl = document.getElementById('departures-list'); throw new Error('Failed to fetch departures');
const countEl = document.getElementById('departures-count');
loadingEl.style.display = 'none';
listEl.style.display = 'block';
if (departures.length === 0) {
listEl.innerHTML = createNoDataMessage('departures');
countEl.textContent = '0 flights';
} else {
// Render each item async
const htmlArr = [];
for (const ppr of departures) {
htmlArr.push(await createPPRItem(ppr));
}
listEl.innerHTML = htmlArr.join('');
countEl.textContent = `${departures.length} flight${departures.length !== 1 ? 's' : ''}`;
} }
const departures = await response.json();
if (departures.length === 0) {
tbody.innerHTML = '<tr><td colspan="4">No departures found.</td></tr>';
return;
}
tbody.innerHTML = departures.map(departure => `
<tr>
<td>${escapeHtml(departure.ac_reg || '')}</td>
<td>${escapeHtml(departure.ac_type || '')}</td>
<td>${escapeHtml(departure.out_to || '')}</td>
<td>${convertToLocalTime(departure.etd)}</td>
</tr>
`).join('');
} catch (error) { } catch (error) {
console.error('Error loading departures:', error); console.error('Error loading departures:', error);
document.getElementById('departures-list').innerHTML = ` tbody.innerHTML = '<tr><td colspan="4" class="error">Error loading departures</td></tr>';
<div class="no-data">
<div style="color: #dc3545;">❌ Error loading departures</div>
</div>
`;
document.getElementById('departures-loading').style.display = 'none';
document.getElementById('departures-list').style.display = 'block';
} }
} }
// Load both arrivals and departures // Escape HTML to prevent XSS
async function loadData() { function escapeHtml(text) {
await Promise.all([loadArrivals(), loadDepartures()]); const div = document.createElement('div');
document.getElementById('last-updated').textContent = new Date().toLocaleTimeString('en-GB'); div.textContent = text;
return div.innerHTML;
} }
// Initialize the page // Load data on page load
async function init() { window.addEventListener('load', function() {
await loadData(); loadArrivals();
loadDepartures();
// Set up auto-refresh every 30 seconds // Connect to WebSocket for real-time updates
refreshInterval = setInterval(loadData, 30000); connectWebSocket();
}
// Refresh data every 60 seconds as fallback
// Start the application setInterval(() => {
init(); loadArrivals();
loadDepartures();
// Handle page visibility change to pause/resume refresh }, 60000);
document.addEventListener('visibilitychange', function() {
if (document.visibilityState === 'visible') {
if (!refreshInterval) {
loadData();
refreshInterval = setInterval(loadData, 30000);
}
} else {
if (refreshInterval) {
clearInterval(refreshInterval);
refreshInterval = null;
}
}
}); });
</script> </script>
</body> </body>
</html> </html>

View File

@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Swansea PPR Request</title> <title>Swansea PPR</title>
<style> <style>
* { * {
margin: 0; margin: 0;
@@ -254,13 +254,91 @@
color: #6c757d; color: #6c757d;
font-size: 0.85rem; font-size: 0.85rem;
} }
/* Mobile responsive styles */
@media (max-width: 768px) {
.container {
margin: 1rem auto;
padding: 1rem;
}
.header h1 {
font-size: 1.3rem;
}
.header p {
font-size: 1rem;
}
.form-grid {
grid-template-columns: 1fr; /* Single column on mobile */
gap: 0.8rem;
}
.form-group input, .form-group select, .form-group textarea {
padding: 0.7rem;
font-size: 1rem; /* Prevent zoom on iOS */
}
.form-actions {
flex-direction: column;
align-items: stretch;
}
.btn {
width: 100%;
padding: 1rem;
font-size: 1rem;
}
.notification {
font-size: 0.9rem;
padding: 1rem;
}
.success-message {
padding: 1.5rem;
font-size: 0.95rem;
}
.loading {
padding: 2rem;
}
.airport-lookup-results, .aircraft-lookup-results {
max-height: 200px;
font-size: 0.9rem;
}
.aircraft-option, .airport-option {
padding: 0.8rem;
font-size: 0.9rem;
}
}
/* Extra small screens */
@media (max-width: 480px) {
.container {
margin: 0.5rem;
padding: 0.8rem;
}
.header {
margin-bottom: 1.5rem;
}
.form-grid {
gap: 0.6rem;
}
}
</style> </style>
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<div class="header"> <div class="header">
<h1>✈️ Swansea Airport PPR Request</h1> <h1>✈️ PPR Request</h1>
<p>Please fill out the form below to submit a Prior Permission Required (PPR) request for Swansea Airport.</p> <p>Please fill out the form below to submit a PPR request for Swansea Airport.</p>
<p>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.</p>
</div> </div>
<form id="ppr-form"> <form id="ppr-form">
@@ -354,9 +432,9 @@
</div> </div>
<div class="success-message" id="success-message"> <div class="success-message" id="success-message">
<h3>PPR Request Submitted Successfully!</h3> <h3>PPR Request Submitted.</h3>
<p>Your Prior Permission Required request has been submitted and will be reviewed by airport operations. You will receive confirmation via email if provided.</p> <p>Your PPR request has been submitted. You will receive confirmation via email if provided.</p>
<p><strong>Please note:</strong> This is not confirmation of approval. Airport operations will contact you if additional information is required.</p> <p><strong>Please note:</strong> PPR requests are accepted by default. We will contact you if additional information is required. Remember to check NOTAMs before your flight.</p>
</div> </div>
</div> </div>
@@ -364,6 +442,29 @@
<div id="notification" class="notification"></div> <div id="notification" class="notification"></div>
<script> <script>
// API base URL for iframe embedding
const API_BASE = 'https://ppr.swansea-airport.wales/api/v1';
// Iframe resizing functionality
function sendHeightToParent() {
const height = document.body.scrollHeight || document.documentElement.scrollHeight;
if (window.parent !== window) {
window.parent.postMessage({
type: 'setHeight',
height: height + 20 // Add some padding
}, '*');
}
}
// Send height on load and resize
window.addEventListener('load', function() {
sendHeightToParent();
// Also send height after any content changes
setTimeout(sendHeightToParent, 100);
});
window.addEventListener('resize', sendHeightToParent);
// Initialize time dropdowns // Initialize time dropdowns
function initializeTimeDropdowns() { function initializeTimeDropdowns() {
const timeSelects = ['eta-time', 'etd-time']; const timeSelects = ['eta-time', 'etd-time'];
@@ -395,11 +496,15 @@
// Show notification // Show notification
setTimeout(() => { setTimeout(() => {
notification.classList.add('show'); notification.classList.add('show');
// Update iframe height when notification appears
setTimeout(sendHeightToParent, 50);
}, 10); }, 10);
// Hide after 5 seconds // Hide after 5 seconds
setTimeout(() => { setTimeout(() => {
notification.classList.remove('show'); notification.classList.remove('show');
// Update iframe height when notification disappears
setTimeout(sendHeightToParent, 50);
}, 5000); }, 5000);
} }
@@ -419,7 +524,7 @@
aircraftLookupTimeout = setTimeout(async () => { aircraftLookupTimeout = setTimeout(async () => {
try { try {
const response = await fetch(`/api/v1/aircraft/public/lookup/${registration.toUpperCase()}`); const response = await fetch(`${API_BASE}/aircraft/public/lookup/${registration.toUpperCase()}`);
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
if (data && data.length > 0) { if (data && data.length > 0) {
@@ -498,17 +603,19 @@
arrivalAirportLookupTimeout = setTimeout(async () => { arrivalAirportLookupTimeout = setTimeout(async () => {
try { try {
const response = await fetch(`/api/v1/airport/public/lookup/${query.toUpperCase()}`); const response = await fetch(`${API_BASE}/airport/public/lookup/${query.toUpperCase()}`);
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
if (data && data.length > 0) { if (data && data.length > 0) {
if (data.length === 1) { if (data.length === 1) {
// Single match - auto-populate the input field with ICAO code // Single match - show as clickable option
const airport = data[0]; const airport = data[0];
inputField.value = airport.icao;
resultsDiv.innerHTML = ` resultsDiv.innerHTML = `
<div class="lookup-match"> <div class="aircraft-list">
${airport.icao}/${airport.iata || ''} - ${airport.name}, ${airport.country} <div class="aircraft-option" onclick="selectAirport('in_from', '${airport.icao}')">
<div class="aircraft-code">${airport.icao}/${airport.iata || ''}</div>
<div class="aircraft-details">${airport.name}, ${airport.country}</div>
</div>
</div> </div>
`; `;
} else if (data.length <= 10) { } else if (data.length <= 10) {
@@ -555,17 +662,19 @@
departureAirportLookupTimeout = setTimeout(async () => { departureAirportLookupTimeout = setTimeout(async () => {
try { try {
const response = await fetch(`/api/v1/airport/public/lookup/${query.toUpperCase()}`); const response = await fetch(`${API_BASE}/airport/public/lookup/${query.toUpperCase()}`);
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
if (data && data.length > 0) { if (data && data.length > 0) {
if (data.length === 1) { if (data.length === 1) {
// Single match - auto-populate the input field with ICAO code // Single match - show as clickable option
const airport = data[0]; const airport = data[0];
inputField.value = airport.icao;
resultsDiv.innerHTML = ` resultsDiv.innerHTML = `
<div class="lookup-match"> <div class="aircraft-list">
${airport.icao}/${airport.iata || ''} - ${airport.name}, ${airport.country} <div class="aircraft-option" onclick="selectAirport('out_to', '${airport.icao}')">
<div class="aircraft-code">${airport.icao}/${airport.iata || ''}</div>
<div class="aircraft-details">${airport.name}, ${airport.country}</div>
</div>
</div> </div>
`; `;
} else if (data.length <= 10) { } else if (data.length <= 10) {
@@ -653,7 +762,7 @@
document.getElementById('submit-btn').textContent = 'Submitting...'; document.getElementById('submit-btn').textContent = 'Submitting...';
try { try {
const response = await fetch('/api/v1/pprs/public', { const response = await fetch(`${API_BASE}/pprs/public`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -670,6 +779,8 @@
document.getElementById('success-message').style.display = 'block'; document.getElementById('success-message').style.display = 'block';
showNotification('PPR request submitted successfully!'); showNotification('PPR request submitted successfully!');
// Update iframe height after content change
setTimeout(sendHeightToParent, 100);
} else { } else {
const errorData = await response.json().catch(() => ({})); const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `Submission failed: ${response.status}`); throw new Error(errorData.detail || `Submission failed: ${response.status}`);
@@ -677,6 +788,8 @@
} catch (error) { } catch (error) {
console.error('Error submitting PPR:', error); console.error('Error submitting PPR:', error);
showNotification(`Error submitting PPR: ${error.message}`, true); showNotification(`Error submitting PPR: ${error.message}`, true);
// Update iframe height after showing error
setTimeout(sendHeightToParent, 100);
} finally { } finally {
// Hide loading // Hide loading
document.getElementById('loading').style.display = 'none'; document.getElementById('loading').style.display = 'none';

1
web/widgets/iframe.min.js vendored Normal file
View File

@@ -0,0 +1 @@
function initEmbed(e){window.addEventListener("message",function(t){if(t.data.type==="setHeight"){var n=document.getElementById(e);n&&(n.style.height=t.data.height+"px")}})}