mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-08-09 12:16:01 +02:00
Let agents temporarily override native map layer visibility.
This commit is contained in:
@@ -381,6 +381,50 @@ async def api_refresh_layer_feed(request: Request, layer_id: str):
|
||||
return {"ok": True, "layer": updated}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Native map layer overrides — additive, transient, agent-driven.
|
||||
#
|
||||
# These are the DATA LAYERS toggles, not the pin layers above. An override
|
||||
# switches an overlay on without touching the operator's own toggle state, and
|
||||
# lapses on its own after the TTL. Agents holding an overlay open should re-PUT
|
||||
# on their refresh tick rather than sending a long TTL.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class LayerOverrideUpdate(BaseModel):
|
||||
layers: dict[str, bool]
|
||||
ttl_seconds: float = 300.0
|
||||
|
||||
|
||||
@router.put("/api/ai/layer-overrides", dependencies=[Depends(require_openclaw_or_local)])
|
||||
@limiter.limit("30/minute")
|
||||
async def put_layer_overrides(request: Request, body: LayerOverrideUpdate):
|
||||
"""Replace the override map. Returns which keys were accepted and ignored."""
|
||||
from services.fetchers._store import set_layer_overrides
|
||||
|
||||
accepted = set_layer_overrides(body.layers, body.ttl_seconds)
|
||||
ignored = sorted(set(body.layers) - set(accepted))
|
||||
return {"ok": True, "overrides": accepted, "ignored": ignored}
|
||||
|
||||
|
||||
@router.get("/api/ai/layer-overrides", dependencies=[Depends(require_openclaw_or_local)])
|
||||
@limiter.limit("60/minute")
|
||||
async def read_layer_overrides(request: Request):
|
||||
"""Return the overrides that are currently live."""
|
||||
from services.fetchers._store import get_layer_overrides
|
||||
|
||||
return {"ok": True, "overrides": get_layer_overrides()}
|
||||
|
||||
|
||||
@router.delete("/api/ai/layer-overrides", dependencies=[Depends(require_openclaw_or_local)])
|
||||
@limiter.limit("30/minute")
|
||||
async def delete_layer_overrides(request: Request):
|
||||
"""Drop all overrides, restoring the operator's own layer state."""
|
||||
from services.fetchers._store import clear_layer_overrides
|
||||
|
||||
clear_layer_overrides()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Agent Actions endpoint — frontend polls this for UI commands from the agent
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
+29
-4
@@ -566,6 +566,18 @@ def _run_prediction_markets_disable() -> None:
|
||||
logger.warning("Prediction markets disable cleanup failed: %s", e)
|
||||
|
||||
|
||||
@router.get("/api/layers", dependencies=[Depends(require_local_operator)])
|
||||
async def get_layers(request: Request):
|
||||
"""Report operator layer state plus any live overrides.
|
||||
|
||||
The UI polls this so the DATA LAYERS toggles can show an overlay that an
|
||||
agent switched on. ``layers`` is the operator's own state and is what the
|
||||
browser persists; ``overrides`` is transient and must not be saved.
|
||||
"""
|
||||
from services.fetchers._store import active_layers, get_layer_overrides
|
||||
return {"layers": dict(active_layers), "overrides": get_layer_overrides()}
|
||||
|
||||
|
||||
@router.post("/api/layers", dependencies=[Depends(require_local_operator)])
|
||||
@limiter.limit("30/minute")
|
||||
async def update_layers(update: LayerUpdate, request: Request):
|
||||
@@ -668,11 +680,15 @@ async def bootstrap_critical(request: Request):
|
||||
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,
|
||||
effective_layers,
|
||||
get_latest_data_subset_refs,
|
||||
get_source_timestamps_snapshot,
|
||||
)
|
||||
|
||||
# Rebind the local name to the override-merged map. Every layer filter
|
||||
# below reads this name, so one merge here covers all of them.
|
||||
active_layers = effective_layers()
|
||||
|
||||
def _build() -> dict:
|
||||
d = get_latest_data_subset_refs(
|
||||
"last_updated", "commercial_flights", "military_flights", "private_flights",
|
||||
@@ -769,14 +785,17 @@ def _try_build_fast_delta(
|
||||
) -> dict | None:
|
||||
"""Return a delta payload, or None to fall back to a full snapshot."""
|
||||
from services.fetchers._store import (
|
||||
active_layers,
|
||||
compute_layer_row_delta,
|
||||
effective_layers,
|
||||
get_data_version,
|
||||
get_layer_versions,
|
||||
get_latest_data_subset_refs,
|
||||
get_source_timestamps_snapshot,
|
||||
)
|
||||
|
||||
# Rebind the local name to the override-merged map (see bootstrap_critical).
|
||||
active_layers = effective_layers()
|
||||
|
||||
server_lv = get_layer_versions()
|
||||
deltas: dict[str, Any] = {}
|
||||
for key in _DELTA_FAST_KEYS:
|
||||
@@ -894,7 +913,10 @@ async def live_data_fast(
|
||||
etag = _current_etag(prefix=("fast|initial|" if initial else "fast|full|") + bbox_suffix.lstrip("|") + ("|" if bbox_suffix else ""))
|
||||
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)
|
||||
from services.fetchers._store import (effective_layers, get_latest_data_subset_refs, get_source_timestamps_snapshot)
|
||||
|
||||
# Rebind the local name to the override-merged map (see bootstrap_critical).
|
||||
active_layers = effective_layers()
|
||||
|
||||
def _build() -> dict:
|
||||
d = get_latest_data_subset_refs(
|
||||
@@ -959,7 +981,10 @@ async def live_data_slow(
|
||||
etag = _current_etag(prefix="slow|full|" + bbox_suffix.lstrip("|") + ("|" if bbox_suffix else ""))
|
||||
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)
|
||||
from services.fetchers._store import (effective_layers, get_latest_data_subset_refs, get_source_timestamps_snapshot)
|
||||
|
||||
# Rebind the local name to the override-merged map (see bootstrap_critical).
|
||||
active_layers = effective_layers()
|
||||
|
||||
def _build() -> dict:
|
||||
d = get_latest_data_subset_refs(
|
||||
|
||||
@@ -7,6 +7,7 @@ Every fetcher imports from here instead of maintaining its own copy.
|
||||
import copy
|
||||
import threading
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional, TypedDict
|
||||
|
||||
@@ -505,6 +506,69 @@ active_layers: dict[str, bool] = {
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Layer overrides — additive, agent-driven, never persisted.
|
||||
# An automation (e.g. a hotspot daemon) can switch an overlay on for a while
|
||||
# without touching the operator's own toggles. Overrides merge ON TOP of
|
||||
# active_layers for reads; active_layers itself is only ever written by the
|
||||
# operator via POST /api/layers. The whole map shares one expiry, evaluated
|
||||
# lazily on read so no background task is needed. When it lapses the operator's
|
||||
# own view returns with no save/restore bookkeeping.
|
||||
# ---------------------------------------------------------------------------
|
||||
_MAX_OVERRIDE_TTL_S = 3600.0
|
||||
|
||||
layer_overrides: dict[str, bool] = {}
|
||||
_layer_overrides_expires_at: float = 0.0
|
||||
|
||||
|
||||
def get_layer_overrides() -> dict[str, bool]:
|
||||
"""Return the live overrides, or {} once the TTL has lapsed."""
|
||||
global _layer_overrides_expires_at
|
||||
if not layer_overrides:
|
||||
return {}
|
||||
if time.monotonic() >= _layer_overrides_expires_at:
|
||||
layer_overrides.clear()
|
||||
_layer_overrides_expires_at = 0.0
|
||||
bump_active_layers_version()
|
||||
return {}
|
||||
return dict(layer_overrides)
|
||||
|
||||
|
||||
def set_layer_overrides(overrides: dict[str, bool], ttl_seconds: float) -> dict[str, bool]:
|
||||
"""Replace the override map, returning the entries that were accepted.
|
||||
|
||||
Keys that are not real layers are dropped so a typo cannot silently do
|
||||
nothing — the caller compares the return value against what it sent.
|
||||
"""
|
||||
global _layer_overrides_expires_at
|
||||
ttl = max(0.0, min(float(ttl_seconds), _MAX_OVERRIDE_TTL_S))
|
||||
accepted = {k: bool(v) for k, v in overrides.items() if k in active_layers}
|
||||
layer_overrides.clear()
|
||||
layer_overrides.update(accepted)
|
||||
_layer_overrides_expires_at = time.monotonic() + ttl if accepted else 0.0
|
||||
bump_active_layers_version()
|
||||
return accepted
|
||||
|
||||
|
||||
def clear_layer_overrides() -> None:
|
||||
"""Drop all overrides, restoring the operator's own layer state."""
|
||||
global _layer_overrides_expires_at
|
||||
if layer_overrides:
|
||||
layer_overrides.clear()
|
||||
_layer_overrides_expires_at = 0.0
|
||||
bump_active_layers_version()
|
||||
|
||||
|
||||
def effective_layers() -> dict[str, bool]:
|
||||
"""Operator layer state merged with any live overrides."""
|
||||
overrides = get_layer_overrides()
|
||||
return {**active_layers, **overrides} if overrides else dict(active_layers)
|
||||
|
||||
|
||||
def is_any_active(*layer_names: str) -> bool:
|
||||
"""Return True if any of the given layer names is currently active."""
|
||||
return any(active_layers.get(name, True) for name in layer_names)
|
||||
overrides = get_layer_overrides()
|
||||
return any(
|
||||
overrides.get(name, active_layers.get(name, True))
|
||||
for name in layer_names
|
||||
)
|
||||
|
||||
@@ -191,6 +191,9 @@ export default function Dashboard() {
|
||||
}, []);
|
||||
|
||||
const [activeLayers, setActiveLayers] = useState<ActiveLayers>(getDefaultActiveLayers);
|
||||
// Backend-driven layer overrides. Additive on top of activeLayers, never
|
||||
// persisted and never pushed back — the operator's own toggles stay theirs.
|
||||
const [layerOverrides, setLayerOverrides] = useState<Partial<ActiveLayers>>({});
|
||||
const [activeStyle, setActiveStyle] = useState<MapStyle>(getDefaultMapStyle);
|
||||
const [activeFilters, setActiveFilters] = useState<Record<string, string[]>>({});
|
||||
const [layerPrefsHydrated, setLayerPrefsHydrated] = useState(false);
|
||||
@@ -323,6 +326,27 @@ export default function Dashboard() {
|
||||
};
|
||||
}, [activeLayers, secondaryBootReady, layerPrefsHydrated]);
|
||||
|
||||
// Poll for backend layer overrides so an agent can switch an overlay on
|
||||
// without the operator reloading. Overrides carry a TTL server-side, so a
|
||||
// missed poll self-corrects and a dropped backend just lets them lapse.
|
||||
useEffect(() => {
|
||||
if (!secondaryBootReady) return;
|
||||
let cancelled = false;
|
||||
const pollOverrides = () =>
|
||||
fetch(`${API_BASE}/api/layers`)
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
if (!cancelled) setLayerOverrides(d?.overrides ?? {});
|
||||
})
|
||||
.catch(() => {});
|
||||
void pollOverrides();
|
||||
const id = setInterval(pollOverrides, 5000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(id);
|
||||
};
|
||||
}, [secondaryBootReady]);
|
||||
|
||||
// Left panel accordion state
|
||||
const [leftDataMinimized, setLeftDataMinimized] = useState(false);
|
||||
const [leftMeshExpanded, setLeftMeshExpanded] = useState(true);
|
||||
@@ -457,8 +481,10 @@ export default function Dashboard() {
|
||||
});
|
||||
};
|
||||
|
||||
// Overrides merge last so they win over both the operator's toggles and the
|
||||
// first-paint suppressions below.
|
||||
const firstPaintActiveLayers = useMemo<ActiveLayers>(() => {
|
||||
if (secondaryBootReady) return activeLayers;
|
||||
if (secondaryBootReady) return { ...activeLayers, ...layerOverrides };
|
||||
return {
|
||||
...activeLayers,
|
||||
cctv: false,
|
||||
@@ -472,8 +498,9 @@ export default function Dashboard() {
|
||||
tinygs: false,
|
||||
datacenters: false,
|
||||
power_plants: false,
|
||||
...layerOverrides,
|
||||
};
|
||||
}, [activeLayers, secondaryBootReady]);
|
||||
}, [activeLayers, layerOverrides, secondaryBootReady]);
|
||||
// Agent fly_to handler (sar_focus_aoi etc.) — wired here now that
|
||||
// setFlyToLocation is in scope. show_image is routed through
|
||||
// useAgentActions at the top of Dashboard.
|
||||
@@ -587,7 +614,7 @@ export default function Dashboard() {
|
||||
{secondaryBootReady ? (
|
||||
<ErrorBoundary name="WorldviewLeftPanel">
|
||||
<WorldviewLeftPanel
|
||||
activeLayers={activeLayers}
|
||||
activeLayers={firstPaintActiveLayers}
|
||||
setActiveLayers={setActiveLayers}
|
||||
onResetLayers={resetActiveLayers}
|
||||
shodanResultCount={shodanResults.length}
|
||||
|
||||
Reference in New Issue
Block a user