Compare commits

...
8 Commits
Author SHA1 Message Date
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
8 changed files with 294 additions and 26 deletions
+49 -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
@@ -101,6 +132,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 +147,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 +178,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 +230,20 @@ 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.
attached_org: Optional[str] = None
if not existing_claims.get("org_id"):
attached_org = await _resolve_org(body.org_id, decoded)
new_claims["org_id"] = attached_org
new_claims["org_role"] = "member"
elif body.org_id and body.org_id != existing_claims["org_id"]:
raise HTTPException(400, "User already belongs to another org; moving orgs isn't supported here.")
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 +264,7 @@ 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 {}),
}, },
) )
+85
View File
@@ -0,0 +1,85 @@
"""
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
assert _run("patch", "/admin/users/u1", {"org_id": "org-A"}, fb)[0].status_code == 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,
+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} />;
}
+38 -5
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;
@@ -380,14 +381,28 @@ function FeedProblem({ map, label, status, fetchError }: { map: L.Map; label: st
); );
} }
// Pinned cameras: clicking a camera's snapshot pins its live feed as a small
// tile anchored at the camera, so several views along one road can be read
// against the map. Oldest pin drops past the cap.
const MAX_CAMERA_PINS = 6;
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 { data, error } = use511(map, "cameras", shown); const { data, error } = use511(map, "cameras", shown);
const [pinned, setPinned] = useState<Ny511Camera[]>([]);
const pinnedIds = useMemo(() => new Set(pinned.map((c) => c.id)), [pinned]);
const pin = (c: Ny511Camera) => {
map.closePopup();
setPinned((prev) => (prev.some((p) => p.id === c.id) ? prev : [...prev, c].slice(-MAX_CAMERA_PINS)));
};
const unpin = (id: string) => setPinned((prev) => prev.filter((p) => p.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) => ( {(data.cameras ?? []).filter((c) => !pinnedIds.has(c.id)).map((c) => (
<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 +410,32 @@ 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={() => pin(c)} 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="text-[10px] text-ink-muted">
Click the image to pin {c.video_url ? "the live feed" : "it"} to the map · 511NY / NYSDOT
</div>
</div> </div>
</Popup> </Popup>
</Marker> </Marker>
))} ))}
{/* Players only exist while the overlay is on, so hiding it stops every stream. */}
{shown && pinned.map((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={() => unpin(c.id)} aria-label={`Unpin ${c.name}`} className="text-ink-muted hover:text-ink text-xs leading-none px-0.5">×</button>
</div>
<CameraFeed camera={c} className="block w-[192px] h-[108px] object-cover bg-black rounded-b" />
</div>
</Tooltip>
</Marker>
))}
</> </>
); );
} }
+10 -4
View File
@@ -26,10 +26,11 @@ 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]"; "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({ function DeviceSelect({
value, devices, autoLabel, label, disabled, onChange, value, devices, devicesKnown, autoLabel, label, disabled, onChange,
}: { }: {
value: string; value: string;
devices: SdrDevice[]; devices: SdrDevice[];
devicesKnown: boolean;
autoLabel: string; autoLabel: string;
label: string; label: string;
disabled: boolean; disabled: boolean;
@@ -44,7 +45,9 @@ function DeviceSelect({
SDR {d.index + 1} · serial {d.serial ?? "unknown"}{d.duplicate_serial ? " (shared serial!)" : ""} SDR {d.index + 1} · serial {d.serial ?? "unknown"}{d.duplicate_serial ? " (shared serial!)" : ""}
</option> </option>
))} ))}
{value && !known && <option value={value}>serial {value} (not plugged in)</option>} {value && !known && (
<option value={value}>serial {value} ({devicesKnown ? "not plugged in" : "not reported"})</option>
)}
</select> </select>
); );
} }
@@ -57,6 +60,7 @@ export function SdrSettings({ node, canEdit }: { node: NodeRecord; canEdit: bool
const reported = node.secondary_sdr_running != null; const reported = node.secondary_sdr_running != null;
const running = node.secondary_sdr_running ?? []; const running = node.secondary_sdr_running ?? [];
const devices = node.sdr_devices ?? []; const devices = node.sdr_devices ?? [];
const devicesKnown = reported && node.sdr_devices != null;
const [rows, setRows] = useState<Row[]>(() => rowsFrom(priority, pins)); const [rows, setRows] = useState<Row[]>(() => rowsFrom(priority, pins));
const [op25Pin, setOp25Pin] = useState(pins.op25 ?? ""); const [op25Pin, setOp25Pin] = useState(pins.op25 ?? "");
@@ -114,7 +118,7 @@ export function SdrSettings({ node, canEdit }: { node: NodeRecord; canEdit: bool
<section> <section>
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-1">SDRs</h2> <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"> <p className="text-xs text-gray-500 font-mono mb-3">
{reported && node.sdr_devices != null {devicesKnown
? `This node reports ${devices.length} SDR${devices.length === 1 ? "" : "s"}.` ? `This node reports ${devices.length} SDR${devices.length === 1 ? "" : "s"}.`
: "This node hasn't reported its SDRs yet."} : "This node hasn't reported its SDRs yet."}
</p> </p>
@@ -134,6 +138,7 @@ export function SdrSettings({ node, canEdit }: { node: NodeRecord; canEdit: bool
<DeviceSelect <DeviceSelect
value={op25Pin} value={op25Pin}
devices={devices} devices={devices}
devicesKnown={devicesKnown}
autoLabel="Automatic (first SDR)" autoLabel="Automatic (first SDR)"
label="OP25 SDR" label="OP25 SDR"
disabled={!canEdit} disabled={!canEdit}
@@ -143,7 +148,7 @@ export function SdrSettings({ node, canEdit }: { node: NodeRecord; canEdit: bool
{rows.map((row, i) => { {rows.map((row, i) => {
const meta = MODES.find((x) => x.mode === row.mode)!; const meta = MODES.find((x) => x.mode === row.mode)!;
const pinMissing = !!row.pin && !devices.some((d) => d.serial === row.pin); const pinMissing = devicesKnown && !!row.pin && !devices.some((d) => d.serial === row.pin);
const state = !row.enabled const state = !row.enabled
? "Off" ? "Off"
: dirty : dirty
@@ -173,6 +178,7 @@ export function SdrSettings({ node, canEdit }: { node: NodeRecord; canEdit: bool
<DeviceSelect <DeviceSelect
value={row.pin} value={row.pin}
devices={devices} devices={devices}
devicesKnown={devicesKnown}
autoLabel="Any spare SDR" autoLabel="Any spare SDR"
label={`${meta.name} SDR`} label={`${meta.name} SDR`}
disabled={!canEdit} disabled={!canEdit}
+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