auto-sync: 2026-04-19 14:50:01
This commit is contained in:
10
tasks/flightradar24/frontend/Dockerfile
Normal file
10
tasks/flightradar24/frontend/Dockerfile
Normal file
@@ -0,0 +1,10 @@
|
||||
FROM python:3.13-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY main.py .
|
||||
|
||||
CMD ["python", "-u", "main.py"]
|
||||
46
tasks/flightradar24/frontend/README.md
Normal file
46
tasks/flightradar24/frontend/README.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# API Service
|
||||
|
||||
Minimal Flask HTTP API that reads from the `fr24` PostgreSQL schema and exposes data for the noisemap UI.
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| GET | `/health` | API + DB liveness check |
|
||||
| GET | `/dashboard/status` | Ingest pipeline stats (captures, packets, processing cursor, aircraft, flights) |
|
||||
| GET | `/viewer/config` | Static system config for the map viewer |
|
||||
| GET | `/captures` | List capture sessions (`?limit=50`) |
|
||||
| GET | `/aircraft` | List aircraft (`?limit=100`) |
|
||||
| GET | `/flights` | List flights (`?limit=100&status=active`) |
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `flask` — HTTP server
|
||||
- `psycopg2-binary` — PostgreSQL driver
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `POSTGRES_HOST` | required | DB host |
|
||||
| `POSTGRES_PORT` | `5432` | DB port |
|
||||
| `POSTGRES_DB` | required | DB name |
|
||||
| `POSTGRES_USER` | required | DB user |
|
||||
| `POSTGRES_PASSWORD` | required | DB password |
|
||||
| `API_PORT` | `8080` | Port to listen on |
|
||||
|
||||
## Run locally
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
export POSTGRES_HOST=localhost POSTGRES_DB=fr24 POSTGRES_USER=fr24 POSTGRES_PASSWORD=change-me
|
||||
python main.py
|
||||
# → http://localhost:8080/health
|
||||
```
|
||||
|
||||
## Build & run via Docker
|
||||
|
||||
```bash
|
||||
docker build -t fr24-api .
|
||||
docker run -p 8080:8080 --env-file ../../compose/.env fr24-api
|
||||
```
|
||||
239
tasks/flightradar24/frontend/main.py
Normal file
239
tasks/flightradar24/frontend/main.py
Normal file
@@ -0,0 +1,239 @@
|
||||
"""
|
||||
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)
|
||||
2
tasks/flightradar24/frontend/requirements.txt
Normal file
2
tasks/flightradar24/frontend/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
flask==3.1.0
|
||||
psycopg2-binary==2.9.9
|
||||
Reference in New Issue
Block a user