mirror of
https://github.com/smittix/intercept.git
synced 2026-07-25 09:18:10 -07:00
Add ADS-B history persistence and reporting UI
This commit is contained in:
@@ -33,6 +33,9 @@ htmlcov/
|
|||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
# Local Postgres data
|
||||||
|
pgdata/
|
||||||
|
|
||||||
# Captured files (don't include in image)
|
# Captured files (don't include in image)
|
||||||
*.cap
|
*.cap
|
||||||
*.pcap
|
*.pcap
|
||||||
|
|||||||
+12
-7
@@ -10,9 +10,17 @@ venv/
|
|||||||
ENV/
|
ENV/
|
||||||
uv.lock
|
uv.lock
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
pager_messages.log
|
pager_messages.log
|
||||||
|
|
||||||
|
# Local data
|
||||||
|
downloads/
|
||||||
|
pgdata/
|
||||||
|
|
||||||
|
# Local data
|
||||||
|
downloads/
|
||||||
|
pgdata/
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.idea/
|
.idea/
|
||||||
@@ -34,7 +42,4 @@ build/
|
|||||||
uv.lock
|
uv.lock
|
||||||
*.db
|
*.db
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
intercept.db
|
intercept.db
|
||||||
|
|
||||||
# Agent Files
|
|
||||||
.agent
|
|
||||||
|
|||||||
@@ -126,9 +126,18 @@ AIRODUMP_HEADER_LINES = _get_env_int('AIRODUMP_HEADER_LINES', 2)
|
|||||||
BT_SCAN_TIMEOUT = _get_env_int('BT_SCAN_TIMEOUT', 10)
|
BT_SCAN_TIMEOUT = _get_env_int('BT_SCAN_TIMEOUT', 10)
|
||||||
BT_UPDATE_INTERVAL = _get_env_float('BT_UPDATE_INTERVAL', 2.0)
|
BT_UPDATE_INTERVAL = _get_env_float('BT_UPDATE_INTERVAL', 2.0)
|
||||||
|
|
||||||
# ADS-B settings
|
# ADS-B settings
|
||||||
ADSB_SBS_PORT = _get_env_int('ADSB_SBS_PORT', 30003)
|
ADSB_SBS_PORT = _get_env_int('ADSB_SBS_PORT', 30003)
|
||||||
ADSB_UPDATE_INTERVAL = _get_env_float('ADSB_UPDATE_INTERVAL', 1.0)
|
ADSB_UPDATE_INTERVAL = _get_env_float('ADSB_UPDATE_INTERVAL', 1.0)
|
||||||
|
ADSB_HISTORY_ENABLED = _get_env_bool('ADSB_HISTORY_ENABLED', False)
|
||||||
|
ADSB_DB_HOST = _get_env('ADSB_DB_HOST', 'localhost')
|
||||||
|
ADSB_DB_PORT = _get_env_int('ADSB_DB_PORT', 5432)
|
||||||
|
ADSB_DB_NAME = _get_env('ADSB_DB_NAME', 'intercept_adsb')
|
||||||
|
ADSB_DB_USER = _get_env('ADSB_DB_USER', 'intercept')
|
||||||
|
ADSB_DB_PASSWORD = _get_env('ADSB_DB_PASSWORD', 'intercept')
|
||||||
|
ADSB_HISTORY_BATCH_SIZE = _get_env_int('ADSB_HISTORY_BATCH_SIZE', 500)
|
||||||
|
ADSB_HISTORY_FLUSH_INTERVAL = _get_env_float('ADSB_HISTORY_FLUSH_INTERVAL', 1.0)
|
||||||
|
ADSB_HISTORY_QUEUE_SIZE = _get_env_int('ADSB_HISTORY_QUEUE_SIZE', 50000)
|
||||||
|
|
||||||
# Satellite settings
|
# Satellite settings
|
||||||
SATELLITE_UPDATE_INTERVAL = _get_env_int('SATELLITE_UPDATE_INTERVAL', 30)
|
SATELLITE_UPDATE_INTERVAL = _get_env_int('SATELLITE_UPDATE_INTERVAL', 30)
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ services:
|
|||||||
intercept:
|
intercept:
|
||||||
build: .
|
build: .
|
||||||
container_name: intercept
|
container_name: intercept
|
||||||
|
depends_on:
|
||||||
|
- adsb_db
|
||||||
ports:
|
ports:
|
||||||
- "5050:5050"
|
- "5050:5050"
|
||||||
# Privileged mode required for USB SDR device access
|
# Privileged mode required for USB SDR device access
|
||||||
@@ -22,6 +24,12 @@ services:
|
|||||||
- INTERCEPT_HOST=0.0.0.0
|
- INTERCEPT_HOST=0.0.0.0
|
||||||
- INTERCEPT_PORT=5050
|
- INTERCEPT_PORT=5050
|
||||||
- INTERCEPT_LOG_LEVEL=INFO
|
- INTERCEPT_LOG_LEVEL=INFO
|
||||||
|
- INTERCEPT_ADSB_HISTORY_ENABLED=true
|
||||||
|
- INTERCEPT_ADSB_DB_HOST=adsb_db
|
||||||
|
- INTERCEPT_ADSB_DB_PORT=5432
|
||||||
|
- INTERCEPT_ADSB_DB_NAME=intercept_adsb
|
||||||
|
- INTERCEPT_ADSB_DB_USER=intercept
|
||||||
|
- INTERCEPT_ADSB_DB_PASSWORD=intercept
|
||||||
# Network mode for WiFi scanning (requires host network)
|
# Network mode for WiFi scanning (requires host network)
|
||||||
# network_mode: host
|
# network_mode: host
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -32,6 +40,23 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
|
||||||
|
adsb_db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: intercept-adsb-db
|
||||||
|
environment:
|
||||||
|
- POSTGRES_DB=intercept_adsb
|
||||||
|
- POSTGRES_USER=intercept
|
||||||
|
- POSTGRES_PASSWORD=intercept
|
||||||
|
volumes:
|
||||||
|
# Move this path to the USB drive later for larger retention
|
||||||
|
- ./pgdata:/var/lib/postgresql/data
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U intercept -d intercept_adsb"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
# Optional: Add volume for persistent SQLite database
|
# Optional: Add volume for persistent SQLite database
|
||||||
# volumes:
|
# volumes:
|
||||||
# intercept-data:
|
# intercept-data:
|
||||||
|
|||||||
+573
-111
@@ -2,27 +2,39 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import queue
|
import queue
|
||||||
import shutil
|
import shutil
|
||||||
import socket
|
import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from typing import Any, Generator
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Generator
|
||||||
from flask import Blueprint, jsonify, request, Response, render_template
|
|
||||||
|
from flask import Blueprint, jsonify, request, Response, render_template
|
||||||
import app as app_module
|
from flask import make_response
|
||||||
from utils.logging import adsb_logger as logger
|
import psycopg2
|
||||||
from utils.validation import (
|
from psycopg2.extras import RealDictCursor
|
||||||
validate_device_index, validate_gain,
|
|
||||||
validate_rtl_tcp_host, validate_rtl_tcp_port
|
import app as app_module
|
||||||
)
|
from config import (
|
||||||
|
ADSB_DB_HOST,
|
||||||
|
ADSB_DB_NAME,
|
||||||
|
ADSB_DB_PASSWORD,
|
||||||
|
ADSB_DB_PORT,
|
||||||
|
ADSB_DB_USER,
|
||||||
|
ADSB_HISTORY_ENABLED,
|
||||||
|
)
|
||||||
|
from utils.logging import adsb_logger as logger
|
||||||
|
from utils.validation import (
|
||||||
|
validate_device_index, validate_gain,
|
||||||
|
validate_rtl_tcp_host, validate_rtl_tcp_port
|
||||||
|
)
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
from utils.sdr import SDRFactory, SDRType
|
from utils.sdr import SDRFactory, SDRType
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
ADSB_SBS_PORT,
|
ADSB_SBS_PORT,
|
||||||
ADSB_TERMINATE_TIMEOUT,
|
ADSB_TERMINATE_TIMEOUT,
|
||||||
PROCESS_TERMINATE_TIMEOUT,
|
PROCESS_TERMINATE_TIMEOUT,
|
||||||
@@ -33,9 +45,10 @@ from utils.constants import (
|
|||||||
SSE_QUEUE_TIMEOUT,
|
SSE_QUEUE_TIMEOUT,
|
||||||
SOCKET_CONNECT_TIMEOUT,
|
SOCKET_CONNECT_TIMEOUT,
|
||||||
ADSB_UPDATE_INTERVAL,
|
ADSB_UPDATE_INTERVAL,
|
||||||
DUMP1090_START_WAIT,
|
DUMP1090_START_WAIT,
|
||||||
)
|
)
|
||||||
from utils import aircraft_db
|
from utils import aircraft_db
|
||||||
|
from utils.adsb_history import adsb_history_writer, adsb_snapshot_writer, _ensure_adsb_schema
|
||||||
|
|
||||||
adsb_bp = Blueprint('adsb', __name__, url_prefix='/adsb')
|
adsb_bp = Blueprint('adsb', __name__, url_prefix='/adsb')
|
||||||
|
|
||||||
@@ -56,7 +69,7 @@ _looked_up_icaos: set[str] = set()
|
|||||||
aircraft_db.load_database()
|
aircraft_db.load_database()
|
||||||
|
|
||||||
# Common installation paths for dump1090 (when not in PATH)
|
# Common installation paths for dump1090 (when not in PATH)
|
||||||
DUMP1090_PATHS = [
|
DUMP1090_PATHS = [
|
||||||
# Homebrew on Apple Silicon (M1/M2/M3)
|
# Homebrew on Apple Silicon (M1/M2/M3)
|
||||||
'/opt/homebrew/bin/dump1090',
|
'/opt/homebrew/bin/dump1090',
|
||||||
'/opt/homebrew/bin/dump1090-fa',
|
'/opt/homebrew/bin/dump1090-fa',
|
||||||
@@ -69,8 +82,202 @@ DUMP1090_PATHS = [
|
|||||||
'/usr/bin/dump1090',
|
'/usr/bin/dump1090',
|
||||||
'/usr/bin/dump1090-fa',
|
'/usr/bin/dump1090-fa',
|
||||||
'/usr/bin/dump1090-mutability',
|
'/usr/bin/dump1090-mutability',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _get_part(parts: list[str], index: int) -> str | None:
|
||||||
|
if len(parts) <= index:
|
||||||
|
return None
|
||||||
|
value = parts[index].strip()
|
||||||
|
return value or None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_sbs_timestamp(date_str: str | None, time_str: str | None) -> datetime | None:
|
||||||
|
if not date_str or not time_str:
|
||||||
|
return None
|
||||||
|
combined = f"{date_str} {time_str}"
|
||||||
|
for fmt in ("%Y/%m/%d %H:%M:%S.%f", "%Y/%m/%d %H:%M:%S"):
|
||||||
|
try:
|
||||||
|
parsed = datetime.strptime(combined, fmt)
|
||||||
|
return parsed.replace(tzinfo=timezone.utc)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_int(value: str | None) -> int | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(float(value))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_float(value: str | None) -> float | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return float(value)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _build_history_record(
|
||||||
|
parts: list[str],
|
||||||
|
msg_type: str,
|
||||||
|
icao: str,
|
||||||
|
msg_time: datetime | None,
|
||||||
|
logged_time: datetime | None,
|
||||||
|
service_addr: str,
|
||||||
|
raw_line: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
'received_at': datetime.now(timezone.utc),
|
||||||
|
'msg_time': msg_time,
|
||||||
|
'logged_time': logged_time,
|
||||||
|
'icao': icao,
|
||||||
|
'msg_type': _parse_int(msg_type),
|
||||||
|
'callsign': _get_part(parts, 10),
|
||||||
|
'altitude': _parse_int(_get_part(parts, 11)),
|
||||||
|
'speed': _parse_int(_get_part(parts, 12)),
|
||||||
|
'heading': _parse_int(_get_part(parts, 13)),
|
||||||
|
'vertical_rate': _parse_int(_get_part(parts, 16)),
|
||||||
|
'lat': _parse_float(_get_part(parts, 14)),
|
||||||
|
'lon': _parse_float(_get_part(parts, 15)),
|
||||||
|
'squawk': _get_part(parts, 17),
|
||||||
|
'session_id': _get_part(parts, 2),
|
||||||
|
'aircraft_id': _get_part(parts, 3),
|
||||||
|
'flight_id': _get_part(parts, 5),
|
||||||
|
'raw_line': raw_line,
|
||||||
|
'source_host': service_addr,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_history_schema_checked = False
|
||||||
|
|
||||||
|
|
||||||
|
def _get_history_connection():
|
||||||
|
return psycopg2.connect(
|
||||||
|
host=ADSB_DB_HOST,
|
||||||
|
port=ADSB_DB_PORT,
|
||||||
|
dbname=ADSB_DB_NAME,
|
||||||
|
user=ADSB_DB_USER,
|
||||||
|
password=ADSB_DB_PASSWORD,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_history_schema() -> None:
|
||||||
|
global _history_schema_checked
|
||||||
|
if _history_schema_checked:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
with _get_history_connection() as conn:
|
||||||
|
_ensure_adsb_schema(conn)
|
||||||
|
_history_schema_checked = True
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("ADS-B schema check failed: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_int_param(value: str | None, default: int, min_value: int | None = None, max_value: int | None = None) -> int:
|
||||||
|
try:
|
||||||
|
parsed = int(value) if value is not None else default
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
parsed = default
|
||||||
|
if min_value is not None:
|
||||||
|
parsed = max(min_value, parsed)
|
||||||
|
if max_value is not None:
|
||||||
|
parsed = min(max_value, parsed)
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def _get_active_session() -> dict[str, Any] | None:
|
||||||
|
if not ADSB_HISTORY_ENABLED:
|
||||||
|
return None
|
||||||
|
_ensure_history_schema()
|
||||||
|
try:
|
||||||
|
with _get_history_connection() as conn:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT *
|
||||||
|
FROM adsb_sessions
|
||||||
|
WHERE ended_at IS NULL
|
||||||
|
ORDER BY started_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
return cur.fetchone()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("ADS-B session lookup failed: %s", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _record_session_start(
|
||||||
|
*,
|
||||||
|
device_index: int | None,
|
||||||
|
sdr_type: str | None,
|
||||||
|
remote_host: str | None,
|
||||||
|
remote_port: int | None,
|
||||||
|
start_source: str | None,
|
||||||
|
started_by: str | None,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
if not ADSB_HISTORY_ENABLED:
|
||||||
|
return None
|
||||||
|
_ensure_history_schema()
|
||||||
|
try:
|
||||||
|
with _get_history_connection() as conn:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO adsb_sessions (
|
||||||
|
device_index,
|
||||||
|
sdr_type,
|
||||||
|
remote_host,
|
||||||
|
remote_port,
|
||||||
|
start_source,
|
||||||
|
started_by
|
||||||
|
)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s)
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
device_index,
|
||||||
|
sdr_type,
|
||||||
|
remote_host,
|
||||||
|
remote_port,
|
||||||
|
start_source,
|
||||||
|
started_by,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return cur.fetchone()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("ADS-B session start record failed: %s", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _record_session_stop(*, stop_source: str | None, stopped_by: str | None) -> dict[str, Any] | None:
|
||||||
|
if not ADSB_HISTORY_ENABLED:
|
||||||
|
return None
|
||||||
|
_ensure_history_schema()
|
||||||
|
try:
|
||||||
|
with _get_history_connection() as conn:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE adsb_sessions
|
||||||
|
SET ended_at = NOW(),
|
||||||
|
stop_source = COALESCE(%s, stop_source),
|
||||||
|
stopped_by = COALESCE(%s, stopped_by)
|
||||||
|
WHERE ended_at IS NULL
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
(stop_source, stopped_by),
|
||||||
|
)
|
||||||
|
return cur.fetchone()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("ADS-B session stop record failed: %s", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
def find_dump1090():
|
def find_dump1090():
|
||||||
"""Find dump1090 binary, checking PATH and common locations."""
|
"""Find dump1090 binary, checking PATH and common locations."""
|
||||||
@@ -100,12 +307,15 @@ def check_dump1090_service():
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def parse_sbs_stream(service_addr):
|
def parse_sbs_stream(service_addr):
|
||||||
"""Parse SBS format data from dump1090 SBS port."""
|
"""Parse SBS format data from dump1090 SBS port."""
|
||||||
global adsb_using_service, adsb_connected, adsb_messages_received, adsb_last_message_time, adsb_bytes_received, adsb_lines_received, _sbs_error_logged
|
global adsb_using_service, adsb_connected, adsb_messages_received, adsb_last_message_time, adsb_bytes_received, adsb_lines_received, _sbs_error_logged
|
||||||
|
|
||||||
host, port = service_addr.split(':')
|
adsb_history_writer.start()
|
||||||
port = int(port)
|
adsb_snapshot_writer.start()
|
||||||
|
|
||||||
|
host, port = service_addr.split(':')
|
||||||
|
port = int(port)
|
||||||
|
|
||||||
logger.info(f"SBS stream parser started, connecting to {host}:{port}")
|
logger.info(f"SBS stream parser started, connecting to {host}:{port}")
|
||||||
adsb_connected = False
|
adsb_connected = False
|
||||||
@@ -147,18 +357,31 @@ def parse_sbs_stream(service_addr):
|
|||||||
if adsb_lines_received <= 3:
|
if adsb_lines_received <= 3:
|
||||||
logger.info(f"SBS line {adsb_lines_received}: {line[:100]}")
|
logger.info(f"SBS line {adsb_lines_received}: {line[:100]}")
|
||||||
|
|
||||||
parts = line.split(',')
|
parts = line.split(',')
|
||||||
if len(parts) < 11 or parts[0] != 'MSG':
|
if len(parts) < 11 or parts[0] != 'MSG':
|
||||||
if adsb_lines_received <= 5:
|
if adsb_lines_received <= 5:
|
||||||
logger.debug(f"Skipping non-MSG line: {line[:50]}")
|
logger.debug(f"Skipping non-MSG line: {line[:50]}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
msg_type = parts[1]
|
msg_type = parts[1]
|
||||||
icao = parts[4].upper()
|
icao = parts[4].upper()
|
||||||
if not icao:
|
if not icao:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
aircraft = app_module.adsb_aircraft.get(icao) or {'icao': icao}
|
msg_time = _parse_sbs_timestamp(_get_part(parts, 6), _get_part(parts, 7))
|
||||||
|
logged_time = _parse_sbs_timestamp(_get_part(parts, 8), _get_part(parts, 9))
|
||||||
|
history_record = _build_history_record(
|
||||||
|
parts=parts,
|
||||||
|
msg_type=msg_type,
|
||||||
|
icao=icao,
|
||||||
|
msg_time=msg_time,
|
||||||
|
logged_time=logged_time,
|
||||||
|
service_addr=service_addr,
|
||||||
|
raw_line=line,
|
||||||
|
)
|
||||||
|
adsb_history_writer.enqueue(history_record)
|
||||||
|
|
||||||
|
aircraft = app_module.adsb_aircraft.get(icao) or {'icao': icao}
|
||||||
|
|
||||||
# Look up aircraft type from database (once per ICAO)
|
# Look up aircraft type from database (once per ICAO)
|
||||||
if icao not in _looked_up_icaos:
|
if icao not in _looked_up_icaos:
|
||||||
@@ -229,12 +452,30 @@ def parse_sbs_stream(service_addr):
|
|||||||
|
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if now - last_update >= ADSB_UPDATE_INTERVAL:
|
if now - last_update >= ADSB_UPDATE_INTERVAL:
|
||||||
for update_icao in pending_updates:
|
for update_icao in pending_updates:
|
||||||
if update_icao in app_module.adsb_aircraft:
|
if update_icao in app_module.adsb_aircraft:
|
||||||
app_module.adsb_queue.put({
|
snapshot = app_module.adsb_aircraft[update_icao]
|
||||||
'type': 'aircraft',
|
app_module.adsb_queue.put({
|
||||||
**app_module.adsb_aircraft[update_icao]
|
'type': 'aircraft',
|
||||||
})
|
**snapshot
|
||||||
|
})
|
||||||
|
adsb_snapshot_writer.enqueue({
|
||||||
|
'captured_at': datetime.now(timezone.utc),
|
||||||
|
'icao': update_icao,
|
||||||
|
'callsign': snapshot.get('callsign'),
|
||||||
|
'registration': snapshot.get('registration'),
|
||||||
|
'type_code': snapshot.get('type_code'),
|
||||||
|
'type_desc': snapshot.get('type_desc'),
|
||||||
|
'altitude': snapshot.get('altitude'),
|
||||||
|
'speed': snapshot.get('speed'),
|
||||||
|
'heading': snapshot.get('heading'),
|
||||||
|
'vertical_rate': snapshot.get('vertical_rate'),
|
||||||
|
'lat': snapshot.get('lat'),
|
||||||
|
'lon': snapshot.get('lon'),
|
||||||
|
'squawk': snapshot.get('squawk'),
|
||||||
|
'source_host': service_addr,
|
||||||
|
'snapshot': snapshot,
|
||||||
|
})
|
||||||
pending_updates.clear()
|
pending_updates.clear()
|
||||||
last_update = now
|
last_update = now
|
||||||
|
|
||||||
@@ -282,18 +523,18 @@ def check_adsb_tools():
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@adsb_bp.route('/status')
|
@adsb_bp.route('/status')
|
||||||
def adsb_status():
|
def adsb_status():
|
||||||
"""Get ADS-B tracking status for debugging."""
|
"""Get ADS-B tracking status for debugging."""
|
||||||
# Check if dump1090 process is still running
|
# Check if dump1090 process is still running
|
||||||
dump1090_running = False
|
dump1090_running = False
|
||||||
if app_module.adsb_process:
|
if app_module.adsb_process:
|
||||||
dump1090_running = app_module.adsb_process.poll() is None
|
dump1090_running = app_module.adsb_process.poll() is None
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'tracking_active': adsb_using_service,
|
'tracking_active': adsb_using_service,
|
||||||
'active_device': adsb_active_device,
|
'active_device': adsb_active_device,
|
||||||
'connected_to_sbs': adsb_connected,
|
'connected_to_sbs': adsb_connected,
|
||||||
'messages_received': adsb_messages_received,
|
'messages_received': adsb_messages_received,
|
||||||
'bytes_received': adsb_bytes_received,
|
'bytes_received': adsb_bytes_received,
|
||||||
'lines_received': adsb_lines_received,
|
'lines_received': adsb_lines_received,
|
||||||
@@ -303,25 +544,50 @@ def adsb_status():
|
|||||||
'queue_size': app_module.adsb_queue.qsize(),
|
'queue_size': app_module.adsb_queue.qsize(),
|
||||||
'dump1090_path': find_dump1090(),
|
'dump1090_path': find_dump1090(),
|
||||||
'dump1090_running': dump1090_running,
|
'dump1090_running': dump1090_running,
|
||||||
'port_30003_open': check_dump1090_service() is not None
|
'port_30003_open': check_dump1090_service() is not None
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@adsb_bp.route('/session')
|
||||||
|
def adsb_session():
|
||||||
|
"""Get ADS-B session status and uptime."""
|
||||||
|
session = _get_active_session()
|
||||||
|
uptime_seconds = None
|
||||||
|
if session and session.get('started_at'):
|
||||||
|
started_at = session['started_at']
|
||||||
|
if isinstance(started_at, datetime):
|
||||||
|
uptime_seconds = int((datetime.now(timezone.utc) - started_at).total_seconds())
|
||||||
|
return jsonify({
|
||||||
|
'tracking_active': adsb_using_service,
|
||||||
|
'connected_to_sbs': adsb_connected,
|
||||||
|
'active_device': adsb_active_device,
|
||||||
|
'session': session,
|
||||||
|
'uptime_seconds': uptime_seconds,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@adsb_bp.route('/start', methods=['POST'])
|
@adsb_bp.route('/start', methods=['POST'])
|
||||||
def start_adsb():
|
def start_adsb():
|
||||||
"""Start ADS-B tracking."""
|
"""Start ADS-B tracking."""
|
||||||
global adsb_using_service, adsb_active_device
|
global adsb_using_service, adsb_active_device
|
||||||
|
|
||||||
with app_module.adsb_lock:
|
with app_module.adsb_lock:
|
||||||
if adsb_using_service:
|
if adsb_using_service:
|
||||||
return jsonify({'status': 'already_running', 'message': 'ADS-B tracking already active'}), 409
|
session = _get_active_session()
|
||||||
|
return jsonify({
|
||||||
data = request.json or {}
|
'status': 'already_running',
|
||||||
|
'message': 'ADS-B tracking already active',
|
||||||
# Validate inputs
|
'session': session
|
||||||
try:
|
}), 409
|
||||||
gain = int(validate_gain(data.get('gain', '40')))
|
|
||||||
device = validate_device_index(data.get('device', '0'))
|
data = request.json or {}
|
||||||
|
start_source = data.get('source')
|
||||||
|
started_by = request.remote_addr
|
||||||
|
|
||||||
|
# Validate inputs
|
||||||
|
try:
|
||||||
|
gain = int(validate_gain(data.get('gain', '40')))
|
||||||
|
device = validate_device_index(data.get('device', '0'))
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||||
|
|
||||||
@@ -337,21 +603,45 @@ def start_adsb():
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||||
|
|
||||||
remote_addr = f"{remote_sbs_host}:{remote_sbs_port}"
|
remote_addr = f"{remote_sbs_host}:{remote_sbs_port}"
|
||||||
logger.info(f"Connecting to remote dump1090 SBS at {remote_addr}")
|
logger.info(f"Connecting to remote dump1090 SBS at {remote_addr}")
|
||||||
adsb_using_service = True
|
adsb_using_service = True
|
||||||
thread = threading.Thread(target=parse_sbs_stream, args=(remote_addr,), daemon=True)
|
thread = threading.Thread(target=parse_sbs_stream, args=(remote_addr,), daemon=True)
|
||||||
thread.start()
|
thread.start()
|
||||||
return jsonify({'status': 'started', 'message': f'Connected to remote dump1090 at {remote_addr}'})
|
session = _record_session_start(
|
||||||
|
device_index=device,
|
||||||
|
sdr_type='remote',
|
||||||
|
remote_host=remote_sbs_host,
|
||||||
|
remote_port=remote_sbs_port,
|
||||||
|
start_source=start_source,
|
||||||
|
started_by=started_by,
|
||||||
|
)
|
||||||
|
return jsonify({
|
||||||
|
'status': 'started',
|
||||||
|
'message': f'Connected to remote dump1090 at {remote_addr}',
|
||||||
|
'session': session
|
||||||
|
})
|
||||||
|
|
||||||
# Check if dump1090 is already running externally (e.g., user started it manually)
|
# Check if dump1090 is already running externally (e.g., user started it manually)
|
||||||
existing_service = check_dump1090_service()
|
existing_service = check_dump1090_service()
|
||||||
if existing_service:
|
if existing_service:
|
||||||
logger.info(f"Found existing dump1090 service at {existing_service}")
|
logger.info(f"Found existing dump1090 service at {existing_service}")
|
||||||
adsb_using_service = True
|
adsb_using_service = True
|
||||||
thread = threading.Thread(target=parse_sbs_stream, args=(existing_service,), daemon=True)
|
thread = threading.Thread(target=parse_sbs_stream, args=(existing_service,), daemon=True)
|
||||||
thread.start()
|
thread.start()
|
||||||
return jsonify({'status': 'started', 'message': 'Connected to existing dump1090 service'})
|
session = _record_session_start(
|
||||||
|
device_index=device,
|
||||||
|
sdr_type='external',
|
||||||
|
remote_host='localhost',
|
||||||
|
remote_port=ADSB_SBS_PORT,
|
||||||
|
start_source=start_source,
|
||||||
|
started_by=started_by,
|
||||||
|
)
|
||||||
|
return jsonify({
|
||||||
|
'status': 'started',
|
||||||
|
'message': 'Connected to existing dump1090 service',
|
||||||
|
'session': session
|
||||||
|
})
|
||||||
|
|
||||||
# Get SDR type from request
|
# Get SDR type from request
|
||||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||||
@@ -402,10 +692,10 @@ def start_adsb():
|
|||||||
if sdr_type == SDRType.RTL_SDR:
|
if sdr_type == SDRType.RTL_SDR:
|
||||||
cmd[0] = dump1090_path
|
cmd[0] = dump1090_path
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logger.info(f"Starting dump1090 with device index {device}: {' '.join(cmd)}")
|
logger.info(f"Starting dump1090 with device index {device}: {' '.join(cmd)}")
|
||||||
app_module.adsb_process = subprocess.Popen(
|
app_module.adsb_process = subprocess.Popen(
|
||||||
cmd,
|
cmd,
|
||||||
stdout=subprocess.DEVNULL,
|
stdout=subprocess.DEVNULL,
|
||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
start_new_session=True # Create new process group for clean shutdown
|
start_new_session=True # Create new process group for clean shutdown
|
||||||
@@ -432,24 +722,40 @@ def start_adsb():
|
|||||||
error_msg += f' Error: {stderr_output[:200]}'
|
error_msg += f' Error: {stderr_output[:200]}'
|
||||||
return jsonify({'status': 'error', 'message': error_msg})
|
return jsonify({'status': 'error', 'message': error_msg})
|
||||||
|
|
||||||
adsb_using_service = True
|
adsb_using_service = True
|
||||||
adsb_active_device = device # Track which device is being used
|
adsb_active_device = device # Track which device is being used
|
||||||
thread = threading.Thread(target=parse_sbs_stream, args=(f'localhost:{ADSB_SBS_PORT}',), daemon=True)
|
thread = threading.Thread(target=parse_sbs_stream, args=(f'localhost:{ADSB_SBS_PORT}',), daemon=True)
|
||||||
thread.start()
|
thread.start()
|
||||||
|
|
||||||
return jsonify({'status': 'started', 'message': 'ADS-B tracking started', 'device': device})
|
session = _record_session_start(
|
||||||
|
device_index=device,
|
||||||
|
sdr_type=sdr_type.value,
|
||||||
|
remote_host=None,
|
||||||
|
remote_port=None,
|
||||||
|
start_source=start_source,
|
||||||
|
started_by=started_by,
|
||||||
|
)
|
||||||
|
return jsonify({
|
||||||
|
'status': 'started',
|
||||||
|
'message': 'ADS-B tracking started',
|
||||||
|
'device': device,
|
||||||
|
'session': session
|
||||||
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)})
|
return jsonify({'status': 'error', 'message': str(e)})
|
||||||
|
|
||||||
|
|
||||||
@adsb_bp.route('/stop', methods=['POST'])
|
@adsb_bp.route('/stop', methods=['POST'])
|
||||||
def stop_adsb():
|
def stop_adsb():
|
||||||
"""Stop ADS-B tracking."""
|
"""Stop ADS-B tracking."""
|
||||||
global adsb_using_service, adsb_active_device
|
global adsb_using_service, adsb_active_device
|
||||||
|
data = request.json or {}
|
||||||
with app_module.adsb_lock:
|
stop_source = data.get('source')
|
||||||
if app_module.adsb_process:
|
stopped_by = request.remote_addr
|
||||||
try:
|
|
||||||
|
with app_module.adsb_lock:
|
||||||
|
if app_module.adsb_process:
|
||||||
|
try:
|
||||||
# Kill the entire process group to ensure all child processes are terminated
|
# Kill the entire process group to ensure all child processes are terminated
|
||||||
pgid = os.getpgid(app_module.adsb_process.pid)
|
pgid = os.getpgid(app_module.adsb_process.pid)
|
||||||
os.killpg(pgid, 15) # SIGTERM
|
os.killpg(pgid, 15) # SIGTERM
|
||||||
@@ -463,12 +769,13 @@ def stop_adsb():
|
|||||||
pass
|
pass
|
||||||
app_module.adsb_process = None
|
app_module.adsb_process = None
|
||||||
logger.info("ADS-B process stopped")
|
logger.info("ADS-B process stopped")
|
||||||
adsb_using_service = False
|
adsb_using_service = False
|
||||||
adsb_active_device = None
|
adsb_active_device = None
|
||||||
|
|
||||||
app_module.adsb_aircraft.clear()
|
app_module.adsb_aircraft.clear()
|
||||||
_looked_up_icaos.clear()
|
_looked_up_icaos.clear()
|
||||||
return jsonify({'status': 'stopped'})
|
session = _record_session_stop(stop_source=stop_source, stopped_by=stopped_by)
|
||||||
|
return jsonify({'status': 'stopped', 'session': session})
|
||||||
|
|
||||||
|
|
||||||
@adsb_bp.route('/stream')
|
@adsb_bp.route('/stream')
|
||||||
@@ -494,10 +801,165 @@ def stream_adsb():
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
@adsb_bp.route('/dashboard')
|
@adsb_bp.route('/dashboard')
|
||||||
def adsb_dashboard():
|
def adsb_dashboard():
|
||||||
"""Popout ADS-B dashboard."""
|
"""Popout ADS-B dashboard."""
|
||||||
return render_template('adsb_dashboard.html')
|
return render_template('adsb_dashboard.html')
|
||||||
|
|
||||||
|
|
||||||
|
@adsb_bp.route('/history')
|
||||||
|
def adsb_history():
|
||||||
|
"""ADS-B history reporting dashboard."""
|
||||||
|
resp = make_response(render_template('adsb_history.html', history_enabled=ADSB_HISTORY_ENABLED))
|
||||||
|
resp.headers['Cache-Control'] = 'no-store'
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
@adsb_bp.route('/history/summary')
|
||||||
|
def adsb_history_summary():
|
||||||
|
"""Summary stats for ADS-B history window."""
|
||||||
|
if not ADSB_HISTORY_ENABLED:
|
||||||
|
return jsonify({'error': 'ADS-B history is disabled'}), 503
|
||||||
|
_ensure_history_schema()
|
||||||
|
|
||||||
|
since_minutes = _parse_int_param(request.args.get('since_minutes'), 60, 1, 10080)
|
||||||
|
window = f'{since_minutes} minutes'
|
||||||
|
|
||||||
|
sql = """
|
||||||
|
SELECT
|
||||||
|
(SELECT COUNT(*) FROM adsb_messages WHERE received_at >= NOW() - INTERVAL %s) AS message_count,
|
||||||
|
(SELECT COUNT(*) FROM adsb_snapshots WHERE captured_at >= NOW() - INTERVAL %s) AS snapshot_count,
|
||||||
|
(SELECT COUNT(DISTINCT icao) FROM adsb_snapshots WHERE captured_at >= NOW() - INTERVAL %s) AS aircraft_count,
|
||||||
|
(SELECT MIN(captured_at) FROM adsb_snapshots WHERE captured_at >= NOW() - INTERVAL %s) AS first_seen,
|
||||||
|
(SELECT MAX(captured_at) FROM adsb_snapshots WHERE captured_at >= NOW() - INTERVAL %s) AS last_seen
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
with _get_history_connection() as conn:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(sql, (window, window, window, window, window))
|
||||||
|
row = cur.fetchone() or {}
|
||||||
|
return jsonify(row)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("ADS-B history summary failed: %s", exc)
|
||||||
|
return jsonify({'error': 'History database unavailable'}), 503
|
||||||
|
|
||||||
|
|
||||||
|
@adsb_bp.route('/history/aircraft')
|
||||||
|
def adsb_history_aircraft():
|
||||||
|
"""List latest aircraft snapshots for a time window."""
|
||||||
|
if not ADSB_HISTORY_ENABLED:
|
||||||
|
return jsonify({'error': 'ADS-B history is disabled'}), 503
|
||||||
|
_ensure_history_schema()
|
||||||
|
|
||||||
|
since_minutes = _parse_int_param(request.args.get('since_minutes'), 60, 1, 10080)
|
||||||
|
limit = _parse_int_param(request.args.get('limit'), 200, 1, 2000)
|
||||||
|
search = (request.args.get('search') or '').strip()
|
||||||
|
window = f'{since_minutes} minutes'
|
||||||
|
pattern = f'%{search}%'
|
||||||
|
|
||||||
|
sql = """
|
||||||
|
SELECT *
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT ON (icao)
|
||||||
|
icao,
|
||||||
|
callsign,
|
||||||
|
registration,
|
||||||
|
type_code,
|
||||||
|
type_desc,
|
||||||
|
altitude,
|
||||||
|
speed,
|
||||||
|
heading,
|
||||||
|
vertical_rate,
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
|
squawk,
|
||||||
|
captured_at AS last_seen
|
||||||
|
FROM adsb_snapshots
|
||||||
|
WHERE captured_at >= NOW() - INTERVAL %s
|
||||||
|
AND (%s = '' OR icao ILIKE %s OR callsign ILIKE %s OR registration ILIKE %s)
|
||||||
|
ORDER BY icao, captured_at DESC
|
||||||
|
) latest
|
||||||
|
ORDER BY last_seen DESC
|
||||||
|
LIMIT %s
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
with _get_history_connection() as conn:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(sql, (window, search, pattern, pattern, pattern, limit))
|
||||||
|
rows = cur.fetchall()
|
||||||
|
return jsonify({'aircraft': rows, 'count': len(rows)})
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("ADS-B history aircraft query failed: %s", exc)
|
||||||
|
return jsonify({'error': 'History database unavailable'}), 503
|
||||||
|
|
||||||
|
|
||||||
|
@adsb_bp.route('/history/timeline')
|
||||||
|
def adsb_history_timeline():
|
||||||
|
"""Timeline snapshots for a specific aircraft."""
|
||||||
|
if not ADSB_HISTORY_ENABLED:
|
||||||
|
return jsonify({'error': 'ADS-B history is disabled'}), 503
|
||||||
|
_ensure_history_schema()
|
||||||
|
|
||||||
|
icao = (request.args.get('icao') or '').strip().upper()
|
||||||
|
if not icao:
|
||||||
|
return jsonify({'error': 'icao is required'}), 400
|
||||||
|
|
||||||
|
since_minutes = _parse_int_param(request.args.get('since_minutes'), 60, 1, 10080)
|
||||||
|
limit = _parse_int_param(request.args.get('limit'), 2000, 1, 20000)
|
||||||
|
window = f'{since_minutes} minutes'
|
||||||
|
|
||||||
|
sql = """
|
||||||
|
SELECT captured_at, altitude, speed, heading, vertical_rate, lat, lon, squawk
|
||||||
|
FROM adsb_snapshots
|
||||||
|
WHERE icao = %s
|
||||||
|
AND captured_at >= NOW() - INTERVAL %s
|
||||||
|
ORDER BY captured_at ASC
|
||||||
|
LIMIT %s
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
with _get_history_connection() as conn:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(sql, (icao, window, limit))
|
||||||
|
rows = cur.fetchall()
|
||||||
|
return jsonify({'icao': icao, 'timeline': rows, 'count': len(rows)})
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("ADS-B history timeline query failed: %s", exc)
|
||||||
|
return jsonify({'error': 'History database unavailable'}), 503
|
||||||
|
|
||||||
|
|
||||||
|
@adsb_bp.route('/history/messages')
|
||||||
|
def adsb_history_messages():
|
||||||
|
"""Raw message history for a specific aircraft."""
|
||||||
|
if not ADSB_HISTORY_ENABLED:
|
||||||
|
return jsonify({'error': 'ADS-B history is disabled'}), 503
|
||||||
|
_ensure_history_schema()
|
||||||
|
|
||||||
|
icao = (request.args.get('icao') or '').strip().upper()
|
||||||
|
since_minutes = _parse_int_param(request.args.get('since_minutes'), 30, 1, 10080)
|
||||||
|
limit = _parse_int_param(request.args.get('limit'), 200, 1, 2000)
|
||||||
|
window = f'{since_minutes} minutes'
|
||||||
|
|
||||||
|
sql = """
|
||||||
|
SELECT received_at, msg_type, callsign, altitude, speed, heading, vertical_rate, lat, lon, squawk
|
||||||
|
FROM adsb_messages
|
||||||
|
WHERE received_at >= NOW() - INTERVAL %s
|
||||||
|
AND (%s = '' OR icao = %s)
|
||||||
|
ORDER BY received_at DESC
|
||||||
|
LIMIT %s
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
with _get_history_connection() as conn:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(sql, (window, icao, icao, limit))
|
||||||
|
rows = cur.fetchall()
|
||||||
|
return jsonify({'icao': icao, 'messages': rows, 'count': len(rows)})
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("ADS-B history message query failed: %s", exc)
|
||||||
|
return jsonify({'error': 'History database unavailable'}), 503
|
||||||
|
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|||||||
@@ -0,0 +1,615 @@
|
|||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg-dark: #0a0c10;
|
||||||
|
--bg-panel: #0f1218;
|
||||||
|
--bg-card: #141a24;
|
||||||
|
--border-color: #1f2937;
|
||||||
|
--border-glow: rgba(74, 158, 255, 0.6);
|
||||||
|
--text-primary: #e8eaed;
|
||||||
|
--text-secondary: #9ca3af;
|
||||||
|
--text-dim: #4b5563;
|
||||||
|
--accent-cyan: #4a9eff;
|
||||||
|
--accent-green: #22c55e;
|
||||||
|
--accent-amber: #d4a853;
|
||||||
|
--grid-line: rgba(74, 158, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
background: var(--bg-dark);
|
||||||
|
color: var(--text-primary);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radar-bg {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(var(--grid-line) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
|
||||||
|
background-size: 50px 50px;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scanline {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
||||||
|
animation: scan 6s linear infinite;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scan {
|
||||||
|
0% { top: -4px; }
|
||||||
|
100% { top: 100vh; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
padding: 12px 20px;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo span {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-left: 10px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border: 1px solid var(--accent-cyan);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-shell {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
padding: 16px 18px 28px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-strip {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-strip {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
align-items: center;
|
||||||
|
background: linear-gradient(120deg, rgba(15, 18, 24, 0.95), rgba(20, 26, 36, 0.95));
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
box-shadow: 0 0 18px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--text-dim);
|
||||||
|
box-shadow: 0 0 12px rgba(75, 85, 99, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.active {
|
||||||
|
background: var(--accent-green);
|
||||||
|
box-shadow: 0 0 14px rgba(34, 197, 94, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-label {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-value {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-metric {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#sessionNotice {
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-controls select {
|
||||||
|
background: var(--bg-dark);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
min-width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-btn.stop {
|
||||||
|
background: var(--accent-amber);
|
||||||
|
color: #0a0c10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
box-shadow: 0 0 14px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1.3px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-value {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 14px;
|
||||||
|
align-items: flex-end;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-group label {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-group input,
|
||||||
|
.control-group select {
|
||||||
|
background: var(--bg-dark);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
min-width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-btn {
|
||||||
|
background: var(--accent-cyan);
|
||||||
|
border: none;
|
||||||
|
color: #0a0c10;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 10px 18px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-btn:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 6px 14px rgba(74, 158, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--accent-amber);
|
||||||
|
color: var(--accent-amber);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(300px, 1fr) minmax(320px, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 420px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 1.6px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-meta {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-body {
|
||||||
|
padding: 12px 14px;
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aircraft-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aircraft-table th,
|
||||||
|
.aircraft-table td {
|
||||||
|
padding: 8px 6px;
|
||||||
|
border-bottom: 1px solid rgba(31, 41, 55, 0.6);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aircraft-table th {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aircraft-row {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aircraft-row:hover {
|
||||||
|
background: rgba(74, 158, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-row td,
|
||||||
|
.empty-row {
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-align: center;
|
||||||
|
padding: 18px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-card {
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-meta {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
height: 180px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-title {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#altitudeChart {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#speedChart,
|
||||||
|
#headingChart,
|
||||||
|
#verticalChart {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid rgba(31, 41, 55, 0.6);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(15, 18, 24, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.squawk-list {
|
||||||
|
margin-top: 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(5, 8, 15, 0.65);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-backdrop.open {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-card {
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 18px;
|
||||||
|
width: min(820px, 92vw);
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-subtitle {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn {
|
||||||
|
background: rgba(74, 158, 255, 0.15);
|
||||||
|
border: 1px solid rgba(74, 158, 255, 0.4);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1.2fr;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-photo {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
min-height: 220px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-photo img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-fallback {
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-details {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10px 18px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: rgba(20, 26, 36, 0.6);
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(31, 41, 55, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row span {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row strong {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.content-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.controls {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-group input,
|
||||||
|
.control-group select {
|
||||||
|
min-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
min-height: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-controls {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-card {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-details {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -74,9 +74,12 @@
|
|||||||
<button type="button" class="strip-btn" onclick="lookupSelectedFlight()" title="Lookup selected aircraft on FlightAware" id="flightLookupBtn" disabled>
|
<button type="button" class="strip-btn" onclick="lookupSelectedFlight()" title="Lookup selected aircraft on FlightAware" id="flightLookupBtn" disabled>
|
||||||
🔗 Lookup
|
🔗 Lookup
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="strip-btn primary" onclick="generateReport()" title="Generate Session Report">
|
<button type="button" class="strip-btn primary" onclick="generateReport()" title="Generate Session Report">
|
||||||
📊 Report
|
📊 Report
|
||||||
</button>
|
</button>
|
||||||
|
<a class="strip-btn" href="/adsb/history" title="Open History Reporting">
|
||||||
|
📚 History
|
||||||
|
</a>
|
||||||
<div class="strip-divider"></div>
|
<div class="strip-divider"></div>
|
||||||
<div class="strip-status">
|
<div class="strip-status">
|
||||||
<div class="status-dot inactive" id="trackingDot"></div>
|
<div class="status-dot inactive" id="trackingDot"></div>
|
||||||
@@ -1959,11 +1962,14 @@ ACARS: ${r.statistics.acarsMessages} messages`;
|
|||||||
setInterval(cleanupOldAircraft, 10000);
|
setInterval(cleanupOldAircraft, 10000);
|
||||||
checkAdsbTools();
|
checkAdsbTools();
|
||||||
checkAircraftDatabase();
|
checkAircraftDatabase();
|
||||||
checkDvbDriverConflict();
|
checkDvbDriverConflict();
|
||||||
|
|
||||||
// Auto-connect to gpsd if available
|
// Auto-connect to gpsd if available
|
||||||
autoConnectGps();
|
autoConnectGps();
|
||||||
});
|
|
||||||
|
// Sync tracking state if ADS-B already running
|
||||||
|
syncTrackingStatus();
|
||||||
|
});
|
||||||
|
|
||||||
// Track which device is being used for ADS-B tracking
|
// Track which device is being used for ADS-B tracking
|
||||||
let adsbActiveDevice = null;
|
let adsbActiveDevice = null;
|
||||||
@@ -2362,8 +2368,8 @@ sudo make install</code>
|
|||||||
return { host, port };
|
return { host, port };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleTracking() {
|
async function toggleTracking() {
|
||||||
const btn = document.getElementById('startBtn');
|
const btn = document.getElementById('startBtn');
|
||||||
|
|
||||||
if (!isTracking) {
|
if (!isTracking) {
|
||||||
// Check for remote dump1090 config
|
// Check for remote dump1090 config
|
||||||
@@ -2428,12 +2434,52 @@ sudo make install</code>
|
|||||||
document.getElementById('trackingDot').classList.add('inactive');
|
document.getElementById('trackingDot').classList.add('inactive');
|
||||||
document.getElementById('trackingStatus').textContent = 'STANDBY';
|
document.getElementById('trackingStatus').textContent = 'STANDBY';
|
||||||
// Re-enable ADS-B device selector
|
// Re-enable ADS-B device selector
|
||||||
document.getElementById('adsbDeviceSelect').disabled = false;
|
document.getElementById('adsbDeviceSelect').disabled = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function startEventStream() {
|
async function syncTrackingStatus() {
|
||||||
if (eventSource) eventSource.close();
|
try {
|
||||||
|
const response = await fetch('/adsb/session');
|
||||||
|
if (!response.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
if (!data.tracking_active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isTracking = true;
|
||||||
|
startEventStream();
|
||||||
|
drawRangeRings();
|
||||||
|
const startBtn = document.getElementById('startBtn');
|
||||||
|
startBtn.textContent = 'STOP';
|
||||||
|
startBtn.classList.add('active');
|
||||||
|
document.getElementById('trackingDot').classList.remove('inactive');
|
||||||
|
document.getElementById('trackingStatus').textContent = 'TRACKING';
|
||||||
|
document.getElementById('adsbDeviceSelect').disabled = true;
|
||||||
|
|
||||||
|
const session = data.session || {};
|
||||||
|
const startTime = session.started_at ? Date.parse(session.started_at) : null;
|
||||||
|
if (startTime) {
|
||||||
|
stats.sessionStart = startTime;
|
||||||
|
}
|
||||||
|
startSessionTimer();
|
||||||
|
|
||||||
|
const sessionDevice = session.device_index;
|
||||||
|
if (sessionDevice !== null && sessionDevice !== undefined) {
|
||||||
|
adsbActiveDevice = sessionDevice;
|
||||||
|
const adsbSelect = document.getElementById('adsbDeviceSelect');
|
||||||
|
if (adsbSelect) {
|
||||||
|
adsbSelect.value = sessionDevice;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to sync ADS-B tracking status', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEventStream() {
|
||||||
|
if (eventSource) eventSource.close();
|
||||||
|
|
||||||
console.log('Starting ADS-B event stream...');
|
console.log('Starting ADS-B event stream...');
|
||||||
eventSource = new EventSource('/adsb/stream');
|
eventSource = new EventSource('/adsb/stream');
|
||||||
|
|||||||
@@ -0,0 +1,762 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>ADS-B History // INTERCEPT</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/adsb_history.css') }}">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="radar-bg"></div>
|
||||||
|
<div class="scanline"></div>
|
||||||
|
|
||||||
|
<header class="header">
|
||||||
|
<div class="logo">
|
||||||
|
ADS-B HISTORY
|
||||||
|
<span>// INTERCEPT REPORTING</span>
|
||||||
|
</div>
|
||||||
|
<div class="status-bar">
|
||||||
|
<a href="/adsb/dashboard" class="back-link">Live Radar</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="history-shell">
|
||||||
|
<section class="summary-strip">
|
||||||
|
<div class="summary-card">
|
||||||
|
<div class="summary-label">Messages</div>
|
||||||
|
<div class="summary-value" id="summaryMessages">--</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-card">
|
||||||
|
<div class="summary-label">Snapshots</div>
|
||||||
|
<div class="summary-value" id="summarySnapshots">--</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-card">
|
||||||
|
<div class="summary-label">Aircraft</div>
|
||||||
|
<div class="summary-value" id="summaryAircraft">--</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-card">
|
||||||
|
<div class="summary-label">First Seen</div>
|
||||||
|
<div class="summary-value" id="summaryFirstSeen">--</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-card">
|
||||||
|
<div class="summary-label">Last Seen</div>
|
||||||
|
<div class="summary-value" id="summaryLastSeen">--</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="session-strip">
|
||||||
|
<div class="session-status">
|
||||||
|
<div class="status-dot" id="sessionStatusDot"></div>
|
||||||
|
<div>
|
||||||
|
<div class="session-label">Tracking</div>
|
||||||
|
<div class="session-value" id="sessionStatusText">--</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="session-metric">
|
||||||
|
<div class="session-label">Uptime</div>
|
||||||
|
<div class="session-value mono" id="sessionUptime">--:--:--</div>
|
||||||
|
</div>
|
||||||
|
<div class="session-metric">
|
||||||
|
<div class="session-label">Started</div>
|
||||||
|
<div class="session-value" id="sessionStartedAt">--</div>
|
||||||
|
</div>
|
||||||
|
<div class="session-metric">
|
||||||
|
<div class="session-label">Status</div>
|
||||||
|
<div class="session-value" id="sessionNotice">Ready</div>
|
||||||
|
</div>
|
||||||
|
<div class="session-controls">
|
||||||
|
<select id="sessionDeviceSelect"></select>
|
||||||
|
<button class="primary-btn" id="sessionToggleBtn" type="button" onclick="toggleSession()">Start Tracking</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="controls">
|
||||||
|
<div class="control-group">
|
||||||
|
<label for="windowSelect">Window</label>
|
||||||
|
<select id="windowSelect">
|
||||||
|
<option value="15">15 minutes</option>
|
||||||
|
<option value="60" selected>1 hour</option>
|
||||||
|
<option value="360">6 hours</option>
|
||||||
|
<option value="1440">24 hours</option>
|
||||||
|
<option value="10080">7 days</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="control-group">
|
||||||
|
<label for="searchInput">Search</label>
|
||||||
|
<input type="text" id="searchInput" placeholder="ICAO / callsign / registration">
|
||||||
|
</div>
|
||||||
|
<div class="control-group">
|
||||||
|
<label for="limitSelect">Limit</label>
|
||||||
|
<select id="limitSelect">
|
||||||
|
<option value="100">100</option>
|
||||||
|
<option value="200" selected>200</option>
|
||||||
|
<option value="500">500</option>
|
||||||
|
<option value="1000">1000</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button class="primary-btn" id="refreshBtn">Refresh</button>
|
||||||
|
<div class="status-pill" id="historyStatus">
|
||||||
|
{% if history_enabled %}
|
||||||
|
HISTORY ONLINE
|
||||||
|
{% else %}
|
||||||
|
HISTORY DISABLED
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="content-grid">
|
||||||
|
<div class="panel aircraft-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<span>RECENT AIRCRAFT</span>
|
||||||
|
<span class="panel-meta" id="aircraftCount">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<table class="aircraft-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ICAO</th>
|
||||||
|
<th>Callsign</th>
|
||||||
|
<th>Alt</th>
|
||||||
|
<th>Speed</th>
|
||||||
|
<th>Last Seen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="aircraftTableBody">
|
||||||
|
<tr class="empty-row">
|
||||||
|
<td colspan="5">No aircraft in this window</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel detail-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<span>DETAIL TIMELINE</span>
|
||||||
|
<span class="panel-meta" id="detailIcao">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div class="detail-card">
|
||||||
|
<div class="detail-title" id="detailTitle">Select an aircraft</div>
|
||||||
|
<div class="detail-meta" id="detailMeta">---</div>
|
||||||
|
</div>
|
||||||
|
<div class="chart-grid">
|
||||||
|
<div class="chart-card">
|
||||||
|
<div class="chart-title">Altitude (ft)</div>
|
||||||
|
<canvas id="altitudeChart"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="chart-card">
|
||||||
|
<div class="chart-title">Speed (kt)</div>
|
||||||
|
<canvas id="speedChart"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="chart-card">
|
||||||
|
<div class="chart-title">Heading (deg)</div>
|
||||||
|
<canvas id="headingChart"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="chart-card">
|
||||||
|
<div class="chart-title">Vertical Rate (fpm)</div>
|
||||||
|
<canvas id="verticalChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="timeline-list" id="timelineList">
|
||||||
|
<div class="empty-row">No timeline data</div>
|
||||||
|
</div>
|
||||||
|
<div class="squawk-list" id="squawkList">
|
||||||
|
<div class="empty-row">No squawk changes</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div class="modal-backdrop" id="aircraftModalBackdrop" aria-hidden="true">
|
||||||
|
<div class="modal-card" role="dialog" aria-modal="true">
|
||||||
|
<button class="modal-close" id="aircraftModalClose" aria-label="Close">×</button>
|
||||||
|
<div class="modal-header">
|
||||||
|
<div>
|
||||||
|
<div class="modal-title" id="modalTitle">Aircraft</div>
|
||||||
|
<div class="modal-subtitle" id="modalSubtitle">--</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="nav-btn" id="modalPrev">◀</button>
|
||||||
|
<button class="nav-btn" id="modalNext">▶</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="modal-photo">
|
||||||
|
<img id="modalPhoto" alt="Aircraft photo">
|
||||||
|
<div class="photo-fallback" id="modalPhotoFallback">No photo</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-details">
|
||||||
|
<div class="detail-row">
|
||||||
|
<span>ICAO</span>
|
||||||
|
<strong id="modalIcao">--</strong>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span>Callsign</span>
|
||||||
|
<strong id="modalCallsign">--</strong>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span>Registration</span>
|
||||||
|
<strong id="modalRegistration">--</strong>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span>Type</span>
|
||||||
|
<strong id="modalType">--</strong>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span>Altitude</span>
|
||||||
|
<strong id="modalAltitude">--</strong>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span>Speed</span>
|
||||||
|
<strong id="modalSpeed">--</strong>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span>Heading</span>
|
||||||
|
<strong id="modalHeading">--</strong>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span>Vertical Rate</span>
|
||||||
|
<strong id="modalVerticalRate">--</strong>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span>Squawk</span>
|
||||||
|
<strong id="modalSquawk">--</strong>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span>Position</span>
|
||||||
|
<strong id="modalPosition">--</strong>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span>Last Seen</span>
|
||||||
|
<strong id="modalLastSeen">--</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const historyEnabled = {{ 'true' if history_enabled else 'false' }};
|
||||||
|
|
||||||
|
const summaryMessages = document.getElementById('summaryMessages');
|
||||||
|
const summarySnapshots = document.getElementById('summarySnapshots');
|
||||||
|
const summaryAircraft = document.getElementById('summaryAircraft');
|
||||||
|
const summaryFirstSeen = document.getElementById('summaryFirstSeen');
|
||||||
|
const summaryLastSeen = document.getElementById('summaryLastSeen');
|
||||||
|
const aircraftTableBody = document.getElementById('aircraftTableBody');
|
||||||
|
const aircraftCount = document.getElementById('aircraftCount');
|
||||||
|
const detailIcao = document.getElementById('detailIcao');
|
||||||
|
const detailTitle = document.getElementById('detailTitle');
|
||||||
|
const detailMeta = document.getElementById('detailMeta');
|
||||||
|
const timelineList = document.getElementById('timelineList');
|
||||||
|
const squawkList = document.getElementById('squawkList');
|
||||||
|
const altitudeChart = document.getElementById('altitudeChart');
|
||||||
|
const speedChart = document.getElementById('speedChart');
|
||||||
|
const headingChart = document.getElementById('headingChart');
|
||||||
|
const verticalChart = document.getElementById('verticalChart');
|
||||||
|
const refreshBtn = document.getElementById('refreshBtn');
|
||||||
|
const sessionStatusDot = document.getElementById('sessionStatusDot');
|
||||||
|
const sessionStatusText = document.getElementById('sessionStatusText');
|
||||||
|
const sessionUptime = document.getElementById('sessionUptime');
|
||||||
|
const sessionStartedAt = document.getElementById('sessionStartedAt');
|
||||||
|
const sessionNotice = document.getElementById('sessionNotice');
|
||||||
|
const sessionDeviceSelect = document.getElementById('sessionDeviceSelect');
|
||||||
|
const sessionToggleBtn = document.getElementById('sessionToggleBtn');
|
||||||
|
|
||||||
|
const windowSelect = document.getElementById('windowSelect');
|
||||||
|
const searchInput = document.getElementById('searchInput');
|
||||||
|
const limitSelect = document.getElementById('limitSelect');
|
||||||
|
|
||||||
|
let selectedIcao = '';
|
||||||
|
let sessionStartAt = null;
|
||||||
|
let sessionTimer = null;
|
||||||
|
let recentAircraft = [];
|
||||||
|
let selectedIndex = -1;
|
||||||
|
const photoCache = new Map();
|
||||||
|
const modalBackdrop = document.getElementById('aircraftModalBackdrop');
|
||||||
|
const modalClose = document.getElementById('aircraftModalClose');
|
||||||
|
const modalPrev = document.getElementById('modalPrev');
|
||||||
|
const modalNext = document.getElementById('modalNext');
|
||||||
|
|
||||||
|
const modalTitle = document.getElementById('modalTitle');
|
||||||
|
const modalSubtitle = document.getElementById('modalSubtitle');
|
||||||
|
const modalPhoto = document.getElementById('modalPhoto');
|
||||||
|
const modalPhotoFallback = document.getElementById('modalPhotoFallback');
|
||||||
|
const modalIcao = document.getElementById('modalIcao');
|
||||||
|
const modalCallsign = document.getElementById('modalCallsign');
|
||||||
|
const modalRegistration = document.getElementById('modalRegistration');
|
||||||
|
const modalType = document.getElementById('modalType');
|
||||||
|
const modalAltitude = document.getElementById('modalAltitude');
|
||||||
|
const modalSpeed = document.getElementById('modalSpeed');
|
||||||
|
const modalHeading = document.getElementById('modalHeading');
|
||||||
|
const modalVerticalRate = document.getElementById('modalVerticalRate');
|
||||||
|
const modalSquawk = document.getElementById('modalSquawk');
|
||||||
|
const modalPosition = document.getElementById('modalPosition');
|
||||||
|
const modalLastSeen = document.getElementById('modalLastSeen');
|
||||||
|
|
||||||
|
function formatNumber(value) {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return '--';
|
||||||
|
}
|
||||||
|
return Number(value).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(value) {
|
||||||
|
if (!value) {
|
||||||
|
return '--';
|
||||||
|
}
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return '--';
|
||||||
|
}
|
||||||
|
return date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value) {
|
||||||
|
if (!value) {
|
||||||
|
return '--';
|
||||||
|
}
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return '--';
|
||||||
|
}
|
||||||
|
return date.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function valueOrDash(value) {
|
||||||
|
if (value === null || value === undefined || value === '') {
|
||||||
|
return '--';
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUptime(seconds) {
|
||||||
|
if (seconds === null || seconds === undefined) {
|
||||||
|
return '--:--:--';
|
||||||
|
}
|
||||||
|
const hrs = Math.floor(seconds / 3600);
|
||||||
|
const mins = Math.floor((seconds % 3600) / 60);
|
||||||
|
const secs = Math.floor(seconds % 60);
|
||||||
|
return `${hrs.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSummary() {
|
||||||
|
const sinceMinutes = windowSelect.value;
|
||||||
|
const resp = await fetch(`/adsb/history/summary?since_minutes=${sinceMinutes}`);
|
||||||
|
if (!resp.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await resp.json();
|
||||||
|
summaryMessages.textContent = formatNumber(data.message_count);
|
||||||
|
summarySnapshots.textContent = formatNumber(data.snapshot_count);
|
||||||
|
summaryAircraft.textContent = formatNumber(data.aircraft_count);
|
||||||
|
summaryFirstSeen.textContent = formatTime(data.first_seen);
|
||||||
|
summaryLastSeen.textContent = formatTime(data.last_seen);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAircraft() {
|
||||||
|
const sinceMinutes = windowSelect.value;
|
||||||
|
const limit = limitSelect.value;
|
||||||
|
const search = encodeURIComponent(searchInput.value.trim());
|
||||||
|
const resp = await fetch(`/adsb/history/aircraft?since_minutes=${sinceMinutes}&limit=${limit}&search=${search}`);
|
||||||
|
if (!resp.ok) {
|
||||||
|
aircraftTableBody.innerHTML = '<tr class="empty-row"><td colspan="5">History database unavailable</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await resp.json();
|
||||||
|
const rows = data.aircraft || [];
|
||||||
|
recentAircraft = rows;
|
||||||
|
aircraftCount.textContent = rows.length;
|
||||||
|
if (!rows.length) {
|
||||||
|
aircraftTableBody.innerHTML = '<tr class="empty-row"><td colspan="5">No aircraft in this window</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
aircraftTableBody.innerHTML = rows.map(row => `
|
||||||
|
<tr class="aircraft-row" data-icao="${row.icao}">
|
||||||
|
<td class="mono">${row.icao}</td>
|
||||||
|
<td>${valueOrDash(row.callsign)}</td>
|
||||||
|
<td>${valueOrDash(row.altitude)}</td>
|
||||||
|
<td>${valueOrDash(row.speed)}</td>
|
||||||
|
<td>${formatTime(row.last_seen)}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
document.querySelectorAll('.aircraft-row').forEach((row, index) => {
|
||||||
|
row.addEventListener('click', () => {
|
||||||
|
selectAircraft(row.dataset.icao);
|
||||||
|
openModal(index);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectAircraft(icao) {
|
||||||
|
selectedIcao = icao;
|
||||||
|
detailIcao.textContent = icao || '--';
|
||||||
|
if (!icao) {
|
||||||
|
detailTitle.textContent = 'Select an aircraft';
|
||||||
|
detailMeta.textContent = '---';
|
||||||
|
timelineList.innerHTML = '<div class="empty-row">No timeline data</div>';
|
||||||
|
squawkList.innerHTML = '<div class="empty-row">No squawk changes</div>';
|
||||||
|
drawMetricChart(altitudeChart, [], 'altitude', 'Altitude', 'ft');
|
||||||
|
drawMetricChart(speedChart, [], 'speed', 'Speed', 'kt');
|
||||||
|
drawMetricChart(headingChart, [], 'heading', 'Heading', 'deg');
|
||||||
|
drawMetricChart(verticalChart, [], 'vertical_rate', 'Vertical Rate', 'fpm');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await loadTimeline(icao);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTimeline(icao) {
|
||||||
|
const sinceMinutes = windowSelect.value;
|
||||||
|
const resp = await fetch(`/adsb/history/timeline?icao=${icao}&since_minutes=${sinceMinutes}`);
|
||||||
|
if (!resp.ok) {
|
||||||
|
timelineList.innerHTML = '<div class="empty-row">Timeline unavailable</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await resp.json();
|
||||||
|
const timeline = data.timeline || [];
|
||||||
|
if (!timeline.length) {
|
||||||
|
timelineList.innerHTML = '<div class="empty-row">No timeline data</div>';
|
||||||
|
squawkList.innerHTML = '<div class="empty-row">No squawk changes</div>';
|
||||||
|
drawMetricChart(altitudeChart, [], 'altitude', 'Altitude', 'ft');
|
||||||
|
drawMetricChart(speedChart, [], 'speed', 'Speed', 'kt');
|
||||||
|
drawMetricChart(headingChart, [], 'heading', 'Heading', 'deg');
|
||||||
|
drawMetricChart(verticalChart, [], 'vertical_rate', 'Vertical Rate', 'fpm');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const latest = timeline[timeline.length - 1];
|
||||||
|
detailTitle.textContent = `${icao} Timeline`;
|
||||||
|
detailMeta.textContent = `Alt ${valueOrDash(latest.altitude)} ft | Spd ${valueOrDash(latest.speed)} kt | Head ${valueOrDash(latest.heading)}`;
|
||||||
|
timelineList.innerHTML = timeline.slice(-30).reverse().map(point => `
|
||||||
|
<div class="timeline-row">
|
||||||
|
<span>${formatTime(point.captured_at)}</span>
|
||||||
|
<span>Alt ${valueOrDash(point.altitude)} ft</span>
|
||||||
|
<span>Spd ${valueOrDash(point.speed)} kt</span>
|
||||||
|
<span>Hdg ${valueOrDash(point.heading)}</span>
|
||||||
|
<span>V/S ${valueOrDash(point.vertical_rate)} fpm</span>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
updateSquawkChanges(timeline);
|
||||||
|
drawMetricChart(altitudeChart, timeline, 'altitude', 'Altitude', 'ft');
|
||||||
|
drawMetricChart(speedChart, timeline, 'speed', 'Speed', 'kt');
|
||||||
|
drawMetricChart(headingChart, timeline, 'heading', 'Heading', 'deg');
|
||||||
|
drawMetricChart(verticalChart, timeline, 'vertical_rate', 'Vertical Rate', 'fpm');
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawMetricChart(canvas, points, field, label, unit) {
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const width = canvas.clientWidth;
|
||||||
|
const height = canvas.clientHeight;
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
ctx.clearRect(0, 0, width, height);
|
||||||
|
|
||||||
|
if (!points.length) {
|
||||||
|
ctx.fillStyle = 'rgba(156, 163, 175, 0.6)';
|
||||||
|
ctx.font = '12px "JetBrains Mono", monospace';
|
||||||
|
ctx.fillText(`No ${label.toLowerCase()} data`, 12, height / 2);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const series = points.map(p => p[field]).filter(v => v !== null && v !== undefined);
|
||||||
|
if (!series.length) {
|
||||||
|
ctx.fillStyle = 'rgba(156, 163, 175, 0.6)';
|
||||||
|
ctx.font = '12px "JetBrains Mono", monospace';
|
||||||
|
ctx.fillText(`No ${label.toLowerCase()} data`, 12, height / 2);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const minVal = Math.min(...series);
|
||||||
|
const maxVal = Math.max(...series);
|
||||||
|
const range = maxVal - minVal || 1;
|
||||||
|
const padding = 20;
|
||||||
|
|
||||||
|
ctx.strokeStyle = 'rgba(74, 158, 255, 0.4)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(padding, padding);
|
||||||
|
ctx.lineTo(padding, height - padding);
|
||||||
|
ctx.lineTo(width - padding, height - padding);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
ctx.strokeStyle = 'rgba(74, 158, 255, 0.9)';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
let started = false;
|
||||||
|
const span = Math.max(1, points.length - 1);
|
||||||
|
points.forEach((point, index) => {
|
||||||
|
if (point[field] === null || point[field] === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const x = padding + (index / span) * (width - padding * 2);
|
||||||
|
const y = height - padding - ((point[field] - minVal) / range) * (height - padding * 2);
|
||||||
|
if (!started) {
|
||||||
|
ctx.moveTo(x, y);
|
||||||
|
started = true;
|
||||||
|
} else {
|
||||||
|
ctx.lineTo(x, y);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (started) {
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.fillStyle = 'rgba(226, 232, 240, 0.8)';
|
||||||
|
ctx.font = '11px "JetBrains Mono", monospace';
|
||||||
|
ctx.fillText(`${maxVal} ${unit}`, 12, padding);
|
||||||
|
ctx.fillText(`${minVal} ${unit}`, 12, height - padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSquawkChanges(points) {
|
||||||
|
const changes = [];
|
||||||
|
let lastSquawk = null;
|
||||||
|
points.forEach(point => {
|
||||||
|
if (point.squawk && point.squawk !== lastSquawk) {
|
||||||
|
changes.push({ time: point.captured_at, squawk: point.squawk });
|
||||||
|
lastSquawk = point.squawk;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!changes.length) {
|
||||||
|
squawkList.innerHTML = '<div class="empty-row">No squawk changes</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
squawkList.innerHTML = changes.slice(-10).reverse().map(change => `
|
||||||
|
<div class="timeline-row">
|
||||||
|
<span>${formatTime(change.time)}</span>
|
||||||
|
<span>Squawk ${change.squawk}</span>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSessionDevices() {
|
||||||
|
if (!historyEnabled) {
|
||||||
|
sessionDeviceSelect.innerHTML = '<option value="0">History disabled</option>';
|
||||||
|
sessionDeviceSelect.disabled = true;
|
||||||
|
sessionToggleBtn.disabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const resp = await fetch('/devices');
|
||||||
|
if (!resp.ok) {
|
||||||
|
sessionDeviceSelect.innerHTML = '<option value="0">No SDR</option>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const devices = await resp.json();
|
||||||
|
sessionDeviceSelect.innerHTML = '';
|
||||||
|
if (!devices.length) {
|
||||||
|
sessionDeviceSelect.innerHTML = '<option value="0">No SDR</option>';
|
||||||
|
sessionDeviceSelect.disabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
devices.forEach((dev, idx) => {
|
||||||
|
const index = dev.index !== undefined ? dev.index : idx;
|
||||||
|
const type = (dev.sdr_type || dev.driver || 'RTL-SDR').toUpperCase();
|
||||||
|
const serial = dev.serial ? ` (${dev.serial.slice(-4)})` : '';
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = index;
|
||||||
|
opt.textContent = `${type} #${index}${serial}`;
|
||||||
|
sessionDeviceSelect.appendChild(opt);
|
||||||
|
});
|
||||||
|
sessionDeviceSelect.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSessionStatus() {
|
||||||
|
const resp = await fetch('/adsb/session');
|
||||||
|
if (!resp.ok) {
|
||||||
|
sessionStatusText.textContent = 'UNKNOWN';
|
||||||
|
sessionStatusDot.classList.remove('active');
|
||||||
|
sessionNotice.textContent = 'Session unavailable';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.tracking_active) {
|
||||||
|
sessionStatusText.textContent = 'TRACKING';
|
||||||
|
sessionStatusDot.classList.add('active');
|
||||||
|
sessionToggleBtn.textContent = 'Stop Tracking';
|
||||||
|
sessionToggleBtn.classList.add('stop');
|
||||||
|
sessionNotice.textContent = 'Live';
|
||||||
|
} else {
|
||||||
|
sessionStatusText.textContent = 'STANDBY';
|
||||||
|
sessionStatusDot.classList.remove('active');
|
||||||
|
sessionToggleBtn.textContent = 'Start Tracking';
|
||||||
|
sessionToggleBtn.classList.remove('stop');
|
||||||
|
sessionNotice.textContent = 'Idle';
|
||||||
|
}
|
||||||
|
if (data.session && data.session.started_at) {
|
||||||
|
sessionStartAt = new Date(data.session.started_at);
|
||||||
|
sessionStartedAt.textContent = formatDateTime(data.session.started_at);
|
||||||
|
sessionUptime.textContent = formatUptime(data.uptime_seconds);
|
||||||
|
} else {
|
||||||
|
sessionStartAt = null;
|
||||||
|
sessionStartedAt.textContent = '--';
|
||||||
|
sessionUptime.textContent = '--:--:--';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startSessionTimer() {
|
||||||
|
if (sessionTimer) {
|
||||||
|
clearInterval(sessionTimer);
|
||||||
|
}
|
||||||
|
sessionTimer = setInterval(() => {
|
||||||
|
if (!sessionStartAt) {
|
||||||
|
sessionUptime.textContent = '--:--:--';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const seconds = Math.floor((Date.now() - sessionStartAt.getTime()) / 1000);
|
||||||
|
sessionUptime.textContent = formatUptime(seconds);
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleSession() {
|
||||||
|
if (!historyEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sessionToggleBtn.disabled = true;
|
||||||
|
sessionNotice.textContent = 'Working...';
|
||||||
|
if (sessionStatusText.textContent === 'TRACKING') {
|
||||||
|
const resp = await fetch('/adsb/stop', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'same-origin',
|
||||||
|
body: JSON.stringify({ source: 'adsb_history' })
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
sessionNotice.textContent = 'Stop failed';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const device = parseInt(sessionDeviceSelect.value, 10) || 0;
|
||||||
|
const resp = await fetch('/adsb/start', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'same-origin',
|
||||||
|
body: JSON.stringify({ device, source: 'adsb_history' })
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
sessionNotice.textContent = 'Start failed';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await loadSessionStatus();
|
||||||
|
sessionToggleBtn.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openModal(index) {
|
||||||
|
if (index < 0 || index >= recentAircraft.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
selectedIndex = index;
|
||||||
|
const ac = recentAircraft[index];
|
||||||
|
modalTitle.textContent = ac.callsign || ac.icao || 'Aircraft';
|
||||||
|
modalSubtitle.textContent = `${valueOrDash(ac.registration)} • ${valueOrDash(ac.type_code)}`;
|
||||||
|
modalIcao.textContent = valueOrDash(ac.icao);
|
||||||
|
modalCallsign.textContent = valueOrDash(ac.callsign);
|
||||||
|
modalRegistration.textContent = valueOrDash(ac.registration);
|
||||||
|
modalType.textContent = [ac.type_desc, ac.type_code].filter(Boolean).join(' • ') || '--';
|
||||||
|
modalAltitude.textContent = ac.altitude ? `${ac.altitude} ft` : '--';
|
||||||
|
modalSpeed.textContent = ac.speed ? `${ac.speed} kt` : '--';
|
||||||
|
modalHeading.textContent = ac.heading !== null && ac.heading !== undefined ? `${ac.heading}°` : '--';
|
||||||
|
modalVerticalRate.textContent = ac.vertical_rate ? `${ac.vertical_rate} fpm` : '--';
|
||||||
|
modalSquawk.textContent = valueOrDash(ac.squawk);
|
||||||
|
if (ac.lat !== null && ac.lat !== undefined && ac.lon !== null && ac.lon !== undefined) {
|
||||||
|
modalPosition.textContent = `${ac.lat.toFixed(4)}, ${ac.lon.toFixed(4)}`;
|
||||||
|
} else {
|
||||||
|
modalPosition.textContent = '--';
|
||||||
|
}
|
||||||
|
modalLastSeen.textContent = formatDateTime(ac.last_seen);
|
||||||
|
|
||||||
|
await loadPhoto(ac.registration);
|
||||||
|
modalBackdrop.classList.add('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
modalBackdrop.classList.remove('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPhoto(registration) {
|
||||||
|
if (!registration) {
|
||||||
|
modalPhoto.style.display = 'none';
|
||||||
|
modalPhotoFallback.style.display = 'flex';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (photoCache.has(registration)) {
|
||||||
|
const url = photoCache.get(registration);
|
||||||
|
if (url) {
|
||||||
|
modalPhoto.src = url;
|
||||||
|
modalPhoto.style.display = 'block';
|
||||||
|
modalPhotoFallback.style.display = 'none';
|
||||||
|
} else {
|
||||||
|
modalPhoto.style.display = 'none';
|
||||||
|
modalPhotoFallback.style.display = 'flex';
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/adsb/aircraft-photo/${encodeURIComponent(registration)}`);
|
||||||
|
const data = await resp.json();
|
||||||
|
const url = data && data.thumbnail;
|
||||||
|
photoCache.set(registration, url || null);
|
||||||
|
if (url) {
|
||||||
|
modalPhoto.src = url;
|
||||||
|
modalPhoto.onerror = () => {
|
||||||
|
modalPhoto.style.display = 'none';
|
||||||
|
modalPhotoFallback.style.display = 'flex';
|
||||||
|
};
|
||||||
|
modalPhoto.style.display = 'block';
|
||||||
|
modalPhotoFallback.style.display = 'none';
|
||||||
|
} else {
|
||||||
|
modalPhoto.style.display = 'none';
|
||||||
|
modalPhotoFallback.style.display = 'flex';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
modalPhoto.style.display = 'none';
|
||||||
|
modalPhotoFallback.style.display = 'flex';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveModal(offset) {
|
||||||
|
const nextIndex = selectedIndex + offset;
|
||||||
|
if (nextIndex < 0 || nextIndex >= recentAircraft.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openModal(nextIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAll() {
|
||||||
|
if (!historyEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await Promise.all([loadSummary(), loadAircraft(), loadSessionStatus()]);
|
||||||
|
if (selectedIcao) {
|
||||||
|
await loadTimeline(selectedIcao);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshBtn.addEventListener('click', refreshAll);
|
||||||
|
windowSelect.addEventListener('change', refreshAll);
|
||||||
|
limitSelect.addEventListener('change', refreshAll);
|
||||||
|
searchInput.addEventListener('input', () => {
|
||||||
|
clearTimeout(searchInput._debounce);
|
||||||
|
searchInput._debounce = setTimeout(refreshAll, 350);
|
||||||
|
});
|
||||||
|
|
||||||
|
refreshAll();
|
||||||
|
loadSessionDevices();
|
||||||
|
startSessionTimer();
|
||||||
|
modalClose.addEventListener('click', closeModal);
|
||||||
|
modalBackdrop.addEventListener('click', (event) => {
|
||||||
|
if (event.target === modalBackdrop) {
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
modalPrev.addEventListener('click', () => moveModal(-1));
|
||||||
|
modalNext.addEventListener('click', () => moveModal(1));
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
if (selectedIcao) {
|
||||||
|
loadTimeline(selectedIcao);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,397 @@
|
|||||||
|
"""ADS-B history persistence to PostgreSQL."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import queue
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
import psycopg2
|
||||||
|
from psycopg2.extras import execute_values, Json
|
||||||
|
|
||||||
|
from config import (
|
||||||
|
ADSB_DB_HOST,
|
||||||
|
ADSB_DB_NAME,
|
||||||
|
ADSB_DB_PASSWORD,
|
||||||
|
ADSB_DB_PORT,
|
||||||
|
ADSB_DB_USER,
|
||||||
|
ADSB_HISTORY_BATCH_SIZE,
|
||||||
|
ADSB_HISTORY_ENABLED,
|
||||||
|
ADSB_HISTORY_FLUSH_INTERVAL,
|
||||||
|
ADSB_HISTORY_QUEUE_SIZE,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger('intercept.adsb_history')
|
||||||
|
|
||||||
|
|
||||||
|
_MESSAGE_FIELDS = (
|
||||||
|
'received_at',
|
||||||
|
'msg_time',
|
||||||
|
'logged_time',
|
||||||
|
'icao',
|
||||||
|
'msg_type',
|
||||||
|
'callsign',
|
||||||
|
'altitude',
|
||||||
|
'speed',
|
||||||
|
'heading',
|
||||||
|
'vertical_rate',
|
||||||
|
'lat',
|
||||||
|
'lon',
|
||||||
|
'squawk',
|
||||||
|
'session_id',
|
||||||
|
'aircraft_id',
|
||||||
|
'flight_id',
|
||||||
|
'raw_line',
|
||||||
|
'source_host',
|
||||||
|
)
|
||||||
|
|
||||||
|
_MESSAGE_INSERT_SQL = f"""
|
||||||
|
INSERT INTO adsb_messages ({', '.join(_MESSAGE_FIELDS)})
|
||||||
|
VALUES %s
|
||||||
|
"""
|
||||||
|
|
||||||
|
_SNAPSHOT_FIELDS = (
|
||||||
|
'captured_at',
|
||||||
|
'icao',
|
||||||
|
'callsign',
|
||||||
|
'registration',
|
||||||
|
'type_code',
|
||||||
|
'type_desc',
|
||||||
|
'altitude',
|
||||||
|
'speed',
|
||||||
|
'heading',
|
||||||
|
'vertical_rate',
|
||||||
|
'lat',
|
||||||
|
'lon',
|
||||||
|
'squawk',
|
||||||
|
'source_host',
|
||||||
|
'snapshot',
|
||||||
|
)
|
||||||
|
|
||||||
|
_SNAPSHOT_INSERT_SQL = f"""
|
||||||
|
INSERT INTO adsb_snapshots ({', '.join(_SNAPSHOT_FIELDS)})
|
||||||
|
VALUES %s
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _ensure_adsb_schema(conn: psycopg2.extensions.connection) -> None:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS adsb_messages (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
msg_time TIMESTAMPTZ,
|
||||||
|
logged_time TIMESTAMPTZ,
|
||||||
|
icao TEXT NOT NULL,
|
||||||
|
msg_type SMALLINT,
|
||||||
|
callsign TEXT,
|
||||||
|
altitude INTEGER,
|
||||||
|
speed INTEGER,
|
||||||
|
heading INTEGER,
|
||||||
|
vertical_rate INTEGER,
|
||||||
|
lat DOUBLE PRECISION,
|
||||||
|
lon DOUBLE PRECISION,
|
||||||
|
squawk TEXT,
|
||||||
|
session_id TEXT,
|
||||||
|
aircraft_id TEXT,
|
||||||
|
flight_id TEXT,
|
||||||
|
raw_line TEXT,
|
||||||
|
source_host TEXT
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_adsb_messages_icao_time
|
||||||
|
ON adsb_messages (icao, received_at)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_adsb_messages_received_at
|
||||||
|
ON adsb_messages (received_at)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_adsb_messages_msg_time
|
||||||
|
ON adsb_messages (msg_time)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS adsb_snapshots (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
captured_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
icao TEXT NOT NULL,
|
||||||
|
callsign TEXT,
|
||||||
|
registration TEXT,
|
||||||
|
type_code TEXT,
|
||||||
|
type_desc TEXT,
|
||||||
|
altitude INTEGER,
|
||||||
|
speed INTEGER,
|
||||||
|
heading INTEGER,
|
||||||
|
vertical_rate INTEGER,
|
||||||
|
lat DOUBLE PRECISION,
|
||||||
|
lon DOUBLE PRECISION,
|
||||||
|
squawk TEXT,
|
||||||
|
source_host TEXT,
|
||||||
|
snapshot JSONB
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_adsb_snapshots_icao_time
|
||||||
|
ON adsb_snapshots (icao, captured_at)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_adsb_snapshots_captured_at
|
||||||
|
ON adsb_snapshots (captured_at)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS adsb_sessions (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
ended_at TIMESTAMPTZ,
|
||||||
|
device_index INTEGER,
|
||||||
|
sdr_type TEXT,
|
||||||
|
remote_host TEXT,
|
||||||
|
remote_port INTEGER,
|
||||||
|
start_source TEXT,
|
||||||
|
stop_source TEXT,
|
||||||
|
started_by TEXT,
|
||||||
|
stopped_by TEXT,
|
||||||
|
notes TEXT
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_adsb_sessions_started_at
|
||||||
|
ON adsb_sessions (started_at)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_adsb_sessions_active
|
||||||
|
ON adsb_sessions (ended_at)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _make_dsn() -> str:
|
||||||
|
return (
|
||||||
|
f"host={ADSB_DB_HOST} port={ADSB_DB_PORT} dbname={ADSB_DB_NAME} "
|
||||||
|
f"user={ADSB_DB_USER} password={ADSB_DB_PASSWORD}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AdsbHistoryWriter:
|
||||||
|
"""Background writer for ADS-B history records."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.enabled = ADSB_HISTORY_ENABLED
|
||||||
|
self._queue: queue.Queue[dict] = queue.Queue(maxsize=ADSB_HISTORY_QUEUE_SIZE)
|
||||||
|
self._thread: threading.Thread | None = None
|
||||||
|
self._stop_event = threading.Event()
|
||||||
|
self._conn: psycopg2.extensions.connection | None = None
|
||||||
|
self._dropped = 0
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
if not self.enabled:
|
||||||
|
return
|
||||||
|
if self._thread and self._thread.is_alive():
|
||||||
|
return
|
||||||
|
self._thread = threading.Thread(target=self._run, name='adsb-history-writer', daemon=True)
|
||||||
|
self._thread.start()
|
||||||
|
logger.info("ADS-B history writer started")
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self._stop_event.set()
|
||||||
|
|
||||||
|
def enqueue(self, record: dict) -> None:
|
||||||
|
if not self.enabled:
|
||||||
|
return
|
||||||
|
if 'received_at' not in record or record['received_at'] is None:
|
||||||
|
record['received_at'] = datetime.now(timezone.utc)
|
||||||
|
try:
|
||||||
|
self._queue.put_nowait(record)
|
||||||
|
except queue.Full:
|
||||||
|
self._dropped += 1
|
||||||
|
if self._dropped % 1000 == 0:
|
||||||
|
logger.warning("ADS-B history queue full, dropped %d records", self._dropped)
|
||||||
|
|
||||||
|
def _run(self) -> None:
|
||||||
|
batch: list[dict] = []
|
||||||
|
last_flush = time.time()
|
||||||
|
|
||||||
|
while not self._stop_event.is_set():
|
||||||
|
timeout = max(0.0, ADSB_HISTORY_FLUSH_INTERVAL - (time.time() - last_flush))
|
||||||
|
try:
|
||||||
|
item = self._queue.get(timeout=timeout)
|
||||||
|
batch.append(item)
|
||||||
|
except queue.Empty:
|
||||||
|
pass
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
if batch and (len(batch) >= ADSB_HISTORY_BATCH_SIZE or now - last_flush >= ADSB_HISTORY_FLUSH_INTERVAL):
|
||||||
|
if self._flush(batch):
|
||||||
|
batch.clear()
|
||||||
|
last_flush = now
|
||||||
|
|
||||||
|
def _ensure_connection(self) -> psycopg2.extensions.connection | None:
|
||||||
|
if self._conn:
|
||||||
|
return self._conn
|
||||||
|
try:
|
||||||
|
self._conn = psycopg2.connect(_make_dsn())
|
||||||
|
self._conn.autocommit = False
|
||||||
|
self._ensure_schema(self._conn)
|
||||||
|
return self._conn
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("ADS-B history DB connection failed: %s", exc)
|
||||||
|
self._conn = None
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _ensure_schema(self, conn: psycopg2.extensions.connection) -> None:
|
||||||
|
_ensure_adsb_schema(conn)
|
||||||
|
|
||||||
|
def _flush(self, batch: Iterable[dict]) -> bool:
|
||||||
|
conn = self._ensure_connection()
|
||||||
|
if not conn:
|
||||||
|
time.sleep(2.0)
|
||||||
|
return False
|
||||||
|
|
||||||
|
values = []
|
||||||
|
for record in batch:
|
||||||
|
values.append(tuple(record.get(field) for field in _MESSAGE_FIELDS))
|
||||||
|
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
execute_values(cur, _MESSAGE_INSERT_SQL, values)
|
||||||
|
conn.commit()
|
||||||
|
return True
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("ADS-B history insert failed: %s", exc)
|
||||||
|
try:
|
||||||
|
conn.rollback()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._conn = None
|
||||||
|
time.sleep(2.0)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
adsb_history_writer = AdsbHistoryWriter()
|
||||||
|
|
||||||
|
|
||||||
|
class AdsbSnapshotWriter:
|
||||||
|
"""Background writer for ADS-B snapshot records."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.enabled = ADSB_HISTORY_ENABLED
|
||||||
|
self._queue: queue.Queue[dict] = queue.Queue(maxsize=ADSB_HISTORY_QUEUE_SIZE)
|
||||||
|
self._thread: threading.Thread | None = None
|
||||||
|
self._stop_event = threading.Event()
|
||||||
|
self._conn: psycopg2.extensions.connection | None = None
|
||||||
|
self._dropped = 0
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
if not self.enabled:
|
||||||
|
return
|
||||||
|
if self._thread and self._thread.is_alive():
|
||||||
|
return
|
||||||
|
self._thread = threading.Thread(target=self._run, name='adsb-snapshot-writer', daemon=True)
|
||||||
|
self._thread.start()
|
||||||
|
logger.info("ADS-B snapshot writer started")
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self._stop_event.set()
|
||||||
|
|
||||||
|
def enqueue(self, record: dict) -> None:
|
||||||
|
if not self.enabled:
|
||||||
|
return
|
||||||
|
if 'captured_at' not in record or record['captured_at'] is None:
|
||||||
|
record['captured_at'] = datetime.now(timezone.utc)
|
||||||
|
try:
|
||||||
|
self._queue.put_nowait(record)
|
||||||
|
except queue.Full:
|
||||||
|
self._dropped += 1
|
||||||
|
if self._dropped % 1000 == 0:
|
||||||
|
logger.warning("ADS-B snapshot queue full, dropped %d records", self._dropped)
|
||||||
|
|
||||||
|
def _run(self) -> None:
|
||||||
|
batch: list[dict] = []
|
||||||
|
last_flush = time.time()
|
||||||
|
|
||||||
|
while not self._stop_event.is_set():
|
||||||
|
timeout = max(0.0, ADSB_HISTORY_FLUSH_INTERVAL - (time.time() - last_flush))
|
||||||
|
try:
|
||||||
|
item = self._queue.get(timeout=timeout)
|
||||||
|
batch.append(item)
|
||||||
|
except queue.Empty:
|
||||||
|
pass
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
if batch and (len(batch) >= ADSB_HISTORY_BATCH_SIZE or now - last_flush >= ADSB_HISTORY_FLUSH_INTERVAL):
|
||||||
|
if self._flush(batch):
|
||||||
|
batch.clear()
|
||||||
|
last_flush = now
|
||||||
|
|
||||||
|
def _ensure_connection(self) -> psycopg2.extensions.connection | None:
|
||||||
|
if self._conn:
|
||||||
|
return self._conn
|
||||||
|
try:
|
||||||
|
self._conn = psycopg2.connect(_make_dsn())
|
||||||
|
self._conn.autocommit = False
|
||||||
|
self._ensure_schema(self._conn)
|
||||||
|
return self._conn
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("ADS-B snapshot DB connection failed: %s", exc)
|
||||||
|
self._conn = None
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _ensure_schema(self, conn: psycopg2.extensions.connection) -> None:
|
||||||
|
_ensure_adsb_schema(conn)
|
||||||
|
|
||||||
|
def _flush(self, batch: Iterable[dict]) -> bool:
|
||||||
|
conn = self._ensure_connection()
|
||||||
|
if not conn:
|
||||||
|
time.sleep(2.0)
|
||||||
|
return False
|
||||||
|
|
||||||
|
values = []
|
||||||
|
for record in batch:
|
||||||
|
row = []
|
||||||
|
for field in _SNAPSHOT_FIELDS:
|
||||||
|
value = record.get(field)
|
||||||
|
if field == 'snapshot' and value is not None:
|
||||||
|
value = Json(value)
|
||||||
|
row.append(value)
|
||||||
|
values.append(tuple(row))
|
||||||
|
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
execute_values(cur, _SNAPSHOT_INSERT_SQL, values)
|
||||||
|
conn.commit()
|
||||||
|
return True
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("ADS-B snapshot insert failed: %s", exc)
|
||||||
|
try:
|
||||||
|
conn.rollback()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._conn = None
|
||||||
|
time.sleep(2.0)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
adsb_snapshot_writer = AdsbSnapshotWriter()
|
||||||
Reference in New Issue
Block a user