mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-05-16 04:59:05 +02:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ae104fca2 | |||
| 12857a4b83 | |||
| c343084def | |||
| c085475110 | |||
| e0257d2419 | |||
| 5d221c3dc7 | |||
| dd8485d1b6 | |||
| f6aa5ccbc1 | |||
| 97208a01a2 | |||
| d4c725de6e | |||
| d756dd5bd3 | |||
| d96e8f5c21 | |||
| 8afcbca667 | |||
| b68de6a594 | |||
| 36dec1088d | |||
| a38f4cbaea | |||
| 8e7ef8e95e | |||
| e597147a16 | |||
| 71c085cdd5 | |||
| c9cec26309 | |||
| 03aae3216b | |||
| 31755b294e | |||
| 9c831e37ff |
@@ -40,6 +40,10 @@ jobs:
|
|||||||
uses: docker/metadata-action@v5.0.0
|
uses: docker/metadata-action@v5.0.0
|
||||||
with:
|
with:
|
||||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-frontend
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-frontend
|
||||||
|
tags: |
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
id: build-and-push
|
id: build-and-push
|
||||||
@@ -79,6 +83,10 @@ jobs:
|
|||||||
uses: docker/metadata-action@v5.0.0
|
uses: docker/metadata-action@v5.0.0
|
||||||
with:
|
with:
|
||||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-backend
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-backend
|
||||||
|
tags: |
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
id: build-and-push
|
id: build-and-push
|
||||||
|
|||||||
@@ -68,6 +68,12 @@ TheAirTraffic Database.xlsx
|
|||||||
# Debug dumps & release artifacts
|
# Debug dumps & release artifacts
|
||||||
backend/dump.json
|
backend/dump.json
|
||||||
backend/debug_fast.json
|
backend/debug_fast.json
|
||||||
|
backend/nyc_sample.json
|
||||||
|
backend/nyc_full.json
|
||||||
|
backend/liveua_test.html
|
||||||
|
backend/out_liveua.json
|
||||||
|
frontend/server_logs*.txt
|
||||||
|
frontend/cctv.db
|
||||||
*.zip
|
*.zip
|
||||||
.git_backup/
|
.git_backup/
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,12 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
---
|
---
|
||||||

|
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
**ShadowBroker** is a real-time, multi-domain OSINT dashboard that aggregates live data from dozens of open-source intelligence feeds and renders them on a unified dark-ops map interface. It tracks aircraft, ships, satellites, earthquakes, conflict zones, CCTV networks, GPS jamming, and breaking geopolitical events — all updating in real time.
|
**ShadowBroker** is a real-time, multi-domain OSINT dashboard that aggregates live data from dozens of open-source intelligence feeds and renders them on a unified dark-ops map interface. It tracks aircraft, ships, satellites, earthquakes, conflict zones, CCTV networks, GPS jamming, and breaking geopolitical events — all updating in real time.
|
||||||
|
|
||||||
Built with **Next.js**, **MapLibre GL**, **FastAPI**, and **Python**, it's designed for analysts, researchers, and enthusiasts who want a single-pane-of-glass view of global activity.
|
Built with **Next.js**, **MapLibre GL**, **FastAPI**, and **Python**, it's designed for analysts, researchers, and enthusiasts who want a single-pane-of-glass view of global activity.
|
||||||
@@ -17,7 +22,8 @@ Built with **Next.js**, **MapLibre GL**, **FastAPI**, and **Python**, it's desig
|
|||||||
## Interesting Use Cases
|
## Interesting Use Cases
|
||||||
|
|
||||||
* Track private jets of billionaires
|
* Track private jets of billionaires
|
||||||
* Monitor satellites passing overhead
|
* Monitor satellites passing overhead and see high-resolution satellite imagery
|
||||||
|
* Nose around local emergency scanners
|
||||||
* Watch naval traffic worldwide
|
* Watch naval traffic worldwide
|
||||||
* Detect GPS jamming zones
|
* Detect GPS jamming zones
|
||||||
* Follow earthquakes and disasters in real time
|
* Follow earthquakes and disasters in real time
|
||||||
@@ -73,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)
|
* **Global Incidents** — GDELT-powered conflict event aggregation (last 8 hours, ~1,000 events)
|
||||||
* **Ukraine Frontline** — Live warfront GeoJSON from DeepState Map
|
* **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:
|
* **Region Dossier** — Right-click anywhere on the map for:
|
||||||
* Country profile (population, capital, languages, currencies, area)
|
* Country profile (population, capital, languages, currencies, area)
|
||||||
* Head of state & government type (Wikidata SPARQL)
|
* Head of state & government type (Wikidata SPARQL)
|
||||||
@@ -110,6 +116,13 @@ Do not append a trailing `.` to that command; Compose treats it as a service nam
|
|||||||
* Red overlay squares with "GPS JAM XX%" severity labels
|
* Red overlay squares with "GPS JAM XX%" severity labels
|
||||||
* **Radio Intercept Panel** — Scanner-style UI for monitoring communications
|
* **Radio Intercept Panel** — Scanner-style UI for monitoring communications
|
||||||
|
|
||||||
|
### 🔥 Environmental & Infrastructure Monitoring
|
||||||
|
|
||||||
|
* **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
|
### 🌐 Additional Layers
|
||||||
|
|
||||||
* **Earthquakes (24h)** — USGS real-time earthquake feed with magnitude-scaled markers
|
* **Earthquakes (24h)** — USGS real-time earthquake feed with magnitude-scaled markers
|
||||||
@@ -118,6 +131,8 @@ Do not append a trailing `.` to that command; Compose treats it as a service nam
|
|||||||
* **Measurement Tool** — Point-to-point distance & bearing measurement on the map
|
* **Measurement Tool** — Point-to-point distance & bearing measurement on the map
|
||||||
* **LOCATE Bar** — Search by coordinates (31.8, 34.8) or place name (Tehran, Strait of Hormuz) to fly directly to any location — geocoded via OpenStreetMap Nominatim
|
* **LOCATE Bar** — Search by coordinates (31.8, 34.8) or place name (Tehran, Strait of Hormuz) to fly directly to any location — geocoded via OpenStreetMap Nominatim
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🏗️ Architecture
|
## 🏗️ Architecture
|
||||||
@@ -148,6 +163,9 @@ Do not append a trailing `.` to that command; Compose treats it as a service nam
|
|||||||
│ │ ├──────────┼──────────┼──────────┼───────────┤ │ │
|
│ │ ├──────────┼──────────┼──────────┼───────────┤ │ │
|
||||||
│ │ │ DeepState│ RSS │ Region │ GPS │ │ │
|
│ │ │ DeepState│ RSS │ Region │ GPS │ │ │
|
||||||
│ │ │ Frontline│ Intel │ Dossier │ Jamming │ │ │
|
│ │ │ Frontline│ Intel │ Dossier │ Jamming │ │ │
|
||||||
|
│ │ ├──────────┼──────────┼──────────┼───────────┤ │ │
|
||||||
|
│ │ │ NASA │ NOAA │ IODA │ KiwiSDR │ │ │
|
||||||
|
│ │ │ FIRMS │ Space Wx│ Outages │ Radios │ │ │
|
||||||
│ │ └──────────┴──────────┴──────────┴───────────┘ │ │
|
│ │ └──────────┴──────────┴──────────┴───────────┘ │ │
|
||||||
│ └──────────────────────────────────────────────────┘ │
|
│ └──────────────────────────────────────────────────┘ │
|
||||||
└────────────────────────────────────────────────────────┘
|
└────────────────────────────────────────────────────────┘
|
||||||
@@ -178,6 +196,10 @@ Do not append a trailing `.` to that command; Compose treats it as a service nam
|
|||||||
| [MS Planetary Computer](https://planetarycomputer.microsoft.com) | Sentinel-2 L2A scenes (right-click) | On-demand | No |
|
| [MS Planetary Computer](https://planetarycomputer.microsoft.com) | Sentinel-2 L2A scenes (right-click) | On-demand | No |
|
||||||
| [KiwiSDR](https://kiwisdr.com) | Public SDR receiver locations | ~30min | No |
|
| [KiwiSDR](https://kiwisdr.com) | Public SDR receiver locations | ~30min | No |
|
||||||
| [OSM Nominatim](https://nominatim.openstreetmap.org) | Place name geocoding (LOCATE bar) | On-demand | No |
|
| [OSM Nominatim](https://nominatim.openstreetmap.org) | Place name geocoding (LOCATE bar) | On-demand | No |
|
||||||
|
| [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 |
|
| [CARTO Basemaps](https://carto.com) | Dark map tiles | Continuous | No |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -312,6 +334,9 @@ All layers are independently toggleable from the left panel:
|
|||||||
| MODIS Terra (Daily) | ❌ OFF | NASA GIBS daily satellite imagery |
|
| MODIS Terra (Daily) | ❌ OFF | NASA GIBS daily satellite imagery |
|
||||||
| High-Res Satellite | ❌ OFF | Esri sub-meter satellite imagery |
|
| High-Res Satellite | ❌ OFF | Esri sub-meter satellite imagery |
|
||||||
| KiwiSDR Receivers | ❌ OFF | Public SDR radio receivers |
|
| 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 |
|
| Day / Night Cycle | ✅ ON | Solar terminator overlay |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -323,8 +348,9 @@ The platform is optimized for handling massive real-time datasets:
|
|||||||
* **Gzip Compression** — API payloads compressed ~92% (11.6 MB → 915 KB)
|
* **Gzip Compression** — API payloads compressed ~92% (11.6 MB → 915 KB)
|
||||||
* **ETag Caching** — `304 Not Modified` responses skip redundant JSON parsing
|
* **ETag Caching** — `304 Not Modified` responses skip redundant JSON parsing
|
||||||
* **Viewport Culling** — Only features within the visible map bounds (+20% buffer) are rendered
|
* **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
|
* **Imperative Map Updates** — High-volume layers (flights, satellites, fires) bypass React reconciliation via direct `setData()` calls
|
||||||
* **Debounced Viewport Updates** — 300ms debounce prevents GeoJSON rebuild thrash during pan/zoom
|
* **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
|
* **Position Interpolation** — Smooth 10s tick animation between data refreshes
|
||||||
* **React.memo** — Heavy components wrapped to prevent unnecessary re-renders
|
* **React.memo** — Heavy components wrapped to prevent unnecessary re-renders
|
||||||
* **Coordinate Precision** — Lat/lng rounded to 5 decimals (~1m) to reduce JSON size
|
* **Coordinate Precision** — Lat/lng rounded to 5 decimals (~1m) to reduce JSON size
|
||||||
@@ -339,6 +365,8 @@ live-risk-dashboard/
|
|||||||
│ ├── main.py # FastAPI app, middleware, API routes
|
│ ├── main.py # FastAPI app, middleware, API routes
|
||||||
│ ├── carrier_cache.json # Persisted carrier OSINT positions
|
│ ├── carrier_cache.json # Persisted carrier OSINT positions
|
||||||
│ ├── cctv.db # SQLite CCTV camera database
|
│ ├── cctv.db # SQLite CCTV camera database
|
||||||
|
│ ├── config/
|
||||||
|
│ │ └── news_feeds.json # User-customizable RSS feed list (persists across restarts)
|
||||||
│ └── services/
|
│ └── services/
|
||||||
│ ├── data_fetcher.py # Core scheduler — fetches all data sources
|
│ ├── data_fetcher.py # Core scheduler — fetches all data sources
|
||||||
│ ├── ais_stream.py # AIS WebSocket client (25K+ vessels)
|
│ ├── ais_stream.py # AIS WebSocket client (25K+ vessels)
|
||||||
@@ -350,7 +378,8 @@ live-risk-dashboard/
|
|||||||
│ ├── kiwisdr_fetcher.py # KiwiSDR receiver scraper
|
│ ├── kiwisdr_fetcher.py # KiwiSDR receiver scraper
|
||||||
│ ├── sentinel_search.py # Sentinel-2 STAC imagery search
|
│ ├── sentinel_search.py # Sentinel-2 STAC imagery search
|
||||||
│ ├── network_utils.py # HTTP client with curl fallback
|
│ ├── 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/
|
├── frontend/
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
@@ -368,7 +397,7 @@ live-risk-dashboard/
|
|||||||
│ │ ├── RadioInterceptPanel.tsx # Scanner-style radio panel
|
│ │ ├── RadioInterceptPanel.tsx # Scanner-style radio panel
|
||||||
│ │ ├── FindLocateBar.tsx # Search/locate bar
|
│ │ ├── FindLocateBar.tsx # Search/locate bar
|
||||||
│ │ ├── ChangelogModal.tsx # Version changelog popup
|
│ │ ├── ChangelogModal.tsx # Version changelog popup
|
||||||
│ │ ├── SettingsPanel.tsx # App settings
|
│ │ ├── SettingsPanel.tsx # App settings (API Keys + News Feed manager)
|
||||||
│ │ ├── ScaleBar.tsx # Map scale indicator
|
│ │ ├── ScaleBar.tsx # Map scale indicator
|
||||||
│ │ ├── WikiImage.tsx # Wikipedia image fetcher
|
│ │ ├── WikiImage.tsx # Wikipedia image fetcher
|
||||||
│ │ └── ErrorBoundary.tsx # Crash recovery wrapper
|
│ │ └── ErrorBoundary.tsx # Crash recovery wrapper
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"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 }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1 +0,0 @@
|
|||||||
5c3b1c768973ca54e9a1befee8dc075f38e8cc56
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
2b64633521ffb6f06da36e19f5c8eb86979e2187
|
|
||||||
File diff suppressed because one or more lines are too long
+72
-5
@@ -1,16 +1,44 @@
|
|||||||
from fastapi import FastAPI, Request, Response
|
from fastapi import FastAPI, Request, Response
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from contextlib import asynccontextmanager
|
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.ais_stream import start_ais_stream, stop_ais_stream
|
||||||
from services.carrier_tracker import start_carrier_tracker, stop_carrier_tracker
|
from services.carrier_tracker import start_carrier_tracker, stop_carrier_tracker
|
||||||
import uvicorn
|
import uvicorn
|
||||||
import logging
|
import logging
|
||||||
import hashlib
|
import hashlib
|
||||||
import json as json_mod
|
import json as json_mod
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
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
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
# Startup: Start background data fetching, AIS stream, and carrier tracker
|
# 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(GZipMiddleware, minimum_size=1000)
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"], # Must be permissive — users access from localhost, LAN IPs, Docker, custom ports
|
allow_origins=_build_cors_origins(),
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
@@ -64,11 +92,12 @@ async def live_data_fast(request: Request):
|
|||||||
"uavs": d.get("uavs", []),
|
"uavs": d.get("uavs", []),
|
||||||
"liveuamap": d.get("liveuamap", []),
|
"liveuamap": d.get("liveuamap", []),
|
||||||
"gps_jamming": d.get("gps_jamming", []),
|
"gps_jamming": d.get("gps_jamming", []),
|
||||||
|
"freshness": dict(source_timestamps),
|
||||||
}
|
}
|
||||||
# ETag includes last_updated timestamp so it changes on every data refresh,
|
# ETag includes last_updated timestamp so it changes on every data refresh,
|
||||||
# not just when item counts change (old bug: positions went stale)
|
# not just when item counts change (old bug: positions went stale)
|
||||||
last_updated = d.get("last_updated", "")
|
last_updated = d.get("last_updated", "")
|
||||||
counts = "|".join(f"{k}:{len(v) if isinstance(v, list) else 0}" for k, v in payload.items())
|
counts = "|".join(f"{k}:{len(v) if isinstance(v, list) else 0}" for k, v in payload.items() if k != "freshness")
|
||||||
etag = hashlib.md5(f"{last_updated}|{counts}".encode()).hexdigest()[:16]
|
etag = hashlib.md5(f"{last_updated}|{counts}".encode()).hexdigest()[:16]
|
||||||
if request.headers.get("if-none-match") == etag:
|
if request.headers.get("if-none-match") == etag:
|
||||||
return Response(status_code=304, headers={"ETag": etag, "Cache-Control": "no-cache"})
|
return Response(status_code=304, headers={"ETag": etag, "Cache-Control": "no-cache"})
|
||||||
@@ -93,11 +122,16 @@ async def live_data_slow(request: Request):
|
|||||||
"gdelt": d.get("gdelt", []),
|
"gdelt": d.get("gdelt", []),
|
||||||
"airports": d.get("airports", []),
|
"airports": d.get("airports", []),
|
||||||
"satellites": d.get("satellites", []),
|
"satellites": d.get("satellites", []),
|
||||||
"kiwisdr": d.get("kiwisdr", [])
|
"kiwisdr": d.get("kiwisdr", []),
|
||||||
|
"space_weather": d.get("space_weather"),
|
||||||
|
"internet_outages": d.get("internet_outages", []),
|
||||||
|
"firms_fires": d.get("firms_fires", []),
|
||||||
|
"datacenters": d.get("datacenters", []),
|
||||||
|
"freshness": dict(source_timestamps),
|
||||||
}
|
}
|
||||||
# ETag based on last_updated + item counts
|
# ETag based on last_updated + item counts
|
||||||
last_updated = d.get("last_updated", "")
|
last_updated = d.get("last_updated", "")
|
||||||
counts = "|".join(f"{k}:{len(v) if isinstance(v, list) else 0}" for k, v in payload.items())
|
counts = "|".join(f"{k}:{len(v) if isinstance(v, list) else 0}" for k, v in payload.items() if k != "freshness")
|
||||||
etag = hashlib.md5(f"slow|{last_updated}|{counts}".encode()).hexdigest()[:16]
|
etag = hashlib.md5(f"slow|{last_updated}|{counts}".encode()).hexdigest()[:16]
|
||||||
if request.headers.get("if-none-match") == etag:
|
if request.headers.get("if-none-match") == etag:
|
||||||
return Response(status_code=304, headers={"ETag": etag, "Cache-Control": "no-cache"})
|
return Response(status_code=304, headers={"ETag": etag, "Cache-Control": "no-cache"})
|
||||||
@@ -128,7 +162,12 @@ async def health_check():
|
|||||||
"earthquakes": len(d.get("earthquakes", [])),
|
"earthquakes": len(d.get("earthquakes", [])),
|
||||||
"cctv": len(d.get("cctv", [])),
|
"cctv": len(d.get("cctv", [])),
|
||||||
"news": len(d.get("news", [])),
|
"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),
|
"uptime_seconds": round(time.time() - _start_time),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,6 +255,34 @@ async def api_update_key(body: ApiKeyUpdate):
|
|||||||
return {"status": "updated", "env_key": body.env_key}
|
return {"status": "updated", "env_key": body.env_key}
|
||||||
return {"status": "error", "message": "Failed to update .env file"}
|
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__":
|
if __name__ == "__main__":
|
||||||
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
|
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+466
-116
@@ -10,6 +10,7 @@ import random
|
|||||||
import math
|
import math
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
|
from pathlib import Path
|
||||||
import threading
|
import threading
|
||||||
import io
|
import io
|
||||||
from apscheduler.schedulers.background import BackgroundScheduler
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
@@ -101,9 +102,22 @@ latest_data = {
|
|||||||
"frontlines": None,
|
"frontlines": None,
|
||||||
"gdelt": [],
|
"gdelt": [],
|
||||||
"liveuamap": [],
|
"liveuamap": [],
|
||||||
"kiwisdr": []
|
"kiwisdr": [],
|
||||||
|
"space_weather": None,
|
||||||
|
"internet_outages": [],
|
||||||
|
"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
|
# Thread lock for safe reads/writes to latest_data
|
||||||
_data_lock = threading.Lock()
|
_data_lock = threading.Lock()
|
||||||
|
|
||||||
@@ -334,20 +348,10 @@ _KEYWORD_COORDS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
def fetch_news():
|
def fetch_news():
|
||||||
feeds = {
|
from services.news_feed_config import get_feeds
|
||||||
"NPR": "https://feeds.npr.org/1004/rss.xml",
|
feed_config = get_feeds()
|
||||||
"BBC": "http://feeds.bbci.co.uk/news/world/rss.xml",
|
feeds = {f["name"]: f["url"] for f in feed_config}
|
||||||
"AlJazeera": "https://www.aljazeera.com/xml/rss/all.xml",
|
source_weights = {f["name"]: f["weight"] for f in feed_config}
|
||||||
"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
|
|
||||||
}
|
|
||||||
|
|
||||||
clusters = {}
|
clusters = {}
|
||||||
|
|
||||||
@@ -474,6 +478,7 @@ def fetch_news():
|
|||||||
|
|
||||||
news_items.sort(key=lambda x: x['risk_score'], reverse=True)
|
news_items.sort(key=lambda x: x['risk_score'], reverse=True)
|
||||||
latest_data['news'] = news_items
|
latest_data['news'] = news_items
|
||||||
|
_mark_fresh("news")
|
||||||
|
|
||||||
def fetch_defense_stocks():
|
def fetch_defense_stocks():
|
||||||
tickers = ["RTX", "LMT", "NOC", "GD", "BA", "PLTR"]
|
tickers = ["RTX", "LMT", "NOC", "GD", "BA", "PLTR"]
|
||||||
@@ -497,6 +502,7 @@ def fetch_defense_stocks():
|
|||||||
logger.warning(f"Could not fetch data for {t}: {e}")
|
logger.warning(f"Could not fetch data for {t}: {e}")
|
||||||
|
|
||||||
latest_data['stocks'] = stocks_data
|
latest_data['stocks'] = stocks_data
|
||||||
|
_mark_fresh("stocks")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching stocks: {e}")
|
logger.error(f"Error fetching stocks: {e}")
|
||||||
|
|
||||||
@@ -523,6 +529,7 @@ def fetch_oil_prices():
|
|||||||
logger.warning(f"Could not fetch data for {symbol}: {e}")
|
logger.warning(f"Could not fetch data for {symbol}: {e}")
|
||||||
|
|
||||||
latest_data['oil'] = oil_data
|
latest_data['oil'] = oil_data
|
||||||
|
_mark_fresh("oil")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching oil: {e}")
|
logger.error(f"Error fetching oil: {e}")
|
||||||
|
|
||||||
@@ -759,6 +766,11 @@ def fetch_flights():
|
|||||||
speed_knots = round(gs_knots, 1) if isinstance(gs_knots, (int, float)) else None
|
speed_knots = round(gs_knots, 1) if isinstance(gs_knots, (int, float)) else None
|
||||||
|
|
||||||
model_upper = f.get("t", "").upper()
|
model_upper = f.get("t", "").upper()
|
||||||
|
|
||||||
|
# Skip fixed structures (towers, oil platforms) that broadcast ADS-B
|
||||||
|
if model_upper == "TWR":
|
||||||
|
continue
|
||||||
|
|
||||||
ac_category = "heli" if model_upper in _HELI_TYPES_BACKEND else "plane"
|
ac_category = "heli" if model_upper in _HELI_TYPES_BACKEND else "plane"
|
||||||
|
|
||||||
flights.append({
|
flights.append({
|
||||||
@@ -891,6 +903,8 @@ def fetch_flights():
|
|||||||
latest_data['private_jets'] = _merge_category(private_jets, latest_data.get('private_jets', []))
|
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', []))
|
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)
|
# Always write raw flights for GPS jamming analysis (nac_p field)
|
||||||
if flights:
|
if flights:
|
||||||
latest_data['flights'] = flights
|
latest_data['flights'] = flights
|
||||||
@@ -1109,26 +1123,65 @@ def fetch_ships():
|
|||||||
|
|
||||||
logger.info(f"Ships: {len(carriers)} carriers + {len(ais_vessels)} AIS vessels")
|
logger.info(f"Ships: {len(carriers)} carriers + {len(ais_vessels)} AIS vessels")
|
||||||
latest_data['ships'] = ships
|
latest_data['ships'] = ships
|
||||||
|
_mark_fresh("ships")
|
||||||
|
|
||||||
def fetch_military_flights():
|
def fetch_military_flights():
|
||||||
# True ADS-B Exchange military data requires paid API access.
|
# True ADS-B Exchange military data requires paid API access.
|
||||||
# We will use adsb.lol (an open source ADSB aggregator) /v2/mil fallback.
|
# We will use adsb.lol (an open source ADSB aggregator) /v2/mil fallback.
|
||||||
military_flights = []
|
military_flights = []
|
||||||
|
detected_uavs = []
|
||||||
try:
|
try:
|
||||||
url = "https://api.adsb.lol/v2/mil"
|
url = "https://api.adsb.lol/v2/mil"
|
||||||
response = fetch_with_curl(url, timeout=10)
|
response = fetch_with_curl(url, timeout=10)
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
ac = response.json().get('ac', [])
|
ac = response.json().get('ac', [])
|
||||||
for f in ac:
|
for f in ac:
|
||||||
try:
|
try:
|
||||||
lat = f.get("lat")
|
lat = f.get("lat")
|
||||||
lng = f.get("lon")
|
lng = f.get("lon")
|
||||||
heading = f.get("track") or 0
|
heading = f.get("track") or 0
|
||||||
|
|
||||||
if lat is None or lng is None:
|
if lat is None or lng is None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
model = str(f.get("t", "UNKNOWN")).upper()
|
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"
|
mil_cat = "default"
|
||||||
if "H" in model and any(c.isdigit() for c in model):
|
if "H" in model and any(c.isdigit() for c in model):
|
||||||
mil_cat = "heli"
|
mil_cat = "heli"
|
||||||
@@ -1138,27 +1191,11 @@ def fetch_military_flights():
|
|||||||
mil_cat = "fighter"
|
mil_cat = "fighter"
|
||||||
elif any(k in model for k in ["C17", "C5", "C130", "C30", "A400", "V22"]):
|
elif any(k in model for k in ["C17", "C5", "C130", "C30", "A400", "V22"]):
|
||||||
mil_cat = "cargo"
|
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"
|
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({
|
military_flights.append({
|
||||||
"callsign": str(f.get("flight", "MIL-UNKN")).strip(),
|
"callsign": callsign,
|
||||||
"country": f.get("r", "Military Asset"),
|
"country": f.get("r", "Military Asset"),
|
||||||
"lng": float(lng),
|
"lng": float(lng),
|
||||||
"lat": float(lat),
|
"lat": float(lat),
|
||||||
@@ -1166,10 +1203,10 @@ def fetch_military_flights():
|
|||||||
"heading": heading,
|
"heading": heading,
|
||||||
"type": "military_flight",
|
"type": "military_flight",
|
||||||
"military_type": mil_cat,
|
"military_type": mil_cat,
|
||||||
"origin_loc": origin_loc,
|
"origin_loc": None,
|
||||||
"dest_loc": dest_loc,
|
"dest_loc": None,
|
||||||
"origin_name": origin_name,
|
"origin_name": "UNKNOWN",
|
||||||
"dest_name": dest_name,
|
"dest_name": "UNKNOWN",
|
||||||
"registration": f.get("r", "N/A"),
|
"registration": f.get("r", "N/A"),
|
||||||
"model": f.get("t", "Unknown"),
|
"model": f.get("t", "Unknown"),
|
||||||
"icao24": f.get("hex", ""),
|
"icao24": f.get("hex", ""),
|
||||||
@@ -1181,15 +1218,18 @@ def fetch_military_flights():
|
|||||||
continue
|
continue
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching military flights: {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
|
# API failed or rate limited — log but do NOT inject fake data
|
||||||
logger.warning("No military flights retrieved — keeping previous data if available")
|
logger.warning("No military flights retrieved — keeping previous data if available")
|
||||||
# Preserve existing data rather than overwriting with empty
|
# Preserve existing data rather than overwriting with empty
|
||||||
if latest_data.get('military_flights'):
|
if latest_data.get('military_flights'):
|
||||||
return
|
return
|
||||||
|
|
||||||
latest_data['military_flights'] = military_flights
|
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
|
# Cross-reference military flights with Plane-Alert DB
|
||||||
tracked_mil = []
|
tracked_mil = []
|
||||||
@@ -1241,12 +1281,14 @@ def fetch_weather():
|
|||||||
if "radar" in data and "past" in data["radar"]:
|
if "radar" in data and "past" in data["radar"]:
|
||||||
latest_time = data["radar"]["past"][-1]["time"]
|
latest_time = data["radar"]["past"][-1]["time"]
|
||||||
latest_data["weather"] = {"time": latest_time, "host": data.get("host", "https://tilecache.rainviewer.com")}
|
latest_data["weather"] = {"time": latest_time, "host": data.get("host", "https://tilecache.rainviewer.com")}
|
||||||
|
_mark_fresh("weather")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching weather: {e}")
|
logger.error(f"Error fetching weather: {e}")
|
||||||
|
|
||||||
def fetch_cctv():
|
def fetch_cctv():
|
||||||
try:
|
try:
|
||||||
latest_data["cctv"] = get_all_cameras()
|
latest_data["cctv"] = get_all_cameras()
|
||||||
|
_mark_fresh("cctv")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching cctv from DB: {e}")
|
logger.error(f"Error fetching cctv from DB: {e}")
|
||||||
latest_data["cctv"] = []
|
latest_data["cctv"] = []
|
||||||
@@ -1255,10 +1297,311 @@ def fetch_kiwisdr():
|
|||||||
try:
|
try:
|
||||||
from services.kiwisdr_fetcher import fetch_kiwisdr_nodes
|
from services.kiwisdr_fetcher import fetch_kiwisdr_nodes
|
||||||
latest_data["kiwisdr"] = fetch_kiwisdr_nodes()
|
latest_data["kiwisdr"] = fetch_kiwisdr_nodes()
|
||||||
|
_mark_fresh("kiwisdr")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching KiwiSDR nodes: {e}")
|
logger.error(f"Error fetching KiwiSDR nodes: {e}")
|
||||||
latest_data["kiwisdr"] = []
|
latest_data["kiwisdr"] = []
|
||||||
|
|
||||||
|
def fetch_firms_fires():
|
||||||
|
"""Fetch global fire/thermal anomalies from NASA FIRMS (NOAA-20 VIIRS, 24h, no key needed)."""
|
||||||
|
fires = []
|
||||||
|
try:
|
||||||
|
url = "https://firms.modaps.eosdis.nasa.gov/data/active_fire/noaa-20-viirs-c2/csv/J1_VIIRS_C2_Global_24h.csv"
|
||||||
|
response = fetch_with_curl(url, timeout=30)
|
||||||
|
if response.status_code == 200:
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
reader = csv.DictReader(io.StringIO(response.text))
|
||||||
|
all_rows = []
|
||||||
|
for row in reader:
|
||||||
|
try:
|
||||||
|
lat = float(row.get("latitude", 0))
|
||||||
|
lng = float(row.get("longitude", 0))
|
||||||
|
frp = float(row.get("frp", 0)) # Fire Radiative Power (MW)
|
||||||
|
conf = row.get("confidence", "nominal")
|
||||||
|
daynight = row.get("daynight", "")
|
||||||
|
bright = float(row.get("bright_ti4", 0))
|
||||||
|
all_rows.append({
|
||||||
|
"lat": lat,
|
||||||
|
"lng": lng,
|
||||||
|
"frp": frp,
|
||||||
|
"brightness": bright,
|
||||||
|
"confidence": conf,
|
||||||
|
"daynight": daynight,
|
||||||
|
"acq_date": row.get("acq_date", ""),
|
||||||
|
"acq_time": row.get("acq_time", ""),
|
||||||
|
})
|
||||||
|
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]
|
||||||
|
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."""
|
||||||
|
try:
|
||||||
|
kp_resp = fetch_with_curl("https://services.swpc.noaa.gov/json/planetary_k_index_1m.json", timeout=10)
|
||||||
|
kp_value = None
|
||||||
|
kp_text = "QUIET"
|
||||||
|
if kp_resp.status_code == 200:
|
||||||
|
kp_data = kp_resp.json()
|
||||||
|
if kp_data:
|
||||||
|
latest_kp = kp_data[-1]
|
||||||
|
kp_value = float(latest_kp.get("kp_index", 0))
|
||||||
|
if kp_value >= 7:
|
||||||
|
kp_text = f"STORM G{min(int(kp_value) - 4, 5)}"
|
||||||
|
elif kp_value >= 5:
|
||||||
|
kp_text = f"STORM G{min(int(kp_value) - 4, 5)}"
|
||||||
|
elif kp_value >= 4:
|
||||||
|
kp_text = "ACTIVE"
|
||||||
|
elif kp_value >= 3:
|
||||||
|
kp_text = "UNSETTLED"
|
||||||
|
|
||||||
|
events = []
|
||||||
|
ev_resp = fetch_with_curl("https://services.swpc.noaa.gov/json/edited_events.json", timeout=10)
|
||||||
|
if ev_resp.status_code == 200:
|
||||||
|
all_events = ev_resp.json()
|
||||||
|
for ev in all_events[-10:]:
|
||||||
|
events.append({
|
||||||
|
"type": ev.get("type", ""),
|
||||||
|
"begin": ev.get("begin", ""),
|
||||||
|
"end": ev.get("end", ""),
|
||||||
|
"classtype": ev.get("classtype", ""),
|
||||||
|
})
|
||||||
|
|
||||||
|
latest_data["space_weather"] = {
|
||||||
|
"kp_index": kp_value,
|
||||||
|
"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}")
|
||||||
|
|
||||||
|
# Cache geocoded region coordinates so we only hit Nominatim once per region
|
||||||
|
_region_geocode_cache: dict = {}
|
||||||
|
|
||||||
|
def _geocode_region(region_name: str, country_name: str) -> tuple:
|
||||||
|
"""Geocode a region using OpenStreetMap Nominatim (cached, respects rate limit)."""
|
||||||
|
cache_key = f"{region_name}|{country_name}"
|
||||||
|
if cache_key in _region_geocode_cache:
|
||||||
|
return _region_geocode_cache[cache_key]
|
||||||
|
try:
|
||||||
|
import urllib.parse
|
||||||
|
query = urllib.parse.quote(f"{region_name}, {country_name}")
|
||||||
|
url = f"https://nominatim.openstreetmap.org/search?q={query}&format=json&limit=1"
|
||||||
|
response = fetch_with_curl(url, timeout=8, headers={"User-Agent": "ShadowBroker-OSINT/1.0"})
|
||||||
|
if response.status_code == 200:
|
||||||
|
results = response.json()
|
||||||
|
if results:
|
||||||
|
lat = float(results[0]["lat"])
|
||||||
|
lon = float(results[0]["lon"])
|
||||||
|
_region_geocode_cache[cache_key] = (lat, lon)
|
||||||
|
return (lat, lon)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
_region_geocode_cache[cache_key] = None
|
||||||
|
return None
|
||||||
|
|
||||||
|
def fetch_internet_outages():
|
||||||
|
"""Fetch regional internet outage alerts from IODA (Georgia Tech).
|
||||||
|
Region-level only — higher fidelity than country-level. If an entire country
|
||||||
|
is down, all its regions will show up individually.
|
||||||
|
|
||||||
|
Only uses reliable datasources (bgp, ping-slash24) that measure actual
|
||||||
|
connectivity. Excludes merit-nt (network telescope with tiny sample sizes
|
||||||
|
that produces wildly misleading percentages for large regions)."""
|
||||||
|
# Datasources that actually measure real internet connectivity
|
||||||
|
RELIABLE_DATASOURCES = {"bgp", "ping-slash24"}
|
||||||
|
outages = []
|
||||||
|
try:
|
||||||
|
now = int(time.time())
|
||||||
|
start = now - 86400
|
||||||
|
url = f"https://api.ioda.inetintel.cc.gatech.edu/v2/outages/alerts?from={start}&until={now}&limit=500"
|
||||||
|
response = fetch_with_curl(url, timeout=15)
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
alerts = data.get("data", [])
|
||||||
|
# Collect region-level outages (deduplicate by region code, keep worst)
|
||||||
|
region_outages = {}
|
||||||
|
for alert in alerts:
|
||||||
|
entity = alert.get("entity", {})
|
||||||
|
etype = entity.get("type", "")
|
||||||
|
level = alert.get("level", "")
|
||||||
|
if level == "normal" or etype != "region":
|
||||||
|
continue
|
||||||
|
datasource = alert.get("datasource", "")
|
||||||
|
if datasource not in RELIABLE_DATASOURCES:
|
||||||
|
continue # Skip merit-nt and other unreliable sources
|
||||||
|
code = entity.get("code", "")
|
||||||
|
name = entity.get("name", "")
|
||||||
|
attrs = entity.get("attrs", {})
|
||||||
|
country_code = attrs.get("country_code", "")
|
||||||
|
country_name = attrs.get("country_name", "")
|
||||||
|
value = alert.get("value", 0)
|
||||||
|
history_value = alert.get("historyValue", 0)
|
||||||
|
severity = 0
|
||||||
|
if history_value and history_value > 0:
|
||||||
|
severity = round((1 - value / history_value) * 100)
|
||||||
|
severity = max(0, min(severity, 100))
|
||||||
|
if severity < 10:
|
||||||
|
continue # Skip minor fluctuations (<10% is normal jitter)
|
||||||
|
if code not in region_outages or severity > region_outages[code]["severity"]:
|
||||||
|
region_outages[code] = {
|
||||||
|
"region_code": code,
|
||||||
|
"region_name": name,
|
||||||
|
"country_code": country_code,
|
||||||
|
"country_name": country_name,
|
||||||
|
"level": level,
|
||||||
|
"datasource": datasource,
|
||||||
|
"severity": severity,
|
||||||
|
}
|
||||||
|
# Geocode regions and build final list
|
||||||
|
geocoded = []
|
||||||
|
for rcode, r in region_outages.items():
|
||||||
|
coords = _geocode_region(r["region_name"], r["country_name"])
|
||||||
|
if coords:
|
||||||
|
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]
|
||||||
|
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():
|
def fetch_bikeshare():
|
||||||
bikes = []
|
bikes = []
|
||||||
try:
|
try:
|
||||||
@@ -1316,6 +1659,8 @@ def fetch_earthquakes():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching earthquakes: {e}")
|
logger.error(f"Error fetching earthquakes: {e}")
|
||||||
latest_data["earthquakes"] = quakes
|
latest_data["earthquakes"] = quakes
|
||||||
|
if quakes:
|
||||||
|
_mark_fresh("earthquakes")
|
||||||
|
|
||||||
# Satellite GP data cache — re-download from CelesTrak only every 30 minutes
|
# Satellite GP data cache — re-download from CelesTrak only every 30 minutes
|
||||||
_sat_gp_cache = {"data": None, "last_fetch": 0}
|
_sat_gp_cache = {"data": None, "last_fetch": 0}
|
||||||
@@ -1615,79 +1960,78 @@ def fetch_satellites():
|
|||||||
# Only overwrite if we got data — don't wipe the map on API timeout
|
# Only overwrite if we got data — don't wipe the map on API timeout
|
||||||
if sats:
|
if sats:
|
||||||
latest_data["satellites"] = sats
|
latest_data["satellites"] = sats
|
||||||
|
_mark_fresh("satellites")
|
||||||
elif not latest_data.get("satellites"):
|
elif not latest_data.get("satellites"):
|
||||||
latest_data["satellites"] = []
|
latest_data["satellites"] = []
|
||||||
|
|
||||||
def fetch_uavs():
|
# ---------------------------------------------------------------------------
|
||||||
# Simulated high-altitude long-endurance (HALE) and MALE UAVs over high-risk regions
|
# Real UAV detection from ADS-B data — filters military drone transponders
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
uav_targets = [
|
_UAV_TYPE_CODES = {"Q9", "R4", "TB2", "MALE", "HALE", "HERM", "HRON"}
|
||||||
{
|
_UAV_CALLSIGN_PREFIXES = ("FORTE", "GHAWK", "REAP", "BAMS", "UAV", "UAS")
|
||||||
"name": "RQ-4 Global Hawk", "center": [31.5, 34.8], "radius": 0.5, "alt": 15000,
|
_UAV_MODEL_KEYWORDS = ("RQ-", "MQ-", "RQ4", "MQ9", "MQ4", "MQ1", "REAPER", "GLOBALHAWK", "TRITON", "PREDATOR", "HERMES", "HERON", "BAYRAKTAR")
|
||||||
"country": "USA", "uav_type": "HALE Surveillance", "range_km": 2200,
|
_UAV_WIKI = {
|
||||||
"wiki": "https://en.wikipedia.org/wiki/Northrop_Grumman_RQ-4_Global_Hawk",
|
"RQ4": "https://en.wikipedia.org/wiki/Northrop_Grumman_RQ-4_Global_Hawk",
|
||||||
"speed_knots": 340
|
"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",
|
||||||
"name": "MQ-9 Reaper", "center": [49.0, 31.4], "radius": 1.2, "alt": 12000,
|
"MQ9": "https://en.wikipedia.org/wiki/General_Atomics_MQ-9_Reaper",
|
||||||
"country": "USA", "uav_type": "MALE Strike/ISR", "range_km": 1850,
|
"MQ-9": "https://en.wikipedia.org/wiki/General_Atomics_MQ-9_Reaper",
|
||||||
"wiki": "https://en.wikipedia.org/wiki/General_Atomics_MQ-9_Reaper",
|
"MQ1": "https://en.wikipedia.org/wiki/General_Atomics_MQ-1C_Gray_Eagle",
|
||||||
"speed_knots": 250
|
"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",
|
||||||
"name": "Bayraktar TB2", "center": [23.6, 120.9], "radius": 0.8, "alt": 8000,
|
"TRITON": "https://en.wikipedia.org/wiki/Northrop_Grumman_MQ-4C_Triton",
|
||||||
"country": "Turkey", "uav_type": "MALE Strike", "range_km": 150,
|
"PREDATOR": "https://en.wikipedia.org/wiki/General_Atomics_MQ-1_Predator",
|
||||||
"wiki": "https://en.wikipedia.org/wiki/Bayraktar_TB2",
|
"HERMES": "https://en.wikipedia.org/wiki/Elbit_Hermes_900",
|
||||||
"speed_knots": 120
|
"HERON": "https://en.wikipedia.org/wiki/IAI_Heron",
|
||||||
},
|
"BAYRAKTAR": "https://en.wikipedia.org/wiki/Bayraktar_TB2",
|
||||||
{
|
}
|
||||||
"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,
|
def _classify_uav(model: str, callsign: str):
|
||||||
"wiki": "https://en.wikipedia.org/wiki/General_Atomics_MQ-1C_Gray_Eagle",
|
"""Check if an aircraft is a UAV based on type code, callsign prefix, or model keywords.
|
||||||
"speed_knots": 150
|
Returns (is_uav, uav_type, wiki_url) or (False, None, None)."""
|
||||||
},
|
model_up = model.upper().replace(" ", "")
|
||||||
{
|
callsign_up = callsign.upper().strip()
|
||||||
"name": "RQ-170 Sentinel", "center": [25.0, 55.0], "radius": 1.5, "alt": 18000,
|
|
||||||
"country": "USA", "uav_type": "Stealth ISR", "range_km": 1100,
|
# Check ICAO type codes
|
||||||
"wiki": "https://en.wikipedia.org/wiki/Lockheed_Martin_RQ-170_Sentinel",
|
if model_up in _UAV_TYPE_CODES:
|
||||||
"speed_knots": 300
|
uav_type = "HALE Surveillance" if model_up in ("R4", "HALE") else "MALE ISR"
|
||||||
}
|
wiki = _UAV_WIKI.get(model_up, "")
|
||||||
]
|
return True, uav_type, wiki
|
||||||
|
|
||||||
# Use the current hour and minute to create a continuous slow orbit
|
# Check callsign prefixes (must also have a military-ish model)
|
||||||
now = datetime.utcnow()
|
for prefix in _UAV_CALLSIGN_PREFIXES:
|
||||||
# 1 full orbit every 10 minutes
|
if callsign_up.startswith(prefix):
|
||||||
time_factor = ((now.minute % 10) * 60 + now.second) / 600.0
|
uav_type = "HALE Surveillance" if prefix in ("FORTE", "GHAWK", "BAMS") else "MALE ISR"
|
||||||
angle = time_factor * 2 * math.pi
|
wiki = _UAV_WIKI.get(prefix, "")
|
||||||
|
if prefix == "FORTE":
|
||||||
uavs = []
|
wiki = _UAV_WIKI["RQ4"]
|
||||||
for idx, t in enumerate(uav_targets):
|
elif prefix == "BAMS":
|
||||||
# Offset the angle slightly so they aren't all synchronized
|
wiki = _UAV_WIKI["MQ4"]
|
||||||
offset_angle = angle + (idx * math.pi / 2.5)
|
return True, uav_type, wiki
|
||||||
|
|
||||||
lat = t["center"][0] + math.sin(offset_angle) * t["radius"]
|
# Check model keywords
|
||||||
lng = t["center"][1] + math.cos(offset_angle) * t["radius"]
|
for kw in _UAV_MODEL_KEYWORDS:
|
||||||
|
if kw in model_up:
|
||||||
heading = (math.degrees(offset_angle) + 90) % 360
|
# Determine type from keyword
|
||||||
|
if any(h in model_up for h in ("RQ4", "RQ-4", "GLOBALHAWK")):
|
||||||
uavs.append({
|
return True, "HALE Surveillance", _UAV_WIKI.get(kw, "")
|
||||||
"id": f"uav-{idx}",
|
elif any(h in model_up for h in ("MQ4", "MQ-4", "TRITON")):
|
||||||
"callsign": t["name"],
|
return True, "HALE Maritime Surveillance", _UAV_WIKI.get(kw, "")
|
||||||
"aircraft_model": t["name"],
|
elif any(h in model_up for h in ("MQ9", "MQ-9", "REAPER")):
|
||||||
"lat": lat,
|
return True, "MALE Strike/ISR", _UAV_WIKI.get(kw, "")
|
||||||
"lng": lng,
|
elif any(h in model_up for h in ("MQ1", "MQ-1", "PREDATOR")):
|
||||||
"alt": t["alt"],
|
return True, "MALE ISR/Strike", _UAV_WIKI.get(kw, "")
|
||||||
"heading": heading,
|
elif "BAYRAKTAR" in model_up or "TB2" in model_up:
|
||||||
"speed_knots": t["speed_knots"],
|
return True, "MALE Strike", _UAV_WIKI.get("BAYRAKTAR", "")
|
||||||
"center": t["center"],
|
elif "HERMES" in model_up:
|
||||||
"orbit_radius": t["radius"],
|
return True, "MALE ISR", _UAV_WIKI.get("HERMES", "")
|
||||||
"range_km": t["range_km"],
|
elif "HERON" in model_up:
|
||||||
"country": t["country"],
|
return True, "MALE ISR", _UAV_WIKI.get("HERON", "")
|
||||||
"uav_type": t["uav_type"],
|
return True, "MALE ISR", _UAV_WIKI.get(kw, "")
|
||||||
"wiki": t["wiki"],
|
|
||||||
})
|
return False, None, None
|
||||||
|
|
||||||
latest_data['uavs'] = uavs
|
|
||||||
|
|
||||||
cached_airports = []
|
cached_airports = []
|
||||||
flight_trails = {} # {icao_hex: {points: [[lat, lng, alt, ts], ...], last_seen: ts}}
|
flight_trails = {} # {icao_hex: {points: [[lat, lng, alt, ts], ...], last_seen: ts}}
|
||||||
@@ -1769,10 +2113,12 @@ def fetch_geopolitics():
|
|||||||
frontlines = fetch_ukraine_frontlines()
|
frontlines = fetch_ukraine_frontlines()
|
||||||
if frontlines:
|
if frontlines:
|
||||||
latest_data['frontlines'] = frontlines
|
latest_data['frontlines'] = frontlines
|
||||||
|
_mark_fresh("frontlines")
|
||||||
|
|
||||||
gdelt = fetch_global_military_incidents()
|
gdelt = fetch_global_military_incidents()
|
||||||
if gdelt is not None:
|
if gdelt is not None:
|
||||||
latest_data['gdelt'] = gdelt
|
latest_data['gdelt'] = gdelt
|
||||||
|
_mark_fresh("gdelt")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching geopolitics: {e}")
|
logger.error(f"Error fetching geopolitics: {e}")
|
||||||
|
|
||||||
@@ -1783,6 +2129,7 @@ def update_liveuamap():
|
|||||||
res = fetch_liveuamap()
|
res = fetch_liveuamap()
|
||||||
if res:
|
if res:
|
||||||
latest_data['liveuamap'] = res
|
latest_data['liveuamap'] = res
|
||||||
|
_mark_fresh("liveuamap")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Liveuamap scraper error: {e}")
|
logger.error(f"Liveuamap scraper error: {e}")
|
||||||
|
|
||||||
@@ -1791,9 +2138,8 @@ def update_fast_data():
|
|||||||
logger.info("Fast-tier data update starting...")
|
logger.info("Fast-tier data update starting...")
|
||||||
fast_funcs = [
|
fast_funcs = [
|
||||||
fetch_flights,
|
fetch_flights,
|
||||||
fetch_military_flights,
|
fetch_military_flights, # Also detects UAVs from ADS-B
|
||||||
fetch_ships,
|
fetch_ships,
|
||||||
fetch_uavs,
|
|
||||||
fetch_satellites,
|
fetch_satellites,
|
||||||
]
|
]
|
||||||
with concurrent.futures.ThreadPoolExecutor(max_workers=len(fast_funcs)) as executor:
|
with concurrent.futures.ThreadPoolExecutor(max_workers=len(fast_funcs)) as executor:
|
||||||
@@ -1815,6 +2161,10 @@ def update_slow_data():
|
|||||||
fetch_earthquakes,
|
fetch_earthquakes,
|
||||||
fetch_geopolitics,
|
fetch_geopolitics,
|
||||||
fetch_kiwisdr,
|
fetch_kiwisdr,
|
||||||
|
fetch_space_weather,
|
||||||
|
fetch_internet_outages,
|
||||||
|
fetch_firms_fires,
|
||||||
|
fetch_datacenters,
|
||||||
]
|
]
|
||||||
with concurrent.futures.ThreadPoolExecutor(max_workers=len(slow_funcs)) as executor:
|
with concurrent.futures.ThreadPoolExecutor(max_workers=len(slow_funcs)) as executor:
|
||||||
futures = [executor.submit(func) for func in slow_funcs]
|
futures = [executor.submit(func) for func in slow_funcs]
|
||||||
@@ -1840,7 +2190,7 @@ def start_scheduler():
|
|||||||
# Run full update once on startup
|
# Run full update once on startup
|
||||||
scheduler.add_job(update_all_data, 'date', run_date=datetime.now())
|
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)
|
scheduler.add_job(update_fast_data, 'interval', seconds=60)
|
||||||
|
|
||||||
# Slow tier: every 30 minutes (news, stocks, weather, geopolitics)
|
# Slow tier: every 30 minutes (news, stocks, weather, geopolitics)
|
||||||
|
|||||||
@@ -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))
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,8 @@ services:
|
|||||||
- OPENSKY_CLIENT_ID=${OPENSKY_CLIENT_ID}
|
- OPENSKY_CLIENT_ID=${OPENSKY_CLIENT_ID}
|
||||||
- OPENSKY_CLIENT_SECRET=${OPENSKY_CLIENT_SECRET}
|
- OPENSKY_CLIENT_SECRET=${OPENSKY_CLIENT_SECRET}
|
||||||
- LTA_ACCOUNT_KEY=${LTA_ACCOUNT_KEY}
|
- LTA_ACCOUNT_KEY=${LTA_ACCOUNT_KEY}
|
||||||
|
# Override allowed CORS origins (comma-separated). Auto-detects LAN IPs if empty.
|
||||||
|
- CORS_ORIGINS=${CORS_ORIGINS:-}
|
||||||
volumes:
|
volumes:
|
||||||
- backend_data:/app/data
|
- backend_data:/app/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
Binary file not shown.
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "0.3.0",
|
"version": "0.5.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently \"npm run dev:frontend\" \"npm run dev:backend\"",
|
"dev": "concurrently \"npm run dev:frontend\" \"npm run dev:backend\"",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -146,6 +146,9 @@ export default function Dashboard() {
|
|||||||
gibs_imagery: false,
|
gibs_imagery: false,
|
||||||
highres_satellite: false,
|
highres_satellite: false,
|
||||||
kiwisdr: false,
|
kiwisdr: false,
|
||||||
|
firms: false,
|
||||||
|
internet_outages: false,
|
||||||
|
datacenters: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// NASA GIBS satellite imagery state
|
// NASA GIBS satellite imagery state
|
||||||
@@ -161,7 +164,7 @@ export default function Dashboard() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const [activeStyle, setActiveStyle] = useState('DEFAULT');
|
const [activeStyle, setActiveStyle] = useState('DEFAULT');
|
||||||
const stylesList = ['DEFAULT', 'SATELLITE', 'FLIR', 'NVG', 'CRT'];
|
const stylesList = ['DEFAULT', 'SATELLITE'];
|
||||||
|
|
||||||
const cycleStyle = () => {
|
const cycleStyle = () => {
|
||||||
setActiveStyle((prev) => {
|
setActiveStyle((prev) => {
|
||||||
@@ -511,6 +514,21 @@ export default function Dashboard() {
|
|||||||
<div className="text-[8px] text-[var(--text-muted)] font-mono tracking-[0.2em]">STYLE</div>
|
<div className="text-[8px] text-[var(--text-muted)] font-mono tracking-[0.2em]">STYLE</div>
|
||||||
<div className="text-[11px] text-cyan-400 font-mono font-bold">{activeStyle}</div>
|
<div className="text-[11px] text-cyan-400 font-mono font-bold">{activeStyle}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="w-px h-8 bg-[var(--border-primary)]" />
|
||||||
|
|
||||||
|
{/* Space Weather */}
|
||||||
|
<div className="flex flex-col items-center" title={`Kp Index: ${data?.space_weather?.kp_index ?? 'N/A'}`}>
|
||||||
|
<div className="text-[8px] text-[var(--text-muted)] font-mono tracking-[0.2em]">SOLAR</div>
|
||||||
|
<div className={`text-[11px] font-mono font-bold ${
|
||||||
|
(data?.space_weather?.kp_index ?? 0) >= 5 ? 'text-red-400' :
|
||||||
|
(data?.space_weather?.kp_index ?? 0) >= 4 ? 'text-yellow-400' :
|
||||||
|
'text-green-400'
|
||||||
|
}`}>
|
||||||
|
{data?.space_weather?.kp_text || 'N/A'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -2,54 +2,45 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { X, Satellite, Radio, MapPin, Image, Layers, Bug } from "lucide-react";
|
import { X, Rss, Server, Zap, Shield, Bug } from "lucide-react";
|
||||||
|
|
||||||
const CURRENT_VERSION = "0.4";
|
const CURRENT_VERSION = "0.6";
|
||||||
const STORAGE_KEY = `shadowbroker_changelog_v${CURRENT_VERSION}`;
|
const STORAGE_KEY = `shadowbroker_changelog_v${CURRENT_VERSION}`;
|
||||||
|
|
||||||
const NEW_FEATURES = [
|
const NEW_FEATURES = [
|
||||||
{
|
{
|
||||||
icon: <Satellite size={14} className="text-cyan-400" />,
|
icon: <Rss size={14} className="text-orange-400" />,
|
||||||
title: "NASA GIBS Satellite Imagery",
|
title: "Custom News Feed Manager",
|
||||||
desc: "Daily MODIS Terra true-color imagery with 30-day time slider, play/pause animation, and opacity control.",
|
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: "cyan",
|
color: "orange",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <Layers size={14} className="text-green-400" />,
|
icon: <Server size={14} className="text-purple-400" />,
|
||||||
title: "High-Res Satellite (Esri)",
|
title: "Global Data Center Map Layer",
|
||||||
desc: "Sub-meter resolution imagery — zoom into buildings and terrain. Toggle in Data Layers or cycle to SATELLITE style.",
|
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: "green",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: <Radio size={14} className="text-amber-400" />,
|
|
||||||
title: "KiwiSDR Radio Receivers",
|
|
||||||
desc: "500+ public SDR receivers plotted worldwide. Click any node to open a live radio tuner directly in the SIGINT panel.",
|
|
||||||
color: "amber",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: <Image size={14} className="text-blue-400" />,
|
|
||||||
title: "Sentinel-2 Intel Card",
|
|
||||||
desc: "Right-click anywhere — a floating intel card shows the latest Sentinel-2 satellite photo with capture date and cloud cover. Click to open full resolution.",
|
|
||||||
color: "blue",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: <MapPin size={14} className="text-purple-400" />,
|
|
||||||
title: "LOCATE Bar",
|
|
||||||
desc: "New search bar above coordinates — enter coordinates (31.8, 34.8) or place names (Tehran, Strait of Hormuz) to fly directly there.",
|
|
||||||
color: "purple",
|
color: "purple",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <Layers size={14} className="text-cyan-400" />,
|
icon: <Zap size={14} className="text-yellow-400" />,
|
||||||
title: "SATELLITE Style Preset",
|
title: "Imperative Map Rendering",
|
||||||
desc: "STYLE button now cycles: DEFAULT → SATELLITE → FLIR → NVG → CRT. SATELLITE auto-enables high-res imagery.",
|
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: <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",
|
color: "cyan",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const BUG_FIXES = [
|
const BUG_FIXES = [
|
||||||
"Satellite imagery renders below all data icons — flights, ships, markers always visible on top",
|
"Settings panel now has tabbed UI — API Keys and News Feeds on separate tabs",
|
||||||
"Sentinel-2 click now opens the actual high-res PNG image directly in browser",
|
"Data center coordinates fixed for 187 Southern Hemisphere entries (were mirrored north of equator)",
|
||||||
"Light/dark theme fixed — UI stays dark, only the map basemap switches",
|
"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() {
|
export function useChangelog() {
|
||||||
|
|||||||
@@ -94,8 +94,7 @@ const LEGEND: LegendCategory[] = [
|
|||||||
{ svg: airliner("yellow"), label: "Military — Standard" },
|
{ svg: airliner("yellow"), label: "Military — Standard" },
|
||||||
{ svg: plane("yellow"), label: "Fighter / Interceptor" },
|
{ svg: plane("yellow"), label: "Fighter / Interceptor" },
|
||||||
{ svg: heli("yellow"), label: "Military — Helicopter" },
|
{ 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="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)" },
|
||||||
{ 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)" },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 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 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 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 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 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>`)}`;
|
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>`)}`;
|
||||||
@@ -53,6 +54,32 @@ const TURBOPROP_PATH = "M12 3C11.3 3 10.8 3.5 10.8 4V9L3 12V13.5L10.8 11.5V18.5L
|
|||||||
// Bizjet: sleek, small swept wings, T-tail
|
// Bizjet: sleek, small swept wings, T-tail
|
||||||
const BIZJET_PATH = "M12 1.5C11.4 1.5 11 2 11 2.8V9L5 12.5V14L11 12V18.5L8.5 20V21.5L12 20.5L15.5 21.5V20L13 18.5V12L19 14V12.5L13 9V2.8C13 2 12.6 1.5 12 1.5Z";
|
const BIZJET_PATH = "M12 1.5C11.4 1.5 11 2 11 2.8V9L5 12.5V14L11 12V18.5L8.5 20V21.5L12 20.5L15.5 21.5V20L13 18.5V12L19 14V12.5L13 9V2.8C13 2 12.6 1.5 12 1.5Z";
|
||||||
|
|
||||||
|
// --- Fire icon SVGs for FIRMS hotspots (multi-tongue flame, unmistakably fire) ---
|
||||||
|
function makeFireSvg(fill: string, innerFill: string, size = 18) {
|
||||||
|
// Multi-forked flame: main body + left tongue + right tongue + inner glow
|
||||||
|
return `data:image/svg+xml;utf8,${encodeURIComponent(
|
||||||
|
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 28">` +
|
||||||
|
// Main flame body (wide base, pointed top)
|
||||||
|
`<path d="M12 1C12 1 9 5 8 8C7 11 5.5 13 5.5 16.5C5.5 20.5 8 23.5 12 23.5C16 23.5 18.5 20.5 18.5 16.5C18.5 13 17 11 16 8C15 5 12 1 12 1Z" fill="${fill}" stroke="rgba(0,0,0,0.7)" stroke-width="0.7"/>` +
|
||||||
|
// Left tongue (forks out left from top)
|
||||||
|
`<path d="M10 8C10 8 7.5 4.5 7 2.5C7 2.5 6 5.5 7 9C7.5 10.5 8.5 11.5 9.5 12" fill="${fill}" stroke="rgba(0,0,0,0.5)" stroke-width="0.4"/>` +
|
||||||
|
// Right tongue (forks out right from top)
|
||||||
|
`<path d="M14 8C14 8 16.5 4.5 17 2.5C17 2.5 18 5.5 17 9C16.5 10.5 15.5 11.5 14.5 12" fill="${fill}" stroke="rgba(0,0,0,0.5)" stroke-width="0.4"/>` +
|
||||||
|
// Inner bright core
|
||||||
|
`<path d="M12 8C12 8 10.5 11 10.5 14.5C10.5 17.5 11 19.5 12 20C13 19.5 13.5 17.5 13.5 14.5C13.5 11 12 8 12 8Z" fill="${innerFill}" opacity="0.85"/>` +
|
||||||
|
`</svg>`
|
||||||
|
)}`;
|
||||||
|
}
|
||||||
|
const svgFireYellow = makeFireSvg('#ffcc00', '#fff5aa', 16);
|
||||||
|
const svgFireOrange = makeFireSvg('#ff8800', '#ffcc00', 18);
|
||||||
|
const svgFireRed = makeFireSvg('#ff2200', '#ff8800', 20);
|
||||||
|
const svgFireDarkRed = makeFireSvg('#cc0000', '#ff2200', 22);
|
||||||
|
// Larger fire icons for cluster markers (visually distinct from Global Incidents circles)
|
||||||
|
const svgFireClusterSmall = makeFireSvg('#ff6600', '#ffcc00', 32);
|
||||||
|
const svgFireClusterMed = makeFireSvg('#ff3300', '#ff8800', 40);
|
||||||
|
const svgFireClusterLarge = makeFireSvg('#cc0000', '#ff3300', 48);
|
||||||
|
const svgFireClusterXL = makeFireSvg('#880000', '#cc0000', 56);
|
||||||
|
|
||||||
function makeAircraftSvg(type: 'airliner' | 'turboprop' | 'bizjet' | 'generic', fill: string, stroke = 'black', size = 20) {
|
function makeAircraftSvg(type: 'airliner' | 'turboprop' | 'bizjet' | 'generic', fill: string, stroke = 'black', size = 20) {
|
||||||
const paths: Record<string, string> = { airliner: AIRLINER_PATH, turboprop: TURBOPROP_PATH, bizjet: BIZJET_PATH, generic: "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" };
|
const paths: Record<string, string> = { airliner: AIRLINER_PATH, turboprop: TURBOPROP_PATH, bizjet: BIZJET_PATH, generic: "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" };
|
||||||
const p = paths[type] || paths.generic;
|
const p = paths[type] || paths.generic;
|
||||||
@@ -202,8 +229,34 @@ const MISSION_ICON_MAP: Record<string, string> = {
|
|||||||
'commercial_imaging': 'sat-com', 'space_station': 'sat-station'
|
'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 MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, selectedEntity, onMouseCoords, onRightClick, regionDossier, regionDossierLoading, onViewStateChange, measureMode, onMeasureClick, measurePoints, gibsDate, gibsOpacity }: any) => {
|
||||||
const mapRef = useRef<MapRef>(null);
|
const mapRef = useRef<MapRef>(null);
|
||||||
|
const [mapReady, setMapReady] = useState(false);
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const mapThemeStyle = useMemo(() => theme === 'light' ? lightStyle : darkStyle, [theme]);
|
const mapThemeStyle = useMemo(() => theme === 'light' ? lightStyle : darkStyle, [theme]);
|
||||||
|
|
||||||
@@ -411,6 +464,86 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
};
|
};
|
||||||
}, [activeLayers.kiwisdr, data?.kiwisdr, inView]);
|
}, [activeLayers.kiwisdr, data?.kiwisdr, inView]);
|
||||||
|
|
||||||
|
// FIRMS fires — heat-colored dots by FRP (Fire Radiative Power)
|
||||||
|
const firmsGeoJSON = useMemo(() => {
|
||||||
|
if (!activeLayers.firms || !data?.firms_fires?.length) return null;
|
||||||
|
return {
|
||||||
|
type: 'FeatureCollection' as const,
|
||||||
|
features: data.firms_fires.map((f: any, i: number) => {
|
||||||
|
const frp = f.frp || 0;
|
||||||
|
const iconId = frp >= 100 ? 'fire-darkred' : frp >= 20 ? 'fire-red' : frp >= 5 ? 'fire-orange' : 'fire-yellow';
|
||||||
|
return {
|
||||||
|
type: 'Feature' as const,
|
||||||
|
properties: {
|
||||||
|
id: i,
|
||||||
|
type: 'firms_fire',
|
||||||
|
name: `Fire ${frp.toFixed(1)} MW`,
|
||||||
|
frp,
|
||||||
|
iconId,
|
||||||
|
brightness: f.brightness || 0,
|
||||||
|
confidence: f.confidence || '',
|
||||||
|
daynight: f.daynight === 'D' ? 'Day' : 'Night',
|
||||||
|
acq_date: f.acq_date || '',
|
||||||
|
acq_time: f.acq_time || '',
|
||||||
|
},
|
||||||
|
geometry: { type: 'Point' as const, coordinates: [f.lng, f.lat] }
|
||||||
|
};
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}, [activeLayers.firms, data?.firms_fires]);
|
||||||
|
|
||||||
|
// Internet outages — region-level with backend-geocoded coordinates
|
||||||
|
const internetOutagesGeoJSON = useMemo(() => {
|
||||||
|
if (!activeLayers.internet_outages || !data?.internet_outages?.length) return null;
|
||||||
|
return {
|
||||||
|
type: 'FeatureCollection' as const,
|
||||||
|
features: data.internet_outages.map((o: any) => {
|
||||||
|
const lat = o.lat;
|
||||||
|
const lng = o.lng;
|
||||||
|
if (lat == null || lng == null) return null;
|
||||||
|
const severity = o.severity || 0;
|
||||||
|
const region = o.region_name || o.region_code || '?';
|
||||||
|
const country = o.country_name || o.country_code || '';
|
||||||
|
const label = `${region}, ${country}`;
|
||||||
|
const detail = `${label}\n${severity}% drop · ${o.datasource || 'IODA'}`;
|
||||||
|
return {
|
||||||
|
type: 'Feature' as const,
|
||||||
|
properties: {
|
||||||
|
id: o.region_code || region,
|
||||||
|
type: 'internet_outage',
|
||||||
|
name: label,
|
||||||
|
country,
|
||||||
|
region,
|
||||||
|
level: o.level,
|
||||||
|
severity,
|
||||||
|
datasource: o.datasource || '',
|
||||||
|
detail,
|
||||||
|
},
|
||||||
|
geometry: { type: 'Point' as const, coordinates: [lng, lat] }
|
||||||
|
};
|
||||||
|
}).filter(Boolean)
|
||||||
|
};
|
||||||
|
}, [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
|
// Load Images into the Map Style once loaded
|
||||||
const onMapLoad = useCallback((e: any) => {
|
const onMapLoad = useCallback((e: any) => {
|
||||||
const map = e.target;
|
const map = e.target;
|
||||||
@@ -514,6 +647,18 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
loadImg('icon-threat', svgThreat);
|
loadImg('icon-threat', svgThreat);
|
||||||
loadImg('icon-liveua-yellow', svgTriangleYellow);
|
loadImg('icon-liveua-yellow', svgTriangleYellow);
|
||||||
loadImg('icon-liveua-red', svgTriangleRed);
|
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
|
// Satellite mission-type icons
|
||||||
loadImg('sat-mil', makeSatSvg('#ff3333'));
|
loadImg('sat-mil', makeSatSvg('#ff3333'));
|
||||||
@@ -524,6 +669,8 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
loadImg('sat-com', makeSatSvg('#44ff44'));
|
loadImg('sat-com', makeSatSvg('#44ff44'));
|
||||||
loadImg('sat-station', makeSatSvg('#ffdd00'));
|
loadImg('sat-station', makeSatSvg('#ffdd00'));
|
||||||
loadImg('sat-gen', makeSatSvg('#aaaaaa'));
|
loadImg('sat-gen', makeSatSvg('#aaaaaa'));
|
||||||
|
|
||||||
|
setMapReady(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Build a set of tracked icao24s to exclude from other flight layers
|
// Build a set of tracked icao24s to exclude from other flight layers
|
||||||
@@ -1044,7 +1191,7 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
return {
|
return {
|
||||||
type: 'Feature',
|
type: 'Feature',
|
||||||
properties: {
|
properties: {
|
||||||
id: uav.id || i,
|
id: uav.id || `uav-${i}`,
|
||||||
type: 'uav',
|
type: 'uav',
|
||||||
callsign: uav.callsign,
|
callsign: uav.callsign,
|
||||||
rotation: uav.heading || 0,
|
rotation: uav.heading || 0,
|
||||||
@@ -1053,9 +1200,11 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
country: uav.country || '',
|
country: uav.country || '',
|
||||||
uav_type: uav.uav_type || '',
|
uav_type: uav.uav_type || '',
|
||||||
alt: uav.alt || 0,
|
alt: uav.alt || 0,
|
||||||
range_km: uav.range_km || 0,
|
|
||||||
wiki: uav.wiki || '',
|
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] }
|
geometry: { type: 'Point', coordinates: [uav.lng, uav.lat] }
|
||||||
};
|
};
|
||||||
@@ -1063,31 +1212,7 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
};
|
};
|
||||||
}, [activeLayers.military, data?.uavs, inView]);
|
}, [activeLayers.military, data?.uavs, inView]);
|
||||||
|
|
||||||
// UAV operational range circle — only for the selected UAV
|
// UAV range circles removed — real ADS-B drones don't have a fixed orbit center
|
||||||
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]);
|
|
||||||
|
|
||||||
const gdeltGeoJSON = useMemo(() => {
|
const gdeltGeoJSON = useMemo(() => {
|
||||||
if (!activeLayers.global_incidents || !data?.gdelt) return null;
|
if (!activeLayers.global_incidents || !data?.gdelt) return null;
|
||||||
@@ -1145,10 +1270,28 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
earthquakesGeoJSON && 'earthquakes-layer',
|
earthquakesGeoJSON && 'earthquakes-layer',
|
||||||
satellitesGeoJSON && 'satellites-layer',
|
satellitesGeoJSON && 'satellites-layer',
|
||||||
cctvGeoJSON && 'cctv-layer',
|
cctvGeoJSON && 'cctv-layer',
|
||||||
kiwisdrGeoJSON && 'kiwisdr-layer'
|
kiwisdrGeoJSON && 'kiwisdr-layer',
|
||||||
|
internetOutagesGeoJSON && 'internet-outages-layer',
|
||||||
|
dataCentersGeoJSON && 'datacenters-layer',
|
||||||
|
firmsGeoJSON && 'firms-viirs-layer'
|
||||||
].filter(Boolean) as string[];
|
].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) => {
|
const handleMouseMove = useCallback((evt: any) => {
|
||||||
if (onMouseCoords) onMouseCoords({ lat: evt.lngLat.lat, lng: evt.lngLat.lng });
|
if (onMouseCoords) onMouseCoords({ lat: evt.lngLat.lat, lng: evt.lngLat.lng });
|
||||||
}, [onMouseCoords]);
|
}, [onMouseCoords]);
|
||||||
@@ -1249,6 +1392,51 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
</Source>
|
</Source>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* NASA FIRMS VIIRS — fire hotspot icons from FIRMS CSV feed */}
|
||||||
|
{/* 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"
|
||||||
|
type="symbol"
|
||||||
|
filter={['has', 'point_count']}
|
||||||
|
layout={{
|
||||||
|
'icon-image': ['step', ['get', 'point_count'],
|
||||||
|
'fire-cluster-sm', 10, 'fire-cluster-md', 50, 'fire-cluster-lg', 200, 'fire-cluster-xl'],
|
||||||
|
'icon-size': ['step', ['get', 'point_count'], 1.0, 10, 1.1, 50, 1.2, 200, 1.3],
|
||||||
|
'icon-allow-overlap': true,
|
||||||
|
'icon-ignore-placement': true,
|
||||||
|
'text-field': '{point_count_abbreviated}',
|
||||||
|
'text-font': ['Noto Sans Bold'],
|
||||||
|
'text-size': ['step', ['get', 'point_count'], 9, 10, 10, 50, 11, 200, 12],
|
||||||
|
'text-offset': [0, 0.15],
|
||||||
|
'text-allow-overlap': true,
|
||||||
|
}}
|
||||||
|
paint={{
|
||||||
|
'text-color': '#ffffff',
|
||||||
|
'text-halo-color': 'rgba(0,0,0,0.8)',
|
||||||
|
'text-halo-width': 1.2,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* Individual fire icons — flame shape sized by FRP */}
|
||||||
|
<Layer
|
||||||
|
id="firms-viirs-layer"
|
||||||
|
type="symbol"
|
||||||
|
filter={['!', ['has', 'point_count']]}
|
||||||
|
layout={{
|
||||||
|
'icon-image': ['get', 'iconId'],
|
||||||
|
'icon-size': ['interpolate', ['linear'], ['zoom'],
|
||||||
|
2, 0.4,
|
||||||
|
5, 0.6,
|
||||||
|
8, 0.8,
|
||||||
|
12, 1.0
|
||||||
|
],
|
||||||
|
'icon-allow-overlap': true,
|
||||||
|
'icon-ignore-placement': true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Source>
|
||||||
|
|
||||||
{/* SOLAR TERMINATOR — night overlay */}
|
{/* SOLAR TERMINATOR — night overlay */}
|
||||||
{activeLayers.day_night && nightGeoJSON && (
|
{activeLayers.day_night && nightGeoJSON && (
|
||||||
<Source id="night-overlay" type="geojson" data={nightGeoJSON as any}>
|
<Source id="night-overlay" type="geojson" data={nightGeoJSON as any}>
|
||||||
@@ -1263,8 +1451,8 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
</Source>
|
</Source>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{commFlightsGeoJSON && (
|
{/* commercial/private/military flights: data pushed imperatively */}
|
||||||
<Source id="commercial-flights" type="geojson" data={commFlightsGeoJSON as any}>
|
<Source id="commercial-flights" type="geojson" data={EMPTY_FC as any}>
|
||||||
<Layer
|
<Layer
|
||||||
id="commercial-flights-layer"
|
id="commercial-flights-layer"
|
||||||
type="symbol"
|
type="symbol"
|
||||||
@@ -1278,10 +1466,8 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
paint={{ 'icon-opacity': opacityFilter }}
|
paint={{ 'icon-opacity': opacityFilter }}
|
||||||
/>
|
/>
|
||||||
</Source>
|
</Source>
|
||||||
)}
|
|
||||||
|
|
||||||
{privFlightsGeoJSON && (
|
<Source id="private-flights" type="geojson" data={EMPTY_FC as any}>
|
||||||
<Source id="private-flights" type="geojson" data={privFlightsGeoJSON as any}>
|
|
||||||
<Layer
|
<Layer
|
||||||
id="private-flights-layer"
|
id="private-flights-layer"
|
||||||
type="symbol"
|
type="symbol"
|
||||||
@@ -1295,10 +1481,8 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
paint={{ 'icon-opacity': opacityFilter }}
|
paint={{ 'icon-opacity': opacityFilter }}
|
||||||
/>
|
/>
|
||||||
</Source>
|
</Source>
|
||||||
)}
|
|
||||||
|
|
||||||
{privJetsGeoJSON && (
|
<Source id="private-jets" type="geojson" data={EMPTY_FC as any}>
|
||||||
<Source id="private-jets" type="geojson" data={privJetsGeoJSON as any}>
|
|
||||||
<Layer
|
<Layer
|
||||||
id="private-jets-layer"
|
id="private-jets-layer"
|
||||||
type="symbol"
|
type="symbol"
|
||||||
@@ -1312,10 +1496,8 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
paint={{ 'icon-opacity': opacityFilter }}
|
paint={{ 'icon-opacity': opacityFilter }}
|
||||||
/>
|
/>
|
||||||
</Source>
|
</Source>
|
||||||
)}
|
|
||||||
|
|
||||||
{milFlightsGeoJSON && (
|
<Source id="military-flights" type="geojson" data={EMPTY_FC as any}>
|
||||||
<Source id="military-flights" type="geojson" data={milFlightsGeoJSON as any}>
|
|
||||||
<Layer
|
<Layer
|
||||||
id="military-flights-layer"
|
id="military-flights-layer"
|
||||||
type="symbol"
|
type="symbol"
|
||||||
@@ -1329,7 +1511,6 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
paint={{ 'icon-opacity': opacityFilter }}
|
paint={{ 'icon-opacity': opacityFilter }}
|
||||||
/>
|
/>
|
||||||
</Source>
|
</Source>
|
||||||
)}
|
|
||||||
|
|
||||||
{shipsGeoJSON && (
|
{shipsGeoJSON && (
|
||||||
<Source
|
<Source
|
||||||
@@ -1445,8 +1626,8 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
</Source>
|
</Source>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{trackedFlightsGeoJSON && (
|
{/* tracked-flights & UAVs: data pushed imperatively */}
|
||||||
<Source id="tracked-flights" type="geojson" data={trackedFlightsGeoJSON as any}>
|
<Source id="tracked-flights" type="geojson" data={EMPTY_FC as any}>
|
||||||
<Layer
|
<Layer
|
||||||
id="tracked-flights-layer"
|
id="tracked-flights-layer"
|
||||||
type="symbol"
|
type="symbol"
|
||||||
@@ -1460,10 +1641,8 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
paint={{ 'icon-opacity': opacityFilter }}
|
paint={{ 'icon-opacity': opacityFilter }}
|
||||||
/>
|
/>
|
||||||
</Source>
|
</Source>
|
||||||
)}
|
|
||||||
|
|
||||||
{uavGeoJSON && (
|
<Source id="uavs" type="geojson" data={EMPTY_FC as any}>
|
||||||
<Source id="uavs" type="geojson" data={uavGeoJSON as any}>
|
|
||||||
<Layer
|
<Layer
|
||||||
id="uav-layer"
|
id="uav-layer"
|
||||||
type="symbol"
|
type="symbol"
|
||||||
@@ -1477,31 +1656,8 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
paint={{ 'icon-opacity': opacityFilter }}
|
paint={{ 'icon-opacity': opacityFilter }}
|
||||||
/>
|
/>
|
||||||
</Source>
|
</Source>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* UAV Operational Range Circles */}
|
{/* UAV range circles removed — real ADS-B data has no fixed orbit */}
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{gdeltGeoJSON && (
|
{gdeltGeoJSON && (
|
||||||
<Source id="gdelt" type="geojson" data={gdeltGeoJSON as any}>
|
<Source id="gdelt" type="geojson" data={gdeltGeoJSON as any}>
|
||||||
@@ -1560,15 +1716,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) => {
|
{carriersGeoJSON && !selectedEntity && data?.ships?.map((s: any, i: number) => {
|
||||||
if (s.type !== 'carrier' || s.lat == null || s.lng == null) return null;
|
if (s.type !== 'carrier' || s.lat == null || s.lng == null) return null;
|
||||||
if (!inView(s.lat, s.lng)) return null;
|
if (!inView(s.lat, s.lng)) return null;
|
||||||
const [iLng, iLat] = interpShip(s);
|
const [iLng, iLat] = interpShip(s);
|
||||||
return (
|
return (
|
||||||
<Marker key={`carrier-label-${i}`} longitude={iLng} latitude={iLat} anchor="top" offset={[0, 12]} style={{ zIndex: 2 }}>
|
<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' }}>
|
<div style={{ fontFamily: 'monospace', textShadow: '0 0 3px #000, 0 0 3px #000, 1px 1px 2px #000', whiteSpace: 'nowrap', pointerEvents: 'none', textAlign: 'center' }}>
|
||||||
[[{s.name}]]
|
<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>
|
</div>
|
||||||
</Marker>
|
</Marker>
|
||||||
);
|
);
|
||||||
@@ -1906,9 +2069,128 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
</Source>
|
</Source>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Internet Outages — region-level grey markers with % and labels */}
|
||||||
|
{internetOutagesGeoJSON && (
|
||||||
|
<Source id="internet-outages" type="geojson" data={internetOutagesGeoJSON as any}>
|
||||||
|
{/* Outer ring */}
|
||||||
|
<Layer
|
||||||
|
id="internet-outages-pulse"
|
||||||
|
type="circle"
|
||||||
|
paint={{
|
||||||
|
'circle-radius': ['interpolate', ['linear'], ['get', 'severity'], 0, 14, 50, 18, 80, 22],
|
||||||
|
'circle-color': 'rgba(180, 180, 180, 0.1)',
|
||||||
|
'circle-stroke-width': 1.5,
|
||||||
|
'circle-stroke-color': 'rgba(180, 180, 180, 0.35)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* Inner solid circle — all grey, size conveys severity */}
|
||||||
|
<Layer
|
||||||
|
id="internet-outages-layer"
|
||||||
|
type="circle"
|
||||||
|
paint={{
|
||||||
|
'circle-radius': ['interpolate', ['linear'], ['get', 'severity'], 0, 6, 50, 9, 80, 12],
|
||||||
|
'circle-color': '#888888',
|
||||||
|
'circle-stroke-width': 2,
|
||||||
|
'circle-stroke-color': 'rgba(0, 0, 0, 0.6)',
|
||||||
|
'circle-opacity': 0.9
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* Severity % inside circle */}
|
||||||
|
<Layer
|
||||||
|
id="internet-outages-pct"
|
||||||
|
type="symbol"
|
||||||
|
layout={{
|
||||||
|
'text-field': ['case', ['>', ['get', 'severity'], 0], ['concat', ['to-string', ['get', 'severity']], '%'], '!'],
|
||||||
|
'text-size': 9,
|
||||||
|
'text-font': ['Noto Sans Bold'],
|
||||||
|
'text-allow-overlap': true,
|
||||||
|
'text-ignore-placement': true,
|
||||||
|
}}
|
||||||
|
paint={{
|
||||||
|
'text-color': '#ffffff',
|
||||||
|
'text-halo-color': 'rgba(0,0,0,0.8)',
|
||||||
|
'text-halo-width': 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* Region name label below — grey */}
|
||||||
|
<Layer
|
||||||
|
id="internet-outages-label"
|
||||||
|
type="symbol"
|
||||||
|
layout={{
|
||||||
|
'text-field': ['get', 'region'],
|
||||||
|
'text-size': 10,
|
||||||
|
'text-font': ['Noto Sans Bold'],
|
||||||
|
'text-offset': [0, 1.8],
|
||||||
|
'text-anchor': 'top',
|
||||||
|
'text-allow-overlap': false,
|
||||||
|
}}
|
||||||
|
paint={{
|
||||||
|
'text-color': '#aaaaaa',
|
||||||
|
'text-halo-color': 'rgba(0,0,0,0.9)',
|
||||||
|
'text-halo-width': 1.5,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</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 */}
|
{/* Satellite positions — mission-type icons */}
|
||||||
{satellitesGeoJSON && (
|
{/* satellites: data pushed imperatively */}
|
||||||
<Source id="satellites" type="geojson" data={satellitesGeoJSON as any}>
|
<Source id="satellites" type="geojson" data={EMPTY_FC as any}>
|
||||||
<Layer
|
<Layer
|
||||||
id="satellites-layer"
|
id="satellites-layer"
|
||||||
type="symbol"
|
type="symbol"
|
||||||
@@ -1925,7 +2207,6 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Source>
|
</Source>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Satellite click popup */}
|
{/* Satellite click popup */}
|
||||||
{selectedEntity?.type === 'satellite' && (() => {
|
{selectedEntity?.type === 'satellite' && (() => {
|
||||||
@@ -1984,7 +2265,7 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
{/* UAV click popup */}
|
{/* UAV click popup — real ADS-B detected drones */}
|
||||||
{selectedEntity?.type === 'uav' && (() => {
|
{selectedEntity?.type === 'uav' && (() => {
|
||||||
const uav = data?.uavs?.find((u: any) => u.id === selectedEntity.id);
|
const uav = data?.uavs?.find((u: any) => u.id === selectedEntity.id);
|
||||||
if (!uav) return null;
|
if (!uav) return null;
|
||||||
@@ -2001,16 +2282,29 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
fontFamily: 'monospace', fontSize: 11, minWidth: 220, maxWidth: 320
|
fontFamily: 'monospace', fontSize: 11, minWidth: 220, maxWidth: 320
|
||||||
}}>
|
}}>
|
||||||
<div style={{ color: '#ff4444', fontWeight: 700, fontSize: 13, marginBottom: 6, letterSpacing: 1 }}>
|
<div style={{ color: '#ff4444', fontWeight: 700, fontSize: 13, marginBottom: 6, letterSpacing: 1 }}>
|
||||||
✈️ {uav.callsign}
|
{uav.callsign}
|
||||||
</div>
|
</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 && (
|
{uav.uav_type && (
|
||||||
<div style={{ marginBottom: 4 }}>
|
<div style={{ marginBottom: 4 }}>
|
||||||
Type: <span style={{ color: '#ffcc00' }}>{uav.uav_type}</span>
|
Classification: <span style={{ color: '#ffcc00' }}>{uav.uav_type}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{uav.country && (
|
{uav.country && (
|
||||||
<div style={{ marginBottom: 4 }}>
|
<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>
|
||||||
)}
|
)}
|
||||||
<div style={{ marginBottom: 4 }}>
|
<div style={{ marginBottom: 4 }}>
|
||||||
@@ -2021,9 +2315,9 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
Speed: <span style={{ color: '#00e5ff' }}>{uav.speed_knots} kn</span>
|
Speed: <span style={{ color: '#00e5ff' }}>{uav.speed_knots} kn</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{uav.range_km > 0 && (
|
{uav.squawk && (
|
||||||
<div style={{ marginBottom: 4 }}>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{uav.wiki && (
|
{uav.wiki && (
|
||||||
@@ -2035,6 +2329,135 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
</Popup>
|
</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] && (
|
selectedEntity?.type === 'gdelt' && data?.gdelt?.[selectedEntity.id as number] && (
|
||||||
<Popup
|
<Popup
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { API_BASE } from "@/lib/api";
|
import { API_BASE } from "@/lib/api";
|
||||||
import React, { useState, useEffect, useCallback } from "react";
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
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 {
|
interface ApiEntry {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -18,6 +18,22 @@ interface ApiEntry {
|
|||||||
is_set: boolean;
|
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
|
// Category colors for the tactical UI
|
||||||
const CATEGORY_COLORS: Record<string, string> = {
|
const CATEGORY_COLORS: Record<string, string> = {
|
||||||
Aviation: "text-cyan-400 border-cyan-500/30 bg-cyan-950/20",
|
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",
|
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 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 [apis, setApis] = useState<ApiEntry[]>([]);
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
const [editValue, setEditValue] = useState("");
|
const [editValue, setEditValue] = useState("");
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set(["Aviation", "Maritime"]));
|
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 () => {
|
const fetchKeys = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE}/api/settings/api-keys`);
|
const res = await fetch(`${API_BASE}/api/settings/api-keys`);
|
||||||
if (res.ok) {
|
if (res.ok) setApis(await res.json());
|
||||||
const data = await res.json();
|
|
||||||
setApis(data);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to fetch API keys", e);
|
console.error("Failed to fetch API keys", e);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
const fetchFeeds = useCallback(async () => {
|
||||||
if (isOpen) fetchKeys();
|
try {
|
||||||
}, [isOpen, fetchKeys]);
|
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) => {
|
useEffect(() => {
|
||||||
setEditingId(api.id);
|
if (isOpen) {
|
||||||
setEditValue("");
|
fetchKeys();
|
||||||
};
|
fetchFeeds();
|
||||||
|
}
|
||||||
|
}, [isOpen, fetchKeys, fetchFeeds]);
|
||||||
|
|
||||||
|
// API Keys handlers
|
||||||
|
const startEditing = (api: ApiEntry) => { setEditingId(api.id); setEditValue(""); };
|
||||||
|
|
||||||
const saveKey = async (api: ApiEntry) => {
|
const saveKey = async (api: ApiEntry) => {
|
||||||
if (!api.env_key) return;
|
if (!api.env_key) return;
|
||||||
@@ -68,33 +105,81 @@ const SettingsPanel = React.memo(function SettingsPanel({ isOpen, onClose }: { i
|
|||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ env_key: api.env_key, value: editValue }),
|
body: JSON.stringify({ env_key: api.env_key, value: editValue }),
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) { setEditingId(null); fetchKeys(); }
|
||||||
setEditingId(null);
|
|
||||||
fetchKeys(); // Refresh to get new obfuscated value
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to save API key", e);
|
console.error("Failed to save API key", e);
|
||||||
} finally {
|
} finally { setSaving(false); }
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleCategory = (cat: string) => {
|
const toggleCategory = (cat: string) => {
|
||||||
setExpandedCategories(prev => {
|
setExpandedCategories(prev => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
if (next.has(cat)) next.delete(cat);
|
if (next.has(cat)) next.delete(cat); else next.add(cat);
|
||||||
else next.add(cat);
|
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Group APIs by category
|
|
||||||
const grouped = apis.reduce<Record<string, ApiEntry[]>>((acc, api) => {
|
const grouped = apis.reduce<Record<string, ApiEntry[]>>((acc, api) => {
|
||||||
if (!acc[api.category]) acc[api.category] = [];
|
if (!acc[api.category]) acc[api.category] = [];
|
||||||
acc[api.category].push(api);
|
acc[api.category].push(api);
|
||||||
return acc;
|
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 (
|
return (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
@@ -124,7 +209,7 @@ const SettingsPanel = React.memo(function SettingsPanel({ isOpen, onClose }: { i
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-sm font-bold tracking-[0.2em] text-[var(--text-primary)] font-mono">SYSTEM CONFIG</h2>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -135,153 +220,237 @@ const SettingsPanel = React.memo(function SettingsPanel({ isOpen, onClose }: { i
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Info Banner */}
|
{/* Tab Bar */}
|
||||||
<div className="mx-4 mt-4 p-3 rounded-lg border border-cyan-900/30 bg-cyan-950/10">
|
<div className="flex border-b border-[var(--border-primary)]/60">
|
||||||
<div className="flex items-start gap-2">
|
<button
|
||||||
<Shield size={12} className="text-cyan-500 mt-0.5 flex-shrink-0" />
|
onClick={() => setActiveTab("api-keys")}
|
||||||
<p className="text-[10px] text-[var(--text-secondary)] font-mono leading-relaxed">
|
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)]"}`}
|
||||||
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>
|
<Key size={10} />
|
||||||
</div>
|
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>
|
</div>
|
||||||
|
|
||||||
{/* API List */}
|
{/* ==================== API KEYS TAB ==================== */}
|
||||||
<div className="flex-1 overflow-y-auto styled-scrollbar p-4 space-y-3">
|
{activeTab === "api-keys" && (
|
||||||
{Object.entries(grouped).map(([category, categoryApis]) => {
|
<>
|
||||||
const colorClass = CATEGORY_COLORS[category] || "text-gray-400 border-gray-700 bg-gray-900/20";
|
{/* Info Banner */}
|
||||||
const isExpanded = expandedCategories.has(category);
|
<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">
|
||||||
return (
|
<Shield size={12} className="text-cyan-500 mt-0.5 flex-shrink-0" />
|
||||||
<div key={category} className="rounded-lg border border-[var(--border-primary)]/60 overflow-hidden">
|
<p className="text-[10px] text-[var(--text-secondary)] font-mono leading-relaxed">
|
||||||
{/* Category Header */}
|
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.
|
||||||
<button
|
</p>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer */}
|
{/* API List */}
|
||||||
<div className="p-4 border-t border-[var(--border-primary)]/80">
|
<div className="flex-1 overflow-y-auto styled-scrollbar p-4 space-y-3">
|
||||||
<div className="flex items-center justify-between text-[9px] text-[var(--text-muted)] font-mono">
|
{Object.entries(grouped).map(([category, categoryApis]) => {
|
||||||
<span>{apis.length} REGISTERED APIs</span>
|
const colorClass = CATEGORY_COLORS[category] || "text-gray-400 border-gray-700 bg-gray-900/20";
|
||||||
<span>{apis.filter(a => a.has_key).length} KEYS CONFIGURED</span>
|
const isExpanded = expandedCategories.has(category);
|
||||||
</div>
|
return (
|
||||||
</div>
|
<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>
|
</motion.div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,9 +2,44 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect, useRef } from "react";
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
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 } 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";
|
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 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 [isMinimized, setIsMinimized] = useState(false);
|
||||||
const { theme, toggleTheme } = useTheme();
|
const { theme, toggleTheme } = useTheme();
|
||||||
@@ -58,6 +93,9 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, active
|
|||||||
{ id: "gibs_imagery", name: "MODIS Terra (Daily)", source: "NASA GIBS", count: null, icon: Globe },
|
{ id: "gibs_imagery", name: "MODIS Terra (Daily)", source: "NASA GIBS", count: null, icon: Globe },
|
||||||
{ id: "highres_satellite", name: "High-Res Satellite", source: "Esri World Imagery", count: null, icon: Satellite },
|
{ id: "highres_satellite", name: "High-Res Satellite", source: "Esri World Imagery", count: null, icon: Satellite },
|
||||||
{ id: "kiwisdr", name: "KiwiSDR Receivers", source: "KiwiSDR.com", count: data?.kiwisdr?.length || 0, icon: Radio },
|
{ 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 },
|
{ id: "day_night", name: "Day / Night Cycle", source: "Solar Calc", count: null, icon: Sun },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -144,7 +182,12 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, active
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
<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-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>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|||||||
@@ -17,9 +17,16 @@ if %errorlevel% neq 0 (
|
|||||||
exit /b 1
|
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
|
for /f "tokens=2 delims= " %%v in ('python --version 2^>^&1') do set PYVER=%%v
|
||||||
echo [*] Found Python %PYVER%
|
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
|
:: Check for Node.js
|
||||||
where npm >nul 2>&1
|
where npm >nul 2>&1
|
||||||
@@ -47,7 +54,7 @@ if not exist "venv\" (
|
|||||||
)
|
)
|
||||||
call venv\Scripts\activate.bat
|
call venv\Scripts\activate.bat
|
||||||
echo [*] Installing Python dependencies (this may take a minute)...
|
echo [*] Installing Python dependencies (this may take a minute)...
|
||||||
pip install -r requirements.txt
|
pip install -q -r requirements.txt
|
||||||
if %errorlevel% neq 0 (
|
if %errorlevel% neq 0 (
|
||||||
echo.
|
echo.
|
||||||
echo [!] ERROR: pip install failed. See errors above.
|
echo [!] ERROR: pip install failed. See errors above.
|
||||||
@@ -58,6 +65,9 @@ if %errorlevel% neq 0 (
|
|||||||
exit /b 1
|
exit /b 1
|
||||||
)
|
)
|
||||||
echo [*] Backend dependencies OK.
|
echo [*] Backend dependencies OK.
|
||||||
|
echo [*] Installing backend Node.js dependencies...
|
||||||
|
call npm install --silent
|
||||||
|
echo [*] Backend Node.js dependencies OK.
|
||||||
cd ..
|
cd ..
|
||||||
|
|
||||||
echo.
|
echo.
|
||||||
|
|||||||
@@ -23,7 +23,14 @@ else
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
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
|
# Get the directory where this script lives
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
@@ -42,7 +49,7 @@ fi
|
|||||||
|
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
echo "[*] Installing Python dependencies (this may take a minute)..."
|
echo "[*] Installing Python dependencies (this may take a minute)..."
|
||||||
pip install -r requirements.txt
|
pip install -q -r requirements.txt
|
||||||
if [ $? -ne 0 ]; then
|
if [ $? -ne 0 ]; then
|
||||||
echo ""
|
echo ""
|
||||||
echo "[!] ERROR: pip install failed. See errors above."
|
echo "[!] ERROR: pip install failed. See errors above."
|
||||||
@@ -52,6 +59,9 @@ if [ $? -ne 0 ]; then
|
|||||||
fi
|
fi
|
||||||
echo "[*] Backend dependencies OK."
|
echo "[*] Backend dependencies OK."
|
||||||
deactivate
|
deactivate
|
||||||
|
echo "[*] Installing backend Node.js dependencies..."
|
||||||
|
npm install --silent
|
||||||
|
echo "[*] Backend Node.js dependencies OK."
|
||||||
|
|
||||||
cd "$SCRIPT_DIR"
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user