mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-07-30 23:47:27 +02:00
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
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
12857a4b83
commit
2ae104fca2
@@ -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)
|
* **Global Incidents** — GDELT-powered conflict event aggregation (last 8 hours, ~1,000 events)
|
||||||
* **Ukraine Frontline** — Live warfront GeoJSON from DeepState Map
|
* **Ukraine Frontline** — Live warfront GeoJSON from DeepState Map
|
||||||
* **SIGINT/RISINT News Feed** — Real-time RSS aggregation from multiple intelligence-focused sources
|
* **SIGINT/RISINT News Feed** — Real-time RSS aggregation from multiple intelligence-focused sources with user-customizable feeds (up to 20 sources, configurable priority weights 1-5)
|
||||||
* **Region Dossier** — Right-click anywhere on the map for:
|
* **Region Dossier** — Right-click anywhere on the map for:
|
||||||
* Country profile (population, capital, languages, currencies, area)
|
* Country profile (population, capital, languages, currencies, area)
|
||||||
* Head of state & government type (Wikidata SPARQL)
|
* Head of state & government type (Wikidata SPARQL)
|
||||||
@@ -121,6 +121,7 @@ Do not append a trailing `.` to that command; Compose treats it as a service nam
|
|||||||
* **NASA FIRMS Fire Hotspots (24h)** — 5,000+ global thermal anomalies from NOAA-20 VIIRS satellite, updated every cycle. Flame-shaped icons color-coded by fire radiative power (FRP): yellow (low), orange, red, dark red (intense). Clustered at low zoom with fire-shaped cluster markers.
|
* **NASA FIRMS Fire Hotspots (24h)** — 5,000+ global thermal anomalies from NOAA-20 VIIRS satellite, updated every cycle. Flame-shaped icons color-coded by fire radiative power (FRP): yellow (low), orange, red, dark red (intense). Clustered at low zoom with fire-shaped cluster markers.
|
||||||
* **Space Weather Badge** — Live NOAA geomagnetic storm indicator in the bottom status bar. Color-coded Kp index: green (quiet), yellow (active), red (storm G1–G5). Data from SWPC planetary K-index 1-minute feed.
|
* **Space Weather Badge** — Live NOAA geomagnetic storm indicator in the bottom status bar. Color-coded Kp index: green (quiet), yellow (active), red (storm G1–G5). Data from SWPC planetary K-index 1-minute feed.
|
||||||
* **Internet Outage Monitoring** — Regional internet connectivity alerts from Georgia Tech IODA. Grey markers at affected regions with severity percentage. Uses only reliable datasources (BGP routing tables, active ping probing) — no telescope or interpolated data.
|
* **Internet Outage Monitoring** — Regional internet connectivity alerts from Georgia Tech IODA. Grey markers at affected regions with severity percentage. Uses only reliable datasources (BGP routing tables, active ping probing) — no telescope or interpolated data.
|
||||||
|
* **Data Center Mapping** — 2,000+ global data centers plotted from a curated dataset. Clustered purple markers with server-rack icons. Click for operator, location, and automatic internet outage cross-referencing by country.
|
||||||
|
|
||||||
### 🌐 Additional Layers
|
### 🌐 Additional Layers
|
||||||
|
|
||||||
@@ -198,6 +199,7 @@ Do not append a trailing `.` to that command; Compose treats it as a service nam
|
|||||||
| [NASA FIRMS](https://firms.modaps.eosdis.nasa.gov) | NOAA-20 VIIRS fire/thermal hotspots | ~120s | No |
|
| [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 |
|
| [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 |
|
| [IODA (Georgia Tech)](https://ioda.inetintel.cc.gatech.edu) | Regional internet outage alerts | ~120s | No |
|
||||||
|
| [DC Map (GitHub)](https://github.com/Ringmast4r/Data-Center-Map---Global) | Global data center locations | Static (cached 7d) | No |
|
||||||
| [CARTO Basemaps](https://carto.com) | Dark map tiles | Continuous | No |
|
| [CARTO Basemaps](https://carto.com) | Dark map tiles | Continuous | No |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -334,6 +336,7 @@ All layers are independently toggleable from the left panel:
|
|||||||
| KiwiSDR Receivers | ❌ OFF | Public SDR radio receivers |
|
| KiwiSDR Receivers | ❌ OFF | Public SDR radio receivers |
|
||||||
| Fire Hotspots (24h) | ❌ OFF | NASA FIRMS VIIRS thermal anomalies |
|
| Fire Hotspots (24h) | ❌ OFF | NASA FIRMS VIIRS thermal anomalies |
|
||||||
| Internet Outages | ❌ OFF | IODA regional connectivity alerts |
|
| Internet Outages | ❌ OFF | IODA regional connectivity alerts |
|
||||||
|
| Data Centers | ❌ OFF | Global data center locations (2,000+) |
|
||||||
| Day / Night Cycle | ✅ ON | Solar terminator overlay |
|
| Day / Night Cycle | ✅ ON | Solar terminator overlay |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -345,8 +348,9 @@ The platform is optimized for handling massive real-time datasets:
|
|||||||
* **Gzip Compression** — API payloads compressed ~92% (11.6 MB → 915 KB)
|
* **Gzip Compression** — API payloads compressed ~92% (11.6 MB → 915 KB)
|
||||||
* **ETag Caching** — `304 Not Modified` responses skip redundant JSON parsing
|
* **ETag Caching** — `304 Not Modified` responses skip redundant JSON parsing
|
||||||
* **Viewport Culling** — Only features within the visible map bounds (+20% buffer) are rendered
|
* **Viewport Culling** — Only features within the visible map bounds (+20% buffer) are rendered
|
||||||
* **Clustered Rendering** — Ships, CCTV, and earthquakes use MapLibre clustering to reduce feature count
|
* **Imperative Map Updates** — High-volume layers (flights, satellites, fires) bypass React reconciliation via direct `setData()` calls
|
||||||
* **Debounced Viewport Updates** — 300ms debounce prevents GeoJSON rebuild thrash during pan/zoom
|
* **Clustered Rendering** — Ships, CCTV, earthquakes, and data centers use MapLibre clustering to reduce feature count
|
||||||
|
* **Debounced Viewport Updates** — 300ms debounce prevents GeoJSON rebuild thrash during pan/zoom; 2s debounce on dense layers (satellites, fires)
|
||||||
* **Position Interpolation** — Smooth 10s tick animation between data refreshes
|
* **Position Interpolation** — Smooth 10s tick animation between data refreshes
|
||||||
* **React.memo** — Heavy components wrapped to prevent unnecessary re-renders
|
* **React.memo** — Heavy components wrapped to prevent unnecessary re-renders
|
||||||
* **Coordinate Precision** — Lat/lng rounded to 5 decimals (~1m) to reduce JSON size
|
* **Coordinate Precision** — Lat/lng rounded to 5 decimals (~1m) to reduce JSON size
|
||||||
@@ -361,6 +365,8 @@ live-risk-dashboard/
|
|||||||
│ ├── main.py # FastAPI app, middleware, API routes
|
│ ├── main.py # FastAPI app, middleware, API routes
|
||||||
│ ├── carrier_cache.json # Persisted carrier OSINT positions
|
│ ├── carrier_cache.json # Persisted carrier OSINT positions
|
||||||
│ ├── cctv.db # SQLite CCTV camera database
|
│ ├── cctv.db # SQLite CCTV camera database
|
||||||
|
│ ├── config/
|
||||||
|
│ │ └── news_feeds.json # User-customizable RSS feed list (persists across restarts)
|
||||||
│ └── services/
|
│ └── services/
|
||||||
│ ├── data_fetcher.py # Core scheduler — fetches all data sources
|
│ ├── data_fetcher.py # Core scheduler — fetches all data sources
|
||||||
│ ├── ais_stream.py # AIS WebSocket client (25K+ vessels)
|
│ ├── ais_stream.py # AIS WebSocket client (25K+ vessels)
|
||||||
@@ -372,7 +378,8 @@ live-risk-dashboard/
|
|||||||
│ ├── kiwisdr_fetcher.py # KiwiSDR receiver scraper
|
│ ├── kiwisdr_fetcher.py # KiwiSDR receiver scraper
|
||||||
│ ├── sentinel_search.py # Sentinel-2 STAC imagery search
|
│ ├── sentinel_search.py # Sentinel-2 STAC imagery search
|
||||||
│ ├── network_utils.py # HTTP client with curl fallback
|
│ ├── network_utils.py # HTTP client with curl fallback
|
||||||
│ └── api_settings.py # API key management
|
│ ├── api_settings.py # API key management
|
||||||
|
│ └── news_feed_config.py # RSS feed config manager (add/remove/weight feeds)
|
||||||
│
|
│
|
||||||
├── frontend/
|
├── frontend/
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
@@ -390,7 +397,7 @@ live-risk-dashboard/
|
|||||||
│ │ ├── RadioInterceptPanel.tsx # Scanner-style radio panel
|
│ │ ├── RadioInterceptPanel.tsx # Scanner-style radio panel
|
||||||
│ │ ├── FindLocateBar.tsx # Search/locate bar
|
│ │ ├── FindLocateBar.tsx # Search/locate bar
|
||||||
│ │ ├── ChangelogModal.tsx # Version changelog popup
|
│ │ ├── ChangelogModal.tsx # Version changelog popup
|
||||||
│ │ ├── SettingsPanel.tsx # App settings
|
│ │ ├── SettingsPanel.tsx # App settings (API Keys + News Feed manager)
|
||||||
│ │ ├── ScaleBar.tsx # Map scale indicator
|
│ │ ├── ScaleBar.tsx # Map scale indicator
|
||||||
│ │ ├── WikiImage.tsx # Wikipedia image fetcher
|
│ │ ├── WikiImage.tsx # Wikipedia image fetcher
|
||||||
│ │ └── ErrorBoundary.tsx # Crash recovery wrapper
|
│ │ └── ErrorBoundary.tsx # Crash recovery wrapper
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"feeds": [
|
||||||
|
{ "name": "NPR", "url": "https://feeds.npr.org/1004/rss.xml", "weight": 4 },
|
||||||
|
{ "name": "BBC", "url": "http://feeds.bbci.co.uk/news/world/rss.xml", "weight": 3 },
|
||||||
|
{ "name": "AlJazeera", "url": "https://www.aljazeera.com/xml/rss/all.xml", "weight": 2 },
|
||||||
|
{ "name": "NYT", "url": "https://rss.nytimes.com/services/xml/rss/nyt/World.xml", "weight": 1 },
|
||||||
|
{ "name": "GDACS", "url": "https://www.gdacs.org/xml/rss.xml", "weight": 5 },
|
||||||
|
{ "name": "NHK", "url": "https://www3.nhk.or.jp/nhkworld/rss/world.xml", "weight": 3 },
|
||||||
|
{ "name": "CNA", "url": "https://www.channelnewsasia.com/rssfeed/8395986", "weight": 3 },
|
||||||
|
{ "name": "Mercopress", "url": "https://en.mercopress.com/rss/", "weight": 3 }
|
||||||
|
]
|
||||||
|
}
|
||||||
+69
-5
@@ -1,16 +1,44 @@
|
|||||||
from fastapi import FastAPI, Request, Response
|
from fastapi import FastAPI, Request, Response
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from services.data_fetcher import start_scheduler, stop_scheduler, get_latest_data
|
from services.data_fetcher import start_scheduler, stop_scheduler, get_latest_data, source_timestamps
|
||||||
from services.ais_stream import start_ais_stream, stop_ais_stream
|
from services.ais_stream import start_ais_stream, stop_ais_stream
|
||||||
from services.carrier_tracker import start_carrier_tracker, stop_carrier_tracker
|
from services.carrier_tracker import start_carrier_tracker, stop_carrier_tracker
|
||||||
import uvicorn
|
import uvicorn
|
||||||
import logging
|
import logging
|
||||||
import hashlib
|
import hashlib
|
||||||
import json as json_mod
|
import json as json_mod
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_cors_origins():
|
||||||
|
"""Build a CORS origins whitelist: localhost + LAN IPs + env overrides.
|
||||||
|
Falls back to wildcard only if auto-detection fails entirely."""
|
||||||
|
origins = [
|
||||||
|
"http://localhost:3000",
|
||||||
|
"http://127.0.0.1:3000",
|
||||||
|
"http://localhost:8000",
|
||||||
|
"http://127.0.0.1:8000",
|
||||||
|
]
|
||||||
|
# Add this machine's LAN IPs (covers common home/office setups)
|
||||||
|
try:
|
||||||
|
hostname = socket.gethostname()
|
||||||
|
for info in socket.getaddrinfo(hostname, None, socket.AF_INET):
|
||||||
|
ip = info[4][0]
|
||||||
|
if ip not in ("127.0.0.1", "0.0.0.0"):
|
||||||
|
origins.append(f"http://{ip}:3000")
|
||||||
|
origins.append(f"http://{ip}:8000")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Allow user override via CORS_ORIGINS env var (comma-separated)
|
||||||
|
extra = os.environ.get("CORS_ORIGINS", "")
|
||||||
|
if extra:
|
||||||
|
origins.extend([o.strip() for o in extra.split(",") if o.strip()])
|
||||||
|
return list(set(origins)) # deduplicate
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
# Startup: Start background data fetching, AIS stream, and carrier tracker
|
# Startup: Start background data fetching, AIS stream, and carrier tracker
|
||||||
@@ -29,7 +57,7 @@ from fastapi.middleware.gzip import GZipMiddleware
|
|||||||
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"], # Must be permissive — users access from localhost, LAN IPs, Docker, custom ports
|
allow_origins=_build_cors_origins(),
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
@@ -64,11 +92,12 @@ async def live_data_fast(request: Request):
|
|||||||
"uavs": d.get("uavs", []),
|
"uavs": d.get("uavs", []),
|
||||||
"liveuamap": d.get("liveuamap", []),
|
"liveuamap": d.get("liveuamap", []),
|
||||||
"gps_jamming": d.get("gps_jamming", []),
|
"gps_jamming": d.get("gps_jamming", []),
|
||||||
|
"freshness": dict(source_timestamps),
|
||||||
}
|
}
|
||||||
# ETag includes last_updated timestamp so it changes on every data refresh,
|
# ETag includes last_updated timestamp so it changes on every data refresh,
|
||||||
# not just when item counts change (old bug: positions went stale)
|
# not just when item counts change (old bug: positions went stale)
|
||||||
last_updated = d.get("last_updated", "")
|
last_updated = d.get("last_updated", "")
|
||||||
counts = "|".join(f"{k}:{len(v) if isinstance(v, list) else 0}" for k, v in payload.items())
|
counts = "|".join(f"{k}:{len(v) if isinstance(v, list) else 0}" for k, v in payload.items() if k != "freshness")
|
||||||
etag = hashlib.md5(f"{last_updated}|{counts}".encode()).hexdigest()[:16]
|
etag = hashlib.md5(f"{last_updated}|{counts}".encode()).hexdigest()[:16]
|
||||||
if request.headers.get("if-none-match") == etag:
|
if request.headers.get("if-none-match") == etag:
|
||||||
return Response(status_code=304, headers={"ETag": etag, "Cache-Control": "no-cache"})
|
return Response(status_code=304, headers={"ETag": etag, "Cache-Control": "no-cache"})
|
||||||
@@ -96,11 +125,13 @@ async def live_data_slow(request: Request):
|
|||||||
"kiwisdr": d.get("kiwisdr", []),
|
"kiwisdr": d.get("kiwisdr", []),
|
||||||
"space_weather": d.get("space_weather"),
|
"space_weather": d.get("space_weather"),
|
||||||
"internet_outages": d.get("internet_outages", []),
|
"internet_outages": d.get("internet_outages", []),
|
||||||
"firms_fires": d.get("firms_fires", [])
|
"firms_fires": d.get("firms_fires", []),
|
||||||
|
"datacenters": d.get("datacenters", []),
|
||||||
|
"freshness": dict(source_timestamps),
|
||||||
}
|
}
|
||||||
# ETag based on last_updated + item counts
|
# ETag based on last_updated + item counts
|
||||||
last_updated = d.get("last_updated", "")
|
last_updated = d.get("last_updated", "")
|
||||||
counts = "|".join(f"{k}:{len(v) if isinstance(v, list) else 0}" for k, v in payload.items())
|
counts = "|".join(f"{k}:{len(v) if isinstance(v, list) else 0}" for k, v in payload.items() if k != "freshness")
|
||||||
etag = hashlib.md5(f"slow|{last_updated}|{counts}".encode()).hexdigest()[:16]
|
etag = hashlib.md5(f"slow|{last_updated}|{counts}".encode()).hexdigest()[:16]
|
||||||
if request.headers.get("if-none-match") == etag:
|
if request.headers.get("if-none-match") == etag:
|
||||||
return Response(status_code=304, headers={"ETag": etag, "Cache-Control": "no-cache"})
|
return Response(status_code=304, headers={"ETag": etag, "Cache-Control": "no-cache"})
|
||||||
@@ -131,7 +162,12 @@ async def health_check():
|
|||||||
"earthquakes": len(d.get("earthquakes", [])),
|
"earthquakes": len(d.get("earthquakes", [])),
|
||||||
"cctv": len(d.get("cctv", [])),
|
"cctv": len(d.get("cctv", [])),
|
||||||
"news": len(d.get("news", [])),
|
"news": len(d.get("news", [])),
|
||||||
|
"uavs": len(d.get("uavs", [])),
|
||||||
|
"firms_fires": len(d.get("firms_fires", [])),
|
||||||
|
"liveuamap": len(d.get("liveuamap", [])),
|
||||||
|
"gdelt": len(d.get("gdelt", [])),
|
||||||
},
|
},
|
||||||
|
"freshness": dict(source_timestamps),
|
||||||
"uptime_seconds": round(time.time() - _start_time),
|
"uptime_seconds": round(time.time() - _start_time),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,6 +255,34 @@ async def api_update_key(body: ApiKeyUpdate):
|
|||||||
return {"status": "updated", "env_key": body.env_key}
|
return {"status": "updated", "env_key": body.env_key}
|
||||||
return {"status": "error", "message": "Failed to update .env file"}
|
return {"status": "error", "message": "Failed to update .env file"}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# News Feed Configuration
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
from services.news_feed_config import get_feeds, save_feeds, reset_feeds
|
||||||
|
|
||||||
|
@app.get("/api/settings/news-feeds")
|
||||||
|
async def api_get_news_feeds():
|
||||||
|
return get_feeds()
|
||||||
|
|
||||||
|
@app.put("/api/settings/news-feeds")
|
||||||
|
async def api_save_news_feeds(request: Request):
|
||||||
|
body = await request.json()
|
||||||
|
ok = save_feeds(body)
|
||||||
|
if ok:
|
||||||
|
return {"status": "updated", "count": len(body)}
|
||||||
|
return Response(
|
||||||
|
content=json_mod.dumps({"status": "error", "message": "Validation failed (max 20 feeds, each needs name/url/weight 1-5)"}),
|
||||||
|
status_code=400,
|
||||||
|
media_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.post("/api/settings/news-feeds/reset")
|
||||||
|
async def api_reset_news_feeds():
|
||||||
|
ok = reset_feeds()
|
||||||
|
if ok:
|
||||||
|
return {"status": "reset", "feeds": get_feeds()}
|
||||||
|
return {"status": "error", "message": "Failed to reset feeds"}
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
|
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
|
||||||
|
|
||||||
|
|||||||
+276
-116
@@ -10,6 +10,7 @@ import random
|
|||||||
import math
|
import math
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
|
from pathlib import Path
|
||||||
import threading
|
import threading
|
||||||
import io
|
import io
|
||||||
from apscheduler.schedulers.background import BackgroundScheduler
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
@@ -104,9 +105,19 @@ latest_data = {
|
|||||||
"kiwisdr": [],
|
"kiwisdr": [],
|
||||||
"space_weather": None,
|
"space_weather": None,
|
||||||
"internet_outages": [],
|
"internet_outages": [],
|
||||||
"firms_fires": []
|
"firms_fires": [],
|
||||||
|
"datacenters": []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Per-source freshness timestamps — updated each time a fetch function completes successfully
|
||||||
|
source_timestamps = {}
|
||||||
|
|
||||||
|
def _mark_fresh(*keys):
|
||||||
|
"""Record the current UTC time for one or more data source keys."""
|
||||||
|
now = datetime.utcnow().isoformat()
|
||||||
|
for k in keys:
|
||||||
|
source_timestamps[k] = now
|
||||||
|
|
||||||
# Thread lock for safe reads/writes to latest_data
|
# Thread lock for safe reads/writes to latest_data
|
||||||
_data_lock = threading.Lock()
|
_data_lock = threading.Lock()
|
||||||
|
|
||||||
@@ -337,20 +348,10 @@ _KEYWORD_COORDS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
def fetch_news():
|
def fetch_news():
|
||||||
feeds = {
|
from services.news_feed_config import get_feeds
|
||||||
"NPR": "https://feeds.npr.org/1004/rss.xml",
|
feed_config = get_feeds()
|
||||||
"BBC": "http://feeds.bbci.co.uk/news/world/rss.xml",
|
feeds = {f["name"]: f["url"] for f in feed_config}
|
||||||
"AlJazeera": "https://www.aljazeera.com/xml/rss/all.xml",
|
source_weights = {f["name"]: f["weight"] for f in feed_config}
|
||||||
"NYT": "https://rss.nytimes.com/services/xml/rss/nyt/World.xml",
|
|
||||||
"GDACS": "https://www.gdacs.org/xml/rss.xml",
|
|
||||||
"NHK": "https://www3.nhk.or.jp/nhkworld/rss/world.xml",
|
|
||||||
"CNA": "https://www.channelnewsasia.com/rssfeed/8395986",
|
|
||||||
"Mercopress": "https://en.mercopress.com/rss/"
|
|
||||||
}
|
|
||||||
source_weights = {
|
|
||||||
"NPR": 4, "BBC": 3, "AlJazeera": 2, "NYT": 1,
|
|
||||||
"GDACS": 5, "NHK": 3, "CNA": 3, "Mercopress": 3
|
|
||||||
}
|
|
||||||
|
|
||||||
clusters = {}
|
clusters = {}
|
||||||
|
|
||||||
@@ -477,6 +478,7 @@ def fetch_news():
|
|||||||
|
|
||||||
news_items.sort(key=lambda x: x['risk_score'], reverse=True)
|
news_items.sort(key=lambda x: x['risk_score'], reverse=True)
|
||||||
latest_data['news'] = news_items
|
latest_data['news'] = news_items
|
||||||
|
_mark_fresh("news")
|
||||||
|
|
||||||
def fetch_defense_stocks():
|
def fetch_defense_stocks():
|
||||||
tickers = ["RTX", "LMT", "NOC", "GD", "BA", "PLTR"]
|
tickers = ["RTX", "LMT", "NOC", "GD", "BA", "PLTR"]
|
||||||
@@ -500,6 +502,7 @@ def fetch_defense_stocks():
|
|||||||
logger.warning(f"Could not fetch data for {t}: {e}")
|
logger.warning(f"Could not fetch data for {t}: {e}")
|
||||||
|
|
||||||
latest_data['stocks'] = stocks_data
|
latest_data['stocks'] = stocks_data
|
||||||
|
_mark_fresh("stocks")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching stocks: {e}")
|
logger.error(f"Error fetching stocks: {e}")
|
||||||
|
|
||||||
@@ -526,6 +529,7 @@ def fetch_oil_prices():
|
|||||||
logger.warning(f"Could not fetch data for {symbol}: {e}")
|
logger.warning(f"Could not fetch data for {symbol}: {e}")
|
||||||
|
|
||||||
latest_data['oil'] = oil_data
|
latest_data['oil'] = oil_data
|
||||||
|
_mark_fresh("oil")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching oil: {e}")
|
logger.error(f"Error fetching oil: {e}")
|
||||||
|
|
||||||
@@ -899,6 +903,8 @@ def fetch_flights():
|
|||||||
latest_data['private_jets'] = _merge_category(private_jets, latest_data.get('private_jets', []))
|
latest_data['private_jets'] = _merge_category(private_jets, latest_data.get('private_jets', []))
|
||||||
latest_data['private_flights'] = _merge_category(private_ga, latest_data.get('private_flights', []))
|
latest_data['private_flights'] = _merge_category(private_ga, latest_data.get('private_flights', []))
|
||||||
|
|
||||||
|
_mark_fresh("commercial_flights", "private_jets", "private_flights")
|
||||||
|
|
||||||
# Always write raw flights for GPS jamming analysis (nac_p field)
|
# Always write raw flights for GPS jamming analysis (nac_p field)
|
||||||
if flights:
|
if flights:
|
||||||
latest_data['flights'] = flights
|
latest_data['flights'] = flights
|
||||||
@@ -1117,31 +1123,65 @@ def fetch_ships():
|
|||||||
|
|
||||||
logger.info(f"Ships: {len(carriers)} carriers + {len(ais_vessels)} AIS vessels")
|
logger.info(f"Ships: {len(carriers)} carriers + {len(ais_vessels)} AIS vessels")
|
||||||
latest_data['ships'] = ships
|
latest_data['ships'] = ships
|
||||||
|
_mark_fresh("ships")
|
||||||
|
|
||||||
def fetch_military_flights():
|
def fetch_military_flights():
|
||||||
# True ADS-B Exchange military data requires paid API access.
|
# True ADS-B Exchange military data requires paid API access.
|
||||||
# We will use adsb.lol (an open source ADSB aggregator) /v2/mil fallback.
|
# We will use adsb.lol (an open source ADSB aggregator) /v2/mil fallback.
|
||||||
military_flights = []
|
military_flights = []
|
||||||
|
detected_uavs = []
|
||||||
try:
|
try:
|
||||||
url = "https://api.adsb.lol/v2/mil"
|
url = "https://api.adsb.lol/v2/mil"
|
||||||
response = fetch_with_curl(url, timeout=10)
|
response = fetch_with_curl(url, timeout=10)
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
ac = response.json().get('ac', [])
|
ac = response.json().get('ac', [])
|
||||||
for f in ac:
|
for f in ac:
|
||||||
try:
|
try:
|
||||||
lat = f.get("lat")
|
lat = f.get("lat")
|
||||||
lng = f.get("lon")
|
lng = f.get("lon")
|
||||||
heading = f.get("track") or 0
|
heading = f.get("track") or 0
|
||||||
|
|
||||||
if lat is None or lng is None:
|
if lat is None or lng is None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
model = str(f.get("t", "UNKNOWN")).upper()
|
model = str(f.get("t", "UNKNOWN")).upper()
|
||||||
|
callsign = str(f.get("flight", "MIL-UNKN")).strip()
|
||||||
|
|
||||||
# Skip fixed structures (towers, oil platforms) that broadcast ADS-B
|
# Skip fixed structures (towers, oil platforms) that broadcast ADS-B
|
||||||
if model == "TWR":
|
if model == "TWR":
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
alt_raw = f.get("alt_baro")
|
||||||
|
alt_value = 0
|
||||||
|
if isinstance(alt_raw, (int, float)):
|
||||||
|
alt_value = alt_raw * 0.3048
|
||||||
|
|
||||||
|
# Ground speed from ADS-B (in knots)
|
||||||
|
gs_knots = f.get("gs")
|
||||||
|
speed_knots = round(gs_knots, 1) if isinstance(gs_knots, (int, float)) else None
|
||||||
|
|
||||||
|
# Check if this is a UAV/drone before classifying as regular military
|
||||||
|
is_uav, uav_type, wiki_url = _classify_uav(model, callsign)
|
||||||
|
if is_uav:
|
||||||
|
detected_uavs.append({
|
||||||
|
"id": f"uav-{f.get('hex', '')}",
|
||||||
|
"callsign": callsign,
|
||||||
|
"aircraft_model": f.get("t", "Unknown"),
|
||||||
|
"lat": float(lat),
|
||||||
|
"lng": float(lng),
|
||||||
|
"alt": alt_value,
|
||||||
|
"heading": heading,
|
||||||
|
"speed_knots": speed_knots,
|
||||||
|
"country": f.get("r", "Unknown"),
|
||||||
|
"uav_type": uav_type,
|
||||||
|
"wiki": wiki_url or "",
|
||||||
|
"type": "uav",
|
||||||
|
"registration": f.get("r", "N/A"),
|
||||||
|
"icao24": f.get("hex", ""),
|
||||||
|
"squawk": f.get("squawk", ""),
|
||||||
|
})
|
||||||
|
continue # Don't double-count as military flight
|
||||||
|
|
||||||
mil_cat = "default"
|
mil_cat = "default"
|
||||||
if "H" in model and any(c.isdigit() for c in model):
|
if "H" in model and any(c.isdigit() for c in model):
|
||||||
mil_cat = "heli"
|
mil_cat = "heli"
|
||||||
@@ -1151,27 +1191,11 @@ def fetch_military_flights():
|
|||||||
mil_cat = "fighter"
|
mil_cat = "fighter"
|
||||||
elif any(k in model for k in ["C17", "C5", "C130", "C30", "A400", "V22"]):
|
elif any(k in model for k in ["C17", "C5", "C130", "C30", "A400", "V22"]):
|
||||||
mil_cat = "cargo"
|
mil_cat = "cargo"
|
||||||
elif any(k in model for k in ["P8", "E3", "E8", "U2", "RQ", "MQ"]):
|
elif any(k in model for k in ["P8", "E3", "E8", "U2"]):
|
||||||
mil_cat = "recon"
|
mil_cat = "recon"
|
||||||
|
|
||||||
# Military flights don't file public routes
|
|
||||||
origin_loc = None
|
|
||||||
dest_loc = None
|
|
||||||
origin_name = "UNKNOWN"
|
|
||||||
dest_name = "UNKNOWN"
|
|
||||||
|
|
||||||
|
|
||||||
alt_raw = f.get("alt_baro")
|
|
||||||
alt_value = 0
|
|
||||||
if isinstance(alt_raw, (int, float)):
|
|
||||||
alt_value = alt_raw * 0.3048
|
|
||||||
|
|
||||||
# Ground speed from ADS-B (in knots)
|
|
||||||
gs_knots = f.get("gs")
|
|
||||||
speed_knots = round(gs_knots, 1) if isinstance(gs_knots, (int, float)) else None
|
|
||||||
|
|
||||||
military_flights.append({
|
military_flights.append({
|
||||||
"callsign": str(f.get("flight", "MIL-UNKN")).strip(),
|
"callsign": callsign,
|
||||||
"country": f.get("r", "Military Asset"),
|
"country": f.get("r", "Military Asset"),
|
||||||
"lng": float(lng),
|
"lng": float(lng),
|
||||||
"lat": float(lat),
|
"lat": float(lat),
|
||||||
@@ -1179,10 +1203,10 @@ def fetch_military_flights():
|
|||||||
"heading": heading,
|
"heading": heading,
|
||||||
"type": "military_flight",
|
"type": "military_flight",
|
||||||
"military_type": mil_cat,
|
"military_type": mil_cat,
|
||||||
"origin_loc": origin_loc,
|
"origin_loc": None,
|
||||||
"dest_loc": dest_loc,
|
"dest_loc": None,
|
||||||
"origin_name": origin_name,
|
"origin_name": "UNKNOWN",
|
||||||
"dest_name": dest_name,
|
"dest_name": "UNKNOWN",
|
||||||
"registration": f.get("r", "N/A"),
|
"registration": f.get("r", "N/A"),
|
||||||
"model": f.get("t", "Unknown"),
|
"model": f.get("t", "Unknown"),
|
||||||
"icao24": f.get("hex", ""),
|
"icao24": f.get("hex", ""),
|
||||||
@@ -1194,15 +1218,18 @@ def fetch_military_flights():
|
|||||||
continue
|
continue
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching military flights: {e}")
|
logger.error(f"Error fetching military flights: {e}")
|
||||||
|
|
||||||
if not military_flights:
|
if not military_flights and not detected_uavs:
|
||||||
# API failed or rate limited — log but do NOT inject fake data
|
# API failed or rate limited — log but do NOT inject fake data
|
||||||
logger.warning("No military flights retrieved — keeping previous data if available")
|
logger.warning("No military flights retrieved — keeping previous data if available")
|
||||||
# Preserve existing data rather than overwriting with empty
|
# Preserve existing data rather than overwriting with empty
|
||||||
if latest_data.get('military_flights'):
|
if latest_data.get('military_flights'):
|
||||||
return
|
return
|
||||||
|
|
||||||
latest_data['military_flights'] = military_flights
|
latest_data['military_flights'] = military_flights
|
||||||
|
latest_data['uavs'] = detected_uavs
|
||||||
|
_mark_fresh("military_flights", "uavs")
|
||||||
|
logger.info(f"UAVs: {len(detected_uavs)} real drones detected via ADS-B")
|
||||||
|
|
||||||
# Cross-reference military flights with Plane-Alert DB
|
# Cross-reference military flights with Plane-Alert DB
|
||||||
tracked_mil = []
|
tracked_mil = []
|
||||||
@@ -1254,12 +1281,14 @@ def fetch_weather():
|
|||||||
if "radar" in data and "past" in data["radar"]:
|
if "radar" in data and "past" in data["radar"]:
|
||||||
latest_time = data["radar"]["past"][-1]["time"]
|
latest_time = data["radar"]["past"][-1]["time"]
|
||||||
latest_data["weather"] = {"time": latest_time, "host": data.get("host", "https://tilecache.rainviewer.com")}
|
latest_data["weather"] = {"time": latest_time, "host": data.get("host", "https://tilecache.rainviewer.com")}
|
||||||
|
_mark_fresh("weather")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching weather: {e}")
|
logger.error(f"Error fetching weather: {e}")
|
||||||
|
|
||||||
def fetch_cctv():
|
def fetch_cctv():
|
||||||
try:
|
try:
|
||||||
latest_data["cctv"] = get_all_cameras()
|
latest_data["cctv"] = get_all_cameras()
|
||||||
|
_mark_fresh("cctv")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching cctv from DB: {e}")
|
logger.error(f"Error fetching cctv from DB: {e}")
|
||||||
latest_data["cctv"] = []
|
latest_data["cctv"] = []
|
||||||
@@ -1268,6 +1297,7 @@ def fetch_kiwisdr():
|
|||||||
try:
|
try:
|
||||||
from services.kiwisdr_fetcher import fetch_kiwisdr_nodes
|
from services.kiwisdr_fetcher import fetch_kiwisdr_nodes
|
||||||
latest_data["kiwisdr"] = fetch_kiwisdr_nodes()
|
latest_data["kiwisdr"] = fetch_kiwisdr_nodes()
|
||||||
|
_mark_fresh("kiwisdr")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching KiwiSDR nodes: {e}")
|
logger.error(f"Error fetching KiwiSDR nodes: {e}")
|
||||||
latest_data["kiwisdr"] = []
|
latest_data["kiwisdr"] = []
|
||||||
@@ -1310,6 +1340,8 @@ def fetch_firms_fires():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching FIRMS fires: {e}")
|
logger.error(f"Error fetching FIRMS fires: {e}")
|
||||||
latest_data["firms_fires"] = fires
|
latest_data["firms_fires"] = fires
|
||||||
|
if fires:
|
||||||
|
_mark_fresh("firms_fires")
|
||||||
|
|
||||||
def fetch_space_weather():
|
def fetch_space_weather():
|
||||||
"""Fetch NOAA SWPC Kp index and recent solar events."""
|
"""Fetch NOAA SWPC Kp index and recent solar events."""
|
||||||
@@ -1348,6 +1380,7 @@ def fetch_space_weather():
|
|||||||
"kp_text": kp_text,
|
"kp_text": kp_text,
|
||||||
"events": events,
|
"events": events,
|
||||||
}
|
}
|
||||||
|
_mark_fresh("space_weather")
|
||||||
logger.info(f"Space weather: Kp={kp_value} ({kp_text}), {len(events)} events")
|
logger.info(f"Space weather: Kp={kp_value} ({kp_text}), {len(events)} events")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching space weather: {e}")
|
logger.error(f"Error fetching space weather: {e}")
|
||||||
@@ -1445,6 +1478,129 @@ def fetch_internet_outages():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching internet outages: {e}")
|
logger.error(f"Error fetching internet outages: {e}")
|
||||||
latest_data["internet_outages"] = outages
|
latest_data["internet_outages"] = outages
|
||||||
|
if outages:
|
||||||
|
_mark_fresh("internet_outages")
|
||||||
|
|
||||||
|
_DC_CACHE_PATH = Path(__file__).parent.parent / "data" / "datacenters.json"
|
||||||
|
_DC_URL = "https://raw.githubusercontent.com/Ringmast4r/Data-Center-Map---Global/1f290297c6a11454dc7a47bf95aef7cf0fe1d34c/datacenters_cleaned.json"
|
||||||
|
|
||||||
|
# Country bounding boxes (lat_min, lat_max, lng_min, lng_max) for coordinate validation.
|
||||||
|
# The source dataset has abs(lat) for all Southern Hemisphere entries, so we fix the sign
|
||||||
|
# and then validate the result falls within the country's bounding box.
|
||||||
|
_COUNTRY_BBOX: dict[str, tuple[float, float, float, float]] = {
|
||||||
|
"Argentina": (-55, -21, -74, -53), "Australia": (-44, -10, 112, 154),
|
||||||
|
"Bolivia": (-23, -9, -70, -57), "Brazil": (-34, 6, -74, -34),
|
||||||
|
"Chile": (-56, -17, -76, -66), "Colombia": (-5, 13, -82, -66),
|
||||||
|
"Ecuador": (-5, 2, -81, -75), "Indonesia": (-11, 6, 95, 141),
|
||||||
|
"Kenya": (-5, 5, 34, 42), "Madagascar": (-26, -12, 43, 51),
|
||||||
|
"Mozambique": (-27, -10, 30, 41), "New Zealand": (-47, -34, 166, 179),
|
||||||
|
"Paraguay": (-28, -19, -63, -54), "Peru": (-18, 0, -82, -68),
|
||||||
|
"South Africa": (-35, -22, 16, 33), "Tanzania": (-12, -1, 29, 41),
|
||||||
|
"Uruguay": (-35, -30, -59, -53), "Zimbabwe": (-23, -15, 25, 34),
|
||||||
|
# Northern-hemisphere countries for validation only
|
||||||
|
"United States": (24, 72, -180, -65), "Canada": (41, 84, -141, -52),
|
||||||
|
"United Kingdom": (49, 61, -9, 2), "Germany": (47, 55, 5, 16),
|
||||||
|
"France": (41, 51, -5, 10), "Japan": (24, 46, 123, 146),
|
||||||
|
"India": (6, 36, 68, 98), "China": (18, 54, 73, 135),
|
||||||
|
"Singapore": (1, 2, 103, 105), "Spain": (36, 44, -10, 5),
|
||||||
|
"Netherlands": (50, 54, 3, 8), "Sweden": (55, 70, 11, 25),
|
||||||
|
"Italy": (36, 47, 6, 19), "Russia": (41, 82, 19, 180),
|
||||||
|
"Mexico": (14, 33, -118, -86), "Nigeria": (4, 14, 2, 15),
|
||||||
|
"Thailand": (5, 21, 97, 106), "Malaysia": (0, 8, 99, 120),
|
||||||
|
"Philippines": (4, 21, 116, 127), "South Korea": (33, 39, 124, 132),
|
||||||
|
"Taiwan": (21, 26, 119, 123), "Hong Kong": (22, 23, 113, 115),
|
||||||
|
"Vietnam": (8, 24, 102, 110), "Poland": (49, 55, 14, 25),
|
||||||
|
"Switzerland": (45, 48, 5, 11), "Austria": (46, 49, 9, 17),
|
||||||
|
"Belgium": (49, 52, 2, 7), "Denmark": (54, 58, 8, 16),
|
||||||
|
"Finland": (59, 70, 20, 32), "Norway": (57, 72, 4, 32),
|
||||||
|
"Ireland": (51, 56, -11, -5), "Portugal": (36, 42, -10, -6),
|
||||||
|
"Turkey": (35, 42, 25, 45), "Israel": (29, 34, 34, 36),
|
||||||
|
"UAE": (22, 27, 51, 56), "Saudi Arabia": (16, 33, 34, 56),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Countries whose DCs always sit south of the equator
|
||||||
|
_SOUTHERN_COUNTRIES = {
|
||||||
|
"Argentina", "Australia", "Bolivia", "Brazil", "Chile", "Madagascar",
|
||||||
|
"Mozambique", "New Zealand", "Paraguay", "Peru", "South Africa",
|
||||||
|
"Tanzania", "Uruguay", "Zimbabwe",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _fix_dc_coords(lat: float, lng: float, country: str) -> tuple[float, float] | None:
|
||||||
|
"""Fix and validate data-center coordinates against the stated country.
|
||||||
|
|
||||||
|
The source dataset stores abs(lat) for Southern-Hemisphere entries.
|
||||||
|
We negate lat when the country is in the Southern Hemisphere, then
|
||||||
|
validate the result falls within the country bounding box (if known).
|
||||||
|
Returns corrected (lat, lng) or None if the coords are clearly wrong.
|
||||||
|
"""
|
||||||
|
# Fix Southern Hemisphere sign
|
||||||
|
if country in _SOUTHERN_COUNTRIES and lat > 0:
|
||||||
|
lat = -lat
|
||||||
|
|
||||||
|
bbox = _COUNTRY_BBOX.get(country)
|
||||||
|
if bbox:
|
||||||
|
lat_min, lat_max, lng_min, lng_max = bbox
|
||||||
|
if lat_min <= lat <= lat_max and lng_min <= lng <= lng_max:
|
||||||
|
return lat, lng
|
||||||
|
# Try swapping sign as last resort (some entries are just wrong sign)
|
||||||
|
if lat_min <= -lat <= lat_max and lng_min <= lng <= lng_max:
|
||||||
|
return -lat, lng
|
||||||
|
# Coords don't match country at all — drop the entry
|
||||||
|
return None
|
||||||
|
|
||||||
|
# No bbox for this country — basic sanity only
|
||||||
|
return lat, lng
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_datacenters():
|
||||||
|
"""Load data center locations (static dataset, cached locally after first fetch)."""
|
||||||
|
dcs = []
|
||||||
|
try:
|
||||||
|
raw = None
|
||||||
|
# Use local cache if it exists and is less than 7 days old
|
||||||
|
if _DC_CACHE_PATH.exists():
|
||||||
|
age_days = (time.time() - _DC_CACHE_PATH.stat().st_mtime) / 86400
|
||||||
|
if age_days < 7:
|
||||||
|
raw = json.loads(_DC_CACHE_PATH.read_text(encoding="utf-8"))
|
||||||
|
# Otherwise fetch from GitHub
|
||||||
|
if raw is None:
|
||||||
|
resp = fetch_with_curl(_DC_URL, timeout=20)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
raw = resp.json()
|
||||||
|
_DC_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
_DC_CACHE_PATH.write_text(json.dumps(raw), encoding="utf-8")
|
||||||
|
if raw:
|
||||||
|
dropped = 0
|
||||||
|
for entry in raw:
|
||||||
|
coords = entry.get("city_coords")
|
||||||
|
if not coords or not isinstance(coords, list) or len(coords) < 2:
|
||||||
|
continue
|
||||||
|
lat, lng = coords[0], coords[1]
|
||||||
|
if not (-90 <= lat <= 90 and -180 <= lng <= 180):
|
||||||
|
continue
|
||||||
|
country = entry.get("country", "")
|
||||||
|
fixed = _fix_dc_coords(lat, lng, country)
|
||||||
|
if fixed is None:
|
||||||
|
dropped += 1
|
||||||
|
continue
|
||||||
|
lat, lng = fixed
|
||||||
|
dcs.append({
|
||||||
|
"name": entry.get("name", "Unknown"),
|
||||||
|
"company": entry.get("company", ""),
|
||||||
|
"city": entry.get("city", ""),
|
||||||
|
"country": country,
|
||||||
|
"lat": lat,
|
||||||
|
"lng": lng,
|
||||||
|
})
|
||||||
|
if dropped:
|
||||||
|
logger.info(f"Data centers: dropped {dropped} entries with mismatched coordinates")
|
||||||
|
logger.info(f"Data centers: {len(dcs)} with valid coordinates (from {'cache' if _DC_CACHE_PATH.exists() else 'GitHub'})")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching data centers: {e}")
|
||||||
|
latest_data["datacenters"] = dcs
|
||||||
|
if dcs:
|
||||||
|
_mark_fresh("datacenters")
|
||||||
|
|
||||||
def fetch_bikeshare():
|
def fetch_bikeshare():
|
||||||
bikes = []
|
bikes = []
|
||||||
@@ -1503,6 +1659,8 @@ def fetch_earthquakes():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching earthquakes: {e}")
|
logger.error(f"Error fetching earthquakes: {e}")
|
||||||
latest_data["earthquakes"] = quakes
|
latest_data["earthquakes"] = quakes
|
||||||
|
if quakes:
|
||||||
|
_mark_fresh("earthquakes")
|
||||||
|
|
||||||
# Satellite GP data cache — re-download from CelesTrak only every 30 minutes
|
# Satellite GP data cache — re-download from CelesTrak only every 30 minutes
|
||||||
_sat_gp_cache = {"data": None, "last_fetch": 0}
|
_sat_gp_cache = {"data": None, "last_fetch": 0}
|
||||||
@@ -1802,79 +1960,78 @@ def fetch_satellites():
|
|||||||
# Only overwrite if we got data — don't wipe the map on API timeout
|
# Only overwrite if we got data — don't wipe the map on API timeout
|
||||||
if sats:
|
if sats:
|
||||||
latest_data["satellites"] = sats
|
latest_data["satellites"] = sats
|
||||||
|
_mark_fresh("satellites")
|
||||||
elif not latest_data.get("satellites"):
|
elif not latest_data.get("satellites"):
|
||||||
latest_data["satellites"] = []
|
latest_data["satellites"] = []
|
||||||
|
|
||||||
def fetch_uavs():
|
# ---------------------------------------------------------------------------
|
||||||
# Simulated high-altitude long-endurance (HALE) and MALE UAVs over high-risk regions
|
# Real UAV detection from ADS-B data — filters military drone transponders
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
uav_targets = [
|
_UAV_TYPE_CODES = {"Q9", "R4", "TB2", "MALE", "HALE", "HERM", "HRON"}
|
||||||
{
|
_UAV_CALLSIGN_PREFIXES = ("FORTE", "GHAWK", "REAP", "BAMS", "UAV", "UAS")
|
||||||
"name": "RQ-4 Global Hawk", "center": [31.5, 34.8], "radius": 0.5, "alt": 15000,
|
_UAV_MODEL_KEYWORDS = ("RQ-", "MQ-", "RQ4", "MQ9", "MQ4", "MQ1", "REAPER", "GLOBALHAWK", "TRITON", "PREDATOR", "HERMES", "HERON", "BAYRAKTAR")
|
||||||
"country": "USA", "uav_type": "HALE Surveillance", "range_km": 2200,
|
_UAV_WIKI = {
|
||||||
"wiki": "https://en.wikipedia.org/wiki/Northrop_Grumman_RQ-4_Global_Hawk",
|
"RQ4": "https://en.wikipedia.org/wiki/Northrop_Grumman_RQ-4_Global_Hawk",
|
||||||
"speed_knots": 340
|
"RQ-4": "https://en.wikipedia.org/wiki/Northrop_Grumman_RQ-4_Global_Hawk",
|
||||||
},
|
"MQ4": "https://en.wikipedia.org/wiki/Northrop_Grumman_MQ-4C_Triton",
|
||||||
{
|
"MQ-4": "https://en.wikipedia.org/wiki/Northrop_Grumman_MQ-4C_Triton",
|
||||||
"name": "MQ-9 Reaper", "center": [49.0, 31.4], "radius": 1.2, "alt": 12000,
|
"MQ9": "https://en.wikipedia.org/wiki/General_Atomics_MQ-9_Reaper",
|
||||||
"country": "USA", "uav_type": "MALE Strike/ISR", "range_km": 1850,
|
"MQ-9": "https://en.wikipedia.org/wiki/General_Atomics_MQ-9_Reaper",
|
||||||
"wiki": "https://en.wikipedia.org/wiki/General_Atomics_MQ-9_Reaper",
|
"MQ1": "https://en.wikipedia.org/wiki/General_Atomics_MQ-1C_Gray_Eagle",
|
||||||
"speed_knots": 250
|
"MQ-1": "https://en.wikipedia.org/wiki/General_Atomics_MQ-1C_Gray_Eagle",
|
||||||
},
|
"REAPER": "https://en.wikipedia.org/wiki/General_Atomics_MQ-9_Reaper",
|
||||||
{
|
"GLOBALHAWK": "https://en.wikipedia.org/wiki/Northrop_Grumman_RQ-4_Global_Hawk",
|
||||||
"name": "Bayraktar TB2", "center": [23.6, 120.9], "radius": 0.8, "alt": 8000,
|
"TRITON": "https://en.wikipedia.org/wiki/Northrop_Grumman_MQ-4C_Triton",
|
||||||
"country": "Turkey", "uav_type": "MALE Strike", "range_km": 150,
|
"PREDATOR": "https://en.wikipedia.org/wiki/General_Atomics_MQ-1_Predator",
|
||||||
"wiki": "https://en.wikipedia.org/wiki/Bayraktar_TB2",
|
"HERMES": "https://en.wikipedia.org/wiki/Elbit_Hermes_900",
|
||||||
"speed_knots": 120
|
"HERON": "https://en.wikipedia.org/wiki/IAI_Heron",
|
||||||
},
|
"BAYRAKTAR": "https://en.wikipedia.org/wiki/Bayraktar_TB2",
|
||||||
{
|
}
|
||||||
"name": "MQ-1C Gray Eagle", "center": [38.0, 127.0], "radius": 0.4, "alt": 10000,
|
|
||||||
"country": "USA", "uav_type": "MALE ISR/Strike", "range_km": 400,
|
def _classify_uav(model: str, callsign: str):
|
||||||
"wiki": "https://en.wikipedia.org/wiki/General_Atomics_MQ-1C_Gray_Eagle",
|
"""Check if an aircraft is a UAV based on type code, callsign prefix, or model keywords.
|
||||||
"speed_knots": 150
|
Returns (is_uav, uav_type, wiki_url) or (False, None, None)."""
|
||||||
},
|
model_up = model.upper().replace(" ", "")
|
||||||
{
|
callsign_up = callsign.upper().strip()
|
||||||
"name": "RQ-170 Sentinel", "center": [25.0, 55.0], "radius": 1.5, "alt": 18000,
|
|
||||||
"country": "USA", "uav_type": "Stealth ISR", "range_km": 1100,
|
# Check ICAO type codes
|
||||||
"wiki": "https://en.wikipedia.org/wiki/Lockheed_Martin_RQ-170_Sentinel",
|
if model_up in _UAV_TYPE_CODES:
|
||||||
"speed_knots": 300
|
uav_type = "HALE Surveillance" if model_up in ("R4", "HALE") else "MALE ISR"
|
||||||
}
|
wiki = _UAV_WIKI.get(model_up, "")
|
||||||
]
|
return True, uav_type, wiki
|
||||||
|
|
||||||
# Use the current hour and minute to create a continuous slow orbit
|
# Check callsign prefixes (must also have a military-ish model)
|
||||||
now = datetime.utcnow()
|
for prefix in _UAV_CALLSIGN_PREFIXES:
|
||||||
# 1 full orbit every 10 minutes
|
if callsign_up.startswith(prefix):
|
||||||
time_factor = ((now.minute % 10) * 60 + now.second) / 600.0
|
uav_type = "HALE Surveillance" if prefix in ("FORTE", "GHAWK", "BAMS") else "MALE ISR"
|
||||||
angle = time_factor * 2 * math.pi
|
wiki = _UAV_WIKI.get(prefix, "")
|
||||||
|
if prefix == "FORTE":
|
||||||
uavs = []
|
wiki = _UAV_WIKI["RQ4"]
|
||||||
for idx, t in enumerate(uav_targets):
|
elif prefix == "BAMS":
|
||||||
# Offset the angle slightly so they aren't all synchronized
|
wiki = _UAV_WIKI["MQ4"]
|
||||||
offset_angle = angle + (idx * math.pi / 2.5)
|
return True, uav_type, wiki
|
||||||
|
|
||||||
lat = t["center"][0] + math.sin(offset_angle) * t["radius"]
|
# Check model keywords
|
||||||
lng = t["center"][1] + math.cos(offset_angle) * t["radius"]
|
for kw in _UAV_MODEL_KEYWORDS:
|
||||||
|
if kw in model_up:
|
||||||
heading = (math.degrees(offset_angle) + 90) % 360
|
# Determine type from keyword
|
||||||
|
if any(h in model_up for h in ("RQ4", "RQ-4", "GLOBALHAWK")):
|
||||||
uavs.append({
|
return True, "HALE Surveillance", _UAV_WIKI.get(kw, "")
|
||||||
"id": f"uav-{idx}",
|
elif any(h in model_up for h in ("MQ4", "MQ-4", "TRITON")):
|
||||||
"callsign": t["name"],
|
return True, "HALE Maritime Surveillance", _UAV_WIKI.get(kw, "")
|
||||||
"aircraft_model": t["name"],
|
elif any(h in model_up for h in ("MQ9", "MQ-9", "REAPER")):
|
||||||
"lat": lat,
|
return True, "MALE Strike/ISR", _UAV_WIKI.get(kw, "")
|
||||||
"lng": lng,
|
elif any(h in model_up for h in ("MQ1", "MQ-1", "PREDATOR")):
|
||||||
"alt": t["alt"],
|
return True, "MALE ISR/Strike", _UAV_WIKI.get(kw, "")
|
||||||
"heading": heading,
|
elif "BAYRAKTAR" in model_up or "TB2" in model_up:
|
||||||
"speed_knots": t["speed_knots"],
|
return True, "MALE Strike", _UAV_WIKI.get("BAYRAKTAR", "")
|
||||||
"center": t["center"],
|
elif "HERMES" in model_up:
|
||||||
"orbit_radius": t["radius"],
|
return True, "MALE ISR", _UAV_WIKI.get("HERMES", "")
|
||||||
"range_km": t["range_km"],
|
elif "HERON" in model_up:
|
||||||
"country": t["country"],
|
return True, "MALE ISR", _UAV_WIKI.get("HERON", "")
|
||||||
"uav_type": t["uav_type"],
|
return True, "MALE ISR", _UAV_WIKI.get(kw, "")
|
||||||
"wiki": t["wiki"],
|
|
||||||
})
|
return False, None, None
|
||||||
|
|
||||||
latest_data['uavs'] = uavs
|
|
||||||
|
|
||||||
cached_airports = []
|
cached_airports = []
|
||||||
flight_trails = {} # {icao_hex: {points: [[lat, lng, alt, ts], ...], last_seen: ts}}
|
flight_trails = {} # {icao_hex: {points: [[lat, lng, alt, ts], ...], last_seen: ts}}
|
||||||
@@ -1956,10 +2113,12 @@ def fetch_geopolitics():
|
|||||||
frontlines = fetch_ukraine_frontlines()
|
frontlines = fetch_ukraine_frontlines()
|
||||||
if frontlines:
|
if frontlines:
|
||||||
latest_data['frontlines'] = frontlines
|
latest_data['frontlines'] = frontlines
|
||||||
|
_mark_fresh("frontlines")
|
||||||
|
|
||||||
gdelt = fetch_global_military_incidents()
|
gdelt = fetch_global_military_incidents()
|
||||||
if gdelt is not None:
|
if gdelt is not None:
|
||||||
latest_data['gdelt'] = gdelt
|
latest_data['gdelt'] = gdelt
|
||||||
|
_mark_fresh("gdelt")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching geopolitics: {e}")
|
logger.error(f"Error fetching geopolitics: {e}")
|
||||||
|
|
||||||
@@ -1970,6 +2129,7 @@ def update_liveuamap():
|
|||||||
res = fetch_liveuamap()
|
res = fetch_liveuamap()
|
||||||
if res:
|
if res:
|
||||||
latest_data['liveuamap'] = res
|
latest_data['liveuamap'] = res
|
||||||
|
_mark_fresh("liveuamap")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Liveuamap scraper error: {e}")
|
logger.error(f"Liveuamap scraper error: {e}")
|
||||||
|
|
||||||
@@ -1978,9 +2138,8 @@ def update_fast_data():
|
|||||||
logger.info("Fast-tier data update starting...")
|
logger.info("Fast-tier data update starting...")
|
||||||
fast_funcs = [
|
fast_funcs = [
|
||||||
fetch_flights,
|
fetch_flights,
|
||||||
fetch_military_flights,
|
fetch_military_flights, # Also detects UAVs from ADS-B
|
||||||
fetch_ships,
|
fetch_ships,
|
||||||
fetch_uavs,
|
|
||||||
fetch_satellites,
|
fetch_satellites,
|
||||||
]
|
]
|
||||||
with concurrent.futures.ThreadPoolExecutor(max_workers=len(fast_funcs)) as executor:
|
with concurrent.futures.ThreadPoolExecutor(max_workers=len(fast_funcs)) as executor:
|
||||||
@@ -2005,6 +2164,7 @@ def update_slow_data():
|
|||||||
fetch_space_weather,
|
fetch_space_weather,
|
||||||
fetch_internet_outages,
|
fetch_internet_outages,
|
||||||
fetch_firms_fires,
|
fetch_firms_fires,
|
||||||
|
fetch_datacenters,
|
||||||
]
|
]
|
||||||
with concurrent.futures.ThreadPoolExecutor(max_workers=len(slow_funcs)) as executor:
|
with concurrent.futures.ThreadPoolExecutor(max_workers=len(slow_funcs)) as executor:
|
||||||
futures = [executor.submit(func) for func in slow_funcs]
|
futures = [executor.submit(func) for func in slow_funcs]
|
||||||
@@ -2030,7 +2190,7 @@ def start_scheduler():
|
|||||||
# Run full update once on startup
|
# Run full update once on startup
|
||||||
scheduler.add_job(update_all_data, 'date', run_date=datetime.now())
|
scheduler.add_job(update_all_data, 'date', run_date=datetime.now())
|
||||||
|
|
||||||
# Fast tier: every 60 seconds (flights, ships, military, satellites, UAVs)
|
# Fast tier: every 60 seconds (flights, ships, military+UAVs, satellites)
|
||||||
scheduler.add_job(update_fast_data, 'interval', seconds=60)
|
scheduler.add_job(update_fast_data, 'interval', seconds=60)
|
||||||
|
|
||||||
# Slow tier: every 30 minutes (news, stocks, weather, geopolitics)
|
# Slow tier: every 30 minutes (news, stocks, weather, geopolitics)
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""
|
||||||
|
News feed configuration — manages the user-customisable RSS feed list.
|
||||||
|
Feeds are stored in backend/config/news_feeds.json and persist across restarts.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
CONFIG_PATH = Path(__file__).parent.parent / "config" / "news_feeds.json"
|
||||||
|
MAX_FEEDS = 20
|
||||||
|
|
||||||
|
DEFAULT_FEEDS = [
|
||||||
|
{"name": "NPR", "url": "https://feeds.npr.org/1004/rss.xml", "weight": 4},
|
||||||
|
{"name": "BBC", "url": "http://feeds.bbci.co.uk/news/world/rss.xml", "weight": 3},
|
||||||
|
{"name": "AlJazeera", "url": "https://www.aljazeera.com/xml/rss/all.xml", "weight": 2},
|
||||||
|
{"name": "NYT", "url": "https://rss.nytimes.com/services/xml/rss/nyt/World.xml", "weight": 1},
|
||||||
|
{"name": "GDACS", "url": "https://www.gdacs.org/xml/rss.xml", "weight": 5},
|
||||||
|
{"name": "NHK", "url": "https://www3.nhk.or.jp/nhkworld/rss/world.xml", "weight": 3},
|
||||||
|
{"name": "CNA", "url": "https://www.channelnewsasia.com/rssfeed/8395986", "weight": 3},
|
||||||
|
{"name": "Mercopress", "url": "https://en.mercopress.com/rss/", "weight": 3},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_feeds() -> list[dict]:
|
||||||
|
"""Load feeds from config file, falling back to defaults."""
|
||||||
|
try:
|
||||||
|
if CONFIG_PATH.exists():
|
||||||
|
data = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
|
||||||
|
feeds = data.get("feeds", []) if isinstance(data, dict) else data
|
||||||
|
if isinstance(feeds, list) and len(feeds) > 0:
|
||||||
|
return feeds
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to read news feed config: {e}")
|
||||||
|
return list(DEFAULT_FEEDS)
|
||||||
|
|
||||||
|
|
||||||
|
def save_feeds(feeds: list[dict]) -> bool:
|
||||||
|
"""Validate and save feeds to config file. Returns True on success."""
|
||||||
|
if not isinstance(feeds, list):
|
||||||
|
return False
|
||||||
|
if len(feeds) > MAX_FEEDS:
|
||||||
|
return False
|
||||||
|
# Validate each feed entry
|
||||||
|
for f in feeds:
|
||||||
|
if not isinstance(f, dict):
|
||||||
|
return False
|
||||||
|
name = f.get("name", "").strip()
|
||||||
|
url = f.get("url", "").strip()
|
||||||
|
weight = f.get("weight", 3)
|
||||||
|
if not name or not url:
|
||||||
|
return False
|
||||||
|
if not isinstance(weight, (int, float)) or weight < 1 or weight > 5:
|
||||||
|
return False
|
||||||
|
# Normalise
|
||||||
|
f["name"] = name
|
||||||
|
f["url"] = url
|
||||||
|
f["weight"] = int(weight)
|
||||||
|
try:
|
||||||
|
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
CONFIG_PATH.write_text(
|
||||||
|
json.dumps({"feeds": feeds}, indent=2, ensure_ascii=False),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to write news feed config: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def reset_feeds() -> bool:
|
||||||
|
"""Reset feeds to defaults."""
|
||||||
|
return save_feeds(list(DEFAULT_FEEDS))
|
||||||
@@ -12,6 +12,8 @@ services:
|
|||||||
- OPENSKY_CLIENT_ID=${OPENSKY_CLIENT_ID}
|
- OPENSKY_CLIENT_ID=${OPENSKY_CLIENT_ID}
|
||||||
- OPENSKY_CLIENT_SECRET=${OPENSKY_CLIENT_SECRET}
|
- OPENSKY_CLIENT_SECRET=${OPENSKY_CLIENT_SECRET}
|
||||||
- LTA_ACCOUNT_KEY=${LTA_ACCOUNT_KEY}
|
- LTA_ACCOUNT_KEY=${LTA_ACCOUNT_KEY}
|
||||||
|
# Override allowed CORS origins (comma-separated). Auto-detects LAN IPs if empty.
|
||||||
|
- CORS_ORIGINS=${CORS_ORIGINS:-}
|
||||||
volumes:
|
volumes:
|
||||||
- backend_data:/app/data
|
- backend_data:/app/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -148,6 +148,7 @@ export default function Dashboard() {
|
|||||||
kiwisdr: false,
|
kiwisdr: false,
|
||||||
firms: false,
|
firms: false,
|
||||||
internet_outages: false,
|
internet_outages: false,
|
||||||
|
datacenters: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// NASA GIBS satellite imagery state
|
// NASA GIBS satellite imagery state
|
||||||
|
|||||||
@@ -2,43 +2,45 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { X, Flame, Sun, Wifi, Activity, Bug } from "lucide-react";
|
import { X, Rss, Server, Zap, Shield, Bug } from "lucide-react";
|
||||||
|
|
||||||
const CURRENT_VERSION = "0.5";
|
const CURRENT_VERSION = "0.6";
|
||||||
const STORAGE_KEY = `shadowbroker_changelog_v${CURRENT_VERSION}`;
|
const STORAGE_KEY = `shadowbroker_changelog_v${CURRENT_VERSION}`;
|
||||||
|
|
||||||
const NEW_FEATURES = [
|
const NEW_FEATURES = [
|
||||||
{
|
{
|
||||||
icon: <Flame size={14} className="text-orange-400" />,
|
icon: <Rss size={14} className="text-orange-400" />,
|
||||||
title: "NASA FIRMS Fire Hotspots (24h)",
|
title: "Custom News Feed Manager",
|
||||||
desc: "5,000+ global thermal anomalies from NOAA-20 VIIRS satellite. Flame-shaped icons color-coded by fire radiative power — yellow (low), orange, red, dark red (intense). Clusters show fire counts.",
|
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",
|
color: "orange",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <Sun size={14} className="text-yellow-400" />,
|
icon: <Server size={14} className="text-purple-400" />,
|
||||||
title: "Space Weather Badge",
|
title: "Global Data Center Map Layer",
|
||||||
desc: "Live NOAA geomagnetic storm indicator in the bottom status bar. Color-coded Kp index: green (quiet), yellow (active), red (storm G1-G5). Sourced from SWPC planetary K-index.",
|
desc: "2,000+ data centers plotted worldwide from a curated dataset. Click any DC for operator details — and if an internet outage is detected in the same country, the popup flags it automatically.",
|
||||||
|
color: "purple",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Zap size={14} className="text-yellow-400" />,
|
||||||
|
title: "Imperative Map Rendering",
|
||||||
|
desc: "High-volume layers (flights, satellites, fire hotspots) now bypass React reconciliation and update the map directly via setData(). Debounced updates on dense layers. Smoother panning and zooming under load.",
|
||||||
color: "yellow",
|
color: "yellow",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <Wifi size={14} className="text-gray-400" />,
|
icon: <Shield size={14} className="text-cyan-400" />,
|
||||||
title: "Internet Outage Monitoring",
|
title: "Enhanced Health Observability",
|
||||||
desc: "Regional internet connectivity alerts from Georgia Tech IODA. Grey markers show affected regions with severity percentage — powered by BGP and active probing data. No false positives.",
|
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: "gray",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: <Activity size={14} className="text-cyan-400" />,
|
|
||||||
title: "Enhanced Layer Differentiation",
|
|
||||||
desc: "Fire hotspots use distinct flame icons (not circles) to prevent confusion with Global Incidents. Internet outages use grey markers. Each layer is now instantly recognizable at a glance.",
|
|
||||||
color: "cyan",
|
color: "cyan",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const BUG_FIXES = [
|
const BUG_FIXES = [
|
||||||
"All data sourced from verified OSINT feeds — no fabricated or interpolated data points",
|
"Settings panel now has tabbed UI — API Keys and News Feeds on separate tabs",
|
||||||
"Internet outages filtered to reliable datasources only (BGP, ping) — no misleading telescope data",
|
"Data center coordinates fixed for 187 Southern Hemisphere entries (were mirrored north of equator)",
|
||||||
"Fire clusters use flame-shaped icons instead of circles for clear visual separation",
|
"Docker networking: CORS_ORIGINS env var properly passed through docker-compose",
|
||||||
"MapLibre font errors resolved — switched to Noto Sans (universally available)",
|
"Start scripts warn on Python 3.13+ compatibility issues before install",
|
||||||
|
"Satellite and fire hotspot layers debounced (2s) to prevent render thrashing",
|
||||||
|
"Entries with invalid geocoded coordinates automatically filtered out",
|
||||||
];
|
];
|
||||||
|
|
||||||
export function useChangelog() {
|
export function useChangelog() {
|
||||||
|
|||||||
@@ -94,8 +94,7 @@ const LEGEND: LegendCategory[] = [
|
|||||||
{ svg: airliner("yellow"), label: "Military — Standard" },
|
{ svg: airliner("yellow"), label: "Military — Standard" },
|
||||||
{ svg: plane("yellow"), label: "Fighter / Interceptor" },
|
{ svg: plane("yellow"), label: "Fighter / Interceptor" },
|
||||||
{ svg: heli("yellow"), label: "Military — Helicopter" },
|
{ svg: heli("yellow"), label: "Military — Helicopter" },
|
||||||
{ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="orange" stroke="black"><path d="M12 2L15 8H9L12 2Z" /><rect x="8" y="8" width="8" height="2" /><path d="M4 10L10 14H14L20 10V12L14 16H10L4 12V10Z" /><circle cx="12" cy="14" r="2" fill="red"/></svg>`, label: "UAV / Drone" },
|
{ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="orange" stroke="black"><path d="M12 2L15 8H9L12 2Z" /><rect x="8" y="8" width="8" height="2" /><path d="M4 10L10 14H14L20 10V12L14 16H10L4 12V10Z" /><circle cx="12" cy="14" r="2" fill="red"/></svg>`, label: "UAV / Drone (live ADS-B)" },
|
||||||
{ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="orange" stroke-width="1.5" stroke-dasharray="4 2" opacity="0.6"/><circle cx="12" cy="12" r="2" fill="orange"/></svg>`, label: "UAV Operational Range (dashed circle)" },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const svgHeliWhiteAlert = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xm
|
|||||||
const svgPlaneBlack = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="#222" stroke="#444"><path d="M21 16v-2l-8-5V3.5c0-.83-.67-1.5-1.5-1.5S10 2.67 10 3.5V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z" /></svg>`)}`;
|
const svgPlaneBlack = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="#222" stroke="#444"><path d="M21 16v-2l-8-5V3.5c0-.83-.67-1.5-1.5-1.5S10 2.67 10 3.5V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z" /></svg>`)}`;
|
||||||
const svgHeliBlack = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="#222" stroke="#444"><path d="M10 6L10 14L8 16L8 18L10 17L12 22L14 17L16 18L16 16L14 14L14 6C14 4 13 2 12 2C11 2 10 4 10 6Z"/><circle cx="12" cy="12" r="8" fill="none" stroke="#444" stroke-dasharray="2 2" stroke-width="1"/></svg>`)}`;
|
const svgHeliBlack = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="#222" stroke="#444"><path d="M10 6L10 14L8 16L8 18L10 17L12 22L14 17L16 18L16 16L14 14L14 6C14 4 13 2 12 2C11 2 10 4 10 6Z"/><circle cx="12" cy="12" r="8" fill="none" stroke="#444" stroke-dasharray="2 2" stroke-width="1"/></svg>`)}`;
|
||||||
const svgDrone = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="orange" stroke="black"><path d="M12 2L15 8H9L12 2Z" /><rect x="8" y="8" width="8" height="2" /><path d="M4 10L10 14H14L20 10V12L14 16H10L4 12V10Z" /><circle cx="12" cy="14" r="2" fill="red"/></svg>`)}`;
|
const svgDrone = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="orange" stroke="black"><path d="M12 2L15 8H9L12 2Z" /><rect x="8" y="8" width="8" height="2" /><path d="M4 10L10 14H14L20 10V12L14 16H10L4 12V10Z" /><circle cx="12" cy="14" r="2" fill="red"/></svg>`)}`;
|
||||||
|
const svgDataCenter = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#a78bfa" stroke-width="1.5"><rect x="3" y="3" width="18" height="6" rx="1" fill="#2e1065"/><rect x="3" y="11" width="18" height="6" rx="1" fill="#2e1065"/><circle cx="7" cy="6" r="1" fill="#a78bfa"/><circle cx="7" cy="14" r="1" fill="#a78bfa"/><line x1="11" y1="6" x2="17" y2="6" stroke="#a78bfa" stroke-width="1"/><line x1="11" y1="14" x2="17" y2="14" stroke="#a78bfa" stroke-width="1"/><line x1="12" y1="19" x2="12" y2="22" stroke="#a78bfa" stroke-width="1.5"/></svg>`)}`;
|
||||||
const svgShipGray = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="12" height="24" viewBox="0 0 24 24" fill="none"><path d="M6 20 L6 8 L12 2 L18 8 L18 20 C18 22 6 22 6 20 Z" fill="gray" stroke="#000" stroke-width="1"/><polygon points="12,6 16,16 8,16" fill="#fff" stroke="#000" stroke-width="1"/></svg>`)}`;
|
const svgShipGray = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="12" height="24" viewBox="0 0 24 24" fill="none"><path d="M6 20 L6 8 L12 2 L18 8 L18 20 C18 22 6 22 6 20 Z" fill="gray" stroke="#000" stroke-width="1"/><polygon points="12,6 16,16 8,16" fill="#fff" stroke="#000" stroke-width="1"/></svg>`)}`;
|
||||||
const svgShipRed = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="32" viewBox="0 0 24 24" fill="none"><path d="M6 22 L6 6 L12 2 L18 6 L18 22 Z" fill="#ff2222" stroke="#000" stroke-width="1"/><rect x="8" y="15" width="8" height="4" fill="#880000" stroke="#000" stroke-width="1"/><rect x="8" y="7" width="8" height="6" fill="#444" stroke="#000" stroke-width="1"/></svg>`)}`;
|
const svgShipRed = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="32" viewBox="0 0 24 24" fill="none"><path d="M6 22 L6 6 L12 2 L18 6 L18 22 Z" fill="#ff2222" stroke="#000" stroke-width="1"/><rect x="8" y="15" width="8" height="4" fill="#880000" stroke="#000" stroke-width="1"/><rect x="8" y="7" width="8" height="6" fill="#444" stroke="#000" stroke-width="1"/></svg>`)}`;
|
||||||
const svgShipYellow = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="14" height="34" viewBox="0 0 24 24" fill="none"><path d="M7 22 L7 6 L12 1 L17 6 L17 22 Z" fill="yellow" stroke="#000" stroke-width="1"/><rect x="9" y="8" width="6" height="8" fill="#555" stroke="#000" stroke-width="1"/><circle cx="12" cy="18" r="1.5" fill="#000"/><line x1="12" y1="18" x2="12" y2="24" stroke="#000" stroke-width="1.5"/></svg>`)}`;
|
const svgShipYellow = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="14" height="34" viewBox="0 0 24 24" fill="none"><path d="M7 22 L7 6 L12 1 L17 6 L17 22 Z" fill="yellow" stroke="#000" stroke-width="1"/><rect x="9" y="8" width="6" height="8" fill="#555" stroke="#000" stroke-width="1"/><circle cx="12" cy="18" r="1.5" fill="#000"/><line x1="12" y1="18" x2="12" y2="24" stroke="#000" stroke-width="1.5"/></svg>`)}`;
|
||||||
@@ -228,8 +229,34 @@ const MISSION_ICON_MAP: Record<string, string> = {
|
|||||||
'commercial_imaging': 'sat-com', 'space_station': 'sat-station'
|
'commercial_imaging': 'sat-com', 'space_station': 'sat-station'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Empty GeoJSON constant — avoids recreating empty objects on every render
|
||||||
|
const EMPTY_FC: GeoJSON.FeatureCollection = { type: 'FeatureCollection', features: [] };
|
||||||
|
|
||||||
|
// Imperatively push GeoJSON data to a MapLibre source, bypassing React reconciliation.
|
||||||
|
// This is critical for high-volume layers (flights, ships, satellites, fires) where
|
||||||
|
// React's prop diffing on thousands of coordinate arrays causes memory pressure.
|
||||||
|
function useImperativeSource(map: MapRef | null, sourceId: string, geojson: any, debounceMs = 0) {
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!map) return;
|
||||||
|
const push = () => {
|
||||||
|
const src = map.getSource(sourceId) as any;
|
||||||
|
if (src && typeof src.setData === 'function') {
|
||||||
|
src.setData(geojson || EMPTY_FC);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (debounceMs > 0) {
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = setTimeout(push, debounceMs);
|
||||||
|
return () => { if (timerRef.current) clearTimeout(timerRef.current); };
|
||||||
|
}
|
||||||
|
push();
|
||||||
|
}, [map, sourceId, geojson, debounceMs]);
|
||||||
|
}
|
||||||
|
|
||||||
const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, selectedEntity, onMouseCoords, onRightClick, regionDossier, regionDossierLoading, onViewStateChange, measureMode, onMeasureClick, measurePoints, gibsDate, gibsOpacity }: any) => {
|
const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, selectedEntity, onMouseCoords, onRightClick, regionDossier, regionDossierLoading, onViewStateChange, measureMode, onMeasureClick, measurePoints, gibsDate, gibsOpacity }: any) => {
|
||||||
const mapRef = useRef<MapRef>(null);
|
const mapRef = useRef<MapRef>(null);
|
||||||
|
const [mapReady, setMapReady] = useState(false);
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const mapThemeStyle = useMemo(() => theme === 'light' ? lightStyle : darkStyle, [theme]);
|
const mapThemeStyle = useMemo(() => theme === 'light' ? lightStyle : darkStyle, [theme]);
|
||||||
|
|
||||||
@@ -498,6 +525,25 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
};
|
};
|
||||||
}, [activeLayers.internet_outages, data?.internet_outages]);
|
}, [activeLayers.internet_outages, data?.internet_outages]);
|
||||||
|
|
||||||
|
const dataCentersGeoJSON = useMemo(() => {
|
||||||
|
if (!activeLayers.datacenters || !data?.datacenters?.length) return null;
|
||||||
|
return {
|
||||||
|
type: 'FeatureCollection' as const,
|
||||||
|
features: data.datacenters.map((dc: any, i: number) => ({
|
||||||
|
type: 'Feature' as const,
|
||||||
|
properties: {
|
||||||
|
id: `dc-${i}`,
|
||||||
|
type: 'datacenter',
|
||||||
|
name: dc.name || 'Unknown',
|
||||||
|
company: dc.company || '',
|
||||||
|
city: dc.city || '',
|
||||||
|
country: dc.country || '',
|
||||||
|
},
|
||||||
|
geometry: { type: 'Point' as const, coordinates: [dc.lng, dc.lat] }
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}, [activeLayers.datacenters, data?.datacenters]);
|
||||||
|
|
||||||
// Load Images into the Map Style once loaded
|
// Load Images into the Map Style once loaded
|
||||||
const onMapLoad = useCallback((e: any) => {
|
const onMapLoad = useCallback((e: any) => {
|
||||||
const map = e.target;
|
const map = e.target;
|
||||||
@@ -611,6 +657,9 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
loadImg('fire-cluster-lg', svgFireClusterLarge);
|
loadImg('fire-cluster-lg', svgFireClusterLarge);
|
||||||
loadImg('fire-cluster-xl', svgFireClusterXL);
|
loadImg('fire-cluster-xl', svgFireClusterXL);
|
||||||
|
|
||||||
|
// Data center icon
|
||||||
|
loadImg('datacenter', svgDataCenter);
|
||||||
|
|
||||||
// Satellite mission-type icons
|
// Satellite mission-type icons
|
||||||
loadImg('sat-mil', makeSatSvg('#ff3333'));
|
loadImg('sat-mil', makeSatSvg('#ff3333'));
|
||||||
loadImg('sat-sar', makeSatSvg('#00e5ff'));
|
loadImg('sat-sar', makeSatSvg('#00e5ff'));
|
||||||
@@ -620,6 +669,8 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
loadImg('sat-com', makeSatSvg('#44ff44'));
|
loadImg('sat-com', makeSatSvg('#44ff44'));
|
||||||
loadImg('sat-station', makeSatSvg('#ffdd00'));
|
loadImg('sat-station', makeSatSvg('#ffdd00'));
|
||||||
loadImg('sat-gen', makeSatSvg('#aaaaaa'));
|
loadImg('sat-gen', makeSatSvg('#aaaaaa'));
|
||||||
|
|
||||||
|
setMapReady(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Build a set of tracked icao24s to exclude from other flight layers
|
// Build a set of tracked icao24s to exclude from other flight layers
|
||||||
@@ -1140,7 +1191,7 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
return {
|
return {
|
||||||
type: 'Feature',
|
type: 'Feature',
|
||||||
properties: {
|
properties: {
|
||||||
id: uav.id || i,
|
id: uav.id || `uav-${i}`,
|
||||||
type: 'uav',
|
type: 'uav',
|
||||||
callsign: uav.callsign,
|
callsign: uav.callsign,
|
||||||
rotation: uav.heading || 0,
|
rotation: uav.heading || 0,
|
||||||
@@ -1149,9 +1200,11 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
country: uav.country || '',
|
country: uav.country || '',
|
||||||
uav_type: uav.uav_type || '',
|
uav_type: uav.uav_type || '',
|
||||||
alt: uav.alt || 0,
|
alt: uav.alt || 0,
|
||||||
range_km: uav.range_km || 0,
|
|
||||||
wiki: uav.wiki || '',
|
wiki: uav.wiki || '',
|
||||||
speed_knots: uav.speed_knots || 0
|
speed_knots: uav.speed_knots || 0,
|
||||||
|
icao24: uav.icao24 || '',
|
||||||
|
registration: uav.registration || '',
|
||||||
|
squawk: uav.squawk || '',
|
||||||
},
|
},
|
||||||
geometry: { type: 'Point', coordinates: [uav.lng, uav.lat] }
|
geometry: { type: 'Point', coordinates: [uav.lng, uav.lat] }
|
||||||
};
|
};
|
||||||
@@ -1159,31 +1212,7 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
};
|
};
|
||||||
}, [activeLayers.military, data?.uavs, inView]);
|
}, [activeLayers.military, data?.uavs, inView]);
|
||||||
|
|
||||||
// UAV operational range circle — only for the selected UAV
|
// UAV range circles removed — real ADS-B drones don't have a fixed orbit center
|
||||||
const uavRangeGeoJSON = useMemo(() => {
|
|
||||||
if (!activeLayers.military || !data?.uavs || selectedEntity?.type !== 'uav') return null;
|
|
||||||
const uav = data.uavs.find((u: any) => u.id === selectedEntity.id);
|
|
||||||
if (!uav?.center || !uav?.range_km) return null;
|
|
||||||
const R = 6371;
|
|
||||||
const rangeDeg = uav.range_km / R * (180 / Math.PI);
|
|
||||||
const centerLat = uav.center[0];
|
|
||||||
const centerLng = uav.center[1];
|
|
||||||
const coords: number[][] = [];
|
|
||||||
for (let i = 0; i <= 64; i++) {
|
|
||||||
const angle = (i / 64) * 2 * Math.PI;
|
|
||||||
const lat = centerLat + rangeDeg * Math.sin(angle);
|
|
||||||
const lng = centerLng + rangeDeg * Math.cos(angle) / Math.cos(centerLat * Math.PI / 180);
|
|
||||||
coords.push([lng, lat]);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
type: 'FeatureCollection' as const,
|
|
||||||
features: [{
|
|
||||||
type: 'Feature' as const,
|
|
||||||
properties: { name: uav.callsign, range_km: uav.range_km },
|
|
||||||
geometry: { type: 'Polygon' as const, coordinates: [coords] }
|
|
||||||
}]
|
|
||||||
};
|
|
||||||
}, [activeLayers.military, data?.uavs, selectedEntity]);
|
|
||||||
|
|
||||||
const gdeltGeoJSON = useMemo(() => {
|
const gdeltGeoJSON = useMemo(() => {
|
||||||
if (!activeLayers.global_incidents || !data?.gdelt) return null;
|
if (!activeLayers.global_incidents || !data?.gdelt) return null;
|
||||||
@@ -1243,10 +1272,26 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
cctvGeoJSON && 'cctv-layer',
|
cctvGeoJSON && 'cctv-layer',
|
||||||
kiwisdrGeoJSON && 'kiwisdr-layer',
|
kiwisdrGeoJSON && 'kiwisdr-layer',
|
||||||
internetOutagesGeoJSON && 'internet-outages-layer',
|
internetOutagesGeoJSON && 'internet-outages-layer',
|
||||||
|
dataCentersGeoJSON && 'datacenters-layer',
|
||||||
firmsGeoJSON && 'firms-viirs-layer'
|
firmsGeoJSON && 'firms-viirs-layer'
|
||||||
].filter(Boolean) as string[];
|
].filter(Boolean) as string[];
|
||||||
|
|
||||||
|
|
||||||
|
// --- Imperative source updates for high-volume layers ---
|
||||||
|
// Bypasses React reconciliation of huge GeoJSON FeatureCollections.
|
||||||
|
// The <Source data={EMPTY_FC}> mounts the source; the hook pushes real data.
|
||||||
|
const mapForHook = mapReady ? mapRef.current : null;
|
||||||
|
// Flights & UAVs: immediate (they move fast, stale = visually wrong)
|
||||||
|
useImperativeSource(mapForHook, 'commercial-flights', commFlightsGeoJSON);
|
||||||
|
useImperativeSource(mapForHook, 'private-flights', privFlightsGeoJSON);
|
||||||
|
useImperativeSource(mapForHook, 'private-jets', privJetsGeoJSON);
|
||||||
|
useImperativeSource(mapForHook, 'military-flights', milFlightsGeoJSON);
|
||||||
|
useImperativeSource(mapForHook, 'tracked-flights', trackedFlightsGeoJSON);
|
||||||
|
useImperativeSource(mapForHook, 'uavs', uavGeoJSON);
|
||||||
|
// Satellites & fires: 2s debounce (slow-changing, high feature count)
|
||||||
|
useImperativeSource(mapForHook, 'satellites', satellitesGeoJSON, 2000);
|
||||||
|
useImperativeSource(mapForHook, 'firms-fires', firmsGeoJSON, 2000);
|
||||||
|
|
||||||
const handleMouseMove = useCallback((evt: any) => {
|
const handleMouseMove = useCallback((evt: any) => {
|
||||||
if (onMouseCoords) onMouseCoords({ lat: evt.lngLat.lat, lng: evt.lngLat.lng });
|
if (onMouseCoords) onMouseCoords({ lat: evt.lngLat.lat, lng: evt.lngLat.lng });
|
||||||
}, [onMouseCoords]);
|
}, [onMouseCoords]);
|
||||||
@@ -1348,8 +1393,8 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* NASA FIRMS VIIRS — fire hotspot icons from FIRMS CSV feed */}
|
{/* NASA FIRMS VIIRS — fire hotspot icons from FIRMS CSV feed */}
|
||||||
{firmsGeoJSON && (
|
{/* firms-fires: data pushed imperatively via useImperativeSource */}
|
||||||
<Source id="firms-fires" type="geojson" data={firmsGeoJSON as any} cluster={true} clusterRadius={40} clusterMaxZoom={10}>
|
<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 */}
|
{/* Cluster fire icons — flame shape to differentiate from Global Incidents circles */}
|
||||||
<Layer
|
<Layer
|
||||||
id="firms-clusters"
|
id="firms-clusters"
|
||||||
@@ -1391,7 +1436,6 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Source>
|
</Source>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* SOLAR TERMINATOR — night overlay */}
|
{/* SOLAR TERMINATOR — night overlay */}
|
||||||
{activeLayers.day_night && nightGeoJSON && (
|
{activeLayers.day_night && nightGeoJSON && (
|
||||||
@@ -1407,8 +1451,8 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
</Source>
|
</Source>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{commFlightsGeoJSON && (
|
{/* commercial/private/military flights: data pushed imperatively */}
|
||||||
<Source id="commercial-flights" type="geojson" data={commFlightsGeoJSON as any}>
|
<Source id="commercial-flights" type="geojson" data={EMPTY_FC as any}>
|
||||||
<Layer
|
<Layer
|
||||||
id="commercial-flights-layer"
|
id="commercial-flights-layer"
|
||||||
type="symbol"
|
type="symbol"
|
||||||
@@ -1422,10 +1466,8 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
paint={{ 'icon-opacity': opacityFilter }}
|
paint={{ 'icon-opacity': opacityFilter }}
|
||||||
/>
|
/>
|
||||||
</Source>
|
</Source>
|
||||||
)}
|
|
||||||
|
|
||||||
{privFlightsGeoJSON && (
|
<Source id="private-flights" type="geojson" data={EMPTY_FC as any}>
|
||||||
<Source id="private-flights" type="geojson" data={privFlightsGeoJSON as any}>
|
|
||||||
<Layer
|
<Layer
|
||||||
id="private-flights-layer"
|
id="private-flights-layer"
|
||||||
type="symbol"
|
type="symbol"
|
||||||
@@ -1439,10 +1481,8 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
paint={{ 'icon-opacity': opacityFilter }}
|
paint={{ 'icon-opacity': opacityFilter }}
|
||||||
/>
|
/>
|
||||||
</Source>
|
</Source>
|
||||||
)}
|
|
||||||
|
|
||||||
{privJetsGeoJSON && (
|
<Source id="private-jets" type="geojson" data={EMPTY_FC as any}>
|
||||||
<Source id="private-jets" type="geojson" data={privJetsGeoJSON as any}>
|
|
||||||
<Layer
|
<Layer
|
||||||
id="private-jets-layer"
|
id="private-jets-layer"
|
||||||
type="symbol"
|
type="symbol"
|
||||||
@@ -1456,10 +1496,8 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
paint={{ 'icon-opacity': opacityFilter }}
|
paint={{ 'icon-opacity': opacityFilter }}
|
||||||
/>
|
/>
|
||||||
</Source>
|
</Source>
|
||||||
)}
|
|
||||||
|
|
||||||
{milFlightsGeoJSON && (
|
<Source id="military-flights" type="geojson" data={EMPTY_FC as any}>
|
||||||
<Source id="military-flights" type="geojson" data={milFlightsGeoJSON as any}>
|
|
||||||
<Layer
|
<Layer
|
||||||
id="military-flights-layer"
|
id="military-flights-layer"
|
||||||
type="symbol"
|
type="symbol"
|
||||||
@@ -1473,7 +1511,6 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
paint={{ 'icon-opacity': opacityFilter }}
|
paint={{ 'icon-opacity': opacityFilter }}
|
||||||
/>
|
/>
|
||||||
</Source>
|
</Source>
|
||||||
)}
|
|
||||||
|
|
||||||
{shipsGeoJSON && (
|
{shipsGeoJSON && (
|
||||||
<Source
|
<Source
|
||||||
@@ -1589,8 +1626,8 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
</Source>
|
</Source>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{trackedFlightsGeoJSON && (
|
{/* tracked-flights & UAVs: data pushed imperatively */}
|
||||||
<Source id="tracked-flights" type="geojson" data={trackedFlightsGeoJSON as any}>
|
<Source id="tracked-flights" type="geojson" data={EMPTY_FC as any}>
|
||||||
<Layer
|
<Layer
|
||||||
id="tracked-flights-layer"
|
id="tracked-flights-layer"
|
||||||
type="symbol"
|
type="symbol"
|
||||||
@@ -1604,10 +1641,8 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
paint={{ 'icon-opacity': opacityFilter }}
|
paint={{ 'icon-opacity': opacityFilter }}
|
||||||
/>
|
/>
|
||||||
</Source>
|
</Source>
|
||||||
)}
|
|
||||||
|
|
||||||
{uavGeoJSON && (
|
<Source id="uavs" type="geojson" data={EMPTY_FC as any}>
|
||||||
<Source id="uavs" type="geojson" data={uavGeoJSON as any}>
|
|
||||||
<Layer
|
<Layer
|
||||||
id="uav-layer"
|
id="uav-layer"
|
||||||
type="symbol"
|
type="symbol"
|
||||||
@@ -1621,31 +1656,8 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
paint={{ 'icon-opacity': opacityFilter }}
|
paint={{ 'icon-opacity': opacityFilter }}
|
||||||
/>
|
/>
|
||||||
</Source>
|
</Source>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* UAV Operational Range Circles */}
|
{/* UAV range circles removed — real ADS-B data has no fixed orbit */}
|
||||||
{uavRangeGeoJSON && (
|
|
||||||
<Source id="uav-ranges" type="geojson" data={uavRangeGeoJSON as any}>
|
|
||||||
<Layer
|
|
||||||
id="uav-range-fill"
|
|
||||||
type="fill"
|
|
||||||
paint={{
|
|
||||||
'fill-color': '#ff4444',
|
|
||||||
'fill-opacity': 0.04
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Layer
|
|
||||||
id="uav-range-border"
|
|
||||||
type="line"
|
|
||||||
paint={{
|
|
||||||
'line-color': '#ff4444',
|
|
||||||
'line-width': 1,
|
|
||||||
'line-opacity': 0.3,
|
|
||||||
'line-dasharray': [4, 4]
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Source>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{gdeltGeoJSON && (
|
{gdeltGeoJSON && (
|
||||||
<Source id="gdelt" type="geojson" data={gdeltGeoJSON as any}>
|
<Source id="gdelt" type="geojson" data={gdeltGeoJSON as any}>
|
||||||
@@ -1704,15 +1716,22 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* HTML labels for carriers (orange names) */}
|
{/* HTML labels for carriers (orange names, with ESTIMATED badge for OSINT positions) */}
|
||||||
{carriersGeoJSON && !selectedEntity && data?.ships?.map((s: any, i: number) => {
|
{carriersGeoJSON && !selectedEntity && data?.ships?.map((s: any, i: number) => {
|
||||||
if (s.type !== 'carrier' || s.lat == null || s.lng == null) return null;
|
if (s.type !== 'carrier' || s.lat == null || s.lng == null) return null;
|
||||||
if (!inView(s.lat, s.lng)) return null;
|
if (!inView(s.lat, s.lng)) return null;
|
||||||
const [iLng, iLat] = interpShip(s);
|
const [iLng, iLat] = interpShip(s);
|
||||||
return (
|
return (
|
||||||
<Marker key={`carrier-label-${i}`} longitude={iLng} latitude={iLat} anchor="top" offset={[0, 12]} style={{ zIndex: 2 }}>
|
<Marker key={`carrier-label-${i}`} longitude={iLng} latitude={iLat} anchor="top" offset={[0, 12]} style={{ zIndex: 2 }}>
|
||||||
<div style={{ color: '#ffaa00', fontSize: '11px', fontFamily: 'monospace', fontWeight: 'bold', textShadow: '0 0 3px #000, 0 0 3px #000, 1px 1px 2px #000', whiteSpace: 'nowrap', pointerEvents: 'none' }}>
|
<div style={{ fontFamily: 'monospace', textShadow: '0 0 3px #000, 0 0 3px #000, 1px 1px 2px #000', whiteSpace: 'nowrap', pointerEvents: 'none', textAlign: 'center' }}>
|
||||||
[[{s.name}]]
|
<div style={{ color: '#ffaa00', fontSize: '11px', fontWeight: 'bold' }}>
|
||||||
|
[[{s.name}]]
|
||||||
|
</div>
|
||||||
|
{s.estimated && (
|
||||||
|
<div style={{ color: '#ff6644', fontSize: '8px', letterSpacing: '1.5px' }}>
|
||||||
|
EST. POSITION — OSINT
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Marker>
|
</Marker>
|
||||||
);
|
);
|
||||||
@@ -2114,9 +2133,64 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
</Source>
|
</Source>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Data Center positions */}
|
||||||
|
{dataCentersGeoJSON && (
|
||||||
|
<Source id="datacenters" type="geojson" data={dataCentersGeoJSON as any} cluster={true} clusterRadius={30} clusterMaxZoom={8}>
|
||||||
|
{/* Cluster circles */}
|
||||||
|
<Layer
|
||||||
|
id="datacenters-clusters"
|
||||||
|
type="circle"
|
||||||
|
filter={['has', 'point_count']}
|
||||||
|
paint={{
|
||||||
|
'circle-color': '#7c3aed',
|
||||||
|
'circle-radius': ['step', ['get', 'point_count'], 12, 10, 16, 50, 20],
|
||||||
|
'circle-opacity': 0.7,
|
||||||
|
'circle-stroke-width': 1,
|
||||||
|
'circle-stroke-color': '#a78bfa',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Layer
|
||||||
|
id="datacenters-cluster-count"
|
||||||
|
type="symbol"
|
||||||
|
filter={['has', 'point_count']}
|
||||||
|
layout={{
|
||||||
|
'text-field': '{point_count_abbreviated}',
|
||||||
|
'text-font': ['Noto Sans Bold'],
|
||||||
|
'text-size': 10,
|
||||||
|
'text-allow-overlap': true,
|
||||||
|
}}
|
||||||
|
paint={{
|
||||||
|
'text-color': '#e9d5ff',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* Individual DC icons */}
|
||||||
|
<Layer
|
||||||
|
id="datacenters-layer"
|
||||||
|
type="symbol"
|
||||||
|
filter={['!', ['has', 'point_count']]}
|
||||||
|
layout={{
|
||||||
|
'icon-image': 'datacenter',
|
||||||
|
'icon-size': ['interpolate', ['linear'], ['zoom'], 2, 0.5, 6, 0.7, 10, 1.0],
|
||||||
|
'icon-allow-overlap': true,
|
||||||
|
'text-field': ['step', ['zoom'], '', 6, ['get', 'name']],
|
||||||
|
'text-font': ['Noto Sans Regular'],
|
||||||
|
'text-size': 9,
|
||||||
|
'text-offset': [0, 1.2],
|
||||||
|
'text-anchor': 'top',
|
||||||
|
'text-allow-overlap': false,
|
||||||
|
}}
|
||||||
|
paint={{
|
||||||
|
'text-color': '#c4b5fd',
|
||||||
|
'text-halo-color': 'rgba(0,0,0,0.9)',
|
||||||
|
'text-halo-width': 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Source>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Satellite positions — mission-type icons */}
|
{/* Satellite positions — mission-type icons */}
|
||||||
{satellitesGeoJSON && (
|
{/* satellites: data pushed imperatively */}
|
||||||
<Source id="satellites" type="geojson" data={satellitesGeoJSON as any}>
|
<Source id="satellites" type="geojson" data={EMPTY_FC as any}>
|
||||||
<Layer
|
<Layer
|
||||||
id="satellites-layer"
|
id="satellites-layer"
|
||||||
type="symbol"
|
type="symbol"
|
||||||
@@ -2133,7 +2207,6 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Source>
|
</Source>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Satellite click popup */}
|
{/* Satellite click popup */}
|
||||||
{selectedEntity?.type === 'satellite' && (() => {
|
{selectedEntity?.type === 'satellite' && (() => {
|
||||||
@@ -2192,7 +2265,7 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
{/* UAV click popup */}
|
{/* UAV click popup — real ADS-B detected drones */}
|
||||||
{selectedEntity?.type === 'uav' && (() => {
|
{selectedEntity?.type === 'uav' && (() => {
|
||||||
const uav = data?.uavs?.find((u: any) => u.id === selectedEntity.id);
|
const uav = data?.uavs?.find((u: any) => u.id === selectedEntity.id);
|
||||||
if (!uav) return null;
|
if (!uav) return null;
|
||||||
@@ -2209,16 +2282,29 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
fontFamily: 'monospace', fontSize: 11, minWidth: 220, maxWidth: 320
|
fontFamily: 'monospace', fontSize: 11, minWidth: 220, maxWidth: 320
|
||||||
}}>
|
}}>
|
||||||
<div style={{ color: '#ff4444', fontWeight: 700, fontSize: 13, marginBottom: 6, letterSpacing: 1 }}>
|
<div style={{ color: '#ff4444', fontWeight: 700, fontSize: 13, marginBottom: 6, letterSpacing: 1 }}>
|
||||||
✈️ {uav.callsign}
|
{uav.callsign}
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ color: '#ff8844', fontSize: 9, marginBottom: 6, letterSpacing: 1.5, textTransform: 'uppercase' as const }}>
|
||||||
|
LIVE ADS-B TRANSPONDER
|
||||||
|
</div>
|
||||||
|
{uav.aircraft_model && (
|
||||||
|
<div style={{ marginBottom: 4 }}>
|
||||||
|
Model: <span style={{ color: '#fff' }}>{uav.aircraft_model}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{uav.uav_type && (
|
{uav.uav_type && (
|
||||||
<div style={{ marginBottom: 4 }}>
|
<div style={{ marginBottom: 4 }}>
|
||||||
Type: <span style={{ color: '#ffcc00' }}>{uav.uav_type}</span>
|
Classification: <span style={{ color: '#ffcc00' }}>{uav.uav_type}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{uav.country && (
|
{uav.country && (
|
||||||
<div style={{ marginBottom: 4 }}>
|
<div style={{ marginBottom: 4 }}>
|
||||||
Country: <span style={{ color: '#fff' }}>{uav.country}</span>
|
Registration: <span style={{ color: '#fff' }}>{uav.country}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{uav.icao24 && (
|
||||||
|
<div style={{ marginBottom: 4 }}>
|
||||||
|
ICAO: <span style={{ color: '#888' }}>{uav.icao24}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div style={{ marginBottom: 4 }}>
|
<div style={{ marginBottom: 4 }}>
|
||||||
@@ -2229,9 +2315,9 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
Speed: <span style={{ color: '#00e5ff' }}>{uav.speed_knots} kn</span>
|
Speed: <span style={{ color: '#00e5ff' }}>{uav.speed_knots} kn</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{uav.range_km > 0 && (
|
{uav.squawk && (
|
||||||
<div style={{ marginBottom: 4 }}>
|
<div style={{ marginBottom: 4 }}>
|
||||||
Operational Range: <span style={{ color: '#ff8844' }}>{uav.range_km?.toLocaleString()} km</span>
|
Squawk: <span style={{ color: '#888' }}>{uav.squawk}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{uav.wiki && (
|
{uav.wiki && (
|
||||||
@@ -2243,6 +2329,135 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
</Popup>
|
</Popup>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
|
{/* Ship / carrier click popup */}
|
||||||
|
{selectedEntity?.type === 'ship' && (() => {
|
||||||
|
const ship = data?.ships?.[selectedEntity.id as number];
|
||||||
|
if (!ship) return null;
|
||||||
|
const [iLng, iLat] = interpShip(ship);
|
||||||
|
return (
|
||||||
|
<Popup
|
||||||
|
longitude={iLng} latitude={iLat}
|
||||||
|
closeButton={false} closeOnClick={false}
|
||||||
|
onClose={() => onEntityClick?.(null)}
|
||||||
|
anchor="bottom" offset={12}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
background: 'rgba(10,14,26,0.95)', border: `1px solid ${ship.type === 'carrier' ? 'rgba(255,170,0,0.5)' : 'rgba(59,130,246,0.4)'}`,
|
||||||
|
borderRadius: 6, padding: '10px 14px', color: '#e0e6f0',
|
||||||
|
fontFamily: 'monospace', fontSize: 11, minWidth: 220, maxWidth: 320
|
||||||
|
}}>
|
||||||
|
<div className="flex justify-between items-start mb-1">
|
||||||
|
<div style={{ color: ship.type === 'carrier' ? '#ffaa00' : '#3b82f6', fontWeight: 700, fontSize: 13, letterSpacing: 1 }}>
|
||||||
|
{ship.name || 'UNKNOWN VESSEL'}
|
||||||
|
</div>
|
||||||
|
<button onClick={() => onEntityClick?.(null)} className="text-[var(--text-secondary)] hover:text-[var(--text-primary)] ml-2">✕</button>
|
||||||
|
</div>
|
||||||
|
{ship.estimated && (
|
||||||
|
<div style={{ color: '#ff6644', fontSize: 9, marginBottom: 6, letterSpacing: 1.5, textTransform: 'uppercase' as const, borderBottom: '1px solid rgba(255,102,68,0.3)', paddingBottom: 4 }}>
|
||||||
|
ESTIMATED POSITION — {ship.source || 'OSINT DERIVED'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{ship.type && (
|
||||||
|
<div style={{ marginBottom: 4 }}>
|
||||||
|
Type: <span style={{ color: '#fff', textTransform: 'capitalize' as const }}>{ship.type.replace('_', ' ')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{ship.mmsi && (
|
||||||
|
<div style={{ marginBottom: 4 }}>
|
||||||
|
MMSI: <span style={{ color: '#888' }}>{ship.mmsi}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{ship.imo && (
|
||||||
|
<div style={{ marginBottom: 4 }}>
|
||||||
|
IMO: <span style={{ color: '#888' }}>{ship.imo}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{ship.callsign && (
|
||||||
|
<div style={{ marginBottom: 4 }}>
|
||||||
|
Callsign: <span style={{ color: '#00e5ff' }}>{ship.callsign}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{ship.country && (
|
||||||
|
<div style={{ marginBottom: 4 }}>
|
||||||
|
Flag: <span style={{ color: '#fff' }}>{ship.country}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{ship.destination && (
|
||||||
|
<div style={{ marginBottom: 4 }}>
|
||||||
|
Destination: <span style={{ color: '#44ff88' }}>{ship.destination}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{typeof ship.sog === 'number' && ship.sog > 0 && (
|
||||||
|
<div style={{ marginBottom: 4 }}>
|
||||||
|
Speed: <span style={{ color: '#00e5ff' }}>{ship.sog.toFixed(1)} kn</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{ship.heading != null && (
|
||||||
|
<div style={{ marginBottom: 4 }}>
|
||||||
|
Heading: <span style={{ color: '#888' }}>{Math.round(ship.heading)}°</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{ship.last_osint_update && (
|
||||||
|
<div style={{ marginBottom: 4 }}>
|
||||||
|
Last OSINT Update: <span style={{ color: '#888' }}>{new Date(ship.last_osint_update).toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Popup>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* Data Center click popup */}
|
||||||
|
{selectedEntity?.type === 'datacenter' && (() => {
|
||||||
|
const dc = data?.datacenters?.find((_: any, i: number) => `dc-${i}` === selectedEntity.id);
|
||||||
|
if (!dc) return null;
|
||||||
|
// Check if any internet outage is in the same country
|
||||||
|
const outagesInCountry = (data?.internet_outages || []).filter((o: any) =>
|
||||||
|
o.country_name && dc.country && o.country_name.toLowerCase() === dc.country.toLowerCase()
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Popup
|
||||||
|
longitude={dc.lng}
|
||||||
|
latitude={dc.lat}
|
||||||
|
closeButton={false}
|
||||||
|
closeOnClick={false}
|
||||||
|
onClose={() => onEntityClick?.(null)}
|
||||||
|
className="threat-popup"
|
||||||
|
maxWidth="280px"
|
||||||
|
>
|
||||||
|
<div style={{ background: '#1a1035', padding: '10px 14px', borderRadius: 8, border: '1px solid rgba(167,139,250,0.4)', fontFamily: 'monospace', fontSize: 11, color: '#e9d5ff', minWidth: 200 }}>
|
||||||
|
<div style={{ fontWeight: 'bold', fontSize: 13, color: '#a78bfa', marginBottom: 6, borderBottom: '1px solid rgba(167,139,250,0.2)', paddingBottom: 4 }}>
|
||||||
|
{dc.name}
|
||||||
|
</div>
|
||||||
|
{dc.company && (
|
||||||
|
<div style={{ marginBottom: 4 }}>
|
||||||
|
Operator: <span style={{ color: '#c4b5fd' }}>{dc.company}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{dc.city && (
|
||||||
|
<div style={{ marginBottom: 4 }}>
|
||||||
|
Location: <span style={{ color: '#fff' }}>{dc.city}{dc.country ? `, ${dc.country}` : ''}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!dc.city && dc.country && (
|
||||||
|
<div style={{ marginBottom: 4 }}>
|
||||||
|
Country: <span style={{ color: '#fff' }}>{dc.country}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{outagesInCountry.length > 0 && (
|
||||||
|
<div style={{ marginTop: 6, padding: '4px 8px', background: 'rgba(255,0,0,0.15)', border: '1px solid rgba(255,80,80,0.4)', borderRadius: 4, fontSize: 10, color: '#ff6b6b' }}>
|
||||||
|
OUTAGE IN REGION — {outagesInCountry.map((o: any) => `${o.region_name} (${o.severity}%)`).join(', ')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ marginTop: 6, fontSize: 9, color: '#7c3aed', letterSpacing: '0.05em' }}>
|
||||||
|
DATA CENTER
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Popup>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{
|
{
|
||||||
selectedEntity?.type === 'gdelt' && data?.gdelt?.[selectedEntity.id as number] && (
|
selectedEntity?.type === 'gdelt' && data?.gdelt?.[selectedEntity.id as number] && (
|
||||||
<Popup
|
<Popup
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { API_BASE } from "@/lib/api";
|
import { API_BASE } from "@/lib/api";
|
||||||
import React, { useState, useEffect, useCallback } from "react";
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { Settings, ExternalLink, Key, Shield, X, Save, ChevronDown, ChevronUp } from "lucide-react";
|
import { Settings, ExternalLink, Key, Shield, X, Save, ChevronDown, ChevronUp, Rss, Plus, Trash2, RotateCcw } from "lucide-react";
|
||||||
|
|
||||||
interface ApiEntry {
|
interface ApiEntry {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -18,6 +18,22 @@ interface ApiEntry {
|
|||||||
is_set: boolean;
|
is_set: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface FeedEntry {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
weight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WEIGHT_LABELS: Record<number, string> = { 1: "LOW", 2: "MED", 3: "STD", 4: "HIGH", 5: "CRIT" };
|
||||||
|
const WEIGHT_COLORS: Record<number, string> = {
|
||||||
|
1: "text-gray-400 border-gray-600",
|
||||||
|
2: "text-blue-400 border-blue-600",
|
||||||
|
3: "text-cyan-400 border-cyan-600",
|
||||||
|
4: "text-orange-400 border-orange-600",
|
||||||
|
5: "text-red-400 border-red-600",
|
||||||
|
};
|
||||||
|
const MAX_FEEDS = 20;
|
||||||
|
|
||||||
// Category colors for the tactical UI
|
// Category colors for the tactical UI
|
||||||
const CATEGORY_COLORS: Record<string, string> = {
|
const CATEGORY_COLORS: Record<string, string> = {
|
||||||
Aviation: "text-cyan-400 border-cyan-500/30 bg-cyan-950/20",
|
Aviation: "text-cyan-400 border-cyan-500/30 bg-cyan-950/20",
|
||||||
@@ -31,33 +47,54 @@ const CATEGORY_COLORS: Record<string, string> = {
|
|||||||
SIGINT: "text-rose-400 border-rose-500/30 bg-rose-950/20",
|
SIGINT: "text-rose-400 border-rose-500/30 bg-rose-950/20",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type Tab = "api-keys" | "news-feeds";
|
||||||
|
|
||||||
const SettingsPanel = React.memo(function SettingsPanel({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) {
|
const SettingsPanel = React.memo(function SettingsPanel({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) {
|
||||||
|
const [activeTab, setActiveTab] = useState<Tab>("api-keys");
|
||||||
|
|
||||||
|
// --- API Keys state ---
|
||||||
const [apis, setApis] = useState<ApiEntry[]>([]);
|
const [apis, setApis] = useState<ApiEntry[]>([]);
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
const [editValue, setEditValue] = useState("");
|
const [editValue, setEditValue] = useState("");
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set(["Aviation", "Maritime"]));
|
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set(["Aviation", "Maritime"]));
|
||||||
|
|
||||||
|
// --- News Feeds state ---
|
||||||
|
const [feeds, setFeeds] = useState<FeedEntry[]>([]);
|
||||||
|
const [feedsDirty, setFeedsDirty] = useState(false);
|
||||||
|
const [feedSaving, setFeedSaving] = useState(false);
|
||||||
|
const [feedMsg, setFeedMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null);
|
||||||
|
|
||||||
const fetchKeys = useCallback(async () => {
|
const fetchKeys = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE}/api/settings/api-keys`);
|
const res = await fetch(`${API_BASE}/api/settings/api-keys`);
|
||||||
if (res.ok) {
|
if (res.ok) setApis(await res.json());
|
||||||
const data = await res.json();
|
|
||||||
setApis(data);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to fetch API keys", e);
|
console.error("Failed to fetch API keys", e);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
const fetchFeeds = useCallback(async () => {
|
||||||
if (isOpen) fetchKeys();
|
try {
|
||||||
}, [isOpen, fetchKeys]);
|
const res = await fetch(`${API_BASE}/api/settings/news-feeds`);
|
||||||
|
if (res.ok) {
|
||||||
|
setFeeds(await res.json());
|
||||||
|
setFeedsDirty(false);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to fetch news feeds", e);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const startEditing = (api: ApiEntry) => {
|
useEffect(() => {
|
||||||
setEditingId(api.id);
|
if (isOpen) {
|
||||||
setEditValue("");
|
fetchKeys();
|
||||||
};
|
fetchFeeds();
|
||||||
|
}
|
||||||
|
}, [isOpen, fetchKeys, fetchFeeds]);
|
||||||
|
|
||||||
|
// API Keys handlers
|
||||||
|
const startEditing = (api: ApiEntry) => { setEditingId(api.id); setEditValue(""); };
|
||||||
|
|
||||||
const saveKey = async (api: ApiEntry) => {
|
const saveKey = async (api: ApiEntry) => {
|
||||||
if (!api.env_key) return;
|
if (!api.env_key) return;
|
||||||
@@ -68,33 +105,81 @@ const SettingsPanel = React.memo(function SettingsPanel({ isOpen, onClose }: { i
|
|||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ env_key: api.env_key, value: editValue }),
|
body: JSON.stringify({ env_key: api.env_key, value: editValue }),
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) { setEditingId(null); fetchKeys(); }
|
||||||
setEditingId(null);
|
|
||||||
fetchKeys(); // Refresh to get new obfuscated value
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to save API key", e);
|
console.error("Failed to save API key", e);
|
||||||
} finally {
|
} finally { setSaving(false); }
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleCategory = (cat: string) => {
|
const toggleCategory = (cat: string) => {
|
||||||
setExpandedCategories(prev => {
|
setExpandedCategories(prev => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
if (next.has(cat)) next.delete(cat);
|
if (next.has(cat)) next.delete(cat); else next.add(cat);
|
||||||
else next.add(cat);
|
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Group APIs by category
|
|
||||||
const grouped = apis.reduce<Record<string, ApiEntry[]>>((acc, api) => {
|
const grouped = apis.reduce<Record<string, ApiEntry[]>>((acc, api) => {
|
||||||
if (!acc[api.category]) acc[api.category] = [];
|
if (!acc[api.category]) acc[api.category] = [];
|
||||||
acc[api.category].push(api);
|
acc[api.category].push(api);
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
|
// News Feeds handlers
|
||||||
|
const updateFeed = (idx: number, field: keyof FeedEntry, value: string | number) => {
|
||||||
|
setFeeds(prev => prev.map((f, i) => i === idx ? { ...f, [field]: value } : f));
|
||||||
|
setFeedsDirty(true);
|
||||||
|
setFeedMsg(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeFeed = (idx: number) => {
|
||||||
|
setFeeds(prev => prev.filter((_, i) => i !== idx));
|
||||||
|
setFeedsDirty(true);
|
||||||
|
setFeedMsg(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addFeed = () => {
|
||||||
|
if (feeds.length >= MAX_FEEDS) return;
|
||||||
|
setFeeds(prev => [...prev, { name: "", url: "", weight: 3 }]);
|
||||||
|
setFeedsDirty(true);
|
||||||
|
setFeedMsg(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveFeeds = async () => {
|
||||||
|
setFeedSaving(true);
|
||||||
|
setFeedMsg(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/api/settings/news-feeds`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(feeds),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setFeedsDirty(false);
|
||||||
|
setFeedMsg({ type: "ok", text: "Feeds saved. Changes take effect on next news refresh (~30min) or manual /api/refresh." });
|
||||||
|
} else {
|
||||||
|
const d = await res.json().catch(() => ({}));
|
||||||
|
setFeedMsg({ type: "err", text: d.message || "Save failed" });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setFeedMsg({ type: "err", text: "Network error" });
|
||||||
|
} finally { setFeedSaving(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetFeeds = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/api/settings/news-feeds/reset`, { method: "POST" });
|
||||||
|
if (res.ok) {
|
||||||
|
const d = await res.json();
|
||||||
|
setFeeds(d.feeds || []);
|
||||||
|
setFeedsDirty(false);
|
||||||
|
setFeedMsg({ type: "ok", text: "Reset to defaults" });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setFeedMsg({ type: "err", text: "Reset failed" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
@@ -124,7 +209,7 @@ const SettingsPanel = React.memo(function SettingsPanel({ isOpen, onClose }: { i
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-sm font-bold tracking-[0.2em] text-[var(--text-primary)] font-mono">SYSTEM CONFIG</h2>
|
<h2 className="text-sm font-bold tracking-[0.2em] text-[var(--text-primary)] font-mono">SYSTEM CONFIG</h2>
|
||||||
<span className="text-[9px] text-[var(--text-muted)] font-mono tracking-widest">API KEY REGISTRY</span>
|
<span className="text-[9px] text-[var(--text-muted)] font-mono tracking-widest">SETTINGS & DATA SOURCES</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -135,153 +220,237 @@ const SettingsPanel = React.memo(function SettingsPanel({ isOpen, onClose }: { i
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Info Banner */}
|
{/* Tab Bar */}
|
||||||
<div className="mx-4 mt-4 p-3 rounded-lg border border-cyan-900/30 bg-cyan-950/10">
|
<div className="flex border-b border-[var(--border-primary)]/60">
|
||||||
<div className="flex items-start gap-2">
|
<button
|
||||||
<Shield size={12} className="text-cyan-500 mt-0.5 flex-shrink-0" />
|
onClick={() => setActiveTab("api-keys")}
|
||||||
<p className="text-[10px] text-[var(--text-secondary)] font-mono leading-relaxed">
|
className={`flex-1 px-4 py-2.5 text-[10px] font-mono tracking-widest font-bold transition-colors flex items-center justify-center gap-1.5 ${activeTab === "api-keys" ? "text-cyan-400 border-b-2 border-cyan-500 bg-cyan-950/10" : "text-[var(--text-muted)] hover:text-[var(--text-secondary)]"}`}
|
||||||
API keys are stored locally in the backend <span className="text-cyan-400">.env</span> file. Keys marked with <Key size={8} className="inline text-yellow-500" /> are required for full functionality. Public APIs need no key.
|
>
|
||||||
</p>
|
<Key size={10} />
|
||||||
</div>
|
API KEYS
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab("news-feeds")}
|
||||||
|
className={`flex-1 px-4 py-2.5 text-[10px] font-mono tracking-widest font-bold transition-colors flex items-center justify-center gap-1.5 ${activeTab === "news-feeds" ? "text-orange-400 border-b-2 border-orange-500 bg-orange-950/10" : "text-[var(--text-muted)] hover:text-[var(--text-secondary)]"}`}
|
||||||
|
>
|
||||||
|
<Rss size={10} />
|
||||||
|
NEWS FEEDS
|
||||||
|
{feedsDirty && <span className="w-1.5 h-1.5 rounded-full bg-orange-400 animate-pulse" />}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* API List */}
|
{/* ==================== API KEYS TAB ==================== */}
|
||||||
<div className="flex-1 overflow-y-auto styled-scrollbar p-4 space-y-3">
|
{activeTab === "api-keys" && (
|
||||||
{Object.entries(grouped).map(([category, categoryApis]) => {
|
<>
|
||||||
const colorClass = CATEGORY_COLORS[category] || "text-gray-400 border-gray-700 bg-gray-900/20";
|
{/* Info Banner */}
|
||||||
const isExpanded = expandedCategories.has(category);
|
<div className="mx-4 mt-4 p-3 rounded-lg border border-cyan-900/30 bg-cyan-950/10">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
return (
|
<Shield size={12} className="text-cyan-500 mt-0.5 flex-shrink-0" />
|
||||||
<div key={category} className="rounded-lg border border-[var(--border-primary)]/60 overflow-hidden">
|
<p className="text-[10px] text-[var(--text-secondary)] font-mono leading-relaxed">
|
||||||
{/* Category Header */}
|
API keys are stored locally in the backend <span className="text-cyan-400">.env</span> file. Keys marked with <Key size={8} className="inline text-yellow-500" /> are required for full functionality. Public APIs need no key.
|
||||||
<button
|
</p>
|
||||||
onClick={() => toggleCategory(category)}
|
|
||||||
className="w-full flex items-center justify-between px-4 py-2.5 bg-[var(--bg-secondary)]/50 hover:bg-[var(--bg-secondary)]/80 transition-colors"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className={`text-[9px] font-mono tracking-widest font-bold px-2 py-0.5 rounded border ${colorClass}`}>
|
|
||||||
{category.toUpperCase()}
|
|
||||||
</span>
|
|
||||||
<span className="text-[10px] text-[var(--text-muted)] font-mono">
|
|
||||||
{categoryApis.length} {categoryApis.length === 1 ? 'service' : 'services'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{isExpanded ? <ChevronUp size={12} className="text-[var(--text-muted)]" /> : <ChevronDown size={12} className="text-[var(--text-muted)]" />}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* APIs in Category */}
|
|
||||||
<AnimatePresence>
|
|
||||||
{isExpanded && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ height: 0, opacity: 0 }}
|
|
||||||
animate={{ height: "auto", opacity: 1 }}
|
|
||||||
exit={{ height: 0, opacity: 0 }}
|
|
||||||
transition={{ duration: 0.2 }}
|
|
||||||
>
|
|
||||||
{categoryApis.map((api) => (
|
|
||||||
<div key={api.id} className="border-t border-[var(--border-primary)]/40 px-4 py-3 hover:bg-[var(--bg-secondary)]/30 transition-colors">
|
|
||||||
{/* API Name + Status */}
|
|
||||||
<div className="flex items-center justify-between mb-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{api.required && <Key size={10} className="text-yellow-500" />}
|
|
||||||
<span className="text-xs font-mono text-[var(--text-primary)] font-medium">{api.name}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
{api.has_key ? (
|
|
||||||
api.is_set ? (
|
|
||||||
<span className="text-[8px] font-mono px-1.5 py-0.5 rounded border border-green-500/30 text-green-400 bg-green-950/20">
|
|
||||||
KEY SET
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-[8px] font-mono px-1.5 py-0.5 rounded border border-yellow-500/30 text-yellow-400 bg-yellow-950/20">
|
|
||||||
MISSING
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<span className="text-[8px] font-mono px-1.5 py-0.5 rounded border border-[var(--border-primary)] text-[var(--text-muted)]">
|
|
||||||
PUBLIC
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{api.url && (
|
|
||||||
<a
|
|
||||||
href={api.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-[var(--text-muted)] hover:text-cyan-400 transition-colors"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<ExternalLink size={10} />
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
<p className="text-[10px] text-[var(--text-muted)] font-mono leading-relaxed mb-2">
|
|
||||||
{api.description}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Key Field (only for APIs with keys) */}
|
|
||||||
{api.has_key && (
|
|
||||||
<div className="mt-2">
|
|
||||||
{editingId === api.id ? (
|
|
||||||
/* Edit Mode */
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={editValue}
|
|
||||||
onChange={(e) => setEditValue(e.target.value)}
|
|
||||||
className="flex-1 bg-black/60 border border-cyan-900/50 rounded px-2 py-1.5 text-[11px] font-mono text-cyan-300 outline-none focus:border-cyan-500/70 transition-colors"
|
|
||||||
placeholder="Enter API key..."
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={() => saveKey(api)}
|
|
||||||
disabled={saving}
|
|
||||||
className="px-3 py-1.5 rounded bg-cyan-500/20 border border-cyan-500/40 text-cyan-400 hover:bg-cyan-500/30 transition-colors text-[10px] font-mono flex items-center gap-1"
|
|
||||||
>
|
|
||||||
<Save size={10} />
|
|
||||||
{saving ? "..." : "SAVE"}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setEditingId(null)}
|
|
||||||
className="px-2 py-1.5 rounded border border-[var(--border-primary)] text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:border-[var(--border-secondary)] transition-colors text-[10px] font-mono"
|
|
||||||
>
|
|
||||||
ESC
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
/* Display Mode */
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<div
|
|
||||||
className="flex-1 bg-[var(--bg-primary)]/40 border border-[var(--border-primary)] rounded px-2.5 py-1.5 font-mono text-[11px] cursor-pointer hover:border-[var(--border-secondary)] transition-colors select-none"
|
|
||||||
onClick={() => startEditing(api)}
|
|
||||||
>
|
|
||||||
<span className="text-[var(--text-muted)] tracking-wider">
|
|
||||||
{api.is_set ? api.value_obfuscated : "Click to set key..."}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer */}
|
{/* API List */}
|
||||||
<div className="p-4 border-t border-[var(--border-primary)]/80">
|
<div className="flex-1 overflow-y-auto styled-scrollbar p-4 space-y-3">
|
||||||
<div className="flex items-center justify-between text-[9px] text-[var(--text-muted)] font-mono">
|
{Object.entries(grouped).map(([category, categoryApis]) => {
|
||||||
<span>{apis.length} REGISTERED APIs</span>
|
const colorClass = CATEGORY_COLORS[category] || "text-gray-400 border-gray-700 bg-gray-900/20";
|
||||||
<span>{apis.filter(a => a.has_key).length} KEYS CONFIGURED</span>
|
const isExpanded = expandedCategories.has(category);
|
||||||
</div>
|
return (
|
||||||
</div>
|
<div key={category} className="rounded-lg border border-[var(--border-primary)]/60 overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleCategory(category)}
|
||||||
|
className="w-full flex items-center justify-between px-4 py-2.5 bg-[var(--bg-secondary)]/50 hover:bg-[var(--bg-secondary)]/80 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`text-[9px] font-mono tracking-widest font-bold px-2 py-0.5 rounded border ${colorClass}`}>
|
||||||
|
{category.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-[var(--text-muted)] font-mono">
|
||||||
|
{categoryApis.length} {categoryApis.length === 1 ? 'service' : 'services'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{isExpanded ? <ChevronUp size={12} className="text-[var(--text-muted)]" /> : <ChevronDown size={12} className="text-[var(--text-muted)]" />}
|
||||||
|
</button>
|
||||||
|
<AnimatePresence>
|
||||||
|
{isExpanded && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: "auto", opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
{categoryApis.map((api) => (
|
||||||
|
<div key={api.id} className="border-t border-[var(--border-primary)]/40 px-4 py-3 hover:bg-[var(--bg-secondary)]/30 transition-colors">
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{api.required && <Key size={10} className="text-yellow-500" />}
|
||||||
|
<span className="text-xs font-mono text-[var(--text-primary)] font-medium">{api.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{api.has_key ? (
|
||||||
|
api.is_set ? (
|
||||||
|
<span className="text-[8px] font-mono px-1.5 py-0.5 rounded border border-green-500/30 text-green-400 bg-green-950/20">KEY SET</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-[8px] font-mono px-1.5 py-0.5 rounded border border-yellow-500/30 text-yellow-400 bg-yellow-950/20">MISSING</span>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span className="text-[8px] font-mono px-1.5 py-0.5 rounded border border-[var(--border-primary)] text-[var(--text-muted)]">PUBLIC</span>
|
||||||
|
)}
|
||||||
|
{api.url && (
|
||||||
|
<a href={api.url} target="_blank" rel="noopener noreferrer" className="text-[var(--text-muted)] hover:text-cyan-400 transition-colors" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<ExternalLink size={10} />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-[var(--text-muted)] font-mono leading-relaxed mb-2">{api.description}</p>
|
||||||
|
{api.has_key && (
|
||||||
|
<div className="mt-2">
|
||||||
|
{editingId === api.id ? (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input type="text" value={editValue} onChange={(e) => setEditValue(e.target.value)} className="flex-1 bg-black/60 border border-cyan-900/50 rounded px-2 py-1.5 text-[11px] font-mono text-cyan-300 outline-none focus:border-cyan-500/70 transition-colors" placeholder="Enter API key..." autoFocus />
|
||||||
|
<button onClick={() => saveKey(api)} disabled={saving} className="px-3 py-1.5 rounded bg-cyan-500/20 border border-cyan-500/40 text-cyan-400 hover:bg-cyan-500/30 transition-colors text-[10px] font-mono flex items-center gap-1">
|
||||||
|
<Save size={10} />{saving ? "..." : "SAVE"}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setEditingId(null)} className="px-2 py-1.5 rounded border border-[var(--border-primary)] text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:border-[var(--border-secondary)] transition-colors text-[10px] font-mono">ESC</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div className="flex-1 bg-[var(--bg-primary)]/40 border border-[var(--border-primary)] rounded px-2.5 py-1.5 font-mono text-[11px] cursor-pointer hover:border-[var(--border-secondary)] transition-colors select-none" onClick={() => startEditing(api)}>
|
||||||
|
<span className="text-[var(--text-muted)] tracking-wider">{api.is_set ? api.value_obfuscated : "Click to set key..."}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="p-4 border-t border-[var(--border-primary)]/80">
|
||||||
|
<div className="flex items-center justify-between text-[9px] text-[var(--text-muted)] font-mono">
|
||||||
|
<span>{apis.length} REGISTERED APIs</span>
|
||||||
|
<span>{apis.filter(a => a.has_key).length} KEYS CONFIGURED</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ==================== NEWS FEEDS TAB ==================== */}
|
||||||
|
{activeTab === "news-feeds" && (
|
||||||
|
<>
|
||||||
|
{/* Info Banner */}
|
||||||
|
<div className="mx-4 mt-4 p-3 rounded-lg border border-orange-900/30 bg-orange-950/10">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<Rss size={12} className="text-orange-500 mt-0.5 flex-shrink-0" />
|
||||||
|
<p className="text-[10px] text-[var(--text-secondary)] font-mono leading-relaxed">
|
||||||
|
Configure RSS/Atom feeds for the Threat Intel news panel. Each feed is scored by keyword heuristics and weighted by the priority you set. Up to <span className="text-orange-400">{MAX_FEEDS}</span> sources.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Feed List */}
|
||||||
|
<div className="flex-1 overflow-y-auto styled-scrollbar p-4 space-y-2">
|
||||||
|
{feeds.map((feed, idx) => (
|
||||||
|
<div key={idx} className="rounded-lg border border-[var(--border-primary)]/60 p-3 hover:border-[var(--border-secondary)]/60 transition-colors group">
|
||||||
|
{/* Row 1: Name + Weight + Delete */}
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={feed.name}
|
||||||
|
onChange={(e) => updateFeed(idx, "name", e.target.value)}
|
||||||
|
className="flex-1 bg-transparent border-b border-[var(--border-primary)] text-xs font-mono text-[var(--text-primary)] outline-none focus:border-cyan-500/70 transition-colors px-1 py-0.5"
|
||||||
|
placeholder="Source name..."
|
||||||
|
/>
|
||||||
|
{/* Weight selector */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{[1, 2, 3, 4, 5].map(w => (
|
||||||
|
<button
|
||||||
|
key={w}
|
||||||
|
onClick={() => updateFeed(idx, "weight", w)}
|
||||||
|
className={`w-5 h-5 rounded text-[8px] font-mono font-bold border transition-all ${feed.weight === w ? WEIGHT_COLORS[w] + " bg-black/40" : "border-[var(--border-primary)]/40 text-[var(--text-muted)]/50 hover:border-[var(--border-secondary)]"}`}
|
||||||
|
title={WEIGHT_LABELS[w]}
|
||||||
|
>
|
||||||
|
{w}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<span className={`text-[8px] font-mono ml-1 w-7 ${WEIGHT_COLORS[feed.weight]?.split(" ")[0] || "text-gray-400"}`}>
|
||||||
|
{WEIGHT_LABELS[feed.weight] || "STD"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => removeFeed(idx)}
|
||||||
|
className="w-6 h-6 rounded flex items-center justify-center text-[var(--text-muted)] hover:text-red-400 hover:bg-red-950/20 transition-all opacity-0 group-hover:opacity-100"
|
||||||
|
title="Remove feed"
|
||||||
|
>
|
||||||
|
<Trash2 size={11} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/* Row 2: URL */}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={feed.url}
|
||||||
|
onChange={(e) => updateFeed(idx, "url", e.target.value)}
|
||||||
|
className="w-full bg-black/30 border border-[var(--border-primary)]/40 rounded px-2 py-1 text-[10px] font-mono text-[var(--text-muted)] outline-none focus:border-cyan-500/50 focus:text-cyan-300 transition-colors"
|
||||||
|
placeholder="https://example.com/rss.xml"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Add Feed Button */}
|
||||||
|
<button
|
||||||
|
onClick={addFeed}
|
||||||
|
disabled={feeds.length >= MAX_FEEDS}
|
||||||
|
className="w-full py-2.5 rounded-lg border border-dashed border-[var(--border-primary)]/60 text-[var(--text-muted)] hover:border-orange-500/50 hover:text-orange-400 hover:bg-orange-950/10 transition-all text-[10px] font-mono flex items-center justify-center gap-1.5 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<Plus size={10} />
|
||||||
|
ADD FEED ({feeds.length}/{MAX_FEEDS})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status message */}
|
||||||
|
{feedMsg && (
|
||||||
|
<div className={`mx-4 mb-2 px-3 py-2 rounded text-[10px] font-mono ${feedMsg.type === "ok" ? "text-green-400 bg-green-950/20 border border-green-900/30" : "text-red-400 bg-red-950/20 border border-red-900/30"}`}>
|
||||||
|
{feedMsg.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="p-4 border-t border-[var(--border-primary)]/80">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={saveFeeds}
|
||||||
|
disabled={!feedsDirty || feedSaving}
|
||||||
|
className="flex-1 px-4 py-2 rounded bg-orange-500/20 border border-orange-500/40 text-orange-400 hover:bg-orange-500/30 transition-colors text-[10px] font-mono flex items-center justify-center gap-1.5 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<Save size={10} />
|
||||||
|
{feedSaving ? "SAVING..." : "SAVE FEEDS"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={resetFeeds}
|
||||||
|
className="px-3 py-2 rounded border border-[var(--border-primary)] text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:border-[var(--border-secondary)] transition-all text-[10px] font-mono flex items-center gap-1.5"
|
||||||
|
title="Reset to defaults"
|
||||||
|
>
|
||||||
|
<RotateCcw size={10} />
|
||||||
|
RESET
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-[9px] text-[var(--text-muted)] font-mono mt-2">
|
||||||
|
<span>{feeds.length}/{MAX_FEEDS} SOURCES</span>
|
||||||
|
<span>WEIGHT: 1=LOW 5=CRITICAL</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,9 +2,44 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect, useRef } from "react";
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { Plane, AlertTriangle, Activity, Satellite, Cctv, ChevronDown, ChevronUp, Ship, Eye, Anchor, Settings, Sun, Moon, BookOpen, Radio, Play, Pause, Globe, Flame, Wifi } from "lucide-react";
|
import { Plane, AlertTriangle, Activity, Satellite, Cctv, ChevronDown, ChevronUp, Ship, Eye, Anchor, Settings, Sun, Moon, BookOpen, Radio, Play, Pause, Globe, Flame, Wifi, Server } from "lucide-react";
|
||||||
import { useTheme } from "@/lib/ThemeContext";
|
import { useTheme } from "@/lib/ThemeContext";
|
||||||
|
|
||||||
|
function relativeTime(iso: string | undefined): string {
|
||||||
|
if (!iso) return "";
|
||||||
|
const diff = Date.now() - new Date(iso + "Z").getTime();
|
||||||
|
if (diff < 0) return "now";
|
||||||
|
const sec = Math.floor(diff / 1000);
|
||||||
|
if (sec < 60) return `${sec}s ago`;
|
||||||
|
const min = Math.floor(sec / 60);
|
||||||
|
if (min < 60) return `${min}m ago`;
|
||||||
|
const hr = Math.floor(min / 60);
|
||||||
|
if (hr < 24) return `${hr}h ago`;
|
||||||
|
return `${Math.floor(hr / 24)}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map layer IDs to freshness keys from the backend source_timestamps dict
|
||||||
|
const FRESHNESS_MAP: Record<string, string> = {
|
||||||
|
flights: "commercial_flights",
|
||||||
|
private: "private_flights",
|
||||||
|
jets: "private_jets",
|
||||||
|
military: "military_flights",
|
||||||
|
tracked: "military_flights",
|
||||||
|
earthquakes: "earthquakes",
|
||||||
|
satellites: "satellites",
|
||||||
|
ships_important: "ships",
|
||||||
|
ships_civilian: "ships",
|
||||||
|
ships_passenger: "ships",
|
||||||
|
ukraine_frontline: "frontlines",
|
||||||
|
global_incidents: "gdelt",
|
||||||
|
cctv: "cctv",
|
||||||
|
gps_jamming: "commercial_flights",
|
||||||
|
kiwisdr: "kiwisdr",
|
||||||
|
firms: "firms_fires",
|
||||||
|
internet_outages: "internet_outages",
|
||||||
|
datacenters: "datacenters",
|
||||||
|
};
|
||||||
|
|
||||||
const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, activeLayers, setActiveLayers, onSettingsClick, onLegendClick, gibsDate, setGibsDate, gibsOpacity, setGibsOpacity }: { data: any; activeLayers: any; setActiveLayers: any; onSettingsClick?: () => void; onLegendClick?: () => void; gibsDate?: string; setGibsDate?: (d: string) => void; gibsOpacity?: number; setGibsOpacity?: (o: number) => void }) {
|
const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, activeLayers, setActiveLayers, onSettingsClick, onLegendClick, gibsDate, setGibsDate, gibsOpacity, setGibsOpacity }: { data: any; activeLayers: any; setActiveLayers: any; onSettingsClick?: () => void; onLegendClick?: () => void; gibsDate?: string; setGibsDate?: (d: string) => void; gibsOpacity?: number; setGibsOpacity?: (o: number) => void }) {
|
||||||
const [isMinimized, setIsMinimized] = useState(false);
|
const [isMinimized, setIsMinimized] = useState(false);
|
||||||
const { theme, toggleTheme } = useTheme();
|
const { theme, toggleTheme } = useTheme();
|
||||||
@@ -60,6 +95,7 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, active
|
|||||||
{ id: "kiwisdr", name: "KiwiSDR Receivers", source: "KiwiSDR.com", count: data?.kiwisdr?.length || 0, icon: Radio },
|
{ id: "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: "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: "internet_outages", name: "Internet Outages", source: "IODA / Georgia Tech", count: data?.internet_outages?.length || 0, icon: Wifi },
|
||||||
|
{ id: "datacenters", name: "Data Centers", source: "DC Map (GitHub)", count: data?.datacenters?.length || 0, icon: Server },
|
||||||
{ id: "day_night", name: "Day / Night Cycle", source: "Solar Calc", count: null, icon: Sun },
|
{ id: "day_night", name: "Day / Night Cycle", source: "Solar Calc", count: null, icon: Sun },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -146,7 +182,12 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, active
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className={`text-sm font-medium ${active ? 'text-[var(--text-primary)]' : 'text-[var(--text-secondary)]'} tracking-wide`}>{layer.name}</span>
|
<span className={`text-sm font-medium ${active ? 'text-[var(--text-primary)]' : 'text-[var(--text-secondary)]'} tracking-wide`}>{layer.name}</span>
|
||||||
<span className="text-[9px] text-[var(--text-muted)] font-mono tracking-wider mt-0.5">{layer.source} · {active ? 'LIVE' : 'OFF'}</span>
|
<span className="text-[9px] text-[var(--text-muted)] font-mono tracking-wider mt-0.5">{layer.source} · {active ? (() => {
|
||||||
|
const fKey = FRESHNESS_MAP[layer.id];
|
||||||
|
const freshness = fKey && data?.freshness?.[fKey];
|
||||||
|
const rt = freshness ? relativeTime(freshness) : '';
|
||||||
|
return rt ? <span className="text-cyan-500/70">{rt}</span> : 'LIVE';
|
||||||
|
})() : 'OFF'}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|||||||
@@ -17,9 +17,16 @@ if %errorlevel% neq 0 (
|
|||||||
exit /b 1
|
exit /b 1
|
||||||
)
|
)
|
||||||
|
|
||||||
:: Check Python version
|
:: Check Python version (warn if 3.13+)
|
||||||
for /f "tokens=2 delims= " %%v in ('python --version 2^>^&1') do set PYVER=%%v
|
for /f "tokens=2 delims= " %%v in ('python --version 2^>^&1') do set PYVER=%%v
|
||||||
echo [*] Found Python %PYVER%
|
echo [*] Found Python %PYVER%
|
||||||
|
for /f "tokens=1,2 delims=." %%a in ("%PYVER%") do (
|
||||||
|
if %%b GEQ 13 (
|
||||||
|
echo [!] WARNING: Python %PYVER% detected. Some packages may fail to build.
|
||||||
|
echo [!] Recommended: Python 3.10, 3.11, or 3.12.
|
||||||
|
echo.
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
:: Check for Node.js
|
:: Check for Node.js
|
||||||
where npm >nul 2>&1
|
where npm >nul 2>&1
|
||||||
@@ -47,7 +54,7 @@ if not exist "venv\" (
|
|||||||
)
|
)
|
||||||
call venv\Scripts\activate.bat
|
call venv\Scripts\activate.bat
|
||||||
echo [*] Installing Python dependencies (this may take a minute)...
|
echo [*] Installing Python dependencies (this may take a minute)...
|
||||||
pip install -r requirements.txt
|
pip install -q -r requirements.txt
|
||||||
if %errorlevel% neq 0 (
|
if %errorlevel% neq 0 (
|
||||||
echo.
|
echo.
|
||||||
echo [!] ERROR: pip install failed. See errors above.
|
echo [!] ERROR: pip install failed. See errors above.
|
||||||
|
|||||||
@@ -23,7 +23,14 @@ else
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "[*] Found $($PYTHON_CMD --version 2>&1)"
|
PYVER=$($PYTHON_CMD --version 2>&1 | awk '{print $2}')
|
||||||
|
echo "[*] Found Python $PYVER"
|
||||||
|
PY_MINOR=$(echo "$PYVER" | cut -d. -f2)
|
||||||
|
if [ "$PY_MINOR" -ge 13 ] 2>/dev/null; then
|
||||||
|
echo "[!] WARNING: Python $PYVER detected. Some packages may fail to build."
|
||||||
|
echo "[!] Recommended: Python 3.10, 3.11, or 3.12."
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
# Get the directory where this script lives
|
# Get the directory where this script lives
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
@@ -42,7 +49,7 @@ fi
|
|||||||
|
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
echo "[*] Installing Python dependencies (this may take a minute)..."
|
echo "[*] Installing Python dependencies (this may take a minute)..."
|
||||||
pip install -r requirements.txt
|
pip install -q -r requirements.txt
|
||||||
if [ $? -ne 0 ]; then
|
if [ $? -ne 0 ]; then
|
||||||
echo ""
|
echo ""
|
||||||
echo "[!] ERROR: pip install failed. See errors above."
|
echo "[!] ERROR: pip install failed. See errors above."
|
||||||
|
|||||||
Reference in New Issue
Block a user