Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cbb0828ae | ||
|
|
fa207e494d | ||
|
|
b20196c499 | ||
|
|
e5fc6ca838 | ||
|
|
5d97058f9d | ||
|
|
bff69a1d04 | ||
|
|
9b83f0ec6d | ||
|
|
5845fc5694 | ||
|
|
ddf13402d0 | ||
|
|
20c5799a8d | ||
|
|
e90a73ff09 |
+15
-13
@@ -307,28 +307,30 @@ jobs:
|
|||||||
|
|
||||||
- name: Deploy firestore rules and indexes
|
- name: Deploy firestore rules and indexes
|
||||||
env:
|
env:
|
||||||
FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }}
|
FIREBASE_SA_KEY: ${{ secrets.FIREBASE_SA_KEY }}
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
# server-26#51: this used to run over SSH on the deploy VM, gated
|
# 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
|
# on the VM having firebase-tools installed. It never did, so it
|
||||||
# silently warned-and-skipped on every single deploy for weeks.
|
# silently warned-and-skipped on every single deploy for weeks.
|
||||||
# Running it here instead means the only prerequisite is a secret
|
# Auth is a dedicated service account (drb-ci-firestore-deploy,
|
||||||
# -- FIREBASE_TOKEN, from `firebase login:ci` -- rather than
|
# roles: Firebase Rules Admin, Cloud Datastore Index Admin,
|
||||||
# something installed by hand on a machine this pipeline doesn't
|
# Service Usage Consumer), its JSON key stored as the
|
||||||
# otherwise touch. A missing token now fails this job LOUDLY
|
# FIREBASE_SA_KEY secret. Not `firebase login:ci`: those tokens are
|
||||||
# (picked up by notify-failure) instead of a buried warning line
|
# deprecated and carry the full permissions of whoever minted them.
|
||||||
# nobody reads in the app deploy's logs.
|
# A missing key fails this job LOUDLY (picked up by notify-failure).
|
||||||
if [ -z "$FIREBASE_TOKEN" ]; then
|
if [ -z "$FIREBASE_SA_KEY" ]; then
|
||||||
echo "FIREBASE_TOKEN secret is not set -- cannot deploy Firestore rules/indexes." >&2
|
echo "FIREBASE_SA_KEY 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
|
echo "Add the drb-ci-firestore-deploy service account's JSON key as a Gitea Actions secret." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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
|
npm install -g firebase-tools
|
||||||
cd infra/firestore
|
cd infra/firestore
|
||||||
firebase deploy --only firestore:rules,firestore:indexes \
|
firebase deploy --only firestore:rules,firestore:indexes \
|
||||||
--project ${{ secrets.FIREBASE_PROJECT_ID }} \
|
--project ${{ secrets.FIREBASE_PROJECT_ID }} --non-interactive
|
||||||
--token "$FIREBASE_TOKEN" --non-interactive
|
|
||||||
|
|
||||||
notify-failure:
|
notify-failure:
|
||||||
name: Report a failed deploy
|
name: Report a failed deploy
|
||||||
@@ -371,7 +373,7 @@ jobs:
|
|||||||
# failed before any deploy was attempted" text even when the app
|
# failed before any deploy was attempted" text even when the app
|
||||||
# deployed fine and only the Firestore rules/indexes push failed.
|
# deployed fine and only the Firestore rules/indexes push failed.
|
||||||
if deploy_result != "failure" and rules_result == "failure":
|
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
|
# server-26#65: the old text here unconditionally claimed
|
||||||
# "production is still running the previous build" -- true only
|
# "production is still running the previous build" -- true only
|
||||||
|
|||||||
@@ -343,9 +343,21 @@ def clean_location(value) -> Optional[str]:
|
|||||||
s = str(value).strip()
|
s = str(value).strip()
|
||||||
if not s or not _LOCATION_WORD_RE.search(s):
|
if not s or not _LOCATION_WORD_RE.search(s):
|
||||||
return None
|
return None
|
||||||
|
if _RADIO_CODE_RE.match(s):
|
||||||
|
return None
|
||||||
return s
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
# Status/disposition codes the extractor sometimes returns as a location:
|
||||||
|
# "96 times 5" (a disposition code read aloud) titled a 09-22 replay stop
|
||||||
|
# "Traffic Stop at 96 times 5" (server-26#170); "10-8", "signal 99", "code 4"
|
||||||
|
# are the same shape.
|
||||||
|
_RADIO_CODE_RE = re.compile(
|
||||||
|
r"^\s*(?:\d{1,3}\s*(?:times|x)\s*\d{1,3}|10[\s-]?\d{1,3}|(?:signal|code|condition)\s+\d{1,3})\s*$",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def location_is_unit(location, units) -> bool:
|
def location_is_unit(location, units) -> bool:
|
||||||
"""
|
"""
|
||||||
True when a location label is really one of the incident's own unit
|
True when a location label is really one of the incident's own unit
|
||||||
|
|||||||
@@ -112,6 +112,8 @@ class MQTTHandler:
|
|||||||
"approval_status": "pending",
|
"approval_status": "pending",
|
||||||
"node_type": payload.get("node_type", "fixed"),
|
"node_type": payload.get("node_type", "fixed"),
|
||||||
"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_running": payload.get("secondary_sdr_running"),
|
||||||
"sdr_count": payload.get("sdr_count", 1),
|
"sdr_count": payload.get("sdr_count", 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,
|
||||||
@@ -143,8 +145,9 @@ class MQTTHandler:
|
|||||||
updates["node_type"] = node_type
|
updates["node_type"] = node_type
|
||||||
updates["enforce_override_timeout"] = enforce_timeout
|
updates["enforce_override_timeout"] = enforce_timeout
|
||||||
|
|
||||||
if "secondary_sdr_mode" in payload:
|
for key in ("secondary_sdr_mode", "secondary_sdr_priority", "secondary_sdr_running"):
|
||||||
updates["secondary_sdr_mode"] = payload["secondary_sdr_mode"]
|
if key in payload:
|
||||||
|
updates[key] = payload[key]
|
||||||
if "sdr_count" in payload:
|
if "sdr_count" in payload:
|
||||||
updates["sdr_count"] = payload["sdr_count"]
|
updates["sdr_count"] = payload["sdr_count"]
|
||||||
|
|
||||||
|
|||||||
@@ -62,7 +62,12 @@ class NodeRecord(BaseModel):
|
|||||||
last_seen: Optional[datetime] = None
|
last_seen: Optional[datetime] = None
|
||||||
assigned_system_id: Optional[str] = None
|
assigned_system_id: Optional[str] = None
|
||||||
node_type: str = "fixed" # fixed or portable
|
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
|
sdr_count: int = 1 # self-reported by the node's checkin, best-effort
|
||||||
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 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
|
||||||
@@ -192,10 +192,15 @@ async def assign_system(
|
|||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
SECONDARY_SDR_MODES = ("adsb", "ais")
|
||||||
|
|
||||||
|
|
||||||
class NodeUpdateBody(BaseModel):
|
class NodeUpdateBody(BaseModel):
|
||||||
node_type: Optional[str] = None
|
node_type: Optional[str] = None
|
||||||
enforce_override_timeout: Optional[bool] = 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}")
|
@router.patch("/{node_id}")
|
||||||
@@ -212,8 +217,23 @@ async def update_node(
|
|||||||
if not updates:
|
if not updates:
|
||||||
return {"ok": True}
|
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)
|
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
|
# Re-push config to apply new node settings locally
|
||||||
updated_node = await fstore.doc_get("nodes", node_id)
|
updated_node = await fstore.doc_get("nodes", node_id)
|
||||||
assigned_system_id = updated_node.get("assigned_system_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:
|
if updated_node.get("ppm_override") is not None:
|
||||||
push_payload["ppm_override"] = updated_node["ppm_override"]
|
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"]
|
push_payload["secondary_sdr_mode"] = updated_node["secondary_sdr_mode"]
|
||||||
mqtt_handler.push_config(node_id, push_payload)
|
mqtt_handler.push_config(node_id, push_payload)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from datetime import datetime, timezone
|
import asyncio
|
||||||
from typing import List, Optional
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Dict, List, Optional, Tuple
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
@@ -10,6 +11,20 @@ from app.internal.logger import logger
|
|||||||
|
|
||||||
router = APIRouter(prefix="/telemetry", tags=["telemetry"])
|
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):
|
class AircraftReport(BaseModel):
|
||||||
icao: str
|
icao: str
|
||||||
@@ -32,8 +47,8 @@ async def upload_adsb(
|
|||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Node-initiated: a second-SDR ADS-B decoder (node-26#9) periodically posts
|
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 —
|
its current aircraft snapshot here. One doc per icao, last-seen-wins,
|
||||||
this is a live-map overlay, not a flight history.
|
plus one trail point per position change (see POSITIONS_SUBCOLLECTION).
|
||||||
"""
|
"""
|
||||||
node_id = decoded.get("node_id")
|
node_id = decoded.get("node_id")
|
||||||
if not node_id:
|
if not node_id:
|
||||||
@@ -43,7 +58,11 @@ async def upload_adsb(
|
|||||||
org_id = node.get("org_id") if node else None
|
org_id = node.get("org_id") if node else None
|
||||||
now = datetime.now(timezone.utc).isoformat()
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
expire_at = datetime.now(timezone.utc) + POSITION_TTL
|
||||||
|
epoch_ms = int(datetime.now(timezone.utc).timestamp() * 1000)
|
||||||
|
|
||||||
writes = []
|
writes = []
|
||||||
|
trail = []
|
||||||
for ac in body.aircraft:
|
for ac in body.aircraft:
|
||||||
if not ac.icao:
|
if not ac.icao:
|
||||||
continue
|
continue
|
||||||
@@ -62,12 +81,33 @@ async def upload_adsb(
|
|||||||
doc["org_id"] = org_id
|
doc["org_id"] = org_id
|
||||||
writes.append(("aircraft", ac.icao, doc))
|
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:
|
try:
|
||||||
await fstore.doc_set(collection, doc_id, doc, merge=True)
|
await fstore.doc_set(collection, doc_id, doc, merge=True)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to upsert {collection}/{doc_id} from node {node_id}: {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)}
|
return {"ok": True, "count": len(writes)}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -154,3 +154,10 @@ def test_plate_read_on_a_patrol_channel_is_a_stop():
|
|||||||
# not on rail/bridge channels, and not without digits
|
# not on rail/bridge channels, and not without digits
|
||||||
assert b("Frank David Boy 4514", [], None, "routine", "MTA Bridges and Tunnels - Whitestone") == ([], None, "routine")
|
assert b("Frank David Boy 4514", [], None, "routine", "MTA Bridges and Tunnels - Whitestone") == ([], None, "routine")
|
||||||
assert b("Charlie, David, go ahead.", [], None, "routine", ch) == ([], None, "routine")
|
assert b("Charlie, David, go ahead.", [], None, "routine", ch) == ([], None, "routine")
|
||||||
|
|
||||||
|
|
||||||
|
def test_radio_codes_are_not_locations():
|
||||||
|
for junk in ("96 times 5", "96 x 1", "10-8", "Signal 99", "code 4"):
|
||||||
|
assert ic.clean_location(junk) is None, junk
|
||||||
|
for place in ("West Main Street", "Route 9", "96 Main Street", "Exit 17 southbound"):
|
||||||
|
assert ic.clean_location(place) == place, place
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -24,6 +24,11 @@ def _override(decoded: dict):
|
|||||||
|
|
||||||
def teardown_function():
|
def teardown_function():
|
||||||
app.dependency_overrides.pop(require_node_service_or_firebase_token, None)
|
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():
|
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.status_code == 200
|
||||||
assert resp.json() == {"ok": True, "count": 1}
|
assert resp.json() == {"ok": True, "count": 1}
|
||||||
mock_set.assert_awaited_once()
|
snapshot = [c for c in mock_set.await_args_list if c.args[0] == "aircraft"]
|
||||||
(collection, doc_id, doc), kwargs = mock_set.await_args
|
assert len(snapshot) == 1
|
||||||
|
(collection, doc_id, doc), kwargs = snapshot[0]
|
||||||
assert collection == "aircraft"
|
assert collection == "aircraft"
|
||||||
assert doc_id == "A1B2C3"
|
assert doc_id == "A1B2C3"
|
||||||
assert doc["node_id"] == "node-1"
|
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.status_code == 200
|
||||||
assert resp.json() == {"ok": True, "count": 0}
|
assert resp.json() == {"ok": True, "count": 0}
|
||||||
mock_set.assert_not_awaited()
|
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
|
||||||
|
|||||||
@@ -8,6 +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 { 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";
|
||||||
@@ -336,6 +337,8 @@ export default function NodeDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<SecondarySdrPriority node={node} canEdit={isAdmin} />
|
||||||
|
|
||||||
{/* Recent calls */}
|
{/* Recent calls */}
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Recent Calls</h2>
|
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Recent Calls</h2>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
import {
|
import {
|
||||||
FeatureGroup,
|
FeatureGroup,
|
||||||
LayersControl,
|
LayersControl,
|
||||||
@@ -9,13 +10,16 @@ import {
|
|||||||
Polyline,
|
Polyline,
|
||||||
Popup,
|
Popup,
|
||||||
TileLayer,
|
TileLayer,
|
||||||
|
Tooltip,
|
||||||
useMap,
|
useMap,
|
||||||
|
useMapEvents,
|
||||||
} from "react-leaflet";
|
} from "react-leaflet";
|
||||||
import L from "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 { isKnownSeverity, SEVERITY_COLORS, SEVERITY_LABEL, type Severity } from "@/lib/severity";
|
||||||
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
||||||
import { useAircraft } from "@/lib/useAircraft";
|
import { useAircraft } from "@/lib/useAircraft";
|
||||||
|
import { useAircraftTrail } from "@/lib/useAircraftTrail";
|
||||||
import { useVessels } from "@/lib/useVessels";
|
import { useVessels } from "@/lib/useVessels";
|
||||||
|
|
||||||
// ── Leaflet icon fix ──────────────────────────────────────────────────────────
|
// ── Leaflet icon fix ──────────────────────────────────────────────────────────
|
||||||
@@ -92,34 +96,151 @@ function nodeIcon(status: NodeStatus): L.DivIcon {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Aircraft icon — node-26#9 second-SDR ADS-B overlay ────────────────────────
|
// ── Aircraft — node-26#9 second-SDR ADS-B overlay ─────────────────────────────
|
||||||
function aircraftIcon(trackDeg: number | null): L.DivIcon {
|
// Styled after ADS-B Exchange / tar1090: a sized airliner silhouette with a
|
||||||
const size = 16;
|
// dark outline, filled by altitude on tar1090's hue ramp, so height reads at a
|
||||||
const rotation = trackDeg ?? 0;
|
// 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({
|
return L.divIcon({
|
||||||
className: "",
|
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],
|
iconSize: [size, size],
|
||||||
iconAnchor: [size / 2, size / 2],
|
iconAnchor: [size / 2, size / 2],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function AircraftLayer() {
|
function AircraftTrail({ icao, current }: { icao: string; current: AircraftTrack }) {
|
||||||
const { aircraft } = useAircraft();
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{aircraft
|
{points.slice(1).map((p, i) => (
|
||||||
.filter((a) => a.lat != null && a.lon != null)
|
<Polyline
|
||||||
.map((a) => (
|
key={`${icao}-${i}`}
|
||||||
<Marker key={a.icao} position={[a.lat as number, a.lon as number]} icon={aircraftIcon(a.track_deg)}>
|
positions={[[points[i].lat, points[i].lon], [p.lat, p.lon]]}
|
||||||
<Popup minWidth={160}>
|
pathOptions={{ color: altitudeColor(p.altitude_ft), weight: 3, opacity: 0.9, lineCap: "round" }}
|
||||||
<div className="space-y-1">
|
interactive={false}
|
||||||
<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>}
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
</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>
|
</Marker>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
@@ -127,12 +248,34 @@ function AircraftLayer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Vessel icon — node-26#9 second-SDR AIS overlay ─────────────────────────────
|
// ── 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 {
|
function vesselIcon(headingDeg: number | null): L.DivIcon {
|
||||||
const size = 14;
|
const size = 22;
|
||||||
const rotation = headingDeg ?? 0;
|
|
||||||
return L.divIcon({
|
return L.divIcon({
|
||||||
className: "",
|
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],
|
iconSize: [size, size],
|
||||||
iconAnchor: [size / 2, size / 2],
|
iconAnchor: [size / 2, size / 2],
|
||||||
});
|
});
|
||||||
@@ -144,17 +287,25 @@ function VesselLayer() {
|
|||||||
<>
|
<>
|
||||||
{vessels
|
{vessels
|
||||||
.filter((v) => v.lat != null && v.lon != null)
|
.filter((v) => v.lat != null && v.lon != null)
|
||||||
.map((v) => (
|
.map((v) => {
|
||||||
<Marker key={v.mmsi} position={[v.lat as number, v.lon as number]} icon={vesselIcon(v.heading_deg)}>
|
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}>
|
<Popup minWidth={160}>
|
||||||
<div className="space-y-1">
|
<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>
|
<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>
|
</div>
|
||||||
</Popup>
|
</Popup>
|
||||||
</Marker>
|
</Marker>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -528,6 +679,21 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
|
|||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
const [agoClock, setAgoClock] = useState(0);
|
const [agoClock, setAgoClock] = useState(0);
|
||||||
const [radarEpoch, setRadarEpoch] = useState(() => Date.now());
|
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(() => {
|
useEffect(() => {
|
||||||
const id = setInterval(() => setAgoClock((t: number) => t + 1), 10_000);
|
const id = setInterval(() => setAgoClock((t: number) => t + 1), 10_000);
|
||||||
@@ -718,6 +884,18 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</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">
|
<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>
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -35,7 +35,10 @@ export const c2api = {
|
|||||||
request(`/nodes/${nodeId}/override/ack`, { method: "POST", body: JSON.stringify({ timeout_minutes: timeoutMinutes }) }),
|
request(`/nodes/${nodeId}/override/ack`, { method: "POST", body: JSON.stringify({ timeout_minutes: timeoutMinutes }) }),
|
||||||
resetOverride: (nodeId: string) =>
|
resetOverride: (nodeId: string) =>
|
||||||
request(`/nodes/${nodeId}/override/reset`, { method: "POST" }),
|
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) }),
|
request(`/nodes/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
|
||||||
|
|
||||||
// Systems
|
// Systems
|
||||||
|
|||||||
@@ -53,7 +53,11 @@ export interface NodeRecord {
|
|||||||
hardware_preset?: string;
|
hardware_preset?: string;
|
||||||
ppm_override?: number | null;
|
ppm_override?: number | null;
|
||||||
node_type?: string;
|
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;
|
sdr_count?: number;
|
||||||
enforce_override_timeout?: boolean;
|
enforce_override_timeout?: boolean;
|
||||||
is_overridden?: boolean;
|
is_overridden?: boolean;
|
||||||
@@ -74,6 +78,14 @@ export interface AircraftTrack {
|
|||||||
last_seen: string;
|
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 {
|
export interface VesselTrack {
|
||||||
mmsi: string;
|
mmsi: string;
|
||||||
org_id?: string;
|
org_id?: string;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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": []
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,13 +8,11 @@
|
|||||||
// hand-set in the Firebase console: unversioned, unreviewed, unknown. See
|
// hand-set in the Firebase console: unversioned, unreviewed, unknown. See
|
||||||
// SAAS_PLAN.md B1.
|
// SAAS_PLAN.md B1.
|
||||||
//
|
//
|
||||||
// DEPLOY IS A MANUAL, OUT-OF-BAND STEP — nothing in CI or this codebase
|
// DEPLOYED BY CI on every push to main (.gitea/workflows/deploy.yml, job
|
||||||
// pushes these rules to Firebase:
|
// deploy-firestore-rules, service-account auth via the FIREBASE_SA_KEY
|
||||||
// firebase deploy --only firestore:rules --project <project-id>
|
// secret — server-26#51). That job is separate from the app deploy, so a
|
||||||
// (from this directory, or point --config at infra/firestore/firebase.json
|
// green app deploy does NOT mean these rules are live: check that job too.
|
||||||
// from the repo root). Do this before or immediately after the code that
|
// Editing rules in the Firebase console is overwritten by the next push.
|
||||||
// starts stamping org_id ships — until these rules are live, the
|
|
||||||
// console-configured rules are still what's actually enforced.
|
|
||||||
//
|
//
|
||||||
// MODEL: c2-core (firebase-admin SDK, server-side) bypasses these rules
|
// MODEL: c2-core (firebase-admin SDK, server-side) bypasses these rules
|
||||||
// entirely and is the sole writer for every collection below — that was
|
// entirely and is the sole writer for every collection below — that was
|
||||||
@@ -100,6 +98,14 @@ service cloud.firestore {
|
|||||||
match /aircraft/{icao} {
|
match /aircraft/{icao} {
|
||||||
allow read: if docInMyOrg();
|
allow read: if docInMyOrg();
|
||||||
allow write: if false;
|
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} {
|
match /vessels/{mmsi} {
|
||||||
|
|||||||
Reference in New Issue
Block a user