mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-05-14 04:22:07 +02:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cf68f1978d | |||
| 10f376d4d7 | |||
| ff168150c9 | |||
| 782225ff99 | |||
| 25262323f5 | |||
| bad50b8924 | |||
| e2a9ef9bbf | |||
| 3c16071fcd | |||
| 2ae104fca2 |
@@ -50,6 +50,7 @@ jobs:
|
||||
uses: docker/build-push-action@v5.0.0
|
||||
with:
|
||||
context: ./frontend
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
@@ -93,6 +94,7 @@ jobs:
|
||||
uses: docker/build-push-action@v5.0.0
|
||||
with:
|
||||
context: ./backend
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
@@ -79,7 +79,7 @@ Do not append a trailing `.` to that command; Compose treats it as a service nam
|
||||
|
||||
* **Global Incidents** — GDELT-powered conflict event aggregation (last 8 hours, ~1,000 events)
|
||||
* **Ukraine Frontline** — Live warfront GeoJSON from DeepState Map
|
||||
* **SIGINT/RISINT News Feed** — Real-time RSS aggregation from multiple intelligence-focused sources
|
||||
* **SIGINT/RISINT News Feed** — Real-time RSS aggregation from multiple intelligence-focused sources with user-customizable feeds (up to 20 sources, configurable priority weights 1-5)
|
||||
* **Region Dossier** — Right-click anywhere on the map for:
|
||||
* Country profile (population, capital, languages, currencies, area)
|
||||
* Head of state & government type (Wikidata SPARQL)
|
||||
@@ -121,6 +121,7 @@ Do not append a trailing `.` to that command; Compose treats it as a service nam
|
||||
* **NASA FIRMS Fire Hotspots (24h)** — 5,000+ global thermal anomalies from NOAA-20 VIIRS satellite, updated every cycle. Flame-shaped icons color-coded by fire radiative power (FRP): yellow (low), orange, red, dark red (intense). Clustered at low zoom with fire-shaped cluster markers.
|
||||
* **Space Weather Badge** — Live NOAA geomagnetic storm indicator in the bottom status bar. Color-coded Kp index: green (quiet), yellow (active), red (storm G1–G5). Data from SWPC planetary K-index 1-minute feed.
|
||||
* **Internet Outage Monitoring** — Regional internet connectivity alerts from Georgia Tech IODA. Grey markers at affected regions with severity percentage. Uses only reliable datasources (BGP routing tables, active ping probing) — no telescope or interpolated data.
|
||||
* **Data Center Mapping** — 2,000+ global data centers plotted from a curated dataset. Clustered purple markers with server-rack icons. Click for operator, location, and automatic internet outage cross-referencing by country.
|
||||
|
||||
### 🌐 Additional Layers
|
||||
|
||||
@@ -198,6 +199,7 @@ Do not append a trailing `.` to that command; Compose treats it as a service nam
|
||||
| [NASA FIRMS](https://firms.modaps.eosdis.nasa.gov) | NOAA-20 VIIRS fire/thermal hotspots | ~120s | No |
|
||||
| [NOAA SWPC](https://services.swpc.noaa.gov) | Space weather Kp index & solar events | ~120s | No |
|
||||
| [IODA (Georgia Tech)](https://ioda.inetintel.cc.gatech.edu) | Regional internet outage alerts | ~120s | No |
|
||||
| [DC Map (GitHub)](https://github.com/Ringmast4r/Data-Center-Map---Global) | Global data center locations | Static (cached 7d) | No |
|
||||
| [CARTO Basemaps](https://carto.com) | Dark map tiles | Continuous | No |
|
||||
|
||||
---
|
||||
@@ -217,36 +219,76 @@ cd Shadowbroker
|
||||
|
||||
Open `http://localhost:3000` to view the dashboard.
|
||||
|
||||
> **Deploying publicly or on a LAN?** The frontend **auto-detects** the
|
||||
> backend — it uses your browser's hostname with port `8000`
|
||||
> (e.g. if you visit `http://192.168.1.50:3000`, API calls go to
|
||||
> `http://192.168.1.50:8000`). **No configuration needed** for most setups.
|
||||
> **Deploying publicly or on a LAN?** No configuration needed for most setups.
|
||||
> The frontend proxies all API calls through the Next.js server to `BACKEND_URL`,
|
||||
> which defaults to `http://backend:8000` (Docker internal networking).
|
||||
> Port 8000 does not need to be exposed externally.
|
||||
>
|
||||
> If your backend runs on a **different port or host** (reverse proxy,
|
||||
> custom Docker port mapping, separate server), set `NEXT_PUBLIC_API_URL`:
|
||||
> If your backend runs on a **different host or port**, set `BACKEND_URL` at runtime — no rebuild required:
|
||||
>
|
||||
> ```bash
|
||||
> # Linux / macOS
|
||||
> NEXT_PUBLIC_API_URL=http://myserver.com:9096 docker-compose up -d --build
|
||||
> BACKEND_URL=http://myserver.com:9096 docker-compose up -d
|
||||
>
|
||||
> # Podman (via compose.sh wrapper)
|
||||
> NEXT_PUBLIC_API_URL=http://192.168.1.50:9096 ./compose.sh up -d --build
|
||||
> BACKEND_URL=http://192.168.1.50:9096 ./compose.sh up -d
|
||||
>
|
||||
> # Windows (PowerShell)
|
||||
> $env:NEXT_PUBLIC_API_URL="http://myserver.com:9096"; docker-compose up -d --build
|
||||
> $env:BACKEND_URL="http://myserver.com:9096"; docker-compose up -d
|
||||
>
|
||||
> # Or add to a .env file next to docker-compose.yml:
|
||||
> # NEXT_PUBLIC_API_URL=http://myserver.com:9096
|
||||
> # BACKEND_URL=http://myserver.com:9096
|
||||
> ```
|
||||
>
|
||||
> This is a **build-time** variable (Next.js limitation) — it gets baked into
|
||||
> the frontend during `npm run build`. Changing it requires a rebuild.
|
||||
|
||||
If you prefer to call the container engine directly, Podman users can run `podman compose up -d`, or force the wrapper to use Podman with `./compose.sh --engine podman up -d`.
|
||||
Depending on your local Podman configuration, `podman compose` may still delegate to an external compose provider while talking to the Podman socket.
|
||||
|
||||
---
|
||||
|
||||
### 🐋 Standalone Deploy (Portainer, Uncloud, NAS, etc.)
|
||||
|
||||
No need to clone the repo. Use the pre-built images published to the GitHub Container Registry.
|
||||
|
||||
Create a `docker-compose.yml` with the following content and deploy it directly — paste it into Portainer's stack editor, `uncloud deploy`, or any Docker host:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
backend:
|
||||
image: ghcr.io/bigbodycobain/shadowbroker-backend:latest
|
||||
container_name: shadowbroker-backend
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
- AIS_API_KEY=your_aisstream_key # Required — get one free at aisstream.io
|
||||
- OPENSKY_CLIENT_ID= # Optional — higher flight data rate limits
|
||||
- OPENSKY_CLIENT_SECRET= # Optional — paired with Client ID above
|
||||
- LTA_ACCOUNT_KEY= # Optional — Singapore CCTV cameras
|
||||
- CORS_ORIGINS= # Optional — comma-separated allowed origins
|
||||
volumes:
|
||||
- backend_data:/app/data
|
||||
restart: unless-stopped
|
||||
|
||||
frontend:
|
||||
image: ghcr.io/bigbodycobain/shadowbroker-frontend:latest
|
||||
container_name: shadowbroker-frontend
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- BACKEND_URL=http://backend:8000 # Docker internal networking — no rebuild needed
|
||||
depends_on:
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
backend_data:
|
||||
```
|
||||
|
||||
> **How it works:** The frontend container proxies all `/api/*` requests through the Next.js server to `BACKEND_URL` using Docker's internal networking. The browser only ever talks to port 3000 — port 8000 does not need to be exposed externally.
|
||||
>
|
||||
> `BACKEND_URL` is a plain runtime environment variable (not a build-time `NEXT_PUBLIC_*`), so you can change it in Portainer, Uncloud, or any compose editor without rebuilding the image. Set it to the address where your backend is reachable from inside the Docker network (e.g. `http://backend:8000`, `http://192.168.1.50:8000`).
|
||||
|
||||
---
|
||||
|
||||
### 📦 Quick Start (No Code Required)
|
||||
|
||||
If you just want to run the dashboard without dealing with terminal commands:
|
||||
@@ -334,6 +376,7 @@ All layers are independently toggleable from the left panel:
|
||||
| KiwiSDR Receivers | ❌ OFF | Public SDR radio receivers |
|
||||
| Fire Hotspots (24h) | ❌ OFF | NASA FIRMS VIIRS thermal anomalies |
|
||||
| Internet Outages | ❌ OFF | IODA regional connectivity alerts |
|
||||
| Data Centers | ❌ OFF | Global data center locations (2,000+) |
|
||||
| Day / Night Cycle | ✅ ON | Solar terminator overlay |
|
||||
|
||||
---
|
||||
@@ -345,8 +388,9 @@ The platform is optimized for handling massive real-time datasets:
|
||||
* **Gzip Compression** — API payloads compressed ~92% (11.6 MB → 915 KB)
|
||||
* **ETag Caching** — `304 Not Modified` responses skip redundant JSON parsing
|
||||
* **Viewport Culling** — Only features within the visible map bounds (+20% buffer) are rendered
|
||||
* **Clustered Rendering** — Ships, CCTV, and earthquakes use MapLibre clustering to reduce feature count
|
||||
* **Debounced Viewport Updates** — 300ms debounce prevents GeoJSON rebuild thrash during pan/zoom
|
||||
* **Imperative Map Updates** — High-volume layers (flights, satellites, fires) bypass React reconciliation via direct `setData()` calls
|
||||
* **Clustered Rendering** — Ships, CCTV, earthquakes, and data centers use MapLibre clustering to reduce feature count
|
||||
* **Debounced Viewport Updates** — 300ms debounce prevents GeoJSON rebuild thrash during pan/zoom; 2s debounce on dense layers (satellites, fires)
|
||||
* **Position Interpolation** — Smooth 10s tick animation between data refreshes
|
||||
* **React.memo** — Heavy components wrapped to prevent unnecessary re-renders
|
||||
* **Coordinate Precision** — Lat/lng rounded to 5 decimals (~1m) to reduce JSON size
|
||||
@@ -361,6 +405,8 @@ live-risk-dashboard/
|
||||
│ ├── main.py # FastAPI app, middleware, API routes
|
||||
│ ├── carrier_cache.json # Persisted carrier OSINT positions
|
||||
│ ├── cctv.db # SQLite CCTV camera database
|
||||
│ ├── config/
|
||||
│ │ └── news_feeds.json # User-customizable RSS feed list (persists across restarts)
|
||||
│ └── services/
|
||||
│ ├── data_fetcher.py # Core scheduler — fetches all data sources
|
||||
│ ├── ais_stream.py # AIS WebSocket client (25K+ vessels)
|
||||
@@ -372,7 +418,8 @@ live-risk-dashboard/
|
||||
│ ├── kiwisdr_fetcher.py # KiwiSDR receiver scraper
|
||||
│ ├── sentinel_search.py # Sentinel-2 STAC imagery search
|
||||
│ ├── network_utils.py # HTTP client with curl fallback
|
||||
│ └── api_settings.py # API key management
|
||||
│ ├── api_settings.py # API key management
|
||||
│ └── news_feed_config.py # RSS feed config manager (add/remove/weight feeds)
|
||||
│
|
||||
├── frontend/
|
||||
│ ├── src/
|
||||
@@ -390,7 +437,7 @@ live-risk-dashboard/
|
||||
│ │ ├── RadioInterceptPanel.tsx # Scanner-style radio panel
|
||||
│ │ ├── FindLocateBar.tsx # Search/locate bar
|
||||
│ │ ├── ChangelogModal.tsx # Version changelog popup
|
||||
│ │ ├── SettingsPanel.tsx # App settings
|
||||
│ │ ├── SettingsPanel.tsx # App settings (API Keys + News Feed manager)
|
||||
│ │ ├── ScaleBar.tsx # Map scale indicator
|
||||
│ │ ├── WikiImage.tsx # Wikipedia image fetcher
|
||||
│ │ └── ErrorBoundary.tsx # Crash recovery wrapper
|
||||
@@ -413,16 +460,13 @@ OPENSKY_CLIENT_SECRET=your_opensky_secret # OAuth2 — paired with Client ID
|
||||
LTA_ACCOUNT_KEY=your_lta_key # Singapore CCTV cameras
|
||||
```
|
||||
|
||||
### Frontend (optional)
|
||||
### Frontend
|
||||
|
||||
| Variable | Where to set | Purpose |
|
||||
|---|---|---|
|
||||
| `NEXT_PUBLIC_API_URL` | `.env` next to `docker-compose.yml`, or shell env | Override backend URL when deploying publicly or behind a reverse proxy. Leave unset for auto-detection. |
|
||||
| `BACKEND_URL` | `environment` in `docker-compose.yml`, or shell env | URL the Next.js server uses to proxy API calls to the backend. Defaults to `http://backend:8000`. **Runtime variable — no rebuild needed.** |
|
||||
|
||||
**How auto-detection works:** When `NEXT_PUBLIC_API_URL` is not set, the frontend
|
||||
reads `window.location.hostname` in the browser and calls `{protocol}//{hostname}:8000`.
|
||||
This means the dashboard works on `localhost`, LAN IPs, and public domains without
|
||||
any configuration — as long as the backend is reachable on port 8000 of the same host.
|
||||
**How it works:** The frontend proxies all `/api/*` requests through the Next.js server to `BACKEND_URL` using Docker's internal networking. Browsers only talk to port 3000; port 8000 never needs to be exposed externally. For local dev without Docker, `BACKEND_URL` defaults to `http://localhost:8000`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"feeds": [
|
||||
{
|
||||
"name": "NPR",
|
||||
"url": "https://feeds.npr.org/1004/rss.xml",
|
||||
"weight": 4
|
||||
},
|
||||
{
|
||||
"name": "BBC",
|
||||
"url": "http://feeds.bbci.co.uk/news/world/rss.xml",
|
||||
"weight": 3
|
||||
},
|
||||
{
|
||||
"name": "AlJazeera",
|
||||
"url": "https://www.aljazeera.com/xml/rss/all.xml",
|
||||
"weight": 2
|
||||
},
|
||||
{
|
||||
"name": "NYT",
|
||||
"url": "https://rss.nytimes.com/services/xml/rss/nyt/World.xml",
|
||||
"weight": 1
|
||||
},
|
||||
{
|
||||
"name": "GDACS",
|
||||
"url": "https://www.gdacs.org/xml/rss.xml",
|
||||
"weight": 5
|
||||
},
|
||||
{
|
||||
"name": "NHK",
|
||||
"url": "https://www3.nhk.or.jp/nhkworld/rss/world.xml",
|
||||
"weight": 3
|
||||
},
|
||||
{
|
||||
"name": "CNA",
|
||||
"url": "https://www.channelnewsasia.com/rssfeed/8395986",
|
||||
"weight": 3
|
||||
},
|
||||
{
|
||||
"name": "Mercopress",
|
||||
"url": "https://en.mercopress.com/rss/",
|
||||
"weight": 3
|
||||
}
|
||||
]
|
||||
}
|
||||
+78
-27
@@ -1,16 +1,44 @@
|
||||
from fastapi import FastAPI, Request, Response
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from contextlib import asynccontextmanager
|
||||
from services.data_fetcher import start_scheduler, stop_scheduler, get_latest_data
|
||||
from services.data_fetcher import start_scheduler, stop_scheduler, get_latest_data, source_timestamps
|
||||
from services.ais_stream import start_ais_stream, stop_ais_stream
|
||||
from services.carrier_tracker import start_carrier_tracker, stop_carrier_tracker
|
||||
import uvicorn
|
||||
import logging
|
||||
import hashlib
|
||||
import json as json_mod
|
||||
import os
|
||||
import socket
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
|
||||
def _build_cors_origins():
|
||||
"""Build a CORS origins whitelist: localhost + LAN IPs + env overrides.
|
||||
Falls back to wildcard only if auto-detection fails entirely."""
|
||||
origins = [
|
||||
"http://localhost:3000",
|
||||
"http://127.0.0.1:3000",
|
||||
"http://localhost:8000",
|
||||
"http://127.0.0.1:8000",
|
||||
]
|
||||
# Add this machine's LAN IPs (covers common home/office setups)
|
||||
try:
|
||||
hostname = socket.gethostname()
|
||||
for info in socket.getaddrinfo(hostname, None, socket.AF_INET):
|
||||
ip = info[4][0]
|
||||
if ip not in ("127.0.0.1", "0.0.0.0"):
|
||||
origins.append(f"http://{ip}:3000")
|
||||
origins.append(f"http://{ip}:8000")
|
||||
except Exception:
|
||||
pass
|
||||
# Allow user override via CORS_ORIGINS env var (comma-separated)
|
||||
extra = os.environ.get("CORS_ORIGINS", "")
|
||||
if extra:
|
||||
origins.extend([o.strip() for o in extra.split(",") if o.strip()])
|
||||
return list(set(origins)) # deduplicate
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Startup: Start background data fetching, AIS stream, and carrier tracker
|
||||
@@ -29,7 +57,7 @@ from fastapi.middleware.gzip import GZipMiddleware
|
||||
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # Must be permissive — users access from localhost, LAN IPs, Docker, custom ports
|
||||
allow_origins=_build_cors_origins(),
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
@@ -49,6 +77,15 @@ async def force_refresh():
|
||||
async def live_data():
|
||||
return get_latest_data()
|
||||
|
||||
def _etag_response(request: Request, payload: dict, prefix: str = "", default=None):
|
||||
"""Serialize once, hash the bytes for ETag, return 304 or full response."""
|
||||
content = json_mod.dumps(payload, default=default)
|
||||
etag = hashlib.md5(f"{prefix}{content[:256]}".encode()).hexdigest()[:16]
|
||||
if request.headers.get("if-none-match") == etag:
|
||||
return Response(status_code=304, headers={"ETag": etag, "Cache-Control": "no-cache"})
|
||||
return Response(content=content, media_type="application/json",
|
||||
headers={"ETag": etag, "Cache-Control": "no-cache"})
|
||||
|
||||
@app.get("/api/live-data/fast")
|
||||
async def live_data_fast(request: Request):
|
||||
d = get_latest_data()
|
||||
@@ -59,24 +96,13 @@ async def live_data_fast(request: Request):
|
||||
"private_jets": d.get("private_jets", []),
|
||||
"tracked_flights": d.get("tracked_flights", []),
|
||||
"ships": d.get("ships", []),
|
||||
"satellites": d.get("satellites", []),
|
||||
"cctv": d.get("cctv", []),
|
||||
"uavs": d.get("uavs", []),
|
||||
"liveuamap": d.get("liveuamap", []),
|
||||
"gps_jamming": d.get("gps_jamming", []),
|
||||
"freshness": dict(source_timestamps),
|
||||
}
|
||||
# ETag includes last_updated timestamp so it changes on every data refresh,
|
||||
# not just when item counts change (old bug: positions went stale)
|
||||
last_updated = d.get("last_updated", "")
|
||||
counts = "|".join(f"{k}:{len(v) if isinstance(v, list) else 0}" for k, v in payload.items())
|
||||
etag = hashlib.md5(f"{last_updated}|{counts}".encode()).hexdigest()[:16]
|
||||
if request.headers.get("if-none-match") == etag:
|
||||
return Response(status_code=304, headers={"ETag": etag, "Cache-Control": "no-cache"})
|
||||
return Response(
|
||||
content=json_mod.dumps(payload),
|
||||
media_type="application/json",
|
||||
headers={"ETag": etag, "Cache-Control": "no-cache"}
|
||||
)
|
||||
return _etag_response(request, payload, prefix="fast|")
|
||||
|
||||
@app.get("/api/live-data/slow")
|
||||
async def live_data_slow(request: Request):
|
||||
@@ -96,19 +122,11 @@ async def live_data_slow(request: Request):
|
||||
"kiwisdr": d.get("kiwisdr", []),
|
||||
"space_weather": d.get("space_weather"),
|
||||
"internet_outages": d.get("internet_outages", []),
|
||||
"firms_fires": d.get("firms_fires", [])
|
||||
"firms_fires": d.get("firms_fires", []),
|
||||
"datacenters": d.get("datacenters", []),
|
||||
"freshness": dict(source_timestamps),
|
||||
}
|
||||
# ETag based on last_updated + item counts
|
||||
last_updated = d.get("last_updated", "")
|
||||
counts = "|".join(f"{k}:{len(v) if isinstance(v, list) else 0}" for k, v in payload.items())
|
||||
etag = hashlib.md5(f"slow|{last_updated}|{counts}".encode()).hexdigest()[:16]
|
||||
if request.headers.get("if-none-match") == etag:
|
||||
return Response(status_code=304, headers={"ETag": etag, "Cache-Control": "no-cache"})
|
||||
return Response(
|
||||
content=json_mod.dumps(payload, default=str),
|
||||
media_type="application/json",
|
||||
headers={"ETag": etag, "Cache-Control": "no-cache"}
|
||||
)
|
||||
return _etag_response(request, payload, prefix="slow|", default=str)
|
||||
|
||||
@app.get("/api/debug-latest")
|
||||
async def debug_latest_data():
|
||||
@@ -131,7 +149,12 @@ async def health_check():
|
||||
"earthquakes": len(d.get("earthquakes", [])),
|
||||
"cctv": len(d.get("cctv", [])),
|
||||
"news": len(d.get("news", [])),
|
||||
"uavs": len(d.get("uavs", [])),
|
||||
"firms_fires": len(d.get("firms_fires", [])),
|
||||
"liveuamap": len(d.get("liveuamap", [])),
|
||||
"gdelt": len(d.get("gdelt", [])),
|
||||
},
|
||||
"freshness": dict(source_timestamps),
|
||||
"uptime_seconds": round(time.time() - _start_time),
|
||||
}
|
||||
|
||||
@@ -219,6 +242,34 @@ async def api_update_key(body: ApiKeyUpdate):
|
||||
return {"status": "updated", "env_key": body.env_key}
|
||||
return {"status": "error", "message": "Failed to update .env file"}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# News Feed Configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
from services.news_feed_config import get_feeds, save_feeds, reset_feeds
|
||||
|
||||
@app.get("/api/settings/news-feeds")
|
||||
async def api_get_news_feeds():
|
||||
return get_feeds()
|
||||
|
||||
@app.put("/api/settings/news-feeds")
|
||||
async def api_save_news_feeds(request: Request):
|
||||
body = await request.json()
|
||||
ok = save_feeds(body)
|
||||
if ok:
|
||||
return {"status": "updated", "count": len(body)}
|
||||
return Response(
|
||||
content=json_mod.dumps({"status": "error", "message": "Validation failed (max 20 feeds, each needs name/url/weight 1-5)"}),
|
||||
status_code=400,
|
||||
media_type="application/json",
|
||||
)
|
||||
|
||||
@app.post("/api/settings/news-feeds/reset")
|
||||
async def api_reset_news_feeds():
|
||||
ok = reset_feeds()
|
||||
if ok:
|
||||
return {"status": "reset", "feeds": get_feeds()}
|
||||
return {"status": "error", "message": "Failed to reset feeds"}
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
|
||||
|
||||
|
||||
@@ -238,49 +238,51 @@ def _ais_stream_loop():
|
||||
logger.info("AIS Stream proxy started — receiving vessel data")
|
||||
|
||||
msg_count = 0
|
||||
ok_streak = 0 # Track consecutive successful messages for backoff reset
|
||||
last_log_time = time.time()
|
||||
for raw_msg in iter(process.stdout.readline, ''):
|
||||
if not _ws_running:
|
||||
process.terminate()
|
||||
break
|
||||
|
||||
|
||||
raw_msg = raw_msg.strip()
|
||||
if not raw_msg:
|
||||
continue
|
||||
|
||||
|
||||
try:
|
||||
data = json.loads(raw_msg)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
|
||||
if "error" in data:
|
||||
logger.error(f"AIS Stream error: {data['error']}")
|
||||
continue
|
||||
|
||||
|
||||
msg_type = data.get("MessageType", "")
|
||||
metadata = data.get("MetaData", {})
|
||||
message = data.get("Message", {})
|
||||
|
||||
|
||||
mmsi = metadata.get("MMSI", 0)
|
||||
if not mmsi:
|
||||
continue
|
||||
|
||||
|
||||
with _vessels_lock:
|
||||
if mmsi not in _vessels:
|
||||
_vessels[mmsi] = {"_updated": time.time()}
|
||||
vessel = _vessels[mmsi]
|
||||
|
||||
|
||||
# Update position from PositionReport or StandardClassBPositionReport
|
||||
if msg_type in ("PositionReport", "StandardClassBPositionReport"):
|
||||
report = message.get(msg_type, {})
|
||||
lat = report.get("Latitude", metadata.get("latitude", 0))
|
||||
lng = report.get("Longitude", metadata.get("longitude", 0))
|
||||
|
||||
|
||||
# Skip invalid positions
|
||||
if lat == 0 and lng == 0:
|
||||
continue
|
||||
if abs(lat) > 90 or abs(lng) > 180:
|
||||
continue
|
||||
|
||||
|
||||
with _vessels_lock:
|
||||
vessel["lat"] = lat
|
||||
vessel["lng"] = lng
|
||||
@@ -292,12 +294,12 @@ def _ais_stream_loop():
|
||||
# Use metadata name if we don't have one yet
|
||||
if not vessel.get("name") or vessel["name"] == "UNKNOWN":
|
||||
vessel["name"] = metadata.get("ShipName", "UNKNOWN").strip() or "UNKNOWN"
|
||||
|
||||
|
||||
# Update static data from ShipStaticData
|
||||
elif msg_type == "ShipStaticData":
|
||||
static = message.get("ShipStaticData", {})
|
||||
ais_type = static.get("Type", 0)
|
||||
|
||||
|
||||
with _vessels_lock:
|
||||
vessel["name"] = (static.get("Name", "") or metadata.get("ShipName", "UNKNOWN")).strip() or "UNKNOWN"
|
||||
vessel["callsign"] = (static.get("CallSign", "") or "").strip()
|
||||
@@ -306,21 +308,24 @@ def _ais_stream_loop():
|
||||
vessel["ais_type_code"] = ais_type
|
||||
vessel["type"] = classify_vessel(ais_type, mmsi)
|
||||
vessel["_updated"] = time.time()
|
||||
|
||||
|
||||
msg_count += 1
|
||||
if msg_count % 5000 == 0:
|
||||
ok_streak += 1
|
||||
|
||||
# Reset backoff after 200 consecutive successful messages
|
||||
if ok_streak >= 200 and backoff > 1:
|
||||
backoff = 1
|
||||
ok_streak = 0
|
||||
|
||||
# Periodic logging + cache save (time-based instead of count-based to avoid lock in hot loop)
|
||||
now = time.time()
|
||||
if now - last_log_time >= 60:
|
||||
with _vessels_lock:
|
||||
# Inline pruning: remove vessels not updated in 15 minutes
|
||||
prune_cutoff = time.time() - 900
|
||||
stale = [k for k, v in _vessels.items() if v.get("_updated", 0) < prune_cutoff]
|
||||
for k in stale:
|
||||
del _vessels[k]
|
||||
count = len(_vessels)
|
||||
if stale:
|
||||
logger.info(f"AIS pruned {len(stale)} stale vessels")
|
||||
logger.info(f"AIS Stream: processed {msg_count} messages, tracking {count} vessels")
|
||||
_save_cache() # Auto-save every 5000 messages (~60 seconds)
|
||||
|
||||
_save_cache()
|
||||
last_log_time = now
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"AIS proxy connection error: {e}")
|
||||
if _ws_running:
|
||||
@@ -328,8 +333,6 @@ def _ais_stream_loop():
|
||||
time.sleep(backoff)
|
||||
backoff = min(backoff * 2, 60) # Double up to 60s max
|
||||
continue
|
||||
# Reset backoff on successful connection (got at least some messages)
|
||||
backoff = 1
|
||||
|
||||
|
||||
def _run_ais_loop():
|
||||
|
||||
+424
-161
@@ -10,10 +10,12 @@ import random
|
||||
import math
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
import threading
|
||||
import io
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
import concurrent.futures
|
||||
import heapq
|
||||
from sgp4.api import Satrec, WGS72
|
||||
from sgp4.api import jday
|
||||
from datetime import datetime
|
||||
@@ -80,6 +82,25 @@ opensky_client = OpenSkyClient(
|
||||
last_opensky_fetch = 0
|
||||
cached_opensky_flights = []
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Supplemental ADS-B sources for blind-spot gap-filling (Russia/China/Africa)
|
||||
# These aggregators have different feeder pools than adsb.lol and can surface
|
||||
# aircraft invisible to our primary source. Only gap-fill planes are kept.
|
||||
# ---------------------------------------------------------------------------
|
||||
_BLIND_SPOT_REGIONS = [
|
||||
{"name": "Yekaterinburg", "lat": 56.8, "lon": 60.6, "radius_nm": 250},
|
||||
{"name": "Novosibirsk", "lat": 55.0, "lon": 82.9, "radius_nm": 250},
|
||||
{"name": "Krasnoyarsk", "lat": 56.0, "lon": 92.9, "radius_nm": 250},
|
||||
{"name": "Vladivostok", "lat": 43.1, "lon": 131.9, "radius_nm": 250},
|
||||
{"name": "Urumqi", "lat": 43.8, "lon": 87.6, "radius_nm": 250},
|
||||
{"name": "Chengdu", "lat": 30.6, "lon": 104.1, "radius_nm": 250},
|
||||
{"name": "Lagos-Accra", "lat": 6.5, "lon": 3.4, "radius_nm": 250},
|
||||
{"name": "Addis Ababa", "lat": 9.0, "lon": 38.7, "radius_nm": 250},
|
||||
]
|
||||
_SUPPLEMENTAL_FETCH_INTERVAL = 120 # seconds — only query every 2 min
|
||||
last_supplemental_fetch = 0
|
||||
cached_supplemental_flights = []
|
||||
|
||||
|
||||
|
||||
# In-memory store
|
||||
@@ -104,9 +125,19 @@ latest_data = {
|
||||
"kiwisdr": [],
|
||||
"space_weather": None,
|
||||
"internet_outages": [],
|
||||
"firms_fires": []
|
||||
"firms_fires": [],
|
||||
"datacenters": []
|
||||
}
|
||||
|
||||
# Per-source freshness timestamps — updated each time a fetch function completes successfully
|
||||
source_timestamps = {}
|
||||
|
||||
def _mark_fresh(*keys):
|
||||
"""Record the current UTC time for one or more data source keys."""
|
||||
now = datetime.utcnow().isoformat()
|
||||
for k in keys:
|
||||
source_timestamps[k] = now
|
||||
|
||||
# Thread lock for safe reads/writes to latest_data
|
||||
_data_lock = threading.Lock()
|
||||
|
||||
@@ -337,20 +368,10 @@ _KEYWORD_COORDS = {
|
||||
}
|
||||
|
||||
def fetch_news():
|
||||
feeds = {
|
||||
"NPR": "https://feeds.npr.org/1004/rss.xml",
|
||||
"BBC": "http://feeds.bbci.co.uk/news/world/rss.xml",
|
||||
"AlJazeera": "https://www.aljazeera.com/xml/rss/all.xml",
|
||||
"NYT": "https://rss.nytimes.com/services/xml/rss/nyt/World.xml",
|
||||
"GDACS": "https://www.gdacs.org/xml/rss.xml",
|
||||
"NHK": "https://www3.nhk.or.jp/nhkworld/rss/world.xml",
|
||||
"CNA": "https://www.channelnewsasia.com/rssfeed/8395986",
|
||||
"Mercopress": "https://en.mercopress.com/rss/"
|
||||
}
|
||||
source_weights = {
|
||||
"NPR": 4, "BBC": 3, "AlJazeera": 2, "NYT": 1,
|
||||
"GDACS": 5, "NHK": 3, "CNA": 3, "Mercopress": 3
|
||||
}
|
||||
from services.news_feed_config import get_feeds
|
||||
feed_config = get_feeds()
|
||||
feeds = {f["name"]: f["url"] for f in feed_config}
|
||||
source_weights = {f["name"]: f["weight"] for f in feed_config}
|
||||
|
||||
clusters = {}
|
||||
|
||||
@@ -477,55 +498,47 @@ def fetch_news():
|
||||
|
||||
news_items.sort(key=lambda x: x['risk_score'], reverse=True)
|
||||
latest_data['news'] = news_items
|
||||
_mark_fresh("news")
|
||||
|
||||
def _fetch_single_ticker(symbol: str, period: str = "2d"):
|
||||
"""Fetch a single yfinance ticker. Returns (symbol, data_dict) or (symbol, None)."""
|
||||
try:
|
||||
ticker = yf.Ticker(symbol)
|
||||
hist = ticker.history(period=period)
|
||||
if len(hist) >= 1:
|
||||
current_price = hist['Close'].iloc[-1]
|
||||
prev_close = hist['Close'].iloc[0] if len(hist) > 1 else current_price
|
||||
change_percent = ((current_price - prev_close) / prev_close) * 100 if prev_close else 0
|
||||
return symbol, {
|
||||
"price": round(float(current_price), 2),
|
||||
"change_percent": round(float(change_percent), 2),
|
||||
"up": bool(change_percent >= 0)
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not fetch data for {symbol}: {e}")
|
||||
return symbol, None
|
||||
|
||||
|
||||
def fetch_defense_stocks():
|
||||
tickers = ["RTX", "LMT", "NOC", "GD", "BA", "PLTR"]
|
||||
stocks_data = {}
|
||||
try:
|
||||
for t in tickers:
|
||||
try:
|
||||
ticker = yf.Ticker(t)
|
||||
hist = ticker.history(period="2d")
|
||||
if len(hist) >= 1:
|
||||
current_price = hist['Close'].iloc[-1]
|
||||
prev_close = hist['Close'].iloc[0] if len(hist) > 1 else current_price
|
||||
change_percent = ((current_price - prev_close) / prev_close) * 100 if prev_close else 0
|
||||
|
||||
stocks_data[t] = {
|
||||
"price": round(float(current_price), 2),
|
||||
"change_percent": round(float(change_percent), 2),
|
||||
"up": bool(change_percent >= 0)
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not fetch data for {t}: {e}")
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool:
|
||||
results = pool.map(lambda t: _fetch_single_ticker(t, "2d"), tickers)
|
||||
stocks_data = {sym: data for sym, data in results if data}
|
||||
latest_data['stocks'] = stocks_data
|
||||
_mark_fresh("stocks")
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching stocks: {e}")
|
||||
|
||||
def fetch_oil_prices():
|
||||
# CL=F is Crude Oil, BZ=F is Brent Crude
|
||||
tickers = {"WTI Crude": "CL=F", "Brent Crude": "BZ=F"}
|
||||
oil_data = {}
|
||||
try:
|
||||
for name, symbol in tickers.items():
|
||||
try:
|
||||
ticker = yf.Ticker(symbol)
|
||||
hist = ticker.history(period="5d")
|
||||
if len(hist) >= 2:
|
||||
current_price = hist['Close'].iloc[-1]
|
||||
prev_close = hist['Close'].iloc[-2]
|
||||
change_percent = ((current_price - prev_close) / prev_close) * 100 if prev_close else 0
|
||||
|
||||
oil_data[name] = {
|
||||
"price": round(float(current_price), 2),
|
||||
"change_percent": round(float(change_percent), 2),
|
||||
"up": bool(change_percent >= 0)
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not fetch data for {symbol}: {e}")
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
|
||||
results = pool.map(lambda item: (_fetch_single_ticker(item[1], "5d")[1], item[0]), tickers.items())
|
||||
oil_data = {name: data for data, name in results if data}
|
||||
latest_data['oil'] = oil_data
|
||||
_mark_fresh("oil")
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching oil: {e}")
|
||||
|
||||
@@ -608,6 +621,87 @@ _HELI_TYPES_BACKEND = {
|
||||
"B47G", "HUEY", "GAMA", "CABR", "EXE",
|
||||
}
|
||||
|
||||
|
||||
def _fetch_supplemental_sources(seen_hex: set) -> list:
|
||||
"""Fetch from airplanes.live and adsb.fi to fill blind-spot gaps.
|
||||
|
||||
Only returns aircraft whose ICAO hex is NOT already in seen_hex.
|
||||
Throttled to run every _SUPPLEMENTAL_FETCH_INTERVAL seconds.
|
||||
Fully wrapped in try/except — returns [] on any failure.
|
||||
"""
|
||||
global last_supplemental_fetch, cached_supplemental_flights
|
||||
|
||||
now = time.time()
|
||||
if now - last_supplemental_fetch < _SUPPLEMENTAL_FETCH_INTERVAL:
|
||||
# Return cached results, but still filter against current seen_hex
|
||||
return [f for f in cached_supplemental_flights
|
||||
if f.get("hex", "").lower().strip() not in seen_hex]
|
||||
|
||||
new_supplemental = []
|
||||
supplemental_hex = set() # track hex within supplemental to avoid internal dupes
|
||||
|
||||
# --- airplanes.live (parallel, all hotspots) ---
|
||||
def _fetch_airplaneslive(region):
|
||||
try:
|
||||
url = (f"https://api.airplanes.live/v2/point/"
|
||||
f"{region['lat']}/{region['lon']}/{region['radius_nm']}")
|
||||
res = fetch_with_curl(url, timeout=10)
|
||||
if res.status_code == 200:
|
||||
data = res.json()
|
||||
return data.get("ac", [])
|
||||
except Exception as e:
|
||||
logger.debug(f"airplanes.live {region['name']} failed: {e}")
|
||||
return []
|
||||
|
||||
try:
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool:
|
||||
results = list(pool.map(_fetch_airplaneslive, _BLIND_SPOT_REGIONS))
|
||||
for region_flights in results:
|
||||
for f in region_flights:
|
||||
h = f.get("hex", "").lower().strip()
|
||||
if h and h not in seen_hex and h not in supplemental_hex:
|
||||
f["supplemental_source"] = "airplanes.live"
|
||||
new_supplemental.append(f)
|
||||
supplemental_hex.add(h)
|
||||
except Exception as e:
|
||||
logger.warning(f"airplanes.live supplemental fetch failed: {e}")
|
||||
|
||||
ap_count = len(new_supplemental)
|
||||
|
||||
# --- adsb.fi (sequential, 1.1s between requests to respect 1 req/sec limit) ---
|
||||
try:
|
||||
for region in _BLIND_SPOT_REGIONS:
|
||||
try:
|
||||
url = (f"https://opendata.adsb.fi/api/v3/lat/"
|
||||
f"{region['lat']}/lon/{region['lon']}/dist/{region['radius_nm']}")
|
||||
res = fetch_with_curl(url, timeout=10)
|
||||
if res.status_code == 200:
|
||||
data = res.json()
|
||||
for f in data.get("ac", []):
|
||||
h = f.get("hex", "").lower().strip()
|
||||
if h and h not in seen_hex and h not in supplemental_hex:
|
||||
f["supplemental_source"] = "adsb.fi"
|
||||
new_supplemental.append(f)
|
||||
supplemental_hex.add(h)
|
||||
except Exception as e:
|
||||
logger.debug(f"adsb.fi {region['name']} failed: {e}")
|
||||
time.sleep(1.1) # Rate limit: 1 req/sec
|
||||
except Exception as e:
|
||||
logger.warning(f"adsb.fi supplemental fetch failed: {e}")
|
||||
|
||||
fi_count = len(new_supplemental) - ap_count
|
||||
|
||||
cached_supplemental_flights = new_supplemental
|
||||
last_supplemental_fetch = now
|
||||
if new_supplemental:
|
||||
_mark_fresh("supplemental_flights")
|
||||
|
||||
logger.info(f"Supplemental: +{len(new_supplemental)} new aircraft from blind-spot "
|
||||
f"hotspots (airplanes.live: {ap_count}, adsb.fi: {fi_count})")
|
||||
|
||||
return new_supplemental
|
||||
|
||||
|
||||
def fetch_flights():
|
||||
# OpenSky Network public API for flights. We want to demonstrate global coverage.
|
||||
flights = []
|
||||
@@ -708,7 +802,22 @@ def fetch_flights():
|
||||
all_adsb_flights.append(osf)
|
||||
seen_hex.add(h.lower().strip())
|
||||
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Supplemental Sources: airplanes.live + adsb.fi (blind-spot gap-fill)
|
||||
# Only adds aircraft whose ICAO hex is NOT already in seen_hex.
|
||||
# -------------------------------------------------------------------
|
||||
try:
|
||||
gap_fill = _fetch_supplemental_sources(seen_hex)
|
||||
for f in gap_fill:
|
||||
all_adsb_flights.append(f)
|
||||
h = f.get("hex", "").lower().strip()
|
||||
if h:
|
||||
seen_hex.add(h)
|
||||
if gap_fill:
|
||||
logger.info(f"Gap-fill: added {len(gap_fill)} aircraft to pipeline")
|
||||
except Exception as e:
|
||||
logger.warning(f"Supplemental source fetch failed (non-fatal): {e}")
|
||||
|
||||
if all_adsb_flights:
|
||||
|
||||
# The user requested maximum flight density. Rendering all available aircraft.
|
||||
@@ -899,6 +1008,8 @@ def fetch_flights():
|
||||
latest_data['private_jets'] = _merge_category(private_jets, latest_data.get('private_jets', []))
|
||||
latest_data['private_flights'] = _merge_category(private_ga, latest_data.get('private_flights', []))
|
||||
|
||||
_mark_fresh("commercial_flights", "private_jets", "private_flights")
|
||||
|
||||
# Always write raw flights for GPS jamming analysis (nac_p field)
|
||||
if flights:
|
||||
latest_data['flights'] = flights
|
||||
@@ -1117,31 +1228,65 @@ def fetch_ships():
|
||||
|
||||
logger.info(f"Ships: {len(carriers)} carriers + {len(ais_vessels)} AIS vessels")
|
||||
latest_data['ships'] = ships
|
||||
_mark_fresh("ships")
|
||||
|
||||
def fetch_military_flights():
|
||||
# True ADS-B Exchange military data requires paid API access.
|
||||
# We will use adsb.lol (an open source ADSB aggregator) /v2/mil fallback.
|
||||
military_flights = []
|
||||
detected_uavs = []
|
||||
try:
|
||||
url = "https://api.adsb.lol/v2/mil"
|
||||
response = fetch_with_curl(url, timeout=10)
|
||||
if response.status_code == 200:
|
||||
ac = response.json().get('ac', [])
|
||||
for f in ac:
|
||||
for f in ac:
|
||||
try:
|
||||
lat = f.get("lat")
|
||||
lng = f.get("lon")
|
||||
heading = f.get("track") or 0
|
||||
|
||||
|
||||
if lat is None or lng is None:
|
||||
continue
|
||||
|
||||
|
||||
model = str(f.get("t", "UNKNOWN")).upper()
|
||||
callsign = str(f.get("flight", "MIL-UNKN")).strip()
|
||||
|
||||
# Skip fixed structures (towers, oil platforms) that broadcast ADS-B
|
||||
if model == "TWR":
|
||||
continue
|
||||
|
||||
alt_raw = f.get("alt_baro")
|
||||
alt_value = 0
|
||||
if isinstance(alt_raw, (int, float)):
|
||||
alt_value = alt_raw * 0.3048
|
||||
|
||||
# Ground speed from ADS-B (in knots)
|
||||
gs_knots = f.get("gs")
|
||||
speed_knots = round(gs_knots, 1) if isinstance(gs_knots, (int, float)) else None
|
||||
|
||||
# Check if this is a UAV/drone before classifying as regular military
|
||||
is_uav, uav_type, wiki_url = _classify_uav(model, callsign)
|
||||
if is_uav:
|
||||
detected_uavs.append({
|
||||
"id": f"uav-{f.get('hex', '')}",
|
||||
"callsign": callsign,
|
||||
"aircraft_model": f.get("t", "Unknown"),
|
||||
"lat": float(lat),
|
||||
"lng": float(lng),
|
||||
"alt": alt_value,
|
||||
"heading": heading,
|
||||
"speed_knots": speed_knots,
|
||||
"country": f.get("r", "Unknown"),
|
||||
"uav_type": uav_type,
|
||||
"wiki": wiki_url or "",
|
||||
"type": "uav",
|
||||
"registration": f.get("r", "N/A"),
|
||||
"icao24": f.get("hex", ""),
|
||||
"squawk": f.get("squawk", ""),
|
||||
})
|
||||
continue # Don't double-count as military flight
|
||||
|
||||
mil_cat = "default"
|
||||
if "H" in model and any(c.isdigit() for c in model):
|
||||
mil_cat = "heli"
|
||||
@@ -1151,27 +1296,11 @@ def fetch_military_flights():
|
||||
mil_cat = "fighter"
|
||||
elif any(k in model for k in ["C17", "C5", "C130", "C30", "A400", "V22"]):
|
||||
mil_cat = "cargo"
|
||||
elif any(k in model for k in ["P8", "E3", "E8", "U2", "RQ", "MQ"]):
|
||||
elif any(k in model for k in ["P8", "E3", "E8", "U2"]):
|
||||
mil_cat = "recon"
|
||||
|
||||
# Military flights don't file public routes
|
||||
origin_loc = None
|
||||
dest_loc = None
|
||||
origin_name = "UNKNOWN"
|
||||
dest_name = "UNKNOWN"
|
||||
|
||||
|
||||
alt_raw = f.get("alt_baro")
|
||||
alt_value = 0
|
||||
if isinstance(alt_raw, (int, float)):
|
||||
alt_value = alt_raw * 0.3048
|
||||
|
||||
# Ground speed from ADS-B (in knots)
|
||||
gs_knots = f.get("gs")
|
||||
speed_knots = round(gs_knots, 1) if isinstance(gs_knots, (int, float)) else None
|
||||
|
||||
military_flights.append({
|
||||
"callsign": str(f.get("flight", "MIL-UNKN")).strip(),
|
||||
"callsign": callsign,
|
||||
"country": f.get("r", "Military Asset"),
|
||||
"lng": float(lng),
|
||||
"lat": float(lat),
|
||||
@@ -1179,10 +1308,10 @@ def fetch_military_flights():
|
||||
"heading": heading,
|
||||
"type": "military_flight",
|
||||
"military_type": mil_cat,
|
||||
"origin_loc": origin_loc,
|
||||
"dest_loc": dest_loc,
|
||||
"origin_name": origin_name,
|
||||
"dest_name": dest_name,
|
||||
"origin_loc": None,
|
||||
"dest_loc": None,
|
||||
"origin_name": "UNKNOWN",
|
||||
"dest_name": "UNKNOWN",
|
||||
"registration": f.get("r", "N/A"),
|
||||
"model": f.get("t", "Unknown"),
|
||||
"icao24": f.get("hex", ""),
|
||||
@@ -1194,15 +1323,18 @@ def fetch_military_flights():
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching military flights: {e}")
|
||||
|
||||
if not military_flights:
|
||||
|
||||
if not military_flights and not detected_uavs:
|
||||
# API failed or rate limited — log but do NOT inject fake data
|
||||
logger.warning("No military flights retrieved — keeping previous data if available")
|
||||
# Preserve existing data rather than overwriting with empty
|
||||
if latest_data.get('military_flights'):
|
||||
return
|
||||
|
||||
|
||||
latest_data['military_flights'] = military_flights
|
||||
latest_data['uavs'] = detected_uavs
|
||||
_mark_fresh("military_flights", "uavs")
|
||||
logger.info(f"UAVs: {len(detected_uavs)} real drones detected via ADS-B")
|
||||
|
||||
# Cross-reference military flights with Plane-Alert DB
|
||||
tracked_mil = []
|
||||
@@ -1254,12 +1386,14 @@ def fetch_weather():
|
||||
if "radar" in data and "past" in data["radar"]:
|
||||
latest_time = data["radar"]["past"][-1]["time"]
|
||||
latest_data["weather"] = {"time": latest_time, "host": data.get("host", "https://tilecache.rainviewer.com")}
|
||||
_mark_fresh("weather")
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching weather: {e}")
|
||||
|
||||
def fetch_cctv():
|
||||
try:
|
||||
latest_data["cctv"] = get_all_cameras()
|
||||
_mark_fresh("cctv")
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching cctv from DB: {e}")
|
||||
latest_data["cctv"] = []
|
||||
@@ -1268,6 +1402,7 @@ def fetch_kiwisdr():
|
||||
try:
|
||||
from services.kiwisdr_fetcher import fetch_kiwisdr_nodes
|
||||
latest_data["kiwisdr"] = fetch_kiwisdr_nodes()
|
||||
_mark_fresh("kiwisdr")
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching KiwiSDR nodes: {e}")
|
||||
latest_data["kiwisdr"] = []
|
||||
@@ -1303,13 +1438,14 @@ def fetch_firms_fires():
|
||||
})
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
# Sort by FRP descending, keep top 5000 (most intense fires first)
|
||||
all_rows.sort(key=lambda x: x["frp"], reverse=True)
|
||||
fires = all_rows[:5000]
|
||||
# Keep top 5000 by FRP (most intense fires first) — heapq is O(n) vs O(n log n) sort
|
||||
fires = heapq.nlargest(5000, all_rows, key=lambda x: x["frp"])
|
||||
logger.info(f"FIRMS fires: {len(fires)} hotspots (from {response.status_code})")
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching FIRMS fires: {e}")
|
||||
latest_data["firms_fires"] = fires
|
||||
if fires:
|
||||
_mark_fresh("firms_fires")
|
||||
|
||||
def fetch_space_weather():
|
||||
"""Fetch NOAA SWPC Kp index and recent solar events."""
|
||||
@@ -1348,6 +1484,7 @@ def fetch_space_weather():
|
||||
"kp_text": kp_text,
|
||||
"events": events,
|
||||
}
|
||||
_mark_fresh("space_weather")
|
||||
logger.info(f"Space weather: Kp={kp_value} ({kp_text}), {len(events)} events")
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching space weather: {e}")
|
||||
@@ -1438,13 +1575,135 @@ def fetch_internet_outages():
|
||||
r["lat"] = coords[0]
|
||||
r["lng"] = coords[1]
|
||||
geocoded.append(r)
|
||||
# Sort by severity descending, cap at 100
|
||||
geocoded.sort(key=lambda x: x["severity"], reverse=True)
|
||||
outages = geocoded[:100]
|
||||
# Keep top 100 by severity
|
||||
outages = heapq.nlargest(100, geocoded, key=lambda x: x["severity"])
|
||||
logger.info(f"Internet outages: {len(outages)} regions affected")
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching internet outages: {e}")
|
||||
latest_data["internet_outages"] = outages
|
||||
if outages:
|
||||
_mark_fresh("internet_outages")
|
||||
|
||||
_DC_CACHE_PATH = Path(__file__).parent.parent / "data" / "datacenters.json"
|
||||
_DC_URL = "https://raw.githubusercontent.com/Ringmast4r/Data-Center-Map---Global/1f290297c6a11454dc7a47bf95aef7cf0fe1d34c/datacenters_cleaned.json"
|
||||
|
||||
# Country bounding boxes (lat_min, lat_max, lng_min, lng_max) for coordinate validation.
|
||||
# The source dataset has abs(lat) for all Southern Hemisphere entries, so we fix the sign
|
||||
# and then validate the result falls within the country's bounding box.
|
||||
_COUNTRY_BBOX: dict[str, tuple[float, float, float, float]] = {
|
||||
"Argentina": (-55, -21, -74, -53), "Australia": (-44, -10, 112, 154),
|
||||
"Bolivia": (-23, -9, -70, -57), "Brazil": (-34, 6, -74, -34),
|
||||
"Chile": (-56, -17, -76, -66), "Colombia": (-5, 13, -82, -66),
|
||||
"Ecuador": (-5, 2, -81, -75), "Indonesia": (-11, 6, 95, 141),
|
||||
"Kenya": (-5, 5, 34, 42), "Madagascar": (-26, -12, 43, 51),
|
||||
"Mozambique": (-27, -10, 30, 41), "New Zealand": (-47, -34, 166, 179),
|
||||
"Paraguay": (-28, -19, -63, -54), "Peru": (-18, 0, -82, -68),
|
||||
"South Africa": (-35, -22, 16, 33), "Tanzania": (-12, -1, 29, 41),
|
||||
"Uruguay": (-35, -30, -59, -53), "Zimbabwe": (-23, -15, 25, 34),
|
||||
# Northern-hemisphere countries for validation only
|
||||
"United States": (24, 72, -180, -65), "Canada": (41, 84, -141, -52),
|
||||
"United Kingdom": (49, 61, -9, 2), "Germany": (47, 55, 5, 16),
|
||||
"France": (41, 51, -5, 10), "Japan": (24, 46, 123, 146),
|
||||
"India": (6, 36, 68, 98), "China": (18, 54, 73, 135),
|
||||
"Singapore": (1, 2, 103, 105), "Spain": (36, 44, -10, 5),
|
||||
"Netherlands": (50, 54, 3, 8), "Sweden": (55, 70, 11, 25),
|
||||
"Italy": (36, 47, 6, 19), "Russia": (41, 82, 19, 180),
|
||||
"Mexico": (14, 33, -118, -86), "Nigeria": (4, 14, 2, 15),
|
||||
"Thailand": (5, 21, 97, 106), "Malaysia": (0, 8, 99, 120),
|
||||
"Philippines": (4, 21, 116, 127), "South Korea": (33, 39, 124, 132),
|
||||
"Taiwan": (21, 26, 119, 123), "Hong Kong": (22, 23, 113, 115),
|
||||
"Vietnam": (8, 24, 102, 110), "Poland": (49, 55, 14, 25),
|
||||
"Switzerland": (45, 48, 5, 11), "Austria": (46, 49, 9, 17),
|
||||
"Belgium": (49, 52, 2, 7), "Denmark": (54, 58, 8, 16),
|
||||
"Finland": (59, 70, 20, 32), "Norway": (57, 72, 4, 32),
|
||||
"Ireland": (51, 56, -11, -5), "Portugal": (36, 42, -10, -6),
|
||||
"Turkey": (35, 42, 25, 45), "Israel": (29, 34, 34, 36),
|
||||
"UAE": (22, 27, 51, 56), "Saudi Arabia": (16, 33, 34, 56),
|
||||
}
|
||||
|
||||
# Countries whose DCs always sit south of the equator
|
||||
_SOUTHERN_COUNTRIES = {
|
||||
"Argentina", "Australia", "Bolivia", "Brazil", "Chile", "Madagascar",
|
||||
"Mozambique", "New Zealand", "Paraguay", "Peru", "South Africa",
|
||||
"Tanzania", "Uruguay", "Zimbabwe",
|
||||
}
|
||||
|
||||
|
||||
def _fix_dc_coords(lat: float, lng: float, country: str) -> tuple[float, float] | None:
|
||||
"""Fix and validate data-center coordinates against the stated country.
|
||||
|
||||
The source dataset stores abs(lat) for Southern-Hemisphere entries.
|
||||
We negate lat when the country is in the Southern Hemisphere, then
|
||||
validate the result falls within the country bounding box (if known).
|
||||
Returns corrected (lat, lng) or None if the coords are clearly wrong.
|
||||
"""
|
||||
# Fix Southern Hemisphere sign
|
||||
if country in _SOUTHERN_COUNTRIES and lat > 0:
|
||||
lat = -lat
|
||||
|
||||
bbox = _COUNTRY_BBOX.get(country)
|
||||
if bbox:
|
||||
lat_min, lat_max, lng_min, lng_max = bbox
|
||||
if lat_min <= lat <= lat_max and lng_min <= lng <= lng_max:
|
||||
return lat, lng
|
||||
# Try swapping sign as last resort (some entries are just wrong sign)
|
||||
if lat_min <= -lat <= lat_max and lng_min <= lng <= lng_max:
|
||||
return -lat, lng
|
||||
# Coords don't match country at all — drop the entry
|
||||
return None
|
||||
|
||||
# No bbox for this country — basic sanity only
|
||||
return lat, lng
|
||||
|
||||
|
||||
def fetch_datacenters():
|
||||
"""Load data center locations (static dataset, cached locally after first fetch)."""
|
||||
dcs = []
|
||||
try:
|
||||
raw = None
|
||||
# Use local cache if it exists and is less than 7 days old
|
||||
if _DC_CACHE_PATH.exists():
|
||||
age_days = (time.time() - _DC_CACHE_PATH.stat().st_mtime) / 86400
|
||||
if age_days < 7:
|
||||
raw = json.loads(_DC_CACHE_PATH.read_text(encoding="utf-8"))
|
||||
# Otherwise fetch from GitHub
|
||||
if raw is None:
|
||||
resp = fetch_with_curl(_DC_URL, timeout=20)
|
||||
if resp.status_code == 200:
|
||||
raw = resp.json()
|
||||
_DC_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
_DC_CACHE_PATH.write_text(json.dumps(raw), encoding="utf-8")
|
||||
if raw:
|
||||
dropped = 0
|
||||
for entry in raw:
|
||||
coords = entry.get("city_coords")
|
||||
if not coords or not isinstance(coords, list) or len(coords) < 2:
|
||||
continue
|
||||
lat, lng = coords[0], coords[1]
|
||||
if not (-90 <= lat <= 90 and -180 <= lng <= 180):
|
||||
continue
|
||||
country = entry.get("country", "")
|
||||
fixed = _fix_dc_coords(lat, lng, country)
|
||||
if fixed is None:
|
||||
dropped += 1
|
||||
continue
|
||||
lat, lng = fixed
|
||||
dcs.append({
|
||||
"name": entry.get("name", "Unknown"),
|
||||
"company": entry.get("company", ""),
|
||||
"city": entry.get("city", ""),
|
||||
"country": country,
|
||||
"lat": lat,
|
||||
"lng": lng,
|
||||
})
|
||||
if dropped:
|
||||
logger.info(f"Data centers: dropped {dropped} entries with mismatched coordinates")
|
||||
logger.info(f"Data centers: {len(dcs)} with valid coordinates (from {'cache' if _DC_CACHE_PATH.exists() else 'GitHub'})")
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching data centers: {e}")
|
||||
latest_data["datacenters"] = dcs
|
||||
if dcs:
|
||||
_mark_fresh("datacenters")
|
||||
|
||||
def fetch_bikeshare():
|
||||
bikes = []
|
||||
@@ -1503,6 +1762,8 @@ def fetch_earthquakes():
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching earthquakes: {e}")
|
||||
latest_data["earthquakes"] = quakes
|
||||
if quakes:
|
||||
_mark_fresh("earthquakes")
|
||||
|
||||
# Satellite GP data cache — re-download from CelesTrak only every 30 minutes
|
||||
_sat_gp_cache = {"data": None, "last_fetch": 0}
|
||||
@@ -1802,79 +2063,78 @@ def fetch_satellites():
|
||||
# Only overwrite if we got data — don't wipe the map on API timeout
|
||||
if sats:
|
||||
latest_data["satellites"] = sats
|
||||
_mark_fresh("satellites")
|
||||
elif not latest_data.get("satellites"):
|
||||
latest_data["satellites"] = []
|
||||
|
||||
def fetch_uavs():
|
||||
# Simulated high-altitude long-endurance (HALE) and MALE UAVs over high-risk regions
|
||||
|
||||
uav_targets = [
|
||||
{
|
||||
"name": "RQ-4 Global Hawk", "center": [31.5, 34.8], "radius": 0.5, "alt": 15000,
|
||||
"country": "USA", "uav_type": "HALE Surveillance", "range_km": 2200,
|
||||
"wiki": "https://en.wikipedia.org/wiki/Northrop_Grumman_RQ-4_Global_Hawk",
|
||||
"speed_knots": 340
|
||||
},
|
||||
{
|
||||
"name": "MQ-9 Reaper", "center": [49.0, 31.4], "radius": 1.2, "alt": 12000,
|
||||
"country": "USA", "uav_type": "MALE Strike/ISR", "range_km": 1850,
|
||||
"wiki": "https://en.wikipedia.org/wiki/General_Atomics_MQ-9_Reaper",
|
||||
"speed_knots": 250
|
||||
},
|
||||
{
|
||||
"name": "Bayraktar TB2", "center": [23.6, 120.9], "radius": 0.8, "alt": 8000,
|
||||
"country": "Turkey", "uav_type": "MALE Strike", "range_km": 150,
|
||||
"wiki": "https://en.wikipedia.org/wiki/Bayraktar_TB2",
|
||||
"speed_knots": 120
|
||||
},
|
||||
{
|
||||
"name": "MQ-1C Gray Eagle", "center": [38.0, 127.0], "radius": 0.4, "alt": 10000,
|
||||
"country": "USA", "uav_type": "MALE ISR/Strike", "range_km": 400,
|
||||
"wiki": "https://en.wikipedia.org/wiki/General_Atomics_MQ-1C_Gray_Eagle",
|
||||
"speed_knots": 150
|
||||
},
|
||||
{
|
||||
"name": "RQ-170 Sentinel", "center": [25.0, 55.0], "radius": 1.5, "alt": 18000,
|
||||
"country": "USA", "uav_type": "Stealth ISR", "range_km": 1100,
|
||||
"wiki": "https://en.wikipedia.org/wiki/Lockheed_Martin_RQ-170_Sentinel",
|
||||
"speed_knots": 300
|
||||
}
|
||||
]
|
||||
|
||||
# Use the current hour and minute to create a continuous slow orbit
|
||||
now = datetime.utcnow()
|
||||
# 1 full orbit every 10 minutes
|
||||
time_factor = ((now.minute % 10) * 60 + now.second) / 600.0
|
||||
angle = time_factor * 2 * math.pi
|
||||
|
||||
uavs = []
|
||||
for idx, t in enumerate(uav_targets):
|
||||
# Offset the angle slightly so they aren't all synchronized
|
||||
offset_angle = angle + (idx * math.pi / 2.5)
|
||||
|
||||
lat = t["center"][0] + math.sin(offset_angle) * t["radius"]
|
||||
lng = t["center"][1] + math.cos(offset_angle) * t["radius"]
|
||||
|
||||
heading = (math.degrees(offset_angle) + 90) % 360
|
||||
|
||||
uavs.append({
|
||||
"id": f"uav-{idx}",
|
||||
"callsign": t["name"],
|
||||
"aircraft_model": t["name"],
|
||||
"lat": lat,
|
||||
"lng": lng,
|
||||
"alt": t["alt"],
|
||||
"heading": heading,
|
||||
"speed_knots": t["speed_knots"],
|
||||
"center": t["center"],
|
||||
"orbit_radius": t["radius"],
|
||||
"range_km": t["range_km"],
|
||||
"country": t["country"],
|
||||
"uav_type": t["uav_type"],
|
||||
"wiki": t["wiki"],
|
||||
})
|
||||
|
||||
latest_data['uavs'] = uavs
|
||||
# ---------------------------------------------------------------------------
|
||||
# Real UAV detection from ADS-B data — filters military drone transponders
|
||||
# ---------------------------------------------------------------------------
|
||||
_UAV_TYPE_CODES = {"Q9", "R4", "TB2", "MALE", "HALE", "HERM", "HRON"}
|
||||
_UAV_CALLSIGN_PREFIXES = ("FORTE", "GHAWK", "REAP", "BAMS", "UAV", "UAS")
|
||||
_UAV_MODEL_KEYWORDS = ("RQ-", "MQ-", "RQ4", "MQ9", "MQ4", "MQ1", "REAPER", "GLOBALHAWK", "TRITON", "PREDATOR", "HERMES", "HERON", "BAYRAKTAR")
|
||||
_UAV_WIKI = {
|
||||
"RQ4": "https://en.wikipedia.org/wiki/Northrop_Grumman_RQ-4_Global_Hawk",
|
||||
"RQ-4": "https://en.wikipedia.org/wiki/Northrop_Grumman_RQ-4_Global_Hawk",
|
||||
"MQ4": "https://en.wikipedia.org/wiki/Northrop_Grumman_MQ-4C_Triton",
|
||||
"MQ-4": "https://en.wikipedia.org/wiki/Northrop_Grumman_MQ-4C_Triton",
|
||||
"MQ9": "https://en.wikipedia.org/wiki/General_Atomics_MQ-9_Reaper",
|
||||
"MQ-9": "https://en.wikipedia.org/wiki/General_Atomics_MQ-9_Reaper",
|
||||
"MQ1": "https://en.wikipedia.org/wiki/General_Atomics_MQ-1C_Gray_Eagle",
|
||||
"MQ-1": "https://en.wikipedia.org/wiki/General_Atomics_MQ-1C_Gray_Eagle",
|
||||
"REAPER": "https://en.wikipedia.org/wiki/General_Atomics_MQ-9_Reaper",
|
||||
"GLOBALHAWK": "https://en.wikipedia.org/wiki/Northrop_Grumman_RQ-4_Global_Hawk",
|
||||
"TRITON": "https://en.wikipedia.org/wiki/Northrop_Grumman_MQ-4C_Triton",
|
||||
"PREDATOR": "https://en.wikipedia.org/wiki/General_Atomics_MQ-1_Predator",
|
||||
"HERMES": "https://en.wikipedia.org/wiki/Elbit_Hermes_900",
|
||||
"HERON": "https://en.wikipedia.org/wiki/IAI_Heron",
|
||||
"BAYRAKTAR": "https://en.wikipedia.org/wiki/Bayraktar_TB2",
|
||||
}
|
||||
|
||||
def _classify_uav(model: str, callsign: str):
|
||||
"""Check if an aircraft is a UAV based on type code, callsign prefix, or model keywords.
|
||||
Returns (is_uav, uav_type, wiki_url) or (False, None, None)."""
|
||||
model_up = model.upper().replace(" ", "")
|
||||
callsign_up = callsign.upper().strip()
|
||||
|
||||
# Check ICAO type codes
|
||||
if model_up in _UAV_TYPE_CODES:
|
||||
uav_type = "HALE Surveillance" if model_up in ("R4", "HALE") else "MALE ISR"
|
||||
wiki = _UAV_WIKI.get(model_up, "")
|
||||
return True, uav_type, wiki
|
||||
|
||||
# Check callsign prefixes (must also have a military-ish model)
|
||||
for prefix in _UAV_CALLSIGN_PREFIXES:
|
||||
if callsign_up.startswith(prefix):
|
||||
uav_type = "HALE Surveillance" if prefix in ("FORTE", "GHAWK", "BAMS") else "MALE ISR"
|
||||
wiki = _UAV_WIKI.get(prefix, "")
|
||||
if prefix == "FORTE":
|
||||
wiki = _UAV_WIKI["RQ4"]
|
||||
elif prefix == "BAMS":
|
||||
wiki = _UAV_WIKI["MQ4"]
|
||||
return True, uav_type, wiki
|
||||
|
||||
# Check model keywords
|
||||
for kw in _UAV_MODEL_KEYWORDS:
|
||||
if kw in model_up:
|
||||
# Determine type from keyword
|
||||
if any(h in model_up for h in ("RQ4", "RQ-4", "GLOBALHAWK")):
|
||||
return True, "HALE Surveillance", _UAV_WIKI.get(kw, "")
|
||||
elif any(h in model_up for h in ("MQ4", "MQ-4", "TRITON")):
|
||||
return True, "HALE Maritime Surveillance", _UAV_WIKI.get(kw, "")
|
||||
elif any(h in model_up for h in ("MQ9", "MQ-9", "REAPER")):
|
||||
return True, "MALE Strike/ISR", _UAV_WIKI.get(kw, "")
|
||||
elif any(h in model_up for h in ("MQ1", "MQ-1", "PREDATOR")):
|
||||
return True, "MALE ISR/Strike", _UAV_WIKI.get(kw, "")
|
||||
elif "BAYRAKTAR" in model_up or "TB2" in model_up:
|
||||
return True, "MALE Strike", _UAV_WIKI.get("BAYRAKTAR", "")
|
||||
elif "HERMES" in model_up:
|
||||
return True, "MALE ISR", _UAV_WIKI.get("HERMES", "")
|
||||
elif "HERON" in model_up:
|
||||
return True, "MALE ISR", _UAV_WIKI.get("HERON", "")
|
||||
return True, "MALE ISR", _UAV_WIKI.get(kw, "")
|
||||
|
||||
return False, None, None
|
||||
|
||||
cached_airports = []
|
||||
flight_trails = {} # {icao_hex: {points: [[lat, lng, alt, ts], ...], last_seen: ts}}
|
||||
@@ -1956,10 +2216,12 @@ def fetch_geopolitics():
|
||||
frontlines = fetch_ukraine_frontlines()
|
||||
if frontlines:
|
||||
latest_data['frontlines'] = frontlines
|
||||
_mark_fresh("frontlines")
|
||||
|
||||
gdelt = fetch_global_military_incidents()
|
||||
if gdelt is not None:
|
||||
latest_data['gdelt'] = gdelt
|
||||
_mark_fresh("gdelt")
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching geopolitics: {e}")
|
||||
|
||||
@@ -1970,6 +2232,7 @@ def update_liveuamap():
|
||||
res = fetch_liveuamap()
|
||||
if res:
|
||||
latest_data['liveuamap'] = res
|
||||
_mark_fresh("liveuamap")
|
||||
except Exception as e:
|
||||
logger.error(f"Liveuamap scraper error: {e}")
|
||||
|
||||
@@ -1978,9 +2241,8 @@ def update_fast_data():
|
||||
logger.info("Fast-tier data update starting...")
|
||||
fast_funcs = [
|
||||
fetch_flights,
|
||||
fetch_military_flights,
|
||||
fetch_military_flights, # Also detects UAVs from ADS-B
|
||||
fetch_ships,
|
||||
fetch_uavs,
|
||||
fetch_satellites,
|
||||
]
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=len(fast_funcs)) as executor:
|
||||
@@ -2005,6 +2267,7 @@ def update_slow_data():
|
||||
fetch_space_weather,
|
||||
fetch_internet_outages,
|
||||
fetch_firms_fires,
|
||||
fetch_datacenters,
|
||||
]
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=len(slow_funcs)) as executor:
|
||||
futures = [executor.submit(func) for func in slow_funcs]
|
||||
@@ -2030,7 +2293,7 @@ def start_scheduler():
|
||||
# Run full update once on startup
|
||||
scheduler.add_job(update_all_data, 'date', run_date=datetime.now())
|
||||
|
||||
# Fast tier: every 60 seconds (flights, ships, military, satellites, UAVs)
|
||||
# Fast tier: every 60 seconds (flights, ships, military+UAVs, satellites)
|
||||
scheduler.add_job(update_fast_data, 'interval', seconds=60)
|
||||
|
||||
# Slow tier: every 30 minutes (news, stocks, weather, geopolitics)
|
||||
@@ -2059,8 +2322,8 @@ def start_scheduler():
|
||||
scheduler.add_job(update_liveuamap, 'date', run_date=datetime.now())
|
||||
scheduler.add_job(update_liveuamap, 'interval', hours=12)
|
||||
|
||||
# Geopolitics (frontlines) more frequently than other slow data
|
||||
scheduler.add_job(fetch_geopolitics, 'interval', minutes=5)
|
||||
# Geopolitics (frontlines) aligned with slow-data tier
|
||||
scheduler.add_job(fetch_geopolitics, 'interval', minutes=30)
|
||||
|
||||
scheduler.start()
|
||||
|
||||
|
||||
+153
-27
@@ -86,8 +86,10 @@ def _extract_domain(url):
|
||||
|
||||
def _url_to_headline(url):
|
||||
"""Extract a human-readable headline from a URL path.
|
||||
e.g. 'https://nytimes.com/2026/03/us-strikes-iran-nuclear-sites.html' -> 'Us Strikes Iran Nuclear Sites (nytimes.com)'
|
||||
e.g. 'https://nytimes.com/2026/03/us-strikes-iran-nuclear-sites.html' -> 'Us Strikes Iran Nuclear Sites'
|
||||
Falls back to domain name if the URL slug is gibberish (hex IDs, UUIDs, etc.).
|
||||
"""
|
||||
import re
|
||||
try:
|
||||
from urllib.parse import urlparse, unquote
|
||||
parsed = urlparse(url)
|
||||
@@ -100,43 +102,151 @@ def _url_to_headline(url):
|
||||
if not path:
|
||||
return domain
|
||||
|
||||
# Take the last path segment (usually the slug)
|
||||
slug = path.split('/')[-1]
|
||||
# Remove file extensions
|
||||
for ext in ['.html', '.htm', '.php', '.asp', '.aspx', '.shtml']:
|
||||
if slug.lower().endswith(ext):
|
||||
slug = slug[:-len(ext)]
|
||||
# If slug is purely numeric or a short ID, try the second-to-last segment
|
||||
import re
|
||||
if re.match(r'^[a-z]?\d{5,}$', slug, re.IGNORECASE):
|
||||
segments = path.split('/')
|
||||
if len(segments) >= 2:
|
||||
slug = segments[-2]
|
||||
for ext in ['.html', '.htm', '.php']:
|
||||
if slug.lower().endswith(ext):
|
||||
slug = slug[:-len(ext)]
|
||||
# Try the last path segment first, then walk backwards
|
||||
segments = [s for s in path.split('/') if s]
|
||||
slug = ''
|
||||
for seg in reversed(segments):
|
||||
# Remove file extensions
|
||||
for ext in ['.html', '.htm', '.php', '.asp', '.aspx', '.shtml']:
|
||||
if seg.lower().endswith(ext):
|
||||
seg = seg[:-len(ext)]
|
||||
# Skip segments that are clearly not headlines
|
||||
if _is_gibberish(seg):
|
||||
continue
|
||||
slug = seg
|
||||
break
|
||||
|
||||
if not slug:
|
||||
return domain
|
||||
|
||||
# Remove common ID patterns at start/end
|
||||
slug = re.sub(r'^[\d]+-', '', slug) # leading numbers like "13847569-"
|
||||
slug = re.sub(r'-[\da-f]{6,}$', '', slug) # trailing hex IDs
|
||||
slug = re.sub(r'[-_]c-\d+$', '', slug) # trailing "-c-21803431"
|
||||
slug = re.sub(r'^p=\d+$', '', slug) # WordPress ?p=1234
|
||||
slug = re.sub(r'^[\d]+-', '', slug) # leading "13847569-"
|
||||
slug = re.sub(r'-[\da-f]{6,}$', '', slug) # trailing hex IDs
|
||||
slug = re.sub(r'[-_]c-\d+$', '', slug) # trailing "-c-21803431"
|
||||
slug = re.sub(r'^p=\d+$', '', slug) # WordPress ?p=1234
|
||||
# Convert slug separators to spaces
|
||||
slug = slug.replace('-', ' ').replace('_', ' ')
|
||||
# Clean up multiple spaces
|
||||
slug = re.sub(r'\s+', ' ', slug).strip()
|
||||
|
||||
# If slug is still just a number or too short, fall back to domain
|
||||
if len(slug) < 5 or re.match(r'^\d+$', slug):
|
||||
# Final gibberish check after cleanup
|
||||
if len(slug) < 8 or _is_gibberish(slug.replace(' ', '-')):
|
||||
return domain
|
||||
|
||||
# Title case and truncate
|
||||
headline = slug.title()
|
||||
if len(headline) > 80:
|
||||
headline = headline[:77] + '...'
|
||||
return f"{headline} ({domain})"
|
||||
if len(headline) > 90:
|
||||
headline = headline[:87] + '...'
|
||||
return headline
|
||||
except Exception:
|
||||
return url[:60]
|
||||
|
||||
|
||||
def _is_gibberish(text):
|
||||
"""Detect if a URL segment is gibberish (hex IDs, UUIDs, numeric IDs, etc.)
|
||||
rather than a real human-readable slug like 'us-strikes-iran'."""
|
||||
import re
|
||||
t = text.strip()
|
||||
if not t:
|
||||
return True
|
||||
# Pure numbers
|
||||
if re.match(r'^\d+$', t):
|
||||
return True
|
||||
# UUID pattern (with or without dashes)
|
||||
if re.match(r'^[0-9a-f]{8}[_-]?[0-9a-f]{4}[_-]?[0-9a-f]{4}[_-]?[0-9a-f]{4}[_-]?[0-9a-f]{12}$', t, re.I):
|
||||
return True
|
||||
# Hex-heavy string: more than 40% hex digits among alphanumeric chars
|
||||
alnum = re.sub(r'[^a-zA-Z0-9]', '', t)
|
||||
if alnum:
|
||||
hex_chars = sum(1 for c in alnum if c in '0123456789abcdefABCDEF')
|
||||
if hex_chars / len(alnum) > 0.4 and len(alnum) > 6:
|
||||
return True
|
||||
# Mostly digits with a few alpha (like "article8efa6c53")
|
||||
digits = sum(1 for c in alnum if c.isdigit())
|
||||
if alnum and digits / len(alnum) > 0.5:
|
||||
return True
|
||||
# Too short to be a headline slug
|
||||
if len(t) < 5:
|
||||
return True
|
||||
# Query-param style segments
|
||||
if '=' in t:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# Persistent cache for article titles — survives across GDELT cache refreshes
|
||||
_article_title_cache = {}
|
||||
|
||||
def _fetch_article_title(url):
|
||||
"""Fetch the real headline from an article's HTML <title> or og:title tag.
|
||||
Returns the title string, or None if it can't be fetched.
|
||||
Uses a persistent cache to avoid refetching."""
|
||||
if url in _article_title_cache:
|
||||
return _article_title_cache[url]
|
||||
|
||||
import re
|
||||
try:
|
||||
# Only read the first 32KB — the <title> is always in <head>
|
||||
resp = requests.get(url, timeout=4, headers={
|
||||
'User-Agent': 'Mozilla/5.0 (compatible; OSINT Dashboard/1.0)'
|
||||
}, stream=True)
|
||||
if resp.status_code != 200:
|
||||
_article_title_cache[url] = None
|
||||
return None
|
||||
|
||||
chunk = resp.raw.read(32768).decode('utf-8', errors='replace')
|
||||
resp.close()
|
||||
|
||||
title = None
|
||||
|
||||
# Try og:title first (usually the cleanest)
|
||||
og_match = re.search(r'<meta[^>]+property=["\']og:title["\'][^>]+content=["\']([^"\'>]+)["\']', chunk, re.I)
|
||||
if not og_match:
|
||||
og_match = re.search(r'<meta[^>]+content=["\']([^"\'>]+)["\'][^>]+property=["\']og:title["\']', chunk, re.I)
|
||||
if og_match:
|
||||
title = og_match.group(1).strip()
|
||||
|
||||
# Fall back to <title> tag
|
||||
if not title:
|
||||
title_match = re.search(r'<title[^>]*>([^<]+)</title>', chunk, re.I)
|
||||
if title_match:
|
||||
title = title_match.group(1).strip()
|
||||
|
||||
if title:
|
||||
# Clean up HTML entities
|
||||
import html as html_mod
|
||||
title = html_mod.unescape(title)
|
||||
# Remove site name suffixes like " | CNN" or " - BBC News"
|
||||
title = re.sub(r'\s*[|\-–—]\s*[^|\-–—]{2,30}$', '', title).strip()
|
||||
# Truncate very long titles
|
||||
if len(title) > 120:
|
||||
title = title[:117] + '...'
|
||||
if len(title) > 10:
|
||||
_article_title_cache[url] = title
|
||||
return title
|
||||
|
||||
_article_title_cache[url] = None
|
||||
return None
|
||||
except Exception:
|
||||
_article_title_cache[url] = None
|
||||
return None
|
||||
|
||||
|
||||
def _batch_fetch_titles(urls):
|
||||
"""Fetch real article titles for a list of URLs in parallel.
|
||||
Returns a dict of url -> title (or None if fetch failed)."""
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
results = {}
|
||||
with ThreadPoolExecutor(max_workers=16) as executor:
|
||||
futures = {executor.submit(_fetch_article_title, u): u for u in urls}
|
||||
for future in futures:
|
||||
url = futures[future]
|
||||
try:
|
||||
results[url] = future.result()
|
||||
except Exception:
|
||||
results[url] = None
|
||||
return results
|
||||
|
||||
|
||||
def _parse_gdelt_export_zip(zip_bytes, conflict_codes, seen_locs, features, loc_index):
|
||||
"""Parse a single GDELT export ZIP and append conflict features.
|
||||
loc_index maps loc_key -> index in features list for fast duplicate merging.
|
||||
@@ -278,11 +388,27 @@ def fetch_global_military_incidents():
|
||||
if zip_bytes:
|
||||
_parse_gdelt_export_zip(zip_bytes, CONFLICT_CODES, seen_locs, features, loc_index)
|
||||
|
||||
# Collect all unique article URLs for batch title fetching
|
||||
all_article_urls = set()
|
||||
for f in features:
|
||||
for u in f["properties"].get("_urls", []):
|
||||
if u:
|
||||
all_article_urls.add(u)
|
||||
|
||||
logger.info(f"Fetching real article titles for {len(all_article_urls)} unique URLs...")
|
||||
fetched_titles = _batch_fetch_titles(all_article_urls)
|
||||
fetched_count = sum(1 for v in fetched_titles.values() if v)
|
||||
logger.info(f"Resolved {fetched_count}/{len(all_article_urls)} article titles from HTML")
|
||||
|
||||
# Build URL + headline arrays for frontend rendering
|
||||
for f in features:
|
||||
urls = f["properties"].pop("_urls", [])
|
||||
f["properties"].pop("_domains", None)
|
||||
headlines = [_url_to_headline(u) for u in urls]
|
||||
headlines = []
|
||||
for u in urls:
|
||||
# Try the real fetched title first, then fall back to URL slug parsing
|
||||
real_title = fetched_titles.get(u)
|
||||
headlines.append(real_title if real_title else _url_to_headline(u))
|
||||
f["properties"]["_urls_list"] = urls
|
||||
f["properties"]["_headlines_list"] = headlines
|
||||
import html
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""
|
||||
News feed configuration — manages the user-customisable RSS feed list.
|
||||
Feeds are stored in backend/config/news_feeds.json and persist across restarts.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CONFIG_PATH = Path(__file__).parent.parent / "config" / "news_feeds.json"
|
||||
MAX_FEEDS = 20
|
||||
|
||||
DEFAULT_FEEDS = [
|
||||
{"name": "NPR", "url": "https://feeds.npr.org/1004/rss.xml", "weight": 4},
|
||||
{"name": "BBC", "url": "http://feeds.bbci.co.uk/news/world/rss.xml", "weight": 3},
|
||||
{"name": "AlJazeera", "url": "https://www.aljazeera.com/xml/rss/all.xml", "weight": 2},
|
||||
{"name": "NYT", "url": "https://rss.nytimes.com/services/xml/rss/nyt/World.xml", "weight": 1},
|
||||
{"name": "GDACS", "url": "https://www.gdacs.org/xml/rss.xml", "weight": 5},
|
||||
{"name": "NHK", "url": "https://www3.nhk.or.jp/nhkworld/rss/world.xml", "weight": 3},
|
||||
{"name": "CNA", "url": "https://www.channelnewsasia.com/rssfeed/8395986", "weight": 3},
|
||||
{"name": "Mercopress", "url": "https://en.mercopress.com/rss/", "weight": 3},
|
||||
]
|
||||
|
||||
|
||||
def get_feeds() -> list[dict]:
|
||||
"""Load feeds from config file, falling back to defaults."""
|
||||
try:
|
||||
if CONFIG_PATH.exists():
|
||||
data = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
|
||||
feeds = data.get("feeds", []) if isinstance(data, dict) else data
|
||||
if isinstance(feeds, list) and len(feeds) > 0:
|
||||
return feeds
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to read news feed config: {e}")
|
||||
return list(DEFAULT_FEEDS)
|
||||
|
||||
|
||||
def save_feeds(feeds: list[dict]) -> bool:
|
||||
"""Validate and save feeds to config file. Returns True on success."""
|
||||
if not isinstance(feeds, list):
|
||||
return False
|
||||
if len(feeds) > MAX_FEEDS:
|
||||
return False
|
||||
# Validate each feed entry
|
||||
for f in feeds:
|
||||
if not isinstance(f, dict):
|
||||
return False
|
||||
name = f.get("name", "").strip()
|
||||
url = f.get("url", "").strip()
|
||||
weight = f.get("weight", 3)
|
||||
if not name or not url:
|
||||
return False
|
||||
if not isinstance(weight, (int, float)) or weight < 1 or weight > 5:
|
||||
return False
|
||||
# Normalise
|
||||
f["name"] = name
|
||||
f["url"] = url
|
||||
f["weight"] = int(weight)
|
||||
try:
|
||||
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
CONFIG_PATH.write_text(
|
||||
json.dumps({"feeds": feeds}, indent=2, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to write news feed config: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def reset_feeds() -> bool:
|
||||
"""Reset feeds to defaults."""
|
||||
return save_feeds(list(DEFAULT_FEEDS))
|
||||
+6
-4
@@ -12,6 +12,8 @@ services:
|
||||
- OPENSKY_CLIENT_ID=${OPENSKY_CLIENT_ID}
|
||||
- OPENSKY_CLIENT_SECRET=${OPENSKY_CLIENT_SECRET}
|
||||
- LTA_ACCOUNT_KEY=${LTA_ACCOUNT_KEY}
|
||||
# Override allowed CORS origins (comma-separated). Auto-detects LAN IPs if empty.
|
||||
- CORS_ORIGINS=${CORS_ORIGINS:-}
|
||||
volumes:
|
||||
- backend_data:/app/data
|
||||
restart: unless-stopped
|
||||
@@ -19,13 +21,13 @@ services:
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
args:
|
||||
# Optional: set this to your backend's external URL if using custom ports
|
||||
# e.g. http://192.168.1.50:9096 — leave empty to auto-detect from browser
|
||||
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-}
|
||||
container_name: shadowbroker-frontend
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
# Points the Next.js server-side proxy at the backend container via Docker networking.
|
||||
# Change this if your backend runs on a different host or port.
|
||||
- BACKEND_URL=http://backend:8000
|
||||
depends_on:
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
// BACKEND_URL is a plain (non-NEXT_PUBLIC_) env var read at server startup —
|
||||
// not baked at build time — so it can be set in docker-compose `environment`.
|
||||
// Defaults to localhost for local dev where both services run on the same host.
|
||||
const backendUrl = process.env.BACKEND_URL ?? "http://localhost:8000";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
transpilePackages: ['react-map-gl', 'mapbox-gl', 'maplibre-gl'],
|
||||
output: "standalone",
|
||||
@@ -9,6 +14,14 @@ const nextConfig: NextConfig = {
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: "/api/:path*",
|
||||
destination: `${backendUrl}/api/:path*`,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Catch-all proxy route — forwards /api/* requests from the browser to the
|
||||
* backend server. BACKEND_URL is a plain server-side env var (not NEXT_PUBLIC_),
|
||||
* so it is read at request time from the runtime environment, never baked into
|
||||
* the client bundle or the build manifest.
|
||||
*
|
||||
* Set BACKEND_URL in docker-compose `environment:` (e.g. http://backend:8000)
|
||||
* to use Docker internal networking. Defaults to http://localhost:8000 for
|
||||
* local development where both services run on the same host.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
// Headers that must not be forwarded to the backend.
|
||||
const STRIP_REQUEST = new Set([
|
||||
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
|
||||
"te", "trailers", "transfer-encoding", "upgrade", "host",
|
||||
]);
|
||||
|
||||
// Headers that must not be forwarded back to the browser.
|
||||
// content-encoding and content-length are stripped because Node.js fetch()
|
||||
// automatically decompresses gzip/br responses — forwarding these headers
|
||||
// would cause ERR_CONTENT_DECODING_FAILED in the browser.
|
||||
const STRIP_RESPONSE = new Set([
|
||||
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
|
||||
"te", "trailers", "transfer-encoding", "upgrade",
|
||||
"content-encoding", "content-length",
|
||||
]);
|
||||
|
||||
async function proxy(req: NextRequest, path: string[]): Promise<NextResponse> {
|
||||
const backendUrl = process.env.BACKEND_URL ?? "http://localhost:8000";
|
||||
const targetUrl = new URL(`/api/${path.join("/")}`, backendUrl);
|
||||
targetUrl.search = req.nextUrl.search;
|
||||
|
||||
// Forward relevant request headers
|
||||
const forwardHeaders = new Headers();
|
||||
req.headers.forEach((value, key) => {
|
||||
if (!STRIP_REQUEST.has(key.toLowerCase())) {
|
||||
forwardHeaders.set(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
const isBodyless = req.method === "GET" || req.method === "HEAD";
|
||||
let upstream: Response;
|
||||
try {
|
||||
upstream = await fetch(targetUrl.toString(), {
|
||||
method: req.method,
|
||||
headers: forwardHeaders,
|
||||
body: isBodyless ? undefined : req.body,
|
||||
// Required for streaming request bodies in Node.js fetch
|
||||
// @ts-ignore
|
||||
duplex: "half",
|
||||
});
|
||||
} catch (err) {
|
||||
// Backend unreachable — return a clean 502 so the UI can handle it gracefully
|
||||
return new NextResponse(JSON.stringify({ error: "Backend unavailable" }), {
|
||||
status: 502,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// Forward response headers
|
||||
const responseHeaders = new Headers();
|
||||
upstream.headers.forEach((value, key) => {
|
||||
if (!STRIP_RESPONSE.has(key.toLowerCase())) {
|
||||
responseHeaders.set(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
// 304 responses must have no body
|
||||
if (upstream.status === 304) {
|
||||
return new NextResponse(null, { status: 304, headers: responseHeaders });
|
||||
}
|
||||
|
||||
return new NextResponse(upstream.body, {
|
||||
status: upstream.status,
|
||||
headers: responseHeaders,
|
||||
});
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
|
||||
return proxy(req, (await params).path);
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
|
||||
return proxy(req, (await params).path);
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
|
||||
return proxy(req, (await params).path);
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
|
||||
return proxy(req, (await params).path);
|
||||
}
|
||||
@@ -148,6 +148,7 @@ export default function Dashboard() {
|
||||
kiwisdr: false,
|
||||
firms: false,
|
||||
internet_outages: false,
|
||||
datacenters: false,
|
||||
});
|
||||
|
||||
// NASA GIBS satellite imagery state
|
||||
@@ -591,7 +592,7 @@ export default function Dashboard() {
|
||||
{backendStatus === 'disconnected' && (
|
||||
<div className="absolute top-0 left-0 right-0 z-[9000] flex items-center justify-center py-2 bg-red-950/90 border-b border-red-500/40 backdrop-blur-sm">
|
||||
<span className="text-[10px] font-mono tracking-widest text-red-400">
|
||||
BACKEND OFFLINE — Cannot reach {API_BASE}. Start the backend server or check your connection.
|
||||
BACKEND OFFLINE — Cannot reach backend server. Check that the backend container is running and BACKEND_URL is correct.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2,43 +2,45 @@
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { X, Flame, Sun, Wifi, Activity, Bug } from "lucide-react";
|
||||
import { X, Rss, Server, Zap, Shield, Bug } from "lucide-react";
|
||||
|
||||
const CURRENT_VERSION = "0.5";
|
||||
const CURRENT_VERSION = "0.6";
|
||||
const STORAGE_KEY = `shadowbroker_changelog_v${CURRENT_VERSION}`;
|
||||
|
||||
const NEW_FEATURES = [
|
||||
{
|
||||
icon: <Flame size={14} className="text-orange-400" />,
|
||||
title: "NASA FIRMS Fire Hotspots (24h)",
|
||||
desc: "5,000+ global thermal anomalies from NOAA-20 VIIRS satellite. Flame-shaped icons color-coded by fire radiative power — yellow (low), orange, red, dark red (intense). Clusters show fire counts.",
|
||||
icon: <Rss size={14} className="text-orange-400" />,
|
||||
title: "Custom News Feed Manager",
|
||||
desc: "Add, remove, and prioritize up to 20 RSS intelligence sources directly from the Settings panel. Assign weight levels (1-5) to control feed importance. No more editing Python files — your custom feeds persist across restarts.",
|
||||
color: "orange",
|
||||
},
|
||||
{
|
||||
icon: <Sun size={14} className="text-yellow-400" />,
|
||||
title: "Space Weather Badge",
|
||||
desc: "Live NOAA geomagnetic storm indicator in the bottom status bar. Color-coded Kp index: green (quiet), yellow (active), red (storm G1-G5). Sourced from SWPC planetary K-index.",
|
||||
icon: <Server size={14} className="text-purple-400" />,
|
||||
title: "Global Data Center Map Layer",
|
||||
desc: "2,000+ data centers plotted worldwide from a curated dataset. Click any DC for operator details — and if an internet outage is detected in the same country, the popup flags it automatically.",
|
||||
color: "purple",
|
||||
},
|
||||
{
|
||||
icon: <Zap size={14} className="text-yellow-400" />,
|
||||
title: "Imperative Map Rendering",
|
||||
desc: "High-volume layers (flights, satellites, fire hotspots) now bypass React reconciliation and update the map directly via setData(). Debounced updates on dense layers. Smoother panning and zooming under load.",
|
||||
color: "yellow",
|
||||
},
|
||||
{
|
||||
icon: <Wifi size={14} className="text-gray-400" />,
|
||||
title: "Internet Outage Monitoring",
|
||||
desc: "Regional internet connectivity alerts from Georgia Tech IODA. Grey markers show affected regions with severity percentage — powered by BGP and active probing data. No false positives.",
|
||||
color: "gray",
|
||||
},
|
||||
{
|
||||
icon: <Activity size={14} className="text-cyan-400" />,
|
||||
title: "Enhanced Layer Differentiation",
|
||||
desc: "Fire hotspots use distinct flame icons (not circles) to prevent confusion with Global Incidents. Internet outages use grey markers. Each layer is now instantly recognizable at a glance.",
|
||||
icon: <Shield size={14} className="text-cyan-400" />,
|
||||
title: "Enhanced Health Observability",
|
||||
desc: "The /api/health endpoint now reports per-source freshness timestamps and counts for all data layers — UAVs, FIRMS fires, LiveUAMap, GDELT, and more. Better uptime monitoring for self-hosters.",
|
||||
color: "cyan",
|
||||
},
|
||||
];
|
||||
|
||||
const BUG_FIXES = [
|
||||
"All data sourced from verified OSINT feeds — no fabricated or interpolated data points",
|
||||
"Internet outages filtered to reliable datasources only (BGP, ping) — no misleading telescope data",
|
||||
"Fire clusters use flame-shaped icons instead of circles for clear visual separation",
|
||||
"MapLibre font errors resolved — switched to Noto Sans (universally available)",
|
||||
"Settings panel now has tabbed UI — API Keys and News Feeds on separate tabs",
|
||||
"Data center coordinates fixed for 187 Southern Hemisphere entries (were mirrored north of equator)",
|
||||
"Docker networking: CORS_ORIGINS env var properly passed through docker-compose",
|
||||
"Start scripts warn on Python 3.13+ compatibility issues before install",
|
||||
"Satellite and fire hotspot layers debounced (2s) to prevent render thrashing",
|
||||
"Entries with invalid geocoded coordinates automatically filtered out",
|
||||
];
|
||||
|
||||
export function useChangelog() {
|
||||
|
||||
@@ -94,8 +94,7 @@ const LEGEND: LegendCategory[] = [
|
||||
{ svg: airliner("yellow"), label: "Military — Standard" },
|
||||
{ svg: plane("yellow"), label: "Fighter / Interceptor" },
|
||||
{ svg: heli("yellow"), label: "Military — Helicopter" },
|
||||
{ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="orange" stroke="black"><path d="M12 2L15 8H9L12 2Z" /><rect x="8" y="8" width="8" height="2" /><path d="M4 10L10 14H14L20 10V12L14 16H10L4 12V10Z" /><circle cx="12" cy="14" r="2" fill="red"/></svg>`, label: "UAV / Drone" },
|
||||
{ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="orange" stroke-width="1.5" stroke-dasharray="4 2" opacity="0.6"/><circle cx="12" cy="12" r="2" fill="orange"/></svg>`, label: "UAV Operational Range (dashed circle)" },
|
||||
{ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="orange" stroke="black"><path d="M12 2L15 8H9L12 2Z" /><rect x="8" y="8" width="8" height="2" /><path d="M4 10L10 14H14L20 10V12L14 16H10L4 12V10Z" /><circle cx="12" cy="14" r="2" fill="red"/></svg>`, label: "UAV / Drone (live ADS-B)" },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -33,6 +33,7 @@ const svgHeliWhiteAlert = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xm
|
||||
const svgPlaneBlack = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="#222" stroke="#444"><path d="M21 16v-2l-8-5V3.5c0-.83-.67-1.5-1.5-1.5S10 2.67 10 3.5V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z" /></svg>`)}`;
|
||||
const svgHeliBlack = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="#222" stroke="#444"><path d="M10 6L10 14L8 16L8 18L10 17L12 22L14 17L16 18L16 16L14 14L14 6C14 4 13 2 12 2C11 2 10 4 10 6Z"/><circle cx="12" cy="12" r="8" fill="none" stroke="#444" stroke-dasharray="2 2" stroke-width="1"/></svg>`)}`;
|
||||
const svgDrone = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="orange" stroke="black"><path d="M12 2L15 8H9L12 2Z" /><rect x="8" y="8" width="8" height="2" /><path d="M4 10L10 14H14L20 10V12L14 16H10L4 12V10Z" /><circle cx="12" cy="14" r="2" fill="red"/></svg>`)}`;
|
||||
const svgDataCenter = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#a78bfa" stroke-width="1.5"><rect x="3" y="3" width="18" height="6" rx="1" fill="#2e1065"/><rect x="3" y="11" width="18" height="6" rx="1" fill="#2e1065"/><circle cx="7" cy="6" r="1" fill="#a78bfa"/><circle cx="7" cy="14" r="1" fill="#a78bfa"/><line x1="11" y1="6" x2="17" y2="6" stroke="#a78bfa" stroke-width="1"/><line x1="11" y1="14" x2="17" y2="14" stroke="#a78bfa" stroke-width="1"/><line x1="12" y1="19" x2="12" y2="22" stroke="#a78bfa" stroke-width="1.5"/></svg>`)}`;
|
||||
const svgShipGray = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="12" height="24" viewBox="0 0 24 24" fill="none"><path d="M6 20 L6 8 L12 2 L18 8 L18 20 C18 22 6 22 6 20 Z" fill="gray" stroke="#000" stroke-width="1"/><polygon points="12,6 16,16 8,16" fill="#fff" stroke="#000" stroke-width="1"/></svg>`)}`;
|
||||
const svgShipRed = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="32" viewBox="0 0 24 24" fill="none"><path d="M6 22 L6 6 L12 2 L18 6 L18 22 Z" fill="#ff2222" stroke="#000" stroke-width="1"/><rect x="8" y="15" width="8" height="4" fill="#880000" stroke="#000" stroke-width="1"/><rect x="8" y="7" width="8" height="6" fill="#444" stroke="#000" stroke-width="1"/></svg>`)}`;
|
||||
const svgShipYellow = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="14" height="34" viewBox="0 0 24 24" fill="none"><path d="M7 22 L7 6 L12 1 L17 6 L17 22 Z" fill="yellow" stroke="#000" stroke-width="1"/><rect x="9" y="8" width="6" height="8" fill="#555" stroke="#000" stroke-width="1"/><circle cx="12" cy="18" r="1.5" fill="#000"/><line x1="12" y1="18" x2="12" y2="24" stroke="#000" stroke-width="1.5"/></svg>`)}`;
|
||||
@@ -124,6 +125,13 @@ const svgHeliGrey = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="h
|
||||
// Grey icon map for grounded aircraft
|
||||
const GROUNDED_ICON_MAP: Record<string, string> = { heli: 'svgHeliGrey', turboprop: 'svgTurbopropGrey', bizjet: 'svgBizjetGrey', airliner: 'svgAirlinerGrey' };
|
||||
|
||||
// Per-layer color maps (module-level to avoid re-allocation every render tick)
|
||||
const COLOR_MAP_COMMERCIAL: Record<string, string> = { heli: 'svgHeliCyan', turboprop: 'svgTurbopropCyan', bizjet: 'svgBizjetCyan', airliner: 'svgAirlinerCyan' };
|
||||
const COLOR_MAP_PRIVATE: Record<string, string> = { heli: 'svgHeliOrange', turboprop: 'svgTurbopropOrange', bizjet: 'svgBizjetOrange', airliner: 'svgAirlinerOrange' };
|
||||
const COLOR_MAP_JETS: Record<string, string> = { heli: 'svgHeliPurple', turboprop: 'svgTurbopropPurple', bizjet: 'svgBizjetPurple', airliner: 'svgAirlinerPurple' };
|
||||
const COLOR_MAP_MILITARY: Record<string, string> = { heli: 'svgHeli', turboprop: 'svgTurbopropYellow', bizjet: 'svgBizjetYellow', airliner: 'svgAirlinerYellow' };
|
||||
const MIL_SPECIAL_MAP: Record<string, string> = { fighter: 'svgFighter', tanker: 'svgTanker', recon: 'svgRecon' };
|
||||
|
||||
// ICAO type code -> aircraft shape classification
|
||||
const HELI_TYPES = new Set(['R22', 'R44', 'R66', 'B06', 'B05', 'B47G', 'B105', 'B212', 'B222', 'B230', 'B407', 'B412', 'B429', 'B430', 'B505', 'BK17', 'S55', 'S58', 'S61', 'S64', 'S70', 'S76', 'S92', 'A109', 'A119', 'A139', 'A169', 'A189', 'AW09', 'EC20', 'EC25', 'EC30', 'EC35', 'EC45', 'EC55', 'EC75', 'H125', 'H130', 'H135', 'H145', 'H155', 'H160', 'H175', 'H215', 'H225', 'AS32', 'AS35', 'AS50', 'AS55', 'AS65', 'MD52', 'MD60', 'MDHI', 'MD90', 'NOTR', 'HUEY', 'GAMA', 'CABR', 'EXE', 'R300', 'R480', 'LAMA', 'ALLI', 'PUMA', 'NH90', 'CH47', 'UH1', 'UH60', 'AH64', 'MI8', 'MI24', 'MI26', 'MI28', 'KA52', 'K32', 'LYNX', 'WILD', 'MRLX', 'A149', 'A119']);
|
||||
const TURBOPROP_TYPES = new Set(['AT43', 'AT45', 'AT72', 'AT73', 'AT75', 'AT76', 'B190', 'B350', 'BE20', 'BE30', 'BE40', 'BE9L', 'BE99', 'C130', 'C160', 'C208', 'C212', 'C295', 'CN35', 'D228', 'D328', 'DHC2', 'DHC3', 'DHC4', 'DHC5', 'DHC6', 'DHC7', 'DHC8', 'DO28', 'DH8A', 'DH8B', 'DH8C', 'DH8D', 'E110', 'E120', 'F27', 'F406', 'F50', 'G159', 'G73T', 'J328', 'JS31', 'JS32', 'JS41', 'L188', 'MA60', 'M28', 'N262', 'P68', 'P180', 'PA31', 'PA42', 'PC12', 'PC21', 'PC24', 'S2', 'S340', 'SF34', 'SF50', 'SW4', 'TRIS', 'TBM7', 'TBM8', 'TBM9', 'C30J', 'C5M', 'AN12', 'AN24', 'AN26', 'AN30', 'AN32', 'IL18', 'L410', 'Y12', 'BALL', 'AEST', 'AC68', 'AC80', 'AC90', 'AC95', 'AC11', 'C172', 'C182', 'C206', 'C210', 'C310', 'C337', 'C402', 'C414', 'C421', 'C425', 'C441', 'M20P', 'M20T', 'PA28', 'PA32', 'PA34', 'PA44', 'PA46', 'PA60', 'P28A', 'P28B', 'P28R', 'P32R', 'P46T', 'SR20', 'SR22', 'DA40', 'DA42', 'DA62', 'RV10', 'BE33', 'BE35', 'BE36', 'BE55', 'BE58', 'DR40', 'TB20', 'AA5']);
|
||||
@@ -228,8 +236,34 @@ const MISSION_ICON_MAP: Record<string, string> = {
|
||||
'commercial_imaging': 'sat-com', 'space_station': 'sat-station'
|
||||
};
|
||||
|
||||
// Empty GeoJSON constant — avoids recreating empty objects on every render
|
||||
const EMPTY_FC: GeoJSON.FeatureCollection = { type: 'FeatureCollection', features: [] };
|
||||
|
||||
// Imperatively push GeoJSON data to a MapLibre source, bypassing React reconciliation.
|
||||
// This is critical for high-volume layers (flights, ships, satellites, fires) where
|
||||
// React's prop diffing on thousands of coordinate arrays causes memory pressure.
|
||||
function useImperativeSource(map: MapRef | null, sourceId: string, geojson: any, debounceMs = 0) {
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(() => {
|
||||
if (!map) return;
|
||||
const push = () => {
|
||||
const src = map.getSource(sourceId) as any;
|
||||
if (src && typeof src.setData === 'function') {
|
||||
src.setData(geojson || EMPTY_FC);
|
||||
}
|
||||
};
|
||||
if (debounceMs > 0) {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(push, debounceMs);
|
||||
return () => { if (timerRef.current) clearTimeout(timerRef.current); };
|
||||
}
|
||||
push();
|
||||
}, [map, sourceId, geojson, debounceMs]);
|
||||
}
|
||||
|
||||
const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, selectedEntity, onMouseCoords, onRightClick, regionDossier, regionDossierLoading, onViewStateChange, measureMode, onMeasureClick, measurePoints, gibsDate, gibsOpacity }: any) => {
|
||||
const mapRef = useRef<MapRef>(null);
|
||||
const [mapReady, setMapReady] = useState(false);
|
||||
const { theme } = useTheme();
|
||||
const mapThemeStyle = useMemo(() => theme === 'light' ? lightStyle : darkStyle, [theme]);
|
||||
|
||||
@@ -498,6 +532,25 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
||||
};
|
||||
}, [activeLayers.internet_outages, data?.internet_outages]);
|
||||
|
||||
const dataCentersGeoJSON = useMemo(() => {
|
||||
if (!activeLayers.datacenters || !data?.datacenters?.length) return null;
|
||||
return {
|
||||
type: 'FeatureCollection' as const,
|
||||
features: data.datacenters.map((dc: any, i: number) => ({
|
||||
type: 'Feature' as const,
|
||||
properties: {
|
||||
id: `dc-${i}`,
|
||||
type: 'datacenter',
|
||||
name: dc.name || 'Unknown',
|
||||
company: dc.company || '',
|
||||
city: dc.city || '',
|
||||
country: dc.country || '',
|
||||
},
|
||||
geometry: { type: 'Point' as const, coordinates: [dc.lng, dc.lat] }
|
||||
}))
|
||||
};
|
||||
}, [activeLayers.datacenters, data?.datacenters]);
|
||||
|
||||
// Load Images into the Map Style once loaded
|
||||
const onMapLoad = useCallback((e: any) => {
|
||||
const map = e.target;
|
||||
@@ -533,93 +586,95 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
||||
}
|
||||
});
|
||||
|
||||
// Legacy generic plane icons (still used as fallbacks)
|
||||
// Critical icons — needed immediately for default-on layers
|
||||
loadImg('svgPlaneCyan', svgPlaneCyan);
|
||||
loadImg('svgPlaneYellow', svgPlaneYellow);
|
||||
loadImg('svgPlaneOrange', svgPlaneOrange);
|
||||
loadImg('svgPlanePurple', svgPlanePurple);
|
||||
loadImg('svgPlanePink', svgPlanePink);
|
||||
loadImg('svgPlaneAlertRed', svgPlaneAlertRed);
|
||||
loadImg('svgPlaneDarkBlue', svgPlaneDarkBlue);
|
||||
loadImg('svgPlaneWhiteAlert', svgPlaneWhiteAlert);
|
||||
loadImg('svgPlaneBlack', svgPlaneBlack);
|
||||
// Heli icons
|
||||
loadImg('svgHeli', svgHeli);
|
||||
loadImg('svgHeliCyan', svgHeliCyan);
|
||||
loadImg('svgHeliOrange', svgHeliOrange);
|
||||
loadImg('svgHeliPurple', svgHeliPurple);
|
||||
loadImg('svgHeliPink', svgHeliPink);
|
||||
loadImg('svgHeliAlertRed', svgHeliAlertRed);
|
||||
loadImg('svgHeliDarkBlue', svgHeliDarkBlue);
|
||||
loadImg('svgHeliWhiteAlert', svgHeliWhiteAlert);
|
||||
loadImg('svgHeliBlack', svgHeliBlack);
|
||||
// Military special
|
||||
loadImg('svgFighter', svgFighter);
|
||||
loadImg('svgTanker', svgTanker);
|
||||
loadImg('svgRecon', svgRecon);
|
||||
// Airliner icons (swept wings + engine pods)
|
||||
loadImg('svgAirlinerCyan', svgAirlinerCyan);
|
||||
loadImg('svgAirlinerOrange', svgAirlinerOrange);
|
||||
loadImg('svgAirlinerPurple', svgAirlinerPurple);
|
||||
loadImg('svgAirlinerYellow', svgAirlinerYellow);
|
||||
loadImg('svgAirlinerPink', svgAirlinerPink);
|
||||
loadImg('svgAirlinerRed', svgAirlinerRed);
|
||||
loadImg('svgAirlinerDarkBlue', svgAirlinerDarkBlue);
|
||||
loadImg('svgAirlinerWhite', svgAirlinerWhite);
|
||||
// Turboprop icons (straight wings)
|
||||
loadImg('svgTurbopropCyan', svgTurbopropCyan);
|
||||
loadImg('svgTurbopropOrange', svgTurbopropOrange);
|
||||
loadImg('svgTurbopropPurple', svgTurbopropPurple);
|
||||
loadImg('svgTurbopropYellow', svgTurbopropYellow);
|
||||
loadImg('svgTurbopropPink', svgTurbopropPink);
|
||||
loadImg('svgTurbopropRed', svgTurbopropRed);
|
||||
loadImg('svgTurbopropDarkBlue', svgTurbopropDarkBlue);
|
||||
loadImg('svgTurbopropWhite', svgTurbopropWhite);
|
||||
// Bizjet icons (sleek, T-tail)
|
||||
loadImg('svgBizjetCyan', svgBizjetCyan);
|
||||
loadImg('svgBizjetOrange', svgBizjetOrange);
|
||||
loadImg('svgBizjetPurple', svgBizjetPurple);
|
||||
loadImg('svgBizjetYellow', svgBizjetYellow);
|
||||
loadImg('svgBizjetPink', svgBizjetPink);
|
||||
loadImg('svgBizjetRed', svgBizjetRed);
|
||||
loadImg('svgBizjetDarkBlue', svgBizjetDarkBlue);
|
||||
loadImg('svgBizjetWhite', svgBizjetWhite);
|
||||
// Grey grounded icons
|
||||
loadImg('svgAirlinerGrey', svgAirlinerGrey);
|
||||
loadImg('svgTurbopropGrey', svgTurbopropGrey);
|
||||
loadImg('svgBizjetGrey', svgBizjetGrey);
|
||||
loadImg('svgHeliGrey', svgHeliGrey);
|
||||
loadImg('svgDrone', svgDrone);
|
||||
loadImg('svgShipGray', svgShipGray);
|
||||
loadImg('svgShipRed', svgShipRed);
|
||||
loadImg('svgShipYellow', svgShipYellow);
|
||||
loadImg('svgShipBlue', svgShipBlue);
|
||||
loadImg('svgShipWhite', svgShipWhite);
|
||||
loadImg('svgCarrier', svgCarrier);
|
||||
loadImg('svgCctv', svgCctv);
|
||||
loadImg('svgWarning', svgWarning);
|
||||
loadImg('icon-threat', svgThreat);
|
||||
loadImg('icon-liveua-yellow', svgTriangleYellow);
|
||||
loadImg('icon-liveua-red', svgTriangleRed);
|
||||
// FIRMS fire icons
|
||||
loadImg('fire-yellow', svgFireYellow);
|
||||
loadImg('fire-orange', svgFireOrange);
|
||||
loadImg('fire-red', svgFireRed);
|
||||
loadImg('fire-darkred', svgFireDarkRed);
|
||||
loadImg('fire-cluster-sm', svgFireClusterSmall);
|
||||
loadImg('fire-cluster-md', svgFireClusterMed);
|
||||
loadImg('fire-cluster-lg', svgFireClusterLarge);
|
||||
loadImg('fire-cluster-xl', svgFireClusterXL);
|
||||
|
||||
// Satellite mission-type icons
|
||||
loadImg('sat-mil', makeSatSvg('#ff3333'));
|
||||
loadImg('sat-sar', makeSatSvg('#00e5ff'));
|
||||
loadImg('sat-sigint', makeSatSvg('#ffffff'));
|
||||
loadImg('sat-nav', makeSatSvg('#4488ff'));
|
||||
loadImg('sat-ew', makeSatSvg('#ff00ff'));
|
||||
loadImg('sat-com', makeSatSvg('#44ff44'));
|
||||
loadImg('sat-station', makeSatSvg('#ffdd00'));
|
||||
loadImg('sat-gen', makeSatSvg('#aaaaaa'));
|
||||
// Deferred icons — for off-by-default layers and rare variants
|
||||
// Loaded in next frame to avoid blocking initial map render
|
||||
setTimeout(() => {
|
||||
loadImg('svgPlanePink', svgPlanePink);
|
||||
loadImg('svgPlaneAlertRed', svgPlaneAlertRed);
|
||||
loadImg('svgPlaneDarkBlue', svgPlaneDarkBlue);
|
||||
loadImg('svgPlaneWhiteAlert', svgPlaneWhiteAlert);
|
||||
loadImg('svgPlaneBlack', svgPlaneBlack);
|
||||
loadImg('svgHeliPink', svgHeliPink);
|
||||
loadImg('svgHeliAlertRed', svgHeliAlertRed);
|
||||
loadImg('svgHeliDarkBlue', svgHeliDarkBlue);
|
||||
loadImg('svgHeliWhiteAlert', svgHeliWhiteAlert);
|
||||
loadImg('svgHeliBlack', svgHeliBlack);
|
||||
loadImg('svgAirlinerPink', svgAirlinerPink);
|
||||
loadImg('svgAirlinerRed', svgAirlinerRed);
|
||||
loadImg('svgAirlinerDarkBlue', svgAirlinerDarkBlue);
|
||||
loadImg('svgAirlinerWhite', svgAirlinerWhite);
|
||||
loadImg('svgTurbopropPink', svgTurbopropPink);
|
||||
loadImg('svgTurbopropRed', svgTurbopropRed);
|
||||
loadImg('svgTurbopropDarkBlue', svgTurbopropDarkBlue);
|
||||
loadImg('svgTurbopropWhite', svgTurbopropWhite);
|
||||
loadImg('svgBizjetPink', svgBizjetPink);
|
||||
loadImg('svgBizjetRed', svgBizjetRed);
|
||||
loadImg('svgBizjetDarkBlue', svgBizjetDarkBlue);
|
||||
loadImg('svgBizjetWhite', svgBizjetWhite);
|
||||
loadImg('svgDrone', svgDrone);
|
||||
loadImg('svgCctv', svgCctv);
|
||||
loadImg('icon-liveua-yellow', svgTriangleYellow);
|
||||
loadImg('icon-liveua-red', svgTriangleRed);
|
||||
// FIRMS fire icons
|
||||
loadImg('fire-yellow', svgFireYellow);
|
||||
loadImg('fire-orange', svgFireOrange);
|
||||
loadImg('fire-red', svgFireRed);
|
||||
loadImg('fire-darkred', svgFireDarkRed);
|
||||
loadImg('fire-cluster-sm', svgFireClusterSmall);
|
||||
loadImg('fire-cluster-md', svgFireClusterMed);
|
||||
loadImg('fire-cluster-lg', svgFireClusterLarge);
|
||||
loadImg('fire-cluster-xl', svgFireClusterXL);
|
||||
// Data center icon
|
||||
loadImg('datacenter', svgDataCenter);
|
||||
// Satellite mission-type icons
|
||||
loadImg('sat-mil', makeSatSvg('#ff3333'));
|
||||
loadImg('sat-sar', makeSatSvg('#00e5ff'));
|
||||
loadImg('sat-sigint', makeSatSvg('#ffffff'));
|
||||
loadImg('sat-nav', makeSatSvg('#4488ff'));
|
||||
loadImg('sat-ew', makeSatSvg('#ff00ff'));
|
||||
loadImg('sat-com', makeSatSvg('#44ff44'));
|
||||
loadImg('sat-station', makeSatSvg('#ffdd00'));
|
||||
loadImg('sat-gen', makeSatSvg('#aaaaaa'));
|
||||
}, 0);
|
||||
|
||||
setMapReady(true);
|
||||
}, []);
|
||||
|
||||
// Build a set of tracked icao24s to exclude from other flight layers
|
||||
@@ -697,7 +752,6 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
||||
// Create GeoJSON collections dynamically (this runs ultra fast in pure JS)
|
||||
const commFlightsGeoJSON = useMemo(() => {
|
||||
if (!activeLayers.flights || !data?.commercial_flights) return null;
|
||||
const colorMap: Record<string, string> = { heli: 'svgHeliCyan', turboprop: 'svgTurbopropCyan', bizjet: 'svgBizjetCyan', airliner: 'svgAirlinerCyan' };
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: data.commercial_flights.map((f: any, i: number) => {
|
||||
@@ -709,7 +763,7 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
||||
const [iLng, iLat] = interpFlight(f);
|
||||
return {
|
||||
type: 'Feature',
|
||||
properties: { id: i, type: 'flight', callsign: f.callsign || f.icao24, rotation: f.true_track || f.heading || 0, iconId: grounded ? GROUNDED_ICON_MAP[acType] : colorMap[acType] },
|
||||
properties: { id: i, type: 'flight', callsign: f.callsign || f.icao24, rotation: f.true_track || f.heading || 0, iconId: grounded ? GROUNDED_ICON_MAP[acType] : COLOR_MAP_COMMERCIAL[acType] },
|
||||
geometry: { type: 'Point', coordinates: [iLng, iLat] }
|
||||
};
|
||||
}).filter(Boolean)
|
||||
@@ -718,7 +772,6 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
||||
|
||||
const privFlightsGeoJSON = useMemo(() => {
|
||||
if (!activeLayers.private || !data?.private_flights) return null;
|
||||
const colorMap: Record<string, string> = { heli: 'svgHeliOrange', turboprop: 'svgTurbopropOrange', bizjet: 'svgBizjetOrange', airliner: 'svgAirlinerOrange' };
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: data.private_flights.map((f: any, i: number) => {
|
||||
@@ -730,7 +783,7 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
||||
const [iLng, iLat] = interpFlight(f);
|
||||
return {
|
||||
type: 'Feature',
|
||||
properties: { id: i, type: 'private_flight', callsign: f.callsign || f.icao24, rotation: f.heading || 0, iconId: grounded ? GROUNDED_ICON_MAP[acType] : colorMap[acType] },
|
||||
properties: { id: i, type: 'private_flight', callsign: f.callsign || f.icao24, rotation: f.heading || 0, iconId: grounded ? GROUNDED_ICON_MAP[acType] : COLOR_MAP_PRIVATE[acType] },
|
||||
geometry: { type: 'Point', coordinates: [iLng, iLat] }
|
||||
};
|
||||
}).filter(Boolean)
|
||||
@@ -739,7 +792,6 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
||||
|
||||
const privJetsGeoJSON = useMemo(() => {
|
||||
if (!activeLayers.jets || !data?.private_jets) return null;
|
||||
const colorMap: Record<string, string> = { heli: 'svgHeliPurple', turboprop: 'svgTurbopropPurple', bizjet: 'svgBizjetPurple', airliner: 'svgAirlinerPurple' };
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: data.private_jets.map((f: any, i: number) => {
|
||||
@@ -751,7 +803,7 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
||||
const [iLng, iLat] = interpFlight(f);
|
||||
return {
|
||||
type: 'Feature',
|
||||
properties: { id: i, type: 'private_jet', callsign: f.callsign || f.icao24, rotation: f.heading || 0, iconId: grounded ? GROUNDED_ICON_MAP[acType] : colorMap[acType] },
|
||||
properties: { id: i, type: 'private_jet', callsign: f.callsign || f.icao24, rotation: f.heading || 0, iconId: grounded ? GROUNDED_ICON_MAP[acType] : COLOR_MAP_JETS[acType] },
|
||||
geometry: { type: 'Point', coordinates: [iLng, iLat] }
|
||||
};
|
||||
}).filter(Boolean)
|
||||
@@ -761,11 +813,6 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
||||
const milFlightsGeoJSON = useMemo(() => {
|
||||
if (!activeLayers.military || !data?.military_flights) return null;
|
||||
|
||||
// Special military types keep their unique icons
|
||||
const milSpecialMap: any = { 'fighter': 'svgFighter', 'tanker': 'svgTanker', 'recon': 'svgRecon' };
|
||||
// Fallback by aircraft shape for cargo/default
|
||||
const milColorMap: Record<string, string> = { heli: 'svgHeli', turboprop: 'svgTurbopropYellow', bizjet: 'svgBizjetYellow', airliner: 'svgAirlinerYellow' };
|
||||
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: data.military_flights.map((f: any, i: number) => {
|
||||
@@ -774,10 +821,10 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
||||
if (f.icao24 && trackedIcaoSet.has(f.icao24.toLowerCase())) return null;
|
||||
const milType = f.military_type || 'default';
|
||||
const grounded = f.alt != null && f.alt <= 100;
|
||||
let iconId = milSpecialMap[milType];
|
||||
let iconId = MIL_SPECIAL_MAP[milType];
|
||||
if (!iconId) {
|
||||
const acType = classifyAircraft(f.model, f.aircraft_category);
|
||||
iconId = grounded ? GROUNDED_ICON_MAP[acType] : milColorMap[acType];
|
||||
iconId = grounded ? GROUNDED_ICON_MAP[acType] : COLOR_MAP_MILITARY[acType];
|
||||
} else if (grounded) {
|
||||
const acType = classifyAircraft(f.model, f.aircraft_category);
|
||||
iconId = GROUNDED_ICON_MAP[acType];
|
||||
@@ -1140,7 +1187,7 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
||||
return {
|
||||
type: 'Feature',
|
||||
properties: {
|
||||
id: uav.id || i,
|
||||
id: uav.id || `uav-${i}`,
|
||||
type: 'uav',
|
||||
callsign: uav.callsign,
|
||||
rotation: uav.heading || 0,
|
||||
@@ -1149,9 +1196,11 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
||||
country: uav.country || '',
|
||||
uav_type: uav.uav_type || '',
|
||||
alt: uav.alt || 0,
|
||||
range_km: uav.range_km || 0,
|
||||
wiki: uav.wiki || '',
|
||||
speed_knots: uav.speed_knots || 0
|
||||
speed_knots: uav.speed_knots || 0,
|
||||
icao24: uav.icao24 || '',
|
||||
registration: uav.registration || '',
|
||||
squawk: uav.squawk || '',
|
||||
},
|
||||
geometry: { type: 'Point', coordinates: [uav.lng, uav.lat] }
|
||||
};
|
||||
@@ -1159,31 +1208,7 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
||||
};
|
||||
}, [activeLayers.military, data?.uavs, inView]);
|
||||
|
||||
// UAV operational range circle — only for the selected UAV
|
||||
const uavRangeGeoJSON = useMemo(() => {
|
||||
if (!activeLayers.military || !data?.uavs || selectedEntity?.type !== 'uav') return null;
|
||||
const uav = data.uavs.find((u: any) => u.id === selectedEntity.id);
|
||||
if (!uav?.center || !uav?.range_km) return null;
|
||||
const R = 6371;
|
||||
const rangeDeg = uav.range_km / R * (180 / Math.PI);
|
||||
const centerLat = uav.center[0];
|
||||
const centerLng = uav.center[1];
|
||||
const coords: number[][] = [];
|
||||
for (let i = 0; i <= 64; i++) {
|
||||
const angle = (i / 64) * 2 * Math.PI;
|
||||
const lat = centerLat + rangeDeg * Math.sin(angle);
|
||||
const lng = centerLng + rangeDeg * Math.cos(angle) / Math.cos(centerLat * Math.PI / 180);
|
||||
coords.push([lng, lat]);
|
||||
}
|
||||
return {
|
||||
type: 'FeatureCollection' as const,
|
||||
features: [{
|
||||
type: 'Feature' as const,
|
||||
properties: { name: uav.callsign, range_km: uav.range_km },
|
||||
geometry: { type: 'Polygon' as const, coordinates: [coords] }
|
||||
}]
|
||||
};
|
||||
}, [activeLayers.military, data?.uavs, selectedEntity]);
|
||||
// UAV range circles removed — real ADS-B drones don't have a fixed orbit center
|
||||
|
||||
const gdeltGeoJSON = useMemo(() => {
|
||||
if (!activeLayers.global_incidents || !data?.gdelt) return null;
|
||||
@@ -1243,10 +1268,26 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
||||
cctvGeoJSON && 'cctv-layer',
|
||||
kiwisdrGeoJSON && 'kiwisdr-layer',
|
||||
internetOutagesGeoJSON && 'internet-outages-layer',
|
||||
dataCentersGeoJSON && 'datacenters-layer',
|
||||
firmsGeoJSON && 'firms-viirs-layer'
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
|
||||
// --- Imperative source updates for high-volume layers ---
|
||||
// Bypasses React reconciliation of huge GeoJSON FeatureCollections.
|
||||
// The <Source data={EMPTY_FC}> mounts the source; the hook pushes real data.
|
||||
const mapForHook = mapReady ? mapRef.current : null;
|
||||
// Flights & UAVs: immediate (they move fast, stale = visually wrong)
|
||||
useImperativeSource(mapForHook, 'commercial-flights', commFlightsGeoJSON);
|
||||
useImperativeSource(mapForHook, 'private-flights', privFlightsGeoJSON);
|
||||
useImperativeSource(mapForHook, 'private-jets', privJetsGeoJSON);
|
||||
useImperativeSource(mapForHook, 'military-flights', milFlightsGeoJSON);
|
||||
useImperativeSource(mapForHook, 'tracked-flights', trackedFlightsGeoJSON);
|
||||
useImperativeSource(mapForHook, 'uavs', uavGeoJSON);
|
||||
// Satellites & fires: 2s debounce (slow-changing, high feature count)
|
||||
useImperativeSource(mapForHook, 'satellites', satellitesGeoJSON, 2000);
|
||||
useImperativeSource(mapForHook, 'firms-fires', firmsGeoJSON, 2000);
|
||||
|
||||
const handleMouseMove = useCallback((evt: any) => {
|
||||
if (onMouseCoords) onMouseCoords({ lat: evt.lngLat.lat, lng: evt.lngLat.lng });
|
||||
}, [onMouseCoords]);
|
||||
@@ -1348,8 +1389,8 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
||||
)}
|
||||
|
||||
{/* NASA FIRMS VIIRS — fire hotspot icons from FIRMS CSV feed */}
|
||||
{firmsGeoJSON && (
|
||||
<Source id="firms-fires" type="geojson" data={firmsGeoJSON as any} cluster={true} clusterRadius={40} clusterMaxZoom={10}>
|
||||
{/* firms-fires: data pushed imperatively via useImperativeSource */}
|
||||
<Source id="firms-fires" type="geojson" data={EMPTY_FC as any} cluster={true} clusterRadius={40} clusterMaxZoom={10}>
|
||||
{/* Cluster fire icons — flame shape to differentiate from Global Incidents circles */}
|
||||
<Layer
|
||||
id="firms-clusters"
|
||||
@@ -1391,7 +1432,6 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
)}
|
||||
|
||||
{/* SOLAR TERMINATOR — night overlay */}
|
||||
{activeLayers.day_night && nightGeoJSON && (
|
||||
@@ -1407,8 +1447,8 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
||||
</Source>
|
||||
)}
|
||||
|
||||
{commFlightsGeoJSON && (
|
||||
<Source id="commercial-flights" type="geojson" data={commFlightsGeoJSON as any}>
|
||||
{/* commercial/private/military flights: data pushed imperatively */}
|
||||
<Source id="commercial-flights" type="geojson" data={EMPTY_FC as any}>
|
||||
<Layer
|
||||
id="commercial-flights-layer"
|
||||
type="symbol"
|
||||
@@ -1422,10 +1462,8 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
||||
paint={{ 'icon-opacity': opacityFilter }}
|
||||
/>
|
||||
</Source>
|
||||
)}
|
||||
|
||||
{privFlightsGeoJSON && (
|
||||
<Source id="private-flights" type="geojson" data={privFlightsGeoJSON as any}>
|
||||
<Source id="private-flights" type="geojson" data={EMPTY_FC as any}>
|
||||
<Layer
|
||||
id="private-flights-layer"
|
||||
type="symbol"
|
||||
@@ -1439,10 +1477,8 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
||||
paint={{ 'icon-opacity': opacityFilter }}
|
||||
/>
|
||||
</Source>
|
||||
)}
|
||||
|
||||
{privJetsGeoJSON && (
|
||||
<Source id="private-jets" type="geojson" data={privJetsGeoJSON as any}>
|
||||
<Source id="private-jets" type="geojson" data={EMPTY_FC as any}>
|
||||
<Layer
|
||||
id="private-jets-layer"
|
||||
type="symbol"
|
||||
@@ -1456,10 +1492,8 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
||||
paint={{ 'icon-opacity': opacityFilter }}
|
||||
/>
|
||||
</Source>
|
||||
)}
|
||||
|
||||
{milFlightsGeoJSON && (
|
||||
<Source id="military-flights" type="geojson" data={milFlightsGeoJSON as any}>
|
||||
<Source id="military-flights" type="geojson" data={EMPTY_FC as any}>
|
||||
<Layer
|
||||
id="military-flights-layer"
|
||||
type="symbol"
|
||||
@@ -1473,7 +1507,6 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
||||
paint={{ 'icon-opacity': opacityFilter }}
|
||||
/>
|
||||
</Source>
|
||||
)}
|
||||
|
||||
{shipsGeoJSON && (
|
||||
<Source
|
||||
@@ -1589,8 +1622,8 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
||||
</Source>
|
||||
)}
|
||||
|
||||
{trackedFlightsGeoJSON && (
|
||||
<Source id="tracked-flights" type="geojson" data={trackedFlightsGeoJSON as any}>
|
||||
{/* tracked-flights & UAVs: data pushed imperatively */}
|
||||
<Source id="tracked-flights" type="geojson" data={EMPTY_FC as any}>
|
||||
<Layer
|
||||
id="tracked-flights-layer"
|
||||
type="symbol"
|
||||
@@ -1604,10 +1637,8 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
||||
paint={{ 'icon-opacity': opacityFilter }}
|
||||
/>
|
||||
</Source>
|
||||
)}
|
||||
|
||||
{uavGeoJSON && (
|
||||
<Source id="uavs" type="geojson" data={uavGeoJSON as any}>
|
||||
<Source id="uavs" type="geojson" data={EMPTY_FC as any}>
|
||||
<Layer
|
||||
id="uav-layer"
|
||||
type="symbol"
|
||||
@@ -1621,31 +1652,8 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
||||
paint={{ 'icon-opacity': opacityFilter }}
|
||||
/>
|
||||
</Source>
|
||||
)}
|
||||
|
||||
{/* UAV Operational Range Circles */}
|
||||
{uavRangeGeoJSON && (
|
||||
<Source id="uav-ranges" type="geojson" data={uavRangeGeoJSON as any}>
|
||||
<Layer
|
||||
id="uav-range-fill"
|
||||
type="fill"
|
||||
paint={{
|
||||
'fill-color': '#ff4444',
|
||||
'fill-opacity': 0.04
|
||||
}}
|
||||
/>
|
||||
<Layer
|
||||
id="uav-range-border"
|
||||
type="line"
|
||||
paint={{
|
||||
'line-color': '#ff4444',
|
||||
'line-width': 1,
|
||||
'line-opacity': 0.3,
|
||||
'line-dasharray': [4, 4]
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
)}
|
||||
{/* UAV range circles removed — real ADS-B data has no fixed orbit */}
|
||||
|
||||
{gdeltGeoJSON && (
|
||||
<Source id="gdelt" type="geojson" data={gdeltGeoJSON as any}>
|
||||
@@ -1704,15 +1712,22 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
||||
);
|
||||
})}
|
||||
|
||||
{/* HTML labels for carriers (orange names) */}
|
||||
{/* HTML labels for carriers (orange names, with ESTIMATED badge for OSINT positions) */}
|
||||
{carriersGeoJSON && !selectedEntity && data?.ships?.map((s: any, i: number) => {
|
||||
if (s.type !== 'carrier' || s.lat == null || s.lng == null) return null;
|
||||
if (!inView(s.lat, s.lng)) return null;
|
||||
const [iLng, iLat] = interpShip(s);
|
||||
return (
|
||||
<Marker key={`carrier-label-${i}`} longitude={iLng} latitude={iLat} anchor="top" offset={[0, 12]} style={{ zIndex: 2 }}>
|
||||
<div style={{ color: '#ffaa00', fontSize: '11px', fontFamily: 'monospace', fontWeight: 'bold', textShadow: '0 0 3px #000, 0 0 3px #000, 1px 1px 2px #000', whiteSpace: 'nowrap', pointerEvents: 'none' }}>
|
||||
[[{s.name}]]
|
||||
<div style={{ fontFamily: 'monospace', textShadow: '0 0 3px #000, 0 0 3px #000, 1px 1px 2px #000', whiteSpace: 'nowrap', pointerEvents: 'none', textAlign: 'center' }}>
|
||||
<div style={{ color: '#ffaa00', fontSize: '11px', fontWeight: 'bold' }}>
|
||||
[[{s.name}]]
|
||||
</div>
|
||||
{s.estimated && (
|
||||
<div style={{ color: '#ff6644', fontSize: '8px', letterSpacing: '1.5px' }}>
|
||||
EST. POSITION — OSINT
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Marker>
|
||||
);
|
||||
@@ -2114,9 +2129,64 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
||||
</Source>
|
||||
)}
|
||||
|
||||
{/* Data Center positions */}
|
||||
{dataCentersGeoJSON && (
|
||||
<Source id="datacenters" type="geojson" data={dataCentersGeoJSON as any} cluster={true} clusterRadius={30} clusterMaxZoom={8}>
|
||||
{/* Cluster circles */}
|
||||
<Layer
|
||||
id="datacenters-clusters"
|
||||
type="circle"
|
||||
filter={['has', 'point_count']}
|
||||
paint={{
|
||||
'circle-color': '#7c3aed',
|
||||
'circle-radius': ['step', ['get', 'point_count'], 12, 10, 16, 50, 20],
|
||||
'circle-opacity': 0.7,
|
||||
'circle-stroke-width': 1,
|
||||
'circle-stroke-color': '#a78bfa',
|
||||
}}
|
||||
/>
|
||||
<Layer
|
||||
id="datacenters-cluster-count"
|
||||
type="symbol"
|
||||
filter={['has', 'point_count']}
|
||||
layout={{
|
||||
'text-field': '{point_count_abbreviated}',
|
||||
'text-font': ['Noto Sans Bold'],
|
||||
'text-size': 10,
|
||||
'text-allow-overlap': true,
|
||||
}}
|
||||
paint={{
|
||||
'text-color': '#e9d5ff',
|
||||
}}
|
||||
/>
|
||||
{/* Individual DC icons */}
|
||||
<Layer
|
||||
id="datacenters-layer"
|
||||
type="symbol"
|
||||
filter={['!', ['has', 'point_count']]}
|
||||
layout={{
|
||||
'icon-image': 'datacenter',
|
||||
'icon-size': ['interpolate', ['linear'], ['zoom'], 2, 0.5, 6, 0.7, 10, 1.0],
|
||||
'icon-allow-overlap': true,
|
||||
'text-field': ['step', ['zoom'], '', 6, ['get', 'name']],
|
||||
'text-font': ['Noto Sans Regular'],
|
||||
'text-size': 9,
|
||||
'text-offset': [0, 1.2],
|
||||
'text-anchor': 'top',
|
||||
'text-allow-overlap': false,
|
||||
}}
|
||||
paint={{
|
||||
'text-color': '#c4b5fd',
|
||||
'text-halo-color': 'rgba(0,0,0,0.9)',
|
||||
'text-halo-width': 1,
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
)}
|
||||
|
||||
{/* Satellite positions — mission-type icons */}
|
||||
{satellitesGeoJSON && (
|
||||
<Source id="satellites" type="geojson" data={satellitesGeoJSON as any}>
|
||||
{/* satellites: data pushed imperatively */}
|
||||
<Source id="satellites" type="geojson" data={EMPTY_FC as any}>
|
||||
<Layer
|
||||
id="satellites-layer"
|
||||
type="symbol"
|
||||
@@ -2133,7 +2203,6 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
)}
|
||||
|
||||
{/* Satellite click popup */}
|
||||
{selectedEntity?.type === 'satellite' && (() => {
|
||||
@@ -2192,7 +2261,7 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* UAV click popup */}
|
||||
{/* UAV click popup — real ADS-B detected drones */}
|
||||
{selectedEntity?.type === 'uav' && (() => {
|
||||
const uav = data?.uavs?.find((u: any) => u.id === selectedEntity.id);
|
||||
if (!uav) return null;
|
||||
@@ -2209,16 +2278,29 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
||||
fontFamily: 'monospace', fontSize: 11, minWidth: 220, maxWidth: 320
|
||||
}}>
|
||||
<div style={{ color: '#ff4444', fontWeight: 700, fontSize: 13, marginBottom: 6, letterSpacing: 1 }}>
|
||||
✈️ {uav.callsign}
|
||||
{uav.callsign}
|
||||
</div>
|
||||
<div style={{ color: '#ff8844', fontSize: 9, marginBottom: 6, letterSpacing: 1.5, textTransform: 'uppercase' as const }}>
|
||||
LIVE ADS-B TRANSPONDER
|
||||
</div>
|
||||
{uav.aircraft_model && (
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
Model: <span style={{ color: '#fff' }}>{uav.aircraft_model}</span>
|
||||
</div>
|
||||
)}
|
||||
{uav.uav_type && (
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
Type: <span style={{ color: '#ffcc00' }}>{uav.uav_type}</span>
|
||||
Classification: <span style={{ color: '#ffcc00' }}>{uav.uav_type}</span>
|
||||
</div>
|
||||
)}
|
||||
{uav.country && (
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
Country: <span style={{ color: '#fff' }}>{uav.country}</span>
|
||||
Registration: <span style={{ color: '#fff' }}>{uav.country}</span>
|
||||
</div>
|
||||
)}
|
||||
{uav.icao24 && (
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
ICAO: <span style={{ color: '#888' }}>{uav.icao24}</span>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
@@ -2229,9 +2311,9 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
||||
Speed: <span style={{ color: '#00e5ff' }}>{uav.speed_knots} kn</span>
|
||||
</div>
|
||||
)}
|
||||
{uav.range_km > 0 && (
|
||||
{uav.squawk && (
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
Operational Range: <span style={{ color: '#ff8844' }}>{uav.range_km?.toLocaleString()} km</span>
|
||||
Squawk: <span style={{ color: '#888' }}>{uav.squawk}</span>
|
||||
</div>
|
||||
)}
|
||||
{uav.wiki && (
|
||||
@@ -2243,6 +2325,135 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
||||
</Popup>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Ship / carrier click popup */}
|
||||
{selectedEntity?.type === 'ship' && (() => {
|
||||
const ship = data?.ships?.[selectedEntity.id as number];
|
||||
if (!ship) return null;
|
||||
const [iLng, iLat] = interpShip(ship);
|
||||
return (
|
||||
<Popup
|
||||
longitude={iLng} latitude={iLat}
|
||||
closeButton={false} closeOnClick={false}
|
||||
onClose={() => onEntityClick?.(null)}
|
||||
anchor="bottom" offset={12}
|
||||
>
|
||||
<div style={{
|
||||
background: 'rgba(10,14,26,0.95)', border: `1px solid ${ship.type === 'carrier' ? 'rgba(255,170,0,0.5)' : 'rgba(59,130,246,0.4)'}`,
|
||||
borderRadius: 6, padding: '10px 14px', color: '#e0e6f0',
|
||||
fontFamily: 'monospace', fontSize: 11, minWidth: 220, maxWidth: 320
|
||||
}}>
|
||||
<div className="flex justify-between items-start mb-1">
|
||||
<div style={{ color: ship.type === 'carrier' ? '#ffaa00' : '#3b82f6', fontWeight: 700, fontSize: 13, letterSpacing: 1 }}>
|
||||
{ship.name || 'UNKNOWN VESSEL'}
|
||||
</div>
|
||||
<button onClick={() => onEntityClick?.(null)} className="text-[var(--text-secondary)] hover:text-[var(--text-primary)] ml-2">✕</button>
|
||||
</div>
|
||||
{ship.estimated && (
|
||||
<div style={{ color: '#ff6644', fontSize: 9, marginBottom: 6, letterSpacing: 1.5, textTransform: 'uppercase' as const, borderBottom: '1px solid rgba(255,102,68,0.3)', paddingBottom: 4 }}>
|
||||
ESTIMATED POSITION — {ship.source || 'OSINT DERIVED'}
|
||||
</div>
|
||||
)}
|
||||
{ship.type && (
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
Type: <span style={{ color: '#fff', textTransform: 'capitalize' as const }}>{ship.type.replace('_', ' ')}</span>
|
||||
</div>
|
||||
)}
|
||||
{ship.mmsi && (
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
MMSI: <span style={{ color: '#888' }}>{ship.mmsi}</span>
|
||||
</div>
|
||||
)}
|
||||
{ship.imo && (
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
IMO: <span style={{ color: '#888' }}>{ship.imo}</span>
|
||||
</div>
|
||||
)}
|
||||
{ship.callsign && (
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
Callsign: <span style={{ color: '#00e5ff' }}>{ship.callsign}</span>
|
||||
</div>
|
||||
)}
|
||||
{ship.country && (
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
Flag: <span style={{ color: '#fff' }}>{ship.country}</span>
|
||||
</div>
|
||||
)}
|
||||
{ship.destination && (
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
Destination: <span style={{ color: '#44ff88' }}>{ship.destination}</span>
|
||||
</div>
|
||||
)}
|
||||
{typeof ship.sog === 'number' && ship.sog > 0 && (
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
Speed: <span style={{ color: '#00e5ff' }}>{ship.sog.toFixed(1)} kn</span>
|
||||
</div>
|
||||
)}
|
||||
{ship.heading != null && (
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
Heading: <span style={{ color: '#888' }}>{Math.round(ship.heading)}°</span>
|
||||
</div>
|
||||
)}
|
||||
{ship.last_osint_update && (
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
Last OSINT Update: <span style={{ color: '#888' }}>{new Date(ship.last_osint_update).toLocaleDateString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Data Center click popup */}
|
||||
{selectedEntity?.type === 'datacenter' && (() => {
|
||||
const dc = data?.datacenters?.find((_: any, i: number) => `dc-${i}` === selectedEntity.id);
|
||||
if (!dc) return null;
|
||||
// Check if any internet outage is in the same country
|
||||
const outagesInCountry = (data?.internet_outages || []).filter((o: any) =>
|
||||
o.country_name && dc.country && o.country_name.toLowerCase() === dc.country.toLowerCase()
|
||||
);
|
||||
return (
|
||||
<Popup
|
||||
longitude={dc.lng}
|
||||
latitude={dc.lat}
|
||||
closeButton={false}
|
||||
closeOnClick={false}
|
||||
onClose={() => onEntityClick?.(null)}
|
||||
className="threat-popup"
|
||||
maxWidth="280px"
|
||||
>
|
||||
<div style={{ background: '#1a1035', padding: '10px 14px', borderRadius: 8, border: '1px solid rgba(167,139,250,0.4)', fontFamily: 'monospace', fontSize: 11, color: '#e9d5ff', minWidth: 200 }}>
|
||||
<div style={{ fontWeight: 'bold', fontSize: 13, color: '#a78bfa', marginBottom: 6, borderBottom: '1px solid rgba(167,139,250,0.2)', paddingBottom: 4 }}>
|
||||
{dc.name}
|
||||
</div>
|
||||
{dc.company && (
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
Operator: <span style={{ color: '#c4b5fd' }}>{dc.company}</span>
|
||||
</div>
|
||||
)}
|
||||
{dc.city && (
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
Location: <span style={{ color: '#fff' }}>{dc.city}{dc.country ? `, ${dc.country}` : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
{!dc.city && dc.country && (
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
Country: <span style={{ color: '#fff' }}>{dc.country}</span>
|
||||
</div>
|
||||
)}
|
||||
{outagesInCountry.length > 0 && (
|
||||
<div style={{ marginTop: 6, padding: '4px 8px', background: 'rgba(255,0,0,0.15)', border: '1px solid rgba(255,80,80,0.4)', borderRadius: 4, fontSize: 10, color: '#ff6b6b' }}>
|
||||
OUTAGE IN REGION — {outagesInCountry.map((o: any) => `${o.region_name} (${o.severity}%)`).join(', ')}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ marginTop: 6, fontSize: 9, color: '#7c3aed', letterSpacing: '0.05em' }}>
|
||||
DATA CENTER
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
})()}
|
||||
|
||||
{
|
||||
selectedEntity?.type === 'gdelt' && data?.gdelt?.[selectedEntity.id as number] && (
|
||||
<Popup
|
||||
@@ -2272,20 +2483,30 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
||||
{(() => {
|
||||
const urls: string[] = data.gdelt[selectedEntity.id as number].properties?._urls_list || [];
|
||||
const headlines: string[] = data.gdelt[selectedEntity.id as number].properties?._headlines_list || [];
|
||||
if (urls.length === 0) return <span className="text-[var(--text-muted)] text-[9px]">No articles available.</span>;
|
||||
return urls.map((url: string, idx: number) => (
|
||||
<a
|
||||
key={idx}
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-orange-400 text-[9px] underline hover:text-orange-300 block py-1 border-b border-[var(--border-primary)]/50 last:border-0 cursor-pointer"
|
||||
style={{ pointerEvents: 'all' }}
|
||||
>
|
||||
{headlines[idx] || url}
|
||||
</a>
|
||||
));
|
||||
if (urls.length === 0) return <span className="text-[var(--text-muted)] text-[10px]">No articles available.</span>;
|
||||
return urls.map((url: string, idx: number) => {
|
||||
const headline = headlines[idx] || '';
|
||||
let domain = '';
|
||||
try { domain = new URL(url).hostname.replace('www.', ''); } catch { domain = ''; }
|
||||
return (
|
||||
<a
|
||||
key={idx}
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="block py-1.5 border-b border-[var(--border-primary)]/50 last:border-0 cursor-pointer group"
|
||||
style={{ pointerEvents: 'all' }}
|
||||
>
|
||||
<span className="text-orange-400 text-[11px] font-bold leading-tight group-hover:text-orange-300 block">
|
||||
{headline || domain || 'View Article'}
|
||||
</span>
|
||||
{headline && domain && (
|
||||
<span className="text-[var(--text-muted)] text-[9px] block mt-0.5">{domain}</span>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -667,10 +667,34 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 mt-2">
|
||||
<span className="text-[var(--text-muted)] text-[10px]">LATEST REPORTS:</span>
|
||||
<div
|
||||
className="text-[var(--text-primary)] text-xs whitespace-normal [&_a]:text-orange-400 [&_a]:underline hover:[&_a]:text-orange-300 [&_br]:mb-2"
|
||||
dangerouslySetInnerHTML={{ __html: props.html || 'No articles available.' }}
|
||||
/>
|
||||
<div className="flex flex-col gap-1 max-h-[250px] overflow-y-auto styled-scrollbar">
|
||||
{(() => {
|
||||
const urls: string[] = props._urls_list || [];
|
||||
const headlines: string[] = props._headlines_list || [];
|
||||
if (urls.length === 0) return <span className="text-[var(--text-muted)] text-[10px]">No articles available.</span>;
|
||||
return urls.map((url: string, idx: number) => {
|
||||
const headline = headlines[idx] || '';
|
||||
let domain = '';
|
||||
try { domain = new URL(url).hostname.replace('www.', ''); } catch { domain = ''; }
|
||||
return (
|
||||
<a
|
||||
key={idx}
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block py-1.5 border-b border-[var(--border-primary)]/50 last:border-0 cursor-pointer group"
|
||||
>
|
||||
<span className="text-orange-400 text-[11px] font-bold leading-tight group-hover:text-orange-300 block">
|
||||
{headline || domain || 'View Article'}
|
||||
</span>
|
||||
{headline && domain && (
|
||||
<span className="text-[var(--text-muted)] text-[9px] block mt-0.5">{domain}</span>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
@@ -966,9 +990,9 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
||||
<motion.div
|
||||
key={idx}
|
||||
ref={(el) => { itemRefs.current[idx] = el; }}
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
initial={idx < 15 ? { opacity: 0, x: -10 } : { opacity: 1, x: 0 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.1 + (idx * 0.05) }}
|
||||
transition={idx < 15 ? { delay: 0.1 + (idx * 0.05) } : { duration: 0 }}
|
||||
className={`p-2 rounded-sm border-l-[2px] border-r border-t border-b ${bgClass} flex flex-col gap-1 relative group shrink-0`}
|
||||
>
|
||||
<div className="flex items-center justify-between text-[8px] text-[var(--text-secondary)] uppercase tracking-widest">
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { API_BASE } from "@/lib/api";
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Settings, ExternalLink, Key, Shield, X, Save, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { Settings, ExternalLink, Key, Shield, X, Save, ChevronDown, ChevronUp, Rss, Plus, Trash2, RotateCcw } from "lucide-react";
|
||||
|
||||
interface ApiEntry {
|
||||
id: string;
|
||||
@@ -18,6 +18,22 @@ interface ApiEntry {
|
||||
is_set: boolean;
|
||||
}
|
||||
|
||||
interface FeedEntry {
|
||||
name: string;
|
||||
url: string;
|
||||
weight: number;
|
||||
}
|
||||
|
||||
const WEIGHT_LABELS: Record<number, string> = { 1: "LOW", 2: "MED", 3: "STD", 4: "HIGH", 5: "CRIT" };
|
||||
const WEIGHT_COLORS: Record<number, string> = {
|
||||
1: "text-gray-400 border-gray-600",
|
||||
2: "text-blue-400 border-blue-600",
|
||||
3: "text-cyan-400 border-cyan-600",
|
||||
4: "text-orange-400 border-orange-600",
|
||||
5: "text-red-400 border-red-600",
|
||||
};
|
||||
const MAX_FEEDS = 20;
|
||||
|
||||
// Category colors for the tactical UI
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
Aviation: "text-cyan-400 border-cyan-500/30 bg-cyan-950/20",
|
||||
@@ -31,33 +47,54 @@ const CATEGORY_COLORS: Record<string, string> = {
|
||||
SIGINT: "text-rose-400 border-rose-500/30 bg-rose-950/20",
|
||||
};
|
||||
|
||||
type Tab = "api-keys" | "news-feeds";
|
||||
|
||||
const SettingsPanel = React.memo(function SettingsPanel({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) {
|
||||
const [activeTab, setActiveTab] = useState<Tab>("api-keys");
|
||||
|
||||
// --- API Keys state ---
|
||||
const [apis, setApis] = useState<ApiEntry[]>([]);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editValue, setEditValue] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set(["Aviation", "Maritime"]));
|
||||
|
||||
// --- News Feeds state ---
|
||||
const [feeds, setFeeds] = useState<FeedEntry[]>([]);
|
||||
const [feedsDirty, setFeedsDirty] = useState(false);
|
||||
const [feedSaving, setFeedSaving] = useState(false);
|
||||
const [feedMsg, setFeedMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null);
|
||||
|
||||
const fetchKeys = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/settings/api-keys`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setApis(data);
|
||||
}
|
||||
if (res.ok) setApis(await res.json());
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch API keys", e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) fetchKeys();
|
||||
}, [isOpen, fetchKeys]);
|
||||
const fetchFeeds = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/settings/news-feeds`);
|
||||
if (res.ok) {
|
||||
setFeeds(await res.json());
|
||||
setFeedsDirty(false);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch news feeds", e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startEditing = (api: ApiEntry) => {
|
||||
setEditingId(api.id);
|
||||
setEditValue("");
|
||||
};
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
fetchKeys();
|
||||
fetchFeeds();
|
||||
}
|
||||
}, [isOpen, fetchKeys, fetchFeeds]);
|
||||
|
||||
// API Keys handlers
|
||||
const startEditing = (api: ApiEntry) => { setEditingId(api.id); setEditValue(""); };
|
||||
|
||||
const saveKey = async (api: ApiEntry) => {
|
||||
if (!api.env_key) return;
|
||||
@@ -68,33 +105,81 @@ const SettingsPanel = React.memo(function SettingsPanel({ isOpen, onClose }: { i
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ env_key: api.env_key, value: editValue }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setEditingId(null);
|
||||
fetchKeys(); // Refresh to get new obfuscated value
|
||||
}
|
||||
if (res.ok) { setEditingId(null); fetchKeys(); }
|
||||
} catch (e) {
|
||||
console.error("Failed to save API key", e);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
} finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const toggleCategory = (cat: string) => {
|
||||
setExpandedCategories(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(cat)) next.delete(cat);
|
||||
else next.add(cat);
|
||||
if (next.has(cat)) next.delete(cat); else next.add(cat);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Group APIs by category
|
||||
const grouped = apis.reduce<Record<string, ApiEntry[]>>((acc, api) => {
|
||||
if (!acc[api.category]) acc[api.category] = [];
|
||||
acc[api.category].push(api);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// News Feeds handlers
|
||||
const updateFeed = (idx: number, field: keyof FeedEntry, value: string | number) => {
|
||||
setFeeds(prev => prev.map((f, i) => i === idx ? { ...f, [field]: value } : f));
|
||||
setFeedsDirty(true);
|
||||
setFeedMsg(null);
|
||||
};
|
||||
|
||||
const removeFeed = (idx: number) => {
|
||||
setFeeds(prev => prev.filter((_, i) => i !== idx));
|
||||
setFeedsDirty(true);
|
||||
setFeedMsg(null);
|
||||
};
|
||||
|
||||
const addFeed = () => {
|
||||
if (feeds.length >= MAX_FEEDS) return;
|
||||
setFeeds(prev => [...prev, { name: "", url: "", weight: 3 }]);
|
||||
setFeedsDirty(true);
|
||||
setFeedMsg(null);
|
||||
};
|
||||
|
||||
const saveFeeds = async () => {
|
||||
setFeedSaving(true);
|
||||
setFeedMsg(null);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/settings/news-feeds`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(feeds),
|
||||
});
|
||||
if (res.ok) {
|
||||
setFeedsDirty(false);
|
||||
setFeedMsg({ type: "ok", text: "Feeds saved. Changes take effect on next news refresh (~30min) or manual /api/refresh." });
|
||||
} else {
|
||||
const d = await res.json().catch(() => ({}));
|
||||
setFeedMsg({ type: "err", text: d.message || "Save failed" });
|
||||
}
|
||||
} catch (e) {
|
||||
setFeedMsg({ type: "err", text: "Network error" });
|
||||
} finally { setFeedSaving(false); }
|
||||
};
|
||||
|
||||
const resetFeeds = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/settings/news-feeds/reset`, { method: "POST" });
|
||||
if (res.ok) {
|
||||
const d = await res.json();
|
||||
setFeeds(d.feeds || []);
|
||||
setFeedsDirty(false);
|
||||
setFeedMsg({ type: "ok", text: "Reset to defaults" });
|
||||
}
|
||||
} catch (e) {
|
||||
setFeedMsg({ type: "err", text: "Reset failed" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
@@ -124,7 +209,7 @@ const SettingsPanel = React.memo(function SettingsPanel({ isOpen, onClose }: { i
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-sm font-bold tracking-[0.2em] text-[var(--text-primary)] font-mono">SYSTEM CONFIG</h2>
|
||||
<span className="text-[9px] text-[var(--text-muted)] font-mono tracking-widest">API KEY REGISTRY</span>
|
||||
<span className="text-[9px] text-[var(--text-muted)] font-mono tracking-widest">SETTINGS & DATA SOURCES</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -135,153 +220,237 @@ const SettingsPanel = React.memo(function SettingsPanel({ isOpen, onClose }: { i
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Info Banner */}
|
||||
<div className="mx-4 mt-4 p-3 rounded-lg border border-cyan-900/30 bg-cyan-950/10">
|
||||
<div className="flex items-start gap-2">
|
||||
<Shield size={12} className="text-cyan-500 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-[10px] text-[var(--text-secondary)] font-mono leading-relaxed">
|
||||
API keys are stored locally in the backend <span className="text-cyan-400">.env</span> file. Keys marked with <Key size={8} className="inline text-yellow-500" /> are required for full functionality. Public APIs need no key.
|
||||
</p>
|
||||
</div>
|
||||
{/* Tab Bar */}
|
||||
<div className="flex border-b border-[var(--border-primary)]/60">
|
||||
<button
|
||||
onClick={() => setActiveTab("api-keys")}
|
||||
className={`flex-1 px-4 py-2.5 text-[10px] font-mono tracking-widest font-bold transition-colors flex items-center justify-center gap-1.5 ${activeTab === "api-keys" ? "text-cyan-400 border-b-2 border-cyan-500 bg-cyan-950/10" : "text-[var(--text-muted)] hover:text-[var(--text-secondary)]"}`}
|
||||
>
|
||||
<Key size={10} />
|
||||
API KEYS
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("news-feeds")}
|
||||
className={`flex-1 px-4 py-2.5 text-[10px] font-mono tracking-widest font-bold transition-colors flex items-center justify-center gap-1.5 ${activeTab === "news-feeds" ? "text-orange-400 border-b-2 border-orange-500 bg-orange-950/10" : "text-[var(--text-muted)] hover:text-[var(--text-secondary)]"}`}
|
||||
>
|
||||
<Rss size={10} />
|
||||
NEWS FEEDS
|
||||
{feedsDirty && <span className="w-1.5 h-1.5 rounded-full bg-orange-400 animate-pulse" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* API List */}
|
||||
<div className="flex-1 overflow-y-auto styled-scrollbar p-4 space-y-3">
|
||||
{Object.entries(grouped).map(([category, categoryApis]) => {
|
||||
const colorClass = CATEGORY_COLORS[category] || "text-gray-400 border-gray-700 bg-gray-900/20";
|
||||
const isExpanded = expandedCategories.has(category);
|
||||
|
||||
return (
|
||||
<div key={category} className="rounded-lg border border-[var(--border-primary)]/60 overflow-hidden">
|
||||
{/* Category Header */}
|
||||
<button
|
||||
onClick={() => toggleCategory(category)}
|
||||
className="w-full flex items-center justify-between px-4 py-2.5 bg-[var(--bg-secondary)]/50 hover:bg-[var(--bg-secondary)]/80 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-[9px] font-mono tracking-widest font-bold px-2 py-0.5 rounded border ${colorClass}`}>
|
||||
{category.toUpperCase()}
|
||||
</span>
|
||||
<span className="text-[10px] text-[var(--text-muted)] font-mono">
|
||||
{categoryApis.length} {categoryApis.length === 1 ? 'service' : 'services'}
|
||||
</span>
|
||||
</div>
|
||||
{isExpanded ? <ChevronUp size={12} className="text-[var(--text-muted)]" /> : <ChevronDown size={12} className="text-[var(--text-muted)]" />}
|
||||
</button>
|
||||
|
||||
{/* APIs in Category */}
|
||||
<AnimatePresence>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{categoryApis.map((api) => (
|
||||
<div key={api.id} className="border-t border-[var(--border-primary)]/40 px-4 py-3 hover:bg-[var(--bg-secondary)]/30 transition-colors">
|
||||
{/* API Name + Status */}
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{api.required && <Key size={10} className="text-yellow-500" />}
|
||||
<span className="text-xs font-mono text-[var(--text-primary)] font-medium">{api.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{api.has_key ? (
|
||||
api.is_set ? (
|
||||
<span className="text-[8px] font-mono px-1.5 py-0.5 rounded border border-green-500/30 text-green-400 bg-green-950/20">
|
||||
KEY SET
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[8px] font-mono px-1.5 py-0.5 rounded border border-yellow-500/30 text-yellow-400 bg-yellow-950/20">
|
||||
MISSING
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
<span className="text-[8px] font-mono px-1.5 py-0.5 rounded border border-[var(--border-primary)] text-[var(--text-muted)]">
|
||||
PUBLIC
|
||||
</span>
|
||||
)}
|
||||
{api.url && (
|
||||
<a
|
||||
href={api.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[var(--text-muted)] hover:text-cyan-400 transition-colors"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ExternalLink size={10} />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-[10px] text-[var(--text-muted)] font-mono leading-relaxed mb-2">
|
||||
{api.description}
|
||||
</p>
|
||||
|
||||
{/* Key Field (only for APIs with keys) */}
|
||||
{api.has_key && (
|
||||
<div className="mt-2">
|
||||
{editingId === api.id ? (
|
||||
/* Edit Mode */
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
className="flex-1 bg-black/60 border border-cyan-900/50 rounded px-2 py-1.5 text-[11px] font-mono text-cyan-300 outline-none focus:border-cyan-500/70 transition-colors"
|
||||
placeholder="Enter API key..."
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={() => saveKey(api)}
|
||||
disabled={saving}
|
||||
className="px-3 py-1.5 rounded bg-cyan-500/20 border border-cyan-500/40 text-cyan-400 hover:bg-cyan-500/30 transition-colors text-[10px] font-mono flex items-center gap-1"
|
||||
>
|
||||
<Save size={10} />
|
||||
{saving ? "..." : "SAVE"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditingId(null)}
|
||||
className="px-2 py-1.5 rounded border border-[var(--border-primary)] text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:border-[var(--border-secondary)] transition-colors text-[10px] font-mono"
|
||||
>
|
||||
ESC
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
/* Display Mode */
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div
|
||||
className="flex-1 bg-[var(--bg-primary)]/40 border border-[var(--border-primary)] rounded px-2.5 py-1.5 font-mono text-[11px] cursor-pointer hover:border-[var(--border-secondary)] transition-colors select-none"
|
||||
onClick={() => startEditing(api)}
|
||||
>
|
||||
<span className="text-[var(--text-muted)] tracking-wider">
|
||||
{api.is_set ? api.value_obfuscated : "Click to set key..."}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{/* ==================== API KEYS TAB ==================== */}
|
||||
{activeTab === "api-keys" && (
|
||||
<>
|
||||
{/* Info Banner */}
|
||||
<div className="mx-4 mt-4 p-3 rounded-lg border border-cyan-900/30 bg-cyan-950/10">
|
||||
<div className="flex items-start gap-2">
|
||||
<Shield size={12} className="text-cyan-500 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-[10px] text-[var(--text-secondary)] font-mono leading-relaxed">
|
||||
API keys are stored locally in the backend <span className="text-cyan-400">.env</span> file. Keys marked with <Key size={8} className="inline text-yellow-500" /> are required for full functionality. Public APIs need no key.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t border-[var(--border-primary)]/80">
|
||||
<div className="flex items-center justify-between text-[9px] text-[var(--text-muted)] font-mono">
|
||||
<span>{apis.length} REGISTERED APIs</span>
|
||||
<span>{apis.filter(a => a.has_key).length} KEYS CONFIGURED</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* API List */}
|
||||
<div className="flex-1 overflow-y-auto styled-scrollbar p-4 space-y-3">
|
||||
{Object.entries(grouped).map(([category, categoryApis]) => {
|
||||
const colorClass = CATEGORY_COLORS[category] || "text-gray-400 border-gray-700 bg-gray-900/20";
|
||||
const isExpanded = expandedCategories.has(category);
|
||||
return (
|
||||
<div key={category} className="rounded-lg border border-[var(--border-primary)]/60 overflow-hidden">
|
||||
<button
|
||||
onClick={() => toggleCategory(category)}
|
||||
className="w-full flex items-center justify-between px-4 py-2.5 bg-[var(--bg-secondary)]/50 hover:bg-[var(--bg-secondary)]/80 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-[9px] font-mono tracking-widest font-bold px-2 py-0.5 rounded border ${colorClass}`}>
|
||||
{category.toUpperCase()}
|
||||
</span>
|
||||
<span className="text-[10px] text-[var(--text-muted)] font-mono">
|
||||
{categoryApis.length} {categoryApis.length === 1 ? 'service' : 'services'}
|
||||
</span>
|
||||
</div>
|
||||
{isExpanded ? <ChevronUp size={12} className="text-[var(--text-muted)]" /> : <ChevronDown size={12} className="text-[var(--text-muted)]" />}
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{categoryApis.map((api) => (
|
||||
<div key={api.id} className="border-t border-[var(--border-primary)]/40 px-4 py-3 hover:bg-[var(--bg-secondary)]/30 transition-colors">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{api.required && <Key size={10} className="text-yellow-500" />}
|
||||
<span className="text-xs font-mono text-[var(--text-primary)] font-medium">{api.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{api.has_key ? (
|
||||
api.is_set ? (
|
||||
<span className="text-[8px] font-mono px-1.5 py-0.5 rounded border border-green-500/30 text-green-400 bg-green-950/20">KEY SET</span>
|
||||
) : (
|
||||
<span className="text-[8px] font-mono px-1.5 py-0.5 rounded border border-yellow-500/30 text-yellow-400 bg-yellow-950/20">MISSING</span>
|
||||
)
|
||||
) : (
|
||||
<span className="text-[8px] font-mono px-1.5 py-0.5 rounded border border-[var(--border-primary)] text-[var(--text-muted)]">PUBLIC</span>
|
||||
)}
|
||||
{api.url && (
|
||||
<a href={api.url} target="_blank" rel="noopener noreferrer" className="text-[var(--text-muted)] hover:text-cyan-400 transition-colors" onClick={(e) => e.stopPropagation()}>
|
||||
<ExternalLink size={10} />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-[var(--text-muted)] font-mono leading-relaxed mb-2">{api.description}</p>
|
||||
{api.has_key && (
|
||||
<div className="mt-2">
|
||||
{editingId === api.id ? (
|
||||
<div className="flex gap-2">
|
||||
<input type="text" value={editValue} onChange={(e) => setEditValue(e.target.value)} className="flex-1 bg-black/60 border border-cyan-900/50 rounded px-2 py-1.5 text-[11px] font-mono text-cyan-300 outline-none focus:border-cyan-500/70 transition-colors" placeholder="Enter API key..." autoFocus />
|
||||
<button onClick={() => saveKey(api)} disabled={saving} className="px-3 py-1.5 rounded bg-cyan-500/20 border border-cyan-500/40 text-cyan-400 hover:bg-cyan-500/30 transition-colors text-[10px] font-mono flex items-center gap-1">
|
||||
<Save size={10} />{saving ? "..." : "SAVE"}
|
||||
</button>
|
||||
<button onClick={() => setEditingId(null)} className="px-2 py-1.5 rounded border border-[var(--border-primary)] text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:border-[var(--border-secondary)] transition-colors text-[10px] font-mono">ESC</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex-1 bg-[var(--bg-primary)]/40 border border-[var(--border-primary)] rounded px-2.5 py-1.5 font-mono text-[11px] cursor-pointer hover:border-[var(--border-secondary)] transition-colors select-none" onClick={() => startEditing(api)}>
|
||||
<span className="text-[var(--text-muted)] tracking-wider">{api.is_set ? api.value_obfuscated : "Click to set key..."}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t border-[var(--border-primary)]/80">
|
||||
<div className="flex items-center justify-between text-[9px] text-[var(--text-muted)] font-mono">
|
||||
<span>{apis.length} REGISTERED APIs</span>
|
||||
<span>{apis.filter(a => a.has_key).length} KEYS CONFIGURED</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ==================== NEWS FEEDS TAB ==================== */}
|
||||
{activeTab === "news-feeds" && (
|
||||
<>
|
||||
{/* Info Banner */}
|
||||
<div className="mx-4 mt-4 p-3 rounded-lg border border-orange-900/30 bg-orange-950/10">
|
||||
<div className="flex items-start gap-2">
|
||||
<Rss size={12} className="text-orange-500 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-[10px] text-[var(--text-secondary)] font-mono leading-relaxed">
|
||||
Configure RSS/Atom feeds for the Threat Intel news panel. Each feed is scored by keyword heuristics and weighted by the priority you set. Up to <span className="text-orange-400">{MAX_FEEDS}</span> sources.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feed List */}
|
||||
<div className="flex-1 overflow-y-auto styled-scrollbar p-4 space-y-2">
|
||||
{feeds.map((feed, idx) => (
|
||||
<div key={idx} className="rounded-lg border border-[var(--border-primary)]/60 p-3 hover:border-[var(--border-secondary)]/60 transition-colors group">
|
||||
{/* Row 1: Name + Weight + Delete */}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<input
|
||||
type="text"
|
||||
value={feed.name}
|
||||
onChange={(e) => updateFeed(idx, "name", e.target.value)}
|
||||
className="flex-1 bg-transparent border-b border-[var(--border-primary)] text-xs font-mono text-[var(--text-primary)] outline-none focus:border-cyan-500/70 transition-colors px-1 py-0.5"
|
||||
placeholder="Source name..."
|
||||
/>
|
||||
{/* Weight selector */}
|
||||
<div className="flex items-center gap-1">
|
||||
{[1, 2, 3, 4, 5].map(w => (
|
||||
<button
|
||||
key={w}
|
||||
onClick={() => updateFeed(idx, "weight", w)}
|
||||
className={`w-5 h-5 rounded text-[8px] font-mono font-bold border transition-all ${feed.weight === w ? WEIGHT_COLORS[w] + " bg-black/40" : "border-[var(--border-primary)]/40 text-[var(--text-muted)]/50 hover:border-[var(--border-secondary)]"}`}
|
||||
title={WEIGHT_LABELS[w]}
|
||||
>
|
||||
{w}
|
||||
</button>
|
||||
))}
|
||||
<span className={`text-[8px] font-mono ml-1 w-7 ${WEIGHT_COLORS[feed.weight]?.split(" ")[0] || "text-gray-400"}`}>
|
||||
{WEIGHT_LABELS[feed.weight] || "STD"}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeFeed(idx)}
|
||||
className="w-6 h-6 rounded flex items-center justify-center text-[var(--text-muted)] hover:text-red-400 hover:bg-red-950/20 transition-all opacity-0 group-hover:opacity-100"
|
||||
title="Remove feed"
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</button>
|
||||
</div>
|
||||
{/* Row 2: URL */}
|
||||
<input
|
||||
type="text"
|
||||
value={feed.url}
|
||||
onChange={(e) => updateFeed(idx, "url", e.target.value)}
|
||||
className="w-full bg-black/30 border border-[var(--border-primary)]/40 rounded px-2 py-1 text-[10px] font-mono text-[var(--text-muted)] outline-none focus:border-cyan-500/50 focus:text-cyan-300 transition-colors"
|
||||
placeholder="https://example.com/rss.xml"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Add Feed Button */}
|
||||
<button
|
||||
onClick={addFeed}
|
||||
disabled={feeds.length >= MAX_FEEDS}
|
||||
className="w-full py-2.5 rounded-lg border border-dashed border-[var(--border-primary)]/60 text-[var(--text-muted)] hover:border-orange-500/50 hover:text-orange-400 hover:bg-orange-950/10 transition-all text-[10px] font-mono flex items-center justify-center gap-1.5 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Plus size={10} />
|
||||
ADD FEED ({feeds.length}/{MAX_FEEDS})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Status message */}
|
||||
{feedMsg && (
|
||||
<div className={`mx-4 mb-2 px-3 py-2 rounded text-[10px] font-mono ${feedMsg.type === "ok" ? "text-green-400 bg-green-950/20 border border-green-900/30" : "text-red-400 bg-red-950/20 border border-red-900/30"}`}>
|
||||
{feedMsg.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t border-[var(--border-primary)]/80">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={saveFeeds}
|
||||
disabled={!feedsDirty || feedSaving}
|
||||
className="flex-1 px-4 py-2 rounded bg-orange-500/20 border border-orange-500/40 text-orange-400 hover:bg-orange-500/30 transition-colors text-[10px] font-mono flex items-center justify-center gap-1.5 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Save size={10} />
|
||||
{feedSaving ? "SAVING..." : "SAVE FEEDS"}
|
||||
</button>
|
||||
<button
|
||||
onClick={resetFeeds}
|
||||
className="px-3 py-2 rounded border border-[var(--border-primary)] text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:border-[var(--border-secondary)] transition-all text-[10px] font-mono flex items-center gap-1.5"
|
||||
title="Reset to defaults"
|
||||
>
|
||||
<RotateCcw size={10} />
|
||||
RESET
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-[9px] text-[var(--text-muted)] font-mono mt-2">
|
||||
<span>{feeds.length}/{MAX_FEEDS} SOURCES</span>
|
||||
<span>WEIGHT: 1=LOW 5=CRITICAL</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,10 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import React, { useState, useEffect, useRef, useMemo } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Plane, AlertTriangle, Activity, Satellite, Cctv, ChevronDown, ChevronUp, Ship, Eye, Anchor, Settings, Sun, Moon, BookOpen, Radio, Play, Pause, Globe, Flame, Wifi } from "lucide-react";
|
||||
import { Plane, AlertTriangle, Activity, Satellite, Cctv, ChevronDown, ChevronUp, Ship, Eye, Anchor, Settings, Sun, Moon, BookOpen, Radio, Play, Pause, Globe, Flame, Wifi, Server } from "lucide-react";
|
||||
import { useTheme } from "@/lib/ThemeContext";
|
||||
|
||||
function relativeTime(iso: string | undefined): string {
|
||||
if (!iso) return "";
|
||||
const diff = Date.now() - new Date(iso + "Z").getTime();
|
||||
if (diff < 0) return "now";
|
||||
const sec = Math.floor(diff / 1000);
|
||||
if (sec < 60) return `${sec}s ago`;
|
||||
const min = Math.floor(sec / 60);
|
||||
if (min < 60) return `${min}m ago`;
|
||||
const hr = Math.floor(min / 60);
|
||||
if (hr < 24) return `${hr}h ago`;
|
||||
return `${Math.floor(hr / 24)}d ago`;
|
||||
}
|
||||
|
||||
// Map layer IDs to freshness keys from the backend source_timestamps dict
|
||||
const FRESHNESS_MAP: Record<string, string> = {
|
||||
flights: "commercial_flights",
|
||||
private: "private_flights",
|
||||
jets: "private_jets",
|
||||
military: "military_flights",
|
||||
tracked: "military_flights",
|
||||
earthquakes: "earthquakes",
|
||||
satellites: "satellites",
|
||||
ships_important: "ships",
|
||||
ships_civilian: "ships",
|
||||
ships_passenger: "ships",
|
||||
ukraine_frontline: "frontlines",
|
||||
global_incidents: "gdelt",
|
||||
cctv: "cctv",
|
||||
gps_jamming: "commercial_flights",
|
||||
kiwisdr: "kiwisdr",
|
||||
firms: "firms_fires",
|
||||
internet_outages: "internet_outages",
|
||||
datacenters: "datacenters",
|
||||
};
|
||||
|
||||
const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, activeLayers, setActiveLayers, onSettingsClick, onLegendClick, gibsDate, setGibsDate, gibsOpacity, setGibsOpacity }: { data: any; activeLayers: any; setActiveLayers: any; onSettingsClick?: () => void; onLegendClick?: () => void; gibsDate?: string; setGibsDate?: (d: string) => void; gibsOpacity?: number; setGibsOpacity?: (o: number) => void }) {
|
||||
const [isMinimized, setIsMinimized] = useState(false);
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
@@ -35,10 +70,19 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, active
|
||||
return () => { if (gibsIntervalRef.current) clearInterval(gibsIntervalRef.current); };
|
||||
}, [gibsPlaying, gibsDate, setGibsDate]);
|
||||
|
||||
// Compute ship category counts
|
||||
const importantShipCount = data?.ships?.filter((s: any) => ['carrier', 'military_vessel', 'tanker', 'cargo'].includes(s.type))?.length || 0;
|
||||
const passengerShipCount = data?.ships?.filter((s: any) => s.type === 'passenger')?.length || 0;
|
||||
const civilianShipCount = data?.ships?.filter((s: any) => !['carrier', 'military_vessel', 'tanker', 'cargo', 'passenger'].includes(s.type))?.length || 0;
|
||||
// Compute ship category counts (memoized — ships array can be 1000+ items)
|
||||
const { importantShipCount, passengerShipCount, civilianShipCount } = useMemo(() => {
|
||||
const ships = data?.ships;
|
||||
if (!ships || !ships.length) return { importantShipCount: 0, passengerShipCount: 0, civilianShipCount: 0 };
|
||||
let important = 0, passenger = 0, civilian = 0;
|
||||
for (const s of ships) {
|
||||
const t = s.type;
|
||||
if (t === 'carrier' || t === 'military_vessel' || t === 'tanker' || t === 'cargo') important++;
|
||||
else if (t === 'passenger') passenger++;
|
||||
else civilian++;
|
||||
}
|
||||
return { importantShipCount: important, passengerShipCount: passenger, civilianShipCount: civilian };
|
||||
}, [data?.ships]);
|
||||
|
||||
const layers = [
|
||||
{ id: "flights", name: "Commercial Flights", source: "adsb.lol", count: data?.commercial_flights?.length || 0, icon: Plane },
|
||||
@@ -60,6 +104,7 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, active
|
||||
{ id: "kiwisdr", name: "KiwiSDR Receivers", source: "KiwiSDR.com", count: data?.kiwisdr?.length || 0, icon: Radio },
|
||||
{ id: "firms", name: "Fire Hotspots (24h)", source: "NASA FIRMS VIIRS", count: data?.firms_fires?.length || 0, icon: Flame },
|
||||
{ id: "internet_outages", name: "Internet Outages", source: "IODA / Georgia Tech", count: data?.internet_outages?.length || 0, icon: Wifi },
|
||||
{ id: "datacenters", name: "Data Centers", source: "DC Map (GitHub)", count: data?.datacenters?.length || 0, icon: Server },
|
||||
{ id: "day_night", name: "Day / Night Cycle", source: "Solar Calc", count: null, icon: Sun },
|
||||
];
|
||||
|
||||
@@ -146,7 +191,12 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, active
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className={`text-sm font-medium ${active ? 'text-[var(--text-primary)]' : 'text-[var(--text-secondary)]'} tracking-wide`}>{layer.name}</span>
|
||||
<span className="text-[9px] text-[var(--text-muted)] font-mono tracking-wider mt-0.5">{layer.source} · {active ? 'LIVE' : 'OFF'}</span>
|
||||
<span className="text-[9px] text-[var(--text-muted)] font-mono tracking-wider mt-0.5">{layer.source} · {active ? (() => {
|
||||
const fKey = FRESHNESS_MAP[layer.id];
|
||||
const freshness = fKey && data?.freshness?.[fKey];
|
||||
const rt = freshness ? relativeTime(freshness) : '';
|
||||
return rt ? <span className="text-cyan-500/70">{rt}</span> : 'LIVE';
|
||||
})() : 'OFF'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
+7
-28
@@ -1,28 +1,7 @@
|
||||
// NEXT_PUBLIC_* vars are baked at build time in Next.js, so setting them
|
||||
// in docker-compose `environment` has no effect at runtime. Instead we
|
||||
// auto-detect: use the browser's current hostname with a configurable port
|
||||
// so the dashboard works on localhost, LAN IPs, and custom Docker port maps
|
||||
// without any code changes.
|
||||
//
|
||||
// Override order:
|
||||
// 1. Build-time NEXT_PUBLIC_API_URL (for advanced users who rebuild the image)
|
||||
// 2. Runtime auto-detect from window.location.hostname + port 8000
|
||||
|
||||
function resolveApiBase(): string {
|
||||
// Build-time override (works when image is rebuilt with the env var)
|
||||
if (process.env.NEXT_PUBLIC_API_URL) {
|
||||
return process.env.NEXT_PUBLIC_API_URL;
|
||||
}
|
||||
|
||||
// Server-side rendering: fall back to localhost
|
||||
if (typeof window === "undefined") {
|
||||
return "http://localhost:8000";
|
||||
}
|
||||
|
||||
// Client-side: use the same hostname the user is browsing on
|
||||
const proto = window.location.protocol;
|
||||
const host = window.location.hostname;
|
||||
return `${proto}//${host}:8000`;
|
||||
}
|
||||
|
||||
export const API_BASE = resolveApiBase();
|
||||
// All API calls use relative paths (e.g. /api/flights).
|
||||
// Next.js rewrites them at the server level to BACKEND_URL (set in docker-compose
|
||||
// or .env.local for dev). This means:
|
||||
// - No build-time baking of the backend URL into the client bundle
|
||||
// - BACKEND_URL=http://backend:8000 works via Docker internal networking
|
||||
// - Only port 3000 needs to be exposed externally
|
||||
export const API_BASE = "";
|
||||
|
||||
@@ -17,9 +17,16 @@ if %errorlevel% neq 0 (
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
:: Check Python version
|
||||
:: Check Python version (warn if 3.13+)
|
||||
for /f "tokens=2 delims= " %%v in ('python --version 2^>^&1') do set PYVER=%%v
|
||||
echo [*] Found Python %PYVER%
|
||||
for /f "tokens=1,2 delims=." %%a in ("%PYVER%") do (
|
||||
if %%b GEQ 13 (
|
||||
echo [!] WARNING: Python %PYVER% detected. Some packages may fail to build.
|
||||
echo [!] Recommended: Python 3.10, 3.11, or 3.12.
|
||||
echo.
|
||||
)
|
||||
)
|
||||
|
||||
:: Check for Node.js
|
||||
where npm >nul 2>&1
|
||||
@@ -47,7 +54,7 @@ if not exist "venv\" (
|
||||
)
|
||||
call venv\Scripts\activate.bat
|
||||
echo [*] Installing Python dependencies (this may take a minute)...
|
||||
pip install -r requirements.txt
|
||||
pip install -q -r requirements.txt
|
||||
if %errorlevel% neq 0 (
|
||||
echo.
|
||||
echo [!] ERROR: pip install failed. See errors above.
|
||||
|
||||
@@ -23,7 +23,14 @@ else
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[*] Found $($PYTHON_CMD --version 2>&1)"
|
||||
PYVER=$($PYTHON_CMD --version 2>&1 | awk '{print $2}')
|
||||
echo "[*] Found Python $PYVER"
|
||||
PY_MINOR=$(echo "$PYVER" | cut -d. -f2)
|
||||
if [ "$PY_MINOR" -ge 13 ] 2>/dev/null; then
|
||||
echo "[!] WARNING: Python $PYVER detected. Some packages may fail to build."
|
||||
echo "[!] Recommended: Python 3.10, 3.11, or 3.12."
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Get the directory where this script lives
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
@@ -42,7 +49,7 @@ fi
|
||||
|
||||
source venv/bin/activate
|
||||
echo "[*] Installing Python dependencies (this may take a minute)..."
|
||||
pip install -r requirements.txt
|
||||
pip install -q -r requirements.txt
|
||||
if [ $? -ne 0 ]; then
|
||||
echo ""
|
||||
echo "[!] ERROR: pip install failed. See errors above."
|
||||
|
||||
Reference in New Issue
Block a user