Add ADS-B, AIS, and ALPR map layers with live CoT streaming (#36)
Push / release (push) Successful in 13s
Push / publish (push) Successful in 1m4s

## Summary

- **ADS-B & AIS:** OpenSky and AISStream OSINT feeds upsert into the CoT store; tactical tracks still arrive via adsbcot/aiscot on `:8089`. Map clients subscribe via `GET /api/cot/stream` (SSE) with viewport bbox filtering and Air / Surface / Team layer toggles.
- **ALPR (Flock/OSM):** Toggleable license-plate reader layer sourced from OpenStreetMap, with SQLite cache, Overpass fallback, tiled viewport fetching, and clustered markers with direction cones.
- **Map performance:** Ring-based tile selection (fixes zoom-out crash), immutable tile cache, incremental marker sync, split cluster load/query, and padded SSE bbox to reduce reconnect churn.

## Docs

- `docs/tracking.md` — ADS-B/AIS accuracy tiers, freshness, self-hosted receivers, optional OSINT API keys
- `docs/map-and-cameras.md` — ALPR layer and map behavior updates

---------

Co-authored-by: Madison Grubb <madison@elastiflow.com>
Reviewed-on: #36
This commit was merged in pull request #36.
This commit is contained in:
2026-06-24 20:54:50 +00:00
parent a6b87305a1
commit bb01e9a06c
64 changed files with 5762 additions and 2119 deletions
+1 -1
View File
@@ -41,7 +41,7 @@ jobs:
e2e:
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.59.1-noble
image: mcr.microsoft.com/playwright:v1.61.1-noble
steps:
- uses: https://git.keligrubb.com/actions/checkout@v7
+1 -1
View File
@@ -1 +1 @@
setups.@nuxt/test-utils="4.0.0"
setups.@nuxt/test-utils="4.0.3"
+2 -2
View File
@@ -56,7 +56,7 @@ See [docs/live-streaming.md](docs/live-streaming.md) for setup and usage.
### ATAK / CoT (Cursor on Target)
KestrelOS can act as a **TAK Server** so ATAK and iTAK devices connect and share positions. No plugins: in ATAK, add a **Server** connection (host = KestrelOS, port **8089** for CoT). Check **Use Authentication** and enter your **KestrelOS username** and **password** (local users use their login password; OIDC users must set an **ATAK password** once under **Account** in the web app). Devices relay CoT to each other (team members see each other on the ATAK map) and appear on the KestrelOS web map; they drop off after ~90 seconds if no updates. Optional: set `COT_TTL_MS`, `COT_REQUIRE_AUTH`; CoT runs on port 8089 (default).
KestrelOS can act as a **TAK Server** so ATAK and iTAK devices connect and share positions. No plugins: in ATAK, add a **Server** connection (host = KestrelOS, port **8089** for CoT). Check **Use Authentication** and enter your **KestrelOS username** and **password** (local users use their login password; OIDC users must set an **ATAK password** once under **Account** in the web app). Devices relay CoT to each other (team members see each other on the ATAK map) and appear on the KestrelOS web map; they drop off after ~90 seconds if no updates. CoT runs on port 8089 (default).
## Scripts
@@ -74,7 +74,7 @@ Full docs are in the **[docs/](docs/README.md)** directory: [installation](docs/
## Configuration
- **Devices**: Manage cameras/devices via the API (`/api/devices`); see [Map and cameras](docs/map-and-cameras.md). Each device needs `name`, `device_type`, `lat`, `lng`, `stream_url`, and `source_type` (`mjpeg` or `hls`).
- **Environment**: No required env vars for basic run. For production, set `HOST=0.0.0.0` and expose ports 3000 (web/API) and 8089 (CoT). Set `COT_TTL_MS=90000`, `COT_REQUIRE_AUTH=true`. For TLS use `.dev-certs/` or set `COT_SSL_CERT` and `COT_SSL_KEY`.
- **Environment**: No required env vars for basic run. For production, set `HOST=0.0.0.0` and expose ports 3000 (web/API) and 8089 (CoT). For TLS use `.dev-certs/` or set `COT_SSL_CERT` and `COT_SSL_KEY`.
- **Authentication**: The login page always offers password sign-in (local). Optionally set `BOOTSTRAP_EMAIL` and `BOOTSTRAP_PASSWORD` before the first run to create the first admin; otherwise a default admin is created and its credentials are printed in the terminal. To also show an OIDC sign-in button, configure `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, and optionally `OIDC_LABEL`, `OIDC_REDIRECT_URI`. See [docs/auth.md](docs/auth.md) for local login, OIDC config, and sign up.
- **Bootstrap admin** (when using local auth): The server initializes the database and runs bootstrap at startup. On first run (no users in the database), it creates the first admin. If you set `BOOTSTRAP_EMAIL` and `BOOTSTRAP_PASSWORD` before starting, that account is created. If you don't set them, a default admin is created (identifier: `admin`) with a random password and the credentials are printed in the terminal-copy them and sign in at `/login`, then change the password or add users via Members. Use **Members** to change roles (admin, leader, member). Only admins can change roles; admins and leaders can edit POIs.
- **Database**: SQLite file at `data/kestrelos.db` (created automatically). Contains users, sessions, and POIs.
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<NuxtLayout>
<NuxtPage :key="$route.path" />
<NuxtPage />
</NuxtLayout>
</template>
+38 -2
View File
@@ -16,6 +16,9 @@
.kestrel-btn-secondary { @apply rounded border border-kestrel-border px-4 py-2 text-sm text-kestrel-text transition-colors hover:bg-kestrel-border; }
.kestrel-context-menu-item { @apply block w-full px-3 py-1.5 text-left text-sm text-kestrel-text transition-colors hover:bg-kestrel-border; }
.kestrel-context-menu-item-danger { @apply block w-full px-3 py-1.5 text-left text-sm text-red-400 transition-colors hover:bg-kestrel-border; }
.kestrel-cot-layer-btn { @apply rounded px-1.5 py-0.5 text-kestrel-muted transition-colors hover:text-kestrel-text; }
.kestrel-cot-layer-btn-active { @apply bg-kestrel-border text-kestrel-accent; }
.cot-icon-rotatable { @apply inline-flex origin-center; }
.kestrel-panel-base { @apply flex flex-col border border-kestrel-border bg-kestrel-surface; }
.kestrel-panel-inline { @apply rounded-lg shadow-glow; }
.kestrel-panel-overlay { @apply absolute right-0 top-0 z-[1000] h-full w-full border-l shadow-glow md:w-[420px] shadow-glow-panel; }
@@ -84,12 +87,14 @@
}
.kestrel-map-container .leaflet-control-zoom,
.kestrel-map-container .leaflet-control-locate,
.kestrel-map-container .leaflet-control-alpr,
.kestrel-map-container .savetiles.leaflet-bar {
@apply rounded-md overflow-hidden font-mono border border-kestrel-glow shadow-glow-sm;
border-color: rgba(34, 201, 201, 0.35) !important;
}
.kestrel-map-container .leaflet-control-zoom a,
.kestrel-map-container .leaflet-control-locate,
.kestrel-map-container .leaflet-control-alpr,
.kestrel-map-container .savetiles.leaflet-bar a {
@apply w-8 h-8 leading-8 bg-kestrel-surface text-kestrel-text border-none rounded-none text-lg font-semibold no-underline transition-all duration-150;
width: 32px !important;
@@ -105,9 +110,14 @@
}
.kestrel-map-container .leaflet-control-zoom a:hover,
.kestrel-map-container .leaflet-control-locate:hover,
.kestrel-map-container .leaflet-control-alpr:hover,
.kestrel-map-container .savetiles.leaflet-bar a:hover {
@apply bg-kestrel-surface-hover text-kestrel-accent shadow-glow-md text-shadow-glow-md;
}
.kestrel-map-container .leaflet-control-alpr[aria-pressed="true"] {
color: #ef4444 !important;
box-shadow: 0 0 8px rgba(239, 68, 68, 0.45);
}
.kestrel-map-container .savetiles.leaflet-bar {
@apply flex flex-col;
}
@@ -119,12 +129,38 @@
padding: 6px 10px !important;
font-size: 11px !important;
}
.kestrel-map-container .leaflet-control-locate {
.kestrel-map-container .leaflet-control-locate,
.kestrel-map-container .leaflet-control-alpr {
@apply flex items-center justify-center p-0 cursor-pointer;
}
.kestrel-map-container .leaflet-control-locate svg {
.kestrel-map-container .leaflet-control-locate svg,
.kestrel-map-container .leaflet-control-alpr svg {
color: currentColor;
}
.kestrel-map-container .alpr-cone {
display: inline-flex;
transform-origin: center center;
}
.kestrel-map-container .alpr-cluster-icon {
background: transparent;
border: none;
}
.kestrel-map-container .alpr-cluster {
@apply flex items-center justify-center rounded-full bg-red-500/90 font-mono text-xs font-semibold text-white;
width: 100%;
height: 100%;
box-shadow: 0 0 8px rgba(239, 68, 68, 0.5);
}
.kestrel-map-container .cot-cluster-icon {
background: transparent;
border: none;
}
.kestrel-map-container .cot-cluster {
@apply flex items-center justify-center rounded-full bg-sky-500/90 font-mono text-xs font-semibold text-white;
width: 100%;
height: 100%;
box-shadow: 0 0 8px rgba(56, 189, 248, 0.5);
}
.kestrel-map-container .live-session-icon {
animation: live-pulse 1.5s ease-in-out infinite;
}
+6 -1
View File
@@ -26,6 +26,11 @@
:user="user"
@signout="onLogout"
/>
<span
v-else-if="authPending"
class="inline-block h-8 w-8"
aria-hidden="true"
/>
<NuxtLink
v-else
to="/login"
@@ -75,7 +80,7 @@ watch(sidebarCollapsed, (v) => {
}
})
const { user, refresh } = useUser()
const { user, authPending, refresh } = useUser()
watch(isMobile, (mobile) => {
if (mobile) drawerOpen.value = false
+135 -45
View File
@@ -47,11 +47,41 @@
@submit="onPoiSubmit"
@confirm-delete="confirmDeletePoi"
/>
<div
v-if="mapContext"
class="pointer-events-auto absolute right-3 top-3 z-[1000] flex gap-0.5 rounded border border-kestrel-border bg-kestrel-surface/95 p-0.5 text-xs shadow-glow"
data-testid="cot-layer-toggles"
>
<button
v-for="layer in COT_LAYERS"
:key="layer.key"
type="button"
class="kestrel-cot-layer-btn"
:class="{ 'kestrel-cot-layer-btn-active': cotLayers[layer.key] }"
@click="emit('toggleCotLayer', layer.key)"
>
{{ layer.label }}
</button>
</div>
</div>
</template>
<script setup>
import 'leaflet/dist/leaflet.css'
import {
createAlprControl,
createAlprLayer,
setAlprControlPressed,
syncAlprLayer,
} from '~/utils/alprMapLayer.js'
import {
createCotLayer,
getCotClusters,
loadCotCluster,
syncCotLayer,
} from '~/utils/cotMapLayer.js'
import { clearFeatureMarkers } from '~/utils/mapMarkerSync.js'
const props = defineProps({
devices: {
@@ -70,13 +100,25 @@ const props = defineProps({
type: Array,
default: () => [],
},
cotLayers: {
type: Object,
default: () => ({ air: true, surface: true, ground: true }),
},
alprMarkers: {
type: Array,
default: () => [],
},
showAlpr: {
type: Boolean,
default: false,
},
canEditPois: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['select', 'selectLive', 'refreshPois'])
const emit = defineEmits(['select', 'selectLive', 'refreshPois', 'boundsChange', 'toggleAlpr', 'toggleCotLayer'])
const CONTEXT_MENU_EMPTY = Object.freeze({ type: null, poi: null, latlng: null, x: 0, y: 0 })
const mapRef = ref(null)
const contextMenuRef = ref(null)
@@ -85,7 +127,9 @@ const mapContext = ref(null)
const markersRef = ref([])
const poiMarkersRef = ref({})
const liveMarkersRef = ref({})
const cotMarkersRef = ref({})
const cotLayerRef = ref(null)
const cotMapView = ref(null)
const alprLayerRef = ref(null)
const contextMenu = ref({ ...CONTEXT_MENU_EMPTY })
const showPoiModal = ref(false)
@@ -104,6 +148,11 @@ const DEFAULT_ZOOM = 17
const MARKER_ICON_PATH = '/'
const POI_TOOLTIP_CLASS = 'kestrel-poi-tooltip'
const POI_ICON_COLORS = { pin: '#22c9c9', flag: '#e53e3e', waypoint: '#a78bfa' }
const COT_LAYERS = Object.freeze([
{ key: 'air', label: 'Air' },
{ key: 'surface', label: 'Surface' },
{ key: 'ground', label: 'Team' },
])
const ICON_SIZE = 28
@@ -141,14 +190,41 @@ function getLiveSessionIcon(L) {
})
}
const COT_ICON_COLOR = '#f59e0b' /* amber - ATAK/CoT devices */
function getCotEntityIcon(L) {
const html = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${COT_ICON_COLOR}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="8" r="2.5" fill="${COT_ICON_COLOR}"/></svg>`
return L.divIcon({
className: 'poi-div-icon cot-entity-icon',
html: `<span class="poi-icon-svg">${html}</span>`,
iconSize: [ICON_SIZE, ICON_SIZE],
iconAnchor: [ICON_SIZE / 2, ICON_SIZE],
function refreshCotMapView(map) {
const bounds = map.getBounds()
cotMapView.value = {
south: bounds.getSouth(),
west: bounds.getWest(),
north: bounds.getNorth(),
east: bounds.getEast(),
zoom: map.getZoom(),
}
}
function renderCotLayer() {
const ctx = mapContext.value
const { L } = leafletRef.value || {}
const layer = cotLayerRef.value
if (!ctx?.map || !L || !layer) return
const view = cotMapView.value
const features = view ? getCotClusters(view) : []
syncCotLayer(L, ctx.map, layer, features)
}
function reloadCotCluster() {
loadCotCluster(props.cotEntities || [])
renderCotLayer()
}
function emitBounds(map) {
refreshCotMapView(map)
const bounds = map.getBounds()
emit('boundsChange', {
south: bounds.getSouth(),
west: bounds.getWest(),
north: bounds.getNorth(),
east: bounds.getEast(),
zoom: map.getZoom(),
})
}
@@ -160,7 +236,7 @@ function createMap(initialCenter) {
? initialCenter
: DEFAULT_VIEW
const map = L.map(mapRef.value, { zoomControl: false, attributionControl: false }).setView(center, DEFAULT_ZOOM)
const map = L.map(mapRef.value, { zoomControl: false, attributionControl: false, minZoom: 1, maxZoom: 19 }).setView(center, DEFAULT_ZOOM)
L.control.zoom({ position: 'topleft' }).addTo(map)
const locateControl = L.control({ position: 'topleft' })
@@ -186,6 +262,14 @@ function createMap(initialCenter) {
}
locateControl.addTo(map)
const alprControl = createAlprControl(L, {
showAlpr: props.showAlpr,
onToggle: () => emit('toggleAlpr'),
})
alprControl.addTo(map)
const alprLayer = createAlprLayer(L, map)
const cotLayer = createCotLayer(L, map)
const baseLayer = L.tileLayer(TILE_URL, {
attribution: ATTRIBUTION,
subdomains: TILE_SUBDOMAINS,
@@ -214,12 +298,28 @@ function createMap(initialCenter) {
contextMenu.value = { type: 'map', latlng: e.latlng, x: pt.x, y: pt.y }
})
mapContext.value = { map, layer: baseLayer, control, locateControl }
map.on('moveend', () => {
emitBounds(map)
renderCotLayer()
})
map.on('zoomend', () => {
emitBounds(map)
renderCotLayer()
})
mapContext.value = { map, layer: baseLayer, control, locateControl, alprControl }
alprLayerRef.value = alprLayer
cotLayerRef.value = cotLayer
refreshCotMapView(map)
updateMarkers()
updatePoiMarkers()
updateLiveMarkers()
updateCotMarkers()
nextTick(() => map.invalidateSize())
reloadCotCluster()
updateAlprLayer()
nextTick(() => {
map.invalidateSize()
emitBounds(map)
})
}
function updateMarkers() {
@@ -309,37 +409,16 @@ function updateLiveMarkers() {
liveMarkersRef.value = next
}
function updateCotMarkers() {
function updateAlprLayer() {
const ctx = mapContext.value
const { L } = leafletRef.value || {}
if (!ctx?.map || !L) return
const entities = (props.cotEntities || []).filter(
e => typeof e?.lat === 'number' && typeof e?.lng === 'number' && e?.id,
)
const byId = Object.fromEntries(entities.map(e => [e.id, e]))
const prev = cotMarkersRef.value
const icon = getCotEntityIcon(L)
Object.keys(prev).forEach((id) => {
if (!byId[id]) prev[id]?.remove()
})
const next = entities.reduce((acc, entity) => {
const content = `<div class="kestrel-live-popup"><strong>${escapeHtml(entity.label || entity.id)}</strong> <span class="text-kestrel-muted">ATAK</span></div>`
const existing = prev[entity.id]
if (existing) {
existing.setLatLng([entity.lat, entity.lng])
existing.setIcon(icon)
existing.getPopup()?.setContent(content)
return { ...acc, [entity.id]: existing }
const layer = alprLayerRef.value
if (!ctx?.map || !L || !layer) return
if (!props.showAlpr) {
clearFeatureMarkers(layer)
return
}
const marker = L.marker([entity.lat, entity.lng], { icon })
.addTo(ctx.map)
.bindPopup(content, { className: 'kestrel-live-popup-wrap', maxWidth: 360 })
return { ...acc, [entity.id]: marker }
}, {})
cotMarkersRef.value = next
syncAlprLayer(L, ctx.map, layer, props.alprMarkers || [])
}
function escapeHtml(text) {
@@ -427,17 +506,22 @@ function destroyMap() {
poiMarkersRef.value = {}
Object.values(liveMarkersRef.value).forEach(m => m?.remove())
liveMarkersRef.value = {}
Object.values(cotMarkersRef.value).forEach(m => m?.remove())
cotMarkersRef.value = {}
clearFeatureMarkers(cotLayerRef.value)
clearFeatureMarkers(alprLayerRef.value)
const ctx = mapContext.value
if (ctx) {
if (ctx.control && ctx.map) ctx.map.removeControl(ctx.control)
if (ctx.locateControl && ctx.map) ctx.map.removeControl(ctx.locateControl)
if (ctx.alprControl && ctx.map) ctx.map.removeControl(ctx.alprControl)
if (cotLayerRef.value && ctx.map) ctx.map.removeLayer(cotLayerRef.value)
if (alprLayerRef.value && ctx.map) ctx.map.removeLayer(alprLayerRef.value)
if (ctx.layer && ctx.map) ctx.map.removeLayer(ctx.layer)
if (ctx.map) ctx.map.remove()
mapContext.value = null
}
cotLayerRef.value = null
alprLayerRef.value = null
}
function initMapWithLocation() {
@@ -503,5 +587,11 @@ onBeforeUnmount(() => {
watch(() => props.devices, () => updateMarkers(), { deep: true })
watch([() => props.pois, () => props.canEditPois], () => updatePoiMarkers(), { deep: true })
watch(() => props.liveSessions, () => updateLiveMarkers(), { deep: true })
watch(() => props.cotEntities, () => updateCotMarkers(), { deep: true })
watch(() => props.cotEntities, () => reloadCotCluster())
watch(() => props.alprMarkers, () => updateAlprLayer())
watch(() => props.showAlpr, (enabled) => {
setAlprControlPressed(mapContext.value?.alprControl, enabled)
if (enabled && mapContext.value?.map) emitBounds(mapContext.value.map)
else updateAlprLayer()
})
</script>
+146
View File
@@ -0,0 +1,146 @@
import { createClusterIndex } from '~/utils/mapCluster.js'
import {
MAX_BBOX_DEGREES,
bboxFetchKey,
bboxToTileKey,
tileKey,
tilesNearCenter,
} from '~/utils/alprViewport.js'
const STORAGE_KEY = 'kestrelos:showAlprLayer'
const MIN_FETCH_ZOOM = 10
const MAX_TILE_FETCHES = 16
const MAX_CACHED_TILES = 64
const TILE_RETENTION = 3
const EMPTY_TILES = Object.freeze({})
const mergeFeatures = lists => Object.values(
lists.flat().reduce((acc, feature) => {
const id = feature.id ?? feature.properties?.osmId
return id == null ? acc : { ...acc, [id]: feature }
}, {}),
)
const offsets = radius => Array.from({ length: 2 * radius + 1 }, (_, i) => i - radius)
const retentionKeys = bounds => new Set(
tilesNearCenter(bounds, MAX_TILE_FETCHES).flatMap((tile) => {
const r0 = Math.floor(tile.south / MAX_BBOX_DEGREES)
const c0 = Math.floor(tile.west / MAX_BBOX_DEGREES)
return offsets(TILE_RETENTION).flatMap(dr =>
offsets(TILE_RETENTION).map(dc => tileKey(r0 + dr, c0 + dc)),
)
}),
)
const pruneTileCache = (cache, bounds) => {
const keep = retentionKeys(bounds)
const retained = Object.fromEntries(
Object.entries(cache).filter(([key]) => keep.has(key)),
)
const overflow = Object.keys(retained).length - MAX_CACHED_TILES
if (overflow <= 0) return Object.freeze(retained)
const cr = Math.floor((bounds.south + bounds.north) / 2 / MAX_BBOX_DEGREES)
const cc = Math.floor((bounds.west + bounds.east) / 2 / MAX_BBOX_DEGREES)
const drop = new Set(
Object.keys(retained)
.map((key) => {
const [r, c] = key.split(',').map(Number)
return { key, dist: Math.hypot(r - cr, c - cc) }
})
.sort((a, b) => b.dist - a.dist)
.slice(0, overflow)
.map(({ key }) => key),
)
return Object.freeze(
Object.fromEntries(Object.entries(retained).filter(([key]) => !drop.has(key))),
)
}
const cacheChanged = (before, after) => Object.keys(before).length !== Object.keys(after).length
export function useAlprCameras() {
const showAlpr = useState('showAlprLayer', () => {
if (!import.meta.client) return true
return localStorage.getItem(STORAGE_KEY) !== '0'
})
const view = ref(null)
const tiles = ref(EMPTY_TILES)
const cluster = createClusterIndex({ radius: 50, maxZoom: 17 })
const debounceTimer = ref(null)
const requestId = ref(0)
const lastFetchKey = ref('')
const applyTiles = (next) => {
tiles.value = next
cluster.load(mergeFeatures(Object.values(next)))
}
watch(showAlpr, (enabled) => {
if (import.meta.client) localStorage.setItem(STORAGE_KEY, enabled ? '1' : '0')
if (!enabled) {
applyTiles(EMPTY_TILES)
view.value = null
lastFetchKey.value = ''
}
})
const alprMarkers = computed(() => view.value ? cluster.query(view.value) : [])
const toggleAlpr = () => {
showAlpr.value = !showAlpr.value
}
const onBoundsChange = (bounds) => {
if (!showAlpr.value || !bounds) return
view.value = bounds
const pruned = pruneTileCache(tiles.value, bounds)
if (cacheChanged(tiles.value, pruned)) applyTiles(pruned)
if ((bounds.zoom ?? 14) < MIN_FETCH_ZOOM) return
const key = bboxFetchKey(bounds)
if (key === lastFetchKey.value) return
if (debounceTimer.value) clearTimeout(debounceTimer.value)
debounceTimer.value = setTimeout(() => {
lastFetchKey.value = key
fetchViewport(bounds)
}, 400)
}
const fetchViewport = async (bounds) => {
const id = requestId.value + 1
requestId.value = id
const missing = tilesNearCenter(bounds, MAX_TILE_FETCHES)
.filter(tile => !(bboxToTileKey(tile) in tiles.value))
try {
const fetched = missing.length
? await Promise.all(missing.map(async (tile) => {
const params = new URLSearchParams({
south: String(tile.south),
west: String(tile.west),
north: String(tile.north),
east: String(tile.east),
})
const data = await $fetch(`/api/alpr?${params}`).catch(() => null)
return [bboxToTileKey(tile), data?.features ?? []]
}))
: []
if (id !== requestId.value) return
const merged = Object.freeze({
...tiles.value,
...Object.fromEntries(fetched),
})
const pruned = pruneTileCache(merged, bounds)
applyTiles(pruned)
}
catch { /* keep last good index */ }
}
return Object.freeze({ showAlpr, toggleAlpr, alprMarkers, onBoundsChange })
}
+2 -3
View File
@@ -1,6 +1,6 @@
/** Fetches devices + live sessions; polls when tab visible. */
const POLL_MS = 1500
const EMPTY_RESPONSE = Object.freeze({ devices: [], liveSessions: [], cotEntities: [] })
const EMPTY_RESPONSE = Object.freeze({ devices: [], liveSessions: [] })
export function useCameras(options = {}) {
const { poll: enablePoll = true } = options
@@ -12,7 +12,6 @@ export function useCameras(options = {}) {
const devices = computed(() => Object.freeze([...(data.value?.devices ?? [])]))
const liveSessions = computed(() => Object.freeze([...(data.value?.liveSessions ?? [])]))
const cotEntities = computed(() => Object.freeze([...(data.value?.cotEntities ?? [])]))
const cameras = computed(() => Object.freeze([...devices.value, ...liveSessions.value]))
const pollInterval = ref(null)
@@ -37,5 +36,5 @@ export function useCameras(options = {}) {
})
onBeforeUnmount(stopPolling)
return Object.freeze({ data, devices, liveSessions, cotEntities, cameras, refresh, startPolling, stopPolling })
return Object.freeze({ data, devices, liveSessions, cameras, refresh, startPolling, stopPolling })
}
+48
View File
@@ -0,0 +1,48 @@
const STORAGE_KEY = 'kestrel-cot-layers'
const DEFAULT_LAYERS = Object.freeze({ air: true, surface: true, ground: true })
function loadLayers() {
if (typeof localStorage === 'undefined') return { ...DEFAULT_LAYERS }
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return { ...DEFAULT_LAYERS }
const parsed = JSON.parse(raw)
return {
air: parsed.air !== false,
surface: parsed.surface !== false,
ground: parsed.ground !== false,
}
}
catch {
return { ...DEFAULT_LAYERS }
}
}
function saveLayers(layers) {
if (typeof localStorage === 'undefined') return
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(layers))
}
catch { /* ignore quota */ }
}
export function useCotLayers() {
const layers = ref(loadLayers())
const layerQuery = computed(() => {
const parts = []
if (layers.value.air) parts.push('air')
if (layers.value.surface) parts.push('surface')
if (layers.value.ground) parts.push('ground')
return parts.length ? parts.join(',') : 'none'
})
function toggleLayer(name) {
if (!(name in DEFAULT_LAYERS)) return
layers.value = { ...layers.value, [name]: !layers.value[name] }
saveLayers(layers.value)
}
return Object.freeze({ layers, layerQuery, toggleLayer })
}
+117
View File
@@ -0,0 +1,117 @@
import { bboxFetchKey } from '~/utils/alprViewport.js'
const DEBOUNCE_MS = 300
const EMPTY_ENTITIES = Object.freeze({})
const expandBounds = (bounds, factor = 0.25) => {
const latPad = (bounds.north - bounds.south) * factor
const lngPad = (bounds.east - bounds.west) * factor
return {
south: Math.max(-90, bounds.south - latPad),
north: Math.min(90, bounds.north + latPad),
west: bounds.west - lngPad,
east: bounds.east + lngPad,
}
}
const entitiesFromList = list => Object.freeze(
Object.fromEntries(
(list ?? []).filter(entity => entity?.id).map(entity => [entity.id, entity]),
),
)
export function useCotStream(boundsRef, layerQueryRef) {
const entities = ref(EMPTY_ENTITIES)
const cotEntities = computed(() => Object.freeze(Object.values(entities.value)))
const eventSource = ref(null)
const debounceTimer = ref(null)
const subscribedKey = ref('')
const streamBounds = ref(null)
const setEntities = (record) => {
entities.value = Object.freeze(record)
}
const parseEvent = (e, fn) => {
try {
fn(JSON.parse(e.data))
}
catch { /* ignore */ }
}
const closeStream = () => {
eventSource.value?.close()
eventSource.value = null
}
const connect = () => {
if (typeof window === 'undefined' || typeof EventSource === 'undefined') return
const bounds = streamBounds.value ?? boundsRef.value
if (!bounds) return
closeStream()
const q = new URLSearchParams({
bbox: `${bounds.west},${bounds.south},${bounds.east},${bounds.north}`,
layers: unref(layerQueryRef) || 'air,surface,ground',
})
const es = new EventSource(`/api/cot/stream?${q}`)
eventSource.value = es
es.addEventListener('snapshot', e => parseEvent(e, ({ entities: list }) => {
setEntities(entitiesFromList(list))
}))
es.addEventListener('update', e => parseEvent(e, ({ entity }) => {
if (!entity?.id) return
setEntities({ ...entities.value, [entity.id]: entity })
}))
es.addEventListener('remove', e => parseEvent(e, ({ id }) => {
if (!id) return
setEntities(Object.fromEntries(
Object.entries(entities.value).filter(([key]) => key !== String(id)),
))
}))
es.onerror = () => {
closeStream()
scheduleConnect()
}
}
const scheduleConnect = () => {
if (debounceTimer.value) clearTimeout(debounceTimer.value)
debounceTimer.value = setTimeout(() => {
debounceTimer.value = null
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return
connect()
}, DEBOUNCE_MS)
}
const maybeReconnect = (bounds) => {
if (!bounds) return
const key = bboxFetchKey(bounds)
if (key === subscribedKey.value) return
subscribedKey.value = key
streamBounds.value = expandBounds(bounds)
scheduleConnect()
}
watch(boundsRef, maybeReconnect, { deep: true })
watch(layerQueryRef, () => {
subscribedKey.value = ''
scheduleConnect()
})
onMounted(() => {
if (typeof document === 'undefined') return
document.addEventListener('visibilitychange', () => {
document.visibilityState === 'visible' ? scheduleConnect() : closeStream()
})
if (document.visibilityState === 'visible') maybeReconnect(boundsRef.value)
})
onBeforeUnmount(() => {
if (debounceTimer.value) clearTimeout(debounceTimer.value)
closeStream()
})
return Object.freeze({ cotEntities })
}
+3 -2
View File
@@ -2,12 +2,13 @@ const EDIT_ROLES = Object.freeze(['admin', 'leader'])
export function useUser() {
const requestFetch = useRequestFetch()
const { data: user, refresh } = useAsyncData(
const { data: user, refresh, status } = useAsyncData(
'user',
() => (requestFetch ?? $fetch)('/api/me').catch(() => null),
{ default: () => null },
)
const authPending = computed(() => status.value === 'pending')
const canEditPois = computed(() => EDIT_ROLES.includes(user.value?.role))
const isAdmin = computed(() => user.value?.role === 'admin')
return Object.freeze({ user, canEditPois, isAdmin, refresh })
return Object.freeze({ user, authPending, canEditPois, isAdmin, refresh })
}
+1 -1
View File
@@ -3,7 +3,7 @@ const LOGIN_PATH = '/login'
export default defineNuxtRouteMiddleware(async (to) => {
if (to.path === LOGIN_PATH) return
const { user, refresh } = useUser()
await refresh()
if (!user.value) await refresh()
if (user.value) return
const redirect = to.fullPath.startsWith('/') ? to.fullPath : `/${to.fullPath}`
return navigateTo({ path: LOGIN_PATH, query: { redirect } }, { replace: true })
+22 -1
View File
@@ -7,11 +7,23 @@
:pois="pois ?? []"
:live-sessions="liveSessions ?? []"
:cot-entities="cotEntities ?? []"
:cot-layers="cotLayers"
:alpr-markers="showAlpr ? (alprMarkers ?? []) : []"
:show-alpr="showAlpr"
:can-edit-pois="canEditPois"
@select="selectedCamera = $event"
@select-live="onSelectLive($event)"
@refresh-pois="refreshPois"
@bounds-change="onMapBoundsChange"
@toggle-alpr="toggleAlpr"
@toggle-cot-layer="toggleLayer"
/>
<template #fallback>
<div
class="h-full min-h-[300px] bg-kestrel-bg"
aria-hidden="true"
/>
</template>
</ClientOnly>
</div>
<CameraViewer
@@ -23,11 +35,20 @@
</template>
<script setup>
const { devices, liveSessions, cotEntities } = useCameras()
const { devices, liveSessions } = useCameras()
const { data: pois, refresh: refreshPois } = usePois()
const { canEditPois } = useUser()
const { showAlpr, toggleAlpr, alprMarkers, onBoundsChange: onAlprBoundsChange } = useAlprCameras()
const { layers: cotLayers, layerQuery, toggleLayer } = useCotLayers()
const mapBounds = ref(null)
const { cotEntities } = useCotStream(mapBounds, layerQuery)
const selectedCamera = ref(null)
function onMapBoundsChange(bounds) {
mapBounds.value = bounds
onAlprBoundsChange(bounds)
}
function onSelectLive(session) {
selectedCamera.value = (liveSessions.value ?? []).find(s => s.id === session?.id) ?? session
}
+220
View File
@@ -0,0 +1,220 @@
import { syncFeatureMarkers } from './mapMarkerSync.js'
const ICON_SIZE = 28
const CONE_SIZE = 52
const ALPR_COLOR = '#ef4444'
const DEFAULT_FOV = 60
function escapeHtml(text) {
const div = document.createElement('div')
div.textContent = text
return div.innerHTML
}
function conePath(fov) {
const half = Math.min(Math.max(fov / 2, 10), 85)
const cx = 26
const cy = 26
const r = 24
const toRad = deg => ((deg - 90) * Math.PI) / 180
const x1 = cx + r * Math.cos(toRad(-half))
const y1 = cy + r * Math.sin(toRad(-half))
const x2 = cx + r * Math.cos(toRad(half))
const y2 = cy + r * Math.sin(toRad(half))
return `M ${cx} ${cy} L ${x1} ${y1} A ${r} ${r} 0 0 1 ${x2} ${y2} Z`
}
function compassLabel(deg) {
if (!Number.isFinite(deg)) return null
const dirs = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']
const idx = Math.round((((deg % 360) + 360) % 360) / 45) % 8
return dirs[idx]
}
function wikidataLink(label, qid) {
if (!qid) return escapeHtml(label)
const id = escapeHtml(qid)
return `<a href="https://www.wikidata.org/wiki/${id}" target="_blank" rel="noopener">${escapeHtml(label)}</a>`
}
function popupLine(parts) {
const line = parts.filter(Boolean).join(' · ')
return line ? `<div class="text-kestrel-muted text-xs mt-1">${line}</div>` : ''
}
function titleHtml(props) {
if (props.name) return escapeHtml(props.name)
if (props.model) {
const mfr = props.manufacturer ? escapeHtml(props.manufacturer) : null
return mfr ? `${mfr} <span class="text-kestrel-accent">${escapeHtml(props.model)}</span>` : escapeHtml(props.model)
}
const makeModel = [props.manufacturer, props.model].filter(Boolean).join(' ')
if (makeModel) {
if (props.manufacturerWikidata && !props.model) return wikidataLink(makeModel, props.manufacturerWikidata)
return escapeHtml(makeModel)
}
if (props.operator) return wikidataLink(props.operator, props.operatorWikidata)
if (props.ref) return escapeHtml(props.ref)
return 'ALPR camera'
}
function titleText(props) {
if (props.name) return props.name
if (props.model) {
return [props.manufacturer, props.model].filter(Boolean).join(' ')
}
const makeModel = [props.manufacturer, props.model].filter(Boolean).join(' ')
if (makeModel) return makeModel
if (props.operator) return props.operator
if (props.ref) return props.ref
return 'ALPR camera'
}
function modelLineHtml(props) {
if (props.model) {
return `<div class="text-sm mt-0.5"><span class="text-kestrel-muted">Model</span> <strong>${escapeHtml(props.model)}</strong></div>`
}
if (props.modelUnknown) {
return '<div class="text-kestrel-muted text-xs mt-0.5">Model not recorded in OpenStreetMap</div>'
}
return ''
}
/** Human-readable ALPR popup (GeoJSON properties in, HTML out). */
export function formatAlprPopup(props) {
const title = titleHtml(props)
const headline = titleText(props)
const makeModel = [props.manufacturer, props.model].filter(Boolean).join(' ')
const identity = []
if (props.name && makeModel) identity.push(escapeHtml(makeModel))
if (props.operator && headline !== props.operator) {
identity.push(wikidataLink(props.operator, props.operatorWikidata))
}
if (props.ref && headline !== props.ref) identity.push(escapeHtml(props.ref))
if (props.brand) identity.push(escapeHtml(props.brand))
const view = []
if (props.direction != null) {
const deg = Math.round(props.direction)
const compass = compassLabel(props.direction)
view.push(compass ? `Facing ${compass} (${deg}°)` : `Facing ${deg}°`)
}
if (props.fov != null && props.direction != null) view.push(`~${Math.round(props.fov)}° view`)
const note = props.description || props.note
const noteHtml = note ? `<div class="text-kestrel-muted text-xs mt-1">${escapeHtml(note)}</div>` : ''
const identityHtml = identity.length
? `<div class="text-kestrel-muted text-xs mt-0.5">${identity.join(' · ')}</div>`
: ''
const modelHtml = (props.model || props.modelUnknown) ? modelLineHtml(props) : ''
const foot = `<div class="text-kestrel-muted text-xs mt-2"><a href="https://www.openstreetmap.org/node/${props.osmId}" target="_blank" rel="noopener">View on OpenStreetMap</a></div>`
return `<div class="kestrel-live-popup"><strong>${title}</strong> <span class="text-kestrel-muted">License plate reader</span>${modelHtml}${identityHtml}${popupLine(view)}${noteHtml}${foot}</div>`
}
function popupHtml(props) {
return formatAlprPopup(props)
}
function pointIcon(L, direction, fov) {
const hasDirection = Number.isFinite(direction)
if (!hasDirection) {
const html = `<span class="poi-icon-svg"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${ALPR_COLOR}" stroke-width="2"><rect x="3" y="5" width="18" height="12" rx="2"/><circle cx="12" cy="11" r="3"/><path d="M8 21h8"/></svg></span>`
return L.divIcon({
className: 'poi-div-icon alpr-icon',
html,
iconSize: [ICON_SIZE, ICON_SIZE],
iconAnchor: [ICON_SIZE / 2, ICON_SIZE],
})
}
const spread = Number.isFinite(fov) ? fov : DEFAULT_FOV
const html = `<span class="alpr-cone" style="transform:rotate(${direction}deg)"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 52 52" width="${CONE_SIZE}" height="${CONE_SIZE}"><path d="${conePath(spread)}" fill="${ALPR_COLOR}" fill-opacity="0.3" stroke="${ALPR_COLOR}" stroke-width="1.5"/><circle cx="26" cy="26" r="3" fill="${ALPR_COLOR}"/></svg></span>`
return L.divIcon({
className: 'poi-div-icon alpr-icon',
html,
iconSize: [CONE_SIZE, CONE_SIZE],
iconAnchor: [CONE_SIZE / 2, CONE_SIZE / 2],
})
}
function clusterIcon(L, count) {
const size = count < 10 ? 28 : count < 100 ? 34 : 40
return L.divIcon({
className: 'alpr-cluster-icon',
html: `<span class="alpr-cluster">${count}</span>`,
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
})
}
export function createAlprControl(L, { showAlpr, onToggle }) {
const control = L.control({ position: 'topleft' })
control.onAdd = function () {
const el = document.createElement('button')
el.type = 'button'
el.className = 'leaflet-bar leaflet-control-alpr'
el.title = 'Toggle ALPR cameras (OSM)'
el.setAttribute('aria-label', 'Toggle ALPR cameras')
el.setAttribute('aria-pressed', showAlpr ? 'true' : 'false')
el.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><rect x="3" y="5" width="18" height="12" rx="2"/><circle cx="12" cy="11" r="3"/></svg>'
el.addEventListener('click', onToggle)
control._button = el
return el
}
return control
}
export function setAlprControlPressed(control, pressed) {
control?._button?.setAttribute('aria-pressed', pressed ? 'true' : 'false')
}
function featureKey(feature) {
const props = feature.properties ?? {}
if (props.cluster) return `c:${props.cluster_id}`
const id = props.osmId
return id != null ? `a:${id}` : null
}
function coords(feature) {
const [lng, lat] = feature.geometry.coordinates
return { lat, lng }
}
function attachClusterClick(marker, feature, map) {
marker.on('click', () => {
const { lat, lng } = coords(feature)
const props = feature.properties ?? {}
const zoom = props.expansionZoom ?? map.getZoom() + 2
map.setView([lat, lng], Math.min(zoom, 19), { animate: true })
})
}
export function createAlprLayer(L, map) {
return L.layerGroup().addTo(map)
}
export function syncAlprLayer(L, map, layer, features) {
syncFeatureMarkers(layer, features, {
keyFor: featureKey,
create: (feature) => {
const { lat, lng } = coords(feature)
const props = feature.properties ?? {}
const isCluster = Boolean(props.cluster)
const icon = isCluster ? clusterIcon(L, props.point_count) : pointIcon(L, props.direction, props.fov)
const marker = L.marker([lat, lng], { icon })
if (isCluster) attachClusterClick(marker, feature, map)
else marker.bindPopup(popupHtml(props), { className: 'kestrel-live-popup-wrap', maxWidth: 320 })
return marker
},
update: (marker, feature) => {
const { lat, lng } = coords(feature)
const props = feature.properties ?? {}
const isCluster = Boolean(props.cluster)
marker.setLatLng([lat, lng])
const icon = isCluster ? clusterIcon(L, props.point_count) : pointIcon(L, props.direction, props.fov)
marker.setIcon(icon)
if (!isCluster) marker.setPopupContent(popupHtml(props))
},
})
}
+77
View File
@@ -0,0 +1,77 @@
export const MAX_BBOX_DEGREES = 0.5
export function tileKey(row, col) {
return `${row},${col}`
}
export function bboxToTileKey(bbox) {
const row = Math.floor(bbox.south / MAX_BBOX_DEGREES)
const col = Math.floor(bbox.west / MAX_BBOX_DEGREES)
return tileKey(row, col)
}
function tileBox(row, col, step = MAX_BBOX_DEGREES) {
const south = row * step
const west = col * step
return { south, west, north: south + step, east: west + step }
}
export function bboxFetchKey(bounds) {
const zoom = bounds.zoom ?? 14
const step = zoom >= 14 ? 0.025 : zoom >= 11 ? 0.1 : zoom >= 8 ? 0.25 : 1
const q = v => Math.round(v / step) * step
return [q(bounds.south), q(bounds.west), q(bounds.north), q(bounds.east)].join(',')
}
function ringOffsets(radius) {
if (radius === 0) return [[0, 0]]
return Array.from({ length: 2 * radius + 1 }, (_, i) => i - radius)
.flatMap(dr => Array.from({ length: 2 * radius + 1 }, (_, j) => j - radius)
.filter(dc => Math.abs(dr) === radius || Math.abs(dc) === radius)
.map(dc => [dr, dc]))
}
function collectTiles(state) {
const {
centerRow, centerCol, minRow, maxRow, minCol, maxCol, step, limit, radius, seen, tiles,
} = state
if (tiles.length >= limit) return tiles
const inBounds = (row, col) => row >= minRow && row <= maxRow && col >= minCol && col <= maxCol
const { nextSeen, added } = ringOffsets(radius)
.map(([dr, dc]) => [centerRow + dr, centerCol + dc])
.filter(([row, col]) => inBounds(row, col))
.reduce((acc, [row, col]) => {
const key = `${row},${col}`
if (acc.nextSeen.has(key)) return acc
return {
nextSeen: new Set([...acc.nextSeen, key]),
added: [...acc.added, tileBox(row, col, step)],
}
}, { nextSeen: seen, added: [] })
if (added.length === 0 && radius > 0) return tiles
const nextTiles = [...tiles, ...added].slice(0, limit)
if (nextTiles.length >= limit) return nextTiles
return collectTiles({ ...state, radius: radius + 1, seen: nextSeen, tiles: nextTiles })
}
export function tilesNearCenter(bounds, limit) {
const step = MAX_BBOX_DEGREES
const lat = (bounds.south + bounds.north) / 2
const lng = (bounds.west + bounds.east) / 2
return collectTiles({
centerRow: Math.floor(lat / step),
centerCol: Math.floor(lng / step),
minRow: Math.floor(bounds.south / step),
maxRow: Math.ceil(bounds.north / step) - 1,
minCol: Math.floor(bounds.west / step),
maxCol: Math.ceil(bounds.east / step) - 1,
step,
limit,
radius: 0,
seen: new Set(),
tiles: [],
})
}
+131
View File
@@ -0,0 +1,131 @@
/** Map CoT / ADS-B entity display: icons and popups. */
export const COT_COLORS = {
air: '#60a5fa',
helicopter: '#fbbf24',
surface: '#38bdf8',
ground: '#f59e0b',
}
export function cotCategory(type) {
const t = typeof type === 'string' ? type : ''
if (t.startsWith('a-f-A-')) return 'air'
if (t.startsWith('a-f-S-')) return 'surface'
return 'ground'
}
/** Whether the entity is a helicopter or fixed-wing aircraft. @returns {'helicopter' | 'fixedWing'} */
export function cotAirIconKind(entity) {
const type = entity?.type ?? ''
if (type.endsWith('-C-H') || type.endsWith('-M-H')) return 'helicopter'
return 'fixedWing'
}
function iconWrap(heading, inner) {
const rotate = Number.isFinite(heading) ? ` style="transform:rotate(${heading}deg)"` : ''
return `<span class="poi-icon-svg cot-icon-rotatable"${rotate}>${inner}</span>`
}
const PLANE_SVG = color =>
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="${color}"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg>`
const HELI_SVG = color =>
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="1.75" stroke-linecap="round"><circle cx="12" cy="12" r="2.5" fill="${color}"/><path d="M3 8h18M3 12h18"/><path d="M12 8v8"/><path d="M9 16h6"/></svg>`
const SHIP_SVG = color =>
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2"><path d="M2 20c2-4 6-6 10-6s8 2 10 6"/><path d="M12 14V4"/><path d="m8 8 4-4 4 4"/></svg>`
const GROUND_SVG = color =>
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="8" r="2.5" fill="${color}"/></svg>`
export function getCotIconHtml(entity) {
const category = cotCategory(entity?.type)
const heading = Number(entity?.heading)
if (category === 'air') {
const kind = cotAirIconKind(entity)
const color = kind === 'helicopter' ? COT_COLORS.helicopter : COT_COLORS.air
const svg = kind === 'helicopter' ? HELI_SVG(color) : PLANE_SVG(color)
return { html: iconWrap(heading, svg), className: `cot-entity-${kind}` }
}
if (category === 'surface') {
return { html: iconWrap(heading, SHIP_SVG(COT_COLORS.surface)), className: 'cot-entity-surface' }
}
return { html: iconWrap(undefined, GROUND_SVG(COT_COLORS.ground)), className: 'cot-entity-ground' }
}
function msToKnots(ms) {
return Number.isFinite(ms) ? Math.round(ms * 1.94384) : null
}
function metersToFeet(m) {
return Number.isFinite(m) ? Math.round(m * 3.28084) : null
}
function fmtHeading(deg) {
return Number.isFinite(deg) ? `${Math.round(deg)}°` : null
}
function fmtVerticalFpm(ms) {
if (!Number.isFinite(ms) || ms === 0) return null
const fpm = Math.round(ms * 196.85)
return `${fpm > 0 ? '+' : ''}${fpm} fpm`
}
function icaoFromEntity(entity) {
if (entity?.icao) return String(entity.icao).toUpperCase()
if (typeof entity?.id === 'string' && entity.id.startsWith('ICAO.')) {
return entity.id.slice(5).toUpperCase()
}
return null
}
function mmsiFromEntity(entity) {
if (entity?.mmsi) return String(entity.mmsi)
if (typeof entity?.id === 'string' && entity.id.startsWith('MMSI.')) return entity.id.slice(5)
return null
}
function popupLine(escape, parts) {
const line = parts.filter(Boolean).join(' · ')
return line ? `<div class="text-kestrel-muted text-xs mt-1">${line}</div>` : ''
}
/**
* @param {Record<string, unknown>} entity
* @param {(s: string) => string} escape
*/
export function formatCotPopup(entity, escape) {
const category = cotCategory(entity?.type)
const label = escape(entity?.label || entity?.id || 'Unknown')
if (entity?.source === 'adsb' || category === 'air') {
const tag = cotAirIconKind(entity) === 'helicopter' ? 'Helicopter' : 'Aircraft'
const icao = icaoFromEntity(entity)
const meta = [
icao ? `ICAO ${icao}` : null,
entity?.originCountry ? escape(String(entity.originCountry)) : null,
].filter(Boolean).join(' · ')
const alt = metersToFeet(entity?.altitude)
const stats = [
alt != null ? `${alt.toLocaleString()} ft` : null,
entity?.onGround ? 'On ground' : null,
msToKnots(entity?.speed) != null ? `${msToKnots(entity.speed)} kt` : null,
fmtHeading(entity?.heading),
fmtVerticalFpm(entity?.verticalRate),
entity?.squawk ? `Squawk ${escape(String(entity.squawk))}` : null,
]
return `<div class="kestrel-live-popup"><strong>${label}</strong> <span class="text-kestrel-muted">${tag}</span>${meta ? `<div class="text-kestrel-muted text-xs mt-0.5">${meta}</div>` : ''}${popupLine(escape, stats)}</div>`
}
if (entity?.source === 'ais' || category === 'surface') {
const mmsi = mmsiFromEntity(entity)
const meta = mmsi ? `MMSI ${escape(mmsi)}` : ''
const stats = [
Number.isFinite(entity?.speed) ? `${Number(entity.speed).toFixed(1)} kt` : null,
fmtHeading(entity?.heading),
]
return `<div class="kestrel-live-popup"><strong>${label}</strong> <span class="text-kestrel-muted">Vessel</span>${meta ? `<div class="text-kestrel-muted text-xs mt-0.5">${meta}</div>` : ''}${popupLine(escape, stats)}</div>`
}
return `<div class="kestrel-live-popup"><strong>${label}</strong> <span class="text-kestrel-muted">Team</span></div>`
}
+107
View File
@@ -0,0 +1,107 @@
import { createClusterIndex } from './mapCluster.js'
import { syncFeatureMarkers } from './mapMarkerSync.js'
import { cotCategory, formatCotPopup, getCotIconHtml } from './cotDisplay.js'
const ICON_SIZE = 28
const CLUSTER = createClusterIndex({ radius: 50, maxZoom: 14, minPoints: 2 })
function escapeHtml(text) {
const div = document.createElement('div')
div.textContent = text
return div.innerHTML
}
export function entitiesToFeatures(entities) {
return (entities || [])
.filter(e => typeof e?.lat === 'number' && typeof e?.lng === 'number' && e?.id)
.map(e => ({
type: 'Feature',
geometry: { type: 'Point', coordinates: [e.lng, e.lat] },
properties: { entity: e, cotCategory: cotCategory(e.type) },
}))
}
export function loadCotCluster(entities) {
CLUSTER.load(entitiesToFeatures(entities))
}
export function getCotClusters(view) {
return CLUSTER.query(view)
}
function featureKey(feature) {
const props = feature.properties ?? {}
if (props.cluster) return `c:${props.cluster_id}`
const id = props.entity?.id
return id != null ? `e:${id}` : null
}
function clusterIcon(L, count) {
const size = count < 10 ? 28 : count < 100 ? 34 : 40
return L.divIcon({
className: 'cot-cluster-icon',
html: `<span class="cot-cluster">${count}</span>`,
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
})
}
function entityIcon(L, entity) {
const { html, className } = getCotIconHtml(entity)
return L.divIcon({
className: `poi-div-icon cot-entity-icon ${className}`,
html,
iconSize: [ICON_SIZE, ICON_SIZE],
iconAnchor: [ICON_SIZE / 2, ICON_SIZE / 2],
})
}
function coords(feature) {
const [lng, lat] = feature.geometry.coordinates
return { lat, lng }
}
function attachClusterClick(marker, feature, map) {
marker.on('click', () => {
const { lat, lng } = coords(feature)
const props = feature.properties ?? {}
const zoom = props.expansionZoom ?? map.getZoom() + 2
map.setView([lat, lng], Math.min(zoom, 19), { animate: true })
})
}
export function createCotLayer(L, map) {
return L.layerGroup().addTo(map)
}
export function syncCotLayer(L, map, layer, features) {
syncFeatureMarkers(layer, features, {
keyFor: featureKey,
create: (feature) => {
const { lat, lng } = coords(feature)
const props = feature.properties ?? {}
const isCluster = Boolean(props.cluster)
const icon = isCluster ? clusterIcon(L, props.point_count) : entityIcon(L, props.entity)
const marker = L.marker([lat, lng], { icon })
if (isCluster) attachClusterClick(marker, feature, map)
else if (props.entity) {
marker.bindPopup(
formatCotPopup(props.entity, escapeHtml),
{ className: 'kestrel-live-popup-wrap', maxWidth: 360 },
)
}
return marker
},
update: (marker, feature) => {
const { lat, lng } = coords(feature)
const props = feature.properties ?? {}
const isCluster = Boolean(props.cluster)
marker.setLatLng([lat, lng])
const icon = isCluster ? clusterIcon(L, props.point_count) : entityIcon(L, props.entity)
marker.setIcon(icon)
if (!isCluster && props.entity) {
marker.setPopupContent(formatCotPopup(props.entity, escapeHtml))
}
},
})
}
+31
View File
@@ -0,0 +1,31 @@
import Supercluster from 'supercluster'
export function createClusterIndex(options = {}) {
const index = new Supercluster(options)
const state = { features: Object.freeze([]) }
return {
load(features) {
const list = Object.freeze([...(features ?? [])])
index.load(list)
state.features = list
},
query(view) {
if (!view || state.features.length === 0) return []
const { west, south, east, north, zoom } = view
return index.getClusters(
[west, south, east, north],
Math.floor(zoom ?? 14),
).map((feature) => {
if (!feature.properties?.cluster) return feature
return {
...feature,
properties: {
...feature.properties,
expansionZoom: index.getClusterExpansionZoom(feature.properties.cluster_id),
},
}
})
},
}
}
+37
View File
@@ -0,0 +1,37 @@
const SYNC_KEY = '_kestrelMarkerSync'
const pointFeatures = (features, keyFor) => (features ?? [])
.filter(f => f?.geometry?.type === 'Point')
.map(f => ({ feature: f, key: keyFor(f) }))
.filter(({ key }) => key != null)
export function syncFeatureMarkers(layer, features, { keyFor, create, update }) {
const prev = layer[SYNC_KEY] ?? new Map()
const next = pointFeatures(features, keyFor).reduce((map, { feature, key }) => {
const existing = prev.get(key)
if (existing) {
update(existing, feature)
return new Map([...map, [key, existing]])
}
const marker = create(feature)
layer.addLayer(marker)
return new Map([...map, [key, marker]])
}, new Map())
Array.from(prev.entries())
.filter(([key]) => !next.has(key))
.forEach(([, marker]) => layer.removeLayer(marker))
layer[SYNC_KEY] = next
}
export function clearFeatureMarkers(layer) {
if (!layer) return
const prev = layer[SYNC_KEY]
if (prev) {
Array.from(prev.values()).forEach(marker => layer.removeLayer(marker))
layer[SYNC_KEY] = new Map()
return
}
layer.clearLayers()
}
+2 -1
View File
@@ -2,6 +2,8 @@
KestrelOS acts as a **TAK Server**. ATAK (Android) and iTAK (iOS) connect on **port 8089** (CoT). Devices relay positions to each other and appear on the KestrelOS map.
ADS-B and AIS via [adsbcot](https://github.com/snstac/adsbcot) / [aiscot](https://github.com/snstac/aiscot): see [tracking.md](tracking.md).
## Connection
**Host:** KestrelOS hostname/IP
@@ -59,7 +61,6 @@ OIDC users must set an **ATAK password** first:
| Variable | Default | Description |
|----------|---------|-------------|
| `COT_PORT` | `8089` | CoT server port |
| `COT_TTL_MS` | `90000` | Device timeout (~90s) |
| `COT_REQUIRE_AUTH` | `true` | Require authentication |
| `COT_SSL_CERT` | `.dev-certs/cert.pem` | TLS cert path |
| `COT_SSL_KEY` | `.dev-certs/key.pem` | TLS key path |
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 MiB

+10
View File
@@ -5,6 +5,7 @@ KestrelOS shows a **map** with devices, POIs, live sessions (Share live), and AT
## Map Layers
- **Devices** - Fixed feeds (IPTV, ALPR, CCTV, NVR, etc.) added via API
- **ALPR (OSM / DeFlock)** - Crowdsourced license-plate cameras from OpenStreetMap; toggle on the map (camera icon control). Reference only, no stream.
- **POIs** - Points of interest (admin/leader can edit)
- **Live sessions** - Mobile devices streaming via Share live
- **CoT (ATAK/iTAK)** - Amber markers for connected TAK devices (position only)
@@ -47,6 +48,15 @@ Stream URLs must be `http://` or `https://`.
**Cameras endpoint:** `GET /api/cameras` returns devices + live sessions + CoT entities.
## ALPR layer (DeFlock / OpenStreetMap)
deflock.me has no bulk download API. KestrelOS queries OpenStreetMap via Overpass (`surveillance:type=ALPR`) and returns **GeoJSON FeatureCollection** from `GET /api/alpr`.
- **Map:** ALPR layer is on by default (toggle top-left to hide). Marker popups show OSM identifying tags (manufacturer, model, operator, ref, Wikidata, etc.) when contributors tagged them.
- **Offline:** Run `npm run import:alpr` to preload SQLite; cache serves automatically when Overpass is unreachable.
Attribution: © OpenStreetMap contributors.
## POIs
Admins/leaders add/edit from **POI** page (sidebar). POIs appear as map pins (reference only, no stream).
+48
View File
@@ -0,0 +1,48 @@
# ADS-B and AIS
Aircraft and vessels use the same **CoT** store as ATAK/iTAK. The map consumes `GET /api/cot/stream` (SSE, viewport bbox). Toggle **Air**, **Surface**, and **Team** on the map.
## Accuracy tiers
1. **Tactical (best):** local SDR/AIS receiver → [adsbcot](https://github.com/snstac/adsbcot) / [aiscot](https://github.com/snstac/aiscot) → KestrelOS CoT `:8089` (sub-second updates).
2. **Vessels (live OSINT):** AISStream WebSocket push as vessels transmit.
3. **Aircraft (awareness OSINT):** OpenSky bbox poll — not a live stream; typical lag ~5s.
For tactical use, run local receivers. Do not rely on OpenSky alone.
## Freshness
Tracks update via SSE `update` events (CoT `:8089`, AISStream) or coalesced `snapshot` after each OpenSky poll. Stale tracks are removed automatically (team ~90s, OSINT ~30s without a new fix).
OSINT feeds run only while a map client is connected (SSE subscriber). Keep the map tab visible for live updates.
## Self-hosted
**ADS-B:** [adsbcot](https://github.com/snstac/adsbcot) → `tls://host:8089`
```ini
[adsbcot]
COT_URL = tls://kestrelos.example.com:8089
FEED_URL = tcp+beast://127.0.0.1:30005
```
**AIS:** [aiscot](https://github.com/snstac/aiscot) → `tls://host:8089`
```ini
[aiscot]
COT_URL = tls://kestrelos.example.com:8089
FEED_URL = tcp://127.0.0.1:10110
```
Use KestrelOS credentials (see [atak-itak.md](atak-itak.md)).
## OSINT APIs (optional)
Set these only if you want viewport OSINT without local receivers:
| Variable | Purpose |
|----------|---------|
| `AISSTREAM_API_KEY` | AISStream WebSocket |
| `OPENSKY_CLIENT_ID` / `OPENSKY_CLIENT_SECRET` | OpenSky OAuth (recommended for production) |
UIDs: `ICAO.*` (ADS-B), `MMSI.*` (AIS). Icons follow CoT type (`a-f-A-*`, `a-f-S-*`, `a-f-G-*`).
+3 -1
View File
@@ -32,9 +32,11 @@ export default defineNuxtConfig({
public: {
version: pkg.version ?? '',
},
cotTtlMs: 90_000,
cotRequireAuth: true,
cotDebug: false,
aisstreamApiKey: process.env.AISSTREAM_API_KEY || '',
openskyClientId: process.env.OPENSKY_CLIENT_ID || '',
openskyClientSecret: process.env.OPENSKY_CLIENT_SECRET || '',
},
devServer: {
host: '0.0.0.0',
+2379 -1946
View File
File diff suppressed because it is too large Load Diff
+23 -21
View File
@@ -16,34 +16,36 @@
"test:e2e:ui": "playwright test --ui test/e2e",
"test:e2e:debug": "playwright test --debug test/e2e",
"test:e2e:install": "playwright install --with-deps webkit chromium firefox",
"lint": "eslint . --max-warnings 0"
"lint": "eslint . --max-warnings 0",
"import:alpr": "node scripts/import-alpr.js"
},
"dependencies": {
"@nuxt/icon": "^2.2.1",
"@nuxt/icon": "^2.2.3",
"@nuxtjs/tailwindcss": "^6.14.0",
"fast-xml-parser": "^5.3.6",
"hls.js": "^1.5.0",
"fast-xml-parser": "^5.9.3",
"hls.js": "^1.6.16",
"jszip": "^3.10.1",
"leaflet": "^1.9.4",
"leaflet.offline": "^3.2.0",
"mediasoup": "^3.19.14",
"mediasoup-client": "^3.18.6",
"nuxt": "^4.0.0",
"openid-client": "^6.8.2",
"leaflet.offline": "^3.2.1",
"mediasoup": "^3.20.9",
"mediasoup-client": "^3.21.0",
"nuxt": "^4.4.8",
"openid-client": "^6.8.4",
"qrcode": "^1.5.4",
"vue": "^3.4.0",
"vue-router": "^5.0.0",
"ws": "^8.18.0"
"supercluster": "^8.0.1",
"vue": "^3.5.38",
"vue-router": "^5.1.0",
"ws": "^8.21.0"
},
"devDependencies": {
"@iconify-json/tabler": "^1.2.26",
"@nuxt/eslint": "^1.15.0",
"@nuxt/test-utils": "^4.0.0",
"@playwright/test": "^1.58.2",
"@vitest/coverage-v8": "^4.0.0",
"@vue/test-utils": "^2.4.0",
"eslint": "^10.0.0",
"happy-dom": "^20.6.1",
"vitest": "^4.0.0"
"@iconify-json/tabler": "^1.2.35",
"@nuxt/eslint": "^1.16.0",
"@nuxt/test-utils": "^4.0.3",
"@playwright/test": "^1.61.1",
"@vitest/coverage-v8": "^4.1.9",
"@vue/test-utils": "^2.4.11",
"eslint": "^10.5.0",
"happy-dom": "^20.10.6",
"vitest": "^4.1.9"
}
}
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env node
import { getDb, closeDb } from '../server/utils/db.js'
import { importAllAlprNodes } from '../server/utils/alpr.js'
try {
const db = await getDb()
console.log('[import-alpr] Fetching ALPR nodes from Overpass…')
const count = await importAllAlprNodes(db)
console.log(`[import-alpr] Cached ${count} nodes in SQLite.`)
}
catch (error) {
console.error('[import-alpr] Failed:', error?.message || error)
process.exitCode = 1
}
finally {
closeDb()
}
+19
View File
@@ -0,0 +1,19 @@
import { getDb } from '../utils/db.js'
import { requireAuth } from '../utils/authHelpers.js'
import { getAlprCameras, parseBbox } from '../utils/alpr.js'
export default defineEventHandler(async (event) => {
requireAuth(event)
let bbox
try {
bbox = parseBbox(getQuery(event))
}
catch (error) {
throw createError({
statusCode: error?.statusCode || 400,
message: error?.message || 'invalid bbox',
})
}
const db = await getDb()
return getAlprCameras(db, bbox)
})
+2 -6
View File
@@ -1,19 +1,15 @@
import { getDb } from '../utils/db.js'
import { requireAuth } from '../utils/authHelpers.js'
import { getActiveSessions } from '../utils/liveSessions.js'
import { getActiveEntities } from '../utils/cotStore.js'
import { rowToDevice, sanitizeDeviceForResponse } from '../utils/deviceUtils.js'
export default defineEventHandler(async (event) => {
requireAuth(event)
const config = useRuntimeConfig()
const ttlMs = Number(config.cotTtlMs ?? 90_000) || 90_000
const [db, sessions, cotEntities] = await Promise.all([
const [db, sessions] = await Promise.all([
getDb(),
getActiveSessions(),
getActiveEntities(ttlMs),
])
const rows = await db.all('SELECT id, name, device_type, vendor, lat, lng, stream_url, source_type, config FROM devices ORDER BY id')
const devices = rows.map(rowToDevice).filter(Boolean).map(sanitizeDeviceForResponse)
return { devices, liveSessions: sessions, cotEntities }
return { devices, liveSessions: sessions }
})
+45
View File
@@ -0,0 +1,45 @@
import { createEventStream } from 'h3'
import { requireAuth } from '../../utils/authHelpers.js'
import { getActiveEntitiesInBbox } from '../../utils/cotStore.js'
import { registerSubscriber } from '../../utils/cotSubscribers.js'
import { getCotSnapshotOpts } from '../../utils/cotSnapshot.js'
import { COT_SSE_HEARTBEAT_MS } from '../../utils/constants.js'
import { parseBboxParam, parseLayersParam } from '../../utils/cotEntityUtils.js'
import { scheduleTrackingFeedRefresh } from '../../utils/trackingFeed.js'
export default defineEventHandler((event) => {
requireAuth(event)
const query = getQuery(event)
const bbox = parseBboxParam(typeof query.bbox === 'string' ? query.bbox : undefined)
const layers = parseLayersParam(typeof query.layers === 'string' ? query.layers : undefined)
const snapshotOpts = getCotSnapshotOpts()
const stream = createEventStream(event)
const push = (eventName, data) => stream.push({ event: eventName, data })
const sendSnapshot = async () => {
const entities = await getActiveEntitiesInBbox(bbox, { ...snapshotOpts, layers })
await push('snapshot', JSON.stringify({ entities }))
}
const unregister = registerSubscriber({ bbox, layers, push })
let heartbeat
stream.onClosed(async () => {
clearInterval(heartbeat)
unregister()
scheduleTrackingFeedRefresh()
})
void (async () => {
scheduleTrackingFeedRefresh()
await sendSnapshot()
heartbeat = setInterval(() => {
push('heartbeat', '{}').catch(() => {})
}, COT_SSE_HEARTBEAT_MS)
})()
return stream.send()
})
+1 -1
View File
@@ -108,7 +108,7 @@ async function processFrame(socket, rawMessage, payload, authenticated) {
socket.destroy()
return
}
updateFromCot(parsed).catch((err) => {
updateFromCot({ ...parsed, type: parsed.eventType }).catch((err) => {
console.error('[cot] Error updating from CoT:', err?.message)
})
if (authenticated) broadcast(socket, rawMessage)
+32
View File
@@ -0,0 +1,32 @@
import { registerCleanup } from '../utils/shutdown.js'
import { onCotChange, pruneStaleEntities } from '../utils/cotStore.js'
import { getCotSnapshotOpts } from '../utils/cotSnapshot.js'
import {
notifySubscribersForEntity,
notifySubscribersRemove,
} from '../utils/cotSubscribers.js'
import { COT_PRUNE_INTERVAL_MS } from '../utils/constants.js'
let pruneTimer = null
let offChange = () => {}
export default defineNitroPlugin(() => {
offChange = onCotChange((changeEvent, payload) => {
if (changeEvent === 'update' && payload?.entity) {
notifySubscribersForEntity('update', { entity: payload.entity }, payload.entity).catch(() => {})
}
else if (changeEvent === 'remove' && payload?.id) {
notifySubscribersRemove(payload.id).catch(() => {})
}
})
pruneTimer = setInterval(() => {
pruneStaleEntities(getCotSnapshotOpts()).catch(() => {})
}, COT_PRUNE_INTERVAL_MS)
registerCleanup(() => {
offChange()
if (pruneTimer) clearInterval(pruneTimer)
pruneTimer = null
})
})
+7
View File
@@ -0,0 +1,7 @@
import { registerCleanup } from '../utils/shutdown.js'
import { startTrackingFeed, stopTrackingFeed } from '../utils/trackingFeed.js'
export default defineNitroPlugin(() => {
startTrackingFeed()
registerCleanup(stopTrackingFeed)
})
+263
View File
@@ -0,0 +1,263 @@
import { cameraToFeature, featureCollection } from './alprGeo.js'
const OVERPASS_URL = 'https://overpass.deflock.org/api/interpreter'
const MAX_BBOX_DEGREES = 0.5
const CACHE_TTL_MS = 86_400_000
const TILE_SCALE = 10
function httpError(statusCode, message) {
const err = new Error(message)
err.statusCode = statusCode
return err
}
/**
* @param {unknown} tags
* @returns {Record<string, string>} String OSM tags only
*/
function normalizeTags(tags) {
if (!tags || typeof tags !== 'object') return {}
return Object.fromEntries(
Object.entries(/** @type {Record<string, unknown>} */ (tags))
.filter(([, v]) => typeof v === 'string')
.map(([k, v]) => [k, /** @type {string} */ (v)]),
)
}
/**
* @param {unknown} element
* @returns {{ osmId: number, lat: number, lng: number, manufacturer: string | null, direction: number | null, tags: Record<string, string> } | null} Parsed camera or null
*/
export function parseOverpassElement(element) {
if (!element || typeof element !== 'object') return null
const el = /** @type {Record<string, unknown>} */ (element)
if (el.type !== 'node' || typeof el.id !== 'number') return null
const lat = Number(el.lat)
const lng = Number(el.lon ?? el.lng)
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null
const tags = normalizeTags(el.tags)
const directionRaw = tags['camera:direction'] ?? tags.direction
const direction = directionRaw != null && directionRaw !== '' ? Number(directionRaw) : null
const fovRaw = tags['camera:angle'] ?? tags['surveillance:angle']
const fov = fovRaw != null && fovRaw !== '' ? Number(fovRaw) : null
return {
osmId: el.id,
lat,
lng,
manufacturer: tags.manufacturer ?? tags.brand ?? null,
direction: Number.isFinite(direction) ? direction : null,
fov: Number.isFinite(fov) ? fov : null,
tags,
}
}
export function parseBbox(query) {
const south = Number(query.south)
const west = Number(query.west)
const north = Number(query.north)
const east = Number(query.east)
if (![south, west, north, east].every(Number.isFinite)) {
throw httpError(400, 'south, west, north, east required')
}
if (south >= north || west >= east) {
throw httpError(400, 'invalid bbox')
}
if (north - south > MAX_BBOX_DEGREES || east - west > MAX_BBOX_DEGREES) {
throw httpError(400, `bbox exceeds ${MAX_BBOX_DEGREES}°`)
}
return { south, west, north, east }
}
/** @param {{ south: number, west: number, north: number, east: number }} bbox */
export function tileKeysForBbox(bbox) {
const latMin = Math.floor(bbox.south * TILE_SCALE)
const latMax = Math.floor(bbox.north * TILE_SCALE)
const lngMin = Math.floor(bbox.west * TILE_SCALE)
const lngMax = Math.floor(bbox.east * TILE_SCALE)
const keys = []
for (let lat = latMin; lat <= latMax; lat++) {
for (let lng = lngMin; lng <= lngMax; lng++) {
keys.push(`${lat}:${lng}`)
}
}
return keys
}
export function buildBboxQuery(bbox) {
const { south, west, north, east } = bbox
return `[out:json][timeout:25];node["surveillance:type"="ALPR"](${south},${west},${north},${east});out body;`
}
export function buildGlobalQuery() {
return '[out:json][timeout:300];node["surveillance:type"="ALPR"];out body;'
}
function rowToCamera(row, source) {
const tags = (() => {
try {
return JSON.parse(row.tags)
}
catch {
return {}
}
})()
const fovRaw = tags['camera:angle'] ?? tags['surveillance:angle']
const fov = fovRaw != null && fovRaw !== '' ? Number(fovRaw) : null
return {
osmId: row.osm_id,
lat: row.lat,
lng: row.lng,
manufacturer: row.manufacturer ?? null,
direction: row.direction ?? null,
fov: Number.isFinite(fov) ? fov : null,
tags,
source,
}
}
/** @param {Awaited<ReturnType<typeof import('./db.js').getDb>>} db */
export async function upsertAlprNode(db, camera) {
const now = new Date().toISOString()
await db.run(
`INSERT INTO alpr_nodes (osm_id, lat, lng, manufacturer, direction, tags, fetched_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(osm_id) DO UPDATE SET
lat = excluded.lat,
lng = excluded.lng,
manufacturer = excluded.manufacturer,
direction = excluded.direction,
tags = excluded.tags,
fetched_at = excluded.fetched_at`,
[
camera.osmId,
camera.lat,
camera.lng,
camera.manufacturer,
camera.direction,
JSON.stringify(camera.tags),
now,
],
)
}
/** @param {Awaited<ReturnType<typeof import('./db.js').getDb>>} db */
export async function upsertAlprNodesBatch(db, cameras) {
if (!cameras.length) return
await db.run('BEGIN TRANSACTION')
try {
for (const camera of cameras) {
await upsertAlprNode(db, camera)
}
await db.run('COMMIT')
}
catch (error) {
await db.run('ROLLBACK').catch(() => {})
throw error
}
}
/** @param {Awaited<ReturnType<typeof import('./db.js').getDb>>} db */
export async function markTilesFetched(db, keys) {
if (!keys.length) return
const now = new Date().toISOString()
await db.run('BEGIN TRANSACTION')
try {
for (const key of keys) {
await db.run('INSERT OR REPLACE INTO alpr_tiles (tile_key, fetched_at) VALUES (?, ?)', [key, now])
}
await db.run('COMMIT')
}
catch (error) {
await db.run('ROLLBACK').catch(() => {})
throw error
}
}
/** @param {Awaited<ReturnType<typeof import('./db.js').getDb>>} db */
async function tilesAreFresh(db, keys) {
if (!keys.length) return false
const placeholders = keys.map(() => '?').join(',')
const rows = await db.all(
`SELECT tile_key, fetched_at FROM alpr_tiles WHERE tile_key IN (${placeholders})`,
keys,
)
if (rows.length !== keys.length) return false
const cutoff = Date.now() - CACHE_TTL_MS
return rows.every(row => Date.parse(row.fetched_at) >= cutoff)
}
/** @param {Awaited<ReturnType<typeof import('./db.js').getDb>>} db */
export async function getCachedAlprNodes(db, bbox) {
const rows = await db.all(
`SELECT osm_id, lat, lng, manufacturer, direction, tags
FROM alpr_nodes
WHERE lat >= ? AND lat <= ? AND lng >= ? AND lng <= ?`,
[bbox.south, bbox.north, bbox.west, bbox.east],
)
return rows.map(row => rowToCamera(row, 'cache'))
}
export async function fetchOverpass(query) {
const res = await fetch(OVERPASS_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `data=${encodeURIComponent(query)}`,
signal: AbortSignal.timeout(120_000),
})
if (!res.ok) {
throw httpError(502, `Overpass error: ${res.status}`)
}
const data = await res.json()
const elements = Array.isArray(data?.elements) ? data.elements : []
return elements.map(parseOverpassElement).filter(Boolean)
}
/** @param {Awaited<ReturnType<typeof import('./db.js').getDb>>} db */
async function fetchAndCacheBbox(db, bbox, tileKeys) {
const live = await fetchOverpass(buildBboxQuery(bbox))
await upsertAlprNodesBatch(db, live)
await markTilesFetched(db, tileKeys)
return live.map(c => ({ ...c, source: 'live' }))
}
const refreshInFlight = new Map()
/** @param {Awaited<ReturnType<typeof import('./db.js').getDb>>} db */
function refreshBboxInBackground(db, bbox, tileKeys) {
const key = tileKeys.join('|')
if (refreshInFlight.has(key)) return
const job = fetchAndCacheBbox(db, bbox, tileKeys)
.catch(() => {})
.finally(() => refreshInFlight.delete(key))
refreshInFlight.set(key, job)
}
/** @param {Awaited<ReturnType<typeof import('./db.js').getDb>>} db */
export async function getAlprCameras(db, bbox) {
const tileKeys = tileKeysForBbox(bbox)
const cached = await getCachedAlprNodes(db, bbox)
if (await tilesAreFresh(db, tileKeys)) {
return featureCollection(cached.map(cameraToFeature), 'cache')
}
if (cached.length > 0) {
refreshBboxInBackground(db, bbox, tileKeys)
return featureCollection(cached.map(cameraToFeature), 'cache')
}
try {
const live = await fetchAndCacheBbox(db, bbox, tileKeys)
return featureCollection(live.map(cameraToFeature), 'live')
}
catch {
return featureCollection(cached.map(cameraToFeature), 'cache')
}
}
/** @param {Awaited<ReturnType<typeof import('./db.js').getDb>>} db */
export async function importAllAlprNodes(db) {
const cameras = await fetchOverpass(buildGlobalQuery())
await upsertAlprNodesBatch(db, cameras)
return cameras.length
}
+175
View File
@@ -0,0 +1,175 @@
const ATTRIBUTION = '© OpenStreetMap contributors'
/** Longest match first — Flock, Genetec, Leonardo, etc. */
const KNOWN_ALPR_MODELS = [
'Falcon Flex',
'AutoVu CR-H2',
'AutoVu Sharp',
'UnicamVELOCITY3',
'Falcon',
'Sparrow',
'Raven',
'Condor',
'Wing',
'Pelican',
]
const GENERIC_CAMERA_NAMES = new Set([
'flock alpr camera',
'flock alpr cameera',
'flock camera',
'flock alpr',
'flock',
'flock camera',
'automatic license plate reader (alpr)',
'alpr camera',
'alpr',
])
const SKIP_EXTRA_TAGS = new Set([
'surveillance:type',
'man_made',
'camera:direction',
'direction',
'camera:angle',
'surveillance:angle',
'manufacturer',
'brand',
'model',
'operator',
'name',
'ref',
'surveillance',
'camera:type',
'description',
'note',
'colour',
'color',
'manufacturer:wikidata',
'model:wikidata',
'operator:wikidata',
])
function escapeRegex(text) {
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
/** @param {string} value */
export function normalizeModelName(value) {
const trimmed = value.trim()
if (!trimmed) return null
const lower = trimmed.toLowerCase()
for (const model of KNOWN_ALPR_MODELS) {
if (model.toLowerCase() === lower) return model
}
return trimmed.split(/\s+/).map(word =>
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(),
).join(' ')
}
/** @param {string} text */
function matchKnownModel(text) {
for (const model of KNOWN_ALPR_MODELS) {
const re = new RegExp(`\\b${escapeRegex(model)}\\b`, 'i')
if (re.test(text)) return model
}
const flock = text.match(/\bFlock\s+([a-z][\w -]*)/i)
if (flock) {
const fragment = flock[1].trim()
if (!fragment || /^safety$/i.test(fragment)) return null
return matchKnownModel(fragment)
}
return null
}
/** @param {Record<string, string>} tags */
export function inferModelFromTags(tags) {
const t = tags ?? {}
if (t.model?.trim()) return normalizeModelName(t.model)
for (const key of ['name', 'description', 'note']) {
const value = t[key]
if (!value?.trim()) continue
const model = matchKnownModel(value)
if (model) return model
}
return null
}
/** @param {string | null | undefined} name */
export function isGenericCameraName(name) {
if (!name?.trim()) return false
const normalized = name.trim().toLowerCase()
if (GENERIC_CAMERA_NAMES.has(normalized)) return true
if (/^flock\s+alpr\b/i.test(name) && !matchKnownModel(name)) return true
if (matchKnownModel(name) && /\bflock\b/i.test(name)) return true
return false
}
/** @param {Record<string, string>} tags */
export function displayNameFromTags(tags) {
const name = tags?.name?.trim()
if (!name || isGenericCameraName(name)) return null
if (matchKnownModel(name)) return null
return name
}
/** @param {Record<string, string>} tags */
export function identifyingProperties(tags, manufacturer = null) {
const t = tags ?? {}
const mfr = manufacturer ?? t.manufacturer ?? t.brand ?? null
const model = inferModelFromTags(t)
const name = displayNameFromTags(t)
/** @type {Record<string, string | boolean>} */
const props = {}
if (mfr) props.manufacturer = mfr
if (model) props.model = model
if (mfr && !model) props.modelUnknown = true
if (t.brand && t.brand !== mfr) props.brand = t.brand
if (t.operator) props.operator = t.operator
if (name) props.name = name
if (t.ref) props.ref = t.ref
if (t.surveillance) props.surveillance = t.surveillance
if (t['camera:type']) props.cameraType = t['camera:type']
if (t.description) props.description = t.description
if (t.note) props.note = t.note
const colour = t.colour ?? t.color
if (colour) props.colour = colour
if (t['manufacturer:wikidata']) props.manufacturerWikidata = t['manufacturer:wikidata']
if (t['model:wikidata']) props.modelWikidata = t['model:wikidata']
if (t['operator:wikidata']) props.operatorWikidata = t['operator:wikidata']
const extra = {}
for (const [key, value] of Object.entries(t)) {
if (SKIP_EXTRA_TAGS.has(key) || !value?.trim()) continue
extra[key] = value
}
if (Object.keys(extra).length) props.tags = extra
return props
}
/** @param {{ osmId: number, lat: number, lng: number, manufacturer: string | null, direction: number | null, fov: number | null, tags: Record<string, string> }} camera */
export function cameraToFeature(camera) {
return {
type: 'Feature',
id: camera.osmId,
geometry: { type: 'Point', coordinates: [camera.lng, camera.lat] },
properties: {
osmId: camera.osmId,
direction: camera.direction,
fov: camera.fov,
...identifyingProperties(camera.tags, camera.manufacturer),
},
}
}
/** @param {ReturnType<typeof cameraToFeature>[]} features */
export function featureCollection(features, source) {
return {
type: 'FeatureCollection',
features,
attribution: ATTRIBUTION,
source,
}
}
export { ATTRIBUTION }
+2
View File
@@ -19,6 +19,8 @@ export const SKIP_PATHS = Object.freeze([
])
export const PROTECTED_PATH_PREFIXES = Object.freeze([
'/api/alpr',
'/api/cot',
'/api/cameras',
'/api/devices',
'/api/live',
+13 -1
View File
@@ -2,10 +2,22 @@
* Application constants with environment variable support.
*/
// CoT / tracking (fixed defaults — not env-configurable)
export const COT_TTL_MS = 90_000
/** @deprecated Use COT_TTL_MS */
export const COT_ENTITY_TTL_MS = COT_TTL_MS
export const COT_OSINT_TTL_MS = 30_000
export const COT_PRUNE_INTERVAL_MS = 15_000
export const COT_SSE_HEARTBEAT_MS = 15_000
export const OPENSKY_CACHE_MS = 5_000
export const TRACKING_FEED_DEBOUNCE_MS = 500
export const COT_TAK_FILTER_BBOX = false
export const COT_SSE_MAX_ENTITIES = 2000
export const MAX_OPENSKY_BBOX_DEGREES = 10
// Timeouts (milliseconds)
export const COT_AUTH_TIMEOUT_MS = Number(process.env.COT_AUTH_TIMEOUT_MS) || 15_000
export const LIVE_SESSION_TTL_MS = Number(process.env.LIVE_SESSION_TTL_MS) || 60_000
export const COT_ENTITY_TTL_MS = Number(process.env.COT_ENTITY_TTL_MS) || 90_000
export const POLL_INTERVAL_MS = Number(process.env.POLL_INTERVAL_MS) || 1500
export const SHUTDOWN_TIMEOUT_MS = Number(process.env.SHUTDOWN_TIMEOUT_MS) || 30_000
+191
View File
@@ -0,0 +1,191 @@
/**
* CoT entity helpers: filters and OSINT → CoT mapping.
*/
/**
* @param {string} id
* @returns {'adsb' | 'ais' | 'tak'} Inferred track source.
*/
export function inferSourceFromId(id) {
if (typeof id !== 'string') return 'tak'
if (id.startsWith('ICAO.')) return 'adsb'
if (id.startsWith('MMSI.')) return 'ais'
return 'tak'
}
/**
* @param {string} [type]
* @returns {'air' | 'surface' | 'ground'} CoT display category.
*/
export function cotCategoryFromType(type) {
const t = typeof type === 'string' ? type : ''
if (t.startsWith('a-f-A-')) return 'air'
if (t.startsWith('a-f-S-')) return 'surface'
return 'ground'
}
/**
* @param {{ lat: number, lng: number }} point
* @param {{ west: number, south: number, east: number, north: number }} bbox
*/
export function isInBbox(point, bbox) {
if (!point || !bbox) return false
const { lat, lng } = point
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return false
return lat >= bbox.south && lat <= bbox.north && lng >= bbox.west && lng <= bbox.east
}
/**
* @param {Set<string> | string[] | undefined} layers
* @param {{ type?: string, source?: string }} entity
*/
export function matchesLayerFilter(layers, entity) {
if (!layers || layers.size === 0) return true
const category = cotCategoryFromType(entity.type)
if (layers.has('air') && category === 'air') return true
if (layers.has('surface') && category === 'surface') return true
if (layers.has('ground') && category === 'ground') return true
return false
}
/** OpenSky emitter category → MilStd CoT air type. */
function openSkyCategoryToType(category) {
if (category === 8) return 'a-f-A-C-H' // rotorcraft
if (category === 14) return 'a-f-A-C-F' // UAV — plane icon
return 'a-f-A-C-F'
}
/** OpenSky state vector → CoT upsert. */
export function openSkyStateToCot(state) {
if (!Array.isArray(state) || state.length < 11) return null
const icao24 = String(state[0] ?? '').trim().toLowerCase()
if (!icao24) return null
const lat = Number(state[6])
const lng = Number(state[5])
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null
const callsign = typeof state[1] === 'string' ? state[1].trim() : ''
const originCountry = typeof state[2] === 'string' ? state[2].trim() : ''
const category = Number(state[17])
const type = Number.isFinite(category) ? openSkyCategoryToType(category) : 'a-f-A-C-F'
const heading = Number(state[10])
const speed = Number(state[9])
const altitude = Number(state[7])
const verticalRate = Number(state[11])
const onGround = state[8] === true
const squawk = state[14] != null ? String(state[14]).padStart(4, '0') : undefined
return {
id: `ICAO.${icao24}`,
lat,
lng,
label: callsign || icao24.toUpperCase(),
type,
source: 'adsb',
icao: icao24,
originCountry: originCountry || undefined,
heading: Number.isFinite(heading) ? heading : undefined,
speed: Number.isFinite(speed) ? speed : undefined,
altitude: Number.isFinite(altitude) ? altitude : undefined,
verticalRate: Number.isFinite(verticalRate) ? verticalRate : undefined,
onGround: onGround || undefined,
squawk: squawk && squawk !== '0000' ? squawk : undefined,
}
}
/** AISStream position report → CoT upsert. */
export function aisStreamMessageToCot(message) {
if (!message || typeof message !== 'object') return null
const meta = /** @type {Record<string, unknown>} */ (message.MetaData)
const msg = /** @type {Record<string, unknown>} */ (message.Message)
if (!msg || typeof msg !== 'object') return null
const report = /** @type {Record<string, unknown>} */ (
msg.PositionReport ?? msg.StandardClassBPositionReport ?? msg.ExtendedClassBPositionReport
)
if (!report || typeof report !== 'object') return null
const mmsi = Number(meta?.MMSI ?? report.UserID)
if (!Number.isFinite(mmsi)) return null
const lat = Number(report.Latitude)
const lng = Number(report.Longitude)
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null
const shipName = typeof meta?.ShipName === 'string' ? meta.ShipName.trim() : ''
const heading = Number(report.Cog ?? report.TrueHeading)
const speed = Number(report.Sog)
return {
id: `MMSI.${mmsi}`,
lat,
lng,
label: shipName || `MMSI ${mmsi}`,
type: 'a-f-S-C',
source: 'ais',
mmsi: String(mmsi),
heading: Number.isFinite(heading) ? heading : undefined,
speed: Number.isFinite(speed) ? speed : undefined,
}
}
/**
* Union of subscriber bboxes.
* @param {Array<{ west: number, south: number, east: number, north: number } | null | undefined>} boxes
*/
export function unionBboxes(boxes) {
let west = Infinity
let south = Infinity
let east = -Infinity
let north = -Infinity
let has = false
for (const bbox of boxes) {
if (!bbox) continue
has = true
west = Math.min(west, bbox.west)
south = Math.min(south, bbox.south)
east = Math.max(east, bbox.east)
north = Math.max(north, bbox.north)
}
return has ? { west, south, east, north } : null
}
/**
* Shrink bbox to max span (degrees per axis), centered on midpoint.
* @param {{ west: number, south: number, east: number, north: number } | null} bbox
* @param {number} maxDegrees
*/
export function clampBbox(bbox, maxDegrees) {
if (!bbox || !Number.isFinite(maxDegrees) || maxDegrees <= 0) return bbox
const latSpan = bbox.north - bbox.south
const lngSpan = bbox.east - bbox.west
if (latSpan <= maxDegrees && lngSpan <= maxDegrees) return bbox
const latMid = (bbox.north + bbox.south) / 2
const lngMid = (bbox.east + bbox.west) / 2
const half = maxDegrees / 2
return {
south: latMid - half,
north: latMid + half,
west: lngMid - half,
east: lngMid + half,
}
}
/**
* @param {string | undefined} raw
* @returns {Set<string>} Enabled layer names.
*/
export function parseLayersParam(raw) {
if (!raw || typeof raw !== 'string') {
return new Set(['air', 'surface', 'ground'])
}
const parts = raw.split(',').map(s => s.trim().toLowerCase()).filter(Boolean)
if (parts.length === 0 || parts.includes('none')) return new Set()
return new Set(parts)
}
/**
* @param {string | undefined} raw
* @returns {{ west: number, south: number, east: number, north: number } | null} Parsed bbox or null.
*/
export function parseBboxParam(raw) {
if (!raw || typeof raw !== 'string') return null
const parts = raw.split(',').map(s => Number(s.trim()))
if (parts.length !== 4 || parts.some(n => !Number.isFinite(n))) return null
const [west, south, east, north] = parts
if (south > north || west > east) return null
return { west, south, east, north }
}
+15
View File
@@ -0,0 +1,15 @@
import {
COT_OSINT_TTL_MS,
COT_SSE_MAX_ENTITIES,
COT_TAK_FILTER_BBOX,
COT_TTL_MS,
} from './constants.js'
export function getCotSnapshotOpts() {
return {
ttlMs: COT_TTL_MS,
osintTtlMs: COT_OSINT_TTL_MS,
takFilterBbox: COT_TAK_FILTER_BBOX,
maxEntities: COT_SSE_MAX_ENTITIES,
}
}
+182 -38
View File
@@ -1,66 +1,210 @@
/**
* In-memory CoT entity store: upsert by id, prune on read by TTL.
* Single source of truth; getActiveEntities returns new objects (no mutation of returned refs).
* In-memory CoT store (TAK, ADS-B, AIS).
*/
import { acquire } from './asyncLock.js'
import { COT_ENTITY_TTL_MS } from './constants.js'
import { COT_OSINT_TTL_MS, COT_TTL_MS } from './constants.js'
import { inferSourceFromId, isInBbox, matchesLayerFilter } from './cotEntityUtils.js'
const entities = new Map()
/** @type {Set<(event: string, payload: unknown) => void>} */
const listeners = new Set()
/**
* Upsert entity by id. Input is not mutated; stored value is a new object.
* @param {{ id: string, lat: number, lng: number, label?: string, eventType?: string, type?: string }} parsed
* @param {(event: string, payload: unknown) => void} fn
* @returns {() => void} Unsubscribe function.
*/
export async function updateFromCot(parsed) {
if (!parsed || typeof parsed.id !== 'string') return
const lat = Number(parsed.lat)
const lng = Number(parsed.lng)
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return
export function onCotChange(fn) {
listeners.add(fn)
return () => listeners.delete(fn)
}
await acquire(`cot-${parsed.id}`, async () => {
const now = Date.now()
const existing = entities.get(parsed.id)
const label = typeof parsed.label === 'string' ? parsed.label : (existing?.label ?? parsed.id)
const type = typeof parsed.eventType === 'string' ? parsed.eventType : (typeof parsed.type === 'string' ? parsed.type : (existing?.type ?? ''))
function emitChange(event, payload) {
for (const fn of listeners) {
try {
fn(event, payload)
}
catch {
/* ignore listener errors */
}
}
}
entities.set(parsed.id, {
id: parsed.id,
lat,
lng,
label,
type,
updatedAt: now,
})
})
function pickOptionalNumber(value, fallback) {
if (value === undefined || value === null) return fallback
const n = Number(value)
return Number.isFinite(n) ? n : fallback
}
/**
* Active entities (updated within ttlMs). Prunes expired. Returns new array of new objects.
* @param {number} [ttlMs]
* @returns {Promise<Array<{ id: string, lat: number, lng: number, label: string, type: string, updatedAt: number }>>} Snapshot of active entities.
* @param {Record<string, unknown>} entity
*/
export async function getActiveEntities(ttlMs = COT_ENTITY_TTL_MS) {
return acquire('cot-prune', async () => {
const now = Date.now()
const active = []
const expired = []
for (const entity of entities.values()) {
if (now - entity.updatedAt <= ttlMs) {
active.push({
function toSnapshot(entity) {
return {
id: entity.id,
lat: entity.lat,
lng: entity.lng,
label: entity.label ?? entity.id,
type: entity.type ?? '',
source: entity.source ?? 'tak',
heading: entity.heading,
speed: entity.speed,
altitude: entity.altitude,
verticalRate: entity.verticalRate,
onGround: entity.onGround,
originCountry: entity.originCountry,
icao: entity.icao,
mmsi: entity.mmsi,
squawk: entity.squawk,
updatedAt: entity.updatedAt,
}
}
/**
* @param {Record<string, unknown>} entity
* @param {{ ttlMs?: number, osintTtlMs?: number }} opts
*/
function entityTtlMs(entity, opts) {
const source = entity.source
if (source === 'adsb' || source === 'ais') {
return opts.osintTtlMs ?? COT_OSINT_TTL_MS
}
return opts.ttlMs ?? COT_TTL_MS
}
function isEntityExpired(entity, now, opts) {
return now - entity.updatedAt > entityTtlMs(entity, opts)
}
/**
* Upsert entity by id. Input is not mutated; stored value is a new object.
* @param {{ id: string, lat: number, lng: number, label?: string, eventType?: string, type?: string, source?: string, heading?: number, speed?: number, altitude?: number }} parsed
* @param {{ silent?: boolean }} [options]
*/
export async function updateFromCot(parsed, options = {}) {
if (!parsed || typeof parsed.id !== 'string') return
const lat = Number(parsed.lat)
const lng = Number(parsed.lng)
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return
let snapshot = null
await acquire(`cot-${parsed.id}`, async () => {
const now = Date.now()
const existing = entities.get(parsed.id)
const label = typeof parsed.label === 'string' ? parsed.label : (existing?.label ?? parsed.id)
const type = typeof parsed.eventType === 'string'
? parsed.eventType
: (typeof parsed.type === 'string' ? parsed.type : (existing?.type ?? ''))
const explicitSource = parsed.source
const source = explicitSource === 'adsb' || explicitSource === 'ais' || explicitSource === 'tak'
? explicitSource
: inferSourceFromId(parsed.id)
const stored = {
id: parsed.id,
lat,
lng,
label,
type,
source,
heading: pickOptionalNumber(parsed.heading, existing?.heading),
speed: pickOptionalNumber(parsed.speed, existing?.speed),
altitude: pickOptionalNumber(parsed.altitude, existing?.altitude),
verticalRate: pickOptionalNumber(parsed.verticalRate, existing?.verticalRate),
onGround: typeof parsed.onGround === 'boolean' ? parsed.onGround : existing?.onGround,
originCountry: typeof parsed.originCountry === 'string' ? parsed.originCountry : existing?.originCountry,
icao: typeof parsed.icao === 'string' ? parsed.icao : existing?.icao,
mmsi: typeof parsed.mmsi === 'string' ? parsed.mmsi : existing?.mmsi,
squawk: typeof parsed.squawk === 'string' ? parsed.squawk : existing?.squawk,
updatedAt: now,
}
entities.set(parsed.id, stored)
snapshot = toSnapshot(stored)
})
if (snapshot && !options.silent) emitChange('update', { entity: snapshot })
}
/**
* @param {number} now
* @param {{ ttlMs?: number, osintTtlMs?: number }} opts
*/
function pruneExpired(now, opts) {
const expired = []
for (const entity of entities.values()) {
if (isEntityExpired(entity, now, opts)) expired.push(entity.id)
}
for (const id of expired) {
entities.delete(id)
emitChange('remove', { id })
}
}
/**
* @param {{ ttlMs?: number, osintTtlMs?: number }} [opts]
*/
export async function pruneStaleEntities(opts = {}) {
const ttlMs = opts.ttlMs ?? COT_TTL_MS
const osintTtlMs = opts.osintTtlMs ?? COT_OSINT_TTL_MS
await acquire('cot-prune', async () => {
pruneExpired(Date.now(), { ttlMs, osintTtlMs })
})
}
else {
expired.push(entity.id)
/**
* @param {Record<string, unknown>} entity
* @param {{ west: number, south: number, east: number, north: number } | null | undefined} bbox
* @param {boolean} takFilterBbox
*/
function passesBboxFilter(entity, bbox, takFilterBbox) {
if (!bbox) return true
const inBox = isInBbox(entity, bbox)
if (entity.source === 'tak' && !takFilterBbox) return true
return inBox
}
/**
* Active entities (updated within ttlMs). Prunes expired. Returns new array of new objects.
* @param {{ ttlMs?: number, osintTtlMs?: number }} [opts]
*/
export async function getActiveEntities(opts = {}) {
const ttlMs = opts.ttlMs ?? COT_TTL_MS
const osintTtlMs = opts.osintTtlMs ?? COT_OSINT_TTL_MS
return acquire('cot-prune', async () => {
const now = Date.now()
pruneExpired(now, { ttlMs, osintTtlMs })
return [...entities.values()].map(toSnapshot)
})
}
/**
* Active entities filtered by viewport bbox and layer set.
* @param {{ west: number, south: number, east: number, north: number } | null} bbox
* @param {{ ttlMs?: number, osintTtlMs?: number, layers?: Set<string>, takFilterBbox?: boolean, maxEntities?: number }} [opts]
*/
export async function getActiveEntitiesInBbox(bbox, opts = {}) {
const ttlMs = opts.ttlMs ?? COT_TTL_MS
const osintTtlMs = opts.osintTtlMs ?? COT_OSINT_TTL_MS
const layers = opts.layers
const takFilterBbox = Boolean(opts.takFilterBbox)
const ttlOpts = { ttlMs, osintTtlMs }
return acquire('cot-prune', async () => {
const now = Date.now()
pruneExpired(now, ttlOpts)
const active = []
for (const entity of entities.values()) {
if (isEntityExpired(entity, now, ttlOpts)) continue
const snap = toSnapshot(entity)
if (!passesBboxFilter(snap, bbox, takFilterBbox)) continue
if (!matchesLayerFilter(layers, snap)) continue
active.push(snap)
}
const maxEntities = opts.maxEntities
if (maxEntities != null && active.length > maxEntities) {
active.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0))
active.length = maxEntities
}
for (const id of expired) entities.delete(id)
return active
})
}
+70
View File
@@ -0,0 +1,70 @@
/** SSE subscriber registry; bbox union drives OSINT feeds. */
import { getActiveEntitiesInBbox } from './cotStore.js'
import { isInBbox, matchesLayerFilter, unionBboxes } from './cotEntityUtils.js'
/** @typedef {{ west: number, south: number, east: number, north: number }} Bbox */
/** @typedef {(event: string, data: string) => Promise<void> | void} PushFn */
/** @type {Map<string, { bbox: Bbox | null, layers: Set<string>, push: PushFn }>} */
const subscribers = new Map()
let nextId = 1
/**
* @param {{ bbox: Bbox | null, layers: Set<string>, push: PushFn }} sub
* @returns {() => void} Unregister function.
*/
export function registerSubscriber(sub) {
const id = String(nextId++)
subscribers.set(id, sub)
return () => subscribers.delete(id)
}
/** @returns {Bbox | null} Union of all subscriber bboxes. */
export function getSubscriberBboxUnion() {
return unionBboxes([...subscribers.values()].map(s => s.bbox))
}
export function getSubscriberCount() {
return subscribers.size
}
export function clearSubscribers() {
subscribers.clear()
}
export async function notifySubscribersForEntity(event, payload, entity) {
const data = JSON.stringify(payload)
const tasks = []
for (const sub of subscribers.values()) {
if (sub.bbox && !isInBbox(entity, sub.bbox)) continue
if (!matchesLayerFilter(sub.layers, entity)) continue
tasks.push(Promise.resolve(sub.push(event, data)))
}
await Promise.all(tasks)
}
export async function notifySubscribersRemove(id) {
const data = JSON.stringify({ id })
await Promise.all(
[...subscribers.values()].map(sub => Promise.resolve(sub.push('remove', data))),
)
}
/**
* Push a filtered snapshot to each active SSE subscriber.
* @param {{ ttlMs?: number, osintTtlMs?: number, takFilterBbox?: boolean, maxEntities?: number }} snapshotOpts
*/
export async function broadcastSubscriberSnapshots(snapshotOpts) {
const tasks = []
for (const sub of subscribers.values()) {
tasks.push((async () => {
const entities = await getActiveEntitiesInBbox(sub.bbox, {
...snapshotOpts,
layers: sub.layers,
})
await sub.push('snapshot', JSON.stringify({ entities }))
})())
}
await Promise.all(tasks)
}
+39 -1
View File
@@ -8,7 +8,7 @@ import { registerCleanup } from './shutdown.js'
const requireFromRoot = createRequire(join(process.cwd(), 'package.json'))
const { DatabaseSync } = requireFromRoot('node:sqlite')
const SCHEMA_VERSION = 4
const SCHEMA_VERSION = 6
const DB_BUSY_TIMEOUT_MS = 5000
let dbInstance = null
@@ -59,6 +59,20 @@ const SCHEMA = {
source_type TEXT NOT NULL DEFAULT 'mjpeg',
config TEXT
)`,
alpr_nodes: `CREATE TABLE IF NOT EXISTS alpr_nodes (
osm_id INTEGER PRIMARY KEY,
lat REAL NOT NULL,
lng REAL NOT NULL,
manufacturer TEXT,
direction INTEGER,
tags TEXT NOT NULL,
fetched_at TEXT NOT NULL
)`,
alpr_nodes_index: 'CREATE INDEX IF NOT EXISTS idx_alpr_lat_lng ON alpr_nodes(lat, lng)',
alpr_tiles: `CREATE TABLE IF NOT EXISTS alpr_tiles (
tile_key TEXT PRIMARY KEY,
fetched_at TEXT NOT NULL
)`,
}
const getDbPath = () => {
@@ -118,6 +132,19 @@ const migrateToV4 = async (run, all) => {
await run('ALTER TABLE users ADD COLUMN cot_password_hash TEXT')
}
const migrateToV5 = async (run, all) => {
const tables = await all('SELECT name FROM sqlite_master WHERE type=\'table\' AND name=\'alpr_nodes\'')
if (tables.length > 0) return
await run(SCHEMA.alpr_nodes)
await run(SCHEMA.alpr_nodes_index)
}
const migrateToV6 = async (run, all) => {
const tables = await all('SELECT name FROM sqlite_master WHERE type=\'table\' AND name=\'alpr_tiles\'')
if (tables.length > 0) return
await run(SCHEMA.alpr_tiles)
}
const runMigrations = async (run, all, get) => {
const version = await getSchemaVersion(get)
if (version >= SCHEMA_VERSION) return
@@ -133,6 +160,14 @@ const runMigrations = async (run, all, get) => {
await migrateToV4(run, all)
await setSchemaVersion(run, 4)
}
if (version < 5) {
await migrateToV5(run, all)
await setSchemaVersion(run, 5)
}
if (version < 6) {
await migrateToV6(run, all)
await setSchemaVersion(run, 6)
}
}
const initDb = async (db, run, all, get) => {
@@ -149,6 +184,9 @@ const initDb = async (db, run, all, get) => {
await run(SCHEMA.sessions)
await run(SCHEMA.pois)
await run(SCHEMA.devices)
await run(SCHEMA.alpr_nodes)
await run(SCHEMA.alpr_nodes_index)
await run(SCHEMA.alpr_tiles)
if (!testPath) {
// Bootstrap admin user on first run
+280
View File
@@ -0,0 +1,280 @@
/** OSINT feeds (AISStream, OpenSky) → cotStore. */
import WebSocket from 'ws'
import { updateFromCot } from './cotStore.js'
import {
broadcastSubscriberSnapshots,
getSubscriberBboxUnion,
} from './cotSubscribers.js'
import { getCotSnapshotOpts } from './cotSnapshot.js'
import { openSkyStateToCot, aisStreamMessageToCot, clampBbox } from './cotEntityUtils.js'
import { OPENSKY_CACHE_MS, TRACKING_FEED_DEBOUNCE_MS, MAX_OPENSKY_BBOX_DEGREES } from './constants.js'
const COALESCE_MS = 150
const state = {
aisSocket: null,
aisReconnectTimer: null,
aisBackoffMs: 1000,
openSkyTimer: null,
bboxDebounceTimer: null,
coalesceTimer: null,
openSkyToken: null,
openSkyTokenExpiresAt: 0,
/** @type {Map<string, { fetchedAt: number, states: unknown[][] }>} */
openSkyCache: new Map(),
lastAisBbox: null,
stopped: false,
}
function getConfig() {
return useRuntimeConfig()
}
function openskyPollMs() {
return OPENSKY_CACHE_MS
}
async function fetchOpenSkyToken(clientId, clientSecret) {
const now = Date.now()
if (state.openSkyToken && state.openSkyTokenExpiresAt > now + 60_000) {
return state.openSkyToken
}
const body = new URLSearchParams({
grant_type: 'client_credentials',
client_id: clientId,
client_secret: clientSecret,
})
const res = await fetch('https://auth.opensky-network.org/auth/realms/opensky-network/protocol/openid-connect/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
})
if (!res.ok) throw new Error(`OpenSky auth failed: ${res.status}`)
const json = await res.json()
state.openSkyToken = json.access_token
state.openSkyTokenExpiresAt = now + (Number(json.expires_in) || 1800) * 1000
return state.openSkyToken
}
function cacheKeyForBbox(bbox) {
const round = n => Math.round(n * 10) / 10
return `${round(bbox.west)},${round(bbox.south)},${round(bbox.east)},${round(bbox.north)}`
}
async function fetchOpenSkyForBbox(bbox) {
const config = getConfig()
const clientId = config.openskyClientId
const clientSecret = config.openskyClientSecret
const cacheMs = openskyPollMs()
const key = cacheKeyForBbox(bbox)
const cached = state.openSkyCache.get(key)
if (cached && Date.now() - cached.fetchedAt < cacheMs) {
return cached.states
}
const params = new URLSearchParams({
lamin: String(bbox.south),
lomin: String(bbox.west),
lamax: String(bbox.north),
lomax: String(bbox.east),
})
const headers = {}
if (clientId && clientSecret) {
const token = await fetchOpenSkyToken(clientId, clientSecret)
headers.Authorization = `Bearer ${token}`
}
const res = await fetch(`https://opensky-network.org/api/states/all?${params}`, { headers })
if (!res.ok) {
console.error('[trackingFeed] OpenSky fetch failed:', res.status)
return []
}
const json = await res.json()
const states = Array.isArray(json?.states) ? json.states : []
state.openSkyCache.set(key, { fetchedAt: Date.now(), states })
return states
}
export function scheduleCoalescedSnapshot() {
if (state.coalesceTimer) clearTimeout(state.coalesceTimer)
state.coalesceTimer = setTimeout(() => {
state.coalesceTimer = null
broadcastSubscriberSnapshots(getCotSnapshotOpts()).catch(() => {})
}, COALESCE_MS)
}
async function ingestOpenSkyBbox(bbox) {
try {
const states = await fetchOpenSkyForBbox(bbox)
for (const row of states) {
const parsed = openSkyStateToCot(row)
if (parsed) await updateFromCot(parsed, { silent: true })
}
if (states.length > 0) scheduleCoalescedSnapshot()
}
catch (err) {
console.error('[trackingFeed] OpenSky error:', err?.message)
}
}
async function pollOpenSky() {
if (state.stopped) return
const union = getSubscriberBboxUnion()
const bbox = clampBbox(union, MAX_OPENSKY_BBOX_DEGREES)
if (!bbox) return
await ingestOpenSkyBbox(bbox)
}
function stopOpenSkyPoll() {
if (state.openSkyTimer) {
clearInterval(state.openSkyTimer)
state.openSkyTimer = null
}
}
/** Tests only. @internal */
export function scheduleOpenSkyPollForTests() {
scheduleOpenSkyPoll()
}
function scheduleOpenSkyPoll() {
if (state.openSkyTimer || state.stopped) return
if (!getSubscriberBboxUnion()) return
const intervalMs = openskyPollMs()
state.openSkyTimer = setInterval(() => {
pollOpenSky().catch((err) => {
console.error('[trackingFeed] OpenSky poll error:', err?.message)
})
}, intervalMs)
}
function aisBboxKey(bbox) {
if (!bbox) return null
return `${bbox.west},${bbox.south},${bbox.east},${bbox.north}`
}
function closeAisSocket() {
if (state.aisSocket) {
try {
state.aisSocket.removeAllListeners()
state.aisSocket.close()
}
catch { /* ignore */ }
state.aisSocket = null
}
}
function scheduleAisReconnect() {
if (state.stopped || state.aisReconnectTimer) return
state.aisReconnectTimer = setTimeout(() => {
state.aisReconnectTimer = null
connectAisStream()
}, state.aisBackoffMs)
state.aisBackoffMs = Math.min(state.aisBackoffMs * 2, 60_000)
}
function subscribeAisBbox(ws, bbox) {
const apiKey = getConfig().aisstreamApiKey
if (!apiKey || !bbox) return
const key = aisBboxKey(bbox)
if (key === state.lastAisBbox) return
state.lastAisBbox = key
ws.send(JSON.stringify({
APIKey: apiKey,
BoundingBoxes: [[[bbox.south, bbox.west], [bbox.north, bbox.east]]],
}))
}
function connectAisStream() {
const apiKey = getConfig().aisstreamApiKey
if (!apiKey || state.stopped) return
closeAisSocket()
const ws = new WebSocket('wss://stream.aisstream.io/v0/stream')
state.aisSocket = ws
ws.on('open', () => {
state.aisBackoffMs = 1000
const union = getSubscriberBboxUnion()
const bbox = clampBbox(union, MAX_OPENSKY_BBOX_DEGREES)
if (bbox) subscribeAisBbox(ws, bbox)
})
ws.on('message', (data) => {
try {
const message = JSON.parse(data.toString())
const parsed = aisStreamMessageToCot(message)
if (parsed) updateFromCot(parsed).catch(() => {})
}
catch {
/* ignore malformed AIS messages */
}
})
ws.on('close', () => {
state.aisSocket = null
if (!state.stopped) scheduleAisReconnect()
})
ws.on('error', (err) => {
console.error('[trackingFeed] AISStream error:', err?.message)
})
}
function refreshFeedBboxes() {
if (state.stopped) return
const union = getSubscriberBboxUnion()
const bbox = clampBbox(union, MAX_OPENSKY_BBOX_DEGREES)
if (!bbox) {
stopOpenSkyPoll()
return
}
if (getConfig().aisstreamApiKey) {
if (state.aisSocket?.readyState === WebSocket.OPEN) {
subscribeAisBbox(state.aisSocket, bbox)
}
else if (!state.aisSocket) {
connectAisStream()
}
}
pollOpenSky().catch(() => {})
scheduleOpenSkyPoll()
}
export function scheduleTrackingFeedRefresh() {
if (state.bboxDebounceTimer) clearTimeout(state.bboxDebounceTimer)
state.bboxDebounceTimer = setTimeout(() => {
state.bboxDebounceTimer = null
refreshFeedBboxes()
}, TRACKING_FEED_DEBOUNCE_MS)
}
export function startTrackingFeed() {
state.stopped = false
}
export function stopTrackingFeed() {
state.stopped = true
if (state.bboxDebounceTimer) clearTimeout(state.bboxDebounceTimer)
if (state.coalesceTimer) clearTimeout(state.coalesceTimer)
state.coalesceTimer = null
stopOpenSkyPoll()
if (state.aisReconnectTimer) clearTimeout(state.aisReconnectTimer)
state.aisReconnectTimer = null
closeAisSocket()
state.openSkyCache.clear()
state.lastAisBbox = null
}
/** Tests only. @internal */
export function resetTrackingFeedForTests() {
stopTrackingFeed()
state.stopped = false
}
/** Tests only. @internal */
export function isOpenSkyPollActive() {
return state.openSkyTimer != null
}
+1 -1
View File
@@ -23,7 +23,7 @@ const ensureDevCerts = () => {
)
}
catch (error) {
throw new Error(`Failed to generate dev certificates: ${error.message}`)
throw new Error(`Failed to generate dev certificates: ${error.message}`, { cause: error })
}
}
+1 -1
View File
@@ -35,7 +35,7 @@ export function ensureDevCerts() {
console.log('[test] Generated .dev-certs/key.pem and .dev-certs/cert.pem')
}
catch (error) {
throw new Error(`Failed to generate dev certificates: ${error.message}`)
throw new Error(`Failed to generate dev certificates: ${error.message}`, { cause: error })
}
}
+27 -10
View File
@@ -7,6 +7,7 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { spawn, execSync } from 'node:child_process'
import { connect } from 'node:tls'
import { createServer } from 'node:net'
import { existsSync, mkdirSync } from 'node:fs'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
@@ -21,11 +22,21 @@ const devCertsDir = join(projectRoot, '.dev-certs')
const devKey = join(devCertsDir, 'key.pem')
const devCert = join(devCertsDir, 'cert.pem')
const API_PORT = 3000
const COT_PORT = 8089
const COT_AUTH_USER = 'test'
const COT_AUTH_PASS = 'test'
function getFreePort() {
return new Promise((resolve, reject) => {
const server = createServer()
server.listen(0, () => {
const address = server.address()
const port = typeof address === 'object' && address ? address.port : 0
server.close(() => resolve(port))
})
server.on('error', reject)
})
}
function ensureDevCerts() {
if (existsSync(devKey) && existsSync(devCert)) return
mkdirSync(devCertsDir, { recursive: true })
@@ -37,12 +48,12 @@ function ensureDevCerts() {
const FETCH_TIMEOUT_MS = 5000
async function waitForHealth(timeoutMs = 90000) {
async function waitForHealth(apiPort, timeoutMs = 90000) {
const start = Date.now()
while (Date.now() - start < timeoutMs) {
for (const protocol of ['https', 'http']) {
try {
const baseURL = `${protocol}://localhost:${API_PORT}`
const baseURL = `${protocol}://localhost:${apiPort}`
const ctrl = new AbortController()
const t = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS)
const res = await fetch(`${baseURL}/health`, { method: 'GET', signal: ctrl.signal })
@@ -55,16 +66,20 @@ async function waitForHealth(timeoutMs = 90000) {
}
await new Promise(r => setTimeout(r, 1000))
}
throw new Error(`Health not OK on ${API_PORT} within ${timeoutMs}ms`)
throw new Error(`Health not OK on ${apiPort} within ${timeoutMs}ms`)
}
describe('Server and CoT integration', () => {
const testState = {
serverProcess: null,
apiPort: 0,
cotPort: 0,
}
beforeAll(async () => {
ensureDevCerts()
testState.apiPort = await getFreePort()
testState.cotPort = await getFreePort()
const serverPath = join(projectRoot, '.output', 'server', 'index.mjs')
if (!existsSync(serverPath)) {
execSync('npm run build', { cwd: projectRoot, stdio: 'pipe' })
@@ -72,6 +87,8 @@ describe('Server and CoT integration', () => {
const dbPath = join(tmpdir(), `kestrelos-it-${process.pid}-${Date.now()}.db`)
const env = {
...process.env,
PORT: String(testState.apiPort),
COT_PORT: String(testState.cotPort),
DB_PATH: dbPath,
BOOTSTRAP_EMAIL: COT_AUTH_USER,
BOOTSTRAP_PASSWORD: COT_AUTH_PASS,
@@ -83,7 +100,7 @@ describe('Server and CoT integration', () => {
})
testState.serverProcess.stdout?.on('data', d => process.stdout.write(d))
testState.serverProcess.stderr?.on('data', d => process.stderr.write(d))
await waitForHealth(90000)
await waitForHealth(testState.apiPort, 90000)
}, 120000)
afterAll(() => {
@@ -92,11 +109,11 @@ describe('Server and CoT integration', () => {
}
})
it('serves health on port 3000', async () => {
it('serves health on the configured API port', async () => {
const tryProtocols = async (protocols) => {
if (protocols.length === 0) throw new Error('No protocol succeeded')
try {
const res = await fetch(`${protocols[0]}://localhost:${API_PORT}/health`, { method: 'GET', headers: { Accept: 'application/json' } })
const res = await fetch(`${protocols[0]}://localhost:${testState.apiPort}/health`, { method: 'GET', headers: { Accept: 'application/json' } })
if (res?.ok) return res
return tryProtocols(protocols.slice(1))
}
@@ -112,10 +129,10 @@ describe('Server and CoT integration', () => {
expect(body.endpoints).toHaveProperty('ready', '/health/ready')
})
it('CoT on 8089: TAK client auth with username/password succeeds (socket stays open)', async () => {
it('CoT: TAK client auth with username/password succeeds (socket stays open)', async () => {
const payload = buildAuthCotXml({ username: COT_AUTH_USER, password: COT_AUTH_PASS })
const socket = await new Promise((resolve, reject) => {
const s = connect(COT_PORT, '127.0.0.1', { rejectUnauthorized: false }, () => {
const s = connect(testState.cotPort, '127.0.0.1', { rejectUnauthorized: false }, () => {
s.write(payload, () => resolve(s))
})
s.on('error', reject)
+23 -24
View File
@@ -1,27 +1,24 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { SHUTDOWN_TIMEOUT_MS } from '../../server/utils/constants.js'
import { registerCleanup, graceful, initShutdownHandlers, clearCleanup } from '../../server/utils/shutdown.js'
describe('shutdown integration', () => {
const testState = {
originalExit: null,
exitCalls: [],
originalOn: null,
}
/** @type {import('vitest').MockInstance} */
let exitSpy
/** @type {typeof process.on} */
let originalOn
beforeEach(() => {
clearCleanup()
testState.exitCalls = []
testState.originalExit = process.exit
process.exit = vi.fn((code) => {
testState.exitCalls.push(code)
})
testState.originalOn = process.on
exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {})
originalOn = process.on
})
afterEach(() => {
process.exit = testState.originalExit
process.on = testState.originalOn
exitSpy.mockRestore()
process.on = originalOn
clearCleanup()
vi.useRealTimers()
})
it('initializes signal handlers', () => {
@@ -32,7 +29,6 @@ describe('shutdown integration', () => {
initShutdownHandlers()
expect(process.on).toHaveBeenCalledWith('SIGTERM', expect.any(Function))
expect(process.on).toHaveBeenCalledWith('SIGINT', expect.any(Function))
process.on = testState.originalOn
})
it('signal handler calls graceful', async () => {
@@ -43,9 +39,10 @@ describe('shutdown integration', () => {
initShutdownHandlers()
const sigtermHandler = handlers.SIGTERM
expect(sigtermHandler).toBeDefined()
await sigtermHandler()
expect(testState.exitCalls.length).toBeGreaterThan(0)
process.on = testState.originalOn
sigtermHandler()
await vi.waitFor(() => {
expect(exitSpy).toHaveBeenCalled()
})
})
it('signal handler handles graceful error', async () => {
@@ -59,18 +56,20 @@ describe('shutdown integration', () => {
registerCleanup(async () => {
throw new Error('Force error')
})
await sigintHandler()
expect(testState.exitCalls.length).toBeGreaterThan(0)
process.on = testState.originalOn
sigintHandler()
await vi.waitFor(() => {
expect(exitSpy).toHaveBeenCalled()
})
})
it('covers timeout path in graceful', async () => {
vi.useFakeTimers()
registerCleanup(async () => {
await new Promise(resolve => setTimeout(resolve, 40000))
await new Promise(resolve => setTimeout(resolve, SHUTDOWN_TIMEOUT_MS + 5_000))
})
graceful()
await new Promise(resolve => setTimeout(resolve, 100))
expect(testState.exitCalls.length).toBeGreaterThan(0)
await vi.advanceTimersByTimeAsync(SHUTDOWN_TIMEOUT_MS + 1)
expect(exitSpy).toHaveBeenCalledWith(1)
})
it('covers graceful catch block', async () => {
@@ -78,6 +77,6 @@ describe('shutdown integration', () => {
throw new Error('Test error')
})
await graceful()
expect(testState.exitCalls.length).toBeGreaterThan(0)
expect(exitSpy).toHaveBeenCalled()
})
})
+8
View File
@@ -75,4 +75,12 @@ describe('KestrelMap', () => {
expect(wrapper.props('pois')).toHaveLength(1)
expect(wrapper.props('canEditPois')).toBe(false)
})
it('includes CoT layer toggles and supercluster layer', async () => {
const componentPath = resolve(__dirname, '../../app/components/KestrelMap.vue')
const source = readFileSync(componentPath, 'utf-8')
expect(source).toContain('cot-layer-toggles')
expect(source).toContain('cotMapLayer')
expect(source).toContain('syncCotLayer')
})
})
-14
View File
@@ -15,26 +15,12 @@ describe('useCameras', () => {
setupEndpoints(() => ({
devices: [{ id: '1', name: 'Test', lat: 37.7, lng: -122.4, streamUrl: '', sourceType: 'mjpeg', device_type: 'feed' }],
liveSessions: [],
cotEntities: [],
}))
const wrapper = await mountSuspended(Index)
await wait()
expect(wrapper.findComponent({ name: 'KestrelMap' }).exists()).toBe(true)
})
it('exposes cotEntities from API', async () => {
const cotEntities = [{ id: 'cot-1', lat: 38, lng: -123, label: 'ATAK1' }]
setupEndpoints(() => ({
devices: [],
liveSessions: [],
cotEntities,
}))
const wrapper = await mountSuspended(Index)
await wait()
const map = wrapper.findComponent({ name: 'KestrelMap' })
expect(map.props('cotEntities')).toEqual(cotEntities)
})
it('handles API error and falls back to empty devices and liveSessions', async () => {
setupEndpoints(() => {
throw new Error('network')
+38
View File
@@ -0,0 +1,38 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { useCotLayers } from '../../app/composables/useCotLayers.js'
describe('useCotLayers', () => {
beforeEach(() => {
localStorage.clear()
})
afterEach(() => {
localStorage.clear()
})
it('defaults all layers on', () => {
const { layers, layerQuery } = useCotLayers()
expect(layers.value).toEqual({ air: true, surface: true, ground: true })
expect(layerQuery.value).toBe('air,surface,ground')
})
it('toggles layers and persists to localStorage', () => {
const { layers, toggleLayer, layerQuery } = useCotLayers()
toggleLayer('air')
expect(layers.value.air).toBe(false)
expect(layerQuery.value).toBe('surface,ground')
const stored = JSON.parse(localStorage.getItem('kestrel-cot-layers'))
expect(stored.air).toBe(false)
})
})
describe('useCotStream helpers', () => {
it('layer query none when all off', () => {
localStorage.clear()
const { toggleLayer, layerQuery } = useCotLayers()
toggleLayer('air')
toggleLayer('surface')
toggleLayer('ground')
expect(layerQuery.value).toBe('none')
})
})
+154
View File
@@ -0,0 +1,154 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import {
parseOverpassElement,
parseBbox,
tileKeysForBbox,
getAlprCameras,
markTilesFetched,
} from '../../server/utils/alpr.js'
import { cameraToFeature, identifyingProperties, inferModelFromTags, isGenericCameraName } from '../../server/utils/alprGeo.js'
import { getDb, setDbPathForTest, closeDb } from '../../server/utils/db.js'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { randomUUID } from 'node:crypto'
describe('alpr utils', () => {
describe('parseOverpassElement', () => {
it('parses a valid OSM node', () => {
const out = parseOverpassElement({
type: 'node',
id: 123,
lat: 33.75,
lon: -84.39,
tags: { 'manufacturer': 'Flock Safety', 'camera:direction': '90' },
})
expect(out?.osmId).toBe(123)
expect(out?.manufacturer).toBe('Flock Safety')
})
})
describe('cameraToFeature', () => {
it('returns GeoJSON Point feature with identifying tags', () => {
const feature = cameraToFeature({
osmId: 1,
lat: 33.5,
lng: -84.5,
manufacturer: 'Flock Safety',
direction: 90,
fov: 45,
tags: {
'model': 'Falcon',
'operator': 'City PD',
'ref': 'CAM-12',
'operator:wikidata': 'Q123',
'fixme': 'verify mount',
},
})
expect(feature.properties.manufacturer).toBe('Flock Safety')
expect(feature.properties.model).toBe('Falcon')
expect(feature.properties.operator).toBe('City PD')
expect(feature.properties.ref).toBe('CAM-12')
expect(feature.properties.operatorWikidata).toBe('Q123')
expect(feature.properties.tags).toEqual({ fixme: 'verify mount' })
})
})
describe('identifyingProperties', () => {
it('omits brand when same as manufacturer', () => {
const props = identifyingProperties({ manufacturer: 'Flock Safety', brand: 'Flock Safety', model: 'Falcon' })
expect(props.manufacturer).toBe('Flock Safety')
expect(props.model).toBe('Falcon')
expect(props.brand).toBeUndefined()
})
it('infers model from lowercase name tag', () => {
const props = identifyingProperties({
manufacturer: 'Flock Safety',
name: 'falcon',
})
expect(props.model).toBe('Falcon')
expect(props.name).toBeUndefined()
expect(props.modelUnknown).toBeUndefined()
})
it('drops generic camera names and flags unknown model', () => {
const props = identifyingProperties({
manufacturer: 'Flock Safety',
name: 'Flock ALPR camera',
})
expect(props.name).toBeUndefined()
expect(props.model).toBeUndefined()
expect(props.modelUnknown).toBe(true)
})
})
describe('inferModelFromTags', () => {
it('reads model tag and mis-tagged Flock Falcon name', () => {
expect(inferModelFromTags({ model: 'Sparrow' })).toBe('Sparrow')
expect(inferModelFromTags({ name: 'Flock Falcon' })).toBe('Falcon')
expect(isGenericCameraName('Flock ALPR camera')).toBe(true)
expect(isGenericCameraName('Peachtree & 5th')).toBe(false)
})
})
describe('parseBbox', () => {
it('parses valid bbox', () => {
expect(parseBbox({ south: 33.0, west: -85.0, north: 33.4, east: -84.6 })).toEqual({
south: 33.0,
west: -85.0,
north: 33.4,
east: -84.6,
})
})
it('rejects oversized bbox', () => {
expect(() => parseBbox({ south: 0, west: 0, north: 2, east: 1 })).toThrow(/bbox exceeds/)
})
})
describe('cache and fetch', () => {
let dbPath
beforeEach(async () => {
dbPath = join(tmpdir(), `kestrelos-alpr-${randomUUID()}.db`)
setDbPathForTest(dbPath)
await getDb()
})
afterEach(() => {
closeDb()
setDbPathForTest(null)
})
it('returns GeoJSON FeatureCollection from cache', async () => {
const db = await getDb()
const bbox = { south: 33.4, west: -85.0, north: 33.6, east: -84.0 }
await db.run(
`INSERT INTO alpr_nodes (osm_id, lat, lng, manufacturer, direction, tags, fetched_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[99, 33.5, -84.5, 'Acme', 180, '{}', new Date().toISOString()],
)
await markTilesFetched(db, tileKeysForBbox(bbox))
vi.stubGlobal('fetch', vi.fn())
const result = await getAlprCameras(db, bbox)
expect(result.type).toBe('FeatureCollection')
expect(result.features).toHaveLength(1)
expect(result.source).toBe('cache')
vi.unstubAllGlobals()
})
it('falls back to cache when Overpass fails', async () => {
const db = await getDb()
await db.run(
`INSERT INTO alpr_nodes (osm_id, lat, lng, manufacturer, direction, tags, fetched_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[42, 33.5, -84.5, null, null, '{}', new Date().toISOString()],
)
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network down')))
const result = await getAlprCameras(db, { south: 33.4, west: -85.0, north: 33.6, east: -84.0 })
expect(result.type).toBe('FeatureCollection')
expect(result.features).toHaveLength(1)
vi.unstubAllGlobals()
})
})
})
+57
View File
@@ -0,0 +1,57 @@
import { describe, it, expect } from 'vitest'
import { formatAlprPopup } from '../../app/utils/alprMapLayer.js'
describe('formatAlprPopup', () => {
it('summarizes manufacturer, model, and operator', () => {
const html = formatAlprPopup({
osmId: 1,
manufacturer: 'Flock Safety',
model: 'Falcon',
operator: 'City PD',
direction: 90,
fov: 60,
})
expect(html).toContain('Flock Safety')
expect(html).toContain('Falcon')
expect(html).toContain('License plate reader')
expect(html).toContain('City PD')
expect(html).toContain('Facing E (90°)')
expect(html).toContain('~60° view')
expect(html).not.toContain('Manufacturer')
expect(html).not.toContain('fixme')
})
it('uses name as title with make/model below', () => {
const html = formatAlprPopup({
osmId: 2,
name: 'Peachtree & 5th',
manufacturer: 'Flock Safety',
model: 'Falcon',
})
expect(html).toContain('<strong>Peachtree &amp; 5th</strong>')
expect(html).toContain('Model</span> <strong>Falcon</strong>')
expect(html).toContain('Flock Safety Falcon')
})
it('links operator wikidata inline', () => {
const html = formatAlprPopup({
osmId: 3,
operator: 'Atlanta Police',
operatorWikidata: 'Q123',
})
expect(html).toContain('<strong>')
expect(html).toContain('href="https://www.wikidata.org/wiki/Q123"')
expect(html).toContain('Atlanta Police')
expect(html).not.toContain('Wikidata')
})
it('shows model unknown when OSM lacks model tag', () => {
const html = formatAlprPopup({
osmId: 4,
manufacturer: 'Flock Safety',
modelUnknown: true,
})
expect(html).toContain('Flock Safety')
expect(html).toContain('Model not recorded in OpenStreetMap')
})
})
+10
View File
@@ -0,0 +1,10 @@
import { describe, it, expect } from 'vitest'
import { tilesNearCenter } from '../../app/utils/alprViewport.js'
describe('tilesNearCenter', () => {
it('returns a single tile for a small viewport', () => {
const tiles = tilesNearCenter({ south: 37.7, west: -122.5, north: 37.8, east: -122.4 }, 16)
expect(tiles).toHaveLength(1)
expect(tiles[0]).toEqual({ south: 37.5, west: -122.5, north: 38, east: -122 })
})
})
+1
View File
@@ -10,6 +10,7 @@ describe('authSkipPaths', () => {
it('does not skip any protected path', () => {
const protectedPaths = [
...PROTECTED_PATH_PREFIXES,
'/api/alpr',
'/api/cameras',
'/api/devices',
'/api/devices/any-id',
+6
View File
@@ -2,7 +2,10 @@ import { describe, it, expect } from 'vitest'
import {
COT_AUTH_TIMEOUT_MS,
LIVE_SESSION_TTL_MS,
COT_TTL_MS,
COT_ENTITY_TTL_MS,
COT_OSINT_TTL_MS,
COT_PRUNE_INTERVAL_MS,
POLL_INTERVAL_MS,
SHUTDOWN_TIMEOUT_MS,
COT_PORT,
@@ -18,7 +21,10 @@ describe('constants', () => {
it('uses default values when env vars not set', () => {
expect(COT_AUTH_TIMEOUT_MS).toBe(15000)
expect(LIVE_SESSION_TTL_MS).toBe(60000)
expect(COT_TTL_MS).toBe(90000)
expect(COT_ENTITY_TTL_MS).toBe(90000)
expect(COT_OSINT_TTL_MS).toBe(30000)
expect(COT_PRUNE_INTERVAL_MS).toBe(15000)
expect(POLL_INTERVAL_MS).toBe(1500)
expect(SHUTDOWN_TIMEOUT_MS).toBe(30000)
expect(COT_PORT).toBe(8089)
+68
View File
@@ -0,0 +1,68 @@
import { describe, it, expect } from 'vitest'
import {
cotAirIconKind,
cotCategory,
formatCotPopup,
getCotIconHtml,
} from '../../app/utils/cotDisplay.js'
const esc = s => String(s)
describe('cotDisplay', () => {
it('detects helicopter from CoT type', () => {
expect(cotAirIconKind({ type: 'a-f-A-C-H' })).toBe('helicopter')
expect(cotAirIconKind({ type: 'a-f-A-C-F' })).toBe('fixedWing')
})
it('renders distinct air icon kinds', () => {
const plane = getCotIconHtml({ type: 'a-f-A-C-F', heading: 90 })
const heli = getCotIconHtml({ type: 'a-f-A-C-H', heading: 180 })
expect(plane.className).toBe('cot-entity-fixedWing')
expect(heli.className).toBe('cot-entity-helicopter')
expect(plane.html).toContain('rotate(90deg)')
expect(heli.html).toContain('rotate(180deg)')
})
it('formats rich ADS-B popup', () => {
const html = formatCotPopup({
source: 'adsb',
type: 'a-f-A-C-F',
label: 'UAL123',
icao: 'abc123',
originCountry: 'United States',
altitude: 10000,
speed: 200,
heading: 270,
verticalRate: 5,
squawk: '1200',
}, esc)
expect(html).toContain('UAL123')
expect(html).toContain('Aircraft')
expect(html).toContain('ICAO ABC123')
expect(html).toContain('United States')
expect(html).toContain('ft')
expect(html).toContain('kt')
expect(html).toContain('270°')
expect(html).toContain('Squawk 1200')
})
it('formats vessel popup with MMSI', () => {
const html = formatCotPopup({
source: 'ais',
type: 'a-f-S-C',
label: 'TEST SHIP',
id: 'MMSI.366123456',
speed: 12,
heading: 90,
}, esc)
expect(html).toContain('Vessel')
expect(html).toContain('MMSI 366123456')
})
it('formats team popup', () => {
expect(cotCategory('a-f-G-U-C')).toBe('ground')
const html = formatCotPopup({ type: 'a-f-G-U-C', label: 'Alpha 1' }, esc)
expect(html).toContain('Team')
expect(html).toContain('Alpha 1')
})
})
+111
View File
@@ -0,0 +1,111 @@
import { describe, it, expect } from 'vitest'
import {
inferSourceFromId,
cotCategoryFromType,
isInBbox,
matchesLayerFilter,
openSkyStateToCot,
aisStreamMessageToCot,
unionBboxes,
clampBbox,
parseBboxParam,
parseLayersParam,
} from '../../../server/utils/cotEntityUtils.js'
describe('cotEntityUtils', () => {
it('infers source from UID prefix', () => {
expect(inferSourceFromId('ICAO.abc123')).toBe('adsb')
expect(inferSourceFromId('MMSI.366123456')).toBe('ais')
expect(inferSourceFromId('ANDROID-deadbeef')).toBe('tak')
})
it('maps CoT type to category', () => {
expect(cotCategoryFromType('a-f-A-C-F')).toBe('air')
expect(cotCategoryFromType('a-f-S-C')).toBe('surface')
expect(cotCategoryFromType('a-f-G-U-C')).toBe('ground')
})
it('checks bbox membership', () => {
const bbox = { west: -123, south: 37, east: -122, north: 38 }
expect(isInBbox({ lat: 37.5, lng: -122.5 }, bbox)).toBe(true)
expect(isInBbox({ lat: 40, lng: -122.5 }, bbox)).toBe(false)
})
it('filters by layer set', () => {
const airOnly = new Set(['air'])
expect(matchesLayerFilter(airOnly, { type: 'a-f-A-C-F' })).toBe(true)
expect(matchesLayerFilter(airOnly, { type: 'a-f-S-C' })).toBe(false)
})
it('maps OpenSky state vector to CoT', () => {
const state = ['abc123', 'UAL123 ', 'United States', 1, 2, -122.4, 37.7, 10000, false, 200, 90, 5, null, null, 1200, false, 0, 0]
const cot = openSkyStateToCot(state)
expect(cot).toMatchObject({
id: 'ICAO.abc123',
lat: 37.7,
lng: -122.4,
label: 'UAL123',
source: 'adsb',
type: 'a-f-A-C-F',
icao: 'abc123',
originCountry: 'United States',
heading: 90,
speed: 200,
altitude: 10000,
verticalRate: 5,
squawk: '1200',
})
})
it('maps OpenSky rotorcraft to helicopter CoT type', () => {
const state = ['heli01', 'N123HC ', 'United States', 1, 2, -122.4, 37.7, 500, false, 50, 180, 0, null, null, null, false, 0, 8]
const cot = openSkyStateToCot(state)
expect(cot?.type).toBe('a-f-A-C-H')
})
it('maps AISStream message to CoT', () => {
const cot = aisStreamMessageToCot({
MetaData: { MMSI: 366123456, ShipName: 'TEST SHIP' },
Message: {
PositionReport: {
UserID: 366123456,
Latitude: 37.8,
Longitude: -122.3,
Sog: 12.5,
Cog: 180,
},
},
})
expect(cot).toMatchObject({
id: 'MMSI.366123456',
lat: 37.8,
lng: -122.3,
label: 'TEST SHIP',
source: 'ais',
type: 'a-f-S-C',
})
})
it('unions bboxes', () => {
expect(unionBboxes([
{ west: -123, south: 37, east: -122, north: 38 },
{ west: -124, south: 36, east: -121, north: 39 },
])).toEqual({ west: -124, south: 36, east: -121, north: 39 })
})
it('clamps oversized bbox to max span', () => {
const huge = { west: -125, south: 32, east: -115, north: 42 }
const clamped = clampBbox(huge, 10)
expect(clamped.north - clamped.south).toBeCloseTo(10)
expect(clamped.east - clamped.west).toBeCloseTo(10)
expect((clamped.north + clamped.south) / 2).toBeCloseTo(37)
expect((clamped.east + clamped.west) / 2).toBeCloseTo(-120)
})
it('parses bbox and layers query params', () => {
expect(parseBboxParam('-123,37,-122,38')).toEqual({ west: -123, south: 37, east: -122, north: 38 })
expect(parseBboxParam('bad')).toBeNull()
expect(parseLayersParam('air,surface')).toEqual(new Set(['air', 'surface']))
expect(parseLayersParam('none').size).toBe(0)
})
})
+46
View File
@@ -0,0 +1,46 @@
import { describe, it, expect } from 'vitest'
import {
entitiesToFeatures,
getCotClusters,
loadCotCluster,
} from '../../app/utils/cotMapLayer.js'
function makeEntities(n, centerLat = 37.7, centerLng = -122.4) {
return Array.from({ length: n }, (_, i) => ({
id: `ICAO.${i}`,
lat: centerLat + (i % 10) * 0.01,
lng: centerLng + Math.floor(i / 10) * 0.01,
type: 'a-f-A-C-F',
label: `AC${i}`,
}))
}
describe('cotMapLayer', () => {
it('converts entities to GeoJSON points', () => {
const features = entitiesToFeatures([
{ id: 'ICAO.1', lat: 37.7, lng: -122.4, type: 'a-f-A-C-F' },
{ id: 'bad', lat: 'x', lng: 0 },
])
expect(features).toHaveLength(1)
expect(features[0].geometry.coordinates).toEqual([-122.4, 37.7])
expect(features[0].properties.entity.id).toBe('ICAO.1')
})
it('clusters dense tracks at low zoom', () => {
loadCotCluster(makeEntities(50))
const view = { west: -123, south: 37, east: -122, north: 38, zoom: 6 }
const clusters = getCotClusters(view)
const clusterCount = clusters.filter(f => f.properties?.cluster).length
const pointCount = clusters.filter(f => !f.properties?.cluster).length
expect(clusterCount).toBeGreaterThan(0)
expect(clusterCount + pointCount).toBeLessThan(50)
})
it('shows individual markers at high zoom', () => {
loadCotCluster(makeEntities(20))
const view = { west: -123, south: 37, east: -122, north: 38, zoom: 15 }
const clusters = getCotClusters(view)
expect(clusters.every(f => !f.properties?.cluster)).toBe(true)
expect(clusters).toHaveLength(20)
})
})
+13
View File
@@ -44,4 +44,17 @@ describe('cotServer (parse-and-store path)', () => {
expect(active[0].lng).toBe(4)
expect(active[0].label).toBe('Updated')
})
it('infers adsb source from ICAO uid when ingesting CoT position', async () => {
await updateFromCot({
id: 'ICAO.abc123',
lat: 37.7,
lng: -122.4,
label: 'N12345',
eventType: 'a-f-A-C-F',
})
const active = await getActiveEntities()
expect(active[0].source).toBe('adsb')
expect(active[0].type).toBe('a-f-A-C-F')
})
})
+84 -3
View File
@@ -1,5 +1,12 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { updateFromCot, getActiveEntities, clearCotStore } from '../../../server/utils/cotStore.js'
import {
updateFromCot,
getActiveEntities,
getActiveEntitiesInBbox,
clearCotStore,
onCotChange,
pruneStaleEntities,
} from '../../../server/utils/cotStore.js'
describe('cotStore', () => {
beforeEach(() => {
@@ -14,6 +21,28 @@ describe('cotStore', () => {
expect(active[0].lat).toBe(37.7)
expect(active[0].lng).toBe(-122.4)
expect(active[0].label).toBe('Alpha')
expect(active[0].source).toBe('tak')
})
it('stores enriched ADS-B fields and infers source', async () => {
await updateFromCot({
id: 'ICAO.abc123',
lat: 37.7,
lng: -122.4,
label: 'UAL1',
type: 'a-f-A-C-F',
heading: 90,
speed: 200,
altitude: 10000,
})
const active = await getActiveEntities()
expect(active[0]).toMatchObject({
source: 'adsb',
heading: 90,
speed: 200,
altitude: 10000,
type: 'a-f-A-C-F',
})
})
it('updates same uid', async () => {
@@ -41,10 +70,10 @@ describe('cotStore', () => {
it('prunes expired entities after getActiveEntities', async () => {
await updateFromCot({ id: 'uid-1', lat: 37, lng: -122 })
const active1 = await getActiveEntities(100)
const active1 = await getActiveEntities({ ttlMs: 100 })
expect(active1).toHaveLength(1)
await new Promise(r => setTimeout(r, 150))
const active2 = await getActiveEntities(100)
const active2 = await getActiveEntities({ ttlMs: 100 })
expect(active2).toHaveLength(0)
})
@@ -55,4 +84,56 @@ describe('cotStore', () => {
expect(active).toHaveLength(2)
expect(active.map(e => e.id).sort()).toEqual(['a', 'b'])
})
it('filters OSINT entities by bbox but keeps team globally', async () => {
await updateFromCot({ id: 'ICAO.abc', lat: 37.5, lng: -122.5, type: 'a-f-A-C-F' })
await updateFromCot({ id: 'MMSI.123', lat: 40, lng: -100, type: 'a-f-S-C' })
await updateFromCot({ id: 'ANDROID-1', lat: 50, lng: 10, source: 'tak' })
const bbox = { west: -123, south: 37, east: -122, north: 38 }
const active = await getActiveEntitiesInBbox(bbox, { takFilterBbox: false })
const ids = active.map(e => e.id).sort()
expect(ids).toEqual(['ANDROID-1', 'ICAO.abc'])
})
it('filters team by bbox when COT_TAK_FILTER_BBOX enabled', async () => {
await updateFromCot({ id: 'ANDROID-1', lat: 50, lng: 10, source: 'tak' })
const bbox = { west: -123, south: 37, east: -122, north: 38 }
const active = await getActiveEntitiesInBbox(bbox, { takFilterBbox: true })
expect(active).toHaveLength(0)
})
it('caps bbox query results at maxEntities', async () => {
for (let i = 0; i < 5; i++) {
await updateFromCot({ id: `ICAO.${i}`, lat: 37.5 + i * 0.01, lng: -122.4, type: 'a-f-A-C-F' })
}
const bbox = { west: -123, south: 37, east: -122, north: 38 }
const active = await getActiveEntitiesInBbox(bbox, { maxEntities: 3 })
expect(active).toHaveLength(3)
})
it('skips emit when silent upsert', async () => {
const updates = []
const off = onCotChange((event) => {
if (event === 'update') updates.push(event)
})
await updateFromCot({ id: 'ICAO.silent', lat: 37, lng: -122, type: 'a-f-A-C-F' }, { silent: true })
off()
expect(updates).toHaveLength(0)
const active = await getActiveEntities()
expect(active.some(e => e.id === 'ICAO.silent')).toBe(true)
})
it('pruneStaleEntities uses shorter TTL for OSINT sources', async () => {
await updateFromCot({ id: 'ICAO.old', lat: 37, lng: -122, source: 'adsb', type: 'a-f-A-C-F' })
await updateFromCot({ id: 'ANDROID-1', lat: 37, lng: -122, source: 'tak' })
const removed = []
const off = onCotChange((event, payload) => {
if (event === 'remove') removed.push(payload.id)
})
await new Promise(r => setTimeout(r, 60))
await pruneStaleEntities({ ttlMs: 10_000, osintTtlMs: 50 })
off()
expect(removed).toContain('ICAO.old')
expect(removed).not.toContain('ANDROID-1')
})
})
+96
View File
@@ -0,0 +1,96 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import {
registerSubscriber,
getSubscriberBboxUnion,
clearSubscribers,
notifySubscribersForEntity,
notifySubscribersRemove,
broadcastSubscriberSnapshots,
} from '../../../server/utils/cotSubscribers.js'
import { updateFromCot, clearCotStore } from '../../../server/utils/cotStore.js'
describe('cotSubscribers', () => {
beforeEach(() => {
clearSubscribers()
})
it('unions subscriber bboxes', () => {
registerSubscriber({
bbox: { west: -123, south: 37, east: -122, north: 38 },
layers: new Set(['air']),
push: vi.fn(),
})
registerSubscriber({
bbox: { west: -124, south: 36, east: -121, north: 39 },
layers: new Set(['surface']),
push: vi.fn(),
})
expect(getSubscriberBboxUnion()).toEqual({ west: -124, south: 36, east: -121, north: 39 })
})
it('notifies subscribers inside bbox and matching layer', async () => {
const push = vi.fn()
registerSubscriber({
bbox: { west: -123, south: 37, east: -122, north: 38 },
layers: new Set(['air']),
push,
})
await notifySubscribersForEntity('update', { entity: { id: 'ICAO.x' } }, {
id: 'ICAO.x',
lat: 37.5,
lng: -122.5,
type: 'a-f-A-C-F',
})
expect(push).toHaveBeenCalledWith('update', expect.any(String))
})
it('skips subscribers when entity outside bbox', async () => {
const push = vi.fn()
registerSubscriber({
bbox: { west: -123, south: 37, east: -122, north: 38 },
layers: new Set(['air']),
push,
})
await notifySubscribersForEntity('update', { entity: { id: 'ICAO.x' } }, {
id: 'ICAO.x',
lat: 40,
lng: -122.5,
type: 'a-f-A-C-F',
})
expect(push).not.toHaveBeenCalled()
})
it('notifySubscribersRemove pushes to all subscribers', async () => {
const pushA = vi.fn()
const pushB = vi.fn()
registerSubscriber({
bbox: { west: -123, south: 37, east: -122, north: 38 },
layers: new Set(['air']),
push: pushA,
})
registerSubscriber({
bbox: { west: -125, south: 35, east: -120, north: 40 },
layers: new Set(['surface']),
push: pushB,
})
await notifySubscribersRemove('ICAO.removed')
expect(pushA).toHaveBeenCalledWith('remove', JSON.stringify({ id: 'ICAO.removed' }))
expect(pushB).toHaveBeenCalledWith('remove', JSON.stringify({ id: 'ICAO.removed' }))
})
it('broadcastSubscriberSnapshots sends per-subscriber filtered snapshot', async () => {
clearCotStore()
await updateFromCot({ id: 'ICAO.in', lat: 37.5, lng: -122.5, type: 'a-f-A-C-F' })
await updateFromCot({ id: 'ICAO.out', lat: 40, lng: -100, type: 'a-f-A-C-F' })
const push = vi.fn()
registerSubscriber({
bbox: { west: -123, south: 37, east: -122, north: 38 },
layers: new Set(['air']),
push,
})
await broadcastSubscriberSnapshots({ ttlMs: 90_000, osintTtlMs: 30_000, takFilterBbox: false })
expect(push).toHaveBeenCalledWith('snapshot', expect.any(String))
const payload = JSON.parse(push.mock.calls[0][1])
expect(payload.entities.map(e => e.id)).toEqual(['ICAO.in'])
})
})
+55
View File
@@ -0,0 +1,55 @@
import { describe, it, expect, vi } from 'vitest'
import { createClusterIndex } from '../../app/utils/mapCluster.js'
import { clearFeatureMarkers, syncFeatureMarkers } from '../../app/utils/mapMarkerSync.js'
import { bboxFetchKey, tilesNearCenter } from '../../app/utils/alprViewport.js'
describe('mapCluster', () => {
it('loads once and queries by viewport', () => {
const index = createClusterIndex({ radius: 50, maxZoom: 14, minPoints: 2 })
const features = Array.from({ length: 20 }, (_, i) => ({
type: 'Feature',
geometry: { type: 'Point', coordinates: [-122.4 + i * 0.01, 37.7] },
properties: { id: i },
}))
index.load(features)
const zoomedOut = index.query({ west: -123, south: 37, east: -122, north: 38, zoom: 6 })
expect(zoomedOut.some(f => f.properties?.cluster)).toBe(true)
const zoomedIn = index.query({ west: -123, south: 37, east: -122, north: 38, zoom: 15 })
expect(zoomedIn).toHaveLength(20)
})
})
describe('mapMarkerSync', () => {
it('reuses markers by key', () => {
const layer = {
_layers: [],
addLayer(m) { this._layers.push(m) },
removeLayer(m) { this._layers = this._layers.filter(x => x !== m) },
}
const create = vi.fn(f => ({ id: f.properties.id }))
const update = vi.fn()
const opts = { keyFor: f => f.properties.id, create, update }
const pt = (id, lng, lat) => ({ type: 'Feature', geometry: { type: 'Point', coordinates: [lng, lat] }, properties: { id } })
syncFeatureMarkers(layer, [pt(1, -122, 37)], opts)
syncFeatureMarkers(layer, [pt(1, -121.9, 37.1)], opts)
expect(create).toHaveBeenCalledTimes(1)
expect(update).toHaveBeenCalledTimes(1)
clearFeatureMarkers(layer)
expect(layer._layers).toHaveLength(0)
})
})
describe('alprViewport', () => {
it('selects nearby tiles without scanning the world', () => {
const world = { south: -85, west: -180, north: 85, east: 180 }
expect(tilesNearCenter(world, 16)).toHaveLength(16)
expect(tilesNearCenter(world, 1)[0]).toEqual({ south: 0, west: 0, north: 0.5, east: 0.5 })
})
it('coarsens fetch keys when zoomed out', () => {
const bounds = { south: 37.01, west: -122.51, north: 37.99, east: -122.01, zoom: 6 }
expect(bboxFetchKey(bounds)).toBe(bboxFetchKey({ ...bounds, south: bounds.south + 0.05 }))
expect(bboxFetchKey({ ...bounds, zoom: 14 })).not.toBe(bboxFetchKey(bounds))
})
})
+29
View File
@@ -0,0 +1,29 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { clearSubscribers, registerSubscriber } from '../../../server/utils/cotSubscribers.js'
import {
isOpenSkyPollActive,
resetTrackingFeedForTests,
scheduleOpenSkyPollForTests,
} from '../../../server/utils/trackingFeed.js'
describe('trackingFeed', () => {
beforeEach(() => {
clearSubscribers()
resetTrackingFeedForTests()
})
it('does not start OpenSky poll without SSE subscribers', () => {
scheduleOpenSkyPollForTests()
expect(isOpenSkyPollActive()).toBe(false)
})
it('starts OpenSky poll when a subscriber is registered', () => {
registerSubscriber({
bbox: { west: -123, south: 37, east: -122, north: 38 },
layers: new Set(['air']),
push: vi.fn(),
})
scheduleOpenSkyPollForTests()
expect(isOpenSkyPollActive()).toBe(true)
})
})