diff --git a/.gitea/workflows/pr.yml b/.gitea/workflows/pr.yml
index e6a632f..b2be0f4 100644
--- a/.gitea/workflows/pr.yml
+++ b/.gitea/workflows/pr.yml
@@ -41,7 +41,7 @@ jobs:
e2e:
runs-on: ubuntu-latest
container:
- image: mcr.microsoft.com/playwright:v1.59.1-noble
+ image: mcr.microsoft.com/playwright:v1.61.1-noble
steps:
- uses: https://git.keligrubb.com/actions/checkout@v7
diff --git a/.nuxtrc b/.nuxtrc
index 1e1fe83..640f280 100644
--- a/.nuxtrc
+++ b/.nuxtrc
@@ -1 +1 @@
-setups.@nuxt/test-utils="4.0.0"
\ No newline at end of file
+setups.@nuxt/test-utils="4.0.3"
\ No newline at end of file
diff --git a/README.md b/README.md
index 1de3c68..59c2a76 100644
--- a/README.md
+++ b/README.md
@@ -56,7 +56,7 @@ See [docs/live-streaming.md](docs/live-streaming.md) for setup and usage.
### ATAK / CoT (Cursor on Target)
-KestrelOS can act as a **TAK Server** so ATAK and iTAK devices connect and share positions. No plugins: in ATAK, add a **Server** connection (host = KestrelOS, port **8089** for CoT). Check **Use Authentication** and enter your **KestrelOS username** and **password** (local users use their login password; OIDC users must set an **ATAK password** once under **Account** in the web app). Devices relay CoT to each other (team members see each other on the ATAK map) and appear on the KestrelOS web map; they drop off after ~90 seconds if no updates. Optional: set `COT_TTL_MS`, `COT_REQUIRE_AUTH`; CoT runs on port 8089 (default).
+KestrelOS can act as a **TAK Server** so ATAK and iTAK devices connect and share positions. No plugins: in ATAK, add a **Server** connection (host = KestrelOS, port **8089** for CoT). Check **Use Authentication** and enter your **KestrelOS username** and **password** (local users use their login password; OIDC users must set an **ATAK password** once under **Account** in the web app). Devices relay CoT to each other (team members see each other on the ATAK map) and appear on the KestrelOS web map; they drop off after ~90 seconds if no updates. CoT runs on port 8089 (default).
## Scripts
@@ -74,7 +74,7 @@ Full docs are in the **[docs/](docs/README.md)** directory: [installation](docs/
## Configuration
- **Devices**: Manage cameras/devices via the API (`/api/devices`); see [Map and cameras](docs/map-and-cameras.md). Each device needs `name`, `device_type`, `lat`, `lng`, `stream_url`, and `source_type` (`mjpeg` or `hls`).
-- **Environment**: No required env vars for basic run. For production, set `HOST=0.0.0.0` and expose ports 3000 (web/API) and 8089 (CoT). Set `COT_TTL_MS=90000`, `COT_REQUIRE_AUTH=true`. For TLS use `.dev-certs/` or set `COT_SSL_CERT` and `COT_SSL_KEY`.
+- **Environment**: No required env vars for basic run. For production, set `HOST=0.0.0.0` and expose ports 3000 (web/API) and 8089 (CoT). For TLS use `.dev-certs/` or set `COT_SSL_CERT` and `COT_SSL_KEY`.
- **Authentication**: The login page always offers password sign-in (local). Optionally set `BOOTSTRAP_EMAIL` and `BOOTSTRAP_PASSWORD` before the first run to create the first admin; otherwise a default admin is created and its credentials are printed in the terminal. To also show an OIDC sign-in button, configure `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, and optionally `OIDC_LABEL`, `OIDC_REDIRECT_URI`. See [docs/auth.md](docs/auth.md) for local login, OIDC config, and sign up.
- **Bootstrap admin** (when using local auth): The server initializes the database and runs bootstrap at startup. On first run (no users in the database), it creates the first admin. If you set `BOOTSTRAP_EMAIL` and `BOOTSTRAP_PASSWORD` before starting, that account is created. If you don't set them, a default admin is created (identifier: `admin`) with a random password and the credentials are printed in the terminal-copy them and sign in at `/login`, then change the password or add users via Members. Use **Members** to change roles (admin, leader, member). Only admins can change roles; admins and leaders can edit POIs.
- **Database**: SQLite file at `data/kestrelos.db` (created automatically). Contains users, sessions, and POIs.
diff --git a/app/app.vue b/app/app.vue
index 4c26c91..f8eacfa 100644
--- a/app/app.vue
+++ b/app/app.vue
@@ -1,5 +1,5 @@
-
+
diff --git a/app/assets/css/main.css b/app/assets/css/main.css
index 56a23fa..d55f3e1 100644
--- a/app/assets/css/main.css
+++ b/app/assets/css/main.css
@@ -16,6 +16,9 @@
.kestrel-btn-secondary { @apply rounded border border-kestrel-border px-4 py-2 text-sm text-kestrel-text transition-colors hover:bg-kestrel-border; }
.kestrel-context-menu-item { @apply block w-full px-3 py-1.5 text-left text-sm text-kestrel-text transition-colors hover:bg-kestrel-border; }
.kestrel-context-menu-item-danger { @apply block w-full px-3 py-1.5 text-left text-sm text-red-400 transition-colors hover:bg-kestrel-border; }
+ .kestrel-cot-layer-btn { @apply rounded px-1.5 py-0.5 text-kestrel-muted transition-colors hover:text-kestrel-text; }
+ .kestrel-cot-layer-btn-active { @apply bg-kestrel-border text-kestrel-accent; }
+ .cot-icon-rotatable { @apply inline-flex origin-center; }
.kestrel-panel-base { @apply flex flex-col border border-kestrel-border bg-kestrel-surface; }
.kestrel-panel-inline { @apply rounded-lg shadow-glow; }
.kestrel-panel-overlay { @apply absolute right-0 top-0 z-[1000] h-full w-full border-l shadow-glow md:w-[420px] shadow-glow-panel; }
@@ -84,12 +87,14 @@
}
.kestrel-map-container .leaflet-control-zoom,
.kestrel-map-container .leaflet-control-locate,
+.kestrel-map-container .leaflet-control-alpr,
.kestrel-map-container .savetiles.leaflet-bar {
@apply rounded-md overflow-hidden font-mono border border-kestrel-glow shadow-glow-sm;
border-color: rgba(34, 201, 201, 0.35) !important;
}
.kestrel-map-container .leaflet-control-zoom a,
.kestrel-map-container .leaflet-control-locate,
+.kestrel-map-container .leaflet-control-alpr,
.kestrel-map-container .savetiles.leaflet-bar a {
@apply w-8 h-8 leading-8 bg-kestrel-surface text-kestrel-text border-none rounded-none text-lg font-semibold no-underline transition-all duration-150;
width: 32px !important;
@@ -105,9 +110,14 @@
}
.kestrel-map-container .leaflet-control-zoom a:hover,
.kestrel-map-container .leaflet-control-locate:hover,
+.kestrel-map-container .leaflet-control-alpr:hover,
.kestrel-map-container .savetiles.leaflet-bar a:hover {
@apply bg-kestrel-surface-hover text-kestrel-accent shadow-glow-md text-shadow-glow-md;
}
+.kestrel-map-container .leaflet-control-alpr[aria-pressed="true"] {
+ color: #ef4444 !important;
+ box-shadow: 0 0 8px rgba(239, 68, 68, 0.45);
+}
.kestrel-map-container .savetiles.leaflet-bar {
@apply flex flex-col;
}
@@ -119,12 +129,38 @@
padding: 6px 10px !important;
font-size: 11px !important;
}
-.kestrel-map-container .leaflet-control-locate {
+.kestrel-map-container .leaflet-control-locate,
+.kestrel-map-container .leaflet-control-alpr {
@apply flex items-center justify-center p-0 cursor-pointer;
}
-.kestrel-map-container .leaflet-control-locate svg {
+.kestrel-map-container .leaflet-control-locate svg,
+.kestrel-map-container .leaflet-control-alpr svg {
color: currentColor;
}
+.kestrel-map-container .alpr-cone {
+ display: inline-flex;
+ transform-origin: center center;
+}
+.kestrel-map-container .alpr-cluster-icon {
+ background: transparent;
+ border: none;
+}
+.kestrel-map-container .alpr-cluster {
+ @apply flex items-center justify-center rounded-full bg-red-500/90 font-mono text-xs font-semibold text-white;
+ width: 100%;
+ height: 100%;
+ box-shadow: 0 0 8px rgba(239, 68, 68, 0.5);
+}
+.kestrel-map-container .cot-cluster-icon {
+ background: transparent;
+ border: none;
+}
+.kestrel-map-container .cot-cluster {
+ @apply flex items-center justify-center rounded-full bg-sky-500/90 font-mono text-xs font-semibold text-white;
+ width: 100%;
+ height: 100%;
+ box-shadow: 0 0 8px rgba(56, 189, 248, 0.5);
+}
.kestrel-map-container .live-session-icon {
animation: live-pulse 1.5s ease-in-out infinite;
}
diff --git a/app/components/AppShell.vue b/app/components/AppShell.vue
index 0c7be21..7386f83 100644
--- a/app/components/AppShell.vue
+++ b/app/components/AppShell.vue
@@ -26,6 +26,11 @@
:user="user"
@signout="onLogout"
/>
+
{
}
})
-const { user, refresh } = useUser()
+const { user, authPending, refresh } = useUser()
watch(isMobile, (mobile) => {
if (mobile) drawerOpen.value = false
diff --git a/app/components/KestrelMap.vue b/app/components/KestrelMap.vue
index 18e0cf7..4e5a181 100644
--- a/app/components/KestrelMap.vue
+++ b/app/components/KestrelMap.vue
@@ -47,11 +47,41 @@
@submit="onPoiSubmit"
@confirm-delete="confirmDeletePoi"
/>
+
+