diff --git a/README.md b/README.md index 477014c..bcb0536 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/backend/config/news_feeds.json b/backend/config/news_feeds.json new file mode 100644 index 0000000..791adf3 --- /dev/null +++ b/backend/config/news_feeds.json @@ -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 } + ] +} diff --git a/backend/main.py b/backend/main.py index 5df50b1..e7c2ff3 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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) diff --git a/backend/services/data_fetcher.py b/backend/services/data_fetcher.py index 8d952ed..9138d4c 100644 --- a/backend/services/data_fetcher.py +++ b/backend/services/data_fetcher.py @@ -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) diff --git a/backend/services/news_feed_config.py b/backend/services/news_feed_config.py new file mode 100644 index 0000000..33346c2 --- /dev/null +++ b/backend/services/news_feed_config.py @@ -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)) diff --git a/docker-compose.yml b/docker-compose.yml index 097da07..d6f72d7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index e08e295..9e425c9 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -148,6 +148,7 @@ export default function Dashboard() { kiwisdr: false, firms: false, internet_outages: false, + datacenters: false, }); // NASA GIBS satellite imagery state diff --git a/frontend/src/components/ChangelogModal.tsx b/frontend/src/components/ChangelogModal.tsx index c29c13c..8a307de 100644 --- a/frontend/src/components/ChangelogModal.tsx +++ b/frontend/src/components/ChangelogModal.tsx @@ -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: , - 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: , + 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: , - 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: , + 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: , + 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: , - 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: , - 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: , + 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() { diff --git a/frontend/src/components/MapLegend.tsx b/frontend/src/components/MapLegend.tsx index b5494d8..bd4f801 100644 --- a/frontend/src/components/MapLegend.tsx +++ b/frontend/src/components/MapLegend.tsx @@ -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: ``, label: "UAV / Drone" }, - { svg: ``, label: "UAV Operational Range (dashed circle)" }, + { svg: ``, label: "UAV / Drone (live ADS-B)" }, ], }, { diff --git a/frontend/src/components/MaplibreViewer.tsx b/frontend/src/components/MaplibreViewer.tsx index fd32e67..94cbb27 100644 --- a/frontend/src/components/MaplibreViewer.tsx +++ b/frontend/src/components/MaplibreViewer.tsx @@ -33,6 +33,7 @@ const svgHeliWhiteAlert = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; const svgHeliBlack = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; const svgDrone = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; +const svgDataCenter = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; const svgShipGray = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; const svgShipRed = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; const svgShipYellow = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; @@ -228,8 +229,34 @@ const MISSION_ICON_MAP: Record = { '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 | 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(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 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 && ( - + {/* firms-fires: data pushed imperatively via useImperativeSource */} + {/* Cluster fire icons — flame shape to differentiate from Global Incidents circles */} - )} {/* SOLAR TERMINATOR — night overlay */} {activeLayers.day_night && nightGeoJSON && ( @@ -1407,8 +1451,8 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele )} - {commFlightsGeoJSON && ( - + {/* commercial/private/military flights: data pushed imperatively */} + - )} - {privFlightsGeoJSON && ( - + - )} - {privJetsGeoJSON && ( - + - )} - {milFlightsGeoJSON && ( - + - )} {shipsGeoJSON && ( )} - {trackedFlightsGeoJSON && ( - + {/* tracked-flights & UAVs: data pushed imperatively */} + - )} - {uavGeoJSON && ( - + - )} - {/* UAV Operational Range Circles */} - {uavRangeGeoJSON && ( - - - - - )} + {/* UAV range circles removed — real ADS-B data has no fixed orbit */} {gdeltGeoJSON && ( @@ -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 ( -
- [[{s.name}]] +
+
+ [[{s.name}]] +
+ {s.estimated && ( +
+ EST. POSITION — OSINT +
+ )}
); @@ -2114,9 +2133,64 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele )} + {/* Data Center positions */} + {dataCentersGeoJSON && ( + + {/* Cluster circles */} + + + {/* Individual DC icons */} + + + )} + {/* Satellite positions — mission-type icons */} - {satellitesGeoJSON && ( - + {/* satellites: data pushed imperatively */} + - )} {/* 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 }}>
- ✈️ {uav.callsign} + {uav.callsign}
+
+ LIVE ADS-B TRANSPONDER +
+ {uav.aircraft_model && ( +
+ Model: {uav.aircraft_model} +
+ )} {uav.uav_type && (
- Type: {uav.uav_type} + Classification: {uav.uav_type}
)} {uav.country && (
- Country: {uav.country} + Registration: {uav.country} +
+ )} + {uav.icao24 && ( +
+ ICAO: {uav.icao24}
)}
@@ -2229,9 +2315,9 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele Speed: {uav.speed_knots} kn
)} - {uav.range_km > 0 && ( + {uav.squawk && (
- Operational Range: {uav.range_km?.toLocaleString()} km + Squawk: {uav.squawk}
)} {uav.wiki && ( @@ -2243,6 +2329,135 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele ); })()} + + {/* 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 ( + onEntityClick?.(null)} + anchor="bottom" offset={12} + > +
+
+
+ {ship.name || 'UNKNOWN VESSEL'} +
+ +
+ {ship.estimated && ( +
+ ESTIMATED POSITION — {ship.source || 'OSINT DERIVED'} +
+ )} + {ship.type && ( +
+ Type: {ship.type.replace('_', ' ')} +
+ )} + {ship.mmsi && ( +
+ MMSI: {ship.mmsi} +
+ )} + {ship.imo && ( +
+ IMO: {ship.imo} +
+ )} + {ship.callsign && ( +
+ Callsign: {ship.callsign} +
+ )} + {ship.country && ( +
+ Flag: {ship.country} +
+ )} + {ship.destination && ( +
+ Destination: {ship.destination} +
+ )} + {typeof ship.sog === 'number' && ship.sog > 0 && ( +
+ Speed: {ship.sog.toFixed(1)} kn +
+ )} + {ship.heading != null && ( +
+ Heading: {Math.round(ship.heading)}° +
+ )} + {ship.last_osint_update && ( +
+ Last OSINT Update: {new Date(ship.last_osint_update).toLocaleDateString()} +
+ )} +
+
+ ); + })()} + + {/* 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 ( + onEntityClick?.(null)} + className="threat-popup" + maxWidth="280px" + > +
+
+ {dc.name} +
+ {dc.company && ( +
+ Operator: {dc.company} +
+ )} + {dc.city && ( +
+ Location: {dc.city}{dc.country ? `, ${dc.country}` : ''} +
+ )} + {!dc.city && dc.country && ( +
+ Country: {dc.country} +
+ )} + {outagesInCountry.length > 0 && ( +
+ OUTAGE IN REGION — {outagesInCountry.map((o: any) => `${o.region_name} (${o.severity}%)`).join(', ')} +
+ )} +
+ DATA CENTER +
+
+
+ ); + })()} + { selectedEntity?.type === 'gdelt' && data?.gdelt?.[selectedEntity.id as number] && ( = { 1: "LOW", 2: "MED", 3: "STD", 4: "HIGH", 5: "CRIT" }; +const WEIGHT_COLORS: Record = { + 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 = { Aviation: "text-cyan-400 border-cyan-500/30 bg-cyan-950/20", @@ -31,33 +47,54 @@ const CATEGORY_COLORS: Record = { 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("api-keys"); + + // --- API Keys state --- const [apis, setApis] = useState([]); const [editingId, setEditingId] = useState(null); const [editValue, setEditValue] = useState(""); const [saving, setSaving] = useState(false); const [expandedCategories, setExpandedCategories] = useState>(new Set(["Aviation", "Maritime"])); + // --- News Feeds state --- + const [feeds, setFeeds] = useState([]); + 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>((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 ( {isOpen && ( @@ -124,7 +209,7 @@ const SettingsPanel = React.memo(function SettingsPanel({ isOpen, onClose }: { i

SYSTEM CONFIG

- API KEY REGISTRY + SETTINGS & DATA SOURCES
+ - {/* API List */} -
- {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 ( -
- {/* Category Header */} - - - {/* APIs in Category */} - - {isExpanded && ( - - {categoryApis.map((api) => ( -
- {/* API Name + Status */} -
-
- {api.required && } - {api.name} -
-
- {api.has_key ? ( - api.is_set ? ( - - KEY SET - - ) : ( - - MISSING - - ) - ) : ( - - PUBLIC - - )} - {api.url && ( - e.stopPropagation()} - > - - - )} -
-
- - {/* Description */} -

- {api.description} -

- - {/* Key Field (only for APIs with keys) */} - {api.has_key && ( -
- {editingId === api.id ? ( - /* Edit Mode */ -
- 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 - /> - - -
- ) : ( - /* Display Mode */ -
-
startEditing(api)} - > - - {api.is_set ? api.value_obfuscated : "Click to set key..."} - -
-
- )} -
- )} -
- ))} -
- )} -
+ {/* ==================== API KEYS TAB ==================== */} + {activeTab === "api-keys" && ( + <> + {/* Info Banner */} +
+
+ +

+ API keys are stored locally in the backend .env file. Keys marked with are required for full functionality. Public APIs need no key. +

- ); - })} -
+
- {/* Footer */} -
-
- {apis.length} REGISTERED APIs - {apis.filter(a => a.has_key).length} KEYS CONFIGURED -
-
+ {/* API List */} +
+ {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 ( +
+ + + {isExpanded && ( + + {categoryApis.map((api) => ( +
+
+
+ {api.required && } + {api.name} +
+
+ {api.has_key ? ( + api.is_set ? ( + KEY SET + ) : ( + MISSING + ) + ) : ( + PUBLIC + )} + {api.url && ( + e.stopPropagation()}> + + + )} +
+
+

{api.description}

+ {api.has_key && ( +
+ {editingId === api.id ? ( +
+ 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 /> + + +
+ ) : ( +
+
startEditing(api)}> + {api.is_set ? api.value_obfuscated : "Click to set key..."} +
+
+ )} +
+ )} +
+ ))} +
+ )} +
+
+ ); + })} +
+ + {/* Footer */} +
+
+ {apis.length} REGISTERED APIs + {apis.filter(a => a.has_key).length} KEYS CONFIGURED +
+
+ + )} + + {/* ==================== NEWS FEEDS TAB ==================== */} + {activeTab === "news-feeds" && ( + <> + {/* Info Banner */} +
+
+ +

+ 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 {MAX_FEEDS} sources. +

+
+
+ + {/* Feed List */} +
+ {feeds.map((feed, idx) => ( +
+ {/* Row 1: Name + Weight + Delete */} +
+ 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 */} +
+ {[1, 2, 3, 4, 5].map(w => ( + + ))} + + {WEIGHT_LABELS[feed.weight] || "STD"} + +
+ +
+ {/* Row 2: URL */} + 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" + /> +
+ ))} + + {/* Add Feed Button */} + +
+ + {/* Status message */} + {feedMsg && ( +
+ {feedMsg.text} +
+ )} + + {/* Footer */} +
+
+ + +
+
+ {feeds.length}/{MAX_FEEDS} SOURCES + WEIGHT: 1=LOW 5=CRITICAL +
+
+ + )} )} diff --git a/frontend/src/components/WorldviewLeftPanel.tsx b/frontend/src/components/WorldviewLeftPanel.tsx index 0d0b696..b180d05 100644 --- a/frontend/src/components/WorldviewLeftPanel.tsx +++ b/frontend/src/components/WorldviewLeftPanel.tsx @@ -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 = { + 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
{layer.name} - {layer.source} · {active ? 'LIVE' : 'OFF'} + {layer.source} · {active ? (() => { + const fKey = FRESHNESS_MAP[layer.id]; + const freshness = fKey && data?.freshness?.[fKey]; + const rt = freshness ? relativeTime(freshness) : ''; + return rt ? {rt} : 'LIVE'; + })() : 'OFF'}
diff --git a/start.bat b/start.bat index e5608bb..38334e0 100644 --- a/start.bat +++ b/start.bat @@ -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. diff --git a/start.sh b/start.sh index 1ad8729..a1a4a34 100644 --- a/start.sh +++ b/start.sh @@ -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."