Merge branch 'BigBodyCobain:main' into feature/locate-auto-zoom

This commit is contained in:
Ethan Morchy
2026-08-11 15:03:13 -07:00
committed by GitHub
16 changed files with 333 additions and 19 deletions
+4
View File
@@ -10,6 +10,10 @@ OPENSKY_CLIENT_ID=
OPENSKY_CLIENT_SECRET=
AIS_API_KEY=
# Optional AISHub REST backup when AISStream is silent/offline (same ships layer, ~20 min cadence).
# Free registration at https://www.aishub.net/api — paste the account username (not a password).
# AISHUB_USERNAME=
# Global Fishing Watch — fishing vessel activity events (Fishing Activity map layer).
# Free API token from https://globalfishingwatch.org/our-apis/tokens
# Without this the fishing_activity layer stays empty.
+1
View File
@@ -168,6 +168,7 @@ def _gate_privileged_access_status_snapshot_local() -> dict[str, Any]:
# ---------------------------------------------------------------------------
_SECRET_VARS = [
"AIS_API_KEY",
"AISHUB_USERNAME",
"OPENSKY_CLIENT_ID",
"OPENSKY_CLIENT_SECRET",
"LTA_ACCOUNT_KEY",
+6
View File
@@ -111,6 +111,12 @@ async def health_check(request: Request):
ais_status = ais_proxy_status() or {}
except Exception:
ais_status = {}
try:
from services.fetchers.aishub_fallback import aishub_fallback_enabled
ais_status["aishub_configured"] = bool(aishub_fallback_enabled())
except Exception:
ais_status["aishub_configured"] = bool(str(os.environ.get("AISHUB_USERNAME", "") or "").strip())
if ais_status.get("degraded_tls") and top_status == "ok":
# Don't override a worse top-level status if SLOs already failed,
# but escalate ok -> degraded so the field surfaces in dashboards.
+6 -5
View File
@@ -137,15 +137,16 @@ def inject_layer_data(
tagged.append(entry)
with _data_lock:
existing = latest_data.get(layer)
if not isinstance(existing, list):
existing = []
current = latest_data.get(layer)
existing = list(current) if isinstance(current, list) else []
if mode == "replace":
existing = [e for e in existing if not e.get("_injected")]
existing.extend(tagged)
latest_data[layer] = existing
# Readers can hold references to published layer lists after releasing
# _data_lock. Build a fresh list and swap it atomically rather than
# mutating the published object in place with list.extend().
latest_data[layer] = [*existing, *tagged]
bump_data_version()
+9
View File
@@ -56,6 +56,15 @@ API_REGISTRY = [
"url": "https://aisstream.io/",
"required": True,
},
{
"id": "aishub_username",
"env_key": "AISHUB_USERNAME",
"name": "AISHub Username (backup)",
"description": "Free AISHub account username used as a slow REST backup when AISStream is silent or offline. Does not replace live AIS — polls about every 20 minutes into the same ships layer. Register at aishub.net/api.",
"category": "Maritime",
"url": "https://www.aishub.net/api",
"required": False,
},
{
"id": "gfw_api_token",
"env_key": "GFW_API_TOKEN",
+1
View File
@@ -16,6 +16,7 @@ class Settings(BaseSettings):
# Data sources
AIS_API_KEY: str = ""
AISHUB_USERNAME: str = "" # Optional AISHub REST backup when AISStream is silent
OPENSKY_CLIENT_ID: str = ""
OPENSKY_CLIENT_SECRET: str = ""
LTA_ACCOUNT_KEY: str = ""
+1
View File
@@ -46,6 +46,7 @@ _CRITICAL_WARN = {
_OPTIONAL = {
"AIS_API_KEY": "AIS vessel streaming (ships layer will be empty without it)",
"AISHUB_USERNAME": "AISHub REST backup when AISStream is silent (optional; free at aishub.net/api)",
"GFW_API_TOKEN": "Global Fishing Watch fishing-vessel activity (fishing_activity layer)",
"LTA_ACCOUNT_KEY": "Singapore LTA traffic cameras (CCTV layer)",
"PUBLIC_API_KEY": "Optional client auth for public endpoints (recommended for exposed deployments)",
@@ -0,0 +1,59 @@
"""Regression coverage for copy-on-write OpenClaw layer injection."""
from services import ai_intel_store
from services.fetchers import _store
def _publish_test_layer(monkeypatch, items):
published = list(items)
monkeypatch.setitem(_store.latest_data, "air_quality", published)
monkeypatch.setattr(_store, "bump_data_version", lambda: None)
return published
def test_append_does_not_mutate_previously_published_list(monkeypatch):
before = _publish_test_layer(monkeypatch, [{"id": "existing"}])
result = ai_intel_store.inject_layer_data(
"air_quality",
[{"id": "injected"}],
mode="append",
)
after = _store.latest_data["air_quality"]
assert result == {
"ok": True,
"layer": "air_quality",
"injected": 1,
"mode": "append",
}
assert after is not before
assert before == [{"id": "existing"}]
assert [item["id"] for item in after] == ["existing", "injected"]
assert after[-1]["_injected"] is True
assert after[-1]["_source"] == "user:openclaw"
def test_replace_does_not_mutate_previously_published_list(monkeypatch):
before = _publish_test_layer(
monkeypatch,
[
{"id": "native"},
{"id": "old-injected", "_injected": True},
],
)
result = ai_intel_store.inject_layer_data(
"air_quality",
[{"id": "new-injected"}],
mode="replace",
)
after = _store.latest_data["air_quality"]
assert result["ok"] is True
assert after is not before
assert before == [
{"id": "native"},
{"id": "old-injected", "_injected": True},
]
assert [item["id"] for item in after] == ["native", "new-injected"]
+13
View File
@@ -130,6 +130,7 @@ class TestHealthEndpointEscalation:
body = res.json()
assert body["ais_proxy"]["connected"] is False
assert body["ais_proxy"]["proxy_spawn_count"] == 5
assert "aishub_configured" in body["ais_proxy"]
# Without API_KEY this would stay "ok"; with it set + connected=false,
# we expect at least "degraded" (could be "error" if an SLO is also
# red, but never "ok").
@@ -138,6 +139,18 @@ class TestHealthEndpointEscalation:
f"got {body['status']!r}"
)
def test_health_reports_aishub_configured_flag(self, client, monkeypatch):
_reset_ais_module()
monkeypatch.setenv("AISHUB_USERNAME", "shadowbroker-test")
res = client.get("/api/health")
assert res.status_code == 200
assert res.json()["ais_proxy"]["aishub_configured"] is True
monkeypatch.delenv("AISHUB_USERNAME", raising=False)
res = client.get("/api/health")
assert res.status_code == 200
assert res.json()["ais_proxy"]["aishub_configured"] is False
def test_no_api_key_does_not_escalate(self, client, monkeypatch):
"""When AIS_API_KEY isn't set, the operator hasn't opted in. Don't
flag the system as degraded just because AIS isn't running — that's
@@ -0,0 +1,9 @@
from services.api_settings import ALLOWED_ENV_KEYS, API_REGISTRY
def test_aishub_username_is_in_api_registry():
entry = next((item for item in API_REGISTRY if item.get("env_key") == "AISHUB_USERNAME"), None)
assert entry is not None
assert entry["category"] == "Maritime"
assert entry["required"] is False
assert "AISHUB_USERNAME" in ALLOWED_ENV_KEYS
@@ -0,0 +1,63 @@
from __future__ import annotations
import json
from types import SimpleNamespace
from scripts.regen_duplicate_routes_baseline import (
build_baseline_payload,
collect_duplicate_routes,
write_baseline,
)
def _route(path: str, methods: set[str], module: str):
def endpoint():
return None
endpoint.__module__ = module
return SimpleNamespace(path=path, methods=methods, endpoint=endpoint)
def test_collect_duplicate_routes_matches_ci_guard_shape():
routes = [
_route("/api/layers", {"POST"}, "routers.data"),
_route("/api/layers", {"POST"}, "main"),
_route("/api/health", {"GET", "HEAD"}, "routers.health"),
_route("/api/health", {"GET", "HEAD"}, "main"),
_route("/api/only-once", {"GET"}, "routers.example"),
SimpleNamespace(path=None, methods={"GET"}, endpoint=lambda: None),
]
assert collect_duplicate_routes(routes) == {
"GET /api/health": ["main", "routers.health"],
"POST /api/layers": ["main", "routers.data"],
}
def test_build_baseline_payload_is_deterministic():
payload = build_baseline_payload(
{
"POST /z": ["routers.z", "main"],
"GET /a": ["routers.a", "main"],
}
)
assert list(payload["duplicates"]) == ["GET /a", "POST /z"]
assert payload["duplicates"]["GET /a"] == ["main", "routers.a"]
assert payload["_meta"]["issue"] == "#239"
assert payload["_meta"]["generated_with"] == (
"python -m scripts.regen_duplicate_routes_baseline"
)
def test_write_baseline_emits_stable_json(tmp_path):
output = tmp_path / "duplicate_routes_baseline.json"
duplicates = {"POST /api/layers": ["routers.data", "main"]}
payload = write_baseline(output, duplicates=duplicates)
assert json.loads(output.read_text(encoding="utf-8")) == payload
assert output.read_text(encoding="utf-8").endswith("\n")
assert payload["duplicates"] == {
"POST /api/layers": ["main", "routers.data"]
}
+1 -1
View File
@@ -994,7 +994,7 @@ export default function Dashboard() {
{/* AIS UPSTREAM OUTAGE BANNER — renders only when AIS is configured
but the WebSocket upstream is unreachable. Tells users the empty
ocean isn't their fault. */}
<AisUpstreamBanner />
<AisUpstreamBanner onOpenApiKeys={() => setSettingsOpen(true)} />
{/* ONBOARDING MODAL */}
{showOnboarding && (
+26 -10
View File
@@ -1,15 +1,18 @@
/**
* AisUpstreamBanner — visible notice that AIS ship data is unavailable
* because the upstream provider (AISStream) is offline.
* AisUpstreamBanner — visible notice that AISStream ship data is unavailable.
*
* Renders nothing when AIS is healthy or when AIS isn't configured at all.
* Mounted at the app shell level so users see it before they wonder why
* the ocean looks empty.
* When AISStream is silent, nudge operators toward the existing AISHub REST
* backup (Settings → API Keys) or confirm that backup is already active.
*/
import { useState } from 'react';
import { useAisUpstreamHealth } from '@/hooks/useAisUpstreamHealth';
export function AisUpstreamBanner() {
type AisUpstreamBannerProps = {
onOpenApiKeys?: () => void;
};
export function AisUpstreamBanner({ onOpenApiKeys }: AisUpstreamBannerProps = {}) {
const health = useAisUpstreamHealth();
const [dismissed, setDismissed] = useState(false);
@@ -29,6 +32,10 @@ export function AisUpstreamBanner() {
}
}
const detail = health.aishubConfigured
? `AISStream is silent (${stalenessLabel}). AISHub backup polling stays active on a slower cadence (~20 min) — live WebSocket traffic will resume when AISStream recovers.`
: `AISStream is silent (${stalenessLabel}). Add a free AISHub username under Settings → API Keys → Maritime for slow backup ship coverage while AISStream is down.`;
return (
<div
role="status"
@@ -38,12 +45,21 @@ export function AisUpstreamBanner() {
<div className="flex items-start gap-3">
<span aria-hidden className="mt-0.5 text-amber-300"></span>
<div className="flex-1">
<div className="font-semibold">Ship data temporarily unavailable</div>
<div className="text-xs opacity-90">
AISStream upstream is offline ({stalenessLabel}). The map will
refill once their service comes back online nothing is wrong
with your install.
<div className="font-semibold">
{health.aishubConfigured
? 'Live AIS offline — AISHub backup active'
: 'Ship data temporarily unavailable'}
</div>
<div className="text-xs opacity-90">{detail}</div>
{!health.aishubConfigured && onOpenApiKeys ? (
<button
type="button"
onClick={onOpenApiKeys}
className="mt-2 text-[11px] font-mono tracking-wide text-amber-100 underline underline-offset-2 hover:text-white"
>
Open API Keys
</button>
) : null}
</div>
<button
type="button"
+21 -3
View File
@@ -38,6 +38,20 @@ const API_GUIDES = [
url: 'https://aisstream.io/authenticate',
color: 'blue',
},
{
name: 'AISHub (backup)',
icon: <Ship size={14} className="text-blue-300" />,
required: false,
description:
'Slow REST backup for the ships layer when AISStream is silent or offline. Uses the same map layer on a ~20 minute cadence.',
steps: [
'Create a free account at aishub.net',
'Open the API page and note your username',
'Paste the username into Quick Local Setup above or Settings → API Keys → Maritime',
],
url: 'https://www.aishub.net/api',
color: 'blue',
},
{
name: 'Global Fishing Watch',
icon: <Ship size={14} className="text-teal-400" />,
@@ -79,6 +93,7 @@ const OnboardingModal = React.memo(function OnboardingModal({
OPENSKY_CLIENT_ID: '',
OPENSKY_CLIENT_SECRET: '',
AIS_API_KEY: '',
AISHUB_USERNAME: '',
GFW_API_TOKEN: '',
});
const [setupSaving, setSetupSaving] = useState(false);
@@ -129,6 +144,7 @@ const OnboardingModal = React.memo(function OnboardingModal({
OPENSKY_CLIENT_ID: '',
OPENSKY_CLIENT_SECRET: '',
AIS_API_KEY: '',
AISHUB_USERNAME: '',
GFW_API_TOKEN: '',
});
setSetupMsg({ type: 'ok', text: 'Keys saved locally. Restart or refresh feeds to use them.' });
@@ -577,9 +593,10 @@ const OnboardingModal = React.memo(function OnboardingModal({
</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. Global Fishing Watch
unlocks the fishing-activity layer. Paste them below or use Settings later;
secrets stay on the local backend.
useful immediately: live aircraft and vessel tracking. Optionally add an
AISHub username as a slow ships-layer backup when AISStream is silent.
Global Fishing Watch unlocks the fishing-activity layer. Paste them below
or use Settings later; secrets stay on the local backend.
</p>
</div>
</div>
@@ -599,6 +616,7 @@ const OnboardingModal = React.memo(function OnboardingModal({
['OPENSKY_CLIENT_ID', 'OpenSky Client ID'],
['OPENSKY_CLIENT_SECRET', 'OpenSky Client Secret'],
['AIS_API_KEY', 'AIS Stream API Key'],
['AISHUB_USERNAME', 'AISHub Username (optional backup)'],
['GFW_API_TOKEN', 'Global Fishing Watch API Token (optional)'],
].map(([key, label]) => (
<input
@@ -8,6 +8,10 @@
* banner can explain "AIS upstream is offline" instead of letting users
* wonder.
*
* When AISStream is silent, the backend can still fill ships via AISHub REST
* (`AISHUB_USERNAME`) on a slow cadence. ``aishubConfigured`` tells the banner
* whether to nudge the operator to add that backup or confirm it is active.
*
* The poll interval is intentionally relaxed (30s) — this is a low-urgency UX
* signal, not a real-time data feed. Backend already escalates top_status to
* "degraded" when AIS is configured-but-disconnected.
@@ -35,6 +39,8 @@ export interface AisUpstreamHealth {
* seen — we approximate it by requiring at least one spawn before
* declaring an outage. */
aisEnabled: boolean;
/** True when ``AISHUB_USERNAME`` is set so the REST backup can run. */
aishubConfigured: boolean;
}
const POLL_INTERVAL_MS = 30_000;
@@ -67,6 +73,7 @@ export function useAisUpstreamHealth(): AisUpstreamHealth | null {
degradedTls: Boolean(proxy.degraded_tls),
proxySpawnCount: spawns,
aisEnabled: spawns > 0,
aishubConfigured: Boolean(proxy.aishub_configured),
});
} catch {
// Backend unreachable — separate problem. Banner not relevant.
+106
View File
@@ -0,0 +1,106 @@
"""Regenerate the tolerated duplicate-route baseline used by issue #239 tests.
Run from the repository root with::
python -m scripts.regen_duplicate_routes_baseline
The command imports the backend application, inspects FastAPI's registered
routes, and rewrites ``backend/tests/data/duplicate_routes_baseline.json`` in a
deterministic order.
"""
from __future__ import annotations
import json
import sys
from collections import defaultdict
from collections.abc import Iterable
from pathlib import Path
from typing import Any
REPO_ROOT = Path(__file__).resolve().parents[1]
BACKEND_DIR = REPO_ROOT / "backend"
DEFAULT_BASELINE_PATH = BACKEND_DIR / "tests" / "data" / "duplicate_routes_baseline.json"
_BASELINE_NOTE = (
"Snapshot of currently-tolerated duplicate route registrations. The test in "
"test_no_new_duplicate_routes.py fails if any NEW (method, path) duplicate "
"appears outside this list. Removing entries (by actually deduping) is fine "
"and the test stays green. New entries here require explicit, reviewed updates."
)
def collect_duplicate_routes(routes: Iterable[Any]) -> dict[str, list[str]]:
"""Return duplicate ``METHOD /path`` registrations and their modules."""
by_key: dict[str, list[str]] = defaultdict(list)
for route in routes:
path = getattr(route, "path", None)
methods = getattr(route, "methods", None)
endpoint = getattr(route, "endpoint", None)
if not path or not methods or endpoint is None:
continue
module = str(getattr(endpoint, "__module__", "") or "")
for method in sorted(methods):
if method in {"HEAD", "OPTIONS"}:
continue
by_key[f"{method} {path}"].append(module)
return {
key: sorted(modules)
for key, modules in sorted(by_key.items())
if len(modules) > 1
}
def current_duplicates() -> dict[str, list[str]]:
"""Import the backend application and inspect its live route table."""
backend = str(BACKEND_DIR)
if backend not in sys.path:
sys.path.insert(0, backend)
import main
return collect_duplicate_routes(main.app.routes)
def build_baseline_payload(duplicates: dict[str, list[str]]) -> dict[str, Any]:
"""Build the canonical JSON payload written by the regeneration command."""
return {
"_meta": {
"issue": "#239",
"note": _BASELINE_NOTE,
"generated_with": "python -m scripts.regen_duplicate_routes_baseline",
},
"duplicates": {
key: sorted(modules)
for key, modules in sorted(duplicates.items())
},
}
def write_baseline(
path: Path = DEFAULT_BASELINE_PATH,
*,
duplicates: dict[str, list[str]] | None = None,
) -> dict[str, Any]:
"""Write the baseline and return the payload for callers and tests."""
payload = build_baseline_payload(
current_duplicates() if duplicates is None else duplicates
)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
return payload
def main() -> int:
payload = write_baseline()
print(
f"Wrote {len(payload['duplicates'])} duplicate route entries to "
f"{DEFAULT_BASELINE_PATH.relative_to(REPO_ROOT)}"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())