Merge feat/adsb-trails-icons: altitude-colored aircraft icons + flight trails
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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)}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -9,13 +9,15 @@ import {
|
|||||||
Polyline,
|
Polyline,
|
||||||
Popup,
|
Popup,
|
||||||
TileLayer,
|
TileLayer,
|
||||||
|
Tooltip,
|
||||||
useMap,
|
useMap,
|
||||||
} 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,32 +94,109 @@ 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%)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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]]}
|
||||||
|
pathOptions={{ color: altitudeColor(p.altitude_ft), weight: 3, opacity: 0.9, lineCap: "round" }}
|
||||||
|
interactive={false}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AircraftLayer() {
|
||||||
|
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);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{selectedTrack && <AircraftTrail icao={selectedTrack.icao} current={selectedTrack} />}
|
||||||
|
{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(a.icao),
|
||||||
|
popupclose: () => setSelected((cur) => (cur === a.icao ? null : cur)),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tooltip direction="top" offset={[0, -14]}>
|
||||||
|
{a.callsign || a.icao}
|
||||||
|
{a.altitude_ft != null && ` · ${Math.round(a.altitude_ft).toLocaleString()} ft`}
|
||||||
|
</Tooltip>
|
||||||
<Popup minWidth={160}>
|
<Popup minWidth={160}>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="font-semibold">{a.callsign || a.icao}</div>
|
<div className="font-semibold">{a.callsign || a.icao}</div>
|
||||||
<div className="text-xs text-ink-muted">ICAO {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.altitude_ft != null && (
|
||||||
|
<div className="text-xs">
|
||||||
|
<span
|
||||||
|
className="inline-block w-2 h-2 rounded-full mr-1 align-middle"
|
||||||
|
style={{ background: altitudeColor(a.altitude_ft) }}
|
||||||
|
/>
|
||||||
|
Altitude: {Math.round(a.altitude_ft).toLocaleString()} ft
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{a.ground_speed_kt != null && <div className="text-xs">Speed: {Math.round(a.ground_speed_kt)} kt</div>}
|
{a.ground_speed_kt != null && <div className="text-xs">Speed: {Math.round(a.ground_speed_kt)} kt</div>}
|
||||||
|
{a.track_deg != null && <div className="text-xs">Heading: {Math.round(a.track_deg)}°</div>}
|
||||||
</div>
|
</div>
|
||||||
</Popup>
|
</Popup>
|
||||||
</Marker>
|
</Marker>
|
||||||
|
|||||||
@@ -74,6 +74,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": []
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,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