auto-sync: 2026-04-19 16:00:01

This commit is contained in:
Stream
2026-04-19 16:00:01 +03:00
parent 63a95b6539
commit aefed3a4ce
3 changed files with 349 additions and 10 deletions

View File

@@ -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"]

View File

@@ -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):

View 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>