8 Commits

Author SHA1 Message Date
anoracleofra-code 2ae104fca2 v0.6.0: custom news feeds, data center map layer, performance hardening
New features:
- Custom RSS Feed Manager: add/remove/prioritize up to 20 news sources
  from the Settings panel with weight levels 1-5. Persists across restarts.
- Global Data Center Map Layer: 2,000+ DCs plotted worldwide with clustering,
  server-rack icons, and automatic internet outage cross-referencing.
- Imperative map rendering: high-volume layers bypass React reconciliation
  via direct setData() calls with debounced updates on dense layers.
- Enhanced /api/health with per-source freshness timestamps and counts.

Fixes:
- Data center coordinates fixed for 187 Southern Hemisphere entries
- Docker CORS_ORIGINS passthrough in docker-compose.yml
- Start scripts warn on Python 3.13+ compatibility
- Settings panel redesigned with tabbed UI (API Keys / News Feeds)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Former-commit-id: 950c308f04
2026-03-10 15:27:20 -06:00
anoracleofra-code 12857a4b83 v0.5.0: FIRMS fire hotspots, space weather, internet outages
New intelligence layers:
- NASA FIRMS VIIRS fire hotspots (5K+ global thermal anomalies, flame icons)
- NOAA space weather badge (Kp index in status bar)
- IODA regional internet outage monitoring (grey markers, BGP/ping only)

Key improvements:
- Fire clusters use flame-shaped icons (not circles) for clear differentiation
- Internet outages are region-level with reliable datasources only
- Removed radiation layer (no viable free real-time API)
- All outage markers grey to avoid color confusion with other layers
- Filtered out merit-nt telescope data that produced misleading percentages

Updated changelog modal, README, and package.json for v0.5.0.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Former-commit-id: 195c6b64b9
2026-03-10 10:23:38 -06:00
anoracleofra-code c343084def feat: add FIRMS thermal, space weather, radiation, and internet outage layers
Add 4 new intelligence layers for v0.5:
- NASA FIRMS VIIRS thermal anomaly tiles (frontend-only WMTS)
- NOAA Space Weather Kp index badge in bottom bar
- Safecast radiation monitoring with clustered markers
- IODA internet outage alerts at country centroids

All use free keyless APIs. All layers default to off.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Former-commit-id: 7cb926e227
2026-03-10 09:01:35 -06:00
anoracleofra-code c085475110 fix: remove defunct FLIR/NVG/CRT style presets, keep only DEFAULT and SATELLITE
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Former-commit-id: c4de39bb02
2026-03-10 04:53:17 -06:00
anoracleofra-code e0257d2419 chore: remove debug/sample files from tracking, update .gitignore
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Former-commit-id: e7f3378b5a
2026-03-10 04:31:21 -06:00
anoracleofra-code 5d221c3dc7 fix: install backend Node.js deps (ws) in start scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Former-commit-id: 41a7811360
2026-03-10 04:25:53 -06:00
anoracleofra-code dd8485d1b6 fix: filter out TWR (tower/platform) ADS-B transponders from flight data
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Former-commit-id: 791ec971d9
2026-03-09 21:41:57 -06:00
anoracleofra-code f6aa5ccbc1 chore: bump frontend version to 0.4.0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Former-commit-id: d05bef8de5
2026-03-09 21:02:03 -06:00
26 changed files with 1612 additions and 11513 deletions
+6
View File
@@ -68,6 +68,12 @@ TheAirTraffic Database.xlsx
# Debug dumps & release artifacts
backend/dump.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
.git_backup/
+26 -5
View File
@@ -79,7 +79,7 @@ Do not append a trailing `.` to that command; Compose treats it as a service nam
* **Global Incidents** — GDELT-powered conflict event aggregation (last 8 hours, ~1,000 events)
* **Ukraine Frontline** — Live warfront GeoJSON from DeepState Map
* **SIGINT/RISINT News Feed** — Real-time RSS aggregation from multiple intelligence-focused sources
* **SIGINT/RISINT News Feed** — Real-time RSS aggregation from multiple intelligence-focused sources with user-customizable feeds (up to 20 sources, configurable priority weights 1-5)
* **Region Dossier** — Right-click anywhere on the map for:
* Country profile (population, capital, languages, currencies, area)
* Head of state & government type (Wikidata SPARQL)
@@ -116,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
* **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 G1G5). 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
* **Earthquakes (24h)** — USGS real-time earthquake feed with magnitude-scaled markers
@@ -156,6 +163,9 @@ Do not append a trailing `.` to that command; Compose treats it as a service nam
│ │ ├──────────┼──────────┼──────────┼───────────┤ │ │
│ │ │ DeepState│ RSS │ Region │ GPS │ │ │
│ │ │ Frontline│ Intel │ Dossier │ Jamming │ │ │
│ │ ├──────────┼──────────┼──────────┼───────────┤ │ │
│ │ │ NASA │ NOAA │ IODA │ KiwiSDR │ │ │
│ │ │ FIRMS │ Space Wx│ Outages │ Radios │ │ │
│ │ └──────────┴──────────┴──────────┴───────────┘ │ │
│ └──────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────┘
@@ -186,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 |
| [KiwiSDR](https://kiwisdr.com) | Public SDR receiver locations | ~30min | 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 |
---
@@ -320,6 +334,9 @@ All layers are independently toggleable from the left panel:
| MODIS Terra (Daily) | ❌ OFF | NASA GIBS daily satellite imagery |
| High-Res Satellite | ❌ OFF | Esri sub-meter satellite imagery |
| 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 |
---
@@ -331,8 +348,9 @@ The platform is optimized for handling massive real-time datasets:
* **Gzip Compression** — API payloads compressed ~92% (11.6 MB → 915 KB)
* **ETag Caching** — `304 Not Modified` responses skip redundant JSON parsing
* **Viewport Culling** — Only features within the visible map bounds (+20% buffer) are rendered
* **Clustered Rendering** — Ships, CCTV, and earthquakes use MapLibre clustering to reduce feature count
* **Debounced Viewport Updates** — 300ms debounce prevents GeoJSON rebuild thrash during pan/zoom
* **Imperative Map Updates** — High-volume layers (flights, satellites, fires) bypass React reconciliation via direct `setData()` calls
* **Clustered Rendering** — Ships, CCTV, earthquakes, and data centers use MapLibre clustering to reduce feature count
* **Debounced Viewport Updates** — 300ms debounce prevents GeoJSON rebuild thrash during pan/zoom; 2s debounce on dense layers (satellites, fires)
* **Position Interpolation** — Smooth 10s tick animation between data refreshes
* **React.memo** — Heavy components wrapped to prevent unnecessary re-renders
* **Coordinate Precision** — Lat/lng rounded to 5 decimals (~1m) to reduce JSON size
@@ -347,6 +365,8 @@ live-risk-dashboard/
│ ├── main.py # FastAPI app, middleware, API routes
│ ├── carrier_cache.json # Persisted carrier OSINT positions
│ ├── cctv.db # SQLite CCTV camera database
│ ├── config/
│ │ └── news_feeds.json # User-customizable RSS feed list (persists across restarts)
│ └── services/
│ ├── data_fetcher.py # Core scheduler — fetches all data sources
│ ├── ais_stream.py # AIS WebSocket client (25K+ vessels)
@@ -358,7 +378,8 @@ live-risk-dashboard/
│ ├── kiwisdr_fetcher.py # KiwiSDR receiver scraper
│ ├── sentinel_search.py # Sentinel-2 STAC imagery search
│ ├── network_utils.py # HTTP client with curl fallback
── api_settings.py # API key management
── api_settings.py # API key management
│ └── news_feed_config.py # RSS feed config manager (add/remove/weight feeds)
├── frontend/
│ ├── src/
@@ -376,7 +397,7 @@ live-risk-dashboard/
│ │ ├── RadioInterceptPanel.tsx # Scanner-style radio panel
│ │ ├── FindLocateBar.tsx # Search/locate bar
│ │ ├── ChangelogModal.tsx # Version changelog popup
│ │ ├── SettingsPanel.tsx # App settings
│ │ ├── SettingsPanel.tsx # App settings (API Keys + News Feed manager)
│ │ ├── ScaleBar.tsx # Map scale indicator
│ │ ├── WikiImage.tsx # Wikipedia image fetcher
│ │ └── ErrorBoundary.tsx # Crash recovery wrapper
+12
View File
@@ -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
View File
@@ -1 +0,0 @@
5c3b1c768973ca54e9a1befee8dc075f38e8cc56
-1
View File
@@ -1 +0,0 @@
2b64633521ffb6f06da36e19f5c8eb86979e2187
File diff suppressed because one or more lines are too long
+72 -5
View File
@@ -1,16 +1,44 @@
from fastapi import FastAPI, Request, Response
from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager
from services.data_fetcher import start_scheduler, stop_scheduler, get_latest_data
from services.data_fetcher import start_scheduler, stop_scheduler, get_latest_data, source_timestamps
from services.ais_stream import start_ais_stream, stop_ais_stream
from services.carrier_tracker import start_carrier_tracker, stop_carrier_tracker
import uvicorn
import logging
import hashlib
import json as json_mod
import os
import socket
logging.basicConfig(level=logging.INFO)
def _build_cors_origins():
"""Build a CORS origins whitelist: localhost + LAN IPs + env overrides.
Falls back to wildcard only if auto-detection fails entirely."""
origins = [
"http://localhost:3000",
"http://127.0.0.1:3000",
"http://localhost:8000",
"http://127.0.0.1:8000",
]
# Add this machine's LAN IPs (covers common home/office setups)
try:
hostname = socket.gethostname()
for info in socket.getaddrinfo(hostname, None, socket.AF_INET):
ip = info[4][0]
if ip not in ("127.0.0.1", "0.0.0.0"):
origins.append(f"http://{ip}:3000")
origins.append(f"http://{ip}:8000")
except Exception:
pass
# Allow user override via CORS_ORIGINS env var (comma-separated)
extra = os.environ.get("CORS_ORIGINS", "")
if extra:
origins.extend([o.strip() for o in extra.split(",") if o.strip()])
return list(set(origins)) # deduplicate
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup: Start background data fetching, AIS stream, and carrier tracker
@@ -29,7 +57,7 @@ from fastapi.middleware.gzip import GZipMiddleware
app.add_middleware(GZipMiddleware, minimum_size=1000)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # Must be permissive — users access from localhost, LAN IPs, Docker, custom ports
allow_origins=_build_cors_origins(),
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
@@ -64,11 +92,12 @@ async def live_data_fast(request: Request):
"uavs": d.get("uavs", []),
"liveuamap": d.get("liveuamap", []),
"gps_jamming": d.get("gps_jamming", []),
"freshness": dict(source_timestamps),
}
# ETag includes last_updated timestamp so it changes on every data refresh,
# not just when item counts change (old bug: positions went stale)
last_updated = d.get("last_updated", "")
counts = "|".join(f"{k}:{len(v) if isinstance(v, list) else 0}" for k, v in payload.items())
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]
if request.headers.get("if-none-match") == etag:
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", []),
"airports": d.get("airports", []),
"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
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]
if request.headers.get("if-none-match") == etag:
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", [])),
"cctv": len(d.get("cctv", [])),
"news": len(d.get("news", [])),
"uavs": len(d.get("uavs", [])),
"firms_fires": len(d.get("firms_fires", [])),
"liveuamap": len(d.get("liveuamap", [])),
"gdelt": len(d.get("gdelt", [])),
},
"freshness": dict(source_timestamps),
"uptime_seconds": round(time.time() - _start_time),
}
@@ -216,6 +255,34 @@ async def api_update_key(body: ApiKeyUpdate):
return {"status": "updated", "env_key": body.env_key}
return {"status": "error", "message": "Failed to update .env file"}
# ---------------------------------------------------------------------------
# News Feed Configuration
# ---------------------------------------------------------------------------
from services.news_feed_config import get_feeds, save_feeds, reset_feeds
@app.get("/api/settings/news-feeds")
async def api_get_news_feeds():
return get_feeds()
@app.put("/api/settings/news-feeds")
async def api_save_news_feeds(request: Request):
body = await request.json()
ok = save_feeds(body)
if ok:
return {"status": "updated", "count": len(body)}
return Response(
content=json_mod.dumps({"status": "error", "message": "Validation failed (max 20 feeds, each needs name/url/weight 1-5)"}),
status_code=400,
media_type="application/json",
)
@app.post("/api/settings/news-feeds/reset")
async def api_reset_news_feeds():
ok = reset_feeds()
if ok:
return {"status": "reset", "feeds": get_feeds()}
return {"status": "error", "message": "Failed to reset feeds"}
if __name__ == "__main__":
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
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
View File
@@ -10,6 +10,7 @@ import random
import math
import json
import time
from pathlib import Path
import threading
import io
from apscheduler.schedulers.background import BackgroundScheduler
@@ -101,9 +102,22 @@ latest_data = {
"frontlines": None,
"gdelt": [],
"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
_data_lock = threading.Lock()
@@ -334,20 +348,10 @@ _KEYWORD_COORDS = {
}
def fetch_news():
feeds = {
"NPR": "https://feeds.npr.org/1004/rss.xml",
"BBC": "http://feeds.bbci.co.uk/news/world/rss.xml",
"AlJazeera": "https://www.aljazeera.com/xml/rss/all.xml",
"NYT": "https://rss.nytimes.com/services/xml/rss/nyt/World.xml",
"GDACS": "https://www.gdacs.org/xml/rss.xml",
"NHK": "https://www3.nhk.or.jp/nhkworld/rss/world.xml",
"CNA": "https://www.channelnewsasia.com/rssfeed/8395986",
"Mercopress": "https://en.mercopress.com/rss/"
}
source_weights = {
"NPR": 4, "BBC": 3, "AlJazeera": 2, "NYT": 1,
"GDACS": 5, "NHK": 3, "CNA": 3, "Mercopress": 3
}
from services.news_feed_config import get_feeds
feed_config = get_feeds()
feeds = {f["name"]: f["url"] for f in feed_config}
source_weights = {f["name"]: f["weight"] for f in feed_config}
clusters = {}
@@ -474,6 +478,7 @@ def fetch_news():
news_items.sort(key=lambda x: x['risk_score'], reverse=True)
latest_data['news'] = news_items
_mark_fresh("news")
def fetch_defense_stocks():
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}")
latest_data['stocks'] = stocks_data
_mark_fresh("stocks")
except Exception as 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}")
latest_data['oil'] = oil_data
_mark_fresh("oil")
except Exception as 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
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"
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_flights'] = _merge_category(private_ga, latest_data.get('private_flights', []))
_mark_fresh("commercial_flights", "private_jets", "private_flights")
# Always write raw flights for GPS jamming analysis (nac_p field)
if flights:
latest_data['flights'] = flights
@@ -1109,26 +1123,65 @@ def fetch_ships():
logger.info(f"Ships: {len(carriers)} carriers + {len(ais_vessels)} AIS vessels")
latest_data['ships'] = ships
_mark_fresh("ships")
def fetch_military_flights():
# True ADS-B Exchange military data requires paid API access.
# We will use adsb.lol (an open source ADSB aggregator) /v2/mil fallback.
military_flights = []
detected_uavs = []
try:
url = "https://api.adsb.lol/v2/mil"
response = fetch_with_curl(url, timeout=10)
if response.status_code == 200:
ac = response.json().get('ac', [])
for f in ac:
for f in ac:
try:
lat = f.get("lat")
lng = f.get("lon")
heading = f.get("track") or 0
if lat is None or lng is None:
continue
model = str(f.get("t", "UNKNOWN")).upper()
callsign = str(f.get("flight", "MIL-UNKN")).strip()
# Skip fixed structures (towers, oil platforms) that broadcast ADS-B
if model == "TWR":
continue
alt_raw = f.get("alt_baro")
alt_value = 0
if isinstance(alt_raw, (int, float)):
alt_value = alt_raw * 0.3048
# Ground speed from ADS-B (in knots)
gs_knots = f.get("gs")
speed_knots = round(gs_knots, 1) if isinstance(gs_knots, (int, float)) else None
# Check if this is a UAV/drone before classifying as regular military
is_uav, uav_type, wiki_url = _classify_uav(model, callsign)
if is_uav:
detected_uavs.append({
"id": f"uav-{f.get('hex', '')}",
"callsign": callsign,
"aircraft_model": f.get("t", "Unknown"),
"lat": float(lat),
"lng": float(lng),
"alt": alt_value,
"heading": heading,
"speed_knots": speed_knots,
"country": f.get("r", "Unknown"),
"uav_type": uav_type,
"wiki": wiki_url or "",
"type": "uav",
"registration": f.get("r", "N/A"),
"icao24": f.get("hex", ""),
"squawk": f.get("squawk", ""),
})
continue # Don't double-count as military flight
mil_cat = "default"
if "H" in model and any(c.isdigit() for c in model):
mil_cat = "heli"
@@ -1138,27 +1191,11 @@ def fetch_military_flights():
mil_cat = "fighter"
elif any(k in model for k in ["C17", "C5", "C130", "C30", "A400", "V22"]):
mil_cat = "cargo"
elif any(k in model for k in ["P8", "E3", "E8", "U2", "RQ", "MQ"]):
elif any(k in model for k in ["P8", "E3", "E8", "U2"]):
mil_cat = "recon"
# Military flights don't file public routes
origin_loc = None
dest_loc = None
origin_name = "UNKNOWN"
dest_name = "UNKNOWN"
alt_raw = f.get("alt_baro")
alt_value = 0
if isinstance(alt_raw, (int, float)):
alt_value = alt_raw * 0.3048
# Ground speed from ADS-B (in knots)
gs_knots = f.get("gs")
speed_knots = round(gs_knots, 1) if isinstance(gs_knots, (int, float)) else None
military_flights.append({
"callsign": str(f.get("flight", "MIL-UNKN")).strip(),
"callsign": callsign,
"country": f.get("r", "Military Asset"),
"lng": float(lng),
"lat": float(lat),
@@ -1166,10 +1203,10 @@ def fetch_military_flights():
"heading": heading,
"type": "military_flight",
"military_type": mil_cat,
"origin_loc": origin_loc,
"dest_loc": dest_loc,
"origin_name": origin_name,
"dest_name": dest_name,
"origin_loc": None,
"dest_loc": None,
"origin_name": "UNKNOWN",
"dest_name": "UNKNOWN",
"registration": f.get("r", "N/A"),
"model": f.get("t", "Unknown"),
"icao24": f.get("hex", ""),
@@ -1181,15 +1218,18 @@ def fetch_military_flights():
continue
except Exception as e:
logger.error(f"Error fetching military flights: {e}")
if not military_flights:
if not military_flights and not detected_uavs:
# API failed or rate limited — log but do NOT inject fake data
logger.warning("No military flights retrieved — keeping previous data if available")
# Preserve existing data rather than overwriting with empty
if latest_data.get('military_flights'):
return
latest_data['military_flights'] = military_flights
latest_data['uavs'] = detected_uavs
_mark_fresh("military_flights", "uavs")
logger.info(f"UAVs: {len(detected_uavs)} real drones detected via ADS-B")
# Cross-reference military flights with Plane-Alert DB
tracked_mil = []
@@ -1241,12 +1281,14 @@ def fetch_weather():
if "radar" in data and "past" in data["radar"]:
latest_time = data["radar"]["past"][-1]["time"]
latest_data["weather"] = {"time": latest_time, "host": data.get("host", "https://tilecache.rainviewer.com")}
_mark_fresh("weather")
except Exception as e:
logger.error(f"Error fetching weather: {e}")
def fetch_cctv():
try:
latest_data["cctv"] = get_all_cameras()
_mark_fresh("cctv")
except Exception as e:
logger.error(f"Error fetching cctv from DB: {e}")
latest_data["cctv"] = []
@@ -1255,10 +1297,311 @@ def fetch_kiwisdr():
try:
from services.kiwisdr_fetcher import fetch_kiwisdr_nodes
latest_data["kiwisdr"] = fetch_kiwisdr_nodes()
_mark_fresh("kiwisdr")
except Exception as e:
logger.error(f"Error fetching KiwiSDR nodes: {e}")
latest_data["kiwisdr"] = []
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():
bikes = []
try:
@@ -1316,6 +1659,8 @@ def fetch_earthquakes():
except Exception as e:
logger.error(f"Error fetching earthquakes: {e}")
latest_data["earthquakes"] = quakes
if quakes:
_mark_fresh("earthquakes")
# Satellite GP data cache — re-download from CelesTrak only every 30 minutes
_sat_gp_cache = {"data": None, "last_fetch": 0}
@@ -1615,79 +1960,78 @@ def fetch_satellites():
# Only overwrite if we got data — don't wipe the map on API timeout
if sats:
latest_data["satellites"] = sats
_mark_fresh("satellites")
elif not latest_data.get("satellites"):
latest_data["satellites"] = []
def fetch_uavs():
# Simulated high-altitude long-endurance (HALE) and MALE UAVs over high-risk regions
uav_targets = [
{
"name": "RQ-4 Global Hawk", "center": [31.5, 34.8], "radius": 0.5, "alt": 15000,
"country": "USA", "uav_type": "HALE Surveillance", "range_km": 2200,
"wiki": "https://en.wikipedia.org/wiki/Northrop_Grumman_RQ-4_Global_Hawk",
"speed_knots": 340
},
{
"name": "MQ-9 Reaper", "center": [49.0, 31.4], "radius": 1.2, "alt": 12000,
"country": "USA", "uav_type": "MALE Strike/ISR", "range_km": 1850,
"wiki": "https://en.wikipedia.org/wiki/General_Atomics_MQ-9_Reaper",
"speed_knots": 250
},
{
"name": "Bayraktar TB2", "center": [23.6, 120.9], "radius": 0.8, "alt": 8000,
"country": "Turkey", "uav_type": "MALE Strike", "range_km": 150,
"wiki": "https://en.wikipedia.org/wiki/Bayraktar_TB2",
"speed_knots": 120
},
{
"name": "MQ-1C Gray Eagle", "center": [38.0, 127.0], "radius": 0.4, "alt": 10000,
"country": "USA", "uav_type": "MALE ISR/Strike", "range_km": 400,
"wiki": "https://en.wikipedia.org/wiki/General_Atomics_MQ-1C_Gray_Eagle",
"speed_knots": 150
},
{
"name": "RQ-170 Sentinel", "center": [25.0, 55.0], "radius": 1.5, "alt": 18000,
"country": "USA", "uav_type": "Stealth ISR", "range_km": 1100,
"wiki": "https://en.wikipedia.org/wiki/Lockheed_Martin_RQ-170_Sentinel",
"speed_knots": 300
}
]
# Use the current hour and minute to create a continuous slow orbit
now = datetime.utcnow()
# 1 full orbit every 10 minutes
time_factor = ((now.minute % 10) * 60 + now.second) / 600.0
angle = time_factor * 2 * math.pi
uavs = []
for idx, t in enumerate(uav_targets):
# Offset the angle slightly so they aren't all synchronized
offset_angle = angle + (idx * math.pi / 2.5)
lat = t["center"][0] + math.sin(offset_angle) * t["radius"]
lng = t["center"][1] + math.cos(offset_angle) * t["radius"]
heading = (math.degrees(offset_angle) + 90) % 360
uavs.append({
"id": f"uav-{idx}",
"callsign": t["name"],
"aircraft_model": t["name"],
"lat": lat,
"lng": lng,
"alt": t["alt"],
"heading": heading,
"speed_knots": t["speed_knots"],
"center": t["center"],
"orbit_radius": t["radius"],
"range_km": t["range_km"],
"country": t["country"],
"uav_type": t["uav_type"],
"wiki": t["wiki"],
})
latest_data['uavs'] = uavs
# ---------------------------------------------------------------------------
# Real UAV detection from ADS-B data — filters military drone transponders
# ---------------------------------------------------------------------------
_UAV_TYPE_CODES = {"Q9", "R4", "TB2", "MALE", "HALE", "HERM", "HRON"}
_UAV_CALLSIGN_PREFIXES = ("FORTE", "GHAWK", "REAP", "BAMS", "UAV", "UAS")
_UAV_MODEL_KEYWORDS = ("RQ-", "MQ-", "RQ4", "MQ9", "MQ4", "MQ1", "REAPER", "GLOBALHAWK", "TRITON", "PREDATOR", "HERMES", "HERON", "BAYRAKTAR")
_UAV_WIKI = {
"RQ4": "https://en.wikipedia.org/wiki/Northrop_Grumman_RQ-4_Global_Hawk",
"RQ-4": "https://en.wikipedia.org/wiki/Northrop_Grumman_RQ-4_Global_Hawk",
"MQ4": "https://en.wikipedia.org/wiki/Northrop_Grumman_MQ-4C_Triton",
"MQ-4": "https://en.wikipedia.org/wiki/Northrop_Grumman_MQ-4C_Triton",
"MQ9": "https://en.wikipedia.org/wiki/General_Atomics_MQ-9_Reaper",
"MQ-9": "https://en.wikipedia.org/wiki/General_Atomics_MQ-9_Reaper",
"MQ1": "https://en.wikipedia.org/wiki/General_Atomics_MQ-1C_Gray_Eagle",
"MQ-1": "https://en.wikipedia.org/wiki/General_Atomics_MQ-1C_Gray_Eagle",
"REAPER": "https://en.wikipedia.org/wiki/General_Atomics_MQ-9_Reaper",
"GLOBALHAWK": "https://en.wikipedia.org/wiki/Northrop_Grumman_RQ-4_Global_Hawk",
"TRITON": "https://en.wikipedia.org/wiki/Northrop_Grumman_MQ-4C_Triton",
"PREDATOR": "https://en.wikipedia.org/wiki/General_Atomics_MQ-1_Predator",
"HERMES": "https://en.wikipedia.org/wiki/Elbit_Hermes_900",
"HERON": "https://en.wikipedia.org/wiki/IAI_Heron",
"BAYRAKTAR": "https://en.wikipedia.org/wiki/Bayraktar_TB2",
}
def _classify_uav(model: str, callsign: str):
"""Check if an aircraft is a UAV based on type code, callsign prefix, or model keywords.
Returns (is_uav, uav_type, wiki_url) or (False, None, None)."""
model_up = model.upper().replace(" ", "")
callsign_up = callsign.upper().strip()
# Check ICAO type codes
if model_up in _UAV_TYPE_CODES:
uav_type = "HALE Surveillance" if model_up in ("R4", "HALE") else "MALE ISR"
wiki = _UAV_WIKI.get(model_up, "")
return True, uav_type, wiki
# Check callsign prefixes (must also have a military-ish model)
for prefix in _UAV_CALLSIGN_PREFIXES:
if callsign_up.startswith(prefix):
uav_type = "HALE Surveillance" if prefix in ("FORTE", "GHAWK", "BAMS") else "MALE ISR"
wiki = _UAV_WIKI.get(prefix, "")
if prefix == "FORTE":
wiki = _UAV_WIKI["RQ4"]
elif prefix == "BAMS":
wiki = _UAV_WIKI["MQ4"]
return True, uav_type, wiki
# Check model keywords
for kw in _UAV_MODEL_KEYWORDS:
if kw in model_up:
# Determine type from keyword
if any(h in model_up for h in ("RQ4", "RQ-4", "GLOBALHAWK")):
return True, "HALE Surveillance", _UAV_WIKI.get(kw, "")
elif any(h in model_up for h in ("MQ4", "MQ-4", "TRITON")):
return True, "HALE Maritime Surveillance", _UAV_WIKI.get(kw, "")
elif any(h in model_up for h in ("MQ9", "MQ-9", "REAPER")):
return True, "MALE Strike/ISR", _UAV_WIKI.get(kw, "")
elif any(h in model_up for h in ("MQ1", "MQ-1", "PREDATOR")):
return True, "MALE ISR/Strike", _UAV_WIKI.get(kw, "")
elif "BAYRAKTAR" in model_up or "TB2" in model_up:
return True, "MALE Strike", _UAV_WIKI.get("BAYRAKTAR", "")
elif "HERMES" in model_up:
return True, "MALE ISR", _UAV_WIKI.get("HERMES", "")
elif "HERON" in model_up:
return True, "MALE ISR", _UAV_WIKI.get("HERON", "")
return True, "MALE ISR", _UAV_WIKI.get(kw, "")
return False, None, None
cached_airports = []
flight_trails = {} # {icao_hex: {points: [[lat, lng, alt, ts], ...], last_seen: ts}}
@@ -1769,10 +2113,12 @@ def fetch_geopolitics():
frontlines = fetch_ukraine_frontlines()
if frontlines:
latest_data['frontlines'] = frontlines
_mark_fresh("frontlines")
gdelt = fetch_global_military_incidents()
if gdelt is not None:
latest_data['gdelt'] = gdelt
_mark_fresh("gdelt")
except Exception as e:
logger.error(f"Error fetching geopolitics: {e}")
@@ -1783,6 +2129,7 @@ def update_liveuamap():
res = fetch_liveuamap()
if res:
latest_data['liveuamap'] = res
_mark_fresh("liveuamap")
except Exception as e:
logger.error(f"Liveuamap scraper error: {e}")
@@ -1791,9 +2138,8 @@ def update_fast_data():
logger.info("Fast-tier data update starting...")
fast_funcs = [
fetch_flights,
fetch_military_flights,
fetch_military_flights, # Also detects UAVs from ADS-B
fetch_ships,
fetch_uavs,
fetch_satellites,
]
with concurrent.futures.ThreadPoolExecutor(max_workers=len(fast_funcs)) as executor:
@@ -1815,6 +2161,10 @@ def update_slow_data():
fetch_earthquakes,
fetch_geopolitics,
fetch_kiwisdr,
fetch_space_weather,
fetch_internet_outages,
fetch_firms_fires,
fetch_datacenters,
]
with concurrent.futures.ThreadPoolExecutor(max_workers=len(slow_funcs)) as executor:
futures = [executor.submit(func) for func in slow_funcs]
@@ -1840,7 +2190,7 @@ def start_scheduler():
# Run full update once on startup
scheduler.add_job(update_all_data, 'date', run_date=datetime.now())
# Fast tier: every 60 seconds (flights, ships, military, satellites, UAVs)
# Fast tier: every 60 seconds (flights, ships, military+UAVs, satellites)
scheduler.add_job(update_fast_data, 'interval', seconds=60)
# Slow tier: every 30 minutes (news, stocks, weather, geopolitics)
+74
View File
@@ -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
+2
View File
@@ -12,6 +12,8 @@ services:
- OPENSKY_CLIENT_ID=${OPENSKY_CLIENT_ID}
- OPENSKY_CLIENT_SECRET=${OPENSKY_CLIENT_SECRET}
- LTA_ACCOUNT_KEY=${LTA_ACCOUNT_KEY}
# Override allowed CORS origins (comma-separated). Auto-detects LAN IPs if empty.
- CORS_ORIGINS=${CORS_ORIGINS:-}
volumes:
- backend_data:/app/data
restart: unless-stopped
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "frontend",
"version": "0.3.0",
"version": "0.5.0",
"private": true,
"scripts": {
"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
+19 -1
View File
@@ -146,6 +146,9 @@ export default function Dashboard() {
gibs_imagery: false,
highres_satellite: false,
kiwisdr: false,
firms: false,
internet_outages: false,
datacenters: false,
});
// NASA GIBS satellite imagery state
@@ -161,7 +164,7 @@ export default function Dashboard() {
});
const [activeStyle, setActiveStyle] = useState('DEFAULT');
const stylesList = ['DEFAULT', 'SATELLITE', 'FLIR', 'NVG', 'CRT'];
const stylesList = ['DEFAULT', 'SATELLITE'];
const cycleStyle = () => {
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-[11px] text-cyan-400 font-mono font-bold">{activeStyle}</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>
</motion.div>
</>
+24 -33
View File
@@ -2,54 +2,45 @@
import React, { useState, useEffect } from "react";
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 NEW_FEATURES = [
{
icon: <Satellite size={14} className="text-cyan-400" />,
title: "NASA GIBS Satellite Imagery",
desc: "Daily MODIS Terra true-color imagery with 30-day time slider, play/pause animation, and opacity control.",
color: "cyan",
icon: <Rss size={14} className="text-orange-400" />,
title: "Custom News Feed Manager",
desc: "Add, remove, and prioritize up to 20 RSS intelligence sources directly from the Settings panel. Assign weight levels (1-5) to control feed importance. No more editing Python files — your custom feeds persist across restarts.",
color: "orange",
},
{
icon: <Layers size={14} className="text-green-400" />,
title: "High-Res Satellite (Esri)",
desc: "Sub-meter resolution imagery — zoom into buildings and terrain. Toggle in Data Layers or cycle to SATELLITE style.",
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.",
icon: <Server size={14} className="text-purple-400" />,
title: "Global Data Center Map Layer",
desc: "2,000+ data centers plotted worldwide from a curated dataset. Click any DC for operator details — and if an internet outage is detected in the same country, the popup flags it automatically.",
color: "purple",
},
{
icon: <Layers size={14} className="text-cyan-400" />,
title: "SATELLITE Style Preset",
desc: "STYLE button now cycles: DEFAULT → SATELLITE → FLIR → NVG → CRT. SATELLITE auto-enables high-res imagery.",
icon: <Zap size={14} className="text-yellow-400" />,
title: "Imperative Map Rendering",
desc: "High-volume layers (flights, satellites, fire hotspots) now bypass React reconciliation and update the map directly via setData(). Debounced updates on dense layers. Smoother panning and zooming under load.",
color: "yellow",
},
{
icon: <Shield size={14} className="text-cyan-400" />,
title: "Enhanced Health Observability",
desc: "The /api/health endpoint now reports per-source freshness timestamps and counts for all data layers — UAVs, FIRMS fires, LiveUAMap, GDELT, and more. Better uptime monitoring for self-hosters.",
color: "cyan",
},
];
const BUG_FIXES = [
"Satellite imagery renders below all data icons — flights, ships, markers always visible on top",
"Sentinel-2 click now opens the actual high-res PNG image directly in browser",
"Light/dark theme fixed — UI stays dark, only the map basemap switches",
"Settings panel now has tabbed UI — API Keys and News Feeds on separate tabs",
"Data center coordinates fixed for 187 Southern Hemisphere entries (were mirrored north of equator)",
"Docker networking: CORS_ORIGINS env var properly passed through docker-compose",
"Start scripts warn on Python 3.13+ compatibility issues before install",
"Satellite and fire hotspot layers debounced (2s) to prevent render thrashing",
"Entries with invalid geocoded coordinates automatically filtered out",
];
export function useChangelog() {
+1 -2
View File
@@ -94,8 +94,7 @@ const LEGEND: LegendCategory[] = [
{ svg: airliner("yellow"), label: "Military — Standard" },
{ svg: plane("yellow"), label: "Fighter / Interceptor" },
{ svg: heli("yellow"), label: "Military — Helicopter" },
{ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="orange" stroke="black"><path d="M12 2L15 8H9L12 2Z" /><rect x="8" y="8" width="8" height="2" /><path d="M4 10L10 14H14L20 10V12L14 16H10L4 12V10Z" /><circle cx="12" cy="14" r="2" fill="red"/></svg>`, label: "UAV / Drone" },
{ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="orange" stroke-width="1.5" stroke-dasharray="4 2" opacity="0.6"/><circle cx="12" cy="12" r="2" fill="orange"/></svg>`, label: "UAV Operational Range (dashed circle)" },
{ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="orange" stroke="black"><path d="M12 2L15 8H9L12 2Z" /><rect x="8" y="8" width="8" height="2" /><path d="M4 10L10 14H14L20 10V12L14 16H10L4 12V10Z" /><circle cx="12" cy="14" r="2" fill="red"/></svg>`, label: "UAV / Drone (live ADS-B)" },
],
},
{
+505 -82
View File
@@ -33,6 +33,7 @@ const svgHeliWhiteAlert = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xm
const svgPlaneBlack = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="#222" stroke="#444"><path d="M21 16v-2l-8-5V3.5c0-.83-.67-1.5-1.5-1.5S10 2.67 10 3.5V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z" /></svg>`)}`;
const svgHeliBlack = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="#222" stroke="#444"><path d="M10 6L10 14L8 16L8 18L10 17L12 22L14 17L16 18L16 16L14 14L14 6C14 4 13 2 12 2C11 2 10 4 10 6Z"/><circle cx="12" cy="12" r="8" fill="none" stroke="#444" stroke-dasharray="2 2" stroke-width="1"/></svg>`)}`;
const svgDrone = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="orange" stroke="black"><path d="M12 2L15 8H9L12 2Z" /><rect x="8" y="8" width="8" height="2" /><path d="M4 10L10 14H14L20 10V12L14 16H10L4 12V10Z" /><circle cx="12" cy="14" r="2" fill="red"/></svg>`)}`;
const svgDataCenter = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#a78bfa" stroke-width="1.5"><rect x="3" y="3" width="18" height="6" rx="1" fill="#2e1065"/><rect x="3" y="11" width="18" height="6" rx="1" fill="#2e1065"/><circle cx="7" cy="6" r="1" fill="#a78bfa"/><circle cx="7" cy="14" r="1" fill="#a78bfa"/><line x1="11" y1="6" x2="17" y2="6" stroke="#a78bfa" stroke-width="1"/><line x1="11" y1="14" x2="17" y2="14" stroke="#a78bfa" stroke-width="1"/><line x1="12" y1="19" x2="12" y2="22" stroke="#a78bfa" stroke-width="1.5"/></svg>`)}`;
const svgShipGray = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="12" height="24" viewBox="0 0 24 24" fill="none"><path d="M6 20 L6 8 L12 2 L18 8 L18 20 C18 22 6 22 6 20 Z" fill="gray" stroke="#000" stroke-width="1"/><polygon points="12,6 16,16 8,16" fill="#fff" stroke="#000" stroke-width="1"/></svg>`)}`;
const svgShipRed = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="32" viewBox="0 0 24 24" fill="none"><path d="M6 22 L6 6 L12 2 L18 6 L18 22 Z" fill="#ff2222" stroke="#000" stroke-width="1"/><rect x="8" y="15" width="8" height="4" fill="#880000" stroke="#000" stroke-width="1"/><rect x="8" y="7" width="8" height="6" fill="#444" stroke="#000" stroke-width="1"/></svg>`)}`;
const svgShipYellow = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="14" height="34" viewBox="0 0 24 24" fill="none"><path d="M7 22 L7 6 L12 1 L17 6 L17 22 Z" fill="yellow" stroke="#000" stroke-width="1"/><rect x="9" y="8" width="6" height="8" fill="#555" stroke="#000" stroke-width="1"/><circle cx="12" cy="18" r="1.5" fill="#000"/><line x1="12" y1="18" x2="12" y2="24" stroke="#000" stroke-width="1.5"/></svg>`)}`;
@@ -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
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) {
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;
@@ -202,8 +229,34 @@ const MISSION_ICON_MAP: Record<string, string> = {
'commercial_imaging': 'sat-com', 'space_station': 'sat-station'
};
// Empty GeoJSON constant — avoids recreating empty objects on every render
const EMPTY_FC: GeoJSON.FeatureCollection = { type: 'FeatureCollection', features: [] };
// Imperatively push GeoJSON data to a MapLibre source, bypassing React reconciliation.
// This is critical for high-volume layers (flights, ships, satellites, fires) where
// React's prop diffing on thousands of coordinate arrays causes memory pressure.
function useImperativeSource(map: MapRef | null, sourceId: string, geojson: any, debounceMs = 0) {
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (!map) return;
const push = () => {
const src = map.getSource(sourceId) as any;
if (src && typeof src.setData === 'function') {
src.setData(geojson || EMPTY_FC);
}
};
if (debounceMs > 0) {
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(push, debounceMs);
return () => { if (timerRef.current) clearTimeout(timerRef.current); };
}
push();
}, [map, sourceId, geojson, debounceMs]);
}
const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, selectedEntity, onMouseCoords, onRightClick, regionDossier, regionDossierLoading, onViewStateChange, measureMode, onMeasureClick, measurePoints, gibsDate, gibsOpacity }: any) => {
const mapRef = useRef<MapRef>(null);
const [mapReady, setMapReady] = useState(false);
const { theme } = useTheme();
const mapThemeStyle = useMemo(() => theme === 'light' ? lightStyle : darkStyle, [theme]);
@@ -411,6 +464,86 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
};
}, [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
const onMapLoad = useCallback((e: any) => {
const map = e.target;
@@ -514,6 +647,18 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
loadImg('icon-threat', svgThreat);
loadImg('icon-liveua-yellow', svgTriangleYellow);
loadImg('icon-liveua-red', svgTriangleRed);
// FIRMS fire icons
loadImg('fire-yellow', svgFireYellow);
loadImg('fire-orange', svgFireOrange);
loadImg('fire-red', svgFireRed);
loadImg('fire-darkred', svgFireDarkRed);
loadImg('fire-cluster-sm', svgFireClusterSmall);
loadImg('fire-cluster-md', svgFireClusterMed);
loadImg('fire-cluster-lg', svgFireClusterLarge);
loadImg('fire-cluster-xl', svgFireClusterXL);
// Data center icon
loadImg('datacenter', svgDataCenter);
// Satellite mission-type icons
loadImg('sat-mil', makeSatSvg('#ff3333'));
@@ -524,6 +669,8 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
loadImg('sat-com', makeSatSvg('#44ff44'));
loadImg('sat-station', makeSatSvg('#ffdd00'));
loadImg('sat-gen', makeSatSvg('#aaaaaa'));
setMapReady(true);
}, []);
// Build a set of tracked icao24s to exclude from other flight layers
@@ -1044,7 +1191,7 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
return {
type: 'Feature',
properties: {
id: uav.id || i,
id: uav.id || `uav-${i}`,
type: 'uav',
callsign: uav.callsign,
rotation: uav.heading || 0,
@@ -1053,9 +1200,11 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
country: uav.country || '',
uav_type: uav.uav_type || '',
alt: uav.alt || 0,
range_km: uav.range_km || 0,
wiki: uav.wiki || '',
speed_knots: uav.speed_knots || 0
speed_knots: uav.speed_knots || 0,
icao24: uav.icao24 || '',
registration: uav.registration || '',
squawk: uav.squawk || '',
},
geometry: { type: 'Point', coordinates: [uav.lng, uav.lat] }
};
@@ -1063,31 +1212,7 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
};
}, [activeLayers.military, data?.uavs, inView]);
// UAV operational range circle — only for the selected UAV
const uavRangeGeoJSON = useMemo(() => {
if (!activeLayers.military || !data?.uavs || selectedEntity?.type !== 'uav') return null;
const uav = data.uavs.find((u: any) => u.id === selectedEntity.id);
if (!uav?.center || !uav?.range_km) return null;
const R = 6371;
const rangeDeg = uav.range_km / R * (180 / Math.PI);
const centerLat = uav.center[0];
const centerLng = uav.center[1];
const coords: number[][] = [];
for (let i = 0; i <= 64; i++) {
const angle = (i / 64) * 2 * Math.PI;
const lat = centerLat + rangeDeg * Math.sin(angle);
const lng = centerLng + rangeDeg * Math.cos(angle) / Math.cos(centerLat * Math.PI / 180);
coords.push([lng, lat]);
}
return {
type: 'FeatureCollection' as const,
features: [{
type: 'Feature' as const,
properties: { name: uav.callsign, range_km: uav.range_km },
geometry: { type: 'Polygon' as const, coordinates: [coords] }
}]
};
}, [activeLayers.military, data?.uavs, selectedEntity]);
// UAV range circles removed — real ADS-B drones don't have a fixed orbit center
const gdeltGeoJSON = useMemo(() => {
if (!activeLayers.global_incidents || !data?.gdelt) return null;
@@ -1145,10 +1270,28 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
earthquakesGeoJSON && 'earthquakes-layer',
satellitesGeoJSON && 'satellites-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[];
// --- Imperative source updates for high-volume layers ---
// Bypasses React reconciliation of huge GeoJSON FeatureCollections.
// The <Source data={EMPTY_FC}> mounts the source; the hook pushes real data.
const mapForHook = mapReady ? mapRef.current : null;
// Flights & UAVs: immediate (they move fast, stale = visually wrong)
useImperativeSource(mapForHook, 'commercial-flights', commFlightsGeoJSON);
useImperativeSource(mapForHook, 'private-flights', privFlightsGeoJSON);
useImperativeSource(mapForHook, 'private-jets', privJetsGeoJSON);
useImperativeSource(mapForHook, 'military-flights', milFlightsGeoJSON);
useImperativeSource(mapForHook, 'tracked-flights', trackedFlightsGeoJSON);
useImperativeSource(mapForHook, 'uavs', uavGeoJSON);
// Satellites & fires: 2s debounce (slow-changing, high feature count)
useImperativeSource(mapForHook, 'satellites', satellitesGeoJSON, 2000);
useImperativeSource(mapForHook, 'firms-fires', firmsGeoJSON, 2000);
const handleMouseMove = useCallback((evt: any) => {
if (onMouseCoords) onMouseCoords({ lat: evt.lngLat.lat, lng: evt.lngLat.lng });
}, [onMouseCoords]);
@@ -1249,6 +1392,51 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
</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 */}
{activeLayers.day_night && nightGeoJSON && (
<Source id="night-overlay" type="geojson" data={nightGeoJSON as any}>
@@ -1263,8 +1451,8 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
</Source>
)}
{commFlightsGeoJSON && (
<Source id="commercial-flights" type="geojson" data={commFlightsGeoJSON as any}>
{/* commercial/private/military flights: data pushed imperatively */}
<Source id="commercial-flights" type="geojson" data={EMPTY_FC as any}>
<Layer
id="commercial-flights-layer"
type="symbol"
@@ -1278,10 +1466,8 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
paint={{ 'icon-opacity': opacityFilter }}
/>
</Source>
)}
{privFlightsGeoJSON && (
<Source id="private-flights" type="geojson" data={privFlightsGeoJSON as any}>
<Source id="private-flights" type="geojson" data={EMPTY_FC as any}>
<Layer
id="private-flights-layer"
type="symbol"
@@ -1295,10 +1481,8 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
paint={{ 'icon-opacity': opacityFilter }}
/>
</Source>
)}
{privJetsGeoJSON && (
<Source id="private-jets" type="geojson" data={privJetsGeoJSON as any}>
<Source id="private-jets" type="geojson" data={EMPTY_FC as any}>
<Layer
id="private-jets-layer"
type="symbol"
@@ -1312,10 +1496,8 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
paint={{ 'icon-opacity': opacityFilter }}
/>
</Source>
)}
{milFlightsGeoJSON && (
<Source id="military-flights" type="geojson" data={milFlightsGeoJSON as any}>
<Source id="military-flights" type="geojson" data={EMPTY_FC as any}>
<Layer
id="military-flights-layer"
type="symbol"
@@ -1329,7 +1511,6 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
paint={{ 'icon-opacity': opacityFilter }}
/>
</Source>
)}
{shipsGeoJSON && (
<Source
@@ -1445,8 +1626,8 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
</Source>
)}
{trackedFlightsGeoJSON && (
<Source id="tracked-flights" type="geojson" data={trackedFlightsGeoJSON as any}>
{/* tracked-flights & UAVs: data pushed imperatively */}
<Source id="tracked-flights" type="geojson" data={EMPTY_FC as any}>
<Layer
id="tracked-flights-layer"
type="symbol"
@@ -1460,10 +1641,8 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
paint={{ 'icon-opacity': opacityFilter }}
/>
</Source>
)}
{uavGeoJSON && (
<Source id="uavs" type="geojson" data={uavGeoJSON as any}>
<Source id="uavs" type="geojson" data={EMPTY_FC as any}>
<Layer
id="uav-layer"
type="symbol"
@@ -1477,31 +1656,8 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
paint={{ 'icon-opacity': opacityFilter }}
/>
</Source>
)}
{/* UAV Operational Range Circles */}
{uavRangeGeoJSON && (
<Source id="uav-ranges" type="geojson" data={uavRangeGeoJSON as any}>
<Layer
id="uav-range-fill"
type="fill"
paint={{
'fill-color': '#ff4444',
'fill-opacity': 0.04
}}
/>
<Layer
id="uav-range-border"
type="line"
paint={{
'line-color': '#ff4444',
'line-width': 1,
'line-opacity': 0.3,
'line-dasharray': [4, 4]
}}
/>
</Source>
)}
{/* UAV range circles removed — real ADS-B data has no fixed orbit */}
{gdeltGeoJSON && (
<Source id="gdelt" type="geojson" data={gdeltGeoJSON as any}>
@@ -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) => {
if (s.type !== 'carrier' || s.lat == null || s.lng == null) return null;
if (!inView(s.lat, s.lng)) return null;
const [iLng, iLat] = interpShip(s);
return (
<Marker key={`carrier-label-${i}`} longitude={iLng} latitude={iLat} anchor="top" offset={[0, 12]} style={{ zIndex: 2 }}>
<div style={{ color: '#ffaa00', fontSize: '11px', fontFamily: 'monospace', fontWeight: 'bold', textShadow: '0 0 3px #000, 0 0 3px #000, 1px 1px 2px #000', whiteSpace: 'nowrap', pointerEvents: 'none' }}>
[[{s.name}]]
<div style={{ fontFamily: 'monospace', textShadow: '0 0 3px #000, 0 0 3px #000, 1px 1px 2px #000', whiteSpace: 'nowrap', pointerEvents: 'none', textAlign: 'center' }}>
<div style={{ color: '#ffaa00', fontSize: '11px', fontWeight: 'bold' }}>
[[{s.name}]]
</div>
{s.estimated && (
<div style={{ color: '#ff6644', fontSize: '8px', letterSpacing: '1.5px' }}>
EST. POSITION OSINT
</div>
)}
</div>
</Marker>
);
@@ -1906,9 +2069,128 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
</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 */}
{satellitesGeoJSON && (
<Source id="satellites" type="geojson" data={satellitesGeoJSON as any}>
{/* satellites: data pushed imperatively */}
<Source id="satellites" type="geojson" data={EMPTY_FC as any}>
<Layer
id="satellites-layer"
type="symbol"
@@ -1925,7 +2207,6 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
}}
/>
</Source>
)}
{/* Satellite click popup */}
{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' && (() => {
const uav = data?.uavs?.find((u: any) => u.id === selectedEntity.id);
if (!uav) return null;
@@ -2001,16 +2282,29 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
fontFamily: 'monospace', fontSize: 11, minWidth: 220, maxWidth: 320
}}>
<div style={{ color: '#ff4444', fontWeight: 700, fontSize: 13, marginBottom: 6, letterSpacing: 1 }}>
{uav.callsign}
{uav.callsign}
</div>
<div style={{ color: '#ff8844', fontSize: 9, marginBottom: 6, letterSpacing: 1.5, textTransform: 'uppercase' as const }}>
LIVE ADS-B TRANSPONDER
</div>
{uav.aircraft_model && (
<div style={{ marginBottom: 4 }}>
Model: <span style={{ color: '#fff' }}>{uav.aircraft_model}</span>
</div>
)}
{uav.uav_type && (
<div style={{ marginBottom: 4 }}>
Type: <span style={{ color: '#ffcc00' }}>{uav.uav_type}</span>
Classification: <span style={{ color: '#ffcc00' }}>{uav.uav_type}</span>
</div>
)}
{uav.country && (
<div style={{ marginBottom: 4 }}>
Country: <span style={{ color: '#fff' }}>{uav.country}</span>
Registration: <span style={{ color: '#fff' }}>{uav.country}</span>
</div>
)}
{uav.icao24 && (
<div style={{ marginBottom: 4 }}>
ICAO: <span style={{ color: '#888' }}>{uav.icao24}</span>
</div>
)}
<div style={{ marginBottom: 4 }}>
@@ -2021,9 +2315,9 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
Speed: <span style={{ color: '#00e5ff' }}>{uav.speed_knots} kn</span>
</div>
)}
{uav.range_km > 0 && (
{uav.squawk && (
<div style={{ marginBottom: 4 }}>
Operational Range: <span style={{ color: '#ff8844' }}>{uav.range_km?.toLocaleString()} km</span>
Squawk: <span style={{ color: '#888' }}>{uav.squawk}</span>
</div>
)}
{uav.wiki && (
@@ -2035,6 +2329,135 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
</Popup>
);
})()}
{/* Ship / carrier click popup */}
{selectedEntity?.type === 'ship' && (() => {
const ship = data?.ships?.[selectedEntity.id as number];
if (!ship) return null;
const [iLng, iLat] = interpShip(ship);
return (
<Popup
longitude={iLng} latitude={iLat}
closeButton={false} closeOnClick={false}
onClose={() => onEntityClick?.(null)}
anchor="bottom" offset={12}
>
<div style={{
background: 'rgba(10,14,26,0.95)', border: `1px solid ${ship.type === 'carrier' ? 'rgba(255,170,0,0.5)' : 'rgba(59,130,246,0.4)'}`,
borderRadius: 6, padding: '10px 14px', color: '#e0e6f0',
fontFamily: 'monospace', fontSize: 11, minWidth: 220, maxWidth: 320
}}>
<div className="flex justify-between items-start mb-1">
<div style={{ color: ship.type === 'carrier' ? '#ffaa00' : '#3b82f6', fontWeight: 700, fontSize: 13, letterSpacing: 1 }}>
{ship.name || 'UNKNOWN VESSEL'}
</div>
<button onClick={() => onEntityClick?.(null)} className="text-[var(--text-secondary)] hover:text-[var(--text-primary)] ml-2"></button>
</div>
{ship.estimated && (
<div style={{ color: '#ff6644', fontSize: 9, marginBottom: 6, letterSpacing: 1.5, textTransform: 'uppercase' as const, borderBottom: '1px solid rgba(255,102,68,0.3)', paddingBottom: 4 }}>
ESTIMATED POSITION {ship.source || 'OSINT DERIVED'}
</div>
)}
{ship.type && (
<div style={{ marginBottom: 4 }}>
Type: <span style={{ color: '#fff', textTransform: 'capitalize' as const }}>{ship.type.replace('_', ' ')}</span>
</div>
)}
{ship.mmsi && (
<div style={{ marginBottom: 4 }}>
MMSI: <span style={{ color: '#888' }}>{ship.mmsi}</span>
</div>
)}
{ship.imo && (
<div style={{ marginBottom: 4 }}>
IMO: <span style={{ color: '#888' }}>{ship.imo}</span>
</div>
)}
{ship.callsign && (
<div style={{ marginBottom: 4 }}>
Callsign: <span style={{ color: '#00e5ff' }}>{ship.callsign}</span>
</div>
)}
{ship.country && (
<div style={{ marginBottom: 4 }}>
Flag: <span style={{ color: '#fff' }}>{ship.country}</span>
</div>
)}
{ship.destination && (
<div style={{ marginBottom: 4 }}>
Destination: <span style={{ color: '#44ff88' }}>{ship.destination}</span>
</div>
)}
{typeof ship.sog === 'number' && ship.sog > 0 && (
<div style={{ marginBottom: 4 }}>
Speed: <span style={{ color: '#00e5ff' }}>{ship.sog.toFixed(1)} kn</span>
</div>
)}
{ship.heading != null && (
<div style={{ marginBottom: 4 }}>
Heading: <span style={{ color: '#888' }}>{Math.round(ship.heading)}°</span>
</div>
)}
{ship.last_osint_update && (
<div style={{ marginBottom: 4 }}>
Last OSINT Update: <span style={{ color: '#888' }}>{new Date(ship.last_osint_update).toLocaleDateString()}</span>
</div>
)}
</div>
</Popup>
);
})()}
{/* Data Center click popup */}
{selectedEntity?.type === 'datacenter' && (() => {
const dc = data?.datacenters?.find((_: any, i: number) => `dc-${i}` === selectedEntity.id);
if (!dc) return null;
// Check if any internet outage is in the same country
const outagesInCountry = (data?.internet_outages || []).filter((o: any) =>
o.country_name && dc.country && o.country_name.toLowerCase() === dc.country.toLowerCase()
);
return (
<Popup
longitude={dc.lng}
latitude={dc.lat}
closeButton={false}
closeOnClick={false}
onClose={() => onEntityClick?.(null)}
className="threat-popup"
maxWidth="280px"
>
<div style={{ background: '#1a1035', padding: '10px 14px', borderRadius: 8, border: '1px solid rgba(167,139,250,0.4)', fontFamily: 'monospace', fontSize: 11, color: '#e9d5ff', minWidth: 200 }}>
<div style={{ fontWeight: 'bold', fontSize: 13, color: '#a78bfa', marginBottom: 6, borderBottom: '1px solid rgba(167,139,250,0.2)', paddingBottom: 4 }}>
{dc.name}
</div>
{dc.company && (
<div style={{ marginBottom: 4 }}>
Operator: <span style={{ color: '#c4b5fd' }}>{dc.company}</span>
</div>
)}
{dc.city && (
<div style={{ marginBottom: 4 }}>
Location: <span style={{ color: '#fff' }}>{dc.city}{dc.country ? `, ${dc.country}` : ''}</span>
</div>
)}
{!dc.city && dc.country && (
<div style={{ marginBottom: 4 }}>
Country: <span style={{ color: '#fff' }}>{dc.country}</span>
</div>
)}
{outagesInCountry.length > 0 && (
<div style={{ marginTop: 6, padding: '4px 8px', background: 'rgba(255,0,0,0.15)', border: '1px solid rgba(255,80,80,0.4)', borderRadius: 4, fontSize: 10, color: '#ff6b6b' }}>
OUTAGE IN REGION {outagesInCountry.map((o: any) => `${o.region_name} (${o.severity}%)`).join(', ')}
</div>
)}
<div style={{ marginTop: 6, fontSize: 9, color: '#7c3aed', letterSpacing: '0.05em' }}>
DATA CENTER
</div>
</div>
</Popup>
);
})()}
{
selectedEntity?.type === 'gdelt' && data?.gdelt?.[selectedEntity.id as number] && (
<Popup
+335 -166
View File
@@ -3,7 +3,7 @@
import { API_BASE } from "@/lib/api";
import React, { useState, useEffect, useCallback } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Settings, ExternalLink, Key, Shield, X, Save, ChevronDown, ChevronUp } from "lucide-react";
import { Settings, ExternalLink, Key, Shield, X, Save, ChevronDown, ChevronUp, Rss, Plus, Trash2, RotateCcw } from "lucide-react";
interface ApiEntry {
id: string;
@@ -18,6 +18,22 @@ interface ApiEntry {
is_set: boolean;
}
interface FeedEntry {
name: string;
url: string;
weight: number;
}
const WEIGHT_LABELS: Record<number, string> = { 1: "LOW", 2: "MED", 3: "STD", 4: "HIGH", 5: "CRIT" };
const WEIGHT_COLORS: Record<number, string> = {
1: "text-gray-400 border-gray-600",
2: "text-blue-400 border-blue-600",
3: "text-cyan-400 border-cyan-600",
4: "text-orange-400 border-orange-600",
5: "text-red-400 border-red-600",
};
const MAX_FEEDS = 20;
// Category colors for the tactical UI
const CATEGORY_COLORS: Record<string, string> = {
Aviation: "text-cyan-400 border-cyan-500/30 bg-cyan-950/20",
@@ -31,33 +47,54 @@ const CATEGORY_COLORS: Record<string, string> = {
SIGINT: "text-rose-400 border-rose-500/30 bg-rose-950/20",
};
type Tab = "api-keys" | "news-feeds";
const SettingsPanel = React.memo(function SettingsPanel({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) {
const [activeTab, setActiveTab] = useState<Tab>("api-keys");
// --- API Keys state ---
const [apis, setApis] = useState<ApiEntry[]>([]);
const [editingId, setEditingId] = useState<string | null>(null);
const [editValue, setEditValue] = useState("");
const [saving, setSaving] = useState(false);
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set(["Aviation", "Maritime"]));
// --- News Feeds state ---
const [feeds, setFeeds] = useState<FeedEntry[]>([]);
const [feedsDirty, setFeedsDirty] = useState(false);
const [feedSaving, setFeedSaving] = useState(false);
const [feedMsg, setFeedMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null);
const fetchKeys = useCallback(async () => {
try {
const res = await fetch(`${API_BASE}/api/settings/api-keys`);
if (res.ok) {
const data = await res.json();
setApis(data);
}
if (res.ok) setApis(await res.json());
} catch (e) {
console.error("Failed to fetch API keys", e);
}
}, []);
useEffect(() => {
if (isOpen) fetchKeys();
}, [isOpen, fetchKeys]);
const fetchFeeds = useCallback(async () => {
try {
const res = await fetch(`${API_BASE}/api/settings/news-feeds`);
if (res.ok) {
setFeeds(await res.json());
setFeedsDirty(false);
}
} catch (e) {
console.error("Failed to fetch news feeds", e);
}
}, []);
const startEditing = (api: ApiEntry) => {
setEditingId(api.id);
setEditValue("");
};
useEffect(() => {
if (isOpen) {
fetchKeys();
fetchFeeds();
}
}, [isOpen, fetchKeys, fetchFeeds]);
// API Keys handlers
const startEditing = (api: ApiEntry) => { setEditingId(api.id); setEditValue(""); };
const saveKey = async (api: ApiEntry) => {
if (!api.env_key) return;
@@ -68,33 +105,81 @@ const SettingsPanel = React.memo(function SettingsPanel({ isOpen, onClose }: { i
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ env_key: api.env_key, value: editValue }),
});
if (res.ok) {
setEditingId(null);
fetchKeys(); // Refresh to get new obfuscated value
}
if (res.ok) { setEditingId(null); fetchKeys(); }
} catch (e) {
console.error("Failed to save API key", e);
} finally {
setSaving(false);
}
} finally { setSaving(false); }
};
const toggleCategory = (cat: string) => {
setExpandedCategories(prev => {
const next = new Set(prev);
if (next.has(cat)) next.delete(cat);
else next.add(cat);
if (next.has(cat)) next.delete(cat); else next.add(cat);
return next;
});
};
// Group APIs by category
const grouped = apis.reduce<Record<string, ApiEntry[]>>((acc, api) => {
if (!acc[api.category]) acc[api.category] = [];
acc[api.category].push(api);
return acc;
}, {});
// News Feeds handlers
const updateFeed = (idx: number, field: keyof FeedEntry, value: string | number) => {
setFeeds(prev => prev.map((f, i) => i === idx ? { ...f, [field]: value } : f));
setFeedsDirty(true);
setFeedMsg(null);
};
const removeFeed = (idx: number) => {
setFeeds(prev => prev.filter((_, i) => i !== idx));
setFeedsDirty(true);
setFeedMsg(null);
};
const addFeed = () => {
if (feeds.length >= MAX_FEEDS) return;
setFeeds(prev => [...prev, { name: "", url: "", weight: 3 }]);
setFeedsDirty(true);
setFeedMsg(null);
};
const saveFeeds = async () => {
setFeedSaving(true);
setFeedMsg(null);
try {
const res = await fetch(`${API_BASE}/api/settings/news-feeds`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(feeds),
});
if (res.ok) {
setFeedsDirty(false);
setFeedMsg({ type: "ok", text: "Feeds saved. Changes take effect on next news refresh (~30min) or manual /api/refresh." });
} else {
const d = await res.json().catch(() => ({}));
setFeedMsg({ type: "err", text: d.message || "Save failed" });
}
} catch (e) {
setFeedMsg({ type: "err", text: "Network error" });
} finally { setFeedSaving(false); }
};
const resetFeeds = async () => {
try {
const res = await fetch(`${API_BASE}/api/settings/news-feeds/reset`, { method: "POST" });
if (res.ok) {
const d = await res.json();
setFeeds(d.feeds || []);
setFeedsDirty(false);
setFeedMsg({ type: "ok", text: "Reset to defaults" });
}
} catch (e) {
setFeedMsg({ type: "err", text: "Reset failed" });
}
};
return (
<AnimatePresence>
{isOpen && (
@@ -124,7 +209,7 @@ const SettingsPanel = React.memo(function SettingsPanel({ isOpen, onClose }: { i
</div>
<div>
<h2 className="text-sm font-bold tracking-[0.2em] text-[var(--text-primary)] font-mono">SYSTEM CONFIG</h2>
<span className="text-[9px] text-[var(--text-muted)] font-mono tracking-widest">API KEY REGISTRY</span>
<span className="text-[9px] text-[var(--text-muted)] font-mono tracking-widest">SETTINGS &amp; DATA SOURCES</span>
</div>
</div>
<button
@@ -135,153 +220,237 @@ const SettingsPanel = React.memo(function SettingsPanel({ isOpen, onClose }: { i
</button>
</div>
{/* Info Banner */}
<div className="mx-4 mt-4 p-3 rounded-lg border border-cyan-900/30 bg-cyan-950/10">
<div className="flex items-start gap-2">
<Shield size={12} className="text-cyan-500 mt-0.5 flex-shrink-0" />
<p className="text-[10px] text-[var(--text-secondary)] font-mono leading-relaxed">
API keys are stored locally in the backend <span className="text-cyan-400">.env</span> file. Keys marked with <Key size={8} className="inline text-yellow-500" /> are required for full functionality. Public APIs need no key.
</p>
</div>
{/* Tab Bar */}
<div className="flex border-b border-[var(--border-primary)]/60">
<button
onClick={() => setActiveTab("api-keys")}
className={`flex-1 px-4 py-2.5 text-[10px] font-mono tracking-widest font-bold transition-colors flex items-center justify-center gap-1.5 ${activeTab === "api-keys" ? "text-cyan-400 border-b-2 border-cyan-500 bg-cyan-950/10" : "text-[var(--text-muted)] hover:text-[var(--text-secondary)]"}`}
>
<Key size={10} />
API KEYS
</button>
<button
onClick={() => setActiveTab("news-feeds")}
className={`flex-1 px-4 py-2.5 text-[10px] font-mono tracking-widest font-bold transition-colors flex items-center justify-center gap-1.5 ${activeTab === "news-feeds" ? "text-orange-400 border-b-2 border-orange-500 bg-orange-950/10" : "text-[var(--text-muted)] hover:text-[var(--text-secondary)]"}`}
>
<Rss size={10} />
NEWS FEEDS
{feedsDirty && <span className="w-1.5 h-1.5 rounded-full bg-orange-400 animate-pulse" />}
</button>
</div>
{/* API List */}
<div className="flex-1 overflow-y-auto styled-scrollbar p-4 space-y-3">
{Object.entries(grouped).map(([category, categoryApis]) => {
const colorClass = CATEGORY_COLORS[category] || "text-gray-400 border-gray-700 bg-gray-900/20";
const isExpanded = expandedCategories.has(category);
return (
<div key={category} className="rounded-lg border border-[var(--border-primary)]/60 overflow-hidden">
{/* Category Header */}
<button
onClick={() => toggleCategory(category)}
className="w-full flex items-center justify-between px-4 py-2.5 bg-[var(--bg-secondary)]/50 hover:bg-[var(--bg-secondary)]/80 transition-colors"
>
<div className="flex items-center gap-2">
<span className={`text-[9px] font-mono tracking-widest font-bold px-2 py-0.5 rounded border ${colorClass}`}>
{category.toUpperCase()}
</span>
<span className="text-[10px] text-[var(--text-muted)] font-mono">
{categoryApis.length} {categoryApis.length === 1 ? 'service' : 'services'}
</span>
</div>
{isExpanded ? <ChevronUp size={12} className="text-[var(--text-muted)]" /> : <ChevronDown size={12} className="text-[var(--text-muted)]" />}
</button>
{/* APIs in Category */}
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
>
{categoryApis.map((api) => (
<div key={api.id} className="border-t border-[var(--border-primary)]/40 px-4 py-3 hover:bg-[var(--bg-secondary)]/30 transition-colors">
{/* API Name + Status */}
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
{api.required && <Key size={10} className="text-yellow-500" />}
<span className="text-xs font-mono text-[var(--text-primary)] font-medium">{api.name}</span>
</div>
<div className="flex items-center gap-1.5">
{api.has_key ? (
api.is_set ? (
<span className="text-[8px] font-mono px-1.5 py-0.5 rounded border border-green-500/30 text-green-400 bg-green-950/20">
KEY SET
</span>
) : (
<span className="text-[8px] font-mono px-1.5 py-0.5 rounded border border-yellow-500/30 text-yellow-400 bg-yellow-950/20">
MISSING
</span>
)
) : (
<span className="text-[8px] font-mono px-1.5 py-0.5 rounded border border-[var(--border-primary)] text-[var(--text-muted)]">
PUBLIC
</span>
)}
{api.url && (
<a
href={api.url}
target="_blank"
rel="noopener noreferrer"
className="text-[var(--text-muted)] hover:text-cyan-400 transition-colors"
onClick={(e) => e.stopPropagation()}
>
<ExternalLink size={10} />
</a>
)}
</div>
</div>
{/* Description */}
<p className="text-[10px] text-[var(--text-muted)] font-mono leading-relaxed mb-2">
{api.description}
</p>
{/* Key Field (only for APIs with keys) */}
{api.has_key && (
<div className="mt-2">
{editingId === api.id ? (
/* Edit Mode */
<div className="flex gap-2">
<input
type="text"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
className="flex-1 bg-black/60 border border-cyan-900/50 rounded px-2 py-1.5 text-[11px] font-mono text-cyan-300 outline-none focus:border-cyan-500/70 transition-colors"
placeholder="Enter API key..."
autoFocus
/>
<button
onClick={() => saveKey(api)}
disabled={saving}
className="px-3 py-1.5 rounded bg-cyan-500/20 border border-cyan-500/40 text-cyan-400 hover:bg-cyan-500/30 transition-colors text-[10px] font-mono flex items-center gap-1"
>
<Save size={10} />
{saving ? "..." : "SAVE"}
</button>
<button
onClick={() => setEditingId(null)}
className="px-2 py-1.5 rounded border border-[var(--border-primary)] text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:border-[var(--border-secondary)] transition-colors text-[10px] font-mono"
>
ESC
</button>
</div>
) : (
/* Display Mode */
<div className="flex items-center gap-1.5">
<div
className="flex-1 bg-[var(--bg-primary)]/40 border border-[var(--border-primary)] rounded px-2.5 py-1.5 font-mono text-[11px] cursor-pointer hover:border-[var(--border-secondary)] transition-colors select-none"
onClick={() => startEditing(api)}
>
<span className="text-[var(--text-muted)] tracking-wider">
{api.is_set ? api.value_obfuscated : "Click to set key..."}
</span>
</div>
</div>
)}
</div>
)}
</div>
))}
</motion.div>
)}
</AnimatePresence>
{/* ==================== API KEYS TAB ==================== */}
{activeTab === "api-keys" && (
<>
{/* Info Banner */}
<div className="mx-4 mt-4 p-3 rounded-lg border border-cyan-900/30 bg-cyan-950/10">
<div className="flex items-start gap-2">
<Shield size={12} className="text-cyan-500 mt-0.5 flex-shrink-0" />
<p className="text-[10px] text-[var(--text-secondary)] font-mono leading-relaxed">
API keys are stored locally in the backend <span className="text-cyan-400">.env</span> file. Keys marked with <Key size={8} className="inline text-yellow-500" /> are required for full functionality. Public APIs need no key.
</p>
</div>
);
})}
</div>
</div>
{/* Footer */}
<div className="p-4 border-t border-[var(--border-primary)]/80">
<div className="flex items-center justify-between text-[9px] text-[var(--text-muted)] font-mono">
<span>{apis.length} REGISTERED APIs</span>
<span>{apis.filter(a => a.has_key).length} KEYS CONFIGURED</span>
</div>
</div>
{/* API List */}
<div className="flex-1 overflow-y-auto styled-scrollbar p-4 space-y-3">
{Object.entries(grouped).map(([category, categoryApis]) => {
const colorClass = CATEGORY_COLORS[category] || "text-gray-400 border-gray-700 bg-gray-900/20";
const isExpanded = expandedCategories.has(category);
return (
<div key={category} className="rounded-lg border border-[var(--border-primary)]/60 overflow-hidden">
<button
onClick={() => toggleCategory(category)}
className="w-full flex items-center justify-between px-4 py-2.5 bg-[var(--bg-secondary)]/50 hover:bg-[var(--bg-secondary)]/80 transition-colors"
>
<div className="flex items-center gap-2">
<span className={`text-[9px] font-mono tracking-widest font-bold px-2 py-0.5 rounded border ${colorClass}`}>
{category.toUpperCase()}
</span>
<span className="text-[10px] text-[var(--text-muted)] font-mono">
{categoryApis.length} {categoryApis.length === 1 ? 'service' : 'services'}
</span>
</div>
{isExpanded ? <ChevronUp size={12} className="text-[var(--text-muted)]" /> : <ChevronDown size={12} className="text-[var(--text-muted)]" />}
</button>
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
>
{categoryApis.map((api) => (
<div key={api.id} className="border-t border-[var(--border-primary)]/40 px-4 py-3 hover:bg-[var(--bg-secondary)]/30 transition-colors">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
{api.required && <Key size={10} className="text-yellow-500" />}
<span className="text-xs font-mono text-[var(--text-primary)] font-medium">{api.name}</span>
</div>
<div className="flex items-center gap-1.5">
{api.has_key ? (
api.is_set ? (
<span className="text-[8px] font-mono px-1.5 py-0.5 rounded border border-green-500/30 text-green-400 bg-green-950/20">KEY SET</span>
) : (
<span className="text-[8px] font-mono px-1.5 py-0.5 rounded border border-yellow-500/30 text-yellow-400 bg-yellow-950/20">MISSING</span>
)
) : (
<span className="text-[8px] font-mono px-1.5 py-0.5 rounded border border-[var(--border-primary)] text-[var(--text-muted)]">PUBLIC</span>
)}
{api.url && (
<a href={api.url} target="_blank" rel="noopener noreferrer" className="text-[var(--text-muted)] hover:text-cyan-400 transition-colors" onClick={(e) => e.stopPropagation()}>
<ExternalLink size={10} />
</a>
)}
</div>
</div>
<p className="text-[10px] text-[var(--text-muted)] font-mono leading-relaxed mb-2">{api.description}</p>
{api.has_key && (
<div className="mt-2">
{editingId === api.id ? (
<div className="flex gap-2">
<input type="text" value={editValue} onChange={(e) => setEditValue(e.target.value)} className="flex-1 bg-black/60 border border-cyan-900/50 rounded px-2 py-1.5 text-[11px] font-mono text-cyan-300 outline-none focus:border-cyan-500/70 transition-colors" placeholder="Enter API key..." autoFocus />
<button onClick={() => saveKey(api)} disabled={saving} className="px-3 py-1.5 rounded bg-cyan-500/20 border border-cyan-500/40 text-cyan-400 hover:bg-cyan-500/30 transition-colors text-[10px] font-mono flex items-center gap-1">
<Save size={10} />{saving ? "..." : "SAVE"}
</button>
<button onClick={() => setEditingId(null)} className="px-2 py-1.5 rounded border border-[var(--border-primary)] text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:border-[var(--border-secondary)] transition-colors text-[10px] font-mono">ESC</button>
</div>
) : (
<div className="flex items-center gap-1.5">
<div className="flex-1 bg-[var(--bg-primary)]/40 border border-[var(--border-primary)] rounded px-2.5 py-1.5 font-mono text-[11px] cursor-pointer hover:border-[var(--border-secondary)] transition-colors select-none" onClick={() => startEditing(api)}>
<span className="text-[var(--text-muted)] tracking-wider">{api.is_set ? api.value_obfuscated : "Click to set key..."}</span>
</div>
</div>
)}
</div>
)}
</div>
))}
</motion.div>
)}
</AnimatePresence>
</div>
);
})}
</div>
{/* Footer */}
<div className="p-4 border-t border-[var(--border-primary)]/80">
<div className="flex items-center justify-between text-[9px] text-[var(--text-muted)] font-mono">
<span>{apis.length} REGISTERED APIs</span>
<span>{apis.filter(a => a.has_key).length} KEYS CONFIGURED</span>
</div>
</div>
</>
)}
{/* ==================== NEWS FEEDS TAB ==================== */}
{activeTab === "news-feeds" && (
<>
{/* Info Banner */}
<div className="mx-4 mt-4 p-3 rounded-lg border border-orange-900/30 bg-orange-950/10">
<div className="flex items-start gap-2">
<Rss size={12} className="text-orange-500 mt-0.5 flex-shrink-0" />
<p className="text-[10px] text-[var(--text-secondary)] font-mono leading-relaxed">
Configure RSS/Atom feeds for the Threat Intel news panel. Each feed is scored by keyword heuristics and weighted by the priority you set. Up to <span className="text-orange-400">{MAX_FEEDS}</span> sources.
</p>
</div>
</div>
{/* Feed List */}
<div className="flex-1 overflow-y-auto styled-scrollbar p-4 space-y-2">
{feeds.map((feed, idx) => (
<div key={idx} className="rounded-lg border border-[var(--border-primary)]/60 p-3 hover:border-[var(--border-secondary)]/60 transition-colors group">
{/* Row 1: Name + Weight + Delete */}
<div className="flex items-center gap-2 mb-2">
<input
type="text"
value={feed.name}
onChange={(e) => updateFeed(idx, "name", e.target.value)}
className="flex-1 bg-transparent border-b border-[var(--border-primary)] text-xs font-mono text-[var(--text-primary)] outline-none focus:border-cyan-500/70 transition-colors px-1 py-0.5"
placeholder="Source name..."
/>
{/* Weight selector */}
<div className="flex items-center gap-1">
{[1, 2, 3, 4, 5].map(w => (
<button
key={w}
onClick={() => updateFeed(idx, "weight", w)}
className={`w-5 h-5 rounded text-[8px] font-mono font-bold border transition-all ${feed.weight === w ? WEIGHT_COLORS[w] + " bg-black/40" : "border-[var(--border-primary)]/40 text-[var(--text-muted)]/50 hover:border-[var(--border-secondary)]"}`}
title={WEIGHT_LABELS[w]}
>
{w}
</button>
))}
<span className={`text-[8px] font-mono ml-1 w-7 ${WEIGHT_COLORS[feed.weight]?.split(" ")[0] || "text-gray-400"}`}>
{WEIGHT_LABELS[feed.weight] || "STD"}
</span>
</div>
<button
onClick={() => removeFeed(idx)}
className="w-6 h-6 rounded flex items-center justify-center text-[var(--text-muted)] hover:text-red-400 hover:bg-red-950/20 transition-all opacity-0 group-hover:opacity-100"
title="Remove feed"
>
<Trash2 size={11} />
</button>
</div>
{/* Row 2: URL */}
<input
type="text"
value={feed.url}
onChange={(e) => updateFeed(idx, "url", e.target.value)}
className="w-full bg-black/30 border border-[var(--border-primary)]/40 rounded px-2 py-1 text-[10px] font-mono text-[var(--text-muted)] outline-none focus:border-cyan-500/50 focus:text-cyan-300 transition-colors"
placeholder="https://example.com/rss.xml"
/>
</div>
))}
{/* Add Feed Button */}
<button
onClick={addFeed}
disabled={feeds.length >= MAX_FEEDS}
className="w-full py-2.5 rounded-lg border border-dashed border-[var(--border-primary)]/60 text-[var(--text-muted)] hover:border-orange-500/50 hover:text-orange-400 hover:bg-orange-950/10 transition-all text-[10px] font-mono flex items-center justify-center gap-1.5 disabled:opacity-30 disabled:cursor-not-allowed"
>
<Plus size={10} />
ADD FEED ({feeds.length}/{MAX_FEEDS})
</button>
</div>
{/* Status message */}
{feedMsg && (
<div className={`mx-4 mb-2 px-3 py-2 rounded text-[10px] font-mono ${feedMsg.type === "ok" ? "text-green-400 bg-green-950/20 border border-green-900/30" : "text-red-400 bg-red-950/20 border border-red-900/30"}`}>
{feedMsg.text}
</div>
)}
{/* Footer */}
<div className="p-4 border-t border-[var(--border-primary)]/80">
<div className="flex items-center gap-2">
<button
onClick={saveFeeds}
disabled={!feedsDirty || feedSaving}
className="flex-1 px-4 py-2 rounded bg-orange-500/20 border border-orange-500/40 text-orange-400 hover:bg-orange-500/30 transition-colors text-[10px] font-mono flex items-center justify-center gap-1.5 disabled:opacity-30 disabled:cursor-not-allowed"
>
<Save size={10} />
{feedSaving ? "SAVING..." : "SAVE FEEDS"}
</button>
<button
onClick={resetFeeds}
className="px-3 py-2 rounded border border-[var(--border-primary)] text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:border-[var(--border-secondary)] transition-all text-[10px] font-mono flex items-center gap-1.5"
title="Reset to defaults"
>
<RotateCcw size={10} />
RESET
</button>
</div>
<div className="flex items-center justify-between text-[9px] text-[var(--text-muted)] font-mono mt-2">
<span>{feeds.length}/{MAX_FEEDS} SOURCES</span>
<span>WEIGHT: 1=LOW 5=CRITICAL</span>
</div>
</div>
</>
)}
</motion.div>
</>
)}
+45 -2
View File
@@ -2,9 +2,44 @@
import React, { useState, useEffect, useRef } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Plane, AlertTriangle, Activity, Satellite, Cctv, ChevronDown, ChevronUp, Ship, Eye, Anchor, Settings, Sun, Moon, BookOpen, Radio, Play, Pause, Globe } from "lucide-react";
import { Plane, AlertTriangle, Activity, Satellite, Cctv, ChevronDown, ChevronUp, Ship, Eye, Anchor, Settings, Sun, Moon, BookOpen, Radio, Play, Pause, Globe, Flame, Wifi, Server } from "lucide-react";
import { useTheme } from "@/lib/ThemeContext";
function relativeTime(iso: string | undefined): string {
if (!iso) return "";
const diff = Date.now() - new Date(iso + "Z").getTime();
if (diff < 0) return "now";
const sec = Math.floor(diff / 1000);
if (sec < 60) return `${sec}s ago`;
const min = Math.floor(sec / 60);
if (min < 60) return `${min}m ago`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}h ago`;
return `${Math.floor(hr / 24)}d ago`;
}
// Map layer IDs to freshness keys from the backend source_timestamps dict
const FRESHNESS_MAP: Record<string, string> = {
flights: "commercial_flights",
private: "private_flights",
jets: "private_jets",
military: "military_flights",
tracked: "military_flights",
earthquakes: "earthquakes",
satellites: "satellites",
ships_important: "ships",
ships_civilian: "ships",
ships_passenger: "ships",
ukraine_frontline: "frontlines",
global_incidents: "gdelt",
cctv: "cctv",
gps_jamming: "commercial_flights",
kiwisdr: "kiwisdr",
firms: "firms_fires",
internet_outages: "internet_outages",
datacenters: "datacenters",
};
const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, activeLayers, setActiveLayers, onSettingsClick, onLegendClick, gibsDate, setGibsDate, gibsOpacity, setGibsOpacity }: { data: any; activeLayers: any; setActiveLayers: any; onSettingsClick?: () => void; onLegendClick?: () => void; gibsDate?: string; setGibsDate?: (d: string) => void; gibsOpacity?: number; setGibsOpacity?: (o: number) => void }) {
const [isMinimized, setIsMinimized] = useState(false);
const { theme, toggleTheme } = useTheme();
@@ -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: "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: "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 },
];
@@ -144,7 +182,12 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, active
</div>
<div className="flex flex-col">
<span className={`text-sm font-medium ${active ? 'text-[var(--text-primary)]' : 'text-[var(--text-secondary)]'} tracking-wide`}>{layer.name}</span>
<span className="text-[9px] text-[var(--text-muted)] font-mono tracking-wider mt-0.5">{layer.source} · {active ? 'LIVE' : 'OFF'}</span>
<span className="text-[9px] text-[var(--text-muted)] font-mono tracking-wider mt-0.5">{layer.source} · {active ? (() => {
const fKey = FRESHNESS_MAP[layer.id];
const freshness = fKey && data?.freshness?.[fKey];
const rt = freshness ? relativeTime(freshness) : '';
return rt ? <span className="text-cyan-500/70">{rt}</span> : 'LIVE';
})() : 'OFF'}</span>
</div>
</div>
<div className="flex items-center gap-3">
+12 -2
View File
@@ -17,9 +17,16 @@ if %errorlevel% neq 0 (
exit /b 1
)
:: Check Python version
:: Check Python version (warn if 3.13+)
for /f "tokens=2 delims= " %%v in ('python --version 2^>^&1') do set PYVER=%%v
echo [*] Found Python %PYVER%
for /f "tokens=1,2 delims=." %%a in ("%PYVER%") do (
if %%b GEQ 13 (
echo [!] WARNING: Python %PYVER% detected. Some packages may fail to build.
echo [!] Recommended: Python 3.10, 3.11, or 3.12.
echo.
)
)
:: Check for Node.js
where npm >nul 2>&1
@@ -47,7 +54,7 @@ if not exist "venv\" (
)
call venv\Scripts\activate.bat
echo [*] Installing Python dependencies (this may take a minute)...
pip install -r requirements.txt
pip install -q -r requirements.txt
if %errorlevel% neq 0 (
echo.
echo [!] ERROR: pip install failed. See errors above.
@@ -58,6 +65,9 @@ if %errorlevel% neq 0 (
exit /b 1
)
echo [*] Backend dependencies OK.
echo [*] Installing backend Node.js dependencies...
call npm install --silent
echo [*] Backend Node.js dependencies OK.
cd ..
echo.
+12 -2
View File
@@ -23,7 +23,14 @@ else
exit 1
fi
echo "[*] Found $($PYTHON_CMD --version 2>&1)"
PYVER=$($PYTHON_CMD --version 2>&1 | awk '{print $2}')
echo "[*] Found Python $PYVER"
PY_MINOR=$(echo "$PYVER" | cut -d. -f2)
if [ "$PY_MINOR" -ge 13 ] 2>/dev/null; then
echo "[!] WARNING: Python $PYVER detected. Some packages may fail to build."
echo "[!] Recommended: Python 3.10, 3.11, or 3.12."
echo ""
fi
# Get the directory where this script lives
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
@@ -42,7 +49,7 @@ fi
source venv/bin/activate
echo "[*] Installing Python dependencies (this may take a minute)..."
pip install -r requirements.txt
pip install -q -r requirements.txt
if [ $? -ne 0 ]; then
echo ""
echo "[!] ERROR: pip install failed. See errors above."
@@ -52,6 +59,9 @@ if [ $? -ne 0 ]; then
fi
echo "[*] Backend dependencies OK."
deactivate
echo "[*] Installing backend Node.js dependencies..."
npm install --silent
echo "[*] Backend Node.js dependencies OK."
cd "$SCRIPT_DIR"