Compare commits

..
4 Commits
Author SHA1 Message Date
Madison Grubb b2ea25fe5b trigger ci 2026-02-11 22:51:30 -05:00
Madison GrubbandCursor 4d0193ebc0 chore: empty commit
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-11 22:50:25 -05:00
Madison Grubb 480f2508f2 fix ci 2026-02-11 22:41:01 -05:00
Madison Grubb dc92c981e4 add ci 2026-02-11 22:38:50 -05:00
195 changed files with 8056 additions and 16262 deletions
-92
View File
@@ -1,92 +0,0 @@
name: PR
on:
pull_request:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: https://git.keligrubb.com/actions/checkout@v7
- name: Setup Node.js
uses: https://git.keligrubb.com/actions/setup-node@v7
with:
node-version: "24"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Run lint
run: npm run lint
test:
runs-on: ubuntu-latest
steps:
- uses: https://git.keligrubb.com/actions/checkout@v7
- name: Setup Node.js
uses: https://git.keligrubb.com/actions/setup-node@v7
with:
node-version: "24"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm run test
e2e:
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.61.1-noble
steps:
- uses: https://git.keligrubb.com/actions/checkout@v7
- name: Setup Node.js
uses: https://git.keligrubb.com/actions/setup-node@v7
with:
node-version: "24"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Generate dev cert
run: ./scripts/gen-dev-cert.sh
- name: Run e2e tests
run: npm run test:e2e
env:
NODE_TLS_REJECT_UNAUTHORIZED: "0"
docker-build:
runs-on: ubuntu-latest
steps:
- uses: https://git.keligrubb.com/actions/checkout@v7
- name: Set Docker image tag
id: image
run: |
REGISTRY="${GITHUB_SERVER_URL#https://}"
REGISTRY="${REGISTRY#http://}"
echo "tag=${REGISTRY}/${{ github.repository }}:latest" >> $GITHUB_OUTPUT
- name: Set up Docker Buildx
uses: https://git.keligrubb.com/actions/docker-setup-buildx-action@v4
- name: Build (dry run)
uses: https://git.keligrubb.com/actions/docker-build-push-action@v7
env:
# Keeps GITHUB_OUTPUT small; Gitea act-runner can choke on multiline
# outputs when PR webhook payloads (e.g. Renovate bodies) are huge.
DOCKER_BUILD_SUMMARY: "false"
DOCKER_BUILD_RECORD_UPLOAD: "false"
with:
context: .
push: false
provenance: false
sbom: false
tags: ${{ steps.image.outputs.tag }}
-90
View File
@@ -1,90 +0,0 @@
name: Push
on:
push:
branches: [main]
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: https://git.keligrubb.com/actions/checkout@v7
with:
token: ${{ secrets.KESTRELOS_REPO_TOKEN }}
- name: Get PR description for changelog
env:
GITEA_REPO_TOKEN: ${{ secrets.KESTRELOS_REPO_TOKEN }}
run: |
sudo rm -f /etc/apt/sources.list.d/microsoft*.list /etc/apt/sources.list.d/azure*.list 2>/dev/null || true
sudo apt-get update -qq && sudo apt-get install -y -qq jq
RESP=$(curl -sf -H "Authorization: token $GITEA_REPO_TOKEN" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/commits/${{ github.sha }}/pull") || true
if [ -n "$RESP" ]; then
echo "$RESP" | jq -r '.body // empty' > .ci_pr_body 2>/dev/null || true
fi
- name: Release (bump, tag, push, create release)
env:
CI_REPO_OWNER: ${{ github.actor }}
CI_REPO_NAME: ${{ github.event.repository.name }}
CI_FORGE_URL: ${{ github.server_url }}
CI_COMMIT_MESSAGE: ${{ github.event.head_commit.message }}
GITEA_REPO_TOKEN: ${{ secrets.KESTRELOS_REPO_TOKEN }}
run: |
sudo rm -f /etc/apt/sources.list.d/microsoft*.list /etc/apt/sources.list.d/azure*.list 2>/dev/null || true
sudo apt-get update -qq && sudo apt-get install -y -qq git wget
./scripts/release.sh
publish:
needs: release
runs-on: ubuntu-latest
steps:
- uses: https://git.keligrubb.com/actions/checkout@v7
with:
ref: main
token: ${{ secrets.KESTRELOS_REPO_TOKEN }}
- name: Log in to container registry
uses: https://git.keligrubb.com/actions/docker-login-action@v4
with:
registry: git.keligrubb.com
username: ${{ github.actor }}
password: ${{ secrets.KESTRELOS_REPO_TOKEN }}
- name: Set up Docker Buildx
uses: https://git.keligrubb.com/actions/docker-setup-buildx-action@v4
- name: Build Docker image
uses: https://git.keligrubb.com/actions/docker-build-push-action@v7
with:
context: .
load: true
tags: kestrelos:built
- name: Push Docker image (version + latest)
run: |
VERSION=$(awk '/"version"/ { match($0, /[0-9]+\.[0-9]+\.[0-9]+/); print substr($0, RSTART, RLENGTH); exit }' package.json)
case "$VERSION" in
[0-9]*.[0-9]*.[0-9]*) ;;
*) echo "error: package.json version must be x.y.z (got: $VERSION)"; exit 1 ;;
esac
REGISTRY="git.keligrubb.com"
IMAGE="$REGISTRY/${{ github.repository }}"
for tag in "$VERSION" latest; do
docker tag kestrelos:built "$IMAGE:$tag"
docker push "$IMAGE:$tag"
done
- name: Set up Helm
uses: https://git.keligrubb.com/actions/setup-helm@v5
- name: Package and push Helm chart
env:
GITEA_REPO_TOKEN: ${{ secrets.KESTRELOS_REPO_TOKEN }}
run: |
helm package helm/kestrelos
for f in kestrelos-*.tgz; do
curl -sf -u "${{ github.actor }}:$GITEA_REPO_TOKEN" -X POST --upload-file "$f" \
"${{ github.server_url }}/api/packages/${{ github.actor }}/helm/api/charts"
done
-3
View File
@@ -44,6 +44,3 @@ data
# Dev TLS certs (self-signed for local testing) # Dev TLS certs (self-signed for local testing)
.dev-certs .dev-certs
# CI artifact (kaniko reads .tags for image tag list)
.tags
+1 -1
View File
@@ -1 +1 @@
setups.@nuxt/test-utils="4.0.3" setups.@nuxt/test-utils="4.0.0"
+51
View File
@@ -0,0 +1,51 @@
steps:
- name: lint
image: node:24-slim
commands:
- npm ci
- npm run lint
when:
- event: pull_request
- name: test
image: node:24-slim
environment:
CI: "true"
commands:
- npm run test
when:
- event: pull_request
- name: e2e
image: mcr.microsoft.com/playwright:v1.58.2-noble
commands:
- npm ci
- npm run test:e2e
environment:
CI: "true"
NODE_TLS_REJECT_UNAUTHORIZED: "0"
when:
- event: pull_request
- name: docker-build
image: woodpeckerci/plugin-docker-buildx
settings:
repo: git.keligrubb.com/${CI_REPO_OWNER}/${CI_REPO_NAME}
registry: git.keligrubb.com
tags: latest
dry_run: true
when:
- event: pull_request
- name: docker-build-push
image: woodpeckerci/plugin-docker-buildx
settings:
repo: git.keligrubb.com/${CI_REPO_OWNER}/${CI_REPO_NAME}
registry: git.keligrubb.com
tags: latest,${CI_COMMIT_SHA:0:7}
username: ${CI_REPO_OWNER}
password:
from_secret: gitea_registry_token
when:
- event: push
branch: main
-748
View File
@@ -1,748 +0,0 @@
## [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) | ![age](https://developer.mend.io/api/mc/badges/age/npm/fast-xml-parser/5.10.1?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/fast-xml-parser/5.9.3/5.10.1?slim=true) |
---
### 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 [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;849](https://github.com/NaturalIntelligence/fast-xml-parser/pull/849)
- Bump zizmorcore/zizmor-action from 0.5.6 to 0.5.7 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;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) | ![age](https://developer.mend.io/api/mc/badges/age/npm/supercluster/9.0.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/supercluster/8.0.1/9.0.0?slim=true) |
---
### 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. [#&#8203;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. [#&#8203;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 [@&#8203;aiqiaoy](https://github.com/aiqiaoy) in [#&#8203;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 [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2458](https://github.com/actions/checkout/pull/2458)
- Bump flatted from 3.3.1 to 3.4.2 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2460](https://github.com/actions/checkout/pull/2460)
- Bump js-yaml from 4.1.0 to 4.2.0 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2461](https://github.com/actions/checkout/pull/2461)
- Bump [@&#8203;actions/core](https://github.com/actions/core) and [@&#8203;actions/tool-cache](https://github.com/actions/tool-cache) and Remove uuid by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2459](https://github.com/actions/checkout/pull/2459)
- upgrade module to esm and update dependencies by [@&#8203;aiqiaoy](https://github.com/aiqiaoy) in [#&#8203;2463](https://github.com/actions/checkout/pull/2463)
- Bump the minor-npm-dependencies group across 1 directory with 3 updates by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;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 [@&#8203;aiqiaoy](https://github.com/aiqiaoy) in [#&#8203;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 [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2458](https://github.com/actions/checkout/pull/2458)
- Bump flatted from 3.3.1 to 3.4.2 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2460](https://github.com/actions/checkout/pull/2460)
- Bump js-yaml from 4.1.0 to 4.2.0 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2461](https://github.com/actions/checkout/pull/2461)
- Bump [@&#8203;actions/core](https://github.com/actions/core) and [@&#8203;actions/tool-cache](https://github.com/actions/tool-cache) and Remove uuid by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2459](https://github.com/actions/checkout/pull/2459)
- upgrade module to esm and update dependencies by [@&#8203;aiqiaoy](https://github.com/aiqiaoy) in [#&#8203;2463](https://github.com/actions/checkout/pull/2463)
- Bump the minor-npm-dependencies group across 1 directory with 3 updates by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;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) | ![age](https://developer.mend.io/api/mc/badges/age/npm/@vitest%2fcoverage-v8/4.1.5?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@vitest%2fcoverage-v8/4.1.4/4.1.5?slim=true) |
| [@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) | ![age](https://developer.mend.io/api/mc/badges/age/npm/@vue%2ftest-utils/2.4.8?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@vue%2ftest-utils/2.4.6/2.4.8?slim=true) |
| [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) | ![age](https://developer.mend.io/api/mc/badges/age/npm/eslint/10.2.1?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/eslint/10.2.0/10.2.1?slim=true) |
| [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) | ![age](https://developer.mend.io/api/mc/badges/age/npm/fast-xml-parser/5.7.2?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/fast-xml-parser/5.6.0/5.7.2?slim=true) |
| [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) | ![age](https://developer.mend.io/api/mc/badges/age/npm/mediasoup/3.19.21?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/mediasoup/3.19.19/3.19.21?slim=true) |
| [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) | ![age](https://developer.mend.io/api/mc/badges/age/npm/mediasoup-client/3.19.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/mediasoup-client/3.18.8/3.19.0?slim=true) |
| [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) | ![age](https://developer.mend.io/api/mc/badges/age/npm/vitest/4.1.5?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/vitest/4.1.4/4.1.5?slim=true) |
| [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) | ![age](https://developer.mend.io/api/mc/badges/age/npm/vue/3.5.33?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/vue/3.5.32/3.5.33?slim=true) |
| [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) | ![age](https://developer.mend.io/api/mc/badges/age/npm/vue-router/5.0.6?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/vue-router/5.0.4/5.0.6?slim=true) |
---
### Release Notes
<details>
<summary>vitest-dev/vitest (@&#8203;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 [@&#8203;BartWaardenburg](https://github.com/BartWaardenburg) and [@&#8203;AriPerkkio](https://github.com/AriPerkkio) in [#&#8203;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 [@&#8203;felamaslen](https://github.com/felamaslen) in [#&#8203;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 [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;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 [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;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 [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;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 [@&#8203;Copilot](https://github.com/Copilot), **sheremet-va** and [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;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 [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;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 [@&#8203;Yejneshwar](https://github.com/Yejneshwar) in [#&#8203;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 [@&#8203;DaveT1991](https://github.com/DaveT1991) and [@&#8203;AriPerkkio](https://github.com/AriPerkkio) in [#&#8203;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 [@&#8203;hi-ogawa](https://github.com/hi-ogawa) and **Codex** in [#&#8203;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 [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;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 [@&#8203;whitphx](https://github.com/whitphx) and **Claude Opus 4.6 (1M context)** in [#&#8203;9927](https://github.com/vitest-dev/vitest/issues/9927) and [#&#8203;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 [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;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 (@&#8203;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 ([#&#8203;2826](https://github.com/vuejs/test-utils/pull/2826))
##### 🤖 CI
- Enable pkg.pr.new ([#&#8203;2827](https://github.com/vuejs/test-utils/pull/2827))
##### ❤️ Contributors
- Cédric Exbrayat ([@&#8203;cexbrayat](https://github.com/cexbrayat))
- Daniel Roe ([@&#8203;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 ([#&#8203;2552](https://github.com/vuejs/test-utils/pull/2552))
- SetData()/shallowMount with initialData for components using the Composition API / <script setup> ([#&#8203;2655](https://github.com/vuejs/test-utils/pull/2655))
##### 🩹 Fixes
- Preserve code from keyboard events ([#&#8203;2434](https://github.com/vuejs/test-utils/pull/2434))
- Switch browser and require exports definitions ([#&#8203;2501](https://github.com/vuejs/test-utils/pull/2501))
- Re-add peer dependencies but with wider range ([#&#8203;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 ([#&#8203;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 ([#&#8203;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 ([#&#8203;2683](https://github.com/vuejs/test-utils/pull/2683))
- Remove wrapper div when unmount ([#&#8203;2700](https://github.com/vuejs/test-utils/pull/2700))
- Make mount options slots compatible with noUncheckedIndexedAccess true ([#&#8203;2713](https://github.com/vuejs/test-utils/pull/2713))
- Add missing peerDependency [@&#8203;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 ([#&#8203;2734](https://github.com/vuejs/test-utils/pull/2734))
##### 📖 Documentation
- Clarify findComponent vs getComponent ([#&#8203;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 ([#&#8203;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 ([#&#8203;2466](https://github.com/vuejs/test-utils/pull/2466))
- Fixed incorrect checkbox value check ([#&#8203;2495](https://github.com/vuejs/test-utils/pull/2495))
- Capital letter in sentence fix ([#&#8203;2499](https://github.com/vuejs/test-utils/pull/2499))
- Import missing DOMWrapper on Implementation of the plugin section ([#&#8203;2519](https://github.com/vuejs/test-utils/pull/2519))
- Add migration step for deprecated ref syntax in findAllComponents ([#&#8203;2498](https://github.com/vuejs/test-utils/pull/2498))
- Correct anchor hash links and fix typo ([#&#8203;2551](https://github.com/vuejs/test-utils/pull/2551))
- Center logo on home ([#&#8203;2559](https://github.com/vuejs/test-utils/pull/2559))
- **zh-cn:** Review a-crash-course ([#&#8203;2563](https://github.com/vuejs/test-utils/pull/2563))
- Use code-group for install commands ([#&#8203;2571](https://github.com/vuejs/test-utils/pull/2571))
- **zh-cn:** Review event-handing.md ([#&#8203;2572](https://github.com/vuejs/test-utils/pull/2572))
- **zh-cn:** Enhance conditional-rendering.md ([#&#8203;2562](https://github.com/vuejs/test-utils/pull/2562))
- **zh-cn:** Review easy-to-test ([#&#8203;2567](https://github.com/vuejs/test-utils/pull/2567))
- **zh-cn:** Review passing-data.md ([#&#8203;2575](https://github.com/vuejs/test-utils/pull/2575))
- **zh-cn:** Review async-suspense.md ([#&#8203;2576](https://github.com/vuejs/test-utils/pull/2576))
- **zh:** 优化 API 文档格式和内容 ([#&#8203;2569](https://github.com/vuejs/test-utils/pull/2569))
- **zh:** 更新 Vitest 模拟日期和计时器的说明 ([#&#8203;2578](https://github.com/vuejs/test-utils/pull/2578))
- **zh-cn:** Review http-requests.md ([#&#8203;2580](https://github.com/vuejs/test-utils/pull/2580))
- **zh-cn:** Review forms ([#&#8203;2582](https://github.com/vuejs/test-utils/pull/2582))
- **zh-cn:** Guide/advanced/slots.md ([#&#8203;2565](https://github.com/vuejs/test-utils/pull/2565))
- **zh:** Review extending-vtu ([#&#8203;2583](https://github.com/vuejs/test-utils/pull/2583))
- **zh:** Review index ([#&#8203;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 ([#&#8203;2616](https://github.com/vuejs/test-utils/pull/2616))
- **zh:** Review v-model and vuex ([#&#8203;2617](https://github.com/vuejs/test-utils/pull/2617))
- **zh:** Review all the rest advanced guide ([#&#8203;2619](https://github.com/vuejs/test-utils/pull/2619))
- **zh:** Review migration ([#&#8203;2623](https://github.com/vuejs/test-utils/pull/2623))
- Fix a typo in transitions.md ([#&#8203;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) ([#&#8203;2647](https://github.com/vuejs/test-utils/pull/2647))
- Typos in examples ([#&#8203;2678](https://github.com/vuejs/test-utils/pull/2678))
- Typo in easy-to-test.md ([#&#8203;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 ([#&#8203;2727](https://github.com/vuejs/test-utils/pull/2727))
- Remove "Using data" section from "Conditional Rendering" guide and fix passing data test example ([#&#8203;2743](https://github.com/vuejs/test-utils/pull/2743))
- Follow-up fixes for the conditional rendering guide ([#&#8203;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 ([#&#8203;2799](https://github.com/vuejs/test-utils/pull/2799))
- Restore Options API component for data() mounting example ([#&#8203;2804](https://github.com/vuejs/test-utils/pull/2804))
- Promote Vitest as recommended test runner ([#&#8203;2805](https://github.com/vuejs/test-utils/pull/2805))
- **api:** Note that setValue does not accept objects on `<select>` ([#&#8203;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 ([#&#8203;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 ([#&#8203;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 ([#&#8203;2825](https://github.com/vuejs/test-utils/pull/2825))
##### ❤️ Contributors
- Lachlan Miller ([@&#8203;lmiller1990](https://github.com/lmiller1990))
- cexbrayat ([@&#8203;cexbrayat](https://github.com/cexbrayat))
- Nicolas Bonamy ([@&#8203;nbonamy](https://github.com/nbonamy))
- KatWorkGit ([@&#8203;KatWorkGit](https://github.com/KatWorkGit))
- Wouter Kroes ([@&#8203;wouterkroes](https://github.com/wouterkroes))
- Rama Muhammad Murshal ([@&#8203;ramammurshal](https://github.com/ramammurshal))
- Evan You ([@&#8203;yyx990803](https://github.com/yyx990803))
- Vlad Starkovsky ([@&#8203;starkovsky](https://github.com/starkovsky))
- Joe ([@&#8203;joaoprp](https://github.com/joaoprp))
- Priyadarshi Kumar ([@&#8203;Psingh132](https://github.com/Psingh132))
- Sébastien Ronveaux ([@&#8203;sronveaux](https://github.com/sronveaux))
- Gilliam ([@&#8203;Gi11i4m](https://github.com/Gi11i4m))
- Baranov Dmytro ([@&#8203;dimas7001](https://github.com/dimas7001))
- BrendonHenrique ([@&#8203;BrendonHenrique](https://github.com/BrendonHenrique))
- Lorenz van Herwaarden ([@&#8203;lorenzvanherwaarden](https://github.com/lorenzvanherwaarden))
- wuzhiqing ([@&#8203;DDDDD12138](https://github.com/DDDDD12138))
- 阿菜 Cai ([@&#8203;RSS1102](https://github.com/RSS1102))
- Jinjiang ([@&#8203;Jinjiang](https://github.com/Jinjiang))
- Kylin ([@&#8203;lxKylin](https://github.com/lxKylin))
- Qianhe Chen ([@&#8203;chenqianhe](https://github.com/chenqianhe))
- 时瑶 ([@&#8203;KiritaniAyaka](https://github.com/KiritaniAyaka))
- h7ml ([@&#8203;h7ml](https://github.com/h7ml))
- Nicander ([@&#8203;Nicander93](https://github.com/Nicander93))
- Take-John ([@&#8203;takejohn](https://github.com/takejohn))
- ilyasherstoboev ([@&#8203;ilyasherstoboev](https://github.com/ilyasherstoboev))
- aimerie ([@&#8203;aimerie](https://github.com/aimerie))
- Miguel Rincon ([@&#8203;miguelrincon](https://github.com/miguelrincon))
- bcastlel ([@&#8203;bcastlel](https://github.com/bcastlel))
- Claudiu ([@&#8203;sofuxro](https://github.com/sofuxro))
- Artem Dragunov ([@&#8203;dragunovartem99](https://github.com/dragunovartem99))
- Robin ([@&#8203;OrbisK](https://github.com/OrbisK))
- Koen Mertens ([@&#8203;KCMertens](https://github.com/KCMertens))
- meomking ([@&#8203;CaptainWang98](https://github.com/CaptainWang98))
- Pepijn Olivier ([@&#8203;pepijnolivier](https://github.com/pepijnolivier))
- Tomina ([@&#8203;Thomaash](https://github.com/Thomaash))
- Gareth Jones ([@&#8203;G-Rath](https://github.com/G-Rath))
- Jerry Hogan ([@&#8203;hdJerry](https://github.com/hdJerry))
- Marco Pasqualetti ([@&#8203;marcalexiei](https://github.com/marcalexiei))
- guoxk ([@&#8203;guoxk-me](https://github.com/guoxk-me))
- kimulaco ([@&#8203;kimulaco](https://github.com/kimulaco))
- Erwan IQUEL ([@&#8203;Olympus5](https://github.com/Olympus5))
- Matt Van Horn ([@&#8203;mvanhorn](https://github.com/mvanhorn))
- Daniel Roe ([@&#8203;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 ([#&#8203;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 ([#&#8203;20740](https://github.com/eslint/eslint/issues/20740)) (xbinaryx)
- [`af764af`](https://github.com/eslint/eslint/commit/af764af0ec38225755fbf8a6f207f0c77b595a8d) fix: clarify language and processor validation errors ([#&#8203;20729](https://github.com/eslint/eslint/issues/20729)) (Pixel998)
- [`e251b89`](https://github.com/eslint/eslint/commit/e251b89a38280973e468a4a9386c138f4f55d10d) fix: update eslint ([#&#8203;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 ([#&#8203;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 ([#&#8203;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 ([#&#8203;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 ([#&#8203;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 ([#&#8203;20723](https://github.com/eslint/eslint/issues/20723)) (Amaresh S M)
- [`8f3fb77`](https://github.com/eslint/eslint/commit/8f3fb77f122a5641d1833cad5d93f3f54fa3be0b) docs: document `meta.docs.dialects` ([#&#8203;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 ([#&#8203;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 ([#&#8203;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 ([#&#8203;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 ([#&#8203;20762](https://github.com/eslint/eslint/issues/20762)) (renovate\[bot])
- [`51080eb`](https://github.com/eslint/eslint/commit/51080eb5c98d619434e4835dbe9f1c6654aca3b8) test: processor service ([#&#8203;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 ([#&#8203;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 ([#&#8203;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` ([#&#8203;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 #&#8203;705, #&#8203;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 [#&#8203;705](https://github.com/NaturalIntelligence/fast-xml-parser/issues/705): attributesGroupName working with preserveOrder
- fix [#&#8203;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 @&#8203;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 #&#8203;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 #&#8203;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 ([#&#8203;14725](https://github.com/vuejs/core/issues/14725)) ([bb9d265](https://github.com/vuejs/core/commit/bb9d265d8dcdde2af824fc01b24f9a7b3169f5fa)), closes [#&#8203;14724](https://github.com/vuejs/core/issues/14724)
- **reactivity:** unlink effect scopes on out-of-order off ([#&#8203;14734](https://github.com/vuejs/core/issues/14734)) ([e7659be](https://github.com/vuejs/core/commit/e7659beafc5407e892fa70f3f4ade80263b0905d)), closes [#&#8203;14733](https://github.com/vuejs/core/issues/14733)
- **runtime-dom:** preserve textarea resize dimensions ([#&#8203;14747](https://github.com/vuejs/core/issues/14747)) ([11fb2fd](https://github.com/vuejs/core/commit/11fb2fd4a246e40f6f350701dfea73ec525b4f59)), closes [#&#8203;14741](https://github.com/vuejs/core/issues/14741)
- **teleport:** don't move teleport children if not mounted ([#&#8203;14702](https://github.com/vuejs/core/issues/14702)) ([6a61f44](https://github.com/vuejs/core/commit/6a61f4452ba1a31fc929cadf8abe3337ac4d3a46)), closes [#&#8203;14701](https://github.com/vuejs/core/issues/14701)
- **transition:** preserve placeholder for conditional explicit default slots ([#&#8203;14748](https://github.com/vuejs/core/issues/14748)) ([45990ce](https://github.com/vuejs/core/commit/45990cecf4604b2f39c571ab6aefa49d362af36a)), closes [#&#8203;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 [@&#8203;zjy040525](https://github.com/zjy040525) and [@&#8203;posva](https://github.com/posva) in [#&#8203;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 [@&#8203;posva](https://github.com/posva) [<samp>(ea8e3)</samp>](https://github.com/vuejs/router/commit/ea8e3e21)
- Normalize param parsers once  -  by [@&#8203;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 [@&#8203;posva](https://github.com/posva) [<samp>(11191)</samp>](https://github.com/vuejs/router/commit/11191bca)
- Avoid double decoding hash on string location  -  by [@&#8203;posva](https://github.com/posva) [<samp>(1578c)</samp>](https://github.com/vuejs/router/commit/1578c9e9)
#####     [View changes on GitHub](https://github.com/vuejs/router/compare/v5.0.4...v5.0.5)
</details>
---
- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xMzIuMyIsInVwZGF0ZWRJblZlciI6IjQzLjE1MC4wIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->
## [1.1.3] - 2026-04-19
### Changed
- update dependency mediasoup-client to v3.18.8 (#30)
This PR contains the following updates:
| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [mediasoup-client](https://mediasoup.org) ([source](https://github.com/versatica/mediasoup-client)) | [`3.18.7` → `3.18.8`](https://renovatebot.com/diffs/npm/mediasoup-client/3.18.7/3.18.8) | ![age](https://developer.mend.io/api/mc/badges/age/npm/mediasoup-client/3.18.8?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/mediasoup-client/3.18.7/3.18.8?slim=true) |
---
### Release Notes
<details>
<summary>versatica/mediasoup-client (mediasoup-client)</summary>
### [`v3.18.8`](https://github.com/versatica/mediasoup-client/compare/3.18.7...3.18.8)
[Compare Source](https://github.com/versatica/mediasoup-client/compare/3.18.7...3.18.8)
</details>
---
- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xMjkuMCIsInVwZGF0ZWRJblZlciI6IjQzLjEyOS4wIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->
## [1.1.2] - 2026-04-15
### Changed
- Update dependency fast-xml-parser to v5.6.0 (#28)
This PR contains the following updates:
| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) | [`5.5.12` → `5.6.0`](https://renovatebot.com/diffs/npm/fast-xml-parser/5.5.12/5.6.0) | ![age](https://developer.mend.io/api/mc/badges/age/npm/fast-xml-parser/5.6.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/fast-xml-parser/5.5.12/5.6.0?slim=true) |
---
### Release Notes
<details>
<summary>NaturalIntelligence/fast-xml-parser (fast-xml-parser)</summary>
### [`v5.6.0`](https://github.com/NaturalIntelligence/fast-xml-parser/releases/tag/v5.6.0): use @&#8203;nodable/entities to replace entities
[Compare Source](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.5.12...v5.6.0)
- No API change
- No change in performance for basic usage
- No typing change
- No config change
- new dependency
- breaking: error messages for entities might have been changed.
-
**Full Changelog**: <https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.5.12...v5.6.0>
</details>
---
### Configuration
📅 **Schedule**: (UTC)
- Branch creation
- At any time (no schedule defined)
- Automerge
- At any time (no schedule defined)
🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.
**Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.
🔕 **Ignore**: Close this PR and you won't be reminded about this update again.
---
- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box
---
This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xMjAuMSIsInVwZGF0ZWRJblZlciI6IjQzLjEyMC4xIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->
## [1.1.1] - 2026-04-15
### Changed
- split push release/publish and harden workflows (#27)
### Added
* Separate release from Docker/Helm publish
* enrich releases with PRbodies when available
* tighten release.sh validation and idempotency
* trim PR docker-build metadata for act-runner stability
## [1.1.0] - 2026-04-15
### Changed
- Update all non-major dependencies (#25)
## [1.0.10] - 2026-04-15
### Changed
- Remove npm overrides for tar (#26)
## [1.0.9] - 2026-03-24
### Changed
- update https://git.keligrubb.com/actions/setup-helm action to v5 (#23)
## [1.0.8] - 2026-03-12
### Changed
- fix release file (#22)
## [1.0.7] - 2026-03-06
### Changed
- chore(deps): update docker/build-push-action action to v7 (#19)
## [1.0.6] - 2026-03-05
### Changed
- fix docker login during push stage (#18)
## [1.0.5] - 2026-03-05
### Changed
- fix deploy pipeline stages for token registry uploads (#17)
## [1.0.4] - 2026-03-04
### Changed
- fix deploy pipeline (#15)
## [1.0.3] - 2026-02-23
### Changed
- fix(deps): update dependency vue-router to v5 (#12)
## [1.0.2] - 2026-02-22
### Changed
- chore(deps): update dependency eslint to v10 (#10)
## [1.0.1] - 2026-02-22
### Changed
- chore: Configure Renovate (#7)
## [1.0.0] - 2026-02-17
### Changed
- kestrel is now a tak server (#6)
## [0.4.0] - 2026-02-15
### Changed
- new nav system (#5)
## [0.3.0] - 2026-02-14
### Changed
- heavily simplify server and app content. unify styling (#4)
## [0.2.0] - 2026-02-12
### Changed
- add a new release system (#3)
# Changelog
All notable changes to this project will be documented in this file.
+5 -3
View File
@@ -1,4 +1,5 @@
FROM node:24-slim AS builder # Build stage
FROM node:22-alpine AS builder
WORKDIR /app WORKDIR /app
@@ -9,17 +10,18 @@ COPY . .
RUN npm run build RUN npm run build
# Run stage # Run stage
FROM node:24-slim AS runner FROM node:22-alpine AS runner
# Run as non-root user (node user exists in official image) # Run as non-root user (node user exists in official image)
USER node USER node
WORKDIR /app WORKDIR /app
ENV HOST=0.0.0.0 ENV HOST=0.0.0.0
ENV PORT=3000
# Copy app as node user (builder stage ran as root) # Copy app as node user (builder stage ran as root)
COPY --from=builder --chown=node:node /app/.output ./.output COPY --from=builder --chown=node:node /app/.output ./.output
EXPOSE 3000 8089 EXPOSE 3000
CMD ["node", ".output/server/index.mjs"] CMD ["node", ".output/server/index.mjs"]
+17 -46
View File
@@ -2,8 +2,6 @@
Tactical Operations Center (TOC) for OSINT feeds. Map view with offline-capable tiles and clickable camera/feed sources; click a marker to view the live stream. Tactical Operations Center (TOC) for OSINT feeds. Map view with offline-capable tiles and clickable camera/feed sources; click a marker to view the live stream.
![KestrelOS map UI](docs/screenshot.png)
## Stack ## Stack
- Nuxt 4, JavaScript, Tailwind CSS, ESLint, Vitest - Nuxt 4, JavaScript, Tailwind CSS, ESLint, Vitest
@@ -24,7 +22,7 @@ Open http://localhost:3000. The app requires login by default; you will see the
Camera and geolocation in the browser require a **secure context** (HTTPS) when you open the app from your phone. To test Share live from a device on your LAN without buying a domain or cert: Camera and geolocation in the browser require a **secure context** (HTTPS) when you open the app from your phone. To test Share live from a device on your LAN without buying a domain or cert:
1. Generate a self-signed cert (once). Use your machine's LAN IP so the phone can use it: 1. Generate a self-signed cert (once). Use your machines LAN IP so the phone can use it:
```bash ```bash
chmod +x scripts/gen-dev-cert.sh chmod +x scripts/gen-dev-cert.sh
./scripts/gen-dev-cert.sh 192.168.1.123 ./scripts/gen-dev-cert.sh 192.168.1.123
@@ -36,7 +34,7 @@ Camera and geolocation in the browser require a **secure context** (HTTPS) when
npm run dev npm run dev
``` ```
3. On your phone, open **https://192.168.1.123:3000** (same IP you passed above). Accept the browser's "untrusted certificate" warning once (e.g. Advanced → Proceed). Then log in and use Share live; camera and location will work. 3. On your phone, open **https://192.168.1.123:3000** (same IP you passed above). Accept the browsers untrusted certificate warning once (e.g. Advanced → Proceed). Then log in and use Share live; camera and location will work.
Without the certs, `npm run dev` still runs over HTTP as before. Without the certs, `npm run dev` still runs over HTTP as before.
@@ -50,72 +48,45 @@ The **Share live** feature uses WebRTC for real-time video streaming from mobile
- **Mediasoup** server (runs automatically in the Nuxt process) - **Mediasoup** server (runs automatically in the Nuxt process)
- **mediasoup-client** (browser library, included automatically) - **mediasoup-client** (browser library, included automatically)
**Streaming from a phone on your LAN:** The server auto-detects your machine's LAN IP (from network interfaces) and uses it for WebRTC. Open **https://<your-LAN-IP>:3000** on both phone and laptop (same IP as for your dev cert). To override (e.g. Docker or multiple NICs), set `MEDIASOUP_ANNOUNCED_IP`. Ensure firewall allows UDP/TCP ports 40000-49999 on the server. **Streaming from a phone on your LAN:** The server auto-detects your machines LAN IP (from network interfaces) and uses it for WebRTC. Open **https://<your-LAN-IP>:3000** on both phone and laptop (same IP as for your dev cert). To override (e.g. Docker or multiple NICs), set `MEDIASOUP_ANNOUNCED_IP`. Ensure firewall allows UDP/TCP ports 4000049999 on the server.
See [docs/live-streaming.md](docs/live-streaming.md) for setup and usage. See [docs/live-streaming.md](docs/live-streaming.md) for architecture details.
### 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. CoT runs on port 8089 (default).
## Scripts ## Scripts
- `npm run dev` - development server - `npm run dev` development server
- `npm run build` - production build - `npm run build` production build
- `npm run test` - run tests - `npm run test` run tests
- `npm run test:coverage` - run tests with coverage (85% threshold) - `npm run test:coverage` run tests with coverage (85% threshold)
- `npm run test:e2e` - Playwright E2E tests - `npm run lint` ESLint (zero warnings)
- `npm run lint` - ESLint (zero warnings)
## Documentation
Full docs are in the **[docs/](docs/README.md)** directory: [installation](docs/installation.md) (npm, Docker, Helm), [authentication](docs/auth.md) (local login, OIDC), [map and cameras](docs/map-and-cameras.md) (adding IPTV, ALPR, CCTV, NVR, etc.), [ATAK and iTAK](docs/atak-itak.md), and [Share live](docs/live-streaming.md) (mobile device as live camera).
## Configuration ## 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`). - **Feeds**: Edit `server/data/feeds.json` to add cameras/feeds. Each feed needs `id`, `name`, `lat`, `lng`, `streamUrl`, and `sourceType` (`mjpeg` or `hls`). Home Assistant and other sources use the same shape; use proxy URLs for HA.
- **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`. - **Environment**: No required env vars for basic run. For production, set `HOST=0.0.0.0` and `PORT` as needed (e.g. in Docker/Helm).
- **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. - **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 provider-specific examples.
- **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. - **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 dont set them, a default admin is created (identifier: `admin`) with a random password and the credentials are printed in the terminalcopy 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. - **Database**: SQLite file at `data/kestrelos.db` (created automatically). Contains users, sessions, and POIs.
## Docker ## Docker
```bash ```bash
docker build -t kestrelos:latest . docker build -t kestrelos:latest .
docker run -p 3000:3000 -p 8089:8089 kestrelos:latest docker run -p 3000:3000 kestrelos:latest
``` ```
## Kubernetes (Helm) ## Kubernetes (Helm)
**From Gitea registry:**
```bash ```bash
helm repo add keligrubb --username YOUR_USER --password YOUR_TOKEN https://git.keligrubb.com/api/packages/keligrubb/helm helm install kestrelos ./helm/kestrelos --set image.repository=your-registry/kestrelos --set image.tag=latest
helm repo update
helm install kestrelos keligrubb/kestrelos
```
**From source:**
```bash
helm install kestrelos ./helm/kestrelos
``` ```
Health: `GET /health` (overview), `GET /health/live` (liveness), `GET /health/ready` (readiness). Probes are configured in the Helm chart. Optional: enable Ingress in `helm/kestrelos/values.yaml`. Health: `GET /health` (overview), `GET /health/live` (liveness), `GET /health/ready` (readiness). Probes are configured in the Helm chart. Optional: enable Ingress in `helm/kestrelos/values.yaml`.
## Releases
Merges to `main` trigger a semver release. Use one of these prefixes in your PR title to set the version bump:
- `major:` - breaking changes
- `minor:` - new features
- `patch:` - bug fixes, docs (default if no prefix)
Example: `minor: Add map layer toggle`
## Security ## Security
- Device data is validated server-side; only valid entries are returned. - Feed list is validated server-side (`getValidFeeds`); only valid entries are returned.
- Stream URLs are sanitized to `http://` or `https://` only; other protocols are rejected. - Stream URLs are treated as untrusted; the UI only uses `http://` or `https://` URLs for display.
## License ## License
-170
View File
@@ -1,170 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer components {
.kestrel-page-heading { @apply text-xl font-semibold tracking-wide text-kestrel-text text-shadow-glow-sm; }
.kestrel-section-heading { @apply text-lg font-semibold tracking-wide text-kestrel-text text-shadow-glow-sm; }
.kestrel-panel-header { @apply flex items-center justify-between border-b border-kestrel-border px-4 py-3 shadow-border-header; }
.kestrel-video-frame { @apply relative aspect-video w-full overflow-hidden rounded border border-kestrel-border bg-black shadow-glow-inset-video; }
.kestrel-close-btn { @apply rounded p-1 text-kestrel-muted transition-colors hover:bg-kestrel-border hover:text-kestrel-accent; }
.kestrel-card { @apply rounded border border-kestrel-border bg-kestrel-surface shadow-glow-card; }
.kestrel-card-modal { @apply rounded-lg border border-kestrel-border bg-kestrel-surface shadow-glow-modal; }
.kestrel-label { @apply mb-1.5 block text-xs font-medium uppercase tracking-wider text-kestrel-muted; }
.kestrel-section-label { @apply mb-2 text-sm font-medium uppercase tracking-wider text-kestrel-muted; }
.kestrel-input { @apply w-full rounded border border-kestrel-border bg-kestrel-bg px-3 py-2 text-sm text-kestrel-text placeholder:text-kestrel-muted outline-none transition-colors focus:border-kestrel-accent; }
.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; }
}
/* Transitions: modal + drawer-backdrop (same fade) */
.modal-enter-active, .modal-leave-active,
.drawer-backdrop-enter-active, .drawer-backdrop-leave-active { transition: opacity 0.2s ease; }
.modal-enter-from, .modal-leave-to,
.drawer-backdrop-enter-from, .drawer-backdrop-leave-to { opacity: 0; }
.dropdown-enter-active, .dropdown-leave-active { transition: opacity 0.15s ease, transform 0.15s ease; }
.dropdown-enter-from, .dropdown-leave-to { opacity: 0; transform: translateY(-4px); }
.modal-enter-active .relative, .modal-leave-active .relative { transition: transform 0.2s ease; }
.modal-enter-from .relative, .modal-leave-to .relative { transform: scale(0.96); }
.nav-drawer { box-shadow: 8px 0 24px -4px rgba(34, 201, 201, 0.12); }
@media (min-width: 768px) { .nav-drawer { box-shadow: none; } }
/* Leaflet map */
.kestrel-map-container {
background: #000 !important;
}
.kestrel-map-container .leaflet-container {
border: none !important;
outline: none !important;
}
.kestrel-map-container .leaflet-tile-pane,
.kestrel-map-container .leaflet-map-pane,
.kestrel-map-container .leaflet-tile-container {
background: #000 !important;
}
.kestrel-map-container img.leaflet-tile {
background: #000 !important;
mix-blend-mode: normal;
}
.kestrel-map-container .poi-div-icon {
background: none;
border: none;
}
.kestrel-map-container .poi-icon-svg {
display: block;
width: 100%;
height: 100%;
pointer-events: none;
}
.kestrel-map-container .kestrel-poi-tooltip,
.kestrel-map-container .kestrel-live-popup-wrap .leaflet-popup-content-wrapper,
.kestrel-map-container .kestrel-live-popup-wrap .leaflet-popup-tip {
@apply bg-kestrel-surface-elevated border border-kestrel-glow rounded-md shadow-elevated;
}
.kestrel-map-container .kestrel-poi-tooltip {
@apply text-kestrel-text-bright text-xs font-[inherit] py-1.5 px-2.5;
}
.kestrel-map-container .kestrel-poi-tooltip::before,
.kestrel-map-container .kestrel-poi-tooltip::after {
border-color: #1e293b;
}
.kestrel-map-container .kestrel-live-popup-wrap .leaflet-popup-content {
@apply text-kestrel-text-bright my-2 mx-3 min-w-[200px];
}
.kestrel-map-container .kestrel-live-popup {
@apply text-kestrel-text-bright text-xs;
}
.kestrel-map-container .kestrel-live-popup img {
@apply block max-h-40 w-auto rounded bg-kestrel-bg;
}
.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;
height: 32px !important;
line-height: 32px !important;
background: #0d1424 !important;
color: #b8c9e0 !important;
text-decoration: none !important;
}
.kestrel-map-container .leaflet-control-zoom a + a,
.kestrel-map-container .savetiles.leaflet-bar a + a {
border-top: 1px solid rgba(34, 201, 201, 0.2);
}
.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;
}
.kestrel-map-container .savetiles.leaflet-bar a {
@apply min-w-[5.5em] leading-tight py-1.5 px-2.5 whitespace-nowrap text-center text-[11px] font-medium tracking-wide;
width: auto !important;
height: auto !important;
line-height: 1.25 !important;
padding: 6px 10px !important;
font-size: 11px !important;
}
.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-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;
}
@keyframes live-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
-115
View File
@@ -1,115 +0,0 @@
<template>
<BaseModal
:show="show"
aria-labelledby="add-user-title"
@close="$emit('close')"
>
<div class="kestrel-card-modal w-full max-w-sm p-4">
<h3
id="add-user-title"
class="mb-3 text-sm font-medium text-kestrel-text"
>
Add user
</h3>
<form @submit.prevent="onSubmit">
<div class="mb-3 flex flex-col gap-1">
<label
for="add-identifier"
class="text-xs text-kestrel-muted"
>Username</label>
<input
id="add-identifier"
v-model="form.identifier"
type="text"
required
autocomplete="username"
class="kestrel-input"
placeholder="username"
>
</div>
<div class="mb-3 flex flex-col gap-1">
<label
for="add-password"
class="text-xs text-kestrel-muted"
>Password</label>
<input
id="add-password"
v-model="form.password"
type="password"
required
autocomplete="new-password"
class="kestrel-input"
placeholder="••••••••"
>
</div>
<div class="mb-4 flex flex-col gap-1">
<label
for="add-role"
class="text-xs text-kestrel-muted"
>Role</label>
<select
id="add-role"
v-model="form.role"
class="kestrel-input"
>
<option value="member">
member
</option>
<option value="leader">
leader
</option>
<option value="admin">
admin
</option>
</select>
</div>
<p
v-if="submitError"
class="mb-2 text-xs text-red-400"
>
{{ submitError }}
</p>
<div class="flex justify-end gap-2">
<button
type="button"
class="kestrel-btn-secondary"
@click="$emit('close')"
>
Cancel
</button>
<button
type="submit"
class="rounded border border-kestrel-accent px-3 py-1.5 text-sm text-kestrel-accent hover:bg-kestrel-accent-dim"
>
Add user
</button>
</div>
</form>
</div>
</BaseModal>
</template>
<script setup>
import { ref, watch } from 'vue'
const props = defineProps({
show: Boolean,
submitError: { type: String, default: '' },
})
const emit = defineEmits(['close', 'submit'])
const form = ref({ identifier: '', password: '', role: 'member' })
watch(() => props.show, (show) => {
if (show) form.value = { identifier: '', password: '', role: 'member' }
})
function onSubmit() {
emit('submit', {
identifier: form.value.identifier.trim(),
password: form.value.password,
role: form.value.role,
})
}
</script>
-95
View File
@@ -1,95 +0,0 @@
<template>
<div class="relative">
<div ref="triggerRef">
<slot />
</div>
<Teleport
v-if="teleport"
to="body"
>
<Transition
enter-active-class="transition duration-100 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition duration-75 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div
v-if="open && placement"
ref="menuRef"
role="menu"
class="fixed z-[100] min-w-[6rem] rounded border border-kestrel-border bg-kestrel-surface py-1 shadow-glow shadow-glow-dropdown"
:style="menuStyle"
>
<slot name="menu" />
</div>
</Transition>
</Teleport>
<Transition
v-else
name="dropdown"
>
<div
v-if="open"
ref="menuRef"
role="menu"
class="absolute right-0 top-full z-[2001] mt-1 min-w-[160px] rounded border border-kestrel-border bg-kestrel-surface py-1 shadow-glow"
>
<slot name="menu" />
</div>
</Transition>
</div>
</template>
<script setup>
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
const props = defineProps({
open: { type: Boolean, default: false },
teleport: { type: Boolean, default: false },
})
const emit = defineEmits(['close'])
const triggerRef = ref(null)
const menuRef = ref(null)
const placement = ref(null)
const menuStyle = computed(() => {
if (!placement.value) return undefined
const p = placement.value
return { top: p.top + 'px', left: p.left + 'px', minWidth: p.minWidth + 'px' }
})
watch(() => props.open, (open) => {
if (open && triggerRef.value && props.teleport) {
nextTick(() => {
const rect = triggerRef.value.getBoundingClientRect()
placement.value = {
top: rect.bottom + 4,
left: rect.left,
minWidth: Math.max(rect.width, 96),
}
})
}
else {
placement.value = null
}
})
function onDocumentClick(e) {
if (!props.open) return
const trigger = triggerRef.value
const menu = menuRef.value
const inTrigger = trigger && trigger.contains(e.target)
const inMenu = menu && menu.contains(e.target)
if (!inTrigger && !inMenu) emit('close')
}
onMounted(() => {
document.addEventListener('click', onDocumentClick)
})
onBeforeUnmount(() => {
document.removeEventListener('click', onDocumentClick)
})
</script>
-94
View File
@@ -1,94 +0,0 @@
<template>
<div class="flex min-h-0 flex-1 flex-col">
<header class="relative z-40 flex h-14 shrink-0 items-center gap-3 bg-kestrel-surface px-4">
<NuxtLink
to="/"
class="text-lg font-semibold tracking-wide text-kestrel-text no-underline text-shadow-glow-md transition-colors hover:text-kestrel-accent focus-visible:ring-2 focus-visible:ring-kestrel-accent focus-visible:rounded"
>
KestrelOS
</NuxtLink>
<button
type="button"
class="rounded p-2 text-kestrel-muted transition-colors hover:bg-kestrel-border hover:text-kestrel-accent md:hidden"
aria-label="Toggle navigation"
:aria-expanded="drawerOpen"
@click="drawerOpen = !drawerOpen"
>
<span
class="text-lg leading-none"
aria-hidden="true"
>&#9776;</span>
</button>
<div class="min-w-0 flex-1" />
<div class="flex items-center gap-2">
<UserMenu
v-if="user"
:user="user"
@signout="onLogout"
/>
<span
v-else-if="authPending"
class="inline-block h-8 w-8"
aria-hidden="true"
/>
<NuxtLink
v-else
to="/login"
class="rounded px-2 py-1 text-xs text-kestrel-muted hover:bg-kestrel-border hover:text-kestrel-accent"
>
Sign in
</NuxtLink>
</div>
</header>
<div class="flex min-h-0 flex-1">
<NavDrawer
v-model="drawerOpen"
v-model:collapsed="sidebarCollapsed"
:is-mobile="isMobile"
/>
<!-- Content area: rounded top-left so it nestles into the shell (GitLab gl-rounded-t-lg style). -->
<div class="relative min-h-0 flex-1 min-w-0 overflow-clip rounded-tl-lg">
<main class="relative h-full w-full min-h-0 overflow-auto">
<slot />
</main>
</div>
</div>
</div>
</template>
<script setup>
const isMobile = useMediaQuery('(max-width: 767px)')
const drawerOpen = ref(true)
const SIDEBAR_COLLAPSED_KEY = 'kestrelos-sidebar-collapsed'
const sidebarCollapsed = ref(false)
onMounted(() => {
try {
const stored = localStorage.getItem(SIDEBAR_COLLAPSED_KEY)
if (stored !== null) sidebarCollapsed.value = stored === 'true'
}
catch {
// localStorage unavailable (e.g. private mode)
}
})
watch(sidebarCollapsed, (v) => {
try {
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(v))
}
catch {
// localStorage unavailable
}
})
const { user, authPending, refresh } = useUser()
watch(isMobile, (mobile) => {
if (mobile) drawerOpen.value = false
}, { immediate: true })
async function onLogout() {
await $fetch('/api/auth/logout', { method: 'POST' })
await refresh()
await navigateTo('/')
}
</script>
-36
View File
@@ -1,36 +0,0 @@
<template>
<Teleport to="body">
<Transition name="modal">
<div
v-if="show"
class="fixed inset-0 z-[2000] flex items-center justify-center p-4"
role="dialog"
aria-modal="true"
:aria-labelledby="ariaLabelledby"
@keydown.escape="$emit('close')"
>
<button
type="button"
class="absolute inset-0 bg-black/60 transition-opacity"
aria-label="Close"
@click="$emit('close')"
/>
<div
class="relative w-full"
@click.stop
>
<slot />
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup>
defineProps({
show: Boolean,
ariaLabelledby: { type: String, default: undefined },
})
defineEmits(['close'])
</script>
+13 -9
View File
@@ -7,18 +7,18 @@
/> />
<aside <aside
v-else v-else
class="kestrel-panel-base" class="flex flex-col border border-kestrel-border bg-kestrel-surface"
:class="inline ? 'kestrel-panel-inline' : 'kestrel-panel-overlay'" :class="asideClass"
role="dialog" role="dialog"
aria-label="Camera feed" aria-label="Camera feed"
> >
<div class="kestrel-panel-header"> <div class="flex items-center justify-between border-b border-kestrel-border px-4 py-3 [box-shadow:0_1px_0_0_rgba(34,201,201,0.08)]">
<h2 class="font-medium tracking-wide text-kestrel-text text-shadow-glow-sm"> <h2 class="font-medium tracking-wide text-kestrel-text [text-shadow:0_0_8px_rgba(34,201,201,0.25)]">
{{ camera?.name ?? 'Camera' }} {{ camera?.name ?? 'Camera' }}
</h2> </h2>
<button <button
type="button" type="button"
class="kestrel-close-btn" class="rounded p-1 text-kestrel-muted transition-colors hover:bg-kestrel-border hover:text-kestrel-accent"
aria-label="Close panel" aria-label="Close panel"
@click="$emit('close')" @click="$emit('close')"
> >
@@ -26,7 +26,7 @@
</button> </button>
</div> </div>
<div class="flex-1 overflow-auto p-4"> <div class="flex-1 overflow-auto p-4">
<div class="kestrel-video-frame"> <div class="relative aspect-video w-full overflow-hidden rounded border border-kestrel-border bg-black [box-shadow:inset_0_0_20px_-8px_rgba(34,201,201,0.1)]">
<template v-if="sourceType === 'hls'"> <template v-if="sourceType === 'hls'">
<video <video
ref="videoRef" ref="videoRef"
@@ -75,14 +75,18 @@ defineEmits(['close'])
const videoRef = ref(null) const videoRef = ref(null)
const streamError = ref(false) const streamError = ref(false)
const isLiveSession = computed(() => props.camera?.hasStream !== undefined) const isLiveSession = computed(() =>
props.camera && typeof props.camera.hasStream !== 'undefined')
const asideClass = computed(() =>
props.inline ? 'rounded-lg shadow-glow' : 'absolute right-0 top-0 z-[1000] h-full w-full border-l shadow-glow md:w-[420px] [box-shadow:-8px_0_24px_-4px_rgba(34,201,201,0.12)]')
const streamUrl = computed(() => props.camera?.streamUrl ?? '') const streamUrl = computed(() => props.camera?.streamUrl ?? '')
const sourceType = computed(() => (props.camera?.sourceType === 'hls' ? 'hls' : 'mjpeg')) const sourceType = computed(() => (props.camera?.sourceType === 'hls' ? 'hls' : 'mjpeg'))
const safeStreamUrl = computed(() => { const safeStreamUrl = computed(() => {
const u = streamUrl.value?.trim() const u = streamUrl.value
return (u?.startsWith('http://') || u?.startsWith('https://')) ? u : '' return typeof u === 'string' && u.trim() && (u.startsWith('http://') || u.startsWith('https://')) ? u.trim() : ''
}) })
function initHls() { function initHls() {
-46
View File
@@ -1,46 +0,0 @@
<template>
<BaseModal
:show="!!user"
aria-labelledby="delete-user-title"
@close="$emit('close')"
>
<div
v-if="user"
class="kestrel-card-modal w-full max-w-sm p-4"
>
<h3
id="delete-user-title"
class="mb-2 text-sm font-medium text-kestrel-text"
>
Delete user?
</h3>
<p class="mb-4 text-sm text-kestrel-muted">
Are you sure you want to delete <strong class="text-kestrel-text">{{ user.identifier }}</strong>? They will not be able to sign in again.
</p>
<div class="flex justify-end gap-2">
<button
type="button"
class="kestrel-btn-secondary"
@click="$emit('close')"
>
Cancel
</button>
<button
type="button"
class="rounded border border-red-500/60 bg-red-500/10 px-3 py-1.5 text-sm text-red-400 hover:bg-red-500/20"
@click="$emit('confirm')"
>
Delete
</button>
</div>
</div>
</BaseModal>
</template>
<script setup>
defineProps({
user: { type: Object, default: null },
})
defineEmits(['close', 'confirm'])
</script>
-95
View File
@@ -1,95 +0,0 @@
<template>
<BaseModal
:show="!!user"
aria-labelledby="edit-user-title"
@close="$emit('close')"
>
<div
v-if="user"
class="kestrel-card-modal w-full max-w-sm p-4"
>
<h3
id="edit-user-title"
class="mb-3 text-sm font-medium text-kestrel-text"
>
Edit local user
</h3>
<form @submit.prevent="onSubmit">
<div class="mb-3 flex flex-col gap-1">
<label
for="edit-identifier"
class="text-xs text-kestrel-muted"
>Identifier</label>
<input
id="edit-identifier"
v-model="form.identifier"
type="text"
required
class="kestrel-input"
>
</div>
<div class="mb-4 flex flex-col gap-1">
<label
for="edit-password"
class="text-xs text-kestrel-muted"
>New password (leave blank to keep)</label>
<input
id="edit-password"
v-model="form.password"
type="password"
autocomplete="new-password"
class="kestrel-input"
placeholder="••••••••"
>
<p class="mt-0.5 text-xs text-kestrel-muted">
If you change your password, use the new one next time you sign in.
</p>
</div>
<p
v-if="submitError"
class="mb-2 text-xs text-red-400"
>
{{ submitError }}
</p>
<div class="flex justify-end gap-2">
<button
type="button"
class="kestrel-btn-secondary"
@click="$emit('close')"
>
Cancel
</button>
<button
type="submit"
class="rounded border border-kestrel-accent px-3 py-1.5 text-sm text-kestrel-accent hover:bg-kestrel-accent-dim"
>
Save
</button>
</div>
</form>
</div>
</BaseModal>
</template>
<script setup>
import { ref, watch } from 'vue'
const props = defineProps({
user: { type: Object, default: null },
submitError: { type: String, default: '' },
})
const emit = defineEmits(['close', 'submit'])
const form = ref({ identifier: '', password: '' })
watch(() => props.user, (u) => {
if (u) form.value = { identifier: u.identifier, password: '' }
}, { immediate: true })
function onSubmit() {
const payload = { identifier: form.value.identifier.trim() }
if (form.value.password) payload.password = form.value.password
emit('submit', payload)
}
</script>
+373 -187
View File
@@ -7,13 +7,13 @@
<div <div
v-if="contextMenu.type" v-if="contextMenu.type"
ref="contextMenuRef" ref="contextMenuRef"
class="pointer-events-auto absolute z-[1000] min-w-[120px] rounded border border-kestrel-border bg-kestrel-surface py-1 shadow-glow shadow-glow-context" class="pointer-events-auto absolute z-[1000] min-w-[120px] rounded border border-kestrel-border bg-kestrel-surface py-1 shadow-glow [box-shadow:0_0_20px_-4px_rgba(34,201,201,0.2)]"
:style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }" :style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }"
> >
<template v-if="contextMenu.type === 'map'"> <template v-if="contextMenu.type === 'map'">
<button <button
type="button" type="button"
class="kestrel-context-menu-item" class="block w-full px-3 py-1.5 text-left text-sm text-kestrel-text hover:bg-kestrel-border"
@click="openAddPoiModal(contextMenu.latlng)" @click="openAddPoiModal(contextMenu.latlng)"
> >
Add POI here Add POI here
@@ -22,14 +22,14 @@
<template v-else-if="contextMenu.type === 'poi'"> <template v-else-if="contextMenu.type === 'poi'">
<button <button
type="button" type="button"
class="kestrel-context-menu-item" class="block w-full px-3 py-1.5 text-left text-sm text-kestrel-text hover:bg-kestrel-border"
@click="openEditPoiModal(contextMenu.poi)" @click="openEditPoiModal(contextMenu.poi)"
> >
Edit Edit
</button> </button>
<button <button
type="button" type="button"
class="kestrel-context-menu-item-danger" class="block w-full px-3 py-1.5 text-left text-sm text-red-400 hover:bg-kestrel-border"
@click="openDeletePoiModal(contextMenu.poi)" @click="openDeletePoiModal(contextMenu.poi)"
> >
Delete Delete
@@ -37,53 +37,187 @@
</template> </template>
</div> </div>
<PoiModal <!-- POI modal (Add / Edit) -->
:show="showPoiModal" <Teleport to="body">
:mode="poiModalMode" <Transition name="modal">
:form="poiForm"
:edit-poi="editPoi"
:delete-poi="deletePoi"
@close="closePoiModal"
@submit="onPoiSubmit"
@confirm-delete="confirmDeletePoi"
/>
<div <div
v-if="mapContext" v-if="showPoiModal"
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" class="fixed inset-0 z-[2000] flex items-center justify-center p-4"
data-testid="cot-layer-toggles" role="dialog"
aria-modal="true"
:aria-labelledby="poiModalMode === 'delete' ? 'delete-poi-title' : 'poi-modal-title'"
@keydown.escape="closePoiModal"
> >
<button <button
v-for="layer in COT_LAYERS"
:key="layer.key"
type="button" type="button"
class="kestrel-cot-layer-btn" class="absolute inset-0 bg-black/60 transition-opacity"
:class="{ 'kestrel-cot-layer-btn-active': cotLayers[layer.key] }" aria-label="Close"
@click="emit('toggleCotLayer', layer.key)" @click="closePoiModal"
/>
<!-- Add / Edit form -->
<div
v-if="poiModalMode === 'add' || poiModalMode === 'edit'"
ref="poiModalRef"
class="relative w-full max-w-md rounded-lg border border-kestrel-border bg-kestrel-surface p-6 shadow-glow [box-shadow:0_0_32px_-8px_rgba(34,201,201,0.25)]"
@click.stop
> >
{{ layer.label }} <h2
id="poi-modal-title"
class="mb-4 text-lg font-semibold tracking-wide text-kestrel-text [text-shadow:0_0_8px_rgba(34,201,201,0.25)]"
>
{{ poiModalMode === 'edit' ? 'Edit POI' : 'Add POI' }}
</h2>
<form
class="space-y-4"
@submit.prevent="submitPoiModal"
>
<div>
<label
for="add-poi-label"
class="mb-1.5 block text-xs font-medium uppercase tracking-wider text-kestrel-muted"
>
Label (optional)
</label>
<input
id="add-poi-label"
v-model="poiForm.label"
type="text"
placeholder="e.g. Rally point"
class="w-full rounded border border-kestrel-border bg-kestrel-bg px-3 py-2 text-sm text-kestrel-text placeholder:text-kestrel-muted outline-none transition-colors focus:border-kestrel-accent"
autocomplete="off"
>
</div>
<div>
<label
class="mb-1.5 block text-xs font-medium uppercase tracking-wider text-kestrel-muted"
>
Icon type
</label>
<div
:ref="el => iconDropdownOpen && (iconDropdownRef.value = el)"
class="relative inline-block w-full"
>
<button
type="button"
class="flex w-full min-w-0 items-center justify-between gap-2 rounded border border-kestrel-border bg-kestrel-bg px-3 py-2 text-left text-sm text-kestrel-text transition-colors hover:border-kestrel-accent/50"
:aria-expanded="iconDropdownOpen"
aria-haspopup="listbox"
:aria-label="`Icon type: ${poiForm.iconType}`"
@click="iconDropdownOpen = !iconDropdownOpen"
>
<span class="flex items-center gap-2 capitalize">
<Icon
:name="POI_ICONIFY_IDS[poiForm.iconType]"
class="size-4 shrink-0"
/>
{{ poiForm.iconType }}
</span>
<span
class="text-kestrel-muted transition-transform"
:class="iconDropdownOpen && 'rotate-180'"
>
</span>
</button>
<Transition
enter-active-class="transition duration-100 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition duration-75 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div
v-show="iconDropdownOpen"
class="absolute left-0 right-0 top-full z-10 mt-1 rounded border border-kestrel-border bg-kestrel-surface py-1 shadow-glow [box-shadow:0_4px_12px_-2px_rgba(34,201,201,0.15)]"
role="listbox"
>
<button
v-for="opt in POI_ICON_TYPES"
:key="opt"
type="button"
role="option"
:aria-selected="poiForm.iconType === opt"
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm capitalize transition-colors"
:class="poiForm.iconType === opt
? 'bg-kestrel-accent-dim text-kestrel-accent'
: 'text-kestrel-text hover:bg-kestrel-border'"
@click="poiForm.iconType = opt; iconDropdownOpen = false"
>
<Icon
:name="POI_ICONIFY_IDS[opt]"
class="size-4 shrink-0"
/>
{{ opt }}
</button> </button>
</div> </div>
</Transition>
</div>
</div>
<div class="flex justify-end gap-2 pt-2">
<button
type="button"
class="rounded border border-kestrel-border px-4 py-2 text-sm text-kestrel-text transition-colors hover:bg-kestrel-border"
@click="closePoiModal"
>
Cancel
</button>
<button
type="submit"
class="rounded bg-kestrel-accent px-4 py-2 text-sm font-medium text-kestrel-bg transition-opacity hover:opacity-90"
>
{{ poiModalMode === 'edit' ? 'Save changes' : 'Add POI' }}
</button>
</div>
</form>
</div>
<!-- Delete confirmation -->
<div
v-if="poiModalMode === 'delete'"
ref="poiModalRef"
class="relative w-full max-w-sm rounded-lg border border-kestrel-border bg-kestrel-surface p-6 shadow-glow [box-shadow:0_0_32px_-8px_rgba(34,201,201,0.25)]"
@click.stop
>
<h2
id="delete-poi-title"
class="mb-2 text-lg font-semibold tracking-wide text-kestrel-text [text-shadow:0_0_8px_rgba(34,201,201,0.25)]"
>
Delete POI?
</h2>
<p class="mb-4 text-sm text-kestrel-muted">
{{ deletePoi?.label ? `${deletePoi.label}” will be removed.` : 'This POI will be removed.' }}
</p>
<div class="flex justify-end gap-2">
<button
type="button"
class="rounded border border-kestrel-border px-4 py-2 text-sm text-kestrel-text transition-colors hover:bg-kestrel-border"
@click="closePoiModal"
>
Cancel
</button>
<button
type="button"
class="rounded bg-red-600 px-4 py-2 text-sm font-medium text-white transition-opacity hover:opacity-90"
@click="confirmDeletePoi"
>
Delete
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</div> </div>
</template> </template>
<script setup> <script setup>
import 'leaflet/dist/leaflet.css' 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({ const props = defineProps({
feeds: {
type: Array,
default: () => [],
},
devices: { devices: {
type: Array, type: Array,
default: () => [], default: () => [],
@@ -96,29 +230,13 @@ const props = defineProps({
type: Array, type: Array,
default: () => [], default: () => [],
}, },
cotEntities: {
type: Array,
default: () => [],
},
cotLayers: {
type: Object,
default: () => ({ air: true, surface: true, ground: true }),
},
alprMarkers: {
type: Array,
default: () => [],
},
showAlpr: {
type: Boolean,
default: false,
},
canEditPois: { canEditPois: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
}) })
const emit = defineEmits(['select', 'selectLive', 'refreshPois', 'boundsChange', 'toggleAlpr', 'toggleCotLayer']) const emit = defineEmits(['select', 'selectLive', 'refreshPois'])
const CONTEXT_MENU_EMPTY = Object.freeze({ type: null, poi: null, latlng: null, x: 0, y: 0 }) const CONTEXT_MENU_EMPTY = Object.freeze({ type: null, poi: null, latlng: null, x: 0, y: 0 })
const mapRef = ref(null) const mapRef = ref(null)
const contextMenuRef = ref(null) const contextMenuRef = ref(null)
@@ -127,18 +245,17 @@ const mapContext = ref(null)
const markersRef = ref([]) const markersRef = ref([])
const poiMarkersRef = ref({}) const poiMarkersRef = ref({})
const liveMarkersRef = ref({}) const liveMarkersRef = ref({})
const cotLayerRef = ref(null)
const cotMapView = ref(null)
const alprLayerRef = ref(null)
const contextMenu = ref({ ...CONTEXT_MENU_EMPTY }) const contextMenu = ref({ ...CONTEXT_MENU_EMPTY })
const showPoiModal = ref(false) const showPoiModal = ref(false)
const poiModalRef = ref(null)
const poiModalMode = ref('add') // 'add' | 'edit' | 'delete' const poiModalMode = ref('add') // 'add' | 'edit' | 'delete'
const addPoiLatlng = ref(null) const addPoiLatlng = ref(null)
const editPoi = ref(null) const editPoi = ref(null)
const deletePoi = ref(null) const deletePoi = ref(null)
const poiForm = ref({ label: '', iconType: 'pin' }) const poiForm = ref({ label: '', iconType: 'pin' })
const resizeObserver = ref(null) const iconDropdownOpen = ref(false)
const iconDropdownRef = ref(null)
const TILE_URL = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png' const TILE_URL = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png'
const TILE_SUBDOMAINS = 'abcd' const TILE_SUBDOMAINS = 'abcd'
@@ -146,13 +263,12 @@ const ATTRIBUTION = '&copy; <a href="https://www.openstreetmap.org/copyright">Op
const DEFAULT_VIEW = [37.7749, -122.4194] const DEFAULT_VIEW = [37.7749, -122.4194]
const DEFAULT_ZOOM = 17 const DEFAULT_ZOOM = 17
const MARKER_ICON_PATH = '/' const MARKER_ICON_PATH = '/'
const POI_ICON_TYPES = ['pin', 'flag', 'waypoint']
const POI_TOOLTIP_CLASS = 'kestrel-poi-tooltip' const POI_TOOLTIP_CLASS = 'kestrel-poi-tooltip'
/** Tabler icon names (Nuxt Icon / Iconify) modern technical aesthetic. */
const POI_ICONIFY_IDS = { pin: 'tabler:map-pin', flag: 'tabler:flag', waypoint: 'tabler:current-location' }
const POI_ICON_COLORS = { pin: '#22c9c9', flag: '#e53e3e', waypoint: '#a78bfa' } 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 const ICON_SIZE = 28
@@ -167,9 +283,8 @@ function getPoiIconSvg(type) {
return shapes[type] || shapes.pin return shapes[type] || shapes.pin
} }
const VALID_POI_TYPES = ['pin', 'flag', 'waypoint']
function getPoiIcon(L, poi) { function getPoiIcon(L, poi) {
const type = VALID_POI_TYPES.includes(poi.icon_type) ? poi.icon_type : 'pin' const type = poi.icon_type === 'pin' || poi.icon_type === 'flag' || poi.icon_type === 'waypoint' ? poi.icon_type : 'pin'
const html = getPoiIconSvg(type) const html = getPoiIconSvg(type)
return L.divIcon({ return L.divIcon({
className: 'poi-div-icon', className: 'poi-div-icon',
@@ -179,7 +294,7 @@ function getPoiIcon(L, poi) {
}) })
} }
const LIVE_ICON_COLOR = '#22c9c9' /* kestrel-accent - JS string for Leaflet SVG */ const LIVE_ICON_COLOR = '#22c9c9'
function getLiveSessionIcon(L) { function getLiveSessionIcon(L) {
const html = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${LIVE_ICON_COLOR}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="5"/><circle cx="12" cy="12" r="2" fill="${LIVE_ICON_COLOR}"/></svg>` const html = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${LIVE_ICON_COLOR}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="5"/><circle cx="12" cy="12" r="2" fill="${LIVE_ICON_COLOR}"/></svg>`
return L.divIcon({ return L.divIcon({
@@ -190,44 +305,6 @@ function getLiveSessionIcon(L) {
}) })
} }
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(),
})
}
function createMap(initialCenter) { function createMap(initialCenter) {
const { L, offlineApi } = leafletRef.value || {} const { L, offlineApi } = leafletRef.value || {}
if (typeof document === 'undefined' || !mapRef.value || !L?.map) return if (typeof document === 'undefined' || !mapRef.value || !L?.map) return
@@ -236,7 +313,7 @@ function createMap(initialCenter) {
? initialCenter ? initialCenter
: DEFAULT_VIEW : DEFAULT_VIEW
const map = L.map(mapRef.value, { zoomControl: false, attributionControl: false, minZoom: 1, maxZoom: 19 }).setView(center, DEFAULT_ZOOM) const map = L.map(mapRef.value, { zoomControl: false, attributionControl: false }).setView(center, DEFAULT_ZOOM)
L.control.zoom({ position: 'topleft' }).addTo(map) L.control.zoom({ position: 'topleft' }).addTo(map)
const locateControl = L.control({ position: 'topleft' }) const locateControl = L.control({ position: 'topleft' })
@@ -262,14 +339,6 @@ function createMap(initialCenter) {
} }
locateControl.addTo(map) 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, { const baseLayer = L.tileLayer(TILE_URL, {
attribution: ATTRIBUTION, attribution: ATTRIBUTION,
subdomains: TILE_SUBDOMAINS, subdomains: TILE_SUBDOMAINS,
@@ -298,28 +367,10 @@ function createMap(initialCenter) {
contextMenu.value = { type: 'map', latlng: e.latlng, x: pt.x, y: pt.y } contextMenu.value = { type: 'map', latlng: e.latlng, x: pt.x, y: pt.y }
}) })
map.on('moveend', () => { mapContext.value = { map, layer: baseLayer, control, locateControl }
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() updateMarkers()
updatePoiMarkers() updatePoiMarkers()
updateLiveMarkers() updateLiveMarkers()
reloadCotCluster()
updateAlprLayer()
nextTick(() => {
map.invalidateSize()
emitBounds(map)
})
} }
function updateMarkers() { function updateMarkers() {
@@ -331,7 +382,8 @@ function updateMarkers() {
if (m) m.remove() if (m) m.remove()
}) })
const validSources = (props.devices || []).filter(f => typeof f?.lat === 'number' && typeof f?.lng === 'number') const feedSources = [...(props.feeds || []), ...(props.devices || [])]
const validSources = feedSources.filter(f => typeof f?.lat === 'number' && typeof f?.lng === 'number')
markersRef.value = validSources.map(item => markersRef.value = validSources.map(item =>
L.marker([item.lat, item.lng]).addTo(ctx.map).on('click', () => emit('select', item)), L.marker([item.lat, item.lng]).addTo(ctx.map).on('click', () => emit('select', item)),
) )
@@ -392,7 +444,7 @@ function updateLiveMarkers() {
}) })
const next = sessions.reduce((acc, session) => { const next = sessions.reduce((acc, session) => {
const content = `<div class="kestrel-live-popup"><strong>${escapeHtml(session.label)}</strong>${session.hasStream ? ' <span class="text-kestrel-accent">● Live</span>' : ''}</div>` const content = `<div class="kestrel-live-popup"><strong>${escapeHtml(session.label)}</strong>${session.hasStream ? ' <span style="color:#22c9c9">● Live</span>' : ''}</div>`
const existing = prev[session.id] const existing = prev[session.id]
if (existing) { if (existing) {
existing.setLatLng([session.lat, session.lng]) existing.setLatLng([session.lat, session.lng])
@@ -409,18 +461,6 @@ function updateLiveMarkers() {
liveMarkersRef.value = next liveMarkersRef.value = next
} }
function updateAlprLayer() {
const ctx = mapContext.value
const { L } = leafletRef.value || {}
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) { function escapeHtml(text) {
const div = document.createElement('div') const div = document.createElement('div')
div.textContent = text div.textContent = text
@@ -438,6 +478,7 @@ function openAddPoiModal(latlng) {
editPoi.value = null editPoi.value = null
deletePoi.value = null deletePoi.value = null
poiForm.value = { label: '', iconType: 'pin' } poiForm.value = { label: '', iconType: 'pin' }
iconDropdownOpen.value = false
showPoiModal.value = true showPoiModal.value = true
} }
@@ -448,6 +489,7 @@ function openEditPoiModal(poi) {
addPoiLatlng.value = null addPoiLatlng.value = null
deletePoi.value = null deletePoi.value = null
poiForm.value = { label: (poi.label ?? '').trim(), iconType: poi.icon_type || 'pin' } poiForm.value = { label: (poi.label ?? '').trim(), iconType: poi.icon_type || 'pin' }
iconDropdownOpen.value = false
showPoiModal.value = true showPoiModal.value = true
} }
@@ -463,38 +505,52 @@ function openDeletePoiModal(poi) {
function closePoiModal() { function closePoiModal() {
showPoiModal.value = false showPoiModal.value = false
poiModalMode.value = 'add' poiModalMode.value = 'add'
iconDropdownOpen.value = false
addPoiLatlng.value = null addPoiLatlng.value = null
editPoi.value = null editPoi.value = null
deletePoi.value = null deletePoi.value = null
} }
async function doPoiFetch(fn) { function onPoiModalDocumentClick(e) {
if (!showPoiModal.value) return
if (iconDropdownOpen.value && iconDropdownRef.value && !iconDropdownRef.value.contains(e.target)) {
iconDropdownOpen.value = false
}
}
async function submitPoiModal() {
if (poiModalMode.value === 'add') {
const latlng = addPoiLatlng.value
if (!latlng) return
const { label, iconType } = poiForm.value
try { try {
await fn() await $fetch('/api/pois', { method: 'POST', body: { lat: latlng.lat, lng: latlng.lng, label: (label ?? '').trim(), iconType: iconType || 'pin' } })
emit('refreshPois') emit('refreshPois')
closePoiModal() closePoiModal()
} }
catch { /* ignore */ } catch { /* ignore */ }
}
async function onPoiSubmit(payload) {
const { label, iconType } = payload
const body = { label: (label ?? '').trim(), iconType: iconType || 'pin' }
if (poiModalMode.value === 'add') {
const latlng = addPoiLatlng.value
if (!latlng) return
await doPoiFetch(() => $fetch('/api/pois', { method: 'POST', body: { ...body, lat: latlng.lat, lng: latlng.lng } }))
return return
} }
if (poiModalMode.value === 'edit' && editPoi.value) { if (poiModalMode.value === 'edit' && editPoi.value) {
await doPoiFetch(() => $fetch(`/api/pois/${editPoi.value.id}`, { method: 'PATCH', body })) const { label, iconType } = poiForm.value
try {
await $fetch(`/api/pois/${editPoi.value.id}`, { method: 'PATCH', body: { label: (label ?? '').trim(), iconType: iconType || 'pin' } })
emit('refreshPois')
closePoiModal()
}
catch { /* ignore */ }
} }
} }
async function confirmDeletePoi() { async function confirmDeletePoi() {
const poi = deletePoi.value const poi = deletePoi.value
if (!poi?.id) return if (!poi?.id) return
await doPoiFetch(() => $fetch(`/api/pois/${poi.id}`, { method: 'DELETE' })) try {
await $fetch(`/api/pois/${poi.id}`, { method: 'DELETE' })
emit('refreshPois')
closePoiModal()
}
catch { /* ignore */ }
} }
function destroyMap() { function destroyMap() {
@@ -506,22 +562,15 @@ function destroyMap() {
poiMarkersRef.value = {} poiMarkersRef.value = {}
Object.values(liveMarkersRef.value).forEach(m => m?.remove()) Object.values(liveMarkersRef.value).forEach(m => m?.remove())
liveMarkersRef.value = {} liveMarkersRef.value = {}
clearFeatureMarkers(cotLayerRef.value)
clearFeatureMarkers(alprLayerRef.value)
const ctx = mapContext.value const ctx = mapContext.value
if (ctx) { if (ctx) {
if (ctx.control && ctx.map) ctx.map.removeControl(ctx.control) if (ctx.control && ctx.map) ctx.map.removeControl(ctx.control)
if (ctx.locateControl && ctx.map) ctx.map.removeControl(ctx.locateControl) 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.layer && ctx.map) ctx.map.removeLayer(ctx.layer)
if (ctx.map) ctx.map.remove() if (ctx.map) ctx.map.remove()
mapContext.value = null mapContext.value = null
} }
cotLayerRef.value = null
alprLayerRef.value = null
} }
function initMapWithLocation() { function initMapWithLocation() {
@@ -560,15 +609,7 @@ onMounted(async () => {
leafletRef.value = { L, offlineApi: offline } leafletRef.value = { L, offlineApi: offline }
initMapWithLocation() initMapWithLocation()
document.addEventListener('click', onDocumentClick) document.addEventListener('click', onDocumentClick)
document.addEventListener('click', onPoiModalDocumentClick)
nextTick(() => {
if (mapRef.value) {
resizeObserver.value = new ResizeObserver(() => {
mapContext.value?.map?.invalidateSize()
})
resizeObserver.value.observe(mapRef.value)
}
})
}) })
function onDocumentClick(e) { function onDocumentClick(e) {
@@ -577,21 +618,166 @@ function onDocumentClick(e) {
onBeforeUnmount(() => { onBeforeUnmount(() => {
document.removeEventListener('click', onDocumentClick) document.removeEventListener('click', onDocumentClick)
if (resizeObserver.value && mapRef.value) { document.removeEventListener('click', onPoiModalDocumentClick)
resizeObserver.value.disconnect()
resizeObserver.value = null
}
destroyMap() destroyMap()
}) })
watch(() => props.devices, () => updateMarkers(), { deep: true }) watch(() => [props.feeds, props.devices], () => updateMarkers(), { deep: true })
watch([() => props.pois, () => props.canEditPois], () => updatePoiMarkers(), { deep: true }) watch([() => props.pois, () => props.canEditPois], () => updatePoiMarkers(), { deep: true })
watch(() => props.liveSessions, () => updateLiveMarkers(), { deep: true }) watch(() => props.liveSessions, () => updateLiveMarkers(), { 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> </script>
<style scoped>
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.2s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
.modal-enter-active .relative,
.modal-leave-active .relative {
transition: transform 0.2s ease;
}
.modal-enter-from .relative,
.modal-leave-to .relative {
transform: scale(0.96);
}
/* Unrendered/loading tiles show black instead of white when panning */
.kestrel-map-container {
background: #000 !important;
}
:deep(.leaflet-tile-pane),
:deep(.leaflet-map-pane),
:deep(.leaflet-tile-container) {
background: #000 !important;
}
:deep(img.leaflet-tile) {
background: #000 !important;
/* Override Leaflets plus-lighter so unloaded/empty tiles dont flash white */
mix-blend-mode: normal;
}
/* Leaflet injects divIcon HTML into the map; :deep() so these styles apply to that content */
:deep(.poi-div-icon) {
background: none;
border: none;
}
:deep(.poi-icon-svg) {
display: block;
width: 100%;
height: 100%;
pointer-events: none;
}
/* Dark-themed tooltip for POI labels (Leaflet creates these in the map container) */
:deep(.kestrel-poi-tooltip) {
background: #1e293b;
border: 1px solid rgba(34, 201, 201, 0.35);
border-radius: 6px;
color: #e2e8f0;
font-size: 12px;
font-family: inherit;
padding: 6px 10px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
:deep(.kestrel-poi-tooltip::before),
:deep(.kestrel-poi-tooltip::after) {
border-top-color: #1e293b;
border-bottom-color: #1e293b;
border-left-color: #1e293b;
border-right-color: #1e293b;
}
/* Live session popup (content injected by Leaflet) */
:deep(.kestrel-live-popup-wrap .leaflet-popup-content) {
margin: 8px 12px;
min-width: 200px;
}
:deep(.kestrel-live-popup) {
color: #e2e8f0;
font-size: 12px;
}
:deep(.kestrel-live-popup img) {
display: block;
max-height: 160px;
width: auto;
border-radius: 4px;
background: #0f172a;
}
:deep(.live-session-icon) {
animation: live-pulse 1.5s ease-in-out infinite;
}
@keyframes live-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
/* Map controls dark theme with cyan glow (zoom, locate, save/clear tiles) */
:deep(.leaflet-control-zoom),
:deep(.leaflet-control-locate),
:deep(.savetiles.leaflet-bar) {
border: 1px solid rgba(34, 201, 201, 0.35) !important;
border-radius: 6px;
overflow: hidden;
box-shadow: 0 0 12px -2px rgba(34, 201, 201, 0.15);
font-family: "JetBrains Mono", "Fira Code", ui-monospace, monospace;
}
:deep(.leaflet-control-zoom a),
:deep(.leaflet-control-locate),
:deep(.savetiles.leaflet-bar a) {
width: 32px !important;
height: 32px !important;
line-height: 32px !important;
background: #0d1424 !important;
color: #b8c9e0 !important;
border: none !important;
border-radius: 0 !important;
font-size: 18px !important;
font-weight: 600;
text-decoration: none !important;
transition: background 0.15s, color 0.15s, box-shadow 0.15s, text-shadow 0.15s;
}
:deep(.leaflet-control-zoom a + a) {
border-top: 1px solid rgba(34, 201, 201, 0.2) !important;
}
:deep(.leaflet-control-zoom a:hover),
:deep(.leaflet-control-locate:hover),
:deep(.savetiles.leaflet-bar a:hover) {
background: #111a2e !important;
color: #22c9c9 !important;
box-shadow: 0 0 16px -2px rgba(34, 201, 201, 0.25);
text-shadow: 0 0 8px rgba(34, 201, 201, 0.35);
}
:deep(.leaflet-control-locate) {
display: flex !important;
align-items: center;
justify-content: center;
padding: 0;
cursor: pointer;
}
:deep(.leaflet-control-locate svg) {
color: currentColor;
}
/* Save/Clear tiles text buttons */
:deep(.savetiles.leaflet-bar) {
display: flex;
flex-direction: column;
}
:deep(.savetiles.leaflet-bar a) {
width: auto !important;
min-width: 5.5em;
height: auto !important;
line-height: 1.25 !important;
padding: 6px 10px !important;
white-space: nowrap;
text-align: center;
font-size: 11px !important;
font-weight: 500;
letter-spacing: 0.02em;
}
:deep(.savetiles.leaflet-bar a + a) {
border-top: 1px solid rgba(34, 201, 201, 0.2) !important;
}
</style>
+42 -42
View File
@@ -1,17 +1,17 @@
<template> <template>
<aside <aside
class="kestrel-panel-base" class="flex flex-col border border-kestrel-border bg-kestrel-surface"
:class="inline ? 'kestrel-panel-inline' : 'kestrel-panel-overlay'" :class="inline ? 'rounded-lg shadow-glow' : 'absolute right-0 top-0 z-[1000] h-full w-full border-l shadow-glow md:w-[420px] [box-shadow:-8px_0_24px_-4px_rgba(34,201,201,0.12)]'"
role="dialog" role="dialog"
aria-label="Live feed" aria-label="Live feed"
> >
<div class="kestrel-panel-header"> <div class="flex items-center justify-between border-b border-kestrel-border px-4 py-3 [box-shadow:0_1px_0_0_rgba(34,201,201,0.08)]">
<h2 class="font-medium tracking-wide text-kestrel-text text-shadow-glow-sm"> <h2 class="font-medium tracking-wide text-kestrel-text [text-shadow:0_0_8px_rgba(34,201,201,0.25)]">
{{ session?.label ?? 'Live' }} {{ session?.label ?? 'Live' }}
</h2> </h2>
<button <button
type="button" type="button"
class="kestrel-close-btn" class="rounded p-1 text-kestrel-muted transition-colors hover:bg-kestrel-border hover:text-kestrel-accent"
aria-label="Close panel" aria-label="Close panel"
@click="$emit('close')" @click="$emit('close')"
> >
@@ -22,7 +22,7 @@
<p class="mb-3 text-xs text-kestrel-muted"> <p class="mb-3 text-xs text-kestrel-muted">
Live camera feed (WebRTC) Live camera feed (WebRTC)
</p> </p>
<div class="kestrel-video-frame"> <div class="relative aspect-video w-full overflow-hidden rounded border border-kestrel-border bg-black [box-shadow:inset_0_0_20px_-8px_rgba(34,201,201,0.1)]">
<video <video
ref="videoRef" ref="videoRef"
autoplay autoplay
@@ -47,7 +47,7 @@
Wrong host: server sees <strong>{{ failureReason.wrongHost.serverHostname }}</strong> but you opened this page at <strong>{{ failureReason.wrongHost.clientHostname }}</strong>. Use the same URL or set MEDIASOUP_ANNOUNCED_IP. Wrong host: server sees <strong>{{ failureReason.wrongHost.serverHostname }}</strong> but you opened this page at <strong>{{ failureReason.wrongHost.clientHostname }}</strong>. Use the same URL or set MEDIASOUP_ANNOUNCED_IP.
</p> </p>
<ul class="normal-case list-inside list-disc text-left text-kestrel-muted"> <ul class="normal-case list-inside list-disc text-left text-kestrel-muted">
<li><strong>Firewall:</strong> Open UDP/TCP 40000-49999 on the server.</li> <li><strong>Firewall:</strong> Open UDP/TCP 4000049999 on the server.</li>
<li><strong>Wrong host:</strong> Server must see the same address you use.</li> <li><strong>Wrong host:</strong> Server must see the same address you use.</li>
<li><strong>Restrictive NAT / cellular:</strong> TURN may be required.</li> <li><strong>Restrictive NAT / cellular:</strong> TURN may be required.</li>
</ul> </ul>
@@ -66,7 +66,7 @@
Wrong host: server sees <strong>{{ failureReason.wrongHost.serverHostname }}</strong> but you opened at <strong>{{ failureReason.wrongHost.clientHostname }}</strong>. Wrong host: server sees <strong>{{ failureReason.wrongHost.serverHostname }}</strong> but you opened at <strong>{{ failureReason.wrongHost.clientHostname }}</strong>.
</p> </p>
<ul class="normal-case list-inside list-disc text-left text-kestrel-muted"> <ul class="normal-case list-inside list-disc text-left text-kestrel-muted">
<li>Firewall: open ports 40000-49999.</li> <li>Firewall: open ports 4000049999.</li>
<li>Wrong host: use same URL or set MEDIASOUP_ANNOUNCED_IP.</li> <li>Wrong host: use same URL or set MEDIASOUP_ANNOUNCED_IP.</li>
<li>Restrictive NAT: TURN may be required.</li> <li>Restrictive NAT: TURN may be required.</li>
</ul> </ul>
@@ -104,9 +104,9 @@ const hasStream = ref(false)
const error = ref('') const error = ref('')
const connectionState = ref('') // '', 'connecting', 'connected', 'failed' const connectionState = ref('') // '', 'connecting', 'connected', 'failed'
const failureReason = ref(null) // { wrongHost: { serverHostname, clientHostname } | null } const failureReason = ref(null) // { wrongHost: { serverHostname, clientHostname } | null }
const device = ref(null) let device = null
const recvTransport = ref(null) let recvTransport = null
const consumer = ref(null) let consumer = null
async function runFailureReasonCheck() { async function runFailureReasonCheck() {
failureReason.value = await getWebRTCFailureReason() failureReason.value = await getWebRTCFailureReason()
@@ -135,16 +135,16 @@ async function setupWebRTC() {
const rtpCapabilities = await $fetch(`/api/live/webrtc/router-rtp-capabilities?sessionId=${props.session.id}`, { const rtpCapabilities = await $fetch(`/api/live/webrtc/router-rtp-capabilities?sessionId=${props.session.id}`, {
credentials: 'include', credentials: 'include',
}) })
device.value = await createMediasoupDevice(rtpCapabilities) device = await createMediasoupDevice(rtpCapabilities)
recvTransport.value = await createRecvTransport(device.value, props.session.id) recvTransport = await createRecvTransport(device, props.session.id)
recvTransport.value.on('connectionstatechange', () => { recvTransport.on('connectionstatechange', () => {
const state = recvTransport.value.connectionState const state = recvTransport.connectionState
if (state === 'connected') connectionState.value = 'connected' if (state === 'connected') connectionState.value = 'connected'
else if (state === 'failed' || state === 'disconnected' || state === 'closed') { else if (state === 'failed' || state === 'disconnected' || state === 'closed') {
logWarn('LiveSessionPanel: Receive transport connection state changed', { logWarn('LiveSessionPanel: Receive transport connection state changed', {
state, state,
transportId: recvTransport.value.id, transportId: recvTransport.id,
sessionId: props.session.id, sessionId: props.session.id,
}) })
if (state === 'failed') { if (state === 'failed') {
@@ -154,8 +154,8 @@ async function setupWebRTC() {
} }
}) })
const connectionPromise = waitForConnectionState(recvTransport.value, 10000) const connectionPromise = waitForConnectionState(recvTransport, 10000)
consumer.value = await consumeProducer(recvTransport.value, device.value, props.session.id) consumer = await consumeProducer(recvTransport, device, props.session.id)
const finalConnectionState = await connectionPromise const finalConnectionState = await connectionPromise
if (finalConnectionState !== 'connected') { if (finalConnectionState !== 'connected') {
@@ -163,8 +163,8 @@ async function setupWebRTC() {
runFailureReasonCheck() runFailureReasonCheck()
logWarn('LiveSessionPanel: Transport not fully connected', { logWarn('LiveSessionPanel: Transport not fully connected', {
state: finalConnectionState, state: finalConnectionState,
transportId: recvTransport.value.id, transportId: recvTransport.id,
consumerId: consumer.value.id, consumerId: consumer.id,
}) })
} }
else { else {
@@ -182,14 +182,14 @@ async function setupWebRTC() {
attempts++ attempts++
} }
if (!consumer.value.track) { if (!consumer.track) {
logError('LiveSessionPanel: No video track available', { logError('LiveSessionPanel: No video track available', {
consumerId: consumer.value.id, consumerId: consumer.id,
consumerKind: consumer.value.kind, consumerKind: consumer.kind,
consumerPaused: consumer.value.paused, consumerPaused: consumer.paused,
consumerClosed: consumer.value.closed, consumerClosed: consumer.closed,
consumerProducerId: consumer.value.producerId, consumerProducerId: consumer.producerId,
transportConnectionState: recvTransport.value?.connectionState, transportConnectionState: recvTransport?.connectionState,
}) })
error.value = 'No video track available - consumer may not be receiving data from producer' error.value = 'No video track available - consumer may not be receiving data from producer'
return return
@@ -197,14 +197,14 @@ async function setupWebRTC() {
if (!videoRef.value) { if (!videoRef.value) {
logError('LiveSessionPanel: Video ref not available', { logError('LiveSessionPanel: Video ref not available', {
consumerId: consumer.value.id, consumerId: consumer.id,
hasTrack: !!consumer.value.track, hasTrack: !!consumer.track,
}) })
error.value = 'Video element not available' error.value = 'Video element not available'
return return
} }
const stream = new MediaStream([consumer.value.track]) const stream = new MediaStream([consumer.track])
videoRef.value.srcObject = stream videoRef.value.srcObject = stream
hasStream.value = true hasStream.value = true
@@ -227,7 +227,7 @@ async function setupWebRTC() {
if (resolved) return if (resolved) return
resolved = true resolved = true
videoRef.value.removeEventListener('loadedmetadata', handler) videoRef.value.removeEventListener('loadedmetadata', handler)
logWarn('LiveSessionPanel: Video metadata timeout', { consumerId: consumer.value.id }) logWarn('LiveSessionPanel: Video metadata timeout', { consumerId: consumer.id })
resolve() resolve()
}, 5000) }, 5000)
}) })
@@ -239,7 +239,7 @@ async function setupWebRTC() {
} }
catch (playErr) { catch (playErr) {
logWarn('LiveSessionPanel: Video play() failed (may need user interaction)', { logWarn('LiveSessionPanel: Video play() failed (may need user interaction)', {
consumerId: consumer.value.id, consumerId: consumer.id,
error: playErr.message || String(playErr), error: playErr.message || String(playErr),
errorName: playErr.name, errorName: playErr.name,
videoPaused: videoRef.value.paused, videoPaused: videoRef.value.paused,
@@ -248,12 +248,12 @@ async function setupWebRTC() {
// Don't set error - video might still work, just needs user interaction // Don't set error - video might still work, just needs user interaction
} }
consumer.value.track.addEventListener('ended', () => { consumer.track.addEventListener('ended', () => {
error.value = 'Video track ended' error.value = 'Video track ended'
hasStream.value = false hasStream.value = false
}) })
videoRef.value.addEventListener('error', () => { videoRef.value.addEventListener('error', () => {
logError('LiveSessionPanel: Video element error', { consumerId: consumer.value.id }) logError('LiveSessionPanel: Video element error', { consumerId: consumer.id })
}) })
} }
catch (err) { catch (err) {
@@ -274,15 +274,15 @@ async function setupWebRTC() {
} }
function cleanup() { function cleanup() {
if (consumer.value) { if (consumer) {
consumer.value.close() consumer.close()
consumer.value = null consumer = null
} }
if (recvTransport.value) { if (recvTransport) {
recvTransport.value.close() recvTransport.close()
recvTransport.value = null recvTransport = null
} }
device.value = null device = null
if (videoRef.value) { if (videoRef.value) {
videoRef.value.srcObject = null videoRef.value.srcObject = null
} }
@@ -308,7 +308,7 @@ watch(
watch( watch(
() => props.session?.hasStream, () => props.session?.hasStream,
(hasStream) => { (hasStream) => {
if (hasStream && props.session?.id && !device.value) { if (hasStream && props.session?.id && !device) {
setupWebRTC() setupWebRTC()
} }
else if (!hasStream) { else if (!hasStream) {
-133
View File
@@ -1,133 +0,0 @@
<template>
<div class="overflow-x-auto rounded border border-kestrel-border">
<table class="w-full text-left text-sm">
<thead>
<tr class="border-b border-kestrel-border bg-kestrel-surface-hover">
<th class="px-4 py-2 font-medium text-kestrel-text">
Identifier
</th>
<th class="px-4 py-2 font-medium text-kestrel-text">
Auth
</th>
<th class="px-4 py-2 font-medium text-kestrel-text">
Role
</th>
<th
v-if="isAdmin"
class="px-4 py-2 font-medium text-kestrel-text"
>
Actions
</th>
</tr>
</thead>
<tbody>
<tr
v-for="u in users"
:key="u.id"
class="border-b border-kestrel-border"
>
<td class="px-4 py-2 text-kestrel-text">
{{ u.identifier }}
</td>
<td class="px-4 py-2">
<span
class="rounded px-1.5 py-0.5 text-xs text-kestrel-muted"
:class="u.auth_provider === 'oidc' ? 'bg-kestrel-surface' : ''"
>
{{ u.auth_provider === 'oidc' ? 'OIDC' : 'Local' }}
</span>
</td>
<td class="px-4 py-2">
<AppDropdown
v-if="isAdmin"
:open="openRoleDropdownId === u.id"
teleport
@close="emit('closeRoleDropdown')"
>
<button
type="button"
class="flex min-w-[6rem] items-center justify-between gap-2 rounded border border-kestrel-border bg-kestrel-bg px-2 py-1 text-left text-sm text-kestrel-text shadow-sm transition-colors hover:border-kestrel-accent/50 hover:bg-kestrel-surface"
:aria-expanded="openRoleDropdownId === u.id"
:aria-haspopup="true"
aria-label="Change role"
@click.stop="emit('toggleRoleDropdown', u.id)"
>
<span>{{ roleByUserId[u.id] ?? u.role }}</span>
<span
class="text-kestrel-muted transition-transform"
:class="openRoleDropdownId === u.id && 'rotate-180'"
>
</span>
</button>
<template #menu>
<button
v-for="role in roleOptions"
:key="role"
type="button"
role="menuitem"
class="block w-full px-3 py-1.5 text-left text-sm transition-colors"
:class="roleByUserId[u.id] === role
? 'bg-kestrel-accent-dim text-kestrel-accent'
: 'text-kestrel-text hover:bg-kestrel-border hover:text-kestrel-text'"
@click.stop="emit('selectRole', u.id, role)"
>
{{ role }}
</button>
</template>
</AppDropdown>
<span
v-else
class="text-kestrel-muted"
>{{ u.role }}</span>
</td>
<td
v-if="isAdmin"
class="px-4 py-2"
>
<div class="flex flex-wrap items-center gap-2">
<button
v-if="roleByUserId[u.id] !== u.role"
type="button"
class="rounded border border-kestrel-accent px-2 py-1 text-xs text-kestrel-accent hover:bg-kestrel-accent-dim"
@click="emit('saveRole', u.id)"
>
Save role
</button>
<template v-if="u.auth_provider !== 'oidc'">
<button
type="button"
class="rounded border border-kestrel-border px-2 py-1 text-xs text-kestrel-text hover:bg-kestrel-surface"
@click="emit('editUser', u)"
>
Edit
</button>
<button
v-if="u.id !== currentUserId"
type="button"
class="rounded border border-red-500/60 px-2 py-1 text-xs text-red-400 hover:bg-red-500/10"
@click="emit('deleteConfirm', u)"
>
Remove
</button>
</template>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<script setup>
defineProps({
users: { type: Array, required: true },
roleByUserId: { type: Object, required: true },
roleOptions: { type: Array, required: true },
isAdmin: Boolean,
currentUserId: { type: [String, Number], default: null },
openRoleDropdownId: { type: [String, Number], default: null },
})
const emit = defineEmits(['toggleRoleDropdown', 'closeRoleDropdown', 'selectRole', 'saveRole', 'editUser', 'deleteConfirm'])
</script>
+57 -79
View File
@@ -1,8 +1,8 @@
<template> <template>
<div class="flex h-full shrink-0"> <Teleport to="body">
<Transition name="drawer-backdrop"> <Transition name="drawer-backdrop">
<button <button
v-if="isMobile && modelValue" v-if="modelValue"
type="button" type="button"
class="fixed inset-0 z-20 block h-full w-full border-0 bg-black/50 p-0 md:hidden" class="fixed inset-0 z-20 block h-full w-full border-0 bg-black/50 p-0 md:hidden"
aria-label="Close navigation" aria-label="Close navigation"
@@ -10,29 +10,28 @@
/> />
</Transition> </Transition>
<aside <aside
class="nav-drawer flex h-full flex-col bg-kestrel-surface transition-[width] duration-200 ease-out md:relative md:translate-x-0" class="nav-drawer fixed left-0 top-0 z-30 flex h-full w-[260px] flex-col border-r border-kestrel-border bg-kestrel-surface transition-transform duration-200 ease-out"
:class="[ :class="{ '-translate-x-full': !modelValue }"
isMobile && !modelValue ? 'fixed left-0 top-14 z-30 -translate-x-full' : 'fixed left-0 top-14 z-30 md:relative md:top-0',
showCollapsed ? 'w-16' : 'w-[260px]',
]"
role="navigation" role="navigation"
aria-label="Main navigation" aria-label="Main navigation"
:aria-expanded="modelValue" :aria-expanded="modelValue"
> >
<div <div
v-if="isMounted && isMobile" class="flex h-14 shrink-0 items-center justify-between border-b border-kestrel-border bg-kestrel-surface px-4 shadow-glow-sm [box-shadow:0_0_20px_-4px_rgba(34,201,201,0.15)]"
class="flex shrink-0 items-center justify-end border-b border-kestrel-border bg-kestrel-surface px-2 py-1"
> >
<h2 class="text-sm font-medium uppercase tracking-wider text-kestrel-muted">
Navigation
</h2>
<button <button
type="button" type="button"
class="kestrel-close-btn" class="rounded p-1 text-kestrel-muted transition-colors hover:bg-kestrel-border hover:text-kestrel-accent"
aria-label="Close navigation" aria-label="Close navigation"
@click="close" @click="close"
> >
<span class="text-xl leading-none">&times;</span> <span class="text-xl leading-none">&times;</span>
</button> </button>
</div> </div>
<nav class="flex-1 overflow-auto bg-kestrel-surface py-2"> <nav class="flex-1 overflow-auto py-2">
<ul class="space-y-0.5 px-2"> <ul class="space-y-0.5 px-2">
<li <li
v-for="item in navItems" v-for="item in navItems"
@@ -40,91 +39,50 @@
> >
<NuxtLink <NuxtLink
:to="item.to" :to="item.to"
class="flex items-center gap-3 rounded px-3 py-2 text-sm transition-colors" class="block rounded px-3 py-2 text-sm transition-colors"
:class="[ :class="isActive(item.to)
showCollapsed ? 'justify-center px-2' : '', ? 'border-l-2 border-kestrel-accent bg-kestrel-surface-hover font-medium text-kestrel-accent [text-shadow:0_0_8px_rgba(34,201,201,0.25)]'
isActive(item.to) : 'border-l-2 border-transparent text-kestrel-muted hover:bg-kestrel-border hover:text-kestrel-text'"
? 'bg-kestrel-surface-hover font-medium text-kestrel-accent text-shadow-glow-sm' @click="close"
: 'text-kestrel-muted hover:bg-kestrel-border hover:text-kestrel-text',
!showCollapsed && (isActive(item.to) ? 'border-l-2 border-kestrel-accent' : 'border-l-2 border-transparent'),
]"
:title="showCollapsed ? item.label : undefined"
@click="isMobile ? close() : undefined"
> >
<Icon {{ item.label }}
:name="item.icon"
class="size-5 shrink-0"
aria-hidden="true"
/>
<span
v-show="!showCollapsed"
class="truncate"
>{{ item.label }}</span>
</NuxtLink> </NuxtLink>
</li> </li>
</ul> </ul>
</nav> </nav>
<div
v-if="isMounted && !isMobile"
class="shrink-0 border-t border-kestrel-border bg-kestrel-surface py-2"
>
<button
type="button"
class="flex w-full items-center gap-3 rounded px-3 py-2 text-sm text-kestrel-muted transition-colors hover:bg-kestrel-border hover:text-kestrel-text"
:class="showCollapsed ? 'justify-center px-2' : ''"
:aria-label="showCollapsed ? 'Expand sidebar' : 'Collapse sidebar'"
@click="toggleCollapsed"
>
<Icon
:name="showCollapsed ? 'tabler:chevron-right' : 'tabler:chevron-left'"
class="size-5 shrink-0"
aria-hidden="true"
/>
<span v-show="!showCollapsed">Collapse sidebar</span>
</button>
</div>
</aside> </aside>
</div> </Teleport>
</template> </template>
<script setup> <script setup>
const props = defineProps({ defineProps({
modelValue: { type: Boolean, default: false }, modelValue: {
collapsed: { type: Boolean, default: false }, type: Boolean,
isMobile: { type: Boolean, default: true }, default: false,
},
}) })
const emit = defineEmits(['update:modelValue', 'update:collapsed']) const emit = defineEmits(['update:modelValue'])
const isMounted = ref(false)
const route = useRoute() const route = useRoute()
const { canEditPois } = useUser() const { canEditPois } = useUser()
const NAV_ITEMS = Object.freeze([
{ to: '/', label: 'Map', icon: 'tabler:map' },
{ to: '/cameras', label: 'Cameras', icon: 'tabler:video' },
{ to: '/poi', label: 'POI', icon: 'tabler:map-pin' },
{ to: '/members', label: 'Members', icon: 'tabler:users' },
{ to: '/account', label: 'Account', icon: 'tabler:user-circle' },
{ to: '/settings', label: 'Settings', icon: 'tabler:settings' },
])
const SHARE_LIVE_ITEM = { to: '/share-live', label: 'Share live', icon: 'tabler:live-photo' }
const navItems = computed(() => { const navItems = computed(() => {
if (!canEditPois.value) return NAV_ITEMS const items = [
const list = [...NAV_ITEMS] { to: '/', label: 'Map' },
list.splice(3, 0, SHARE_LIVE_ITEM) { to: '/account', label: 'Account' },
return list { to: '/cameras', label: 'Cameras' },
{ to: '/poi', label: 'POI' },
{ to: '/members', label: 'Members' },
{ to: '/settings', label: 'Settings' },
]
if (canEditPois.value) {
items.splice(1, 0, { to: '/share-live', label: 'Share live' })
}
return items
}) })
const showCollapsed = computed(() => props.collapsed && !props.isMobile) const isActive = to => to === '/' ? route.path === '/' : route.path.startsWith(to)
function toggleCollapsed() {
emit('update:collapsed', !props.collapsed)
}
const isActive = to => (to === '/' ? route.path === '/' : route.path.startsWith(to))
function close() { function close() {
emit('update:modelValue', false) emit('update:modelValue', false)
@@ -137,7 +95,6 @@ function onEscape(e) {
defineExpose({ close }) defineExpose({ close })
onMounted(() => { onMounted(() => {
isMounted.value = true
document.addEventListener('keydown', onEscape) document.addEventListener('keydown', onEscape)
}) })
@@ -145,3 +102,24 @@ onBeforeUnmount(() => {
document.removeEventListener('keydown', onEscape) document.removeEventListener('keydown', onEscape)
}) })
</script> </script>
<style scoped>
.drawer-backdrop-enter-active,
.drawer-backdrop-leave-active {
transition: opacity 0.2s ease;
}
.drawer-backdrop-enter-from,
.drawer-backdrop-leave-to {
opacity: 0;
}
/* Same elevation as content: no right-edge shadow on desktop so drawer and navbar read as one layer */
.nav-drawer {
box-shadow: 8px 0 24px -4px rgba(34, 201, 201, 0.12);
}
@media (min-width: 768px) {
.nav-drawer {
box-shadow: none;
}
}
</style>
-175
View File
@@ -1,175 +0,0 @@
<template>
<BaseModal
:show="show"
:aria-labelledby="mode === 'delete' ? 'delete-poi-title' : 'poi-modal-title'"
@close="$emit('close')"
>
<div
v-if="mode === 'add' || mode === 'edit'"
ref="modalRef"
class="kestrel-card-modal relative w-full max-w-md p-6"
>
<h2
id="poi-modal-title"
class="kestrel-section-heading mb-4"
>
{{ mode === 'edit' ? 'Edit POI' : 'Add POI' }}
</h2>
<form
class="space-y-4"
@submit.prevent="$emit('submit', { label: localForm.label, iconType: localForm.iconType })"
>
<div>
<label
for="add-poi-label"
class="kestrel-label"
>Label (optional)</label>
<input
id="add-poi-label"
v-model="localForm.label"
type="text"
placeholder="e.g. Rally point"
class="kestrel-input"
autocomplete="off"
>
</div>
<div
ref="iconRef"
class="relative inline-block w-full"
>
<label class="kestrel-label">Icon type</label>
<button
type="button"
class="flex w-full min-w-0 items-center justify-between gap-2 rounded border border-kestrel-border bg-kestrel-bg px-3 py-2 text-left text-sm text-kestrel-text transition-colors hover:border-kestrel-accent/50"
:aria-expanded="iconOpen"
aria-haspopup="listbox"
:aria-label="`Icon type: ${localForm.iconType}`"
@click="iconOpen = !iconOpen"
>
<span class="flex items-center gap-2 capitalize">
<Icon
:name="POI_ICONIFY_IDS[localForm.iconType]"
class="size-4 shrink-0"
/>
{{ localForm.iconType }}
</span>
<span
class="text-kestrel-muted transition-transform"
:class="iconOpen && 'rotate-180'"
></span>
</button>
<Transition
enter-active-class="transition duration-100 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition duration-75 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div
v-show="iconOpen"
class="absolute left-0 right-0 top-full z-10 mt-1 rounded border border-kestrel-border bg-kestrel-surface py-1 shadow-glow shadow-glow-dropdown"
role="listbox"
>
<button
v-for="opt in POI_ICON_TYPES"
:key="opt"
type="button"
role="option"
:aria-selected="localForm.iconType === opt"
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm capitalize transition-colors"
:class="localForm.iconType === opt ? 'bg-kestrel-accent-dim text-kestrel-accent' : 'text-kestrel-text hover:bg-kestrel-border'"
@click="localForm.iconType = opt; iconOpen = false"
>
<Icon
:name="POI_ICONIFY_IDS[opt]"
class="size-4 shrink-0"
/>
{{ opt }}
</button>
</div>
</Transition>
</div>
<div class="flex justify-end gap-2 pt-2">
<button
type="button"
class="kestrel-btn-secondary"
@click="$emit('close')"
>
Cancel
</button>
<button
type="submit"
class="rounded bg-kestrel-accent px-4 py-2 text-sm font-medium text-kestrel-bg transition-opacity hover:opacity-90"
>
{{ mode === 'edit' ? 'Save changes' : 'Add POI' }}
</button>
</div>
</form>
</div>
<div
v-else-if="mode === 'delete'"
ref="modalRef"
class="kestrel-card-modal relative w-full max-w-sm p-6"
>
<h2
id="delete-poi-title"
class="kestrel-section-heading mb-2"
>
Delete POI?
</h2>
<p class="mb-4 text-sm text-kestrel-muted">
{{ deletePoi?.label ? `"${deletePoi.label}" will be removed.` : 'This POI will be removed.' }}
</p>
<div class="flex justify-end gap-2">
<button
type="button"
class="kestrel-btn-secondary"
@click="$emit('close')"
>
Cancel
</button>
<button
type="button"
class="rounded bg-red-600 px-4 py-2 text-sm font-medium text-white transition-opacity hover:opacity-90"
@click="$emit('confirmDelete')"
>
Delete
</button>
</div>
</div>
</BaseModal>
</template>
<script setup>
const POI_ICONIFY_IDS = { pin: 'tabler:map-pin', flag: 'tabler:flag', waypoint: 'tabler:current-location' }
const POI_ICON_TYPES = Object.keys(POI_ICONIFY_IDS)
const props = defineProps({
show: Boolean,
mode: { type: String, default: 'add' },
form: { type: Object, default: () => ({ label: '', iconType: 'pin' }) },
editPoi: { type: Object, default: null },
deletePoi: { type: Object, default: null },
})
defineEmits(['close', 'submit', 'confirmDelete'])
const modalRef = ref(null)
const iconRef = ref(null)
const iconOpen = ref(false)
const localForm = ref({ label: '', iconType: 'pin' })
watch(() => props.show, (show) => {
if (!show) return
iconOpen.value = false
localForm.value = props.mode === 'edit' && props.editPoi
? { label: (props.editPoi.label ?? '').trim(), iconType: props.editPoi.icon_type || 'pin' }
: { ...props.form }
})
function onDocClick(e) {
if (iconOpen.value && iconRef.value && !iconRef.value.contains(e.target)) iconOpen.value = false
}
onMounted(() => document.addEventListener('click', onDocClick))
onBeforeUnmount(() => document.removeEventListener('click', onDocClick))
</script>
-84
View File
@@ -1,84 +0,0 @@
<template>
<AppDropdown
:open="open"
@close="open = false"
>
<button
type="button"
class="flex rounded-full border border-kestrel-border bg-kestrel-surface p-0.5 transition-colors hover:bg-kestrel-border hover:border-kestrel-accent"
aria-label="User menu"
:aria-expanded="open"
aria-haspopup="true"
@click="open = !open"
>
<img
v-if="user?.avatar_url"
:src="user.avatar_url"
:alt="user.identifier"
class="h-8 w-8 rounded-full object-cover"
>
<span
v-else
class="flex h-8 w-8 items-center justify-center rounded-full bg-kestrel-border text-xs font-medium text-kestrel-text"
>
{{ initials }}
</span>
</button>
<template #menu>
<NuxtLink
to="/account"
class="kestrel-context-menu-item"
role="menuitem"
@click="open = false"
>
Profile
</NuxtLink>
<NuxtLink
to="/settings"
class="kestrel-context-menu-item"
role="menuitem"
@click="open = false"
>
Settings
</NuxtLink>
<button
type="button"
class="kestrel-context-menu-item-danger w-full"
role="menuitem"
@click="onSignOut"
>
Sign out
</button>
</template>
</AppDropdown>
</template>
<script setup>
const props = defineProps({
user: {
type: Object,
default: null,
},
})
const emit = defineEmits(['signout'])
const open = ref(false)
const initials = computed(() => {
const id = props.user?.identifier ?? ''
const parts = id.trim().split(/\s+/)
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase()
return id.slice(0, 2).toUpperCase() || '?'
})
function onSignOut() {
open.value = false
emit('signout')
}
const route = useRoute()
watch(() => route.path, () => {
open.value = false
})
</script>
-146
View File
@@ -1,146 +0,0 @@
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,12 +0,0 @@
/** Auto-closes selectedCamera when the selected live session disappears from liveSessions. */
export function useAutoCloseLiveSession(selectedCamera, liveSessions) {
watch(
[() => selectedCamera.value, () => liveSessions.value],
([sel, sessions]) => {
if (!sel || typeof sel.hasStream === 'undefined') return
const stillActive = (sessions ?? []).some(s => s.id === sel.id)
if (!stillActive) selectedCamera.value = null
},
{ deep: true },
)
}
+18 -10
View File
@@ -1,19 +1,16 @@
/** Fetches devices + live sessions; polls when tab visible. */ /**
* Fetches devices + live sessions (unified cameras). Optionally polls when tab is visible.
*/
const POLL_MS = 1500 const POLL_MS = 1500
const EMPTY_RESPONSE = Object.freeze({ devices: [], liveSessions: [] })
export function useCameras(options = {}) { export function useCameras(options = {}) {
const { poll: enablePoll = true } = options const { poll: enablePoll = true } = options
const { data, refresh } = useAsyncData( const { data, refresh } = useAsyncData(
'cameras', 'cameras',
() => $fetch('/api/cameras').catch(() => EMPTY_RESPONSE), () => $fetch('/api/cameras').catch(() => ({ devices: [], liveSessions: [] })),
{ default: () => EMPTY_RESPONSE }, { default: () => ({ devices: [], liveSessions: [] }) },
) )
const devices = computed(() => Object.freeze([...(data.value?.devices ?? [])]))
const liveSessions = computed(() => Object.freeze([...(data.value?.liveSessions ?? [])]))
const cameras = computed(() => Object.freeze([...devices.value, ...liveSessions.value]))
const pollInterval = ref(null) const pollInterval = ref(null)
function startPolling() { function startPolling() {
if (!enablePoll || pollInterval.value) return if (!enablePoll || pollInterval.value) return
@@ -30,11 +27,22 @@ export function useCameras(options = {}) {
onMounted(() => { onMounted(() => {
if (typeof document === 'undefined') return if (typeof document === 'undefined') return
document.addEventListener('visibilitychange', () => { document.addEventListener('visibilitychange', () => {
document.visibilityState === 'visible' ? (startPolling(), refresh()) : stopPolling() if (document.visibilityState === 'visible') {
startPolling()
refresh()
}
else {
stopPolling()
}
}) })
if (document.visibilityState === 'visible') startPolling() if (document.visibilityState === 'visible') startPolling()
}) })
onBeforeUnmount(stopPolling) onBeforeUnmount(stopPolling)
return Object.freeze({ data, devices, liveSessions, cameras, refresh, startPolling, stopPolling }) const devices = computed(() => data.value?.devices ?? [])
const liveSessions = computed(() => data.value?.liveSessions ?? [])
/** All cameras: devices first, then live sessions */
const cameras = computed(() => [...devices.value, ...liveSessions.value])
return { data, devices, liveSessions, cameras, refresh, startPolling, stopPolling }
} }
-48
View File
@@ -1,48 +0,0 @@
const STORAGE_KEY = 'kestrel-cot-layers'
const DEFAULT_LAYERS = Object.freeze({ air: true, surface: true, ground: true })
function loadLayers() {
if (typeof localStorage === 'undefined') return { ...DEFAULT_LAYERS }
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return { ...DEFAULT_LAYERS }
const parsed = JSON.parse(raw)
return {
air: parsed.air !== false,
surface: parsed.surface !== false,
ground: parsed.ground !== false,
}
}
catch {
return { ...DEFAULT_LAYERS }
}
}
function saveLayers(layers) {
if (typeof localStorage === 'undefined') return
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(layers))
}
catch { /* ignore quota */ }
}
export function useCotLayers() {
const layers = ref(loadLayers())
const layerQuery = computed(() => {
const parts = []
if (layers.value.air) parts.push('air')
if (layers.value.surface) parts.push('surface')
if (layers.value.ground) parts.push('ground')
return parts.length ? parts.join(',') : 'none'
})
function toggleLayer(name) {
if (!(name in DEFAULT_LAYERS)) return
layers.value = { ...layers.value, [name]: !layers.value[name] }
saveLayers(layers.value)
}
return Object.freeze({ layers, layerQuery, toggleLayer })
}
-117
View File
@@ -1,117 +0,0 @@
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 })
}
+30 -8
View File
@@ -1,12 +1,24 @@
/** Fetches live sessions; polls when tab visible. */ /**
* Fetches active live sessions (camera + location sharing) and refreshes on an interval.
* Only runs when the app is focused so we don't poll in the background.
*/
const POLL_MS = 1500 const POLL_MS = 1500
export function useLiveSessions() { export function useLiveSessions() {
const { data: _sessions, refresh } = useAsyncData( const { data: sessions, refresh } = useAsyncData(
'live-sessions', 'live-sessions',
async () => { async () => {
try { try {
return await $fetch('/api/live') const result = await $fetch('/api/live')
if (process.env.NODE_ENV === 'development') {
console.log('[useLiveSessions] Fetched sessions:', result.map(s => ({
id: s.id,
label: s.label,
hasStream: s.hasStream,
})))
}
return result
} }
catch (err) { catch (err) {
const msg = err?.message ?? String(err) const msg = err?.message ?? String(err)
@@ -18,13 +30,14 @@ export function useLiveSessions() {
{ default: () => [] }, { default: () => [] },
) )
const sessions = computed(() => Object.freeze([...(_sessions.value ?? [])]))
const pollInterval = ref(null) const pollInterval = ref(null)
function startPolling() { function startPolling() {
if (pollInterval.value) return if (pollInterval.value) return
refresh() // Fetch immediately so new sessions show without waiting for first interval
pollInterval.value = setInterval(() => {
refresh() refresh()
pollInterval.value = setInterval(refresh, POLL_MS) }, POLL_MS)
} }
function stopPolling() { function stopPolling() {
@@ -36,12 +49,21 @@ export function useLiveSessions() {
onMounted(() => { onMounted(() => {
if (typeof document === 'undefined') return if (typeof document === 'undefined') return
const onFocus = () => startPolling()
const onBlur = () => stopPolling()
document.addEventListener('visibilitychange', () => { document.addEventListener('visibilitychange', () => {
document.visibilityState === 'visible' ? (startPolling(), refresh()) : stopPolling() if (document.visibilityState === 'visible') {
onFocus()
refresh() // Fresh data when returning to tab
}
else onBlur()
}) })
if (document.visibilityState === 'visible') startPolling() if (document.visibilityState === 'visible') startPolling()
}) })
onBeforeUnmount(stopPolling)
return Object.freeze({ sessions, refresh, startPolling, stopPolling }) onBeforeUnmount(() => {
stopPolling()
})
return { sessions, refresh, startPolling, stopPolling }
} }
-21
View File
@@ -1,21 +0,0 @@
/**
* Reactive viewport media query. SSR-safe: defaults to true (mobile) so sidebar closed on first paint.
* @param {string} query - CSS media query, e.g. '(max-width: 767px)'
* @returns {import('vue').Ref<boolean>} Ref that is true when the media query matches.
*/
export function useMediaQuery(query) {
const matches = ref(true)
const mql = ref(null)
const handler = (e) => {
matches.value = e.matches
}
onMounted(() => {
mql.value = window.matchMedia(query)
matches.value = mql.value.matches
mql.value.addEventListener('change', handler)
})
onBeforeUnmount(() => {
if (mql.value) mql.value.removeEventListener('change', handler)
})
return matches
}
+3 -6
View File
@@ -1,14 +1,11 @@
const EDIT_ROLES = Object.freeze(['admin', 'leader'])
export function useUser() { export function useUser() {
const requestFetch = useRequestFetch() const requestFetch = useRequestFetch()
const { data: user, refresh, status } = useAsyncData( const { data: user, refresh } = useAsyncData(
'user', 'user',
() => (requestFetch ?? $fetch)('/api/me').catch(() => null), () => (requestFetch ?? $fetch)('/api/me').catch(() => null),
{ default: () => null }, { default: () => null },
) )
const authPending = computed(() => status.value === 'pending') const canEditPois = computed(() => user.value?.role === 'admin' || user.value?.role === 'leader')
const canEditPois = computed(() => EDIT_ROLES.includes(user.value?.role))
const isAdmin = computed(() => user.value?.role === 'admin') const isAdmin = computed(() => user.value?.role === 'admin')
return Object.freeze({ user, authPending, canEditPois, isAdmin, refresh }) return { user, canEditPois, isAdmin, refresh }
} }
+144 -36
View File
@@ -1,26 +1,61 @@
/** WebRTC/Mediasoup client utilities. */ /**
* WebRTC composable for Mediasoup client operations.
* Handles device initialization, transport creation, and WebSocket signaling.
*/
import { logError, logWarn } from '../utils/logger.js' import { logError, logWarn } from '../utils/logger.js'
const FETCH_OPTS = { credentials: 'include' } /**
* Initialize Mediasoup device from router RTP capabilities.
* @param {object} rtpCapabilities
* @returns {Promise<object>} Mediasoup device
*/
export async function createMediasoupDevice(rtpCapabilities) { export async function createMediasoupDevice(rtpCapabilities) {
if (typeof window === 'undefined') throw new TypeError('Mediasoup device can only be created in browser') // Dynamically import mediasoup-client only in browser
if (typeof window === 'undefined') {
throw new TypeError('Mediasoup device can only be created in browser')
}
// Use dynamic import for mediasoup-client
const { Device } = await import('mediasoup-client') const { Device } = await import('mediasoup-client')
const device = new Device() const device = new Device()
await device.load({ routerRtpCapabilities: rtpCapabilities }) await device.load({ routerRtpCapabilities: rtpCapabilities })
return device return device
} }
/**
* Create WebSocket connection for signaling.
* @param {string} url - WebSocket URL (e.g., 'ws://localhost:3000/ws')
* @returns {Promise<WebSocket>} WebSocket connection
*/
export function createWebSocketConnection(url) { export function createWebSocketConnection(url) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const wsUrl = url.startsWith('ws') ? url : `${protocol}//${window.location.host}/ws` const wsUrl = url.startsWith('ws') ? url : `${protocol}//${window.location.host}/ws`
const ws = new WebSocket(wsUrl) const ws = new WebSocket(wsUrl)
ws.onopen = () => resolve(ws)
ws.onerror = () => reject(new Error('WebSocket connection failed')) ws.onopen = () => {
resolve(ws)
}
ws.onerror = () => {
reject(new Error('WebSocket connection failed'))
}
ws.onclose = () => {
// Connection closed
}
}) })
} }
/**
* Send WebSocket message and wait for response.
* @param {WebSocket} ws
* @param {string} sessionId
* @param {string} type
* @param {object} data
* @returns {Promise<object>} Response message
*/
export function sendWebSocketMessage(ws, sessionId, type, data = {}) { export function sendWebSocketMessage(ws, sessionId, type, data = {}) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (ws.readyState !== WebSocket.OPEN) { if (ws.readyState !== WebSocket.OPEN) {
@@ -60,20 +95,41 @@ export function sendWebSocketMessage(ws, sessionId, type, data = {}) {
}) })
} }
function attachTransportHandlers(transport, transportParams, sessionId, label, { onConnectSuccess, onConnectFailure } = {}) { /**
* Create send transport (for publisher).
* @param {object} device
* @param {string} sessionId
* @param {{ onConnectSuccess?: () => void, onConnectFailure?: (err: Error) => void }} [options] - Optional callbacks when transport connect succeeds or fails.
* @returns {Promise<object>} Transport with send method
*/
export async function createSendTransport(device, sessionId, options = {}) {
const { onConnectSuccess, onConnectFailure } = options
// Create transport via HTTP API
const transportParams = await $fetch('/api/live/webrtc/create-transport', {
method: 'POST',
body: { sessionId, isProducer: true },
credentials: 'include',
})
const transport = device.createSendTransport({
id: transportParams.id,
iceParameters: transportParams.iceParameters,
iceCandidates: transportParams.iceCandidates,
dtlsParameters: transportParams.dtlsParameters,
})
transport.on('connect', async ({ dtlsParameters }, callback, errback) => { transport.on('connect', async ({ dtlsParameters }, callback, errback) => {
try { try {
await $fetch('/api/live/webrtc/connect-transport', { await $fetch('/api/live/webrtc/connect-transport', {
method: 'POST', method: 'POST',
body: { sessionId, transportId: transportParams.id, dtlsParameters }, body: { sessionId, transportId: transportParams.id, dtlsParameters },
...FETCH_OPTS, credentials: 'include',
}) })
onConnectSuccess?.() onConnectSuccess?.()
callback() callback()
} }
catch (err) { catch (err) {
logError(`useWebRTC: ${label} transport connect failed`, { logError('useWebRTC: Send transport connect failed', {
err: err?.message ?? String(err), err: err.message || String(err),
transportId: transportParams.id, transportId: transportParams.id,
connectionState: transport.connectionState, connectionState: transport.connectionState,
sessionId, sessionId,
@@ -82,50 +138,48 @@ function attachTransportHandlers(transport, transportParams, sessionId, label, {
errback(err) errback(err)
} }
}) })
transport.on('connectionstatechange', () => { transport.on('connectionstatechange', () => {
const state = transport.connectionState const state = transport.connectionState
if (['failed', 'disconnected', 'closed'].includes(state)) { if (state === 'failed' || state === 'disconnected' || state === 'closed') {
logWarn(`useWebRTC: ${label} transport connection state changed`, { state, transportId: transportParams.id, sessionId }) logWarn('useWebRTC: Send transport connection state changed', {
state,
transportId: transportParams.id,
sessionId,
})
} }
}) })
}
export async function createSendTransport(device, sessionId, options = {}) {
const transportParams = await $fetch('/api/live/webrtc/create-transport', {
method: 'POST',
body: { sessionId, isProducer: true },
...FETCH_OPTS,
})
const transport = device.createSendTransport({
id: transportParams.id,
iceParameters: transportParams.iceParameters,
iceCandidates: transportParams.iceCandidates,
dtlsParameters: transportParams.dtlsParameters,
})
attachTransportHandlers(transport, transportParams, sessionId, 'Send', options)
transport.on('produce', async ({ kind, rtpParameters }, callback, errback) => { transport.on('produce', async ({ kind, rtpParameters }, callback, errback) => {
try { try {
const { id } = await $fetch('/api/live/webrtc/create-producer', { const { id } = await $fetch('/api/live/webrtc/create-producer', {
method: 'POST', method: 'POST',
body: { sessionId, transportId: transportParams.id, kind, rtpParameters }, body: { sessionId, transportId: transportParams.id, kind, rtpParameters },
...FETCH_OPTS, credentials: 'include',
}) })
callback({ id }) callback({ id })
} }
catch (err) { catch (err) {
logError('useWebRTC: Producer creation failed', { err: err?.message ?? String(err) }) logError('useWebRTC: Producer creation failed', { err: err.message || String(err) })
errback(err) errback(err)
} }
}) })
return transport return transport
} }
/**
* Create receive transport (for viewer).
* @param {object} device
* @param {string} sessionId
* @returns {Promise<object>} Transport with consume method
*/
export async function createRecvTransport(device, sessionId) { export async function createRecvTransport(device, sessionId) {
// Create transport via HTTP API
const transportParams = await $fetch('/api/live/webrtc/create-transport', { const transportParams = await $fetch('/api/live/webrtc/create-transport', {
method: 'POST', method: 'POST',
body: { sessionId, isProducer: false }, body: { sessionId, isProducer: false },
...FETCH_OPTS, credentials: 'include',
}) })
const transport = device.createRecvTransport({ const transport = device.createRecvTransport({
id: transportParams.id, id: transportParams.id,
@@ -133,15 +187,55 @@ export async function createRecvTransport(device, sessionId) {
iceCandidates: transportParams.iceCandidates, iceCandidates: transportParams.iceCandidates,
dtlsParameters: transportParams.dtlsParameters, dtlsParameters: transportParams.dtlsParameters,
}) })
attachTransportHandlers(transport, transportParams, sessionId, 'Recv')
// Set up connect handler (will be called by mediasoup-client when needed)
transport.on('connect', async ({ dtlsParameters }, callback, errback) => {
try {
await $fetch('/api/live/webrtc/connect-transport', {
method: 'POST',
body: { sessionId, transportId: transportParams.id, dtlsParameters },
credentials: 'include',
})
callback()
}
catch (err) {
logError('useWebRTC: Recv transport connect failed', {
err: err.message || String(err),
transportId: transportParams.id,
connectionState: transport.connectionState,
sessionId,
})
errback(err)
}
})
transport.on('connectionstatechange', () => {
const state = transport.connectionState
if (state === 'failed' || state === 'disconnected' || state === 'closed') {
logWarn('useWebRTC: Recv transport connection state changed', {
state,
transportId: transportParams.id,
sessionId,
})
}
})
return transport return transport
} }
/**
* Consume producer's stream (for viewer).
* @param {object} transport
* @param {object} device
* @param {string} sessionId
* @returns {Promise<object>} Consumer with track
*/
export async function consumeProducer(transport, device, sessionId) { export async function consumeProducer(transport, device, sessionId) {
const rtpCapabilities = device.rtpCapabilities
const consumerParams = await $fetch('/api/live/webrtc/create-consumer', { const consumerParams = await $fetch('/api/live/webrtc/create-consumer', {
method: 'POST', method: 'POST',
body: { sessionId, transportId: transport.id, rtpCapabilities: device.rtpCapabilities }, body: { sessionId, transportId: transport.id, rtpCapabilities },
...FETCH_OPTS, credentials: 'include',
}) })
const consumer = await transport.consume({ const consumer = await transport.consume({
@@ -162,6 +256,14 @@ export async function consumeProducer(transport, device, sessionId) {
return consumer return consumer
} }
/**
* Resolve when condition() returns truthy, or after timeoutMs (then resolve anyway).
* No mutable shared state; cleanup on first completion.
* @param {() => unknown} condition
* @param {number} timeoutMs
* @param {number} intervalMs
* @returns {Promise<void>}
*/
function waitForCondition(condition, timeoutMs = 3000, intervalMs = 100) { function waitForCondition(condition, timeoutMs = 3000, intervalMs = 100) {
return new Promise((resolve) => { return new Promise((resolve) => {
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
@@ -183,21 +285,27 @@ function waitForCondition(condition, timeoutMs = 3000, intervalMs = 100) {
}) })
} }
/**
* Wait for transport connection state to reach a terminal state or timeout.
* @param {object} transport - Mediasoup transport with connectionState and on/off
* @param {number} timeoutMs
* @returns {Promise<string>} Final connection state
*/
export function waitForConnectionState(transport, timeoutMs = 10000) { export function waitForConnectionState(transport, timeoutMs = 10000) {
const terminal = ['connected', 'failed', 'disconnected', 'closed'] const terminal = ['connected', 'failed', 'disconnected', 'closed']
return new Promise((resolve) => { return new Promise((resolve) => {
const tid = ref(null) let tid
const handler = () => { const handler = () => {
const state = transport.connectionState const state = transport.connectionState
if (terminal.includes(state)) { if (terminal.includes(state)) {
transport.off('connectionstatechange', handler) transport.off('connectionstatechange', handler)
if (tid.value) clearTimeout(tid.value) if (tid) clearTimeout(tid)
resolve(state) resolve(state)
} }
} }
transport.on('connectionstatechange', handler) transport.on('connectionstatechange', handler)
handler() handler()
tid.value = setTimeout(() => { tid = setTimeout(() => {
transport.off('connectionstatechange', handler) transport.off('connectionstatechange', handler)
resolve(transport.connectionState) resolve(transport.connectionState)
}, timeoutMs) }, timeoutMs)
+9 -4
View File
@@ -1,13 +1,18 @@
/** Pure: fetches WebRTC failure reason (e.g. wrong host). Returns frozen object. */ /**
* Fetch WebRTC failure reason (e.g. wrong host). Pure: same inputs → same output.
* @returns {Promise<{ wrongHost: { serverHostname: string, clientHostname: string } | null }>} Failure reason or null.
*/
export async function getWebRTCFailureReason() { export async function getWebRTCFailureReason() {
try { try {
const res = await $fetch('/api/live/debug-request-host', { credentials: 'include' }) const res = await $fetch('/api/live/debug-request-host', { credentials: 'include' })
const clientHostname = typeof window !== 'undefined' ? window.location.hostname : '' const clientHostname = typeof window !== 'undefined' ? window.location.hostname : ''
const serverHostname = res?.hostname ?? '' const serverHostname = res?.hostname ?? ''
if (serverHostname && clientHostname && serverHostname !== clientHostname) { if (serverHostname && clientHostname && serverHostname !== clientHostname) {
return Object.freeze({ wrongHost: Object.freeze({ serverHostname, clientHostname }) }) return { wrongHost: { serverHostname, clientHostname } }
} }
} }
catch { /* ignore */ } catch {
return Object.freeze({ wrongHost: null }) // ignore
}
return { wrongHost: null }
} }
+1 -1
View File
@@ -1,7 +1,7 @@
<template> <template>
<div class="flex min-h-screen items-center justify-center bg-kestrel-bg font-mono text-kestrel-text"> <div class="flex min-h-screen items-center justify-center bg-kestrel-bg font-mono text-kestrel-text">
<div class="text-center"> <div class="text-center">
<h1 class="text-2xl font-semibold tracking-wide text-shadow-glow-md"> <h1 class="text-2xl font-semibold tracking-wide [text-shadow:0_0_12px_rgba(34,201,201,0.3)]">
[ Error ] [ Error ]
</h1> </h1>
<p class="mt-2 text-sm text-kestrel-muted"> <p class="mt-2 text-sm text-kestrel-muted">
+67 -3
View File
@@ -1,7 +1,71 @@
<template> <template>
<div class="flex h-screen flex-col overflow-hidden bg-kestrel-bg font-mono text-kestrel-text"> <div class="min-h-screen bg-kestrel-bg text-kestrel-text font-mono flex flex-col">
<AppShell> <div class="relative flex flex-1 min-h-0">
<NavDrawer v-model="drawerOpen" />
<div
class="flex min-h-0 flex-1 flex-col transition-[margin] duration-200 ease-out"
:class="{ 'md:ml-[260px]': drawerOpen }"
>
<header class="flex h-14 shrink-0 items-center gap-3 border-b border-kestrel-border bg-kestrel-surface px-4 shadow-glow-sm [box-shadow:0_0_20px_-4px_rgba(34,201,201,0.15)]">
<button
type="button"
class="rounded p-2 text-kestrel-muted transition-colors hover:bg-kestrel-border hover:text-kestrel-accent"
aria-label="Toggle navigation"
:aria-expanded="drawerOpen"
@click="drawerOpen = !drawerOpen"
>
<span
class="text-lg leading-none"
aria-hidden="true"
>&#9776;</span>
</button>
<div class="min-w-0 flex-1">
<h1 class="text-lg font-semibold tracking-wide text-kestrel-text [text-shadow:0_0_12px_rgba(34,201,201,0.35)]">
KestrelOS
</h1>
<p class="text-xs uppercase tracking-widest text-kestrel-muted">
&gt; Tactical Operations Center OSINT Feeds
</p>
</div>
<div class="flex items-center gap-2">
<template v-if="user">
<span class="text-xs text-kestrel-muted">{{ user.identifier }}</span>
<button
type="button"
class="rounded px-2 py-1 text-xs text-kestrel-muted hover:bg-kestrel-border hover:text-kestrel-accent"
@click="onLogout"
>
Logout
</button>
</template>
<NuxtLink
v-else
to="/login"
class="rounded px-2 py-1 text-xs text-kestrel-muted hover:bg-kestrel-border hover:text-kestrel-accent"
>
Sign in
</NuxtLink>
</div>
</header>
<main class="min-h-0 flex-1">
<slot /> <slot />
</AppShell> </main>
</div>
</div>
</div> </div>
</template> </template>
<script setup>
const drawerOpen = ref(true)
const { user, refresh } = useUser()
const route = useRoute()
async function onLogout() {
await $fetch('/api/auth/logout', { method: 'POST' })
await refresh()
await navigateTo('/')
}
watch(() => route.path, () => {
drawerOpen.value = false
})
</script>
+1 -1
View File
@@ -3,7 +3,7 @@ const LOGIN_PATH = '/login'
export default defineNuxtRouteMiddleware(async (to) => { export default defineNuxtRouteMiddleware(async (to) => {
if (to.path === LOGIN_PATH) return if (to.path === LOGIN_PATH) return
const { user, refresh } = useUser() const { user, refresh } = useUser()
if (!user.value) await refresh() await refresh()
if (user.value) return if (user.value) return
const redirect = to.fullPath.startsWith('/') ? to.fullPath : `/${to.fullPath}` const redirect = to.fullPath.startsWith('/') ? to.fullPath : `/${to.fullPath}`
return navigateTo({ path: LOGIN_PATH, query: { redirect } }, { replace: true }) return navigateTo({ path: LOGIN_PATH, query: { redirect } }, { replace: true })
+23 -201
View File
@@ -1,59 +1,15 @@
<template> <template>
<div class="p-6"> <div class="p-6">
<h2 class="kestrel-page-heading mb-4"> <h2 class="mb-4 text-xl font-semibold tracking-wide text-kestrel-text [text-shadow:0_0_8px_rgba(34,201,201,0.25)]">
Account Account
</h2> </h2>
<section <!-- Profile -->
v-if="user"
class="mb-8"
>
<h3 class="kestrel-section-label">
Profile photo
</h3>
<div class="kestrel-card flex items-center gap-4 p-4">
<div class="flex h-16 w-16 shrink-0 overflow-hidden rounded-full border border-kestrel-border bg-kestrel-border">
<img
v-if="user.avatar_url"
:src="`${user.avatar_url}${avatarBust ? `?t=${avatarBust}` : ''}`"
alt=""
class="h-full w-full object-cover"
>
<span
v-else
class="flex h-full w-full items-center justify-center text-lg font-medium text-kestrel-text"
>
{{ accountInitials }}
</span>
</div>
<div class="flex flex-wrap gap-2">
<label class="kestrel-btn-secondary cursor-pointer">
<input
type="file"
accept="image/jpeg,image/png"
class="sr-only"
:disabled="avatarLoading"
@change="onAvatarFileChange"
>
{{ avatarLoading ? 'Uploading…' : 'Upload' }}
</label>
<button
type="button"
class="kestrel-btn-secondary disabled:opacity-50"
:disabled="avatarLoading || !user.avatar_url"
@click="onRemoveAvatar"
>
Remove
</button>
</div>
</div>
</section>
<section class="mb-8"> <section class="mb-8">
<h3 class="kestrel-section-label"> <h3 class="mb-2 text-sm font-medium uppercase tracking-wider text-kestrel-muted">
Profile Profile
</h3> </h3>
<div class="kestrel-card p-4"> <div class="rounded border border-kestrel-border bg-kestrel-surface p-4 shadow-glow [box-shadow:0_0_20px_-4px_rgba(34,201,201,0.15)]">
<template v-if="user"> <template v-if="user">
<dl class="space-y-2 text-sm"> <dl class="space-y-2 text-sm">
<div> <div>
@@ -94,79 +50,15 @@
</div> </div>
</section> </section>
<section <!-- Change password (local only) -->
v-if="user"
class="mb-8"
>
<h3 class="kestrel-section-label">
ATAK / device password
</h3>
<div class="kestrel-card p-4">
<p class="mb-3 text-sm text-kestrel-muted">
{{ user.auth_provider === 'oidc' ? 'Set a password to use when connecting from ATAK (check "Use Authentication" and enter your KestrelOS username and this password).' : 'Optionally set a separate password for ATAK; otherwise use your login password.' }}
</p>
<p
v-if="cotPasswordSuccess"
class="mb-3 text-sm text-green-400"
>
ATAK password saved.
</p>
<p
v-if="cotPasswordError"
class="mb-3 text-sm text-red-400"
>
{{ cotPasswordError }}
</p>
<form
class="space-y-3"
@submit.prevent="onSetCotPassword"
>
<div>
<label
for="account-cot-password"
class="kestrel-label"
>ATAK password</label>
<input
id="account-cot-password"
v-model="cotPassword"
type="password"
autocomplete="new-password"
class="kestrel-input"
:placeholder="user.auth_provider === 'oidc' ? 'Set password for ATAK' : 'Optional'"
>
</div>
<div>
<label
for="account-cot-password-confirm"
class="kestrel-label"
>Confirm ATAK password</label>
<input
id="account-cot-password-confirm"
v-model="cotPasswordConfirm"
type="password"
autocomplete="new-password"
class="kestrel-input"
>
</div>
<button
type="submit"
class="rounded bg-kestrel-accent px-4 py-2 text-sm font-medium text-kestrel-bg transition-opacity hover:opacity-90 disabled:opacity-50"
:disabled="cotPasswordLoading"
>
{{ cotPasswordLoading ? 'Saving…' : 'Save ATAK password' }}
</button>
</form>
</div>
</section>
<section <section
v-if="user?.auth_provider === 'local'" v-if="user?.auth_provider === 'local'"
class="mb-8" class="mb-8"
> >
<h3 class="kestrel-section-label"> <h3 class="mb-2 text-sm font-medium uppercase tracking-wider text-kestrel-muted">
Change password Change password
</h3> </h3>
<div class="kestrel-card p-4"> <div class="rounded border border-kestrel-border bg-kestrel-surface p-4 shadow-glow [box-shadow:0_0_20px_-4px_rgba(34,201,201,0.15)]">
<p <p
v-if="passwordSuccess" v-if="passwordSuccess"
class="mb-3 text-sm text-green-400" class="mb-3 text-sm text-green-400"
@@ -186,40 +78,46 @@
<div> <div>
<label <label
for="account-current-password" for="account-current-password"
class="kestrel-label" class="mb-1 block text-xs text-kestrel-muted"
>Current password</label> >
Current password
</label>
<input <input
id="account-current-password" id="account-current-password"
v-model="currentPassword" v-model="currentPassword"
type="password" type="password"
autocomplete="current-password" autocomplete="current-password"
class="kestrel-input" class="w-full rounded border border-kestrel-border bg-kestrel-bg px-3 py-2 text-sm text-kestrel-text outline-none focus:border-kestrel-accent"
> >
</div> </div>
<div> <div>
<label <label
for="account-new-password" for="account-new-password"
class="kestrel-label" class="mb-1 block text-xs text-kestrel-muted"
>New password</label> >
New password
</label>
<input <input
id="account-new-password" id="account-new-password"
v-model="newPassword" v-model="newPassword"
type="password" type="password"
autocomplete="new-password" autocomplete="new-password"
class="kestrel-input" class="w-full rounded border border-kestrel-border bg-kestrel-bg px-3 py-2 text-sm text-kestrel-text outline-none focus:border-kestrel-accent"
> >
</div> </div>
<div> <div>
<label <label
for="account-confirm-password" for="account-confirm-password"
class="kestrel-label" class="mb-1 block text-xs text-kestrel-muted"
>Confirm new password</label> >
Confirm new password
</label>
<input <input
id="account-confirm-password" id="account-confirm-password"
v-model="confirmPassword" v-model="confirmPassword"
type="password" type="password"
autocomplete="new-password" autocomplete="new-password"
class="kestrel-input" class="w-full rounded border border-kestrel-border bg-kestrel-bg px-3 py-2 text-sm text-kestrel-text outline-none focus:border-kestrel-accent"
> >
</div> </div>
<button <button
@@ -236,60 +134,14 @@
</template> </template>
<script setup> <script setup>
const { user, refresh } = useUser() const { user } = useUser()
const avatarBust = ref(0)
const avatarLoading = ref(false)
const currentPassword = ref('') const currentPassword = ref('')
const newPassword = ref('') const newPassword = ref('')
const confirmPassword = ref('') const confirmPassword = ref('')
const passwordLoading = ref(false) const passwordLoading = ref(false)
const passwordSuccess = ref(false) const passwordSuccess = ref(false)
const passwordError = ref('') const passwordError = ref('')
const cotPassword = ref('')
const cotPasswordConfirm = ref('')
const cotPasswordLoading = ref(false)
const cotPasswordSuccess = ref(false)
const cotPasswordError = ref('')
const accountInitials = computed(() => {
const id = user.value?.identifier ?? ''
const parts = id.trim().split(/\s+/)
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase()
return id.slice(0, 2).toUpperCase() || '?'
})
async function onAvatarFileChange(e) {
const file = e.target.files?.[0]
if (!file) return
avatarLoading.value = true
try {
const form = new FormData()
form.append('avatar', file, file.name)
await $fetch('/api/me/avatar', { method: 'PUT', body: form, credentials: 'include' })
avatarBust.value = Date.now()
await refresh()
}
catch {
// Error surfaced by refresh or network
}
finally {
avatarLoading.value = false
e.target.value = ''
}
}
async function onRemoveAvatar() {
avatarLoading.value = true
try {
await $fetch('/api/me/avatar', { method: 'DELETE', credentials: 'include' })
avatarBust.value = Date.now()
await refresh()
}
finally {
avatarLoading.value = false
}
}
async function onChangePassword() { async function onChangePassword() {
passwordError.value = '' passwordError.value = ''
@@ -324,34 +176,4 @@ async function onChangePassword() {
passwordLoading.value = false passwordLoading.value = false
} }
} }
async function onSetCotPassword() {
cotPasswordError.value = ''
cotPasswordSuccess.value = false
if (cotPassword.value !== cotPasswordConfirm.value) {
cotPasswordError.value = 'Password and confirmation do not match.'
return
}
if (cotPassword.value.length < 1) {
cotPasswordError.value = 'Password cannot be empty.'
return
}
cotPasswordLoading.value = true
try {
await $fetch('/api/me/cot-password', {
method: 'PUT',
body: { password: cotPassword.value },
credentials: 'include',
})
cotPassword.value = ''
cotPasswordConfirm.value = ''
cotPasswordSuccess.value = true
}
catch (e) {
cotPasswordError.value = e.data?.message ?? e.message ?? 'Failed to save ATAK password.'
}
finally {
cotPasswordLoading.value = false
}
}
</script> </script>
+2 -4
View File
@@ -1,6 +1,6 @@
<template> <template>
<div class="p-6"> <div class="p-6">
<h2 class="kestrel-page-heading mb-4"> <h2 class="mb-4 text-xl font-semibold tracking-wide text-kestrel-text [text-shadow:0_0_8px_rgba(34,201,201,0.25)]">
Cameras Cameras
</h2> </h2>
<p class="mb-4 text-sm text-kestrel-muted"> <p class="mb-4 text-sm text-kestrel-muted">
@@ -80,8 +80,6 @@
<script setup> <script setup>
definePageMeta({ layout: 'default' }) definePageMeta({ layout: 'default' })
const { cameras, liveSessions } = useCameras() const { cameras } = useCameras()
const selectedCamera = ref(null) const selectedCamera = ref(null)
useAutoCloseLiveSession(selectedCamera, liveSessions)
</script> </script>
+5 -27
View File
@@ -1,29 +1,17 @@
<template> <template>
<div class="flex h-full w-full flex-col md:flex-row"> <div class="flex h-[calc(100vh-5rem)] w-full flex-col md:flex-row">
<div class="relative min-h-0 flex-1"> <div class="relative h-2/3 w-full md:h-full md:flex-1">
<ClientOnly> <ClientOnly>
<KestrelMap <KestrelMap
:feeds="[]"
:devices="devices ?? []" :devices="devices ?? []"
:pois="pois ?? []" :pois="pois ?? []"
:live-sessions="liveSessions ?? []" :live-sessions="liveSessions ?? []"
:cot-entities="cotEntities ?? []"
:cot-layers="cotLayers"
:alpr-markers="showAlpr ? (alprMarkers ?? []) : []"
:show-alpr="showAlpr"
:can-edit-pois="canEditPois" :can-edit-pois="canEditPois"
@select="selectedCamera = $event" @select="selectedCamera = $event"
@select-live="onSelectLive($event)" @select-live="onSelectLive($event)"
@refresh-pois="refreshPois" @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> </ClientOnly>
</div> </div>
<CameraViewer <CameraViewer
@@ -38,20 +26,10 @@
const { devices, liveSessions } = useCameras() const { devices, liveSessions } = useCameras()
const { data: pois, refresh: refreshPois } = usePois() const { data: pois, refresh: refreshPois } = usePois()
const { canEditPois } = useUser() 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) const selectedCamera = ref(null)
function onMapBoundsChange(bounds) {
mapBounds.value = bounds
onAlprBoundsChange(bounds)
}
function onSelectLive(session) { function onSelectLive(session) {
selectedCamera.value = (liveSessions.value ?? []).find(s => s.id === session?.id) ?? session const latest = (liveSessions.value || []).find(s => s.id === session?.id)
selectedCamera.value = latest ?? session
} }
useAutoCloseLiveSession(selectedCamera, liveSessions)
</script> </script>
+10 -10
View File
@@ -1,7 +1,7 @@
<template> <template>
<div class="flex min-h-[60vh] items-center justify-center p-6"> <div class="flex min-h-[60vh] items-center justify-center p-6">
<div class="kestrel-card w-full max-w-sm p-6"> <div class="w-full max-w-sm rounded border border-kestrel-border bg-kestrel-surface p-6 shadow-glow [box-shadow:0_0_20px_-4px_rgba(34,201,201,0.15)]">
<h2 class="kestrel-section-heading mb-4"> <h2 class="mb-4 text-lg font-semibold text-kestrel-text [text-shadow:0_0_8px_rgba(34,201,201,0.25)]">
Sign in Sign in
</h2> </h2>
<p <p
@@ -29,28 +29,28 @@
<div class="mb-3"> <div class="mb-3">
<label <label
for="login-identifier" for="login-identifier"
class="kestrel-label" class="mb-1 block text-xs text-kestrel-muted"
>Email or username</label> >Email or username</label>
<input <input
id="login-identifier" id="login-identifier"
v-model="identifier" v-model="identifier"
type="text" type="text"
autocomplete="username" autocomplete="username"
class="kestrel-input" class="w-full rounded border border-kestrel-border bg-kestrel-bg px-3 py-2 text-sm text-kestrel-text outline-none focus:border-kestrel-accent"
required required
> >
</div> </div>
<div class="mb-4"> <div class="mb-4">
<label <label
for="login-password" for="login-password"
class="kestrel-label" class="mb-1 block text-xs text-kestrel-muted"
>Password</label> >Password</label>
<input <input
id="login-password" id="login-password"
v-model="password" v-model="password"
type="password" type="password"
autocomplete="current-password" autocomplete="current-password"
class="kestrel-input" class="w-full rounded border border-kestrel-border bg-kestrel-bg px-3 py-2 text-sm text-kestrel-text outline-none focus:border-kestrel-accent"
required required
> >
</div> </div>
@@ -69,16 +69,16 @@
<script setup> <script setup>
const route = useRoute() const route = useRoute()
const redirect = computed(() => route.query.redirect || '/') const redirect = computed(() => route.query.redirect || '/')
const AUTH_CONFIG_DEFAULT = Object.freeze({ oidc: { enabled: false, label: '' } })
const { data: authConfig } = useAsyncData( const { data: authConfig } = useAsyncData(
'auth-config', 'auth-config',
() => $fetch('/api/auth/config').catch(() => AUTH_CONFIG_DEFAULT), () => $fetch('/api/auth/config').catch(() => ({ oidc: { enabled: false, label: '' } })),
{ default: () => null }, { default: () => null },
) )
const showDivider = computed(() => !!authConfig.value?.oidc?.enabled) const showDivider = computed(() => !!authConfig.value?.oidc?.enabled)
const oidcAuthorizeUrl = computed(() => { const oidcAuthorizeUrl = computed(() => {
const r = redirect.value const base = '/api/auth/oidc/authorize'
return `/api/auth/oidc/authorize${r && r !== '/' ? `?redirect=${encodeURIComponent(r)}` : ''}` const q = redirect.value && redirect.value !== '/' ? `?redirect=${encodeURIComponent(redirect.value)}` : ''
return base + q
}) })
const identifier = ref('') const identifier = ref('')
+425 -48
View File
@@ -1,6 +1,6 @@
<template> <template>
<div class="p-6"> <div class="p-6">
<h2 class="kestrel-page-heading mb-2"> <h2 class="mb-2 text-xl font-semibold tracking-wide text-kestrel-text [text-shadow:0_0_8px_rgba(34,201,201,0.25)]">
Members Members
</h2> </h2>
<p <p
@@ -10,7 +10,7 @@
Sign in to view members. Sign in to view members.
</p> </p>
<p <p
v-else-if="!canEditPois" v-else-if="!canViewMembers"
class="text-sm text-kestrel-muted" class="text-sm text-kestrel-muted"
> >
You don't have access to the members list. You don't have access to the members list.
@@ -34,51 +34,371 @@
Add user Add user
</button> </button>
</div> </div>
<MembersTable <div class="overflow-x-auto rounded border border-kestrel-border">
:users="users" <table class="w-full text-left text-sm">
:role-by-user-id="roleByUserId" <thead>
:role-options="roleOptions" <tr class="border-b border-kestrel-border bg-kestrel-surface-hover">
:is-admin="isAdmin" <th class="px-4 py-2 font-medium text-kestrel-text">
:current-user-id="user?.id ?? null" Identifier
:open-role-dropdown-id="openRoleDropdownId" </th>
@toggle-role-dropdown="toggleRoleDropdown" <th class="px-4 py-2 font-medium text-kestrel-text">
@close-role-dropdown="openRoleDropdownId = null" Auth
@select-role="selectRole" </th>
@save-role="saveRole" <th class="px-4 py-2 font-medium text-kestrel-text">
@edit-user="openEditUser" Role
@delete-confirm="openDeleteConfirm" </th>
/> <th
v-if="isAdmin"
class="px-4 py-2 font-medium text-kestrel-text"
>
Actions
</th>
</tr>
</thead>
<tbody>
<tr
v-for="u in users"
:key="u.id"
class="border-b border-kestrel-border"
>
<td class="px-4 py-2 text-kestrel-text">
{{ u.identifier }}
</td>
<td class="px-4 py-2">
<span
class="rounded px-1.5 py-0.5 text-xs text-kestrel-muted"
:class="u.auth_provider === 'oidc' ? 'bg-kestrel-surface' : ''"
>
{{ u.auth_provider === 'oidc' ? 'OIDC' : 'Local' }}
</span>
</td>
<td class="px-4 py-2">
<div
v-if="isAdmin"
:ref="el => setDropdownWrapRef(u.id, el)"
class="relative inline-block"
>
<button
type="button"
class="flex min-w-[6rem] items-center justify-between gap-2 rounded border border-kestrel-border bg-kestrel-bg px-2 py-1 text-left text-sm text-kestrel-text shadow-sm transition-colors hover:border-kestrel-accent/50 hover:bg-kestrel-surface"
:aria-expanded="openRoleDropdownId === u.id"
:aria-haspopup="true"
aria-label="Change role"
@click.stop="toggleRoleDropdown(u.id)"
>
<span>{{ roleByUserId[u.id] ?? u.role }}</span>
<span
class="text-kestrel-muted transition-transform"
:class="openRoleDropdownId === u.id && 'rotate-180'"
>
</span>
</button>
</div>
<span
v-else
class="text-kestrel-muted"
>{{ u.role }}</span>
</td>
<td
v-if="isAdmin"
class="px-4 py-2"
>
<div class="flex flex-wrap items-center gap-2">
<button
v-if="roleByUserId[u.id] !== u.role"
type="button"
class="rounded border border-kestrel-accent px-2 py-1 text-xs text-kestrel-accent hover:bg-kestrel-accent-dim"
@click="saveRole(u.id)"
>
Save role
</button>
<template v-if="u.auth_provider !== 'oidc'">
<button
type="button"
class="rounded border border-kestrel-border px-2 py-1 text-xs text-kestrel-text hover:bg-kestrel-surface"
@click="openEditUser(u)"
>
Edit
</button>
<button
v-if="u.id !== user?.id"
type="button"
class="rounded border border-red-500/60 px-2 py-1 text-xs text-red-400 hover:bg-red-500/10"
@click="openDeleteConfirm(u)"
>
Remove
</button>
</template>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<Teleport to="body">
<Transition
enter-active-class="transition duration-100 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition duration-75 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div
v-if="openRoleDropdownId && dropdownPlacement"
ref="dropdownMenuRef"
role="menu"
class="fixed z-[100] min-w-[6rem] rounded border border-kestrel-border bg-kestrel-surface py-1 shadow-glow [box-shadow:0_4px_12px_-2px_rgba(34,201,201,0.15)]"
:style="{
top: `${dropdownPlacement.top}px`,
left: `${dropdownPlacement.left}px`,
minWidth: `${dropdownPlacement.minWidth}px`,
}"
>
<button
v-for="role in roleOptions"
:key="role"
type="button"
role="menuitem"
class="block w-full px-3 py-1.5 text-left text-sm transition-colors"
:class="roleByUserId[openRoleDropdownId] === role
? 'bg-kestrel-accent-dim text-kestrel-accent'
: 'text-kestrel-text hover:bg-kestrel-border hover:text-kestrel-text'"
@click.stop="selectRole(openRoleDropdownId, role)"
>
{{ role }}
</button>
</div>
</Transition>
</Teleport>
<!-- Add user modal --> <!-- Add user modal -->
<AddUserModal <Teleport to="body">
:show="addUserModalOpen" <div
:submit-error="createError" v-if="addUserModalOpen"
@close="closeAddUserModal" class="fixed inset-0 z-[200] flex items-center justify-center bg-black/50 p-4"
@submit="onAddUserSubmit" role="dialog"
/> aria-modal="true"
<DeleteUserConfirmModal aria-labelledby="add-user-title"
:user="deleteConfirmUser" @click.self="closeAddUserModal"
@close="deleteConfirmUser = null" >
@confirm="confirmDeleteUser" <div
/> class="w-full max-w-sm rounded border border-kestrel-border bg-kestrel-surface p-4 shadow-glow"
<EditUserModal @click.stop
:user="editUserModal" >
:submit-error="editError" <h3
@close="editUserModal = null" id="add-user-title"
@submit="onEditUserSubmit" class="mb-3 text-sm font-medium text-kestrel-text"
/> >
Add user
</h3>
<form @submit.prevent="submitAddUser">
<div class="mb-3 flex flex-col gap-1">
<label
for="add-identifier"
class="text-xs text-kestrel-muted"
>Username</label>
<input
id="add-identifier"
v-model="newUser.identifier"
type="text"
required
autocomplete="username"
class="rounded border border-kestrel-border bg-kestrel-bg px-2 py-1.5 text-sm text-kestrel-text"
placeholder="username"
>
</div>
<div class="mb-3 flex flex-col gap-1">
<label
for="add-password"
class="text-xs text-kestrel-muted"
>Password</label>
<input
id="add-password"
v-model="newUser.password"
type="password"
required
autocomplete="new-password"
class="rounded border border-kestrel-border bg-kestrel-bg px-2 py-1.5 text-sm text-kestrel-text"
placeholder="••••••••"
>
</div>
<div class="mb-4 flex flex-col gap-1">
<label
for="add-role"
class="text-xs text-kestrel-muted"
>Role</label>
<select
id="add-role"
v-model="newUser.role"
class="rounded border border-kestrel-border bg-kestrel-bg px-2 py-1.5 text-sm text-kestrel-text"
>
<option value="member">
member
</option>
<option value="leader">
leader
</option>
<option value="admin">
admin
</option>
</select>
</div>
<p
v-if="createError"
class="mb-2 text-xs text-red-400"
>
{{ createError }}
</p>
<div class="flex justify-end gap-2">
<button
type="button"
class="rounded border border-kestrel-border px-3 py-1.5 text-sm text-kestrel-text hover:bg-kestrel-surface-hover"
@click="closeAddUserModal"
>
Cancel
</button>
<button
type="submit"
class="rounded border border-kestrel-accent px-3 py-1.5 text-sm text-kestrel-accent hover:bg-kestrel-accent-dim"
>
Add user
</button>
</div>
</form>
</div>
</div>
</Teleport>
<!-- Delete user confirmation modal -->
<Teleport to="body">
<div
v-if="deleteConfirmUser"
class="fixed inset-0 z-[200] flex items-center justify-center bg-black/50 p-4"
role="dialog"
aria-modal="true"
aria-labelledby="delete-user-title"
@click.self="deleteConfirmUser = null"
>
<div
class="w-full max-w-sm rounded border border-kestrel-border bg-kestrel-surface p-4 shadow-glow"
@click.stop
>
<h3
id="delete-user-title"
class="mb-2 text-sm font-medium text-kestrel-text"
>
Delete user?
</h3>
<p class="mb-4 text-sm text-kestrel-muted">
Are you sure you want to delete <strong class="text-kestrel-text">{{ deleteConfirmUser?.identifier }}</strong>? They will not be able to sign in again.
</p>
<div class="flex justify-end gap-2">
<button
type="button"
class="rounded border border-kestrel-border px-3 py-1.5 text-sm text-kestrel-text hover:bg-kestrel-surface-hover"
@click="deleteConfirmUser = null"
>
Cancel
</button>
<button
type="button"
class="rounded border border-red-500/60 bg-red-500/10 px-3 py-1.5 text-sm text-red-400 hover:bg-red-500/20"
@click="confirmDeleteUser"
>
Delete
</button>
</div>
</div>
</div>
</Teleport>
<Teleport to="body">
<div
v-if="editUserModal"
class="fixed inset-0 z-[200] flex items-center justify-center bg-black/50 p-4"
@click.self="editUserModal = null"
>
<div
class="w-full max-w-sm rounded border border-kestrel-border bg-kestrel-surface p-4 shadow-glow"
role="dialog"
aria-modal="true"
aria-labelledby="edit-user-title"
>
<h3
id="edit-user-title"
class="mb-3 text-sm font-medium text-kestrel-text"
>
Edit local user
</h3>
<form @submit.prevent="submitEditUser">
<div class="mb-3 flex flex-col gap-1">
<label
for="edit-identifier"
class="text-xs text-kestrel-muted"
>Identifier</label>
<input
id="edit-identifier"
v-model="editForm.identifier"
type="text"
required
class="rounded border border-kestrel-border bg-kestrel-bg px-2 py-1.5 text-sm text-kestrel-text"
>
</div>
<div class="mb-4 flex flex-col gap-1">
<label
for="edit-password"
class="text-xs text-kestrel-muted"
>New password (leave blank to keep)</label>
<input
id="edit-password"
v-model="editForm.password"
type="password"
autocomplete="new-password"
class="rounded border border-kestrel-border bg-kestrel-bg px-2 py-1.5 text-sm text-kestrel-text"
placeholder="••••••••"
>
<p class="mt-0.5 text-xs text-kestrel-muted">
If you change your password, use the new one next time you sign in.
</p>
</div>
<p
v-if="editError"
class="mb-2 text-xs text-red-400"
>
{{ editError }}
</p>
<div class="flex justify-end gap-2">
<button
type="button"
class="rounded border border-kestrel-border px-3 py-1.5 text-sm text-kestrel-text hover:bg-kestrel-surface-hover"
@click="editUserModal = null"
>
Cancel
</button>
<button
type="submit"
class="rounded border border-kestrel-accent px-3 py-1.5 text-sm text-kestrel-accent hover:bg-kestrel-accent-dim"
>
Save
</button>
</div>
</form>
</div>
</div>
</Teleport>
</template> </template>
</div> </div>
</template> </template>
<script setup> <script setup>
const { user, isAdmin, canEditPois, refresh: refreshUser } = useUser() const { user, isAdmin, refresh: refreshUser } = useUser()
const canViewMembers = computed(() => user.value?.role === 'admin' || user.value?.role === 'leader')
const { data: usersData, refresh: refreshUsers } = useAsyncData( const { data: usersData, refresh: refreshUsers } = useAsyncData(
'users', 'users',
() => $fetch('/api/users').catch(() => []), () => $fetch('/api/users').catch(() => []),
{ default: () => [] }, { default: () => [] },
) )
const users = computed(() => Object.freeze([...(usersData.value ?? [])])) const users = computed(() => (Array.isArray(usersData.value) ? usersData.value : []))
const roleOptions = ['admin', 'leader', 'member'] const roleOptions = ['admin', 'leader', 'member']
const pendingRoleUpdates = ref({}) const pendingRoleUpdates = ref({})
@@ -87,26 +407,80 @@ const roleByUserId = computed(() => {
return { ...base, ...pendingRoleUpdates.value } return { ...base, ...pendingRoleUpdates.value }
}) })
const openRoleDropdownId = ref(null) const openRoleDropdownId = ref(null)
const dropdownWrapRefs = ref({})
const dropdownPlacement = ref(null)
const dropdownMenuRef = ref(null)
const addUserModalOpen = ref(false) const addUserModalOpen = ref(false)
const newUser = ref({ identifier: '', password: '', role: 'member' })
const createError = ref('') const createError = ref('')
const editUserModal = ref(null) const editUserModal = ref(null)
const editForm = ref({ identifier: '', password: '' })
const editError = ref('') const editError = ref('')
const deleteConfirmUser = ref(null) const deleteConfirmUser = ref(null)
watch(user, () => { function setDropdownWrapRef(userId, el) {
if (canEditPois.value) refreshUsers() if (el) dropdownWrapRefs.value[userId] = el
else {
dropdownWrapRefs.value = Object.fromEntries(
Object.entries(dropdownWrapRefs.value).filter(([k]) => k !== userId),
)
}
}
watch(user, (u) => {
if (u?.role === 'admin' || u?.role === 'leader') refreshUsers()
}, { immediate: true }) }, { immediate: true })
function toggleRoleDropdown(userId) { function toggleRoleDropdown(userId) {
openRoleDropdownId.value = openRoleDropdownId.value === userId ? null : userId if (openRoleDropdownId.value === userId) {
openRoleDropdownId.value = null
dropdownPlacement.value = null
return
}
openRoleDropdownId.value = userId
nextTick(() => {
const wrap = dropdownWrapRefs.value[userId]
if (wrap) {
const rect = wrap.getBoundingClientRect()
dropdownPlacement.value = {
top: rect.bottom + 4,
left: rect.left,
minWidth: Math.max(rect.width, 96),
}
}
else {
dropdownPlacement.value = { top: 0, left: 0, minWidth: 96 }
}
})
} }
function selectRole(userId, role) { function selectRole(userId, role) {
pendingRoleUpdates.value = { ...pendingRoleUpdates.value, [userId]: role } pendingRoleUpdates.value = { ...pendingRoleUpdates.value, [userId]: role }
openRoleDropdownId.value = null openRoleDropdownId.value = null
dropdownPlacement.value = null
} }
function onDocumentClick(e) {
const openId = openRoleDropdownId.value
if (openId == null) return
const wrap = dropdownWrapRefs.value[openId]
const menu = dropdownMenuRef.value
const inTrigger = wrap && wrap.contains(e.target)
const inMenu = menu && menu.contains(e.target)
if (!inTrigger && !inMenu) {
openRoleDropdownId.value = null
dropdownPlacement.value = null
}
}
onMounted(() => {
document.addEventListener('click', onDocumentClick)
})
onBeforeUnmount(() => {
document.removeEventListener('click', onDocumentClick)
})
async function saveRole(id) { async function saveRole(id) {
const role = roleByUserId.value[id] const role = roleByUserId.value[id]
if (!role) return if (!role) return
@@ -124,6 +498,7 @@ async function saveRole(id) {
function openAddUserModal() { function openAddUserModal() {
addUserModalOpen.value = true addUserModalOpen.value = true
newUser.value = { identifier: '', password: '', role: 'member' }
createError.value = '' createError.value = ''
} }
@@ -132,15 +507,15 @@ function closeAddUserModal() {
createError.value = '' createError.value = ''
} }
async function onAddUserSubmit(payload) { async function submitAddUser() {
createError.value = '' createError.value = ''
try { try {
await $fetch('/api/users', { await $fetch('/api/users', {
method: 'POST', method: 'POST',
body: { body: {
identifier: payload.identifier, identifier: newUser.value.identifier.trim(),
password: payload.password, password: newUser.value.password,
role: payload.role, role: newUser.value.role,
}, },
}) })
closeAddUserModal() closeAddUserModal()
@@ -153,19 +528,21 @@ async function onAddUserSubmit(payload) {
function openEditUser(u) { function openEditUser(u) {
editUserModal.value = u editUserModal.value = u
editForm.value = { identifier: u.identifier, password: '' }
editError.value = '' editError.value = ''
} }
async function onEditUserSubmit(payload) { async function submitEditUser() {
const u = editUserModal.value if (!editUserModal.value) return
if (!u) return
editError.value = '' editError.value = ''
const body = { identifier: payload.identifier.trim() } const id = editUserModal.value.id
if (payload.password) body.password = payload.password const body = { identifier: editForm.value.identifier.trim() }
if (editForm.value.password) body.password = editForm.value.password
try { try {
await $fetch(`/api/users/${u.id}`, { method: 'PATCH', body }) await $fetch(`/api/users/${id}`, { method: 'PATCH', body })
editUserModal.value = null editUserModal.value = null
await refreshUsers() await refreshUsers()
// If you edited yourself, refresh current user so the header/nav shows the new identifier
await refreshUser() await refreshUser()
} }
catch (e) { catch (e) {
+18 -18
View File
@@ -1,6 +1,6 @@
<template> <template>
<div class="p-6"> <div class="p-6">
<h2 class="kestrel-page-heading mb-2"> <h2 class="mb-2 text-xl font-semibold tracking-wide text-kestrel-text [text-shadow:0_0_8px_rgba(34,201,201,0.25)]">
POI placement POI placement
</h2> </h2>
<p <p
@@ -17,7 +17,7 @@
<div> <div>
<label <label
for="poi-lat" for="poi-lat"
class="kestrel-label" class="mb-1 block text-xs text-kestrel-muted"
>Lat</label> >Lat</label>
<input <input
id="poi-lat" id="poi-lat"
@@ -25,13 +25,13 @@
type="number" type="number"
step="any" step="any"
required required
class="kestrel-input w-28" class="w-28 rounded border border-kestrel-border bg-kestrel-bg px-2 py-1 text-sm text-kestrel-text"
> >
</div> </div>
<div> <div>
<label <label
for="poi-lng" for="poi-lng"
class="kestrel-label" class="mb-1 block text-xs text-kestrel-muted"
>Lng</label> >Lng</label>
<input <input
id="poi-lng" id="poi-lng"
@@ -39,37 +39,39 @@
type="number" type="number"
step="any" step="any"
required required
class="kestrel-input w-28" class="w-28 rounded border border-kestrel-border bg-kestrel-bg px-2 py-1 text-sm text-kestrel-text"
> >
</div> </div>
<div> <div>
<label <label
for="poi-label" for="poi-label"
class="kestrel-label" class="mb-1 block text-xs text-kestrel-muted"
>Label</label> >Label</label>
<input <input
id="poi-label" id="poi-label"
v-model="form.label" v-model="form.label"
type="text" type="text"
class="kestrel-input w-40" class="w-40 rounded border border-kestrel-border bg-kestrel-bg px-2 py-1 text-sm text-kestrel-text"
> >
</div> </div>
<div> <div>
<label <label
for="poi-icon" for="poi-icon"
class="kestrel-label" class="mb-1 block text-xs text-kestrel-muted"
>Icon</label> >Icon</label>
<select <select
id="poi-icon" id="poi-icon"
v-model="form.iconType" v-model="form.iconType"
class="kestrel-input w-28" class="rounded border border-kestrel-border bg-kestrel-bg px-2 py-1 text-sm text-kestrel-text"
> >
<option <option value="pin">
v-for="opt in POI_ICON_TYPES" pin
:key="opt" </option>
:value="opt" <option value="flag">
> flag
{{ opt }} </option>
<option value="waypoint">
waypoint
</option> </option>
</select> </select>
</div> </div>
@@ -112,7 +114,7 @@
class="border-b border-kestrel-border" class="border-b border-kestrel-border"
> >
<td class="px-4 py-2 text-kestrel-text"> <td class="px-4 py-2 text-kestrel-text">
{{ p.label || '-' }} {{ p.label || '' }}
</td> </td>
<td class="px-4 py-2 text-kestrel-muted"> <td class="px-4 py-2 text-kestrel-muted">
{{ p.lat }} {{ p.lat }}
@@ -143,8 +145,6 @@
</template> </template>
<script setup> <script setup>
const POI_ICON_TYPES = Object.freeze(['pin', 'flag', 'waypoint'])
const { data: poisData, refresh } = usePois() const { data: poisData, refresh } = usePois()
const { canEditPois } = useUser() const { canEditPois } = useUser()
const poisList = computed(() => poisData.value ?? []) const poisList = computed(() => poisData.value ?? [])
+8 -91
View File
@@ -1,14 +1,15 @@
<template> <template>
<div class="p-6"> <div class="p-6">
<h2 class="kestrel-page-heading mb-4"> <h2 class="mb-4 text-xl font-semibold tracking-wide text-kestrel-text [text-shadow:0_0_8px_rgba(34,201,201,0.25)]">
Settings Settings
</h2> </h2>
<!-- Map & offline -->
<section class="mb-8"> <section class="mb-8">
<h3 class="kestrel-section-label"> <h3 class="mb-2 text-sm font-medium uppercase tracking-wider text-kestrel-muted">
Map & offline Map & offline
</h3> </h3>
<div class="kestrel-card p-4"> <div class="rounded border border-kestrel-border bg-kestrel-surface p-4 shadow-glow [box-shadow:0_0_20px_-4px_rgba(34,201,201,0.15)]">
<p class="mb-3 text-sm text-kestrel-text"> <p class="mb-3 text-sm text-kestrel-text">
Clear saved map tiles to free storage. The map will load tiles from the network again when you use it. Clear saved map tiles to free storage. The map will load tiles from the network again when you use it.
</p> </p>
@@ -27,7 +28,7 @@
</p> </p>
<button <button
type="button" type="button"
class="kestrel-btn-secondary disabled:opacity-50" class="rounded border border-kestrel-border px-4 py-2 text-sm text-kestrel-text transition-colors hover:bg-kestrel-border disabled:opacity-50"
:disabled="tilesLoading" :disabled="tilesLoading"
@click="onClearTiles" @click="onClearTiles"
> >
@@ -36,72 +37,12 @@
</div> </div>
</section> </section>
<section class="mb-8"> <!-- About -->
<h3 class="kestrel-section-label">
TAK Server (ATAK / iTAK)
</h3>
<div class="kestrel-card p-4">
<p class="mb-3 text-sm text-kestrel-text">
Scan this QR code with iTAK (or ATAK) to add this KestrelOS server. You'll be prompted for your KestrelOS username and password after scanning.
</p>
<div
v-if="takQrDataUrl"
class="inline-block rounded-lg border border-kestrel-border bg-white p-3"
>
<img
:src="takQrDataUrl"
alt="TAK Server QR code"
class="h-48 w-48"
width="192"
height="192"
>
</div>
<p
v-else-if="takQrError"
class="text-sm text-red-400"
>
{{ takQrError }}
</p>
<p
v-else
class="text-sm text-kestrel-muted"
>
Loading QR code…
</p>
<p
v-if="takServerString"
class="mt-3 text-xs text-kestrel-muted break-all"
>
{{ takServerString }}
</p>
<template v-if="cotConfig?.ssl">
<p class="mt-3 text-sm text-kestrel-text">
This server uses a self-signed certificate. iTAK will not connect until it trusts the cert.
</p>
<ol class="mt-2 list-decimal list-inside space-y-1 text-sm text-kestrel-text">
<li>
<strong>Upload server package:</strong> Download below, then in iTAK tap Add Server (+) → Upload server package and select the zip; enter KestrelOS username and password when prompted.
</li>
<li>
<strong>Plain TCP:</strong> Remove or rename <code class="bg-kestrel-surface px-1 rounded">.dev-certs</code>, restart, then in iTAK add the server with SSL disabled.
</li>
</ol>
<a
href="/api/cot/server-package"
download="kestrelos-itak-server-package.zip"
class="kestrel-btn-secondary mt-3 inline-block"
>
Download server package (zip)
</a>
</template>
</div>
</section>
<section> <section>
<h3 class="kestrel-section-label"> <h3 class="mb-2 text-sm font-medium uppercase tracking-wider text-kestrel-muted">
About About
</h3> </h3>
<div class="kestrel-card p-4"> <div class="rounded border border-kestrel-border bg-kestrel-surface p-4 shadow-glow [box-shadow:0_0_20px_-4px_rgba(34,201,201,0.15)]">
<p class="font-medium text-kestrel-text"> <p class="font-medium text-kestrel-text">
KestrelOS KestrelOS
</p> </p>
@@ -128,11 +69,6 @@ const tilesMessage = ref('')
const tilesMessageSuccess = ref(false) const tilesMessageSuccess = ref(false)
const tilesLoading = ref(false) const tilesLoading = ref(false)
const cotConfig = ref(null)
const takQrDataUrl = ref('')
const takQrError = ref('')
const takServerString = ref('')
async function loadTilesStored() { async function loadTilesStored() {
if (typeof window === 'undefined') return if (typeof window === 'undefined') return
try { try {
@@ -172,26 +108,7 @@ async function onClearTiles() {
} }
} }
async function loadTakQr() {
if (typeof window === 'undefined') return
try {
const res = await $fetch('/api/cot/config')
cotConfig.value = res
const hostname = window.location.hostname
const port = res?.port ?? 8089
const protocol = res?.ssl ? 'ssl' : 'tcp'
const str = `KestrelOS,${hostname},${port},${protocol}`
takServerString.value = str
const QRCode = (await import('qrcode')).default
takQrDataUrl.value = await QRCode.toDataURL(str, { width: 192, margin: 1 })
}
catch (e) {
takQrError.value = e?.data?.error ?? e?.message ?? 'Could not load TAK server config.'
}
}
onMounted(() => { onMounted(() => {
loadTilesStored() loadTilesStored()
loadTakQr()
}) })
</script> </script>
+57 -57
View File
@@ -1,7 +1,7 @@
<template> <template>
<div class="flex min-h-[80vh] flex-col items-center justify-center p-6"> <div class="flex min-h-[80vh] flex-col items-center justify-center p-6">
<div class="kestrel-card-modal w-full max-w-md p-6"> <div class="w-full max-w-md rounded-lg border border-kestrel-border bg-kestrel-surface p-6 shadow-glow [box-shadow:0_0_24px_-6px_rgba(34,201,201,0.2)]">
<h2 class="kestrel-section-heading mb-2"> <h2 class="mb-2 text-lg font-semibold tracking-wide text-kestrel-text [text-shadow:0_0_8px_rgba(34,201,201,0.25)]">
Share live (camera + location) Share live (camera + location)
</h2> </h2>
<p class="mb-4 text-sm text-kestrel-muted"> <p class="mb-4 text-sm text-kestrel-muted">
@@ -39,7 +39,7 @@
Wrong host: server sees <strong>{{ webrtcFailureReason.wrongHost.serverHostname }}</strong> but you opened this page at <strong>{{ webrtcFailureReason.wrongHost.clientHostname }}</strong>. Use the same URL on phone and server, or set MEDIASOUP_ANNOUNCED_IP. Wrong host: server sees <strong>{{ webrtcFailureReason.wrongHost.serverHostname }}</strong> but you opened this page at <strong>{{ webrtcFailureReason.wrongHost.clientHostname }}</strong>. Use the same URL on phone and server, or set MEDIASOUP_ANNOUNCED_IP.
</p> </p>
<ul class="mt-2 list-inside list-disc space-y-0.5 text-kestrel-muted"> <ul class="mt-2 list-inside list-disc space-y-0.5 text-kestrel-muted">
<li><strong>Firewall:</strong> Open UDP/TCP ports 40000-49999 on the server.</li> <li><strong>Firewall:</strong> Open UDP/TCP ports 4000049999 on the server.</li>
<li><strong>Wrong host:</strong> Server must see the same address you use (see above or open /api/live/debug-request-host).</li> <li><strong>Wrong host:</strong> Server must see the same address you use (see above or open /api/live/debug-request-host).</li>
<li><strong>Restrictive NAT / cellular:</strong> A TURN server may be required (future enhancement).</li> <li><strong>Restrictive NAT / cellular:</strong> A TURN server may be required (future enhancement).</li>
</ul> </ul>
@@ -55,7 +55,7 @@
<!-- Local preview --> <!-- Local preview -->
<div <div
v-if="stream && videoRef" v-if="stream && videoRef"
class="kestrel-video-frame mb-4" class="relative mb-4 aspect-video w-full overflow-hidden rounded border border-kestrel-border bg-black"
> >
<video <video
ref="videoRef" ref="videoRef"
@@ -68,7 +68,7 @@
v-if="sharing" v-if="sharing"
class="absolute bottom-2 left-2 rounded bg-black/70 px-2 py-1 text-xs text-green-400" class="absolute bottom-2 left-2 rounded bg-black/70 px-2 py-1 text-xs text-green-400"
> >
● Live - you appear on the map ● Live you appear on the map
</div> </div>
</div> </div>
@@ -122,11 +122,11 @@ const starting = ref(false)
const isSecureContext = typeof window !== 'undefined' && window.isSecureContext const isSecureContext = typeof window !== 'undefined' && window.isSecureContext
const webrtcState = ref('') // '', 'connecting', 'connected', 'failed' const webrtcState = ref('') // '', 'connecting', 'connected', 'failed'
const webrtcFailureReason = ref(null) // { wrongHost: { serverHostname, clientHostname } | null } const webrtcFailureReason = ref(null) // { wrongHost: { serverHostname, clientHostname } | null }
const locationWatchId = ref(null) let locationWatchId = null
const locationIntervalId = ref(null) let locationIntervalId = null
const device = ref(null) let device = null
const sendTransport = ref(null) let sendTransport = null
const producer = ref(null) let producer = null
async function runFailureReasonCheck() { async function runFailureReasonCheck() {
webrtcFailureReason.value = await getWebRTCFailureReason() webrtcFailureReason.value = await getWebRTCFailureReason()
@@ -194,8 +194,8 @@ async function startSharing() {
const rtpCapabilities = await $fetch(`/api/live/webrtc/router-rtp-capabilities?sessionId=${sessionId.value}`, { const rtpCapabilities = await $fetch(`/api/live/webrtc/router-rtp-capabilities?sessionId=${sessionId.value}`, {
credentials: 'include', credentials: 'include',
}) })
device.value = await createMediasoupDevice(rtpCapabilities) device = await createMediasoupDevice(rtpCapabilities)
sendTransport.value = await createSendTransport(device.value, sessionId.value, { sendTransport = await createSendTransport(device, sessionId.value, {
onConnectSuccess: () => { webrtcState.value = 'connected' }, onConnectSuccess: () => { webrtcState.value = 'connected' },
onConnectFailure: () => { onConnectFailure: () => {
webrtcState.value = 'failed' webrtcState.value = 'failed'
@@ -208,31 +208,31 @@ async function startSharing() {
if (!videoTrack) { if (!videoTrack) {
throw new Error('No video track available') throw new Error('No video track available')
} }
producer.value = await sendTransport.value.produce({ track: videoTrack }) producer = await sendTransport.produce({ track: videoTrack })
// Monitor producer events // Monitor producer events
producer.value.on('transportclose', () => { producer.on('transportclose', () => {
logWarn('share-live: Producer transport closed', { logWarn('share-live: Producer transport closed', {
producerId: producer.value.id, producerId: producer.id,
producerPaused: producer.value.paused, producerPaused: producer.paused,
producerClosed: producer.value.closed, producerClosed: producer.closed,
}) })
}) })
producer.value.on('trackended', () => { producer.on('trackended', () => {
logWarn('share-live: Producer track ended', { logWarn('share-live: Producer track ended', {
producerId: producer.value.id, producerId: producer.id,
producerPaused: producer.value.paused, producerPaused: producer.paused,
producerClosed: producer.value.closed, producerClosed: producer.closed,
}) })
}) })
// Monitor transport state (mediasoup-client does not pass a parameter; read from transport.connectionState) // Monitor transport state (mediasoup-client does not pass a parameter; read from transport.connectionState)
sendTransport.value.on('connectionstatechange', () => { sendTransport.on('connectionstatechange', () => {
const state = sendTransport.value.connectionState const state = sendTransport.connectionState
if (state === 'connected') webrtcState.value = 'connected' if (state === 'connected') webrtcState.value = 'connected'
else if (state === 'failed' || state === 'disconnected' || state === 'closed') { else if (state === 'failed' || state === 'disconnected' || state === 'closed') {
logWarn('share-live: Send transport connection state changed', { logWarn('share-live: Send transport connection state changed', {
state, state,
transportId: sendTransport.value.id, transportId: sendTransport.id,
producerId: producer.value.id, producerId: producer.id,
}) })
if (state === 'failed') { if (state === 'failed') {
webrtcState.value = 'failed' webrtcState.value = 'failed'
@@ -241,25 +241,25 @@ async function startSharing() {
} }
}) })
// Monitor track state // Monitor track state
if (producer.value.track) { if (producer.track) {
producer.value.track.addEventListener('ended', () => { producer.track.addEventListener('ended', () => {
logWarn('share-live: Producer track ended', { logWarn('share-live: Producer track ended', {
producerId: producer.value.id, producerId: producer.id,
trackId: producer.value.track.id, trackId: producer.track.id,
trackReadyState: producer.value.track.readyState, trackReadyState: producer.track.readyState,
trackEnabled: producer.value.track.enabled, trackEnabled: producer.track.enabled,
trackMuted: producer.value.track.muted, trackMuted: producer.track.muted,
}) })
}) })
producer.value.track.addEventListener('mute', () => { producer.track.addEventListener('mute', () => {
logWarn('share-live: Producer track muted', { logWarn('share-live: Producer track muted', {
producerId: producer.value.id, producerId: producer.id,
trackId: producer.value.track.id, trackId: producer.track.id,
trackEnabled: producer.value.track.enabled, trackEnabled: producer.track.enabled,
trackMuted: producer.value.track.muted, trackMuted: producer.track.muted,
}) })
}) })
producer.value.track.addEventListener('unmute', () => {}) producer.track.addEventListener('unmute', () => {})
} }
webrtcState.value = 'connected' webrtcState.value = 'connected'
setStatus('WebRTC connected. Requesting location…') setStatus('WebRTC connected. Requesting location…')
@@ -273,7 +273,7 @@ async function startSharing() {
return return
} }
// 5. Get location (continuous) - also requires HTTPS on mobile Safari // 5. Get location (continuous) also requires HTTPS on mobile Safari
if (!navigator.geolocation) { if (!navigator.geolocation) {
setError('Geolocation not supported in this browser.') setError('Geolocation not supported in this browser.')
cleanup() cleanup()
@@ -281,7 +281,7 @@ async function startSharing() {
} }
try { try {
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
locationWatchId.value = navigator.geolocation.watchPosition( locationWatchId = navigator.geolocation.watchPosition(
(pos) => { (pos) => {
resolve(pos) resolve(pos)
}, },
@@ -332,9 +332,9 @@ async function startSharing() {
} }
catch (e) { catch (e) {
if (e?.statusCode === 404) { if (e?.statusCode === 404) {
if (locationIntervalId.value != null) { if (locationIntervalId != null) {
clearInterval(locationIntervalId.value) clearInterval(locationIntervalId)
locationIntervalId.value = null locationIntervalId = null
} }
sharing.value = false sharing.value = false
if (!locationUpdate404Logged) { if (!locationUpdate404Logged) {
@@ -350,7 +350,7 @@ async function startSharing() {
} }
await sendLocationUpdate() await sendLocationUpdate()
locationIntervalId.value = setInterval(sendLocationUpdate, 2000) locationIntervalId = setInterval(sendLocationUpdate, 2000)
} }
catch (e) { catch (e) {
starting.value = false starting.value = false
@@ -363,23 +363,23 @@ async function startSharing() {
} }
function cleanup() { function cleanup() {
if (locationWatchId.value != null && navigator.geolocation?.clearWatch) { if (locationWatchId != null && navigator.geolocation?.clearWatch) {
navigator.geolocation.clearWatch(locationWatchId.value) navigator.geolocation.clearWatch(locationWatchId)
} }
locationWatchId.value = null locationWatchId = null
if (locationIntervalId.value != null) { if (locationIntervalId != null) {
clearInterval(locationIntervalId.value) clearInterval(locationIntervalId)
} }
locationIntervalId.value = null locationIntervalId = null
if (producer.value) { if (producer) {
producer.value.close() producer.close()
producer.value = null producer = null
} }
if (sendTransport.value) { if (sendTransport) {
sendTransport.value.close() sendTransport.close()
sendTransport.value = null sendTransport = null
} }
device.value = null device = null
if (stream.value) { if (stream.value) {
stream.value.getTracks().forEach(t => t.stop()) stream.value.getTracks().forEach(t => t.stop())
stream.value = null stream.value = null
+2 -2
View File
@@ -1,4 +1,3 @@
/** Wraps $fetch to redirect to /login on 401 for same-origin requests. */
export default defineNuxtPlugin(() => { export default defineNuxtPlugin(() => {
const route = useRoute() const route = useRoute()
const baseFetch = globalThis.$fetch ?? $fetch const baseFetch = globalThis.$fetch ?? $fetch
@@ -7,7 +6,8 @@ export default defineNuxtPlugin(() => {
if (response?.status !== 401) return if (response?.status !== 401) return
const url = typeof request === 'string' ? request : request?.url ?? '' const url = typeof request === 'string' ? request : request?.url ?? ''
if (!url.startsWith('/')) return if (!url.startsWith('/')) return
navigateTo({ path: '/login', query: { redirect: route.fullPath || '/' } }, { replace: true }) const redirect = (route.fullPath && route.fullPath !== '/' ? route.fullPath : '/')
navigateTo({ path: '/login', query: { redirect } }, { replace: true })
}, },
}) })
}) })
-220
View File
@@ -1,220 +0,0 @@
import { syncFeatureMarkers } from './mapMarkerSync.js'
const ICON_SIZE = 28
const CONE_SIZE = 52
const ALPR_COLOR = '#ef4444'
const DEFAULT_FOV = 60
function escapeHtml(text) {
const div = document.createElement('div')
div.textContent = text
return div.innerHTML
}
function conePath(fov) {
const half = Math.min(Math.max(fov / 2, 10), 85)
const cx = 26
const cy = 26
const r = 24
const toRad = deg => ((deg - 90) * Math.PI) / 180
const x1 = cx + r * Math.cos(toRad(-half))
const y1 = cy + r * Math.sin(toRad(-half))
const x2 = cx + r * Math.cos(toRad(half))
const y2 = cy + r * Math.sin(toRad(half))
return `M ${cx} ${cy} L ${x1} ${y1} A ${r} ${r} 0 0 1 ${x2} ${y2} Z`
}
function compassLabel(deg) {
if (!Number.isFinite(deg)) return null
const dirs = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']
const idx = Math.round((((deg % 360) + 360) % 360) / 45) % 8
return dirs[idx]
}
function wikidataLink(label, qid) {
if (!qid) return escapeHtml(label)
const id = escapeHtml(qid)
return `<a href="https://www.wikidata.org/wiki/${id}" target="_blank" rel="noopener">${escapeHtml(label)}</a>`
}
function popupLine(parts) {
const line = parts.filter(Boolean).join(' · ')
return line ? `<div class="text-kestrel-muted text-xs mt-1">${line}</div>` : ''
}
function titleHtml(props) {
if (props.name) return escapeHtml(props.name)
if (props.model) {
const mfr = props.manufacturer ? escapeHtml(props.manufacturer) : null
return mfr ? `${mfr} <span class="text-kestrel-accent">${escapeHtml(props.model)}</span>` : escapeHtml(props.model)
}
const makeModel = [props.manufacturer, props.model].filter(Boolean).join(' ')
if (makeModel) {
if (props.manufacturerWikidata && !props.model) return wikidataLink(makeModel, props.manufacturerWikidata)
return escapeHtml(makeModel)
}
if (props.operator) return wikidataLink(props.operator, props.operatorWikidata)
if (props.ref) return escapeHtml(props.ref)
return 'ALPR camera'
}
function titleText(props) {
if (props.name) return props.name
if (props.model) {
return [props.manufacturer, props.model].filter(Boolean).join(' ')
}
const makeModel = [props.manufacturer, props.model].filter(Boolean).join(' ')
if (makeModel) return makeModel
if (props.operator) return props.operator
if (props.ref) return props.ref
return 'ALPR camera'
}
function modelLineHtml(props) {
if (props.model) {
return `<div class="text-sm mt-0.5"><span class="text-kestrel-muted">Model</span> <strong>${escapeHtml(props.model)}</strong></div>`
}
if (props.modelUnknown) {
return '<div class="text-kestrel-muted text-xs mt-0.5">Model not recorded in OpenStreetMap</div>'
}
return ''
}
/** Human-readable ALPR popup (GeoJSON properties in, HTML out). */
export function formatAlprPopup(props) {
const title = titleHtml(props)
const headline = titleText(props)
const makeModel = [props.manufacturer, props.model].filter(Boolean).join(' ')
const identity = []
if (props.name && makeModel) identity.push(escapeHtml(makeModel))
if (props.operator && headline !== props.operator) {
identity.push(wikidataLink(props.operator, props.operatorWikidata))
}
if (props.ref && headline !== props.ref) identity.push(escapeHtml(props.ref))
if (props.brand) identity.push(escapeHtml(props.brand))
const view = []
if (props.direction != null) {
const deg = Math.round(props.direction)
const compass = compassLabel(props.direction)
view.push(compass ? `Facing ${compass} (${deg}°)` : `Facing ${deg}°`)
}
if (props.fov != null && props.direction != null) view.push(`~${Math.round(props.fov)}° view`)
const note = props.description || props.note
const noteHtml = note ? `<div class="text-kestrel-muted text-xs mt-1">${escapeHtml(note)}</div>` : ''
const identityHtml = identity.length
? `<div class="text-kestrel-muted text-xs mt-0.5">${identity.join(' · ')}</div>`
: ''
const modelHtml = (props.model || props.modelUnknown) ? modelLineHtml(props) : ''
const foot = `<div class="text-kestrel-muted text-xs mt-2"><a href="https://www.openstreetmap.org/node/${props.osmId}" target="_blank" rel="noopener">View on OpenStreetMap</a></div>`
return `<div class="kestrel-live-popup"><strong>${title}</strong> <span class="text-kestrel-muted">License plate reader</span>${modelHtml}${identityHtml}${popupLine(view)}${noteHtml}${foot}</div>`
}
function popupHtml(props) {
return formatAlprPopup(props)
}
function pointIcon(L, direction, fov) {
const hasDirection = Number.isFinite(direction)
if (!hasDirection) {
const html = `<span class="poi-icon-svg"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${ALPR_COLOR}" stroke-width="2"><rect x="3" y="5" width="18" height="12" rx="2"/><circle cx="12" cy="11" r="3"/><path d="M8 21h8"/></svg></span>`
return L.divIcon({
className: 'poi-div-icon alpr-icon',
html,
iconSize: [ICON_SIZE, ICON_SIZE],
iconAnchor: [ICON_SIZE / 2, ICON_SIZE],
})
}
const spread = Number.isFinite(fov) ? fov : DEFAULT_FOV
const html = `<span class="alpr-cone" style="transform:rotate(${direction}deg)"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 52 52" width="${CONE_SIZE}" height="${CONE_SIZE}"><path d="${conePath(spread)}" fill="${ALPR_COLOR}" fill-opacity="0.3" stroke="${ALPR_COLOR}" stroke-width="1.5"/><circle cx="26" cy="26" r="3" fill="${ALPR_COLOR}"/></svg></span>`
return L.divIcon({
className: 'poi-div-icon alpr-icon',
html,
iconSize: [CONE_SIZE, CONE_SIZE],
iconAnchor: [CONE_SIZE / 2, CONE_SIZE / 2],
})
}
function clusterIcon(L, count) {
const size = count < 10 ? 28 : count < 100 ? 34 : 40
return L.divIcon({
className: 'alpr-cluster-icon',
html: `<span class="alpr-cluster">${count}</span>`,
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
})
}
export function createAlprControl(L, { showAlpr, onToggle }) {
const control = L.control({ position: 'topleft' })
control.onAdd = function () {
const el = document.createElement('button')
el.type = 'button'
el.className = 'leaflet-bar leaflet-control-alpr'
el.title = 'Toggle ALPR cameras (OSM)'
el.setAttribute('aria-label', 'Toggle ALPR cameras')
el.setAttribute('aria-pressed', showAlpr ? 'true' : 'false')
el.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><rect x="3" y="5" width="18" height="12" rx="2"/><circle cx="12" cy="11" r="3"/></svg>'
el.addEventListener('click', onToggle)
control._button = el
return el
}
return control
}
export function setAlprControlPressed(control, pressed) {
control?._button?.setAttribute('aria-pressed', pressed ? 'true' : 'false')
}
function featureKey(feature) {
const props = feature.properties ?? {}
if (props.cluster) return `c:${props.cluster_id}`
const id = props.osmId
return id != null ? `a:${id}` : null
}
function coords(feature) {
const [lng, lat] = feature.geometry.coordinates
return { lat, lng }
}
function attachClusterClick(marker, feature, map) {
marker.on('click', () => {
const { lat, lng } = coords(feature)
const props = feature.properties ?? {}
const zoom = props.expansionZoom ?? map.getZoom() + 2
map.setView([lat, lng], Math.min(zoom, 19), { animate: true })
})
}
export function createAlprLayer(L, map) {
return L.layerGroup().addTo(map)
}
export function syncAlprLayer(L, map, layer, features) {
syncFeatureMarkers(layer, features, {
keyFor: featureKey,
create: (feature) => {
const { lat, lng } = coords(feature)
const props = feature.properties ?? {}
const isCluster = Boolean(props.cluster)
const icon = isCluster ? clusterIcon(L, props.point_count) : pointIcon(L, props.direction, props.fov)
const marker = L.marker([lat, lng], { icon })
if (isCluster) attachClusterClick(marker, feature, map)
else marker.bindPopup(popupHtml(props), { className: 'kestrel-live-popup-wrap', maxWidth: 320 })
return marker
},
update: (marker, feature) => {
const { lat, lng } = coords(feature)
const props = feature.properties ?? {}
const isCluster = Boolean(props.cluster)
marker.setLatLng([lat, lng])
const icon = isCluster ? clusterIcon(L, props.point_count) : pointIcon(L, props.direction, props.fov)
marker.setIcon(icon)
if (!isCluster) marker.setPopupContent(popupHtml(props))
},
})
}
-77
View File
@@ -1,77 +0,0 @@
export const MAX_BBOX_DEGREES = 0.5
export function tileKey(row, col) {
return `${row},${col}`
}
export function bboxToTileKey(bbox) {
const row = Math.floor(bbox.south / MAX_BBOX_DEGREES)
const col = Math.floor(bbox.west / MAX_BBOX_DEGREES)
return tileKey(row, col)
}
function tileBox(row, col, step = MAX_BBOX_DEGREES) {
const south = row * step
const west = col * step
return { south, west, north: south + step, east: west + step }
}
export function bboxFetchKey(bounds) {
const zoom = bounds.zoom ?? 14
const step = zoom >= 14 ? 0.025 : zoom >= 11 ? 0.1 : zoom >= 8 ? 0.25 : 1
const q = v => Math.round(v / step) * step
return [q(bounds.south), q(bounds.west), q(bounds.north), q(bounds.east)].join(',')
}
function ringOffsets(radius) {
if (radius === 0) return [[0, 0]]
return Array.from({ length: 2 * radius + 1 }, (_, i) => i - radius)
.flatMap(dr => Array.from({ length: 2 * radius + 1 }, (_, j) => j - radius)
.filter(dc => Math.abs(dr) === radius || Math.abs(dc) === radius)
.map(dc => [dr, dc]))
}
function collectTiles(state) {
const {
centerRow, centerCol, minRow, maxRow, minCol, maxCol, step, limit, radius, seen, tiles,
} = state
if (tiles.length >= limit) return tiles
const inBounds = (row, col) => row >= minRow && row <= maxRow && col >= minCol && col <= maxCol
const { nextSeen, added } = ringOffsets(radius)
.map(([dr, dc]) => [centerRow + dr, centerCol + dc])
.filter(([row, col]) => inBounds(row, col))
.reduce((acc, [row, col]) => {
const key = `${row},${col}`
if (acc.nextSeen.has(key)) return acc
return {
nextSeen: new Set([...acc.nextSeen, key]),
added: [...acc.added, tileBox(row, col, step)],
}
}, { nextSeen: seen, added: [] })
if (added.length === 0 && radius > 0) return tiles
const nextTiles = [...tiles, ...added].slice(0, limit)
if (nextTiles.length >= limit) return nextTiles
return collectTiles({ ...state, radius: radius + 1, seen: nextSeen, tiles: nextTiles })
}
export function tilesNearCenter(bounds, limit) {
const step = MAX_BBOX_DEGREES
const lat = (bounds.south + bounds.north) / 2
const lng = (bounds.west + bounds.east) / 2
return collectTiles({
centerRow: Math.floor(lat / step),
centerCol: Math.floor(lng / step),
minRow: Math.floor(bounds.south / step),
maxRow: Math.ceil(bounds.north / step) - 1,
minCol: Math.floor(bounds.west / step),
maxCol: Math.ceil(bounds.east / step) - 1,
step,
limit,
radius: 0,
seen: new Set(),
tiles: [],
})
}
-131
View File
@@ -1,131 +0,0 @@
/** Map CoT / ADS-B entity display: icons and popups. */
export const COT_COLORS = {
air: '#60a5fa',
helicopter: '#fbbf24',
surface: '#38bdf8',
ground: '#f59e0b',
}
export function cotCategory(type) {
const t = typeof type === 'string' ? type : ''
if (t.startsWith('a-f-A-')) return 'air'
if (t.startsWith('a-f-S-')) return 'surface'
return 'ground'
}
/** Whether the entity is a helicopter or fixed-wing aircraft. @returns {'helicopter' | 'fixedWing'} */
export function cotAirIconKind(entity) {
const type = entity?.type ?? ''
if (type.endsWith('-C-H') || type.endsWith('-M-H')) return 'helicopter'
return 'fixedWing'
}
function iconWrap(heading, inner) {
const rotate = Number.isFinite(heading) ? ` style="transform:rotate(${heading}deg)"` : ''
return `<span class="poi-icon-svg cot-icon-rotatable"${rotate}>${inner}</span>`
}
const PLANE_SVG = color =>
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="${color}"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg>`
const HELI_SVG = color =>
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="1.75" stroke-linecap="round"><circle cx="12" cy="12" r="2.5" fill="${color}"/><path d="M3 8h18M3 12h18"/><path d="M12 8v8"/><path d="M9 16h6"/></svg>`
const SHIP_SVG = color =>
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2"><path d="M2 20c2-4 6-6 10-6s8 2 10 6"/><path d="M12 14V4"/><path d="m8 8 4-4 4 4"/></svg>`
const GROUND_SVG = color =>
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="8" r="2.5" fill="${color}"/></svg>`
export function getCotIconHtml(entity) {
const category = cotCategory(entity?.type)
const heading = Number(entity?.heading)
if (category === 'air') {
const kind = cotAirIconKind(entity)
const color = kind === 'helicopter' ? COT_COLORS.helicopter : COT_COLORS.air
const svg = kind === 'helicopter' ? HELI_SVG(color) : PLANE_SVG(color)
return { html: iconWrap(heading, svg), className: `cot-entity-${kind}` }
}
if (category === 'surface') {
return { html: iconWrap(heading, SHIP_SVG(COT_COLORS.surface)), className: 'cot-entity-surface' }
}
return { html: iconWrap(undefined, GROUND_SVG(COT_COLORS.ground)), className: 'cot-entity-ground' }
}
function msToKnots(ms) {
return Number.isFinite(ms) ? Math.round(ms * 1.94384) : null
}
function metersToFeet(m) {
return Number.isFinite(m) ? Math.round(m * 3.28084) : null
}
function fmtHeading(deg) {
return Number.isFinite(deg) ? `${Math.round(deg)}°` : null
}
function fmtVerticalFpm(ms) {
if (!Number.isFinite(ms) || ms === 0) return null
const fpm = Math.round(ms * 196.85)
return `${fpm > 0 ? '+' : ''}${fpm} fpm`
}
function icaoFromEntity(entity) {
if (entity?.icao) return String(entity.icao).toUpperCase()
if (typeof entity?.id === 'string' && entity.id.startsWith('ICAO.')) {
return entity.id.slice(5).toUpperCase()
}
return null
}
function mmsiFromEntity(entity) {
if (entity?.mmsi) return String(entity.mmsi)
if (typeof entity?.id === 'string' && entity.id.startsWith('MMSI.')) return entity.id.slice(5)
return null
}
function popupLine(escape, parts) {
const line = parts.filter(Boolean).join(' · ')
return line ? `<div class="text-kestrel-muted text-xs mt-1">${line}</div>` : ''
}
/**
* @param {Record<string, unknown>} entity
* @param {(s: string) => string} escape
*/
export function formatCotPopup(entity, escape) {
const category = cotCategory(entity?.type)
const label = escape(entity?.label || entity?.id || 'Unknown')
if (entity?.source === 'adsb' || category === 'air') {
const tag = cotAirIconKind(entity) === 'helicopter' ? 'Helicopter' : 'Aircraft'
const icao = icaoFromEntity(entity)
const meta = [
icao ? `ICAO ${icao}` : null,
entity?.originCountry ? escape(String(entity.originCountry)) : null,
].filter(Boolean).join(' · ')
const alt = metersToFeet(entity?.altitude)
const stats = [
alt != null ? `${alt.toLocaleString()} ft` : null,
entity?.onGround ? 'On ground' : null,
msToKnots(entity?.speed) != null ? `${msToKnots(entity.speed)} kt` : null,
fmtHeading(entity?.heading),
fmtVerticalFpm(entity?.verticalRate),
entity?.squawk ? `Squawk ${escape(String(entity.squawk))}` : null,
]
return `<div class="kestrel-live-popup"><strong>${label}</strong> <span class="text-kestrel-muted">${tag}</span>${meta ? `<div class="text-kestrel-muted text-xs mt-0.5">${meta}</div>` : ''}${popupLine(escape, stats)}</div>`
}
if (entity?.source === 'ais' || category === 'surface') {
const mmsi = mmsiFromEntity(entity)
const meta = mmsi ? `MMSI ${escape(mmsi)}` : ''
const stats = [
Number.isFinite(entity?.speed) ? `${Number(entity.speed).toFixed(1)} kt` : null,
fmtHeading(entity?.heading),
]
return `<div class="kestrel-live-popup"><strong>${label}</strong> <span class="text-kestrel-muted">Vessel</span>${meta ? `<div class="text-kestrel-muted text-xs mt-0.5">${meta}</div>` : ''}${popupLine(escape, stats)}</div>`
}
return `<div class="kestrel-live-popup"><strong>${label}</strong> <span class="text-kestrel-muted">Team</span></div>`
}
-107
View File
@@ -1,107 +0,0 @@
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))
}
},
})
}
+76 -18
View File
@@ -1,30 +1,88 @@
/** Client-side logger: sends to server, falls back to console. */ /**
const sessionId = ref(null) * Client-side logger that sends logs to server for debugging.
const userId = ref(null) * Falls back to console if server logging fails.
*/
const CONSOLE_METHOD = Object.freeze({ error: 'error', warn: 'warn', info: 'log', debug: 'log' }) let sessionId = null
let userId = null
/**
* Initialize logger with session/user context.
* @param {string} sessId
* @param {string} uid
*/
export function initLogger(sessId, uid) { export function initLogger(sessId, uid) {
sessionId.value = sessId sessionId = sessId
userId.value = uid userId = uid
} }
function sendToServer(level, message, data) { /**
setTimeout(() => { * Send log to server (non-blocking).
$fetch('/api/log', { * @param {string} level
* @param {string} message
* @param {object} data
*/
async function sendToServer(level, message, data) {
// Use setTimeout to avoid blocking - fire and forget
setTimeout(async () => {
try {
await $fetch('/api/log', {
method: 'POST', method: 'POST',
body: { level, message, data, sessionId: sessionId.value, userId: userId.value, timestamp: new Date().toISOString() }, body: {
level,
message,
data,
sessionId,
userId,
timestamp: new Date().toISOString(),
},
credentials: 'include', credentials: 'include',
}).catch(() => { /* server down - don't spam console */ }) }).catch(() => {
// Silently fail - don't spam console if server is down
})
}
catch {
// Ignore errors - logging shouldn't break the app
}
}, 0) }, 0)
} }
function log(level, message, data) { /**
console[CONSOLE_METHOD[level]](`[${message}]`, data) * Log at error level.
sendToServer(level, message, data) * @param {string} message
* @param {object} data
*/
export function logError(message, data) {
console.error(`[${message}]`, data)
sendToServer('error', message, data)
} }
export const logError = (message, data) => log('error', message, data) /**
export const logWarn = (message, data) => log('warn', message, data) * Log at warn level.
export const logInfo = (message, data) => log('info', message, data) * @param {string} message
export const logDebug = (message, data) => log('debug', message, data) * @param {object} data
*/
export function logWarn(message, data) {
console.warn(`[${message}]`, data)
sendToServer('warn', message, data)
}
/**
* Log at info level.
* @param {string} message
* @param {object} data
*/
export function logInfo(message, data) {
console.log(`[${message}]`, data)
sendToServer('info', message, data)
}
/**
* Log at debug level.
* @param {string} message
* @param {object} data
*/
export function logDebug(message, data) {
console.log(`[${message}]`, data)
sendToServer('debug', message, data)
}
-31
View File
@@ -1,31 +0,0 @@
import Supercluster from 'supercluster'
export function createClusterIndex(options = {}) {
const index = new Supercluster(options)
const state = { features: Object.freeze([]) }
return {
load(features) {
const list = Object.freeze([...(features ?? [])])
index.load(list)
state.features = list
},
query(view) {
if (!view || state.features.length === 0) return []
const { west, south, east, north, zoom } = view
return index.getClusters(
[west, south, east, north],
Math.floor(zoom ?? 14),
).map((feature) => {
if (!feature.properties?.cluster) return feature
return {
...feature,
properties: {
...feature.properties,
expansionZoom: index.getClusterExpansionZoom(feature.properties.cluster_id),
},
}
})
},
}
}
-37
View File
@@ -1,37 +0,0 @@
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()
}
-11
View File
@@ -1,11 +0,0 @@
# KestrelOS Documentation
Tactical Operations Center (TOC) for OSINT feeds: map view, cameras/devices, live sharing, and ATAK/iTAK integration.
## Quick Start
1. [Installation](installation.md) - npm, Docker, or Helm
2. [Authentication](auth.md) - First login (bootstrap admin or OIDC)
3. [Map and cameras](map-and-cameras.md) - Add devices and view streams
4. [ATAK and iTAK](atak-itak.md) - Connect TAK clients (port 8089)
5. [Share live](live-streaming.md) - Stream from mobile device (HTTPS required)
-80
View File
@@ -1,80 +0,0 @@
# ATAK and iTAK
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
**Port:** `8089` (CoT)
**SSL:** Enable if server uses TLS (`.dev-certs/` or production cert)
**Authentication:**
- **Username:** KestrelOS identifier
- **Password:** Login password (local) or ATAK password (OIDC; set in **Account**)
## ATAK (Android)
1. **Settings****Network****Connections** → Add **TAK Server**
2. Set **Host** and **Port** (`8089`)
3. Enable **Use Authentication**, enter username/password
4. Save and connect
## iTAK (iOS)
**Option A - QR code (easiest):**
1. KestrelOS **Settings****TAK Server** → Scan QR with iTAK
2. Enter username/password when prompted
**Option B - Manual:**
1. **Settings****Network** → Add **TAK Server**
2. Set **Host**, **Port** (`8089`), enable SSL if needed
3. Enable **Use Authentication**, enter username/password
4. Save and connect
## Self-Signed Certificate (iTAK)
If server uses self-signed cert (`.dev-certs/`):
**Upload server package:**
1. KestrelOS **Settings****TAK Server****Download server package (zip)**
2. Transfer to iPhone (AirDrop, email, Safari)
3. iTAK: **Settings****Network****Servers****+** → **Upload server package**
4. Enter username/password
**Or use plain TCP:**
1. Stop KestrelOS, remove `.dev-certs/`, restart
2. Add server with **SSL disabled**
**ATAK (Android):** Download trust store from `https://your-server/api/cot/truststore`, import `.p12` (password: `kestrelos`), or use server package/plain TCP.
## OIDC Users
OIDC users must set an **ATAK password** first:
1. Sign in with OIDC
2. **Account****ATAK / device password** → set password
3. Use KestrelOS username + ATAK password in TAK client
## Configuration
| Variable | Default | Description |
|----------|---------|-------------|
| `COT_PORT` | `8089` | CoT server port |
| `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 |
## Troubleshooting
**"Error authenticating" with no `[cot]` logs:**
- Connection not reaching server (TLS handshake failed or firewall blocking)
- Check server logs show `[cot] CoT server listening on 0.0.0.0:8089`
- Verify port `8089` (not `3000`) and firewall allows it
- For TLS: trust cert (server package) or use plain TCP
**"Error authenticating" with `[cot]` logs:**
- Username must be KestrelOS identifier
- Password must match (local: login password; OIDC: ATAK password)
**Devices not on map:** They appear only while sending updates; drop off after TTL (~90s).
-39
View File
@@ -1,39 +0,0 @@
# Authentication
KestrelOS supports **local login** (username/email + password) and optional **OIDC** (SSO). All users must sign in.
## Local Login
**First run:** On first start, KestrelOS creates an admin account:
- If `BOOTSTRAP_EMAIL` and `BOOTSTRAP_PASSWORD` are set → that account is created
- Otherwise → default admin (`admin`) with random password printed in terminal
**Sign in:** Open `/login`, enter identifier and password. Change password or add users via **Members** (admin only).
## OIDC (SSO)
**Enable:** Set `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`. Optional: `OIDC_LABEL`, `OIDC_REDIRECT_URI`, `OIDC_SCOPES`.
**IdP setup:**
1. Create OIDC client in your IdP (Keycloak, Auth0, etc.)
2. Set redirect URI: `https://<your-host>/api/auth/oidc/callback`
3. Copy Client ID and Secret to env vars
**Sign up:** Users sign up at the IdP. First OIDC login in KestrelOS creates their account automatically.
**Redirect URI:** Defaults to `{APP_URL}/api/auth/oidc/callback` (uses `NUXT_APP_URL`/`APP_URL` or falls back to `HOST`/`PORT`).
## OIDC Users and ATAK/iTAK
OIDC users don't have a KestrelOS password. To use ATAK/iTAK:
1. Sign in with OIDC
2. Go to **Account** → set **ATAK password**
3. Use KestrelOS username + ATAK password in TAK client
## Roles
- **Admin** - Manage users, edit POIs, add/edit devices (API)
- **Leader** - Edit POIs, add/edit devices (API)
- **Member** - View map/cameras/POIs, use Share live
Only admins can change roles (Members page).
-61
View File
@@ -1,61 +0,0 @@
# Installation
Run KestrelOS from source (npm), Docker, or Kubernetes (Helm).
## npm (from source)
```bash
git clone <repository-url> kestrelos
cd kestrelos
npm install
npm run dev
```
Open **http://localhost:3000**. First run creates `data/kestrelos.db` and bootstraps an admin (see [Authentication](auth.md)).
**Production:**
```bash
npm run build
npm run preview
# or
node .output/server/index.mjs
```
Set `HOST=0.0.0.0` and `PORT` for production.
## Docker
```bash
docker build -t kestrelos:latest .
docker run -p 3000:3000 -p 8089:8089 \
-v kestrelos-data:/app/data \
kestrelos:latest
```
Expose ports **3000** (web/API) and **8089** (CoT for ATAK/iTAK).
## Helm (Kubernetes)
**From registry:**
```bash
helm repo add keligrubb --username USER --password TOKEN \
https://git.keligrubb.com/api/packages/keligrubb/helm
helm install kestrelos keligrubb/kestrelos
```
**From source:**
```bash
helm install kestrelos ./helm/kestrelos
```
Configure in `helm/kestrelos/values.yaml`. Health: `GET /health`, `/health/live`, `/health/ready`.
## Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `HOST` | Nuxt default | Bind address (use `0.0.0.0` for all interfaces) |
| `PORT` | `3000` | Web/API port |
| `DB_PATH` | `data/kestrelos.db` | SQLite database path |
See [Authentication](auth.md) for auth variables. See [ATAK and iTAK](atak-itak.md) for CoT options.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 MiB

-44
View File
@@ -1,44 +0,0 @@
# Share Live
Stream your phone's camera and location to KestrelOS. Appears as a **live session** on the map and in **Cameras**. Uses **WebRTC** (Mediasoup) and requires **HTTPS** on mobile.
## Usage
1. Open **Share live** (sidebar → **Share live** or `/share-live`)
2. Tap **Start sharing**, allow camera/location permissions
3. Device appears on map and in **Cameras**
4. Tap **Stop sharing** to end
**Permissions:** Admin/leader can start sharing. All users can view live sessions.
## Requirements
- **HTTPS** (browsers require secure context for camera/geolocation)
- **Camera and location permissions**
- **WebRTC ports:** UDP/TCP `40000-49999` open on server
## Local Development
**Generate self-signed cert:**
```bash
chmod +x scripts/gen-dev-cert.sh
./scripts/gen-dev-cert.sh 192.168.1.123 # Your LAN IP
npm run dev
```
**On phone:** Open `https://192.168.1.123:3000`, accept cert warning, sign in, use Share live.
## WebRTC Configuration
- Server auto-detects LAN IP for WebRTC
- **Docker/multiple NICs:** Set `MEDIASOUP_ANNOUNCED_IP` to client-reachable IP/hostname
- **"Wrong host" error:** Use same URL on phone/server, or set `MEDIASOUP_ANNOUNCED_IP`
## Troubleshooting
| Issue | Fix |
|-------|-----|
| "HTTPS required" | Use `https://` (not `http://`) |
| "Media devices not available" | Ensure HTTPS and browser permissions |
| "WebRTC: failed" / "Wrong host" | Set `MEDIASOUP_ANNOUNCED_IP`, open firewall ports `40000-49999` |
| Stream not visible | Check server reachability and firewall |
-62
View File
@@ -1,62 +0,0 @@
# Map and Cameras
KestrelOS shows a **map** with devices, POIs, live sessions (Share live), and ATAK/iTAK positions. Click markers or use **Cameras** page to view streams.
## 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)
## Cameras
A **camera** is either:
1. A **device** - Fixed feed with stream URL
2. A **live session** - Mobile device streaming via Share live
View via map markers or **Cameras** page (sidebar).
## Device Types
| device_type | Use case |
|-------------|----------|
| `alpr`, `nvr`, `doorbell`, `feed`, `traffic`, `ip`, `drone` | Labeling/filtering |
**source_type:** `mjpeg` (MJPEG over HTTP) or `hls` (HLS `.m3u8` playlist)
Stream URLs must be `http://` or `https://`.
## API: Devices
**Create:** `POST /api/devices` (admin/leader)
```json
{
"name": "Main gate ALPR",
"device_type": "alpr",
"lat": 37.7749,
"lng": -122.4194,
"stream_url": "https://alpr.example.com/stream.m3u8",
"source_type": "hls"
}
```
**List:** `GET /api/devices`
**Update:** `PATCH /api/devices/:id`
**Delete:** `DELETE /api/devices/:id`
**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).
Binary file not shown.

Before

Width:  |  Height:  |  Size: 159 KiB

-48
View File
@@ -1,48 +0,0 @@
# 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 -2
View File
@@ -2,5 +2,5 @@ apiVersion: v2
name: kestrelos name: kestrelos
description: KestrelOS TOC for OSINT feeds - map, camera feeds, offline tiles description: KestrelOS TOC for OSINT feeds - map, camera feeds, offline tiles
type: application type: application
version: 1.1.9 version: 0.1.0
appVersion: "1.1.9" appVersion: "0.1.0"
+2 -2
View File
@@ -1,8 +1,8 @@
replicaCount: 1 replicaCount: 1
image: image:
repository: git.keligrubb.com/keligrubb/kestrelos repository: kestrelos
tag: 1.1.9 tag: latest
pullPolicy: IfNotPresent pullPolicy: IfNotPresent
service: service:
+1 -6
View File
@@ -27,19 +27,14 @@ export default defineNuxtConfig({
], ],
}, },
}, },
css: ['~/assets/css/main.css'],
runtimeConfig: { runtimeConfig: {
public: { public: {
version: pkg.version ?? '', version: pkg.version ?? '',
}, },
cotRequireAuth: true,
cotDebug: false,
aisstreamApiKey: process.env.AISSTREAM_API_KEY || '',
openskyClientId: process.env.OPENSKY_CLIENT_ID || '',
openskyClientSecret: process.env.OPENSKY_CLIENT_SECRET || '',
}, },
devServer: { devServer: {
host: '0.0.0.0', host: '0.0.0.0',
port: 3000,
...(useDevHttps ...(useDevHttps
? { https: { key: devKey, cert: devCert } } ? { https: { key: devKey, cert: devCert } }
: {}), : {}),
+4913 -5068
View File
File diff suppressed because it is too large Load Diff
+23 -27
View File
@@ -1,6 +1,5 @@
{ {
"name": "kestrelos", "name": "kestrelos",
"version": "1.1.9",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -10,42 +9,39 @@
"preview": "nuxt preview", "preview": "nuxt preview",
"postinstall": "nuxt prepare", "postinstall": "nuxt prepare",
"test": "vitest", "test": "vitest",
"test:integration": "vitest run --config vitest.integration.config.js",
"test:coverage": "vitest run --coverage", "test:coverage": "vitest run --coverage",
"test:e2e": "playwright test test/e2e", "test:e2e": "playwright test test/e2e",
"test:e2e:ui": "playwright test --ui test/e2e", "test:e2e:ui": "playwright test --ui test/e2e",
"test:e2e:debug": "playwright test --debug test/e2e", "test:e2e:debug": "playwright test --debug test/e2e",
"test:e2e:install": "playwright install --with-deps webkit chromium firefox", "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": { "dependencies": {
"@nuxt/icon": "^2.2.3", "@nuxt/icon": "^2.2.1",
"@nuxtjs/tailwindcss": "^6.14.0", "@nuxtjs/tailwindcss": "^6.14.0",
"fast-xml-parser": "^5.9.3", "hls.js": "^1.5.0",
"hls.js": "^1.6.16",
"jszip": "^3.10.1",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"leaflet.offline": "^3.2.1", "leaflet.offline": "^3.2.0",
"mediasoup": "^3.20.9", "mediasoup": "^3.19.14",
"mediasoup-client": "^3.21.0", "mediasoup-client": "^3.18.6",
"nuxt": "^4.4.8", "nuxt": "^4.0.0",
"openid-client": "^6.8.4", "openid-client": "^6.8.2",
"qrcode": "^1.5.4", "sqlite3": "^5.1.7",
"supercluster": "^9.0.0", "vue": "^3.4.0",
"vue": "^3.5.38", "vue-router": "^4.4.0",
"vue-router": "^5.1.0", "ws": "^8.18.0"
"ws": "^8.21.0"
}, },
"devDependencies": { "devDependencies": {
"@iconify-json/tabler": "^1.2.35", "@nuxt/eslint": "^1.15.0",
"@nuxt/eslint": "^1.16.0", "@nuxt/test-utils": "^4.0.0",
"@nuxt/test-utils": "^4.0.3", "@playwright/test": "^1.58.2",
"@playwright/test": "^1.61.1", "@vitest/coverage-v8": "^4.0.0",
"@vitest/coverage-v8": "^4.1.9", "@vue/test-utils": "^2.4.0",
"@vue/test-utils": "^2.4.11", "eslint": "^9.0.0",
"eslint": "^10.5.0", "happy-dom": "^20.6.1",
"happy-dom": "^20.10.6", "vitest": "^4.0.0"
"vitest": "^4.1.9" },
"overrides": {
"tar": "^7.5.7"
} }
} }
+4 -5
View File
@@ -35,7 +35,6 @@ export default defineConfig({
name: 'desktop-chrome', name: 'desktop-chrome',
use: { use: {
...devices['Desktop Chrome'], ...devices['Desktop Chrome'],
permissions: ['camera', 'microphone', 'geolocation'],
launchOptions: { launchOptions: {
args: [ args: [
'--use-fake-ui-for-media-stream', '--use-fake-ui-for-media-stream',
@@ -53,10 +52,10 @@ export default defineConfig({
], ],
webServer: { webServer: {
command: 'npm run dev', command: 'npm run dev',
url: 'https://localhost:3000/health/ready', url: 'https://localhost:3000/health',
reuseExistingServer: !process.env.CI, // Don't reuse in CI (always start fresh) reuseExistingServer: true, // Always reuse existing server for E2E tests
timeout: 180_000, // 3 minutes (180 seconds) for server startup (CI can be slower) timeout: 120 * 1000, // 2 minutes for server startup
ignoreHTTPSErrors: true, ignoreHTTPSErrors: true,
}, },
timeout: process.env.CI ? 180_000 : 60_000, // 3 minutes in CI, 1 minute locally (WebRTC setup takes time) timeout: 60 * 1000, // 60 seconds per test (WebRTC setup takes time)
}) })
-3
View File
@@ -1,3 +0,0 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
}
-17
View File
@@ -1,17 +0,0 @@
#!/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()
}
-89
View File
@@ -1,89 +0,0 @@
#!/bin/sh
set -e
# version
msg="${CI_COMMIT_MESSAGE:-}"
# optional PR body (written by workflow from Gitea API when this commit is a merged PR)
if [ -f .ci_pr_body ]; then
CI_PR_DESCRIPTION=$(cat .ci_pr_body); rm -f .ci_pr_body
else
CI_PR_DESCRIPTION=""
fi
export CI_PR_DESCRIPTION
bump=patch
# Conventional commits: chore/fix => patch, feat => minor
echo "$msg" | grep -Eqi '(^|[[:space:]])(fix|chore)(\([^)]*\))?:' && bump=patch
echo "$msg" | grep -Eqi '(^|[[:space:]])feat(\([^)]*\))?:' && bump=minor
# Conventional commits breaking change: type!:
echo "$msg" | grep -Eqi '(^|[[:space:]])[a-zA-Z]+(\([^)]*\))?!:' && bump=major
# Explicit bump prefixes still supported (but never downgrade a major bump)
echo "$msg" | grep -qi minor: && [ "$bump" != "major" ] && bump=minor
echo "$msg" | grep -qi major: && bump=major
cur=$(awk '/"version"/ { match($0, /[0-9]+\.[0-9]+\.[0-9]+/); print substr($0, RSTART, RLENGTH); exit }' package.json)
case "$cur" in
[0-9]*.[0-9]*.[0-9]*) ;;
*) echo "error: package.json version must be x.y.z (got: $cur)"; exit 1 ;;
esac
major=$(echo "$cur" | cut -d. -f1); minor=$(echo "$cur" | cut -d. -f2); patch=$(echo "$cur" | cut -d. -f3)
case "$bump" in major) major=$((major+1)); minor=0; patch=0 ;; minor) minor=$((minor+1)); patch=0 ;; patch) patch=$((patch+1)) ;; esac
newVersion="$major.$minor.$patch"
url="https://${CI_REPO_OWNER}:${GITEA_REPO_TOKEN}@${CI_FORGE_URL#https://}/${CI_REPO_OWNER}/${CI_REPO_NAME}.git"
if [ -n "$(git ls-remote "$url" "refs/tags/v$newVersion" 2>/dev/null)" ]; then
exit 0
fi
# changelog entry (strip explicit bump prefixes & any conventional-commit type(scope):); optional PR description enriches it
changelogEntry=$(
echo "$msg" \
| head -1 \
| sed -E 's/^[[:space:]]*[mM]ajor:[[:space:]]*//; s/^[[:space:]]*[mM]inor:[[:space:]]*//; s/^[[:space:]]*[pP]atch:[[:space:]]*//' \
| sed -E 's/^[[:space:]]*[a-zA-Z]+(\([^)]*\))?:[[:space:]]*//'
)
[ -z "$changelogEntry" ] && changelogEntry="Release v$newVersion"
if [ -n "$CI_PR_DESCRIPTION" ]; then
changelogFull="- $changelogEntry
$CI_PR_DESCRIPTION"
else
changelogFull="- $changelogEntry"
fi
# bump files
awk -v v="$newVersion" '/"version"/ { sub(/[0-9]+\.[0-9]+\.[0-9]+/, v) } { print }' package.json > package.json.tmp && mv package.json.tmp package.json
awk -v v="$newVersion" '/^version:/ { $0 = "version: " v }; /^appVersion:/ { $0 = "appVersion: \"" v "\"" }; { print }' helm/kestrelos/Chart.yaml > helm/kestrelos/Chart.yaml.tmp && mv helm/kestrelos/Chart.yaml.tmp helm/kestrelos/Chart.yaml
awk -v v="$newVersion" '/^ tag:/ { $0 = " tag: " v }; { print }' helm/kestrelos/values.yaml > helm/kestrelos/values.yaml.tmp && mv helm/kestrelos/values.yaml.tmp helm/kestrelos/values.yaml
# changelog
new="## [$newVersion] - $(date +%Y-%m-%d)
### Changed
$changelogFull
"
# Create CHANGELOG.md if missing (first release); otherwise prepend new entry to existing content.
{ [ ! -f CHANGELOG.md ] && printf '# Changelog\n\n'; printf '%s' "$new"; [ -f CHANGELOG.md ] && cat CHANGELOG.md || true; } > CHANGELOG.md.tmp && mv CHANGELOG.md.tmp CHANGELOG.md
# git
git config user.email "ci@kestrelos" && git config user.name "CI"
git add package.json helm/kestrelos/Chart.yaml helm/kestrelos/values.yaml CHANGELOG.md
git commit -m "release v$newVersion [skip ci]"
git tag "v$newVersion"
# artifact for docker (tag list)
printf '%s\n%s\n' "$newVersion" "latest" > .tags
retry() { n=0; while ! "$@"; do n=$((n+1)); [ $n -ge 3 ] && return 1; sleep 2; done; }
retry git push "$url" HEAD:main "v$newVersion"
# gitea release
body="## Changelog
### Changed
$changelogFull
## Installation
- [Docker image](${CI_FORGE_URL}/${CI_REPO_OWNER}/-/packages/container/${CI_REPO_NAME})
- [Helm chart](${CI_FORGE_URL}/${CI_REPO_OWNER}/-/packages/helm/${CI_REPO_NAME})"
release_url="${CI_FORGE_URL}/api/v1/repos/${CI_REPO_OWNER}/${CI_REPO_NAME}/releases"
echo "$body" | awk -v tag="v$newVersion" 'BEGIN{printf "{\"tag_name\":\"" tag "\",\"name\":\"" tag "\",\"body\":\""} { gsub(/\\/,"\\\\"); gsub(/"/,"\\\""); if (NR>1) printf "\\n"; printf "%s", $0 } END{printf "\"}\n"}' > /tmp/release.json
wget -q -O /dev/null --post-file=/tmp/release.json \
--header="Authorization: token ${GITEA_REPO_TOKEN}" \
--header="Content-Type: application/json" \
"$release_url"
-19
View File
@@ -1,19 +0,0 @@
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 -1
View File
@@ -1,3 +1,3 @@
import { getAuthConfig } from '../../utils/oidc.js' import { getAuthConfig } from '../../utils/authConfig.js'
export default defineEventHandler(() => getAuthConfig()) export default defineEventHandler(() => getAuthConfig())
+1 -5
View File
@@ -1,7 +1,7 @@
import { setCookie } from 'h3' import { setCookie } from 'h3'
import { getDb } from '../../utils/db.js' import { getDb } from '../../utils/db.js'
import { verifyPassword } from '../../utils/password.js' import { verifyPassword } from '../../utils/password.js'
import { getSessionMaxAgeDays } from '../../utils/constants.js' import { getSessionMaxAgeDays } from '../../utils/session.js'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const body = await readBody(event) const body = await readBody(event)
@@ -15,10 +15,6 @@ export default defineEventHandler(async (event) => {
if (!user || !user.password_hash || !verifyPassword(password, user.password_hash)) { if (!user || !user.password_hash || !verifyPassword(password, user.password_hash)) {
throw createError({ statusCode: 401, message: 'Invalid credentials' }) throw createError({ statusCode: 401, message: 'Invalid credentials' })
} }
// Invalidate all existing sessions for this user to prevent session fixation
await run('DELETE FROM sessions WHERE user_id = ?', [user.id])
const sessionDays = getSessionMaxAgeDays() const sessionDays = getSessionMaxAgeDays()
const sid = crypto.randomUUID() const sid = crypto.randomUUID()
const now = new Date() const now = new Date()
+1 -1
View File
@@ -1,5 +1,5 @@
import { getAuthConfig } from '../../../utils/authConfig.js'
import { import {
getAuthConfig,
getOidcConfig, getOidcConfig,
getOidcRedirectUri, getOidcRedirectUri,
createOidcParams, createOidcParams,
+1 -4
View File
@@ -6,7 +6,7 @@ import {
exchangeCode, exchangeCode,
} from '../../../utils/oidc.js' } from '../../../utils/oidc.js'
import { getDb } from '../../../utils/db.js' import { getDb } from '../../../utils/db.js'
import { getSessionMaxAgeDays } from '../../../utils/constants.js' import { getSessionMaxAgeDays } from '../../../utils/session.js'
const DEFAULT_ROLE = process.env.OIDC_DEFAULT_ROLE || 'member' const DEFAULT_ROLE = process.env.OIDC_DEFAULT_ROLE || 'member'
@@ -74,9 +74,6 @@ export default defineEventHandler(async (event) => {
user = await get('SELECT id, identifier, role FROM users WHERE id = ?', [id]) user = await get('SELECT id, identifier, role FROM users WHERE id = ?', [id])
} }
// Invalidate all existing sessions for this user to prevent session fixation
await run('DELETE FROM sessions WHERE user_id = ?', [user.id])
const sessionDays = getSessionMaxAgeDays() const sessionDays = getSessionMaxAgeDays()
const sid = crypto.randomUUID() const sid = crypto.randomUUID()
const now = new Date() const now = new Date()
+2 -5
View File
@@ -5,11 +5,8 @@ import { rowToDevice, sanitizeDeviceForResponse } from '../utils/deviceUtils.js'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
requireAuth(event) requireAuth(event)
const [db, sessions] = await Promise.all([ const [db, sessions] = await Promise.all([getDb(), getActiveSessions()])
getDb(),
getActiveSessions(),
])
const rows = await db.all('SELECT id, name, device_type, vendor, lat, lng, stream_url, source_type, config FROM devices ORDER BY id') 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) const devices = rows.map(r => rowToDevice(r)).filter(Boolean).map(sanitizeDeviceForResponse)
return { devices, liveSessions: sessions } return { devices, liveSessions: sessions }
}) })
-8
View File
@@ -1,8 +0,0 @@
import { getCotSslPaths, getCotPort } from '../../utils/cotSsl.js'
/** Public CoT server config for QR code / client setup (port and whether TLS is used). */
export default defineEventHandler(() => {
const config = useRuntimeConfig()
const paths = getCotSslPaths(config)
return { port: getCotPort(), ssl: Boolean(paths) }
})
-60
View File
@@ -1,60 +0,0 @@
import { existsSync } from 'node:fs'
import JSZip from 'jszip'
import { getCotSslPaths, getCotPort, TRUSTSTORE_PASSWORD, COT_TLS_REQUIRED_MESSAGE, buildP12FromCertPath } from '../../utils/cotSsl.js'
import { requireAuth } from '../../utils/authHelpers.js'
/**
* Build config.pref XML for iTAK: server connection + CA cert for trust (credentials entered in app).
* connectString format: host:port:ssl or host:port:tcp
*/
function buildConfigPref(hostname, port, ssl) {
const connectString = `${hostname}:${port}:${ssl ? 'ssl' : 'tcp'}`
return `<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
<preference-set id="com.atakmap.app_preferences">
<entry key="connectionEntry">1</entry>
<entry key="description">KestrelOS</entry>
<entry key="enabled">true</entry>
<entry key="connectString">${escapeXml(connectString)}</entry>
<entry key="caCertPath">cert/caCert.p12</entry>
<entry key="caCertPassword">${escapeXml(TRUSTSTORE_PASSWORD)}</entry>
<entry key="cacheCredentials">true</entry>
</preference-set>
`
}
function escapeXml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
export default defineEventHandler(async (event) => {
requireAuth(event)
const config = useRuntimeConfig()
const paths = getCotSslPaths(config)
if (!paths || !existsSync(paths.certPath)) {
setResponseStatus(event, 404)
return { error: `CoT server is not using TLS. Server package ${COT_TLS_REQUIRED_MESSAGE} Use the QR code and add the server with SSL disabled (plain TCP) instead.` }
}
const hostname = getRequestURL(event).hostname
const port = getCotPort()
try {
const p12 = buildP12FromCertPath(paths.certPath, TRUSTSTORE_PASSWORD)
const zip = new JSZip()
zip.file('config.pref', buildConfigPref(hostname, port, true))
zip.folder('cert').file('caCert.p12', p12)
const blob = await zip.generateAsync({ type: 'nodebuffer' })
setHeader(event, 'Content-Type', 'application/zip')
setHeader(event, 'Content-Disposition', 'attachment; filename="kestrelos-itak-server-package.zip"')
return blob
}
catch (err) {
setResponseStatus(event, 500)
return { error: 'Failed to build server package.', detail: err?.message }
}
})
-45
View File
@@ -1,45 +0,0 @@
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()
})
-24
View File
@@ -1,24 +0,0 @@
import { existsSync } from 'node:fs'
import { getCotSslPaths, TRUSTSTORE_PASSWORD, COT_TLS_REQUIRED_MESSAGE, buildP12FromCertPath } from '../../utils/cotSsl.js'
import { requireAuth } from '../../utils/authHelpers.js'
export default defineEventHandler((event) => {
requireAuth(event)
const config = useRuntimeConfig()
const paths = getCotSslPaths(config)
if (!paths || !existsSync(paths.certPath)) {
setResponseStatus(event, 404)
return { error: `CoT server is not using TLS or cert not found. Trust store ${COT_TLS_REQUIRED_MESSAGE}` }
}
try {
const p12 = buildP12FromCertPath(paths.certPath, TRUSTSTORE_PASSWORD)
setHeader(event, 'Content-Type', 'application/x-pkcs12')
setHeader(event, 'Content-Disposition', 'attachment; filename="kestrelos-cot-truststore.p12"')
return p12
}
catch (err) {
setResponseStatus(event, 500)
return { error: 'Failed to build trust store.', detail: err?.message }
}
})
+2 -4
View File
@@ -1,4 +1,4 @@
import { getDb, withTransaction } from '../utils/db.js' import { getDb } from '../utils/db.js'
import { requireAuth } from '../utils/authHelpers.js' import { requireAuth } from '../utils/authHelpers.js'
import { validateDeviceBody, rowToDevice, sanitizeDeviceForResponse } from '../utils/deviceUtils.js' import { validateDeviceBody, rowToDevice, sanitizeDeviceForResponse } from '../utils/deviceUtils.js'
@@ -7,8 +7,7 @@ export default defineEventHandler(async (event) => {
const body = await readBody(event).catch(() => ({})) const body = await readBody(event).catch(() => ({}))
const { name, device_type, vendor, lat, lng, stream_url, source_type, config } = validateDeviceBody(body) const { name, device_type, vendor, lat, lng, stream_url, source_type, config } = validateDeviceBody(body)
const id = crypto.randomUUID() const id = crypto.randomUUID()
const db = await getDb() const { run, get } = await getDb()
return withTransaction(db, async ({ run, get }) => {
await run( await run(
'INSERT INTO devices (id, name, device_type, vendor, lat, lng, stream_url, source_type, config) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', 'INSERT INTO devices (id, name, device_type, vendor, lat, lng, stream_url, source_type, config) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
[id, name, device_type, vendor, lat, lng, stream_url, source_type, config], [id, name, device_type, vendor, lat, lng, stream_url, source_type, config],
@@ -17,5 +16,4 @@ export default defineEventHandler(async (event) => {
const device = rowToDevice(row) const device = rowToDevice(row)
if (!device) throw createError({ statusCode: 500, message: 'Device not found after insert' }) if (!device) throw createError({ statusCode: 500, message: 'Device not found after insert' })
return sanitizeDeviceForResponse(device) return sanitizeDeviceForResponse(device)
})
}) })
+21 -15
View File
@@ -1,49 +1,55 @@
import { getDb } from '../../utils/db.js' import { getDb } from '../../utils/db.js'
import { requireAuth } from '../../utils/authHelpers.js' import { requireAuth } from '../../utils/authHelpers.js'
import { rowToDevice, sanitizeDeviceForResponse, DEVICE_TYPES, SOURCE_TYPES } from '../../utils/deviceUtils.js' import { rowToDevice, sanitizeDeviceForResponse, DEVICE_TYPES, SOURCE_TYPES } from '../../utils/deviceUtils.js'
import { buildUpdateQuery } from '../../utils/queryBuilder.js'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
requireAuth(event, { role: 'adminOrLeader' }) requireAuth(event, { role: 'adminOrLeader' })
const id = event.context.params?.id const id = event.context.params?.id
if (!id) throw createError({ statusCode: 400, message: 'id required' }) if (!id) throw createError({ statusCode: 400, message: 'id required' })
const body = (await readBody(event).catch(() => ({}))) || {} const body = (await readBody(event).catch(() => ({}))) || {}
const updates = {} const updates = []
const params = []
if (typeof body.name === 'string') { if (typeof body.name === 'string') {
updates.name = body.name.trim() updates.push('name = ?')
params.push(body.name.trim())
} }
if (DEVICE_TYPES.includes(body.device_type)) { if (DEVICE_TYPES.includes(body.device_type)) {
updates.device_type = body.device_type updates.push('device_type = ?')
params.push(body.device_type)
} }
if (body.vendor !== undefined) { if (body.vendor !== undefined) {
updates.vendor = typeof body.vendor === 'string' && body.vendor.trim() ? body.vendor.trim() : null updates.push('vendor = ?')
params.push(typeof body.vendor === 'string' && body.vendor.trim() ? body.vendor.trim() : null)
} }
if (Number.isFinite(body.lat)) { if (Number.isFinite(body.lat)) {
updates.lat = body.lat updates.push('lat = ?')
params.push(body.lat)
} }
if (Number.isFinite(body.lng)) { if (Number.isFinite(body.lng)) {
updates.lng = body.lng updates.push('lng = ?')
params.push(body.lng)
} }
if (typeof body.stream_url === 'string') { if (typeof body.stream_url === 'string') {
updates.stream_url = body.stream_url.trim() updates.push('stream_url = ?')
params.push(body.stream_url.trim())
} }
if (SOURCE_TYPES.includes(body.source_type)) { if (SOURCE_TYPES.includes(body.source_type)) {
updates.source_type = body.source_type updates.push('source_type = ?')
params.push(body.source_type)
} }
if (body.config !== undefined) { if (body.config !== undefined) {
updates.config = typeof body.config === 'string' ? body.config : (body.config != null ? JSON.stringify(body.config) : null) updates.push('config = ?')
params.push(typeof body.config === 'string' ? body.config : (body.config != null ? JSON.stringify(body.config) : null))
} }
const { run, get } = await getDb() const { run, get } = await getDb()
if (Object.keys(updates).length === 0) { if (updates.length === 0) {
const row = await get('SELECT id, name, device_type, vendor, lat, lng, stream_url, source_type, config FROM devices WHERE id = ?', [id]) const row = await get('SELECT id, name, device_type, vendor, lat, lng, stream_url, source_type, config FROM devices WHERE id = ?', [id])
if (!row) throw createError({ statusCode: 404, message: 'Device not found' }) if (!row) throw createError({ statusCode: 404, message: 'Device not found' })
const device = rowToDevice(row) const device = rowToDevice(row)
return device ? sanitizeDeviceForResponse(device) : row return device ? sanitizeDeviceForResponse(device) : row
} }
const { query, params } = buildUpdateQuery('devices', null, updates) params.push(id)
if (query) { await run(`UPDATE devices SET ${updates.join(', ')} WHERE id = ?`, params)
await run(query, [...params, id])
}
const row = await get('SELECT id, name, device_type, vendor, lat, lng, stream_url, source_type, config FROM devices WHERE id = ?', [id]) const row = await get('SELECT id, name, device_type, vendor, lat, lng, stream_url, source_type, config FROM devices WHERE id = ?', [id])
if (!row) throw createError({ statusCode: 404, message: 'Device not found' }) if (!row) throw createError({ statusCode: 404, message: 'Device not found' })
const device = rowToDevice(row) const device = rowToDevice(row)
+1 -4
View File
@@ -1,14 +1,12 @@
import { requireAuth } from '../../utils/authHelpers.js' import { requireAuth } from '../../utils/authHelpers.js'
import { getLiveSession, deleteLiveSession } from '../../utils/liveSessions.js' import { getLiveSession, deleteLiveSession } from '../../utils/liveSessions.js'
import { closeRouter, getProducer, getTransport } from '../../utils/mediasoup.js' import { closeRouter, getProducer, getTransport } from '../../utils/mediasoup.js'
import { acquire } from '../../utils/asyncLock.js'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const user = requireAuth(event) const user = requireAuth(event)
const id = event.context.params?.id const id = event.context.params?.id
if (!id) throw createError({ statusCode: 400, message: 'id required' }) if (!id) throw createError({ statusCode: 400, message: 'id required' })
return await acquire(`session-delete-${id}`, async () => {
const session = getLiveSession(id) const session = getLiveSession(id)
if (!session) throw createError({ statusCode: 404, message: 'Live session not found' }) if (!session) throw createError({ statusCode: 404, message: 'Live session not found' })
if (session.userId !== user.id) throw createError({ statusCode: 403, message: 'Forbidden' }) if (session.userId !== user.id) throw createError({ statusCode: 403, message: 'Forbidden' })
@@ -32,7 +30,6 @@ export default defineEventHandler(async (event) => {
// Clean up router // Clean up router
await closeRouter(id) await closeRouter(id)
await deleteLiveSession(id) deleteLiveSession(id)
return { ok: true } return { ok: true }
})
}) })
+7 -33
View File
@@ -1,44 +1,26 @@
import { requireAuth } from '../../utils/authHelpers.js' import { requireAuth } from '../../utils/authHelpers.js'
import { getLiveSession, updateLiveSession } from '../../utils/liveSessions.js' import { getLiveSession, updateLiveSession } from '../../utils/liveSessions.js'
import { acquire } from '../../utils/asyncLock.js'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const user = requireAuth(event) const user = requireAuth(event)
const id = event.context.params?.id const id = event.context.params?.id
if (!id) throw createError({ statusCode: 400, message: 'id required' }) if (!id) throw createError({ statusCode: 400, message: 'id required' })
const session = getLiveSession(id)
if (!session) throw createError({ statusCode: 404, message: 'Live session not found' })
if (session.userId !== user.id) throw createError({ statusCode: 403, message: 'Forbidden' })
const body = await readBody(event).catch(() => ({})) const body = await readBody(event).catch(() => ({}))
const lat = Number(body?.lat) const lat = Number(body?.lat)
const lng = Number(body?.lng) const lng = Number(body?.lng)
const updates = {} const updates = {}
if (Number.isFinite(lat)) updates.lat = lat if (Number.isFinite(lat)) updates.lat = lat
if (Number.isFinite(lng)) updates.lng = lng if (Number.isFinite(lng)) updates.lng = lng
if (Object.keys(updates).length === 0) { if (Object.keys(updates).length) {
// No updates, just return current session updateLiveSession(id, updates)
const session = getLiveSession(id)
if (!session) throw createError({ statusCode: 404, message: 'Live session not found' })
if (session.userId !== user.id) throw createError({ statusCode: 403, message: 'Forbidden' })
return {
id: session.id,
label: session.label,
lat: session.lat,
lng: session.lng,
updatedAt: session.updatedAt,
}
} }
// Use lock to atomically check and update session const updated = getLiveSession(id)
return await acquire(`session-patch-${id}`, async () => {
const session = getLiveSession(id)
if (!session) throw createError({ statusCode: 404, message: 'Live session not found' })
if (session.userId !== user.id) throw createError({ statusCode: 403, message: 'Forbidden' })
try {
const updated = await updateLiveSession(id, updates)
// Re-verify after update (updateLiveSession throws if session not found)
if (!updated || updated.userId !== user.id) {
throw createError({ statusCode: 404, message: 'Live session not found' })
}
return { return {
id: updated.id, id: updated.id,
label: updated.label, label: updated.label,
@@ -46,12 +28,4 @@ export default defineEventHandler(async (event) => {
lng: updated.lng, lng: updated.lng,
updatedAt: updated.updatedAt, updatedAt: updated.updatedAt,
} }
}
catch (err) {
if (err.message === 'Session not found') {
throw createError({ statusCode: 404, message: 'Live session not found' })
}
throw err
}
})
}) })
+7 -11
View File
@@ -1,22 +1,19 @@
import { requireAuth } from '../../utils/authHelpers.js' import { requireAuth } from '../../utils/authHelpers.js'
import { import {
getOrCreateSession, createSession,
getActiveSessionByUserId, getActiveSessionByUserId,
deleteLiveSession, deleteLiveSession,
} from '../../utils/liveSessions.js' } from '../../utils/liveSessions.js'
import { closeRouter, getProducer, getTransport } from '../../utils/mediasoup.js' import { closeRouter, getProducer, getTransport } from '../../utils/mediasoup.js'
import { acquire } from '../../utils/asyncLock.js'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const user = requireAuth(event, { role: 'adminOrLeader' }) const user = requireAuth(event, { role: 'adminOrLeader' })
const body = await readBody(event).catch(() => ({})) const body = await readBody(event).catch(() => ({}))
const label = typeof body?.label === 'string' ? body.label.trim().slice(0, 100) : '' const label = typeof body?.label === 'string' ? body.label.trim() : ''
// Atomically get or create session, replacing existing if needed // Replace any existing live session for this user (one session per user)
return await acquire(`session-start-${user.id}`, async () => { const existing = getActiveSessionByUserId(user.id)
const existing = await getActiveSessionByUserId(user.id)
if (existing) { if (existing) {
// Clean up existing session resources
if (existing.producerId) { if (existing.producerId) {
const producer = getProducer(existing.producerId) const producer = getProducer(existing.producerId)
if (producer) producer.close() if (producer) producer.close()
@@ -30,15 +27,14 @@ export default defineEventHandler(async (event) => {
console.error('[live.start] Error closing previous router:', err) console.error('[live.start] Error closing previous router:', err)
}) })
} }
await deleteLiveSession(existing.id) deleteLiveSession(existing.id)
console.log('[live.start] Replaced previous session:', existing.id) console.log('[live.start] Replaced previous session:', existing.id)
} }
const session = await getOrCreateSession(user.id, label || `Live: ${user.identifier || 'User'}`) const session = createSession(user.id, label || `Live: ${user.identifier || 'User'}`)
console.log('[live.start] Session ready:', { id: session.id, userId: user.id, label: session.label }) console.log('[live.start] Session created:', { id: session.id, userId: user.id, label: session.label })
return { return {
id: session.id, id: session.id,
label: session.label, label: session.label,
} }
})
}) })
@@ -3,7 +3,7 @@ import { getLiveSession } from '../../../utils/liveSessions.js'
import { getTransport } from '../../../utils/mediasoup.js' import { getTransport } from '../../../utils/mediasoup.js'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const user = requireAuth(event) // Verify authentication requireAuth(event) // Verify authentication
const body = await readBody(event).catch(() => ({})) const body = await readBody(event).catch(() => ({}))
const { sessionId, transportId, dtlsParameters } = body const { sessionId, transportId, dtlsParameters } = body
@@ -15,12 +15,8 @@ export default defineEventHandler(async (event) => {
if (!session) { if (!session) {
throw createError({ statusCode: 404, message: 'Session not found' }) throw createError({ statusCode: 404, message: 'Session not found' })
} }
// Note: Both publisher and viewers can connect their own transports
// Verify user has permission to connect transport for this session // The transportId ensures they can only connect transports they created
// Only session owner or admin/leader can connect transports
if (session.userId !== user.id && user.role !== 'admin' && user.role !== 'leader') {
throw createError({ statusCode: 403, message: 'Forbidden' })
}
const transport = getTransport(transportId) const transport = getTransport(transportId)
if (!transport) { if (!transport) {
@@ -3,7 +3,7 @@ import { getLiveSession } from '../../../utils/liveSessions.js'
import { getRouter, getTransport, getProducer, createConsumer } from '../../../utils/mediasoup.js' import { getRouter, getTransport, getProducer, createConsumer } from '../../../utils/mediasoup.js'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const user = requireAuth(event) // Verify authentication requireAuth(event) // Verify authentication
const body = await readBody(event).catch(() => ({})) const body = await readBody(event).catch(() => ({}))
const { sessionId, transportId, rtpCapabilities } = body const { sessionId, transportId, rtpCapabilities } = body
@@ -15,12 +15,6 @@ export default defineEventHandler(async (event) => {
if (!session) { if (!session) {
throw createError({ statusCode: 404, message: `Session not found: ${sessionId}` }) throw createError({ statusCode: 404, message: `Session not found: ${sessionId}` })
} }
// Authorization check: only session owner or admin/leader can consume
if (session.userId !== user.id && user.role !== 'admin' && user.role !== 'leader') {
throw createError({ statusCode: 403, message: 'Forbidden' })
}
if (!session.producerId) { if (!session.producerId) {
throw createError({ statusCode: 404, message: 'No producer available for this session' }) throw createError({ statusCode: 404, message: 'No producer available for this session' })
} }
+3 -19
View File
@@ -1,7 +1,6 @@
import { requireAuth } from '../../../utils/authHelpers.js' import { requireAuth } from '../../../utils/authHelpers.js'
import { getLiveSession, updateLiveSession } from '../../../utils/liveSessions.js' import { getLiveSession, updateLiveSession } from '../../../utils/liveSessions.js'
import { getTransport, producers } from '../../../utils/mediasoup.js' import { getTransport, producers } from '../../../utils/mediasoup.js'
import { acquire } from '../../../utils/asyncLock.js'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const user = requireAuth(event) const user = requireAuth(event)
@@ -12,7 +11,6 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 400, message: 'sessionId, transportId, kind, and rtpParameters required' }) throw createError({ statusCode: 400, message: 'sessionId, transportId, kind, and rtpParameters required' })
} }
return await acquire(`create-producer-${sessionId}`, async () => {
const session = getLiveSession(sessionId) const session = getLiveSession(sessionId)
if (!session) { if (!session) {
throw createError({ statusCode: 404, message: 'Session not found' }) throw createError({ statusCode: 404, message: 'Session not found' })
@@ -28,32 +26,18 @@ export default defineEventHandler(async (event) => {
const producer = await transport.produce({ kind, rtpParameters }) const producer = await transport.produce({ kind, rtpParameters })
producers.set(producer.id, producer) producers.set(producer.id, producer)
producer.on('close', async () => { producer.on('close', () => {
producers.delete(producer.id) producers.delete(producer.id)
const s = getLiveSession(sessionId) const s = getLiveSession(sessionId)
if (s && s.producerId === producer.id) { if (s && s.producerId === producer.id) {
try { updateLiveSession(sessionId, { producerId: null })
await updateLiveSession(sessionId, { producerId: null })
}
catch {
// Ignore errors during cleanup
}
} }
}) })
try { updateLiveSession(sessionId, { producerId: producer.id })
await updateLiveSession(sessionId, { producerId: producer.id })
}
catch (err) {
if (err.message === 'Session not found') {
throw createError({ statusCode: 404, message: 'Session not found' })
}
throw err
}
return { return {
id: producer.id, id: producer.id,
kind: producer.kind, kind: producer.kind,
} }
})
}) })
@@ -2,7 +2,6 @@ import { getRequestURL } from 'h3'
import { requireAuth } from '../../../utils/authHelpers.js' import { requireAuth } from '../../../utils/authHelpers.js'
import { getLiveSession, updateLiveSession } from '../../../utils/liveSessions.js' import { getLiveSession, updateLiveSession } from '../../../utils/liveSessions.js'
import { getRouter, createTransport } from '../../../utils/mediasoup.js' import { getRouter, createTransport } from '../../../utils/mediasoup.js'
import { acquire } from '../../../utils/asyncLock.js'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const user = requireAuth(event) const user = requireAuth(event)
@@ -13,7 +12,6 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 400, message: 'sessionId required' }) throw createError({ statusCode: 400, message: 'sessionId required' })
} }
return await acquire(`create-transport-${sessionId}`, async () => {
const session = getLiveSession(sessionId) const session = getLiveSession(sessionId)
if (!session) { if (!session) {
throw createError({ statusCode: 404, message: 'Session not found' }) throw createError({ statusCode: 404, message: 'Session not found' })
@@ -28,23 +26,14 @@ export default defineEventHandler(async (event) => {
const url = getRequestURL(event) const url = getRequestURL(event)
const requestHost = url.hostname const requestHost = url.hostname
const router = await getRouter(sessionId) const router = await getRouter(sessionId)
const { transport, params } = await createTransport(router, requestHost) const { transport, params } = await createTransport(router, Boolean(isProducer), requestHost)
if (isProducer) { if (isProducer) {
try { updateLiveSession(sessionId, {
await updateLiveSession(sessionId, {
transportId: transport.id, transportId: transport.id,
routerId: router.id, routerId: router.id,
}) })
} }
catch (err) {
if (err.message === 'Session not found') {
throw createError({ statusCode: 404, message: 'Session not found' })
}
throw err
}
}
return params return params
})
}) })
@@ -3,7 +3,7 @@ import { getLiveSession } from '../../../utils/liveSessions.js'
import { getRouter } from '../../../utils/mediasoup.js' import { getRouter } from '../../../utils/mediasoup.js'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const user = requireAuth(event) requireAuth(event)
const sessionId = getQuery(event).sessionId const sessionId = getQuery(event).sessionId
if (!sessionId) { if (!sessionId) {
@@ -15,11 +15,6 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 404, message: 'Session not found' }) throw createError({ statusCode: 404, message: 'Session not found' })
} }
// Only session owner or admin/leader can access
if (session.userId !== user.id && user.role !== 'admin' && user.role !== 'leader') {
throw createError({ statusCode: 403, message: 'Forbidden' })
}
const router = await getRouter(sessionId) const router = await getRouter(sessionId)
return router.rtpCapabilities return router.rtpCapabilities
}) })
+27 -6
View File
@@ -1,11 +1,32 @@
const CONSOLE_METHOD = Object.freeze({ error: 'error', warn: 'warn', info: 'log', debug: 'log' }) /**
* Client-side logging endpoint.
* Accepts log messages from the browser and outputs them server-side.
*/
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
// Note: Auth is optional - we rely on session cookie validation if needed
const body = await readBody(event).catch(() => ({})) const body = await readBody(event).catch(() => ({}))
const { level, message, data, sessionId, userId } = body const { level, message, data, sessionId, userId } = body
const prefix = `[CLIENT${sessionId ? `:${sessionId}` : ''}${userId ? `:${userId.slice(0, 8)}` : ''}]`
const msg = data ? `${message} ${JSON.stringify(data)}` : message const logPrefix = `[CLIENT${sessionId ? `:${sessionId}` : ''}${userId ? `:${userId.slice(0, 8)}` : ''}]`
const method = CONSOLE_METHOD[level] || 'log' const logMessage = data ? `${message} ${JSON.stringify(data)}` : message
console[method](prefix, msg)
switch (level) {
case 'error':
console.error(logPrefix, logMessage)
break
case 'warn':
console.warn(logPrefix, logMessage)
break
case 'info':
console.log(logPrefix, logMessage)
break
case 'debug':
console.log(logPrefix, logMessage)
break
default:
console.log(logPrefix, logMessage)
}
return { ok: true } return { ok: true }
}) })
+1 -7
View File
@@ -1,11 +1,5 @@
export default defineEventHandler((event) => { export default defineEventHandler((event) => {
const user = event.context.user const user = event.context.user
if (!user) throw createError({ statusCode: 401, message: 'Unauthorized' }) if (!user) throw createError({ statusCode: 401, message: 'Unauthorized' })
return { return { id: user.id, identifier: user.identifier, role: user.role, auth_provider: user.auth_provider ?? 'local' }
id: user.id,
identifier: user.identifier,
role: user.role,
auth_provider: user.auth_provider ?? 'local',
avatar_url: user.avatar_path ? '/api/me/avatar' : null,
}
}) })
-21
View File
@@ -1,21 +0,0 @@
import { unlink } from 'node:fs/promises'
import { join } from 'node:path'
import { getDb, getAvatarsDir } from '../../utils/db.js'
import { requireAuth } from '../../utils/authHelpers.js'
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
if (!user.avatar_path) return { ok: true }
// Validate avatar path to prevent path traversal attacks
const filename = user.avatar_path
if (!filename || !/^[a-f0-9-]+\.(?:jpg|jpeg|png)$/i.test(filename)) {
throw createError({ statusCode: 400, message: 'Invalid avatar path' })
}
const path = join(getAvatarsDir(), filename)
await unlink(path).catch(() => {})
const { run } = await getDb()
await run('UPDATE users SET avatar_path = NULL WHERE id = ?', [user.id])
return { ok: true }
})
-30
View File
@@ -1,30 +0,0 @@
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { getAvatarsDir } from '../../utils/db.js'
import { requireAuth } from '../../utils/authHelpers.js'
const MIME = Object.freeze({ jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png' })
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
if (!user.avatar_path) throw createError({ statusCode: 404, message: 'No avatar' })
// Validate avatar path to prevent path traversal attacks
const filename = user.avatar_path
if (!filename || !/^[a-f0-9-]+\.(?:jpg|jpeg|png)$/i.test(filename)) {
throw createError({ statusCode: 400, message: 'Invalid avatar path' })
}
const path = join(getAvatarsDir(), filename)
const ext = filename.split('.').pop()?.toLowerCase()
const mime = MIME[ext] ?? 'application/octet-stream'
try {
const buf = await readFile(path)
setResponseHeader(event, 'Content-Type', mime)
setResponseHeader(event, 'Cache-Control', 'private, max-age=3600')
return buf
}
catch {
throw createError({ statusCode: 404, message: 'Avatar not found' })
}
})
-57
View File
@@ -1,57 +0,0 @@
import { writeFile, unlink } from 'node:fs/promises'
import { join } from 'node:path'
import { readMultipartFormData } from 'h3'
import { getDb, getAvatarsDir } from '../../utils/db.js'
import { requireAuth } from '../../utils/authHelpers.js'
const MAX_SIZE = 2 * 1024 * 1024
const ALLOWED_TYPES = Object.freeze(['image/jpeg', 'image/png'])
const EXT_BY_MIME = Object.freeze({ 'image/jpeg': 'jpg', 'image/png': 'png' })
/**
* Validate image content using magic bytes to prevent MIME type spoofing.
* @param {Buffer} buffer - File data buffer
* @returns {string|null} Detected MIME type or null if invalid
*/
function validateImageContent(buffer) {
if (!buffer || buffer.length < 8) return null
// JPEG: FF D8 FF
if (buffer[0] === 0xFF && buffer[1] === 0xD8 && buffer[2] === 0xFF) {
return 'image/jpeg'
}
// PNG: 89 50 4E 47 0D 0A 1A 0A
if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4E && buffer[3] === 0x47) {
return 'image/png'
}
return null
}
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
const form = await readMultipartFormData(event)
const file = form?.find(f => f.name === 'avatar' && f.data)
if (!file || !file.filename) throw createError({ statusCode: 400, message: 'Missing avatar file' })
if (file.data.length > MAX_SIZE) throw createError({ statusCode: 400, message: 'File too large' })
const mime = file.type ?? ''
if (!ALLOWED_TYPES.includes(mime)) throw createError({ statusCode: 400, message: 'Invalid type; use JPEG or PNG' })
// Validate file content matches declared MIME type
const actualMime = validateImageContent(file.data)
if (!actualMime || actualMime !== mime) {
throw createError({ statusCode: 400, message: 'File content does not match declared type' })
}
const ext = EXT_BY_MIME[actualMime] ?? 'jpg'
const filename = `${user.id}.${ext}`
const dir = getAvatarsDir()
const path = join(dir, filename)
await writeFile(path, file.data)
const { run } = await getDb()
const previous = user.avatar_path
await run('UPDATE users SET avatar_path = ? WHERE id = ?', [filename, user.id])
if (previous && previous !== filename) {
const oldPath = join(dir, previous)
await unlink(oldPath).catch(() => {})
}
return { ok: true }
})
-26
View File
@@ -1,26 +0,0 @@
import { getDb } from '../../utils/db.js'
import { requireAuth } from '../../utils/authHelpers.js'
import { hashPassword } from '../../utils/password.js'
export default defineEventHandler(async (event) => {
const currentUser = requireAuth(event)
const body = await readBody(event).catch(() => ({}))
const password = body?.password
if (typeof password !== 'string' || password.length < 1) {
throw createError({ statusCode: 400, message: 'Password is required' })
}
const { get, run } = await getDb()
const user = await get(
'SELECT id, auth_provider FROM users WHERE id = ?',
[currentUser.id],
)
if (!user) {
throw createError({ statusCode: 404, message: 'User not found' })
}
const hash = hashPassword(password)
await run('UPDATE users SET cot_password_hash = ? WHERE id = ?', [hash, currentUser.id])
return { ok: true }
})
+6 -3
View File
@@ -1,15 +1,18 @@
import { getDb } from '../utils/db.js' import { getDb } from '../utils/db.js'
import { requireAuth } from '../utils/authHelpers.js' import { requireAuth } from '../utils/authHelpers.js'
import { POI_ICON_TYPES } from '../utils/validation.js'
const ICON_TYPES = ['pin', 'flag', 'waypoint']
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
requireAuth(event, { role: 'adminOrLeader' }) requireAuth(event, { role: 'adminOrLeader' })
const body = await readBody(event) const body = await readBody(event)
const lat = Number(body?.lat) const lat = Number(body?.lat)
const lng = Number(body?.lng) const lng = Number(body?.lng)
if (!Number.isFinite(lat) || !Number.isFinite(lng)) throw createError({ statusCode: 400, message: 'lat and lng required as numbers' }) if (!Number.isFinite(lat) || !Number.isFinite(lng)) {
throw createError({ statusCode: 400, message: 'lat and lng required as numbers' })
}
const label = typeof body?.label === 'string' ? body.label.trim() : '' const label = typeof body?.label === 'string' ? body.label.trim() : ''
const iconType = POI_ICON_TYPES.includes(body?.iconType) ? body.iconType : 'pin' const iconType = ICON_TYPES.includes(body?.iconType) ? body.iconType : 'pin'
const id = crypto.randomUUID() const id = crypto.randomUUID()
const { run } = await getDb() const { run } = await getDb()
await run( await run(
+17 -14
View File
@@ -1,37 +1,40 @@
import { getDb } from '../../utils/db.js' import { getDb } from '../../utils/db.js'
import { requireAuth } from '../../utils/authHelpers.js' import { requireAuth } from '../../utils/authHelpers.js'
import { POI_ICON_TYPES } from '../../utils/validation.js'
import { buildUpdateQuery } from '../../utils/queryBuilder.js' const ICON_TYPES = ['pin', 'flag', 'waypoint']
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
requireAuth(event, { role: 'adminOrLeader' }) requireAuth(event, { role: 'adminOrLeader' })
const id = event.context.params?.id const id = event.context.params?.id
if (!id) throw createError({ statusCode: 400, message: 'id required' }) if (!id) throw createError({ statusCode: 400, message: 'id required' })
const body = (await readBody(event)) || {} const body = await readBody(event) || {}
const updates = {} const updates = []
const params = []
if (typeof body.label === 'string') { if (typeof body.label === 'string') {
updates.label = body.label.trim() updates.push('label = ?')
params.push(body.label.trim())
} }
if (POI_ICON_TYPES.includes(body.iconType)) { if (ICON_TYPES.includes(body.iconType)) {
updates.icon_type = body.iconType updates.push('icon_type = ?')
params.push(body.iconType)
} }
if (Number.isFinite(body.lat)) { if (Number.isFinite(body.lat)) {
updates.lat = body.lat updates.push('lat = ?')
params.push(body.lat)
} }
if (Number.isFinite(body.lng)) { if (Number.isFinite(body.lng)) {
updates.lng = body.lng updates.push('lng = ?')
params.push(body.lng)
} }
if (Object.keys(updates).length === 0) { if (updates.length === 0) {
const { get } = await getDb() const { get } = await getDb()
const row = await get('SELECT id, lat, lng, label, icon_type FROM pois WHERE id = ?', [id]) const row = await get('SELECT id, lat, lng, label, icon_type FROM pois WHERE id = ?', [id])
if (!row) throw createError({ statusCode: 404, message: 'POI not found' }) if (!row) throw createError({ statusCode: 404, message: 'POI not found' })
return row return row
} }
params.push(id)
const { run, get } = await getDb() const { run, get } = await getDb()
const { query, params } = buildUpdateQuery('pois', null, updates) await run(`UPDATE pois SET ${updates.join(', ')} WHERE id = ?`, params)
if (query) {
await run(query, [...params, id])
}
const row = await get('SELECT id, lat, lng, label, icon_type FROM pois WHERE id = ?', [id]) const row = await get('SELECT id, lat, lng, label, icon_type FROM pois WHERE id = ?', [id])
if (!row) throw createError({ statusCode: 404, message: 'POI not found' }) if (!row) throw createError({ statusCode: 404, message: 'POI not found' })
return row return row
+2 -4
View File
@@ -1,4 +1,4 @@
import { getDb, withTransaction } from '../utils/db.js' import { getDb } from '../utils/db.js'
import { requireAuth } from '../utils/authHelpers.js' import { requireAuth } from '../utils/authHelpers.js'
import { hashPassword } from '../utils/password.js' import { hashPassword } from '../utils/password.js'
@@ -21,8 +21,7 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 400, message: 'role must be admin, leader, or member' }) throw createError({ statusCode: 400, message: 'role must be admin, leader, or member' })
} }
const db = await getDb() const { run, get } = await getDb()
return withTransaction(db, async ({ run, get }) => {
const existing = await get('SELECT id FROM users WHERE identifier = ?', [identifier]) const existing = await get('SELECT id FROM users WHERE identifier = ?', [identifier])
if (existing) { if (existing) {
throw createError({ statusCode: 409, message: 'Identifier already in use' }) throw createError({ statusCode: 409, message: 'Identifier already in use' })
@@ -36,5 +35,4 @@ export default defineEventHandler(async (event) => {
) )
const user = await get('SELECT id, identifier, role, auth_provider FROM users WHERE id = ?', [id]) const user = await get('SELECT id, identifier, role, auth_provider FROM users WHERE id = ?', [id])
return user return user
})
}) })
+13 -14
View File
@@ -1,7 +1,6 @@
import { getDb, withTransaction } from '../../utils/db.js' import { getDb } from '../../utils/db.js'
import { requireAuth } from '../../utils/authHelpers.js' import { requireAuth } from '../../utils/authHelpers.js'
import { hashPassword } from '../../utils/password.js' import { hashPassword } from '../../utils/password.js'
import { buildUpdateQuery } from '../../utils/queryBuilder.js'
const ROLES = ['admin', 'leader', 'member'] const ROLES = ['admin', 'leader', 'member']
@@ -10,20 +9,21 @@ export default defineEventHandler(async (event) => {
const id = event.context.params?.id const id = event.context.params?.id
if (!id) throw createError({ statusCode: 400, message: 'id required' }) if (!id) throw createError({ statusCode: 400, message: 'id required' })
const body = await readBody(event) const body = await readBody(event)
const db = await getDb() const { run, get } = await getDb()
return withTransaction(db, async ({ run, get }) => {
const user = await get('SELECT id, identifier, role, auth_provider, password_hash FROM users WHERE id = ?', [id]) const user = await get('SELECT id, identifier, role, auth_provider, password_hash FROM users WHERE id = ?', [id])
if (!user) throw createError({ statusCode: 404, message: 'User not found' }) if (!user) throw createError({ statusCode: 404, message: 'User not found' })
const updates = {} const updates = []
const params = []
if (body?.role !== undefined) { if (body?.role !== undefined) {
const role = body.role const role = body.role
if (!role || !ROLES.includes(role)) { if (!role || !ROLES.includes(role)) {
throw createError({ statusCode: 400, message: 'role must be admin, leader, or member' }) throw createError({ statusCode: 400, message: 'role must be admin, leader, or member' })
} }
updates.role = role updates.push('role = ?')
params.push(role)
} }
if (user.auth_provider === 'local') { if (user.auth_provider === 'local') {
@@ -36,26 +36,25 @@ export default defineEventHandler(async (event) => {
if (existing) { if (existing) {
throw createError({ statusCode: 409, message: 'Identifier already in use' }) throw createError({ statusCode: 409, message: 'Identifier already in use' })
} }
updates.identifier = identifier updates.push('identifier = ?')
params.push(identifier)
} }
if (body?.password !== undefined && body.password !== '') { if (body?.password !== undefined && body.password !== '') {
const password = body.password const password = body.password
if (typeof password !== 'string' || password.length < 1) { if (typeof password !== 'string' || password.length < 1) {
throw createError({ statusCode: 400, message: 'password cannot be empty' }) throw createError({ statusCode: 400, message: 'password cannot be empty' })
} }
updates.password_hash = hashPassword(password) updates.push('password_hash = ?')
params.push(hashPassword(password))
} }
} }
if (Object.keys(updates).length === 0) { if (updates.length === 0) {
return { id: user.id, identifier: user.identifier, role: user.role, auth_provider: user.auth_provider ?? 'local' } return { id: user.id, identifier: user.identifier, role: user.role, auth_provider: user.auth_provider ?? 'local' }
} }
const { query, params } = buildUpdateQuery('users', null, updates) params.push(id)
if (query) { await run(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`, params)
await run(query, [...params, id])
}
const updated = await get('SELECT id, identifier, role, auth_provider FROM users WHERE id = ?', [id]) const updated = await get('SELECT id, identifier, role, auth_provider FROM users WHERE id = ?', [id])
return updated return updated
})
}) })
+3 -9
View File
@@ -1,6 +1,6 @@
import { getCookie } from 'h3' import { getCookie } from 'h3'
import { getDb } from '../utils/db.js' import { getDb } from '../utils/db.js'
import { skipAuth } from '../utils/authHelpers.js' import { skipAuth } from '../utils/authSkipPaths.js'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
if (skipAuth(event.path)) return if (skipAuth(event.path)) return
@@ -10,16 +10,10 @@ export default defineEventHandler(async (event) => {
const { get } = await getDb() const { get } = await getDb()
const session = await get('SELECT user_id, expires_at FROM sessions WHERE id = ?', [sid]) const session = await get('SELECT user_id, expires_at FROM sessions WHERE id = ?', [sid])
if (!session || new Date(session.expires_at) < new Date()) return if (!session || new Date(session.expires_at) < new Date()) return
const user = await get('SELECT id, identifier, role, auth_provider, avatar_path FROM users WHERE id = ?', [session.user_id]) const user = await get('SELECT id, identifier, role, auth_provider FROM users WHERE id = ?', [session.user_id])
if (user) { if (user) {
const authProvider = user.auth_provider ?? 'local' const authProvider = user.auth_provider ?? 'local'
event.context.user = { event.context.user = { id: user.id, identifier: user.identifier, role: user.role, auth_provider: authProvider }
id: user.id,
identifier: user.identifier,
role: user.role,
auth_provider: authProvider,
avatar_path: user.avatar_path ?? null,
}
} }
} }
catch { catch {

Some files were not shown because too many files have changed in this diff Show More