-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathapp.py
More file actions
710 lines (600 loc) · 26.5 KB
/
app.py
File metadata and controls
710 lines (600 loc) · 26.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
#!/usr/bin/env python3
"""
Arctic Shadow Tracker - Real-Time Dashboard
Simple Flask server with auto-refreshing vessel map
"""
from flask import Flask, render_template, jsonify, make_response, send_from_directory, abort
from pathlib import Path
import json
import logging
import sys
import requests
from datetime import datetime, timezone
from functools import wraps
import re
# Add src to path
sys.path.insert(0, str(Path(__file__).parent))
from src.track_manager import process_vessel_tracks
from src.map_generator import generate_focused_map
app = Flask(__name__)
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s')
logger = logging.getLogger('arctic-shadow-tracker')
# Data directories
BASE_DIR = Path(__file__).parent
DATA_DIR = BASE_DIR / 'data'
SNAPSHOTS_DIR = DATA_DIR / 'snapshots'
OUTPUTS_DIR = BASE_DIR / 'outputs'
# GitHub Pages URLs for data (updated every 5 minutes by GitHub Actions)
GITHUB_PAGES_VESSELS_URL = "https://henrfo.github.io/ArcticShadowTracker/vessel_tracks.json"
GITHUB_PAGES_ANOMALIES_URL = "https://henrfo.github.io/ArcticShadowTracker/data/anomalies/anomalies.json"
# Raw GitHub URLs for satellite data (committed to main by satellite_monitor.yml daily)
GITHUB_RAW_BASE = "https://raw.githubusercontent.com/henrfo/ArcticShadowTracker/main"
GITHUB_RAW_SAR_METADATA_URL = f"{GITHUB_RAW_BASE}/data/satellite_imagery/metadata.json"
GITHUB_RAW_DETECTIONS_URL = f"{GITHUB_RAW_BASE}/data/satellite_imagery/detections.json"
GITHUB_RAW_DARK_VESSELS_URL = f"{GITHUB_RAW_BASE}/data/anomalies/dark_vessels.json"
GITHUB_RAW_THUMBNAILS_BASE = f"{GITHUB_RAW_BASE}/data/satellite_imagery/thumbnails"
# Data freshness threshold — pipeline runs every 5 min, allow 6x tolerance
STALE_THRESHOLD_MINUTES = 30
# In-memory cache of the rendered Folium map HTML. Keyed on the ISO timestamp
# of the underlying vessel snapshot, so it auto-invalidates as soon as new data
# is published on gh-pages. Regeneration takes ~12s for ~5k vessels; every
# subsequent request inside the same 5-min window returns the cached HTML.
_map_cache = {'html': None, 'raw_last_update': None}
# Short-TTL cache for load_vessel_data() so we don't round-trip to GitHub Pages
# on every request. The upstream pipeline only publishes every ~5 minutes, so
# 30 seconds is plenty fresh — trades ~30s of staleness for a ~6s speedup per
# request. Map cache above chains on top of this.
_data_cache = {'result': None, 'fetched_at': 0.0}
DATA_TTL_SECONDS = 30
# SAR satellite coverage metadata — fetched from raw.githubusercontent.com at
# runtime (with local-disk fallback for dev). Updated daily by the
# satellite_monitor.yml workflow. Each anomaly is enriched with any nearby
# SAR passes.
_sar_cache = {'data': None, 'fetched_at': 0.0}
SAR_CACHE_TTL_SECONDS = 60
SAR_TIME_WINDOW_HOURS = 12 # ± window for "nearby" SAR passes on an anomaly
# Dark-vessel anomalies — fetched from raw.githubusercontent.com at runtime
# (with local-disk fallback). Lives in a separate file from the main
# anomalies.json to avoid clobbering between the two workflows.
# /api/anomalies merges both at request time.
_dark_vessels_cache = {'data': None, 'fetched_at': 0.0}
# CFAR detections — enriched with AIS match status by correlate_detections.py
_detections_cache = {'data': None, 'fetched_at': 0.0}
def add_cors_headers(response):
"""Add CORS headers to response"""
response.headers['Access-Control-Allow-Origin'] = '*'
response.headers['Access-Control-Allow-Methods'] = 'GET, POST, OPTIONS'
response.headers['Access-Control-Allow-Headers'] = 'Content-Type'
return response
def no_cache(response):
"""Attach no-cache headers so clients never see stale stats."""
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
response.headers['Pragma'] = 'no-cache'
response.headers['Expires'] = '0'
return response
def cors_enabled(f):
"""Decorator to add CORS headers to routes"""
@wraps(f)
def decorated_function(*args, **kwargs):
response = make_response(f(*args, **kwargs))
return add_cors_headers(response)
return decorated_function
def _empty_stats(last_update=None, stale_reason='No data available'):
return {
'total': 0,
'russian': 0,
'chinese': 0,
'norwegian': 0,
'norwegian_military': 0,
'shadow_fleet': 0,
'suspected_shadow': 0,
'buoy': 0,
'other': 0,
'last_update': last_update or 'No data',
'raw_last_update': None,
'minutes_since_update': None,
'is_stale': True,
'stale_reason': stale_reason,
}
def _format_age(minutes):
"""Format a duration in minutes as a human-readable 'X ago' string."""
if minutes is None:
return 'Unknown'
if minutes < 1:
return 'just now'
if minutes < 60:
return f"{int(minutes)}m ago"
hours = minutes / 60
if hours < 24:
h = int(hours)
m = int(minutes - h * 60)
return f"{h}h {m}m ago"
days = hours / 24
d = int(days)
h = int(hours - d * 24)
return f"{d}d {h}h ago"
def _classify_vessels(vessel_tracks):
"""Count vessels into mutually exclusive buckets.
Classification priority (each vessel counted exactly once):
1. buoy
2. shadow_fleet
3. suspected_shadow
4. norwegian_military (Norway + military/law enforcement ship_type)
5. russian (pure: country == Russia, no flags above)
6. chinese (pure: country == China, no flags above)
7. norwegian (pure: country == Norway, no flags above)
8. other (everything else)
"""
counts = {
'buoy': 0,
'shadow_fleet': 0,
'suspected_shadow': 0,
'norwegian_military': 0,
'russian': 0,
'chinese': 0,
'norwegian': 0,
'other': 0,
}
for v in vessel_tracks.values():
if v.get('is_buoy'):
counts['buoy'] += 1
continue
if v.get('is_shadow_fleet'):
counts['shadow_fleet'] += 1
continue
if v.get('is_suspected_shadow'):
counts['suspected_shadow'] += 1
continue
country = v.get('country') or ''
ship_type = (v.get('ship_type') or '').lower()
if country == 'Norway' and ('military' in ship_type or 'law enforcement' in ship_type):
counts['norwegian_military'] += 1
continue
if country == 'Russia':
counts['russian'] += 1
elif country == 'China':
counts['chinese'] += 1
elif country == 'Norway':
counts['norwegian'] += 1
else:
counts['other'] += 1
return counts
def _compute_freshness(raw_last_update):
"""Return (minutes_since_update, pretty_age, is_stale, stale_reason)."""
if not raw_last_update:
return None, 'No data', True, 'No last_updated timestamp in data'
try:
dt = datetime.fromisoformat(raw_last_update.replace('Z', '+00:00'))
now = datetime.now(timezone.utc)
minutes = (now - dt).total_seconds() / 60
pretty = _format_age(minutes)
is_stale = minutes > STALE_THRESHOLD_MINUTES
reason = f"Data is {pretty} (threshold: {STALE_THRESHOLD_MINUTES}m)" if is_stale else ''
return minutes, pretty, is_stale, reason
except Exception as exc: # noqa: BLE001
return None, 'Unknown', True, f'Could not parse last_updated: {exc}'
# ============================================================================
# SAR coverage — match anomalies to nearby Sentinel-1 passes
# ============================================================================
def _load_sar_metadata():
"""Fetch SAR tile metadata from GitHub, with local-disk fallback.
Primary: raw.githubusercontent.com (always up to date after workflow push).
Fallback: data/satellite_imagery/metadata.json on disk (stale on Fly.io
but works for local dev).
"""
import time as _time
now = _time.monotonic()
if _sar_cache['data'] is not None and (now - _sar_cache['fetched_at']) < SAR_CACHE_TTL_SECONDS:
return _sar_cache['data']
data = None
try:
resp = requests.get(GITHUB_RAW_SAR_METADATA_URL, timeout=10)
resp.raise_for_status()
data = resp.json()
logger.info("Fetched SAR metadata from GitHub (%d tiles)", len(data.get('tiles', [])))
except Exception as exc: # noqa: BLE001
logger.warning("GitHub SAR metadata fetch failed: %s — falling back to local file", exc)
if data is None:
path = DATA_DIR / 'satellite_imagery' / 'metadata.json'
if path.exists():
try:
with open(path, 'r') as f:
data = json.load(f)
logger.info("Using local SAR metadata (%d tiles)", len(data.get('tiles', [])))
except Exception as exc: # noqa: BLE001
logger.warning("Could not load local SAR metadata: %s", exc)
if data is None:
return {'tiles': []}
_sar_cache['data'] = data
_sar_cache['fetched_at'] = now
return data
def _load_dark_vessels():
"""Fetch dark-vessel anomalies from GitHub, with local-disk fallback.
Primary: raw.githubusercontent.com (committed by satellite_monitor.yml).
Fallback: data/anomalies/dark_vessels.json on disk.
"""
import time as _time
now = _time.monotonic()
if _dark_vessels_cache['data'] is not None and (now - _dark_vessels_cache['fetched_at']) < SAR_CACHE_TTL_SECONDS:
return _dark_vessels_cache['data']
data = None
try:
resp = requests.get(GITHUB_RAW_DARK_VESSELS_URL, timeout=10)
resp.raise_for_status()
data = resp.json()
logger.info("Fetched dark_vessels.json from GitHub (%d anomalies)", len(data.get('anomalies', [])))
except Exception as exc: # noqa: BLE001
logger.warning("GitHub dark_vessels fetch failed: %s — falling back to local file", exc)
if data is None:
path = DATA_DIR / 'anomalies' / 'dark_vessels.json'
if path.exists():
try:
with open(path, 'r') as f:
data = json.load(f)
logger.info("Using local dark_vessels.json (%d anomalies)", len(data.get('anomalies', [])))
except Exception as exc: # noqa: BLE001
logger.warning("Could not load local dark_vessels.json: %s", exc)
if data is None:
return {'anomalies': []}
_dark_vessels_cache['data'] = data
_dark_vessels_cache['fetched_at'] = now
return data
def _load_detections():
"""Fetch CFAR detections from GitHub, with local-disk fallback.
Returns the full detections.json payload (tiles + detections + metadata).
After correlate_detections.py runs, each detection is enriched with
matched_ais, nearest_ais_km, and matched_vessel fields.
"""
import time as _time
now = _time.monotonic()
if _detections_cache['data'] is not None and (now - _detections_cache['fetched_at']) < SAR_CACHE_TTL_SECONDS:
return _detections_cache['data']
data = None
try:
resp = requests.get(GITHUB_RAW_DETECTIONS_URL, timeout=10)
resp.raise_for_status()
data = resp.json()
logger.info("Fetched detections from GitHub (%d detections)",
data.get('total_detections_in_history', 0))
except Exception as exc: # noqa: BLE001
logger.warning("GitHub detections fetch failed: %s — falling back to local file", exc)
if data is None:
path = DATA_DIR / 'satellite_imagery' / 'detections.json'
if path.exists():
try:
with open(path, 'r') as f:
data = json.load(f)
except Exception as exc: # noqa: BLE001
logger.warning("Could not load local detections.json: %s", exc)
if data is None:
return {'tiles': [], 'total_detections_in_history': 0}
_detections_cache['data'] = data
_detections_cache['fetched_at'] = now
return data
def _anomaly_position(anomaly):
"""Extract a (lat, lon) for an anomaly, matching the dashboard's pan logic.
Checks details.last_position, then details.center_position, then the last
element of details.positions[]. Returns (None, None) for rendezvous and
any other anomaly type without embedded coordinates.
"""
details = (anomaly or {}).get('details') or {}
last = details.get('last_position') or {}
if last.get('lat') is not None and last.get('lon') is not None:
return last['lat'], last['lon']
center = details.get('center_position') or {}
if center.get('lat') is not None and center.get('lon') is not None:
return center['lat'], center['lon']
positions = details.get('positions')
if isinstance(positions, list) and positions:
p = positions[-1]
if isinstance(p, dict) and p.get('lat') is not None and p.get('lon') is not None:
return p['lat'], p['lon']
return None, None
def _sar_coverage_for(lat, lon, iso_ts, window_hours=SAR_TIME_WINDOW_HOURS):
"""Return up to 3 nearest Sentinel-1 passes that covered (lat, lon) within
±window_hours of iso_ts.
Each entry: {tile_id, filename, datetime, bbox, delta_minutes}.
Sorted closest-pass-first by abs(delta_minutes).
"""
if lat is None or lon is None or not iso_ts:
return []
try:
target = datetime.fromisoformat(iso_ts.replace('Z', '+00:00'))
except Exception:
return []
metadata = _load_sar_metadata()
hits = []
for t in metadata.get('tiles', []) or []:
bbox = t.get('bbox')
if not bbox or len(bbox) != 4:
continue
min_lon, min_lat, max_lon, max_lat = bbox
if not (min_lon <= lon <= max_lon and min_lat <= lat <= max_lat):
continue
try:
tile_ts = datetime.fromisoformat((t.get('datetime') or '').replace('Z', '+00:00'))
except Exception:
continue
delta_min = (tile_ts - target).total_seconds() / 60
if abs(delta_min) > window_hours * 60:
continue
hits.append({
'tile_id': t.get('id'),
'filename': t.get('filename'),
'datetime': t.get('datetime'),
'bbox': bbox,
'delta_minutes': round(delta_min, 0),
})
hits.sort(key=lambda h: abs(h['delta_minutes']))
return hits[:3]
def load_vessel_data():
"""Load pre-processed vessel track data from GitHub Pages or local file.
Returns a cached result if called within DATA_TTL_SECONDS of the last fetch.
Upstream only publishes every ~5 min, so a 30-second TTL is plenty fresh
and eliminates per-request GitHub Pages round-trips.
Priority:
1. Try GitHub Pages (for cloud deployment)
2. Fallback to local bootstrap file data/vessel_tracks.json
"""
import time as _time
now = _time.monotonic()
if _data_cache['result'] is not None and (now - _data_cache['fetched_at']) < DATA_TTL_SECONDS:
return _data_cache['result']
data = None
source = None
fetch_error = None
# Primary: GitHub Pages
try:
logger.info("Fetching vessel data from GitHub Pages...")
response = requests.get(GITHUB_PAGES_VESSELS_URL, timeout=10)
response.raise_for_status()
data = response.json()
source = 'github-pages'
logger.info("Fetched vessel data from GitHub Pages")
except Exception as exc: # noqa: BLE001
fetch_error = str(exc)
logger.warning("GitHub Pages fetch failed: %s — falling back to local file", fetch_error)
# Fallback: local file, then committed fixture
if data is None:
# 1. Real collected data (gitignored; written by scripts/collect_ais.py)
# 2. Committed dev fixture (checked in; small representative sample)
candidates = [
(DATA_DIR / 'vessel_tracks.json', 'local-file'),
(DATA_DIR / 'fixtures' / 'vessel_tracks.json', 'local-fixture'),
]
for path, label in candidates:
if not path.exists():
continue
try:
with open(path, 'r') as f:
data = json.load(f)
source = label
logger.warning("Using %s at %s", label, path)
break
except Exception as exc: # noqa: BLE001
logger.error("Failed to read %s: %s", path, exc)
if data is None:
return {
'vessels': {},
'stats': _empty_stats(
stale_reason=f"GitHub Pages unreachable and no local bootstrap. Error: {fetch_error}",
),
}
vessel_tracks = data.get('vessels', {}) or {}
raw_last_update = data.get('last_updated')
counts = _classify_vessels(vessel_tracks)
minutes, pretty_age, is_stale, stale_reason = _compute_freshness(raw_last_update)
# If we fell back to a local file, surface that in the reason too
if source in ('local-file', 'local-fixture'):
is_stale = True
label = 'fixture' if source == 'local-fixture' else 'local file'
stale_reason = (
f"Serving local {label} — GitHub Pages unreachable "
f"({fetch_error or 'unknown error'}). Data age: {pretty_age}."
)
stats = {
'total': len(vessel_tracks),
**counts,
'last_update': pretty_age,
'raw_last_update': raw_last_update,
'minutes_since_update': round(minutes, 1) if minutes is not None else None,
'is_stale': bool(is_stale),
'stale_reason': stale_reason or '',
'source': source,
}
# Sanity check: buckets sum to total (assertion disabled in prod, logged instead)
bucket_sum = sum(counts.values())
if bucket_sum != stats['total']:
logger.error(
"Stats mismatch: bucket sum %d != total %d (counts=%s)",
bucket_sum, stats['total'], counts,
)
result = {'vessels': vessel_tracks, 'stats': stats}
_data_cache['result'] = result
_data_cache['fetched_at'] = _time.monotonic()
return result
@app.route('/')
def dashboard():
"""Serve the main dashboard page"""
data = load_vessel_data()
return render_template('dashboard.html', stats=data['stats'])
@app.route('/api/vessels')
@cors_enabled
def api_vessels():
"""API endpoint for vessel data"""
data = load_vessel_data()
response = make_response(jsonify(data))
return no_cache(response)
@app.route('/api/map')
@cors_enabled
def api_map():
"""Generate and return map HTML, with in-memory caching keyed on snapshot ISO timestamp."""
data = load_vessel_data()
snapshot_ts = data['stats'].get('raw_last_update')
# Cache hit: the snapshot timestamp matches the one we already rendered — return instantly.
if _map_cache['html'] and _map_cache['raw_last_update'] == snapshot_ts:
logger.info("Map cache HIT for snapshot %s", snapshot_ts)
response = make_response(_map_cache['html'])
response.headers['Content-Type'] = 'text/html; charset=utf-8'
return no_cache(response)
if not data['vessels']:
return "<div>No vessel data available</div>"
# Cache miss: regenerate and store. Use get_root().render() for plain HTML
# (not the Jupyter srcdoc wrapper from _repr_html_()) so postMessage from
# the dashboard iframe can reach the map window.
logger.info("Map cache MISS for snapshot %s — regenerating", snapshot_ts)
map_obj = generate_focused_map(data['vessels'])
html = map_obj.get_root().render()
_map_cache['html'] = html
_map_cache['raw_last_update'] = snapshot_ts
response = make_response(html)
response.headers['Content-Type'] = 'text/html; charset=utf-8'
return no_cache(response)
@app.route('/api/anomalies')
@cors_enabled
def api_anomalies():
"""API endpoint for recent anomaly detections from GitHub Pages"""
try:
logger.info("Fetching anomaly data from GitHub Pages...")
response = requests.get(GITHUB_PAGES_ANOMALIES_URL, timeout=10)
response.raise_for_status()
data = response.json()
except Exception as exc: # noqa: BLE001
logger.warning("Anomaly fetch from GitHub Pages failed: %s — using local fallback", exc)
anomalies_file = DATA_DIR / 'anomalies' / 'anomalies.json'
if not anomalies_file.exists():
return no_cache(make_response(jsonify({'anomalies': [], 'total': 0})))
with open(anomalies_file, 'r') as f:
data = json.load(f)
main_anomalies = data.get('anomalies', []) or []
main_anomalies.sort(key=lambda x: x.get('detected_at', ''), reverse=True)
main_anomalies = main_anomalies[:100]
# Merge dark_vessel anomalies written by the daily satellite_monitor.yml
# pipeline. Separate file avoids clobbering between the two workflows.
# Dark vessels are naturally ~12-24h "older" than the continuous AIS stream
# (daily cron vs 5-min cron), so we cap main_anomalies and dark_anomalies
# INDEPENDENTLY before merging — otherwise dark vessels get crowded out.
dark_data = _load_dark_vessels()
# Only surface critical dark vessels (≥15σ) on the main feed to avoid
# cry-wolf noise from sea ice / coastal false positives. The Analysis
# View still shows all detections for deep-dive investigation.
dark_anomalies = [a for a in dark_data.get('anomalies', []) or []
if a.get('severity') == 'critical']
dark_anomalies.sort(key=lambda x: x.get('detected_at', ''), reverse=True)
all_anomalies = main_anomalies + dark_anomalies
all_anomalies.sort(key=lambda x: x.get('detected_at', ''), reverse=True)
# Preload SAR metadata once for the whole batch (cached by mtime anyway)
_load_sar_metadata()
for anomaly in all_anomalies:
if 'detected_at' in anomaly:
try:
dt = datetime.fromisoformat(anomaly['detected_at'].replace('Z', '+00:00'))
anomaly['formatted_time'] = dt.strftime('%b %d, %H:%M')
except Exception:
anomaly['formatted_time'] = "Unknown"
# Attach nearby Sentinel-1 passes if we have coverage for this vessel's
# last known position within ±SAR_TIME_WINDOW_HOURS of the anomaly time.
# Rendezvous and other position-less anomalies get an empty list.
lat, lon = _anomaly_position(anomaly)
anomaly['sar_coverage'] = _sar_coverage_for(
lat, lon,
anomaly.get('detected_at') or (anomaly.get('details') or {}).get('gap_start'),
)
response = make_response(jsonify({'anomalies': all_anomalies, 'total': len(all_anomalies)}))
return no_cache(response)
@app.route('/api/satellite-tiles')
@cors_enabled
def api_satellite_tiles():
"""Return the current SAR tile history for the dashboard satellite viewer.
Each tile entry includes a `thumbnail_url` field (served by this Flask app)
so the frontend can render it directly without needing to know the on-disk
layout. Tiles are returned newest-first.
"""
metadata = _load_sar_metadata()
tiles_out = []
for t in metadata.get('tiles', []) or []:
thumb = t.get('thumbnail')
tiles_out.append({
'id': t.get('id'),
'filename': t.get('filename'),
'datetime': t.get('datetime'),
'bbox': t.get('bbox'),
'instrument_mode': t.get('instrument_mode'),
'zone': t.get('zone'),
'thumbnail_url': f'/satellite-thumbnails/{thumb}' if thumb else None,
})
body = {
'last_updated': metadata.get('last_updated'),
'history_window_days': metadata.get('history_window_days'),
'count': len(tiles_out),
'tiles': tiles_out,
}
return no_cache(make_response(jsonify(body)))
@app.route('/api/satellite-detections')
@cors_enabled
def api_satellite_detections():
"""Return CFAR detections enriched with AIS match status.
Each detection includes lat/lon centroid, confidence, severity,
bbox_geo (geographic bounding box), and match info (matched_ais,
nearest_ais_km, matched_vessel) added by correlate_detections.py.
"""
data = _load_detections()
return no_cache(make_response(jsonify(data)))
@app.route('/analysis-view')
def analysis_view():
"""Serve the standalone full-screen SAR Analysis View page."""
return render_template('analysis.html')
_THUMBNAIL_RE = re.compile(r'^[\w][\w.\-]*\.png$')
@app.route('/satellite-thumbnails/<path:filename>')
def satellite_thumbnail(filename):
"""Serve a SAR thumbnail PNG, fetched from GitHub with local-disk fallback.
Primary: proxy from raw.githubusercontent.com (always current after a
satellite_monitor.yml push, no redeploy required).
Fallback: send_from_directory for local dev where thumbnails exist on disk.
Filenames are content-addressed (timestamp + tile-id) so they're immutable
once written — a 1-hour browser cache is safe.
"""
if not _THUMBNAIL_RE.match(filename):
abort(400)
# Try local disk first (fast, no network hop — works in dev and if the
# thumbnail happens to be baked into the current Docker image)
thumbnails_dir = DATA_DIR / 'satellite_imagery' / 'thumbnails'
if (thumbnails_dir / filename).exists():
response = make_response(send_from_directory(str(thumbnails_dir), filename))
response.headers['Cache-Control'] = 'public, max-age=3600'
return response
# Proxy from GitHub (handles new tiles committed after last Fly.io deploy)
try:
url = f"{GITHUB_RAW_THUMBNAILS_BASE}/{filename}"
resp = requests.get(url, timeout=15)
resp.raise_for_status()
response = make_response(resp.content)
response.headers['Content-Type'] = 'image/png'
response.headers['Cache-Control'] = 'public, max-age=3600'
return response
except Exception as exc: # noqa: BLE001
logger.warning("Thumbnail proxy failed for %s: %s", filename, exc)
abort(404)
@app.route('/health')
def health():
"""Health check endpoint. Returns 503 if vessel data is stale."""
data = load_vessel_data()
stats = data['stats']
body = {
'service': 'Arctic Shadow Tracker',
'is_stale': stats.get('is_stale', True),
'minutes_since_update': stats.get('minutes_since_update'),
'last_update': stats.get('last_update'),
'source': stats.get('source'),
}
if stats.get('is_stale'):
body['status'] = 'stale'
body['reason'] = stats.get('stale_reason', '')
return jsonify(body), 503
body['status'] = 'healthy'
return jsonify(body), 200
if __name__ == '__main__':
import os
port = int(os.environ.get('PORT', 5001))
logger.info("Starting Arctic Shadow Tracker Dashboard on port %d", port)
app.run(debug=False, host='0.0.0.0', port=port)