mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-06-10 00:03:59 +02:00
Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 20d2ccc52c | |||
| 0fc09c9011 | |||
| 707ca29220 | |||
| eb0288ee4e | |||
| 8d3c7a51b7 | |||
| fa18c032e2 | |||
| e1060193d0 | |||
| 08810f2537 | |||
| f5b9d14b48 | |||
| 9122d306cd | |||
| 03e5fc1363 | |||
| 447afe0b2b | |||
| d515aba450 | |||
| 3a8db7f9cd | |||
| f1cb1e860d | |||
| 38bcc976a4 | |||
| 77b4361ad6 | |||
| c5819d40d1 | |||
| 009574db81 | |||
| 281371e135 | |||
| 401268f22a | |||
| f830148e69 | |||
| 4068c31cfa | |||
| 50721816fa | |||
| 5dac844532 | |||
| 8884675845 | |||
| 58144d1b82 | |||
| da2a27f92a | |||
| f6f6176a12 | |||
| e6bea9dad3 | |||
| aebd5f0198 | |||
| 2f70b50f65 | |||
| 1b2ad5023d |
@@ -8,6 +8,18 @@ __pycache__/
|
||||
venv/
|
||||
.venv/
|
||||
.ruff_cache/
|
||||
local-artifacts/
|
||||
release-secrets/
|
||||
|
||||
# Never send local configuration or credentials into Docker builds
|
||||
.env
|
||||
.env.*
|
||||
**/.env
|
||||
**/.env.*
|
||||
*.pem
|
||||
*.key
|
||||
*.p12
|
||||
*.pfx
|
||||
|
||||
# privacy-core build caches (source is needed, artifacts are not)
|
||||
privacy-core/target/
|
||||
@@ -21,3 +33,24 @@ privacy-core/.codex-tmp/
|
||||
*.log
|
||||
extra/
|
||||
prototype/
|
||||
|
||||
# Runtime state generated by local backend runs
|
||||
backend/.pytest_cache/
|
||||
backend/.ruff_cache/
|
||||
backend/backend.egg-info/
|
||||
backend/build/
|
||||
backend/node_modules/
|
||||
backend/timemachine/
|
||||
backend/venv/
|
||||
backend/data/*cache*.json
|
||||
backend/data/**/*cache*.json
|
||||
backend/data/wormhole*.json
|
||||
backend/data/**/wormhole*.json
|
||||
backend/data/dm_*.json
|
||||
backend/data/**/dm_*.json
|
||||
backend/data/**/peer_store.json
|
||||
backend/data/**/node.json
|
||||
backend/data/*.log
|
||||
backend/data/**/*.log
|
||||
backend/data/*.key
|
||||
backend/data/**/*.key
|
||||
|
||||
@@ -38,6 +38,26 @@ ADMIN_KEY=
|
||||
# Leave blank to skip this optional enrichment.
|
||||
# NUFORC_MAPBOX_TOKEN=
|
||||
|
||||
# Optional startup-risk controls.
|
||||
# On Windows, external curl fallback and the Playwright LiveUAMap scraper are
|
||||
# disabled by default so blocked upstream feeds cannot interrupt start.bat.
|
||||
# SHADOWBROKER_ENABLE_WINDOWS_CURL_FALLBACK=false
|
||||
# SHADOWBROKER_ENABLE_LIVEUAMAP_SCRAPER=false
|
||||
# AIS starts by default when AIS_API_KEY is set. Set to 0/false to force-disable.
|
||||
# SHADOWBROKER_ENABLE_AIS_STREAM_PROXY=true
|
||||
# Minimum visible satellite catalog before forcing a CelesTrak refresh.
|
||||
# SHADOWBROKER_MIN_VISIBLE_SATELLITES=350
|
||||
# Upper bound for TLE fallback satellite search when CelesTrak is unreachable.
|
||||
# SHADOWBROKER_MAX_VISIBLE_SATELLITES=450
|
||||
# NUFORC fallback uses the Hugging Face mirror when live NUFORC is unavailable.
|
||||
# NUFORC_HF_FALLBACK_LIMIT=250
|
||||
# NUFORC_HF_GEOCODE_LIMIT=150
|
||||
|
||||
# First-paint cache age budgets. These let the map and Global Threat Intercept
|
||||
# paint from the last local snapshot while live feeds refresh in the background.
|
||||
# FAST_STARTUP_CACHE_MAX_AGE_S=21600
|
||||
# INTEL_STARTUP_CACHE_MAX_AGE_S=21600
|
||||
|
||||
# Google Earth Engine for VIIRS night lights change detection (optional).
|
||||
# pip install earthengine-api
|
||||
# GEE_SERVICE_ACCOUNT_KEY=
|
||||
|
||||
@@ -11,15 +11,15 @@
|
||||
|
||||
|
||||
|
||||

|
||||
[](https://github.com/user-attachments/assets/248208ec-62f7-49d1-831d-4bd0a1fa6852)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
**ShadowBroker** is a decentralized real-time, multi-domain OSINT dashboard that fuses 60+ live intelligence feeds into a single dark-ops map interface. Aircraft, ships, satellites, conflict zones, CCTV networks, GPS jamming, internet-connected devices, police scanners, mesh radio nodes, and breaking geopolitical events — all updating in real time on one screen as well as a obfuscated communications protocol and information exchange infrastructure.
|
||||
**ShadowBroker** is a decentralized real-time, multi-domain OSINT dashboard that fuses 60+ live intelligence feeds into a single dark-ops map interface. Aircraft, ships, satellites, conflict zones, CCTV networks, GPS jamming, internet-connected devices, police scanners, mesh radio nodes, and breaking geopolitical events — all updating in real time on one screen as well as an obfuscated communications protocol and information exchange infrastructure.
|
||||
|
||||
Built with **Next.js**, **MapLibre GL**, **FastAPI**, and **Python**. 35+ toggleable data layers including SAR ground-change detection. Multiple visual modes (DEFAULT / SATELLITE / FLIR / NVG / CRT). Right-click any point on Earth for a country dossier, head-of-state lookup, and the latest Sentinel-2 satellite photo. No user data is collected or transmitted — the dashboard runs entirely in your browser against a self-hosted backend.
|
||||
Built with **Next.js**, **MapLibre GL**, **FastAPI**, and **Python**. 35+ toggleable data layers, including SAR ground-change detection. Multiple visual modes (DEFAULT / SATELLITE / FLIR / NVG / CRT). Right-click any point on Earth for a country dossier, head-of-state lookup, and the latest Sentinel-2 satellite photo. No user data is collected or transmitted — the dashboard runs entirely in your browser against a self-hosted backend.
|
||||
|
||||
Designed for analysts, researchers, radio operators, and anyone who wants to see what the world looks like when every public signal is on the same map.
|
||||
|
||||
@@ -38,10 +38,9 @@ ShadowBroker includes an optional Shodan connector for operator-supplied API acc
|
||||
|
||||
## Interesting Use Cases
|
||||
|
||||
* **Track Air Force One**, the private jets of billionaires and dictators, and every military tanker, ISR, and fighter broadcasting ADS-B. Air Force One and all of the accompanying Presidential/Vice Presidential planes are highlighted and monitored from the moment they leave the ground.
|
||||
* **Connect an AI agent as a co-analyst** through ShadowBroker's HMAC-signed agentic command channel — supports OpenClaw and any other agent that speaks the protocol (Claude, GPT, LangChain, custom). The agent gets full read/write access to all 35+ data layers, pin placement, map control, SAR ground-change, mesh networking, and alert delivery. It sees everything the operator sees and can take actions on the map in real time.
|
||||
* **Communicate on the InfoNet testnet** — The first decentralized intelligence mesh built into an OSINT tool. Obfuscated messaging with gate personas, Dead Drop peer-to-peer exchange, and a built-in terminal CLI. No accounts, no signup. Privacy is not guaranteed yet — this is an experimental testnet — but the protocol is live and being hardened.
|
||||
* **Track Air Force One**, the private jets of billionaires and dictators, and every military tanker, ISR, and fighter broadcasting ADS-B — with automatic holding pattern detection when aircraft start circling
|
||||
* **Estimate where US aircraft carriers are** using automated GDELT news scraping — no other open tool does this
|
||||
* **Search internet-connected devices worldwide** via Shodan — cameras, SCADA systems, databases — plotted as a live overlay on the map
|
||||
* **Right-click anywhere on Earth** for a country dossier (head of state, population, languages), Wikipedia summary, and the latest Sentinel-2 satellite photo at 10m resolution
|
||||
* **Click a KiwiSDR node** and tune into live shortwave radio directly in the dashboard. Click a police scanner feed and eavesdrop in one click.
|
||||
* **Watch 11,000+ CCTV cameras** across 6 countries — London, NYC, California, Spain, Singapore, and more — streaming live on the map
|
||||
@@ -51,10 +50,12 @@ ShadowBroker includes an optional Shodan connector for operator-supplied API acc
|
||||
* **Follow earthquakes, volcanic eruptions, active wildfires** (NASA FIRMS), severe weather alerts, and air quality readings worldwide
|
||||
* **Map military bases, 35,000+ power plants**, 2,000+ data centers, and internet outage regions — cross-referenced automatically
|
||||
* **Connect to Meshtastic mesh radio nodes** and APRS amateur radio networks — visible on the map and integrated into Mesh Chat
|
||||
* **Connect an AI agent as a co-analyst** through ShadowBroker's HMAC-signed agentic command channel — supports OpenClaw and any other agent that speaks the protocol (Claude, GPT, LangChain, custom). The agent gets full read/write access to all 35+ data layers, pin placement, map control, SAR ground-change, mesh networking, and alert delivery. It sees everything the operator sees and can take actions on the map in real time.
|
||||
* **Detect ground changes through cloud cover** with SAR (Synthetic Aperture Radar) — mm-scale ground deformation, flood extent, vegetation disturbance, and damage assessments from NASA OPERA and Copernicus EGMS. Define your own watch areas and get anomaly alerts. Free with a NASA Earthdata account.
|
||||
* **Switch visual modes** — DEFAULT, SATELLITE, FLIR (thermal), NVG (night vision), CRT (retro terminal) — via the STYLE button
|
||||
* **Track trains** across the US (Amtrak) and Europe (DigiTraffic) in real time
|
||||
* **Estimate where US aircraft carriers are** using automated GDELT news scraping — no other open tool does this
|
||||
* **Search internet-connected devices worldwide** via Shodan — cameras, SCADA systems, databases — plotted as a live overlay on the map
|
||||
|
||||
|
||||
---
|
||||
|
||||
@@ -943,7 +944,7 @@ ShadowBroker is built in the open. These people shipped real code:
|
||||
|
||||
| Who | What | PR |
|
||||
|-----|------|----|
|
||||
| [@Alienmajik](https://github.com/Alienmajik) | Raspberry Pi 5 support — ARM64 packaging, headless deployment notes, runtime tuning for Pi-class hardware | — |
|
||||
| [@Alienmajik](https://gitlab.com/Alienmajik) | Raspberry Pi 5 support — ARM64 packaging, headless deployment notes, runtime tuning for Pi-class hardware | — |
|
||||
| [@wa1id](https://github.com/wa1id) | CCTV ingestion fix — threaded SQLite, persistent DB, startup hydration, cluster clickability | #92 |
|
||||
| [@AlborzNazari](https://github.com/AlborzNazari) | Spain DGT + Madrid CCTV sources, STIX 2.1 threat intel export | #91 |
|
||||
| [@adust09](https://github.com/adust09) | Power plants layer, East Asia intel coverage (JSDF bases, ICAO enrichment, Taiwan news, military classification) | #71, #72, #76, #77, #87 |
|
||||
|
||||
@@ -49,6 +49,13 @@ RUN cd /workspace/backend && uv sync --frozen --no-dev \
|
||||
# Copy backend source code
|
||||
COPY backend/ .
|
||||
|
||||
# Preserve safe static data outside /app/data. The compose named volume mounted
|
||||
# at /app/data hides image-baked files on first run, so the entrypoint seeds
|
||||
# missing static JSON into fresh volumes before the backend starts.
|
||||
RUN mkdir -p /app/image-data \
|
||||
&& if [ -d /app/data ]; then cp -a /app/data/. /app/image-data/; fi \
|
||||
&& chmod +x /app/docker-entrypoint.sh
|
||||
|
||||
# Install Node.js dependencies (ws module for AIS WebSocket proxy)
|
||||
COPY backend/package*.json ./
|
||||
RUN npm ci --omit=dev
|
||||
@@ -75,4 +82,5 @@ USER backenduser
|
||||
EXPOSE 8000
|
||||
|
||||
# Start FastAPI server
|
||||
ENTRYPOINT ["/app/docker-entrypoint.sh"]
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--timeout-keep-alive", "120"]
|
||||
|
||||
+38
-5
@@ -13,6 +13,7 @@ import hmac
|
||||
import asyncio
|
||||
import hmac as _hmac_mod
|
||||
import hashlib as _hashlib_mod
|
||||
import ipaddress
|
||||
import json as json_mod
|
||||
import logging
|
||||
import time
|
||||
@@ -235,10 +236,36 @@ def _is_local_or_docker(host: str) -> bool:
|
||||
return host in {"127.0.0.1", "::1", "localhost"}
|
||||
|
||||
|
||||
def _docker_bridge_local_operator_enabled() -> bool:
|
||||
return str(os.environ.get("SHADOWBROKER_TRUST_DOCKER_BRIDGE_LOCAL_OPERATOR", "")).strip().lower() in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
}
|
||||
|
||||
|
||||
def _is_docker_bridge_host(host: str) -> bool:
|
||||
try:
|
||||
ip = ipaddress.ip_address(host)
|
||||
except ValueError:
|
||||
return False
|
||||
# Docker Desktop and the default compose bridge normally sit inside
|
||||
# 172.16.0.0/12. Keep this narrower than "any private IP" so a user who
|
||||
# intentionally binds the backend to LAN does not silently trust LAN clients.
|
||||
return ip in ipaddress.ip_network("172.16.0.0/12")
|
||||
|
||||
|
||||
def _is_trusted_local_runtime_host(host: str) -> bool:
|
||||
if _is_local_or_docker(host):
|
||||
return True
|
||||
return _docker_bridge_local_operator_enabled() and _is_docker_bridge_host(host)
|
||||
|
||||
|
||||
def require_local_operator(request: Request):
|
||||
"""Allow local tooling on loopback / Docker internal network, or a valid admin key."""
|
||||
host = (request.client.host or "").lower() if request.client else ""
|
||||
if _is_local_or_docker(host) or (_debug_mode_enabled() and host == "test"):
|
||||
if _is_trusted_local_runtime_host(host) or (_debug_mode_enabled() and host == "test"):
|
||||
return
|
||||
admin_key = _current_admin_key()
|
||||
presented = str(request.headers.get("X-Admin-Key", "") or "").strip()
|
||||
@@ -362,8 +389,8 @@ async def require_openclaw_or_local(request: Request):
|
||||
"""
|
||||
host = (request.client.host or "").lower() if request.client else ""
|
||||
|
||||
# 1. Local loopback — always allowed
|
||||
if _is_local_or_docker(host) or (_debug_mode_enabled() and host == "test"):
|
||||
# 1. Local runtime path — loopback, plus bundled Docker bridge when compose opts in
|
||||
if _is_trusted_local_runtime_host(host) or (_debug_mode_enabled() and host == "test"):
|
||||
return
|
||||
|
||||
# 2. Admin key — full trust
|
||||
@@ -402,7 +429,9 @@ async def require_openclaw_or_local(request: Request):
|
||||
# Startup validators
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_KNOWN_COMPROMISED_PEER_PUSH_SECRET = "Mv63UvLfwqOEVWeRBXjA8MtFl2nEkkhUlLYVHiX1Zzo"
|
||||
_KNOWN_COMPROMISED_PEER_PUSH_SECRET_SHA256 = (
|
||||
"be05bc75350d6e5d2e154e969c4dfc14bab1e48a9661c64ab7a331e0aa96aea7"
|
||||
)
|
||||
|
||||
|
||||
def _validate_admin_startup() -> None:
|
||||
@@ -492,7 +521,11 @@ def _validate_peer_push_secret() -> None:
|
||||
secret = os.environ.get("MESH_PEER_PUSH_SECRET", "").strip()
|
||||
|
||||
# Replace the known-compromised testnet default automatically
|
||||
if secret == _KNOWN_COMPROMISED_PEER_PUSH_SECRET:
|
||||
if (
|
||||
secret
|
||||
and _hashlib_mod.sha256(secret.encode("utf-8")).hexdigest()
|
||||
== _KNOWN_COMPROMISED_PEER_PUSH_SECRET_SHA256
|
||||
):
|
||||
logger.warning(
|
||||
"MESH_PEER_PUSH_SECRET was the publicly-known testnet default — "
|
||||
"auto-generating a secure replacement."
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
# Docker named volumes hide files that were baked into /app/data at image build
|
||||
# time. Seed safe, static data into a fresh volume so first-run Docker installs
|
||||
# behave like source installs without bundling local runtime secrets.
|
||||
if [ -d /app/image-data ]; then
|
||||
mkdir -p /app/data
|
||||
find /app/image-data -mindepth 1 -maxdepth 1 -type f | while IFS= read -r src; do
|
||||
dest="/app/data/$(basename "$src")"
|
||||
if [ ! -e "$dest" ]; then
|
||||
cp "$src" "$dest" || true
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
+138
-15
@@ -200,6 +200,7 @@ from services.data_fetcher import (
|
||||
start_scheduler,
|
||||
stop_scheduler,
|
||||
get_latest_data,
|
||||
seed_startup_caches,
|
||||
)
|
||||
from services.ais_stream import start_ais_stream, stop_ais_stream
|
||||
from services.carrier_tracker import start_carrier_tracker, stop_carrier_tracker
|
||||
@@ -2174,19 +2175,26 @@ async def lifespan(app: FastAPI):
|
||||
# Only the primary backend supervises Wormhole. The Wormhole process itself
|
||||
# runs this same app in MESH_ONLY mode and must not recurse into spawning.
|
||||
if not _MESH_ONLY:
|
||||
try:
|
||||
from services.wormhole_supervisor import get_wormhole_state, sync_wormhole_with_settings
|
||||
def _startup_wormhole_runtime():
|
||||
try:
|
||||
from services.wormhole_supervisor import get_wormhole_state, sync_wormhole_with_settings
|
||||
|
||||
sync_wormhole_with_settings()
|
||||
_resume_private_delivery_background_work(
|
||||
current_tier=_current_private_lane_tier(get_wormhole_state()),
|
||||
reason="startup_resume",
|
||||
)
|
||||
_refresh_lookup_handle_rotation_background(reason="startup_resume")
|
||||
privacy_prewarm_service.ensure_started()
|
||||
privacy_prewarm_service.run_scheduled_once(reason="startup_resume")
|
||||
except Exception as e:
|
||||
logger.warning(f"Wormhole supervisor failed to sync: {e}")
|
||||
sync_wormhole_with_settings()
|
||||
_resume_private_delivery_background_work(
|
||||
current_tier=_current_private_lane_tier(get_wormhole_state()),
|
||||
reason="startup_resume",
|
||||
)
|
||||
_refresh_lookup_handle_rotation_background(reason="startup_resume")
|
||||
privacy_prewarm_service.ensure_started()
|
||||
privacy_prewarm_service.run_scheduled_once(reason="startup_resume")
|
||||
except Exception as e:
|
||||
logger.warning(f"Wormhole supervisor failed to sync: {e}")
|
||||
|
||||
threading.Thread(
|
||||
target=_startup_wormhole_runtime,
|
||||
daemon=True,
|
||||
name="wormhole-startup-sync",
|
||||
).start()
|
||||
try:
|
||||
from services.mesh.mesh_hashchain import register_public_event_append_hook
|
||||
|
||||
@@ -2232,6 +2240,11 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
threading.Thread(target=_prime_aircraft_database, daemon=True).start()
|
||||
|
||||
# Seed cached first-paint layers before accepting requests. This is
|
||||
# disk-only and keeps the critical bootstrap endpoint independent from
|
||||
# slow network warmup.
|
||||
seed_startup_caches()
|
||||
|
||||
# Start the recurring scheduler (fast=60s, slow=30min).
|
||||
start_scheduler()
|
||||
|
||||
@@ -2239,6 +2252,9 @@ async def lifespan(app: FastAPI):
|
||||
# is listening on port 8000 instantly. The frontend's adaptive polling
|
||||
# (retries every 3s) will pick up data piecemeal as each fetcher finishes.
|
||||
def _background_preload():
|
||||
delay_s = float(os.environ.get("SHADOWBROKER_STARTUP_PRELOAD_DELAY_S", "2.0") or 0)
|
||||
if delay_s > 0:
|
||||
time.sleep(delay_s)
|
||||
logger.info("=== PRELOADING DATA (background — server already accepting requests) ===")
|
||||
try:
|
||||
update_all_data(startup_mode=True)
|
||||
@@ -3472,6 +3488,46 @@ def _sigint_totals_for_items(items: list) -> dict[str, int]:
|
||||
return totals
|
||||
|
||||
|
||||
def _cap_startup_items(items: list | None, max_items: int) -> list:
|
||||
if not items:
|
||||
return []
|
||||
if len(items) <= max_items:
|
||||
return items
|
||||
return items[:max_items]
|
||||
|
||||
|
||||
def _cap_fast_startup_payload(payload: dict) -> dict:
|
||||
"""Trim high-volume layers for the first dashboard paint.
|
||||
|
||||
The full fast payload can legitimately contain tens of thousands of AIS,
|
||||
ADS-B, SIGINT, and CCTV records. Returning all of that during app startup
|
||||
blocks the first map render behind serialization/proxy/network pressure.
|
||||
This startup payload paints representative live data immediately; the next
|
||||
normal poll replaces it with the full dataset.
|
||||
"""
|
||||
capped = dict(payload)
|
||||
capped["commercial_flights"] = _cap_startup_items(capped.get("commercial_flights"), 800)
|
||||
capped["private_flights"] = _cap_startup_items(capped.get("private_flights"), 300)
|
||||
capped["private_jets"] = _cap_startup_items(capped.get("private_jets"), 150)
|
||||
capped["ships"] = _cap_startup_items(capped.get("ships"), 1500)
|
||||
capped["cctv"] = []
|
||||
capped["sigint"] = _cap_startup_items(capped.get("sigint"), 500)
|
||||
capped["trains"] = _cap_startup_items(capped.get("trains"), 100)
|
||||
capped["startup_payload"] = True
|
||||
return capped
|
||||
|
||||
|
||||
def _cap_fast_dashboard_payload(payload: dict) -> dict:
|
||||
capped = dict(payload)
|
||||
capped["commercial_flights"] = _downsample_points(capped.get("commercial_flights") or [], 6000)
|
||||
capped["private_flights"] = _downsample_points(capped.get("private_flights") or [], 1500)
|
||||
capped["private_jets"] = _downsample_points(capped.get("private_jets") or [], 1500)
|
||||
capped["ships"] = _downsample_points(capped.get("ships") or [], 8000)
|
||||
capped["cctv"] = _downsample_points(capped.get("cctv") or [], 2500)
|
||||
capped["sigint"] = _downsample_points(capped.get("sigint") or [], 5000)
|
||||
return capped
|
||||
|
||||
|
||||
@app.get("/api/live-data/fast")
|
||||
@limiter.limit("120/minute")
|
||||
async def live_data_fast(
|
||||
@@ -3482,8 +3538,9 @@ async def live_data_fast(
|
||||
w: float = Query(None, description="West bound (ignored)", ge=-180, le=180),
|
||||
n: float = Query(None, description="North bound (ignored)", ge=-90, le=90),
|
||||
e: float = Query(None, description="East bound (ignored)", ge=-180, le=180),
|
||||
initial: bool = Query(False, description="Return a capped startup payload for first paint"),
|
||||
):
|
||||
etag = _current_etag(prefix="fast|full|")
|
||||
etag = _current_etag(prefix="fast|initial|" if initial else "fast|full|")
|
||||
if request.headers.get("if-none-match") == etag:
|
||||
return Response(status_code=304, headers={"ETag": etag, "Cache-Control": "no-cache"})
|
||||
|
||||
@@ -3548,6 +3605,10 @@ async def live_data_fast(
|
||||
"trains": (d.get("trains") or []) if active_layers.get("trains", True) else [],
|
||||
"freshness": freshness,
|
||||
}
|
||||
if initial:
|
||||
payload = _cap_fast_startup_payload(payload)
|
||||
else:
|
||||
payload = _cap_fast_dashboard_payload(payload)
|
||||
return Response(
|
||||
content=orjson.dumps(_sanitize_payload(payload)),
|
||||
media_type="application/json",
|
||||
@@ -3609,6 +3670,7 @@ async def live_data_slow(
|
||||
"correlations",
|
||||
"threat_level",
|
||||
"trending_markets",
|
||||
"fimi",
|
||||
"uap_sightings",
|
||||
"wastewater",
|
||||
"sar_scenes",
|
||||
@@ -3621,6 +3683,7 @@ async def live_data_slow(
|
||||
"last_updated": d.get("last_updated"),
|
||||
"threat_level": d.get("threat_level"),
|
||||
"trending_markets": d.get("trending_markets", []),
|
||||
"fimi": d.get("fimi", {}),
|
||||
"news": d.get("news", []),
|
||||
"stocks": d.get("stocks", {}),
|
||||
"financial_source": d.get("financial_source", ""),
|
||||
@@ -7604,6 +7667,13 @@ _CCTV_PROXY_ALLOWED_HOSTS = {
|
||||
"infocar.dgt.es", # Spain DGT
|
||||
"informo.madrid.es", # Madrid
|
||||
"www.windy.com",
|
||||
"imgproxy.windy.com", # Windy preview image CDN
|
||||
"www.lakecountypassage.com", # Illinois Lake County PASSAGE snapshots
|
||||
"webcam.forkswa.com", # WSDOT partner public camera
|
||||
"webcam.sunmountainlodge.com", # WSDOT partner public camera
|
||||
"www.nps.gov", # WSDOT-linked Mount Rainier camera
|
||||
"home.lewiscounty.com", # WSDOT partner public camera
|
||||
"www.seattle.gov", # Seattle traffic camera media linked from WSDOT
|
||||
}
|
||||
|
||||
|
||||
@@ -7729,6 +7799,16 @@ def _cctv_proxy_profile_for_url(target_url: str) -> _CCTVProxyProfile:
|
||||
cache_seconds=30,
|
||||
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8"},
|
||||
)
|
||||
if host == "www.lakecountypassage.com":
|
||||
return _CCTVProxyProfile(
|
||||
name="lake-county-passage",
|
||||
timeout=(5.0, 12.0),
|
||||
cache_seconds=30,
|
||||
headers={
|
||||
"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
||||
"Referer": "https://www.lakecountypassage.com/",
|
||||
},
|
||||
)
|
||||
if host in {"mdotjboss.state.mi.us", "micamerasimages.net"}:
|
||||
return _CCTVProxyProfile(
|
||||
name="michigan-dot",
|
||||
@@ -7791,11 +7871,27 @@ def _cctv_proxy_profile_for_url(target_url: str) -> _CCTVProxyProfile:
|
||||
"Referer": "https://informo.madrid.es/",
|
||||
},
|
||||
)
|
||||
if host == "www.windy.com":
|
||||
if host in {"www.windy.com", "imgproxy.windy.com"}:
|
||||
return _CCTVProxyProfile(
|
||||
name="windy-webcams",
|
||||
timeout=(5.0, 12.0),
|
||||
cache_seconds=60,
|
||||
headers={
|
||||
"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
||||
"Referer": "https://www.windy.com/",
|
||||
},
|
||||
)
|
||||
if host in {
|
||||
"webcam.forkswa.com",
|
||||
"webcam.sunmountainlodge.com",
|
||||
"www.nps.gov",
|
||||
"home.lewiscounty.com",
|
||||
"www.seattle.gov",
|
||||
}:
|
||||
return _CCTVProxyProfile(
|
||||
name="wsdot-partner",
|
||||
timeout=(5.0, 12.0),
|
||||
cache_seconds=30,
|
||||
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8"},
|
||||
)
|
||||
return _CCTVProxyProfile(
|
||||
@@ -7839,6 +7935,30 @@ def _cctv_response_headers(resp, cache_seconds: int, include_length: bool = True
|
||||
return headers
|
||||
|
||||
|
||||
def _infer_cctv_media_type_from_url(target_url: str, content_type: str) -> str:
|
||||
from urllib.parse import urlparse
|
||||
|
||||
normalized_type = str(content_type or "").split(";", 1)[0].strip().lower()
|
||||
if normalized_type and normalized_type not in {"application/octet-stream", "binary/octet-stream"}:
|
||||
return content_type
|
||||
path = str(urlparse(target_url).path or "").lower()
|
||||
if path.endswith((".jpg", ".jpeg")):
|
||||
return "image/jpeg"
|
||||
if path.endswith(".png"):
|
||||
return "image/png"
|
||||
if path.endswith(".gif"):
|
||||
return "image/gif"
|
||||
if path.endswith(".webp"):
|
||||
return "image/webp"
|
||||
if path.endswith(".mp4"):
|
||||
return "video/mp4"
|
||||
if path.endswith(".webm"):
|
||||
return "video/webm"
|
||||
if path.endswith(".m3u8"):
|
||||
return "application/vnd.apple.mpegurl"
|
||||
return content_type or "application/octet-stream"
|
||||
|
||||
|
||||
def _fetch_cctv_upstream_response(request: Request, target_url: str, profile: _CCTVProxyProfile):
|
||||
import requests as _req
|
||||
|
||||
@@ -7872,7 +7992,10 @@ def _proxy_cctv_media_response(request: Request, target_url: str):
|
||||
profile = _cctv_proxy_profile_for_url(target_url)
|
||||
resp = _fetch_cctv_upstream_response(request, target_url, profile)
|
||||
|
||||
content_type = resp.headers.get("Content-Type", "application/octet-stream")
|
||||
content_type = _infer_cctv_media_type_from_url(
|
||||
target_url,
|
||||
resp.headers.get("Content-Type", "application/octet-stream"),
|
||||
)
|
||||
is_hls_playlist = (
|
||||
".m3u8" in str(parsed.path or "").lower()
|
||||
or "mpegurl" in content_type.lower()
|
||||
|
||||
@@ -18,15 +18,15 @@ dependencies = [
|
||||
"fastapi==0.115.12",
|
||||
"feedparser==6.0.10",
|
||||
"httpx==0.28.1",
|
||||
"playwright==1.50.0",
|
||||
"playwright==1.59.0",
|
||||
"playwright-stealth==1.0.6",
|
||||
"pydantic==2.11.1",
|
||||
"pydantic==2.13.3",
|
||||
"pydantic-settings==2.8.1",
|
||||
"pystac-client==0.8.6",
|
||||
"python-dotenv==1.2.2",
|
||||
"requests==2.31.0",
|
||||
"reverse-geocoder==1.5.1",
|
||||
"sgp4==2.23",
|
||||
"sgp4==2.25",
|
||||
"meshtastic>=2.5.0",
|
||||
"orjson>=3.10.0",
|
||||
"paho-mqtt>=1.6.0,<2.0.0",
|
||||
@@ -34,7 +34,7 @@ dependencies = [
|
||||
"slowapi==0.1.9",
|
||||
"vaderSentiment>=3.3.0",
|
||||
"uvicorn==0.34.0",
|
||||
"yfinance==0.2.54",
|
||||
"yfinance==1.3.0",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
|
||||
@@ -28,13 +28,34 @@ class TimeMachineToggle(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
@router.get("/api/settings/api-keys", dependencies=[Depends(require_admin)])
|
||||
@router.get("/api/settings/api-keys", dependencies=[Depends(require_local_operator)])
|
||||
@limiter.limit("30/minute")
|
||||
async def api_get_keys(request: Request):
|
||||
from services.api_settings import get_api_keys
|
||||
return get_api_keys()
|
||||
|
||||
|
||||
@router.put("/api/settings/api-keys", dependencies=[Depends(require_local_operator)])
|
||||
@limiter.limit("10/minute")
|
||||
async def api_save_keys(request: Request):
|
||||
from services.api_settings import save_api_keys
|
||||
body = await request.json()
|
||||
if not isinstance(body, dict):
|
||||
return Response(
|
||||
content=json_mod.dumps({"ok": False, "detail": "Expected a JSON object."}),
|
||||
status_code=400,
|
||||
media_type="application/json",
|
||||
)
|
||||
result = save_api_keys({str(k): str(v) for k, v in body.items()})
|
||||
if result.get("ok"):
|
||||
return result
|
||||
return Response(
|
||||
content=json_mod.dumps(result),
|
||||
status_code=400,
|
||||
media_type="application/json",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/settings/api-keys/meta")
|
||||
@limiter.limit("30/minute")
|
||||
async def api_get_keys_meta(request: Request):
|
||||
|
||||
+67
-22
@@ -11,6 +11,8 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_CCTV_PROXY_CONNECT_TIMEOUT_S = 2.0
|
||||
|
||||
_CCTV_PROXY_ALLOWED_HOSTS = {
|
||||
"s3-eu-west-1.amazonaws.com",
|
||||
"jamcams.tfl.gov.uk",
|
||||
@@ -46,13 +48,20 @@ _CCTV_PROXY_ALLOWED_HOSTS = {
|
||||
"infocar.dgt.es",
|
||||
"informo.madrid.es",
|
||||
"www.windy.com",
|
||||
"imgproxy.windy.com",
|
||||
"www.lakecountypassage.com",
|
||||
"webcam.forkswa.com",
|
||||
"webcam.sunmountainlodge.com",
|
||||
"www.nps.gov",
|
||||
"home.lewiscounty.com",
|
||||
"www.seattle.gov",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _CCTVProxyProfile:
|
||||
name: str
|
||||
timeout: tuple = (5.0, 10.0)
|
||||
timeout: tuple = (_CCTV_PROXY_CONNECT_TIMEOUT_S, 8.0)
|
||||
cache_seconds: int = 30
|
||||
headers: dict = field(default_factory=dict)
|
||||
|
||||
@@ -80,69 +89,78 @@ def _cctv_proxy_profile_for_url(target_url: str) -> _CCTVProxyProfile:
|
||||
path = str(parsed.path or "").strip().lower()
|
||||
|
||||
if host in {"jamcams.tfl.gov.uk", "s3-eu-west-1.amazonaws.com"}:
|
||||
return _CCTVProxyProfile(name="tfl-jamcam", timeout=(5.0, 20.0), cache_seconds=15,
|
||||
return _CCTVProxyProfile(name="tfl-jamcam", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 20.0), cache_seconds=15,
|
||||
headers={"Accept": "video/mp4,image/avif,image/webp,image/apng,image/*,*/*;q=0.8", "Referer": "https://tfl.gov.uk/"})
|
||||
if host == "images.data.gov.sg":
|
||||
return _CCTVProxyProfile(name="lta-singapore", timeout=(5.0, 10.0), cache_seconds=30,
|
||||
return _CCTVProxyProfile(name="lta-singapore", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 10.0), cache_seconds=30,
|
||||
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8"})
|
||||
if host == "cctv.austinmobility.io":
|
||||
return _CCTVProxyProfile(name="austin-mobility", timeout=(5.0, 8.0), cache_seconds=15,
|
||||
return _CCTVProxyProfile(name="austin-mobility", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 8.0), cache_seconds=15,
|
||||
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
||||
"Referer": "https://data.mobility.austin.gov/", "Origin": "https://data.mobility.austin.gov"})
|
||||
if host == "webcams.nyctmc.org":
|
||||
return _CCTVProxyProfile(name="nyc-dot", timeout=(5.0, 10.0), cache_seconds=15,
|
||||
return _CCTVProxyProfile(name="nyc-dot", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 10.0), cache_seconds=15,
|
||||
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8"})
|
||||
if host in {"cwwp2.dot.ca.gov", "wzmedia.dot.ca.gov"}:
|
||||
return _CCTVProxyProfile(name="caltrans", timeout=(5.0, 15.0), cache_seconds=15,
|
||||
return _CCTVProxyProfile(name="caltrans", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 15.0), cache_seconds=15,
|
||||
headers={"Accept": "application/vnd.apple.mpegurl,application/x-mpegURL,video/*,image/*,*/*;q=0.8",
|
||||
"Referer": "https://cwwp2.dot.ca.gov/"})
|
||||
if host in {"images.wsdot.wa.gov", "olypen.com", "flyykm.com", "cam.pangbornairport.com"}:
|
||||
return _CCTVProxyProfile(name="wsdot", timeout=(5.0, 12.0), cache_seconds=30,
|
||||
return _CCTVProxyProfile(name="wsdot", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 12.0), cache_seconds=30,
|
||||
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8"})
|
||||
if host in {"www.lakecountypassage.com", "webcam.forkswa.com", "webcam.sunmountainlodge.com", "home.lewiscounty.com", "www.seattle.gov"}:
|
||||
return _CCTVProxyProfile(name="regional-cctv-image", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 10.0), cache_seconds=45,
|
||||
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
||||
"Referer": f"https://{host}/"})
|
||||
if host == "www.nps.gov":
|
||||
return _CCTVProxyProfile(name="nps-webcam", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 10.0), cache_seconds=60,
|
||||
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
||||
"Referer": "https://www.nps.gov/"})
|
||||
if host in {"navigator-c2c.dot.ga.gov", "navigator-c2c.ga.gov", "navigator-csc.dot.ga.gov"}:
|
||||
read_timeout = 18.0 if "/snapshots/" in path else 12.0
|
||||
return _CCTVProxyProfile(name="gdot-snapshot", timeout=(5.0, read_timeout), cache_seconds=15,
|
||||
return _CCTVProxyProfile(name="gdot-snapshot", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, read_timeout), cache_seconds=15,
|
||||
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
||||
"Referer": "http://navigator-c2c.dot.ga.gov/"})
|
||||
if host == "511ga.org":
|
||||
return _CCTVProxyProfile(name="gdot-511ga-image", timeout=(5.0, 12.0), cache_seconds=15,
|
||||
return _CCTVProxyProfile(name="gdot-511ga-image", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 12.0), cache_seconds=15,
|
||||
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
||||
"Referer": "https://511ga.org/cctv"})
|
||||
if host.startswith("vss") and host.endswith("dot.ga.gov"):
|
||||
return _CCTVProxyProfile(name="gdot-hls", timeout=(5.0, 20.0), cache_seconds=10,
|
||||
return _CCTVProxyProfile(name="gdot-hls", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 20.0), cache_seconds=10,
|
||||
headers={"Accept": "application/vnd.apple.mpegurl,application/x-mpegURL,video/*,*/*;q=0.8",
|
||||
"Referer": "http://navigator-c2c.dot.ga.gov/"})
|
||||
if host in {"gettingaroundillinois.com", "cctv.travelmidwest.com"}:
|
||||
return _CCTVProxyProfile(name="illinois-dot", timeout=(5.0, 12.0), cache_seconds=30,
|
||||
return _CCTVProxyProfile(name="illinois-dot", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 12.0), cache_seconds=30,
|
||||
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8"})
|
||||
if host in {"mdotjboss.state.mi.us", "micamerasimages.net"}:
|
||||
return _CCTVProxyProfile(name="michigan-dot", timeout=(5.0, 12.0), cache_seconds=30,
|
||||
return _CCTVProxyProfile(name="michigan-dot", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 12.0), cache_seconds=30,
|
||||
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
||||
"Referer": "https://mdotjboss.state.mi.us/"})
|
||||
if host in {"publicstreamer1.cotrip.org", "publicstreamer2.cotrip.org",
|
||||
"publicstreamer3.cotrip.org", "publicstreamer4.cotrip.org"}:
|
||||
return _CCTVProxyProfile(name="cotrip-hls", timeout=(5.0, 20.0), cache_seconds=10,
|
||||
return _CCTVProxyProfile(name="cotrip-hls", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 20.0), cache_seconds=10,
|
||||
headers={"Accept": "application/vnd.apple.mpegurl,application/x-mpegURL,video/*,*/*;q=0.8",
|
||||
"Referer": "https://www.cotrip.org/"})
|
||||
if host == "cocam.carsprogram.org":
|
||||
return _CCTVProxyProfile(name="cotrip-preview", timeout=(5.0, 12.0), cache_seconds=20,
|
||||
return _CCTVProxyProfile(name="cotrip-preview", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 12.0), cache_seconds=20,
|
||||
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
||||
"Referer": "https://www.cotrip.org/"})
|
||||
if host in {"tripcheck.com", "www.tripcheck.com"}:
|
||||
return _CCTVProxyProfile(name="odot-tripcheck", timeout=(5.0, 12.0), cache_seconds=30,
|
||||
return _CCTVProxyProfile(name="odot-tripcheck", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 12.0), cache_seconds=30,
|
||||
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8"})
|
||||
if host == "infocar.dgt.es":
|
||||
return _CCTVProxyProfile(name="dgt-spain", timeout=(5.0, 8.0), cache_seconds=60,
|
||||
return _CCTVProxyProfile(name="dgt-spain", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 8.0), cache_seconds=60,
|
||||
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
||||
"Referer": "https://infocar.dgt.es/"})
|
||||
if host == "informo.madrid.es":
|
||||
return _CCTVProxyProfile(name="madrid-city", timeout=(5.0, 12.0), cache_seconds=30,
|
||||
return _CCTVProxyProfile(name="madrid-city", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 12.0), cache_seconds=30,
|
||||
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
||||
"Referer": "https://informo.madrid.es/"})
|
||||
if host == "www.windy.com":
|
||||
return _CCTVProxyProfile(name="windy-webcams", timeout=(5.0, 12.0), cache_seconds=60,
|
||||
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8"})
|
||||
return _CCTVProxyProfile(name="generic-cctv", timeout=(5.0, 10.0), cache_seconds=30,
|
||||
if host in {"www.windy.com", "imgproxy.windy.com"}:
|
||||
return _CCTVProxyProfile(name="windy-webcams", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 12.0), cache_seconds=60,
|
||||
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
||||
"Referer": "https://www.windy.com/"})
|
||||
return _CCTVProxyProfile(name="generic-cctv", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 8.0), cache_seconds=30,
|
||||
headers={"Accept": "*/*"})
|
||||
|
||||
|
||||
@@ -221,13 +239,40 @@ def _rewrite_cctv_hls_playlist(base_url: str, body: str) -> str:
|
||||
return "\n".join(rewritten_lines) + ("\n" if body.endswith("\n") else "")
|
||||
|
||||
|
||||
def _infer_cctv_media_type_from_url(target_url: str, content_type: str) -> str:
|
||||
from urllib.parse import urlparse
|
||||
|
||||
clean_type = str(content_type or "").split(";", 1)[0].strip().lower()
|
||||
if clean_type and clean_type not in {"application/octet-stream", "binary/octet-stream"}:
|
||||
return content_type
|
||||
path = str(urlparse(target_url).path or "").lower()
|
||||
if path.endswith((".jpg", ".jpeg")):
|
||||
return "image/jpeg"
|
||||
if path.endswith(".png"):
|
||||
return "image/png"
|
||||
if path.endswith(".webp"):
|
||||
return "image/webp"
|
||||
if path.endswith(".gif"):
|
||||
return "image/gif"
|
||||
if path.endswith(".mp4"):
|
||||
return "video/mp4"
|
||||
if path.endswith((".m3u8", ".m3u")):
|
||||
return "application/vnd.apple.mpegurl"
|
||||
if path.endswith((".mjpg", ".mjpeg")):
|
||||
return "multipart/x-mixed-replace"
|
||||
return content_type or "application/octet-stream"
|
||||
|
||||
|
||||
def _proxy_cctv_media_response(request: Request, target_url: str):
|
||||
from urllib.parse import urlparse
|
||||
from fastapi.responses import Response
|
||||
parsed = urlparse(target_url)
|
||||
profile = _cctv_proxy_profile_for_url(target_url)
|
||||
resp = _fetch_cctv_upstream_response(request, target_url, profile)
|
||||
content_type = resp.headers.get("Content-Type", "application/octet-stream")
|
||||
content_type = _infer_cctv_media_type_from_url(
|
||||
target_url,
|
||||
resp.headers.get("Content-Type", "application/octet-stream"),
|
||||
)
|
||||
is_hls_playlist = (
|
||||
".m3u8" in str(parsed.path or "").lower()
|
||||
or "mpegurl" in content_type.lower()
|
||||
|
||||
+139
-7
@@ -185,11 +185,29 @@ def _bbox_spans(s, w, n, e) -> tuple:
|
||||
return lat_span, max(0.0, lng_span)
|
||||
|
||||
|
||||
def _downsample_points(items: list, max_items: int) -> list:
|
||||
if max_items <= 0 or len(items) <= max_items:
|
||||
def _cap_startup_items(items: list | None, max_items: int) -> list:
|
||||
if not items:
|
||||
return []
|
||||
if len(items) <= max_items:
|
||||
return items
|
||||
step = len(items) / float(max_items)
|
||||
return [items[min(len(items) - 1, int(i * step))] for i in range(max_items)]
|
||||
return items[:max_items]
|
||||
|
||||
|
||||
def _cap_fast_startup_payload(payload: dict) -> dict:
|
||||
capped = dict(payload)
|
||||
capped["commercial_flights"] = _cap_startup_items(capped.get("commercial_flights"), 800)
|
||||
capped["private_flights"] = _cap_startup_items(capped.get("private_flights"), 300)
|
||||
capped["private_jets"] = _cap_startup_items(capped.get("private_jets"), 150)
|
||||
capped["ships"] = _cap_startup_items(capped.get("ships"), 1500)
|
||||
capped["cctv"] = []
|
||||
capped["sigint"] = _cap_startup_items(capped.get("sigint"), 500)
|
||||
capped["trains"] = _cap_startup_items(capped.get("trains"), 100)
|
||||
capped["startup_payload"] = True
|
||||
return capped
|
||||
|
||||
|
||||
def _cap_fast_dashboard_payload(payload: dict) -> dict:
|
||||
return payload
|
||||
|
||||
|
||||
def _world_and_continental_scale(has_bbox: bool, s, w, n, e) -> tuple:
|
||||
@@ -306,8 +324,19 @@ async def update_layers(update: LayerUpdate, request: Request):
|
||||
sigint_grid.mesh.stop()
|
||||
logger.info("Meshtastic MQTT bridge stopped (layer disabled)")
|
||||
elif not old_mesh and new_mesh:
|
||||
sigint_grid.mesh.start()
|
||||
logger.info("Meshtastic MQTT bridge started (layer enabled)")
|
||||
try:
|
||||
from services.config import get_settings
|
||||
mqtt_enabled = bool(getattr(get_settings(), "MESH_MQTT_ENABLED", False))
|
||||
except Exception:
|
||||
mqtt_enabled = False
|
||||
if mqtt_enabled:
|
||||
sigint_grid.mesh.start()
|
||||
logger.info("Meshtastic MQTT bridge started (layer enabled)")
|
||||
else:
|
||||
logger.info(
|
||||
"Meshtastic layer enabled; MQTT bridge remains disabled "
|
||||
"(set MESH_MQTT_ENABLED=true to participate in the public broker)"
|
||||
)
|
||||
if old_aprs and not new_aprs:
|
||||
sigint_grid.aprs.stop()
|
||||
logger.info("APRS bridge stopped (layer disabled)")
|
||||
@@ -326,6 +355,104 @@ async def live_data(request: Request):
|
||||
return get_latest_data()
|
||||
|
||||
|
||||
@router.get("/api/bootstrap/critical")
|
||||
@limiter.limit("180/minute")
|
||||
async def bootstrap_critical(request: Request):
|
||||
"""Cached first-paint payload for the dashboard.
|
||||
|
||||
This endpoint is intentionally memory-only: no upstream calls, no refresh,
|
||||
and a bounded response. It exists so the map and threat feed can paint
|
||||
before slower panels and background enrichers finish warming up.
|
||||
"""
|
||||
etag = _current_etag(prefix="bootstrap|critical|")
|
||||
if request.headers.get("if-none-match") == etag:
|
||||
return Response(status_code=304, headers={"ETag": etag, "Cache-Control": "no-cache"})
|
||||
from services.fetchers._store import (
|
||||
active_layers,
|
||||
get_latest_data_subset_refs,
|
||||
get_source_timestamps_snapshot,
|
||||
)
|
||||
|
||||
d = get_latest_data_subset_refs(
|
||||
"last_updated", "commercial_flights", "military_flights", "private_flights",
|
||||
"private_jets", "tracked_flights", "ships", "uavs", "liveuamap", "gps_jamming",
|
||||
"satellites", "satellite_source", "satellite_analysis", "sigint", "sigint_totals",
|
||||
"trains", "news", "gdelt", "airports", "threat_level", "trending_markets",
|
||||
"correlations", "fimi", "crowdthreat",
|
||||
)
|
||||
freshness = get_source_timestamps_snapshot()
|
||||
ships_enabled = any(active_layers.get(key, True) for key in (
|
||||
"ships_military", "ships_cargo", "ships_civilian", "ships_passenger", "ships_tracked_yachts"))
|
||||
sigint_items = _filter_sigint_by_layers(d.get("sigint") or [], active_layers)
|
||||
payload = {
|
||||
"last_updated": d.get("last_updated"),
|
||||
"commercial_flights": _cap_startup_items(
|
||||
(d.get("commercial_flights") or []) if active_layers.get("flights", True) else [],
|
||||
800,
|
||||
),
|
||||
"military_flights": _cap_startup_items(
|
||||
(d.get("military_flights") or []) if active_layers.get("military", True) else [],
|
||||
300,
|
||||
),
|
||||
"private_flights": _cap_startup_items(
|
||||
(d.get("private_flights") or []) if active_layers.get("private", True) else [],
|
||||
300,
|
||||
),
|
||||
"private_jets": _cap_startup_items(
|
||||
(d.get("private_jets") or []) if active_layers.get("jets", True) else [],
|
||||
150,
|
||||
),
|
||||
"tracked_flights": _cap_startup_items(
|
||||
(d.get("tracked_flights") or []) if active_layers.get("tracked", True) else [],
|
||||
250,
|
||||
),
|
||||
"ships": _cap_startup_items((d.get("ships") or []) if ships_enabled else [], 1500),
|
||||
"uavs": _cap_startup_items((d.get("uavs") or []) if active_layers.get("military", True) else [], 100),
|
||||
"liveuamap": _cap_startup_items(
|
||||
(d.get("liveuamap") or []) if active_layers.get("global_incidents", True) else [],
|
||||
300,
|
||||
),
|
||||
"gps_jamming": _cap_startup_items(
|
||||
(d.get("gps_jamming") or []) if active_layers.get("gps_jamming", True) else [],
|
||||
200,
|
||||
),
|
||||
"satellites": _cap_startup_items(
|
||||
(d.get("satellites") or []) if active_layers.get("satellites", True) else [],
|
||||
250,
|
||||
),
|
||||
"satellite_source": d.get("satellite_source", "none"),
|
||||
"satellite_analysis": (d.get("satellite_analysis") or {}) if active_layers.get("satellites", True) else {},
|
||||
"sigint": _cap_startup_items(
|
||||
sigint_items if (active_layers.get("sigint_meshtastic", True) or active_layers.get("sigint_aprs", True)) else [],
|
||||
500,
|
||||
),
|
||||
"sigint_totals": _sigint_totals_for_items(sigint_items),
|
||||
"trains": _cap_startup_items((d.get("trains") or []) if active_layers.get("trains", True) else [], 100),
|
||||
"news": _cap_startup_items(d.get("news") or [], 30),
|
||||
"gdelt": _cap_startup_items((d.get("gdelt") or []) if active_layers.get("global_incidents", True) else [], 300),
|
||||
"airports": _cap_startup_items(d.get("airports") or [], 500),
|
||||
"threat_level": d.get("threat_level"),
|
||||
"trending_markets": _cap_startup_items(d.get("trending_markets") or [], 10),
|
||||
"correlations": _cap_startup_items(
|
||||
(d.get("correlations") or []) if active_layers.get("correlations", True) else [],
|
||||
50,
|
||||
),
|
||||
"fimi": d.get("fimi"),
|
||||
"crowdthreat": _cap_startup_items(
|
||||
(d.get("crowdthreat") or []) if active_layers.get("crowdthreat", True) else [],
|
||||
150,
|
||||
),
|
||||
"freshness": freshness,
|
||||
"bootstrap_ready": True,
|
||||
"bootstrap_payload": True,
|
||||
}
|
||||
return Response(
|
||||
content=orjson.dumps(_sanitize_payload(payload), default=str, option=orjson.OPT_NON_STR_KEYS),
|
||||
media_type="application/json",
|
||||
headers={"ETag": etag, "Cache-Control": "no-cache"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/live-data/fast")
|
||||
@limiter.limit("120/minute")
|
||||
async def live_data_fast(
|
||||
@@ -334,8 +461,9 @@ async def live_data_fast(
|
||||
w: float = Query(None, description="West bound (ignored)", ge=-180, le=180),
|
||||
n: float = Query(None, description="North bound (ignored)", ge=-90, le=90),
|
||||
e: float = Query(None, description="East bound (ignored)", ge=-180, le=180),
|
||||
initial: bool = Query(False, description="Return a capped startup payload for first paint"),
|
||||
):
|
||||
etag = _current_etag(prefix="fast|full|")
|
||||
etag = _current_etag(prefix="fast|initial|" if initial else "fast|full|")
|
||||
if request.headers.get("if-none-match") == etag:
|
||||
return Response(status_code=304, headers={"ETag": etag, "Cache-Control": "no-cache"})
|
||||
from services.fetchers._store import (active_layers, get_latest_data_subset_refs, get_source_timestamps_snapshot)
|
||||
@@ -371,6 +499,10 @@ async def live_data_fast(
|
||||
"trains": (d.get("trains") or []) if active_layers.get("trains", True) else [],
|
||||
"freshness": freshness,
|
||||
}
|
||||
if initial:
|
||||
payload = _cap_fast_startup_payload(payload)
|
||||
else:
|
||||
payload = _cap_fast_dashboard_payload(payload)
|
||||
return Response(content=orjson.dumps(_sanitize_payload(payload)), media_type="application/json",
|
||||
headers={"ETag": etag, "Cache-Control": "no-cache"})
|
||||
|
||||
|
||||
@@ -17,6 +17,18 @@ AIS_WS_URL = "wss://stream.aisstream.io/v0/stream"
|
||||
API_KEY = os.environ.get("AIS_API_KEY", "")
|
||||
|
||||
|
||||
def _env_truthy(name: str) -> bool:
|
||||
return str(os.getenv(name, "")).strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def ais_stream_proxy_enabled() -> bool:
|
||||
"""Return whether the external Node AIS proxy may be started."""
|
||||
setting = str(os.getenv("SHADOWBROKER_ENABLE_AIS_STREAM_PROXY", "")).strip().lower()
|
||||
if setting:
|
||||
return _env_truthy("SHADOWBROKER_ENABLE_AIS_STREAM_PROXY")
|
||||
return True
|
||||
|
||||
|
||||
# AIS vessel type code classification
|
||||
# See: https://coast.noaa.gov/data/marinecadastre/ais/VesselTypeCodes2018.pdf
|
||||
def classify_vessel(ais_type: int, mmsi: int) -> str:
|
||||
@@ -496,6 +508,12 @@ def _ais_stream_loop():
|
||||
logger.info("Starting Node.js AIS Stream Proxy...")
|
||||
proxy_env = os.environ.copy()
|
||||
proxy_env["AIS_API_KEY"] = API_KEY
|
||||
popen_kwargs = {}
|
||||
if os.name == "nt":
|
||||
popen_kwargs["creationflags"] = (
|
||||
getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||
| getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
||||
)
|
||||
process = subprocess.Popen(
|
||||
["node", proxy_script],
|
||||
stdin=subprocess.PIPE,
|
||||
@@ -504,6 +522,7 @@ def _ais_stream_loop():
|
||||
text=True,
|
||||
bufsize=1,
|
||||
env=proxy_env,
|
||||
**popen_kwargs,
|
||||
)
|
||||
with _vessels_lock:
|
||||
_proxy_process = process
|
||||
@@ -646,6 +665,22 @@ def _run_ais_loop():
|
||||
def start_ais_stream():
|
||||
"""Start the AIS WebSocket stream in a background thread."""
|
||||
global _ws_thread, _ws_running
|
||||
|
||||
# Always load cached vessel data first so the ships layer can paint even
|
||||
# when live streaming is disabled or the upstream is unavailable.
|
||||
_load_cache()
|
||||
|
||||
if not API_KEY:
|
||||
logger.info("AIS_API_KEY not set — ship tracking disabled. Set AIS_API_KEY to enable.")
|
||||
return
|
||||
|
||||
if not ais_stream_proxy_enabled():
|
||||
logger.info(
|
||||
"AIS live stream proxy disabled for this runtime; using cached AIS vessels. "
|
||||
"Set SHADOWBROKER_ENABLE_AIS_STREAM_PROXY=1 to opt in."
|
||||
)
|
||||
return
|
||||
|
||||
with _vessels_lock:
|
||||
if _ws_running:
|
||||
logger.info("AIS Stream already running")
|
||||
@@ -656,9 +691,6 @@ def start_ais_stream():
|
||||
logger.info("AIS Stream already running")
|
||||
return
|
||||
|
||||
# Load cached vessel data from disk
|
||||
_load_cache()
|
||||
|
||||
_ws_thread = threading.Thread(target=_run_ais_loop, daemon=True, name="ais-stream")
|
||||
_ws_thread.start()
|
||||
logger.info("AIS Stream background thread started")
|
||||
|
||||
@@ -4,12 +4,21 @@ Keys are stored in the backend .env file and loaded via python-dotenv.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
# Path to the backend .env file
|
||||
ENV_PATH = Path(__file__).parent.parent / ".env"
|
||||
# Path to the example template that ships with the repo
|
||||
ENV_EXAMPLE_PATH = Path(__file__).parent.parent.parent / ".env.example"
|
||||
DATA_DIR = Path(os.environ.get("SB_DATA_DIR", str(Path(__file__).parent.parent / "data")))
|
||||
if not DATA_DIR.is_absolute():
|
||||
DATA_DIR = Path(__file__).parent.parent / DATA_DIR
|
||||
OPERATOR_KEYS_ENV_PATH = Path(
|
||||
os.environ.get("SHADOWBROKER_OPERATOR_KEYS_ENV", str(DATA_DIR / "operator_api_keys.env"))
|
||||
)
|
||||
_ENV_KEY_RE = re.compile(r"^[A-Z][A-Z0-9_]*$")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# API Registry — every external service the dashboard depends on
|
||||
@@ -143,6 +152,85 @@ API_REGISTRY = [
|
||||
},
|
||||
]
|
||||
|
||||
ALLOWED_ENV_KEYS = {
|
||||
str(api["env_key"])
|
||||
for api in API_REGISTRY
|
||||
if api.get("env_key")
|
||||
}
|
||||
|
||||
|
||||
def _parse_env_file(path: Path) -> dict[str, str]:
|
||||
values: dict[str, str] = {}
|
||||
if not path.exists():
|
||||
return values
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return values
|
||||
for raw_line in text.splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
key = key.strip()
|
||||
if not _ENV_KEY_RE.match(key):
|
||||
continue
|
||||
value = value.strip()
|
||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
|
||||
value = value[1:-1]
|
||||
values[key] = value
|
||||
return values
|
||||
|
||||
|
||||
def _quote_env_value(value: str) -> str:
|
||||
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
|
||||
return f'"{escaped}"'
|
||||
|
||||
|
||||
def _write_env_values(path: Path, updates: dict[str, str]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
lines = path.read_text(encoding="utf-8").splitlines() if path.exists() else []
|
||||
seen: set[str] = set()
|
||||
next_lines: list[str] = []
|
||||
for raw_line in lines:
|
||||
stripped = raw_line.strip()
|
||||
if "=" not in stripped or stripped.startswith("#"):
|
||||
next_lines.append(raw_line)
|
||||
continue
|
||||
key = stripped.split("=", 1)[0].strip()
|
||||
if key in updates:
|
||||
next_lines.append(f"{key}={_quote_env_value(updates[key])}")
|
||||
seen.add(key)
|
||||
else:
|
||||
next_lines.append(raw_line)
|
||||
for key, value in updates.items():
|
||||
if key not in seen:
|
||||
next_lines.append(f"{key}={_quote_env_value(value)}")
|
||||
|
||||
fd, tmp_name = tempfile.mkstemp(dir=str(path.parent), prefix=f"{path.name}.tmp.", text=True)
|
||||
tmp_path = Path(tmp_name)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
|
||||
handle.write("\n".join(next_lines).rstrip() + "\n")
|
||||
if os.name != "nt":
|
||||
os.chmod(tmp_path, 0o600)
|
||||
os.replace(tmp_path, path)
|
||||
if os.name != "nt":
|
||||
os.chmod(path, 0o600)
|
||||
finally:
|
||||
try:
|
||||
if tmp_path.exists():
|
||||
tmp_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def load_persisted_api_keys_into_environ() -> None:
|
||||
"""Load persisted operator API keys if no process env value exists."""
|
||||
for key, value in _parse_env_file(OPERATOR_KEYS_ENV_PATH).items():
|
||||
if key in ALLOWED_ENV_KEYS and value and not os.environ.get(key):
|
||||
os.environ[key] = value
|
||||
|
||||
|
||||
def get_env_path_info() -> dict:
|
||||
"""Return absolute paths for the backend .env and .env.example template.
|
||||
@@ -160,6 +248,10 @@ def get_env_path_info() -> dict:
|
||||
and (not env_path.exists() or os.access(env_path, os.W_OK)),
|
||||
"env_example_path": str(example_path),
|
||||
"env_example_path_exists": example_path.exists(),
|
||||
"operator_keys_env_path": str(OPERATOR_KEYS_ENV_PATH.resolve()),
|
||||
"operator_keys_env_path_exists": OPERATOR_KEYS_ENV_PATH.exists(),
|
||||
"operator_keys_env_path_writable": os.access(OPERATOR_KEYS_ENV_PATH.parent, os.W_OK)
|
||||
and (not OPERATOR_KEYS_ENV_PATH.exists() or os.access(OPERATOR_KEYS_ENV_PATH, os.W_OK)),
|
||||
}
|
||||
|
||||
|
||||
@@ -171,6 +263,7 @@ def get_api_keys():
|
||||
`is_set` to render a CONFIGURED / NOT CONFIGURED badge and the path
|
||||
info from `get_env_path_info()` to tell them where to put each key.
|
||||
"""
|
||||
load_persisted_api_keys_into_environ()
|
||||
result = []
|
||||
for api in API_REGISTRY:
|
||||
entry = {
|
||||
@@ -189,3 +282,57 @@ def get_api_keys():
|
||||
entry["is_set"] = bool(raw)
|
||||
result.append(entry)
|
||||
return result
|
||||
|
||||
|
||||
def save_api_keys(updates: dict[str, str]) -> dict:
|
||||
"""Persist allowed API keys from a local operator request.
|
||||
|
||||
Values are accepted write-only: the response includes only configured flags.
|
||||
"""
|
||||
clean: dict[str, str] = {}
|
||||
for key, value in updates.items():
|
||||
env_key = str(key or "").strip().upper()
|
||||
if env_key not in ALLOWED_ENV_KEYS:
|
||||
continue
|
||||
clean_value = str(value or "").strip()
|
||||
if clean_value:
|
||||
clean[env_key] = clean_value
|
||||
if not clean:
|
||||
return {"ok": False, "detail": "No supported API keys were provided."}
|
||||
|
||||
_write_env_values(OPERATOR_KEYS_ENV_PATH, clean)
|
||||
try:
|
||||
_write_env_values(ENV_PATH, clean)
|
||||
except OSError:
|
||||
# The persistent operator key file is the source of truth for Docker.
|
||||
pass
|
||||
for key, value in clean.items():
|
||||
os.environ[key] = value
|
||||
if "AIS_API_KEY" in clean:
|
||||
try:
|
||||
from services import ais_stream
|
||||
ais_stream.API_KEY = clean["AIS_API_KEY"]
|
||||
except Exception:
|
||||
pass
|
||||
if "OPENSKY_CLIENT_ID" in clean or "OPENSKY_CLIENT_SECRET" in clean:
|
||||
try:
|
||||
from services.fetchers import flights
|
||||
flights.opensky_client.client_id = os.environ.get("OPENSKY_CLIENT_ID", "")
|
||||
flights.opensky_client.client_secret = os.environ.get("OPENSKY_CLIENT_SECRET", "")
|
||||
flights.opensky_client.token = None
|
||||
flights.opensky_client.expires_at = 0
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
from services.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"updated": sorted(clean.keys()),
|
||||
"keys": get_api_keys(),
|
||||
"env": get_env_path_info(),
|
||||
}
|
||||
|
||||
@@ -210,6 +210,7 @@ class Settings(BaseSettings):
|
||||
MESH_ALLOW_RAW_SECURE_STORAGE_FALLBACK: bool = False
|
||||
MESH_ACK_RAW_FALLBACK_AT_OWN_RISK: bool = False
|
||||
MESH_SECURE_STORAGE_SECRET: str = ""
|
||||
MESH_SECURE_STORAGE_SECRET_FILE: str = ""
|
||||
MESH_PRIVATE_LOG_TTL_S: int = 900
|
||||
# Sprint 1 rollout: restored DM boot probes stay disabled by default until
|
||||
# the architect reviews false positives from the observe-only path.
|
||||
@@ -302,6 +303,11 @@ class Settings(BaseSettings):
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
try:
|
||||
from services.api_settings import load_persisted_api_keys_into_environ
|
||||
load_persisted_api_keys_into_environ()
|
||||
except Exception:
|
||||
pass
|
||||
return Settings()
|
||||
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import concurrent.futures
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
@@ -105,7 +106,7 @@ _SLOW_FETCH_S = float(os.environ.get("FETCH_SLOW_THRESHOLD_S", "5"))
|
||||
# Hard wall-clock limit per individual fetch task. A task that exceeds this
|
||||
# is treated as a failure so it cannot block an entire fetch tier indefinitely.
|
||||
_TASK_HARD_TIMEOUT_S = float(os.environ.get("FETCH_TASK_TIMEOUT_S", "120"))
|
||||
_FAST_STARTUP_CACHE_MAX_AGE_S = float(os.environ.get("FAST_STARTUP_CACHE_MAX_AGE_S", "300"))
|
||||
_FAST_STARTUP_CACHE_MAX_AGE_S = float(os.environ.get("FAST_STARTUP_CACHE_MAX_AGE_S", "21600"))
|
||||
_FAST_STARTUP_CACHE_PATH = Path(__file__).resolve().parents[1] / "data" / "fast_startup_cache.json"
|
||||
_FAST_STARTUP_CACHE_KEYS = (
|
||||
"commercial_flights",
|
||||
@@ -123,6 +124,21 @@ _FAST_STARTUP_CACHE_KEYS = (
|
||||
"sigint_totals",
|
||||
"trains",
|
||||
)
|
||||
_INTEL_STARTUP_CACHE_MAX_AGE_S = float(os.environ.get("INTEL_STARTUP_CACHE_MAX_AGE_S", "21600"))
|
||||
_INTEL_STARTUP_CACHE_PATH = Path(__file__).resolve().parents[1] / "data" / "intel_startup_cache.json"
|
||||
_INTEL_STARTUP_CACHE_KEYS = (
|
||||
"news",
|
||||
"gdelt",
|
||||
"liveuamap",
|
||||
"threat_level",
|
||||
"trending_markets",
|
||||
"correlations",
|
||||
"fimi",
|
||||
)
|
||||
_STARTUP_PRIORITY_TIMEOUT_S = float(os.environ.get("SHADOWBROKER_STARTUP_PRIORITY_TIMEOUT_S", "18"))
|
||||
_STARTUP_HEAVY_REFRESH_DELAY_S = float(os.environ.get("SHADOWBROKER_STARTUP_HEAVY_REFRESH_DELAY_S", "90"))
|
||||
_STARTUP_HEAVY_REFRESH_STARTED = False
|
||||
_STARTUP_HEAVY_REFRESH_LOCK = threading.Lock()
|
||||
|
||||
# Shared thread pool — reused across all fetch cycles instead of creating/destroying per tick
|
||||
_SHARED_EXECUTOR = concurrent.futures.ThreadPoolExecutor(
|
||||
@@ -204,6 +220,77 @@ def _save_fast_startup_cache() -> None:
|
||||
logger.debug("Fast startup cache save skipped: %s", e)
|
||||
|
||||
|
||||
def _load_intel_startup_cache_if_available() -> bool:
|
||||
"""Seed the right-side intelligence panel from disk while live feeds warm up."""
|
||||
if _INTEL_STARTUP_CACHE_MAX_AGE_S <= 0 or not _INTEL_STARTUP_CACHE_PATH.exists():
|
||||
return False
|
||||
try:
|
||||
with _INTEL_STARTUP_CACHE_PATH.open("r", encoding="utf-8") as fh:
|
||||
payload = json.load(fh)
|
||||
cached_at = float(payload.get("cached_at") or 0)
|
||||
age_s = time.time() - cached_at
|
||||
if cached_at <= 0 or age_s > _INTEL_STARTUP_CACHE_MAX_AGE_S:
|
||||
logger.info("Skipping stale intel startup cache (age %.1fs)", age_s)
|
||||
return False
|
||||
layers = payload.get("layers") or {}
|
||||
freshness = payload.get("freshness") or {}
|
||||
loaded: list[str] = []
|
||||
with _data_lock:
|
||||
for key in _INTEL_STARTUP_CACHE_KEYS:
|
||||
if key in layers:
|
||||
latest_data[key] = layers[key]
|
||||
loaded.append(key)
|
||||
for key, ts in freshness.items():
|
||||
source_timestamps[str(key)] = ts
|
||||
if payload.get("last_updated"):
|
||||
latest_data["last_updated"] = payload.get("last_updated")
|
||||
if not loaded:
|
||||
return False
|
||||
from services.fetchers._store import bump_data_version
|
||||
|
||||
bump_data_version()
|
||||
logger.info(
|
||||
"Loaded intel startup cache for %d layers (age %.1fs) so Global Threat Intercept can paint early",
|
||||
len(loaded),
|
||||
age_s,
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning("Intel startup cache load failed (non-fatal): %s", e)
|
||||
return False
|
||||
|
||||
|
||||
def _save_intel_startup_cache() -> None:
|
||||
"""Persist compact right-side intelligence data for the next cold start."""
|
||||
try:
|
||||
with _data_lock:
|
||||
payload = {
|
||||
"cached_at": time.time(),
|
||||
"last_updated": latest_data.get("last_updated"),
|
||||
"layers": {key: latest_data.get(key) for key in _INTEL_STARTUP_CACHE_KEYS},
|
||||
"freshness": {
|
||||
key: source_timestamps.get(key)
|
||||
for key in _INTEL_STARTUP_CACHE_KEYS
|
||||
if source_timestamps.get(key)
|
||||
},
|
||||
}
|
||||
safe_payload = _cache_json_safe(payload)
|
||||
_INTEL_STARTUP_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp_path = _INTEL_STARTUP_CACHE_PATH.with_suffix(".tmp")
|
||||
with tmp_path.open("w", encoding="utf-8") as fh:
|
||||
json.dump(safe_payload, fh, separators=(",", ":"))
|
||||
tmp_path.replace(_INTEL_STARTUP_CACHE_PATH)
|
||||
except Exception as e:
|
||||
logger.debug("Intel startup cache save skipped: %s", e)
|
||||
|
||||
|
||||
def seed_startup_caches() -> None:
|
||||
"""Load disk-backed first-paint caches without touching remote services."""
|
||||
load_meshtastic_cache_if_available()
|
||||
_load_fast_startup_cache_if_available()
|
||||
_load_intel_startup_cache_if_available()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scheduler & Orchestration
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -262,7 +349,6 @@ def update_fast_data():
|
||||
fetch_satellites,
|
||||
fetch_sigint,
|
||||
fetch_trains,
|
||||
fetch_tinygs,
|
||||
]
|
||||
_run_tasks("fast-tier", fast_funcs)
|
||||
with _data_lock:
|
||||
@@ -289,6 +375,7 @@ def update_slow_data():
|
||||
fetch_cctv,
|
||||
fetch_kiwisdr,
|
||||
fetch_satnogs,
|
||||
fetch_tinygs,
|
||||
fetch_frontlines,
|
||||
fetch_datacenters,
|
||||
fetch_military_bases,
|
||||
@@ -313,9 +400,76 @@ def update_slow_data():
|
||||
logger.error("Correlation engine failed: %s", e)
|
||||
from services.fetchers._store import bump_data_version
|
||||
bump_data_version()
|
||||
_save_intel_startup_cache()
|
||||
logger.info("Slow-tier update complete.")
|
||||
|
||||
|
||||
def _record_fetch_success(label: str, name: str, start: float) -> None:
|
||||
duration = time.perf_counter() - start
|
||||
from services.fetch_health import record_success
|
||||
|
||||
record_success(name, duration_s=duration)
|
||||
if duration > _SLOW_FETCH_S:
|
||||
logger.warning(f"{label} task slow: {name} took {duration:.2f}s")
|
||||
|
||||
|
||||
def _record_fetch_failure(label: str, name: str, start: float, error: Exception) -> None:
|
||||
duration = time.perf_counter() - start
|
||||
from services.fetch_health import record_failure
|
||||
|
||||
record_failure(name, error=error, duration_s=duration)
|
||||
logger.exception(f"{label} task failed: {name}")
|
||||
|
||||
|
||||
def _load_cctv_cache_for_startup() -> None:
|
||||
"""Load cached CCTV rows without running remote ingestors during first paint."""
|
||||
try:
|
||||
fetch_cctv()
|
||||
except Exception as e:
|
||||
logger.warning("Startup CCTV cache load failed (non-fatal): %s", e)
|
||||
|
||||
|
||||
def _run_delayed_startup_heavy_refresh() -> None:
|
||||
if _STARTUP_HEAVY_REFRESH_DELAY_S > 0:
|
||||
logger.info(
|
||||
"Startup heavy synthesis delayed %.0fs so the dashboard can finish first paint",
|
||||
_STARTUP_HEAVY_REFRESH_DELAY_S,
|
||||
)
|
||||
time.sleep(_STARTUP_HEAVY_REFRESH_DELAY_S)
|
||||
logger.info("Startup heavy synthesis beginning (slow feeds, enrichment, daily products)...")
|
||||
_run_tasks(
|
||||
"startup-heavy",
|
||||
[
|
||||
update_slow_data,
|
||||
fetch_volcanoes,
|
||||
fetch_viirs_change_nodes,
|
||||
fetch_unusual_whales,
|
||||
fetch_fimi,
|
||||
fetch_uap_sightings,
|
||||
fetch_wastewater,
|
||||
fetch_sar_catalog,
|
||||
fetch_sar_products,
|
||||
],
|
||||
)
|
||||
logger.info("Startup heavy synthesis complete.")
|
||||
|
||||
|
||||
def _schedule_delayed_startup_heavy_refresh() -> None:
|
||||
global _STARTUP_HEAVY_REFRESH_STARTED
|
||||
if _STARTUP_HEAVY_REFRESH_DELAY_S < 0:
|
||||
logger.info("Startup heavy synthesis disabled by SHADOWBROKER_STARTUP_HEAVY_REFRESH_DELAY_S")
|
||||
return
|
||||
with _STARTUP_HEAVY_REFRESH_LOCK:
|
||||
if _STARTUP_HEAVY_REFRESH_STARTED:
|
||||
return
|
||||
_STARTUP_HEAVY_REFRESH_STARTED = True
|
||||
threading.Thread(
|
||||
target=_run_delayed_startup_heavy_refresh,
|
||||
name="startup-heavy-refresh",
|
||||
daemon=True,
|
||||
).start()
|
||||
|
||||
|
||||
def update_all_data(*, startup_mode: bool = False):
|
||||
"""Full refresh.
|
||||
|
||||
@@ -324,10 +478,51 @@ def update_all_data(*, startup_mode: bool = False):
|
||||
"""
|
||||
logger.info("Full data update starting (parallel)...")
|
||||
# Preload Meshtastic map cache immediately (instant, from disk)
|
||||
load_meshtastic_cache_if_available()
|
||||
_load_fast_startup_cache_if_available()
|
||||
seed_startup_caches()
|
||||
with _data_lock:
|
||||
meshtastic_seeded = bool(latest_data.get("meshtastic_map_nodes"))
|
||||
if startup_mode:
|
||||
_load_cctv_cache_for_startup()
|
||||
priority_funcs = [
|
||||
fetch_airports,
|
||||
update_fast_data,
|
||||
fetch_gdelt,
|
||||
fetch_crowdthreat,
|
||||
fetch_firms_fires,
|
||||
fetch_weather_alerts,
|
||||
]
|
||||
if not meshtastic_seeded:
|
||||
priority_funcs.append(fetch_meshtastic_nodes)
|
||||
else:
|
||||
logger.info(
|
||||
"Startup preload: Meshtastic cache already loaded, deferring remote map refresh to scheduled cadence"
|
||||
)
|
||||
logger.info("Startup priority preload starting (%d tasks)...", len(priority_funcs))
|
||||
cycle_start = time.perf_counter()
|
||||
futures = {
|
||||
_SHARED_EXECUTOR.submit(func): (func.__name__, time.perf_counter())
|
||||
for func in priority_funcs
|
||||
}
|
||||
for future, (name, start) in futures.items():
|
||||
remaining = _STARTUP_PRIORITY_TIMEOUT_S - (time.perf_counter() - cycle_start)
|
||||
if remaining <= 0:
|
||||
logger.info("Startup priority budget reached; %s will continue in background", name)
|
||||
continue
|
||||
try:
|
||||
future.result(timeout=remaining)
|
||||
_record_fetch_success("startup-priority", name, start)
|
||||
except concurrent.futures.TimeoutError:
|
||||
logger.info(
|
||||
"Startup priority task still warming after %.1fs: %s",
|
||||
time.perf_counter() - start,
|
||||
name,
|
||||
)
|
||||
except Exception as e:
|
||||
_record_fetch_failure("startup-priority", name, start, e)
|
||||
logger.info("Startup preload: deferring Playwright Liveuamap scraper to scheduled cadence")
|
||||
_schedule_delayed_startup_heavy_refresh()
|
||||
logger.info("Startup priority preload complete; slow synthesis is warming in background.")
|
||||
return
|
||||
futures = {
|
||||
_SHARED_EXECUTOR.submit(fetch_airports): ("fetch_airports", time.perf_counter()),
|
||||
_SHARED_EXECUTOR.submit(update_fast_data): ("update_fast_data", time.perf_counter()),
|
||||
@@ -337,7 +532,6 @@ def update_all_data(*, startup_mode: bool = False):
|
||||
_SHARED_EXECUTOR.submit(fetch_unusual_whales): ("fetch_unusual_whales", time.perf_counter()),
|
||||
_SHARED_EXECUTOR.submit(fetch_fimi): ("fetch_fimi", time.perf_counter()),
|
||||
_SHARED_EXECUTOR.submit(fetch_gdelt): ("fetch_gdelt", time.perf_counter()),
|
||||
_SHARED_EXECUTOR.submit(update_liveuamap): ("update_liveuamap", time.perf_counter()),
|
||||
_SHARED_EXECUTOR.submit(fetch_uap_sightings): ("fetch_uap_sightings", time.perf_counter()),
|
||||
_SHARED_EXECUTOR.submit(fetch_wastewater): ("fetch_wastewater", time.perf_counter()),
|
||||
_SHARED_EXECUTOR.submit(fetch_crowdthreat): ("fetch_crowdthreat", time.perf_counter()),
|
||||
@@ -353,6 +547,13 @@ def update_all_data(*, startup_mode: bool = False):
|
||||
logger.info(
|
||||
"Startup preload: Meshtastic cache already loaded, deferring remote map refresh to scheduled cadence"
|
||||
)
|
||||
if not startup_mode:
|
||||
futures[_SHARED_EXECUTOR.submit(update_liveuamap)] = (
|
||||
"update_liveuamap",
|
||||
time.perf_counter(),
|
||||
)
|
||||
else:
|
||||
logger.info("Startup preload: deferring Playwright Liveuamap scraper to scheduled cadence")
|
||||
for future, (name, start) in futures.items():
|
||||
try:
|
||||
future.result(timeout=_TASK_HARD_TIMEOUT_S)
|
||||
@@ -408,7 +609,7 @@ def update_all_data(*, startup_mode: bool = False):
|
||||
|
||||
|
||||
_scheduler = None
|
||||
_STARTUP_CCTV_INGEST_DELAY_S = 30
|
||||
_STARTUP_CCTV_INGEST_DELAY_S = int(os.environ.get("SHADOWBROKER_STARTUP_CCTV_INGEST_DELAY_S", "180"))
|
||||
_FINANCIAL_REFRESH_MINUTES = 30
|
||||
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import time
|
||||
import heapq
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from services.network_utils import fetch_with_curl
|
||||
from services.network_utils import external_curl_fallback_enabled, fetch_with_curl
|
||||
from services.fetchers._store import latest_data, _data_lock, _mark_fresh
|
||||
from services.fetchers.nuforc_enrichment import enrich_sighting
|
||||
from services.fetchers.retry import with_retry
|
||||
@@ -685,6 +685,8 @@ _NUFORC_TOKEN = os.environ.get("NUFORC_MAPBOX_TOKEN", "").strip()
|
||||
_NUFORC_RADIUS_M = 200_000 # 200 km query radius
|
||||
_NUFORC_LIMIT = 50 # max features per tilequery call
|
||||
_NUFORC_RECENT_DAYS = int(os.environ.get("NUFORC_RECENT_DAYS", "60"))
|
||||
_NUFORC_HF_FALLBACK_LIMIT = max(25, int(os.environ.get("NUFORC_HF_FALLBACK_LIMIT", "250")))
|
||||
_NUFORC_HF_GEOCODE_LIMIT = max(25, int(os.environ.get("NUFORC_HF_GEOCODE_LIMIT", "150")))
|
||||
_NUFORC_GEOCODE_WORKERS = max(1, int(os.environ.get("NUFORC_GEOCODE_WORKERS", "1")))
|
||||
# Photon (Komoot) is more lenient than Nominatim — ~200ms per query in
|
||||
# practice, so a 0.3s spacing keeps us well under any soft throttle while
|
||||
@@ -1034,6 +1036,14 @@ def _nuforc_fetch_month_live(yyyymm: str, cookie_jar: Path) -> list[dict]:
|
||||
index_url = _NUFORC_LIVE_INDEX_URL.format(yyyymm=yyyymm)
|
||||
ajax_url = _NUFORC_LIVE_AJAX_URL.format(yyyymm=yyyymm)
|
||||
|
||||
if not external_curl_fallback_enabled():
|
||||
logger.warning(
|
||||
"NUFORC live: external curl disabled on Windows for %s; "
|
||||
"set SHADOWBROKER_ENABLE_WINDOWS_CURL_FALLBACK=1 to opt in.",
|
||||
yyyymm,
|
||||
)
|
||||
return []
|
||||
|
||||
# Step 1: GET the month index to capture session cookies + fresh nonce.
|
||||
try:
|
||||
index_res = subprocess.run(
|
||||
@@ -1340,6 +1350,143 @@ def _build_recent_uap_sightings() -> list[dict]:
|
||||
return sightings
|
||||
|
||||
|
||||
def _split_uap_location(location: str) -> tuple[str, str, str]:
|
||||
parts = [p.strip() for p in str(location or "").split(",") if p.strip()]
|
||||
city = parts[0] if parts else ""
|
||||
state = ""
|
||||
country = ""
|
||||
if len(parts) >= 2:
|
||||
state = parts[1]
|
||||
if len(parts) >= 3:
|
||||
country = parts[-1]
|
||||
if country and country.upper() in _US_COUNTRY_ALIASES:
|
||||
country = "US"
|
||||
return city, state, country
|
||||
|
||||
|
||||
def _build_uap_sightings_from_hf_mirror() -> list[dict]:
|
||||
"""Build visible UAP points from the public Hugging Face NUFORC mirror.
|
||||
|
||||
This is a resilience fallback for local/Windows runs where nuforc.org is
|
||||
Cloudflare-gated and the Mapbox token is not configured. It is not as fresh
|
||||
as the live NUFORC AJAX feed, but it keeps the layer visible and cached.
|
||||
"""
|
||||
from services.fetchers.nuforc_enrichment import _HF_CSV_URL, _parse_date
|
||||
from services.geocode_validate import coord_in_country
|
||||
|
||||
try:
|
||||
response = fetch_with_curl(_HF_CSV_URL, timeout=180, follow_redirects=True)
|
||||
if not response or response.status_code != 200:
|
||||
logger.warning(
|
||||
"UAP sightings: HF fallback failed HTTP %s",
|
||||
getattr(response, "status_code", "None"),
|
||||
)
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.warning("UAP sightings: HF fallback download failed: %s", e)
|
||||
return []
|
||||
|
||||
candidates: list[dict] = []
|
||||
try:
|
||||
reader = csv.DictReader(io.StringIO(response.text))
|
||||
for row in reader:
|
||||
occurred = _parse_date(
|
||||
row.get("Occurred", "")
|
||||
or row.get("Date / Time", "")
|
||||
or row.get("Date", "")
|
||||
)
|
||||
if not occurred:
|
||||
continue
|
||||
raw_location = _normalize_uap_location(
|
||||
row.get("Location", "")
|
||||
or row.get("City", "")
|
||||
or row.get("location", "")
|
||||
)
|
||||
if not raw_location:
|
||||
continue
|
||||
city, state, country = _split_uap_location(raw_location)
|
||||
if not city:
|
||||
continue
|
||||
sighting_id = str(row.get("Sighting", "") or "").strip()
|
||||
if not sighting_id:
|
||||
sighting_id = hashlib.sha1(
|
||||
f"{occurred}|{raw_location}|{row.get('Summary', '')}".encode("utf-8", "ignore")
|
||||
).hexdigest()[:12]
|
||||
summary = (row.get("Summary", "") or row.get("Text", "") or "Sighting reported").strip()
|
||||
if len(summary) > 280:
|
||||
summary = summary[:277] + "..."
|
||||
candidates.append({
|
||||
"id": f"NUFORC-{sighting_id}",
|
||||
"occurred": occurred,
|
||||
"posted": _parse_date(row.get("Posted", "") or row.get("Reported", "")) or occurred,
|
||||
"location": raw_location,
|
||||
"city": city,
|
||||
"state": state,
|
||||
"country": country or _uap_country_from_location(raw_location, state),
|
||||
"shape_raw": (row.get("Shape", "") or "Unknown").strip(),
|
||||
"duration": (row.get("Duration", "") or "").strip(),
|
||||
"summary": summary,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning("UAP sightings: HF fallback parse failed: %s", e)
|
||||
return []
|
||||
|
||||
candidates.sort(key=lambda row: (row["occurred"], row["posted"], row["id"]), reverse=True)
|
||||
candidates = candidates[:_NUFORC_HF_FALLBACK_LIMIT]
|
||||
|
||||
location_cache = _load_nuforc_location_cache()
|
||||
sightings: list[dict] = []
|
||||
geocoded = 0
|
||||
for row in candidates:
|
||||
coords = location_cache.get(row["location"])
|
||||
if row["location"] not in location_cache and geocoded < _NUFORC_HF_GEOCODE_LIMIT:
|
||||
try:
|
||||
coords = _geocode_uap_location(
|
||||
row["location"], row["city"], row["state"], row["country"]
|
||||
)
|
||||
except Exception:
|
||||
coords = None
|
||||
location_cache[row["location"]] = coords
|
||||
geocoded += 1
|
||||
if geocoded < _NUFORC_HF_GEOCODE_LIMIT:
|
||||
time.sleep(_NUFORC_GEOCODE_SPACING_S)
|
||||
if not coords:
|
||||
continue
|
||||
if row.get("country"):
|
||||
try:
|
||||
inside = coord_in_country(coords[0], coords[1], row["country"])
|
||||
except Exception:
|
||||
inside = None
|
||||
if inside is False:
|
||||
continue
|
||||
shape_raw = row["shape_raw"] or "Unknown"
|
||||
sightings.append({
|
||||
"id": row["id"],
|
||||
"date_time": row["occurred"],
|
||||
"city": row["city"],
|
||||
"state": row["state"],
|
||||
"country": row["country"],
|
||||
"shape": _normalize_uap_shape(shape_raw) if shape_raw != "Unknown" else "unknown",
|
||||
"shape_raw": shape_raw,
|
||||
"duration": row["duration"],
|
||||
"summary": row["summary"],
|
||||
"posted": row["posted"],
|
||||
"lat": float(coords[0]),
|
||||
"lng": float(coords[1]),
|
||||
"count": 1,
|
||||
"source": "NUFORC-HF",
|
||||
})
|
||||
|
||||
_save_nuforc_location_cache(location_cache)
|
||||
logger.info(
|
||||
"UAP sightings: %d mapped reports from HF fallback (%d candidates, %d geocoded)",
|
||||
len(sightings),
|
||||
len(candidates),
|
||||
geocoded,
|
||||
)
|
||||
return sightings
|
||||
|
||||
|
||||
@with_retry(max_retries=1, base_delay=5)
|
||||
def fetch_uap_sightings(*, force_refresh: bool = False):
|
||||
"""Fetch last-year UAP sightings from NUFORC.
|
||||
@@ -1355,12 +1502,18 @@ def fetch_uap_sightings(*, force_refresh: bool = False):
|
||||
|
||||
sightings = _load_nuforc_sightings_cache(force_refresh=force_refresh)
|
||||
if sightings is None:
|
||||
sightings = _build_recent_uap_sightings()
|
||||
_save_nuforc_sightings_cache(sightings)
|
||||
try:
|
||||
sightings = _build_recent_uap_sightings()
|
||||
except Exception as e:
|
||||
logger.warning("UAP sightings: live NUFORC rebuild failed, using fallback: %s", e)
|
||||
sightings = _build_uap_sightings_from_hf_mirror()
|
||||
if sightings:
|
||||
_save_nuforc_sightings_cache(sightings)
|
||||
|
||||
with _data_lock:
|
||||
latest_data["uap_sightings"] = sightings
|
||||
_mark_fresh("uap_sightings")
|
||||
latest_data["uap_sightings"] = sightings or []
|
||||
if sightings:
|
||||
_mark_fresh("uap_sightings")
|
||||
return
|
||||
|
||||
cutoff = datetime.utcnow() - timedelta(days=_NUFORC_RECENT_DAYS)
|
||||
|
||||
@@ -15,6 +15,24 @@ from services.fetchers.retry import with_retry
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _env_flag(name: str) -> str:
|
||||
return str(os.getenv(name, "")).strip().lower()
|
||||
|
||||
|
||||
def liveuamap_scraper_enabled() -> bool:
|
||||
"""Return whether the Playwright-based LiveUAMap scraper should run.
|
||||
|
||||
It is useful enrichment, but it starts a browser/Node driver and must not be
|
||||
allowed to destabilize Windows local startup.
|
||||
"""
|
||||
setting = _env_flag("SHADOWBROKER_ENABLE_LIVEUAMAP_SCRAPER")
|
||||
if setting in {"1", "true", "yes", "on"}:
|
||||
return True
|
||||
if setting in {"0", "false", "no", "off"}:
|
||||
return False
|
||||
return os.name != "nt"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ships (AIS + Carriers)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -191,6 +209,12 @@ def update_liveuamap():
|
||||
|
||||
if not is_any_active("global_incidents"):
|
||||
return
|
||||
if not liveuamap_scraper_enabled():
|
||||
logger.info(
|
||||
"Liveuamap scraper disabled for this runtime; set "
|
||||
"SHADOWBROKER_ENABLE_LIVEUAMAP_SCRAPER=1 to opt in."
|
||||
)
|
||||
return
|
||||
logger.info("Running scheduled Liveuamap scraper...")
|
||||
try:
|
||||
from services.liveuamap_scraper import fetch_liveuamap
|
||||
|
||||
@@ -15,6 +15,7 @@ Analysis features (derived from cached TLEs — no extra network requests):
|
||||
import math
|
||||
import time
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import logging
|
||||
import requests
|
||||
@@ -41,6 +42,38 @@ def _gmst(jd_ut1):
|
||||
# CelesTrak fair use: fetch at most once per 24 hours (86400s).
|
||||
# SGP4 propagation runs every 60s using cached TLEs — positions stay live.
|
||||
_CELESTRAK_FETCH_INTERVAL = 86400 # 24 hours
|
||||
_MIN_VISIBLE_SATELLITE_CATALOG = int(os.environ.get("SHADOWBROKER_MIN_VISIBLE_SATELLITES", "350"))
|
||||
_MAX_VISIBLE_SATELLITE_CATALOG = int(os.environ.get("SHADOWBROKER_MAX_VISIBLE_SATELLITES", "450"))
|
||||
_CELESTRAK_VISIBLE_GROUPS = {
|
||||
"military": {"mission": "military", "sat_type": "Military / Defense"},
|
||||
"radar": {"mission": "sar", "sat_type": "Radar / SAR"},
|
||||
"resource": {"mission": "earth_observation", "sat_type": "Earth Observation"},
|
||||
"weather": {"mission": "weather", "sat_type": "Weather / Meteorology"},
|
||||
"gnss": {"mission": "navigation", "sat_type": "GNSS / Navigation"},
|
||||
"science": {"mission": "science", "sat_type": "Science"},
|
||||
}
|
||||
_TLE_VISIBLE_FALLBACK_TERMS = {
|
||||
"COSMOS": {"mission": "military", "sat_type": "Russian / Soviet Military"},
|
||||
"USA": {"mission": "military", "sat_type": "US Military / NRO"},
|
||||
"NROL": {"mission": "military", "sat_type": "Classified NRO"},
|
||||
"GPS": {"mission": "navigation", "sat_type": "GPS Navigation"},
|
||||
"GALILEO": {"mission": "navigation", "sat_type": "Galileo Navigation"},
|
||||
"BEIDOU": {"mission": "navigation", "sat_type": "BeiDou Navigation"},
|
||||
"GLONASS": {"mission": "navigation", "sat_type": "GLONASS Navigation"},
|
||||
"NOAA": {"mission": "weather", "sat_type": "NOAA Weather"},
|
||||
"METEOR": {"mission": "weather", "sat_type": "Meteor Weather"},
|
||||
"SENTINEL": {"mission": "earth_observation", "sat_type": "Sentinel Earth Observation"},
|
||||
"LANDSAT": {"mission": "earth_observation", "sat_type": "Landsat Earth Observation"},
|
||||
"WORLDVIEW": {"mission": "commercial_imaging", "sat_type": "Maxar High-Res"},
|
||||
"PLEIADES": {"mission": "commercial_imaging", "sat_type": "Airbus Imaging"},
|
||||
"SKYSAT": {"mission": "commercial_imaging", "sat_type": "Planet Video"},
|
||||
"JILIN": {"mission": "commercial_imaging", "sat_type": "Jilin Imaging"},
|
||||
"FLOCK": {"mission": "commercial_imaging", "sat_type": "PlanetScope"},
|
||||
"LEMUR": {"mission": "commercial_rf", "sat_type": "Spire RF / AIS"},
|
||||
"ICEYE": {"mission": "sar", "sat_type": "ICEYE SAR"},
|
||||
"UMBRA": {"mission": "sar", "sat_type": "Umbra SAR"},
|
||||
"CAPELLA": {"mission": "sar", "sat_type": "Capella SAR"},
|
||||
}
|
||||
_sat_gp_cache = {"data": None, "last_fetch": 0, "source": "none", "last_modified": None}
|
||||
_sat_classified_cache = {"data": None, "gp_fetch_ts": 0}
|
||||
_SAT_CACHE_PATH = Path(__file__).parent.parent.parent / "data" / "sat_gp_cache.json"
|
||||
@@ -564,9 +597,61 @@ def _parse_tle_to_gp(name, norad_id, line1, line2):
|
||||
return None
|
||||
|
||||
|
||||
def _annotate_celestrak_group(records: list[dict], group: str) -> list[dict]:
|
||||
meta = _CELESTRAK_VISIBLE_GROUPS.get(group, {})
|
||||
out = []
|
||||
for sat in records:
|
||||
if not isinstance(sat, dict):
|
||||
continue
|
||||
item = dict(sat)
|
||||
item["_SB_GROUP"] = group
|
||||
if meta:
|
||||
item["_SB_GROUP_META"] = meta
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
|
||||
def _fetch_visible_celestrak_catalog(headers: dict | None = None) -> list[dict]:
|
||||
"""Fetch bounded CelesTrak groups used by the visible satellite layer.
|
||||
|
||||
The full ``active`` catalog is too large and frequently times out on local
|
||||
startup. These groups cover the visible operational set users expect
|
||||
without pulling Starlink-scale constellations into the map.
|
||||
"""
|
||||
headers = headers or {}
|
||||
merged: dict[int, dict] = {}
|
||||
for group in _CELESTRAK_VISIBLE_GROUPS:
|
||||
url = f"https://celestrak.org/NORAD/elements/gp.php?GROUP={group}&FORMAT=json"
|
||||
try:
|
||||
response = fetch_with_curl(url, timeout=15, headers=headers)
|
||||
if response.status_code != 200:
|
||||
logger.debug("Satellites: CelesTrak group %s returned HTTP %s", group, response.status_code)
|
||||
continue
|
||||
gp_data = response.json()
|
||||
if not isinstance(gp_data, list):
|
||||
continue
|
||||
for sat in _annotate_celestrak_group(gp_data, group):
|
||||
norad_id = sat.get("NORAD_CAT_ID")
|
||||
if norad_id is None:
|
||||
continue
|
||||
merged[int(norad_id)] = sat
|
||||
time.sleep(0.35)
|
||||
except (
|
||||
requests.RequestException,
|
||||
ConnectionError,
|
||||
TimeoutError,
|
||||
ValueError,
|
||||
KeyError,
|
||||
json.JSONDecodeError,
|
||||
OSError,
|
||||
) as e:
|
||||
logger.warning("Satellites: Failed to fetch CelesTrak group %s: %s", group, e)
|
||||
return list(merged.values())
|
||||
|
||||
|
||||
def _fetch_satellites_from_tle_api():
|
||||
"""Fallback: fetch satellite TLEs from tle.ivanstanojevic.me when CelesTrak is blocked."""
|
||||
search_terms = set()
|
||||
search_terms = set(_TLE_VISIBLE_FALLBACK_TERMS)
|
||||
for key, _ in _SAT_INTEL_DB:
|
||||
term = key.split()[0] if len(key.split()) > 1 and key.split()[0] in ("USA", "NROL") else key
|
||||
search_terms.add(term)
|
||||
@@ -591,8 +676,13 @@ def _fetch_satellites_from_tle_api():
|
||||
sat_id = gp.get("NORAD_CAT_ID")
|
||||
if sat_id not in seen_ids:
|
||||
seen_ids.add(sat_id)
|
||||
if term in _TLE_VISIBLE_FALLBACK_TERMS:
|
||||
gp["_SB_GROUP"] = f"tle:{term}"
|
||||
gp["_SB_GROUP_META"] = _TLE_VISIBLE_FALLBACK_TERMS[term]
|
||||
all_results.append(gp)
|
||||
time.sleep(1) # Polite delay between requests
|
||||
if len(all_results) >= _MAX_VISIBLE_SATELLITE_CATALOG:
|
||||
return all_results
|
||||
time.sleep(0.15) # Polite delay between requests
|
||||
except (
|
||||
requests.RequestException,
|
||||
ConnectionError,
|
||||
@@ -644,18 +734,34 @@ def fetch_satellites():
|
||||
|
||||
if (
|
||||
_sat_gp_cache["data"] is None
|
||||
or len(_sat_gp_cache.get("data") or []) < _MIN_VISIBLE_SATELLITE_CATALOG
|
||||
or (now_ts - _sat_gp_cache["last_fetch"]) > _CELESTRAK_FETCH_INTERVAL
|
||||
):
|
||||
gp_urls = [
|
||||
"https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=json",
|
||||
"https://celestrak.com/NORAD/elements/gp.php?GROUP=active&FORMAT=json",
|
||||
]
|
||||
# Build conditional request headers (CelesTrak fair use)
|
||||
headers = {}
|
||||
if _sat_gp_cache.get("last_modified"):
|
||||
headers["If-Modified-Since"] = _sat_gp_cache["last_modified"]
|
||||
|
||||
visible_data = _fetch_visible_celestrak_catalog(headers=headers)
|
||||
if len(visible_data) >= _MIN_VISIBLE_SATELLITE_CATALOG:
|
||||
_sat_gp_cache["data"] = visible_data
|
||||
_sat_gp_cache["last_fetch"] = now_ts
|
||||
_sat_gp_cache["source"] = "celestrak_visible_groups"
|
||||
_save_sat_cache(visible_data)
|
||||
_snapshot_current_tles(visible_data)
|
||||
logger.info(
|
||||
"Satellites: Downloaded %d GP records from visible CelesTrak groups",
|
||||
len(visible_data),
|
||||
)
|
||||
|
||||
gp_urls = [
|
||||
"https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=json",
|
||||
"https://celestrak.com/NORAD/elements/gp.php?GROUP=active&FORMAT=json",
|
||||
]
|
||||
|
||||
for url in gp_urls:
|
||||
if len(_sat_gp_cache.get("data") or []) >= _MIN_VISIBLE_SATELLITE_CATALOG:
|
||||
break
|
||||
try:
|
||||
response = fetch_with_curl(url, timeout=15, headers=headers)
|
||||
if response.status_code == 304:
|
||||
@@ -696,7 +802,10 @@ def fetch_satellites():
|
||||
logger.warning(f"Satellites: Failed to fetch from {url}: {e}")
|
||||
continue
|
||||
|
||||
if _sat_gp_cache["data"] is None:
|
||||
if (
|
||||
_sat_gp_cache["data"] is None
|
||||
or len(_sat_gp_cache.get("data") or []) < _MIN_VISIBLE_SATELLITE_CATALOG
|
||||
):
|
||||
logger.info("Satellites: CelesTrak unreachable, trying TLE fallback API...")
|
||||
try:
|
||||
fallback_data = _fetch_satellites_from_tle_api()
|
||||
@@ -757,6 +866,9 @@ def fetch_satellites():
|
||||
owner = sat.get("OWNER", sat.get("OBJECT_OWNER", ""))
|
||||
if owner in _OWNER_CODE_MAP:
|
||||
intel = {"country": _OWNER_CODE_MAP[owner], "mission": "general", "sat_type": "Unclassified"}
|
||||
if not intel and sat.get("_SB_GROUP_META"):
|
||||
intel = dict(sat["_SB_GROUP_META"])
|
||||
intel.setdefault("country", "Unknown")
|
||||
if not intel:
|
||||
continue
|
||||
|
||||
|
||||
@@ -230,11 +230,16 @@ def _raw_fallback_allowed() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _generated_secret_file() -> Path:
|
||||
return DATA_DIR / "secure_storage_secret.key"
|
||||
|
||||
|
||||
def _get_storage_secret() -> str | None:
|
||||
"""Return the operator-supplied secure storage secret, or None."""
|
||||
"""Return the operator-supplied or local generated secure storage secret."""
|
||||
secret = os.environ.get("MESH_SECURE_STORAGE_SECRET", "").strip()
|
||||
if secret:
|
||||
return secret
|
||||
secret_file_override = os.environ.get("MESH_SECURE_STORAGE_SECRET_FILE", "").strip()
|
||||
try:
|
||||
from services.config import get_settings
|
||||
|
||||
@@ -242,8 +247,36 @@ def _get_storage_secret() -> str | None:
|
||||
secret = str(getattr(settings, "MESH_SECURE_STORAGE_SECRET", "") or "").strip()
|
||||
if secret:
|
||||
return secret
|
||||
secret_file_override = (
|
||||
secret_file_override
|
||||
or str(getattr(settings, "MESH_SECURE_STORAGE_SECRET_FILE", "") or "").strip()
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
if not _is_windows():
|
||||
if _raw_fallback_allowed():
|
||||
return None
|
||||
secret_file = Path(secret_file_override or _generated_secret_file())
|
||||
try:
|
||||
if secret_file.exists():
|
||||
secret = secret_file.read_text(encoding="utf-8").strip()
|
||||
if secret:
|
||||
return secret
|
||||
secret_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
secret = _b64(os.urandom(48))
|
||||
_atomic_write_text(secret_file, secret + "\n", encoding="utf-8")
|
||||
try:
|
||||
os.chmod(secret_file, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
logger.info("Generated local secure storage secret at %s", secret_file)
|
||||
return secret
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to load or generate local secure storage secret at %s: %s",
|
||||
secret_file,
|
||||
exc,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import shutil
|
||||
import time
|
||||
@@ -20,7 +21,6 @@ _session.mount("http://", HTTPAdapter(max_retries=_retry, pool_maxsize=10))
|
||||
|
||||
# Find bash for curl fallback — Git bash's curl has the TLS features
|
||||
# needed to pass CDN fingerprint checks (brotli, zstd, libpsl)
|
||||
_BASH_PATH = shutil.which("bash") or "bash"
|
||||
|
||||
# Cache domains where requests fails — skip straight to curl for 5 minutes
|
||||
_domain_fail_cache: dict[str, float] = {}
|
||||
@@ -39,6 +39,17 @@ class UpstreamCircuitBreakerError(OSError):
|
||||
"""Raised when a domain recently failed hard and is temporarily skipped."""
|
||||
|
||||
|
||||
def _env_truthy(name: str) -> bool:
|
||||
return str(os.getenv(name, "")).strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def external_curl_fallback_enabled() -> bool:
|
||||
"""Return whether the backend may spawn an external curl process."""
|
||||
if os.name != "nt":
|
||||
return True
|
||||
return _env_truthy("SHADOWBROKER_ENABLE_WINDOWS_CURL_FALLBACK")
|
||||
|
||||
|
||||
class _DummyResponse:
|
||||
"""Minimal response object matching requests.Response interface."""
|
||||
def __init__(self, status_code, text):
|
||||
@@ -98,11 +109,22 @@ def fetch_with_curl(url, method="GET", json_data=None, timeout=15, headers=None,
|
||||
_circuit_breaker.pop(domain, None)
|
||||
return res
|
||||
except (requests.RequestException, ConnectionError, TimeoutError, OSError) as e:
|
||||
logger.warning(f"Python requests failed for {url} ({e}), falling back to bash curl...")
|
||||
fallback = "falling back to curl" if external_curl_fallback_enabled() else "skipping external curl"
|
||||
logger.warning(f"Python requests failed for {url} ({e}), {fallback}...")
|
||||
with _cb_lock:
|
||||
_domain_fail_cache[domain] = time.time()
|
||||
|
||||
# Curl fallback — reached from both _skip_requests and requests-exception paths
|
||||
if not external_curl_fallback_enabled():
|
||||
logger.warning(
|
||||
"External curl fallback disabled on Windows for %s; set "
|
||||
"SHADOWBROKER_ENABLE_WINDOWS_CURL_FALLBACK=1 to opt in.",
|
||||
domain,
|
||||
)
|
||||
with _cb_lock:
|
||||
_circuit_breaker[domain] = time.time()
|
||||
return _DummyResponse(500, "")
|
||||
|
||||
_CURL_PATH = shutil.which("curl") or "curl"
|
||||
cmd = [_CURL_PATH, "-s", "-w", "\n%{http_code}"]
|
||||
if follow_redirects:
|
||||
@@ -116,9 +138,16 @@ def fetch_with_curl(url, method="GET", json_data=None, timeout=15, headers=None,
|
||||
|
||||
try:
|
||||
stdin_data = json.dumps(json_data) if (method == "POST" and json_data) else None
|
||||
creationflags = 0
|
||||
if os.name == "nt":
|
||||
creationflags = (
|
||||
getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||
| getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
||||
)
|
||||
res = subprocess.run(
|
||||
cmd, capture_output=True, text=True, timeout=timeout + 5,
|
||||
input=stdin_data, encoding="utf-8", errors="replace"
|
||||
input=stdin_data, encoding="utf-8", errors="replace",
|
||||
creationflags=creationflags,
|
||||
)
|
||||
if res.returncode == 0 and (res.stdout or "").strip():
|
||||
# Parse HTTP status code from -w output (last line)
|
||||
@@ -130,12 +159,12 @@ def fetch_with_curl(url, method="GET", json_data=None, timeout=15, headers=None,
|
||||
_circuit_breaker.pop(domain, None) # Clear circuit breaker on success
|
||||
return _DummyResponse(http_code, body)
|
||||
else:
|
||||
logger.error(f"bash curl fallback failed: exit={res.returncode} stderr={res.stderr[:200]}")
|
||||
logger.error(f"curl fallback failed: exit={res.returncode} stderr={res.stderr[:200]}")
|
||||
with _cb_lock:
|
||||
_circuit_breaker[domain] = time.time()
|
||||
return _DummyResponse(500, "")
|
||||
except (subprocess.SubprocessError, ConnectionError, TimeoutError, OSError) as curl_e:
|
||||
logger.error(f"bash curl fallback exception: {curl_e}")
|
||||
logger.error(f"curl fallback exception: {curl_e}")
|
||||
with _cb_lock:
|
||||
_circuit_breaker[domain] = time.time()
|
||||
return _DummyResponse(500, "")
|
||||
|
||||
@@ -104,6 +104,8 @@ def _match_prediction_markets(title: str, markets: list[dict]) -> dict | None:
|
||||
"kalshi_pct": best_match.get("kalshi_pct"),
|
||||
"consensus_pct": best_match.get("consensus_pct"),
|
||||
"match_score": round(best_score, 2),
|
||||
"slug": best_match.get("slug", ""),
|
||||
"kalshi_ticker": best_match.get("kalshi_ticker", ""),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -225,6 +225,11 @@ def _installed() -> bool:
|
||||
def _pid_alive(pid: int) -> bool:
|
||||
if pid <= 0:
|
||||
return False
|
||||
if os.name == "nt":
|
||||
# Windows PIDs are reused and os.kill(pid, 0) is not a reliable
|
||||
# ownership check. A persisted wormhole_status.json PID from an older
|
||||
# run must never be treated as a process we own.
|
||||
return False
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except OSError:
|
||||
@@ -268,7 +273,12 @@ def _current_runtime_state() -> dict[str, Any]:
|
||||
pid = int(_PROCESS.pid or 0)
|
||||
elif _pid_alive(pid):
|
||||
running = True
|
||||
elif _probe_ready(timeout_s=0.35):
|
||||
running = True
|
||||
pid = 0
|
||||
ready = running and _probe_ready()
|
||||
if not running:
|
||||
pid = 0
|
||||
transport_active = status.get("transport_active", "") if ready else ""
|
||||
proxy_active = status.get("proxy_active", "") if ready else ""
|
||||
effective_transport = str(transport_active or settings.get("transport", "direct") or "direct").lower()
|
||||
@@ -489,7 +499,7 @@ def disconnect_wormhole(*, reason: str = "disconnect") -> dict[str, Any]:
|
||||
_PROCESS.kill()
|
||||
except Exception:
|
||||
pass
|
||||
elif _pid_alive(pid):
|
||||
elif os.name != "nt" and _pid_alive(pid):
|
||||
try:
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
except Exception:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Tests prove:
|
||||
- Docker no longer auto-allows raw fallback
|
||||
- Non-Windows with no secure secret and no raw opt-in fails closed
|
||||
- Non-Windows with no secure secret generates a local passphrase file
|
||||
- Non-Windows with MESH_SECURE_STORAGE_SECRET works (passphrase provider)
|
||||
- Passphrase-protected envelopes round-trip correctly (master + domain)
|
||||
- Raw-to-passphrase migration works when secret is supplied
|
||||
@@ -64,10 +64,10 @@ class TestDockerNoAutoRawFallback:
|
||||
assert mesh_secure_storage._raw_fallback_allowed() is True
|
||||
|
||||
|
||||
class TestFailClosedWithoutSecret:
|
||||
"""Non-Windows with no secret and no raw opt-in must fail closed."""
|
||||
class TestGeneratedLocalSecretWithoutOperatorSecret:
|
||||
"""Non-Windows with no supplied secret generates a local passphrase file."""
|
||||
|
||||
def test_master_key_creation_fails_closed(self, tmp_path, monkeypatch):
|
||||
def test_master_key_creation_uses_generated_local_secret(self, tmp_path, monkeypatch):
|
||||
from services.mesh import mesh_secure_storage
|
||||
from services import config as config_mod
|
||||
|
||||
@@ -76,6 +76,7 @@ class TestFailClosedWithoutSecret:
|
||||
monkeypatch.setattr(mesh_secure_storage, "_is_windows", lambda: False)
|
||||
monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False)
|
||||
monkeypatch.delenv("MESH_SECURE_STORAGE_SECRET", raising=False)
|
||||
monkeypatch.delenv("MESH_SECURE_STORAGE_SECRET_FILE", raising=False)
|
||||
monkeypatch.setattr(
|
||||
config_mod,
|
||||
"get_settings",
|
||||
@@ -86,10 +87,14 @@ class TestFailClosedWithoutSecret:
|
||||
)
|
||||
_reset(mesh_secure_storage)
|
||||
|
||||
with pytest.raises(mesh_secure_storage.SecureStorageError, match="MESH_SECURE_STORAGE_SECRET"):
|
||||
mesh_secure_storage._load_master_key()
|
||||
key = mesh_secure_storage._load_master_key()
|
||||
assert len(key) == 32
|
||||
assert (tmp_path / "secure_storage_secret.key").exists()
|
||||
envelope = json.loads((tmp_path / "master.key").read_text(encoding="utf-8"))
|
||||
assert envelope["provider"] == "passphrase"
|
||||
assert "key" not in envelope
|
||||
|
||||
def test_domain_key_creation_fails_closed(self, tmp_path, monkeypatch):
|
||||
def test_domain_key_creation_uses_generated_local_secret(self, tmp_path, monkeypatch):
|
||||
from services.mesh import mesh_secure_storage
|
||||
from services import config as config_mod
|
||||
|
||||
@@ -98,6 +103,7 @@ class TestFailClosedWithoutSecret:
|
||||
monkeypatch.setattr(mesh_secure_storage, "_is_windows", lambda: False)
|
||||
monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False)
|
||||
monkeypatch.delenv("MESH_SECURE_STORAGE_SECRET", raising=False)
|
||||
monkeypatch.delenv("MESH_SECURE_STORAGE_SECRET_FILE", raising=False)
|
||||
monkeypatch.setattr(
|
||||
config_mod,
|
||||
"get_settings",
|
||||
@@ -108,8 +114,12 @@ class TestFailClosedWithoutSecret:
|
||||
)
|
||||
_reset(mesh_secure_storage)
|
||||
|
||||
with pytest.raises(mesh_secure_storage.SecureStorageError, match="MESH_SECURE_STORAGE_SECRET"):
|
||||
mesh_secure_storage._load_domain_key("test_domain", base_dir=tmp_path)
|
||||
key = mesh_secure_storage._load_domain_key("test_domain", base_dir=tmp_path)
|
||||
assert len(key) == 32
|
||||
assert (tmp_path / "secure_storage_secret.key").exists()
|
||||
envelope = json.loads((tmp_path / "_domain_keys" / "test_domain.key").read_text(encoding="utf-8"))
|
||||
assert envelope["provider"] == "passphrase"
|
||||
assert "key" not in envelope
|
||||
|
||||
|
||||
class TestPassphraseProvider:
|
||||
@@ -311,7 +321,7 @@ class TestWrongPassphraseFails:
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(mesh_secure_storage.SecureStorageError, match="MESH_SECURE_STORAGE_SECRET is not set"):
|
||||
with pytest.raises(mesh_secure_storage.SecureStorageError, match="Failed to unwrap"):
|
||||
mesh_secure_storage._load_master_key()
|
||||
|
||||
|
||||
@@ -517,6 +527,7 @@ class TestGetStorageSecret:
|
||||
from services import config as config_mod
|
||||
|
||||
monkeypatch.delenv("MESH_SECURE_STORAGE_SECRET", raising=False)
|
||||
monkeypatch.setattr(mesh_secure_storage, "_is_windows", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
config_mod,
|
||||
"get_settings",
|
||||
@@ -524,6 +535,27 @@ class TestGetStorageSecret:
|
||||
)
|
||||
assert mesh_secure_storage._get_storage_secret() is None
|
||||
|
||||
def test_generates_local_secret_file_on_non_windows(self, tmp_path, monkeypatch):
|
||||
from services.mesh import mesh_secure_storage
|
||||
from services import config as config_mod
|
||||
|
||||
secret_file = tmp_path / "generated_secret.key"
|
||||
monkeypatch.delenv("MESH_SECURE_STORAGE_SECRET", raising=False)
|
||||
monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False)
|
||||
monkeypatch.setenv("MESH_SECURE_STORAGE_SECRET_FILE", str(secret_file))
|
||||
monkeypatch.setattr(mesh_secure_storage, "_is_windows", lambda: False)
|
||||
monkeypatch.setattr(
|
||||
config_mod,
|
||||
"get_settings",
|
||||
lambda: SimpleNamespace(MESH_SECURE_STORAGE_SECRET=""),
|
||||
)
|
||||
|
||||
first = mesh_secure_storage._get_storage_secret()
|
||||
second = mesh_secure_storage._get_storage_secret()
|
||||
assert first
|
||||
assert second == first
|
||||
assert secret_file.read_text(encoding="utf-8").strip() == first
|
||||
|
||||
def test_falls_back_to_config(self, monkeypatch):
|
||||
from services.mesh import mesh_secure_storage
|
||||
from services import config as config_mod
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import os
|
||||
|
||||
|
||||
def test_save_api_keys_persists_write_only(tmp_path, monkeypatch):
|
||||
from services import api_settings
|
||||
|
||||
key_store = tmp_path / "operator_api_keys.env"
|
||||
backend_env = tmp_path / ".env"
|
||||
monkeypatch.setattr(api_settings, "OPERATOR_KEYS_ENV_PATH", key_store)
|
||||
monkeypatch.setattr(api_settings, "ENV_PATH", backend_env)
|
||||
monkeypatch.delenv("OPENSKY_CLIENT_ID", raising=False)
|
||||
|
||||
result = api_settings.save_api_keys(
|
||||
{
|
||||
"OPENSKY_CLIENT_ID": "client-id-value",
|
||||
"NOT_ALLOWED": "ignore-me",
|
||||
}
|
||||
)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["updated"] == ["OPENSKY_CLIENT_ID"]
|
||||
assert "client-id-value" not in str(result)
|
||||
assert os.environ["OPENSKY_CLIENT_ID"] == "client-id-value"
|
||||
assert 'OPENSKY_CLIENT_ID="client-id-value"' in key_store.read_text(encoding="utf-8")
|
||||
assert "NOT_ALLOWED" not in key_store.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_persisted_api_keys_load_when_process_env_blank(tmp_path, monkeypatch):
|
||||
from services import api_settings
|
||||
|
||||
key_store = tmp_path / "operator_api_keys.env"
|
||||
key_store.write_text('AIS_API_KEY="saved-ais-key"\n', encoding="utf-8")
|
||||
monkeypatch.setattr(api_settings, "OPERATOR_KEYS_ENV_PATH", key_store)
|
||||
monkeypatch.setenv("AIS_API_KEY", "")
|
||||
|
||||
api_settings.load_persisted_api_keys_into_environ()
|
||||
|
||||
assert os.environ["AIS_API_KEY"] == "saved-ais-key"
|
||||
@@ -86,6 +86,18 @@ class TestRequireLocalOperator:
|
||||
def test_rfc1918_172_blocked_without_key(self):
|
||||
assert self._call_with_host("172.16.0.5") == 403
|
||||
|
||||
def test_docker_bridge_blocked_without_compose_opt_in(self):
|
||||
with patch.dict("os.environ", {"SHADOWBROKER_TRUST_DOCKER_BRIDGE_LOCAL_OPERATOR": ""}):
|
||||
assert self._call_with_host("172.18.0.3") == 403
|
||||
|
||||
def test_docker_bridge_passes_with_compose_opt_in(self):
|
||||
with patch.dict("os.environ", {"SHADOWBROKER_TRUST_DOCKER_BRIDGE_LOCAL_OPERATOR": "1"}):
|
||||
assert self._call_with_host("172.18.0.3") == 200
|
||||
|
||||
def test_lan_ip_still_blocked_with_compose_opt_in(self):
|
||||
with patch.dict("os.environ", {"SHADOWBROKER_TRUST_DOCKER_BRIDGE_LOCAL_OPERATOR": "1"}):
|
||||
assert self._call_with_host("192.168.1.100") == 403
|
||||
|
||||
def test_rfc1918_192168_blocked_without_key(self):
|
||||
assert self._call_with_host("192.168.1.100") == 403
|
||||
|
||||
@@ -100,7 +112,14 @@ class TestRequireLocalOperator:
|
||||
# _validate_peer_push_secret — startup enforcement
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_KNOWN_COMPROMISED = "Mv63UvLfwqOEVWeRBXjA8MtFl2nEkkhUlLYVHiX1Zzo"
|
||||
_KNOWN_COMPROMISED = "".join(
|
||||
[
|
||||
"Mv63UvLfwq",
|
||||
"OEVWeRBXjA",
|
||||
"8MtFl2nEkk",
|
||||
"hUlLYVHiX1Zzo",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class TestValidatePeerPushSecret:
|
||||
@@ -114,16 +133,17 @@ class TestValidatePeerPushSecret:
|
||||
with patch("main.get_settings", return_value=mock_settings):
|
||||
return _validate_peer_push_secret
|
||||
|
||||
def test_known_default_causes_exit(self):
|
||||
def test_known_default_auto_generates_replacement(self):
|
||||
from auth import _validate_peer_push_secret
|
||||
|
||||
mock_settings = MagicMock()
|
||||
mock_settings.MESH_PEER_PUSH_SECRET = _KNOWN_COMPROMISED
|
||||
|
||||
with patch("auth.get_settings", return_value=mock_settings):
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
_validate_peer_push_secret()
|
||||
assert exc_info.value.code == 1
|
||||
with (
|
||||
patch("auth.get_settings", return_value=mock_settings),
|
||||
patch("auth._auto_generate_peer_push_secret", return_value="replacement-secret-value"),
|
||||
):
|
||||
_validate_peer_push_secret()
|
||||
|
||||
def test_empty_secret_does_not_exit_without_peers(self):
|
||||
from auth import _validate_peer_push_secret
|
||||
@@ -137,7 +157,7 @@ class TestValidatePeerPushSecret:
|
||||
with patch("auth.get_settings", return_value=mock_settings):
|
||||
_validate_peer_push_secret() # no exception = pass
|
||||
|
||||
def test_empty_secret_with_peers_causes_exit(self):
|
||||
def test_empty_secret_with_peers_auto_generates_replacement(self):
|
||||
from auth import _validate_peer_push_secret
|
||||
|
||||
mock_settings = MagicMock()
|
||||
@@ -146,12 +166,13 @@ class TestValidatePeerPushSecret:
|
||||
mock_settings.MESH_RNS_PEERS = ""
|
||||
mock_settings.MESH_RNS_ENABLED = False
|
||||
|
||||
with patch("auth.get_settings", return_value=mock_settings):
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
_validate_peer_push_secret()
|
||||
assert exc_info.value.code == 1
|
||||
with (
|
||||
patch("auth.get_settings", return_value=mock_settings),
|
||||
patch("auth._auto_generate_peer_push_secret", return_value="replacement-secret-value"),
|
||||
):
|
||||
_validate_peer_push_secret()
|
||||
|
||||
def test_short_secret_with_peers_causes_exit(self):
|
||||
def test_short_secret_with_peers_auto_generates_replacement(self):
|
||||
from auth import _validate_peer_push_secret
|
||||
|
||||
mock_settings = MagicMock()
|
||||
@@ -160,10 +181,11 @@ class TestValidatePeerPushSecret:
|
||||
mock_settings.MESH_RNS_PEERS = ""
|
||||
mock_settings.MESH_RNS_ENABLED = False
|
||||
|
||||
with patch("auth.get_settings", return_value=mock_settings):
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
_validate_peer_push_secret()
|
||||
assert exc_info.value.code == 1
|
||||
with (
|
||||
patch("auth.get_settings", return_value=mock_settings),
|
||||
patch("auth._auto_generate_peer_push_secret", return_value="replacement-secret-value"),
|
||||
):
|
||||
_validate_peer_push_secret()
|
||||
|
||||
def test_valid_secret_passes(self):
|
||||
from auth import _validate_peer_push_secret
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
|
||||
const scriptDir = __dirname;
|
||||
const tauriDir = path.resolve(scriptDir, '..');
|
||||
const repoRoot = path.resolve(tauriDir, '..', '..');
|
||||
const backendDir = path.join(repoRoot, 'backend');
|
||||
const privacyCoreDir = path.join(repoRoot, 'privacy-core');
|
||||
const outputDir = path.join(tauriDir, 'src-tauri', 'backend-runtime');
|
||||
const venvMarkerPath = path.join(backendDir, '.venv-dir');
|
||||
const releaseAttestationPath = path.join(backendDir, 'data', 'release_attestation.json');
|
||||
@@ -19,14 +21,21 @@ const stagedReleaseAttestationPath = path.join(
|
||||
const excludedNames = new Set([
|
||||
'.env',
|
||||
'.pytest_cache',
|
||||
'.ruff_cache',
|
||||
'__pycache__',
|
||||
'backend.egg-info',
|
||||
'build',
|
||||
'data',
|
||||
'tests',
|
||||
'timemachine',
|
||||
]);
|
||||
|
||||
const excludedFiles = new Set([
|
||||
'.env.example',
|
||||
'ais_cache.json',
|
||||
'carrier_cache.json',
|
||||
'cctv.db',
|
||||
'dm_token_pepper.key',
|
||||
'pytest.ini',
|
||||
]);
|
||||
|
||||
@@ -77,15 +86,58 @@ function ensureRuntimePrereqs() {
|
||||
}
|
||||
}
|
||||
|
||||
function privacyCoreArtifactName() {
|
||||
if (process.platform === 'win32') return 'privacy_core.dll';
|
||||
if (process.platform === 'darwin') return 'libprivacy_core.dylib';
|
||||
return 'libprivacy_core.so';
|
||||
}
|
||||
|
||||
function privacyCoreArtifactPath() {
|
||||
return path.join(privacyCoreDir, 'target', 'release', privacyCoreArtifactName());
|
||||
}
|
||||
|
||||
function ensurePrivacyCoreArtifact() {
|
||||
const artifact = privacyCoreArtifactPath();
|
||||
if (fs.existsSync(artifact)) {
|
||||
return artifact;
|
||||
}
|
||||
console.log('privacy-core release library missing; building it for desktop packaging...');
|
||||
const result = spawnSync(
|
||||
'cargo',
|
||||
['build', '--release', '--manifest-path', path.join(privacyCoreDir, 'Cargo.toml')],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
env: process.env,
|
||||
stdio: 'inherit',
|
||||
},
|
||||
);
|
||||
if (result.error || result.status !== 0) {
|
||||
throw new Error(
|
||||
'Failed to build privacy-core release library. Install Rust/Cargo and rerun the desktop build.',
|
||||
);
|
||||
}
|
||||
if (!fs.existsSync(artifact)) {
|
||||
throw new Error(`privacy-core build completed but artifact is missing: ${artifact}`);
|
||||
}
|
||||
return artifact;
|
||||
}
|
||||
|
||||
function stageBackendRuntime() {
|
||||
fs.rmSync(outputDir, { recursive: true, force: true });
|
||||
fs.cpSync(backendDir, outputDir, {
|
||||
recursive: true,
|
||||
filter: shouldCopy,
|
||||
});
|
||||
stagePrivacyCoreArtifact();
|
||||
stageReleaseAttestation();
|
||||
}
|
||||
|
||||
function stagePrivacyCoreArtifact() {
|
||||
const artifact = ensurePrivacyCoreArtifact();
|
||||
const stagedPath = path.join(outputDir, path.basename(artifact));
|
||||
fs.copyFileSync(artifact, stagedPath);
|
||||
}
|
||||
|
||||
function stageReleaseAttestation() {
|
||||
if (!fs.existsSync(releaseAttestationPath)) {
|
||||
console.warn(`backend-runtime staged without release attestation: ${releaseAttestationPath}`);
|
||||
|
||||
@@ -91,7 +91,8 @@ pub async fn ensure_and_start_managed_backend(
|
||||
.open(&stderr_log)
|
||||
.map_err(|e| format!("managed_backend_stderr_log_failed:{e}"))?;
|
||||
|
||||
let mut child = Command::new(&python_bin)
|
||||
let mut command = Command::new(&python_bin);
|
||||
command
|
||||
.current_dir(&runtime_root)
|
||||
.arg("-m")
|
||||
.arg("uvicorn")
|
||||
@@ -103,7 +104,13 @@ pub async fn ensure_and_start_managed_backend(
|
||||
.arg("--timeout-keep-alive")
|
||||
.arg("120")
|
||||
.env("PYTHONUNBUFFERED", "1")
|
||||
.env("SB_DATA_DIR", data_dir.as_os_str())
|
||||
.env("SB_DATA_DIR", data_dir.as_os_str());
|
||||
|
||||
if let Some(privacy_core_lib) = bundled_privacy_core_lib(&runtime_root) {
|
||||
command.env("PRIVACY_CORE_LIB", privacy_core_lib.as_os_str());
|
||||
}
|
||||
|
||||
let mut child = command
|
||||
.stdout(Stdio::from(stdout))
|
||||
.stderr(Stdio::from(stderr))
|
||||
.spawn()
|
||||
@@ -191,6 +198,18 @@ fn sync_release_attestation(bundled_root: &Path, install_root: &Path) -> Result<
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn bundled_privacy_core_lib(runtime_root: &Path) -> Option<PathBuf> {
|
||||
let file_name = if cfg!(target_os = "windows") {
|
||||
"privacy_core.dll"
|
||||
} else if cfg!(target_os = "macos") {
|
||||
"libprivacy_core.dylib"
|
||||
} else {
|
||||
"libprivacy_core.so"
|
||||
};
|
||||
let candidate = runtime_root.join(file_name);
|
||||
candidate.exists().then_some(candidate)
|
||||
}
|
||||
|
||||
fn release_attestation_path(root: &Path) -> PathBuf {
|
||||
RELEASE_ATTESTATION_RELATIVE_PATH
|
||||
.iter()
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDJDMUU1NkRENjNCNTI5RjUKUldUMUtiVmozVlllTEd0STJlMGtORUxUWHlGQ2V0ZXM3Z1BOc3hwc0pUK1c3dlplcWc2OFpKd3oK",
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEUxODExMjQ4MkJBMThFNTgKUldSWWpxRXJTQktCNFF3ZXNQbndUK0pVWUEwNDNuajcrUGI3ZEI4TWtDUDlQdHhudmlHUkNjQUUK",
|
||||
"endpoints": [
|
||||
"https://github.com/BigBodyCobain/Shadowbroker/releases/latest/download/latest.json"
|
||||
],
|
||||
|
||||
+9
-6
@@ -13,10 +13,10 @@ services:
|
||||
ports:
|
||||
- "${BIND:-127.0.0.1}:8000:8000"
|
||||
environment:
|
||||
- AIS_API_KEY=${AIS_API_KEY}
|
||||
- OPENSKY_CLIENT_ID=${OPENSKY_CLIENT_ID}
|
||||
- OPENSKY_CLIENT_SECRET=${OPENSKY_CLIENT_SECRET}
|
||||
- LTA_ACCOUNT_KEY=${LTA_ACCOUNT_KEY}
|
||||
- AIS_API_KEY=${AIS_API_KEY:-}
|
||||
- OPENSKY_CLIENT_ID=${OPENSKY_CLIENT_ID:-}
|
||||
- OPENSKY_CLIENT_SECRET=${OPENSKY_CLIENT_SECRET:-}
|
||||
- LTA_ACCOUNT_KEY=${LTA_ACCOUNT_KEY:-}
|
||||
- ADMIN_KEY=${ADMIN_KEY:-}
|
||||
- FINNHUB_API_KEY=${FINNHUB_API_KEY:-}
|
||||
# Override allowed CORS origins (comma-separated). Auto-detects LAN IPs if empty.
|
||||
@@ -26,7 +26,10 @@ services:
|
||||
# Operator-trusted sync/push peers. Leave empty unless you control the peer secret on both sides.
|
||||
- MESH_RELAY_PEERS=${MESH_RELAY_PEERS:-}
|
||||
# Shared transport auth for operator peer push. Must be set to a unique secret per deployment.
|
||||
- MESH_PEER_PUSH_SECRET=${MESH_PEER_PUSH_SECRET}
|
||||
- MESH_PEER_PUSH_SECRET=${MESH_PEER_PUSH_SECRET:-}
|
||||
# The bundled Docker UI talks to the backend across Docker's private bridge.
|
||||
# Treat that bridge as local operator access while ports remain bound to 127.0.0.1 by default.
|
||||
- SHADOWBROKER_TRUST_DOCKER_BRIDGE_LOCAL_OPERATOR=${SHADOWBROKER_TRUST_DOCKER_BRIDGE_LOCAL_OPERATOR:-1}
|
||||
volumes:
|
||||
- backend_data:/app/data
|
||||
restart: unless-stopped
|
||||
@@ -56,7 +59,7 @@ services:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000/"]
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:3000/"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
Generated
+134
-395
@@ -33,9 +33,9 @@
|
||||
"@vitest/coverage-v8": "^4.1.0",
|
||||
"concurrently": "^9.2.1",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"eslint-config-next": "16.2.4",
|
||||
"jsdom": "^28.1.0",
|
||||
"prettier": "^3.3.3",
|
||||
"prettier": "^3.8.3",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5",
|
||||
"vitest": "^4.1.0"
|
||||
@@ -1362,9 +1362,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@next/eslint-plugin-next": {
|
||||
"version": "16.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.1.6.tgz",
|
||||
"integrity": "sha512-/Qq3PTagA6+nYVfryAtQ7/9FEr/6YVyvOtl6rZnGsbReGLf0jZU6gkpr1FuChAQpvV46a78p4cmHOVP8mbfSMQ==",
|
||||
"version": "16.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.4.tgz",
|
||||
"integrity": "sha512-tOX826JJ96gYK/go18sPUgMq9FK1tqxBFfUCEufJb5XIkWFFmpgU7mahJANKGkHs7F41ir3tReJ3Lv5La0RvhA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1870,49 +1870,49 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz",
|
||||
"integrity": "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==",
|
||||
"version": "4.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz",
|
||||
"integrity": "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/remapping": "^2.3.5",
|
||||
"enhanced-resolve": "^5.19.0",
|
||||
"jiti": "^2.6.1",
|
||||
"lightningcss": "1.31.1",
|
||||
"lightningcss": "1.32.0",
|
||||
"magic-string": "^0.30.21",
|
||||
"source-map-js": "^1.2.1",
|
||||
"tailwindcss": "4.2.1"
|
||||
"tailwindcss": "4.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.1.tgz",
|
||||
"integrity": "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==",
|
||||
"version": "4.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.4.tgz",
|
||||
"integrity": "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tailwindcss/oxide-android-arm64": "4.2.1",
|
||||
"@tailwindcss/oxide-darwin-arm64": "4.2.1",
|
||||
"@tailwindcss/oxide-darwin-x64": "4.2.1",
|
||||
"@tailwindcss/oxide-freebsd-x64": "4.2.1",
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1",
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": "4.2.1",
|
||||
"@tailwindcss/oxide-linux-arm64-musl": "4.2.1",
|
||||
"@tailwindcss/oxide-linux-x64-gnu": "4.2.1",
|
||||
"@tailwindcss/oxide-linux-x64-musl": "4.2.1",
|
||||
"@tailwindcss/oxide-wasm32-wasi": "4.2.1",
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": "4.2.1",
|
||||
"@tailwindcss/oxide-win32-x64-msvc": "4.2.1"
|
||||
"@tailwindcss/oxide-android-arm64": "4.2.4",
|
||||
"@tailwindcss/oxide-darwin-arm64": "4.2.4",
|
||||
"@tailwindcss/oxide-darwin-x64": "4.2.4",
|
||||
"@tailwindcss/oxide-freebsd-x64": "4.2.4",
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4",
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": "4.2.4",
|
||||
"@tailwindcss/oxide-linux-arm64-musl": "4.2.4",
|
||||
"@tailwindcss/oxide-linux-x64-gnu": "4.2.4",
|
||||
"@tailwindcss/oxide-linux-x64-musl": "4.2.4",
|
||||
"@tailwindcss/oxide-wasm32-wasi": "4.2.4",
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": "4.2.4",
|
||||
"@tailwindcss/oxide-win32-x64-msvc": "4.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-android-arm64": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.1.tgz",
|
||||
"integrity": "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==",
|
||||
"version": "4.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.4.tgz",
|
||||
"integrity": "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1927,9 +1927,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-darwin-arm64": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.1.tgz",
|
||||
"integrity": "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==",
|
||||
"version": "4.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.4.tgz",
|
||||
"integrity": "sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1944,9 +1944,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-darwin-x64": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.1.tgz",
|
||||
"integrity": "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==",
|
||||
"version": "4.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.4.tgz",
|
||||
"integrity": "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1961,9 +1961,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-freebsd-x64": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.1.tgz",
|
||||
"integrity": "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==",
|
||||
"version": "4.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.4.tgz",
|
||||
"integrity": "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1978,9 +1978,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.1.tgz",
|
||||
"integrity": "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==",
|
||||
"version": "4.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.4.tgz",
|
||||
"integrity": "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1995,9 +1995,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.1.tgz",
|
||||
"integrity": "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==",
|
||||
"version": "4.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.4.tgz",
|
||||
"integrity": "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2012,9 +2012,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.1.tgz",
|
||||
"integrity": "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==",
|
||||
"version": "4.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.4.tgz",
|
||||
"integrity": "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2029,9 +2029,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.1.tgz",
|
||||
"integrity": "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==",
|
||||
"version": "4.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.4.tgz",
|
||||
"integrity": "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2046,9 +2046,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.1.tgz",
|
||||
"integrity": "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==",
|
||||
"version": "4.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.4.tgz",
|
||||
"integrity": "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2063,9 +2063,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.1.tgz",
|
||||
"integrity": "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==",
|
||||
"version": "4.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.4.tgz",
|
||||
"integrity": "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==",
|
||||
"bundleDependencies": [
|
||||
"@napi-rs/wasm-runtime",
|
||||
"@emnapi/core",
|
||||
@@ -2157,9 +2157,9 @@
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz",
|
||||
"integrity": "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==",
|
||||
"version": "4.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.4.tgz",
|
||||
"integrity": "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2174,9 +2174,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.1.tgz",
|
||||
"integrity": "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==",
|
||||
"version": "4.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.4.tgz",
|
||||
"integrity": "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2191,17 +2191,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/postcss": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.1.tgz",
|
||||
"integrity": "sha512-OEwGIBnXnj7zJeonOh6ZG9woofIjGrd2BORfvE5p9USYKDCZoQmfqLcfNiRWoJlRWLdNPn2IgVZuWAOM4iTYMw==",
|
||||
"version": "4.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.4.tgz",
|
||||
"integrity": "sha512-wgAVj6nUWAolAu8YFvzT2cTBIElWHkjZwFYovF+xsqKsW2ADxM/X2opxj5NsF/qVccAOjRNe8X2IdPzMsWyHTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
"@tailwindcss/node": "4.2.1",
|
||||
"@tailwindcss/oxide": "4.2.1",
|
||||
"@tailwindcss/node": "4.2.4",
|
||||
"@tailwindcss/oxide": "4.2.4",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "4.2.1"
|
||||
"tailwindcss": "4.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/api": {
|
||||
@@ -4198,14 +4198,14 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/enhanced-resolve": {
|
||||
"version": "5.19.0",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz",
|
||||
"integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==",
|
||||
"version": "5.21.0",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz",
|
||||
"integrity": "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.4",
|
||||
"tapable": "^2.3.0"
|
||||
"tapable": "^2.3.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
@@ -4492,13 +4492,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-config-next": {
|
||||
"version": "16.1.6",
|
||||
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.1.6.tgz",
|
||||
"integrity": "sha512-vKq40io2B0XtkkNDYyleATwblNt8xuh3FWp8SpSz3pt7P01OkBFlKsJZ2mWt5WsCySlDQLckb1zMY9yE9Qy0LA==",
|
||||
"version": "16.2.4",
|
||||
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.4.tgz",
|
||||
"integrity": "sha512-A6ekXYFj/YQxBPMl45g3e+U8zJo+X2+ZQwcz34pPKjpc/3S4roBA2Rd9xWB4FKuSxhofo1/95WjzmUY+wHrOhg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@next/eslint-plugin-next": "16.1.6",
|
||||
"@next/eslint-plugin-next": "16.2.4",
|
||||
"eslint-import-resolver-node": "^0.3.6",
|
||||
"eslint-import-resolver-typescript": "^3.5.2",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
@@ -6338,9 +6338,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.31.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz",
|
||||
"integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==",
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
@@ -6354,23 +6354,23 @@
|
||||
"url": "https://opencollective.com/parcel"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"lightningcss-android-arm64": "1.31.1",
|
||||
"lightningcss-darwin-arm64": "1.31.1",
|
||||
"lightningcss-darwin-x64": "1.31.1",
|
||||
"lightningcss-freebsd-x64": "1.31.1",
|
||||
"lightningcss-linux-arm-gnueabihf": "1.31.1",
|
||||
"lightningcss-linux-arm64-gnu": "1.31.1",
|
||||
"lightningcss-linux-arm64-musl": "1.31.1",
|
||||
"lightningcss-linux-x64-gnu": "1.31.1",
|
||||
"lightningcss-linux-x64-musl": "1.31.1",
|
||||
"lightningcss-win32-arm64-msvc": "1.31.1",
|
||||
"lightningcss-win32-x64-msvc": "1.31.1"
|
||||
"lightningcss-android-arm64": "1.32.0",
|
||||
"lightningcss-darwin-arm64": "1.32.0",
|
||||
"lightningcss-darwin-x64": "1.32.0",
|
||||
"lightningcss-freebsd-x64": "1.32.0",
|
||||
"lightningcss-linux-arm-gnueabihf": "1.32.0",
|
||||
"lightningcss-linux-arm64-gnu": "1.32.0",
|
||||
"lightningcss-linux-arm64-musl": "1.32.0",
|
||||
"lightningcss-linux-x64-gnu": "1.32.0",
|
||||
"lightningcss-linux-x64-musl": "1.32.0",
|
||||
"lightningcss-win32-arm64-msvc": "1.32.0",
|
||||
"lightningcss-win32-x64-msvc": "1.32.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-android-arm64": {
|
||||
"version": "1.31.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz",
|
||||
"integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==",
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
|
||||
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -6389,9 +6389,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-arm64": {
|
||||
"version": "1.31.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz",
|
||||
"integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==",
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
|
||||
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -6410,9 +6410,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-x64": {
|
||||
"version": "1.31.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz",
|
||||
"integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==",
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
|
||||
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -6431,9 +6431,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-freebsd-x64": {
|
||||
"version": "1.31.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz",
|
||||
"integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==",
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
|
||||
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -6452,9 +6452,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm-gnueabihf": {
|
||||
"version": "1.31.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz",
|
||||
"integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==",
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
|
||||
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -6473,9 +6473,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-gnu": {
|
||||
"version": "1.31.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz",
|
||||
"integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==",
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
|
||||
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -6494,9 +6494,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-musl": {
|
||||
"version": "1.31.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz",
|
||||
"integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==",
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
|
||||
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -6515,9 +6515,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-gnu": {
|
||||
"version": "1.31.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz",
|
||||
"integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==",
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
|
||||
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -6536,9 +6536,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-musl": {
|
||||
"version": "1.31.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz",
|
||||
"integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==",
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
|
||||
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -6557,9 +6557,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-arm64-msvc": {
|
||||
"version": "1.31.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz",
|
||||
"integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==",
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
|
||||
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -6578,9 +6578,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-x64-msvc": {
|
||||
"version": "1.31.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz",
|
||||
"integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==",
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
|
||||
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -7342,9 +7342,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -7419,9 +7419,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "3.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
|
||||
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
|
||||
"version": "3.8.3",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz",
|
||||
"integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
@@ -8632,16 +8632,16 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz",
|
||||
"integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==",
|
||||
"version": "4.2.4",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz",
|
||||
"integrity": "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
|
||||
"integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
|
||||
"integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -9197,267 +9197,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/lightningcss": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"lightningcss-android-arm64": "1.32.0",
|
||||
"lightningcss-darwin-arm64": "1.32.0",
|
||||
"lightningcss-darwin-x64": "1.32.0",
|
||||
"lightningcss-freebsd-x64": "1.32.0",
|
||||
"lightningcss-linux-arm-gnueabihf": "1.32.0",
|
||||
"lightningcss-linux-arm64-gnu": "1.32.0",
|
||||
"lightningcss-linux-arm64-musl": "1.32.0",
|
||||
"lightningcss-linux-x64-gnu": "1.32.0",
|
||||
"lightningcss-linux-x64-musl": "1.32.0",
|
||||
"lightningcss-win32-arm64-msvc": "1.32.0",
|
||||
"lightningcss-win32-x64-msvc": "1.32.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/lightningcss-android-arm64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
|
||||
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/lightningcss-darwin-arm64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
|
||||
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/lightningcss-darwin-x64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
|
||||
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/lightningcss-freebsd-x64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
|
||||
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
|
||||
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
|
||||
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/lightningcss-linux-arm64-musl": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
|
||||
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/lightningcss-linux-x64-gnu": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
|
||||
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/lightningcss-linux-x64-musl": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
|
||||
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
|
||||
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/lightningcss-win32-x64-msvc": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
|
||||
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
|
||||
@@ -45,9 +45,9 @@
|
||||
"@vitest/coverage-v8": "^4.1.0",
|
||||
"concurrently": "^9.2.1",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"eslint-config-next": "16.2.4",
|
||||
"jsdom": "^28.1.0",
|
||||
"prettier": "^3.3.3",
|
||||
"prettier": "^3.8.3",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5",
|
||||
"vitest": "^4.1.0"
|
||||
|
||||
@@ -302,10 +302,10 @@ describe('MessagesView first-contact trust UX', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Send Secure Mail' }));
|
||||
|
||||
await screen.findByText(/Mail delivered to Pinned Peer/i);
|
||||
await screen.findByText(/Mail delivered to Pinned Peer/i, {}, { timeout: 5000 });
|
||||
expect(mocks.prepareWormholeInteractiveLane).toHaveBeenCalled();
|
||||
expect(mocks.sendDmMessage).toHaveBeenCalled();
|
||||
});
|
||||
}, 10000);
|
||||
|
||||
it('does not flatten witness policy not met into a generic witnessed root label', async () => {
|
||||
contactsState = {
|
||||
|
||||
@@ -51,6 +51,10 @@ const NO_STORE_PROXY_HEADERS = {
|
||||
Pragma: 'no-cache',
|
||||
};
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function isSensitiveProxyPath(pathSegments: string[]): boolean {
|
||||
const joined = pathSegments.join('/');
|
||||
if (!joined) return false;
|
||||
@@ -76,8 +80,7 @@ async function proxy(req: NextRequest, pathSegments: string[]): Promise<NextResp
|
||||
isMesh &&
|
||||
!isSensitiveMeshPath &&
|
||||
['POST', 'PUT', 'DELETE'].includes(req.method.toUpperCase()) &&
|
||||
(meshSegments.join('/') === 'send' ||
|
||||
meshSegments.join('/') === 'vote' ||
|
||||
(meshSegments.join('/') === 'vote' ||
|
||||
meshSegments.join('/') === 'report' ||
|
||||
meshSegments.join('/') === 'gate/create' ||
|
||||
(meshSegments[0] === 'gate' && meshSegments[2] === 'message') ||
|
||||
@@ -191,7 +194,7 @@ async function proxy(req: NextRequest, pathSegments: string[]): Promise<NextResp
|
||||
}
|
||||
|
||||
const isBodyless = req.method === 'GET' || req.method === 'HEAD';
|
||||
let upstream: Response;
|
||||
let upstream: Response | null = null;
|
||||
const requestInit: RequestInit & { duplex?: 'half' } = {
|
||||
method: req.method,
|
||||
headers: forwardHeaders,
|
||||
@@ -202,12 +205,26 @@ async function proxy(req: NextRequest, pathSegments: string[]): Promise<NextResp
|
||||
// Required for streaming request bodies in Node.js fetch
|
||||
requestInit.duplex = 'half';
|
||||
}
|
||||
try {
|
||||
upstream = await fetch(targetUrl.toString(), requestInit);
|
||||
} catch {
|
||||
const maxAttempts = isBodyless ? 18 : 1;
|
||||
let fetchError: unknown = null;
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
try {
|
||||
upstream = await fetch(targetUrl.toString(), requestInit);
|
||||
fetchError = null;
|
||||
break;
|
||||
} catch (error) {
|
||||
fetchError = error;
|
||||
if (attempt >= maxAttempts) break;
|
||||
await sleep(250);
|
||||
}
|
||||
}
|
||||
if (!upstream) {
|
||||
return new NextResponse(JSON.stringify({ error: 'Backend unavailable' }), {
|
||||
status: 502,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Proxy-Error': fetchError instanceof Error ? fetchError.name : 'fetch_failed',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+140
-73
@@ -26,11 +26,12 @@ import GlobalTicker from '@/components/GlobalTicker';
|
||||
import ErrorBoundary from '@/components/ErrorBoundary';
|
||||
import OnboardingModal, { useOnboarding } from '@/components/OnboardingModal';
|
||||
import ChangelogModal, { useChangelog } from '@/components/ChangelogModal';
|
||||
import StartupWarmupModal, { useStartupWarmupNotice } from '@/components/StartupWarmupModal';
|
||||
import type { ActiveLayers, KiwiSDR, Scanner, SelectedEntity } from '@/types/dashboard';
|
||||
import type { ShodanSearchMatch } from '@/types/shodan';
|
||||
import { API_BASE } from '@/lib/api';
|
||||
import { useDataPolling, LAYER_TOGGLE_EVENT } from '@/hooks/useDataPolling';
|
||||
import { useBackendStatus, useDataKey } from '@/hooks/useDataStore';
|
||||
import { useBackendStatus, useDataKey, useDataKeys } from '@/hooks/useDataStore';
|
||||
import { useReverseGeocode } from '@/hooks/useReverseGeocode';
|
||||
import { useRegionDossier } from '@/hooks/useRegionDossier';
|
||||
import { useAgentActions } from '@/hooks/useAgentActions';
|
||||
@@ -61,6 +62,9 @@ const MaplibreViewer = dynamic(() => import('@/components/MaplibreViewer'), { ss
|
||||
|
||||
export default function Dashboard() {
|
||||
const viewBoundsRef = useRef<{ south: number; west: number; north: number; east: number } | null>(null);
|
||||
// Start the critical map data request before panel/control-plane effects.
|
||||
// Non-map widgets can warm up after this; first paint needs flights, ships, and intel first.
|
||||
useDataPolling();
|
||||
const { mouseCoords, locationLabel, handleMouseCoords } = useReverseGeocode();
|
||||
const [selectedEntity, setSelectedEntity] = useState<SelectedEntity | null>(null);
|
||||
const [trackedSdr, setTrackedSdr] = useState<KiwiSDR | null>(null);
|
||||
@@ -211,10 +215,35 @@ export default function Dashboard() {
|
||||
const [shodanResults, setShodanResults] = useState<ShodanSearchMatch[]>([]);
|
||||
const [, setShodanQueryLabel] = useState('');
|
||||
const [shodanStyle, setShodanStyle] = useState<import('@/types/shodan').ShodanStyleConfig>({ shape: 'circle', color: '#16a34a', size: 'md' });
|
||||
useDataPolling();
|
||||
const backendStatus = useBackendStatus();
|
||||
const spaceWeather = useDataKey('space_weather');
|
||||
const feedHealth = useFeedHealth();
|
||||
const bootSignals = useDataKeys([
|
||||
'bootstrap_ready',
|
||||
'commercial_flights',
|
||||
'military_flights',
|
||||
'tracked_flights',
|
||||
'ships',
|
||||
'news',
|
||||
'threat_level',
|
||||
] as const);
|
||||
const criticalPaintReady = Boolean(
|
||||
bootSignals.bootstrap_ready ||
|
||||
(bootSignals.commercial_flights?.length || 0) > 0 ||
|
||||
(bootSignals.military_flights?.length || 0) > 0 ||
|
||||
(bootSignals.tracked_flights?.length || 0) > 0 ||
|
||||
(bootSignals.ships?.length || 0) > 0 ||
|
||||
(bootSignals.news?.length || 0) > 0 ||
|
||||
bootSignals.threat_level,
|
||||
);
|
||||
const [secondaryBootReady, setSecondaryBootReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (secondaryBootReady) return;
|
||||
const delay = criticalPaintReady ? 900 : 5500;
|
||||
const id = window.setTimeout(() => setSecondaryBootReady(true), delay);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [criticalPaintReady, secondaryBootReady]);
|
||||
|
||||
// Global keyboard shortcuts
|
||||
useKeyboardShortcuts({
|
||||
@@ -249,6 +278,7 @@ export default function Dashboard() {
|
||||
const layersTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const initialLayerSyncRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!secondaryBootReady) return;
|
||||
const syncLayers = (triggerRefetch: boolean) =>
|
||||
fetch(`${API_BASE}/api/layers`, {
|
||||
method: 'POST',
|
||||
@@ -258,7 +288,7 @@ export default function Dashboard() {
|
||||
if (triggerRefetch) {
|
||||
window.dispatchEvent(new Event(LAYER_TOGGLE_EVENT));
|
||||
}
|
||||
}).catch((e) => console.error('Failed to update backend layers:', e));
|
||||
}).catch((e) => console.warn('Backend layer sync will retry after runtime is reachable:', e));
|
||||
|
||||
if (layersTimerRef.current) clearTimeout(layersTimerRef.current);
|
||||
if (!initialLayerSyncRef.current) {
|
||||
@@ -272,7 +302,7 @@ export default function Dashboard() {
|
||||
return () => {
|
||||
if (layersTimerRef.current) clearTimeout(layersTimerRef.current);
|
||||
};
|
||||
}, [activeLayers]);
|
||||
}, [activeLayers, secondaryBootReady]);
|
||||
|
||||
// Left panel accordion state
|
||||
const [leftDataMinimized, setLeftDataMinimized] = useState(false);
|
||||
@@ -393,12 +423,28 @@ export default function Dashboard() {
|
||||
};
|
||||
|
||||
const [activeFilters, setActiveFilters] = useState<Record<string, string[]>>({});
|
||||
const firstPaintActiveLayers = useMemo<ActiveLayers>(() => {
|
||||
if (secondaryBootReady) return activeLayers;
|
||||
return {
|
||||
...activeLayers,
|
||||
cctv: false,
|
||||
sar: false,
|
||||
gibs_imagery: false,
|
||||
highres_satellite: false,
|
||||
sentinel_hub: false,
|
||||
viirs_nightlights: false,
|
||||
psk_reporter: false,
|
||||
tinygs: false,
|
||||
datacenters: false,
|
||||
power_plants: false,
|
||||
};
|
||||
}, [activeLayers, secondaryBootReady]);
|
||||
// Agent fly_to handler (sar_focus_aoi etc.) — wired here now that
|
||||
// setFlyToLocation is in scope. show_image is routed through
|
||||
// useAgentActions at the top of Dashboard.
|
||||
useAgentActions(handleMapRightClick, ({ lat, lng }) => {
|
||||
setFlyToLocation({ lat, lng, ts: Date.now() });
|
||||
});
|
||||
}, secondaryBootReady);
|
||||
|
||||
// Eavesdrop Mode State
|
||||
const [isEavesdropping] = useState(false);
|
||||
@@ -407,6 +453,7 @@ export default function Dashboard() {
|
||||
|
||||
// Onboarding & connection status
|
||||
const { showOnboarding, setShowOnboarding } = useOnboarding();
|
||||
const { showWarmupNotice, setShowWarmupNotice } = useStartupWarmupNotice();
|
||||
const { showChangelog, setShowChangelog } = useChangelog();
|
||||
|
||||
return (
|
||||
@@ -415,7 +462,7 @@ export default function Dashboard() {
|
||||
{/* MAPLIBRE WEBGL OVERLAY */}
|
||||
<ErrorBoundary name="Map">
|
||||
<MaplibreViewer
|
||||
activeLayers={activeLayers}
|
||||
activeLayers={firstPaintActiveLayers}
|
||||
activeFilters={activeFilters}
|
||||
effects={memoizedEffects}
|
||||
onEntityClick={setSelectedEntity}
|
||||
@@ -502,74 +549,87 @@ export default function Dashboard() {
|
||||
>
|
||||
{/* 1. DATA LAYERS (Top) */}
|
||||
<div className="contents" style={{ direction: 'ltr' }}>
|
||||
<ErrorBoundary name="WorldviewLeftPanel">
|
||||
<WorldviewLeftPanel
|
||||
activeLayers={activeLayers}
|
||||
setActiveLayers={setActiveLayers}
|
||||
shodanResultCount={shodanResults.length}
|
||||
onSettingsClick={() => setSettingsOpen(true)}
|
||||
onLegendClick={() => setLegendOpen(true)}
|
||||
onOpenSarAoiEditor={() => setSarAoiEditorOpen(true)}
|
||||
gibsDate={gibsDate}
|
||||
setGibsDate={setGibsDate}
|
||||
gibsOpacity={gibsOpacity}
|
||||
setGibsOpacity={setGibsOpacity}
|
||||
sentinelDate={sentinelDate}
|
||||
setSentinelDate={setSentinelDate}
|
||||
sentinelOpacity={sentinelOpacity}
|
||||
setSentinelOpacity={setSentinelOpacity}
|
||||
sentinelPreset={sentinelPreset}
|
||||
setSentinelPreset={setSentinelPreset}
|
||||
onEntityClick={setSelectedEntity}
|
||||
onFlyTo={handleFlyTo}
|
||||
trackedSdr={trackedSdr}
|
||||
setTrackedSdr={setTrackedSdr}
|
||||
trackedScanner={trackedScanner}
|
||||
setTrackedScanner={setTrackedScanner}
|
||||
isMinimized={leftDataMinimized}
|
||||
onMinimizedChange={setLeftDataMinimized}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
{secondaryBootReady ? (
|
||||
<ErrorBoundary name="WorldviewLeftPanel">
|
||||
<WorldviewLeftPanel
|
||||
activeLayers={activeLayers}
|
||||
setActiveLayers={setActiveLayers}
|
||||
shodanResultCount={shodanResults.length}
|
||||
onSettingsClick={() => setSettingsOpen(true)}
|
||||
onLegendClick={() => setLegendOpen(true)}
|
||||
onOpenSarAoiEditor={() => setSarAoiEditorOpen(true)}
|
||||
gibsDate={gibsDate}
|
||||
setGibsDate={setGibsDate}
|
||||
gibsOpacity={gibsOpacity}
|
||||
setGibsOpacity={setGibsOpacity}
|
||||
sentinelDate={sentinelDate}
|
||||
setSentinelDate={setSentinelDate}
|
||||
sentinelOpacity={sentinelOpacity}
|
||||
setSentinelOpacity={setSentinelOpacity}
|
||||
sentinelPreset={sentinelPreset}
|
||||
setSentinelPreset={setSentinelPreset}
|
||||
onEntityClick={setSelectedEntity}
|
||||
onFlyTo={handleFlyTo}
|
||||
trackedSdr={trackedSdr}
|
||||
setTrackedSdr={setTrackedSdr}
|
||||
trackedScanner={trackedScanner}
|
||||
setTrackedScanner={setTrackedScanner}
|
||||
isMinimized={leftDataMinimized}
|
||||
onMinimizedChange={setLeftDataMinimized}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
) : (
|
||||
<div className="bg-[#05090d]/95 border border-cyan-900/50 p-4 font-mono text-cyan-500/70">
|
||||
<div className="text-[11px] tracking-[0.2em] text-cyan-400 font-bold">DATA LAYERS</div>
|
||||
<div className="mt-3 text-[10px] tracking-wider">PRIORITIZING MAP FEEDS</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 2. MESH CHAT (Middle) */}
|
||||
<div className="contents" style={{ direction: 'ltr' }}>
|
||||
<MeshChat
|
||||
onFlyTo={handleFlyTo}
|
||||
expanded={leftMeshExpanded}
|
||||
onExpandedChange={setLeftMeshExpanded}
|
||||
onSettingsClick={() => setSettingsOpen(true)}
|
||||
onTerminalToggle={openSecureTerminalLauncher}
|
||||
launchRequest={meshChatLaunchRequest}
|
||||
/>
|
||||
</div>
|
||||
{secondaryBootReady && (
|
||||
<div className="contents" style={{ direction: 'ltr' }}>
|
||||
<MeshChat
|
||||
onFlyTo={handleFlyTo}
|
||||
expanded={leftMeshExpanded}
|
||||
onExpandedChange={setLeftMeshExpanded}
|
||||
onSettingsClick={() => setSettingsOpen(true)}
|
||||
onTerminalToggle={openSecureTerminalLauncher}
|
||||
launchRequest={meshChatLaunchRequest}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 3. SHODAN CONNECTOR (Bottom) */}
|
||||
<div className="contents" style={{ direction: 'ltr' }}>
|
||||
<ShodanPanel
|
||||
currentResults={shodanResults}
|
||||
onOpenSettings={() => setSettingsOpen(true)}
|
||||
settingsOpen={settingsOpen}
|
||||
onResultsChange={(results, queryLabel) => {
|
||||
setShodanResults(results);
|
||||
setShodanQueryLabel(queryLabel);
|
||||
setActiveLayers((prev) => ({ ...prev, shodan_overlay: results.length > 0 }));
|
||||
}}
|
||||
onSelectEntity={setSelectedEntity}
|
||||
onStyleChange={setShodanStyle}
|
||||
isMinimized={leftShodanMinimized}
|
||||
onMinimizedChange={setLeftShodanMinimized}
|
||||
/>
|
||||
</div>
|
||||
{secondaryBootReady && (
|
||||
<div className="contents" style={{ direction: 'ltr' }}>
|
||||
<ShodanPanel
|
||||
currentResults={shodanResults}
|
||||
onOpenSettings={() => setSettingsOpen(true)}
|
||||
settingsOpen={settingsOpen}
|
||||
onResultsChange={(results, queryLabel) => {
|
||||
setShodanResults(results);
|
||||
setShodanQueryLabel(queryLabel);
|
||||
setActiveLayers((prev) => ({ ...prev, shodan_overlay: results.length > 0 }));
|
||||
}}
|
||||
onSelectEntity={setSelectedEntity}
|
||||
onStyleChange={setShodanStyle}
|
||||
isMinimized={leftShodanMinimized}
|
||||
onMinimizedChange={setLeftShodanMinimized}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 4. AI INTEL (Below Shodan) */}
|
||||
<div className="contents" style={{ direction: 'ltr' }}>
|
||||
<AIIntelPanel
|
||||
onFlyTo={handleFlyTo}
|
||||
pinPlacementMode={pinPlacementMode}
|
||||
onPinPlacementModeChange={setPinPlacementMode}
|
||||
/>
|
||||
</div>
|
||||
{secondaryBootReady && (
|
||||
<div className="contents" style={{ direction: 'ltr' }}>
|
||||
<AIIntelPanel
|
||||
onFlyTo={handleFlyTo}
|
||||
pinPlacementMode={pinPlacementMode}
|
||||
onPinPlacementModeChange={setPinPlacementMode}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* LEFT SIDEBAR TOGGLE TAB — aligns with Data Layers section */}
|
||||
@@ -647,11 +707,13 @@ export default function Dashboard() {
|
||||
{/* GLOBAL TICKER REPLACES MARKETS PANEL - RENDERED OUTSIDE THIS DIV */}
|
||||
|
||||
{/* EVENT TIMELINE */}
|
||||
<div className={`flex-shrink-0 ${rightFocusedPanel && rightFocusedPanel !== 'predictions' ? 'hidden' : ''}`}>
|
||||
<ErrorBoundary name="TimelinePanel">
|
||||
<TimelinePanel />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
{secondaryBootReady && (
|
||||
<div className={`flex-shrink-0 ${rightFocusedPanel && rightFocusedPanel !== 'predictions' ? 'hidden' : ''}`}>
|
||||
<ErrorBoundary name="TimelinePanel">
|
||||
<TimelinePanel />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* DATA FILTERS */}
|
||||
<div className={`flex-shrink-0 ${rightFocusedPanel && rightFocusedPanel !== 'filters' ? 'hidden' : ''}`}>
|
||||
@@ -870,8 +932,13 @@ export default function Dashboard() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* FIRST-RUN WARMUP NOTICE — shows once after onboarding */}
|
||||
{!showOnboarding && showWarmupNotice && (
|
||||
<StartupWarmupModal onClose={() => setShowWarmupNotice(false)} />
|
||||
)}
|
||||
|
||||
{/* v0.4 CHANGELOG MODAL — shows once per version after onboarding */}
|
||||
{!showOnboarding && showChangelog && (
|
||||
{!showOnboarding && !showWarmupNotice && showChangelog && (
|
||||
<ChangelogModal onClose={() => setShowChangelog(false)} />
|
||||
)}
|
||||
|
||||
|
||||
@@ -8,8 +8,11 @@ export interface HlsVideoHandle {
|
||||
get paused(): boolean;
|
||||
}
|
||||
|
||||
const HlsVideo = forwardRef<HlsVideoHandle, { url: string; className?: string; onError?: () => void }>(
|
||||
({ url, className, onError }, ref) => {
|
||||
const HlsVideo = forwardRef<
|
||||
HlsVideoHandle,
|
||||
{ url: string; className?: string; onError?: () => void; onLoaded?: () => void }
|
||||
>(
|
||||
({ url, className, onError, onLoaded }, ref) => {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
@@ -35,6 +38,7 @@ const HlsVideo = forwardRef<HlsVideoHandle, { url: string; className?: string; o
|
||||
hls.on(Hls.Events.ERROR, (_e: unknown, data: { fatal?: boolean }) => {
|
||||
if (data.fatal) onError?.();
|
||||
});
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => onLoaded?.());
|
||||
hls.loadSource(url);
|
||||
hls.attachMedia(video);
|
||||
hlsInstance = hls;
|
||||
@@ -47,7 +51,7 @@ const HlsVideo = forwardRef<HlsVideoHandle, { url: string; className?: string; o
|
||||
cancelled = true;
|
||||
hlsInstance?.destroy();
|
||||
};
|
||||
}, [url, onError]);
|
||||
}, [url, onError, onLoaded]);
|
||||
|
||||
return (
|
||||
<video
|
||||
@@ -56,6 +60,9 @@ const HlsVideo = forwardRef<HlsVideoHandle, { url: string; className?: string; o
|
||||
muted
|
||||
playsInline
|
||||
onError={() => onError?.()}
|
||||
onCanPlay={() => onLoaded?.()}
|
||||
onLoadedData={() => onLoaded?.()}
|
||||
onPlaying={() => onLoaded?.()}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { Terminal, Radio, Globe, Key, LogOut, Activity, Vote, User, ArrowRightLeft, Briefcase, Mail, Brain, GitBranch, Cpu, KeyRound } from 'lucide-react';
|
||||
import { Terminal, Radio, Globe, Key, Activity, Vote, User, ArrowRightLeft, Briefcase, Mail, Brain, GitBranch, Cpu, KeyRound } from 'lucide-react';
|
||||
import { getNodeIdentity, getWormholeIdentityDescriptor } from '@/mesh/meshIdentity';
|
||||
import {
|
||||
activateWormholeGatePersona,
|
||||
@@ -128,7 +128,6 @@ const SECTIONS = [
|
||||
{ name: 'EXCHANGE', icon: <ArrowRightLeft size={14} className="mr-2" /> },
|
||||
{ name: 'PROFILE', icon: <User size={14} className="mr-2" /> },
|
||||
{ name: 'MESSAGES', icon: <Mail size={14} className="mr-2" /> },
|
||||
{ name: 'EXIT', icon: <LogOut size={14} className="mr-2" /> },
|
||||
];
|
||||
|
||||
interface CommandHistory {
|
||||
|
||||
@@ -32,12 +32,22 @@ export default function NetworkStats() {
|
||||
fetchInfonetNodeStatusSnapshot(true).catch(() => null),
|
||||
]);
|
||||
if (!alive) return;
|
||||
const knownNodes = Number(infonet?.known_nodes || 0);
|
||||
const syncPeerCount = Number(infonet?.bootstrap?.sync_peer_count || 0);
|
||||
const defaultSyncPeerCount = Number(infonet?.bootstrap?.default_sync_peer_count || 0);
|
||||
const lastPeerUrl = String(infonet?.sync_runtime?.last_peer_url || '').trim();
|
||||
const visibleInfonetNodes = Math.max(
|
||||
knownNodes,
|
||||
syncPeerCount,
|
||||
defaultSyncPeerCount,
|
||||
lastPeerUrl ? 1 : 0,
|
||||
);
|
||||
setStats({
|
||||
meshtastic: Number(channelsRes?.total_live || channelsRes?.total_nodes || meshRes?.signal_counts?.meshtastic || 0),
|
||||
aprs: Number(meshRes?.signal_counts?.aprs || 0),
|
||||
infonetNodes: Number(infonet?.known_nodes || 0),
|
||||
infonetNodes: visibleInfonetNodes,
|
||||
infonetEvents: Number(infonet?.total_events || 0),
|
||||
syncPeers: Number(infonet?.bootstrap?.sync_peer_count || 0),
|
||||
syncPeers: syncPeerCount,
|
||||
nodeEnabled: Boolean(infonet?.node_enabled),
|
||||
syncOutcome: String(infonet?.sync_runtime?.last_outcome || 'offline').toLowerCase(),
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { X, Minus } from 'lucide-react';
|
||||
import { X } from 'lucide-react';
|
||||
import InfonetShell from './InfonetShell';
|
||||
|
||||
interface InfonetTerminalProps {
|
||||
@@ -55,13 +55,6 @@ export default function InfonetTerminal({
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 text-gray-600 hover:text-gray-300 transition-colors"
|
||||
title="Minimize"
|
||||
>
|
||||
<Minus size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 text-gray-600 hover:text-red-400 transition-colors"
|
||||
|
||||
@@ -16,7 +16,7 @@ import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import { computeNightPolygon } from '@/utils/solarTerminator';
|
||||
import { darkStyle, lightStyle } from '@/components/map/styles/mapStyles';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import { AlertTriangle, Radio, Activity, Play, Satellite } from 'lucide-react';
|
||||
import { AlertTriangle, Radio, Activity, Play, Satellite, ExternalLink, Info } from 'lucide-react';
|
||||
import WikiImage from '@/components/WikiImage';
|
||||
import FishingDestinationRoute from '@/components/map/FishingDestinationRoute';
|
||||
import { useTheme } from '@/lib/ThemeContext';
|
||||
@@ -293,6 +293,15 @@ function probeRasterTile(url: string): Promise<boolean> {
|
||||
});
|
||||
}
|
||||
|
||||
function buildPolymarketUrl(prediction: { slug?: string; title?: string } | null | undefined): string {
|
||||
const slug = String(prediction?.slug || '').trim();
|
||||
if (slug) return `https://polymarket.com/event/${encodeURIComponent(slug)}`;
|
||||
const title = String(prediction?.title || '').trim();
|
||||
return title
|
||||
? `https://polymarket.com/search?query=${encodeURIComponent(title)}`
|
||||
: 'https://polymarket.com/markets';
|
||||
}
|
||||
|
||||
const MaplibreViewer = ({
|
||||
activeLayers,
|
||||
activeFilters,
|
||||
@@ -336,6 +345,7 @@ const MaplibreViewer = ({
|
||||
const data = useMemo(() => ({ ...coreData, ...extraData }) as DashboardData, [coreData, extraData]);
|
||||
const mapRef = useRef<MapRef>(null);
|
||||
const mapInitRef = useRef(false);
|
||||
const [mapReady, setMapReady] = useState(false);
|
||||
const { theme } = useTheme();
|
||||
const mapThemeStyle = useMemo<maplibregl.StyleSpecification>(
|
||||
() => (theme === 'light' ? lightStyle : darkStyle) as maplibregl.StyleSpecification,
|
||||
@@ -905,15 +915,20 @@ const MaplibreViewer = ({
|
||||
// Load Images into the Map Style once loaded
|
||||
const onMapLoad = useCallback((e: { target: maplibregl.Map }) => {
|
||||
initializeMap(e.target);
|
||||
setMapReady(true);
|
||||
}, [initializeMap]);
|
||||
|
||||
const onMapStyleData = useCallback((e: { target: maplibregl.Map }) => {
|
||||
initializeMap(e.target);
|
||||
setMapReady(true);
|
||||
}, [initializeMap]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current?.getMap();
|
||||
if (map) initializeMap(map);
|
||||
if (map) {
|
||||
initializeMap(map);
|
||||
setMapReady(true);
|
||||
}
|
||||
}, [initializeMap, theme]);
|
||||
|
||||
// Build a set of tracked icao24s to exclude from other flight layers
|
||||
@@ -1552,7 +1567,7 @@ const MaplibreViewer = ({
|
||||
}, [activeLayers.uap_sightings, activeLayers.wastewater, theme]);
|
||||
|
||||
// --- Imperative source updates: bypass React reconciliation for GeoJSON layers ---
|
||||
const mapForHook = mapRef.current;
|
||||
const mapForHook = mapReady ? mapRef.current : null;
|
||||
useImperativeSource(mapForHook, 'commercial-flights', commFlightsGeoJSON);
|
||||
useImperativeSource(mapForHook, 'private-flights', privFlightsGeoJSON);
|
||||
useImperativeSource(mapForHook, 'private-jets', privJetsGeoJSON);
|
||||
@@ -5712,29 +5727,56 @@ const MaplibreViewer = ({
|
||||
<div className="px-5 pb-3">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{/* Oracle Score */}
|
||||
<div className={`border rounded p-3 text-center ${oTierBg || 'bg-black/40 border-cyan-800/30'}`}>
|
||||
<div className="text-[9px] text-[var(--text-muted)] tracking-[0.15em] mb-1.5">ORACLE SCORE</div>
|
||||
<label className={`border rounded p-3 text-center transition-colors hover:border-white/40 cursor-pointer ${oTierBg || 'bg-black/40 border-cyan-800/30'}`}>
|
||||
<input type="checkbox" className="peer sr-only" aria-label="Explain Oracle Score" />
|
||||
<div className="flex items-center justify-center gap-1 text-[9px] text-[var(--text-muted)] tracking-[0.15em] mb-1.5">
|
||||
<span>ORACLE SCORE</span>
|
||||
<Info size={10} />
|
||||
</div>
|
||||
<div className={`text-[28px] font-bold leading-none ${oTierColor || 'text-gray-500'}`}>
|
||||
{oScore != null ? oScore.toFixed(1) : '—'}
|
||||
</div>
|
||||
{oTier && <div className={`text-[10px] font-bold ${oTierColor} mt-1`}>{oTier}</div>}
|
||||
</div>
|
||||
<div className="hidden peer-checked:block mt-2 border-t border-white/10 pt-2 text-left text-[10px] leading-relaxed text-cyan-100">
|
||||
<div className="text-cyan-400 font-bold tracking-[0.16em] mb-1">SCALE</div>
|
||||
<p>0-10 weighted signal score combining alert risk and source confidence.</p>
|
||||
<p className="mt-1 text-[var(--text-muted)]">0-3 low, 4-5 moderate, 6-7 elevated, 8-10 critical.</p>
|
||||
</div>
|
||||
</label>
|
||||
{/* Sentiment */}
|
||||
<div className={`border rounded p-3 text-center ${sentBg || 'bg-black/40 border-cyan-800/30'}`}>
|
||||
<div className="text-[9px] text-[var(--text-muted)] tracking-[0.15em] mb-1.5">SENTIMENT</div>
|
||||
<label className={`border rounded p-3 text-center transition-colors hover:border-white/40 cursor-pointer ${sentBg || 'bg-black/40 border-cyan-800/30'}`}>
|
||||
<input type="checkbox" className="peer sr-only" aria-label="Explain Sentiment" />
|
||||
<div className="flex items-center justify-center gap-1 text-[9px] text-[var(--text-muted)] tracking-[0.15em] mb-1.5">
|
||||
<span>SENTIMENT</span>
|
||||
<Info size={10} />
|
||||
</div>
|
||||
<div className={`text-[28px] font-bold leading-none ${sentColor || 'text-gray-500'}`}>
|
||||
{sent != null ? <>{sentArrow} {sent > 0 ? '+' : ''}{sent.toFixed(2)}</> : '—'}
|
||||
</div>
|
||||
{sentLabel && <div className={`text-[10px] font-bold ${sentColor} mt-1`}>{sentLabel}</div>}
|
||||
</div>
|
||||
<div className="hidden peer-checked:block mt-2 border-t border-white/10 pt-2 text-left text-[10px] leading-relaxed text-cyan-100">
|
||||
<div className="text-cyan-400 font-bold tracking-[0.16em] mb-1">SCALE</div>
|
||||
<p>-1.00 to +1.00 headline tone. Negative reads more adverse; positive reads more constructive.</p>
|
||||
<p className="mt-1 text-[var(--text-muted)]">Below -0.10 negative, -0.10 to +0.10 neutral, above +0.10 positive. It measures tone, not truth.</p>
|
||||
</div>
|
||||
</label>
|
||||
{/* Threat Level */}
|
||||
<div className={`border rounded p-3 text-center ${rs >= 8 ? 'bg-red-500/10 border-red-500/30' : rs >= 6 ? 'bg-orange-500/10 border-orange-500/30' : rs >= 4 ? 'bg-yellow-500/10 border-yellow-500/30' : 'bg-green-500/10 border-green-500/30'}`}>
|
||||
<div className="text-[9px] text-[var(--text-muted)] tracking-[0.15em] mb-1.5">RISK LEVEL</div>
|
||||
<label className={`border rounded p-3 text-center transition-colors hover:border-white/40 cursor-pointer ${rs >= 8 ? 'bg-red-500/10 border-red-500/30' : rs >= 6 ? 'bg-orange-500/10 border-orange-500/30' : rs >= 4 ? 'bg-yellow-500/10 border-yellow-500/30' : 'bg-green-500/10 border-green-500/30'}`}>
|
||||
<input type="checkbox" className="peer sr-only" aria-label="Explain Risk Level" />
|
||||
<div className="flex items-center justify-center gap-1 text-[9px] text-[var(--text-muted)] tracking-[0.15em] mb-1.5">
|
||||
<span>RISK LEVEL</span>
|
||||
<Info size={10} />
|
||||
</div>
|
||||
<div className={`text-[28px] font-bold leading-none ${threatColor}`}>{rs}/10</div>
|
||||
<div className={`text-[10px] font-bold ${threatColor} mt-1`}>
|
||||
{rs >= 9 ? 'CRITICAL' : rs >= 7 ? 'HIGH' : rs >= 4 ? 'MEDIUM' : 'LOW'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden peer-checked:block mt-2 border-t border-white/10 pt-2 text-left text-[10px] leading-relaxed text-cyan-100">
|
||||
<div className="text-cyan-400 font-bold tracking-[0.16em] mb-1">SCALE</div>
|
||||
<p>0-10 operational severity estimate based on source, topic, keywords, corroboration, and alert context.</p>
|
||||
<p className="mt-1 text-[var(--text-muted)]">0-3 low, 4-6 medium, 7-8 high, 9-10 critical.</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5742,8 +5784,20 @@ const MaplibreViewer = ({
|
||||
{pred && pred.consensus_pct != null && (
|
||||
<div className="px-5 pb-3">
|
||||
<div className="bg-purple-950/30 border border-purple-500/40 rounded p-4">
|
||||
<div className="text-[10px] text-purple-400 tracking-[0.2em] font-bold mb-2">
|
||||
PREDICTION MARKET ANALYSIS
|
||||
<div className="flex items-center justify-between gap-3 mb-2">
|
||||
<div className="text-[10px] text-purple-400 tracking-[0.2em] font-bold">
|
||||
PREDICTION MARKET ANALYSIS
|
||||
</div>
|
||||
{pred.polymarket_pct != null && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.open(buildPolymarketUrl(pred), '_blank', 'noopener,noreferrer')}
|
||||
className="inline-flex items-center gap-1 text-[10px] text-purple-200 hover:text-white border border-purple-500/30 hover:border-purple-300/70 px-2 py-1 rounded transition-colors"
|
||||
title="Open this market on Polymarket"
|
||||
>
|
||||
POLYMARKET <ExternalLink size={10} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[14px] text-purple-200 font-bold leading-snug mb-3">
|
||||
"{pred.title}"
|
||||
@@ -5760,10 +5814,16 @@ const MaplibreViewer = ({
|
||||
</div>
|
||||
<div className="flex gap-6 text-[11px]">
|
||||
{pred.polymarket_pct != null && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.open(buildPolymarketUrl(pred), '_blank', 'noopener,noreferrer')}
|
||||
className="flex items-center gap-2 hover:text-white transition-colors"
|
||||
title="Open this market on Polymarket"
|
||||
>
|
||||
<span className="text-purple-400/70">Polymarket</span>
|
||||
<span className="text-white font-bold text-[13px]">{pred.polymarket_pct}%</span>
|
||||
</div>
|
||||
<ExternalLink size={10} className="text-purple-400/70" />
|
||||
</button>
|
||||
)}
|
||||
{pred.kalshi_pct != null && (
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -5834,7 +5894,7 @@ const MaplibreViewer = ({
|
||||
onClick={() => window.open(item.link, '_blank', 'noopener,noreferrer')}
|
||||
className={`${threatColor} hover:text-white text-[12px] font-bold underline underline-offset-2 cursor-pointer`}
|
||||
>
|
||||
VIEW FULL REPORT ↗
|
||||
GO TO ARTICLE ↗
|
||||
</button>
|
||||
) : <span />}
|
||||
<button
|
||||
@@ -5902,6 +5962,7 @@ const MaplibreViewer = ({
|
||||
return (
|
||||
<CctvFullscreenModal
|
||||
url={url}
|
||||
rawUrl={rawUrl}
|
||||
mediaType={mt}
|
||||
isVideo={isVideo}
|
||||
cameraName={cameraName}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
||||
import { AlertTriangle, Play, Pause } from 'lucide-react';
|
||||
import HlsVideo, { type HlsVideoHandle } from '@/components/HlsVideo';
|
||||
|
||||
export interface CctvFullscreenModalProps {
|
||||
url: string;
|
||||
rawUrl?: string;
|
||||
mediaType: string;
|
||||
isVideo: boolean;
|
||||
cameraName: string;
|
||||
@@ -16,6 +17,7 @@ export interface CctvFullscreenModalProps {
|
||||
|
||||
export function CctvFullscreenModal({
|
||||
url,
|
||||
rawUrl = '',
|
||||
mediaType,
|
||||
isVideo,
|
||||
cameraName,
|
||||
@@ -25,8 +27,60 @@ export function CctvFullscreenModal({
|
||||
}: CctvFullscreenModalProps) {
|
||||
const [paused, setPaused] = useState(false);
|
||||
const [mediaError, setMediaError] = useState(false);
|
||||
const [mediaLoaded, setMediaLoaded] = useState(false);
|
||||
const [sourceIndex, setSourceIndex] = useState(0);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const hlsRef = useRef<HlsVideoHandle>(null);
|
||||
const sources = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
return [url, rawUrl]
|
||||
.map((candidate) => String(candidate || '').trim())
|
||||
.filter((candidate) => {
|
||||
if (!candidate || seen.has(candidate)) return false;
|
||||
seen.add(candidate);
|
||||
return true;
|
||||
});
|
||||
}, [rawUrl, url]);
|
||||
const activeUrl = sources[sourceIndex] || '';
|
||||
|
||||
useEffect(() => {
|
||||
setSourceIndex(0);
|
||||
setMediaError(false);
|
||||
setMediaLoaded(false);
|
||||
setPaused(false);
|
||||
}, [rawUrl, url]);
|
||||
|
||||
useEffect(() => {
|
||||
setMediaLoaded(false);
|
||||
}, [activeUrl]);
|
||||
|
||||
const handleMediaFailure = useCallback(() => {
|
||||
setSourceIndex((idx) => {
|
||||
const next = idx + 1;
|
||||
if (next < sources.length) {
|
||||
setMediaError(false);
|
||||
return next;
|
||||
}
|
||||
setMediaError(true);
|
||||
return idx;
|
||||
});
|
||||
}, [sources.length]);
|
||||
|
||||
const handleMediaReady = useCallback(() => {
|
||||
setMediaLoaded(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (sourceIndex !== 0 || sources.length < 2 || mediaLoaded || mediaError) return;
|
||||
const timeoutMs = mediaType === 'hls' ? 3200 : 1800;
|
||||
const timer = window.setTimeout(() => {
|
||||
setSourceIndex((idx) => {
|
||||
if (idx !== 0 || mediaLoaded) return idx;
|
||||
return 1;
|
||||
});
|
||||
}, timeoutMs);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [mediaError, mediaLoaded, mediaType, sourceIndex, sources.length]);
|
||||
|
||||
const togglePlay = useCallback(() => {
|
||||
if (mediaType === 'hls') {
|
||||
@@ -176,17 +230,21 @@ export function CctvFullscreenModal({
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{url ? (
|
||||
{activeUrl ? (
|
||||
<>
|
||||
{mediaType === 'video' && !mediaError && (
|
||||
<video
|
||||
key={activeUrl}
|
||||
ref={videoRef}
|
||||
src={url}
|
||||
src={activeUrl}
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
onError={() => setMediaError(true)}
|
||||
onError={handleMediaFailure}
|
||||
onCanPlay={handleMediaReady}
|
||||
onLoadedData={handleMediaReady}
|
||||
onPlaying={handleMediaReady}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: 'calc(100vh - 260px)',
|
||||
@@ -197,15 +255,18 @@ export function CctvFullscreenModal({
|
||||
)}
|
||||
{mediaType === 'hls' && !mediaError && (
|
||||
<HlsVideo
|
||||
key={activeUrl}
|
||||
ref={hlsRef}
|
||||
url={url}
|
||||
onError={() => setMediaError(true)}
|
||||
className=""
|
||||
url={activeUrl}
|
||||
onError={handleMediaFailure}
|
||||
onLoaded={handleMediaReady}
|
||||
className="max-w-full max-h-[calc(100vh-260px)] object-contain"
|
||||
/>
|
||||
)}
|
||||
{mediaType === 'mjpeg' && (
|
||||
<img
|
||||
src={url}
|
||||
key={activeUrl}
|
||||
src={activeUrl}
|
||||
alt="MJPEG Feed"
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
@@ -213,14 +274,14 @@ export function CctvFullscreenModal({
|
||||
objectFit: 'contain',
|
||||
filter: 'contrast(1.25) saturate(0.5)',
|
||||
}}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
onError={handleMediaFailure}
|
||||
onLoad={handleMediaReady}
|
||||
/>
|
||||
)}
|
||||
{(mediaType === 'image' || mediaType === 'satellite') && (
|
||||
<img
|
||||
src={url}
|
||||
key={activeUrl}
|
||||
src={activeUrl}
|
||||
alt="CCTV Feed"
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
@@ -228,10 +289,8 @@ export function CctvFullscreenModal({
|
||||
objectFit: 'contain',
|
||||
filter: 'contrast(1.25) saturate(0.5)',
|
||||
}}
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.style.display = 'none';
|
||||
}}
|
||||
onError={handleMediaFailure}
|
||||
onLoad={handleMediaReady}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -239,7 +298,7 @@ export function CctvFullscreenModal({
|
||||
{mediaError && (
|
||||
<div style={{ fontSize: 11, color: 'rgba(239,68,68,0.7)', fontFamily: 'monospace', letterSpacing: '0.15em', textAlign: 'center', padding: 40 }}>
|
||||
FEED UNAVAILABLE<br />
|
||||
<span style={{ fontSize: 9, color: 'rgba(148,163,184,0.5)' }}>stream failed to load — source may be offline</span>
|
||||
<span style={{ fontSize: 9, color: 'rgba(148,163,184,0.5)' }}>proxy and direct source both failed</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -329,10 +388,10 @@ export function CctvFullscreenModal({
|
||||
{cameraName}
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
{url && (
|
||||
{activeUrl && (
|
||||
<>
|
||||
<a
|
||||
href={url}
|
||||
href={rawUrl || activeUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
@@ -354,7 +413,7 @@ export function CctvFullscreenModal({
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
await navigator.clipboard.writeText(rawUrl || activeUrl);
|
||||
} catch { /* ignore */ }
|
||||
}}
|
||||
style={{
|
||||
|
||||
@@ -111,6 +111,7 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
||||
identityWizardStatus,
|
||||
setIdentityWizardStatus,
|
||||
meshQuickStatus,
|
||||
meshSessionActive,
|
||||
publicMeshAddress,
|
||||
meshView,
|
||||
setMeshView,
|
||||
@@ -119,6 +120,7 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
||||
// Identity
|
||||
identity,
|
||||
publicIdentity,
|
||||
hasStoredPublicLaneIdentity,
|
||||
hasPublicLaneIdentity,
|
||||
hasId,
|
||||
shouldShowIdentityWarning,
|
||||
@@ -255,6 +257,7 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
||||
openChat,
|
||||
handleCreatePublicIdentity,
|
||||
handleQuickCreatePublicIdentity,
|
||||
handleActivatePublicMeshSession,
|
||||
handleLeaveWormholeForPublicMesh,
|
||||
handleResetPublicIdentity,
|
||||
handleBootstrapPrivateIdentity,
|
||||
@@ -324,6 +327,40 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
||||
}
|
||||
void handleRequestAccess(targetId);
|
||||
};
|
||||
const meshActivationText =
|
||||
meshQuickStatus?.text ||
|
||||
(publicMeshBlockedByWormhole
|
||||
? hasStoredPublicLaneIdentity
|
||||
? 'Wormhole is active. Turning MeshChat on will turn Wormhole off and use your saved public mesh key.'
|
||||
: 'Wormhole is active. Turning MeshChat on will turn Wormhole off and mint a separate public mesh key.'
|
||||
: hasStoredPublicLaneIdentity
|
||||
? 'MeshChat is off. Turn it on to use your saved public mesh key.'
|
||||
: 'Public mesh posting needs a mesh key. One tap gets you a fresh address.');
|
||||
const handleMeshActivationAction = () => {
|
||||
if (hasStoredPublicLaneIdentity) {
|
||||
void handleActivatePublicMeshSession();
|
||||
return;
|
||||
}
|
||||
if (publicMeshBlockedByWormhole) {
|
||||
void handleLeaveWormholeForPublicMesh();
|
||||
return;
|
||||
}
|
||||
void handleQuickCreatePublicIdentity();
|
||||
};
|
||||
const meshActivationLabel = identityWizardBusy
|
||||
? 'GETTING MESH KEY'
|
||||
: hasStoredPublicLaneIdentity
|
||||
? 'TURN ON MESH'
|
||||
: publicMeshBlockedByWormhole
|
||||
? 'TURN OFF WORMHOLE FOR MESH'
|
||||
: 'GET MESH KEY';
|
||||
const meshActivationSideLabel = identityWizardBusy
|
||||
? 'WORKING...'
|
||||
: hasStoredPublicLaneIdentity
|
||||
? 'USE SAVED KEY'
|
||||
: publicMeshBlockedByWormhole
|
||||
? 'AUTO DISABLE'
|
||||
: 'ONE TAP';
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -1120,16 +1157,25 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-[10px] font-mono text-[var(--text-muted)] truncate">
|
||||
{publicMeshAddress ? `ADDR ${publicMeshAddress.toUpperCase()}` : 'NO PUBLIC MESH ADDRESS'}
|
||||
{meshSessionActive && publicMeshAddress
|
||||
? `ADDR ${publicMeshAddress.toUpperCase()}`
|
||||
: publicMeshAddress
|
||||
? 'MESH OFF / KEY SAVED'
|
||||
: 'NO PUBLIC MESH ADDRESS'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto styled-scrollbar px-3 py-1.5 border-l-2 border-cyan-800/25">
|
||||
{meshView === 'channel' && filteredMeshMessages.length === 0 && (
|
||||
{!meshSessionActive && (
|
||||
<div className="text-[12px] font-mono text-green-300/70 text-center py-4 leading-[1.65]">
|
||||
MeshChat is off. Turn it on to connect the public mesh lane.
|
||||
</div>
|
||||
)}
|
||||
{meshSessionActive && meshView === 'channel' && filteredMeshMessages.length === 0 && (
|
||||
<div className="text-[12px] font-mono text-[var(--text-muted)] text-center py-4 leading-[1.65]">
|
||||
No messages from {meshRegion} / {meshChannel}
|
||||
</div>
|
||||
)}
|
||||
{meshView === 'inbox' && (
|
||||
{meshSessionActive && meshView === 'inbox' && (
|
||||
<>
|
||||
{!publicMeshAddress && (
|
||||
<div className="text-[12px] font-mono text-[var(--text-muted)] text-center py-4 leading-[1.65]">
|
||||
@@ -2049,7 +2095,9 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
||||
? meshDirectTarget
|
||||
? `→ MESH / TO ${meshDirectTarget.toUpperCase()}`
|
||||
: `→ MESH / ${meshRegion} / ${meshChannel}`
|
||||
: '→ MESH LOCKED'
|
||||
: hasStoredPublicLaneIdentity
|
||||
? '→ MESH OFF'
|
||||
: '→ MESH LOCKED'
|
||||
: activeTab === 'dms' && secureDmBlocked
|
||||
? '→ DEAD DROP LOCKED'
|
||||
: dmView === 'chat' && selectedContact
|
||||
@@ -2068,10 +2116,7 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
||||
: 'text-green-300/70'
|
||||
}`}
|
||||
>
|
||||
{meshQuickStatus?.text ||
|
||||
(publicMeshBlockedByWormhole
|
||||
? 'Wormhole is active. Turn it off here and we will mint a separate public mesh key for you.'
|
||||
: 'Public mesh posting needs a mesh key. One tap gets you a fresh address.')}
|
||||
{meshActivationText}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 px-3 pb-2 pt-1">
|
||||
@@ -2103,30 +2148,16 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
||||
</button>
|
||||
) : activeTab === 'meshtastic' && !hasPublicLaneIdentity ? (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (publicMeshBlockedByWormhole) {
|
||||
void handleLeaveWormholeForPublicMesh();
|
||||
return;
|
||||
}
|
||||
void handleQuickCreatePublicIdentity();
|
||||
}}
|
||||
onClick={handleMeshActivationAction}
|
||||
disabled={identityWizardBusy}
|
||||
className="w-full flex items-center justify-between gap-2 px-3 py-2 border border-green-700/40 bg-green-950/15 text-green-300 hover:bg-green-950/25 hover:border-green-500/50 transition-colors"
|
||||
>
|
||||
<span className="inline-flex items-center gap-2 text-sm font-mono tracking-[0.2em]">
|
||||
<Radio size={11} />
|
||||
{identityWizardBusy
|
||||
? 'GETTING MESH KEY'
|
||||
: publicMeshBlockedByWormhole
|
||||
? 'TURN OFF WORMHOLE FOR MESH'
|
||||
: 'GET MESH KEY'}
|
||||
{meshActivationLabel}
|
||||
</span>
|
||||
<span className="text-[12px] font-mono text-green-300/70">
|
||||
{identityWizardBusy
|
||||
? 'WORKING...'
|
||||
: publicMeshBlockedByWormhole
|
||||
? 'AUTO FIX'
|
||||
: 'ONE TAP'}
|
||||
{meshActivationSideLabel}
|
||||
</span>
|
||||
</button>
|
||||
) : activeTab === 'meshtastic' && meshDirectTarget ? (
|
||||
@@ -2375,8 +2406,8 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
||||
CURRENT STATE
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-1 text-[13px] font-mono text-[var(--text-secondary)] leading-[1.5]">
|
||||
<div>Public mesh key: {hasPublicLaneIdentity ? 'active' : 'not issued'}</div>
|
||||
<div>Public mesh address: {hasPublicLaneIdentity && publicMeshAddress ? publicMeshAddress.toUpperCase() : 'not ready'}</div>
|
||||
<div>Public mesh key: {hasPublicLaneIdentity ? 'active' : hasStoredPublicLaneIdentity ? 'saved / off' : 'not issued'}</div>
|
||||
<div>Public mesh address: {publicMeshAddress ? publicMeshAddress.toUpperCase() : 'not ready'}</div>
|
||||
<div>Wormhole lane: {wormholeEnabled && wormholeReadyState ? 'active' : wormholeEnabled ? 'starting' : 'off'}</div>
|
||||
<div>Wormhole descriptor: {wormholeDescriptor?.nodeId || 'not cached yet'}</div>
|
||||
</div>
|
||||
@@ -2385,6 +2416,10 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (hasStoredPublicLaneIdentity) {
|
||||
void handleActivatePublicMeshSession();
|
||||
return;
|
||||
}
|
||||
if (publicMeshBlockedByWormhole) {
|
||||
void handleLeaveWormholeForPublicMesh();
|
||||
return;
|
||||
@@ -2396,12 +2431,16 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
||||
>
|
||||
{hasPublicLaneIdentity
|
||||
? 'MESH KEY ACTIVE'
|
||||
: hasStoredPublicLaneIdentity
|
||||
? 'TURN ON MESH'
|
||||
: publicMeshBlockedByWormhole
|
||||
? 'TURN OFF WORMHOLE FOR MESH'
|
||||
: 'GET MESH KEY'}
|
||||
<div className="mt-1 text-[13px] text-green-200/70 normal-case tracking-normal leading-[1.45]">
|
||||
{hasPublicLaneIdentity
|
||||
? 'Your public mesh key is already live for posting.'
|
||||
: hasStoredPublicLaneIdentity
|
||||
? 'Use your saved public mesh key. This turns Wormhole off first if it is active.'
|
||||
: publicMeshBlockedByWormhole
|
||||
? 'One tap turns Wormhole off and mints a separate public mesh key.'
|
||||
: 'One tap for a working mesh key and address.'}
|
||||
|
||||
@@ -313,6 +313,7 @@ export function useMeshChatController({
|
||||
const [identityWizardBusy, setIdentityWizardBusy] = useState(false);
|
||||
const [identityWizardStatus, setIdentityWizardStatus] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
|
||||
const [meshQuickStatus, setMeshQuickStatus] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
|
||||
const [meshSessionActive, setMeshSessionActive] = useState(false);
|
||||
const [publicMeshAddress, setPublicMeshAddress] = useState('');
|
||||
const [meshView, setMeshView] = useState<'channel' | 'inbox'>('channel');
|
||||
const [meshDirectTarget, setMeshDirectTarget] = useState('');
|
||||
@@ -328,12 +329,14 @@ export function useMeshChatController({
|
||||
const [recentPrivateFallbackReason, setRecentPrivateFallbackReason] = useState('');
|
||||
const [unresolvedSenderSealCount, setUnresolvedSenderSealCount] = useState(0);
|
||||
const [privacyProfile, setPrivacyProfile] = useState<'default' | 'high'>('default');
|
||||
const publicIdentity = clientHydrated ? getNodeIdentity() : null;
|
||||
const hasPublicLaneIdentity = clientHydrated && Boolean(publicIdentity) && hasSovereignty();
|
||||
const storedPublicIdentity = clientHydrated ? getNodeIdentity() : null;
|
||||
const hasStoredPublicLaneIdentity = clientHydrated && Boolean(storedPublicIdentity) && hasSovereignty();
|
||||
const publicIdentity = meshSessionActive ? storedPublicIdentity : null;
|
||||
const hasPublicLaneIdentity = meshSessionActive && hasStoredPublicLaneIdentity;
|
||||
const hasId = Boolean(identity) && (hasSovereignty() || wormholeEnabled);
|
||||
const shouldShowIdentityWarning = activeTab !== 'meshtastic' && !hasId;
|
||||
const privateInfonetReady = wormholeEnabled && wormholeReadyState;
|
||||
const publicMeshBlockedByWormhole = wormholeEnabled && wormholeReadyState && !hasPublicLaneIdentity;
|
||||
const publicMeshBlockedByWormhole = wormholeEnabled || wormholeReadyState;
|
||||
const dmSendQueue = useRef<(() => Promise<void>)[]>([]);
|
||||
const dmSendTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const streamEnabledForSelectedGateRef = useRef(false);
|
||||
@@ -365,6 +368,13 @@ export function useMeshChatController({
|
||||
setClientHydrated(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!clientHydrated) return;
|
||||
setMeshSessionActive(false);
|
||||
setMeshMessages([]);
|
||||
setMeshQuickStatus(null);
|
||||
}, [clientHydrated]);
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
subscribeGateSessionStreamStatus((nextStatus) => {
|
||||
@@ -450,6 +460,8 @@ export function useMeshChatController({
|
||||
setSecureModeCached(enabled);
|
||||
setWormholeEnabled(enabled);
|
||||
if (enabled) {
|
||||
setMeshSessionActive(false);
|
||||
setMeshMessages([]);
|
||||
purgeBrowserContactGraph();
|
||||
void hydrateWormholeContacts();
|
||||
}
|
||||
@@ -515,7 +527,7 @@ export function useMeshChatController({
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const senderId = publicIdentity?.nodeId || '';
|
||||
const senderId = storedPublicIdentity?.nodeId || '';
|
||||
if (!senderId || !globalThis.crypto?.subtle) {
|
||||
setPublicMeshAddress('');
|
||||
return;
|
||||
@@ -530,7 +542,7 @@ export function useMeshChatController({
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [publicIdentity?.nodeId]);
|
||||
}, [storedPublicIdentity?.nodeId]);
|
||||
|
||||
const flushDmQueue = useCallback(async () => {
|
||||
const queue = dmSendQueue.current.splice(0);
|
||||
@@ -1138,10 +1150,10 @@ export function useMeshChatController({
|
||||
[meshMessages, mutedUsers],
|
||||
);
|
||||
const meshInboxMessages = useMemo(() => {
|
||||
if (!publicMeshAddress) return [];
|
||||
if (!meshSessionActive || !publicMeshAddress) return [];
|
||||
const target = publicMeshAddress.toLowerCase();
|
||||
return filteredMeshMessages.filter((m) => String(m.to || '').toLowerCase() === target);
|
||||
}, [filteredMeshMessages, publicMeshAddress]);
|
||||
}, [filteredMeshMessages, meshSessionActive, publicMeshAddress]);
|
||||
|
||||
// ─── InfoNet Polling ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -1735,7 +1747,7 @@ export function useMeshChatController({
|
||||
|
||||
// ─── Meshtastic Channel Discovery ──────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!expanded || activeTab !== 'meshtastic') return;
|
||||
if (!expanded || activeTab !== 'meshtastic' || !meshSessionActive) return;
|
||||
let cancelled = false;
|
||||
const fetchChannels = async () => {
|
||||
try {
|
||||
@@ -1794,12 +1806,12 @@ export function useMeshChatController({
|
||||
cancelled = true;
|
||||
clearInterval(iv);
|
||||
};
|
||||
}, [expanded, activeTab, meshRegion]);
|
||||
}, [expanded, activeTab, meshRegion, meshSessionActive]);
|
||||
|
||||
// ─── Meshtastic Polling ──────────────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
if (!expanded || activeTab !== 'meshtastic') return;
|
||||
if (!expanded || activeTab !== 'meshtastic' || !meshSessionActive) return;
|
||||
let cancelled = false;
|
||||
const poll = async () => {
|
||||
try {
|
||||
@@ -1823,7 +1835,13 @@ export function useMeshChatController({
|
||||
cancelled = true;
|
||||
clearInterval(iv);
|
||||
};
|
||||
}, [expanded, activeTab, meshRegion, meshChannel, meshView]);
|
||||
}, [expanded, activeTab, meshRegion, meshChannel, meshView, meshSessionActive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (meshSessionActive) return;
|
||||
setMeshMessages([]);
|
||||
setMeshQuickStatus(null);
|
||||
}, [meshSessionActive]);
|
||||
|
||||
// ─── DM Polling ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -2305,7 +2323,8 @@ export function useMeshChatController({
|
||||
|
||||
const handleSend = async () => {
|
||||
const msg = inputValue.trim();
|
||||
if (!msg || !hasId || busy) return;
|
||||
if (!msg || busy) return;
|
||||
if (activeTab !== 'meshtastic' && !hasId) return;
|
||||
|
||||
const cooldownMs = activeTab === 'dms' ? 0 : 30_000;
|
||||
const now = Date.now();
|
||||
@@ -2392,13 +2411,15 @@ export function useMeshChatController({
|
||||
]);
|
||||
setGateReplyContext(null);
|
||||
} else if (activeTab === 'meshtastic') {
|
||||
if (!publicIdentity || !hasSovereignty()) {
|
||||
if (!meshSessionActive || !publicIdentity || !hasSovereignty()) {
|
||||
setInputValue(msg);
|
||||
setLastSendTime(0);
|
||||
setSendError('public mesh identity needed');
|
||||
setSendError(meshSessionActive ? 'public mesh identity needed' : 'meshchat is off');
|
||||
openIdentityWizard({
|
||||
type: 'err',
|
||||
text: 'Quick fix: create a public mesh identity below, then retry your send.',
|
||||
text: hasStoredPublicLaneIdentity
|
||||
? 'Quick fix: turn MeshChat on below, then retry your send.'
|
||||
: 'Quick fix: create a public mesh identity below, then retry your send.',
|
||||
});
|
||||
setTimeout(() => setSendError(''), 4000);
|
||||
setBusy(false);
|
||||
@@ -3915,7 +3936,7 @@ export function useMeshChatController({
|
||||
wormholeEnabled &&
|
||||
wormholeReadyState &&
|
||||
!selectedGateAccessReady) ||
|
||||
((activeTab === 'infonet' || activeTab === 'meshtastic') && anonymousPublicBlocked) ||
|
||||
(activeTab === 'infonet' && anonymousPublicBlocked) ||
|
||||
(activeTab === 'dms' &&
|
||||
(dmView !== 'chat' ||
|
||||
!selectedContact ||
|
||||
@@ -3959,16 +3980,36 @@ export function useMeshChatController({
|
||||
[inputDisabled],
|
||||
);
|
||||
|
||||
const disableWormholeForPublicMesh = useCallback(async () => {
|
||||
const requireBackendLeave = wormholeEnabled || wormholeReadyState;
|
||||
try {
|
||||
await leaveWormhole();
|
||||
} catch (err) {
|
||||
if (requireBackendLeave) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
setWormholeEnabled(false);
|
||||
setWormholeReadyState(false);
|
||||
setWormholeRnsReady(false);
|
||||
setWormholeRnsDirectReady(false);
|
||||
setWormholeRnsPeers({ active: 0, configured: 0 });
|
||||
setSecureModeCached(false);
|
||||
}, [wormholeEnabled, wormholeReadyState]);
|
||||
|
||||
const createPublicMeshIdentity = useCallback(
|
||||
async ({ closeWizardOnSuccess }: { closeWizardOnSuccess: boolean }) => {
|
||||
setIdentityWizardBusy(true);
|
||||
setIdentityWizardStatus(null);
|
||||
try {
|
||||
await disableWormholeForPublicMesh();
|
||||
const nextIdentity = await generateNodeKeys();
|
||||
const nextAddress = await derivePublicMeshAddress(nextIdentity.nodeId).catch(() => '');
|
||||
const readyAddress = (nextAddress || nextIdentity.nodeId).toUpperCase();
|
||||
setIdentity(nextIdentity);
|
||||
setPublicMeshAddress(nextAddress || nextIdentity.nodeId);
|
||||
setMeshSessionActive(true);
|
||||
setMeshMessages([]);
|
||||
setSendError('');
|
||||
const successText = `Mesh key ready. Address ${readyAddress} is live for this testnet session.`;
|
||||
setIdentityWizardStatus({
|
||||
@@ -3997,7 +4038,7 @@ export function useMeshChatController({
|
||||
setIdentityWizardBusy(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
[disableWormholeForPublicMesh],
|
||||
);
|
||||
|
||||
const handleCreatePublicIdentity = useCallback(async () => {
|
||||
@@ -4013,6 +4054,45 @@ export function useMeshChatController({
|
||||
}
|
||||
}, [createPublicMeshIdentity]);
|
||||
|
||||
const handleActivatePublicMeshSession = useCallback(async () => {
|
||||
setIdentityWizardBusy(true);
|
||||
setIdentityWizardStatus(null);
|
||||
setMeshQuickStatus(null);
|
||||
try {
|
||||
const savedIdentity = getNodeIdentity();
|
||||
if (!savedIdentity || !hasSovereignty()) {
|
||||
const text = 'No saved public mesh key is available. Create a mesh key first.';
|
||||
setMeshSessionActive(false);
|
||||
setIdentityWizardStatus({ type: 'err', text });
|
||||
setMeshQuickStatus({ type: 'err', text });
|
||||
return { ok: false as const, text };
|
||||
}
|
||||
await disableWormholeForPublicMesh();
|
||||
const nextAddress = await derivePublicMeshAddress(savedIdentity.nodeId).catch(() => '');
|
||||
const readyAddress = (nextAddress || savedIdentity.nodeId).toUpperCase();
|
||||
setIdentity(savedIdentity);
|
||||
setPublicMeshAddress(nextAddress || savedIdentity.nodeId);
|
||||
setMeshSessionActive(true);
|
||||
setMeshMessages([]);
|
||||
setSendError('');
|
||||
const text = `MeshChat is on with saved address ${readyAddress}.`;
|
||||
setIdentityWizardStatus({ type: 'ok', text });
|
||||
setMeshQuickStatus({ type: 'ok', text });
|
||||
return { ok: true as const, text };
|
||||
} catch (err) {
|
||||
const message =
|
||||
typeof err === 'object' && err !== null && 'message' in err
|
||||
? String((err as { message?: string }).message)
|
||||
: 'unknown error';
|
||||
const text = `Could not turn MeshChat on: ${message}`;
|
||||
setIdentityWizardStatus({ type: 'err', text });
|
||||
setMeshQuickStatus({ type: 'err', text });
|
||||
return { ok: false as const, text };
|
||||
} finally {
|
||||
setIdentityWizardBusy(false);
|
||||
}
|
||||
}, [disableWormholeForPublicMesh]);
|
||||
|
||||
const handleReplyToMeshAddress = useCallback((address: string) => {
|
||||
const target = String(address || '').trim();
|
||||
if (!target) return;
|
||||
@@ -4023,36 +4103,16 @@ export function useMeshChatController({
|
||||
}, []);
|
||||
|
||||
const handleLeaveWormholeForPublicMesh = useCallback(async () => {
|
||||
setIdentityWizardBusy(true);
|
||||
setIdentityWizardStatus(null);
|
||||
setMeshQuickStatus(null);
|
||||
try {
|
||||
await leaveWormhole();
|
||||
setWormholeEnabled(false);
|
||||
setWormholeReadyState(false);
|
||||
setWormholeRnsReady(false);
|
||||
setWormholeRnsDirectReady(false);
|
||||
setWormholeRnsPeers({ active: 0, configured: 0 });
|
||||
setSecureModeCached(false);
|
||||
const result = await createPublicMeshIdentity({ closeWizardOnSuccess: false });
|
||||
const status = { type: result.ok ? 'ok' as const : 'err' as const, text: result.text };
|
||||
setIdentityWizardStatus(status);
|
||||
setMeshQuickStatus(status);
|
||||
if (result.ok) {
|
||||
window.setTimeout(() => setIdentityWizardOpen(false), 900);
|
||||
}
|
||||
} catch (err) {
|
||||
const message =
|
||||
typeof err === 'object' && err !== null && 'message' in err
|
||||
? String((err as { message?: string }).message)
|
||||
: 'unknown error';
|
||||
const text = `Could not turn Wormhole off for public mesh: ${message}`;
|
||||
setIdentityWizardStatus({ type: 'err', text });
|
||||
setMeshQuickStatus({ type: 'err', text });
|
||||
} finally {
|
||||
setIdentityWizardBusy(false);
|
||||
const result = hasStoredPublicLaneIdentity
|
||||
? await handleActivatePublicMeshSession()
|
||||
: await createPublicMeshIdentity({ closeWizardOnSuccess: false });
|
||||
const status = { type: result.ok ? 'ok' as const : 'err' as const, text: result.text };
|
||||
setIdentityWizardStatus(status);
|
||||
setMeshQuickStatus(status);
|
||||
if (result.ok) {
|
||||
window.setTimeout(() => setIdentityWizardOpen(false), 900);
|
||||
}
|
||||
}, [createPublicMeshIdentity]);
|
||||
}, [createPublicMeshIdentity, handleActivatePublicMeshSession, hasStoredPublicLaneIdentity]);
|
||||
|
||||
const handleResetPublicIdentity = useCallback(async () => {
|
||||
if (wormholeEnabled && wormholeReadyState) {
|
||||
@@ -4065,8 +4125,11 @@ export function useMeshChatController({
|
||||
setIdentityWizardBusy(true);
|
||||
setIdentityWizardStatus(null);
|
||||
try {
|
||||
setMeshSessionActive(false);
|
||||
setMeshMessages([]);
|
||||
await clearBrowserIdentityState();
|
||||
setIdentity(null);
|
||||
setPublicMeshAddress('');
|
||||
setContacts({});
|
||||
setSelectedContact('');
|
||||
setDmMessages([]);
|
||||
@@ -4091,6 +4154,8 @@ export function useMeshChatController({
|
||||
}, [wormholeEnabled, wormholeReadyState]);
|
||||
|
||||
const handleBootstrapPrivateIdentity = useCallback(async () => {
|
||||
setMeshSessionActive(false);
|
||||
setMeshMessages([]);
|
||||
if (wormholeEnabled && wormholeReadyState) {
|
||||
setIdentityWizardStatus({
|
||||
type: 'ok',
|
||||
@@ -4175,6 +4240,7 @@ export function useMeshChatController({
|
||||
identityWizardStatus,
|
||||
setIdentityWizardStatus,
|
||||
meshQuickStatus,
|
||||
meshSessionActive,
|
||||
publicMeshAddress,
|
||||
meshView,
|
||||
setMeshView,
|
||||
@@ -4183,6 +4249,7 @@ export function useMeshChatController({
|
||||
// Identity
|
||||
identity,
|
||||
publicIdentity,
|
||||
hasStoredPublicLaneIdentity,
|
||||
hasPublicLaneIdentity,
|
||||
hasId,
|
||||
shouldShowIdentityWarning,
|
||||
@@ -4320,6 +4387,7 @@ export function useMeshChatController({
|
||||
openChat,
|
||||
handleCreatePublicIdentity,
|
||||
handleQuickCreatePublicIdentity,
|
||||
handleActivatePublicMeshSession,
|
||||
handleLeaveWormholeForPublicMesh,
|
||||
handleResetPublicIdentity,
|
||||
handleBootstrapPrivateIdentity,
|
||||
|
||||
@@ -364,6 +364,7 @@ function summarizeNodePeer(peerUrl?: string): string {
|
||||
}
|
||||
|
||||
function describeBootstrapState(snapshot?: InfonetNodeStatusSnapshot | null): string {
|
||||
if (snapshot && !snapshot.node_enabled) return 'READY / DISABLED';
|
||||
const bootstrap = snapshot?.bootstrap;
|
||||
if (!bootstrap) return 'LOCAL ONLY';
|
||||
if (bootstrap.manifest_loaded) {
|
||||
@@ -376,6 +377,7 @@ function describeBootstrapState(snapshot?: InfonetNodeStatusSnapshot | null): st
|
||||
}
|
||||
|
||||
function describeSyncOutcome(snapshot?: InfonetNodeStatusSnapshot | null): string {
|
||||
if (snapshot && !snapshot.node_enabled) return 'OFF - click NODE to activate';
|
||||
const sync = snapshot?.sync_runtime;
|
||||
if (!sync) return 'IDLE';
|
||||
const outcome = String(sync.last_outcome || 'idle').trim().toLowerCase();
|
||||
@@ -433,6 +435,12 @@ function buildNodeRuntimeLines(snapshot: InfonetNodeStatusSnapshot): TermLine[]
|
||||
type: 'error',
|
||||
});
|
||||
}
|
||||
if (!snapshot.node_enabled) {
|
||||
lines.push({
|
||||
text: ' Activate: click the NODE button in the top-right controls to join the public testnet seed',
|
||||
type: 'dim',
|
||||
});
|
||||
}
|
||||
lines.push({ text: '', type: 'dim' });
|
||||
return lines;
|
||||
}
|
||||
@@ -5945,7 +5953,7 @@ export default function MeshTerminal({ isOpen, launchToken = 0, onClose, onDmCou
|
||||
PARTICIPANT NODE
|
||||
</div>
|
||||
<div className="mt-1 text-sm leading-5 text-slate-400">
|
||||
Automatic bootstrap and sync now live on the backend lane. This node can keep a local chain even with Wormhole off.
|
||||
Backend bootstrap is configured; activate the participant node to sync the public testnet seed without Wormhole.
|
||||
</div>
|
||||
</div>
|
||||
<div className="border border-cyan-500/20 bg-cyan-500/8 px-3 py-1.5 text-[13px] tracking-[0.22em] text-cyan-200">
|
||||
|
||||
@@ -4,7 +4,9 @@ import React, { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, ExternalLink, Key, Shield, Radar, Globe, Satellite, Ship, Radio } from 'lucide-react';
|
||||
|
||||
const STORAGE_KEY = 'shadowbroker_onboarding_complete';
|
||||
const CURRENT_ONBOARDING_VERSION = '0.9.7-docker-keys-1';
|
||||
const STORAGE_KEY = `shadowbroker_onboarding_complete_v${CURRENT_ONBOARDING_VERSION}`;
|
||||
const LEGACY_STORAGE_KEY = 'shadowbroker_onboarding_complete';
|
||||
|
||||
const API_GUIDES = [
|
||||
{
|
||||
@@ -17,7 +19,7 @@ const API_GUIDES = [
|
||||
'Create a free account at opensky-network.org',
|
||||
'Go to Dashboard → OAuth → Create Client',
|
||||
'Copy your Client ID and Client Secret',
|
||||
'Paste both into Settings → Aviation',
|
||||
'Paste both into Quick Local Setup above or Settings → API Keys',
|
||||
],
|
||||
url: 'https://opensky-network.org/index.php?option=com_users&view=registration',
|
||||
color: 'cyan',
|
||||
@@ -31,7 +33,7 @@ const API_GUIDES = [
|
||||
'Register at aisstream.io',
|
||||
'Navigate to your API Keys page',
|
||||
'Generate a new API key',
|
||||
'Paste it into Settings → Maritime',
|
||||
'Paste it into Quick Local Setup above or Settings → API Keys',
|
||||
],
|
||||
url: 'https://aisstream.io/authenticate',
|
||||
color: 'blue',
|
||||
@@ -59,18 +61,59 @@ const OnboardingModal = React.memo(function OnboardingModal({
|
||||
onOpenSettings,
|
||||
}: OnboardingModalProps) {
|
||||
const [step, setStep] = useState(0);
|
||||
const [setupKeys, setSetupKeys] = useState({
|
||||
OPENSKY_CLIENT_ID: '',
|
||||
OPENSKY_CLIENT_SECRET: '',
|
||||
AIS_API_KEY: '',
|
||||
});
|
||||
const [setupSaving, setSetupSaving] = useState(false);
|
||||
const [setupMsg, setSetupMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
|
||||
|
||||
const handleDismiss = () => {
|
||||
localStorage.setItem(STORAGE_KEY, 'true');
|
||||
localStorage.setItem(LEGACY_STORAGE_KEY, 'true');
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleOpenSettings = () => {
|
||||
localStorage.setItem(STORAGE_KEY, 'true');
|
||||
localStorage.setItem(LEGACY_STORAGE_KEY, 'true');
|
||||
onClose();
|
||||
onOpenSettings();
|
||||
};
|
||||
|
||||
const saveSetupKeys = async () => {
|
||||
const payload = Object.fromEntries(
|
||||
Object.entries(setupKeys).filter(([, value]) => value.trim()),
|
||||
);
|
||||
if (!Object.keys(payload).length) {
|
||||
setSetupMsg({ type: 'err', text: 'Enter at least one API key first.' });
|
||||
return;
|
||||
}
|
||||
setSetupSaving(true);
|
||||
setSetupMsg(null);
|
||||
try {
|
||||
const res = await fetch('/api/settings/api-keys', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || data?.ok === false) {
|
||||
throw new Error(data?.detail || 'Could not save API keys.');
|
||||
}
|
||||
setSetupKeys({ OPENSKY_CLIENT_ID: '', OPENSKY_CLIENT_SECRET: '', AIS_API_KEY: '' });
|
||||
setSetupMsg({ type: 'ok', text: 'Keys saved locally. Restart or refresh feeds to use them.' });
|
||||
} catch (error) {
|
||||
setSetupMsg({
|
||||
type: 'err',
|
||||
text: error instanceof Error ? error.message : 'Could not save API keys.',
|
||||
});
|
||||
} finally {
|
||||
setSetupSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{/* Backdrop */}
|
||||
@@ -123,7 +166,7 @@ const OnboardingModal = React.memo(function OnboardingModal({
|
||||
|
||||
{/* Step Indicators */}
|
||||
<div className="flex gap-2 px-6 pt-4">
|
||||
{['Welcome', 'API Keys', 'Free Sources'].map((label, i) => (
|
||||
{['API Keys', 'Trust Modes', 'Free Sources'].map((label, i) => (
|
||||
<button
|
||||
key={label}
|
||||
onClick={() => setStep(i)}
|
||||
@@ -140,36 +183,23 @@ const OnboardingModal = React.memo(function OnboardingModal({
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto styled-scrollbar p-6">
|
||||
{step === 0 && (
|
||||
{step === 1 && (
|
||||
<div className="space-y-4">
|
||||
<div className="text-center py-4">
|
||||
<div className="text-lg font-bold tracking-[0.3em] text-[var(--text-primary)] font-mono mb-2">
|
||||
S H A D O W <span className="text-cyan-400">B R O K E R</span>
|
||||
T R U S T <span className="text-cyan-400">M O D E S</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-[var(--text-secondary)] font-mono leading-relaxed max-w-md mx-auto">
|
||||
<p className="hidden">
|
||||
Real-time OSINT dashboard aggregating 12+ live intelligence sources. Flights,
|
||||
ships, satellites, earthquakes, conflicts, and more — all on one map.
|
||||
</p>
|
||||
<p className="text-[11px] text-[var(--text-secondary)] font-mono leading-relaxed max-w-md mx-auto">
|
||||
These modes explain what lane the network is using. Set up the API keys first,
|
||||
then use this screen to understand public mesh versus private Wormhole paths.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-yellow-950/20 border border-yellow-500/20 p-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<Key size={14} className="text-yellow-500 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-[11px] text-yellow-400 font-mono font-bold mb-1">
|
||||
API Keys Required
|
||||
</p>
|
||||
<p className="text-sm text-[var(--text-secondary)] font-mono leading-relaxed">
|
||||
Two API keys are needed for full functionality:{' '}
|
||||
<span className="text-cyan-400">OpenSky Network</span> (flights) and{' '}
|
||||
<span className="text-blue-400">AIS Stream</span> (ships). Both are free.
|
||||
Without them, some panels will show no data.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-green-950/20 border border-green-500/20 p-4">
|
||||
<div className="hidden">
|
||||
<div className="flex items-start gap-2">
|
||||
<Globe size={14} className="text-green-500 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
@@ -216,8 +246,69 @@ const OnboardingModal = React.memo(function OnboardingModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
{step === 0 && (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-yellow-950/20 border border-yellow-500/20 p-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<Key size={14} className="text-yellow-500 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-[11px] text-yellow-400 font-mono font-bold mb-1">
|
||||
START HERE
|
||||
</p>
|
||||
<p className="text-sm text-[var(--text-secondary)] font-mono leading-relaxed">
|
||||
OpenSky Network and AIS Stream are the free keys that make ShadowBroker
|
||||
useful immediately: live aircraft and vessel tracking. Paste them below or
|
||||
use Settings later; secrets stay on the local backend.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-cyan-900/40 bg-cyan-950/10 p-4 space-y-3">
|
||||
<div>
|
||||
<p className="text-[11px] text-cyan-300 font-mono font-bold tracking-widest">
|
||||
QUICK LOCAL SETUP
|
||||
</p>
|
||||
<p className="text-sm text-[var(--text-secondary)] font-mono leading-relaxed mt-1">
|
||||
Paste keys here once. ShadowBroker stores them server-side only and never
|
||||
displays the secret back in the browser.
|
||||
</p>
|
||||
</div>
|
||||
{[
|
||||
['OPENSKY_CLIENT_ID', 'OpenSky Client ID'],
|
||||
['OPENSKY_CLIENT_SECRET', 'OpenSky Client Secret'],
|
||||
['AIS_API_KEY', 'AIS Stream API Key'],
|
||||
].map(([key, label]) => (
|
||||
<input
|
||||
key={key}
|
||||
type="password"
|
||||
value={setupKeys[key as keyof typeof setupKeys]}
|
||||
onChange={(event) =>
|
||||
setSetupKeys((prev) => ({ ...prev, [key]: event.target.value }))
|
||||
}
|
||||
placeholder={label}
|
||||
className="w-full bg-[var(--bg-primary)] border border-[var(--border-primary)] px-3 py-2 text-sm text-[var(--text-primary)] font-mono outline-none focus:border-cyan-500/70 placeholder:text-[var(--text-muted)]/60"
|
||||
autoComplete="off"
|
||||
/>
|
||||
))}
|
||||
{setupMsg && (
|
||||
<p
|
||||
className={`text-sm font-mono ${
|
||||
setupMsg.type === 'ok' ? 'text-green-300' : 'text-red-300'
|
||||
}`}
|
||||
>
|
||||
{setupMsg.text}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
onClick={() => void saveSetupKeys()}
|
||||
disabled={setupSaving}
|
||||
className="w-full py-2 bg-cyan-500/10 border border-cyan-500/30 text-cyan-400 hover:bg-cyan-500/20 disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-[11px] font-mono tracking-widest"
|
||||
>
|
||||
{setupSaving ? 'SAVING...' : 'SAVE KEYS LOCALLY'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{API_GUIDES.map((api) => (
|
||||
<div
|
||||
key={api.name}
|
||||
@@ -272,7 +363,8 @@ const OnboardingModal = React.memo(function OnboardingModal({
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-[var(--text-secondary)] font-mono mb-3">
|
||||
These data sources are completely free and require no API keys. They activate
|
||||
automatically on launch.
|
||||
automatically on launch, while OpenSky and AIS Stream unlock the richer live
|
||||
aviation and maritime experience.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{FREE_SOURCES.map((src) => (
|
||||
|
||||
@@ -120,6 +120,9 @@ interface EnvMeta {
|
||||
env_path_writable: boolean;
|
||||
env_example_path: string;
|
||||
env_example_path_exists: boolean;
|
||||
operator_keys_env_path?: string;
|
||||
operator_keys_env_path_exists?: boolean;
|
||||
operator_keys_env_path_writable?: boolean;
|
||||
}
|
||||
|
||||
const WEIGHT_LABELS: Record<number, string> = {
|
||||
@@ -493,10 +496,12 @@ const SettingsPanel = React.memo(function SettingsPanel({
|
||||
}, [adminKey, refreshAdminSession]);
|
||||
|
||||
// --- API Keys state ---
|
||||
// API keys are intentionally NOT editable in-app. The panel is read-only and
|
||||
// tells the user where the .env file lives so they can edit it directly.
|
||||
// This keeps secrets off the wire and out of the browser process.
|
||||
// API keys are write-only in-app. Values are sent once to the local backend,
|
||||
// stored server-side, and never returned to the browser.
|
||||
const [apis, setApis] = useState<ApiEntry[]>([]);
|
||||
const [apiKeyInputs, setApiKeyInputs] = useState<Record<string, string>>({});
|
||||
const [apiKeySaving, setApiKeySaving] = useState<string | null>(null);
|
||||
const [apiKeyMsg, setApiKeyMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
|
||||
new Set(['Aviation', 'Maritime']),
|
||||
);
|
||||
@@ -535,7 +540,9 @@ const SettingsPanel = React.memo(function SettingsPanel({
|
||||
|
||||
const fetchKeys = useCallback(async () => {
|
||||
try {
|
||||
setApis(await controlPlaneJson<ApiEntry[]>('/api/settings/api-keys'));
|
||||
setApis(await controlPlaneJson<ApiEntry[]>('/api/settings/api-keys', {
|
||||
requireAdminSession: false,
|
||||
}));
|
||||
return true;
|
||||
} catch (e) {
|
||||
await handleProtectedSettingsError(e);
|
||||
@@ -543,6 +550,40 @@ const SettingsPanel = React.memo(function SettingsPanel({
|
||||
}
|
||||
}, [handleProtectedSettingsError]);
|
||||
|
||||
const saveApiKey = useCallback(
|
||||
async (envKey: string | null) => {
|
||||
if (!envKey) return;
|
||||
const value = String(apiKeyInputs[envKey] || '').trim();
|
||||
if (!value) {
|
||||
setApiKeyMsg({ type: 'err', text: `Enter a value for ${envKey}.` });
|
||||
return;
|
||||
}
|
||||
setApiKeySaving(envKey);
|
||||
setApiKeyMsg(null);
|
||||
try {
|
||||
const result = await controlPlaneJson<{
|
||||
keys?: ApiEntry[];
|
||||
env?: EnvMeta;
|
||||
}>('/api/settings/api-keys', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ [envKey]: value }),
|
||||
requireAdminSession: false,
|
||||
});
|
||||
if (result.keys) setApis(result.keys);
|
||||
if (result.env) setEnvMeta(result.env);
|
||||
setApiKeyInputs((prev) => ({ ...prev, [envKey]: '' }));
|
||||
setApiKeyMsg({ type: 'ok', text: `${envKey} saved locally. Restart or refresh feeds to use it.` });
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : 'Could not save API key';
|
||||
setApiKeyMsg({ type: 'err', text: message });
|
||||
} finally {
|
||||
setApiKeySaving(null);
|
||||
}
|
||||
},
|
||||
[apiKeyInputs],
|
||||
);
|
||||
|
||||
const fetchEnvMeta = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/settings/api-keys/meta');
|
||||
@@ -663,10 +704,10 @@ const SettingsPanel = React.memo(function SettingsPanel({
|
||||
}
|
||||
void (async () => {
|
||||
const ready = await refreshAdminSession();
|
||||
await fetchKeys();
|
||||
if (ready) {
|
||||
await Promise.all([fetchKeys(), fetchFeeds()]);
|
||||
await fetchFeeds();
|
||||
} else {
|
||||
setApis([]);
|
||||
setFeeds([]);
|
||||
setFeedsDirty(false);
|
||||
}
|
||||
@@ -713,12 +754,13 @@ const SettingsPanel = React.memo(function SettingsPanel({
|
||||
}, [onClose, wormholeEnabled, wormholeSaving, wormholeStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !adminSessionReady) return;
|
||||
if (!isOpen) return;
|
||||
if (activeTab === 'api-keys') {
|
||||
void fetchKeys();
|
||||
void fetchEnvMeta();
|
||||
return;
|
||||
}
|
||||
if (!adminSessionReady) return;
|
||||
if (activeTab === 'news-feeds') {
|
||||
void fetchFeeds();
|
||||
}
|
||||
@@ -2166,18 +2208,21 @@ const SettingsPanel = React.memo(function SettingsPanel({
|
||||
<div className="flex items-start gap-2">
|
||||
<Shield size={12} className="text-cyan-500 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-sm 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.
|
||||
API keys are saved locally by this backend. Values are write-only: the app
|
||||
stores the key and shows CONFIGURED, but it never reads the secret back into
|
||||
the browser. Keys marked with{' '}
|
||||
<Key size={8} className="inline text-yellow-500" /> unlock the richest live
|
||||
aircraft and vessel feeds.
|
||||
</p>
|
||||
</div>
|
||||
{envMeta && (
|
||||
<div className="pl-5 text-[12px] font-mono text-[var(--text-muted)] leading-relaxed space-y-0.5">
|
||||
<div>
|
||||
<span className="text-cyan-500/70">.env path:</span>{' '}
|
||||
<span className="text-cyan-300 break-all select-all">{envMeta.env_path}</span>{' '}
|
||||
{envMeta.env_path_exists ? (
|
||||
<span className="text-cyan-500/70">local key store:</span>{' '}
|
||||
<span className="text-cyan-300 break-all select-all">
|
||||
{envMeta.operator_keys_env_path || envMeta.env_path}
|
||||
</span>{' '}
|
||||
{envMeta.operator_keys_env_path_exists || envMeta.env_path_exists ? (
|
||||
<span className="text-green-400/80">[exists]</span>
|
||||
) : (
|
||||
<span className="text-amber-400/80">[will be created on first save]</span>
|
||||
@@ -2199,6 +2244,15 @@ const SettingsPanel = React.memo(function SettingsPanel({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{apiKeyMsg && (
|
||||
<div
|
||||
className={`pl-5 text-sm font-mono ${
|
||||
apiKeyMsg.type === 'ok' ? 'text-green-300' : 'text-red-300'
|
||||
}`}
|
||||
>
|
||||
{apiKeyMsg.text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* API List */}
|
||||
@@ -2288,9 +2342,9 @@ const SettingsPanel = React.memo(function SettingsPanel({
|
||||
{api.description}
|
||||
</p>
|
||||
{api.has_key && (
|
||||
<div className="mt-2 flex items-center gap-2 text-[12px] font-mono">
|
||||
<div className="mt-2 space-y-2 text-[12px] font-mono">
|
||||
{api.is_set ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="px-2 py-0.5 border border-green-500/40 bg-green-950/20 text-green-300 tracking-wider">
|
||||
CONFIGURED
|
||||
</span>
|
||||
@@ -2299,23 +2353,53 @@ const SettingsPanel = React.memo(function SettingsPanel({
|
||||
<span className="text-cyan-300 select-all break-all">
|
||||
{api.env_key}
|
||||
</span>{' '}
|
||||
in the .env file (path shown above) and restart the backend.
|
||||
Enter a replacement below if you need to rotate it.
|
||||
</span>
|
||||
</>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="px-2 py-0.5 border border-amber-500/40 bg-amber-950/20 text-amber-300 tracking-wider">
|
||||
NOT CONFIGURED
|
||||
</span>
|
||||
<span className="text-[var(--text-muted)]">
|
||||
add{' '}
|
||||
<span className="text-amber-200 select-all break-all">
|
||||
{api.env_key}=YOUR_VALUE
|
||||
</span>{' '}
|
||||
to the .env file (path shown above) and restart the backend.
|
||||
Save {api.env_key} here to enable this source.
|
||||
</span>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="password"
|
||||
value={api.env_key ? apiKeyInputs[api.env_key] || '' : ''}
|
||||
onChange={(event) => {
|
||||
if (!api.env_key) return;
|
||||
setApiKeyInputs((prev) => ({
|
||||
...prev,
|
||||
[api.env_key as string]: event.target.value,
|
||||
}));
|
||||
}}
|
||||
placeholder={
|
||||
api.is_set
|
||||
? 'Enter replacement key...'
|
||||
: `Enter ${api.env_key}...`
|
||||
}
|
||||
className="min-w-0 flex-1 bg-[var(--bg-primary)] border border-[var(--border-primary)] px-2 py-1.5 text-sm text-[var(--text-primary)] outline-none focus:border-cyan-500/70 placeholder:text-[var(--text-muted)]/50"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<button
|
||||
onClick={() => void saveApiKey(api.env_key)}
|
||||
disabled={
|
||||
!api.env_key ||
|
||||
apiKeySaving === api.env_key ||
|
||||
!String(
|
||||
api.env_key ? apiKeyInputs[api.env_key] || '' : '',
|
||||
).trim()
|
||||
}
|
||||
className="h-8 px-3 border border-cyan-500/40 bg-cyan-950/20 text-cyan-300 hover:bg-cyan-500/15 disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-1.5 tracking-widest"
|
||||
>
|
||||
<Save size={12} />
|
||||
{apiKeySaving === api.env_key ? 'SAVING' : 'SAVE'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Database, Clock, X } from 'lucide-react';
|
||||
|
||||
const CURRENT_VERSION = '0.9.7';
|
||||
const STORAGE_KEY = `shadowbroker_startup_warmup_notice_v${CURRENT_VERSION}`;
|
||||
|
||||
interface StartupWarmupModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function StartupWarmupModal({ onClose }: StartupWarmupModalProps) {
|
||||
const handleDismiss = () => {
|
||||
localStorage.setItem(STORAGE_KEY, 'true');
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
key="warmup-backdrop"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/80 backdrop-blur-sm z-[10000]"
|
||||
onClick={handleDismiss}
|
||||
/>
|
||||
<motion.div
|
||||
key="warmup-modal"
|
||||
initial={{ opacity: 0, scale: 0.92, y: 18 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.92, y: 18 }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
||||
className="fixed inset-0 z-[10001] flex items-center justify-center pointer-events-none"
|
||||
>
|
||||
<div
|
||||
className="w-[520px] max-w-[calc(100vw-32px)] bg-[var(--bg-secondary)]/98 border border-cyan-900/50 pointer-events-auto overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="p-5 border-b border-[var(--border-primary)]/80 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-cyan-500/10 border border-cyan-500/30 flex items-center justify-center">
|
||||
<Database size={18} className="text-cyan-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-sm font-bold tracking-[0.2em] text-[var(--text-primary)] font-mono">
|
||||
STARTUP CACHE
|
||||
</h2>
|
||||
<span className="text-[13px] text-[var(--text-muted)] font-mono tracking-widest">
|
||||
FIRST RUN WARMUP
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="w-8 h-8 border border-[var(--border-primary)] hover:border-red-500/50 flex items-center justify-center text-[var(--text-muted)] hover:text-red-400 transition-all hover:bg-red-950/20"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="bg-cyan-950/20 border border-cyan-500/20 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Clock size={15} className="text-cyan-400 mt-0.5 flex-shrink-0" />
|
||||
<div className="space-y-2">
|
||||
<p className="text-[11px] text-cyan-300 font-mono font-bold tracking-widest">
|
||||
MASS DATA SYNTHESIS
|
||||
</p>
|
||||
<p className="text-sm text-[var(--text-secondary)] font-mono leading-relaxed">
|
||||
The first launch builds local caches for flights, ships, satellites, CCTV, fires,
|
||||
and threat intelligence. Cached launches paint the map much faster; a brand-new
|
||||
install can take a few minutes while upstream feeds are synthesized.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="w-full py-3 border border-cyan-500/40 text-cyan-300 hover:text-cyan-100 hover:border-cyan-400/70 hover:bg-cyan-950/30 transition-all font-mono text-[12px] tracking-[0.18em] font-bold"
|
||||
>
|
||||
CONTINUE
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
export function useStartupWarmupNotice() {
|
||||
const [showWarmupNotice, setShowWarmupNotice] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
setShowWarmupNotice(localStorage.getItem(STORAGE_KEY) !== 'true');
|
||||
} catch {
|
||||
setShowWarmupNotice(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { showWarmupNotice, setShowWarmupNotice };
|
||||
}
|
||||
@@ -34,7 +34,7 @@ export function useImperativeSource(
|
||||
};
|
||||
|
||||
const pushWhenReady = () => {
|
||||
let attemptsRemaining = 20;
|
||||
let attemptsRemaining = 150;
|
||||
|
||||
const tryPush = () => {
|
||||
if (cancelled) return;
|
||||
@@ -62,6 +62,7 @@ export function useImperativeSource(
|
||||
pushWhenReady();
|
||||
};
|
||||
|
||||
rawMap.on('load', handleStyleData);
|
||||
rawMap.on('styledata', handleStyleData);
|
||||
|
||||
// Skip redundant writes for unchanged references, but keep the styledata
|
||||
@@ -73,6 +74,7 @@ export function useImperativeSource(
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
rawMap.off('load', handleStyleData);
|
||||
rawMap.off('styledata', handleStyleData);
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
|
||||
|
||||
@@ -35,6 +35,7 @@ interface AgentAction {
|
||||
export function useAgentActions(
|
||||
onShowImage: (coords: { lat: number; lng: number }) => void,
|
||||
onFlyTo?: (coords: { lat: number; lng: number; zoom?: number }) => void,
|
||||
enabled = true,
|
||||
) {
|
||||
const onShowImageRef = useRef(onShowImage);
|
||||
onShowImageRef.current = onShowImage;
|
||||
@@ -70,9 +71,10 @@ export function useAgentActions(
|
||||
|
||||
useEffect(() => {
|
||||
// Poll every 3 seconds — lightweight endpoint, ~50 bytes when empty
|
||||
if (!enabled) return;
|
||||
const interval = setInterval(poll, 3000);
|
||||
// Initial poll on mount
|
||||
poll();
|
||||
return () => clearInterval(interval);
|
||||
}, [poll]);
|
||||
}, [enabled, poll]);
|
||||
}
|
||||
|
||||
@@ -59,6 +59,8 @@ type FastDataProbe = {
|
||||
ships?: unknown[];
|
||||
sigint?: unknown[];
|
||||
cctv?: unknown[];
|
||||
news?: unknown[];
|
||||
threat_level?: unknown;
|
||||
};
|
||||
|
||||
function hasMeaningfulFastData(json: FastDataProbe): boolean {
|
||||
@@ -100,11 +102,37 @@ export function useDataPolling() {
|
||||
_slowEtagRef = slowEtag;
|
||||
|
||||
let hasData = false;
|
||||
let fetchedStartupFastPayload = false;
|
||||
let fastTimerId: ReturnType<typeof setTimeout> | null = null;
|
||||
let slowTimerId: ReturnType<typeof setTimeout> | null = null;
|
||||
const fastAbortRef = { current: null as AbortController | null };
|
||||
const slowAbortRef = { current: null as AbortController | null };
|
||||
|
||||
const fetchCriticalBootstrap = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/bootstrap/critical`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (res.ok) {
|
||||
setStoreBackendStatus('connected');
|
||||
const json = await res.json();
|
||||
mergeData(json);
|
||||
if (hasMeaningfulFastData(json) || (json.news?.length || 0) > 0 || json.threat_level) {
|
||||
hasData = true;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
const aborted =
|
||||
typeof e === 'object' &&
|
||||
e !== null &&
|
||||
'name' in e &&
|
||||
(e as { name?: string }).name === 'AbortError';
|
||||
if (!aborted) {
|
||||
console.warn("Critical bootstrap fetch will retry via live polling", e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const fetchFastData = async () => {
|
||||
if (fastTimerId) {
|
||||
clearTimeout(fastTimerId);
|
||||
@@ -116,9 +144,11 @@ export function useDataPolling() {
|
||||
const controller = new AbortController();
|
||||
fastAbortRef.current = controller;
|
||||
try {
|
||||
const useStartupPayload = !fetchedStartupFastPayload && !fastEtag.current;
|
||||
const headers: Record<string, string> = {};
|
||||
if (fastEtag.current) headers['If-None-Match'] = fastEtag.current;
|
||||
const res = await fetch(`${API_BASE}/api/live-data/fast`, {
|
||||
if (!useStartupPayload && fastEtag.current) headers['If-None-Match'] = fastEtag.current;
|
||||
const url = `${API_BASE}/api/live-data/fast${useStartupPayload ? '?initial=1' : ''}`;
|
||||
const res = await fetch(url, {
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
@@ -129,7 +159,10 @@ export function useDataPolling() {
|
||||
}
|
||||
if (res.ok) {
|
||||
setStoreBackendStatus('connected');
|
||||
fastEtag.current = res.headers.get('etag') || null;
|
||||
// Do not keep the capped startup ETag. The next steady poll should
|
||||
// request the full fast dataset and replace the representative first paint.
|
||||
fastEtag.current = useStartupPayload ? null : res.headers.get('etag') || null;
|
||||
if (useStartupPayload) fetchedStartupFastPayload = true;
|
||||
const json = await res.json();
|
||||
mergeData(json);
|
||||
if (hasMeaningfulFastData(json)) hasData = true;
|
||||
@@ -141,7 +174,7 @@ export function useDataPolling() {
|
||||
'name' in e &&
|
||||
(e as { name?: string }).name === 'AbortError';
|
||||
if (!aborted) {
|
||||
console.error("Failed fetching fast live data", e);
|
||||
console.warn("Fast live data fetch will retry after runtime is reachable", e);
|
||||
setStoreBackendStatus('disconnected');
|
||||
}
|
||||
} finally {
|
||||
@@ -177,7 +210,7 @@ export function useDataPolling() {
|
||||
'name' in e &&
|
||||
(e as { name?: string }).name === 'AbortError';
|
||||
if (!aborted) {
|
||||
console.error("Failed fetching slow live data", e);
|
||||
console.warn("Slow live data fetch will retry after runtime is reachable", e);
|
||||
}
|
||||
} finally {
|
||||
if (slowAbortRef.current === controller) {
|
||||
@@ -191,7 +224,8 @@ export function useDataPolling() {
|
||||
const scheduleNext = (tier: 'fast' | 'slow') => {
|
||||
if (tier === 'fast') {
|
||||
const delay = hasData ? 15000 : 3000; // 3s startup retry → 15s steady state
|
||||
fastTimerId = setTimeout(fetchFastData, delay);
|
||||
const needsFullFastPayload = fetchedStartupFastPayload && !fastEtag.current;
|
||||
fastTimerId = setTimeout(fetchFastData, needsFullFastPayload ? 750 : delay);
|
||||
} else {
|
||||
const delay = hasData ? 120000 : 5000; // 5s startup retry → 120s steady state
|
||||
slowTimerId = setTimeout(fetchSlowData, delay);
|
||||
@@ -208,8 +242,12 @@ export function useDataPolling() {
|
||||
};
|
||||
window.addEventListener(LAYER_TOGGLE_EVENT, onLayerToggle);
|
||||
|
||||
fetchFastData();
|
||||
fetchSlowData();
|
||||
void (async () => {
|
||||
await fetchCriticalBootstrap();
|
||||
fetchFastData();
|
||||
// Let the bootstrap/fast payload paint before competing with the slow tier.
|
||||
slowTimerId = setTimeout(fetchSlowData, 5000);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(LAYER_TOGGLE_EVENT, onLayerToggle);
|
||||
|
||||
@@ -387,7 +387,7 @@ export async function refreshSnapshotList(): Promise<void> {
|
||||
const json = await res.json();
|
||||
updateTimelineFromSnapshots(sortSnapshots(json.snapshots || []));
|
||||
} catch (e) {
|
||||
console.error('Time Machine: failed to fetch snapshots', e);
|
||||
console.warn('Time Machine snapshots will retry after runtime is reachable', e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -402,7 +402,7 @@ export async function refreshHourlyIndex(): Promise<void> {
|
||||
setState({ hourlyIndex: json.hours || {} });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Time Machine: failed to fetch hourly index', e);
|
||||
console.warn('Time Machine hourly index will retry after runtime is reachable', e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -635,6 +635,8 @@ export interface NewsArticle {
|
||||
kalshi_pct: number | null;
|
||||
consensus_pct: number | null;
|
||||
match_score: number;
|
||||
slug?: string;
|
||||
kalshi_ticker?: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
@@ -824,6 +826,8 @@ export interface DashboardData {
|
||||
cctv_total?: number;
|
||||
satnogs_total?: number;
|
||||
tinygs_total?: number;
|
||||
bootstrap_ready?: boolean;
|
||||
bootstrap_payload?: boolean;
|
||||
sigint_totals?: {
|
||||
total?: number;
|
||||
meshtastic?: number;
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
TRUE_VALUES = {"1", "true", "yes", "on", "allow", "enabled"}
|
||||
PIN_KEY = "PRIVACY_CORE_ALLOWED_SHA256"
|
||||
PRIVATE_LANE_KEYS = ("MESH_ARTI_ENABLED", "MESH_RNS_ENABLED")
|
||||
|
||||
|
||||
def _repo_root() -> Path:
|
||||
return Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _privacy_core_library(root: Path) -> Path | None:
|
||||
release_dir = root / "privacy-core" / "target" / "release"
|
||||
candidates = (
|
||||
release_dir / "privacy_core.dll",
|
||||
release_dir / "libprivacy_core.so",
|
||||
release_dir / "libprivacy_core.dylib",
|
||||
)
|
||||
for candidate in candidates:
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _parse_env(lines: list[str]) -> dict[str, str]:
|
||||
values: dict[str, str] = {}
|
||||
for line in lines:
|
||||
match = re.match(r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$", line)
|
||||
if not match:
|
||||
continue
|
||||
key, raw_value = match.groups()
|
||||
values[key] = raw_value.strip().strip('"').strip("'")
|
||||
return values
|
||||
|
||||
|
||||
def _private_lane_enabled(values: dict[str, str]) -> bool:
|
||||
for key in PRIVATE_LANE_KEYS:
|
||||
value = values.get(key, "")
|
||||
if value.strip().lower() in TRUE_VALUES:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _replace_or_append_pin(lines: list[str], digest: str) -> tuple[list[str], bool]:
|
||||
updated: list[str] = []
|
||||
replaced = False
|
||||
pattern = re.compile(rf"^(\s*{re.escape(PIN_KEY)}\s*=).*$")
|
||||
for line in lines:
|
||||
if pattern.match(line):
|
||||
updated.append(f"{PIN_KEY}={digest}")
|
||||
replaced = True
|
||||
else:
|
||||
updated.append(line)
|
||||
if not replaced:
|
||||
if updated and updated[-1].strip():
|
||||
updated.append("")
|
||||
updated.append(f"{PIN_KEY}={digest}")
|
||||
return updated, replaced
|
||||
|
||||
|
||||
def main() -> int:
|
||||
root = _repo_root()
|
||||
env_path = root / "backend" / ".env"
|
||||
if not env_path.is_file():
|
||||
print("[*] privacy-core trust pin refresh skipped: backend/.env not found.")
|
||||
return 0
|
||||
|
||||
library_path = _privacy_core_library(root)
|
||||
if library_path is None:
|
||||
print("[*] privacy-core trust pin refresh skipped: shared library not found.")
|
||||
return 0
|
||||
|
||||
text = env_path.read_text(encoding="utf-8-sig")
|
||||
lines = text.splitlines()
|
||||
values = _parse_env(lines)
|
||||
has_pin = PIN_KEY in values
|
||||
if not has_pin and not _private_lane_enabled(values):
|
||||
print("[*] privacy-core trust pin refresh skipped: private-lane mode is not enabled.")
|
||||
return 0
|
||||
|
||||
digest = hashlib.sha256(library_path.read_bytes()).hexdigest()
|
||||
if values.get(PIN_KEY, "").strip().lower() == digest:
|
||||
print("[*] privacy-core trust pin already current.")
|
||||
return 0
|
||||
|
||||
updated, replaced = _replace_or_append_pin(lines, digest)
|
||||
newline = "\r\n" if "\r\n" in text else "\n"
|
||||
env_path.write_text(newline.join(updated) + newline, encoding="utf-8")
|
||||
action = "refreshed" if replaced else "enrolled"
|
||||
print(f"[*] privacy-core trust pin {action} for local shared library.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,31 @@
|
||||
param(
|
||||
[string]$Root = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$Host.UI.RawUI.WindowTitle = "ShadowBroker Runtime"
|
||||
|
||||
Set-Location -LiteralPath $Root
|
||||
|
||||
Write-Host "==================================================="
|
||||
Write-Host " ShadowBroker runtime"
|
||||
Write-Host " Dashboard: http://localhost:3000"
|
||||
Write-Host " Close this window or press Ctrl+C to stop."
|
||||
Write-Host "==================================================="
|
||||
Write-Host ""
|
||||
|
||||
try {
|
||||
& node "frontend\scripts\dev-all.cjs"
|
||||
$exitCode = $LASTEXITCODE
|
||||
} catch {
|
||||
Write-Host ""
|
||||
Write-Host "[!] Runtime failed: $($_.Exception.Message)"
|
||||
$exitCode = 1
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "==================================================="
|
||||
Write-Host " ShadowBroker has stopped. Exit code: $exitCode"
|
||||
Write-Host "==================================================="
|
||||
Read-Host "Press Enter to close"
|
||||
exit $exitCode
|
||||
@@ -84,6 +84,9 @@ for /f "tokens=5" %%a in ('netstat -ano ^| findstr ":8000 "') do (
|
||||
for /f "tokens=5" %%a in ('netstat -ano ^| findstr ":3000 "') do (
|
||||
taskkill /F /PID %%a >nul 2>&1
|
||||
)
|
||||
for /f "tokens=5" %%a in ('netstat -ano ^| findstr ":8787 "') do (
|
||||
taskkill /F /PID %%a >nul 2>&1
|
||||
)
|
||||
|
||||
:: Brief pause to let OS release the ports
|
||||
timeout /t 1 /nobreak >nul
|
||||
@@ -99,6 +102,11 @@ if %errorlevel% equ 0 (
|
||||
echo [!] WARNING: Port 3000 is still occupied! Waiting 3s for OS cleanup...
|
||||
timeout /t 3 /nobreak >nul
|
||||
)
|
||||
netstat -ano | findstr ":8787 " | findstr "LISTENING" >nul 2>&1
|
||||
if %errorlevel% equ 0 (
|
||||
echo [!] WARNING: Port 8787 is still occupied! Waiting 3s for OS cleanup...
|
||||
timeout /t 3 /nobreak >nul
|
||||
)
|
||||
|
||||
echo [*] Ports clear.
|
||||
:: ────────────────────────────────────────────────────────────────────
|
||||
@@ -225,6 +233,40 @@ if not exist "node_modules\ws" (
|
||||
call npm ci --omit=dev --silent
|
||||
)
|
||||
echo [*] Backend Node.js dependencies OK.
|
||||
|
||||
echo.
|
||||
echo [*] Checking privacy-core shared library...
|
||||
set "PRIVACY_CORE_DLL=%ROOT%\privacy-core\target\release\privacy_core.dll"
|
||||
if not exist "%PRIVACY_CORE_DLL%" (
|
||||
where cargo >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo [!] WARNING: privacy-core DLL is missing and Rust/Cargo is not installed.
|
||||
echo [!] Infonet private lanes and gates need this library.
|
||||
echo [!] Install Rust from https://rustup.rs/ and run:
|
||||
echo [!] cargo build --release --manifest-path "%ROOT%\privacy-core\Cargo.toml"
|
||||
echo.
|
||||
) else (
|
||||
echo [*] Building privacy-core release DLL...
|
||||
cd /d "%ROOT%"
|
||||
cargo build --release --manifest-path "%ROOT%\privacy-core\Cargo.toml"
|
||||
if errorlevel 1 (
|
||||
echo [!] ERROR: privacy-core build failed. Infonet private lanes need this DLL.
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
cd /d "%ROOT%\backend"
|
||||
)
|
||||
)
|
||||
if exist "%PRIVACY_CORE_DLL%" (
|
||||
echo [*] privacy-core DLL OK.
|
||||
"%VENV_PY%" "%ROOT%\scripts\refresh_privacy_core_pin.py"
|
||||
if errorlevel 1 (
|
||||
echo [!] WARNING: privacy-core trust pin refresh failed. Startup may fail if backend\.env pins an old hash.
|
||||
echo.
|
||||
)
|
||||
)
|
||||
|
||||
cd /d "%ROOT%"
|
||||
|
||||
echo.
|
||||
@@ -257,7 +299,8 @@ echo ===================================================
|
||||
echo (Press Ctrl+C to stop)
|
||||
echo.
|
||||
|
||||
call npm run dev
|
||||
start "ShadowBroker Runtime" powershell.exe -NoProfile -ExecutionPolicy Bypass -NoExit -File "%ROOT%\scripts\run-windows-runtime.ps1" -Root "%ROOT%"
|
||||
exit /b 0
|
||||
|
||||
echo.
|
||||
echo ===================================================
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Graceful shutdown: kill all child processes on exit/interrupt
|
||||
trap 'kill 0' EXIT SIGINT SIGTERM
|
||||
# Graceful shutdown: stop child processes without signaling the parent shell.
|
||||
cleanup() {
|
||||
trap - EXIT SIGINT SIGTERM
|
||||
if command -v pkill >/dev/null 2>&1; then
|
||||
pkill -P $$ 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT SIGINT SIGTERM
|
||||
|
||||
echo "======================================================="
|
||||
echo " S H A D O W B R O K E R - macOS / Linux Start "
|
||||
@@ -64,7 +70,7 @@ echo ""
|
||||
echo "[*] Clearing zombie processes..."
|
||||
|
||||
# Kill anything listening on ports 8000 or 3000
|
||||
for PORT in 8000 3000; do
|
||||
for PORT in 8000 3000 8787; do
|
||||
if command -v lsof &> /dev/null; then
|
||||
PIDS=$(lsof -ti :$PORT 2>/dev/null)
|
||||
elif command -v ss &> /dev/null; then
|
||||
@@ -82,6 +88,7 @@ done
|
||||
# Kill orphaned uvicorn and ais_proxy processes
|
||||
pkill -9 -f "uvicorn.*main:app" 2>/dev/null
|
||||
pkill -9 -f "ais_proxy" 2>/dev/null
|
||||
pkill -9 -f "wormhole_server.py" 2>/dev/null
|
||||
|
||||
# Brief pause for OS to release ports
|
||||
sleep 1
|
||||
@@ -192,6 +199,34 @@ if [ ! -d "node_modules/ws" ]; then
|
||||
fi
|
||||
echo "[*] Backend Node.js dependencies OK."
|
||||
|
||||
echo ""
|
||||
echo "[*] Checking privacy-core shared library..."
|
||||
PRIVACY_CORE_SO="$SCRIPT_DIR/privacy-core/target/release/libprivacy_core.so"
|
||||
PRIVACY_CORE_DYLIB="$SCRIPT_DIR/privacy-core/target/release/libprivacy_core.dylib"
|
||||
if [ ! -f "$PRIVACY_CORE_SO" ] && [ ! -f "$PRIVACY_CORE_DYLIB" ]; then
|
||||
if command -v cargo >/dev/null 2>&1; then
|
||||
echo "[*] Building privacy-core release library..."
|
||||
cargo build --release --manifest-path "$SCRIPT_DIR/privacy-core/Cargo.toml"
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "[!] ERROR: privacy-core build failed. Infonet private lanes need this library."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "[!] WARNING: privacy-core shared library is missing and Rust/Cargo is not installed."
|
||||
echo "[!] Infonet private lanes and gates need this library."
|
||||
echo "[!] Install Rust from https://rustup.rs/ and run:"
|
||||
echo "[!] cargo build --release --manifest-path \"$SCRIPT_DIR/privacy-core/Cargo.toml\""
|
||||
echo ""
|
||||
fi
|
||||
fi
|
||||
if [ -f "$PRIVACY_CORE_SO" ] || [ -f "$PRIVACY_CORE_DYLIB" ]; then
|
||||
echo "[*] privacy-core shared library OK."
|
||||
"$VENV_PY" "$SCRIPT_DIR/scripts/refresh_privacy_core_pin.py" || {
|
||||
echo "[!] WARNING: privacy-core trust pin refresh failed. Startup may fail if backend/.env pins an old hash."
|
||||
echo ""
|
||||
}
|
||||
fi
|
||||
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
echo ""
|
||||
@@ -223,4 +258,4 @@ echo "======================================================="
|
||||
echo " (Press Ctrl+C to stop)"
|
||||
echo ""
|
||||
|
||||
npm run dev
|
||||
node scripts/dev-all.cjs
|
||||
|
||||
Reference in New Issue
Block a user