2026-06-04 20:30:04 -04:00
|
|
|
|
"""OSINT Dashboard — FastAPI backend.
|
|
|
|
|
|
|
|
|
|
|
|
Real-time geospatial OSINT dashboard API:
|
|
|
|
|
|
- Event ingestion via NATS JetStream consumers
|
|
|
|
|
|
- Full-text search across events (PostgreSQL tsvector)
|
|
|
|
|
|
- Entity tracking and relationship mapping
|
|
|
|
|
|
- Alert management
|
|
|
|
|
|
- Document storage (MinIO-backed)
|
|
|
|
|
|
- Sentiment aggregation and timeline analytics
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
2026-08-24 23:36:34 -04:00
|
|
|
|
import asyncio
|
2026-06-04 20:30:04 -04:00
|
|
|
|
import json
|
|
|
|
|
|
import logging
|
2026-08-29 14:14:08 -04:00
|
|
|
|
import re
|
2026-08-27 21:21:19 -04:00
|
|
|
|
from contextlib import asynccontextmanager
|
2026-06-04 20:30:04 -04:00
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
|
|
from decimal import Decimal
|
|
|
|
|
|
from pathlib import Path
|
2026-08-28 22:58:04 -04:00
|
|
|
|
from typing import NoReturn
|
2026-06-04 20:30:04 -04:00
|
|
|
|
from uuid import UUID
|
|
|
|
|
|
|
2026-08-29 14:14:08 -04:00
|
|
|
|
import httpx
|
2026-06-04 20:30:04 -04:00
|
|
|
|
import structlog
|
2026-08-28 09:33:19 -04:00
|
|
|
|
from fastapi import BackgroundTasks, FastAPI, HTTPException, Query, WebSocket, WebSocketDisconnect
|
2026-08-27 21:21:19 -04:00
|
|
|
|
from fastapi.middleware.gzip import GZipMiddleware
|
|
|
|
|
|
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
2026-08-24 17:35:44 -04:00
|
|
|
|
from fastapi.staticfiles import StaticFiles
|
2026-08-25 22:05:15 -04:00
|
|
|
|
from sqlalchemy import and_, func, or_, select, text
|
2026-06-04 20:30:04 -04:00
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
|
|
|
|
|
|
from database import async_session, init_extensions
|
|
|
|
|
|
from models import (
|
2026-08-24 17:28:46 -04:00
|
|
|
|
alerts, documents, entities, entity_events, events, feed_sources, fires,
|
2026-08-27 22:49:59 -04:00
|
|
|
|
articles, article_summaries, news_items,
|
2026-06-04 20:30:04 -04:00
|
|
|
|
)
|
|
|
|
|
|
from schemas import (
|
|
|
|
|
|
AlertCreate, AlertOut, AlertSeverity, AlertType, AlertUpdate,
|
|
|
|
|
|
DashboardSummary, EntityCreate, EntityKind, EntityOut,
|
2026-08-27 22:49:59 -04:00
|
|
|
|
EventCreate, EventOut, FireOut, NewsArticleOut, NewsMapItemOut,
|
|
|
|
|
|
NewsSummaryOut, NewsTickerItemOut,
|
2026-06-04 20:30:04 -04:00
|
|
|
|
FeedSourceCreate, FeedSourceOut,
|
Add NASA FIRMS active-fire ingest + /api/fires; API keys management page
Coherent merge of two coordinated features on the shared working tree:
FIRMS fire heatmap (backend, t_6e404c14):
- app/fire_sources.py: fetch FIRMS VIIRS area CSV (free MAP_KEY) -> NATS events.fire
- fires hypertable (TimescaleDB, 1-day chunks) with natural-key PK
(latitude, longitude, acq_time, satellite); idempotent ON CONFLICT DO NOTHING
- alembic/versions/002_fires.py; GET /api/fires?bbox=&since= (JSON only)
- POST /api/ingest/fires; ~15 min poll loop (FIRMS_INTERVAL=900) in ingester
- env-driven config (FIRMS_MAP_KEY/DATASET/BBOX/INTERVAL); docs/firms.md covers
the zero-cost GIBS VIIRS_SNPP_Thermal_Anomalies_375m_All tile alternative
- 18 tests (parser, mapping, idempotency, API contract) verified vs real
TimescaleDB+PostGIS (localhost/osint-dashboard-pg image)
API keys page (frontend, t_4433cff2):
- app/keystore.py: api_keys table (self-creating), FIRMS/GEMINI/TELEGRAM
registry with format validation, ****last4 masking, get_api_key()
- GET/POST/DELETE /api/keys (never returns full values); Keys tab in index.html
DB_NULL_POOL env switch in app/database.py enables a NullPool for tests /
short-lived processes that open a fresh event loop per unit.
2026-08-24 15:37:42 -04:00
|
|
|
|
KeyOut, KeyValueIn,
|
2026-08-27 23:07:01 -04:00
|
|
|
|
NewsModelsOut, SettingsIn, SettingsOut,
|
2026-06-04 20:30:04 -04:00
|
|
|
|
SearchResult, SentimentSummary, SourceType,
|
2026-08-27 21:46:37 -04:00
|
|
|
|
SearchQuery, TimelinePoint, VesselBboxUpdate,
|
2026-08-28 09:33:19 -04:00
|
|
|
|
GeofenceCreate, GeofenceUpdate,
|
2026-06-04 20:30:04 -04:00
|
|
|
|
)
|
|
|
|
|
|
from ingestor import ingest_event, fetch_and_process
|
|
|
|
|
|
from sources import ingest_rss_feed, ingest_gdelt, ingest_earthquakes, ingest_social_signals
|
Add NASA FIRMS active-fire ingest + /api/fires; API keys management page
Coherent merge of two coordinated features on the shared working tree:
FIRMS fire heatmap (backend, t_6e404c14):
- app/fire_sources.py: fetch FIRMS VIIRS area CSV (free MAP_KEY) -> NATS events.fire
- fires hypertable (TimescaleDB, 1-day chunks) with natural-key PK
(latitude, longitude, acq_time, satellite); idempotent ON CONFLICT DO NOTHING
- alembic/versions/002_fires.py; GET /api/fires?bbox=&since= (JSON only)
- POST /api/ingest/fires; ~15 min poll loop (FIRMS_INTERVAL=900) in ingester
- env-driven config (FIRMS_MAP_KEY/DATASET/BBOX/INTERVAL); docs/firms.md covers
the zero-cost GIBS VIIRS_SNPP_Thermal_Anomalies_375m_All tile alternative
- 18 tests (parser, mapping, idempotency, API contract) verified vs real
TimescaleDB+PostGIS (localhost/osint-dashboard-pg image)
API keys page (frontend, t_4433cff2):
- app/keystore.py: api_keys table (self-creating), FIRMS/GEMINI/TELEGRAM
registry with format validation, ****last4 masking, get_api_key()
- GET/POST/DELETE /api/keys (never returns full values); Keys tab in index.html
DB_NULL_POOL env switch in app/database.py enables a NullPool for tests /
short-lived processes that open a fresh event loop per unit.
2026-08-24 15:37:42 -04:00
|
|
|
|
from fire_sources import ingest_fires
|
|
|
|
|
|
from keystore import KeyFormatError, delete_key, list_keys, set_key
|
2026-08-27 23:07:01 -04:00
|
|
|
|
from settings_store import SettingsError, get_app_settings, list_models, set_summary_model
|
feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.
- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00
|
|
|
|
from live_layers import (
|
|
|
|
|
|
fetch_aircraft, fetch_fire_incidents, fetch_fire_perimeters,
|
2026-08-29 14:14:08 -04:00
|
|
|
|
fetch_gpsjam, fetch_planespotters_photo, fetch_radar_meta, fetch_sentinel1,
|
|
|
|
|
|
fetch_storms, fetch_trains, fetch_vessels, fetch_weather_alerts,
|
|
|
|
|
|
overlay_catalog, parse_bbox, UpstreamRateLimited,
|
feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.
- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00
|
|
|
|
)
|
2026-06-04 20:30:04 -04:00
|
|
|
|
|
|
|
|
|
|
logging.basicConfig(level=logging.INFO)
|
|
|
|
|
|
logger = structlog.get_logger("osint.dashboard")
|
|
|
|
|
|
|
2026-08-27 21:21:19 -04:00
|
|
|
|
|
|
|
|
|
|
@asynccontextmanager
|
|
|
|
|
|
async def _lifespan(app: FastAPI):
|
|
|
|
|
|
await init_extensions()
|
|
|
|
|
|
from live_layers import close_http, init_http
|
|
|
|
|
|
await init_http()
|
2026-08-28 09:33:19 -04:00
|
|
|
|
try:
|
|
|
|
|
|
from geofence import refresh_cache
|
|
|
|
|
|
await refresh_cache()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
2026-08-29 00:18:49 -04:00
|
|
|
|
from config import AISSTREAM_IN_APP, VESSELAPI_IN_APP
|
2026-08-27 21:21:19 -04:00
|
|
|
|
ais_task = None
|
2026-08-29 00:18:49 -04:00
|
|
|
|
vesselapi_task = None
|
2026-08-28 21:49:05 -04:00
|
|
|
|
adsb_task = None
|
2026-08-27 21:21:19 -04:00
|
|
|
|
if AISSTREAM_IN_APP:
|
|
|
|
|
|
from ais_stream import run_ais_worker
|
|
|
|
|
|
ais_task = asyncio.create_task(run_ais_worker())
|
2026-08-29 00:18:49 -04:00
|
|
|
|
if VESSELAPI_IN_APP:
|
|
|
|
|
|
from vesselapi import run_vesselapi_worker
|
|
|
|
|
|
vesselapi_task = asyncio.create_task(run_vesselapi_worker())
|
2026-08-28 21:49:05 -04:00
|
|
|
|
adsb_task = asyncio.create_task(_adsb_refresh_loop())
|
2026-08-27 21:21:19 -04:00
|
|
|
|
yield
|
|
|
|
|
|
if ais_task is not None:
|
|
|
|
|
|
ais_task.cancel()
|
2026-08-29 00:18:49 -04:00
|
|
|
|
if vesselapi_task is not None:
|
|
|
|
|
|
vesselapi_task.cancel()
|
2026-08-28 21:49:05 -04:00
|
|
|
|
if adsb_task is not None:
|
|
|
|
|
|
adsb_task.cancel()
|
2026-08-29 00:18:49 -04:00
|
|
|
|
from vesselapi import close_client
|
|
|
|
|
|
await close_client()
|
2026-08-27 21:21:19 -04:00
|
|
|
|
await close_http()
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-04 20:30:04 -04:00
|
|
|
|
app = FastAPI(
|
|
|
|
|
|
title="OSINT Dashboard",
|
|
|
|
|
|
description="Real-time geospatial OSINT intelligence dashboard",
|
|
|
|
|
|
version="0.1.0",
|
2026-08-27 21:21:19 -04:00
|
|
|
|
lifespan=_lifespan,
|
2026-06-04 20:30:04 -04:00
|
|
|
|
)
|
2026-08-27 21:21:19 -04:00
|
|
|
|
app.add_middleware(GZipMiddleware, minimum_size=1024)
|
2026-06-04 20:30:04 -04:00
|
|
|
|
|
|
|
|
|
|
STATIC_DIR = Path(__file__).parent / "static"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-28 21:49:05 -04:00
|
|
|
|
async def _adsb_refresh_loop() -> None:
|
|
|
|
|
|
"""Poll ADSB.lol for connected viewports — never from GET /api/aircraft."""
|
|
|
|
|
|
from ws_manager import manager
|
|
|
|
|
|
from live_layers import fetch_aircraft
|
|
|
|
|
|
|
|
|
|
|
|
while True:
|
|
|
|
|
|
try:
|
|
|
|
|
|
boxes = manager.viewports()
|
|
|
|
|
|
for box in boxes:
|
|
|
|
|
|
bbox = f"{box[0]},{box[1]},{box[2]},{box[3]}"
|
|
|
|
|
|
try:
|
|
|
|
|
|
await fetch_aircraft(bbox, persist=True)
|
|
|
|
|
|
except Exception as exc: # noqa: BLE001
|
|
|
|
|
|
logger.warning("adsb_refresh_failed", error=str(exc))
|
|
|
|
|
|
except Exception as exc: # noqa: BLE001
|
|
|
|
|
|
logger.warning("adsb_refresh_loop", error=str(exc))
|
|
|
|
|
|
await asyncio.sleep(8)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class CachedStaticFiles(StaticFiles):
|
|
|
|
|
|
"""Long-cache hashed/vendor Leaflet assets; index.html is served separately."""
|
|
|
|
|
|
|
|
|
|
|
|
async def get_response(self, path: str, scope):
|
|
|
|
|
|
resp = await super().get_response(path, scope)
|
|
|
|
|
|
if path.startswith("vendor/") or path.endswith((".js", ".css", ".woff2", ".png", ".svg")):
|
|
|
|
|
|
resp.headers["Cache-Control"] = "public, max-age=31536000, immutable"
|
|
|
|
|
|
return resp
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-04 20:30:04 -04:00
|
|
|
|
# ── Helpers ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
def event_to_out(row: dict) -> EventOut:
|
|
|
|
|
|
"""Convert DB row dict to EventOut schema."""
|
|
|
|
|
|
return EventOut(
|
|
|
|
|
|
id=row["id"],
|
|
|
|
|
|
source_type=row["source_type"],
|
2026-08-27 21:19:42 -04:00
|
|
|
|
source_id=row.get("source_id"),
|
|
|
|
|
|
title=row.get("title"),
|
|
|
|
|
|
body=row.get("body"),
|
|
|
|
|
|
url=row.get("url"),
|
|
|
|
|
|
sentiment_score=row.get("sentiment_score"),
|
|
|
|
|
|
sentiment_label=row.get("sentiment_label"),
|
|
|
|
|
|
location_lat=row.get("location_lat"),
|
|
|
|
|
|
location_lon=row.get("location_lon"),
|
|
|
|
|
|
location_name=row.get("location_name"),
|
|
|
|
|
|
entities=row.get("entities"),
|
|
|
|
|
|
tags=row.get("tags"),
|
2026-06-04 20:30:04 -04:00
|
|
|
|
ingested_at=row["ingested_at"],
|
|
|
|
|
|
source_timestamp=row["source_timestamp"],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def entity_to_out(row: dict) -> EntityOut:
|
|
|
|
|
|
"""Convert DB row dict to EntityOut schema."""
|
|
|
|
|
|
return EntityOut(
|
|
|
|
|
|
id=row["id"],
|
|
|
|
|
|
name=row["name"],
|
|
|
|
|
|
entity_type=row["entity_type"],
|
|
|
|
|
|
aliases=row["aliases"],
|
|
|
|
|
|
description=row["description"],
|
|
|
|
|
|
metadata=row["metadata"],
|
|
|
|
|
|
location_lat=row["location_lat"],
|
|
|
|
|
|
location_lon=row["location_lon"],
|
|
|
|
|
|
event_count=row["event_count"],
|
|
|
|
|
|
first_seen=row["first_seen"],
|
|
|
|
|
|
last_seen=row["last_seen"],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def alert_to_out(row: dict) -> AlertOut:
|
|
|
|
|
|
"""Convert DB row dict to AlertOut schema."""
|
|
|
|
|
|
return AlertOut(
|
|
|
|
|
|
id=row["id"],
|
|
|
|
|
|
alert_type=row["alert_type"],
|
|
|
|
|
|
entity_id=row["entity_id"],
|
|
|
|
|
|
event_id=row["event_id"],
|
|
|
|
|
|
severity=row["severity"],
|
|
|
|
|
|
title=row["title"],
|
|
|
|
|
|
message=row["message"],
|
|
|
|
|
|
context=row["context"],
|
|
|
|
|
|
acknowledged=bool(row["acknowledged"]),
|
|
|
|
|
|
acknowledged_by=row["acknowledged_by"],
|
|
|
|
|
|
created_at=row["created_at"],
|
|
|
|
|
|
resolved_at=row["resolved_at"],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Health ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/api/health")
|
|
|
|
|
|
async def health():
|
2026-08-28 21:49:05 -04:00
|
|
|
|
"""Liveness: process + DB. Pipeline freshness is in ``checks`` (HTTP 200
|
|
|
|
|
|
unless DB is down, so docker healthcheck does not restart a working HUD).
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
async with async_session() as session:
|
|
|
|
|
|
result = await session.execute(select(func.now()))
|
|
|
|
|
|
db_time = result.scalar()
|
|
|
|
|
|
checks = await _pipeline_checks(session)
|
|
|
|
|
|
except Exception as exc: # noqa: BLE001
|
|
|
|
|
|
logger.warning("health_db_failed", error=str(exc))
|
|
|
|
|
|
return JSONResponse(
|
|
|
|
|
|
{"status": "down", "db_time": None, "checks": {"db": False}},
|
|
|
|
|
|
status_code=503,
|
|
|
|
|
|
)
|
|
|
|
|
|
checks["db"] = True
|
|
|
|
|
|
stale = [
|
|
|
|
|
|
name for name, val in checks.items()
|
|
|
|
|
|
if isinstance(val, dict) and val.get("ok") is False
|
|
|
|
|
|
]
|
|
|
|
|
|
status = "degraded" if stale else "ok"
|
|
|
|
|
|
return {
|
|
|
|
|
|
"status": status,
|
|
|
|
|
|
"db_time": db_time.isoformat() if db_time else None,
|
|
|
|
|
|
"checks": checks,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/api/ready")
|
|
|
|
|
|
async def ready():
|
|
|
|
|
|
"""Readiness: 503 only when the database is unreachable."""
|
|
|
|
|
|
payload = await health()
|
|
|
|
|
|
if isinstance(payload, JSONResponse):
|
|
|
|
|
|
return payload
|
|
|
|
|
|
return JSONResponse(payload, status_code=200)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _pipeline_checks(session) -> dict:
|
|
|
|
|
|
now = datetime.now(timezone.utc)
|
|
|
|
|
|
checks: dict = {}
|
|
|
|
|
|
|
|
|
|
|
|
async def _age(sql: str, name: str, max_age_s: int) -> None:
|
|
|
|
|
|
try:
|
|
|
|
|
|
ts = (await session.execute(text(sql))).scalar()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
checks[name] = {"ok": False, "age_s": None}
|
|
|
|
|
|
return
|
|
|
|
|
|
if ts is None:
|
|
|
|
|
|
checks[name] = {"ok": False, "age_s": None}
|
|
|
|
|
|
return
|
|
|
|
|
|
if getattr(ts, "tzinfo", None) is None:
|
|
|
|
|
|
ts = ts.replace(tzinfo=timezone.utc)
|
|
|
|
|
|
age = (now - ts).total_seconds()
|
|
|
|
|
|
checks[name] = {"ok": age <= max_age_s, "age_s": int(age)}
|
|
|
|
|
|
|
|
|
|
|
|
await _age("SELECT max(ingested_at) FROM events", "events", 20 * 60)
|
|
|
|
|
|
await _age("SELECT max(ingested_at) FROM fires", "fires", 30 * 60)
|
|
|
|
|
|
await _age("SELECT max(timestamp) FROM articles", "articles", 2 * 3600)
|
|
|
|
|
|
await _age("SELECT max(batch_timestamp) FROM article_summaries", "summaries", 2 * 3600)
|
|
|
|
|
|
return checks
|
2026-06-04 20:30:04 -04:00
|
|
|
|
|
|
|
|
|
|
|
2026-08-27 21:21:19 -04:00
|
|
|
|
def overlay_json(data, max_age: int) -> JSONResponse:
|
|
|
|
|
|
"""JSON overlay payload with a short browser/proxy TTL."""
|
|
|
|
|
|
resp = JSONResponse(content=data)
|
|
|
|
|
|
resp.headers["Cache-Control"] = f"public, max-age={max_age}"
|
|
|
|
|
|
return resp
|
2026-06-04 20:30:04 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Feed Sources ──────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/api/sources", response_model=list[FeedSourceOut])
|
|
|
|
|
|
async def list_sources(enabled_only: bool = Query(True)):
|
|
|
|
|
|
"""List all configured feed sources."""
|
|
|
|
|
|
async with async_session() as session:
|
|
|
|
|
|
stmt = select(feed_sources).order_by(feed_sources.c.name)
|
|
|
|
|
|
if enabled_only:
|
|
|
|
|
|
stmt = stmt.where(feed_sources.c.enabled == 1)
|
|
|
|
|
|
rows = (await session.execute(stmt)).mappings().all()
|
|
|
|
|
|
return [FeedSourceOut(
|
|
|
|
|
|
id=r["id"], name=r["name"], source_type=r["source_type"],
|
|
|
|
|
|
url=r["url"], config=r["config"], enabled=bool(r["enabled"]),
|
|
|
|
|
|
created_at=r["created_at"],
|
|
|
|
|
|
) for r in rows]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/api/sources", status_code=201)
|
|
|
|
|
|
async def create_source(payload: FeedSourceCreate):
|
|
|
|
|
|
"""Add a new feed source."""
|
|
|
|
|
|
async with async_session() as session:
|
|
|
|
|
|
values = payload.model_dump()
|
|
|
|
|
|
result = await session.execute(feed_sources.insert().values(**values))
|
|
|
|
|
|
await session.commit()
|
|
|
|
|
|
pk = result.inserted_primary_key[0] # type: ignore
|
|
|
|
|
|
return {"id": str(pk)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.patch("/api/sources/{source_id}")
|
|
|
|
|
|
async def update_source(source_id: UUID, payload: dict):
|
|
|
|
|
|
"""Update a feed source (e.g., toggle enabled)."""
|
|
|
|
|
|
async with async_session() as session:
|
|
|
|
|
|
row = (await session.execute(
|
|
|
|
|
|
select(feed_sources).where(feed_sources.c.id == source_id)
|
|
|
|
|
|
)).mappings().one_or_none()
|
|
|
|
|
|
if not row:
|
|
|
|
|
|
raise HTTPException(404, "Source not found")
|
|
|
|
|
|
await session.execute(
|
|
|
|
|
|
feed_sources.update()
|
|
|
|
|
|
.where(feed_sources.c.id == source_id)
|
|
|
|
|
|
.values(**payload)
|
|
|
|
|
|
)
|
|
|
|
|
|
await session.commit()
|
|
|
|
|
|
return {"ok": True}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Events ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/api/events", response_model=list[EventOut])
|
|
|
|
|
|
async def list_events(
|
|
|
|
|
|
source_type: SourceType | None = Query(None),
|
frontend: Ghost-in-the-Shell overhaul — map-first landing, HUD chrome, event blips, tickers
- Map is now the landing view: full-viewport NASA GIBS globe, existing
fires/cameras/HLS map machinery preserved verbatim
- New 'Event Blips' layer: geolocated ingest events, color-coded by source,
bbox/since/has_coords filters added to GET /api/events
- Weather / Flights(ADS-B) / Vessels(AIS) layer slots reserved (feed pending)
- Market ticker strip with configurable symbols — auto-promotes to LIVE when
GET /api/market returns {symbols:[...]} (contract documented in Settings)
- Breaking-news ticker: LLM exec-summary flash + headline marquee, 15-min cycle
- Dropdown nav (Map/News/Events/Alerts/Entities/Ingest/API Keys/Settings),
Settings view (localStorage: symbols, map layer defaults), System panel
- Section-9 theme: near-black navy, cyan/magenta accents, Orbitron/Rajdhani/
Share Tech Mono, chamfered HUD panels, scanlines, boot splash, UTC clock
- Fix: events.camera enum value missing from models.py/schemas.py caused 500s
on every events query once camera events flowed in (migration 004 added it
to the DB enum only)
2026-08-27 17:22:14 -04:00
|
|
|
|
bbox: str | None = Query(
|
|
|
|
|
|
None,
|
|
|
|
|
|
description="Comma-separated 'minlon,minlat,maxlon,maxlat' to bound the "
|
|
|
|
|
|
"result set by event coordinates. Omit for all stored events.",
|
|
|
|
|
|
),
|
|
|
|
|
|
since: datetime | None = Query(
|
|
|
|
|
|
None,
|
|
|
|
|
|
description="Only events ingested at/after this UTC instant "
|
|
|
|
|
|
"(ISO 8601, e.g. '2026-08-24T12:00:00Z').",
|
|
|
|
|
|
),
|
|
|
|
|
|
has_coords: bool = Query(
|
|
|
|
|
|
False,
|
|
|
|
|
|
description="Only events that carry a location (location_lat/lon set). "
|
|
|
|
|
|
"Used by the map's event-blips layer.",
|
|
|
|
|
|
),
|
2026-06-04 20:30:04 -04:00
|
|
|
|
limit: int = Query(50, ge=1, le=500),
|
|
|
|
|
|
offset: int = Query(0, ge=0),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""List recent ingested events."""
|
|
|
|
|
|
async with async_session() as session:
|
2026-08-27 21:19:42 -04:00
|
|
|
|
if has_coords:
|
|
|
|
|
|
stmt = select(
|
|
|
|
|
|
events.c.id, events.c.source_type, events.c.source_id,
|
|
|
|
|
|
events.c.title, events.c.url,
|
|
|
|
|
|
events.c.location_lat, events.c.location_lon, events.c.location_name,
|
|
|
|
|
|
events.c.ingested_at, events.c.source_timestamp,
|
|
|
|
|
|
).order_by(events.c.ingested_at.desc())
|
|
|
|
|
|
else:
|
|
|
|
|
|
stmt = select(events).order_by(events.c.ingested_at.desc())
|
2026-06-04 20:30:04 -04:00
|
|
|
|
if source_type:
|
|
|
|
|
|
stmt = stmt.where(events.c.source_type == source_type.value)
|
frontend: Ghost-in-the-Shell overhaul — map-first landing, HUD chrome, event blips, tickers
- Map is now the landing view: full-viewport NASA GIBS globe, existing
fires/cameras/HLS map machinery preserved verbatim
- New 'Event Blips' layer: geolocated ingest events, color-coded by source,
bbox/since/has_coords filters added to GET /api/events
- Weather / Flights(ADS-B) / Vessels(AIS) layer slots reserved (feed pending)
- Market ticker strip with configurable symbols — auto-promotes to LIVE when
GET /api/market returns {symbols:[...]} (contract documented in Settings)
- Breaking-news ticker: LLM exec-summary flash + headline marquee, 15-min cycle
- Dropdown nav (Map/News/Events/Alerts/Entities/Ingest/API Keys/Settings),
Settings view (localStorage: symbols, map layer defaults), System panel
- Section-9 theme: near-black navy, cyan/magenta accents, Orbitron/Rajdhani/
Share Tech Mono, chamfered HUD panels, scanlines, boot splash, UTC clock
- Fix: events.camera enum value missing from models.py/schemas.py caused 500s
on every events query once camera events flowed in (migration 004 added it
to the DB enum only)
2026-08-27 17:22:14 -04:00
|
|
|
|
if since:
|
|
|
|
|
|
stmt = stmt.where(events.c.ingested_at >= since)
|
|
|
|
|
|
if has_coords:
|
|
|
|
|
|
stmt = stmt.where(events.c.location_lat.isnot(None))
|
|
|
|
|
|
if bbox:
|
|
|
|
|
|
parts = [p.strip() for p in bbox.split(",")]
|
|
|
|
|
|
if len(parts) != 4:
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
422, "bbox must be 'minlon,minlat,maxlon,maxlat' (4 comma-separated values)"
|
|
|
|
|
|
)
|
|
|
|
|
|
try:
|
|
|
|
|
|
minlon, minlat, maxlon, maxlat = (float(p) for p in parts)
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
422, "bbox values must be floats: 'minlon,minlat,maxlon,maxlat'"
|
|
|
|
|
|
)
|
|
|
|
|
|
stmt = stmt.where(
|
|
|
|
|
|
and_(
|
|
|
|
|
|
events.c.location_lon >= minlon, events.c.location_lon <= maxlon,
|
|
|
|
|
|
events.c.location_lat >= minlat, events.c.location_lat <= maxlat,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
2026-06-04 20:30:04 -04:00
|
|
|
|
stmt = stmt.limit(limit).offset(offset)
|
|
|
|
|
|
rows = (await session.execute(stmt)).mappings().all()
|
|
|
|
|
|
return [event_to_out(r) for r in rows]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/api/events/{event_id}", response_model=EventOut)
|
|
|
|
|
|
async def get_event(event_id: UUID):
|
|
|
|
|
|
"""Get a single event by ID."""
|
|
|
|
|
|
async with async_session() as session:
|
|
|
|
|
|
row = (await session.execute(
|
|
|
|
|
|
select(events).where(events.c.id == event_id)
|
|
|
|
|
|
)).mappings().one_or_none()
|
|
|
|
|
|
if not row:
|
|
|
|
|
|
raise HTTPException(404, "Event not found")
|
|
|
|
|
|
return event_to_out(row)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/api/events", status_code=201)
|
|
|
|
|
|
async def create_event(payload: EventCreate):
|
|
|
|
|
|
"""Manually ingest an event (bypasses NATS)."""
|
|
|
|
|
|
values = payload.model_dump(exclude_unset=True)
|
|
|
|
|
|
if not values.get("source_timestamp"):
|
|
|
|
|
|
values["source_timestamp"] = datetime.now(timezone.utc)
|
|
|
|
|
|
event_id = await ingest_event(values)
|
|
|
|
|
|
return {"id": str(event_id)}
|
|
|
|
|
|
|
|
|
|
|
|
|
Add NASA FIRMS active-fire ingest + /api/fires; API keys management page
Coherent merge of two coordinated features on the shared working tree:
FIRMS fire heatmap (backend, t_6e404c14):
- app/fire_sources.py: fetch FIRMS VIIRS area CSV (free MAP_KEY) -> NATS events.fire
- fires hypertable (TimescaleDB, 1-day chunks) with natural-key PK
(latitude, longitude, acq_time, satellite); idempotent ON CONFLICT DO NOTHING
- alembic/versions/002_fires.py; GET /api/fires?bbox=&since= (JSON only)
- POST /api/ingest/fires; ~15 min poll loop (FIRMS_INTERVAL=900) in ingester
- env-driven config (FIRMS_MAP_KEY/DATASET/BBOX/INTERVAL); docs/firms.md covers
the zero-cost GIBS VIIRS_SNPP_Thermal_Anomalies_375m_All tile alternative
- 18 tests (parser, mapping, idempotency, API contract) verified vs real
TimescaleDB+PostGIS (localhost/osint-dashboard-pg image)
API keys page (frontend, t_4433cff2):
- app/keystore.py: api_keys table (self-creating), FIRMS/GEMINI/TELEGRAM
registry with format validation, ****last4 masking, get_api_key()
- GET/POST/DELETE /api/keys (never returns full values); Keys tab in index.html
DB_NULL_POOL env switch in app/database.py enables a NullPool for tests /
short-lived processes that open a fresh event loop per unit.
2026-08-24 15:37:42 -04:00
|
|
|
|
# ── Active Fires / Hotspots (NASA FIRMS) ─────────────────────────────────
|
|
|
|
|
|
|
2026-08-27 21:19:42 -04:00
|
|
|
|
def fire_heat_row(r) -> dict:
|
|
|
|
|
|
"""Minimal FIRMS point for the heatmap overlay."""
|
|
|
|
|
|
return {
|
|
|
|
|
|
"lat": r["latitude"],
|
|
|
|
|
|
"lon": r["longitude"],
|
|
|
|
|
|
"i": r["brightness"],
|
|
|
|
|
|
"c": r["confidence"],
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def camera_map_row(r) -> dict:
|
|
|
|
|
|
"""Minimal camera pin — URLs stay off the list payload."""
|
|
|
|
|
|
return {
|
|
|
|
|
|
"id": str(r["id"]),
|
|
|
|
|
|
"lat": r["location_lat"],
|
|
|
|
|
|
"lon": r["location_lon"],
|
|
|
|
|
|
"device_type": r["device_type"],
|
|
|
|
|
|
"discovery_source": r["discovery_source"],
|
|
|
|
|
|
"location_name": r["location_name"],
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/api/fires", response_model=None)
|
Add NASA FIRMS active-fire ingest + /api/fires; API keys management page
Coherent merge of two coordinated features on the shared working tree:
FIRMS fire heatmap (backend, t_6e404c14):
- app/fire_sources.py: fetch FIRMS VIIRS area CSV (free MAP_KEY) -> NATS events.fire
- fires hypertable (TimescaleDB, 1-day chunks) with natural-key PK
(latitude, longitude, acq_time, satellite); idempotent ON CONFLICT DO NOTHING
- alembic/versions/002_fires.py; GET /api/fires?bbox=&since= (JSON only)
- POST /api/ingest/fires; ~15 min poll loop (FIRMS_INTERVAL=900) in ingester
- env-driven config (FIRMS_MAP_KEY/DATASET/BBOX/INTERVAL); docs/firms.md covers
the zero-cost GIBS VIIRS_SNPP_Thermal_Anomalies_375m_All tile alternative
- 18 tests (parser, mapping, idempotency, API contract) verified vs real
TimescaleDB+PostGIS (localhost/osint-dashboard-pg image)
API keys page (frontend, t_4433cff2):
- app/keystore.py: api_keys table (self-creating), FIRMS/GEMINI/TELEGRAM
registry with format validation, ****last4 masking, get_api_key()
- GET/POST/DELETE /api/keys (never returns full values); Keys tab in index.html
DB_NULL_POOL env switch in app/database.py enables a NullPool for tests /
short-lived processes that open a fresh event loop per unit.
2026-08-24 15:37:42 -04:00
|
|
|
|
async def list_fires(
|
|
|
|
|
|
bbox: str | None = Query(
|
|
|
|
|
|
None,
|
|
|
|
|
|
description="Comma-separated 'minlon,minlat,maxlon,maxlat' to bound the "
|
|
|
|
|
|
"result set (e.g. '-125,24,-66,50'). Omit for all stored "
|
|
|
|
|
|
"detections (most recent first).",
|
|
|
|
|
|
),
|
|
|
|
|
|
since: datetime | None = Query(
|
|
|
|
|
|
None,
|
|
|
|
|
|
description="Only hotspots acquired at/after this UTC instant "
|
|
|
|
|
|
"(ISO 8601, e.g. '2026-08-24T12:00:00Z').",
|
|
|
|
|
|
),
|
|
|
|
|
|
limit: int = Query(2000, ge=1, le=10000),
|
2026-08-27 21:19:42 -04:00
|
|
|
|
format: str = Query("full", description="'full' FireOut rows or 'heat' {lat,lon,i,c}"),
|
Add NASA FIRMS active-fire ingest + /api/fires; API keys management page
Coherent merge of two coordinated features on the shared working tree:
FIRMS fire heatmap (backend, t_6e404c14):
- app/fire_sources.py: fetch FIRMS VIIRS area CSV (free MAP_KEY) -> NATS events.fire
- fires hypertable (TimescaleDB, 1-day chunks) with natural-key PK
(latitude, longitude, acq_time, satellite); idempotent ON CONFLICT DO NOTHING
- alembic/versions/002_fires.py; GET /api/fires?bbox=&since= (JSON only)
- POST /api/ingest/fires; ~15 min poll loop (FIRMS_INTERVAL=900) in ingester
- env-driven config (FIRMS_MAP_KEY/DATASET/BBOX/INTERVAL); docs/firms.md covers
the zero-cost GIBS VIIRS_SNPP_Thermal_Anomalies_375m_All tile alternative
- 18 tests (parser, mapping, idempotency, API contract) verified vs real
TimescaleDB+PostGIS (localhost/osint-dashboard-pg image)
API keys page (frontend, t_4433cff2):
- app/keystore.py: api_keys table (self-creating), FIRMS/GEMINI/TELEGRAM
registry with format validation, ****last4 masking, get_api_key()
- GET/POST/DELETE /api/keys (never returns full values); Keys tab in index.html
DB_NULL_POOL env switch in app/database.py enables a NullPool for tests /
short-lived processes that open a fresh event loop per unit.
2026-08-24 15:37:42 -04:00
|
|
|
|
):
|
|
|
|
|
|
"""List stored FIRMS active fire/hotspot detections as JSON.
|
|
|
|
|
|
|
|
|
|
|
|
This is the data contract for the map's fire heatmap overlay: the frontend
|
2026-08-27 21:19:42 -04:00
|
|
|
|
calls `GET /api/fires?bbox=...&since=...&format=heat` and renders the points.
|
Add NASA FIRMS active-fire ingest + /api/fires; API keys management page
Coherent merge of two coordinated features on the shared working tree:
FIRMS fire heatmap (backend, t_6e404c14):
- app/fire_sources.py: fetch FIRMS VIIRS area CSV (free MAP_KEY) -> NATS events.fire
- fires hypertable (TimescaleDB, 1-day chunks) with natural-key PK
(latitude, longitude, acq_time, satellite); idempotent ON CONFLICT DO NOTHING
- alembic/versions/002_fires.py; GET /api/fires?bbox=&since= (JSON only)
- POST /api/ingest/fires; ~15 min poll loop (FIRMS_INTERVAL=900) in ingester
- env-driven config (FIRMS_MAP_KEY/DATASET/BBOX/INTERVAL); docs/firms.md covers
the zero-cost GIBS VIIRS_SNPP_Thermal_Anomalies_375m_All tile alternative
- 18 tests (parser, mapping, idempotency, API contract) verified vs real
TimescaleDB+PostGIS (localhost/osint-dashboard-pg image)
API keys page (frontend, t_4433cff2):
- app/keystore.py: api_keys table (self-creating), FIRMS/GEMINI/TELEGRAM
registry with format validation, ****last4 masking, get_api_key()
- GET/POST/DELETE /api/keys (never returns full values); Keys tab in index.html
DB_NULL_POOL env switch in app/database.py enables a NullPool for tests /
short-lived processes that open a fresh event loop per unit.
2026-08-24 15:37:42 -04:00
|
|
|
|
"""
|
2026-08-27 21:19:42 -04:00
|
|
|
|
fmt = (format or "full").lower()
|
|
|
|
|
|
if fmt not in ("full", "heat"):
|
|
|
|
|
|
raise HTTPException(422, "format must be 'full' or 'heat'")
|
Add NASA FIRMS active-fire ingest + /api/fires; API keys management page
Coherent merge of two coordinated features on the shared working tree:
FIRMS fire heatmap (backend, t_6e404c14):
- app/fire_sources.py: fetch FIRMS VIIRS area CSV (free MAP_KEY) -> NATS events.fire
- fires hypertable (TimescaleDB, 1-day chunks) with natural-key PK
(latitude, longitude, acq_time, satellite); idempotent ON CONFLICT DO NOTHING
- alembic/versions/002_fires.py; GET /api/fires?bbox=&since= (JSON only)
- POST /api/ingest/fires; ~15 min poll loop (FIRMS_INTERVAL=900) in ingester
- env-driven config (FIRMS_MAP_KEY/DATASET/BBOX/INTERVAL); docs/firms.md covers
the zero-cost GIBS VIIRS_SNPP_Thermal_Anomalies_375m_All tile alternative
- 18 tests (parser, mapping, idempotency, API contract) verified vs real
TimescaleDB+PostGIS (localhost/osint-dashboard-pg image)
API keys page (frontend, t_4433cff2):
- app/keystore.py: api_keys table (self-creating), FIRMS/GEMINI/TELEGRAM
registry with format validation, ****last4 masking, get_api_key()
- GET/POST/DELETE /api/keys (never returns full values); Keys tab in index.html
DB_NULL_POOL env switch in app/database.py enables a NullPool for tests /
short-lived processes that open a fresh event loop per unit.
2026-08-24 15:37:42 -04:00
|
|
|
|
async with async_session() as session:
|
|
|
|
|
|
stmt = select(fires).order_by(fires.c.acq_time.desc())
|
|
|
|
|
|
if since:
|
|
|
|
|
|
stmt = stmt.where(fires.c.acq_time >= since)
|
|
|
|
|
|
if bbox:
|
|
|
|
|
|
parts = [p.strip() for p in bbox.split(",")]
|
|
|
|
|
|
if len(parts) != 4:
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
422, "bbox must be 'minlon,minlat,maxlon,maxlat' (4 comma-separated values)"
|
|
|
|
|
|
)
|
|
|
|
|
|
try:
|
|
|
|
|
|
minlon, minlat, maxlon, maxlat = (float(p) for p in parts)
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
422, "bbox values must be floats: 'minlon,minlat,maxlon,maxlat'"
|
|
|
|
|
|
)
|
|
|
|
|
|
stmt = stmt.where(
|
|
|
|
|
|
and_(
|
|
|
|
|
|
fires.c.longitude >= minlon, fires.c.longitude <= maxlon,
|
|
|
|
|
|
fires.c.latitude >= minlat, fires.c.latitude <= maxlat,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
stmt = stmt.limit(limit)
|
|
|
|
|
|
rows = (await session.execute(stmt)).mappings().all()
|
2026-08-27 21:19:42 -04:00
|
|
|
|
if fmt == "heat":
|
|
|
|
|
|
return [fire_heat_row(r) for r in rows]
|
Add NASA FIRMS active-fire ingest + /api/fires; API keys management page
Coherent merge of two coordinated features on the shared working tree:
FIRMS fire heatmap (backend, t_6e404c14):
- app/fire_sources.py: fetch FIRMS VIIRS area CSV (free MAP_KEY) -> NATS events.fire
- fires hypertable (TimescaleDB, 1-day chunks) with natural-key PK
(latitude, longitude, acq_time, satellite); idempotent ON CONFLICT DO NOTHING
- alembic/versions/002_fires.py; GET /api/fires?bbox=&since= (JSON only)
- POST /api/ingest/fires; ~15 min poll loop (FIRMS_INTERVAL=900) in ingester
- env-driven config (FIRMS_MAP_KEY/DATASET/BBOX/INTERVAL); docs/firms.md covers
the zero-cost GIBS VIIRS_SNPP_Thermal_Anomalies_375m_All tile alternative
- 18 tests (parser, mapping, idempotency, API contract) verified vs real
TimescaleDB+PostGIS (localhost/osint-dashboard-pg image)
API keys page (frontend, t_4433cff2):
- app/keystore.py: api_keys table (self-creating), FIRMS/GEMINI/TELEGRAM
registry with format validation, ****last4 masking, get_api_key()
- GET/POST/DELETE /api/keys (never returns full values); Keys tab in index.html
DB_NULL_POOL env switch in app/database.py enables a NullPool for tests /
short-lived processes that open a fresh event loop per unit.
2026-08-24 15:37:42 -04:00
|
|
|
|
return [
|
|
|
|
|
|
FireOut(
|
|
|
|
|
|
latitude=r["latitude"], longitude=r["longitude"],
|
|
|
|
|
|
brightness=r["brightness"], confidence=r["confidence"],
|
|
|
|
|
|
acq_time=r["acq_time"], satellite=r["satellite"],
|
|
|
|
|
|
instrument=r["instrument"], bright_ti5=r["bright_ti5"],
|
|
|
|
|
|
frp=r["frp"], daynight=r["daynight"],
|
|
|
|
|
|
)
|
|
|
|
|
|
for r in rows
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-04 20:30:04 -04:00
|
|
|
|
# ── Search ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/api/search", response_model=SearchResult)
|
|
|
|
|
|
async def search_events(query: SearchQuery):
|
|
|
|
|
|
"""Full-text search across events with optional filters."""
|
|
|
|
|
|
async with async_session() as session:
|
|
|
|
|
|
# Build query with tsvector full-text search (parameterized to avoid SQL injection)
|
|
|
|
|
|
tsquery_param = text("plainto_tsquery('english', :q)")
|
|
|
|
|
|
|
|
|
|
|
|
base_stmt = select(
|
|
|
|
|
|
events,
|
|
|
|
|
|
func.count().over().label("total")
|
|
|
|
|
|
).where(
|
|
|
|
|
|
events.c.search_vector.op("@@")(tsquery_param)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# Apply filters
|
|
|
|
|
|
if query.source_type:
|
|
|
|
|
|
base_stmt = base_stmt.where(events.c.source_type == query.source_type.value)
|
|
|
|
|
|
if query.entity_id:
|
|
|
|
|
|
base_stmt = base_stmt.join(
|
|
|
|
|
|
entity_events, entity_events.c.event_id == events.c.id
|
|
|
|
|
|
).where(entity_events.c.entity_id == query.entity_id)
|
|
|
|
|
|
if query.sentiment:
|
|
|
|
|
|
base_stmt = base_stmt.where(events.c.sentiment_label == query.sentiment.value)
|
|
|
|
|
|
if query.min_date:
|
|
|
|
|
|
base_stmt = base_stmt.where(events.c.source_timestamp >= query.min_date)
|
|
|
|
|
|
if query.max_date:
|
|
|
|
|
|
base_stmt = base_stmt.where(events.c.source_timestamp <= query.max_date)
|
|
|
|
|
|
if query.min_lat is not None and query.max_lat is not None:
|
|
|
|
|
|
base_stmt = base_stmt.where(
|
|
|
|
|
|
and_(
|
|
|
|
|
|
events.c.location_lat >= query.min_lat,
|
|
|
|
|
|
events.c.location_lat <= query.max_lat,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
if query.min_lon is not None and query.max_lon is not None:
|
|
|
|
|
|
base_stmt = base_stmt.where(
|
|
|
|
|
|
and_(
|
|
|
|
|
|
events.c.location_lon >= query.min_lon,
|
|
|
|
|
|
events.c.location_lon <= query.max_lon,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
base_stmt = base_stmt.order_by(events.c.ingested_at.desc())
|
|
|
|
|
|
base_stmt = base_stmt.limit(query.limit).offset(query.offset)
|
|
|
|
|
|
|
|
|
|
|
|
result = (await session.execute(base_stmt, {"q": query.q})).mappings().all()
|
|
|
|
|
|
if result:
|
|
|
|
|
|
total = result[0]["total"]
|
|
|
|
|
|
else:
|
|
|
|
|
|
total = 0
|
|
|
|
|
|
evts = [event_to_out(r) for r in result]
|
|
|
|
|
|
|
|
|
|
|
|
return SearchResult(
|
|
|
|
|
|
events=evts,
|
|
|
|
|
|
total=total,
|
|
|
|
|
|
has_more=query.offset + len(evts) < total,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Entities ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/api/entities", response_model=list[EntityOut])
|
|
|
|
|
|
async def list_entities(
|
|
|
|
|
|
entity_type: EntityKind | None = Query(None),
|
|
|
|
|
|
limit: int = Query(50, ge=1, le=500),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""List tracked entities."""
|
|
|
|
|
|
async with async_session() as session:
|
|
|
|
|
|
stmt = select(entities).order_by(entities.c.event_count.desc())
|
|
|
|
|
|
if entity_type:
|
|
|
|
|
|
stmt = stmt.where(entities.c.entity_type == entity_type.value)
|
|
|
|
|
|
stmt = stmt.limit(limit)
|
|
|
|
|
|
rows = (await session.execute(stmt)).mappings().all()
|
|
|
|
|
|
return [entity_to_out(r) for r in rows]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/api/entities/{entity_id}", response_model=EntityOut)
|
|
|
|
|
|
async def get_entity(entity_id: UUID):
|
|
|
|
|
|
"""Get entity details with recent events."""
|
|
|
|
|
|
async with async_session() as session:
|
|
|
|
|
|
row = (await session.execute(
|
|
|
|
|
|
select(entities).where(entities.c.id == entity_id)
|
|
|
|
|
|
)).mappings().one_or_none()
|
|
|
|
|
|
if not row:
|
|
|
|
|
|
raise HTTPException(404, "Entity not found")
|
|
|
|
|
|
return entity_to_out(row)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/api/entities", status_code=201)
|
|
|
|
|
|
async def create_entity(payload: EntityCreate):
|
|
|
|
|
|
"""Create or update a tracked entity."""
|
|
|
|
|
|
async with async_session() as session:
|
|
|
|
|
|
# Check if entity already exists by name
|
|
|
|
|
|
existing = (await session.execute(
|
|
|
|
|
|
select(entities).where(entities.c.name == payload.name)
|
|
|
|
|
|
)).mappings().one_or_none()
|
|
|
|
|
|
|
|
|
|
|
|
if existing:
|
|
|
|
|
|
# Update
|
|
|
|
|
|
updates = payload.model_dump(exclude_unset=True)
|
|
|
|
|
|
updates["last_seen"] = datetime.now(timezone.utc)
|
|
|
|
|
|
await session.execute(
|
|
|
|
|
|
entities.update()
|
|
|
|
|
|
.where(entities.c.id == existing["id"])
|
|
|
|
|
|
.values(**updates)
|
|
|
|
|
|
)
|
|
|
|
|
|
await session.commit()
|
|
|
|
|
|
return {"id": str(existing["id"]), "created": False}
|
|
|
|
|
|
|
|
|
|
|
|
# Create
|
|
|
|
|
|
values = payload.model_dump()
|
|
|
|
|
|
result = await session.execute(entities.insert().values(**values))
|
|
|
|
|
|
await session.commit()
|
|
|
|
|
|
pk = result.inserted_primary_key[0] # type: ignore
|
|
|
|
|
|
return {"id": str(pk), "created": True}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/api/entities/{entity_id}/events", response_model=list[EventOut])
|
|
|
|
|
|
async def get_entity_events(
|
|
|
|
|
|
entity_id: UUID,
|
|
|
|
|
|
limit: int = Query(50, ge=1, le=500),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""Get events linked to a specific entity."""
|
|
|
|
|
|
async with async_session() as session:
|
|
|
|
|
|
stmt = (
|
|
|
|
|
|
select(events)
|
|
|
|
|
|
.join(entity_events, entity_events.c.event_id == events.c.id)
|
|
|
|
|
|
.where(entity_events.c.entity_id == entity_id)
|
|
|
|
|
|
.order_by(events.c.source_timestamp.desc())
|
|
|
|
|
|
.limit(limit)
|
|
|
|
|
|
)
|
|
|
|
|
|
rows = (await session.execute(stmt)).mappings().all()
|
|
|
|
|
|
return [event_to_out(r) for r in rows]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Alerts ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/api/alerts", response_model=list[AlertOut])
|
|
|
|
|
|
async def list_alerts(
|
|
|
|
|
|
severity: AlertSeverity | None = Query(None),
|
|
|
|
|
|
acknowledged: bool | None = Query(None),
|
|
|
|
|
|
entity_id: UUID | None = Query(None),
|
|
|
|
|
|
limit: int = Query(50, ge=1, le=500),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""List alerts with optional filters."""
|
|
|
|
|
|
async with async_session() as session:
|
|
|
|
|
|
stmt = select(alerts).order_by(
|
|
|
|
|
|
alerts.c.severity.desc(), alerts.c.created_at.desc()
|
|
|
|
|
|
)
|
|
|
|
|
|
if severity:
|
|
|
|
|
|
stmt = stmt.where(alerts.c.severity == severity.value)
|
|
|
|
|
|
if acknowledged is not None:
|
|
|
|
|
|
stmt = stmt.where(alerts.c.acknowledged == int(acknowledged))
|
|
|
|
|
|
if entity_id:
|
|
|
|
|
|
stmt = stmt.where(alerts.c.entity_id == entity_id)
|
|
|
|
|
|
stmt = stmt.limit(limit)
|
|
|
|
|
|
rows = (await session.execute(stmt)).mappings().all()
|
|
|
|
|
|
return [alert_to_out(r) for r in rows]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/api/alerts", status_code=201)
|
|
|
|
|
|
async def create_alert(payload: AlertCreate):
|
|
|
|
|
|
"""Create a new alert."""
|
|
|
|
|
|
async with async_session() as session:
|
|
|
|
|
|
values = payload.model_dump()
|
|
|
|
|
|
result = await session.execute(alerts.insert().values(**values))
|
|
|
|
|
|
await session.commit()
|
|
|
|
|
|
pk = result.inserted_primary_key[0] # type: ignore
|
|
|
|
|
|
return {"id": str(pk)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.patch("/api/alerts/{alert_id}")
|
|
|
|
|
|
async def update_alert(alert_id: UUID, payload: AlertUpdate):
|
|
|
|
|
|
"""Update alert (acknowledge, resolve)."""
|
|
|
|
|
|
async with async_session() as session:
|
|
|
|
|
|
row = (await session.execute(
|
|
|
|
|
|
select(alerts).where(alerts.c.id == alert_id)
|
|
|
|
|
|
)).mappings().one_or_none()
|
|
|
|
|
|
if not row:
|
|
|
|
|
|
raise HTTPException(404, "Alert not found")
|
|
|
|
|
|
updates = payload.model_dump(exclude_unset=True)
|
|
|
|
|
|
if "acknowledged" in updates:
|
|
|
|
|
|
updates["acknowledged"] = int(updates["acknowledged"])
|
|
|
|
|
|
await session.execute(
|
|
|
|
|
|
alerts.update().where(alerts.c.id == alert_id).values(**updates)
|
|
|
|
|
|
)
|
|
|
|
|
|
await session.commit()
|
|
|
|
|
|
return {"ok": True}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Documents ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/api/documents", response_model=dict)
|
|
|
|
|
|
async def list_documents(
|
|
|
|
|
|
limit: int = Query(50, ge=1, le=500),
|
|
|
|
|
|
offset: int = Query(0, ge=0),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""List documents indexed in MinIO."""
|
|
|
|
|
|
async with async_session() as session:
|
|
|
|
|
|
stmt = select(documents).order_by(documents.c.uploaded_at.desc()).limit(limit).offset(offset)
|
|
|
|
|
|
rows = (await session.execute(stmt)).mappings().all()
|
|
|
|
|
|
return {
|
|
|
|
|
|
"documents": [{
|
|
|
|
|
|
"id": str(r["id"]), "bucket": r["bucket"], "object_key": r["object_key"],
|
|
|
|
|
|
"content_type": r["content_type"], "size_bytes": r["size_bytes"],
|
|
|
|
|
|
"description": r["description"], "tags": r["tags"],
|
|
|
|
|
|
"event_id": str(r["event_id"]) if r["event_id"] else None,
|
|
|
|
|
|
"uploaded_at": r["uploaded_at"].isoformat() if r["uploaded_at"] else None,
|
|
|
|
|
|
} for r in rows],
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
Add NASA FIRMS active-fire ingest + /api/fires; API keys management page
Coherent merge of two coordinated features on the shared working tree:
FIRMS fire heatmap (backend, t_6e404c14):
- app/fire_sources.py: fetch FIRMS VIIRS area CSV (free MAP_KEY) -> NATS events.fire
- fires hypertable (TimescaleDB, 1-day chunks) with natural-key PK
(latitude, longitude, acq_time, satellite); idempotent ON CONFLICT DO NOTHING
- alembic/versions/002_fires.py; GET /api/fires?bbox=&since= (JSON only)
- POST /api/ingest/fires; ~15 min poll loop (FIRMS_INTERVAL=900) in ingester
- env-driven config (FIRMS_MAP_KEY/DATASET/BBOX/INTERVAL); docs/firms.md covers
the zero-cost GIBS VIIRS_SNPP_Thermal_Anomalies_375m_All tile alternative
- 18 tests (parser, mapping, idempotency, API contract) verified vs real
TimescaleDB+PostGIS (localhost/osint-dashboard-pg image)
API keys page (frontend, t_4433cff2):
- app/keystore.py: api_keys table (self-creating), FIRMS/GEMINI/TELEGRAM
registry with format validation, ****last4 masking, get_api_key()
- GET/POST/DELETE /api/keys (never returns full values); Keys tab in index.html
DB_NULL_POOL env switch in app/database.py enables a NullPool for tests /
short-lived processes that open a fresh event loop per unit.
2026-08-24 15:37:42 -04:00
|
|
|
|
# ── API Keys ────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/api/keys", response_model=list[KeyOut])
|
|
|
|
|
|
async def list_api_keys():
|
|
|
|
|
|
"""List known API keys with set/missing status — masked, never raw.
|
|
|
|
|
|
|
2026-08-28 00:02:51 -04:00
|
|
|
|
Registered keys (FIRMS_MAP_KEY, NOUS_API_KEY, TELEGRAM_TOKEN)
|
Add NASA FIRMS active-fire ingest + /api/fires; API keys management page
Coherent merge of two coordinated features on the shared working tree:
FIRMS fire heatmap (backend, t_6e404c14):
- app/fire_sources.py: fetch FIRMS VIIRS area CSV (free MAP_KEY) -> NATS events.fire
- fires hypertable (TimescaleDB, 1-day chunks) with natural-key PK
(latitude, longitude, acq_time, satellite); idempotent ON CONFLICT DO NOTHING
- alembic/versions/002_fires.py; GET /api/fires?bbox=&since= (JSON only)
- POST /api/ingest/fires; ~15 min poll loop (FIRMS_INTERVAL=900) in ingester
- env-driven config (FIRMS_MAP_KEY/DATASET/BBOX/INTERVAL); docs/firms.md covers
the zero-cost GIBS VIIRS_SNPP_Thermal_Anomalies_375m_All tile alternative
- 18 tests (parser, mapping, idempotency, API contract) verified vs real
TimescaleDB+PostGIS (localhost/osint-dashboard-pg image)
API keys page (frontend, t_4433cff2):
- app/keystore.py: api_keys table (self-creating), FIRMS/GEMINI/TELEGRAM
registry with format validation, ****last4 masking, get_api_key()
- GET/POST/DELETE /api/keys (never returns full values); Keys tab in index.html
DB_NULL_POOL env switch in app/database.py enables a NullPool for tests /
short-lived processes that open a fresh event loop per unit.
2026-08-24 15:37:42 -04:00
|
|
|
|
are always included. Any extra stored keys are appended.
|
|
|
|
|
|
"""
|
|
|
|
|
|
return await list_keys()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/api/keys/{name}", status_code=200)
|
|
|
|
|
|
async def save_api_key(name: str, payload: KeyValueIn):
|
|
|
|
|
|
"""Save (upsert) an API key value.
|
|
|
|
|
|
|
|
|
|
|
|
Registered key formats are validated (e.g. FIRMS_MAP_KEY must be a
|
|
|
|
|
|
32-char hex string). Unregistered names are accepted as long as they
|
|
|
|
|
|
use UPPER_SNAKE_CASE. The raw value is stored in Postgres and never
|
|
|
|
|
|
returned by any API endpoint — only the masked status is exposed.
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
result = await set_key(name, payload.value)
|
|
|
|
|
|
except KeyFormatError as exc:
|
|
|
|
|
|
raise HTTPException(status_code=422, detail=str(exc))
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.delete("/api/keys/{name}")
|
|
|
|
|
|
async def remove_api_key(name: str):
|
|
|
|
|
|
"""Delete a stored API key (unsets it)."""
|
|
|
|
|
|
removed = await delete_key(name)
|
|
|
|
|
|
if not removed:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="Key not found")
|
|
|
|
|
|
return {"ok": True}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-27 23:07:01 -04:00
|
|
|
|
@app.get("/api/settings", response_model=SettingsOut)
|
|
|
|
|
|
async def get_settings():
|
|
|
|
|
|
"""Summarizer model + read-only Nous base URL."""
|
|
|
|
|
|
return await get_app_settings()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.put("/api/settings", response_model=SettingsOut)
|
|
|
|
|
|
async def put_settings(payload: SettingsIn):
|
|
|
|
|
|
"""Persist SUMMARY_MODEL. ``nous_base_url`` is ignored even if sent."""
|
|
|
|
|
|
try:
|
|
|
|
|
|
return await set_summary_model(payload.summary_model)
|
|
|
|
|
|
except SettingsError as exc:
|
|
|
|
|
|
raise HTTPException(status_code=422, detail=str(exc))
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-04 20:30:04 -04:00
|
|
|
|
# ── Ingestion Triggers ───────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/api/ingest/rss")
|
|
|
|
|
|
async def trigger_rss_ingest(feed_url: str, source_id: str | None = None):
|
|
|
|
|
|
"""Trigger RSS feed ingestion."""
|
|
|
|
|
|
count = await ingest_rss_feed(feed_url, source_id)
|
|
|
|
|
|
return {"status": "ok", "items_ingested": count}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/api/ingest/gdelt")
|
|
|
|
|
|
async def trigger_gdelt_ingest(query: str = "", max_articles: int = 50):
|
|
|
|
|
|
"""Trigger GDELT data ingestion."""
|
|
|
|
|
|
count = await ingest_gdelt(query, max_articles)
|
|
|
|
|
|
return {"status": "ok", "articles_ingested": count}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/api/ingest/earthquakes")
|
|
|
|
|
|
async def trigger_earthquake_ingest():
|
|
|
|
|
|
"""Trigger USGS earthquake ingestion."""
|
|
|
|
|
|
count = await ingest_earthquakes()
|
|
|
|
|
|
return {"status": "ok", "events_ingested": count}
|
|
|
|
|
|
|
|
|
|
|
|
|
Add NASA FIRMS active-fire ingest + /api/fires; API keys management page
Coherent merge of two coordinated features on the shared working tree:
FIRMS fire heatmap (backend, t_6e404c14):
- app/fire_sources.py: fetch FIRMS VIIRS area CSV (free MAP_KEY) -> NATS events.fire
- fires hypertable (TimescaleDB, 1-day chunks) with natural-key PK
(latitude, longitude, acq_time, satellite); idempotent ON CONFLICT DO NOTHING
- alembic/versions/002_fires.py; GET /api/fires?bbox=&since= (JSON only)
- POST /api/ingest/fires; ~15 min poll loop (FIRMS_INTERVAL=900) in ingester
- env-driven config (FIRMS_MAP_KEY/DATASET/BBOX/INTERVAL); docs/firms.md covers
the zero-cost GIBS VIIRS_SNPP_Thermal_Anomalies_375m_All tile alternative
- 18 tests (parser, mapping, idempotency, API contract) verified vs real
TimescaleDB+PostGIS (localhost/osint-dashboard-pg image)
API keys page (frontend, t_4433cff2):
- app/keystore.py: api_keys table (self-creating), FIRMS/GEMINI/TELEGRAM
registry with format validation, ****last4 masking, get_api_key()
- GET/POST/DELETE /api/keys (never returns full values); Keys tab in index.html
DB_NULL_POOL env switch in app/database.py enables a NullPool for tests /
short-lived processes that open a fresh event loop per unit.
2026-08-24 15:37:42 -04:00
|
|
|
|
@app.post("/api/ingest/fires")
|
|
|
|
|
|
async def trigger_fire_ingest(bbox: str | None = None):
|
|
|
|
|
|
"""Trigger a NASA FIRMS active-fire poll (uses FIRMS_BBOX if bbox omitted)."""
|
|
|
|
|
|
count = await ingest_fires(bbox)
|
|
|
|
|
|
return {"status": "ok", "hotspots_published": count}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-04 20:30:04 -04:00
|
|
|
|
@app.post("/api/ingest/social")
|
|
|
|
|
|
async def trigger_social_ingest(query: str = "", max_items: int = 50):
|
|
|
|
|
|
"""Trigger social signals ingestion."""
|
|
|
|
|
|
count = await ingest_social_signals(query, max_items)
|
|
|
|
|
|
return {"status": "ok", "signals_ingested": count}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-28 09:33:19 -04:00
|
|
|
|
@app.post("/api/ingest/masscan")
|
|
|
|
|
|
async def trigger_masscan(background_tasks: BackgroundTasks):
|
|
|
|
|
|
"""Queue one masscan pass at ≤200 pps. Does not block the request on the scan."""
|
|
|
|
|
|
from bg_jobs import MASSCAN_PPS_CAP, schedule_masscan_pass
|
|
|
|
|
|
|
|
|
|
|
|
async def _kick() -> None:
|
|
|
|
|
|
schedule_masscan_pass()
|
|
|
|
|
|
|
|
|
|
|
|
background_tasks.add_task(_kick)
|
|
|
|
|
|
return JSONResponse(
|
|
|
|
|
|
{"status": "accepted", "rate_pps": MASSCAN_PPS_CAP},
|
|
|
|
|
|
status_code=202,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.websocket("/ws/live")
|
|
|
|
|
|
async def live_ws(ws: WebSocket):
|
|
|
|
|
|
"""Viewport-filtered AIS/ADS-B fan-out. Client sends {type:viewport,bbox}."""
|
|
|
|
|
|
from ws_manager import manager
|
|
|
|
|
|
|
|
|
|
|
|
client_id = str(id(ws))
|
|
|
|
|
|
await ws.accept()
|
|
|
|
|
|
queue = manager.register(client_id)
|
|
|
|
|
|
|
|
|
|
|
|
async def _pump() -> None:
|
|
|
|
|
|
try:
|
|
|
|
|
|
while True:
|
|
|
|
|
|
msg = await queue.get()
|
|
|
|
|
|
await ws.send_json(msg)
|
|
|
|
|
|
except Exception: # noqa: BLE001
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
pump = asyncio.create_task(_pump())
|
|
|
|
|
|
try:
|
|
|
|
|
|
while True:
|
|
|
|
|
|
data = await ws.receive_json()
|
|
|
|
|
|
if not isinstance(data, dict):
|
|
|
|
|
|
continue
|
|
|
|
|
|
if data.get("type") == "viewport" and data.get("bbox"):
|
|
|
|
|
|
try:
|
|
|
|
|
|
manager.set_viewport(client_id, parse_bbox(str(data["bbox"])))
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
continue
|
|
|
|
|
|
except WebSocketDisconnect:
|
|
|
|
|
|
pass
|
|
|
|
|
|
finally:
|
|
|
|
|
|
pump.cancel()
|
|
|
|
|
|
manager.unregister(client_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-04 20:30:04 -04:00
|
|
|
|
@app.post("/api/ingest/process")
|
|
|
|
|
|
async def trigger_nats_processing(batch_size: int = 100):
|
|
|
|
|
|
"""Process pending NATS JetStream messages."""
|
|
|
|
|
|
count = await fetch_and_process(batch_size)
|
|
|
|
|
|
return {"status": "ok", "processed": count}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Analytics / Aggregation ───────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/api/analytics/summary", response_model=DashboardSummary)
|
|
|
|
|
|
async def get_dashboard_summary():
|
|
|
|
|
|
"""Dashboard overview: event counts, sentiment, top entities, alerts."""
|
|
|
|
|
|
async with async_session() as session:
|
|
|
|
|
|
now = datetime.now(timezone.utc)
|
|
|
|
|
|
yesterday = now - timedelta(hours=24)
|
|
|
|
|
|
|
|
|
|
|
|
# Total events
|
|
|
|
|
|
total = (await session.execute(
|
|
|
|
|
|
select(func.count()).select_from(events)
|
|
|
|
|
|
)).scalar() or 0
|
|
|
|
|
|
|
|
|
|
|
|
# Events in last 24h
|
|
|
|
|
|
events_24h = (await session.execute(
|
|
|
|
|
|
select(func.count()).where(events.c.ingested_at >= yesterday)
|
|
|
|
|
|
)).scalar() or 0
|
|
|
|
|
|
|
|
|
|
|
|
# Active sources
|
|
|
|
|
|
active = (await session.execute(
|
|
|
|
|
|
select(func.count()).where(feed_sources.c.enabled == 1)
|
|
|
|
|
|
)).scalar() or 0
|
|
|
|
|
|
|
|
|
|
|
|
# Open alerts
|
|
|
|
|
|
open_alerts = (await session.execute(
|
|
|
|
|
|
select(func.count()).where(alerts.c.acknowledged == 0)
|
|
|
|
|
|
)).scalar() or 0
|
|
|
|
|
|
|
|
|
|
|
|
# Tracked entities
|
|
|
|
|
|
ent_count = (await session.execute(
|
|
|
|
|
|
select(func.count()).select_from(entities)
|
|
|
|
|
|
)).scalar() or 0
|
|
|
|
|
|
|
|
|
|
|
|
# Sentiment breakdown (last 24h)
|
|
|
|
|
|
def sentiment_query():
|
|
|
|
|
|
return select(
|
2026-07-07 18:32:02 -04:00
|
|
|
|
func.count().filter(events.c.sentiment_label == "positive").label("pos"),
|
|
|
|
|
|
func.count().filter(events.c.sentiment_label == "neutral").label("neu"),
|
|
|
|
|
|
func.count().filter(events.c.sentiment_label == "negative").label("neg"),
|
2026-06-04 20:30:04 -04:00
|
|
|
|
func.avg(events.c.sentiment_score).label("avg"),
|
|
|
|
|
|
).where(events.c.ingested_at >= yesterday)
|
|
|
|
|
|
|
|
|
|
|
|
sent_row = (await session.execute(sentiment_query())).mappings().one()
|
|
|
|
|
|
sentiment = SentimentSummary(
|
|
|
|
|
|
period="24h",
|
|
|
|
|
|
positive_count=sent_row["pos"] or 0,
|
|
|
|
|
|
neutral_count=sent_row["neu"] or 0,
|
|
|
|
|
|
negative_count=sent_row["neg"] or 0,
|
|
|
|
|
|
avg_score=float(sent_row["avg"] or 0),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# Top entities by event count
|
|
|
|
|
|
top_ent = (await session.execute(
|
|
|
|
|
|
select(entities).order_by(entities.c.event_count.desc()).limit(10)
|
|
|
|
|
|
)).mappings().all()
|
|
|
|
|
|
|
|
|
|
|
|
return DashboardSummary(
|
|
|
|
|
|
total_events=total,
|
|
|
|
|
|
events_last_24h=events_24h,
|
|
|
|
|
|
active_sources=active,
|
|
|
|
|
|
open_alerts=open_alerts,
|
|
|
|
|
|
tracked_entities=ent_count,
|
|
|
|
|
|
sentiment=sentiment,
|
|
|
|
|
|
top_entities=[entity_to_out(r) for r in top_ent],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/api/analytics/timeline")
|
|
|
|
|
|
async def get_timeline(
|
|
|
|
|
|
hours: int = Query(24, ge=1, le=168),
|
|
|
|
|
|
bucket_hours: int = Query(1, ge=1, le=24),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""Event timeline: counts and avg sentiment per time bucket."""
|
|
|
|
|
|
async with async_session() as session:
|
|
|
|
|
|
cutoff = datetime.now(timezone.utc) - timedelta(hours=hours)
|
2026-08-28 21:49:05 -04:00
|
|
|
|
bucket_s = bucket_hours * 3600
|
|
|
|
|
|
buckets = await session.execute(text("""
|
2026-06-04 20:30:04 -04:00
|
|
|
|
SELECT
|
2026-08-28 21:49:05 -04:00
|
|
|
|
to_timestamp(
|
|
|
|
|
|
floor(extract(epoch FROM source_timestamp) / :bucket_s) * :bucket_s
|
|
|
|
|
|
) AT TIME ZONE 'UTC' AS ts,
|
2026-06-04 20:30:04 -04:00
|
|
|
|
COUNT(*) AS event_count,
|
|
|
|
|
|
COALESCE(AVG(sentiment_score), 0) AS avg_sentiment
|
|
|
|
|
|
FROM events
|
|
|
|
|
|
WHERE source_timestamp >= :cutoff
|
|
|
|
|
|
GROUP BY ts
|
|
|
|
|
|
ORDER BY ts
|
2026-08-28 21:49:05 -04:00
|
|
|
|
"""), {"cutoff": cutoff, "bucket_s": bucket_s})
|
2026-06-04 20:30:04 -04:00
|
|
|
|
rows = buckets.mappings().all()
|
|
|
|
|
|
|
|
|
|
|
|
return [TimelinePoint(timestamp=r["ts"], event_count=r["event_count"],
|
|
|
|
|
|
avg_sentiment=float(r["avg_sentiment"])) for r in rows]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/api/analytics/sentiment/by-source")
|
|
|
|
|
|
async def sentiment_by_source(hours: int = 24):
|
|
|
|
|
|
"""Sentiment breakdown grouped by source type."""
|
|
|
|
|
|
async with async_session() as session:
|
|
|
|
|
|
cutoff = datetime.now(timezone.utc) - timedelta(hours=hours)
|
|
|
|
|
|
result = await session.execute(text(f"""
|
|
|
|
|
|
SELECT
|
|
|
|
|
|
source_type,
|
|
|
|
|
|
COUNT(*) AS total,
|
|
|
|
|
|
COUNT(*) FILTER (WHERE sentiment_label = 'positive') AS positive,
|
|
|
|
|
|
COUNT(*) FILTER (WHERE sentiment_label = 'neutral') AS neutral,
|
|
|
|
|
|
COUNT(*) FILTER (WHERE sentiment_label = 'negative') AS negative,
|
|
|
|
|
|
COALESCE(AVG(sentiment_score), 0) AS avg_score
|
|
|
|
|
|
FROM events
|
|
|
|
|
|
WHERE ingested_at >= :cutoff
|
|
|
|
|
|
GROUP BY source_type
|
|
|
|
|
|
ORDER BY total DESC
|
|
|
|
|
|
"""), {"cutoff": cutoff})
|
|
|
|
|
|
rows = result.mappings().all()
|
|
|
|
|
|
return [{
|
|
|
|
|
|
"source_type": r["source_type"],
|
|
|
|
|
|
"total": r["total"],
|
|
|
|
|
|
"positive": r["positive"],
|
|
|
|
|
|
"neutral": r["neutral"],
|
|
|
|
|
|
"negative": r["negative"],
|
|
|
|
|
|
"avg_score": float(r["avg_score"]),
|
|
|
|
|
|
} for r in rows]
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-24 14:54:51 -04:00
|
|
|
|
# ── Cameras (open-camera discovery map) ───────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/api/cameras")
|
|
|
|
|
|
async def list_cameras(
|
|
|
|
|
|
bbox: str | None = Query(
|
|
|
|
|
|
None,
|
|
|
|
|
|
description="Bounding box 'min_lon,min_lat,max_lon,max_lat'",
|
|
|
|
|
|
),
|
|
|
|
|
|
source: str | None = Query(None, description="Filter by discovery_source"),
|
2026-08-25 22:05:15 -04:00
|
|
|
|
working: bool = Query(
|
|
|
|
|
|
True,
|
|
|
|
|
|
description="Only cameras with a verified HTTP/MJPEG snapshot_url "
|
|
|
|
|
|
"(the ones that actually preview). Set false to include "
|
|
|
|
|
|
"unverified masscan port-554 hits.",
|
|
|
|
|
|
),
|
2026-08-24 14:54:51 -04:00
|
|
|
|
limit: int = Query(500, ge=1, le=5000),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""Cameras for map display, optionally filtered by geographic bbox."""
|
|
|
|
|
|
from camera_models import cameras as cam_table
|
|
|
|
|
|
|
|
|
|
|
|
async with async_session() as session:
|
|
|
|
|
|
stmt = select(cam_table).order_by(cam_table.c.last_seen.desc())
|
2026-08-25 22:05:15 -04:00
|
|
|
|
if working:
|
|
|
|
|
|
stmt = stmt.where(
|
|
|
|
|
|
cam_table.c.snapshot_url.isnot(None),
|
|
|
|
|
|
or_(
|
|
|
|
|
|
cam_table.c.snapshot_url.startswith("http://"),
|
|
|
|
|
|
cam_table.c.snapshot_url.startswith("https://"),
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
2026-08-24 14:54:51 -04:00
|
|
|
|
if bbox:
|
|
|
|
|
|
try:
|
|
|
|
|
|
min_lon, min_lat, max_lon, max_lat = (
|
|
|
|
|
|
float(v) for v in bbox.split(",")
|
|
|
|
|
|
)
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
422, "bbox must be 'min_lon,min_lat,max_lon,max_lat'"
|
|
|
|
|
|
)
|
|
|
|
|
|
if not (-180 <= min_lon <= 180 and -180 <= max_lon <= 180
|
|
|
|
|
|
and -90 <= min_lat <= 90 and -90 <= max_lat <= 90):
|
|
|
|
|
|
raise HTTPException(422, "bbox coordinates out of range")
|
2026-08-27 15:59:22 -04:00
|
|
|
|
# Inverted/empty box (wrapped world view) → do not filter.
|
|
|
|
|
|
if min_lon < max_lon and min_lat < max_lat:
|
|
|
|
|
|
stmt = stmt.where(
|
|
|
|
|
|
and_(cam_table.c.location_lat >= min_lat,
|
|
|
|
|
|
cam_table.c.location_lat <= max_lat,
|
|
|
|
|
|
cam_table.c.location_lon >= min_lon,
|
|
|
|
|
|
cam_table.c.location_lon <= max_lon))
|
2026-08-24 14:54:51 -04:00
|
|
|
|
if source:
|
|
|
|
|
|
stmt = stmt.where(cam_table.c.discovery_source == source)
|
|
|
|
|
|
rows = (await session.execute(stmt.limit(limit))).mappings().all()
|
|
|
|
|
|
|
2026-08-27 21:19:42 -04:00
|
|
|
|
return [camera_map_row(r) for r in rows]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def camera_detail_row(r) -> dict:
|
|
|
|
|
|
return {
|
2026-08-24 14:54:51 -04:00
|
|
|
|
"id": str(r["id"]),
|
|
|
|
|
|
"source_url": r["source_url"],
|
|
|
|
|
|
"snapshot_url": r["snapshot_url"],
|
|
|
|
|
|
"discovery_source": r["discovery_source"],
|
|
|
|
|
|
"lat": r["location_lat"],
|
|
|
|
|
|
"lon": r["location_lon"],
|
|
|
|
|
|
"location_name": r["location_name"],
|
|
|
|
|
|
"vendor": r["vendor"],
|
|
|
|
|
|
"device_type": r["device_type"],
|
2026-08-27 21:19:42 -04:00
|
|
|
|
"first_seen": r["first_seen"].isoformat() if r["first_seen"] else None,
|
|
|
|
|
|
"last_seen": r["last_seen"].isoformat() if r["last_seen"] else None,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/api/cameras/{camera_id}")
|
|
|
|
|
|
async def get_camera(camera_id: UUID):
|
|
|
|
|
|
"""Full camera row for a map popup. List endpoint stays slim."""
|
|
|
|
|
|
from camera_models import cameras as cam_table
|
|
|
|
|
|
|
|
|
|
|
|
async with async_session() as session:
|
|
|
|
|
|
row = (await session.execute(
|
|
|
|
|
|
select(cam_table).where(cam_table.c.id == camera_id)
|
|
|
|
|
|
)).mappings().one_or_none()
|
|
|
|
|
|
if not row:
|
|
|
|
|
|
raise HTTPException(404, "Camera not found")
|
|
|
|
|
|
return camera_detail_row(row)
|
2026-08-24 14:54:51 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/api/cameras/{camera_id}/snapshot")
|
|
|
|
|
|
async def camera_snapshot(camera_id: UUID):
|
2026-08-24 23:36:34 -04:00
|
|
|
|
"""Still image for one camera.
|
|
|
|
|
|
|
|
|
|
|
|
HTTP cameras go through the TTL cache. masscan/RTSP finds have no HTTP
|
|
|
|
|
|
snapshot_url — we probe common still-image paths and, failing that, grab
|
|
|
|
|
|
one JPEG frame from RTSP via ffmpeg. No credentials are tried.
|
|
|
|
|
|
"""
|
2026-08-24 14:54:51 -04:00
|
|
|
|
from camera_models import cameras as cam_table
|
2026-08-24 23:36:34 -04:00
|
|
|
|
from camera_preview import resolve_preview
|
|
|
|
|
|
from fastapi.responses import Response
|
2026-08-24 14:54:51 -04:00
|
|
|
|
|
|
|
|
|
|
async with async_session() as session:
|
|
|
|
|
|
row = (await session.execute(
|
|
|
|
|
|
select(cam_table).where(cam_table.c.id == camera_id)
|
|
|
|
|
|
)).mappings().one_or_none()
|
2026-08-24 23:36:34 -04:00
|
|
|
|
if not row:
|
|
|
|
|
|
raise HTTPException(404, "Camera not found")
|
|
|
|
|
|
try:
|
|
|
|
|
|
data, _url = await asyncio.wait_for(resolve_preview(row), timeout=10)
|
|
|
|
|
|
except asyncio.TimeoutError:
|
|
|
|
|
|
raise HTTPException(502, "Snapshot unavailable")
|
2026-08-24 14:54:51 -04:00
|
|
|
|
if not data:
|
|
|
|
|
|
raise HTTPException(502, "Snapshot unavailable")
|
|
|
|
|
|
return Response(content=data, media_type="image/jpeg")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-24 21:38:46 -04:00
|
|
|
|
@app.get("/api/cameras/{camera_id}/stream")
|
|
|
|
|
|
async def camera_stream(camera_id: UUID):
|
|
|
|
|
|
"""Live MJPEG passthrough for one camera.
|
|
|
|
|
|
|
|
|
|
|
|
Browsers render multipart/x-mixed-replace responses natively inside an
|
|
|
|
|
|
<img> tag, so proxying the camera's own MJPEG stream through here gives a
|
|
|
|
|
|
true live preview in the map popup (no player, no JS). Single-frame JPEG
|
|
|
|
|
|
endpoints also work — they render as a static image.
|
|
|
|
|
|
"""
|
|
|
|
|
|
from camera_models import cameras as cam_table
|
|
|
|
|
|
from fastapi.responses import StreamingResponse
|
|
|
|
|
|
import httpx
|
|
|
|
|
|
|
|
|
|
|
|
async with async_session() as session:
|
2026-08-24 23:36:34 -04:00
|
|
|
|
row = (await session.execute(
|
|
|
|
|
|
select(cam_table).where(cam_table.c.id == camera_id)
|
|
|
|
|
|
)).mappings().one_or_none()
|
|
|
|
|
|
if not row:
|
|
|
|
|
|
raise HTTPException(404, "Camera not found")
|
|
|
|
|
|
url = row["snapshot_url"] or row["source_url"]
|
2026-08-27 15:54:54 -04:00
|
|
|
|
low = str(url or "").lower()
|
|
|
|
|
|
if low.startswith("rtsp://") or ".m3u8" in low or row.get("device_type") == "hls":
|
2026-08-24 23:36:34 -04:00
|
|
|
|
from camera_preview import ffmpeg_mjpeg_stream, _FFMPEG
|
|
|
|
|
|
if not _FFMPEG:
|
2026-08-27 15:54:54 -04:00
|
|
|
|
raise HTTPException(502, "Live preview requires ffmpeg")
|
|
|
|
|
|
if not low.startswith("rtsp://") and not low.startswith(("http://", "https://")):
|
|
|
|
|
|
raise HTTPException(404, "Camera or snapshot not found")
|
2026-08-24 23:36:34 -04:00
|
|
|
|
return StreamingResponse(
|
|
|
|
|
|
ffmpeg_mjpeg_stream(url),
|
|
|
|
|
|
media_type="multipart/x-mixed-replace; boundary=ffmpeg",
|
|
|
|
|
|
)
|
|
|
|
|
|
if not str(url or "").lower().startswith(("http://", "https://")):
|
2026-08-24 21:38:46 -04:00
|
|
|
|
raise HTTPException(404, "Camera or snapshot not found")
|
|
|
|
|
|
|
|
|
|
|
|
# connect timeout short so dead cams fail fast; read timeout None because
|
|
|
|
|
|
# an MJPEG stream legitimately idles between frames.
|
|
|
|
|
|
client = httpx.AsyncClient(
|
|
|
|
|
|
timeout=httpx.Timeout(5.0, read=None), follow_redirects=True,
|
|
|
|
|
|
headers={"User-Agent": "osint-dashboard-camera-view/1.0"},
|
|
|
|
|
|
)
|
|
|
|
|
|
try:
|
|
|
|
|
|
req = client.build_request("GET", url)
|
|
|
|
|
|
resp = await client.send(req, stream=True)
|
|
|
|
|
|
if resp.status_code != 200 or len(resp.headers.get("content-type", "")) == 0:
|
|
|
|
|
|
await resp.aclose()
|
|
|
|
|
|
await client.aclose()
|
|
|
|
|
|
raise HTTPException(502, "Stream unavailable")
|
|
|
|
|
|
except HTTPException:
|
|
|
|
|
|
raise
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
await client.aclose()
|
|
|
|
|
|
raise HTTPException(502, "Stream unavailable")
|
|
|
|
|
|
|
|
|
|
|
|
async def gen():
|
|
|
|
|
|
try:
|
|
|
|
|
|
async for chunk in resp.aiter_bytes():
|
|
|
|
|
|
yield chunk
|
|
|
|
|
|
finally:
|
|
|
|
|
|
await resp.aclose()
|
|
|
|
|
|
await client.aclose()
|
|
|
|
|
|
|
|
|
|
|
|
ctype = resp.headers.get("content-type", "")
|
|
|
|
|
|
media = ctype if "multipart" in ctype.lower() else (
|
|
|
|
|
|
ctype if "image/" in ctype.lower()
|
|
|
|
|
|
else "multipart/x-mixed-replace; boundary=frame"
|
|
|
|
|
|
)
|
|
|
|
|
|
return StreamingResponse(gen(), media_type=media)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-27 15:54:54 -04:00
|
|
|
|
@app.get("/api/cameras/{camera_id}/hls.m3u8")
|
|
|
|
|
|
async def camera_hls_playlist(camera_id: UUID):
|
|
|
|
|
|
"""CORS-safe rewritten HLS playlist for the in-page player."""
|
|
|
|
|
|
from camera_hls import fetch_playlist
|
|
|
|
|
|
from camera_models import cameras as cam_table
|
|
|
|
|
|
|
|
|
|
|
|
async with async_session() as session:
|
|
|
|
|
|
row = (await session.execute(
|
|
|
|
|
|
select(cam_table).where(cam_table.c.id == camera_id)
|
|
|
|
|
|
)).mappings().one_or_none()
|
|
|
|
|
|
if not row:
|
|
|
|
|
|
raise HTTPException(404, "Camera not found")
|
|
|
|
|
|
url = row["snapshot_url"] or row["source_url"] or ""
|
|
|
|
|
|
if ".m3u8" not in url.lower() and row.get("device_type") != "hls":
|
|
|
|
|
|
raise HTTPException(404, "Camera is not an HLS feed")
|
|
|
|
|
|
return await fetch_playlist(str(camera_id), url)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/api/cameras/{camera_id}/hlsseg")
|
|
|
|
|
|
async def camera_hls_segment(camera_id: UUID, u: str = Query(..., min_length=8)):
|
|
|
|
|
|
"""Proxy one HLS segment/playlist URI rewritten by hls.m3u8."""
|
|
|
|
|
|
from camera_hls import fetch_segment
|
|
|
|
|
|
from camera_models import cameras as cam_table
|
|
|
|
|
|
|
|
|
|
|
|
async with async_session() as session:
|
|
|
|
|
|
exists = (await session.execute(
|
|
|
|
|
|
select(cam_table.c.id).where(cam_table.c.id == camera_id)
|
|
|
|
|
|
)).scalar_one_or_none()
|
|
|
|
|
|
if not exists:
|
|
|
|
|
|
raise HTTPException(404, "Camera not found")
|
|
|
|
|
|
return await fetch_segment(str(camera_id), u)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-24 17:28:46 -04:00
|
|
|
|
# ── News pipeline (scraper + summarizer) ──────────────────────────────────
|
|
|
|
|
|
# Backing data for the frontend news panel. Written by the vendored
|
2026-08-28 20:53:32 -04:00
|
|
|
|
# news-scraper (continuous Scrapy crawl) and news-summarizer (15-min Nous
|
2026-08-24 17:28:46 -04:00
|
|
|
|
# map-reduce) services into the shared osint-db.
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/api/news", response_model=list[NewsArticleOut])
|
|
|
|
|
|
async def list_news(
|
|
|
|
|
|
domain: str | None = Query(
|
|
|
|
|
|
None, description="Filter by source domain (e.g. 'www.reuters.com')"
|
|
|
|
|
|
),
|
|
|
|
|
|
since: datetime | None = Query(
|
|
|
|
|
|
None,
|
|
|
|
|
|
description="Only articles captured at/after this UTC instant "
|
|
|
|
|
|
"(ISO 8601, e.g. '2026-08-24T12:00:00Z').",
|
|
|
|
|
|
),
|
|
|
|
|
|
limit: int = Query(50, ge=1, le=500),
|
|
|
|
|
|
offset: int = Query(0, ge=0),
|
2026-08-27 21:19:42 -04:00
|
|
|
|
include_content: bool = Query(
|
|
|
|
|
|
False,
|
|
|
|
|
|
description="Include full article body. Default false — ticker/list only need title/url.",
|
|
|
|
|
|
),
|
2026-08-24 17:28:46 -04:00
|
|
|
|
):
|
|
|
|
|
|
"""Most recent scraped news articles (newest first)."""
|
|
|
|
|
|
async with async_session() as session:
|
|
|
|
|
|
stmt = select(articles).order_by(
|
|
|
|
|
|
articles.c.timestamp.desc().nullslast()
|
|
|
|
|
|
)
|
|
|
|
|
|
if domain:
|
|
|
|
|
|
stmt = stmt.where(articles.c.domain == domain)
|
|
|
|
|
|
if since:
|
|
|
|
|
|
stmt = stmt.where(articles.c.timestamp >= since)
|
|
|
|
|
|
stmt = stmt.limit(limit).offset(offset)
|
|
|
|
|
|
rows = (await session.execute(stmt)).mappings().all()
|
|
|
|
|
|
return [
|
|
|
|
|
|
NewsArticleOut(
|
|
|
|
|
|
id=r["id"], title=r["title"], url=r["url"],
|
2026-08-27 21:19:42 -04:00
|
|
|
|
content=r["content"] if include_content else None, domain=r["domain"],
|
2026-08-24 17:28:46 -04:00
|
|
|
|
timestamp=r["timestamp"],
|
|
|
|
|
|
)
|
|
|
|
|
|
for r in rows
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/api/news/summaries", response_model=list[NewsSummaryOut])
|
|
|
|
|
|
async def list_news_summaries(
|
|
|
|
|
|
since: datetime | None = Query(
|
|
|
|
|
|
None,
|
|
|
|
|
|
description="Only summaries generated at/after this UTC instant.",
|
|
|
|
|
|
),
|
2026-08-28 22:53:31 -04:00
|
|
|
|
kind: str | None = Query(
|
|
|
|
|
|
None,
|
|
|
|
|
|
description="Filter: interval or daily_recap. Omit for all.",
|
|
|
|
|
|
),
|
2026-08-24 17:28:46 -04:00
|
|
|
|
limit: int = Query(20, ge=1, le=100),
|
|
|
|
|
|
offset: int = Query(0, ge=0),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""Most recent master LLM summaries (newest first)."""
|
2026-08-28 22:53:31 -04:00
|
|
|
|
if kind is not None and kind not in ("interval", "daily_recap"):
|
|
|
|
|
|
raise HTTPException(422, "kind must be interval or daily_recap")
|
2026-08-24 17:28:46 -04:00
|
|
|
|
async with async_session() as session:
|
|
|
|
|
|
stmt = select(article_summaries).order_by(
|
|
|
|
|
|
article_summaries.c.batch_timestamp.desc().nullslast()
|
|
|
|
|
|
)
|
|
|
|
|
|
if since:
|
|
|
|
|
|
stmt = stmt.where(article_summaries.c.batch_timestamp >= since)
|
2026-08-28 22:53:31 -04:00
|
|
|
|
if kind is not None:
|
|
|
|
|
|
stmt = stmt.where(article_summaries.c.kind == kind)
|
2026-08-24 17:28:46 -04:00
|
|
|
|
stmt = stmt.limit(limit).offset(offset)
|
|
|
|
|
|
rows = (await session.execute(stmt)).mappings().all()
|
|
|
|
|
|
return [
|
|
|
|
|
|
NewsSummaryOut(
|
|
|
|
|
|
id=r["id"], summary_text=r["summary_text"],
|
2026-08-27 22:49:59 -04:00
|
|
|
|
batch_timestamp=r["batch_timestamp"], model=r["model"],
|
2026-08-28 22:53:31 -04:00
|
|
|
|
kind=r["kind"],
|
2026-08-27 22:49:59 -04:00
|
|
|
|
)
|
|
|
|
|
|
for r in rows
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_FLAGGED = ("critical", "high")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/api/news/ticker", response_model=list[NewsTickerItemOut])
|
|
|
|
|
|
async def list_news_ticker(
|
|
|
|
|
|
since: datetime | None = Query(
|
|
|
|
|
|
None,
|
|
|
|
|
|
description="Only ticker items created at/after this UTC instant.",
|
|
|
|
|
|
),
|
|
|
|
|
|
limit: int = Query(20, ge=1, le=50),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""Flagged ticker rows (critical/high), newest first. No LLM required."""
|
|
|
|
|
|
async with async_session() as session:
|
|
|
|
|
|
stmt = (
|
|
|
|
|
|
select(news_items)
|
|
|
|
|
|
.where(
|
|
|
|
|
|
news_items.c.kind == "ticker",
|
|
|
|
|
|
news_items.c.importance.in_(_FLAGGED),
|
|
|
|
|
|
)
|
|
|
|
|
|
.order_by(news_items.c.created_at.desc())
|
|
|
|
|
|
)
|
|
|
|
|
|
if since:
|
|
|
|
|
|
stmt = stmt.where(news_items.c.created_at >= since)
|
|
|
|
|
|
stmt = stmt.limit(limit)
|
|
|
|
|
|
rows = (await session.execute(stmt)).mappings().all()
|
|
|
|
|
|
return [
|
|
|
|
|
|
NewsTickerItemOut(
|
|
|
|
|
|
id=r["id"], headline=r["headline"], importance=r["importance"],
|
|
|
|
|
|
location_name=r["location_name"], url=r["url"],
|
|
|
|
|
|
created_at=r["created_at"],
|
|
|
|
|
|
)
|
|
|
|
|
|
for r in rows
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/api/news/map", response_model=list[NewsMapItemOut])
|
|
|
|
|
|
async def list_news_map(
|
|
|
|
|
|
bbox: str | None = Query(
|
|
|
|
|
|
None,
|
|
|
|
|
|
description="Comma-separated 'minlon,minlat,maxlon,maxlat' to bound the "
|
|
|
|
|
|
"result set by item coordinates. Omit for all flagged pins.",
|
|
|
|
|
|
),
|
|
|
|
|
|
since: datetime | None = Query(
|
|
|
|
|
|
None,
|
|
|
|
|
|
description="Only map items created at/after this UTC instant. "
|
|
|
|
|
|
"Defaults to the last 24 hours.",
|
|
|
|
|
|
),
|
|
|
|
|
|
limit: int = Query(200, ge=1, le=500),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""Flagged map pins (critical/high with coords). No zoom skip — world view."""
|
|
|
|
|
|
if since is None:
|
|
|
|
|
|
since = datetime.now(timezone.utc) - timedelta(hours=24)
|
|
|
|
|
|
async with async_session() as session:
|
|
|
|
|
|
stmt = (
|
|
|
|
|
|
select(news_items)
|
|
|
|
|
|
.where(
|
|
|
|
|
|
news_items.c.kind == "map",
|
|
|
|
|
|
news_items.c.lat.isnot(None),
|
|
|
|
|
|
news_items.c.lon.isnot(None),
|
|
|
|
|
|
news_items.c.importance.in_(_FLAGGED),
|
|
|
|
|
|
news_items.c.created_at >= since,
|
|
|
|
|
|
)
|
|
|
|
|
|
.order_by(news_items.c.created_at.desc())
|
|
|
|
|
|
)
|
|
|
|
|
|
if bbox:
|
|
|
|
|
|
parts = [p.strip() for p in bbox.split(",")]
|
|
|
|
|
|
if len(parts) != 4:
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
422, "bbox must be 'minlon,minlat,maxlon,maxlat' (4 comma-separated values)"
|
|
|
|
|
|
)
|
|
|
|
|
|
try:
|
|
|
|
|
|
minlon, minlat, maxlon, maxlat = (float(p) for p in parts)
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
422, "bbox values must be floats: 'minlon,minlat,maxlon,maxlat'"
|
|
|
|
|
|
)
|
|
|
|
|
|
stmt = stmt.where(
|
|
|
|
|
|
and_(
|
|
|
|
|
|
news_items.c.lon >= minlon, news_items.c.lon <= maxlon,
|
|
|
|
|
|
news_items.c.lat >= minlat, news_items.c.lat <= maxlat,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
stmt = stmt.limit(limit)
|
|
|
|
|
|
rows = (await session.execute(stmt)).mappings().all()
|
|
|
|
|
|
return [
|
|
|
|
|
|
NewsMapItemOut(
|
|
|
|
|
|
id=r["id"], headline=r["headline"], importance=r["importance"],
|
|
|
|
|
|
location_name=r["location_name"], lat=r["lat"], lon=r["lon"],
|
|
|
|
|
|
location_confidence=r["location_confidence"], category=r["category"],
|
|
|
|
|
|
url=r["url"], created_at=r["created_at"],
|
2026-08-24 17:28:46 -04:00
|
|
|
|
)
|
|
|
|
|
|
for r in rows
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-27 23:07:01 -04:00
|
|
|
|
@app.get("/api/news/models", response_model=NewsModelsOut)
|
|
|
|
|
|
async def list_news_models():
|
|
|
|
|
|
"""Nous model catalog for the summarizer selector. Never 502s."""
|
|
|
|
|
|
return await list_models()
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-04 20:30:04 -04:00
|
|
|
|
# ── Frontend ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/", response_class=HTMLResponse)
|
|
|
|
|
|
async def index():
|
2026-08-27 17:00:09 -04:00
|
|
|
|
# Never let browsers serve a stale copy of the app shell: no Cache-Control
|
|
|
|
|
|
# means heuristic caching, and a stale index.html froze older bugs in users'
|
|
|
|
|
|
# tabs after deploys. Revalidate every load (ETag still returns 304).
|
|
|
|
|
|
resp = FileResponse(str(STATIC_DIR / "index.html"))
|
|
|
|
|
|
resp.headers["Cache-Control"] = "no-cache"
|
|
|
|
|
|
return resp
|
2026-06-04 20:30:04 -04:00
|
|
|
|
|
|
|
|
|
|
|
2026-08-24 17:35:44 -04:00
|
|
|
|
# ── NASA GIBS basemap map tab ─────────────────────────────────────────────
|
|
|
|
|
|
|
feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.
- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00
|
|
|
|
def _parse_bbox_query(bbox: str) -> tuple[float, float, float, float]:
|
|
|
|
|
|
try:
|
|
|
|
|
|
return parse_bbox(bbox)
|
|
|
|
|
|
except ValueError as exc:
|
|
|
|
|
|
raise HTTPException(422, str(exc)) from exc
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-24 17:35:44 -04:00
|
|
|
|
@app.get("/api/map/layers")
|
|
|
|
|
|
async def map_layers():
|
|
|
|
|
|
"""Curated NASA GIBS raster basemap layers for the map tab.
|
|
|
|
|
|
|
|
|
|
|
|
Each entry has everything the browser needs to render the WMTS tiles:
|
|
|
|
|
|
id — GIBS layer identifier (used in the tile URL path)
|
|
|
|
|
|
title — human-readable display name
|
|
|
|
|
|
tms — GIBS tile matrix set (GoogleMapsCompatible_LevelN)
|
|
|
|
|
|
format — tile image extension (jpeg|png)
|
|
|
|
|
|
has_time — whether the layer has a Time dimension (=> date selector)
|
|
|
|
|
|
max_zoom — highest native zoom served by that tile matrix set
|
feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.
- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00
|
|
|
|
|
|
|
|
|
|
``overlays`` lists toggleable live feeds (radar tiles, aircraft, …).
|
2026-08-24 17:35:44 -04:00
|
|
|
|
"""
|
|
|
|
|
|
from gibs_map import MAP_LAYERS
|
feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.
- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00
|
|
|
|
return {"layers": MAP_LAYERS, "overlays": overlay_catalog()}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-28 22:58:04 -04:00
|
|
|
|
def _upstream_or_502(exc: Exception, name: str) -> NoReturn:
|
feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.
- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00
|
|
|
|
logger.warning("live_layer_upstream_failed", layer=name, error=str(exc))
|
|
|
|
|
|
raise HTTPException(502, f"{name} upstream unavailable: {exc}") from exc
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/api/map/radar")
|
|
|
|
|
|
async def map_radar():
|
|
|
|
|
|
"""RainViewer frame list + IEM NEXRAD tile template. Browser fetches tiles."""
|
|
|
|
|
|
try:
|
2026-08-27 21:21:19 -04:00
|
|
|
|
return overlay_json(await fetch_radar_meta(), 60)
|
feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.
- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
_upstream_or_502(exc, "radar")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-29 01:01:09 -04:00
|
|
|
|
@app.get("/api/map/sentinel1")
|
|
|
|
|
|
async def map_sentinel1(bbox: str = Query(..., description="minlon,minlat,maxlon,maxlat")):
|
|
|
|
|
|
"""Most recent Sentinel-1 GRD as a signed COG tile template (TiTiler).
|
|
|
|
|
|
|
|
|
|
|
|
Queries Planetary Computer only on demand; no tiles proxied through the Pi.
|
|
|
|
|
|
"""
|
|
|
|
|
|
_parse_bbox_query(bbox)
|
|
|
|
|
|
try:
|
|
|
|
|
|
result = await fetch_sentinel1(bbox)
|
|
|
|
|
|
except UpstreamRateLimited as exc:
|
|
|
|
|
|
headers = {"Retry-After": exc.retry_after} if exc.retry_after else None
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
429, "Planetary Computer rate limit", headers=headers,
|
|
|
|
|
|
) from exc
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
_upstream_or_502(exc, "sentinel1")
|
|
|
|
|
|
if result is None:
|
|
|
|
|
|
return JSONResponse(
|
|
|
|
|
|
status_code=404,
|
|
|
|
|
|
content={
|
|
|
|
|
|
"error": "no_imagery",
|
|
|
|
|
|
"message": "No Sentinel-1 GRD in the last 7 days for this bbox",
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
return overlay_json(result, 300)
|
|
|
|
|
|
|
|
|
|
|
|
|
feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.
- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00
|
|
|
|
@app.get("/api/aircraft")
|
|
|
|
|
|
async def list_aircraft(
|
|
|
|
|
|
bbox: str = Query(..., description="minlon,minlat,maxlon,maxlat"),
|
|
|
|
|
|
limit: int = Query(2000, ge=1, le=5000),
|
2026-08-28 09:33:19 -04:00
|
|
|
|
timestamp: str | None = Query(None, description="ISO time — DVR 1-min tracks instead of live"),
|
feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.
- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00
|
|
|
|
):
|
|
|
|
|
|
"""Viewport ADS-B last-known (ADSB.lol). Requires bbox; radius clamped ≤ 150 nm."""
|
|
|
|
|
|
_parse_bbox_query(bbox)
|
|
|
|
|
|
try:
|
2026-08-28 09:33:19 -04:00
|
|
|
|
from tracks import fetch_positions_at, parse_timestamp
|
|
|
|
|
|
ts = parse_timestamp(timestamp)
|
|
|
|
|
|
if ts is not None:
|
|
|
|
|
|
return overlay_json(await fetch_positions_at("aircraft", ts, bbox, limit), 5)
|
2026-08-28 21:49:05 -04:00
|
|
|
|
return overlay_json(await fetch_aircraft(bbox, limit, persist=False), 5)
|
feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.
- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00
|
|
|
|
except ValueError as exc:
|
|
|
|
|
|
raise HTTPException(422, str(exc)) from exc
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
_upstream_or_502(exc, "aircraft")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-28 22:58:04 -04:00
|
|
|
|
@app.get("/api/aircraft/photo")
|
|
|
|
|
|
async def aircraft_photo(
|
|
|
|
|
|
hex_code: str | None = Query(None, alias="hex", pattern="^[0-9a-fA-F]{6}$"),
|
|
|
|
|
|
reg: str | None = Query(None, min_length=1, max_length=12),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""Latest planespotters.net photo for an aircraft (hex preferred, reg fallback)."""
|
|
|
|
|
|
if not hex_code and not reg:
|
|
|
|
|
|
raise HTTPException(422, "hex or reg required")
|
|
|
|
|
|
try:
|
|
|
|
|
|
photo = await fetch_planespotters_photo(hex_code=hex_code, reg=reg)
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
_upstream_or_502(exc, "planespotters")
|
|
|
|
|
|
if photo is None:
|
|
|
|
|
|
raise HTTPException(404, "no photo")
|
|
|
|
|
|
return overlay_json(photo, 86400)
|
|
|
|
|
|
|
|
|
|
|
|
|
feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.
- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00
|
|
|
|
@app.get("/api/trains")
|
|
|
|
|
|
async def list_trains(
|
|
|
|
|
|
bbox: str | None = Query(None, description="minlon,minlat,maxlon,maxlat"),
|
|
|
|
|
|
limit: int = Query(2000, ge=1, le=5000),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""Amtrak / Brightline / VIA last-known (Amtraker). Bbox optional (~200 rows)."""
|
|
|
|
|
|
if bbox:
|
|
|
|
|
|
_parse_bbox_query(bbox)
|
|
|
|
|
|
try:
|
2026-08-27 21:21:19 -04:00
|
|
|
|
return overlay_json(await fetch_trains(bbox, limit), 20)
|
feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.
- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00
|
|
|
|
except ValueError as exc:
|
|
|
|
|
|
raise HTTPException(422, str(exc)) from exc
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
_upstream_or_502(exc, "trains")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/api/vessels")
|
|
|
|
|
|
async def list_vessels(
|
|
|
|
|
|
bbox: str | None = Query(None, description="minlon,minlat,maxlon,maxlat"),
|
|
|
|
|
|
limit: int = Query(2000, ge=1, le=5000),
|
2026-08-28 09:33:19 -04:00
|
|
|
|
timestamp: str | None = Query(None, description="ISO time — DVR 1-min tracks instead of live"),
|
feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.
- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00
|
|
|
|
):
|
2026-08-29 00:40:41 -04:00
|
|
|
|
"""AIS last-known — union of two independent providers.
|
2026-08-29 00:18:49 -04:00
|
|
|
|
|
2026-08-29 00:40:41 -04:00
|
|
|
|
AISStream (extra.src="aisstream", live US-coast WebSocket) and VesselAPI
|
|
|
|
|
|
(extra.src="vesselapi", Strait of Hormuz 5×/day poll) both upsert into the
|
|
|
|
|
|
same store. Empty without either key / until the first successful poll.
|
2026-08-29 00:18:49 -04:00
|
|
|
|
"""
|
feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.
- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00
|
|
|
|
if bbox:
|
|
|
|
|
|
_parse_bbox_query(bbox)
|
|
|
|
|
|
try:
|
2026-08-28 09:33:19 -04:00
|
|
|
|
from tracks import fetch_positions_at, parse_timestamp
|
|
|
|
|
|
ts = parse_timestamp(timestamp)
|
|
|
|
|
|
if ts is not None:
|
|
|
|
|
|
return overlay_json(await fetch_positions_at("vessel", ts, bbox, limit), 5)
|
2026-08-27 21:21:19 -04:00
|
|
|
|
return overlay_json(await fetch_vessels(bbox, limit), 5)
|
feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.
- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00
|
|
|
|
except ValueError as exc:
|
|
|
|
|
|
raise HTTPException(422, str(exc)) from exc
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-27 21:46:37 -04:00
|
|
|
|
@app.post("/api/vessels/subscribe")
|
|
|
|
|
|
async def vessels_subscribe(payload: VesselBboxUpdate):
|
|
|
|
|
|
"""Retune the server-side AISStream subscription to the client viewport.
|
|
|
|
|
|
|
|
|
|
|
|
The stream follows the map: after a moveend the frontend posts its bbox,
|
|
|
|
|
|
the worker re-subscribes (≤1/s upstream), and last-known vessels for the
|
|
|
|
|
|
new area start arriving within a second or two. Empty/null bbox resets to
|
|
|
|
|
|
the env AISSTREAM_BBOX default. The API key never reaches the browser.
|
|
|
|
|
|
"""
|
|
|
|
|
|
raw = (payload.bbox or "").strip()
|
|
|
|
|
|
if not raw:
|
|
|
|
|
|
from ais_stream import reset_viewport_bbox
|
|
|
|
|
|
await reset_viewport_bbox()
|
|
|
|
|
|
return {"ok": True, "bbox": None}
|
|
|
|
|
|
try:
|
|
|
|
|
|
minlon, minlat, maxlon, maxlat = parse_bbox(raw)
|
|
|
|
|
|
except ValueError as exc:
|
|
|
|
|
|
raise HTTPException(422, str(exc)) from exc
|
|
|
|
|
|
if not (-180 <= minlon <= 180 and -180 <= maxlon <= 180
|
|
|
|
|
|
and -90 <= minlat <= 90 and -90 <= maxlat <= 90):
|
|
|
|
|
|
raise HTTPException(422, "bbox coordinates out of range")
|
|
|
|
|
|
if minlon >= maxlon or minlat >= maxlat:
|
|
|
|
|
|
raise HTTPException(422, "bbox must have min < max")
|
|
|
|
|
|
from ais_stream import request_viewport_bbox
|
|
|
|
|
|
await request_viewport_bbox(minlon, minlat, maxlon, maxlat)
|
|
|
|
|
|
return {"ok": True, "bbox": raw}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-28 09:33:19 -04:00
|
|
|
|
@app.get("/api/tracks/range")
|
|
|
|
|
|
async def tracks_range():
|
|
|
|
|
|
"""Earliest/latest 1-minute track buckets for the DVR slider."""
|
|
|
|
|
|
from tracks import track_range
|
|
|
|
|
|
return await track_range()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/api/geofences")
|
|
|
|
|
|
async def api_list_geofences():
|
|
|
|
|
|
"""Drawn GeoJSON polygons. Not /api/alerts (entity/keyword)."""
|
|
|
|
|
|
from geofence import list_geofences
|
|
|
|
|
|
return await list_geofences()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/api/geofences", status_code=201)
|
|
|
|
|
|
async def api_create_geofence(payload: GeofenceCreate):
|
|
|
|
|
|
from geofence import create_geofence, validate_polygon_geojson
|
|
|
|
|
|
try:
|
|
|
|
|
|
validate_polygon_geojson(payload.geojson)
|
|
|
|
|
|
except ValueError as exc:
|
|
|
|
|
|
raise HTTPException(422, str(exc)) from exc
|
|
|
|
|
|
try:
|
|
|
|
|
|
return await create_geofence(payload.name, payload.geojson, payload.active)
|
|
|
|
|
|
except HTTPException:
|
|
|
|
|
|
raise
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
raise HTTPException(503, f"geofence persist failed: {exc}") from exc
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.patch("/api/geofences/{gid}")
|
|
|
|
|
|
async def api_update_geofence(gid: str, payload: GeofenceUpdate):
|
|
|
|
|
|
from geofence import update_geofence, validate_polygon_geojson
|
|
|
|
|
|
if payload.geojson is not None:
|
|
|
|
|
|
try:
|
|
|
|
|
|
validate_polygon_geojson(payload.geojson)
|
|
|
|
|
|
except ValueError as exc:
|
|
|
|
|
|
raise HTTPException(422, str(exc)) from exc
|
|
|
|
|
|
row = await update_geofence(
|
|
|
|
|
|
gid, name=payload.name, geojson=payload.geojson, active=payload.active,
|
|
|
|
|
|
)
|
|
|
|
|
|
if row is None:
|
|
|
|
|
|
raise HTTPException(404, "geofence not found")
|
|
|
|
|
|
return row
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.delete("/api/geofences/{gid}", status_code=204)
|
|
|
|
|
|
async def api_delete_geofence(gid: str):
|
|
|
|
|
|
from geofence import delete_geofence
|
|
|
|
|
|
await delete_geofence(gid)
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/api/geofence-alerts")
|
|
|
|
|
|
async def api_geofence_alerts(limit: int = Query(100, ge=1, le=500)):
|
|
|
|
|
|
from sqlalchemy import text as sql_text
|
|
|
|
|
|
try:
|
|
|
|
|
|
async with async_session() as session:
|
|
|
|
|
|
rows = (await session.execute(sql_text(
|
|
|
|
|
|
"""
|
|
|
|
|
|
SELECT id::text, geofence_id::text, source_kind, entity_id,
|
|
|
|
|
|
lat, lon, payload, created_at
|
|
|
|
|
|
FROM geofence_alerts
|
|
|
|
|
|
ORDER BY created_at DESC
|
|
|
|
|
|
LIMIT :limit
|
|
|
|
|
|
"""
|
|
|
|
|
|
), {"limit": limit})).mappings().all()
|
|
|
|
|
|
out = []
|
|
|
|
|
|
for r in rows:
|
|
|
|
|
|
item = dict(r)
|
|
|
|
|
|
if item.get("created_at") is not None:
|
|
|
|
|
|
item["created_at"] = item["created_at"].isoformat()
|
|
|
|
|
|
out.append(item)
|
|
|
|
|
|
return out
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/api/fire-aircraft")
|
|
|
|
|
|
async def api_fire_aircraft(limit: int = Query(200, ge=1, le=1000)):
|
|
|
|
|
|
"""Persisted firefighting ADS-B × wildfire correlations (20 mi)."""
|
|
|
|
|
|
from fire_aircraft import recent_hits
|
|
|
|
|
|
return await recent_hits(limit)
|
|
|
|
|
|
|
|
|
|
|
|
|
feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.
- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00
|
|
|
|
@app.get("/api/fire-incidents")
|
|
|
|
|
|
async def list_fire_incidents(
|
|
|
|
|
|
bbox: str | None = Query(None),
|
|
|
|
|
|
limit: int = Query(2000, ge=1, le=5000),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""NIFC WFIGS current incident points."""
|
|
|
|
|
|
if bbox:
|
|
|
|
|
|
_parse_bbox_query(bbox)
|
|
|
|
|
|
try:
|
2026-08-27 21:21:19 -04:00
|
|
|
|
return overlay_json(await fetch_fire_incidents(bbox, limit), 30)
|
feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.
- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
_upstream_or_502(exc, "fire-incidents")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/api/fire-perimeters")
|
|
|
|
|
|
async def list_fire_perimeters(bbox: str | None = Query(None)):
|
|
|
|
|
|
"""NIFC WFIGS current wildfire perimeters (GeoJSON)."""
|
|
|
|
|
|
if bbox:
|
|
|
|
|
|
_parse_bbox_query(bbox)
|
|
|
|
|
|
try:
|
2026-08-27 21:21:19 -04:00
|
|
|
|
return overlay_json(await fetch_fire_perimeters(bbox), 30)
|
feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.
- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
_upstream_or_502(exc, "fire-perimeters")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/api/weather-alerts")
|
|
|
|
|
|
async def list_weather_alerts(
|
|
|
|
|
|
area: str | None = Query(None, description="US state two-letter code, e.g. NC"),
|
|
|
|
|
|
bbox: str | None = Query(None, description="minlon,minlat,maxlon,maxlat"),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""Cached NWS active alerts + IEM storm-based warning polygons.
|
|
|
|
|
|
|
|
|
|
|
|
Named ``/api/weather-alerts`` so it does not collide with dashboard
|
|
|
|
|
|
``/api/alerts`` (entity/keyword alert records).
|
|
|
|
|
|
"""
|
|
|
|
|
|
if bbox:
|
|
|
|
|
|
_parse_bbox_query(bbox)
|
|
|
|
|
|
try:
|
2026-08-27 21:21:19 -04:00
|
|
|
|
return overlay_json(await fetch_weather_alerts(area, bbox), 30)
|
feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.
- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
_upstream_or_502(exc, "weather-alerts")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/api/storms")
|
|
|
|
|
|
async def list_storms():
|
|
|
|
|
|
"""NHC active tropical cyclones."""
|
|
|
|
|
|
try:
|
2026-08-27 21:21:19 -04:00
|
|
|
|
return overlay_json(await fetch_storms(), 20)
|
feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.
- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
_upstream_or_502(exc, "storms")
|
2026-08-24 17:35:44 -04:00
|
|
|
|
|
|
|
|
|
|
|
2026-08-29 14:14:08 -04:00
|
|
|
|
_GPSJAM_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/api/map/gpsjam")
|
|
|
|
|
|
async def map_gpsjam(
|
|
|
|
|
|
date: str | None = Query(None, description="YYYY-MM-DD (default: yesterday UTC)"),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""GPSJAM daily GPS-interference hex layer (whole world, GeoJSON).
|
|
|
|
|
|
|
|
|
|
|
|
Red/yellow hexes correlate with suspected jamming but are NOT proof of it.
|
|
|
|
|
|
Fetched once per day from gpsjam.org (ADS-B Exchange data) and cached 1h.
|
|
|
|
|
|
"""
|
|
|
|
|
|
target = date
|
|
|
|
|
|
if target is None:
|
|
|
|
|
|
target = (datetime.now(timezone.utc) - timedelta(days=1)).strftime("%Y-%m-%d")
|
|
|
|
|
|
if not _GPSJAM_DATE.match(target):
|
|
|
|
|
|
raise HTTPException(422, "date must be YYYY-MM-DD")
|
|
|
|
|
|
try:
|
|
|
|
|
|
fc = await fetch_gpsjam(target)
|
|
|
|
|
|
except httpx.HTTPStatusError as exc:
|
|
|
|
|
|
if exc.response.status_code == 404:
|
|
|
|
|
|
return JSONResponse(
|
|
|
|
|
|
{"error": "unavailable", "href": "https://gpsjam.org/",
|
|
|
|
|
|
"date": target},
|
|
|
|
|
|
)
|
|
|
|
|
|
_upstream_or_502(exc, "gpsjam")
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
_upstream_or_502(exc, "gpsjam")
|
|
|
|
|
|
if not fc.get("features"):
|
|
|
|
|
|
return JSONResponse(
|
|
|
|
|
|
{"error": "unavailable", "href": "https://gpsjam.org/", "date": target},
|
|
|
|
|
|
)
|
|
|
|
|
|
return overlay_json(fc, 3600)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-24 17:35:44 -04:00
|
|
|
|
@app.get("/api/map/times")
|
|
|
|
|
|
async def map_layer_times(
|
|
|
|
|
|
layer: str = Query(..., description="GIBS layer identifier, e.g. VIIRS_SNPP_CorrectedReflectance_TrueColor"),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""Available date windows for a time-aware GIBS layer.
|
|
|
|
|
|
|
|
|
|
|
|
Parsed from the layer's Domains XML (GIBS serves nearest-time tiles even
|
|
|
|
|
|
for dates slightly outside a window, but we surface the real ranges so
|
|
|
|
|
|
the UI can clamp the picker and flag out-of-range picks).
|
|
|
|
|
|
"""
|
|
|
|
|
|
from gibs_map import _LAYER_BY_ID, fetch_layer_domain
|
|
|
|
|
|
|
|
|
|
|
|
meta = _LAYER_BY_ID.get(layer)
|
|
|
|
|
|
if not meta:
|
|
|
|
|
|
raise HTTPException(404, f"Unknown GIBS layer: {layer}")
|
|
|
|
|
|
if not meta["has_time"]:
|
|
|
|
|
|
raise HTTPException(422, f"Layer '{layer}' is static (no Time dimension)")
|
|
|
|
|
|
try:
|
|
|
|
|
|
domain = await fetch_layer_domain(meta["id"], meta["tms"])
|
|
|
|
|
|
except Exception as exc: # network / GIBS hiccup → degrade gracefully
|
|
|
|
|
|
logger.warning("gibs_domain_fetch_failed", layer=layer, error=str(exc))
|
|
|
|
|
|
raise HTTPException(502, f"GIBS time domain unavailable: {exc}")
|
|
|
|
|
|
return {"layer": layer, **domain}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-28 21:49:05 -04:00
|
|
|
|
app.mount("/static", CachedStaticFiles(directory=str(STATIC_DIR)), name="static")
|
2026-08-24 17:35:44 -04:00
|
|
|
|
|
|
|
|
|
|
|
2026-06-04 20:30:04 -04:00
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
|
import uvicorn
|
|
|
|
|
|
uvicorn.run(app, host="0.0.0.0", port=8000)
|