mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-06-10 08:13:58 +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/
|
||||||
.venv/
|
.venv/
|
||||||
.ruff_cache/
|
.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 build caches (source is needed, artifacts are not)
|
||||||
privacy-core/target/
|
privacy-core/target/
|
||||||
@@ -21,3 +33,24 @@ privacy-core/.codex-tmp/
|
|||||||
*.log
|
*.log
|
||||||
extra/
|
extra/
|
||||||
prototype/
|
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.
|
# Leave blank to skip this optional enrichment.
|
||||||
# NUFORC_MAPBOX_TOKEN=
|
# 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).
|
# Google Earth Engine for VIIRS night lights change detection (optional).
|
||||||
# pip install earthengine-api
|
# pip install earthengine-api
|
||||||
# GEE_SERVICE_ACCOUNT_KEY=
|
# 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.
|
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
|
## 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.
|
* **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
|
* **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.
|
* **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
|
* **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
|
* **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
|
* **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 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.
|
* **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
|
* **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
|
* **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 |
|
| 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 |
|
| [@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 |
|
| [@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 |
|
| [@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 source code
|
||||||
COPY backend/ .
|
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)
|
# Install Node.js dependencies (ws module for AIS WebSocket proxy)
|
||||||
COPY backend/package*.json ./
|
COPY backend/package*.json ./
|
||||||
RUN npm ci --omit=dev
|
RUN npm ci --omit=dev
|
||||||
@@ -75,4 +82,5 @@ USER backenduser
|
|||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
# Start FastAPI server
|
# Start FastAPI server
|
||||||
|
ENTRYPOINT ["/app/docker-entrypoint.sh"]
|
||||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--timeout-keep-alive", "120"]
|
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 asyncio
|
||||||
import hmac as _hmac_mod
|
import hmac as _hmac_mod
|
||||||
import hashlib as _hashlib_mod
|
import hashlib as _hashlib_mod
|
||||||
|
import ipaddress
|
||||||
import json as json_mod
|
import json as json_mod
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
@@ -235,10 +236,36 @@ def _is_local_or_docker(host: str) -> bool:
|
|||||||
return host in {"127.0.0.1", "::1", "localhost"}
|
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):
|
def require_local_operator(request: Request):
|
||||||
"""Allow local tooling on loopback / Docker internal network, or a valid admin key."""
|
"""Allow local tooling on loopback / Docker internal network, or a valid admin key."""
|
||||||
host = (request.client.host or "").lower() if request.client else ""
|
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
|
return
|
||||||
admin_key = _current_admin_key()
|
admin_key = _current_admin_key()
|
||||||
presented = str(request.headers.get("X-Admin-Key", "") or "").strip()
|
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 ""
|
host = (request.client.host or "").lower() if request.client else ""
|
||||||
|
|
||||||
# 1. Local loopback — always allowed
|
# 1. Local runtime path — loopback, plus bundled Docker bridge when compose opts in
|
||||||
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
|
return
|
||||||
|
|
||||||
# 2. Admin key — full trust
|
# 2. Admin key — full trust
|
||||||
@@ -402,7 +429,9 @@ async def require_openclaw_or_local(request: Request):
|
|||||||
# Startup validators
|
# Startup validators
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
_KNOWN_COMPROMISED_PEER_PUSH_SECRET = "Mv63UvLfwqOEVWeRBXjA8MtFl2nEkkhUlLYVHiX1Zzo"
|
_KNOWN_COMPROMISED_PEER_PUSH_SECRET_SHA256 = (
|
||||||
|
"be05bc75350d6e5d2e154e969c4dfc14bab1e48a9661c64ab7a331e0aa96aea7"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _validate_admin_startup() -> None:
|
def _validate_admin_startup() -> None:
|
||||||
@@ -492,7 +521,11 @@ def _validate_peer_push_secret() -> None:
|
|||||||
secret = os.environ.get("MESH_PEER_PUSH_SECRET", "").strip()
|
secret = os.environ.get("MESH_PEER_PUSH_SECRET", "").strip()
|
||||||
|
|
||||||
# Replace the known-compromised testnet default automatically
|
# 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(
|
logger.warning(
|
||||||
"MESH_PEER_PUSH_SECRET was the publicly-known testnet default — "
|
"MESH_PEER_PUSH_SECRET was the publicly-known testnet default — "
|
||||||
"auto-generating a secure replacement."
|
"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,
|
start_scheduler,
|
||||||
stop_scheduler,
|
stop_scheduler,
|
||||||
get_latest_data,
|
get_latest_data,
|
||||||
|
seed_startup_caches,
|
||||||
)
|
)
|
||||||
from services.ais_stream import start_ais_stream, stop_ais_stream
|
from services.ais_stream import start_ais_stream, stop_ais_stream
|
||||||
from services.carrier_tracker import start_carrier_tracker, stop_carrier_tracker
|
from services.carrier_tracker import start_carrier_tracker, stop_carrier_tracker
|
||||||
@@ -2174,19 +2175,26 @@ async def lifespan(app: FastAPI):
|
|||||||
# Only the primary backend supervises Wormhole. The Wormhole process itself
|
# Only the primary backend supervises Wormhole. The Wormhole process itself
|
||||||
# runs this same app in MESH_ONLY mode and must not recurse into spawning.
|
# runs this same app in MESH_ONLY mode and must not recurse into spawning.
|
||||||
if not _MESH_ONLY:
|
if not _MESH_ONLY:
|
||||||
try:
|
def _startup_wormhole_runtime():
|
||||||
from services.wormhole_supervisor import get_wormhole_state, sync_wormhole_with_settings
|
try:
|
||||||
|
from services.wormhole_supervisor import get_wormhole_state, sync_wormhole_with_settings
|
||||||
|
|
||||||
sync_wormhole_with_settings()
|
sync_wormhole_with_settings()
|
||||||
_resume_private_delivery_background_work(
|
_resume_private_delivery_background_work(
|
||||||
current_tier=_current_private_lane_tier(get_wormhole_state()),
|
current_tier=_current_private_lane_tier(get_wormhole_state()),
|
||||||
reason="startup_resume",
|
reason="startup_resume",
|
||||||
)
|
)
|
||||||
_refresh_lookup_handle_rotation_background(reason="startup_resume")
|
_refresh_lookup_handle_rotation_background(reason="startup_resume")
|
||||||
privacy_prewarm_service.ensure_started()
|
privacy_prewarm_service.ensure_started()
|
||||||
privacy_prewarm_service.run_scheduled_once(reason="startup_resume")
|
privacy_prewarm_service.run_scheduled_once(reason="startup_resume")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Wormhole supervisor failed to sync: {e}")
|
logger.warning(f"Wormhole supervisor failed to sync: {e}")
|
||||||
|
|
||||||
|
threading.Thread(
|
||||||
|
target=_startup_wormhole_runtime,
|
||||||
|
daemon=True,
|
||||||
|
name="wormhole-startup-sync",
|
||||||
|
).start()
|
||||||
try:
|
try:
|
||||||
from services.mesh.mesh_hashchain import register_public_event_append_hook
|
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()
|
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 the recurring scheduler (fast=60s, slow=30min).
|
||||||
start_scheduler()
|
start_scheduler()
|
||||||
|
|
||||||
@@ -2239,6 +2252,9 @@ async def lifespan(app: FastAPI):
|
|||||||
# is listening on port 8000 instantly. The frontend's adaptive polling
|
# is listening on port 8000 instantly. The frontend's adaptive polling
|
||||||
# (retries every 3s) will pick up data piecemeal as each fetcher finishes.
|
# (retries every 3s) will pick up data piecemeal as each fetcher finishes.
|
||||||
def _background_preload():
|
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) ===")
|
logger.info("=== PRELOADING DATA (background — server already accepting requests) ===")
|
||||||
try:
|
try:
|
||||||
update_all_data(startup_mode=True)
|
update_all_data(startup_mode=True)
|
||||||
@@ -3472,6 +3488,46 @@ def _sigint_totals_for_items(items: list) -> dict[str, int]:
|
|||||||
return totals
|
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")
|
@app.get("/api/live-data/fast")
|
||||||
@limiter.limit("120/minute")
|
@limiter.limit("120/minute")
|
||||||
async def live_data_fast(
|
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),
|
w: float = Query(None, description="West bound (ignored)", ge=-180, le=180),
|
||||||
n: float = Query(None, description="North bound (ignored)", ge=-90, le=90),
|
n: float = Query(None, description="North bound (ignored)", ge=-90, le=90),
|
||||||
e: float = Query(None, description="East bound (ignored)", ge=-180, le=180),
|
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:
|
if request.headers.get("if-none-match") == etag:
|
||||||
return Response(status_code=304, headers={"ETag": etag, "Cache-Control": "no-cache"})
|
return Response(status_code=304, headers={"ETag": etag, "Cache-Control": "no-cache"})
|
||||||
|
|
||||||
@@ -3548,6 +3605,10 @@ async def live_data_fast(
|
|||||||
"trains": (d.get("trains") or []) if active_layers.get("trains", True) else [],
|
"trains": (d.get("trains") or []) if active_layers.get("trains", True) else [],
|
||||||
"freshness": freshness,
|
"freshness": freshness,
|
||||||
}
|
}
|
||||||
|
if initial:
|
||||||
|
payload = _cap_fast_startup_payload(payload)
|
||||||
|
else:
|
||||||
|
payload = _cap_fast_dashboard_payload(payload)
|
||||||
return Response(
|
return Response(
|
||||||
content=orjson.dumps(_sanitize_payload(payload)),
|
content=orjson.dumps(_sanitize_payload(payload)),
|
||||||
media_type="application/json",
|
media_type="application/json",
|
||||||
@@ -3609,6 +3670,7 @@ async def live_data_slow(
|
|||||||
"correlations",
|
"correlations",
|
||||||
"threat_level",
|
"threat_level",
|
||||||
"trending_markets",
|
"trending_markets",
|
||||||
|
"fimi",
|
||||||
"uap_sightings",
|
"uap_sightings",
|
||||||
"wastewater",
|
"wastewater",
|
||||||
"sar_scenes",
|
"sar_scenes",
|
||||||
@@ -3621,6 +3683,7 @@ async def live_data_slow(
|
|||||||
"last_updated": d.get("last_updated"),
|
"last_updated": d.get("last_updated"),
|
||||||
"threat_level": d.get("threat_level"),
|
"threat_level": d.get("threat_level"),
|
||||||
"trending_markets": d.get("trending_markets", []),
|
"trending_markets": d.get("trending_markets", []),
|
||||||
|
"fimi": d.get("fimi", {}),
|
||||||
"news": d.get("news", []),
|
"news": d.get("news", []),
|
||||||
"stocks": d.get("stocks", {}),
|
"stocks": d.get("stocks", {}),
|
||||||
"financial_source": d.get("financial_source", ""),
|
"financial_source": d.get("financial_source", ""),
|
||||||
@@ -7604,6 +7667,13 @@ _CCTV_PROXY_ALLOWED_HOSTS = {
|
|||||||
"infocar.dgt.es", # Spain DGT
|
"infocar.dgt.es", # Spain DGT
|
||||||
"informo.madrid.es", # Madrid
|
"informo.madrid.es", # Madrid
|
||||||
"www.windy.com",
|
"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,
|
cache_seconds=30,
|
||||||
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8"},
|
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"}:
|
if host in {"mdotjboss.state.mi.us", "micamerasimages.net"}:
|
||||||
return _CCTVProxyProfile(
|
return _CCTVProxyProfile(
|
||||||
name="michigan-dot",
|
name="michigan-dot",
|
||||||
@@ -7791,11 +7871,27 @@ def _cctv_proxy_profile_for_url(target_url: str) -> _CCTVProxyProfile:
|
|||||||
"Referer": "https://informo.madrid.es/",
|
"Referer": "https://informo.madrid.es/",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if host == "www.windy.com":
|
if host in {"www.windy.com", "imgproxy.windy.com"}:
|
||||||
return _CCTVProxyProfile(
|
return _CCTVProxyProfile(
|
||||||
name="windy-webcams",
|
name="windy-webcams",
|
||||||
timeout=(5.0, 12.0),
|
timeout=(5.0, 12.0),
|
||||||
cache_seconds=60,
|
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"},
|
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8"},
|
||||||
)
|
)
|
||||||
return _CCTVProxyProfile(
|
return _CCTVProxyProfile(
|
||||||
@@ -7839,6 +7935,30 @@ def _cctv_response_headers(resp, cache_seconds: int, include_length: bool = True
|
|||||||
return headers
|
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):
|
def _fetch_cctv_upstream_response(request: Request, target_url: str, profile: _CCTVProxyProfile):
|
||||||
import requests as _req
|
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)
|
profile = _cctv_proxy_profile_for_url(target_url)
|
||||||
resp = _fetch_cctv_upstream_response(request, target_url, profile)
|
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 = (
|
is_hls_playlist = (
|
||||||
".m3u8" in str(parsed.path or "").lower()
|
".m3u8" in str(parsed.path or "").lower()
|
||||||
or "mpegurl" in content_type.lower()
|
or "mpegurl" in content_type.lower()
|
||||||
|
|||||||
@@ -18,15 +18,15 @@ dependencies = [
|
|||||||
"fastapi==0.115.12",
|
"fastapi==0.115.12",
|
||||||
"feedparser==6.0.10",
|
"feedparser==6.0.10",
|
||||||
"httpx==0.28.1",
|
"httpx==0.28.1",
|
||||||
"playwright==1.50.0",
|
"playwright==1.59.0",
|
||||||
"playwright-stealth==1.0.6",
|
"playwright-stealth==1.0.6",
|
||||||
"pydantic==2.11.1",
|
"pydantic==2.13.3",
|
||||||
"pydantic-settings==2.8.1",
|
"pydantic-settings==2.8.1",
|
||||||
"pystac-client==0.8.6",
|
"pystac-client==0.8.6",
|
||||||
"python-dotenv==1.2.2",
|
"python-dotenv==1.2.2",
|
||||||
"requests==2.31.0",
|
"requests==2.31.0",
|
||||||
"reverse-geocoder==1.5.1",
|
"reverse-geocoder==1.5.1",
|
||||||
"sgp4==2.23",
|
"sgp4==2.25",
|
||||||
"meshtastic>=2.5.0",
|
"meshtastic>=2.5.0",
|
||||||
"orjson>=3.10.0",
|
"orjson>=3.10.0",
|
||||||
"paho-mqtt>=1.6.0,<2.0.0",
|
"paho-mqtt>=1.6.0,<2.0.0",
|
||||||
@@ -34,7 +34,7 @@ dependencies = [
|
|||||||
"slowapi==0.1.9",
|
"slowapi==0.1.9",
|
||||||
"vaderSentiment>=3.3.0",
|
"vaderSentiment>=3.3.0",
|
||||||
"uvicorn==0.34.0",
|
"uvicorn==0.34.0",
|
||||||
"yfinance==0.2.54",
|
"yfinance==1.3.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
|
|||||||
@@ -28,13 +28,34 @@ class TimeMachineToggle(BaseModel):
|
|||||||
enabled: bool
|
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")
|
@limiter.limit("30/minute")
|
||||||
async def api_get_keys(request: Request):
|
async def api_get_keys(request: Request):
|
||||||
from services.api_settings import get_api_keys
|
from services.api_settings import get_api_keys
|
||||||
return 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")
|
@router.get("/api/settings/api-keys/meta")
|
||||||
@limiter.limit("30/minute")
|
@limiter.limit("30/minute")
|
||||||
async def api_get_keys_meta(request: Request):
|
async def api_get_keys_meta(request: Request):
|
||||||
|
|||||||
+67
-22
@@ -11,6 +11,8 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
_CCTV_PROXY_CONNECT_TIMEOUT_S = 2.0
|
||||||
|
|
||||||
_CCTV_PROXY_ALLOWED_HOSTS = {
|
_CCTV_PROXY_ALLOWED_HOSTS = {
|
||||||
"s3-eu-west-1.amazonaws.com",
|
"s3-eu-west-1.amazonaws.com",
|
||||||
"jamcams.tfl.gov.uk",
|
"jamcams.tfl.gov.uk",
|
||||||
@@ -46,13 +48,20 @@ _CCTV_PROXY_ALLOWED_HOSTS = {
|
|||||||
"infocar.dgt.es",
|
"infocar.dgt.es",
|
||||||
"informo.madrid.es",
|
"informo.madrid.es",
|
||||||
"www.windy.com",
|
"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)
|
@dataclass(frozen=True)
|
||||||
class _CCTVProxyProfile:
|
class _CCTVProxyProfile:
|
||||||
name: str
|
name: str
|
||||||
timeout: tuple = (5.0, 10.0)
|
timeout: tuple = (_CCTV_PROXY_CONNECT_TIMEOUT_S, 8.0)
|
||||||
cache_seconds: int = 30
|
cache_seconds: int = 30
|
||||||
headers: dict = field(default_factory=dict)
|
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()
|
path = str(parsed.path or "").strip().lower()
|
||||||
|
|
||||||
if host in {"jamcams.tfl.gov.uk", "s3-eu-west-1.amazonaws.com"}:
|
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/"})
|
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":
|
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"})
|
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8"})
|
||||||
if host == "cctv.austinmobility.io":
|
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",
|
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
||||||
"Referer": "https://data.mobility.austin.gov/", "Origin": "https://data.mobility.austin.gov"})
|
"Referer": "https://data.mobility.austin.gov/", "Origin": "https://data.mobility.austin.gov"})
|
||||||
if host == "webcams.nyctmc.org":
|
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"})
|
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8"})
|
||||||
if host in {"cwwp2.dot.ca.gov", "wzmedia.dot.ca.gov"}:
|
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",
|
headers={"Accept": "application/vnd.apple.mpegurl,application/x-mpegURL,video/*,image/*,*/*;q=0.8",
|
||||||
"Referer": "https://cwwp2.dot.ca.gov/"})
|
"Referer": "https://cwwp2.dot.ca.gov/"})
|
||||||
if host in {"images.wsdot.wa.gov", "olypen.com", "flyykm.com", "cam.pangbornairport.com"}:
|
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"})
|
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"}:
|
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
|
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",
|
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
||||||
"Referer": "http://navigator-c2c.dot.ga.gov/"})
|
"Referer": "http://navigator-c2c.dot.ga.gov/"})
|
||||||
if host == "511ga.org":
|
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",
|
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
||||||
"Referer": "https://511ga.org/cctv"})
|
"Referer": "https://511ga.org/cctv"})
|
||||||
if host.startswith("vss") and host.endswith("dot.ga.gov"):
|
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",
|
headers={"Accept": "application/vnd.apple.mpegurl,application/x-mpegURL,video/*,*/*;q=0.8",
|
||||||
"Referer": "http://navigator-c2c.dot.ga.gov/"})
|
"Referer": "http://navigator-c2c.dot.ga.gov/"})
|
||||||
if host in {"gettingaroundillinois.com", "cctv.travelmidwest.com"}:
|
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"})
|
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8"})
|
||||||
if host in {"mdotjboss.state.mi.us", "micamerasimages.net"}:
|
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",
|
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
||||||
"Referer": "https://mdotjboss.state.mi.us/"})
|
"Referer": "https://mdotjboss.state.mi.us/"})
|
||||||
if host in {"publicstreamer1.cotrip.org", "publicstreamer2.cotrip.org",
|
if host in {"publicstreamer1.cotrip.org", "publicstreamer2.cotrip.org",
|
||||||
"publicstreamer3.cotrip.org", "publicstreamer4.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",
|
headers={"Accept": "application/vnd.apple.mpegurl,application/x-mpegURL,video/*,*/*;q=0.8",
|
||||||
"Referer": "https://www.cotrip.org/"})
|
"Referer": "https://www.cotrip.org/"})
|
||||||
if host == "cocam.carsprogram.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",
|
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
||||||
"Referer": "https://www.cotrip.org/"})
|
"Referer": "https://www.cotrip.org/"})
|
||||||
if host in {"tripcheck.com", "www.tripcheck.com"}:
|
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"})
|
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8"})
|
||||||
if host == "infocar.dgt.es":
|
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",
|
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
||||||
"Referer": "https://infocar.dgt.es/"})
|
"Referer": "https://infocar.dgt.es/"})
|
||||||
if host == "informo.madrid.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",
|
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
||||||
"Referer": "https://informo.madrid.es/"})
|
"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,
|
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"})
|
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,
|
"Referer": "https://www.windy.com/"})
|
||||||
|
return _CCTVProxyProfile(name="generic-cctv", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 8.0), cache_seconds=30,
|
||||||
headers={"Accept": "*/*"})
|
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 "")
|
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):
|
def _proxy_cctv_media_response(request: Request, target_url: str):
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
from fastapi.responses import Response
|
from fastapi.responses import Response
|
||||||
parsed = urlparse(target_url)
|
parsed = urlparse(target_url)
|
||||||
profile = _cctv_proxy_profile_for_url(target_url)
|
profile = _cctv_proxy_profile_for_url(target_url)
|
||||||
resp = _fetch_cctv_upstream_response(request, target_url, profile)
|
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 = (
|
is_hls_playlist = (
|
||||||
".m3u8" in str(parsed.path or "").lower()
|
".m3u8" in str(parsed.path or "").lower()
|
||||||
or "mpegurl" in content_type.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)
|
return lat_span, max(0.0, lng_span)
|
||||||
|
|
||||||
|
|
||||||
def _downsample_points(items: list, max_items: int) -> list:
|
def _cap_startup_items(items: list | None, max_items: int) -> list:
|
||||||
if max_items <= 0 or len(items) <= max_items:
|
if not items:
|
||||||
|
return []
|
||||||
|
if len(items) <= max_items:
|
||||||
return items
|
return items
|
||||||
step = len(items) / float(max_items)
|
return items[:max_items]
|
||||||
return [items[min(len(items) - 1, int(i * step))] for i in range(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:
|
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()
|
sigint_grid.mesh.stop()
|
||||||
logger.info("Meshtastic MQTT bridge stopped (layer disabled)")
|
logger.info("Meshtastic MQTT bridge stopped (layer disabled)")
|
||||||
elif not old_mesh and new_mesh:
|
elif not old_mesh and new_mesh:
|
||||||
sigint_grid.mesh.start()
|
try:
|
||||||
logger.info("Meshtastic MQTT bridge started (layer enabled)")
|
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:
|
if old_aprs and not new_aprs:
|
||||||
sigint_grid.aprs.stop()
|
sigint_grid.aprs.stop()
|
||||||
logger.info("APRS bridge stopped (layer disabled)")
|
logger.info("APRS bridge stopped (layer disabled)")
|
||||||
@@ -326,6 +355,104 @@ async def live_data(request: Request):
|
|||||||
return get_latest_data()
|
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")
|
@router.get("/api/live-data/fast")
|
||||||
@limiter.limit("120/minute")
|
@limiter.limit("120/minute")
|
||||||
async def live_data_fast(
|
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),
|
w: float = Query(None, description="West bound (ignored)", ge=-180, le=180),
|
||||||
n: float = Query(None, description="North bound (ignored)", ge=-90, le=90),
|
n: float = Query(None, description="North bound (ignored)", ge=-90, le=90),
|
||||||
e: float = Query(None, description="East bound (ignored)", ge=-180, le=180),
|
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:
|
if request.headers.get("if-none-match") == etag:
|
||||||
return Response(status_code=304, headers={"ETag": etag, "Cache-Control": "no-cache"})
|
return Response(status_code=304, headers={"ETag": etag, "Cache-Control": "no-cache"})
|
||||||
from services.fetchers._store import (active_layers, get_latest_data_subset_refs, get_source_timestamps_snapshot)
|
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 [],
|
"trains": (d.get("trains") or []) if active_layers.get("trains", True) else [],
|
||||||
"freshness": freshness,
|
"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",
|
return Response(content=orjson.dumps(_sanitize_payload(payload)), media_type="application/json",
|
||||||
headers={"ETag": etag, "Cache-Control": "no-cache"})
|
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", "")
|
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
|
# AIS vessel type code classification
|
||||||
# See: https://coast.noaa.gov/data/marinecadastre/ais/VesselTypeCodes2018.pdf
|
# See: https://coast.noaa.gov/data/marinecadastre/ais/VesselTypeCodes2018.pdf
|
||||||
def classify_vessel(ais_type: int, mmsi: int) -> str:
|
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...")
|
logger.info("Starting Node.js AIS Stream Proxy...")
|
||||||
proxy_env = os.environ.copy()
|
proxy_env = os.environ.copy()
|
||||||
proxy_env["AIS_API_KEY"] = API_KEY
|
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(
|
process = subprocess.Popen(
|
||||||
["node", proxy_script],
|
["node", proxy_script],
|
||||||
stdin=subprocess.PIPE,
|
stdin=subprocess.PIPE,
|
||||||
@@ -504,6 +522,7 @@ def _ais_stream_loop():
|
|||||||
text=True,
|
text=True,
|
||||||
bufsize=1,
|
bufsize=1,
|
||||||
env=proxy_env,
|
env=proxy_env,
|
||||||
|
**popen_kwargs,
|
||||||
)
|
)
|
||||||
with _vessels_lock:
|
with _vessels_lock:
|
||||||
_proxy_process = process
|
_proxy_process = process
|
||||||
@@ -646,6 +665,22 @@ def _run_ais_loop():
|
|||||||
def start_ais_stream():
|
def start_ais_stream():
|
||||||
"""Start the AIS WebSocket stream in a background thread."""
|
"""Start the AIS WebSocket stream in a background thread."""
|
||||||
global _ws_thread, _ws_running
|
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:
|
with _vessels_lock:
|
||||||
if _ws_running:
|
if _ws_running:
|
||||||
logger.info("AIS Stream already running")
|
logger.info("AIS Stream already running")
|
||||||
@@ -656,9 +691,6 @@ def start_ais_stream():
|
|||||||
logger.info("AIS Stream already running")
|
logger.info("AIS Stream already running")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Load cached vessel data from disk
|
|
||||||
_load_cache()
|
|
||||||
|
|
||||||
_ws_thread = threading.Thread(target=_run_ais_loop, daemon=True, name="ais-stream")
|
_ws_thread = threading.Thread(target=_run_ais_loop, daemon=True, name="ais-stream")
|
||||||
_ws_thread.start()
|
_ws_thread.start()
|
||||||
logger.info("AIS Stream background thread started")
|
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 os
|
||||||
|
import re
|
||||||
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Path to the backend .env file
|
# Path to the backend .env file
|
||||||
ENV_PATH = Path(__file__).parent.parent / ".env"
|
ENV_PATH = Path(__file__).parent.parent / ".env"
|
||||||
# Path to the example template that ships with the repo
|
# Path to the example template that ships with the repo
|
||||||
ENV_EXAMPLE_PATH = Path(__file__).parent.parent.parent / ".env.example"
|
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
|
# 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:
|
def get_env_path_info() -> dict:
|
||||||
"""Return absolute paths for the backend .env and .env.example template.
|
"""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)),
|
and (not env_path.exists() or os.access(env_path, os.W_OK)),
|
||||||
"env_example_path": str(example_path),
|
"env_example_path": str(example_path),
|
||||||
"env_example_path_exists": example_path.exists(),
|
"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
|
`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.
|
info from `get_env_path_info()` to tell them where to put each key.
|
||||||
"""
|
"""
|
||||||
|
load_persisted_api_keys_into_environ()
|
||||||
result = []
|
result = []
|
||||||
for api in API_REGISTRY:
|
for api in API_REGISTRY:
|
||||||
entry = {
|
entry = {
|
||||||
@@ -189,3 +282,57 @@ def get_api_keys():
|
|||||||
entry["is_set"] = bool(raw)
|
entry["is_set"] = bool(raw)
|
||||||
result.append(entry)
|
result.append(entry)
|
||||||
return result
|
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_ALLOW_RAW_SECURE_STORAGE_FALLBACK: bool = False
|
||||||
MESH_ACK_RAW_FALLBACK_AT_OWN_RISK: bool = False
|
MESH_ACK_RAW_FALLBACK_AT_OWN_RISK: bool = False
|
||||||
MESH_SECURE_STORAGE_SECRET: str = ""
|
MESH_SECURE_STORAGE_SECRET: str = ""
|
||||||
|
MESH_SECURE_STORAGE_SECRET_FILE: str = ""
|
||||||
MESH_PRIVATE_LOG_TTL_S: int = 900
|
MESH_PRIVATE_LOG_TTL_S: int = 900
|
||||||
# Sprint 1 rollout: restored DM boot probes stay disabled by default until
|
# Sprint 1 rollout: restored DM boot probes stay disabled by default until
|
||||||
# the architect reviews false positives from the observe-only path.
|
# the architect reviews false positives from the observe-only path.
|
||||||
@@ -302,6 +303,11 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
@lru_cache
|
@lru_cache
|
||||||
def get_settings() -> Settings:
|
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()
|
return Settings()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import concurrent.futures
|
|||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
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
|
# 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.
|
# 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"))
|
_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_PATH = Path(__file__).resolve().parents[1] / "data" / "fast_startup_cache.json"
|
||||||
_FAST_STARTUP_CACHE_KEYS = (
|
_FAST_STARTUP_CACHE_KEYS = (
|
||||||
"commercial_flights",
|
"commercial_flights",
|
||||||
@@ -123,6 +124,21 @@ _FAST_STARTUP_CACHE_KEYS = (
|
|||||||
"sigint_totals",
|
"sigint_totals",
|
||||||
"trains",
|
"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 thread pool — reused across all fetch cycles instead of creating/destroying per tick
|
||||||
_SHARED_EXECUTOR = concurrent.futures.ThreadPoolExecutor(
|
_SHARED_EXECUTOR = concurrent.futures.ThreadPoolExecutor(
|
||||||
@@ -204,6 +220,77 @@ def _save_fast_startup_cache() -> None:
|
|||||||
logger.debug("Fast startup cache save skipped: %s", e)
|
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
|
# Scheduler & Orchestration
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -262,7 +349,6 @@ def update_fast_data():
|
|||||||
fetch_satellites,
|
fetch_satellites,
|
||||||
fetch_sigint,
|
fetch_sigint,
|
||||||
fetch_trains,
|
fetch_trains,
|
||||||
fetch_tinygs,
|
|
||||||
]
|
]
|
||||||
_run_tasks("fast-tier", fast_funcs)
|
_run_tasks("fast-tier", fast_funcs)
|
||||||
with _data_lock:
|
with _data_lock:
|
||||||
@@ -289,6 +375,7 @@ def update_slow_data():
|
|||||||
fetch_cctv,
|
fetch_cctv,
|
||||||
fetch_kiwisdr,
|
fetch_kiwisdr,
|
||||||
fetch_satnogs,
|
fetch_satnogs,
|
||||||
|
fetch_tinygs,
|
||||||
fetch_frontlines,
|
fetch_frontlines,
|
||||||
fetch_datacenters,
|
fetch_datacenters,
|
||||||
fetch_military_bases,
|
fetch_military_bases,
|
||||||
@@ -313,9 +400,76 @@ def update_slow_data():
|
|||||||
logger.error("Correlation engine failed: %s", e)
|
logger.error("Correlation engine failed: %s", e)
|
||||||
from services.fetchers._store import bump_data_version
|
from services.fetchers._store import bump_data_version
|
||||||
bump_data_version()
|
bump_data_version()
|
||||||
|
_save_intel_startup_cache()
|
||||||
logger.info("Slow-tier update complete.")
|
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):
|
def update_all_data(*, startup_mode: bool = False):
|
||||||
"""Full refresh.
|
"""Full refresh.
|
||||||
|
|
||||||
@@ -324,10 +478,51 @@ def update_all_data(*, startup_mode: bool = False):
|
|||||||
"""
|
"""
|
||||||
logger.info("Full data update starting (parallel)...")
|
logger.info("Full data update starting (parallel)...")
|
||||||
# Preload Meshtastic map cache immediately (instant, from disk)
|
# Preload Meshtastic map cache immediately (instant, from disk)
|
||||||
load_meshtastic_cache_if_available()
|
seed_startup_caches()
|
||||||
_load_fast_startup_cache_if_available()
|
|
||||||
with _data_lock:
|
with _data_lock:
|
||||||
meshtastic_seeded = bool(latest_data.get("meshtastic_map_nodes"))
|
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 = {
|
futures = {
|
||||||
_SHARED_EXECUTOR.submit(fetch_airports): ("fetch_airports", time.perf_counter()),
|
_SHARED_EXECUTOR.submit(fetch_airports): ("fetch_airports", time.perf_counter()),
|
||||||
_SHARED_EXECUTOR.submit(update_fast_data): ("update_fast_data", 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_unusual_whales): ("fetch_unusual_whales", time.perf_counter()),
|
||||||
_SHARED_EXECUTOR.submit(fetch_fimi): ("fetch_fimi", 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(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_uap_sightings): ("fetch_uap_sightings", time.perf_counter()),
|
||||||
_SHARED_EXECUTOR.submit(fetch_wastewater): ("fetch_wastewater", time.perf_counter()),
|
_SHARED_EXECUTOR.submit(fetch_wastewater): ("fetch_wastewater", time.perf_counter()),
|
||||||
_SHARED_EXECUTOR.submit(fetch_crowdthreat): ("fetch_crowdthreat", 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(
|
logger.info(
|
||||||
"Startup preload: Meshtastic cache already loaded, deferring remote map refresh to scheduled cadence"
|
"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():
|
for future, (name, start) in futures.items():
|
||||||
try:
|
try:
|
||||||
future.result(timeout=_TASK_HARD_TIMEOUT_S)
|
future.result(timeout=_TASK_HARD_TIMEOUT_S)
|
||||||
@@ -408,7 +609,7 @@ def update_all_data(*, startup_mode: bool = False):
|
|||||||
|
|
||||||
|
|
||||||
_scheduler = None
|
_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
|
_FINANCIAL_REFRESH_MINUTES = 30
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import time
|
|||||||
import heapq
|
import heapq
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
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._store import latest_data, _data_lock, _mark_fresh
|
||||||
from services.fetchers.nuforc_enrichment import enrich_sighting
|
from services.fetchers.nuforc_enrichment import enrich_sighting
|
||||||
from services.fetchers.retry import with_retry
|
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_RADIUS_M = 200_000 # 200 km query radius
|
||||||
_NUFORC_LIMIT = 50 # max features per tilequery call
|
_NUFORC_LIMIT = 50 # max features per tilequery call
|
||||||
_NUFORC_RECENT_DAYS = int(os.environ.get("NUFORC_RECENT_DAYS", "60"))
|
_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")))
|
_NUFORC_GEOCODE_WORKERS = max(1, int(os.environ.get("NUFORC_GEOCODE_WORKERS", "1")))
|
||||||
# Photon (Komoot) is more lenient than Nominatim — ~200ms per query in
|
# 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
|
# 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)
|
index_url = _NUFORC_LIVE_INDEX_URL.format(yyyymm=yyyymm)
|
||||||
ajax_url = _NUFORC_LIVE_AJAX_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.
|
# Step 1: GET the month index to capture session cookies + fresh nonce.
|
||||||
try:
|
try:
|
||||||
index_res = subprocess.run(
|
index_res = subprocess.run(
|
||||||
@@ -1340,6 +1350,143 @@ def _build_recent_uap_sightings() -> list[dict]:
|
|||||||
return sightings
|
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)
|
@with_retry(max_retries=1, base_delay=5)
|
||||||
def fetch_uap_sightings(*, force_refresh: bool = False):
|
def fetch_uap_sightings(*, force_refresh: bool = False):
|
||||||
"""Fetch last-year UAP sightings from NUFORC.
|
"""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)
|
sightings = _load_nuforc_sightings_cache(force_refresh=force_refresh)
|
||||||
if sightings is None:
|
if sightings is None:
|
||||||
sightings = _build_recent_uap_sightings()
|
try:
|
||||||
_save_nuforc_sightings_cache(sightings)
|
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:
|
with _data_lock:
|
||||||
latest_data["uap_sightings"] = sightings
|
latest_data["uap_sightings"] = sightings or []
|
||||||
_mark_fresh("uap_sightings")
|
if sightings:
|
||||||
|
_mark_fresh("uap_sightings")
|
||||||
return
|
return
|
||||||
|
|
||||||
cutoff = datetime.utcnow() - timedelta(days=_NUFORC_RECENT_DAYS)
|
cutoff = datetime.utcnow() - timedelta(days=_NUFORC_RECENT_DAYS)
|
||||||
|
|||||||
@@ -15,6 +15,24 @@ from services.fetchers.retry import with_retry
|
|||||||
logger = logging.getLogger(__name__)
|
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)
|
# Ships (AIS + Carriers)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -191,6 +209,12 @@ def update_liveuamap():
|
|||||||
|
|
||||||
if not is_any_active("global_incidents"):
|
if not is_any_active("global_incidents"):
|
||||||
return
|
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...")
|
logger.info("Running scheduled Liveuamap scraper...")
|
||||||
try:
|
try:
|
||||||
from services.liveuamap_scraper import fetch_liveuamap
|
from services.liveuamap_scraper import fetch_liveuamap
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ Analysis features (derived from cached TLEs — no extra network requests):
|
|||||||
import math
|
import math
|
||||||
import time
|
import time
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
import logging
|
import logging
|
||||||
import requests
|
import requests
|
||||||
@@ -41,6 +42,38 @@ def _gmst(jd_ut1):
|
|||||||
# CelesTrak fair use: fetch at most once per 24 hours (86400s).
|
# CelesTrak fair use: fetch at most once per 24 hours (86400s).
|
||||||
# SGP4 propagation runs every 60s using cached TLEs — positions stay live.
|
# SGP4 propagation runs every 60s using cached TLEs — positions stay live.
|
||||||
_CELESTRAK_FETCH_INTERVAL = 86400 # 24 hours
|
_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_gp_cache = {"data": None, "last_fetch": 0, "source": "none", "last_modified": None}
|
||||||
_sat_classified_cache = {"data": None, "gp_fetch_ts": 0}
|
_sat_classified_cache = {"data": None, "gp_fetch_ts": 0}
|
||||||
_SAT_CACHE_PATH = Path(__file__).parent.parent.parent / "data" / "sat_gp_cache.json"
|
_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
|
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():
|
def _fetch_satellites_from_tle_api():
|
||||||
"""Fallback: fetch satellite TLEs from tle.ivanstanojevic.me when CelesTrak is blocked."""
|
"""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:
|
for key, _ in _SAT_INTEL_DB:
|
||||||
term = key.split()[0] if len(key.split()) > 1 and key.split()[0] in ("USA", "NROL") else key
|
term = key.split()[0] if len(key.split()) > 1 and key.split()[0] in ("USA", "NROL") else key
|
||||||
search_terms.add(term)
|
search_terms.add(term)
|
||||||
@@ -591,8 +676,13 @@ def _fetch_satellites_from_tle_api():
|
|||||||
sat_id = gp.get("NORAD_CAT_ID")
|
sat_id = gp.get("NORAD_CAT_ID")
|
||||||
if sat_id not in seen_ids:
|
if sat_id not in seen_ids:
|
||||||
seen_ids.add(sat_id)
|
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)
|
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 (
|
except (
|
||||||
requests.RequestException,
|
requests.RequestException,
|
||||||
ConnectionError,
|
ConnectionError,
|
||||||
@@ -644,18 +734,34 @@ def fetch_satellites():
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
_sat_gp_cache["data"] is None
|
_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
|
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)
|
# Build conditional request headers (CelesTrak fair use)
|
||||||
headers = {}
|
headers = {}
|
||||||
if _sat_gp_cache.get("last_modified"):
|
if _sat_gp_cache.get("last_modified"):
|
||||||
headers["If-Modified-Since"] = _sat_gp_cache["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:
|
for url in gp_urls:
|
||||||
|
if len(_sat_gp_cache.get("data") or []) >= _MIN_VISIBLE_SATELLITE_CATALOG:
|
||||||
|
break
|
||||||
try:
|
try:
|
||||||
response = fetch_with_curl(url, timeout=15, headers=headers)
|
response = fetch_with_curl(url, timeout=15, headers=headers)
|
||||||
if response.status_code == 304:
|
if response.status_code == 304:
|
||||||
@@ -696,7 +802,10 @@ def fetch_satellites():
|
|||||||
logger.warning(f"Satellites: Failed to fetch from {url}: {e}")
|
logger.warning(f"Satellites: Failed to fetch from {url}: {e}")
|
||||||
continue
|
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...")
|
logger.info("Satellites: CelesTrak unreachable, trying TLE fallback API...")
|
||||||
try:
|
try:
|
||||||
fallback_data = _fetch_satellites_from_tle_api()
|
fallback_data = _fetch_satellites_from_tle_api()
|
||||||
@@ -757,6 +866,9 @@ def fetch_satellites():
|
|||||||
owner = sat.get("OWNER", sat.get("OBJECT_OWNER", ""))
|
owner = sat.get("OWNER", sat.get("OBJECT_OWNER", ""))
|
||||||
if owner in _OWNER_CODE_MAP:
|
if owner in _OWNER_CODE_MAP:
|
||||||
intel = {"country": _OWNER_CODE_MAP[owner], "mission": "general", "sat_type": "Unclassified"}
|
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:
|
if not intel:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|||||||
@@ -230,11 +230,16 @@ def _raw_fallback_allowed() -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _generated_secret_file() -> Path:
|
||||||
|
return DATA_DIR / "secure_storage_secret.key"
|
||||||
|
|
||||||
|
|
||||||
def _get_storage_secret() -> str | None:
|
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()
|
secret = os.environ.get("MESH_SECURE_STORAGE_SECRET", "").strip()
|
||||||
if secret:
|
if secret:
|
||||||
return secret
|
return secret
|
||||||
|
secret_file_override = os.environ.get("MESH_SECURE_STORAGE_SECRET_FILE", "").strip()
|
||||||
try:
|
try:
|
||||||
from services.config import get_settings
|
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()
|
secret = str(getattr(settings, "MESH_SECURE_STORAGE_SECRET", "") or "").strip()
|
||||||
if secret:
|
if secret:
|
||||||
return secret
|
return secret
|
||||||
|
secret_file_override = (
|
||||||
|
secret_file_override
|
||||||
|
or str(getattr(settings, "MESH_SECURE_STORAGE_SECRET_FILE", "") or "").strip()
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import shutil
|
import shutil
|
||||||
import time
|
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
|
# Find bash for curl fallback — Git bash's curl has the TLS features
|
||||||
# needed to pass CDN fingerprint checks (brotli, zstd, libpsl)
|
# 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
|
# Cache domains where requests fails — skip straight to curl for 5 minutes
|
||||||
_domain_fail_cache: dict[str, float] = {}
|
_domain_fail_cache: dict[str, float] = {}
|
||||||
@@ -39,6 +39,17 @@ class UpstreamCircuitBreakerError(OSError):
|
|||||||
"""Raised when a domain recently failed hard and is temporarily skipped."""
|
"""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:
|
class _DummyResponse:
|
||||||
"""Minimal response object matching requests.Response interface."""
|
"""Minimal response object matching requests.Response interface."""
|
||||||
def __init__(self, status_code, text):
|
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)
|
_circuit_breaker.pop(domain, None)
|
||||||
return res
|
return res
|
||||||
except (requests.RequestException, ConnectionError, TimeoutError, OSError) as e:
|
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:
|
with _cb_lock:
|
||||||
_domain_fail_cache[domain] = time.time()
|
_domain_fail_cache[domain] = time.time()
|
||||||
|
|
||||||
# Curl fallback — reached from both _skip_requests and requests-exception paths
|
# 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"
|
_CURL_PATH = shutil.which("curl") or "curl"
|
||||||
cmd = [_CURL_PATH, "-s", "-w", "\n%{http_code}"]
|
cmd = [_CURL_PATH, "-s", "-w", "\n%{http_code}"]
|
||||||
if follow_redirects:
|
if follow_redirects:
|
||||||
@@ -116,9 +138,16 @@ def fetch_with_curl(url, method="GET", json_data=None, timeout=15, headers=None,
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
stdin_data = json.dumps(json_data) if (method == "POST" and json_data) else None
|
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(
|
res = subprocess.run(
|
||||||
cmd, capture_output=True, text=True, timeout=timeout + 5,
|
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():
|
if res.returncode == 0 and (res.stdout or "").strip():
|
||||||
# Parse HTTP status code from -w output (last line)
|
# 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
|
_circuit_breaker.pop(domain, None) # Clear circuit breaker on success
|
||||||
return _DummyResponse(http_code, body)
|
return _DummyResponse(http_code, body)
|
||||||
else:
|
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:
|
with _cb_lock:
|
||||||
_circuit_breaker[domain] = time.time()
|
_circuit_breaker[domain] = time.time()
|
||||||
return _DummyResponse(500, "")
|
return _DummyResponse(500, "")
|
||||||
except (subprocess.SubprocessError, ConnectionError, TimeoutError, OSError) as curl_e:
|
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:
|
with _cb_lock:
|
||||||
_circuit_breaker[domain] = time.time()
|
_circuit_breaker[domain] = time.time()
|
||||||
return _DummyResponse(500, "")
|
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"),
|
"kalshi_pct": best_match.get("kalshi_pct"),
|
||||||
"consensus_pct": best_match.get("consensus_pct"),
|
"consensus_pct": best_match.get("consensus_pct"),
|
||||||
"match_score": round(best_score, 2),
|
"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:
|
def _pid_alive(pid: int) -> bool:
|
||||||
if pid <= 0:
|
if pid <= 0:
|
||||||
return False
|
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:
|
try:
|
||||||
os.kill(pid, 0)
|
os.kill(pid, 0)
|
||||||
except OSError:
|
except OSError:
|
||||||
@@ -268,7 +273,12 @@ def _current_runtime_state() -> dict[str, Any]:
|
|||||||
pid = int(_PROCESS.pid or 0)
|
pid = int(_PROCESS.pid or 0)
|
||||||
elif _pid_alive(pid):
|
elif _pid_alive(pid):
|
||||||
running = True
|
running = True
|
||||||
|
elif _probe_ready(timeout_s=0.35):
|
||||||
|
running = True
|
||||||
|
pid = 0
|
||||||
ready = running and _probe_ready()
|
ready = running and _probe_ready()
|
||||||
|
if not running:
|
||||||
|
pid = 0
|
||||||
transport_active = status.get("transport_active", "") if ready else ""
|
transport_active = status.get("transport_active", "") if ready else ""
|
||||||
proxy_active = status.get("proxy_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()
|
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()
|
_PROCESS.kill()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
elif _pid_alive(pid):
|
elif os.name != "nt" and _pid_alive(pid):
|
||||||
try:
|
try:
|
||||||
os.kill(pid, signal.SIGTERM)
|
os.kill(pid, signal.SIGTERM)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Tests prove:
|
Tests prove:
|
||||||
- Docker no longer auto-allows raw fallback
|
- 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)
|
- Non-Windows with MESH_SECURE_STORAGE_SECRET works (passphrase provider)
|
||||||
- Passphrase-protected envelopes round-trip correctly (master + domain)
|
- Passphrase-protected envelopes round-trip correctly (master + domain)
|
||||||
- Raw-to-passphrase migration works when secret is supplied
|
- Raw-to-passphrase migration works when secret is supplied
|
||||||
@@ -64,10 +64,10 @@ class TestDockerNoAutoRawFallback:
|
|||||||
assert mesh_secure_storage._raw_fallback_allowed() is True
|
assert mesh_secure_storage._raw_fallback_allowed() is True
|
||||||
|
|
||||||
|
|
||||||
class TestFailClosedWithoutSecret:
|
class TestGeneratedLocalSecretWithoutOperatorSecret:
|
||||||
"""Non-Windows with no secret and no raw opt-in must fail closed."""
|
"""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.mesh import mesh_secure_storage
|
||||||
from services import config as config_mod
|
from services import config as config_mod
|
||||||
|
|
||||||
@@ -76,6 +76,7 @@ class TestFailClosedWithoutSecret:
|
|||||||
monkeypatch.setattr(mesh_secure_storage, "_is_windows", lambda: False)
|
monkeypatch.setattr(mesh_secure_storage, "_is_windows", lambda: False)
|
||||||
monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False)
|
monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False)
|
||||||
monkeypatch.delenv("MESH_SECURE_STORAGE_SECRET", raising=False)
|
monkeypatch.delenv("MESH_SECURE_STORAGE_SECRET", raising=False)
|
||||||
|
monkeypatch.delenv("MESH_SECURE_STORAGE_SECRET_FILE", raising=False)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
config_mod,
|
config_mod,
|
||||||
"get_settings",
|
"get_settings",
|
||||||
@@ -86,10 +87,14 @@ class TestFailClosedWithoutSecret:
|
|||||||
)
|
)
|
||||||
_reset(mesh_secure_storage)
|
_reset(mesh_secure_storage)
|
||||||
|
|
||||||
with pytest.raises(mesh_secure_storage.SecureStorageError, match="MESH_SECURE_STORAGE_SECRET"):
|
key = mesh_secure_storage._load_master_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.mesh import mesh_secure_storage
|
||||||
from services import config as config_mod
|
from services import config as config_mod
|
||||||
|
|
||||||
@@ -98,6 +103,7 @@ class TestFailClosedWithoutSecret:
|
|||||||
monkeypatch.setattr(mesh_secure_storage, "_is_windows", lambda: False)
|
monkeypatch.setattr(mesh_secure_storage, "_is_windows", lambda: False)
|
||||||
monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False)
|
monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False)
|
||||||
monkeypatch.delenv("MESH_SECURE_STORAGE_SECRET", raising=False)
|
monkeypatch.delenv("MESH_SECURE_STORAGE_SECRET", raising=False)
|
||||||
|
monkeypatch.delenv("MESH_SECURE_STORAGE_SECRET_FILE", raising=False)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
config_mod,
|
config_mod,
|
||||||
"get_settings",
|
"get_settings",
|
||||||
@@ -108,8 +114,12 @@ class TestFailClosedWithoutSecret:
|
|||||||
)
|
)
|
||||||
_reset(mesh_secure_storage)
|
_reset(mesh_secure_storage)
|
||||||
|
|
||||||
with pytest.raises(mesh_secure_storage.SecureStorageError, match="MESH_SECURE_STORAGE_SECRET"):
|
key = mesh_secure_storage._load_domain_key("test_domain", base_dir=tmp_path)
|
||||||
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:
|
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()
|
mesh_secure_storage._load_master_key()
|
||||||
|
|
||||||
|
|
||||||
@@ -517,6 +527,7 @@ class TestGetStorageSecret:
|
|||||||
from services import config as config_mod
|
from services import config as config_mod
|
||||||
|
|
||||||
monkeypatch.delenv("MESH_SECURE_STORAGE_SECRET", raising=False)
|
monkeypatch.delenv("MESH_SECURE_STORAGE_SECRET", raising=False)
|
||||||
|
monkeypatch.setattr(mesh_secure_storage, "_is_windows", lambda: True)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
config_mod,
|
config_mod,
|
||||||
"get_settings",
|
"get_settings",
|
||||||
@@ -524,6 +535,27 @@ class TestGetStorageSecret:
|
|||||||
)
|
)
|
||||||
assert mesh_secure_storage._get_storage_secret() is None
|
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):
|
def test_falls_back_to_config(self, monkeypatch):
|
||||||
from services.mesh import mesh_secure_storage
|
from services.mesh import mesh_secure_storage
|
||||||
from services import config as config_mod
|
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):
|
def test_rfc1918_172_blocked_without_key(self):
|
||||||
assert self._call_with_host("172.16.0.5") == 403
|
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):
|
def test_rfc1918_192168_blocked_without_key(self):
|
||||||
assert self._call_with_host("192.168.1.100") == 403
|
assert self._call_with_host("192.168.1.100") == 403
|
||||||
|
|
||||||
@@ -100,7 +112,14 @@ class TestRequireLocalOperator:
|
|||||||
# _validate_peer_push_secret — startup enforcement
|
# _validate_peer_push_secret — startup enforcement
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
_KNOWN_COMPROMISED = "Mv63UvLfwqOEVWeRBXjA8MtFl2nEkkhUlLYVHiX1Zzo"
|
_KNOWN_COMPROMISED = "".join(
|
||||||
|
[
|
||||||
|
"Mv63UvLfwq",
|
||||||
|
"OEVWeRBXjA",
|
||||||
|
"8MtFl2nEkk",
|
||||||
|
"hUlLYVHiX1Zzo",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestValidatePeerPushSecret:
|
class TestValidatePeerPushSecret:
|
||||||
@@ -114,16 +133,17 @@ class TestValidatePeerPushSecret:
|
|||||||
with patch("main.get_settings", return_value=mock_settings):
|
with patch("main.get_settings", return_value=mock_settings):
|
||||||
return _validate_peer_push_secret
|
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
|
from auth import _validate_peer_push_secret
|
||||||
|
|
||||||
mock_settings = MagicMock()
|
mock_settings = MagicMock()
|
||||||
mock_settings.MESH_PEER_PUSH_SECRET = _KNOWN_COMPROMISED
|
mock_settings.MESH_PEER_PUSH_SECRET = _KNOWN_COMPROMISED
|
||||||
|
|
||||||
with patch("auth.get_settings", return_value=mock_settings):
|
with (
|
||||||
with pytest.raises(SystemExit) as exc_info:
|
patch("auth.get_settings", return_value=mock_settings),
|
||||||
_validate_peer_push_secret()
|
patch("auth._auto_generate_peer_push_secret", return_value="replacement-secret-value"),
|
||||||
assert exc_info.value.code == 1
|
):
|
||||||
|
_validate_peer_push_secret()
|
||||||
|
|
||||||
def test_empty_secret_does_not_exit_without_peers(self):
|
def test_empty_secret_does_not_exit_without_peers(self):
|
||||||
from auth import _validate_peer_push_secret
|
from auth import _validate_peer_push_secret
|
||||||
@@ -137,7 +157,7 @@ class TestValidatePeerPushSecret:
|
|||||||
with patch("auth.get_settings", return_value=mock_settings):
|
with patch("auth.get_settings", return_value=mock_settings):
|
||||||
_validate_peer_push_secret() # no exception = pass
|
_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
|
from auth import _validate_peer_push_secret
|
||||||
|
|
||||||
mock_settings = MagicMock()
|
mock_settings = MagicMock()
|
||||||
@@ -146,12 +166,13 @@ class TestValidatePeerPushSecret:
|
|||||||
mock_settings.MESH_RNS_PEERS = ""
|
mock_settings.MESH_RNS_PEERS = ""
|
||||||
mock_settings.MESH_RNS_ENABLED = False
|
mock_settings.MESH_RNS_ENABLED = False
|
||||||
|
|
||||||
with patch("auth.get_settings", return_value=mock_settings):
|
with (
|
||||||
with pytest.raises(SystemExit) as exc_info:
|
patch("auth.get_settings", return_value=mock_settings),
|
||||||
_validate_peer_push_secret()
|
patch("auth._auto_generate_peer_push_secret", return_value="replacement-secret-value"),
|
||||||
assert exc_info.value.code == 1
|
):
|
||||||
|
_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
|
from auth import _validate_peer_push_secret
|
||||||
|
|
||||||
mock_settings = MagicMock()
|
mock_settings = MagicMock()
|
||||||
@@ -160,10 +181,11 @@ class TestValidatePeerPushSecret:
|
|||||||
mock_settings.MESH_RNS_PEERS = ""
|
mock_settings.MESH_RNS_PEERS = ""
|
||||||
mock_settings.MESH_RNS_ENABLED = False
|
mock_settings.MESH_RNS_ENABLED = False
|
||||||
|
|
||||||
with patch("auth.get_settings", return_value=mock_settings):
|
with (
|
||||||
with pytest.raises(SystemExit) as exc_info:
|
patch("auth.get_settings", return_value=mock_settings),
|
||||||
_validate_peer_push_secret()
|
patch("auth._auto_generate_peer_push_secret", return_value="replacement-secret-value"),
|
||||||
assert exc_info.value.code == 1
|
):
|
||||||
|
_validate_peer_push_secret()
|
||||||
|
|
||||||
def test_valid_secret_passes(self):
|
def test_valid_secret_passes(self):
|
||||||
from auth import _validate_peer_push_secret
|
from auth import _validate_peer_push_secret
|
||||||
|
|||||||
@@ -2,11 +2,13 @@
|
|||||||
|
|
||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
|
const { spawnSync } = require('node:child_process');
|
||||||
|
|
||||||
const scriptDir = __dirname;
|
const scriptDir = __dirname;
|
||||||
const tauriDir = path.resolve(scriptDir, '..');
|
const tauriDir = path.resolve(scriptDir, '..');
|
||||||
const repoRoot = path.resolve(tauriDir, '..', '..');
|
const repoRoot = path.resolve(tauriDir, '..', '..');
|
||||||
const backendDir = path.join(repoRoot, 'backend');
|
const backendDir = path.join(repoRoot, 'backend');
|
||||||
|
const privacyCoreDir = path.join(repoRoot, 'privacy-core');
|
||||||
const outputDir = path.join(tauriDir, 'src-tauri', 'backend-runtime');
|
const outputDir = path.join(tauriDir, 'src-tauri', 'backend-runtime');
|
||||||
const venvMarkerPath = path.join(backendDir, '.venv-dir');
|
const venvMarkerPath = path.join(backendDir, '.venv-dir');
|
||||||
const releaseAttestationPath = path.join(backendDir, 'data', 'release_attestation.json');
|
const releaseAttestationPath = path.join(backendDir, 'data', 'release_attestation.json');
|
||||||
@@ -19,14 +21,21 @@ const stagedReleaseAttestationPath = path.join(
|
|||||||
const excludedNames = new Set([
|
const excludedNames = new Set([
|
||||||
'.env',
|
'.env',
|
||||||
'.pytest_cache',
|
'.pytest_cache',
|
||||||
|
'.ruff_cache',
|
||||||
'__pycache__',
|
'__pycache__',
|
||||||
'backend.egg-info',
|
'backend.egg-info',
|
||||||
'build',
|
'build',
|
||||||
'data',
|
'data',
|
||||||
'tests',
|
'tests',
|
||||||
|
'timemachine',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const excludedFiles = new Set([
|
const excludedFiles = new Set([
|
||||||
|
'.env.example',
|
||||||
|
'ais_cache.json',
|
||||||
|
'carrier_cache.json',
|
||||||
|
'cctv.db',
|
||||||
|
'dm_token_pepper.key',
|
||||||
'pytest.ini',
|
'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() {
|
function stageBackendRuntime() {
|
||||||
fs.rmSync(outputDir, { recursive: true, force: true });
|
fs.rmSync(outputDir, { recursive: true, force: true });
|
||||||
fs.cpSync(backendDir, outputDir, {
|
fs.cpSync(backendDir, outputDir, {
|
||||||
recursive: true,
|
recursive: true,
|
||||||
filter: shouldCopy,
|
filter: shouldCopy,
|
||||||
});
|
});
|
||||||
|
stagePrivacyCoreArtifact();
|
||||||
stageReleaseAttestation();
|
stageReleaseAttestation();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stagePrivacyCoreArtifact() {
|
||||||
|
const artifact = ensurePrivacyCoreArtifact();
|
||||||
|
const stagedPath = path.join(outputDir, path.basename(artifact));
|
||||||
|
fs.copyFileSync(artifact, stagedPath);
|
||||||
|
}
|
||||||
|
|
||||||
function stageReleaseAttestation() {
|
function stageReleaseAttestation() {
|
||||||
if (!fs.existsSync(releaseAttestationPath)) {
|
if (!fs.existsSync(releaseAttestationPath)) {
|
||||||
console.warn(`backend-runtime staged without release attestation: ${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)
|
.open(&stderr_log)
|
||||||
.map_err(|e| format!("managed_backend_stderr_log_failed:{e}"))?;
|
.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)
|
.current_dir(&runtime_root)
|
||||||
.arg("-m")
|
.arg("-m")
|
||||||
.arg("uvicorn")
|
.arg("uvicorn")
|
||||||
@@ -103,7 +104,13 @@ pub async fn ensure_and_start_managed_backend(
|
|||||||
.arg("--timeout-keep-alive")
|
.arg("--timeout-keep-alive")
|
||||||
.arg("120")
|
.arg("120")
|
||||||
.env("PYTHONUNBUFFERED", "1")
|
.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))
|
.stdout(Stdio::from(stdout))
|
||||||
.stderr(Stdio::from(stderr))
|
.stderr(Stdio::from(stderr))
|
||||||
.spawn()
|
.spawn()
|
||||||
@@ -191,6 +198,18 @@ fn sync_release_attestation(bundled_root: &Path, install_root: &Path) -> Result<
|
|||||||
Ok(())
|
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 {
|
fn release_attestation_path(root: &Path) -> PathBuf {
|
||||||
RELEASE_ATTESTATION_RELATIVE_PATH
|
RELEASE_ATTESTATION_RELATIVE_PATH
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
},
|
},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
"updater": {
|
"updater": {
|
||||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDJDMUU1NkRENjNCNTI5RjUKUldUMUtiVmozVlllTEd0STJlMGtORUxUWHlGQ2V0ZXM3Z1BOc3hwc0pUK1c3dlplcWc2OFpKd3oK",
|
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEUxODExMjQ4MkJBMThFNTgKUldSWWpxRXJTQktCNFF3ZXNQbndUK0pVWUEwNDNuajcrUGI3ZEI4TWtDUDlQdHhudmlHUkNjQUUK",
|
||||||
"endpoints": [
|
"endpoints": [
|
||||||
"https://github.com/BigBodyCobain/Shadowbroker/releases/latest/download/latest.json"
|
"https://github.com/BigBodyCobain/Shadowbroker/releases/latest/download/latest.json"
|
||||||
],
|
],
|
||||||
|
|||||||
+9
-6
@@ -13,10 +13,10 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "${BIND:-127.0.0.1}:8000:8000"
|
- "${BIND:-127.0.0.1}:8000:8000"
|
||||||
environment:
|
environment:
|
||||||
- AIS_API_KEY=${AIS_API_KEY}
|
- AIS_API_KEY=${AIS_API_KEY:-}
|
||||||
- OPENSKY_CLIENT_ID=${OPENSKY_CLIENT_ID}
|
- OPENSKY_CLIENT_ID=${OPENSKY_CLIENT_ID:-}
|
||||||
- OPENSKY_CLIENT_SECRET=${OPENSKY_CLIENT_SECRET}
|
- OPENSKY_CLIENT_SECRET=${OPENSKY_CLIENT_SECRET:-}
|
||||||
- LTA_ACCOUNT_KEY=${LTA_ACCOUNT_KEY}
|
- LTA_ACCOUNT_KEY=${LTA_ACCOUNT_KEY:-}
|
||||||
- ADMIN_KEY=${ADMIN_KEY:-}
|
- ADMIN_KEY=${ADMIN_KEY:-}
|
||||||
- FINNHUB_API_KEY=${FINNHUB_API_KEY:-}
|
- FINNHUB_API_KEY=${FINNHUB_API_KEY:-}
|
||||||
# Override allowed CORS origins (comma-separated). Auto-detects LAN IPs if empty.
|
# 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.
|
# Operator-trusted sync/push peers. Leave empty unless you control the peer secret on both sides.
|
||||||
- MESH_RELAY_PEERS=${MESH_RELAY_PEERS:-}
|
- MESH_RELAY_PEERS=${MESH_RELAY_PEERS:-}
|
||||||
# Shared transport auth for operator peer push. Must be set to a unique secret per deployment.
|
# 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:
|
volumes:
|
||||||
- backend_data:/app/data
|
- backend_data:/app/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -56,7 +59,7 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000/"]
|
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:3000/"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|||||||
Generated
+134
-395
@@ -33,9 +33,9 @@
|
|||||||
"@vitest/coverage-v8": "^4.1.0",
|
"@vitest/coverage-v8": "^4.1.0",
|
||||||
"concurrently": "^9.2.1",
|
"concurrently": "^9.2.1",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.1.6",
|
"eslint-config-next": "16.2.4",
|
||||||
"jsdom": "^28.1.0",
|
"jsdom": "^28.1.0",
|
||||||
"prettier": "^3.3.3",
|
"prettier": "^3.8.3",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
"vitest": "^4.1.0"
|
"vitest": "^4.1.0"
|
||||||
@@ -1362,9 +1362,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@next/eslint-plugin-next": {
|
"node_modules/@next/eslint-plugin-next": {
|
||||||
"version": "16.1.6",
|
"version": "16.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.4.tgz",
|
||||||
"integrity": "sha512-/Qq3PTagA6+nYVfryAtQ7/9FEr/6YVyvOtl6rZnGsbReGLf0jZU6gkpr1FuChAQpvV46a78p4cmHOVP8mbfSMQ==",
|
"integrity": "sha512-tOX826JJ96gYK/go18sPUgMq9FK1tqxBFfUCEufJb5XIkWFFmpgU7mahJANKGkHs7F41ir3tReJ3Lv5La0RvhA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -1870,49 +1870,49 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/node": {
|
"node_modules/@tailwindcss/node": {
|
||||||
"version": "4.2.1",
|
"version": "4.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz",
|
||||||
"integrity": "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==",
|
"integrity": "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/remapping": "^2.3.5",
|
"@jridgewell/remapping": "^2.3.5",
|
||||||
"enhanced-resolve": "^5.19.0",
|
"enhanced-resolve": "^5.19.0",
|
||||||
"jiti": "^2.6.1",
|
"jiti": "^2.6.1",
|
||||||
"lightningcss": "1.31.1",
|
"lightningcss": "1.32.0",
|
||||||
"magic-string": "^0.30.21",
|
"magic-string": "^0.30.21",
|
||||||
"source-map-js": "^1.2.1",
|
"source-map-js": "^1.2.1",
|
||||||
"tailwindcss": "4.2.1"
|
"tailwindcss": "4.2.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/oxide": {
|
"node_modules/@tailwindcss/oxide": {
|
||||||
"version": "4.2.1",
|
"version": "4.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.4.tgz",
|
||||||
"integrity": "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==",
|
"integrity": "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 20"
|
"node": ">= 20"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@tailwindcss/oxide-android-arm64": "4.2.1",
|
"@tailwindcss/oxide-android-arm64": "4.2.4",
|
||||||
"@tailwindcss/oxide-darwin-arm64": "4.2.1",
|
"@tailwindcss/oxide-darwin-arm64": "4.2.4",
|
||||||
"@tailwindcss/oxide-darwin-x64": "4.2.1",
|
"@tailwindcss/oxide-darwin-x64": "4.2.4",
|
||||||
"@tailwindcss/oxide-freebsd-x64": "4.2.1",
|
"@tailwindcss/oxide-freebsd-x64": "4.2.4",
|
||||||
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1",
|
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4",
|
||||||
"@tailwindcss/oxide-linux-arm64-gnu": "4.2.1",
|
"@tailwindcss/oxide-linux-arm64-gnu": "4.2.4",
|
||||||
"@tailwindcss/oxide-linux-arm64-musl": "4.2.1",
|
"@tailwindcss/oxide-linux-arm64-musl": "4.2.4",
|
||||||
"@tailwindcss/oxide-linux-x64-gnu": "4.2.1",
|
"@tailwindcss/oxide-linux-x64-gnu": "4.2.4",
|
||||||
"@tailwindcss/oxide-linux-x64-musl": "4.2.1",
|
"@tailwindcss/oxide-linux-x64-musl": "4.2.4",
|
||||||
"@tailwindcss/oxide-wasm32-wasi": "4.2.1",
|
"@tailwindcss/oxide-wasm32-wasi": "4.2.4",
|
||||||
"@tailwindcss/oxide-win32-arm64-msvc": "4.2.1",
|
"@tailwindcss/oxide-win32-arm64-msvc": "4.2.4",
|
||||||
"@tailwindcss/oxide-win32-x64-msvc": "4.2.1"
|
"@tailwindcss/oxide-win32-x64-msvc": "4.2.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/oxide-android-arm64": {
|
"node_modules/@tailwindcss/oxide-android-arm64": {
|
||||||
"version": "4.2.1",
|
"version": "4.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.4.tgz",
|
||||||
"integrity": "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==",
|
"integrity": "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1927,9 +1927,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/oxide-darwin-arm64": {
|
"node_modules/@tailwindcss/oxide-darwin-arm64": {
|
||||||
"version": "4.2.1",
|
"version": "4.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.4.tgz",
|
||||||
"integrity": "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==",
|
"integrity": "sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1944,9 +1944,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/oxide-darwin-x64": {
|
"node_modules/@tailwindcss/oxide-darwin-x64": {
|
||||||
"version": "4.2.1",
|
"version": "4.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.4.tgz",
|
||||||
"integrity": "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==",
|
"integrity": "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1961,9 +1961,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/oxide-freebsd-x64": {
|
"node_modules/@tailwindcss/oxide-freebsd-x64": {
|
||||||
"version": "4.2.1",
|
"version": "4.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.4.tgz",
|
||||||
"integrity": "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==",
|
"integrity": "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1978,9 +1978,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
|
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
|
||||||
"version": "4.2.1",
|
"version": "4.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.4.tgz",
|
||||||
"integrity": "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==",
|
"integrity": "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -1995,9 +1995,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
|
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
|
||||||
"version": "4.2.1",
|
"version": "4.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.4.tgz",
|
||||||
"integrity": "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==",
|
"integrity": "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2012,9 +2012,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
|
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
|
||||||
"version": "4.2.1",
|
"version": "4.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.4.tgz",
|
||||||
"integrity": "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==",
|
"integrity": "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2029,9 +2029,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
|
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
|
||||||
"version": "4.2.1",
|
"version": "4.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.4.tgz",
|
||||||
"integrity": "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==",
|
"integrity": "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2046,9 +2046,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
|
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
|
||||||
"version": "4.2.1",
|
"version": "4.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.4.tgz",
|
||||||
"integrity": "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==",
|
"integrity": "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2063,9 +2063,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
|
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
|
||||||
"version": "4.2.1",
|
"version": "4.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.4.tgz",
|
||||||
"integrity": "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==",
|
"integrity": "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==",
|
||||||
"bundleDependencies": [
|
"bundleDependencies": [
|
||||||
"@napi-rs/wasm-runtime",
|
"@napi-rs/wasm-runtime",
|
||||||
"@emnapi/core",
|
"@emnapi/core",
|
||||||
@@ -2157,9 +2157,9 @@
|
|||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||||
"version": "4.2.1",
|
"version": "4.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.4.tgz",
|
||||||
"integrity": "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==",
|
"integrity": "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2174,9 +2174,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
|
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
|
||||||
"version": "4.2.1",
|
"version": "4.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.4.tgz",
|
||||||
"integrity": "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==",
|
"integrity": "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2191,17 +2191,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/postcss": {
|
"node_modules/@tailwindcss/postcss": {
|
||||||
"version": "4.2.1",
|
"version": "4.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.4.tgz",
|
||||||
"integrity": "sha512-OEwGIBnXnj7zJeonOh6ZG9woofIjGrd2BORfvE5p9USYKDCZoQmfqLcfNiRWoJlRWLdNPn2IgVZuWAOM4iTYMw==",
|
"integrity": "sha512-wgAVj6nUWAolAu8YFvzT2cTBIElWHkjZwFYovF+xsqKsW2ADxM/X2opxj5NsF/qVccAOjRNe8X2IdPzMsWyHTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@alloc/quick-lru": "^5.2.0",
|
"@alloc/quick-lru": "^5.2.0",
|
||||||
"@tailwindcss/node": "4.2.1",
|
"@tailwindcss/node": "4.2.4",
|
||||||
"@tailwindcss/oxide": "4.2.1",
|
"@tailwindcss/oxide": "4.2.4",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"tailwindcss": "4.2.1"
|
"tailwindcss": "4.2.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tauri-apps/api": {
|
"node_modules/@tauri-apps/api": {
|
||||||
@@ -4198,14 +4198,14 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/enhanced-resolve": {
|
"node_modules/enhanced-resolve": {
|
||||||
"version": "5.19.0",
|
"version": "5.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz",
|
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz",
|
||||||
"integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==",
|
"integrity": "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"graceful-fs": "^4.2.4",
|
"graceful-fs": "^4.2.4",
|
||||||
"tapable": "^2.3.0"
|
"tapable": "^2.3.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10.13.0"
|
"node": ">=10.13.0"
|
||||||
@@ -4492,13 +4492,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint-config-next": {
|
"node_modules/eslint-config-next": {
|
||||||
"version": "16.1.6",
|
"version": "16.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.4.tgz",
|
||||||
"integrity": "sha512-vKq40io2B0XtkkNDYyleATwblNt8xuh3FWp8SpSz3pt7P01OkBFlKsJZ2mWt5WsCySlDQLckb1zMY9yE9Qy0LA==",
|
"integrity": "sha512-A6ekXYFj/YQxBPMl45g3e+U8zJo+X2+ZQwcz34pPKjpc/3S4roBA2Rd9xWB4FKuSxhofo1/95WjzmUY+wHrOhg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"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-node": "^0.3.6",
|
||||||
"eslint-import-resolver-typescript": "^3.5.2",
|
"eslint-import-resolver-typescript": "^3.5.2",
|
||||||
"eslint-plugin-import": "^2.32.0",
|
"eslint-plugin-import": "^2.32.0",
|
||||||
@@ -6338,9 +6338,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lightningcss": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.31.1",
|
"version": "1.32.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||||
"integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==",
|
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -6354,23 +6354,23 @@
|
|||||||
"url": "https://opencollective.com/parcel"
|
"url": "https://opencollective.com/parcel"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"lightningcss-android-arm64": "1.31.1",
|
"lightningcss-android-arm64": "1.32.0",
|
||||||
"lightningcss-darwin-arm64": "1.31.1",
|
"lightningcss-darwin-arm64": "1.32.0",
|
||||||
"lightningcss-darwin-x64": "1.31.1",
|
"lightningcss-darwin-x64": "1.32.0",
|
||||||
"lightningcss-freebsd-x64": "1.31.1",
|
"lightningcss-freebsd-x64": "1.32.0",
|
||||||
"lightningcss-linux-arm-gnueabihf": "1.31.1",
|
"lightningcss-linux-arm-gnueabihf": "1.32.0",
|
||||||
"lightningcss-linux-arm64-gnu": "1.31.1",
|
"lightningcss-linux-arm64-gnu": "1.32.0",
|
||||||
"lightningcss-linux-arm64-musl": "1.31.1",
|
"lightningcss-linux-arm64-musl": "1.32.0",
|
||||||
"lightningcss-linux-x64-gnu": "1.31.1",
|
"lightningcss-linux-x64-gnu": "1.32.0",
|
||||||
"lightningcss-linux-x64-musl": "1.31.1",
|
"lightningcss-linux-x64-musl": "1.32.0",
|
||||||
"lightningcss-win32-arm64-msvc": "1.31.1",
|
"lightningcss-win32-arm64-msvc": "1.32.0",
|
||||||
"lightningcss-win32-x64-msvc": "1.31.1"
|
"lightningcss-win32-x64-msvc": "1.32.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lightningcss-android-arm64": {
|
"node_modules/lightningcss-android-arm64": {
|
||||||
"version": "1.31.1",
|
"version": "1.32.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
|
||||||
"integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==",
|
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -6389,9 +6389,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lightningcss-darwin-arm64": {
|
"node_modules/lightningcss-darwin-arm64": {
|
||||||
"version": "1.31.1",
|
"version": "1.32.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
|
||||||
"integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==",
|
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -6410,9 +6410,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lightningcss-darwin-x64": {
|
"node_modules/lightningcss-darwin-x64": {
|
||||||
"version": "1.31.1",
|
"version": "1.32.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
|
||||||
"integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==",
|
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -6431,9 +6431,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lightningcss-freebsd-x64": {
|
"node_modules/lightningcss-freebsd-x64": {
|
||||||
"version": "1.31.1",
|
"version": "1.32.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
|
||||||
"integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==",
|
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -6452,9 +6452,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lightningcss-linux-arm-gnueabihf": {
|
"node_modules/lightningcss-linux-arm-gnueabihf": {
|
||||||
"version": "1.31.1",
|
"version": "1.32.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
|
||||||
"integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==",
|
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -6473,9 +6473,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lightningcss-linux-arm64-gnu": {
|
"node_modules/lightningcss-linux-arm64-gnu": {
|
||||||
"version": "1.31.1",
|
"version": "1.32.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
|
||||||
"integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==",
|
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -6494,9 +6494,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lightningcss-linux-arm64-musl": {
|
"node_modules/lightningcss-linux-arm64-musl": {
|
||||||
"version": "1.31.1",
|
"version": "1.32.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
|
||||||
"integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==",
|
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -6515,9 +6515,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lightningcss-linux-x64-gnu": {
|
"node_modules/lightningcss-linux-x64-gnu": {
|
||||||
"version": "1.31.1",
|
"version": "1.32.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
|
||||||
"integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==",
|
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -6536,9 +6536,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lightningcss-linux-x64-musl": {
|
"node_modules/lightningcss-linux-x64-musl": {
|
||||||
"version": "1.31.1",
|
"version": "1.32.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
|
||||||
"integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==",
|
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -6557,9 +6557,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lightningcss-win32-arm64-msvc": {
|
"node_modules/lightningcss-win32-arm64-msvc": {
|
||||||
"version": "1.31.1",
|
"version": "1.32.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
|
||||||
"integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==",
|
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -6578,9 +6578,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lightningcss-win32-x64-msvc": {
|
"node_modules/lightningcss-win32-x64-msvc": {
|
||||||
"version": "1.31.1",
|
"version": "1.32.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
|
||||||
"integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==",
|
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -7342,9 +7342,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -7419,9 +7419,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/prettier": {
|
"node_modules/prettier": {
|
||||||
"version": "3.8.1",
|
"version": "3.8.3",
|
||||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz",
|
||||||
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
|
"integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -8632,16 +8632,16 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/tailwindcss": {
|
"node_modules/tailwindcss": {
|
||||||
"version": "4.2.1",
|
"version": "4.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz",
|
||||||
"integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==",
|
"integrity": "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/tapable": {
|
"node_modules/tapable": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
|
||||||
"integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
|
"integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"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": {
|
"node_modules/vite/node_modules/picomatch": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||||
|
|||||||
@@ -45,9 +45,9 @@
|
|||||||
"@vitest/coverage-v8": "^4.1.0",
|
"@vitest/coverage-v8": "^4.1.0",
|
||||||
"concurrently": "^9.2.1",
|
"concurrently": "^9.2.1",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.1.6",
|
"eslint-config-next": "16.2.4",
|
||||||
"jsdom": "^28.1.0",
|
"jsdom": "^28.1.0",
|
||||||
"prettier": "^3.3.3",
|
"prettier": "^3.8.3",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
"vitest": "^4.1.0"
|
"vitest": "^4.1.0"
|
||||||
|
|||||||
@@ -302,10 +302,10 @@ describe('MessagesView first-contact trust UX', () => {
|
|||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Send Secure Mail' }));
|
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.prepareWormholeInteractiveLane).toHaveBeenCalled();
|
||||||
expect(mocks.sendDmMessage).toHaveBeenCalled();
|
expect(mocks.sendDmMessage).toHaveBeenCalled();
|
||||||
});
|
}, 10000);
|
||||||
|
|
||||||
it('does not flatten witness policy not met into a generic witnessed root label', async () => {
|
it('does not flatten witness policy not met into a generic witnessed root label', async () => {
|
||||||
contactsState = {
|
contactsState = {
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ const NO_STORE_PROXY_HEADERS = {
|
|||||||
Pragma: 'no-cache',
|
Pragma: 'no-cache',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
function isSensitiveProxyPath(pathSegments: string[]): boolean {
|
function isSensitiveProxyPath(pathSegments: string[]): boolean {
|
||||||
const joined = pathSegments.join('/');
|
const joined = pathSegments.join('/');
|
||||||
if (!joined) return false;
|
if (!joined) return false;
|
||||||
@@ -76,8 +80,7 @@ async function proxy(req: NextRequest, pathSegments: string[]): Promise<NextResp
|
|||||||
isMesh &&
|
isMesh &&
|
||||||
!isSensitiveMeshPath &&
|
!isSensitiveMeshPath &&
|
||||||
['POST', 'PUT', 'DELETE'].includes(req.method.toUpperCase()) &&
|
['POST', 'PUT', 'DELETE'].includes(req.method.toUpperCase()) &&
|
||||||
(meshSegments.join('/') === 'send' ||
|
(meshSegments.join('/') === 'vote' ||
|
||||||
meshSegments.join('/') === 'vote' ||
|
|
||||||
meshSegments.join('/') === 'report' ||
|
meshSegments.join('/') === 'report' ||
|
||||||
meshSegments.join('/') === 'gate/create' ||
|
meshSegments.join('/') === 'gate/create' ||
|
||||||
(meshSegments[0] === 'gate' && meshSegments[2] === 'message') ||
|
(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';
|
const isBodyless = req.method === 'GET' || req.method === 'HEAD';
|
||||||
let upstream: Response;
|
let upstream: Response | null = null;
|
||||||
const requestInit: RequestInit & { duplex?: 'half' } = {
|
const requestInit: RequestInit & { duplex?: 'half' } = {
|
||||||
method: req.method,
|
method: req.method,
|
||||||
headers: forwardHeaders,
|
headers: forwardHeaders,
|
||||||
@@ -202,12 +205,26 @@ async function proxy(req: NextRequest, pathSegments: string[]): Promise<NextResp
|
|||||||
// Required for streaming request bodies in Node.js fetch
|
// Required for streaming request bodies in Node.js fetch
|
||||||
requestInit.duplex = 'half';
|
requestInit.duplex = 'half';
|
||||||
}
|
}
|
||||||
try {
|
const maxAttempts = isBodyless ? 18 : 1;
|
||||||
upstream = await fetch(targetUrl.toString(), requestInit);
|
let fetchError: unknown = null;
|
||||||
} catch {
|
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' }), {
|
return new NextResponse(JSON.stringify({ error: 'Backend unavailable' }), {
|
||||||
status: 502,
|
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 ErrorBoundary from '@/components/ErrorBoundary';
|
||||||
import OnboardingModal, { useOnboarding } from '@/components/OnboardingModal';
|
import OnboardingModal, { useOnboarding } from '@/components/OnboardingModal';
|
||||||
import ChangelogModal, { useChangelog } from '@/components/ChangelogModal';
|
import ChangelogModal, { useChangelog } from '@/components/ChangelogModal';
|
||||||
|
import StartupWarmupModal, { useStartupWarmupNotice } from '@/components/StartupWarmupModal';
|
||||||
import type { ActiveLayers, KiwiSDR, Scanner, SelectedEntity } from '@/types/dashboard';
|
import type { ActiveLayers, KiwiSDR, Scanner, SelectedEntity } from '@/types/dashboard';
|
||||||
import type { ShodanSearchMatch } from '@/types/shodan';
|
import type { ShodanSearchMatch } from '@/types/shodan';
|
||||||
import { API_BASE } from '@/lib/api';
|
import { API_BASE } from '@/lib/api';
|
||||||
import { useDataPolling, LAYER_TOGGLE_EVENT } from '@/hooks/useDataPolling';
|
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 { useReverseGeocode } from '@/hooks/useReverseGeocode';
|
||||||
import { useRegionDossier } from '@/hooks/useRegionDossier';
|
import { useRegionDossier } from '@/hooks/useRegionDossier';
|
||||||
import { useAgentActions } from '@/hooks/useAgentActions';
|
import { useAgentActions } from '@/hooks/useAgentActions';
|
||||||
@@ -61,6 +62,9 @@ const MaplibreViewer = dynamic(() => import('@/components/MaplibreViewer'), { ss
|
|||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const viewBoundsRef = useRef<{ south: number; west: number; north: number; east: number } | null>(null);
|
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 { mouseCoords, locationLabel, handleMouseCoords } = useReverseGeocode();
|
||||||
const [selectedEntity, setSelectedEntity] = useState<SelectedEntity | null>(null);
|
const [selectedEntity, setSelectedEntity] = useState<SelectedEntity | null>(null);
|
||||||
const [trackedSdr, setTrackedSdr] = useState<KiwiSDR | null>(null);
|
const [trackedSdr, setTrackedSdr] = useState<KiwiSDR | null>(null);
|
||||||
@@ -211,10 +215,35 @@ export default function Dashboard() {
|
|||||||
const [shodanResults, setShodanResults] = useState<ShodanSearchMatch[]>([]);
|
const [shodanResults, setShodanResults] = useState<ShodanSearchMatch[]>([]);
|
||||||
const [, setShodanQueryLabel] = useState('');
|
const [, setShodanQueryLabel] = useState('');
|
||||||
const [shodanStyle, setShodanStyle] = useState<import('@/types/shodan').ShodanStyleConfig>({ shape: 'circle', color: '#16a34a', size: 'md' });
|
const [shodanStyle, setShodanStyle] = useState<import('@/types/shodan').ShodanStyleConfig>({ shape: 'circle', color: '#16a34a', size: 'md' });
|
||||||
useDataPolling();
|
|
||||||
const backendStatus = useBackendStatus();
|
const backendStatus = useBackendStatus();
|
||||||
const spaceWeather = useDataKey('space_weather');
|
const spaceWeather = useDataKey('space_weather');
|
||||||
const feedHealth = useFeedHealth();
|
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
|
// Global keyboard shortcuts
|
||||||
useKeyboardShortcuts({
|
useKeyboardShortcuts({
|
||||||
@@ -249,6 +278,7 @@ export default function Dashboard() {
|
|||||||
const layersTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const layersTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const initialLayerSyncRef = useRef(false);
|
const initialLayerSyncRef = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!secondaryBootReady) return;
|
||||||
const syncLayers = (triggerRefetch: boolean) =>
|
const syncLayers = (triggerRefetch: boolean) =>
|
||||||
fetch(`${API_BASE}/api/layers`, {
|
fetch(`${API_BASE}/api/layers`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -258,7 +288,7 @@ export default function Dashboard() {
|
|||||||
if (triggerRefetch) {
|
if (triggerRefetch) {
|
||||||
window.dispatchEvent(new Event(LAYER_TOGGLE_EVENT));
|
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 (layersTimerRef.current) clearTimeout(layersTimerRef.current);
|
||||||
if (!initialLayerSyncRef.current) {
|
if (!initialLayerSyncRef.current) {
|
||||||
@@ -272,7 +302,7 @@ export default function Dashboard() {
|
|||||||
return () => {
|
return () => {
|
||||||
if (layersTimerRef.current) clearTimeout(layersTimerRef.current);
|
if (layersTimerRef.current) clearTimeout(layersTimerRef.current);
|
||||||
};
|
};
|
||||||
}, [activeLayers]);
|
}, [activeLayers, secondaryBootReady]);
|
||||||
|
|
||||||
// Left panel accordion state
|
// Left panel accordion state
|
||||||
const [leftDataMinimized, setLeftDataMinimized] = useState(false);
|
const [leftDataMinimized, setLeftDataMinimized] = useState(false);
|
||||||
@@ -393,12 +423,28 @@ export default function Dashboard() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const [activeFilters, setActiveFilters] = useState<Record<string, string[]>>({});
|
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
|
// Agent fly_to handler (sar_focus_aoi etc.) — wired here now that
|
||||||
// setFlyToLocation is in scope. show_image is routed through
|
// setFlyToLocation is in scope. show_image is routed through
|
||||||
// useAgentActions at the top of Dashboard.
|
// useAgentActions at the top of Dashboard.
|
||||||
useAgentActions(handleMapRightClick, ({ lat, lng }) => {
|
useAgentActions(handleMapRightClick, ({ lat, lng }) => {
|
||||||
setFlyToLocation({ lat, lng, ts: Date.now() });
|
setFlyToLocation({ lat, lng, ts: Date.now() });
|
||||||
});
|
}, secondaryBootReady);
|
||||||
|
|
||||||
// Eavesdrop Mode State
|
// Eavesdrop Mode State
|
||||||
const [isEavesdropping] = useState(false);
|
const [isEavesdropping] = useState(false);
|
||||||
@@ -407,6 +453,7 @@ export default function Dashboard() {
|
|||||||
|
|
||||||
// Onboarding & connection status
|
// Onboarding & connection status
|
||||||
const { showOnboarding, setShowOnboarding } = useOnboarding();
|
const { showOnboarding, setShowOnboarding } = useOnboarding();
|
||||||
|
const { showWarmupNotice, setShowWarmupNotice } = useStartupWarmupNotice();
|
||||||
const { showChangelog, setShowChangelog } = useChangelog();
|
const { showChangelog, setShowChangelog } = useChangelog();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -415,7 +462,7 @@ export default function Dashboard() {
|
|||||||
{/* MAPLIBRE WEBGL OVERLAY */}
|
{/* MAPLIBRE WEBGL OVERLAY */}
|
||||||
<ErrorBoundary name="Map">
|
<ErrorBoundary name="Map">
|
||||||
<MaplibreViewer
|
<MaplibreViewer
|
||||||
activeLayers={activeLayers}
|
activeLayers={firstPaintActiveLayers}
|
||||||
activeFilters={activeFilters}
|
activeFilters={activeFilters}
|
||||||
effects={memoizedEffects}
|
effects={memoizedEffects}
|
||||||
onEntityClick={setSelectedEntity}
|
onEntityClick={setSelectedEntity}
|
||||||
@@ -502,74 +549,87 @@ export default function Dashboard() {
|
|||||||
>
|
>
|
||||||
{/* 1. DATA LAYERS (Top) */}
|
{/* 1. DATA LAYERS (Top) */}
|
||||||
<div className="contents" style={{ direction: 'ltr' }}>
|
<div className="contents" style={{ direction: 'ltr' }}>
|
||||||
<ErrorBoundary name="WorldviewLeftPanel">
|
{secondaryBootReady ? (
|
||||||
<WorldviewLeftPanel
|
<ErrorBoundary name="WorldviewLeftPanel">
|
||||||
activeLayers={activeLayers}
|
<WorldviewLeftPanel
|
||||||
setActiveLayers={setActiveLayers}
|
activeLayers={activeLayers}
|
||||||
shodanResultCount={shodanResults.length}
|
setActiveLayers={setActiveLayers}
|
||||||
onSettingsClick={() => setSettingsOpen(true)}
|
shodanResultCount={shodanResults.length}
|
||||||
onLegendClick={() => setLegendOpen(true)}
|
onSettingsClick={() => setSettingsOpen(true)}
|
||||||
onOpenSarAoiEditor={() => setSarAoiEditorOpen(true)}
|
onLegendClick={() => setLegendOpen(true)}
|
||||||
gibsDate={gibsDate}
|
onOpenSarAoiEditor={() => setSarAoiEditorOpen(true)}
|
||||||
setGibsDate={setGibsDate}
|
gibsDate={gibsDate}
|
||||||
gibsOpacity={gibsOpacity}
|
setGibsDate={setGibsDate}
|
||||||
setGibsOpacity={setGibsOpacity}
|
gibsOpacity={gibsOpacity}
|
||||||
sentinelDate={sentinelDate}
|
setGibsOpacity={setGibsOpacity}
|
||||||
setSentinelDate={setSentinelDate}
|
sentinelDate={sentinelDate}
|
||||||
sentinelOpacity={sentinelOpacity}
|
setSentinelDate={setSentinelDate}
|
||||||
setSentinelOpacity={setSentinelOpacity}
|
sentinelOpacity={sentinelOpacity}
|
||||||
sentinelPreset={sentinelPreset}
|
setSentinelOpacity={setSentinelOpacity}
|
||||||
setSentinelPreset={setSentinelPreset}
|
sentinelPreset={sentinelPreset}
|
||||||
onEntityClick={setSelectedEntity}
|
setSentinelPreset={setSentinelPreset}
|
||||||
onFlyTo={handleFlyTo}
|
onEntityClick={setSelectedEntity}
|
||||||
trackedSdr={trackedSdr}
|
onFlyTo={handleFlyTo}
|
||||||
setTrackedSdr={setTrackedSdr}
|
trackedSdr={trackedSdr}
|
||||||
trackedScanner={trackedScanner}
|
setTrackedSdr={setTrackedSdr}
|
||||||
setTrackedScanner={setTrackedScanner}
|
trackedScanner={trackedScanner}
|
||||||
isMinimized={leftDataMinimized}
|
setTrackedScanner={setTrackedScanner}
|
||||||
onMinimizedChange={setLeftDataMinimized}
|
isMinimized={leftDataMinimized}
|
||||||
/>
|
onMinimizedChange={setLeftDataMinimized}
|
||||||
</ErrorBoundary>
|
/>
|
||||||
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* 2. MESH CHAT (Middle) */}
|
{/* 2. MESH CHAT (Middle) */}
|
||||||
<div className="contents" style={{ direction: 'ltr' }}>
|
{secondaryBootReady && (
|
||||||
<MeshChat
|
<div className="contents" style={{ direction: 'ltr' }}>
|
||||||
onFlyTo={handleFlyTo}
|
<MeshChat
|
||||||
expanded={leftMeshExpanded}
|
onFlyTo={handleFlyTo}
|
||||||
onExpandedChange={setLeftMeshExpanded}
|
expanded={leftMeshExpanded}
|
||||||
onSettingsClick={() => setSettingsOpen(true)}
|
onExpandedChange={setLeftMeshExpanded}
|
||||||
onTerminalToggle={openSecureTerminalLauncher}
|
onSettingsClick={() => setSettingsOpen(true)}
|
||||||
launchRequest={meshChatLaunchRequest}
|
onTerminalToggle={openSecureTerminalLauncher}
|
||||||
/>
|
launchRequest={meshChatLaunchRequest}
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 3. SHODAN CONNECTOR (Bottom) */}
|
{/* 3. SHODAN CONNECTOR (Bottom) */}
|
||||||
<div className="contents" style={{ direction: 'ltr' }}>
|
{secondaryBootReady && (
|
||||||
<ShodanPanel
|
<div className="contents" style={{ direction: 'ltr' }}>
|
||||||
currentResults={shodanResults}
|
<ShodanPanel
|
||||||
onOpenSettings={() => setSettingsOpen(true)}
|
currentResults={shodanResults}
|
||||||
settingsOpen={settingsOpen}
|
onOpenSettings={() => setSettingsOpen(true)}
|
||||||
onResultsChange={(results, queryLabel) => {
|
settingsOpen={settingsOpen}
|
||||||
setShodanResults(results);
|
onResultsChange={(results, queryLabel) => {
|
||||||
setShodanQueryLabel(queryLabel);
|
setShodanResults(results);
|
||||||
setActiveLayers((prev) => ({ ...prev, shodan_overlay: results.length > 0 }));
|
setShodanQueryLabel(queryLabel);
|
||||||
}}
|
setActiveLayers((prev) => ({ ...prev, shodan_overlay: results.length > 0 }));
|
||||||
onSelectEntity={setSelectedEntity}
|
}}
|
||||||
onStyleChange={setShodanStyle}
|
onSelectEntity={setSelectedEntity}
|
||||||
isMinimized={leftShodanMinimized}
|
onStyleChange={setShodanStyle}
|
||||||
onMinimizedChange={setLeftShodanMinimized}
|
isMinimized={leftShodanMinimized}
|
||||||
/>
|
onMinimizedChange={setLeftShodanMinimized}
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 4. AI INTEL (Below Shodan) */}
|
{/* 4. AI INTEL (Below Shodan) */}
|
||||||
<div className="contents" style={{ direction: 'ltr' }}>
|
{secondaryBootReady && (
|
||||||
<AIIntelPanel
|
<div className="contents" style={{ direction: 'ltr' }}>
|
||||||
onFlyTo={handleFlyTo}
|
<AIIntelPanel
|
||||||
pinPlacementMode={pinPlacementMode}
|
onFlyTo={handleFlyTo}
|
||||||
onPinPlacementModeChange={setPinPlacementMode}
|
pinPlacementMode={pinPlacementMode}
|
||||||
/>
|
onPinPlacementModeChange={setPinPlacementMode}
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* LEFT SIDEBAR TOGGLE TAB — aligns with Data Layers section */}
|
{/* 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 */}
|
{/* GLOBAL TICKER REPLACES MARKETS PANEL - RENDERED OUTSIDE THIS DIV */}
|
||||||
|
|
||||||
{/* EVENT TIMELINE */}
|
{/* EVENT TIMELINE */}
|
||||||
<div className={`flex-shrink-0 ${rightFocusedPanel && rightFocusedPanel !== 'predictions' ? 'hidden' : ''}`}>
|
{secondaryBootReady && (
|
||||||
<ErrorBoundary name="TimelinePanel">
|
<div className={`flex-shrink-0 ${rightFocusedPanel && rightFocusedPanel !== 'predictions' ? 'hidden' : ''}`}>
|
||||||
<TimelinePanel />
|
<ErrorBoundary name="TimelinePanel">
|
||||||
</ErrorBoundary>
|
<TimelinePanel />
|
||||||
</div>
|
</ErrorBoundary>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* DATA FILTERS */}
|
{/* DATA FILTERS */}
|
||||||
<div className={`flex-shrink-0 ${rightFocusedPanel && rightFocusedPanel !== 'filters' ? 'hidden' : ''}`}>
|
<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 */}
|
{/* v0.4 CHANGELOG MODAL — shows once per version after onboarding */}
|
||||||
{!showOnboarding && showChangelog && (
|
{!showOnboarding && !showWarmupNotice && showChangelog && (
|
||||||
<ChangelogModal onClose={() => setShowChangelog(false)} />
|
<ChangelogModal onClose={() => setShowChangelog(false)} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,11 @@ export interface HlsVideoHandle {
|
|||||||
get paused(): boolean;
|
get paused(): boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HlsVideo = forwardRef<HlsVideoHandle, { url: string; className?: string; onError?: () => void }>(
|
const HlsVideo = forwardRef<
|
||||||
({ url, className, onError }, ref) => {
|
HlsVideoHandle,
|
||||||
|
{ url: string; className?: string; onError?: () => void; onLoaded?: () => void }
|
||||||
|
>(
|
||||||
|
({ url, className, onError, onLoaded }, ref) => {
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
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 }) => {
|
hls.on(Hls.Events.ERROR, (_e: unknown, data: { fatal?: boolean }) => {
|
||||||
if (data.fatal) onError?.();
|
if (data.fatal) onError?.();
|
||||||
});
|
});
|
||||||
|
hls.on(Hls.Events.MANIFEST_PARSED, () => onLoaded?.());
|
||||||
hls.loadSource(url);
|
hls.loadSource(url);
|
||||||
hls.attachMedia(video);
|
hls.attachMedia(video);
|
||||||
hlsInstance = hls;
|
hlsInstance = hls;
|
||||||
@@ -47,7 +51,7 @@ const HlsVideo = forwardRef<HlsVideoHandle, { url: string; className?: string; o
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
hlsInstance?.destroy();
|
hlsInstance?.destroy();
|
||||||
};
|
};
|
||||||
}, [url, onError]);
|
}, [url, onError, onLoaded]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<video
|
<video
|
||||||
@@ -56,6 +60,9 @@ const HlsVideo = forwardRef<HlsVideoHandle, { url: string; className?: string; o
|
|||||||
muted
|
muted
|
||||||
playsInline
|
playsInline
|
||||||
onError={() => onError?.()}
|
onError={() => onError?.()}
|
||||||
|
onCanPlay={() => onLoaded?.()}
|
||||||
|
onLoadedData={() => onLoaded?.()}
|
||||||
|
onPlaying={() => onLoaded?.()}
|
||||||
className={className}
|
className={className}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
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 { getNodeIdentity, getWormholeIdentityDescriptor } from '@/mesh/meshIdentity';
|
||||||
import {
|
import {
|
||||||
activateWormholeGatePersona,
|
activateWormholeGatePersona,
|
||||||
@@ -128,7 +128,6 @@ const SECTIONS = [
|
|||||||
{ name: 'EXCHANGE', icon: <ArrowRightLeft size={14} className="mr-2" /> },
|
{ name: 'EXCHANGE', icon: <ArrowRightLeft size={14} className="mr-2" /> },
|
||||||
{ name: 'PROFILE', icon: <User size={14} className="mr-2" /> },
|
{ name: 'PROFILE', icon: <User size={14} className="mr-2" /> },
|
||||||
{ name: 'MESSAGES', icon: <Mail 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 {
|
interface CommandHistory {
|
||||||
|
|||||||
@@ -32,12 +32,22 @@ export default function NetworkStats() {
|
|||||||
fetchInfonetNodeStatusSnapshot(true).catch(() => null),
|
fetchInfonetNodeStatusSnapshot(true).catch(() => null),
|
||||||
]);
|
]);
|
||||||
if (!alive) return;
|
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({
|
setStats({
|
||||||
meshtastic: Number(channelsRes?.total_live || channelsRes?.total_nodes || meshRes?.signal_counts?.meshtastic || 0),
|
meshtastic: Number(channelsRes?.total_live || channelsRes?.total_nodes || meshRes?.signal_counts?.meshtastic || 0),
|
||||||
aprs: Number(meshRes?.signal_counts?.aprs || 0),
|
aprs: Number(meshRes?.signal_counts?.aprs || 0),
|
||||||
infonetNodes: Number(infonet?.known_nodes || 0),
|
infonetNodes: visibleInfonetNodes,
|
||||||
infonetEvents: Number(infonet?.total_events || 0),
|
infonetEvents: Number(infonet?.total_events || 0),
|
||||||
syncPeers: Number(infonet?.bootstrap?.sync_peer_count || 0),
|
syncPeers: syncPeerCount,
|
||||||
nodeEnabled: Boolean(infonet?.node_enabled),
|
nodeEnabled: Boolean(infonet?.node_enabled),
|
||||||
syncOutcome: String(infonet?.sync_runtime?.last_outcome || 'offline').toLowerCase(),
|
syncOutcome: String(infonet?.sync_runtime?.last_outcome || 'offline').toLowerCase(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
import { X, Minus } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
import InfonetShell from './InfonetShell';
|
import InfonetShell from './InfonetShell';
|
||||||
|
|
||||||
interface InfonetTerminalProps {
|
interface InfonetTerminalProps {
|
||||||
@@ -55,13 +55,6 @@ export default function InfonetTerminal({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<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
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="p-1 text-gray-600 hover:text-red-400 transition-colors"
|
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 { computeNightPolygon } from '@/utils/solarTerminator';
|
||||||
import { darkStyle, lightStyle } from '@/components/map/styles/mapStyles';
|
import { darkStyle, lightStyle } from '@/components/map/styles/mapStyles';
|
||||||
import maplibregl from 'maplibre-gl';
|
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 WikiImage from '@/components/WikiImage';
|
||||||
import FishingDestinationRoute from '@/components/map/FishingDestinationRoute';
|
import FishingDestinationRoute from '@/components/map/FishingDestinationRoute';
|
||||||
import { useTheme } from '@/lib/ThemeContext';
|
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 = ({
|
const MaplibreViewer = ({
|
||||||
activeLayers,
|
activeLayers,
|
||||||
activeFilters,
|
activeFilters,
|
||||||
@@ -336,6 +345,7 @@ const MaplibreViewer = ({
|
|||||||
const data = useMemo(() => ({ ...coreData, ...extraData }) as DashboardData, [coreData, extraData]);
|
const data = useMemo(() => ({ ...coreData, ...extraData }) as DashboardData, [coreData, extraData]);
|
||||||
const mapRef = useRef<MapRef>(null);
|
const mapRef = useRef<MapRef>(null);
|
||||||
const mapInitRef = useRef(false);
|
const mapInitRef = useRef(false);
|
||||||
|
const [mapReady, setMapReady] = useState(false);
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const mapThemeStyle = useMemo<maplibregl.StyleSpecification>(
|
const mapThemeStyle = useMemo<maplibregl.StyleSpecification>(
|
||||||
() => (theme === 'light' ? lightStyle : darkStyle) as maplibregl.StyleSpecification,
|
() => (theme === 'light' ? lightStyle : darkStyle) as maplibregl.StyleSpecification,
|
||||||
@@ -905,15 +915,20 @@ const MaplibreViewer = ({
|
|||||||
// Load Images into the Map Style once loaded
|
// Load Images into the Map Style once loaded
|
||||||
const onMapLoad = useCallback((e: { target: maplibregl.Map }) => {
|
const onMapLoad = useCallback((e: { target: maplibregl.Map }) => {
|
||||||
initializeMap(e.target);
|
initializeMap(e.target);
|
||||||
|
setMapReady(true);
|
||||||
}, [initializeMap]);
|
}, [initializeMap]);
|
||||||
|
|
||||||
const onMapStyleData = useCallback((e: { target: maplibregl.Map }) => {
|
const onMapStyleData = useCallback((e: { target: maplibregl.Map }) => {
|
||||||
initializeMap(e.target);
|
initializeMap(e.target);
|
||||||
|
setMapReady(true);
|
||||||
}, [initializeMap]);
|
}, [initializeMap]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapRef.current?.getMap();
|
const map = mapRef.current?.getMap();
|
||||||
if (map) initializeMap(map);
|
if (map) {
|
||||||
|
initializeMap(map);
|
||||||
|
setMapReady(true);
|
||||||
|
}
|
||||||
}, [initializeMap, theme]);
|
}, [initializeMap, theme]);
|
||||||
|
|
||||||
// Build a set of tracked icao24s to exclude from other flight layers
|
// Build a set of tracked icao24s to exclude from other flight layers
|
||||||
@@ -1552,7 +1567,7 @@ const MaplibreViewer = ({
|
|||||||
}, [activeLayers.uap_sightings, activeLayers.wastewater, theme]);
|
}, [activeLayers.uap_sightings, activeLayers.wastewater, theme]);
|
||||||
|
|
||||||
// --- Imperative source updates: bypass React reconciliation for GeoJSON layers ---
|
// --- 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, 'commercial-flights', commFlightsGeoJSON);
|
||||||
useImperativeSource(mapForHook, 'private-flights', privFlightsGeoJSON);
|
useImperativeSource(mapForHook, 'private-flights', privFlightsGeoJSON);
|
||||||
useImperativeSource(mapForHook, 'private-jets', privJetsGeoJSON);
|
useImperativeSource(mapForHook, 'private-jets', privJetsGeoJSON);
|
||||||
@@ -5712,29 +5727,56 @@ const MaplibreViewer = ({
|
|||||||
<div className="px-5 pb-3">
|
<div className="px-5 pb-3">
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
{/* Oracle Score */}
|
{/* Oracle Score */}
|
||||||
<div className={`border rounded p-3 text-center ${oTierBg || 'bg-black/40 border-cyan-800/30'}`}>
|
<label className={`border rounded p-3 text-center transition-colors hover:border-white/40 cursor-pointer ${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>
|
<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'}`}>
|
<div className={`text-[28px] font-bold leading-none ${oTierColor || 'text-gray-500'}`}>
|
||||||
{oScore != null ? oScore.toFixed(1) : '—'}
|
{oScore != null ? oScore.toFixed(1) : '—'}
|
||||||
</div>
|
</div>
|
||||||
{oTier && <div className={`text-[10px] font-bold ${oTierColor} mt-1`}>{oTier}</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 */}
|
{/* Sentiment */}
|
||||||
<div className={`border rounded p-3 text-center ${sentBg || 'bg-black/40 border-cyan-800/30'}`}>
|
<label className={`border rounded p-3 text-center transition-colors hover:border-white/40 cursor-pointer ${sentBg || 'bg-black/40 border-cyan-800/30'}`}>
|
||||||
<div className="text-[9px] text-[var(--text-muted)] tracking-[0.15em] mb-1.5">SENTIMENT</div>
|
<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'}`}>
|
<div className={`text-[28px] font-bold leading-none ${sentColor || 'text-gray-500'}`}>
|
||||||
{sent != null ? <>{sentArrow} {sent > 0 ? '+' : ''}{sent.toFixed(2)}</> : '—'}
|
{sent != null ? <>{sentArrow} {sent > 0 ? '+' : ''}{sent.toFixed(2)}</> : '—'}
|
||||||
</div>
|
</div>
|
||||||
{sentLabel && <div className={`text-[10px] font-bold ${sentColor} mt-1`}>{sentLabel}</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 */}
|
{/* 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'}`}>
|
<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'}`}>
|
||||||
<div className="text-[9px] text-[var(--text-muted)] tracking-[0.15em] mb-1.5">RISK LEVEL</div>
|
<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-[28px] font-bold leading-none ${threatColor}`}>{rs}/10</div>
|
||||||
<div className={`text-[10px] font-bold ${threatColor} mt-1`}>
|
<div className={`text-[10px] font-bold ${threatColor} mt-1`}>
|
||||||
{rs >= 9 ? 'CRITICAL' : rs >= 7 ? 'HIGH' : rs >= 4 ? 'MEDIUM' : 'LOW'}
|
{rs >= 9 ? 'CRITICAL' : rs >= 7 ? 'HIGH' : rs >= 4 ? 'MEDIUM' : 'LOW'}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -5742,8 +5784,20 @@ const MaplibreViewer = ({
|
|||||||
{pred && pred.consensus_pct != null && (
|
{pred && pred.consensus_pct != null && (
|
||||||
<div className="px-5 pb-3">
|
<div className="px-5 pb-3">
|
||||||
<div className="bg-purple-950/30 border border-purple-500/40 rounded p-4">
|
<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">
|
<div className="flex items-center justify-between gap-3 mb-2">
|
||||||
PREDICTION MARKET ANALYSIS
|
<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>
|
||||||
<div className="text-[14px] text-purple-200 font-bold leading-snug mb-3">
|
<div className="text-[14px] text-purple-200 font-bold leading-snug mb-3">
|
||||||
"{pred.title}"
|
"{pred.title}"
|
||||||
@@ -5760,10 +5814,16 @@ const MaplibreViewer = ({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex gap-6 text-[11px]">
|
<div className="flex gap-6 text-[11px]">
|
||||||
{pred.polymarket_pct != null && (
|
{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-purple-400/70">Polymarket</span>
|
||||||
<span className="text-white font-bold text-[13px]">{pred.polymarket_pct}%</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 && (
|
{pred.kalshi_pct != null && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -5834,7 +5894,7 @@ const MaplibreViewer = ({
|
|||||||
onClick={() => window.open(item.link, '_blank', 'noopener,noreferrer')}
|
onClick={() => window.open(item.link, '_blank', 'noopener,noreferrer')}
|
||||||
className={`${threatColor} hover:text-white text-[12px] font-bold underline underline-offset-2 cursor-pointer`}
|
className={`${threatColor} hover:text-white text-[12px] font-bold underline underline-offset-2 cursor-pointer`}
|
||||||
>
|
>
|
||||||
VIEW FULL REPORT ↗
|
GO TO ARTICLE ↗
|
||||||
</button>
|
</button>
|
||||||
) : <span />}
|
) : <span />}
|
||||||
<button
|
<button
|
||||||
@@ -5902,6 +5962,7 @@ const MaplibreViewer = ({
|
|||||||
return (
|
return (
|
||||||
<CctvFullscreenModal
|
<CctvFullscreenModal
|
||||||
url={url}
|
url={url}
|
||||||
|
rawUrl={rawUrl}
|
||||||
mediaType={mt}
|
mediaType={mt}
|
||||||
isVideo={isVideo}
|
isVideo={isVideo}
|
||||||
cameraName={cameraName}
|
cameraName={cameraName}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
'use client';
|
'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 { AlertTriangle, Play, Pause } from 'lucide-react';
|
||||||
import HlsVideo, { type HlsVideoHandle } from '@/components/HlsVideo';
|
import HlsVideo, { type HlsVideoHandle } from '@/components/HlsVideo';
|
||||||
|
|
||||||
export interface CctvFullscreenModalProps {
|
export interface CctvFullscreenModalProps {
|
||||||
url: string;
|
url: string;
|
||||||
|
rawUrl?: string;
|
||||||
mediaType: string;
|
mediaType: string;
|
||||||
isVideo: boolean;
|
isVideo: boolean;
|
||||||
cameraName: string;
|
cameraName: string;
|
||||||
@@ -16,6 +17,7 @@ export interface CctvFullscreenModalProps {
|
|||||||
|
|
||||||
export function CctvFullscreenModal({
|
export function CctvFullscreenModal({
|
||||||
url,
|
url,
|
||||||
|
rawUrl = '',
|
||||||
mediaType,
|
mediaType,
|
||||||
isVideo,
|
isVideo,
|
||||||
cameraName,
|
cameraName,
|
||||||
@@ -25,8 +27,60 @@ export function CctvFullscreenModal({
|
|||||||
}: CctvFullscreenModalProps) {
|
}: CctvFullscreenModalProps) {
|
||||||
const [paused, setPaused] = useState(false);
|
const [paused, setPaused] = useState(false);
|
||||||
const [mediaError, setMediaError] = useState(false);
|
const [mediaError, setMediaError] = useState(false);
|
||||||
|
const [mediaLoaded, setMediaLoaded] = useState(false);
|
||||||
|
const [sourceIndex, setSourceIndex] = useState(0);
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const hlsRef = useRef<HlsVideoHandle>(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(() => {
|
const togglePlay = useCallback(() => {
|
||||||
if (mediaType === 'hls') {
|
if (mediaType === 'hls') {
|
||||||
@@ -176,17 +230,21 @@ export function CctvFullscreenModal({
|
|||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{url ? (
|
{activeUrl ? (
|
||||||
<>
|
<>
|
||||||
{mediaType === 'video' && !mediaError && (
|
{mediaType === 'video' && !mediaError && (
|
||||||
<video
|
<video
|
||||||
|
key={activeUrl}
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
src={url}
|
src={activeUrl}
|
||||||
autoPlay
|
autoPlay
|
||||||
loop
|
loop
|
||||||
muted
|
muted
|
||||||
playsInline
|
playsInline
|
||||||
onError={() => setMediaError(true)}
|
onError={handleMediaFailure}
|
||||||
|
onCanPlay={handleMediaReady}
|
||||||
|
onLoadedData={handleMediaReady}
|
||||||
|
onPlaying={handleMediaReady}
|
||||||
style={{
|
style={{
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
maxHeight: 'calc(100vh - 260px)',
|
maxHeight: 'calc(100vh - 260px)',
|
||||||
@@ -197,15 +255,18 @@ export function CctvFullscreenModal({
|
|||||||
)}
|
)}
|
||||||
{mediaType === 'hls' && !mediaError && (
|
{mediaType === 'hls' && !mediaError && (
|
||||||
<HlsVideo
|
<HlsVideo
|
||||||
|
key={activeUrl}
|
||||||
ref={hlsRef}
|
ref={hlsRef}
|
||||||
url={url}
|
url={activeUrl}
|
||||||
onError={() => setMediaError(true)}
|
onError={handleMediaFailure}
|
||||||
className=""
|
onLoaded={handleMediaReady}
|
||||||
|
className="max-w-full max-h-[calc(100vh-260px)] object-contain"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{mediaType === 'mjpeg' && (
|
{mediaType === 'mjpeg' && (
|
||||||
<img
|
<img
|
||||||
src={url}
|
key={activeUrl}
|
||||||
|
src={activeUrl}
|
||||||
alt="MJPEG Feed"
|
alt="MJPEG Feed"
|
||||||
style={{
|
style={{
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
@@ -213,14 +274,14 @@ export function CctvFullscreenModal({
|
|||||||
objectFit: 'contain',
|
objectFit: 'contain',
|
||||||
filter: 'contrast(1.25) saturate(0.5)',
|
filter: 'contrast(1.25) saturate(0.5)',
|
||||||
}}
|
}}
|
||||||
onError={(e) => {
|
onError={handleMediaFailure}
|
||||||
(e.target as HTMLImageElement).style.display = 'none';
|
onLoad={handleMediaReady}
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{(mediaType === 'image' || mediaType === 'satellite') && (
|
{(mediaType === 'image' || mediaType === 'satellite') && (
|
||||||
<img
|
<img
|
||||||
src={url}
|
key={activeUrl}
|
||||||
|
src={activeUrl}
|
||||||
alt="CCTV Feed"
|
alt="CCTV Feed"
|
||||||
style={{
|
style={{
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
@@ -228,10 +289,8 @@ export function CctvFullscreenModal({
|
|||||||
objectFit: 'contain',
|
objectFit: 'contain',
|
||||||
filter: 'contrast(1.25) saturate(0.5)',
|
filter: 'contrast(1.25) saturate(0.5)',
|
||||||
}}
|
}}
|
||||||
onError={(e) => {
|
onError={handleMediaFailure}
|
||||||
const target = e.target as HTMLImageElement;
|
onLoad={handleMediaReady}
|
||||||
target.style.display = 'none';
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -239,7 +298,7 @@ export function CctvFullscreenModal({
|
|||||||
{mediaError && (
|
{mediaError && (
|
||||||
<div style={{ fontSize: 11, color: 'rgba(239,68,68,0.7)', fontFamily: 'monospace', letterSpacing: '0.15em', textAlign: 'center', padding: 40 }}>
|
<div style={{ fontSize: 11, color: 'rgba(239,68,68,0.7)', fontFamily: 'monospace', letterSpacing: '0.15em', textAlign: 'center', padding: 40 }}>
|
||||||
FEED UNAVAILABLE<br />
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -329,10 +388,10 @@ export function CctvFullscreenModal({
|
|||||||
{cameraName}
|
{cameraName}
|
||||||
</span>
|
</span>
|
||||||
<div style={{ display: 'flex', gap: 10 }}>
|
<div style={{ display: 'flex', gap: 10 }}>
|
||||||
{url && (
|
{activeUrl && (
|
||||||
<>
|
<>
|
||||||
<a
|
<a
|
||||||
href={url}
|
href={rawUrl || activeUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
style={{
|
style={{
|
||||||
@@ -354,7 +413,7 @@ export function CctvFullscreenModal({
|
|||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(url);
|
await navigator.clipboard.writeText(rawUrl || activeUrl);
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
|||||||
identityWizardStatus,
|
identityWizardStatus,
|
||||||
setIdentityWizardStatus,
|
setIdentityWizardStatus,
|
||||||
meshQuickStatus,
|
meshQuickStatus,
|
||||||
|
meshSessionActive,
|
||||||
publicMeshAddress,
|
publicMeshAddress,
|
||||||
meshView,
|
meshView,
|
||||||
setMeshView,
|
setMeshView,
|
||||||
@@ -119,6 +120,7 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
|||||||
// Identity
|
// Identity
|
||||||
identity,
|
identity,
|
||||||
publicIdentity,
|
publicIdentity,
|
||||||
|
hasStoredPublicLaneIdentity,
|
||||||
hasPublicLaneIdentity,
|
hasPublicLaneIdentity,
|
||||||
hasId,
|
hasId,
|
||||||
shouldShowIdentityWarning,
|
shouldShowIdentityWarning,
|
||||||
@@ -255,6 +257,7 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
|||||||
openChat,
|
openChat,
|
||||||
handleCreatePublicIdentity,
|
handleCreatePublicIdentity,
|
||||||
handleQuickCreatePublicIdentity,
|
handleQuickCreatePublicIdentity,
|
||||||
|
handleActivatePublicMeshSession,
|
||||||
handleLeaveWormholeForPublicMesh,
|
handleLeaveWormholeForPublicMesh,
|
||||||
handleResetPublicIdentity,
|
handleResetPublicIdentity,
|
||||||
handleBootstrapPrivateIdentity,
|
handleBootstrapPrivateIdentity,
|
||||||
@@ -324,6 +327,40 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
|||||||
}
|
}
|
||||||
void handleRequestAccess(targetId);
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -1120,16 +1157,25 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] font-mono text-[var(--text-muted)] truncate">
|
<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>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto styled-scrollbar px-3 py-1.5 border-l-2 border-cyan-800/25">
|
<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]">
|
<div className="text-[12px] font-mono text-[var(--text-muted)] text-center py-4 leading-[1.65]">
|
||||||
No messages from {meshRegion} / {meshChannel}
|
No messages from {meshRegion} / {meshChannel}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{meshView === 'inbox' && (
|
{meshSessionActive && meshView === 'inbox' && (
|
||||||
<>
|
<>
|
||||||
{!publicMeshAddress && (
|
{!publicMeshAddress && (
|
||||||
<div className="text-[12px] font-mono text-[var(--text-muted)] text-center py-4 leading-[1.65]">
|
<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
|
? meshDirectTarget
|
||||||
? `→ MESH / TO ${meshDirectTarget.toUpperCase()}`
|
? `→ MESH / TO ${meshDirectTarget.toUpperCase()}`
|
||||||
: `→ MESH / ${meshRegion} / ${meshChannel}`
|
: `→ MESH / ${meshRegion} / ${meshChannel}`
|
||||||
: '→ MESH LOCKED'
|
: hasStoredPublicLaneIdentity
|
||||||
|
? '→ MESH OFF'
|
||||||
|
: '→ MESH LOCKED'
|
||||||
: activeTab === 'dms' && secureDmBlocked
|
: activeTab === 'dms' && secureDmBlocked
|
||||||
? '→ DEAD DROP LOCKED'
|
? '→ DEAD DROP LOCKED'
|
||||||
: dmView === 'chat' && selectedContact
|
: dmView === 'chat' && selectedContact
|
||||||
@@ -2068,10 +2116,7 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
|||||||
: 'text-green-300/70'
|
: 'text-green-300/70'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{meshQuickStatus?.text ||
|
{meshActivationText}
|
||||||
(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.')}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-2 px-3 pb-2 pt-1">
|
<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>
|
</button>
|
||||||
) : activeTab === 'meshtastic' && !hasPublicLaneIdentity ? (
|
) : activeTab === 'meshtastic' && !hasPublicLaneIdentity ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={handleMeshActivationAction}
|
||||||
if (publicMeshBlockedByWormhole) {
|
|
||||||
void handleLeaveWormholeForPublicMesh();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
void handleQuickCreatePublicIdentity();
|
|
||||||
}}
|
|
||||||
disabled={identityWizardBusy}
|
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"
|
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]">
|
<span className="inline-flex items-center gap-2 text-sm font-mono tracking-[0.2em]">
|
||||||
<Radio size={11} />
|
<Radio size={11} />
|
||||||
{identityWizardBusy
|
{meshActivationLabel}
|
||||||
? 'GETTING MESH KEY'
|
|
||||||
: publicMeshBlockedByWormhole
|
|
||||||
? 'TURN OFF WORMHOLE FOR MESH'
|
|
||||||
: 'GET MESH KEY'}
|
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[12px] font-mono text-green-300/70">
|
<span className="text-[12px] font-mono text-green-300/70">
|
||||||
{identityWizardBusy
|
{meshActivationSideLabel}
|
||||||
? 'WORKING...'
|
|
||||||
: publicMeshBlockedByWormhole
|
|
||||||
? 'AUTO FIX'
|
|
||||||
: 'ONE TAP'}
|
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
) : activeTab === 'meshtastic' && meshDirectTarget ? (
|
) : activeTab === 'meshtastic' && meshDirectTarget ? (
|
||||||
@@ -2375,8 +2406,8 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
|||||||
CURRENT STATE
|
CURRENT STATE
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 gap-1 text-[13px] font-mono text-[var(--text-secondary)] leading-[1.5]">
|
<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 key: {hasPublicLaneIdentity ? 'active' : hasStoredPublicLaneIdentity ? 'saved / off' : 'not issued'}</div>
|
||||||
<div>Public mesh address: {hasPublicLaneIdentity && publicMeshAddress ? publicMeshAddress.toUpperCase() : 'not ready'}</div>
|
<div>Public mesh address: {publicMeshAddress ? publicMeshAddress.toUpperCase() : 'not ready'}</div>
|
||||||
<div>Wormhole lane: {wormholeEnabled && wormholeReadyState ? 'active' : wormholeEnabled ? 'starting' : 'off'}</div>
|
<div>Wormhole lane: {wormholeEnabled && wormholeReadyState ? 'active' : wormholeEnabled ? 'starting' : 'off'}</div>
|
||||||
<div>Wormhole descriptor: {wormholeDescriptor?.nodeId || 'not cached yet'}</div>
|
<div>Wormhole descriptor: {wormholeDescriptor?.nodeId || 'not cached yet'}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2385,6 +2416,10 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
|||||||
<div className="grid grid-cols-1 gap-2">
|
<div className="grid grid-cols-1 gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (hasStoredPublicLaneIdentity) {
|
||||||
|
void handleActivatePublicMeshSession();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (publicMeshBlockedByWormhole) {
|
if (publicMeshBlockedByWormhole) {
|
||||||
void handleLeaveWormholeForPublicMesh();
|
void handleLeaveWormholeForPublicMesh();
|
||||||
return;
|
return;
|
||||||
@@ -2396,12 +2431,16 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
|||||||
>
|
>
|
||||||
{hasPublicLaneIdentity
|
{hasPublicLaneIdentity
|
||||||
? 'MESH KEY ACTIVE'
|
? 'MESH KEY ACTIVE'
|
||||||
|
: hasStoredPublicLaneIdentity
|
||||||
|
? 'TURN ON MESH'
|
||||||
: publicMeshBlockedByWormhole
|
: publicMeshBlockedByWormhole
|
||||||
? 'TURN OFF WORMHOLE FOR MESH'
|
? 'TURN OFF WORMHOLE FOR MESH'
|
||||||
: 'GET MESH KEY'}
|
: 'GET MESH KEY'}
|
||||||
<div className="mt-1 text-[13px] text-green-200/70 normal-case tracking-normal leading-[1.45]">
|
<div className="mt-1 text-[13px] text-green-200/70 normal-case tracking-normal leading-[1.45]">
|
||||||
{hasPublicLaneIdentity
|
{hasPublicLaneIdentity
|
||||||
? 'Your public mesh key is already live for posting.'
|
? '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
|
: publicMeshBlockedByWormhole
|
||||||
? 'One tap turns Wormhole off and mints a separate public mesh key.'
|
? 'One tap turns Wormhole off and mints a separate public mesh key.'
|
||||||
: 'One tap for a working mesh key and address.'}
|
: 'One tap for a working mesh key and address.'}
|
||||||
|
|||||||
@@ -313,6 +313,7 @@ export function useMeshChatController({
|
|||||||
const [identityWizardBusy, setIdentityWizardBusy] = useState(false);
|
const [identityWizardBusy, setIdentityWizardBusy] = useState(false);
|
||||||
const [identityWizardStatus, setIdentityWizardStatus] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
|
const [identityWizardStatus, setIdentityWizardStatus] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
|
||||||
const [meshQuickStatus, setMeshQuickStatus] = 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 [publicMeshAddress, setPublicMeshAddress] = useState('');
|
||||||
const [meshView, setMeshView] = useState<'channel' | 'inbox'>('channel');
|
const [meshView, setMeshView] = useState<'channel' | 'inbox'>('channel');
|
||||||
const [meshDirectTarget, setMeshDirectTarget] = useState('');
|
const [meshDirectTarget, setMeshDirectTarget] = useState('');
|
||||||
@@ -328,12 +329,14 @@ export function useMeshChatController({
|
|||||||
const [recentPrivateFallbackReason, setRecentPrivateFallbackReason] = useState('');
|
const [recentPrivateFallbackReason, setRecentPrivateFallbackReason] = useState('');
|
||||||
const [unresolvedSenderSealCount, setUnresolvedSenderSealCount] = useState(0);
|
const [unresolvedSenderSealCount, setUnresolvedSenderSealCount] = useState(0);
|
||||||
const [privacyProfile, setPrivacyProfile] = useState<'default' | 'high'>('default');
|
const [privacyProfile, setPrivacyProfile] = useState<'default' | 'high'>('default');
|
||||||
const publicIdentity = clientHydrated ? getNodeIdentity() : null;
|
const storedPublicIdentity = clientHydrated ? getNodeIdentity() : null;
|
||||||
const hasPublicLaneIdentity = clientHydrated && Boolean(publicIdentity) && hasSovereignty();
|
const hasStoredPublicLaneIdentity = clientHydrated && Boolean(storedPublicIdentity) && hasSovereignty();
|
||||||
|
const publicIdentity = meshSessionActive ? storedPublicIdentity : null;
|
||||||
|
const hasPublicLaneIdentity = meshSessionActive && hasStoredPublicLaneIdentity;
|
||||||
const hasId = Boolean(identity) && (hasSovereignty() || wormholeEnabled);
|
const hasId = Boolean(identity) && (hasSovereignty() || wormholeEnabled);
|
||||||
const shouldShowIdentityWarning = activeTab !== 'meshtastic' && !hasId;
|
const shouldShowIdentityWarning = activeTab !== 'meshtastic' && !hasId;
|
||||||
const privateInfonetReady = wormholeEnabled && wormholeReadyState;
|
const privateInfonetReady = wormholeEnabled && wormholeReadyState;
|
||||||
const publicMeshBlockedByWormhole = wormholeEnabled && wormholeReadyState && !hasPublicLaneIdentity;
|
const publicMeshBlockedByWormhole = wormholeEnabled || wormholeReadyState;
|
||||||
const dmSendQueue = useRef<(() => Promise<void>)[]>([]);
|
const dmSendQueue = useRef<(() => Promise<void>)[]>([]);
|
||||||
const dmSendTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const dmSendTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const streamEnabledForSelectedGateRef = useRef(false);
|
const streamEnabledForSelectedGateRef = useRef(false);
|
||||||
@@ -365,6 +368,13 @@ export function useMeshChatController({
|
|||||||
setClientHydrated(true);
|
setClientHydrated(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!clientHydrated) return;
|
||||||
|
setMeshSessionActive(false);
|
||||||
|
setMeshMessages([]);
|
||||||
|
setMeshQuickStatus(null);
|
||||||
|
}, [clientHydrated]);
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() =>
|
() =>
|
||||||
subscribeGateSessionStreamStatus((nextStatus) => {
|
subscribeGateSessionStreamStatus((nextStatus) => {
|
||||||
@@ -450,6 +460,8 @@ export function useMeshChatController({
|
|||||||
setSecureModeCached(enabled);
|
setSecureModeCached(enabled);
|
||||||
setWormholeEnabled(enabled);
|
setWormholeEnabled(enabled);
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
|
setMeshSessionActive(false);
|
||||||
|
setMeshMessages([]);
|
||||||
purgeBrowserContactGraph();
|
purgeBrowserContactGraph();
|
||||||
void hydrateWormholeContacts();
|
void hydrateWormholeContacts();
|
||||||
}
|
}
|
||||||
@@ -515,7 +527,7 @@ export function useMeshChatController({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
const senderId = publicIdentity?.nodeId || '';
|
const senderId = storedPublicIdentity?.nodeId || '';
|
||||||
if (!senderId || !globalThis.crypto?.subtle) {
|
if (!senderId || !globalThis.crypto?.subtle) {
|
||||||
setPublicMeshAddress('');
|
setPublicMeshAddress('');
|
||||||
return;
|
return;
|
||||||
@@ -530,7 +542,7 @@ export function useMeshChatController({
|
|||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
}, [publicIdentity?.nodeId]);
|
}, [storedPublicIdentity?.nodeId]);
|
||||||
|
|
||||||
const flushDmQueue = useCallback(async () => {
|
const flushDmQueue = useCallback(async () => {
|
||||||
const queue = dmSendQueue.current.splice(0);
|
const queue = dmSendQueue.current.splice(0);
|
||||||
@@ -1138,10 +1150,10 @@ export function useMeshChatController({
|
|||||||
[meshMessages, mutedUsers],
|
[meshMessages, mutedUsers],
|
||||||
);
|
);
|
||||||
const meshInboxMessages = useMemo(() => {
|
const meshInboxMessages = useMemo(() => {
|
||||||
if (!publicMeshAddress) return [];
|
if (!meshSessionActive || !publicMeshAddress) return [];
|
||||||
const target = publicMeshAddress.toLowerCase();
|
const target = publicMeshAddress.toLowerCase();
|
||||||
return filteredMeshMessages.filter((m) => String(m.to || '').toLowerCase() === target);
|
return filteredMeshMessages.filter((m) => String(m.to || '').toLowerCase() === target);
|
||||||
}, [filteredMeshMessages, publicMeshAddress]);
|
}, [filteredMeshMessages, meshSessionActive, publicMeshAddress]);
|
||||||
|
|
||||||
// ─── InfoNet Polling ─────────────────────────────────────────────────────
|
// ─── InfoNet Polling ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -1735,7 +1747,7 @@ export function useMeshChatController({
|
|||||||
|
|
||||||
// ─── Meshtastic Channel Discovery ──────────────────────────────────────
|
// ─── Meshtastic Channel Discovery ──────────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!expanded || activeTab !== 'meshtastic') return;
|
if (!expanded || activeTab !== 'meshtastic' || !meshSessionActive) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const fetchChannels = async () => {
|
const fetchChannels = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -1794,12 +1806,12 @@ export function useMeshChatController({
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
clearInterval(iv);
|
clearInterval(iv);
|
||||||
};
|
};
|
||||||
}, [expanded, activeTab, meshRegion]);
|
}, [expanded, activeTab, meshRegion, meshSessionActive]);
|
||||||
|
|
||||||
// ─── Meshtastic Polling ──────────────────────────────────────────────────
|
// ─── Meshtastic Polling ──────────────────────────────────────────────────
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!expanded || activeTab !== 'meshtastic') return;
|
if (!expanded || activeTab !== 'meshtastic' || !meshSessionActive) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const poll = async () => {
|
const poll = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -1823,7 +1835,13 @@ export function useMeshChatController({
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
clearInterval(iv);
|
clearInterval(iv);
|
||||||
};
|
};
|
||||||
}, [expanded, activeTab, meshRegion, meshChannel, meshView]);
|
}, [expanded, activeTab, meshRegion, meshChannel, meshView, meshSessionActive]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (meshSessionActive) return;
|
||||||
|
setMeshMessages([]);
|
||||||
|
setMeshQuickStatus(null);
|
||||||
|
}, [meshSessionActive]);
|
||||||
|
|
||||||
// ─── DM Polling ──────────────────────────────────────────────────────────
|
// ─── DM Polling ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -2305,7 +2323,8 @@ export function useMeshChatController({
|
|||||||
|
|
||||||
const handleSend = async () => {
|
const handleSend = async () => {
|
||||||
const msg = inputValue.trim();
|
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 cooldownMs = activeTab === 'dms' ? 0 : 30_000;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -2392,13 +2411,15 @@ export function useMeshChatController({
|
|||||||
]);
|
]);
|
||||||
setGateReplyContext(null);
|
setGateReplyContext(null);
|
||||||
} else if (activeTab === 'meshtastic') {
|
} else if (activeTab === 'meshtastic') {
|
||||||
if (!publicIdentity || !hasSovereignty()) {
|
if (!meshSessionActive || !publicIdentity || !hasSovereignty()) {
|
||||||
setInputValue(msg);
|
setInputValue(msg);
|
||||||
setLastSendTime(0);
|
setLastSendTime(0);
|
||||||
setSendError('public mesh identity needed');
|
setSendError(meshSessionActive ? 'public mesh identity needed' : 'meshchat is off');
|
||||||
openIdentityWizard({
|
openIdentityWizard({
|
||||||
type: 'err',
|
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);
|
setTimeout(() => setSendError(''), 4000);
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
@@ -3915,7 +3936,7 @@ export function useMeshChatController({
|
|||||||
wormholeEnabled &&
|
wormholeEnabled &&
|
||||||
wormholeReadyState &&
|
wormholeReadyState &&
|
||||||
!selectedGateAccessReady) ||
|
!selectedGateAccessReady) ||
|
||||||
((activeTab === 'infonet' || activeTab === 'meshtastic') && anonymousPublicBlocked) ||
|
(activeTab === 'infonet' && anonymousPublicBlocked) ||
|
||||||
(activeTab === 'dms' &&
|
(activeTab === 'dms' &&
|
||||||
(dmView !== 'chat' ||
|
(dmView !== 'chat' ||
|
||||||
!selectedContact ||
|
!selectedContact ||
|
||||||
@@ -3959,16 +3980,36 @@ export function useMeshChatController({
|
|||||||
[inputDisabled],
|
[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(
|
const createPublicMeshIdentity = useCallback(
|
||||||
async ({ closeWizardOnSuccess }: { closeWizardOnSuccess: boolean }) => {
|
async ({ closeWizardOnSuccess }: { closeWizardOnSuccess: boolean }) => {
|
||||||
setIdentityWizardBusy(true);
|
setIdentityWizardBusy(true);
|
||||||
setIdentityWizardStatus(null);
|
setIdentityWizardStatus(null);
|
||||||
try {
|
try {
|
||||||
|
await disableWormholeForPublicMesh();
|
||||||
const nextIdentity = await generateNodeKeys();
|
const nextIdentity = await generateNodeKeys();
|
||||||
const nextAddress = await derivePublicMeshAddress(nextIdentity.nodeId).catch(() => '');
|
const nextAddress = await derivePublicMeshAddress(nextIdentity.nodeId).catch(() => '');
|
||||||
const readyAddress = (nextAddress || nextIdentity.nodeId).toUpperCase();
|
const readyAddress = (nextAddress || nextIdentity.nodeId).toUpperCase();
|
||||||
setIdentity(nextIdentity);
|
setIdentity(nextIdentity);
|
||||||
setPublicMeshAddress(nextAddress || nextIdentity.nodeId);
|
setPublicMeshAddress(nextAddress || nextIdentity.nodeId);
|
||||||
|
setMeshSessionActive(true);
|
||||||
|
setMeshMessages([]);
|
||||||
setSendError('');
|
setSendError('');
|
||||||
const successText = `Mesh key ready. Address ${readyAddress} is live for this testnet session.`;
|
const successText = `Mesh key ready. Address ${readyAddress} is live for this testnet session.`;
|
||||||
setIdentityWizardStatus({
|
setIdentityWizardStatus({
|
||||||
@@ -3997,7 +4038,7 @@ export function useMeshChatController({
|
|||||||
setIdentityWizardBusy(false);
|
setIdentityWizardBusy(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[],
|
[disableWormholeForPublicMesh],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCreatePublicIdentity = useCallback(async () => {
|
const handleCreatePublicIdentity = useCallback(async () => {
|
||||||
@@ -4013,6 +4054,45 @@ export function useMeshChatController({
|
|||||||
}
|
}
|
||||||
}, [createPublicMeshIdentity]);
|
}, [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 handleReplyToMeshAddress = useCallback((address: string) => {
|
||||||
const target = String(address || '').trim();
|
const target = String(address || '').trim();
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
@@ -4023,36 +4103,16 @@ export function useMeshChatController({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleLeaveWormholeForPublicMesh = useCallback(async () => {
|
const handleLeaveWormholeForPublicMesh = useCallback(async () => {
|
||||||
setIdentityWizardBusy(true);
|
const result = hasStoredPublicLaneIdentity
|
||||||
setIdentityWizardStatus(null);
|
? await handleActivatePublicMeshSession()
|
||||||
setMeshQuickStatus(null);
|
: await createPublicMeshIdentity({ closeWizardOnSuccess: false });
|
||||||
try {
|
const status = { type: result.ok ? 'ok' as const : 'err' as const, text: result.text };
|
||||||
await leaveWormhole();
|
setIdentityWizardStatus(status);
|
||||||
setWormholeEnabled(false);
|
setMeshQuickStatus(status);
|
||||||
setWormholeReadyState(false);
|
if (result.ok) {
|
||||||
setWormholeRnsReady(false);
|
window.setTimeout(() => setIdentityWizardOpen(false), 900);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}, [createPublicMeshIdentity]);
|
}, [createPublicMeshIdentity, handleActivatePublicMeshSession, hasStoredPublicLaneIdentity]);
|
||||||
|
|
||||||
const handleResetPublicIdentity = useCallback(async () => {
|
const handleResetPublicIdentity = useCallback(async () => {
|
||||||
if (wormholeEnabled && wormholeReadyState) {
|
if (wormholeEnabled && wormholeReadyState) {
|
||||||
@@ -4065,8 +4125,11 @@ export function useMeshChatController({
|
|||||||
setIdentityWizardBusy(true);
|
setIdentityWizardBusy(true);
|
||||||
setIdentityWizardStatus(null);
|
setIdentityWizardStatus(null);
|
||||||
try {
|
try {
|
||||||
|
setMeshSessionActive(false);
|
||||||
|
setMeshMessages([]);
|
||||||
await clearBrowserIdentityState();
|
await clearBrowserIdentityState();
|
||||||
setIdentity(null);
|
setIdentity(null);
|
||||||
|
setPublicMeshAddress('');
|
||||||
setContacts({});
|
setContacts({});
|
||||||
setSelectedContact('');
|
setSelectedContact('');
|
||||||
setDmMessages([]);
|
setDmMessages([]);
|
||||||
@@ -4091,6 +4154,8 @@ export function useMeshChatController({
|
|||||||
}, [wormholeEnabled, wormholeReadyState]);
|
}, [wormholeEnabled, wormholeReadyState]);
|
||||||
|
|
||||||
const handleBootstrapPrivateIdentity = useCallback(async () => {
|
const handleBootstrapPrivateIdentity = useCallback(async () => {
|
||||||
|
setMeshSessionActive(false);
|
||||||
|
setMeshMessages([]);
|
||||||
if (wormholeEnabled && wormholeReadyState) {
|
if (wormholeEnabled && wormholeReadyState) {
|
||||||
setIdentityWizardStatus({
|
setIdentityWizardStatus({
|
||||||
type: 'ok',
|
type: 'ok',
|
||||||
@@ -4175,6 +4240,7 @@ export function useMeshChatController({
|
|||||||
identityWizardStatus,
|
identityWizardStatus,
|
||||||
setIdentityWizardStatus,
|
setIdentityWizardStatus,
|
||||||
meshQuickStatus,
|
meshQuickStatus,
|
||||||
|
meshSessionActive,
|
||||||
publicMeshAddress,
|
publicMeshAddress,
|
||||||
meshView,
|
meshView,
|
||||||
setMeshView,
|
setMeshView,
|
||||||
@@ -4183,6 +4249,7 @@ export function useMeshChatController({
|
|||||||
// Identity
|
// Identity
|
||||||
identity,
|
identity,
|
||||||
publicIdentity,
|
publicIdentity,
|
||||||
|
hasStoredPublicLaneIdentity,
|
||||||
hasPublicLaneIdentity,
|
hasPublicLaneIdentity,
|
||||||
hasId,
|
hasId,
|
||||||
shouldShowIdentityWarning,
|
shouldShowIdentityWarning,
|
||||||
@@ -4320,6 +4387,7 @@ export function useMeshChatController({
|
|||||||
openChat,
|
openChat,
|
||||||
handleCreatePublicIdentity,
|
handleCreatePublicIdentity,
|
||||||
handleQuickCreatePublicIdentity,
|
handleQuickCreatePublicIdentity,
|
||||||
|
handleActivatePublicMeshSession,
|
||||||
handleLeaveWormholeForPublicMesh,
|
handleLeaveWormholeForPublicMesh,
|
||||||
handleResetPublicIdentity,
|
handleResetPublicIdentity,
|
||||||
handleBootstrapPrivateIdentity,
|
handleBootstrapPrivateIdentity,
|
||||||
|
|||||||
@@ -364,6 +364,7 @@ function summarizeNodePeer(peerUrl?: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function describeBootstrapState(snapshot?: InfonetNodeStatusSnapshot | null): string {
|
function describeBootstrapState(snapshot?: InfonetNodeStatusSnapshot | null): string {
|
||||||
|
if (snapshot && !snapshot.node_enabled) return 'READY / DISABLED';
|
||||||
const bootstrap = snapshot?.bootstrap;
|
const bootstrap = snapshot?.bootstrap;
|
||||||
if (!bootstrap) return 'LOCAL ONLY';
|
if (!bootstrap) return 'LOCAL ONLY';
|
||||||
if (bootstrap.manifest_loaded) {
|
if (bootstrap.manifest_loaded) {
|
||||||
@@ -376,6 +377,7 @@ function describeBootstrapState(snapshot?: InfonetNodeStatusSnapshot | null): st
|
|||||||
}
|
}
|
||||||
|
|
||||||
function describeSyncOutcome(snapshot?: InfonetNodeStatusSnapshot | null): string {
|
function describeSyncOutcome(snapshot?: InfonetNodeStatusSnapshot | null): string {
|
||||||
|
if (snapshot && !snapshot.node_enabled) return 'OFF - click NODE to activate';
|
||||||
const sync = snapshot?.sync_runtime;
|
const sync = snapshot?.sync_runtime;
|
||||||
if (!sync) return 'IDLE';
|
if (!sync) return 'IDLE';
|
||||||
const outcome = String(sync.last_outcome || 'idle').trim().toLowerCase();
|
const outcome = String(sync.last_outcome || 'idle').trim().toLowerCase();
|
||||||
@@ -433,6 +435,12 @@ function buildNodeRuntimeLines(snapshot: InfonetNodeStatusSnapshot): TermLine[]
|
|||||||
type: 'error',
|
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' });
|
lines.push({ text: '', type: 'dim' });
|
||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
@@ -5945,7 +5953,7 @@ export default function MeshTerminal({ isOpen, launchToken = 0, onClose, onDmCou
|
|||||||
PARTICIPANT NODE
|
PARTICIPANT NODE
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-sm leading-5 text-slate-400">
|
<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>
|
</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">
|
<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 { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { X, ExternalLink, Key, Shield, Radar, Globe, Satellite, Ship, Radio } from 'lucide-react';
|
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 = [
|
const API_GUIDES = [
|
||||||
{
|
{
|
||||||
@@ -17,7 +19,7 @@ const API_GUIDES = [
|
|||||||
'Create a free account at opensky-network.org',
|
'Create a free account at opensky-network.org',
|
||||||
'Go to Dashboard → OAuth → Create Client',
|
'Go to Dashboard → OAuth → Create Client',
|
||||||
'Copy your Client ID and Client Secret',
|
'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',
|
url: 'https://opensky-network.org/index.php?option=com_users&view=registration',
|
||||||
color: 'cyan',
|
color: 'cyan',
|
||||||
@@ -31,7 +33,7 @@ const API_GUIDES = [
|
|||||||
'Register at aisstream.io',
|
'Register at aisstream.io',
|
||||||
'Navigate to your API Keys page',
|
'Navigate to your API Keys page',
|
||||||
'Generate a new API key',
|
'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',
|
url: 'https://aisstream.io/authenticate',
|
||||||
color: 'blue',
|
color: 'blue',
|
||||||
@@ -59,18 +61,59 @@ const OnboardingModal = React.memo(function OnboardingModal({
|
|||||||
onOpenSettings,
|
onOpenSettings,
|
||||||
}: OnboardingModalProps) {
|
}: OnboardingModalProps) {
|
||||||
const [step, setStep] = useState(0);
|
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 = () => {
|
const handleDismiss = () => {
|
||||||
localStorage.setItem(STORAGE_KEY, 'true');
|
localStorage.setItem(STORAGE_KEY, 'true');
|
||||||
|
localStorage.setItem(LEGACY_STORAGE_KEY, 'true');
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenSettings = () => {
|
const handleOpenSettings = () => {
|
||||||
localStorage.setItem(STORAGE_KEY, 'true');
|
localStorage.setItem(STORAGE_KEY, 'true');
|
||||||
|
localStorage.setItem(LEGACY_STORAGE_KEY, 'true');
|
||||||
onClose();
|
onClose();
|
||||||
onOpenSettings();
|
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 (
|
return (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{/* Backdrop */}
|
{/* Backdrop */}
|
||||||
@@ -123,7 +166,7 @@ const OnboardingModal = React.memo(function OnboardingModal({
|
|||||||
|
|
||||||
{/* Step Indicators */}
|
{/* Step Indicators */}
|
||||||
<div className="flex gap-2 px-6 pt-4">
|
<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
|
<button
|
||||||
key={label}
|
key={label}
|
||||||
onClick={() => setStep(i)}
|
onClick={() => setStep(i)}
|
||||||
@@ -140,36 +183,23 @@ const OnboardingModal = React.memo(function OnboardingModal({
|
|||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 overflow-y-auto styled-scrollbar p-6">
|
<div className="flex-1 overflow-y-auto styled-scrollbar p-6">
|
||||||
{step === 0 && (
|
{step === 1 && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="text-center py-4">
|
<div className="text-center py-4">
|
||||||
<div className="text-lg font-bold tracking-[0.3em] text-[var(--text-primary)] font-mono mb-2">
|
<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>
|
</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,
|
Real-time OSINT dashboard aggregating 12+ live intelligence sources. Flights,
|
||||||
ships, satellites, earthquakes, conflicts, and more — all on one map.
|
ships, satellites, earthquakes, conflicts, and more — all on one map.
|
||||||
</p>
|
</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>
|
||||||
|
|
||||||
<div className="bg-yellow-950/20 border border-yellow-500/20 p-4">
|
<div className="hidden">
|
||||||
<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="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<Globe size={14} className="text-green-500 mt-0.5 flex-shrink-0" />
|
<Globe size={14} className="text-green-500 mt-0.5 flex-shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
@@ -216,8 +246,69 @@ const OnboardingModal = React.memo(function OnboardingModal({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step === 1 && (
|
{step === 0 && (
|
||||||
<div className="space-y-4">
|
<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) => (
|
{API_GUIDES.map((api) => (
|
||||||
<div
|
<div
|
||||||
key={api.name}
|
key={api.name}
|
||||||
@@ -272,7 +363,8 @@ const OnboardingModal = React.memo(function OnboardingModal({
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<p className="text-sm text-[var(--text-secondary)] font-mono mb-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
|
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>
|
</p>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
{FREE_SOURCES.map((src) => (
|
{FREE_SOURCES.map((src) => (
|
||||||
|
|||||||
@@ -120,6 +120,9 @@ interface EnvMeta {
|
|||||||
env_path_writable: boolean;
|
env_path_writable: boolean;
|
||||||
env_example_path: string;
|
env_example_path: string;
|
||||||
env_example_path_exists: boolean;
|
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> = {
|
const WEIGHT_LABELS: Record<number, string> = {
|
||||||
@@ -493,10 +496,12 @@ const SettingsPanel = React.memo(function SettingsPanel({
|
|||||||
}, [adminKey, refreshAdminSession]);
|
}, [adminKey, refreshAdminSession]);
|
||||||
|
|
||||||
// --- API Keys state ---
|
// --- API Keys state ---
|
||||||
// API keys are intentionally NOT editable in-app. The panel is read-only and
|
// API keys are write-only in-app. Values are sent once to the local backend,
|
||||||
// tells the user where the .env file lives so they can edit it directly.
|
// stored server-side, and never returned to the browser.
|
||||||
// This keeps secrets off the wire and out of the browser process.
|
|
||||||
const [apis, setApis] = useState<ApiEntry[]>([]);
|
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>>(
|
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
|
||||||
new Set(['Aviation', 'Maritime']),
|
new Set(['Aviation', 'Maritime']),
|
||||||
);
|
);
|
||||||
@@ -535,7 +540,9 @@ const SettingsPanel = React.memo(function SettingsPanel({
|
|||||||
|
|
||||||
const fetchKeys = useCallback(async () => {
|
const fetchKeys = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setApis(await controlPlaneJson<ApiEntry[]>('/api/settings/api-keys'));
|
setApis(await controlPlaneJson<ApiEntry[]>('/api/settings/api-keys', {
|
||||||
|
requireAdminSession: false,
|
||||||
|
}));
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
await handleProtectedSettingsError(e);
|
await handleProtectedSettingsError(e);
|
||||||
@@ -543,6 +550,40 @@ const SettingsPanel = React.memo(function SettingsPanel({
|
|||||||
}
|
}
|
||||||
}, [handleProtectedSettingsError]);
|
}, [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 () => {
|
const fetchEnvMeta = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/settings/api-keys/meta');
|
const res = await fetch('/api/settings/api-keys/meta');
|
||||||
@@ -663,10 +704,10 @@ const SettingsPanel = React.memo(function SettingsPanel({
|
|||||||
}
|
}
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const ready = await refreshAdminSession();
|
const ready = await refreshAdminSession();
|
||||||
|
await fetchKeys();
|
||||||
if (ready) {
|
if (ready) {
|
||||||
await Promise.all([fetchKeys(), fetchFeeds()]);
|
await fetchFeeds();
|
||||||
} else {
|
} else {
|
||||||
setApis([]);
|
|
||||||
setFeeds([]);
|
setFeeds([]);
|
||||||
setFeedsDirty(false);
|
setFeedsDirty(false);
|
||||||
}
|
}
|
||||||
@@ -713,12 +754,13 @@ const SettingsPanel = React.memo(function SettingsPanel({
|
|||||||
}, [onClose, wormholeEnabled, wormholeSaving, wormholeStatus]);
|
}, [onClose, wormholeEnabled, wormholeSaving, wormholeStatus]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen || !adminSessionReady) return;
|
if (!isOpen) return;
|
||||||
if (activeTab === 'api-keys') {
|
if (activeTab === 'api-keys') {
|
||||||
void fetchKeys();
|
void fetchKeys();
|
||||||
void fetchEnvMeta();
|
void fetchEnvMeta();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!adminSessionReady) return;
|
||||||
if (activeTab === 'news-feeds') {
|
if (activeTab === 'news-feeds') {
|
||||||
void fetchFeeds();
|
void fetchFeeds();
|
||||||
}
|
}
|
||||||
@@ -2166,18 +2208,21 @@ const SettingsPanel = React.memo(function SettingsPanel({
|
|||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<Shield size={12} className="text-cyan-500 mt-0.5 flex-shrink-0" />
|
<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">
|
<p className="text-sm text-[var(--text-secondary)] font-mono leading-relaxed">
|
||||||
API keys are stored locally in the backend{' '}
|
API keys are saved locally by this backend. Values are write-only: the app
|
||||||
<span className="text-cyan-400">.env</span> file. Keys marked with{' '}
|
stores the key and shows CONFIGURED, but it never reads the secret back into
|
||||||
<Key size={8} className="inline text-yellow-500" /> are required for full
|
the browser. Keys marked with{' '}
|
||||||
functionality. Public APIs need no key.
|
<Key size={8} className="inline text-yellow-500" /> unlock the richest live
|
||||||
|
aircraft and vessel feeds.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{envMeta && (
|
{envMeta && (
|
||||||
<div className="pl-5 text-[12px] font-mono text-[var(--text-muted)] leading-relaxed space-y-0.5">
|
<div className="pl-5 text-[12px] font-mono text-[var(--text-muted)] leading-relaxed space-y-0.5">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-cyan-500/70">.env path:</span>{' '}
|
<span className="text-cyan-500/70">local key store:</span>{' '}
|
||||||
<span className="text-cyan-300 break-all select-all">{envMeta.env_path}</span>{' '}
|
<span className="text-cyan-300 break-all select-all">
|
||||||
{envMeta.env_path_exists ? (
|
{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-green-400/80">[exists]</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-amber-400/80">[will be created on first save]</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{apiKeyMsg && (
|
||||||
|
<div
|
||||||
|
className={`pl-5 text-sm font-mono ${
|
||||||
|
apiKeyMsg.type === 'ok' ? 'text-green-300' : 'text-red-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{apiKeyMsg.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* API List */}
|
{/* API List */}
|
||||||
@@ -2288,9 +2342,9 @@ const SettingsPanel = React.memo(function SettingsPanel({
|
|||||||
{api.description}
|
{api.description}
|
||||||
</p>
|
</p>
|
||||||
{api.has_key && (
|
{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 ? (
|
{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">
|
<span className="px-2 py-0.5 border border-green-500/40 bg-green-950/20 text-green-300 tracking-wider">
|
||||||
CONFIGURED
|
CONFIGURED
|
||||||
</span>
|
</span>
|
||||||
@@ -2299,23 +2353,53 @@ const SettingsPanel = React.memo(function SettingsPanel({
|
|||||||
<span className="text-cyan-300 select-all break-all">
|
<span className="text-cyan-300 select-all break-all">
|
||||||
{api.env_key}
|
{api.env_key}
|
||||||
</span>{' '}
|
</span>{' '}
|
||||||
in the .env file (path shown above) and restart the backend.
|
Enter a replacement below if you need to rotate it.
|
||||||
</span>
|
</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">
|
<span className="px-2 py-0.5 border border-amber-500/40 bg-amber-950/20 text-amber-300 tracking-wider">
|
||||||
NOT CONFIGURED
|
NOT CONFIGURED
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[var(--text-muted)]">
|
<span className="text-[var(--text-muted)]">
|
||||||
add{' '}
|
Save {api.env_key} here to enable this source.
|
||||||
<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.
|
|
||||||
</span>
|
</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>
|
||||||
)}
|
)}
|
||||||
</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 = () => {
|
const pushWhenReady = () => {
|
||||||
let attemptsRemaining = 20;
|
let attemptsRemaining = 150;
|
||||||
|
|
||||||
const tryPush = () => {
|
const tryPush = () => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
@@ -62,6 +62,7 @@ export function useImperativeSource(
|
|||||||
pushWhenReady();
|
pushWhenReady();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
rawMap.on('load', handleStyleData);
|
||||||
rawMap.on('styledata', handleStyleData);
|
rawMap.on('styledata', handleStyleData);
|
||||||
|
|
||||||
// Skip redundant writes for unchanged references, but keep the styledata
|
// Skip redundant writes for unchanged references, but keep the styledata
|
||||||
@@ -73,6 +74,7 @@ export function useImperativeSource(
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
rawMap.off('load', handleStyleData);
|
||||||
rawMap.off('styledata', handleStyleData);
|
rawMap.off('styledata', handleStyleData);
|
||||||
if (timerRef.current) clearTimeout(timerRef.current);
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
|
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ interface AgentAction {
|
|||||||
export function useAgentActions(
|
export function useAgentActions(
|
||||||
onShowImage: (coords: { lat: number; lng: number }) => void,
|
onShowImage: (coords: { lat: number; lng: number }) => void,
|
||||||
onFlyTo?: (coords: { lat: number; lng: number; zoom?: number }) => void,
|
onFlyTo?: (coords: { lat: number; lng: number; zoom?: number }) => void,
|
||||||
|
enabled = true,
|
||||||
) {
|
) {
|
||||||
const onShowImageRef = useRef(onShowImage);
|
const onShowImageRef = useRef(onShowImage);
|
||||||
onShowImageRef.current = onShowImage;
|
onShowImageRef.current = onShowImage;
|
||||||
@@ -70,9 +71,10 @@ export function useAgentActions(
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Poll every 3 seconds — lightweight endpoint, ~50 bytes when empty
|
// Poll every 3 seconds — lightweight endpoint, ~50 bytes when empty
|
||||||
|
if (!enabled) return;
|
||||||
const interval = setInterval(poll, 3000);
|
const interval = setInterval(poll, 3000);
|
||||||
// Initial poll on mount
|
// Initial poll on mount
|
||||||
poll();
|
poll();
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [poll]);
|
}, [enabled, poll]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ type FastDataProbe = {
|
|||||||
ships?: unknown[];
|
ships?: unknown[];
|
||||||
sigint?: unknown[];
|
sigint?: unknown[];
|
||||||
cctv?: unknown[];
|
cctv?: unknown[];
|
||||||
|
news?: unknown[];
|
||||||
|
threat_level?: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
function hasMeaningfulFastData(json: FastDataProbe): boolean {
|
function hasMeaningfulFastData(json: FastDataProbe): boolean {
|
||||||
@@ -100,11 +102,37 @@ export function useDataPolling() {
|
|||||||
_slowEtagRef = slowEtag;
|
_slowEtagRef = slowEtag;
|
||||||
|
|
||||||
let hasData = false;
|
let hasData = false;
|
||||||
|
let fetchedStartupFastPayload = false;
|
||||||
let fastTimerId: ReturnType<typeof setTimeout> | null = null;
|
let fastTimerId: ReturnType<typeof setTimeout> | null = null;
|
||||||
let slowTimerId: ReturnType<typeof setTimeout> | null = null;
|
let slowTimerId: ReturnType<typeof setTimeout> | null = null;
|
||||||
const fastAbortRef = { current: null as AbortController | null };
|
const fastAbortRef = { current: null as AbortController | null };
|
||||||
const slowAbortRef = { 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 () => {
|
const fetchFastData = async () => {
|
||||||
if (fastTimerId) {
|
if (fastTimerId) {
|
||||||
clearTimeout(fastTimerId);
|
clearTimeout(fastTimerId);
|
||||||
@@ -116,9 +144,11 @@ export function useDataPolling() {
|
|||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
fastAbortRef.current = controller;
|
fastAbortRef.current = controller;
|
||||||
try {
|
try {
|
||||||
|
const useStartupPayload = !fetchedStartupFastPayload && !fastEtag.current;
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
if (fastEtag.current) headers['If-None-Match'] = fastEtag.current;
|
if (!useStartupPayload && fastEtag.current) headers['If-None-Match'] = fastEtag.current;
|
||||||
const res = await fetch(`${API_BASE}/api/live-data/fast`, {
|
const url = `${API_BASE}/api/live-data/fast${useStartupPayload ? '?initial=1' : ''}`;
|
||||||
|
const res = await fetch(url, {
|
||||||
headers,
|
headers,
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
@@ -129,7 +159,10 @@ export function useDataPolling() {
|
|||||||
}
|
}
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
setStoreBackendStatus('connected');
|
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();
|
const json = await res.json();
|
||||||
mergeData(json);
|
mergeData(json);
|
||||||
if (hasMeaningfulFastData(json)) hasData = true;
|
if (hasMeaningfulFastData(json)) hasData = true;
|
||||||
@@ -141,7 +174,7 @@ export function useDataPolling() {
|
|||||||
'name' in e &&
|
'name' in e &&
|
||||||
(e as { name?: string }).name === 'AbortError';
|
(e as { name?: string }).name === 'AbortError';
|
||||||
if (!aborted) {
|
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');
|
setStoreBackendStatus('disconnected');
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -177,7 +210,7 @@ export function useDataPolling() {
|
|||||||
'name' in e &&
|
'name' in e &&
|
||||||
(e as { name?: string }).name === 'AbortError';
|
(e as { name?: string }).name === 'AbortError';
|
||||||
if (!aborted) {
|
if (!aborted) {
|
||||||
console.error("Failed fetching slow live data", e);
|
console.warn("Slow live data fetch will retry after runtime is reachable", e);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (slowAbortRef.current === controller) {
|
if (slowAbortRef.current === controller) {
|
||||||
@@ -191,7 +224,8 @@ export function useDataPolling() {
|
|||||||
const scheduleNext = (tier: 'fast' | 'slow') => {
|
const scheduleNext = (tier: 'fast' | 'slow') => {
|
||||||
if (tier === 'fast') {
|
if (tier === 'fast') {
|
||||||
const delay = hasData ? 15000 : 3000; // 3s startup retry → 15s steady state
|
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 {
|
} else {
|
||||||
const delay = hasData ? 120000 : 5000; // 5s startup retry → 120s steady state
|
const delay = hasData ? 120000 : 5000; // 5s startup retry → 120s steady state
|
||||||
slowTimerId = setTimeout(fetchSlowData, delay);
|
slowTimerId = setTimeout(fetchSlowData, delay);
|
||||||
@@ -208,8 +242,12 @@ export function useDataPolling() {
|
|||||||
};
|
};
|
||||||
window.addEventListener(LAYER_TOGGLE_EVENT, onLayerToggle);
|
window.addEventListener(LAYER_TOGGLE_EVENT, onLayerToggle);
|
||||||
|
|
||||||
fetchFastData();
|
void (async () => {
|
||||||
fetchSlowData();
|
await fetchCriticalBootstrap();
|
||||||
|
fetchFastData();
|
||||||
|
// Let the bootstrap/fast payload paint before competing with the slow tier.
|
||||||
|
slowTimerId = setTimeout(fetchSlowData, 5000);
|
||||||
|
})();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener(LAYER_TOGGLE_EVENT, onLayerToggle);
|
window.removeEventListener(LAYER_TOGGLE_EVENT, onLayerToggle);
|
||||||
|
|||||||
@@ -387,7 +387,7 @@ export async function refreshSnapshotList(): Promise<void> {
|
|||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
updateTimelineFromSnapshots(sortSnapshots(json.snapshots || []));
|
updateTimelineFromSnapshots(sortSnapshots(json.snapshots || []));
|
||||||
} catch (e) {
|
} 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 || {} });
|
setState({ hourlyIndex: json.hours || {} });
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} 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;
|
kalshi_pct: number | null;
|
||||||
consensus_pct: number | null;
|
consensus_pct: number | null;
|
||||||
match_score: number;
|
match_score: number;
|
||||||
|
slug?: string;
|
||||||
|
kalshi_ticker?: string;
|
||||||
} | null;
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -824,6 +826,8 @@ export interface DashboardData {
|
|||||||
cctv_total?: number;
|
cctv_total?: number;
|
||||||
satnogs_total?: number;
|
satnogs_total?: number;
|
||||||
tinygs_total?: number;
|
tinygs_total?: number;
|
||||||
|
bootstrap_ready?: boolean;
|
||||||
|
bootstrap_payload?: boolean;
|
||||||
sigint_totals?: {
|
sigint_totals?: {
|
||||||
total?: number;
|
total?: number;
|
||||||
meshtastic?: 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 (
|
for /f "tokens=5" %%a in ('netstat -ano ^| findstr ":3000 "') do (
|
||||||
taskkill /F /PID %%a >nul 2>&1
|
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
|
:: Brief pause to let OS release the ports
|
||||||
timeout /t 1 /nobreak >nul
|
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...
|
echo [!] WARNING: Port 3000 is still occupied! Waiting 3s for OS cleanup...
|
||||||
timeout /t 3 /nobreak >nul
|
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.
|
echo [*] Ports clear.
|
||||||
:: ────────────────────────────────────────────────────────────────────
|
:: ────────────────────────────────────────────────────────────────────
|
||||||
@@ -225,6 +233,40 @@ if not exist "node_modules\ws" (
|
|||||||
call npm ci --omit=dev --silent
|
call npm ci --omit=dev --silent
|
||||||
)
|
)
|
||||||
echo [*] Backend Node.js dependencies OK.
|
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%"
|
cd /d "%ROOT%"
|
||||||
|
|
||||||
echo.
|
echo.
|
||||||
@@ -257,7 +299,8 @@ echo ===================================================
|
|||||||
echo (Press Ctrl+C to stop)
|
echo (Press Ctrl+C to stop)
|
||||||
echo.
|
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.
|
||||||
echo ===================================================
|
echo ===================================================
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
# Graceful shutdown: kill all child processes on exit/interrupt
|
# Graceful shutdown: stop child processes without signaling the parent shell.
|
||||||
trap 'kill 0' EXIT SIGINT SIGTERM
|
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 "======================================================="
|
||||||
echo " S H A D O W B R O K E R - macOS / Linux Start "
|
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..."
|
echo "[*] Clearing zombie processes..."
|
||||||
|
|
||||||
# Kill anything listening on ports 8000 or 3000
|
# 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
|
if command -v lsof &> /dev/null; then
|
||||||
PIDS=$(lsof -ti :$PORT 2>/dev/null)
|
PIDS=$(lsof -ti :$PORT 2>/dev/null)
|
||||||
elif command -v ss &> /dev/null; then
|
elif command -v ss &> /dev/null; then
|
||||||
@@ -82,6 +88,7 @@ done
|
|||||||
# Kill orphaned uvicorn and ais_proxy processes
|
# Kill orphaned uvicorn and ais_proxy processes
|
||||||
pkill -9 -f "uvicorn.*main:app" 2>/dev/null
|
pkill -9 -f "uvicorn.*main:app" 2>/dev/null
|
||||||
pkill -9 -f "ais_proxy" 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
|
# Brief pause for OS to release ports
|
||||||
sleep 1
|
sleep 1
|
||||||
@@ -192,6 +199,34 @@ if [ ! -d "node_modules/ws" ]; then
|
|||||||
fi
|
fi
|
||||||
echo "[*] Backend Node.js dependencies OK."
|
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"
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
@@ -223,4 +258,4 @@ echo "======================================================="
|
|||||||
echo " (Press Ctrl+C to stop)"
|
echo " (Press Ctrl+C to stop)"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
npm run dev
|
node scripts/dev-all.cjs
|
||||||
|
|||||||
Reference in New Issue
Block a user