Compare commits
21
Commits
@@ -26,6 +26,7 @@ NODE_OFFLINE_THRESHOLD=90
|
||||
# Google Maps — for geocoding location strings extracted from transcripts
|
||||
# Enable "Geocoding API" in Cloud Console for this key
|
||||
GOOGLE_MAPS_API_KEY=
|
||||
NY511_API_KEY=
|
||||
|
||||
# OpenAI — for transcription (Whisper), intelligence extraction, embeddings, and summaries
|
||||
OPENAI_API_KEY=
|
||||
|
||||
@@ -32,6 +32,9 @@ class Settings(BaseSettings):
|
||||
|
||||
# Google Maps (geocoding)
|
||||
google_maps_api_key: Optional[str] = None
|
||||
# 511NY developer key (server-26#183). Optional: the API answered without one
|
||||
# as of 2026-09-27, but its terms require a registered key.
|
||||
ny511_api_key: Optional[str] = None
|
||||
|
||||
# Gemini (intelligence extraction, embeddings, incident summaries)
|
||||
gemini_api_key: Optional[str] = None
|
||||
|
||||
@@ -112,7 +112,12 @@ class MQTTHandler:
|
||||
"approval_status": "pending",
|
||||
"node_type": payload.get("node_type", "fixed"),
|
||||
"secondary_sdr_mode": payload.get("secondary_sdr_mode", "none"),
|
||||
"sdr_count": payload.get("sdr_count", 1),
|
||||
"secondary_sdr_priority": payload.get("secondary_sdr_priority", []),
|
||||
"secondary_sdr_running": payload.get("secondary_sdr_running"),
|
||||
"sdr_pins": payload.get("sdr_pins", {}),
|
||||
"sdr_devices": payload.get("sdr_devices"),
|
||||
"op25_sdr_serial": payload.get("op25_sdr_serial"),
|
||||
"sdr_count": payload.get("sdr_count"), # None until reported, never a guessed 1
|
||||
"enforce_override_timeout": payload.get("enforce_override_timeout", True),
|
||||
"is_overridden": False,
|
||||
"override_system_id": None,
|
||||
@@ -143,8 +148,10 @@ class MQTTHandler:
|
||||
updates["node_type"] = node_type
|
||||
updates["enforce_override_timeout"] = enforce_timeout
|
||||
|
||||
if "secondary_sdr_mode" in payload:
|
||||
updates["secondary_sdr_mode"] = payload["secondary_sdr_mode"]
|
||||
for key in ("secondary_sdr_mode", "secondary_sdr_priority", "secondary_sdr_running",
|
||||
"sdr_pins", "sdr_devices", "op25_sdr_serial"):
|
||||
if key in payload:
|
||||
updates[key] = payload[key]
|
||||
if "sdr_count" in payload:
|
||||
updates["sdr_count"] = payload["sdr_count"]
|
||||
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
"""511NY (NYSDOT) traffic cameras and events, cached in memory (server-26#183).
|
||||
|
||||
Public data, identical for every org, so it is NOT written to Firestore: the
|
||||
statewide feeds are ~3k cameras and ~2.3k events, and re-writing them every
|
||||
poll would be millions of writes a day for data nobody needs history of. The
|
||||
cache is filled lazily on request and refreshed per TTL, so an idle deploy
|
||||
makes no 511 calls at all.
|
||||
|
||||
A failed refresh keeps serving the last good data and reports the error and
|
||||
its age to the caller -- an empty layer must never be the only symptom of a
|
||||
dead feed (the AI-silent-failures lesson).
|
||||
"""
|
||||
import asyncio
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import settings
|
||||
from app.internal.logger import logger
|
||||
|
||||
_BASE = "https://511ny.org/api"
|
||||
CAMERAS_TTL_S = 60 * 60 # camera list is near-static
|
||||
EVENTS_TTL_S = 2 * 60 # accidents/closures change minute to minute
|
||||
RETRY_AFTER_FAILURE_S = 60
|
||||
_DESCRIPTION_MAX = 500
|
||||
|
||||
|
||||
class _Feed:
|
||||
def __init__(self, path: str, ttl_s: int, normalize):
|
||||
self.path = path
|
||||
self.ttl_s = ttl_s
|
||||
self.normalize = normalize
|
||||
self.items: List[Dict[str, Any]] = []
|
||||
self.fetched_at: Optional[float] = None # epoch s of last SUCCESSFUL fetch
|
||||
self.error: Optional[str] = None
|
||||
self._next_attempt = 0.0
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def get(self) -> "_Feed":
|
||||
if time.time() < self._next_attempt:
|
||||
return self
|
||||
async with self._lock:
|
||||
if time.time() < self._next_attempt:
|
||||
return self # another request refreshed while we waited
|
||||
try:
|
||||
self.items = await _fetch(self.path, self.normalize)
|
||||
self.fetched_at = time.time()
|
||||
self.error = None
|
||||
self._next_attempt = time.time() + self.ttl_s
|
||||
except Exception as e:
|
||||
self._next_attempt = time.time() + min(self.ttl_s, RETRY_AFTER_FAILURE_S)
|
||||
self.error = f"{type(e).__name__}: {e}"[:300]
|
||||
logger.warning(f"511NY {self.path} refresh failed, serving {len(self.items)} cached: {self.error}")
|
||||
return self
|
||||
|
||||
|
||||
async def _fetch(path: str, normalize) -> List[Dict[str, Any]]:
|
||||
params = {"format": "json"}
|
||||
if settings.ny511_api_key:
|
||||
params["key"] = settings.ny511_api_key
|
||||
async with httpx.AsyncClient(timeout=20.0) as client:
|
||||
r = await client.get(f"{_BASE}/{path}", params=params)
|
||||
r.raise_for_status()
|
||||
raw = r.json()
|
||||
if not isinstance(raw, list):
|
||||
raise ValueError(f"expected a JSON list, got {type(raw).__name__}")
|
||||
out = [n for n in (normalize(x) for x in raw) if n is not None]
|
||||
if raw and not out:
|
||||
# every record failed to normalize: the schema changed under us
|
||||
raise ValueError(f"0 of {len(raw)} records parsed -- 511NY schema change?")
|
||||
return out
|
||||
|
||||
|
||||
def _coords(x: Dict[str, Any]) -> Optional[tuple]:
|
||||
try:
|
||||
lat, lon = float(x["Latitude"]), float(x["Longitude"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return None
|
||||
if lat == 0 and lon == 0:
|
||||
return None
|
||||
return lat, lon
|
||||
|
||||
|
||||
def _local_iso(s: Any) -> Optional[str]:
|
||||
"""511NY stamps are 'DD/MM/YYYY HH:MM:SS' New York local time. Returned as a
|
||||
naive ISO string (no offset) -- display-only, never compared to UTC."""
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
return datetime.strptime(s, "%d/%m/%Y %H:%M:%S").isoformat()
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def normalize_camera(x: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
c = _coords(x)
|
||||
if c is None or x.get("Disabled") or x.get("Blocked") or not x.get("ID"):
|
||||
return None
|
||||
return {
|
||||
"id": x["ID"],
|
||||
"lat": c[0],
|
||||
"lon": c[1],
|
||||
"name": x.get("Name") or "",
|
||||
"roadway": x.get("RoadwayName") or "",
|
||||
"direction": x.get("DirectionOfTravel") or "",
|
||||
"image_url": x.get("Url"), # 511NY serves the current still at this URL
|
||||
"video_url": x.get("VideoUrl"), # HLS playlist, when the camera streams
|
||||
}
|
||||
|
||||
|
||||
def normalize_event(x: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
c = _coords(x)
|
||||
if c is None or not x.get("ID"):
|
||||
return None
|
||||
desc = x.get("Description") or ""
|
||||
return {
|
||||
"id": x["ID"],
|
||||
"lat": c[0],
|
||||
"lon": c[1],
|
||||
"type": x.get("EventType") or "",
|
||||
"subtype": x.get("EventSubType") or "",
|
||||
"severity": x.get("Severity") or "",
|
||||
"roadway": x.get("RoadwayName") or "",
|
||||
"direction": x.get("DirectionOfTravel") or "",
|
||||
"county": x.get("CountyName") or "",
|
||||
"description": desc[:_DESCRIPTION_MAX] + ("…" if len(desc) > _DESCRIPTION_MAX else ""),
|
||||
"start_local": _local_iso(x.get("StartDate")),
|
||||
"planned_end_local": _local_iso(x.get("PlannedEndDate")),
|
||||
"updated_local": _local_iso(x.get("LastUpdated")),
|
||||
}
|
||||
|
||||
|
||||
cameras = _Feed("getcameras", CAMERAS_TTL_S, normalize_camera)
|
||||
events = _Feed("getevents", EVENTS_TTL_S, normalize_event)
|
||||
|
||||
|
||||
def in_bbox(items: List[Dict[str, Any]], south: float, west: float, north: float, east: float) -> List[Dict[str, Any]]:
|
||||
return [i for i in items if south <= i["lat"] <= north and west <= i["lon"] <= east]
|
||||
@@ -17,7 +17,7 @@ from app.internal.auth import (
|
||||
require_node_service_or_firebase_token,
|
||||
)
|
||||
from app.routers import nodes, systems, calls, upload, tokens, incidents, alerts, admin, trips, places, links, users
|
||||
from app.routers import enrollment, media, org, waitlist, telemetry, replay
|
||||
from app.routers import enrollment, media, org, waitlist, telemetry, replay, traffic
|
||||
from app.internal import dynsec
|
||||
from app.internal import firestore as fstore
|
||||
|
||||
@@ -127,6 +127,7 @@ app.include_router(incidents.router, dependencies=[Depends(require_service_or_fi
|
||||
app.include_router(alerts.router, dependencies=[Depends(require_service_or_firebase_token)])
|
||||
app.include_router(trips.router, dependencies=[Depends(require_service_or_firebase_token)])
|
||||
app.include_router(places.router, dependencies=[Depends(require_service_or_firebase_token)])
|
||||
app.include_router(traffic.router, dependencies=[Depends(require_service_or_firebase_token)])
|
||||
app.include_router(upload.router) # auth is per-node, handled inline
|
||||
app.include_router(admin.router) # auth is per-endpoint (read: firebase, write: admin)
|
||||
app.include_router(replay.router) # auth: admin only (every route spends or reads a replay run)
|
||||
|
||||
@@ -62,8 +62,17 @@ class NodeRecord(BaseModel):
|
||||
last_seen: Optional[datetime] = None
|
||||
assigned_system_id: Optional[str] = None
|
||||
node_type: str = "fixed" # fixed or portable
|
||||
secondary_sdr_mode: str = "none" # none | adsb | ais | op25_2 — requires a second physical SDR
|
||||
sdr_count: int = 1 # self-reported by the node's checkin, best-effort
|
||||
secondary_sdr_mode: str = "none" # legacy single-mode field; priority[0] on current nodes
|
||||
# Ordered decoders for the SDRs beyond op25's (node-26#9): the node runs
|
||||
# them top-down until it runs out of dongles. Mirrored from the node's own
|
||||
# checkin, which is the source of truth; set via PATCH /nodes/{id}.
|
||||
secondary_sdr_priority: List[str] = []
|
||||
secondary_sdr_running: Optional[List[str]] = None # what the node reports actually running
|
||||
# node-26#11: service (op25/adsb/ais) -> dongle serial; absent = automatic.
|
||||
sdr_pins: Dict[str, str] = {}
|
||||
sdr_devices: Optional[List[Dict[str, Any]]] = None # [{index, serial, name, duplicate_serial}] from checkin
|
||||
op25_sdr_serial: Optional[str] = None # the dongle OP25 is actually using, per the node
|
||||
sdr_count: Optional[int] = None # self-reported by the node's checkin; None = never reported
|
||||
enforce_override_timeout: bool = True
|
||||
is_overridden: bool = False
|
||||
override_system_id: Optional[str] = None
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import secrets
|
||||
from typing import Optional
|
||||
from typing import Dict, List, Optional
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query
|
||||
from pydantic import BaseModel
|
||||
from app.models import CommandPayload
|
||||
@@ -192,10 +192,19 @@ async def assign_system(
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
SECONDARY_SDR_MODES = ("adsb", "ais")
|
||||
SDR_PIN_KEYS = ("op25",) + SECONDARY_SDR_MODES
|
||||
|
||||
|
||||
class NodeUpdateBody(BaseModel):
|
||||
node_type: Optional[str] = None
|
||||
enforce_override_timeout: Optional[bool] = None
|
||||
secondary_sdr_mode: Optional[str] = None # none | adsb | ais | op25_2
|
||||
secondary_sdr_mode: Optional[str] = None # legacy: none | adsb | ais
|
||||
# Ordered, e.g. ["adsb", "ais"]: SDRs beyond op25's run these top-down.
|
||||
secondary_sdr_priority: Optional[List[str]] = None
|
||||
# node-26#11: service -> dongle serial; null/"" = automatic. Moving OP25's
|
||||
# dongle restarts OP25 on the node; the other pins never do.
|
||||
sdr_pins: Optional[Dict[str, Optional[str]]] = None
|
||||
|
||||
|
||||
@router.patch("/{node_id}")
|
||||
@@ -212,8 +221,41 @@ async def update_node(
|
||||
if not updates:
|
||||
return {"ok": True}
|
||||
|
||||
priority = updates.get("secondary_sdr_priority")
|
||||
if priority is not None:
|
||||
unknown = [m for m in priority if m not in SECONDARY_SDR_MODES]
|
||||
if unknown or len(set(priority)) != len(priority):
|
||||
raise HTTPException(400, f"secondary_sdr_priority must be distinct values from {SECONDARY_SDR_MODES}.")
|
||||
updates["secondary_sdr_mode"] = priority[0] if priority else "none"
|
||||
|
||||
if "sdr_pins" in updates:
|
||||
raw = updates["sdr_pins"] or {}
|
||||
unknown = [k for k in raw if k not in SDR_PIN_KEYS]
|
||||
if unknown:
|
||||
raise HTTPException(400, f"sdr_pins keys must be from {SDR_PIN_KEYS}.")
|
||||
pins = {k: str(v).strip() for k, v in raw.items() if v and str(v).strip()}
|
||||
if len(set(pins.values())) != len(pins):
|
||||
raise HTTPException(400, "Two services can't be pinned to the same SDR.")
|
||||
updates["sdr_pins"] = pins
|
||||
|
||||
await fstore.doc_update("nodes", node_id, updates)
|
||||
|
||||
# SDR settings go as their own command: a config re-push restarts OP25, and
|
||||
# changing what the spare dongles do must never interrupt P25 recording
|
||||
# (only moving OP25's own dongle restarts it, on the node's side). The node
|
||||
# applies it, then its checkin reports back what's really running.
|
||||
sdr_keys = {"secondary_sdr_priority", "secondary_sdr_mode", "sdr_pins"}
|
||||
if sdr_keys & set(updates):
|
||||
command = {"action": "set_sdr_config"}
|
||||
if priority is not None:
|
||||
command["priority"] = priority
|
||||
if "sdr_pins" in updates:
|
||||
# Explicit nulls so a cleared pin reaches the node as "automatic".
|
||||
command["pins"] = {k: updates["sdr_pins"].get(k) for k in SDR_PIN_KEYS}
|
||||
mqtt_handler.send_command(node_id, command)
|
||||
if set(updates) <= sdr_keys:
|
||||
return {"ok": True}
|
||||
|
||||
# Re-push config to apply new node settings locally
|
||||
updated_node = await fstore.doc_get("nodes", node_id)
|
||||
assigned_system_id = updated_node.get("assigned_system_id")
|
||||
@@ -228,7 +270,9 @@ async def update_node(
|
||||
}
|
||||
if updated_node.get("ppm_override") is not None:
|
||||
push_payload["ppm_override"] = updated_node["ppm_override"]
|
||||
if updated_node.get("secondary_sdr_mode") is not None:
|
||||
if updated_node.get("secondary_sdr_priority") is not None:
|
||||
push_payload["secondary_sdr_priority"] = updated_node["secondary_sdr_priority"]
|
||||
elif updated_node.get("secondary_sdr_mode") is not None:
|
||||
push_payload["secondary_sdr_mode"] = updated_node["secondary_sdr_mode"]
|
||||
mqtt_handler.push_config(node_id, push_payload)
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import asyncio
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
|
||||
from app.internal import ny511
|
||||
|
||||
router = APIRouter(prefix="/traffic", tags=["traffic"])
|
||||
|
||||
# Per-layer cap per response. A statewide view is ~3k cameras; past this the
|
||||
# map is unreadable anyway, and the client is told the list was cut.
|
||||
MAX_ITEMS = 1500
|
||||
|
||||
|
||||
def _feed_status(feed: "ny511._Feed") -> dict:
|
||||
return {"fetched_at": feed.fetched_at, "error": feed.error}
|
||||
|
||||
|
||||
@router.get("/511")
|
||||
async def get_511(
|
||||
south: float = Query(..., ge=-90, le=90),
|
||||
west: float = Query(..., ge=-180, le=180),
|
||||
north: float = Query(..., ge=-90, le=90),
|
||||
east: float = Query(..., ge=-180, le=180),
|
||||
layers: Optional[str] = Query("cameras,events", description="comma list: cameras, events"),
|
||||
):
|
||||
if south > north or west > east:
|
||||
raise HTTPException(400, "bbox must satisfy south<=north and west<=east")
|
||||
wanted = {s.strip() for s in (layers or "").split(",") if s.strip()}
|
||||
feeds = {name: getattr(ny511, name) for name in ("cameras", "events") if name in wanted}
|
||||
await asyncio.gather(*(f.get() for f in feeds.values()))
|
||||
|
||||
out: dict = {}
|
||||
for name, feed in feeds.items():
|
||||
hits = ny511.in_bbox(feed.items, south, west, north, east)
|
||||
out[name] = hits[:MAX_ITEMS]
|
||||
out[f"{name}_status"] = {**_feed_status(feed), "total_in_bbox": len(hits), "truncated": len(hits) > MAX_ITEMS}
|
||||
return out
|
||||
@@ -6,6 +6,7 @@ from pydantic import BaseModel
|
||||
from firebase_admin import auth as firebase_auth
|
||||
from app.internal.auth import require_admin_token
|
||||
from app.internal import firestore as fstore
|
||||
from app.internal.tenancy import FOUNDING_ORG_ID
|
||||
from app.internal import audit
|
||||
|
||||
router = APIRouter(prefix="/admin/users", tags=["users"])
|
||||
@@ -22,9 +23,13 @@ class UserCreate(BaseModel):
|
||||
role: str = "viewer"
|
||||
display_name: Optional[str] = None
|
||||
owned_node_ids: list[str] = []
|
||||
# Org the new user joins as a member. Defaults to the creating admin's own
|
||||
# org — without one, firestore.rules lets the user read nothing at all.
|
||||
org_id: Optional[str] = None
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
org_id: Optional[str] = None # attach an org-less user; defaults to the admin's org
|
||||
role: Optional[str] = None
|
||||
owned_node_ids: Optional[list[str]] = None
|
||||
display_name: Optional[str] = None
|
||||
@@ -34,6 +39,32 @@ class UserUpdate(BaseModel):
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _resolve_org(requested: Optional[str], decoded: dict) -> str:
|
||||
"""The org a user created/edited here belongs to: the one asked for, else
|
||||
the acting admin's own. Every read the frontend makes is gated on the
|
||||
org_id claim (firestore.rules inOrg()), so a user without one sees no
|
||||
incidents, calls or nodes — which is how admin-created viewers came out
|
||||
before this existed."""
|
||||
# A platform admin needn't have an org claim (isPlatformAdmin reads every
|
||||
# org), so fall back to the founding org every pre-tenancy node, call and
|
||||
# incident was stamped with (app/internal/tenancy.py).
|
||||
org_id = requested or decoded.get("org_id") or FOUNDING_ORG_ID
|
||||
if not await fstore.doc_get("organizations", org_id):
|
||||
raise HTTPException(400, f"Organization '{org_id}' does not exist.")
|
||||
return org_id
|
||||
|
||||
|
||||
async def _write_membership(uid: str, email: Optional[str], org_id: str) -> None:
|
||||
"""Same org_members shape as POST /auth/signup (routers/links.py)."""
|
||||
await fstore.doc_set("org_members", uid, {
|
||||
"uid": uid,
|
||||
"org_id": org_id,
|
||||
"org_role": "member",
|
||||
"email": email,
|
||||
"added_at": datetime.now(timezone.utc).isoformat(),
|
||||
}, merge=False)
|
||||
|
||||
|
||||
def _ms_to_iso(ms: Optional[int]) -> Optional[str]:
|
||||
if ms is None:
|
||||
return None
|
||||
@@ -66,6 +97,9 @@ def _format_user(fb_user: firebase_auth.UserRecord, link: Optional[dict] = None)
|
||||
"discord_linked": bool(link and link.get("discord_user_id")),
|
||||
"discord_username": link.get("discord_username") if link else None,
|
||||
"discord_user_id": link.get("discord_user_id") if link else None,
|
||||
# Which org's data this user can read (firestore.rules gates on it).
|
||||
"org_id": (fb_user.custom_claims or {}).get("org_id"),
|
||||
"org_role": (fb_user.custom_claims or {}).get("org_role"),
|
||||
}
|
||||
|
||||
|
||||
@@ -101,6 +135,7 @@ async def create_user(body: UserCreate, decoded: dict = Depends(require_admin_to
|
||||
raise HTTPException(400, f"Invalid role. Must be one of: {', '.join(sorted(VALID_ROLES))}")
|
||||
if body.role == "operator" and not body.owned_node_ids:
|
||||
raise HTTPException(400, "Operator role requires at least one owned node.")
|
||||
org_id = await _resolve_org(body.org_id, decoded)
|
||||
|
||||
try:
|
||||
fb_user: firebase_auth.UserRecord = await asyncio.to_thread(
|
||||
@@ -115,10 +150,11 @@ async def create_user(body: UserCreate, decoded: dict = Depends(require_admin_to
|
||||
raise HTTPException(400, f"Failed to create user: {e}")
|
||||
|
||||
# Set custom claims
|
||||
claims: dict = {"role": body.role, "owned_node_ids": body.owned_node_ids}
|
||||
claims: dict = {"role": body.role, "owned_node_ids": body.owned_node_ids, "org_id": org_id, "org_role": "member"}
|
||||
if body.role == "admin":
|
||||
claims["admin"] = True
|
||||
await asyncio.to_thread(firebase_auth.set_custom_user_claims, fb_user.uid, claims)
|
||||
await _write_membership(fb_user.uid, body.email, org_id)
|
||||
|
||||
# Write Firestore profile
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
@@ -145,7 +181,7 @@ async def create_user(body: UserCreate, decoded: dict = Depends(require_admin_to
|
||||
action="user.create",
|
||||
target_uid=fb_user.uid,
|
||||
target_email=body.email,
|
||||
details={"role": body.role, "owned_node_ids": body.owned_node_ids},
|
||||
details={"role": body.role, "owned_node_ids": body.owned_node_ids, "org_id": org_id},
|
||||
)
|
||||
|
||||
return {**_format_user(fb_user), "invite_link": invite_link}
|
||||
@@ -197,7 +233,26 @@ async def update_user(uid: str, body: UserUpdate, decoded: dict = Depends(requir
|
||||
else:
|
||||
new_claims.pop("admin", None)
|
||||
|
||||
# Heal users created before POST /users set an org (they could read
|
||||
# nothing): any edit attaches them to the requested/admin's org. An
|
||||
# existing org is never silently moved.
|
||||
# Heal users created before POST /admin/users set an org (they could read
|
||||
# nothing): any edit attaches them to the requested/admin's org. Moving a
|
||||
# user who already has an org only happens when org_id is passed
|
||||
# explicitly — e.g. a viewer whose first login self-provisioned an empty
|
||||
# org of their own via POST /auth/signup (seen 2026-09-27).
|
||||
attached_org: Optional[str] = None
|
||||
left_org: Optional[str] = None
|
||||
current_org = existing_claims.get("org_id")
|
||||
if not current_org or (body.org_id and body.org_id != current_org):
|
||||
attached_org = await _resolve_org(body.org_id, decoded)
|
||||
left_org = current_org
|
||||
new_claims["org_id"] = attached_org
|
||||
new_claims["org_role"] = "member"
|
||||
|
||||
await asyncio.to_thread(firebase_auth.set_custom_user_claims, uid, new_claims)
|
||||
if attached_org:
|
||||
await _write_membership(uid, fb_user.email, attached_org)
|
||||
|
||||
if body.display_name is not None:
|
||||
await asyncio.to_thread(firebase_auth.update_user, uid, display_name=body.display_name)
|
||||
@@ -218,6 +273,9 @@ async def update_user(uid: str, body: UserUpdate, decoded: dict = Depends(requir
|
||||
"new_role": new_role,
|
||||
"old_nodes": current_nodes,
|
||||
"new_nodes": new_nodes,
|
||||
**({"attached_org_id": attached_org} if attached_org else {}),
|
||||
# The org left behind is not deleted: it may hold nodes or data.
|
||||
**({"left_org_id": left_org} if left_org else {}),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"""
|
||||
node-26#9 — PATCH /nodes/{id} secondary_sdr_priority.
|
||||
|
||||
The priority must reach the node as its own MQTT command, never via a config
|
||||
re-push: a config push restarts OP25, and reordering what the spare dongles do
|
||||
must not interrupt P25 recording.
|
||||
"""
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
from app.internal.auth import require_admin_token, require_service_or_firebase_token
|
||||
from app.routers import nodes
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
NODE = {"node_id": "n1", "assigned_system_id": "sys-1", "hardware_preset": "rtl-sdr-v3"}
|
||||
|
||||
|
||||
def setup_function():
|
||||
app.dependency_overrides[require_admin_token] = lambda: {"admin": True}
|
||||
app.dependency_overrides[require_service_or_firebase_token] = lambda: {"admin": True}
|
||||
|
||||
|
||||
def teardown_function():
|
||||
app.dependency_overrides.pop(require_admin_token, None)
|
||||
app.dependency_overrides.pop(require_service_or_firebase_token, None)
|
||||
|
||||
|
||||
def _patch(body):
|
||||
with patch.object(nodes.fstore, "doc_get", AsyncMock(side_effect=lambda c, i: NODE if c == "nodes" else {"system_id": "sys-1"})), \
|
||||
patch.object(nodes.fstore, "doc_update", AsyncMock()) as update, \
|
||||
patch.object(nodes.mqtt_handler, "send_command", MagicMock(return_value=True)) as command, \
|
||||
patch.object(nodes.mqtt_handler, "push_config", MagicMock()) as push:
|
||||
resp = client.patch("/nodes/n1", json=body)
|
||||
return resp, update, command, push
|
||||
|
||||
|
||||
def test_priority_only_sends_command_and_never_repushes_config():
|
||||
resp, update, command, push = _patch({"secondary_sdr_priority": ["ais", "adsb"]})
|
||||
assert resp.status_code == 200
|
||||
command.assert_called_once_with("n1", {"action": "set_sdr_config", "priority": ["ais", "adsb"]})
|
||||
push.assert_not_called()
|
||||
(_, _, updates), _ = update.await_args
|
||||
assert updates == {"secondary_sdr_priority": ["ais", "adsb"], "secondary_sdr_mode": "ais"}
|
||||
|
||||
|
||||
def test_empty_priority_turns_secondaries_off():
|
||||
resp, update, command, push = _patch({"secondary_sdr_priority": []})
|
||||
assert resp.status_code == 200
|
||||
command.assert_called_once_with("n1", {"action": "set_sdr_config", "priority": []})
|
||||
(_, _, updates), _ = update.await_args
|
||||
assert updates["secondary_sdr_mode"] == "none"
|
||||
|
||||
|
||||
def test_unknown_or_duplicate_modes_are_rejected():
|
||||
assert _patch({"secondary_sdr_priority": ["adsb", "sonar"]})[0].status_code == 400
|
||||
assert _patch({"secondary_sdr_priority": ["adsb", "adsb"]})[0].status_code == 400
|
||||
|
||||
|
||||
def test_pins_send_every_service_with_nulls_for_automatic():
|
||||
resp, update, command, push = _patch({"sdr_pins": {"op25": "00000001", "adsb": "69420", "ais": ""}})
|
||||
assert resp.status_code == 200
|
||||
push.assert_not_called()
|
||||
command.assert_called_once_with("n1", {
|
||||
"action": "set_sdr_config",
|
||||
"pins": {"op25": "00000001", "adsb": "69420", "ais": None},
|
||||
})
|
||||
(_, _, updates), _ = update.await_args
|
||||
assert updates == {"sdr_pins": {"op25": "00000001", "adsb": "69420"}}
|
||||
|
||||
|
||||
def test_two_services_on_one_dongle_or_unknown_service_rejected():
|
||||
assert _patch({"sdr_pins": {"op25": "69420", "adsb": "69420"}})[0].status_code == 400
|
||||
assert _patch({"sdr_pins": {"sonar": "1"}})[0].status_code == 400
|
||||
@@ -0,0 +1,98 @@
|
||||
"""
|
||||
server-26#183 — 511NY cameras/events layer.
|
||||
|
||||
The feed is scraped from a third party, so the tests pin the two failure shapes
|
||||
that would otherwise look like "no traffic right now": a refresh error must keep
|
||||
the last good data AND report the error, and a schema change (every record
|
||||
unparseable) must be an error, not an empty list.
|
||||
"""
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
from app.internal import ny511
|
||||
from app.internal.auth import require_service_or_firebase_token
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
CAM = {"Latitude": 41.03, "Longitude": -73.76, "ID": "NYSDOT-1", "Name": "I-287 at Exit 5",
|
||||
"DirectionOfTravel": "Unknown", "RoadwayName": "I-287", "Url": "https://511ny.org/map/Cctv/1",
|
||||
"VideoUrl": None, "Disabled": False, "Blocked": False}
|
||||
EVENT = {"Latitude": 41.019265, "Longitude": -73.797869, "ID": "TRANSCOM-1", "EventType": "roadwork",
|
||||
"EventSubType": "Gas main repairs", "Severity": "Unknown", "RoadwayName": "NY 100",
|
||||
"DirectionOfTravel": "Both directions", "CountyName": "Westchester", "Description": "x" * 900,
|
||||
"StartDate": "28/09/2026 09:00:00", "PlannedEndDate": "", "LastUpdated": "26/09/2026 14:01:12"}
|
||||
|
||||
|
||||
def setup_function():
|
||||
app.dependency_overrides[require_service_or_firebase_token] = lambda: {"admin": True}
|
||||
for feed in (ny511.cameras, ny511.events):
|
||||
feed.items, feed.fetched_at, feed.error, feed._next_attempt = [], None, None, 0.0
|
||||
|
||||
|
||||
def teardown_function():
|
||||
app.dependency_overrides.pop(require_service_or_firebase_token, None)
|
||||
|
||||
|
||||
def test_normalize_camera_skips_disabled_blocked_and_zero_coords():
|
||||
assert ny511.normalize_camera(CAM)["image_url"] == "https://511ny.org/map/Cctv/1"
|
||||
assert ny511.normalize_camera({**CAM, "Disabled": True}) is None
|
||||
assert ny511.normalize_camera({**CAM, "Blocked": True}) is None
|
||||
assert ny511.normalize_camera({**CAM, "Latitude": 0, "Longitude": 0}) is None
|
||||
|
||||
|
||||
def test_normalize_event_parses_day_first_dates_and_truncates_description():
|
||||
e = ny511.normalize_event(EVENT)
|
||||
assert e["start_local"] == "2026-09-28T09:00:00" # DD/MM, not MM/DD
|
||||
assert e["planned_end_local"] is None
|
||||
assert len(e["description"]) == ny511._DESCRIPTION_MAX + 1
|
||||
|
||||
|
||||
def test_failed_refresh_keeps_last_good_data_and_reports_error():
|
||||
feed = ny511._Feed("getcameras", 3600, ny511.normalize_camera)
|
||||
with patch.object(ny511, "_fetch", AsyncMock(return_value=[ny511.normalize_camera(CAM)])):
|
||||
asyncio.run(feed.get())
|
||||
feed._next_attempt = 0.0
|
||||
with patch.object(ny511, "_fetch", AsyncMock(side_effect=RuntimeError("boom"))):
|
||||
asyncio.run(feed.get())
|
||||
assert len(feed.items) == 1 and feed.fetched_at is not None
|
||||
assert "boom" in feed.error
|
||||
assert feed._next_attempt - feed.fetched_at <= ny511.RETRY_AFTER_FAILURE_S + 5 # retries soon, not after the full TTL
|
||||
|
||||
|
||||
def test_schema_change_is_an_error_not_an_empty_layer():
|
||||
class Resp:
|
||||
def raise_for_status(self): pass
|
||||
def json(self): return [{"lat": 1, "lng": 2}] # renamed fields -> nothing parses
|
||||
|
||||
class Client:
|
||||
async def __aenter__(self): return self
|
||||
async def __aexit__(self, *a): pass
|
||||
async def get(self, *a, **k): return Resp()
|
||||
|
||||
with patch.object(ny511.httpx, "AsyncClient", lambda **k: Client()):
|
||||
try:
|
||||
asyncio.run(ny511._fetch("getcameras", ny511.normalize_camera))
|
||||
assert False, "expected a schema-change error"
|
||||
except ValueError as e:
|
||||
assert "schema" in str(e)
|
||||
|
||||
|
||||
def test_endpoint_filters_to_bbox_and_reports_status():
|
||||
far = {**CAM, "ID": "NYSDOT-2", "Latitude": 42.9, "Longitude": -78.8} # Buffalo
|
||||
for feed, rows, norm in ((ny511.cameras, [CAM, far], ny511.normalize_camera), (ny511.events, [EVENT], ny511.normalize_event)):
|
||||
feed.items = [norm(r) for r in rows]
|
||||
feed.fetched_at, feed._next_attempt = 1.0, float("inf")
|
||||
r = client.get("/traffic/511", params={"south": 40.9, "west": -74.0, "north": 41.4, "east": -73.4})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert [c["id"] for c in body["cameras"]] == ["NYSDOT-1"]
|
||||
assert body["cameras_status"] == {"fetched_at": 1.0, "error": None, "total_in_bbox": 1, "truncated": False}
|
||||
assert len(body["events"]) == 1
|
||||
|
||||
|
||||
def test_endpoint_rejects_inverted_bbox():
|
||||
r = client.get("/traffic/511", params={"south": 41.4, "west": -74.0, "north": 40.9, "east": -73.4})
|
||||
assert r.status_code == 400
|
||||
@@ -0,0 +1,94 @@
|
||||
"""
|
||||
Admin-created users must land in an org. firestore.rules gates every read on
|
||||
the org_id claim, so a viewer created via POST /admin/users without one saw no
|
||||
incidents or calls at all (reported 2026-09-27).
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
from app.internal.auth import require_admin_token
|
||||
from app.routers import users
|
||||
|
||||
client = TestClient(app)
|
||||
ADMIN = {"uid": "admin-1", "email": "a@x", "role": "admin", "org_id": "org-A"}
|
||||
|
||||
|
||||
def _as(decoded):
|
||||
app.dependency_overrides[require_admin_token] = lambda: decoded
|
||||
|
||||
|
||||
def teardown_function():
|
||||
app.dependency_overrides.pop(require_admin_token, None)
|
||||
|
||||
|
||||
def _fb(**kw):
|
||||
base = dict(uid="u1", email="v@x", display_name="", custom_claims={}, disabled=False,
|
||||
email_verified=False, user_metadata=SimpleNamespace(creation_timestamp=0, last_sign_in_timestamp=None))
|
||||
return SimpleNamespace(**{**base, **kw})
|
||||
|
||||
|
||||
def _run(method, path, body, fb_user, orgs=("org-A", "founding")):
|
||||
fa = users.firebase_auth
|
||||
with patch.object(fa, "create_user", return_value=fb_user, create=True), \
|
||||
patch.object(fa, "get_user", return_value=fb_user, create=True), \
|
||||
patch.object(fa, "set_custom_user_claims", create=True) as set_claims, \
|
||||
patch.object(fa, "generate_password_reset_link", return_value="link", create=True), \
|
||||
patch.object(users.fstore, "doc_get", AsyncMock(side_effect=lambda c, i: {"org_id": i} if c == "organizations" and i in orgs else None)), \
|
||||
patch.object(users.fstore, "doc_set", AsyncMock()) as doc_set, \
|
||||
patch.object(users.audit, "write_audit", AsyncMock()):
|
||||
resp = getattr(client, method)(path, json=body)
|
||||
members = [c for c in doc_set.await_args_list if c.args[0] == "org_members"]
|
||||
return resp, set_claims, members
|
||||
|
||||
|
||||
def test_created_viewer_joins_the_admins_org_as_member():
|
||||
_as(ADMIN)
|
||||
resp, set_claims, members = _run("post", "/admin/users", {"email": "v@x", "role": "viewer"}, _fb())
|
||||
assert resp.status_code == 200, resp.text
|
||||
claims = set_claims.call_args.args[1]
|
||||
assert (claims["org_id"], claims["org_role"], claims["role"]) == ("org-A", "member", "viewer")
|
||||
assert members and members[0].args[2]["org_id"] == "org-A"
|
||||
|
||||
|
||||
def test_admin_without_an_org_claim_defaults_to_founding():
|
||||
_as({k: v for k, v in ADMIN.items() if k != "org_id"})
|
||||
resp, set_claims, _ = _run("post", "/admin/users", {"email": "v@x", "role": "viewer"}, _fb())
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert set_claims.call_args.args[1]["org_id"] == "founding"
|
||||
|
||||
|
||||
def test_unknown_org_is_rejected():
|
||||
_as(ADMIN)
|
||||
resp, set_claims, _ = _run("post", "/admin/users", {"email": "v@x", "role": "viewer", "org_id": "nope"}, _fb())
|
||||
assert resp.status_code == 400
|
||||
set_claims.assert_not_called()
|
||||
|
||||
|
||||
def test_editing_an_orgless_user_heals_them():
|
||||
_as(ADMIN)
|
||||
resp, set_claims, members = _run("patch", "/admin/users/u1", {"role": "viewer"}, _fb(custom_claims={"role": "viewer"}))
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert set_claims.call_args.args[1]["org_id"] == "org-A"
|
||||
assert members
|
||||
|
||||
|
||||
def test_editing_never_silently_moves_an_existing_org():
|
||||
_as(ADMIN)
|
||||
fb = _fb(custom_claims={"role": "viewer", "org_id": "org-B", "org_role": "member"})
|
||||
resp, set_claims, members = _run("patch", "/admin/users/u1", {"role": "viewer"}, fb)
|
||||
assert resp.status_code == 200
|
||||
assert set_claims.call_args.args[1]["org_id"] == "org-B"
|
||||
assert not members
|
||||
|
||||
|
||||
def test_explicit_org_id_moves_a_self_provisioned_owner_into_the_network():
|
||||
_as(ADMIN)
|
||||
fb = _fb(custom_claims={"role": "viewer", "org_id": "own-empty-org", "org_role": "owner"})
|
||||
resp, set_claims, members = _run("patch", "/admin/users/u1", {"org_id": "org-A"}, fb)
|
||||
assert resp.status_code == 200, resp.text
|
||||
claims = set_claims.call_args.args[1]
|
||||
assert (claims["org_id"], claims["org_role"]) == ("org-A", "member")
|
||||
assert members and members[0].args[2]["org_id"] == "org-A"
|
||||
@@ -367,6 +367,26 @@ function UserDetailPanel({
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showSessions, setShowSessions] = useState(false);
|
||||
const { orgId: myOrgId } = useAuth();
|
||||
const [moving, setMoving] = useState(false);
|
||||
|
||||
// A user outside the admin's org reads none of its incidents or calls —
|
||||
// e.g. a viewer whose first login self-provisioned an empty org.
|
||||
async function handleMoveToMyOrg() {
|
||||
if (!myOrgId) return;
|
||||
if (!confirm(`Move ${detail.email ?? "this user"} into your organization as a member? They'll need to sign out and back in.`)) return;
|
||||
setMoving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const updated = await c2api.updateUser(user.uid, { org_id: myOrgId });
|
||||
onUpdated(updated);
|
||||
setDetail((d) => ({ ...d, ...updated }));
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setMoving(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch full detail (sessions) lazily
|
||||
useEffect(() => {
|
||||
@@ -496,6 +516,25 @@ function UserDetailPanel({
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-800 pt-4 space-y-2 text-xs">
|
||||
<div className="flex justify-between items-center gap-3">
|
||||
<span className="text-gray-500">Organization</span>
|
||||
<span className={`font-mono truncate ${detail.org_id && detail.org_id === myOrgId ? "text-gray-300" : "text-yellow-400"}`}>
|
||||
{!detail.org_id
|
||||
? "None: sees no data"
|
||||
: detail.org_id === myOrgId
|
||||
? `Your org (${detail.org_role ?? "member"})`
|
||||
: `Other org ${detail.org_id.slice(0, 8)}… (${detail.org_role ?? "member"})`}
|
||||
</span>
|
||||
</div>
|
||||
{myOrgId && detail.org_id !== myOrgId && (
|
||||
<button
|
||||
onClick={handleMoveToMyOrg}
|
||||
disabled={moving}
|
||||
className="w-full bg-yellow-900/60 hover:bg-yellow-800/60 disabled:opacity-50 text-yellow-200 px-3 py-1.5 rounded-lg transition-colors"
|
||||
>
|
||||
{moving ? "Moving…" : "Move to my organization"}
|
||||
</button>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Status</span>
|
||||
<span className={detail.disabled ? "text-red-400" : "text-green-400"}>
|
||||
|
||||
@@ -67,6 +67,10 @@ html, body {
|
||||
font-family: var(--font-mono), ui-monospace, monospace;
|
||||
}
|
||||
|
||||
/* Dark mode: raw gray-600/700 text sits ~2.4:1 on the near-black page — below
|
||||
* readable. Lift both to gray-500 (~4:1). */
|
||||
.dark .text-gray-600, .dark .text-gray-700 { color: #6b7280; }
|
||||
|
||||
/* ── Light mode overrides ─────────────────────────────────────────────────── */
|
||||
/*
|
||||
* The app's components use hardcoded dark-palette Tailwind classes (bg-gray-9xx,
|
||||
@@ -84,6 +88,7 @@ html:not(.dark) .bg-gray-900\/60 { background-color: rgba(255,255,255,0.85) !
|
||||
html:not(.dark) .bg-gray-900\/50 { background-color: rgba(255,255,255,0.75) !important; }
|
||||
html:not(.dark) .bg-gray-900\/30 { background-color: rgba(255,255,255,0.50) !important; }
|
||||
html:not(.dark) .bg-gray-800 { background-color: #f1f5f9 !important; }
|
||||
html:not(.dark) .bg-gray-800\/60 { background-color: rgba(226,232,240,0.70) !important; }
|
||||
html:not(.dark) .bg-gray-800\/40 { background-color: rgba(241,245,249,0.60) !important; }
|
||||
html:not(.dark) .bg-gray-800\/30 { background-color: rgba(241,245,249,0.50) !important; }
|
||||
html:not(.dark) .bg-gray-700 { background-color: #e2e8f0 !important; }
|
||||
@@ -91,16 +96,25 @@ html:not(.dark) .bg-gray-700 { background-color: #e2e8f0 !important; }
|
||||
/* Borders */
|
||||
html:not(.dark) .border-gray-800 { border-color: #e2e8f0 !important; }
|
||||
html:not(.dark) .border-gray-700 { border-color: #cbd5e1 !important; }
|
||||
html:not(.dark) .border-gray-600 { border-color: #94a3b8 !important; }
|
||||
html:not(.dark) .border-gray-800\/60 { border-color: #e2e8f0 !important; }
|
||||
html:not(.dark) .divide-gray-800 > * + * { border-color: #e2e8f0 !important; }
|
||||
|
||||
/* Text */
|
||||
html:not(.dark) .text-white { color: #0f172a !important; }
|
||||
html:not(.dark) .text-gray-100 { color: #1e293b !important; }
|
||||
html:not(.dark) .text-gray-200 { color: #1e293b !important; }
|
||||
html:not(.dark) .text-gray-300 { color: #334155 !important; }
|
||||
html:not(.dark) .text-gray-400 { color: #475569 !important; }
|
||||
html:not(.dark) .text-gray-500 { color: #64748b !important; }
|
||||
html:not(.dark) .text-gray-600 { color: #94a3b8 !important; }
|
||||
|
||||
/* …except on saturated fills (primary/danger/success buttons), where the fill
|
||||
* stays dark in both themes — remapping to navy made their labels unreadable. */
|
||||
html:not(.dark) .text-white:is(.bg-accent, .bg-sev-major, .bg-sev-moderate,
|
||||
.bg-indigo-500, .bg-indigo-600, .bg-indigo-700, .bg-red-600, .bg-red-700,
|
||||
.bg-green-600, .bg-green-700, .bg-yellow-700, .bg-yellow-800, .bg-gray-600) { color: #ffffff !important; }
|
||||
|
||||
/* Hover states */
|
||||
html:not(.dark) .hover\:bg-gray-900:hover { background-color: #f8fafc !important; }
|
||||
html:not(.dark) .hover\:bg-gray-900\/50:hover { background-color: rgba(255,255,255,0.75) !important; }
|
||||
@@ -177,6 +191,18 @@ html:not(.dark) .border-indigo-800 { border-color: #a5b4fc !important; }
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* Pinned 511 camera tiles (MapView DotCameraLayer): strip Leaflet's tooltip
|
||||
* padding and let the tile's own theme colours show, so the tile is no bigger
|
||||
* than the video it holds. */
|
||||
.leaflet-tooltip.drb-cam-pin {
|
||||
padding: 0;
|
||||
border-radius: 6px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line-strong);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.45);
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
/* ── Form inputs ─────────────────────────────────────────────────────────── */
|
||||
html:not(.dark) input:not([type="submit"]):not([type="button"]):not([type="reset"]),
|
||||
html:not(.dark) select,
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useSystems } from "@/lib/useSystems";
|
||||
import { useCalls } from "@/lib/useCalls";
|
||||
import { StatusBadge } from "@/components/StatusBadge";
|
||||
import { NodeConfigModal } from "@/components/NodeConfigModal";
|
||||
import { SdrSettings } from "@/components/SdrSettings";
|
||||
import { CallRow } from "@/components/CallRow";
|
||||
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
@@ -336,6 +337,8 @@ export default function NodeDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SdrSettings node={node} canEdit={isAdmin} />
|
||||
|
||||
{/* Recent calls */}
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Recent Calls</h2>
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type HlsType from "hls.js";
|
||||
import type { Ny511Camera } from "@/lib/types";
|
||||
|
||||
// 511NY stills are served with max-age=60, so refreshing faster buys nothing.
|
||||
const SNAPSHOT_REFRESH_MS = 60_000;
|
||||
|
||||
/**
|
||||
* One 511NY camera: the live HLS stream when the camera has one (NYSDOT's
|
||||
* skyvdn hosts send Access-Control-Allow-Origin: *), otherwise its snapshot on
|
||||
* a timer. A stream that fails falls back to the snapshot rather than a black box.
|
||||
*/
|
||||
export function CameraFeed({ camera, className }: { camera: Ny511Camera; className?: string }) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const [videoFailed, setVideoFailed] = useState(false);
|
||||
const [tick, setTick] = useState(() => Date.now());
|
||||
const useVideo = !!camera.video_url && !videoFailed;
|
||||
|
||||
useEffect(() => {
|
||||
if (useVideo) return;
|
||||
const id = setInterval(() => setTick(Date.now()), SNAPSHOT_REFRESH_MS);
|
||||
return () => clearInterval(id);
|
||||
}, [useVideo]);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
const src = camera.video_url;
|
||||
if (!useVideo || !video || !src) return;
|
||||
let hls: HlsType | null = null;
|
||||
let cancelled = false;
|
||||
|
||||
if (video.canPlayType("application/vnd.apple.mpegurl")) {
|
||||
video.src = src; // Safari/iOS play HLS natively
|
||||
} else {
|
||||
import("hls.js")
|
||||
.then(({ default: Hls }) => {
|
||||
if (cancelled) return;
|
||||
if (!Hls.isSupported()) { setVideoFailed(true); return; }
|
||||
hls = new Hls({ maxBufferLength: 10, backBufferLength: 0 });
|
||||
hls.on(Hls.Events.ERROR, (_evt, data) => { if (data.fatal) setVideoFailed(true); });
|
||||
hls.loadSource(src);
|
||||
hls.attachMedia(video);
|
||||
})
|
||||
.catch(() => setVideoFailed(true));
|
||||
}
|
||||
return () => {
|
||||
cancelled = true;
|
||||
hls?.destroy();
|
||||
video.removeAttribute("src");
|
||||
video.load(); // stop the download, not just the playback
|
||||
};
|
||||
}, [useVideo, camera.video_url]);
|
||||
|
||||
if (useVideo) {
|
||||
return <video ref={videoRef} muted autoPlay playsInline className={className} onError={() => setVideoFailed(true)} />;
|
||||
}
|
||||
if (!camera.image_url) {
|
||||
return <div className={`${className ?? ""} flex items-center justify-center text-[10px] text-ink-muted`}>No image</div>;
|
||||
}
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
return <img src={`${camera.image_url}?t=${tick}`} alt={`Camera: ${camera.name}`} className={className} />;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
FeatureGroup,
|
||||
LayersControl,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
TileLayer,
|
||||
Tooltip,
|
||||
useMap,
|
||||
useMapEvents,
|
||||
} from "react-leaflet";
|
||||
import L from "leaflet";
|
||||
import type { AircraftTrack, CallRecord, IncidentRecord, NodeRecord, NodeStatus } from "@/lib/types";
|
||||
@@ -19,6 +21,9 @@ import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
||||
import { useAircraft } from "@/lib/useAircraft";
|
||||
import { useAircraftTrail } from "@/lib/useAircraftTrail";
|
||||
import { useVessels } from "@/lib/useVessels";
|
||||
import { use511 } from "@/lib/use511";
|
||||
import type { Ny511Camera, Ny511Event, Ny511FeedStatus } from "@/lib/types";
|
||||
import { CameraFeed } from "@/components/CameraFeed";
|
||||
|
||||
// ── Leaflet icon fix ──────────────────────────────────────────────────────────
|
||||
delete (L.Icon.Default.prototype as unknown as Record<string, unknown>)._getIconUrl;
|
||||
@@ -117,6 +122,11 @@ function altitudeColor(altFt: number | null): string {
|
||||
return `hsl(${hue.toFixed(0)}, 88%, 48%)`;
|
||||
}
|
||||
|
||||
// Legend ticks for the altitude ramp, evenly spaced (the ramp itself isn't linear).
|
||||
const ALTITUDE_LEGEND_TICKS: [number, string][] = [
|
||||
[1000, "1k"], [4000, "4k"], [10000, "10k"], [20000, "20k"], [40000, "40k"],
|
||||
];
|
||||
|
||||
const AIRLINER_PATH =
|
||||
"M32 2 C34.2 2 35.2 5 35.2 8 L35.2 23 L61 37.5 L61 42 L35.2 35 L34.2 51 L42.5 57.5 L42.5 61 L32 58.5 " +
|
||||
"L21.5 61 L21.5 57.5 L29.8 51 L28.8 35 L3 42 L3 37.5 L28.8 23 L28.8 8 C28.8 5 29.8 2 32 2 Z";
|
||||
@@ -158,47 +168,82 @@ function AircraftTrail({ icao, current }: { icao: string; current: AircraftTrack
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-wide text-ink-muted">{label}</div>
|
||||
<div className="text-ink font-medium tabular-nums">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Details dock at the right edge (bottom sheet on phones, above the incident
|
||||
// drawer) instead of a popup over the plane, so the trail stays visible and
|
||||
// the map can be panned to follow it.
|
||||
function AircraftPanel({ a, onClose }: { a: AircraftTrack; onClose: () => void }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
// The panel is portaled into the Leaflet container, whose native listeners
|
||||
// would otherwise treat clicks/scrolls here as map clicks (deselect) or zoom.
|
||||
if (!ref.current) return;
|
||||
L.DomEvent.disableClickPropagation(ref.current);
|
||||
L.DomEvent.disableScrollPropagation(ref.current);
|
||||
}, []);
|
||||
const fmt = (n: number | null, unit: string) => (n == null ? "—" : `${Math.round(n).toLocaleString()} ${unit}`);
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="absolute left-3 right-3 bottom-12 md:left-auto md:bottom-auto md:top-[5.5rem] md:right-3 md:w-60 z-[1002] bg-surface/95 backdrop-blur-sm border border-line rounded-lg shadow-lg text-xs"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 px-3 pt-2.5 pb-2 border-b border-line">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="inline-block w-2.5 h-2.5 rounded-full shrink-0" style={{ background: altitudeColor(a.altitude_ft) }} />
|
||||
<span className="text-ink font-semibold text-sm truncate">{a.callsign || a.icao}</span>
|
||||
</div>
|
||||
<div className="text-ink-muted mt-0.5">ICAO {a.icao}</div>
|
||||
</div>
|
||||
<button onClick={onClose} aria-label="Close aircraft details" className="text-ink-muted hover:text-ink px-1 leading-none text-base">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-2 px-3 py-2.5">
|
||||
<Stat label="Altitude" value={fmt(a.altitude_ft, "ft")} />
|
||||
<Stat label="Speed" value={fmt(a.ground_speed_kt, "kt")} />
|
||||
<Stat label="Heading" value={a.track_deg == null ? "—" : `${Math.round(a.track_deg)}°`} />
|
||||
<Stat label="Last heard" value={timeAgo(new Date(a.last_seen))} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AircraftLayer() {
|
||||
const map = useMap();
|
||||
const { aircraft } = useAircraft();
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const positioned = aircraft.filter((a) => a.lat != null && a.lon != null);
|
||||
const selectedTrack = positioned.find((a) => a.icao === selected);
|
||||
|
||||
// Clicking empty map deselects; marker clicks don't reach the map.
|
||||
useMapEvents({ click: () => setSelected(null) });
|
||||
|
||||
return (
|
||||
<>
|
||||
{selectedTrack && <AircraftTrail icao={selectedTrack.icao} current={selectedTrack} />}
|
||||
{selectedTrack &&
|
||||
createPortal(<AircraftPanel a={selectedTrack} onClose={() => setSelected(null)} />, map.getContainer())}
|
||||
{positioned.map((a) => (
|
||||
<Marker
|
||||
key={a.icao}
|
||||
position={[a.lat as number, a.lon as number]}
|
||||
icon={aircraftIcon(a.track_deg, a.altitude_ft, a.icao === selected)}
|
||||
zIndexOffset={a.icao === selected ? 1000 : 0}
|
||||
eventHandlers={{
|
||||
click: () => setSelected(a.icao),
|
||||
popupclose: () => setSelected((cur) => (cur === a.icao ? null : cur)),
|
||||
}}
|
||||
eventHandlers={{ click: () => setSelected((cur) => (cur === a.icao ? null : a.icao)) }}
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -14]}>
|
||||
{a.callsign || a.icao}
|
||||
{a.altitude_ft != null && ` · ${Math.round(a.altitude_ft).toLocaleString()} ft`}
|
||||
</Tooltip>
|
||||
<Popup minWidth={160}>
|
||||
<div className="space-y-1">
|
||||
<div className="font-semibold">{a.callsign || a.icao}</div>
|
||||
<div className="text-xs text-ink-muted">ICAO {a.icao}</div>
|
||||
{a.altitude_ft != null && (
|
||||
<div className="text-xs">
|
||||
<span
|
||||
className="inline-block w-2 h-2 rounded-full mr-1 align-middle"
|
||||
style={{ background: altitudeColor(a.altitude_ft) }}
|
||||
/>
|
||||
Altitude: {Math.round(a.altitude_ft).toLocaleString()} ft
|
||||
</div>
|
||||
)}
|
||||
{a.ground_speed_kt != null && <div className="text-xs">Speed: {Math.round(a.ground_speed_kt)} kt</div>}
|
||||
{a.track_deg != null && <div className="text-xs">Heading: {Math.round(a.track_deg)}°</div>}
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
))}
|
||||
</>
|
||||
@@ -206,12 +251,34 @@ function AircraftLayer() {
|
||||
}
|
||||
|
||||
// ── Vessel icon — node-26#9 second-SDR AIS overlay ─────────────────────────────
|
||||
// MMSI 99xxxxxxx is an aid to navigation (buoy, beacon, light), not a vessel —
|
||||
// on the Hudson most of what a node hears is buoys (server-26#186).
|
||||
function isAidToNavigation(mmsi: string): boolean {
|
||||
return /^99\d{7}$/.test(mmsi);
|
||||
}
|
||||
|
||||
// AIS reports heading 511 (and course 360) for "not available".
|
||||
function aisHeading(deg: number | null): number | null {
|
||||
return deg == null || deg >= 360 ? null : deg;
|
||||
}
|
||||
|
||||
function vesselIcon(headingDeg: number | null): L.DivIcon {
|
||||
const size = 14;
|
||||
const rotation = headingDeg ?? 0;
|
||||
const size = 22;
|
||||
return L.divIcon({
|
||||
className: "",
|
||||
html: `<div style="width:${size}px;height:${size}px;transform:rotate(${rotation}deg)"><svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="var(--accent)" stroke="var(--surface)" stroke-width="1"><path d="M12 2 L18 14 L18 20 L6 20 L6 14 Z"/></svg></div>`,
|
||||
html:
|
||||
`<div style="width:${size}px;height:${size}px;transform:rotate(${aisHeading(headingDeg) ?? 0}deg);filter:drop-shadow(0 1px 1px rgba(0,0,0,.45))">` +
|
||||
`<svg width="${size}" height="${size}" viewBox="0 0 24 24"><path d="M12 2 L18 12 L18 21 L6 21 L6 12 Z" fill="hsl(190, 80%, 42%)" stroke="#000" stroke-width="1.25" stroke-linejoin="round"/></svg></div>`,
|
||||
iconSize: [size, size],
|
||||
iconAnchor: [size / 2, size / 2],
|
||||
});
|
||||
}
|
||||
|
||||
function aidToNavigationIcon(): L.DivIcon {
|
||||
const size = 12;
|
||||
return L.divIcon({
|
||||
className: "",
|
||||
html: `<svg width="${size}" height="${size}" viewBox="0 0 12 12"><rect x="2" y="2" width="8" height="8" transform="rotate(45 6 6)" fill="hsl(50, 95%, 55%)" stroke="#000" stroke-width="1"/></svg>`,
|
||||
iconSize: [size, size],
|
||||
iconAnchor: [size / 2, size / 2],
|
||||
});
|
||||
@@ -223,17 +290,328 @@ function VesselLayer() {
|
||||
<>
|
||||
{vessels
|
||||
.filter((v) => v.lat != null && v.lon != null)
|
||||
.map((v) => (
|
||||
<Marker key={v.mmsi} position={[v.lat as number, v.lon as number]} icon={vesselIcon(v.heading_deg)}>
|
||||
<Popup minWidth={160}>
|
||||
.map((v) => {
|
||||
const aton = isAidToNavigation(v.mmsi);
|
||||
return (
|
||||
<Marker
|
||||
key={v.mmsi}
|
||||
position={[v.lat as number, v.lon as number]}
|
||||
icon={aton ? aidToNavigationIcon() : vesselIcon(v.heading_deg)}
|
||||
zIndexOffset={aton ? -100 : 0}
|
||||
>
|
||||
<Popup minWidth={160}>
|
||||
<div className="space-y-1">
|
||||
<div className="font-semibold">{aton ? `Aid to navigation${v.name ? ` ${v.name}` : ""}` : v.name || v.mmsi}</div>
|
||||
<div className="text-xs text-ink-muted">MMSI {v.mmsi}</div>
|
||||
{!aton && v.speed_kt != null && <div className="text-xs">Speed: {Math.round(v.speed_kt)} kt</div>}
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Weather radar ────────────────────────────────────────────────────────────
|
||||
// IEM tiles are static once loaded, so refresh them in place with a
|
||||
// cache-buster. Never remount the layer (e.g. via `key`) to refresh it: a
|
||||
// remounted child of an unchecked LayersControl.Overlay is re-registered with
|
||||
// the control but not re-added to the map, which switched radar off every 5 min.
|
||||
const RADAR_URL = "https://mesonet.agron.iastate.edu/cache/tile.py/1.0.0/nexrad-n0r-900913/{z}/{x}/{y}.png";
|
||||
const RADAR_REFRESH_MS = 5 * 60 * 1000;
|
||||
|
||||
function RadarLayer() {
|
||||
const ref = useRef<L.TileLayer>(null);
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => ref.current?.setUrl(`${RADAR_URL}?_=${Date.now()}`), RADAR_REFRESH_MS);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
return (
|
||||
<TileLayer
|
||||
ref={ref}
|
||||
url={RADAR_URL}
|
||||
attribution='Radar © <a href="https://mesonet.agron.iastate.edu/">IEM/NWS</a>'
|
||||
opacity={0.65}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 511NY traffic layers (server-26#183) ─────────────────────────────────────
|
||||
// Overlay names double as the event keys for overlayadd/overlayremove.
|
||||
const OVERLAY_DOT_CAMERAS = "DOT Cameras";
|
||||
const OVERLAY_TRAFFIC_EVENTS = "Traffic Events";
|
||||
|
||||
/** True while the named LayersControl overlay is checked. Overlays start unchecked. */
|
||||
function useOverlayShown(map: L.Map, name: string): boolean {
|
||||
const [shown, setShown] = useState(false);
|
||||
useEffect(() => {
|
||||
const on = (e: L.LayersControlEvent) => e.name === name && setShown(true);
|
||||
const off = (e: L.LayersControlEvent) => e.name === name && setShown(false);
|
||||
map.on("overlayadd", on);
|
||||
map.on("overlayremove", off);
|
||||
return () => { map.off("overlayadd", on); map.off("overlayremove", off); };
|
||||
}, [map, name]);
|
||||
return shown;
|
||||
}
|
||||
|
||||
/** Small count badge for an LOD cluster icon; empty when the marker stands alone. */
|
||||
function countBadge(count: number): string {
|
||||
if (count < 2) return "";
|
||||
const label = count > 99 ? "99+" : String(count);
|
||||
return `<span style="position:absolute;top:-7px;right:-9px;min-width:14px;height:14px;padding:0 3px;border-radius:7px;background:#0f172a;color:#fff;border:1px solid #fff;font:700 9px/12px sans-serif;text-align:center;box-sizing:border-box">${label}</span>`;
|
||||
}
|
||||
|
||||
function cameraIcon(count = 1): L.DivIcon {
|
||||
return L.divIcon({
|
||||
className: "",
|
||||
html: `<div style="position:relative;width:16px;height:12px"><svg width="16" height="12" viewBox="0 0 16 12"><rect x="0.5" y="1.5" width="11" height="9" rx="2" fill="#1e3a5f" stroke="#93c5fd" stroke-width="1"/><circle cx="6" cy="6" r="2.5" fill="none" stroke="#93c5fd" stroke-width="1.25"/><polygon points="12,4 15.5,2 15.5,10 12,8" fill="#93c5fd"/></svg>${countBadge(count)}</div>`,
|
||||
iconSize: [16, 12],
|
||||
iconAnchor: [8, 6],
|
||||
});
|
||||
}
|
||||
|
||||
// Shape + glyph per 511 event type, so the layer reads without colour alone.
|
||||
// Order is LOD priority: when events share a cell, the earliest type represents it.
|
||||
const EVENT_STYLE: Record<string, { glyph: string; fill: string; label: string }> = {
|
||||
accidentsAndIncidents: { glyph: "!", fill: "#dc2626", label: "Accident / incident" },
|
||||
closures: { glyph: "×", fill: "#ea580c", label: "Closure" },
|
||||
roadwork: { glyph: "W", fill: "#ca8a04", label: "Roadwork" },
|
||||
specialEvents: { glyph: "E", fill: "#7c3aed", label: "Special event" },
|
||||
transitOperations: { glyph: "T", fill: "#0891b2", label: "Transit" },
|
||||
};
|
||||
const EVENT_STYLE_OTHER = { glyph: "i", fill: "#6b7280", label: "Other" };
|
||||
const EVENT_RANK = Object.keys(EVENT_STYLE);
|
||||
const eventRank = (e: Ny511Event) => {
|
||||
const r = EVENT_RANK.indexOf(e.type);
|
||||
return r < 0 ? EVENT_RANK.length : r;
|
||||
};
|
||||
|
||||
function trafficEventIcon(type: string, count = 1): L.DivIcon {
|
||||
const st = EVENT_STYLE[type] ?? EVENT_STYLE_OTHER;
|
||||
return L.divIcon({
|
||||
className: "",
|
||||
html: `<div style="position:relative;width:16px;height:16px"><svg width="16" height="16" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="${st.fill}" stroke="#000" stroke-width="1"/><text x="8" y="12" text-anchor="middle" font-size="11" font-weight="700" font-family="sans-serif" fill="#fff">${st.glyph}</text></svg>${countBadge(count)}</div>`,
|
||||
iconSize: [16, 16],
|
||||
iconAnchor: [8, 8],
|
||||
});
|
||||
}
|
||||
|
||||
// ── 511 level of detail ──────────────────────────────────────────────────────
|
||||
// Zoomed out, a county's worth of cameras/events buries the map. Below
|
||||
// LOD_FULL_ZOOM, markers are bucketed into a grid of LOD_CELL_PX screen pixels
|
||||
// and each cell draws one representative with a count badge; clicking a
|
||||
// cluster zooms in on it. The grid is in world pixels (map.project), so it
|
||||
// only changes on zoom — panning never reshuffles which marker represents a cell.
|
||||
const LOD_CELL_PX = 40;
|
||||
const LOD_FULL_ZOOM = 14;
|
||||
|
||||
interface LodCell<T> { rep: T; count: number }
|
||||
|
||||
function useMapZoom(map: L.Map): number {
|
||||
const [zoom, setZoom] = useState(() => map.getZoom());
|
||||
useEffect(() => {
|
||||
const h = () => setZoom(map.getZoom());
|
||||
map.on("zoomend", h);
|
||||
return () => { map.off("zoomend", h); };
|
||||
}, [map]);
|
||||
return zoom;
|
||||
}
|
||||
|
||||
function lodCells<T extends { lat: number; lon: number }>(
|
||||
items: T[], map: L.Map, zoom: number, rank: (t: T) => number = () => 0,
|
||||
): LodCell<T>[] {
|
||||
if (zoom >= LOD_FULL_ZOOM) return items.map((rep) => ({ rep, count: 1 }));
|
||||
const cells = new Map<string, LodCell<T>>();
|
||||
for (const it of items) {
|
||||
const p = map.project([it.lat, it.lon], zoom);
|
||||
const key = `${Math.floor(p.x / LOD_CELL_PX)}:${Math.floor(p.y / LOD_CELL_PX)}`;
|
||||
const cell = cells.get(key);
|
||||
if (!cell) cells.set(key, { rep: it, count: 1 });
|
||||
else {
|
||||
cell.count++;
|
||||
if (rank(it) < rank(cell.rep)) cell.rep = it;
|
||||
}
|
||||
}
|
||||
return Array.from(cells.values());
|
||||
}
|
||||
|
||||
function zoomInto(map: L.Map, lat: number, lon: number) {
|
||||
map.setView([lat, lon], Math.min(map.getZoom() + 2, LOD_FULL_ZOOM));
|
||||
}
|
||||
|
||||
function fmtLocal(iso: string | null): string | null {
|
||||
if (!iso) return null;
|
||||
const d = new Date(iso); // naive ISO parses as browser-local; 511NY stamps are NY local
|
||||
return isNaN(d.getTime()) ? null : d.toLocaleString([], { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
|
||||
}
|
||||
|
||||
/** Surfaces a dead or stale 511 feed on the map instead of an unexplained empty layer. */
|
||||
function FeedProblem({ map, label, status, fetchError }: { map: L.Map; label: string; status?: Ny511FeedStatus; fetchError: string | null }) {
|
||||
let msg: string | null = null;
|
||||
if (fetchError) msg = `${label}: could not reach server`;
|
||||
else if (status?.error) {
|
||||
const age = status.fetched_at ? `showing data from ${Math.round((Date.now() / 1000 - status.fetched_at) / 60)} min ago` : "no data yet";
|
||||
msg = `${label}: 511NY feed error, ${age}`;
|
||||
} else if (status?.truncated) msg = `${label}: showing ${1500} of ${status.total_in_bbox} — zoom in`;
|
||||
if (!msg) return null;
|
||||
return createPortal(
|
||||
<div className="absolute bottom-8 left-3 z-[1001] bg-surface/90 border border-line rounded px-2 py-1 text-xs text-ink-2 pointer-events-none">{msg}</div>,
|
||||
map.getContainer(),
|
||||
);
|
||||
}
|
||||
|
||||
// Open cameras: each is either pinned (a small tile anchored at the camera, so
|
||||
// several views along one road read against the map) or docked (a larger tile
|
||||
// in a strip over the map, which stays put while you pan and survives the
|
||||
// overlay being switched off). Oldest camera drops past the cap.
|
||||
const MAX_OPEN_CAMERAS = 6;
|
||||
type CameraMode = "pin" | "dock";
|
||||
interface OpenCamera { cam: Ny511Camera; mode: CameraMode }
|
||||
|
||||
const camBtn = "text-ink-muted hover:text-ink text-xs leading-none px-0.5";
|
||||
|
||||
function CameraDock({ map, open, onMode, onClose }: {
|
||||
map: L.Map;
|
||||
open: Ny511Camera[];
|
||||
onMode: (id: string, mode: CameraMode) => void;
|
||||
onClose: (id: string) => void;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
// Portaled into the Leaflet container — keep clicks/scrolls off the map.
|
||||
if (!ref.current) return;
|
||||
L.DomEvent.disableClickPropagation(ref.current);
|
||||
L.DomEvent.disableScrollPropagation(ref.current);
|
||||
}, [open.length > 0]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
if (!open.length) return null;
|
||||
return createPortal(
|
||||
<div
|
||||
ref={ref}
|
||||
className="absolute z-[1002] left-3 right-3 top-12 md:top-auto md:bottom-8 md:left-[16rem] md:right-[12rem] flex flex-wrap-reverse justify-center gap-2 max-h-[calc(100%-8rem)] overflow-y-auto pointer-events-none"
|
||||
>
|
||||
{open.map((c) => (
|
||||
<div key={c.id} className="pointer-events-auto w-[240px] bg-surface border border-line-strong rounded-md shadow-lg">
|
||||
<div className="flex items-center gap-1 px-1.5 h-5">
|
||||
<span className="flex-1 truncate text-[11px] leading-5 text-ink-2" title={c.name}>{c.name}</span>
|
||||
<button type="button" onClick={() => onMode(c.id, "pin")} title="Pin to the map at the camera" aria-label={`Pin ${c.name} to the map`} className={camBtn}>⌖</button>
|
||||
<button type="button" onClick={() => onClose(c.id)} aria-label={`Close ${c.name}`} className={camBtn}>×</button>
|
||||
</div>
|
||||
<CameraFeed camera={c} className="block w-[240px] h-[135px] object-cover bg-black rounded-b-md" />
|
||||
</div>
|
||||
))}
|
||||
</div>,
|
||||
map.getContainer(),
|
||||
);
|
||||
}
|
||||
|
||||
function DotCameraLayer() {
|
||||
const map = useMap();
|
||||
const shown = useOverlayShown(map, OVERLAY_DOT_CAMERAS);
|
||||
const zoom = useMapZoom(map);
|
||||
const { data, error } = use511(map, "cameras", shown);
|
||||
const [open, setOpen] = useState<OpenCamera[]>([]);
|
||||
|
||||
const pinnedIds = useMemo(() => new Set(open.filter((o) => o.mode === "pin").map((o) => o.cam.id)), [open]);
|
||||
const cells = useMemo(
|
||||
() => lodCells((data.cameras ?? []).filter((c) => !pinnedIds.has(c.id)), map, zoom),
|
||||
[data.cameras, pinnedIds, map, zoom],
|
||||
);
|
||||
|
||||
const show = (cam: Ny511Camera, mode: CameraMode) => {
|
||||
map.closePopup();
|
||||
setOpen((prev) => [...prev.filter((o) => o.cam.id !== cam.id), { cam, mode }].slice(-MAX_OPEN_CAMERAS));
|
||||
};
|
||||
const setMode = (id: string, mode: CameraMode) =>
|
||||
setOpen((prev) => prev.map((o) => (o.cam.id === id ? { ...o, mode } : o)));
|
||||
const close = (id: string) => setOpen((prev) => prev.filter((o) => o.cam.id !== id));
|
||||
|
||||
return (
|
||||
<>
|
||||
{shown && <FeedProblem map={map} label="DOT cameras" status={data.cameras_status} fetchError={error} />}
|
||||
{cells.map(({ rep: c, count }) => count > 1 ? (
|
||||
<Marker key={`lod-${c.id}`} position={[c.lat, c.lon]} icon={cameraIcon(count)}
|
||||
title={`${count} cameras — click to zoom in`}
|
||||
eventHandlers={{ click: () => zoomInto(map, c.lat, c.lon) }} />
|
||||
) : (
|
||||
<Marker key={c.id} position={[c.lat, c.lon]} icon={cameraIcon()}>
|
||||
<Popup minWidth={260} maxWidth={340}>
|
||||
<div className="space-y-1">
|
||||
<div className="font-semibold">{c.name}</div>
|
||||
{c.roadway && <div className="text-xs text-ink-muted">{c.roadway}{c.direction && c.direction !== "Unknown" ? ` · ${c.direction}` : ""}</div>}
|
||||
{c.image_url && (
|
||||
// Popup content mounts on open, so the timestamp busts the cache per open.
|
||||
<button type="button" onClick={() => show(c, "pin")} title="Pin this camera to the map" className="block w-full p-0 border-0 bg-transparent cursor-pointer">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={`${c.image_url}?t=${Date.now()}`} alt={`Camera: ${c.name}`} className="w-full rounded border border-line" loading="lazy" />
|
||||
</button>
|
||||
)}
|
||||
<div className="flex gap-1.5 pt-0.5">
|
||||
<button type="button" onClick={() => show(c, "pin")} className="flex-1 text-xs border border-line rounded px-2 py-1 hover:bg-raised">Pin to map</button>
|
||||
<button type="button" onClick={() => show(c, "dock")} className="flex-1 text-xs border border-line rounded px-2 py-1 hover:bg-raised">Dock</button>
|
||||
</div>
|
||||
<div className="text-[10px] text-ink-muted">
|
||||
{c.video_url ? "Live feed" : "Snapshot"} · 511NY / NYSDOT
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
))}
|
||||
{/* Pinned players only exist while the overlay is on, so hiding it stops their streams. */}
|
||||
{shown && open.filter((o) => o.mode === "pin").map(({ cam: c }) => (
|
||||
<Marker key={`pin-${c.id}`} position={[c.lat, c.lon]} icon={cameraIcon()} zIndexOffset={300}>
|
||||
<Tooltip permanent interactive direction="top" offset={[0, -8]} opacity={1} className="drb-cam-pin">
|
||||
<div className="w-[192px]">
|
||||
<div className="flex items-center gap-1 px-1.5 h-4">
|
||||
<span className="flex-1 truncate text-[10px] leading-4 text-ink-2" title={c.name}>{c.name}</span>
|
||||
<button type="button" onClick={() => setMode(c.id, "dock")} title="Dock" aria-label={`Dock ${c.name}`} className={camBtn}>⇲</button>
|
||||
<button type="button" onClick={() => close(c.id)} aria-label={`Close ${c.name}`} className={camBtn}>×</button>
|
||||
</div>
|
||||
<CameraFeed camera={c} className="block w-[192px] h-[108px] object-cover bg-black rounded-b" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Marker>
|
||||
))}
|
||||
<CameraDock map={map} open={open.filter((o) => o.mode === "dock").map((o) => o.cam)} onMode={setMode} onClose={close} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TrafficEventLayer() {
|
||||
const map = useMap();
|
||||
const shown = useOverlayShown(map, OVERLAY_TRAFFIC_EVENTS);
|
||||
const zoom = useMapZoom(map);
|
||||
const { data, error } = use511(map, "events", shown);
|
||||
const cells = useMemo(() => lodCells(data.events ?? [], map, zoom, eventRank), [data.events, map, zoom]);
|
||||
return (
|
||||
<>
|
||||
{shown && <FeedProblem map={map} label="Traffic events" status={data.events_status} fetchError={error} />}
|
||||
{cells.map(({ rep: e, count }) => {
|
||||
const zIndexOffset = e.type === "accidentsAndIncidents" ? 200 : 0;
|
||||
if (count > 1) {
|
||||
return (
|
||||
<Marker key={`lod-${e.id}`} position={[e.lat, e.lon]} icon={trafficEventIcon(e.type, count)} zIndexOffset={zIndexOffset}
|
||||
title={`${count} traffic events — click to zoom in`}
|
||||
eventHandlers={{ click: () => zoomInto(map, e.lat, e.lon) }} />
|
||||
);
|
||||
}
|
||||
const st = EVENT_STYLE[e.type] ?? EVENT_STYLE_OTHER;
|
||||
const start = fmtLocal(e.start_local);
|
||||
const end = fmtLocal(e.planned_end_local);
|
||||
return (
|
||||
<Marker key={e.id} position={[e.lat, e.lon]} icon={trafficEventIcon(e.type)} zIndexOffset={zIndexOffset}>
|
||||
<Popup minWidth={220} maxWidth={320}>
|
||||
<div className="space-y-1">
|
||||
<div className="font-semibold">{v.name || v.mmsi}</div>
|
||||
<div className="text-xs text-ink-muted">MMSI {v.mmsi}</div>
|
||||
{v.speed_kt != null && <div className="text-xs">Speed: {Math.round(v.speed_kt)} kt</div>}
|
||||
<div className="font-semibold">{st.label}{e.subtype ? `: ${e.subtype}` : ""}</div>
|
||||
<div className="text-xs text-ink-muted">{[e.roadway, e.direction, e.county].filter(Boolean).join(" · ")}</div>
|
||||
{(start || end) && <div className="text-xs">{start ? `From ${start}` : ""}{end ? ` until ${end}` : ""}</div>}
|
||||
<div className="text-xs whitespace-pre-line">{e.description}</div>
|
||||
<div className="text-[10px] text-ink-muted">511NY{e.updated_local ? ` · updated ${fmtLocal(e.updated_local)}` : ""}</div>
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -606,19 +984,27 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
|
||||
const [mapInstance, setMapInstance] = useState<L.Map | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [agoClock, setAgoClock] = useState(0);
|
||||
const [radarEpoch, setRadarEpoch] = useState(() => Date.now());
|
||||
const [aircraftShown, setAircraftShown] = useState(false);
|
||||
|
||||
// The altitude key only belongs in the legend while the opt-in Aircraft
|
||||
// overlay is actually on (server-26#185).
|
||||
useEffect(() => {
|
||||
if (!mapInstance) return;
|
||||
const on = (e: L.LayersControlEvent) => e.name === "Aircraft" && setAircraftShown(true);
|
||||
const off = (e: L.LayersControlEvent) => e.name === "Aircraft" && setAircraftShown(false);
|
||||
mapInstance.on("overlayadd", on);
|
||||
mapInstance.on("overlayremove", off);
|
||||
return () => {
|
||||
mapInstance.off("overlayadd", on);
|
||||
mapInstance.off("overlayremove", off);
|
||||
};
|
||||
}, [mapInstance]);
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setAgoClock((t: number) => t + 1), 10_000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
// Radar tiles are static once loaded — force remount every 5 min to refresh
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setRadarEpoch(Date.now()), 5 * 60 * 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const ago = useMemo(() => (lastUpdated ? timeAgo(lastUpdated) : null), [lastUpdated, agoClock]);
|
||||
|
||||
@@ -739,14 +1125,21 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
|
||||
</FeatureGroup>
|
||||
</LayersControl.Overlay>
|
||||
|
||||
{/* Overlay: Weather Radar — NEXRAD via Iowa Env Mesonet; key forces remount on refresh */}
|
||||
{/* Overlays: 511NY DOT cameras + traffic events (server-26#183), opt-in */}
|
||||
<LayersControl.Overlay name={OVERLAY_DOT_CAMERAS}>
|
||||
<FeatureGroup>
|
||||
<DotCameraLayer />
|
||||
</FeatureGroup>
|
||||
</LayersControl.Overlay>
|
||||
<LayersControl.Overlay name={OVERLAY_TRAFFIC_EVENTS}>
|
||||
<FeatureGroup>
|
||||
<TrafficEventLayer />
|
||||
</FeatureGroup>
|
||||
</LayersControl.Overlay>
|
||||
|
||||
{/* Overlay: Weather Radar — NEXRAD via Iowa Env Mesonet */}
|
||||
<LayersControl.Overlay name="Weather Radar">
|
||||
<TileLayer
|
||||
key={radarEpoch}
|
||||
url="https://mesonet.agron.iastate.edu/cache/tile.py/1.0.0/nexrad-n0r-900913/{z}/{x}/{y}.png"
|
||||
attribution='Radar © <a href="https://mesonet.agron.iastate.edu/">IEM/NWS</a>'
|
||||
opacity={0.65}
|
||||
/>
|
||||
<RadarLayer />
|
||||
</LayersControl.Overlay>
|
||||
</LayersControl>
|
||||
</MapContainer>
|
||||
@@ -797,6 +1190,18 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{aircraftShown && (
|
||||
<div className="border-t border-line pt-1.5 space-y-1">
|
||||
<p className="text-ink-muted font-medium text-[10px] uppercase tracking-wide">Aircraft altitude</p>
|
||||
<div
|
||||
className="h-2 w-32 rounded-sm border border-line"
|
||||
style={{ background: `linear-gradient(to right, ${ALTITUDE_LEGEND_TICKS.map(([ft], i) => `${altitudeColor(ft)} ${(i / (ALTITUDE_LEGEND_TICKS.length - 1)) * 100}%`).join(", ")})` }}
|
||||
/>
|
||||
<div className="flex justify-between w-32 text-[10px] text-ink-2 tabular-nums">
|
||||
{ALTITUDE_LEGEND_TICKS.map(([ft, label]) => <span key={ft}>{label}</span>)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="border-t border-line pt-1.5 space-y-1">
|
||||
<p className="text-ink-muted font-medium text-[10px] uppercase tracking-wide">Nodes</p>
|
||||
{([
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { c2api } from "@/lib/c2api";
|
||||
import type { NodeRecord, SdrDevice } from "@/lib/types";
|
||||
|
||||
// node-26#9 / #11. OP25 always has exactly one SDR (pinned by serial, or the
|
||||
// first one found). Every other SDR runs the next enabled service, top first;
|
||||
// a service can be pinned to the SDR that carries its antenna.
|
||||
const MODES: { mode: string; name: string; hint: string }[] = [
|
||||
{ mode: "adsb", name: "ADS-B", hint: "Aircraft · 1090 MHz" },
|
||||
{ mode: "ais", name: "AIS", hint: "Vessels · 162 MHz" },
|
||||
];
|
||||
|
||||
type Row = { mode: string; enabled: boolean; pin: string };
|
||||
|
||||
function rowsFrom(priority: string[], pins: Record<string, string>): Row[] {
|
||||
const known = priority.filter((m) => MODES.some((x) => x.mode === m));
|
||||
return [
|
||||
...known.map((mode) => ({ mode, enabled: true, pin: pins[mode] ?? "" })),
|
||||
...MODES.filter((x) => !known.includes(x.mode)).map((x) => ({ mode: x.mode, enabled: false, pin: pins[x.mode] ?? "" })),
|
||||
];
|
||||
}
|
||||
|
||||
const selectClass =
|
||||
"bg-gray-800 border border-gray-700 rounded px-2 py-1 text-gray-200 text-xs focus:outline-none focus:border-indigo-500 disabled:opacity-60 max-w-[13rem]";
|
||||
|
||||
function DeviceSelect({
|
||||
value, devices, devicesKnown, autoLabel, label, disabled, onChange,
|
||||
}: {
|
||||
value: string;
|
||||
devices: SdrDevice[];
|
||||
devicesKnown: boolean;
|
||||
autoLabel: string;
|
||||
label: string;
|
||||
disabled: boolean;
|
||||
onChange: (serial: string) => void;
|
||||
}) {
|
||||
const known = devices.some((d) => d.serial === value);
|
||||
return (
|
||||
<select aria-label={label} value={value} disabled={disabled} onChange={(e) => onChange(e.target.value)} className={selectClass}>
|
||||
<option value="">{autoLabel}</option>
|
||||
{devices.map((d) => (
|
||||
<option key={`${d.index}-${d.serial}`} value={d.serial ?? ""} disabled={!d.serial}>
|
||||
SDR {d.index + 1} · serial {d.serial ?? "unknown"}{d.duplicate_serial ? " (shared serial!)" : ""}
|
||||
</option>
|
||||
))}
|
||||
{value && !known && (
|
||||
<option value={value}>serial {value} ({devicesKnown ? "not plugged in" : "not reported"})</option>
|
||||
)}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
export function SdrSettings({ node, canEdit }: { node: NodeRecord; canEdit: boolean }) {
|
||||
const priority = node.secondary_sdr_priority ?? [];
|
||||
const pins = node.sdr_pins ?? {};
|
||||
// null/absent = the node has never reported (older firmware, container down
|
||||
// at checkin): unknown, not "nothing running" (server-26#187).
|
||||
const reported = node.secondary_sdr_running != null;
|
||||
const running = node.secondary_sdr_running ?? [];
|
||||
const devices = node.sdr_devices ?? [];
|
||||
const devicesKnown = reported && node.sdr_devices != null;
|
||||
|
||||
const [rows, setRows] = useState<Row[]>(() => rowsFrom(priority, pins));
|
||||
const [op25Pin, setOp25Pin] = useState(pins.op25 ?? "");
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
// Follow the node's live checkin unless there are unsaved edits.
|
||||
const serverKey = JSON.stringify([priority, pins]);
|
||||
useEffect(() => {
|
||||
if (dirty) return;
|
||||
const [p, pn] = JSON.parse(serverKey) as [string[], Record<string, string>];
|
||||
setRows(rowsFrom(p, pn));
|
||||
setOp25Pin(pn.op25 ?? "");
|
||||
}, [serverKey, dirty]);
|
||||
|
||||
function edit(next: Row[]) {
|
||||
setRows(next);
|
||||
setDirty(true);
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
function move(i: number, delta: number) {
|
||||
const next = [...rows];
|
||||
[next[i], next[i + delta]] = [next[i + delta], next[i]];
|
||||
edit(next);
|
||||
}
|
||||
|
||||
const pinned = [op25Pin, ...rows.map((r) => r.pin)].filter(Boolean);
|
||||
const clash = new Set(pinned).size !== pinned.length;
|
||||
const op25Moving = op25Pin !== (pins.op25 ?? "");
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const nextPins: Record<string, string | null> = { op25: op25Pin || null };
|
||||
rows.forEach((r) => { nextPins[r.mode] = r.pin || null; });
|
||||
await c2api.updateNode(node.node_id, {
|
||||
secondary_sdr_priority: rows.filter((r) => r.enabled).map((r) => r.mode),
|
||||
sdr_pins: nextPins,
|
||||
});
|
||||
setDirty(false);
|
||||
setMessage("Sent to the node. Status updates when it checks in.");
|
||||
} catch (err) {
|
||||
setMessage(err instanceof Error ? err.message : "Save failed.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
let rank = 0;
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-1">SDRs</h2>
|
||||
<p className="text-xs text-gray-500 font-mono mb-3">
|
||||
{devicesKnown
|
||||
? `This node reports ${devices.length} SDR${devices.length === 1 ? "" : "s"}.`
|
||||
: "This node hasn't reported its SDRs yet."}
|
||||
</p>
|
||||
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-lg divide-y divide-gray-800 font-mono text-sm">
|
||||
<div className="flex flex-wrap items-center gap-3 px-4 py-2.5">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-gray-200">OP25 SDR</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{op25Moving
|
||||
? "Saving restarts OP25 on the selected SDR."
|
||||
: node.op25_sdr_serial
|
||||
? `Using serial ${node.op25_sdr_serial}`
|
||||
: "P25 / analog scanning"}
|
||||
</div>
|
||||
</div>
|
||||
<DeviceSelect
|
||||
value={op25Pin}
|
||||
devices={devices}
|
||||
devicesKnown={devicesKnown}
|
||||
autoLabel="Automatic (first SDR)"
|
||||
label="OP25 SDR"
|
||||
disabled={!canEdit}
|
||||
onChange={(v) => { setOp25Pin(v); setDirty(true); setMessage(null); }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{rows.map((row, i) => {
|
||||
const meta = MODES.find((x) => x.mode === row.mode)!;
|
||||
const pinMissing = devicesKnown && !!row.pin && !devices.some((d) => d.serial === row.pin);
|
||||
const state = !row.enabled
|
||||
? "Off"
|
||||
: dirty
|
||||
? "Unsaved"
|
||||
: !reported
|
||||
? "Not reported"
|
||||
: running.includes(row.mode)
|
||||
? "Running"
|
||||
: pinMissing
|
||||
? "Pinned SDR missing"
|
||||
: "Waiting for SDR";
|
||||
return (
|
||||
<div key={row.mode} className="flex flex-wrap items-center gap-3 px-4 py-2.5">
|
||||
<span className="w-4 text-right text-gray-600 text-xs">{row.enabled ? ++rank : ""}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={row.enabled}
|
||||
disabled={!canEdit}
|
||||
aria-label={`Enable ${meta.name}`}
|
||||
onChange={(e) => edit(rows.map((r, j) => (j === i ? { ...r, enabled: e.target.checked } : r)))}
|
||||
className="rounded bg-gray-800 border-gray-700 text-indigo-600 focus:ring-indigo-500 focus:ring-offset-gray-900"
|
||||
/>
|
||||
<div className="flex-1 min-w-[7rem]">
|
||||
<div className="text-gray-200">{meta.name}</div>
|
||||
<div className="text-xs text-gray-500">{meta.hint}</div>
|
||||
</div>
|
||||
<DeviceSelect
|
||||
value={row.pin}
|
||||
devices={devices}
|
||||
devicesKnown={devicesKnown}
|
||||
autoLabel="Any spare SDR"
|
||||
label={`${meta.name} SDR`}
|
||||
disabled={!canEdit}
|
||||
onChange={(v) => edit(rows.map((r, j) => (j === i ? { ...r, pin: v } : r)))}
|
||||
/>
|
||||
{canEdit && (
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => move(i, -1)}
|
||||
disabled={i === 0}
|
||||
aria-label={`Move ${meta.name} up`}
|
||||
className="w-7 h-7 rounded bg-gray-800 hover:bg-gray-700 text-gray-300 disabled:opacity-30"
|
||||
>
|
||||
▲
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => move(i, 1)}
|
||||
disabled={i === rows.length - 1}
|
||||
aria-label={`Move ${meta.name} down`}
|
||||
className="w-7 h-7 rounded bg-gray-800 hover:bg-gray-700 text-gray-300 disabled:opacity-30"
|
||||
>
|
||||
▼
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<span className={`w-32 text-right text-xs ${state === "Running" ? "text-green-400" : "text-gray-500"}`}>
|
||||
{state}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-500 font-mono mt-2">
|
||||
Every SDR besides OP25's runs the next enabled service, top first. Pin a service to the SDR with its
|
||||
antenna, or leave it on "Any spare SDR".
|
||||
</p>
|
||||
{devices.some((d) => d.duplicate_serial) && (
|
||||
<p className="text-xs text-red-400 font-mono mt-2">
|
||||
Two SDRs share a serial number, so they can't be told apart. Give each a unique serial (rtl_eeprom -s)
|
||||
before pinning.
|
||||
</p>
|
||||
)}
|
||||
{clash && <p className="text-xs text-red-400 font-mono mt-2">Two services are pinned to the same SDR.</p>}
|
||||
|
||||
{canEdit && (
|
||||
<div className="flex items-center gap-3 mt-3">
|
||||
<button
|
||||
onClick={save}
|
||||
disabled={!dirty || saving || clash}
|
||||
className="px-4 py-2 bg-indigo-700 hover:bg-indigo-600 disabled:opacity-50 text-white rounded-lg text-sm font-mono transition-colors"
|
||||
>
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
{message && <span className="text-xs text-gray-500 font-mono">{message}</span>}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { auth } from "@/lib/firebase";
|
||||
import type { AreaContext, TalkgroupPending } from "@/lib/types";
|
||||
import type { AreaContext, Ny511Response, TalkgroupPending } from "@/lib/types";
|
||||
|
||||
const BASE = process.env.NEXT_PUBLIC_C2_URL ?? "http://localhost:8000";
|
||||
|
||||
@@ -35,7 +35,15 @@ export const c2api = {
|
||||
request(`/nodes/${nodeId}/override/ack`, { method: "POST", body: JSON.stringify({ timeout_minutes: timeoutMinutes }) }),
|
||||
resetOverride: (nodeId: string) =>
|
||||
request(`/nodes/${nodeId}/override/reset`, { method: "POST" }),
|
||||
updateNode: (id: string, body: { node_type?: string; enforce_override_timeout?: boolean }) =>
|
||||
updateNode: (
|
||||
id: string,
|
||||
body: {
|
||||
node_type?: string;
|
||||
enforce_override_timeout?: boolean;
|
||||
secondary_sdr_priority?: string[];
|
||||
sdr_pins?: Record<string, string | null>;
|
||||
},
|
||||
) =>
|
||||
request(`/nodes/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
|
||||
|
||||
// Systems
|
||||
@@ -320,7 +328,10 @@ export const c2api = {
|
||||
}),
|
||||
getUser: (uid: string) =>
|
||||
request<import("@/lib/types").UserRecord>(`/admin/users/${uid}`),
|
||||
updateUser: (uid: string, body: { role?: string; owned_node_ids?: string[]; display_name?: string }) =>
|
||||
updateUser: (
|
||||
uid: string,
|
||||
body: { role?: string; owned_node_ids?: string[]; display_name?: string; org_id?: string },
|
||||
) =>
|
||||
request<import("@/lib/types").UserRecord>(`/admin/users/${uid}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(body),
|
||||
@@ -367,6 +378,12 @@ export const c2api = {
|
||||
revokeEnrollmentToken: (tokenId: string) =>
|
||||
request(`/org/enrollment-tokens/${tokenId}`, { method: "DELETE" }),
|
||||
|
||||
// 511NY cameras/events inside a map bbox (server-26#183)
|
||||
get511: (bbox: { south: number; west: number; north: number; east: number }, layers: string) =>
|
||||
request<Ny511Response>(`/traffic/511?${new URLSearchParams({
|
||||
south: String(bbox.south), west: String(bbox.west), north: String(bbox.north), east: String(bbox.east), layers,
|
||||
})}`),
|
||||
|
||||
// Public waitlist — no auth, see routers/waitlist.py
|
||||
joinWaitlist: (body: { email: string; org_name?: string; note?: string }) =>
|
||||
request<{ ok: boolean }>("/waitlist", { method: "POST", body: JSON.stringify(body) }),
|
||||
|
||||
@@ -14,6 +14,9 @@ export interface UserRecord {
|
||||
discord_linked: boolean;
|
||||
discord_username: string | null;
|
||||
discord_user_id: string | null;
|
||||
/** The org whose data this user can read; null = none (sees nothing). */
|
||||
org_id?: string | null;
|
||||
org_role?: "owner" | "member" | null;
|
||||
// only present on GET /admin/users/{uid}
|
||||
sessions?: UserSession[];
|
||||
// only present on POST /admin/users response
|
||||
@@ -53,14 +56,31 @@ export interface NodeRecord {
|
||||
hardware_preset?: string;
|
||||
ppm_override?: number | null;
|
||||
node_type?: string;
|
||||
secondary_sdr_mode?: string;
|
||||
sdr_count?: number;
|
||||
secondary_sdr_mode?: string; // legacy; priority[0] on current nodes
|
||||
/** Ordered decoders for the SDRs beyond OP25's, run top-down (node-26#9). */
|
||||
secondary_sdr_priority?: string[];
|
||||
/** What the node's last checkin reported actually running. */
|
||||
secondary_sdr_running?: string[] | null;
|
||||
/** node-26#11: service (op25/adsb/ais) -> dongle serial; absent = automatic. */
|
||||
sdr_pins?: Record<string, string>;
|
||||
/** Dongles the node detected, from its checkin; null/absent = not reported. */
|
||||
sdr_devices?: SdrDevice[] | null;
|
||||
/** The dongle OP25 is actually using, per the node. */
|
||||
op25_sdr_serial?: string | null;
|
||||
sdr_count?: number | null; // null = never reported
|
||||
enforce_override_timeout?: boolean;
|
||||
is_overridden?: boolean;
|
||||
override_system_id?: string | null;
|
||||
override_timeout_at?: string | null;
|
||||
}
|
||||
|
||||
export interface SdrDevice {
|
||||
index: number;
|
||||
serial: string | null;
|
||||
name: string;
|
||||
duplicate_serial: boolean;
|
||||
}
|
||||
|
||||
export interface AircraftTrack {
|
||||
icao: string;
|
||||
org_id?: string;
|
||||
@@ -94,6 +114,49 @@ export interface VesselTrack {
|
||||
last_seen: string;
|
||||
}
|
||||
|
||||
// 511NY traffic layer (server-26#183) — GET /traffic/511, cached server-side,
|
||||
// not Firestore. *_local times are New York local, no offset: display only.
|
||||
export interface Ny511Camera {
|
||||
id: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
name: string;
|
||||
roadway: string;
|
||||
direction: string;
|
||||
image_url: string | null;
|
||||
video_url: string | null;
|
||||
}
|
||||
|
||||
export interface Ny511Event {
|
||||
id: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
type: string;
|
||||
subtype: string;
|
||||
severity: string;
|
||||
roadway: string;
|
||||
direction: string;
|
||||
county: string;
|
||||
description: string;
|
||||
start_local: string | null;
|
||||
planned_end_local: string | null;
|
||||
updated_local: string | null;
|
||||
}
|
||||
|
||||
export interface Ny511FeedStatus {
|
||||
fetched_at: number | null; // epoch seconds of the last successful 511NY fetch
|
||||
error: string | null; // set when the latest refresh failed (data is then stale)
|
||||
total_in_bbox: number;
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
export interface Ny511Response {
|
||||
cameras?: Ny511Camera[];
|
||||
cameras_status?: Ny511FeedStatus;
|
||||
events?: Ny511Event[];
|
||||
events_status?: Ny511FeedStatus;
|
||||
}
|
||||
|
||||
export interface VocabularyPendingTerm {
|
||||
term: string;
|
||||
source: "induction" | "correction";
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type L from "leaflet";
|
||||
import { c2api } from "@/lib/c2api";
|
||||
import type { Ny511Response } from "@/lib/types";
|
||||
|
||||
// 511NY cameras/events for the current map view (server-26#183). Unlike
|
||||
// useAircraft/useVessels this is not a Firestore listener: the data is public
|
||||
// and statewide, so c2-core caches it in memory and serves a bbox slice.
|
||||
// Fetches only while `enabled` (the overlay is on), on pan/zoom (debounced),
|
||||
// and on a poll matching the server's events TTL.
|
||||
const POLL_MS = 2 * 60 * 1000;
|
||||
const MOVE_DEBOUNCE_MS = 400;
|
||||
const BBOX_PAD = 0.2; // fetch a little past the edges so small pans don't blank the layer
|
||||
|
||||
export function use511(map: L.Map, layers: "cameras" | "events", enabled: boolean) {
|
||||
const [data, setData] = useState<Ny511Response>({});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const seq = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) { setData({}); return; }
|
||||
let debounce: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const load = async () => {
|
||||
const b = map.getBounds().pad(BBOX_PAD);
|
||||
const mine = ++seq.current;
|
||||
try {
|
||||
const res = await c2api.get511(
|
||||
{ south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() },
|
||||
layers,
|
||||
);
|
||||
if (mine !== seq.current) return; // a newer pan's response wins
|
||||
setData(res);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
if (mine !== seq.current) return;
|
||||
console.error("use511:", e);
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
};
|
||||
const onMove = () => { clearTimeout(debounce); debounce = setTimeout(load, MOVE_DEBOUNCE_MS); };
|
||||
|
||||
load();
|
||||
map.on("moveend", onMove);
|
||||
const poll = setInterval(load, POLL_MS);
|
||||
return () => {
|
||||
map.off("moveend", onMove);
|
||||
clearTimeout(debounce);
|
||||
clearInterval(poll);
|
||||
seq.current++; // drop any in-flight response
|
||||
};
|
||||
}, [map, layers, enabled]);
|
||||
|
||||
return { data, error };
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0",
|
||||
"firebase": "^10.12.0",
|
||||
"hls.js": "^1.5.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-markdown": "^9.0.1"
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
const tok = (name: string) =>
|
||||
`color-mix(in srgb, var(--${name}) calc(<alpha-value> * 100%), transparent)`;
|
||||
|
||||
const config: Config = {
|
||||
content: [
|
||||
"./app/**/*.{ts,tsx}",
|
||||
@@ -15,24 +18,27 @@ const config: Config = {
|
||||
// Semantic tokens — defined as CSS custom properties in app/globals.css on
|
||||
// :root (light) and .dark (dark). Components must use these names, never a
|
||||
// raw gray-9xx, so a theme is one variable block rather than an override sheet.
|
||||
// Wrapped in color-mix so opacity modifiers (bg-surface/90, bg-accent/15)
|
||||
// work — a bare var() hex gives Tailwind nothing to apply alpha to, and the
|
||||
// class silently generates no rule (map overlays rendered see-through).
|
||||
colors: {
|
||||
page: "var(--page)",
|
||||
surface: "var(--surface)",
|
||||
raised: "var(--raised)",
|
||||
line: "var(--line)",
|
||||
"line-strong": "var(--line-strong)",
|
||||
page: tok("page"),
|
||||
surface: tok("surface"),
|
||||
raised: tok("raised"),
|
||||
line: tok("line"),
|
||||
"line-strong": tok("line-strong"),
|
||||
ink: {
|
||||
DEFAULT: "var(--ink)",
|
||||
2: "var(--ink-2)",
|
||||
muted: "var(--ink-muted)",
|
||||
DEFAULT: tok("ink"),
|
||||
2: tok("ink-2"),
|
||||
muted: tok("ink-muted"),
|
||||
},
|
||||
accent: "var(--accent)",
|
||||
"sev-moderate": "var(--sev-moderate)",
|
||||
"sev-major": "var(--sev-major)",
|
||||
"map-bg": "var(--map-bg)",
|
||||
"map-block": "var(--map-block)",
|
||||
"map-road": "var(--map-road)",
|
||||
"map-water": "var(--map-water)",
|
||||
accent: tok("accent"),
|
||||
"sev-moderate": tok("sev-moderate"),
|
||||
"sev-major": tok("sev-major"),
|
||||
"map-bg": tok("map-bg"),
|
||||
"map-block": tok("map-block"),
|
||||
"map-road": tok("map-road"),
|
||||
"map-water": tok("map-water"),
|
||||
},
|
||||
// Marketing/product type scale — used by the (marketing) surface and
|
||||
// settings shell so headings read as a deliberate hierarchy rather than
|
||||
|
||||
Reference in New Issue
Block a user