Compare commits

...

2 Commits

Author SHA1 Message Date
James Pattinson
39d5c2c9e2 Daily arrivals fix 2025-12-07 15:10:11 +00:00
James Pattinson
4d71d59d90 Gone live 2025-12-07 15:02:51 +00:00
11 changed files with 546 additions and 402 deletions

View File

@@ -53,7 +53,10 @@ class CRUDPPR:
return db.query(PPRRecord).filter(
and_(
func.date(PPRRecord.eta) == today,
PPRRecord.status == PPRStatus.NEW
or_(
PPRRecord.status == PPRStatus.NEW,
PPRRecord.status == PPRStatus.CONFIRMED
)
)
).order_by(PPRRecord.eta).all()

View File

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

View File

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

View File

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

View File

@@ -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;
}
}

View File

@@ -2308,20 +2308,9 @@
if (matches.length === 0) {
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 {
// 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 => `
<div class="airport-option" onclick="selectArrivalAirport('${airport.icao}')">
<div>
@@ -2334,7 +2323,7 @@
resultsDiv.innerHTML = `
<div class="airport-no-match" style="margin-bottom: 0.5rem;">
Multiple matches found - select one:
${matchText}
</div>
<div class="airport-list">
${listHtml}
@@ -2348,20 +2337,9 @@
if (matches.length === 0) {
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 {
// 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 => `
<div class="airport-option" onclick="selectDepartureAirport('${airport.icao}')">
<div>
@@ -2374,7 +2352,7 @@
resultsDiv.innerHTML = `
<div class="airport-no-match" style="margin-bottom: 0.5rem;">
Multiple matches found - select one:
${matchText}
</div>
<div class="airport-list">
${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">
<head>
<meta charset="UTF-8">
<meta http-equiv="refresh" content="300">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Airfield PPR - Arrivals & Departures</title>
<title>Swansea Airport - Arrivals & Departures</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
/* Overall page styling */
body {
font-family: 'Arial', sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
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 {
margin: 0;
font-family: Arial, sans-serif;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 30px;
margin-bottom: 20px;
grid-template-rows: auto 1fr auto;
grid-template-columns: 1fr;
height: 100vh;
font-size: 30px; /* Increased font size */
}
@media (max-width: 768px) {
.boards {
grid-template-columns: 1fr;
}
}
.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);
/* Header styles */
header {
background-color: #333;
color: white;
padding: 20px;
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;
}
.loading {
display: flex;
justify-content: center;
align-items: center;
height: 400px;
font-size: 1.1rem;
color: #666;
header img.left-image {
position: absolute;
top: 0;
left: 0;
height: auto;
}
.spinner {
border: 4px solid #f3f3f3;
border-top: 4px solid #667eea;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin-right: 10px;
header img.right-image {
position: absolute;
top: 0;
right: 0;
width: 9%;
height: auto;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.ppr-list {
padding: 0;
}
.ppr-item {
/* Main section styles */
main {
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr;
gap: 15px;
padding: 15px 20px;
border-bottom: 1px solid #eee;
align-items: center;
grid-template-columns: 1fr 1fr; /* Two equal-width columns */
gap: 20px;
padding: 20px;
overflow-y: auto;
}
.ppr-item:last-child {
border-bottom: none;
/* Table styles */
table {
width: 100%;
border-collapse: collapse;
margin: 0;
border: 1px solid #ccc;
}
.ppr-item:hover {
background: #f8f9fa;
th, td {
padding: 12px;
text-align: left;
border: 1px solid #ccc;
}
.ppr-field {
font-size: 0.9rem;
th {
background-color: #f4f4f4;
}
.ppr-field strong {
display: block;
color: #495057;
font-size: 0.8rem;
margin-bottom: 2px;
text-transform: uppercase;
font-weight: 600;
tr:nth-child(even) {
background-color: #d3d3d3;
}
.ppr-field span {
font-size: 1rem;
color: #212529;
}
.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 {
/* Footer styles */
footer {
background-color: #333;
color: white;
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;
}
.no-data i {
font-size: 3rem;
margin-bottom: 15px;
opacity: 0.3;
}
.last-updated {
/* Error message */
.error {
text-align: center;
color: white;
opacity: 0.8;
margin-top: 20px;
padding: 20px;
color: #e74c3c;
}
.auto-refresh {
background: rgba(255,255,255,0.1);
color: white;
border: 1px solid rgba(255,255,255,0.2);
padding: 8px 16px;
border-radius: 20px;
font-size: 0.9rem;
margin-top: 10px;
/* Responsive adjustments */
@media (max-width: 768px) {
main {
grid-template-columns: 1fr; /* Stack columns on smaller screens */
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>✈️ Airfield PPR System</h1>
<p>Real-time Arrivals & Departures Board</p>
<header>
<img src="assets/logo.png" alt="EGFH Logo" class="left-image">
<h1>Arrivals/Departures Information</h1>
<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 class="boards">
<!-- Arrivals Board -->
<div class="board">
<div class="board-header">
<h2>🛬 Today's Arrivals</h2>
<div id="arrivals-count">Loading...</div>
</div>
<div class="board-content">
<div id="arrivals-loading" class="loading">
<div class="spinner"></div>
Loading arrivals...
</div>
<div id="arrivals-list" class="ppr-list" style="display: none;"></div>
</div>
</div>
<!-- Right column with departures table -->
<div>
<h2><center>Departures</center></h2>
<table>
<thead>
<tr>
<th>Registration</th>
<th>Aircraft Type</th>
<th>To</th>
<th>Due</th>
</tr>
</thead>
<tbody id="departures-tbody">
<tr>
<td colspan="4" class="loading">Loading departures...</td>
</tr>
</tbody>
</table>
</div>
</main>
<!-- Departures Board -->
<div class="board">
<div class="board-header departures">
<h2>🛫 Today's Departures</h2>
<div id="departures-count">Loading...</div>
</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>
<footer>
<!-- Footer content -->
<div class="iso-marquee-linkwrap">
<div class="iso-marquee--long iso-marquee">
<!-- Add marquee content here -->
</div>
</div>
<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>
</footer>
<script>
let refreshInterval;
let wsConnection = null;
// Format datetime for display
function formatDateTime(dateStr) {
if (!dateStr) return 'N/A';
const date = new Date(dateStr);
return date.toLocaleTimeString('en-GB', {
hour: '2-digit',
minute: '2-digit'
});
}
// WebSocket connection for real-time updates
function connectWebSocket() {
if (wsConnection && wsConnection.readyState === WebSocket.OPEN) {
return; // Already connected
}
// Format status for display
function formatStatus(status) {
return `<span class="status ${status.toLowerCase()}">${status}</span>`;
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws/tower-updates`;
// ICAO code to airport name cache
const airportNameCache = {};
wsConnection = new WebSocket(wsUrl);
async function getAirportDisplay(code) {
if (!code || code.length !== 4 || !/^[A-Z]{4}$/.test(code)) return code;
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}`);
if (resp.ok) {
const data = await resp.json();
if (data && data.length && data[0].name) {
airportNameCache[code] = data[0].name;
return `${code}<br><span style="font-size:0.8em;color:#888;font-style:italic;">${data[0].name}</span>`;
wsConnection.onopen = function(event) {
console.log('WebSocket connected for real-time updates');
};
wsConnection.onmessage = function(event) {
try {
const data = JSON.parse(event.data);
console.log('WebSocket message received:', data);
// 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)
async function createPPRItem(ppr) {
// Display callsign as main item if present, registration below; otherwise show registration
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>` :
ppr.ac_reg;
// Lookup airport name for in_from/out_to
let fromDisplay = ppr.in_from;
if (ppr.in_from && ppr.in_from.length === 4 && /^[A-Z]{4}$/.test(ppr.in_from)) {
fromDisplay = await getAirportDisplay(ppr.in_from);
}
let toDisplay = ppr.out_to;
if (ppr.out_to && ppr.out_to.length === 4 && /^[A-Z]{4}$/.test(ppr.out_to)) {
toDisplay = await getAirportDisplay(ppr.out_to);
}
return `
<div class="ppr-item">
<div class="ppr-field">
<strong>Aircraft</strong>
<span>${aircraftDisplay}</span>
</div>
<div class="ppr-field">
<strong>Type</strong>
<span>${ppr.ac_type}</span>
</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>
`;
// Convert UTC time to local time (Europe/London)
function convertToLocalTime(utcTimeString) {
if (!utcTimeString) return '';
// Parse the time string (format: HH:MM or HH:MM:SS)
const timeParts = utcTimeString.split(':');
if (timeParts.length < 2) return utcTimeString;
// Create a date object with today's date and the UTC time
const now = new Date();
const utcDate = new Date(Date.UTC(
now.getUTCFullYear(),
now.getUTCMonth(),
now.getUTCDate(),
parseInt(timeParts[0]),
parseInt(timeParts[1]),
timeParts.length > 2 ? parseInt(timeParts[2]) : 0
));
// Convert to local time
const localHours = utcDate.getHours().toString().padStart(2, '0');
const localMinutes = utcDate.getMinutes().toString().padStart(2, '0');
return `${localHours}:${localMinutes}`;
}
// Fetch and display arrivals
async function loadArrivals() {
const tbody = document.getElementById('arrivals-tbody');
try {
const response = await fetch('/api/v1/public/arrivals');
const arrivals = await response.json();
const loadingEl = document.getElementById('arrivals-loading');
const listEl = document.getElementById('arrivals-list');
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' : ''}`;
if (!response.ok) {
throw new Error('Failed to fetch arrivals');
}
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) {
console.error('Error loading arrivals:', error);
document.getElementById('arrivals-list').innerHTML = `
<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';
tbody.innerHTML = '<tr><td colspan="4" class="error">Error loading arrivals</td></tr>';
}
}
// Fetch and display departures
async function loadDepartures() {
const tbody = document.getElementById('departures-tbody');
try {
const response = await fetch('/api/v1/public/departures');
const departures = await response.json();
const loadingEl = document.getElementById('departures-loading');
const listEl = document.getElementById('departures-list');
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' : ''}`;
if (!response.ok) {
throw new Error('Failed to fetch departures');
}
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) {
console.error('Error loading departures:', error);
document.getElementById('departures-list').innerHTML = `
<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';
tbody.innerHTML = '<tr><td colspan="4" class="error">Error loading departures</td></tr>';
}
}
// Load both arrivals and departures
async function loadData() {
await Promise.all([loadArrivals(), loadDepartures()]);
document.getElementById('last-updated').textContent = new Date().toLocaleTimeString('en-GB');
// Escape HTML to prevent XSS
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Initialize the page
async function init() {
await loadData();
// Load data on page load
window.addEventListener('load', function() {
loadArrivals();
loadDepartures();
// Set up auto-refresh every 30 seconds
refreshInterval = setInterval(loadData, 30000);
}
// Start the application
init();
// Handle page visibility change to pause/resume refresh
document.addEventListener('visibilitychange', function() {
if (document.visibilityState === 'visible') {
if (!refreshInterval) {
loadData();
refreshInterval = setInterval(loadData, 30000);
}
} else {
if (refreshInterval) {
clearInterval(refreshInterval);
refreshInterval = null;
}
}
// Connect to WebSocket for real-time updates
connectWebSocket();
// Refresh data every 60 seconds as fallback
setInterval(() => {
loadArrivals();
loadDepartures();
}, 60000);
});
</script>
</body>
</html>
</html>

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Swansea PPR Request</title>
<title>Swansea PPR</title>
<style>
* {
margin: 0;
@@ -254,13 +254,91 @@
color: #6c757d;
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>
</head>
<body>
<div class="container">
<div class="header">
<h1>✈️ Swansea Airport PPR Request</h1>
<p>Please fill out the form below to submit a Prior Permission Required (PPR) request for Swansea Airport.</p>
<h1>✈️ PPR Request</h1>
<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>
<form id="ppr-form">
@@ -354,9 +432,9 @@
</div>
<div class="success-message" id="success-message">
<h3>PPR Request Submitted Successfully!</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><strong>Please note:</strong> This is not confirmation of approval. Airport operations will contact you if additional information is required.</p>
<h3>PPR Request Submitted.</h3>
<p>Your PPR request has been submitted. You will receive confirmation via email if provided.</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>
@@ -364,6 +442,29 @@
<div id="notification" class="notification"></div>
<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
function initializeTimeDropdowns() {
const timeSelects = ['eta-time', 'etd-time'];
@@ -395,11 +496,15 @@
// Show notification
setTimeout(() => {
notification.classList.add('show');
// Update iframe height when notification appears
setTimeout(sendHeightToParent, 50);
}, 10);
// Hide after 5 seconds
setTimeout(() => {
notification.classList.remove('show');
// Update iframe height when notification disappears
setTimeout(sendHeightToParent, 50);
}, 5000);
}
@@ -419,7 +524,7 @@
aircraftLookupTimeout = setTimeout(async () => {
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) {
const data = await response.json();
if (data && data.length > 0) {
@@ -498,17 +603,19 @@
arrivalAirportLookupTimeout = setTimeout(async () => {
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) {
const data = await response.json();
if (data && data.length > 0) {
if (data.length === 1) {
// Single match - auto-populate the input field with ICAO code
// Single match - show as clickable option
const airport = data[0];
inputField.value = airport.icao;
resultsDiv.innerHTML = `
<div class="lookup-match">
${airport.icao}/${airport.iata || ''} - ${airport.name}, ${airport.country}
<div class="aircraft-list">
<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>
`;
} else if (data.length <= 10) {
@@ -555,17 +662,19 @@
departureAirportLookupTimeout = setTimeout(async () => {
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) {
const data = await response.json();
if (data && data.length > 0) {
if (data.length === 1) {
// Single match - auto-populate the input field with ICAO code
// Single match - show as clickable option
const airport = data[0];
inputField.value = airport.icao;
resultsDiv.innerHTML = `
<div class="lookup-match">
${airport.icao}/${airport.iata || ''} - ${airport.name}, ${airport.country}
<div class="aircraft-list">
<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>
`;
} else if (data.length <= 10) {
@@ -653,7 +762,7 @@
document.getElementById('submit-btn').textContent = 'Submitting...';
try {
const response = await fetch('/api/v1/pprs/public', {
const response = await fetch(`${API_BASE}/pprs/public`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -670,6 +779,8 @@
document.getElementById('success-message').style.display = 'block';
showNotification('PPR request submitted successfully!');
// Update iframe height after content change
setTimeout(sendHeightToParent, 100);
} else {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `Submission failed: ${response.status}`);
@@ -677,6 +788,8 @@
} catch (error) {
console.error('Error submitting PPR:', error);
showNotification(`Error submitting PPR: ${error.message}`, true);
// Update iframe height after showing error
setTimeout(sendHeightToParent, 100);
} finally {
// Hide loading
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")}})}