240 lines
7.5 KiB
Python
240 lines
7.5 KiB
Python
"""
|
|
FR24 API Service
|
|
Minimal Flask API reading from PostgreSQL fr24 schema.
|
|
"""
|
|
import os
|
|
import time
|
|
import logging
|
|
from datetime import datetime, timezone
|
|
from functools import wraps
|
|
|
|
import psycopg2
|
|
import psycopg2.extras
|
|
from flask import Flask, jsonify, request
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s [api] %(levelname)s %(message)s",
|
|
datefmt="%Y-%m-%dT%H:%M:%S",
|
|
)
|
|
log = logging.getLogger("api")
|
|
|
|
app = Flask(__name__)
|
|
|
|
DB_DSN = (
|
|
f"host={os.environ['POSTGRES_HOST']} "
|
|
f"port={os.environ.get('POSTGRES_PORT', 5432)} "
|
|
f"dbname={os.environ['POSTGRES_DB']} "
|
|
f"user={os.environ['POSTGRES_USER']} "
|
|
f"password={os.environ['POSTGRES_PASSWORD']}"
|
|
)
|
|
API_PORT = int(os.environ.get("API_PORT", 8080))
|
|
HEALTHCHECK_FILE = "/tmp/api-ready"
|
|
START_TIME = datetime.now(timezone.utc)
|
|
|
|
# ── db connection (simple persistent conn with reconnect) ─────────────────────
|
|
|
|
_conn = None
|
|
|
|
def get_conn():
|
|
global _conn
|
|
if _conn is None or _conn.closed:
|
|
_conn = psycopg2.connect(DB_DSN)
|
|
psycopg2.extras.register_uuid(_conn)
|
|
log.info("DB connection established")
|
|
return _conn
|
|
|
|
|
|
def query(sql: str, params=None) -> list:
|
|
for attempt in range(2):
|
|
try:
|
|
conn = get_conn()
|
|
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
|
cur.execute(sql, params)
|
|
return [dict(r) for r in cur.fetchall()]
|
|
except psycopg2.OperationalError:
|
|
global _conn
|
|
_conn = None
|
|
if attempt == 1:
|
|
raise
|
|
|
|
|
|
def query_one(sql: str, params=None) -> dict | None:
|
|
rows = query(sql, params)
|
|
return rows[0] if rows else None
|
|
|
|
|
|
# ── serialisation helper ──────────────────────────────────────────────────────
|
|
|
|
def serial(obj):
|
|
"""Make psycopg2 types JSON-serialisable."""
|
|
import decimal, uuid
|
|
if isinstance(obj, (datetime,)):
|
|
return obj.isoformat()
|
|
if isinstance(obj, decimal.Decimal):
|
|
return float(obj)
|
|
if isinstance(obj, uuid.UUID):
|
|
return str(obj)
|
|
raise TypeError(f"Not serialisable: {type(obj)}")
|
|
|
|
|
|
def ok(data, **kwargs):
|
|
return app.response_class(
|
|
__import__("json").dumps(data, default=serial),
|
|
mimetype="application/json",
|
|
**kwargs,
|
|
)
|
|
|
|
|
|
def err(msg: str, status: int = 500):
|
|
return ok({"error": msg}, status=status)
|
|
|
|
|
|
# ── routes ────────────────────────────────────────────────────────────────────
|
|
|
|
@app.get("/health")
|
|
def health():
|
|
try:
|
|
query_one("SELECT 1")
|
|
db_ok = True
|
|
except Exception as e:
|
|
db_ok = False
|
|
return ok({
|
|
"status": "ok" if db_ok else "degraded",
|
|
"db": "ok" if db_ok else "error",
|
|
"uptime_seconds": int((datetime.now(timezone.utc) - START_TIME).total_seconds()),
|
|
}, status=200 if db_ok else 503)
|
|
|
|
|
|
@app.get("/dashboard/status")
|
|
def dashboard_status():
|
|
try:
|
|
captures = query_one("SELECT COUNT(*) AS total, COUNT(*) FILTER (WHERE status='active') AS active FROM fr24.captures")
|
|
packets = query_one("SELECT COUNT(*) AS total FROM fr24.raw_packets")
|
|
state = query_one("SELECT state_value FROM fr24.processing_state WHERE state_key='preprocess_cursor'")
|
|
aircraft = query_one("SELECT COUNT(*) AS total FROM fr24.aircraft")
|
|
flights = query_one("SELECT COUNT(*) AS total, COUNT(*) FILTER (WHERE status='active') AS active FROM fr24.flights")
|
|
return ok({
|
|
"captures": captures,
|
|
"raw_packets": packets,
|
|
"processing_state": state["state_value"] if state else None,
|
|
"aircraft": aircraft,
|
|
"flights": flights,
|
|
})
|
|
except Exception as e:
|
|
return err(str(e))
|
|
|
|
|
|
@app.get("/viewer/config")
|
|
def viewer_config():
|
|
return ok({
|
|
"system": "fr24-ingest",
|
|
"version": "0.1.0-scaffold",
|
|
"stage": "step-1-fake-data",
|
|
"db_schema": "fr24",
|
|
"center": {"lat": 52.0, "lon": 27.0},
|
|
"zoom": 6,
|
|
"features": {
|
|
"adsb_decode": False,
|
|
"real_rtlsdr": False,
|
|
"noise_model": False,
|
|
},
|
|
})
|
|
|
|
|
|
@app.get("/captures")
|
|
def captures():
|
|
try:
|
|
limit = min(int(request.args.get("limit", 50)), 200)
|
|
rows = query(
|
|
"""
|
|
SELECT capture_id, started_at, ended_at, source, device_index,
|
|
center_frequency_hz, sample_rate_hz, gain_db, status, notes, created_at
|
|
FROM fr24.captures
|
|
ORDER BY started_at DESC
|
|
LIMIT %s
|
|
""",
|
|
(limit,),
|
|
)
|
|
return ok({"captures": rows, "count": len(rows)})
|
|
except Exception as e:
|
|
return err(str(e))
|
|
|
|
|
|
@app.get("/aircraft")
|
|
def aircraft():
|
|
try:
|
|
limit = min(int(request.args.get("limit", 100)), 500)
|
|
rows = query(
|
|
"""
|
|
SELECT aircraft_id, icao24, callsign, registration, aircraft_type,
|
|
operator_name, first_seen_at, last_seen_at
|
|
FROM fr24.aircraft
|
|
ORDER BY last_seen_at DESC NULLS LAST
|
|
LIMIT %s
|
|
""",
|
|
(limit,),
|
|
)
|
|
return ok({"aircraft": rows, "count": len(rows)})
|
|
except Exception as e:
|
|
return err(str(e))
|
|
|
|
|
|
@app.get("/flights")
|
|
def flights():
|
|
try:
|
|
limit = min(int(request.args.get("limit", 100)), 500)
|
|
status_filter = request.args.get("status")
|
|
if status_filter:
|
|
rows = query(
|
|
"""
|
|
SELECT f.flight_id, f.aircraft_id, a.icao24, f.callsign,
|
|
f.departure_airport, f.arrival_airport,
|
|
f.started_at, f.ended_at, f.status, f.source
|
|
FROM fr24.flights f
|
|
JOIN fr24.aircraft a USING (aircraft_id)
|
|
WHERE f.status = %s
|
|
ORDER BY f.started_at DESC
|
|
LIMIT %s
|
|
""",
|
|
(status_filter, limit),
|
|
)
|
|
else:
|
|
rows = query(
|
|
"""
|
|
SELECT f.flight_id, f.aircraft_id, a.icao24, f.callsign,
|
|
f.departure_airport, f.arrival_airport,
|
|
f.started_at, f.ended_at, f.status, f.source
|
|
FROM fr24.flights f
|
|
JOIN fr24.aircraft a USING (aircraft_id)
|
|
ORDER BY f.started_at DESC
|
|
LIMIT %s
|
|
""",
|
|
(limit,),
|
|
)
|
|
return ok({"flights": rows, "count": len(rows)})
|
|
except Exception as e:
|
|
return err(str(e))
|
|
|
|
|
|
# ── startup ───────────────────────────────────────────────────────────────────
|
|
|
|
def wait_for_db(max_attempts: int = 30):
|
|
for attempt in range(1, max_attempts + 1):
|
|
try:
|
|
get_conn()
|
|
return
|
|
except psycopg2.OperationalError as e:
|
|
log.warning("DB not ready (%d/%d): %s", attempt, max_attempts, e)
|
|
time.sleep(2)
|
|
log.error("Could not connect to DB")
|
|
raise SystemExit(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
wait_for_db()
|
|
open(HEALTHCHECK_FILE, "w").close()
|
|
log.info("Healthcheck file written: %s", HEALTHCHECK_FILE)
|
|
log.info("Starting API on port %d", API_PORT)
|
|
app.run(host="0.0.0.0", port=API_PORT, debug=False)
|