From aefed3a4ce2c66106a09e364f4ab3c438d4fadd5 Mon Sep 17 00:00:00 2001 From: Stream Date: Sun, 19 Apr 2026 16:00:01 +0300 Subject: [PATCH] auto-sync: 2026-04-19 16:00:01 --- tasks/flightradar24/frontend/Dockerfile | 1 + tasks/flightradar24/frontend/main.py | 208 +++++++++++++++++- .../flightradar24/frontend/static/index.html | 150 +++++++++++++ 3 files changed, 349 insertions(+), 10 deletions(-) create mode 100644 tasks/flightradar24/frontend/static/index.html diff --git a/tasks/flightradar24/frontend/Dockerfile b/tasks/flightradar24/frontend/Dockerfile index d8aabbf..797f8d4 100644 --- a/tasks/flightradar24/frontend/Dockerfile +++ b/tasks/flightradar24/frontend/Dockerfile @@ -9,5 +9,6 @@ COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY main.py . +COPY static/ /app/static/ CMD ["python", "-u", "main.py"] diff --git a/tasks/flightradar24/frontend/main.py b/tasks/flightradar24/frontend/main.py index f463123..21edc95 100644 --- a/tasks/flightradar24/frontend/main.py +++ b/tasks/flightradar24/frontend/main.py @@ -10,7 +10,7 @@ from functools import wraps import psycopg2 import psycopg2.extras -from flask import Flask, jsonify, request +from flask import Flask, jsonify, request, send_from_directory logging.basicConfig( level=logging.INFO, @@ -19,7 +19,7 @@ logging.basicConfig( ) log = logging.getLogger("api") -app = Flask(__name__) +app = Flask(__name__, static_folder="/app/static", static_url_path="/static") DB_DSN = ( f"host={os.environ['POSTGRES_HOST']} " @@ -92,12 +92,17 @@ def err(msg: str, status: int = 500): # ── routes ──────────────────────────────────────────────────────────────────── +@app.get("/") +def index(): + return send_from_directory("/app/static", "index.html") + + @app.get("/health") def health(): try: query_one("SELECT 1") db_ok = True - except Exception as e: + except Exception: db_ok = False return ok({ "status": "ok" if db_ok else "degraded", @@ -129,14 +134,14 @@ def dashboard_status(): def viewer_config(): return ok({ "system": "fr24-ingest", - "version": "0.1.0-scaffold", - "stage": "step-1-fake-data", + "version": "0.1.0", + "stage": "step-3-real-tracks", "db_schema": "fr24", - "center": {"lat": 52.0, "lon": 27.0}, - "zoom": 6, + "center": {"lat": 55.75, "lon": 37.62}, + "zoom": 7, "features": { - "adsb_decode": False, - "real_rtlsdr": False, + "adsb_decode": True, + "real_rtlsdr": True, "noise_model": False, }, }) @@ -162,7 +167,7 @@ def captures(): @app.get("/aircraft") -def aircraft(): +def aircraft_list(): try: limit = min(int(request.args.get("limit", 100)), 500) rows = query( @@ -217,6 +222,189 @@ def flights(): return err(str(e)) +# ── map / live endpoints ────────────────────────────────────────────────────── + +@app.get("/api/aircraft/live") +def aircraft_live(): + """Active aircraft with their latest position as GeoJSON FeatureCollection.""" + try: + minutes = int(request.args.get("minutes", 60)) + rows = query( + """ + SELECT + a.icao24, + a.callsign, + a.registration, + a.aircraft_type, + tp.observed_at, + ST_X(tp.geom) AS lon, + ST_Y(tp.geom) AS lat, + tp.altitude_m, + tp.ground_speed_kt, + tp.heading_deg, + tp.vertical_rate_fpm + FROM fr24.aircraft a + JOIN fr24.flights f ON f.aircraft_id = a.aircraft_id + JOIN LATERAL ( + SELECT tp2.geom, tp2.observed_at, tp2.altitude_m, + tp2.ground_speed_kt, tp2.heading_deg, tp2.vertical_rate_fpm + FROM fr24.track_points tp2 + WHERE tp2.flight_id = f.flight_id + ORDER BY tp2.observed_at DESC + LIMIT 1 + ) tp ON true + WHERE f.status = 'active' + AND tp.observed_at >= now() - (%s || ' minutes')::interval + ORDER BY tp.observed_at DESC + """, + (minutes,), + ) + features = [] + for r in rows: + if r["lon"] is None or r["lat"] is None: + continue + features.append({ + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [r["lon"], r["lat"]]}, + "properties": { + "icao24": r["icao24"], + "callsign": r["callsign"] or r["icao24"], + "registration": r["registration"], + "aircraft_type": r["aircraft_type"], + "altitude_m": r["altitude_m"], + "ground_speed_kt": r["ground_speed_kt"], + "heading_deg": r["heading_deg"], + "vertical_rate_fpm": r["vertical_rate_fpm"], + "observed_at": r["observed_at"].isoformat() if r["observed_at"] else None, + }, + }) + return ok({"type": "FeatureCollection", "features": features, "count": len(features)}) + except Exception as e: + return err(str(e)) + + +@app.get("/api/aircraft/") +def aircraft_detail(icao24: str): + """Details for a single aircraft including recent track points.""" + try: + ac = query_one( + "SELECT * FROM fr24.aircraft WHERE icao24 = %s", + (icao24.lower(),), + ) + if not ac: + return err("not found", 404) + + flights_rows = query( + """ + SELECT flight_id, callsign, departure_airport, arrival_airport, + started_at, ended_at, status + FROM fr24.flights + WHERE aircraft_id = %s + ORDER BY started_at DESC + LIMIT 10 + """, + (ac["aircraft_id"],), + ) + + # last 100 track points across all recent flights + points = query( + """ + SELECT tp.observed_at, + ST_X(tp.geom) AS lon, ST_Y(tp.geom) AS lat, + tp.altitude_m, tp.ground_speed_kt, tp.heading_deg + FROM fr24.track_points tp + JOIN fr24.flights f ON f.flight_id = tp.flight_id + WHERE f.aircraft_id = %s + ORDER BY tp.observed_at DESC + LIMIT 100 + """, + (ac["aircraft_id"],), + ) + + return ok({ + "aircraft": ac, + "flights": flights_rows, + "recent_points": points, + }) + except Exception as e: + return err(str(e)) + + +@app.get("/api/tracks") +def tracks(): + """Track points as GeoJSON LineStrings, filtered by time and optional bbox.""" + try: + minutes = int(request.args.get("minutes", 30)) + limit = min(int(request.args.get("limit", 50)), 200) + + # optional bbox: ?bbox=minlon,minlat,maxlon,maxlat + bbox = request.args.get("bbox") + bbox_clause = "" + bbox_params: list = [] + if bbox: + try: + minlon, minlat, maxlon, maxlat = map(float, bbox.split(",")) + bbox_clause = ( + "AND tp.geom && ST_MakeEnvelope(%s, %s, %s, %s, 4326)" + ) + bbox_params = [minlon, minlat, maxlon, maxlat] + except ValueError: + pass + + rows = query( + f""" + SELECT + t.track_id, + f.flight_id, + a.icao24, + f.callsign, + json_agg( + json_build_object( + 'lon', ST_X(tp.geom), + 'lat', ST_Y(tp.geom), + 'alt', tp.altitude_m, + 'spd', tp.ground_speed_kt, + 'hdg', tp.heading_deg, + 'ts', tp.observed_at + ) + ORDER BY tp.point_order + ) AS points + FROM fr24.tracks t + JOIN fr24.flights f ON f.flight_id = t.flight_id + JOIN fr24.aircraft a ON a.aircraft_id = f.aircraft_id + JOIN fr24.track_points tp ON tp.track_id = t.track_id + WHERE tp.observed_at >= now() - (%s || ' minutes')::interval + {bbox_clause} + GROUP BY t.track_id, f.flight_id, a.icao24, f.callsign + ORDER BY t.last_point_at DESC NULLS LAST + LIMIT %s + """, + (minutes, *bbox_params, limit), + ) + + features = [] + for r in rows: + pts = r["points"] if isinstance(r["points"], list) else [] + coords = [[p["lon"], p["lat"]] for p in pts if p.get("lon") is not None] + if len(coords) < 2: + continue + features.append({ + "type": "Feature", + "geometry": {"type": "LineString", "coordinates": coords}, + "properties": { + "track_id": r["track_id"], + "flight_id": r["flight_id"], + "icao24": r["icao24"], + "callsign": r["callsign"] or r["icao24"], + "point_count": len(coords), + }, + }) + + return ok({"type": "FeatureCollection", "features": features, "count": len(features)}) + except Exception as e: + return err(str(e)) + + # ── startup ─────────────────────────────────────────────────────────────────── def wait_for_db(max_attempts: int = 30): diff --git a/tasks/flightradar24/frontend/static/index.html b/tasks/flightradar24/frontend/static/index.html new file mode 100644 index 0000000..31a74be --- /dev/null +++ b/tasks/flightradar24/frontend/static/index.html @@ -0,0 +1,150 @@ + + + + + +FR24 Live Map + + + + +
+

✈ FR24 Live

+
Aircraft:
+
Updated:
+
+
+ + + +
+ + + + +