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
# ─────────────────────────────────────────────────────────────────────────────
def _fmt_idle(inc: dict, now: datetime) -> str:
def _fmt_since(raw, now: datetime) -> str:
try:
raw = inc.get("updated_at") or inc.get("started_at") or ""
dt = datetime.fromisoformat(str(raw).replace("Z", "+00:00"))
dt = datetime.fromisoformat(str(raw or "").replace("Z", "+00:00"))
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
minutes = int((now - dt).total_seconds() / 60)
return f"{minutes}min ago" if minutes < 60 else f"{minutes // 60}h{minutes % 60:02d}m ago"
# Clamped: the recorrelation sweep back-dates `now` (see
# _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:
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:
# 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,
@@ -65,6 +71,19 @@ def _inc_summary(inc: dict, now: datetime) -> str:
if tags:
parts.append(f"tags:[{', '.join(tags[:4])}]")
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)
@@ -90,6 +109,8 @@ def _call_block(ctx: dict) -> str:
lines.append(f"Units: {ctx['call_units']}")
if 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:
# 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
@@ -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>"}'
_RULES = """
Rules (this system OVER-SPLITS — a real incident routinely gets shattered into
5-10 duplicates. A wrong link is cheap; a duplicate incident is the failure
mode. Bias accordingly.):
Rules (this system usually OVER-SPLITS — a real incident often gets shattered
into 5-10 duplicates, so lean toward "link" for follow-ups. The exception is a
call that reports a new event, below.):
- 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
"Ave"/"Avenue", "St"/"Street", "Rd"/"Road" as identical; a house number plus
the same street is the same place), the same subject/vehicle/case number, or a
follow-up beat ("units clearing", "negative contact", "tow en route", "event
number 214-201", a status update) to an incident that is only a few minutes
idle. The bar for "link" on the same talkgroup is LOW.
TALKGROUP: the same or an adjacent location (treat "Ave"/"Avenue",
"St"/"Street", "Rd"/"Road" as identical; a house number plus the same street
is the same place), the same subject/vehicle/case number, or a follow-up beat
("units clearing", "negative contact", "tow en route", "event number
214-201", a status update) from units already on it. The bar for "link" on
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
recent incident — a different place, different units, and a different subject,
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_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,
@@ -145,7 +148,8 @@ class MQTTHandler:
updates["node_type"] = node_type
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:
updates[key] = payload[key]
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}.
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
+28 -6
View File
@@ -1,5 +1,5 @@
import secrets
from typing import List, Optional
from typing import Dict, List, Optional
from fastapi import APIRouter, HTTPException, Depends, Query
from pydantic import BaseModel
from app.models import CommandPayload
@@ -193,6 +193,7 @@ async def assign_system(
SECONDARY_SDR_MODES = ("adsb", "ais")
SDR_PIN_KEYS = ("op25",) + SECONDARY_SDR_MODES
class NodeUpdateBody(BaseModel):
@@ -201,6 +202,9 @@ class NodeUpdateBody(BaseModel):
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}")
@@ -224,14 +228,32 @@ async def update_node(
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)
# Priority goes as its own command: a config re-push restarts OP25, and
# changing what the spare dongles do must never interrupt P25 recording.
# The node applies it, then its checkin reports back what's really running.
# 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:
mqtt_handler.send_command(node_id, {"action": "set_secondary_priority", "priority": priority})
if set(updates) <= {"secondary_sdr_priority", "secondary_sdr_mode"}:
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
+60 -2
View File
@@ -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 {}),
},
)
+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
assert _prompt_incidents([{"incident_id": "a", "started_at": NOW.isoformat()},
{"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():
resp, update, command, push = _patch({"secondary_sdr_priority": ["ais", "adsb"]})
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()
(_, _, updates), _ = update.await_args
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():
resp, update, command, push = _patch({"secondary_sdr_priority": []})
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
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():
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
+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 [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"}>
+26
View File
@@ -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,
+2 -2
View File
@@ -8,7 +8,7 @@ import { useSystems } from "@/lib/useSystems";
import { useCalls } from "@/lib/useCalls";
import { StatusBadge } from "@/components/StatusBadge";
import { NodeConfigModal } from "@/components/NodeConfigModal";
import { SecondarySdrPriority } from "@/components/SecondarySdrPriority";
import { SdrSettings } from "@/components/SdrSettings";
import { CallRow } from "@/components/CallRow";
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
import { useAuth } from "@/components/AuthProvider";
@@ -337,7 +337,7 @@ export default function NodeDetailPage() {
)}
</div>
<SecondarySdrPriority node={node} canEdit={isAdmin} />
<SdrSettings node={node} canEdit={isAdmin} />
{/* Recent calls */}
<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} />;
}
+192 -24
View File
@@ -22,7 +22,8 @@ import { useAircraft } from "@/lib/useAircraft";
import { useAircraftTrail } from "@/lib/useAircraftTrail";
import { useVessels } from "@/lib/useVessels";
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 ──────────────────────────────────────────────────────────
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) ─────────────────────────────────────
// Overlay names double as the event keys for overlayadd/overlayremove.
const OVERLAY_DOT_CAMERAS = "DOT Cameras";
@@ -330,16 +355,24 @@ function useOverlayShown(map: L.Map, name: string): boolean {
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({
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],
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" },
@@ -348,17 +381,65 @@ const EVENT_STYLE: Record<string, { glyph: string; fill: string; label: string }
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): L.DivIcon {
function trafficEventIcon(type: string, count = 1): L.DivIcon {
const st = EVENT_STYLE[type] ?? EVENT_STYLE_OTHER;
return L.divIcon({
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],
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
@@ -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() {
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} />}
{(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()}>
<Popup minWidth={260} maxWidth={340}>
<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.image_url && (
// 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">
{/* 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>
</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} />
</>
);
}
@@ -410,16 +580,26 @@ function DotCameraLayer() {
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} />}
{(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 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={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}>
<div className="space-y-1">
<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 [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
@@ -826,12 +1005,6 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
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]);
@@ -964,14 +1137,9 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
</FeatureGroup>
</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">
<TileLayer
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}
/>
<RadarLayer />
</LayersControl.Overlay>
</LayersControl>
</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" }),
updateNode: (
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) }),
@@ -323,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),
+16
View File
@@ -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
@@ -58,6 +61,12 @@ export interface NodeRecord {
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;
@@ -65,6 +74,13 @@ export interface NodeRecord {
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;
+1
View File
@@ -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"
+21 -15
View File
@@ -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