Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1a2721fbe |
@@ -114,9 +114,6 @@ 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,
|
||||||
@@ -148,8 +145,7 @@ 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:
|
||||||
|
|||||||
@@ -68,10 +68,6 @@ 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
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import secrets
|
import secrets
|
||||||
from typing import Dict, List, Optional
|
from typing import 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,7 +193,6 @@ 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):
|
||||||
@@ -202,9 +201,6 @@ 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}")
|
||||||
@@ -228,32 +224,14 @@ 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)
|
||||||
|
|
||||||
# SDR settings go as their own command: a config re-push restarts OP25, and
|
# Priority goes as its 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.
|
||||||
# (only moving OP25's own dongle restarts it, on the node's side). The node
|
# The node applies it, then its checkin reports back what's really running.
|
||||||
# applies it, then its checkin reports back what's really running.
|
if priority is not None:
|
||||||
sdr_keys = {"secondary_sdr_priority", "secondary_sdr_mode", "sdr_pins"}
|
mqtt_handler.send_command(node_id, {"action": "set_secondary_priority", "priority": priority})
|
||||||
if sdr_keys & set(updates):
|
if set(updates) <= {"secondary_sdr_priority", "secondary_sdr_mode"}:
|
||||||
command = {"action": "set_sdr_config"}
|
|
||||||
if priority is not None:
|
|
||||||
command["priority"] = priority
|
|
||||||
if "sdr_pins" in updates:
|
|
||||||
# Explicit nulls so a cleared pin reaches the node as "automatic".
|
|
||||||
command["pins"] = {k: updates["sdr_pins"].get(k) for k in SDR_PIN_KEYS}
|
|
||||||
mqtt_handler.send_command(node_id, command)
|
|
||||||
if set(updates) <= sdr_keys:
|
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
# Re-push config to apply new node settings locally
|
# Re-push config to apply new node settings locally
|
||||||
|
|||||||
@@ -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_sdr_config", "priority": ["ais", "adsb"]})
|
command.assert_called_once_with("n1", {"action": "set_secondary_priority", "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_sdr_config", "priority": []})
|
command.assert_called_once_with("n1", {"action": "set_secondary_priority", "priority": []})
|
||||||
(_, _, updates), _ = update.await_args
|
(_, _, updates), _ = update.await_args
|
||||||
assert updates["secondary_sdr_mode"] == "none"
|
assert updates["secondary_sdr_mode"] == "none"
|
||||||
|
|
||||||
@@ -57,20 +57,3 @@ 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
|
|
||||||
|
|||||||
@@ -177,6 +177,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,
|
||||||
|
|||||||
@@ -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 { SdrSettings } from "@/components/SdrSettings";
|
import { SecondarySdrPriority } from "@/components/SecondarySdrPriority";
|
||||||
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>
|
||||||
|
|
||||||
<SdrSettings node={node} canEdit={isAdmin} />
|
<SecondarySdrPriority node={node} canEdit={isAdmin} />
|
||||||
|
|
||||||
{/* Recent calls */}
|
{/* Recent calls */}
|
||||||
<section>
|
<section>
|
||||||
|
|||||||
@@ -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} />;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
))}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,237 +0,0 @@
|
|||||||
"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, autoLabel, label, disabled, onChange,
|
|
||||||
}: {
|
|
||||||
value: string;
|
|
||||||
devices: SdrDevice[];
|
|
||||||
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} (not plugged in)</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 [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">
|
|
||||||
{reported && node.sdr_devices != null
|
|
||||||
? `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}
|
|
||||||
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 = !!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}
|
|
||||||
autoLabel="Any spare SDR"
|
|
||||||
label={`${meta.name} SDR`}
|
|
||||||
disabled={!canEdit}
|
|
||||||
onChange={(v) => edit(rows.map((r, j) => (j === i ? { ...r, pin: v } : r)))}
|
|
||||||
/>
|
|
||||||
{canEdit && (
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => move(i, -1)}
|
|
||||||
disabled={i === 0}
|
|
||||||
aria-label={`Move ${meta.name} up`}
|
|
||||||
className="w-7 h-7 rounded bg-gray-800 hover:bg-gray-700 text-gray-300 disabled:opacity-30"
|
|
||||||
>
|
|
||||||
▲
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => move(i, 1)}
|
|
||||||
disabled={i === rows.length - 1}
|
|
||||||
aria-label={`Move ${meta.name} down`}
|
|
||||||
className="w-7 h-7 rounded bg-gray-800 hover:bg-gray-700 text-gray-300 disabled:opacity-30"
|
|
||||||
>
|
|
||||||
▼
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<span className={`w-32 text-right text-xs ${state === "Running" ? "text-green-400" : "text-gray-500"}`}>
|
|
||||||
{state}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-xs text-gray-500 font-mono mt-2">
|
|
||||||
Every SDR besides OP25's runs the next enabled service, top first. Pin a service to the SDR with its
|
|
||||||
antenna, or leave it on "Any spare SDR".
|
|
||||||
</p>
|
|
||||||
{devices.some((d) => d.duplicate_serial) && (
|
|
||||||
<p className="text-xs text-red-400 font-mono mt-2">
|
|
||||||
Two SDRs share a serial number, so they can't be told apart. Give each a unique serial (rtl_eeprom -s)
|
|
||||||
before pinning.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{clash && <p className="text-xs text-red-400 font-mono mt-2">Two services are pinned to the same SDR.</p>}
|
|
||||||
|
|
||||||
{canEdit && (
|
|
||||||
<div className="flex items-center gap-3 mt-3">
|
|
||||||
<button
|
|
||||||
onClick={save}
|
|
||||||
disabled={!dirty || saving || clash}
|
|
||||||
className="px-4 py-2 bg-indigo-700 hover:bg-indigo-600 disabled:opacity-50 text-white rounded-lg text-sm font-mono transition-colors"
|
|
||||||
>
|
|
||||||
{saving ? "Saving…" : "Save"}
|
|
||||||
</button>
|
|
||||||
{message && <span className="text-xs text-gray-500 font-mono">{message}</span>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -37,12 +37,7 @@ export const c2api = {
|
|||||||
request(`/nodes/${nodeId}/override/reset`, { method: "POST" }),
|
request(`/nodes/${nodeId}/override/reset`, { method: "POST" }),
|
||||||
updateNode: (
|
updateNode: (
|
||||||
id: string,
|
id: string,
|
||||||
body: {
|
body: { node_type?: string; enforce_override_timeout?: boolean; secondary_sdr_priority?: string[] },
|
||||||
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) }),
|
||||||
|
|
||||||
|
|||||||
@@ -58,12 +58,6 @@ 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;
|
||||||
@@ -71,13 +65,6 @@ 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;
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user