Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cebeee4040 | |||
| bb01e9a06c | |||
| a6b87305a1 | |||
| 829db93065 | |||
| afaf305bda | |||
| 2a48715ea8 | |||
| 9d34f5f7c5 | |||
| 5b4bb6b33a | |||
| e16b492257 | |||
| 265e02119a | |||
| 82d454ade4 | |||
| a9b300d711 | |||
| fded3a04d4 | |||
| 0d897f17b5 | |||
| 216f6f83fe |
+12
-5
@@ -7,7 +7,7 @@ jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: https://git.keligrubb.com/actions/checkout@v6
|
||||
- uses: https://git.keligrubb.com/actions/checkout@v7
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: https://git.keligrubb.com/actions/setup-node@v6
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: https://git.keligrubb.com/actions/checkout@v6
|
||||
- uses: https://git.keligrubb.com/actions/checkout@v7
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: https://git.keligrubb.com/actions/setup-node@v6
|
||||
@@ -41,9 +41,9 @@ jobs:
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.58.2-noble
|
||||
image: mcr.microsoft.com/playwright:v1.61.1-noble
|
||||
steps:
|
||||
- uses: https://git.keligrubb.com/actions/checkout@v6
|
||||
- uses: https://git.keligrubb.com/actions/checkout@v7
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: https://git.keligrubb.com/actions/setup-node@v6
|
||||
@@ -65,7 +65,7 @@ jobs:
|
||||
docker-build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: https://git.keligrubb.com/actions/checkout@v6
|
||||
- uses: https://git.keligrubb.com/actions/checkout@v7
|
||||
|
||||
- name: Set Docker image tag
|
||||
id: image
|
||||
@@ -79,7 +79,14 @@ jobs:
|
||||
|
||||
- name: Build (dry run)
|
||||
uses: https://git.keligrubb.com/actions/docker-build-push-action@v7
|
||||
env:
|
||||
# Keeps GITHUB_OUTPUT small; Gitea act-runner can choke on multiline
|
||||
# outputs when PR webhook payloads (e.g. Renovate bodies) are huge.
|
||||
DOCKER_BUILD_SUMMARY: "false"
|
||||
DOCKER_BUILD_RECORD_UPLOAD: "false"
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
provenance: false
|
||||
sbom: false
|
||||
tags: ${{ steps.image.outputs.tag }}
|
||||
|
||||
@@ -5,13 +5,25 @@ on:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
release-docker-helm:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: https://git.keligrubb.com/actions/checkout@v6
|
||||
- uses: https://git.keligrubb.com/actions/checkout@v7
|
||||
with:
|
||||
token: ${{ secrets.KESTRELOS_REPO_TOKEN }}
|
||||
|
||||
- name: Get PR description for changelog
|
||||
env:
|
||||
GITEA_REPO_TOKEN: ${{ secrets.KESTRELOS_REPO_TOKEN }}
|
||||
run: |
|
||||
sudo rm -f /etc/apt/sources.list.d/microsoft*.list /etc/apt/sources.list.d/azure*.list 2>/dev/null || true
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq jq
|
||||
RESP=$(curl -sf -H "Authorization: token $GITEA_REPO_TOKEN" \
|
||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/commits/${{ github.sha }}/pull") || true
|
||||
if [ -n "$RESP" ]; then
|
||||
echo "$RESP" | jq -r '.body // empty' > .ci_pr_body 2>/dev/null || true
|
||||
fi
|
||||
|
||||
- name: Release (bump, tag, push, create release)
|
||||
env:
|
||||
CI_REPO_OWNER: ${{ github.actor }}
|
||||
@@ -20,9 +32,19 @@ jobs:
|
||||
CI_COMMIT_MESSAGE: ${{ github.event.head_commit.message }}
|
||||
GITEA_REPO_TOKEN: ${{ secrets.KESTRELOS_REPO_TOKEN }}
|
||||
run: |
|
||||
sudo rm -f /etc/apt/sources.list.d/microsoft*.list /etc/apt/sources.list.d/azure*.list 2>/dev/null || true
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq git wget
|
||||
./scripts/release.sh
|
||||
|
||||
publish:
|
||||
needs: release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: https://git.keligrubb.com/actions/checkout@v7
|
||||
with:
|
||||
ref: main
|
||||
token: ${{ secrets.KESTRELOS_REPO_TOKEN }}
|
||||
|
||||
- name: Log in to container registry
|
||||
uses: https://git.keligrubb.com/actions/docker-login-action@v4
|
||||
with:
|
||||
@@ -40,14 +62,19 @@ jobs:
|
||||
load: true
|
||||
tags: kestrelos:built
|
||||
|
||||
- name: Push Docker image (all tags from .tags)
|
||||
- name: Push Docker image (version + latest)
|
||||
run: |
|
||||
VERSION=$(awk '/"version"/ { match($0, /[0-9]+\.[0-9]+\.[0-9]+/); print substr($0, RSTART, RLENGTH); exit }' package.json)
|
||||
case "$VERSION" in
|
||||
[0-9]*.[0-9]*.[0-9]*) ;;
|
||||
*) echo "error: package.json version must be x.y.z (got: $VERSION)"; exit 1 ;;
|
||||
esac
|
||||
REGISTRY="git.keligrubb.com"
|
||||
IMAGE="$REGISTRY/${{ github.repository }}"
|
||||
while read -r tag; do
|
||||
for tag in "$VERSION" latest; do
|
||||
docker tag kestrelos:built "$IMAGE:$tag"
|
||||
docker push "$IMAGE:$tag"
|
||||
done < .tags
|
||||
done
|
||||
|
||||
- name: Set up Helm
|
||||
uses: https://git.keligrubb.com/actions/setup-helm@v5
|
||||
|
||||
+549
@@ -1,3 +1,552 @@
|
||||
## [1.1.6] - 2026-06-24
|
||||
### Changed
|
||||
- Add ADS-B, AIS, and ALPR map layers with live CoT streaming (#36)
|
||||
|
||||
## 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
|
||||
|
||||
## [1.1.5] - 2026-06-21
|
||||
### Changed
|
||||
- update https://git.keligrubb.com/actions/checkout action to v7 (#35)
|
||||
|
||||
This PR contains the following updates:
|
||||
|
||||
| Package | Type | Update | Change |
|
||||
|---|---|---|---|
|
||||
| [https://git.keligrubb.com/actions/checkout](https://git.keligrubb.com/actions/checkout) | action | major | `v6` → `v7` |
|
||||
|
||||
---
|
||||
|
||||
### Release Notes
|
||||
|
||||
<details>
|
||||
<summary>actions/checkout (https://git.keligrubb.com/actions/checkout)</summary>
|
||||
|
||||
### [`v7.0.0`](https://git.keligrubb.com/actions/checkout/blob/HEAD/CHANGELOG.md#v700)
|
||||
|
||||
[Compare Source](https://git.keligrubb.com/actions/checkout/compare/v7...v7)
|
||||
|
||||
- Block checking out fork PR for pull\_request\_target and workflow\_run by [@​aiqiaoy](https://github.com/aiqiaoy) in [#​2454](https://github.com/actions/checkout/pull/2454)
|
||||
- Bump actions/publish-immutable-action from 0.0.3 to 0.0.4 in the minor-actions-dependencies group across 1 directory by [@​dependabot](https://github.com/dependabot)\[bot] in [#​2458](https://github.com/actions/checkout/pull/2458)
|
||||
- Bump flatted from 3.3.1 to 3.4.2 by [@​dependabot](https://github.com/dependabot)\[bot] in [#​2460](https://github.com/actions/checkout/pull/2460)
|
||||
- Bump js-yaml from 4.1.0 to 4.2.0 by [@​dependabot](https://github.com/dependabot)\[bot] in [#​2461](https://github.com/actions/checkout/pull/2461)
|
||||
- Bump [@​actions/core](https://github.com/actions/core) and [@​actions/tool-cache](https://github.com/actions/tool-cache) and Remove uuid by [@​dependabot](https://github.com/dependabot)\[bot] in [#​2459](https://github.com/actions/checkout/pull/2459)
|
||||
- upgrade module to esm and update dependencies by [@​aiqiaoy](https://github.com/aiqiaoy) in [#​2463](https://github.com/actions/checkout/pull/2463)
|
||||
- Bump the minor-npm-dependencies group across 1 directory with 3 updates by [@​dependabot](https://github.com/dependabot)\[bot] in [#​2462](https://github.com/actions/checkout/pull/2462)
|
||||
|
||||
### [`v7`](https://git.keligrubb.com/actions/checkout/blob/HEAD/CHANGELOG.md#v700)
|
||||
|
||||
[Compare Source](https://git.keligrubb.com/actions/checkout/compare/v6.0.3...v7)
|
||||
|
||||
- Block checking out fork PR for pull\_request\_target and workflow\_run by [@​aiqiaoy](https://github.com/aiqiaoy) in [#​2454](https://github.com/actions/checkout/pull/2454)
|
||||
- Bump actions/publish-immutable-action from 0.0.3 to 0.0.4 in the minor-actions-dependencies group across 1 directory by [@​dependabot](https://github.com/dependabot)\[bot] in [#​2458](https://github.com/actions/checkout/pull/2458)
|
||||
- Bump flatted from 3.3.1 to 3.4.2 by [@​dependabot](https://github.com/dependabot)\[bot] in [#​2460](https://github.com/actions/checkout/pull/2460)
|
||||
- Bump js-yaml from 4.1.0 to 4.2.0 by [@​dependabot](https://github.com/dependabot)\[bot] in [#​2461](https://github.com/actions/checkout/pull/2461)
|
||||
- Bump [@​actions/core](https://github.com/actions/core) and [@​actions/tool-cache](https://github.com/actions/tool-cache) and Remove uuid by [@​dependabot](https://github.com/dependabot)\[bot] in [#​2459](https://github.com/actions/checkout/pull/2459)
|
||||
- upgrade module to esm and update dependencies by [@​aiqiaoy](https://github.com/aiqiaoy) in [#​2463](https://github.com/actions/checkout/pull/2463)
|
||||
- Bump the minor-npm-dependencies group across 1 directory with 3 updates by [@​dependabot](https://github.com/dependabot)\[bot] in [#​2462](https://github.com/actions/checkout/pull/2462)
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box
|
||||
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4yMzMuNCIsInVwZGF0ZWRJblZlciI6IjQzLjIzMy40IiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->
|
||||
|
||||
## [1.1.4] - 2026-04-29
|
||||
### Changed
|
||||
- update all non-major dependencies (#31)
|
||||
|
||||
This PR contains the following updates:
|
||||
|
||||
| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|
||||
|---|---|---|---|
|
||||
| [@vitest/coverage-v8](https://vitest.dev/guide/coverage) ([source](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8)) | [`4.1.4` → `4.1.5`](https://renovatebot.com/diffs/npm/@vitest%2fcoverage-v8/4.1.4/4.1.5) |  |  |
|
||||
| [@vue/test-utils](https://github.com/vuejs/test-utils) | [`2.4.6` → `2.4.8`](https://renovatebot.com/diffs/npm/@vue%2ftest-utils/2.4.6/2.4.8) |  |  |
|
||||
| [eslint](https://eslint.org) ([source](https://github.com/eslint/eslint)) | [`10.2.0` → `10.2.1`](https://renovatebot.com/diffs/npm/eslint/10.2.0/10.2.1) |  |  |
|
||||
| [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) | [`5.6.0` → `5.7.2`](https://renovatebot.com/diffs/npm/fast-xml-parser/5.6.0/5.7.2) |  |  |
|
||||
| [mediasoup](https://mediasoup.org) ([source](https://github.com/versatica/mediasoup)) | [`3.19.19` → `3.19.21`](https://renovatebot.com/diffs/npm/mediasoup/3.19.19/3.19.21) |  |  |
|
||||
| [mediasoup-client](https://mediasoup.org) ([source](https://github.com/versatica/mediasoup-client)) | [`3.18.8` → `3.19.0`](https://renovatebot.com/diffs/npm/mediasoup-client/3.18.8/3.19.0) |  |  |
|
||||
| [vitest](https://vitest.dev) ([source](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest)) | [`4.1.4` → `4.1.5`](https://renovatebot.com/diffs/npm/vitest/4.1.4/4.1.5) |  |  |
|
||||
| [vue](https://vuejs.org/) ([source](https://github.com/vuejs/core)) | [`3.5.32` → `3.5.33`](https://renovatebot.com/diffs/npm/vue/3.5.32/3.5.33) |  |  |
|
||||
| [vue-router](https://router.vuejs.org) ([source](https://github.com/vuejs/router)) | [`5.0.4` → `5.0.6`](https://renovatebot.com/diffs/npm/vue-router/5.0.4/5.0.6) |  |  |
|
||||
|
||||
---
|
||||
|
||||
### Release Notes
|
||||
|
||||
<details>
|
||||
<summary>vitest-dev/vitest (@​vitest/coverage-v8)</summary>
|
||||
|
||||
### [`v4.1.5`](https://github.com/vitest-dev/vitest/releases/tag/v4.1.5)
|
||||
|
||||
[Compare Source](https://github.com/vitest-dev/vitest/compare/v4.1.4...v4.1.5)
|
||||
|
||||
##### 🚀 Experimental Features
|
||||
|
||||
- **coverage**: Istanbul to support `instrumenter` option - by [@​BartWaardenburg](https://github.com/BartWaardenburg) and [@​AriPerkkio](https://github.com/AriPerkkio) in [#​10119](https://github.com/vitest-dev/vitest/issues/10119) [<samp>(0e0ff)</samp>](https://github.com/vitest-dev/vitest/commit/0e0ff41c7)
|
||||
|
||||
##### 🐞 Bug Fixes
|
||||
|
||||
- \--project negation excludes browser instances - by [@​felamaslen](https://github.com/felamaslen) in [#​10131](https://github.com/vitest-dev/vitest/issues/10131) [<samp>(9423d)</samp>](https://github.com/vitest-dev/vitest/commit/9423dc084)
|
||||
- Project color label on html reporter - by [@​hi-ogawa](https://github.com/hi-ogawa) in [#​10142](https://github.com/vitest-dev/vitest/issues/10142) [<samp>(596f7)</samp>](https://github.com/vitest-dev/vitest/commit/596f73986)
|
||||
- Fix `vi.defineHelper` called as object method - by [@​hi-ogawa](https://github.com/hi-ogawa) in [#​10163](https://github.com/vitest-dev/vitest/issues/10163) [<samp>(122c2)</samp>](https://github.com/vitest-dev/vitest/commit/122c25b5b)
|
||||
- Alias `agent` reporter to `minimal` - by [@​sheremet-va](https://github.com/sheremet-va) in [#​10157](https://github.com/vitest-dev/vitest/issues/10157) [<samp>(663b9)</samp>](https://github.com/vitest-dev/vitest/commit/663b99fe3)
|
||||
- Respect diff config options in soft assertions - by [@​Copilot](https://github.com/Copilot), **sheremet-va** and [@​sheremet-va](https://github.com/sheremet-va) in [#​8696](https://github.com/vitest-dev/vitest/issues/8696) [<samp>(9787d)</samp>](https://github.com/vitest-dev/vitest/commit/9787dedad)
|
||||
- Respect diff config options in soft assertions " - by [@​sheremet-va](https://github.com/sheremet-va) in [#​8696](https://github.com/vitest-dev/vitest/issues/8696) [<samp>(7dc6d)</samp>](https://github.com/vitest-dev/vitest/commit/7dc6d54fd)
|
||||
- **ast-collect**: Recognize \_*vi\_import* prefix in static test discovery - by [@​Yejneshwar](https://github.com/Yejneshwar) in [#​10129](https://github.com/vitest-dev/vitest/issues/10129) [<samp>(32546)</samp>](https://github.com/vitest-dev/vitest/commit/325463ab2)
|
||||
- **coverage**: Descriptive error message when reports directory is removed during test run - by [@​DaveT1991](https://github.com/DaveT1991) and [@​AriPerkkio](https://github.com/AriPerkkio) in [#​10117](https://github.com/vitest-dev/vitest/issues/10117) [<samp>(14133)</samp>](https://github.com/vitest-dev/vitest/commit/1413382e1)
|
||||
- **snapshot**: Increase default snapshot max output length - by [@​hi-ogawa](https://github.com/hi-ogawa) and **Codex** in [#​10150](https://github.com/vitest-dev/vitest/issues/10150) [<samp>(21e66)</samp>](https://github.com/vitest-dev/vitest/commit/21e66ff63)
|
||||
- **ui**: Fix jsx/tsx syntax highlight - by [@​hi-ogawa](https://github.com/hi-ogawa) in [#​10152](https://github.com/vitest-dev/vitest/issues/10152) [<samp>(f1b1f)</samp>](https://github.com/vitest-dev/vitest/commit/f1b1f6c7b)
|
||||
- **web-worker**: Support MessagePort objects referenced inside postMessage data - by [@​whitphx](https://github.com/whitphx) and **Claude Opus 4.6 (1M context)** in [#​9927](https://github.com/vitest-dev/vitest/issues/9927) and [#​10124](https://github.com/vitest-dev/vitest/issues/10124) [<samp>(7ad7d)</samp>](https://github.com/vitest-dev/vitest/commit/7ad7d39af)
|
||||
- **api**: Make test-specification options writable - by [@​sheremet-va](https://github.com/sheremet-va) in [#​10154](https://github.com/vitest-dev/vitest/issues/10154) [<samp>(6abd5)</samp>](https://github.com/vitest-dev/vitest/commit/6abd557b7)
|
||||
|
||||
##### [View changes on GitHub](https://github.com/vitest-dev/vitest/compare/v4.1.4...v4.1.5)
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>vuejs/test-utils (@​vue/test-utils)</summary>
|
||||
|
||||
### [`v2.4.8`](https://github.com/vuejs/test-utils/releases/tag/v2.4.8)
|
||||
|
||||
[Compare Source](https://github.com/vuejs/test-utils/compare/v2.4.7...v2.4.8)
|
||||
|
||||
[compare changes](https://github.com/vuejs/test-utils/compare/v2.4.7...v2.4.8)
|
||||
|
||||
##### 🩹 Fixes
|
||||
|
||||
- Correct declaration entrypoint ([#​2826](https://github.com/vuejs/test-utils/pull/2826))
|
||||
|
||||
##### 🤖 CI
|
||||
|
||||
- Enable pkg.pr.new ([#​2827](https://github.com/vuejs/test-utils/pull/2827))
|
||||
|
||||
##### ❤️ Contributors
|
||||
|
||||
- Cédric Exbrayat ([@​cexbrayat](https://github.com/cexbrayat))
|
||||
- Daniel Roe ([@​danielroe](https://github.com/danielroe))
|
||||
|
||||
### [`v2.4.7`](https://github.com/vuejs/test-utils/releases/tag/v2.4.7)
|
||||
|
||||
[Compare Source](https://github.com/vuejs/test-utils/compare/v2.4.6...v2.4.7)
|
||||
|
||||
[compare changes](https://github.com/vuejs/test-utils/compare/v2.4.6...v2.4.7)
|
||||
|
||||
##### 🚀 Enhancements
|
||||
|
||||
- Add Chinese docs translation ([#​2552](https://github.com/vuejs/test-utils/pull/2552))
|
||||
- SetData()/shallowMount with initialData for components using the Composition API / <script setup> ([#​2655](https://github.com/vuejs/test-utils/pull/2655))
|
||||
|
||||
##### 🩹 Fixes
|
||||
|
||||
- Preserve code from keyboard events ([#​2434](https://github.com/vuejs/test-utils/pull/2434))
|
||||
- Switch browser and require exports definitions ([#​2501](https://github.com/vuejs/test-utils/pull/2501))
|
||||
- Re-add peer dependencies but with wider range ([#​2511](https://github.com/vuejs/test-utils/pull/2511))
|
||||
- Resolve warnings in docs:dev ([30b7491](https://github.com/vuejs/test-utils/commit/30b7491))
|
||||
- Resolve TypeScript type errors in .vitepress/config ([#​2549](https://github.com/vuejs/test-utils/pull/2549))
|
||||
- Accept FunctionalComponent<any> as selector ([0bb947f](https://github.com/vuejs/test-utils/commit/0bb947f))
|
||||
- Text() misses content for array functional component ([#​2579](https://github.com/vuejs/test-utils/pull/2579))
|
||||
- Use await in test ([c5482b4](https://github.com/vuejs/test-utils/commit/c5482b4))
|
||||
- **deps:** Update dependency vue-component-type-helpers to v3 ([#​2683](https://github.com/vuejs/test-utils/pull/2683))
|
||||
- Remove wrapper div when unmount ([#​2700](https://github.com/vuejs/test-utils/pull/2700))
|
||||
- Make mount options slots compatible with noUncheckedIndexedAccess true ([#​2713](https://github.com/vuejs/test-utils/pull/2713))
|
||||
- Add missing peerDependency [@​vue/compiler-dom](https://github.com/vue/compiler-dom) ([75801ba](https://github.com/vuejs/test-utils/commit/75801ba))
|
||||
- **docs:** Declare css module for vitepress typecheck ([ddaca97](https://github.com/vuejs/test-utils/commit/ddaca97))
|
||||
|
||||
##### 💅 Refactors
|
||||
|
||||
- Enforce consistent usage of type imports ([#​2734](https://github.com/vuejs/test-utils/pull/2734))
|
||||
|
||||
##### 📖 Documentation
|
||||
|
||||
- Clarify findComponent vs getComponent ([#​2435](https://github.com/vuejs/test-utils/pull/2435))
|
||||
- Update fr docs ([67064ef](https://github.com/vuejs/test-utils/commit/67064ef))
|
||||
- Add note about partial transition stub support ([#​2431](https://github.com/vuejs/test-utils/pull/2431))
|
||||
- Fix missing data at passing data section essentials guide ([dda205e](https://github.com/vuejs/test-utils/commit/dda205e))
|
||||
- Fix missing data at passing data section essentials guide fr ([ae2c72c](https://github.com/vuejs/test-utils/commit/ae2c72c))
|
||||
- Fix plugin TS declaration example ([#​2466](https://github.com/vuejs/test-utils/pull/2466))
|
||||
- Fixed incorrect checkbox value check ([#​2495](https://github.com/vuejs/test-utils/pull/2495))
|
||||
- Capital letter in sentence fix ([#​2499](https://github.com/vuejs/test-utils/pull/2499))
|
||||
- Import missing DOMWrapper on Implementation of the plugin section ([#​2519](https://github.com/vuejs/test-utils/pull/2519))
|
||||
- Add migration step for deprecated ref syntax in findAllComponents ([#​2498](https://github.com/vuejs/test-utils/pull/2498))
|
||||
- Correct anchor hash links and fix typo ([#​2551](https://github.com/vuejs/test-utils/pull/2551))
|
||||
- Center logo on home ([#​2559](https://github.com/vuejs/test-utils/pull/2559))
|
||||
- **zh-cn:** Review a-crash-course ([#​2563](https://github.com/vuejs/test-utils/pull/2563))
|
||||
- Use code-group for install commands ([#​2571](https://github.com/vuejs/test-utils/pull/2571))
|
||||
- **zh-cn:** Review event-handing.md ([#​2572](https://github.com/vuejs/test-utils/pull/2572))
|
||||
- **zh-cn:** Enhance conditional-rendering.md ([#​2562](https://github.com/vuejs/test-utils/pull/2562))
|
||||
- **zh-cn:** Review easy-to-test ([#​2567](https://github.com/vuejs/test-utils/pull/2567))
|
||||
- **zh-cn:** Review passing-data.md ([#​2575](https://github.com/vuejs/test-utils/pull/2575))
|
||||
- **zh-cn:** Review async-suspense.md ([#​2576](https://github.com/vuejs/test-utils/pull/2576))
|
||||
- **zh:** 优化 API 文档格式和内容 ([#​2569](https://github.com/vuejs/test-utils/pull/2569))
|
||||
- **zh:** 更新 Vitest 模拟日期和计时器的说明 ([#​2578](https://github.com/vuejs/test-utils/pull/2578))
|
||||
- **zh-cn:** Review http-requests.md ([#​2580](https://github.com/vuejs/test-utils/pull/2580))
|
||||
- **zh-cn:** Review forms ([#​2582](https://github.com/vuejs/test-utils/pull/2582))
|
||||
- **zh-cn:** Guide/advanced/slots.md ([#​2565](https://github.com/vuejs/test-utils/pull/2565))
|
||||
- **zh:** Review extending-vtu ([#​2583](https://github.com/vuejs/test-utils/pull/2583))
|
||||
- **zh:** Review index ([#​2584](https://github.com/vuejs/test-utils/pull/2584))
|
||||
- Fix modelValue test example ([85bfdf4](https://github.com/vuejs/test-utils/commit/85bfdf4))
|
||||
- Removes broken link from plugins.md ([69bc1ce](https://github.com/vuejs/test-utils/commit/69bc1ce))
|
||||
- **zh:** Review transitions, component-instance, and reusability-composition ([#​2616](https://github.com/vuejs/test-utils/pull/2616))
|
||||
- **zh:** Review v-model and vuex ([#​2617](https://github.com/vuejs/test-utils/pull/2617))
|
||||
- **zh:** Review all the rest advanced guide ([#​2619](https://github.com/vuejs/test-utils/pull/2619))
|
||||
- **zh:** Review migration ([#​2623](https://github.com/vuejs/test-utils/pull/2623))
|
||||
- Fix a typo in transitions.md ([#​2635](https://github.com/vuejs/test-utils/pull/2635))
|
||||
- Update crash-course to script setup ([c81aa79](https://github.com/vuejs/test-utils/commit/c81aa79))
|
||||
- Update Essentials section to setup (composition api) ([#​2647](https://github.com/vuejs/test-utils/pull/2647))
|
||||
- Typos in examples ([#​2678](https://github.com/vuejs/test-utils/pull/2678))
|
||||
- Typo in easy-to-test.md ([#​2710](https://github.com/vuejs/test-utils/pull/2710))
|
||||
- Add note about mocking requestAnimationFrame for transitions ([2324c65](https://github.com/vuejs/test-utils/commit/2324c65))
|
||||
- Updated example TodoApp to script setup ([#​2727](https://github.com/vuejs/test-utils/pull/2727))
|
||||
- Remove "Using data" section from "Conditional Rendering" guide and fix passing data test example ([#​2743](https://github.com/vuejs/test-utils/pull/2743))
|
||||
- Follow-up fixes for the conditional rendering guide ([#​2744](https://github.com/vuejs/test-utils/pull/2744))
|
||||
- Mention shallowMount stub name changes in migration guide ([80e051a](https://github.com/vuejs/test-utils/commit/80e051a))
|
||||
- Update conditional rendering documentation to clarify isVisible() usage with attachTo ([#​2799](https://github.com/vuejs/test-utils/pull/2799))
|
||||
- Restore Options API component for data() mounting example ([#​2804](https://github.com/vuejs/test-utils/pull/2804))
|
||||
- Promote Vitest as recommended test runner ([#​2805](https://github.com/vuejs/test-utils/pull/2805))
|
||||
- **api:** Note that setValue does not accept objects on `<select>` ([#​2819](https://github.com/vuejs/test-utils/pull/2819))
|
||||
|
||||
##### 🏡 Chore
|
||||
|
||||
- Add api/index.md to docs:translation:compare ([6b8681c](https://github.com/vuejs/test-utils/commit/6b8681c))
|
||||
- Remove unnecessary generic arguments ([cfd70c6](https://github.com/vuejs/test-utils/commit/cfd70c6))
|
||||
- Ignore TS error in config object ([9d0a618](https://github.com/vuejs/test-utils/commit/9d0a618))
|
||||
- Simplify eslint packages ([c1d0ffd](https://github.com/vuejs/test-utils/commit/c1d0ffd))
|
||||
- Use eslint v9 with flat config ([2f19fdf](https://github.com/vuejs/test-utils/commit/2f19fdf))
|
||||
- Expose Stubs type publicly ([#​2492](https://github.com/vuejs/test-utils/pull/2492))
|
||||
- Update documentation file path ([9c96594](https://github.com/vuejs/test-utils/commit/9c96594))
|
||||
- Use pnpm v10 ([e4c2cb3](https://github.com/vuejs/test-utils/commit/e4c2cb3))
|
||||
- Pnpm approve build ([81c54e9](https://github.com/vuejs/test-utils/commit/81c54e9))
|
||||
- Use github issue forms ([#​2673](https://github.com/vuejs/test-utils/pull/2673))
|
||||
- Exclude class components from test type-checking ([0899008](https://github.com/vuejs/test-utils/commit/0899008))
|
||||
- Add explicit coverage include for vitest v4 ([51672b9](https://github.com/vuejs/test-utils/commit/51672b9))
|
||||
- Update to prettier v3.7 ([fed9e7c](https://github.com/vuejs/test-utils/commit/fed9e7c))
|
||||
- Migrate to oxfmt ([81c1de9](https://github.com/vuejs/test-utils/commit/81c1de9))
|
||||
- Migrate to oxlint ([a361908](https://github.com/vuejs/test-utils/commit/a361908))
|
||||
- Prepare TypeScript 6 migration settings ([55e1262](https://github.com/vuejs/test-utils/commit/55e1262))
|
||||
- Adjust tsd config for TypeScript 6 ([7d23eb5](https://github.com/vuejs/test-utils/commit/7d23eb5))
|
||||
- Avoid TypeScript 6 target deprecation warning ([81d063c](https://github.com/vuejs/test-utils/commit/81d063c))
|
||||
|
||||
##### 🤖 CI
|
||||
|
||||
- Remove node v22 build ([7ebf58d](https://github.com/vuejs/test-utils/commit/7ebf58d))
|
||||
- Add node v22 build ([57540ee](https://github.com/vuejs/test-utils/commit/57540ee))
|
||||
- Use "pool: threads" instead of vmThreads ([d0cbb54](https://github.com/vuejs/test-utils/commit/d0cbb54))
|
||||
- Remove node v18 and add v24 ([fd9cf95](https://github.com/vuejs/test-utils/commit/fd9cf95))
|
||||
- Add trusted publishing release workflow ([#​2825](https://github.com/vuejs/test-utils/pull/2825))
|
||||
|
||||
##### ❤️ Contributors
|
||||
|
||||
- Lachlan Miller ([@​lmiller1990](https://github.com/lmiller1990))
|
||||
- cexbrayat ([@​cexbrayat](https://github.com/cexbrayat))
|
||||
- Nicolas Bonamy ([@​nbonamy](https://github.com/nbonamy))
|
||||
- KatWorkGit ([@​KatWorkGit](https://github.com/KatWorkGit))
|
||||
- Wouter Kroes ([@​wouterkroes](https://github.com/wouterkroes))
|
||||
- Rama Muhammad Murshal ([@​ramammurshal](https://github.com/ramammurshal))
|
||||
- Evan You ([@​yyx990803](https://github.com/yyx990803))
|
||||
- Vlad Starkovsky ([@​starkovsky](https://github.com/starkovsky))
|
||||
- Joe ([@​joaoprp](https://github.com/joaoprp))
|
||||
- Priyadarshi Kumar ([@​Psingh132](https://github.com/Psingh132))
|
||||
- Sébastien Ronveaux ([@​sronveaux](https://github.com/sronveaux))
|
||||
- Gilliam ([@​Gi11i4m](https://github.com/Gi11i4m))
|
||||
- Baranov Dmytro ([@​dimas7001](https://github.com/dimas7001))
|
||||
- BrendonHenrique ([@​BrendonHenrique](https://github.com/BrendonHenrique))
|
||||
- Lorenz van Herwaarden ([@​lorenzvanherwaarden](https://github.com/lorenzvanherwaarden))
|
||||
- wuzhiqing ([@​DDDDD12138](https://github.com/DDDDD12138))
|
||||
- 阿菜 Cai ([@​RSS1102](https://github.com/RSS1102))
|
||||
- Jinjiang ([@​Jinjiang](https://github.com/Jinjiang))
|
||||
- Kylin ([@​lxKylin](https://github.com/lxKylin))
|
||||
- Qianhe Chen ([@​chenqianhe](https://github.com/chenqianhe))
|
||||
- 时瑶 ([@​KiritaniAyaka](https://github.com/KiritaniAyaka))
|
||||
- h7ml ([@​h7ml](https://github.com/h7ml))
|
||||
- Nicander ([@​Nicander93](https://github.com/Nicander93))
|
||||
- Take-John ([@​takejohn](https://github.com/takejohn))
|
||||
- ilyasherstoboev ([@​ilyasherstoboev](https://github.com/ilyasherstoboev))
|
||||
- aimerie ([@​aimerie](https://github.com/aimerie))
|
||||
- Miguel Rincon ([@​miguelrincon](https://github.com/miguelrincon))
|
||||
- bcastlel ([@​bcastlel](https://github.com/bcastlel))
|
||||
- Claudiu ([@​sofuxro](https://github.com/sofuxro))
|
||||
- Artem Dragunov ([@​dragunovartem99](https://github.com/dragunovartem99))
|
||||
- Robin ([@​OrbisK](https://github.com/OrbisK))
|
||||
- Koen Mertens ([@​KCMertens](https://github.com/KCMertens))
|
||||
- meomking ([@​CaptainWang98](https://github.com/CaptainWang98))
|
||||
- Pepijn Olivier ([@​pepijnolivier](https://github.com/pepijnolivier))
|
||||
- Tomina ([@​Thomaash](https://github.com/Thomaash))
|
||||
- Gareth Jones ([@​G-Rath](https://github.com/G-Rath))
|
||||
- Jerry Hogan ([@​hdJerry](https://github.com/hdJerry))
|
||||
- Marco Pasqualetti ([@​marcalexiei](https://github.com/marcalexiei))
|
||||
- guoxk ([@​guoxk-me](https://github.com/guoxk-me))
|
||||
- kimulaco ([@​kimulaco](https://github.com/kimulaco))
|
||||
- Erwan IQUEL ([@​Olympus5](https://github.com/Olympus5))
|
||||
- Matt Van Horn ([@​mvanhorn](https://github.com/mvanhorn))
|
||||
- Daniel Roe ([@​danielroe](https://github.com/danielroe))
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>eslint/eslint (eslint)</summary>
|
||||
|
||||
### [`v10.2.1`](https://github.com/eslint/eslint/releases/tag/v10.2.1)
|
||||
|
||||
[Compare Source](https://github.com/eslint/eslint/compare/v10.2.0...v10.2.1)
|
||||
|
||||
#### Bug Fixes
|
||||
|
||||
- [`14be92b`](https://github.com/eslint/eslint/commit/14be92b6d1fa0923b8923830f2208e5e2705b002) fix: model generator yield resumption paths in code path analysis ([#​20665](https://github.com/eslint/eslint/issues/20665)) (sethamus)
|
||||
- [`84a19d2`](https://github.com/eslint/eslint/commit/84a19d2c32255db6b9cfc08644a607aae6d5cb62) fix: no-async-promise-executor false positives for shadowed Promise ([#​20740](https://github.com/eslint/eslint/issues/20740)) (xbinaryx)
|
||||
- [`af764af`](https://github.com/eslint/eslint/commit/af764af0ec38225755fbf8a6f207f0c77b595a8d) fix: clarify language and processor validation errors ([#​20729](https://github.com/eslint/eslint/issues/20729)) (Pixel998)
|
||||
- [`e251b89`](https://github.com/eslint/eslint/commit/e251b89a38280973e468a4a9386c138f4f55d10d) fix: update eslint ([#​20715](https://github.com/eslint/eslint/issues/20715)) (renovate\[bot])
|
||||
|
||||
#### Documentation
|
||||
|
||||
- [`ca92ca0`](https://github.com/eslint/eslint/commit/ca92ca0fb4599e8de1e2fb914e695fe7397cbe63) docs: reuse markdown-it instance for markdown filter ([#​20768](https://github.com/eslint/eslint/issues/20768)) (Amaresh S M)
|
||||
- [`57d2ee2`](https://github.com/eslint/eslint/commit/57d2ee213305cee0cb55ef08e0480b57396269a9) docs: Enable Eleventy incremental mode for watch ([#​20767](https://github.com/eslint/eslint/issues/20767)) (Amaresh S M)
|
||||
- [`c1621b9`](https://github.com/eslint/eslint/commit/c1621b915742276e5f4b25efe790ca62296330dc) docs: fix typos in code-path-analyzer.js ([#​20700](https://github.com/eslint/eslint/issues/20700)) (Ayush Shukla)
|
||||
- [`1418d52`](https://github.com/eslint/eslint/commit/1418d522d10bde1960f4942afb548bc7160ec49e) docs: Update README (GitHub Actions Bot)
|
||||
- [`39771e6`](https://github.com/eslint/eslint/commit/39771e6e600f0b0617fdeafff6dd07e4211ffde6) docs: Update README (GitHub Actions Bot)
|
||||
- [`71e0469`](https://github.com/eslint/eslint/commit/71e04693def2df57268f08f3072a2749df6bf438) docs: fix incomplete JSDoc param description in no-shadow rule ([#​20728](https://github.com/eslint/eslint/issues/20728)) (kuldeep kumar)
|
||||
- [`22119ce`](https://github.com/eslint/eslint/commit/22119ceb93e28f62262fc1d98ff1b1442d6e2dbf) docs: clarify scope of for-direction rule with dead code examples ([#​20723](https://github.com/eslint/eslint/issues/20723)) (Amaresh S M)
|
||||
- [`8f3fb77`](https://github.com/eslint/eslint/commit/8f3fb77f122a5641d1833cad5d93f3f54fa3be0b) docs: document `meta.docs.dialects` ([#​20718](https://github.com/eslint/eslint/issues/20718)) (Pixel998)
|
||||
|
||||
#### Chores
|
||||
|
||||
- [`7ddfea9`](https://github.com/eslint/eslint/commit/7ddfea9c4f62add1588c5c0b0da568c299246383) chore: update dependency prettier to v3.8.2 ([#​20770](https://github.com/eslint/eslint/issues/20770)) (renovate\[bot])
|
||||
- [`fac40e1`](https://github.com/eslint/eslint/commit/fac40e1de2ba7646cc7cd2d3f93fbdd1f8819001) ci: bump pnpm/action-setup from 5.0.0 to 6.0.0 ([#​20763](https://github.com/eslint/eslint/issues/20763)) (dependabot\[bot])
|
||||
- [`7246f92`](https://github.com/eslint/eslint/commit/7246f923332522d8b3d46b6ee646fce88535f3fb) test: add tests for SuppressionsService.load() error handling ([#​20734](https://github.com/eslint/eslint/issues/20734)) (kuldeep kumar)
|
||||
- [`4f34b1e`](https://github.com/eslint/eslint/commit/4f34b1e592b0f63d766d9903998e8e36eb49d3aa) chore: update pnpm/action-setup action to v5 ([#​20762](https://github.com/eslint/eslint/issues/20762)) (renovate\[bot])
|
||||
- [`51080eb`](https://github.com/eslint/eslint/commit/51080eb5c98d619434e4835dbe9f1c6654aca3b8) test: processor service ([#​20731](https://github.com/eslint/eslint/issues/20731)) (kuldeep kumar)
|
||||
- [`e7e1889`](https://github.com/eslint/eslint/commit/e7e1889fca9b6044e08f41b38df20a1ce45808c8) chore: remove stale babel-eslint10 fixture and test ([#​20727](https://github.com/eslint/eslint/issues/20727)) (kuldeep kumar)
|
||||
- [`4e1a87c`](https://github.com/eslint/eslint/commit/4e1a87cb8fb90e309524bc36bc5f31b9f9cfaa76) test: remove redundant async/await in flat config array tests ([#​20722](https://github.com/eslint/eslint/issues/20722)) (Pixel998)
|
||||
- [`066eabb`](https://github.com/eslint/eslint/commit/066eabb3643b12931f991594969bcc0028f71a5f) test: add rule metadata coverage for `languages` and `docs.dialects` ([#​20717](https://github.com/eslint/eslint/issues/20717)) (Pixel998)
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>NaturalIntelligence/fast-xml-parser (fast-xml-parser)</summary>
|
||||
|
||||
### [`v5.7.2`](https://github.com/NaturalIntelligence/fast-xml-parser/releases/tag/v5.7.2): backward compatibility for numerical external entity, fix #​705, #​817
|
||||
|
||||
[Compare Source](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.7.1...v5.7.2)
|
||||
|
||||
- allow numerical external entity for backward compatibility
|
||||
- fix [#​705](https://github.com/NaturalIntelligence/fast-xml-parser/issues/705): attributesGroupName working with preserveOrder
|
||||
- fix [#​817](https://github.com/NaturalIntelligence/fast-xml-parser/issues/817): stackoverflow when tag expression is very long
|
||||
|
||||
### [`v5.7.1`](https://github.com/NaturalIntelligence/fast-xml-parser/releases/tag/v5.7.1): upgrade @​nodable/entities and FXB
|
||||
|
||||
[Compare Source](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.7.0...v5.7.1)
|
||||
|
||||
- Use `@nodable/entities` v2.1.0
|
||||
- breaking changes
|
||||
- single entity scan. You're not allowed to use entity value to form another entity name.
|
||||
- you cant add numeric external entity
|
||||
- entity error message when expantion limit is crossed might change
|
||||
- typings are updated for new options related to process entity
|
||||
- please follow documentation of `@nodable/entities` for more detail.
|
||||
- performance
|
||||
- if processEntities is false, then there should not be impact on performance.
|
||||
- if processEntities is true, but you dont pass entity decoder separately then performance may degrade by approx 8-10%
|
||||
- if processEntities is true, and you pass entity decoder separately
|
||||
- if no entity then performance should be same as before
|
||||
- if there are entities then performance should be increased from past versions
|
||||
- ignoreAttributes is not required to be set to set xml version for NCR entity value
|
||||
- update 'fast-xml-builder' to sanitize malicious CDATA and comment's content
|
||||
|
||||
### [`v5.7.0`](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.6.0...v5.7.0)
|
||||
|
||||
[Compare Source](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.6.0...v5.7.0)
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>versatica/mediasoup (mediasoup)</summary>
|
||||
|
||||
### [`v3.19.21`](https://github.com/versatica/mediasoup/blob/HEAD/CHANGELOG.md#31921)
|
||||
|
||||
[Compare Source](https://github.com/versatica/mediasoup/compare/3.19.20...3.19.21)
|
||||
|
||||
- Worker: Fix regression in `DirectTransport` when closing a `DataProducer` or `DataConsumer` ([PR #​1780](https://github.com/versatica/mediasoup/pull/1780)).
|
||||
|
||||
### [`v3.19.20`](https://github.com/versatica/mediasoup/blob/HEAD/CHANGELOG.md#31920)
|
||||
|
||||
[Compare Source](https://github.com/versatica/mediasoup/compare/3.19.19...3.19.20)
|
||||
|
||||
- Worker: Add `useBuiltInSctpStack` setting (defaults to `false`) to enable mediasoup built-in SCTP stack ([PR #​1777](https://github.com/versatica/mediasoup/pull/1777)).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>versatica/mediasoup-client (mediasoup-client)</summary>
|
||||
|
||||
### [`v3.19.0`](https://github.com/versatica/mediasoup-client/compare/3.18.8...3.19.0)
|
||||
|
||||
[Compare Source](https://github.com/versatica/mediasoup-client/compare/3.18.8...3.19.0)
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>vuejs/core (vue)</summary>
|
||||
|
||||
### [`v3.5.33`](https://github.com/vuejs/core/blob/HEAD/CHANGELOG.md#3533-2026-04-22)
|
||||
|
||||
[Compare Source](https://github.com/vuejs/core/compare/v3.5.32...v3.5.33)
|
||||
|
||||
##### Bug Fixes
|
||||
|
||||
- **compiler-sfc:** handle nested :deep in selector pseudos ([#​14725](https://github.com/vuejs/core/issues/14725)) ([bb9d265](https://github.com/vuejs/core/commit/bb9d265d8dcdde2af824fc01b24f9a7b3169f5fa)), closes [#​14724](https://github.com/vuejs/core/issues/14724)
|
||||
- **reactivity:** unlink effect scopes on out-of-order off ([#​14734](https://github.com/vuejs/core/issues/14734)) ([e7659be](https://github.com/vuejs/core/commit/e7659beafc5407e892fa70f3f4ade80263b0905d)), closes [#​14733](https://github.com/vuejs/core/issues/14733)
|
||||
- **runtime-dom:** preserve textarea resize dimensions ([#​14747](https://github.com/vuejs/core/issues/14747)) ([11fb2fd](https://github.com/vuejs/core/commit/11fb2fd4a246e40f6f350701dfea73ec525b4f59)), closes [#​14741](https://github.com/vuejs/core/issues/14741)
|
||||
- **teleport:** don't move teleport children if not mounted ([#​14702](https://github.com/vuejs/core/issues/14702)) ([6a61f44](https://github.com/vuejs/core/commit/6a61f4452ba1a31fc929cadf8abe3337ac4d3a46)), closes [#​14701](https://github.com/vuejs/core/issues/14701)
|
||||
- **transition:** preserve placeholder for conditional explicit default slots ([#​14748](https://github.com/vuejs/core/issues/14748)) ([45990ce](https://github.com/vuejs/core/commit/45990cecf4604b2f39c571ab6aefa49d362af36a)), closes [#​14727](https://github.com/vuejs/core/issues/14727)
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>vuejs/router (vue-router)</summary>
|
||||
|
||||
### [`v5.0.6`](https://github.com/vuejs/router/releases/tag/v5.0.6)
|
||||
|
||||
[Compare Source](https://github.com/vuejs/router/compare/v5.0.5...v5.0.6)
|
||||
|
||||
##### 🐞 Bug Fixes
|
||||
|
||||
- Missing closing quote in generated import - by [@​zjy040525](https://github.com/zjy040525) and [@​posva](https://github.com/posva) in [#​2688](https://github.com/vuejs/router/issues/2688) [<samp>(32f78)</samp>](https://github.com/vuejs/router/commit/32f78c77)
|
||||
|
||||
##### [View changes on GitHub](https://github.com/vuejs/router/compare/v5.0.5...v5.0.6)
|
||||
|
||||
### [`v5.0.5`](https://github.com/vuejs/router/releases/tag/v5.0.5)
|
||||
|
||||
[Compare Source](https://github.com/vuejs/router/compare/v5.0.4...v5.0.5)
|
||||
|
||||
##### 🚀 Features
|
||||
|
||||
- Enable standard schema param parsers - by [@​posva](https://github.com/posva) [<samp>(ea8e3)</samp>](https://github.com/vuejs/router/commit/ea8e3e21)
|
||||
- Normalize param parsers once - by [@​posva](https://github.com/posva) [<samp>(48087)</samp>](https://github.com/vuejs/router/commit/480877cc)
|
||||
|
||||
##### 🐞 Bug Fixes
|
||||
|
||||
- Track definePage imports per-file to fix named view race condition - by [@​posva](https://github.com/posva) [<samp>(11191)</samp>](https://github.com/vuejs/router/commit/11191bca)
|
||||
- Avoid double decoding hash on string location - by [@​posva](https://github.com/posva) [<samp>(1578c)</samp>](https://github.com/vuejs/router/commit/1578c9e9)
|
||||
|
||||
##### [View changes on GitHub](https://github.com/vuejs/router/compare/v5.0.4...v5.0.5)
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box
|
||||
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xMzIuMyIsInVwZGF0ZWRJblZlciI6IjQzLjE1MC4wIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->
|
||||
|
||||
## [1.1.3] - 2026-04-19
|
||||
### Changed
|
||||
- update dependency mediasoup-client to v3.18.8 (#30)
|
||||
|
||||
This PR contains the following updates:
|
||||
|
||||
| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|
||||
|---|---|---|---|
|
||||
| [mediasoup-client](https://mediasoup.org) ([source](https://github.com/versatica/mediasoup-client)) | [`3.18.7` → `3.18.8`](https://renovatebot.com/diffs/npm/mediasoup-client/3.18.7/3.18.8) |  |  |
|
||||
|
||||
---
|
||||
|
||||
### Release Notes
|
||||
|
||||
<details>
|
||||
<summary>versatica/mediasoup-client (mediasoup-client)</summary>
|
||||
|
||||
### [`v3.18.8`](https://github.com/versatica/mediasoup-client/compare/3.18.7...3.18.8)
|
||||
|
||||
[Compare Source](https://github.com/versatica/mediasoup-client/compare/3.18.7...3.18.8)
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box
|
||||
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xMjkuMCIsInVwZGF0ZWRJblZlciI6IjQzLjEyOS4wIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->
|
||||
|
||||
## [1.1.2] - 2026-04-15
|
||||
### Changed
|
||||
- Update dependency fast-xml-parser to v5.6.0 (#28)
|
||||
|
||||
This PR contains the following updates:
|
||||
|
||||
| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|
||||
|---|---|---|---|
|
||||
| [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) | [`5.5.12` → `5.6.0`](https://renovatebot.com/diffs/npm/fast-xml-parser/5.5.12/5.6.0) |  |  |
|
||||
|
||||
---
|
||||
|
||||
### Release Notes
|
||||
|
||||
<details>
|
||||
<summary>NaturalIntelligence/fast-xml-parser (fast-xml-parser)</summary>
|
||||
|
||||
### [`v5.6.0`](https://github.com/NaturalIntelligence/fast-xml-parser/releases/tag/v5.6.0): use @​nodable/entities to replace entities
|
||||
|
||||
[Compare Source](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.5.12...v5.6.0)
|
||||
|
||||
- No API change
|
||||
- No change in performance for basic usage
|
||||
- No typing change
|
||||
- No config change
|
||||
- new dependency
|
||||
- breaking: error messages for entities might have been changed.
|
||||
-
|
||||
|
||||
**Full Changelog**: <https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.5.12...v5.6.0>
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
### Configuration
|
||||
|
||||
📅 **Schedule**: (UTC)
|
||||
|
||||
- Branch creation
|
||||
- At any time (no schedule defined)
|
||||
- Automerge
|
||||
- At any time (no schedule defined)
|
||||
|
||||
🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.
|
||||
|
||||
♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.
|
||||
|
||||
🔕 **Ignore**: Close this PR and you won't be reminded about this update again.
|
||||
|
||||
---
|
||||
|
||||
- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box
|
||||
|
||||
---
|
||||
|
||||
This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
|
||||
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xMjAuMSIsInVwZGF0ZWRJblZlciI6IjQzLjEyMC4xIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->
|
||||
|
||||
## [1.1.1] - 2026-04-15
|
||||
### Changed
|
||||
- split push release/publish and harden workflows (#27)
|
||||
|
||||
### Added
|
||||
* Separate release from Docker/Helm publish
|
||||
* enrich releases with PRbodies when available
|
||||
* tighten release.sh validation and idempotency
|
||||
* trim PR docker-build metadata for act-runner stability
|
||||
|
||||
## [1.1.0] - 2026-04-15
|
||||
### Changed
|
||||
- Update all non-major dependencies (#25)
|
||||
|
||||
## [1.0.10] - 2026-04-15
|
||||
### Changed
|
||||
- Remove npm overrides for tar (#26)
|
||||
|
||||
@@ -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
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<NuxtLayout>
|
||||
<NuxtPage :key="$route.path" />
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
|
||||
+38
-2
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+136
-46
@@ -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 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
|
||||
const layer = alprLayerRef.value
|
||||
if (!ctx?.map || !L || !layer) return
|
||||
if (!props.showAlpr) {
|
||||
clearFeatureMarkers(layer)
|
||||
return
|
||||
}
|
||||
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>
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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: [],
|
||||
})
|
||||
}
|
||||
@@ -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>`
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 |
@@ -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).
|
||||
|
||||
@@ -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-*`).
|
||||
@@ -2,5 +2,5 @@ apiVersion: v2
|
||||
name: kestrelos
|
||||
description: KestrelOS TOC for OSINT feeds - map, camera feeds, offline tiles
|
||||
type: application
|
||||
version: 1.0.10
|
||||
appVersion: "1.0.10"
|
||||
version: 1.1.6
|
||||
appVersion: "1.1.6"
|
||||
|
||||
@@ -2,7 +2,7 @@ replicaCount: 1
|
||||
|
||||
image:
|
||||
repository: git.keligrubb.com/keligrubb/kestrelos
|
||||
tag: 1.0.10
|
||||
tag: 1.1.6
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
service:
|
||||
|
||||
+3
-1
@@ -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',
|
||||
|
||||
Generated
+3450
-2382
File diff suppressed because it is too large
Load Diff
+24
-22
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "kestrelos",
|
||||
"version": "1.0.10",
|
||||
"version": "1.1.6",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
+9
-2
@@ -20,10 +20,18 @@ echo "$msg" | grep -Eqi '(^|[[:space:]])[a-zA-Z]+(\([^)]*\))?!:' && bump=major
|
||||
echo "$msg" | grep -qi minor: && [ "$bump" != "major" ] && bump=minor
|
||||
echo "$msg" | grep -qi major: && bump=major
|
||||
cur=$(awk '/"version"/ { match($0, /[0-9]+\.[0-9]+\.[0-9]+/); print substr($0, RSTART, RLENGTH); exit }' package.json)
|
||||
case "$cur" in
|
||||
[0-9]*.[0-9]*.[0-9]*) ;;
|
||||
*) echo "error: package.json version must be x.y.z (got: $cur)"; exit 1 ;;
|
||||
esac
|
||||
major=$(echo "$cur" | cut -d. -f1); minor=$(echo "$cur" | cut -d. -f2); patch=$(echo "$cur" | cut -d. -f3)
|
||||
case "$bump" in major) major=$((major+1)); minor=0; patch=0 ;; minor) minor=$((minor+1)); patch=0 ;; patch) patch=$((patch+1)) ;; esac
|
||||
newVersion="$major.$minor.$patch"
|
||||
[ -z "$cur" ] && { echo "error: could not read version from package.json"; exit 1; }
|
||||
|
||||
url="https://${CI_REPO_OWNER}:${GITEA_REPO_TOKEN}@${CI_FORGE_URL#https://}/${CI_REPO_OWNER}/${CI_REPO_NAME}.git"
|
||||
if [ -n "$(git ls-remote "$url" "refs/tags/v$newVersion" 2>/dev/null)" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# changelog entry (strip explicit bump prefixes & any conventional-commit type(scope):); optional PR description enriches it
|
||||
changelogEntry=$(
|
||||
@@ -59,7 +67,6 @@ $changelogFull
|
||||
git config user.email "ci@kestrelos" && git config user.name "CI"
|
||||
git add package.json helm/kestrelos/Chart.yaml helm/kestrelos/values.yaml CHANGELOG.md
|
||||
git commit -m "release v$newVersion [skip ci]"
|
||||
url="https://${CI_REPO_OWNER}:${GITEA_REPO_TOKEN}@${CI_FORGE_URL#https://}/${CI_REPO_OWNER}/${CI_REPO_NAME}.git"
|
||||
git tag "v$newVersion"
|
||||
# artifact for docker (tag list)
|
||||
printf '%s\n%s\n' "$newVersion" "latest" > .tags
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
import { registerCleanup } from '../utils/shutdown.js'
|
||||
import { startTrackingFeed, stopTrackingFeed } from '../utils/trackingFeed.js'
|
||||
|
||||
export default defineNitroPlugin(() => {
|
||||
startTrackingFeed()
|
||||
registerCleanup(stopTrackingFeed)
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
+171
-27
@@ -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()
|
||||
|
||||
/**
|
||||
* @param {(event: string, payload: unknown) => void} fn
|
||||
* @returns {() => void} Unsubscribe function.
|
||||
*/
|
||||
export function onCotChange(fn) {
|
||||
listeners.add(fn)
|
||||
return () => listeners.delete(fn)
|
||||
}
|
||||
|
||||
function emitChange(event, payload) {
|
||||
for (const fn of listeners) {
|
||||
try {
|
||||
fn(event, payload)
|
||||
}
|
||||
catch {
|
||||
/* ignore listener errors */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function pickOptionalNumber(value, fallback) {
|
||||
if (value === undefined || value === null) return fallback
|
||||
const n = Number(value)
|
||||
return Number.isFinite(n) ? n : fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} entity
|
||||
*/
|
||||
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 }} parsed
|
||||
* @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) {
|
||||
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 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)
|
||||
|
||||
entities.set(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 })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @param {{ west: number, south: number, east: number, north: number } | null | undefined} bbox
|
||||
* @param {boolean} takFilterBbox
|
||||
*/
|
||||
export async function getActiveEntities(ttlMs = COT_ENTITY_TTL_MS) {
|
||||
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 = []
|
||||
const expired = []
|
||||
for (const entity of entities.values()) {
|
||||
if (now - entity.updatedAt <= ttlMs) {
|
||||
active.push({
|
||||
id: entity.id,
|
||||
lat: entity.lat,
|
||||
lng: entity.lng,
|
||||
label: entity.label ?? entity.id,
|
||||
type: entity.type ?? '',
|
||||
updatedAt: entity.updatedAt,
|
||||
})
|
||||
}
|
||||
else {
|
||||
expired.push(entity.id)
|
||||
}
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 & 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')
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user