Compare commits
20
Commits
v1.1.3
...
dc3d34622d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc3d34622d | ||
|
|
b8d4ab7b48 | ||
|
|
9c6960d7c7 | ||
|
|
068f82c0ff | ||
|
|
1f45cc152a | ||
|
|
be95b6864b | ||
|
|
cfac12b699 | ||
|
|
88e520aa59 | ||
|
|
5aa3bd8977 | ||
|
|
6fae069dec | ||
|
|
2d32047ae2 | ||
|
|
4d835a4f82 | ||
|
|
8c3f878636 | ||
|
|
cebeee4040 | ||
|
|
bb01e9a06c | ||
|
|
a6b87305a1 | ||
|
|
829db93065 | ||
|
|
afaf305bda | ||
|
|
2a48715ea8 | ||
|
|
9d34f5f7c5 |
@@ -7,10 +7,10 @@ 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
|
||||
uses: https://git.keligrubb.com/actions/setup-node@v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: "npm"
|
||||
@@ -24,10 +24,10 @@ 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
|
||||
uses: https://git.keligrubb.com/actions/setup-node@v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: "npm"
|
||||
@@ -41,12 +41,12 @@ jobs:
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.59.1-noble
|
||||
image: mcr.microsoft.com/playwright:v1.62.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
|
||||
uses: https://git.keligrubb.com/actions/setup-node@v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: "npm"
|
||||
@@ -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
|
||||
|
||||
@@ -8,7 +8,7 @@ jobs:
|
||||
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 }}
|
||||
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
needs: release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: https://git.keligrubb.com/actions/checkout@v6
|
||||
- uses: https://git.keligrubb.com/actions/checkout@v7
|
||||
with:
|
||||
ref: main
|
||||
token: ${{ secrets.KESTRELOS_REPO_TOKEN }}
|
||||
|
||||
+833
@@ -1,3 +1,836 @@
|
||||
## [1.1.12] - 2026-08-23
|
||||
### Changed
|
||||
- update all non-major dependencies (#47)
|
||||
|
||||
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.10` → `4.1.11`](https://renovatebot.com/diffs/npm/@vitest%2fcoverage-v8/4.1.10/4.1.11) |  |  |
|
||||
| [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) | [`5.10.1` → `5.11.0`](https://renovatebot.com/diffs/npm/fast-xml-parser/5.10.1/5.11.0) |  |  |
|
||||
| [happy-dom](https://github.com/capricorn86/happy-dom) | [`20.11.2` → `20.11.6`](https://renovatebot.com/diffs/npm/happy-dom/20.11.2/20.11.6) |  |  |
|
||||
| [hls.js](https://github.com/video-dev/hls.js) | [`1.7.0` → `1.7.1`](https://renovatebot.com/diffs/npm/hls.js/1.7.0/1.7.1) |  |  |
|
||||
| [mediasoup](https://mediasoup.org) ([source](https://github.com/versatica/mediasoup)) | [`3.24.2` → `3.26.0`](https://renovatebot.com/diffs/npm/mediasoup/3.24.2/3.26.0) |  |  |
|
||||
| [openid-client](https://github.com/panva/openid-client) | [`6.8.5` → `6.8.7`](https://renovatebot.com/diffs/npm/openid-client/6.8.5/6.8.7) |  |  |
|
||||
| [vitest](https://vitest.dev) ([source](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest)) | [`4.1.10` → `4.1.11`](https://renovatebot.com/diffs/npm/vitest/4.1.10/4.1.11) |  |  |
|
||||
|
||||
---
|
||||
|
||||
### Release Notes
|
||||
|
||||
<details>
|
||||
<summary>vitest-dev/vitest (@​vitest/coverage-v8)</summary>
|
||||
|
||||
### [`v4.1.11`](https://github.com/vitest-dev/vitest/releases/tag/v4.1.11)
|
||||
|
||||
[Compare Source](https://github.com/vitest-dev/vitest/compare/v4.1.10...v4.1.11)
|
||||
|
||||
##### 🐞 Bug Fixes
|
||||
|
||||
- Revive global concurrency limit for test lifecycle \[backport to v4] - by [@​sheremet-va](https://github.com/sheremet-va) and [@​hi-ogawa](https://github.com/hi-ogawa) in [#​10992](https://github.com/vitest-dev/vitest/issues/10992) [<samp>(5146d)</samp>](https://github.com/vitest-dev/vitest/commit/5146df80b)
|
||||
- **browser**:
|
||||
- Encode iframeId in tester iframe URL \[backport to v4] - by [@​sheremet-va](https://github.com/sheremet-va), **Pduhard** and **Claude Opus 4.8** in [#​10955](https://github.com/vitest-dev/vitest/issues/10955) [<samp>(10b2c)</samp>](https://github.com/vitest-dev/vitest/commit/10b2cd201)
|
||||
- Trigger playwright/chromium gc on lower disk availability \[backport to v4] - by [@​hi-ogawa](https://github.com/hi-ogawa), **Hiroshi Ogawa** and **OpenCode** in [#​10951](https://github.com/vitest-dev/vitest/issues/10951) [<samp>(9851d)</samp>](https://github.com/vitest-dev/vitest/commit/9851dbc41)
|
||||
- **mocker**:
|
||||
- Restrict redirect mocks to the fs allowlist \[backport to v4] - by [@​sheremet-va](https://github.com/sheremet-va) in [#​10974](https://github.com/vitest-dev/vitest/issues/10974) [<samp>(fe5a1)</samp>](https://github.com/vitest-dev/vitest/commit/fe5a11d3c)
|
||||
|
||||
##### [View changes on GitHub](https://github.com/vitest-dev/vitest/compare/v4.1.10...v4.1.11)
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>NaturalIntelligence/fast-xml-parser (fast-xml-parser)</summary>
|
||||
|
||||
### [`v5.11.0`](https://github.com/NaturalIntelligence/fast-xml-parser/releases/tag/v5.11.0)
|
||||
|
||||
[Compare Source](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.10.1...v5.11.0)
|
||||
|
||||
#### What's Changed
|
||||
|
||||
- add support for endIndex in node metadata (5.x edition) by [@​Wain-PC](https://github.com/Wain-PC) in [#​850](https://github.com/NaturalIntelligence/fast-xml-parser/pull/850)
|
||||
- fix: don't crash on a closing tag with no matching opening tag by [@​hdimer](https://github.com/hdimer) in [#​861](https://github.com/NaturalIntelligence/fast-xml-parser/pull/861)
|
||||
|
||||
#### New Contributors
|
||||
|
||||
- [@​Wain-PC](https://github.com/Wain-PC) made their first contribution in [#​850](https://github.com/NaturalIntelligence/fast-xml-parser/pull/850)
|
||||
- [@​hdimer](https://github.com/hdimer) made their first contribution in [#​861](https://github.com/NaturalIntelligence/fast-xml-parser/pull/861)
|
||||
|
||||
**Full Changelog**: <https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.10.1...v5.11.0>
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>capricorn86/happy-dom (happy-dom)</summary>
|
||||
|
||||
### [`v20.11.6`](https://github.com/capricorn86/happy-dom/releases/tag/v20.11.6)
|
||||
|
||||
[Compare Source](https://github.com/capricorn86/happy-dom/compare/v20.11.5...v20.11.6)
|
||||
|
||||
##### :construction\_worker\_man: Patch fixes
|
||||
|
||||
- Updates docs for the global-registrator package - By **[@​capricorn86](https://github.com/capricorn86)** in task [#​2300](https://github.com/capricorn86/happy-dom/issues/2300)
|
||||
|
||||
### [`v20.11.5`](https://github.com/capricorn86/happy-dom/releases/tag/v20.11.5)
|
||||
|
||||
[Compare Source](https://github.com/capricorn86/happy-dom/compare/v20.11.4...v20.11.5)
|
||||
|
||||
##### :construction\_worker\_man: Patch fixes
|
||||
|
||||
- Allow explicit element types for querySelector (e.g. `querySelector<HTMLInputElement>(".my-input")`) - By **[@​cyphercodes](https://github.com/cyphercodes)**
|
||||
|
||||
### [`v20.11.4`](https://github.com/capricorn86/happy-dom/releases/tag/v20.11.4)
|
||||
|
||||
[Compare Source](https://github.com/capricorn86/happy-dom/compare/v20.11.3...v20.11.4)
|
||||
|
||||
##### :construction\_worker\_man: Patch fixes
|
||||
|
||||
- Fixes the CORS check `fetch()` to match origins instead of host and protocol - By **[@​rexxars](https://github.com/rexxars)** in task [#​1490](https://github.com/capricorn86/happy-dom/issues/1490)
|
||||
|
||||
### [`v20.11.3`](https://github.com/capricorn86/happy-dom/releases/tag/v20.11.3)
|
||||
|
||||
[Compare Source](https://github.com/capricorn86/happy-dom/compare/v20.11.2...v20.11.3)
|
||||
|
||||
##### :construction\_worker\_man: Patch fixes
|
||||
|
||||
- Make document.links return a live HTMLCollection - By **[@​bangseongbeom](https://github.com/bangseongbeom)** in task [#​2299](https://github.com/capricorn86/happy-dom/issues/2299)
|
||||
- Copy labels array to prevent mutation of cached querySelectorAll result - By **[@​mixelburg](https://github.com/mixelburg)** in task [#​2226](https://github.com/capricorn86/happy-dom/issues/2226)
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>video-dev/hls.js (hls.js)</summary>
|
||||
|
||||
### [`v1.7.1`](https://github.com/video-dev/hls.js/releases/tag/v1.7.1)
|
||||
|
||||
[Compare Source](https://github.com/video-dev/hls.js/compare/v1.7.0...v1.7.1)
|
||||
|
||||
### Summary
|
||||
|
||||
HLS.js v1.7.1 includes bug fixes and improvements over the previous release.
|
||||
|
||||
#### Changes Since The Last Release
|
||||
|
||||
- Fix Interstitial snap-out at live edge and `BUFFER_APPEND_NO_PROGRESS` false positives ([#​7979](https://github.com/video-dev/hls.js/issues/7979)) [@​robwalch](https://github.com/robwalch)
|
||||
- Workaround issue where `ManagedMediaSource` does not emit "startstreaming" when seeking ([#​7984](https://github.com/video-dev/hls.js/issues/7984))
|
||||
- Fix permanent stall loading fragment-hint parts of encrypted low-latency streams ([#​7976](https://github.com/video-dev/hls.js/issues/7976)) [@​zaki699-blip](https://github.com/zaki699-blip)
|
||||
- Document decode timebase change in MIGRATING ([#​7986](https://github.com/video-dev/hls.js/issues/7986)) [@​robwalch](https://github.com/robwalch)
|
||||
|
||||
#### Demo Page
|
||||
|
||||
<https://26ea065a.hls-js-dev.pages.dev/demo/>
|
||||
|
||||
#### API and Breaking Changes
|
||||
|
||||
No public exports were removed and no runtime behavior changes are required to upgrade from v1.6 to v1.7. TypeScript consumers might see new compile errors where previously loose types have been narrowed. Each is listed with upgrade guidance in the migration guide:
|
||||
<https://github.com/video-dev/hls.js/blob/v1.7.0/MIGRATING.md#migrating-from-hlsjs-16-to-17>
|
||||
|
||||
Some exported type dependencies ("eventemitter3", "[@​svta/cml-cmcd](https://github.com/svta/cml-cmcd)", "[@​svta/cml-utils](https://github.com/svta/cml-utils)", "[@​svta/cml-structured-field-values](https://github.com/svta/cml-structured-field-values)") have not been bundled with hls.d.ts. Please file an issue if this is blocking you from upgrading.
|
||||
|
||||
#### Feedback
|
||||
|
||||
Please provide feedback via [Issues in GitHub](https://github.com/video-dev/hls.js/issues/new/choose). For more details on how to contribute to HLS.js, see our [CONTRIBUTING guide](https://github.com/video-dev/hls.js/blob/master/CONTRIBUTING.md).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>versatica/mediasoup (mediasoup)</summary>
|
||||
|
||||
### [`v3.26.0`](https://github.com/versatica/mediasoup/blob/HEAD/CHANGELOG.md#3260)
|
||||
|
||||
[Compare Source](https://github.com/versatica/mediasoup/compare/3.25.0...3.26.0)
|
||||
|
||||
- **Breaking change:** Simulcast and SVC: Limit temporal layer to the preferred one ([PR #​1892](https://github.com/versatica/mediasoup/pull/1892)).
|
||||
|
||||
### [`v3.25.0`](https://github.com/versatica/mediasoup/blob/HEAD/CHANGELOG.md#3250)
|
||||
|
||||
[Compare Source](https://github.com/versatica/mediasoup/compare/3.24.2...3.25.0)
|
||||
|
||||
- Worker: Fix undefined behavior in `RtpStreamRecv::UpdateScore()` when no packets were received ([PR #​1886](https://github.com/versatica/mediasoup/pull/1886)).
|
||||
- SCTP: Fix `SackChunk::GetValidatedGapAckBlocks()` returning a bogus gap-ack-block ([PR #​1891](https://github.com/versatica/mediasoup/pull/1891)).
|
||||
- Do not make generated RTCP Sender Reports depend on RTP packet arrival time ([issue #​1881](https://github.com/versatica/mediasoup/issues/1881)):
|
||||
- `RemoteClockOffsetEstimator` class ([PR #​1882](https://github.com/versatica/mediasoup/pull/1882)).
|
||||
- Prepare `RtpStream` classes for capture time based RTCP Sender Reports ([PR #​1883](https://github.com/versatica/mediasoup/pull/1883), [PR #​1888](https://github.com/versatica/mediasoup/pull/1888)).
|
||||
- `RemoteCaptureTimeEstimator` class ([PR #​1884](https://github.com/versatica/mediasoup/pull/1884)).
|
||||
- Estimate the capture instant of each received RTP packet ([PR #​1885](https://github.com/versatica/mediasoup/pull/1885)).
|
||||
- Generate RTCP Sender Reports based on the capture instant of the media rather than on the packet arrival time ([PR #​1887](https://github.com/versatica/mediasoup/pull/1887)).
|
||||
- `SimulcastProducerStreamManager`: Apply new capture time logic and fix 'abs-capture-time' rewriting ([PR #​1889](https://github.com/versatica/mediasoup/pull/1889)).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>panva/openid-client (openid-client)</summary>
|
||||
|
||||
### [`v6.8.7`](https://github.com/panva/openid-client/blob/HEAD/CHANGELOG.md#687-2026-08-20)
|
||||
|
||||
[Compare Source](https://github.com/panva/openid-client/compare/v6.8.6...v6.8.7)
|
||||
|
||||
##### Fixes
|
||||
|
||||
- allow destructuring the claims helper ([38bd8c0](https://github.com/panva/openid-client/commit/38bd8c052a7e1e6d0e2beda14d35fb38b9b26d4c)), references [#​887](https://github.com/panva/openid-client/issues/887)
|
||||
|
||||
### [`v6.8.6`](https://github.com/panva/openid-client/blob/HEAD/CHANGELOG.md#686-2026-08-18)
|
||||
|
||||
[Compare Source](https://github.com/panva/openid-client/compare/v6.8.5...v6.8.6)
|
||||
|
||||
##### Fixes
|
||||
|
||||
- avoid undefined user-agent in fetchProtectedResource ([492c3c3](https://github.com/panva/openid-client/commit/492c3c36aad1ac324661b808b32bc17a35d22665)), references [#​885](https://github.com/panva/openid-client/issues/885)
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box
|
||||
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0NC4zMy4yIiwidXBkYXRlZEluVmVyIjoiNDQuMzkuMiIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiZGVwZW5kZW5jaWVzIl19-->
|
||||
|
||||
## [1.1.11] - 2026-08-19
|
||||
### Changed
|
||||
- remove unused broadcastToSession export (#45)
|
||||
|
||||
## Removed
|
||||
|
||||
- `broadcastToSession` from `server/plugins/websocket.js` — an exported but never-imported, never-called dead-code function. Its dependencies (`getSessionConnections`, `addSessionConnection`, `removeSessionConnection`) are retained since they remain actively used for per-session connection tracking within the plugin.
|
||||
|
||||
## Notes
|
||||
|
||||
- Full repository search confirms zero remaining references to `broadcastToSession`.
|
||||
- ESLint passes; full test suite passes (50 files, 406 tests).
|
||||
|
||||
Closes #40
|
||||
|
||||
## [1.1.10] - 2026-08-14
|
||||
### Changed
|
||||
- update dependencies and fix security vulnerabilities (#44)
|
||||
|
||||
## Summary
|
||||
|
||||
Updates all out-of-date dependencies and applies `npm audit fix` to resolve 14 security vulnerabilities (3 critical, 8 high).
|
||||
|
||||
### Dependency Updates
|
||||
|
||||
| Package | Current → Latest |
|
||||
|---|---|
|
||||
| `nuxt` | 4.4.8 → 4.5.2 |
|
||||
| `mediasoup` | 3.20.9 → 3.24.2 |
|
||||
| `mediasoup-client` | 3.21.0 → 3.22.0 |
|
||||
| `hls.js` | 1.6.16 → 1.7.0 |
|
||||
| `vue` | 3.5.38 → 3.5.41 |
|
||||
| `vue-router` | 5.1.0 → 5.2.0 |
|
||||
| `@nuxt/icon` | 2.2.3 → 2.5.0 |
|
||||
| `eslint` | 10.5.0 → 10.8.1 |
|
||||
| `vitest` / `@vitest/coverage-v8` | 4.1.9 → 4.1.10 |
|
||||
| `ws` | 8.21.0 → 8.21.3 |
|
||||
| `openid-client` | 6.8.4 → 6.8.5 |
|
||||
| `fast-xml-parser` | 5.9.3 → 5.10.1 |
|
||||
| `happy-dom` | 20.10.6 → 20.11.2 |
|
||||
| `@playwright/test` | 1.61.1 → 1.62.1 |
|
||||
| `@nuxt/eslint` | 1.16.0 → 1.17.0 |
|
||||
| `@nuxt/test-utils` | 4.0.3 → 4.1.0 |
|
||||
| `@iconify-json/tabler` | 1.2.35 → 1.2.38 |
|
||||
|
||||
### Security Audit Fixes (`npm audit fix`)
|
||||
|
||||
- **`@nuxt/devtools`** (critical): Unauthenticated DevTools RPC allows arbitrary command execution on the developer's host
|
||||
- **`tar`** (critical): Multiple process-crash/DoS vulnerabilities via crafted tar archives
|
||||
- **`esbuild`** (high): Arbitrary file read when running dev server on Windows
|
||||
- **`brace-expansion`** (high): Multiple DoS vectors (memory exhaustion, process hang)
|
||||
- **`flatted`** (high): Prototype pollution via `parse()`
|
||||
- **`svgo`** (high): `removeScripts` plugin leaves executable scripts intact
|
||||
|
||||
### Verification
|
||||
|
||||
- All 406 tests pass (3 skipped)
|
||||
- Lint clean (`eslint . --max-warnings 0`)
|
||||
- `npm audit` reports 0 vulnerabilities
|
||||
|
||||
## [1.1.9] - 2026-08-13
|
||||
### Changed
|
||||
- update dependency fast-xml-parser to v5.10.1 [security] (#42)
|
||||
|
||||
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.9.3` → `5.10.1`](https://renovatebot.com/diffs/npm/fast-xml-parser/5.9.3/5.10.1) |  |  |
|
||||
|
||||
---
|
||||
|
||||
### fast-xml-parser: Repeated DOCTYPE declarations reset entity expansion limits
|
||||
[GHSA-8r6m-32jq-jx6q](https://github.com/advisories/GHSA-8r6m-32jq-jx6q)
|
||||
|
||||
<details>
|
||||
<summary>More information</summary>
|
||||
|
||||
#### Details
|
||||
##### Impact
|
||||
`fast-xml-parser` processes multiple "DOCTYPE" declarations within a single XML document. Each declaration passes its entities to `@nodable/entities` through `addInputEntities()`.
|
||||
|
||||
`addInputEntities()` resets the entity expansion counters every time it is called. An attacker can therefore insert additional DOCTYPE declarations to repeatedly reset maxTotalExpansions and maxExpandedLength during one parse operation.
|
||||
|
||||
This allows a crafted XML document to exceed the configured entity-expansion limits and can cause excessive CPU use, event-loop blocking, memory exhaustion, and process termination.
|
||||
|
||||
##### Workarounds
|
||||
- Manually check if multiple DOCTYPEs are not present in input contents
|
||||
- Update to v5.10.1
|
||||
- Keep `processEntity` flag off
|
||||
|
||||
#### Severity
|
||||
- CVSS Score: 8.7 / 10 (High)
|
||||
- Vector String: `CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N`
|
||||
|
||||
#### References
|
||||
- [https://github.com/NaturalIntelligence/fast-xml-parser/security/advisories/GHSA-8r6m-32jq-jx6q](https://github.com/NaturalIntelligence/fast-xml-parser/security/advisories/GHSA-8r6m-32jq-jx6q)
|
||||
- [https://github.com/NaturalIntelligence/fast-xml-parser/commit/4e546e03987662de5495d050b5fba26bea65383f](https://github.com/NaturalIntelligence/fast-xml-parser/commit/4e546e03987662de5495d050b5fba26bea65383f)
|
||||
- [https://github.com/NaturalIntelligence/fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser)
|
||||
- [https://github.com/NaturalIntelligence/fast-xml-parser/releases/tag/v5.10.1](https://github.com/NaturalIntelligence/fast-xml-parser/releases/tag/v5.10.1)
|
||||
|
||||
This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-8r6m-32jq-jx6q) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)).
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
### Release Notes
|
||||
|
||||
<details>
|
||||
<summary>NaturalIntelligence/fast-xml-parser (fast-xml-parser)</summary>
|
||||
|
||||
### [`v5.10.1`](https://github.com/NaturalIntelligence/fast-xml-parser/releases/tag/v5.10.1)
|
||||
|
||||
[Compare Source](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.10.0...v5.10.1)
|
||||
|
||||
**Full Changelog**: <https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.10.0...v5.10.1>
|
||||
|
||||
### [`v5.10.0`](https://github.com/NaturalIntelligence/fast-xml-parser/releases/tag/v5.10.0)
|
||||
|
||||
[Compare Source](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.9.3...v5.10.0)
|
||||
|
||||
#### What's Changed
|
||||
|
||||
- Bump actions/checkout from 6.0.3 to 7.0.0 by [@​dependabot](https://github.com/dependabot)\[bot] in [#​849](https://github.com/NaturalIntelligence/fast-xml-parser/pull/849)
|
||||
- Bump zizmorcore/zizmor-action from 0.5.6 to 0.5.7 by [@​dependabot](https://github.com/dependabot)\[bot] in [#​848](https://github.com/NaturalIntelligence/fast-xml-parser/pull/848)
|
||||
|
||||
**Full Changelog**: <https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.9.3...v5.10.0>
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box
|
||||
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0NC4yOC4wIiwidXBkYXRlZEluVmVyIjoiNDQuMjguMCIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiZGVwZW5kZW5jaWVzIiwic2VjdXJpdHkiXX0=-->
|
||||
|
||||
## [1.1.8] - 2026-08-13
|
||||
### Changed
|
||||
- update https://git.keligrubb.com/actions/setup-node action to v7 (#38)
|
||||
|
||||
This PR contains the following updates:
|
||||
|
||||
| Package | Type | Update | Change |
|
||||
|---|---|---|---|
|
||||
| [https://git.keligrubb.com/actions/setup-node](https://git.keligrubb.com/actions/setup-node) | action | major | `v6` → `v7` |
|
||||
|
||||
---
|
||||
|
||||
### Release Notes
|
||||
|
||||
<details>
|
||||
<summary>actions/setup-node (https://git.keligrubb.com/actions/setup-node)</summary>
|
||||
|
||||
### [`v7.0.0`](https://git.keligrubb.com/actions/setup-node/compare/v7...v7)
|
||||
|
||||
[Compare Source](https://git.keligrubb.com/actions/setup-node/compare/v7...v7)
|
||||
|
||||
### [`v7`](https://git.keligrubb.com/actions/setup-node/compare/v6.5.0...v7)
|
||||
|
||||
[Compare Source](https://git.keligrubb.com/actions/setup-node/compare/v6.5.0...v7)
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box
|
||||
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4yNjUuNCIsInVwZGF0ZWRJblZlciI6IjQzLjI2NS40IiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->
|
||||
|
||||
## [1.1.7] - 2026-08-13
|
||||
### Changed
|
||||
- update dependency supercluster to v9 (#41)
|
||||
|
||||
This PR contains the following updates:
|
||||
|
||||
| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|
||||
|---|---|---|---|
|
||||
| [supercluster](https://github.com/mapbox/supercluster) | [`^8.0.1` → `^9.0.0`](https://renovatebot.com/diffs/npm/supercluster/8.0.1/9.0.0) |  |  |
|
||||
|
||||
---
|
||||
|
||||
### Release Notes
|
||||
|
||||
<details>
|
||||
<summary>mapbox/supercluster (supercluster)</summary>
|
||||
|
||||
### [`v9.0.0`](https://github.com/mapbox/supercluster/releases/tag/v9.0.0)
|
||||
|
||||
[Compare Source](https://github.com/mapbox/supercluster/compare/v8.0.1...v9.0.0)
|
||||
|
||||
- Radically optimize **memory footprint and performance** — on a sample 1M points index, Supercluster now uses 94% less transient allocation, 57% lower peak heap, 65% less retained memory, and runs 23% faster. [#​258](https://github.com/mapbox/supercluster/issues/258)
|
||||
- Improve internal coordinate precision by 6 bits (64 times).
|
||||
- ⚠️ Breaking: hard-cap `maxZoom` at 30 (it wasn't practical to use it on higher zooms anyway).
|
||||
- Add support for `MultiPoint` features. [#​263](https://github.com/mapbox/supercluster/issues/263)
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box
|
||||
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0NC4yNy4wIiwidXBkYXRlZEluVmVyIjoiNDQuMjcuMCIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->
|
||||
|
||||
## [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)
|
||||
|
||||
@@ -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.1.3
|
||||
appVersion: "1.1.3"
|
||||
version: 1.1.12
|
||||
appVersion: "1.1.12"
|
||||
|
||||
@@ -2,7 +2,7 @@ replicaCount: 1
|
||||
|
||||
image:
|
||||
repository: git.keligrubb.com/keligrubb/kestrelos
|
||||
tag: 1.1.3
|
||||
tag: 1.1.12
|
||||
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
+3760
-3914
File diff suppressed because it is too large
Load Diff
+24
-22
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "kestrelos",
|
||||
"version": "1.1.3",
|
||||
"version": "1.1.12",
|
||||
"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.5.0",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
"fast-xml-parser": "^5.3.6",
|
||||
"hls.js": "^1.5.0",
|
||||
"fast-xml-parser": "^5.10.1",
|
||||
"hls.js": "^1.7.0",
|
||||
"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.24.2",
|
||||
"mediasoup-client": "^3.22.0",
|
||||
"nuxt": "^4.5.2",
|
||||
"openid-client": "^6.8.5",
|
||||
"qrcode": "^1.5.4",
|
||||
"vue": "^3.4.0",
|
||||
"vue-router": "^5.0.0",
|
||||
"ws": "^8.18.0"
|
||||
"supercluster": "^9.0.0",
|
||||
"vue": "^3.5.41",
|
||||
"vue-router": "^5.2.0",
|
||||
"ws": "^8.21.3"
|
||||
},
|
||||
"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.38",
|
||||
"@nuxt/eslint": "^1.17.0",
|
||||
"@nuxt/test-utils": "^4.1.0",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@vitest/coverage-v8": "^4.1.10",
|
||||
"@vue/test-utils": "^2.4.11",
|
||||
"eslint": "^10.8.1",
|
||||
"happy-dom": "^20.11.2",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -43,16 +43,6 @@ export function removeSessionConnection(sessionId, ws) {
|
||||
}
|
||||
}
|
||||
|
||||
export function broadcastToSession(sessionId, message) {
|
||||
const conns = getSessionConnections(sessionId)
|
||||
const data = JSON.stringify(message)
|
||||
for (const ws of conns) {
|
||||
if (ws.readyState === 1) { // OPEN
|
||||
ws.send(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default defineNitroPlugin((nitroApp) => {
|
||||
nitroApp.hooks.hook('ready', async () => {
|
||||
const server = nitroApp.h3App.server || nitroApp.h3App.nodeServer
|
||||
|
||||
@@ -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