Compare commits

..
Author SHA1 Message Date
Logan CusanoandClaude Opus 5.5 3cbb0828ae Secondary SDR priority: C2 field, node command, dashboard section
Pairs with node-26 feat/secondary-sdr-priority. NodeRecord gains
secondary_sdr_priority (ordered; SDRs beyond OP25's run it top-down) and
secondary_sdr_running, both mirrored from the node's checkin.
PATCH /nodes/{id} accepts the priority, validates it, and sends it as a
'set_secondary_priority' MQTT command. A priority-only change never
re-pushes system config, because that restarts OP25. The node detail page
gets a 'Secondary SDRs' section (admin-editable) with enable, reorder,
save, and live Running / Waiting-for-SDR state from the checkin.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 14:10:11 -04:00
Logan CusanoandClaude Opus 5.5 fa207e494d map: altitude legend, buoys drawn as buoys, aircraft panel on phones
Build & Deploy / Build & push images (push) Successful in 5m35s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 33s
Build & Deploy / Deploy to VM (push) Successful in 3m59s
Build & Deploy / Report a failed deploy (push) Skipped
QA follow-ups on the ADS-B/AIS overlays:
- Legend gains an aircraft altitude key (tar1090 ramp) while the Aircraft
  overlay is on (closes #185).
- AIS aids to navigation (MMSI 99xxxxxxx) render as small yellow buoy
  diamonds labelled 'Aid to navigation', no speed; vessels get a 22px
  outlined hull, and heading 511/360 ('not available') no longer
  rotates the icon (closes #186).
- Aircraft details panel becomes a bottom sheet above the incident drawer
  below md, instead of fighting the layers control at the top right.

Verified: tsc --noEmit clean (node:20 on radio-box).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 13:45:19 -04:00
Logan CusanoandClaude Opus 5.5 b20196c499 Merge feat/aircraft-side-panel: aircraft details dock right, not over the trail
Build & Deploy / Build & push images (push) Successful in 5m43s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 41s
Build & Deploy / Deploy to VM (push) Successful in 2m3s
Build & Deploy / Report a failed deploy (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 13:43:11 -04:00
Logan CusanoandClaude Opus 5.5 e5fc6ca838 map: aircraft details dock at the right edge instead of a popup
The popup sat on top of the plane and hid the trail it had just drawn.
Details now render in a panel docked top-right (portaled into the
Leaflet container, click/scroll propagation disabled), so the map can
be panned to follow the path. Clicking empty map or the same plane
deselects; hover tooltip unchanged.

Verified: tsc --noEmit clean (node:20 on radio-box).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 13:32:24 -04:00
Logan CusanoandClaude Opus 5.5 5d97058f9d Merge feat/adsb-trails-icons: altitude-colored aircraft icons + flight trails
Build & Deploy / Build & push images (push) Successful in 4m50s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 32s
Build & Deploy / Deploy to VM (push) Successful in 1m27s
Build & Deploy / Report a failed deploy (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 13:16:08 -04:00
Logan CusanoandClaude Opus 5.5 bff69a1d04 ADS-B map: altitude-colored aircraft icons + click-to-show flight trail
Icons were 16px accent-colored glyphs, indistinguishable from OSM's own
airport symbols. Now a 30px outlined airliner silhouette filled on
tar1090/ADS-B Exchange's altitude hue ramp, with a callsign/altitude
hover tooltip; the selected aircraft grows and gets a white outline.

Clicking an aircraft draws the path heard so far, segment-colored by
altitude. c2-core writes one point per position change to
aircraft/{icao}/positions (deduped in-process, writes now concurrent);
points carry expire_at and a TTL fieldOverride deletes them after ~24h.
Trail reads are gated on the parent aircraft doc's org via get(), so the
query needs no org filter or composite index. The latest stretch without
a 20-min gap counts as the current flight.

Verified: c2-core pytest 479 passed; frontend tsc --noEmit clean (node:20
container on radio-box).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 13:12:36 -04:00
Logan CusanoandClaude Opus 5.5 9b83f0ec6d Merge ci/firestore-sa-auth: service-account auth for Firestore rules deploy (#51)
Build & Deploy / Build & push images (push) Successful in 4m14s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 30s
Build & Deploy / Deploy to VM (push) Successful in 1m40s
Build & Deploy / Report a failed deploy (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 12:57:07 -04:00
Logan CusanoandClaude Opus 5.5 5845fc5694 ci: deploy Firestore rules with a service account, not login:ci (#51)
The deploy-firestore-rules job has failed on every push because
FIREBASE_TOKEN was never set, so rule changes (e.g. aircraft/vessels for
node-26#9) never reached prod. Switch to a dedicated least-privilege
service account whose JSON key lives in FIREBASE_SA_KEY; login:ci tokens
are deprecated and carry their minter's full access. Key is written to
RUNNER_TEMP at 0600 and removed on exit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 12:46:33 -04:00
logan ddf13402d0 Merge pull request 'correlator: a radio code is not a location' (#182) from fix/radio-code-location into main
Build & Deploy / Build & push images (push) Successful in 4m9s
Build & Deploy / Deploy Firestore rules & indexes (push) Failing after 3s
Build & Deploy / Deploy to VM (push) Successful in 1m48s
Build & Deploy / Report a failed deploy (push) Successful in 1s
2026-09-27 11:32:12 -04:00
15 changed files with 637 additions and 73 deletions
+15 -13
View File
@@ -307,28 +307,30 @@ jobs:
- name: Deploy firestore rules and indexes
env:
FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }}
FIREBASE_SA_KEY: ${{ secrets.FIREBASE_SA_KEY }}
run: |
set -e
# server-26#51: this used to run over SSH on the deploy VM, gated
# on the VM having firebase-tools installed. It never did, so it
# silently warned-and-skipped on every single deploy for weeks.
# Running it here instead means the only prerequisite is a secret
# -- FIREBASE_TOKEN, from `firebase login:ci` -- rather than
# something installed by hand on a machine this pipeline doesn't
# otherwise touch. A missing token now fails this job LOUDLY
# (picked up by notify-failure) instead of a buried warning line
# nobody reads in the app deploy's logs.
if [ -z "$FIREBASE_TOKEN" ]; then
echo "FIREBASE_TOKEN secret is not set -- cannot deploy Firestore rules/indexes." >&2
echo "Generate one with 'firebase login:ci' and add it as a Gitea Actions secret." >&2
# Auth is a dedicated service account (drb-ci-firestore-deploy,
# roles: Firebase Rules Admin, Cloud Datastore Index Admin,
# Service Usage Consumer), its JSON key stored as the
# FIREBASE_SA_KEY secret. Not `firebase login:ci`: those tokens are
# deprecated and carry the full permissions of whoever minted them.
# A missing key fails this job LOUDLY (picked up by notify-failure).
if [ -z "$FIREBASE_SA_KEY" ]; then
echo "FIREBASE_SA_KEY secret is not set -- cannot deploy Firestore rules/indexes." >&2
echo "Add the drb-ci-firestore-deploy service account's JSON key as a Gitea Actions secret." >&2
exit 1
fi
export GOOGLE_APPLICATION_CREDENTIALS="$RUNNER_TEMP/firebase-sa.json"
trap 'rm -f "$GOOGLE_APPLICATION_CREDENTIALS"' EXIT
( umask 077 && printf '%s' "$FIREBASE_SA_KEY" > "$GOOGLE_APPLICATION_CREDENTIALS" )
npm install -g firebase-tools
cd infra/firestore
firebase deploy --only firestore:rules,firestore:indexes \
--project ${{ secrets.FIREBASE_PROJECT_ID }} \
--token "$FIREBASE_TOKEN" --non-interactive
--project ${{ secrets.FIREBASE_PROJECT_ID }} --non-interactive
notify-failure:
name: Report a failed deploy
@@ -371,7 +373,7 @@ jobs:
# failed before any deploy was attempted" text even when the app
# deployed fine and only the Firestore rules/indexes push failed.
if deploy_result != "failure" and rules_result == "failure":
detail = "App deploy succeeded; Firestore rules/indexes deploy FAILED (server-26#51). Rules may be stale — check FIREBASE_TOKEN and the job log."
detail = "App deploy succeeded; Firestore rules/indexes deploy FAILED (server-26#51). Rules may be stale — check the FIREBASE_SA_KEY secret and the job log."
# server-26#65: the old text here unconditionally claimed
# "production is still running the previous build" -- true only
+5 -2
View File
@@ -112,6 +112,8 @@ class MQTTHandler:
"approval_status": "pending",
"node_type": payload.get("node_type", "fixed"),
"secondary_sdr_mode": payload.get("secondary_sdr_mode", "none"),
"secondary_sdr_priority": payload.get("secondary_sdr_priority", []),
"secondary_sdr_running": payload.get("secondary_sdr_running"),
"sdr_count": payload.get("sdr_count", 1),
"enforce_override_timeout": payload.get("enforce_override_timeout", True),
"is_overridden": False,
@@ -143,8 +145,9 @@ class MQTTHandler:
updates["node_type"] = node_type
updates["enforce_override_timeout"] = enforce_timeout
if "secondary_sdr_mode" in payload:
updates["secondary_sdr_mode"] = payload["secondary_sdr_mode"]
for key in ("secondary_sdr_mode", "secondary_sdr_priority", "secondary_sdr_running"):
if key in payload:
updates[key] = payload[key]
if "sdr_count" in payload:
updates["sdr_count"] = payload["sdr_count"]
+6 -1
View File
@@ -62,7 +62,12 @@ class NodeRecord(BaseModel):
last_seen: Optional[datetime] = None
assigned_system_id: Optional[str] = None
node_type: str = "fixed" # fixed or portable
secondary_sdr_mode: str = "none" # none | adsb | ais | op25_2 — requires a second physical SDR
secondary_sdr_mode: str = "none" # legacy single-mode field; priority[0] on current nodes
# Ordered decoders for the SDRs beyond op25's (node-26#9): the node runs
# them top-down until it runs out of dongles. Mirrored from the node's own
# checkin, which is the source of truth; set via PATCH /nodes/{id}.
secondary_sdr_priority: List[str] = []
secondary_sdr_running: Optional[List[str]] = None # what the node reports actually running
sdr_count: int = 1 # self-reported by the node's checkin, best-effort
enforce_override_timeout: bool = True
is_overridden: bool = False
+25 -3
View File
@@ -1,5 +1,5 @@
import secrets
from typing import Optional
from typing import List, Optional
from fastapi import APIRouter, HTTPException, Depends, Query
from pydantic import BaseModel
from app.models import CommandPayload
@@ -192,10 +192,15 @@ async def assign_system(
return {"ok": True}
SECONDARY_SDR_MODES = ("adsb", "ais")
class NodeUpdateBody(BaseModel):
node_type: Optional[str] = None
enforce_override_timeout: Optional[bool] = None
secondary_sdr_mode: Optional[str] = None # none | adsb | ais | op25_2
secondary_sdr_mode: Optional[str] = None # legacy: none | adsb | ais
# Ordered, e.g. ["adsb", "ais"]: SDRs beyond op25's run these top-down.
secondary_sdr_priority: Optional[List[str]] = None
@router.patch("/{node_id}")
@@ -212,8 +217,23 @@ async def update_node(
if not updates:
return {"ok": True}
priority = updates.get("secondary_sdr_priority")
if priority is not None:
unknown = [m for m in priority if m not in SECONDARY_SDR_MODES]
if unknown or len(set(priority)) != len(priority):
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"
await fstore.doc_update("nodes", node_id, updates)
# Priority goes as its own command: a config re-push restarts OP25, and
# changing what the spare dongles do must never interrupt P25 recording.
# The node applies it, then its checkin reports back what's really running.
if priority is not None:
mqtt_handler.send_command(node_id, {"action": "set_secondary_priority", "priority": priority})
if set(updates) <= {"secondary_sdr_priority", "secondary_sdr_mode"}:
return {"ok": True}
# Re-push config to apply new node settings locally
updated_node = await fstore.doc_get("nodes", node_id)
assigned_system_id = updated_node.get("assigned_system_id")
@@ -228,7 +248,9 @@ async def update_node(
}
if updated_node.get("ppm_override") is not None:
push_payload["ppm_override"] = updated_node["ppm_override"]
if updated_node.get("secondary_sdr_mode") is not None:
if updated_node.get("secondary_sdr_priority") is not None:
push_payload["secondary_sdr_priority"] = updated_node["secondary_sdr_priority"]
elif updated_node.get("secondary_sdr_mode") is not None:
push_payload["secondary_sdr_mode"] = updated_node["secondary_sdr_mode"]
mqtt_handler.push_config(node_id, push_payload)
+45 -5
View File
@@ -1,5 +1,6 @@
from datetime import datetime, timezone
from typing import List, Optional
import asyncio
from datetime import datetime, timedelta, timezone
from typing import Dict, List, Optional, Tuple
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
@@ -10,6 +11,20 @@ from app.internal.logger import logger
router = APIRouter(prefix="/telemetry", tags=["telemetry"])
# Flight trail: every position change is also written to
# aircraft/{icao}/positions/{epoch_ms}, so clicking an aircraft on the map can
# draw the path heard so far. Points expire via a Firestore TTL policy on
# expire_at (infra/firestore/firestore.indexes.json fieldOverrides).
POSITIONS_SUBCOLLECTION = "positions"
POSITION_TTL = timedelta(hours=24)
# Last position written per icao, so an aircraft reported unchanged across
# several 10s uploads (readsb holds a position until a new one decodes)
# doesn't get a duplicate point each time. Process-local and lossy by design:
# after a restart the worst case is one duplicate point per aircraft.
_last_position: Dict[str, Tuple[float, float]] = {}
_LAST_POSITION_MAX = 5000
class AircraftReport(BaseModel):
icao: str
@@ -32,8 +47,8 @@ async def upload_adsb(
):
"""
Node-initiated: a second-SDR ADS-B decoder (node-26#9) periodically posts
its current aircraft snapshot here. One doc per icao, last-seen-wins —
this is a live-map overlay, not a flight history.
its current aircraft snapshot here. One doc per icao, last-seen-wins,
plus one trail point per position change (see POSITIONS_SUBCOLLECTION).
"""
node_id = decoded.get("node_id")
if not node_id:
@@ -43,7 +58,11 @@ async def upload_adsb(
org_id = node.get("org_id") if node else None
now = datetime.now(timezone.utc).isoformat()
expire_at = datetime.now(timezone.utc) + POSITION_TTL
epoch_ms = int(datetime.now(timezone.utc).timestamp() * 1000)
writes = []
trail = []
for ac in body.aircraft:
if not ac.icao:
continue
@@ -62,12 +81,33 @@ async def upload_adsb(
doc["org_id"] = org_id
writes.append(("aircraft", ac.icao, doc))
for collection, doc_id, doc in writes:
if ac.lat is None or ac.lon is None:
continue
pos = (ac.lat, ac.lon)
if _last_position.get(ac.icao) == pos:
continue
_last_position[ac.icao] = pos
point = {
"lat": ac.lat,
"lon": ac.lon,
"altitude_ft": ac.altitude_ft,
"t": now,
"expire_at": expire_at,
}
trail.append((f"aircraft/{ac.icao}/{POSITIONS_SUBCOLLECTION}", str(epoch_ms), point))
if len(_last_position) > _LAST_POSITION_MAX:
_last_position.clear()
async def _write(collection: str, doc_id: str, doc: dict) -> None:
try:
await fstore.doc_set(collection, doc_id, doc, merge=True)
except Exception as e:
logger.warning(f"Failed to upsert {collection}/{doc_id} from node {node_id}: {e}")
# Concurrent: a busy sky is dozens of aircraft, two writes each, every 10s.
await asyncio.gather(*(_write(*w) for w in writes + trail))
return {"ok": True, "count": len(writes)}
@@ -0,0 +1,59 @@
"""
node-26#9 — PATCH /nodes/{id} secondary_sdr_priority.
The priority must reach the node as its own MQTT command, never via a config
re-push: a config push restarts OP25, and reordering what the spare dongles do
must not interrupt P25 recording.
"""
from unittest.mock import AsyncMock, MagicMock, patch
from fastapi.testclient import TestClient
from app.main import app
from app.internal.auth import require_admin_token, require_service_or_firebase_token
from app.routers import nodes
client = TestClient(app)
NODE = {"node_id": "n1", "assigned_system_id": "sys-1", "hardware_preset": "rtl-sdr-v3"}
def setup_function():
app.dependency_overrides[require_admin_token] = lambda: {"admin": True}
app.dependency_overrides[require_service_or_firebase_token] = lambda: {"admin": True}
def teardown_function():
app.dependency_overrides.pop(require_admin_token, None)
app.dependency_overrides.pop(require_service_or_firebase_token, None)
def _patch(body):
with patch.object(nodes.fstore, "doc_get", AsyncMock(side_effect=lambda c, i: NODE if c == "nodes" else {"system_id": "sys-1"})), \
patch.object(nodes.fstore, "doc_update", AsyncMock()) as update, \
patch.object(nodes.mqtt_handler, "send_command", MagicMock(return_value=True)) as command, \
patch.object(nodes.mqtt_handler, "push_config", MagicMock()) as push:
resp = client.patch("/nodes/n1", json=body)
return resp, update, command, push
def test_priority_only_sends_command_and_never_repushes_config():
resp, update, command, push = _patch({"secondary_sdr_priority": ["ais", "adsb"]})
assert resp.status_code == 200
command.assert_called_once_with("n1", {"action": "set_secondary_priority", "priority": ["ais", "adsb"]})
push.assert_not_called()
(_, _, updates), _ = update.await_args
assert updates == {"secondary_sdr_priority": ["ais", "adsb"], "secondary_sdr_mode": "ais"}
def test_empty_priority_turns_secondaries_off():
resp, update, command, push = _patch({"secondary_sdr_priority": []})
assert resp.status_code == 200
command.assert_called_once_with("n1", {"action": "set_secondary_priority", "priority": []})
(_, _, updates), _ = update.await_args
assert updates["secondary_sdr_mode"] == "none"
def test_unknown_or_duplicate_modes_are_rejected():
assert _patch({"secondary_sdr_priority": ["adsb", "sonar"]})[0].status_code == 400
assert _patch({"secondary_sdr_priority": ["adsb", "adsb"]})[0].status_code == 400
+43 -2
View File
@@ -24,6 +24,11 @@ def _override(decoded: dict):
def teardown_function():
app.dependency_overrides.pop(require_node_service_or_firebase_token, None)
telemetry._last_position.clear()
def _writes_to(mock_set, collection_prefix: str):
return [c for c in mock_set.await_args_list if c.args[0].startswith(collection_prefix)]
def test_service_token_without_node_id_is_rejected():
@@ -41,8 +46,9 @@ def test_node_upload_upserts_and_stamps_org_id():
})
assert resp.status_code == 200
assert resp.json() == {"ok": True, "count": 1}
mock_set.assert_awaited_once()
(collection, doc_id, doc), kwargs = mock_set.await_args
snapshot = [c for c in mock_set.await_args_list if c.args[0] == "aircraft"]
assert len(snapshot) == 1
(collection, doc_id, doc), kwargs = snapshot[0]
assert collection == "aircraft"
assert doc_id == "A1B2C3"
assert doc["node_id"] == "node-1"
@@ -92,3 +98,38 @@ def test_ais_node_upload_skips_entries_missing_mmsi():
assert resp.status_code == 200
assert resp.json() == {"ok": True, "count": 0}
mock_set.assert_not_awaited()
def _post_adsb(aircraft):
with patch.object(telemetry.fstore, "doc_get_cached", AsyncMock(return_value={"org_id": "org-A"})), \
patch.object(telemetry.fstore, "doc_set", AsyncMock()) as mock_set:
resp = client.post("/telemetry/adsb", json={"aircraft": aircraft})
assert resp.status_code == 200
return mock_set
def test_position_writes_trail_point_with_ttl():
_override({"node": True, "node_id": "node-1"})
mock_set = _post_adsb([{"icao": "A1B2C3", "lat": 41.1, "lon": -73.8, "altitude_ft": 3000}])
trail = _writes_to(mock_set, "aircraft/A1B2C3/positions")
assert len(trail) == 1
(_, doc_id, point), _ = trail[0]
assert doc_id.isdigit()
assert (point["lat"], point["lon"], point["altitude_ft"]) == (41.1, -73.8, 3000)
assert point["expire_at"] > telemetry.datetime.now(telemetry.timezone.utc)
def test_unchanged_position_is_not_rewritten_to_trail():
_override({"node": True, "node_id": "node-1"})
_post_adsb([{"icao": "A1B2C3", "lat": 41.1, "lon": -73.8}])
again = _post_adsb([{"icao": "A1B2C3", "lat": 41.1, "lon": -73.8}])
moved = _post_adsb([{"icao": "A1B2C3", "lat": 41.2, "lon": -73.8}])
assert _writes_to(again, "aircraft/A1B2C3/positions") == []
assert len(_writes_to(moved, "aircraft/A1B2C3/positions")) == 1
def test_aircraft_without_position_gets_no_trail_point():
_override({"node": True, "node_id": "node-1"})
mock_set = _post_adsb([{"icao": "A1B2C3", "callsign": "UAL123"}])
assert _writes_to(mock_set, "aircraft/A1B2C3/positions") == []
assert len(_writes_to(mock_set, "aircraft")) == 1
+3
View File
@@ -8,6 +8,7 @@ import { useSystems } from "@/lib/useSystems";
import { useCalls } from "@/lib/useCalls";
import { StatusBadge } from "@/components/StatusBadge";
import { NodeConfigModal } from "@/components/NodeConfigModal";
import { SecondarySdrPriority } from "@/components/SecondarySdrPriority";
import { CallRow } from "@/components/CallRow";
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
import { useAuth } from "@/components/AuthProvider";
@@ -336,6 +337,8 @@ export default function NodeDetailPage() {
)}
</div>
<SecondarySdrPriority node={node} canEdit={isAdmin} />
{/* Recent calls */}
<section>
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Recent Calls</h2>
+206 -28
View File
@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import {
FeatureGroup,
LayersControl,
@@ -9,13 +10,16 @@ import {
Polyline,
Popup,
TileLayer,
Tooltip,
useMap,
useMapEvents,
} from "react-leaflet";
import L from "leaflet";
import type { CallRecord, IncidentRecord, NodeRecord, NodeStatus } from "@/lib/types";
import type { AircraftTrack, CallRecord, IncidentRecord, NodeRecord, NodeStatus } from "@/lib/types";
import { isKnownSeverity, SEVERITY_COLORS, SEVERITY_LABEL, type Severity } from "@/lib/severity";
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
import { useAircraft } from "@/lib/useAircraft";
import { useAircraftTrail } from "@/lib/useAircraftTrail";
import { useVessels } from "@/lib/useVessels";
// ── Leaflet icon fix ──────────────────────────────────────────────────────────
@@ -92,34 +96,151 @@ function nodeIcon(status: NodeStatus): L.DivIcon {
});
}
// ── Aircraft icon — node-26#9 second-SDR ADS-B overlay ────────────────────────
function aircraftIcon(trackDeg: number | null): L.DivIcon {
const size = 16;
const rotation = trackDeg ?? 0;
// ── Aircraft — node-26#9 second-SDR ADS-B overlay ─────────────────────────────
// Styled after ADS-B Exchange / tar1090: a sized airliner silhouette with a
// dark outline, filled by altitude on tar1090's hue ramp, so height reads at a
// glance and the icon stands out from OSM's own (purple) airport symbols.
const ALT_HUE_STOPS: [number, number][] = [
[0, 20], [2000, 32.5], [4000, 43], [6000, 54], [8000, 72], [9000, 85], [11000, 140], [40000, 300],
];
function altitudeColor(altFt: number | null): string {
if (altFt == null) return "hsl(0, 0%, 55%)";
if (altFt <= 0) return "hsl(0, 0%, 45%)"; // on the ground
let hue = ALT_HUE_STOPS[ALT_HUE_STOPS.length - 1][1];
for (let i = 1; i < ALT_HUE_STOPS.length; i++) {
const [a1, h1] = ALT_HUE_STOPS[i];
if (altFt <= a1) {
const [a0, h0] = ALT_HUE_STOPS[i - 1];
hue = h0 + ((h1 - h0) * (altFt - a0)) / (a1 - a0);
break;
}
}
return `hsl(${hue.toFixed(0)}, 88%, 48%)`;
}
// Legend ticks for the altitude ramp, evenly spaced (the ramp itself isn't linear).
const ALTITUDE_LEGEND_TICKS: [number, string][] = [
[1000, "1k"], [4000, "4k"], [10000, "10k"], [20000, "20k"], [40000, "40k"],
];
const AIRLINER_PATH =
"M32 2 C34.2 2 35.2 5 35.2 8 L35.2 23 L61 37.5 L61 42 L35.2 35 L34.2 51 L42.5 57.5 L42.5 61 L32 58.5 " +
"L21.5 61 L21.5 57.5 L29.8 51 L28.8 35 L3 42 L3 37.5 L28.8 23 L28.8 8 C28.8 5 29.8 2 32 2 Z";
function aircraftIcon(trackDeg: number | null, altFt: number | null, selected: boolean): L.DivIcon {
const size = selected ? 36 : 30;
const outline = selected ? "#ffffff" : "#000000";
const shadow = selected ? "drop-shadow(0 0 3px #000)" : "drop-shadow(0 1px 1px rgba(0,0,0,.45))";
return L.divIcon({
className: "",
html: `<div style="width:${size}px;height:${size}px;transform:rotate(${rotation}deg)"><svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="var(--accent)" stroke="var(--surface)" stroke-width="1"><path d="M12 2 L15 11 L22 15 L15 15.5 L14 21 L17 22.5 L12 21.5 L7 22.5 L10 21 L9 15.5 L2 15 L9 11 Z"/></svg></div>`,
html:
`<div style="width:${size}px;height:${size}px;transform:rotate(${trackDeg ?? 0}deg);filter:${shadow}">` +
`<svg width="${size}" height="${size}" viewBox="0 0 64 64"><path d="${AIRLINER_PATH}" ` +
`fill="${altitudeColor(altFt)}" stroke="${outline}" stroke-width="${selected ? 3 : 2}" stroke-linejoin="round"/></svg></div>`,
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
});
}
function AircraftLayer() {
const { aircraft } = useAircraft();
function AircraftTrail({ icao, current }: { icao: string; current: AircraftTrack }) {
const trail = useAircraftTrail(icao);
// Extend to the live position so the path always meets the icon.
const points = [...trail];
if (current.lat != null && current.lon != null) {
points.push({ lat: current.lat, lon: current.lon, altitude_ft: current.altitude_ft, t: current.last_seen });
}
// One segment per leg, colored by altitude like tar1090's track.
return (
<>
{aircraft
.filter((a) => a.lat != null && a.lon != null)
.map((a) => (
<Marker key={a.icao} position={[a.lat as number, a.lon as number]} icon={aircraftIcon(a.track_deg)}>
<Popup minWidth={160}>
<div className="space-y-1">
<div className="font-semibold">{a.callsign || a.icao}</div>
<div className="text-xs text-ink-muted">ICAO {a.icao}</div>
{a.altitude_ft != null && <div className="text-xs">Altitude: {Math.round(a.altitude_ft)} ft</div>}
{a.ground_speed_kt != null && <div className="text-xs">Speed: {Math.round(a.ground_speed_kt)} kt</div>}
{points.slice(1).map((p, i) => (
<Polyline
key={`${icao}-${i}`}
positions={[[points[i].lat, points[i].lon], [p.lat, p.lon]]}
pathOptions={{ color: altitudeColor(p.altitude_ft), weight: 3, opacity: 0.9, lineCap: "round" }}
interactive={false}
/>
))}
</>
);
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<div>
<div className="text-[10px] uppercase tracking-wide text-ink-muted">{label}</div>
<div className="text-ink font-medium tabular-nums">{value}</div>
</div>
</Popup>
);
}
// Details dock at the right edge (bottom sheet on phones, above the incident
// drawer) instead of a popup over the plane, so the trail stays visible and
// the map can be panned to follow it.
function AircraftPanel({ a, onClose }: { a: AircraftTrack; onClose: () => void }) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
// The panel is portaled into the Leaflet container, whose native listeners
// would otherwise treat clicks/scrolls here as map clicks (deselect) or zoom.
if (!ref.current) return;
L.DomEvent.disableClickPropagation(ref.current);
L.DomEvent.disableScrollPropagation(ref.current);
}, []);
const fmt = (n: number | null, unit: string) => (n == null ? "—" : `${Math.round(n).toLocaleString()} ${unit}`);
return (
<div
ref={ref}
className="absolute left-3 right-3 bottom-12 md:left-auto md:bottom-auto md:top-[5.5rem] md:right-3 md:w-60 z-[1002] bg-surface/95 backdrop-blur-sm border border-line rounded-lg shadow-lg text-xs"
>
<div className="flex items-start justify-between gap-2 px-3 pt-2.5 pb-2 border-b border-line">
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<span className="inline-block w-2.5 h-2.5 rounded-full shrink-0" style={{ background: altitudeColor(a.altitude_ft) }} />
<span className="text-ink font-semibold text-sm truncate">{a.callsign || a.icao}</span>
</div>
<div className="text-ink-muted mt-0.5">ICAO {a.icao}</div>
</div>
<button onClick={onClose} aria-label="Close aircraft details" className="text-ink-muted hover:text-ink px-1 leading-none text-base">
×
</button>
</div>
<div className="grid grid-cols-2 gap-x-3 gap-y-2 px-3 py-2.5">
<Stat label="Altitude" value={fmt(a.altitude_ft, "ft")} />
<Stat label="Speed" value={fmt(a.ground_speed_kt, "kt")} />
<Stat label="Heading" value={a.track_deg == null ? "—" : `${Math.round(a.track_deg)}°`} />
<Stat label="Last heard" value={timeAgo(new Date(a.last_seen))} />
</div>
</div>
);
}
function AircraftLayer() {
const map = useMap();
const { aircraft } = useAircraft();
const [selected, setSelected] = useState<string | null>(null);
const positioned = aircraft.filter((a) => a.lat != null && a.lon != null);
const selectedTrack = positioned.find((a) => a.icao === selected);
// Clicking empty map deselects; marker clicks don't reach the map.
useMapEvents({ click: () => setSelected(null) });
return (
<>
{selectedTrack && <AircraftTrail icao={selectedTrack.icao} current={selectedTrack} />}
{selectedTrack &&
createPortal(<AircraftPanel a={selectedTrack} onClose={() => setSelected(null)} />, map.getContainer())}
{positioned.map((a) => (
<Marker
key={a.icao}
position={[a.lat as number, a.lon as number]}
icon={aircraftIcon(a.track_deg, a.altitude_ft, a.icao === selected)}
zIndexOffset={a.icao === selected ? 1000 : 0}
eventHandlers={{ click: () => setSelected((cur) => (cur === a.icao ? null : a.icao)) }}
>
<Tooltip direction="top" offset={[0, -14]}>
{a.callsign || a.icao}
{a.altitude_ft != null && ` · ${Math.round(a.altitude_ft).toLocaleString()} ft`}
</Tooltip>
</Marker>
))}
</>
@@ -127,12 +248,34 @@ function AircraftLayer() {
}
// ── Vessel icon — node-26#9 second-SDR AIS overlay ─────────────────────────────
// MMSI 99xxxxxxx is an aid to navigation (buoy, beacon, light), not a vessel —
// on the Hudson most of what a node hears is buoys (server-26#186).
function isAidToNavigation(mmsi: string): boolean {
return /^99\d{7}$/.test(mmsi);
}
// AIS reports heading 511 (and course 360) for "not available".
function aisHeading(deg: number | null): number | null {
return deg == null || deg >= 360 ? null : deg;
}
function vesselIcon(headingDeg: number | null): L.DivIcon {
const size = 14;
const rotation = headingDeg ?? 0;
const size = 22;
return L.divIcon({
className: "",
html: `<div style="width:${size}px;height:${size}px;transform:rotate(${rotation}deg)"><svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="var(--accent)" stroke="var(--surface)" stroke-width="1"><path d="M12 2 L18 14 L18 20 L6 20 L6 14 Z"/></svg></div>`,
html:
`<div style="width:${size}px;height:${size}px;transform:rotate(${aisHeading(headingDeg) ?? 0}deg);filter:drop-shadow(0 1px 1px rgba(0,0,0,.45))">` +
`<svg width="${size}" height="${size}" viewBox="0 0 24 24"><path d="M12 2 L18 12 L18 21 L6 21 L6 12 Z" fill="hsl(190, 80%, 42%)" stroke="#000" stroke-width="1.25" stroke-linejoin="round"/></svg></div>`,
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
});
}
function aidToNavigationIcon(): L.DivIcon {
const size = 12;
return L.divIcon({
className: "",
html: `<svg width="${size}" height="${size}" viewBox="0 0 12 12"><rect x="2" y="2" width="8" height="8" transform="rotate(45 6 6)" fill="hsl(50, 95%, 55%)" stroke="#000" stroke-width="1"/></svg>`,
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
});
@@ -144,17 +287,25 @@ function VesselLayer() {
<>
{vessels
.filter((v) => v.lat != null && v.lon != null)
.map((v) => (
<Marker key={v.mmsi} position={[v.lat as number, v.lon as number]} icon={vesselIcon(v.heading_deg)}>
.map((v) => {
const aton = isAidToNavigation(v.mmsi);
return (
<Marker
key={v.mmsi}
position={[v.lat as number, v.lon as number]}
icon={aton ? aidToNavigationIcon() : vesselIcon(v.heading_deg)}
zIndexOffset={aton ? -100 : 0}
>
<Popup minWidth={160}>
<div className="space-y-1">
<div className="font-semibold">{v.name || v.mmsi}</div>
<div className="font-semibold">{aton ? `Aid to navigation${v.name ? ` ${v.name}` : ""}` : v.name || v.mmsi}</div>
<div className="text-xs text-ink-muted">MMSI {v.mmsi}</div>
{v.speed_kt != null && <div className="text-xs">Speed: {Math.round(v.speed_kt)} kt</div>}
{!aton && v.speed_kt != null && <div className="text-xs">Speed: {Math.round(v.speed_kt)} kt</div>}
</div>
</Popup>
</Marker>
))}
);
})}
</>
);
}
@@ -528,6 +679,21 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
const [drawerOpen, setDrawerOpen] = useState(false);
const [agoClock, setAgoClock] = useState(0);
const [radarEpoch, setRadarEpoch] = useState(() => Date.now());
const [aircraftShown, setAircraftShown] = useState(false);
// The altitude key only belongs in the legend while the opt-in Aircraft
// overlay is actually on (server-26#185).
useEffect(() => {
if (!mapInstance) return;
const on = (e: L.LayersControlEvent) => e.name === "Aircraft" && setAircraftShown(true);
const off = (e: L.LayersControlEvent) => e.name === "Aircraft" && setAircraftShown(false);
mapInstance.on("overlayadd", on);
mapInstance.on("overlayremove", off);
return () => {
mapInstance.off("overlayadd", on);
mapInstance.off("overlayremove", off);
};
}, [mapInstance]);
useEffect(() => {
const id = setInterval(() => setAgoClock((t: number) => t + 1), 10_000);
@@ -718,6 +884,18 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
</div>
))}
</div>
{aircraftShown && (
<div className="border-t border-line pt-1.5 space-y-1">
<p className="text-ink-muted font-medium text-[10px] uppercase tracking-wide">Aircraft altitude</p>
<div
className="h-2 w-32 rounded-sm border border-line"
style={{ background: `linear-gradient(to right, ${ALTITUDE_LEGEND_TICKS.map(([ft], i) => `${altitudeColor(ft)} ${(i / (ALTITUDE_LEGEND_TICKS.length - 1)) * 100}%`).join(", ")})` }}
/>
<div className="flex justify-between w-32 text-[10px] text-ink-2 tabular-nums">
{ALTITUDE_LEGEND_TICKS.map(([ft, label]) => <span key={ft}>{label}</span>)}
</div>
</div>
)}
<div className="border-t border-line pt-1.5 space-y-1">
<p className="text-ink-muted font-medium text-[10px] uppercase tracking-wide">Nodes</p>
{([
@@ -0,0 +1,139 @@
"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 ?? [];
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);
}
}
const sdrCount = node.sdr_count ?? 1;
const spare = Math.max(sdrCount - 1, 0);
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.
{" "}This node reports {sdrCount} SDR{sdrCount === 1 ? "" : "s"} ({spare} spare).
</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" : 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>
);
}
+4 -1
View File
@@ -35,7 +35,10 @@ export const c2api = {
request(`/nodes/${nodeId}/override/ack`, { method: "POST", body: JSON.stringify({ timeout_minutes: timeoutMinutes }) }),
resetOverride: (nodeId: string) =>
request(`/nodes/${nodeId}/override/reset`, { method: "POST" }),
updateNode: (id: string, body: { node_type?: string; enforce_override_timeout?: boolean }) =>
updateNode: (
id: string,
body: { node_type?: string; enforce_override_timeout?: boolean; secondary_sdr_priority?: string[] },
) =>
request(`/nodes/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
// Systems
+13 -1
View File
@@ -53,7 +53,11 @@ export interface NodeRecord {
hardware_preset?: string;
ppm_override?: number | null;
node_type?: string;
secondary_sdr_mode?: string;
secondary_sdr_mode?: string; // legacy; priority[0] on current nodes
/** Ordered decoders for the SDRs beyond OP25's, run top-down (node-26#9). */
secondary_sdr_priority?: string[];
/** What the node's last checkin reported actually running. */
secondary_sdr_running?: string[] | null;
sdr_count?: number;
enforce_override_timeout?: boolean;
is_overridden?: boolean;
@@ -74,6 +78,14 @@ export interface AircraftTrack {
last_seen: string;
}
/** One point of an aircraft's flight path — aircraft/{icao}/positions. */
export interface AircraftTrailPoint {
lat: number;
lon: number;
altitude_ft: number | null;
t: string;
}
export interface VesselTrack {
mmsi: string;
org_id?: string;
+43
View File
@@ -0,0 +1,43 @@
"use client";
import { useEffect, useState } from "react";
import { collection, onSnapshot, orderBy, query, where, FirestoreError } from "firebase/firestore";
import { db } from "@/lib/firebase";
import type { AircraftTrailPoint } from "@/lib/types";
// Trail points live at aircraft/{icao}/positions (written by c2-core
// telemetry.py on every position change, TTL-deleted after ~24h). The same
// icao can fly several legs a day, so only the latest continuous stretch is
// "this flight": a gap longer than FLIGHT_GAP_MS starts a new one.
const LOOKBACK_MS = 6 * 60 * 60 * 1000;
const FLIGHT_GAP_MS = 20 * 60 * 1000;
function currentFlight(points: AircraftTrailPoint[]): AircraftTrailPoint[] {
let start = 0;
for (let i = 1; i < points.length; i++) {
if (new Date(points[i].t).getTime() - new Date(points[i - 1].t).getTime() > FLIGHT_GAP_MS) start = i;
}
return points.slice(start);
}
/** Live flight path for one aircraft; pass null to subscribe to nothing. */
export function useAircraftTrail(icao: string | null) {
const [trail, setTrail] = useState<AircraftTrailPoint[]>([]);
useEffect(() => {
setTrail([]);
if (!icao) return;
// `t` is Python's isoformat() in UTC ("...T17:06:48.755123+00:00"), so it
// sorts and range-filters correctly as a string against toISOString()'s
// "...T17:06:48.755Z" down to the second — no composite index needed.
const since = new Date(Date.now() - LOOKBACK_MS).toISOString();
const q = query(collection(db, "aircraft", icao, "positions"), where("t", ">=", since), orderBy("t"));
return onSnapshot(
q,
(snap) => setTrail(currentFlight(snap.docs.map((d) => d.data() as AircraftTrailPoint))),
(err: FirestoreError) => console.error("useAircraftTrail:", err),
);
}, [icao]);
return trail;
}
+9 -1
View File
@@ -77,5 +77,13 @@
]
}
],
"fieldOverrides": []
"fieldOverrides": [
{
"//": "TTL: flight-trail points (aircraft/{icao}/positions, server-26 telemetry.py) are deleted ~24h after expire_at. indexes: [] because nothing queries on expire_at.",
"collectionGroup": "positions",
"fieldPath": "expire_at",
"ttl": true,
"indexes": []
}
]
}
+13 -7
View File
@@ -8,13 +8,11 @@
// hand-set in the Firebase console: unversioned, unreviewed, unknown. See
// SAAS_PLAN.md B1.
//
// DEPLOY IS A MANUAL, OUT-OF-BAND STEP — nothing in CI or this codebase
// pushes these rules to Firebase:
// firebase deploy --only firestore:rules --project <project-id>
// (from this directory, or point --config at infra/firestore/firebase.json
// from the repo root). Do this before or immediately after the code that
// starts stamping org_id ships — until these rules are live, the
// console-configured rules are still what's actually enforced.
// DEPLOYED BY CI on every push to main (.gitea/workflows/deploy.yml, job
// deploy-firestore-rules, service-account auth via the FIREBASE_SA_KEY
// secret — server-26#51). That job is separate from the app deploy, so a
// green app deploy does NOT mean these rules are live: check that job too.
// Editing rules in the Firebase console is overwritten by the next push.
//
// MODEL: c2-core (firebase-admin SDK, server-side) bypasses these rules
// entirely and is the sole writer for every collection below — that was
@@ -100,6 +98,14 @@ service cloud.firestore {
match /aircraft/{icao} {
allow read: if docInMyOrg();
allow write: if false;
// Flight trail points. Org is checked against the PARENT aircraft doc
// (one get() per query) so the map can query a trail by time alone,
// without an org_id filter and the composite index that would need.
match /positions/{pointId} {
allow read: if inOrg(get(/databases/$(database)/documents/aircraft/$(icao)).data.org_id);
allow write: if false;
}
}
match /vessels/{mmsi} {