8 Commits

Author SHA1 Message Date
CI
b18283d3b3 release v0.4.0 [skip ci] 2026-02-15 04:08:16 +00:00
0aab29ea72 minor: new nav system (#5)
All checks were successful
ci/woodpecker/push/push Pipeline was successful
Co-authored-by: Madison Grubb <madison@elastiflow.com>
Reviewed-on: #5
2026-02-15 04:04:54 +00:00
CI
9261ba92bf release v0.3.0 [skip ci] 2026-02-14 04:53:34 +00:00
17f28401ba minor: heavily simplify server and app content. unify styling (#4)
All checks were successful
ci/woodpecker/push/push Pipeline was successful
Co-authored-by: Madison Grubb <madison@elastiflow.com>
Reviewed-on: #4
2026-02-14 04:52:18 +00:00
CI
1a143d2f8e release v0.2.0 [skip ci] 2026-02-12 22:08:18 +00:00
a302a4a1a0 minor: add a new release system (#3)
All checks were successful
ci/woodpecker/push/push Pipeline was successful
# Changes

* package and release helm charts for the project
* configure a new release system based of semver
* add changelog entries via keep-a-changelog formatting
* add gitea releases

Co-authored-by: Madison Grubb <madison@elastiflow.com>
Reviewed-on: #3
2026-02-12 22:07:53 +00:00
547b94bac8 fix malformed path in docker publish (#2)
All checks were successful
ci/woodpecker/push/push Pipeline was successful
Co-authored-by: Madison Grubb <madison@elastiflow.com>
Reviewed-on: #2
2026-02-12 20:27:03 +00:00
28ac43e47b add ci (#1)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Co-authored-by: Madison Grubb <madison@elastiflow.com>
Reviewed-on: #1
2026-02-12 19:50:44 +00:00
84 changed files with 2115 additions and 2196 deletions

3
.gitignore vendored
View File

@@ -44,3 +44,6 @@ data
# Dev TLS certs (self-signed for local testing) # Dev TLS certs (self-signed for local testing)
.dev-certs .dev-certs
# CI artifact (kaniko reads .tags for image tag list)
.tags

View File

@@ -1,51 +0,0 @@
steps:
- name: lint
image: node:24-slim
commands:
- npm ci
- npm run lint
when:
- event: pull_request
- name: test
image: node:24-slim
environment:
CI: "true"
commands:
- npm run test
when:
- event: pull_request
- name: e2e
image: mcr.microsoft.com/playwright:v1.58.2-noble
commands:
- npm ci
- npm run test:e2e
environment:
CI: "true"
NODE_TLS_REJECT_UNAUTHORIZED: "0"
when:
- event: pull_request
- name: docker-build
image: woodpeckerci/plugin-docker-buildx
settings:
repo: git.keligrubb.com/${CI_REPO_OWNER}/${CI_REPO_NAME}
registry: git.keligrubb.com
tags: latest
dry_run: true
when:
- event: pull_request
- name: docker-build-push
image: woodpeckerci/plugin-docker-buildx
settings:
repo: git.keligrubb.com/${CI_REPO_OWNER}/${CI_REPO_NAME}
registry: git.keligrubb.com
tags: latest,${CI_COMMIT_SHA:0:7}
username: ${CI_REPO_OWNER}
password:
from_secret: gitea_registry_token
when:
- event: push
branch: main

38
.woodpecker/pr.yml Normal file
View File

@@ -0,0 +1,38 @@
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

36
.woodpecker/push.yml Normal file
View File

@@ -0,0 +1,36 @@
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

15
CHANGELOG.md Normal file
View File

@@ -0,0 +1,15 @@
## [0.4.0] - 2026-02-15
### Changed
- new nav system (#5)
## [0.3.0] - 2026-02-14
### Changed
- heavily simplify server and app content. unify styling (#4)
## [0.2.0] - 2026-02-12
### Changed
- add a new release system (#3)
# Changelog
All notable changes to this project will be documented in this file.

View File

@@ -1,5 +1,4 @@
# Build stage FROM node:24-slim AS builder
FROM node:22-alpine AS builder
WORKDIR /app WORKDIR /app
@@ -10,7 +9,7 @@ COPY . .
RUN npm run build RUN npm run build
# Run stage # Run stage
FROM node:22-alpine AS runner FROM node:24-slim AS runner
# Run as non-root user (node user exists in official image) # Run as non-root user (node user exists in official image)
USER node USER node

View File

@@ -22,7 +22,7 @@ Open http://localhost:3000. The app requires login by default; you will see the
Camera and geolocation in the browser require a **secure context** (HTTPS) when you open the app from your phone. To test Share live from a device on your LAN without buying a domain or cert: Camera and geolocation in the browser require a **secure context** (HTTPS) when you open the app from your phone. To test Share live from a device on your LAN without buying a domain or cert:
1. Generate a self-signed cert (once). Use your machines LAN IP so the phone can use it: 1. Generate a self-signed cert (once). Use your machine's LAN IP so the phone can use it:
```bash ```bash
chmod +x scripts/gen-dev-cert.sh chmod +x scripts/gen-dev-cert.sh
./scripts/gen-dev-cert.sh 192.168.1.123 ./scripts/gen-dev-cert.sh 192.168.1.123
@@ -34,7 +34,7 @@ Camera and geolocation in the browser require a **secure context** (HTTPS) when
npm run dev npm run dev
``` ```
3. On your phone, open **https://192.168.1.123:3000** (same IP you passed above). Accept the browsers “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,7 +48,7 @@ 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 machines LAN IP (from network interfaces) and uses it for WebRTC. Open **https://<your-LAN-IP>:3000** on both phone and laptop (same IP as for your dev cert). To override (e.g. Docker or multiple NICs), set `MEDIASOUP_ANNOUNCED_IP`. Ensure firewall allows UDP/TCP ports 4000049999 on the server. **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 4000049999 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 architecture details.
@@ -62,10 +62,10 @@ See [docs/live-streaming.md](docs/live-streaming.md) for architecture details.
## Configuration ## Configuration
- **Feeds**: Edit `server/data/feeds.json` to add cameras/feeds. Each feed needs `id`, `name`, `lat`, `lng`, `streamUrl`, and `sourceType` (`mjpeg` or `hls`). Home Assistant and other sources use the same shape; use proxy URLs for HA. - **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`).
- **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 `PORT` as needed (e.g. in Docker/Helm).
- **Authentication**: The login page always offers password sign-in (local). Optionally set `BOOTSTRAP_EMAIL` and `BOOTSTRAP_PASSWORD` before the first run to create the first admin; otherwise a default admin is created and its credentials are printed in the terminal. To also show an OIDC sign-in button, configure `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, and optionally `OIDC_LABEL`, `OIDC_REDIRECT_URI`. See [docs/auth.md](docs/auth.md) for 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 provider-specific examples.
- **Bootstrap admin** (when using local auth): The server initializes the database and runs bootstrap at startup. On first run (no users in the database), it creates the first admin. If you set `BOOTSTRAP_EMAIL` and `BOOTSTRAP_PASSWORD` before starting, that account is created. If you dont 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
@@ -77,16 +77,34 @@ docker run -p 3000:3000 kestrelos:latest
## Kubernetes (Helm) ## Kubernetes (Helm)
**From Gitea registry:**
```bash ```bash
helm install kestrelos ./helm/kestrelos --set image.repository=your-registry/kestrelos --set image.tag=latest helm repo add keligrubb --username YOUR_USER --password YOUR_TOKEN https://git.keligrubb.com/api/packages/keligrubb/helm
helm repo update
helm install kestrelos keligrubb/kestrelos
```
**From source:**
```bash
helm install kestrelos ./helm/kestrelos
``` ```
Health: `GET /health` (overview), `GET /health/live` (liveness), `GET /health/ready` (readiness). Probes are configured in the Helm chart. Optional: enable Ingress in `helm/kestrelos/values.yaml`. Health: `GET /health` (overview), `GET /health/live` (liveness), `GET /health/ready` (readiness). Probes are configured in the Helm chart. Optional: enable Ingress in `helm/kestrelos/values.yaml`.
## Releases
Merges to `main` trigger a semver release. Use one of these prefixes in your PR title to set the version bump:
- `major:` breaking changes
- `minor:` new features
- `patch:` bug fixes, docs (default if no prefix)
Example: `minor: Add map layer toggle`
## Security ## Security
- Feed list is validated server-side (`getValidFeeds`); only valid entries are returned. - Device data is validated server-side; only valid entries are returned.
- Stream URLs are treated as untrusted; the UI only uses `http://` or `https://` URLs for display. - Stream URLs are sanitized to `http://` or `https://` only; other protocols are rejected.
## License ## License

View File

@@ -1,5 +1,5 @@
<template> <template>
<NuxtLayout> <NuxtLayout>
<NuxtPage /> <NuxtPage :key="$route.path" />
</NuxtLayout> </NuxtLayout>
</template> </template>

134
app/assets/css/main.css Normal file
View File

@@ -0,0 +1,134 @@
@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-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 .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 .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 .savetiles.leaflet-bar a:hover {
@apply bg-kestrel-surface-hover text-kestrel-accent shadow-glow-md text-shadow-glow-md;
}
.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 {
@apply flex items-center justify-center p-0 cursor-pointer;
}
.kestrel-map-container .leaflet-control-locate svg {
color: currentColor;
}
.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; }
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -0,0 +1,89 @@
<template>
<div class="flex min-h-0 flex-1 flex-col">
<header class="relative z-40 flex h-14 shrink-0 items-center gap-3 bg-kestrel-surface px-4">
<NuxtLink
to="/"
class="text-lg font-semibold tracking-wide text-kestrel-text no-underline text-shadow-glow-md transition-colors hover:text-kestrel-accent focus-visible:ring-2 focus-visible:ring-kestrel-accent focus-visible:rounded"
>
KestrelOS
</NuxtLink>
<button
type="button"
class="rounded p-2 text-kestrel-muted transition-colors hover:bg-kestrel-border hover:text-kestrel-accent md:hidden"
aria-label="Toggle navigation"
:aria-expanded="drawerOpen"
@click="drawerOpen = !drawerOpen"
>
<span
class="text-lg leading-none"
aria-hidden="true"
>&#9776;</span>
</button>
<div class="min-w-0 flex-1" />
<div class="flex items-center gap-2">
<UserMenu
v-if="user"
:user="user"
@signout="onLogout"
/>
<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, 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>

View File

@@ -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>

View File

@@ -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() {

View File

@@ -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>

View File

@@ -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>

View File

@@ -7,13 +7,13 @@
<div <div
v-if="contextMenu.type" v-if="contextMenu.type"
ref="contextMenuRef" ref="contextMenuRef"
class="pointer-events-auto absolute z-[1000] min-w-[120px] rounded border border-kestrel-border bg-kestrel-surface py-1 shadow-glow [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,176 +37,16 @@
</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
type="button"
class="absolute inset-0 bg-black/60 transition-opacity"
aria-label="Close"
@click="closePoiModal"
/> />
<!-- Add / Edit form -->
<div
v-if="poiModalMode === 'add' || poiModalMode === 'edit'"
ref="poiModalRef"
class="relative w-full max-w-md rounded-lg border border-kestrel-border bg-kestrel-surface p-6 shadow-glow [box-shadow:0_0_32px_-8px_rgba(34,201,201,0.25)]"
@click.stop
>
<h2
id="poi-modal-title"
class="mb-4 text-lg font-semibold tracking-wide text-kestrel-text [text-shadow:0_0_8px_rgba(34,201,201,0.25)]"
>
{{ poiModalMode === 'edit' ? 'Edit POI' : 'Add POI' }}
</h2>
<form
class="space-y-4"
@submit.prevent="submitPoiModal"
>
<div>
<label
for="add-poi-label"
class="mb-1.5 block text-xs font-medium uppercase tracking-wider text-kestrel-muted"
>
Label (optional)
</label>
<input
id="add-poi-label"
v-model="poiForm.label"
type="text"
placeholder="e.g. Rally point"
class="w-full rounded border border-kestrel-border bg-kestrel-bg px-3 py-2 text-sm text-kestrel-text placeholder:text-kestrel-muted outline-none transition-colors focus:border-kestrel-accent"
autocomplete="off"
>
</div>
<div>
<label
class="mb-1.5 block text-xs font-medium uppercase tracking-wider text-kestrel-muted"
>
Icon type
</label>
<div
:ref="el => iconDropdownOpen && (iconDropdownRef.value = el)"
class="relative inline-block w-full"
>
<button
type="button"
class="flex w-full min-w-0 items-center justify-between gap-2 rounded border border-kestrel-border bg-kestrel-bg px-3 py-2 text-left text-sm text-kestrel-text transition-colors hover:border-kestrel-accent/50"
:aria-expanded="iconDropdownOpen"
aria-haspopup="listbox"
:aria-label="`Icon type: ${poiForm.iconType}`"
@click="iconDropdownOpen = !iconDropdownOpen"
>
<span class="flex items-center gap-2 capitalize">
<Icon
:name="POI_ICONIFY_IDS[poiForm.iconType]"
class="size-4 shrink-0"
/>
{{ poiForm.iconType }}
</span>
<span
class="text-kestrel-muted transition-transform"
:class="iconDropdownOpen && 'rotate-180'"
>
</span>
</button>
<Transition
enter-active-class="transition duration-100 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition duration-75 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div
v-show="iconDropdownOpen"
class="absolute left-0 right-0 top-full z-10 mt-1 rounded border border-kestrel-border bg-kestrel-surface py-1 shadow-glow [box-shadow:0_4px_12px_-2px_rgba(34,201,201,0.15)]"
role="listbox"
>
<button
v-for="opt in POI_ICON_TYPES"
:key="opt"
type="button"
role="option"
:aria-selected="poiForm.iconType === opt"
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm capitalize transition-colors"
:class="poiForm.iconType === opt
? 'bg-kestrel-accent-dim text-kestrel-accent'
: 'text-kestrel-text hover:bg-kestrel-border'"
@click="poiForm.iconType = opt; iconDropdownOpen = false"
>
<Icon
:name="POI_ICONIFY_IDS[opt]"
class="size-4 shrink-0"
/>
{{ opt }}
</button>
</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>
@@ -214,10 +54,6 @@
import 'leaflet/dist/leaflet.css' import 'leaflet/dist/leaflet.css'
const props = defineProps({ const props = defineProps({
feeds: {
type: Array,
default: () => [],
},
devices: { devices: {
type: Array, type: Array,
default: () => [], default: () => [],
@@ -248,14 +84,11 @@ const liveMarkersRef = ref({})
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 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'
@@ -263,11 +96,7 @@ const ATTRIBUTION = '&copy; <a href="https://www.openstreetmap.org/copyright">Op
const DEFAULT_VIEW = [37.7749, -122.4194] const DEFAULT_VIEW = [37.7749, -122.4194]
const DEFAULT_ZOOM = 17 const DEFAULT_ZOOM = 17
const MARKER_ICON_PATH = '/' const MARKER_ICON_PATH = '/'
const POI_ICON_TYPES = ['pin', 'flag', 'waypoint']
const POI_TOOLTIP_CLASS = 'kestrel-poi-tooltip' const POI_TOOLTIP_CLASS = 'kestrel-poi-tooltip'
/** Tabler icon names (Nuxt Icon / Iconify) modern technical aesthetic. */
const POI_ICONIFY_IDS = { pin: 'tabler:map-pin', flag: 'tabler:flag', waypoint: 'tabler:current-location' }
const POI_ICON_COLORS = { pin: '#22c9c9', flag: '#e53e3e', waypoint: '#a78bfa' } const POI_ICON_COLORS = { pin: '#22c9c9', flag: '#e53e3e', waypoint: '#a78bfa' }
const ICON_SIZE = 28 const ICON_SIZE = 28
@@ -283,8 +112,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',
@@ -294,7 +124,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({
@@ -371,6 +201,7 @@ function createMap(initialCenter) {
updateMarkers() updateMarkers()
updatePoiMarkers() updatePoiMarkers()
updateLiveMarkers() updateLiveMarkers()
nextTick(() => map.invalidateSize())
} }
function updateMarkers() { function updateMarkers() {
@@ -382,8 +213,7 @@ function updateMarkers() {
if (m) m.remove() if (m) m.remove()
}) })
const feedSources = [...(props.feeds || []), ...(props.devices || [])] const validSources = (props.devices || []).filter(f => typeof f?.lat === 'number' && typeof f?.lng === 'number')
const validSources = feedSources.filter(f => typeof f?.lat === 'number' && typeof f?.lng === 'number')
markersRef.value = validSources.map(item => markersRef.value = validSources.map(item =>
L.marker([item.lat, item.lng]).addTo(ctx.map).on('click', () => emit('select', item)), L.marker([item.lat, item.lng]).addTo(ctx.map).on('click', () => emit('select', item)),
) )
@@ -444,7 +274,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])
@@ -478,7 +308,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
} }
@@ -489,7 +318,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
} }
@@ -505,52 +333,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() {
@@ -590,6 +404,8 @@ function initMapWithLocation() {
) )
} }
let resizeObserver = null
onMounted(async () => { onMounted(async () => {
if (!import.meta.client || typeof document === 'undefined') return if (!import.meta.client || typeof document === 'undefined') return
const [leaflet, offline] = await Promise.all([ const [leaflet, offline] = await Promise.all([
@@ -609,7 +425,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 = new ResizeObserver(() => {
mapContext.value?.map?.invalidateSize()
})
resizeObserver.observe(mapRef.value)
}
})
}) })
function onDocumentClick(e) { function onDocumentClick(e) {
@@ -618,166 +442,14 @@ function onDocumentClick(e) {
onBeforeUnmount(() => { onBeforeUnmount(() => {
document.removeEventListener('click', onDocumentClick) document.removeEventListener('click', onDocumentClick)
document.removeEventListener('click', onPoiModalDocumentClick) if (resizeObserver && mapRef.value) {
resizeObserver.disconnect()
resizeObserver = null
}
destroyMap() destroyMap()
}) })
watch(() => [props.feeds, 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 })
</script> </script>
<style scoped>
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.2s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
.modal-enter-active .relative,
.modal-leave-active .relative {
transition: transform 0.2s ease;
}
.modal-enter-from .relative,
.modal-leave-to .relative {
transform: scale(0.96);
}
/* Unrendered/loading tiles show black instead of white when panning */
.kestrel-map-container {
background: #000 !important;
}
:deep(.leaflet-tile-pane),
:deep(.leaflet-map-pane),
:deep(.leaflet-tile-container) {
background: #000 !important;
}
:deep(img.leaflet-tile) {
background: #000 !important;
/* Override Leaflets plus-lighter so unloaded/empty tiles dont flash white */
mix-blend-mode: normal;
}
/* Leaflet injects divIcon HTML into the map; :deep() so these styles apply to that content */
:deep(.poi-div-icon) {
background: none;
border: none;
}
:deep(.poi-icon-svg) {
display: block;
width: 100%;
height: 100%;
pointer-events: none;
}
/* Dark-themed tooltip for POI labels (Leaflet creates these in the map container) */
:deep(.kestrel-poi-tooltip) {
background: #1e293b;
border: 1px solid rgba(34, 201, 201, 0.35);
border-radius: 6px;
color: #e2e8f0;
font-size: 12px;
font-family: inherit;
padding: 6px 10px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
:deep(.kestrel-poi-tooltip::before),
:deep(.kestrel-poi-tooltip::after) {
border-top-color: #1e293b;
border-bottom-color: #1e293b;
border-left-color: #1e293b;
border-right-color: #1e293b;
}
/* Live session popup (content injected by Leaflet) */
:deep(.kestrel-live-popup-wrap .leaflet-popup-content) {
margin: 8px 12px;
min-width: 200px;
}
:deep(.kestrel-live-popup) {
color: #e2e8f0;
font-size: 12px;
}
:deep(.kestrel-live-popup img) {
display: block;
max-height: 160px;
width: auto;
border-radius: 4px;
background: #0f172a;
}
:deep(.live-session-icon) {
animation: live-pulse 1.5s ease-in-out infinite;
}
@keyframes live-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
/* Map controls dark theme with cyan glow (zoom, locate, save/clear tiles) */
:deep(.leaflet-control-zoom),
:deep(.leaflet-control-locate),
:deep(.savetiles.leaflet-bar) {
border: 1px solid rgba(34, 201, 201, 0.35) !important;
border-radius: 6px;
overflow: hidden;
box-shadow: 0 0 12px -2px rgba(34, 201, 201, 0.15);
font-family: "JetBrains Mono", "Fira Code", ui-monospace, monospace;
}
:deep(.leaflet-control-zoom a),
:deep(.leaflet-control-locate),
:deep(.savetiles.leaflet-bar a) {
width: 32px !important;
height: 32px !important;
line-height: 32px !important;
background: #0d1424 !important;
color: #b8c9e0 !important;
border: none !important;
border-radius: 0 !important;
font-size: 18px !important;
font-weight: 600;
text-decoration: none !important;
transition: background 0.15s, color 0.15s, box-shadow 0.15s, text-shadow 0.15s;
}
:deep(.leaflet-control-zoom a + a) {
border-top: 1px solid rgba(34, 201, 201, 0.2) !important;
}
:deep(.leaflet-control-zoom a:hover),
:deep(.leaflet-control-locate:hover),
:deep(.savetiles.leaflet-bar a:hover) {
background: #111a2e !important;
color: #22c9c9 !important;
box-shadow: 0 0 16px -2px rgba(34, 201, 201, 0.25);
text-shadow: 0 0 8px rgba(34, 201, 201, 0.35);
}
:deep(.leaflet-control-locate) {
display: flex !important;
align-items: center;
justify-content: center;
padding: 0;
cursor: pointer;
}
:deep(.leaflet-control-locate svg) {
color: currentColor;
}
/* Save/Clear tiles text buttons */
:deep(.savetiles.leaflet-bar) {
display: flex;
flex-direction: column;
}
:deep(.savetiles.leaflet-bar a) {
width: auto !important;
min-width: 5.5em;
height: auto !important;
line-height: 1.25 !important;
padding: 6px 10px !important;
white-space: nowrap;
text-align: center;
font-size: 11px !important;
font-weight: 500;
letter-spacing: 0.02em;
}
:deep(.savetiles.leaflet-bar a + a) {
border-top: 1px solid rgba(34, 201, 201, 0.2) !important;
}
</style>

View File

@@ -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

View File

@@ -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>

View File

@@ -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">&times;</span> <span class="text-xl leading-none">&times;</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>

175
app/components/PoiModal.vue Normal file
View File

@@ -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>

View File

@@ -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>

View File

@@ -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 },
)
}

View File

@@ -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 }
} }

View File

@@ -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
pollInterval.value = setInterval(() => {
refresh() refresh()
}, POLL_MS) pollInterval.value = setInterval(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 }
} }

View File

@@ -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)
let mql = null
const handler = (e) => {
matches.value = e.matches
}
onMounted(() => {
mql = window.matchMedia(query)
matches.value = mql.matches
mql.addEventListener('change', handler)
})
onBeforeUnmount(() => {
if (mql) mql.removeEventListener('change', handler)
})
return matches
}

View File

@@ -1,3 +1,5 @@
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 } = useAsyncData(
@@ -5,7 +7,7 @@ export function useUser() {
() => (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 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, canEditPois, isAdmin, refresh })
} }

View File

@@ -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,12 +183,6 @@ 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) => {

View File

@@ -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 }
} }

View File

@@ -1,7 +1,7 @@
<template> <template>
<div class="flex min-h-screen items-center justify-center bg-kestrel-bg font-mono text-kestrel-text"> <div class="flex min-h-screen items-center justify-center bg-kestrel-bg font-mono text-kestrel-text">
<div class="text-center"> <div class="text-center">
<h1 class="text-2xl font-semibold tracking-wide [text-shadow: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">

View File

@@ -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" />
<div
class="flex min-h-0 flex-1 flex-col transition-[margin] duration-200 ease-out"
:class="{ 'md:ml-[260px]': drawerOpen }"
>
<header class="flex h-14 shrink-0 items-center gap-3 border-b border-kestrel-border bg-kestrel-surface px-4 shadow-glow-sm [box-shadow:0_0_20px_-4px_rgba(34,201,201,0.15)]">
<button
type="button"
class="rounded p-2 text-kestrel-muted transition-colors hover:bg-kestrel-border hover:text-kestrel-accent"
aria-label="Toggle navigation"
:aria-expanded="drawerOpen"
@click="drawerOpen = !drawerOpen"
>
<span
class="text-lg leading-none"
aria-hidden="true"
>&#9776;</span>
</button>
<div class="min-w-0 flex-1">
<h1 class="text-lg font-semibold tracking-wide text-kestrel-text [text-shadow:0_0_12px_rgba(34,201,201,0.35)]">
KestrelOS
</h1>
<p class="text-xs uppercase tracking-widest text-kestrel-muted">
&gt; Tactical Operations Center OSINT Feeds
</p>
</div>
<div class="flex items-center gap-2">
<template v-if="user">
<span class="text-xs text-kestrel-muted">{{ user.identifier }}</span>
<button
type="button"
class="rounded px-2 py-1 text-xs text-kestrel-muted hover:bg-kestrel-border hover:text-kestrel-accent"
@click="onLogout"
>
Logout
</button>
</template>
<NuxtLink
v-else
to="/login"
class="rounded px-2 py-1 text-xs text-kestrel-muted hover:bg-kestrel-border hover:text-kestrel-accent"
>
Sign in
</NuxtLink>
</div>
</header>
<main class="min-h-0 flex-1">
<slot /> <slot />
</main> </AppShell>
</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>

View File

@@ -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,14 @@
</div> </div>
</section> </section>
<!-- Change password (local only) -->
<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 +121,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,8 +171,10 @@
</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('')
@@ -143,6 +182,45 @@ const passwordLoading = ref(false)
const passwordSuccess = ref(false) const passwordSuccess = ref(false)
const passwordError = ref('') const passwordError = 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 = ''
passwordSuccess.value = false passwordSuccess.value = false

View File

@@ -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>

View File

@@ -1,9 +1,8 @@
<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
:feeds="[]"
:devices="devices ?? []" :devices="devices ?? []"
:pois="pois ?? []" :pois="pois ?? []"
:live-sessions="liveSessions ?? []" :live-sessions="liveSessions ?? []"
@@ -29,7 +28,8 @@ const { canEditPois } = useUser()
const selectedCamera = ref(null) const selectedCamera = ref(null)
function onSelectLive(session) { function onSelectLive(session) {
const latest = (liveSessions.value || []).find(s => s.id === session?.id) selectedCamera.value = (liveSessions.value ?? []).find(s => s.id === session?.id) ?? session
selectedCamera.value = latest ?? session
} }
useAutoCloseLiveSession(selectedCamera, liveSessions)
</script> </script>

View File

@@ -1,7 +1,7 @@
<template> <template>
<div class="flex min-h-[60vh] items-center justify-center p-6"> <div class="flex min-h-[60vh] items-center justify-center p-6">
<div class="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('')

View File

@@ -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) {

View File

@@ -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>
@@ -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 ?? [])

View File

@@ -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,11 @@
</div> </div>
</section> </section>
<!-- About -->
<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>

View File

@@ -1,7 +1,7 @@
<template> <template>
<div class="flex min-h-[80vh] flex-col items-center justify-center p-6"> <div class="flex min-h-[80vh] flex-col items-center justify-center p-6">
<div class="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">
@@ -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"

View File

@@ -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 })
}, },
}) })
}) })

View File

@@ -1,88 +1,30 @@
/** /** Client-side logger: sends to server, falls back to console. */
* Client-side logger that sends logs to server for debugging.
* Falls back to console if server logging fails.
*/
let sessionId = null let sessionId = null
let userId = null let userId = null
/** const CONSOLE_METHOD = Object.freeze({ error: 'error', warn: 'warn', info: 'log', debug: 'log' })
* 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 = sessId
userId = uid userId = uid
} }
/** function sendToServer(level, message, data) {
* Send log to server (non-blocking). setTimeout(() => {
* @param {string} level $fetch('/api/log', {
* @param {string} message
* @param {object} data
*/
async function sendToServer(level, message, data) {
// Use setTimeout to avoid blocking - fire and forget
setTimeout(async () => {
try {
await $fetch('/api/log', {
method: 'POST', method: 'POST',
body: { body: { level, message, data, sessionId, userId, timestamp: new Date().toISOString() },
level,
message,
data,
sessionId,
userId,
timestamp: new Date().toISOString(),
},
credentials: 'include', credentials: 'include',
}).catch(() => { }).catch(() => { /* server down - don't spam console */ })
// 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)
}

View File

@@ -2,5 +2,5 @@ apiVersion: v2
name: kestrelos name: kestrelos
description: KestrelOS TOC for OSINT feeds - map, camera feeds, offline tiles description: KestrelOS TOC for OSINT feeds - map, camera feeds, offline tiles
type: application type: application
version: 0.1.0 version: 0.4.0
appVersion: "0.1.0" appVersion: "0.4.0"

View File

@@ -1,8 +1,8 @@
replicaCount: 1 replicaCount: 1
image: image:
repository: kestrelos repository: git.keligrubb.com/keligrubb/kestrelos
tag: latest tag: 0.4.0
pullPolicy: IfNotPresent pullPolicy: IfNotPresent
service: service:

View File

@@ -27,6 +27,7 @@ export default defineNuxtConfig({
], ],
}, },
}, },
css: ['~/assets/css/main.css'],
runtimeConfig: { runtimeConfig: {
public: { public: {
version: pkg.version ?? '', version: pkg.version ?? '',

11
package-lock.json generated
View File

@@ -22,6 +22,7 @@
"ws": "^8.18.0" "ws": "^8.18.0"
}, },
"devDependencies": { "devDependencies": {
"@iconify-json/tabler": "^1.2.26",
"@nuxt/eslint": "^1.15.0", "@nuxt/eslint": "^1.15.0",
"@nuxt/test-utils": "^4.0.0", "@nuxt/test-utils": "^4.0.0",
"@playwright/test": "^1.58.2", "@playwright/test": "^1.58.2",
@@ -1433,6 +1434,16 @@
"url": "https://github.com/sponsors/nzakas" "url": "https://github.com/sponsors/nzakas"
} }
}, },
"node_modules/@iconify-json/tabler": {
"version": "1.2.26",
"resolved": "https://registry.npmjs.org/@iconify-json/tabler/-/tabler-1.2.26.tgz",
"integrity": "sha512-92G+ZD70AZgeJf07JfQzH+isnai6DwPcMBuF/qL1F+xAxdXCJzGd3w2RmsRvOmB+w1ImmWEEDms50QivQIjd6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@iconify/types": "*"
}
},
"node_modules/@iconify/collections": { "node_modules/@iconify/collections": {
"version": "1.0.649", "version": "1.0.649",
"resolved": "https://registry.npmjs.org/@iconify/collections/-/collections-1.0.649.tgz", "resolved": "https://registry.npmjs.org/@iconify/collections/-/collections-1.0.649.tgz",

View File

@@ -1,5 +1,6 @@
{ {
"name": "kestrelos", "name": "kestrelos",
"version": "0.4.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -32,6 +33,7 @@
"ws": "^8.18.0" "ws": "^8.18.0"
}, },
"devDependencies": { "devDependencies": {
"@iconify-json/tabler": "^1.2.26",
"@nuxt/eslint": "^1.15.0", "@nuxt/eslint": "^1.15.0",
"@nuxt/test-utils": "^4.0.0", "@nuxt/test-utils": "^4.0.0",
"@playwright/test": "^1.58.2", "@playwright/test": "^1.58.2",

View File

@@ -35,6 +35,7 @@ export default defineConfig({
name: 'desktop-chrome', name: 'desktop-chrome',
use: { use: {
...devices['Desktop Chrome'], ...devices['Desktop Chrome'],
permissions: ['camera', 'microphone', 'geolocation'],
launchOptions: { launchOptions: {
args: [ args: [
'--use-fake-ui-for-media-stream', '--use-fake-ui-for-media-stream',
@@ -52,10 +53,10 @@ export default defineConfig({
], ],
webServer: { webServer: {
command: 'npm run dev', command: 'npm run dev',
url: 'https://localhost:3000/health', url: 'https://localhost:3000/health/ready',
reuseExistingServer: true, // Always reuse existing server for E2E tests reuseExistingServer: !process.env.CI, // Don't reuse in CI (always start fresh)
timeout: 120 * 1000, // 2 minutes for server startup timeout: 180_000, // 3 minutes (180 seconds) for server startup (CI can be slower)
ignoreHTTPSErrors: true, ignoreHTTPSErrors: true,
}, },
timeout: 60 * 1000, // 60 seconds per test (WebRTC setup takes time) timeout: process.env.CI ? 180_000 : 60_000, // 3 minutes in CI, 1 minute locally (WebRTC setup takes time)
}) })

56
scripts/release.sh Executable file
View File

@@ -0,0 +1,56 @@
#!/bin/sh
set -e
# version
msg="${CI_COMMIT_MESSAGE:-}"
bump=patch
echo "$msg" | grep -qi minor: && bump=minor
echo "$msg" | grep -qi major: && bump=major
cur=$(awk '/"version"/ { match($0, /[0-9]+\.[0-9]+\.[0-9]+/); print substr($0, RSTART, RLENGTH); exit }' package.json)
major=$(echo "$cur" | cut -d. -f1); minor=$(echo "$cur" | cut -d. -f2); patch=$(echo "$cur" | cut -d. -f3)
case "$bump" in major) major=$((major+1)); minor=0; patch=0 ;; minor) minor=$((minor+1)); patch=0 ;; patch) patch=$((patch+1)) ;; esac
newVersion="$major.$minor.$patch"
[ -z "$cur" ] && { echo "error: could not read version from package.json"; exit 1; }
# changelog entry (strip prefix from first line)
changelogEntry=$(echo "$msg" | head -1 | awk '{sub(/^[mM]ajor:[ \t]*/,""); sub(/^[mM]inor:[ \t]*/,""); sub(/^[pP]atch:[ \t]*/,""); print}')
[ -z "$changelogEntry" ] && changelogEntry="Release v$newVersion"
# bump files
awk -v v="$newVersion" '/"version"/ { sub(/[0-9]+\.[0-9]+\.[0-9]+/, v) } { print }' package.json > package.json.tmp && mv package.json.tmp package.json
awk -v v="$newVersion" '/^version:/ { $0 = "version: " v }; /^appVersion:/ { $0 = "appVersion: \"" v "\"" }; { print }' helm/kestrelos/Chart.yaml > helm/kestrelos/Chart.yaml.tmp && mv helm/kestrelos/Chart.yaml.tmp helm/kestrelos/Chart.yaml
awk -v v="$newVersion" '/^ tag:/ { $0 = " tag: " v }; { print }' helm/kestrelos/values.yaml > helm/kestrelos/values.yaml.tmp && mv helm/kestrelos/values.yaml.tmp helm/kestrelos/values.yaml
# changelog
new="## [$newVersion] - $(date +%Y-%m-%d)
### Changed
- $changelogEntry
"
{ [ ! -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
# git
git config user.email "ci@kestrelos" && git config user.name "CI"
git add package.json helm/kestrelos/Chart.yaml helm/kestrelos/values.yaml CHANGELOG.md
git commit -m "release v$newVersion [skip ci]"
url="https://${CI_REPO_OWNER}:${GITEA_REPO_TOKEN}@${CI_FORGE_URL#https://}/${CI_REPO_OWNER}/${CI_REPO_NAME}.git"
git tag "v$newVersion"
# artifact for kaniko (tag list)
printf '%s\n%s\n' "$newVersion" "latest" > .tags
retry() { n=0; while ! "$@"; do n=$((n+1)); [ $n -ge 3 ] && return 1; sleep 2; done; }
retry git push "$url" HEAD:main "v$newVersion"
# gitea release
body="## Changelog
### Changed
- $changelogEntry
## Installation
- [Docker image](${CI_FORGE_URL}/${CI_REPO_OWNER}/-/packages/container/${CI_REPO_NAME})
- [Helm chart](${CI_FORGE_URL}/${CI_REPO_OWNER}/-/packages/helm/${CI_REPO_NAME})"
release_url="${CI_FORGE_URL}/api/v1/repos/${CI_REPO_OWNER}/${CI_REPO_NAME}/releases"
echo "$body" | awk -v tag="v$newVersion" 'BEGIN{printf "{\"tag_name\":\"" tag "\",\"name\":\"" tag "\",\"body\":\""} { gsub(/\\/,"\\\\"); gsub(/"/,"\\\""); if (NR>1) printf "\\n"; printf "%s", $0 } END{printf "\"}\n"}' > /tmp/release.json
wget -q -O /dev/null --post-file=/tmp/release.json \
--header="Authorization: token ${GITEA_REPO_TOKEN}" \
--header="Content-Type: application/json" \
"$release_url"

View File

@@ -7,6 +7,6 @@ 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 }
}) })

View File

@@ -26,7 +26,7 @@ export default defineEventHandler(async (event) => {
const url = getRequestURL(event) const url = getRequestURL(event)
const requestHost = url.hostname const requestHost = url.hostname
const router = await getRouter(sessionId) const router = await getRouter(sessionId)
const { transport, params } = await createTransport(router, Boolean(isProducer), requestHost) const { transport, params } = await createTransport(router, requestHost)
if (isProducer) { if (isProducer) {
updateLiveSession(sessionId, { updateLiveSession(sessionId, {

View File

@@ -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 }
}) })

View File

@@ -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,
}
}) })

View File

@@ -0,0 +1,14 @@
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 }
const path = join(getAvatarsDir(), user.avatar_path)
await unlink(path).catch(() => {})
const { run } = await getDb()
await run('UPDATE users SET avatar_path = NULL WHERE id = ?', [user.id])
return { ok: true }
})

View File

@@ -0,0 +1,23 @@
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' })
const path = join(getAvatarsDir(), user.avatar_path)
const ext = user.avatar_path.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' })
}
})

View File

@@ -0,0 +1,32 @@
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' })
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' })
const ext = EXT_BY_MIME[mime] ?? '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 }
})

View File

@@ -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/poiConstants.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(

View File

@@ -1,20 +1,19 @@
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/poiConstants.js'
const ICON_TYPES = ['pin', 'flag', 'waypoint']
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
requireAuth(event, { role: 'adminOrLeader' }) requireAuth(event, { role: 'adminOrLeader' })
const id = event.context.params?.id const id = event.context.params?.id
if (!id) throw createError({ statusCode: 400, message: 'id required' }) if (!id) throw createError({ statusCode: 400, message: 'id required' })
const body = await readBody(event) || {} const body = (await readBody(event)) || {}
const updates = [] const updates = []
const params = [] const params = []
if (typeof body.label === 'string') { if (typeof body.label === 'string') {
updates.push('label = ?') updates.push('label = ?')
params.push(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.push('icon_type = ?')
params.push(body.iconType) params.push(body.iconType)
} }

View File

@@ -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 {

View File

@@ -1,14 +1,7 @@
import { getDb, closeDb } from '../utils/db.js' import { getDb, closeDb } from '../utils/db.js'
import { migrateFeedsToDevices } from '../utils/migrateFeedsToDevices.js'
/**
* Initialize DB (and run bootstrap if no users) at server startup
* so credentials are printed in the terminal before any request.
* Close DB on server shutdown to avoid native sqlite3 crashes in worker teardown.
*/
export default defineNitroPlugin((nitroApp) => { export default defineNitroPlugin((nitroApp) => {
void getDb().then(() => migrateFeedsToDevices()) void getDb()
nitroApp.hooks.hook('close', () => { nitroApp.hooks.hook('close', () => {
closeDb() closeDb()
}) })

View File

@@ -1,17 +1,7 @@
/**
* WebSocket server for WebRTC signaling.
* Attaches to Nitro's HTTP server and handles WebSocket connections.
*/
import { WebSocketServer } from 'ws' import { WebSocketServer } from 'ws'
import { getDb } from '../utils/db.js' import { getDb } from '../utils/db.js'
import { handleWebSocketMessage } from '../utils/webrtcSignaling.js' import { handleWebSocketMessage } from '../utils/webrtcSignaling.js'
/**
* Parse cookie header string into object.
* @param {string} cookieHeader
* @returns {Record<string, string>} Parsed cookie name-value pairs.
*/
function parseCookie(cookieHeader) { function parseCookie(cookieHeader) {
const cookies = {} const cookies = {}
if (!cookieHeader) return cookies if (!cookieHeader) return cookies
@@ -25,30 +15,16 @@ function parseCookie(cookieHeader) {
} }
let wss = null let wss = null
const connections = new Map() // sessionId -> Set<WebSocket> const connections = new Map()
/**
* Get WebSocket server instance.
* @returns {WebSocketServer | null} WebSocket server instance or null.
*/
export function getWebSocketServer() { export function getWebSocketServer() {
return wss return wss
} }
/**
* Get connections for a session.
* @param {string} sessionId
* @returns {Set<WebSocket>} Set of WebSockets for the session.
*/
export function getSessionConnections(sessionId) { export function getSessionConnections(sessionId) {
return connections.get(sessionId) || new Set() return connections.get(sessionId) || new Set()
} }
/**
* Add connection to session.
* @param {string} sessionId
* @param {WebSocket} ws
*/
export function addSessionConnection(sessionId, ws) { export function addSessionConnection(sessionId, ws) {
if (!connections.has(sessionId)) { if (!connections.has(sessionId)) {
connections.set(sessionId, new Set()) connections.set(sessionId, new Set())
@@ -56,11 +32,6 @@ export function addSessionConnection(sessionId, ws) {
connections.get(sessionId).add(ws) connections.get(sessionId).add(ws)
} }
/**
* Remove connection from session.
* @param {string} sessionId
* @param {WebSocket} ws
*/
export function removeSessionConnection(sessionId, ws) { export function removeSessionConnection(sessionId, ws) {
const conns = connections.get(sessionId) const conns = connections.get(sessionId)
if (conns) { if (conns) {
@@ -71,11 +42,6 @@ export function removeSessionConnection(sessionId, ws) {
} }
} }
/**
* Send message to all connections for a session.
* @param {string} sessionId
* @param {object} message
*/
export function broadcastToSession(sessionId, message) { export function broadcastToSession(sessionId, message) {
const conns = getSessionConnections(sessionId) const conns = getSessionConnections(sessionId)
const data = JSON.stringify(message) const data = JSON.stringify(message)

View File

@@ -1,17 +1,5 @@
/**
* Read auth config from env. Returns only non-secret data for client.
* Auth always allows local (password) sign-in and OIDC when configured.
* @returns {{ oidc: { enabled: boolean, label: string } }} Public auth config (oidc.enabled, oidc.label).
*/
export function getAuthConfig() { export function getAuthConfig() {
const hasOidcEnv const hasOidc = !!(process.env.OIDC_ISSUER && process.env.OIDC_CLIENT_ID && process.env.OIDC_CLIENT_SECRET)
= process.env.OIDC_ISSUER && process.env.OIDC_CLIENT_ID && process.env.OIDC_CLIENT_SECRET const label = process.env.OIDC_LABEL?.trim() || (hasOidc ? 'Sign in with OIDC' : '')
const envLabel = process.env.OIDC_LABEL ?? '' return Object.freeze({ oidc: { enabled: hasOidc, label } })
const label = envLabel || (hasOidcEnv ? 'Sign in with OIDC' : '')
return {
oidc: {
enabled: !!hasOidcEnv,
label,
},
}
} }

View File

@@ -1,20 +1,10 @@
/** const ROLES_ADMIN_OR_LEADER = Object.freeze(['admin', 'leader'])
* Require authenticated user. Optionally require role. Throws 401 if none, 403 if role insufficient.
* @param {import('h3').H3Event} event
* @param {{ role?: 'admin' | 'adminOrLeader' }} [opts] - role: 'admin' = admin only; 'adminOrLeader' = admin or leader
* @returns {{ id: string, identifier: string, role: string }} The current user.
*/
export function requireAuth(event, opts = {}) { export function requireAuth(event, opts = {}) {
const user = event.context.user const user = event.context.user
if (!user) { if (!user) throw createError({ statusCode: 401, message: 'Unauthorized' })
throw createError({ statusCode: 401, message: 'Unauthorized' })
}
const { role } = opts const { role } = opts
if (role === 'admin' && user.role !== 'admin') { if (role === 'admin' && user.role !== 'admin') throw createError({ statusCode: 403, message: 'Forbidden' })
throw createError({ statusCode: 403, message: 'Forbidden' }) if (role === 'adminOrLeader' && !ROLES_ADMIN_OR_LEADER.includes(user.role)) throw createError({ statusCode: 403, message: 'Forbidden' })
}
if (role === 'adminOrLeader' && user.role !== 'admin' && user.role !== 'leader') {
throw createError({ statusCode: 403, message: 'Forbidden' })
}
return user return user
} }

View File

@@ -1,30 +1,21 @@
/** /** Paths that skip auth (no session required). Do not add if any handler uses requireAuth. */
* Paths that skip auth middleware (no session required). export const SKIP_PATHS = Object.freeze([
* Do not add a path here if any handler under it uses requireAuth (with or without role).
* When adding a new API route that requires auth, add its path prefix to PROTECTED_PATH_PREFIXES below
* so tests can assert it is never skipped.
*/
export const SKIP_PATHS = [
'/api/auth/login', '/api/auth/login',
'/api/auth/logout', '/api/auth/logout',
'/api/auth/config', '/api/auth/config',
'/api/auth/oidc/authorize', '/api/auth/oidc/authorize',
'/api/auth/oidc/callback', '/api/auth/oidc/callback',
] ])
/** /** Path prefixes for protected routes. Used by tests to ensure they're never in SKIP_PATHS. */
* Path prefixes for API routes that require an authenticated user (or role). export const PROTECTED_PATH_PREFIXES = Object.freeze([
* Every path in this list must NOT be skipped (skipAuth must return false).
* Used by tests to prevent protected routes from being added to SKIP_PATHS.
*/
export const PROTECTED_PATH_PREFIXES = [
'/api/cameras', '/api/cameras',
'/api/devices', '/api/devices',
'/api/live', '/api/live',
'/api/me', '/api/me',
'/api/pois', '/api/pois',
'/api/users', '/api/users',
] ])
export function skipAuth(path) { export function skipAuth(path) {
if (path.startsWith('/api/health') || path === '/health') return true if (path.startsWith('/api/health') || path === '/health') return true

26
server/utils/bootstrap.js vendored Normal file
View File

@@ -0,0 +1,26 @@
import { randomBytes } from 'node:crypto'
import { hashPassword } from './password.js'
const PASSWORD_CHARS = Object.freeze('abcdefghjkmnopqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789')
const generateRandomPassword = () =>
Array.from(randomBytes(14), b => PASSWORD_CHARS[b % PASSWORD_CHARS.length]).join('')
export async function bootstrapAdmin(run, get) {
const row = await get('SELECT COUNT(*) as n FROM users')
if (row?.n !== 0) return
const email = process.env.BOOTSTRAP_EMAIL?.trim()
const password = process.env.BOOTSTRAP_PASSWORD
const identifier = (email && password) ? email : 'admin'
const plainPassword = (email && password) ? password : generateRandomPassword()
await run(
'INSERT INTO users (id, identifier, password_hash, role, created_at, auth_provider, oidc_issuer, oidc_sub) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[crypto.randomUUID(), identifier, hashPassword(plainPassword), 'admin', new Date().toISOString(), 'local', null, null],
)
if (!email || !password) {
console.log(`\n[KestrelOS] No bootstrap admin configured. Default admin created. Sign in at /login with:\n\n Identifier: ${identifier}\n Password: ${plainPassword}\n\n Set BOOTSTRAP_EMAIL and BOOTSTRAP_PASSWORD to use your own credentials on first run.\n`)
}
}

View File

@@ -1,39 +1,28 @@
import { join } from 'node:path' import { join, dirname } from 'node:path'
import { mkdirSync, existsSync } from 'node:fs' import { mkdirSync, existsSync } from 'node:fs'
import { createRequire } from 'node:module' import { createRequire } from 'node:module'
import { promisify } from 'node:util' import { promisify } from 'node:util'
import { randomBytes } from 'node:crypto' import { bootstrapAdmin } from './bootstrap.js'
import { hashPassword } from './password.js'
const DEFAULT_ADMIN_IDENTIFIER = 'admin'
const DEFAULT_PASSWORD_LENGTH = 14
const PASSWORD_CHARS = 'abcdefghjkmnopqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789'
function generateRandomPassword() {
const bytes = randomBytes(DEFAULT_PASSWORD_LENGTH)
let s = ''
for (let i = 0; i < DEFAULT_PASSWORD_LENGTH; i++) {
s += PASSWORD_CHARS[bytes[i] % PASSWORD_CHARS.length]
}
return s
}
const require = createRequire(import.meta.url) const require = createRequire(import.meta.url)
const sqlite3 = require('sqlite3') const sqlite3 = require('sqlite3')
const SCHEMA_VERSION = 3
const DB_BUSY_TIMEOUT_MS = 5000
let dbInstance = null let dbInstance = null
/** Set by tests to use :memory: or a temp path */
let testPath = null let testPath = null
const USERS_SQL = `CREATE TABLE IF NOT EXISTS users ( const SCHEMA = {
schema_version: 'CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY)',
users: `CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
identifier TEXT UNIQUE NOT NULL, identifier TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL, password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'member', role TEXT NOT NULL DEFAULT 'member',
created_at TEXT NOT NULL created_at TEXT NOT NULL
)` )`,
users_v2: `CREATE TABLE users_new (
const USERS_V2_SQL = `CREATE TABLE users_new (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
identifier TEXT UNIQUE NOT NULL, identifier TEXT UNIQUE NOT NULL,
password_hash TEXT, password_hash TEXT,
@@ -42,23 +31,23 @@ const USERS_V2_SQL = `CREATE TABLE users_new (
auth_provider TEXT NOT NULL DEFAULT 'local', auth_provider TEXT NOT NULL DEFAULT 'local',
oidc_issuer TEXT, oidc_issuer TEXT,
oidc_sub TEXT oidc_sub TEXT
)` )`,
const USERS_OIDC_UNIQUE = `CREATE UNIQUE INDEX IF NOT EXISTS users_oidc_unique ON users(oidc_issuer, oidc_sub) WHERE oidc_issuer IS NOT NULL AND oidc_sub IS NOT NULL` users_oidc_index: `CREATE UNIQUE INDEX IF NOT EXISTS users_oidc_unique ON users(oidc_issuer, oidc_sub) WHERE oidc_issuer IS NOT NULL AND oidc_sub IS NOT NULL`,
const SESSIONS_SQL = `CREATE TABLE IF NOT EXISTS sessions ( sessions: `CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
user_id TEXT NOT NULL, user_id TEXT NOT NULL,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
expires_at TEXT NOT NULL, expires_at TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) FOREIGN KEY(user_id) REFERENCES users(id)
)` )`,
const POIS_SQL = `CREATE TABLE IF NOT EXISTS pois ( pois: `CREATE TABLE IF NOT EXISTS pois (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
lat REAL NOT NULL, lat REAL NOT NULL,
lng REAL NOT NULL, lng REAL NOT NULL,
label TEXT NOT NULL DEFAULT '', label TEXT NOT NULL DEFAULT '',
icon_type TEXT NOT NULL DEFAULT 'pin' icon_type TEXT NOT NULL DEFAULT 'pin'
)` )`,
const DEVICES_SQL = `CREATE TABLE IF NOT EXISTS devices ( devices: `CREATE TABLE IF NOT EXISTS devices (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT '', name TEXT NOT NULL DEFAULT '',
device_type TEXT NOT NULL, device_type TEXT NOT NULL,
@@ -68,88 +57,133 @@ const DEVICES_SQL = `CREATE TABLE IF NOT EXISTS devices (
stream_url TEXT NOT NULL DEFAULT '', stream_url TEXT NOT NULL DEFAULT '',
source_type TEXT NOT NULL DEFAULT 'mjpeg', source_type TEXT NOT NULL DEFAULT 'mjpeg',
config TEXT config TEXT
)` )`,
}
function getDbPath() { const getDbPath = () => {
if (testPath) return testPath if (testPath) return testPath
if (process.env.DB_PATH) return process.env.DB_PATH
const dir = join(process.cwd(), 'data') const dir = join(process.cwd(), 'data')
if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
return join(dir, 'kestrelos.db') return join(dir, 'kestrelos.db')
} }
async function bootstrap(db) { export const getAvatarsDir = () => {
if (testPath) return const dir = join(dirname(getDbPath()), 'avatars')
const row = await db.get('SELECT COUNT(*) as n FROM users') if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
if (row?.n !== 0) return return dir
const email = process.env.BOOTSTRAP_EMAIL?.trim() }
const password = process.env.BOOTSTRAP_PASSWORD
const identifier = (email && password) ? email : DEFAULT_ADMIN_IDENTIFIER
const plainPassword = (email && password) ? password : generateRandomPassword()
const id = crypto.randomUUID()
const now = new Date().toISOString()
await db.run(
'INSERT INTO users (id, identifier, password_hash, role, created_at, auth_provider, oidc_issuer, oidc_sub) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[id, identifier, hashPassword(plainPassword), 'admin', now, 'local', null, null],
)
if (!email || !password) {
console.log('\n[KestrelOS] No bootstrap admin configured. Default admin created. Sign in at /login with:\n')
console.log(` Identifier: ${identifier}\n Password: ${plainPassword}\n`) const getSchemaVersion = async (get) => {
try {
console.log(' Set BOOTSTRAP_EMAIL and BOOTSTRAP_PASSWORD to use your own credentials on first run.\n') const row = await get('SELECT version FROM schema_version ORDER BY version DESC LIMIT 1')
return row?.version || 0
}
catch {
return 0
} }
} }
async function migrateUsersIfNeeded(run, all) { const setSchemaVersion = (run, version) => run('INSERT OR REPLACE INTO schema_version (version) VALUES (?)', [version])
const migrateToV2 = async (run, all) => {
const info = await all('PRAGMA table_info(users)') const info = await all('PRAGMA table_info(users)')
if (info.some(c => c.name === 'auth_provider')) return if (info.some(c => c.name === 'auth_provider')) return
await run(USERS_V2_SQL)
await run( await run('BEGIN TRANSACTION')
`INSERT INTO users_new (id, identifier, password_hash, role, created_at, auth_provider, oidc_issuer, oidc_sub) try {
SELECT id, identifier, password_hash, role, created_at, 'local', NULL, NULL FROM users`, await run(SCHEMA.users_v2)
) await run('INSERT INTO users_new (id, identifier, password_hash, role, created_at, auth_provider, oidc_issuer, oidc_sub) SELECT id, identifier, password_hash, role, created_at, ?, ?, ? FROM users', ['local', null, null])
await run('DROP TABLE users') await run('DROP TABLE users')
await run('ALTER TABLE users_new RENAME TO users') await run('ALTER TABLE users_new RENAME TO users')
await run(USERS_OIDC_UNIQUE) await run(SCHEMA.users_oidc_index)
await run('COMMIT')
}
catch (error) {
await run('ROLLBACK').catch(() => {})
throw error
}
}
const migrateToV3 = async (run, all) => {
const info = await all('PRAGMA table_info(users)')
if (info.some(c => c.name === 'avatar_path')) return
await run('ALTER TABLE users ADD COLUMN avatar_path TEXT')
}
const runMigrations = async (run, all, get) => {
const version = await getSchemaVersion(get)
if (version >= SCHEMA_VERSION) return
if (version < 2) {
await migrateToV2(run, all)
await setSchemaVersion(run, 2)
}
if (version < 3) {
await migrateToV3(run, all)
await setSchemaVersion(run, 3)
}
}
const initDb = async (db, run, all, get) => {
try {
await run('PRAGMA journal_mode = WAL')
}
catch {
// WAL not supported (e.g., network filesystem)
}
db.configure('busyTimeout', DB_BUSY_TIMEOUT_MS)
await run(SCHEMA.schema_version)
await run(SCHEMA.users)
await runMigrations(run, all, get)
await run(SCHEMA.sessions)
await run(SCHEMA.pois)
await run(SCHEMA.devices)
if (!testPath) await bootstrapAdmin(run, get)
} }
export async function getDb() { export async function getDb() {
if (dbInstance) return dbInstance if (dbInstance) return dbInstance
const path = getDbPath()
const db = new sqlite3.Database(path) const db = new sqlite3.Database(getDbPath(), (err) => {
if (err) {
console.error('[db] Failed to open database:', err.message)
throw err
}
})
const run = promisify(db.run.bind(db)) const run = promisify(db.run.bind(db))
const all = promisify(db.all.bind(db)) const all = promisify(db.all.bind(db))
const get = promisify(db.get.bind(db)) const get = promisify(db.get.bind(db))
await run(USERS_SQL)
await migrateUsersIfNeeded(run, all) try {
await run(SESSIONS_SQL) await initDb(db, run, all, get)
await run(POIS_SQL) }
await run(DEVICES_SQL) catch (error) {
await bootstrap({ run, get }) db.close()
console.error('[db] Database initialization failed:', error.message)
throw error
}
dbInstance = { db, run, all, get } dbInstance = { db, run, all, get }
return dbInstance return dbInstance
} }
/**
* Close the DB connection. Call on server shutdown to avoid native sqlite3 crashes in worker teardown.
*/
export function closeDb() { export function closeDb() {
if (dbInstance) { if (!dbInstance) return
try { try {
dbInstance.db.close() dbInstance.db.close((err) => {
if (err) console.error('[db] Error closing database:', err.message)
})
} }
catch { catch (error) {
// ignore if already closed console.error('[db] Error closing database:', error.message)
} }
dbInstance = null dbInstance = null
}
} }
/**
* For tests: use in-memory DB and reset singleton.
* @param {string} path - e.g. ':memory:'
*/
export function setDbPathForTest(path) { export function setDbPathForTest(path) {
testPath = path testPath = path || null
closeDb() closeDb()
} }

View File

@@ -1,8 +1,12 @@
import { sanitizeStreamUrl } from './feedUtils.js'
const DEVICE_TYPES = Object.freeze(['alpr', 'nvr', 'doorbell', 'feed', 'traffic', 'ip', 'drone']) const DEVICE_TYPES = Object.freeze(['alpr', 'nvr', 'doorbell', 'feed', 'traffic', 'ip', 'drone'])
const SOURCE_TYPES = Object.freeze(['mjpeg', 'hls']) const SOURCE_TYPES = Object.freeze(['mjpeg', 'hls'])
const sanitizeStreamUrl = (url) => {
if (typeof url !== 'string' || !url.trim()) return ''
const u = url.trim()
return (u.startsWith('https://') || u.startsWith('http://')) ? u : ''
}
/** @typedef {{ id: string, name: string, device_type: string, vendor: string | null, lat: number, lng: number, stream_url: string, source_type: string, config: string | null }} DeviceRow */ /** @typedef {{ id: string, name: string, device_type: string, vendor: string | null, lat: number, lng: number, stream_url: string, source_type: string, config: string | null }} DeviceRow */
/** /**

View File

@@ -1,54 +0,0 @@
/**
* Validates a single feed object shape (pure function).
* @param {unknown} item
* @returns {boolean} True if item has id, name, lat, lng with correct types.
*/
export function isValidFeed(item) {
if (!item || typeof item !== 'object') return false
const o = /** @type {Record<string, unknown>} */ (item)
return (
typeof o.id === 'string'
&& typeof o.name === 'string'
&& typeof o.lat === 'number'
&& typeof o.lng === 'number'
)
}
/**
* Returns a safe stream URL (http/https only) or empty string. Prevents javascript:, data:, etc.
* @param {unknown} url
* @returns {string} Safe http(s) URL or empty string.
*/
export function sanitizeStreamUrl(url) {
if (typeof url !== 'string' || !url.trim()) return ''
const u = url.trim()
if (u.startsWith('https://') || u.startsWith('http://')) return u
return ''
}
/**
* Sanitizes a validated feed for API response: safe streamUrl and sourceType only.
* @param {{ id: string, name: string, lat: number, lng: number, [key: string]: unknown }} feed
* @returns {{ id: string, name: string, lat: number, lng: number, streamUrl: string, sourceType: string, description?: string }} Sanitized feed for API.
*/
export function sanitizeFeedForResponse(feed) {
return {
id: feed.id,
name: feed.name,
lat: feed.lat,
lng: feed.lng,
streamUrl: sanitizeStreamUrl(feed.streamUrl),
sourceType: feed.sourceType === 'hls' ? 'hls' : 'mjpeg',
...(typeof feed.description === 'string' ? { description: feed.description } : {}),
}
}
/**
* Filters and returns only valid feeds from an array (pure function).
* @param {unknown[]} list
* @returns {Array<{ id: string, name: string, lat: number, lng: number }>} Array of valid feed objects.
*/
export function getValidFeeds(list) {
if (!Array.isArray(list)) return []
return list.filter(isValidFeed)
}

View File

@@ -1,43 +1,17 @@
/**
* In-memory store for live sharing sessions (camera + location).
* Sessions expire after TTL_MS without an update.
*/
import { closeRouter, getProducer, getTransport } from './mediasoup.js' import { closeRouter, getProducer, getTransport } from './mediasoup.js'
const TTL_MS = 60_000 // 60 seconds without update = inactive const TTL_MS = 60_000
const sessions = new Map() const sessions = new Map()
/** export const createSession = (userId, label = '') => {
* @typedef {{
* id: string
* userId: string
* label: string
* lat: number
* lng: number
* updatedAt: number
* routerId: string | null
* producerId: string | null
* transportId: string | null
* }} LiveSession
*/
/**
* @param {string} userId
* @param {string} [label]
* @returns {LiveSession} The created live session.
*/
export function createSession(userId, label = '') {
const id = crypto.randomUUID() const id = crypto.randomUUID()
const now = Date.now()
const session = { const session = {
id, id,
userId, userId,
label: (label || 'Live').trim() || 'Live', label: (label || 'Live').trim() || 'Live',
lat: 0, lat: 0,
lng: 0, lng: 0,
updatedAt: now, updatedAt: Date.now(),
routerId: null, routerId: null,
producerId: null, producerId: null,
transportId: null, transportId: null,
@@ -46,34 +20,16 @@ export function createSession(userId, label = '') {
return session return session
} }
/** export const getLiveSession = id => sessions.get(id)
* @param {string} id
* @returns {LiveSession | undefined} The session or undefined.
*/
export function getLiveSession(id) {
return sessions.get(id)
}
/** export const getActiveSessionByUserId = (userId) => {
* Get an existing active session for a user (for replacing with a new one).
* @param {string} userId
* @returns {LiveSession | undefined} The first active session for the user, or undefined.
*/
export function getActiveSessionByUserId(userId) {
const now = Date.now() const now = Date.now()
for (const [, s] of sessions) { for (const s of sessions.values()) {
if (s.userId === userId && now - s.updatedAt <= TTL_MS) { if (s.userId === userId && now - s.updatedAt <= TTL_MS) return s
return s
} }
}
return undefined
} }
/** export const updateLiveSession = (id, updates) => {
* @param {string} id
* @param {{ lat?: number, lng?: number, routerId?: string | null, producerId?: string | null, transportId?: string | null }} updates
*/
export function updateLiveSession(id, updates) {
const session = sessions.get(id) const session = sessions.get(id)
if (!session) return if (!session) return
const now = Date.now() const now = Date.now()
@@ -85,74 +41,52 @@ export function updateLiveSession(id, updates) {
session.updatedAt = now session.updatedAt = now
} }
/** export const deleteLiveSession = id => sessions.delete(id)
* @param {string} id
*/ export const clearSessions = () => sessions.clear()
export function deleteLiveSession(id) {
sessions.delete(id) const cleanupSession = async (session) => {
if (session.producerId) {
const producer = getProducer(session.producerId)
producer?.close()
}
if (session.transportId) {
const transport = getTransport(session.transportId)
transport?.close()
}
if (session.routerId) {
await closeRouter(session.id).catch((err) => {
console.error(`[liveSessions] Error closing router for expired session ${session.id}:`, err)
})
}
} }
/** export const getActiveSessions = async () => {
* Clear all sessions (for tests only).
*/
export function clearSessions() {
sessions.clear()
}
/**
* Returns sessions updated within TTL_MS (active only).
* Also cleans up expired sessions.
* @returns {Promise<Array<{ id: string, userId: string, label: string, lat: number, lng: number, updatedAt: number, hasStream: boolean }>>} Active sessions with hasStream flag.
*/
export async function getActiveSessions() {
const now = Date.now() const now = Date.now()
const result = [] const active = []
const expiredIds = [] const expired = []
for (const [id, s] of sessions) {
if (now - s.updatedAt <= TTL_MS) { for (const session of sessions.values()) {
result.push({ if (now - session.updatedAt <= TTL_MS) {
id: s.id, active.push({
userId: s.userId, id: session.id,
label: s.label, userId: session.userId,
lat: s.lat, label: session.label,
lng: s.lng, lat: session.lat,
updatedAt: s.updatedAt, lng: session.lng,
hasStream: Boolean(s.producerId), updatedAt: session.updatedAt,
hasStream: Boolean(session.producerId),
}) })
} }
else { else {
expiredIds.push(id) expired.push(session)
}
}
// Clean up expired sessions and their WebRTC resources
for (const id of expiredIds) {
const session = sessions.get(id)
if (session) {
// Clean up producer if it exists
if (session.producerId) {
const producer = getProducer(session.producerId)
if (producer) {
producer.close()
} }
} }
// Clean up transport if it exists for (const session of expired) {
if (session.transportId) { await cleanupSession(session)
const transport = getTransport(session.transportId) sessions.delete(session.id)
if (transport) {
transport.close()
}
} }
// Clean up router return active
if (session.routerId) {
await closeRouter(id).catch((err) => {
console.error(`[liveSessions] Error closing router for expired session ${id}:`, err)
})
}
sessions.delete(id)
}
}
return result
} }

View File

@@ -1,21 +1,18 @@
/**
* Mediasoup SFU (Selective Forwarding Unit) setup and management.
* Handles WebRTC router, transport, producer, and consumer creation.
*/
import os from 'node:os' import os from 'node:os'
import mediasoup from 'mediasoup' import mediasoup from 'mediasoup'
let worker = null let worker = null
const routers = new Map() // sessionId -> Router const routers = new Map()
const transports = new Map() // transportId -> WebRtcTransport const transports = new Map()
export const producers = new Map() // producerId -> Producer export const producers = new Map()
/** const MEDIA_CODECS = [
* Initialize Mediasoup worker (singleton). { kind: 'video', mimeType: 'video/H264', clockRate: 90000, parameters: { 'packetization-mode': 1, 'profile-level-id': '42e01f' } },
* @returns {Promise<mediasoup.types.Worker>} The Mediasoup worker. { kind: 'video', mimeType: 'video/VP8', clockRate: 90000 },
*/ { kind: 'video', mimeType: 'video/VP9', clockRate: 90000 },
export async function getWorker() { ]
export const getWorker = async () => {
if (worker) return worker if (worker) return worker
worker = await mediasoup.createWorker({ worker = await mediasoup.createWorker({
logLevel: process.env.NODE_ENV === 'development' ? 'debug' : 'warn', logLevel: process.env.NODE_ENV === 'development' ? 'debug' : 'warn',
@@ -30,50 +27,15 @@ export async function getWorker() {
return worker return worker
} }
/** export const getRouter = async (sessionId) => {
* Create or get a router for a live session. const existing = routers.get(sessionId)
* @param {string} sessionId if (existing) return existing
* @returns {Promise<mediasoup.types.Router>} Router for the session. const router = await (await getWorker()).createRouter({ mediaCodecs: MEDIA_CODECS })
*/
export async function getRouter(sessionId) {
if (routers.has(sessionId)) {
return routers.get(sessionId)
}
const w = await getWorker()
const router = await w.createRouter({
mediaCodecs: [
{
kind: 'video',
mimeType: 'video/H264',
clockRate: 90000,
parameters: {
'packetization-mode': 1,
'profile-level-id': '42e01f',
},
},
{
kind: 'video',
mimeType: 'video/VP8',
clockRate: 90000,
},
{
kind: 'video',
mimeType: 'video/VP9',
clockRate: 90000,
},
],
})
routers.set(sessionId, router) routers.set(sessionId, router)
return router return router
} }
/** const isIPv4 = (host) => {
* True if the string is a valid IPv4 address (numeric a.b.c.d, each octet 0-255).
* Used to accept request Host as announced IP only when it's safe (no hostnames/DNS rebinding).
* @param {string} host
* @returns {boolean} True if host is a valid IPv4 address.
*/
function isIPv4(host) {
if (typeof host !== 'string' || !host) return false if (typeof host !== 'string' || !host) return false
const parts = host.split('.') const parts = host.split('.')
if (parts.length !== 4) return false if (parts.length !== 4) return false
@@ -84,45 +46,24 @@ function isIPv4(host) {
return true return true
} }
/** const getAnnouncedIpFromInterfaces = () => {
* First non-internal IPv4 from network interfaces (no env read). for (const addrs of Object.values(os.networkInterfaces())) {
* @returns {string | null} First non-internal IPv4 address or null.
*/
function getAnnouncedIpFromInterfaces() {
const ifaces = os.networkInterfaces()
for (const addrs of Object.values(ifaces)) {
if (!addrs) continue if (!addrs) continue
for (const addr of addrs) { for (const addr of addrs) {
if (addr.family === 'IPv4' && !addr.internal) { if (addr.family === 'IPv4' && !addr.internal) return addr.address
return addr.address
}
} }
} }
return null return null
} }
/** const resolveAnnouncedIp = (requestHost) => {
* Resolve announced IP: env override, then request host if IPv4, then auto-detect. Pure and deterministic.
* @param {string | undefined} requestHost - Host header from the client.
* @returns {string | null} The IP to announce in ICE, or null for localhost-only.
*/
function resolveAnnouncedIp(requestHost) {
const envIp = process.env.MEDIASOUP_ANNOUNCED_IP?.trim() const envIp = process.env.MEDIASOUP_ANNOUNCED_IP?.trim()
if (envIp) return envIp if (envIp) return envIp
if (requestHost && isIPv4(requestHost)) return requestHost if (requestHost && isIPv4(requestHost)) return requestHost
return getAnnouncedIpFromInterfaces() return getAnnouncedIpFromInterfaces()
} }
/** export const createTransport = async (router, requestHost = undefined) => {
* Create a WebRTC transport for a router.
* @param {mediasoup.types.Router} router
* @param {boolean} _isProducer - true for publisher, false for consumer (reserved for future use)
* @param {string} [requestHost] - Hostname from the request (e.g. getRequestURL(event).hostname). If a valid IPv4, used as announced IP so the client can reach the server.
* @returns {Promise<{ transport: mediasoup.types.WebRtcTransport, params: object }>} Transport and connection params.
*/
// eslint-disable-next-line no-unused-vars
export async function createTransport(router, _isProducer = false, requestHost = undefined) {
// LAN first so the phone (and remote viewers) try the reachable IP before 127.0.0.1 (loopback on the client).
const announcedIp = resolveAnnouncedIp(requestHost) const announcedIp = resolveAnnouncedIp(requestHost)
const listenIps = announcedIp const listenIps = announcedIp
? [{ ip: '0.0.0.0', announcedIp }, { ip: '127.0.0.1' }] ? [{ ip: '0.0.0.0', announcedIp }, { ip: '127.0.0.1' }]
@@ -138,10 +79,10 @@ export async function createTransport(router, _isProducer = false, requestHost =
console.error('[mediasoup] Transport creation failed:', err) console.error('[mediasoup] Transport creation failed:', err)
throw new Error(`Failed to create transport: ${err.message || String(err)}`) throw new Error(`Failed to create transport: ${err.message || String(err)}`)
}) })
transports.set(transport.id, transport) transports.set(transport.id, transport)
transport.on('close', () => { transport.on('close', () => transports.delete(transport.id))
transports.delete(transport.id)
})
return { return {
transport, transport,
params: { params: {
@@ -153,61 +94,22 @@ export async function createTransport(router, _isProducer = false, requestHost =
} }
} }
/** export const getTransport = transportId => transports.get(transportId)
* Get transport by ID.
* @param {string} transportId
* @returns {mediasoup.types.WebRtcTransport | undefined} Transport or undefined.
*/
export function getTransport(transportId) {
return transports.get(transportId)
}
/** export const createProducer = async (transport, track) => {
* Create a producer (publisher's video track).
* @param {mediasoup.types.WebRtcTransport} transport
* @param {MediaStreamTrack} track
* @returns {Promise<mediasoup.types.Producer>} The producer.
*/
export async function createProducer(transport, track) {
const producer = await transport.produce({ track }) const producer = await transport.produce({ track })
producers.set(producer.id, producer) producers.set(producer.id, producer)
producer.on('close', () => { producer.on('close', () => producers.delete(producer.id))
producers.delete(producer.id)
})
return producer return producer
} }
/** export const getProducer = producerId => producers.get(producerId)
* Get producer by ID.
* @param {string} producerId
* @returns {mediasoup.types.Producer | undefined} Producer or undefined.
*/
export function getProducer(producerId) {
return producers.get(producerId)
}
/** export const getTransports = () => transports
* Get transports Map (for cleanup).
* @returns {Map<string, mediasoup.types.WebRtcTransport>} Map of transport ID to transport.
*/
export function getTransports() {
return transports
}
/** export const createConsumer = async (transport, producer, rtpCapabilities) => {
* Create a consumer (viewer subscribes to producer's stream). if (producer.closed) throw new Error('Producer is closed')
* @param {mediasoup.types.WebRtcTransport} transport if (producer.paused) await producer.resume()
* @param {mediasoup.types.Producer} producer
* @param {boolean} rtpCapabilities
* @returns {Promise<{ consumer: mediasoup.types.Consumer, params: object }>} Consumer and connection params.
*/
export async function createConsumer(transport, producer, rtpCapabilities) {
if (producer.closed) {
throw new Error('Producer is closed')
}
if (producer.paused) {
await producer.resume()
}
const consumer = await transport.consume({ const consumer = await transport.consume({
producerId: producer.id, producerId: producer.id,
@@ -229,11 +131,7 @@ export async function createConsumer(transport, producer, rtpCapabilities) {
} }
} }
/** export const closeRouter = async (sessionId) => {
* Clean up router for a session.
* @param {string} sessionId
*/
export async function closeRouter(sessionId) {
const router = routers.get(sessionId) const router = routers.get(sessionId)
if (router) { if (router) {
router.close() router.close()
@@ -241,10 +139,4 @@ export async function closeRouter(sessionId) {
} }
} }
/** export const getActiveRouters = () => Array.from(routers.keys())
* Get all active routers (for debugging/monitoring).
* @returns {Array<string>} Session IDs with active routers
*/
export function getActiveRouters() {
return Array.from(routers.keys())
}

View File

@@ -1,27 +0,0 @@
import { join } from 'node:path'
import { readFileSync, existsSync } from 'node:fs'
import { getDb } from './db.js'
import { sanitizeStreamUrl } from './feedUtils.js'
/**
* One-time migration: insert entries from server/data/feeds.json into devices (device_type = 'feed').
* No-op if devices table already has rows or feeds file is missing.
*/
export async function migrateFeedsToDevices() {
const db = await getDb()
const row = await db.get('SELECT COUNT(*) as n FROM devices')
if (row?.n > 0) return
const path = join(process.cwd(), 'server/data/feeds.json')
if (!existsSync(path)) return
const data = JSON.parse(readFileSync(path, 'utf8'))
const list = Array.isArray(data) ? data : []
for (const feed of list) {
if (!feed?.id || typeof feed.name !== 'string' || typeof feed.lat !== 'number' || typeof feed.lng !== 'number') continue
const streamUrl = sanitizeStreamUrl(feed.streamUrl) ?? ''
const sourceType = feed.sourceType === 'hls' ? 'hls' : 'mjpeg'
await db.run(
'INSERT OR IGNORE INTO devices (id, name, device_type, vendor, lat, lng, stream_url, source_type, config) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
[feed.id, feed.name, 'feed', null, feed.lat, feed.lng, streamUrl, sourceType, null],
)
}
}

View File

@@ -0,0 +1 @@
export const POI_ICON_TYPES = Object.freeze(['pin', 'flag', 'waypoint'])

View File

@@ -1,15 +1,6 @@
const DEFAULT_DAYS = 7 const [MIN_DAYS, MAX_DAYS, DEFAULT_DAYS] = [1, 365, 7]
const MIN_DAYS = 1
const MAX_DAYS = 365
/**
* Session lifetime in days (for cookie and DB expires_at). Uses SESSION_MAX_AGE_DAYS.
* Clamped to 1365 days.
*/
export function getSessionMaxAgeDays() { export function getSessionMaxAgeDays() {
const raw = process.env.SESSION_MAX_AGE_DAYS != null const raw = Number.parseInt(process.env.SESSION_MAX_AGE_DAYS ?? '', 10)
? Number.parseInt(process.env.SESSION_MAX_AGE_DAYS, 10) return Number.isFinite(raw) ? Math.max(MIN_DAYS, Math.min(MAX_DAYS, raw)) : DEFAULT_DAYS
: Number.NaN
if (Number.isFinite(raw)) return Math.max(MIN_DAYS, Math.min(MAX_DAYS, raw))
return DEFAULT_DAYS
} }

View File

@@ -1,19 +1,6 @@
/**
* WebRTC signaling message handlers.
* Processes WebSocket messages for WebRTC operations.
*/
import { getLiveSession, updateLiveSession } from './liveSessions.js' import { getLiveSession, updateLiveSession } from './liveSessions.js'
import { getRouter, createTransport, getTransport } from './mediasoup.js' import { getRouter, createTransport, getTransport } from './mediasoup.js'
/**
* Handle WebSocket message for WebRTC signaling.
* @param {string} userId
* @param {string} sessionId
* @param {string} type
* @param {object} data
* @returns {Promise<object | null>} Response message or null
*/
export async function handleWebSocketMessage(userId, sessionId, type, data) { export async function handleWebSocketMessage(userId, sessionId, type, data) {
const session = getLiveSession(sessionId) const session = getLiveSession(sessionId)
if (!session) { if (!session) {
@@ -32,7 +19,7 @@ export async function handleWebSocketMessage(userId, sessionId, type, data) {
} }
case 'create-transport': { case 'create-transport': {
const router = await getRouter(sessionId) const router = await getRouter(sessionId)
const { transport, params } = await createTransport(router, true) const { transport, params } = await createTransport(router)
updateLiveSession(sessionId, { transportId: transport.id, routerId: router.id }) updateLiveSession(sessionId, { transportId: transport.id, routerId: router.id })
return { type: 'transport-created', data: params } return { type: 'transport-created', data: params }
} }

View File

@@ -16,9 +16,11 @@ export default {
kestrel: { kestrel: {
'bg': '#060b14', 'bg': '#060b14',
'surface': '#0d1424', 'surface': '#0d1424',
'surface-elevated': '#1e293b',
'surface-hover': '#111a2e', 'surface-hover': '#111a2e',
'border': '#1a2744', 'border': '#1a2744',
'text': '#b8c9e0', 'text': '#b8c9e0',
'text-bright': '#e2e8f0',
'muted': '#5c6f8a', 'muted': '#5c6f8a',
'accent': '#22c9c9', 'accent': '#22c9c9',
'accent-dim': '#0f3d3d', 'accent-dim': '#0f3d3d',
@@ -30,12 +32,30 @@ export default {
'glow': '0 0 20px -4px rgba(34, 201, 201, 0.3)', 'glow': '0 0 20px -4px rgba(34, 201, 201, 0.3)',
'glow-md': '0 0 24px -2px rgba(34, 201, 201, 0.25)', 'glow-md': '0 0 24px -2px rgba(34, 201, 201, 0.25)',
'glow-border': 'inset 0 0 20px -8px rgba(34, 201, 201, 0.15)', 'glow-border': 'inset 0 0 20px -8px rgba(34, 201, 201, 0.15)',
'glow-header': '0 0 20px -4px rgba(34, 201, 201, 0.15)',
'glow-dropdown': '0 4px 12px -2px rgba(34, 201, 201, 0.15)',
'glow-panel': '-8px 0 24px -4px rgba(34, 201, 201, 0.12)',
'glow-modal': '0 0 32px -8px rgba(34, 201, 201, 0.25)',
'glow-card': '0 0 20px -4px rgba(34, 201, 201, 0.15)',
'glow-context': '0 0 20px -4px rgba(34, 201, 201, 0.2)',
'glow-inset-video': 'inset 0 0 20px -8px rgba(34, 201, 201, 0.1)',
'border-header': '0 1px 0 0 rgba(34, 201, 201, 0.08)',
'elevated': '0 4px 12px rgba(0, 0, 0, 0.4)',
}, },
textShadow: { textShadow: {
'glow': '0 0 12px rgba(34, 201, 201, 0.4)', 'glow': '0 0 12px rgba(34, 201, 201, 0.4)',
'glow-sm': '0 0 8px rgba(34, 201, 201, 0.3)', 'glow-sm': '0 0 8px rgba(34, 201, 201, 0.25)',
'glow-md': '0 0 12px rgba(34, 201, 201, 0.35)',
}, },
}, },
}, },
plugins: [], plugins: [
function ({ addUtilities, theme }) {
addUtilities({
'.text-shadow-glow-sm': { textShadow: theme('textShadow.glow-sm') },
'.text-shadow-glow': { textShadow: theme('textShadow.glow') },
'.text-shadow-glow-md': { textShadow: theme('textShadow.glow-md') },
})
},
],
} }

View File

@@ -1,66 +1,58 @@
/**
* Global setup for E2E tests.
* Runs once before all tests.
*/
import { existsSync, mkdirSync } from 'node:fs' import { existsSync, mkdirSync } from 'node:fs'
import { join, dirname } from 'node:path' import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { execSync } from 'node:child_process' import { execSync } from 'node:child_process'
const _dirname = dirname(fileURLToPath(import.meta.url)) const projectRoot = join(dirname(fileURLToPath(import.meta.url)), '../../..')
const projectRoot = join(_dirname, '../../..')
const devCertsDir = join(projectRoot, '.dev-certs') const devCertsDir = join(projectRoot, '.dev-certs')
const devKey = join(devCertsDir, 'key.pem') const devKey = join(devCertsDir, 'key.pem')
const devCert = join(devCertsDir, 'cert.pem') const devCert = join(devCertsDir, 'cert.pem')
// Import server modules (ES modules)
const { getDb } = await import('../../server/utils/db.js') const { getDb } = await import('../../server/utils/db.js')
const { hashPassword } = await import('../../server/utils/password.js') const { hashPassword } = await import('../../server/utils/password.js')
const { TEST_ADMIN } = await import('./fixtures/users.js') const { TEST_ADMIN } = await import('./fixtures/users.js')
function ensureDevCerts() { const ensureDevCerts = () => {
if (existsSync(devKey) && existsSync(devCert)) { if (existsSync(devKey) && existsSync(devCert)) return
return // Certs already exist
}
// Create .dev-certs directory
mkdirSync(devCertsDir, { recursive: true }) mkdirSync(devCertsDir, { recursive: true })
// Generate self-signed cert for localhost/127.0.0.1
const SAN = 'subjectAltName=IP:127.0.0.1,DNS:localhost'
try { try {
execSync( execSync(
`openssl req -x509 -newkey rsa:2048 -keyout "${devKey}" -out "${devCert}" -days 365 -nodes -subj "/CN=localhost" -addext "${SAN}"`, `openssl req -x509 -newkey rsa:2048 -keyout "${devKey}" -out "${devCert}" -days 365 -nodes -subj "/CN=localhost" -addext "subjectAltName=IP:127.0.0.1,DNS:localhost"`,
{ cwd: projectRoot, stdio: 'inherit' }, { cwd: projectRoot, stdio: process.env.CI ? 'pipe' : 'inherit' },
) )
console.log('[test] Generated .dev-certs/key.pem and .dev-certs/cert.pem')
} }
catch (error) { catch (error) {
throw new Error(`Failed to generate dev certificates: ${error.message}`) throw new Error(`Failed to generate dev certificates: ${error.message}`)
} }
} }
async function globalSetup() { export default async function globalSetup() {
// Ensure dev certificates exist
ensureDevCerts() ensureDevCerts()
// Create test admin user if it doesn't exist let retries = 3
while (retries > 0) {
try {
const { get, run } = await getDb() const { get, run } = await getDb()
const existingUser = await get('SELECT id FROM users WHERE identifier = ?', [TEST_ADMIN.identifier]) const existing = await get('SELECT id FROM users WHERE identifier = ?', [TEST_ADMIN.identifier])
if (!existingUser) { if (!existing) {
const id = crypto.randomUUID()
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, TEST_ADMIN.identifier, hashPassword(TEST_ADMIN.password), TEST_ADMIN.role, now, 'local', null, null], [crypto.randomUUID(), TEST_ADMIN.identifier, hashPassword(TEST_ADMIN.password), TEST_ADMIN.role, new Date().toISOString(), 'local', null, null],
) )
console.log(`[test] Created test admin user: ${TEST_ADMIN.identifier}`)
} }
else { return
console.log(`[test] Test admin user already exists: ${TEST_ADMIN.identifier}`) }
catch (error) {
if (error.message?.includes('SQLITE_BUSY') || error.message?.includes('database is locked')) {
retries--
if (retries > 0) {
await new Promise(resolve => setTimeout(resolve, 100 * (4 - retries)))
continue
}
}
throw error
}
} }
} }
export default globalSetup

View File

@@ -23,13 +23,15 @@ test.describe('Live Streaming E2E', () => {
await loginAsAdmin(page, TEST_ADMIN.identifier, TEST_ADMIN.password) await loginAsAdmin(page, TEST_ADMIN.identifier, TEST_ADMIN.password)
await page.goto('/cameras') await page.goto('/cameras')
await page.waitForLoadState('domcontentloaded') await page.waitForLoadState('domcontentloaded')
await expect(page.getByRole('heading', { name: 'Cameras' })).toBeVisible({ timeout: 10000 }) await expect(page.getByRole('heading', { name: 'Cameras', exact: true })).toBeVisible({ timeout: 10000 })
}) })
test('publisher only: start sharing and reach Live', async ({ browser, browserName }) => { test('publisher only: start sharing and reach Live', async ({ browser, browserName }) => {
test.skip(browserName !== 'chromium', 'Fake camera only supported in Chromium') test.skip(browserName !== 'chromium', 'Fake camera only supported in Chromium')
// Skip in CI - WebRTC tests are flaky with fake media devices in CI environments
test.skip(!!process.env.CI, 'WebRTC tests skipped in CI due to flaky fake media device support')
const ctx = await browser.newContext({ const ctx = await browser.newContext({
permissions: ['geolocation'], permissions: ['camera', 'microphone', 'geolocation'],
geolocation: { latitude: 37.7749, longitude: -122.4194 }, geolocation: { latitude: 37.7749, longitude: -122.4194 },
}) })
const page = await ctx.newPage() const page = await ctx.newPage()
@@ -55,9 +57,11 @@ test.describe('Live Streaming E2E', () => {
test('Mobile Safari publishes, Desktop Chrome views', async ({ browser, browserName }) => { test('Mobile Safari publishes, Desktop Chrome views', async ({ browser, browserName }) => {
test.skip(browserName !== 'chromium', 'Fake camera only supported in Chromium') test.skip(browserName !== 'chromium', 'Fake camera only supported in Chromium')
// Skip in CI - WebRTC tests are flaky with fake media devices in CI environments
test.skip(!!process.env.CI, 'WebRTC tests skipped in CI due to flaky fake media device support')
// Publisher context (same as publisher-only test for reliability) // Publisher context (same as publisher-only test for reliability)
const publisherContext = await browser.newContext({ const publisherContext = await browser.newContext({
permissions: ['geolocation'], permissions: ['camera', 'microphone', 'geolocation'],
geolocation: { latitude: 37.7749, longitude: -122.4194 }, geolocation: { latitude: 37.7749, longitude: -122.4194 },
}) })
const publisherPage = await publisherContext.newPage() const publisherPage = await publisherContext.newPage()
@@ -123,8 +127,10 @@ test.describe('Live Streaming E2E', () => {
test('Mobile Safari publishes, Desktop Firefox views', async ({ browser, browserName }) => { test('Mobile Safari publishes, Desktop Firefox views', async ({ browser, browserName }) => {
test.skip(browserName !== 'chromium', 'Fake camera only supported in Chromium') test.skip(browserName !== 'chromium', 'Fake camera only supported in Chromium')
// Skip in CI - WebRTC tests are flaky with fake media devices in CI environments
test.skip(!!process.env.CI, 'WebRTC tests skipped in CI due to flaky fake media device support')
const publisherContext = await browser.newContext({ const publisherContext = await browser.newContext({
permissions: ['geolocation'], permissions: ['camera', 'microphone', 'geolocation'],
geolocation: { latitude: 37.7749, longitude: -122.4194 }, geolocation: { latitude: 37.7749, longitude: -122.4194 },
}) })
const publisherPage = await publisherContext.newPage() const publisherPage = await publisherContext.newPage()

View File

@@ -10,24 +10,24 @@ vi.mock('leaflet.offline', () => ({ tileLayerOffline: null, savetiles: null }))
describe('KestrelMap', () => { describe('KestrelMap', () => {
it('renders map container', async () => { it('renders map container', async () => {
const wrapper = await mountSuspended(KestrelMap, { const wrapper = await mountSuspended(KestrelMap, {
props: { feeds: [] }, props: { devices: [] },
}) })
expect(wrapper.find('[data-testid="kestrel-map"]').exists()).toBe(true) expect(wrapper.find('[data-testid="kestrel-map"]').exists()).toBe(true)
}) })
it('accepts feeds prop', async () => { it('accepts devices prop', async () => {
const feeds = [ const devices = [
{ id: '1', name: 'A', lat: 37.7, lng: -122.4, streamUrl: '', sourceType: 'mjpeg' }, { id: '1', name: 'A', lat: 37.7, lng: -122.4, streamUrl: '', sourceType: 'mjpeg' },
] ]
const wrapper = await mountSuspended(KestrelMap, { const wrapper = await mountSuspended(KestrelMap, {
props: { feeds }, props: { devices },
}) })
expect(wrapper.props('feeds')).toEqual(feeds) expect(wrapper.props('devices')).toEqual(devices)
}) })
it('has select emit', async () => { it('has select emit', async () => {
const wrapper = await mountSuspended(KestrelMap, { const wrapper = await mountSuspended(KestrelMap, {
props: { feeds: [] }, props: { devices: [] },
}) })
wrapper.vm.$emit('select', { id: 'x', name: 'X', lat: 0, lng: 0 }) wrapper.vm.$emit('select', { id: 'x', name: 'X', lat: 0, lng: 0 })
expect(wrapper.emitted('select')).toHaveLength(1) expect(wrapper.emitted('select')).toHaveLength(1)
@@ -67,7 +67,7 @@ describe('KestrelMap', () => {
it('accepts pois and canEditPois props', async () => { it('accepts pois and canEditPois props', async () => {
const wrapper = await mountSuspended(KestrelMap, { const wrapper = await mountSuspended(KestrelMap, {
props: { props: {
feeds: [], devices: [],
pois: [{ id: 'p1', lat: 37.7, lng: -122.4, label: 'P', icon_type: 'pin' }], pois: [{ id: 'p1', lat: 37.7, lng: -122.4, label: 'P', icon_type: 'pin' }],
canEditPois: false, canEditPois: false,
}, },

View File

@@ -3,7 +3,7 @@ import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
import NavDrawer from '../../app/components/NavDrawer.vue' import NavDrawer from '../../app/components/NavDrawer.vue'
const withAuth = () => { const withAuth = () => {
registerEndpoint('/api/me', () => ({ id: '1', identifier: 'user', role: 'member' }), { method: 'GET' }) registerEndpoint('/api/me', () => ({ id: '1', identifier: 'user', role: 'member', avatar_url: null }), { method: 'GET' })
} }
describe('NavDrawer', () => { describe('NavDrawer', () => {
@@ -32,7 +32,6 @@ describe('NavDrawer', () => {
}) })
expect(document.body.textContent).toContain('Map') expect(document.body.textContent).toContain('Map')
expect(document.body.textContent).toContain('Settings') expect(document.body.textContent).toContain('Settings')
expect(document.body.textContent).toContain('Navigation')
}) })
it('emits update:modelValue when close is triggered', async () => { it('emits update:modelValue when close is triggered', async () => {

View File

@@ -1,14 +0,0 @@
import { describe, it, expect } from 'vitest'
import { getValidFeeds } from '../../server/utils/feedUtils.js'
describe('API contract', () => {
it('getValidFeeds returns array suitable for API response', () => {
const raw = [
{ id: '1', name: 'A', lat: 1, lng: 2 },
{ id: '2', name: 'B', lat: 3, lng: 4 },
]
const out = getValidFeeds(raw)
expect(Array.isArray(out)).toBe(true)
expect(out).toHaveLength(2)
})
})

View File

@@ -4,7 +4,7 @@ import DefaultLayout from '../../app/layouts/default.vue'
import NavDrawer from '../../app/components/NavDrawer.vue' import NavDrawer from '../../app/components/NavDrawer.vue'
const withAuth = () => { const withAuth = () => {
registerEndpoint('/api/me', () => ({ id: '1', identifier: 'user', role: 'member' }), { method: 'GET' }) registerEndpoint('/api/me', () => ({ id: '1', identifier: 'user', role: 'member', avatar_url: null }), { method: 'GET' })
} }
describe('default layout', () => { describe('default layout', () => {
@@ -12,10 +12,9 @@ describe('default layout', () => {
withAuth() withAuth()
const wrapper = await mountSuspended(DefaultLayout) const wrapper = await mountSuspended(DefaultLayout)
expect(wrapper.text()).toContain('KestrelOS') expect(wrapper.text()).toContain('KestrelOS')
expect(wrapper.text()).toContain('Tactical Operations Center')
}) })
it('renders drawer toggle with accessible label', async () => { it('renders drawer toggle with accessible label on mobile', async () => {
withAuth() withAuth()
const wrapper = await mountSuspended(DefaultLayout) const wrapper = await mountSuspended(DefaultLayout)
const toggle = wrapper.find('button[aria-label="Toggle navigation"]') const toggle = wrapper.find('button[aria-label="Toggle navigation"]')
@@ -28,14 +27,19 @@ describe('default layout', () => {
expect(wrapper.findComponent(NavDrawer).exists()).toBe(true) expect(wrapper.findComponent(NavDrawer).exists()).toBe(true)
}) })
it('calls logout and navigates when Logout is clicked', async () => { it('renders user menu and sign out navigates home', async () => {
withAuth() withAuth()
registerEndpoint('/api/auth/logout', () => null, { method: 'POST' }) registerEndpoint('/api/auth/logout', () => null, { method: 'POST' })
const wrapper = await mountSuspended(DefaultLayout) const wrapper = await mountSuspended(DefaultLayout)
await new Promise(r => setTimeout(r, 100)) await new Promise(r => setTimeout(r, 100))
const logoutBtn = wrapper.findAll('button').find(b => b.text().includes('Logout')) const menuTrigger = wrapper.find('button[aria-label="User menu"]')
expect(logoutBtn).toBeDefined() expect(menuTrigger.exists()).toBe(true)
await logoutBtn.trigger('click') await menuTrigger.trigger('click')
await new Promise(r => setTimeout(r, 50))
const signOut = wrapper.find('button[role="menuitem"]')
expect(signOut.exists()).toBe(true)
expect(signOut.text()).toContain('Sign out')
await signOut.trigger('click')
await new Promise(r => setTimeout(r, 100)) await new Promise(r => setTimeout(r, 100))
const router = useRouter() const router = useRouter()
await router.isReady() await router.isReady()

View File

@@ -1,119 +0,0 @@
import { describe, it, expect } from 'vitest'
import { isValidFeed, getValidFeeds, sanitizeStreamUrl, sanitizeFeedForResponse } from '../../server/utils/feedUtils.js'
describe('feedUtils', () => {
describe('isValidFeed', () => {
it('returns true for valid feed', () => {
expect(isValidFeed({
id: '1',
name: 'Cam',
lat: 37.7,
lng: -122.4,
})).toBe(true)
})
it('returns false for null', () => {
expect(isValidFeed(null)).toBe(false)
})
it('returns false for missing id', () => {
expect(isValidFeed({ name: 'x', lat: 0, lng: 0 })).toBe(false)
})
it('returns false for wrong lat type', () => {
expect(isValidFeed({ id: '1', name: 'x', lat: '37', lng: -122 })).toBe(false)
})
})
describe('getValidFeeds', () => {
it('returns only valid feeds', () => {
const list = [
{ id: 'a', name: 'A', lat: 1, lng: 2 },
null,
{ id: 'b', name: 'B', lat: 3, lng: 4 },
]
expect(getValidFeeds(list)).toHaveLength(2)
})
it('returns empty array for non-array', () => {
expect(getValidFeeds(null)).toEqual([])
expect(getValidFeeds({})).toEqual([])
})
})
describe('sanitizeStreamUrl', () => {
it('allows http and https', () => {
expect(sanitizeStreamUrl('https://example.com/stream')).toBe('https://example.com/stream')
expect(sanitizeStreamUrl('http://example.com/stream')).toBe('http://example.com/stream')
})
it('returns empty for javascript:, data:, and other schemes', () => {
expect(sanitizeStreamUrl('javascript:alert(1)')).toBe('')
expect(sanitizeStreamUrl('data:text/html,<script>')).toBe('')
expect(sanitizeStreamUrl('file:///etc/passwd')).toBe('')
})
it('returns empty for non-strings or empty', () => {
expect(sanitizeStreamUrl('')).toBe('')
expect(sanitizeStreamUrl(' ')).toBe('')
expect(sanitizeStreamUrl(null)).toBe('')
expect(sanitizeStreamUrl(123)).toBe('')
})
})
describe('sanitizeFeedForResponse', () => {
it('returns safe shape with sanitized streamUrl and sourceType', () => {
const feed = {
id: 'f1',
name: 'Cam',
lat: 37,
lng: -122,
streamUrl: 'https://safe.com/s',
sourceType: 'mjpeg',
}
const out = sanitizeFeedForResponse(feed)
expect(out).toEqual({
id: 'f1',
name: 'Cam',
lat: 37,
lng: -122,
streamUrl: 'https://safe.com/s',
sourceType: 'mjpeg',
})
})
it('strips dangerous streamUrl and normalizes sourceType', () => {
const feed = {
id: 'f2',
name: 'Bad',
lat: 0,
lng: 0,
streamUrl: 'javascript:alert(1)',
sourceType: 'hls',
}
const out = sanitizeFeedForResponse(feed)
expect(out.streamUrl).toBe('')
expect(out.sourceType).toBe('hls')
})
it('includes description only when string', () => {
const withDesc = sanitizeFeedForResponse({
id: 'a',
name: 'n',
lat: 0,
lng: 0,
description: 'A camera',
})
expect(withDesc.description).toBe('A camera')
const noDesc = sanitizeFeedForResponse({
id: 'b',
name: 'n',
lat: 0,
lng: 0,
description: 123,
})
expect(noDesc).not.toHaveProperty('description')
})
})
})

View File

@@ -25,7 +25,7 @@ describe('Mediasoup', () => {
it('should create a transport', async () => { it('should create a transport', async () => {
const router = await getRouter(sessionId) const router = await getRouter(sessionId)
const { transport, params } = await createTransport(router, true) const { transport, params } = await createTransport(router)
expect(transport).toBeDefined() expect(transport).toBeDefined()
expect(params.id).toBe(transport.id) expect(params.id).toBe(transport.id)
expect(params.iceParameters).toBeDefined() expect(params.iceParameters).toBeDefined()
@@ -35,7 +35,7 @@ describe('Mediasoup', () => {
it('should create a transport with requestHost IPv4 and return valid params', async () => { it('should create a transport with requestHost IPv4 and return valid params', async () => {
const router = await getRouter(sessionId) const router = await getRouter(sessionId)
const { transport, params } = await createTransport(router, true, '192.168.2.100') const { transport, params } = await createTransport(router, '192.168.2.100')
expect(transport).toBeDefined() expect(transport).toBeDefined()
expect(params.id).toBe(transport.id) expect(params.id).toBe(transport.id)
expect(params.iceParameters).toBeDefined() expect(params.iceParameters).toBeDefined()

View File

@@ -1,32 +0,0 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { getDb, setDbPathForTest } from '../../server/utils/db.js'
import { migrateFeedsToDevices } from '../../server/utils/migrateFeedsToDevices.js'
describe('migrateFeedsToDevices', () => {
beforeEach(() => {
setDbPathForTest(':memory:')
})
afterEach(() => {
setDbPathForTest(null)
})
it('runs without error when devices table is empty', async () => {
const db = await getDb()
await expect(migrateFeedsToDevices()).resolves.toBeUndefined()
const rows = await db.all('SELECT id FROM devices')
expect(rows.length).toBeGreaterThanOrEqual(0)
})
it('is no-op when devices already has rows', async () => {
const db = await getDb()
await db.run(
'INSERT INTO devices (id, name, device_type, vendor, lat, lng, stream_url, source_type, config) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
['existing', 'Existing', 'feed', null, 0, 0, '', 'mjpeg', null],
)
await migrateFeedsToDevices()
const rows = await db.all('SELECT id FROM devices')
expect(rows).toHaveLength(1)
expect(rows[0].id).toBe('existing')
})
})

View File

@@ -22,7 +22,6 @@ export default defineVitestConfig({
'app/composables/useCameras.js', // Visibility/polling branches; covered by E2E 'app/composables/useCameras.js', // Visibility/polling branches; covered by E2E
'server/utils/mediasoup.js', // Requires real mediasoup worker; covered by integration/E2E 'server/utils/mediasoup.js', // Requires real mediasoup worker; covered by integration/E2E
'server/utils/db.js', // Bootstrap/path branches depend on env; covered by integration 'server/utils/db.js', // Bootstrap/path branches depend on env; covered by integration
'server/utils/migrateFeedsToDevices.js', // File-system branches; one-time migration
'**/*.spec.js', '**/*.spec.js',
'**/*.config.js', '**/*.config.js',
'**/node_modules/**', '**/node_modules/**',