Compare commits

..
Author SHA1 Message Date
Logan CusanoandClaude Opus 5.5 e009090418 Correlator prompt: show incident age/size, stop unit overlap chaining new jobs (#189)
The tiebreaker forced ~all of the 09-27 rollover sink's bad links: idle resets
on every link so a 2.5h, 40-call incident read as fresh, and the rules treated
unit overlap as enough on a county channel where units work jobs back to back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 19:43:21 -04:00
Logan CusanoandClaude Opus 5.5 3a1dca9533 Map: dock/pin cameras, 511 icon LOD, stop radar switching itself off
Build & Deploy / Build & push images (push) Successful in 4m12s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 28s
Build & Deploy / Deploy to VM (push) Successful in 1m31s
Build & Deploy / Report a failed deploy (push) Skipped
- DOT cameras can be pinned at the camera or docked in a strip over the
  map; up to 6 open at once, switchable between modes.
- Below zoom 14, 511 cameras/events bucket into a 40px world-pixel grid,
  one icon per cell with a count badge; click zooms in.
- Weather radar refreshed by remounting via `key`, and a remounted child of
  an unchecked LayersControl.Overlay isn't re-added to the map — radar went
  off every 5 min. Now refreshes in place with setUrl + cache-buster.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 19:22:07 -04:00
Logan CusanoandClaude Opus 5.5 6658c26fe0 Merge fix/move-user-org: admin can move a user into their org
Build & Deploy / Build & push images (push) Successful in 4m55s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 30s
Build & Deploy / Deploy to VM (push) Successful in 1m32s
Build & Deploy / Report a failed deploy (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 18:52:58 -04:00
Logan CusanoandClaude Opus 5.5 2b42e5ee9a Admin Users: show each user's org and move a user into yours
A viewer whose first login ran self-serve signup got an empty org of
their own (org_role owner), so they saw no incidents or calls, and the
earlier fix deliberately never moved a user who already had an org.
PATCH /admin/users/{uid} now moves a user when org_id is passed
explicitly (claims + org_members, audited with left_org_id; the old org
is not deleted). The user list returns org_id/org_role, and the admin
user panel shows the org and a 'Move to my organization' button when it
isn't yours.

Verified: c2-core pytest 496 passed; frontend tsc --noEmit clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 18:52:55 -04:00
Logan CusanoandClaude Opus 5.5 fa41b9a30c Merge fix/admin-created-users-org: admin-created users join an org
Build & Deploy / Build & push images (push) Successful in 4m24s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 32s
Build & Deploy / Deploy to VM (push) Successful in 1m49s
Build & Deploy / Report a failed deploy (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 18:17:29 -04:00
Logan CusanoandClaude Opus 5.5 17da2ff739 Admin-created users join an org, so viewers can actually see data
POST /admin/users set a role claim but never an org_id/org_role claim or
an org_members doc. firestore.rules gates every read on the org_id claim,
so a viewer created from the Users page saw no incidents or calls.

New users now join the requested org (default: the acting admin's own,
else 'founding') as org_role 'member', with the same org_members doc
POST /auth/signup writes. PATCH /admin/users/{uid} attaches an org-less
user the same way, which heals accounts created before this fix; it never
silently moves a user who already has an org.

Verified: c2-core pytest 495 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 18:16:16 -04:00
logan 33bb60b165 Merge pull request 'fix(frontend): readability in both themes' (#188) from fix/ui-readability into main
Build & Deploy / Build & push images (push) Successful in 4m19s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 34s
Build & Deploy / Deploy to VM (push) Successful in 1m51s
Build & Deploy / Report a failed deploy (push) Skipped
Reviewed-on: #188
2026-09-27 16:20:54 -04:00
Logan CusanoandClaude Opus 5.5 d18bb612df fix(frontend): readability in both themes
- Token colors wrapped in color-mix so opacity modifiers (bg-surface/90 etc.)
  generate rules; map overlay panels were rendering see-through.
- Light mode: keep white text on saturated fills (primary/danger buttons were
  remapped to navy); map text-gray-200, bg-gray-800/60, border-gray-600.
- Dark mode: lift text-gray-600/700 to gray-500 for contrast.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 16:17:24 -04:00
Logan Cusano 49ef914fd2 Merge feat/511-camera-pins: pinned 511 camera feed tiles (#183)
Build & Deploy / Build & push images (push) Successful in 4m44s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 33s
Build & Deploy / Deploy to VM (push) Successful in 1m36s
Build & Deploy / Report a failed deploy (push) Skipped
2026-09-27 14:43:52 -04:00
Logan CusanoandClaude Opus 5.5 c1a2721fbe Map: pin 511NY camera feeds as tiles on the map (#183)
Clicking a camera's snapshot pins a 192x108 tile anchored at the camera --
the live HLS stream when the camera has one (hls.js; Safari native), else the
snapshot refreshed every 60s, falling back to the snapshot if the stream dies.
Up to 6 pins, oldest dropped. Players exist only while the DOT Cameras overlay
is on, so hiding it stops every stream.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 14:43:40 -04:00
Logan CusanoandClaude Opus 5.5 000a5f11bd Merge feat/sdr-pins: pin OP25 and each SDR service to a dongle by serial (node-26#11)
Build & Deploy / Build & push images (push) Successful in 5m19s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 31s
Build & Deploy / Deploy to VM (push) Successful in 2m45s
Build & Deploy / Report a failed deploy (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 14:41:23 -04:00
Logan CusanoandClaude Opus 5.5 5dcf1b5e9c SDRs panel: an unreported device list reads 'not reported', not 'not plugged in'
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 14:41:21 -04:00
Logan CusanoandClaude Opus 5.5 fb2d737d6f Node SDRs: pin OP25 and each service to a dongle by serial (node-26#11)
Pairs with node-26 feat/sdr-pins. NodeRecord gains sdr_pins
(service -> serial), sdr_devices and op25_sdr_serial, mirrored from the
node's checkin. PATCH /nodes/{id} validates pins (known services, one
dongle per service) and sends priority/pins as a 'set_sdr_config'
command, never a config re-push. The node restarts OP25 only when OP25's
own dongle changes. The node page's section becomes 'SDRs' with an OP25
SDR dropdown ('Automatic (first SDR)' + detected dongles) and a
per-service dongle dropdown ('Any spare SDR'), plus duplicate-serial and
double-pin warnings.

Verified: c2-core pytest 490 passed; frontend tsc --noEmit clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 14:36:49 -04:00
Logan Cusano c95498e7fe Merge feat/511ny-layer: 511NY DOT cameras + traffic events (#183)
Build & Deploy / Build & push images (push) Successful in 4m20s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 31s
Build & Deploy / Deploy to VM (push) Successful in 1m40s
Build & Deploy / Report a failed deploy (push) Skipped
2026-09-27 14:27:32 -04:00
19 changed files with 881 additions and 224 deletions
+42 -14
View File
@@ -31,18 +31,24 @@ from app.config import settings
# Prompt helpers # Prompt helpers
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
def _fmt_idle(inc: dict, now: datetime) -> str: def _fmt_since(raw, now: datetime) -> str:
try: try:
raw = inc.get("updated_at") or inc.get("started_at") or "" dt = datetime.fromisoformat(str(raw or "").replace("Z", "+00:00"))
dt = datetime.fromisoformat(str(raw).replace("Z", "+00:00"))
if dt.tzinfo is None: if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc) dt = dt.replace(tzinfo=timezone.utc)
minutes = int((now - dt).total_seconds() / 60) # Clamped: the recorrelation sweep back-dates `now` (see
return f"{minutes}min ago" if minutes < 60 else f"{minutes // 60}h{minutes % 60:02d}m ago" # _idle_gate_minutes), which would otherwise print "-12min".
minutes = max(int((now - dt).total_seconds() / 60), 0)
return f"{minutes}min" if minutes < 60 else f"{minutes // 60}h{minutes % 60:02d}m"
except Exception: except Exception:
return "?" return "?"
def _fmt_idle(inc: dict, now: datetime) -> str:
since = _fmt_since(inc.get("updated_at") or inc.get("started_at"), now)
return since if since == "?" else f"{since} ago"
def _inc_summary(inc: dict, now: datetime) -> str: def _inc_summary(inc: dict, now: datetime) -> str:
# server-26#115: the model was given no title and no talkgroup, so it # server-26#115: the model was given no title and no talkgroup, so it
# could not tell that "car alarms, Mohegan Park Ave" and "car alarms, # could not tell that "car alarms, Mohegan Park Ave" and "car alarms,
@@ -65,6 +71,19 @@ def _inc_summary(inc: dict, now: datetime) -> str:
if tags: if tags:
parts.append(f"tags:[{', '.join(tags[:4])}]") parts.append(f"tags:[{', '.join(tags[:4])}]")
parts.append(f"idle:{_fmt_idle(inc, now)}") parts.append(f"idle:{_fmt_idle(inc, now)}")
# server-26#189: idle alone made a 2.5h-old job look as fresh as a
# 5-minute one — every link resets idle, so a busy channel's oldest
# incident always reads "2min ago" and keeps winning. Age and size let
# the model see it has become a catch-all.
if inc.get("started_at"):
from app.internal.incident_correlator import _incident_span_minutes
span = int(_incident_span_minutes(inc, now))
parts.append(f"open:{span}min" if span < 60 else f"open:{span // 60}h{span % 60:02d}m")
n_calls = inc.get("substantive_call_count")
if n_calls is None:
n_calls = len(inc.get("call_ids") or [])
if n_calls:
parts.append(f"calls:{n_calls}")
return " | ".join(parts) return " | ".join(parts)
@@ -90,6 +109,8 @@ def _call_block(ctx: dict) -> str:
lines.append(f"Units: {ctx['call_units']}") lines.append(f"Units: {ctx['call_units']}")
if ctx["call_vehicles"]: if ctx["call_vehicles"]:
lines.append(f"Vehicles: {ctx['call_vehicles']}") lines.append(f"Vehicles: {ctx['call_vehicles']}")
if ctx.get("reassignment"):
lines.append("Reassignment: yes — a unit is being sent to a NEW job")
if ctx["talkgroup_name"] or ctx.get("talkgroup_id") is not None: if ctx["talkgroup_name"] or ctx.get("talkgroup_id") is not None:
# Both the name and the id — _inc_summary emits numeric tg ids, so the # Both the name and the id — _inc_summary emits numeric tg ids, so the
# id is what makes the "same talkgroup" rule in _RULES evaluable # id is what makes the "same talkgroup" rule in _RULES evaluable
@@ -116,16 +137,23 @@ def _prompt_incidents(recent: list[dict]) -> list[dict]:
_SCHEMA = '{"action": "link" | "new" | "orphan", "incident_id": "<id_string or null>", "reasoning": "<one sentence>"}' _SCHEMA = '{"action": "link" | "new" | "orphan", "incident_id": "<id_string or null>", "reasoning": "<one sentence>"}'
_RULES = """ _RULES = """
Rules (this system OVER-SPLITS — a real incident routinely gets shattered into Rules (this system usually OVER-SPLITS — a real incident often gets shattered
5-10 duplicates. A wrong link is cheap; a duplicate incident is the failure into 5-10 duplicates, so lean toward "link" for follow-ups. The exception is a
mode. Bias accordingly.): call that reports a new event, below.):
- Prefer "link" when the call plausibly continues a recent incident ON THE SAME - Prefer "link" when the call plausibly continues a recent incident ON THE SAME
TALKGROUP: same or overlapping units, the same or an adjacent location (treat TALKGROUP: the same or an adjacent location (treat "Ave"/"Avenue",
"Ave"/"Avenue", "St"/"Street", "Rd"/"Road" as identical; a house number plus "St"/"Street", "Rd"/"Road" as identical; a house number plus the same street
the same street is the same place), the same subject/vehicle/case number, or a is the same place), the same subject/vehicle/case number, or a follow-up beat
follow-up beat ("units clearing", "negative contact", "tow en route", "event ("units clearing", "negative contact", "tow en route", "event number
number 214-201", a status update) to an incident that is only a few minutes 214-201", a status update) from units already on it. The bar for "link" on
idle. The bar for "link" on the same talkgroup is LOW. the same talkgroup is LOW for follow-ups.
- EXCEPT: a call that REPORTS an event is "new" unless it names the same place
or subject as the incident: "another accident", "a second MVA", "getting
flagged down for", "just got a report of", "respond to <address>", a
reassignment. Patrol units on a shared channel work many jobs back to back,
so a unit already on an incident is not evidence that its next job is the
same one — especially once that incident has been open a long time (see
open:/calls:).
- Reserve "new" for a call that clearly describes a DIFFERENT event from every - Reserve "new" for a call that clearly describes a DIFFERENT event from every
recent incident — a different place, different units, and a different subject, recent incident — a different place, different units, and a different subject,
not merely a different transmission about the same job. not merely a different transmission about the same job.
+5 -1
View File
@@ -114,6 +114,9 @@ class MQTTHandler:
"secondary_sdr_mode": payload.get("secondary_sdr_mode", "none"), "secondary_sdr_mode": payload.get("secondary_sdr_mode", "none"),
"secondary_sdr_priority": payload.get("secondary_sdr_priority", []), "secondary_sdr_priority": payload.get("secondary_sdr_priority", []),
"secondary_sdr_running": payload.get("secondary_sdr_running"), "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 "sdr_count": payload.get("sdr_count"), # None until reported, never a guessed 1
"enforce_override_timeout": payload.get("enforce_override_timeout", True), "enforce_override_timeout": payload.get("enforce_override_timeout", True),
"is_overridden": False, "is_overridden": False,
@@ -145,7 +148,8 @@ class MQTTHandler:
updates["node_type"] = node_type updates["node_type"] = node_type
updates["enforce_override_timeout"] = enforce_timeout updates["enforce_override_timeout"] = enforce_timeout
for key in ("secondary_sdr_mode", "secondary_sdr_priority", "secondary_sdr_running"): for key in ("secondary_sdr_mode", "secondary_sdr_priority", "secondary_sdr_running",
"sdr_pins", "sdr_devices", "op25_sdr_serial"):
if key in payload: if key in payload:
updates[key] = payload[key] updates[key] = payload[key]
if "sdr_count" in payload: if "sdr_count" in payload:
+4
View File
@@ -68,6 +68,10 @@ class NodeRecord(BaseModel):
# checkin, which is the source of truth; set via PATCH /nodes/{id}. # checkin, which is the source of truth; set via PATCH /nodes/{id}.
secondary_sdr_priority: List[str] = [] secondary_sdr_priority: List[str] = []
secondary_sdr_running: Optional[List[str]] = None # what the node reports actually running 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 sdr_count: Optional[int] = None # self-reported by the node's checkin; None = never reported
enforce_override_timeout: bool = True enforce_override_timeout: bool = True
is_overridden: bool = False is_overridden: bool = False
+29 -7
View File
@@ -1,5 +1,5 @@
import secrets import secrets
from typing import List, Optional from typing import Dict, List, Optional
from fastapi import APIRouter, HTTPException, Depends, Query from fastapi import APIRouter, HTTPException, Depends, Query
from pydantic import BaseModel from pydantic import BaseModel
from app.models import CommandPayload from app.models import CommandPayload
@@ -193,6 +193,7 @@ async def assign_system(
SECONDARY_SDR_MODES = ("adsb", "ais") SECONDARY_SDR_MODES = ("adsb", "ais")
SDR_PIN_KEYS = ("op25",) + SECONDARY_SDR_MODES
class NodeUpdateBody(BaseModel): class NodeUpdateBody(BaseModel):
@@ -201,6 +202,9 @@ class NodeUpdateBody(BaseModel):
secondary_sdr_mode: Optional[str] = None # legacy: none | adsb | ais secondary_sdr_mode: Optional[str] = None # legacy: none | adsb | ais
# Ordered, e.g. ["adsb", "ais"]: SDRs beyond op25's run these top-down. # Ordered, e.g. ["adsb", "ais"]: SDRs beyond op25's run these top-down.
secondary_sdr_priority: Optional[List[str]] = None 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}") @router.patch("/{node_id}")
@@ -224,14 +228,32 @@ async def update_node(
raise HTTPException(400, f"secondary_sdr_priority must be distinct values from {SECONDARY_SDR_MODES}.") 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" 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) await fstore.doc_update("nodes", node_id, updates)
# Priority goes as its own command: a config re-push restarts OP25, and # 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. # changing what the spare dongles do must never interrupt P25 recording
# The node applies it, then its checkin reports back what's really running. # (only moving OP25's own dongle restarts it, on the node's side). The node
if priority is not None: # applies it, then its checkin reports back what's really running.
mqtt_handler.send_command(node_id, {"action": "set_secondary_priority", "priority": priority}) sdr_keys = {"secondary_sdr_priority", "secondary_sdr_mode", "sdr_pins"}
if set(updates) <= {"secondary_sdr_priority", "secondary_sdr_mode"}: 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} return {"ok": True}
# Re-push config to apply new node settings locally # Re-push config to apply new node settings locally
+60 -2
View File
@@ -6,6 +6,7 @@ from pydantic import BaseModel
from firebase_admin import auth as firebase_auth from firebase_admin import auth as firebase_auth
from app.internal.auth import require_admin_token from app.internal.auth import require_admin_token
from app.internal import firestore as fstore from app.internal import firestore as fstore
from app.internal.tenancy import FOUNDING_ORG_ID
from app.internal import audit from app.internal import audit
router = APIRouter(prefix="/admin/users", tags=["users"]) router = APIRouter(prefix="/admin/users", tags=["users"])
@@ -22,9 +23,13 @@ class UserCreate(BaseModel):
role: str = "viewer" role: str = "viewer"
display_name: Optional[str] = None display_name: Optional[str] = None
owned_node_ids: list[str] = [] 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): class UserUpdate(BaseModel):
org_id: Optional[str] = None # attach an org-less user; defaults to the admin's org
role: Optional[str] = None role: Optional[str] = None
owned_node_ids: Optional[list[str]] = None owned_node_ids: Optional[list[str]] = None
display_name: Optional[str] = None display_name: Optional[str] = None
@@ -34,6 +39,32 @@ class UserUpdate(BaseModel):
# Helpers # 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]: def _ms_to_iso(ms: Optional[int]) -> Optional[str]:
if ms is None: if ms is None:
return 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_linked": bool(link and link.get("discord_user_id")),
"discord_username": link.get("discord_username") if link else None, "discord_username": link.get("discord_username") if link else None,
"discord_user_id": link.get("discord_user_id") 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))}") raise HTTPException(400, f"Invalid role. Must be one of: {', '.join(sorted(VALID_ROLES))}")
if body.role == "operator" and not body.owned_node_ids: if body.role == "operator" and not body.owned_node_ids:
raise HTTPException(400, "Operator role requires at least one owned node.") raise HTTPException(400, "Operator role requires at least one owned node.")
org_id = await _resolve_org(body.org_id, decoded)
try: try:
fb_user: firebase_auth.UserRecord = await asyncio.to_thread( 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}") raise HTTPException(400, f"Failed to create user: {e}")
# Set custom claims # 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": if body.role == "admin":
claims["admin"] = True claims["admin"] = True
await asyncio.to_thread(firebase_auth.set_custom_user_claims, fb_user.uid, claims) 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 # Write Firestore profile
now = datetime.now(timezone.utc).isoformat() 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", action="user.create",
target_uid=fb_user.uid, target_uid=fb_user.uid,
target_email=body.email, 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} 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: else:
new_claims.pop("admin", None) 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) 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: if body.display_name is not None:
await asyncio.to_thread(firebase_auth.update_user, uid, display_name=body.display_name) 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, "new_role": new_role,
"old_nodes": current_nodes, "old_nodes": current_nodes,
"new_nodes": new_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 {}),
}, },
) )
+13
View File
@@ -65,3 +65,16 @@ def test_prompt_incidents_is_most_recently_active_first_and_capped():
# falls back to started_at when updated_at is absent, and never raises # falls back to started_at when updated_at is absent, and never raises
assert _prompt_incidents([{"incident_id": "a", "started_at": NOW.isoformat()}, assert _prompt_incidents([{"incident_id": "a", "started_at": NOW.isoformat()},
{"incident_id": "b"}])[0]["incident_id"] == "a" {"incident_id": "b"}])[0]["incident_id"] == "a"
def test_inc_summary_shows_age_and_size_not_just_idle():
# server-26#189: every link resets idle, so a 2.5h catch-all read as fresh.
s = _inc_summary({
"incident_id": "sink",
"started_at": "2026-09-07T05:26:00+00:00",
"updated_at": "2026-09-07T07:58:00+00:00",
"call_ids": [f"c{n}" for n in range(40)],
}, NOW)
assert "idle:2min ago" in s
assert "open:2h34m" in s
assert "calls:40" in s
@@ -40,7 +40,7 @@ def _patch(body):
def test_priority_only_sends_command_and_never_repushes_config(): def test_priority_only_sends_command_and_never_repushes_config():
resp, update, command, push = _patch({"secondary_sdr_priority": ["ais", "adsb"]}) resp, update, command, push = _patch({"secondary_sdr_priority": ["ais", "adsb"]})
assert resp.status_code == 200 assert resp.status_code == 200
command.assert_called_once_with("n1", {"action": "set_secondary_priority", "priority": ["ais", "adsb"]}) command.assert_called_once_with("n1", {"action": "set_sdr_config", "priority": ["ais", "adsb"]})
push.assert_not_called() push.assert_not_called()
(_, _, updates), _ = update.await_args (_, _, updates), _ = update.await_args
assert updates == {"secondary_sdr_priority": ["ais", "adsb"], "secondary_sdr_mode": "ais"} assert updates == {"secondary_sdr_priority": ["ais", "adsb"], "secondary_sdr_mode": "ais"}
@@ -49,7 +49,7 @@ def test_priority_only_sends_command_and_never_repushes_config():
def test_empty_priority_turns_secondaries_off(): def test_empty_priority_turns_secondaries_off():
resp, update, command, push = _patch({"secondary_sdr_priority": []}) resp, update, command, push = _patch({"secondary_sdr_priority": []})
assert resp.status_code == 200 assert resp.status_code == 200
command.assert_called_once_with("n1", {"action": "set_secondary_priority", "priority": []}) command.assert_called_once_with("n1", {"action": "set_sdr_config", "priority": []})
(_, _, updates), _ = update.await_args (_, _, updates), _ = update.await_args
assert updates["secondary_sdr_mode"] == "none" assert updates["secondary_sdr_mode"] == "none"
@@ -57,3 +57,20 @@ def test_empty_priority_turns_secondaries_off():
def test_unknown_or_duplicate_modes_are_rejected(): 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", "sonar"]})[0].status_code == 400
assert _patch({"secondary_sdr_priority": ["adsb", "adsb"]})[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
+94
View File
@@ -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"
+39
View File
@@ -367,6 +367,26 @@ function UserDetailPanel({
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [showSessions, setShowSessions] = useState(false); 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 // Fetch full detail (sessions) lazily
useEffect(() => { useEffect(() => {
@@ -496,6 +516,25 @@ function UserDetailPanel({
</div> </div>
<div className="border-t border-gray-800 pt-4 space-y-2 text-xs"> <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"> <div className="flex justify-between">
<span className="text-gray-500">Status</span> <span className="text-gray-500">Status</span>
<span className={detail.disabled ? "text-red-400" : "text-green-400"}> <span className={detail.disabled ? "text-red-400" : "text-green-400"}>
+26
View File
@@ -67,6 +67,10 @@ html, body {
font-family: var(--font-mono), ui-monospace, monospace; 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 ─────────────────────────────────────────────────── */ /* ── Light mode overrides ─────────────────────────────────────────────────── */
/* /*
* The app's components use hardcoded dark-palette Tailwind classes (bg-gray-9xx, * 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\/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-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 { 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\/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-800\/30 { background-color: rgba(241,245,249,0.50) !important; }
html:not(.dark) .bg-gray-700 { background-color: #e2e8f0 !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 */ /* Borders */
html:not(.dark) .border-gray-800 { border-color: #e2e8f0 !important; } 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-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; } html:not(.dark) .divide-gray-800 > * + * { border-color: #e2e8f0 !important; }
/* Text */ /* Text */
html:not(.dark) .text-white { color: #0f172a !important; } html:not(.dark) .text-white { color: #0f172a !important; }
html:not(.dark) .text-gray-100 { color: #1e293b !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-300 { color: #334155 !important; }
html:not(.dark) .text-gray-400 { color: #475569 !important; } html:not(.dark) .text-gray-400 { color: #475569 !important; }
html:not(.dark) .text-gray-500 { color: #64748b !important; } html:not(.dark) .text-gray-500 { color: #64748b !important; }
html:not(.dark) .text-gray-600 { color: #94a3b8 !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 */ /* Hover states */
html:not(.dark) .hover\:bg-gray-900:hover { background-color: #f8fafc !important; } 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; } 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; 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 ─────────────────────────────────────────────────────────── */ /* ── Form inputs ─────────────────────────────────────────────────────────── */
html:not(.dark) input:not([type="submit"]):not([type="button"]):not([type="reset"]), html:not(.dark) input:not([type="submit"]):not([type="button"]):not([type="reset"]),
html:not(.dark) select, html:not(.dark) select,
+2 -2
View File
@@ -8,7 +8,7 @@ import { useSystems } from "@/lib/useSystems";
import { useCalls } from "@/lib/useCalls"; import { useCalls } from "@/lib/useCalls";
import { StatusBadge } from "@/components/StatusBadge"; import { StatusBadge } from "@/components/StatusBadge";
import { NodeConfigModal } from "@/components/NodeConfigModal"; import { NodeConfigModal } from "@/components/NodeConfigModal";
import { SecondarySdrPriority } from "@/components/SecondarySdrPriority"; import { SdrSettings } from "@/components/SdrSettings";
import { CallRow } from "@/components/CallRow"; import { CallRow } from "@/components/CallRow";
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice"; import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
import { useAuth } from "@/components/AuthProvider"; import { useAuth } from "@/components/AuthProvider";
@@ -337,7 +337,7 @@ export default function NodeDetailPage() {
)} )}
</div> </div>
<SecondarySdrPriority node={node} canEdit={isAdmin} /> <SdrSettings node={node} canEdit={isAdmin} />
{/* Recent calls */} {/* Recent calls */}
<section> <section>
+64
View File
@@ -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} />;
}
+193 -25
View File
@@ -22,7 +22,8 @@ import { useAircraft } from "@/lib/useAircraft";
import { useAircraftTrail } from "@/lib/useAircraftTrail"; import { useAircraftTrail } from "@/lib/useAircraftTrail";
import { useVessels } from "@/lib/useVessels"; import { useVessels } from "@/lib/useVessels";
import { use511 } from "@/lib/use511"; import { use511 } from "@/lib/use511";
import type { Ny511Event, Ny511FeedStatus } from "@/lib/types"; import type { Ny511Camera, Ny511Event, Ny511FeedStatus } from "@/lib/types";
import { CameraFeed } from "@/components/CameraFeed";
// ── Leaflet icon fix ────────────────────────────────────────────────────────── // ── Leaflet icon fix ──────────────────────────────────────────────────────────
delete (L.Icon.Default.prototype as unknown as Record<string, unknown>)._getIconUrl; delete (L.Icon.Default.prototype as unknown as Record<string, unknown>)._getIconUrl;
@@ -312,6 +313,30 @@ function VesselLayer() {
); );
} }
// ── 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 &copy; <a href="https://mesonet.agron.iastate.edu/">IEM/NWS</a>'
opacity={0.65}
/>
);
}
// ── 511NY traffic layers (server-26#183) ───────────────────────────────────── // ── 511NY traffic layers (server-26#183) ─────────────────────────────────────
// Overlay names double as the event keys for overlayadd/overlayremove. // Overlay names double as the event keys for overlayadd/overlayremove.
const OVERLAY_DOT_CAMERAS = "DOT Cameras"; const OVERLAY_DOT_CAMERAS = "DOT Cameras";
@@ -330,16 +355,24 @@ function useOverlayShown(map: L.Map, name: string): boolean {
return shown; return shown;
} }
function cameraIcon(): L.DivIcon { /** 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({ return L.divIcon({
className: "", className: "",
html: `<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>`, 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], iconSize: [16, 12],
iconAnchor: [8, 6], iconAnchor: [8, 6],
}); });
} }
// Shape + glyph per 511 event type, so the layer reads without colour alone. // 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 }> = { const EVENT_STYLE: Record<string, { glyph: string; fill: string; label: string }> = {
accidentsAndIncidents: { glyph: "!", fill: "#dc2626", label: "Accident / incident" }, accidentsAndIncidents: { glyph: "!", fill: "#dc2626", label: "Accident / incident" },
closures: { glyph: "×", fill: "#ea580c", label: "Closure" }, closures: { glyph: "×", fill: "#ea580c", label: "Closure" },
@@ -348,17 +381,65 @@ const EVENT_STYLE: Record<string, { glyph: string; fill: string; label: string }
transitOperations: { glyph: "T", fill: "#0891b2", label: "Transit" }, transitOperations: { glyph: "T", fill: "#0891b2", label: "Transit" },
}; };
const EVENT_STYLE_OTHER = { glyph: "i", fill: "#6b7280", label: "Other" }; 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): L.DivIcon { function trafficEventIcon(type: string, count = 1): L.DivIcon {
const st = EVENT_STYLE[type] ?? EVENT_STYLE_OTHER; const st = EVENT_STYLE[type] ?? EVENT_STYLE_OTHER;
return L.divIcon({ return L.divIcon({
className: "", className: "",
html: `<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>`, 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], iconSize: [16, 16],
iconAnchor: [8, 8], 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 { function fmtLocal(iso: string | null): string | null {
if (!iso) return null; if (!iso) return null;
const d = new Date(iso); // naive ISO parses as browser-local; 511NY stamps are NY local const d = new Date(iso); // naive ISO parses as browser-local; 511NY stamps are NY local
@@ -380,14 +461,79 @@ function FeedProblem({ map, label, status, fetchError }: { map: L.Map; label: st
); );
} }
// 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() { function DotCameraLayer() {
const map = useMap(); const map = useMap();
const shown = useOverlayShown(map, OVERLAY_DOT_CAMERAS); const shown = useOverlayShown(map, OVERLAY_DOT_CAMERAS);
const zoom = useMapZoom(map);
const { data, error } = use511(map, "cameras", shown); 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 ( return (
<> <>
{shown && <FeedProblem map={map} label="DOT cameras" status={data.cameras_status} fetchError={error} />} {shown && <FeedProblem map={map} label="DOT cameras" status={data.cameras_status} fetchError={error} />}
{(data.cameras ?? []).map((c) => ( {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()}> <Marker key={c.id} position={[c.lat, c.lon]} icon={cameraIcon()}>
<Popup minWidth={260} maxWidth={340}> <Popup minWidth={260} maxWidth={340}>
<div className="space-y-1"> <div className="space-y-1">
@@ -395,14 +541,38 @@ function DotCameraLayer() {
{c.roadway && <div className="text-xs text-ink-muted">{c.roadway}{c.direction && c.direction !== "Unknown" ? ` · ${c.direction}` : ""}</div>} {c.roadway && <div className="text-xs text-ink-muted">{c.roadway}{c.direction && c.direction !== "Unknown" ? ` · ${c.direction}` : ""}</div>}
{c.image_url && ( {c.image_url && (
// Popup content mounts on open, so the timestamp busts the cache per open. // Popup content mounts on open, so the timestamp busts the cache per open.
// eslint-disable-next-line @next/next/no-img-element <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">
<img src={`${c.image_url}?t=${Date.now()}`} alt={`Camera: ${c.name}`} className="w-full rounded border border-line" loading="lazy" /> {/* 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="text-[10px] text-ink-muted">Snapshot: 511NY / NYSDOT</div> <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> </div>
</Popup> </Popup>
</Marker> </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} />
</> </>
); );
} }
@@ -410,16 +580,26 @@ function DotCameraLayer() {
function TrafficEventLayer() { function TrafficEventLayer() {
const map = useMap(); const map = useMap();
const shown = useOverlayShown(map, OVERLAY_TRAFFIC_EVENTS); const shown = useOverlayShown(map, OVERLAY_TRAFFIC_EVENTS);
const zoom = useMapZoom(map);
const { data, error } = use511(map, "events", shown); const { data, error } = use511(map, "events", shown);
const cells = useMemo(() => lodCells(data.events ?? [], map, zoom, eventRank), [data.events, map, zoom]);
return ( return (
<> <>
{shown && <FeedProblem map={map} label="Traffic events" status={data.events_status} fetchError={error} />} {shown && <FeedProblem map={map} label="Traffic events" status={data.events_status} fetchError={error} />}
{(data.events ?? []).map((e: Ny511Event) => { {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 st = EVENT_STYLE[e.type] ?? EVENT_STYLE_OTHER;
const start = fmtLocal(e.start_local); const start = fmtLocal(e.start_local);
const end = fmtLocal(e.planned_end_local); const end = fmtLocal(e.planned_end_local);
return ( return (
<Marker key={e.id} position={[e.lat, e.lon]} icon={trafficEventIcon(e.type)} zIndexOffset={e.type === "accidentsAndIncidents" ? 200 : 0}> <Marker key={e.id} position={[e.lat, e.lon]} icon={trafficEventIcon(e.type)} zIndexOffset={zIndexOffset}>
<Popup minWidth={220} maxWidth={320}> <Popup minWidth={220} maxWidth={320}>
<div className="space-y-1"> <div className="space-y-1">
<div className="font-semibold">{st.label}{e.subtype ? `: ${e.subtype}` : ""}</div> <div className="font-semibold">{st.label}{e.subtype ? `: ${e.subtype}` : ""}</div>
@@ -804,7 +984,6 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
const [mapInstance, setMapInstance] = useState<L.Map | null>(null); const [mapInstance, setMapInstance] = useState<L.Map | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [agoClock, setAgoClock] = useState(0); const [agoClock, setAgoClock] = useState(0);
const [radarEpoch, setRadarEpoch] = useState(() => Date.now());
const [aircraftShown, setAircraftShown] = useState(false); const [aircraftShown, setAircraftShown] = useState(false);
// The altitude key only belongs in the legend while the opt-in Aircraft // The altitude key only belongs in the legend while the opt-in Aircraft
@@ -826,12 +1005,6 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
return () => clearInterval(id); 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 // eslint-disable-next-line react-hooks/exhaustive-deps
const ago = useMemo(() => (lastUpdated ? timeAgo(lastUpdated) : null), [lastUpdated, agoClock]); const ago = useMemo(() => (lastUpdated ? timeAgo(lastUpdated) : null), [lastUpdated, agoClock]);
@@ -964,14 +1137,9 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
</FeatureGroup> </FeatureGroup>
</LayersControl.Overlay> </LayersControl.Overlay>
{/* Overlay: Weather Radar — NEXRAD via Iowa Env Mesonet; key forces remount on refresh */} {/* Overlay: Weather Radar — NEXRAD via Iowa Env Mesonet */}
<LayersControl.Overlay name="Weather Radar"> <LayersControl.Overlay name="Weather Radar">
<TileLayer <RadarLayer />
key={radarEpoch}
url="https://mesonet.agron.iastate.edu/cache/tile.py/1.0.0/nexrad-n0r-900913/{z}/{x}/{y}.png"
attribution='Radar &copy; <a href="https://mesonet.agron.iastate.edu/">IEM/NWS</a>'
opacity={0.65}
/>
</LayersControl.Overlay> </LayersControl.Overlay>
</LayersControl> </LayersControl>
</MapContainer> </MapContainer>
+243
View File
@@ -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&apos;s runs the next enabled service, top first. Pin a service to the SDR with its
antenna, or leave it on &quot;Any spare SDR&quot;.
</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&apos;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,154 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { c2api } from "@/lib/c2api";
import type { NodeRecord } from "@/lib/types";
// node-26#9. OP25 always keeps its own SDR; every other SDR on the node runs
// the next enabled item here, top first — so a 3-SDR node runs both.
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 };
function rowsFrom(priority: string[]): Row[] {
return [
...priority.filter((m) => MODES.some((x) => x.mode === m)).map((mode) => ({ mode, enabled: true })),
...MODES.filter((x) => !priority.includes(x.mode)).map((x) => ({ mode: x.mode, enabled: false })),
];
}
export function SecondarySdrPriority({ node, canEdit }: { node: NodeRecord; canEdit: boolean }) {
const priority = node.secondary_sdr_priority ?? [];
// 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 [rows, setRows] = useState<Row[]>(() => rowsFrom(priority));
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 priorityKey = priority.join(",");
useEffect(() => {
if (!dirty) setRows(rowsFrom(priorityKey ? priorityKey.split(",") : []));
}, [priorityKey, 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);
}
async function save() {
setSaving(true);
setMessage(null);
try {
await c2api.updateNode(node.node_id, {
secondary_sdr_priority: rows.filter((r) => r.enabled).map((r) => r.mode),
});
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);
}
}
// Only quote a count the node actually sent alongside its running list; a
// bare sdr_count may be the Firestore default, not a report.
const sdrCount = reported ? node.sdr_count : undefined;
let rank = 0;
return (
<section>
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-1">Secondary SDRs</h2>
<p className="text-xs text-gray-500 font-mono mb-3">
OP25 always keeps its own SDR. Every other SDR runs the next enabled item, top first.
{" "}
{sdrCount != null
? `This node reports ${sdrCount} SDR${sdrCount === 1 ? "" : "s"} (${Math.max(sdrCount - 1, 0)} spare).`
: "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">
{rows.map((row, i) => {
const meta = MODES.find((x) => x.mode === row.mode)!;
const isRunning = running.includes(row.mode);
const state = !row.enabled
? "Off"
: dirty
? "Unsaved"
: !reported
? "Not reported"
: isRunning
? "Running"
: "Waiting for SDR";
return (
<div key={row.mode} className="flex 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-0">
<div className="text-gray-200">{meta.name}</div>
<div className="text-xs text-gray-500">{meta.hint}</div>
</div>
{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-28 text-right text-xs ${state === "Running" ? "text-green-400" : "text-gray-500"}`}>
{state}
</span>
</div>
);
})}
</div>
{canEdit && (
<div className="flex items-center gap-3 mt-3">
<button
onClick={save}
disabled={!dirty || saving}
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 priority"}
</button>
{message && <span className="text-xs text-gray-500 font-mono">{message}</span>}
</div>
)}
</section>
);
}
+10 -2
View File
@@ -37,7 +37,12 @@ export const c2api = {
request(`/nodes/${nodeId}/override/reset`, { method: "POST" }), request(`/nodes/${nodeId}/override/reset`, { method: "POST" }),
updateNode: ( updateNode: (
id: string, id: string,
body: { node_type?: string; enforce_override_timeout?: boolean; secondary_sdr_priority?: 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) }), request(`/nodes/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
@@ -323,7 +328,10 @@ export const c2api = {
}), }),
getUser: (uid: string) => getUser: (uid: string) =>
request<import("@/lib/types").UserRecord>(`/admin/users/${uid}`), 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}`, { request<import("@/lib/types").UserRecord>(`/admin/users/${uid}`, {
method: "PATCH", method: "PATCH",
body: JSON.stringify(body), body: JSON.stringify(body),
+16
View File
@@ -14,6 +14,9 @@ export interface UserRecord {
discord_linked: boolean; discord_linked: boolean;
discord_username: string | null; discord_username: string | null;
discord_user_id: 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} // only present on GET /admin/users/{uid}
sessions?: UserSession[]; sessions?: UserSession[];
// only present on POST /admin/users response // only present on POST /admin/users response
@@ -58,6 +61,12 @@ export interface NodeRecord {
secondary_sdr_priority?: string[]; secondary_sdr_priority?: string[];
/** What the node's last checkin reported actually running. */ /** What the node's last checkin reported actually running. */
secondary_sdr_running?: string[] | null; 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 sdr_count?: number | null; // null = never reported
enforce_override_timeout?: boolean; enforce_override_timeout?: boolean;
is_overridden?: boolean; is_overridden?: boolean;
@@ -65,6 +74,13 @@ export interface NodeRecord {
override_timeout_at?: string | null; override_timeout_at?: string | null;
} }
export interface SdrDevice {
index: number;
serial: string | null;
name: string;
duplicate_serial: boolean;
}
export interface AircraftTrack { export interface AircraftTrack {
icao: string; icao: string;
org_id?: string; org_id?: string;
+1
View File
@@ -13,6 +13,7 @@
"react": "^18.3.0", "react": "^18.3.0",
"react-dom": "^18.3.0", "react-dom": "^18.3.0",
"firebase": "^10.12.0", "firebase": "^10.12.0",
"hls.js": "^1.5.0",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"react-leaflet": "^4.2.1", "react-leaflet": "^4.2.1",
"react-markdown": "^9.0.1" "react-markdown": "^9.0.1"
+21 -15
View File
@@ -1,5 +1,8 @@
import type { Config } from "tailwindcss"; import type { Config } from "tailwindcss";
const tok = (name: string) =>
`color-mix(in srgb, var(--${name}) calc(<alpha-value> * 100%), transparent)`;
const config: Config = { const config: Config = {
content: [ content: [
"./app/**/*.{ts,tsx}", "./app/**/*.{ts,tsx}",
@@ -15,24 +18,27 @@ const config: Config = {
// Semantic tokens — defined as CSS custom properties in app/globals.css on // Semantic tokens — defined as CSS custom properties in app/globals.css on
// :root (light) and .dark (dark). Components must use these names, never a // :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. // 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: { colors: {
page: "var(--page)", page: tok("page"),
surface: "var(--surface)", surface: tok("surface"),
raised: "var(--raised)", raised: tok("raised"),
line: "var(--line)", line: tok("line"),
"line-strong": "var(--line-strong)", "line-strong": tok("line-strong"),
ink: { ink: {
DEFAULT: "var(--ink)", DEFAULT: tok("ink"),
2: "var(--ink-2)", 2: tok("ink-2"),
muted: "var(--ink-muted)", muted: tok("ink-muted"),
}, },
accent: "var(--accent)", accent: tok("accent"),
"sev-moderate": "var(--sev-moderate)", "sev-moderate": tok("sev-moderate"),
"sev-major": "var(--sev-major)", "sev-major": tok("sev-major"),
"map-bg": "var(--map-bg)", "map-bg": tok("map-bg"),
"map-block": "var(--map-block)", "map-block": tok("map-block"),
"map-road": "var(--map-road)", "map-road": tok("map-road"),
"map-water": "var(--map-water)", "map-water": tok("map-water"),
}, },
// Marketing/product type scale — used by the (marketing) surface and // Marketing/product type scale — used by the (marketing) surface and
// settings shell so headings read as a deliberate hierarchy rather than // settings shell so headings read as a deliberate hierarchy rather than