Compare commits

..
Author SHA1 Message Date
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
14 changed files with 471 additions and 187 deletions
+5 -1
View File
@@ -114,6 +114,9 @@ class MQTTHandler:
"secondary_sdr_mode": payload.get("secondary_sdr_mode", "none"), "secondary_sdr_mode": payload.get("secondary_sdr_mode", "none"),
"secondary_sdr_priority": payload.get("secondary_sdr_priority", []), "secondary_sdr_priority": payload.get("secondary_sdr_priority", []),
"secondary_sdr_running": payload.get("secondary_sdr_running"), "secondary_sdr_running": payload.get("secondary_sdr_running"),
"sdr_pins": payload.get("sdr_pins", {}),
"sdr_devices": payload.get("sdr_devices"),
"op25_sdr_serial": payload.get("op25_sdr_serial"),
"sdr_count": payload.get("sdr_count"), # None until reported, never a guessed 1 "sdr_count": payload.get("sdr_count"), # None until reported, never a guessed 1
"enforce_override_timeout": payload.get("enforce_override_timeout", True), "enforce_override_timeout": payload.get("enforce_override_timeout", True),
"is_overridden": False, "is_overridden": False,
@@ -145,7 +148,8 @@ class MQTTHandler:
updates["node_type"] = node_type updates["node_type"] = node_type
updates["enforce_override_timeout"] = enforce_timeout updates["enforce_override_timeout"] = enforce_timeout
for key in ("secondary_sdr_mode", "secondary_sdr_priority", "secondary_sdr_running"): for key in ("secondary_sdr_mode", "secondary_sdr_priority", "secondary_sdr_running",
"sdr_pins", "sdr_devices", "op25_sdr_serial"):
if key in payload: if key in payload:
updates[key] = payload[key] updates[key] = payload[key]
if "sdr_count" in payload: if "sdr_count" in payload:
+4
View File
@@ -68,6 +68,10 @@ class NodeRecord(BaseModel):
# checkin, which is the source of truth; set via PATCH /nodes/{id}. # checkin, which is the source of truth; set via PATCH /nodes/{id}.
secondary_sdr_priority: List[str] = [] secondary_sdr_priority: List[str] = []
secondary_sdr_running: Optional[List[str]] = None # what the node reports actually running secondary_sdr_running: Optional[List[str]] = None # what the node reports actually running
# node-26#11: service (op25/adsb/ais) -> dongle serial; absent = automatic.
sdr_pins: Dict[str, str] = {}
sdr_devices: Optional[List[Dict[str, Any]]] = None # [{index, serial, name, duplicate_serial}] from checkin
op25_sdr_serial: Optional[str] = None # the dongle OP25 is actually using, per the node
sdr_count: Optional[int] = None # self-reported by the node's checkin; None = never reported sdr_count: Optional[int] = None # self-reported by the node's checkin; None = never reported
enforce_override_timeout: bool = True enforce_override_timeout: bool = True
is_overridden: bool = False is_overridden: bool = False
+28 -6
View File
@@ -1,5 +1,5 @@
import secrets import secrets
from typing import List, Optional from typing import Dict, List, Optional
from fastapi import APIRouter, HTTPException, Depends, Query from fastapi import APIRouter, HTTPException, Depends, Query
from pydantic import BaseModel from pydantic import BaseModel
from app.models import CommandPayload from app.models import CommandPayload
@@ -193,6 +193,7 @@ async def assign_system(
SECONDARY_SDR_MODES = ("adsb", "ais") SECONDARY_SDR_MODES = ("adsb", "ais")
SDR_PIN_KEYS = ("op25",) + SECONDARY_SDR_MODES
class NodeUpdateBody(BaseModel): class NodeUpdateBody(BaseModel):
@@ -201,6 +202,9 @@ class NodeUpdateBody(BaseModel):
secondary_sdr_mode: Optional[str] = None # legacy: none | adsb | ais secondary_sdr_mode: Optional[str] = None # legacy: none | adsb | ais
# Ordered, e.g. ["adsb", "ais"]: SDRs beyond op25's run these top-down. # Ordered, e.g. ["adsb", "ais"]: SDRs beyond op25's run these top-down.
secondary_sdr_priority: Optional[List[str]] = None secondary_sdr_priority: Optional[List[str]] = None
# node-26#11: service -> dongle serial; null/"" = automatic. Moving OP25's
# dongle restarts OP25 on the node; the other pins never do.
sdr_pins: Optional[Dict[str, Optional[str]]] = None
@router.patch("/{node_id}") @router.patch("/{node_id}")
@@ -224,14 +228,32 @@ async def update_node(
raise HTTPException(400, f"secondary_sdr_priority must be distinct values from {SECONDARY_SDR_MODES}.") raise HTTPException(400, f"secondary_sdr_priority must be distinct values from {SECONDARY_SDR_MODES}.")
updates["secondary_sdr_mode"] = priority[0] if priority else "none" updates["secondary_sdr_mode"] = priority[0] if priority else "none"
if "sdr_pins" in updates:
raw = updates["sdr_pins"] or {}
unknown = [k for k in raw if k not in SDR_PIN_KEYS]
if unknown:
raise HTTPException(400, f"sdr_pins keys must be from {SDR_PIN_KEYS}.")
pins = {k: str(v).strip() for k, v in raw.items() if v and str(v).strip()}
if len(set(pins.values())) != len(pins):
raise HTTPException(400, "Two services can't be pinned to the same SDR.")
updates["sdr_pins"] = pins
await fstore.doc_update("nodes", node_id, updates) await fstore.doc_update("nodes", node_id, updates)
# Priority goes as its own command: a config re-push restarts OP25, and # SDR settings go as their own command: a config re-push restarts OP25, and
# changing what the spare dongles do must never interrupt P25 recording. # changing what the spare dongles do must never interrupt P25 recording
# The node applies it, then its checkin reports back what's really running. # (only moving OP25's own dongle restarts it, on the node's side). The node
# 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: if priority is not None:
mqtt_handler.send_command(node_id, {"action": "set_secondary_priority", "priority": priority}) command["priority"] = priority
if set(updates) <= {"secondary_sdr_priority", "secondary_sdr_mode"}: if "sdr_pins" in updates:
# Explicit nulls so a cleared pin reaches the node as "automatic".
command["pins"] = {k: updates["sdr_pins"].get(k) for k in SDR_PIN_KEYS}
mqtt_handler.send_command(node_id, command)
if set(updates) <= sdr_keys:
return {"ok": True} return {"ok": True}
# Re-push config to apply new node settings locally # Re-push config to apply new node settings locally
@@ -40,7 +40,7 @@ def _patch(body):
def test_priority_only_sends_command_and_never_repushes_config(): def test_priority_only_sends_command_and_never_repushes_config():
resp, update, command, push = _patch({"secondary_sdr_priority": ["ais", "adsb"]}) resp, update, command, push = _patch({"secondary_sdr_priority": ["ais", "adsb"]})
assert resp.status_code == 200 assert resp.status_code == 200
command.assert_called_once_with("n1", {"action": "set_secondary_priority", "priority": ["ais", "adsb"]}) command.assert_called_once_with("n1", {"action": "set_sdr_config", "priority": ["ais", "adsb"]})
push.assert_not_called() push.assert_not_called()
(_, _, updates), _ = update.await_args (_, _, updates), _ = update.await_args
assert updates == {"secondary_sdr_priority": ["ais", "adsb"], "secondary_sdr_mode": "ais"} assert updates == {"secondary_sdr_priority": ["ais", "adsb"], "secondary_sdr_mode": "ais"}
@@ -49,7 +49,7 @@ def test_priority_only_sends_command_and_never_repushes_config():
def test_empty_priority_turns_secondaries_off(): def test_empty_priority_turns_secondaries_off():
resp, update, command, push = _patch({"secondary_sdr_priority": []}) resp, update, command, push = _patch({"secondary_sdr_priority": []})
assert resp.status_code == 200 assert resp.status_code == 200
command.assert_called_once_with("n1", {"action": "set_secondary_priority", "priority": []}) command.assert_called_once_with("n1", {"action": "set_sdr_config", "priority": []})
(_, _, updates), _ = update.await_args (_, _, updates), _ = update.await_args
assert updates["secondary_sdr_mode"] == "none" assert updates["secondary_sdr_mode"] == "none"
@@ -57,3 +57,20 @@ def test_empty_priority_turns_secondaries_off():
def test_unknown_or_duplicate_modes_are_rejected(): def test_unknown_or_duplicate_modes_are_rejected():
assert _patch({"secondary_sdr_priority": ["adsb", "sonar"]})[0].status_code == 400 assert _patch({"secondary_sdr_priority": ["adsb", "sonar"]})[0].status_code == 400
assert _patch({"secondary_sdr_priority": ["adsb", "adsb"]})[0].status_code == 400 assert _patch({"secondary_sdr_priority": ["adsb", "adsb"]})[0].status_code == 400
def test_pins_send_every_service_with_nulls_for_automatic():
resp, update, command, push = _patch({"sdr_pins": {"op25": "00000001", "adsb": "69420", "ais": ""}})
assert resp.status_code == 200
push.assert_not_called()
command.assert_called_once_with("n1", {
"action": "set_sdr_config",
"pins": {"op25": "00000001", "adsb": "69420", "ais": None},
})
(_, _, updates), _ = update.await_args
assert updates == {"sdr_pins": {"op25": "00000001", "adsb": "69420"}}
def test_two_services_on_one_dongle_or_unknown_service_rejected():
assert _patch({"sdr_pins": {"op25": "69420", "adsb": "69420"}})[0].status_code == 400
assert _patch({"sdr_pins": {"sonar": "1"}})[0].status_code == 400
+26
View File
@@ -67,6 +67,10 @@ html, body {
font-family: var(--font-mono), ui-monospace, monospace; font-family: var(--font-mono), ui-monospace, monospace;
} }
/* Dark mode: raw gray-600/700 text sits ~2.4:1 on the near-black page — below
* readable. Lift both to gray-500 (~4:1). */
.dark .text-gray-600, .dark .text-gray-700 { color: #6b7280; }
/* ── Light mode overrides ─────────────────────────────────────────────────── */ /* ── Light mode overrides ─────────────────────────────────────────────────── */
/* /*
* The app's components use hardcoded dark-palette Tailwind classes (bg-gray-9xx, * The app's components use hardcoded dark-palette Tailwind classes (bg-gray-9xx,
@@ -84,6 +88,7 @@ html:not(.dark) .bg-gray-900\/60 { background-color: rgba(255,255,255,0.85) !
html:not(.dark) .bg-gray-900\/50 { background-color: rgba(255,255,255,0.75) !important; } html:not(.dark) .bg-gray-900\/50 { background-color: rgba(255,255,255,0.75) !important; }
html:not(.dark) .bg-gray-900\/30 { background-color: rgba(255,255,255,0.50) !important; } html:not(.dark) .bg-gray-900\/30 { background-color: rgba(255,255,255,0.50) !important; }
html:not(.dark) .bg-gray-800 { background-color: #f1f5f9 !important; } html:not(.dark) .bg-gray-800 { background-color: #f1f5f9 !important; }
html:not(.dark) .bg-gray-800\/60 { background-color: rgba(226,232,240,0.70) !important; }
html:not(.dark) .bg-gray-800\/40 { background-color: rgba(241,245,249,0.60) !important; } html:not(.dark) .bg-gray-800\/40 { background-color: rgba(241,245,249,0.60) !important; }
html:not(.dark) .bg-gray-800\/30 { background-color: rgba(241,245,249,0.50) !important; } html:not(.dark) .bg-gray-800\/30 { background-color: rgba(241,245,249,0.50) !important; }
html:not(.dark) .bg-gray-700 { background-color: #e2e8f0 !important; } html:not(.dark) .bg-gray-700 { background-color: #e2e8f0 !important; }
@@ -91,16 +96,25 @@ html:not(.dark) .bg-gray-700 { background-color: #e2e8f0 !important; }
/* Borders */ /* Borders */
html:not(.dark) .border-gray-800 { border-color: #e2e8f0 !important; } html:not(.dark) .border-gray-800 { border-color: #e2e8f0 !important; }
html:not(.dark) .border-gray-700 { border-color: #cbd5e1 !important; } html:not(.dark) .border-gray-700 { border-color: #cbd5e1 !important; }
html:not(.dark) .border-gray-600 { border-color: #94a3b8 !important; }
html:not(.dark) .border-gray-800\/60 { border-color: #e2e8f0 !important; }
html:not(.dark) .divide-gray-800 > * + * { border-color: #e2e8f0 !important; } html:not(.dark) .divide-gray-800 > * + * { border-color: #e2e8f0 !important; }
/* Text */ /* Text */
html:not(.dark) .text-white { color: #0f172a !important; } html:not(.dark) .text-white { color: #0f172a !important; }
html:not(.dark) .text-gray-100 { color: #1e293b !important; } html:not(.dark) .text-gray-100 { color: #1e293b !important; }
html:not(.dark) .text-gray-200 { color: #1e293b !important; }
html:not(.dark) .text-gray-300 { color: #334155 !important; } html:not(.dark) .text-gray-300 { color: #334155 !important; }
html:not(.dark) .text-gray-400 { color: #475569 !important; } html:not(.dark) .text-gray-400 { color: #475569 !important; }
html:not(.dark) .text-gray-500 { color: #64748b !important; } html:not(.dark) .text-gray-500 { color: #64748b !important; }
html:not(.dark) .text-gray-600 { color: #94a3b8 !important; } html:not(.dark) .text-gray-600 { color: #94a3b8 !important; }
/* …except on saturated fills (primary/danger/success buttons), where the fill
* stays dark in both themes — remapping to navy made their labels unreadable. */
html:not(.dark) .text-white:is(.bg-accent, .bg-sev-major, .bg-sev-moderate,
.bg-indigo-500, .bg-indigo-600, .bg-indigo-700, .bg-red-600, .bg-red-700,
.bg-green-600, .bg-green-700, .bg-yellow-700, .bg-yellow-800, .bg-gray-600) { color: #ffffff !important; }
/* Hover states */ /* Hover states */
html:not(.dark) .hover\:bg-gray-900:hover { background-color: #f8fafc !important; } html:not(.dark) .hover\:bg-gray-900:hover { background-color: #f8fafc !important; }
html:not(.dark) .hover\:bg-gray-900\/50:hover { background-color: rgba(255,255,255,0.75) !important; } html:not(.dark) .hover\:bg-gray-900\/50:hover { background-color: rgba(255,255,255,0.75) !important; }
@@ -177,6 +191,18 @@ html:not(.dark) .border-indigo-800 { border-color: #a5b4fc !important; }
z-index: 0; z-index: 0;
} }
/* Pinned 511 camera tiles (MapView DotCameraLayer): strip Leaflet's tooltip
* padding and let the tile's own theme colours show, so the tile is no bigger
* than the video it holds. */
.leaflet-tooltip.drb-cam-pin {
padding: 0;
border-radius: 6px;
background: var(--surface);
border: 1px solid var(--line-strong);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.45);
white-space: normal;
}
/* ── Form inputs ─────────────────────────────────────────────────────────── */ /* ── Form inputs ─────────────────────────────────────────────────────────── */
html:not(.dark) input:not([type="submit"]):not([type="button"]):not([type="reset"]), html:not(.dark) input:not([type="submit"]):not([type="button"]):not([type="reset"]),
html:not(.dark) select, html:not(.dark) select,
+2 -2
View File
@@ -8,7 +8,7 @@ import { useSystems } from "@/lib/useSystems";
import { useCalls } from "@/lib/useCalls"; import { useCalls } from "@/lib/useCalls";
import { StatusBadge } from "@/components/StatusBadge"; import { StatusBadge } from "@/components/StatusBadge";
import { NodeConfigModal } from "@/components/NodeConfigModal"; import { NodeConfigModal } from "@/components/NodeConfigModal";
import { SecondarySdrPriority } from "@/components/SecondarySdrPriority"; import { SdrSettings } from "@/components/SdrSettings";
import { CallRow } from "@/components/CallRow"; import { CallRow } from "@/components/CallRow";
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice"; import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
import { useAuth } from "@/components/AuthProvider"; import { useAuth } from "@/components/AuthProvider";
@@ -337,7 +337,7 @@ export default function NodeDetailPage() {
)} )}
</div> </div>
<SecondarySdrPriority node={node} canEdit={isAdmin} /> <SdrSettings node={node} canEdit={isAdmin} />
{/* Recent calls */} {/* Recent calls */}
<section> <section>
+64
View File
@@ -0,0 +1,64 @@
"use client";
import { useEffect, useRef, useState } from "react";
import type HlsType from "hls.js";
import type { Ny511Camera } from "@/lib/types";
// 511NY stills are served with max-age=60, so refreshing faster buys nothing.
const SNAPSHOT_REFRESH_MS = 60_000;
/**
* One 511NY camera: the live HLS stream when the camera has one (NYSDOT's
* skyvdn hosts send Access-Control-Allow-Origin: *), otherwise its snapshot on
* a timer. A stream that fails falls back to the snapshot rather than a black box.
*/
export function CameraFeed({ camera, className }: { camera: Ny511Camera; className?: string }) {
const videoRef = useRef<HTMLVideoElement>(null);
const [videoFailed, setVideoFailed] = useState(false);
const [tick, setTick] = useState(() => Date.now());
const useVideo = !!camera.video_url && !videoFailed;
useEffect(() => {
if (useVideo) return;
const id = setInterval(() => setTick(Date.now()), SNAPSHOT_REFRESH_MS);
return () => clearInterval(id);
}, [useVideo]);
useEffect(() => {
const video = videoRef.current;
const src = camera.video_url;
if (!useVideo || !video || !src) return;
let hls: HlsType | null = null;
let cancelled = false;
if (video.canPlayType("application/vnd.apple.mpegurl")) {
video.src = src; // Safari/iOS play HLS natively
} else {
import("hls.js")
.then(({ default: Hls }) => {
if (cancelled) return;
if (!Hls.isSupported()) { setVideoFailed(true); return; }
hls = new Hls({ maxBufferLength: 10, backBufferLength: 0 });
hls.on(Hls.Events.ERROR, (_evt, data) => { if (data.fatal) setVideoFailed(true); });
hls.loadSource(src);
hls.attachMedia(video);
})
.catch(() => setVideoFailed(true));
}
return () => {
cancelled = true;
hls?.destroy();
video.removeAttribute("src");
video.load(); // stop the download, not just the playback
};
}, [useVideo, camera.video_url]);
if (useVideo) {
return <video ref={videoRef} muted autoPlay playsInline className={className} onError={() => setVideoFailed(true)} />;
}
if (!camera.image_url) {
return <div className={`${className ?? ""} flex items-center justify-center text-[10px] text-ink-muted`}>No image</div>;
}
// eslint-disable-next-line @next/next/no-img-element
return <img src={`${camera.image_url}?t=${tick}`} alt={`Camera: ${camera.name}`} className={className} />;
}
+37 -4
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">
{/* 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" /> <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>
))}
</> </>
); );
} }
+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>
);
}
+6 -1
View File
@@ -37,7 +37,12 @@ export const c2api = {
request(`/nodes/${nodeId}/override/reset`, { method: "POST" }), request(`/nodes/${nodeId}/override/reset`, { method: "POST" }),
updateNode: ( updateNode: (
id: string, id: string,
body: { node_type?: string; enforce_override_timeout?: boolean; secondary_sdr_priority?: string[] }, body: {
node_type?: string;
enforce_override_timeout?: boolean;
secondary_sdr_priority?: string[];
sdr_pins?: Record<string, string | null>;
},
) => ) =>
request(`/nodes/${id}`, { method: "PATCH", body: JSON.stringify(body) }), request(`/nodes/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
+13
View File
@@ -58,6 +58,12 @@ export interface NodeRecord {
secondary_sdr_priority?: string[]; secondary_sdr_priority?: string[];
/** What the node's last checkin reported actually running. */ /** What the node's last checkin reported actually running. */
secondary_sdr_running?: string[] | null; secondary_sdr_running?: string[] | null;
/** node-26#11: service (op25/adsb/ais) -> dongle serial; absent = automatic. */
sdr_pins?: Record<string, string>;
/** Dongles the node detected, from its checkin; null/absent = not reported. */
sdr_devices?: SdrDevice[] | null;
/** The dongle OP25 is actually using, per the node. */
op25_sdr_serial?: string | null;
sdr_count?: number | null; // null = never reported sdr_count?: number | null; // null = never reported
enforce_override_timeout?: boolean; enforce_override_timeout?: boolean;
is_overridden?: boolean; is_overridden?: boolean;
@@ -65,6 +71,13 @@ export interface NodeRecord {
override_timeout_at?: string | null; override_timeout_at?: string | null;
} }
export interface SdrDevice {
index: number;
serial: string | null;
name: string;
duplicate_serial: boolean;
}
export interface AircraftTrack { export interface AircraftTrack {
icao: string; icao: string;
org_id?: string; org_id?: string;
+1
View File
@@ -13,6 +13,7 @@
"react": "^18.3.0", "react": "^18.3.0",
"react-dom": "^18.3.0", "react-dom": "^18.3.0",
"firebase": "^10.12.0", "firebase": "^10.12.0",
"hls.js": "^1.5.0",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"react-leaflet": "^4.2.1", "react-leaflet": "^4.2.1",
"react-markdown": "^9.0.1" "react-markdown": "^9.0.1"
+21 -15
View File
@@ -1,5 +1,8 @@
import type { Config } from "tailwindcss"; import type { Config } from "tailwindcss";
const tok = (name: string) =>
`color-mix(in srgb, var(--${name}) calc(<alpha-value> * 100%), transparent)`;
const config: Config = { const config: Config = {
content: [ content: [
"./app/**/*.{ts,tsx}", "./app/**/*.{ts,tsx}",
@@ -15,24 +18,27 @@ const config: Config = {
// Semantic tokens — defined as CSS custom properties in app/globals.css on // Semantic tokens — defined as CSS custom properties in app/globals.css on
// :root (light) and .dark (dark). Components must use these names, never a // :root (light) and .dark (dark). Components must use these names, never a
// raw gray-9xx, so a theme is one variable block rather than an override sheet. // raw gray-9xx, so a theme is one variable block rather than an override sheet.
// Wrapped in color-mix so opacity modifiers (bg-surface/90, bg-accent/15)
// work — a bare var() hex gives Tailwind nothing to apply alpha to, and the
// class silently generates no rule (map overlays rendered see-through).
colors: { colors: {
page: "var(--page)", page: tok("page"),
surface: "var(--surface)", surface: tok("surface"),
raised: "var(--raised)", raised: tok("raised"),
line: "var(--line)", line: tok("line"),
"line-strong": "var(--line-strong)", "line-strong": tok("line-strong"),
ink: { ink: {
DEFAULT: "var(--ink)", DEFAULT: tok("ink"),
2: "var(--ink-2)", 2: tok("ink-2"),
muted: "var(--ink-muted)", muted: tok("ink-muted"),
}, },
accent: "var(--accent)", accent: tok("accent"),
"sev-moderate": "var(--sev-moderate)", "sev-moderate": tok("sev-moderate"),
"sev-major": "var(--sev-major)", "sev-major": tok("sev-major"),
"map-bg": "var(--map-bg)", "map-bg": tok("map-bg"),
"map-block": "var(--map-block)", "map-block": tok("map-block"),
"map-road": "var(--map-road)", "map-road": tok("map-road"),
"map-water": "var(--map-water)", "map-water": tok("map-water"),
}, },
// Marketing/product type scale — used by the (marketing) surface and // Marketing/product type scale — used by the (marketing) surface and
// settings shell so headings read as a deliberate hierarchy rather than // settings shell so headings read as a deliberate hierarchy rather than