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