Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8d4ab7b48 | ||
|
|
9c6960d7c7 | ||
|
|
068f82c0ff | ||
|
|
1f45cc152a | ||
|
|
be95b6864b | ||
|
|
cfac12b699 | ||
|
|
88e520aa59 | ||
|
|
5aa3bd8977 | ||
|
|
6fae069dec | ||
|
|
2d32047ae2 | ||
|
|
4d835a4f82 | ||
|
|
8c3f878636 | ||
|
|
cebeee4040 | ||
|
|
bb01e9a06c | ||
|
|
a6b87305a1 | ||
|
|
829db93065 | ||
|
|
afaf305bda | ||
|
|
2a48715ea8 | ||
|
|
9d34f5f7c5 | ||
|
|
5b4bb6b33a | ||
|
|
e16b492257 | ||
|
|
265e02119a | ||
|
|
82d454ade4 | ||
|
|
a9b300d711 | ||
|
|
fded3a04d4 | ||
|
|
0d897f17b5 | ||
|
|
216f6f83fe | ||
|
|
c023bdccae | ||
|
|
78f3ad8fcc | ||
|
|
2e7a52ed15 | ||
|
|
221b3533e5 | ||
|
|
578dccd0cf | ||
|
|
0ecad475ef | ||
|
|
d5789b79a6 | ||
|
|
45ce90f3cc | ||
|
|
3a817625c5 | ||
|
|
d5abea48b3 | ||
|
|
1da69ac272 | ||
|
|
bd2092a3ea | ||
|
|
afb6fb8ac7 | ||
|
|
414e9f4c33 | ||
|
|
10035221fb | ||
|
|
52a6f4368c | ||
|
|
e774dbc301 | ||
|
|
5bf582f3ad | ||
|
|
7398bb0a16 | ||
|
|
79d36df83c | ||
|
|
2146c06b02 | ||
|
|
7d955cd89f | ||
|
|
68082fd893 | ||
|
|
7fc4685cfc | ||
|
|
e61e6bc7e3 | ||
|
|
b18283d3b3 | ||
|
|
0aab29ea72 | ||
|
|
9261ba92bf | ||
|
|
17f28401ba |
@@ -0,0 +1,92 @@
|
|||||||
|
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.62.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 }}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
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
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
when:
|
|
||||||
- event: pull_request
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: lint
|
|
||||||
image: node:24-slim
|
|
||||||
depends_on: []
|
|
||||||
commands:
|
|
||||||
- npm ci
|
|
||||||
- npm run lint
|
|
||||||
|
|
||||||
- name: test
|
|
||||||
image: node:24-slim
|
|
||||||
depends_on: []
|
|
||||||
commands:
|
|
||||||
- npm ci
|
|
||||||
- npm run test
|
|
||||||
|
|
||||||
- name: e2e
|
|
||||||
image: mcr.microsoft.com/playwright:v1.58.2-noble
|
|
||||||
depends_on: []
|
|
||||||
commands:
|
|
||||||
- npm ci
|
|
||||||
- ./scripts/gen-dev-cert.sh
|
|
||||||
- npm run test:e2e
|
|
||||||
environment:
|
|
||||||
NODE_TLS_REJECT_UNAUTHORIZED: "0"
|
|
||||||
|
|
||||||
- name: docker-build
|
|
||||||
image: woodpeckerci/plugin-kaniko
|
|
||||||
depends_on: []
|
|
||||||
settings:
|
|
||||||
repo: ${CI_REPO_OWNER}/${CI_REPO_NAME}
|
|
||||||
registry: git.keligrubb.com
|
|
||||||
tags: latest
|
|
||||||
dry-run: true
|
|
||||||
single-snapshot: true
|
|
||||||
cleanup: true
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
when:
|
|
||||||
- event: push
|
|
||||||
branch: main
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: release
|
|
||||||
image: alpine
|
|
||||||
commands:
|
|
||||||
- apk add --no-cache git
|
|
||||||
- ./scripts/release.sh
|
|
||||||
environment:
|
|
||||||
GITEA_REPO_TOKEN:
|
|
||||||
from_secret: gitea_repo_token
|
|
||||||
|
|
||||||
- name: docker
|
|
||||||
image: woodpeckerci/plugin-kaniko
|
|
||||||
depends_on: [release]
|
|
||||||
settings:
|
|
||||||
repo: ${CI_REPO_OWNER}/${CI_REPO_NAME}
|
|
||||||
registry: git.keligrubb.com
|
|
||||||
username: ${CI_REPO_OWNER}
|
|
||||||
password:
|
|
||||||
from_secret: gitea_registry_token
|
|
||||||
single-snapshot: true
|
|
||||||
cleanup: true
|
|
||||||
|
|
||||||
- name: helm
|
|
||||||
image: alpine/helm
|
|
||||||
depends_on: [release]
|
|
||||||
environment:
|
|
||||||
GITEA_REGISTRY_TOKEN:
|
|
||||||
from_secret: gitea_registry_token
|
|
||||||
commands:
|
|
||||||
- apk add --no-cache curl
|
|
||||||
- helm package helm/kestrelos
|
|
||||||
- curl -sf -u $CI_REPO_OWNER:$GITEA_REGISTRY_TOKEN -X POST --upload-file kestrelos-*.tgz https://git.keligrubb.com/api/packages/$CI_REPO_OWNER/helm/api/charts
|
|
||||||
+986
@@ -1,3 +1,989 @@
|
|||||||
|
## [1.1.12] - 2026-08-23
|
||||||
|
### Changed
|
||||||
|
- update all non-major dependencies (#47)
|
||||||
|
|
||||||
|
This PR contains the following updates:
|
||||||
|
|
||||||
|
| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| [@vitest/coverage-v8](https://vitest.dev/guide/coverage) ([source](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8)) | [`4.1.10` → `4.1.11`](https://renovatebot.com/diffs/npm/@vitest%2fcoverage-v8/4.1.10/4.1.11) |  |  |
|
||||||
|
| [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) | [`5.10.1` → `5.11.0`](https://renovatebot.com/diffs/npm/fast-xml-parser/5.10.1/5.11.0) |  |  |
|
||||||
|
| [happy-dom](https://github.com/capricorn86/happy-dom) | [`20.11.2` → `20.11.6`](https://renovatebot.com/diffs/npm/happy-dom/20.11.2/20.11.6) |  |  |
|
||||||
|
| [hls.js](https://github.com/video-dev/hls.js) | [`1.7.0` → `1.7.1`](https://renovatebot.com/diffs/npm/hls.js/1.7.0/1.7.1) |  |  |
|
||||||
|
| [mediasoup](https://mediasoup.org) ([source](https://github.com/versatica/mediasoup)) | [`3.24.2` → `3.26.0`](https://renovatebot.com/diffs/npm/mediasoup/3.24.2/3.26.0) |  |  |
|
||||||
|
| [openid-client](https://github.com/panva/openid-client) | [`6.8.5` → `6.8.7`](https://renovatebot.com/diffs/npm/openid-client/6.8.5/6.8.7) |  |  |
|
||||||
|
| [vitest](https://vitest.dev) ([source](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest)) | [`4.1.10` → `4.1.11`](https://renovatebot.com/diffs/npm/vitest/4.1.10/4.1.11) |  |  |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Release Notes
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>vitest-dev/vitest (@​vitest/coverage-v8)</summary>
|
||||||
|
|
||||||
|
### [`v4.1.11`](https://github.com/vitest-dev/vitest/releases/tag/v4.1.11)
|
||||||
|
|
||||||
|
[Compare Source](https://github.com/vitest-dev/vitest/compare/v4.1.10...v4.1.11)
|
||||||
|
|
||||||
|
##### 🐞 Bug Fixes
|
||||||
|
|
||||||
|
- Revive global concurrency limit for test lifecycle \[backport to v4] - by [@​sheremet-va](https://github.com/sheremet-va) and [@​hi-ogawa](https://github.com/hi-ogawa) in [#​10992](https://github.com/vitest-dev/vitest/issues/10992) [<samp>(5146d)</samp>](https://github.com/vitest-dev/vitest/commit/5146df80b)
|
||||||
|
- **browser**:
|
||||||
|
- Encode iframeId in tester iframe URL \[backport to v4] - by [@​sheremet-va](https://github.com/sheremet-va), **Pduhard** and **Claude Opus 4.8** in [#​10955](https://github.com/vitest-dev/vitest/issues/10955) [<samp>(10b2c)</samp>](https://github.com/vitest-dev/vitest/commit/10b2cd201)
|
||||||
|
- Trigger playwright/chromium gc on lower disk availability \[backport to v4] - by [@​hi-ogawa](https://github.com/hi-ogawa), **Hiroshi Ogawa** and **OpenCode** in [#​10951](https://github.com/vitest-dev/vitest/issues/10951) [<samp>(9851d)</samp>](https://github.com/vitest-dev/vitest/commit/9851dbc41)
|
||||||
|
- **mocker**:
|
||||||
|
- Restrict redirect mocks to the fs allowlist \[backport to v4] - by [@​sheremet-va](https://github.com/sheremet-va) in [#​10974](https://github.com/vitest-dev/vitest/issues/10974) [<samp>(fe5a1)</samp>](https://github.com/vitest-dev/vitest/commit/fe5a11d3c)
|
||||||
|
|
||||||
|
##### [View changes on GitHub](https://github.com/vitest-dev/vitest/compare/v4.1.10...v4.1.11)
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>NaturalIntelligence/fast-xml-parser (fast-xml-parser)</summary>
|
||||||
|
|
||||||
|
### [`v5.11.0`](https://github.com/NaturalIntelligence/fast-xml-parser/releases/tag/v5.11.0)
|
||||||
|
|
||||||
|
[Compare Source](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.10.1...v5.11.0)
|
||||||
|
|
||||||
|
#### What's Changed
|
||||||
|
|
||||||
|
- add support for endIndex in node metadata (5.x edition) by [@​Wain-PC](https://github.com/Wain-PC) in [#​850](https://github.com/NaturalIntelligence/fast-xml-parser/pull/850)
|
||||||
|
- fix: don't crash on a closing tag with no matching opening tag by [@​hdimer](https://github.com/hdimer) in [#​861](https://github.com/NaturalIntelligence/fast-xml-parser/pull/861)
|
||||||
|
|
||||||
|
#### New Contributors
|
||||||
|
|
||||||
|
- [@​Wain-PC](https://github.com/Wain-PC) made their first contribution in [#​850](https://github.com/NaturalIntelligence/fast-xml-parser/pull/850)
|
||||||
|
- [@​hdimer](https://github.com/hdimer) made their first contribution in [#​861](https://github.com/NaturalIntelligence/fast-xml-parser/pull/861)
|
||||||
|
|
||||||
|
**Full Changelog**: <https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.10.1...v5.11.0>
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>capricorn86/happy-dom (happy-dom)</summary>
|
||||||
|
|
||||||
|
### [`v20.11.6`](https://github.com/capricorn86/happy-dom/releases/tag/v20.11.6)
|
||||||
|
|
||||||
|
[Compare Source](https://github.com/capricorn86/happy-dom/compare/v20.11.5...v20.11.6)
|
||||||
|
|
||||||
|
##### :construction\_worker\_man: Patch fixes
|
||||||
|
|
||||||
|
- Updates docs for the global-registrator package - By **[@​capricorn86](https://github.com/capricorn86)** in task [#​2300](https://github.com/capricorn86/happy-dom/issues/2300)
|
||||||
|
|
||||||
|
### [`v20.11.5`](https://github.com/capricorn86/happy-dom/releases/tag/v20.11.5)
|
||||||
|
|
||||||
|
[Compare Source](https://github.com/capricorn86/happy-dom/compare/v20.11.4...v20.11.5)
|
||||||
|
|
||||||
|
##### :construction\_worker\_man: Patch fixes
|
||||||
|
|
||||||
|
- Allow explicit element types for querySelector (e.g. `querySelector<HTMLInputElement>(".my-input")`) - By **[@​cyphercodes](https://github.com/cyphercodes)**
|
||||||
|
|
||||||
|
### [`v20.11.4`](https://github.com/capricorn86/happy-dom/releases/tag/v20.11.4)
|
||||||
|
|
||||||
|
[Compare Source](https://github.com/capricorn86/happy-dom/compare/v20.11.3...v20.11.4)
|
||||||
|
|
||||||
|
##### :construction\_worker\_man: Patch fixes
|
||||||
|
|
||||||
|
- Fixes the CORS check `fetch()` to match origins instead of host and protocol - By **[@​rexxars](https://github.com/rexxars)** in task [#​1490](https://github.com/capricorn86/happy-dom/issues/1490)
|
||||||
|
|
||||||
|
### [`v20.11.3`](https://github.com/capricorn86/happy-dom/releases/tag/v20.11.3)
|
||||||
|
|
||||||
|
[Compare Source](https://github.com/capricorn86/happy-dom/compare/v20.11.2...v20.11.3)
|
||||||
|
|
||||||
|
##### :construction\_worker\_man: Patch fixes
|
||||||
|
|
||||||
|
- Make document.links return a live HTMLCollection - By **[@​bangseongbeom](https://github.com/bangseongbeom)** in task [#​2299](https://github.com/capricorn86/happy-dom/issues/2299)
|
||||||
|
- Copy labels array to prevent mutation of cached querySelectorAll result - By **[@​mixelburg](https://github.com/mixelburg)** in task [#​2226](https://github.com/capricorn86/happy-dom/issues/2226)
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>video-dev/hls.js (hls.js)</summary>
|
||||||
|
|
||||||
|
### [`v1.7.1`](https://github.com/video-dev/hls.js/releases/tag/v1.7.1)
|
||||||
|
|
||||||
|
[Compare Source](https://github.com/video-dev/hls.js/compare/v1.7.0...v1.7.1)
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
|
||||||
|
HLS.js v1.7.1 includes bug fixes and improvements over the previous release.
|
||||||
|
|
||||||
|
#### Changes Since The Last Release
|
||||||
|
|
||||||
|
- Fix Interstitial snap-out at live edge and `BUFFER_APPEND_NO_PROGRESS` false positives ([#​7979](https://github.com/video-dev/hls.js/issues/7979)) [@​robwalch](https://github.com/robwalch)
|
||||||
|
- Workaround issue where `ManagedMediaSource` does not emit "startstreaming" when seeking ([#​7984](https://github.com/video-dev/hls.js/issues/7984))
|
||||||
|
- Fix permanent stall loading fragment-hint parts of encrypted low-latency streams ([#​7976](https://github.com/video-dev/hls.js/issues/7976)) [@​zaki699-blip](https://github.com/zaki699-blip)
|
||||||
|
- Document decode timebase change in MIGRATING ([#​7986](https://github.com/video-dev/hls.js/issues/7986)) [@​robwalch](https://github.com/robwalch)
|
||||||
|
|
||||||
|
#### Demo Page
|
||||||
|
|
||||||
|
<https://26ea065a.hls-js-dev.pages.dev/demo/>
|
||||||
|
|
||||||
|
#### API and Breaking Changes
|
||||||
|
|
||||||
|
No public exports were removed and no runtime behavior changes are required to upgrade from v1.6 to v1.7. TypeScript consumers might see new compile errors where previously loose types have been narrowed. Each is listed with upgrade guidance in the migration guide:
|
||||||
|
<https://github.com/video-dev/hls.js/blob/v1.7.0/MIGRATING.md#migrating-from-hlsjs-16-to-17>
|
||||||
|
|
||||||
|
Some exported type dependencies ("eventemitter3", "[@​svta/cml-cmcd](https://github.com/svta/cml-cmcd)", "[@​svta/cml-utils](https://github.com/svta/cml-utils)", "[@​svta/cml-structured-field-values](https://github.com/svta/cml-structured-field-values)") have not been bundled with hls.d.ts. Please file an issue if this is blocking you from upgrading.
|
||||||
|
|
||||||
|
#### Feedback
|
||||||
|
|
||||||
|
Please provide feedback via [Issues in GitHub](https://github.com/video-dev/hls.js/issues/new/choose). For more details on how to contribute to HLS.js, see our [CONTRIBUTING guide](https://github.com/video-dev/hls.js/blob/master/CONTRIBUTING.md).
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>versatica/mediasoup (mediasoup)</summary>
|
||||||
|
|
||||||
|
### [`v3.26.0`](https://github.com/versatica/mediasoup/blob/HEAD/CHANGELOG.md#3260)
|
||||||
|
|
||||||
|
[Compare Source](https://github.com/versatica/mediasoup/compare/3.25.0...3.26.0)
|
||||||
|
|
||||||
|
- **Breaking change:** Simulcast and SVC: Limit temporal layer to the preferred one ([PR #​1892](https://github.com/versatica/mediasoup/pull/1892)).
|
||||||
|
|
||||||
|
### [`v3.25.0`](https://github.com/versatica/mediasoup/blob/HEAD/CHANGELOG.md#3250)
|
||||||
|
|
||||||
|
[Compare Source](https://github.com/versatica/mediasoup/compare/3.24.2...3.25.0)
|
||||||
|
|
||||||
|
- Worker: Fix undefined behavior in `RtpStreamRecv::UpdateScore()` when no packets were received ([PR #​1886](https://github.com/versatica/mediasoup/pull/1886)).
|
||||||
|
- SCTP: Fix `SackChunk::GetValidatedGapAckBlocks()` returning a bogus gap-ack-block ([PR #​1891](https://github.com/versatica/mediasoup/pull/1891)).
|
||||||
|
- Do not make generated RTCP Sender Reports depend on RTP packet arrival time ([issue #​1881](https://github.com/versatica/mediasoup/issues/1881)):
|
||||||
|
- `RemoteClockOffsetEstimator` class ([PR #​1882](https://github.com/versatica/mediasoup/pull/1882)).
|
||||||
|
- Prepare `RtpStream` classes for capture time based RTCP Sender Reports ([PR #​1883](https://github.com/versatica/mediasoup/pull/1883), [PR #​1888](https://github.com/versatica/mediasoup/pull/1888)).
|
||||||
|
- `RemoteCaptureTimeEstimator` class ([PR #​1884](https://github.com/versatica/mediasoup/pull/1884)).
|
||||||
|
- Estimate the capture instant of each received RTP packet ([PR #​1885](https://github.com/versatica/mediasoup/pull/1885)).
|
||||||
|
- Generate RTCP Sender Reports based on the capture instant of the media rather than on the packet arrival time ([PR #​1887](https://github.com/versatica/mediasoup/pull/1887)).
|
||||||
|
- `SimulcastProducerStreamManager`: Apply new capture time logic and fix 'abs-capture-time' rewriting ([PR #​1889](https://github.com/versatica/mediasoup/pull/1889)).
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>panva/openid-client (openid-client)</summary>
|
||||||
|
|
||||||
|
### [`v6.8.7`](https://github.com/panva/openid-client/blob/HEAD/CHANGELOG.md#687-2026-08-20)
|
||||||
|
|
||||||
|
[Compare Source](https://github.com/panva/openid-client/compare/v6.8.6...v6.8.7)
|
||||||
|
|
||||||
|
##### Fixes
|
||||||
|
|
||||||
|
- allow destructuring the claims helper ([38bd8c0](https://github.com/panva/openid-client/commit/38bd8c052a7e1e6d0e2beda14d35fb38b9b26d4c)), references [#​887](https://github.com/panva/openid-client/issues/887)
|
||||||
|
|
||||||
|
### [`v6.8.6`](https://github.com/panva/openid-client/blob/HEAD/CHANGELOG.md#686-2026-08-18)
|
||||||
|
|
||||||
|
[Compare Source](https://github.com/panva/openid-client/compare/v6.8.5...v6.8.6)
|
||||||
|
|
||||||
|
##### Fixes
|
||||||
|
|
||||||
|
- avoid undefined user-agent in fetchProtectedResource ([492c3c3](https://github.com/panva/openid-client/commit/492c3c36aad1ac324661b808b32bc17a35d22665)), references [#​885](https://github.com/panva/openid-client/issues/885)
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box
|
||||||
|
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0NC4zMy4yIiwidXBkYXRlZEluVmVyIjoiNDQuMzkuMiIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiZGVwZW5kZW5jaWVzIl19-->
|
||||||
|
|
||||||
|
## [1.1.11] - 2026-08-19
|
||||||
|
### Changed
|
||||||
|
- remove unused broadcastToSession export (#45)
|
||||||
|
|
||||||
|
## Removed
|
||||||
|
|
||||||
|
- `broadcastToSession` from `server/plugins/websocket.js` — an exported but never-imported, never-called dead-code function. Its dependencies (`getSessionConnections`, `addSessionConnection`, `removeSessionConnection`) are retained since they remain actively used for per-session connection tracking within the plugin.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Full repository search confirms zero remaining references to `broadcastToSession`.
|
||||||
|
- ESLint passes; full test suite passes (50 files, 406 tests).
|
||||||
|
|
||||||
|
Closes #40
|
||||||
|
|
||||||
|
## [1.1.10] - 2026-08-14
|
||||||
|
### Changed
|
||||||
|
- update dependencies and fix security vulnerabilities (#44)
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Updates all out-of-date dependencies and applies `npm audit fix` to resolve 14 security vulnerabilities (3 critical, 8 high).
|
||||||
|
|
||||||
|
### Dependency Updates
|
||||||
|
|
||||||
|
| Package | Current → Latest |
|
||||||
|
|---|---|
|
||||||
|
| `nuxt` | 4.4.8 → 4.5.2 |
|
||||||
|
| `mediasoup` | 3.20.9 → 3.24.2 |
|
||||||
|
| `mediasoup-client` | 3.21.0 → 3.22.0 |
|
||||||
|
| `hls.js` | 1.6.16 → 1.7.0 |
|
||||||
|
| `vue` | 3.5.38 → 3.5.41 |
|
||||||
|
| `vue-router` | 5.1.0 → 5.2.0 |
|
||||||
|
| `@nuxt/icon` | 2.2.3 → 2.5.0 |
|
||||||
|
| `eslint` | 10.5.0 → 10.8.1 |
|
||||||
|
| `vitest` / `@vitest/coverage-v8` | 4.1.9 → 4.1.10 |
|
||||||
|
| `ws` | 8.21.0 → 8.21.3 |
|
||||||
|
| `openid-client` | 6.8.4 → 6.8.5 |
|
||||||
|
| `fast-xml-parser` | 5.9.3 → 5.10.1 |
|
||||||
|
| `happy-dom` | 20.10.6 → 20.11.2 |
|
||||||
|
| `@playwright/test` | 1.61.1 → 1.62.1 |
|
||||||
|
| `@nuxt/eslint` | 1.16.0 → 1.17.0 |
|
||||||
|
| `@nuxt/test-utils` | 4.0.3 → 4.1.0 |
|
||||||
|
| `@iconify-json/tabler` | 1.2.35 → 1.2.38 |
|
||||||
|
|
||||||
|
### Security Audit Fixes (`npm audit fix`)
|
||||||
|
|
||||||
|
- **`@nuxt/devtools`** (critical): Unauthenticated DevTools RPC allows arbitrary command execution on the developer's host
|
||||||
|
- **`tar`** (critical): Multiple process-crash/DoS vulnerabilities via crafted tar archives
|
||||||
|
- **`esbuild`** (high): Arbitrary file read when running dev server on Windows
|
||||||
|
- **`brace-expansion`** (high): Multiple DoS vectors (memory exhaustion, process hang)
|
||||||
|
- **`flatted`** (high): Prototype pollution via `parse()`
|
||||||
|
- **`svgo`** (high): `removeScripts` plugin leaves executable scripts intact
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
|
||||||
|
- All 406 tests pass (3 skipped)
|
||||||
|
- Lint clean (`eslint . --max-warnings 0`)
|
||||||
|
- `npm audit` reports 0 vulnerabilities
|
||||||
|
|
||||||
|
## [1.1.9] - 2026-08-13
|
||||||
|
### Changed
|
||||||
|
- update dependency fast-xml-parser to v5.10.1 [security] (#42)
|
||||||
|
|
||||||
|
This PR contains the following updates:
|
||||||
|
|
||||||
|
| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) | [`5.9.3` → `5.10.1`](https://renovatebot.com/diffs/npm/fast-xml-parser/5.9.3/5.10.1) |  |  |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### fast-xml-parser: Repeated DOCTYPE declarations reset entity expansion limits
|
||||||
|
[GHSA-8r6m-32jq-jx6q](https://github.com/advisories/GHSA-8r6m-32jq-jx6q)
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>More information</summary>
|
||||||
|
|
||||||
|
#### Details
|
||||||
|
##### Impact
|
||||||
|
`fast-xml-parser` processes multiple "DOCTYPE" declarations within a single XML document. Each declaration passes its entities to `@nodable/entities` through `addInputEntities()`.
|
||||||
|
|
||||||
|
`addInputEntities()` resets the entity expansion counters every time it is called. An attacker can therefore insert additional DOCTYPE declarations to repeatedly reset maxTotalExpansions and maxExpandedLength during one parse operation.
|
||||||
|
|
||||||
|
This allows a crafted XML document to exceed the configured entity-expansion limits and can cause excessive CPU use, event-loop blocking, memory exhaustion, and process termination.
|
||||||
|
|
||||||
|
##### Workarounds
|
||||||
|
- Manually check if multiple DOCTYPEs are not present in input contents
|
||||||
|
- Update to v5.10.1
|
||||||
|
- Keep `processEntity` flag off
|
||||||
|
|
||||||
|
#### Severity
|
||||||
|
- CVSS Score: 8.7 / 10 (High)
|
||||||
|
- Vector String: `CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N`
|
||||||
|
|
||||||
|
#### References
|
||||||
|
- [https://github.com/NaturalIntelligence/fast-xml-parser/security/advisories/GHSA-8r6m-32jq-jx6q](https://github.com/NaturalIntelligence/fast-xml-parser/security/advisories/GHSA-8r6m-32jq-jx6q)
|
||||||
|
- [https://github.com/NaturalIntelligence/fast-xml-parser/commit/4e546e03987662de5495d050b5fba26bea65383f](https://github.com/NaturalIntelligence/fast-xml-parser/commit/4e546e03987662de5495d050b5fba26bea65383f)
|
||||||
|
- [https://github.com/NaturalIntelligence/fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser)
|
||||||
|
- [https://github.com/NaturalIntelligence/fast-xml-parser/releases/tag/v5.10.1](https://github.com/NaturalIntelligence/fast-xml-parser/releases/tag/v5.10.1)
|
||||||
|
|
||||||
|
This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-8r6m-32jq-jx6q) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)).
|
||||||
|
</details>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Release Notes
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>NaturalIntelligence/fast-xml-parser (fast-xml-parser)</summary>
|
||||||
|
|
||||||
|
### [`v5.10.1`](https://github.com/NaturalIntelligence/fast-xml-parser/releases/tag/v5.10.1)
|
||||||
|
|
||||||
|
[Compare Source](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.10.0...v5.10.1)
|
||||||
|
|
||||||
|
**Full Changelog**: <https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.10.0...v5.10.1>
|
||||||
|
|
||||||
|
### [`v5.10.0`](https://github.com/NaturalIntelligence/fast-xml-parser/releases/tag/v5.10.0)
|
||||||
|
|
||||||
|
[Compare Source](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.9.3...v5.10.0)
|
||||||
|
|
||||||
|
#### What's Changed
|
||||||
|
|
||||||
|
- Bump actions/checkout from 6.0.3 to 7.0.0 by [@​dependabot](https://github.com/dependabot)\[bot] in [#​849](https://github.com/NaturalIntelligence/fast-xml-parser/pull/849)
|
||||||
|
- Bump zizmorcore/zizmor-action from 0.5.6 to 0.5.7 by [@​dependabot](https://github.com/dependabot)\[bot] in [#​848](https://github.com/NaturalIntelligence/fast-xml-parser/pull/848)
|
||||||
|
|
||||||
|
**Full Changelog**: <https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.9.3...v5.10.0>
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box
|
||||||
|
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0NC4yOC4wIiwidXBkYXRlZEluVmVyIjoiNDQuMjguMCIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiZGVwZW5kZW5jaWVzIiwic2VjdXJpdHkiXX0=-->
|
||||||
|
|
||||||
|
## [1.1.8] - 2026-08-13
|
||||||
|
### Changed
|
||||||
|
- update https://git.keligrubb.com/actions/setup-node action to v7 (#38)
|
||||||
|
|
||||||
|
This PR contains the following updates:
|
||||||
|
|
||||||
|
| Package | Type | Update | Change |
|
||||||
|
|---|---|---|---|
|
||||||
|
| [https://git.keligrubb.com/actions/setup-node](https://git.keligrubb.com/actions/setup-node) | action | major | `v6` → `v7` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Release Notes
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>actions/setup-node (https://git.keligrubb.com/actions/setup-node)</summary>
|
||||||
|
|
||||||
|
### [`v7.0.0`](https://git.keligrubb.com/actions/setup-node/compare/v7...v7)
|
||||||
|
|
||||||
|
[Compare Source](https://git.keligrubb.com/actions/setup-node/compare/v7...v7)
|
||||||
|
|
||||||
|
### [`v7`](https://git.keligrubb.com/actions/setup-node/compare/v6.5.0...v7)
|
||||||
|
|
||||||
|
[Compare Source](https://git.keligrubb.com/actions/setup-node/compare/v6.5.0...v7)
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box
|
||||||
|
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4yNjUuNCIsInVwZGF0ZWRJblZlciI6IjQzLjI2NS40IiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->
|
||||||
|
|
||||||
|
## [1.1.7] - 2026-08-13
|
||||||
|
### Changed
|
||||||
|
- update dependency supercluster to v9 (#41)
|
||||||
|
|
||||||
|
This PR contains the following updates:
|
||||||
|
|
||||||
|
| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| [supercluster](https://github.com/mapbox/supercluster) | [`^8.0.1` → `^9.0.0`](https://renovatebot.com/diffs/npm/supercluster/8.0.1/9.0.0) |  |  |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Release Notes
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>mapbox/supercluster (supercluster)</summary>
|
||||||
|
|
||||||
|
### [`v9.0.0`](https://github.com/mapbox/supercluster/releases/tag/v9.0.0)
|
||||||
|
|
||||||
|
[Compare Source](https://github.com/mapbox/supercluster/compare/v8.0.1...v9.0.0)
|
||||||
|
|
||||||
|
- Radically optimize **memory footprint and performance** — on a sample 1M points index, Supercluster now uses 94% less transient allocation, 57% lower peak heap, 65% less retained memory, and runs 23% faster. [#​258](https://github.com/mapbox/supercluster/issues/258)
|
||||||
|
- Improve internal coordinate precision by 6 bits (64 times).
|
||||||
|
- ⚠️ Breaking: hard-cap `maxZoom` at 30 (it wasn't practical to use it on higher zooms anyway).
|
||||||
|
- Add support for `MultiPoint` features. [#​263](https://github.com/mapbox/supercluster/issues/263)
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box
|
||||||
|
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0NC4yNy4wIiwidXBkYXRlZEluVmVyIjoiNDQuMjcuMCIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->
|
||||||
|
|
||||||
|
## [1.1.6] - 2026-06-24
|
||||||
|
### Changed
|
||||||
|
- Add ADS-B, AIS, and ALPR map layers with live CoT streaming (#36)
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
- **ADS-B & AIS:** OpenSky and AISStream OSINT feeds upsert into the CoT store; tactical tracks still arrive via adsbcot/aiscot on `:8089`. Map clients subscribe via `GET /api/cot/stream` (SSE) with viewport bbox filtering and Air / Surface / Team layer toggles.
|
||||||
|
- **ALPR (Flock/OSM):** Toggleable license-plate reader layer sourced from OpenStreetMap, with SQLite cache, Overpass fallback, tiled viewport fetching, and clustered markers with direction cones.
|
||||||
|
- **Map performance:** Ring-based tile selection (fixes zoom-out crash), immutable tile cache, incremental marker sync, split cluster load/query, and padded SSE bbox to reduce reconnect churn.
|
||||||
|
|
||||||
|
## Docs
|
||||||
|
|
||||||
|
- `docs/tracking.md` — ADS-B/AIS accuracy tiers, freshness, self-hosted receivers, optional OSINT API keys
|
||||||
|
- `docs/map-and-cameras.md` — ALPR layer and map behavior updates
|
||||||
|
|
||||||
|
## [1.1.5] - 2026-06-21
|
||||||
|
### Changed
|
||||||
|
- update https://git.keligrubb.com/actions/checkout action to v7 (#35)
|
||||||
|
|
||||||
|
This PR contains the following updates:
|
||||||
|
|
||||||
|
| Package | Type | Update | Change |
|
||||||
|
|---|---|---|---|
|
||||||
|
| [https://git.keligrubb.com/actions/checkout](https://git.keligrubb.com/actions/checkout) | action | major | `v6` → `v7` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Release Notes
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>actions/checkout (https://git.keligrubb.com/actions/checkout)</summary>
|
||||||
|
|
||||||
|
### [`v7.0.0`](https://git.keligrubb.com/actions/checkout/blob/HEAD/CHANGELOG.md#v700)
|
||||||
|
|
||||||
|
[Compare Source](https://git.keligrubb.com/actions/checkout/compare/v7...v7)
|
||||||
|
|
||||||
|
- Block checking out fork PR for pull\_request\_target and workflow\_run by [@​aiqiaoy](https://github.com/aiqiaoy) in [#​2454](https://github.com/actions/checkout/pull/2454)
|
||||||
|
- Bump actions/publish-immutable-action from 0.0.3 to 0.0.4 in the minor-actions-dependencies group across 1 directory by [@​dependabot](https://github.com/dependabot)\[bot] in [#​2458](https://github.com/actions/checkout/pull/2458)
|
||||||
|
- Bump flatted from 3.3.1 to 3.4.2 by [@​dependabot](https://github.com/dependabot)\[bot] in [#​2460](https://github.com/actions/checkout/pull/2460)
|
||||||
|
- Bump js-yaml from 4.1.0 to 4.2.0 by [@​dependabot](https://github.com/dependabot)\[bot] in [#​2461](https://github.com/actions/checkout/pull/2461)
|
||||||
|
- Bump [@​actions/core](https://github.com/actions/core) and [@​actions/tool-cache](https://github.com/actions/tool-cache) and Remove uuid by [@​dependabot](https://github.com/dependabot)\[bot] in [#​2459](https://github.com/actions/checkout/pull/2459)
|
||||||
|
- upgrade module to esm and update dependencies by [@​aiqiaoy](https://github.com/aiqiaoy) in [#​2463](https://github.com/actions/checkout/pull/2463)
|
||||||
|
- Bump the minor-npm-dependencies group across 1 directory with 3 updates by [@​dependabot](https://github.com/dependabot)\[bot] in [#​2462](https://github.com/actions/checkout/pull/2462)
|
||||||
|
|
||||||
|
### [`v7`](https://git.keligrubb.com/actions/checkout/blob/HEAD/CHANGELOG.md#v700)
|
||||||
|
|
||||||
|
[Compare Source](https://git.keligrubb.com/actions/checkout/compare/v6.0.3...v7)
|
||||||
|
|
||||||
|
- Block checking out fork PR for pull\_request\_target and workflow\_run by [@​aiqiaoy](https://github.com/aiqiaoy) in [#​2454](https://github.com/actions/checkout/pull/2454)
|
||||||
|
- Bump actions/publish-immutable-action from 0.0.3 to 0.0.4 in the minor-actions-dependencies group across 1 directory by [@​dependabot](https://github.com/dependabot)\[bot] in [#​2458](https://github.com/actions/checkout/pull/2458)
|
||||||
|
- Bump flatted from 3.3.1 to 3.4.2 by [@​dependabot](https://github.com/dependabot)\[bot] in [#​2460](https://github.com/actions/checkout/pull/2460)
|
||||||
|
- Bump js-yaml from 4.1.0 to 4.2.0 by [@​dependabot](https://github.com/dependabot)\[bot] in [#​2461](https://github.com/actions/checkout/pull/2461)
|
||||||
|
- Bump [@​actions/core](https://github.com/actions/core) and [@​actions/tool-cache](https://github.com/actions/tool-cache) and Remove uuid by [@​dependabot](https://github.com/dependabot)\[bot] in [#​2459](https://github.com/actions/checkout/pull/2459)
|
||||||
|
- upgrade module to esm and update dependencies by [@​aiqiaoy](https://github.com/aiqiaoy) in [#​2463](https://github.com/actions/checkout/pull/2463)
|
||||||
|
- Bump the minor-npm-dependencies group across 1 directory with 3 updates by [@​dependabot](https://github.com/dependabot)\[bot] in [#​2462](https://github.com/actions/checkout/pull/2462)
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box
|
||||||
|
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4yMzMuNCIsInVwZGF0ZWRJblZlciI6IjQzLjIzMy40IiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->
|
||||||
|
|
||||||
|
## [1.1.4] - 2026-04-29
|
||||||
|
### Changed
|
||||||
|
- update all non-major dependencies (#31)
|
||||||
|
|
||||||
|
This PR contains the following updates:
|
||||||
|
|
||||||
|
| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| [@vitest/coverage-v8](https://vitest.dev/guide/coverage) ([source](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8)) | [`4.1.4` → `4.1.5`](https://renovatebot.com/diffs/npm/@vitest%2fcoverage-v8/4.1.4/4.1.5) |  |  |
|
||||||
|
| [@vue/test-utils](https://github.com/vuejs/test-utils) | [`2.4.6` → `2.4.8`](https://renovatebot.com/diffs/npm/@vue%2ftest-utils/2.4.6/2.4.8) |  |  |
|
||||||
|
| [eslint](https://eslint.org) ([source](https://github.com/eslint/eslint)) | [`10.2.0` → `10.2.1`](https://renovatebot.com/diffs/npm/eslint/10.2.0/10.2.1) |  |  |
|
||||||
|
| [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) | [`5.6.0` → `5.7.2`](https://renovatebot.com/diffs/npm/fast-xml-parser/5.6.0/5.7.2) |  |  |
|
||||||
|
| [mediasoup](https://mediasoup.org) ([source](https://github.com/versatica/mediasoup)) | [`3.19.19` → `3.19.21`](https://renovatebot.com/diffs/npm/mediasoup/3.19.19/3.19.21) |  |  |
|
||||||
|
| [mediasoup-client](https://mediasoup.org) ([source](https://github.com/versatica/mediasoup-client)) | [`3.18.8` → `3.19.0`](https://renovatebot.com/diffs/npm/mediasoup-client/3.18.8/3.19.0) |  |  |
|
||||||
|
| [vitest](https://vitest.dev) ([source](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest)) | [`4.1.4` → `4.1.5`](https://renovatebot.com/diffs/npm/vitest/4.1.4/4.1.5) |  |  |
|
||||||
|
| [vue](https://vuejs.org/) ([source](https://github.com/vuejs/core)) | [`3.5.32` → `3.5.33`](https://renovatebot.com/diffs/npm/vue/3.5.32/3.5.33) |  |  |
|
||||||
|
| [vue-router](https://router.vuejs.org) ([source](https://github.com/vuejs/router)) | [`5.0.4` → `5.0.6`](https://renovatebot.com/diffs/npm/vue-router/5.0.4/5.0.6) |  |  |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Release Notes
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>vitest-dev/vitest (@​vitest/coverage-v8)</summary>
|
||||||
|
|
||||||
|
### [`v4.1.5`](https://github.com/vitest-dev/vitest/releases/tag/v4.1.5)
|
||||||
|
|
||||||
|
[Compare Source](https://github.com/vitest-dev/vitest/compare/v4.1.4...v4.1.5)
|
||||||
|
|
||||||
|
##### 🚀 Experimental Features
|
||||||
|
|
||||||
|
- **coverage**: Istanbul to support `instrumenter` option - by [@​BartWaardenburg](https://github.com/BartWaardenburg) and [@​AriPerkkio](https://github.com/AriPerkkio) in [#​10119](https://github.com/vitest-dev/vitest/issues/10119) [<samp>(0e0ff)</samp>](https://github.com/vitest-dev/vitest/commit/0e0ff41c7)
|
||||||
|
|
||||||
|
##### 🐞 Bug Fixes
|
||||||
|
|
||||||
|
- \--project negation excludes browser instances - by [@​felamaslen](https://github.com/felamaslen) in [#​10131](https://github.com/vitest-dev/vitest/issues/10131) [<samp>(9423d)</samp>](https://github.com/vitest-dev/vitest/commit/9423dc084)
|
||||||
|
- Project color label on html reporter - by [@​hi-ogawa](https://github.com/hi-ogawa) in [#​10142](https://github.com/vitest-dev/vitest/issues/10142) [<samp>(596f7)</samp>](https://github.com/vitest-dev/vitest/commit/596f73986)
|
||||||
|
- Fix `vi.defineHelper` called as object method - by [@​hi-ogawa](https://github.com/hi-ogawa) in [#​10163](https://github.com/vitest-dev/vitest/issues/10163) [<samp>(122c2)</samp>](https://github.com/vitest-dev/vitest/commit/122c25b5b)
|
||||||
|
- Alias `agent` reporter to `minimal` - by [@​sheremet-va](https://github.com/sheremet-va) in [#​10157](https://github.com/vitest-dev/vitest/issues/10157) [<samp>(663b9)</samp>](https://github.com/vitest-dev/vitest/commit/663b99fe3)
|
||||||
|
- Respect diff config options in soft assertions - by [@​Copilot](https://github.com/Copilot), **sheremet-va** and [@​sheremet-va](https://github.com/sheremet-va) in [#​8696](https://github.com/vitest-dev/vitest/issues/8696) [<samp>(9787d)</samp>](https://github.com/vitest-dev/vitest/commit/9787dedad)
|
||||||
|
- Respect diff config options in soft assertions " - by [@​sheremet-va](https://github.com/sheremet-va) in [#​8696](https://github.com/vitest-dev/vitest/issues/8696) [<samp>(7dc6d)</samp>](https://github.com/vitest-dev/vitest/commit/7dc6d54fd)
|
||||||
|
- **ast-collect**: Recognize \_*vi\_import* prefix in static test discovery - by [@​Yejneshwar](https://github.com/Yejneshwar) in [#​10129](https://github.com/vitest-dev/vitest/issues/10129) [<samp>(32546)</samp>](https://github.com/vitest-dev/vitest/commit/325463ab2)
|
||||||
|
- **coverage**: Descriptive error message when reports directory is removed during test run - by [@​DaveT1991](https://github.com/DaveT1991) and [@​AriPerkkio](https://github.com/AriPerkkio) in [#​10117](https://github.com/vitest-dev/vitest/issues/10117) [<samp>(14133)</samp>](https://github.com/vitest-dev/vitest/commit/1413382e1)
|
||||||
|
- **snapshot**: Increase default snapshot max output length - by [@​hi-ogawa](https://github.com/hi-ogawa) and **Codex** in [#​10150](https://github.com/vitest-dev/vitest/issues/10150) [<samp>(21e66)</samp>](https://github.com/vitest-dev/vitest/commit/21e66ff63)
|
||||||
|
- **ui**: Fix jsx/tsx syntax highlight - by [@​hi-ogawa](https://github.com/hi-ogawa) in [#​10152](https://github.com/vitest-dev/vitest/issues/10152) [<samp>(f1b1f)</samp>](https://github.com/vitest-dev/vitest/commit/f1b1f6c7b)
|
||||||
|
- **web-worker**: Support MessagePort objects referenced inside postMessage data - by [@​whitphx](https://github.com/whitphx) and **Claude Opus 4.6 (1M context)** in [#​9927](https://github.com/vitest-dev/vitest/issues/9927) and [#​10124](https://github.com/vitest-dev/vitest/issues/10124) [<samp>(7ad7d)</samp>](https://github.com/vitest-dev/vitest/commit/7ad7d39af)
|
||||||
|
- **api**: Make test-specification options writable - by [@​sheremet-va](https://github.com/sheremet-va) in [#​10154](https://github.com/vitest-dev/vitest/issues/10154) [<samp>(6abd5)</samp>](https://github.com/vitest-dev/vitest/commit/6abd557b7)
|
||||||
|
|
||||||
|
##### [View changes on GitHub](https://github.com/vitest-dev/vitest/compare/v4.1.4...v4.1.5)
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>vuejs/test-utils (@​vue/test-utils)</summary>
|
||||||
|
|
||||||
|
### [`v2.4.8`](https://github.com/vuejs/test-utils/releases/tag/v2.4.8)
|
||||||
|
|
||||||
|
[Compare Source](https://github.com/vuejs/test-utils/compare/v2.4.7...v2.4.8)
|
||||||
|
|
||||||
|
[compare changes](https://github.com/vuejs/test-utils/compare/v2.4.7...v2.4.8)
|
||||||
|
|
||||||
|
##### 🩹 Fixes
|
||||||
|
|
||||||
|
- Correct declaration entrypoint ([#​2826](https://github.com/vuejs/test-utils/pull/2826))
|
||||||
|
|
||||||
|
##### 🤖 CI
|
||||||
|
|
||||||
|
- Enable pkg.pr.new ([#​2827](https://github.com/vuejs/test-utils/pull/2827))
|
||||||
|
|
||||||
|
##### ❤️ Contributors
|
||||||
|
|
||||||
|
- Cédric Exbrayat ([@​cexbrayat](https://github.com/cexbrayat))
|
||||||
|
- Daniel Roe ([@​danielroe](https://github.com/danielroe))
|
||||||
|
|
||||||
|
### [`v2.4.7`](https://github.com/vuejs/test-utils/releases/tag/v2.4.7)
|
||||||
|
|
||||||
|
[Compare Source](https://github.com/vuejs/test-utils/compare/v2.4.6...v2.4.7)
|
||||||
|
|
||||||
|
[compare changes](https://github.com/vuejs/test-utils/compare/v2.4.6...v2.4.7)
|
||||||
|
|
||||||
|
##### 🚀 Enhancements
|
||||||
|
|
||||||
|
- Add Chinese docs translation ([#​2552](https://github.com/vuejs/test-utils/pull/2552))
|
||||||
|
- SetData()/shallowMount with initialData for components using the Composition API / <script setup> ([#​2655](https://github.com/vuejs/test-utils/pull/2655))
|
||||||
|
|
||||||
|
##### 🩹 Fixes
|
||||||
|
|
||||||
|
- Preserve code from keyboard events ([#​2434](https://github.com/vuejs/test-utils/pull/2434))
|
||||||
|
- Switch browser and require exports definitions ([#​2501](https://github.com/vuejs/test-utils/pull/2501))
|
||||||
|
- Re-add peer dependencies but with wider range ([#​2511](https://github.com/vuejs/test-utils/pull/2511))
|
||||||
|
- Resolve warnings in docs:dev ([30b7491](https://github.com/vuejs/test-utils/commit/30b7491))
|
||||||
|
- Resolve TypeScript type errors in .vitepress/config ([#​2549](https://github.com/vuejs/test-utils/pull/2549))
|
||||||
|
- Accept FunctionalComponent<any> as selector ([0bb947f](https://github.com/vuejs/test-utils/commit/0bb947f))
|
||||||
|
- Text() misses content for array functional component ([#​2579](https://github.com/vuejs/test-utils/pull/2579))
|
||||||
|
- Use await in test ([c5482b4](https://github.com/vuejs/test-utils/commit/c5482b4))
|
||||||
|
- **deps:** Update dependency vue-component-type-helpers to v3 ([#​2683](https://github.com/vuejs/test-utils/pull/2683))
|
||||||
|
- Remove wrapper div when unmount ([#​2700](https://github.com/vuejs/test-utils/pull/2700))
|
||||||
|
- Make mount options slots compatible with noUncheckedIndexedAccess true ([#​2713](https://github.com/vuejs/test-utils/pull/2713))
|
||||||
|
- Add missing peerDependency [@​vue/compiler-dom](https://github.com/vue/compiler-dom) ([75801ba](https://github.com/vuejs/test-utils/commit/75801ba))
|
||||||
|
- **docs:** Declare css module for vitepress typecheck ([ddaca97](https://github.com/vuejs/test-utils/commit/ddaca97))
|
||||||
|
|
||||||
|
##### 💅 Refactors
|
||||||
|
|
||||||
|
- Enforce consistent usage of type imports ([#​2734](https://github.com/vuejs/test-utils/pull/2734))
|
||||||
|
|
||||||
|
##### 📖 Documentation
|
||||||
|
|
||||||
|
- Clarify findComponent vs getComponent ([#​2435](https://github.com/vuejs/test-utils/pull/2435))
|
||||||
|
- Update fr docs ([67064ef](https://github.com/vuejs/test-utils/commit/67064ef))
|
||||||
|
- Add note about partial transition stub support ([#​2431](https://github.com/vuejs/test-utils/pull/2431))
|
||||||
|
- Fix missing data at passing data section essentials guide ([dda205e](https://github.com/vuejs/test-utils/commit/dda205e))
|
||||||
|
- Fix missing data at passing data section essentials guide fr ([ae2c72c](https://github.com/vuejs/test-utils/commit/ae2c72c))
|
||||||
|
- Fix plugin TS declaration example ([#​2466](https://github.com/vuejs/test-utils/pull/2466))
|
||||||
|
- Fixed incorrect checkbox value check ([#​2495](https://github.com/vuejs/test-utils/pull/2495))
|
||||||
|
- Capital letter in sentence fix ([#​2499](https://github.com/vuejs/test-utils/pull/2499))
|
||||||
|
- Import missing DOMWrapper on Implementation of the plugin section ([#​2519](https://github.com/vuejs/test-utils/pull/2519))
|
||||||
|
- Add migration step for deprecated ref syntax in findAllComponents ([#​2498](https://github.com/vuejs/test-utils/pull/2498))
|
||||||
|
- Correct anchor hash links and fix typo ([#​2551](https://github.com/vuejs/test-utils/pull/2551))
|
||||||
|
- Center logo on home ([#​2559](https://github.com/vuejs/test-utils/pull/2559))
|
||||||
|
- **zh-cn:** Review a-crash-course ([#​2563](https://github.com/vuejs/test-utils/pull/2563))
|
||||||
|
- Use code-group for install commands ([#​2571](https://github.com/vuejs/test-utils/pull/2571))
|
||||||
|
- **zh-cn:** Review event-handing.md ([#​2572](https://github.com/vuejs/test-utils/pull/2572))
|
||||||
|
- **zh-cn:** Enhance conditional-rendering.md ([#​2562](https://github.com/vuejs/test-utils/pull/2562))
|
||||||
|
- **zh-cn:** Review easy-to-test ([#​2567](https://github.com/vuejs/test-utils/pull/2567))
|
||||||
|
- **zh-cn:** Review passing-data.md ([#​2575](https://github.com/vuejs/test-utils/pull/2575))
|
||||||
|
- **zh-cn:** Review async-suspense.md ([#​2576](https://github.com/vuejs/test-utils/pull/2576))
|
||||||
|
- **zh:** 优化 API 文档格式和内容 ([#​2569](https://github.com/vuejs/test-utils/pull/2569))
|
||||||
|
- **zh:** 更新 Vitest 模拟日期和计时器的说明 ([#​2578](https://github.com/vuejs/test-utils/pull/2578))
|
||||||
|
- **zh-cn:** Review http-requests.md ([#​2580](https://github.com/vuejs/test-utils/pull/2580))
|
||||||
|
- **zh-cn:** Review forms ([#​2582](https://github.com/vuejs/test-utils/pull/2582))
|
||||||
|
- **zh-cn:** Guide/advanced/slots.md ([#​2565](https://github.com/vuejs/test-utils/pull/2565))
|
||||||
|
- **zh:** Review extending-vtu ([#​2583](https://github.com/vuejs/test-utils/pull/2583))
|
||||||
|
- **zh:** Review index ([#​2584](https://github.com/vuejs/test-utils/pull/2584))
|
||||||
|
- Fix modelValue test example ([85bfdf4](https://github.com/vuejs/test-utils/commit/85bfdf4))
|
||||||
|
- Removes broken link from plugins.md ([69bc1ce](https://github.com/vuejs/test-utils/commit/69bc1ce))
|
||||||
|
- **zh:** Review transitions, component-instance, and reusability-composition ([#​2616](https://github.com/vuejs/test-utils/pull/2616))
|
||||||
|
- **zh:** Review v-model and vuex ([#​2617](https://github.com/vuejs/test-utils/pull/2617))
|
||||||
|
- **zh:** Review all the rest advanced guide ([#​2619](https://github.com/vuejs/test-utils/pull/2619))
|
||||||
|
- **zh:** Review migration ([#​2623](https://github.com/vuejs/test-utils/pull/2623))
|
||||||
|
- Fix a typo in transitions.md ([#​2635](https://github.com/vuejs/test-utils/pull/2635))
|
||||||
|
- Update crash-course to script setup ([c81aa79](https://github.com/vuejs/test-utils/commit/c81aa79))
|
||||||
|
- Update Essentials section to setup (composition api) ([#​2647](https://github.com/vuejs/test-utils/pull/2647))
|
||||||
|
- Typos in examples ([#​2678](https://github.com/vuejs/test-utils/pull/2678))
|
||||||
|
- Typo in easy-to-test.md ([#​2710](https://github.com/vuejs/test-utils/pull/2710))
|
||||||
|
- Add note about mocking requestAnimationFrame for transitions ([2324c65](https://github.com/vuejs/test-utils/commit/2324c65))
|
||||||
|
- Updated example TodoApp to script setup ([#​2727](https://github.com/vuejs/test-utils/pull/2727))
|
||||||
|
- Remove "Using data" section from "Conditional Rendering" guide and fix passing data test example ([#​2743](https://github.com/vuejs/test-utils/pull/2743))
|
||||||
|
- Follow-up fixes for the conditional rendering guide ([#​2744](https://github.com/vuejs/test-utils/pull/2744))
|
||||||
|
- Mention shallowMount stub name changes in migration guide ([80e051a](https://github.com/vuejs/test-utils/commit/80e051a))
|
||||||
|
- Update conditional rendering documentation to clarify isVisible() usage with attachTo ([#​2799](https://github.com/vuejs/test-utils/pull/2799))
|
||||||
|
- Restore Options API component for data() mounting example ([#​2804](https://github.com/vuejs/test-utils/pull/2804))
|
||||||
|
- Promote Vitest as recommended test runner ([#​2805](https://github.com/vuejs/test-utils/pull/2805))
|
||||||
|
- **api:** Note that setValue does not accept objects on `<select>` ([#​2819](https://github.com/vuejs/test-utils/pull/2819))
|
||||||
|
|
||||||
|
##### 🏡 Chore
|
||||||
|
|
||||||
|
- Add api/index.md to docs:translation:compare ([6b8681c](https://github.com/vuejs/test-utils/commit/6b8681c))
|
||||||
|
- Remove unnecessary generic arguments ([cfd70c6](https://github.com/vuejs/test-utils/commit/cfd70c6))
|
||||||
|
- Ignore TS error in config object ([9d0a618](https://github.com/vuejs/test-utils/commit/9d0a618))
|
||||||
|
- Simplify eslint packages ([c1d0ffd](https://github.com/vuejs/test-utils/commit/c1d0ffd))
|
||||||
|
- Use eslint v9 with flat config ([2f19fdf](https://github.com/vuejs/test-utils/commit/2f19fdf))
|
||||||
|
- Expose Stubs type publicly ([#​2492](https://github.com/vuejs/test-utils/pull/2492))
|
||||||
|
- Update documentation file path ([9c96594](https://github.com/vuejs/test-utils/commit/9c96594))
|
||||||
|
- Use pnpm v10 ([e4c2cb3](https://github.com/vuejs/test-utils/commit/e4c2cb3))
|
||||||
|
- Pnpm approve build ([81c54e9](https://github.com/vuejs/test-utils/commit/81c54e9))
|
||||||
|
- Use github issue forms ([#​2673](https://github.com/vuejs/test-utils/pull/2673))
|
||||||
|
- Exclude class components from test type-checking ([0899008](https://github.com/vuejs/test-utils/commit/0899008))
|
||||||
|
- Add explicit coverage include for vitest v4 ([51672b9](https://github.com/vuejs/test-utils/commit/51672b9))
|
||||||
|
- Update to prettier v3.7 ([fed9e7c](https://github.com/vuejs/test-utils/commit/fed9e7c))
|
||||||
|
- Migrate to oxfmt ([81c1de9](https://github.com/vuejs/test-utils/commit/81c1de9))
|
||||||
|
- Migrate to oxlint ([a361908](https://github.com/vuejs/test-utils/commit/a361908))
|
||||||
|
- Prepare TypeScript 6 migration settings ([55e1262](https://github.com/vuejs/test-utils/commit/55e1262))
|
||||||
|
- Adjust tsd config for TypeScript 6 ([7d23eb5](https://github.com/vuejs/test-utils/commit/7d23eb5))
|
||||||
|
- Avoid TypeScript 6 target deprecation warning ([81d063c](https://github.com/vuejs/test-utils/commit/81d063c))
|
||||||
|
|
||||||
|
##### 🤖 CI
|
||||||
|
|
||||||
|
- Remove node v22 build ([7ebf58d](https://github.com/vuejs/test-utils/commit/7ebf58d))
|
||||||
|
- Add node v22 build ([57540ee](https://github.com/vuejs/test-utils/commit/57540ee))
|
||||||
|
- Use "pool: threads" instead of vmThreads ([d0cbb54](https://github.com/vuejs/test-utils/commit/d0cbb54))
|
||||||
|
- Remove node v18 and add v24 ([fd9cf95](https://github.com/vuejs/test-utils/commit/fd9cf95))
|
||||||
|
- Add trusted publishing release workflow ([#​2825](https://github.com/vuejs/test-utils/pull/2825))
|
||||||
|
|
||||||
|
##### ❤️ Contributors
|
||||||
|
|
||||||
|
- Lachlan Miller ([@​lmiller1990](https://github.com/lmiller1990))
|
||||||
|
- cexbrayat ([@​cexbrayat](https://github.com/cexbrayat))
|
||||||
|
- Nicolas Bonamy ([@​nbonamy](https://github.com/nbonamy))
|
||||||
|
- KatWorkGit ([@​KatWorkGit](https://github.com/KatWorkGit))
|
||||||
|
- Wouter Kroes ([@​wouterkroes](https://github.com/wouterkroes))
|
||||||
|
- Rama Muhammad Murshal ([@​ramammurshal](https://github.com/ramammurshal))
|
||||||
|
- Evan You ([@​yyx990803](https://github.com/yyx990803))
|
||||||
|
- Vlad Starkovsky ([@​starkovsky](https://github.com/starkovsky))
|
||||||
|
- Joe ([@​joaoprp](https://github.com/joaoprp))
|
||||||
|
- Priyadarshi Kumar ([@​Psingh132](https://github.com/Psingh132))
|
||||||
|
- Sébastien Ronveaux ([@​sronveaux](https://github.com/sronveaux))
|
||||||
|
- Gilliam ([@​Gi11i4m](https://github.com/Gi11i4m))
|
||||||
|
- Baranov Dmytro ([@​dimas7001](https://github.com/dimas7001))
|
||||||
|
- BrendonHenrique ([@​BrendonHenrique](https://github.com/BrendonHenrique))
|
||||||
|
- Lorenz van Herwaarden ([@​lorenzvanherwaarden](https://github.com/lorenzvanherwaarden))
|
||||||
|
- wuzhiqing ([@​DDDDD12138](https://github.com/DDDDD12138))
|
||||||
|
- 阿菜 Cai ([@​RSS1102](https://github.com/RSS1102))
|
||||||
|
- Jinjiang ([@​Jinjiang](https://github.com/Jinjiang))
|
||||||
|
- Kylin ([@​lxKylin](https://github.com/lxKylin))
|
||||||
|
- Qianhe Chen ([@​chenqianhe](https://github.com/chenqianhe))
|
||||||
|
- 时瑶 ([@​KiritaniAyaka](https://github.com/KiritaniAyaka))
|
||||||
|
- h7ml ([@​h7ml](https://github.com/h7ml))
|
||||||
|
- Nicander ([@​Nicander93](https://github.com/Nicander93))
|
||||||
|
- Take-John ([@​takejohn](https://github.com/takejohn))
|
||||||
|
- ilyasherstoboev ([@​ilyasherstoboev](https://github.com/ilyasherstoboev))
|
||||||
|
- aimerie ([@​aimerie](https://github.com/aimerie))
|
||||||
|
- Miguel Rincon ([@​miguelrincon](https://github.com/miguelrincon))
|
||||||
|
- bcastlel ([@​bcastlel](https://github.com/bcastlel))
|
||||||
|
- Claudiu ([@​sofuxro](https://github.com/sofuxro))
|
||||||
|
- Artem Dragunov ([@​dragunovartem99](https://github.com/dragunovartem99))
|
||||||
|
- Robin ([@​OrbisK](https://github.com/OrbisK))
|
||||||
|
- Koen Mertens ([@​KCMertens](https://github.com/KCMertens))
|
||||||
|
- meomking ([@​CaptainWang98](https://github.com/CaptainWang98))
|
||||||
|
- Pepijn Olivier ([@​pepijnolivier](https://github.com/pepijnolivier))
|
||||||
|
- Tomina ([@​Thomaash](https://github.com/Thomaash))
|
||||||
|
- Gareth Jones ([@​G-Rath](https://github.com/G-Rath))
|
||||||
|
- Jerry Hogan ([@​hdJerry](https://github.com/hdJerry))
|
||||||
|
- Marco Pasqualetti ([@​marcalexiei](https://github.com/marcalexiei))
|
||||||
|
- guoxk ([@​guoxk-me](https://github.com/guoxk-me))
|
||||||
|
- kimulaco ([@​kimulaco](https://github.com/kimulaco))
|
||||||
|
- Erwan IQUEL ([@​Olympus5](https://github.com/Olympus5))
|
||||||
|
- Matt Van Horn ([@​mvanhorn](https://github.com/mvanhorn))
|
||||||
|
- Daniel Roe ([@​danielroe](https://github.com/danielroe))
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>eslint/eslint (eslint)</summary>
|
||||||
|
|
||||||
|
### [`v10.2.1`](https://github.com/eslint/eslint/releases/tag/v10.2.1)
|
||||||
|
|
||||||
|
[Compare Source](https://github.com/eslint/eslint/compare/v10.2.0...v10.2.1)
|
||||||
|
|
||||||
|
#### Bug Fixes
|
||||||
|
|
||||||
|
- [`14be92b`](https://github.com/eslint/eslint/commit/14be92b6d1fa0923b8923830f2208e5e2705b002) fix: model generator yield resumption paths in code path analysis ([#​20665](https://github.com/eslint/eslint/issues/20665)) (sethamus)
|
||||||
|
- [`84a19d2`](https://github.com/eslint/eslint/commit/84a19d2c32255db6b9cfc08644a607aae6d5cb62) fix: no-async-promise-executor false positives for shadowed Promise ([#​20740](https://github.com/eslint/eslint/issues/20740)) (xbinaryx)
|
||||||
|
- [`af764af`](https://github.com/eslint/eslint/commit/af764af0ec38225755fbf8a6f207f0c77b595a8d) fix: clarify language and processor validation errors ([#​20729](https://github.com/eslint/eslint/issues/20729)) (Pixel998)
|
||||||
|
- [`e251b89`](https://github.com/eslint/eslint/commit/e251b89a38280973e468a4a9386c138f4f55d10d) fix: update eslint ([#​20715](https://github.com/eslint/eslint/issues/20715)) (renovate\[bot])
|
||||||
|
|
||||||
|
#### Documentation
|
||||||
|
|
||||||
|
- [`ca92ca0`](https://github.com/eslint/eslint/commit/ca92ca0fb4599e8de1e2fb914e695fe7397cbe63) docs: reuse markdown-it instance for markdown filter ([#​20768](https://github.com/eslint/eslint/issues/20768)) (Amaresh S M)
|
||||||
|
- [`57d2ee2`](https://github.com/eslint/eslint/commit/57d2ee213305cee0cb55ef08e0480b57396269a9) docs: Enable Eleventy incremental mode for watch ([#​20767](https://github.com/eslint/eslint/issues/20767)) (Amaresh S M)
|
||||||
|
- [`c1621b9`](https://github.com/eslint/eslint/commit/c1621b915742276e5f4b25efe790ca62296330dc) docs: fix typos in code-path-analyzer.js ([#​20700](https://github.com/eslint/eslint/issues/20700)) (Ayush Shukla)
|
||||||
|
- [`1418d52`](https://github.com/eslint/eslint/commit/1418d522d10bde1960f4942afb548bc7160ec49e) docs: Update README (GitHub Actions Bot)
|
||||||
|
- [`39771e6`](https://github.com/eslint/eslint/commit/39771e6e600f0b0617fdeafff6dd07e4211ffde6) docs: Update README (GitHub Actions Bot)
|
||||||
|
- [`71e0469`](https://github.com/eslint/eslint/commit/71e04693def2df57268f08f3072a2749df6bf438) docs: fix incomplete JSDoc param description in no-shadow rule ([#​20728](https://github.com/eslint/eslint/issues/20728)) (kuldeep kumar)
|
||||||
|
- [`22119ce`](https://github.com/eslint/eslint/commit/22119ceb93e28f62262fc1d98ff1b1442d6e2dbf) docs: clarify scope of for-direction rule with dead code examples ([#​20723](https://github.com/eslint/eslint/issues/20723)) (Amaresh S M)
|
||||||
|
- [`8f3fb77`](https://github.com/eslint/eslint/commit/8f3fb77f122a5641d1833cad5d93f3f54fa3be0b) docs: document `meta.docs.dialects` ([#​20718](https://github.com/eslint/eslint/issues/20718)) (Pixel998)
|
||||||
|
|
||||||
|
#### Chores
|
||||||
|
|
||||||
|
- [`7ddfea9`](https://github.com/eslint/eslint/commit/7ddfea9c4f62add1588c5c0b0da568c299246383) chore: update dependency prettier to v3.8.2 ([#​20770](https://github.com/eslint/eslint/issues/20770)) (renovate\[bot])
|
||||||
|
- [`fac40e1`](https://github.com/eslint/eslint/commit/fac40e1de2ba7646cc7cd2d3f93fbdd1f8819001) ci: bump pnpm/action-setup from 5.0.0 to 6.0.0 ([#​20763](https://github.com/eslint/eslint/issues/20763)) (dependabot\[bot])
|
||||||
|
- [`7246f92`](https://github.com/eslint/eslint/commit/7246f923332522d8b3d46b6ee646fce88535f3fb) test: add tests for SuppressionsService.load() error handling ([#​20734](https://github.com/eslint/eslint/issues/20734)) (kuldeep kumar)
|
||||||
|
- [`4f34b1e`](https://github.com/eslint/eslint/commit/4f34b1e592b0f63d766d9903998e8e36eb49d3aa) chore: update pnpm/action-setup action to v5 ([#​20762](https://github.com/eslint/eslint/issues/20762)) (renovate\[bot])
|
||||||
|
- [`51080eb`](https://github.com/eslint/eslint/commit/51080eb5c98d619434e4835dbe9f1c6654aca3b8) test: processor service ([#​20731](https://github.com/eslint/eslint/issues/20731)) (kuldeep kumar)
|
||||||
|
- [`e7e1889`](https://github.com/eslint/eslint/commit/e7e1889fca9b6044e08f41b38df20a1ce45808c8) chore: remove stale babel-eslint10 fixture and test ([#​20727](https://github.com/eslint/eslint/issues/20727)) (kuldeep kumar)
|
||||||
|
- [`4e1a87c`](https://github.com/eslint/eslint/commit/4e1a87cb8fb90e309524bc36bc5f31b9f9cfaa76) test: remove redundant async/await in flat config array tests ([#​20722](https://github.com/eslint/eslint/issues/20722)) (Pixel998)
|
||||||
|
- [`066eabb`](https://github.com/eslint/eslint/commit/066eabb3643b12931f991594969bcc0028f71a5f) test: add rule metadata coverage for `languages` and `docs.dialects` ([#​20717](https://github.com/eslint/eslint/issues/20717)) (Pixel998)
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>NaturalIntelligence/fast-xml-parser (fast-xml-parser)</summary>
|
||||||
|
|
||||||
|
### [`v5.7.2`](https://github.com/NaturalIntelligence/fast-xml-parser/releases/tag/v5.7.2): backward compatibility for numerical external entity, fix #​705, #​817
|
||||||
|
|
||||||
|
[Compare Source](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.7.1...v5.7.2)
|
||||||
|
|
||||||
|
- allow numerical external entity for backward compatibility
|
||||||
|
- fix [#​705](https://github.com/NaturalIntelligence/fast-xml-parser/issues/705): attributesGroupName working with preserveOrder
|
||||||
|
- fix [#​817](https://github.com/NaturalIntelligence/fast-xml-parser/issues/817): stackoverflow when tag expression is very long
|
||||||
|
|
||||||
|
### [`v5.7.1`](https://github.com/NaturalIntelligence/fast-xml-parser/releases/tag/v5.7.1): upgrade @​nodable/entities and FXB
|
||||||
|
|
||||||
|
[Compare Source](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.7.0...v5.7.1)
|
||||||
|
|
||||||
|
- Use `@nodable/entities` v2.1.0
|
||||||
|
- breaking changes
|
||||||
|
- single entity scan. You're not allowed to use entity value to form another entity name.
|
||||||
|
- you cant add numeric external entity
|
||||||
|
- entity error message when expantion limit is crossed might change
|
||||||
|
- typings are updated for new options related to process entity
|
||||||
|
- please follow documentation of `@nodable/entities` for more detail.
|
||||||
|
- performance
|
||||||
|
- if processEntities is false, then there should not be impact on performance.
|
||||||
|
- if processEntities is true, but you dont pass entity decoder separately then performance may degrade by approx 8-10%
|
||||||
|
- if processEntities is true, and you pass entity decoder separately
|
||||||
|
- if no entity then performance should be same as before
|
||||||
|
- if there are entities then performance should be increased from past versions
|
||||||
|
- ignoreAttributes is not required to be set to set xml version for NCR entity value
|
||||||
|
- update 'fast-xml-builder' to sanitize malicious CDATA and comment's content
|
||||||
|
|
||||||
|
### [`v5.7.0`](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.6.0...v5.7.0)
|
||||||
|
|
||||||
|
[Compare Source](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.6.0...v5.7.0)
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>versatica/mediasoup (mediasoup)</summary>
|
||||||
|
|
||||||
|
### [`v3.19.21`](https://github.com/versatica/mediasoup/blob/HEAD/CHANGELOG.md#31921)
|
||||||
|
|
||||||
|
[Compare Source](https://github.com/versatica/mediasoup/compare/3.19.20...3.19.21)
|
||||||
|
|
||||||
|
- Worker: Fix regression in `DirectTransport` when closing a `DataProducer` or `DataConsumer` ([PR #​1780](https://github.com/versatica/mediasoup/pull/1780)).
|
||||||
|
|
||||||
|
### [`v3.19.20`](https://github.com/versatica/mediasoup/blob/HEAD/CHANGELOG.md#31920)
|
||||||
|
|
||||||
|
[Compare Source](https://github.com/versatica/mediasoup/compare/3.19.19...3.19.20)
|
||||||
|
|
||||||
|
- Worker: Add `useBuiltInSctpStack` setting (defaults to `false`) to enable mediasoup built-in SCTP stack ([PR #​1777](https://github.com/versatica/mediasoup/pull/1777)).
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>versatica/mediasoup-client (mediasoup-client)</summary>
|
||||||
|
|
||||||
|
### [`v3.19.0`](https://github.com/versatica/mediasoup-client/compare/3.18.8...3.19.0)
|
||||||
|
|
||||||
|
[Compare Source](https://github.com/versatica/mediasoup-client/compare/3.18.8...3.19.0)
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>vuejs/core (vue)</summary>
|
||||||
|
|
||||||
|
### [`v3.5.33`](https://github.com/vuejs/core/blob/HEAD/CHANGELOG.md#3533-2026-04-22)
|
||||||
|
|
||||||
|
[Compare Source](https://github.com/vuejs/core/compare/v3.5.32...v3.5.33)
|
||||||
|
|
||||||
|
##### Bug Fixes
|
||||||
|
|
||||||
|
- **compiler-sfc:** handle nested :deep in selector pseudos ([#​14725](https://github.com/vuejs/core/issues/14725)) ([bb9d265](https://github.com/vuejs/core/commit/bb9d265d8dcdde2af824fc01b24f9a7b3169f5fa)), closes [#​14724](https://github.com/vuejs/core/issues/14724)
|
||||||
|
- **reactivity:** unlink effect scopes on out-of-order off ([#​14734](https://github.com/vuejs/core/issues/14734)) ([e7659be](https://github.com/vuejs/core/commit/e7659beafc5407e892fa70f3f4ade80263b0905d)), closes [#​14733](https://github.com/vuejs/core/issues/14733)
|
||||||
|
- **runtime-dom:** preserve textarea resize dimensions ([#​14747](https://github.com/vuejs/core/issues/14747)) ([11fb2fd](https://github.com/vuejs/core/commit/11fb2fd4a246e40f6f350701dfea73ec525b4f59)), closes [#​14741](https://github.com/vuejs/core/issues/14741)
|
||||||
|
- **teleport:** don't move teleport children if not mounted ([#​14702](https://github.com/vuejs/core/issues/14702)) ([6a61f44](https://github.com/vuejs/core/commit/6a61f4452ba1a31fc929cadf8abe3337ac4d3a46)), closes [#​14701](https://github.com/vuejs/core/issues/14701)
|
||||||
|
- **transition:** preserve placeholder for conditional explicit default slots ([#​14748](https://github.com/vuejs/core/issues/14748)) ([45990ce](https://github.com/vuejs/core/commit/45990cecf4604b2f39c571ab6aefa49d362af36a)), closes [#​14727](https://github.com/vuejs/core/issues/14727)
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>vuejs/router (vue-router)</summary>
|
||||||
|
|
||||||
|
### [`v5.0.6`](https://github.com/vuejs/router/releases/tag/v5.0.6)
|
||||||
|
|
||||||
|
[Compare Source](https://github.com/vuejs/router/compare/v5.0.5...v5.0.6)
|
||||||
|
|
||||||
|
##### 🐞 Bug Fixes
|
||||||
|
|
||||||
|
- Missing closing quote in generated import - by [@​zjy040525](https://github.com/zjy040525) and [@​posva](https://github.com/posva) in [#​2688](https://github.com/vuejs/router/issues/2688) [<samp>(32f78)</samp>](https://github.com/vuejs/router/commit/32f78c77)
|
||||||
|
|
||||||
|
##### [View changes on GitHub](https://github.com/vuejs/router/compare/v5.0.5...v5.0.6)
|
||||||
|
|
||||||
|
### [`v5.0.5`](https://github.com/vuejs/router/releases/tag/v5.0.5)
|
||||||
|
|
||||||
|
[Compare Source](https://github.com/vuejs/router/compare/v5.0.4...v5.0.5)
|
||||||
|
|
||||||
|
##### 🚀 Features
|
||||||
|
|
||||||
|
- Enable standard schema param parsers - by [@​posva](https://github.com/posva) [<samp>(ea8e3)</samp>](https://github.com/vuejs/router/commit/ea8e3e21)
|
||||||
|
- Normalize param parsers once - by [@​posva](https://github.com/posva) [<samp>(48087)</samp>](https://github.com/vuejs/router/commit/480877cc)
|
||||||
|
|
||||||
|
##### 🐞 Bug Fixes
|
||||||
|
|
||||||
|
- Track definePage imports per-file to fix named view race condition - by [@​posva](https://github.com/posva) [<samp>(11191)</samp>](https://github.com/vuejs/router/commit/11191bca)
|
||||||
|
- Avoid double decoding hash on string location - by [@​posva](https://github.com/posva) [<samp>(1578c)</samp>](https://github.com/vuejs/router/commit/1578c9e9)
|
||||||
|
|
||||||
|
##### [View changes on GitHub](https://github.com/vuejs/router/compare/v5.0.4...v5.0.5)
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box
|
||||||
|
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xMzIuMyIsInVwZGF0ZWRJblZlciI6IjQzLjE1MC4wIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->
|
||||||
|
|
||||||
|
## [1.1.3] - 2026-04-19
|
||||||
|
### Changed
|
||||||
|
- update dependency mediasoup-client to v3.18.8 (#30)
|
||||||
|
|
||||||
|
This PR contains the following updates:
|
||||||
|
|
||||||
|
| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| [mediasoup-client](https://mediasoup.org) ([source](https://github.com/versatica/mediasoup-client)) | [`3.18.7` → `3.18.8`](https://renovatebot.com/diffs/npm/mediasoup-client/3.18.7/3.18.8) |  |  |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Release Notes
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>versatica/mediasoup-client (mediasoup-client)</summary>
|
||||||
|
|
||||||
|
### [`v3.18.8`](https://github.com/versatica/mediasoup-client/compare/3.18.7...3.18.8)
|
||||||
|
|
||||||
|
[Compare Source](https://github.com/versatica/mediasoup-client/compare/3.18.7...3.18.8)
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box
|
||||||
|
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xMjkuMCIsInVwZGF0ZWRJblZlciI6IjQzLjEyOS4wIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->
|
||||||
|
|
||||||
|
## [1.1.2] - 2026-04-15
|
||||||
|
### Changed
|
||||||
|
- Update dependency fast-xml-parser to v5.6.0 (#28)
|
||||||
|
|
||||||
|
This PR contains the following updates:
|
||||||
|
|
||||||
|
| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) | [`5.5.12` → `5.6.0`](https://renovatebot.com/diffs/npm/fast-xml-parser/5.5.12/5.6.0) |  |  |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Release Notes
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>NaturalIntelligence/fast-xml-parser (fast-xml-parser)</summary>
|
||||||
|
|
||||||
|
### [`v5.6.0`](https://github.com/NaturalIntelligence/fast-xml-parser/releases/tag/v5.6.0): use @​nodable/entities to replace entities
|
||||||
|
|
||||||
|
[Compare Source](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.5.12...v5.6.0)
|
||||||
|
|
||||||
|
- No API change
|
||||||
|
- No change in performance for basic usage
|
||||||
|
- No typing change
|
||||||
|
- No config change
|
||||||
|
- new dependency
|
||||||
|
- breaking: error messages for entities might have been changed.
|
||||||
|
-
|
||||||
|
|
||||||
|
**Full Changelog**: <https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.5.12...v5.6.0>
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
📅 **Schedule**: (UTC)
|
||||||
|
|
||||||
|
- Branch creation
|
||||||
|
- At any time (no schedule defined)
|
||||||
|
- Automerge
|
||||||
|
- At any time (no schedule defined)
|
||||||
|
|
||||||
|
🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.
|
||||||
|
|
||||||
|
♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.
|
||||||
|
|
||||||
|
🔕 **Ignore**: Close this PR and you won't be reminded about this update again.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
|
||||||
|
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xMjAuMSIsInVwZGF0ZWRJblZlciI6IjQzLjEyMC4xIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->
|
||||||
|
|
||||||
|
## [1.1.1] - 2026-04-15
|
||||||
|
### Changed
|
||||||
|
- split push release/publish and harden workflows (#27)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
* Separate release from Docker/Helm publish
|
||||||
|
* enrich releases with PRbodies when available
|
||||||
|
* tighten release.sh validation and idempotency
|
||||||
|
* trim PR docker-build metadata for act-runner stability
|
||||||
|
|
||||||
|
## [1.1.0] - 2026-04-15
|
||||||
|
### Changed
|
||||||
|
- Update all non-major dependencies (#25)
|
||||||
|
|
||||||
|
## [1.0.10] - 2026-04-15
|
||||||
|
### Changed
|
||||||
|
- Remove npm overrides for tar (#26)
|
||||||
|
|
||||||
|
## [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
|
## [0.2.0] - 2026-02-12
|
||||||
### Changed
|
### Changed
|
||||||
- add a new release system (#3)
|
- add a new release system (#3)
|
||||||
|
|||||||
+1
-2
@@ -16,11 +16,10 @@ 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
|
EXPOSE 3000 8089
|
||||||
|
|
||||||
CMD ["node", ".output/server/index.mjs"]
|
CMD ["node", ".output/server/index.mjs"]
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
- Nuxt 4, JavaScript, Tailwind CSS, ESLint, Vitest
|
- Nuxt 4, JavaScript, Tailwind CSS, ESLint, Vitest
|
||||||
@@ -34,7 +36,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 browser's "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.
|
||||||
|
|
||||||
@@ -48,31 +50,40 @@ 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 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.
|
||||||
|
|
||||||
See [docs/live-streaming.md](docs/live-streaming.md) for architecture details.
|
See [docs/live-streaming.md](docs/live-streaming.md) for setup and usage.
|
||||||
|
|
||||||
|
### ATAK / CoT (Cursor on Target)
|
||||||
|
|
||||||
|
KestrelOS can act as a **TAK Server** so ATAK and iTAK devices connect and share positions. No plugins: in ATAK, add a **Server** connection (host = KestrelOS, port **8089** for CoT). Check **Use Authentication** and enter your **KestrelOS username** and **password** (local users use their login password; OIDC users must set an **ATAK password** once under **Account** in the web app). Devices relay CoT to each other (team members see each other on the ATAK map) and appear on the KestrelOS web map; they drop off after ~90 seconds if no updates. 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 lint` – ESLint (zero warnings)
|
- `npm run test:e2e` - Playwright E2E tests
|
||||||
|
- `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`) or the Members/Cameras UI. Each device needs `name`, `device_type`, `lat`, `lng`, `stream_url`, and `source_type` (`mjpeg` or `hls`).
|
- **Devices**: Manage cameras/devices via the API (`/api/devices`); see [Map and cameras](docs/map-and-cameras.md). Each device needs `name`, `device_type`, `lat`, `lng`, `stream_url`, and `source_type` (`mjpeg` or `hls`).
|
||||||
- **Environment**: No required env vars for basic run. For production, set `HOST=0.0.0.0` and `PORT` as needed (e.g. in Docker/Helm).
|
- **Environment**: No required env vars for basic run. For production, set `HOST=0.0.0.0` and expose ports 3000 (web/API) and 8089 (CoT). For TLS use `.dev-certs/` or set `COT_SSL_CERT` and `COT_SSL_KEY`.
|
||||||
- **Authentication**: The login page always offers password sign-in (local). Optionally set `BOOTSTRAP_EMAIL` and `BOOTSTRAP_PASSWORD` before the first run to create the first admin; otherwise a default admin is created and its credentials are printed in the terminal. To also show an OIDC sign-in button, configure `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, and optionally `OIDC_LABEL`, `OIDC_REDIRECT_URI`. See [docs/auth.md](docs/auth.md) for provider-specific examples.
|
- **Authentication**: The login page always offers password sign-in (local). Optionally set `BOOTSTRAP_EMAIL` and `BOOTSTRAP_PASSWORD` before the first run to create the first admin; otherwise a default admin is created and its credentials are printed in the terminal. To also show an OIDC sign-in button, configure `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, and optionally `OIDC_LABEL`, `OIDC_REDIRECT_URI`. See [docs/auth.md](docs/auth.md) for local login, OIDC config, and sign up.
|
||||||
- **Bootstrap admin** (when using local auth): The server initializes the database and runs bootstrap at startup. On first run (no users in the database), it creates the first admin. If you set `BOOTSTRAP_EMAIL` and `BOOTSTRAP_PASSWORD` before starting, that account is created. If you don't set them, a default admin is created (identifier: `admin`) with a random password and the credentials are printed in the terminal—copy them and sign in at `/login`, then change the password or add users via Members. Use **Members** to change roles (admin, leader, member). Only admins can change roles; admins and leaders can edit POIs.
|
- **Bootstrap admin** (when using local auth): The server initializes the database and runs bootstrap at startup. On first run (no users in the database), it creates the first admin. If you set `BOOTSTRAP_EMAIL` and `BOOTSTRAP_PASSWORD` before starting, that account is created. If you don't set them, a default admin is created (identifier: `admin`) with a random password and the credentials are printed in the terminal-copy them and sign in at `/login`, then change the password or add users via Members. Use **Members** to change roles (admin, leader, member). Only admins can change roles; admins and leaders can edit POIs.
|
||||||
- **Database**: SQLite file at `data/kestrelos.db` (created automatically). Contains users, sessions, and POIs.
|
- **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 kestrelos:latest
|
docker run -p 3000:3000 -p 8089:8089 kestrelos:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
## Kubernetes (Helm)
|
## Kubernetes (Helm)
|
||||||
@@ -95,9 +106,9 @@ Health: `GET /health` (overview), `GET /health/live` (liveness), `GET /health/re
|
|||||||
|
|
||||||
Merges to `main` trigger a semver release. Use one of these prefixes in your PR title to set the version bump:
|
Merges to `main` trigger a semver release. Use one of these prefixes in your PR title to set the version bump:
|
||||||
|
|
||||||
- `major:` – breaking changes
|
- `major:` - breaking changes
|
||||||
- `minor:` – new features
|
- `minor:` - new features
|
||||||
- `patch:` – bug fixes, docs (default if no prefix)
|
- `patch:` - bug fixes, docs (default if no prefix)
|
||||||
|
|
||||||
Example: `minor: Add map layer toggle`
|
Example: `minor: Add map layer toggle`
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
@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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<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"
|
||||||
|
>☰</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>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<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>
|
||||||
@@ -7,18 +7,18 @@
|
|||||||
/>
|
/>
|
||||||
<aside
|
<aside
|
||||||
v-else
|
v-else
|
||||||
class="flex flex-col border border-kestrel-border bg-kestrel-surface"
|
class="kestrel-panel-base"
|
||||||
:class="asideClass"
|
:class="inline ? 'kestrel-panel-inline' : 'kestrel-panel-overlay'"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-label="Camera feed"
|
aria-label="Camera feed"
|
||||||
>
|
>
|
||||||
<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)]">
|
<div class="kestrel-panel-header">
|
||||||
<h2 class="font-medium tracking-wide text-kestrel-text [text-shadow:0_0_8px_rgba(34,201,201,0.25)]">
|
<h2 class="font-medium tracking-wide text-kestrel-text text-shadow-glow-sm">
|
||||||
{{ camera?.name ?? 'Camera' }}
|
{{ camera?.name ?? 'Camera' }}
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded p-1 text-kestrel-muted transition-colors hover:bg-kestrel-border hover:text-kestrel-accent"
|
class="kestrel-close-btn"
|
||||||
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="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)]">
|
<div class="kestrel-video-frame">
|
||||||
<template v-if="sourceType === 'hls'">
|
<template v-if="sourceType === 'hls'">
|
||||||
<video
|
<video
|
||||||
ref="videoRef"
|
ref="videoRef"
|
||||||
@@ -75,18 +75,14 @@ defineEmits(['close'])
|
|||||||
const videoRef = ref(null)
|
const videoRef = ref(null)
|
||||||
const streamError = ref(false)
|
const streamError = ref(false)
|
||||||
|
|
||||||
const isLiveSession = computed(() =>
|
const isLiveSession = computed(() => props.camera?.hasStream !== undefined)
|
||||||
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
|
const u = streamUrl.value?.trim()
|
||||||
return typeof u === 'string' && u.trim() && (u.startsWith('http://') || u.startsWith('https://')) ? u.trim() : ''
|
return (u?.startsWith('http://') || u?.startsWith('https://')) ? u : ''
|
||||||
})
|
})
|
||||||
|
|
||||||
function initHls() {
|
function initHls() {
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<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>
|
||||||
+191
-372
@@ -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 [box-shadow:0_0_20px_-4px_rgba(34,201,201,0.2)]"
|
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"
|
||||||
: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="block w-full px-3 py-1.5 text-left text-sm text-kestrel-text hover:bg-kestrel-border"
|
class="kestrel-context-menu-item"
|
||||||
@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="block w-full px-3 py-1.5 text-left text-sm text-kestrel-text hover:bg-kestrel-border"
|
class="kestrel-context-menu-item"
|
||||||
@click="openEditPoiModal(contextMenu.poi)"
|
@click="openEditPoiModal(contextMenu.poi)"
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="block w-full px-3 py-1.5 text-left text-sm text-red-400 hover:bg-kestrel-border"
|
class="kestrel-context-menu-item-danger"
|
||||||
@click="openDeletePoiModal(contextMenu.poi)"
|
@click="openDeletePoiModal(contextMenu.poi)"
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
@@ -37,181 +37,51 @@
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- POI modal (Add / Edit) -->
|
<PoiModal
|
||||||
<Teleport to="body">
|
:show="showPoiModal"
|
||||||
<Transition name="modal">
|
:mode="poiModalMode"
|
||||||
<div
|
:form="poiForm"
|
||||||
v-if="showPoiModal"
|
:edit-poi="editPoi"
|
||||||
class="fixed inset-0 z-[2000] flex items-center justify-center p-4"
|
:delete-poi="deletePoi"
|
||||||
role="dialog"
|
@close="closePoiModal"
|
||||||
aria-modal="true"
|
@submit="onPoiSubmit"
|
||||||
:aria-labelledby="poiModalMode === 'delete' ? 'delete-poi-title' : 'poi-modal-title'"
|
@confirm-delete="confirmDeletePoi"
|
||||||
@keydown.escape="closePoiModal"
|
/>
|
||||||
>
|
|
||||||
<button
|
<div
|
||||||
type="button"
|
v-if="mapContext"
|
||||||
class="absolute inset-0 bg-black/60 transition-opacity"
|
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"
|
||||||
aria-label="Close"
|
data-testid="cot-layer-toggles"
|
||||||
@click="closePoiModal"
|
>
|
||||||
/>
|
<button
|
||||||
<!-- Add / Edit form -->
|
v-for="layer in COT_LAYERS"
|
||||||
<div
|
:key="layer.key"
|
||||||
v-if="poiModalMode === 'add' || poiModalMode === 'edit'"
|
type="button"
|
||||||
ref="poiModalRef"
|
class="kestrel-cot-layer-btn"
|
||||||
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)]"
|
:class="{ 'kestrel-cot-layer-btn-active': cotLayers[layer.key] }"
|
||||||
@click.stop
|
@click="emit('toggleCotLayer', layer.key)"
|
||||||
>
|
>
|
||||||
<h2
|
{{ layer.label }}
|
||||||
id="poi-modal-title"
|
</button>
|
||||||
class="mb-4 text-lg font-semibold tracking-wide text-kestrel-text [text-shadow:0_0_8px_rgba(34,201,201,0.25)]"
|
</div>
|
||||||
>
|
|
||||||
{{ 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>
|
|
||||||
</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({
|
||||||
devices: {
|
devices: {
|
||||||
@@ -226,13 +96,29 @@ 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'])
|
const emit = defineEmits(['select', 'selectLive', 'refreshPois', 'boundsChange', 'toggleAlpr', 'toggleCotLayer'])
|
||||||
const CONTEXT_MENU_EMPTY = Object.freeze({ type: null, poi: null, latlng: null, x: 0, y: 0 })
|
const 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)
|
||||||
@@ -241,17 +127,18 @@ 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 iconDropdownOpen = ref(false)
|
const resizeObserver = ref(null)
|
||||||
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'
|
||||||
@@ -259,12 +146,13 @@ const ATTRIBUTION = '© <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
|
||||||
|
|
||||||
@@ -279,8 +167,9 @@ 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 = poi.icon_type === 'pin' || poi.icon_type === 'flag' || poi.icon_type === 'waypoint' ? poi.icon_type : 'pin'
|
const type = VALID_POI_TYPES.includes(poi.icon_type) ? 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',
|
||||||
@@ -290,7 +179,7 @@ function getPoiIcon(L, poi) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const LIVE_ICON_COLOR = '#22c9c9'
|
const LIVE_ICON_COLOR = '#22c9c9' /* kestrel-accent - JS string for Leaflet SVG */
|
||||||
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({
|
||||||
@@ -301,6 +190,44 @@ 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
|
||||||
@@ -309,7 +236,7 @@ function createMap(initialCenter) {
|
|||||||
? initialCenter
|
? initialCenter
|
||||||
: DEFAULT_VIEW
|
: DEFAULT_VIEW
|
||||||
|
|
||||||
const map = L.map(mapRef.value, { zoomControl: false, attributionControl: false }).setView(center, DEFAULT_ZOOM)
|
const map = L.map(mapRef.value, { zoomControl: false, attributionControl: false, minZoom: 1, maxZoom: 19 }).setView(center, DEFAULT_ZOOM)
|
||||||
L.control.zoom({ position: 'topleft' }).addTo(map)
|
L.control.zoom({ position: 'topleft' }).addTo(map)
|
||||||
|
|
||||||
const locateControl = L.control({ position: 'topleft' })
|
const locateControl = L.control({ position: 'topleft' })
|
||||||
@@ -335,6 +262,14 @@ 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,
|
||||||
@@ -363,10 +298,28 @@ 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 }
|
||||||
})
|
})
|
||||||
|
|
||||||
mapContext.value = { map, layer: baseLayer, control, locateControl }
|
map.on('moveend', () => {
|
||||||
|
emitBounds(map)
|
||||||
|
renderCotLayer()
|
||||||
|
})
|
||||||
|
map.on('zoomend', () => {
|
||||||
|
emitBounds(map)
|
||||||
|
renderCotLayer()
|
||||||
|
})
|
||||||
|
|
||||||
|
mapContext.value = { map, layer: baseLayer, control, locateControl, alprControl }
|
||||||
|
alprLayerRef.value = alprLayer
|
||||||
|
cotLayerRef.value = cotLayer
|
||||||
|
refreshCotMapView(map)
|
||||||
updateMarkers()
|
updateMarkers()
|
||||||
updatePoiMarkers()
|
updatePoiMarkers()
|
||||||
updateLiveMarkers()
|
updateLiveMarkers()
|
||||||
|
reloadCotCluster()
|
||||||
|
updateAlprLayer()
|
||||||
|
nextTick(() => {
|
||||||
|
map.invalidateSize()
|
||||||
|
emitBounds(map)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateMarkers() {
|
function updateMarkers() {
|
||||||
@@ -439,7 +392,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 style="color:#22c9c9">● Live</span>' : ''}</div>`
|
const content = `<div class="kestrel-live-popup"><strong>${escapeHtml(session.label)}</strong>${session.hasStream ? ' <span class="text-kestrel-accent">● 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])
|
||||||
@@ -456,6 +409,18 @@ 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
|
||||||
@@ -473,7 +438,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -484,7 +448,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -500,52 +463,38 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPoiModalDocumentClick(e) {
|
async function doPoiFetch(fn) {
|
||||||
if (!showPoiModal.value) return
|
try {
|
||||||
if (iconDropdownOpen.value && iconDropdownRef.value && !iconDropdownRef.value.contains(e.target)) {
|
await fn()
|
||||||
iconDropdownOpen.value = false
|
emit('refreshPois')
|
||||||
|
closePoiModal()
|
||||||
}
|
}
|
||||||
|
catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitPoiModal() {
|
async function onPoiSubmit(payload) {
|
||||||
|
const { label, iconType } = payload
|
||||||
|
const body = { label: (label ?? '').trim(), iconType: iconType || 'pin' }
|
||||||
if (poiModalMode.value === 'add') {
|
if (poiModalMode.value === 'add') {
|
||||||
const latlng = addPoiLatlng.value
|
const latlng = addPoiLatlng.value
|
||||||
if (!latlng) return
|
if (!latlng) return
|
||||||
const { label, iconType } = poiForm.value
|
await doPoiFetch(() => $fetch('/api/pois', { method: 'POST', body: { ...body, lat: latlng.lat, lng: latlng.lng } }))
|
||||||
try {
|
|
||||||
await $fetch('/api/pois', { method: 'POST', body: { lat: latlng.lat, lng: latlng.lng, label: (label ?? '').trim(), iconType: iconType || 'pin' } })
|
|
||||||
emit('refreshPois')
|
|
||||||
closePoiModal()
|
|
||||||
}
|
|
||||||
catch { /* ignore */ }
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (poiModalMode.value === 'edit' && editPoi.value) {
|
if (poiModalMode.value === 'edit' && editPoi.value) {
|
||||||
const { label, iconType } = poiForm.value
|
await doPoiFetch(() => $fetch(`/api/pois/${editPoi.value.id}`, { method: 'PATCH', body }))
|
||||||
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
|
||||||
try {
|
await doPoiFetch(() => $fetch(`/api/pois/${poi.id}`, { method: 'DELETE' }))
|
||||||
await $fetch(`/api/pois/${poi.id}`, { method: 'DELETE' })
|
|
||||||
emit('refreshPois')
|
|
||||||
closePoiModal()
|
|
||||||
}
|
|
||||||
catch { /* ignore */ }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function destroyMap() {
|
function destroyMap() {
|
||||||
@@ -557,15 +506,22 @@ 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() {
|
||||||
@@ -604,7 +560,15 @@ 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) {
|
||||||
@@ -613,166 +577,21 @@ function onDocumentClick(e) {
|
|||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
document.removeEventListener('click', onDocumentClick)
|
document.removeEventListener('click', onDocumentClick)
|
||||||
document.removeEventListener('click', onPoiModalDocumentClick)
|
if (resizeObserver.value && mapRef.value) {
|
||||||
|
resizeObserver.value.disconnect()
|
||||||
|
resizeObserver.value = null
|
||||||
|
}
|
||||||
destroyMap()
|
destroyMap()
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(() => props.devices, () => updateMarkers(), { deep: true })
|
watch(() => 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 Leaflet’s plus-lighter so unloaded/empty tiles don’t 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>
|
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<aside
|
<aside
|
||||||
class="flex flex-col border border-kestrel-border bg-kestrel-surface"
|
class="kestrel-panel-base"
|
||||||
: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)]'"
|
:class="inline ? 'kestrel-panel-inline' : 'kestrel-panel-overlay'"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-label="Live feed"
|
aria-label="Live feed"
|
||||||
>
|
>
|
||||||
<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)]">
|
<div class="kestrel-panel-header">
|
||||||
<h2 class="font-medium tracking-wide text-kestrel-text [text-shadow:0_0_8px_rgba(34,201,201,0.25)]">
|
<h2 class="font-medium tracking-wide text-kestrel-text text-shadow-glow-sm">
|
||||||
{{ session?.label ?? 'Live' }}
|
{{ session?.label ?? 'Live' }}
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded p-1 text-kestrel-muted transition-colors hover:bg-kestrel-border hover:text-kestrel-accent"
|
class="kestrel-close-btn"
|
||||||
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="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)]">
|
<div class="kestrel-video-frame">
|
||||||
<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 40000-49999 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 40000-49999.</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 }
|
||||||
let device = null
|
const device = ref(null)
|
||||||
let recvTransport = null
|
const recvTransport = ref(null)
|
||||||
let consumer = null
|
const consumer = ref(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 = await createMediasoupDevice(rtpCapabilities)
|
device.value = await createMediasoupDevice(rtpCapabilities)
|
||||||
recvTransport = await createRecvTransport(device, props.session.id)
|
recvTransport.value = await createRecvTransport(device.value, props.session.id)
|
||||||
|
|
||||||
recvTransport.on('connectionstatechange', () => {
|
recvTransport.value.on('connectionstatechange', () => {
|
||||||
const state = recvTransport.connectionState
|
const state = recvTransport.value.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.id,
|
transportId: recvTransport.value.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, 10000)
|
const connectionPromise = waitForConnectionState(recvTransport.value, 10000)
|
||||||
consumer = await consumeProducer(recvTransport, device, props.session.id)
|
consumer.value = await consumeProducer(recvTransport.value, device.value, 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.id,
|
transportId: recvTransport.value.id,
|
||||||
consumerId: consumer.id,
|
consumerId: consumer.value.id,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
@@ -182,14 +182,14 @@ async function setupWebRTC() {
|
|||||||
attempts++
|
attempts++
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!consumer.track) {
|
if (!consumer.value.track) {
|
||||||
logError('LiveSessionPanel: No video track available', {
|
logError('LiveSessionPanel: No video track available', {
|
||||||
consumerId: consumer.id,
|
consumerId: consumer.value.id,
|
||||||
consumerKind: consumer.kind,
|
consumerKind: consumer.value.kind,
|
||||||
consumerPaused: consumer.paused,
|
consumerPaused: consumer.value.paused,
|
||||||
consumerClosed: consumer.closed,
|
consumerClosed: consumer.value.closed,
|
||||||
consumerProducerId: consumer.producerId,
|
consumerProducerId: consumer.value.producerId,
|
||||||
transportConnectionState: recvTransport?.connectionState,
|
transportConnectionState: recvTransport.value?.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.id,
|
consumerId: consumer.value.id,
|
||||||
hasTrack: !!consumer.track,
|
hasTrack: !!consumer.value.track,
|
||||||
})
|
})
|
||||||
error.value = 'Video element not available'
|
error.value = 'Video element not available'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const stream = new MediaStream([consumer.track])
|
const stream = new MediaStream([consumer.value.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.id })
|
logWarn('LiveSessionPanel: Video metadata timeout', { consumerId: consumer.value.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.id,
|
consumerId: consumer.value.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.track.addEventListener('ended', () => {
|
consumer.value.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.id })
|
logError('LiveSessionPanel: Video element error', { consumerId: consumer.value.id })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
@@ -274,15 +274,15 @@ async function setupWebRTC() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function cleanup() {
|
function cleanup() {
|
||||||
if (consumer) {
|
if (consumer.value) {
|
||||||
consumer.close()
|
consumer.value.close()
|
||||||
consumer = null
|
consumer.value = null
|
||||||
}
|
}
|
||||||
if (recvTransport) {
|
if (recvTransport.value) {
|
||||||
recvTransport.close()
|
recvTransport.value.close()
|
||||||
recvTransport = null
|
recvTransport.value = null
|
||||||
}
|
}
|
||||||
device = null
|
device.value = 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) {
|
if (hasStream && props.session?.id && !device.value) {
|
||||||
setupWebRTC()
|
setupWebRTC()
|
||||||
}
|
}
|
||||||
else if (!hasStream) {
|
else if (!hasStream) {
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
<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>
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<Teleport to="body">
|
<div class="flex h-full shrink-0">
|
||||||
<Transition name="drawer-backdrop">
|
<Transition name="drawer-backdrop">
|
||||||
<button
|
<button
|
||||||
v-if="modelValue"
|
v-if="isMobile && 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,28 +10,29 @@
|
|||||||
/>
|
/>
|
||||||
</Transition>
|
</Transition>
|
||||||
<aside
|
<aside
|
||||||
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="nav-drawer flex h-full flex-col bg-kestrel-surface transition-[width] duration-200 ease-out md:relative md:translate-x-0"
|
||||||
:class="{ '-translate-x-full': !modelValue }"
|
:class="[
|
||||||
|
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
|
||||||
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)]"
|
v-if="isMounted && isMobile"
|
||||||
|
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="rounded p-1 text-kestrel-muted transition-colors hover:bg-kestrel-border hover:text-kestrel-accent"
|
class="kestrel-close-btn"
|
||||||
aria-label="Close navigation"
|
aria-label="Close navigation"
|
||||||
@click="close"
|
@click="close"
|
||||||
>
|
>
|
||||||
<span class="text-xl leading-none">×</span>
|
<span class="text-xl leading-none">×</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<nav class="flex-1 overflow-auto py-2">
|
<nav class="flex-1 overflow-auto bg-kestrel-surface 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"
|
||||||
@@ -39,50 +40,91 @@
|
|||||||
>
|
>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
:to="item.to"
|
:to="item.to"
|
||||||
class="block rounded px-3 py-2 text-sm transition-colors"
|
class="flex items-center gap-3 rounded px-3 py-2 text-sm transition-colors"
|
||||||
:class="isActive(item.to)
|
:class="[
|
||||||
? '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)]'
|
showCollapsed ? 'justify-center px-2' : '',
|
||||||
: 'border-l-2 border-transparent text-kestrel-muted hover:bg-kestrel-border hover:text-kestrel-text'"
|
isActive(item.to)
|
||||||
@click="close"
|
? 'bg-kestrel-surface-hover font-medium text-kestrel-accent text-shadow-glow-sm'
|
||||||
|
: '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"
|
||||||
>
|
>
|
||||||
{{ item.label }}
|
<Icon
|
||||||
|
: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>
|
||||||
</Teleport>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
defineProps({
|
const props = defineProps({
|
||||||
modelValue: {
|
modelValue: { type: Boolean, default: false },
|
||||||
type: Boolean,
|
collapsed: { type: Boolean, default: false },
|
||||||
default: false,
|
isMobile: { type: Boolean, default: true },
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue'])
|
const emit = defineEmits(['update:modelValue', 'update:collapsed'])
|
||||||
|
|
||||||
|
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(() => {
|
||||||
const items = [
|
if (!canEditPois.value) return NAV_ITEMS
|
||||||
{ to: '/', label: 'Map' },
|
const list = [...NAV_ITEMS]
|
||||||
{ to: '/account', label: 'Account' },
|
list.splice(3, 0, SHARE_LIVE_ITEM)
|
||||||
{ to: '/cameras', label: 'Cameras' },
|
return list
|
||||||
{ 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 isActive = to => to === '/' ? route.path === '/' : route.path.startsWith(to)
|
const showCollapsed = computed(() => props.collapsed && !props.isMobile)
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -95,6 +137,7 @@ function onEscape(e) {
|
|||||||
defineExpose({ close })
|
defineExpose({ close })
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
isMounted.value = true
|
||||||
document.addEventListener('keydown', onEscape)
|
document.addEventListener('keydown', onEscape)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -102,24 +145,3 @@ 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>
|
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { createClusterIndex } from '~/utils/mapCluster.js'
|
||||||
|
import {
|
||||||
|
MAX_BBOX_DEGREES,
|
||||||
|
bboxFetchKey,
|
||||||
|
bboxToTileKey,
|
||||||
|
tileKey,
|
||||||
|
tilesNearCenter,
|
||||||
|
} from '~/utils/alprViewport.js'
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'kestrelos:showAlprLayer'
|
||||||
|
const MIN_FETCH_ZOOM = 10
|
||||||
|
const MAX_TILE_FETCHES = 16
|
||||||
|
const MAX_CACHED_TILES = 64
|
||||||
|
const TILE_RETENTION = 3
|
||||||
|
const EMPTY_TILES = Object.freeze({})
|
||||||
|
|
||||||
|
const mergeFeatures = lists => Object.values(
|
||||||
|
lists.flat().reduce((acc, feature) => {
|
||||||
|
const id = feature.id ?? feature.properties?.osmId
|
||||||
|
return id == null ? acc : { ...acc, [id]: feature }
|
||||||
|
}, {}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const offsets = radius => Array.from({ length: 2 * radius + 1 }, (_, i) => i - radius)
|
||||||
|
|
||||||
|
const retentionKeys = bounds => new Set(
|
||||||
|
tilesNearCenter(bounds, MAX_TILE_FETCHES).flatMap((tile) => {
|
||||||
|
const r0 = Math.floor(tile.south / MAX_BBOX_DEGREES)
|
||||||
|
const c0 = Math.floor(tile.west / MAX_BBOX_DEGREES)
|
||||||
|
return offsets(TILE_RETENTION).flatMap(dr =>
|
||||||
|
offsets(TILE_RETENTION).map(dc => tileKey(r0 + dr, c0 + dc)),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const pruneTileCache = (cache, bounds) => {
|
||||||
|
const keep = retentionKeys(bounds)
|
||||||
|
const retained = Object.fromEntries(
|
||||||
|
Object.entries(cache).filter(([key]) => keep.has(key)),
|
||||||
|
)
|
||||||
|
const overflow = Object.keys(retained).length - MAX_CACHED_TILES
|
||||||
|
if (overflow <= 0) return Object.freeze(retained)
|
||||||
|
|
||||||
|
const cr = Math.floor((bounds.south + bounds.north) / 2 / MAX_BBOX_DEGREES)
|
||||||
|
const cc = Math.floor((bounds.west + bounds.east) / 2 / MAX_BBOX_DEGREES)
|
||||||
|
const drop = new Set(
|
||||||
|
Object.keys(retained)
|
||||||
|
.map((key) => {
|
||||||
|
const [r, c] = key.split(',').map(Number)
|
||||||
|
return { key, dist: Math.hypot(r - cr, c - cc) }
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.dist - a.dist)
|
||||||
|
.slice(0, overflow)
|
||||||
|
.map(({ key }) => key),
|
||||||
|
)
|
||||||
|
return Object.freeze(
|
||||||
|
Object.fromEntries(Object.entries(retained).filter(([key]) => !drop.has(key))),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheChanged = (before, after) => Object.keys(before).length !== Object.keys(after).length
|
||||||
|
|
||||||
|
export function useAlprCameras() {
|
||||||
|
const showAlpr = useState('showAlprLayer', () => {
|
||||||
|
if (!import.meta.client) return true
|
||||||
|
return localStorage.getItem(STORAGE_KEY) !== '0'
|
||||||
|
})
|
||||||
|
const view = ref(null)
|
||||||
|
const tiles = ref(EMPTY_TILES)
|
||||||
|
const cluster = createClusterIndex({ radius: 50, maxZoom: 17 })
|
||||||
|
const debounceTimer = ref(null)
|
||||||
|
const requestId = ref(0)
|
||||||
|
const lastFetchKey = ref('')
|
||||||
|
|
||||||
|
const applyTiles = (next) => {
|
||||||
|
tiles.value = next
|
||||||
|
cluster.load(mergeFeatures(Object.values(next)))
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(showAlpr, (enabled) => {
|
||||||
|
if (import.meta.client) localStorage.setItem(STORAGE_KEY, enabled ? '1' : '0')
|
||||||
|
if (!enabled) {
|
||||||
|
applyTiles(EMPTY_TILES)
|
||||||
|
view.value = null
|
||||||
|
lastFetchKey.value = ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const alprMarkers = computed(() => view.value ? cluster.query(view.value) : [])
|
||||||
|
|
||||||
|
const toggleAlpr = () => {
|
||||||
|
showAlpr.value = !showAlpr.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const onBoundsChange = (bounds) => {
|
||||||
|
if (!showAlpr.value || !bounds) return
|
||||||
|
view.value = bounds
|
||||||
|
|
||||||
|
const pruned = pruneTileCache(tiles.value, bounds)
|
||||||
|
if (cacheChanged(tiles.value, pruned)) applyTiles(pruned)
|
||||||
|
|
||||||
|
if ((bounds.zoom ?? 14) < MIN_FETCH_ZOOM) return
|
||||||
|
|
||||||
|
const key = bboxFetchKey(bounds)
|
||||||
|
if (key === lastFetchKey.value) return
|
||||||
|
if (debounceTimer.value) clearTimeout(debounceTimer.value)
|
||||||
|
debounceTimer.value = setTimeout(() => {
|
||||||
|
lastFetchKey.value = key
|
||||||
|
fetchViewport(bounds)
|
||||||
|
}, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchViewport = async (bounds) => {
|
||||||
|
const id = requestId.value + 1
|
||||||
|
requestId.value = id
|
||||||
|
const missing = tilesNearCenter(bounds, MAX_TILE_FETCHES)
|
||||||
|
.filter(tile => !(bboxToTileKey(tile) in tiles.value))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fetched = missing.length
|
||||||
|
? await Promise.all(missing.map(async (tile) => {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
south: String(tile.south),
|
||||||
|
west: String(tile.west),
|
||||||
|
north: String(tile.north),
|
||||||
|
east: String(tile.east),
|
||||||
|
})
|
||||||
|
const data = await $fetch(`/api/alpr?${params}`).catch(() => null)
|
||||||
|
return [bboxToTileKey(tile), data?.features ?? []]
|
||||||
|
}))
|
||||||
|
: []
|
||||||
|
|
||||||
|
if (id !== requestId.value) return
|
||||||
|
|
||||||
|
const merged = Object.freeze({
|
||||||
|
...tiles.value,
|
||||||
|
...Object.fromEntries(fetched),
|
||||||
|
})
|
||||||
|
const pruned = pruneTileCache(merged, bounds)
|
||||||
|
applyTiles(pruned)
|
||||||
|
}
|
||||||
|
catch { /* keep last good index */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.freeze({ showAlpr, toggleAlpr, alprMarkers, onBoundsChange })
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/** 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 },
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,16 +1,19 @@
|
|||||||
/**
|
/** 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(() => ({ devices: [], liveSessions: [] })),
|
() => $fetch('/api/cameras').catch(() => EMPTY_RESPONSE),
|
||||||
{ default: () => ({ devices: [], liveSessions: [] }) },
|
{ default: () => EMPTY_RESPONSE },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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
|
||||||
@@ -27,22 +30,11 @@ export function useCameras(options = {}) {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (typeof document === 'undefined') return
|
if (typeof document === 'undefined') return
|
||||||
document.addEventListener('visibilitychange', () => {
|
document.addEventListener('visibilitychange', () => {
|
||||||
if (document.visibilityState === 'visible') {
|
document.visibilityState === 'visible' ? (startPolling(), refresh()) : stopPolling()
|
||||||
startPolling()
|
|
||||||
refresh()
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
stopPolling()
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
if (document.visibilityState === 'visible') startPolling()
|
if (document.visibilityState === 'visible') startPolling()
|
||||||
})
|
})
|
||||||
onBeforeUnmount(stopPolling)
|
onBeforeUnmount(stopPolling)
|
||||||
|
|
||||||
const devices = computed(() => data.value?.devices ?? [])
|
return Object.freeze({ data, devices, liveSessions, cameras, refresh, startPolling, stopPolling })
|
||||||
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 }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
const STORAGE_KEY = 'kestrel-cot-layers'
|
||||||
|
|
||||||
|
const DEFAULT_LAYERS = Object.freeze({ air: true, surface: true, ground: true })
|
||||||
|
|
||||||
|
function loadLayers() {
|
||||||
|
if (typeof localStorage === 'undefined') return { ...DEFAULT_LAYERS }
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (!raw) return { ...DEFAULT_LAYERS }
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
return {
|
||||||
|
air: parsed.air !== false,
|
||||||
|
surface: parsed.surface !== false,
|
||||||
|
ground: parsed.ground !== false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return { ...DEFAULT_LAYERS }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveLayers(layers) {
|
||||||
|
if (typeof localStorage === 'undefined') return
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(layers))
|
||||||
|
}
|
||||||
|
catch { /* ignore quota */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCotLayers() {
|
||||||
|
const layers = ref(loadLayers())
|
||||||
|
|
||||||
|
const layerQuery = computed(() => {
|
||||||
|
const parts = []
|
||||||
|
if (layers.value.air) parts.push('air')
|
||||||
|
if (layers.value.surface) parts.push('surface')
|
||||||
|
if (layers.value.ground) parts.push('ground')
|
||||||
|
return parts.length ? parts.join(',') : 'none'
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggleLayer(name) {
|
||||||
|
if (!(name in DEFAULT_LAYERS)) return
|
||||||
|
layers.value = { ...layers.value, [name]: !layers.value[name] }
|
||||||
|
saveLayers(layers.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.freeze({ layers, layerQuery, toggleLayer })
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { bboxFetchKey } from '~/utils/alprViewport.js'
|
||||||
|
|
||||||
|
const DEBOUNCE_MS = 300
|
||||||
|
const EMPTY_ENTITIES = Object.freeze({})
|
||||||
|
|
||||||
|
const expandBounds = (bounds, factor = 0.25) => {
|
||||||
|
const latPad = (bounds.north - bounds.south) * factor
|
||||||
|
const lngPad = (bounds.east - bounds.west) * factor
|
||||||
|
return {
|
||||||
|
south: Math.max(-90, bounds.south - latPad),
|
||||||
|
north: Math.min(90, bounds.north + latPad),
|
||||||
|
west: bounds.west - lngPad,
|
||||||
|
east: bounds.east + lngPad,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const entitiesFromList = list => Object.freeze(
|
||||||
|
Object.fromEntries(
|
||||||
|
(list ?? []).filter(entity => entity?.id).map(entity => [entity.id, entity]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
export function useCotStream(boundsRef, layerQueryRef) {
|
||||||
|
const entities = ref(EMPTY_ENTITIES)
|
||||||
|
const cotEntities = computed(() => Object.freeze(Object.values(entities.value)))
|
||||||
|
const eventSource = ref(null)
|
||||||
|
const debounceTimer = ref(null)
|
||||||
|
const subscribedKey = ref('')
|
||||||
|
const streamBounds = ref(null)
|
||||||
|
|
||||||
|
const setEntities = (record) => {
|
||||||
|
entities.value = Object.freeze(record)
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseEvent = (e, fn) => {
|
||||||
|
try {
|
||||||
|
fn(JSON.parse(e.data))
|
||||||
|
}
|
||||||
|
catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeStream = () => {
|
||||||
|
eventSource.value?.close()
|
||||||
|
eventSource.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const connect = () => {
|
||||||
|
if (typeof window === 'undefined' || typeof EventSource === 'undefined') return
|
||||||
|
const bounds = streamBounds.value ?? boundsRef.value
|
||||||
|
if (!bounds) return
|
||||||
|
|
||||||
|
closeStream()
|
||||||
|
const q = new URLSearchParams({
|
||||||
|
bbox: `${bounds.west},${bounds.south},${bounds.east},${bounds.north}`,
|
||||||
|
layers: unref(layerQueryRef) || 'air,surface,ground',
|
||||||
|
})
|
||||||
|
const es = new EventSource(`/api/cot/stream?${q}`)
|
||||||
|
eventSource.value = es
|
||||||
|
|
||||||
|
es.addEventListener('snapshot', e => parseEvent(e, ({ entities: list }) => {
|
||||||
|
setEntities(entitiesFromList(list))
|
||||||
|
}))
|
||||||
|
es.addEventListener('update', e => parseEvent(e, ({ entity }) => {
|
||||||
|
if (!entity?.id) return
|
||||||
|
setEntities({ ...entities.value, [entity.id]: entity })
|
||||||
|
}))
|
||||||
|
es.addEventListener('remove', e => parseEvent(e, ({ id }) => {
|
||||||
|
if (!id) return
|
||||||
|
setEntities(Object.fromEntries(
|
||||||
|
Object.entries(entities.value).filter(([key]) => key !== String(id)),
|
||||||
|
))
|
||||||
|
}))
|
||||||
|
es.onerror = () => {
|
||||||
|
closeStream()
|
||||||
|
scheduleConnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduleConnect = () => {
|
||||||
|
if (debounceTimer.value) clearTimeout(debounceTimer.value)
|
||||||
|
debounceTimer.value = setTimeout(() => {
|
||||||
|
debounceTimer.value = null
|
||||||
|
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return
|
||||||
|
connect()
|
||||||
|
}, DEBOUNCE_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
const maybeReconnect = (bounds) => {
|
||||||
|
if (!bounds) return
|
||||||
|
const key = bboxFetchKey(bounds)
|
||||||
|
if (key === subscribedKey.value) return
|
||||||
|
subscribedKey.value = key
|
||||||
|
streamBounds.value = expandBounds(bounds)
|
||||||
|
scheduleConnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(boundsRef, maybeReconnect, { deep: true })
|
||||||
|
watch(layerQueryRef, () => {
|
||||||
|
subscribedKey.value = ''
|
||||||
|
scheduleConnect()
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (typeof document === 'undefined') return
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
document.visibilityState === 'visible' ? scheduleConnect() : closeStream()
|
||||||
|
})
|
||||||
|
if (document.visibilityState === 'visible') maybeReconnect(boundsRef.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (debounceTimer.value) clearTimeout(debounceTimer.value)
|
||||||
|
closeStream()
|
||||||
|
})
|
||||||
|
|
||||||
|
return Object.freeze({ cotEntities })
|
||||||
|
}
|
||||||
@@ -1,24 +1,12 @@
|
|||||||
/**
|
/** 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 {
|
||||||
const result = await $fetch('/api/live')
|
return 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)
|
||||||
@@ -30,14 +18,13 @@ 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
|
refresh()
|
||||||
pollInterval.value = setInterval(() => {
|
pollInterval.value = setInterval(refresh, POLL_MS)
|
||||||
refresh()
|
|
||||||
}, POLL_MS)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopPolling() {
|
function stopPolling() {
|
||||||
@@ -49,21 +36,12 @@ 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', () => {
|
||||||
if (document.visibilityState === 'visible') {
|
document.visibilityState === 'visible' ? (startPolling(), refresh()) : stopPolling()
|
||||||
onFocus()
|
|
||||||
refresh() // Fresh data when returning to tab
|
|
||||||
}
|
|
||||||
else onBlur()
|
|
||||||
})
|
})
|
||||||
if (document.visibilityState === 'visible') startPolling()
|
if (document.visibilityState === 'visible') startPolling()
|
||||||
})
|
})
|
||||||
|
onBeforeUnmount(stopPolling)
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
return Object.freeze({ sessions, refresh, startPolling, stopPolling })
|
||||||
stopPolling()
|
|
||||||
})
|
|
||||||
|
|
||||||
return { sessions, refresh, startPolling, stopPolling }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* 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
|
||||||
|
}
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
|
const EDIT_ROLES = Object.freeze(['admin', 'leader'])
|
||||||
|
|
||||||
export function useUser() {
|
export function useUser() {
|
||||||
const requestFetch = useRequestFetch()
|
const requestFetch = useRequestFetch()
|
||||||
const { data: user, refresh } = useAsyncData(
|
const { data: user, refresh, status } = useAsyncData(
|
||||||
'user',
|
'user',
|
||||||
() => (requestFetch ?? $fetch)('/api/me').catch(() => null),
|
() => (requestFetch ?? $fetch)('/api/me').catch(() => null),
|
||||||
{ default: () => null },
|
{ default: () => null },
|
||||||
)
|
)
|
||||||
const canEditPois = computed(() => user.value?.role === 'admin' || user.value?.role === 'leader')
|
const authPending = computed(() => status.value === 'pending')
|
||||||
|
const canEditPois = computed(() => EDIT_ROLES.includes(user.value?.role))
|
||||||
const isAdmin = computed(() => user.value?.role === 'admin')
|
const isAdmin = computed(() => user.value?.role === 'admin')
|
||||||
return { user, canEditPois, isAdmin, refresh }
|
return Object.freeze({ user, authPending, canEditPois, isAdmin, refresh })
|
||||||
}
|
}
|
||||||
|
|||||||
+36
-144
@@ -1,61 +1,26 @@
|
|||||||
/**
|
/** 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) {
|
|
||||||
// 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
|
export async function createMediasoupDevice(rtpCapabilities) {
|
||||||
|
if (typeof window === 'undefined') throw new TypeError('Mediasoup device can only be created in browser')
|
||||||
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.onopen = () => {
|
ws.onerror = () => reject(new Error('WebSocket connection failed'))
|
||||||
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) {
|
||||||
@@ -95,41 +60,20 @@ 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 },
|
||||||
credentials: 'include',
|
...FETCH_OPTS,
|
||||||
})
|
})
|
||||||
onConnectSuccess?.()
|
onConnectSuccess?.()
|
||||||
callback()
|
callback()
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
logError('useWebRTC: Send transport connect failed', {
|
logError(`useWebRTC: ${label} 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,
|
||||||
@@ -138,48 +82,50 @@ export async function createSendTransport(device, sessionId, options = {}) {
|
|||||||
errback(err)
|
errback(err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
transport.on('connectionstatechange', () => {
|
transport.on('connectionstatechange', () => {
|
||||||
const state = transport.connectionState
|
const state = transport.connectionState
|
||||||
if (state === 'failed' || state === 'disconnected' || state === 'closed') {
|
if (['failed', 'disconnected', 'closed'].includes(state)) {
|
||||||
logWarn('useWebRTC: Send transport connection state changed', {
|
logWarn(`useWebRTC: ${label} transport connection state changed`, { state, transportId: transportParams.id, sessionId })
|
||||||
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 },
|
||||||
credentials: 'include',
|
...FETCH_OPTS,
|
||||||
})
|
})
|
||||||
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 },
|
||||||
credentials: 'include',
|
...FETCH_OPTS,
|
||||||
})
|
})
|
||||||
const transport = device.createRecvTransport({
|
const transport = device.createRecvTransport({
|
||||||
id: transportParams.id,
|
id: transportParams.id,
|
||||||
@@ -187,55 +133,15 @@ 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 },
|
body: { sessionId, transportId: transport.id, rtpCapabilities: device.rtpCapabilities },
|
||||||
credentials: 'include',
|
...FETCH_OPTS,
|
||||||
})
|
})
|
||||||
|
|
||||||
const consumer = await transport.consume({
|
const consumer = await transport.consume({
|
||||||
@@ -256,14 +162,6 @@ 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(() => {
|
||||||
@@ -285,27 +183,21 @@ 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) => {
|
||||||
let tid
|
const tid = ref(null)
|
||||||
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) clearTimeout(tid)
|
if (tid.value) clearTimeout(tid.value)
|
||||||
resolve(state)
|
resolve(state)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
transport.on('connectionstatechange', handler)
|
transport.on('connectionstatechange', handler)
|
||||||
handler()
|
handler()
|
||||||
tid = setTimeout(() => {
|
tid.value = setTimeout(() => {
|
||||||
transport.off('connectionstatechange', handler)
|
transport.off('connectionstatechange', handler)
|
||||||
resolve(transport.connectionState)
|
resolve(transport.connectionState)
|
||||||
}, timeoutMs)
|
}, timeoutMs)
|
||||||
|
|||||||
@@ -1,18 +1,13 @@
|
|||||||
/**
|
/** 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 { wrongHost: { serverHostname, clientHostname } }
|
return Object.freeze({ wrongHost: Object.freeze({ serverHostname, clientHostname }) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch {
|
catch { /* ignore */ }
|
||||||
// ignore
|
return Object.freeze({ wrongHost: null })
|
||||||
}
|
|
||||||
return { wrongHost: null }
|
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -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:0_0_12px_rgba(34,201,201,0.3)]">
|
<h1 class="text-2xl font-semibold tracking-wide text-shadow-glow-md">
|
||||||
[ Error ]
|
[ Error ]
|
||||||
</h1>
|
</h1>
|
||||||
<p class="mt-2 text-sm text-kestrel-muted">
|
<p class="mt-2 text-sm text-kestrel-muted">
|
||||||
|
|||||||
+4
-68
@@ -1,71 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen bg-kestrel-bg text-kestrel-text font-mono flex flex-col">
|
<div class="flex h-screen flex-col overflow-hidden bg-kestrel-bg font-mono text-kestrel-text">
|
||||||
<div class="relative flex flex-1 min-h-0">
|
<AppShell>
|
||||||
<NavDrawer v-model="drawerOpen" />
|
<slot />
|
||||||
<div
|
</AppShell>
|
||||||
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"
|
|
||||||
>☰</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">
|
|
||||||
> 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 />
|
|
||||||
</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>
|
|
||||||
|
|||||||
@@ -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()
|
||||||
await refresh()
|
if (!user.value) 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 })
|
||||||
|
|||||||
+201
-23
@@ -1,15 +1,59 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<h2 class="mb-4 text-xl font-semibold tracking-wide text-kestrel-text [text-shadow:0_0_8px_rgba(34,201,201,0.25)]">
|
<h2 class="kestrel-page-heading mb-4">
|
||||||
Account
|
Account
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<!-- Profile -->
|
<section
|
||||||
|
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="mb-2 text-sm font-medium uppercase tracking-wider text-kestrel-muted">
|
<h3 class="kestrel-section-label">
|
||||||
Profile
|
Profile
|
||||||
</h3>
|
</h3>
|
||||||
<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)]">
|
<div class="kestrel-card p-4">
|
||||||
<template v-if="user">
|
<template v-if="user">
|
||||||
<dl class="space-y-2 text-sm">
|
<dl class="space-y-2 text-sm">
|
||||||
<div>
|
<div>
|
||||||
@@ -50,15 +94,79 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Change password (local only) -->
|
<section
|
||||||
|
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="mb-2 text-sm font-medium uppercase tracking-wider text-kestrel-muted">
|
<h3 class="kestrel-section-label">
|
||||||
Change password
|
Change password
|
||||||
</h3>
|
</h3>
|
||||||
<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)]">
|
<div class="kestrel-card p-4">
|
||||||
<p
|
<p
|
||||||
v-if="passwordSuccess"
|
v-if="passwordSuccess"
|
||||||
class="mb-3 text-sm text-green-400"
|
class="mb-3 text-sm text-green-400"
|
||||||
@@ -78,46 +186,40 @@
|
|||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
for="account-current-password"
|
for="account-current-password"
|
||||||
class="mb-1 block text-xs text-kestrel-muted"
|
class="kestrel-label"
|
||||||
>
|
>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="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"
|
class="kestrel-input"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
for="account-new-password"
|
for="account-new-password"
|
||||||
class="mb-1 block text-xs text-kestrel-muted"
|
class="kestrel-label"
|
||||||
>
|
>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="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"
|
class="kestrel-input"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
for="account-confirm-password"
|
for="account-confirm-password"
|
||||||
class="mb-1 block text-xs text-kestrel-muted"
|
class="kestrel-label"
|
||||||
>
|
>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="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"
|
class="kestrel-input"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -134,14 +236,60 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
const { user } = useUser()
|
const { user, refresh } = 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 = ''
|
||||||
@@ -176,4 +324,34 @@ 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>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<h2 class="mb-4 text-xl font-semibold tracking-wide text-kestrel-text [text-shadow:0_0_8px_rgba(34,201,201,0.25)]">
|
<h2 class="kestrel-page-heading mb-4">
|
||||||
Cameras
|
Cameras
|
||||||
</h2>
|
</h2>
|
||||||
<p class="mb-4 text-sm text-kestrel-muted">
|
<p class="mb-4 text-sm text-kestrel-muted">
|
||||||
@@ -80,6 +80,8 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
definePageMeta({ layout: 'default' })
|
definePageMeta({ layout: 'default' })
|
||||||
|
|
||||||
const { cameras } = useCameras()
|
const { cameras, liveSessions } = useCameras()
|
||||||
const selectedCamera = ref(null)
|
const selectedCamera = ref(null)
|
||||||
|
|
||||||
|
useAutoCloseLiveSession(selectedCamera, liveSessions)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
+28
-5
@@ -1,16 +1,29 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex h-[calc(100vh-5rem)] w-full flex-col md:flex-row">
|
<div class="flex h-full w-full flex-col md:flex-row">
|
||||||
<div class="relative h-2/3 w-full md:h-full md:flex-1">
|
<div class="relative min-h-0 flex-1">
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
<KestrelMap
|
<KestrelMap
|
||||||
: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
|
||||||
@@ -25,10 +38,20 @@
|
|||||||
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 onSelectLive(session) {
|
function onMapBoundsChange(bounds) {
|
||||||
const latest = (liveSessions.value || []).find(s => s.id === session?.id)
|
mapBounds.value = bounds
|
||||||
selectedCamera.value = latest ?? session
|
onAlprBoundsChange(bounds)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onSelectLive(session) {
|
||||||
|
selectedCamera.value = (liveSessions.value ?? []).find(s => s.id === session?.id) ?? session
|
||||||
|
}
|
||||||
|
|
||||||
|
useAutoCloseLiveSession(selectedCamera, liveSessions)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
+10
-10
@@ -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="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)]">
|
<div class="kestrel-card w-full max-w-sm p-6">
|
||||||
<h2 class="mb-4 text-lg font-semibold text-kestrel-text [text-shadow:0_0_8px_rgba(34,201,201,0.25)]">
|
<h2 class="kestrel-section-heading mb-4">
|
||||||
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="mb-1 block text-xs text-kestrel-muted"
|
class="kestrel-label"
|
||||||
>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="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"
|
class="kestrel-input"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label
|
<label
|
||||||
for="login-password"
|
for="login-password"
|
||||||
class="mb-1 block text-xs text-kestrel-muted"
|
class="kestrel-label"
|
||||||
>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="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"
|
class="kestrel-input"
|
||||||
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(() => ({ oidc: { enabled: false, label: '' } })),
|
() => $fetch('/api/auth/config').catch(() => AUTH_CONFIG_DEFAULT),
|
||||||
{ 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 base = '/api/auth/oidc/authorize'
|
const r = redirect.value
|
||||||
const q = redirect.value && redirect.value !== '/' ? `?redirect=${encodeURIComponent(redirect.value)}` : ''
|
return `/api/auth/oidc/authorize${r && r !== '/' ? `?redirect=${encodeURIComponent(r)}` : ''}`
|
||||||
return base + q
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const identifier = ref('')
|
const identifier = ref('')
|
||||||
|
|||||||
+48
-425
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<h2 class="mb-2 text-xl font-semibold tracking-wide text-kestrel-text [text-shadow:0_0_8px_rgba(34,201,201,0.25)]">
|
<h2 class="kestrel-page-heading mb-2">
|
||||||
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="!canViewMembers"
|
v-else-if="!canEditPois"
|
||||||
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,371 +34,51 @@
|
|||||||
Add user
|
Add user
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="overflow-x-auto rounded border border-kestrel-border">
|
<MembersTable
|
||||||
<table class="w-full text-left text-sm">
|
:users="users"
|
||||||
<thead>
|
:role-by-user-id="roleByUserId"
|
||||||
<tr class="border-b border-kestrel-border bg-kestrel-surface-hover">
|
:role-options="roleOptions"
|
||||||
<th class="px-4 py-2 font-medium text-kestrel-text">
|
:is-admin="isAdmin"
|
||||||
Identifier
|
:current-user-id="user?.id ?? null"
|
||||||
</th>
|
:open-role-dropdown-id="openRoleDropdownId"
|
||||||
<th class="px-4 py-2 font-medium text-kestrel-text">
|
@toggle-role-dropdown="toggleRoleDropdown"
|
||||||
Auth
|
@close-role-dropdown="openRoleDropdownId = null"
|
||||||
</th>
|
@select-role="selectRole"
|
||||||
<th class="px-4 py-2 font-medium text-kestrel-text">
|
@save-role="saveRole"
|
||||||
Role
|
@edit-user="openEditUser"
|
||||||
</th>
|
@delete-confirm="openDeleteConfirm"
|
||||||
<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 -->
|
||||||
<Teleport to="body">
|
<AddUserModal
|
||||||
<div
|
:show="addUserModalOpen"
|
||||||
v-if="addUserModalOpen"
|
:submit-error="createError"
|
||||||
class="fixed inset-0 z-[200] flex items-center justify-center bg-black/50 p-4"
|
@close="closeAddUserModal"
|
||||||
role="dialog"
|
@submit="onAddUserSubmit"
|
||||||
aria-modal="true"
|
/>
|
||||||
aria-labelledby="add-user-title"
|
<DeleteUserConfirmModal
|
||||||
@click.self="closeAddUserModal"
|
:user="deleteConfirmUser"
|
||||||
>
|
@close="deleteConfirmUser = null"
|
||||||
<div
|
@confirm="confirmDeleteUser"
|
||||||
class="w-full max-w-sm rounded border border-kestrel-border bg-kestrel-surface p-4 shadow-glow"
|
/>
|
||||||
@click.stop
|
<EditUserModal
|
||||||
>
|
:user="editUserModal"
|
||||||
<h3
|
:submit-error="editError"
|
||||||
id="add-user-title"
|
@close="editUserModal = null"
|
||||||
class="mb-3 text-sm font-medium text-kestrel-text"
|
@submit="onEditUserSubmit"
|
||||||
>
|
/>
|
||||||
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, refresh: refreshUser } = useUser()
|
const { user, isAdmin, canEditPois, 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(() => (Array.isArray(usersData.value) ? usersData.value : []))
|
const users = computed(() => Object.freeze([...(usersData.value ?? [])]))
|
||||||
|
|
||||||
const roleOptions = ['admin', 'leader', 'member']
|
const roleOptions = ['admin', 'leader', 'member']
|
||||||
const pendingRoleUpdates = ref({})
|
const pendingRoleUpdates = ref({})
|
||||||
@@ -407,80 +87,26 @@ 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)
|
||||||
|
|
||||||
function setDropdownWrapRef(userId, el) {
|
watch(user, () => {
|
||||||
if (el) dropdownWrapRefs.value[userId] = el
|
if (canEditPois.value) refreshUsers()
|
||||||
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) {
|
||||||
if (openRoleDropdownId.value === userId) {
|
openRoleDropdownId.value = openRoleDropdownId.value === userId ? null : 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
|
||||||
@@ -498,7 +124,6 @@ async function saveRole(id) {
|
|||||||
|
|
||||||
function openAddUserModal() {
|
function openAddUserModal() {
|
||||||
addUserModalOpen.value = true
|
addUserModalOpen.value = true
|
||||||
newUser.value = { identifier: '', password: '', role: 'member' }
|
|
||||||
createError.value = ''
|
createError.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -507,15 +132,15 @@ function closeAddUserModal() {
|
|||||||
createError.value = ''
|
createError.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitAddUser() {
|
async function onAddUserSubmit(payload) {
|
||||||
createError.value = ''
|
createError.value = ''
|
||||||
try {
|
try {
|
||||||
await $fetch('/api/users', {
|
await $fetch('/api/users', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: {
|
body: {
|
||||||
identifier: newUser.value.identifier.trim(),
|
identifier: payload.identifier,
|
||||||
password: newUser.value.password,
|
password: payload.password,
|
||||||
role: newUser.value.role,
|
role: payload.role,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
closeAddUserModal()
|
closeAddUserModal()
|
||||||
@@ -528,21 +153,19 @@ async function submitAddUser() {
|
|||||||
|
|
||||||
function openEditUser(u) {
|
function openEditUser(u) {
|
||||||
editUserModal.value = u
|
editUserModal.value = u
|
||||||
editForm.value = { identifier: u.identifier, password: '' }
|
|
||||||
editError.value = ''
|
editError.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitEditUser() {
|
async function onEditUserSubmit(payload) {
|
||||||
if (!editUserModal.value) return
|
const u = editUserModal.value
|
||||||
|
if (!u) return
|
||||||
editError.value = ''
|
editError.value = ''
|
||||||
const id = editUserModal.value.id
|
const body = { identifier: payload.identifier.trim() }
|
||||||
const body = { identifier: editForm.value.identifier.trim() }
|
if (payload.password) body.password = payload.password
|
||||||
if (editForm.value.password) body.password = editForm.value.password
|
|
||||||
try {
|
try {
|
||||||
await $fetch(`/api/users/${id}`, { method: 'PATCH', body })
|
await $fetch(`/api/users/${u.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
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<h2 class="mb-2 text-xl font-semibold tracking-wide text-kestrel-text [text-shadow:0_0_8px_rgba(34,201,201,0.25)]">
|
<h2 class="kestrel-page-heading mb-2">
|
||||||
POI placement
|
POI placement
|
||||||
</h2>
|
</h2>
|
||||||
<p
|
<p
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
for="poi-lat"
|
for="poi-lat"
|
||||||
class="mb-1 block text-xs text-kestrel-muted"
|
class="kestrel-label"
|
||||||
>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="w-28 rounded border border-kestrel-border bg-kestrel-bg px-2 py-1 text-sm text-kestrel-text"
|
class="kestrel-input w-28"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
for="poi-lng"
|
for="poi-lng"
|
||||||
class="mb-1 block text-xs text-kestrel-muted"
|
class="kestrel-label"
|
||||||
>Lng</label>
|
>Lng</label>
|
||||||
<input
|
<input
|
||||||
id="poi-lng"
|
id="poi-lng"
|
||||||
@@ -39,39 +39,37 @@
|
|||||||
type="number"
|
type="number"
|
||||||
step="any"
|
step="any"
|
||||||
required
|
required
|
||||||
class="w-28 rounded border border-kestrel-border bg-kestrel-bg px-2 py-1 text-sm text-kestrel-text"
|
class="kestrel-input w-28"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
for="poi-label"
|
for="poi-label"
|
||||||
class="mb-1 block text-xs text-kestrel-muted"
|
class="kestrel-label"
|
||||||
>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="w-40 rounded border border-kestrel-border bg-kestrel-bg px-2 py-1 text-sm text-kestrel-text"
|
class="kestrel-input w-40"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
for="poi-icon"
|
for="poi-icon"
|
||||||
class="mb-1 block text-xs text-kestrel-muted"
|
class="kestrel-label"
|
||||||
>Icon</label>
|
>Icon</label>
|
||||||
<select
|
<select
|
||||||
id="poi-icon"
|
id="poi-icon"
|
||||||
v-model="form.iconType"
|
v-model="form.iconType"
|
||||||
class="rounded border border-kestrel-border bg-kestrel-bg px-2 py-1 text-sm text-kestrel-text"
|
class="kestrel-input w-28"
|
||||||
>
|
>
|
||||||
<option value="pin">
|
<option
|
||||||
pin
|
v-for="opt in POI_ICON_TYPES"
|
||||||
</option>
|
:key="opt"
|
||||||
<option value="flag">
|
:value="opt"
|
||||||
flag
|
>
|
||||||
</option>
|
{{ opt }}
|
||||||
<option value="waypoint">
|
|
||||||
waypoint
|
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -114,7 +112,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 }}
|
||||||
@@ -145,6 +143,8 @@
|
|||||||
</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 ?? [])
|
||||||
|
|||||||
+91
-8
@@ -1,15 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<h2 class="mb-4 text-xl font-semibold tracking-wide text-kestrel-text [text-shadow:0_0_8px_rgba(34,201,201,0.25)]">
|
<h2 class="kestrel-page-heading mb-4">
|
||||||
Settings
|
Settings
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<!-- Map & offline -->
|
|
||||||
<section class="mb-8">
|
<section class="mb-8">
|
||||||
<h3 class="mb-2 text-sm font-medium uppercase tracking-wider text-kestrel-muted">
|
<h3 class="kestrel-section-label">
|
||||||
Map & offline
|
Map & offline
|
||||||
</h3>
|
</h3>
|
||||||
<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)]">
|
<div class="kestrel-card p-4">
|
||||||
<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>
|
||||||
@@ -28,7 +27,7 @@
|
|||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded border border-kestrel-border px-4 py-2 text-sm text-kestrel-text transition-colors hover:bg-kestrel-border disabled:opacity-50"
|
class="kestrel-btn-secondary disabled:opacity-50"
|
||||||
:disabled="tilesLoading"
|
:disabled="tilesLoading"
|
||||||
@click="onClearTiles"
|
@click="onClearTiles"
|
||||||
>
|
>
|
||||||
@@ -37,12 +36,72 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- About -->
|
<section class="mb-8">
|
||||||
|
<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="mb-2 text-sm font-medium uppercase tracking-wider text-kestrel-muted">
|
<h3 class="kestrel-section-label">
|
||||||
About
|
About
|
||||||
</h3>
|
</h3>
|
||||||
<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)]">
|
<div class="kestrel-card p-4">
|
||||||
<p class="font-medium text-kestrel-text">
|
<p class="font-medium text-kestrel-text">
|
||||||
KestrelOS
|
KestrelOS
|
||||||
</p>
|
</p>
|
||||||
@@ -69,6 +128,11 @@ 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 {
|
||||||
@@ -108,7 +172,26 @@ 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
@@ -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="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)]">
|
<div class="kestrel-card-modal w-full max-w-md p-6">
|
||||||
<h2 class="mb-2 text-lg font-semibold tracking-wide text-kestrel-text [text-shadow:0_0_8px_rgba(34,201,201,0.25)]">
|
<h2 class="kestrel-section-heading mb-2">
|
||||||
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 40000-49999 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="relative mb-4 aspect-video w-full overflow-hidden rounded border border-kestrel-border bg-black"
|
class="kestrel-video-frame mb-4"
|
||||||
>
|
>
|
||||||
<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 }
|
||||||
let locationWatchId = null
|
const locationWatchId = ref(null)
|
||||||
let locationIntervalId = null
|
const locationIntervalId = ref(null)
|
||||||
let device = null
|
const device = ref(null)
|
||||||
let sendTransport = null
|
const sendTransport = ref(null)
|
||||||
let producer = null
|
const producer = ref(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 = await createMediasoupDevice(rtpCapabilities)
|
device.value = await createMediasoupDevice(rtpCapabilities)
|
||||||
sendTransport = await createSendTransport(device, sessionId.value, {
|
sendTransport.value = await createSendTransport(device.value, 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 = await sendTransport.produce({ track: videoTrack })
|
producer.value = await sendTransport.value.produce({ track: videoTrack })
|
||||||
// Monitor producer events
|
// Monitor producer events
|
||||||
producer.on('transportclose', () => {
|
producer.value.on('transportclose', () => {
|
||||||
logWarn('share-live: Producer transport closed', {
|
logWarn('share-live: Producer transport closed', {
|
||||||
producerId: producer.id,
|
producerId: producer.value.id,
|
||||||
producerPaused: producer.paused,
|
producerPaused: producer.value.paused,
|
||||||
producerClosed: producer.closed,
|
producerClosed: producer.value.closed,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
producer.on('trackended', () => {
|
producer.value.on('trackended', () => {
|
||||||
logWarn('share-live: Producer track ended', {
|
logWarn('share-live: Producer track ended', {
|
||||||
producerId: producer.id,
|
producerId: producer.value.id,
|
||||||
producerPaused: producer.paused,
|
producerPaused: producer.value.paused,
|
||||||
producerClosed: producer.closed,
|
producerClosed: producer.value.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.on('connectionstatechange', () => {
|
sendTransport.value.on('connectionstatechange', () => {
|
||||||
const state = sendTransport.connectionState
|
const state = sendTransport.value.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.id,
|
transportId: sendTransport.value.id,
|
||||||
producerId: producer.id,
|
producerId: producer.value.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.track) {
|
if (producer.value.track) {
|
||||||
producer.track.addEventListener('ended', () => {
|
producer.value.track.addEventListener('ended', () => {
|
||||||
logWarn('share-live: Producer track ended', {
|
logWarn('share-live: Producer track ended', {
|
||||||
producerId: producer.id,
|
producerId: producer.value.id,
|
||||||
trackId: producer.track.id,
|
trackId: producer.value.track.id,
|
||||||
trackReadyState: producer.track.readyState,
|
trackReadyState: producer.value.track.readyState,
|
||||||
trackEnabled: producer.track.enabled,
|
trackEnabled: producer.value.track.enabled,
|
||||||
trackMuted: producer.track.muted,
|
trackMuted: producer.value.track.muted,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
producer.track.addEventListener('mute', () => {
|
producer.value.track.addEventListener('mute', () => {
|
||||||
logWarn('share-live: Producer track muted', {
|
logWarn('share-live: Producer track muted', {
|
||||||
producerId: producer.id,
|
producerId: producer.value.id,
|
||||||
trackId: producer.track.id,
|
trackId: producer.value.track.id,
|
||||||
trackEnabled: producer.track.enabled,
|
trackEnabled: producer.value.track.enabled,
|
||||||
trackMuted: producer.track.muted,
|
trackMuted: producer.value.track.muted,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
producer.track.addEventListener('unmute', () => {})
|
producer.value.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 = navigator.geolocation.watchPosition(
|
locationWatchId.value = 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 != null) {
|
if (locationIntervalId.value != null) {
|
||||||
clearInterval(locationIntervalId)
|
clearInterval(locationIntervalId.value)
|
||||||
locationIntervalId = null
|
locationIntervalId.value = null
|
||||||
}
|
}
|
||||||
sharing.value = false
|
sharing.value = false
|
||||||
if (!locationUpdate404Logged) {
|
if (!locationUpdate404Logged) {
|
||||||
@@ -350,7 +350,7 @@ async function startSharing() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await sendLocationUpdate()
|
await sendLocationUpdate()
|
||||||
locationIntervalId = setInterval(sendLocationUpdate, 2000)
|
locationIntervalId.value = 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 != null && navigator.geolocation?.clearWatch) {
|
if (locationWatchId.value != null && navigator.geolocation?.clearWatch) {
|
||||||
navigator.geolocation.clearWatch(locationWatchId)
|
navigator.geolocation.clearWatch(locationWatchId.value)
|
||||||
}
|
}
|
||||||
locationWatchId = null
|
locationWatchId.value = null
|
||||||
if (locationIntervalId != null) {
|
if (locationIntervalId.value != null) {
|
||||||
clearInterval(locationIntervalId)
|
clearInterval(locationIntervalId.value)
|
||||||
}
|
}
|
||||||
locationIntervalId = null
|
locationIntervalId.value = null
|
||||||
if (producer) {
|
if (producer.value) {
|
||||||
producer.close()
|
producer.value.close()
|
||||||
producer = null
|
producer.value = null
|
||||||
}
|
}
|
||||||
if (sendTransport) {
|
if (sendTransport.value) {
|
||||||
sendTransport.close()
|
sendTransport.value.close()
|
||||||
sendTransport = null
|
sendTransport.value = null
|
||||||
}
|
}
|
||||||
device = null
|
device.value = 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
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/** 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
|
||||||
@@ -6,8 +7,7 @@ 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
|
||||||
const redirect = (route.fullPath && route.fullPath !== '/' ? route.fullPath : '/')
|
navigateTo({ path: '/login', query: { redirect: route.fullPath || '/' } }, { replace: true })
|
||||||
navigateTo({ path: '/login', query: { redirect } }, { replace: true })
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import { syncFeatureMarkers } from './mapMarkerSync.js'
|
||||||
|
|
||||||
|
const ICON_SIZE = 28
|
||||||
|
const CONE_SIZE = 52
|
||||||
|
const ALPR_COLOR = '#ef4444'
|
||||||
|
const DEFAULT_FOV = 60
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div')
|
||||||
|
div.textContent = text
|
||||||
|
return div.innerHTML
|
||||||
|
}
|
||||||
|
|
||||||
|
function conePath(fov) {
|
||||||
|
const half = Math.min(Math.max(fov / 2, 10), 85)
|
||||||
|
const cx = 26
|
||||||
|
const cy = 26
|
||||||
|
const r = 24
|
||||||
|
const toRad = deg => ((deg - 90) * Math.PI) / 180
|
||||||
|
const x1 = cx + r * Math.cos(toRad(-half))
|
||||||
|
const y1 = cy + r * Math.sin(toRad(-half))
|
||||||
|
const x2 = cx + r * Math.cos(toRad(half))
|
||||||
|
const y2 = cy + r * Math.sin(toRad(half))
|
||||||
|
return `M ${cx} ${cy} L ${x1} ${y1} A ${r} ${r} 0 0 1 ${x2} ${y2} Z`
|
||||||
|
}
|
||||||
|
|
||||||
|
function compassLabel(deg) {
|
||||||
|
if (!Number.isFinite(deg)) return null
|
||||||
|
const dirs = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']
|
||||||
|
const idx = Math.round((((deg % 360) + 360) % 360) / 45) % 8
|
||||||
|
return dirs[idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
function wikidataLink(label, qid) {
|
||||||
|
if (!qid) return escapeHtml(label)
|
||||||
|
const id = escapeHtml(qid)
|
||||||
|
return `<a href="https://www.wikidata.org/wiki/${id}" target="_blank" rel="noopener">${escapeHtml(label)}</a>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function popupLine(parts) {
|
||||||
|
const line = parts.filter(Boolean).join(' · ')
|
||||||
|
return line ? `<div class="text-kestrel-muted text-xs mt-1">${line}</div>` : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function titleHtml(props) {
|
||||||
|
if (props.name) return escapeHtml(props.name)
|
||||||
|
if (props.model) {
|
||||||
|
const mfr = props.manufacturer ? escapeHtml(props.manufacturer) : null
|
||||||
|
return mfr ? `${mfr} <span class="text-kestrel-accent">${escapeHtml(props.model)}</span>` : escapeHtml(props.model)
|
||||||
|
}
|
||||||
|
const makeModel = [props.manufacturer, props.model].filter(Boolean).join(' ')
|
||||||
|
if (makeModel) {
|
||||||
|
if (props.manufacturerWikidata && !props.model) return wikidataLink(makeModel, props.manufacturerWikidata)
|
||||||
|
return escapeHtml(makeModel)
|
||||||
|
}
|
||||||
|
if (props.operator) return wikidataLink(props.operator, props.operatorWikidata)
|
||||||
|
if (props.ref) return escapeHtml(props.ref)
|
||||||
|
return 'ALPR camera'
|
||||||
|
}
|
||||||
|
|
||||||
|
function titleText(props) {
|
||||||
|
if (props.name) return props.name
|
||||||
|
if (props.model) {
|
||||||
|
return [props.manufacturer, props.model].filter(Boolean).join(' ')
|
||||||
|
}
|
||||||
|
const makeModel = [props.manufacturer, props.model].filter(Boolean).join(' ')
|
||||||
|
if (makeModel) return makeModel
|
||||||
|
if (props.operator) return props.operator
|
||||||
|
if (props.ref) return props.ref
|
||||||
|
return 'ALPR camera'
|
||||||
|
}
|
||||||
|
|
||||||
|
function modelLineHtml(props) {
|
||||||
|
if (props.model) {
|
||||||
|
return `<div class="text-sm mt-0.5"><span class="text-kestrel-muted">Model</span> <strong>${escapeHtml(props.model)}</strong></div>`
|
||||||
|
}
|
||||||
|
if (props.modelUnknown) {
|
||||||
|
return '<div class="text-kestrel-muted text-xs mt-0.5">Model not recorded in OpenStreetMap</div>'
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human-readable ALPR popup (GeoJSON properties in, HTML out). */
|
||||||
|
export function formatAlprPopup(props) {
|
||||||
|
const title = titleHtml(props)
|
||||||
|
const headline = titleText(props)
|
||||||
|
const makeModel = [props.manufacturer, props.model].filter(Boolean).join(' ')
|
||||||
|
|
||||||
|
const identity = []
|
||||||
|
if (props.name && makeModel) identity.push(escapeHtml(makeModel))
|
||||||
|
if (props.operator && headline !== props.operator) {
|
||||||
|
identity.push(wikidataLink(props.operator, props.operatorWikidata))
|
||||||
|
}
|
||||||
|
if (props.ref && headline !== props.ref) identity.push(escapeHtml(props.ref))
|
||||||
|
if (props.brand) identity.push(escapeHtml(props.brand))
|
||||||
|
|
||||||
|
const view = []
|
||||||
|
if (props.direction != null) {
|
||||||
|
const deg = Math.round(props.direction)
|
||||||
|
const compass = compassLabel(props.direction)
|
||||||
|
view.push(compass ? `Facing ${compass} (${deg}°)` : `Facing ${deg}°`)
|
||||||
|
}
|
||||||
|
if (props.fov != null && props.direction != null) view.push(`~${Math.round(props.fov)}° view`)
|
||||||
|
|
||||||
|
const note = props.description || props.note
|
||||||
|
const noteHtml = note ? `<div class="text-kestrel-muted text-xs mt-1">${escapeHtml(note)}</div>` : ''
|
||||||
|
const identityHtml = identity.length
|
||||||
|
? `<div class="text-kestrel-muted text-xs mt-0.5">${identity.join(' · ')}</div>`
|
||||||
|
: ''
|
||||||
|
const modelHtml = (props.model || props.modelUnknown) ? modelLineHtml(props) : ''
|
||||||
|
const foot = `<div class="text-kestrel-muted text-xs mt-2"><a href="https://www.openstreetmap.org/node/${props.osmId}" target="_blank" rel="noopener">View on OpenStreetMap</a></div>`
|
||||||
|
|
||||||
|
return `<div class="kestrel-live-popup"><strong>${title}</strong> <span class="text-kestrel-muted">License plate reader</span>${modelHtml}${identityHtml}${popupLine(view)}${noteHtml}${foot}</div>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function popupHtml(props) {
|
||||||
|
return formatAlprPopup(props)
|
||||||
|
}
|
||||||
|
|
||||||
|
function pointIcon(L, direction, fov) {
|
||||||
|
const hasDirection = Number.isFinite(direction)
|
||||||
|
if (!hasDirection) {
|
||||||
|
const html = `<span class="poi-icon-svg"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${ALPR_COLOR}" stroke-width="2"><rect x="3" y="5" width="18" height="12" rx="2"/><circle cx="12" cy="11" r="3"/><path d="M8 21h8"/></svg></span>`
|
||||||
|
return L.divIcon({
|
||||||
|
className: 'poi-div-icon alpr-icon',
|
||||||
|
html,
|
||||||
|
iconSize: [ICON_SIZE, ICON_SIZE],
|
||||||
|
iconAnchor: [ICON_SIZE / 2, ICON_SIZE],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const spread = Number.isFinite(fov) ? fov : DEFAULT_FOV
|
||||||
|
const html = `<span class="alpr-cone" style="transform:rotate(${direction}deg)"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 52 52" width="${CONE_SIZE}" height="${CONE_SIZE}"><path d="${conePath(spread)}" fill="${ALPR_COLOR}" fill-opacity="0.3" stroke="${ALPR_COLOR}" stroke-width="1.5"/><circle cx="26" cy="26" r="3" fill="${ALPR_COLOR}"/></svg></span>`
|
||||||
|
return L.divIcon({
|
||||||
|
className: 'poi-div-icon alpr-icon',
|
||||||
|
html,
|
||||||
|
iconSize: [CONE_SIZE, CONE_SIZE],
|
||||||
|
iconAnchor: [CONE_SIZE / 2, CONE_SIZE / 2],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function clusterIcon(L, count) {
|
||||||
|
const size = count < 10 ? 28 : count < 100 ? 34 : 40
|
||||||
|
return L.divIcon({
|
||||||
|
className: 'alpr-cluster-icon',
|
||||||
|
html: `<span class="alpr-cluster">${count}</span>`,
|
||||||
|
iconSize: [size, size],
|
||||||
|
iconAnchor: [size / 2, size / 2],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAlprControl(L, { showAlpr, onToggle }) {
|
||||||
|
const control = L.control({ position: 'topleft' })
|
||||||
|
control.onAdd = function () {
|
||||||
|
const el = document.createElement('button')
|
||||||
|
el.type = 'button'
|
||||||
|
el.className = 'leaflet-bar leaflet-control-alpr'
|
||||||
|
el.title = 'Toggle ALPR cameras (OSM)'
|
||||||
|
el.setAttribute('aria-label', 'Toggle ALPR cameras')
|
||||||
|
el.setAttribute('aria-pressed', showAlpr ? 'true' : 'false')
|
||||||
|
el.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><rect x="3" y="5" width="18" height="12" rx="2"/><circle cx="12" cy="11" r="3"/></svg>'
|
||||||
|
el.addEventListener('click', onToggle)
|
||||||
|
control._button = el
|
||||||
|
return el
|
||||||
|
}
|
||||||
|
return control
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setAlprControlPressed(control, pressed) {
|
||||||
|
control?._button?.setAttribute('aria-pressed', pressed ? 'true' : 'false')
|
||||||
|
}
|
||||||
|
|
||||||
|
function featureKey(feature) {
|
||||||
|
const props = feature.properties ?? {}
|
||||||
|
if (props.cluster) return `c:${props.cluster_id}`
|
||||||
|
const id = props.osmId
|
||||||
|
return id != null ? `a:${id}` : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function coords(feature) {
|
||||||
|
const [lng, lat] = feature.geometry.coordinates
|
||||||
|
return { lat, lng }
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachClusterClick(marker, feature, map) {
|
||||||
|
marker.on('click', () => {
|
||||||
|
const { lat, lng } = coords(feature)
|
||||||
|
const props = feature.properties ?? {}
|
||||||
|
const zoom = props.expansionZoom ?? map.getZoom() + 2
|
||||||
|
map.setView([lat, lng], Math.min(zoom, 19), { animate: true })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAlprLayer(L, map) {
|
||||||
|
return L.layerGroup().addTo(map)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncAlprLayer(L, map, layer, features) {
|
||||||
|
syncFeatureMarkers(layer, features, {
|
||||||
|
keyFor: featureKey,
|
||||||
|
create: (feature) => {
|
||||||
|
const { lat, lng } = coords(feature)
|
||||||
|
const props = feature.properties ?? {}
|
||||||
|
const isCluster = Boolean(props.cluster)
|
||||||
|
const icon = isCluster ? clusterIcon(L, props.point_count) : pointIcon(L, props.direction, props.fov)
|
||||||
|
const marker = L.marker([lat, lng], { icon })
|
||||||
|
if (isCluster) attachClusterClick(marker, feature, map)
|
||||||
|
else marker.bindPopup(popupHtml(props), { className: 'kestrel-live-popup-wrap', maxWidth: 320 })
|
||||||
|
return marker
|
||||||
|
},
|
||||||
|
update: (marker, feature) => {
|
||||||
|
const { lat, lng } = coords(feature)
|
||||||
|
const props = feature.properties ?? {}
|
||||||
|
const isCluster = Boolean(props.cluster)
|
||||||
|
marker.setLatLng([lat, lng])
|
||||||
|
const icon = isCluster ? clusterIcon(L, props.point_count) : pointIcon(L, props.direction, props.fov)
|
||||||
|
marker.setIcon(icon)
|
||||||
|
if (!isCluster) marker.setPopupContent(popupHtml(props))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
export const MAX_BBOX_DEGREES = 0.5
|
||||||
|
|
||||||
|
export function tileKey(row, col) {
|
||||||
|
return `${row},${col}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bboxToTileKey(bbox) {
|
||||||
|
const row = Math.floor(bbox.south / MAX_BBOX_DEGREES)
|
||||||
|
const col = Math.floor(bbox.west / MAX_BBOX_DEGREES)
|
||||||
|
return tileKey(row, col)
|
||||||
|
}
|
||||||
|
|
||||||
|
function tileBox(row, col, step = MAX_BBOX_DEGREES) {
|
||||||
|
const south = row * step
|
||||||
|
const west = col * step
|
||||||
|
return { south, west, north: south + step, east: west + step }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bboxFetchKey(bounds) {
|
||||||
|
const zoom = bounds.zoom ?? 14
|
||||||
|
const step = zoom >= 14 ? 0.025 : zoom >= 11 ? 0.1 : zoom >= 8 ? 0.25 : 1
|
||||||
|
const q = v => Math.round(v / step) * step
|
||||||
|
return [q(bounds.south), q(bounds.west), q(bounds.north), q(bounds.east)].join(',')
|
||||||
|
}
|
||||||
|
|
||||||
|
function ringOffsets(radius) {
|
||||||
|
if (radius === 0) return [[0, 0]]
|
||||||
|
return Array.from({ length: 2 * radius + 1 }, (_, i) => i - radius)
|
||||||
|
.flatMap(dr => Array.from({ length: 2 * radius + 1 }, (_, j) => j - radius)
|
||||||
|
.filter(dc => Math.abs(dr) === radius || Math.abs(dc) === radius)
|
||||||
|
.map(dc => [dr, dc]))
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectTiles(state) {
|
||||||
|
const {
|
||||||
|
centerRow, centerCol, minRow, maxRow, minCol, maxCol, step, limit, radius, seen, tiles,
|
||||||
|
} = state
|
||||||
|
if (tiles.length >= limit) return tiles
|
||||||
|
|
||||||
|
const inBounds = (row, col) => row >= minRow && row <= maxRow && col >= minCol && col <= maxCol
|
||||||
|
const { nextSeen, added } = ringOffsets(radius)
|
||||||
|
.map(([dr, dc]) => [centerRow + dr, centerCol + dc])
|
||||||
|
.filter(([row, col]) => inBounds(row, col))
|
||||||
|
.reduce((acc, [row, col]) => {
|
||||||
|
const key = `${row},${col}`
|
||||||
|
if (acc.nextSeen.has(key)) return acc
|
||||||
|
return {
|
||||||
|
nextSeen: new Set([...acc.nextSeen, key]),
|
||||||
|
added: [...acc.added, tileBox(row, col, step)],
|
||||||
|
}
|
||||||
|
}, { nextSeen: seen, added: [] })
|
||||||
|
|
||||||
|
if (added.length === 0 && radius > 0) return tiles
|
||||||
|
|
||||||
|
const nextTiles = [...tiles, ...added].slice(0, limit)
|
||||||
|
if (nextTiles.length >= limit) return nextTiles
|
||||||
|
return collectTiles({ ...state, radius: radius + 1, seen: nextSeen, tiles: nextTiles })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tilesNearCenter(bounds, limit) {
|
||||||
|
const step = MAX_BBOX_DEGREES
|
||||||
|
const lat = (bounds.south + bounds.north) / 2
|
||||||
|
const lng = (bounds.west + bounds.east) / 2
|
||||||
|
return collectTiles({
|
||||||
|
centerRow: Math.floor(lat / step),
|
||||||
|
centerCol: Math.floor(lng / step),
|
||||||
|
minRow: Math.floor(bounds.south / step),
|
||||||
|
maxRow: Math.ceil(bounds.north / step) - 1,
|
||||||
|
minCol: Math.floor(bounds.west / step),
|
||||||
|
maxCol: Math.ceil(bounds.east / step) - 1,
|
||||||
|
step,
|
||||||
|
limit,
|
||||||
|
radius: 0,
|
||||||
|
seen: new Set(),
|
||||||
|
tiles: [],
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
/** Map CoT / ADS-B entity display: icons and popups. */
|
||||||
|
|
||||||
|
export const COT_COLORS = {
|
||||||
|
air: '#60a5fa',
|
||||||
|
helicopter: '#fbbf24',
|
||||||
|
surface: '#38bdf8',
|
||||||
|
ground: '#f59e0b',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cotCategory(type) {
|
||||||
|
const t = typeof type === 'string' ? type : ''
|
||||||
|
if (t.startsWith('a-f-A-')) return 'air'
|
||||||
|
if (t.startsWith('a-f-S-')) return 'surface'
|
||||||
|
return 'ground'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the entity is a helicopter or fixed-wing aircraft. @returns {'helicopter' | 'fixedWing'} */
|
||||||
|
export function cotAirIconKind(entity) {
|
||||||
|
const type = entity?.type ?? ''
|
||||||
|
if (type.endsWith('-C-H') || type.endsWith('-M-H')) return 'helicopter'
|
||||||
|
return 'fixedWing'
|
||||||
|
}
|
||||||
|
|
||||||
|
function iconWrap(heading, inner) {
|
||||||
|
const rotate = Number.isFinite(heading) ? ` style="transform:rotate(${heading}deg)"` : ''
|
||||||
|
return `<span class="poi-icon-svg cot-icon-rotatable"${rotate}>${inner}</span>`
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLANE_SVG = color =>
|
||||||
|
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="${color}"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg>`
|
||||||
|
|
||||||
|
const HELI_SVG = color =>
|
||||||
|
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="1.75" stroke-linecap="round"><circle cx="12" cy="12" r="2.5" fill="${color}"/><path d="M3 8h18M3 12h18"/><path d="M12 8v8"/><path d="M9 16h6"/></svg>`
|
||||||
|
|
||||||
|
const SHIP_SVG = color =>
|
||||||
|
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2"><path d="M2 20c2-4 6-6 10-6s8 2 10 6"/><path d="M12 14V4"/><path d="m8 8 4-4 4 4"/></svg>`
|
||||||
|
|
||||||
|
const GROUND_SVG = color =>
|
||||||
|
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="8" r="2.5" fill="${color}"/></svg>`
|
||||||
|
|
||||||
|
export function getCotIconHtml(entity) {
|
||||||
|
const category = cotCategory(entity?.type)
|
||||||
|
const heading = Number(entity?.heading)
|
||||||
|
if (category === 'air') {
|
||||||
|
const kind = cotAirIconKind(entity)
|
||||||
|
const color = kind === 'helicopter' ? COT_COLORS.helicopter : COT_COLORS.air
|
||||||
|
const svg = kind === 'helicopter' ? HELI_SVG(color) : PLANE_SVG(color)
|
||||||
|
return { html: iconWrap(heading, svg), className: `cot-entity-${kind}` }
|
||||||
|
}
|
||||||
|
if (category === 'surface') {
|
||||||
|
return { html: iconWrap(heading, SHIP_SVG(COT_COLORS.surface)), className: 'cot-entity-surface' }
|
||||||
|
}
|
||||||
|
return { html: iconWrap(undefined, GROUND_SVG(COT_COLORS.ground)), className: 'cot-entity-ground' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function msToKnots(ms) {
|
||||||
|
return Number.isFinite(ms) ? Math.round(ms * 1.94384) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function metersToFeet(m) {
|
||||||
|
return Number.isFinite(m) ? Math.round(m * 3.28084) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtHeading(deg) {
|
||||||
|
return Number.isFinite(deg) ? `${Math.round(deg)}°` : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtVerticalFpm(ms) {
|
||||||
|
if (!Number.isFinite(ms) || ms === 0) return null
|
||||||
|
const fpm = Math.round(ms * 196.85)
|
||||||
|
return `${fpm > 0 ? '+' : ''}${fpm} fpm`
|
||||||
|
}
|
||||||
|
|
||||||
|
function icaoFromEntity(entity) {
|
||||||
|
if (entity?.icao) return String(entity.icao).toUpperCase()
|
||||||
|
if (typeof entity?.id === 'string' && entity.id.startsWith('ICAO.')) {
|
||||||
|
return entity.id.slice(5).toUpperCase()
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function mmsiFromEntity(entity) {
|
||||||
|
if (entity?.mmsi) return String(entity.mmsi)
|
||||||
|
if (typeof entity?.id === 'string' && entity.id.startsWith('MMSI.')) return entity.id.slice(5)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function popupLine(escape, parts) {
|
||||||
|
const line = parts.filter(Boolean).join(' · ')
|
||||||
|
return line ? `<div class="text-kestrel-muted text-xs mt-1">${line}</div>` : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Record<string, unknown>} entity
|
||||||
|
* @param {(s: string) => string} escape
|
||||||
|
*/
|
||||||
|
export function formatCotPopup(entity, escape) {
|
||||||
|
const category = cotCategory(entity?.type)
|
||||||
|
const label = escape(entity?.label || entity?.id || 'Unknown')
|
||||||
|
|
||||||
|
if (entity?.source === 'adsb' || category === 'air') {
|
||||||
|
const tag = cotAirIconKind(entity) === 'helicopter' ? 'Helicopter' : 'Aircraft'
|
||||||
|
const icao = icaoFromEntity(entity)
|
||||||
|
const meta = [
|
||||||
|
icao ? `ICAO ${icao}` : null,
|
||||||
|
entity?.originCountry ? escape(String(entity.originCountry)) : null,
|
||||||
|
].filter(Boolean).join(' · ')
|
||||||
|
const alt = metersToFeet(entity?.altitude)
|
||||||
|
const stats = [
|
||||||
|
alt != null ? `${alt.toLocaleString()} ft` : null,
|
||||||
|
entity?.onGround ? 'On ground' : null,
|
||||||
|
msToKnots(entity?.speed) != null ? `${msToKnots(entity.speed)} kt` : null,
|
||||||
|
fmtHeading(entity?.heading),
|
||||||
|
fmtVerticalFpm(entity?.verticalRate),
|
||||||
|
entity?.squawk ? `Squawk ${escape(String(entity.squawk))}` : null,
|
||||||
|
]
|
||||||
|
return `<div class="kestrel-live-popup"><strong>${label}</strong> <span class="text-kestrel-muted">${tag}</span>${meta ? `<div class="text-kestrel-muted text-xs mt-0.5">${meta}</div>` : ''}${popupLine(escape, stats)}</div>`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entity?.source === 'ais' || category === 'surface') {
|
||||||
|
const mmsi = mmsiFromEntity(entity)
|
||||||
|
const meta = mmsi ? `MMSI ${escape(mmsi)}` : ''
|
||||||
|
const stats = [
|
||||||
|
Number.isFinite(entity?.speed) ? `${Number(entity.speed).toFixed(1)} kt` : null,
|
||||||
|
fmtHeading(entity?.heading),
|
||||||
|
]
|
||||||
|
return `<div class="kestrel-live-popup"><strong>${label}</strong> <span class="text-kestrel-muted">Vessel</span>${meta ? `<div class="text-kestrel-muted text-xs mt-0.5">${meta}</div>` : ''}${popupLine(escape, stats)}</div>`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<div class="kestrel-live-popup"><strong>${label}</strong> <span class="text-kestrel-muted">Team</span></div>`
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { createClusterIndex } from './mapCluster.js'
|
||||||
|
import { syncFeatureMarkers } from './mapMarkerSync.js'
|
||||||
|
import { cotCategory, formatCotPopup, getCotIconHtml } from './cotDisplay.js'
|
||||||
|
|
||||||
|
const ICON_SIZE = 28
|
||||||
|
const CLUSTER = createClusterIndex({ radius: 50, maxZoom: 14, minPoints: 2 })
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div')
|
||||||
|
div.textContent = text
|
||||||
|
return div.innerHTML
|
||||||
|
}
|
||||||
|
|
||||||
|
export function entitiesToFeatures(entities) {
|
||||||
|
return (entities || [])
|
||||||
|
.filter(e => typeof e?.lat === 'number' && typeof e?.lng === 'number' && e?.id)
|
||||||
|
.map(e => ({
|
||||||
|
type: 'Feature',
|
||||||
|
geometry: { type: 'Point', coordinates: [e.lng, e.lat] },
|
||||||
|
properties: { entity: e, cotCategory: cotCategory(e.type) },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadCotCluster(entities) {
|
||||||
|
CLUSTER.load(entitiesToFeatures(entities))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCotClusters(view) {
|
||||||
|
return CLUSTER.query(view)
|
||||||
|
}
|
||||||
|
|
||||||
|
function featureKey(feature) {
|
||||||
|
const props = feature.properties ?? {}
|
||||||
|
if (props.cluster) return `c:${props.cluster_id}`
|
||||||
|
const id = props.entity?.id
|
||||||
|
return id != null ? `e:${id}` : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function clusterIcon(L, count) {
|
||||||
|
const size = count < 10 ? 28 : count < 100 ? 34 : 40
|
||||||
|
return L.divIcon({
|
||||||
|
className: 'cot-cluster-icon',
|
||||||
|
html: `<span class="cot-cluster">${count}</span>`,
|
||||||
|
iconSize: [size, size],
|
||||||
|
iconAnchor: [size / 2, size / 2],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function entityIcon(L, entity) {
|
||||||
|
const { html, className } = getCotIconHtml(entity)
|
||||||
|
return L.divIcon({
|
||||||
|
className: `poi-div-icon cot-entity-icon ${className}`,
|
||||||
|
html,
|
||||||
|
iconSize: [ICON_SIZE, ICON_SIZE],
|
||||||
|
iconAnchor: [ICON_SIZE / 2, ICON_SIZE / 2],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function coords(feature) {
|
||||||
|
const [lng, lat] = feature.geometry.coordinates
|
||||||
|
return { lat, lng }
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachClusterClick(marker, feature, map) {
|
||||||
|
marker.on('click', () => {
|
||||||
|
const { lat, lng } = coords(feature)
|
||||||
|
const props = feature.properties ?? {}
|
||||||
|
const zoom = props.expansionZoom ?? map.getZoom() + 2
|
||||||
|
map.setView([lat, lng], Math.min(zoom, 19), { animate: true })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCotLayer(L, map) {
|
||||||
|
return L.layerGroup().addTo(map)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncCotLayer(L, map, layer, features) {
|
||||||
|
syncFeatureMarkers(layer, features, {
|
||||||
|
keyFor: featureKey,
|
||||||
|
create: (feature) => {
|
||||||
|
const { lat, lng } = coords(feature)
|
||||||
|
const props = feature.properties ?? {}
|
||||||
|
const isCluster = Boolean(props.cluster)
|
||||||
|
const icon = isCluster ? clusterIcon(L, props.point_count) : entityIcon(L, props.entity)
|
||||||
|
const marker = L.marker([lat, lng], { icon })
|
||||||
|
if (isCluster) attachClusterClick(marker, feature, map)
|
||||||
|
else if (props.entity) {
|
||||||
|
marker.bindPopup(
|
||||||
|
formatCotPopup(props.entity, escapeHtml),
|
||||||
|
{ className: 'kestrel-live-popup-wrap', maxWidth: 360 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return marker
|
||||||
|
},
|
||||||
|
update: (marker, feature) => {
|
||||||
|
const { lat, lng } = coords(feature)
|
||||||
|
const props = feature.properties ?? {}
|
||||||
|
const isCluster = Boolean(props.cluster)
|
||||||
|
marker.setLatLng([lat, lng])
|
||||||
|
const icon = isCluster ? clusterIcon(L, props.point_count) : entityIcon(L, props.entity)
|
||||||
|
marker.setIcon(icon)
|
||||||
|
if (!isCluster && props.entity) {
|
||||||
|
marker.setPopupContent(formatCotPopup(props.entity, escapeHtml))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
+20
-78
@@ -1,88 +1,30 @@
|
|||||||
/**
|
/** Client-side logger: sends to server, falls back to console. */
|
||||||
* Client-side logger that sends logs to server for debugging.
|
const sessionId = ref(null)
|
||||||
* Falls back to console if server logging fails.
|
const userId = ref(null)
|
||||||
*/
|
|
||||||
|
|
||||||
let sessionId = null
|
const CONSOLE_METHOD = Object.freeze({ error: 'error', warn: 'warn', info: 'log', debug: 'log' })
|
||||||
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 = sessId
|
sessionId.value = sessId
|
||||||
userId = uid
|
userId.value = uid
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function sendToServer(level, message, data) {
|
||||||
* Send log to server (non-blocking).
|
setTimeout(() => {
|
||||||
* @param {string} level
|
$fetch('/api/log', {
|
||||||
* @param {string} message
|
method: 'POST',
|
||||||
* @param {object} data
|
body: { level, message, data, sessionId: sessionId.value, userId: userId.value, timestamp: new Date().toISOString() },
|
||||||
*/
|
credentials: 'include',
|
||||||
async function sendToServer(level, message, data) {
|
}).catch(() => { /* server down - don't spam console */ })
|
||||||
// Use setTimeout to avoid blocking - fire and forget
|
|
||||||
setTimeout(async () => {
|
|
||||||
try {
|
|
||||||
await $fetch('/api/log', {
|
|
||||||
method: 'POST',
|
|
||||||
body: {
|
|
||||||
level,
|
|
||||||
message,
|
|
||||||
data,
|
|
||||||
sessionId,
|
|
||||||
userId,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
credentials: 'include',
|
|
||||||
}).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) {
|
||||||
* Log at error level.
|
console[CONSOLE_METHOD[level]](`[${message}]`, data)
|
||||||
* @param {string} message
|
sendToServer(level, message, data)
|
||||||
* @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)
|
||||||
* Log at warn level.
|
export const logWarn = (message, data) => log('warn', message, data)
|
||||||
* @param {string} message
|
export const logInfo = (message, data) => log('info', message, data)
|
||||||
* @param {object} data
|
export const logDebug = (message, data) => log('debug', message, 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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import Supercluster from 'supercluster'
|
||||||
|
|
||||||
|
export function createClusterIndex(options = {}) {
|
||||||
|
const index = new Supercluster(options)
|
||||||
|
const state = { features: Object.freeze([]) }
|
||||||
|
|
||||||
|
return {
|
||||||
|
load(features) {
|
||||||
|
const list = Object.freeze([...(features ?? [])])
|
||||||
|
index.load(list)
|
||||||
|
state.features = list
|
||||||
|
},
|
||||||
|
query(view) {
|
||||||
|
if (!view || state.features.length === 0) return []
|
||||||
|
const { west, south, east, north, zoom } = view
|
||||||
|
return index.getClusters(
|
||||||
|
[west, south, east, north],
|
||||||
|
Math.floor(zoom ?? 14),
|
||||||
|
).map((feature) => {
|
||||||
|
if (!feature.properties?.cluster) return feature
|
||||||
|
return {
|
||||||
|
...feature,
|
||||||
|
properties: {
|
||||||
|
...feature.properties,
|
||||||
|
expansionZoom: index.getClusterExpansionZoom(feature.properties.cluster_id),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
const SYNC_KEY = '_kestrelMarkerSync'
|
||||||
|
|
||||||
|
const pointFeatures = (features, keyFor) => (features ?? [])
|
||||||
|
.filter(f => f?.geometry?.type === 'Point')
|
||||||
|
.map(f => ({ feature: f, key: keyFor(f) }))
|
||||||
|
.filter(({ key }) => key != null)
|
||||||
|
|
||||||
|
export function syncFeatureMarkers(layer, features, { keyFor, create, update }) {
|
||||||
|
const prev = layer[SYNC_KEY] ?? new Map()
|
||||||
|
const next = pointFeatures(features, keyFor).reduce((map, { feature, key }) => {
|
||||||
|
const existing = prev.get(key)
|
||||||
|
if (existing) {
|
||||||
|
update(existing, feature)
|
||||||
|
return new Map([...map, [key, existing]])
|
||||||
|
}
|
||||||
|
const marker = create(feature)
|
||||||
|
layer.addLayer(marker)
|
||||||
|
return new Map([...map, [key, marker]])
|
||||||
|
}, new Map())
|
||||||
|
|
||||||
|
Array.from(prev.entries())
|
||||||
|
.filter(([key]) => !next.has(key))
|
||||||
|
.forEach(([, marker]) => layer.removeLayer(marker))
|
||||||
|
|
||||||
|
layer[SYNC_KEY] = next
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearFeatureMarkers(layer) {
|
||||||
|
if (!layer) return
|
||||||
|
const prev = layer[SYNC_KEY]
|
||||||
|
if (prev) {
|
||||||
|
Array.from(prev.values()).forEach(marker => layer.removeLayer(marker))
|
||||||
|
layer[SYNC_KEY] = new Map()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
layer.clearLayers()
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# 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)
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# 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).
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# 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).
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# 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.
|
After Width: | Height: | Size: 4.2 MiB |
@@ -0,0 +1,44 @@
|
|||||||
|
# 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 |
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# 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.
|
After Width: | Height: | Size: 159 KiB |
@@ -0,0 +1,48 @@
|
|||||||
|
# ADS-B and AIS
|
||||||
|
|
||||||
|
Aircraft and vessels use the same **CoT** store as ATAK/iTAK. The map consumes `GET /api/cot/stream` (SSE, viewport bbox). Toggle **Air**, **Surface**, and **Team** on the map.
|
||||||
|
|
||||||
|
## Accuracy tiers
|
||||||
|
|
||||||
|
1. **Tactical (best):** local SDR/AIS receiver → [adsbcot](https://github.com/snstac/adsbcot) / [aiscot](https://github.com/snstac/aiscot) → KestrelOS CoT `:8089` (sub-second updates).
|
||||||
|
2. **Vessels (live OSINT):** AISStream WebSocket push as vessels transmit.
|
||||||
|
3. **Aircraft (awareness OSINT):** OpenSky bbox poll — not a live stream; typical lag ~5s.
|
||||||
|
|
||||||
|
For tactical use, run local receivers. Do not rely on OpenSky alone.
|
||||||
|
|
||||||
|
## Freshness
|
||||||
|
|
||||||
|
Tracks update via SSE `update` events (CoT `:8089`, AISStream) or coalesced `snapshot` after each OpenSky poll. Stale tracks are removed automatically (team ~90s, OSINT ~30s without a new fix).
|
||||||
|
|
||||||
|
OSINT feeds run only while a map client is connected (SSE subscriber). Keep the map tab visible for live updates.
|
||||||
|
|
||||||
|
## Self-hosted
|
||||||
|
|
||||||
|
**ADS-B:** [adsbcot](https://github.com/snstac/adsbcot) → `tls://host:8089`
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[adsbcot]
|
||||||
|
COT_URL = tls://kestrelos.example.com:8089
|
||||||
|
FEED_URL = tcp+beast://127.0.0.1:30005
|
||||||
|
```
|
||||||
|
|
||||||
|
**AIS:** [aiscot](https://github.com/snstac/aiscot) → `tls://host:8089`
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[aiscot]
|
||||||
|
COT_URL = tls://kestrelos.example.com:8089
|
||||||
|
FEED_URL = tcp://127.0.0.1:10110
|
||||||
|
```
|
||||||
|
|
||||||
|
Use KestrelOS credentials (see [atak-itak.md](atak-itak.md)).
|
||||||
|
|
||||||
|
## OSINT APIs (optional)
|
||||||
|
|
||||||
|
Set these only if you want viewport OSINT without local receivers:
|
||||||
|
|
||||||
|
| Variable | Purpose |
|
||||||
|
|----------|---------|
|
||||||
|
| `AISSTREAM_API_KEY` | AISStream WebSocket |
|
||||||
|
| `OPENSKY_CLIENT_ID` / `OPENSKY_CLIENT_SECRET` | OpenSky OAuth (recommended for production) |
|
||||||
|
|
||||||
|
UIDs: `ICAO.*` (ADS-B), `MMSI.*` (AIS). Icons follow CoT type (`a-f-A-*`, `a-f-S-*`, `a-f-G-*`).
|
||||||
@@ -2,5 +2,5 @@ apiVersion: v2
|
|||||||
name: kestrelos
|
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: 0.2.0
|
version: 1.1.12
|
||||||
appVersion: "0.2.0"
|
appVersion: "1.1.12"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ replicaCount: 1
|
|||||||
|
|
||||||
image:
|
image:
|
||||||
repository: git.keligrubb.com/keligrubb/kestrelos
|
repository: git.keligrubb.com/keligrubb/kestrelos
|
||||||
tag: 0.2.0
|
tag: 1.1.12
|
||||||
pullPolicy: IfNotPresent
|
pullPolicy: IfNotPresent
|
||||||
|
|
||||||
service:
|
service:
|
||||||
|
|||||||
+6
-1
@@ -27,14 +27,19 @@ 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 } }
|
||||||
: {}),
|
: {}),
|
||||||
|
|||||||
Generated
+5190
-6297
File diff suppressed because it is too large
Load Diff
+27
-25
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "kestrelos",
|
"name": "kestrelos",
|
||||||
"version": "0.2.0",
|
"version": "1.1.12",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -10,40 +10,42 @@
|
|||||||
"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.1",
|
"@nuxt/icon": "^2.5.0",
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
"hls.js": "^1.5.0",
|
"fast-xml-parser": "^5.10.1",
|
||||||
|
"hls.js": "^1.7.0",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"leaflet.offline": "^3.2.0",
|
"leaflet.offline": "^3.2.1",
|
||||||
"mediasoup": "^3.19.14",
|
"mediasoup": "^3.24.2",
|
||||||
"mediasoup-client": "^3.18.6",
|
"mediasoup-client": "^3.22.0",
|
||||||
"nuxt": "^4.0.0",
|
"nuxt": "^4.5.2",
|
||||||
"openid-client": "^6.8.2",
|
"openid-client": "^6.8.5",
|
||||||
"sqlite3": "^5.1.7",
|
"qrcode": "^1.5.4",
|
||||||
"vue": "^3.4.0",
|
"supercluster": "^9.0.0",
|
||||||
"vue-router": "^4.4.0",
|
"vue": "^3.5.41",
|
||||||
"ws": "^8.18.0"
|
"vue-router": "^5.2.0",
|
||||||
|
"ws": "^8.21.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@iconify-json/tabler": "^1.2.26",
|
"@iconify-json/tabler": "^1.2.38",
|
||||||
"@nuxt/eslint": "^1.15.0",
|
"@nuxt/eslint": "^1.17.0",
|
||||||
"@nuxt/test-utils": "^4.0.0",
|
"@nuxt/test-utils": "^4.1.0",
|
||||||
"@playwright/test": "^1.58.2",
|
"@playwright/test": "^1.62.1",
|
||||||
"@vitest/coverage-v8": "^4.0.0",
|
"@vitest/coverage-v8": "^4.1.10",
|
||||||
"@vue/test-utils": "^2.4.0",
|
"@vue/test-utils": "^2.4.11",
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^10.8.1",
|
||||||
"happy-dom": "^20.6.1",
|
"happy-dom": "^20.11.2",
|
||||||
"vitest": "^4.0.0"
|
"vitest": "^4.1.10"
|
||||||
},
|
|
||||||
"overrides": {
|
|
||||||
"tar": "^7.5.7"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { getDb, closeDb } from '../server/utils/db.js'
|
||||||
|
import { importAllAlprNodes } from '../server/utils/alpr.js'
|
||||||
|
|
||||||
|
try {
|
||||||
|
const db = await getDb()
|
||||||
|
console.log('[import-alpr] Fetching ALPR nodes from Overpass…')
|
||||||
|
const count = await importAllAlprNodes(db)
|
||||||
|
console.log(`[import-alpr] Cached ${count} nodes in SQLite.`)
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error('[import-alpr] Failed:', error?.message || error)
|
||||||
|
process.exitCode = 1
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
closeDb()
|
||||||
|
}
|
||||||
+43
-10
@@ -3,18 +3,51 @@ set -e
|
|||||||
|
|
||||||
# version
|
# version
|
||||||
msg="${CI_COMMIT_MESSAGE:-}"
|
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
|
bump=patch
|
||||||
echo "$msg" | grep -qi minor: && bump=minor
|
# 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
|
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)
|
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)
|
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
|
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"
|
newVersion="$major.$minor.$patch"
|
||||||
[ -z "$cur" ] && { echo "error: could not read version from package.json"; exit 1; }
|
|
||||||
|
|
||||||
# changelog entry (strip prefix from first line)
|
url="https://${CI_REPO_OWNER}:${GITEA_REPO_TOKEN}@${CI_FORGE_URL#https://}/${CI_REPO_OWNER}/${CI_REPO_NAME}.git"
|
||||||
changelogEntry=$(echo "$msg" | head -1 | awk '{sub(/^[mM]ajor:[ \t]*/,""); sub(/^[mM]inor:[ \t]*/,""); sub(/^[pP]atch:[ \t]*/,""); print}')
|
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"
|
[ -z "$changelogEntry" ] && changelogEntry="Release v$newVersion"
|
||||||
|
if [ -n "$CI_PR_DESCRIPTION" ]; then
|
||||||
|
changelogFull="- $changelogEntry
|
||||||
|
|
||||||
|
$CI_PR_DESCRIPTION"
|
||||||
|
else
|
||||||
|
changelogFull="- $changelogEntry"
|
||||||
|
fi
|
||||||
|
|
||||||
# bump files
|
# 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"/ { sub(/[0-9]+\.[0-9]+\.[0-9]+/, v) } { print }' package.json > package.json.tmp && mv package.json.tmp package.json
|
||||||
@@ -24,18 +57,18 @@ awk -v v="$newVersion" '/^ tag:/ { $0 = " tag: " v }; { print }' helm/kestrelo
|
|||||||
# changelog
|
# changelog
|
||||||
new="## [$newVersion] - $(date +%Y-%m-%d)
|
new="## [$newVersion] - $(date +%Y-%m-%d)
|
||||||
### Changed
|
### Changed
|
||||||
- $changelogEntry
|
$changelogFull
|
||||||
|
|
||||||
"
|
"
|
||||||
{ [ ! -f CHANGELOG.md ] && printf '# Changelog\n\n'; printf '%s' "$new"; [ -f CHANGELOG.md ] && cat CHANGELOG.md; } > CHANGELOG.md.tmp && mv CHANGELOG.md.tmp CHANGELOG.md
|
# 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
|
||||||
git config user.email "ci@kestrelos" && git config user.name "CI"
|
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 add package.json helm/kestrelos/Chart.yaml helm/kestrelos/values.yaml CHANGELOG.md
|
||||||
git commit -m "release v$newVersion [skip ci]"
|
git commit -m "release v$newVersion [skip ci]"
|
||||||
url="https://${CI_REPO_OWNER}:${GITEA_REPO_TOKEN}@${CI_FORGE_URL#https://}/${CI_REPO_OWNER}/${CI_REPO_NAME}.git"
|
|
||||||
git tag "v$newVersion"
|
git tag "v$newVersion"
|
||||||
# artifact for kaniko (tag list)
|
# artifact for docker (tag list)
|
||||||
printf '%s\n%s\n' "$newVersion" "latest" > .tags
|
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() { n=0; while ! "$@"; do n=$((n+1)); [ $n -ge 3 ] && return 1; sleep 2; done; }
|
||||||
retry git push "$url" HEAD:main "v$newVersion"
|
retry git push "$url" HEAD:main "v$newVersion"
|
||||||
@@ -43,11 +76,11 @@ retry git push "$url" HEAD:main "v$newVersion"
|
|||||||
# gitea release
|
# gitea release
|
||||||
body="## Changelog
|
body="## Changelog
|
||||||
### Changed
|
### Changed
|
||||||
- $changelogEntry
|
$changelogFull
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
- [Docker image](${CI_FORGE_URL}/${CI_REPO_OWNER}/-/packages/container/${CI_REPO_NAME})
|
- [Docker image](${CI_FORGE_URL}/${CI_REPO_OWNER}/-/packages/container/${CI_REPO_NAME})
|
||||||
- [Helm chart](${CI_FORGE_URL}/${CI_REPO_OWNER}/-/packages/helm)"
|
- [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"
|
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
|
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 \
|
wget -q -O /dev/null --post-file=/tmp/release.json \
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { getDb } from '../utils/db.js'
|
||||||
|
import { requireAuth } from '../utils/authHelpers.js'
|
||||||
|
import { getAlprCameras, parseBbox } from '../utils/alpr.js'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
requireAuth(event)
|
||||||
|
let bbox
|
||||||
|
try {
|
||||||
|
bbox = parseBbox(getQuery(event))
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: error?.statusCode || 400,
|
||||||
|
message: error?.message || 'invalid bbox',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const db = await getDb()
|
||||||
|
return getAlprCameras(db, bbox)
|
||||||
|
})
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
import { getAuthConfig } from '../../utils/authConfig.js'
|
import { getAuthConfig } from '../../utils/oidc.js'
|
||||||
|
|
||||||
export default defineEventHandler(() => getAuthConfig())
|
export default defineEventHandler(() => getAuthConfig())
|
||||||
|
|||||||
@@ -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/session.js'
|
import { getSessionMaxAgeDays } from '../../utils/constants.js'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const body = await readBody(event)
|
const body = await readBody(event)
|
||||||
@@ -15,6 +15,10 @@ 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,5 +1,5 @@
|
|||||||
import { getAuthConfig } from '../../../utils/authConfig.js'
|
|
||||||
import {
|
import {
|
||||||
|
getAuthConfig,
|
||||||
getOidcConfig,
|
getOidcConfig,
|
||||||
getOidcRedirectUri,
|
getOidcRedirectUri,
|
||||||
createOidcParams,
|
createOidcParams,
|
||||||
|
|||||||
@@ -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/session.js'
|
import { getSessionMaxAgeDays } from '../../../utils/constants.js'
|
||||||
|
|
||||||
const DEFAULT_ROLE = process.env.OIDC_DEFAULT_ROLE || 'member'
|
const DEFAULT_ROLE = process.env.OIDC_DEFAULT_ROLE || 'member'
|
||||||
|
|
||||||
@@ -74,6 +74,9 @@ 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()
|
||||||
|
|||||||
@@ -5,8 +5,11 @@ 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([getDb(), getActiveSessions()])
|
const [db, sessions] = await Promise.all([
|
||||||
|
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(r => rowToDevice(r)).filter(Boolean).map(sanitizeDeviceForResponse)
|
const devices = rows.map(rowToDevice).filter(Boolean).map(sanitizeDeviceForResponse)
|
||||||
return { devices, liveSessions: sessions }
|
return { devices, liveSessions: sessions }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
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) }
|
||||||
|
})
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { createEventStream } from 'h3'
|
||||||
|
import { requireAuth } from '../../utils/authHelpers.js'
|
||||||
|
import { getActiveEntitiesInBbox } from '../../utils/cotStore.js'
|
||||||
|
import { registerSubscriber } from '../../utils/cotSubscribers.js'
|
||||||
|
import { getCotSnapshotOpts } from '../../utils/cotSnapshot.js'
|
||||||
|
import { COT_SSE_HEARTBEAT_MS } from '../../utils/constants.js'
|
||||||
|
import { parseBboxParam, parseLayersParam } from '../../utils/cotEntityUtils.js'
|
||||||
|
import { scheduleTrackingFeedRefresh } from '../../utils/trackingFeed.js'
|
||||||
|
|
||||||
|
export default defineEventHandler((event) => {
|
||||||
|
requireAuth(event)
|
||||||
|
const query = getQuery(event)
|
||||||
|
const bbox = parseBboxParam(typeof query.bbox === 'string' ? query.bbox : undefined)
|
||||||
|
const layers = parseLayersParam(typeof query.layers === 'string' ? query.layers : undefined)
|
||||||
|
const snapshotOpts = getCotSnapshotOpts()
|
||||||
|
|
||||||
|
const stream = createEventStream(event)
|
||||||
|
|
||||||
|
const push = (eventName, data) => stream.push({ event: eventName, data })
|
||||||
|
|
||||||
|
const sendSnapshot = async () => {
|
||||||
|
const entities = await getActiveEntitiesInBbox(bbox, { ...snapshotOpts, layers })
|
||||||
|
await push('snapshot', JSON.stringify({ entities }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const unregister = registerSubscriber({ bbox, layers, push })
|
||||||
|
|
||||||
|
let heartbeat
|
||||||
|
|
||||||
|
stream.onClosed(async () => {
|
||||||
|
clearInterval(heartbeat)
|
||||||
|
unregister()
|
||||||
|
scheduleTrackingFeedRefresh()
|
||||||
|
})
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
scheduleTrackingFeedRefresh()
|
||||||
|
await sendSnapshot()
|
||||||
|
heartbeat = setInterval(() => {
|
||||||
|
push('heartbeat', '{}').catch(() => {})
|
||||||
|
}, COT_SSE_HEARTBEAT_MS)
|
||||||
|
})()
|
||||||
|
|
||||||
|
return stream.send()
|
||||||
|
})
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
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 }
|
||||||
|
}
|
||||||
|
})
|
||||||
+12
-10
@@ -1,4 +1,4 @@
|
|||||||
import { getDb } from '../utils/db.js'
|
import { getDb, withTransaction } 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,13 +7,15 @@ 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 { run, get } = await getDb()
|
const db = await getDb()
|
||||||
await run(
|
return withTransaction(db, async ({ run, get }) => {
|
||||||
'INSERT INTO devices (id, name, device_type, vendor, lat, lng, stream_url, source_type, config) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
await run(
|
||||||
[id, name, device_type, vendor, lat, lng, stream_url, source_type, config],
|
'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],
|
||||||
const row = await get('SELECT id, name, device_type, vendor, lat, lng, stream_url, source_type, config FROM devices WHERE id = ?', [id])
|
)
|
||||||
const device = rowToDevice(row)
|
const row = await get('SELECT id, name, device_type, vendor, lat, lng, stream_url, source_type, config FROM devices WHERE id = ?', [id])
|
||||||
if (!device) throw createError({ statusCode: 500, message: 'Device not found after insert' })
|
const device = rowToDevice(row)
|
||||||
return sanitizeDeviceForResponse(device)
|
if (!device) throw createError({ statusCode: 500, message: 'Device not found after insert' })
|
||||||
|
return sanitizeDeviceForResponse(device)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,55 +1,49 @@
|
|||||||
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.push('name = ?')
|
updates.name = body.name.trim()
|
||||||
params.push(body.name.trim())
|
|
||||||
}
|
}
|
||||||
if (DEVICE_TYPES.includes(body.device_type)) {
|
if (DEVICE_TYPES.includes(body.device_type)) {
|
||||||
updates.push('device_type = ?')
|
updates.device_type = body.device_type
|
||||||
params.push(body.device_type)
|
|
||||||
}
|
}
|
||||||
if (body.vendor !== undefined) {
|
if (body.vendor !== undefined) {
|
||||||
updates.push('vendor = ?')
|
updates.vendor = typeof body.vendor === 'string' && body.vendor.trim() ? body.vendor.trim() : null
|
||||||
params.push(typeof body.vendor === 'string' && body.vendor.trim() ? body.vendor.trim() : null)
|
|
||||||
}
|
}
|
||||||
if (Number.isFinite(body.lat)) {
|
if (Number.isFinite(body.lat)) {
|
||||||
updates.push('lat = ?')
|
updates.lat = body.lat
|
||||||
params.push(body.lat)
|
|
||||||
}
|
}
|
||||||
if (Number.isFinite(body.lng)) {
|
if (Number.isFinite(body.lng)) {
|
||||||
updates.push('lng = ?')
|
updates.lng = body.lng
|
||||||
params.push(body.lng)
|
|
||||||
}
|
}
|
||||||
if (typeof body.stream_url === 'string') {
|
if (typeof body.stream_url === 'string') {
|
||||||
updates.push('stream_url = ?')
|
updates.stream_url = body.stream_url.trim()
|
||||||
params.push(body.stream_url.trim())
|
|
||||||
}
|
}
|
||||||
if (SOURCE_TYPES.includes(body.source_type)) {
|
if (SOURCE_TYPES.includes(body.source_type)) {
|
||||||
updates.push('source_type = ?')
|
updates.source_type = body.source_type
|
||||||
params.push(body.source_type)
|
|
||||||
}
|
}
|
||||||
if (body.config !== undefined) {
|
if (body.config !== undefined) {
|
||||||
updates.push('config = ?')
|
updates.config = typeof body.config === 'string' ? body.config : (body.config != null ? JSON.stringify(body.config) : null)
|
||||||
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 (updates.length === 0) {
|
if (Object.keys(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
|
||||||
}
|
}
|
||||||
params.push(id)
|
const { query, params } = buildUpdateQuery('devices', null, updates)
|
||||||
await run(`UPDATE devices SET ${updates.join(', ')} WHERE id = ?`, params)
|
if (query) {
|
||||||
|
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,35 +1,38 @@
|
|||||||
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' })
|
||||||
|
|
||||||
const session = getLiveSession(id)
|
return await acquire(`session-delete-${id}`, async () => {
|
||||||
if (!session) throw createError({ statusCode: 404, message: 'Live session not found' })
|
const session = getLiveSession(id)
|
||||||
if (session.userId !== user.id) throw createError({ statusCode: 403, message: 'Forbidden' })
|
if (!session) throw createError({ statusCode: 404, message: 'Live session not found' })
|
||||||
|
if (session.userId !== user.id) throw createError({ statusCode: 403, message: 'Forbidden' })
|
||||||
|
|
||||||
// Clean up producer if it exists
|
// Clean up producer if it exists
|
||||||
if (session.producerId) {
|
if (session.producerId) {
|
||||||
const producer = getProducer(session.producerId)
|
const producer = getProducer(session.producerId)
|
||||||
if (producer) {
|
if (producer) {
|
||||||
producer.close()
|
producer.close()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up transport if it exists
|
// Clean up transport if it exists
|
||||||
if (session.transportId) {
|
if (session.transportId) {
|
||||||
const transport = getTransport(session.transportId)
|
const transport = getTransport(session.transportId)
|
||||||
if (transport) {
|
if (transport) {
|
||||||
transport.close()
|
transport.close()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up router
|
// Clean up router
|
||||||
await closeRouter(id)
|
await closeRouter(id)
|
||||||
|
|
||||||
deleteLiveSession(id)
|
await deleteLiveSession(id)
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,31 +1,57 @@
|
|||||||
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) {
|
if (Object.keys(updates).length === 0) {
|
||||||
updateLiveSession(id, updates)
|
// No updates, just return current session
|
||||||
|
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,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = getLiveSession(id)
|
// Use lock to atomically check and update session
|
||||||
return {
|
return await acquire(`session-patch-${id}`, async () => {
|
||||||
id: updated.id,
|
const session = getLiveSession(id)
|
||||||
label: updated.label,
|
if (!session) throw createError({ statusCode: 404, message: 'Live session not found' })
|
||||||
lat: updated.lat,
|
if (session.userId !== user.id) throw createError({ statusCode: 403, message: 'Forbidden' })
|
||||||
lng: updated.lng,
|
|
||||||
updatedAt: updated.updatedAt,
|
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 {
|
||||||
|
id: updated.id,
|
||||||
|
label: updated.label,
|
||||||
|
lat: updated.lat,
|
||||||
|
lng: updated.lng,
|
||||||
|
updatedAt: updated.updatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
if (err.message === 'Session not found') {
|
||||||
|
throw createError({ statusCode: 404, message: 'Live session not found' })
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,40 +1,44 @@
|
|||||||
import { requireAuth } from '../../utils/authHelpers.js'
|
import { requireAuth } from '../../utils/authHelpers.js'
|
||||||
import {
|
import {
|
||||||
createSession,
|
getOrCreateSession,
|
||||||
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() : ''
|
const label = typeof body?.label === 'string' ? body.label.trim().slice(0, 100) : ''
|
||||||
|
|
||||||
// Replace any existing live session for this user (one session per user)
|
// Atomically get or create session, replacing existing if needed
|
||||||
const existing = getActiveSessionByUserId(user.id)
|
return await acquire(`session-start-${user.id}`, async () => {
|
||||||
if (existing) {
|
const existing = await getActiveSessionByUserId(user.id)
|
||||||
if (existing.producerId) {
|
if (existing) {
|
||||||
const producer = getProducer(existing.producerId)
|
// Clean up existing session resources
|
||||||
if (producer) producer.close()
|
if (existing.producerId) {
|
||||||
|
const producer = getProducer(existing.producerId)
|
||||||
|
if (producer) producer.close()
|
||||||
|
}
|
||||||
|
if (existing.transportId) {
|
||||||
|
const transport = getTransport(existing.transportId)
|
||||||
|
if (transport) transport.close()
|
||||||
|
}
|
||||||
|
if (existing.routerId) {
|
||||||
|
await closeRouter(existing.id).catch((err) => {
|
||||||
|
console.error('[live.start] Error closing previous router:', err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
await deleteLiveSession(existing.id)
|
||||||
|
console.log('[live.start] Replaced previous session:', existing.id)
|
||||||
}
|
}
|
||||||
if (existing.transportId) {
|
|
||||||
const transport = getTransport(existing.transportId)
|
|
||||||
if (transport) transport.close()
|
|
||||||
}
|
|
||||||
if (existing.routerId) {
|
|
||||||
await closeRouter(existing.id).catch((err) => {
|
|
||||||
console.error('[live.start] Error closing previous router:', err)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
deleteLiveSession(existing.id)
|
|
||||||
console.log('[live.start] Replaced previous session:', existing.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = createSession(user.id, label || `Live: ${user.identifier || 'User'}`)
|
const session = await getOrCreateSession(user.id, label || `Live: ${user.identifier || 'User'}`)
|
||||||
console.log('[live.start] Session created:', { id: session.id, userId: user.id, label: session.label })
|
console.log('[live.start] Session ready:', { 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) => {
|
||||||
requireAuth(event) // Verify authentication
|
const user = 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,8 +15,12 @@ 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
|
|
||||||
// The transportId ensures they can only connect transports they created
|
// Verify user has permission to connect transport for this session
|
||||||
|
// 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) => {
|
||||||
requireAuth(event) // Verify authentication
|
const user = 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,6 +15,12 @@ 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' })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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)
|
||||||
@@ -11,33 +12,48 @@ 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' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = getLiveSession(sessionId)
|
return await acquire(`create-producer-${sessionId}`, async () => {
|
||||||
if (!session) {
|
const session = getLiveSession(sessionId)
|
||||||
throw createError({ statusCode: 404, message: 'Session not found' })
|
if (!session) {
|
||||||
}
|
throw createError({ statusCode: 404, message: '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' })
|
||||||
|
}
|
||||||
|
|
||||||
const transport = getTransport(transportId)
|
const transport = getTransport(transportId)
|
||||||
if (!transport) {
|
if (!transport) {
|
||||||
throw createError({ statusCode: 404, message: 'Transport not found' })
|
throw createError({ statusCode: 404, message: 'Transport not found' })
|
||||||
}
|
}
|
||||||
|
|
||||||
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', () => {
|
producer.on('close', async () => {
|
||||||
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) {
|
||||||
updateLiveSession(sessionId, { producerId: null })
|
try {
|
||||||
|
await updateLiveSession(sessionId, { producerId: null })
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
// Ignore errors during cleanup
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
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 {
|
||||||
|
id: producer.id,
|
||||||
|
kind: producer.kind,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
updateLiveSession(sessionId, { producerId: producer.id })
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: producer.id,
|
|
||||||
kind: producer.kind,
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ 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)
|
||||||
@@ -12,28 +13,38 @@ export default defineEventHandler(async (event) => {
|
|||||||
throw createError({ statusCode: 400, message: 'sessionId required' })
|
throw createError({ statusCode: 400, message: 'sessionId required' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = getLiveSession(sessionId)
|
return await acquire(`create-transport-${sessionId}`, async () => {
|
||||||
if (!session) {
|
const session = getLiveSession(sessionId)
|
||||||
throw createError({ statusCode: 404, message: 'Session not found' })
|
if (!session) {
|
||||||
}
|
throw createError({ statusCode: 404, message: 'Session not found' })
|
||||||
|
}
|
||||||
|
|
||||||
// Only publisher (session owner) can create producer transport
|
// Only publisher (session owner) can create producer transport
|
||||||
// Viewers can create consumer transports
|
// Viewers can create consumer transports
|
||||||
if (isProducer && session.userId !== user.id) {
|
if (isProducer && session.userId !== user.id) {
|
||||||
throw createError({ statusCode: 403, message: 'Forbidden' })
|
throw createError({ statusCode: 403, message: 'Forbidden' })
|
||||||
}
|
}
|
||||||
|
|
||||||
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, requestHost)
|
||||||
|
|
||||||
if (isProducer) {
|
if (isProducer) {
|
||||||
updateLiveSession(sessionId, {
|
try {
|
||||||
transportId: transport.id,
|
await updateLiveSession(sessionId, {
|
||||||
routerId: router.id,
|
transportId: transport.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) => {
|
||||||
requireAuth(event)
|
const user = requireAuth(event)
|
||||||
const sessionId = getQuery(event).sessionId
|
const sessionId = getQuery(event).sessionId
|
||||||
|
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
@@ -15,6 +15,11 @@ 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
|
||||||
})
|
})
|
||||||
|
|||||||
+6
-27
@@ -1,32 +1,11 @@
|
|||||||
/**
|
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) => {
|
|
||||||
// Note: Auth is optional - we rely on session cookie validation if needed
|
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
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 logPrefix = `[CLIENT${sessionId ? `:${sessionId}` : ''}${userId ? `:${userId.slice(0, 8)}` : ''}]`
|
const msg = data ? `${message} ${JSON.stringify(data)}` : message
|
||||||
const logMessage = data ? `${message} ${JSON.stringify(data)}` : message
|
const method = CONSOLE_METHOD[level] || 'log'
|
||||||
|
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,5 +1,11 @@
|
|||||||
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 { 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',
|
||||||
|
avatar_url: user.avatar_path ? '/api/me/avatar' : null,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
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 }
|
||||||
|
})
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
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' })
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
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 }
|
||||||
|
})
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
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 }
|
||||||
|
})
|
||||||
@@ -1,18 +1,15 @@
|
|||||||
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)) {
|
if (!Number.isFinite(lat) || !Number.isFinite(lng)) throw createError({ statusCode: 400, message: 'lat and lng required as numbers' })
|
||||||
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 = ICON_TYPES.includes(body?.iconType) ? body.iconType : 'pin'
|
const iconType = POI_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(
|
||||||
|
|||||||
@@ -1,40 +1,37 @@
|
|||||||
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']
|
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) || {}
|
const body = (await readBody(event)) || {}
|
||||||
const updates = []
|
const updates = {}
|
||||||
const params = []
|
|
||||||
if (typeof body.label === 'string') {
|
if (typeof body.label === 'string') {
|
||||||
updates.push('label = ?')
|
updates.label = body.label.trim()
|
||||||
params.push(body.label.trim())
|
|
||||||
}
|
}
|
||||||
if (ICON_TYPES.includes(body.iconType)) {
|
if (POI_ICON_TYPES.includes(body.iconType)) {
|
||||||
updates.push('icon_type = ?')
|
updates.icon_type = body.iconType
|
||||||
params.push(body.iconType)
|
|
||||||
}
|
}
|
||||||
if (Number.isFinite(body.lat)) {
|
if (Number.isFinite(body.lat)) {
|
||||||
updates.push('lat = ?')
|
updates.lat = body.lat
|
||||||
params.push(body.lat)
|
|
||||||
}
|
}
|
||||||
if (Number.isFinite(body.lng)) {
|
if (Number.isFinite(body.lng)) {
|
||||||
updates.push('lng = ?')
|
updates.lng = body.lng
|
||||||
params.push(body.lng)
|
|
||||||
}
|
}
|
||||||
if (updates.length === 0) {
|
if (Object.keys(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()
|
||||||
await run(`UPDATE pois SET ${updates.join(', ')} WHERE id = ?`, params)
|
const { query, params } = buildUpdateQuery('pois', null, updates)
|
||||||
|
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
|
||||||
|
|||||||
+16
-14
@@ -1,4 +1,4 @@
|
|||||||
import { getDb } from '../utils/db.js'
|
import { getDb, withTransaction } 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,18 +21,20 @@ 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 { run, get } = await getDb()
|
const db = await getDb()
|
||||||
const existing = await get('SELECT id FROM users WHERE identifier = ?', [identifier])
|
return withTransaction(db, async ({ run, get }) => {
|
||||||
if (existing) {
|
const existing = await get('SELECT id FROM users WHERE identifier = ?', [identifier])
|
||||||
throw createError({ statusCode: 409, message: 'Identifier already in use' })
|
if (existing) {
|
||||||
}
|
throw createError({ statusCode: 409, message: 'Identifier already in use' })
|
||||||
|
}
|
||||||
|
|
||||||
const id = crypto.randomUUID()
|
const id = crypto.randomUUID()
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
await run(
|
await run(
|
||||||
'INSERT INTO users (id, identifier, password_hash, role, created_at, auth_provider, oidc_issuer, oidc_sub) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
'INSERT INTO users (id, identifier, password_hash, role, created_at, auth_provider, oidc_issuer, oidc_sub) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||||
[id, identifier, hashPassword(password), role, now, 'local', null, null],
|
[id, identifier, hashPassword(password), role, now, 'local', null, null],
|
||||||
)
|
)
|
||||||
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
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { getDb } from '../../utils/db.js'
|
import { getDb, withTransaction } 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']
|
||||||
|
|
||||||
@@ -9,52 +10,52 @@ 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 { run, get } = await getDb()
|
const db = await getDb()
|
||||||
|
|
||||||
const user = await get('SELECT id, identifier, role, auth_provider, password_hash FROM users WHERE id = ?', [id])
|
return withTransaction(db, async ({ run, get }) => {
|
||||||
if (!user) throw createError({ statusCode: 404, message: 'User not found' })
|
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' })
|
||||||
|
|
||||||
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.push('role = ?')
|
|
||||||
params.push(role)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user.auth_provider === 'local') {
|
|
||||||
if (body?.identifier !== undefined) {
|
|
||||||
const identifier = body.identifier?.trim()
|
|
||||||
if (!identifier || identifier.length < 1) {
|
|
||||||
throw createError({ statusCode: 400, message: 'identifier cannot be empty' })
|
|
||||||
}
|
}
|
||||||
const existing = await get('SELECT id FROM users WHERE identifier = ? AND id != ?', [identifier, id])
|
updates.role = role
|
||||||
if (existing) {
|
|
||||||
throw createError({ statusCode: 409, message: 'Identifier already in use' })
|
|
||||||
}
|
|
||||||
updates.push('identifier = ?')
|
|
||||||
params.push(identifier)
|
|
||||||
}
|
}
|
||||||
if (body?.password !== undefined && body.password !== '') {
|
|
||||||
const password = body.password
|
if (user.auth_provider === 'local') {
|
||||||
if (typeof password !== 'string' || password.length < 1) {
|
if (body?.identifier !== undefined) {
|
||||||
throw createError({ statusCode: 400, message: 'password cannot be empty' })
|
const identifier = body.identifier?.trim()
|
||||||
|
if (!identifier || identifier.length < 1) {
|
||||||
|
throw createError({ statusCode: 400, message: 'identifier cannot be empty' })
|
||||||
|
}
|
||||||
|
const existing = await get('SELECT id FROM users WHERE identifier = ? AND id != ?', [identifier, id])
|
||||||
|
if (existing) {
|
||||||
|
throw createError({ statusCode: 409, message: 'Identifier already in use' })
|
||||||
|
}
|
||||||
|
updates.identifier = identifier
|
||||||
|
}
|
||||||
|
if (body?.password !== undefined && body.password !== '') {
|
||||||
|
const password = body.password
|
||||||
|
if (typeof password !== 'string' || password.length < 1) {
|
||||||
|
throw createError({ statusCode: 400, message: 'password cannot be empty' })
|
||||||
|
}
|
||||||
|
updates.password_hash = hashPassword(password)
|
||||||
}
|
}
|
||||||
updates.push('password_hash = ?')
|
|
||||||
params.push(hashPassword(password))
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (updates.length === 0) {
|
if (Object.keys(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' }
|
||||||
}
|
}
|
||||||
|
|
||||||
params.push(id)
|
const { query, params } = buildUpdateQuery('users', null, updates)
|
||||||
await run(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`, params)
|
if (query) {
|
||||||
const updated = await get('SELECT id, identifier, role, auth_provider FROM users WHERE id = ?', [id])
|
await run(query, [...params, id])
|
||||||
return updated
|
}
|
||||||
|
const updated = await get('SELECT id, identifier, role, auth_provider FROM users WHERE id = ?', [id])
|
||||||
|
return updated
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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/authSkipPaths.js'
|
import { skipAuth } from '../utils/authHelpers.js'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
if (skipAuth(event.path)) return
|
if (skipAuth(event.path)) return
|
||||||
@@ -10,10 +10,16 @@ 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 FROM users WHERE id = ?', [session.user_id])
|
const user = await get('SELECT id, identifier, role, auth_provider, avatar_path 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 = { id: user.id, identifier: user.identifier, role: user.role, auth_provider: authProvider }
|
event.context.user = {
|
||||||
|
id: user.id,
|
||||||
|
identifier: user.identifier,
|
||||||
|
role: user.role,
|
||||||
|
auth_provider: authProvider,
|
||||||
|
avatar_path: user.avatar_path ?? null,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import { createServer as createTcpServer } from 'node:net'
|
||||||
|
import { createServer as createTlsServer } from 'node:tls'
|
||||||
|
import { readFileSync, existsSync } from 'node:fs'
|
||||||
|
import { updateFromCot } from '../utils/cotStore.js'
|
||||||
|
import { parseTakStreamFrame, parseTraditionalXmlFrame, parseCotPayload } from '../utils/cotParser.js'
|
||||||
|
import { validateCotAuth } from '../utils/cotAuth.js'
|
||||||
|
import { getCotSslPaths, getCotPort } from '../utils/cotSsl.js'
|
||||||
|
import { registerCleanup } from '../utils/shutdown.js'
|
||||||
|
import { COT_AUTH_TIMEOUT_MS } from '../utils/constants.js'
|
||||||
|
import { acquire } from '../utils/asyncLock.js'
|
||||||
|
|
||||||
|
const serverState = {
|
||||||
|
tcpServer: null,
|
||||||
|
tlsServer: null,
|
||||||
|
}
|
||||||
|
const relaySet = new Set()
|
||||||
|
const allSockets = new Set()
|
||||||
|
const socketBuffers = new WeakMap()
|
||||||
|
const socketAuthTimeout = new WeakMap()
|
||||||
|
|
||||||
|
function clearAuthTimeout(socket) {
|
||||||
|
const t = socketAuthTimeout.get(socket)
|
||||||
|
if (t) {
|
||||||
|
clearTimeout(t)
|
||||||
|
socketAuthTimeout.delete(socket)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFromRelay(socket) {
|
||||||
|
relaySet.delete(socket)
|
||||||
|
allSockets.delete(socket)
|
||||||
|
clearAuthTimeout(socket)
|
||||||
|
socketBuffers.delete(socket)
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadcast(senderSocket, rawMessage) {
|
||||||
|
for (const s of relaySet) {
|
||||||
|
if (s !== senderSocket && !s.destroyed && s.writable) {
|
||||||
|
try {
|
||||||
|
s.write(rawMessage)
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error('[cot] Broadcast write error:', err?.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createPreview = (payload) => {
|
||||||
|
try {
|
||||||
|
const str = payload.toString('utf8')
|
||||||
|
if (str.startsWith('<')) {
|
||||||
|
const s = str.length <= 120 ? str : str.slice(0, 120) + '...'
|
||||||
|
// eslint-disable-next-line no-control-regex -- sanitize control chars for log preview
|
||||||
|
return s.replace(/[\u0000-\u0008\v\f\u000E-\u001F]/g, '.')
|
||||||
|
}
|
||||||
|
return 'hex:' + payload.subarray(0, Math.min(40, payload.length)).toString('hex')
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return 'hex:' + payload.subarray(0, Math.min(40, payload.length)).toString('hex')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processFrame(socket, rawMessage, payload, authenticated) {
|
||||||
|
const requireAuth = socket._cotRequireAuth !== false
|
||||||
|
const debug = socket._cotDebug === true
|
||||||
|
const parsed = parseCotPayload(payload)
|
||||||
|
if (debug) {
|
||||||
|
const preview = createPreview(payload)
|
||||||
|
console.log('[cot] payload length:', payload.length, 'parsed:', parsed ? parsed.type : null, 'preview:', preview)
|
||||||
|
}
|
||||||
|
if (!parsed) return
|
||||||
|
|
||||||
|
if (parsed.type === 'auth') {
|
||||||
|
if (authenticated) return
|
||||||
|
console.log('[cot] auth attempt username=', parsed.username)
|
||||||
|
// Use lock per socket to prevent concurrent auth attempts
|
||||||
|
const socketKey = `cot-auth-${socket.remoteAddress || 'unknown'}-${socket.remotePort || 0}`
|
||||||
|
await acquire(socketKey, async () => {
|
||||||
|
// Re-check authentication state after acquiring lock
|
||||||
|
if (socket._cotAuthenticated || socket.destroyed) return
|
||||||
|
try {
|
||||||
|
const valid = await validateCotAuth(parsed.username, parsed.password)
|
||||||
|
console.log('[cot] auth result valid=', valid, 'for username=', parsed.username)
|
||||||
|
if (!socket.writable || socket.destroyed) return
|
||||||
|
if (valid) {
|
||||||
|
clearAuthTimeout(socket)
|
||||||
|
relaySet.add(socket)
|
||||||
|
socket._cotAuthenticated = true
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
socket.destroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.log('[cot] auth validation error:', err?.message)
|
||||||
|
if (!socket.destroyed) socket.destroy()
|
||||||
|
}
|
||||||
|
}).catch((err) => {
|
||||||
|
console.log('[cot] auth lock error:', err?.message)
|
||||||
|
if (!socket.destroyed) socket.destroy()
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.type === 'cot') {
|
||||||
|
if (requireAuth && !authenticated) {
|
||||||
|
socket.destroy()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updateFromCot({ ...parsed, type: parsed.eventType }).catch((err) => {
|
||||||
|
console.error('[cot] Error updating from CoT:', err?.message)
|
||||||
|
})
|
||||||
|
if (authenticated) broadcast(socket, rawMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseFrame = (buf) => {
|
||||||
|
const takResult = parseTakStreamFrame(buf)
|
||||||
|
if (takResult) return { result: takResult, frameType: 'tak' }
|
||||||
|
if (buf[0] === 0x3C) {
|
||||||
|
const xmlResult = parseTraditionalXmlFrame(buf)
|
||||||
|
if (xmlResult) return { result: xmlResult, frameType: 'traditional' }
|
||||||
|
}
|
||||||
|
return { result: null, frameType: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
const processBufferedData = async (socket, buf, authenticated) => {
|
||||||
|
if (buf.length === 0) return buf
|
||||||
|
const { result, frameType } = parseFrame(buf)
|
||||||
|
if (result && socket._cotDebug) {
|
||||||
|
console.log('[cot] frame parsed as', frameType, 'bytesConsumed=', result.bytesConsumed)
|
||||||
|
}
|
||||||
|
if (!result) return buf
|
||||||
|
const { payload, bytesConsumed } = result
|
||||||
|
const rawMessage = buf.subarray(0, bytesConsumed)
|
||||||
|
await processFrame(socket, rawMessage, payload, authenticated)
|
||||||
|
if (socket.destroyed) return null
|
||||||
|
const remainingBuf = buf.subarray(bytesConsumed)
|
||||||
|
socketBuffers.set(socket, remainingBuf)
|
||||||
|
return processBufferedData(socket, remainingBuf, authenticated)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onData(socket, data) {
|
||||||
|
const existingBuf = socketBuffers.get(socket)
|
||||||
|
const buf = Buffer.concat([existingBuf || Buffer.alloc(0), data])
|
||||||
|
socketBuffers.set(socket, buf)
|
||||||
|
const authenticated = Boolean(socket._cotAuthenticated)
|
||||||
|
|
||||||
|
if (socket._cotDebug && buf.length > 0 && !socket._cotFirstChunkLogged) {
|
||||||
|
socket._cotFirstChunkLogged = true
|
||||||
|
const hex = buf.subarray(0, Math.min(80, buf.length)).toString('hex')
|
||||||
|
console.log('[cot] first chunk len=', buf.length, 'first bytes (hex):', hex, 'starts with 0xBF:', buf[0] === 0xBF, 'starts with <:', buf[0] === 0x3C)
|
||||||
|
}
|
||||||
|
await processBufferedData(socket, buf, authenticated)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupSocket(socket, tls = false) {
|
||||||
|
const remote = socket.remoteAddress || 'unknown'
|
||||||
|
console.log('[cot] client connected', tls ? '(TLS)' : '(TCP)', 'from', remote)
|
||||||
|
allSockets.add(socket)
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
socket._cotDebug = Boolean(config.cotDebug)
|
||||||
|
socket._cotRequireAuth = config.cotRequireAuth !== false
|
||||||
|
if (socket._cotRequireAuth) {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
if (!socket._cotAuthenticated && !socket.destroyed) {
|
||||||
|
console.log('[cot] auth timeout, closing connection from', remote)
|
||||||
|
socket.destroy()
|
||||||
|
}
|
||||||
|
}, COT_AUTH_TIMEOUT_MS)
|
||||||
|
socketAuthTimeout.set(socket, timeout)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
socket._cotAuthenticated = true
|
||||||
|
relaySet.add(socket)
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.on('data', data => onData(socket, data))
|
||||||
|
socket.on('error', (err) => {
|
||||||
|
console.error('[cot] Socket error:', err?.message)
|
||||||
|
})
|
||||||
|
socket.on('close', () => {
|
||||||
|
console.log('[cot] client disconnected', socket._cotAuthenticated ? '(was authenticated)' : '', 'from', remote)
|
||||||
|
removeFromRelay(socket)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function startCotServers() {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const { certPath, keyPath } = getCotSslPaths(config) || {}
|
||||||
|
const hasTls = certPath && keyPath && existsSync(certPath) && existsSync(keyPath)
|
||||||
|
const port = getCotPort()
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (hasTls) {
|
||||||
|
const tlsOpts = {
|
||||||
|
cert: readFileSync(certPath),
|
||||||
|
key: readFileSync(keyPath),
|
||||||
|
rejectUnauthorized: false,
|
||||||
|
}
|
||||||
|
serverState.tlsServer = createTlsServer(tlsOpts, socket => setupSocket(socket, true))
|
||||||
|
serverState.tlsServer.on('error', err => console.error('[cot] TLS server error:', err?.message))
|
||||||
|
serverState.tlsServer.listen(port, '0.0.0.0', () => {
|
||||||
|
console.log('[cot] CoT server listening on 0.0.0.0:' + port + ' (TLS) - use this port in ATAK/iTAK and enable SSL')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
serverState.tcpServer = createTcpServer(socket => setupSocket(socket, false))
|
||||||
|
serverState.tcpServer.on('error', err => console.error('[cot] TCP server error:', err?.message))
|
||||||
|
serverState.tcpServer.listen(port, '0.0.0.0', () => {
|
||||||
|
console.log('[cot] CoT server listening on 0.0.0.0:' + port + ' (plain TCP) - use this port in ATAK/iTAK with SSL disabled')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error('[cot] Failed to start CoT server:', err?.message)
|
||||||
|
if (err?.code === 'EADDRINUSE') {
|
||||||
|
console.error('[cot] Port', port, 'is already in use. Stop the other process or set COT_PORT to a different port.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineNitroPlugin((nitroApp) => {
|
||||||
|
nitroApp.hooks.hook('ready', startCotServers)
|
||||||
|
// Start immediately so CoT is up before first request in dev; ready may fire late in some setups.
|
||||||
|
setImmediate(startCotServers)
|
||||||
|
|
||||||
|
const cleanupServers = () => {
|
||||||
|
if (serverState.tcpServer) {
|
||||||
|
serverState.tcpServer.close()
|
||||||
|
serverState.tcpServer = null
|
||||||
|
}
|
||||||
|
if (serverState.tlsServer) {
|
||||||
|
serverState.tlsServer.close()
|
||||||
|
serverState.tlsServer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanupSockets = () => {
|
||||||
|
for (const s of allSockets) {
|
||||||
|
try {
|
||||||
|
s.destroy()
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
allSockets.clear()
|
||||||
|
relaySet.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
registerCleanup(async () => {
|
||||||
|
cleanupSockets()
|
||||||
|
cleanupServers()
|
||||||
|
})
|
||||||
|
|
||||||
|
nitroApp.hooks.hook('close', async () => {
|
||||||
|
cleanupSockets()
|
||||||
|
cleanupServers()
|
||||||
|
})
|
||||||
|
})
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user