auto-sync: 2026-04-19 16:00:01
This commit is contained in:
@@ -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"]
|
||||
|
||||
@@ -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/<icao24>")
|
||||
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):
|
||||
|
||||
150
tasks/flightradar24/frontend/static/index.html
Normal file
150
tasks/flightradar24/frontend/static/index.html
Normal file
@@ -0,0 +1,150 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>FR24 Live Map</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: monospace; background: #0d1117; color: #c9d1d9; }
|
||||
#map { width: 100vw; height: 100vh; }
|
||||
#hud {
|
||||
position: fixed; top: 10px; left: 10px; z-index: 1000;
|
||||
background: rgba(13,17,23,0.85); border: 1px solid #30363d;
|
||||
border-radius: 6px; padding: 10px 14px; min-width: 180px;
|
||||
}
|
||||
#hud h2 { font-size: 13px; color: #58a6ff; margin-bottom: 6px; }
|
||||
#hud .stat { font-size: 12px; color: #8b949e; }
|
||||
#hud .stat span { color: #c9d1d9; }
|
||||
#popup-box {
|
||||
position: fixed; bottom: 20px; right: 20px; z-index: 1000;
|
||||
background: rgba(13,17,23,0.92); border: 1px solid #30363d;
|
||||
border-radius: 6px; padding: 12px 16px; min-width: 220px;
|
||||
display: none;
|
||||
}
|
||||
#popup-box h3 { font-size: 14px; color: #58a6ff; margin-bottom: 8px; }
|
||||
#popup-box .row { font-size: 12px; color: #8b949e; margin: 3px 0; }
|
||||
#popup-box .row span { color: #c9d1d9; }
|
||||
#popup-close {
|
||||
float: right; cursor: pointer; color: #8b949e; font-size: 16px; line-height: 1;
|
||||
}
|
||||
#popup-close:hover { color: #c9d1d9; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="hud">
|
||||
<h2>✈ FR24 Live</h2>
|
||||
<div class="stat">Aircraft: <span id="ac-count">—</span></div>
|
||||
<div class="stat">Updated: <span id="last-update">—</span></div>
|
||||
<div class="stat" id="status-line"></div>
|
||||
</div>
|
||||
|
||||
<div id="popup-box">
|
||||
<span id="popup-close" onclick="closePopup()">✕</span>
|
||||
<h3 id="pop-callsign">—</h3>
|
||||
<div class="row">ICAO24: <span id="pop-icao">—</span></div>
|
||||
<div class="row">Altitude: <span id="pop-alt">—</span></div>
|
||||
<div class="row">Speed: <span id="pop-spd">—</span></div>
|
||||
<div class="row">Heading: <span id="pop-hdg">—</span></div>
|
||||
<div class="row">Vert rate: <span id="pop-vr">—</span></div>
|
||||
<div class="row">Seen: <span id="pop-ts">—</span></div>
|
||||
</div>
|
||||
|
||||
<div id="map"></div>
|
||||
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script>
|
||||
const map = L.map('map').setView([55.75, 37.62], 7);
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
maxZoom: 18,
|
||||
}).addTo(map);
|
||||
|
||||
// plane SVG icon, rotated by heading
|
||||
function planeIcon(heading) {
|
||||
const deg = heading || 0;
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"
|
||||
style="transform:rotate(${deg}deg);transform-origin:center">
|
||||
<path fill="#58a6ff" stroke="#0d1117" stroke-width="0.5"
|
||||
d="M12 2 L15 9 L22 10 L17 15 L18.5 22 L12 19 L5.5 22 L7 15 L2 10 L9 9 Z"/>
|
||||
</svg>`;
|
||||
return L.divIcon({
|
||||
html: svg,
|
||||
className: '',
|
||||
iconSize: [24, 24],
|
||||
iconAnchor: [12, 12],
|
||||
});
|
||||
}
|
||||
|
||||
const markers = {}; // icao24 -> L.marker
|
||||
|
||||
function fmt(v, unit, decimals=0) {
|
||||
if (v == null) return '—';
|
||||
return Number(v).toFixed(decimals) + ' ' + unit;
|
||||
}
|
||||
|
||||
function showPopup(props) {
|
||||
document.getElementById('pop-callsign').textContent = props.callsign || props.icao24;
|
||||
document.getElementById('pop-icao').textContent = props.icao24;
|
||||
document.getElementById('pop-alt').textContent = fmt(props.altitude_m, 'm');
|
||||
document.getElementById('pop-spd').textContent = fmt(props.ground_speed_kt, 'kt');
|
||||
document.getElementById('pop-hdg').textContent = fmt(props.heading_deg, '°');
|
||||
document.getElementById('pop-vr').textContent = fmt(props.vertical_rate_fpm, 'fpm');
|
||||
document.getElementById('pop-ts').textContent = props.observed_at
|
||||
? new Date(props.observed_at).toLocaleTimeString() : '—';
|
||||
document.getElementById('popup-box').style.display = 'block';
|
||||
}
|
||||
|
||||
function closePopup() {
|
||||
document.getElementById('popup-box').style.display = 'none';
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const res = await fetch('/api/aircraft/live?minutes=10');
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||
const geojson = await res.json();
|
||||
const features = geojson.features || [];
|
||||
const seen = new Set();
|
||||
|
||||
for (const f of features) {
|
||||
const p = f.properties;
|
||||
const [lon, lat] = f.geometry.coordinates;
|
||||
seen.add(p.icao24);
|
||||
|
||||
if (markers[p.icao24]) {
|
||||
markers[p.icao24].setLatLng([lat, lon]);
|
||||
markers[p.icao24].setIcon(planeIcon(p.heading_deg));
|
||||
markers[p.icao24]._props = p;
|
||||
} else {
|
||||
const m = L.marker([lat, lon], { icon: planeIcon(p.heading_deg) })
|
||||
.addTo(map)
|
||||
.on('click', () => showPopup(m._props));
|
||||
m._props = p;
|
||||
markers[p.icao24] = m;
|
||||
}
|
||||
}
|
||||
|
||||
// remove stale markers
|
||||
for (const icao of Object.keys(markers)) {
|
||||
if (!seen.has(icao)) {
|
||||
map.removeLayer(markers[icao]);
|
||||
delete markers[icao];
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('ac-count').textContent = features.length;
|
||||
document.getElementById('last-update').textContent = new Date().toLocaleTimeString();
|
||||
document.getElementById('status-line').textContent = '';
|
||||
} catch (e) {
|
||||
document.getElementById('status-line').textContent = '⚠ ' + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
refresh();
|
||||
setInterval(refresh, 5000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user