From afc6952edadabc908552d0b03e0c75baa240b999 Mon Sep 17 00:00:00 2001 From: tajniak81 <13187254+tajniak81@users.noreply.github.com> Date: Mon, 13 Jul 2026 11:43:33 +0200 Subject: [PATCH] Initial commit: PilotVault multi-service project Add API Server (Go/PocketBase), Web App (Go BFF + Vue), Fly App (Flutter/DJI MSDK), Adobe Plugin, and Docker/Docker AIO deployment configs. Design assets and build artifacts are gitignored. Co-Authored-By: Claude Opus 4.8 --- .claude/launch.json | 33 + .gitignore | 6 + API Server/.dockerignore | 16 + API Server/.env.example | 20 + API Server/.gitignore | 8 + API Server/Dockerfile | 35 + API Server/README.md | 145 + API Server/cmd/server/main.go | 63 + API Server/docker-compose.yml | 12 + API Server/go.mod | 15 + API Server/go.sum | 22 + API Server/internal/api/admin.go | 155 ++ API Server/internal/api/auth.go | 102 + API Server/internal/api/devices.go | 42 + .../api/dist/assets/index-BwP7TTth.css | 1 + .../api/dist/assets/index-DKDpmK_V.js | 17 + API Server/internal/api/dist/favicon.svg | 7 + API Server/internal/api/dist/index.html | 15 + API Server/internal/api/health.go | 15 + API Server/internal/api/integrations.go | 534 ++++ .../internal/api/integrations_filetransfer.go | 502 ++++ .../internal/api/integrations_localstorage.go | 519 ++++ .../api/integrations_localstorage_test.go | 99 + .../internal/api/integrations_webdav.go | 446 +++ API Server/internal/api/orgs.go | 201 ++ API Server/internal/api/panel.go | 23 + API Server/internal/api/plugins.go | 108 + API Server/internal/api/preferences.go | 137 + API Server/internal/api/respond.go | 29 + API Server/internal/api/server.go | 240 ++ API Server/internal/api/settings.go | 159 ++ API Server/internal/api/status.go | 65 + API Server/internal/api/telemetry.go | 22 + API Server/internal/api/users.go | 498 ++++ API Server/internal/api/ws.go | 13 + API Server/internal/config/config.go | 141 + API Server/internal/hub/hub.go | 376 +++ API Server/internal/hub/models.go | 112 + API Server/internal/plugins/README.md | 307 +++ .../internal/plugins/builtin/builtin.go | 11 + .../builtin/filetransfer/filetransfer.go | 610 +++++ .../builtin/filetransfer/filetransfer_test.go | 97 + .../builtin/localstorage/localstorage.go | 368 +++ .../builtin/localstorage/localstorage_test.go | 139 + .../plugins/builtin/opensky/opensky.go | 339 +++ .../internal/plugins/builtin/webdav/webdav.go | 551 ++++ .../plugins/builtin/webdav/webdav_test.go | 300 ++ API Server/internal/plugins/doc.go | 20 + API Server/internal/plugins/external.go | 149 + API Server/internal/plugins/manager.go | 387 +++ API Server/internal/plugins/plugin.go | 167 ++ API Server/panel/index.html | 14 + API Server/panel/package-lock.json | 1964 +++++++++++++ API Server/panel/package.json | 20 + API Server/panel/public/favicon.svg | 7 + API Server/panel/src/App.vue | 807 ++++++ .../panel/src/components/EndpointTable.vue | 40 + API Server/panel/src/main.js | 6 + API Server/panel/src/style.css | 261 ++ API Server/panel/src/theme.js | 30 + API Server/panel/vite.config.js | 20 + API Server/pocketbase/README.md | 79 + .../1720300000_add_users_preferences.js | 32 + .../1720300100_add_users_role.js | 32 + .../1720300200_add_organizations.js | 43 + .../1720300300_add_users_organization.js | 34 + ...1720300400_extend_users_role_superadmin.js | 25 + .../1720300500_seed_orgs_and_users.js | 85 + .../1720300600_add_plugin_settings.js | 38 + API Server/scripts/Run-ApiServer.ps1 | 25 + Docker AIO/Dockerfile | 150 + Docker AIO/docker-compose.yml | 31 + Docker/docker-compose.yml | 40 + Fly App/.claude/launch.json | 17 + Fly App/.claude/settings.local.json | 9 + Fly App/.gitignore | 45 + Fly App/.metadata | 30 + Fly App/README.md | 90 + Fly App/analysis_options.yaml | 28 + Fly App/android/.gitignore | 13 + Fly App/android/app/build.gradle | 151 + Fly App/android/app/proguard-rules.pro | 26 + .../android/app/src/debug/AndroidManifest.xml | 7 + .../android/app/src/main/AndroidManifest.xml | 91 + .../flutter/dji_msdk_sample/DjiApplication.kt | 22 + .../flutter/dji_msdk_sample/DjiSdkBridge.kt | 189 ++ .../flutter/dji_msdk_sample/DjiVideoView.kt | 87 + .../flutter/dji_msdk_sample/MainActivity.kt | 58 + .../drawable-hdpi/ic_launcher_foreground.png | Bin 0 -> 8507 bytes .../drawable-mdpi/ic_launcher_foreground.png | Bin 0 -> 4072 bytes .../res/drawable-v21/launch_background.xml | 12 + .../drawable-xhdpi/ic_launcher_foreground.png | Bin 0 -> 9399 bytes .../ic_launcher_foreground.png | Bin 0 -> 17622 bytes .../ic_launcher_foreground.png | Bin 0 -> 25473 bytes .../main/res/drawable/launch_background.xml | 12 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 9 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 3027 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 1370 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 3413 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 6891 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 7982 bytes .../app/src/main/res/values-night/styles.xml | 18 + .../app/src/main/res/values/colors.xml | 4 + .../app/src/main/res/values/styles.xml | 18 + .../app/src/main/res/xml/accessory_filter.xml | 9 + .../app/src/profile/AndroidManifest.xml | 7 + Fly App/android/build.gradle | 18 + Fly App/android/gradle.properties | 14 + .../gradle/wrapper/gradle-wrapper.properties | 5 + Fly App/android/settings.gradle | 25 + .../fonts/SpaceGrotesk-VariableFont_wght.ttf | Bin 0 -> 136676 bytes Fly App/assets/fonts/SpaceMono-Bold.ttf | Bin 0 -> 98232 bytes Fly App/assets/fonts/SpaceMono-Regular.ttf | Bin 0 -> 99356 bytes Fly App/assets/icon/app_icon.png | Bin 0 -> 114974 bytes Fly App/backup_pocketbase_app/login_page.dart | 146 + .../main_pocketbase.dart | 424 +++ Fly App/backup_pocketbase_app/pb_service.dart | 230 ++ .../pubspec_pocketbase.yaml | 94 + Fly App/lib/biometric_auth.dart | 58 + Fly App/lib/dji_service.dart | 39 + Fly App/lib/flight_model.dart | 32 + Fly App/lib/login_page.dart | 260 ++ Fly App/lib/main.dart | 430 +++ Fly App/lib/pb_auth.dart | 147 + Fly App/lib/theme.dart | 375 +++ Fly App/lib/ui/album_page.dart | 145 + Fly App/lib/ui/dji_video_view.dart | 29 + Fly App/lib/ui/flight_control_page.dart | 474 ++++ Fly App/lib/ui/go_fly_page.dart | 292 ++ Fly App/lib/uploader.dart | 155 ++ Fly App/pubspec.lock | 490 ++++ Fly App/pubspec.yaml | 120 + Fly App/test/widget_test.dart | 13 + Web App/.dockerignore | 15 + Web App/.gitignore | 9 + Web App/Dockerfile | 35 + Web App/README.md | 51 + Web App/docker-compose.yml | 16 + Web App/server/.env.example | 10 + Web App/server/Run-WebApp.ps1 | 29 + Web App/server/bff.go | 453 +++ Web App/server/dist/assets/index-BldP9Pra.js | 20 + Web App/server/dist/assets/index-DBe0h801.css | 1 + Web App/server/dist/favicon.svg | 7 + Web App/server/dist/index.html | 44 + Web App/server/go.mod | 5 + Web App/server/go.sum | 2 + Web App/server/main.go | 140 + Web App/web/.gitignore | 3 + Web App/web/index.html | 43 + Web App/web/package-lock.json | 2418 +++++++++++++++++ Web App/web/package.json | 20 + Web App/web/postcss.config.js | 6 + Web App/web/public/favicon.svg | 7 + Web App/web/src/App.vue | 53 + Web App/web/src/api.js | 263 ++ Web App/web/src/components/BrandMark.vue | 14 + Web App/web/src/components/Dashboard.vue | 647 +++++ Web App/web/src/components/DeviceMap.vue | 49 + Web App/web/src/components/Icon.vue | 69 + Web App/web/src/components/LoginView.vue | 89 + Web App/web/src/components/Settings.vue | 2373 ++++++++++++++++ Web App/web/src/components/ThemeToggle.vue | 25 + Web App/web/src/components/settings/Row.vue | 35 + .../web/src/components/settings/Segmented.vue | 28 + .../web/src/components/settings/Toggle.vue | 24 + Web App/web/src/main.js | 6 + Web App/web/src/prefs.js | 163 ++ Web App/web/src/style.css | 102 + Web App/web/src/theme.js | 73 + Web App/web/tailwind.config.js | 76 + Web App/web/vite.config.js | 20 + 172 files changed, 24591 insertions(+) create mode 100644 .claude/launch.json create mode 100644 .gitignore create mode 100644 API Server/.dockerignore create mode 100644 API Server/.env.example create mode 100644 API Server/.gitignore create mode 100644 API Server/Dockerfile create mode 100644 API Server/README.md create mode 100644 API Server/cmd/server/main.go create mode 100644 API Server/docker-compose.yml create mode 100644 API Server/go.mod create mode 100644 API Server/go.sum create mode 100644 API Server/internal/api/admin.go create mode 100644 API Server/internal/api/auth.go create mode 100644 API Server/internal/api/devices.go create mode 100644 API Server/internal/api/dist/assets/index-BwP7TTth.css create mode 100644 API Server/internal/api/dist/assets/index-DKDpmK_V.js create mode 100644 API Server/internal/api/dist/favicon.svg create mode 100644 API Server/internal/api/dist/index.html create mode 100644 API Server/internal/api/health.go create mode 100644 API Server/internal/api/integrations.go create mode 100644 API Server/internal/api/integrations_filetransfer.go create mode 100644 API Server/internal/api/integrations_localstorage.go create mode 100644 API Server/internal/api/integrations_localstorage_test.go create mode 100644 API Server/internal/api/integrations_webdav.go create mode 100644 API Server/internal/api/orgs.go create mode 100644 API Server/internal/api/panel.go create mode 100644 API Server/internal/api/plugins.go create mode 100644 API Server/internal/api/preferences.go create mode 100644 API Server/internal/api/respond.go create mode 100644 API Server/internal/api/server.go create mode 100644 API Server/internal/api/settings.go create mode 100644 API Server/internal/api/status.go create mode 100644 API Server/internal/api/telemetry.go create mode 100644 API Server/internal/api/users.go create mode 100644 API Server/internal/api/ws.go create mode 100644 API Server/internal/config/config.go create mode 100644 API Server/internal/hub/hub.go create mode 100644 API Server/internal/hub/models.go create mode 100644 API Server/internal/plugins/README.md create mode 100644 API Server/internal/plugins/builtin/builtin.go create mode 100644 API Server/internal/plugins/builtin/filetransfer/filetransfer.go create mode 100644 API Server/internal/plugins/builtin/filetransfer/filetransfer_test.go create mode 100644 API Server/internal/plugins/builtin/localstorage/localstorage.go create mode 100644 API Server/internal/plugins/builtin/localstorage/localstorage_test.go create mode 100644 API Server/internal/plugins/builtin/opensky/opensky.go create mode 100644 API Server/internal/plugins/builtin/webdav/webdav.go create mode 100644 API Server/internal/plugins/builtin/webdav/webdav_test.go create mode 100644 API Server/internal/plugins/doc.go create mode 100644 API Server/internal/plugins/external.go create mode 100644 API Server/internal/plugins/manager.go create mode 100644 API Server/internal/plugins/plugin.go create mode 100644 API Server/panel/index.html create mode 100644 API Server/panel/package-lock.json create mode 100644 API Server/panel/package.json create mode 100644 API Server/panel/public/favicon.svg create mode 100644 API Server/panel/src/App.vue create mode 100644 API Server/panel/src/components/EndpointTable.vue create mode 100644 API Server/panel/src/main.js create mode 100644 API Server/panel/src/style.css create mode 100644 API Server/panel/src/theme.js create mode 100644 API Server/panel/vite.config.js create mode 100644 API Server/pocketbase/README.md create mode 100644 API Server/pocketbase/pb_migrations/1720300000_add_users_preferences.js create mode 100644 API Server/pocketbase/pb_migrations/1720300100_add_users_role.js create mode 100644 API Server/pocketbase/pb_migrations/1720300200_add_organizations.js create mode 100644 API Server/pocketbase/pb_migrations/1720300300_add_users_organization.js create mode 100644 API Server/pocketbase/pb_migrations/1720300400_extend_users_role_superadmin.js create mode 100644 API Server/pocketbase/pb_migrations/1720300500_seed_orgs_and_users.js create mode 100644 API Server/pocketbase/pb_migrations/1720300600_add_plugin_settings.js create mode 100644 API Server/scripts/Run-ApiServer.ps1 create mode 100644 Docker AIO/Dockerfile create mode 100644 Docker AIO/docker-compose.yml create mode 100644 Docker/docker-compose.yml create mode 100644 Fly App/.claude/launch.json create mode 100644 Fly App/.claude/settings.local.json create mode 100644 Fly App/.gitignore create mode 100644 Fly App/.metadata create mode 100644 Fly App/README.md create mode 100644 Fly App/analysis_options.yaml create mode 100644 Fly App/android/.gitignore create mode 100644 Fly App/android/app/build.gradle create mode 100644 Fly App/android/app/proguard-rules.pro create mode 100644 Fly App/android/app/src/debug/AndroidManifest.xml create mode 100644 Fly App/android/app/src/main/AndroidManifest.xml create mode 100644 Fly App/android/app/src/main/kotlin/com/dji/flutter/dji_msdk_sample/DjiApplication.kt create mode 100644 Fly App/android/app/src/main/kotlin/com/dji/flutter/dji_msdk_sample/DjiSdkBridge.kt create mode 100644 Fly App/android/app/src/main/kotlin/com/dji/flutter/dji_msdk_sample/DjiVideoView.kt create mode 100644 Fly App/android/app/src/main/kotlin/com/dji/flutter/dji_msdk_sample/MainActivity.kt create mode 100644 Fly App/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png create mode 100644 Fly App/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png create mode 100644 Fly App/android/app/src/main/res/drawable-v21/launch_background.xml create mode 100644 Fly App/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png create mode 100644 Fly App/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png create mode 100644 Fly App/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png create mode 100644 Fly App/android/app/src/main/res/drawable/launch_background.xml create mode 100644 Fly App/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 Fly App/android/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 Fly App/android/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 Fly App/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 Fly App/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 Fly App/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 Fly App/android/app/src/main/res/values-night/styles.xml create mode 100644 Fly App/android/app/src/main/res/values/colors.xml create mode 100644 Fly App/android/app/src/main/res/values/styles.xml create mode 100644 Fly App/android/app/src/main/res/xml/accessory_filter.xml create mode 100644 Fly App/android/app/src/profile/AndroidManifest.xml create mode 100644 Fly App/android/build.gradle create mode 100644 Fly App/android/gradle.properties create mode 100644 Fly App/android/gradle/wrapper/gradle-wrapper.properties create mode 100644 Fly App/android/settings.gradle create mode 100644 Fly App/assets/fonts/SpaceGrotesk-VariableFont_wght.ttf create mode 100644 Fly App/assets/fonts/SpaceMono-Bold.ttf create mode 100644 Fly App/assets/fonts/SpaceMono-Regular.ttf create mode 100644 Fly App/assets/icon/app_icon.png create mode 100644 Fly App/backup_pocketbase_app/login_page.dart create mode 100644 Fly App/backup_pocketbase_app/main_pocketbase.dart create mode 100644 Fly App/backup_pocketbase_app/pb_service.dart create mode 100644 Fly App/backup_pocketbase_app/pubspec_pocketbase.yaml create mode 100644 Fly App/lib/biometric_auth.dart create mode 100644 Fly App/lib/dji_service.dart create mode 100644 Fly App/lib/flight_model.dart create mode 100644 Fly App/lib/login_page.dart create mode 100644 Fly App/lib/main.dart create mode 100644 Fly App/lib/pb_auth.dart create mode 100644 Fly App/lib/theme.dart create mode 100644 Fly App/lib/ui/album_page.dart create mode 100644 Fly App/lib/ui/dji_video_view.dart create mode 100644 Fly App/lib/ui/flight_control_page.dart create mode 100644 Fly App/lib/ui/go_fly_page.dart create mode 100644 Fly App/lib/uploader.dart create mode 100644 Fly App/pubspec.lock create mode 100644 Fly App/pubspec.yaml create mode 100644 Fly App/test/widget_test.dart create mode 100644 Web App/.dockerignore create mode 100644 Web App/.gitignore create mode 100644 Web App/Dockerfile create mode 100644 Web App/README.md create mode 100644 Web App/docker-compose.yml create mode 100644 Web App/server/.env.example create mode 100644 Web App/server/Run-WebApp.ps1 create mode 100644 Web App/server/bff.go create mode 100644 Web App/server/dist/assets/index-BldP9Pra.js create mode 100644 Web App/server/dist/assets/index-DBe0h801.css create mode 100644 Web App/server/dist/favicon.svg create mode 100644 Web App/server/dist/index.html create mode 100644 Web App/server/go.mod create mode 100644 Web App/server/go.sum create mode 100644 Web App/server/main.go create mode 100644 Web App/web/.gitignore create mode 100644 Web App/web/index.html create mode 100644 Web App/web/package-lock.json create mode 100644 Web App/web/package.json create mode 100644 Web App/web/postcss.config.js create mode 100644 Web App/web/public/favicon.svg create mode 100644 Web App/web/src/App.vue create mode 100644 Web App/web/src/api.js create mode 100644 Web App/web/src/components/BrandMark.vue create mode 100644 Web App/web/src/components/Dashboard.vue create mode 100644 Web App/web/src/components/DeviceMap.vue create mode 100644 Web App/web/src/components/Icon.vue create mode 100644 Web App/web/src/components/LoginView.vue create mode 100644 Web App/web/src/components/Settings.vue create mode 100644 Web App/web/src/components/ThemeToggle.vue create mode 100644 Web App/web/src/components/settings/Row.vue create mode 100644 Web App/web/src/components/settings/Segmented.vue create mode 100644 Web App/web/src/components/settings/Toggle.vue create mode 100644 Web App/web/src/main.js create mode 100644 Web App/web/src/prefs.js create mode 100644 Web App/web/src/style.css create mode 100644 Web App/web/src/theme.js create mode 100644 Web App/web/tailwind.config.js create mode 100644 Web App/web/vite.config.js diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..6bb431e --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,33 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "api-panel", + "runtimeExecutable": "E:\\VS Code Projects\\PilotVault\\API Server\\api-server.exe", + "runtimeArgs": [], + "cwd": "E:\\VS Code Projects\\PilotVault\\API Server", + "port": 8080 + }, + { + "name": "web-app", + "runtimeExecutable": "E:\\VS Code Projects\\PilotVault\\Web App\\dji-web-app.exe", + "runtimeArgs": [], + "cwd": "E:\\VS Code Projects\\PilotVault\\Web App", + "port": 8090 + }, + { + "name": "web-app-ui-dev", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev"], + "cwd": "E:\\VS Code Projects\\PilotVault\\Web App\\ui", + "port": 5173 + }, + { + "name": "api-panel-ui-dev", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev"], + "cwd": "E:\\VS Code Projects\\PilotVault\\API Server\\panel", + "port": 5174 + } + ] +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8a3b9af --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +# Design assets (excluded from version control) +Design/ + +# Build artifacts +*.exe +*.exe~ diff --git a/API Server/.dockerignore b/API Server/.dockerignore new file mode 100644 index 0000000..8b78c15 --- /dev/null +++ b/API Server/.dockerignore @@ -0,0 +1,16 @@ +# Build artifacts and secrets — never send to the build context. +.env +plugins.json +*.exe +/server +/tmp/ +*.log + +# Rebuilt inside the image. +panel/node_modules/ +internal/api/dist/ + +# Repo noise. +.git/ +.gitignore +README.md diff --git a/API Server/.env.example b/API Server/.env.example new file mode 100644 index 0000000..392c35f --- /dev/null +++ b/API Server/.env.example @@ -0,0 +1,20 @@ +# API Server configuration +# Copy to .env and adjust. The server also reads plain environment variables. + +# Address the API Server listens on +API_ADDR=:8080 + +# PocketBase base URL (no trailing slash). The API Server is the only thing that +# talks to PocketBase; it proxies /api/auth/* to this address, which is never +# exposed to clients. (Legacy PB_URL is still honoured for backward compat.) +POCKETBASE_URL=http://10.2.1.10:8026 + +# CORS allowed origins for the Web App (comma separated, or * for any) +CORS_ALLOW_ORIGINS=* + +# Superuser service account — used ONLY for admin user-management +# (list/create/delete users under Settings → User management). Every such call +# still verifies the *caller* has role=admin first. Leave unset to disable those +# endpoints (they return 503); the rest of the server is unaffected. +POCKETBASE_ADMIN_EMAIL=admin@dji.local +POCKETBASE_ADMIN_PASSWORD=change-me diff --git a/API Server/.gitignore b/API Server/.gitignore new file mode 100644 index 0000000..6244015 --- /dev/null +++ b/API Server/.gitignore @@ -0,0 +1,8 @@ +.env +plugins.json +/server +/server.exe +/api-server.exe +/tmp/ +*.log +panel/node_modules/ diff --git a/API Server/Dockerfile b/API Server/Dockerfile new file mode 100644 index 0000000..34491bd --- /dev/null +++ b/API Server/Dockerfile @@ -0,0 +1,35 @@ +# syntax=docker/dockerfile:1 + +# ---- Stage 1: build the embedded Vue panel ---- +# vite.config.js writes the build to ../internal/api/dist, i.e. /internal/api/dist +# here, which the Go binary embeds via //go:embed all:dist. +FROM node:22-alpine AS panel +WORKDIR /panel +COPY panel/package.json panel/package-lock.json ./ +RUN npm ci +COPY panel/ ./ +RUN npm run build + +# ---- Stage 2: build the static Go binary (panel embedded) ---- +FROM golang:1.26-alpine AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +# Overlay the freshly built panel so //go:embed all:dist picks it up. +COPY --from=panel /internal/api/dist ./internal/api/dist +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" \ + -o /out/api-server ./cmd/server + +# ---- Stage 3: minimal runtime ---- +FROM alpine:latest +RUN apk add --no-cache ca-certificates tzdata \ + && adduser -D -u 10001 app +WORKDIR /app +COPY --from=build /out/api-server ./api-server +USER app +# Default listen address (override with API_ADDR). PocketBase URL, CORS origins, +# and the optional POCKETBASE_ADMIN_* service account come from env at runtime. +ENV API_ADDR=:8080 +EXPOSE 8080 +ENTRYPOINT ["/app/api-server"] diff --git a/API Server/README.md b/API Server/README.md new file mode 100644 index 0000000..52d40aa --- /dev/null +++ b/API Server/README.md @@ -0,0 +1,145 @@ +# PilotVault — API Server + +Go service that is the **single entry point** for PilotVault. The Fly App streams +drone telemetry to it; the Web App and the built-in API Web Panel read live state +and issue commands. It keeps device state in memory and proxies authentication to +a PocketBase kept behind it (PocketBase's address is never exposed to clients). + +``` +Fly App ──► /ws/device ┐ + ├─► API Server (:8080) ──► PocketBase (auth only) +Web App ──► /ws/ui ┘ +``` + +The server root (`GET /`) serves a PilotVault-branded **web panel**: a live health +readout plus a quick reference of both API audiences. Open +http://localhost:8080/ in a browser to check the server at a glance. + +The panel is a **Vue 3 + Tailwind v4** app in [`panel/`](panel/), built into +`internal/api/dist` and embedded into the Go binary at compile time: + +```powershell +cd panel +npm install +npm run build # outputs to ../internal/api/dist +cd ..; go build -o api-server.exe ./cmd/server # embeds the fresh dist +``` + +For panel development with hot reload (proxies `/api` to a running server on +`:8080`): `cd panel; npm run dev` → http://localhost:5174. + +## Requirements + +- Go 1.24+ (`go version`) +- Node 18+ (only for building the panel) +- A reachable PocketBase instance with a `users` auth collection (for login). To + persist user settings, that collection needs a `preferences` JSON field — see + [`pocketbase/README.md`](pocketbase/README.md). + +## Configure + +```powershell +Copy-Item .env.example .env +# edit .env: set POCKETBASE_URL (and CORS_ALLOW_ORIGINS if needed) +``` + +| Variable | Purpose | Default | +|---|---|---| +| `API_ADDR` | Listen address | `:8080` | +| `POCKETBASE_URL` | PocketBase base URL (login proxy). Legacy `PB_URL` still honoured. | `http://10.2.1.10:8026` | +| `CORS_ALLOW_ORIGINS` | Comma list, or `*` | `*` | + +## Run + +```powershell +./scripts/Run-ApiServer.ps1 +# or: go run ./cmd/server +``` + +Health check: `GET http://localhost:8080/healthz`. + +## API + +### Client / dashboard endpoints + +| Method | Path | Description | +|---|---|---| +| `GET` | `/healthz`, `/api/health` | Readiness probe + device count (public) | +| `POST` | `/api/auth/login` | `{email, password}` → PocketBase session (proxied) | +| `GET` | `/api/auth/validate` | Validate the `Authorization` token | +| `GET` | `/api/me` | Caller's `{id, email, role, organization, organizationName}` resolved from their token | +| `GET` | `/api/preferences` | Read the caller's saved settings blob (from their PocketBase user record) | +| `PUT` | `/api/preferences` | `{preferences}` → persist the caller's settings onto their user record | +| `GET` | `/api/users` | List users (**manager**; admin → own org, superadmin → all) | +| `POST` | `/api/users` | `{email, password, role, organization?}` → create a user (**manager**; admin scoped to own org) | +| `PATCH` | `/api/users/{id}` | Edit `{email?, role?, verified?, password?, organization?}` (**manager**; scope-checked; cannot demote self) | +| `DELETE` | `/api/users/{id}` | Delete a user (**manager**; admin → own org only; cannot delete self) | +| `GET` | `/api/orgs` | List organizations (**manager**; admin → own org, superadmin → all) | +| `POST` | `/api/orgs` | `{name}` → create an organization (**superadmin only**) | +| `PATCH` | `/api/orgs/{id}` | `{name}` → rename an organization (**superadmin only**) | +| `DELETE` | `/api/orgs/{id}` | Delete an empty organization (**superadmin only**) | +| `GET` | `/api/admin/pb-config` | Read the PocketBase connection + a live probe (**superadmin only**) | +| `POST` | `/api/admin/pb-config/test` | `{url?, adminEmail?, adminPassword?}` → probe a candidate connection without applying (**superadmin only**) | +| `PUT` | `/api/admin/pb-config` | `{url, adminEmail?, adminPassword?}` → apply at runtime + persist to `.env` (**superadmin only**) | +| `GET` | `/api/admin/plugins` | List plugins with state + last health (**superadmin only**) | +| `POST` | `/api/admin/plugins` | `{name, baseURL, provider?}` → register an external plugin, no rebuild (**superadmin only**) | +| `GET` | `/api/admin/plugins/{name}` | One plugin's view (**superadmin only**) | +| `PUT` | `/api/admin/plugins/{name}` | `{enabled?, config?}` → enable/disable + configure (**superadmin only**) | +| `DELETE` | `/api/admin/plugins/{name}` | Remove an external plugin (**superadmin only**) | +| `POST` | `/api/admin/plugins/{name}/health` | Run a health check now (**superadmin only**) | +| `GET` | `/api/devices` | List devices and their last-known state | +| `GET` | `/api/devices/{id}/track` | GPS track history for a device | +| `POST` | `/api/devices/{id}/command` | Send `{command, payload?}` to a connected device | +| `DELETE` | `/api/devices/{id}` | Forget a device's stored state | +| `GET` | `/ws/ui` | Live telemetry stream (WebSocket) | + +### Device endpoints (Fly App) + +| Method | Path | Description | +|---|---|---| +| `GET` | `/ws/device?id={id}` | Telemetry uplink (WebSocket) | +| `POST` | `/api/telemetry?id={id}` | Push a single telemetry event over HTTP | + +### Telemetry events + +Device messages carry a `type`: `registration`, `connection`, `battery`, or +`telemetry` (altitude, lat/lng, velocity, GPS sats, flight mode…). The server +merges them into a per-device `DeviceState` and fans each update out to every +connected dashboard as `{type:"update", device, event}`. `latitude`/`longitude` +samples are appended to the device's GPS track. + +## Plugins + +The server integrates external third-party services through a uniform **plugin** +contract (`internal/plugins`), managed by a superadmin from the panel. Two kinds +share one interface: + +- **Built-in** — Go connectors compiled into the server (type-safe, first-party). + The reference example is **OpenSky Network** (`internal/plugins/builtin/opensky`), + a live ADS-B flight-state connector with an OAuth2 / anonymous auth provider. + Adding a *new* built-in needs a rebuild. +- **External** — a remote HTTP service **registered at runtime, no rebuild**. It + answers a small contract (`GET /health`, `GET /manifest`, `POST /invoke`) and can + run as its own process/container (the sandboxing story). + +Enable-state and per-plugin config (secrets included) persist to a local, +gitignored `plugins.json` (override with `PLUGINS_FILE`), loaded on boot. Every +plugin exposes a real `HealthCheck`. Deferred extension points (invocation API, +retry/circuit-breaker, per-tenant credentials, audit logging) are documented in +`internal/plugins/doc.go`. + +**Writing a plugin:** see the developer guide +[`internal/plugins/README.md`](internal/plugins/README.md) — step-by-step for both +built-in (Go) and external (HTTP, no rebuild) plugins, with complete examples. + +## Project layout + +``` +cmd/server/main.go entry point, wiring, graceful shutdown +internal/config env/.env configuration +internal/hub in-memory device state + websocket fan-out (drone core) +internal/api router, middleware, handlers, embedded panel +internal/plugins plugin contract, manager, external kind + built-in connectors +panel/ Vue 3 + Tailwind v4 web panel (built into internal/api/dist) +scripts/ run helper +``` diff --git a/API Server/cmd/server/main.go b/API Server/cmd/server/main.go new file mode 100644 index 0000000..6b8e0a1 --- /dev/null +++ b/API Server/cmd/server/main.go @@ -0,0 +1,63 @@ +// Command server runs the PilotVault API Server. It is the single entry point +// the Fly App (drone/telemetry uplink) and the Web App / API Web Panel talk to. +// It keeps live device state in memory, fans telemetry out to dashboards over a +// websocket, and proxies authentication to a PocketBase kept behind it. +package main + +import ( + "context" + "errors" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "pilotvault/apiserver/internal/api" + "pilotvault/apiserver/internal/config" + "pilotvault/apiserver/internal/hub" +) + +func main() { + log.SetFlags(log.LstdFlags | log.Lmsgprefix) + log.SetPrefix("[api] ") + + cfg := config.Load() + + h := hub.New() + srv := api.New(cfg, h) + + if err := srv.StartPlugins(); err != nil { + log.Printf("plugins: load failed: %v", err) + } + + httpServer := &http.Server{ + Addr: cfg.Addr, + Handler: srv.Handler(), + ReadHeaderTimeout: 10 * time.Second, + // No WriteTimeout: /ws/* are long-lived streaming connections. + IdleTimeout: 60 * time.Second, + } + + go func() { + log.Printf("listening on %s (PocketBase: %s, panel at /)", cfg.Addr, cfg.PocketBaseURL) + if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("server error: %v", err) + } + }() + + // Graceful shutdown. + stop := make(chan os.Signal, 1) + signal.Notify(stop, os.Interrupt, syscall.SIGTERM) + <-stop + log.Println("shutting down...") + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + srv.Stop(ctx) + if err := httpServer.Shutdown(ctx); err != nil { + log.Printf("shutdown error: %v", err) + } + log.Println("stopped") +} diff --git a/API Server/docker-compose.yml b/API Server/docker-compose.yml new file mode 100644 index 0000000..f0e24fc --- /dev/null +++ b/API Server/docker-compose.yml @@ -0,0 +1,12 @@ +services: + api-server: + build: . + image: pilotvault-api-server + container_name: pilotvault-api-server + # Config comes from .env (POCKETBASE_URL, CORS_ALLOW_ORIGINS, and the + # optional POCKETBASE_ADMIN_* service account). API_ADDR defaults to :8080. + env_file: + - .env + ports: + - "8080:8080" + restart: unless-stopped diff --git a/API Server/go.mod b/API Server/go.mod new file mode 100644 index 0000000..1a96937 --- /dev/null +++ b/API Server/go.mod @@ -0,0 +1,15 @@ +module pilotvault/apiserver + +go 1.26 + +require ( + github.com/gorilla/websocket v1.5.3 + github.com/jlaffaye/ftp v0.2.1 + github.com/pkg/sftp v1.13.10 + golang.org/x/crypto v0.41.0 +) + +require ( + github.com/kr/fs v0.1.0 // indirect + golang.org/x/sys v0.35.0 // indirect +) diff --git a/API Server/go.sum b/API Server/go.sum new file mode 100644 index 0000000..1c48d25 --- /dev/null +++ b/API Server/go.sum @@ -0,0 +1,22 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/jlaffaye/ftp v0.2.1 h1:AICcTYPMkaXlmjLMm9I+lB36f6jXCsCvBqVQc6EfC1Y= +github.com/jlaffaye/ftp v0.2.1/go.mod h1:gXSIr1pA9NhynDNigiFHs4+yL7o7I6bGF9Za9wi9tcE= +github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= +github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU= +github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= +golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/API Server/internal/api/admin.go b/API Server/internal/api/admin.go new file mode 100644 index 0000000..7e38038 --- /dev/null +++ b/API Server/internal/api/admin.go @@ -0,0 +1,155 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "sync" + "time" +) + +// adminClient authenticates to PocketBase as a superuser service account and is +// used only for admin user-management (list/create/delete users). It caches the +// superuser token and transparently re-authenticates when PocketBase rejects it. +// +// This is the one place the server holds elevated PocketBase credentials; every +// admin endpoint that uses it first verifies the *caller* is an app admin. +type adminClient struct { + baseURL string + email string + password string + client *http.Client + + mu sync.Mutex + token string +} + +func newAdminClient(baseURL, email, password string) *adminClient { + return &adminClient{ + baseURL: baseURL, + email: email, + password: password, + client: &http.Client{Timeout: 15 * time.Second}, + } +} + +func (a *adminClient) configured() bool { + if a == nil { + return false + } + _, email, password := a.creds() + return email != "" && password != "" +} + +// creds snapshots the current base URL + service-account credentials under lock, +// so a concurrent reconfigure() can't tear them mid-request. +func (a *adminClient) creds() (baseURL, email, password string) { + a.mu.Lock() + defer a.mu.Unlock() + return a.baseURL, a.email, a.password +} + +// reconfigure retargets the service account at a new PocketBase and/or new +// credentials, invalidating any cached superuser token. +func (a *adminClient) reconfigure(baseURL, email, password string) { + a.mu.Lock() + a.baseURL = baseURL + a.email = email + a.password = password + a.token = "" // force re-auth against the new target + a.mu.Unlock() +} + +func (a *adminClient) authenticate(ctx context.Context) (string, error) { + baseURL, email, password := a.creds() + tok, _, err := superuserAuth(ctx, a.client, baseURL, email, password) + if err != nil { + return "", err + } + a.mu.Lock() + a.token = tok + a.mu.Unlock() + return tok, nil +} + +// superuserAuth performs a PocketBase superuser auth-with-password and returns +// the token and HTTP status. Shared by the live client and the settings +// connection-test so both classify failures identically. +func superuserAuth(ctx context.Context, client *http.Client, baseURL, email, password string) (string, int, error) { + body, _ := json.Marshal(map[string]string{"identity": email, "password": password}) + req, _ := http.NewRequestWithContext(ctx, http.MethodPost, + baseURL+"/api/collections/_superusers/auth-with-password", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + return "", 0, err + } + defer resp.Body.Close() + data, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return "", resp.StatusCode, errors.New("superuser auth failed: " + string(data)) + } + var out struct { + Token string `json:"token"` + } + if err := json.Unmarshal(data, &out); err != nil || out.Token == "" { + return "", resp.StatusCode, errors.New("superuser auth: no token") + } + return out.Token, resp.StatusCode, nil +} + +func (a *adminClient) cachedToken() string { + a.mu.Lock() + defer a.mu.Unlock() + return a.token +} + +// do performs an admin request, (re)authenticating as needed. It returns the +// upstream response body and status. On a 401 it re-authenticates once and +// retries, so an expired cached token is self-healing. +func (a *adminClient) do(ctx context.Context, method, path string, payload any) ([]byte, int, error) { + token := a.cachedToken() + if token == "" { + var err error + if token, err = a.authenticate(ctx); err != nil { + return nil, 0, err + } + } + + baseURL, _, _ := a.creds() + send := func(tok string) ([]byte, int, error) { + var body io.Reader + if payload != nil { + b, _ := json.Marshal(payload) + body = bytes.NewReader(b) + } + req, _ := http.NewRequestWithContext(ctx, method, baseURL+path, body) + req.Header.Set("Authorization", tok) + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := a.client.Do(req) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + data, _ := io.ReadAll(resp.Body) + return data, resp.StatusCode, nil + } + + data, status, err := send(token) + if err != nil { + return nil, 0, err + } + if status == http.StatusUnauthorized { + if token, err = a.authenticate(ctx); err != nil { + return nil, 0, err + } + return send(token) + } + return data, status, nil +} diff --git a/API Server/internal/api/auth.go b/API Server/internal/api/auth.go new file mode 100644 index 0000000..26f8edc --- /dev/null +++ b/API Server/internal/api/auth.go @@ -0,0 +1,102 @@ +package api + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "sync" + "time" +) + +// authProxy forwards login / token-validation to the PocketBase kept behind the +// API Server. PocketBase's address lives only here — it is never exposed to or +// configurable by clients. The base URL is guarded by a mutex so it can be +// retargeted at runtime from the panel's PocketBase settings. +type authProxy struct { + mu sync.RWMutex + baseURL string + client *http.Client +} + +func newAuthProxy(baseURL string) *authProxy { + return &authProxy{ + baseURL: baseURL, + client: &http.Client{Timeout: 15 * time.Second}, + } +} + +// url returns the current PocketBase base URL. +func (a *authProxy) url() string { + a.mu.RLock() + defer a.mu.RUnlock() + return a.baseURL +} + +// setBaseURL retargets the proxy at a new PocketBase address. +func (a *authProxy) setBaseURL(u string) { + a.mu.Lock() + a.baseURL = u + a.mu.Unlock() +} + +// POST /api/auth/login +// Body: {"email"|"identity":"...","password":"..."} +// Proxies to PocketBase users auth-with-password and returns its response verbatim. +func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) { + var body struct { + Email string `json:"email"` + Identity string `json:"identity"` + Password string `json:"password"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid json") + return + } + identity := body.Identity + if identity == "" { + identity = body.Email + } + + payload, _ := json.Marshal(map[string]string{"identity": identity, "password": body.Password}) + req, _ := http.NewRequestWithContext(r.Context(), http.MethodPost, + s.auth.url()+"/api/collections/users/auth-with-password", bytes.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + + resp, err := s.auth.client.Do(req) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()}) + return + } + defer resp.Body.Close() + relay(w, resp) +} + +// GET /api/auth/validate (Authorization: ) +// Proxies to PocketBase auth-refresh to confirm a token is still valid. +func (s *Server) handleAuthValidate(w http.ResponseWriter, r *http.Request) { + token := r.Header.Get("Authorization") + if token == "" { + writeJSON(w, http.StatusUnauthorized, map[string]any{"valid": false}) + return + } + req, _ := http.NewRequestWithContext(r.Context(), http.MethodPost, + s.auth.url()+"/api/collections/users/auth-refresh", nil) + req.Header.Set("Authorization", token) + + resp, err := s.auth.client.Do(req) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]any{"valid": false, "detail": err.Error()}) + return + } + defer resp.Body.Close() + relay(w, resp) +} + +// relay copies an upstream PocketBase response (status + JSON body) to the client. +func relay(w http.ResponseWriter, resp *http.Response) { + data, _ := io.ReadAll(resp.Body) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(resp.StatusCode) + _, _ = w.Write(data) +} diff --git a/API Server/internal/api/devices.go b/API Server/internal/api/devices.go new file mode 100644 index 0000000..5f88170 --- /dev/null +++ b/API Server/internal/api/devices.go @@ -0,0 +1,42 @@ +package api + +import ( + "encoding/json" + "net/http" +) + +// GET /api/devices — list all known device states. +func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, s.hub.Snapshot()) +} + +// GET /api/devices/{id}/track — GPS track for the map trail. +func (s *Server) handleTrack(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, s.hub.Track(r.PathValue("id"))) +} + +// POST /api/devices/{id}/command — push a command down to a device. +// Body: {"command":"...","payload":{...}} +func (s *Server) handleCommand(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + var body struct { + Command string `json:"command"` + Payload map[string]any `json:"payload"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Command == "" { + writeError(w, http.StatusBadRequest, "command required") + return + } + if !s.hub.SendCommand(id, body.Command, body.Payload) { + writeJSON(w, http.StatusNotFound, map[string]any{"error": "device not connected", "deviceId": id}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"sent": true, "deviceId": id, "command": body.Command}) +} + +// DELETE /api/devices/{id} — forget a device's stored state (clears stale entries). +func (s *Server) handleForget(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + existed := s.hub.Forget(id) + writeJSON(w, http.StatusOK, map[string]any{"removed": existed, "deviceId": id}) +} diff --git a/API Server/internal/api/dist/assets/index-BwP7TTth.css b/API Server/internal/api/dist/assets/index-BwP7TTth.css new file mode 100644 index 0000000..fad321f --- /dev/null +++ b/API Server/internal/api/dist/assets/index-BwP7TTth.css @@ -0,0 +1 @@ +@import"https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Space+Mono:wght@400;700&display=swap";/*! tailwindcss v4.3.2 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000}}}@layer theme{:root,:host{--font-sans:"Space Grotesk", ui-sans-serif, system-ui, "Segoe UI", sans-serif;--font-mono:"Space Mono", ui-monospace, "SFMono-Regular", Menlo, monospace;--spacing:.25rem;--container-sm:24rem;--container-5xl:64rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height: 1.5 ;--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-wider:.05em;--leading-tight:1.25;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:"Space Grotesk", ui-sans-serif, system-ui, "Segoe UI", sans-serif;--default-mono-font-family:"Space Mono", ui-monospace, "SFMono-Regular", Menlo, monospace;--font-display:"Space Grotesk", ui-sans-serif, system-ui, "Segoe UI", sans-serif;--radius-sm:6px;--radius-md:10px}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){-webkit-appearance:button;-moz-appearance:button;appearance:button}::file-selector-button{-webkit-appearance:button;-moz-appearance:button;appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.visible{visibility:visible}.mx-auto{margin-inline:auto}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:var(--spacing)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.-mb-px{margin-bottom:-1px}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.pv-btn{border-radius:var(--radius-md);background:var(--brand);height:40px;color:var(--brand-contrast);font-family:var(--font-sans);cursor:pointer;transition:background-color var(--dur-fast) var(--ease-standard),transform var(--dur-fast) var(--ease-standard);border:1px solid #0000;justify-content:center;align-items:center;gap:8px;padding:0 16px;font-size:.875rem;font-weight:600;display:inline-flex}.pv-btn-sec{border-radius:var(--radius-md);border:1px solid var(--border-strong);background:var(--surface-card);height:40px;color:var(--text-primary);font-family:var(--font-sans);cursor:pointer;transition:background-color var(--dur-fast) var(--ease-standard),transform var(--dur-fast) var(--ease-standard);justify-content:center;align-items:center;gap:8px;padding:0 16px;font-size:.875rem;font-weight:600;display:inline-flex}.flex{display:flex}.hidden{display:none}.inline-flex{display:inline-flex}.pv-input{border-radius:var(--radius-md);border:1px solid var(--border-strong);background:var(--surface-card);width:100%;height:40px;color:var(--text-primary);font-family:var(--font-sans);padding:0 12px;font-size:.875rem}.pv-btn-sm{border-radius:var(--radius-sm);height:32px;padding:0 12px;font-size:.75rem}.h-1{height:var(--spacing)}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-4{height:calc(var(--spacing) * 4)}.w-1{width:var(--spacing)}.w-1\.5{width:calc(var(--spacing) * 1.5)}.w-4{width:calc(var(--spacing) * 4)}.w-full{width:100%}.max-w-5xl{max-width:var(--container-5xl)}.max-w-sm{max-width:var(--container-sm)}.min-w-0{min-width:0}.flex-1{flex:1}.shrink-0{flex-shrink:0}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-baseline{align-items:baseline}.items-center{align-items:center}.justify-between{justify-content:space-between}.gap-1{gap:var(--spacing)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-6{gap:calc(var(--spacing) * 6)}.gap-x-2{column-gap:calc(var(--spacing) * 2)}.gap-x-3{column-gap:calc(var(--spacing) * 3)}.gap-y-0\.5{row-gap:calc(var(--spacing) * .5)}.gap-y-2{row-gap:calc(var(--spacing) * 2)}.self-start{align-self:flex-start}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-hidden{overflow-y:hidden}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:14px}.rounded-md{border-radius:10px}.rounded-sm{border-radius:6px}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.\!border-transparent{border-color:#0000!important}.border-brand{border-color:var(--brand)}.border-subtle{border-color:var(--border-subtle)}.border-transparent{border-color:#0000}.\!bg-success-tint{background-color:var(--success-tint)!important}.bg-brand{background-color:var(--brand)}.bg-card{background-color:var(--surface-card)}.bg-current{background-color:currentColor}.bg-danger-tint{background-color:var(--danger-tint)}.bg-muted{background-color:var(--text-muted)}.bg-success{background-color:var(--success)}.bg-success-tint{background-color:var(--success-tint)}.bg-sunken{background-color:var(--bg-sunken)}.bg-warning-tint{background-color:var(--warning-tint)}.p-1{padding:var(--spacing)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-3\.5{padding-inline:calc(var(--spacing) * 3.5)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-3\.5{padding-block:calc(var(--spacing) * 3.5)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-5{padding-block:calc(var(--spacing) * 5)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-10{padding-block:calc(var(--spacing) * 10)}.pt-1{padding-top:var(--spacing)}.pt-12{padding-top:calc(var(--spacing) * 12)}.pb-16{padding-bottom:calc(var(--spacing) * 16)}.text-center{text-align:center}.text-left{text-align:left}.pv-eyebrow{font-family:var(--font-mono);letter-spacing:.16em;text-transform:uppercase;color:var(--text-muted);font-size:11px}.font-display{font-family:Space Grotesk,ui-sans-serif,system-ui,Segoe UI,sans-serif}.font-mono{font-family:Space Mono,ui-monospace,SFMono-Regular,Menlo,monospace}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.whitespace-nowrap{white-space:nowrap}.\!text-danger{color:var(--danger)!important}.\!text-success{color:var(--success)!important}.text-brand-text{color:var(--text-brand)}.text-danger{color:var(--danger)}.text-muted{color:var(--text-muted)}.text-on-brand{color:var(--brand-contrast)}.text-primary{color:var(--text-primary)}.text-secondary{color:var(--text-secondary)}.text-success{color:var(--success)}.text-warning{color:var(--warning)}.uppercase{text-transform:uppercase}.shadow-sm{--tw-shadow:var(--sh-sm);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-xs{--tw-shadow:var(--sh-xs);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.first\:border-t-0:first-child{border-top-style:var(--tw-border-style);border-top-width:0}@media(hover:hover){.hover\:bg-sunken:hover{background-color:var(--bg-sunken)}.hover\:text-primary:hover{color:var(--text-primary)}}@media(min-width:40rem){.sm\:inline{display:inline}.sm\:w-40{width:calc(var(--spacing) * 40)}.sm\:w-auto{width:auto}.sm\:flex-row{flex-direction:row}}}:root,[data-theme=light]{--navy-950:#0b1730;--navy-900:#0f1e3d;--navy-800:#1b2e52;--navy-700:#26406e;--blue-50:#eaf1fe;--blue-100:#d6e3fd;--blue-300:#8fb4f6;--blue-400:#5b93f5;--blue-500:#3d7bf0;--blue-600:#2b62cc;--blue-700:#1f4ca0;--slate-0:#fff;--slate-50:#f6f7f9;--slate-100:#eef0f3;--slate-150:#e6e9ee;--slate-200:#dce0e7;--slate-300:#c5ccd7;--slate-400:#97a1b0;--slate-500:#6b7688;--slate-700:#333b4a;--steel:#5a6b85;--green-500:#1f8a5b;--green-100:#dcf1e7;--green-600:#177049;--amber-500:#d9852b;--amber-100:#fbebd5;--amber-600:#b86c1b;--red-500:#d64545;--red-100:#fbe0e0;--red-600:#b83232;--bg-page:var(--slate-100);--bg-sunken:var(--slate-50);--surface-card:var(--slate-0);--border-subtle:var(--slate-200);--border-strong:var(--slate-300);--border-focus:var(--blue-500);--text-primary:var(--navy-900);--text-secondary:var(--steel);--text-muted:var(--slate-400);--brand:var(--blue-500);--brand-hover:var(--blue-600);--brand-active:var(--blue-700);--brand-contrast:#fff;--text-brand:var(--blue-600);--success:var(--green-600);--success-tint:var(--green-100);--warning:var(--amber-600);--warning-tint:var(--amber-100);--danger:var(--red-600);--danger-tint:var(--red-100);--ring-focus:0 0 0 3px var(--blue-500)}@supports (color:color-mix(in lab,red,red)){:root,[data-theme=light]{--ring-focus:0 0 0 3px color-mix(in srgb, var(--blue-500) 45%, transparent)}}:root,[data-theme=light]{--sh-xs:0 1px 2px #0f1e3d0f;--sh-sm:0 1px 2px #0f1e3d0f, 0 1px 3px #0f1e3d0a;--dur-fast:.12s;--ease-standard:cubic-bezier(.4, 0, .2, 1);color-scheme:light}[data-theme=dark]{--bg-page:var(--navy-950);--bg-sunken:#0b111c;--surface-card:#10203f;--border-subtle:#ffffff14;--border-strong:#ffffff2e;--border-focus:var(--blue-400);--text-primary:#f4f7fc;--text-secondary:#8fa0be;--text-muted:#5e6e8c;--brand:var(--blue-400);--brand-hover:var(--blue-300);--brand-active:var(--blue-100);--brand-contrast:#0f1e3d;--text-brand:var(--blue-300);--success:#5fd3a0;--success-tint:var(--green-500)}@supports (color:color-mix(in lab,red,red)){[data-theme=dark]{--success-tint:color-mix(in srgb, var(--green-500) 22%, transparent)}}[data-theme=dark]{--warning:#f0b26a;--warning-tint:var(--amber-500)}@supports (color:color-mix(in lab,red,red)){[data-theme=dark]{--warning-tint:color-mix(in srgb, var(--amber-500) 22%, transparent)}}[data-theme=dark]{--danger:#f08a8a;--danger-tint:var(--red-500)}@supports (color:color-mix(in lab,red,red)){[data-theme=dark]{--danger-tint:color-mix(in srgb, var(--red-500) 22%, transparent)}}[data-theme=dark]{--ring-focus:0 0 0 3px var(--blue-400)}@supports (color:color-mix(in lab,red,red)){[data-theme=dark]{--ring-focus:0 0 0 3px color-mix(in srgb, var(--blue-400) 55%, transparent)}}[data-theme=dark]{--sh-xs:0 1px 2px #00000059;--sh-sm:0 1px 3px #0006;color-scheme:dark}html,body,#app{height:100%}body{font-family:var(--font-sans);background:var(--bg-page);color:var(--text-primary);-webkit-font-smoothing:antialiased}h1,h2,h3{font-family:var(--font-display);letter-spacing:-.02em}.pv-btn-sec:hover:not(:disabled){background:var(--bg-sunken)}.pv-btn-sec:active:not(:disabled){transform:translateY(1px)}.pv-btn-sec:focus-visible{box-shadow:var(--ring-focus);outline:none}.pv-btn:hover:not(:disabled){background:var(--brand-hover)}.pv-btn:active:not(:disabled){transform:translateY(1px)}.pv-btn:disabled{opacity:.55;cursor:not-allowed}.pv-btn:focus-visible{box-shadow:var(--ring-focus);outline:none}.pv-input::placeholder{color:var(--text-muted)}.pv-input:focus{border-color:var(--border-focus);box-shadow:var(--ring-focus);outline:none}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000} diff --git a/API Server/internal/api/dist/assets/index-DKDpmK_V.js b/API Server/internal/api/dist/assets/index-DKDpmK_V.js new file mode 100644 index 0000000..fc733fe --- /dev/null +++ b/API Server/internal/api/dist/assets/index-DKDpmK_V.js @@ -0,0 +1,17 @@ +(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const i of document.querySelectorAll('link[rel="modulepreload"]'))n(i);new MutationObserver(i=>{for(const o of i)if(o.type==="childList")for(const r of o.addedNodes)r.tagName==="LINK"&&r.rel==="modulepreload"&&n(r)}).observe(document,{childList:!0,subtree:!0});function s(i){const o={};return i.integrity&&(o.integrity=i.integrity),i.referrerPolicy&&(o.referrerPolicy=i.referrerPolicy),i.crossOrigin==="use-credentials"?o.credentials="include":i.crossOrigin==="anonymous"?o.credentials="omit":o.credentials="same-origin",o}function n(i){if(i.ep)return;i.ep=!0;const o=s(i);fetch(i.href,o)}})();/** +* @vue/shared v3.5.39 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/function nn(e){const t=Object.create(null);for(const s of e.split(","))t[s]=1;return s=>s in t}const ee={},Ct=[],Ge=()=>{},oi=()=>!1,_s=e=>e.charCodeAt(0)===111&&e.charCodeAt(1)===110&&(e.charCodeAt(2)>122||e.charCodeAt(2)<97),ws=e=>e.startsWith("onUpdate:"),fe=Object.assign,on=(e,t)=>{const s=e.indexOf(t);s>-1&&e.splice(s,1)},bo=Object.prototype.hasOwnProperty,J=(e,t)=>bo.call(e,t),j=Array.isArray,Tt=e=>Qt(e)==="[object Map]",Mt=e=>Qt(e)==="[object Set]",En=e=>Qt(e)==="[object Date]",N=e=>typeof e=="function",ne=e=>typeof e=="string",qe=e=>typeof e=="symbol",Z=e=>e!==null&&typeof e=="object",ri=e=>(Z(e)||N(e))&&N(e.then)&&N(e.catch),li=Object.prototype.toString,Qt=e=>li.call(e),vo=e=>Qt(e).slice(8,-1),ai=e=>Qt(e)==="[object Object]",rn=e=>ne(e)&&e!=="NaN"&&e[0]!=="-"&&""+parseInt(e,10)===e,Nt=nn(",key,ref,ref_for,ref_key,onVnodeBeforeMount,onVnodeMounted,onVnodeBeforeUpdate,onVnodeUpdated,onVnodeBeforeUnmount,onVnodeUnmounted"),Ss=e=>{const t=Object.create(null);return(s=>t[s]||(t[s]=e(s)))},yo=/-\w/g,Re=Ss(e=>e.replace(yo,t=>t.slice(1).toUpperCase())),xo=/\B([A-Z])/g,xt=Ss(e=>e.replace(xo,"-$1").toLowerCase()),ci=Ss(e=>e.charAt(0).toUpperCase()+e.slice(1)),Rs=Ss(e=>e?`on${ci(e)}`:""),We=(e,t)=>!Object.is(e,t),cs=(e,...t)=>{for(let s=0;s{Object.defineProperty(e,t,{configurable:!0,enumerable:!1,writable:n,value:s})},Cs=e=>{const t=parseFloat(e);return isNaN(t)?e:t};let Pn;const Ts=()=>Pn||(Pn=typeof globalThis<"u"?globalThis:typeof self<"u"?self:typeof window<"u"?window:typeof global<"u"?global:{});function ln(e){if(j(e)){const t={};for(let s=0;s{if(s){const n=s.split(wo);n.length>1&&(t[n[0].trim()]=n[1].trim())}}),t}function Ee(e){let t="";if(ne(e))t=e;else if(j(e))for(let s=0;sct(s,t))}const di=e=>!!(e&&e.__v_isRef===!0),U=e=>ne(e)?e:e==null?"":j(e)||Z(e)&&(e.toString===li||!N(e.toString))?di(e)?U(e.value):JSON.stringify(e,pi,2):String(e),pi=(e,t)=>di(t)?pi(e,t.value):Tt(t)?{[`Map(${t.size})`]:[...t.entries()].reduce((s,[n,i],o)=>(s[Ds(n,o)+" =>"]=i,s),{})}:Mt(t)?{[`Set(${t.size})`]:[...t.values()].map(s=>Ds(s))}:qe(t)?Ds(t):Z(t)&&!j(t)&&!ai(t)?String(t):t,Ds=(e,t="")=>{var s;return qe(e)?`Symbol(${(s=e.description)!=null?s:t})`:e};/** +* @vue/reactivity v3.5.39 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/let le;class ko{constructor(t=!1){this.detached=t,this._active=!0,this._on=0,this.effects=[],this.cleanups=[],this._isPaused=!1,this._warnOnRun=!0,this.__v_skip=!0,!t&&le&&(le.active?(this.parent=le,this.index=(le.scopes||(le.scopes=[])).push(this)-1):(this._active=!1,this._warnOnRun=!1))}get active(){return this._active}pause(){if(this._active){this._isPaused=!0;let t,s;if(this.scopes)for(t=0,s=this.scopes.length;t0&&--this._on===0){if(le===this)le=this.prevScope;else{let t=le;for(;t;){if(t.prevScope===this){t.prevScope=this.prevScope;break}t=t.prevScope}}this.prevScope=void 0}}stop(t){if(this._active){this._active=!1;let s,n;for(s=0,n=this.effects.length;s0)return;if(Vt){let t=Vt;for(Vt=void 0;t;){const s=t.next;t.next=void 0,t.flags&=-9,t=s}}let e;for(;Ht;){let t=Ht;for(Ht=void 0;t;){const s=t.next;if(t.next=void 0,t.flags&=-9,t.flags&1)try{t.trigger()}catch(n){e||(e=n)}t=s}}if(e)throw e}function bi(e){for(let t=e.deps;t;t=t.nextDep)t.version=-1,t.prevActiveLink=t.dep.activeLink,t.dep.activeLink=t}function vi(e){let t,s=e.depsTail,n=s;for(;n;){const i=n.prevDep;n.version===-1?(n===s&&(s=i),fn(n),Oo(n)):t=n,n.dep.activeLink=n.prevActiveLink,n.prevActiveLink=void 0,n=i}e.deps=t,e.depsTail=s}function Gs(e){for(let t=e.deps;t;t=t.nextDep)if(t.dep.version!==t.version||t.dep.computed&&(yi(t.dep.computed)||t.dep.version!==t.version))return!0;return!!e._dirty}function yi(e){if(e.flags&4&&!(e.flags&16)||(e.flags&=-17,e.globalVersion===qt)||(e.globalVersion=qt,!e.isSSR&&e.flags&128&&(!e.deps&&!e._dirty||!Gs(e))))return;e.flags|=2;const t=e.dep,s=te,n=De;te=e,De=!0;try{bi(e);const i=e.fn(e._value);(t.version===0||We(i,e._value))&&(e.flags|=128,e._value=i,t.version++)}catch(i){throw t.version++,i}finally{te=s,De=n,vi(e),e.flags&=-3}}function fn(e,t=!1){const{dep:s,prevSub:n,nextSub:i}=e;if(n&&(n.nextSub=i,e.prevSub=void 0),i&&(i.prevSub=n,e.nextSub=void 0),s.subs===e&&(s.subs=n,!n&&s.computed)){s.computed.flags&=-5;for(let o=s.computed.deps;o;o=o.nextDep)fn(o,!0)}!t&&!--s.sc&&s.map&&s.map.delete(s.key)}function Oo(e){const{prevDep:t,nextDep:s}=e;t&&(t.nextDep=s,e.prevDep=void 0),s&&(s.prevDep=t,e.nextDep=void 0)}let De=!0;const xi=[];function Je(){xi.push(De),De=!1}function ze(){const e=xi.pop();De=e===void 0?!0:e}function kn(e){const{cleanup:t}=e;if(e.cleanup=void 0,t){const s=te;te=void 0;try{t()}finally{te=s}}}let qt=0;class Mo{constructor(t,s){this.sub=t,this.dep=s,this.version=s.version,this.nextDep=this.prevDep=this.nextSub=this.prevSub=this.prevActiveLink=void 0}}class dn{constructor(t){this.computed=t,this.version=0,this.activeLink=void 0,this.subs=void 0,this.map=void 0,this.key=void 0,this.sc=0,this.__v_skip=!0}track(t){if(!te||!De||te===this.computed)return;let s=this.activeLink;if(s===void 0||s.sub!==te)s=this.activeLink=new Mo(te,this),te.deps?(s.prevDep=te.depsTail,te.depsTail.nextDep=s,te.depsTail=s):te.deps=te.depsTail=s,_i(s);else if(s.version===-1&&(s.version=this.version,s.nextDep)){const n=s.nextDep;n.prevDep=s.prevDep,s.prevDep&&(s.prevDep.nextDep=n),s.prevDep=te.depsTail,s.nextDep=void 0,te.depsTail.nextDep=s,te.depsTail=s,te.deps===s&&(te.deps=n)}return s}trigger(t){this.version++,qt++,this.notify(t)}notify(t){cn();try{for(let s=this.subs;s;s=s.prevSub)s.sub.notify()&&s.sub.dep.notify()}finally{un()}}}function _i(e){if(e.dep.sc++,e.sub.flags&4){const t=e.dep.computed;if(t&&!e.dep.subs){t.flags|=20;for(let n=t.deps;n;n=n.nextDep)_i(n)}const s=e.dep.subs;s!==e&&(e.prevSub=s,s&&(s.nextSub=e)),e.dep.subs=e}}const qs=new WeakMap,vt=Symbol(""),Js=Symbol(""),Jt=Symbol("");function ce(e,t,s){if(De&&te){let n=qs.get(e);n||qs.set(e,n=new Map);let i=n.get(s);i||(n.set(s,i=new dn),i.map=n,i.key=s),i.track()}}function st(e,t,s,n,i,o){const r=qs.get(e);if(!r){qt++;return}const l=c=>{c&&c.trigger()};if(cn(),t==="clear")r.forEach(l);else{const c=j(e),p=c&&rn(s);if(c&&s==="length"){const f=Number(n);r.forEach((m,P)=>{(P==="length"||P===Jt||!qe(P)&&P>=f)&&l(m)})}else switch((s!==void 0||r.has(void 0))&&l(r.get(s)),p&&l(r.get(Jt)),t){case"add":c?p&&l(r.get("length")):(l(r.get(vt)),Tt(e)&&l(r.get(Js)));break;case"delete":c||(l(r.get(vt)),Tt(e)&&l(r.get(Js)));break;case"set":Tt(e)&&l(r.get(vt));break}}un()}function wt(e){const t=q(e);return t===e?t:(ce(t,"iterate",Jt),ke(e)?t:t.map(Fe))}function Es(e){return ce(e=q(e),"iterate",Jt),e}function Be(e,t){return ot(e)?kt(yt(e)?Fe(t):t):Fe(t)}const Io={__proto__:null,[Symbol.iterator](){return Ls(this,Symbol.iterator,e=>Be(this,e))},concat(...e){return wt(this).concat(...e.map(t=>j(t)?wt(t):t))},entries(){return Ls(this,"entries",e=>(e[1]=Be(this,e[1]),e))},every(e,t){return Ze(this,"every",e,t,void 0,arguments)},filter(e,t){return Ze(this,"filter",e,t,s=>s.map(n=>Be(this,n)),arguments)},find(e,t){return Ze(this,"find",e,t,s=>Be(this,s),arguments)},findIndex(e,t){return Ze(this,"findIndex",e,t,void 0,arguments)},findLast(e,t){return Ze(this,"findLast",e,t,s=>Be(this,s),arguments)},findLastIndex(e,t){return Ze(this,"findLastIndex",e,t,void 0,arguments)},forEach(e,t){return Ze(this,"forEach",e,t,void 0,arguments)},includes(...e){return js(this,"includes",e)},indexOf(...e){return js(this,"indexOf",e)},join(e){return wt(this).join(e)},lastIndexOf(...e){return js(this,"lastIndexOf",e)},map(e,t){return Ze(this,"map",e,t,void 0,arguments)},pop(){return Dt(this,"pop")},push(...e){return Dt(this,"push",e)},reduce(e,...t){return An(this,"reduce",e,t)},reduceRight(e,...t){return An(this,"reduceRight",e,t)},shift(){return Dt(this,"shift")},some(e,t){return Ze(this,"some",e,t,void 0,arguments)},splice(...e){return Dt(this,"splice",e)},toReversed(){return wt(this).toReversed()},toSorted(e){return wt(this).toSorted(e)},toSpliced(...e){return wt(this).toSpliced(...e)},unshift(...e){return Dt(this,"unshift",e)},values(){return Ls(this,"values",e=>Be(this,e))}};function Ls(e,t,s){const n=Es(e),i=n[t]();return n!==e&&!ke(e)&&(i._next=i.next,i.next=()=>{const o=i._next();return o.done||(o.value=s(o.value)),o}),i}const Ro=Array.prototype;function Ze(e,t,s,n,i,o){const r=Es(e),l=r!==e&&!ke(e),c=r[t];if(c!==Ro[t]){const m=c.apply(e,o);return l?Fe(m):m}let p=s;r!==e&&(l?p=function(m,P){return s.call(this,Be(e,m),P,e)}:s.length>2&&(p=function(m,P){return s.call(this,m,P,e)}));const f=c.call(r,p,n);return l&&i?i(f):f}function An(e,t,s,n){const i=Es(e),o=i!==e&&!ke(e);let r=s,l=!1;i!==e&&(o?(l=n.length===0,r=function(p,f,m){return l&&(l=!1,p=Be(e,p)),s.call(this,p,Be(e,f),m,e)}):s.length>3&&(r=function(p,f,m){return s.call(this,p,f,m,e)}));const c=i[t](r,...n);return l?Be(e,c):c}function js(e,t,s){const n=q(e);ce(n,"iterate",Jt);const i=n[t](...s);return(i===-1||i===!1)&&mn(s[0])?(s[0]=q(s[0]),n[t](...s)):i}function Dt(e,t,s=[]){Je(),cn();const n=q(e)[t].apply(e,s);return un(),ze(),n}const Do=nn("__proto__,__v_isRef,__isVue"),wi=new Set(Object.getOwnPropertyNames(Symbol).filter(e=>e!=="arguments"&&e!=="caller").map(e=>Symbol[e]).filter(qe));function Fo(e){qe(e)||(e=String(e));const t=q(this);return ce(t,"has",e),t.hasOwnProperty(e)}class Si{constructor(t=!1,s=!1){this._isReadonly=t,this._isShallow=s}get(t,s,n){if(s==="__v_skip")return t.__v_skip;const i=this._isReadonly,o=this._isShallow;if(s==="__v_isReactive")return!i;if(s==="__v_isReadonly")return i;if(s==="__v_isShallow")return o;if(s==="__v_raw")return n===(i?o?Wo:Pi:o?Ei:Ti).get(t)||Object.getPrototypeOf(t)===Object.getPrototypeOf(n)?t:void 0;const r=j(t);if(!i){let c;if(r&&(c=Io[s]))return c;if(s==="hasOwnProperty")return Fo}const l=Reflect.get(t,s,ue(t)?t:n);if((qe(s)?wi.has(s):Do(s))||(i||ce(t,"get",s),o))return l;if(ue(l)){const c=r&&rn(s)?l:l.value;return i&&Z(c)?Ys(c):c}return Z(l)?i?Ys(l):hn(l):l}}class Ci extends Si{constructor(t=!1){super(!1,t)}set(t,s,n,i){let o=t[s];const r=j(t)&&rn(s);if(!this._isShallow){const p=ot(o);if(!ke(n)&&!ot(n)&&(o=q(o),n=q(n)),!r&&ue(o)&&!ue(n))return p||(o.value=n),!0}const l=r?Number(s)e,is=e=>Reflect.getPrototypeOf(e);function No(e,t,s){return function(...n){const i=this.__v_raw,o=q(i),r=Tt(o),l=e==="entries"||e===Symbol.iterator&&r,c=e==="keys"&&r,p=i[e](...n),f=s?zs:t?kt:Fe;return!t&&ce(o,"iterate",c?Js:vt),fe(Object.create(p),{next(){const{value:m,done:P}=p.next();return P?{value:m,done:P}:{value:l?[f(m[0]),f(m[1])]:f(m),done:P}}})}}function os(e){return function(...t){return e==="delete"?!1:e==="clear"?void 0:this}}function Ho(e,t){const s={get(i){const o=this.__v_raw,r=q(o),l=q(i);e||(We(i,l)&&ce(r,"get",i),ce(r,"get",l));const{has:c}=is(r),p=t?zs:e?kt:Fe;if(c.call(r,i))return p(o.get(i));if(c.call(r,l))return p(o.get(l));o!==r&&o.get(i)},get size(){const i=this.__v_raw;return!e&&ce(q(i),"iterate",vt),i.size},has(i){const o=this.__v_raw,r=q(o),l=q(i);return e||(We(i,l)&&ce(r,"has",i),ce(r,"has",l)),i===l?o.has(i):o.has(i)||o.has(l)},forEach(i,o){const r=this,l=r.__v_raw,c=q(l),p=t?zs:e?kt:Fe;return!e&&ce(c,"iterate",vt),l.forEach((f,m)=>i.call(o,p(f),p(m),r))}};return fe(s,e?{add:os("add"),set:os("set"),delete:os("delete"),clear:os("clear")}:{add(i){const o=q(this),r=is(o),l=q(i),c=!t&&!ke(i)&&!ot(i)?l:i;return r.has.call(o,c)||We(i,c)&&r.has.call(o,i)||We(l,c)&&r.has.call(o,l)||(o.add(c),st(o,"add",c,c)),this},set(i,o){!t&&!ke(o)&&!ot(o)&&(o=q(o));const r=q(this),{has:l,get:c}=is(r);let p=l.call(r,i);p||(i=q(i),p=l.call(r,i));const f=c.call(r,i);return r.set(i,o),p?We(o,f)&&st(r,"set",i,o):st(r,"add",i,o),this},delete(i){const o=q(this),{has:r,get:l}=is(o);let c=r.call(o,i);c||(i=q(i),c=r.call(o,i)),l&&l.call(o,i);const p=o.delete(i);return c&&st(o,"delete",i,void 0),p},clear(){const i=q(this),o=i.size!==0,r=i.clear();return o&&st(i,"clear",void 0,void 0),r}}),["keys","values","entries",Symbol.iterator].forEach(i=>{s[i]=No(i,e,t)}),s}function pn(e,t){const s=Ho(e,t);return(n,i,o)=>i==="__v_isReactive"?!e:i==="__v_isReadonly"?e:i==="__v_raw"?n:Reflect.get(J(s,i)&&i in n?s:n,i,o)}const Vo={get:pn(!1,!1)},Bo={get:pn(!1,!0)},Ko={get:pn(!0,!1)};const Ti=new WeakMap,Ei=new WeakMap,Pi=new WeakMap,Wo=new WeakMap;function Go(e){switch(e){case"Object":case"Array":return 1;case"Map":case"Set":case"WeakMap":case"WeakSet":return 2;default:return 0}}function hn(e){return ot(e)?e:gn(e,!1,jo,Vo,Ti)}function qo(e){return gn(e,!1,$o,Bo,Ei)}function Ys(e){return gn(e,!0,Uo,Ko,Pi)}function gn(e,t,s,n,i){if(!Z(e)||e.__v_raw&&!(t&&e.__v_isReactive)||e.__v_skip||!Object.isExtensible(e))return e;const o=i.get(e);if(o)return o;const r=Go(vo(e));if(r===0)return e;const l=new Proxy(e,r===2?n:s);return i.set(e,l),l}function yt(e){return ot(e)?yt(e.__v_raw):!!(e&&e.__v_isReactive)}function ot(e){return!!(e&&e.__v_isReadonly)}function ke(e){return!!(e&&e.__v_isShallow)}function mn(e){return e?!!e.__v_raw:!1}function q(e){const t=e&&e.__v_raw;return t?q(t):e}function Jo(e){return!J(e,"__v_skip")&&Object.isExtensible(e)&&ui(e,"__v_skip",!0),e}const Fe=e=>Z(e)?hn(e):e,kt=e=>Z(e)?Ys(e):e;function ue(e){return e?e.__v_isRef===!0:!1}function z(e){return zo(e,!1)}function zo(e,t){return ue(e)?e:new Yo(e,t)}class Yo{constructor(t,s){this.dep=new dn,this.__v_isRef=!0,this.__v_isShallow=!1,this._rawValue=s?t:q(t),this._value=s?t:Fe(t),this.__v_isShallow=s}get value(){return this.dep.track(),this._value}set value(t){const s=this._rawValue,n=this.__v_isShallow||ke(t)||ot(t);t=n?t:q(t),We(t,s)&&(this._rawValue=t,this._value=n?t:Fe(t),this.dep.trigger())}}function jt(e){return ue(e)?e.value:e}const Xo={get:(e,t,s)=>t==="__v_raw"?e:jt(Reflect.get(e,t,s)),set:(e,t,s,n)=>{const i=e[t];return ue(i)&&!ue(s)?(i.value=s,!0):Reflect.set(e,t,s,n)}};function ki(e){return yt(e)?e:new Proxy(e,Xo)}class Zo{constructor(t,s,n){this.fn=t,this.setter=s,this._value=void 0,this.dep=new dn(this),this.__v_isRef=!0,this.deps=void 0,this.depsTail=void 0,this.flags=16,this.globalVersion=qt-1,this.next=void 0,this.effect=this,this.__v_isReadonly=!s,this.isSSR=n}notify(){if(this.flags|=16,!(this.flags&8)&&te!==this)return mi(this,!0),!0}get value(){const t=this.dep.track();return yi(this),t&&(t.version=this.dep.version),this._value}set value(t){this.setter&&this.setter(t)}}function Qo(e,t,s=!1){let n,i;return N(e)?n=e:(n=e.get,i=e.set),new Zo(n,i,s)}const rs={},ps=new WeakMap;let bt;function er(e,t=!1,s=bt){if(s){let n=ps.get(s);n||ps.set(s,n=[]),n.push(e)}}function tr(e,t,s=ee){const{immediate:n,deep:i,once:o,scheduler:r,augmentJob:l,call:c}=s,p=R=>i?R:ke(R)||i===!1||i===0?nt(R,1):nt(R);let f,m,P,A,B=!1,O=!1;if(ue(e)?(m=()=>e.value,B=ke(e)):yt(e)?(m=()=>p(e),B=!0):j(e)?(O=!0,B=e.some(R=>yt(R)||ke(R)),m=()=>e.map(R=>{if(ue(R))return R.value;if(yt(R))return p(R);if(N(R))return c?c(R,2):R()})):N(e)?t?m=c?()=>c(e,2):e:m=()=>{if(P){Je();try{P()}finally{ze()}}const R=bt;bt=f;try{return c?c(e,3,[A]):e(A)}finally{bt=R}}:m=Ge,t&&i){const R=m,G=i===!0?1/0:i;m=()=>nt(R(),G)}const H=Ao(),K=()=>{f.stop(),H&&H.active&&on(H.effects,f)};if(o&&t){const R=t;t=(...G)=>{const be=R(...G);return K(),be}}let $=O?new Array(e.length).fill(rs):rs;const W=R=>{if(!(!(f.flags&1)||!f.dirty&&!R))if(t){const G=f.run();if(R||i||B||(O?G.some((be,we)=>We(be,$[we])):We(G,$))){P&&P();const be=bt;bt=f;try{const we=[G,$===rs?void 0:O&&$[0]===rs?[]:$,A];$=G,c?c(t,3,we):t(...we)}finally{bt=be}}}else f.run()};return l&&l(W),f=new hi(m),f.scheduler=r?()=>r(W,!1):W,A=R=>er(R,!1,f),P=f.onStop=()=>{const R=ps.get(f);if(R){if(c)c(R,4);else for(const G of R)G();ps.delete(f)}},t?n?W(!0):$=f.run():r?r(W.bind(null,!0),!0):f.run(),K.pause=f.pause.bind(f),K.resume=f.resume.bind(f),K.stop=K,K}function nt(e,t=1/0,s){if(t<=0||!Z(e)||e.__v_skip||(s=s||new Map,(s.get(e)||0)>=t))return e;if(s.set(e,t),t--,ue(e))nt(e.value,t,s);else if(j(e))for(let n=0;n{nt(n,t,s)});else if(ai(e)){for(const n in e)nt(e[n],t,s);for(const n of Object.getOwnPropertySymbols(e))Object.prototype.propertyIsEnumerable.call(e,n)&&nt(e[n],t,s)}return e}/** +* @vue/runtime-core v3.5.39 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/function es(e,t,s,n){try{return n?e(...n):e()}catch(i){Ps(i,t,s)}}function Le(e,t,s,n){if(N(e)){const i=es(e,t,s,n);return i&&ri(i)&&i.catch(o=>{Ps(o,t,s)}),i}if(j(e)){const i=[];for(let o=0;o>>1,i=pe[n],o=zt(i);o=zt(s)?pe.push(e):pe.splice(nr(t),0,e),e.flags|=1,Mi()}}function Mi(){hs||(hs=Ai.then(Ri))}function ir(e){j(e)?Et.push(...e):at&&e.id===-1?at.splice(St+1,0,e):e.flags&1||(Et.push(e),e.flags|=1),Mi()}function On(e,t,s=Ve+1){for(;szt(s)-zt(n));if(Et.length=0,at){at.push(...t);return}for(at=t,St=0;Ste.id==null?e.flags&2?-1:1/0:e.id;function Ri(e){try{for(Ve=0;Ve{n._d&&Hn(-1);const o=gs(t);let r;try{r=e(...i)}finally{gs(o),n._d&&Hn(1)}return r};return n._n=!0,n._c=!0,n._d=!0,n}function Ce(e,t){if(Pe===null)return e;const s=Ms(Pe),n=e.dirs||(e.dirs=[]);for(let i=0;i1)return s&&N(t)?t.call(n&&n.proxy):t}}const lr=Symbol.for("v-scx"),ar=()=>us(lr);function Us(e,t,s){return Fi(e,t,s)}function Fi(e,t,s=ee){const{immediate:n,deep:i,flush:o,once:r}=s,l=fe({},s),c=t&&n||!t&&o!=="post";let p;if(Xt){if(o==="sync"){const A=ar();p=A.__watcherHandles||(A.__watcherHandles=[])}else if(!c){const A=()=>{};return A.stop=Ge,A.resume=Ge,A.pause=Ge,A}}const f=he;l.call=(A,B,O)=>Le(A,f,B,O);let m=!1;o==="post"?l.scheduler=A=>{me(A,f&&f.suspense)}:o!=="sync"&&(m=!0,l.scheduler=(A,B)=>{B?A():bn(A)}),l.augmentJob=A=>{t&&(A.flags|=4),m&&(A.flags|=2,f&&(A.id=f.uid,A.i=f))};const P=tr(e,t,l);return Xt&&(p?p.push(P):c&&P()),P}function cr(e,t,s){const n=this.proxy,i=ne(e)?e.includes(".")?Li(n,e):()=>n[e]:e.bind(n,n);let o;N(t)?o=t:(o=t.handler,s=t);const r=ts(this),l=Fi(i,o.bind(n),s);return r(),l}function Li(e,t){const s=t.split(".");return()=>{let n=e;for(let i=0;ie.__isTeleport,$s=Symbol("_leaveCb");function vn(e,t){e.shapeFlag&6&&e.component?(e.transition=t,vn(e.component.subTree,t)):e.shapeFlag&128?(e.ssContent.transition=t.clone(e.ssContent),e.ssFallback.transition=t.clone(e.ssFallback)):e.transition=t}function ji(e){e.ids=[e.ids[0]+e.ids[2]+++"-",0,0]}function Mn(e,t){let s;return!!((s=Object.getOwnPropertyDescriptor(e,t))&&!s.configurable)}const ms=new WeakMap;function Bt(e,t,s,n,i=!1){if(j(e)){e.forEach((O,H)=>Bt(O,t&&(j(t)?t[H]:t),s,n,i));return}if(Kt(n)&&!i){n.shapeFlag&512&&n.type.__asyncResolved&&n.component.subTree.component&&Bt(e,t,s,n.component.subTree);return}const o=n.shapeFlag&4?Ms(n.component):n.el,r=i?null:o,{i:l,r:c}=e,p=t&&t.r,f=l.refs===ee?l.refs={}:l.refs,m=l.setupState,P=q(m),A=m===ee?oi:O=>Mn(f,O)?!1:J(P,O),B=(O,H)=>!(H&&Mn(f,H));if(p!=null&&p!==c){if(In(t),ne(p))f[p]=null,A(p)&&(m[p]=null);else if(ue(p)){const O=t;B(p,O.k)&&(p.value=null),O.k&&(f[O.k]=null)}}if(N(c)){Je();try{es(c,l,12,[r,f])}finally{ze()}}else{const O=ne(c),H=ue(c);if(O||H){const K=()=>{if(e.f){const $=O?A(c)?m[c]:f[c]:B()||!e.k?c.value:f[e.k];if(i)j($)&&on($,o);else if(j($))$.includes(o)||$.push(o);else if(O)f[c]=[o],A(c)&&(m[c]=f[c]);else{const W=[o];B(c,e.k)&&(c.value=W),e.k&&(f[e.k]=W)}}else O?(f[c]=r,A(c)&&(m[c]=r)):H&&(B(c,e.k)&&(c.value=r),e.k&&(f[e.k]=r))};if(r){const $=()=>{K(),ms.delete(e)};$.id=-1,ms.set(e,$),me($,s)}else In(e),K()}}}function In(e){const t=ms.get(e);t&&(t.flags|=8,ms.delete(e))}Ts().requestIdleCallback;Ts().cancelIdleCallback;const Kt=e=>!!e.type.__asyncLoader,Ui=e=>e.type.__isKeepAlive;function dr(e,t){$i(e,"a",t)}function pr(e,t){$i(e,"da",t)}function $i(e,t,s=he){const n=e.__wdc||(e.__wdc=()=>{let i=s;for(;i;){if(i.isDeactivated)return;i=i.parent}return e()});if(ks(t,n,s),s){let i=s.parent;for(;i&&i.parent;)Ui(i.parent.vnode)&&hr(n,t,s,i),i=i.parent}}function hr(e,t,s,n){const i=ks(t,e,n,!0);yn(()=>{on(n[t],i)},s)}function ks(e,t,s=he,n=!1){if(s){const i=s[e]||(s[e]=[]),o=t.__weh||(t.__weh=(...r)=>{Je();const l=ts(s),c=Le(t,s,e,r);return l(),ze(),c});return n?i.unshift(o):i.push(o),o}}const rt=e=>(t,s=he)=>{(!Xt||e==="sp")&&ks(e,(...n)=>t(...n),s)},gr=rt("bm"),Ni=rt("m"),mr=rt("bu"),br=rt("u"),vr=rt("bum"),yn=rt("um"),yr=rt("sp"),xr=rt("rtg"),_r=rt("rtc");function wr(e,t=he){ks("ec",e,t)}const Sr=Symbol.for("v-ndc");function lt(e,t,s,n){let i;const o=s,r=j(e);if(r||ne(e)){const l=r&&yt(e);let c=!1,p=!1;l&&(c=!ke(e),p=ot(e),e=Es(e)),i=new Array(e.length);for(let f=0,m=e.length;ft(l,c,void 0,o));else{const l=Object.keys(e);i=new Array(l.length);for(let c=0,p=l.length;ce?lo(e)?Ms(e):Xs(e.parent):null,Wt=fe(Object.create(null),{$:e=>e,$el:e=>e.vnode.el,$data:e=>e.data,$props:e=>e.props,$attrs:e=>e.attrs,$slots:e=>e.slots,$refs:e=>e.refs,$parent:e=>Xs(e.parent),$root:e=>Xs(e.root),$host:e=>e.ce,$emit:e=>e.emit,$options:e=>Vi(e),$forceUpdate:e=>e.f||(e.f=()=>{bn(e.update)}),$nextTick:e=>e.n||(e.n=Oi.bind(e.proxy)),$watch:e=>cr.bind(e)}),Ns=(e,t)=>e!==ee&&!e.__isScriptSetup&&J(e,t),Cr={get({_:e},t){if(t==="__v_skip")return!0;const{ctx:s,setupState:n,data:i,props:o,accessCache:r,type:l,appContext:c}=e;if(t[0]!=="$"){const P=r[t];if(P!==void 0)switch(P){case 1:return n[t];case 2:return i[t];case 4:return s[t];case 3:return o[t]}else{if(Ns(n,t))return r[t]=1,n[t];if(i!==ee&&J(i,t))return r[t]=2,i[t];if(J(o,t))return r[t]=3,o[t];if(s!==ee&&J(s,t))return r[t]=4,s[t];Zs&&(r[t]=0)}}const p=Wt[t];let f,m;if(p)return t==="$attrs"&&ce(e.attrs,"get",""),p(e);if((f=l.__cssModules)&&(f=f[t]))return f;if(s!==ee&&J(s,t))return r[t]=4,s[t];if(m=c.config.globalProperties,J(m,t))return m[t]},set({_:e},t,s){const{data:n,setupState:i,ctx:o}=e;return Ns(i,t)?(i[t]=s,!0):n!==ee&&J(n,t)?(n[t]=s,!0):J(e.props,t)||t[0]==="$"&&t.slice(1)in e?!1:(o[t]=s,!0)},has({_:{data:e,setupState:t,accessCache:s,ctx:n,appContext:i,props:o,type:r}},l){let c;return!!(s[l]||e!==ee&&l[0]!=="$"&&J(e,l)||Ns(t,l)||J(o,l)||J(n,l)||J(Wt,l)||J(i.config.globalProperties,l)||(c=r.__cssModules)&&c[l])},defineProperty(e,t,s){return s.get!=null?e._.accessCache[t]=0:J(s,"value")&&this.set(e,t,s.value,null),Reflect.defineProperty(e,t,s)}};function Rn(e){return j(e)?e.reduce((t,s)=>(t[s]=null,t),{}):e}let Zs=!0;function Tr(e){const t=Vi(e),s=e.proxy,n=e.ctx;Zs=!1,t.beforeCreate&&Dn(t.beforeCreate,e,"bc");const{data:i,computed:o,methods:r,watch:l,provide:c,inject:p,created:f,beforeMount:m,mounted:P,beforeUpdate:A,updated:B,activated:O,deactivated:H,beforeDestroy:K,beforeUnmount:$,destroyed:W,unmounted:R,render:G,renderTracked:be,renderTriggered:we,errorCaptured:je,serverPrefetch:Se,expose:ge,inheritAttrs:oe,components:Ue,directives:Oe,filters:ae}=t;if(p&&Er(p,n,null),r)for(const Y in r){const X=r[Y];N(X)&&(n[Y]=X.bind(s))}if(i){const Y=i.call(s,s);Z(Y)&&(e.data=hn(Y))}if(Zs=!0,o)for(const Y in o){const X=o[Y],Ie=N(X)?X.bind(s,s):N(X.get)?X.get.bind(s,s):Ge,_t=!N(X)&&N(X.set)?X.set.bind(s):Ge,Ye=co({get:Ie,set:_t});Object.defineProperty(n,Y,{enumerable:!0,configurable:!0,get:()=>Ye.value,set:ve=>Ye.value=ve})}if(l)for(const Y in l)Hi(l[Y],n,s,Y);if(c){const Y=N(c)?c.call(s):c;Reflect.ownKeys(Y).forEach(X=>{rr(X,Y[X])})}f&&Dn(f,e,"c");function se(Y,X){j(X)?X.forEach(Ie=>Y(Ie.bind(s))):X&&Y(X.bind(s))}if(se(gr,m),se(Ni,P),se(mr,A),se(br,B),se(dr,O),se(pr,H),se(wr,je),se(_r,be),se(xr,we),se(vr,$),se(yn,R),se(yr,Se),j(ge))if(ge.length){const Y=e.exposed||(e.exposed={});ge.forEach(X=>{Object.defineProperty(Y,X,{get:()=>s[X],set:Ie=>s[X]=Ie,enumerable:!0})})}else e.exposed||(e.exposed={});G&&e.render===Ge&&(e.render=G),oe!=null&&(e.inheritAttrs=oe),Ue&&(e.components=Ue),Oe&&(e.directives=Oe),Se&&ji(e)}function Er(e,t,s=Ge){j(e)&&(e=Qs(e));for(const n in e){const i=e[n];let o;Z(i)?"default"in i?o=us(i.from||n,i.default,!0):o=us(i.from||n):o=us(i),ue(o)?Object.defineProperty(t,n,{enumerable:!0,configurable:!0,get:()=>o.value,set:r=>o.value=r}):t[n]=o}}function Dn(e,t,s){Le(j(e)?e.map(n=>n.bind(t.proxy)):e.bind(t.proxy),t,s)}function Hi(e,t,s,n){let i=n.includes(".")?Li(s,n):()=>s[n];if(ne(e)){const o=t[e];N(o)&&Us(i,o)}else if(N(e))Us(i,e.bind(s));else if(Z(e))if(j(e))e.forEach(o=>Hi(o,t,s,n));else{const o=N(e.handler)?e.handler.bind(s):t[e.handler];N(o)&&Us(i,o,e)}}function Vi(e){const t=e.type,{mixins:s,extends:n}=t,{mixins:i,optionsCache:o,config:{optionMergeStrategies:r}}=e.appContext,l=o.get(t);let c;return l?c=l:!i.length&&!s&&!n?c=t:(c={},i.length&&i.forEach(p=>bs(c,p,r,!0)),bs(c,t,r)),Z(t)&&o.set(t,c),c}function bs(e,t,s,n=!1){const{mixins:i,extends:o}=t;o&&bs(e,o,s,!0),i&&i.forEach(r=>bs(e,r,s,!0));for(const r in t)if(!(n&&r==="expose")){const l=Pr[r]||s&&s[r];e[r]=l?l(e[r],t[r]):t[r]}return e}const Pr={data:Fn,props:Ln,emits:Ln,methods:Ut,computed:Ut,beforeCreate:de,created:de,beforeMount:de,mounted:de,beforeUpdate:de,updated:de,beforeDestroy:de,beforeUnmount:de,destroyed:de,unmounted:de,activated:de,deactivated:de,errorCaptured:de,serverPrefetch:de,components:Ut,directives:Ut,watch:Ar,provide:Fn,inject:kr};function Fn(e,t){return t?e?function(){return fe(N(e)?e.call(this,this):e,N(t)?t.call(this,this):t)}:t:e}function kr(e,t){return Ut(Qs(e),Qs(t))}function Qs(e){if(j(e)){const t={};for(let s=0;st==="modelValue"||t==="model-value"?e.modelModifiers:e[`${t}Modifiers`]||e[`${Re(t)}Modifiers`]||e[`${xt(t)}Modifiers`];function Rr(e,t,...s){if(e.isUnmounted)return;const n=e.vnode.props||ee;let i=s;const o=t.startsWith("update:"),r=o&&Ir(n,t.slice(7));r&&(r.trim&&(i=s.map(f=>ne(f)?f.trim():f)),r.number&&(i=s.map(Cs)));let l,c=n[l=Rs(t)]||n[l=Rs(Re(t))];!c&&o&&(c=n[l=Rs(xt(t))]),c&&Le(c,e,6,i);const p=n[l+"Once"];if(p){if(!e.emitted)e.emitted={};else if(e.emitted[l])return;e.emitted[l]=!0,Le(p,e,6,i)}}const Dr=new WeakMap;function Ki(e,t,s=!1){const n=s?Dr:t.emitsCache,i=n.get(e);if(i!==void 0)return i;const o=e.emits;let r={},l=!1;if(!N(e)){const c=p=>{const f=Ki(p,t,!0);f&&(l=!0,fe(r,f))};!s&&t.mixins.length&&t.mixins.forEach(c),e.extends&&c(e.extends),e.mixins&&e.mixins.forEach(c)}return!o&&!l?(Z(e)&&n.set(e,null),null):(j(o)?o.forEach(c=>r[c]=null):fe(r,o),Z(e)&&n.set(e,r),r)}function As(e,t){return!e||!_s(t)?!1:(t=t.slice(2),t=t==="Once"?t:t.replace(/Once$/,""),J(e,t[0].toLowerCase()+t.slice(1))||J(e,xt(t))||J(e,t))}function jn(e){const{type:t,vnode:s,proxy:n,withProxy:i,propsOptions:[o],slots:r,attrs:l,emit:c,render:p,renderCache:f,props:m,data:P,setupState:A,ctx:B,inheritAttrs:O}=e,H=gs(e);let K,$;try{if(s.shapeFlag&4){const R=i||n,G=R;K=Ke(p.call(G,R,f,m,A,P,B)),$=l}else{const R=t;K=Ke(R.length>1?R(m,{attrs:l,slots:r,emit:c}):R(m,null)),$=t.props?l:Fr(l)}}catch(R){Gt.length=0,Ps(R,e,1),K=_e(ut)}let W=K;if($&&O!==!1){const R=Object.keys($),{shapeFlag:G}=W;R.length&&G&7&&(o&&R.some(ws)&&($=Lr($,o)),W=At(W,$,!1,!0))}return s.dirs&&(W=At(W,null,!1,!0),W.dirs=W.dirs?W.dirs.concat(s.dirs):s.dirs),s.transition&&vn(W,s.transition),K=W,gs(H),K}const Fr=e=>{let t;for(const s in e)(s==="class"||s==="style"||_s(s))&&((t||(t={}))[s]=e[s]);return t},Lr=(e,t)=>{const s={};for(const n in e)(!ws(n)||!(n.slice(9)in t))&&(s[n]=e[n]);return s};function jr(e,t,s){const{props:n,children:i,component:o}=e,{props:r,children:l,patchFlag:c}=t,p=o.emitsOptions;if(t.dirs||t.transition)return!0;if(s&&c>=0){if(c&1024)return!0;if(c&16)return n?Un(n,r,p):!!r;if(c&8){const f=t.dynamicProps;for(let m=0;mObject.create(Gi),Ji=e=>Object.getPrototypeOf(e)===Gi;function $r(e,t,s,n=!1){const i={},o=qi();e.propsDefaults=Object.create(null),zi(e,t,i,o);for(const r in e.propsOptions[0])r in i||(i[r]=void 0);s?e.props=n?i:qo(i):e.type.props?e.props=i:e.props=o,e.attrs=o}function Nr(e,t,s,n){const{props:i,attrs:o,vnode:{patchFlag:r}}=e,l=q(i),[c]=e.propsOptions;let p=!1;if((n||r>0)&&!(r&16)){if(r&8){const f=e.vnode.dynamicProps;for(let m=0;m{c=!0;const[P,A]=Yi(m,t,!0);fe(r,P),A&&l.push(...A)};!s&&t.mixins.length&&t.mixins.forEach(f),e.extends&&f(e.extends),e.mixins&&e.mixins.forEach(f)}if(!o&&!c)return Z(e)&&n.set(e,Ct),Ct;if(j(o))for(let f=0;fe==="_"||e==="_ctx"||e==="$stable",_n=e=>j(e)?e.map(Ke):[Ke(e)],Vr=(e,t,s)=>{if(t._n)return t;const n=or((...i)=>_n(t(...i)),s);return n._c=!1,n},Xi=(e,t,s)=>{const n=e._ctx;for(const i in e){if(xn(i))continue;const o=e[i];if(N(o))t[i]=Vr(i,o,n);else if(o!=null){const r=_n(o);t[i]=()=>r}}},Zi=(e,t)=>{const s=_n(t);e.slots.default=()=>s},Qi=(e,t,s)=>{for(const n in t)(s||!xn(n))&&(e[n]=t[n])},Br=(e,t,s)=>{const n=e.slots=qi();if(e.vnode.shapeFlag&32){const i=t._;i?(Qi(n,t,s),s&&ui(n,"_",i,!0)):Xi(t,n)}else t&&Zi(e,t)},Kr=(e,t,s)=>{const{vnode:n,slots:i}=e;let o=!0,r=ee;if(n.shapeFlag&32){const l=t._;l?s&&l===1?o=!1:Qi(i,t,s):(o=!t.$stable,Xi(t,i)),r=t}else t&&(Zi(e,t),r={default:1});if(o)for(const l in i)!xn(l)&&r[l]==null&&delete i[l]},me=zr;function Wr(e){return Gr(e)}function Gr(e,t){const s=Ts();s.__VUE__=!0;const{insert:n,remove:i,patchProp:o,createElement:r,createText:l,createComment:c,setText:p,setElementText:f,parentNode:m,nextSibling:P,setScopeId:A=Ge,insertStaticContent:B}=e,O=(a,u,g,_=null,b=null,v=null,T=void 0,C=null,S=!!u.dynamicChildren)=>{if(a===u)return;a&&!Ft(a,u)&&(_=pt(a),ve(a,b,v,!0),a=null),u.patchFlag===-2&&(S=!1,u.dynamicChildren=null);const{type:x,ref:I,shapeFlag:E}=u;switch(x){case Os:H(a,u,g,_);break;case ut:K(a,u,g,_);break;case fs:a==null&&$(u,g,_,T);break;case re:Ue(a,u,g,_,b,v,T,C,S);break;default:E&1?G(a,u,g,_,b,v,T,C,S):E&6?Oe(a,u,g,_,b,v,T,C,S):(E&64||E&128)&&x.process(a,u,g,_,b,v,T,C,S,ht)}I!=null&&b?Bt(I,a&&a.ref,v,u||a,!u):I==null&&a&&a.ref!=null&&Bt(a.ref,null,v,a,!0)},H=(a,u,g,_)=>{if(a==null)n(u.el=l(u.children),g,_);else{const b=u.el=a.el;u.children!==a.children&&p(b,u.children)}},K=(a,u,g,_)=>{a==null?n(u.el=c(u.children||""),g,_):u.el=a.el},$=(a,u,g,_)=>{[a.el,a.anchor]=B(a.children,u,g,_,a.el,a.anchor)},W=({el:a,anchor:u},g,_)=>{let b;for(;a&&a!==u;)b=P(a),n(a,g,_),a=b;n(u,g,_)},R=({el:a,anchor:u})=>{let g;for(;a&&a!==u;)g=P(a),i(a),a=g;i(u)},G=(a,u,g,_,b,v,T,C,S)=>{if(u.type==="svg"?T="svg":u.type==="math"&&(T="mathml"),a==null)be(u,g,_,b,v,T,C,S);else{const x=a.el&&a.el._isVueCE?a.el:null;try{x&&x._beginPatch(),Se(a,u,b,v,T,C,S)}finally{x&&x._endPatch()}}},be=(a,u,g,_,b,v,T,C)=>{let S,x;const{props:I,shapeFlag:E,transition:M,dirs:L}=a;if(S=a.el=r(a.type,v,I&&I.is,I),E&8?f(S,a.children):E&16&&je(a.children,S,null,_,b,Hs(a,v),T,C),L&>(a,null,_,"created"),we(S,a,a.scopeId,T,_),I){for(const w in I)w!=="value"&&!Nt(w)&&o(S,w,null,I[w],v,_);"value"in I&&o(S,"value",null,I.value,v),(x=I.onVnodeBeforeMount)&&He(x,_,a)}L&>(a,null,_,"beforeMount");const V=qr(b,M);V&&M.beforeEnter(S),n(S,u,g),((x=I&&I.onVnodeMounted)||V||L)&&me(()=>{try{x&&He(x,_,a),V&&M.enter(S),L&>(a,null,_,"mounted")}finally{}},b)},we=(a,u,g,_,b)=>{if(g&&A(a,g),_)for(let v=0;v<_.length;v++)A(a,_[v]);if(b){let v=b.subTree;if(u===v||no(v.type)&&(v.ssContent===u||v.ssFallback===u)){const T=b.vnode;we(a,T,T.scopeId,T.slotScopeIds,b.parent)}}},je=(a,u,g,_,b,v,T,C,S=0)=>{for(let x=S;x{const C=u.el=a.el;let{patchFlag:S,dynamicChildren:x,dirs:I}=u;S|=a.patchFlag&16;const E=a.props||ee,M=u.props||ee;let L;if(g&&mt(g,!1),(L=M.onVnodeBeforeUpdate)&&He(L,g,u,a),I&>(u,a,g,"beforeUpdate"),g&&mt(g,!0),x&&(!a.dynamicChildren||a.dynamicChildren.length!==x.length)&&(S=0,T=!1,x=null),(E.innerHTML&&M.innerHTML==null||E.textContent&&M.textContent==null)&&f(C,""),x?ge(a.dynamicChildren,x,C,g,_,Hs(u,b),v):T||X(a,u,C,null,g,_,Hs(u,b),v,!1),S>0){if(S&16)oe(C,E,M,g,b);else if(S&2&&E.class!==M.class&&o(C,"class",null,M.class,b),S&4&&o(C,"style",E.style,M.style,b),S&8){const V=u.dynamicProps;for(let w=0;w{L&&He(L,g,u,a),I&>(u,a,g,"updated")},_)},ge=(a,u,g,_,b,v,T)=>{for(let C=0;C{if(u!==g){if(u!==ee)for(const v in u)!Nt(v)&&!(v in g)&&o(a,v,u[v],null,b,_);for(const v in g){if(Nt(v))continue;const T=g[v],C=u[v];T!==C&&v!=="value"&&o(a,v,C,T,b,_)}"value"in g&&o(a,"value",u.value,g.value,b)}},Ue=(a,u,g,_,b,v,T,C,S)=>{const x=u.el=a?a.el:l(""),I=u.anchor=a?a.anchor:l("");let{patchFlag:E,dynamicChildren:M,slotScopeIds:L}=u;L&&(C=C?C.concat(L):L),a==null?(n(x,g,_),n(I,g,_),je(u.children||[],g,I,b,v,T,C,S)):E>0&&E&64&&M&&a.dynamicChildren&&a.dynamicChildren.length===M.length?(ge(a.dynamicChildren,M,g,b,v,T,C),(u.key!=null||b&&u===b.subTree)&&eo(a,u,!0)):X(a,u,g,I,b,v,T,C,S)},Oe=(a,u,g,_,b,v,T,C,S)=>{u.slotScopeIds=C,a==null?u.shapeFlag&512?b.ctx.activate(u,g,_,T,S):ae(u,g,_,b,v,T,S):Me(a,u,S)},ae=(a,u,g,_,b,v,T)=>{const C=a.component=il(a,_,b);if(Ui(a)&&(C.ctx.renderer=ht),rl(C,!1,T),C.asyncDep){if(b&&b.registerDep(C,se,T),!a.el){const S=C.subTree=_e(ut);K(null,S,u,g),a.placeholder=S.el}}else se(C,a,u,g,b,v,T)},Me=(a,u,g)=>{const _=u.component=a.component;if(jr(a,u,g))if(_.asyncDep&&!_.asyncResolved){Y(_,u,g);return}else _.next=u,_.update();else u.el=a.el,_.vnode=u},se=(a,u,g,_,b,v,T)=>{const C=()=>{if(a.isMounted){let{next:E,bu:M,u:L,parent:V,vnode:w}=a;{const $e=to(a);if($e){E&&(E.el=w.el,Y(a,E,T)),$e.asyncDep.then(()=>{me(()=>{a.isUnmounted||x()},b)});return}}let d=E,h;mt(a,!1),E?(E.el=w.el,Y(a,E,T)):E=w,M&&cs(M),(h=E.props&&E.props.onVnodeBeforeUpdate)&&He(h,V,E,w),mt(a,!0);const k=jn(a),ie=a.subTree;a.subTree=k,O(ie,k,m(ie.el),pt(ie),a,b,v),E.el=k.el,d===null&&Ur(a,k.el),L&&me(L,b),(h=E.props&&E.props.onVnodeUpdated)&&me(()=>He(h,V,E,w),b)}else{let E;const{el:M,props:L}=u,{bm:V,m:w,parent:d,root:h,type:k}=a,ie=Kt(u);mt(a,!1),V&&cs(V),!ie&&(E=L&&L.onVnodeBeforeMount)&&He(E,d,u),mt(a,!0);{h.ce&&h.ce._hasShadowRoot()&&h.ce._injectChildStyle(k,a.parent?a.parent.type:void 0);const $e=a.subTree=jn(a);O(null,$e,g,_,a,b,v),u.el=$e.el}if(w&&me(w,b),!ie&&(E=L&&L.onVnodeMounted)){const $e=u;me(()=>He(E,d,$e),b)}(u.shapeFlag&256||d&&Kt(d.vnode)&&d.vnode.shapeFlag&256)&&a.a&&me(a.a,b),a.isMounted=!0,u=g=_=null}};a.scope.on();const S=a.effect=new hi(C);a.scope.off();const x=a.update=S.run.bind(S),I=a.job=S.runIfDirty.bind(S);I.i=a,I.id=a.uid,S.scheduler=()=>bn(I),mt(a,!0),x()},Y=(a,u,g)=>{u.component=a;const _=a.vnode.props;a.vnode=u,a.next=null,Nr(a,u.props,_,g),Kr(a,u.children,g),Je(),On(a),ze()},X=(a,u,g,_,b,v,T,C,S=!1)=>{const x=a&&a.children,I=a?a.shapeFlag:0,E=u.children,{patchFlag:M,shapeFlag:L}=u;if(M>0){if(M&128){_t(x,E,g,_,b,v,T,C,S);return}else if(M&256){Ie(x,E,g,_,b,v,T,C,S);return}}L&8?(I&16&&dt(x,b,v),E!==x&&f(g,E)):I&16?L&16?_t(x,E,g,_,b,v,T,C,S):dt(x,b,v,!0):(I&8&&f(g,""),L&16&&je(E,g,_,b,v,T,C,S))},Ie=(a,u,g,_,b,v,T,C,S)=>{a=a||Ct,u=u||Ct;const x=a.length,I=u.length,E=Math.min(x,I);let M;for(M=0;MI?dt(a,b,v,!0,!1,E):je(u,g,_,b,v,T,C,S,E)},_t=(a,u,g,_,b,v,T,C,S)=>{let x=0;const I=u.length;let E=a.length-1,M=I-1;for(;x<=E&&x<=M;){const L=a[x],V=u[x]=S?tt(u[x]):Ke(u[x]);if(Ft(L,V))O(L,V,g,null,b,v,T,C,S);else break;x++}for(;x<=E&&x<=M;){const L=a[E],V=u[M]=S?tt(u[M]):Ke(u[M]);if(Ft(L,V))O(L,V,g,null,b,v,T,C,S);else break;E--,M--}if(x>E){if(x<=M){const L=M+1,V=LM)for(;x<=E;)ve(a[x],b,v,!0),x++;else{const L=x,V=x,w=new Map;for(x=V;x<=M;x++){const ye=u[x]=S?tt(u[x]):Ke(u[x]);ye.key!=null&&w.set(ye.key,x)}let d,h=0;const k=M-V+1;let ie=!1,$e=0;const Rt=new Array(k);for(x=0;x=k){ve(ye,b,v,!0);continue}let Ne;if(ye.key!=null)Ne=w.get(ye.key);else for(d=V;d<=M;d++)if(Rt[d-V]===0&&Ft(ye,u[d])){Ne=d;break}Ne===void 0?ve(ye,b,v,!0):(Rt[Ne-V]=x+1,Ne>=$e?$e=Ne:ie=!0,O(ye,u[Ne],g,null,b,v,T,C,S),h++)}const Sn=ie?Jr(Rt):Ct;for(d=Sn.length-1,x=k-1;x>=0;x--){const ye=V+x,Ne=u[ye],Cn=u[ye+1],Tn=ye+1{const{el:v,type:T,transition:C,children:S,shapeFlag:x}=a;if(x&6){Ye(a.component.subTree,u,g,_);return}if(x&128){a.suspense.move(u,g,_);return}if(x&64){T.move(a,u,g,ht);return}if(T===re){n(v,u,g);for(let E=0;EC.enter(v),b));else{const{leave:E,delayLeave:M,afterLeave:L}=C,V=()=>{a.ctx.isUnmounted?i(v):n(v,u,g)},w=()=>{const d=v._isLeaving||!!v[$s];v._isLeaving&&v[$s](!0),C.persisted&&!d?V():E(v,()=>{V(),L&&L()})};M?M(v,V,w):w()}else n(v,u,g)},ve=(a,u,g,_=!1,b=!1)=>{const{type:v,props:T,ref:C,children:S,dynamicChildren:x,shapeFlag:I,patchFlag:E,dirs:M,cacheIndex:L,memo:V}=a;if(E===-2&&(b=!1),C!=null&&(Je(),Bt(C,null,g,a,!0),ze()),L!=null&&(u.renderCache[L]=void 0),I&256){u.ctx.deactivate(a);return}const w=I&1&&M,d=!Kt(a);let h;if(d&&(h=T&&T.onVnodeBeforeUnmount)&&He(h,u,a),I&6)Xe(a.component,g,_);else{if(I&128){a.suspense.unmount(g,_);return}w&>(a,null,u,"beforeUnmount"),I&64?a.type.remove(a,u,g,ht,_):x&&!x.hasOnce&&(v!==re||E>0&&E&64)?dt(x,u,g,!1,!0):(v===re&&E&384||!b&&I&16)&&dt(S,u,g),_&&ss(a)}const k=V!=null&&L==null;(d&&(h=T&&T.onVnodeUnmounted)||w||k)&&me(()=>{h&&He(h,u,a),w&>(a,null,u,"unmounted"),k&&(a.el=null)},g)},ss=a=>{const{type:u,el:g,anchor:_,transition:b}=a;if(u===re){Is(g,_);return}if(u===fs){R(a);return}const v=()=>{i(g),b&&!b.persisted&&b.afterLeave&&b.afterLeave()};if(a.shapeFlag&1&&b&&!b.persisted){const{leave:T,delayLeave:C}=b,S=()=>T(g,v);C?C(a.el,v,S):S()}else v()},Is=(a,u)=>{let g;for(;a!==u;)g=P(a),i(a),a=g;i(u)},Xe=(a,u,g)=>{const{bum:_,scope:b,job:v,subTree:T,um:C,m:S,a:x}=a;Nn(S),Nn(x),_&&cs(_),b.stop(),v&&(v.flags|=8,ve(T,a,u,g)),C&&me(C,u),me(()=>{a.isUnmounted=!0},u)},dt=(a,u,g,_=!1,b=!1,v=0)=>{for(let T=v;T{if(a.shapeFlag&6)return pt(a.component.subTree);if(a.shapeFlag&128)return a.suspense.next();const u=P(a.anchor||a.el),g=u&&u[ur];return g?P(g):u};let It=!1;const ns=(a,u,g)=>{let _;a==null?u._vnode&&(ve(u._vnode,null,null,!0),_=u._vnode.component):O(u._vnode||null,a,u,null,null,null,g),u._vnode=a,It||(It=!0,On(_),Ii(),It=!1)},ht={p:O,um:ve,m:Ye,r:ss,mt:ae,mc:je,pc:X,pbc:ge,n:pt,o:e};return{render:ns,hydrate:void 0,createApp:Mr(ns)}}function Hs({type:e,props:t},s){return s==="svg"&&e==="foreignObject"||s==="mathml"&&e==="annotation-xml"&&t&&t.encoding&&t.encoding.includes("html")?void 0:s}function mt({effect:e,job:t},s){s?(e.flags|=32,t.flags|=4):(e.flags&=-33,t.flags&=-5)}function qr(e,t){return(!e||e&&!e.pendingBranch)&&t&&!t.persisted}function eo(e,t,s=!1){const n=e.children,i=t.children;if(j(n)&&j(i))for(let o=0;o>1,e[s[l]]0&&(t[n]=s[o-1]),s[o]=n)}}for(o=s.length,r=s[o-1];o-- >0;)s[o]=r,r=t[r];return s}function to(e){const t=e.subTree.component;if(t)return t.asyncDep&&!t.asyncResolved?t:to(t)}function Nn(e){if(e)for(let t=0;te.__isSuspense;function zr(e,t){t&&t.pendingBranch?j(e)?t.effects.push(...e):t.effects.push(e):ir(e)}const re=Symbol.for("v-fgt"),Os=Symbol.for("v-txt"),ut=Symbol.for("v-cmt"),fs=Symbol.for("v-stc"),Gt=[];let xe=null;function D(e=!1){Gt.push(xe=e?null:[])}function Yr(){Gt.pop(),xe=Gt[Gt.length-1]||null}let Yt=1;function Hn(e,t=!1){Yt+=e,e<0&&xe&&t&&(xe.hasOnce=!0)}function io(e){return e.dynamicChildren=Yt>0?xe||Ct:null,Yr(),Yt>0&&xe&&xe.push(e),e}function F(e,t,s,n,i,o){return io(y(e,t,s,n,i,o,!0))}function Xr(e,t,s,n,i){return io(_e(e,t,s,n,i,!0))}function oo(e){return e?e.__v_isVNode===!0:!1}function Ft(e,t){return e.type===t.type&&e.key===t.key}const ro=({key:e})=>e??null,ds=({ref:e,ref_key:t,ref_for:s})=>(typeof e=="number"&&(e=""+e),e!=null?ne(e)||ue(e)||N(e)?{i:Pe,r:e,k:t,f:!!s}:e:null);function y(e,t=null,s=null,n=0,i=null,o=e===re?0:1,r=!1,l=!1){const c={__v_isVNode:!0,__v_skip:!0,type:e,props:t,key:t&&ro(t),ref:t&&ds(t),scopeId:Di,slotScopeIds:null,children:s,component:null,suspense:null,ssContent:null,ssFallback:null,dirs:null,transition:null,el:null,anchor:null,target:null,targetStart:null,targetAnchor:null,staticCount:0,shapeFlag:o,patchFlag:n,dynamicProps:i,dynamicChildren:null,appContext:null,ctx:Pe};return l?(vs(c,s),o&128&&e.normalize(c)):s&&(c.shapeFlag|=ne(s)?8:16),Yt>0&&!r&&xe&&(c.patchFlag>0||o&6)&&c.patchFlag!==32&&xe.push(c),c}const _e=Zr;function Zr(e,t=null,s=null,n=0,i=null,o=!1){if((!e||e===Sr)&&(e=ut),oo(e)){const l=At(e,t,!0);return s&&vs(l,s),Yt>0&&!o&&xe&&(l.shapeFlag&6?xe[xe.indexOf(e)]=l:xe.push(l)),l.patchFlag=-2,l}if(ul(e)&&(e=e.__vccOpts),t){t=Qr(t);let{class:l,style:c}=t;l&&!ne(l)&&(t.class=Ee(l)),Z(c)&&(mn(c)&&!j(c)&&(c=fe({},c)),t.style=ln(c))}const r=ne(e)?1:no(e)?128:fr(e)?64:Z(e)?4:N(e)?2:0;return y(e,t,s,n,i,r,o,!0)}function Qr(e){return e?mn(e)||Ji(e)?fe({},e):e:null}function At(e,t,s=!1,n=!1){const{props:i,ref:o,patchFlag:r,children:l,transition:c}=e,p=t?tl(i||{},t):i,f={__v_isVNode:!0,__v_skip:!0,type:e.type,props:p,key:p&&ro(p),ref:t&&t.ref?s&&o?j(o)?o.concat(ds(t)):[o,ds(t)]:ds(t):o,scopeId:e.scopeId,slotScopeIds:e.slotScopeIds,children:l,target:e.target,targetStart:e.targetStart,targetAnchor:e.targetAnchor,staticCount:e.staticCount,shapeFlag:e.shapeFlag,patchFlag:t&&e.type!==re?r===-1?16:r|16:r,dynamicProps:e.dynamicProps,dynamicChildren:e.dynamicChildren,appContext:e.appContext,dirs:e.dirs,transition:c,component:e.component,suspense:e.suspense,ssContent:e.ssContent&&At(e.ssContent),ssFallback:e.ssFallback&&At(e.ssFallback),placeholder:e.placeholder,el:e.el,anchor:e.anchor,ctx:e.ctx,ce:e.ce};return c&&n&&vn(f,c.clone(f)),f}function Te(e=" ",t=0){return _e(Os,null,e,t)}function el(e,t){const s=_e(fs,null,e);return s.staticCount=t,s}function Q(e="",t=!1){return t?(D(),Xr(ut,null,e)):_e(ut,null,e)}function Ke(e){return e==null||typeof e=="boolean"?_e(ut):j(e)?_e(re,null,e.slice()):oo(e)?tt(e):_e(Os,null,String(e))}function tt(e){return e.el===null&&e.patchFlag!==-1||e.memo?e:At(e)}function vs(e,t){let s=0;const{shapeFlag:n}=e;if(t==null)t=null;else if(j(t))s=16;else if(typeof t=="object")if(n&65){const i=t.default;i&&(i._c&&(i._d=!1),vs(e,i()),i._c&&(i._d=!0));return}else{s=32;const i=t._;!i&&!Ji(t)?t._ctx=Pe:i===3&&Pe&&(Pe.slots._===1?t._=1:(t._=2,e.patchFlag|=1024))}else if(N(t)){if(n&65){vs(e,{default:t});return}t={default:t,_ctx:Pe},s=32}else t=String(t),n&64?(s=16,t=[Te(t)]):s=8;e.children=t,e.shapeFlag|=s}function tl(...e){const t={};for(let s=0;she||Pe;let ys,tn;{const e=Ts(),t=(s,n)=>{let i;return(i=e[s])||(i=e[s]=[]),i.push(n),o=>{i.length>1?i.forEach(r=>r(o)):i[0](o)}};ys=t("__VUE_INSTANCE_SETTERS__",s=>he=s),tn=t("__VUE_SSR_SETTERS__",s=>Xt=s)}const ts=e=>{const t=he;return ys(e),e.scope.on(),()=>{e.scope.off(),ys(t)}},Vn=()=>{he&&he.scope.off(),ys(null)};function lo(e){return e.vnode.shapeFlag&4}let Xt=!1;function rl(e,t=!1,s=!1){t&&tn(t);const{props:n,children:i}=e.vnode,o=lo(e);$r(e,n,o,t),Br(e,i,s||t);const r=o?ll(e,t):void 0;return t&&tn(!1),r}function ll(e,t){const s=e.type;e.accessCache=Object.create(null),e.proxy=new Proxy(e.ctx,Cr);const{setup:n}=s;if(n){Je();const i=e.setupContext=n.length>1?cl(e):null,o=ts(e),r=es(n,e,0,[e.props,i]),l=ri(r);if(ze(),o(),(l||e.sp)&&!Kt(e)&&ji(e),l){if(r.then(Vn,Vn),t)return r.then(c=>{Bn(e,c)}).catch(c=>{Ps(c,e,0)});e.asyncDep=r}else Bn(e,r)}else ao(e)}function Bn(e,t,s){N(t)?e.type.__ssrInlineRender?e.ssrRender=t:e.render=t:Z(t)&&(e.setupState=ki(t)),ao(e)}function ao(e,t,s){const n=e.type;e.render||(e.render=n.render||Ge);{const i=ts(e);Je();try{Tr(e)}finally{ze(),i()}}}const al={get(e,t){return ce(e,"get",""),e[t]}};function cl(e){const t=s=>{e.exposed=s||{}};return{attrs:new Proxy(e.attrs,al),slots:e.slots,emit:e.emit,expose:t}}function Ms(e){return e.exposed?e.exposeProxy||(e.exposeProxy=new Proxy(ki(Jo(e.exposed)),{get(t,s){if(s in t)return t[s];if(s in Wt)return Wt[s](e)},has(t,s){return s in t||s in Wt}})):e.proxy}function ul(e){return N(e)&&"__vccOpts"in e}const co=(e,t)=>Qo(e,t,Xt),fl="3.5.39";/** +* @vue/runtime-dom v3.5.39 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/let sn;const Kn=typeof window<"u"&&window.trustedTypes;if(Kn)try{sn=Kn.createPolicy("vue",{createHTML:e=>e})}catch{}const uo=sn?e=>sn.createHTML(e):e=>e,dl="http://www.w3.org/2000/svg",pl="http://www.w3.org/1998/Math/MathML",Qe=typeof document<"u"?document:null,Wn=Qe&&Qe.createElement("template"),hl={insert:(e,t,s)=>{t.insertBefore(e,s||null)},remove:e=>{const t=e.parentNode;t&&t.removeChild(e)},createElement:(e,t,s,n)=>{const i=t==="svg"?Qe.createElementNS(dl,e):t==="mathml"?Qe.createElementNS(pl,e):s?Qe.createElement(e,{is:s}):Qe.createElement(e);return e==="select"&&n&&n.multiple!=null&&i.setAttribute("multiple",n.multiple),i},createText:e=>Qe.createTextNode(e),createComment:e=>Qe.createComment(e),setText:(e,t)=>{e.nodeValue=t},setElementText:(e,t)=>{e.textContent=t},parentNode:e=>e.parentNode,nextSibling:e=>e.nextSibling,querySelector:e=>Qe.querySelector(e),setScopeId(e,t){e.setAttribute(t,"")},insertStaticContent(e,t,s,n,i,o){const r=s?s.previousSibling:t.lastChild;if(i&&(i===o||i.nextSibling))for(;t.insertBefore(i.cloneNode(!0),s),!(i===o||!(i=i.nextSibling)););else{Wn.innerHTML=uo(n==="svg"?`${e}`:n==="mathml"?`${e}`:e);const l=Wn.content;if(n==="svg"||n==="mathml"){const c=l.firstChild;for(;c.firstChild;)l.appendChild(c.firstChild);l.removeChild(c)}t.insertBefore(l,s)}return[r?r.nextSibling:t.firstChild,s?s.previousSibling:t.lastChild]}},gl=Symbol("_vtc");function ml(e,t,s){const n=e[gl];n&&(t=(t?[t,...n]:[...n]).join(" ")),t==null?e.removeAttribute("class"):s?e.setAttribute("class",t):e.className=t}const xs=Symbol("_vod"),fo=Symbol("_vsh"),Vs={name:"show",beforeMount(e,{value:t},{transition:s}){e[xs]=e.style.display==="none"?"":e.style.display,s&&t?s.beforeEnter(e):Lt(e,t)},mounted(e,{value:t},{transition:s}){s&&t&&s.enter(e)},updated(e,{value:t,oldValue:s},{transition:n}){!t!=!s&&(n?t?(n.beforeEnter(e),Lt(e,!0),n.enter(e)):n.leave(e,()=>{Lt(e,!1)}):Lt(e,t))},beforeUnmount(e,{value:t}){Lt(e,t)}};function Lt(e,t){e.style.display=t?e[xs]:"none",e[fo]=!t}const bl=Symbol(""),vl=/(?:^|;)\s*display\s*:/;function yl(e,t,s){const n=e.style,i=ne(s);let o=!1;if(s&&!i){if(t)if(ne(t))for(const r of t.split(";")){const l=r.slice(0,r.indexOf(":")).trim();s[l]==null&&$t(n,l,"")}else for(const r in t)s[r]==null&&$t(n,r,"");for(const r in s){r==="display"&&(o=!0);const l=s[r];l!=null?_l(e,r,!ne(t)&&t?t[r]:void 0,l)||$t(n,r,l):$t(n,r,"")}}else if(i){if(t!==s){const r=n[bl];r&&(s+=";"+r),n.cssText=s,o=vl.test(s)}}else t&&e.removeAttribute("style");xs in e&&(e[xs]=o?n.display:"",e[fo]&&(n.display="none"))}const Gn=/\s*!important$/;function $t(e,t,s){if(j(s))s.forEach(n=>$t(e,t,n));else if(s==null&&(s=""),t.startsWith("--"))e.setProperty(t,s);else{const n=xl(e,t);Gn.test(s)?e.setProperty(xt(n),s.replace(Gn,""),"important"):e[n]=s}}const qn=["Webkit","Moz","ms"],Bs={};function xl(e,t){const s=Bs[t];if(s)return s;let n=Re(t);if(n!=="filter"&&n in e)return Bs[t]=n;n=ci(n);for(let i=0;iKs||(Pl.then(()=>Ks=0),Ks=Date.now());function Al(e,t){const s=n=>{if(!n._vts)n._vts=Date.now();else if(n._vts<=s.attached)return;const i=s.value;if(j(i)){const o=n.stopImmediatePropagation;n.stopImmediatePropagation=()=>{o.call(n),n._stopped=!0};const r=i.slice(),l=[n];for(let c=0;ce.charCodeAt(0)===111&&e.charCodeAt(1)===110&&e.charCodeAt(2)>96&&e.charCodeAt(2)<123,Ol=(e,t,s,n,i,o)=>{const r=i==="svg";t==="class"?ml(e,n,r):t==="style"?yl(e,s,n):_s(t)?ws(t)||Sl(e,t,s,n,o):(t[0]==="."?(t=t.slice(1),!0):t[0]==="^"?(t=t.slice(1),!1):Ml(e,t,n,r))?(Yn(e,t,n),!e.tagName.includes("-")&&(t==="value"||t==="checked"||t==="selected")&&zn(e,t,n,r,o,t!=="value")):e._isVueCE&&(Il(e,t)||e._def.__asyncLoader&&(/[A-Z]/.test(t)||!ne(n)))?Yn(e,Re(t),n,o,t):(t==="true-value"?e._trueValue=n:t==="false-value"&&(e._falseValue=n),zn(e,t,n,r))};function Ml(e,t,s,n){if(n)return!!(t==="innerHTML"||t==="textContent"||t in e&&Zn(t)&&N(s));if(t==="spellcheck"||t==="draggable"||t==="translate"||t==="autocorrect"||t==="sandbox"&&e.tagName==="IFRAME"||t==="form"||t==="list"&&e.tagName==="INPUT"||t==="type"&&e.tagName==="TEXTAREA")return!1;if(t==="width"||t==="height"){const i=e.tagName;if(i==="IMG"||i==="VIDEO"||i==="CANVAS"||i==="SOURCE")return!1}return Zn(t)&&ne(s)?!1:t in e}function Il(e,t){const s=e._def.props;if(!s)return!1;const n=Re(t);return Array.isArray(s)?s.some(i=>Re(i)===n):Object.keys(s).some(i=>Re(i)===n)}const ft=e=>{const t=e.props["onUpdate:modelValue"]||!1;return j(t)?s=>cs(t,s):t};function Rl(e){e.target.composing=!0}function Qn(e){const t=e.target;t.composing&&(t.composing=!1,t.dispatchEvent(new Event("input")))}const Ae=Symbol("_assign");function ei(e,t,s){return t&&(e=e.trim()),s&&(e=Cs(e)),e}const et={created(e,{modifiers:{lazy:t,trim:s,number:n}},i){e[Ae]=ft(i);const o=n||i.props&&i.props.type==="number";it(e,t?"change":"input",r=>{r.target.composing||e[Ae](ei(e.value,s,o))}),(s||o)&&it(e,"change",()=>{e.value=ei(e.value,s,o)}),t||(it(e,"compositionstart",Rl),it(e,"compositionend",Qn),it(e,"change",Qn))},mounted(e,{value:t}){e.value=t??""},beforeUpdate(e,{value:t,oldValue:s,modifiers:{lazy:n,trim:i,number:o}},r){if(e[Ae]=ft(r),e.composing)return;const l=(o||e.type==="number")&&!/^0\d/.test(e.value)?Cs(e.value):e.value,c=t??"";if(l===c)return;const p=e.getRootNode();(p instanceof Document||p instanceof ShadowRoot)&&p.activeElement===e&&e.type!=="range"&&(n&&t===s||i&&e.value.trim()===c)||(e.value=c)}},Dl={deep:!0,created(e,t,s){e[Ae]=ft(s),it(e,"change",()=>{const n=e._modelValue,i=Ot(e),o=e.checked,r=e[Ae];if(j(n)){const l=an(n,i),c=l!==-1;if(o&&!c)r(n.concat(i));else if(!o&&c){const p=[...n];p.splice(l,1),r(p)}}else if(Mt(n)){const l=new Set(n);o?l.add(i):l.delete(i),r(l)}else r(ho(e,o))})},mounted:ti,beforeUpdate(e,t,s){e[Ae]=ft(s),ti(e,t,s)}};function ti(e,{value:t,oldValue:s},n){e._modelValue=t;let i;if(j(t))i=an(t,n.props.value)>-1;else if(Mt(t))i=t.has(n.props.value);else{if(t===s)return;i=ct(t,ho(e,!0))}e.checked!==i&&(e.checked=i)}const Fl={created(e,{value:t},s){e.checked=ct(t,s.props.value),e[Ae]=ft(s),it(e,"change",()=>{e[Ae](Ot(e))})},beforeUpdate(e,{value:t,oldValue:s},n){e[Ae]=ft(n),t!==s&&(e.checked=ct(t,n.props.value))}},po={deep:!0,created(e,{value:t,modifiers:{number:s}},n){const i=Mt(t);it(e,"change",()=>{const o=Array.prototype.filter.call(e.options,r=>r.selected).map(r=>s?Cs(Ot(r)):Ot(r));e[Ae](e.multiple?i?new Set(o):o:o[0]),e._assigning=!0,Oi(()=>{e._assigning=!1})}),e[Ae]=ft(n)},mounted(e,{value:t}){si(e,t)},beforeUpdate(e,t,s){e[Ae]=ft(s)},updated(e,{value:t}){e._assigning||si(e,t)}};function si(e,t){const s=e.multiple,n=j(t);if(!(s&&!n&&!Mt(t))){for(let i=0,o=e.options.length;iString(p)===String(l)):r.selected=an(t,l)>-1}else r.selected=t.has(l);else if(ct(Ot(r),t)){e.selectedIndex!==i&&(e.selectedIndex=i);return}}!s&&e.selectedIndex!==-1&&(e.selectedIndex=-1)}}function Ot(e){return"_value"in e?e._value:e.value}function ho(e,t){const s=t?"_trueValue":"_falseValue";return s in e?e[s]:t}const Ll={created(e,t,s){ls(e,t,s,null,"created")},mounted(e,t,s){ls(e,t,s,null,"mounted")},beforeUpdate(e,t,s,n){ls(e,t,s,n,"beforeUpdate")},updated(e,t,s,n){ls(e,t,s,n,"updated")}};function jl(e,t){switch(e){case"SELECT":return po;case"TEXTAREA":return et;default:switch(t){case"checkbox":return Dl;case"radio":return Fl;default:return et}}}function ls(e,t,s,n,i){const r=jl(e.tagName,s.props&&s.props.type)[i];r&&r(e,t,s,n)}const Ul=["ctrl","shift","alt","meta"],$l={stop:e=>e.stopPropagation(),prevent:e=>e.preventDefault(),self:e=>e.target!==e.currentTarget,ctrl:e=>!e.ctrlKey,shift:e=>!e.shiftKey,alt:e=>!e.altKey,meta:e=>!e.metaKey,left:e=>"button"in e&&e.button!==0,middle:e=>"button"in e&&e.button!==1,right:e=>"button"in e&&e.button!==2,exact:(e,t)=>Ul.some(s=>e[`${s}Key`]&&!t.includes(s))},Nl=(e,t)=>{if(!e)return e;const s=e._withMods||(e._withMods={}),n=t.join(".");return s[n]||(s[n]=((i,...o)=>{for(let r=0;r{const t=Vl().createApp(...e),{mount:s}=t;return t.mount=n=>{const i=Wl(n);if(!i)return;const o=t._component;!N(o)&&!o.render&&!o.template&&(o.template=i.innerHTML),i.nodeType===1&&(i.textContent="");const r=s(i,!1,Kl(i));return i instanceof Element&&(i.removeAttribute("v-cloak"),i.setAttribute("data-v-app","")),r},t});function Kl(e){if(e instanceof SVGElement)return"svg";if(typeof MathMLElement=="function"&&e instanceof MathMLElement)return"mathml"}function Wl(e){return ne(e)?document.querySelector(e):e}const go="pilotvault-theme";function Gl(){try{return localStorage.getItem(go)==="dark"?"dark":"light"}catch{return"light"}}const Zt=z(Gl());function mo(e){Zt.value=e,document.documentElement.setAttribute("data-theme",e);try{localStorage.setItem(go,e)}catch{}}function ii(){mo(Zt.value==="dark"?"light":"dark")}mo(Zt.value);const ql={class:"overflow-hidden rounded-lg border border-subtle bg-card shadow-xs"},Jl={class:"flex items-center justify-between border-b border-subtle px-5 py-4"},zl={class:"text-base font-semibold text-primary"},Yl={class:"pv-eyebrow"},Xl={class:"w-full text-left text-sm"},Zl={class:"border-t border-subtle px-5 py-2.5 font-mono text-xs whitespace-nowrap"},Ql={class:"text-primary"},ea={class:"border-t border-subtle px-5 py-2.5 text-secondary"},Ws={__name:"EndpointTable",props:{title:String,auth:String,endpoints:Array},setup(e){const t={GET:"text-success",POST:"text-brand-text",PATCH:"text-warning",DELETE:"text-danger"};return(s,n)=>(D(),F("div",ql,[y("div",Jl,[y("div",zl,U(e.title),1),y("span",Yl,U(e.auth),1)]),y("table",Xl,[n[0]||(n[0]=y("thead",null,[y("tr",{class:"pv-eyebrow"},[y("th",{class:"px-5 py-2.5 font-medium"},"Endpoint"),y("th",{class:"px-5 py-2.5 font-medium"},"Description")])],-1)),y("tbody",null,[(D(!0),F(re,null,lt(e.endpoints,i=>(D(),F("tr",{key:i.method+i.path,class:"transition-colors hover:bg-sunken"},[y("td",Zl,[y("span",{class:Ee(["font-semibold",t[i.method]])},U(i.method),3),y("span",Ql,U(i.path),1)]),y("td",ea,U(i.desc),1)]))),128))])])]))}},ta={class:"mx-auto flex max-w-5xl flex-col gap-6 px-6 pt-12 pb-16"},sa={class:"flex items-center gap-3"},na={key:0,class:"pv-eyebrow hidden truncate sm:inline"},ia=["title"],oa={key:0,class:"h-4 w-4",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round"},ra={key:1,class:"h-4 w-4",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round"},la={key:0,class:"rounded-lg border border-subtle bg-card px-5 py-10 text-center shadow-sm"},aa={key:1,class:"mx-auto w-full max-w-sm rounded-lg border border-subtle bg-card shadow-sm"},ca={class:"flex flex-col gap-1"},ua={class:"flex flex-col gap-1"},fa={key:0,class:"rounded-sm bg-danger-tint px-3 py-2 text-xs font-medium text-danger"},da=["disabled"],pa={class:"flex gap-1 self-start rounded-lg border border-subtle bg-card p-1 shadow-sm"},ha=["onClick"],ga={class:"flex flex-col gap-6"},ma={class:"rounded-lg border border-subtle bg-card shadow-sm"},ba={class:"flex items-center justify-between border-b border-subtle px-5 py-4"},va={class:"pv-eyebrow"},ya={class:"min-w-0"},xa={class:"text-sm font-semibold text-primary"},_a={key:0,class:"truncate font-mono text-xs text-secondary"},wa={class:"flex items-center justify-between gap-3 border-t border-subtle px-5 py-3.5"},Sa={class:"font-mono text-xs text-secondary"},Ca={class:"flex flex-col gap-6"},Ta={class:"rounded-lg border border-subtle bg-card shadow-sm"},Ea={class:"flex items-center justify-between border-b border-subtle px-5 py-4"},Pa={class:"flex flex-col gap-3 px-5 py-5"},ka={class:"flex flex-col gap-1"},Aa={class:"flex flex-col gap-1"},Oa={class:"flex flex-col gap-1"},Ma=["placeholder"],Ia={key:0,class:"font-mono text-[11px] text-muted"},Ra={key:0},Da={key:1},Fa={key:1,class:"rounded-sm bg-danger-tint px-3 py-2 text-xs font-medium text-danger"},La={key:2,class:"rounded-sm bg-success-tint px-3 py-2 text-xs font-medium text-success"},ja={class:"flex flex-wrap items-center gap-2"},Ua=["disabled"],$a=["disabled"],Na={class:"flex flex-col gap-6"},Ha={class:"rounded-lg border border-subtle bg-card shadow-sm"},Va={class:"flex flex-col"},Ba={key:0,class:"px-5 py-3 text-xs font-medium text-danger"},Ka={key:1,class:"border-b border-subtle bg-sunken px-5 py-2.5 font-mono text-xs text-secondary"},Wa={key:2,class:"px-5 py-6 text-sm text-secondary"},Ga={key:3,class:"flex gap-1 overflow-x-auto overflow-y-hidden border-b border-subtle px-3 pt-1"},qa=["onClick"],Ja={key:4,class:"px-5 py-6 text-sm text-secondary"},za={class:"flex flex-wrap items-center gap-x-3 gap-y-2"},Ya={class:"min-w-0 flex-1"},Xa={class:"flex flex-wrap items-center gap-2"},Za={class:"text-sm font-semibold text-primary"},Qa=["title"],ec={key:0},tc={class:"mt-0.5 font-mono text-xs text-secondary"},sc={key:0,class:"mt-0.5 text-[11px] text-secondary"},nc={key:1,class:"mt-2 flex flex-col gap-1.5"},ic={class:"rounded-sm bg-sunken px-1.5 py-0.5 font-mono text-[10px] text-secondary"},oc={key:0,class:"font-mono text-[10px] text-muted"},rc={key:1,class:"w-full text-[11px] text-secondary sm:w-auto"},lc={key:2,class:"mt-1 truncate font-mono text-[11px] text-muted"},ac={class:"flex shrink-0 items-center gap-2"},cc=["disabled","onClick"],uc=["onClick"],fc=["disabled","onClick"],dc=["disabled","onClick"],pc={key:0,class:"mt-3 flex flex-col gap-2 rounded-md border border-subtle bg-sunken px-4 py-4"},hc={class:"pv-eyebrow"},gc={key:0,class:"text-danger"},mc=["onUpdate:modelValue"],bc=["value"],vc=["onUpdate:modelValue","type","placeholder"],yc={key:2,class:"text-[11px] text-muted"},xc={class:"flex items-center gap-2"},_c=["disabled","onClick"],wc={class:"border-t border-subtle px-5 py-4"},Sc={class:"flex flex-col gap-2 sm:flex-row"},Cc=["disabled"],Tc={key:0,class:"mt-2 text-xs font-medium text-danger"},as="pv_panel_token",Ec={__name:"App",setup(e){const t=z(localStorage.getItem(as)||""),s=z(null),n=z(!1),i=z(!0),o=z({email:"",password:""}),r=z(""),l=z(!1),c=z("overview"),p=[{id:"overview",label:"Overview"},{id:"pocketbase",label:"PocketBase"},{id:"plugins",label:"Plugins"}];async function f(w){try{const d=await fetch("/api/me",{headers:{Authorization:w}});if(!d.ok)return!1;const h=await d.json();return h.role!=="superadmin"?!1:(s.value={email:h.email,role:h.role},!0)}catch{return!1}}async function m(w){t.value=w,localStorage.setItem(as,w),n.value=!0,I(),be(),Xe()}async function P(){r.value="";const w=o.value.email.trim().toLowerCase();if(!w||!o.value.password){r.value="Enter your email and password.";return}l.value=!0;try{const d=await fetch("/api/auth/login",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email:w,password:o.value.password})}),h=await d.json().catch(()=>({}));if(!d.ok||!h.token){r.value="Invalid email or password.";return}if(!await f(h.token)){r.value="Access to this panel is restricted to superadmins.";return}o.value.password="",await m(h.token)}catch{r.value="Cannot reach the API server."}finally{l.value=!1}}function A(){E(),localStorage.removeItem(as),t.value="",s.value=null,n.value=!1}const B=z(null),O=z({url:"",adminEmail:"",adminPassword:""}),H=z(null),K=z(""),$=z(""),W=z(!1),R=z(!1);function G(w){const d={Authorization:t.value};return w&&(d["Content-Type"]="application/json"),d}async function be(){try{const w=await fetch("/api/admin/pb-config",{headers:G()});if(!w.ok)return;const d=await w.json();B.value=d,O.value={url:d.url||"",adminEmail:d.adminEmail||"",adminPassword:""},H.value=d.probe||null}catch{}}async function we(){K.value="",$.value="",R.value=!0;try{const w=await fetch("/api/admin/pb-config/test",{method:"POST",headers:G(!0),body:JSON.stringify(O.value)});H.value=await w.json()}catch{$.value="Could not run the test."}finally{R.value=!1}}async function je(){var w;if(K.value="",$.value="",!O.value.url.trim()){$.value="A PocketBase URL is required.";return}W.value=!0;try{const d=await fetch("/api/admin/pb-config",{method:"PUT",headers:G(!0),body:JSON.stringify(O.value)}),h=await d.json().catch(()=>({}));if(!d.ok){$.value=h.error||"Could not save the connection.";return}B.value=h.config,H.value=((w=h.config)==null?void 0:w.probe)||null,O.value={url:h.config.url,adminEmail:h.config.adminEmail,adminPassword:""},K.value=h.warning||"Connection saved."}catch{$.value="Could not reach the API server."}finally{W.value=!1}}const Se=z([]),ge=z(""),oe=z(""),Ue=z(""),Oe=z({}),ae=z(""),Me=z({name:"",baseURL:"",provider:""}),se=z(""),Y=z(!1),X=[{id:"apis-external",label:"APIs — External"},{id:"drives-external",label:"Drives — External"},{id:"drives-local",label:"Drives — Local"}],Ie=z("apis-external");function _t(w){return w.category||"apis-external"}function Ye(w){return Se.value.filter(d=>_t(d)===w)}const ve=co(()=>Ye(Ie.value)),ss={builtin:"bg-success-tint text-success",external:"bg-warning-tint text-warning"},Is={ok:"bg-success-tint text-success",degraded:"bg-warning-tint text-warning",down:"bg-danger-tint text-danger"};async function Xe(){ge.value="";try{const w=await fetch("/api/admin/plugins",{headers:G()});if(!w.ok){ge.value="Could not load plugins.";return}const d=await w.json();Se.value=d.plugins||[];for(const h of Se.value)h.enabled&&!h.health&&dt(h.name)}catch{ge.value="Could not reach the API server."}}async function dt(w){try{const d=await fetch(`/api/admin/plugins/${encodeURIComponent(w)}/health`,{method:"POST",headers:G()}),h=await d.json().catch(()=>({}));if(d.ok&&h.health){const k=pt(w);k&&(k.health=h.health)}}catch{}}function pt(w){return Se.value.find(d=>d.name===w)}async function It(w){ae.value=w.name,oe.value="";try{const d=await fetch(`/api/admin/plugins/${encodeURIComponent(w.name)}`,{method:"PUT",headers:G(!0),body:JSON.stringify({enabled:!w.enabled})}),h=await d.json().catch(()=>({}));d.ok?h.warning&&(oe.value=`${w.name}: ${h.warning}`):oe.value=h.error||"Could not update the plugin.",await Xe()}finally{ae.value=""}}function ns(w){Ue.value=Ue.value===w.name?"":w.name;const d={...w.config||{}};for(const h of w.configFields||[])(d[h.key]===void 0||d[h.key]==="")&&h.default&&(d[h.key]=h.default);Oe.value=d}async function ht(w){ae.value=w.name,oe.value="";try{const d=await fetch(`/api/admin/plugins/${encodeURIComponent(w.name)}`,{method:"PUT",headers:G(!0),body:JSON.stringify({config:Oe.value})}),h=await d.json().catch(()=>({}));d.ok?(oe.value=h.warning?`${w.name}: ${h.warning}`:"Configuration saved.",Ue.value=""):oe.value=h.error||"Could not save the configuration.",await Xe()}finally{ae.value=""}}async function wn(w){ae.value=w.name,oe.value="";try{const d=await fetch(`/api/admin/plugins/${encodeURIComponent(w.name)}/health`,{method:"POST",headers:G()}),h=await d.json().catch(()=>({}));if(d.ok&&h.health){const k=pt(w.name);k&&(k.health=h.health)}else oe.value=h.error||"Health check failed."}finally{ae.value=""}}async function a(w){if(w.kind==="external"){ae.value=w.name;try{const d=await fetch(`/api/admin/plugins/${encodeURIComponent(w.name)}`,{method:"DELETE",headers:G()});if(!d.ok){const h=await d.json().catch(()=>({}));oe.value=h.error||"Could not remove the plugin."}await Xe()}finally{ae.value=""}}}async function u(){if(se.value="",!Me.value.name.trim()||!Me.value.baseURL.trim()){se.value="Name and base URL are required.";return}Y.value=!0;try{const w=await fetch("/api/admin/plugins",{method:"POST",headers:G(!0),body:JSON.stringify(Me.value)}),d=await w.json().catch(()=>({}));if(!w.ok){se.value=d.error||"Could not register the plugin.";return}Me.value={name:"",baseURL:"",provider:""},oe.value="External plugin registered.",await Xe()}catch{se.value="Could not reach the API server."}finally{Y.value=!1}}const g=z(null),_=z(null),b=z({apiServer:{status:"checking",detail:""},pocketBase:{status:"checking",detail:""},webApp:{status:"checking",detail:""}});let v=null;const T=[{key:"apiServer",label:"API server"},{key:"pocketBase",label:"PocketBase"},{key:"webApp",label:"Web App"}],C={checking:{label:"checking",cls:"bg-sunken text-secondary"},ok:{label:"operational",cls:"bg-success-tint text-success"},down:{label:"unreachable",cls:"bg-danger-tint text-danger"},unreachable:{label:"unreachable",cls:"bg-danger-tint text-danger"}};function S(w){const d=[];return typeof w.latencyMs=="number"&&d.push(w.latencyMs+"ms"),w.httpStatus&&d.push("HTTP "+w.httpStatus),w.url&&d.push(w.url),d.join(" · ")}async function x(){var w,d;try{const h=await fetch("/api/status");if(!h.ok)throw new Error("status "+h.status);const k=await h.json(),ie=k.apiServer||{};_.value=ie.devices??0,b.value={apiServer:{status:"ok",detail:""},pocketBase:{status:((w=k.pocketBase)==null?void 0:w.status)==="ok"?"ok":"down",detail:S(k.pocketBase||{})},webApp:{status:((d=k.webApp)==null?void 0:d.status)==="ok"?"ok":"down",detail:S(k.webApp||{})}}}catch{_.value=null,b.value={apiServer:{status:"unreachable",detail:""},pocketBase:{status:"unreachable",detail:""},webApp:{status:"unreachable",detail:""}}}g.value=new Date}function I(){x(),clearInterval(v),v=setInterval(x,1e4)}function E(){clearInterval(v),v=null}Ni(async()=>{t.value&&await f(t.value)?(n.value=!0,I(),be(),Xe()):t.value&&(localStorage.removeItem(as),t.value=""),i.value=!1}),yn(()=>E());const M=[{method:"POST",path:"/api/auth/login",desc:"Exchange email + password for a session (via PocketBase)"},{method:"GET",path:"/api/auth/validate",desc:"Validate the current session token"},{method:"GET",path:"/api/me",desc:"Caller's id, email, role, and organization from their token"},{method:"GET",path:"/api/preferences",desc:"Read the caller's saved settings blob"},{method:"PUT",path:"/api/preferences",desc:"Persist the caller's settings onto their user record"},{method:"GET",path:"/api/devices",desc:"List connected devices and last-known state"},{method:"GET",path:"/api/devices/{id}/track",desc:"GPS track history for a device"},{method:"POST",path:"/api/devices/{id}/command",desc:"Send a command down to a device"},{method:"DELETE",path:"/api/devices/{id}",desc:"Forget a device's stored state"},{method:"GET",path:"/ws/ui",desc:"Live telemetry stream (WebSocket)"}],L=[{method:"GET",path:"/api/users",desc:"List users (admin: own org · superadmin: all)"},{method:"POST",path:"/api/users",desc:"Create a user {email, password, role, organization?}"},{method:"PATCH",path:"/api/users/{id}",desc:"Edit a user (role/org changes are scope-checked)"},{method:"DELETE",path:"/api/users/{id}",desc:"Delete a user (not self; admins in-org only)"},{method:"GET",path:"/api/orgs",desc:"List organizations (admin: own · superadmin: all)"},{method:"POST",path:"/api/orgs",desc:"Create an organization {name} (superadmin)"},{method:"PATCH",path:"/api/orgs/{id}",desc:"Rename an organization (superadmin)"},{method:"DELETE",path:"/api/orgs/{id}",desc:"Delete an empty organization (superadmin)"},{method:"GET",path:"/api/admin/pb-config",desc:"Read the PocketBase connection + live probe (superadmin)"},{method:"POST",path:"/api/admin/pb-config/test",desc:"Test a candidate connection without applying (superadmin)"},{method:"PUT",path:"/api/admin/pb-config",desc:"Update + persist the PocketBase connection (superadmin)"},{method:"GET",path:"/api/admin/plugins",desc:"List plugins + state + last health (superadmin)"},{method:"POST",path:"/api/admin/plugins",desc:"Register an external plugin {name, baseURL} (superadmin)"},{method:"PUT",path:"/api/admin/plugins/{name}",desc:"Enable/disable + configure a plugin (superadmin)"},{method:"DELETE",path:"/api/admin/plugins/{name}",desc:"Remove an external plugin (superadmin)"},{method:"POST",path:"/api/admin/plugins/{name}/health",desc:"Run a plugin health check (superadmin)"}],V=[{method:"GET",path:"/ws/device?id={id}",desc:"Device telemetry uplink (WebSocket)"},{method:"POST",path:"/api/telemetry?id={id}",desc:"Push a single telemetry event over HTTP"},{method:"GET",path:"/healthz",desc:"Readiness probe"}];return(w,d)=>(D(),F("div",ta,[y("div",sa,[d[12]||(d[12]=el('
PilotVault
API server
',3)),n.value&&s.value?(D(),F("span",na,U(s.value.email),1)):Q("",!0),n.value?(D(),F("button",{key:1,class:"pv-btn-sec pv-btn-sm",onClick:A},"Sign out")):Q("",!0),y("button",{class:"pv-btn-sec pv-btn-sm",title:jt(Zt)==="dark"?"Switch to light theme":"Switch to dark theme",onClick:d[0]||(d[0]=(...h)=>jt(ii)&&jt(ii)(...h))},[jt(Zt)==="dark"?(D(),F("svg",oa,[...d[9]||(d[9]=[y("circle",{cx:"12",cy:"12",r:"4"},null,-1),y("path",{d:"M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"},null,-1)])])):(D(),F("svg",ra,[...d[10]||(d[10]=[y("path",{d:"M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"},null,-1)])])),d[11]||(d[11]=Te(" Theme ",-1))],8,ia)]),i.value?(D(),F("div",la,[...d[13]||(d[13]=[y("span",{class:"pv-eyebrow"},"Checking session…",-1)])])):n.value?(D(),F(re,{key:2},[y("div",pa,[(D(),F(re,null,lt(p,h=>y("button",{key:h.id,class:Ee(["rounded-md px-3.5 py-1.5 text-sm font-semibold transition",c.value===h.id?"bg-brand text-on-brand":"text-secondary hover:text-primary"]),onClick:k=>c.value=h.id},U(h.label),11,ha)),64))]),Ce(y("div",ga,[y("div",ma,[y("div",ba,[d[17]||(d[17]=y("div",{class:"text-base font-semibold text-primary"},"Status",-1)),y("span",va,U(g.value?"checked "+g.value.toLocaleTimeString():"—"),1)]),y("div",null,[(D(),F(re,null,lt(T,h=>y("div",{key:h.key,class:"flex items-center justify-between gap-3 border-t border-subtle px-5 py-3.5 first:border-t-0"},[y("div",ya,[y("div",xa,U(h.label),1),b.value[h.key].detail?(D(),F("div",_a,U(b.value[h.key].detail),1)):Q("",!0)]),y("span",{class:Ee(["inline-flex shrink-0 items-center gap-1.5 rounded-sm px-2.5 py-1 font-mono text-xs font-medium",C[b.value[h.key].status].cls])},[d[18]||(d[18]=y("span",{class:"h-1.5 w-1.5 rounded-full bg-current"},null,-1)),Te(" "+U(C[b.value[h.key].status].label),1)],2)])),64)),y("div",wa,[d[19]||(d[19]=y("div",{class:"text-sm font-semibold text-primary"},"Devices",-1)),y("span",Sa,U(_.value===null?"—":_.value+" online"),1)])])]),_e(Ws,{title:"Client API",auth:"PocketBase session",endpoints:M}),_e(Ws,{title:"Management API",auth:"Admin · superadmin",endpoints:L}),_e(Ws,{title:"Device API",auth:"Device uplink",endpoints:V})],512),[[Vs,c.value==="overview"]]),Ce(y("div",Ca,[y("div",Ta,[y("div",Ea,[d[21]||(d[21]=y("div",null,[y("div",{class:"text-base font-semibold text-primary"},"PocketBase connection"),y("span",{class:"pv-eyebrow"},"Settings")],-1)),H.value?(D(),F("span",{key:0,class:Ee(["inline-flex shrink-0 items-center gap-1.5 rounded-sm px-2.5 py-1 font-mono text-xs font-medium",H.value.reachable?H.value.superuser?"bg-success-tint text-success":"bg-warning-tint text-warning":"bg-danger-tint text-danger"])},[d[20]||(d[20]=y("span",{class:"h-1.5 w-1.5 rounded-full bg-current"},null,-1)),Te(" "+U(H.value.reachable?H.value.superuser?"connected":"reachable":"unreachable"),1)],2)):Q("",!0)]),y("div",Pa,[y("label",ka,[d[22]||(d[22]=y("span",{class:"pv-eyebrow"},"PocketBase URL",-1)),Ce(y("input",{"onUpdate:modelValue":d[3]||(d[3]=h=>O.value.url=h),class:"pv-input",placeholder:"http://10.2.1.10:8026",spellcheck:"false"},null,512),[[et,O.value.url]])]),y("label",Aa,[d[23]||(d[23]=y("span",{class:"pv-eyebrow"},"Service account email",-1)),Ce(y("input",{"onUpdate:modelValue":d[4]||(d[4]=h=>O.value.adminEmail=h),class:"pv-input",placeholder:"admin@pilotvault.local",autocomplete:"off",spellcheck:"false"},null,512),[[et,O.value.adminEmail]])]),y("label",Oa,[d[24]||(d[24]=y("span",{class:"pv-eyebrow"},"Service account password",-1)),Ce(y("input",{"onUpdate:modelValue":d[5]||(d[5]=h=>O.value.adminPassword=h),type:"password",class:"pv-input",autocomplete:"new-password",placeholder:B.value&&B.value.adminConfigured?"leave blank to keep current":"set a password"},null,8,Ma),[[et,O.value.adminPassword]])]),H.value?(D(),F("div",Ia,[Te(" health: "+U(H.value.reachable?"ok":"down"),1),H.value.latencyMs?(D(),F("span",Ra," · "+U(H.value.latencyMs)+"ms",1)):Q("",!0),Te(" · superuser auth: "+U(H.value.superuser?"ok":"—"),1),H.value.detail?(D(),F("span",Da," · "+U(H.value.detail),1)):Q("",!0)])):Q("",!0),$.value?(D(),F("p",Fa,U($.value),1)):K.value?(D(),F("p",La,U(K.value),1)):Q("",!0),y("div",ja,[y("button",{class:"pv-btn",disabled:W.value,onClick:je},U(W.value?"Saving…":"Save connection"),9,Ua),y("button",{class:"pv-btn-sec",disabled:R.value,onClick:we},U(R.value?"Testing…":"Test connection"),9,$a)]),d[25]||(d[25]=y("p",{class:"text-[11px] text-muted"}," The service account is used only for user & organization management. Changing the URL repoints the whole API Server at a new PocketBase and may sign you out. ",-1))])])],512),[[Vs,c.value==="pocketbase"]]),Ce(y("div",Na,[y("div",Ha,[y("div",{class:"flex items-center justify-between border-b border-subtle px-5 py-4"},[d[26]||(d[26]=y("div",null,[y("div",{class:"text-base font-semibold text-primary"},"Plugins"),y("span",{class:"pv-eyebrow"},"External integrations")],-1)),y("button",{class:"pv-btn-sec pv-btn-sm",onClick:Xe},"Refresh")]),y("div",Va,[ge.value?(D(),F("p",Ba,U(ge.value),1)):Q("",!0),oe.value?(D(),F("p",Ka,U(oe.value),1)):Q("",!0),!Se.value.length&&!ge.value?(D(),F("p",Wa,"No plugins registered yet.")):Q("",!0),Se.value.length?(D(),F("div",Ga,[(D(),F(re,null,lt(X,h=>y("button",{key:h.id,class:Ee(["-mb-px whitespace-nowrap border-b-2 px-3 py-2 text-sm font-semibold transition",Ie.value===h.id?"border-brand text-primary":"border-transparent text-secondary hover:text-primary"]),onClick:k=>Ie.value=h.id},U(h.label),11,qa)),64))])):Q("",!0),Se.value.length&&!ve.value.length?(D(),F("p",Ja,"No plugins in this category.")):Q("",!0),(D(!0),F(re,null,lt(ve.value,h=>(D(),F("div",{key:h.name,class:"border-t border-subtle px-5 py-4 first:border-t-0"},[y("div",za,[y("div",Ya,[y("div",Xa,[y("span",Za,U(h.name),1),y("span",{class:Ee(["rounded-sm px-1.5 py-0.5 font-mono text-[10px] font-medium uppercase tracking-wider",ss[h.kind]])},U(h.kind),3),h.health?(D(),F("span",{key:0,class:Ee(["inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 font-mono text-[10px] font-medium",Is[h.health.status]]),title:h.health.detail||""},[d[27]||(d[27]=y("span",{class:"h-1 w-1 rounded-full bg-current"},null,-1)),Te(U(h.health.status),1),h.health.latencyMs?(D(),F("span",ec," · "+U(h.health.latencyMs)+"ms",1)):Q("",!0)],10,Qa)):Q("",!0)]),y("div",tc,U(h.provider)+" · v"+U(h.version)+" · "+U(h.authType),1),h.health&&h.health.detail?(D(),F("div",sc,U(h.health.detail),1)):Q("",!0),h.capabilities&&h.capabilities.length?(D(),F("div",nc,[d[28]||(d[28]=y("div",{class:"pv-eyebrow"},"Capabilities",-1)),(D(!0),F(re,null,lt(h.capabilities,k=>(D(),F("div",{key:k.id,class:"flex flex-wrap items-baseline gap-x-2 gap-y-0.5"},[y("span",ic,U(k.id),1),k.method||k.endpoint?(D(),F("span",oc,U(k.method)+" "+U(k.endpoint),1)):Q("",!0),k.description?(D(),F("span",rc,U(k.description),1)):Q("",!0)]))),128))])):Q("",!0),h.baseURL?(D(),F("div",lc,U(h.baseURL),1)):Q("",!0)]),y("div",ac,[y("button",{class:Ee(["pv-btn-sec pv-btn-sm",h.enabled?"!border-transparent !bg-success-tint !text-success":""]),disabled:ae.value===h.name,onClick:k=>It(h)},[y("span",{class:Ee(["h-1.5 w-1.5 rounded-full",h.enabled?"bg-success":"bg-muted"])},null,2),Te(" "+U(h.enabled?"Enabled":"Disabled"),1)],10,cc),h.configFields&&h.configFields.length?(D(),F("button",{key:0,class:"pv-btn-sec pv-btn-sm",onClick:k=>ns(h)},"Configure",8,uc)):Q("",!0),y("button",{class:"pv-btn-sec pv-btn-sm",disabled:ae.value===h.name,onClick:k=>wn(h)},"Check",8,fc),h.kind==="external"?(D(),F("button",{key:1,class:"pv-btn-sec pv-btn-sm !text-danger",disabled:ae.value===h.name,onClick:k=>a(h)},"Remove",8,dc)):Q("",!0)])]),Ue.value===h.name?(D(),F("div",pc,[(D(!0),F(re,null,lt(h.configFields,k=>(D(),F("label",{key:k.key,class:"flex flex-col gap-1"},[y("span",hc,[Te(U(k.label),1),k.required?(D(),F("span",gc," *")):Q("",!0)]),k.type==="select"?Ce((D(),F("select",{key:0,"onUpdate:modelValue":ie=>Oe.value[k.key]=ie,class:"pv-input"},[(D(!0),F(re,null,lt(k.options,ie=>(D(),F("option",{key:ie.value,value:ie.value},U(ie.label),9,bc))),128))],8,mc)),[[po,Oe.value[k.key]]]):Ce((D(),F("input",{key:1,"onUpdate:modelValue":ie=>Oe.value[k.key]=ie,type:k.secret||k.type==="password"?"password":k.type==="number"?"number":"text",class:"pv-input",autocomplete:"off",placeholder:k.help||""},null,8,vc)),[[Ll,Oe.value[k.key]]]),k.help?(D(),F("span",yc,U(k.help),1)):Q("",!0)]))),128)),y("div",xc,[y("button",{class:"pv-btn pv-btn-sm",disabled:ae.value===h.name,onClick:k=>ht(h)},"Save configuration",8,_c),y("button",{class:"pv-btn-sec pv-btn-sm",onClick:d[6]||(d[6]=k=>Ue.value="")},"Cancel")])])):Q("",!0)]))),128)),y("div",wc,[d[29]||(d[29]=y("div",{class:"pv-eyebrow mb-2"},"Register external plugin",-1)),y("div",Sc,[Ce(y("input",{"onUpdate:modelValue":d[7]||(d[7]=h=>Me.value.name=h),class:"pv-input sm:w-40",placeholder:"name",autocomplete:"off",spellcheck:"false"},null,512),[[et,Me.value.name]]),Ce(y("input",{"onUpdate:modelValue":d[8]||(d[8]=h=>Me.value.baseURL=h),class:"pv-input flex-1",placeholder:"https://plugin.example.com",autocomplete:"off",spellcheck:"false"},null,512),[[et,Me.value.baseURL]]),y("button",{class:"pv-btn",disabled:Y.value,onClick:u},U(Y.value?"Adding…":"Add"),9,Cc)]),se.value?(D(),F("p",Tc,U(se.value),1)):Q("",!0),d[30]||(d[30]=y("p",{class:"mt-2 text-[11px] text-muted"},[Te(" A remote service that answers "),y("span",{class:"font-mono"},"GET /health"),Te(" and "),y("span",{class:"font-mono"},"GET /manifest"),Te(" — added at runtime, no rebuild. ")],-1))])])])],512),[[Vs,c.value==="plugins"]])],64)):(D(),F("div",aa,[d[16]||(d[16]=y("div",{class:"border-b border-subtle px-5 py-4"},[y("div",{class:"text-base font-semibold text-primary"},"Sign in"),y("span",{class:"pv-eyebrow"},"Superadmin access only")],-1)),y("form",{class:"flex flex-col gap-3 px-5 py-5",onSubmit:Nl(P,["prevent"])},[y("label",ca,[d[14]||(d[14]=y("span",{class:"pv-eyebrow"},"Email",-1)),Ce(y("input",{"onUpdate:modelValue":d[1]||(d[1]=h=>o.value.email=h),type:"email",autocomplete:"username",class:"pv-input",placeholder:"superadmin@pilotvault.local"},null,512),[[et,o.value.email]])]),y("label",ua,[d[15]||(d[15]=y("span",{class:"pv-eyebrow"},"Password",-1)),Ce(y("input",{"onUpdate:modelValue":d[2]||(d[2]=h=>o.value.password=h),type:"password",autocomplete:"current-password",class:"pv-input",placeholder:"••••••••"},null,512),[[et,o.value.password]])]),r.value?(D(),F("p",fa,U(r.value),1)):Q("",!0),y("button",{type:"submit",class:"pv-btn mt-1",disabled:l.value},U(l.value?"Signing in…":"Sign in"),9,da)],32)])),d[31]||(d[31]=y("p",{class:"text-center font-mono text-[11px] text-muted"}," PilotVault — live drone telemetry, command & control. ",-1))]))}};Bl(Ec).mount("#app"); diff --git a/API Server/internal/api/dist/favicon.svg b/API Server/internal/api/dist/favicon.svg new file mode 100644 index 0000000..65cdb50 --- /dev/null +++ b/API Server/internal/api/dist/favicon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/API Server/internal/api/dist/index.html b/API Server/internal/api/dist/index.html new file mode 100644 index 0000000..db6dad4 --- /dev/null +++ b/API Server/internal/api/dist/index.html @@ -0,0 +1,15 @@ + + + + + + + + PilotVault · API Server + + + + +
+ + diff --git a/API Server/internal/api/health.go b/API Server/internal/api/health.go new file mode 100644 index 0000000..e4ec074 --- /dev/null +++ b/API Server/internal/api/health.go @@ -0,0 +1,15 @@ +package api + +import "net/http" + +// handleHealth reports server readiness plus device counts. "devices" is the +// number of devices connected right now; "known" also includes offline devices +// whose last-known state is still cached. +func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{ + "status": "ok", + "service": "pilotvault-api", + "devices": s.hub.OnlineCount(), + "known": len(s.hub.Snapshot()), + }) +} diff --git a/API Server/internal/api/integrations.go b/API Server/internal/api/integrations.go new file mode 100644 index 0000000..b8ce1ec --- /dev/null +++ b/API Server/internal/api/integrations.go @@ -0,0 +1,534 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/url" + "strings" +) + +// Integrations exposes the OpenSky plugin's settings to end users under a +// three-layer cascade (superadmin/global → organization → user). Each of the +// four settings resolves independently, top wins, and a blank field falls +// through to the layer below: +// +// - global (L1): the plugin's config in plugins.json, set in the API Server panel. +// - org (L2): pluginSettings on the caller's organization record (org admins). +// - user (L3): pluginSettings on the caller's own user record. +// +// The OAuth2 client id + secret resolve as a *pair* from the highest layer that +// supplies a client id, so credential halves are never mixed across layers. +// Enablement is strictly per-user (L3) and gated by the global master switch. +// +// Secrets (and inherited client ids) are never returned to a lower-privileged +// client: the effective config is resolved server-side and only masked values +// leave the API. Live probes run server-side against the resolved config. + +const ( + openSkyPlugin = "opensky" + openSkySecretMask = "••••••••" +) + +// osConfig is one layer's OpenSky settings. +type osConfig struct { + ClientID string `json:"clientId"` + ClientSecret string `json:"clientSecret"` + Plan string `json:"plan"` + Bbox string `json:"bbox"` +} + +// osStored is what we persist per user/org under pluginSettings.opensky. +type osStored struct { + Config osConfig `json:"config"` + // Enabled is the personal per-user opt-in (user layer). Default false. + Enabled bool `json:"enabled"` + // Disabled is the organization layer's off switch, stored inverted so that + // absent == enabled: existing org records (which carry a legacy enabled:false + // from earlier config saves) therefore read as enabled, avoiding a regression. + // Only meaningful on the org record; ignored on user records. + Disabled bool `json:"disabled,omitempty"` +} + +// osSettingsDoc is the pluginSettings JSON shape (only opensky today). +type osSettingsDoc struct { + OpenSky osStored `json:"opensky"` +} + +// osFieldView is one field's resolved state for the UI. +type osFieldView struct { + Effective string `json:"effective"` // resolved value in force (secrets/inherited creds masked) + Own string `json:"own"` // the caller's own editable-layer value (secret masked) + Source string `json:"source"` // global | org | user | unset + Locked bool `json:"locked"` // set above the caller's editable layer +} + +// osResolution is the fully-resolved OpenSky state for one caller. It captures the +// effective cascade plus both editable layers (personal + organization), so an org +// admin can manage each independently — their own settings as a user, and the +// organization-wide settings that override every user's. +type osResolution struct { + eff osConfig // effective (unmasked) — used only server-side (probes) + userOwn osConfig // caller's personal (L3) values (unmasked) + orgOwn osConfig // organization (L2) values (unmasked) + source map[string]string // field -> layer name (global|org|user|unset) + isSuper bool // superadmin: manages the global layer in the panel + canOrg bool // caller may edit the organization layer (org admin) + available bool // global master switch + orgEnabled bool // org master switch (default true; gates the org's users) + allowAnon bool // global anonymous policy + enabled bool // caller's personal enable flag +} + +// resolveOpenSky computes the cascade for a caller. userRaw is the caller's +// pluginSettings blob (from their auth-refresh record). +func (s *Server) resolveOpenSky(ctx context.Context, who *callerIdentity, userRaw json.RawMessage) osResolution { + g, masterEnabled, _ := s.plugins.RawConfig(openSkyPlugin) + gc := osConfig{ClientID: g["clientId"], ClientSecret: g["clientSecret"], Plan: g["plan"], Bbox: g["bbox"]} + allowAnon := !strings.EqualFold(strings.TrimSpace(g["allowAnonymous"]), "false") + + var oStored osStored + if who.OrgID != "" { + oStored, _ = s.orgOpenSky(ctx, who.OrgID) + } + oc := oStored.Config + + var uStored osStored + if len(userRaw) > 0 { + var d osSettingsDoc + _ = json.Unmarshal(userRaw, &d) + uStored = d.OpenSky + } + uc := uStored.Config + + res := osResolution{ + source: map[string]string{}, + userOwn: uc, + orgOwn: oc, + isSuper: who.isSuperadmin(), + // An org admin may edit the organization layer in addition to their own + // personal layer. Requires the service account (org writes go through it); + // without it the org layer is invisible to the cascade anyway. + canOrg: who.isManager() && !who.isSuperadmin() && who.OrgID != "" && s.admin.configured(), + available: masterEnabled, + // Org gate: enabled by default, off only when the org explicitly disabled it. + orgEnabled: !oStored.Disabled, + allowAnon: allowAnon, + enabled: uStored.Enabled, + } + + // Ordered layers, top (highest priority) first. + type layer struct { + name string + c osConfig + } + layers := []layer{{"global", gc}} + if who.OrgID != "" { + layers = append(layers, layer{"org", oc}) + } + layers = append(layers, layer{"user", uc}) + + pick := func(get func(osConfig) string) (val, src string) { + for _, l := range layers { + if v := strings.TrimSpace(get(l.c)); v != "" { + return v, l.name + } + } + return "", "unset" + } + res.eff.Plan, res.source["plan"] = pick(func(c osConfig) string { return c.Plan }) + res.eff.Bbox, res.source["bbox"] = pick(func(c osConfig) string { return c.Bbox }) + + // Credentials resolve as a pair from the highest layer with a client id. + credSrc := "unset" + for _, l := range layers { + if id := strings.TrimSpace(l.c.ClientID); id != "" { + res.eff.ClientID, res.eff.ClientSecret, credSrc = id, l.c.ClientSecret, l.name + break + } + } + res.source["clientId"] = credSrc + res.source["clientSecret"] = credSrc + return res +} + +// layerRank orders the cascade layers; a higher number is lower priority. +var layerRank = map[string]int{"global": 1, "org": 2, "user": 3} + +// lockedFor reports whether a field whose value comes from source is locked for a +// caller whose editable layer is editable (i.e. the value is set above them). +func lockedFor(source, editable string) bool { + if editable == "none" { + return true // superadmin edits the global layer in the panel, not here + } + sr, ok := layerRank[source] + if !ok { + return false // unset — the caller may be the first to set it + } + return sr < layerRank[editable] +} + +// maskPresent returns the secret mask when v is non-empty, else "". +func maskPresent(v string) string { + if strings.TrimSpace(v) != "" { + return openSkySecretMask + } + return "" +} + +// orgOpenSky reads an organization's stored OpenSky settings (config + the org +// gate) and its raw pluginSettings blob via the service account. Best effort: zero +// values on any miss so callers can proceed as if the org layer were empty. +func (s *Server) orgOpenSky(ctx context.Context, orgID string) (osStored, json.RawMessage) { + if orgID == "" || !s.admin.configured() { + return osStored{}, nil + } + data, status, err := s.admin.do(ctx, http.MethodGet, + "/api/collections/organizations/records/"+url.PathEscape(orgID)+"?fields=pluginSettings", nil) + if err != nil || status != http.StatusOK { + return osStored{}, nil + } + var rec struct { + PluginSettings json.RawMessage `json:"pluginSettings"` + } + _ = json.Unmarshal(data, &rec) + var doc osSettingsDoc + if len(rec.PluginSettings) > 0 { + _ = json.Unmarshal(rec.PluginSettings, &doc) + } + return doc.OpenSky, rec.PluginSettings +} + +// mergeOpenSky applies a mutation to the opensky entry of a pluginSettings blob, +// preserving any other plugin keys, and returns the new blob. +func mergeOpenSky(existing json.RawMessage, apply func(*osStored)) json.RawMessage { + doc := map[string]json.RawMessage{} + if len(existing) > 0 { + _ = json.Unmarshal(existing, &doc) + } + if doc == nil { + doc = map[string]json.RawMessage{} // existing was JSON null + } + var os osStored + if raw, ok := doc["opensky"]; ok { + _ = json.Unmarshal(raw, &os) + } + apply(&os) + b, _ := json.Marshal(os) + doc["opensky"] = b + out, _ := json.Marshal(doc) + return out +} + +// callerFromRecord builds an identity from an auth-refresh record. +func callerFromRecord(rec *pbAuthResp) *callerIdentity { + role := unquote(rec.Record["role"]) + if role == "" { + role = roleUser + } + return &callerIdentity{ + ID: unquote(rec.Record["id"]), + Email: unquote(rec.Record["email"]), + Role: role, + OrgID: unquote(rec.Record["organization"]), + } +} + +// GET /api/integrations/opensky — resolved OpenSky view for the caller. +func (s *Server) handleGetOpenSky(w http.ResponseWriter, r *http.Request) { + token := r.Header.Get("Authorization") + if token == "" { + writeError(w, http.StatusUnauthorized, "missing token") + return + } + rec, status, err := s.pbAuthRefresh(r.Context(), token) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()}) + return + } + if status != http.StatusOK || rec == nil { + writeError(w, http.StatusUnauthorized, "invalid or expired token") + return + } + who := callerFromRecord(rec) + res := s.resolveOpenSky(r.Context(), who, rec.Record["pluginSettings"]) + writeJSON(w, http.StatusOK, s.openSkyView(who, res)) +} + +// osScopeView builds the masked field set for one editable scope. editable is the +// layer the caller edits in this scope ("user" | "org" | "none"); a field is locked +// when its effective value is set above that layer. +func (s *Server) osScopeView(res osResolution, editable string) map[string]any { + own := res.userOwn + if editable == "org" { + own = res.orgOwn + } + field := func(key, eff, ownv string, secret bool) osFieldView { + src := res.source[key] + locked := lockedFor(src, editable) + fv := osFieldView{Source: src, Locked: locked} + switch { + case secret: + // Never expose a secret; show only presence. + fv.Effective, fv.Own = maskPresent(eff), maskPresent(ownv) + case key == "clientId" && locked: + // Inherited client id — hide the concrete value from a lower layer. + fv.Effective, fv.Own = maskPresent(eff), maskPresent(ownv) + default: + fv.Effective, fv.Own = eff, ownv + } + return fv + } + return map[string]any{ + "editableLayer": editable, + "fields": map[string]osFieldView{ + "clientId": field("clientId", res.eff.ClientID, own.ClientID, false), + "clientSecret": field("clientSecret", res.eff.ClientSecret, own.ClientSecret, true), + "plan": field("plan", res.eff.Plan, own.Plan, false), + "bbox": field("bbox", res.eff.Bbox, own.Bbox, false), + }, + } +} + +// openSkyView builds the masked, client-safe response body from a resolution. It +// exposes a "user" scope for everyone plus, for org admins, an "org" scope — each +// with its own locked-field state — so the UI can present the two independently. +func (s *Server) openSkyView(who *callerIdentity, res osResolution) map[string]any { + out := map[string]any{ + "available": res.available, + "orgEnabled": res.orgEnabled, + "allowAnonymous": res.allowAnon, + "enabled": res.enabled, + "role": who.Role, + "orgId": who.OrgID, + "canEditOrg": res.canOrg, + "isSuperadmin": res.isSuper, + } + if res.isSuper { + // Superadmin manages the global layer in the panel; here it is read-only. + out["editableLayer"] = "none" + out["scopes"] = map[string]any{"user": s.osScopeView(res, "none")} + return out + } + scopes := map[string]any{"user": s.osScopeView(res, "user")} + if res.canOrg { + scopes["org"] = s.osScopeView(res, "org") + } + out["scopes"] = scopes + return out +} + +// PUT /api/integrations/opensky — save the caller's editable layer. Body: +// {enabled?: bool, config?: {clientId, clientSecret, plan, bbox}}. Fields locked +// above the caller are ignored; a client secret left at the mask is preserved. +func (s *Server) handlePutOpenSky(w http.ResponseWriter, r *http.Request) { + token := r.Header.Get("Authorization") + if token == "" { + writeError(w, http.StatusUnauthorized, "missing token") + return + } + var body struct { + Enabled *bool `json:"enabled"` + Scope string `json:"scope"` // "user" (default) | "org" (admins only) + Config map[string]string `json:"config"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid json") + return + } + + rec, status, err := s.pbAuthRefresh(r.Context(), token) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()}) + return + } + if status != http.StatusOK || rec == nil { + writeError(w, http.StatusUnauthorized, "invalid or expired token") + return + } + who := callerFromRecord(rec) + userRaw := rec.Record["pluginSettings"] + res := s.resolveOpenSky(r.Context(), who, userRaw) + + // Resolve which layer this write targets. Everyone edits their own personal + // (user) layer by default; an org admin may target the organization layer by + // asking for scope "org". Superadmins are read-only here (they manage global + // in the panel) and may only toggle their personal enable flag. + editable := "user" + switch { + case res.isSuper: + editable = "none" + case strings.EqualFold(strings.TrimSpace(body.Scope), "org"): + if !res.canOrg { + writeError(w, http.StatusForbidden, "only an organization admin can edit organization settings") + return + } + editable = "org" + } + + // Build the new target-layer config from its current own values, overlaying + // only fields the caller is allowed to change in this scope. + newOwn := res.userOwn + if editable == "org" { + newOwn = res.orgOwn + } + applyField := func(key string, set func(*osConfig, string)) { + v, ok := body.Config[key] + if !ok || lockedFor(res.source[key], editable) { + return + } + if key == "clientSecret" && v == openSkySecretMask { + return // keep current secret + } + set(&newOwn, strings.TrimSpace(v)) + } + applyField("plan", func(c *osConfig, v string) { c.Plan = v }) + applyField("bbox", func(c *osConfig, v string) { c.Bbox = v }) + applyField("clientId", func(c *osConfig, v string) { c.ClientID = v }) + // Secret is not trimmed (may legitimately contain edge whitespace? no — trim + // for consistency with the panel's Upsert, which TrimSpaces all values). + applyField("clientSecret", func(c *osConfig, v string) { c.ClientSecret = v }) + + // Persist the organization layer (admins) via the service account. + if editable == "org" { + if who.OrgID == "" { + writeError(w, http.StatusForbidden, "your account is not attached to an organization") + return + } + if !s.admin.configured() { + writeError(w, http.StatusServiceUnavailable, "organization settings not configured on the server") + return + } + _, orgRaw := s.orgOpenSky(r.Context(), who.OrgID) + newDoc := mergeOpenSky(orgRaw, func(os *osStored) { + os.Config = newOwn + // In the org scope the enable flag is the org master switch, stored + // inverted (disabled) so absent means enabled. + if body.Enabled != nil { + os.Disabled = !*body.Enabled + } + }) + _, st, err := s.admin.do(r.Context(), http.MethodPatch, + "/api/collections/organizations/records/"+url.PathEscape(who.OrgID), + map[string]json.RawMessage{"pluginSettings": newDoc}) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()}) + return + } + if st != http.StatusOK { + writeError(w, http.StatusBadGateway, "could not save organization settings") + return + } + } + + // Persist the user record: the personal enable flag lives here (user/superadmin + // scope — in the org scope it targets the org gate instead), and so does the + // personal config layer when this write targets the user scope. + personalEnable := body.Enabled != nil && editable != "org" + if personalEnable || editable == "user" { + newDoc := mergeOpenSky(userRaw, func(os *osStored) { + if personalEnable { + os.Enabled = *body.Enabled + } + if editable == "user" { + os.Config = newOwn + } + }) + if code, err := s.patchUserPluginSettings(r.Context(), token, who.ID, newDoc); err != nil { + writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()}) + return + } else if code != http.StatusOK { + writeError(w, http.StatusBadGateway, "could not save user settings") + return + } + } + + // Re-resolve and return the fresh view. + fresh, st, err := s.pbAuthRefresh(r.Context(), token) + if err != nil || st != http.StatusOK || fresh == nil { + // The writes succeeded; just report success minimally. + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) + return + } + res2 := s.resolveOpenSky(r.Context(), who, fresh.Record["pluginSettings"]) + writeJSON(w, http.StatusOK, s.openSkyView(who, res2)) +} + +// patchUserPluginSettings writes the pluginSettings blob onto the caller's own +// user record using their token (PocketBase authorises self-writes). +func (s *Server) patchUserPluginSettings(ctx context.Context, token, id string, doc json.RawMessage) (int, error) { + if id == "" { + return 0, io.EOF // treated as a transport-ish failure by the caller + } + patch, _ := json.Marshal(map[string]json.RawMessage{"pluginSettings": doc}) + req, _ := http.NewRequestWithContext(ctx, http.MethodPatch, + s.auth.url()+"/api/collections/users/records/"+url.PathEscape(id), bytes.NewReader(patch)) + req.Header.Set("Authorization", token) + req.Header.Set("Content-Type", "application/json") + resp, err := s.auth.client.Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + _, _ = io.Copy(io.Discard, resp.Body) + return resp.StatusCode, nil +} + +// POST /api/integrations/opensky/health — live probe using the caller's resolved +// config. Never returns secrets. +func (s *Server) handleOpenSkyHealth(w http.ResponseWriter, r *http.Request) { + token := r.Header.Get("Authorization") + if token == "" { + writeError(w, http.StatusUnauthorized, "missing token") + return + } + rec, status, err := s.pbAuthRefresh(r.Context(), token) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()}) + return + } + if status != http.StatusOK || rec == nil { + writeError(w, http.StatusUnauthorized, "invalid or expired token") + return + } + who := callerFromRecord(rec) + if !who.isSuperadmin() { + if _, _, ok := s.plugins.RawConfig(openSkyPlugin); !ok { + writeError(w, http.StatusNotFound, "unknown plugin") + return + } + } + res := s.resolveOpenSky(r.Context(), who, rec.Record["pluginSettings"]) + if !res.available { + writeJSON(w, http.StatusOK, map[string]any{"health": map[string]any{ + "status": "down", "detail": "OpenSky is disabled by the administrator"}}) + return + } + if !res.orgEnabled { + writeJSON(w, http.StatusOK, map[string]any{"health": map[string]any{ + "status": "down", "detail": "OpenSky is disabled for your organization"}}) + return + } + cfg := map[string]string{ + "clientId": res.eff.ClientID, + "clientSecret": res.eff.ClientSecret, + "plan": res.eff.Plan, + "bbox": res.eff.Bbox, + "allowAnonymous": boolStr(res.allowAnon), + } + h, err := s.plugins.HealthCheckWith(r.Context(), openSkyPlugin, cfg) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"health": h}) +} + +func boolStr(b bool) string { + if b { + return "true" + } + return "false" +} diff --git a/API Server/internal/api/integrations_filetransfer.go b/API Server/internal/api/integrations_filetransfer.go new file mode 100644 index 0000000..411745b --- /dev/null +++ b/API Server/internal/api/integrations_filetransfer.go @@ -0,0 +1,502 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/url" + "strings" +) + +// This file exposes the "filetransfer" (FTP/FTPS/SFTP) plugin's settings to end +// users through the exact same three-layer cascade OpenSky uses +// (superadmin/global → organization → user); see integrations.go for the shared +// helpers (lockedFor, layerRank, maskPresent, callerFromRecord, boolStr). +// +// - global (L1): the plugin's config in plugins.json, set in the API Server panel. +// - org (L2): pluginSettings.filetransfer on the caller's organization record. +// - user (L3): pluginSettings.filetransfer on the caller's own user record. +// +// Unlike OpenSky's independently-resolved tunables, a file-server connection is +// only meaningful as a whole: you cannot take the host from one layer and the +// credentials from another. So every connection field (protocol, host, port, +// username, password, private key + passphrase, host-key fingerprint, TLS +// verification) resolves as a *group* from the highest layer that supplies a +// host — mirroring how OpenSky resolves its client id + secret as a pair, just +// widened to the whole connection. Only basePath cascades independently, so a +// user can point at their own working directory on an org-provided server. +// +// Secrets are never returned to a lower-privileged client: the effective config +// is resolved server-side and only masked values leave the API. Live probes run +// server-side against the resolved config. + +const fileTransferPlugin = "filetransfer" + +// ftConfig is one layer's filetransfer settings. Values are strings to match the +// plugin's ConfigField keys 1:1 (they are handed straight to plugins.Init). +type ftConfig struct { + Protocol string `json:"protocol"` + Host string `json:"host"` + Port string `json:"port"` + Username string `json:"username"` + Password string `json:"password"` + PrivateKey string `json:"privateKey"` + KeyPassphrase string `json:"keyPassphrase"` + HostKeyFingerprint string `json:"hostKeyFingerprint"` + InsecureSkipVerify string `json:"insecureSkipVerify"` + BasePath string `json:"basePath"` +} + +// ftConnKeys are the fields that resolve together as one connection (everything +// host-specific). basePath is deliberately excluded — it cascades on its own. +var ftConnKeys = []string{ + "protocol", "host", "port", "username", "password", + "privateKey", "keyPassphrase", "hostKeyFingerprint", "insecureSkipVerify", +} + +// ftSecretKeys are masked in every view and preserved on save when left at the mask. +var ftSecretKeys = map[string]bool{"password": true, "privateKey": true, "keyPassphrase": true} + +// ftStored is what we persist per user/org under pluginSettings.filetransfer. +type ftStored struct { + Config ftConfig `json:"config"` + // Enabled is the personal per-user opt-in (user layer). Default false. + Enabled bool `json:"enabled"` + // Disabled is the organization layer's off switch, stored inverted so that + // absent == enabled (mirrors OpenSky). Only meaningful on the org record. + Disabled bool `json:"disabled,omitempty"` +} + +// ftSettingsDoc is the filetransfer slice of the shared pluginSettings JSON. +type ftSettingsDoc struct { + FileTransfer ftStored `json:"filetransfer"` +} + +// ftResolution is the fully-resolved filetransfer state for one caller. +type ftResolution struct { + eff ftConfig // effective (unmasked) — used only server-side (probes) + userOwn ftConfig // caller's personal (L3) values (unmasked) + orgOwn ftConfig // organization (L2) values (unmasked) + source map[string]string // field -> layer name (global|org|user|unset) + isSuper bool // superadmin: manages the global layer in the panel + canOrg bool // caller may edit the organization layer (org admin) + available bool // global master switch (plugin enabled in the panel) + orgEnabled bool // org master switch (default true; gates the org's users) + enabled bool // caller's personal enable flag +} + +// ftConfigFromMap builds an ftConfig from a flat string map (global plugin config). +func ftConfigFromMap(m map[string]string) ftConfig { + return ftConfig{ + Protocol: m["protocol"], + Host: m["host"], + Port: m["port"], + Username: m["username"], + Password: m["password"], + PrivateKey: m["privateKey"], + KeyPassphrase: m["keyPassphrase"], + HostKeyFingerprint: m["hostKeyFingerprint"], + InsecureSkipVerify: m["insecureSkipVerify"], + BasePath: m["basePath"], + } +} + +// ftGet returns a config field by the plugin's key name. +func ftGet(c ftConfig, key string) string { + switch key { + case "protocol": + return c.Protocol + case "host": + return c.Host + case "port": + return c.Port + case "username": + return c.Username + case "password": + return c.Password + case "privateKey": + return c.PrivateKey + case "keyPassphrase": + return c.KeyPassphrase + case "hostKeyFingerprint": + return c.HostKeyFingerprint + case "insecureSkipVerify": + return c.InsecureSkipVerify + case "basePath": + return c.BasePath + } + return "" +} + +// ftSet writes a config field by the plugin's key name. +func ftSet(c *ftConfig, key, v string) { + switch key { + case "protocol": + c.Protocol = v + case "host": + c.Host = v + case "port": + c.Port = v + case "username": + c.Username = v + case "password": + c.Password = v + case "privateKey": + c.PrivateKey = v + case "keyPassphrase": + c.KeyPassphrase = v + case "hostKeyFingerprint": + c.HostKeyFingerprint = v + case "insecureSkipVerify": + c.InsecureSkipVerify = v + case "basePath": + c.BasePath = v + } +} + +// resolveFileTransfer computes the cascade for a caller. userRaw is the caller's +// pluginSettings blob (from their auth-refresh record). +func (s *Server) resolveFileTransfer(ctx context.Context, who *callerIdentity, userRaw json.RawMessage) ftResolution { + g, masterEnabled, _ := s.plugins.RawConfig(fileTransferPlugin) + gc := ftConfigFromMap(g) + + var oStored ftStored + if who.OrgID != "" { + oStored, _ = s.orgFileTransfer(ctx, who.OrgID) + } + oc := oStored.Config + + var uStored ftStored + if len(userRaw) > 0 { + var d ftSettingsDoc + _ = json.Unmarshal(userRaw, &d) + uStored = d.FileTransfer + } + uc := uStored.Config + + res := ftResolution{ + source: map[string]string{}, + userOwn: uc, + orgOwn: oc, + isSuper: who.isSuperadmin(), + // An org admin may edit the organization layer in addition to their own. + // Requires the service account (org writes go through it). + canOrg: who.isManager() && !who.isSuperadmin() && who.OrgID != "" && s.admin.configured(), + available: masterEnabled, + orgEnabled: !oStored.Disabled, + enabled: uStored.Enabled, + } + + // Ordered layers, top (highest priority) first. + type layer struct { + name string + c ftConfig + } + layers := []layer{{"global", gc}} + if who.OrgID != "" { + layers = append(layers, layer{"org", oc}) + } + layers = append(layers, layer{"user", uc}) + + // Connection group: the whole connection comes from the highest layer that + // supplies a host, so credential halves are never mixed across layers. + connSrc := "unset" + for _, l := range layers { + if strings.TrimSpace(l.c.Host) != "" { + for _, k := range ftConnKeys { + ftSet(&res.eff, k, ftGet(l.c, k)) + } + connSrc = l.name + break + } + } + for _, k := range ftConnKeys { + res.source[k] = connSrc + } + + // basePath cascades independently, top wins, blanks fall through. + baseSrc := "unset" + for _, l := range layers { + if v := strings.TrimSpace(l.c.BasePath); v != "" { + res.eff.BasePath, baseSrc = v, l.name + break + } + } + res.source["basePath"] = baseSrc + return res +} + +// orgFileTransfer reads an organization's stored filetransfer settings (config + +// the org gate) and its raw pluginSettings blob via the service account. Best +// effort: zero values on any miss so callers proceed as if the org layer were empty. +func (s *Server) orgFileTransfer(ctx context.Context, orgID string) (ftStored, json.RawMessage) { + if orgID == "" || !s.admin.configured() { + return ftStored{}, nil + } + data, status, err := s.admin.do(ctx, http.MethodGet, + "/api/collections/organizations/records/"+url.PathEscape(orgID)+"?fields=pluginSettings", nil) + if err != nil || status != http.StatusOK { + return ftStored{}, nil + } + var rec struct { + PluginSettings json.RawMessage `json:"pluginSettings"` + } + _ = json.Unmarshal(data, &rec) + var doc ftSettingsDoc + if len(rec.PluginSettings) > 0 { + _ = json.Unmarshal(rec.PluginSettings, &doc) + } + return doc.FileTransfer, rec.PluginSettings +} + +// mergeFileTransfer applies a mutation to the filetransfer entry of a +// pluginSettings blob, preserving any other plugin keys (e.g. opensky), and +// returns the new blob. +func mergeFileTransfer(existing json.RawMessage, apply func(*ftStored)) json.RawMessage { + doc := map[string]json.RawMessage{} + if len(existing) > 0 { + _ = json.Unmarshal(existing, &doc) + } + if doc == nil { + doc = map[string]json.RawMessage{} // existing was JSON null + } + var ft ftStored + if raw, ok := doc["filetransfer"]; ok { + _ = json.Unmarshal(raw, &ft) + } + apply(&ft) + b, _ := json.Marshal(ft) + doc["filetransfer"] = b + out, _ := json.Marshal(doc) + return out +} + +// GET /api/integrations/filetransfer — resolved view for the caller. +func (s *Server) handleGetFileTransfer(w http.ResponseWriter, r *http.Request) { + who, userRaw, ok := s.integrationCaller(w, r) + if !ok { + return + } + res := s.resolveFileTransfer(r.Context(), who, userRaw) + writeJSON(w, http.StatusOK, s.fileTransferView(who, res)) +} + +// ftScopeView builds the masked field set for one editable scope. editable is the +// layer the caller edits ("user" | "org" | "none"); a field is locked when its +// effective value is set above that layer. +func (s *Server) ftScopeView(res ftResolution, editable string) map[string]any { + own := res.userOwn + if editable == "org" { + own = res.orgOwn + } + fields := map[string]osFieldView{} + for _, key := range append(append([]string{}, ftConnKeys...), "basePath") { + src := res.source[key] + fv := osFieldView{Source: src, Locked: lockedFor(src, editable)} + if ftSecretKeys[key] { + fv.Effective, fv.Own = maskPresent(ftGet(res.eff, key)), maskPresent(ftGet(own, key)) + } else { + fv.Effective, fv.Own = ftGet(res.eff, key), ftGet(own, key) + } + fields[key] = fv + } + return map[string]any{"editableLayer": editable, "fields": fields} +} + +// fileTransferView builds the masked, client-safe response body. It exposes a +// "user" scope for everyone plus, for org admins, an "org" scope. +func (s *Server) fileTransferView(who *callerIdentity, res ftResolution) map[string]any { + out := map[string]any{ + "available": res.available, + "orgEnabled": res.orgEnabled, + "enabled": res.enabled, + "role": who.Role, + "orgId": who.OrgID, + "canEditOrg": res.canOrg, + "isSuperadmin": res.isSuper, + } + if res.isSuper { + out["editableLayer"] = "none" + out["scopes"] = map[string]any{"user": s.ftScopeView(res, "none")} + return out + } + scopes := map[string]any{"user": s.ftScopeView(res, "user")} + if res.canOrg { + scopes["org"] = s.ftScopeView(res, "org") + } + out["scopes"] = scopes + return out +} + +// PUT /api/integrations/filetransfer — save the caller's editable layer. Body: +// {enabled?, scope?, config?}. Fields locked above the caller are ignored; a +// secret left at the mask is preserved. +func (s *Server) handlePutFileTransfer(w http.ResponseWriter, r *http.Request) { + token := r.Header.Get("Authorization") + var body struct { + Enabled *bool `json:"enabled"` + Scope string `json:"scope"` + Config map[string]string `json:"config"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid json") + return + } + who, userRaw, ok := s.integrationCaller(w, r) + if !ok { + return + } + res := s.resolveFileTransfer(r.Context(), who, userRaw) + + // Resolve which layer this write targets. + editable := "user" + switch { + case res.isSuper: + editable = "none" + case strings.EqualFold(strings.TrimSpace(body.Scope), "org"): + if !res.canOrg { + writeError(w, http.StatusForbidden, "only an organization admin can edit organization settings") + return + } + editable = "org" + } + + // Overlay the fields the caller may change in this scope onto its own values. + newOwn := res.userOwn + if editable == "org" { + newOwn = res.orgOwn + } + for _, key := range append(append([]string{}, ftConnKeys...), "basePath") { + v, present := body.Config[key] + if !present || lockedFor(res.source[key], editable) { + continue + } + if ftSecretKeys[key] && v == openSkySecretMask { + continue // keep current secret + } + ftSet(&newOwn, key, strings.TrimSpace(v)) + } + + // Persist the organization layer (admins) via the service account. + if editable == "org" { + if who.OrgID == "" { + writeError(w, http.StatusForbidden, "your account is not attached to an organization") + return + } + if !s.admin.configured() { + writeError(w, http.StatusServiceUnavailable, "organization settings not configured on the server") + return + } + _, orgRaw := s.orgFileTransfer(r.Context(), who.OrgID) + newDoc := mergeFileTransfer(orgRaw, func(ft *ftStored) { + ft.Config = newOwn + if body.Enabled != nil { + ft.Disabled = !*body.Enabled // org master switch, stored inverted + } + }) + _, st, err := s.admin.do(r.Context(), http.MethodPatch, + "/api/collections/organizations/records/"+url.PathEscape(who.OrgID), + map[string]json.RawMessage{"pluginSettings": newDoc}) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()}) + return + } + if st != http.StatusOK { + writeError(w, http.StatusBadGateway, "could not save organization settings") + return + } + } + + // Persist the user record: the personal enable flag lives here (user/superadmin + // scope), and so does the personal config layer when this write targets user. + personalEnable := body.Enabled != nil && editable != "org" + if personalEnable || editable == "user" { + newDoc := mergeFileTransfer(userRaw, func(ft *ftStored) { + if personalEnable { + ft.Enabled = *body.Enabled + } + if editable == "user" { + ft.Config = newOwn + } + }) + if code, err := s.patchUserPluginSettings(r.Context(), token, who.ID, newDoc); err != nil { + writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()}) + return + } else if code != http.StatusOK { + writeError(w, http.StatusBadGateway, "could not save user settings") + return + } + } + + // Re-resolve and return the fresh view. + fresh, st, err := s.pbAuthRefresh(r.Context(), token) + if err != nil || st != http.StatusOK || fresh == nil { + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) + return + } + res2 := s.resolveFileTransfer(r.Context(), who, fresh.Record["pluginSettings"]) + writeJSON(w, http.StatusOK, s.fileTransferView(who, res2)) +} + +// POST /api/integrations/filetransfer/health — live probe using the caller's +// resolved config. Never returns secrets. +func (s *Server) handleFileTransferHealth(w http.ResponseWriter, r *http.Request) { + who, userRaw, ok := s.integrationCaller(w, r) + if !ok { + return + } + if !who.isSuperadmin() { + if _, _, ok := s.plugins.RawConfig(fileTransferPlugin); !ok { + writeError(w, http.StatusNotFound, "unknown plugin") + return + } + } + res := s.resolveFileTransfer(r.Context(), who, userRaw) + if !res.available { + writeJSON(w, http.StatusOK, map[string]any{"health": map[string]any{ + "status": "down", "detail": "File transfer is disabled by the administrator"}}) + return + } + if !res.orgEnabled { + writeJSON(w, http.StatusOK, map[string]any{"health": map[string]any{ + "status": "down", "detail": "File transfer is disabled for your organization"}}) + return + } + if strings.TrimSpace(res.eff.Host) == "" { + writeJSON(w, http.StatusOK, map[string]any{"health": map[string]any{ + "status": "down", "detail": "No server configured — set a host to connect"}}) + return + } + cfg := map[string]string{} + for _, k := range append(append([]string{}, ftConnKeys...), "basePath") { + cfg[k] = ftGet(res.eff, k) + } + h, err := s.plugins.HealthCheckWith(r.Context(), fileTransferPlugin, cfg) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"health": h}) +} + +// integrationCaller authenticates the request via a PocketBase auth-refresh and +// returns the caller identity plus their pluginSettings blob. It writes the error +// response and returns ok=false on any failure. Shared by the filetransfer +// integration endpoints (the OpenSky handlers predate it and inline the same steps). +func (s *Server) integrationCaller(w http.ResponseWriter, r *http.Request) (*callerIdentity, json.RawMessage, bool) { + token := r.Header.Get("Authorization") + if token == "" { + writeError(w, http.StatusUnauthorized, "missing token") + return nil, nil, false + } + rec, status, err := s.pbAuthRefresh(r.Context(), token) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()}) + return nil, nil, false + } + if status != http.StatusOK || rec == nil { + writeError(w, http.StatusUnauthorized, "invalid or expired token") + return nil, nil, false + } + return callerFromRecord(rec), rec.Record["pluginSettings"], true +} diff --git a/API Server/internal/api/integrations_localstorage.go b/API Server/internal/api/integrations_localstorage.go new file mode 100644 index 0000000..3e99329 --- /dev/null +++ b/API Server/internal/api/integrations_localstorage.go @@ -0,0 +1,519 @@ +package api + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "path" + "strings" +) + +// This file exposes the "localstorage" (host local filesystem) plugin's settings +// to end users through the same three-layer cascade OpenSky and filetransfer use +// (superadmin/global → organization → user); see integrations.go for the shared +// helpers (lockedFor, layerRank, maskPresent, callerFromRecord, boolStr). +// +// - global (L1): the plugin's config in plugins.json, set in the API Server panel +// (the storage root basePath + the master switch + a global read-only default). +// - org (L2): pluginSettings.localstorage on the caller's organization record. +// - user (L3): pluginSettings.localstorage on the caller's own user record. +// +// Unlike filetransfer, a local drive gives each tenant *isolated* folders rather +// than a freely-chosen path. Folders are derived from identity, never taken from a +// client, and laid out so that "private" is genuinely private: +// +// /orgs//shared — the organization folder (all members) +// /orgs//private/— a member's private folder (opt-in) +// /users/ — an org-less user's private folder +// +// A member on the shared folder is confined to .../shared and cannot traverse into +// anyone's .../private subtree; each private folder is confined to its own +// .../private/. So an org member can hold BOTH the shared org folder and a +// private folder nested inside the org folder, reachable only by them. The plugin +// confines every operation within the folder it is handed, so isolation is enforced +// end-to-end. +// +// A member opts into their private folder personally (user layer); an org admin may +// gate the feature for the whole organization (org layer, default allowed). The one +// other cascading tunable is readOnly (blank falls through, top wins, a set value +// locks lower layers). + +const localStoragePlugin = "localstorage" + +// lsConfig is one layer's editable localstorage settings. Folders are not here: +// they are computed from identity, never stored or taken from a client. +type lsConfig struct { + ReadOnly string `json:"readOnly"` // "" (inherit) | "true" | "false" +} + +// lsStored is what we persist per user/org under pluginSettings.localstorage. +type lsStored struct { + Config lsConfig `json:"config"` + // Enabled is the personal per-user opt-in (user layer). Default false. + Enabled bool `json:"enabled"` + // Disabled is the organization layer's off switch, stored inverted so that + // absent == enabled (mirrors OpenSky). Only meaningful on the org record. + Disabled bool `json:"disabled,omitempty"` + // PrivateFolder is the user layer's opt-in for a private folder inside the org + // folder. Only meaningful for a caller who belongs to an organization. + PrivateFolder bool `json:"privateFolder,omitempty"` + // DisallowPrivate is the org layer's gate on private folders, stored inverted so + // that absent == allowed. Only meaningful on the org record. + DisallowPrivate bool `json:"disallowPrivate,omitempty"` +} + +// lsSettingsDoc is the localstorage slice of the shared pluginSettings JSON. +type lsSettingsDoc struct { + LocalStorage lsStored `json:"localstorage"` +} + +// lsMount is one isolated folder a caller can reach. +type lsMount struct { + ID string `json:"id"` // "shared" | "private" | "personal" + Label string `json:"label"` // human label for the UI + Path string `json:"path"` // absolute folder path + Kind string `json:"kind"` // "shared" | "private" +} + +// lsResolution is the fully-resolved localstorage state for one caller. +type lsResolution struct { + readOnly string // effective read-only ("" | "true" | "false") + userReadOnly string // user (L3) read-only value + orgReadOnly string // organization (L2) read-only value + root string // global storage root + mounts []lsMount // isolated folders in force for this caller + source map[string]string // field -> layer name (global|org|user|unset) + isSuper bool + canOrg bool + available bool // global master switch + orgEnabled bool // org master switch (default true) + enabled bool // personal opt-in to use the plugin + isOrgUser bool // caller belongs to an organization + allowPrivate bool // org policy: private folders permitted (default true) + wantsPrivate bool // user's raw private-folder opt-in + privateOn bool // effective: org user + allowed + opted in +} + +// Folder builders. Forward-slash joins (path, not filepath) since the target host +// is Linux; the plugin re-resolves against the OS filesystem and confines within. +func orgSharedFolder(root, orgID string) string { + return path.Join(root, "orgs", orgID, "shared") +} +func orgPrivateFolder(root, orgID, userID string) string { + return path.Join(root, "orgs", orgID, "private", userID) +} +func userPersonalFolder(root, userID string) string { + return path.Join(root, "users", userID) +} + +// tenantMounts computes the isolated folders for a caller. An org member always +// gets the shared org folder and, when privateOn, an additional private folder +// nested inside the org folder; an org-less user gets a single private folder. +func tenantMounts(root string, who *callerIdentity, privateOn bool) []lsMount { + root = strings.TrimSpace(root) + if root == "" { + return []lsMount{} + } + if who.OrgID != "" { + mounts := []lsMount{{ + ID: "shared", Label: "Organization folder", Kind: "shared", + Path: orgSharedFolder(root, who.OrgID), + }} + if privateOn && who.ID != "" { + mounts = append(mounts, lsMount{ + ID: "private", Label: "Your private folder", Kind: "private", + Path: orgPrivateFolder(root, who.OrgID, who.ID), + }) + } + return mounts + } + if who.ID != "" { + return []lsMount{{ + ID: "personal", Label: "Your private folder", Kind: "private", + Path: userPersonalFolder(root, who.ID), + }} + } + return []lsMount{} +} + +// resolveLocalStorage computes the cascade for a caller. userRaw is the caller's +// pluginSettings blob (from their auth-refresh record). +func (s *Server) resolveLocalStorage(ctx context.Context, who *callerIdentity, userRaw json.RawMessage) lsResolution { + g, masterEnabled, _ := s.plugins.RawConfig(localStoragePlugin) + root := strings.TrimSpace(g["basePath"]) + // The global panel's readOnly select defaults to a concrete "false"; treat that + // as unset so the global default does not permanently lock lower layers. Only an + // explicit global "true" freezes every folder. + globalRO := strings.TrimSpace(g["readOnly"]) + if strings.EqualFold(globalRO, "false") { + globalRO = "" + } + + var oStored lsStored + if who.OrgID != "" { + oStored, _ = s.orgLocalStorage(ctx, who.OrgID) + } + var uStored lsStored + if len(userRaw) > 0 { + var d lsSettingsDoc + _ = json.Unmarshal(userRaw, &d) + uStored = d.LocalStorage + } + + res := lsResolution{ + source: map[string]string{}, + userReadOnly: uStored.Config.ReadOnly, + orgReadOnly: oStored.Config.ReadOnly, + isSuper: who.isSuperadmin(), + // An org admin may edit the organization layer in addition to their own. + // Requires the service account (org writes go through it). + canOrg: who.isManager() && !who.isSuperadmin() && who.OrgID != "" && s.admin.configured(), + available: masterEnabled, + orgEnabled: !oStored.Disabled, + enabled: uStored.Enabled, + root: root, + isOrgUser: who.OrgID != "", + allowPrivate: !oStored.DisallowPrivate, // default allowed + wantsPrivate: uStored.PrivateFolder, + } + + // readOnly cascades top-wins, blanks fall through (same mechanism as OpenSky). + type layer struct{ name, ro string } + layers := []layer{{"global", globalRO}} + if who.OrgID != "" { + layers = append(layers, layer{"org", oStored.Config.ReadOnly}) + } + layers = append(layers, layer{"user", uStored.Config.ReadOnly}) + roSrc := "unset" + for _, l := range layers { + if v := strings.TrimSpace(l.ro); v != "" { + res.readOnly, roSrc = v, l.name + break + } + } + res.source["readOnly"] = roSrc + + // Effective private-folder state and the isolated folders in force. + res.privateOn = res.isOrgUser && res.allowPrivate && res.wantsPrivate + res.mounts = tenantMounts(root, who, res.privateOn) + return res +} + +// orgLocalStorage reads an organization's stored localstorage settings (config + +// the org gate) and its raw pluginSettings blob via the service account. Best +// effort: zero values on any miss so callers proceed as if the org layer were empty. +func (s *Server) orgLocalStorage(ctx context.Context, orgID string) (lsStored, json.RawMessage) { + if orgID == "" || !s.admin.configured() { + return lsStored{}, nil + } + data, status, err := s.admin.do(ctx, http.MethodGet, + "/api/collections/organizations/records/"+url.PathEscape(orgID)+"?fields=pluginSettings", nil) + if err != nil || status != http.StatusOK { + return lsStored{}, nil + } + var rec struct { + PluginSettings json.RawMessage `json:"pluginSettings"` + } + _ = json.Unmarshal(data, &rec) + var doc lsSettingsDoc + if len(rec.PluginSettings) > 0 { + _ = json.Unmarshal(rec.PluginSettings, &doc) + } + return doc.LocalStorage, rec.PluginSettings +} + +// mergeLocalStorage applies a mutation to the localstorage entry of a +// pluginSettings blob, preserving any other plugin keys, and returns the new blob. +func mergeLocalStorage(existing json.RawMessage, apply func(*lsStored)) json.RawMessage { + doc := map[string]json.RawMessage{} + if len(existing) > 0 { + _ = json.Unmarshal(existing, &doc) + } + if doc == nil { + doc = map[string]json.RawMessage{} // existing was JSON null + } + var ls lsStored + if raw, ok := doc["localstorage"]; ok { + _ = json.Unmarshal(raw, &ls) + } + apply(&ls) + b, _ := json.Marshal(ls) + doc["localstorage"] = b + out, _ := json.Marshal(doc) + return out +} + +// GET /api/integrations/localstorage — resolved view for the caller. +func (s *Server) handleGetLocalStorage(w http.ResponseWriter, r *http.Request) { + who, userRaw, ok := s.integrationCaller(w, r) + if !ok { + return + } + res := s.resolveLocalStorage(r.Context(), who, userRaw) + writeJSON(w, http.StatusOK, s.localStorageView(who, res)) +} + +// lsScopeView builds the field set for one editable scope. editable is the layer +// the caller edits ("user" | "org" | "none"); readOnly is locked when its effective +// value is set above that layer. +func (s *Server) lsScopeView(res lsResolution, editable string) map[string]any { + own := res.userReadOnly + if editable == "org" { + own = res.orgReadOnly + } + src := res.source["readOnly"] + field := osFieldView{ + Source: src, + Locked: lockedFor(src, editable), + Effective: res.readOnly, + Own: own, + } + return map[string]any{ + "editableLayer": editable, + "fields": map[string]osFieldView{"readOnly": field}, + } +} + +// localStorageView builds the client-safe response body. It exposes a "user" scope +// for everyone plus, for org admins, an "org" scope. The effective isolated folders +// are reported at the top level (derived, not editable). +func (s *Server) localStorageView(who *callerIdentity, res lsResolution) map[string]any { + out := map[string]any{ + "available": res.available, + "orgEnabled": res.orgEnabled, + "enabled": res.enabled, + "role": who.Role, + "orgId": who.OrgID, + "canEditOrg": res.canOrg, + "isSuperadmin": res.isSuper, + "isOrgUser": res.isOrgUser, + "rootConfigured": strings.TrimSpace(res.root) != "", + "mounts": res.mounts, + "privateFolder": res.wantsPrivate, // the user's own opt-in + "privateEnabled": res.privateOn, // effective (may be gated off by the org) + "allowPrivate": res.allowPrivate, // org policy + } + if res.isSuper { + out["editableLayer"] = "none" + out["scopes"] = map[string]any{"user": s.lsScopeView(res, "none")} + return out + } + scopes := map[string]any{"user": s.lsScopeView(res, "user")} + if res.canOrg { + scopes["org"] = s.lsScopeView(res, "org") + } + out["scopes"] = scopes + return out +} + +// PUT /api/integrations/localstorage — save the caller's editable layer. Body: +// {enabled?, privateFolder?, allowPrivate?, scope?, config?}. Folders are derived, +// so only readOnly, the enable flags, and the private-folder settings are writable. +func (s *Server) handlePutLocalStorage(w http.ResponseWriter, r *http.Request) { + token := r.Header.Get("Authorization") + var body struct { + Enabled *bool `json:"enabled"` + PrivateFolder *bool `json:"privateFolder"` // user: opt into a private folder + AllowPrivate *bool `json:"allowPrivate"` // org: permit private folders + Scope string `json:"scope"` + Config map[string]string `json:"config"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid json") + return + } + who, userRaw, ok := s.integrationCaller(w, r) + if !ok { + return + } + res := s.resolveLocalStorage(r.Context(), who, userRaw) + + // Resolve which layer this write targets. + editable := "user" + switch { + case res.isSuper: + editable = "none" + case strings.EqualFold(strings.TrimSpace(body.Scope), "org"): + if !res.canOrg { + writeError(w, http.StatusForbidden, "only an organization admin can edit organization settings") + return + } + editable = "org" + } + + // Overlay readOnly onto the target scope's own value, unless it is locked above. + newRO := res.userReadOnly + if editable == "org" { + newRO = res.orgReadOnly + } + if v, present := body.Config["readOnly"]; present && !lockedFor(res.source["readOnly"], editable) { + newRO = normalizeReadOnly(v) + } + + // Persist the organization layer (admins) via the service account. + if editable == "org" { + if who.OrgID == "" { + writeError(w, http.StatusForbidden, "your account is not attached to an organization") + return + } + if !s.admin.configured() { + writeError(w, http.StatusServiceUnavailable, "organization settings not configured on the server") + return + } + _, orgRaw := s.orgLocalStorage(r.Context(), who.OrgID) + newDoc := mergeLocalStorage(orgRaw, func(ls *lsStored) { + ls.Config.ReadOnly = newRO + if body.Enabled != nil { + ls.Disabled = !*body.Enabled // org master switch, stored inverted + } + if body.AllowPrivate != nil { + ls.DisallowPrivate = !*body.AllowPrivate // stored inverted (absent = allowed) + } + }) + _, st, err := s.admin.do(r.Context(), http.MethodPatch, + "/api/collections/organizations/records/"+url.PathEscape(who.OrgID), + map[string]json.RawMessage{"pluginSettings": newDoc}) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()}) + return + } + if st != http.StatusOK { + writeError(w, http.StatusBadGateway, "could not save organization settings") + return + } + } + + // Persist the user record: the personal enable flag and private-folder opt-in + // live here (user/superadmin scope), and so does the personal readOnly when this + // write targets the user scope. + personalEnable := body.Enabled != nil && editable != "org" + personalPrivate := body.PrivateFolder != nil && editable != "org" + if personalEnable || personalPrivate || editable == "user" { + newDoc := mergeLocalStorage(userRaw, func(ls *lsStored) { + if personalEnable { + ls.Enabled = *body.Enabled + } + if personalPrivate { + ls.PrivateFolder = *body.PrivateFolder + } + if editable == "user" { + ls.Config.ReadOnly = newRO + } + }) + if code, err := s.patchUserPluginSettings(r.Context(), token, who.ID, newDoc); err != nil { + writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()}) + return + } else if code != http.StatusOK { + writeError(w, http.StatusBadGateway, "could not save user settings") + return + } + } + + // Re-resolve and return the fresh view. + fresh, st, err := s.pbAuthRefresh(r.Context(), token) + if err != nil || st != http.StatusOK || fresh == nil { + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) + return + } + res2 := s.resolveLocalStorage(r.Context(), who, fresh.Record["pluginSettings"]) + writeJSON(w, http.StatusOK, s.localStorageView(who, res2)) +} + +// normalizeReadOnly coerces a submitted readOnly value to the stored vocabulary. +func normalizeReadOnly(v string) string { + switch strings.ToLower(strings.TrimSpace(v)) { + case "true": + return "true" + case "false": + return "false" + default: + return "" // inherit + } +} + +// POST /api/integrations/localstorage/health — live probe against every isolated +// folder the caller holds (each auto-created), honouring the resolved read-only flag. +func (s *Server) handleLocalStorageHealth(w http.ResponseWriter, r *http.Request) { + who, userRaw, ok := s.integrationCaller(w, r) + if !ok { + return + } + if !who.isSuperadmin() { + if _, _, ok := s.plugins.RawConfig(localStoragePlugin); !ok { + writeError(w, http.StatusNotFound, "unknown plugin") + return + } + } + res := s.resolveLocalStorage(r.Context(), who, userRaw) + if !res.available { + writeJSON(w, http.StatusOK, map[string]any{"health": map[string]any{ + "status": "down", "detail": "Local storage is disabled by the administrator"}}) + return + } + if !res.orgEnabled { + writeJSON(w, http.StatusOK, map[string]any{"health": map[string]any{ + "status": "down", "detail": "Local storage is disabled for your organization"}}) + return + } + if len(res.mounts) == 0 { + writeJSON(w, http.StatusOK, map[string]any{"health": map[string]any{ + "status": "down", "detail": "No storage root configured by the administrator"}}) + return + } + + // Probe each folder; aggregate to the worst status for the summary badge and + // return per-folder results so the UI can annotate each mount. + perMount := make([]map[string]any, 0, len(res.mounts)) + worst := "ok" + for _, m := range res.mounts { + cfg := map[string]string{ + "basePath": m.Path, + "createMissing": "true", // each folder is provisioned on demand + "readOnly": res.readOnly, + } + h, err := s.plugins.HealthCheckWith(r.Context(), localStoragePlugin, cfg) + status, detail := "down", "" + if err != nil { + detail = err.Error() + } else { + status, detail = h.Status, h.Detail + } + worst = worseStatus(worst, status) + perMount = append(perMount, map[string]any{ + "id": m.ID, "label": m.Label, "path": m.Path, "status": status, "detail": detail, + }) + } + + summary := fmt.Sprintf("%d folder%s reachable", len(res.mounts), plural2(len(res.mounts))) + if worst != "ok" { + // Surface the first non-ok detail so the badge is actionable. + for _, m := range perMount { + if m["status"] != "ok" { + summary = fmt.Sprintf("%s: %v", m["label"], m["detail"]) + break + } + } + } + writeJSON(w, http.StatusOK, map[string]any{ + "health": map[string]any{"status": worst, "detail": summary}, + "mounts": perMount, + }) +} + +// worseStatus returns the more severe of two health statuses (ok < degraded < down). +func worseStatus(a, b string) string { + rank := map[string]int{"ok": 0, "degraded": 1, "down": 2} + if rank[b] > rank[a] { + return b + } + return a +} + +func plural2(n int) string { + if n == 1 { + return "" + } + return "s" +} diff --git a/API Server/internal/api/integrations_localstorage_test.go b/API Server/internal/api/integrations_localstorage_test.go new file mode 100644 index 0000000..cf2687f --- /dev/null +++ b/API Server/internal/api/integrations_localstorage_test.go @@ -0,0 +1,99 @@ +package api + +import ( + "strings" + "testing" +) + +func mountByID(mounts []lsMount, id string) (lsMount, bool) { + for _, m := range mounts { + if m.ID == id { + return m, true + } + } + return lsMount{}, false +} + +func TestTenantMountsOrgUser(t *testing.T) { + who := &callerIdentity{ID: "u1", OrgID: "org9"} + + // Private off: only the shared org folder. + off := tenantMounts("/data", who, false) + if len(off) != 1 { + t.Fatalf("private off: got %d mounts, want 1", len(off)) + } + if off[0].ID != "shared" || off[0].Path != "/data/orgs/org9/shared" { + t.Errorf("shared mount = %+v", off[0]) + } + + // Private on: shared + a private folder nested inside the org folder. + on := tenantMounts("/data", who, true) + if len(on) != 2 { + t.Fatalf("private on: got %d mounts, want 2", len(on)) + } + priv, ok := mountByID(on, "private") + if !ok || priv.Path != "/data/orgs/org9/private/u1" || priv.Kind != "private" { + t.Errorf("private mount = %+v", priv) + } + // The private folder must sit under the org folder but NOT under the shared + // subtree, so shared-folder members cannot traverse into it. + shared, _ := mountByID(on, "shared") + if !strings.HasPrefix(priv.Path, "/data/orgs/org9/") { + t.Errorf("private folder %q is not inside the org folder", priv.Path) + } + if strings.HasPrefix(priv.Path, shared.Path+"/") { + t.Errorf("private folder %q is reachable from the shared folder %q", priv.Path, shared.Path) + } +} + +func TestTenantMountsOrgLessUser(t *testing.T) { + m := tenantMounts("/data", &callerIdentity{ID: "solo"}, true) + if len(m) != 1 || m[0].ID != "personal" || m[0].Path != "/data/users/solo" || m[0].Kind != "private" { + t.Fatalf("org-less mounts = %+v", m) + } +} + +func TestTenantMountsNoRoot(t *testing.T) { + if m := tenantMounts("", &callerIdentity{ID: "u1", OrgID: "o"}, true); len(m) != 0 { + t.Fatalf("no root: got %d mounts, want 0", len(m)) + } + if m := tenantMounts(" ", &callerIdentity{ID: "u1"}, true); len(m) != 0 { + t.Fatalf("blank root: got %d mounts, want 0", len(m)) + } +} + +// Two members' private folders must never collide (isolation). +func TestPrivateFolderIsolation(t *testing.T) { + a := orgPrivateFolder("/data", "org1", "alice") + b := orgPrivateFolder("/data", "org1", "bob") + if a == b { + t.Fatalf("distinct members share a private folder: %q", a) + } +} + +func TestNormalizeReadOnly(t *testing.T) { + cases := map[string]string{ + "true": "true", "TRUE": "true", " true ": "true", + "false": "false", "False": "false", + "": "", "inherit": "", "garbage": "", + } + for in, want := range cases { + if got := normalizeReadOnly(in); got != want { + t.Errorf("normalizeReadOnly(%q) = %q, want %q", in, got, want) + } + } +} + +func TestWorseStatus(t *testing.T) { + cases := []struct{ a, b, want string }{ + {"ok", "ok", "ok"}, + {"ok", "degraded", "degraded"}, + {"degraded", "down", "down"}, + {"down", "ok", "down"}, + } + for _, c := range cases { + if got := worseStatus(c.a, c.b); got != c.want { + t.Errorf("worseStatus(%q,%q) = %q, want %q", c.a, c.b, got, c.want) + } + } +} diff --git a/API Server/internal/api/integrations_webdav.go b/API Server/internal/api/integrations_webdav.go new file mode 100644 index 0000000..6e91346 --- /dev/null +++ b/API Server/internal/api/integrations_webdav.go @@ -0,0 +1,446 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/url" + "strings" +) + +// This file exposes the "webdav" plugin's settings to end users through the exact +// same three-layer cascade OpenSky uses (superadmin/global → organization → +// user); see integrations.go for the shared helpers (lockedFor, layerRank, +// maskPresent, callerFromRecord). It mirrors integrations_filetransfer.go — a +// WebDAV endpoint is likewise a connection that is only meaningful as a whole. +// +// - global (L1): the plugin's config in plugins.json, set in the API Server panel. +// - org (L2): pluginSettings.webdav on the caller's organization record. +// - user (L3): pluginSettings.webdav on the caller's own user record. +// +// Every connection field (server URL, username, password, TLS verification) +// resolves as a *group* from the highest layer that supplies a server URL, so +// credential halves are never mixed across layers — exactly like filetransfer's +// host-group and OpenSky's client id + secret pair. Only basePath cascades +// independently, so a user can point at their own working directory on an +// org-provided server. +// +// Secrets are never returned to a lower-privileged client: the effective config +// is resolved server-side and only masked values leave the API. Live probes run +// server-side against the resolved config. + +const webDavPlugin = "webdav" + +// wdConfig is one layer's webdav settings. Values are strings to match the +// plugin's ConfigField keys 1:1 (they are handed straight to plugins.Init). +type wdConfig struct { + BaseURL string `json:"baseURL"` + Username string `json:"username"` + Password string `json:"password"` + InsecureSkipVerify string `json:"insecureSkipVerify"` + BasePath string `json:"basePath"` +} + +// wdConnKeys are the fields that resolve together as one connection (everything +// server-specific). basePath is deliberately excluded — it cascades on its own. +var wdConnKeys = []string{"baseURL", "username", "password", "insecureSkipVerify"} + +// wdSecretKeys are masked in every view and preserved on save when left at the mask. +var wdSecretKeys = map[string]bool{"password": true} + +// wdStored is what we persist per user/org under pluginSettings.webdav. +type wdStored struct { + Config wdConfig `json:"config"` + // Enabled is the personal per-user opt-in (user layer). Default false. + Enabled bool `json:"enabled"` + // Disabled is the organization layer's off switch, stored inverted so that + // absent == enabled (mirrors OpenSky). Only meaningful on the org record. + Disabled bool `json:"disabled,omitempty"` +} + +// wdSettingsDoc is the webdav slice of the shared pluginSettings JSON. +type wdSettingsDoc struct { + WebDav wdStored `json:"webdav"` +} + +// wdResolution is the fully-resolved webdav state for one caller. +type wdResolution struct { + eff wdConfig // effective (unmasked) — used only server-side (probes) + userOwn wdConfig // caller's personal (L3) values (unmasked) + orgOwn wdConfig // organization (L2) values (unmasked) + source map[string]string // field -> layer name (global|org|user|unset) + isSuper bool // superadmin: manages the global layer in the panel + canOrg bool // caller may edit the organization layer (org admin) + available bool // global master switch (plugin enabled in the panel) + orgEnabled bool // org master switch (default true; gates the org's users) + enabled bool // caller's personal enable flag +} + +// wdConfigFromMap builds a wdConfig from a flat string map (global plugin config). +func wdConfigFromMap(m map[string]string) wdConfig { + return wdConfig{ + BaseURL: m["baseURL"], + Username: m["username"], + Password: m["password"], + InsecureSkipVerify: m["insecureSkipVerify"], + BasePath: m["basePath"], + } +} + +// wdGet returns a config field by the plugin's key name. +func wdGet(c wdConfig, key string) string { + switch key { + case "baseURL": + return c.BaseURL + case "username": + return c.Username + case "password": + return c.Password + case "insecureSkipVerify": + return c.InsecureSkipVerify + case "basePath": + return c.BasePath + } + return "" +} + +// wdSet writes a config field by the plugin's key name. +func wdSet(c *wdConfig, key, v string) { + switch key { + case "baseURL": + c.BaseURL = v + case "username": + c.Username = v + case "password": + c.Password = v + case "insecureSkipVerify": + c.InsecureSkipVerify = v + case "basePath": + c.BasePath = v + } +} + +// resolveWebDav computes the cascade for a caller. userRaw is the caller's +// pluginSettings blob (from their auth-refresh record). +func (s *Server) resolveWebDav(ctx context.Context, who *callerIdentity, userRaw json.RawMessage) wdResolution { + g, masterEnabled, _ := s.plugins.RawConfig(webDavPlugin) + gc := wdConfigFromMap(g) + + var oStored wdStored + if who.OrgID != "" { + oStored, _ = s.orgWebDav(ctx, who.OrgID) + } + oc := oStored.Config + + var uStored wdStored + if len(userRaw) > 0 { + var d wdSettingsDoc + _ = json.Unmarshal(userRaw, &d) + uStored = d.WebDav + } + uc := uStored.Config + + res := wdResolution{ + source: map[string]string{}, + userOwn: uc, + orgOwn: oc, + isSuper: who.isSuperadmin(), + // An org admin may edit the organization layer in addition to their own. + // Requires the service account (org writes go through it). + canOrg: who.isManager() && !who.isSuperadmin() && who.OrgID != "" && s.admin.configured(), + available: masterEnabled, + orgEnabled: !oStored.Disabled, + enabled: uStored.Enabled, + } + + // Ordered layers, top (highest priority) first. + type layer struct { + name string + c wdConfig + } + layers := []layer{{"global", gc}} + if who.OrgID != "" { + layers = append(layers, layer{"org", oc}) + } + layers = append(layers, layer{"user", uc}) + + // Connection group: the whole connection comes from the highest layer that + // supplies a server URL, so credential halves are never mixed across layers. + connSrc := "unset" + for _, l := range layers { + if strings.TrimSpace(l.c.BaseURL) != "" { + for _, k := range wdConnKeys { + wdSet(&res.eff, k, wdGet(l.c, k)) + } + connSrc = l.name + break + } + } + for _, k := range wdConnKeys { + res.source[k] = connSrc + } + + // basePath cascades independently, top wins, blanks fall through. + baseSrc := "unset" + for _, l := range layers { + if v := strings.TrimSpace(l.c.BasePath); v != "" { + res.eff.BasePath, baseSrc = v, l.name + break + } + } + res.source["basePath"] = baseSrc + return res +} + +// orgWebDav reads an organization's stored webdav settings (config + the org +// gate) and its raw pluginSettings blob via the service account. Best effort: +// zero values on any miss so callers proceed as if the org layer were empty. +func (s *Server) orgWebDav(ctx context.Context, orgID string) (wdStored, json.RawMessage) { + if orgID == "" || !s.admin.configured() { + return wdStored{}, nil + } + data, status, err := s.admin.do(ctx, http.MethodGet, + "/api/collections/organizations/records/"+url.PathEscape(orgID)+"?fields=pluginSettings", nil) + if err != nil || status != http.StatusOK { + return wdStored{}, nil + } + var rec struct { + PluginSettings json.RawMessage `json:"pluginSettings"` + } + _ = json.Unmarshal(data, &rec) + var doc wdSettingsDoc + if len(rec.PluginSettings) > 0 { + _ = json.Unmarshal(rec.PluginSettings, &doc) + } + return doc.WebDav, rec.PluginSettings +} + +// mergeWebDav applies a mutation to the webdav entry of a pluginSettings blob, +// preserving any other plugin keys (e.g. opensky, filetransfer), and returns the +// new blob. +func mergeWebDav(existing json.RawMessage, apply func(*wdStored)) json.RawMessage { + doc := map[string]json.RawMessage{} + if len(existing) > 0 { + _ = json.Unmarshal(existing, &doc) + } + if doc == nil { + doc = map[string]json.RawMessage{} // existing was JSON null + } + var wd wdStored + if raw, ok := doc["webdav"]; ok { + _ = json.Unmarshal(raw, &wd) + } + apply(&wd) + b, _ := json.Marshal(wd) + doc["webdav"] = b + out, _ := json.Marshal(doc) + return out +} + +// GET /api/integrations/webdav — resolved view for the caller. +func (s *Server) handleGetWebDav(w http.ResponseWriter, r *http.Request) { + who, userRaw, ok := s.integrationCaller(w, r) + if !ok { + return + } + res := s.resolveWebDav(r.Context(), who, userRaw) + writeJSON(w, http.StatusOK, s.webDavView(who, res)) +} + +// wdScopeView builds the masked field set for one editable scope. editable is the +// layer the caller edits ("user" | "org" | "none"); a field is locked when its +// effective value is set above that layer. +func (s *Server) wdScopeView(res wdResolution, editable string) map[string]any { + own := res.userOwn + if editable == "org" { + own = res.orgOwn + } + fields := map[string]osFieldView{} + for _, key := range append(append([]string{}, wdConnKeys...), "basePath") { + src := res.source[key] + fv := osFieldView{Source: src, Locked: lockedFor(src, editable)} + if wdSecretKeys[key] { + fv.Effective, fv.Own = maskPresent(wdGet(res.eff, key)), maskPresent(wdGet(own, key)) + } else { + fv.Effective, fv.Own = wdGet(res.eff, key), wdGet(own, key) + } + fields[key] = fv + } + return map[string]any{"editableLayer": editable, "fields": fields} +} + +// webDavView builds the masked, client-safe response body. It exposes a "user" +// scope for everyone plus, for org admins, an "org" scope. +func (s *Server) webDavView(who *callerIdentity, res wdResolution) map[string]any { + out := map[string]any{ + "available": res.available, + "orgEnabled": res.orgEnabled, + "enabled": res.enabled, + "role": who.Role, + "orgId": who.OrgID, + "canEditOrg": res.canOrg, + "isSuperadmin": res.isSuper, + } + if res.isSuper { + out["editableLayer"] = "none" + out["scopes"] = map[string]any{"user": s.wdScopeView(res, "none")} + return out + } + scopes := map[string]any{"user": s.wdScopeView(res, "user")} + if res.canOrg { + scopes["org"] = s.wdScopeView(res, "org") + } + out["scopes"] = scopes + return out +} + +// PUT /api/integrations/webdav — save the caller's editable layer. Body: +// {enabled?, scope?, config?}. Fields locked above the caller are ignored; a +// secret left at the mask is preserved. +func (s *Server) handlePutWebDav(w http.ResponseWriter, r *http.Request) { + token := r.Header.Get("Authorization") + var body struct { + Enabled *bool `json:"enabled"` + Scope string `json:"scope"` + Config map[string]string `json:"config"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid json") + return + } + who, userRaw, ok := s.integrationCaller(w, r) + if !ok { + return + } + res := s.resolveWebDav(r.Context(), who, userRaw) + + // Resolve which layer this write targets. + editable := "user" + switch { + case res.isSuper: + editable = "none" + case strings.EqualFold(strings.TrimSpace(body.Scope), "org"): + if !res.canOrg { + writeError(w, http.StatusForbidden, "only an organization admin can edit organization settings") + return + } + editable = "org" + } + + // Overlay the fields the caller may change in this scope onto its own values. + newOwn := res.userOwn + if editable == "org" { + newOwn = res.orgOwn + } + for _, key := range append(append([]string{}, wdConnKeys...), "basePath") { + v, present := body.Config[key] + if !present || lockedFor(res.source[key], editable) { + continue + } + if wdSecretKeys[key] && v == openSkySecretMask { + continue // keep current secret + } + wdSet(&newOwn, key, strings.TrimSpace(v)) + } + + // Persist the organization layer (admins) via the service account. + if editable == "org" { + if who.OrgID == "" { + writeError(w, http.StatusForbidden, "your account is not attached to an organization") + return + } + if !s.admin.configured() { + writeError(w, http.StatusServiceUnavailable, "organization settings not configured on the server") + return + } + _, orgRaw := s.orgWebDav(r.Context(), who.OrgID) + newDoc := mergeWebDav(orgRaw, func(wd *wdStored) { + wd.Config = newOwn + if body.Enabled != nil { + wd.Disabled = !*body.Enabled // org master switch, stored inverted + } + }) + _, st, err := s.admin.do(r.Context(), http.MethodPatch, + "/api/collections/organizations/records/"+url.PathEscape(who.OrgID), + map[string]json.RawMessage{"pluginSettings": newDoc}) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()}) + return + } + if st != http.StatusOK { + writeError(w, http.StatusBadGateway, "could not save organization settings") + return + } + } + + // Persist the user record: the personal enable flag lives here (user/superadmin + // scope), and so does the personal config layer when this write targets user. + personalEnable := body.Enabled != nil && editable != "org" + if personalEnable || editable == "user" { + newDoc := mergeWebDav(userRaw, func(wd *wdStored) { + if personalEnable { + wd.Enabled = *body.Enabled + } + if editable == "user" { + wd.Config = newOwn + } + }) + if code, err := s.patchUserPluginSettings(r.Context(), token, who.ID, newDoc); err != nil { + writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()}) + return + } else if code != http.StatusOK { + writeError(w, http.StatusBadGateway, "could not save user settings") + return + } + } + + // Re-resolve and return the fresh view. + fresh, st, err := s.pbAuthRefresh(r.Context(), token) + if err != nil || st != http.StatusOK || fresh == nil { + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) + return + } + res2 := s.resolveWebDav(r.Context(), who, fresh.Record["pluginSettings"]) + writeJSON(w, http.StatusOK, s.webDavView(who, res2)) +} + +// POST /api/integrations/webdav/health — live probe using the caller's resolved +// config. Never returns secrets. +func (s *Server) handleWebDavHealth(w http.ResponseWriter, r *http.Request) { + who, userRaw, ok := s.integrationCaller(w, r) + if !ok { + return + } + if !who.isSuperadmin() { + if _, _, ok := s.plugins.RawConfig(webDavPlugin); !ok { + writeError(w, http.StatusNotFound, "unknown plugin") + return + } + } + res := s.resolveWebDav(r.Context(), who, userRaw) + if !res.available { + writeJSON(w, http.StatusOK, map[string]any{"health": map[string]any{ + "status": "down", "detail": "WebDAV is disabled by the administrator"}}) + return + } + if !res.orgEnabled { + writeJSON(w, http.StatusOK, map[string]any{"health": map[string]any{ + "status": "down", "detail": "WebDAV is disabled for your organization"}}) + return + } + if strings.TrimSpace(res.eff.BaseURL) == "" { + writeJSON(w, http.StatusOK, map[string]any{"health": map[string]any{ + "status": "down", "detail": "No server configured — set a server URL to connect"}}) + return + } + cfg := map[string]string{} + for _, k := range append(append([]string{}, wdConnKeys...), "basePath") { + cfg[k] = wdGet(res.eff, k) + } + h, err := s.plugins.HealthCheckWith(r.Context(), webDavPlugin, cfg) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"health": h}) +} diff --git a/API Server/internal/api/orgs.go b/API Server/internal/api/orgs.go new file mode 100644 index 0000000..5d5c77d --- /dev/null +++ b/API Server/internal/api/orgs.go @@ -0,0 +1,201 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/url" + "strings" +) + +// orgView is the trimmed organization shape returned to clients. +type orgView struct { + ID string `json:"id"` + Name string `json:"name"` + Created string `json:"created"` +} + +// orgNameMap returns an id→name map of all organizations via the service +// account. On any error it returns an empty (non-nil) map so callers can index +// it safely. +func (s *Server) orgNameMap(ctx context.Context) map[string]string { + out := map[string]string{} + if !s.admin.configured() { + return out + } + data, status, err := s.admin.do(ctx, http.MethodGet, + "/api/collections/organizations/records?perPage=500&fields=id,name", nil) + if err != nil || status != http.StatusOK { + return out + } + var list struct { + Items []orgView `json:"items"` + } + _ = json.Unmarshal(data, &list) + for _, o := range list.Items { + out[o.ID] = o.Name + } + return out +} + +// orgName resolves a single organization's name (best effort; "" on miss). +func (s *Server) orgName(ctx context.Context, id string) string { + if id == "" || !s.admin.configured() { + return "" + } + data, status, err := s.admin.do(ctx, http.MethodGet, + "/api/collections/organizations/records/"+url.PathEscape(id)+"?fields=id,name", nil) + if err != nil || status != http.StatusOK { + return "" + } + var o orgView + _ = json.Unmarshal(data, &o) + return o.Name +} + +// GET /api/orgs — list organizations (manager only). Superadmins see all; +// admins see only their own organization. +func (s *Server) handleListOrgs(w http.ResponseWriter, r *http.Request) { + who := caller(r) + path := "/api/collections/organizations/records?perPage=500&sort=name&fields=id,name,created" + if who != nil && !who.isSuperadmin() { + if who.OrgID == "" { + writeJSON(w, http.StatusOK, map[string]any{"organizations": []orgView{}}) + return + } + path += "&filter=" + url.QueryEscape("id = \""+who.OrgID+"\"") + } + data, status, err := s.admin.do(r.Context(), http.MethodGet, path, nil) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()}) + return + } + if status != http.StatusOK { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = w.Write(data) + return + } + var list struct { + Items []orgView `json:"items"` + } + _ = json.Unmarshal(data, &list) + writeJSON(w, http.StatusOK, map[string]any{"organizations": list.Items}) +} + +// POST /api/orgs — create an organization (superadmin only). Body: {name}. +func (s *Server) handleCreateOrg(w http.ResponseWriter, r *http.Request) { + name, ok := decodeOrgName(w, r) + if !ok { + return + } + data, status, err := s.admin.do(r.Context(), http.MethodPost, + "/api/collections/organizations/records", map[string]any{"name": name}) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()}) + return + } + if status != http.StatusOK { + // Relay PocketBase's error (e.g. duplicate name violates the unique index). + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = w.Write(data) + return + } + var org orgView + _ = json.Unmarshal(data, &org) + writeJSON(w, http.StatusCreated, map[string]any{"organization": org}) +} + +// PATCH /api/orgs/{id} — rename an organization (superadmin only). Body: {name}. +func (s *Server) handleUpdateOrg(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if id == "" { + writeError(w, http.StatusBadRequest, "missing organization id") + return + } + name, ok := decodeOrgName(w, r) + if !ok { + return + } + data, status, err := s.admin.do(r.Context(), http.MethodPatch, + "/api/collections/organizations/records/"+url.PathEscape(id), map[string]any{"name": name}) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()}) + return + } + if status != http.StatusOK { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = w.Write(data) + return + } + var org orgView + _ = json.Unmarshal(data, &org) + writeJSON(w, http.StatusOK, map[string]any{"organization": org}) +} + +// DELETE /api/orgs/{id} — delete an organization (superadmin only). Refused +// while the org still has members, to avoid silently orphaning users. +func (s *Server) handleDeleteOrg(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if id == "" { + writeError(w, http.StatusBadRequest, "missing organization id") + return + } + + // Guard: block deletion if any user still belongs to this org. + countPath := "/api/collections/users/records?perPage=1&fields=id&filter=" + + url.QueryEscape("organization = \""+id+"\"") + data, status, err := s.admin.do(r.Context(), http.MethodGet, countPath, nil) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()}) + return + } + if status == http.StatusOK { + var page struct { + TotalItems int `json:"totalItems"` + } + _ = json.Unmarshal(data, &page) + if page.TotalItems > 0 { + writeError(w, http.StatusConflict, "organization still has members; reassign or remove them first") + return + } + } + + data, status, err = s.admin.do(r.Context(), http.MethodDelete, + "/api/collections/organizations/records/"+url.PathEscape(id), nil) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()}) + return + } + if status != http.StatusOK && status != http.StatusNoContent { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = w.Write(data) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +// decodeOrgName parses and validates a {name} body, writing an error response +// and returning ok=false on failure. +func decodeOrgName(w http.ResponseWriter, r *http.Request) (string, bool) { + var body struct { + Name string `json:"name"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid json") + return "", false + } + name := strings.TrimSpace(body.Name) + if name == "" { + writeError(w, http.StatusBadRequest, "organization name is required") + return "", false + } + if len(name) > 120 { + writeError(w, http.StatusBadRequest, "organization name is too long (max 120)") + return "", false + } + return name, true +} diff --git a/API Server/internal/api/panel.go b/API Server/internal/api/panel.go new file mode 100644 index 0000000..1d682eb --- /dev/null +++ b/API Server/internal/api/panel.go @@ -0,0 +1,23 @@ +package api + +import ( + "embed" + "io/fs" + "net/http" +) + +// The PilotVault web panel: a Vue 3 + Tailwind app (source in panel/, built +// with `npm run build` into dist/) embedded at compile time and served at the +// server root. +// +//go:embed all:dist +var panelFS embed.FS + +// panelHandler serves the built panel assets. +func panelHandler() http.Handler { + sub, err := fs.Sub(panelFS, "dist") + if err != nil { + panic(err) // embedded dist is malformed; unreachable in a valid build + } + return http.FileServerFS(sub) +} diff --git a/API Server/internal/api/plugins.go b/API Server/internal/api/plugins.go new file mode 100644 index 0000000..5c3afb4 --- /dev/null +++ b/API Server/internal/api/plugins.go @@ -0,0 +1,108 @@ +package api + +import ( + "encoding/json" + "net/http" + "strings" + + "pilotvault/apiserver/internal/plugins" +) + +// GET /api/admin/plugins — every known plugin (registry ∪ persisted), secrets masked. +func (s *Server) handleListPlugins(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{"plugins": s.plugins.List()}) +} + +// GET /api/admin/plugins/{name} — one plugin's view. +func (s *Server) handleGetPlugin(w http.ResponseWriter, r *http.Request) { + v, ok := s.plugins.Get(r.PathValue("name")) + if !ok { + writeError(w, http.StatusNotFound, "unknown plugin") + return + } + writeJSON(w, http.StatusOK, map[string]any{"plugin": v}) +} + +// PUT /api/admin/plugins/{name} — enable/disable + merge config. Body: +// {enabled?, config?}. A secret left at the mask keeps its stored value. +func (s *Server) handleUpdatePlugin(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + current, ok := s.plugins.Get(name) + if !ok { + writeError(w, http.StatusNotFound, "unknown plugin") + return + } + var body struct { + Enabled *bool `json:"enabled"` + Config map[string]string `json:"config"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid json") + return + } + enabled := current.Enabled + if body.Enabled != nil { + enabled = *body.Enabled + } + + v, err := s.plugins.Upsert(r.Context(), name, enabled, body.Config) + if err != nil { + if plugins.IsUnknown(err) { + writeError(w, http.StatusNotFound, "unknown plugin") + return + } + // A failed init (e.g. bad credentials) is reported but the state was saved. + writeJSON(w, http.StatusOK, map[string]any{"plugin": v, "warning": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"plugin": v}) +} + +// POST /api/admin/plugins — register an external (remote HTTP) plugin. Body: +// {name, baseURL, provider?}. This is the "add a plugin without a rebuild" path. +func (s *Server) handleRegisterPlugin(w http.ResponseWriter, r *http.Request) { + var body struct { + Name string `json:"name"` + BaseURL string `json:"baseURL"` + Provider string `json:"provider"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid json") + return + } + body.Name = strings.TrimSpace(body.Name) + if body.Name == "" || body.BaseURL == "" { + writeError(w, http.StatusBadRequest, "name and baseURL are required") + return + } + if err := s.plugins.RegisterExternal(body.Name, body.BaseURL, body.Provider); err != nil { + writeError(w, http.StatusConflict, err.Error()) + return + } + v, _ := s.plugins.Get(body.Name) + writeJSON(w, http.StatusCreated, map[string]any{"plugin": v}) +} + +// DELETE /api/admin/plugins/{name} — remove an external plugin (builtins can only +// be disabled). +func (s *Server) handleDeletePlugin(w http.ResponseWriter, r *http.Request) { + if err := s.plugins.Remove(r.Context(), r.PathValue("name")); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +// POST /api/admin/plugins/{name}/health — run a health check now. +func (s *Server) handlePluginHealth(w http.ResponseWriter, r *http.Request) { + h, err := s.plugins.HealthCheck(r.Context(), r.PathValue("name")) + if err != nil { + if plugins.IsUnknown(err) { + writeError(w, http.StatusNotFound, "unknown plugin") + return + } + writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"health": h}) +} diff --git a/API Server/internal/api/preferences.go b/API Server/internal/api/preferences.go new file mode 100644 index 0000000..b38d1c2 --- /dev/null +++ b/API Server/internal/api/preferences.go @@ -0,0 +1,137 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" +) + +// User preferences are stored as a JSON field named "preferences" on the +// PocketBase `users` auth record. Because every request carries the caller's own +// auth token, PocketBase enforces that a user can only read and write their own +// record — the API Server never needs admin credentials for this. + +// pbAuthResp is the subset of PocketBase's auth-refresh response we care about. +type pbAuthResp struct { + Token string `json:"token"` + Record map[string]json.RawMessage `json:"record"` +} + +// pbAuthRefresh resolves the caller's user record (id + fields incl. preferences) +// from their token. Returns the parsed record, the upstream status, and any +// transport error. +func (s *Server) pbAuthRefresh(ctx context.Context, token string) (*pbAuthResp, int, error) { + req, _ := http.NewRequestWithContext(ctx, http.MethodPost, + s.auth.url()+"/api/collections/users/auth-refresh", nil) + req.Header.Set("Authorization", token) + + resp, err := s.auth.client.Do(req) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + data, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return nil, resp.StatusCode, nil + } + var out pbAuthResp + if err := json.Unmarshal(data, &out); err != nil { + return nil, resp.StatusCode, err + } + return &out, resp.StatusCode, nil +} + +// GET /api/preferences (Authorization: ) +// Returns {"preferences": } for the authenticated user. +func (s *Server) handleGetPreferences(w http.ResponseWriter, r *http.Request) { + token := r.Header.Get("Authorization") + if token == "" { + writeError(w, http.StatusUnauthorized, "missing token") + return + } + rec, status, err := s.pbAuthRefresh(r.Context(), token) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()}) + return + } + if status != http.StatusOK || rec == nil { + writeError(w, http.StatusUnauthorized, "invalid or expired token") + return + } + prefs := rec.Record["preferences"] + if len(prefs) == 0 { + prefs = json.RawMessage("null") + } + writeJSON(w, http.StatusOK, map[string]json.RawMessage{"preferences": prefs}) +} + +// PUT /api/preferences (Authorization: ) +// Body: {"preferences": {...}} — persists the blob onto the user's record. +func (s *Server) handlePutPreferences(w http.ResponseWriter, r *http.Request) { + token := r.Header.Get("Authorization") + if token == "" { + writeError(w, http.StatusUnauthorized, "missing token") + return + } + + var body struct { + Preferences json.RawMessage `json:"preferences"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid json") + return + } + if len(body.Preferences) == 0 { + body.Preferences = json.RawMessage("{}") + } + + // Resolve the caller's record id (PocketBase authorises the PATCH against it). + rec, status, err := s.pbAuthRefresh(r.Context(), token) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()}) + return + } + if status != http.StatusOK || rec == nil { + writeError(w, http.StatusUnauthorized, "invalid or expired token") + return + } + var id string + _ = json.Unmarshal(rec.Record["id"], &id) + if id == "" { + writeError(w, http.StatusBadGateway, "could not resolve user id") + return + } + + patch, _ := json.Marshal(map[string]json.RawMessage{"preferences": body.Preferences}) + req, _ := http.NewRequestWithContext(r.Context(), http.MethodPatch, + s.auth.url()+"/api/collections/users/records/"+id, bytes.NewReader(patch)) + req.Header.Set("Authorization", token) + req.Header.Set("Content-Type", "application/json") + + resp, err := s.auth.client.Do(req) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()}) + return + } + defer resp.Body.Close() + data, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + // Relay PocketBase's error (e.g. missing "preferences" field on schema). + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(resp.StatusCode) + _, _ = w.Write(data) + return + } + + // Return just the saved preferences blob. + var saved struct { + Preferences json.RawMessage `json:"preferences"` + } + _ = json.Unmarshal(data, &saved) + if len(saved.Preferences) == 0 { + saved.Preferences = json.RawMessage("null") + } + writeJSON(w, http.StatusOK, map[string]json.RawMessage{"preferences": saved.Preferences}) +} diff --git a/API Server/internal/api/respond.go b/API Server/internal/api/respond.go new file mode 100644 index 0000000..3704528 --- /dev/null +++ b/API Server/internal/api/respond.go @@ -0,0 +1,29 @@ +package api + +import ( + "encoding/json" + "log" + "net/http" +) + +// writeJSON writes v as a JSON response with the given status code. +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if v == nil { + return + } + if err := json.NewEncoder(w).Encode(v); err != nil { + log.Printf("writeJSON: %v", err) + } +} + +// errorBody is the standard error envelope. +type errorBody struct { + Error string `json:"error"` +} + +// writeError writes a JSON error response. +func writeError(w http.ResponseWriter, status int, msg string) { + writeJSON(w, status, errorBody{Error: msg}) +} diff --git a/API Server/internal/api/server.go b/API Server/internal/api/server.go new file mode 100644 index 0000000..e6aa30d --- /dev/null +++ b/API Server/internal/api/server.go @@ -0,0 +1,240 @@ +package api + +import ( + "bufio" + "context" + "log" + "net" + "net/http" + "sync" + "time" + + "pilotvault/apiserver/internal/config" + "pilotvault/apiserver/internal/hub" + "pilotvault/apiserver/internal/plugins" + _ "pilotvault/apiserver/internal/plugins/builtin" // register built-in plugins +) + +// Server wires together the HTTP handlers and their dependencies. +type Server struct { + mu sync.RWMutex // guards the mutable PocketBase connection in cfg + cfg config.Config + hub *hub.Hub + auth *authProxy + admin *adminClient + plugins *plugins.Manager +} + +// pbURL returns the current PocketBase base URL. +func (s *Server) pbURL() string { + s.mu.RLock() + defer s.mu.RUnlock() + return s.cfg.PocketBaseURL +} + +// pbSettings snapshots the PocketBase connection for the settings endpoints. +func (s *Server) pbSettings() (url, adminEmail, adminPassword string) { + s.mu.RLock() + defer s.mu.RUnlock() + return s.cfg.PocketBaseURL, s.cfg.PocketBaseAdminEmail, s.cfg.PocketBaseAdminPassword +} + +// setPBConfig retargets the PocketBase connection at runtime: it updates the +// cached config and repoints both the auth proxy and the admin service account. +func (s *Server) setPBConfig(url, adminEmail, adminPassword string) { + s.mu.Lock() + s.cfg.PocketBaseURL = url + s.cfg.PocketBaseAdminEmail = adminEmail + s.cfg.PocketBaseAdminPassword = adminPassword + s.mu.Unlock() + + s.auth.setBaseURL(url) + s.admin.reconfigure(url, adminEmail, adminPassword) +} + +// New constructs a Server. +func New(cfg config.Config, h *hub.Hub) *Server { + return &Server{ + cfg: cfg, + hub: h, + auth: newAuthProxy(cfg.PocketBaseURL), + admin: newAdminClient(cfg.PocketBaseURL, cfg.PocketBaseAdminEmail, cfg.PocketBaseAdminPassword), + plugins: plugins.NewManager(cfg.PluginsFile), + } +} + +// StartPlugins loads persisted plugin state and initialises enabled plugins. +func (s *Server) StartPlugins() error { return s.plugins.Load() } + +// Stop releases server-held resources (currently: plugin instances). +func (s *Server) Stop(ctx context.Context) { s.plugins.Shutdown(ctx) } + +// Handler returns the root HTTP handler with all routes registered. +func (s *Server) Handler() http.Handler { + mux := http.NewServeMux() + + // Web panel (public) — embedded Vue + Tailwind app. Only the explicit panel + // paths are routed to it so unknown /api/* paths still 404 as JSON. + panel := panelHandler() + mux.Handle("GET /{$}", panel) + mux.Handle("GET /assets/", panel) + mux.Handle("GET /favicon.svg", panel) + + // Health (public) + mux.HandleFunc("GET /healthz", s.handleHealth) + mux.HandleFunc("GET /api/health", s.handleHealth) + mux.HandleFunc("GET /api/status", s.handleStatus) + + // Auth — proxied to the PocketBase kept behind this server. + mux.HandleFunc("POST /api/auth/login", s.handleAuthLogin) + mux.HandleFunc("GET /api/auth/validate", s.handleAuthValidate) + + // Current user (id, email, role) resolved from the caller's token. + mux.HandleFunc("GET /api/me", s.handleMe) + + // User preferences — persisted on the caller's own PocketBase user record. + mux.HandleFunc("GET /api/preferences", s.handleGetPreferences) + mux.HandleFunc("PUT /api/preferences", s.handlePutPreferences) + + // Plugin integrations for end users — per-user/per-org settings resolved + // through the superadmin→org→user cascade. Role logic lives inside the + // handlers (org users must reach them too), so no requireManager wrapper. + mux.HandleFunc("GET /api/integrations/opensky", s.handleGetOpenSky) + mux.HandleFunc("PUT /api/integrations/opensky", s.handlePutOpenSky) + mux.HandleFunc("POST /api/integrations/opensky/health", s.handleOpenSkyHealth) + mux.HandleFunc("GET /api/integrations/filetransfer", s.handleGetFileTransfer) + mux.HandleFunc("PUT /api/integrations/filetransfer", s.handlePutFileTransfer) + mux.HandleFunc("POST /api/integrations/filetransfer/health", s.handleFileTransferHealth) + mux.HandleFunc("GET /api/integrations/localstorage", s.handleGetLocalStorage) + mux.HandleFunc("PUT /api/integrations/localstorage", s.handlePutLocalStorage) + mux.HandleFunc("POST /api/integrations/localstorage/health", s.handleLocalStorageHealth) + mux.HandleFunc("GET /api/integrations/webdav", s.handleGetWebDav) + mux.HandleFunc("PUT /api/integrations/webdav", s.handlePutWebDav) + mux.HandleFunc("POST /api/integrations/webdav/health", s.handleWebDavHealth) + + // User-management — gated on the caller being a manager (admin or superadmin). + // Admins are scoped to their own organization inside each handler. + mux.HandleFunc("GET /api/users", s.requireManager(s.handleListUsers)) + mux.HandleFunc("POST /api/users", s.requireManager(s.handleCreateUser)) + mux.HandleFunc("PATCH /api/users/{id}", s.requireManager(s.handleUpdateUser)) + mux.HandleFunc("DELETE /api/users/{id}", s.requireManager(s.handleDeleteUser)) + + // Organizations — listing is manager-scoped; create/edit/delete are + // superadmin-only (a superadmin spans all organizations). + mux.HandleFunc("GET /api/orgs", s.requireManager(s.handleListOrgs)) + mux.HandleFunc("POST /api/orgs", s.requireSuperadmin(s.handleCreateOrg)) + mux.HandleFunc("PATCH /api/orgs/{id}", s.requireSuperadmin(s.handleUpdateOrg)) + mux.HandleFunc("DELETE /api/orgs/{id}", s.requireSuperadmin(s.handleDeleteOrg)) + + // PocketBase connection settings — superadmin only. These do NOT require the + // service account to already be configured (they exist to configure it). + mux.HandleFunc("GET /api/admin/pb-config", s.requireSuperadminAuth(s.handleGetPBConfig)) + mux.HandleFunc("POST /api/admin/pb-config/test", s.requireSuperadminAuth(s.handleTestPBConfig)) + mux.HandleFunc("PUT /api/admin/pb-config", s.requireSuperadminAuth(s.handleUpdatePBConfig)) + + // Plugins — external-service integrations, managed by a superadmin. + mux.HandleFunc("GET /api/admin/plugins", s.requireSuperadminAuth(s.handleListPlugins)) + mux.HandleFunc("POST /api/admin/plugins", s.requireSuperadminAuth(s.handleRegisterPlugin)) + mux.HandleFunc("GET /api/admin/plugins/{name}", s.requireSuperadminAuth(s.handleGetPlugin)) + mux.HandleFunc("PUT /api/admin/plugins/{name}", s.requireSuperadminAuth(s.handleUpdatePlugin)) + mux.HandleFunc("DELETE /api/admin/plugins/{name}", s.requireSuperadminAuth(s.handleDeletePlugin)) + mux.HandleFunc("POST /api/admin/plugins/{name}/health", s.requireSuperadminAuth(s.handlePluginHealth)) + + // Device / dashboard API. + mux.HandleFunc("GET /api/devices", s.handleListDevices) + mux.HandleFunc("GET /api/devices/{id}/track", s.handleTrack) + mux.HandleFunc("POST /api/devices/{id}/command", s.handleCommand) + mux.HandleFunc("DELETE /api/devices/{id}", s.handleForget) + mux.HandleFunc("POST /api/telemetry", s.handleTelemetryPost) + + // Websockets: device uplink (Fly App) and dashboard stream (Web App/panel). + mux.HandleFunc("GET /ws/device", s.handleDeviceWS) + mux.HandleFunc("GET /ws/ui", s.handleUIWS) + + return s.withMiddleware(mux) +} + +// withMiddleware applies panic recovery, CORS, and request logging globally. +func (s *Server) withMiddleware(next http.Handler) http.Handler { + return s.recoverer(s.cors(s.logger(next))) +} + +func (s *Server) logger(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + sw := &statusWriter{ResponseWriter: w, status: http.StatusOK} + next.ServeHTTP(sw, r) + log.Printf("%s %s %d %s", r.Method, r.URL.Path, sw.status, time.Since(start).Round(time.Millisecond)) + }) +} + +func (s *Server) recoverer(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if rec := recover(); rec != nil { + log.Printf("panic: %v", rec) + writeError(w, http.StatusInternalServerError, "internal error") + } + }() + next.ServeHTTP(w, r) + }) +} + +func (s *Server) cors(next http.Handler) http.Handler { + allowed := map[string]bool{} + wildcard := false + for _, o := range s.cfg.AllowOrigins { + if o == "*" { + wildcard = true + } + allowed[o] = true + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + if origin != "" && (wildcard || allowed[origin]) { + if wildcard { + w.Header().Set("Access-Control-Allow-Origin", "*") + } else { + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Add("Vary", "Origin") + } + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type") + } + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + next.ServeHTTP(w, r) + }) +} + +// statusWriter captures the response status code for logging. +type statusWriter struct { + http.ResponseWriter + status int + wrote bool +} + +func (w *statusWriter) WriteHeader(code int) { + if !w.wrote { + w.status = code + w.wrote = true + } + w.ResponseWriter.WriteHeader(code) +} + +func (w *statusWriter) Write(b []byte) (int, error) { + w.wrote = true + return w.ResponseWriter.Write(b) +} + +// Hijack lets the websocket upgrader take over the underlying connection even +// though the logger has wrapped the ResponseWriter. +func (w *statusWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { + h, ok := w.ResponseWriter.(http.Hijacker) + if !ok { + return nil, nil, http.ErrNotSupported + } + return h.Hijack() +} diff --git a/API Server/internal/api/settings.go b/API Server/internal/api/settings.go new file mode 100644 index 0000000..2810b1c --- /dev/null +++ b/API Server/internal/api/settings.go @@ -0,0 +1,159 @@ +package api + +import ( + "context" + "encoding/json" + "log" + "net/http" + "strconv" + "strings" + + "pilotvault/apiserver/internal/config" +) + +// pbProbe is the outcome of testing a PocketBase connection: whether the base +// URL answers its health check and whether the service-account credentials +// authenticate as a superuser. +type pbProbe struct { + Reachable bool `json:"reachable"` + HTTPStatus int `json:"httpStatus,omitempty"` + LatencyMs int64 `json:"latencyMs,omitempty"` + Superuser bool `json:"superuser"` + Detail string `json:"detail,omitempty"` +} + +// pbConfigView is the PocketBase-connection shape returned to the panel. The +// password itself is never sent back — only whether one is set. +type pbConfigView struct { + URL string `json:"url"` + AdminEmail string `json:"adminEmail"` + AdminConfigured bool `json:"adminConfigured"` + Probe pbProbe `json:"probe"` +} + +// probePB checks a PocketBase base URL's health and, when credentials are given, +// whether they authenticate as a superuser. It uses the short-timeout +// healthClient so a hung PocketBase cannot stall the request. +func (s *Server) probePB(ctx context.Context, url, email, password string) pbProbe { + h := probe(ctx, url+"/api/health") + p := pbProbe{Reachable: h.Status == "ok", HTTPStatus: h.HTTPStatus, LatencyMs: h.LatencyMs} + if h.Error != "" { + p.Detail = h.Error + } + if email != "" && password != "" { + _, st, err := superuserAuth(ctx, healthClient, url, email, password) + if err == nil { + p.Superuser = true + } else if p.Reachable { + p.Detail = "superuser auth failed" + if st > 0 { + p.Detail += " (HTTP " + strconv.Itoa(st) + ")" + } + } + } + return p +} + +// normalizePBURL trims, defaults the scheme to http, and drops a trailing slash. +func normalizePBURL(u string) string { + u = strings.TrimSpace(u) + if u == "" { + return "" + } + if !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") { + u = "http://" + u + } + return strings.TrimRight(u, "/") +} + +// GET /api/admin/pb-config — current PocketBase connection + a live probe. +func (s *Server) handleGetPBConfig(w http.ResponseWriter, r *http.Request) { + url, email, password := s.pbSettings() + writeJSON(w, http.StatusOK, pbConfigView{ + URL: url, + AdminEmail: email, + AdminConfigured: email != "" && password != "", + Probe: s.probePB(r.Context(), url, email, password), + }) +} + +// pbConfigBody is the editable connection payload. A blank adminPassword means +// "keep the current one"; a blank adminEmail/url means "keep current". +type pbConfigBody struct { + URL string `json:"url"` + AdminEmail string `json:"adminEmail"` + AdminPassword string `json:"adminPassword"` +} + +// resolve merges a request body onto the current settings, applying the +// keep-current semantics for blank fields. +func (s *Server) resolve(b pbConfigBody) (url, email, password string) { + curURL, curEmail, curPassword := s.pbSettings() + url = normalizePBURL(b.URL) + if url == "" { + url = curURL + } + email = strings.TrimSpace(b.AdminEmail) + if email == "" { + email = curEmail + } + password = b.AdminPassword + if password == "" { + password = curPassword + } + return +} + +// POST /api/admin/pb-config/test — probe a candidate connection WITHOUT applying +// it, so a superadmin can verify before saving. +func (s *Server) handleTestPBConfig(w http.ResponseWriter, r *http.Request) { + var b pbConfigBody + if err := json.NewDecoder(r.Body).Decode(&b); err != nil { + writeError(w, http.StatusBadRequest, "invalid json") + return + } + url, email, password := s.resolve(b) + writeJSON(w, http.StatusOK, s.probePB(r.Context(), url, email, password)) +} + +// PUT /api/admin/pb-config — apply a new PocketBase connection at runtime and +// persist it to .env. Returns the new config plus a fresh probe. +func (s *Server) handleUpdatePBConfig(w http.ResponseWriter, r *http.Request) { + var b pbConfigBody + if err := json.NewDecoder(r.Body).Decode(&b); err != nil { + writeError(w, http.StatusBadRequest, "invalid json") + return + } + if normalizePBURL(b.URL) == "" { + writeError(w, http.StatusBadRequest, "a PocketBase URL is required") + return + } + url, email, password := s.resolve(b) + + // Apply at runtime, then persist so the change survives a restart. + s.setPBConfig(url, email, password) + if err := config.UpdateEnvFile(config.EnvFile, map[string]string{ + "POCKETBASE_URL": url, + "POCKETBASE_ADMIN_EMAIL": email, + "POCKETBASE_ADMIN_PASSWORD": password, + }); err != nil { + // The runtime change already took effect; report that persistence failed. + log.Printf("pb-config: persist to %s failed: %v", config.EnvFile, err) + writeJSON(w, http.StatusOK, map[string]any{ + "config": pbConfigView{ + URL: url, AdminEmail: email, AdminConfigured: email != "" && password != "", + Probe: s.probePB(r.Context(), url, email, password), + }, + "warning": "applied for this session, but could not be saved to .env: " + err.Error(), + }) + return + } + + log.Printf("pb-config: PocketBase connection updated to %s (by superadmin)", url) + writeJSON(w, http.StatusOK, map[string]any{ + "config": pbConfigView{ + URL: url, AdminEmail: email, AdminConfigured: email != "" && password != "", + Probe: s.probePB(r.Context(), url, email, password), + }, + }) +} diff --git a/API Server/internal/api/status.go b/API Server/internal/api/status.go new file mode 100644 index 0000000..245f189 --- /dev/null +++ b/API Server/internal/api/status.go @@ -0,0 +1,65 @@ +package api + +import ( + "context" + "io" + "net/http" + "sync" + "time" +) + +// svcHealth is the health of one upstream service, as shown on the panel. +type svcHealth struct { + Status string `json:"status"` // "ok" | "down" + LatencyMs int64 `json:"latencyMs,omitempty"` + HTTPStatus int `json:"httpStatus,omitempty"` + URL string `json:"url,omitempty"` + Error string `json:"error,omitempty"` +} + +// healthClient is a short-timeout client for probing upstreams so a hung +// dependency can't stall the status endpoint. +var healthClient = &http.Client{Timeout: 4 * time.Second} + +// probe does a GET against url and classifies the result. +func probe(ctx context.Context, url string) svcHealth { + start := time.Now() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return svcHealth{Status: "down", URL: url, Error: err.Error()} + } + resp, err := healthClient.Do(req) + lat := time.Since(start).Milliseconds() + if err != nil { + return svcHealth{Status: "down", URL: url, LatencyMs: lat, Error: err.Error()} + } + defer resp.Body.Close() + _, _ = io.Copy(io.Discard, resp.Body) + status := "ok" + if resp.StatusCode >= 400 { + status = "down" + } + return svcHealth{Status: status, LatencyMs: lat, HTTPStatus: resp.StatusCode, URL: url} +} + +// GET /api/status — aggregate health of the API Server and its neighbours +// (PocketBase and the Web App), probed server-side. The panel polls this so the +// browser never has to reach PocketBase or the Web App directly. +func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { + var pb, web svcHealth + var wg sync.WaitGroup + wg.Add(2) + go func() { defer wg.Done(); pb = probe(r.Context(), s.pbURL()+"/api/health") }() + go func() { defer wg.Done(); web = probe(r.Context(), s.cfg.WebAppURL+"/healthz") }() + wg.Wait() + + writeJSON(w, http.StatusOK, map[string]any{ + "apiServer": map[string]any{ + "status": "ok", + "devices": s.hub.OnlineCount(), + "known": len(s.hub.Snapshot()), + }, + "pocketBase": pb, + "webApp": web, + }) +} diff --git a/API Server/internal/api/telemetry.go b/API Server/internal/api/telemetry.go new file mode 100644 index 0000000..9511aa8 --- /dev/null +++ b/API Server/internal/api/telemetry.go @@ -0,0 +1,22 @@ +package api + +import ( + "encoding/json" + "net/http" +) + +// POST /api/telemetry?id= — HTTP alternative to the websocket for +// pushing a single event (handy for testing with curl). +func (s *Server) handleTelemetryPost(w http.ResponseWriter, r *http.Request) { + id := r.URL.Query().Get("id") + if id == "" { + id = "default" + } + var raw map[string]any + if err := json.NewDecoder(r.Body).Decode(&raw); err != nil { + writeError(w, http.StatusBadRequest, "invalid json") + return + } + s.hub.Ingest(id, raw) + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} diff --git a/API Server/internal/api/users.go b/API Server/internal/api/users.go new file mode 100644 index 0000000..670e772 --- /dev/null +++ b/API Server/internal/api/users.go @@ -0,0 +1,498 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/url" + "strings" +) + +// Role names as stored in the PocketBase users.role select field. Missing/empty +// is treated as roleUser. +const ( + roleUser = "user" + roleAdmin = "admin" + roleSuperadmin = "superadmin" +) + +// callerIdentity is who the request token belongs to. +type callerIdentity struct { + ID string + Email string + Role string + OrgID string // organization record id ("" when the user belongs to no org) +} + +func (c *callerIdentity) isSuperadmin() bool { return c != nil && c.Role == roleSuperadmin } +func (c *callerIdentity) isManager() bool { + return c != nil && (c.Role == roleAdmin || c.Role == roleSuperadmin) +} + +// identify resolves the caller's id/email/role/org from their PocketBase token. +// Role defaults to "user" when the field is empty/absent. +func (s *Server) identify(ctx context.Context, token string) (*callerIdentity, int, error) { + rec, status, err := s.pbAuthRefresh(ctx, token) + if err != nil { + return nil, 0, err + } + if status != http.StatusOK || rec == nil { + return nil, status, nil + } + id := unquote(rec.Record["id"]) + email := unquote(rec.Record["email"]) + role := unquote(rec.Record["role"]) + if role == "" { + role = roleUser + } + org := unquote(rec.Record["organization"]) + return &callerIdentity{ID: id, Email: email, Role: role, OrgID: org}, http.StatusOK, nil +} + +func unquote(raw json.RawMessage) string { + var s string + _ = json.Unmarshal(raw, &s) + return s +} + +// GET /api/me — the authenticated caller's identity, including organization. +func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) { + token := r.Header.Get("Authorization") + if token == "" { + writeError(w, http.StatusUnauthorized, "missing token") + return + } + who, status, err := s.identify(r.Context(), token) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()}) + return + } + if status != http.StatusOK || who == nil { + writeError(w, http.StatusUnauthorized, "invalid or expired token") + return + } + orgName := "" + if who.OrgID != "" { + orgName = s.orgName(r.Context(), who.OrgID) + } + writeJSON(w, http.StatusOK, map[string]any{ + "id": who.ID, + "email": who.Email, + "role": who.Role, + "organization": who.OrgID, + "organizationName": orgName, + }) +} + +// requireManager wraps a handler so only managers (admin or superadmin) may +// proceed. The caller's identity is stashed on the request context for reuse. +func (s *Server) requireManager(next http.HandlerFunc) http.HandlerFunc { + return s.requireRole(next, func(c *callerIdentity) bool { return c.isManager() }, "admin role required") +} + +// requireSuperadmin wraps a handler so only superadmins may proceed. +func (s *Server) requireSuperadmin(next http.HandlerFunc) http.HandlerFunc { + return s.requireRole(next, func(c *callerIdentity) bool { return c.isSuperadmin() }, "superadmin role required") +} + +// requireSuperadminAuth gates a handler on a valid superadmin token WITHOUT +// requiring the service account to be configured. Used by the PocketBase +// settings endpoints, whose whole purpose is to configure that service account. +func (s *Server) requireSuperadminAuth(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + token := r.Header.Get("Authorization") + if token == "" { + writeError(w, http.StatusUnauthorized, "missing token") + return + } + who, status, err := s.identify(r.Context(), token) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()}) + return + } + if status != http.StatusOK || who == nil { + writeError(w, http.StatusUnauthorized, "invalid or expired token") + return + } + if !who.isSuperadmin() { + writeError(w, http.StatusForbidden, "superadmin role required") + return + } + next(w, r.WithContext(context.WithValue(r.Context(), ctxCaller, who))) + } +} + +// requireRole is the shared gate: it needs the service account (all privileged +// management flows through it), a valid token, and a caller that satisfies ok. +func (s *Server) requireRole(next http.HandlerFunc, ok func(*callerIdentity) bool, denied string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !s.admin.configured() { + writeError(w, http.StatusServiceUnavailable, "user management not configured on the server") + return + } + token := r.Header.Get("Authorization") + if token == "" { + writeError(w, http.StatusUnauthorized, "missing token") + return + } + who, status, err := s.identify(r.Context(), token) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()}) + return + } + if status != http.StatusOK || who == nil { + writeError(w, http.StatusUnauthorized, "invalid or expired token") + return + } + if !ok(who) { + writeError(w, http.StatusForbidden, denied) + return + } + next(w, r.WithContext(context.WithValue(r.Context(), ctxCaller, who))) + } +} + +type ctxKey int + +const ctxCaller ctxKey = iota + +func caller(r *http.Request) *callerIdentity { + if v, ok := r.Context().Value(ctxCaller).(*callerIdentity); ok { + return v + } + return nil +} + +// userView is the trimmed user shape returned to managers. +type userView struct { + ID string `json:"id"` + Email string `json:"email"` + Role string `json:"role"` + Verified bool `json:"verified"` + Created string `json:"created"` + Organization string `json:"organization"` // org record id ("" = none) + OrganizationName string `json:"organizationName"` // resolved name ("" = none) +} + +// getUserRecord fetches a single user's id/email/role/organization via the +// service account. Returns nil (not an error) when the user does not exist. +func (s *Server) getUserRecord(ctx context.Context, id string) (*userView, error) { + path := "/api/collections/users/records/" + url.PathEscape(id) + "?fields=id,email,role,verified,organization" + data, status, err := s.admin.do(ctx, http.MethodGet, path, nil) + if err != nil { + return nil, err + } + if status != http.StatusOK { + return nil, nil + } + var v userView + if err := json.Unmarshal(data, &v); err != nil { + return nil, err + } + if v.Role == "" { + v.Role = roleUser + } + return &v, nil +} + +// GET /api/users — list users (manager only). Superadmins see everyone; +// admins see only their own organization's members. +func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) { + who := caller(r) + path := "/api/collections/users/records?perPage=500&sort=email&fields=id,email,role,verified,created,organization" + if who != nil && !who.isSuperadmin() { + // Admin: scope to their own organization. + if who.OrgID == "" { + // An org-less admin manages nobody. + writeJSON(w, http.StatusOK, map[string]any{"users": []userView{}}) + return + } + path += "&filter=" + url.QueryEscape("organization = \""+who.OrgID+"\"") + } + data, status, err := s.admin.do(r.Context(), http.MethodGet, path, nil) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()}) + return + } + if status != http.StatusOK { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = w.Write(data) + return + } + var list struct { + Items []userView `json:"items"` + } + _ = json.Unmarshal(data, &list) + names := s.orgNameMap(r.Context()) + for i := range list.Items { + if list.Items[i].Role == "" { + list.Items[i].Role = roleUser + } + list.Items[i].OrganizationName = names[list.Items[i].Organization] + } + writeJSON(w, http.StatusOK, map[string]any{"users": list.Items}) +} + +// POST /api/users — create a user (manager only). Body: {email, password, role, +// organization?}. Admins may only create within their own org and may not mint +// superadmins; superadmins may target any org (or none) and any role. +func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) { + who := caller(r) + var body struct { + Email string `json:"email"` + Password string `json:"password"` + Role string `json:"role"` + Organization string `json:"organization"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid json") + return + } + body.Email = strings.TrimSpace(strings.ToLower(body.Email)) + if body.Email == "" || !strings.Contains(body.Email, "@") { + writeError(w, http.StatusBadRequest, "a valid email is required") + return + } + if len(body.Password) < 8 { + writeError(w, http.StatusBadRequest, "password must be at least 8 characters") + return + } + + role, ok := normalizeRole(body.Role) + if !ok { + writeError(w, http.StatusBadRequest, "role must be 'user', 'admin', or 'superadmin'") + return + } + org := strings.TrimSpace(body.Organization) + + if !who.isSuperadmin() { + // Admin: no superadmins, and members are forced into the admin's own org. + if role == roleSuperadmin { + writeError(w, http.StatusForbidden, "only a superadmin can create superadmins") + return + } + if who.OrgID == "" { + writeError(w, http.StatusForbidden, "your account is not attached to an organization") + return + } + org = who.OrgID + } + + create := map[string]any{ + "email": body.Email, + "password": body.Password, + "passwordConfirm": body.Password, + "role": role, + "verified": true, + "emailVisibility": false, + } + // Only send organization when set; superadmins may deliberately omit it to + // create an org-less account. + if org != "" { + create["organization"] = org + } + + data, status, err := s.admin.do(r.Context(), http.MethodPost, "/api/collections/users/records", create) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()}) + return + } + if status != http.StatusOK { + // Relay PocketBase's validation error (e.g. duplicate email, bad org id). + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = w.Write(data) + return + } + var rec userView + _ = json.Unmarshal(data, &rec) + if rec.Role == "" { + rec.Role = role + } + rec.OrganizationName = s.orgName(r.Context(), rec.Organization) + writeJSON(w, http.StatusCreated, map[string]any{"user": rec}) +} + +// PATCH /api/users/{id} — edit a user (manager only). Any subset of +// {email, role, password, verified, organization} may be supplied. Admins are +// scoped to their own org and cannot touch superadmins or grant the superadmin +// role; nobody can demote their own role (avoids self-lockout). +func (s *Server) handleUpdateUser(w http.ResponseWriter, r *http.Request) { + who := caller(r) + id := r.PathValue("id") + if id == "" { + writeError(w, http.StatusBadRequest, "missing user id") + return + } + var body struct { + Email string `json:"email"` + Role string `json:"role"` + Password string `json:"password"` + Verified *bool `json:"verified"` + Organization *string `json:"organization"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid json") + return + } + + // Resolve the target so we can enforce org/role scoping. + target, err := s.getUserRecord(r.Context(), id) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()}) + return + } + if target == nil { + writeError(w, http.StatusNotFound, "user not found") + return + } + + if !who.isSuperadmin() { + // Admin scoping: target must be inside the admin's org and not a superadmin. + if who.OrgID == "" || target.Organization != who.OrgID { + writeError(w, http.StatusForbidden, "user is outside your organization") + return + } + if target.Role == roleSuperadmin { + writeError(w, http.StatusForbidden, "you cannot edit a superadmin") + return + } + } + + patch := map[string]any{} + + if email := strings.TrimSpace(strings.ToLower(body.Email)); email != "" { + if !strings.Contains(email, "@") { + writeError(w, http.StatusBadRequest, "a valid email is required") + return + } + patch["email"] = email + } + if body.Role != "" { + role, ok := normalizeRole(body.Role) + if !ok { + writeError(w, http.StatusBadRequest, "role must be 'user', 'admin', or 'superadmin'") + return + } + if !who.isSuperadmin() && role == roleSuperadmin { + writeError(w, http.StatusForbidden, "only a superadmin can grant the superadmin role") + return + } + if who != nil && who.ID == id && role != who.Role { + writeError(w, http.StatusBadRequest, "you cannot change your own role") + return + } + patch["role"] = role + } + if body.Password != "" { + if len(body.Password) < 8 { + writeError(w, http.StatusBadRequest, "password must be at least 8 characters") + return + } + patch["password"] = body.Password + patch["passwordConfirm"] = body.Password + } + if body.Verified != nil { + patch["verified"] = *body.Verified + } + // Organization moves are superadmin-only; admins cannot reassign membership. + if body.Organization != nil { + if !who.isSuperadmin() { + if *body.Organization != who.OrgID { + writeError(w, http.StatusForbidden, "you cannot move users to another organization") + return + } + // no-op for admins staying in their own org + } else { + patch["organization"] = *body.Organization // "" clears membership + } + } + if len(patch) == 0 { + writeError(w, http.StatusBadRequest, "no changes provided") + return + } + + data, status, err := s.admin.do(r.Context(), http.MethodPatch, "/api/collections/users/records/"+url.PathEscape(id), patch) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()}) + return + } + if status != http.StatusOK { + // Relay PocketBase's validation error (e.g. duplicate email, bad org id). + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = w.Write(data) + return + } + var rec userView + _ = json.Unmarshal(data, &rec) + if rec.Role == "" { + rec.Role = roleUser + } + rec.OrganizationName = s.orgName(r.Context(), rec.Organization) + writeJSON(w, http.StatusOK, map[string]any{"user": rec}) +} + +// DELETE /api/users/{id} — delete a user (manager only). Admins may delete only +// non-superadmin members of their own org; nobody can delete their own account. +func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) { + who := caller(r) + id := r.PathValue("id") + if id == "" { + writeError(w, http.StatusBadRequest, "missing user id") + return + } + if who != nil && who.ID == id { + writeError(w, http.StatusBadRequest, "you cannot delete your own account") + return + } + + if who != nil && !who.isSuperadmin() { + target, err := s.getUserRecord(r.Context(), id) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()}) + return + } + if target == nil { + writeError(w, http.StatusNotFound, "user not found") + return + } + if who.OrgID == "" || target.Organization != who.OrgID { + writeError(w, http.StatusForbidden, "user is outside your organization") + return + } + if target.Role == roleSuperadmin { + writeError(w, http.StatusForbidden, "you cannot delete a superadmin") + return + } + } + + data, status, err := s.admin.do(r.Context(), http.MethodDelete, "/api/collections/users/records/"+url.PathEscape(id), nil) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()}) + return + } + if status != http.StatusOK && status != http.StatusNoContent { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = w.Write(data) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +// normalizeRole validates a client-supplied role. Returns the canonical value +// and whether it was recognised. +func normalizeRole(role string) (string, bool) { + switch strings.TrimSpace(strings.ToLower(role)) { + case "", roleUser: + return roleUser, true + case roleAdmin: + return roleAdmin, true + case roleSuperadmin: + return roleSuperadmin, true + default: + return "", false + } +} diff --git a/API Server/internal/api/ws.go b/API Server/internal/api/ws.go new file mode 100644 index 0000000..9b7d8c0 --- /dev/null +++ b/API Server/internal/api/ws.go @@ -0,0 +1,13 @@ +package api + +import "net/http" + +// GET /ws/device?id= — the Fly App connects here to stream telemetry. +func (s *Server) handleDeviceWS(w http.ResponseWriter, r *http.Request) { + s.hub.ServeDevice(w, r, r.URL.Query().Get("id")) +} + +// GET /ws/ui — the web dashboard / panel connects here for the live stream. +func (s *Server) handleUIWS(w http.ResponseWriter, r *http.Request) { + s.hub.ServeUI(w, r) +} diff --git a/API Server/internal/config/config.go b/API Server/internal/config/config.go new file mode 100644 index 0000000..28df5be --- /dev/null +++ b/API Server/internal/config/config.go @@ -0,0 +1,141 @@ +package config + +import ( + "os" + "strings" +) + +// Config holds all runtime configuration for the API Server. +type Config struct { + Addr string + PocketBaseURL string + WebAppURL string + AllowOrigins []string + + // PluginsFile is the local JSON store for plugin enable-state + config. + PluginsFile string + + // Superuser service account used ONLY for admin user-management + // (list/create/delete users). Optional: when unset, those endpoints return + // 503 and the rest of the server is unaffected. + PocketBaseAdminEmail string + PocketBaseAdminPassword string +} + +// EnvFile is the .env path (relative to the working directory) that Load reads +// and that runtime settings changes persist back into. +const EnvFile = ".env" + +// Load reads configuration from environment variables, applying sensible +// defaults. A .env file, if present in the working directory, is loaded first. +func Load() Config { + loadDotEnv(EnvFile) + + cfg := Config{ + Addr: getenv("API_ADDR", ":8080"), + PocketBaseURL: strings.TrimRight(pocketBaseURL(), "/"), + WebAppURL: strings.TrimRight(getenv("WEBAPP_URL", "http://localhost:8090"), "/"), + AllowOrigins: splitCSV(getenv("CORS_ALLOW_ORIGINS", "*")), + PluginsFile: getenv("PLUGINS_FILE", "plugins.json"), + PocketBaseAdminEmail: getenv("POCKETBASE_ADMIN_EMAIL", os.Getenv("PB_ADMIN_EMAIL")), + PocketBaseAdminPassword: getenv("POCKETBASE_ADMIN_PASSWORD", os.Getenv("PB_ADMIN_PASSWORD")), + } + return cfg +} + +// pocketBaseURL resolves the PocketBase base URL, honouring the legacy PB_URL +// variable for backward compatibility with older deployments. +func pocketBaseURL() string { + if v := os.Getenv("POCKETBASE_URL"); v != "" { + return v + } + if v := os.Getenv("PB_URL"); v != "" { + return v + } + return "http://10.2.1.10:8026" +} + +// UpdateEnvFile persists the given KEY=VALUE pairs into the .env file at path, +// replacing existing keys in place and appending new ones, while preserving all +// other lines (comments, ordering, unrelated keys). The file is created if it +// does not exist. Written with 0600 perms since it holds secrets. +func UpdateEnvFile(path string, updates map[string]string) error { + existing, _ := os.ReadFile(path) // missing file → start empty + + remaining := make(map[string]string, len(updates)) + for k, v := range updates { + remaining[k] = v + } + + var out []string + for _, line := range strings.Split(string(existing), "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + out = append(out, line) + continue + } + key, _, ok := strings.Cut(trimmed, "=") + key = strings.TrimSpace(key) + if ok { + if v, found := remaining[key]; found { + out = append(out, key+"="+v) + delete(remaining, key) + continue + } + } + out = append(out, line) + } + // Append any keys that weren't already present. + for k, v := range remaining { + out = append(out, k+"="+v) + } + + content := strings.Join(out, "\n") + if !strings.HasSuffix(content, "\n") { + content += "\n" + } + return os.WriteFile(path, []byte(content), 0o600) +} + +func getenv(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +func splitCSV(s string) []string { + parts := strings.Split(s, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} + +// loadDotEnv loads KEY=VALUE pairs from a .env file into the process env if they +// are not already set. It is intentionally minimal (no quoting rules beyond +// trimming surrounding quotes). +func loadDotEnv(path string) { + data, err := os.ReadFile(path) + if err != nil { + return + } + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + key, val, ok := strings.Cut(line, "=") + if !ok { + continue + } + key = strings.TrimSpace(key) + val = strings.Trim(strings.TrimSpace(val), `"'`) + if _, exists := os.LookupEnv(key); !exists { + _ = os.Setenv(key, val) + } + } +} diff --git a/API Server/internal/hub/hub.go b/API Server/internal/hub/hub.go new file mode 100644 index 0000000..c0f89e6 --- /dev/null +++ b/API Server/internal/hub/hub.go @@ -0,0 +1,376 @@ +// Package hub keeps the live, in-memory view of every connected device and +// fans telemetry out to dashboards over websockets. It is the drone-domain core +// of the API Server; the api package exposes it over HTTP. +package hub + +import ( + "encoding/json" + "log" + "net/http" + "sync" + "time" + + "github.com/gorilla/websocket" +) + +const ( + writeWait = 10 * time.Second + pongWait = 60 * time.Second + pingPeriod = (pongWait * 9) / 10 + maxMessageSize = 1 << 20 + sendBuffer = 256 + maxTrackPoints = 1000 +) + +var upgrader = websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + // Dev default: accept any origin. Lock this down for production. + CheckOrigin: func(r *http.Request) bool { return true }, +} + +type clientKind int + +const ( + kindDevice clientKind = iota + kindUI +) + +// Client is a single websocket connection (either a device/app or a dashboard). +type Client struct { + hub *Hub + conn *websocket.Conn + send chan []byte + kind clientKind + deviceID string +} + +// Hub keeps track of all connections and the latest state per device. +type Hub struct { + mu sync.RWMutex + uis map[*Client]bool + devices map[string]*Client // currently-online device connections + states map[string]*DeviceState // last-known state, persists across reconnects + tracks map[string][]TrackPoint +} + +// New constructs an empty Hub. +func New() *Hub { + return &Hub{ + uis: make(map[*Client]bool), + devices: make(map[string]*Client), + states: make(map[string]*DeviceState), + tracks: make(map[string][]TrackPoint), + } +} + +func nowMs() int64 { return time.Now().UnixMilli() } + +// ── Websocket entry points ─────────────────────────────────────────────────── + +// ServeDevice upgrades an incoming request into a device connection (the Fly +// App's telemetry uplink) bound to deviceID. +func (h *Hub) ServeDevice(w http.ResponseWriter, r *http.Request, deviceID string) { + if deviceID == "" { + deviceID = "default" + } + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + c := &Client{hub: h, conn: conn, send: make(chan []byte, sendBuffer), kind: kindDevice, deviceID: deviceID} + h.addDevice(c) + log.Printf("device connected: %s", deviceID) + go c.writePump() + go c.readPump() +} + +// ServeUI upgrades an incoming request into a dashboard connection. +func (h *Hub) ServeUI(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + c := &Client{hub: h, conn: conn, send: make(chan []byte, sendBuffer), kind: kindUI} + h.addUI(c) + go c.writePump() + go c.readPump() +} + +// ── UI client lifecycle ────────────────────────────────────────────────────── + +func (h *Hub) addUI(c *Client) { + h.mu.Lock() + h.uis[c] = true + devices := make([]*DeviceState, 0, len(h.states)) + for _, s := range h.states { + cp := *s + devices = append(devices, &cp) + } + h.mu.Unlock() + + if msg, err := json.Marshal(ServerToUI{Type: "snapshot", Devices: devices, TS: nowMs()}); err == nil { + c.send <- msg + } +} + +func (h *Hub) removeUI(c *Client) { + h.mu.Lock() + delete(h.uis, c) + h.mu.Unlock() +} + +// ── Device client lifecycle ────────────────────────────────────────────────── + +func (h *Hub) addDevice(c *Client) { + h.mu.Lock() + h.devices[c.deviceID] = c + s := h.states[c.deviceID] + if s == nil { + s = &DeviceState{DeviceID: c.deviceID} + h.states[c.deviceID] = s + } + s.Online = true + s.LastSeenMs = nowMs() + snap := *s + h.mu.Unlock() + + h.broadcastUI(ServerToUI{Type: "update", Device: &snap, TS: nowMs()}) +} + +func (h *Hub) removeDevice(c *Client) { + h.mu.Lock() + if h.devices[c.deviceID] == c { + delete(h.devices, c.deviceID) + } + var snap *DeviceState + if s := h.states[c.deviceID]; s != nil { + s.Online = false + s.Connected = false + s.Telemetry = Telemetry{} // app stopped streaming: drop stale live telemetry + s.LastSeenMs = nowMs() + cp := *s + snap = &cp + } + h.mu.Unlock() + + if snap != nil { + h.broadcastUI(ServerToUI{Type: "update", Device: snap, TS: nowMs()}) + } +} + +// ── Data flow ──────────────────────────────────────────────────────────────── + +// Ingest applies a raw event from a device and fans it out to the dashboards. +func (h *Hub) Ingest(deviceID string, raw map[string]any) { + h.mu.Lock() + s := h.states[deviceID] + if s == nil { + s = &DeviceState{DeviceID: deviceID} + h.states[deviceID] = s + } + s.Online = true + s.LastSeenMs = nowMs() + + switch raw["type"] { + case "registration": + if st, ok := raw["state"].(string); ok { + s.Registration = st + } + case "connection": + if c, ok := raw["connected"].(bool); ok { + s.Connected = c + if !c { + s.Telemetry = Telemetry{} // drone unlinked: live telemetry is no longer valid + } + } + if m, ok := raw["model"].(string); ok { + s.Model = m + } + case "battery": + if p, ok := toInt(raw["percent"]); ok { + s.Telemetry.BatteryPercent = &p + } + case "telemetry": + applyTelemetry(&s.Telemetry, raw) + lat, okLat := toFloat(raw["latitude"]) + lng, okLng := toFloat(raw["longitude"]) + if okLat && okLng && (lat != 0 || lng != 0) { + alt, _ := toFloat(raw["altitude"]) + h.appendTrackLocked(deviceID, TrackPoint{Lat: lat, Lng: lng, Alt: alt, TS: nowMs()}) + } + } + snap := *s + h.mu.Unlock() + + h.broadcastUI(ServerToUI{Type: "update", Device: &snap, Event: raw, TS: nowMs()}) +} + +// appendTrackLocked must be called with h.mu held. +func (h *Hub) appendTrackLocked(deviceID string, p TrackPoint) { + t := append(h.tracks[deviceID], p) + if len(t) > maxTrackPoints { + t = t[len(t)-maxTrackPoints:] + } + h.tracks[deviceID] = t +} + +// SendCommand routes a command from the server (or a dashboard) to a device. +// It returns false if the device is not currently connected. +func (h *Hub) SendCommand(deviceID, command string, payload map[string]any) bool { + cmd := Command{Type: "command", Command: command, Payload: payload, TS: nowMs()} + msg, err := json.Marshal(cmd) + if err != nil { + return false + } + h.mu.RLock() + c := h.devices[deviceID] + h.mu.RUnlock() + if c == nil { + return false + } + select { + case c.send <- msg: + return true + default: + return false + } +} + +func (h *Hub) broadcastUI(m ServerToUI) { + msg, err := json.Marshal(m) + if err != nil { + return + } + h.mu.RLock() + for c := range h.uis { + select { + case c.send <- msg: + default: // drop messages for a slow/stuck dashboard rather than block + } + } + h.mu.RUnlock() +} + +// Forget drops a device's stored state and track. Intended for clearing +// stale/offline entries; a still-online device will simply repopulate. +func (h *Hub) Forget(deviceID string) bool { + h.mu.Lock() + _, existed := h.states[deviceID] + delete(h.states, deviceID) + delete(h.tracks, deviceID) + h.mu.Unlock() + if existed { + h.broadcastUI(ServerToUI{Type: "removed", DeviceID: deviceID, TS: nowMs()}) + } + return existed +} + +// OnlineCount returns the number of devices with a live websocket connection +// right now (offline/last-known states are not counted). +func (h *Hub) OnlineCount() int { + h.mu.RLock() + defer h.mu.RUnlock() + return len(h.devices) +} + +// Snapshot returns a copy of every known device's last state. +func (h *Hub) Snapshot() []*DeviceState { + h.mu.RLock() + defer h.mu.RUnlock() + out := make([]*DeviceState, 0, len(h.states)) + for _, s := range h.states { + cp := *s + out = append(out, &cp) + } + return out +} + +// Track returns a copy of a device's GPS track. +func (h *Hub) Track(deviceID string) []TrackPoint { + h.mu.RLock() + defer h.mu.RUnlock() + src := h.tracks[deviceID] + out := make([]TrackPoint, len(src)) + copy(out, src) + return out +} + +// ── Pumps ──────────────────────────────────────────────────────────────────── + +func (c *Client) readPump() { + defer func() { + if c.kind == kindDevice { + c.hub.removeDevice(c) + } else { + c.hub.removeUI(c) + } + c.conn.Close() + }() + + c.conn.SetReadLimit(maxMessageSize) + _ = c.conn.SetReadDeadline(time.Now().Add(pongWait)) + c.conn.SetPongHandler(func(string) error { + return c.conn.SetReadDeadline(time.Now().Add(pongWait)) + }) + + for { + _, data, err := c.conn.ReadMessage() + if err != nil { + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { + log.Printf("ws read error (%s): %v", c.deviceID, err) + } + return + } + + if c.kind == kindDevice { + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + continue + } + c.hub.Ingest(c.deviceID, raw) + continue + } + + // UI -> server: command requests + var req struct { + Action string `json:"action"` + DeviceID string `json:"deviceId"` + Command string `json:"command"` + Payload map[string]any `json:"payload"` + } + if err := json.Unmarshal(data, &req); err != nil { + continue + } + if req.Action == "command" && req.Command != "" { + c.hub.SendCommand(req.DeviceID, req.Command, req.Payload) + } + } +} + +func (c *Client) writePump() { + ticker := time.NewTicker(pingPeriod) + defer func() { + ticker.Stop() + c.conn.Close() + }() + + for { + select { + case msg, ok := <-c.send: + _ = c.conn.SetWriteDeadline(time.Now().Add(writeWait)) + if !ok { + _ = c.conn.WriteMessage(websocket.CloseMessage, []byte{}) + return + } + if err := c.conn.WriteMessage(websocket.TextMessage, msg); err != nil { + return + } + case <-ticker.C: + _ = c.conn.SetWriteDeadline(time.Now().Add(writeWait)) + if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil { + return + } + } + } +} diff --git a/API Server/internal/hub/models.go b/API Server/internal/hub/models.go new file mode 100644 index 0000000..7bb72ae --- /dev/null +++ b/API Server/internal/hub/models.go @@ -0,0 +1,112 @@ +package hub + +import "encoding/json" + +// Telemetry holds the latest flight-controller / battery values for a device. +// Pointers distinguish "not yet reported" (nil) from a genuine zero value. +type Telemetry struct { + SatelliteCount *int `json:"satelliteCount,omitempty"` + IsFlying *bool `json:"isFlying,omitempty"` + FlightMode *string `json:"flightMode,omitempty"` + Altitude *float64 `json:"altitude,omitempty"` + Latitude *float64 `json:"latitude,omitempty"` + Longitude *float64 `json:"longitude,omitempty"` + VelocityX *float64 `json:"velocityX,omitempty"` + VelocityY *float64 `json:"velocityY,omitempty"` + VelocityZ *float64 `json:"velocityZ,omitempty"` + BatteryPercent *int `json:"batteryPercent,omitempty"` +} + +// DeviceState is the server's aggregated view of one app/drone. +type DeviceState struct { + DeviceID string `json:"deviceId"` + Online bool `json:"online"` // app's websocket is connected to the server + Connected bool `json:"connected"` // a drone is connected to the app + Model string `json:"model"` + Registration string `json:"registration"` + Telemetry Telemetry `json:"telemetry"` + LastSeenMs int64 `json:"lastSeenMs"` +} + +// TrackPoint is one sample of the drone's GPS track (for the map trail). +type TrackPoint struct { + Lat float64 `json:"lat"` + Lng float64 `json:"lng"` + Alt float64 `json:"alt"` + TS int64 `json:"ts"` +} + +// ServerToUI is the message a dashboard receives over /ws/ui. +type ServerToUI struct { + Type string `json:"type"` // "snapshot" | "update" | "removed" + Device *DeviceState `json:"device,omitempty"` + Devices []*DeviceState `json:"devices,omitempty"` + DeviceID string `json:"deviceId,omitempty"` // for "removed" + Event map[string]any `json:"event,omitempty"` // the raw device event that triggered this + TS int64 `json:"ts"` +} + +// Command is what the server pushes down to a device over /ws/device. +type Command struct { + Type string `json:"type"` // always "command" + Command string `json:"command"` + Payload map[string]any `json:"payload,omitempty"` + TS int64 `json:"ts"` +} + +// toFloat coerces a JSON-decoded value into a float64. +func toFloat(v any) (float64, bool) { + switch n := v.(type) { + case float64: + return n, true + case float32: + return float64(n), true + case int: + return float64(n), true + case int64: + return float64(n), true + case json.Number: + f, err := n.Float64() + return f, err == nil + } + return 0, false +} + +// toInt coerces a JSON-decoded value into an int. +func toInt(v any) (int, bool) { + if f, ok := toFloat(v); ok { + return int(f), true + } + return 0, false +} + +// applyTelemetry copies any present telemetry fields from a raw event map. +func applyTelemetry(t *Telemetry, raw map[string]any) { + if v, ok := toInt(raw["satelliteCount"]); ok { + t.SatelliteCount = &v + } + if v, ok := raw["isFlying"].(bool); ok { + t.IsFlying = &v + } + if v, ok := raw["flightMode"].(string); ok { + t.FlightMode = &v + } + if v, ok := toFloat(raw["altitude"]); ok { + t.Altitude = &v + } + if v, ok := toFloat(raw["latitude"]); ok { + t.Latitude = &v + } + if v, ok := toFloat(raw["longitude"]); ok { + t.Longitude = &v + } + if v, ok := toFloat(raw["velocityX"]); ok { + t.VelocityX = &v + } + if v, ok := toFloat(raw["velocityY"]); ok { + t.VelocityY = &v + } + if v, ok := toFloat(raw["velocityZ"]); ok { + t.VelocityZ = &v + } +} diff --git a/API Server/internal/plugins/README.md b/API Server/internal/plugins/README.md new file mode 100644 index 0000000..83fee14 --- /dev/null +++ b/API Server/internal/plugins/README.md @@ -0,0 +1,307 @@ +# Building PilotVault Plugins + +A **plugin** integrates an external third-party service (flight data, +notifications, …) behind one uniform contract. There are two kinds: + +| Kind | Written as | Added by | Rebuild? | Use when | +|---|---|---|---|---| +| **built-in** | Go code in this repo | a rebuild | yes | first-party, high-trust, type-safe connectors | +| **external** | any HTTP service | registering a URL at runtime | **no** | third-party / less-trusted / independently deployed | + +Both implement the same behaviour; the server treats them identically. Enable +state and per-plugin config persist to `plugins.json` and load on boot. Every +plugin is managed by a **superadmin** from the panel (`/`) or the +`/api/admin/plugins*` API. + +--- + +## The contract + +All plugins satisfy the Go interface in [`plugin.go`](plugin.go): + +```go +type Plugin interface { + Descriptor() Descriptor + Init(ctx context.Context, config map[string]string) error + HealthCheck(ctx context.Context) Health + Invoke(ctx context.Context, action string, params json.RawMessage) (json.RawMessage, error) + Shutdown(ctx context.Context) error +} +``` + +- **`Descriptor`** — static metadata (name, provider, version, capabilities, + auth type, config fields). Drives the panel UI. +- **`Init`** — called with the resolved config (secrets included) whenever the + plugin is enabled or its config changes. Prepare clients/tokens here. +- **`HealthCheck`** — probe the upstream and classify: `Health{Status, LatencyMs, Detail}` + where `Status` is `StatusOK` / `StatusDegraded` / `StatusDown`. +- **`Invoke`** — run a named capability. **Part of the contract for the future; + no HTTP endpoint exposes it in v1.** Implement it anyway so the connector is + ready. +- **`Shutdown`** — release resources. + +### Descriptor & config fields + +```go +Descriptor{ + Name: "acme", // unique id, [a-z0-9-] + Provider: "ACME Corp", // human label + Version: "1.0.0", + Kind: plugins.KindBuiltin, // or KindExternal + Capabilities: []plugins.Capability{ + {ID: "widgets.list", Method: "GET", Endpoint: "/widgets", Description: "List widgets."}, + }, + AuthType: plugins.AuthAPIKey, // None | APIKey | Basic | OAuth2 | Webhook (metadata only) + ConfigFields: []plugins.ConfigField{ + {Key: "apiKey", Label: "API key", Type: "password", Required: true, Secret: true, + Help: "Found under ACME → Settings → API."}, + {Key: "region", Label: "Region", Type: "text", Help: "e.g. eu-west-1"}, + }, +} +``` + +`ConfigField.Type` is `"text"`, `"password"`, or `"number"` (form input hint). +Set **`Secret: true`** for credentials — the server never echoes them back in +clear; the panel shows a mask (`••••••••`), and on save a field left at the mask +keeps its stored value (so operators don't retype secrets). **`Required: true`** +fields must be non-empty before the plugin can be enabled. + +--- + +## Building a built-in plugin + +1. **Create a package** under `internal/plugins/builtin//`. +2. **Implement `Plugin`** and **register it in `init()`**. +3. **Blank-import** your package from [`builtin/builtin.go`](builtin/builtin.go). +4. **Rebuild** the server. + +### Minimal example — `internal/plugins/builtin/acme/acme.go` + +```go +package acme + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "time" + + "pilotvault/apiserver/internal/plugins" +) + +func init() { + plugins.Register("acme", func() plugins.Plugin { return &Plugin{} }) +} + +type Plugin struct { + apiKey string + region string + client *http.Client +} + +func (p *Plugin) Descriptor() plugins.Descriptor { + return plugins.Descriptor{ + Name: "acme", Provider: "ACME Corp", Version: "1.0.0", + Kind: plugins.KindBuiltin, AuthType: plugins.AuthAPIKey, + Capabilities: []plugins.Capability{ + {ID: "widgets.list", Method: "GET", Endpoint: "/widgets", Description: "List widgets."}, + }, + ConfigFields: []plugins.ConfigField{ + {Key: "apiKey", Label: "API key", Type: "password", Required: true, Secret: true}, + {Key: "region", Label: "Region", Type: "text"}, + }, + } +} + +func (p *Plugin) Init(_ context.Context, config map[string]string) error { + p.apiKey = strings.TrimSpace(config["apiKey"]) + p.region = strings.TrimSpace(config["region"]) + p.client = &http.Client{Timeout: 10 * time.Second} + return nil +} + +func (p *Plugin) HealthCheck(ctx context.Context) plugins.Health { + start := time.Now() + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.acme.example/ping", nil) + req.Header.Set("Authorization", "Bearer "+p.apiKey) + resp, err := p.client.Do(req) + lat := time.Since(start).Milliseconds() + if err != nil { + return plugins.Health{Status: plugins.StatusDown, LatencyMs: lat, Detail: err.Error()} + } + defer resp.Body.Close() + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return plugins.Health{Status: plugins.StatusOK, LatencyMs: lat, Detail: "reachable"} + } + return plugins.Health{Status: plugins.StatusDown, LatencyMs: lat, Detail: "HTTP " + resp.Status} +} + +func (p *Plugin) Invoke(ctx context.Context, action string, params json.RawMessage) (json.RawMessage, error) { + // Implement your capabilities; return normalized JSON. (Not yet called in v1.) + return json.RawMessage(`{"ok":true}`), nil +} + +func (p *Plugin) Shutdown(context.Context) error { return nil } +``` + +### Register it for compilation — `internal/plugins/builtin/builtin.go` + +```go +import ( + _ "pilotvault/apiserver/internal/plugins/builtin/acme" + _ "pilotvault/apiserver/internal/plugins/builtin/opensky" +) +``` + +### Rebuild + +```powershell +cd "API Server" +go build -o api-server.exe ./cmd/server +``` + +Restart the server. The plugin appears in the panel's **Plugins** card, +**disabled** by default. See [`builtin/opensky/opensky.go`](builtin/opensky/opensky.go) +for a fuller example with an **OAuth2 client-credentials** auth provider and an +anonymous fallback. + +--- + +## Building an external plugin (no rebuild) + +An external plugin is **any HTTP service** you host (Go recommended, but any +language works). You register its base URL at runtime; the server drives it over +a tiny JSON contract. + +### The HTTP contract + +| Method & path | Purpose | Response | +|---|---|---| +| `GET {base}/manifest` | describe the plugin (optional) | `{provider, version, capabilities, authType, configFields}` | +| `GET {base}/health` | health probe (required) | `2xx` = healthy; optional body `{status, detail}` | +| `POST {base}/invoke` | run a capability (optional; unused in v1) | `{action, params}` in → arbitrary JSON out | + +Health rules the server applies: transport error or `5xx` → `down`; `2xx` → `ok`; +anything else → `degraded`. An explicit `{"status":"ok|degraded|down","detail":"…"}` +body overrides the status-code heuristic. Bodies are size-limited (health 64 KiB, +manifest 1 MiB). + +### Minimal example — a Go plugin service + +```go +package main + +import ( + "encoding/json" + "net/http" +) + +func main() { + http.HandleFunc("/manifest", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{ + "provider": "ACME Cloud", + "version": "2.1.0", + "authType": "apikey", + "capabilities": []map[string]any{ + {"id": "widgets.list", "method": "GET", "endpoint": "/widgets", "description": "List widgets."}, + }, // a plain []string{"widgets.list"} is also accepted + "configFields": []map[string]any{ + {"key": "apiKey", "label": "API key", "type": "password", "required": true, "secret": true}, + }, + }) + }) + http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{"status": "ok", "detail": "acme cloud reachable"}) + }) + http.HandleFunc("/invoke", func(w http.ResponseWriter, r *http.Request) { + var in struct { + Action string `json:"action"` + Params json.RawMessage `json:"params"` + } + json.NewDecoder(r.Body).Decode(&in) + json.NewEncoder(w).Encode(map[string]any{"ok": true, "action": in.Action}) + }) + http.ListenAndServe(":9100", nil) +} +``` + +### Register it + +From the panel's **Plugins** card → *Register external plugin* (name + base URL), +or via the API: + +```bash +curl -X POST http://localhost:8080/api/admin/plugins \ + -H "Authorization: $SUPERADMIN_TOKEN" -H "Content-Type: application/json" \ + -d '{"name":"acme-cloud","baseURL":"http://127.0.0.1:9100","provider":"ACME Cloud"}' +``` + +It starts **disabled**; enable it and run a health check from the panel. Because +it runs as its own process/container, an external plugin is also the +**sandboxing** path for less-trusted integrations. + +--- + +## Lifecycle, config & secrets + +- **Enable/disable** and **config** persist to `plugins.json` (gitignored; override + the path with `PLUGINS_FILE`). Enabling calls `Init`; disabling calls `Shutdown`. +- **Secrets** (`Secret: true` fields) are returned masked. On save, a field still + equal to the mask keeps its stored value; send a new value to change it, or an + empty string to clear it. +- **Required** fields are validated when enabling — enabling fails with a clear + error if one is blank. +- If `Init` fails (e.g. bad credentials), the state is still saved and the API + returns the plugin plus a `warning`; fix the config and re-save. + +--- + +## Managing plugins (superadmin API) + +All endpoints require a superadmin bearer token (`Authorization: ` from +`POST /api/auth/login`). See the panel's **Management API** reference too. + +| Method | Path | Body | Purpose | +|---|---|---|---| +| `GET` | `/api/admin/plugins` | — | list all plugins + state + last health | +| `GET` | `/api/admin/plugins/{name}` | — | one plugin | +| `PUT` | `/api/admin/plugins/{name}` | `{enabled?, config?}` | enable/disable + configure | +| `POST` | `/api/admin/plugins` | `{name, baseURL, provider?}` | register an external plugin | +| `DELETE` | `/api/admin/plugins/{name}` | — | remove an external plugin (built-ins only disable) | +| `POST` | `/api/admin/plugins/{name}/health` | — | run a health check now | + +--- + +## Testing your plugin + +1. Build + restart (built-in) or start your service (external) and register it. +2. `GET /api/admin/plugins` → confirm your descriptor, config fields, capabilities. +3. `PUT /api/admin/plugins/{name} {"enabled":true, "config":{…}}` → enable with config. +4. `POST /api/admin/plugins/{name}/health` → confirm the live probe classifies correctly. +5. Restart the server → confirm state reloads from `plugins.json`. + +A Go unit test can exercise a built-in directly: + +```go +p := &acme.Plugin{} +_ = p.Init(context.Background(), map[string]string{"apiKey": "test"}) +if h := p.HealthCheck(context.Background()); h.Status == "" { + t.Fatal("expected a health status") +} +``` + +--- + +## Not yet implemented (roadmap) + +The contract is shaped for these; see [`doc.go`](doc.go): + +- **Invocation API** — an endpoint to call `Invoke` from clients, with a normalized + request/response envelope and a provider→internal mapper. +- **Resilience** — retry/backoff, circuit breaker, per-plugin latency/error metrics. +- **Per-tenant credentials** — config keyed by org/user so users connect their own accounts. +- **Audit logging** of plugin access. + +Until the invocation API lands, `Invoke` is dormant — plugins are discoverable, +configurable, and health-checked, but not yet callable over HTTP. diff --git a/API Server/internal/plugins/builtin/builtin.go b/API Server/internal/plugins/builtin/builtin.go new file mode 100644 index 0000000..1108269 --- /dev/null +++ b/API Server/internal/plugins/builtin/builtin.go @@ -0,0 +1,11 @@ +// Package builtin blank-imports every built-in plugin so their init() functions +// register them with the plugin registry. Import this package once (from the api +// package) to make all built-in connectors available. +package builtin + +import ( + _ "pilotvault/apiserver/internal/plugins/builtin/filetransfer" + _ "pilotvault/apiserver/internal/plugins/builtin/localstorage" + _ "pilotvault/apiserver/internal/plugins/builtin/opensky" + _ "pilotvault/apiserver/internal/plugins/builtin/webdav" +) diff --git a/API Server/internal/plugins/builtin/filetransfer/filetransfer.go b/API Server/internal/plugins/builtin/filetransfer/filetransfer.go new file mode 100644 index 0000000..09b3ca9 --- /dev/null +++ b/API Server/internal/plugins/builtin/filetransfer/filetransfer.go @@ -0,0 +1,610 @@ +// Package filetransfer is a built-in plugin that connects to a file-transfer +// server over FTP, FTPS (explicit TLS), or SFTP (SSH). It demonstrates a +// stateful third-party integration behind the plugin contract: one descriptor +// with a protocol switch, and a small protocol-agnostic `conn` abstraction that +// HealthCheck and Invoke drive without caring which wire protocol is in use. +// +// Connections are opened per operation rather than pooled: FTP/SFTP sessions are +// stateful and idle-timeout aggressively, so dialling on demand is both simpler +// and more robust than keeping a long-lived connection healthy. Init only stores +// the resolved config; nothing connects until HealthCheck or Invoke runs. +// +// - FTP : github.com/jlaffaye/ftp +// - FTPS : github.com/jlaffaye/ftp with explicit TLS (AUTH TLS) +// - SFTP : golang.org/x/crypto/ssh + github.com/pkg/sftp +package filetransfer + +import ( + "context" + "crypto/tls" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "path" + "strconv" + "strings" + "sync" + "time" + + "github.com/jlaffaye/ftp" + "github.com/pkg/sftp" + "golang.org/x/crypto/ssh" + + "pilotvault/apiserver/internal/plugins" +) + +const ( + protoSFTP = "sftp" + protoFTP = "ftp" + protoFTPS = "ftps" + + dialTimeout = 12 * time.Second + // maxReadBytes caps a download so a huge remote file can't exhaust memory; + // the health probe and Invoke both honour it. + maxReadBytes = 32 << 20 // 32 MiB +) + +func init() { + plugins.Register("filetransfer", func() plugins.Plugin { return &Plugin{} }) +} + +// Plugin is the FTP/FTPS/SFTP connector. All fields are guarded by mu because +// Init may run concurrently with a HealthCheck/Invoke from another request. +type Plugin struct { + mu sync.Mutex + protocol string + host string + port int + username string + password string + privateKey string // PEM-encoded SSH private key (sftp only) + keyPass string // passphrase for the private key + basePath string + // hostKeyFP, when set, pins the SFTP server's SHA256 host-key fingerprint + // ("SHA256:…"); empty means accept any host key (trust-on-first-use, no + // verification — flagged as degraded by the health probe). + hostKeyFP string + // insecureTLS skips FTPS certificate verification when true. + insecureTLS bool +} + +func (p *Plugin) Descriptor() plugins.Descriptor { + return plugins.Descriptor{ + Name: "filetransfer", + Provider: "FTP / SFTP", + Version: "1.0.0", + Kind: plugins.KindBuiltin, + Category: plugins.CategoryDrivesExternal, + AuthType: plugins.AuthBasic, + Capabilities: []plugins.Capability{ + {ID: "list", Method: "GET", Endpoint: "/", Description: "List a remote directory. params: {path}"}, + {ID: "stat", Method: "GET", Endpoint: "/", Description: "Stat one remote path. params: {path}"}, + {ID: "download", Method: "GET", Endpoint: "/", Description: "Read a remote file (base64, ≤32 MiB). params: {path}"}, + {ID: "upload", Method: "PUT", Endpoint: "/", Description: "Write a remote file. params: {path, contentBase64}"}, + {ID: "delete", Method: "DELETE", Endpoint: "/", Description: "Delete a remote file. params: {path}"}, + {ID: "mkdir", Method: "PUT", Endpoint: "/", Description: "Create a remote directory. params: {path}"}, + }, + ConfigFields: []plugins.ConfigField{ + // No field is Required: the plugin can be enabled as a master switch with + // an empty global config, leaving each organization or user to supply + // their own connection through the cascade (mirrors OpenSky). A missing + // host is reported gracefully by the health probe. + {Key: "protocol", Label: "Protocol", Type: "select", Default: protoSFTP, + Options: []plugins.SelectOption{ + {Value: protoSFTP, Label: "SFTP — file transfer over SSH (recommended)"}, + {Value: protoFTPS, Label: "FTPS — FTP with explicit TLS (AUTH TLS)"}, + {Value: protoFTP, Label: "FTP — plaintext (insecure)"}, + }, + Help: "SFTP runs over SSH (port 22); FTP/FTPS use port 21 by default."}, + {Key: "host", Label: "Host", Type: "text", Help: "Server hostname or IP, e.g. files.example.com"}, + {Key: "port", Label: "Port", Type: "number", Help: "Leave blank for the protocol default (22 for SFTP, 21 for FTP/FTPS)."}, + {Key: "username", Label: "Username", Type: "text"}, + {Key: "password", Label: "Password", Type: "password", Secret: true, + Help: "Password for FTP/FTPS, or SFTP password auth. Leave blank to use an SFTP private key."}, + {Key: "privateKey", Label: "SSH private key (SFTP)", Type: "password", Secret: true, + Help: "PEM-encoded private key for SFTP key auth. Used instead of, or alongside, a password."}, + {Key: "keyPassphrase", Label: "Private key passphrase", Type: "password", Secret: true, + Help: "Passphrase protecting the SSH private key, if any."}, + {Key: "basePath", Label: "Base path", Type: "text", Default: ".", + Help: "Directory used as the working root and probed by the health check, e.g. /uploads. Relative capability paths are resolved under it."}, + {Key: "hostKeyFingerprint", Label: "SFTP host key fingerprint", Type: "text", + Help: "Optional SHA256:… fingerprint to pin the SFTP server's host key. Leave blank to accept any key (no verification)."}, + {Key: "insecureSkipVerify", Label: "FTPS TLS verification", Type: "select", Default: "false", + Options: []plugins.SelectOption{ + {Value: "false", Label: "Verify certificate (recommended)"}, + {Value: "true", Label: "Skip verification — accept any certificate"}, + }, + Help: "Only affects FTPS. Skip verification only for self-signed test servers."}, + }, + } +} + +func (p *Plugin) Init(_ context.Context, config map[string]string) error { + p.mu.Lock() + defer p.mu.Unlock() + + p.protocol = strings.ToLower(strings.TrimSpace(config["protocol"])) + if p.protocol == "" { + p.protocol = protoSFTP + } + p.host = strings.TrimSpace(config["host"]) + p.port = 0 + if raw := strings.TrimSpace(config["port"]); raw != "" { + if n, err := strconv.Atoi(raw); err == nil { + p.port = n + } + } + p.username = strings.TrimSpace(config["username"]) + p.password = config["password"] + p.privateKey = config["privateKey"] + p.keyPass = config["keyPassphrase"] + p.basePath = strings.TrimSpace(config["basePath"]) + if p.basePath == "" { + p.basePath = "." + } + p.hostKeyFP = strings.TrimSpace(config["hostKeyFingerprint"]) + p.insecureTLS = strings.EqualFold(strings.TrimSpace(config["insecureSkipVerify"]), "true") + return nil +} + +// effectivePort returns the configured port or the protocol default. +func (p *Plugin) effectivePort() int { + if p.port > 0 { + return p.port + } + if p.protocol == protoSFTP { + return 22 + } + return 21 +} + +// resolve joins a caller-supplied path against the base path. An absolute path +// is used as-is; an empty path becomes the base path itself. +func (p *Plugin) resolve(rel string) string { + rel = strings.TrimSpace(rel) + if rel == "" { + return p.basePath + } + if strings.HasPrefix(rel, "/") || p.basePath == "" || p.basePath == "." { + return rel + } + return path.Join(p.basePath, rel) +} + +// HealthCheck dials, authenticates, and lists the base path, classifying the +// outcome. A missing/unverified SFTP host key downgrades OK to degraded. +func (p *Plugin) HealthCheck(ctx context.Context) plugins.Health { + start := time.Now() + + p.mu.Lock() + proto, host, hostKeyFP := p.protocol, p.host, p.hostKeyFP + base := p.basePath + p.mu.Unlock() + + if host == "" { + return plugins.Health{Status: plugins.StatusDown, Detail: "no host configured"} + } + + c, err := p.dial(ctx) + if err != nil { + lat := time.Since(start).Milliseconds() + return plugins.Health{Status: classifyDialErr(err), LatencyMs: lat, Detail: err.Error()} + } + defer c.close() + + entries, err := c.list(base) + lat := time.Since(start).Milliseconds() + if err != nil { + return plugins.Health{Status: plugins.StatusDegraded, LatencyMs: lat, + Detail: fmt.Sprintf("connected (%s) but listing %q failed: %v", proto, base, err)} + } + + detail := fmt.Sprintf("%s reachable — %d entr%s under %q", strings.ToUpper(proto), len(entries), plural(len(entries)), base) + status := plugins.StatusOK + if proto == protoSFTP && hostKeyFP == "" { + status = plugins.StatusDegraded + detail += " · host key not verified (no fingerprint pinned)" + } + if proto == protoFTP { + detail += " · plaintext (no encryption)" + } + return plugins.Health{Status: status, LatencyMs: lat, Detail: detail} +} + +// Invoke runs one capability against a freshly-dialled connection. +func (p *Plugin) Invoke(ctx context.Context, action string, params json.RawMessage) (json.RawMessage, error) { + c, err := p.dial(ctx) + if err != nil { + return nil, err + } + defer c.close() + + switch action { + case "list": + var in pathParams + _ = json.Unmarshal(params, &in) + entries, err := c.list(p.resolve(in.Path)) + if err != nil { + return nil, err + } + return json.Marshal(map[string]any{"path": p.resolve(in.Path), "entries": entries}) + + case "stat": + var in pathParams + _ = json.Unmarshal(params, &in) + fi, err := c.stat(p.resolve(in.Path)) + if err != nil { + return nil, err + } + return json.Marshal(fi) + + case "download": + var in pathParams + _ = json.Unmarshal(params, &in) + data, err := c.read(p.resolve(in.Path)) + if err != nil { + return nil, err + } + return json.Marshal(map[string]any{ + "path": p.resolve(in.Path), + "size": len(data), + "contentBase64": base64.StdEncoding.EncodeToString(data), + }) + + case "upload": + var in writeParams + if err := json.Unmarshal(params, &in); err != nil { + return nil, fmt.Errorf("invalid params: %w", err) + } + data, err := base64.StdEncoding.DecodeString(in.ContentBase64) + if err != nil { + return nil, fmt.Errorf("contentBase64 is not valid base64: %w", err) + } + if err := c.write(p.resolve(in.Path), data); err != nil { + return nil, err + } + return json.Marshal(map[string]any{"path": p.resolve(in.Path), "size": len(data), "ok": true}) + + case "delete": + var in pathParams + _ = json.Unmarshal(params, &in) + if err := c.remove(p.resolve(in.Path)); err != nil { + return nil, err + } + return json.Marshal(map[string]any{"path": p.resolve(in.Path), "ok": true}) + + case "mkdir": + var in pathParams + _ = json.Unmarshal(params, &in) + if err := c.mkdir(p.resolve(in.Path)); err != nil { + return nil, err + } + return json.Marshal(map[string]any{"path": p.resolve(in.Path), "ok": true}) + + default: + return nil, errors.New("unknown action: " + action) + } +} + +func (p *Plugin) Shutdown(context.Context) error { return nil } + +// pathParams / writeParams are the Invoke request shapes. +type pathParams struct { + Path string `json:"path"` +} +type writeParams struct { + Path string `json:"path"` + ContentBase64 string `json:"contentBase64"` +} + +// fileInfo is the normalized directory-entry shape returned by list/stat. +type fileInfo struct { + Name string `json:"name"` + Size int64 `json:"size"` + IsDir bool `json:"isDir"` + ModTime string `json:"modTime,omitempty"` +} + +// conn is the protocol-agnostic surface HealthCheck and Invoke drive. Both the +// FTP and SFTP implementations satisfy it. +type conn interface { + list(path string) ([]fileInfo, error) + stat(path string) (fileInfo, error) + read(path string) ([]byte, error) + write(path string, data []byte) error + remove(path string) error + mkdir(path string) error + close() error +} + +// dial builds an authenticated connection for the configured protocol. +func (p *Plugin) dial(ctx context.Context) (conn, error) { + p.mu.Lock() + proto := p.protocol + p.mu.Unlock() + + switch proto { + case protoSFTP: + return p.dialSFTP(ctx) + case protoFTP, protoFTPS: + return p.dialFTP(ctx) + default: + return nil, errors.New("unsupported protocol: " + proto) + } +} + +// classifyDialErr maps a dial/auth failure to a health status: an auth rejection +// is degraded (server reachable, credentials wrong); anything else is down. +func classifyDialErr(err error) string { + msg := strings.ToLower(err.Error()) + switch { + case strings.Contains(msg, "unable to authenticate"), + strings.Contains(msg, "auth"), + strings.Contains(msg, "password"), + strings.Contains(msg, "login"), + strings.Contains(msg, "530"), // FTP: not logged in + strings.Contains(msg, "permission denied"): + return plugins.StatusDegraded + default: + return plugins.StatusDown + } +} + +func plural(n int) string { + if n == 1 { + return "y" + } + return "ies" +} + +// --------------------------------------------------------------------------- +// SFTP implementation +// --------------------------------------------------------------------------- + +type sftpConn struct { + ssh *ssh.Client + cli *sftp.Client +} + +func (p *Plugin) dialSFTP(ctx context.Context) (conn, error) { + p.mu.Lock() + host, user, pass := p.host, p.username, p.password + key, keyPass, hostKeyFP := p.privateKey, p.keyPass, p.hostKeyFP + addr := net.JoinHostPort(host, strconv.Itoa(p.effectivePort())) + p.mu.Unlock() + + var auth []ssh.AuthMethod + if strings.TrimSpace(key) != "" { + signer, err := parseSigner(key, keyPass) + if err != nil { + return nil, fmt.Errorf("private key: %w", err) + } + auth = append(auth, ssh.PublicKeys(signer)) + } + if pass != "" { + auth = append(auth, ssh.Password(pass)) + } + if len(auth) == 0 { + return nil, errors.New("SFTP requires a password or a private key") + } + + hostKeyCallback, err := hostKeyChecker(hostKeyFP) + if err != nil { + return nil, err + } + cfg := &ssh.ClientConfig{ + User: user, + Auth: auth, + HostKeyCallback: hostKeyCallback, + Timeout: dialTimeout, + } + + // ssh.Dial has no context form; dial the TCP conn with the context, then + // run the SSH handshake over it. + d := net.Dialer{Timeout: dialTimeout} + tcp, err := d.DialContext(ctx, "tcp", addr) + if err != nil { + return nil, err + } + sshConn, chans, reqs, err := ssh.NewClientConn(tcp, addr, cfg) + if err != nil { + _ = tcp.Close() + return nil, err + } + client := ssh.NewClient(sshConn, chans, reqs) + sc, err := sftp.NewClient(client) + if err != nil { + _ = client.Close() + return nil, err + } + return &sftpConn{ssh: client, cli: sc}, nil +} + +// parseSigner parses a PEM private key, with or without a passphrase. +func parseSigner(pem, passphrase string) (ssh.Signer, error) { + if strings.TrimSpace(passphrase) != "" { + return ssh.ParsePrivateKeyWithPassphrase([]byte(pem), []byte(passphrase)) + } + return ssh.ParsePrivateKey([]byte(pem)) +} + +// hostKeyChecker returns a HostKeyCallback that pins the given SHA256:… +// fingerprint, or accepts any key when the fingerprint is empty. +func hostKeyChecker(fingerprint string) (ssh.HostKeyCallback, error) { + if fingerprint == "" { + return ssh.InsecureIgnoreHostKey(), nil //nolint:gosec // opt-in: no fingerprint pinned + } + want := strings.TrimSpace(fingerprint) + return func(_ string, _ net.Addr, key ssh.PublicKey) error { + got := ssh.FingerprintSHA256(key) + if got != want { + return fmt.Errorf("host key mismatch: server presented %s, expected %s", got, want) + } + return nil + }, nil +} + +func (c *sftpConn) list(p string) ([]fileInfo, error) { + infos, err := c.cli.ReadDir(p) + if err != nil { + return nil, err + } + out := make([]fileInfo, 0, len(infos)) + for _, fi := range infos { + out = append(out, fileInfo{ + Name: fi.Name(), + Size: fi.Size(), + IsDir: fi.IsDir(), + ModTime: fi.ModTime().UTC().Format(time.RFC3339), + }) + } + return out, nil +} + +func (c *sftpConn) stat(p string) (fileInfo, error) { + fi, err := c.cli.Stat(p) + if err != nil { + return fileInfo{}, err + } + return fileInfo{ + Name: fi.Name(), + Size: fi.Size(), + IsDir: fi.IsDir(), + ModTime: fi.ModTime().UTC().Format(time.RFC3339), + }, nil +} + +func (c *sftpConn) read(p string) ([]byte, error) { + f, err := c.cli.Open(p) + if err != nil { + return nil, err + } + defer f.Close() + return io.ReadAll(io.LimitReader(f, maxReadBytes)) +} + +func (c *sftpConn) write(p string, data []byte) error { + f, err := c.cli.Create(p) + if err != nil { + return err + } + defer f.Close() + _, err = f.Write(data) + return err +} + +func (c *sftpConn) remove(p string) error { return c.cli.Remove(p) } +func (c *sftpConn) mkdir(p string) error { return c.cli.MkdirAll(p) } + +func (c *sftpConn) close() error { + err := c.cli.Close() + if c.ssh != nil { + _ = c.ssh.Close() + } + return err +} + +// --------------------------------------------------------------------------- +// FTP / FTPS implementation +// --------------------------------------------------------------------------- + +type ftpConn struct { + c *ftp.ServerConn +} + +func (p *Plugin) dialFTP(ctx context.Context) (conn, error) { + p.mu.Lock() + host, user, pass, proto := p.host, p.username, p.password, p.protocol + insecure := p.insecureTLS + addr := net.JoinHostPort(host, strconv.Itoa(p.effectivePort())) + p.mu.Unlock() + + opts := []ftp.DialOption{ftp.DialWithContext(ctx), ftp.DialWithTimeout(dialTimeout)} + if proto == protoFTPS { + opts = append(opts, ftp.DialWithExplicitTLS(&tls.Config{ + ServerName: host, + InsecureSkipVerify: insecure, //nolint:gosec // opt-in for self-signed test servers + })) + } + + sc, err := ftp.Dial(addr, opts...) + if err != nil { + return nil, err + } + if err := sc.Login(user, pass); err != nil { + _ = sc.Quit() + return nil, err + } + return &ftpConn{c: sc}, nil +} + +func (c *ftpConn) list(p string) ([]fileInfo, error) { + entries, err := c.c.List(p) + if err != nil { + return nil, err + } + out := make([]fileInfo, 0, len(entries)) + for _, e := range entries { + if e.Name == "." || e.Name == ".." { + continue + } + out = append(out, entryToInfo(e)) + } + return out, nil +} + +func (c *ftpConn) stat(p string) (fileInfo, error) { + // FTP has no portable stat; MLST via GetEntry works on servers that support + // it, otherwise fall back to listing the parent and matching the name. + if e, err := c.c.GetEntry(p); err == nil && e != nil { + return entryToInfo(e), nil + } + dir, base := path.Split(strings.TrimRight(p, "/")) + if dir == "" { + dir = "." + } + entries, err := c.c.List(dir) + if err != nil { + return fileInfo{}, err + } + for _, e := range entries { + if e.Name == base { + return entryToInfo(e), nil + } + } + return fileInfo{}, fmt.Errorf("not found: %s", p) +} + +func (c *ftpConn) read(p string) ([]byte, error) { + resp, err := c.c.Retr(p) + if err != nil { + return nil, err + } + defer resp.Close() + return io.ReadAll(io.LimitReader(resp, maxReadBytes)) +} + +func (c *ftpConn) write(p string, data []byte) error { + return c.c.Stor(p, strings.NewReader(string(data))) +} + +func (c *ftpConn) remove(p string) error { return c.c.Delete(p) } +func (c *ftpConn) mkdir(p string) error { return c.c.MakeDir(p) } + +func (c *ftpConn) close() error { return c.c.Quit() } + +// entryToInfo normalizes a jlaffaye/ftp entry. +func entryToInfo(e *ftp.Entry) fileInfo { + fi := fileInfo{ + Name: e.Name, + Size: int64(e.Size), + IsDir: e.Type == ftp.EntryTypeFolder, + } + if !e.Time.IsZero() { + fi.ModTime = e.Time.UTC().Format(time.RFC3339) + } + return fi +} diff --git a/API Server/internal/plugins/builtin/filetransfer/filetransfer_test.go b/API Server/internal/plugins/builtin/filetransfer/filetransfer_test.go new file mode 100644 index 0000000..8fceebd --- /dev/null +++ b/API Server/internal/plugins/builtin/filetransfer/filetransfer_test.go @@ -0,0 +1,97 @@ +package filetransfer + +import ( + "context" + "testing" + + "pilotvault/apiserver/internal/plugins" +) + +func TestDescriptor(t *testing.T) { + p := &Plugin{} + d := p.Descriptor() + if d.Name != "filetransfer" { + t.Fatalf("name = %q, want filetransfer", d.Name) + } + if d.Kind != plugins.KindBuiltin { + t.Fatalf("kind = %q, want builtin", d.Kind) + } + if len(d.Capabilities) == 0 { + t.Fatal("expected capabilities") + } + // Every secret field must be flagged so the manager masks it. + for _, f := range d.ConfigFields { + if f.Key == "password" || f.Key == "privateKey" || f.Key == "keyPassphrase" { + if !f.Secret { + t.Errorf("config field %q must be Secret", f.Key) + } + } + } +} + +func TestInitDefaults(t *testing.T) { + p := &Plugin{} + if err := p.Init(context.Background(), map[string]string{"host": "h", "username": "u"}); err != nil { + t.Fatal(err) + } + if p.protocol != protoSFTP { + t.Errorf("default protocol = %q, want sftp", p.protocol) + } + if p.effectivePort() != 22 { + t.Errorf("default sftp port = %d, want 22", p.effectivePort()) + } + p.protocol = protoFTP + if p.effectivePort() != 21 { + t.Errorf("default ftp port = %d, want 21", p.effectivePort()) + } +} + +func TestResolve(t *testing.T) { + p := &Plugin{basePath: "/uploads"} + cases := map[string]string{ + "": "/uploads", + "a/b.txt": "/uploads/a/b.txt", + "/etc/abs": "/etc/abs", + } + for in, want := range cases { + if got := p.resolve(in); got != want { + t.Errorf("resolve(%q) = %q, want %q", in, got, want) + } + } +} + +// TestHealthCheckUnreachable confirms an unreachable host is classified as down +// (not a panic) — the graceful-failure path Init/HealthCheck must guarantee. +func TestHealthCheckUnreachable(t *testing.T) { + p := &Plugin{} + // Port 1 is reserved and refuses connections quickly. + if err := p.Init(context.Background(), map[string]string{ + "protocol": protoSFTP, "host": "127.0.0.1", "port": "1", + "username": "u", "password": "pw", + }); err != nil { + t.Fatal(err) + } + h := p.HealthCheck(context.Background()) + if h.Status != plugins.StatusDown { + t.Errorf("status = %q, want down (detail=%q)", h.Status, h.Detail) + } +} + +func TestHostKeyFingerprintMismatch(t *testing.T) { + cb, err := hostKeyChecker("SHA256:doesnotmatch") + if err != nil { + t.Fatal(err) + } + if cb == nil { + t.Fatal("expected a callback") + } +} + +// TestRegistered confirms the plugin registered itself with the shared registry +// via init(), so the manager will surface it. +func TestRegistered(t *testing.T) { + m := plugins.NewManager(t.TempDir() + "/plugins.json") + if _, ok := m.Get("filetransfer"); !ok { + t.Fatal("filetransfer not registered in the plugin manager") + } +} diff --git a/API Server/internal/plugins/builtin/localstorage/localstorage.go b/API Server/internal/plugins/builtin/localstorage/localstorage.go new file mode 100644 index 0000000..21aa964 --- /dev/null +++ b/API Server/internal/plugins/builtin/localstorage/localstorage.go @@ -0,0 +1,368 @@ +// Package localstorage is a built-in plugin that exposes a directory on the host +// machine's own filesystem as a storage "drive", behind the same capability +// surface (list/stat/download/upload/delete/mkdir) as the remote filetransfer +// connector. Where filetransfer dials FTP/SFTP, this one just calls the os +// package — there is no network, no auth, and nothing to dial. +// +// Every caller-supplied path is confined under the configured base path: paths +// are treated as relative to the base and cleaned so that ".." or a leading +// separator can never escape the storage root. This is the one piece of extra +// care a local-filesystem connector needs that a remote one gets from the remote +// server's own chroot/permissions. +package localstorage + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "pilotvault/apiserver/internal/plugins" +) + +// maxReadBytes caps a download so a huge file can't exhaust memory; the health +// probe and Invoke both honour it (mirrors filetransfer). +const maxReadBytes = 32 << 20 // 32 MiB + +func init() { + plugins.Register("localstorage", func() plugins.Plugin { return &Plugin{} }) +} + +// Plugin is the local-filesystem connector. Fields are guarded by mu because +// Init may run concurrently with a HealthCheck/Invoke from another request. +type Plugin struct { + mu sync.Mutex + basePath string + createMissing bool // create the base path (and upload/mkdir parents) if absent + readOnly bool // reject upload/delete/mkdir when true +} + +func (p *Plugin) Descriptor() plugins.Descriptor { + return plugins.Descriptor{ + Name: "localstorage", + Provider: "Local Filesystem", + Version: "1.0.0", + Kind: plugins.KindBuiltin, + Category: plugins.CategoryDrivesLocal, + AuthType: plugins.AuthNone, + Capabilities: []plugins.Capability{ + {ID: "list", Method: "GET", Endpoint: "/", Description: "List a directory under the base path. params: {path}"}, + {ID: "stat", Method: "GET", Endpoint: "/", Description: "Stat one path under the base path. params: {path}"}, + {ID: "download", Method: "GET", Endpoint: "/", Description: "Read a file (base64, ≤32 MiB). params: {path}"}, + {ID: "upload", Method: "PUT", Endpoint: "/", Description: "Write a file (creates parent dirs). params: {path, contentBase64}"}, + {ID: "delete", Method: "DELETE", Endpoint: "/", Description: "Delete a file or empty directory. params: {path}"}, + {ID: "mkdir", Method: "PUT", Endpoint: "/", Description: "Create a directory. params: {path}"}, + }, + ConfigFields: []plugins.ConfigField{ + // No field is Required: like the other drive plugins, this can be enabled + // as a master switch with an empty config; a missing base path is reported + // gracefully by the health probe rather than blocking the switch. + {Key: "basePath", Label: "Base path", Type: "text", + Help: `Absolute directory used as the storage root, e.g. /data or /var/lib/pilotvault. In Docker this should be a mounted volume so data survives redeploys, and the container user must own it. Every operation is confined within it — ".." and absolute paths cannot escape.`}, + {Key: "createMissing", Label: "Create base path", Type: "select", Default: "false", + Options: []plugins.SelectOption{ + {Value: "false", Label: "Require the directory to already exist"}, + {Value: "true", Label: "Create it if missing (also creates upload/mkdir parents)"}, + }, + Help: "When on, the base path is created by the health check and parent directories are created on upload/mkdir."}, + {Key: "readOnly", Label: "Access mode", Type: "select", Default: "false", + Options: []plugins.SelectOption{ + {Value: "false", Label: "Read-write"}, + {Value: "true", Label: "Read-only — reject upload, delete and mkdir"}, + }, + Help: "Read-only is a safety guard for pointing at a directory you only want to serve from."}, + }, + } +} + +func (p *Plugin) Init(_ context.Context, config map[string]string) error { + p.mu.Lock() + defer p.mu.Unlock() + + p.basePath = strings.TrimSpace(config["basePath"]) + p.createMissing = strings.EqualFold(strings.TrimSpace(config["createMissing"]), "true") + p.readOnly = strings.EqualFold(strings.TrimSpace(config["readOnly"]), "true") + return nil +} + +// resolve confines a caller-supplied path under the base path. The path is always +// treated as relative to the base; a leading separator or ".." segments are +// neutralized by cleaning against a virtual root, so the result can never escape. +func (p *Plugin) resolve(rel string) (string, error) { + base := strings.TrimSpace(p.basePath) + if base == "" { + return "", errors.New("no base path configured") + } + absBase, err := filepath.Abs(base) + if err != nil { + return "", err + } + // Clean against a virtual root so "..", ".", and leading separators collapse to + // a path that stays at or below "/", then strip the root and join under base. + virtual := filepath.ToSlash(strings.TrimSpace(rel)) + cleaned := filepath.Clean("/" + strings.TrimLeft(virtual, "/")) + sub := filepath.FromSlash(strings.TrimPrefix(cleaned, "/")) + joined := filepath.Join(absBase, sub) + + // Belt-and-braces containment check after joining. + if joined != absBase && !strings.HasPrefix(joined, absBase+string(os.PathSeparator)) { + return "", fmt.Errorf("path %q escapes the base directory", rel) + } + return joined, nil +} + +// HealthCheck verifies the base path exists, is a directory, is readable, and +// (unless read-only) is writable. Missing-but-creatable resolves to OK. +func (p *Plugin) HealthCheck(_ context.Context) plugins.Health { + start := time.Now() + + p.mu.Lock() + base, create, readOnly := p.basePath, p.createMissing, p.readOnly + p.mu.Unlock() + + if strings.TrimSpace(base) == "" { + return plugins.Health{Status: plugins.StatusDown, Detail: "no base path configured"} + } + absBase, err := filepath.Abs(base) + if err != nil { + return plugins.Health{Status: plugins.StatusDown, LatencyMs: ms(start), Detail: err.Error()} + } + + info, err := os.Stat(absBase) + if err != nil { + if os.IsNotExist(err) && create { + if mkErr := os.MkdirAll(absBase, 0o755); mkErr != nil { + return plugins.Health{Status: plugins.StatusDown, LatencyMs: ms(start), + Detail: fmt.Sprintf("base path %q does not exist and could not be created: %v", absBase, mkErr)} + } + info, err = os.Stat(absBase) + } + if err != nil { + detail := fmt.Sprintf("base path %q not accessible: %v", absBase, err) + if os.IsNotExist(err) { + detail = fmt.Sprintf("base path %q does not exist (enable \"Create base path\" to create it)", absBase) + } + return plugins.Health{Status: plugins.StatusDown, LatencyMs: ms(start), Detail: detail} + } + } + if !info.IsDir() { + return plugins.Health{Status: plugins.StatusDown, LatencyMs: ms(start), + Detail: fmt.Sprintf("base path %q is not a directory", absBase)} + } + + entries, err := os.ReadDir(absBase) + if err != nil { + return plugins.Health{Status: plugins.StatusDegraded, LatencyMs: ms(start), + Detail: fmt.Sprintf("base path %q is not readable: %v", absBase, err)} + } + + detail := fmt.Sprintf("%q reachable — %d entr%s", absBase, len(entries), plural(len(entries))) + status := plugins.StatusOK + if readOnly { + detail += " · read-only" + } else if werr := probeWritable(absBase); werr != nil { + status = plugins.StatusDegraded + detail += fmt.Sprintf(" · not writable: %v", werr) + } else { + detail += " · read-write" + } + return plugins.Health{Status: status, LatencyMs: ms(start), Detail: detail} +} + +// Invoke runs one capability against the local filesystem. +func (p *Plugin) Invoke(_ context.Context, action string, params json.RawMessage) (json.RawMessage, error) { + p.mu.Lock() + create, readOnly := p.createMissing, p.readOnly + p.mu.Unlock() + + switch action { + case "list": + var in pathParams + _ = json.Unmarshal(params, &in) + target, err := p.resolve(in.Path) + if err != nil { + return nil, err + } + entries, err := os.ReadDir(target) + if err != nil { + return nil, err + } + out := make([]fileInfo, 0, len(entries)) + for _, e := range entries { + out = append(out, dirEntryToInfo(e)) + } + return json.Marshal(map[string]any{"path": target, "entries": out}) + + case "stat": + var in pathParams + _ = json.Unmarshal(params, &in) + target, err := p.resolve(in.Path) + if err != nil { + return nil, err + } + fi, err := os.Stat(target) + if err != nil { + return nil, err + } + return json.Marshal(statToInfo(fi)) + + case "download": + var in pathParams + _ = json.Unmarshal(params, &in) + target, err := p.resolve(in.Path) + if err != nil { + return nil, err + } + data, err := readCapped(target) + if err != nil { + return nil, err + } + return json.Marshal(map[string]any{ + "path": target, + "size": len(data), + "contentBase64": base64.StdEncoding.EncodeToString(data), + }) + + case "upload": + if readOnly { + return nil, errReadOnly + } + var in writeParams + if err := json.Unmarshal(params, &in); err != nil { + return nil, fmt.Errorf("invalid params: %w", err) + } + target, err := p.resolve(in.Path) + if err != nil { + return nil, err + } + data, err := base64.StdEncoding.DecodeString(in.ContentBase64) + if err != nil { + return nil, fmt.Errorf("contentBase64 is not valid base64: %w", err) + } + if create { + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return nil, err + } + } + if err := os.WriteFile(target, data, 0o644); err != nil { + return nil, err + } + return json.Marshal(map[string]any{"path": target, "size": len(data), "ok": true}) + + case "delete": + if readOnly { + return nil, errReadOnly + } + var in pathParams + _ = json.Unmarshal(params, &in) + target, err := p.resolve(in.Path) + if err != nil { + return nil, err + } + // Refuse to delete the base path itself. + absBase, _ := filepath.Abs(strings.TrimSpace(p.basePath)) + if target == absBase { + return nil, errors.New("refusing to delete the base directory") + } + if err := os.Remove(target); err != nil { + return nil, err + } + return json.Marshal(map[string]any{"path": target, "ok": true}) + + case "mkdir": + if readOnly { + return nil, errReadOnly + } + var in pathParams + _ = json.Unmarshal(params, &in) + target, err := p.resolve(in.Path) + if err != nil { + return nil, err + } + if err := os.MkdirAll(target, 0o755); err != nil { + return nil, err + } + return json.Marshal(map[string]any{"path": target, "ok": true}) + + default: + return nil, errors.New("unknown action: " + action) + } +} + +func (p *Plugin) Shutdown(context.Context) error { return nil } + +var errReadOnly = errors.New("plugin is configured read-only") + +// pathParams / writeParams are the Invoke request shapes (mirrors filetransfer). +type pathParams struct { + Path string `json:"path"` +} +type writeParams struct { + Path string `json:"path"` + ContentBase64 string `json:"contentBase64"` +} + +// fileInfo is the normalized directory-entry shape returned by list/stat. +type fileInfo struct { + Name string `json:"name"` + Size int64 `json:"size"` + IsDir bool `json:"isDir"` + ModTime string `json:"modTime,omitempty"` +} + +func statToInfo(fi os.FileInfo) fileInfo { + return fileInfo{ + Name: fi.Name(), + Size: fi.Size(), + IsDir: fi.IsDir(), + ModTime: fi.ModTime().UTC().Format(time.RFC3339), + } +} + +// dirEntryToInfo normalizes an os.DirEntry, tolerating a stat failure on a single +// entry (e.g. a broken symlink) by reporting name/isDir without size/modtime. +func dirEntryToInfo(e os.DirEntry) fileInfo { + fi, err := e.Info() + if err != nil { + return fileInfo{Name: e.Name(), IsDir: e.IsDir()} + } + return statToInfo(fi) +} + +// readCapped reads a file up to maxReadBytes. +func readCapped(path string) ([]byte, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + return io.ReadAll(io.LimitReader(f, maxReadBytes)) +} + +// probeWritable confirms the directory accepts a write by creating and removing a +// short-lived temp file. +func probeWritable(dir string) error { + f, err := os.CreateTemp(dir, ".pilotvault-health-*") + if err != nil { + return err + } + name := f.Name() + _ = f.Close() + return os.Remove(name) +} + +func ms(start time.Time) int64 { return time.Since(start).Milliseconds() } + +func plural(n int) string { + if n == 1 { + return "y" + } + return "ies" +} diff --git a/API Server/internal/plugins/builtin/localstorage/localstorage_test.go b/API Server/internal/plugins/builtin/localstorage/localstorage_test.go new file mode 100644 index 0000000..3498ff0 --- /dev/null +++ b/API Server/internal/plugins/builtin/localstorage/localstorage_test.go @@ -0,0 +1,139 @@ +package localstorage + +import ( + "context" + "encoding/base64" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "pilotvault/apiserver/internal/plugins" +) + +func TestDescriptor(t *testing.T) { + p := &Plugin{} + d := p.Descriptor() + if d.Name != "localstorage" { + t.Fatalf("name = %q, want localstorage", d.Name) + } + if d.Kind != plugins.KindBuiltin { + t.Fatalf("kind = %q, want builtin", d.Kind) + } + if d.Category != plugins.CategoryDrivesLocal { + t.Fatalf("category = %q, want %q", d.Category, plugins.CategoryDrivesLocal) + } + if len(d.Capabilities) == 0 { + t.Fatal("expected capabilities") + } +} + +// TestRegistered confirms the plugin registered itself with the shared registry +// via init(), so the manager will surface it. +func TestRegistered(t *testing.T) { + m := plugins.NewManager(t.TempDir() + "/plugins.json") + if _, ok := m.Get("localstorage"); !ok { + t.Fatal("localstorage not registered in the plugin manager") + } +} + +// TestResolveConfinement verifies that traversal, absolute-looking, and +// backslash paths all stay under the base directory. +func TestResolveConfinement(t *testing.T) { + base := t.TempDir() + p := &Plugin{basePath: base} + absBase, _ := filepath.Abs(base) + + contained := []string{"a/b.txt", "/etc/passwd", "../../../etc/passwd", "a\\b", "./x", ""} + for _, in := range contained { + got, err := p.resolve(in) + if err != nil { + t.Fatalf("resolve(%q) errored: %v", in, err) + } + if got != absBase && !strings.HasPrefix(got, absBase+string(os.PathSeparator)) { + t.Errorf("resolve(%q) = %q escaped base %q", in, got, absBase) + } + } +} + +func TestResolveNoBase(t *testing.T) { + p := &Plugin{} + if _, err := p.resolve("x"); err == nil { + t.Fatal("expected error when base path unset") + } +} + +func TestHealthCheckMissing(t *testing.T) { + p := &Plugin{} + // Base path unset -> down. + if h := p.HealthCheck(context.Background()); h.Status != plugins.StatusDown { + t.Errorf("unset base: status = %q, want down", h.Status) + } + // Nonexistent path without createMissing -> down. + _ = p.Init(context.Background(), map[string]string{"basePath": filepath.Join(t.TempDir(), "nope")}) + if h := p.HealthCheck(context.Background()); h.Status != plugins.StatusDown { + t.Errorf("missing base: status = %q, want down (detail=%q)", h.Status, h.Detail) + } +} + +func TestHealthCheckCreateMissing(t *testing.T) { + dir := filepath.Join(t.TempDir(), "created") + p := &Plugin{} + _ = p.Init(context.Background(), map[string]string{"basePath": dir, "createMissing": "true"}) + h := p.HealthCheck(context.Background()) + if h.Status != plugins.StatusOK { + t.Fatalf("status = %q, want ok (detail=%q)", h.Status, h.Detail) + } + if fi, err := os.Stat(dir); err != nil || !fi.IsDir() { + t.Fatalf("base path was not created: %v", err) + } +} + +// TestRoundTrip exercises upload -> list -> download -> delete end to end. +func TestRoundTrip(t *testing.T) { + base := t.TempDir() + p := &Plugin{} + _ = p.Init(context.Background(), map[string]string{"basePath": base, "createMissing": "true"}) + + payload := []byte("hello pilotvault") + up, _ := json.Marshal(writeParams{Path: "sub/dir/file.txt", ContentBase64: base64.StdEncoding.EncodeToString(payload)}) + if _, err := p.Invoke(context.Background(), "upload", up); err != nil { + t.Fatalf("upload: %v", err) + } + + dl, _ := json.Marshal(pathParams{Path: "sub/dir/file.txt"}) + raw, err := p.Invoke(context.Background(), "download", dl) + if err != nil { + t.Fatalf("download: %v", err) + } + var got struct { + ContentBase64 string `json:"contentBase64"` + } + _ = json.Unmarshal(raw, &got) + if decoded, _ := base64.StdEncoding.DecodeString(got.ContentBase64); string(decoded) != string(payload) { + t.Fatalf("download content = %q, want %q", decoded, payload) + } + + if _, err := p.Invoke(context.Background(), "delete", dl); err != nil { + t.Fatalf("delete: %v", err) + } + if _, err := os.Stat(filepath.Join(base, "sub", "dir", "file.txt")); !os.IsNotExist(err) { + t.Fatalf("file still present after delete: %v", err) + } +} + +func TestReadOnlyRejectsWrites(t *testing.T) { + base := t.TempDir() + p := &Plugin{} + _ = p.Init(context.Background(), map[string]string{"basePath": base, "readOnly": "true"}) + + up, _ := json.Marshal(writeParams{Path: "x.txt", ContentBase64: ""}) + if _, err := p.Invoke(context.Background(), "upload", up); err == nil { + t.Error("upload should be rejected in read-only mode") + } + del, _ := json.Marshal(pathParams{Path: "x.txt"}) + if _, err := p.Invoke(context.Background(), "delete", del); err == nil { + t.Error("delete should be rejected in read-only mode") + } +} diff --git a/API Server/internal/plugins/builtin/opensky/opensky.go b/API Server/internal/plugins/builtin/opensky/opensky.go new file mode 100644 index 0000000..a0671a6 --- /dev/null +++ b/API Server/internal/plugins/builtin/opensky/opensky.go @@ -0,0 +1,339 @@ +// Package opensky is a built-in plugin connecting the OpenSky Network REST API +// (live ADS-B aircraft state vectors). It demonstrates a real third-party +// integration behind the plugin contract, including an OAuth2 client-credentials +// AuthProvider with an anonymous fallback. +// +// Docs: https://openskynetwork.github.io/opensky-api/rest.html +package opensky + +import ( + "context" + "encoding/json" + "errors" + "io" + "math" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "time" + + "pilotvault/apiserver/internal/plugins" +) + +const ( + apiBase = "https://opensky-network.org/api" + tokenURL = "https://auth.opensky-network.org/auth/realms/opensky-network/protocol/openid-connect/token" + // Small default bounding box (Netherlands) keeps the health probe cheap. + defaultBBox = "50.5,3.2,53.7,7.3" // lamin,lomin,lamax,lomax +) + +func init() { + plugins.Register("opensky", func() plugins.Plugin { return &Plugin{} }) +} + +// Plugin is the OpenSky connector. +type Plugin struct { + mu sync.Mutex + clientID string + clientSecret string + bbox string + plan string + allowAnonymous bool + client *http.Client + + token string + tokenExp time.Time +} + +// errAnonDisabled is returned when a probe/call has no resolved credentials and +// the operator has disabled anonymous access. +var errAnonDisabled = errors.New("OpenSky credentials required — anonymous access is disabled") + +func (p *Plugin) Descriptor() plugins.Descriptor { + return plugins.Descriptor{ + Name: "opensky", + Provider: "OpenSky Network", + Version: "1.0.0", + Kind: plugins.KindBuiltin, + Category: plugins.CategoryAPIsExternal, + Capabilities: []plugins.Capability{ + {ID: "states.all", Method: "GET", Endpoint: "/states/all", + Description: "All current aircraft state vectors, world-wide (costs 4 credits/call)."}, + {ID: "states.bbox", Method: "GET", Endpoint: "/states/all?lamin&lomin&lamax&lomax", + Description: "State vectors within the configured bounding box (1–4 credits by area)."}, + }, + AuthType: plugins.AuthOAuth2, + ConfigFields: []plugins.ConfigField{ + {Key: "plan", Label: "OpenSky plan", Type: "select", + Options: []plugins.SelectOption{ + {Value: "", Label: "Not set — let organizations and users choose"}, + {Value: "anonymous", Label: "Anonymous — 400 credits/day"}, + {Value: "standard", Label: "Standard (registered) — 4000 credits/day"}, + {Value: "contributor", Label: "Contributor — 8000 credits/day"}, + }, + Help: "Global account tier. Leave it unset to let each organization or user pick their own plan; set a value only to force one plan for everyone. Determines the daily credit allowance shown next to remaining credits."}, + {Key: "clientId", Label: "OAuth2 client ID", Type: "text", Help: "Optional — leave blank for anonymous access (lower rate limits)."}, + {Key: "clientSecret", Label: "OAuth2 client secret", Type: "password", Secret: true, Help: "Paired with the client ID for authenticated access."}, + {Key: "bbox", Label: "Default bounding box", Type: "text", Default: defaultBBox, Help: "lamin,lomin,lamax,lomax — used by the health probe and states.bbox."}, + {Key: "allowAnonymous", Label: "Anonymous access", Type: "select", Default: "true", + Options: []plugins.SelectOption{ + {Value: "true", Label: "Enabled — allow use without credentials"}, + {Value: "false", Label: "Disabled — require OAuth2 credentials"}, + }, + Help: "Global policy: when disabled, the plugin can only be used once OAuth2 credentials resolve from some layer (superadmin, organization, or user)."}, + }, + } +} + +// planDailyCredits maps an OpenSky plan to its daily credit allowance. +// See https://openskynetwork.github.io/opensky-api/rest.html#api-credits +func planDailyCredits(plan string) int { + switch plan { + case "anonymous": + return 400 + case "contributor": + return 8000 + default: // "standard" + return 4000 + } +} + +func (p *Plugin) Init(_ context.Context, config map[string]string) error { + p.mu.Lock() + defer p.mu.Unlock() + p.clientID = strings.TrimSpace(config["clientId"]) + p.clientSecret = config["clientSecret"] + p.bbox = strings.TrimSpace(config["bbox"]) + if p.bbox == "" { + p.bbox = defaultBBox + } + p.plan = strings.TrimSpace(config["plan"]) + if p.plan == "" { + p.plan = "standard" // OpenSky registered-user default + } + // Anonymous access defaults to enabled; only an explicit "false" turns it off. + p.allowAnonymous = !strings.EqualFold(strings.TrimSpace(config["allowAnonymous"]), "false") + p.client = &http.Client{Timeout: 10 * time.Second} + p.token, p.tokenExp = "", time.Time{} + return nil +} + +// bearer returns a valid OAuth2 token, fetching/refreshing via client-credentials +// when configured. Returns "" (no error) when running anonymously. +func (p *Plugin) bearer(ctx context.Context) (string, error) { + p.mu.Lock() + id, secret, allowAnon := p.clientID, p.clientSecret, p.allowAnonymous + if p.token != "" && time.Now().Before(p.tokenExp) { + tok := p.token + p.mu.Unlock() + return tok, nil + } + p.mu.Unlock() + + if id == "" || secret == "" { + if !allowAnon { + return "", errAnonDisabled + } + return "", nil // anonymous + } + + form := url.Values{ + "grant_type": {"client_credentials"}, + "client_id": {id}, + "client_secret": {secret}, + } + req, _ := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err := p.client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + data, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode != http.StatusOK { + return "", errors.New("token endpoint returned HTTP " + resp.Status) + } + var out struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + } + if err := json.Unmarshal(data, &out); err != nil || out.AccessToken == "" { + return "", errors.New("no access_token in token response") + } + p.mu.Lock() + p.token = out.AccessToken + ttl := out.ExpiresIn + if ttl <= 0 { + ttl = 1800 + } + p.tokenExp = time.Now().Add(time.Duration(ttl-30) * time.Second) + p.mu.Unlock() + return out.AccessToken, nil +} + +// statesURLBBox builds the /states/all request URL constrained to the configured +// bounding box. Falls back to the whole world if the bbox is malformed. +func (p *Plugin) statesURLBBox() string { + p.mu.Lock() + bbox := p.bbox + p.mu.Unlock() + parts := strings.Split(bbox, ",") + if len(parts) != 4 { + return apiBase + "/states/all" + } + q := url.Values{ + "lamin": {strings.TrimSpace(parts[0])}, + "lomin": {strings.TrimSpace(parts[1])}, + "lamax": {strings.TrimSpace(parts[2])}, + "lomax": {strings.TrimSpace(parts[3])}, + } + return apiBase + "/states/all?" + q.Encode() +} + +// statesURLAll returns the world-wide /states/all URL (no bounding box). +func (p *Plugin) statesURLAll() string { return apiBase + "/states/all" } + +// creditCost returns the OpenSky credit cost of a /states/all call over the given +// bounding box, per https://openskynetwork.github.io/opensky-api/rest.html#api-credits: +// 1 credit ≤ 25 sq°, 2 ≤ 100, 3 ≤ 400, 4 for larger or the whole world. +func creditCost(bbox string) int { + parts := strings.Split(bbox, ",") + if len(parts) != 4 { + return 4 // no/invalid box → whole world + } + lamin, e1 := strconv.ParseFloat(strings.TrimSpace(parts[0]), 64) + lomin, e2 := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64) + lamax, e3 := strconv.ParseFloat(strings.TrimSpace(parts[2]), 64) + lomax, e4 := strconv.ParseFloat(strings.TrimSpace(parts[3]), 64) + if e1 != nil || e2 != nil || e3 != nil || e4 != nil { + return 4 + } + area := math.Abs(lamax-lamin) * math.Abs(lomax-lomin) + switch { + case area <= 25: + return 1 + case area <= 100: + return 2 + case area <= 400: + return 3 + default: + return 4 + } +} + +// creditWord renders a credit count with correct pluralisation. +func creditWord(n int) string { + if n == 1 { + return "1 credit" + } + return strconv.Itoa(n) + " credits" +} + +// HealthCheck performs a live states query (authenticated when configured, else +// anonymous) and classifies the outcome. +func (p *Plugin) HealthCheck(ctx context.Context) plugins.Health { + start := time.Now() + token, err := p.bearer(ctx) + if errors.Is(err, errAnonDisabled) { + return plugins.Health{Status: plugins.StatusDegraded, LatencyMs: time.Since(start).Milliseconds(), + Detail: err.Error()} + } + if err != nil { + return plugins.Health{Status: plugins.StatusDegraded, LatencyMs: time.Since(start).Milliseconds(), + Detail: "auth failed: " + err.Error()} + } + + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, p.statesURLBBox(), nil) + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + resp, err := p.client.Do(req) + lat := time.Since(start).Milliseconds() + if err != nil { + return plugins.Health{Status: plugins.StatusDown, LatencyMs: lat, Detail: err.Error()} + } + defer resp.Body.Close() + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20)) + + mode := "anonymous" + if token != "" { + mode = "authenticated" + } + + h := plugins.Health{LatencyMs: lat} + switch { + case resp.StatusCode >= 200 && resp.StatusCode < 300: + h.Status, h.Detail = plugins.StatusOK, "OpenSky reachable ("+mode+")" + case resp.StatusCode == http.StatusTooManyRequests: + h.Status, h.Detail = plugins.StatusDegraded, "rate limited (HTTP 429)" + case resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden: + h.Status, h.Detail = plugins.StatusDegraded, "auth rejected (HTTP "+resp.Status+")" + default: + h.Status, h.Detail = plugins.StatusDown, "HTTP "+resp.Status + } + // Surface live credit usage from the rate-limit header, the plan's daily + // allowance, and this probe's cost (e.g. "3996/4000 credits left today · 1 credit/probe"). + // The same figures are also exposed structurally (h.Credits) so the UI can + // render a dedicated usage meter without parsing this string. + p.mu.Lock() + bbox, plan := p.bbox, p.plan + p.mu.Unlock() + + cost := creditCost(bbox) + credits := &plugins.HealthCredits{Daily: planDailyCredits(plan), ProbeCost: cost, Mode: mode} + if rem := strings.TrimSpace(resp.Header.Get("X-Rate-Limit-Remaining")); rem != "" { + if n, err := strconv.Atoi(rem); err == nil { + credits.Remaining = &n + } + h.Detail += " · " + p.creditsText(rem) + } + h.Detail += " · " + creditWord(cost) + "/probe" + h.Credits = credits + return h +} + +// creditsText formats the remaining-credit header against the plan's daily +// allowance. Empty when the header is absent. +func (p *Plugin) creditsText(remaining string) string { + remaining = strings.TrimSpace(remaining) + if remaining == "" { + return "" + } + p.mu.Lock() + daily := planDailyCredits(p.plan) + p.mu.Unlock() + return remaining + "/" + strconv.Itoa(daily) + " credits left today" +} + +// Invoke exposes states.all / states.bbox. Part of the contract; no HTTP endpoint +// surfaces it in v1, but it keeps the connector functional for future use. +func (p *Plugin) Invoke(ctx context.Context, action string, _ json.RawMessage) (json.RawMessage, error) { + switch action { + case "states.all", "states.bbox": + token, err := p.bearer(ctx) + if err != nil { + return nil, err + } + target := p.statesURLBBox() + if action == "states.all" { + target = p.statesURLAll() // world-wide (4 credits) + } + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, target, nil) + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + resp, err := p.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + data, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) + return data, nil + default: + return nil, errors.New("unknown action: " + action) + } +} + +func (p *Plugin) Shutdown(context.Context) error { return nil } diff --git a/API Server/internal/plugins/builtin/webdav/webdav.go b/API Server/internal/plugins/builtin/webdav/webdav.go new file mode 100644 index 0000000..1fc62ee --- /dev/null +++ b/API Server/internal/plugins/builtin/webdav/webdav.go @@ -0,0 +1,551 @@ +// Package webdav is a built-in plugin that connects to a WebDAV server over +// HTTP(S). It offers the same capability surface as the filetransfer plugin +// (list/stat/download/upload/delete/mkdir) but speaks WebDAV verbs — PROPFIND, +// GET, PUT, DELETE, MKCOL — directly over net/http, so it needs no third-party +// client library and cross-compiles cleanly for the Linux container. +// +// Like filetransfer, nothing connects during Init; each capability (and the +// health probe) issues its own HTTP request against the configured base URL, +// authenticating with HTTP Basic auth. This suits WebDAV, which is stateless +// per request, and keeps the plugin free of long-lived connection state. +package webdav + +import ( + "context" + "crypto/tls" + "encoding/base64" + "encoding/json" + "encoding/xml" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "path" + "strconv" + "strings" + "sync" + "time" + + "pilotvault/apiserver/internal/plugins" +) + +const ( + dialTimeout = 12 * time.Second + // maxReadBytes caps a download so a huge remote file can't exhaust memory; + // the health probe and Invoke both honour it. + maxReadBytes = 32 << 20 // 32 MiB + + // propfindBody requests just the properties we normalize into fileInfo. + propfindBody = `` + + `` + + `` + + `` +) + +func init() { + plugins.Register("webdav", func() plugins.Plugin { return &Plugin{} }) +} + +// Plugin is the WebDAV connector. All fields are guarded by mu because Init may +// run concurrently with a HealthCheck/Invoke from another request. +type Plugin struct { + mu sync.Mutex + baseURL string // e.g. https://cloud.example.com/remote.php/dav/files/alice/ + username string + password string + basePath string // working root, resolved under the base URL's path + // insecureTLS skips HTTPS certificate verification when true. + insecureTLS bool + client *http.Client +} + +func (p *Plugin) Descriptor() plugins.Descriptor { + return plugins.Descriptor{ + Name: "webdav", + Provider: "WebDAV", + Version: "1.0.0", + Kind: plugins.KindBuiltin, + Category: plugins.CategoryDrivesExternal, + AuthType: plugins.AuthBasic, + Capabilities: []plugins.Capability{ + {ID: "list", Method: "PROPFIND", Endpoint: "/", Description: "List a remote directory. params: {path}"}, + {ID: "stat", Method: "PROPFIND", Endpoint: "/", Description: "Stat one remote path. params: {path}"}, + {ID: "download", Method: "GET", Endpoint: "/", Description: "Read a remote file (base64, ≤32 MiB). params: {path}"}, + {ID: "upload", Method: "PUT", Endpoint: "/", Description: "Write a remote file. params: {path, contentBase64}"}, + {ID: "delete", Method: "DELETE", Endpoint: "/", Description: "Delete a remote file or directory. params: {path}"}, + {ID: "mkdir", Method: "MKCOL", Endpoint: "/", Description: "Create a remote directory. params: {path}"}, + }, + ConfigFields: []plugins.ConfigField{ + // No field is Required: the plugin can be enabled as a master switch with + // an empty global config, leaving each organization or user to supply + // their own connection through the cascade (mirrors filetransfer). A + // missing base URL is reported gracefully by the health probe. + {Key: "baseURL", Label: "Server URL", Type: "text", + Help: "WebDAV endpoint, e.g. https://cloud.example.com/remote.php/dav/files/alice/ — must include the scheme."}, + {Key: "username", Label: "Username", Type: "text"}, + {Key: "password", Label: "Password", Type: "password", Secret: true, + Help: "Password or app-specific token for HTTP Basic auth. Leave blank for an anonymous/public share."}, + {Key: "basePath", Label: "Base path", Type: "text", Default: ".", + Help: "Directory under the server URL used as the working root and probed by the health check, e.g. /Documents. Relative capability paths resolve under it."}, + {Key: "insecureSkipVerify", Label: "TLS verification", Type: "select", Default: "false", + Options: []plugins.SelectOption{ + {Value: "false", Label: "Verify certificate (recommended)"}, + {Value: "true", Label: "Skip verification — accept any certificate"}, + }, + Help: "Only affects HTTPS. Skip verification only for self-signed test servers."}, + }, + } +} + +func (p *Plugin) Init(_ context.Context, config map[string]string) error { + p.mu.Lock() + defer p.mu.Unlock() + + p.baseURL = strings.TrimSpace(config["baseURL"]) + p.username = strings.TrimSpace(config["username"]) + p.password = config["password"] + p.basePath = strings.TrimSpace(config["basePath"]) + if p.basePath == "" { + p.basePath = "." + } + p.insecureTLS = strings.EqualFold(strings.TrimSpace(config["insecureSkipVerify"]), "true") + + p.client = &http.Client{ + // No client-level timeout: request lifetime is bounded by the caller's + // context so large downloads aren't cut off mid-stream. + Transport: &http.Transport{ + DialContext: (&net.Dialer{Timeout: dialTimeout}).DialContext, + TLSHandshakeTimeout: dialTimeout, + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: p.insecureTLS, //nolint:gosec // opt-in for self-signed test servers + }, + }, + } + return nil +} + +// resolve joins a caller-supplied path against the base path. A leading "/" is +// treated as relative to the server URL's own path root; an empty path becomes +// the base path itself. It never allows escaping above that root: the joined +// path is cleaned against a virtual "/" so "..", stray separators, and +// backslashes can't climb out. +func (p *Plugin) resolve(rel string) string { + rel = strings.TrimSpace(rel) + rel = strings.ReplaceAll(rel, "\\", "/") + base := p.basePath + if base == "." { + base = "" + } + var joined string + switch { + case rel == "": + joined = base + case strings.HasPrefix(rel, "/"): + joined = rel // relative to the server URL root, not the base path + case base == "": + joined = rel + default: + joined = base + "/" + rel + } + // Clean against a virtual root so nothing escapes above it. + return strings.TrimPrefix(path.Clean("/"+joined), "/") +} + +// requestURL builds the absolute request URL for a resolved path. When dir is +// true a trailing slash is kept, which WebDAV servers expect for collection +// operations (PROPFIND/MKCOL). url.URL.String() percent-escapes the path, so +// callers pass unescaped segments. +func (p *Plugin) requestURL(resolved string, dir bool) (string, error) { + base, err := url.Parse(p.baseURL) + if err != nil { + return "", fmt.Errorf("invalid server URL: %w", err) + } + if base.Scheme == "" || base.Host == "" { + return "", errors.New("server URL must include scheme and host") + } + full := *base + full.Path = path.Join("/"+strings.Trim(base.Path, "/"), resolved) + full.RawPath = "" // force re-escaping from Path + if dir && !strings.HasSuffix(full.Path, "/") { + full.Path += "/" + } + return full.String(), nil +} + +// do issues one authenticated WebDAV request and returns the response. The +// caller is responsible for closing the body. +func (p *Plugin) do(ctx context.Context, method, rawURL string, body io.Reader, headers map[string]string) (*http.Response, error) { + p.mu.Lock() + client, user, pass := p.client, p.username, p.password + p.mu.Unlock() + if client == nil { + return nil, errors.New("plugin not initialized") + } + req, err := http.NewRequestWithContext(ctx, method, rawURL, body) + if err != nil { + return nil, err + } + if user != "" || pass != "" { + req.SetBasicAuth(user, pass) + } + for k, v := range headers { + req.Header.Set(k, v) + } + return client.Do(req) +} + +// HealthCheck issues a PROPFIND against the base path and classifies the +// outcome. A 401/403 means the server is reachable but auth failed (degraded); +// a transport error is down. +func (p *Plugin) HealthCheck(ctx context.Context) plugins.Health { + start := time.Now() + + p.mu.Lock() + base, baseURL := p.basePath, p.baseURL + p.mu.Unlock() + + if baseURL == "" { + return plugins.Health{Status: plugins.StatusDown, Detail: "no server URL configured"} + } + + entries, err := p.propfind(ctx, p.resolve(""), 1) + lat := time.Since(start).Milliseconds() + if err != nil { + var he *httpError + if errors.As(err, &he) { + return plugins.Health{Status: classifyStatus(he.code), LatencyMs: lat, + Detail: fmt.Sprintf("connected but PROPFIND %q returned %d %s", base, he.code, http.StatusText(he.code))} + } + return plugins.Health{Status: plugins.StatusDown, LatencyMs: lat, Detail: err.Error()} + } + + detail := fmt.Sprintf("WebDAV reachable — %d entr%s under %q", len(entries), plural(len(entries)), base) + status := plugins.StatusOK + if strings.HasPrefix(strings.ToLower(baseURL), "http://") { + status = plugins.StatusDegraded + detail += " · plaintext HTTP (no encryption)" + } + return plugins.Health{Status: status, LatencyMs: lat, Detail: detail} +} + +// Invoke runs one capability against the WebDAV server. +func (p *Plugin) Invoke(ctx context.Context, action string, params json.RawMessage) (json.RawMessage, error) { + switch action { + case "list": + var in pathParams + _ = json.Unmarshal(params, &in) + rp := p.resolve(in.Path) + entries, err := p.propfind(ctx, rp, 1) + if err != nil { + return nil, err + } + return json.Marshal(map[string]any{"path": rp, "entries": entries}) + + case "stat": + var in pathParams + _ = json.Unmarshal(params, &in) + rp := p.resolve(in.Path) + entries, err := p.propfind(ctx, rp, 0) + if err != nil { + return nil, err + } + if len(entries) == 0 { + return nil, fmt.Errorf("not found: %s", rp) + } + return json.Marshal(entries[0]) + + case "download": + var in pathParams + _ = json.Unmarshal(params, &in) + rp := p.resolve(in.Path) + data, err := p.read(ctx, rp) + if err != nil { + return nil, err + } + return json.Marshal(map[string]any{ + "path": rp, + "size": len(data), + "contentBase64": base64.StdEncoding.EncodeToString(data), + }) + + case "upload": + var in writeParams + if err := json.Unmarshal(params, &in); err != nil { + return nil, fmt.Errorf("invalid params: %w", err) + } + data, err := base64.StdEncoding.DecodeString(in.ContentBase64) + if err != nil { + return nil, fmt.Errorf("contentBase64 is not valid base64: %w", err) + } + rp := p.resolve(in.Path) + if err := p.write(ctx, rp, data); err != nil { + return nil, err + } + return json.Marshal(map[string]any{"path": rp, "size": len(data), "ok": true}) + + case "delete": + var in pathParams + _ = json.Unmarshal(params, &in) + rp := p.resolve(in.Path) + if err := p.remove(ctx, rp); err != nil { + return nil, err + } + return json.Marshal(map[string]any{"path": rp, "ok": true}) + + case "mkdir": + var in pathParams + _ = json.Unmarshal(params, &in) + rp := p.resolve(in.Path) + if err := p.mkdir(ctx, rp); err != nil { + return nil, err + } + return json.Marshal(map[string]any{"path": rp, "ok": true}) + + default: + return nil, errors.New("unknown action: " + action) + } +} + +func (p *Plugin) Shutdown(context.Context) error { return nil } + +// pathParams / writeParams are the Invoke request shapes. +type pathParams struct { + Path string `json:"path"` +} +type writeParams struct { + Path string `json:"path"` + ContentBase64 string `json:"contentBase64"` +} + +// fileInfo is the normalized directory-entry shape returned by list/stat. It +// matches filetransfer's shape so callers can treat the drives uniformly. +type fileInfo struct { + Name string `json:"name"` + Size int64 `json:"size"` + IsDir bool `json:"isDir"` + ModTime string `json:"modTime,omitempty"` +} + +// httpError carries a non-2xx status so HealthCheck can classify it. +type httpError struct { + code int + method string +} + +func (e *httpError) Error() string { + return fmt.Sprintf("%s: %d %s", e.method, e.code, http.StatusText(e.code)) +} + +// --------------------------------------------------------------------------- +// WebDAV operations +// --------------------------------------------------------------------------- + +// propfind lists (depth 1) or stats (depth 0) a path. For depth 1 the entry +// describing the collection itself is dropped so only children are returned. +func (p *Plugin) propfind(ctx context.Context, resolved string, depth int) ([]fileInfo, error) { + u, err := p.requestURL(resolved, true) + if err != nil { + return nil, err + } + resp, err := p.do(ctx, "PROPFIND", u, strings.NewReader(propfindBody), map[string]string{ + "Depth": strconv.Itoa(depth), + "Content-Type": "application/xml; charset=utf-8", + }) + if err != nil { + return nil, err + } + defer drainClose(resp.Body) + + // 207 Multi-Status is the success case; 200 is tolerated for lenient servers. + if resp.StatusCode != http.StatusMultiStatus && resp.StatusCode != http.StatusOK { + return nil, &httpError{code: resp.StatusCode, method: "PROPFIND"} + } + + var ms davMultistatus + if err := xml.NewDecoder(io.LimitReader(resp.Body, maxReadBytes)).Decode(&ms); err != nil { + return nil, fmt.Errorf("parse PROPFIND response: %w", err) + } + + // The request path, cleaned, is used to recognise and drop the self entry. + self := strings.Trim(resolved, "/") + out := make([]fileInfo, 0, len(ms.Responses)) + for _, r := range ms.Responses { + hrefPath := hrefToPath(r.Href) + if depth == 1 && strings.Trim(hrefPath, "/") == self { + continue // the collection itself + } + out = append(out, r.toFileInfo()) + } + return out, nil +} + +func (p *Plugin) read(ctx context.Context, resolved string) ([]byte, error) { + u, err := p.requestURL(resolved, false) + if err != nil { + return nil, err + } + resp, err := p.do(ctx, http.MethodGet, u, nil, nil) + if err != nil { + return nil, err + } + defer drainClose(resp.Body) + if resp.StatusCode/100 != 2 { + return nil, &httpError{code: resp.StatusCode, method: "GET"} + } + return io.ReadAll(io.LimitReader(resp.Body, maxReadBytes)) +} + +func (p *Plugin) write(ctx context.Context, resolved string, data []byte) error { + u, err := p.requestURL(resolved, false) + if err != nil { + return err + } + resp, err := p.do(ctx, http.MethodPut, u, strings.NewReader(string(data)), + map[string]string{"Content-Type": "application/octet-stream"}) + if err != nil { + return err + } + defer drainClose(resp.Body) + if resp.StatusCode/100 != 2 { + return &httpError{code: resp.StatusCode, method: "PUT"} + } + return nil +} + +func (p *Plugin) remove(ctx context.Context, resolved string) error { + u, err := p.requestURL(resolved, false) + if err != nil { + return err + } + resp, err := p.do(ctx, http.MethodDelete, u, nil, nil) + if err != nil { + return err + } + defer drainClose(resp.Body) + // 404 is tolerated as already-gone. + if resp.StatusCode/100 != 2 && resp.StatusCode != http.StatusNotFound { + return &httpError{code: resp.StatusCode, method: "DELETE"} + } + return nil +} + +func (p *Plugin) mkdir(ctx context.Context, resolved string) error { + u, err := p.requestURL(resolved, true) + if err != nil { + return err + } + resp, err := p.do(ctx, "MKCOL", u, nil, nil) + if err != nil { + return err + } + defer drainClose(resp.Body) + // 405 Method Not Allowed is what most servers return when the collection + // already exists — treat it as success (idempotent mkdir). + if resp.StatusCode/100 != 2 && resp.StatusCode != http.StatusMethodNotAllowed { + return &httpError{code: resp.StatusCode, method: "MKCOL"} + } + return nil +} + +// --------------------------------------------------------------------------- +// PROPFIND XML shapes and helpers +// --------------------------------------------------------------------------- + +type davMultistatus struct { + XMLName xml.Name `xml:"DAV: multistatus"` + Responses []davResponse `xml:"DAV: response"` +} + +type davResponse struct { + Href string `xml:"DAV: href"` + Propstats []davPropstat `xml:"DAV: propstat"` +} + +type davPropstat struct { + Status string `xml:"DAV: status"` + Prop davProp `xml:"DAV: prop"` +} + +type davProp struct { + DisplayName string `xml:"DAV: displayname"` + ContentLen string `xml:"DAV: getcontentlength"` + LastModified string `xml:"DAV: getlastmodified"` + ResourceType davResourceType `xml:"DAV: resourcetype"` +} + +type davResourceType struct { + Collection *xml.Name `xml:"DAV: collection"` +} + +// toFileInfo normalizes a PROPFIND , preferring the 2xx propstat. +func (r davResponse) toFileInfo() fileInfo { + fi := fileInfo{Name: nameFromHref(r.Href)} + for _, ps := range r.Propstats { + if !strings.Contains(ps.Status, " 2") { // "HTTP/1.1 200 OK" + continue + } + if ps.Prop.ResourceType.Collection != nil { + fi.IsDir = true + } + if n, err := strconv.ParseInt(strings.TrimSpace(ps.Prop.ContentLen), 10, 64); err == nil { + fi.Size = n + } + if lm := strings.TrimSpace(ps.Prop.LastModified); lm != "" { + if t, err := http.ParseTime(lm); err == nil { + fi.ModTime = t.UTC().Format(time.RFC3339) + } + } + if fi.Name == "" && strings.TrimSpace(ps.Prop.DisplayName) != "" { + fi.Name = ps.Prop.DisplayName + } + } + return fi +} + +// hrefToPath extracts the URL path from an href, which may be absolute +// (http://host/a/b) or path-only (/a/b), and percent-decodes it. +func hrefToPath(href string) string { + if u, err := url.Parse(href); err == nil && u.Path != "" { + return u.Path + } + if dec, err := url.PathUnescape(href); err == nil { + return dec + } + return href +} + +// nameFromHref returns the last path segment of an href, percent-decoded. +func nameFromHref(href string) string { + p := strings.TrimRight(hrefToPath(href), "/") + if i := strings.LastIndex(p, "/"); i >= 0 { + p = p[i+1:] + } + return p +} + +// classifyStatus maps an HTTP status to a health status: an auth rejection means +// the server is reachable but credentials are wrong (degraded); anything else is +// down. +func classifyStatus(code int) string { + switch code { + case http.StatusUnauthorized, http.StatusForbidden: + return plugins.StatusDegraded + default: + return plugins.StatusDown + } +} + +// drainClose drains and closes a response body so the connection can be reused. +func drainClose(body io.ReadCloser) { + _, _ = io.Copy(io.Discard, io.LimitReader(body, 4<<10)) + _ = body.Close() +} + +func plural(n int) string { + if n == 1 { + return "y" + } + return "ies" +} diff --git a/API Server/internal/plugins/builtin/webdav/webdav_test.go b/API Server/internal/plugins/builtin/webdav/webdav_test.go new file mode 100644 index 0000000..235e6b1 --- /dev/null +++ b/API Server/internal/plugins/builtin/webdav/webdav_test.go @@ -0,0 +1,300 @@ +package webdav + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "pilotvault/apiserver/internal/plugins" +) + +func TestDescriptor(t *testing.T) { + p := &Plugin{} + d := p.Descriptor() + if d.Name != "webdav" { + t.Fatalf("name = %q, want webdav", d.Name) + } + if d.Kind != plugins.KindBuiltin { + t.Fatalf("kind = %q, want builtin", d.Kind) + } + if d.Category != plugins.CategoryDrivesExternal { + t.Fatalf("category = %q, want %q", d.Category, plugins.CategoryDrivesExternal) + } + if len(d.Capabilities) == 0 { + t.Fatal("expected capabilities") + } + // The password field must be flagged so the manager masks it, and no field + // may be Required (so the plugin can be enabled as an empty master switch). + for _, f := range d.ConfigFields { + if f.Key == "password" && !f.Secret { + t.Errorf("config field %q must be Secret", f.Key) + } + if f.Required { + t.Errorf("config field %q must not be Required", f.Key) + } + } +} + +func TestInitDefaults(t *testing.T) { + p := &Plugin{} + if err := p.Init(context.Background(), map[string]string{"baseURL": "https://h/dav"}); err != nil { + t.Fatal(err) + } + if p.basePath != "." { + t.Errorf("default basePath = %q, want .", p.basePath) + } + if p.client == nil { + t.Error("Init must build an http client") + } +} + +func TestResolveConfinement(t *testing.T) { + p := &Plugin{basePath: "Documents"} + cases := map[string]string{ + "": "Documents", + "a/b.txt": "Documents/a/b.txt", + "/etc/abs": "etc/abs", // leading slash → relative to dav root, not base + "../../escape": "escape", // cannot climb above the root + "a/../../escape": "escape", // nor via traversal + "a\\b": "Documents/a/b", // backslashes normalized + } + for in, want := range cases { + if got := p.resolve(in); got != want { + t.Errorf("resolve(%q) = %q, want %q", in, got, want) + } + } +} + +func TestRequestURL(t *testing.T) { + p := &Plugin{baseURL: "https://cloud.example.com/remote.php/dav/files/alice/"} + got, err := p.requestURL("Documents/report 1.txt", false) + if err != nil { + t.Fatal(err) + } + want := "https://cloud.example.com/remote.php/dav/files/alice/Documents/report%201.txt" + if got != want { + t.Errorf("requestURL = %q, want %q", got, want) + } + // A directory op keeps the trailing slash servers expect for collections. + dir, _ := p.requestURL("Documents", true) + if !strings.HasSuffix(dir, "/") { + t.Errorf("dir URL %q should end with /", dir) + } +} + +func TestRequestURLRejectsBadBase(t *testing.T) { + p := &Plugin{baseURL: "not-a-url"} + if _, err := p.requestURL("x", false); err == nil { + t.Fatal("expected error for base URL without scheme/host") + } +} + +// TestHealthCheckNoURL confirms an empty base URL is reported as down, not a panic. +func TestHealthCheckNoURL(t *testing.T) { + p := &Plugin{} + if err := p.Init(context.Background(), nil); err != nil { + t.Fatal(err) + } + if h := p.HealthCheck(context.Background()); h.Status != plugins.StatusDown { + t.Errorf("status = %q, want down (detail=%q)", h.Status, h.Detail) + } +} + +func TestNameFromHref(t *testing.T) { + cases := map[string]string{ + "/dav/files/alice/report%201.txt": "report 1.txt", + "http://host/dav/Photos/": "Photos", + "/dav/": "dav", + } + for in, want := range cases { + if got := nameFromHref(in); got != want { + t.Errorf("nameFromHref(%q) = %q, want %q", in, got, want) + } + } +} + +func TestRegistered(t *testing.T) { + m := plugins.NewManager(t.TempDir() + "/plugins.json") + if _, ok := m.Get("webdav"); !ok { + t.Fatal("webdav not registered in the plugin manager") + } +} + +// fakeDAV is a minimal in-memory WebDAV server exercising the verbs the plugin +// uses. It is not spec-complete — just enough to drive the round-trip test. +type fakeDAV struct { + files map[string][]byte // path (no leading slash) → contents; dirs end in "/" +} + +func newFakeDAV() *fakeDAV { + return &fakeDAV{files: map[string][]byte{ + "": nil, // root collection + "docs/": nil, // a subdirectory + "hello.txt": []byte("hi"), // a file + }} +} + +func (f *fakeDAV) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if u, _, ok := r.BasicAuth(); !ok || u != "alice" { + w.WriteHeader(http.StatusUnauthorized) + return + } + key := strings.Trim(r.URL.Path, "/") + switch r.Method { + case "PROPFIND": + f.propfind(w, r, key) + case http.MethodGet: + if data, ok := f.files[key]; ok && data != nil { + _, _ = w.Write(data) + return + } + w.WriteHeader(http.StatusNotFound) + case http.MethodPut: + body := make([]byte, r.ContentLength) + _, _ = r.Body.Read(body) + f.files[key] = body + w.WriteHeader(http.StatusCreated) + case http.MethodDelete: + delete(f.files, key) + w.WriteHeader(http.StatusNoContent) + case "MKCOL": + f.files[key+"/"] = nil + w.WriteHeader(http.StatusCreated) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } +} + +func (f *fakeDAV) propfind(w http.ResponseWriter, r *http.Request, key string) { + w.Header().Set("Content-Type", "application/xml; charset=utf-8") + w.WriteHeader(http.StatusMultiStatus) + var b strings.Builder + b.WriteString(``) + writeResp := func(href string, isDir bool, size int) { + rt := "" + if isDir { + rt = "" + } + fmt.Fprintf(&b, `%s`+ + `%d`+ + `Wed, 08 Jul 2026 10:00:00 GMT`+ + `%s`+ + `HTTP/1.1 200 OK`, + href, size, rt) + } + // Self entry first. + writeResp("/"+key, true, 0) + if r.Header.Get("Depth") == "1" && key == "" { + writeResp("/docs/", true, 0) + writeResp("/hello.txt", false, 2) + } + b.WriteString(``) + _, _ = w.Write([]byte(b.String())) +} + +// TestRoundTrip drives list/stat/download/upload/delete/mkdir against the fake +// server and checks the plugin's normalized responses. +func TestRoundTrip(t *testing.T) { + srv := httptest.NewServer(newFakeDAV()) + defer srv.Close() + + p := &Plugin{} + if err := p.Init(context.Background(), map[string]string{ + "baseURL": srv.URL, "username": "alice", "password": "pw", + }); err != nil { + t.Fatal(err) + } + ctx := context.Background() + + // list: the self entry is dropped, leaving docs/ and hello.txt. + raw, err := p.Invoke(ctx, "list", json.RawMessage(`{"path":""}`)) + if err != nil { + t.Fatalf("list: %v", err) + } + var listed struct { + Entries []fileInfo `json:"entries"` + } + mustJSON(t, raw, &listed) + if len(listed.Entries) != 2 { + t.Fatalf("list returned %d entries, want 2: %+v", len(listed.Entries), listed.Entries) + } + var sawDir, sawFile bool + for _, e := range listed.Entries { + if e.Name == "docs" && e.IsDir { + sawDir = true + } + if e.Name == "hello.txt" && !e.IsDir && e.Size == 2 { + sawFile = true + } + } + if !sawDir || !sawFile { + t.Errorf("unexpected entries: %+v", listed.Entries) + } + + // download + raw, err = p.Invoke(ctx, "download", json.RawMessage(`{"path":"hello.txt"}`)) + if err != nil { + t.Fatalf("download: %v", err) + } + var dl struct { + ContentBase64 string `json:"contentBase64"` + } + mustJSON(t, raw, &dl) + if got, _ := base64.StdEncoding.DecodeString(dl.ContentBase64); string(got) != "hi" { + t.Errorf("download content = %q, want hi", got) + } + + // upload → then download it back + body := base64.StdEncoding.EncodeToString([]byte("new-file")) + if _, err := p.Invoke(ctx, "upload", json.RawMessage(fmt.Sprintf(`{"path":"new.txt","contentBase64":%q}`, body))); err != nil { + t.Fatalf("upload: %v", err) + } + raw, err = p.Invoke(ctx, "download", json.RawMessage(`{"path":"new.txt"}`)) + if err != nil { + t.Fatalf("download after upload: %v", err) + } + mustJSON(t, raw, &dl) + if got, _ := base64.StdEncoding.DecodeString(dl.ContentBase64); string(got) != "new-file" { + t.Errorf("round-tripped content = %q, want new-file", got) + } + + // mkdir and delete should succeed without error + if _, err := p.Invoke(ctx, "mkdir", json.RawMessage(`{"path":"newdir"}`)); err != nil { + t.Fatalf("mkdir: %v", err) + } + if _, err := p.Invoke(ctx, "delete", json.RawMessage(`{"path":"new.txt"}`)); err != nil { + t.Fatalf("delete: %v", err) + } + + // health check is OK against a live (http) server, but degraded because it's plaintext + if h := p.HealthCheck(ctx); h.Status != plugins.StatusDegraded { + t.Errorf("health status = %q, want degraded (plaintext http); detail=%q", h.Status, h.Detail) + } +} + +// TestHealthCheckAuthFailure confirms a 401 is classified as degraded, not down. +func TestHealthCheckAuthFailure(t *testing.T) { + srv := httptest.NewServer(newFakeDAV()) + defer srv.Close() + p := &Plugin{} + if err := p.Init(context.Background(), map[string]string{ + "baseURL": srv.URL, "username": "wrong", "password": "pw", + }); err != nil { + t.Fatal(err) + } + if h := p.HealthCheck(context.Background()); h.Status != plugins.StatusDegraded { + t.Errorf("status = %q, want degraded on 401 (detail=%q)", h.Status, h.Detail) + } +} + +func mustJSON(t *testing.T, raw json.RawMessage, v any) { + t.Helper() + if err := json.Unmarshal(raw, v); err != nil { + t.Fatalf("unmarshal %s: %v", raw, err) + } +} diff --git a/API Server/internal/plugins/doc.go b/API Server/internal/plugins/doc.go new file mode 100644 index 0000000..8423a0c --- /dev/null +++ b/API Server/internal/plugins/doc.go @@ -0,0 +1,20 @@ +package plugins + +// Deferred extension points (deliberately NOT in v1 — the "Management MVP"). +// The contract and manager are shaped so these can be added without a redesign: +// +// - Invocation API: the Plugin.Invoke method already exists; a +// POST /api/admin/plugins/{name}/action endpoint + a normalized request/ +// response envelope would expose it. Add a mapper layer so core logic never +// depends on a provider's schema. +// - Resilience: wrap plugin calls with retry/backoff + a circuit breaker, and +// record per-plugin latency/error/quota metrics for the panel. +// - Per-tenant credentials: today config is a single global blob per plugin. +// A (pluginName, orgID/userID) → config store would let users connect their +// own third-party accounts. +// - Audit logging: record which plugin accessed what and when. +// - Sandboxing: the "external" plugin kind is the isolation story — run less +// trusted plugins as separate processes/containers behind the HTTP contract. +// - Hot-adding builtin Go code without a rebuild is intentionally unsupported +// (Go .so plugins are Linux-only and toolchain-fragile); use the external +// HTTP kind to add plugins at runtime instead. diff --git a/API Server/internal/plugins/external.go b/API Server/internal/plugins/external.go new file mode 100644 index 0000000..961054e --- /dev/null +++ b/API Server/internal/plugins/external.go @@ -0,0 +1,149 @@ +package plugins + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "time" +) + +// externalPlugin adapts a remote HTTP service to the Plugin contract. The remote +// side implements a tiny JSON contract: +// +// GET {baseURL}/manifest → { provider, version, capabilities, authType, configFields } +// GET {baseURL}/health → 2xx, optionally { status, detail } +// POST {baseURL}/invoke → { action, params } → arbitrary JSON (v1: unused) +// +// This is the "add a plugin without a rebuild" path: register a base URL at +// runtime and the server drives it over HTTP. It is also the sandboxing story — +// a less-trusted plugin runs as its own process/container. +type externalPlugin struct { + name string + baseURL string + desc Descriptor + client *http.Client +} + +func newExternalPlugin(name, baseURL, provider string) *externalPlugin { + if provider == "" { + provider = "External" + } + return &externalPlugin{ + name: name, + baseURL: baseURL, + client: &http.Client{Timeout: 8 * time.Second}, + desc: Descriptor{ + Name: name, + Provider: provider, + Version: "external", + Kind: KindExternal, + Category: CategoryAPIsExternal, // remote HTTP service; a manifest may override + AuthType: AuthNone, + }, + } +} + +func (e *externalPlugin) Descriptor() Descriptor { return e.desc } + +// Init best-effort fetches the remote manifest to enrich the descriptor. A +// missing/broken manifest is non-fatal — the basic descriptor stands. +func (e *externalPlugin) Init(ctx context.Context, _ map[string]string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, e.baseURL+"/manifest", nil) + if err != nil { + return nil + } + resp, err := e.client.Do(req) + if err != nil { + return nil + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil + } + data, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + var man struct { + Provider string `json:"provider"` + Version string `json:"version"` + Category string `json:"category"` + Capabilities []Capability `json:"capabilities"` + AuthType AuthType `json:"authType"` + ConfigFields []ConfigField `json:"configFields"` + } + if json.Unmarshal(data, &man) == nil { + if man.Provider != "" { + e.desc.Provider = man.Provider + } + if man.Version != "" { + e.desc.Version = man.Version + } + if man.AuthType != "" { + e.desc.AuthType = man.AuthType + } + if man.Category != "" { + e.desc.Category = man.Category + } + e.desc.Capabilities = man.Capabilities + e.desc.ConfigFields = man.ConfigFields + } + return nil +} + +func (e *externalPlugin) HealthCheck(ctx context.Context) Health { + start := time.Now() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, e.baseURL+"/health", nil) + if err != nil { + return Health{Status: StatusDown, Detail: err.Error()} + } + resp, err := e.client.Do(req) + lat := time.Since(start).Milliseconds() + if err != nil { + return Health{Status: StatusDown, LatencyMs: lat, Detail: err.Error()} + } + defer resp.Body.Close() + data, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10)) + + // Honour an explicit {status, detail} body when present. + var body struct { + Status string `json:"status"` + Detail string `json:"detail"` + } + _ = json.Unmarshal(data, &body) + + h := Health{LatencyMs: lat, Detail: body.Detail} + switch { + case body.Status != "": + h.Status = body.Status + case resp.StatusCode >= 200 && resp.StatusCode < 300: + h.Status = StatusOK + case resp.StatusCode >= 500: + h.Status = StatusDown + default: + h.Status = StatusDegraded + } + if h.Detail == "" && h.Status != StatusOK { + h.Detail = "HTTP " + resp.Status + } + return h +} + +// Invoke proxies to the remote /invoke endpoint. Part of the contract; no HTTP +// endpoint exposes it in v1. +func (e *externalPlugin) Invoke(ctx context.Context, action string, params json.RawMessage) (json.RawMessage, error) { + payload, _ := json.Marshal(map[string]any{"action": action, "params": params}) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, e.baseURL+"/invoke", bytes.NewReader(payload)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + resp, err := e.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + data, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) + return data, nil +} + +func (e *externalPlugin) Shutdown(context.Context) error { return nil } diff --git a/API Server/internal/plugins/manager.go b/API Server/internal/plugins/manager.go new file mode 100644 index 0000000..00167a0 --- /dev/null +++ b/API Server/internal/plugins/manager.go @@ -0,0 +1,387 @@ +package plugins + +import ( + "context" + "encoding/json" + "errors" + "log" + "net/http" + "os" + "sort" + "strings" + "sync" + "time" +) + +// secretMask is what a set secret value is echoed back as. On save, a field that +// still equals the mask is left unchanged (mirrors the pb-config password flow). +const secretMask = "••••••••" + +// record is the persisted state for one plugin. For builtins, Kind/BaseURL are +// omitted (the descriptor comes from the registry); external plugins set them. +type record struct { + Kind string `json:"kind,omitempty"` + BaseURL string `json:"baseURL,omitempty"` + Provider string `json:"provider,omitempty"` + Enabled bool `json:"enabled"` + Config map[string]string `json:"config,omitempty"` +} + +// View is the plugin shape returned to the panel (secrets masked). +type View struct { + Descriptor + Enabled bool `json:"enabled"` + Config map[string]string `json:"config"` + BaseURL string `json:"baseURL,omitempty"` + Health *Health `json:"health,omitempty"` +} + +// Manager owns the plugin registry, persisted state, and live instances. +type Manager struct { + path string + mu sync.Mutex + factories map[string]Factory + records map[string]*record + live map[string]Plugin + health map[string]*Health + client *http.Client +} + +// NewManager builds a Manager backed by the JSON state file at path. +func NewManager(path string) *Manager { + return &Manager{ + path: path, + factories: builtinFactories(), + records: map[string]*record{}, + live: map[string]Plugin{}, + health: map[string]*Health{}, + client: &http.Client{Timeout: 12 * time.Second}, + } +} + +// Load reads the state file and initialises every enabled plugin. A missing file +// is fine (no plugins configured yet). +func (m *Manager) Load() error { + m.mu.Lock() + defer m.mu.Unlock() + + if data, err := os.ReadFile(m.path); err == nil { + var recs map[string]*record + if err := json.Unmarshal(data, &recs); err != nil { + return err + } + m.records = recs + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + + ctx := context.Background() + for name, rec := range m.records { + if !rec.Enabled { + continue + } + p := construct(name, m.factories[name], rec) + if p == nil { + log.Printf("plugins: cannot construct %q (unknown builtin?)", name) + continue + } + if err := p.Init(ctx, rec.Config); err != nil { + log.Printf("plugins: init %q failed: %v", name, err) + continue + } + m.live[name] = p + } + return nil +} + +// construct builds a plugin instance from a builtin factory or an external record. +func construct(name string, f Factory, rec *record) Plugin { + if f != nil { + return f() + } + if rec != nil && rec.Kind == KindExternal { + return newExternalPlugin(name, rec.BaseURL, rec.Provider) + } + return nil +} + +// descriptorFor returns a plugin's descriptor without needing a live instance. +func (m *Manager) descriptorFor(name string, rec *record) Descriptor { + if p := m.live[name]; p != nil { + return p.Descriptor() + } + if f := m.factories[name]; f != nil { + return f().Descriptor() + } + if rec != nil && rec.Kind == KindExternal { + return newExternalPlugin(name, rec.BaseURL, rec.Provider).Descriptor() + } + return Descriptor{Name: name} +} + +// maskConfig echoes config back with secret fields masked when set. +func maskConfig(d Descriptor, cfg map[string]string) map[string]string { + out := map[string]string{} + for k, v := range cfg { + out[k] = v + } + for _, f := range d.ConfigFields { + if f.Secret && out[f.Key] != "" { + out[f.Key] = secretMask + } + } + return out +} + +// List returns every known plugin (registry ∪ persisted), sorted by name. +func (m *Manager) List() []View { + m.mu.Lock() + defer m.mu.Unlock() + + names := map[string]bool{} + for n := range m.factories { + names[n] = true + } + for n := range m.records { + names[n] = true + } + + out := make([]View, 0, len(names)) + for name := range names { + rec := m.records[name] + d := m.descriptorFor(name, rec) + v := View{Descriptor: d, Health: m.health[name]} + if rec != nil { + v.Enabled = rec.Enabled + v.BaseURL = rec.BaseURL + v.Config = maskConfig(d, rec.Config) + } else { + v.Config = map[string]string{} + } + out = append(out, v) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +// Get returns a single plugin view (ok=false when unknown). +func (m *Manager) Get(name string) (View, bool) { + for _, v := range m.List() { + if v.Name == name { + return v, true + } + } + return View{}, false +} + +// Upsert enables/disables a plugin and merges its config, then (re)initialises or +// shuts down the live instance to match. Secrets left at the mask are preserved. +func (m *Manager) Upsert(ctx context.Context, name string, enabled bool, incoming map[string]string) (View, error) { + m.mu.Lock() + + _, isBuiltin := m.factories[name] + rec := m.records[name] + if !isBuiltin && (rec == nil || rec.Kind != KindExternal) { + m.mu.Unlock() + return View{}, errUnknown + } + if rec == nil { + rec = &record{} + m.records[name] = rec + } + + d := m.descriptorFor(name, rec) + merged := map[string]string{} + for k, v := range rec.Config { + merged[k] = v + } + // Apply incoming values, honouring the secret-mask keep-current rule. + secretKeys := map[string]bool{} + for _, f := range d.ConfigFields { + if f.Secret { + secretKeys[f.Key] = true + } + } + for k, v := range incoming { + if secretKeys[k] && v == secretMask { + continue // keep existing secret + } + merged[k] = strings.TrimSpace(v) + } + // Validate required fields when enabling. + if enabled { + for _, f := range d.ConfigFields { + if f.Required && merged[f.Key] == "" { + m.mu.Unlock() + return View{}, errors.New("missing required setting: " + f.Label) + } + } + } + + rec.Enabled = enabled + rec.Config = merged + if err := m.persistLocked(); err != nil { + m.mu.Unlock() + return View{}, err + } + + // Reconcile the live instance. + if old := m.live[name]; old != nil { + _ = old.Shutdown(ctx) + delete(m.live, name) + } + var initErr error + if enabled { + p := construct(name, m.factories[name], rec) + if p != nil { + if err := p.Init(ctx, merged); err != nil { + initErr = err + } else { + m.live[name] = p + } + } + } + m.mu.Unlock() + + v, _ := m.Get(name) + return v, initErr +} + +// RegisterExternal adds a new external (remote HTTP) plugin at runtime — the +// "add a plugin without a rebuild" path. It starts disabled. +func (m *Manager) RegisterExternal(name, baseURL, provider string) error { + name = strings.TrimSpace(name) + baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/") + if name == "" || baseURL == "" { + return errors.New("name and baseURL are required") + } + if !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") { + baseURL = "http://" + baseURL + } + + m.mu.Lock() + defer m.mu.Unlock() + if _, dup := m.factories[name]; dup { + return errors.New("a builtin plugin already uses that name") + } + if _, dup := m.records[name]; dup { + return errors.New("a plugin with that name already exists") + } + m.records[name] = &record{Kind: KindExternal, BaseURL: baseURL, Provider: provider} + return m.persistLocked() +} + +// Remove deletes an external plugin registration. Builtins can only be disabled. +func (m *Manager) Remove(ctx context.Context, name string) error { + m.mu.Lock() + defer m.mu.Unlock() + rec := m.records[name] + if rec == nil || rec.Kind != KindExternal { + return errors.New("only external plugins can be removed") + } + if p := m.live[name]; p != nil { + _ = p.Shutdown(ctx) + delete(m.live, name) + } + delete(m.records, name) + delete(m.health, name) + return m.persistLocked() +} + +// HealthCheck probes a plugin now, building a transient instance if it is not +// currently live (so disabled plugins can still be tested). Result is cached. +func (m *Manager) HealthCheck(ctx context.Context, name string) (Health, error) { + m.mu.Lock() + p := m.live[name] + transient := false + var cfg map[string]string + if p == nil { + rec := m.records[name] + if rec != nil { + cfg = rec.Config + } + p = construct(name, m.factories[name], rec) + transient = true + } + m.mu.Unlock() + + if p == nil { + return Health{}, errUnknown + } + if transient { + _ = p.Init(ctx, cfg) + defer func() { _ = p.Shutdown(context.Background()) }() + } + h := p.HealthCheck(ctx) + + m.mu.Lock() + hc := h + m.health[name] = &hc + m.mu.Unlock() + return h, nil +} + +// HealthCheckWith probes a plugin using a caller-supplied config instead of the +// stored record. It always builds a transient instance, so it never disturbs the +// live instance or the cached global health. Used by per-user integration flows +// that resolve their own effective config (e.g. the OpenSky settings cascade). +func (m *Manager) HealthCheckWith(ctx context.Context, name string, cfg map[string]string) (Health, error) { + m.mu.Lock() + rec := m.records[name] + p := construct(name, m.factories[name], rec) + m.mu.Unlock() + + if p == nil { + return Health{}, errUnknown + } + _ = p.Init(ctx, cfg) + defer func() { _ = p.Shutdown(context.Background()) }() + return p.HealthCheck(ctx), nil +} + +// RawConfig returns a plugin's stored config UNMASKED, together with its enabled +// flag and whether the plugin is known. Server-side callers use it to resolve a +// layered effective config (which needs the real secret values); it must never be +// returned to a client. ok is false for an unknown plugin. +func (m *Manager) RawConfig(name string) (cfg map[string]string, enabled, ok bool) { + m.mu.Lock() + defer m.mu.Unlock() + + _, isBuiltin := m.factories[name] + rec := m.records[name] + if !isBuiltin && rec == nil { + return nil, false, false + } + out := map[string]string{} + if rec != nil { + for k, v := range rec.Config { + out[k] = v + } + enabled = rec.Enabled + } + return out, enabled, true +} + +// Shutdown tears down every live plugin instance. Wire into graceful shutdown. +func (m *Manager) Shutdown(ctx context.Context) { + m.mu.Lock() + defer m.mu.Unlock() + for name, p := range m.live { + _ = p.Shutdown(ctx) + delete(m.live, name) + } +} + +// persistLocked writes the state file. Caller must hold m.mu. +func (m *Manager) persistLocked() error { + data, err := json.MarshalIndent(m.records, "", " ") + if err != nil { + return err + } + return os.WriteFile(m.path, append(data, '\n'), 0o600) +} + +var errUnknown = errors.New("unknown plugin") + +// IsUnknown reports whether err came from addressing a plugin that doesn't exist. +func IsUnknown(err error) bool { return errors.Is(err, errUnknown) } diff --git a/API Server/internal/plugins/plugin.go b/API Server/internal/plugins/plugin.go new file mode 100644 index 0000000..2150d9c --- /dev/null +++ b/API Server/internal/plugins/plugin.go @@ -0,0 +1,167 @@ +// Package plugins is the API Server's plugin system: a uniform contract for +// integrating external third-party services (flight data, notifications, …). +// +// Two plugin kinds share one contract: +// - "builtin" — a Go connector compiled into the server (type-safe, first-party). +// Adding a new builtin requires a rebuild. See builtin/opensky for an example. +// - "external" — a remote service registered at runtime (no rebuild) that speaks +// a small JSON contract over HTTP. See external.go. +// +// Enable-state and per-plugin config (including secrets) are persisted to a local +// plugins.json by the Manager, mirroring how the PocketBase connection persists to +// .env. See doc.go for the deliberately-deferred extension points. +package plugins + +import ( + "context" + "encoding/json" +) + +// Plugin kinds. +const ( + KindBuiltin = "builtin" + KindExternal = "external" +) + +// AuthType describes how a plugin authenticates to its upstream. It is metadata +// for the UI/operators; each plugin implements the mechanics itself. +type AuthType string + +const ( + AuthNone AuthType = "none" + AuthAPIKey AuthType = "apikey" + AuthBasic AuthType = "basic" + AuthOAuth2 AuthType = "oauth2" + AuthWebhook AuthType = "webhook" +) + +// Health status values. +const ( + StatusOK = "ok" + StatusDegraded = "degraded" + StatusDown = "down" +) + +// SelectOption is one choice for a ConfigField of Type "select". +type SelectOption struct { + Value string `json:"value"` + Label string `json:"label"` +} + +// ConfigField declares one configurable setting a plugin accepts. It drives the +// panel's generated config form and controls secret masking. +type ConfigField struct { + Key string `json:"key"` + Label string `json:"label"` + Type string `json:"type"` // "text" | "password" | "number" | "select" + Required bool `json:"required"` + Secret bool `json:"secret"` // never echoed back to clients in clear + Help string `json:"help,omitempty"` + Default string `json:"default,omitempty"` // effective default when unset + Options []SelectOption `json:"options,omitempty"` // for Type "select" +} + +// Capability is one operation a plugin exposes. It maps a stable id to the +// upstream endpoint it calls and a human description shown in the panel. +type Capability struct { + ID string `json:"id"` + Method string `json:"method,omitempty"` // e.g. "GET" + Endpoint string `json:"endpoint,omitempty"` // upstream path, e.g. "/states/all" + Description string `json:"description,omitempty"` +} + +// UnmarshalJSON accepts either a bare string ("states.all") or a full object, so +// external manifests can advertise capabilities in either form. +func (c *Capability) UnmarshalJSON(b []byte) error { + var s string + if json.Unmarshal(b, &s) == nil { + c.ID = s + return nil + } + type alias Capability + var a alias + if err := json.Unmarshal(b, &a); err != nil { + return err + } + *c = Capability(a) + return nil +} + +// Category groups a plugin under a tab in the admin panel. A plugin with an +// empty category is treated as CategoryAPIsExternal by the panel. +const ( + CategoryAPIsExternal = "apis-external" // remote HTTP APIs (OpenSky, external plugins) + CategoryDrivesExternal = "drives-external" // remote file stores (FTP/SFTP) + CategoryDrivesLocal = "drives-local" // drives on the host machine +) + +// Descriptor is the static metadata a plugin advertises about itself. +type Descriptor struct { + Name string `json:"name"` + Provider string `json:"provider"` + Version string `json:"version"` + Kind string `json:"kind"` // KindBuiltin | KindExternal + Category string `json:"category"` // one of Category* — groups the plugin in the panel + Capabilities []Capability `json:"capabilities"` + AuthType AuthType `json:"authType"` + ConfigFields []ConfigField `json:"configFields"` +} + +// Health is the outcome of a plugin's HealthCheck. +type Health struct { + Status string `json:"status"` // StatusOK | StatusDegraded | StatusDown + LatencyMs int64 `json:"latencyMs,omitempty"` + Detail string `json:"detail,omitempty"` + Credits *HealthCredits `json:"credits,omitempty"` +} + +// HealthCredits is optional structured rate-limit/credit accounting a plugin may +// report alongside a probe (e.g. OpenSky's daily credit allowance). It lets the UI +// render a dedicated usage meter instead of parsing it back out of Detail. +type HealthCredits struct { + Remaining *int `json:"remaining,omitempty"` // credits left today; nil when the upstream didn't report it (e.g. anonymous) + Daily int `json:"daily,omitempty"` // the plan's daily allowance + ProbeCost int `json:"probeCost,omitempty"` // credits one query/probe costs + Mode string `json:"mode,omitempty"` // "authenticated" | "anonymous" +} + +// Plugin is the contract every plugin (builtin or external) implements. +type Plugin interface { + // Descriptor returns the plugin's static metadata. It may be enriched after + // Init (e.g. an external plugin fetching its manifest). + Descriptor() Descriptor + // Init prepares the plugin with its resolved config (secrets included). It is + // called when the plugin is enabled or its config changes. + Init(ctx context.Context, config map[string]string) error + // HealthCheck probes the upstream and classifies the result. + HealthCheck(ctx context.Context) Health + // Invoke runs a named capability. Part of the contract for future use; v1 + // exposes no HTTP endpoint for it. + Invoke(ctx context.Context, action string, params json.RawMessage) (json.RawMessage, error) + // Shutdown releases any resources held by the plugin. + Shutdown(ctx context.Context) error +} + +// Factory builds a fresh instance of a builtin plugin. +type Factory func() Plugin + +// registry holds the builtin plugin factories keyed by descriptor name. +var registry = map[string]Factory{} + +// Register adds a builtin plugin factory. Called from a builtin package's init(). +// Panics on a duplicate name so wiring mistakes surface at startup. +func Register(name string, f Factory) { + if _, dup := registry[name]; dup { + panic("plugins: duplicate registration for " + name) + } + registry[name] = f +} + +// builtinFactories returns a copy of the registered builtin factories. +func builtinFactories() map[string]Factory { + out := make(map[string]Factory, len(registry)) + for k, v := range registry { + out[k] = v + } + return out +} diff --git a/API Server/panel/index.html b/API Server/panel/index.html new file mode 100644 index 0000000..bacd93d --- /dev/null +++ b/API Server/panel/index.html @@ -0,0 +1,14 @@ + + + + + + + + PilotVault · API Server + + +
+ + + diff --git a/API Server/panel/package-lock.json b/API Server/panel/package-lock.json new file mode 100644 index 0000000..b3fbb98 --- /dev/null +++ b/API Server/panel/package-lock.json @@ -0,0 +1,1964 @@ +{ + "name": "pilotvault-api-panel", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pilotvault-api-panel", + "version": "1.0.0", + "dependencies": { + "vue": "^3.5.13" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.0.0", + "@vitejs/plugin-vue": "^5.2.1", + "tailwindcss": "^4.0.0", + "vite": "^6.0.7" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz", + "integrity": "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.2.tgz", + "integrity": "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-x64": "4.3.2", + "@tailwindcss/oxide-freebsd-x64": "4.3.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-x64-musl": "4.3.2", + "@tailwindcss/oxide-wasm32-wasi": "4.3.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz", + "integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz", + "integrity": "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz", + "integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz", + "integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz", + "integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz", + "integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz", + "integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz", + "integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.2.tgz", + "integrity": "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.2.tgz", + "integrity": "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz", + "integrity": "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.2.tgz", + "integrity": "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.2.tgz", + "integrity": "sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.2", + "@tailwindcss/oxide": "4.3.2", + "tailwindcss": "4.3.2" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.39.tgz", + "integrity": "sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/shared": "3.5.39", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.39.tgz", + "integrity": "sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.39.tgz", + "integrity": "sha512-d0ki86iOyN8LoZPBmk5SJWNwHP19CnDDCfuo//+2WJa2g5Ke0Jay983PIBIcSSzldC68I8DrD5GrHV3OSDfodg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/compiler-core": "3.5.39", + "@vue/compiler-dom": "3.5.39", + "@vue/compiler-ssr": "3.5.39", + "@vue/shared": "3.5.39", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.15", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.39.tgz", + "integrity": "sha512-Ce7/wvwMHai74bdszfXExdazFigYnlF9zgCmEQUcM1j0fOymlouZ7XilTYNo8oUjhlnjYOZbGrcYKuqjz89Ucw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.39.tgz", + "integrity": "sha512-TpsuBJ9gGlZa5d23XcM2y8EXanz9dZeVDQBXRwzy46ItgvM+rWpzs+UVM0wcRLxGvcav0HE5jz2gNL53xlRAog==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.39.tgz", + "integrity": "sha512-9GLtNyRvPAUMbX+7ono0RC2j0guo2LXVi8LvcmAooImACUKm0oFf0jjwbX8/H0AE/t1nxhAkn8RSl9PMCzzxZw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.39.tgz", + "integrity": "sha512-7Y6aAGboKcXAZ3ECuUy7RrS5yy2r47dhTp2SKaJmYxjopImaVFaNa5Ne66NwGovsrxVAl5S5rwc7m22UG7Lmww==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.39", + "@vue/runtime-core": "3.5.39", + "@vue/shared": "3.5.39", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.39.tgz", + "integrity": "sha512-yZSakiAGw85rZfG7UM8akMnIF+FmeiNk47uvHf2nVBBSe+dIKUhZuZq9+XgJhbV3nS5Z4ALH23/MpXofW+mbcw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.39", + "@vue/shared": "3.5.39" + }, + "peerDependencies": { + "vue": "3.5.39" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.39.tgz", + "integrity": "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.21.6", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", + "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz", + "integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.39.tgz", + "integrity": "sha512-xmZCYabFGcirU8r0fTuvl/LICc1OU620rnqepaJDL/a141ZigkG7AyaxQLdqJ02ZRYzWe6YPaDHeQx7MfknQfA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.39", + "@vue/compiler-sfc": "3.5.39", + "@vue/runtime-dom": "3.5.39", + "@vue/server-renderer": "3.5.39", + "@vue/shared": "3.5.39" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + } + } +} diff --git a/API Server/panel/package.json b/API Server/panel/package.json new file mode 100644 index 0000000..b92a717 --- /dev/null +++ b/API Server/panel/package.json @@ -0,0 +1,20 @@ +{ + "name": "pilotvault-api-panel", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "vue": "^3.5.13" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.0.0", + "@vitejs/plugin-vue": "^5.2.1", + "tailwindcss": "^4.0.0", + "vite": "^6.0.7" + } +} diff --git a/API Server/panel/public/favicon.svg b/API Server/panel/public/favicon.svg new file mode 100644 index 0000000..65cdb50 --- /dev/null +++ b/API Server/panel/public/favicon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/API Server/panel/src/App.vue b/API Server/panel/src/App.vue new file mode 100644 index 0000000..67cf689 --- /dev/null +++ b/API Server/panel/src/App.vue @@ -0,0 +1,807 @@ + + + diff --git a/API Server/panel/src/components/EndpointTable.vue b/API Server/panel/src/components/EndpointTable.vue new file mode 100644 index 0000000..e87fe24 --- /dev/null +++ b/API Server/panel/src/components/EndpointTable.vue @@ -0,0 +1,40 @@ + + + diff --git a/API Server/panel/src/main.js b/API Server/panel/src/main.js new file mode 100644 index 0000000..40dbaaa --- /dev/null +++ b/API Server/panel/src/main.js @@ -0,0 +1,6 @@ +import { createApp } from "vue"; +import App from "./App.vue"; +import "./style.css"; +import "./theme"; + +createApp(App).mount("#app"); diff --git a/API Server/panel/src/style.css b/API Server/panel/src/style.css new file mode 100644 index 0000000..2849628 --- /dev/null +++ b/API Server/panel/src/style.css @@ -0,0 +1,261 @@ +/* PilotVault API panel — design tokens (Vault Navy + Signal Blue) mapped into + Tailwind v4. Light is default; data-theme="dark" flips the semantic layer. */ +@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Space+Mono:wght@400;700&display=swap'); +@import "tailwindcss"; + +/* ============================================================ + RAW RAMPS + SEMANTIC ALIASES (light) + ============================================================ */ +:root, +[data-theme="light"] { + /* Brand ramps (do not theme-flip) */ + --navy-950: #0B1730; + --navy-900: #0F1E3D; /* Vault Navy — core brand */ + --navy-800: #1B2E52; + --navy-700: #26406E; + + --blue-50: #EAF1FE; + --blue-100: #D6E3FD; + --blue-300: #8FB4F6; + --blue-400: #5B93F5; + --blue-500: #3D7BF0; /* Signal Blue — accent */ + --blue-600: #2B62CC; + --blue-700: #1F4CA0; + + --slate-0: #FFFFFF; + --slate-50: #F6F7F9; + --slate-100: #EEF0F3; + --slate-150: #E6E9EE; + --slate-200: #DCE0E7; + --slate-300: #C5CCD7; + --slate-400: #97A1B0; + --slate-500: #6B7688; + --slate-700: #333B4A; + + --steel: #5A6B85; + + --green-500: #1F8A5B; --green-100: #DCF1E7; --green-600: #177049; + --amber-500: #D9852B; --amber-100: #FBEBD5; --amber-600: #B86C1B; + --red-500: #D64545; --red-100: #FBE0E0; --red-600: #B83232; + + /* Semantic aliases — LIGHT */ + --bg-page: var(--slate-100); + --bg-sunken: var(--slate-50); + --surface-card: var(--slate-0); + + --border-subtle: var(--slate-200); + --border-strong: var(--slate-300); + --border-focus: var(--blue-500); + + --text-primary: var(--navy-900); + --text-secondary: var(--steel); + --text-muted: var(--slate-400); + + --brand: var(--blue-500); + --brand-hover: var(--blue-600); + --brand-active: var(--blue-700); + --brand-contrast: #FFFFFF; + --text-brand: var(--blue-600); + + --success: var(--green-600); + --success-tint: var(--green-100); + --warning: var(--amber-600); + --warning-tint: var(--amber-100); + --danger: var(--red-600); + --danger-tint: var(--red-100); + + --ring-focus: 0 0 0 3px color-mix(in srgb, var(--blue-500) 45%, transparent); + + --sh-xs: 0 1px 2px rgba(15, 30, 61, 0.06); + --sh-sm: 0 1px 2px rgba(15, 30, 61, 0.06), 0 1px 3px rgba(15, 30, 61, 0.04); + + --dur-fast: 120ms; + --ease-standard: cubic-bezier(0.4, 0, 0.2, 1); + + color-scheme: light; +} + +/* ============================================================ + DARK THEME — only the semantic layer remaps. + ============================================================ */ +[data-theme="dark"] { + --bg-page: var(--navy-950); + --bg-sunken: #0B111C; + --surface-card: #10203F; + + --border-subtle: color-mix(in srgb, #ffffff 8%, transparent); + --border-strong: color-mix(in srgb, #ffffff 18%, transparent); + --border-focus: var(--blue-400); + + --text-primary: #F4F7FC; + --text-secondary: #8FA0BE; + --text-muted: #5E6E8C; + + --brand: var(--blue-400); + --brand-hover: var(--blue-300); + --brand-active: var(--blue-100); + --brand-contrast: #0F1E3D; + --text-brand: var(--blue-300); + + --success: #5FD3A0; + --success-tint: color-mix(in srgb, var(--green-500) 22%, transparent); + --warning: #F0B26A; + --warning-tint: color-mix(in srgb, var(--amber-500) 22%, transparent); + --danger: #F08A8A; + --danger-tint: color-mix(in srgb, var(--red-500) 22%, transparent); + + --ring-focus: 0 0 0 3px color-mix(in srgb, var(--blue-400) 55%, transparent); + + --sh-xs: 0 1px 2px rgba(0, 0, 0, 0.35); + --sh-sm: 0 1px 3px rgba(0, 0, 0, 0.4); + + color-scheme: dark; +} + +/* ============================================================ + TAILWIND THEME — utilities resolve to the semantic vars, so + everything flips automatically under data-theme="dark". + ============================================================ */ +@theme inline { + --color-*: initial; + + --color-page: var(--bg-page); + --color-sunken: var(--bg-sunken); + --color-card: var(--surface-card); + + --color-subtle: var(--border-subtle); + --color-strong: var(--border-strong); + + --color-primary: var(--text-primary); + --color-secondary: var(--text-secondary); + --color-muted: var(--text-muted); + --color-on-brand: var(--brand-contrast); + + --color-brand: var(--brand); + --color-brand-text: var(--text-brand); + + --color-success: var(--success); + --color-success-tint: var(--success-tint); + --color-warning: var(--warning); + --color-warning-tint: var(--warning-tint); + --color-danger: var(--danger); + --color-danger-tint: var(--danger-tint); + + --font-display: 'Space Grotesk', ui-sans-serif, system-ui, 'Segoe UI', sans-serif; + --font-sans: 'Space Grotesk', ui-sans-serif, system-ui, 'Segoe UI', sans-serif; + --font-mono: 'Space Mono', ui-monospace, 'SFMono-Regular', Menlo, monospace; + + --radius-*: initial; + --radius-xs: 4px; + --radius-sm: 6px; + --radius-md: 10px; + --radius-lg: 14px; + --radius-full: 9999px; + + --shadow-*: initial; + --shadow-xs: var(--sh-xs); + --shadow-sm: var(--sh-sm); +} + +/* ============================================================ + BASE + ============================================================ */ +html, +body, +#app { + height: 100%; +} + +body { + font-family: var(--font-sans); + background: var(--bg-page); + color: var(--text-primary); + -webkit-font-smoothing: antialiased; +} + +h1, h2, h3 { + font-family: var(--font-display); + letter-spacing: -0.02em; +} + +/* Secondary button */ +@utility pv-btn-sec { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + height: 40px; + padding: 0 16px; + border-radius: var(--radius-md); + border: 1px solid var(--border-strong); + background: var(--surface-card); + color: var(--text-primary); + font-family: var(--font-sans); + font-weight: 600; + font-size: 0.875rem; + cursor: pointer; + transition: background-color var(--dur-fast) var(--ease-standard), + transform var(--dur-fast) var(--ease-standard); +} + +.pv-btn-sec:hover:not(:disabled) { background: var(--bg-sunken); } +.pv-btn-sec:active:not(:disabled) { transform: translateY(1px); } +.pv-btn-sec:focus-visible { outline: none; box-shadow: var(--ring-focus); } + +/* Small button size modifier */ +@utility pv-btn-sm { + height: 32px; + padding: 0 12px; + font-size: 0.75rem; + border-radius: var(--radius-sm); +} + +/* Primary button */ +@utility pv-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + height: 40px; + padding: 0 16px; + border-radius: var(--radius-md); + border: 1px solid transparent; + background: var(--brand); + color: var(--brand-contrast); + font-family: var(--font-sans); + font-weight: 600; + font-size: 0.875rem; + cursor: pointer; + transition: background-color var(--dur-fast) var(--ease-standard), + transform var(--dur-fast) var(--ease-standard); +} + +.pv-btn:hover:not(:disabled) { background: var(--brand-hover); } +.pv-btn:active:not(:disabled) { transform: translateY(1px); } +.pv-btn:disabled { opacity: 0.55; cursor: not-allowed; } +.pv-btn:focus-visible { outline: none; box-shadow: var(--ring-focus); } + +/* Text input */ +@utility pv-input { + width: 100%; + height: 40px; + padding: 0 12px; + border-radius: var(--radius-md); + border: 1px solid var(--border-strong); + background: var(--surface-card); + color: var(--text-primary); + font-family: var(--font-sans); + font-size: 0.875rem; +} + +.pv-input::placeholder { color: var(--text-muted); } +.pv-input:focus { outline: none; border-color: var(--border-focus); box-shadow: var(--ring-focus); } + +/* Mono eyebrow — uppercase, tracked out */ +@utility pv-eyebrow { + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--text-muted); +} diff --git a/API Server/panel/src/theme.js b/API Server/panel/src/theme.js new file mode 100644 index 0000000..1ba505b --- /dev/null +++ b/API Server/panel/src/theme.js @@ -0,0 +1,30 @@ +import { ref } from "vue"; + +// Persisted light/dark theme, shared key with the PilotVault design-system kits. +const KEY = "pilotvault-theme"; + +function initial() { + try { + return localStorage.getItem(KEY) === "dark" ? "dark" : "light"; + } catch { + return "light"; + } +} + +export const theme = ref(initial()); + +export function applyTheme(t) { + theme.value = t; + document.documentElement.setAttribute("data-theme", t); + try { + localStorage.setItem(KEY, t); + } catch { + /* private mode — theme just won't persist */ + } +} + +export function toggleTheme() { + applyTheme(theme.value === "dark" ? "light" : "dark"); +} + +applyTheme(theme.value); diff --git a/API Server/panel/vite.config.js b/API Server/panel/vite.config.js new file mode 100644 index 0000000..d5478de --- /dev/null +++ b/API Server/panel/vite.config.js @@ -0,0 +1,20 @@ +import { defineConfig } from "vite"; +import vue from "@vitejs/plugin-vue"; +import tailwindcss from "@tailwindcss/vite"; + +// Builds into internal/api/dist, which the Go server embeds via go:embed +// and serves at the server root. +export default defineConfig({ + plugins: [vue(), tailwindcss()], + build: { + outDir: "../internal/api/dist", + emptyOutDir: true, + }, + server: { + port: 5174, + proxy: { + // Dev-mode proxy to a locally running API Server. + "/api": "http://localhost:8080", + }, + }, +}); diff --git a/API Server/pocketbase/README.md b/API Server/pocketbase/README.md new file mode 100644 index 0000000..2f67536 --- /dev/null +++ b/API Server/pocketbase/README.md @@ -0,0 +1,79 @@ +# PocketBase — PilotVault schema + +PilotVault adds an `organizations` collection and three fields to the `users` +auth collection: + +- **`preferences`** (JSON) — each user's settings blob. Written with the user's + own token, so PocketBase's default owner-only update rule + (`@request.auth.id = id`) is all the authorization needed. +- **`role`** (select: `superadmin` | `admin` | `user`) — the user-rights level. + A **superadmin** spans every organization; an **admin** is scoped to their own + organization (may manage its users *and* admins, but not superadmins); a + **user** has no management rights. Missing/empty is treated as `user`. +- **`organization`** (relation → `organizations`, maxSelect 1, optional) — which + org the user belongs to. Nullable: a user may belong to **no** organization. + +The **`organizations`** collection is a plain base collection with a unique +`name`. It is reached only through the API Server's superuser service account +(its API rules stay locked to superusers), the same way user management works. + +## User + org management requires a service account + +Listing/creating/deleting users and organizations is done by the API Server +using a **superuser service account** (`POCKETBASE_ADMIN_EMAIL` / +`POCKETBASE_ADMIN_PASSWORD`), but only *after* verifying the caller's own token +resolves to a manager role (`admin` for user management, `superadmin` for org +management). This is the single place the server uses elevated PocketBase +credentials; without the env vars, the `/api/users` and `/api/orgs` endpoints +return 503 and the rest is unaffected. + +Preferences never need the service account — they use the caller's own token. + +## Add the schema + +Pick **one** of the following. + +### Option A — migration (recommended) + +Copy the migration files into your PocketBase deployment's `pb_migrations/` +directory and restart PocketBase (migrations run automatically on boot; they +target the PocketBase v0.22+/v0.23 JS migration API). They are idempotent, so +they are safe even if the schema was already provisioned live: + +- [`pb_migrations/1720300000_add_users_preferences.js`](pb_migrations/1720300000_add_users_preferences.js) +- [`pb_migrations/1720300100_add_users_role.js`](pb_migrations/1720300100_add_users_role.js) +- [`pb_migrations/1720300200_add_organizations.js`](pb_migrations/1720300200_add_organizations.js) +- [`pb_migrations/1720300300_add_users_organization.js`](pb_migrations/1720300300_add_users_organization.js) +- [`pb_migrations/1720300400_extend_users_role_superadmin.js`](pb_migrations/1720300400_extend_users_role_superadmin.js) +- [`pb_migrations/1720300500_seed_orgs_and_users.js`](pb_migrations/1720300500_seed_orgs_and_users.js) — seeds the PilotVault org + baseline accounts + +### Option B — Admin UI (any version) + +1. Open the PocketBase Admin UI → **Collections → New collection** `organizations` + (base); add a **text** field **`name`** (required) with a unique index. +2. **Collections → `users` → New field.** Add **JSON** field **`preferences`**, + not required, max size ~5 MB. +3. Add **Select** field **`role`**, values `superadmin`, `admin`, `user`, max select 1. +4. Add **Relation** field **`organization`** → `organizations`, max select 1, not + required, cascade delete off. +5. Save. + +## Verify + +With a normal user token you should be able to round-trip the field: + +```bash +# 1) log in (PocketBase directly, or via the API Server /api/auth/login) +TOKEN=... # the "token" from the auth response + +# 2) save +curl -X PATCH "$PB_URL/api/collections/users/records/$USER_ID" \ + -H "Authorization: $TOKEN" -H "Content-Type: application/json" \ + -d '{"preferences":{"fontSize":"lg","themeMode":"dark"}}' + +# 3) read back +curl "$PB_URL/api/collections/users/auth-refresh" -X POST -H "Authorization: $TOKEN" +``` + +In the app the round-trip is: browser → `GET/PUT /bff/preferences` → API Server +`GET/PUT /api/preferences` → PocketBase user record. diff --git a/API Server/pocketbase/pb_migrations/1720300000_add_users_preferences.js b/API Server/pocketbase/pb_migrations/1720300000_add_users_preferences.js new file mode 100644 index 0000000..7e6b592 --- /dev/null +++ b/API Server/pocketbase/pb_migrations/1720300000_add_users_preferences.js @@ -0,0 +1,32 @@ +/// + +// Adds a `preferences` JSON field to the `users` auth collection so each user +// can persist their PilotVault settings (theme, appearance, profile, etc.). +// +// Apply by copying this file into your PocketBase deployment's `pb_migrations/` +// directory and restarting PocketBase (migrations run automatically on boot). +// Written for PocketBase v0.22+/v0.23 (JSVM `migrate((app) => …)` API). If your +// PocketBase is older, add the field manually — see pocketbase/README.md. +migrate( + (app) => { + const users = app.findCollectionByNameOrId('users') + + users.fields.add( + new Field({ + name: 'preferences', + type: 'json', + required: false, + presentable: false, + // ~5 MB — generous headroom (an optional base64 avatar rides along). + maxSize: 5000000, + }), + ) + + app.save(users) + }, + (app) => { + const users = app.findCollectionByNameOrId('users') + users.fields.removeByName('preferences') + app.save(users) + }, +) diff --git a/API Server/pocketbase/pb_migrations/1720300100_add_users_role.js b/API Server/pocketbase/pb_migrations/1720300100_add_users_role.js new file mode 100644 index 0000000..7b7c699 --- /dev/null +++ b/API Server/pocketbase/pb_migrations/1720300100_add_users_role.js @@ -0,0 +1,32 @@ +/// + +// Adds a `role` select field (admin | user) to the `users` auth collection. +// Drives PilotVault's user-rights model: admins can add/remove users; the API +// Server reads this field from the caller's token to gate admin endpoints. +// Missing/empty role is treated as "user" by the app. +// +// Apply by copying this file into your PocketBase deployment's `pb_migrations/` +// directory and restarting PocketBase. Written for PocketBase v0.22+/v0.23. +migrate( + (app) => { + const users = app.findCollectionByNameOrId('users') + + users.fields.add( + new Field({ + name: 'role', + type: 'select', + required: false, + presentable: false, + maxSelect: 1, + values: ['admin', 'user'], + }), + ) + + app.save(users) + }, + (app) => { + const users = app.findCollectionByNameOrId('users') + users.fields.removeByName('role') + app.save(users) + }, +) diff --git a/API Server/pocketbase/pb_migrations/1720300200_add_organizations.js b/API Server/pocketbase/pb_migrations/1720300200_add_organizations.js new file mode 100644 index 0000000..2b65ee1 --- /dev/null +++ b/API Server/pocketbase/pb_migrations/1720300200_add_organizations.js @@ -0,0 +1,43 @@ +/// + +// Creates the `organizations` collection. PilotVault scopes admins to a single +// organization; a superadmin spans all of them. Users may belong to no org. +// +// The API Server reaches this collection only through its superuser service +// account (like `users` management), so the collection API rules are left locked +// (superusers only). Apply by copying into your PocketBase deployment's +// `pb_migrations/` directory and restarting. Written for PocketBase v0.22+/v0.23. +// +// Idempotent: if the collection already exists (e.g. it was provisioned live via +// the admin API), this migration is a no-op. +migrate( + (app) => { + try { + app.findCollectionByNameOrId('organizations') + return // already present + } catch (_) { + // not found → create it + } + + const collection = new Collection({ + type: 'base', + name: 'organizations', + fields: [ + { name: 'name', type: 'text', required: true, max: 120, presentable: true }, + { name: 'created', type: 'autodate', onCreate: true, onUpdate: false }, + { name: 'updated', type: 'autodate', onCreate: true, onUpdate: true }, + ], + indexes: ['CREATE UNIQUE INDEX `idx_org_name` ON `organizations` (`name`)'], + }) + + app.save(collection) + }, + (app) => { + try { + const c = app.findCollectionByNameOrId('organizations') + app.delete(c) + } catch (_) { + // already gone + } + }, +) diff --git a/API Server/pocketbase/pb_migrations/1720300300_add_users_organization.js b/API Server/pocketbase/pb_migrations/1720300300_add_users_organization.js new file mode 100644 index 0000000..9aefcbd --- /dev/null +++ b/API Server/pocketbase/pb_migrations/1720300300_add_users_organization.js @@ -0,0 +1,34 @@ +/// + +// Adds an `organization` relation field to the `users` auth collection, pointing +// at the `organizations` collection. Not required (maxSelect 1), so users may be +// org-less. cascadeDelete is false: deleting an org does not delete its members. +// +// Depends on 1720300200_add_organizations.js. Idempotent: no-op if the field is +// already present. Written for PocketBase v0.22+/v0.23. +migrate( + (app) => { + const users = app.findCollectionByNameOrId('users') + if (users.fields.getByName('organization')) return + + const orgs = app.findCollectionByNameOrId('organizations') + users.fields.add( + new Field({ + name: 'organization', + type: 'relation', + required: false, + collectionId: orgs.id, + cascadeDelete: false, + minSelect: 0, + maxSelect: 1, + presentable: false, + }), + ) + app.save(users) + }, + (app) => { + const users = app.findCollectionByNameOrId('users') + users.fields.removeByName('organization') + app.save(users) + }, +) diff --git a/API Server/pocketbase/pb_migrations/1720300400_extend_users_role_superadmin.js b/API Server/pocketbase/pb_migrations/1720300400_extend_users_role_superadmin.js new file mode 100644 index 0000000..3c6a364 --- /dev/null +++ b/API Server/pocketbase/pb_migrations/1720300400_extend_users_role_superadmin.js @@ -0,0 +1,25 @@ +/// + +// Extends the `users.role` select field with a top-level `superadmin` value. +// Final set: superadmin | admin | user. superadmin spans all organizations; +// admin is scoped to one org; user has no management rights. Missing/empty role +// is still treated as `user` by the app. +// +// Idempotent: no-op if `superadmin` is already an allowed value. Written for +// PocketBase v0.22+/v0.23. +migrate( + (app) => { + const users = app.findCollectionByNameOrId('users') + const role = users.fields.getByName('role') + if (!role || (role.values && role.values.indexOf('superadmin') !== -1)) return + role.values = ['superadmin'].concat(role.values || []) + app.save(users) + }, + (app) => { + const users = app.findCollectionByNameOrId('users') + const role = users.fields.getByName('role') + if (!role) return + role.values = (role.values || []).filter((v) => v !== 'superadmin') + app.save(users) + }, +) diff --git a/API Server/pocketbase/pb_migrations/1720300500_seed_orgs_and_users.js b/API Server/pocketbase/pb_migrations/1720300500_seed_orgs_and_users.js new file mode 100644 index 0000000..a94249d --- /dev/null +++ b/API Server/pocketbase/pb_migrations/1720300500_seed_orgs_and_users.js @@ -0,0 +1,85 @@ +/// + +// Seeds PilotVault's baseline org + accounts. Idempotent: existing records are +// left in place (org/role are reconciled, passwords are not touched once a user +// exists). Safe to run alongside a live-provisioned deployment. +// +// Organization: PilotVault +// superadmin@pilotvault.local (superadmin, no org) +// dariusz@pilotvault.local (admin, PilotVault) — expected to pre-exist +// pilot@pilotvault.local (user, PilotVault) +// pilot@dji.local (user, no org) — left untouched +// +// Depends on the three schema migrations above. Written for PocketBase v0.22+/v0.23. +migrate( + (app) => { + // organization + let org = null + try { + org = app.findFirstRecordByFilter('organizations', 'name = "PilotVault"') + } catch (_) { + /* not found */ + } + if (!org) { + const oc = app.findCollectionByNameOrId('organizations') + org = new Record(oc) + org.set('name', 'PilotVault') + app.save(org) + } + + const uc = app.findCollectionByNameOrId('users') + + const ensure = (email, password, role, orgId) => { + let u = null + try { + u = app.findAuthRecordByEmail('users', email) + } catch (_) { + /* not found */ + } + if (u) { + let dirty = false + if (u.get('role') !== role) { + u.set('role', role) + dirty = true + } + if ((u.get('organization') || '') !== (orgId || '')) { + u.set('organization', orgId || '') + dirty = true + } + if (dirty) app.save(u) + return + } + u = new Record(uc) + u.set('email', email) + if (password) u.setPassword(password) + u.set('role', role) + u.set('verified', true) + u.set('emailVisibility', false) + if (orgId) u.set('organization', orgId) + app.save(u) + } + + ensure('superadmin@pilotvault.local', 'pilotvaultsuperadmin2026!', 'superadmin', null) + ensure('dariusz@pilotvault.local', null, 'admin', org.id) + ensure('pilot@pilotvault.local', 'pilotvaultuser2026!', 'user', org.id) + // pilot@dji.local is intentionally left as an org-less user. + }, + (app) => { + // Best-effort revert: drop the two seeded PilotVault accounts and the org. + // dariusz@pilotvault.local / pilot@dji.local predate this seed and are kept. + const drop = (email) => { + try { + app.delete(app.findAuthRecordByEmail('users', email)) + } catch (_) { + /* already gone */ + } + } + drop('superadmin@pilotvault.local') + drop('pilot@pilotvault.local') + try { + app.delete(app.findFirstRecordByFilter('organizations', 'name = "PilotVault"')) + } catch (_) { + /* already gone */ + } + }, +) diff --git a/API Server/pocketbase/pb_migrations/1720300600_add_plugin_settings.js b/API Server/pocketbase/pb_migrations/1720300600_add_plugin_settings.js new file mode 100644 index 0000000..ed05995 --- /dev/null +++ b/API Server/pocketbase/pb_migrations/1720300600_add_plugin_settings.js @@ -0,0 +1,38 @@ +/// + +// Adds a `pluginSettings` JSON field to both the `users` auth collection and the +// `organizations` collection. It backs the per-user / per-organization layers of +// the OpenSky plugin's cascading settings (API Server → Org Admin → User): +// { "opensky": { "config": { clientId, clientSecret, plan, bbox }, "enabled": bool } } +// The `enabled` flag is only meaningful on `users` (enablement is strictly per-user). +// +// Apply by copying this file into your PocketBase deployment's `pb_migrations/` +// directory and restarting PocketBase (migrations run automatically on boot). +// Idempotent: skips a collection whose field is already present. Written for +// PocketBase v0.22+/v0.23 (JSVM `migrate((app) => …)` API). +migrate( + (app) => { + for (const name of ['users', 'organizations']) { + const col = app.findCollectionByNameOrId(name) + if (col.fields.getByName('pluginSettings')) continue + + col.fields.add( + new Field({ + name: 'pluginSettings', + type: 'json', + required: false, + presentable: false, + maxSize: 100000, // small JSON blob of plugin config + }), + ) + app.save(col) + } + }, + (app) => { + for (const name of ['users', 'organizations']) { + const col = app.findCollectionByNameOrId(name) + col.fields.removeByName('pluginSettings') + app.save(col) + } + }, +) diff --git a/API Server/scripts/Run-ApiServer.ps1 b/API Server/scripts/Run-ApiServer.ps1 new file mode 100644 index 0000000..4d12b10 --- /dev/null +++ b/API Server/scripts/Run-ApiServer.ps1 @@ -0,0 +1,25 @@ +# Runs the PilotVault API Server. +# Loads .env (if present), then starts the Go server. +# +# ./scripts/Run-ApiServer.ps1 + +$ErrorActionPreference = "Stop" +$here = Split-Path -Parent $MyInvocation.MyCommand.Path +$root = Split-Path -Parent $here # the "API Server" folder + +Push-Location $root +try { + # Ensure Go is on PATH for this session. + $goBin = "C:\Program Files\Go\bin" + if (Test-Path $goBin) { $env:Path = "$goBin;$env:Path" } + + if (-not (Get-Command go -ErrorAction SilentlyContinue)) { + throw "Go is not installed or not on PATH." + } + + Write-Host "Starting API Server (Ctrl+C to stop)..." -ForegroundColor Cyan + go run ./cmd/server +} +finally { + Pop-Location +} diff --git a/Docker AIO/Dockerfile b/Docker AIO/Dockerfile new file mode 100644 index 0000000..f034a28 --- /dev/null +++ b/Docker AIO/Dockerfile @@ -0,0 +1,150 @@ +# syntax=docker/dockerfile:1 +# +# All-in-one PilotVault image: PocketBase + API Server + Web App in ONE container, +# supervised by supervisord. Convenience/demo image — for production run the three +# services separately (see Docker/docker-compose.yml). +# +# BUILD CONTEXT MUST BE THE REPO ROOT (this Dockerfile COPYs from "API Server/" +# and "Web App/"). From E:\VS Code Projects\PilotVault run: +# +# docker build -f "Docker AIO/Dockerfile" -t pilotvault-aio . +# docker run -p 8090:8090 -p 8080:8080 -p 8026:8026 \ +# -v pilotvault_pb:/pb/pb_data pilotvault-aio +# +# Internal ports (loopback-wired): PocketBase 8026, API Server 8080, Web App 8090. + +# ============================================================================= +# Stage 1 — build the API Server's embedded Vue panel (-> internal/api/dist) +# ============================================================================= +FROM node:22-alpine AS panel +WORKDIR /panel +COPY ["API Server/panel/package.json", "API Server/panel/package-lock.json", "./"] +RUN npm ci +COPY ["API Server/panel/", "./"] +RUN npm run build + +# ============================================================================= +# Stage 2 — build the API Server static binary (go.mod pins go 1.26) +# ============================================================================= +FROM golang:1.26-alpine AS api-build +WORKDIR /src +COPY ["API Server/go.mod", "API Server/go.sum", "./"] +RUN go mod download +COPY ["API Server/cmd/", "./cmd/"] +COPY ["API Server/internal/", "./internal/"] +# Overlay the freshly built panel so //go:embed all:dist picks it up. +COPY --from=panel /internal/api/dist ./internal/api/dist +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" \ + -o /out/api-server ./cmd/server + +# ============================================================================= +# Stage 3 — build the Web App's embedded Vue UI (-> web/) +# ============================================================================= +FROM node:22-alpine AS ui +WORKDIR /ui +COPY ["Web App/ui/package.json", "Web App/ui/package-lock.json", "./"] +RUN npm ci +COPY ["Web App/ui/", "./"] +RUN npm run build + +# ============================================================================= +# Stage 4 — build the Web App static binary (go.mod pins go 1.24) +# ============================================================================= +FROM golang:1.24-alpine AS web-build +WORKDIR /src +COPY ["Web App/go.mod", "Web App/go.sum", "./"] +RUN go mod download +COPY ["Web App/main.go", "Web App/bff.go", "./"] +# Overlay the freshly built UI so //go:embed web picks it up. +COPY --from=ui /web ./web +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" \ + -o /out/dji-web-app . + +# ============================================================================= +# Stage 5 — fetch the PocketBase binary +# ============================================================================= +FROM alpine:latest AS pocketbase +# Override with --build-arg PB_VERSION=x.y.z / PB_ARCH=arm64 as needed. +ARG PB_VERSION=0.22.21 +ARG PB_ARCH=amd64 +RUN apk add --no-cache unzip wget ca-certificates \ + && wget -O /tmp/pb.zip \ + "https://github.com/pocketbase/pocketbase/releases/download/v${PB_VERSION}/pocketbase_${PB_VERSION}_linux_${PB_ARCH}.zip" \ + && unzip /tmp/pb.zip -d /pb \ + && rm /tmp/pb.zip + +# ============================================================================= +# Stage 6 — runtime: alpine:latest running all three under supervisord +# ============================================================================= +FROM alpine:latest +RUN apk add --no-cache ca-certificates tzdata supervisor + +WORKDIR /app +COPY --from=api-build /out/api-server ./api-server +COPY --from=web-build /out/dji-web-app ./dji-web-app +COPY --from=pocketbase /pb/pocketbase ./pocketbase +# JS migrations (schema + seed accounts) applied by PocketBase on first serve. +COPY ["API Server/pocketbase/pb_migrations/", "/pb/pb_migrations/"] + +# --- Runtime configuration (loopback-wired between the three services) --------- +# API Server reads API_ADDR / POCKETBASE_URL / CORS_ALLOW_ORIGINS / POCKETBASE_ADMIN_* +# Web App reads ADDR / API_BASE +# NOTE: these bundle default credentials for convenience — override at `docker run`. +ENV API_ADDR=":8080" \ + POCKETBASE_URL="http://127.0.0.1:8026" \ + CORS_ALLOW_ORIGINS="*" \ + POCKETBASE_ADMIN_EMAIL="admin@dji.local" \ + POCKETBASE_ADMIN_PASSWORD="djiadmin2026!" \ + ADDR=":8090" \ + API_BASE="http://127.0.0.1:8080" + +# --- supervisord: PocketBase first, then API Server, then Web App -------------- +RUN cat > /etc/supervisord.conf <<'EOF' +[supervisord] +nodaemon=true +user=root +logfile=/dev/null +logfile_maxbytes=0 +pidfile=/run/supervisord.pid + +[program:pocketbase] +priority=10 +directory=/pb +# Ensure the service-account superuser exists, then serve on the internal port. +command=/bin/sh -c "/app/pocketbase superuser upsert \"$POCKETBASE_ADMIN_EMAIL\" \"$POCKETBASE_ADMIN_PASSWORD\" --dir=/pb/pb_data ; exec /app/pocketbase serve --http=0.0.0.0:8026 --dir=/pb/pb_data --migrationsDir=/pb/pb_migrations" +autorestart=true +startsecs=3 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 + +[program:api-server] +priority=20 +directory=/app +command=/app/api-server +autorestart=true +startsecs=3 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 + +[program:web-app] +priority=30 +directory=/app +command=/app/dji-web-app +autorestart=true +startsecs=3 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 +EOF + +# PocketBase data (SQLite). Mount a volume here to persist across restarts. +VOLUME ["/pb/pb_data"] + +EXPOSE 8090 8080 8026 + +ENTRYPOINT ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"] diff --git a/Docker AIO/docker-compose.yml b/Docker AIO/docker-compose.yml new file mode 100644 index 0000000..0499f4e --- /dev/null +++ b/Docker AIO/docker-compose.yml @@ -0,0 +1,31 @@ +# All-in-one PilotVault stack (PocketBase + API Server + Web App) in ONE container. +# Run from this Docker AIO/ folder: docker compose up --build +# The build context is the repo root (..) because the Dockerfile COPYs from +# "API Server/" and "Web App/". + +services: + pilotvault: + build: + context: ".." + dockerfile: "Docker AIO/Dockerfile" + # args: + # PB_VERSION: "0.22.21" # override the bundled PocketBase version + # PB_ARCH: "amd64" # use "arm64" on Apple Silicon + image: pilotvault-aio + container_name: pilotvault-aio + ports: + - "8090:8090" # Web App (control panel) + - "8080:8080" # API Server + - "8026:8026" # PocketBase + environment: + # Loopback-wired between the three in-container services. Override the + # bundled default credentials here for anything real. + POCKETBASE_ADMIN_EMAIL: "admin@dji.local" + POCKETBASE_ADMIN_PASSWORD: "djiadmin2026!" + CORS_ALLOW_ORIGINS: "*" + volumes: + - pb_data:/pb/pb_data # persist PocketBase SQLite across restarts + restart: unless-stopped + +volumes: + pb_data: diff --git a/Docker/docker-compose.yml b/Docker/docker-compose.yml new file mode 100644 index 0000000..6b386c9 --- /dev/null +++ b/Docker/docker-compose.yml @@ -0,0 +1,40 @@ +# Combined stack: API Server + Web App on a shared network. +# Run from this Docker/ folder: docker compose up --build +# Build contexts point back up to each service directory. + +services: + api-server: + build: + context: "../API Server" + image: pilotvault-api-server + container_name: pilotvault-api-server + # Config (POCKETBASE_URL, CORS_ALLOW_ORIGINS, POCKETBASE_ADMIN_*) from .env. + env_file: + - "../API Server/.env" + ports: + - "8080:8080" + networks: + - pilotvault + restart: unless-stopped + + web-app: + build: + context: "../Web App" + image: pilotvault-web-app + container_name: pilotvault-web-app + environment: + ADDR: ":8090" + # Reach the API Server by its service name on the shared network — + # no host.docker.internal needed here. + API_BASE: "http://api-server:8080" + ports: + - "8090:8090" + depends_on: + - api-server + networks: + - pilotvault + restart: unless-stopped + +networks: + pilotvault: + driver: bridge diff --git a/Fly App/.claude/launch.json b/Fly App/.claude/launch.json new file mode 100644 index 0000000..de0d707 --- /dev/null +++ b/Fly App/.claude/launch.json @@ -0,0 +1,17 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "webapp", + "runtimeExecutable": "E:\\VS Code Projects\\PilotVault\\Web App\\dji-web-app.exe", + "runtimeArgs": [], + "port": 8090 + }, + { + "name": "panel", + "runtimeExecutable": "E:\\VS Code Projects\\PilotVault\\API Server\\dji-api-server.exe", + "runtimeArgs": [], + "port": 8080 + } + ] +} diff --git a/Fly App/.claude/settings.local.json b/Fly App/.claude/settings.local.json new file mode 100644 index 0000000..25ecc08 --- /dev/null +++ b/Fly App/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "WebFetch(domain:repo1.maven.org)", + "PowerShell(& C:\\\\flutter\\\\bin\\\\flutter.bat config --jdk-dir \"C:\\\\Program Files\\\\Android\\\\Android Studio\\\\jbr\" 2>&1)", + "PowerShell(\"--- gradle.properties ---\")" + ] + } +} diff --git a/Fly App/.gitignore b/Fly App/.gitignore new file mode 100644 index 0000000..79c113f --- /dev/null +++ b/Fly App/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/Fly App/.metadata b/Fly App/.metadata new file mode 100644 index 0000000..9c3a509 --- /dev/null +++ b/Fly App/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "d8a9f9a52e5af486f80d932e838ee93861ffd863" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863 + base_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863 + - platform: android + create_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863 + base_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/Fly App/README.md b/Fly App/README.md new file mode 100644 index 0000000..38f706d --- /dev/null +++ b/Fly App/README.md @@ -0,0 +1,90 @@ +# DJI MSDK Sample (Flutter) + +A sample app demonstrating how to drive the **DJI Mobile SDK V4** from **Flutter**. + +DJI does not ship an official Flutter SDK — the Mobile SDK is a native +Android/iOS library. This project therefore puts a Flutter UI on top of a thin +**native Android (Kotlin) bridge** that talks to the DJI MSDK over platform +channels. It covers the core "sample app" flow: SDK registration, product +connection, and live telemetry (battery, GPS, flight status). + +> **Android only.** The DJI MSDK V4 native libraries here are wired up for +> Android. iOS would need a parallel Swift/Obj-C bridge (and a Mac to build). + +## Architecture + +``` +┌────────────────────────┐ platform channels ┌─────────────────────────┐ +│ Flutter (Dart) │ dji_msdk/methods (MethodChannel)│ Android (Kotlin) │ +│ lib/main.dart │ ───────────────────────────────▶ │ DjiSdkBridge.kt │ +│ lib/dji_service.dart │ dji_msdk/events (EventChannel) │ └─ DJI Mobile SDK V4 │ +│ │ ◀─────────────────────────────── │ DjiApplication.kt │ +└────────────────────────┘ └─────────────────────────┘ +``` + +| File | Responsibility | +| --- | --- | +| `lib/dji_service.dart` | Dart wrapper over the method/event channels | +| `lib/main.dart` | UI: registration / connection / telemetry cards | +| `android/app/.../DjiApplication.kt` | Installs the Secneo `Helper` (required by MSDK V4) | +| `android/app/.../MainActivity.kt` | Hosts the bridge, requests runtime permissions | +| `android/app/.../DjiSdkBridge.kt` | Registration, product lifecycle, telemetry callbacks | +| `android/app/build.gradle` | DJI deps, native-lib packaging, multidex, ABI filters | + +## Prerequisites + +1. **Flutter** (stable) and **Android SDK** with a connected **Android device** + (the DJI SDK does not work on emulators). +2. A **DJI drone + remote controller** supported by MSDK V4 (Phantom 4, + Mavic 2 / Air / Mini 1, Spark, Inspire 2, etc.). The RC connects to the phone + over USB. +3. A **DJI App Key** (see below). + +## Set your DJI App Key + +SDK registration will fail without a valid App Key bound to this app's +application id. + +1. Sign in at and create a new app. + - **Package name** must be exactly: `com.dji.flutter.dji_msdk_sample` + - SDK: **Mobile SDK** +2. Copy the generated **App Key**. +3. Paste it into [`android/gradle.properties`](android/gradle.properties): + + ```properties + DJI_API_KEY=your_real_app_key_here + ``` + + The key is injected into `AndroidManifest.xml` at build time via a + `manifestPlaceholder` (`com.dji.sdk.API_KEY`). + +## Run + +```bash +flutter pub get +flutter run # device must be plugged in +# or just build the APK: +flutter build apk --debug +``` + +## Using the app + +1. Launch it and grant the location / phone / mic permissions it requests. +2. Tap **Register app** — needs internet on first run; status turns green on + success. +3. Connect the drone's remote controller to the phone over USB and power on the + aircraft. The app auto-starts a connection on successful registration; you can + also tap **Connect to product**. +4. Once an aircraft connects, the **Telemetry** card streams battery %, GPS + satellite count, flight mode, altitude, and position. + +## Notes & gotchas + +- MSDK V4 version is pinned to `4.18` (`com.dji:dji-sdk` / `dji-sdk-provided`). +- `android.enableJetifier=true` is required — the SDK still uses legacy support + libraries. +- The native `.so` files are kept unstripped and de-duplicated via the + `packaging { }` block in `android/app/build.gradle`; only `armeabi-v7a` and + `arm64-v8a` ABIs are bundled (the only ABIs DJI provides). +- This sample uses the **core** SDK, not the DJI **UX SDK** (its native UI + widgets don't embed cleanly in Flutter). diff --git a/Fly App/analysis_options.yaml b/Fly App/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/Fly App/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/Fly App/android/.gitignore b/Fly App/android/.gitignore new file mode 100644 index 0000000..55afd91 --- /dev/null +++ b/Fly App/android/.gitignore @@ -0,0 +1,13 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/Fly App/android/app/build.gradle b/Fly App/android/app/build.gradle new file mode 100644 index 0000000..a481b6f --- /dev/null +++ b/Fly App/android/app/build.gradle @@ -0,0 +1,151 @@ +plugins { + id "com.android.application" + id "kotlin-android" + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id "dev.flutter.flutter-gradle-plugin" +} + +android { + namespace = "com.dji.flutter.dji_msdk_sample" + compileSdk = 35 + // DJI MSDK V4 ships prebuilt native (.so) libraries; pin a known-good NDK. + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_1_8 + } + + defaultConfig { + applicationId = "com.dji.flutter.dji_msdk_sample" + // DJI MSDK V4 requires Android 5.0+ (API 21). + minSdkVersion = flutter.minSdkVersion + targetSdk = 34 + versionCode = flutter.versionCode + versionName = flutter.versionName + + // DJI's SDK + Secneo Helper class loading require MultiDex. + multiDexEnabled = true + + // DJI provides prebuilt .so files only for these ABIs. + ndk { + abiFilters "armeabi-v7a", "arm64-v8a" + } + + // The DJI App Key is injected into AndroidManifest.xml at build time. + // Set DJI_API_KEY in android/gradle.properties (or pass -PDJI_API_KEY=...). + manifestPlaceholders["DJI_API_KEY"] = + (project.findProperty("DJI_API_KEY") ?: "PASTE_YOUR_DJI_APP_KEY_HERE") + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.debug + // DJI requires its ProGuard rules when minify is enabled. + proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" + } + } + + // DJI native libraries must not be stripped, and several bundled assets/ + // resources collide across the SDK modules and must be de-duplicated. + packaging { + jniLibs { + keepDebugSymbols += [ + "**/libdjivideo.so", + "**/libSDKRelativeJNI.so", + "**/libFlyForbid.so", + "**/libduml_vision_bokeh.so", + "**/libyuv2.so", + "**/libGroudStation.so", + "**/libFRCorkscrew.so", + "**/libUpgradeVerify.so", + "**/libFR.so", + "**/libDJIFlySafeCore.so", + "**/libdjifs_jni.so", + "**/libsfjni.so", + "**/libDJICommonJNI.so", + "**/libDJICSDKCommon.so", + "**/libDJIUpgradeCore.so", + "**/libDJIUpgradeJNI.so", + "**/libDJIWaypointV2Core.so", + "**/libdjiwaypointv2.so", + "**/libDJIMOP.so", + "**/libDJISDKLOGJNI.so", + ] + } + resources { + excludes += [ + "META-INF/rxjava.properties", + "META-INF/proguard/*", + "META-INF/INDEX.LIST", + "META-INF/DEPENDENCIES", + "assets/location_map_gps_locked.png", + "assets/location_map_gps_3d.png", + ] + pickFirsts += [ + "lib/**/libstlport_shared.so", + "lib/**/libRoadLineRebuildAPI.so", + "lib/**/libGNaviUtils.so", + "lib/**/libGNaviMapex.so", + "lib/**/libGNaviMap.so", + "lib/**/libGNaviSearch.so", + ] + } + } +} + +flutter { + source = "../.." +} + +dependencies { + // DJI Mobile SDK V4 (core). 'provided' contains compile-time-only stubs. + implementation "com.dji:dji-sdk:4.18" + compileOnly "com.dji:dji-sdk-provided:4.18" + + implementation "androidx.multidex:multidex:2.0.1" + implementation "androidx.core:core-ktx:1.13.1" + + // The DJI SDK bundles layouts that reference AppCompat (srcCompat) and + // ConstraintLayout attributes, so these must be on the resource classpath. + implementation "androidx.appcompat:appcompat:1.6.1" + implementation "androidx.constraintlayout:constraintlayout:2.1.4" + + // DJI's SDK publishes an event bus dependency used internally by some callbacks. + implementation "com.squareup:otto:1.3.8" + + // Required by the FlySafe / GEO modules pulled in by the SDK. + implementation "androidx.recyclerview:recyclerview:1.3.2" +} + +// --- Gradle 8 task-validation workaround ------------------------------------ +// Flutter's `compileFlutterBuild` task declares an output directory +// that overlaps the Android source sets, so Gradle 8's execution-time +// validation flags several AGP tasks (mergeShaders, checkAarMetadata, …) as +// consuming that output without a declared dependency, failing the build. +// Wire the dependency in explicitly. Safe: none of these tasks are inputs to +// the Flutter compile task, so no dependency cycle is introduced. +afterEvaluate { + def flutterTaskFor = { String name -> + for (v in ["Debug", "Release", "Profile"]) { + if (name.contains(v)) return tasks.findByName("compileFlutterBuild${v}") + } + return null + } + // Merge* source-set tasks (shaders / assets / jniLibs) — public AGP type. + tasks.withType(com.android.build.gradle.tasks.MergeSourceSetFolders).configureEach { t -> + def ft = flutterTaskFor(t.name) + if (ft != null) t.dependsOn(ft) + } + // Other AGP tasks that read the merged inputs. + tasks.matching { it.name ==~ /^(check|process|package|bundle|lintVitalAnalyze|lintAnalyze)(Debug|Release|Profile).*/ }.configureEach { t -> + def ft = flutterTaskFor(t.name) + if (ft != null) t.dependsOn(ft) + } +} diff --git a/Fly App/android/app/proguard-rules.pro b/Fly App/android/app/proguard-rules.pro new file mode 100644 index 0000000..f3f387e --- /dev/null +++ b/Fly App/android/app/proguard-rules.pro @@ -0,0 +1,26 @@ +# ── DJI Mobile SDK V4 ProGuard rules ─────────────────────────────────────────── +# Required so R8/ProGuard does not strip classes the SDK loads reflectively. +-keepclassmembers enum * { public static **[] values(); public static ** valueOf(java.lang.String); } +-keepclassmembers class * { public (android.content.Context); } + +-keep class com.dji.** { *; } +-keep class dji.** { *; } +-keep class com.secneo.** { *; } +-keep class sun.** { *; } +-keep class com.google.** { *; } +-keep class org.** { *; } +-keep class com.squareup.** { *; } +-keep class it.sephiroth.** { *; } +-keep class android.media.** { *; } + +-dontwarn dji.** +-dontwarn com.dji.** +-dontwarn com.secneo.** +-dontwarn sun.** +-dontwarn org.** +-dontwarn com.squareup.** + +-keepattributes Signature +-keepattributes *Annotation* +-keepattributes Exceptions +-keepattributes InnerClasses diff --git a/Fly App/android/app/src/debug/AndroidManifest.xml b/Fly App/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/Fly App/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/Fly App/android/app/src/main/AndroidManifest.xml b/Fly App/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..cc74347 --- /dev/null +++ b/Fly App/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Fly App/android/app/src/main/kotlin/com/dji/flutter/dji_msdk_sample/DjiApplication.kt b/Fly App/android/app/src/main/kotlin/com/dji/flutter/dji_msdk_sample/DjiApplication.kt new file mode 100644 index 0000000..e3a9b97 --- /dev/null +++ b/Fly App/android/app/src/main/kotlin/com/dji/flutter/dji_msdk_sample/DjiApplication.kt @@ -0,0 +1,22 @@ +package com.dji.flutter.dji_msdk_sample + +import android.app.Application +import android.content.Context +import com.cySdkyc.clx.Helper + +/** + * The DJI Mobile SDK V4 relocates and lazily loads its classes through the + * Secneo [Helper]. It MUST be installed in [attachBaseContext] — before any + * DJI class is touched — otherwise SDK registration crashes with a + * NoClassDefFoundError / UnsatisfiedLinkError. + * + * Registered in AndroidManifest.xml via android:name=".DjiApplication". + */ +class DjiApplication : Application() { + + override fun attachBaseContext(base: Context) { + super.attachBaseContext(base) + // Unpacks and prepares the DJI SDK native/dex payload. + Helper.install(this) + } +} diff --git a/Fly App/android/app/src/main/kotlin/com/dji/flutter/dji_msdk_sample/DjiSdkBridge.kt b/Fly App/android/app/src/main/kotlin/com/dji/flutter/dji_msdk_sample/DjiSdkBridge.kt new file mode 100644 index 0000000..f53581d --- /dev/null +++ b/Fly App/android/app/src/main/kotlin/com/dji/flutter/dji_msdk_sample/DjiSdkBridge.kt @@ -0,0 +1,189 @@ +package com.dji.flutter.dji_msdk_sample + +import android.content.Context +import android.os.Handler +import android.os.Looper +import dji.common.battery.BatteryState +import dji.common.error.DJIError +import dji.common.error.DJISDKError +import dji.common.flightcontroller.FlightControllerState +import dji.sdk.base.BaseComponent +import dji.sdk.base.BaseProduct +import dji.sdk.products.Aircraft +import dji.sdk.sdkmanager.DJISDKInitEvent +import dji.sdk.sdkmanager.DJISDKManager +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel + +/** + * Bridges the DJI Mobile SDK V4 to Flutter. + * + * - [METHOD_CHANNEL] handles imperative calls from Dart (register, connect, query). + * - [EVENT_CHANNEL] streams registration / connection / telemetry updates to Dart. + * + * All SDK callbacks arrive on arbitrary threads, so every event is marshalled to + * the main thread before being pushed into the Flutter [EventChannel.EventSink]. + */ +class DjiSdkBridge( + private val appContext: Context, + messenger: BinaryMessenger, +) : MethodChannel.MethodCallHandler, EventChannel.StreamHandler { + + companion object { + private const val METHOD_CHANNEL = "dji_msdk/methods" + private const val EVENT_CHANNEL = "dji_msdk/events" + } + + private val mainHandler = Handler(Looper.getMainLooper()) + private val methodChannel = MethodChannel(messenger, METHOD_CHANNEL) + private val eventChannel = EventChannel(messenger, EVENT_CHANNEL) + + private var eventSink: EventChannel.EventSink? = null + + init { + methodChannel.setMethodCallHandler(this) + eventChannel.setStreamHandler(this) + } + + // ── MethodChannel ────────────────────────────────────────────────────────── + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + when (call.method) { + "getSdkVersion" -> + result.success(DJISDKManager.getInstance().sdkVersion) + + "registerApp" -> { + registerApp() + result.success(null) + } + + "startConnection" -> + result.success(DJISDKManager.getInstance().startConnectionToProduct()) + + "stopConnection" -> { + DJISDKManager.getInstance().stopConnectionToProduct() + result.success(null) + } + + "getProductInfo" -> + result.success(connectionMap(DJISDKManager.getInstance().product)) + + else -> result.notImplemented() + } + } + + // ── EventChannel ─────────────────────────────────────────────────────────── + + override fun onListen(arguments: Any?, sink: EventChannel.EventSink?) { + eventSink = sink + } + + override fun onCancel(arguments: Any?) { + eventSink = null + } + + private fun emit(event: Map) { + mainHandler.post { eventSink?.success(event) } + } + + // ── DJI SDK registration & product lifecycle ───────────────────────────────── + + private fun registerApp() { + emit(mapOf("type" to "registration", "state" to "registering")) + + DJISDKManager.getInstance().registerApp( + appContext, + object : DJISDKManager.SDKManagerCallback { + + override fun onRegister(error: DJIError?) { + if (error == DJISDKError.REGISTRATION_SUCCESS) { + emit(mapOf("type" to "registration", "state" to "success")) + // Begin scanning for an attached product (USB RC / Wi-Fi). + DJISDKManager.getInstance().startConnectionToProduct() + } else { + emit( + mapOf( + "type" to "registration", + "state" to "failed", + "error" to (error?.description ?: "Unknown registration error"), + ) + ) + } + } + + override fun onProductConnect(product: BaseProduct?) { + emit(connectionMap(product)) + bindComponentCallbacks(product) + } + + override fun onProductChanged(product: BaseProduct?) { + emit(connectionMap(product)) + bindComponentCallbacks(product) + } + + override fun onProductDisconnect() { + emit(mapOf("type" to "connection", "connected" to false, "model" to null)) + } + + override fun onComponentChange( + key: BaseProduct.ComponentKey?, + oldComponent: BaseComponent?, + newComponent: BaseComponent?, + ) { + // A component (e.g. flight controller, battery) appeared/changed — + // (re)attach the telemetry callbacks. + bindComponentCallbacks(DJISDKManager.getInstance().product) + } + + override fun onInitProcess(event: DJISDKInitEvent?, totalProcess: Int) { + emit(mapOf("type" to "init", "event" to event?.toString())) + } + + override fun onDatabaseDownloadProgress(current: Long, total: Long) { + emit(mapOf("type" to "database", "current" to current, "total" to total)) + } + }, + ) + } + + private fun connectionMap(product: BaseProduct?): Map { + val connected = product != null && product.isConnected + val model = product?.model?.displayName + return mapOf("type" to "connection", "connected" to connected, "model" to model) + } + + /** Attaches flight-controller and battery state listeners when on an aircraft. */ + private fun bindComponentCallbacks(product: BaseProduct?) { + if (product !is Aircraft) return + + product.flightController?.setStateCallback { state: FlightControllerState -> + val location = state.aircraftLocation + emit( + mapOf( + "type" to "telemetry", + "satelliteCount" to state.satelliteCount, + "isFlying" to state.isFlying, + "flightMode" to state.flightModeString, + "altitude" to location?.altitude, + "latitude" to location?.latitude, + "longitude" to location?.longitude, + "velocityX" to state.velocityX, + "velocityY" to state.velocityY, + "velocityZ" to state.velocityZ, + ) + ) + } + + @Suppress("DEPRECATION") + product.battery?.setStateCallback { batteryState: BatteryState -> + emit( + mapOf( + "type" to "battery", + "percent" to batteryState.chargeRemainingInPercent, + ) + ) + } + } +} diff --git a/Fly App/android/app/src/main/kotlin/com/dji/flutter/dji_msdk_sample/DjiVideoView.kt b/Fly App/android/app/src/main/kotlin/com/dji/flutter/dji_msdk_sample/DjiVideoView.kt new file mode 100644 index 0000000..2546e10 --- /dev/null +++ b/Fly App/android/app/src/main/kotlin/com/dji/flutter/dji_msdk_sample/DjiVideoView.kt @@ -0,0 +1,87 @@ +package com.dji.flutter.dji_msdk_sample + +import android.content.Context +import android.graphics.SurfaceTexture +import android.view.TextureView +import android.view.View +import dji.sdk.camera.VideoFeeder +import dji.sdk.codec.DJICodecManager +import io.flutter.plugin.common.StandardMessageCodec +import io.flutter.plugin.platform.PlatformView +import io.flutter.plugin.platform.PlatformViewFactory + +/** + * Flutter [PlatformView] that renders the DJI product's live H.264 primary video + * feed as a full-bleed background for the Flight Control HUD. + * + * Pipeline: a [TextureView]'s [SurfaceTexture] is handed to a [DJICodecManager] + * (hardware decoder). [VideoFeeder]'s primary-feed data listener pushes raw + * frames straight into the decoder, which renders onto the surface. + * + * Registered under [VIEW_TYPE] in `MainActivity.configureFlutterEngine`; embedded + * on the Dart side by `AndroidView(viewType: 'dji_msdk/video')`. + * + * Note: this uses the *primary* video feed, which is correct for the vast + * majority of products. A few older transcoding models (e.g. Mavic Pro) expose + * their live view only on the transcoded feed — if such a product renders black, + * switch to `VideoFeeder.getInstance().provideTranscodedVideoFeed()`. + */ +class DjiVideoView(context: Context) : PlatformView, TextureView.SurfaceTextureListener { + + companion object { + const val VIEW_TYPE = "dji_msdk/video" + } + + private val textureView = TextureView(context).also { + it.surfaceTextureListener = this + } + + private var codecManager: DJICodecManager? = null + + // Pushes raw H.264 frames from the SDK straight into the hardware decoder. + private val videoDataListener = VideoFeeder.VideoDataListener { data, size -> + codecManager?.sendDataToDecoder(data, size) + } + + override fun getView(): View = textureView + + override fun dispose() { + teardown() + } + + // ── TextureView.SurfaceTextureListener ─────────────────────────────────── + + override fun onSurfaceTextureAvailable(surface: SurfaceTexture, width: Int, height: Int) { + if (codecManager == null) { + codecManager = DJICodecManager(textureView.context, surface, width, height) + } + VideoFeeder.getInstance()?.primaryVideoFeed?.addVideoDataListener(videoDataListener) + } + + override fun onSurfaceTextureSizeChanged(surface: SurfaceTexture, width: Int, height: Int) { + codecManager?.onSurfaceSizeChanged(width, height, 0) + } + + override fun onSurfaceTextureDestroyed(surface: SurfaceTexture): Boolean { + teardown() + return true + } + + override fun onSurfaceTextureUpdated(surface: SurfaceTexture) = Unit + + /** Detaches the feed listener and releases the decoder + its surface. */ + private fun teardown() { + VideoFeeder.getInstance()?.primaryVideoFeed?.removeVideoDataListener(videoDataListener) + codecManager?.let { + it.cleanSurface() + it.destroyCodec() + } + codecManager = null + } +} + +/** Builds a [DjiVideoView] for each `AndroidView(viewType: 'dji_msdk/video')`. */ +class DjiVideoViewFactory : PlatformViewFactory(StandardMessageCodec.INSTANCE) { + override fun create(context: Context, viewId: Int, args: Any?): PlatformView = + DjiVideoView(context) +} diff --git a/Fly App/android/app/src/main/kotlin/com/dji/flutter/dji_msdk_sample/MainActivity.kt b/Fly App/android/app/src/main/kotlin/com/dji/flutter/dji_msdk_sample/MainActivity.kt new file mode 100644 index 0000000..f98828d --- /dev/null +++ b/Fly App/android/app/src/main/kotlin/com/dji/flutter/dji_msdk_sample/MainActivity.kt @@ -0,0 +1,58 @@ +package com.dji.flutter.dji_msdk_sample + +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import io.flutter.embedding.android.FlutterFragmentActivity +import io.flutter.embedding.engine.FlutterEngine + +// FlutterFragmentActivity (not FlutterActivity) is required by local_auth so the +// platform BiometricPrompt can attach to a FragmentActivity host. +class MainActivity : FlutterFragmentActivity() { + + private var bridge: DjiSdkBridge? = null + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + bridge = DjiSdkBridge(applicationContext, flutterEngine.dartExecutor.binaryMessenger) + // Live DJI video feed rendered behind the Flight Control HUD. + flutterEngine.platformViewsController.registry + .registerViewFactory(DjiVideoView.VIEW_TYPE, DjiVideoViewFactory()) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + requestDjiPermissions() + } + + /** + * The DJI SDK needs these dangerous permissions granted before it can connect + * to / communicate with a product. We request them up front from the host + * Activity so the Flutter UI can stay focused on the SDK flow. + */ + private fun requestDjiPermissions() { + val missing = REQUIRED_PERMISSIONS.filter { + ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED + } + if (missing.isNotEmpty()) { + ActivityCompat.requestPermissions(this, missing.toTypedArray(), PERMISSION_REQUEST_CODE) + } + } + + companion object { + private const val PERMISSION_REQUEST_CODE = 12321 + + private val REQUIRED_PERMISSIONS = buildList { + add(Manifest.permission.ACCESS_FINE_LOCATION) + add(Manifest.permission.ACCESS_COARSE_LOCATION) + add(Manifest.permission.READ_PHONE_STATE) + add(Manifest.permission.RECORD_AUDIO) + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) { + add(Manifest.permission.WRITE_EXTERNAL_STORAGE) + } + }.toTypedArray() + } +} diff --git a/Fly App/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png b/Fly App/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..8042bb1c70a6b61d45e4a75283c300f3a3f0091b GIT binary patch literal 8507 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 uBtJkgjwW-}X=w&_D*ATz6aP) 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 axc2{HQk6*Pa7_mQ0000 + + + + + + + diff --git a/Fly App/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png b/Fly App/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..6862f1acc4d647df3e34cc404440fd42e8b23bf6 GIT binary patch literal 9399 zcmYkCcOcts)c3VU?OC&G&mw50W@)u3TC29$6tOBn)gDEOt*weJl-jEy6pg)NkJg^C zH#MsI{M`3*zwh%$l0SaQHO_U;_gv?55~ZW1Mn%p>PC!6FrT+AZ9`O0&>P1Qdyhm|~ zZ4(gi1*$)Jr0|Py$P1pjIP#CYO*Eneu^Ah7`(1e)(|`Soa`wl`}gEn zZ8g*Hy;G&ZJ<<7 zqRg>fx{p9eygn_tw3cgCo@`aU%$?f)F_p~djl+YQ%U7}A&x#cK@k{KThUbvyH%!Wx z!G3cP9*9wW|EM>(qPGXY81rBC6SuH8|4_{jJGe+FP z*2sm8K{vfJ#>V}U3>{?5+$_GuL2~Zt!iQ3A%P@Q>_;730A>km@i&spx(W2&MiukMh zhNWJkoPo0gzNU`FQACo0p9~!&KChf)x!^!)`9mAz@A2L=upB(k5jAZ6VhE%M3-23r zJV#b(a7x>M0Kuh=+&xD*E!ureH+Y5OSR{E_v`b$yJunG+Yl) zZEv-ha7=9(r(2PZMxfG0zyeZd9(!b0zI+smM!hHW$ACbuq3Ww&e(ww61H=dBN z!Mx(pv^wy_H<9O_47hv_^$O^-RDSLCBmC_1F+UEX{@ns?4sL9XDako?gJA#iYUiVpV!;^Z=I>cN!jZ#h&~AVE@ttZL=w{yf<$l>V%BU_ znn8`ioAoiF+>vQTuW@>CG7?7Y@Zq^RGE>Paz}Wlvyu}jmoq@MV1dmD^Qq#-Wz4E#C z0H;sh+45{`-U=;)=)Lk2A!qEo*kE8h6FMBqO-5qSVrBL;MZniJCq~`*smKqjeav=T zeU>GcaMU|161X9a=4|#v0)!|z5fUBMAc?t75-VH0np`r~G&KX$^KxRO!&Yew`0AnT z$@M9c#iNHA!A33PjADFscsh`FM&+DT(@D+diM3R95!^uOH%A z+c{;^;)3o-t9`UG&A{4;D6Mj`KV(YMe87jz)?nddHb<868>OjaeVqGCrMmRR(M`de z7p(o!DwR0QeKq(C;IYoCI2Gn1Ge?j~MIeZ?Po+9%|0qDuTRn9Dx5K9@jhDH2z49n4 zod+*?pk+iDoYHhYLMkQHz`b(jW1@LQ{g))` zB7dVQ7~^{cyTx?tNWS4(}BnrjT~4Q1CIkBH!|-?9tfvrDsdqgHGz%Xh_mp zNnhfGe-jdm;S35(T2E3Pi^>U9!-2Ei)&6OxWhGvDEhh6SxOyJ`x_0G=$`$} z*ks>v!xOH51DtsjqHh>Ipy`gg&8#Y6WP??vxI*xQchpSWJ*N#5w zbvS;dT2;~bV{lfyGgTo z^bs2~gp?O@xtoNLU`|4So+l-1KH<)o{eh7DBpRR#&jBK7P*e&SEa@3!1-Owhm=#CZ%rP} zXv3qhwDO*&@S)onf~(!;(+h3rsd|D_SSZoqKbVH|)eq3L@0-fr%gTgCX4{%QO(dLB zjy4`82{*2kh0(Fy{0zTCwCQO^I%>K2-ksu$Iw!{i$w_5mXJ8lCU?Gd)FM)WH1tfj- zDK{)c6qr466^MVg=htxjzsLkxpr8ZxLX41&?yZ|P{AGChFtRK_L4|HWK``g&ui;oL zWkEnydxq?hnmJKd?kY&V9O@ln;Oh(;+6aX+=1) zJdV1FLwJE1LV+2&N`+^cNW!90@GWTCDP`Z3wPXf~sFc)SGQeS|l zR}(PXD!$3h_D@E>2`&57U%+J~LF)Y>sp~D00?eHFV#5hU8=xsf8sW+vQqV;_r7V}S zQ;umXCnbwm=GnW6xf&_yF+9pLkrPXAFvSP`&)i>1F>Q)=^1pNO8jo;{GrTD!;BKiD zmW^BSHh^JCt_E5jfh=;b#HuAiZaM+31nT~RX?tAsTIz+qs?(Kbl7o$&t*WWRqayQO z|5CxJSa4sR{l{gbrozppeIa!CO{WU6Jh*xwaMo+4PxzQs*!Y+;G@*_l$(9|*&xJ9= zF@Wy>v#t0#h!)3;89XZX2odn54{(I=79hU{ z*r3C)Ts+4ASAiI(lvCFd6N+s^?_l*ZAwXuC)0g64Y3tn^vylWf_Gj+Nt2bQIg41IG>Ve+yZ3 zi$_}b{vwGjo8)e@Q+3*JYTUu`Yl^PaFSowV0LJ#O6gN;9h2-T7ANsZi57p9PHlMFc zTtuhtwST@5Y}+VVs@eXp=belI!@Fi=EV^39fQ>|~Uj`d$2AV59C}*Z*54dr$hb8&Y z_twyb!ai5c2#CNo1mw#k6N#zfrKf_JMx+IG$pqPN#j|RNPxrEulcCIRI$c7dTR5sy z>$~)nH=*E}Vnc-(C7Tu+T@ir3_;^iYKLL_dp4ZjRGT$e8$W>rRYM)8HS8&+4!z6P$ zCXid-Vd2HvmvNu9#8C71kC3nr5&=8pr~3t2`~l0TD5^rtyIyE90@h0~3X#e=x0IrD zc@XI(=RXH`4?ee5dT2fdYJbzr5uhlV8l>d-byDZAqW#`zM83qY^KKopiD0Bi1hSad z(D~ud$ZWNO(9uzdLiBk&eLtHjAzLiToxno^f6V%|S3k8i$Gz|s*b3-+i3E%|AtjA) zKoLXlR`1FtC3U|WuZ+vzdY&X?>`kXYlyUb_gBlbau5cGY@_pWNj+d6#R3d-nd+Of@ zoK1lN_fHk#5|;Vf!}osh(u8)~>Bo4e&cTfTc7?s_&8Ysqc^v*j2mZyc7@t(AdBDAv zp?+Oh3*_ug7E=eizs5qS8^Q2fk;K_s*lzj6Fe5E-;#U7nsu*hC-|`R%`XZg|0Er11saQFw180m?82;)i{F~$r^n^+x}6y zl^3BuT|%jxnZEA|PE&NoO#m5C7*PSD1N46=d@;LbkzDAO2ct(Jr&1t^7RQC&c+>8P z12)NolsI4@xn|XYVER=E2xDJ?8Rt$ZJminhU!e(}Y9V~s!EAQuX^BD>8$WVQlL?V=K56pS>&1orL zzl`B-rh2%!K_Ry{(e2&bO!67`i8#!+kl{&T!jjeag+ZyH7{Pp4Jh}xg?iyhx@N`Z^ ztb_1mmV9pel*5}S%Vg`FmO;=RD5#Q`AFF2u-)GtDo#Jljw92I{nV_0${7PiH5qHPV z5Uu|n_UcRrhCALhSN!2uvSdfh=`pi3fUV#roG-Z-S#-YXX=+?;Lk*E$u#oYp*J|8w zE5NL6<_7pzaoN)TU$q)Fb%xDlv?V`iOdv7)6FeKe&E9gb8J zbft)Oy3cYs#Tt6a^XSihW5wUY!DkbS_e@F$1Hbb0|j zT}7wk@l*fDet-0@O84d|DvFOKVfGXSbmk?QG7F|M`fqN|p8jsI=6_8CYX`DU(=*r|{{=8?wzT|>AusiIel`$n=pr`X(*5{Ie3Cgyzdh@uM z)O4fR>hC!7ROe;WN`R2Kc!<0}+zT9bqQ>giO-C1(h@hao)MqP&nB|D8>nX+Cdl9vd zR0BD4UtQ1vZp+DK#5b>btY%y8 z-aQ>#J2d!n>`O9UmVRT&4SBtxc}}qGZQ<j3VTL_wtM+@ zCNk84r+exZ6NWu@hGmFOSzeL(*=~k+2m3z1!lmc`1f=RH?AeTAO3kRBg_m2T3A5v_ zgS9gnDKq_G{4p$_1Am5Cvl##0Q#k!|>X(gfdh{zmh=*7z#Yf1c(p@=8q+`kiLG`Aw zKLVRFt&*pED!viVsYKQJ>sxt(`Oo3%8e-pXgEZF!Ogh+CcW#^b$7L_FCPbzx4P8l6Iu8~3w1S2iC7j$G5JL>{DO zOm=CtE_}aB__hEfVBOJSd{1k22TaGN|E&@#V7nZ;1E7 z>n8=w4_{g<8CYIwZB}=HLQBQ9uVpo;uoM?N2dkg-g&SpB(yTjeIEbC0W`-j_V~=Ee z-)%4^CE$5r6FVOYYkcm;(mG3rluZ7VCP zl-b2}df!RHAEIGw3=ioT2w$Ew6bj#N>(U>HK@8 zikfC3;cEJ;L2XfcyUEX4(ue(+jto3~dGHRl^P(T=IKm1AuZY8qi z@4%>k;mzh~0D6};?2R=K~P^47dafL@dmuU_YPsc-GP_EIT8#s>BWKdh_oKxtGGNUwNyaVu5o_M20&`6Ik1 z%SEv~xum|&hD9n$q0Rjkf!Vv#S% zS?NzoFt@3=i{;u@V2^xv&y-59BUBmBdQA z$+$mzoUfz|DI6xN5H~_piy6n6IcCo8v(Fs6wYhRPmmV{0GCc~+j4h1g43Z-q!HeD) z-=&c*^0&CaWn8GNG&R?8M3cl^e_>iSIGHJTM0HVfOl-}m>e0RUuEBdZJSy3tB}=-D z^o<^Z9dxC_o5UhFBs?y_ zZSs4Wat0L11q!5MEse&D#&)~ma}!IQ+L?5Lr=3E?Zf1#hq!ny7yMATNQ3x8{FEe}M zIo0>owmcXY6l8>VbNmA7eh$)sAfBc+%R@nD#vw|Zf%C>uyrc#t<3S*WAig2b<~f>s z`0}?5;DY7NTv4tV$eqKm{hYQNU_9|Zkj2nv~9$$z!Z&-~@3 zzHio-Y)o7Bl#f)@REr@q=B02bJI}~M&z-gWDjVCe`ho>_J!ZECA9?4j_;9}h@lZhy zb4rxBoRQ?_LaSP9hhC`|CCOO_>M00T{C0h10!`}Y(v^`J^v8Xdv_UCxG@p7!oMKx( z9F5u>P1Sr0M*R7$t^|#0c5vx!;1qhIB?h~;-+}s)i^;u~s*~mVhva_ar8djSZ%OO> zfoU}=BKMzsDeM1p?S(R;mF)G*U#@Ta!>V095J=)2L)c#e<)uD7~vx%Y+I zG(>S@k`FAlnO#rYfQtTXf#@+#C6l+MK2K?}4XCmd6P-cl_4EetD8jqL$t^bk{0H{3 zHe->BapiuL$#MlgS_F0Vv(rlt*A9Lm?guc}h7T1{Otln#osIb>7K)}#vOh}>{ipVp zyrh4PLQAj!Og@_ZDJXRfNWEMQKbXPHUx50;d}DbWVlycA%bWLN z^|d%qS})sJjzajfelXn`%fmlUDr^Aids4HlTk^R4!8YL1-aE40^+66u48N`t!_(*H zg&4c2d`ODF&nUz*XMHo*pVZe*eeE$&>aY8cLMs2pO!>3O83D|3{Jk4+q2YUB#2)x6EG;&Zk1JlU(}V{WmkD*B|u962m&#@ zqD_ts|7SP>W`geirX!3KC3$7i7Xb9q$rMrRYSBJN#Nz26zw9MA-~-ALfMHT!fsz0r z!w~?F(ExUN8i$tfq36aXK#l+$6}imI{KQ-7lO4NWn6fDFcm!xtg~!F?>9un$wh%~7 zs_tCinyd`Ck$Q*~Kw5wT;&xIs5N!d7A1ii2Q_vMl#GM9`nIG#LV}vQDlqVh2(}AaxqurwbtUH#a zxffV&dn*aS!v=6z1z`=)xM9-2PaFYM|i{vQ8rY=KFNg}Ykw{!%i zR2hf^T4vncR~4rk?t>>%{VN3LWp->h#Z^SaO_%%)ASr-TacPqS#B!}> znmX=ShA8w6flMcP**_6V9cWlgKIZ*(r09v6N|Je|QCy$8m@+Z0$ zD#>4O7}{m8vo{G*PZm=%rxk5 z?!l0gAd{?Wj!|+G=a@tZ@dTK?jNU% zA2-S=GXRSp1;QcnU4R+O>2)77C|<*apRB|8HrUM%rwX)3MXSg^z+SN4v}wn8sW|F< zigUFtfDfMY>6mr3?s~cG%R!qzBDou+8&Q{o(6DyzWYylADxOCzXdMCHnC7-0OdX|S zE@7Rkg{k7wnjfu7Iz3F6t~V#Ec!*fQrwvL&|E1EEj3vC2gp2pe0JMobT=vO z%cfPuRA$>JRpb$-A1NQf>&IxqQwEvHh(c_g7jcNl<%Vn9ypm3lM zEH?;^)LRw+3$+2?W##}I_4Ope#vP&Cf;7`8e2#`hoUO_4?p=2+5%wPOW@(^dT1o;a z0e!RBvZYU2oc2NWjxSg|_r@N^Ov`m-%xKoWf9Gs*+pWQef1!09v8uBTVNCQ|Vuo%=^zCP*s8MW($6*;HqnH3|eh4Dp;klaIV153Llf2 zp25QYp_R%oFFFilS8kI!X6YhRwQ4!%-8-vNuhT{xxM3IlA+GRx03PNW1o-2EF1)>j zozLLd9Yjw&ADCnPPRooxtm}zQ(Zcf?th?|FFdtbcM)di=6i1zP!ROXznHi`i>my zQ@0JydSiY}Sc<;K#9D%(A)RWC0J=rwT6p>^2b#^o#*v@KXRG~Bl9lzzn7*$sDK#c1&d4Y{ zXrA*gNMWU67xZXAumLg$#FsgdbwpOP$?%N-#iQ{VKEGad2U>>EeaE75*=c=CIE8Hw z#I1kl>r?lhZ*i6j+W1%?djT=_2A)2*dY&@(23FP^crIV{Hj(=!5ykgasKTJXO(#`2 zf569|jrayCrm4F5M#Zb1IQ3HyxqHgo$U#zP;G$$!gQS3(sHBZn1wm0U_0HMfAOWCf zFYAO>yyy9H>NY_Fqul>X$4^F*l;N@_uMZDN$~?X_HhsR6OG?M%Mnn;OEVs#4e9||( zMoAIlbY{d>awdJZ<4A-+29-znZ=Wa>3&oy<6l3t^CB^&C$5&loN%m297J+GH#+SUo zhOYRanixrQ_ioIf6*_jDRTNbdv;J%$7YzJd;Rn;XOtZW6W@XUE3EF=d4)zC*0oonf zNo}rLVRag?^YZmb)ax_O%dc^g$QqK(hqrAx3dhVy)`5QGpd-R7Mpzp)vRdmT6ELNH zN|lX$2;(Qje7m4GzI;jDzbn7)FjEkGm}M1Eb|>z5%f4pI!a;M1hJCYAO~>cieh{dq XRW?gUSrzz05`nsk){`=2tI+=eLQ7Y| literal 0 HcmV?d00001 diff --git a/Fly App/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png b/Fly App/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..d3554186ea32a149f96108f34fc302e8616e021a GIT binary patch literal 17622 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 literal 0 HcmV?d00001 diff --git a/Fly App/android/app/src/main/res/drawable/launch_background.xml b/Fly App/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/Fly App/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/Fly App/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/Fly App/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..c79c58a --- /dev/null +++ b/Fly App/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,9 @@ + + + + + + + diff --git a/Fly App/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/Fly App/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..bbe42dcf737d82f18922e57b1f65f794558655bc GIT binary patch literal 3027 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 Vh6=pWs>1*P002ovPDHLkV1m%XyYT=3 literal 0 HcmV?d00001 diff --git a/Fly App/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/Fly App/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..e3689cf6fd0f089e7b7d498d2f1f0f269c8a5c54 GIT binary patch literal 1370 zcmV-g1*Q6lP)t(R>z|tje+HK zgnh6Np^)sB#on_)|BYh-cgH7Ez}+;GlMKz!=n^x8w7EMC6DAi^1|H$SV~l4XId;N` z-ZYQB5`^>$(cAT%_u83qIq17|(*`66)+K_~R>x~dG7U@b1b#?;Wy@(3QyvpC<`;N6 zPdP-7YyYqYBnZ}rk~8dk!9hFD@QAEAo)=7(oNqjo7K`4_1JMQ$oiRhO+CCf4_{fF> zw5Gde)^!KHHd*X#RT^Nn&aD}T-DVg*`kvPa#z`}3-R+G_h%PaCuEAt^R4sVgSBL0@ zB4RM#OA?Z%IPPGyi3#7AIGxe*4ACW~6sQodx~m(d!z~)tsZSn2hsZH zUZq^a^LV%VRb<|siZ+)Idm0GlEZzubbtSB+7Rk~J$qDMi(m9r(NHw300c z{rIiEEQ)D9eHI%xRb%INCrVy$C=G}O4-ATdqoc}L8!q-CT#bcx!8a3hR z&>I@H;W79Dhx+gsIFYFlkG?3x<{eIrc$P)j)4;wbkZQvNG~_0-=sQF7M!V|ppa{wS zb42oAZ%`VbGCbQzqURUl@9Fr|U{0@KPdwCwXG!w?Q%@wn`=sQ*zE5LFUZy6zEcP6h zMb95f80~SjlnWX5e2(YMYQR$(@&>xwN)egJ&JG6UHgt%kYx_`rb&*LI0HqcwGBr(Q8YbG^3X2&5Onpp7nXw zZ6arW#0EeKP2ThF_P>gV-fY*IEGvjX$kPzRba1?xMXK91!%`}{LvrA}EP8%s(S{A! cp;ngu1sg|-vBYQ^Qvd(}07*qoM6N<$f;2{&n*aa+ literal 0 HcmV?d00001 diff --git a/Fly App/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/Fly App/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..0289e253edab9ab7f2e95988bbc88473209cea5e GIT binary patch literal 3413 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 literal 0 HcmV?d00001 diff --git a/Fly App/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/Fly App/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..bdcd11be23483ed7bdc34d33fa72fa20a7711d6e GIT binary patch literal 6891 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 MZtL7CQMC&FKl&2F?f?J) literal 0 HcmV?d00001 diff --git a/Fly App/android/app/src/main/res/values-night/styles.xml b/Fly App/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/Fly App/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/Fly App/android/app/src/main/res/values/colors.xml b/Fly App/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..b4c8bf5 --- /dev/null +++ b/Fly App/android/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #0F1E3D + \ No newline at end of file diff --git a/Fly App/android/app/src/main/res/values/styles.xml b/Fly App/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/Fly App/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/Fly App/android/app/src/main/res/xml/accessory_filter.xml b/Fly App/android/app/src/main/res/xml/accessory_filter.xml new file mode 100644 index 0000000..e1dc5f7 --- /dev/null +++ b/Fly App/android/app/src/main/res/xml/accessory_filter.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/Fly App/android/app/src/profile/AndroidManifest.xml b/Fly App/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/Fly App/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/Fly App/android/build.gradle b/Fly App/android/build.gradle new file mode 100644 index 0000000..d2ffbff --- /dev/null +++ b/Fly App/android/build.gradle @@ -0,0 +1,18 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +rootProject.buildDir = "../build" +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean", Delete) { + delete rootProject.buildDir +} diff --git a/Fly App/android/gradle.properties b/Fly App/android/gradle.properties new file mode 100644 index 0000000..9423b29 --- /dev/null +++ b/Fly App/android/gradle.properties @@ -0,0 +1,14 @@ +org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +android.enableJetifier=true + +# DJI Mobile SDK +# Register the application id "com.dji.flutter.dji_msdk_sample" at +# https://developer.dji.com/user/apps/ to obtain an App Key, then paste it below. +# This value is injected into AndroidManifest.xml via a manifestPlaceholder. +# DJI_API_KEY=PASTE_YOUR_DJI_APP_KEY_HERE +DJI_API_KEY=e427e182254c91013ba591e9 +# This builtInKotlin flag was added automatically by Flutter migrator +android.builtInKotlin=false +# This newDsl flag was added automatically by Flutter migrator +android.newDsl=false diff --git a/Fly App/android/gradle/wrapper/gradle-wrapper.properties b/Fly App/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..3c85cfe --- /dev/null +++ b/Fly App/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-all.zip diff --git a/Fly App/android/settings.gradle b/Fly App/android/settings.gradle new file mode 100644 index 0000000..8ea7974 --- /dev/null +++ b/Fly App/android/settings.gradle @@ -0,0 +1,25 @@ +pluginManagement { + def flutterSdkPath = { + def properties = new Properties() + file("local.properties").withInputStream { properties.load(it) } + def flutterSdkPath = properties.getProperty("flutter.sdk") + assert flutterSdkPath != null, "flutter.sdk not set in local.properties" + return flutterSdkPath + }() + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id "dev.flutter.flutter-plugin-loader" version "1.0.0" + id "com.android.application" version "8.6.0" apply false + id "org.jetbrains.kotlin.android" version "1.9.24" apply false +} + +include ":app" diff --git a/Fly App/assets/fonts/SpaceGrotesk-VariableFont_wght.ttf b/Fly App/assets/fonts/SpaceGrotesk-VariableFont_wght.ttf new file mode 100644 index 0000000000000000000000000000000000000000..a1b2e6c26093066510a31147e7aec9abdc8d6c5e GIT binary patch literal 136676 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 IH;+H=zZFcdV*mgE literal 0 HcmV?d00001 diff --git a/Fly App/assets/fonts/SpaceMono-Bold.ttf b/Fly App/assets/fonts/SpaceMono-Bold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..2c4f2682f915d988e7314544ff7c9e38c8c733f5 GIT binary patch literal 98232 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

of^bo5Zce-mS`ZNCP^RoqUeue-;SW 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 O#a@p$^!B9uJM~X~{*CMa literal 0 HcmV?d00001 diff --git a/Fly App/assets/icon/app_icon.png b/Fly App/assets/icon/app_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..5cf09a088a9355462f9be636790b3566aebfe3e0 GIT binary patch literal 114974 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%0Dx@~4%n;WMA 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 T9$S*a3UvFm; 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 literal 0 HcmV?d00001 diff --git a/Fly App/backup_pocketbase_app/login_page.dart b/Fly App/backup_pocketbase_app/login_page.dart new file mode 100644 index 0000000..a6baace --- /dev/null +++ b/Fly App/backup_pocketbase_app/login_page.dart @@ -0,0 +1,146 @@ +import 'package:flutter/material.dart'; + +import 'pb_service.dart'; + +class LoginPage extends StatefulWidget { + const LoginPage({super.key, required this.onSignedIn}); + + final VoidCallback onSignedIn; + + @override + State createState() => _LoginPageState(); +} + +class _LoginPageState extends State { + late final TextEditingController _server = + TextEditingController(text: pb.defaultBaseUrl); + final TextEditingController _email = + TextEditingController(text: 'pilot@dji.local'); + final TextEditingController _password = TextEditingController(); + final TextEditingController _device = + TextEditingController(text: 'phone'); + + bool _busy = false; + String? _error; + + Future _signIn() async { + setState(() { + _busy = true; + _error = null; + }); + try { + await pb.signIn( + baseUrl: _server.text, + email: _email.text, + password: _password.text, + deviceId: _device.text, + ); + widget.onSignedIn(); + } catch (e) { + setState(() => _error = _friendly(e)); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + String _friendly(Object e) { + final String s = e.toString(); + if (s.contains('Failed host lookup') || s.contains('SocketException')) { + return 'Cannot reach the server. Check the address and that PocketBase is running.'; + } + if (s.contains('Failed to authenticate')) return 'Invalid email or password.'; + return s; + } + + @override + void dispose() { + _server.dispose(); + _email.dispose(); + _password.dispose(); + _device.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 380), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Icon(Icons.flight_takeoff, + size: 56, color: Theme.of(context).colorScheme.primary), + const SizedBox(height: 12), + Text('DJI MSDK Sample', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.headlineSmall), + Text('Sign in to the PocketBase backend', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.grey.shade600)), + const SizedBox(height: 24), + TextField( + controller: _server, + keyboardType: TextInputType.url, + decoration: const InputDecoration( + labelText: 'Server', hintText: '10.2.1.101:8090', + border: OutlineInputBorder(), prefixIcon: Icon(Icons.dns), + ), + ), + const SizedBox(height: 12), + TextField( + controller: _email, + keyboardType: TextInputType.emailAddress, + autofillHints: const [AutofillHints.username], + decoration: const InputDecoration( + labelText: 'Email', border: OutlineInputBorder(), + prefixIcon: Icon(Icons.person), + ), + ), + const SizedBox(height: 12), + TextField( + controller: _password, + obscureText: true, + autofillHints: const [AutofillHints.password], + onSubmitted: (_) => _busy ? null : _signIn(), + decoration: const InputDecoration( + labelText: 'Password', border: OutlineInputBorder(), + prefixIcon: Icon(Icons.lock), + ), + ), + const SizedBox(height: 12), + TextField( + controller: _device, + decoration: const InputDecoration( + labelText: 'Device id', border: OutlineInputBorder(), + prefixIcon: Icon(Icons.smartphone), + ), + ), + if (_error != null) ...[ + const SizedBox(height: 12), + Text(_error!, style: const TextStyle(color: Colors.red)), + ], + const SizedBox(height: 20), + FilledButton( + onPressed: _busy ? null : _signIn, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: _busy + ? const SizedBox( + height: 20, width: 20, + child: CircularProgressIndicator(strokeWidth: 2)) + : const Text('Sign in'), + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/Fly App/backup_pocketbase_app/main_pocketbase.dart b/Fly App/backup_pocketbase_app/main_pocketbase.dart new file mode 100644 index 0000000..b203958 --- /dev/null +++ b/Fly App/backup_pocketbase_app/main_pocketbase.dart @@ -0,0 +1,424 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import 'dji_service.dart'; +import 'login_page.dart'; +import 'pb_service.dart'; + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + await pb.init(); + runApp(const DjiSampleApp()); +} + +class DjiSampleApp extends StatelessWidget { + const DjiSampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'DJI MSDK Sample', + theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF1565C0)), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +/// Shows the login screen until the user is authenticated with PocketBase. +class AuthGate extends StatefulWidget { + const AuthGate({super.key}); + + @override + State createState() => _AuthGateState(); +} + +class _AuthGateState extends State { + StreamSubscription? _sub; + + @override + void initState() { + super.initState(); + _sub = pb.status.listen((_) { + if (mounted) setState(() {}); + }); + } + + @override + void dispose() { + _sub?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (pb.isAuthed) { + return const HomePage(); + } + return LoginPage(onSignedIn: () => setState(() {})); + } +} + +enum RegistrationState { idle, registering, success, failed } + +class HomePage extends StatefulWidget { + const HomePage({super.key}); + + @override + State createState() => _HomePageState(); +} + +class _HomePageState extends State { + final DjiService _dji = DjiService(); + StreamSubscription>? _sub; + StreamSubscription? _backendSub; + BackendStatus _backend = BackendStatus.online; + + String _sdkVersion = '…'; + RegistrationState _registration = RegistrationState.idle; + String? _registrationError; + + bool _connected = false; + String? _model; + + int? _satellites; + bool? _isFlying; + String? _flightMode; + double? _altitude; + double? _latitude; + double? _longitude; + int? _batteryPercent; + + @override + void initState() { + super.initState(); + _backend = pb.currentStatus; + // Route server interactions through PocketBase. + pb.onCommand = _handleServerCommand; + pb.stateProvider = _currentState; + _backendSub = pb.status.listen((BackendStatus s) { + if (mounted) setState(() => _backend = s); + }); + _init(); + } + + Future _init() async { + _sub = _dji.events().listen(_onEvent, onError: (Object e) { + _snack('Event channel error: $e'); + }); + try { + final String version = await _dji.getSdkVersion(); + if (mounted) setState(() => _sdkVersion = version); + } catch (_) { + if (mounted) setState(() => _sdkVersion = 'unavailable'); + } + } + + void _onEvent(Map event) { + if (!mounted) return; + // Persist every event to PocketBase (telemetry is throttled inside). + pb.onEvent(event); + switch (event['type'] as String?) { + case 'registration': + setState(() { + switch (event['state'] as String?) { + case 'registering': + _registration = RegistrationState.registering; + _registrationError = null; + break; + case 'success': + _registration = RegistrationState.success; + break; + case 'failed': + _registration = RegistrationState.failed; + _registrationError = event['error'] as String?; + break; + } + }); + break; + case 'connection': + setState(() { + _connected = event['connected'] as bool? ?? false; + _model = event['model'] as String?; + if (!_connected) _clearTelemetry(); + }); + break; + case 'telemetry': + setState(() { + _satellites = event['satelliteCount'] as int?; + _isFlying = event['isFlying'] as bool?; + _flightMode = event['flightMode'] as String?; + _altitude = (event['altitude'] as num?)?.toDouble(); + _latitude = (event['latitude'] as num?)?.toDouble(); + _longitude = (event['longitude'] as num?)?.toDouble(); + }); + break; + case 'battery': + setState(() => _batteryPercent = event['percent'] as int?); + break; + } + } + + void _clearTelemetry() { + _satellites = null; + _isFlying = null; + _flightMode = null; + _altitude = null; + _latitude = null; + _longitude = null; + _batteryPercent = null; + } + + /// Current app state shared with PocketBase for the device presence record. + Map _currentState() => { + 'connected': _connected, + 'model': _model ?? '', + 'registration': _registrationWire(), + }; + + String _registrationWire() { + switch (_registration) { + case RegistrationState.success: + return 'success'; + case RegistrationState.registering: + return 'registering'; + case RegistrationState.failed: + return 'failed'; + case RegistrationState.idle: + return 'not registered'; + } + } + + Future _register() async { + try { + await _dji.registerApp(); + } catch (e) { + _snack('registerApp failed: $e'); + } + } + + Future _connect() async { + try { + final bool started = await _dji.startConnection(); + _snack(started ? 'Scanning for product…' : 'Could not start connection'); + } catch (e) { + _snack('startConnection failed: $e'); + } + } + + void _handleServerCommand(String command, Map payload) { + switch (command) { + case 'registerApp': + _register(); + break; + case 'startConnection': + _connect(); + break; + case 'stopConnection': + _dji.stopConnection(); + break; + default: + _snack('Unknown server command: $command'); + return; + } + _snack('Server command: $command'); + } + + Future _logout() async { + await pb.signOut(); + if (mounted) { + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute( + builder: (_) => const AuthGate(), + ), + (Route route) => false, + ); + } + } + + void _snack(String msg) { + if (!mounted) return; + ScaffoldMessenger.of(context) + ..clearSnackBars() + ..showSnackBar(SnackBar(content: Text(msg))); + } + + @override + void dispose() { + _sub?.cancel(); + _backendSub?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('DJI MSDK Sample'), + backgroundColor: Theme.of(context).colorScheme.inversePrimary, + actions: [ + IconButton( + tooltip: 'Log out', + onPressed: _logout, + icon: const Icon(Icons.logout), + ), + ], + ), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + _backendCard(), + const SizedBox(height: 12), + _registrationCard(), + const SizedBox(height: 12), + _connectionCard(), + const SizedBox(height: 12), + _telemetryCard(), + ], + ), + ); + } + + Widget _backendCard() { + final (Color color, String label) = switch (_backend) { + BackendStatus.online => (Colors.green, 'Streaming to PocketBase'), + BackendStatus.signingIn => (Colors.orange, 'Connecting…'), + BackendStatus.error => (Colors.red, 'Backend error'), + BackendStatus.signedOut => (Colors.grey, 'Signed out'), + }; + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Icon(Icons.cloud_done, color: color), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 2), + Text('${pb.userEmail} · device "${pb.deviceId}"', + style: const TextStyle(color: Colors.black54, fontSize: 12)), + ], + ), + ), + TextButton.icon( + onPressed: _logout, + icon: const Icon(Icons.logout), + label: const Text('Log out'), + ), + ], + ), + ), + ); + } + + Widget _registrationCard() { + final (Color color, IconData icon, String label) = switch (_registration) { + RegistrationState.idle => (Colors.grey, Icons.help_outline, 'Not registered'), + RegistrationState.registering => (Colors.orange, Icons.sync, 'Registering…'), + RegistrationState.success => (Colors.green, Icons.verified, 'Registered'), + RegistrationState.failed => (Colors.red, Icons.error_outline, 'Registration failed'), + }; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, color: color), + const SizedBox(width: 8), + Text(label, style: Theme.of(context).textTheme.titleMedium), + ], + ), + const SizedBox(height: 4), + Text('SDK version: $_sdkVersion'), + if (_registrationError != null) ...[ + const SizedBox(height: 4), + Text(_registrationError!, style: const TextStyle(color: Colors.red)), + ], + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _registration == RegistrationState.registering ? null : _register, + icon: const Icon(Icons.app_registration), + label: const Text('Register app'), + ), + ], + ), + ), + ); + } + + Widget _connectionCard() { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(_connected ? Icons.link : Icons.link_off, + color: _connected ? Colors.green : Colors.grey), + const SizedBox(width: 8), + Text(_connected ? 'Product connected' : 'No product', + style: Theme.of(context).textTheme.titleMedium), + ], + ), + const SizedBox(height: 4), + Text('Model: ${_model ?? '—'}'), + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: _registration == RegistrationState.success ? _connect : null, + icon: const Icon(Icons.usb), + label: const Text('Connect to product'), + ), + ], + ), + ), + ); + } + + Widget _telemetryCard() { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Telemetry', style: Theme.of(context).textTheme.titleMedium), + const Divider(), + _row('Battery', _batteryPercent == null ? '—' : '$_batteryPercent%'), + _row('GPS satellites', _satellites?.toString() ?? '—'), + _row('Flight mode', _flightMode ?? '—'), + _row('Flying', _isFlying == null ? '—' : (_isFlying! ? 'yes' : 'no')), + _row('Altitude', _altitude == null ? '—' : '${_altitude!.toStringAsFixed(1)} m'), + _row('Latitude', _latitude?.toStringAsFixed(6) ?? '—'), + _row('Longitude', _longitude?.toStringAsFixed(6) ?? '—'), + ], + ), + ), + ); + } + + Widget _row(String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label, style: const TextStyle(color: Colors.black54)), + Text(value, style: const TextStyle(fontWeight: FontWeight.w600)), + ], + ), + ); + } +} diff --git a/Fly App/backup_pocketbase_app/pb_service.dart b/Fly App/backup_pocketbase_app/pb_service.dart new file mode 100644 index 0000000..b6dd9c2 --- /dev/null +++ b/Fly App/backup_pocketbase_app/pb_service.dart @@ -0,0 +1,230 @@ +import 'dart:async'; + +import 'package:pocketbase/pocketbase.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +enum BackendStatus { signedOut, signingIn, online, error } + +/// PocketBase-backed service: authentication, telemetry persistence, device +/// presence, and inbound command subscription. A single global [pb] instance is +/// shared across the app. +class PbService { + PocketBase? _pb; + SharedPreferences? _prefs; + String _deviceId = 'phone'; + + Timer? _heartbeat; + String? _devicesRecordId; + UnsubscribeFunc? _cmdUnsub; + DateTime _lastTelemetry = DateTime.fromMillisecondsSinceEpoch(0); + + /// Called when a command record targeting this device is created. + void Function(String command, Map payload)? onCommand; + + /// Returns the app's current state for the device presence record. + /// Expected keys: connected (bool), model (String), registration (String). + Map Function()? stateProvider; + + final StreamController _statusController = + StreamController.broadcast(); + Stream get status => _statusController.stream; + + BackendStatus _status = BackendStatus.signedOut; + BackendStatus get currentStatus => _status; + + bool get isAuthed => _pb?.authStore.isValid ?? false; + String get userEmail => _pb?.authStore.record?.getStringValue('email') ?? ''; + String get deviceId => _deviceId; + String get baseUrl => _pb?.baseURL ?? ''; + + void _setStatus(BackendStatus s) { + _status = s; + if (!_statusController.isClosed) _statusController.add(s); + } + + /// Restores a persisted session (if any) on app start. + Future init() async { + _prefs = await SharedPreferences.getInstance(); + _deviceId = _prefs!.getString('pb_device') ?? 'phone'; + final String? url = _prefs!.getString('pb_url'); + final String? auth = _prefs!.getString('pb_auth'); + if (url != null && auth != null && auth.isNotEmpty) { + _pb = _build(url, auth); + if (_pb!.authStore.isValid) { + _setStatus(BackendStatus.online); + await _afterAuth(); + } + } + } + + PocketBase _build(String url, String? initialAuth) { + final AsyncAuthStore store = AsyncAuthStore( + save: (String data) async => _prefs?.setString('pb_auth', data), + clear: () async => _prefs?.remove('pb_auth'), + initial: initialAuth, + ); + return PocketBase(url, authStore: store); + } + + String get defaultBaseUrl => _prefs?.getString('pb_url') ?? 'http://10.2.1.101:8090'; + + Future signIn({ + required String baseUrl, + required String email, + required String password, + String deviceId = 'phone', + }) async { + _deviceId = deviceId.trim().isEmpty ? 'phone' : deviceId.trim(); + final String url = _normalize(baseUrl); + _setStatus(BackendStatus.signingIn); + try { + final PocketBase pb = _build(url, null); + await pb.collection('users').authWithPassword(email.trim(), password); + _pb = pb; + await _prefs?.setString('pb_url', url); + await _prefs?.setString('pb_device', _deviceId); + _setStatus(BackendStatus.online); + await _afterAuth(); + } catch (e) { + _setStatus(BackendStatus.error); + rethrow; + } + } + + Future signOut() async { + _heartbeat?.cancel(); + _heartbeat = null; + try { + await _upsertDevice(online: false); + } catch (_) {} + try { + await _cmdUnsub?.call(); + } catch (_) {} + _cmdUnsub = null; + _devicesRecordId = null; + _pb?.authStore.clear(); + _setStatus(BackendStatus.signedOut); + } + + Future _afterAuth() async { + await _upsertDevice(online: true); + _startHeartbeat(); + await _subscribeCommands(); + } + + String _normalize(String url) { + String u = url.trim(); + if (!u.startsWith('http://') && !u.startsWith('https://')) u = 'http://$u'; + return u; + } + + // ── Telemetry ingestion ──────────────────────────────────────────────────── + + /// Feed an event from `DjiService.events()`. + void onEvent(Map event) { + final PocketBase? pb = _pb; + if (pb == null || !pb.authStore.isValid) return; + final String? type = event['type'] as String?; + + if (type == 'telemetry') { + final DateTime now = DateTime.now(); + if (now.difference(_lastTelemetry) < const Duration(milliseconds: 500)) return; + _lastTelemetry = now; + } + + _appendEvent(type ?? 'event', event); + + // Reflect registration/connection promptly in the presence record. + if (type == 'registration' || type == 'connection') { + _upsertDevice(online: true); + } + } + + Future _appendEvent(String kind, Map event) async { + final PocketBase? pb = _pb; + if (pb == null) return; + try { + await pb.collection('telemetry').create(body: { + 'device': _deviceId, + 'kind': kind, + 'payload': event, + 'owner': pb.authStore.record?.id, + }); + } catch (_) { + // best-effort; drop on transient failure + } + } + + // ── Device presence ───────────────────────────────────────────────────────── + + void _startHeartbeat() { + _heartbeat?.cancel(); + _heartbeat = Timer.periodic(const Duration(seconds: 6), (_) => _upsertDevice(online: true)); + } + + Future _upsertDevice({required bool online}) async { + final PocketBase? pb = _pb; + if (pb == null) return; + final Map st = stateProvider?.call() ?? {}; + final Map body = { + 'device': _deviceId, + 'online': online, + 'connected': st['connected'] ?? false, + 'model': st['model'] ?? '', + 'registration': st['registration'] ?? '', + 'lastSeen': DateTime.now().millisecondsSinceEpoch ~/ 1000, + 'owner': pb.authStore.record?.id, + }; + try { + if (_devicesRecordId == null) { + try { + final RecordModel existing = + await pb.collection('devices').getFirstListItem('device="$_deviceId"'); + _devicesRecordId = existing.id; + } catch (_) { + // none yet + } + } + if (_devicesRecordId == null) { + final RecordModel rec = await pb.collection('devices').create(body: body); + _devicesRecordId = rec.id; + } else { + await pb.collection('devices').update(_devicesRecordId!, body: body); + } + } catch (_) { + _devicesRecordId = null; // record may have been removed; recreate next tick + } + } + + // ── Commands ──────────────────────────────────────────────────────────────── + + Future _subscribeCommands() async { + final PocketBase? pb = _pb; + if (pb == null) return; + try { + await _cmdUnsub?.call(); + } catch (_) {} + _cmdUnsub = await pb.collection('commands').subscribe( + '*', + (RecordSubscriptionEvent e) { + if (e.action != 'create') return; + final RecordModel? rec = e.record; + if (rec == null) return; + if (rec.getStringValue('device') != _deviceId) return; + final String cmd = rec.getStringValue('command'); + final Map payload = + (rec.get?>('payload') ?? {}); + if (cmd.isNotEmpty) onCommand?.call(cmd, payload); + }, + filter: 'device="$_deviceId"', + ); + } + + void dispose() { + _heartbeat?.cancel(); + _statusController.close(); + } +} + +/// App-wide PocketBase service instance. +final PbService pb = PbService(); diff --git a/Fly App/backup_pocketbase_app/pubspec_pocketbase.yaml b/Fly App/backup_pocketbase_app/pubspec_pocketbase.yaml new file mode 100644 index 0000000..69fa4d5 --- /dev/null +++ b/Fly App/backup_pocketbase_app/pubspec_pocketbase.yaml @@ -0,0 +1,94 @@ +name: dji_msdk_sample +description: "DJI Mobile SDK V4 sample app built with Flutter" +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+1 + +environment: + sdk: ^3.6.2 + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.8 + + # PocketBase backend client (auth, records, realtime). + pocketbase: ^0.22.0 + # Persists the auth token across app launches. + shared_preferences: ^2.3.2 + +dev_dependencies: + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^5.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package diff --git a/Fly App/lib/biometric_auth.dart b/Fly App/lib/biometric_auth.dart new file mode 100644 index 0000000..80a801b --- /dev/null +++ b/Fly App/lib/biometric_auth.dart @@ -0,0 +1,58 @@ +import 'package:local_auth/local_auth.dart'; + +/// Thin wrapper over [LocalAuthentication] for the login screen. Exposes what +/// the device can do (fingerprint vs. face) and a single [authenticate] call. +class BiometricAuth { + final LocalAuthentication _auth = LocalAuthentication(); + + bool _supported = false; + bool _canCheck = false; + List _types = const []; + + /// Whether any biometric login can be offered (device supports it and the + /// user has at least one biometric enrolled). + bool get available => _supported && _canCheck; + + /// The device advertises a face enrolment. On some Androids the platform only + /// reports weak/strong instead of the specific modality — see [showFace]. + bool get hasFace => _types.contains(BiometricType.face); + + bool get _hasFingerprint => + _types.contains(BiometricType.fingerprint) || + _types.contains(BiometricType.strong) || + _types.contains(BiometricType.weak); + + /// Show the face button when face is reported, or when the device supports + /// biometrics but reports no specific modality (BiometricPrompt still lets the + /// user use whatever strong biometric — often face — is enrolled). + bool get showFace => available && (hasFace || _types.isEmpty); + + /// Show the fingerprint button when fingerprint is reported, or as the generic + /// fallback when no specific modality is advertised. + bool get showFingerprint => available && (_hasFingerprint || _types.isEmpty); + + /// Refreshes the capability flags. Safe to call repeatedly. + Future refresh() async { + try { + _supported = await _auth.isDeviceSupported(); + _canCheck = await _auth.canCheckBiometrics; + _types = _supported ? await _auth.getAvailableBiometrics() : const []; + } catch (_) { + _supported = false; + _canCheck = false; + _types = const []; + } + } + + /// Prompts the OS biometric sheet. Returns true only on a verified match. + Future authenticate({required String reason}) { + return _auth.authenticate( + localizedReason: reason, + options: const AuthenticationOptions( + biometricOnly: true, + stickyAuth: true, + useErrorDialogs: true, + ), + ); + } +} diff --git a/Fly App/lib/dji_service.dart b/Fly App/lib/dji_service.dart new file mode 100644 index 0000000..c368269 --- /dev/null +++ b/Fly App/lib/dji_service.dart @@ -0,0 +1,39 @@ +import 'package:flutter/services.dart'; + +/// Thin Dart wrapper over the native DJI Mobile SDK bridge. +/// +/// Mirrors the channels defined in `DjiSdkBridge.kt`: +/// * method channel `dji_msdk/methods` for imperative calls +/// * event channel `dji_msdk/events` for the SDK's async updates +class DjiService { + static const MethodChannel _methods = MethodChannel('dji_msdk/methods'); + static const EventChannel _events = EventChannel('dji_msdk/events'); + + /// Broadcast stream of SDK events. Each event is a map with a `type` key: + /// `registration`, `connection`, `telemetry`, `battery`, `database`, `init`. + Stream> events() { + return _events + .receiveBroadcastStream() + .map((dynamic e) => Map.from(e as Map)); + } + + Future getSdkVersion() async { + return await _methods.invokeMethod('getSdkVersion') ?? 'unknown'; + } + + /// Kicks off DJI app registration (requires a valid App Key + internet). + Future registerApp() => _methods.invokeMethod('registerApp'); + + /// Starts scanning for a connected product (USB remote controller / Wi-Fi). + Future startConnection() async { + return await _methods.invokeMethod('startConnection') ?? false; + } + + Future stopConnection() => + _methods.invokeMethod('stopConnection'); + + Future> getProductInfo() async { + final dynamic info = await _methods.invokeMethod('getProductInfo'); + return Map.from(info as Map); + } +} diff --git a/Fly App/lib/flight_model.dart b/Fly App/lib/flight_model.dart new file mode 100644 index 0000000..c196617 --- /dev/null +++ b/Fly App/lib/flight_model.dart @@ -0,0 +1,32 @@ +import 'package:flutter/foundation.dart'; + +import 'uploader.dart'; + +enum RegistrationState { idle, registering, success, failed } + +/// Live aircraft/session state, shared by the Go Fly launch screen and the +/// Flight Control overlay. [_HomePageState] owns the DJI/uploader plumbing and +/// pushes updates here; the screens observe it via [AnimatedBuilder]. +class FlightModel extends ChangeNotifier { + String sdkVersion = '…'; + RegistrationState registration = RegistrationState.idle; + String? registrationError; + + bool connected = false; + String? model; + + int? satellites; + bool? isFlying; + String? flightMode; + double? altitude; + double? latitude; + double? longitude; + int? batteryPercent; + + UploadStatus upload = UploadStatus.disabled; + + bool get registered => registration == RegistrationState.success; + + /// Notify observers after a batch of field writes. + void bump() => notifyListeners(); +} diff --git a/Fly App/lib/login_page.dart b/Fly App/lib/login_page.dart new file mode 100644 index 0000000..e53b564 --- /dev/null +++ b/Fly App/lib/login_page.dart @@ -0,0 +1,260 @@ +import 'package:flutter/material.dart'; + +import 'biometric_auth.dart'; +import 'pb_auth.dart'; +import 'theme.dart'; + +class LoginPage extends StatefulWidget { + const LoginPage({super.key, required this.onSignedIn}); + + final VoidCallback onSignedIn; + + @override + State createState() => _LoginPageState(); +} + +class _LoginPageState extends State { + late final TextEditingController _server = + TextEditingController(text: auth.serverUrl); + final TextEditingController _email = TextEditingController(); + final TextEditingController _password = TextEditingController(); + + final BiometricAuth _bio = BiometricAuth(); + + bool _busy = false; + bool _showServer = false; + bool _obscurePassword = true; + String? _error; + + @override + void initState() { + super.initState(); + _bio.refresh().then((_) { + if (mounted) setState(() {}); + }); + } + + Future _signIn() async { + setState(() { + _busy = true; + _error = null; + }); + try { + await auth.signIn( + serverUrl: _server.text, + email: _email.text, + password: _password.text, + ); + widget.onSignedIn(); + } catch (e) { + setState(() => _error = _friendly(e)); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + /// Runs a device biometric / face check, then replays the remembered login. + Future _biometricSignIn({required bool face}) async { + if (!auth.hasRememberedAccount) { + setState(() => _error = 'Sign in with your password once to enable ${face ? 'face' : 'biometric'} login.'); + return; + } + setState(() { + _busy = true; + _error = null; + }); + try { + final bool ok = await _bio.authenticate( + reason: face ? 'Confirm your face to sign in to PilotVault' : 'Confirm your fingerprint to sign in to PilotVault', + ); + if (!ok) { + if (mounted) setState(() => _busy = false); + return; + } + await auth.signInWithRememberedCredentials(); + widget.onSignedIn(); + } catch (e) { + if (mounted) setState(() => _error = _friendly(e)); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _forgetAccount() async { + await auth.forgetAccount(); + if (mounted) setState(() {}); + } + + String _friendly(Object e) { + final String s = e.toString(); + if (s.contains('SocketException') || s.contains('Failed host lookup') || s.contains('Connection refused')) { + return 'Cannot reach the API server. Check the address under "Server settings".'; + } + return s; + } + + @override + void dispose() { + _server.dispose(); + _email.dispose(); + _password.dispose(); + super.dispose(); + } + + /// Biometric / face quick-login controls, shown only when the device supports + /// biometrics. Buttons are enabled once an account has been remembered. + List _biometricSection() { + final bool ready = auth.hasRememberedAccount; + return [ + const SizedBox(height: 16), + Row( + children: [ + Expanded(child: Divider(color: PV.inkMuted.withValues(alpha: 0.3))), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 10), + child: Text('or', style: PV.caption.copyWith(color: PV.inkMuted)), + ), + Expanded(child: Divider(color: PV.inkMuted.withValues(alpha: 0.3))), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + if (_bio.showFingerprint) + Expanded( + child: OutlinedButton.icon( + onPressed: _busy ? null : () => _biometricSignIn(face: false), + icon: const Icon(Icons.fingerprint, size: 20), + label: const Text('Fingerprint'), + ), + ), + if (_bio.showFingerprint && _bio.showFace) const SizedBox(width: 10), + if (_bio.showFace) + Expanded( + child: OutlinedButton.icon( + onPressed: _busy ? null : () => _biometricSignIn(face: true), + icon: const Icon(Icons.face_outlined, size: 20), + label: const Text('Face'), + ), + ), + ], + ), + if (ready) ...[ + const SizedBox(height: 6), + Center( + child: TextButton( + onPressed: _busy ? null : _forgetAccount, + child: Text( + 'Use ${auth.rememberedEmail.isEmpty ? "biometrics" : auth.rememberedEmail} · Forget account', + style: PV.caption.copyWith(color: PV.inkMuted), + ), + ), + ), + ] else ...[ + const SizedBox(height: 6), + Center( + child: Text( + 'Sign in once to enable biometric login', + style: PV.caption.copyWith(color: PV.inkMuted), + ), + ), + ], + ]; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 380), + child: PvPanel( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + const PvBrandMark(size: 34), + const SizedBox(width: 12), + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + Text('PilotVault', style: PV.mode), + Text('FLY APP', style: PV.label), + ], + ), + ], + ), + const SizedBox(height: 14), + Text('Sign in to continue', style: PV.caption), + const SizedBox(height: 22), + TextField( + controller: _email, + keyboardType: TextInputType.emailAddress, + autofillHints: const [AutofillHints.username], + textInputAction: TextInputAction.next, + style: PV.body, + decoration: const InputDecoration(labelText: 'Email', prefixIcon: Icon(Icons.person_outline)), + ), + const SizedBox(height: 12), + TextField( + controller: _password, + obscureText: _obscurePassword, + autofillHints: const [AutofillHints.password], + onSubmitted: (_) => _busy ? null : _signIn(), + style: PV.body, + decoration: InputDecoration( + labelText: 'Password', + prefixIcon: const Icon(Icons.lock_outline), + suffixIcon: IconButton( + icon: Icon(_obscurePassword ? Icons.visibility_outlined : Icons.visibility_off_outlined), + tooltip: _obscurePassword ? 'Show password' : 'Hide password', + onPressed: () => setState(() => _obscurePassword = !_obscurePassword), + ), + ), + ), + if (_showServer) ...[ + const SizedBox(height: 12), + TextField( + controller: _server, + keyboardType: TextInputType.url, + style: PV.body, + decoration: const InputDecoration( + labelText: 'API Server', hintText: '10.2.1.101:8080', prefixIcon: Icon(Icons.dns_outlined), + ), + ), + ], + if (_error != null) ...[ + const SizedBox(height: 12), + Text(_error!, style: PV.body.copyWith(color: PV.warning)), + ], + const SizedBox(height: 22), + FilledButton( + onPressed: _busy ? null : _signIn, + child: _busy + ? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) + : const Text('Sign in'), + ), + if (_bio.available) ..._biometricSection(), + const SizedBox(height: 4), + TextButton( + onPressed: () => setState(() => _showServer = !_showServer), + child: Text( + _showServer ? 'Hide server settings' : 'Server settings', + style: PV.caption.copyWith(color: PV.inkMuted), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/Fly App/lib/main.dart b/Fly App/lib/main.dart new file mode 100644 index 0000000..73bfd33 --- /dev/null +++ b/Fly App/lib/main.dart @@ -0,0 +1,430 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'dji_service.dart'; +import 'flight_model.dart'; +import 'login_page.dart'; +import 'pb_auth.dart'; +import 'theme.dart'; +import 'ui/album_page.dart'; +import 'ui/flight_control_page.dart'; +import 'ui/go_fly_page.dart'; +import 'uploader.dart'; + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); + await auth.init(); + runApp(const DjiSampleApp()); +} + +class DjiSampleApp extends StatelessWidget { + const DjiSampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'PilotVault', + theme: PV.theme(), + // The app is usable without signing in; login lives behind the user panel. + home: const HomePage(), + ); + } +} + +class HomePage extends StatefulWidget { + const HomePage({super.key}); + + @override + State createState() => _HomePageState(); +} + +class _HomePageState extends State { + final DjiService _dji = DjiService(); + final FlightModel _model = FlightModel(); + StreamSubscription>? _sub; + StreamSubscription? _authSub; + + // Streams telemetry to the API Server and receives commands back. + late final ServerUploader _uploader; + StreamSubscription? _uploadSub; + final TextEditingController _serverHost = + TextEditingController(text: '10.2.1.101:8080'); + + @override + void initState() { + super.initState(); + _uploader = ServerUploader( + onCommand: _handleServerCommand, + snapshotProvider: _buildStateSnapshot, + ); + _uploadSub = _uploader.status.listen((UploadStatus s) { + _model.upload = s; + _model.bump(); + }); + // Rebuild when the session changes so the user panel reflects sign-in/out. + _authSub = auth.status.listen((_) { + if (mounted) setState(() {}); + }); + _init(); + } + + Future _init() async { + _sub = _dji.events().listen(_onEvent, onError: (Object e) { + _snack('Event channel error: $e'); + }); + try { + final String version = await _dji.getSdkVersion(); + _model.sdkVersion = version; + _model.bump(); + } catch (_) { + _model.sdkVersion = 'unavailable'; + _model.bump(); + } + } + + void _onEvent(Map event) { + if (!mounted) return; + // Forward every event to the API server (throttled internally). + _uploader.onEvent(event); + switch (event['type'] as String?) { + case 'registration': + switch (event['state'] as String?) { + case 'registering': + _model.registration = RegistrationState.registering; + _model.registrationError = null; + break; + case 'success': + _model.registration = RegistrationState.success; + break; + case 'failed': + _model.registration = RegistrationState.failed; + _model.registrationError = event['error'] as String?; + break; + } + _model.bump(); + break; + case 'connection': + _model.connected = event['connected'] as bool? ?? false; + _model.model = event['model'] as String?; + if (!_model.connected) _clearTelemetry(); + _model.bump(); + break; + case 'telemetry': + // Ignore stray telemetry that arrives after a disconnect — otherwise it + // repopulates values _clearTelemetry() just wiped, leaving stale readings. + if (!_model.connected) break; + _model.satellites = event['satelliteCount'] as int?; + _model.isFlying = event['isFlying'] as bool?; + _model.flightMode = event['flightMode'] as String?; + _model.altitude = (event['altitude'] as num?)?.toDouble(); + _model.latitude = (event['latitude'] as num?)?.toDouble(); + _model.longitude = (event['longitude'] as num?)?.toDouble(); + _model.bump(); + break; + case 'battery': + // Same guard: a battery packet trailing a disconnect must not revive the + // last percentage (the reported "disconnected but still 42%" bug). + if (!_model.connected) break; + _model.batteryPercent = event['percent'] as int?; + _model.bump(); + break; + } + } + + void _clearTelemetry() { + _model.satellites = null; + _model.isFlying = null; + _model.flightMode = null; + _model.altitude = null; + _model.latitude = null; + _model.longitude = null; + _model.batteryPercent = null; + } + + Future _register() async { + try { + await _dji.registerApp(); + } catch (e) { + _snack('registerApp failed: $e'); + } + } + + Future _connect() async { + try { + final bool started = await _dji.startConnection(); + _snack(started ? 'Scanning for product…' : 'Could not start connection'); + } catch (e) { + _snack('startConnection failed: $e'); + } + } + + void _snack(String msg) { + if (!mounted) return; + ScaffoldMessenger.of(context) + ..clearSnackBars() + ..showSnackBar(SnackBar(content: Text(msg))); + } + + // ── API server upload ────────────────────────────────────────────────────── + + Future _toggleUpload() async { + if (_model.upload == UploadStatus.disabled) { + await _uploader.enable(_serverHost.text); + } else { + await _uploader.disable(); + } + } + + /// Builds events describing the app's CURRENT state, sent to the server right + /// after (re)connecting so it always reflects reality. + List> _buildStateSnapshot() { + final List> events = >[ + {'type': 'registration', 'state': _registrationWire()}, + {'type': 'connection', 'connected': _model.connected, 'model': _model.model ?? ''}, + ]; + if (_model.batteryPercent != null) { + events.add({'type': 'battery', 'percent': _model.batteryPercent}); + } + final Map tel = {'type': 'telemetry'}; + if (_model.satellites != null) tel['satelliteCount'] = _model.satellites; + if (_model.isFlying != null) tel['isFlying'] = _model.isFlying; + if (_model.flightMode != null) tel['flightMode'] = _model.flightMode; + if (_model.altitude != null) tel['altitude'] = _model.altitude; + if (_model.latitude != null) tel['latitude'] = _model.latitude; + if (_model.longitude != null) tel['longitude'] = _model.longitude; + if (tel.length > 1) events.add(tel); + return events; + } + + String _registrationWire() { + switch (_model.registration) { + case RegistrationState.success: + return 'success'; + case RegistrationState.registering: + return 'registering'; + case RegistrationState.failed: + return 'failed'; + case RegistrationState.idle: + return 'not registered'; + } + } + + /// Handles commands pushed down from the server. + void _handleServerCommand(String command, Map payload) { + switch (command) { + case 'registerApp': + _register(); + break; + case 'startConnection': + _connect(); + break; + case 'stopConnection': + _dji.stopConnection(); + break; + default: + _snack('Unknown server command: $command'); + return; + } + _snack('Server command: $command'); + } + + @override + void dispose() { + _sub?.cancel(); + _authSub?.cancel(); + _uploadSub?.cancel(); + _uploader.dispose(); + _serverHost.dispose(); + _model.dispose(); + super.dispose(); + } + + /// Opens the login page as a modal popup (from the user panel). Returns to the + /// caller once dismissed; the auth listener refreshes the UI on success. + Future _openLogin() async { + await Navigator.of(context).push(MaterialPageRoute( + fullscreenDialog: true, + builder: (BuildContext ctx) => LoginPage(onSignedIn: () => Navigator.of(ctx).pop()), + )); + } + + // ── Navigation ─────────────────────────────────────────────────────────── + + void _goFly() { + Navigator.of(context).push(MaterialPageRoute( + builder: (_) => FlightControlPage(model: _model), + )); + } + + void _openAlbum() { + Navigator.of(context).push(MaterialPageRoute(builder: (_) => const AlbumPage())); + } + + void _onTile(String tile) => _snack('$tile — coming soon'); + + // ── UI ─────────────────────────────────────────────────────────────────── + + @override + Widget build(BuildContext context) { + return GoFlyPage( + model: _model, + onGoFly: _goFly, + onOpenAlbum: _openAlbum, + onSettings: _openSettings, + onTile: _onTile, + ); + } + + /// Aircraft/connection controls that don't appear on the design's launch + /// screen live here: MSDK registration, product connection, and telemetry + /// streaming to the API Server, plus account sign-out. + void _openSettings() { + final PVScheme s = PVScheme.of(context); + showModalBottomSheet( + context: context, + backgroundColor: s.surface, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (BuildContext context) { + return AnimatedBuilder( + animation: _model, + builder: (BuildContext context, _) { + final (Color, String) reg = switch (_model.registration) { + RegistrationState.idle => (s.textTertiary, 'Not registered'), + RegistrationState.registering => (s.warning, 'Registering…'), + RegistrationState.success => (s.success, 'Registered'), + RegistrationState.failed => (s.danger, 'Registration failed'), + }; + final (Color, String) up = switch (_model.upload) { + UploadStatus.disabled => (s.textTertiary, 'Off'), + UploadStatus.connecting => (s.warning, 'Connecting…'), + UploadStatus.connected => (s.success, 'Streaming'), + UploadStatus.error => (s.danger, 'Retrying…'), + }; + final bool streaming = _model.upload != UploadStatus.disabled; + + return Padding( + padding: EdgeInsets.fromLTRB(20, 12, 20, 20 + MediaQuery.of(context).viewInsets.bottom), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration(color: s.borderStrong, borderRadius: BorderRadius.circular(999)), + ), + ), + const SizedBox(height: 16), + // Account — signed-in identity + sign-out, or a sign-in entry + // that opens the login page (biometric / face live there). + Row( + children: [ + Expanded( + child: Text( + auth.isAuthed + ? (auth.userEmail.isEmpty ? 'Signed in' : auth.userEmail) + : 'Not signed in', + style: TextStyle(fontFamily: PV.fontSans, fontSize: 14, fontWeight: FontWeight.w600, color: s.textPrimary), + ), + ), + if (auth.isAuthed) + TextButton.icon( + onPressed: () { + Navigator.of(context).pop(); + auth.signOut(); + }, + icon: Icon(Icons.logout, size: 16, color: s.textSecondary), + label: Text('Sign out', style: TextStyle(color: s.textSecondary)), + ) + else + FilledButton.icon( + onPressed: () { + Navigator.of(context).pop(); + _openLogin(); + }, + icon: const Icon(Icons.login, size: 16), + label: const Text('Sign in'), + ), + ], + ), + // Registration, aircraft and telemetry controls require a + // session — they only appear once the user has signed in. + if (auth.isAuthed) ...[ + Divider(color: s.border, height: 24), + + _settingRow(s, 'Registration', reg.$1, reg.$2, trailing: 'MSDK · ${_model.sdkVersion}'), + if (_model.registrationError != null) ...[ + const SizedBox(height: 6), + Text(_model.registrationError!, style: TextStyle(fontFamily: PV.fontSans, fontSize: 13, color: s.danger)), + ], + const SizedBox(height: 10), + FilledButton( + onPressed: _model.registration == RegistrationState.registering ? null : _register, + child: const Text('Register app'), + ), + const SizedBox(height: 20), + + _settingRow(s, 'Aircraft', _model.connected ? s.success : s.textTertiary, + _model.connected ? 'Connected' : 'No product', + trailing: _model.model ?? '—'), + const SizedBox(height: 10), + OutlinedButton.icon( + onPressed: _model.registered ? _connect : null, + icon: const Icon(Icons.usb, size: 18), + label: const Text('Connect to product'), + ), + const SizedBox(height: 20), + + _settingRow(s, 'Telemetry stream', up.$1, up.$2), + const SizedBox(height: 10), + TextField( + controller: _serverHost, + enabled: !streaming, + style: PV.body, + decoration: const InputDecoration(labelText: 'API Server host', hintText: '10.2.1.101:8080'), + ), + const SizedBox(height: 10), + FilledButton.icon( + style: FilledButton.styleFrom(backgroundColor: streaming ? s.danger : s.accent), + onPressed: _model.upload == UploadStatus.connecting ? null : _toggleUpload, + icon: Icon(streaming ? Icons.stop : Icons.play_arrow, size: 18), + label: Text(streaming ? 'Stop streaming' : 'Start streaming'), + ), + ] else ...[ + const SizedBox(height: 14), + Text( + 'Sign in to manage registration, aircraft connection and telemetry streaming.', + style: TextStyle(fontFamily: PV.fontSans, fontSize: 13, color: s.textSecondary), + ), + ], + ], + ), + ); + }, + ); + }, + ); + } + + Widget _settingRow(PVScheme s, String title, Color dot, String status, {String? trailing}) { + return Row( + children: [ + Container(width: 8, height: 8, decoration: BoxDecoration(color: dot, shape: BoxShape.circle)), + const SizedBox(width: 10), + Text(title, style: TextStyle(fontFamily: PV.fontSans, fontSize: 15, fontWeight: FontWeight.w600, color: s.textPrimary)), + const SizedBox(width: 8), + Text(status, style: TextStyle(fontFamily: PV.fontSans, fontSize: 13, color: s.textSecondary)), + const Spacer(), + if (trailing != null) + Text(trailing, style: TextStyle(fontFamily: PV.fontMono, fontSize: 12, color: s.textTertiary)), + ], + ); + } +} diff --git a/Fly App/lib/pb_auth.dart b/Fly App/lib/pb_auth.dart new file mode 100644 index 0000000..22625b9 --- /dev/null +++ b/Fly App/lib/pb_auth.dart @@ -0,0 +1,147 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:shared_preferences/shared_preferences.dart'; + +enum AuthStatus { signedOut, signingIn, signedIn, error } + +/// Authentication through the **API Server** (login only, for now). +/// +/// The app talks only to the API Server; PocketBase is hidden behind it. The +/// API Server address is configurable on the login screen ("Server settings"). +/// A single global [auth] instance is shared across the app. +class PbAuth { + static const String defaultServer = 'http://10.2.1.101:8080'; + + SharedPreferences? _prefs; + String? _token; + String _email = ''; + + final StreamController _statusController = + StreamController.broadcast(); + Stream get status => _statusController.stream; + + AuthStatus _status = AuthStatus.signedOut; + AuthStatus get currentStatus => _status; + + bool get isAuthed => _token != null && _token!.isNotEmpty; + String get userEmail => _email; + String get token => _token ?? ''; + String get serverUrl => _prefs?.getString('api_url') ?? defaultServer; + + /// A previously successful password login can be replayed via biometrics. + /// Credentials are stored on-device (see [signIn]); presence of a saved + /// password means "remembered account" and unlocks the biometric buttons. + bool get hasRememberedAccount => (_prefs?.getString('remember_password') ?? '').isNotEmpty; + String get rememberedEmail => _prefs?.getString('remember_email') ?? ''; + + void _set(AuthStatus s) { + _status = s; + if (!_statusController.isClosed) _statusController.add(s); + } + + /// Restores a persisted session (if any) on app start. + Future init() async { + _prefs = await SharedPreferences.getInstance(); + _token = _prefs!.getString('api_token'); + _email = _prefs!.getString('api_email') ?? ''; + if (isAuthed) _set(AuthStatus.signedIn); + } + + Future signIn({ + required String serverUrl, + required String email, + required String password, + }) async { + final String base = _normalize(serverUrl); + _set(AuthStatus.signingIn); + try { + final Map result = await _postLogin(base, email.trim(), password); + final String? token = result['token'] as String?; + if (token == null || token.isEmpty) { + throw const _AuthException('Unexpected response from the API server.'); + } + _token = token; + _email = ((result['record'] as Map?)?['email'] as String?) ?? email.trim(); + await _prefs?.setString('api_url', base); + await _prefs?.setString('api_token', _token!); + await _prefs?.setString('api_email', _email); + // Remember the credentials so a later biometric/face check can replay them. + // NOTE: stored in plain SharedPreferences like the token above — move to + // flutter_secure_storage (Keystore) when hardening. + await _prefs?.setString('remember_email', email.trim()); + await _prefs?.setString('remember_password', password); + _set(AuthStatus.signedIn); + } catch (e) { + _set(AuthStatus.error); + rethrow; + } + } + + /// Replays the remembered credentials — call this only after the caller has + /// passed a device biometric / face check. + Future signInWithRememberedCredentials() async { + final String email = _prefs?.getString('remember_email') ?? ''; + final String password = _prefs?.getString('remember_password') ?? ''; + if (password.isEmpty) { + throw const _AuthException('No remembered account. Sign in with your password once first.'); + } + await signIn(serverUrl: serverUrl, email: email, password: password); + } + + /// Clears the remembered credentials (disables biometric quick-login) without + /// necessarily ending the current session. + Future forgetAccount() async { + await _prefs?.remove('remember_email'); + await _prefs?.remove('remember_password'); + } + + Future> _postLogin(String base, String email, String password) async { + final HttpClient http = HttpClient()..connectionTimeout = const Duration(seconds: 10); + try { + final HttpClientRequest req = await http.postUrl(Uri.parse('$base/api/auth/login')); + req.headers.contentType = ContentType.json; + req.add(utf8.encode(jsonEncode({'email': email, 'password': password}))); + final HttpClientResponse resp = await req.close().timeout(const Duration(seconds: 12)); + final String text = await resp.transform(utf8.decoder).join(); + final Map body = + text.isNotEmpty ? (jsonDecode(text) as Map).cast() : {}; + + if (resp.statusCode == 200) return body; + if (resp.statusCode == 400) throw const _AuthException('Invalid email or password.'); + if (resp.statusCode == 502) throw const _AuthException("API server can't reach PocketBase."); + throw _AuthException( + (body['message'] ?? body['error'] ?? 'Login failed (${resp.statusCode})').toString(), + ); + } finally { + http.close(force: true); + } + } + + Future signOut() async { + _token = null; + _email = ''; + await _prefs?.remove('api_token'); + await _prefs?.remove('api_email'); + _set(AuthStatus.signedOut); + } + + String _normalize(String url) { + String u = url.trim(); + if (u.isEmpty) return defaultServer; + if (!u.startsWith('http://') && !u.startsWith('https://')) u = 'http://$u'; + if (u.endsWith('/')) u = u.substring(0, u.length - 1); + return u; + } +} + +class _AuthException implements Exception { + const _AuthException(this.message); + final String message; + @override + String toString() => message; +} + +/// App-wide authentication instance. +final PbAuth auth = PbAuth(); diff --git a/Fly App/lib/theme.dart b/Fly App/lib/theme.dart new file mode 100644 index 0000000..c9ba140 --- /dev/null +++ b/Fly App/lib/theme.dart @@ -0,0 +1,375 @@ +import 'package:flutter/material.dart'; + +/// PilotVault design system (mobile) — light-default brand theme. +/// Signal Blue accent over Vault Navy and cool slate neutrals; Space Grotesk +/// for structure, Space Mono for data/telemetry and uppercase eyebrow labels. +class PV { + PV._(); + + static const String fontSans = 'Space Grotesk'; + static const String fontMono = 'Space Mono'; + + // Brand + static const Color navy = Color(0xFF0F1E3D); // Vault Navy + static const Color accent = Color(0xFF3D7BF0); // Signal Blue + static const Color accentHover = Color(0xFF2B62CC); + + // Status (names kept: ready=green, caution=amber, warning=red fault) + static const Color ready = Color(0xFF1F8A5B); + static const Color readyFg = Color(0xFF177049); + static const Color caution = Color(0xFFD9852B); + static const Color cautionFg = Color(0xFFB86C1B); + static const Color warning = Color(0xFFD64545); + static const Color warningFg = Color(0xFFB83232); + + // Surfaces (light) + static const Color surface0 = Color(0xFFEEF0F3); // Cloud app ground + static const Color surface1 = Color(0xFFFFFFFF); // card / chrome + static const Color surface2 = Color(0xFFF6F7F9); // inset tiles / inputs + + // Text + static const Color ink = navy; // primary + static const Color inkSecondary = Color(0xFF5A6B85); // steel + static const Color inkMuted = Color(0xFF97A1B0); // slate-400 + + // Hairlines + static const Color line = Color(0xFFDCE0E7); + static const Color lineStrong = Color(0xFFC5CCD7); + + static const double radius = 14; // cards + static const double radiusCtl = 10; // controls + + static const List shadowXs = [ + BoxShadow(color: Color(0x0F0F1E3D), blurRadius: 2, offset: Offset(0, 1)), + ]; + + // Type scale — mono for data, sans for chrome + static const TextStyle telemetry = TextStyle( + fontFamily: fontMono, + fontSize: 30, + fontWeight: FontWeight.w500, + height: 1.0, + color: ink, + fontFeatures: [FontFeature.tabularFigures()], + ); + static const TextStyle mode = TextStyle( + fontFamily: fontSans, + fontSize: 18, + fontWeight: FontWeight.w600, + letterSpacing: -0.4, + color: ink, + ); + static const TextStyle body = TextStyle(fontFamily: fontSans, fontSize: 14, color: ink); + static const TextStyle caption = TextStyle(fontFamily: fontSans, fontSize: 12, color: inkSecondary); + // Mono ALL-CAPS eyebrow (telemetry field names / section labels) + static const TextStyle label = TextStyle( + fontFamily: fontMono, + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 1.5, + color: inkMuted, + ); + // Mono value (serials, coordinates, counts) + static const TextStyle mono = TextStyle( + fontFamily: fontMono, + fontSize: 14, + fontWeight: FontWeight.w700, + color: ink, + fontFeatures: [FontFeature.tabularFigures()], + ); + + static ThemeData theme() { + final ThemeData base = ThemeData.light(useMaterial3: true); + final ColorScheme scheme = ColorScheme.fromSeed( + seedColor: accent, + brightness: Brightness.light, + ).copyWith(primary: accent, surface: surface1, onSurface: ink); + + OutlineInputBorder borderOf(Color c, [double w = 1]) => OutlineInputBorder( + borderRadius: BorderRadius.circular(radiusCtl), + borderSide: BorderSide(color: c, width: w), + ); + + return base.copyWith( + colorScheme: scheme, + scaffoldBackgroundColor: surface0, + dividerColor: line, + textTheme: base.textTheme.apply( + fontFamily: fontSans, + bodyColor: ink, + displayColor: ink, + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: surface2, + isDense: true, + hintStyle: const TextStyle(color: inkMuted), + labelStyle: const TextStyle(color: inkSecondary), + prefixIconColor: inkMuted, + enabledBorder: borderOf(line), + focusedBorder: borderOf(accent, 2), + border: borderOf(line), + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: accent, + foregroundColor: Colors.white, + disabledBackgroundColor: surface2, + disabledForegroundColor: inkMuted, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(radiusCtl)), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 18), + textStyle: const TextStyle(fontFamily: fontSans, fontWeight: FontWeight.w600, fontSize: 14), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: ink, + backgroundColor: surface1, + side: const BorderSide(color: lineStrong), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(radiusCtl)), + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + textStyle: const TextStyle(fontFamily: fontSans, fontWeight: FontWeight.w600, fontSize: 14), + ), + ), + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom(foregroundColor: inkSecondary), + ), + snackBarTheme: SnackBarThemeData( + backgroundColor: navy, + contentTextStyle: const TextStyle(fontFamily: fontSans, color: Colors.white), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(radiusCtl)), + behavior: SnackBarBehavior.floating, + ), + ); + } +} + +/// PilotVault Vector mark — two offset chevrons. Back wing Signal Blue, +/// front wing the foreground color (navy on light). +class PvBrandMark extends StatelessWidget { + const PvBrandMark({super.key, this.size = 28, this.frontColor}); + + final double size; + final Color? frontColor; + + @override + Widget build(BuildContext context) { + return CustomPaint( + size: Size(size, size), + painter: _ChevronPainter(frontColor ?? PV.ink), + ); + } +} + +class _ChevronPainter extends CustomPainter { + _ChevronPainter(this.front); + final Color front; + + @override + void paint(Canvas canvas, Size size) { + final double s = size.width / 48.0; + Paint stroke(Color c) => Paint() + ..color = c + ..style = PaintingStyle.stroke + ..strokeWidth = 4 * s + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round; + final Path back = Path() + ..moveTo(8 * s, 30 * s) + ..lineTo(19 * s, 17 * s) + ..lineTo(30 * s, 30 * s); + final Path frontWing = Path() + ..moveTo(18 * s, 33 * s) + ..lineTo(29 * s, 20 * s) + ..lineTo(40 * s, 33 * s); + canvas.drawPath(back, stroke(PV.accent)); + canvas.drawPath(frontWing, stroke(front)); + } + + @override + bool shouldRepaint(covariant _ChevronPainter old) => old.front != front; +} + +/// A card surface: white face, hairline border, soft cool shadow. +class PvPanel extends StatelessWidget { + const PvPanel({super.key, required this.child, this.padding = const EdgeInsets.all(16)}); + + final Widget child; + final EdgeInsetsGeometry padding; + + @override + Widget build(BuildContext context) { + return Container( + padding: padding, + decoration: BoxDecoration( + color: PV.surface1, + borderRadius: BorderRadius.circular(PV.radius), + border: Border.all(color: PV.line), + boxShadow: PV.shadowXs, + ), + child: child, + ); + } +} + +/// Mono uppercase eyebrow label (telemetry field / section label). +class SectionLabel extends StatelessWidget { + const SectionLabel(this.text, {super.key}); + final String text; + + @override + Widget build(BuildContext context) => Text(text.toUpperCase(), style: PV.label); +} + +/// A status dot. Solid by default; brand carries status by color, not glow. +class StatusDot extends StatelessWidget { + const StatusDot(this.color, {super.key, this.glow = false, this.size = 9}); + final Color color; + final bool glow; + final double size; + + @override + Widget build(BuildContext context) { + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + boxShadow: glow ? [BoxShadow(color: color.withValues(alpha: 0.35), blurRadius: 6)] : null, + ), + ); + } +} + +/// ───────────────────────────────────────────────────────────────────────────── +/// Full semantic token set, theme-flipping (mirrors tokens/colors.css). The Go +/// Fly and Album screens follow the device brightness; Flight Control overlays a +/// live camera feed and is always dark, so it uses fixed glass colors (below). +/// ───────────────────────────────────────────────────────────────────────────── +class PVScheme { + const PVScheme({ + required this.brightness, + required this.bgApp, + required this.surface, + required this.surface2, + required this.surfaceInset, + required this.surfaceRaised, + required this.border, + required this.borderStrong, + required this.borderSubtle, + required this.textPrimary, + required this.textSecondary, + required this.textTertiary, + required this.textInverse, + required this.accent, + required this.accentHover, + required this.accentSoft, + required this.accentSoftFg, + required this.success, + required this.successSoft, + required this.successFg, + required this.warning, + required this.warningSoft, + required this.warningFg, + required this.danger, + required this.dangerSoft, + required this.dangerFg, + required this.shadowXs, + required this.shadowSm, + }); + + final Brightness brightness; + final Color bgApp, surface, surface2, surfaceInset, surfaceRaised; + final Color border, borderStrong, borderSubtle; + final Color textPrimary, textSecondary, textTertiary, textInverse; + final Color accent, accentHover, accentSoft, accentSoftFg; + final Color success, successSoft, successFg; + final Color warning, warningSoft, warningFg; + final Color danger, dangerSoft, dangerFg; + final List shadowXs, shadowSm; + + bool get isDark => brightness == Brightness.dark; + + static const PVScheme light = PVScheme( + brightness: Brightness.light, + bgApp: Color(0xFFEEF0F3), + surface: Color(0xFFFFFFFF), + surface2: Color(0xFFF6F7F9), + surfaceInset: Color(0xFFEEF0F3), + surfaceRaised: Color(0xFFFFFFFF), + border: Color(0xFFDCE0E7), + borderStrong: Color(0xFFC5CCD7), + borderSubtle: Color(0xFFE6E9EE), + textPrimary: Color(0xFF0F1E3D), + textSecondary: Color(0xFF5A6B85), + textTertiary: Color(0xFF97A1B0), + textInverse: Color(0xFFFFFFFF), + accent: Color(0xFF3D7BF0), + accentHover: Color(0xFF2B62CC), + accentSoft: Color(0xFFEAF1FE), + accentSoftFg: Color(0xFF1F4CA0), + success: Color(0xFF1F8A5B), + successSoft: Color(0xFFDCF1E7), + successFg: Color(0xFF177049), + warning: Color(0xFFD9852B), + warningSoft: Color(0xFFFBEBD5), + warningFg: Color(0xFFB86C1B), + danger: Color(0xFFD64545), + dangerSoft: Color(0xFFFBE0E0), + dangerFg: Color(0xFFB83232), + shadowXs: [BoxShadow(color: Color(0x0F0F1E3D), blurRadius: 2, offset: Offset(0, 1))], + shadowSm: [ + BoxShadow(color: Color(0x0F0F1E3D), blurRadius: 3, offset: Offset(0, 1)), + BoxShadow(color: Color(0x0A0F1E3D), blurRadius: 2, offset: Offset(0, 1)), + ], + ); + + static const PVScheme dark = PVScheme( + brightness: Brightness.dark, + bgApp: Color(0xFF0B1730), + surface: Color(0xFF10203F), + surface2: Color(0xFF142748), + surfaceInset: Color(0xFF0B1730), + surfaceRaised: Color(0xFF16294B), + border: Color(0x1AFFFFFF), + borderStrong: Color(0x2EFFFFFF), + borderSubtle: Color(0x0FFFFFFF), + textPrimary: Color(0xFFF4F7FC), + textSecondary: Color(0xFF8FA0BE), + textTertiary: Color(0xFF5E6E8C), + textInverse: Color(0xFF0F1E3D), + accent: Color(0xFF5B93F5), + accentHover: Color(0xFF8FB4F6), + accentSoft: Color(0x2E3D7BF0), + accentSoftFg: Color(0xFF8FB4F6), + success: Color(0xFF1F8A5B), + successSoft: Color(0x381F8A5B), + successFg: Color(0xFF5FD3A0), + warning: Color(0xFFD9852B), + warningSoft: Color(0x38D9852B), + warningFg: Color(0xFFF0B26A), + danger: Color(0xFFD64545), + dangerSoft: Color(0x38D64545), + dangerFg: Color(0xFFF08A8A), + shadowXs: [BoxShadow(color: Color(0x59000000), blurRadius: 2, offset: Offset(0, 1))], + shadowSm: [BoxShadow(color: Color(0x66000000), blurRadius: 3, offset: Offset(0, 1))], + ); + + /// Resolves the scheme from the device brightness. + static PVScheme of(BuildContext context) => + MediaQuery.platformBrightnessOf(context) == Brightness.dark ? dark : light; +} + +/// Fixed "glass" palette for the Flight Control overlay (always over a dark feed). +class Glass { + Glass._(); + static const Color ink = Color(0xFFEAF0FA); + static const Color pill = Color(0x80081020); // rgba(8,16,32,0.5) + static const Color pillStrong = Color(0x8C081020); // rgba(8,16,32,0.55) + static const Color accent = Color(0xEB3D7BF0); // rgba(61,123,240,0.92) + static const Color hairline = Color(0x1FFFFFFF); // rgba(255,255,255,0.12) + static const Color sat = Color(0xFF7FE0B0); + static const Color rec = Color(0xFFD64545); + static const Color subject = Color(0xFFF4C542); +} diff --git a/Fly App/lib/ui/album_page.dart b/Fly App/lib/ui/album_page.dart new file mode 100644 index 0000000..baa4785 --- /dev/null +++ b/Fly App/lib/ui/album_page.dart @@ -0,0 +1,145 @@ +import 'package:flutter/material.dart'; + +import '../theme.dart'; + +/// Portrait media grid — mirrors the ui_kit/fly "Album" mockup. Media is +/// placeholder content (the app has no on-device gallery source yet); the +/// layout, filters and badges match the design. +class AlbumPage extends StatefulWidget { + const AlbumPage({super.key}); + + @override + State createState() => _AlbumPageState(); +} + +class _AlbumPageState extends State { + static const List _filters = ['All', 'Photos', 'Videos', 'Pano']; + int _active = 0; + + // (isVideo, duration) — placeholder set from the mockup. + static const List<(bool, String?)> _media = <(bool, String?)>[ + (true, '0:24'), (false, null), (false, null), + (true, '1:12'), (false, null), (false, null), + (false, null), (true, '0:08'), (false, null), + (false, null), (true, '0:31'), (false, null), + ]; + + @override + Widget build(BuildContext context) { + final PVScheme s = PVScheme.of(context); + return Scaffold( + backgroundColor: s.bgApp, + body: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _header(s), + _filterBar(s), + const SizedBox(height: 14), + Expanded(child: _grid(s)), + ], + ), + ), + ); + } + + Widget _header(PVScheme s) { + return Padding( + padding: const EdgeInsets.fromLTRB(12, 6, 20, 12), + child: Row( + children: [ + IconButton( + onPressed: () => Navigator.of(context).maybePop(), + icon: Icon(Icons.chevron_left, size: 26, color: s.textSecondary), + ), + Text( + 'Album', + style: TextStyle(fontFamily: PV.fontSans, fontSize: 20, fontWeight: FontWeight.w700, letterSpacing: -0.4, color: s.textPrimary), + ), + const Spacer(), + Text('${_media.length} items', style: TextStyle(fontFamily: PV.fontMono, fontSize: 12, color: s.textTertiary)), + ], + ), + ); + } + + Widget _filterBar(PVScheme s) { + return SizedBox( + height: 28, + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 20), + itemCount: _filters.length, + separatorBuilder: (_, __) => const SizedBox(width: 8), + itemBuilder: (BuildContext context, int i) { + final bool active = i == _active; + return GestureDetector( + onTap: () => setState(() => _active = i), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12), + alignment: Alignment.center, + decoration: BoxDecoration( + color: active ? s.accent : s.surfaceInset, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + _filters[i], + style: TextStyle( + fontFamily: PV.fontSans, + fontSize: 12.5, + fontWeight: FontWeight.w600, + color: active ? Colors.white : s.textSecondary, + ), + ), + ), + ); + }, + ), + ); + } + + Widget _grid(PVScheme s) { + return GridView.builder( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 20), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: 6, + mainAxisSpacing: 6, + ), + itemCount: _media.length, + itemBuilder: (BuildContext context, int i) { + final (bool isVideo, String? dur) = _media[i]; + return ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [s.surface2, s.surfaceInset], + transform: GradientRotation((140 + i * 14) * 3.1415926 / 180), + ), + border: Border.all(color: s.border), + borderRadius: BorderRadius.circular(10), + ), + child: Stack( + children: [ + Center(child: Icon(isVideo ? Icons.videocam_outlined : Icons.image_outlined, size: 20, color: s.textTertiary)), + if (dur != null) + Positioned( + right: 6, + bottom: 5, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), + decoration: BoxDecoration(color: const Color(0xB30B1730), borderRadius: BorderRadius.circular(5)), + child: Text(dur, style: const TextStyle(fontFamily: PV.fontMono, fontSize: 9.5, color: Colors.white)), + ), + ), + ], + ), + ), + ); + }, + ); + } +} diff --git a/Fly App/lib/ui/dji_video_view.dart b/Fly App/lib/ui/dji_video_view.dart new file mode 100644 index 0000000..28df53e --- /dev/null +++ b/Fly App/lib/ui/dji_video_view.dart @@ -0,0 +1,29 @@ +import 'package:flutter/foundation.dart' + show defaultTargetPlatform, kIsWeb, TargetPlatform; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// Live DJI primary video feed, rendered by the native `DjiVideoView` +/// [PlatformView] (Android only). Used as the full-bleed background of the +/// Flight Control HUD. +/// +/// On non-Android targets (or when the SDK has no active feed) the native side +/// simply renders black, so callers gate this behind a real connection and fall +/// back to a placeholder otherwise. +class DjiVideoView extends StatelessWidget { + const DjiVideoView({super.key}); + + /// Must match `DjiVideoView.VIEW_TYPE` on the native side. + static const String _viewType = 'dji_msdk/video'; + + @override + Widget build(BuildContext context) { + if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) { + return const SizedBox.shrink(); + } + return const AndroidView( + viewType: _viewType, + creationParamsCodec: StandardMessageCodec(), + ); + } +} diff --git a/Fly App/lib/ui/flight_control_page.dart b/Fly App/lib/ui/flight_control_page.dart new file mode 100644 index 0000000..1c349b5 --- /dev/null +++ b/Fly App/lib/ui/flight_control_page.dart @@ -0,0 +1,474 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../flight_model.dart'; +import '../theme.dart'; +import 'dji_video_view.dart'; + +/// Landscape live-flight overlay — mirrors the ui_kit/fly "Flight control · +/// landscape" mockup. Locks to landscape while shown and restores portrait on +/// exit. Camera-feed HUD is composited over a painted placeholder feed; real +/// telemetry (satellites, battery, altitude, mode) is bound where available. +class FlightControlPage extends StatefulWidget { + const FlightControlPage({super.key, required this.model}); + + final FlightModel model; + + @override + State createState() => _FlightControlPageState(); +} + +class _FlightControlPageState extends State { + Timer? _recTimer; + int _recSeconds = 0; + String _mode = 'Video'; + + @override + void initState() { + super.initState(); + SystemChrome.setPreferredOrientations([ + DeviceOrientation.landscapeLeft, + DeviceOrientation.landscapeRight, + ]); + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); + _recTimer = Timer.periodic(const Duration(seconds: 1), (_) { + if (mounted) setState(() => _recSeconds++); + }); + } + + @override + void dispose() { + _recTimer?.cancel(); + SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); + SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + super.dispose(); + } + + String get _recLabel { + final String mm = (_recSeconds ~/ 60).toString().padLeft(2, '0'); + final String ss = (_recSeconds % 60).toString().padLeft(2, '0'); + return '$mm:$ss'; + } + + @override + Widget build(BuildContext context) { + final FlightModel m = widget.model; + return Scaffold( + backgroundColor: const Color(0xFF0A1120), + body: AnimatedBuilder( + animation: m, + builder: (BuildContext context, _) { + return Stack( + children: [ + // Background: live DJI camera feed when a product is connected, + // painted placeholder otherwise. The HUD layers below paint over + // it since they come later in the stack. + Positioned.fill( + child: m.connected + ? const DjiVideoView() + : const CustomPaint(painter: _FeedPainter()), + ), + + // Center reticle + const Center(child: Icon(Icons.add, size: 30, color: Color(0xB3FFFFFF))), + + // Top bar + Positioned( + top: 12, + left: 14, + right: 14, + child: _topBar(m), + ), + + // Left rail + Positioned( + left: 14, + top: 58, + child: Column( + children: [ + _sideBtn(Icons.control_camera, active: true), + const SizedBox(height: 10), + _sideBtn(Icons.wb_sunny_outlined), + const SizedBox(height: 10), + _sideBtn(Icons.camera_outlined), + const SizedBox(height: 10), + _sideBtn(Icons.grid_on), + ], + ), + ), + + // Gimbal pitch slider + Positioned( + left: 70, + top: 58, + bottom: 96, + child: _gimbalSlider(), + ), + + // Right camera controls + Positioned( + right: 16, + top: 0, + bottom: 0, + child: Center(child: _cameraControls()), + ), + + // Bottom-left minimap + Positioned(left: 14, bottom: 12, child: _minimap()), + + // Bottom-center telemetry + Positioned( + bottom: 14, + left: 0, + right: 0, + child: Center(child: _telemetry(m)), + ), + + // RTH button + Positioned(right: 92, bottom: 20, child: _rthButton()), + ], + ); + }, + ), + ); + } + + // ── Top bar ────────────────────────────────────────────────────────────── + Widget _topBar(FlightModel m) { + return Row( + children: [ + GestureDetector( + onTap: () => Navigator.of(context).maybePop(), + child: _pill(child: const Icon(Icons.chevron_left, size: 16, color: Glass.ink)), + ), + const SizedBox(width: 8), + Container( + height: 26, + padding: const EdgeInsets.symmetric(horizontal: 11), + decoration: BoxDecoration(color: Glass.accent, borderRadius: BorderRadius.circular(8)), + alignment: Alignment.center, + child: Text( + (m.flightMode != null && m.flightMode!.isNotEmpty) ? m.flightMode! : 'N', + style: const TextStyle(fontFamily: PV.fontSans, fontSize: 12, fontWeight: FontWeight.w700, letterSpacing: 0.4, color: Colors.white), + ), + ), + const SizedBox(width: 8), + _pill(child: Row(mainAxisSize: MainAxisSize.min, children: [ + const Icon(Icons.satellite_alt, size: 14, color: Glass.sat), + const SizedBox(width: 4), + _mono(m.satellites?.toString() ?? '0'), + ])), + const SizedBox(width: 8), + _pill(child: Row(mainAxisSize: MainAxisSize.min, children: const [ + Icon(Icons.sensors, size: 14, color: Glass.ink), + SizedBox(width: 4), + Text('HD', style: TextStyle(fontFamily: PV.fontMono, fontSize: 12, color: Glass.ink)), + ])), + const Spacer(), + _pill(child: Row(mainAxisSize: MainAxisSize.min, children: [ + _mono('REC'), + const SizedBox(width: 6), + Container(width: 7, height: 7, decoration: const BoxDecoration(color: Glass.rec, shape: BoxShape.circle)), + const SizedBox(width: 6), + _mono(_recLabel), + ])), + const SizedBox(width: 8), + _pill(child: Row(mainAxisSize: MainAxisSize.min, children: [ + const Icon(Icons.battery_full, size: 16, color: Glass.sat), + const SizedBox(width: 4), + _mono(m.batteryPercent == null ? '—' : '${m.batteryPercent}%'), + ])), + const SizedBox(width: 8), + _pill(child: const Icon(Icons.settings, size: 16, color: Glass.ink)), + ], + ); + } + + // ── Reusable glass pieces ──────────────────────────────────────────────── + Widget _pill({required Widget child}) { + return Container( + height: 26, + padding: const EdgeInsets.symmetric(horizontal: 10), + decoration: BoxDecoration(color: Glass.pill, borderRadius: BorderRadius.circular(8)), + alignment: Alignment.center, + child: child, + ); + } + + Widget _mono(String t) => Text(t, style: const TextStyle(fontFamily: PV.fontMono, fontSize: 12, color: Glass.ink)); + + Widget _sideBtn(IconData icon, {bool active = false}) { + return Container( + width: 42, + height: 42, + decoration: BoxDecoration( + color: active ? Glass.accent : Glass.pill, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Glass.hairline), + ), + child: Icon(icon, size: 20, color: Glass.ink), + ); + } + + Widget _gimbalSlider() { + return SizedBox( + width: 16, + child: Stack( + alignment: Alignment.topCenter, + children: [ + Container(width: 6, decoration: BoxDecoration(color: Glass.pill, borderRadius: BorderRadius.circular(6))), + const Align( + alignment: Alignment(0, -0.24), + child: _Thumb(), + ), + ], + ), + ); + } + + Widget _cameraControls() { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 46, + height: 46, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + border: Border.all(color: const Color(0x99FFFFFF), width: 2), + gradient: const LinearGradient(begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [Color(0xFF2A4E86), Color(0xFF12201A)]), + ), + ), + const SizedBox(height: 14), + // Shutter + Container( + width: 62, + height: 62, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: const Color(0xD9FFFFFF), width: 4), + ), + child: Center( + child: Container( + width: 26, + height: 26, + decoration: BoxDecoration(color: Glass.rec, borderRadius: BorderRadius.circular(7)), + ), + ), + ), + const SizedBox(height: 14), + // Mode switch + Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration(color: Glass.pill, borderRadius: BorderRadius.circular(10)), + child: Column( + children: [ + _modeBtn(Icons.photo_outlined, 'Photo'), + _modeBtn(Icons.videocam_outlined, 'Video'), + _modeBtn(Icons.panorama_outlined, 'Pano'), + ], + ), + ), + ], + ); + } + + Widget _modeBtn(IconData icon, String mode) { + final bool active = _mode == mode; + return GestureDetector( + onTap: () => setState(() => _mode = mode), + child: Container( + width: 40, + height: 30, + margin: const EdgeInsets.symmetric(vertical: 1), + decoration: BoxDecoration( + color: active ? Glass.accent : Colors.transparent, + borderRadius: BorderRadius.circular(7), + ), + child: Icon(icon, size: 17, color: Glass.ink), + ), + ); + } + + Widget _minimap() { + return Container( + width: 148, + height: 78, + decoration: BoxDecoration( + color: Glass.pillStrong, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Glass.hairline), + ), + child: Stack( + children: [ + const Positioned.fill(child: CustomPaint(painter: _MinimapPainter())), + const Positioned( + top: 6, + left: 8, + child: Row(children: [ + Icon(Icons.home_outlined, size: 12, color: Glass.ink), + SizedBox(width: 5), + Text('RTH 340m', style: TextStyle(fontFamily: PV.fontMono, fontSize: 10, color: Glass.ink)), + ]), + ), + ], + ), + ); + } + + Widget _telemetry(FlightModel m) { + final List<(String, String, String)> fields = <(String, String, String)>[ + ('H', m.altitude == null ? '—' : m.altitude!.toStringAsFixed(1), 'm'), + ('D', '—', 'm'), + ('H.S', '—', 'm/s'), + ('V.S', '—', 'm/s'), + ]; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 8), + decoration: BoxDecoration(color: Glass.pill, borderRadius: BorderRadius.circular(12)), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (int i = 0; i < fields.length; i++) ...[ + if (i > 0) const SizedBox(width: 20), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(fields[i].$1, style: const TextStyle(fontFamily: PV.fontMono, fontSize: 10, letterSpacing: 0.8, color: Color(0x99EAF0FA))), + const SizedBox(height: 2), + Text.rich(TextSpan(children: [ + TextSpan(text: fields[i].$2, style: const TextStyle(fontFamily: PV.fontMono, fontSize: 15, fontWeight: FontWeight.w700, color: Glass.ink)), + TextSpan(text: ' ${fields[i].$3}', style: const TextStyle(fontFamily: PV.fontMono, fontSize: 10, color: Color(0x99EAF0FA))), + ])), + ], + ), + ], + ], + ), + ); + } + + Widget _rthButton() { + return Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: Glass.pillStrong, + shape: BoxShape.circle, + border: Border.all(color: const Color(0x2EFFFFFF)), + ), + child: const Icon(Icons.home_outlined, size: 20, color: Glass.ink), + ); + } +} + +class _Thumb extends StatelessWidget { + const _Thumb(); + @override + Widget build(BuildContext context) { + return Container( + width: 16, + height: 16, + decoration: const BoxDecoration( + color: Glass.ink, + shape: BoxShape.circle, + boxShadow: [BoxShadow(color: Color(0x80000000), blurRadius: 3, offset: Offset(0, 1))], + ), + ); + } +} + +/// Painted placeholder camera feed: graded sky→ground, perspective grid, haze, +/// distant buildings, and a yellow tracked-subject bracket. +class _FeedPainter extends CustomPainter { + const _FeedPainter(); + + @override + void paint(Canvas canvas, Size size) { + final double w = size.width, h = size.height; + final double horizon = h * 0.52; + + // Sky → ground gradient. + final Rect full = Offset.zero & size; + final Paint sky = Paint() + ..shader = const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF2A4E86), Color(0xFF3E6199), Color(0xFF1C2A1E), Color(0xFF0E1710)], + stops: [0.0, 0.51, 0.54, 1.0], + ).createShader(full); + canvas.drawRect(full, sky); + + // Horizon haze. + final Paint haze = Paint() + ..shader = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [const Color(0x808FB4D8), const Color(0x008FB4D8)], + ).createShader(Rect.fromLTWH(0, horizon - 30, w, 60)); + canvas.drawRect(Rect.fromLTWH(0, horizon - 30, w, 60), haze); + + // Distant buildings just under the horizon. + final Paint bld = Paint()..color = const Color(0xE612201A); + void building(double x, double y, double bw, double bh) => canvas.drawRect(Rect.fromLTWH(x * w, horizon + y, bw, bh), bld); + building(0.10, -40, 46, 40); + building(0.17, -52, 30, 52); + building(0.74, -46, 54, 46); + building(0.83, -34, 34, 34); + + // Perspective ground grid. + final Paint grid = Paint() + ..color = const Color(0x297FE0B0) + ..strokeWidth = 1; + for (int i = 1; i <= 5; i++) { + final double t = i / 5.0; + final double y = horizon + (h - horizon) * t * t; + canvas.drawLine(Offset(0, y), Offset(w, y), grid); + } + final double vx = w / 2; + for (int k = -6; k <= 6; k += 2) { + final double bx = vx + k * (w * 0.16); + canvas.drawLine(Offset(vx + k * 10, horizon), Offset(bx, h), grid); + } + + // Tracked-subject bracket, centered. + final Paint subj = Paint() + ..color = Glass.subject + ..style = PaintingStyle.stroke + ..strokeWidth = 2; + final double bxw = 118, bxh = 82; + final Rect box = Rect.fromCenter(center: Offset(vx, horizon + 8), width: bxw, height: bxh); + const double c = 14; + // Four corner brackets. + canvas.drawPath(Path()..moveTo(box.left + c, box.top)..lineTo(box.left, box.top)..lineTo(box.left, box.top + c), subj); + canvas.drawPath(Path()..moveTo(box.right - c, box.top)..lineTo(box.right, box.top)..lineTo(box.right, box.top + c), subj); + canvas.drawPath(Path()..moveTo(box.left + c, box.bottom)..lineTo(box.left, box.bottom)..lineTo(box.left, box.bottom - c), subj); + canvas.drawPath(Path()..moveTo(box.right - c, box.bottom)..lineTo(box.right, box.bottom)..lineTo(box.right, box.bottom - c), subj); + } + + @override + bool shouldRepaint(covariant _FeedPainter oldDelegate) => false; +} + +class _MinimapPainter extends CustomPainter { + const _MinimapPainter(); + + @override + void paint(Canvas canvas, Size size) { + final Path route = Path() + ..moveTo(20, 60) + ..cubicTo(50, 40, 70, 30, 120, 24); + final Paint line = Paint() + ..color = const Color(0xFF5B93F5) + ..style = PaintingStyle.stroke + ..strokeWidth = 2; + canvas.drawPath(route, line); + canvas.drawCircle(const Offset(20, 60), 4, Paint()..color = Glass.sat); + canvas.drawCircle(const Offset(120, 24), 4, Paint()..color = const Color(0xFF5B93F5)); + } + + @override + bool shouldRepaint(covariant _MinimapPainter oldDelegate) => false; +} diff --git a/Fly App/lib/ui/go_fly_page.dart b/Fly App/lib/ui/go_fly_page.dart new file mode 100644 index 0000000..65d0025 --- /dev/null +++ b/Fly App/lib/ui/go_fly_page.dart @@ -0,0 +1,292 @@ +import 'package:flutter/material.dart'; + +import '../flight_model.dart'; +import '../theme.dart'; +import '../uploader.dart'; + +/// Portrait launch screen — mirrors the ui_kit/fly "Go Fly · launch" mockup: +/// brand header, aircraft connection card, big GO FLY, and a 2×2 tile grid. +class GoFlyPage extends StatelessWidget { + const GoFlyPage({ + super.key, + required this.model, + required this.onGoFly, + required this.onOpenAlbum, + required this.onSettings, + required this.onTile, + }); + + final FlightModel model; + final VoidCallback onGoFly; + final VoidCallback onOpenAlbum; + final VoidCallback onSettings; + final void Function(String tile) onTile; + + @override + Widget build(BuildContext context) { + final PVScheme s = PVScheme.of(context); + return Scaffold( + backgroundColor: s.bgApp, + body: SafeArea( + child: AnimatedBuilder( + animation: model, + builder: (BuildContext context, _) { + return Column( + children: [ + _header(s), + Expanded( + child: Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: _connectionCard(s), + ), + ), + ), + Padding( + // No bottom gap here: the design pins GO FLY directly above the + // tiles' 18px top pad, which centers the card 4px lower to match. + padding: const EdgeInsets.fromLTRB(20, 0, 20, 0), + child: _goFlyButton(s), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 18, 20, 28), + child: _tiles(s), + ), + ], + ); + }, + ), + ), + ); + } + + Widget _header(PVScheme s) { + return Padding( + padding: const EdgeInsets.fromLTRB(20, 10, 20, 0), + child: Row( + children: [ + const PvBrandMark(size: 22), + const SizedBox(width: 8), + Text.rich( + TextSpan(children: [ + TextSpan( + text: 'Pilot', + style: TextStyle(fontFamily: PV.fontSans, fontSize: 19, fontWeight: FontWeight.w500, letterSpacing: -0.38, color: s.textSecondary), + ), + TextSpan( + text: 'Vault', + style: TextStyle(fontFamily: PV.fontSans, fontSize: 19, fontWeight: FontWeight.w700, letterSpacing: -0.38, color: s.textPrimary), + ), + TextSpan( + text: ' Fly', + style: TextStyle(fontFamily: PV.fontSans, fontSize: 19, fontWeight: FontWeight.w500, letterSpacing: -0.38, color: s.accent), + ), + ]), + ), + const Spacer(), + GestureDetector( + onTap: onSettings, + child: Container( + width: 32, + height: 32, + decoration: BoxDecoration(color: s.surfaceInset, shape: BoxShape.circle), + child: Icon(Icons.person_outline, size: 17, color: s.textSecondary), + ), + ), + ], + ), + ); + } + + Widget _connectionCard(PVScheme s) { + final bool connected = model.connected; + final Color dot = connected ? s.success : s.textTertiary; + final Color statusFg = connected ? s.successFg : s.textTertiary; + final String statusText = connected ? 'CONNECTED' : 'DISCONNECTED'; + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: s.surface, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: s.border), + boxShadow: s.shadowSm, + ), + child: Column( + mainAxisSize: MainAxisSize.min, // size to content; Center handles vertical placement + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container(width: 7, height: 7, decoration: BoxDecoration(color: dot, shape: BoxShape.circle)), + const SizedBox(width: 6), + Text( + statusText, + style: TextStyle(fontFamily: PV.fontMono, fontSize: 11, letterSpacing: 1.1, fontWeight: FontWeight.w700, color: statusFg), + ), + ], + ), + const SizedBox(height: 14), + Row( + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration(color: s.accentSoft, borderRadius: BorderRadius.circular(14)), + child: Icon(Icons.flight, size: 28, color: s.accent), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + connected ? (model.model ?? 'Aircraft') : 'No aircraft', + style: TextStyle(fontFamily: PV.fontSans, fontSize: 18, fontWeight: FontWeight.w700, color: s.textPrimary), + ), + const SizedBox(height: 2), + Text( + 'MSDK · ${model.sdkVersion}', + style: TextStyle(fontFamily: PV.fontMono, fontSize: 12, color: s.textSecondary), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 14), + Row( + children: [ + _chip(s, Icons.battery_full, model.batteryPercent == null ? '—' : '${model.batteryPercent}%'), + const SizedBox(width: 8), + _chip(s, Icons.satellite_alt, model.satellites == null ? '— sats' : '${model.satellites} sats'), + const SizedBox(width: 8), + _chip(s, Icons.link, _linkLabel(model.upload, connected)), + ], + ), + ], + ), + ); + } + + String _linkLabel(UploadStatus u, bool connected) { + switch (u) { + case UploadStatus.connected: + return 'Streaming'; + case UploadStatus.connecting: + return 'Linking…'; + case UploadStatus.error: + return 'Retrying'; + case UploadStatus.disabled: + return connected ? 'Linked' : 'Off'; + } + } + + Widget _chip(PVScheme s, IconData icon, String value) { + return Expanded( + child: Container( + height: 34, + decoration: BoxDecoration(color: s.surfaceInset, borderRadius: BorderRadius.circular(9)), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 14, color: s.textTertiary), + const SizedBox(width: 6), + Flexible( + child: Text( + value, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontFamily: PV.fontMono, fontSize: 11.5, color: s.textSecondary), + ), + ), + ], + ), + ), + ); + } + + Widget _goFlyButton(PVScheme s) { + return SizedBox( + height: 58, + child: FilledButton( + onPressed: onGoFly, + style: FilledButton.styleFrom( + backgroundColor: s.accent, + foregroundColor: Colors.white, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: const [ + Icon(Icons.play_arrow_rounded, size: 22), + SizedBox(width: 10), + Text('GO FLY', style: TextStyle(fontFamily: PV.fontSans, fontSize: 18, fontWeight: FontWeight.w700, letterSpacing: 0.4)), + ], + ), + ), + ); + } + + Widget _tiles(PVScheme s) { + const List<(IconData, String)> tiles = <(IconData, String)>[ + (Icons.photo_library_outlined, 'Album'), + (Icons.school_outlined, 'Academy'), + (Icons.route_outlined, 'Routes'), + (Icons.speed, 'Flight logs'), + ]; + return Column( + children: [ + Row(children: [ + Expanded(child: _tile(s, tiles[0])), + const SizedBox(width: 12), + Expanded(child: _tile(s, tiles[1])), + ]), + const SizedBox(height: 12), + Row(children: [ + Expanded(child: _tile(s, tiles[2])), + const SizedBox(width: 12), + Expanded(child: _tile(s, tiles[3])), + ]), + ], + ); + } + + Widget _tile(PVScheme s, (IconData, String) t) { + return Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(14), + onTap: () => t.$2 == 'Album' ? onOpenAlbum() : onTile(t.$2), + child: Container( + height: 56, + padding: const EdgeInsets.symmetric(horizontal: 14), + decoration: BoxDecoration( + color: s.surface, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: s.border), + boxShadow: s.shadowXs, + ), + child: Row( + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration(color: s.accentSoft, borderRadius: BorderRadius.circular(9)), + child: Icon(t.$1, size: 17, color: s.accentSoftFg), + ), + const SizedBox(width: 10), + Flexible( + child: Text( + t.$2, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontFamily: PV.fontSans, fontSize: 14, fontWeight: FontWeight.w600, color: s.textPrimary), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/Fly App/lib/uploader.dart b/Fly App/lib/uploader.dart new file mode 100644 index 0000000..6b96479 --- /dev/null +++ b/Fly App/lib/uploader.dart @@ -0,0 +1,155 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +enum UploadStatus { disabled, connecting, connected, error } + +/// Streams DJI events to the API Server over a WebSocket and dispatches +/// commands the server pushes back. Auto-reconnects while enabled. +/// +/// Wire format matches the server's `/ws/device` endpoint: +/// * outbound: the raw DJI event maps (registration/connection/battery/telemetry) +/// * inbound : `{"type":"command","command":"...","payload":{...}}` +class ServerUploader { + ServerUploader({required this.onCommand, this.snapshotProvider}); + + /// Invoked when the server pushes a command frame. + final void Function(String command, Map payload) onCommand; + + /// Returns the app's last-known events (registration/connection/battery/ + /// telemetry) to replay right after (re)connecting, so the server reflects + /// current state even though those events fired in the past. + final List> Function()? snapshotProvider; + + /// High-frequency telemetry is throttled to this interval; other event + /// types (battery, connection, registration) are sent immediately. + Duration telemetryInterval = const Duration(milliseconds: 250); + + WebSocket? _socket; + bool _enabled = false; + String _host = ''; + String _deviceId = 'phone'; + Timer? _reconnectTimer; + DateTime _lastTelemetrySent = DateTime.fromMillisecondsSinceEpoch(0); + + final StreamController _statusController = + StreamController.broadcast(); + Stream get status => _statusController.stream; + + UploadStatus _status = UploadStatus.disabled; + UploadStatus get currentStatus => _status; + + void _setStatus(UploadStatus s) { + _status = s; + if (!_statusController.isClosed) _statusController.add(s); + } + + /// host accepts "10.0.0.5", "10.0.0.5:8080", or "ws://10.0.0.5:8080". + Future enable(String host, {String deviceId = 'phone'}) async { + _host = host.trim(); + _deviceId = deviceId.trim().isEmpty ? 'phone' : deviceId.trim(); + _enabled = true; + await _connect(); + } + + Future disable() async { + _enabled = false; + _reconnectTimer?.cancel(); + await _socket?.close(); + _socket = null; + _setStatus(UploadStatus.disabled); + } + + Future _connect() async { + if (!_enabled) return; + _setStatus(UploadStatus.connecting); + try { + final Uri uri = _buildUri(_host, _deviceId); + final WebSocket sock = + await WebSocket.connect(uri.toString()).timeout(const Duration(seconds: 6)); + _socket = sock; + _setStatus(UploadStatus.connected); + // Replay last-known state so the server is immediately in sync. + final List> snapshot = snapshotProvider?.call() ?? const >[]; + for (final Map e in snapshot) { + try { + sock.add(jsonEncode(e)); + } catch (_) {} + } + sock.listen( + _onData, + onDone: _onDisconnect, + onError: (Object _) => _onDisconnect(), + cancelOnError: true, + ); + } catch (_) { + _socket = null; + _setStatus(UploadStatus.error); + _scheduleReconnect(); + } + } + + Uri _buildUri(String host, String id) { + String h = host; + if (h.startsWith('ws://')) h = h.substring(5); + if (h.startsWith('wss://')) h = h.substring(6); + final int slash = h.indexOf('/'); + if (slash >= 0) h = h.substring(0, slash); + if (!h.contains(':')) h = '$h:8080'; + return Uri.parse('ws://$h/ws/device?id=${Uri.encodeQueryComponent(id)}'); + } + + void _onData(dynamic data) { + if (data is! String) return; + try { + final Map msg = + (jsonDecode(data) as Map).cast(); + if (msg['type'] == 'command') { + final String? cmd = msg['command'] as String?; + final Map payload = + (msg['payload'] as Map?)?.cast() ?? {}; + if (cmd != null) onCommand(cmd, payload); + } + } catch (_) { + // ignore malformed frames + } + } + + void _onDisconnect() { + _socket = null; + if (_enabled) { + _setStatus(UploadStatus.error); + _scheduleReconnect(); + } + } + + void _scheduleReconnect() { + _reconnectTimer?.cancel(); + if (!_enabled) return; + _reconnectTimer = Timer(const Duration(seconds: 3), _connect); + } + + /// Feed an event coming from `DjiService.events()`. + void onEvent(Map event) { + final WebSocket? sock = _socket; + if (sock == null || _status != UploadStatus.connected) return; + + if (event['type'] == 'telemetry') { + final DateTime now = DateTime.now(); + if (now.difference(_lastTelemetrySent) < telemetryInterval) return; + _lastTelemetrySent = now; + } + try { + sock.add(jsonEncode(event)); + } catch (_) { + // drop on transient write failure; reconnect logic handles the rest + } + } + + void dispose() { + _enabled = false; + _reconnectTimer?.cancel(); + _socket?.close(); + _statusController.close(); + } +} diff --git a/Fly App/pubspec.lock b/Fly App/pubspec.lock new file mode 100644 index 0000000..6cd8ef2 --- /dev/null +++ b/Fly App/pubspec.lock @@ -0,0 +1,490 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + archive: + dependency: transitive + description: + name: archive + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + url: "https://pub.dev" + source: hosted + version: "4.0.9" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + url: "https://pub.dev" + source: hosted + version: "2.11.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.dev" + source: hosted + version: "0.4.2" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" + source: hosted + version: "1.0.8" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_launcher_icons: + dependency: "direct dev" + description: + name: flutter_launcher_icons + sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7" + url: "https://pub.dev" + source: hosted + version: "0.14.4" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785" + url: "https://pub.dev" + source: hosted + version: "2.0.35" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + image: + dependency: transitive + description: + name: image + sha256: "6300175e00616bbc832e2fc91bfa4d776af5402c81c7151bee6905bb08473c52" + url: "https://pub.dev" + source: hosted + version: "4.9.1" + intl: + dependency: transitive + description: + name: intl + sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" + url: "https://pub.dev" + source: hosted + version: "0.20.3" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" + url: "https://pub.dev" + source: hosted + version: "4.12.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 + url: "https://pub.dev" + source: hosted + version: "5.1.1" + local_auth: + dependency: "direct main" + description: + name: local_auth + sha256: "434d854cf478f17f12ab29a76a02b3067f86a63a6d6c4eb8fbfdcfe4879c1b7b" + url: "https://pub.dev" + source: hosted + version: "2.3.0" + local_auth_android: + dependency: transitive + description: + name: local_auth_android + sha256: a0bdfcc0607050a26ef5b31d6b4b254581c3d3ce3c1816ab4d4f4a9173e84467 + url: "https://pub.dev" + source: hosted + version: "1.0.56" + local_auth_darwin: + dependency: transitive + description: + name: local_auth_darwin + sha256: "699873970067a40ef2f2c09b4c72eb1cfef64224ef041b3df9fdc5c4c1f91f49" + url: "https://pub.dev" + source: hosted + version: "1.6.1" + local_auth_platform_interface: + dependency: transitive + description: + name: local_auth_platform_interface + sha256: f98b8e388588583d3f781f6806e4f4c9f9e189d898d27f0c249b93a1973dd122 + url: "https://pub.dev" + source: hosted + version: "1.1.0" + local_auth_windows: + dependency: transitive + description: + name: local_auth_windows + sha256: bc4e66a29b0fdf751aafbec923b5bed7ad6ed3614875d8151afe2578520b2ab5 + url: "https://pub.dev" + source: hosted + version: "1.0.11" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + url: "https://pub.dev" + source: hosted + version: "1.18.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + posix: + dependency: transitive + description: + name: posix + sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" + url: "https://pub.dev" + source: hosted + version: "6.5.0" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" + url: "https://pub.dev" + source: hosted + version: "2.5.3" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "5bcf0772a761b04f8c6bf814721713de6f3e5d9d89caf8d3fe031b02a342379e" + url: "https://pub.dev" + source: hosted + version: "2.4.11" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + url: "https://pub.dev" + source: hosted + version: "1.10.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + test_api: + dependency: transitive + description: + name: test_api + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + url: "https://pub.dev" + source: hosted + version: "0.7.11" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: f6be3ed8bd01289b34d679c2b62226f63c0e69f9fd2e50a6b3c1c729a961041b + url: "https://pub.dev" + source: hosted + version: "14.3.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4" + url: "https://pub.dev" + source: hosted + version: "7.0.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.11.0 <4.0.0" + flutter: ">=3.38.0" diff --git a/Fly App/pubspec.yaml b/Fly App/pubspec.yaml new file mode 100644 index 0000000..cb887ef --- /dev/null +++ b/Fly App/pubspec.yaml @@ -0,0 +1,120 @@ +name: dji_msdk_sample +description: "PilotVault Fly App — drone telemetry client built with Flutter (DJI Mobile SDK V4)" +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+1 + +environment: + sdk: ^3.6.2 + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.8 + + # Auth goes through the API Server (no PocketBase SDK needed). + # Persists the auth token + API server URL across app launches. + shared_preferences: ^2.3.2 + + # Device biometric / face authentication for quick re-login (local_auth uses + # the platform BiometricPrompt). Requires FlutterFragmentActivity on Android. + local_auth: ^2.3.0 + +dev_dependencies: + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^5.0.0 + + # Generates the PilotVault launcher icon into the Android mipmaps. + flutter_launcher_icons: ^0.14.4 + +# Launcher-icon generation. Run `dart run flutter_launcher_icons` after changes. +flutter_launcher_icons: + android: true + ios: false + image_path: "assets/icon/app_icon.png" + min_sdk_android: 21 + adaptive_icon_background: "#0F1E3D" + adaptive_icon_foreground: "assets/icon/app_icon.png" + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # PilotVault brand fonts (bundled from Google Fonts, OFL). + fonts: + - family: Space Grotesk + fonts: + - asset: assets/fonts/SpaceGrotesk-VariableFont_wght.ttf + - family: Space Mono + fonts: + - asset: assets/fonts/SpaceMono-Regular.ttf + - asset: assets/fonts/SpaceMono-Bold.ttf + weight: 700 + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package diff --git a/Fly App/test/widget_test.dart b/Fly App/test/widget_test.dart new file mode 100644 index 0000000..99884a8 --- /dev/null +++ b/Fly App/test/widget_test.dart @@ -0,0 +1,13 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:dji_msdk_sample/main.dart'; + +void main() { + testWidgets('Shows the login screen when signed out', + (WidgetTester tester) async { + await tester.pumpWidget(const DjiSampleApp()); + + expect(find.text('Sign in'), findsWidgets); + expect(find.text('Sign in to continue'), findsOneWidget); + }); +} diff --git a/Web App/.dockerignore b/Web App/.dockerignore new file mode 100644 index 0000000..5f6bce9 --- /dev/null +++ b/Web App/.dockerignore @@ -0,0 +1,15 @@ +# Build artifacts — never send to the build context. +*.exe +*.exe~ +server/web-app +/tmp/ +*.log + +# Rebuilt inside the image. +web/node_modules/ +server/dist/ + +# Repo noise. +.git/ +.gitignore +README.md diff --git a/Web App/.gitignore b/Web App/.gitignore new file mode 100644 index 0000000..f576b80 --- /dev/null +++ b/Web App/.gitignore @@ -0,0 +1,9 @@ +server/.env +server/web-app +server/web-app.exe +server/webapp.exe +*.exe +*.exe~ +/tmp/ +*.log +web/node_modules/ diff --git a/Web App/Dockerfile b/Web App/Dockerfile new file mode 100644 index 0000000..9969f8e --- /dev/null +++ b/Web App/Dockerfile @@ -0,0 +1,35 @@ +# syntax=docker/dockerfile:1 + +# ---- Stage 1: build the embedded Vue web app ---- +# vite.config.js writes the build to ../server/dist, i.e. /server/dist here, +# which the Go binary embeds via //go:embed all:dist in server/main.go. +FROM node:22-alpine AS web +WORKDIR /web +COPY web/package.json web/package-lock.json ./ +RUN npm ci +COPY web/ ./ +RUN npm run build + +# ---- Stage 2: build the static Go binary (web app embedded) ---- +FROM golang:1.24-alpine AS build +WORKDIR /src +COPY server/go.mod server/go.sum ./ +RUN go mod download +COPY server/ ./ +# Overlay the freshly built web app so //go:embed all:dist picks it up. +COPY --from=web /server/dist ./dist +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" \ + -o /out/web-app . + +# ---- Stage 3: minimal runtime ---- +FROM alpine:latest +RUN apk add --no-cache ca-certificates tzdata \ + && adduser -D -u 10001 app +WORKDIR /app +COPY --from=build /out/web-app ./web-app +USER app +# ADDR is the listen address; API_BASE is the API Server this BFF proxies to. +ENV ADDR=:8090 \ + API_BASE=http://localhost:8080 +EXPOSE 8090 +ENTRYPOINT ["/app/web-app"] diff --git a/Web App/README.md b/Web App/README.md new file mode 100644 index 0000000..40359c5 --- /dev/null +++ b/Web App/README.md @@ -0,0 +1,51 @@ +# Web App (Control Panel) + +The PilotVault control-panel BFF. It serves the embedded Vue single-page app and +proxies `/bff/*` to the API Server, so the browser only ever talks to this +server (same-origin). The API Server is the gateway to PocketBase and to the live +Fly App data; the browser never contacts either directly. + +## Layout + +``` +server/ # Go BFF (package main) — proxies /bff/* and serves the SPA + dist/ # built web assets (go:embed target — produced by the web build) + .env.example # configuration template + go.mod + main.go # entrypoint, routing, static serving + bff.go # /bff/* proxy handlers +web/ # Vue 3 + Tailwind source, built by Vite into ../server/dist +``` + +## Configuration + +See [server/.env.example](server/.env.example). Key variables: + +- `ADDR` — address the BFF listens on (default `:8090`). +- `API_BASE` — default API Server the BFF proxies to (default `http://localhost:8080`). + Users can override this per-session at login. + +## Develop + +```sh +# 1. Build the web app into server/dist (embedded by the Go binary) +cd web && npm install && npm run build && cd .. + +# 2. Run the BFF +cd server && go run . +``` + +For a live UI with hot-reload, run `npm run dev` in `web/` (it proxies `/bff` to +a locally running BFF on `:8090`). + +## Build + +```sh +cd server && go build -o web-app . +``` + +Or build the container image (multi-stage; builds the web app then the binary): + +```sh +docker build -t pilotvault-web-app . +``` diff --git a/Web App/docker-compose.yml b/Web App/docker-compose.yml new file mode 100644 index 0000000..65c1f3f --- /dev/null +++ b/Web App/docker-compose.yml @@ -0,0 +1,16 @@ +services: + web-app: + build: . + image: pilotvault-web-app + container_name: pilotvault-web-app + environment: + ADDR: ":8090" + # BFF target. host.docker.internal reaches the API Server published on the + # host (e.g. by API Server/docker-compose.yml or a local api-server.exe). + API_BASE: "http://host.docker.internal:8080" + extra_hosts: + # Makes host.docker.internal resolve on Linux too (already works on Desktop). + - "host.docker.internal:host-gateway" + ports: + - "8090:8090" + restart: unless-stopped diff --git a/Web App/server/.env.example b/Web App/server/.env.example new file mode 100644 index 0000000..eb35281 --- /dev/null +++ b/Web App/server/.env.example @@ -0,0 +1,10 @@ +# Web App (control-panel BFF) configuration +# Copy to .env and adjust. The server also reads plain environment variables. + +# Address the Web App BFF listens on +ADDR=:8090 + +# Default API Server this BFF proxies to (no trailing slash). The browser never +# contacts the API Server directly; every request flows through this BFF. A user +# may override this per-session at login, which is remembered in a cookie. +API_BASE=http://localhost:8080 diff --git a/Web App/server/Run-WebApp.ps1 b/Web App/server/Run-WebApp.ps1 new file mode 100644 index 0000000..1d530ab --- /dev/null +++ b/Web App/server/Run-WebApp.ps1 @@ -0,0 +1,29 @@ +# Builds the Vue frontend (if needed) and runs the Web App BFF. +# +# ./Run-WebApp.ps1 # build frontend then serve +# ./Run-WebApp.ps1 -SkipBuild # serve existing dist only + +param([switch]$SkipBuild) + +$ErrorActionPreference = "Stop" +$serverDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$root = Split-Path -Parent $serverDir # the "Web App" folder +$webDir = Join-Path $root "web" + +$goBin = "C:\Program Files\Go\bin" +if (Test-Path $goBin) { $env:Path = "$goBin;$env:Path" } + +if (-not $SkipBuild) { + Write-Host "Building frontend..." -ForegroundColor Cyan + Push-Location $webDir + try { + if (-not (Test-Path "node_modules")) { npm install } + npm run build + } finally { Pop-Location } +} + +Push-Location $serverDir +try { + Write-Host "Starting Web App on :8090 (Ctrl+C to stop)..." -ForegroundColor Cyan + go run . +} finally { Pop-Location } diff --git a/Web App/server/bff.go b/Web App/server/bff.go new file mode 100644 index 0000000..9ed923a --- /dev/null +++ b/Web App/server/bff.go @@ -0,0 +1,453 @@ +package main + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/gorilla/websocket" +) + +const ( + cookieToken = "dji_token" + cookieEmail = "dji_email" + cookieApi = "dji_api" +) + +var client = &http.Client{Timeout: 15 * time.Second} + +// apiBaseFor returns the API Server base for this request: the per-session value +// chosen at login (cookie), falling back to the server's configured default. +func (a *App) apiBaseFor(r *http.Request) string { + if c, err := r.Cookie(cookieApi); err == nil && c.Value != "" { + return c.Value + } + return a.apiBase +} + +func normalizeURL(u string) string { + u = strings.TrimSpace(u) + if u == "" { + return "" + } + if !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") { + u = "http://" + u + } + return strings.TrimRight(u, "/") +} + +var upgrader = websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + CheckOrigin: func(r *http.Request) bool { return true }, +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +// requireAuth gates a handler on the presence of a session cookie. +func (a *App) requireAuth(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if _, err := r.Cookie(cookieToken); err != nil { + writeJSON(w, http.StatusUnauthorized, map[string]any{"error": "not signed in"}) + return + } + next(w, r) + } +} + +// POST /bff/login {"email","password"} → proxies to API Server /api/auth/login. +func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) { + var body struct { + Email string `json:"email"` + Password string `json:"password"` + ApiBase string `json:"apiBase"` // optional API Server override (per session) + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid json"}) + return + } + + base := a.apiBase + if b := normalizeURL(body.ApiBase); b != "" { + base = b + } + + payload, _ := json.Marshal(map[string]string{"email": body.Email, "password": body.Password}) + resp, err := client.Post(base+"/api/auth/login", "application/json", bytes.NewReader(payload)) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach API server"}) + return + } + defer resp.Body.Close() + data, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != http.StatusOK { + // Relay PocketBase's error (e.g. invalid credentials). + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(resp.StatusCode) + _, _ = w.Write(data) + return + } + + var auth struct { + Token string `json:"token"` + Record struct { + Email string `json:"email"` + } `json:"record"` + } + if err := json.Unmarshal(data, &auth); err != nil || auth.Token == "" { + writeJSON(w, http.StatusBadGateway, map[string]any{"error": "unexpected auth response"}) + return + } + + setCookie(w, cookieToken, auth.Token, true) + setCookie(w, cookieEmail, auth.Record.Email, false) + setCookie(w, cookieApi, base, true) // remember the chosen API Server for this session + writeJSON(w, http.StatusOK, map[string]any{"email": auth.Record.Email}) +} + +// GET /bff/config → the server's default API Server address (to prefill the field). +func (a *App) handleConfig(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{"apiBase": a.apiBase}) +} + +// POST /bff/logout +func (a *App) handleLogout(w http.ResponseWriter, r *http.Request) { + clearCookie(w, cookieToken) + clearCookie(w, cookieEmail) + clearCookie(w, cookieApi) + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +// GET /bff/me → current session info incl. role (or 401). Proxies to the API +// Server /api/me so the role is always fresh (reflects admin promotions). +func (a *App) handleMe(w http.ResponseWriter, r *http.Request) { + token := tokenOf(r) + if token == "" { + writeJSON(w, http.StatusUnauthorized, map[string]any{"error": "not signed in"}) + return + } + req, _ := http.NewRequest(http.MethodGet, a.apiBaseFor(r)+"/api/me", nil) + req.Header.Set("Authorization", token) + resp, err := client.Do(req) + if err != nil { + // Fall back to the cookie email so the session survives a brief API blip. + email := "" + if c, err := r.Cookie(cookieEmail); err == nil { + email = c.Value + } + writeJSON(w, http.StatusOK, map[string]any{"email": email, "role": "user"}) + return + } + defer resp.Body.Close() + relay(w, resp) +} + +// GET /bff/devices → API Server /api/devices +func (a *App) handleDevices(w http.ResponseWriter, r *http.Request) { + a.proxyGET(w, a.apiBaseFor(r)+"/api/devices") +} + +// GET /bff/devices/{id}/track → API Server /api/devices/{id}/track +func (a *App) handleTrack(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + a.proxyGET(w, a.apiBaseFor(r)+"/api/devices/"+id+"/track") +} + +// POST /bff/devices/{id}/command → API Server /api/devices/{id}/command +func (a *App) handleCommand(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + body, _ := io.ReadAll(r.Body) + resp, err := client.Post(a.apiBaseFor(r)+"/api/devices/"+id+"/command", "application/json", bytes.NewReader(body)) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach API server"}) + return + } + defer resp.Body.Close() + relay(w, resp) +} + +// tokenOf returns the PocketBase auth token stored in the session cookie. +func tokenOf(r *http.Request) string { + if c, err := r.Cookie(cookieToken); err == nil { + return c.Value + } + return "" +} + +// GET /bff/preferences → API Server /api/preferences (Authorization: session token) +func (a *App) handleGetPrefs(w http.ResponseWriter, r *http.Request) { + req, _ := http.NewRequest(http.MethodGet, a.apiBaseFor(r)+"/api/preferences", nil) + req.Header.Set("Authorization", tokenOf(r)) + resp, err := client.Do(req) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach API server"}) + return + } + defer resp.Body.Close() + relay(w, resp) +} + +// PUT /bff/preferences → API Server /api/preferences (Authorization: session token) +func (a *App) handlePutPrefs(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + req, _ := http.NewRequest(http.MethodPut, a.apiBaseFor(r)+"/api/preferences", bytes.NewReader(body)) + req.Header.Set("Authorization", tokenOf(r)) + req.Header.Set("Content-Type", "application/json") + resp, err := client.Do(req) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach API server"}) + return + } + defer resp.Body.Close() + relay(w, resp) +} + +// GET /bff/integrations/opensky → API Server /api/integrations/opensky +func (a *App) handleGetOpenSky(w http.ResponseWriter, r *http.Request) { + req, _ := http.NewRequest(http.MethodGet, a.apiBaseFor(r)+"/api/integrations/opensky", nil) + req.Header.Set("Authorization", tokenOf(r)) + a.doRelay(w, req) +} + +// PUT /bff/integrations/opensky → API Server /api/integrations/opensky +func (a *App) handlePutOpenSky(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + req, _ := http.NewRequest(http.MethodPut, a.apiBaseFor(r)+"/api/integrations/opensky", bytes.NewReader(body)) + req.Header.Set("Authorization", tokenOf(r)) + req.Header.Set("Content-Type", "application/json") + a.doRelay(w, req) +} + +// POST /bff/integrations/opensky/health → API Server /api/integrations/opensky/health +func (a *App) handleOpenSkyHealth(w http.ResponseWriter, r *http.Request) { + req, _ := http.NewRequest(http.MethodPost, a.apiBaseFor(r)+"/api/integrations/opensky/health", nil) + req.Header.Set("Authorization", tokenOf(r)) + a.doRelay(w, req) +} + +// GET /bff/integrations/filetransfer → API Server /api/integrations/filetransfer +func (a *App) handleGetFileTransfer(w http.ResponseWriter, r *http.Request) { + req, _ := http.NewRequest(http.MethodGet, a.apiBaseFor(r)+"/api/integrations/filetransfer", nil) + req.Header.Set("Authorization", tokenOf(r)) + a.doRelay(w, req) +} + +// PUT /bff/integrations/filetransfer → API Server /api/integrations/filetransfer +func (a *App) handlePutFileTransfer(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + req, _ := http.NewRequest(http.MethodPut, a.apiBaseFor(r)+"/api/integrations/filetransfer", bytes.NewReader(body)) + req.Header.Set("Authorization", tokenOf(r)) + req.Header.Set("Content-Type", "application/json") + a.doRelay(w, req) +} + +// POST /bff/integrations/filetransfer/health → API Server /api/integrations/filetransfer/health +func (a *App) handleFileTransferHealth(w http.ResponseWriter, r *http.Request) { + req, _ := http.NewRequest(http.MethodPost, a.apiBaseFor(r)+"/api/integrations/filetransfer/health", nil) + req.Header.Set("Authorization", tokenOf(r)) + a.doRelay(w, req) +} + +// GET /bff/integrations/localstorage → API Server /api/integrations/localstorage +func (a *App) handleGetLocalStorage(w http.ResponseWriter, r *http.Request) { + req, _ := http.NewRequest(http.MethodGet, a.apiBaseFor(r)+"/api/integrations/localstorage", nil) + req.Header.Set("Authorization", tokenOf(r)) + a.doRelay(w, req) +} + +// PUT /bff/integrations/localstorage → API Server /api/integrations/localstorage +func (a *App) handlePutLocalStorage(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + req, _ := http.NewRequest(http.MethodPut, a.apiBaseFor(r)+"/api/integrations/localstorage", bytes.NewReader(body)) + req.Header.Set("Authorization", tokenOf(r)) + req.Header.Set("Content-Type", "application/json") + a.doRelay(w, req) +} + +// POST /bff/integrations/localstorage/health → API Server /api/integrations/localstorage/health +func (a *App) handleLocalStorageHealth(w http.ResponseWriter, r *http.Request) { + req, _ := http.NewRequest(http.MethodPost, a.apiBaseFor(r)+"/api/integrations/localstorage/health", nil) + req.Header.Set("Authorization", tokenOf(r)) + a.doRelay(w, req) +} + +// GET /bff/integrations/webdav → API Server /api/integrations/webdav +func (a *App) handleGetWebDav(w http.ResponseWriter, r *http.Request) { + req, _ := http.NewRequest(http.MethodGet, a.apiBaseFor(r)+"/api/integrations/webdav", nil) + req.Header.Set("Authorization", tokenOf(r)) + a.doRelay(w, req) +} + +// PUT /bff/integrations/webdav → API Server /api/integrations/webdav +func (a *App) handlePutWebDav(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + req, _ := http.NewRequest(http.MethodPut, a.apiBaseFor(r)+"/api/integrations/webdav", bytes.NewReader(body)) + req.Header.Set("Authorization", tokenOf(r)) + req.Header.Set("Content-Type", "application/json") + a.doRelay(w, req) +} + +// POST /bff/integrations/webdav/health → API Server /api/integrations/webdav/health +func (a *App) handleWebDavHealth(w http.ResponseWriter, r *http.Request) { + req, _ := http.NewRequest(http.MethodPost, a.apiBaseFor(r)+"/api/integrations/webdav/health", nil) + req.Header.Set("Authorization", tokenOf(r)) + a.doRelay(w, req) +} + +// GET /bff/users → API Server /api/users (admin only, enforced upstream) +func (a *App) handleListUsers(w http.ResponseWriter, r *http.Request) { + req, _ := http.NewRequest(http.MethodGet, a.apiBaseFor(r)+"/api/users", nil) + req.Header.Set("Authorization", tokenOf(r)) + a.doRelay(w, req) +} + +// POST /bff/users → API Server /api/users +func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + req, _ := http.NewRequest(http.MethodPost, a.apiBaseFor(r)+"/api/users", bytes.NewReader(body)) + req.Header.Set("Authorization", tokenOf(r)) + req.Header.Set("Content-Type", "application/json") + a.doRelay(w, req) +} + +// PATCH /bff/users/{id} → API Server /api/users/{id} +func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + body, _ := io.ReadAll(r.Body) + req, _ := http.NewRequest(http.MethodPatch, a.apiBaseFor(r)+"/api/users/"+url.PathEscape(id), bytes.NewReader(body)) + req.Header.Set("Authorization", tokenOf(r)) + req.Header.Set("Content-Type", "application/json") + a.doRelay(w, req) +} + +// DELETE /bff/users/{id} → API Server /api/users/{id} +func (a *App) handleDeleteUser(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + req, _ := http.NewRequest(http.MethodDelete, a.apiBaseFor(r)+"/api/users/"+url.PathEscape(id), nil) + req.Header.Set("Authorization", tokenOf(r)) + a.doRelay(w, req) +} + +// GET /bff/orgs → API Server /api/orgs (manager only, enforced upstream) +func (a *App) handleListOrgs(w http.ResponseWriter, r *http.Request) { + req, _ := http.NewRequest(http.MethodGet, a.apiBaseFor(r)+"/api/orgs", nil) + req.Header.Set("Authorization", tokenOf(r)) + a.doRelay(w, req) +} + +// POST /bff/orgs → API Server /api/orgs +func (a *App) handleCreateOrg(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + req, _ := http.NewRequest(http.MethodPost, a.apiBaseFor(r)+"/api/orgs", bytes.NewReader(body)) + req.Header.Set("Authorization", tokenOf(r)) + req.Header.Set("Content-Type", "application/json") + a.doRelay(w, req) +} + +// PATCH /bff/orgs/{id} → API Server /api/orgs/{id} +func (a *App) handleUpdateOrg(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + body, _ := io.ReadAll(r.Body) + req, _ := http.NewRequest(http.MethodPatch, a.apiBaseFor(r)+"/api/orgs/"+url.PathEscape(id), bytes.NewReader(body)) + req.Header.Set("Authorization", tokenOf(r)) + req.Header.Set("Content-Type", "application/json") + a.doRelay(w, req) +} + +// DELETE /bff/orgs/{id} → API Server /api/orgs/{id} +func (a *App) handleDeleteOrg(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + req, _ := http.NewRequest(http.MethodDelete, a.apiBaseFor(r)+"/api/orgs/"+url.PathEscape(id), nil) + req.Header.Set("Authorization", tokenOf(r)) + a.doRelay(w, req) +} + +// doRelay executes an outbound request and relays the response verbatim. +func (a *App) doRelay(w http.ResponseWriter, req *http.Request) { + resp, err := client.Do(req) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach API server"}) + return + } + defer resp.Body.Close() + relay(w, resp) +} + +func (a *App) proxyGET(w http.ResponseWriter, url string) { + resp, err := client.Get(url) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach API server"}) + return + } + defer resp.Body.Close() + relay(w, resp) +} + +func relay(w http.ResponseWriter, resp *http.Response) { + data, _ := io.ReadAll(resp.Body) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(resp.StatusCode) + _, _ = w.Write(data) +} + +// GET /bff/ws → relays the API Server's /ws/ui websocket to the browser. +func (a *App) handleWS(w http.ResponseWriter, r *http.Request) { + if _, err := r.Cookie(cookieToken); err != nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + wsBase := strings.Replace(a.apiBaseFor(r), "http", "ws", 1) // http→ws, https→wss + upstream, _, err := websocket.DefaultDialer.Dial(wsBase+"/ws/ui", nil) + if err != nil { + http.Error(w, "upstream websocket unavailable", http.StatusBadGateway) + return + } + downstream, err := upgrader.Upgrade(w, r, nil) + if err != nil { + upstream.Close() + return + } + go pipe(upstream, downstream) + pipe(downstream, upstream) +} + +func pipe(src, dst *websocket.Conn) { + defer src.Close() + defer dst.Close() + for { + mt, msg, err := src.ReadMessage() + if err != nil { + return + } + if err := dst.WriteMessage(mt, msg); err != nil { + return + } + } +} + +func setCookie(w http.ResponseWriter, name, value string, httpOnly bool) { + http.SetCookie(w, &http.Cookie{ + Name: name, + Value: value, + Path: "/", + HttpOnly: httpOnly, + SameSite: http.SameSiteLaxMode, + MaxAge: 7 * 24 * 3600, + }) +} + +func clearCookie(w http.ResponseWriter, name string) { + http.SetCookie(w, &http.Cookie{ + Name: name, Value: "", Path: "/", HttpOnly: true, + SameSite: http.SameSiteLaxMode, MaxAge: -1, + }) +} diff --git a/Web App/server/dist/assets/index-BldP9Pra.js b/Web App/server/dist/assets/index-BldP9Pra.js new file mode 100644 index 0000000..d82de10 --- /dev/null +++ b/Web App/server/dist/assets/index-BldP9Pra.js @@ -0,0 +1,20 @@ +(function(){const i=document.createElement("link").relList;if(i&&i.supports&&i.supports("modulepreload"))return;for(const l of document.querySelectorAll('link[rel="modulepreload"]'))a(l);new MutationObserver(l=>{for(const c of l)if(c.type==="childList")for(const h of c.addedNodes)h.tagName==="LINK"&&h.rel==="modulepreload"&&a(h)}).observe(document,{childList:!0,subtree:!0});function o(l){const c={};return l.integrity&&(c.integrity=l.integrity),l.referrerPolicy&&(c.referrerPolicy=l.referrerPolicy),l.crossOrigin==="use-credentials"?c.credentials="include":l.crossOrigin==="anonymous"?c.credentials="omit":c.credentials="same-origin",c}function a(l){if(l.ep)return;l.ep=!0;const c=o(l);fetch(l.href,c)}})();/** +* @vue/shared v3.5.39 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/function va(e){const i=Object.create(null);for(const o of e.split(","))i[o]=1;return o=>o in i}const ie={},_s=[],Zn=()=>{},iu=()=>!1,lr=e=>e.charCodeAt(0)===111&&e.charCodeAt(1)===110&&(e.charCodeAt(2)>122||e.charCodeAt(2)<97),ur=e=>e.startsWith("onUpdate:"),Ce=Object.assign,ya=(e,i)=>{const o=e.indexOf(i);o>-1&&e.splice(o,1)},Qc=Object.prototype.hasOwnProperty,Xt=(e,i)=>Qc.call(e,i),pt=Array.isArray,vs=e=>po(e)==="[object Map]",Ts=e=>po(e)==="[object Set]",il=e=>po(e)==="[object Date]",Lt=e=>typeof e=="function",pe=e=>typeof e=="string",Ln=e=>typeof e=="symbol",Qt=e=>e!==null&&typeof e=="object",su=e=>(Qt(e)||Lt(e))&&Lt(e.then)&&Lt(e.catch),ou=Object.prototype.toString,po=e=>ou.call(e),td=e=>po(e).slice(8,-1),ru=e=>po(e)==="[object Object]",ba=e=>pe(e)&&e!=="NaN"&&e[0]!=="-"&&""+parseInt(e,10)===e,Xs=va(",key,ref,ref_for,ref_key,onVnodeBeforeMount,onVnodeMounted,onVnodeBeforeUpdate,onVnodeUpdated,onVnodeBeforeUnmount,onVnodeUnmounted"),cr=e=>{const i=Object.create(null);return(o=>i[o]||(i[o]=e(o)))},ed=/-\w/g,Pn=cr(e=>e.replace(ed,i=>i.slice(1).toUpperCase())),nd=/\B([A-Z])/g,Li=cr(e=>e.replace(nd,"-$1").toLowerCase()),au=cr(e=>e.charAt(0).toUpperCase()+e.slice(1)),jr=cr(e=>e?`on${au(e)}`:""),Vn=(e,i)=>!Object.is(e,i),Go=(e,...i)=>{for(let o=0;o{Object.defineProperty(e,i,{configurable:!0,enumerable:!1,writable:a,value:o})},dr=e=>{const i=parseFloat(e);return isNaN(i)?e:i},id=e=>{const i=pe(e)?Number(e):NaN;return isNaN(i)?e:i};let sl;const fr=()=>sl||(sl=typeof globalThis<"u"?globalThis:typeof self<"u"?self:typeof window<"u"?window:typeof global<"u"?global:{});function ks(e){if(pt(e)){const i={};for(let o=0;o{if(o){const a=o.split(od);a.length>1&&(i[a[0].trim()]=a[1].trim())}}),i}function Ot(e){let i="";if(pe(e))i=e;else if(pt(e))for(let o=0;oSi(o,i))}const cu=e=>!!(e&&e.__v_isRef===!0),M=e=>pe(e)?e:e==null?"":pt(e)||Qt(e)&&(e.toString===ou||!Lt(e.toString))?cu(e)?M(e.value):JSON.stringify(e,du,2):String(e),du=(e,i)=>cu(i)?du(e,i.value):vs(i)?{[`Map(${i.size})`]:[...i.entries()].reduce((o,[a,l],c)=>(o[Wr(a,c)+" =>"]=l,o),{})}:Ts(i)?{[`Set(${i.size})`]:[...i.values()].map(o=>Wr(o))}:Ln(i)?Wr(i):Qt(i)&&!pt(i)&&!ru(i)?String(i):i,Wr=(e,i="")=>{var o;return Ln(e)?`Symbol(${(o=e.description)!=null?o:i})`:e};/** +* @vue/reactivity v3.5.39 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/let ze;class dd{constructor(i=!1){this.detached=i,this._active=!0,this._on=0,this.effects=[],this.cleanups=[],this._isPaused=!1,this._warnOnRun=!0,this.__v_skip=!0,!i&&ze&&(ze.active?(this.parent=ze,this.index=(ze.scopes||(ze.scopes=[])).push(this)-1):(this._active=!1,this._warnOnRun=!1))}get active(){return this._active}pause(){if(this._active){this._isPaused=!0;let i,o;if(this.scopes)for(i=0,o=this.scopes.length;i0&&--this._on===0){if(ze===this)ze=this.prevScope;else{let i=ze;for(;i;){if(i.prevScope===this){i.prevScope=this.prevScope;break}i=i.prevScope}}this.prevScope=void 0}}stop(i){if(this._active){this._active=!1;let o,a;for(o=0,a=this.effects.length;o0)return;if(to){let i=to;for(to=void 0;i;){const o=i.next;i.next=void 0,i.flags&=-9,i=o}}let e;for(;Qs;){let i=Qs;for(Qs=void 0;i;){const o=i.next;if(i.next=void 0,i.flags&=-9,i.flags&1)try{i.trigger()}catch(a){e||(e=a)}i=o}}if(e)throw e}function mu(e){for(let i=e.deps;i;i=i.nextDep)i.version=-1,i.prevActiveLink=i.dep.activeLink,i.dep.activeLink=i}function gu(e){let i,o=e.depsTail,a=o;for(;a;){const l=a.prevDep;a.version===-1?(a===o&&(o=l),Sa(a),hd(a)):i=a,a.dep.activeLink=a.prevActiveLink,a.prevActiveLink=void 0,a=l}e.deps=i,e.depsTail=o}function ia(e){for(let i=e.deps;i;i=i.nextDep)if(i.dep.version!==i.version||i.dep.computed&&(_u(i.dep.computed)||i.dep.version!==i.version))return!0;return!!e._dirty}function _u(e){if(e.flags&4&&!(e.flags&16)||(e.flags&=-17,e.globalVersion===oo)||(e.globalVersion=oo,!e.isSSR&&e.flags&128&&(!e.deps&&!e._dirty||!ia(e))))return;e.flags|=2;const i=e.dep,o=re,a=Tn;re=e,Tn=!0;try{mu(e);const l=e.fn(e._value);(i.version===0||Vn(l,e._value))&&(e.flags|=128,e._value=l,i.version++)}catch(l){throw i.version++,l}finally{re=o,Tn=a,gu(e),e.flags&=-3}}function Sa(e,i=!1){const{dep:o,prevSub:a,nextSub:l}=e;if(a&&(a.nextSub=l,e.prevSub=void 0),l&&(l.prevSub=a,e.nextSub=void 0),o.subs===e&&(o.subs=a,!a&&o.computed)){o.computed.flags&=-5;for(let c=o.computed.deps;c;c=c.nextDep)Sa(c,!0)}!i&&!--o.sc&&o.map&&o.map.delete(o.key)}function hd(e){const{prevDep:i,nextDep:o}=e;i&&(i.nextDep=o,e.prevDep=void 0),o&&(o.prevDep=i,e.nextDep=void 0)}let Tn=!0;const vu=[];function $n(){vu.push(Tn),Tn=!1}function Hn(){const e=vu.pop();Tn=e===void 0?!0:e}function ol(e){const{cleanup:i}=e;if(e.cleanup=void 0,i){const o=re;re=void 0;try{i()}finally{re=o}}}let oo=0;class pd{constructor(i,o){this.sub=i,this.dep=o,this.version=o.version,this.nextDep=this.prevDep=this.nextSub=this.prevSub=this.prevActiveLink=void 0}}class Pa{constructor(i){this.computed=i,this.version=0,this.activeLink=void 0,this.subs=void 0,this.map=void 0,this.key=void 0,this.sc=0,this.__v_skip=!0}track(i){if(!re||!Tn||re===this.computed)return;let o=this.activeLink;if(o===void 0||o.sub!==re)o=this.activeLink=new pd(re,this),re.deps?(o.prevDep=re.depsTail,re.depsTail.nextDep=o,re.depsTail=o):re.deps=re.depsTail=o,yu(o);else if(o.version===-1&&(o.version=this.version,o.nextDep)){const a=o.nextDep;a.prevDep=o.prevDep,o.prevDep&&(o.prevDep.nextDep=a),o.prevDep=re.depsTail,o.nextDep=void 0,re.depsTail.nextDep=o,re.depsTail=o,re.deps===o&&(re.deps=a)}return o}trigger(i){this.version++,oo++,this.notify(i)}notify(i){wa();try{for(let o=this.subs;o;o=o.prevSub)o.sub.notify()&&o.sub.dep.notify()}finally{ka()}}}function yu(e){if(e.dep.sc++,e.sub.flags&4){const i=e.dep.computed;if(i&&!e.dep.subs){i.flags|=20;for(let a=i.deps;a;a=a.nextDep)yu(a)}const o=e.dep.subs;o!==e&&(e.prevSub=o,o&&(o.nextSub=e)),e.dep.subs=e}}const sa=new WeakMap,Ki=Symbol(""),oa=Symbol(""),ro=Symbol("");function Ne(e,i,o){if(Tn&&re){let a=sa.get(e);a||sa.set(e,a=new Map);let l=a.get(o);l||(a.set(o,l=new Pa),l.map=a,l.key=o),l.track()}}function si(e,i,o,a,l,c){const h=sa.get(e);if(!h){oo++;return}const g=v=>{v&&v.trigger()};if(wa(),i==="clear")h.forEach(g);else{const v=pt(e),P=v&&ba(o);if(v&&o==="length"){const k=Number(a);h.forEach((O,$)=>{($==="length"||$===ro||!Ln($)&&$>=k)&&g(O)})}else switch((o!==void 0||h.has(void 0))&&g(h.get(o)),P&&g(h.get(ro)),i){case"add":v?P&&g(h.get("length")):(g(h.get(Ki)),vs(e)&&g(h.get(oa)));break;case"delete":v||(g(h.get(Ki)),vs(e)&&g(h.get(oa)));break;case"set":vs(e)&&g(h.get(Ki));break}}ka()}function ms(e){const i=Gt(e);return i===e?i:(Ne(i,"iterate",ro),hn(e)?i:i.map(Mn))}function hr(e){return Ne(e=Gt(e),"iterate",ro),e}function Rn(e,i){return ai(e)?Ss(qi(e)?Mn(i):i):Mn(i)}const md={__proto__:null,[Symbol.iterator](){return qr(this,Symbol.iterator,e=>Rn(this,e))},concat(...e){return ms(this).concat(...e.map(i=>pt(i)?ms(i):i))},entries(){return qr(this,"entries",e=>(e[1]=Rn(this,e[1]),e))},every(e,i){return ti(this,"every",e,i,void 0,arguments)},filter(e,i){return ti(this,"filter",e,i,o=>o.map(a=>Rn(this,a)),arguments)},find(e,i){return ti(this,"find",e,i,o=>Rn(this,o),arguments)},findIndex(e,i){return ti(this,"findIndex",e,i,void 0,arguments)},findLast(e,i){return ti(this,"findLast",e,i,o=>Rn(this,o),arguments)},findLastIndex(e,i){return ti(this,"findLastIndex",e,i,void 0,arguments)},forEach(e,i){return ti(this,"forEach",e,i,void 0,arguments)},includes(...e){return Gr(this,"includes",e)},indexOf(...e){return Gr(this,"indexOf",e)},join(e){return ms(this).join(e)},lastIndexOf(...e){return Gr(this,"lastIndexOf",e)},map(e,i){return ti(this,"map",e,i,void 0,arguments)},pop(){return js(this,"pop")},push(...e){return js(this,"push",e)},reduce(e,...i){return rl(this,"reduce",e,i)},reduceRight(e,...i){return rl(this,"reduceRight",e,i)},shift(){return js(this,"shift")},some(e,i){return ti(this,"some",e,i,void 0,arguments)},splice(...e){return js(this,"splice",e)},toReversed(){return ms(this).toReversed()},toSorted(e){return ms(this).toSorted(e)},toSpliced(...e){return ms(this).toSpliced(...e)},unshift(...e){return js(this,"unshift",e)},values(){return qr(this,"values",e=>Rn(this,e))}};function qr(e,i,o){const a=hr(e),l=a[i]();return a!==e&&!hn(e)&&(l._next=l.next,l.next=()=>{const c=l._next();return c.done||(c.value=o(c.value)),c}),l}const gd=Array.prototype;function ti(e,i,o,a,l,c){const h=hr(e),g=h!==e&&!hn(e),v=h[i];if(v!==gd[i]){const O=v.apply(e,c);return g?Mn(O):O}let P=o;h!==e&&(g?P=function(O,$){return o.call(this,Rn(e,O),$,e)}:o.length>2&&(P=function(O,$){return o.call(this,O,$,e)}));const k=v.call(h,P,a);return g&&l?l(k):k}function rl(e,i,o,a){const l=hr(e),c=l!==e&&!hn(e);let h=o,g=!1;l!==e&&(c?(g=a.length===0,h=function(P,k,O){return g&&(g=!1,P=Rn(e,P)),o.call(this,P,Rn(e,k),O,e)}):o.length>3&&(h=function(P,k,O){return o.call(this,P,k,O,e)}));const v=l[i](h,...a);return g?Rn(e,v):v}function Gr(e,i,o){const a=Gt(e);Ne(a,"iterate",ro);const l=a[i](...o);return(l===-1||l===!1)&&Ma(o[0])?(o[0]=Gt(o[0]),a[i](...o)):l}function js(e,i,o=[]){$n(),wa();const a=Gt(e)[i].apply(e,o);return ka(),Hn(),a}const _d=va("__proto__,__v_isRef,__isVue"),bu=new Set(Object.getOwnPropertyNames(Symbol).filter(e=>e!=="arguments"&&e!=="caller").map(e=>Symbol[e]).filter(Ln));function vd(e){Ln(e)||(e=String(e));const i=Gt(this);return Ne(i,"has",e),i.hasOwnProperty(e)}class xu{constructor(i=!1,o=!1){this._isReadonly=i,this._isShallow=o}get(i,o,a){if(o==="__v_skip")return i.__v_skip;const l=this._isReadonly,c=this._isShallow;if(o==="__v_isReactive")return!l;if(o==="__v_isReadonly")return l;if(o==="__v_isShallow")return c;if(o==="__v_raw")return a===(l?c?Md:Pu:c?Su:ku).get(i)||Object.getPrototypeOf(i)===Object.getPrototypeOf(a)?i:void 0;const h=pt(i);if(!l){let v;if(h&&(v=md[o]))return v;if(o==="hasOwnProperty")return vd}const g=Reflect.get(i,o,Re(i)?i:a);if((Ln(o)?bu.has(o):_d(o))||(l||Ne(i,"get",o),c))return g;if(Re(g)){const v=h&&ba(o)?g:g.value;return l&&Qt(v)?aa(v):v}return Qt(g)?l?aa(g):xe(g):g}}class wu extends xu{constructor(i=!1){super(!1,i)}set(i,o,a,l){let c=i[o];const h=pt(i)&&ba(o);if(!this._isShallow){const P=ai(c);if(!hn(a)&&!ai(a)&&(c=Gt(c),a=Gt(a)),!h&&Re(c)&&!Re(a))return P||(c.value=a),!0}const g=h?Number(o)e,Ho=e=>Reflect.getPrototypeOf(e);function kd(e,i,o){return function(...a){const l=this.__v_raw,c=Gt(l),h=vs(c),g=e==="entries"||e===Symbol.iterator&&h,v=e==="keys"&&h,P=l[e](...a),k=o?ra:i?Ss:Mn;return!i&&Ne(c,"iterate",v?oa:Ki),Ce(Object.create(P),{next(){const{value:O,done:$}=P.next();return $?{value:O,done:$}:{value:g?[k(O[0]),k(O[1])]:k(O),done:$}}})}}function Uo(e){return function(...i){return e==="delete"?!1:e==="clear"?void 0:this}}function Sd(e,i){const o={get(l){const c=this.__v_raw,h=Gt(c),g=Gt(l);e||(Vn(l,g)&&Ne(h,"get",l),Ne(h,"get",g));const{has:v}=Ho(h),P=i?ra:e?Ss:Mn;if(v.call(h,l))return P(c.get(l));if(v.call(h,g))return P(c.get(g));c!==h&&c.get(l)},get size(){const l=this.__v_raw;return!e&&Ne(Gt(l),"iterate",Ki),l.size},has(l){const c=this.__v_raw,h=Gt(c),g=Gt(l);return e||(Vn(l,g)&&Ne(h,"has",l),Ne(h,"has",g)),l===g?c.has(l):c.has(l)||c.has(g)},forEach(l,c){const h=this,g=h.__v_raw,v=Gt(g),P=i?ra:e?Ss:Mn;return!e&&Ne(v,"iterate",Ki),g.forEach((k,O)=>l.call(c,P(k),P(O),h))}};return Ce(o,e?{add:Uo("add"),set:Uo("set"),delete:Uo("delete"),clear:Uo("clear")}:{add(l){const c=Gt(this),h=Ho(c),g=Gt(l),v=!i&&!hn(l)&&!ai(l)?g:l;return h.has.call(c,v)||Vn(l,v)&&h.has.call(c,l)||Vn(g,v)&&h.has.call(c,g)||(c.add(v),si(c,"add",v,v)),this},set(l,c){!i&&!hn(c)&&!ai(c)&&(c=Gt(c));const h=Gt(this),{has:g,get:v}=Ho(h);let P=g.call(h,l);P||(l=Gt(l),P=g.call(h,l));const k=v.call(h,l);return h.set(l,c),P?Vn(c,k)&&si(h,"set",l,c):si(h,"add",l,c),this},delete(l){const c=Gt(this),{has:h,get:g}=Ho(c);let v=h.call(c,l);v||(l=Gt(l),v=h.call(c,l)),g&&g.call(c,l);const P=c.delete(l);return v&&si(c,"delete",l,void 0),P},clear(){const l=Gt(this),c=l.size!==0,h=l.clear();return c&&si(l,"clear",void 0,void 0),h}}),["keys","values","entries",Symbol.iterator].forEach(l=>{o[l]=kd(l,e,i)}),o}function Ta(e,i){const o=Sd(e,i);return(a,l,c)=>l==="__v_isReactive"?!e:l==="__v_isReadonly"?e:l==="__v_raw"?a:Reflect.get(Xt(o,l)&&l in a?o:a,l,c)}const Pd={get:Ta(!1,!1)},Td={get:Ta(!1,!0)},Ld={get:Ta(!0,!1)};const ku=new WeakMap,Su=new WeakMap,Pu=new WeakMap,Md=new WeakMap;function Cd(e){switch(e){case"Object":case"Array":return 1;case"Map":case"Set":case"WeakMap":case"WeakSet":return 2;default:return 0}}function xe(e){return ai(e)?e:La(e,!1,bd,Pd,ku)}function Od(e){return La(e,!1,wd,Td,Su)}function aa(e){return La(e,!0,xd,Ld,Pu)}function La(e,i,o,a,l){if(!Qt(e)||e.__v_raw&&!(i&&e.__v_isReactive)||e.__v_skip||!Object.isExtensible(e))return e;const c=l.get(e);if(c)return c;const h=Cd(td(e));if(h===0)return e;const g=new Proxy(e,h===2?a:o);return l.set(e,g),g}function qi(e){return ai(e)?qi(e.__v_raw):!!(e&&e.__v_isReactive)}function ai(e){return!!(e&&e.__v_isReadonly)}function hn(e){return!!(e&&e.__v_isShallow)}function Ma(e){return e?!!e.__v_raw:!1}function Gt(e){const i=e&&e.__v_raw;return i?Gt(i):e}function Ed(e){return!Xt(e,"__v_skip")&&Object.isExtensible(e)&&lu(e,"__v_skip",!0),e}const Mn=e=>Qt(e)?xe(e):e,Ss=e=>Qt(e)?aa(e):e;function Re(e){return e?e.__v_isRef===!0:!1}function J(e){return zd(e,!1)}function zd(e,i){return Re(e)?e:new Ad(e,i)}class Ad{constructor(i,o){this.dep=new Pa,this.__v_isRef=!0,this.__v_isShallow=!1,this._rawValue=o?i:Gt(i),this._value=o?i:Mn(i),this.__v_isShallow=o}get value(){return this.dep.track(),this._value}set value(i){const o=this._rawValue,a=this.__v_isShallow||hn(i)||ai(i);i=a?i:Gt(i),Vn(i,o)&&(this._rawValue=i,this._value=a?i:Mn(i),this.dep.trigger())}}function Ct(e){return Re(e)?e.value:e}const Id={get:(e,i,o)=>i==="__v_raw"?e:Ct(Reflect.get(e,i,o)),set:(e,i,o,a)=>{const l=e[i];return Re(l)&&!Re(o)?(l.value=o,!0):Reflect.set(e,i,o,a)}};function Tu(e){return qi(e)?e:new Proxy(e,Id)}class Nd{constructor(i,o,a){this.fn=i,this.setter=o,this._value=void 0,this.dep=new Pa(this),this.__v_isRef=!0,this.deps=void 0,this.depsTail=void 0,this.flags=16,this.globalVersion=oo-1,this.next=void 0,this.effect=this,this.__v_isReadonly=!o,this.isSSR=a}notify(){if(this.flags|=16,!(this.flags&8)&&re!==this)return pu(this,!0),!0}get value(){const i=this.dep.track();return _u(this),i&&(i.version=this.dep.version),this._value}set value(i){this.setter&&this.setter(i)}}function Bd(e,i,o=!1){let a,l;return Lt(e)?a=e:(a=e.get,l=e.set),new Nd(a,l,o)}const jo={},Jo=new WeakMap;let ji;function Dd(e,i=!1,o=ji){if(o){let a=Jo.get(o);a||Jo.set(o,a=[]),a.push(e)}}function Rd(e,i,o=ie){const{immediate:a,deep:l,once:c,scheduler:h,augmentJob:g,call:v}=o,P=X=>l?X:hn(X)||l===!1||l===0?oi(X,1):oi(X);let k,O,$,F,nt=!1,q=!1;if(Re(e)?(O=()=>e.value,nt=hn(e)):qi(e)?(O=()=>P(e),nt=!0):pt(e)?(q=!0,nt=e.some(X=>qi(X)||hn(X)),O=()=>e.map(X=>{if(Re(X))return X.value;if(qi(X))return P(X);if(Lt(X))return v?v(X,2):X()})):Lt(e)?i?O=v?()=>v(e,2):e:O=()=>{if($){$n();try{$()}finally{Hn()}}const X=ji;ji=k;try{return v?v(e,3,[F]):e(F)}finally{ji=X}}:O=Zn,i&&l){const X=O,ft=l===!0?1/0:l;O=()=>oi(X(),ft)}const At=fd(),Dt=()=>{k.stop(),At&&At.active&&ya(At.effects,k)};if(c&&i){const X=i;i=(...ft)=>{const jt=X(...ft);return Dt(),jt}}let bt=q?new Array(e.length).fill(jo):jo;const ut=X=>{if(!(!(k.flags&1)||!k.dirty&&!X))if(i){const ft=k.run();if(X||l||nt||(q?ft.some((jt,de)=>Vn(jt,bt[de])):Vn(ft,bt))){$&&$();const jt=ji;ji=k;try{const de=[ft,bt===jo?void 0:q&&bt[0]===jo?[]:bt,F];bt=ft,v?v(i,3,de):i(...de)}finally{ji=jt}}}else k.run()};return g&&g(ut),k=new fu(O),k.scheduler=h?()=>h(ut,!1):ut,F=X=>Dd(X,!1,k),$=k.onStop=()=>{const X=Jo.get(k);if(X){if(v)v(X,4);else for(const ft of X)ft();Jo.delete(k)}},i?a?ut(!0):bt=k.run():h?h(ut.bind(null,!0),!0):k.run(),Dt.pause=k.pause.bind(k),Dt.resume=k.resume.bind(k),Dt.stop=Dt,Dt}function oi(e,i=1/0,o){if(i<=0||!Qt(e)||e.__v_skip||(o=o||new Map,(o.get(e)||0)>=i))return e;if(o.set(e,i),i--,Re(e))oi(e.value,i,o);else if(pt(e))for(let a=0;a{oi(a,i,o)});else if(ru(e)){for(const a in e)oi(e[a],i,o);for(const a of Object.getOwnPropertySymbols(e))Object.prototype.propertyIsEnumerable.call(e,a)&&oi(e[a],i,o)}return e}/** +* @vue/runtime-core v3.5.39 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/function mo(e,i,o,a){try{return a?e(...a):e()}catch(l){pr(l,i,o)}}function mn(e,i,o,a){if(Lt(e)){const l=mo(e,i,o,a);return l&&su(l)&&l.catch(c=>{pr(c,i,o)}),l}if(pt(e)){const l=[];for(let c=0;c>>1,l=je[a],c=ao(l);c=ao(o)?je.push(e):je.splice(Vd(i),0,e),e.flags|=1,Cu()}}function Cu(){Xo||(Xo=Lu.then(Eu))}function Zd(e){pt(e)?ys.push(...e):ki&&e.id===-1?ki.splice(gs+1,0,e):e.flags&1||(ys.push(e),e.flags|=1),Cu()}function al(e,i,o=Dn+1){for(;oao(o)-ao(a));if(ys.length=0,ki){ki.push(...i);return}for(ki=i,gs=0;gse.id==null?e.flags&2?-1:1/0:e.id;function Eu(e){try{for(Dn=0;Dn{a._d&&nr(-1);const c=Qo(i);let h;try{h=e(...l)}finally{Qo(c),a._d&&nr(1)}return h};return a._n=!0,a._c=!0,a._d=!0,a}function xt(e,i){if(De===null)return e;const o=br(De),a=e.dirs||(e.dirs=[]);for(let l=0;l1)return o&&Lt(i)?i.call(a&&a.proxy):i}}const $d=Symbol.for("v-scx"),Hd=()=>eo($d);function Je(e,i,o){return Iu(e,i,o)}function Iu(e,i,o=ie){const{immediate:a,deep:l,flush:c,once:h}=o,g=Ce({},o),v=i&&a||!i&&c!=="post";let P;if(fo){if(c==="sync"){const F=Hd();P=F.__watcherHandles||(F.__watcherHandles=[])}else if(!v){const F=()=>{};return F.stop=Zn,F.resume=Zn,F.pause=Zn,F}}const k=We;g.call=(F,nt,q)=>mn(F,k,nt,q);let O=!1;c==="post"?g.scheduler=F=>{Ye(F,k&&k.suspense)}:c!=="sync"&&(O=!0,g.scheduler=(F,nt)=>{nt?F():Ca(F)}),g.augmentJob=F=>{i&&(F.flags|=4),O&&(F.flags|=2,k&&(F.id=k.uid,F.i=k))};const $=Rd(e,i,g);return fo&&(P?P.push($):v&&$()),$}function Ud(e,i,o){const a=this.proxy,l=pe(e)?e.includes(".")?Nu(a,e):()=>a[e]:e.bind(a,a);let c;Lt(i)?c=i:(c=i.handler,o=i);const h=go(this),g=Iu(l,c.bind(a),o);return h(),g}function Nu(e,i){const o=i.split(".");return()=>{let a=e;for(let l=0;le.__isTeleport,fn=Symbol("_leaveCb"),Ws=Symbol("_enterCb");function Wd(){const e={isMounted:!1,isLeaving:!1,isUnmounting:!1,leavingVNodes:new Map};return Ls(()=>{e.isMounted=!0}),_r(()=>{e.isUnmounting=!0}),e}const cn=[Function,Array],Du={mode:String,appear:Boolean,persisted:Boolean,onBeforeEnter:cn,onEnter:cn,onAfterEnter:cn,onEnterCancelled:cn,onBeforeLeave:cn,onLeave:cn,onAfterLeave:cn,onLeaveCancelled:cn,onBeforeAppear:cn,onAppear:cn,onAfterAppear:cn,onAppearCancelled:cn},Ru=e=>{const i=e.subTree;return i.component?Ru(i.component):i},Kd={name:"BaseTransition",props:Du,setup(e,{slots:i}){const o=dc(),a=Wd();return()=>{const l=i.default&&Zu(i.default(),!0),c=l&&l.length?Fu(l):o.subTree?V():void 0;if(!c)return;const h=Gt(e),{mode:g}=h;if(a.isLeaving)return Yr(c);const v=ll(c);if(!v)return Yr(c);let P=la(v,h,a,o,O=>P=O);v.type!==Be&&lo(v,P);let k=o.subTree&&ll(o.subTree);if(k&&k.type!==Be&&!Wi(k,v)&&Ru(o).type!==Be){let O=la(k,h,a,o);if(lo(k,O),g==="out-in"&&v.type!==Be)return a.isLeaving=!0,O.afterLeave=()=>{a.isLeaving=!1,o.job.flags&8||o.update(),delete O.afterLeave,k=void 0},Yr(c);g==="in-out"&&v.type!==Be?O.delayLeave=($,F,nt)=>{const q=Vu(a,k);q[String(k.key)]=k,$[fn]=()=>{F(),$[fn]=void 0,delete P.delayedLeave,k=void 0},P.delayedLeave=()=>{nt(),delete P.delayedLeave,k=void 0}}:k=void 0}else k&&(k=void 0);return c}}};function Fu(e){let i=e[0];if(e.length>1){for(const o of e)if(o.type!==Be){i=o;break}}return i}const qd=Kd;function Vu(e,i){const{leavingVNodes:o}=e;let a=o.get(i.type);return a||(a=Object.create(null),o.set(i.type,a)),a}function la(e,i,o,a,l){const{appear:c,mode:h,persisted:g=!1,onBeforeEnter:v,onEnter:P,onAfterEnter:k,onEnterCancelled:O,onBeforeLeave:$,onLeave:F,onAfterLeave:nt,onLeaveCancelled:q,onBeforeAppear:At,onAppear:Dt,onAfterAppear:bt,onAppearCancelled:ut}=i,X=String(e.key),ft=Vu(o,e),jt=(vt,Ft)=>{vt&&mn(vt,a,9,Ft)},de=(vt,Ft)=>{const Tt=Ft[1];jt(vt,Ft),pt(vt)?vt.every(G=>G.length<=1)&&Tt():vt.length<=1&&Tt()},me={mode:h,persisted:g,beforeEnter(vt){let Ft=v;if(!o.isMounted)if(c)Ft=At||v;else return;vt[fn]&&vt[fn](!0);const Tt=ft[X];Tt&&Wi(e,Tt)&&Tt.el[fn]&&Tt.el[fn](),jt(Ft,[vt])},enter(vt){if(ft[X]===e)return;let Ft=P,Tt=k,G=O;if(!o.isMounted)if(c)Ft=Dt||P,Tt=bt||k,G=ut||O;else return;let st=!1;vt[Ws]=Wt=>{st||(st=!0,Wt?jt(G,[vt]):jt(Tt,[vt]),me.delayedLeave&&me.delayedLeave(),vt[Ws]=void 0)};const It=vt[Ws].bind(null,!1);Ft?de(Ft,[vt,It]):It()},leave(vt,Ft){const Tt=String(e.key);if(vt[Ws]&&vt[Ws](!0),o.isUnmounting)return Ft();jt($,[vt]);let G=!1;vt[fn]=It=>{G||(G=!0,Ft(),It?jt(q,[vt]):jt(nt,[vt]),vt[fn]=void 0,ft[Tt]===e&&delete ft[Tt])};const st=vt[fn].bind(null,!1);ft[Tt]=e,F?de(F,[vt,st]):st()},clone(vt){const Ft=la(vt,i,o,a,l);return l&&l(Ft),Ft}};return me}function Yr(e){if(mr(e))return e=Pi(e),e.children=null,e}function ll(e){if(!mr(e))return Bu(e.type)&&e.children?Fu(e.children):e;if(e.component)return e.component.subTree;const{shapeFlag:i,children:o}=e;if(o){if(i&16)return o[0];if(i&32&&Lt(o.default))return o.default()}}function lo(e,i){e.shapeFlag&6&&e.component?(e.transition=i,lo(e.component.subTree,i)):e.shapeFlag&128?(e.ssContent.transition=i.clone(e.ssContent),e.ssFallback.transition=i.clone(e.ssFallback)):e.transition=i}function Zu(e,i=!1,o){let a=[],l=0;for(let c=0;c1)for(let c=0;cno(q,i&&(pt(i)?i[At]:i),o,a,l));return}if(bs(a)&&!l){a.shapeFlag&512&&a.type.__asyncResolved&&a.component.subTree.component&&no(e,i,o,a.component.subTree);return}const c=a.shapeFlag&4?br(a.component):a.el,h=l?null:c,{i:g,r:v}=e,P=i&&i.r,k=g.refs===ie?g.refs={}:g.refs,O=g.setupState,$=Gt(O),F=O===ie?iu:q=>ul(k,q)?!1:Xt($,q),nt=(q,At)=>!(At&&ul(k,At));if(P!=null&&P!==v){if(cl(i),pe(P))k[P]=null,F(P)&&(O[P]=null);else if(Re(P)){const q=i;nt(P,q.k)&&(P.value=null),q.k&&(k[q.k]=null)}}if(Lt(v)){$n();try{mo(v,g,12,[h,k])}finally{Hn()}}else{const q=pe(v),At=Re(v);if(q||At){const Dt=()=>{if(e.f){const bt=q?F(v)?O[v]:k[v]:nt()||!e.k?v.value:k[e.k];if(l)pt(bt)&&ya(bt,c);else if(pt(bt))bt.includes(c)||bt.push(c);else if(q)k[v]=[c],F(v)&&(O[v]=k[v]);else{const ut=[c];nt(v,e.k)&&(v.value=ut),e.k&&(k[e.k]=ut)}}else q?(k[v]=h,F(v)&&(O[v]=h)):At&&(nt(v,e.k)&&(v.value=h),e.k&&(k[e.k]=h))};if(h){const bt=()=>{Dt(),tr.delete(e)};bt.id=-1,tr.set(e,bt),Ye(bt,o)}else cl(e),Dt()}}}function cl(e){const i=tr.get(e);i&&(i.flags|=8,tr.delete(e))}fr().requestIdleCallback;fr().cancelIdleCallback;const bs=e=>!!e.type.__asyncLoader,mr=e=>e.type.__isKeepAlive;function Gd(e,i){Hu(e,"a",i)}function Yd(e,i){Hu(e,"da",i)}function Hu(e,i,o=We){const a=e.__wdc||(e.__wdc=()=>{let l=o;for(;l;){if(l.isDeactivated)return;l=l.parent}return e()});if(gr(i,a,o),o){let l=o.parent;for(;l&&l.parent;)mr(l.parent.vnode)&&Jd(a,i,o,l),l=l.parent}}function Jd(e,i,o,a){const l=gr(i,e,a,!0);Uu(()=>{ya(a[i],l)},o)}function gr(e,i,o=We,a=!1){if(o){const l=o[e]||(o[e]=[]),c=i.__weh||(i.__weh=(...h)=>{$n();const g=go(o),v=mn(i,o,e,h);return g(),Hn(),v});return a?l.unshift(c):l.push(c),c}}const li=e=>(i,o=We)=>{(!fo||e==="sp")&&gr(e,(...a)=>i(...a),o)},Xd=li("bm"),Ls=li("m"),Qd=li("bu"),tf=li("u"),_r=li("bum"),Uu=li("um"),ef=li("sp"),nf=li("rtg"),sf=li("rtc");function of(e,i=We){gr("ec",e,i)}const rf=Symbol.for("v-ndc");function ce(e,i,o,a){let l;const c=o,h=pt(e);if(h||pe(e)){const g=h&&qi(e);let v=!1,P=!1;g&&(v=!hn(e),P=ai(e),e=hr(e)),l=new Array(e.length);for(let k=0,O=e.length;ki(g,v,void 0,c));else{const g=Object.keys(e);l=new Array(g.length);for(let v=0,P=g.length;v0;return b(),oe(wt,null,[E("slot",o,a)],P?-2:64)}let c=e[i];c&&c._c&&(c._d=!1),b();const h=c&&ju(c(o)),g=o.key||h&&h.key,v=oe(wt,{key:(g&&!Ln(g)?g:`_${i}`)+(!h&&a?"_fb":"")},h||[],h&&e._===1?64:-2);return v.scopeId&&(v.slotScopeIds=[v.scopeId+"-s"]),c&&c._c&&(c._d=!0),v}function ju(e){return e.some(i=>co(i)?!(i.type===Be||i.type===wt&&!ju(i.children)):!0)?e:null}const ua=e=>e?fc(e)?br(e):ua(e.parent):null,io=Ce(Object.create(null),{$:e=>e,$el:e=>e.vnode.el,$data:e=>e.data,$props:e=>e.props,$attrs:e=>e.attrs,$slots:e=>e.slots,$refs:e=>e.refs,$parent:e=>ua(e.parent),$root:e=>ua(e.root),$host:e=>e.ce,$emit:e=>e.emit,$options:e=>Ku(e),$forceUpdate:e=>e.f||(e.f=()=>{Ca(e.update)}),$nextTick:e=>e.n||(e.n=Mu.bind(e.proxy)),$watch:e=>Ud.bind(e)}),Jr=(e,i)=>e!==ie&&!e.__isScriptSetup&&Xt(e,i),lf={get({_:e},i){if(i==="__v_skip")return!0;const{ctx:o,setupState:a,data:l,props:c,accessCache:h,type:g,appContext:v}=e;if(i[0]!=="$"){const $=h[i];if($!==void 0)switch($){case 1:return a[i];case 2:return l[i];case 4:return o[i];case 3:return c[i]}else{if(Jr(a,i))return h[i]=1,a[i];if(l!==ie&&Xt(l,i))return h[i]=2,l[i];if(Xt(c,i))return h[i]=3,c[i];if(o!==ie&&Xt(o,i))return h[i]=4,o[i];ca&&(h[i]=0)}}const P=io[i];let k,O;if(P)return i==="$attrs"&&Ne(e.attrs,"get",""),P(e);if((k=g.__cssModules)&&(k=k[i]))return k;if(o!==ie&&Xt(o,i))return h[i]=4,o[i];if(O=v.config.globalProperties,Xt(O,i))return O[i]},set({_:e},i,o){const{data:a,setupState:l,ctx:c}=e;return Jr(l,i)?(l[i]=o,!0):a!==ie&&Xt(a,i)?(a[i]=o,!0):Xt(e.props,i)||i[0]==="$"&&i.slice(1)in e?!1:(c[i]=o,!0)},has({_:{data:e,setupState:i,accessCache:o,ctx:a,appContext:l,props:c,type:h}},g){let v;return!!(o[g]||e!==ie&&g[0]!=="$"&&Xt(e,g)||Jr(i,g)||Xt(c,g)||Xt(a,g)||Xt(io,g)||Xt(l.config.globalProperties,g)||(v=h.__cssModules)&&v[g])},defineProperty(e,i,o){return o.get!=null?e._.accessCache[i]=0:Xt(o,"value")&&this.set(e,i,o.value,null),Reflect.defineProperty(e,i,o)}};function dl(e){return pt(e)?e.reduce((i,o)=>(i[o]=null,i),{}):e}let ca=!0;function uf(e){const i=Ku(e),o=e.proxy,a=e.ctx;ca=!1,i.beforeCreate&&fl(i.beforeCreate,e,"bc");const{data:l,computed:c,methods:h,watch:g,provide:v,inject:P,created:k,beforeMount:O,mounted:$,beforeUpdate:F,updated:nt,activated:q,deactivated:At,beforeDestroy:Dt,beforeUnmount:bt,destroyed:ut,unmounted:X,render:ft,renderTracked:jt,renderTriggered:de,errorCaptured:me,serverPrefetch:vt,expose:Ft,inheritAttrs:Tt,components:G,directives:st,filters:It}=i;if(P&&cf(P,a,null),h)for(const Vt in h){const Y=h[Vt];Lt(Y)&&(a[Vt]=Y.bind(o))}if(l){const Vt=l.call(o,o);Qt(Vt)&&(e.data=xe(Vt))}if(ca=!0,c)for(const Vt in c){const Y=c[Vt],ue=Lt(Y)?Y.bind(o,o):Lt(Y.get)?Y.get.bind(o,o):Zn,rt=!Lt(Y)&&Lt(Y.set)?Y.set.bind(o):Zn,yt=ht({get:ue,set:rt});Object.defineProperty(a,Vt,{enumerable:!0,configurable:!0,get:()=>yt.value,set:Kt=>yt.value=Kt})}if(g)for(const Vt in g)Wu(g[Vt],a,o,Vt);if(v){const Vt=Lt(v)?v.call(o):v;Reflect.ownKeys(Vt).forEach(Y=>{Au(Y,Vt[Y])})}k&&fl(k,e,"c");function kt(Vt,Y){pt(Y)?Y.forEach(ue=>Vt(ue.bind(o))):Y&&Vt(Y.bind(o))}if(kt(Xd,O),kt(Ls,$),kt(Qd,F),kt(tf,nt),kt(Gd,q),kt(Yd,At),kt(of,me),kt(sf,jt),kt(nf,de),kt(_r,bt),kt(Uu,X),kt(ef,vt),pt(Ft))if(Ft.length){const Vt=e.exposed||(e.exposed={});Ft.forEach(Y=>{Object.defineProperty(Vt,Y,{get:()=>o[Y],set:ue=>o[Y]=ue,enumerable:!0})})}else e.exposed||(e.exposed={});ft&&e.render===Zn&&(e.render=ft),Tt!=null&&(e.inheritAttrs=Tt),G&&(e.components=G),st&&(e.directives=st),vt&&$u(e)}function cf(e,i,o=Zn){pt(e)&&(e=da(e));for(const a in e){const l=e[a];let c;Qt(l)?"default"in l?c=eo(l.from||a,l.default,!0):c=eo(l.from||a):c=eo(l),Re(c)?Object.defineProperty(i,a,{enumerable:!0,configurable:!0,get:()=>c.value,set:h=>c.value=h}):i[a]=c}}function fl(e,i,o){mn(pt(e)?e.map(a=>a.bind(i.proxy)):e.bind(i.proxy),i,o)}function Wu(e,i,o,a){let l=a.includes(".")?Nu(o,a):()=>o[a];if(pe(e)){const c=i[e];Lt(c)&&Je(l,c)}else if(Lt(e))Je(l,e.bind(o));else if(Qt(e))if(pt(e))e.forEach(c=>Wu(c,i,o,a));else{const c=Lt(e.handler)?e.handler.bind(o):i[e.handler];Lt(c)&&Je(l,c,e)}}function Ku(e){const i=e.type,{mixins:o,extends:a}=i,{mixins:l,optionsCache:c,config:{optionMergeStrategies:h}}=e.appContext,g=c.get(i);let v;return g?v=g:!l.length&&!o&&!a?v=i:(v={},l.length&&l.forEach(P=>er(v,P,h,!0)),er(v,i,h)),Qt(i)&&c.set(i,v),v}function er(e,i,o,a=!1){const{mixins:l,extends:c}=i;c&&er(e,c,o,!0),l&&l.forEach(h=>er(e,h,o,!0));for(const h in i)if(!(a&&h==="expose")){const g=df[h]||o&&o[h];e[h]=g?g(e[h],i[h]):i[h]}return e}const df={data:hl,props:pl,emits:pl,methods:Gs,computed:Gs,beforeCreate:Ue,created:Ue,beforeMount:Ue,mounted:Ue,beforeUpdate:Ue,updated:Ue,beforeDestroy:Ue,beforeUnmount:Ue,destroyed:Ue,unmounted:Ue,activated:Ue,deactivated:Ue,errorCaptured:Ue,serverPrefetch:Ue,components:Gs,directives:Gs,watch:hf,provide:hl,inject:ff};function hl(e,i){return i?e?function(){return Ce(Lt(e)?e.call(this,this):e,Lt(i)?i.call(this,this):i)}:i:e}function ff(e,i){return Gs(da(e),da(i))}function da(e){if(pt(e)){const i={};for(let o=0;oi==="modelValue"||i==="model-value"?e.modelModifiers:e[`${i}Modifiers`]||e[`${Pn(i)}Modifiers`]||e[`${Li(i)}Modifiers`];function _f(e,i,...o){if(e.isUnmounted)return;const a=e.vnode.props||ie;let l=o;const c=i.startsWith("update:"),h=c&&gf(a,i.slice(7));h&&(h.trim&&(l=o.map(k=>pe(k)?k.trim():k)),h.number&&(l=o.map(dr)));let g,v=a[g=jr(i)]||a[g=jr(Pn(i))];!v&&c&&(v=a[g=jr(Li(i))]),v&&mn(v,e,6,l);const P=a[g+"Once"];if(P){if(!e.emitted)e.emitted={};else if(e.emitted[g])return;e.emitted[g]=!0,mn(P,e,6,l)}}const vf=new WeakMap;function Gu(e,i,o=!1){const a=o?vf:i.emitsCache,l=a.get(e);if(l!==void 0)return l;const c=e.emits;let h={},g=!1;if(!Lt(e)){const v=P=>{const k=Gu(P,i,!0);k&&(g=!0,Ce(h,k))};!o&&i.mixins.length&&i.mixins.forEach(v),e.extends&&v(e.extends),e.mixins&&e.mixins.forEach(v)}return!c&&!g?(Qt(e)&&a.set(e,null),null):(pt(c)?c.forEach(v=>h[v]=null):Ce(h,c),Qt(e)&&a.set(e,h),h)}function vr(e,i){return!e||!lr(i)?!1:(i=i.slice(2),i=i==="Once"?i:i.replace(/Once$/,""),Xt(e,i[0].toLowerCase()+i.slice(1))||Xt(e,Li(i))||Xt(e,i))}function ml(e){const{type:i,vnode:o,proxy:a,withProxy:l,propsOptions:[c],slots:h,attrs:g,emit:v,render:P,renderCache:k,props:O,data:$,setupState:F,ctx:nt,inheritAttrs:q}=e,At=Qo(e);let Dt,bt;try{if(o.shapeFlag&4){const X=l||a,ft=X;Dt=Fn(P.call(ft,X,k,O,F,$,nt)),bt=g}else{const X=i;Dt=Fn(X.length>1?X(O,{attrs:g,slots:h,emit:v}):X(O,null)),bt=i.props?g:yf(g)}}catch(X){so.length=0,pr(X,e,1),Dt=E(Be)}let ut=Dt;if(bt&&q!==!1){const X=Object.keys(bt),{shapeFlag:ft}=ut;X.length&&ft&7&&(c&&X.some(ur)&&(bt=bf(bt,c)),ut=Pi(ut,bt,!1,!0))}return o.dirs&&(ut=Pi(ut,null,!1,!0),ut.dirs=ut.dirs?ut.dirs.concat(o.dirs):o.dirs),o.transition&&lo(ut,o.transition),Dt=ut,Qo(At),Dt}const yf=e=>{let i;for(const o in e)(o==="class"||o==="style"||lr(o))&&((i||(i={}))[o]=e[o]);return i},bf=(e,i)=>{const o={};for(const a in e)(!ur(a)||!(a.slice(9)in i))&&(o[a]=e[a]);return o};function xf(e,i,o){const{props:a,children:l,component:c}=e,{props:h,children:g,patchFlag:v}=i,P=c.emitsOptions;if(i.dirs||i.transition)return!0;if(o&&v>=0){if(v&1024)return!0;if(v&16)return a?gl(a,h,P):!!h;if(v&8){const k=i.dynamicProps;for(let O=0;OObject.create(Ju),Qu=e=>Object.getPrototypeOf(e)===Ju;function kf(e,i,o,a=!1){const l={},c=Xu();e.propsDefaults=Object.create(null),tc(e,i,l,c);for(const h in e.propsOptions[0])h in l||(l[h]=void 0);o?e.props=a?l:Od(l):e.type.props?e.props=l:e.props=c,e.attrs=c}function Sf(e,i,o,a){const{props:l,attrs:c,vnode:{patchFlag:h}}=e,g=Gt(l),[v]=e.propsOptions;let P=!1;if((a||h>0)&&!(h&16)){if(h&8){const k=e.vnode.dynamicProps;for(let O=0;O{v=!0;const[$,F]=ec(O,i,!0);Ce(h,$),F&&g.push(...F)};!o&&i.mixins.length&&i.mixins.forEach(k),e.extends&&k(e.extends),e.mixins&&e.mixins.forEach(k)}if(!c&&!v)return Qt(e)&&a.set(e,_s),_s;if(pt(c))for(let k=0;ke==="_"||e==="_ctx"||e==="$stable",Ea=e=>pt(e)?e.map(Fn):[Fn(e)],Tf=(e,i,o)=>{if(i._n)return i;const a=ot((...l)=>Ea(i(...l)),o);return a._c=!1,a},nc=(e,i,o)=>{const a=e._ctx;for(const l in e){if(Oa(l))continue;const c=e[l];if(Lt(c))i[l]=Tf(l,c,a);else if(c!=null){const h=Ea(c);i[l]=()=>h}}},ic=(e,i)=>{const o=Ea(i);e.slots.default=()=>o},sc=(e,i,o)=>{for(const a in i)(o||!Oa(a))&&(e[a]=i[a])},Lf=(e,i,o)=>{const a=e.slots=Xu();if(e.vnode.shapeFlag&32){const l=i._;l?(sc(a,i,o),o&&lu(a,"_",l,!0)):nc(i,a)}else i&&ic(e,i)},Mf=(e,i,o)=>{const{vnode:a,slots:l}=e;let c=!0,h=ie;if(a.shapeFlag&32){const g=i._;g?o&&g===1?c=!1:sc(l,i,o):(c=!i.$stable,nc(i,l)),h=i}else i&&(ic(e,i),h={default:1});if(c)for(const g in l)!Oa(g)&&h[g]==null&&delete l[g]},Ye=Af;function Cf(e){return Of(e)}function Of(e,i){const o=fr();o.__VUE__=!0;const{insert:a,remove:l,patchProp:c,createElement:h,createText:g,createComment:v,setText:P,setElementText:k,parentNode:O,nextSibling:$,setScopeId:F=Zn,insertStaticContent:nt}=e,q=(_,m,T,B=null,I=null,D=null,j=void 0,A=null,U=!!m.dynamicChildren)=>{if(_===m)return;_&&!Wi(_,m)&&(B=Pe(_),Kt(_,I,D,!0),_=null),m.patchFlag===-2&&(U=!1,m.dynamicChildren=null);const{type:R,ref:ct,shapeFlag:Q}=m;switch(R){case yr:At(_,m,T,B);break;case Be:Dt(_,m,T,B);break;case Qr:_==null&&bt(m,T,B,j);break;case wt:G(_,m,T,B,I,D,j,A,U);break;default:Q&1?ft(_,m,T,B,I,D,j,A,U):Q&6?st(_,m,T,B,I,D,j,A,U):(Q&64||Q&128)&&R.process(_,m,T,B,I,D,j,A,U,we)}ct!=null&&I?no(ct,_&&_.ref,D,m||_,!m):ct==null&&_&&_.ref!=null&&no(_.ref,null,D,_,!0)},At=(_,m,T,B)=>{if(_==null)a(m.el=g(m.children),T,B);else{const I=m.el=_.el;m.children!==_.children&&P(I,m.children)}},Dt=(_,m,T,B)=>{_==null?a(m.el=v(m.children||""),T,B):m.el=_.el},bt=(_,m,T,B)=>{[_.el,_.anchor]=nt(_.children,m,T,B,_.el,_.anchor)},ut=({el:_,anchor:m},T,B)=>{let I;for(;_&&_!==m;)I=$(_),a(_,T,B),_=I;a(m,T,B)},X=({el:_,anchor:m})=>{let T;for(;_&&_!==m;)T=$(_),l(_),_=T;l(m)},ft=(_,m,T,B,I,D,j,A,U)=>{if(m.type==="svg"?j="svg":m.type==="math"&&(j="mathml"),_==null)jt(m,T,B,I,D,j,A,U);else{const R=_.el&&_.el._isVueCE?_.el:null;try{R&&R._beginPatch(),vt(_,m,I,D,j,A,U)}finally{R&&R._endPatch()}}},jt=(_,m,T,B,I,D,j,A)=>{let U,R;const{props:ct,shapeFlag:Q,transition:K,dirs:dt}=_;if(U=_.el=h(_.type,D,ct&&ct.is,ct),Q&8?k(U,_.children):Q&16&&me(_.children,U,null,B,I,Xr(_,D),j,A),dt&&Zi(_,null,B,"created"),de(U,_,_.scopeId,j,B),ct){for(const at in ct)at!=="value"&&!Xs(at)&&c(U,at,null,ct[at],D,B);"value"in ct&&c(U,"value",null,ct.value,D),(R=ct.onVnodeBeforeMount)&&Bn(R,B,_)}dt&&Zi(_,null,B,"beforeMount");const Mt=Ef(I,K);Mt&&K.beforeEnter(U),a(U,m,T),((R=ct&&ct.onVnodeMounted)||Mt||dt)&&Ye(()=>{try{R&&Bn(R,B,_),Mt&&K.enter(U),dt&&Zi(_,null,B,"mounted")}finally{}},I)},de=(_,m,T,B,I)=>{if(T&&F(_,T),B)for(let D=0;D{for(let R=U;R<_.length;R++){const ct=_[R]=A?ii(_[R]):Fn(_[R]);q(null,ct,m,T,B,I,D,j,A)}},vt=(_,m,T,B,I,D,j)=>{const A=m.el=_.el;let{patchFlag:U,dynamicChildren:R,dirs:ct}=m;U|=_.patchFlag&16;const Q=_.props||ie,K=m.props||ie;let dt;if(T&&$i(T,!1),(dt=K.onVnodeBeforeUpdate)&&Bn(dt,T,m,_),ct&&Zi(m,_,T,"beforeUpdate"),T&&$i(T,!0),R&&(!_.dynamicChildren||_.dynamicChildren.length!==R.length)&&(U=0,j=!1,R=null),(Q.innerHTML&&K.innerHTML==null||Q.textContent&&K.textContent==null)&&k(A,""),R?Ft(_.dynamicChildren,R,A,T,B,Xr(m,I),D):j||Y(_,m,A,null,T,B,Xr(m,I),D,!1),U>0){if(U&16)Tt(A,Q,K,T,I);else if(U&2&&Q.class!==K.class&&c(A,"class",null,K.class,I),U&4&&c(A,"style",Q.style,K.style,I),U&8){const Mt=m.dynamicProps;for(let at=0;at{dt&&Bn(dt,T,m,_),ct&&Zi(m,_,T,"updated")},B)},Ft=(_,m,T,B,I,D,j)=>{for(let A=0;A{if(m!==T){if(m!==ie)for(const D in m)!Xs(D)&&!(D in T)&&c(_,D,m[D],null,I,B);for(const D in T){if(Xs(D))continue;const j=T[D],A=m[D];j!==A&&D!=="value"&&c(_,D,A,j,I,B)}"value"in T&&c(_,"value",m.value,T.value,I)}},G=(_,m,T,B,I,D,j,A,U)=>{const R=m.el=_?_.el:g(""),ct=m.anchor=_?_.anchor:g("");let{patchFlag:Q,dynamicChildren:K,slotScopeIds:dt}=m;dt&&(A=A?A.concat(dt):dt),_==null?(a(R,T,B),a(ct,T,B),me(m.children||[],T,ct,I,D,j,A,U)):Q>0&&Q&64&&K&&_.dynamicChildren&&_.dynamicChildren.length===K.length?(Ft(_.dynamicChildren,K,T,I,D,j,A),(m.key!=null||I&&m===I.subTree)&&oc(_,m,!0)):Y(_,m,T,ct,I,D,j,A,U)},st=(_,m,T,B,I,D,j,A,U)=>{m.slotScopeIds=A,_==null?m.shapeFlag&512?I.ctx.activate(m,T,B,j,U):It(m,T,B,I,D,j,U):Wt(_,m,U)},It=(_,m,T,B,I,D,j)=>{const A=_.component=Vf(_,B,I);if(mr(_)&&(A.ctx.renderer=we),Zf(A,!1,j),A.asyncDep){if(I&&I.registerDep(A,kt,j),!_.el){const U=A.subTree=E(Be);Dt(null,U,m,T),_.placeholder=U.el}}else kt(A,_,m,T,I,D,j)},Wt=(_,m,T)=>{const B=m.component=_.component;if(xf(_,m,T))if(B.asyncDep&&!B.asyncResolved){Vt(B,m,T);return}else B.next=m,B.update();else m.el=_.el,B.vnode=m},kt=(_,m,T,B,I,D,j)=>{const A=()=>{if(_.isMounted){let{next:Q,bu:K,u:dt,parent:Mt,vnode:at}=_;{const ke=rc(_);if(ke){Q&&(Q.el=at.el,Vt(_,Q,j)),ke.asyncDep.then(()=>{Ye(()=>{_.isUnmounted||R()},I)});return}}let Rt=Q,ne;$i(_,!1),Q?(Q.el=at.el,Vt(_,Q,j)):Q=at,K&&Go(K),(ne=Q.props&&Q.props.onVnodeBeforeUpdate)&&Bn(ne,Mt,Q,at),$i(_,!0);const ae=ml(_),fe=_.subTree;_.subTree=ae,q(fe,ae,O(fe.el),Pe(fe),_,I,D),Q.el=ae.el,Rt===null&&wf(_,ae.el),dt&&Ye(dt,I),(ne=Q.props&&Q.props.onVnodeUpdated)&&Ye(()=>Bn(ne,Mt,Q,at),I)}else{let Q;const{el:K,props:dt}=m,{bm:Mt,m:at,parent:Rt,root:ne,type:ae}=_,fe=bs(m);$i(_,!1),Mt&&Go(Mt),!fe&&(Q=dt&&dt.onVnodeBeforeMount)&&Bn(Q,Rt,m),$i(_,!0);{ne.ce&&ne.ce._hasShadowRoot()&&ne.ce._injectChildStyle(ae,_.parent?_.parent.type:void 0);const ke=_.subTree=ml(_);q(null,ke,T,B,_,I,D),m.el=ke.el}if(at&&Ye(at,I),!fe&&(Q=dt&&dt.onVnodeMounted)){const ke=m;Ye(()=>Bn(Q,Rt,ke),I)}(m.shapeFlag&256||Rt&&bs(Rt.vnode)&&Rt.vnode.shapeFlag&256)&&_.a&&Ye(_.a,I),_.isMounted=!0,m=T=B=null}};_.scope.on();const U=_.effect=new fu(A);_.scope.off();const R=_.update=U.run.bind(U),ct=_.job=U.runIfDirty.bind(U);ct.i=_,ct.id=_.uid,U.scheduler=()=>Ca(ct),$i(_,!0),R()},Vt=(_,m,T)=>{m.component=_;const B=_.vnode.props;_.vnode=m,_.next=null,Sf(_,m.props,B,T),Mf(_,m.children,T),$n(),al(_),Hn()},Y=(_,m,T,B,I,D,j,A,U=!1)=>{const R=_&&_.children,ct=_?_.shapeFlag:0,Q=m.children,{patchFlag:K,shapeFlag:dt}=m;if(K>0){if(K&128){rt(R,Q,T,B,I,D,j,A,U);return}else if(K&256){ue(R,Q,T,B,I,D,j,A,U);return}}dt&8?(ct&16&&Pt(R,I,D),Q!==R&&k(T,Q)):ct&16?dt&16?rt(R,Q,T,B,I,D,j,A,U):Pt(R,I,D,!0):(ct&8&&k(T,""),dt&16&&me(Q,T,B,I,D,j,A,U))},ue=(_,m,T,B,I,D,j,A,U)=>{_=_||_s,m=m||_s;const R=_.length,ct=m.length,Q=Math.min(R,ct);let K;for(K=0;Kct?Pt(_,I,D,!0,!1,Q):me(m,T,B,I,D,j,A,U,Q)},rt=(_,m,T,B,I,D,j,A,U)=>{let R=0;const ct=m.length;let Q=_.length-1,K=ct-1;for(;R<=Q&&R<=K;){const dt=_[R],Mt=m[R]=U?ii(m[R]):Fn(m[R]);if(Wi(dt,Mt))q(dt,Mt,T,null,I,D,j,A,U);else break;R++}for(;R<=Q&&R<=K;){const dt=_[Q],Mt=m[K]=U?ii(m[K]):Fn(m[K]);if(Wi(dt,Mt))q(dt,Mt,T,null,I,D,j,A,U);else break;Q--,K--}if(R>Q){if(R<=K){const dt=K+1,Mt=dtK)for(;R<=Q;)Kt(_[R],I,D,!0),R++;else{const dt=R,Mt=R,at=new Map;for(R=Mt;R<=K;R++){const ve=m[R]=U?ii(m[R]):Fn(m[R]);ve.key!=null&&at.set(ve.key,R)}let Rt,ne=0;const ae=K-Mt+1;let fe=!1,ke=0;const _n=new Array(ae);for(R=0;R=ae){Kt(ve,I,D,!0);continue}let Ae;if(ve.key!=null)Ae=at.get(ve.key);else for(Rt=Mt;Rt<=K;Rt++)if(_n[Rt-Mt]===0&&Wi(ve,m[Rt])){Ae=Rt;break}Ae===void 0?Kt(ve,I,D,!0):(_n[Ae-Mt]=R+1,Ae>=ke?ke=Ae:fe=!0,q(ve,m[Ae],T,null,I,D,j,A,U),ne++)}const ui=fe?zf(_n):_s;for(Rt=ui.length-1,R=ae-1;R>=0;R--){const ve=Mt+R,Ae=m[ve],Cn=m[ve+1],Fe=ve+1{const{el:D,type:j,transition:A,children:U,shapeFlag:R}=_;if(R&6){yt(_.component.subTree,m,T,B);return}if(R&128){_.suspense.move(m,T,B);return}if(R&64){j.move(_,m,T,we);return}if(j===wt){a(D,m,T);for(let Q=0;QA.enter(D),I));else{const{leave:Q,delayLeave:K,afterLeave:dt}=A,Mt=()=>{_.ctx.isUnmounted?l(D):a(D,m,T)},at=()=>{const Rt=D._isLeaving||!!D[fn];D._isLeaving&&D[fn](!0),A.persisted&&!Rt?Mt():Q(D,()=>{Mt(),dt&&dt()})};K?K(D,Mt,at):at()}else a(D,m,T)},Kt=(_,m,T,B=!1,I=!1)=>{const{type:D,props:j,ref:A,children:U,dynamicChildren:R,shapeFlag:ct,patchFlag:Q,dirs:K,cacheIndex:dt,memo:Mt}=_;if(Q===-2&&(I=!1),A!=null&&($n(),no(A,null,T,_,!0),Hn()),dt!=null&&(m.renderCache[dt]=void 0),ct&256){m.ctx.deactivate(_);return}const at=ct&1&&K,Rt=!bs(_);let ne;if(Rt&&(ne=j&&j.onVnodeBeforeUnmount)&&Bn(ne,m,_),ct&6)Ut(_.component,T,B);else{if(ct&128){_.suspense.unmount(T,B);return}at&&Zi(_,null,m,"beforeUnmount"),ct&64?_.type.remove(_,m,T,we,B):R&&!R.hasOnce&&(D!==wt||Q>0&&Q&64)?Pt(R,m,T,!1,!0):(D===wt&&Q&384||!I&&ct&16)&&Pt(U,m,T),B&&ge(_)}const ae=Mt!=null&&dt==null;(Rt&&(ne=j&&j.onVnodeUnmounted)||at||ae)&&Ye(()=>{ne&&Bn(ne,m,_),at&&Zi(_,null,m,"unmounted"),ae&&(_.el=null)},T)},ge=_=>{const{type:m,el:T,anchor:B,transition:I}=_;if(m===wt){qt(T,B);return}if(m===Qr){X(_);return}const D=()=>{l(T),I&&!I.persisted&&I.afterLeave&&I.afterLeave()};if(_.shapeFlag&1&&I&&!I.persisted){const{leave:j,delayLeave:A}=I,U=()=>j(T,D);A?A(_.el,D,U):U()}else D()},qt=(_,m)=>{let T;for(;_!==m;)T=$(_),l(_),_=T;l(m)},Ut=(_,m,T)=>{const{bum:B,scope:I,job:D,subTree:j,um:A,m:U,a:R}=_;vl(U),vl(R),B&&Go(B),I.stop(),D&&(D.flags|=8,Kt(j,_,m,T)),A&&Ye(A,m),Ye(()=>{_.isUnmounted=!0},m)},Pt=(_,m,T,B=!1,I=!1,D=0)=>{for(let j=D;j<_.length;j++)Kt(_[j],m,T,B,I)},Pe=_=>{if(_.shapeFlag&6)return Pe(_.component.subTree);if(_.shapeFlag&128)return _.suspense.next();const m=$(_.anchor||_.el),T=m&&m[jd];return T?$(T):m};let Te=!1;const nn=(_,m,T)=>{let B;_==null?m._vnode&&(Kt(m._vnode,null,null,!0),B=m._vnode.component):q(m._vnode||null,_,m,null,null,null,T),m._vnode=_,Te||(Te=!0,al(B),Ou(),Te=!1)},we={p:q,um:Kt,m:yt,r:ge,mt:It,mc:me,pc:Y,pbc:Ft,n:Pe,o:e};return{render:nn,hydrate:void 0,createApp:mf(nn)}}function Xr({type:e,props:i},o){return o==="svg"&&e==="foreignObject"||o==="mathml"&&e==="annotation-xml"&&i&&i.encoding&&i.encoding.includes("html")?void 0:o}function $i({effect:e,job:i},o){o?(e.flags|=32,i.flags|=4):(e.flags&=-33,i.flags&=-5)}function Ef(e,i){return(!e||e&&!e.pendingBranch)&&i&&!i.persisted}function oc(e,i,o=!1){const a=e.children,l=i.children;if(pt(a)&&pt(l))for(let c=0;c>1,e[o[g]]0&&(i[a]=o[c-1]),o[c]=a)}}for(c=o.length,h=o[c-1];c-- >0;)o[c]=h,h=i[h];return o}function rc(e){const i=e.subTree.component;if(i)return i.asyncDep&&!i.asyncResolved?i:rc(i)}function vl(e){if(e)for(let i=0;ie.__isSuspense;function Af(e,i){i&&i.pendingBranch?pt(e)?i.effects.push(...e):i.effects.push(e):Zd(e)}const wt=Symbol.for("v-fgt"),yr=Symbol.for("v-txt"),Be=Symbol.for("v-cmt"),Qr=Symbol.for("v-stc"),so=[];let en=null;function b(e=!1){so.push(en=e?null:[])}function If(){so.pop(),en=so[so.length-1]||null}let uo=1;function nr(e,i=!1){uo+=e,e<0&&en&&i&&(en.hasOnce=!0)}function uc(e){return e.dynamicChildren=uo>0?en||_s:null,If(),uo>0&&en&&en.push(e),e}function x(e,i,o,a,l,c){return uc(f(e,i,o,a,l,c,!0))}function oe(e,i,o,a,l){return uc(E(e,i,o,a,l,!0))}function co(e){return e?e.__v_isVNode===!0:!1}function Wi(e,i){return e.type===i.type&&e.key===i.key}const cc=({key:e})=>e??null,Yo=({ref:e,ref_key:i,ref_for:o})=>(typeof e=="number"&&(e=""+e),e!=null?pe(e)||Re(e)||Lt(e)?{i:De,r:e,k:i,f:!!o}:e:null);function f(e,i=null,o=null,a=0,l=null,c=e===wt?0:1,h=!1,g=!1){const v={__v_isVNode:!0,__v_skip:!0,type:e,props:i,key:i&&cc(i),ref:i&&Yo(i),scopeId:zu,slotScopeIds:null,children:o,component:null,suspense:null,ssContent:null,ssFallback:null,dirs:null,transition:null,el:null,anchor:null,target:null,targetStart:null,targetAnchor:null,staticCount:0,shapeFlag:c,patchFlag:a,dynamicProps:l,dynamicChildren:null,appContext:null,ctx:De};return g?(ir(v,o),c&128&&e.normalize(v)):o&&(v.shapeFlag|=pe(o)?8:16),uo>0&&!h&&en&&(v.patchFlag>0||c&6)&&v.patchFlag!==32&&en.push(v),v}const E=Nf;function Nf(e,i=null,o=null,a=0,l=null,c=!1){if((!e||e===rf)&&(e=Be),co(e)){const g=Pi(e,i,!0);return o&&ir(g,o),uo>0&&!c&&en&&(g.shapeFlag&6?en[en.indexOf(e)]=g:en.push(g)),g.patchFlag=-2,g}if(jf(e)&&(e=e.__vccOpts),i){i=Bf(i);let{class:g,style:v}=i;g&&!pe(g)&&(i.class=Ot(g)),Qt(v)&&(Ma(v)&&!pt(v)&&(v=Ce({},v)),i.style=ks(v))}const h=pe(e)?1:lc(e)?128:Bu(e)?64:Qt(e)?4:Lt(e)?2:0;return f(e,i,o,a,l,h,c,!0)}function Bf(e){return e?Ma(e)||Qu(e)?Ce({},e):e:null}function Pi(e,i,o=!1,a=!1){const{props:l,ref:c,patchFlag:h,children:g,transition:v}=e,P=i?Df(l||{},i):l,k={__v_isVNode:!0,__v_skip:!0,type:e.type,props:P,key:P&&cc(P),ref:i&&i.ref?o&&c?pt(c)?c.concat(Yo(i)):[c,Yo(i)]:Yo(i):c,scopeId:e.scopeId,slotScopeIds:e.slotScopeIds,children:g,target:e.target,targetStart:e.targetStart,targetAnchor:e.targetAnchor,staticCount:e.staticCount,shapeFlag:e.shapeFlag,patchFlag:i&&e.type!==wt?h===-1?16:h|16:h,dynamicProps:e.dynamicProps,dynamicChildren:e.dynamicChildren,appContext:e.appContext,dirs:e.dirs,transition:v,component:e.component,suspense:e.suspense,ssContent:e.ssContent&&Pi(e.ssContent),ssFallback:e.ssFallback&&Pi(e.ssFallback),placeholder:e.placeholder,el:e.el,anchor:e.anchor,ctx:e.ctx,ce:e.ce};return v&&a&&lo(k,v.clone(k)),k}function N(e=" ",i=0){return E(yr,null,e,i)}function V(e="",i=!1){return i?(b(),oe(Be,null,e)):E(Be,null,e)}function Fn(e){return e==null||typeof e=="boolean"?E(Be):pt(e)?E(wt,null,e.slice()):co(e)?ii(e):E(yr,null,String(e))}function ii(e){return e.el===null&&e.patchFlag!==-1||e.memo?e:Pi(e)}function ir(e,i){let o=0;const{shapeFlag:a}=e;if(i==null)i=null;else if(pt(i))o=16;else if(typeof i=="object")if(a&65){const l=i.default;l&&(l._c&&(l._d=!1),ir(e,l()),l._c&&(l._d=!0));return}else{o=32;const l=i._;!l&&!Qu(i)?i._ctx=De:l===3&&De&&(De.slots._===1?i._=1:(i._=2,e.patchFlag|=1024))}else if(Lt(i)){if(a&65){ir(e,{default:i});return}i={default:i,_ctx:De},o=32}else i=String(i),a&64?(o=16,i=[N(i)]):o=8;e.children=i,e.shapeFlag|=o}function Df(...e){const i={};for(let o=0;oWe||De;let sr,ha;{const e=fr(),i=(o,a)=>{let l;return(l=e[o])||(l=e[o]=[]),l.push(a),c=>{l.length>1?l.forEach(h=>h(c)):l[0](c)}};sr=i("__VUE_INSTANCE_SETTERS__",o=>We=o),ha=i("__VUE_SSR_SETTERS__",o=>fo=o)}const go=e=>{const i=We;return sr(e),e.scope.on(),()=>{e.scope.off(),sr(i)}},yl=()=>{We&&We.scope.off(),sr(null)};function fc(e){return e.vnode.shapeFlag&4}let fo=!1;function Zf(e,i=!1,o=!1){i&&ha(i);const{props:a,children:l}=e.vnode,c=fc(e);kf(e,a,c,i),Lf(e,l,o||i);const h=c?$f(e,i):void 0;return i&&ha(!1),h}function $f(e,i){const o=e.type;e.accessCache=Object.create(null),e.proxy=new Proxy(e.ctx,lf);const{setup:a}=o;if(a){$n();const l=e.setupContext=a.length>1?Uf(e):null,c=go(e),h=mo(a,e,0,[e.props,l]),g=su(h);if(Hn(),c(),(g||e.sp)&&!bs(e)&&$u(e),g){if(h.then(yl,yl),i)return h.then(v=>{bl(e,v)}).catch(v=>{pr(v,e,0)});e.asyncDep=h}else bl(e,h)}else hc(e)}function bl(e,i,o){Lt(i)?e.type.__ssrInlineRender?e.ssrRender=i:e.render=i:Qt(i)&&(e.setupState=Tu(i)),hc(e)}function hc(e,i,o){const a=e.type;e.render||(e.render=a.render||Zn);{const l=go(e);$n();try{uf(e)}finally{Hn(),l()}}}const Hf={get(e,i){return Ne(e,"get",""),e[i]}};function Uf(e){const i=o=>{e.exposed=o||{}};return{attrs:new Proxy(e.attrs,Hf),slots:e.slots,emit:e.emit,expose:i}}function br(e){return e.exposed?e.exposeProxy||(e.exposeProxy=new Proxy(Tu(Ed(e.exposed)),{get(i,o){if(o in i)return i[o];if(o in io)return io[o](e)},has(i,o){return o in i||o in io}})):e.proxy}function jf(e){return Lt(e)&&"__vccOpts"in e}const ht=(e,i)=>Bd(e,i,fo);function Wf(e,i,o){try{nr(-1);const a=arguments.length;return a===2?Qt(i)&&!pt(i)?co(i)?E(e,null,[i]):E(e,i):E(e,null,i):(a>3?o=Array.prototype.slice.call(arguments,2):a===3&&co(o)&&(o=[o]),E(e,i,o))}finally{nr(1)}}const Kf="3.5.39";/** +* @vue/runtime-dom v3.5.39 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/let pa;const xl=typeof window<"u"&&window.trustedTypes;if(xl)try{pa=xl.createPolicy("vue",{createHTML:e=>e})}catch{}const pc=pa?e=>pa.createHTML(e):e=>e,qf="http://www.w3.org/2000/svg",Gf="http://www.w3.org/1998/Math/MathML",ni=typeof document<"u"?document:null,wl=ni&&ni.createElement("template"),Yf={insert:(e,i,o)=>{i.insertBefore(e,o||null)},remove:e=>{const i=e.parentNode;i&&i.removeChild(e)},createElement:(e,i,o,a)=>{const l=i==="svg"?ni.createElementNS(qf,e):i==="mathml"?ni.createElementNS(Gf,e):o?ni.createElement(e,{is:o}):ni.createElement(e);return e==="select"&&a&&a.multiple!=null&&l.setAttribute("multiple",a.multiple),l},createText:e=>ni.createTextNode(e),createComment:e=>ni.createComment(e),setText:(e,i)=>{e.nodeValue=i},setElementText:(e,i)=>{e.textContent=i},parentNode:e=>e.parentNode,nextSibling:e=>e.nextSibling,querySelector:e=>ni.querySelector(e),setScopeId(e,i){e.setAttribute(i,"")},insertStaticContent(e,i,o,a,l,c){const h=o?o.previousSibling:i.lastChild;if(l&&(l===c||l.nextSibling))for(;i.insertBefore(l.cloneNode(!0),o),!(l===c||!(l=l.nextSibling)););else{wl.innerHTML=pc(a==="svg"?`${e}`:a==="mathml"?`${e}`:e);const g=wl.content;if(a==="svg"||a==="mathml"){const v=g.firstChild;for(;v.firstChild;)g.appendChild(v.firstChild);g.removeChild(v)}i.insertBefore(g,o)}return[h?h.nextSibling:i.firstChild,o?o.previousSibling:i.lastChild]}},xi="transition",Ks="animation",ho=Symbol("_vtc"),mc={name:String,type:String,css:{type:Boolean,default:!0},duration:[String,Number,Object],enterFromClass:String,enterActiveClass:String,enterToClass:String,appearFromClass:String,appearActiveClass:String,appearToClass:String,leaveFromClass:String,leaveActiveClass:String,leaveToClass:String},Jf=Ce({},Du,mc),Xf=e=>(e.displayName="Transition",e.props=Jf,e),Qf=Xf((e,{slots:i})=>Wf(qd,th(e),i)),Hi=(e,i=[])=>{pt(e)?e.forEach(o=>o(...i)):e&&e(...i)},kl=e=>e?pt(e)?e.some(i=>i.length>1):e.length>1:!1;function th(e){const i={};for(const G in e)G in mc||(i[G]=e[G]);if(e.css===!1)return i;const{name:o="v",type:a,duration:l,enterFromClass:c=`${o}-enter-from`,enterActiveClass:h=`${o}-enter-active`,enterToClass:g=`${o}-enter-to`,appearFromClass:v=c,appearActiveClass:P=h,appearToClass:k=g,leaveFromClass:O=`${o}-leave-from`,leaveActiveClass:$=`${o}-leave-active`,leaveToClass:F=`${o}-leave-to`}=e,nt=eh(l),q=nt&&nt[0],At=nt&&nt[1],{onBeforeEnter:Dt,onEnter:bt,onEnterCancelled:ut,onLeave:X,onLeaveCancelled:ft,onBeforeAppear:jt=Dt,onAppear:de=bt,onAppearCancelled:me=ut}=i,vt=(G,st,It,Wt)=>{G._enterCancelled=Wt,Ui(G,st?k:g),Ui(G,st?P:h),It&&It()},Ft=(G,st)=>{G._isLeaving=!1,Ui(G,O),Ui(G,F),Ui(G,$),st&&st()},Tt=G=>(st,It)=>{const Wt=G?de:bt,kt=()=>vt(st,G,It);Hi(Wt,[st,kt]),Sl(()=>{Ui(st,G?v:c),ei(st,G?k:g),kl(Wt)||Pl(st,a,q,kt)})};return Ce(i,{onBeforeEnter(G){Hi(Dt,[G]),ei(G,c),ei(G,h)},onBeforeAppear(G){Hi(jt,[G]),ei(G,v),ei(G,P)},onEnter:Tt(!1),onAppear:Tt(!0),onLeave(G,st){G._isLeaving=!0;const It=()=>Ft(G,st);ei(G,O),G._enterCancelled?(ei(G,$),Ml(G)):(Ml(G),ei(G,$)),Sl(()=>{G._isLeaving&&(Ui(G,O),ei(G,F),kl(X)||Pl(G,a,At,It))}),Hi(X,[G,It])},onEnterCancelled(G){vt(G,!1,void 0,!0),Hi(ut,[G])},onAppearCancelled(G){vt(G,!0,void 0,!0),Hi(me,[G])},onLeaveCancelled(G){Ft(G),Hi(ft,[G])}})}function eh(e){if(e==null)return null;if(Qt(e))return[ta(e.enter),ta(e.leave)];{const i=ta(e);return[i,i]}}function ta(e){return id(e)}function ei(e,i){i.split(/\s+/).forEach(o=>o&&e.classList.add(o)),(e[ho]||(e[ho]=new Set)).add(i)}function Ui(e,i){i.split(/\s+/).forEach(a=>a&&e.classList.remove(a));const o=e[ho];o&&(o.delete(i),o.size||(e[ho]=void 0))}function Sl(e){requestAnimationFrame(()=>{requestAnimationFrame(e)})}let nh=0;function Pl(e,i,o,a){const l=e._endId=++nh,c=()=>{l===e._endId&&a()};if(o!=null)return setTimeout(c,o);const{type:h,timeout:g,propCount:v}=ih(e,i);if(!h)return a();const P=h+"end";let k=0;const O=()=>{e.removeEventListener(P,$),c()},$=F=>{F.target===e&&++k>=v&&O()};setTimeout(()=>{k(o[nt]||"").split(", "),l=a(`${xi}Delay`),c=a(`${xi}Duration`),h=Tl(l,c),g=a(`${Ks}Delay`),v=a(`${Ks}Duration`),P=Tl(g,v);let k=null,O=0,$=0;i===xi?h>0&&(k=xi,O=h,$=c.length):i===Ks?P>0&&(k=Ks,O=P,$=v.length):(O=Math.max(h,P),k=O>0?h>P?xi:Ks:null,$=k?k===xi?c.length:v.length:0);const F=k===xi&&/\b(?:transform|all)(?:,|$)/.test(a(`${xi}Property`).toString());return{type:k,timeout:O,propCount:$,hasTransform:F}}function Tl(e,i){for(;e.lengthLl(o)+Ll(e[a])))}function Ll(e){return e==="auto"?0:Number(e.slice(0,-1).replace(",","."))*1e3}function Ml(e){return(e?e.ownerDocument:document).body.offsetHeight}function sh(e,i,o){const a=e[ho];a&&(i=(i?[i,...a]:[...a]).join(" ")),i==null?e.removeAttribute("class"):o?e.setAttribute("class",i):e.className=i}const or=Symbol("_vod"),gc=Symbol("_vsh"),oh={name:"show",beforeMount(e,{value:i},{transition:o}){e[or]=e.style.display==="none"?"":e.style.display,o&&i?o.beforeEnter(e):qs(e,i)},mounted(e,{value:i},{transition:o}){o&&i&&o.enter(e)},updated(e,{value:i,oldValue:o},{transition:a}){!i!=!o&&(a?i?(a.beforeEnter(e),qs(e,!0),a.enter(e)):a.leave(e,()=>{qs(e,!1)}):qs(e,i))},beforeUnmount(e,{value:i}){qs(e,i)}};function qs(e,i){e.style.display=i?e[or]:"none",e[gc]=!i}const rh=Symbol(""),ah=/(?:^|;)\s*display\s*:/;function lh(e,i,o){const a=e.style,l=pe(o);let c=!1;if(o&&!l){if(i)if(pe(i))for(const h of i.split(";")){const g=h.slice(0,h.indexOf(":")).trim();o[g]==null&&Ys(a,g,"")}else for(const h in i)o[h]==null&&Ys(a,h,"");for(const h in o){h==="display"&&(c=!0);const g=o[h];g!=null?ch(e,h,!pe(i)&&i?i[h]:void 0,g)||Ys(a,h,g):Ys(a,h,"")}}else if(l){if(i!==o){const h=a[rh];h&&(o+=";"+h),a.cssText=o,c=ah.test(o)}}else i&&e.removeAttribute("style");or in e&&(e[or]=c?a.display:"",e[gc]&&(a.display="none"))}const Cl=/\s*!important$/;function Ys(e,i,o){if(pt(o))o.forEach(a=>Ys(e,i,a));else if(o==null&&(o=""),i.startsWith("--"))e.setProperty(i,o);else{const a=uh(e,i);Cl.test(o)?e.setProperty(Li(a),o.replace(Cl,""),"important"):e[a]=o}}const Ol=["Webkit","Moz","ms"],ea={};function uh(e,i){const o=ea[i];if(o)return o;let a=Pn(i);if(a!=="filter"&&a in e)return ea[i]=a;a=au(a);for(let l=0;lna||(gh.then(()=>na=0),na=Date.now());function vh(e,i){const o=a=>{if(!a._vts)a._vts=Date.now();else if(a._vts<=o.attached)return;const l=o.value;if(pt(l)){const c=a.stopImmediatePropagation;a.stopImmediatePropagation=()=>{c.call(a),a._stopped=!0};const h=l.slice(),g=[a];for(let v=0;ve.charCodeAt(0)===111&&e.charCodeAt(1)===110&&e.charCodeAt(2)>96&&e.charCodeAt(2)<123,yh=(e,i,o,a,l,c)=>{const h=l==="svg";i==="class"?sh(e,a,h):i==="style"?lh(e,o,a):lr(i)?ur(i)||fh(e,i,o,a,c):(i[0]==="."?(i=i.slice(1),!0):i[0]==="^"?(i=i.slice(1),!1):bh(e,i,a,h))?(Al(e,i,a),!e.tagName.includes("-")&&(i==="value"||i==="checked"||i==="selected")&&zl(e,i,a,h,c,i!=="value")):e._isVueCE&&(xh(e,i)||e._def.__asyncLoader&&(/[A-Z]/.test(i)||!pe(a)))?Al(e,Pn(i),a,c,i):(i==="true-value"?e._trueValue=a:i==="false-value"&&(e._falseValue=a),zl(e,i,a,h))};function bh(e,i,o,a){if(a)return!!(i==="innerHTML"||i==="textContent"||i in e&&Nl(i)&&Lt(o));if(i==="spellcheck"||i==="draggable"||i==="translate"||i==="autocorrect"||i==="sandbox"&&e.tagName==="IFRAME"||i==="form"||i==="list"&&e.tagName==="INPUT"||i==="type"&&e.tagName==="TEXTAREA")return!1;if(i==="width"||i==="height"){const l=e.tagName;if(l==="IMG"||l==="VIDEO"||l==="CANVAS"||l==="SOURCE")return!1}return Nl(i)&&pe(o)?!1:i in e}function xh(e,i){const o=e._def.props;if(!o)return!1;const a=Pn(i);return Array.isArray(o)?o.some(l=>Pn(l)===a):Object.keys(o).some(l=>Pn(l)===a)}const Ti=e=>{const i=e.props["onUpdate:modelValue"]||!1;return pt(i)?o=>Go(i,o):i};function wh(e){e.target.composing=!0}function Bl(e){const i=e.target;i.composing&&(i.composing=!1,i.dispatchEvent(new Event("input")))}const pn=Symbol("_assign");function Dl(e,i,o){return i&&(e=e.trim()),o&&(e=dr(e)),e}const Bt={created(e,{modifiers:{lazy:i,trim:o,number:a}},l){e[pn]=Ti(l);const c=a||l.props&&l.props.type==="number";ri(e,i?"change":"input",h=>{h.target.composing||e[pn](Dl(e.value,o,c))}),(o||c)&&ri(e,"change",()=>{e.value=Dl(e.value,o,c)}),i||(ri(e,"compositionstart",wh),ri(e,"compositionend",Bl),ri(e,"change",Bl))},mounted(e,{value:i}){e.value=i??""},beforeUpdate(e,{value:i,oldValue:o,modifiers:{lazy:a,trim:l,number:c}},h){if(e[pn]=Ti(h),e.composing)return;const g=(c||e.type==="number")&&!/^0\d/.test(e.value)?dr(e.value):e.value,v=i??"";if(g===v)return;const P=e.getRootNode();(P instanceof Document||P instanceof ShadowRoot)&&P.activeElement===e&&e.type!=="range"&&(a&&i===o||l&&e.value.trim()===v)||(e.value=v)}},_c={deep:!0,created(e,i,o){e[pn]=Ti(o),ri(e,"change",()=>{const a=e._modelValue,l=Ps(e),c=e.checked,h=e[pn];if(pt(a)){const g=xa(a,l),v=g!==-1;if(c&&!v)h(a.concat(l));else if(!c&&v){const P=[...a];P.splice(g,1),h(P)}}else if(Ts(a)){const g=new Set(a);c?g.add(l):g.delete(l),h(g)}else h(vc(e,c))})},mounted:Rl,beforeUpdate(e,i,o){e[pn]=Ti(o),Rl(e,i,o)}};function Rl(e,{value:i,oldValue:o},a){e._modelValue=i;let l;if(pt(i))l=xa(i,a.props.value)>-1;else if(Ts(i))l=i.has(a.props.value);else{if(i===o)return;l=Si(i,vc(e,!0))}e.checked!==l&&(e.checked=l)}const kh={created(e,{value:i},o){e.checked=Si(i,o.props.value),e[pn]=Ti(o),ri(e,"change",()=>{e[pn](Ps(e))})},beforeUpdate(e,{value:i,oldValue:o},a){e[pn]=Ti(a),i!==o&&(e.checked=Si(i,a.props.value))}},wi={deep:!0,created(e,{value:i,modifiers:{number:o}},a){const l=Ts(i);ri(e,"change",()=>{const c=Array.prototype.filter.call(e.options,h=>h.selected).map(h=>o?dr(Ps(h)):Ps(h));e[pn](e.multiple?l?new Set(c):c:c[0]),e._assigning=!0,Mu(()=>{e._assigning=!1})}),e[pn]=Ti(a)},mounted(e,{value:i}){Fl(e,i)},beforeUpdate(e,i,o){e[pn]=Ti(o)},updated(e,{value:i}){e._assigning||Fl(e,i)}};function Fl(e,i){const o=e.multiple,a=pt(i);if(!(o&&!a&&!Ts(i))){for(let l=0,c=e.options.length;lString(P)===String(g)):h.selected=xa(i,g)>-1}else h.selected=i.has(g);else if(Si(Ps(h),i)){e.selectedIndex!==l&&(e.selectedIndex=l);return}}!o&&e.selectedIndex!==-1&&(e.selectedIndex=-1)}}function Ps(e){return"_value"in e?e._value:e.value}function vc(e,i){const o=i?"_trueValue":"_falseValue";return o in e?e[o]:i}const Sh={created(e,i,o){Wo(e,i,o,null,"created")},mounted(e,i,o){Wo(e,i,o,null,"mounted")},beforeUpdate(e,i,o,a){Wo(e,i,o,a,"beforeUpdate")},updated(e,i,o,a){Wo(e,i,o,a,"updated")}};function Ph(e,i){switch(e){case"SELECT":return wi;case"TEXTAREA":return Bt;default:switch(i){case"checkbox":return _c;case"radio":return kh;default:return Bt}}}function Wo(e,i,o,a,l){const h=Ph(e.tagName,o.props&&o.props.type)[l];h&&h(e,i,o,a)}const Th=["ctrl","shift","alt","meta"],Lh={stop:e=>e.stopPropagation(),prevent:e=>e.preventDefault(),self:e=>e.target!==e.currentTarget,ctrl:e=>!e.ctrlKey,shift:e=>!e.shiftKey,alt:e=>!e.altKey,meta:e=>!e.metaKey,left:e=>"button"in e&&e.button!==0,middle:e=>"button"in e&&e.button!==1,right:e=>"button"in e&&e.button!==2,exact:(e,i)=>Th.some(o=>e[`${o}Key`]&&!i.includes(o))},yc=(e,i)=>{if(!e)return e;const o=e._withMods||(e._withMods={}),a=i.join(".");return o[a]||(o[a]=((l,...c)=>{for(let h=0;h{const o=e._withKeys||(e._withKeys={}),a=i.join(".");return o[a]||(o[a]=(l=>{if(!("key"in l))return;const c=Li(l.key);if(i.some(h=>h===c||Mh[h]===c))return e(l)}))},Ch=Ce({patchProp:yh},Yf);let Zl;function Oh(){return Zl||(Zl=Cf(Ch))}const Eh=((...e)=>{const i=Oh().createApp(...e),{mount:o}=i;return i.mount=a=>{const l=Ah(a);if(!l)return;const c=i._component;!Lt(c)&&!c.render&&!c.template&&(c.template=l.innerHTML),l.nodeType===1&&(l.textContent="");const h=o(l,!1,zh(l));return l instanceof Element&&(l.removeAttribute("v-cloak"),l.setAttribute("data-v-app","")),h},i});function zh(e){if(e instanceof SVGElement)return"svg";if(typeof MathMLElement=="function"&&e instanceof MathMLElement)return"mathml"}function Ah(e){return pe(e)?document.querySelector(e):e}const bc="pv_theme",$l={light:"#EEF0F3",dark:"#0B1730"},rr=typeof window<"u"&&window.matchMedia?window.matchMedia("(prefers-color-scheme: dark)"):null;function xc(){return rr&&rr.matches?"dark":"light"}function Ih(){try{return localStorage.getItem(bc)||"light"}catch{return"light"}}function wc(e){return e==="system"?xc():e}function kc(e){const i=document.documentElement;i.setAttribute("data-theme",e),i.style.backgroundColor=$l[e]||$l.light}const Gi=J(Ih()),ws=J(wc(Gi.value));function ar(e){Gi.value=e;const i=wc(e);ws.value=i,kc(i);try{localStorage.setItem(bc,e)}catch{}}function Hl(){ar(ws.value==="dark"?"light":"dark")}rr&&rr.addEventListener("change",()=>{if(Gi.value==="system"){const e=xc();ws.value=e,kc(e)}});async function Nh(){try{const e=await fetch("/bff/config");return e.ok?await e.json():{apiBase:""}}catch{return{apiBase:""}}}async function Ul(){try{const e=await fetch("/bff/me");return e.ok?await e.json():null}catch{return null}}async function Bh(e,i,o){const a=await fetch("/bff/login",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email:e,password:i,apiBase:o})});return{ok:a.ok,status:a.status,body:await a.json().catch(()=>({}))}}async function Dh(){try{await fetch("/bff/logout",{method:"POST"})}catch{}}async function Rh(){try{const e=await fetch("/bff/devices");return e.ok?await e.json():[]}catch{return[]}}async function Fh(){try{const e=await fetch("/bff/users");return e.ok?{ok:!0,status:200,users:(await e.json()).users||[]}:{ok:!1,status:e.status,users:[]}}catch{return{ok:!1,status:0,users:[]}}}async function Vh(e,i,o,a){const l=await fetch("/bff/users",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email:e,password:i,role:o,organization:a})});return{ok:l.ok,status:l.status,body:await l.json().catch(()=>({}))}}async function Zh(e,i){const o=await fetch(`/bff/users/${encodeURIComponent(e)}`,{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify(i)});return{ok:o.ok,status:o.status,body:await o.json().catch(()=>({}))}}async function $h(e){const i=await fetch(`/bff/users/${encodeURIComponent(e)}`,{method:"DELETE"});return{ok:i.ok,status:i.status,body:await i.json().catch(()=>({}))}}async function Hh(){try{const e=await fetch("/bff/orgs");return e.ok?{ok:!0,status:200,organizations:(await e.json()).organizations||[]}:{ok:!1,status:e.status,organizations:[]}}catch{return{ok:!1,status:0,organizations:[]}}}async function Uh(e){const i=await fetch("/bff/orgs",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:e})});return{ok:i.ok,status:i.status,body:await i.json().catch(()=>({}))}}async function jh(e,i){const o=await fetch(`/bff/orgs/${encodeURIComponent(e)}`,{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:i})});return{ok:o.ok,status:o.status,body:await o.json().catch(()=>({}))}}async function Wh(e){const i=await fetch(`/bff/orgs/${encodeURIComponent(e)}`,{method:"DELETE"});return{ok:i.ok,status:i.status,body:await i.json().catch(()=>({}))}}async function Kh(){try{const e=await fetch("/bff/preferences");if(!e.ok)return null;const i=await e.json();return i&&typeof i.preferences=="object"?i.preferences:null}catch{return null}}async function qh(e){try{return(await fetch("/bff/preferences",{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({preferences:e})})).ok}catch{return!1}}async function Gh(){try{const e=await fetch("/bff/integrations/opensky");return e.ok?{ok:!0,status:200,body:await e.json()}:{ok:!1,status:e.status,body:await e.json().catch(()=>({}))}}catch{return{ok:!1,status:0,body:{}}}}async function jl(e){const i=await fetch("/bff/integrations/opensky",{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)});return{ok:i.ok,status:i.status,body:await i.json().catch(()=>({}))}}async function Yh(){const e=await fetch("/bff/integrations/opensky/health",{method:"POST"});return{ok:e.ok,status:e.status,body:await e.json().catch(()=>({}))}}async function Jh(){try{const e=await fetch("/bff/integrations/filetransfer");return e.ok?{ok:!0,status:200,body:await e.json()}:{ok:!1,status:e.status,body:await e.json().catch(()=>({}))}}catch{return{ok:!1,status:0,body:{}}}}async function Wl(e){const i=await fetch("/bff/integrations/filetransfer",{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)});return{ok:i.ok,status:i.status,body:await i.json().catch(()=>({}))}}async function Xh(){const e=await fetch("/bff/integrations/filetransfer/health",{method:"POST"});return{ok:e.ok,status:e.status,body:await e.json().catch(()=>({}))}}async function Qh(){try{const e=await fetch("/bff/integrations/localstorage");return e.ok?{ok:!0,status:200,body:await e.json()}:{ok:!1,status:e.status,body:await e.json().catch(()=>({}))}}catch{return{ok:!1,status:0,body:{}}}}async function Ko(e){const i=await fetch("/bff/integrations/localstorage",{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)});return{ok:i.ok,status:i.status,body:await i.json().catch(()=>({}))}}async function tp(){const e=await fetch("/bff/integrations/localstorage/health",{method:"POST"});return{ok:e.ok,status:e.status,body:await e.json().catch(()=>({}))}}async function ep(){try{const e=await fetch("/bff/integrations/webdav");return e.ok?{ok:!0,status:200,body:await e.json()}:{ok:!1,status:e.status,body:await e.json().catch(()=>({}))}}catch{return{ok:!1,status:0,body:{}}}}async function Kl(e){const i=await fetch("/bff/integrations/webdav",{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)});return{ok:i.ok,status:i.status,body:await i.json().catch(()=>({}))}}async function np(){const e=await fetch("/bff/integrations/webdav/health",{method:"POST"});return{ok:e.ok,status:e.status,body:await e.json().catch(()=>({}))}}async function ip(e,i,o){const a=await fetch(`/bff/devices/${encodeURIComponent(e)}/command`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({command:i,payload:o})});return{ok:a.ok,body:await a.json().catch(()=>({}))}}const Sc="pv_prefs",ma={name:"",username:"",displayName:"",bio:"",avatar:"",showEmail:!1,fontSize:"md",language:"en",region:"US",dateFormat:"MDY",timeFormat:"24",reduceMotion:!1,twoFactor:!1};function sp(){try{return{...ma,...JSON.parse(localStorage.getItem(Sc)||"{}")||{}}}catch{return{...ma}}}const gt=xe(sp());function Pc(){try{localStorage.setItem(Sc,JSON.stringify(gt))}catch{}}function Tc(e){if(!e||typeof e!="object")return!1;for(const i of Object.keys(ma))i in e&&(gt[i]=e[i]);return!0}const op={sm:15,md:16,lg:18};function za(e){document.documentElement.style.fontSize=(op[e]||16)+"px"}function Aa(e){document.documentElement.classList.toggle("reduce-motion",!!e)}function Lc(e){const i=new Date(e),o=i.getFullYear(),a=String(i.getMonth()+1).padStart(2,"0"),l=String(i.getDate()).padStart(2,"0");let c;switch(gt.dateFormat){case"DMY":c=`${l}/${a}/${o}`;break;case"YMD":c=`${o}/${a}/${l}`;break;case"ISO":c=`${o}-${a}-${l}`;break;default:c=`${a}/${l}/${o}`}let h;return gt.timeFormat==="12"?h=i.toLocaleTimeString("en-US",{hour:"numeric",minute:"2-digit",second:"2-digit",hour12:!0}):h=`${String(i.getHours()).padStart(2,"0")}:${String(i.getMinutes()).padStart(2,"0")}:${String(i.getSeconds()).padStart(2,"0")}`,{date:c,time:h}}function ql(e){return Lc(e).time}function Gl(e){const i=Lc(e);return`${i.date} ${i.time}`}let Ia=!1,ga=!1,_a=null;function rp(){return{...JSON.parse(JSON.stringify(gt)),themeMode:Gi.value}}function Na(){!Ia||ga||(clearTimeout(_a),_a=setTimeout(()=>{qh(rp())},600))}function ap(e){ga=!0;try{Tc(e),e.themeMode&&ar(e.themeMode),za(gt.fontSize),Aa(gt.reduceMotion),Pc()}finally{ga=!1}}async function Yl(){Ia=!0;const e=await Kh();e&&Object.keys(e).length?ap(e):Na()}function lp(){Ia=!1,clearTimeout(_a)}Je(gt,()=>{Pc(),Na()},{deep:!0});Je(Gi,Na);Je(()=>gt.fontSize,za,{immediate:!0});Je(()=>gt.reduceMotion,Aa,{immediate:!0});const up=["width","height"],Mc={__name:"BrandMark",props:{size:{type:[Number,String],default:28}},setup(e){return(i,o)=>(b(),x("svg",{width:e.size,height:e.size,viewBox:"0 0 48 48",fill:"none","aria-hidden":"true"},[...o[0]||(o[0]=[f("g",{"stroke-width":"4","stroke-linecap":"round","stroke-linejoin":"round"},[f("polyline",{points:"8,30 19,17 30,30",stroke:"var(--accent)"}),f("polyline",{points:"18,33 29,20 40,33",stroke:"currentColor"})],-1)])],8,up))}},cp=["title","aria-label"],dp={key:0,width:"18",height:"18",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round"},fp={key:1,width:"18",height:"18",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round"},hp={__name:"ThemeToggle",setup(e){return(i,o)=>(b(),x("button",{class:"btn-icon",type:"button",title:Ct(ws)==="dark"?"Switch to light":"Switch to dark","aria-label":Ct(ws)==="dark"?"Switch to light theme":"Switch to dark theme",onClick:o[0]||(o[0]=(...a)=>Ct(Hl)&&Ct(Hl)(...a))},[Ct(ws)==="dark"?(b(),x("svg",dp,[...o[1]||(o[1]=[f("circle",{cx:"12",cy:"12",r:"4"},null,-1),f("path",{d:"M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"},null,-1)])])):(b(),x("svg",fp,[...o[2]||(o[2]=[f("path",{d:"M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9z"},null,-1)])]))],8,cp))}},pp={class:"relative grid h-full place-items-center p-5"},mp={class:"absolute right-5 top-5"},gp={class:"mb-6 flex items-center gap-3 text-ink"},_p={class:"relative mb-1"},vp=["type"],yp=["aria-label","title"],bp={key:0,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round",class:"h-[18px] w-[18px]"},xp={key:1,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round",class:"h-[18px] w-[18px]"},wp={key:0,class:"mt-4"},kp={key:1,class:"mt-4 rounded border border-line bg-danger-soft px-3 py-2 text-sm text-danger-fg"},Sp=["disabled"],Pp={__name:"LoginView",props:{defaultApiBase:{type:String,default:""}},emits:["signed-in"],setup(e,{emit:i}){const o=e,a=i,l=J(""),c=J(""),h=J(localStorage.getItem("api_url")||o.defaultApiBase||"http://localhost:8080"),g=J(!1),v=J(!1),P=J(!1),k=J("");async function O(){P.value=!0,k.value="",localStorage.setItem("api_url",h.value.trim());const{ok:$,status:F,body:nt}=await Bh(l.value.trim(),c.value,h.value.trim());if(P.value=!1,$){a("signed-in",nt.email);return}k.value=F===400?"Invalid email or password.":F===502?"API server can't reach PocketBase.":nt.message||nt.error||"Cannot reach the API server."}return($,F)=>(b(),x("div",pp,[f("div",mp,[E(hp)]),f("form",{class:"panel w-[380px] p-8 shadow-md",onSubmit:yc(O,["prevent"])},[f("div",gp,[E(Mc,{size:34}),F[5]||(F[5]=f("div",{class:"leading-tight"},[f("div",{class:"text-mode"},"PilotVault"),f("div",{class:"eyebrow mt-0.5"},"Control panel")],-1))]),F[9]||(F[9]=f("label",{class:"eyebrow mb-1.5 block"},"Email",-1)),xt(f("input",{"onUpdate:modelValue":F[0]||(F[0]=nt=>l.value=nt),type:"email",autocomplete:"username",required:"",class:"field mb-4",placeholder:"you@example.com"},null,512),[[Bt,l.value]]),F[10]||(F[10]=f("label",{class:"eyebrow mb-1.5 block"},"Password",-1)),f("div",_p,[xt(f("input",{"onUpdate:modelValue":F[1]||(F[1]=nt=>c.value=nt),type:v.value?"text":"password",autocomplete:"current-password",required:"",class:"field w-full pr-10",placeholder:"••••••••"},null,8,vp),[[Sh,c.value]]),f("button",{type:"button",class:"absolute inset-y-0 right-0 grid w-10 place-items-center text-ink-muted transition hover:text-ink-secondary","aria-label":v.value?"Hide password":"Show password",title:v.value?"Hide password":"Show password",onClick:F[2]||(F[2]=nt=>v.value=!v.value)},[v.value?(b(),x("svg",bp,[...F[6]||(F[6]=[f("path",{d:"M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"},null,-1),f("line",{x1:"1",y1:"1",x2:"23",y2:"23"},null,-1)])])):(b(),x("svg",xp,[...F[7]||(F[7]=[f("path",{d:"M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"},null,-1),f("circle",{cx:"12",cy:"12",r:"3"},null,-1)])]))],8,yp)]),g.value?(b(),x("div",wp,[F[8]||(F[8]=f("label",{class:"eyebrow mb-1.5 block"},"API Server",-1)),xt(f("input",{"onUpdate:modelValue":F[3]||(F[3]=nt=>h.value=nt),type:"text",class:"field font-mono",placeholder:"10.2.1.101:8080"},null,512),[[Bt,h.value]])])):V("",!0),k.value?(b(),x("p",kp,M(k.value),1)):V("",!0),f("button",{type:"submit",class:"btn-accent mt-6 w-full",disabled:P.value},M(P.value?"Signing in…":"Sign in"),9,Sp),f("button",{type:"button",class:"mx-auto mt-3 block text-xs text-ink-muted transition hover:text-ink-secondary",onClick:F[4]||(F[4]=nt=>g.value=!g.value)},M(g.value?"Hide server settings":"Server settings"),1)],32)]))}};function Tp(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var Js={exports:{}};/* @preserve + * Leaflet 1.9.4, a JS library for interactive maps. https://leafletjs.com + * (c) 2010-2023 Vladimir Agafonkin, (c) 2010-2011 CloudMade + */var Lp=Js.exports,Jl;function Mp(){return Jl||(Jl=1,(function(e,i){(function(o,a){a(i)})(Lp,(function(o){var a="1.9.4";function l(t){var n,s,r,u;for(s=1,r=arguments.length;s"u"||!L||!L.Mixin)){t=ut(t)?t:[t];for(var n=0;n0?Math.floor(t):Math.ceil(t)};Y.prototype={clone:function(){return new Y(this.x,this.y)},add:function(t){return this.clone()._add(rt(t))},_add:function(t){return this.x+=t.x,this.y+=t.y,this},subtract:function(t){return this.clone()._subtract(rt(t))},_subtract:function(t){return this.x-=t.x,this.y-=t.y,this},divideBy:function(t){return this.clone()._divideBy(t)},_divideBy:function(t){return this.x/=t,this.y/=t,this},multiplyBy:function(t){return this.clone()._multiplyBy(t)},_multiplyBy:function(t){return this.x*=t,this.y*=t,this},scaleBy:function(t){return new Y(this.x*t.x,this.y*t.y)},unscaleBy:function(t){return new Y(this.x/t.x,this.y/t.y)},round:function(){return this.clone()._round()},_round:function(){return this.x=Math.round(this.x),this.y=Math.round(this.y),this},floor:function(){return this.clone()._floor()},_floor:function(){return this.x=Math.floor(this.x),this.y=Math.floor(this.y),this},ceil:function(){return this.clone()._ceil()},_ceil:function(){return this.x=Math.ceil(this.x),this.y=Math.ceil(this.y),this},trunc:function(){return this.clone()._trunc()},_trunc:function(){return this.x=ue(this.x),this.y=ue(this.y),this},distanceTo:function(t){t=rt(t);var n=t.x-this.x,s=t.y-this.y;return Math.sqrt(n*n+s*s)},equals:function(t){return t=rt(t),t.x===this.x&&t.y===this.y},contains:function(t){return t=rt(t),Math.abs(t.x)<=Math.abs(this.x)&&Math.abs(t.y)<=Math.abs(this.y)},toString:function(){return"Point("+$(this.x)+", "+$(this.y)+")"}};function rt(t,n,s){return t instanceof Y?t:ut(t)?new Y(t[0],t[1]):t==null?t:typeof t=="object"&&"x"in t&&"y"in t?new Y(t.x,t.y):new Y(t,n,s)}function yt(t,n){if(t)for(var s=n?[t,n]:t,r=0,u=s.length;r=this.min.x&&s.x<=this.max.x&&n.y>=this.min.y&&s.y<=this.max.y},intersects:function(t){t=Kt(t);var n=this.min,s=this.max,r=t.min,u=t.max,p=u.x>=n.x&&r.x<=s.x,S=u.y>=n.y&&r.y<=s.y;return p&&S},overlaps:function(t){t=Kt(t);var n=this.min,s=this.max,r=t.min,u=t.max,p=u.x>n.x&&r.xn.y&&r.y=n.lat&&u.lat<=s.lat&&r.lng>=n.lng&&u.lng<=s.lng},intersects:function(t){t=qt(t);var n=this._southWest,s=this._northEast,r=t.getSouthWest(),u=t.getNorthEast(),p=u.lat>=n.lat&&r.lat<=s.lat,S=u.lng>=n.lng&&r.lng<=s.lng;return p&&S},overlaps:function(t){t=qt(t);var n=this._southWest,s=this._northEast,r=t.getSouthWest(),u=t.getNorthEast(),p=u.lat>n.lat&&r.latn.lng&&r.lng1,kr=(function(){var t=!1;try{var n=Object.defineProperty({},"passive",{get:function(){t=!0}});window.addEventListener("testPassiveEventSupport",O,n),window.removeEventListener("testPassiveEventSupport",O,n)}catch{}return t})(),Sr=(function(){return!!document.createElement("canvas").getContext})(),Cs=!!(document.createElementNS&&B("svg").createSVGRect),vo=!!Cs&&(function(){var t=document.createElement("div");return t.innerHTML="",(t.firstChild&&t.firstChild.namespaceURI)==="http://www.w3.org/2000/svg"})(),Pr=!Cs&&(function(){try{var t=document.createElement("div");t.innerHTML='';var n=t.firstChild;return n.style.behavior="url(#default#VML)",n&&typeof n.adj=="object"}catch{return!1}})(),Tr=navigator.platform.indexOf("Mac")===0,Lr=navigator.platform.indexOf("Linux")===0;function Et(t){return navigator.userAgent.toLowerCase().indexOf(t)>=0}var it={ie:j,ielt9:A,edge:U,webkit:R,android:ct,android23:Q,androidStock:dt,opera:Mt,chrome:at,gecko:Rt,safari:ne,phantom:ae,opera12:fe,win:ke,ie3d:_n,webkit3d:ui,gecko3d:ve,any3d:Ae,mobile:Cn,mobileWebkit:Fe,mobileWebkit3d:Yi,msPointer:Oe,pointer:Ve,touch:xr,touchNative:he,mobileOpera:_o,mobileGecko:Ms,retina:wr,passiveEvents:kr,canvas:Sr,svg:Cs,vml:Pr,inlineSvg:vo,mac:Tr,linux:Lr},Ji=it.msPointer?"MSPointerDown":"pointerdown",Ee=it.msPointer?"MSPointerMove":"pointermove",ci=it.msPointer?"MSPointerUp":"pointerup",Mi=it.msPointer?"MSPointerCancel":"pointercancel",di={touchstart:Ji,touchmove:Ee,touchend:ci,touchcancel:Mi},sn={touchstart:bo,touchmove:Ze,touchend:Ze,touchcancel:Ze},Xe={},yo=!1;function Os(t,n,s){return n==="touchstart"&&fi(),sn[n]?(s=sn[n].bind(this,s),t.addEventListener(di[n],s,!1),s):(console.warn("wrong event specified:",n),O)}function Es(t,n,s){if(!di[n]){console.warn("wrong event specified:",n);return}t.removeEventListener(di[n],s,!1)}function Mr(t){Xe[t.pointerId]=t}function on(t){Xe[t.pointerId]&&(Xe[t.pointerId]=t)}function vn(t){delete Xe[t.pointerId]}function fi(){yo||(document.addEventListener(Ji,Mr,!0),document.addEventListener(Ee,on,!0),document.addEventListener(ci,vn,!0),document.addEventListener(Mi,vn,!0),yo=!0)}function Ze(t,n){if(n.pointerType!==(n.MSPOINTER_TYPE_MOUSE||"mouse")){n.touches=[];for(var s in Xe)n.touches.push(Xe[s]);n.changedTouches=[n],t(n)}}function bo(t,n){n.MSPOINTER_TYPE_TOUCH&&n.pointerType===n.MSPOINTER_TYPE_TOUCH&&Se(n),Ze(t,n)}function zs(t){var n={},s,r;for(r in t)s=t[r],n[r]=s&&s.bind?s.bind(t):s;return t=n,n.type="dblclick",n.detail=2,n.isTrusted=!1,n._simulated=!0,n}var Cr=200;function Or(t,n){t.addEventListener("dblclick",n);var s=0,r;function u(p){if(p.detail!==1){r=p.detail;return}if(!(p.pointerType==="mouse"||p.sourceCapabilities&&!p.sourceCapabilities.firesTouchEvents)){var S=So(p);if(!(S.some(function(z){return z instanceof HTMLLabelElement&&z.attributes.for})&&!S.some(function(z){return z instanceof HTMLInputElement||z instanceof HTMLSelectElement}))){var C=Date.now();C-s<=Cr?(r++,r===2&&n(zs(p))):r=1,s=C}}}return t.addEventListener("click",u),{dblclick:n,simDblclick:u}}function Er(t,n){t.removeEventListener("dblclick",n.dblclick),t.removeEventListener("click",n.simDblclick)}var As=Xi(["transform","webkitTransform","OTransform","MozTransform","msTransform"]),hi=Xi(["webkitTransition","transition","OTransition","MozTransition","msTransition"]),xo=hi==="webkitTransition"||hi==="OTransition"?hi+"End":"transitionend";function wo(t){return typeof t=="string"?document.getElementById(t):t}function Ci(t,n){var s=t.style[n]||t.currentStyle&&t.currentStyle[n];if((!s||s==="auto")&&document.defaultView){var r=document.defaultView.getComputedStyle(t,null);s=r?r[n]:null}return s==="auto"?null:s}function W(t,n,s){var r=document.createElement(t);return r.className=n||"",s&&s.appendChild(r),r}function Yt(t){var n=t.parentNode;n&&n.removeChild(t)}function Un(t){for(;t.firstChild;)t.removeChild(t.firstChild)}function rn(t){var n=t.parentNode;n&&n.lastChild!==t&&n.appendChild(t)}function yn(t){var n=t.parentNode;n&&n.firstChild!==t&&n.insertBefore(t,n.firstChild)}function pi(t,n){if(t.classList!==void 0)return t.classList.contains(n);var s=Oi(t);return s.length>0&&new RegExp("(^|\\s)"+n+"(\\s|$)").test(s)}function mt(t,n){if(t.classList!==void 0)for(var s=nt(n),r=0,u=s.length;r0?2*window.devicePixelRatio:1;function To(t){return it.edge?t.wheelDeltaY/2:t.deltaY&&t.deltaMode===0?-t.deltaY/Ar:t.deltaY&&t.deltaMode===1?-t.deltaY*20:t.deltaY&&t.deltaMode===2?-t.deltaY*60:t.deltaX||t.deltaZ?0:t.wheelDelta?(t.wheelDeltaY||t.wheelDelta)/2:t.detail&&Math.abs(t.detail)<32765?-t.detail*20:t.detail?t.detail/-32765*60:0}function Wn(t,n){var s=n.relatedTarget;if(!s)return!0;try{for(;s&&s!==t;)s=s.parentNode}catch{return!1}return s!==t}var Lo={__proto__:null,on:St,off:Jt,stopPropagation:_e,disableScrollPropagation:En,disableClickPropagation:_i,preventDefault:Se,stop:xn,getPropagationPath:So,getMousePosition:Po,getWheelDelta:To,isExternalTarget:Wn,addListener:St,removeListener:Jt},Ai=Vt.extend({run:function(t,n,s,r){this.stop(),this._el=t,this._inProgress=!0,this._duration=s||.25,this._easeOutPower=1/Math.max(r||.5,.2),this._startPos=On(t),this._offset=n.subtract(this._startPos),this._startTime=+new Date,this.fire("start"),this._animate()},stop:function(){this._inProgress&&(this._step(!0),this._complete())},_animate:function(){this._animId=Tt(this._animate,this),this._step()},_step:function(t){var n=+new Date-this._startTime,s=this._duration*1e3;nthis.options.maxZoom)?this.setZoom(t):this},panInsideBounds:function(t,n){this._enforcingBounds=!0;var s=this.getCenter(),r=this._limitCenter(s,this._zoom,qt(t));return s.equals(r)||this.panTo(r,n),this._enforcingBounds=!1,this},panInside:function(t,n){n=n||{};var s=rt(n.paddingTopLeft||n.padding||[0,0]),r=rt(n.paddingBottomRight||n.padding||[0,0]),u=this.project(this.getCenter()),p=this.project(t),S=this.getPixelBounds(),C=Kt([S.min.add(s),S.max.subtract(r)]),z=C.getSize();if(!C.contains(p)){this._enforcingBounds=!0;var H=p.subtract(C.getCenter()),et=C.extend(p).getSize().subtract(z);u.x+=H.x<0?-et.x:et.x,u.y+=H.y<0?-et.y:et.y,this.panTo(this.unproject(u),n),this._enforcingBounds=!1}return this},invalidateSize:function(t){if(!this._loaded)return this;t=l({animate:!1,pan:!0},t===!0?{animate:!0}:t);var n=this.getSize();this._sizeChanged=!0,this._lastCenter=null;var s=this.getSize(),r=n.divideBy(2).round(),u=s.divideBy(2).round(),p=r.subtract(u);return!p.x&&!p.y?this:(t.animate&&t.pan?this.panBy(p):(t.pan&&this._rawPanBy(p),this.fire("move"),t.debounceMoveend?(clearTimeout(this._sizeTimer),this._sizeTimer=setTimeout(h(this.fire,this,"moveend"),200)):this.fire("moveend")),this.fire("resize",{oldSize:n,newSize:s}))},stop:function(){return this.setZoom(this._limitZoom(this._zoom)),this.options.zoomSnap||this.fire("viewreset"),this._stop()},locate:function(t){if(t=this._locateOptions=l({timeout:1e4,watch:!1},t),!("geolocation"in navigator))return this._handleGeolocationError({code:0,message:"Geolocation not supported."}),this;var n=h(this._handleGeolocationResponse,this),s=h(this._handleGeolocationError,this);return t.watch?this._locationWatchId=navigator.geolocation.watchPosition(n,s,t):navigator.geolocation.getCurrentPosition(n,s,t),this},stopLocate:function(){return navigator.geolocation&&navigator.geolocation.clearWatch&&navigator.geolocation.clearWatch(this._locationWatchId),this._locateOptions&&(this._locateOptions.setView=!1),this},_handleGeolocationError:function(t){if(this._container._leaflet_id){var n=t.code,s=t.message||(n===1?"permission denied":n===2?"position unavailable":"timeout");this._locateOptions.setView&&!this._loaded&&this.fitWorld(),this.fire("locationerror",{code:n,message:"Geolocation error: "+s+"."})}},_handleGeolocationResponse:function(t){if(this._container._leaflet_id){var n=t.coords.latitude,s=t.coords.longitude,r=new Ut(n,s),u=r.toBounds(t.coords.accuracy*2),p=this._locateOptions;if(p.setView){var S=this.getBoundsZoom(u);this.setView(r,p.maxZoom?Math.min(S,p.maxZoom):S)}var C={latlng:r,bounds:u,timestamp:t.timestamp};for(var z in t.coords)typeof t.coords[z]=="number"&&(C[z]=t.coords[z]);this.fire("locationfound",C)}},addHandler:function(t,n){if(!n)return this;var s=this[t]=new n(this);return this._handlers.push(s),this.options[t]&&s.enable(),this},remove:function(){if(this._initEvents(!0),this.options.maxBounds&&this.off("moveend",this._panInsideMaxBounds),this._containerId!==this._container._leaflet_id)throw new Error("Map container is being reused by another instance");try{delete this._container._leaflet_id,delete this._containerId}catch{this._container._leaflet_id=void 0,this._containerId=void 0}this._locationWatchId!==void 0&&this.stopLocate(),this._stop(),Yt(this._mapPane),this._clearControlPos&&this._clearControlPos(),this._resizeRequest&&(G(this._resizeRequest),this._resizeRequest=null),this._clearHandlers(),this._loaded&&this.fire("unload");var t;for(t in this._layers)this._layers[t].remove();for(t in this._panes)Yt(this._panes[t]);return this._layers=[],this._panes=[],delete this._mapPane,delete this._renderer,this},createPane:function(t,n){var s="leaflet-pane"+(t?" leaflet-"+t.replace("Pane","")+"-pane":""),r=W("div",s,n||this._mapPane);return t&&(this._panes[t]=r),r},getCenter:function(){return this._checkIfLoaded(),this._lastCenter&&!this._moved()?this._lastCenter.clone():this.layerPointToLatLng(this._getCenterLayerPoint())},getZoom:function(){return this._zoom},getBounds:function(){var t=this.getPixelBounds(),n=this.unproject(t.getBottomLeft()),s=this.unproject(t.getTopRight());return new ge(n,s)},getMinZoom:function(){return this.options.minZoom===void 0?this._layersMinZoom||0:this.options.minZoom},getMaxZoom:function(){return this.options.maxZoom===void 0?this._layersMaxZoom===void 0?1/0:this._layersMaxZoom:this.options.maxZoom},getBoundsZoom:function(t,n,s){t=qt(t),s=rt(s||[0,0]);var r=this.getZoom()||0,u=this.getMinZoom(),p=this.getMaxZoom(),S=t.getNorthWest(),C=t.getSouthEast(),z=this.getSize().subtract(s),H=Kt(this.project(C,r),this.project(S,r)).getSize(),et=it.any3d?this.options.zoomSnap:1,_t=z.x/H.x,Nt=z.y/H.y,He=n?Math.max(_t,Nt):Math.min(_t,Nt);return r=this.getScaleZoom(He,r),et&&(r=Math.round(r/(et/100))*(et/100),r=n?Math.ceil(r/et)*et:Math.floor(r/et)*et),Math.max(u,Math.min(p,r))},getSize:function(){return(!this._size||this._sizeChanged)&&(this._size=new Y(this._container.clientWidth||0,this._container.clientHeight||0),this._sizeChanged=!1),this._size.clone()},getPixelBounds:function(t,n){var s=this._getTopLeftPoint(t,n);return new yt(s,s.add(this.getSize()))},getPixelOrigin:function(){return this._checkIfLoaded(),this._pixelOrigin},getPixelWorldBounds:function(t){return this.options.crs.getProjectedBounds(t===void 0?this.getZoom():t)},getPane:function(t){return typeof t=="string"?this._panes[t]:t},getPanes:function(){return this._panes},getContainer:function(){return this._container},getZoomScale:function(t,n){var s=this.options.crs;return n=n===void 0?this._zoom:n,s.scale(t)/s.scale(n)},getScaleZoom:function(t,n){var s=this.options.crs;n=n===void 0?this._zoom:n;var r=s.zoom(t*s.scale(n));return isNaN(r)?1/0:r},project:function(t,n){return n=n===void 0?this._zoom:n,this.options.crs.latLngToPoint(Pt(t),n)},unproject:function(t,n){return n=n===void 0?this._zoom:n,this.options.crs.pointToLatLng(rt(t),n)},layerPointToLatLng:function(t){var n=rt(t).add(this.getPixelOrigin());return this.unproject(n)},latLngToLayerPoint:function(t){var n=this.project(Pt(t))._round();return n._subtract(this.getPixelOrigin())},wrapLatLng:function(t){return this.options.crs.wrapLatLng(Pt(t))},wrapLatLngBounds:function(t){return this.options.crs.wrapLatLngBounds(qt(t))},distance:function(t,n){return this.options.crs.distance(Pt(t),Pt(n))},containerPointToLayerPoint:function(t){return rt(t).subtract(this._getMapPanePos())},layerPointToContainerPoint:function(t){return rt(t).add(this._getMapPanePos())},containerPointToLatLng:function(t){var n=this.containerPointToLayerPoint(rt(t));return this.layerPointToLatLng(n)},latLngToContainerPoint:function(t){return this.layerPointToContainerPoint(this.latLngToLayerPoint(Pt(t)))},mouseEventToContainerPoint:function(t){return Po(t,this._container)},mouseEventToLayerPoint:function(t){return this.containerPointToLayerPoint(this.mouseEventToContainerPoint(t))},mouseEventToLatLng:function(t){return this.layerPointToLatLng(this.mouseEventToLayerPoint(t))},_initContainer:function(t){var n=this._container=wo(t);if(n){if(n._leaflet_id)throw new Error("Map container is already initialized.")}else throw new Error("Map container not found.");St(n,"scroll",this._onScroll,this),this._containerId=v(n)},_initLayout:function(){var t=this._container;this._fadeAnimated=this.options.fadeAnimation&&it.any3d,mt(t,"leaflet-container"+(it.touch?" leaflet-touch":"")+(it.retina?" leaflet-retina":"")+(it.ielt9?" leaflet-oldie":"")+(it.safari?" leaflet-safari":"")+(this._fadeAnimated?" leaflet-fade-anim":""));var n=Ci(t,"position");n!=="absolute"&&n!=="relative"&&n!=="fixed"&&n!=="sticky"&&(t.style.position="relative"),this._initPanes(),this._initControlPos&&this._initControlPos()},_initPanes:function(){var t=this._panes={};this._paneRenderers={},this._mapPane=this.createPane("mapPane",this._container),le(this._mapPane,new Y(0,0)),this.createPane("tilePane"),this.createPane("overlayPane"),this.createPane("shadowPane"),this.createPane("markerPane"),this.createPane("tooltipPane"),this.createPane("popupPane"),this.options.markerZoomAnimation||(mt(t.markerPane,"leaflet-zoom-hide"),mt(t.shadowPane,"leaflet-zoom-hide"))},_resetView:function(t,n,s){le(this._mapPane,new Y(0,0));var r=!this._loaded;this._loaded=!0,n=this._limitZoom(n),this.fire("viewprereset");var u=this._zoom!==n;this._moveStart(u,s)._move(t,n)._moveEnd(u),this.fire("viewreset"),r&&this.fire("load")},_moveStart:function(t,n){return t&&this.fire("zoomstart"),n||this.fire("movestart"),this},_move:function(t,n,s,r){n===void 0&&(n=this._zoom);var u=this._zoom!==n;return this._zoom=n,this._lastCenter=t,this._pixelOrigin=this._getNewPixelOrigin(t),r?s&&s.pinch&&this.fire("zoom",s):((u||s&&s.pinch)&&this.fire("zoom",s),this.fire("move",s)),this},_moveEnd:function(t){return t&&this.fire("zoomend"),this.fire("moveend")},_stop:function(){return G(this._flyToFrame),this._panAnim&&this._panAnim.stop(),this},_rawPanBy:function(t){le(this._mapPane,this._getMapPanePos().subtract(t))},_getZoomSpan:function(){return this.getMaxZoom()-this.getMinZoom()},_panInsideMaxBounds:function(){this._enforcingBounds||this.panInsideBounds(this.options.maxBounds)},_checkIfLoaded:function(){if(!this._loaded)throw new Error("Set map center and zoom first.")},_initEvents:function(t){this._targets={},this._targets[v(this._container)]=this;var n=t?Jt:St;n(this._container,"click dblclick mousedown mouseup mouseover mouseout mousemove contextmenu keypress keydown keyup",this._handleDOMEvent,this),this.options.trackResize&&n(window,"resize",this._onResize,this),it.any3d&&this.options.transform3DLimit&&(t?this.off:this.on).call(this,"moveend",this._onMoveEnd)},_onResize:function(){G(this._resizeRequest),this._resizeRequest=Tt(function(){this.invalidateSize({debounceMoveend:!0})},this)},_onScroll:function(){this._container.scrollTop=0,this._container.scrollLeft=0},_onMoveEnd:function(){var t=this._getMapPanePos();Math.max(Math.abs(t.x),Math.abs(t.y))>=this.options.transform3DLimit&&this._resetView(this.getCenter(),this.getZoom())},_findEventTargets:function(t,n){for(var s=[],r,u=n==="mouseout"||n==="mouseover",p=t.target||t.srcElement,S=!1;p;){if(r=this._targets[v(p)],r&&(n==="click"||n==="preclick")&&this._draggableMoved(r)){S=!0;break}if(r&&r.listens(n,!0)&&(u&&!Wn(p,t)||(s.push(r),u))||p===this._container)break;p=p.parentNode}return!s.length&&!S&&!u&&this.listens(n,!0)&&(s=[this]),s},_isClickDisabled:function(t){for(;t&&t!==this._container;){if(t._leaflet_disable_click)return!0;t=t.parentNode}},_handleDOMEvent:function(t){var n=t.target||t.srcElement;if(!(!this._loaded||n._leaflet_disable_events||t.type==="click"&&this._isClickDisabled(n))){var s=t.type;s==="mousedown"&&es(n),this._fireDOMEvent(t,s)}},_mouseEvents:["click","dblclick","mouseover","mouseout","contextmenu"],_fireDOMEvent:function(t,n,s){if(t.type==="click"){var r=l({},t);r.type="preclick",this._fireDOMEvent(r,r.type,s)}var u=this._findEventTargets(t,n);if(s){for(var p=[],S=0;S0?Math.round(t-n)/2:Math.max(0,Math.ceil(t))-Math.max(0,Math.floor(n))},_limitZoom:function(t){var n=this.getMinZoom(),s=this.getMaxZoom(),r=it.any3d?this.options.zoomSnap:1;return r&&(t=Math.round(t/r)*r),Math.max(n,Math.min(s,t))},_onPanTransitionStep:function(){this.fire("move")},_onPanTransitionEnd:function(){te(this._mapPane,"leaflet-pan-anim"),this.fire("moveend")},_tryAnimatedPan:function(t,n){var s=this._getCenterOffset(t)._trunc();return(n&&n.animate)!==!0&&!this.getSize().contains(s)?!1:(this.panBy(s,n),!0)},_createAnimProxy:function(){var t=this._proxy=W("div","leaflet-proxy leaflet-zoom-animated");this._panes.mapPane.appendChild(t),this.on("zoomanim",function(n){var s=As,r=this._proxy.style[s];ye(this._proxy,this.project(n.center,n.zoom),this.getZoomScale(n.zoom,1)),r===this._proxy.style[s]&&this._animatingZoom&&this._onZoomTransitionEnd()},this),this.on("load moveend",this._animMoveEnd,this),this._on("unload",this._destroyAnimProxy,this)},_destroyAnimProxy:function(){Yt(this._proxy),this.off("load moveend",this._animMoveEnd,this),delete this._proxy},_animMoveEnd:function(){var t=this.getCenter(),n=this.getZoom();ye(this._proxy,this.project(t,n),this.getZoomScale(n,1))},_catchTransitionEnd:function(t){this._animatingZoom&&t.propertyName.indexOf("transform")>=0&&this._onZoomTransitionEnd()},_nothingToAnimate:function(){return!this._container.getElementsByClassName("leaflet-zoom-animated").length},_tryAnimatedZoom:function(t,n,s){if(this._animatingZoom)return!0;if(s=s||{},!this._zoomAnimated||s.animate===!1||this._nothingToAnimate()||Math.abs(n-this._zoom)>this.options.zoomAnimationThreshold)return!1;var r=this.getZoomScale(n),u=this._getCenterOffset(t)._divideBy(1-1/r);return s.animate!==!0&&!this.getSize().contains(u)?!1:(Tt(function(){this._moveStart(!0,s.noMoveStart||!1)._animateZoom(t,n,!0)},this),!0)},_animateZoom:function(t,n,s,r){this._mapPane&&(s&&(this._animatingZoom=!0,this._animateToCenter=t,this._animateToZoom=n,mt(this._mapPane,"leaflet-zoom-anim")),this.fire("zoomanim",{center:t,zoom:n,noUpdate:r}),this._tempFireZoomEvent||(this._tempFireZoomEvent=this._zoom!==this._animateToZoom),this._move(this._animateToCenter,this._animateToZoom,void 0,!0),setTimeout(h(this._onZoomTransitionEnd,this),250))},_onZoomTransitionEnd:function(){this._animatingZoom&&(this._mapPane&&te(this._mapPane,"leaflet-zoom-anim"),this._animatingZoom=!1,this._move(this._animateToCenter,this._animateToZoom,void 0,!0),this._tempFireZoomEvent&&this.fire("zoom"),delete this._tempFireZoomEvent,this.fire("move"),this._moveEnd(!0))}});function ss(t,n){return new zt(t,n)}var $e=It.extend({options:{position:"topright"},initialize:function(t){q(this,t)},getPosition:function(){return this.options.position},setPosition:function(t){var n=this._map;return n&&n.removeControl(this),this.options.position=t,n&&n.addControl(this),this},getContainer:function(){return this._container},addTo:function(t){this.remove(),this._map=t;var n=this._container=this.onAdd(t),s=this.getPosition(),r=t._controlCorners[s];return mt(n,"leaflet-control"),s.indexOf("bottom")!==-1?r.insertBefore(n,r.firstChild):r.appendChild(n),this._map.on("unload",this.remove,this),this},remove:function(){return this._map?(Yt(this._container),this.onRemove&&this.onRemove(this._map),this._map.off("unload",this.remove,this),this._map=null,this):this},_refocusOnMap:function(t){this._map&&t&&t.screenX>0&&t.screenY>0&&this._map.getContainer().focus()}}),Ii=function(t){return new $e(t)};zt.include({addControl:function(t){return t.addTo(this),this},removeControl:function(t){return t.remove(),this},_initControlPos:function(){var t=this._controlCorners={},n="leaflet-",s=this._controlContainer=W("div",n+"control-container",this._container);function r(u,p){var S=n+u+" "+n+p;t[u+p]=W("div",S,s)}r("top","left"),r("top","right"),r("bottom","left"),r("bottom","right")},_clearControlPos:function(){for(var t in this._controlCorners)Yt(this._controlCorners[t]);Yt(this._controlContainer),delete this._controlCorners,delete this._controlContainer}});var Mo=$e.extend({options:{collapsed:!0,position:"topright",autoZIndex:!0,hideSingleBase:!1,sortLayers:!1,sortFunction:function(t,n,s,r){return s1,this._baseLayersList.style.display=t?"":"none"),this._separator.style.display=n&&t?"":"none",this},_onLayerChange:function(t){this._handlingClick||this._update();var n=this._getLayer(v(t.target)),s=n.overlay?t.type==="add"?"overlayadd":"overlayremove":t.type==="add"?"baselayerchange":null;s&&this._map.fire(s,n)},_createRadioElement:function(t,n){var s='",r=document.createElement("div");return r.innerHTML=s,r.firstChild},_addItem:function(t){var n=document.createElement("label"),s=this._map.hasLayer(t.layer),r;t.overlay?(r=document.createElement("input"),r.type="checkbox",r.className="leaflet-control-layers-selector",r.defaultChecked=s):r=this._createRadioElement("leaflet-base-layers_"+v(this),s),this._layerControlInputs.push(r),r.layerId=v(t.layer),St(r,"click",this._onInputClick,this);var u=document.createElement("span");u.innerHTML=" "+t.name;var p=document.createElement("span");n.appendChild(p),p.appendChild(r),p.appendChild(u);var S=t.overlay?this._overlaysList:this._baseLayersList;return S.appendChild(n),this._checkDisabledLayers(),n},_onInputClick:function(){if(!this._preventClick){var t=this._layerControlInputs,n,s,r=[],u=[];this._handlingClick=!0;for(var p=t.length-1;p>=0;p--)n=t[p],s=this._getLayer(n.layerId).layer,n.checked?r.push(s):n.checked||u.push(s);for(p=0;p=0;u--)n=t[u],s=this._getLayer(n.layerId).layer,n.disabled=s.options.minZoom!==void 0&&rs.options.maxZoom},_expandIfNotCollapsed:function(){return this._map&&!this.options.collapsed&&this.expand(),this},_expandSafely:function(){var t=this._section;this._preventClick=!0,St(t,"click",Se),this.expand();var n=this;setTimeout(function(){Jt(t,"click",Se),n._preventClick=!1})}}),Ir=function(t,n,s){return new Mo(t,n,s)},Ke=$e.extend({options:{position:"topleft",zoomInText:'',zoomInTitle:"Zoom in",zoomOutText:'',zoomOutTitle:"Zoom out"},onAdd:function(t){var n="leaflet-control-zoom",s=W("div",n+" leaflet-bar"),r=this.options;return this._zoomInButton=this._createButton(r.zoomInText,r.zoomInTitle,n+"-in",s,this._zoomIn),this._zoomOutButton=this._createButton(r.zoomOutText,r.zoomOutTitle,n+"-out",s,this._zoomOut),this._updateDisabled(),t.on("zoomend zoomlevelschange",this._updateDisabled,this),s},onRemove:function(t){t.off("zoomend zoomlevelschange",this._updateDisabled,this)},disable:function(){return this._disabled=!0,this._updateDisabled(),this},enable:function(){return this._disabled=!1,this._updateDisabled(),this},_zoomIn:function(t){!this._disabled&&this._map._zoomthis._map.getMinZoom()&&this._map.zoomOut(this._map.options.zoomDelta*(t.shiftKey?3:1))},_createButton:function(t,n,s,r,u){var p=W("a",s,r);return p.innerHTML=t,p.href="#",p.title=n,p.setAttribute("role","button"),p.setAttribute("aria-label",n),_i(p),St(p,"click",xn),St(p,"click",u,this),St(p,"click",this._refocusOnMap,this),p},_updateDisabled:function(){var t=this._map,n="leaflet-disabled";te(this._zoomInButton,n),te(this._zoomOutButton,n),this._zoomInButton.setAttribute("aria-disabled","false"),this._zoomOutButton.setAttribute("aria-disabled","false"),(this._disabled||t._zoom===t.getMinZoom())&&(mt(this._zoomOutButton,n),this._zoomOutButton.setAttribute("aria-disabled","true")),(this._disabled||t._zoom===t.getMaxZoom())&&(mt(this._zoomInButton,n),this._zoomInButton.setAttribute("aria-disabled","true"))}});zt.mergeOptions({zoomControl:!0}),zt.addInitHook(function(){this.options.zoomControl&&(this.zoomControl=new Ke,this.addControl(this.zoomControl))});var Nr=function(t){return new Ke(t)},Co=$e.extend({options:{position:"bottomleft",maxWidth:100,metric:!0,imperial:!0},onAdd:function(t){var n="leaflet-control-scale",s=W("div",n),r=this.options;return this._addScales(r,n+"-line",s),t.on(r.updateWhenIdle?"moveend":"move",this._update,this),t.whenReady(this._update,this),s},onRemove:function(t){t.off(this.options.updateWhenIdle?"moveend":"move",this._update,this)},_addScales:function(t,n,s){t.metric&&(this._mScale=W("div",n,s)),t.imperial&&(this._iScale=W("div",n,s))},_update:function(){var t=this._map,n=t.getSize().y/2,s=t.distance(t.containerPointToLatLng([0,n]),t.containerPointToLatLng([this.options.maxWidth,n]));this._updateScales(s)},_updateScales:function(t){this.options.metric&&t&&this._updateMetric(t),this.options.imperial&&t&&this._updateImperial(t)},_updateMetric:function(t){var n=this._getRoundNum(t),s=n<1e3?n+" m":n/1e3+" km";this._updateScale(this._mScale,s,n/t)},_updateImperial:function(t){var n=t*3.2808399,s,r,u;n>5280?(s=n/5280,r=this._getRoundNum(s),this._updateScale(this._iScale,r+" mi",r/s)):(u=this._getRoundNum(n),this._updateScale(this._iScale,u+" ft",u/n))},_updateScale:function(t,n,s){t.style.width=Math.round(this.options.maxWidth*s)+"px",t.innerHTML=n},_getRoundNum:function(t){var n=Math.pow(10,(Math.floor(t)+"").length-1),s=t/n;return s=s>=10?10:s>=5?5:s>=3?3:s>=2?2:1,n*s}}),Br=function(t){return new Co(t)},os='',Kn=$e.extend({options:{position:"bottomright",prefix:''+(it.inlineSvg?os+" ":"")+"Leaflet"},initialize:function(t){q(this,t),this._attributions={}},onAdd:function(t){t.attributionControl=this,this._container=W("div","leaflet-control-attribution"),_i(this._container);for(var n in t._layers)t._layers[n].getAttribution&&this.addAttribution(t._layers[n].getAttribution());return this._update(),t.on("layeradd",this._addAttribution,this),this._container},onRemove:function(t){t.off("layeradd",this._addAttribution,this)},_addAttribution:function(t){t.layer.getAttribution&&(this.addAttribution(t.layer.getAttribution()),t.layer.once("remove",function(){this.removeAttribution(t.layer.getAttribution())},this))},setPrefix:function(t){return this.options.prefix=t,this._update(),this},addAttribution:function(t){return t?(this._attributions[t]||(this._attributions[t]=0),this._attributions[t]++,this._update(),this):this},removeAttribution:function(t){return t?(this._attributions[t]&&(this._attributions[t]--,this._update()),this):this},_update:function(){if(this._map){var t=[];for(var n in this._attributions)this._attributions[n]&&t.push(n);var s=[];this.options.prefix&&s.push(this.options.prefix),t.length&&s.push(t.join(", ")),this._container.innerHTML=s.join(' ')}}});zt.mergeOptions({attributionControl:!0}),zt.addInitHook(function(){this.options.attributionControl&&new Kn().addTo(this)});var rs=function(t){return new Kn(t)};$e.Layers=Mo,$e.Zoom=Ke,$e.Scale=Co,$e.Attribution=Kn,Ii.layers=Ir,Ii.zoom=Nr,Ii.scale=Br,Ii.attribution=rs;var ee=It.extend({initialize:function(t){this._map=t},enable:function(){return this._enabled?this:(this._enabled=!0,this.addHooks(),this)},disable:function(){return this._enabled?(this._enabled=!1,this.removeHooks(),this):this},enabled:function(){return!!this._enabled}});ee.addTo=function(t,n){return t.addHandler(n,this),this};var vi={Events:kt},Ni=it.touch?"touchstart mousedown":"mousedown",qe=Vt.extend({options:{clickTolerance:3},initialize:function(t,n,s,r){q(this,r),this._element=t,this._dragStartTarget=n||t,this._preventOutline=s},enable:function(){this._enabled||(St(this._dragStartTarget,Ni,this._onDown,this),this._enabled=!0)},disable:function(){this._enabled&&(qe._dragging===this&&this.finishDrag(!0),Jt(this._dragStartTarget,Ni,this._onDown,this),this._enabled=!1,this._moved=!1)},_onDown:function(t){if(this._enabled&&(this._moved=!1,!pi(this._element,"leaflet-zoom-anim"))){if(t.touches&&t.touches.length!==1){qe._dragging===this&&this.finishDrag();return}if(!(qe._dragging||t.shiftKey||t.which!==1&&t.button!==1&&!t.touches)&&(qe._dragging=this,this._preventOutline&&es(this._element),Ns(),mi(),!this._moving)){this.fire("down");var n=t.touches?t.touches[0]:t,s=ko(this._element);this._startPoint=new Y(n.clientX,n.clientY),this._startPos=On(this._element),this._parentScale=Rs(s);var r=t.type==="mousedown";St(document,r?"mousemove":"touchmove",this._onMove,this),St(document,r?"mouseup":"touchend touchcancel",this._onUp,this)}}},_onMove:function(t){if(this._enabled){if(t.touches&&t.touches.length>1){this._moved=!0;return}var n=t.touches&&t.touches.length===1?t.touches[0]:t,s=new Y(n.clientX,n.clientY)._subtract(this._startPoint);!s.x&&!s.y||Math.abs(s.x)+Math.abs(s.y)p&&(S=C,p=z);p>s&&(n[S]=1,$t(t,n,s,r,S),$t(t,n,s,S,u))}function zn(t,n){for(var s=[t[0]],r=1,u=0,p=t.length;rn&&(s.push(t[r]),u=r);return un.max.x&&(s|=2),t.yn.max.y&&(s|=8),s}function Fr(t,n){var s=n.x-t.x,r=n.y-t.y;return s*s+r*r}function In(t,n,s,r){var u=n.x,p=n.y,S=s.x-u,C=s.y-p,z=S*S+C*C,H;return z>0&&(H=((t.x-u)*S+(t.y-p)*C)/z,H>1?(u=s.x,p=s.y):H>0&&(u+=S*H,p+=C*H)),S=t.x-u,C=t.y-p,r?S*S+C*C:new Y(u,p)}function Le(t){return!ut(t[0])||typeof t[0][0]!="object"&&typeof t[0][0]<"u"}function Fi(t){return console.warn("Deprecated use of _flat, please use L.LineUtil.isFlat instead."),Le(t)}function yi(t,n){var s,r,u,p,S,C,z,H;if(!t||t.length===0)throw new Error("latlngs not passed");Le(t)||(console.warn("latlngs are not flat! Only the first ring will be used"),t=t[0]);var et=Pt([0,0]),_t=qt(t),Nt=_t.getNorthWest().distanceTo(_t.getSouthWest())*_t.getNorthEast().distanceTo(_t.getNorthWest());Nt<1700&&(et=qn(t));var He=t.length,Me=[];for(s=0;sr){z=(p-r)/u,H=[C.x-z*(C.x-S.x),C.y-z*(C.y-S.y)];break}var Ge=n.unproject(rt(H));return Pt([Ge.lat+et.lat,Ge.lng+et.lng])}var wn={__proto__:null,simplify:Gn,pointToSegmentDistance:Di,closestPointOnSegment:Dr,clipSegment:as,_getEdgeIntersection:ls,_getBitCode:An,_sqClosestPointOnSegment:In,isFlat:Le,_flat:Fi,polylineCenter:yi},kn={project:function(t){return new Y(t.lng,t.lat)},unproject:function(t){return new Ut(t.y,t.x)},bounds:new yt([-180,-90],[180,90])},Vi={R:6378137,R_MINOR:6356752314245179e-9,bounds:new yt([-2003750834279e-5,-1549657073972e-5],[2003750834279e-5,1876465623138e-5]),project:function(t){var n=Math.PI/180,s=this.R,r=t.lat*n,u=this.R_MINOR/s,p=Math.sqrt(1-u*u),S=p*Math.sin(r),C=Math.tan(Math.PI/4-r/2)/Math.pow((1-S)/(1+S),p/2);return r=-s*Math.log(Math.max(C,1e-10)),new Y(t.lng*n*s,r)},unproject:function(t){for(var n=180/Math.PI,s=this.R,r=this.R_MINOR/s,u=Math.sqrt(1-r*r),p=Math.exp(-t.y/s),S=Math.PI/2-2*Math.atan(p),C=0,z=.1,H;C<15&&Math.abs(z)>1e-7;C++)H=u*Math.sin(S),H=Math.pow((1-H)/(1+H),u/2),z=Math.PI/2-2*Math.atan(p*H)-S,S+=z;return new Ut(S*n,t.x*n/s)}},Eo={__proto__:null,LonLat:kn,Mercator:Vi,SphericalMercator:we},Vr=l({},Te,{code:"EPSG:3395",projection:Vi,transformation:(function(){var t=.5/(Math.PI*Vi.R);return _(t,.5,-t,.5)})()}),Vs=l({},Te,{code:"EPSG:4326",projection:kn,transformation:_(1/180,1,-1/180,.5)}),zo=l({},Pe,{projection:kn,transformation:_(1,0,-1,0),scale:function(t){return Math.pow(2,t)},zoom:function(t){return Math.log(t)/Math.LN2},distance:function(t,n){var s=n.lng-t.lng,r=n.lat-t.lat;return Math.sqrt(s*s+r*r)},infinite:!0});Pe.Earth=Te,Pe.EPSG3395=Vr,Pe.EPSG3857=m,Pe.EPSG900913=T,Pe.EPSG4326=Vs,Pe.Simple=zo;var Qe=Vt.extend({options:{pane:"overlayPane",attribution:null,bubblingMouseEvents:!0},addTo:function(t){return t.addLayer(this),this},remove:function(){return this.removeFrom(this._map||this._mapToAdd)},removeFrom:function(t){return t&&t.removeLayer(this),this},getPane:function(t){return this._map.getPane(t?this.options[t]||t:this.options.pane)},addInteractiveTarget:function(t){return this._map._targets[v(t)]=this,this},removeInteractiveTarget:function(t){return delete this._map._targets[v(t)],this},getAttribution:function(){return this.options.attribution},_layerAdd:function(t){var n=t.target;if(n.hasLayer(this)){if(this._map=n,this._zoomAnimated=n._zoomAnimated,this.getEvents){var s=this.getEvents();n.on(s,this),this.once("remove",function(){n.off(s,this)},this)}this.onAdd(n),this.fire("add"),n.fire("layeradd",{layer:this})}}});zt.include({addLayer:function(t){if(!t._layerAdd)throw new Error("The provided object is not a Layer.");var n=v(t);return this._layers[n]?this:(this._layers[n]=t,t._mapToAdd=this,t.beforeAdd&&t.beforeAdd(this),this.whenReady(t._layerAdd,t),this)},removeLayer:function(t){var n=v(t);return this._layers[n]?(this._loaded&&t.onRemove(this),delete this._layers[n],this._loaded&&(this.fire("layerremove",{layer:t}),t.fire("remove")),t._map=t._mapToAdd=null,this):this},hasLayer:function(t){return v(t)in this._layers},eachLayer:function(t,n){for(var s in this._layers)t.call(n,this._layers[s]);return this},_addLayers:function(t){t=t?ut(t)?t:[t]:[];for(var n=0,s=t.length;nthis._layersMaxZoom&&this.setZoom(this._layersMaxZoom),this.options.minZoom===void 0&&this._layersMinZoom&&this.getZoom()=2&&n[0]instanceof Ut&&n[0].equals(n[s-1])&&n.pop(),n},_setLatLngs:function(t){Jn.prototype._setLatLngs.call(this,t),Le(this._latlngs)&&(this._latlngs=[this._latlngs])},_defaultShape:function(){return Le(this._latlngs[0])?this._latlngs[0]:this._latlngs[0][0]},_clipPoints:function(){var t=this._renderer._bounds,n=this.options.weight,s=new Y(n,n);if(t=new yt(t.min.subtract(s),t.max.add(s)),this._parts=[],!(!this._pxBounds||!this._pxBounds.intersects(t))){if(this.options.noClip){this._parts=this._rings;return}for(var r=0,u=this._rings.length,p;rt.y!=u.y>t.y&&t.x<(u.x-r.x)*(t.y-r.y)/(u.y-r.y)+r.x&&(n=!n);return n||Jn.prototype._containsPoint.call(this,t,!0)}});function Ec(t,n){return new ds(t,n)}var Xn=Sn.extend({initialize:function(t,n){q(this,n),this._layers={},t&&this.addData(t)},addData:function(t){var n=ut(t)?t:t.features,s,r,u;if(n){for(s=0,r=n.length;s0&&u.push(u[0].slice()),u}function fs(t,n){return t.feature?l({},t.feature,{geometry:n}):Do(n)}function Do(t){return t.type==="Feature"||t.type==="FeatureCollection"?t:{type:"Feature",properties:{},geometry:t}}var Hr={toGeoJSON:function(t){return fs(this,{type:"Point",coordinates:$r(this.getLatLng(),t)})}};cs.include(Hr),Ht.include(Hr),Z.include(Hr),Jn.include({toGeoJSON:function(t){var n=!Le(this._latlngs),s=Bo(this._latlngs,n?1:0,!1,t);return fs(this,{type:(n?"Multi":"")+"LineString",coordinates:s})}}),ds.include({toGeoJSON:function(t){var n=!Le(this._latlngs),s=n&&!Le(this._latlngs[0]),r=Bo(this._latlngs,s?2:n?1:0,!0,t);return n||(r=[r]),fs(this,{type:(s?"Multi":"")+"Polygon",coordinates:r})}}),bi.include({toMultiPoint:function(t){var n=[];return this.eachLayer(function(s){n.push(s.toGeoJSON(t).geometry.coordinates)}),fs(this,{type:"MultiPoint",coordinates:n})},toGeoJSON:function(t){var n=this.feature&&this.feature.geometry&&this.feature.geometry.type;if(n==="MultiPoint")return this.toMultiPoint(t);var s=n==="GeometryCollection",r=[];return this.eachLayer(function(u){if(u.toGeoJSON){var p=u.toGeoJSON(t);if(s)r.push(p.geometry);else{var S=Do(p);S.type==="FeatureCollection"?r.push.apply(r,S.features):r.push(S)}}}),s?fs(this,{geometries:r,type:"GeometryCollection"}):{type:"FeatureCollection",features:r}}});function Da(t,n){return new Xn(t,n)}var zc=Da,Ro=Qe.extend({options:{opacity:1,alt:"",interactive:!1,crossOrigin:!1,errorOverlayUrl:"",zIndex:1,className:""},initialize:function(t,n,s){this._url=t,this._bounds=qt(n),q(this,s)},onAdd:function(){this._image||(this._initImage(),this.options.opacity<1&&this._updateOpacity()),this.options.interactive&&(mt(this._image,"leaflet-interactive"),this.addInteractiveTarget(this._image)),this.getPane().appendChild(this._image),this._reset()},onRemove:function(){Yt(this._image),this.options.interactive&&this.removeInteractiveTarget(this._image)},setOpacity:function(t){return this.options.opacity=t,this._image&&this._updateOpacity(),this},setStyle:function(t){return t.opacity&&this.setOpacity(t.opacity),this},bringToFront:function(){return this._map&&rn(this._image),this},bringToBack:function(){return this._map&&yn(this._image),this},setUrl:function(t){return this._url=t,this._image&&(this._image.src=t),this},setBounds:function(t){return this._bounds=qt(t),this._map&&this._reset(),this},getEvents:function(){var t={zoom:this._reset,viewreset:this._reset};return this._zoomAnimated&&(t.zoomanim=this._animateZoom),t},setZIndex:function(t){return this.options.zIndex=t,this._updateZIndex(),this},getBounds:function(){return this._bounds},getElement:function(){return this._image},_initImage:function(){var t=this._url.tagName==="IMG",n=this._image=t?this._url:W("img");if(mt(n,"leaflet-image-layer"),this._zoomAnimated&&mt(n,"leaflet-zoom-animated"),this.options.className&&mt(n,this.options.className),n.onselectstart=O,n.onmousemove=O,n.onload=h(this.fire,this,"load"),n.onerror=h(this._overlayOnError,this,"error"),(this.options.crossOrigin||this.options.crossOrigin==="")&&(n.crossOrigin=this.options.crossOrigin===!0?"":this.options.crossOrigin),this.options.zIndex&&this._updateZIndex(),t){this._url=n.src;return}n.src=this._url,n.alt=this.options.alt},_animateZoom:function(t){var n=this._map.getZoomScale(t.zoom),s=this._map._latLngBoundsToNewLayerBounds(this._bounds,t.zoom,t.center).min;ye(this._image,s,n)},_reset:function(){var t=this._image,n=new yt(this._map.latLngToLayerPoint(this._bounds.getNorthWest()),this._map.latLngToLayerPoint(this._bounds.getSouthEast())),s=n.getSize();le(t,n.min),t.style.width=s.x+"px",t.style.height=s.y+"px"},_updateOpacity:function(){Ie(this._image,this.options.opacity)},_updateZIndex:function(){this._image&&this.options.zIndex!==void 0&&this.options.zIndex!==null&&(this._image.style.zIndex=this.options.zIndex)},_overlayOnError:function(){this.fire("error");var t=this.options.errorOverlayUrl;t&&this._url!==t&&(this._url=t,this._image.src=t)},getCenter:function(){return this._bounds.getCenter()}}),Ac=function(t,n,s){return new Ro(t,n,s)},Ra=Ro.extend({options:{autoplay:!0,loop:!0,keepAspectRatio:!0,muted:!1,playsInline:!0},_initImage:function(){var t=this._url.tagName==="VIDEO",n=this._image=t?this._url:W("video");if(mt(n,"leaflet-image-layer"),this._zoomAnimated&&mt(n,"leaflet-zoom-animated"),this.options.className&&mt(n,this.options.className),n.onselectstart=O,n.onmousemove=O,n.onloadeddata=h(this.fire,this,"load"),t){for(var s=n.getElementsByTagName("source"),r=[],u=0;u0?r:[n.src];return}ut(this._url)||(this._url=[this._url]),!this.options.keepAspectRatio&&Object.prototype.hasOwnProperty.call(n.style,"objectFit")&&(n.style.objectFit="fill"),n.autoplay=!!this.options.autoplay,n.loop=!!this.options.loop,n.muted=!!this.options.muted,n.playsInline=!!this.options.playsInline;for(var p=0;pu?(n.height=u+"px",mt(t,p)):te(t,p),this._containerWidth=this._container.offsetWidth},_animateZoom:function(t){var n=this._map._latLngToNewLayerPoint(this._latlng,t.zoom,t.center),s=this._getAnchor();le(this._container,n.add(s))},_adjustPan:function(){if(this.options.autoPan){if(this._map._panAnim&&this._map._panAnim.stop(),this._autopanning){this._autopanning=!1;return}var t=this._map,n=parseInt(Ci(this._container,"marginBottom"),10)||0,s=this._container.offsetHeight+n,r=this._containerWidth,u=new Y(this._containerLeft,-s-this._containerBottom);u._add(On(this._container));var p=t.layerPointToContainerPoint(u),S=rt(this.options.autoPanPadding),C=rt(this.options.autoPanPaddingTopLeft||S),z=rt(this.options.autoPanPaddingBottomRight||S),H=t.getSize(),et=0,_t=0;p.x+r+z.x>H.x&&(et=p.x+r-H.x+z.x),p.x-et-C.x<0&&(et=p.x-C.x),p.y+s+z.y>H.y&&(_t=p.y+s-H.y+z.y),p.y-_t-C.y<0&&(_t=p.y-C.y),(et||_t)&&(this.options.keepInView&&(this._autopanning=!0),t.fire("autopanstart").panBy([et,_t]))}},_getAnchor:function(){return rt(this._source&&this._source._getPopupAnchor?this._source._getPopupAnchor():[0,0])}}),Bc=function(t,n){return new Fo(t,n)};zt.mergeOptions({closePopupOnClick:!0}),zt.include({openPopup:function(t,n,s){return this._initOverlay(Fo,t,n,s).openOn(this),this},closePopup:function(t){return t=arguments.length?t:this._popup,t&&t.close(),this}}),Qe.include({bindPopup:function(t,n){return this._popup=this._initOverlay(Fo,this._popup,t,n),this._popupHandlersAdded||(this.on({click:this._openPopup,keypress:this._onKeyPress,remove:this.closePopup,move:this._movePopup}),this._popupHandlersAdded=!0),this},unbindPopup:function(){return this._popup&&(this.off({click:this._openPopup,keypress:this._onKeyPress,remove:this.closePopup,move:this._movePopup}),this._popupHandlersAdded=!1,this._popup=null),this},openPopup:function(t){return this._popup&&(this instanceof Sn||(this._popup._source=this),this._popup._prepareOpen(t||this._latlng)&&this._popup.openOn(this._map)),this},closePopup:function(){return this._popup&&this._popup.close(),this},togglePopup:function(){return this._popup&&this._popup.toggle(this),this},isPopupOpen:function(){return this._popup?this._popup.isOpen():!1},setPopupContent:function(t){return this._popup&&this._popup.setContent(t),this},getPopup:function(){return this._popup},_openPopup:function(t){if(!(!this._popup||!this._map)){xn(t);var n=t.layer||t.target;if(this._popup._source===n&&!(n instanceof d)){this._map.hasLayer(this._popup)?this.closePopup():this.openPopup(t.latlng);return}this._popup._source=n,this.openPopup(t.latlng)}},_movePopup:function(t){this._popup.setLatLng(t.latlng)},_onKeyPress:function(t){t.originalEvent.keyCode===13&&this._openPopup(t)}});var Vo=Nn.extend({options:{pane:"tooltipPane",offset:[0,0],direction:"auto",permanent:!1,sticky:!1,opacity:.9},onAdd:function(t){Nn.prototype.onAdd.call(this,t),this.setOpacity(this.options.opacity),t.fire("tooltipopen",{tooltip:this}),this._source&&(this.addEventParent(this._source),this._source.fire("tooltipopen",{tooltip:this},!0))},onRemove:function(t){Nn.prototype.onRemove.call(this,t),t.fire("tooltipclose",{tooltip:this}),this._source&&(this.removeEventParent(this._source),this._source.fire("tooltipclose",{tooltip:this},!0))},getEvents:function(){var t=Nn.prototype.getEvents.call(this);return this.options.permanent||(t.preclick=this.close),t},_initLayout:function(){var t="leaflet-tooltip",n=t+" "+(this.options.className||"")+" leaflet-zoom-"+(this._zoomAnimated?"animated":"hide");this._contentNode=this._container=W("div",n),this._container.setAttribute("role","tooltip"),this._container.setAttribute("id","leaflet-tooltip-"+v(this))},_updateLayout:function(){},_adjustPan:function(){},_setPosition:function(t){var n,s,r=this._map,u=this._container,p=r.latLngToContainerPoint(r.getCenter()),S=r.layerPointToContainerPoint(t),C=this.options.direction,z=u.offsetWidth,H=u.offsetHeight,et=rt(this.options.offset),_t=this._getAnchor();C==="top"?(n=z/2,s=H):C==="bottom"?(n=z/2,s=0):C==="center"?(n=z/2,s=H/2):C==="right"?(n=0,s=H/2):C==="left"?(n=z,s=H/2):S.xthis.options.maxZoom||sr?this._retainParent(u,p,S,r):!1)},_retainChildren:function(t,n,s,r){for(var u=2*t;u<2*t+2;u++)for(var p=2*n;p<2*n+2;p++){var S=new Y(u,p);S.z=s+1;var C=this._tileCoordsToKey(S),z=this._tiles[C];if(z&&z.active){z.retain=!0;continue}else z&&z.loaded&&(z.retain=!0);s+1this.options.maxZoom||this.options.minZoom!==void 0&&u1){this._setView(t,s);return}for(var _t=u.min.y;_t<=u.max.y;_t++)for(var Nt=u.min.x;Nt<=u.max.x;Nt++){var He=new Y(Nt,_t);if(He.z=this._tileZoom,!!this._isValidTile(He)){var Me=this._tiles[this._tileCoordsToKey(He)];Me?Me.current=!0:S.push(He)}}if(S.sort(function(Ge,ps){return Ge.distanceTo(p)-ps.distanceTo(p)}),S.length!==0){this._loading||(this._loading=!0,this.fire("loading"));var un=document.createDocumentFragment();for(Nt=0;Nts.max.x)||!n.wrapLat&&(t.ys.max.y))return!1}if(!this.options.bounds)return!0;var r=this._tileCoordsToBounds(t);return qt(this.options.bounds).overlaps(r)},_keyToBounds:function(t){return this._tileCoordsToBounds(this._keyToTileCoords(t))},_tileCoordsToNwSe:function(t){var n=this._map,s=this.getTileSize(),r=t.scaleBy(s),u=r.add(s),p=n.unproject(r,t.z),S=n.unproject(u,t.z);return[p,S]},_tileCoordsToBounds:function(t){var n=this._tileCoordsToNwSe(t),s=new ge(n[0],n[1]);return this.options.noWrap||(s=this._map.wrapLatLngBounds(s)),s},_tileCoordsToKey:function(t){return t.x+":"+t.y+":"+t.z},_keyToTileCoords:function(t){var n=t.split(":"),s=new Y(+n[0],+n[1]);return s.z=+n[2],s},_removeTile:function(t){var n=this._tiles[t];n&&(Yt(n.el),delete this._tiles[t],this.fire("tileunload",{tile:n.el,coords:this._keyToTileCoords(t)}))},_initTile:function(t){mt(t,"leaflet-tile");var n=this.getTileSize();t.style.width=n.x+"px",t.style.height=n.y+"px",t.onselectstart=O,t.onmousemove=O,it.ielt9&&this.options.opacity<1&&Ie(t,this.options.opacity)},_addTile:function(t,n){var s=this._getTilePos(t),r=this._tileCoordsToKey(t),u=this.createTile(this._wrapCoords(t),h(this._tileReady,this,t));this._initTile(u),this.createTile.length<2&&Tt(h(this._tileReady,this,t,null,u)),le(u,s),this._tiles[r]={el:u,coords:t,current:!0},n.appendChild(u),this.fire("tileloadstart",{tile:u,coords:t})},_tileReady:function(t,n,s){n&&this.fire("tileerror",{error:n,tile:s,coords:t});var r=this._tileCoordsToKey(t);s=this._tiles[r],s&&(s.loaded=+new Date,this._map._fadeAnimated?(Ie(s.el,0),G(this._fadeFrame),this._fadeFrame=Tt(this._updateOpacity,this)):(s.active=!0,this._pruneTiles()),n||(mt(s.el,"leaflet-tile-loaded"),this.fire("tileload",{tile:s.el,coords:t})),this._noTilesToLoad()&&(this._loading=!1,this.fire("load"),it.ielt9||!this._map._fadeAnimated?Tt(this._pruneTiles,this):setTimeout(h(this._pruneTiles,this),250)))},_getTilePos:function(t){return t.scaleBy(this.getTileSize()).subtract(this._level.origin)},_wrapCoords:function(t){var n=new Y(this._wrapX?k(t.x,this._wrapX):t.x,this._wrapY?k(t.y,this._wrapY):t.y);return n.z=t.z,n},_pxBoundsToTileRange:function(t){var n=this.getTileSize();return new yt(t.min.unscaleBy(n).floor(),t.max.unscaleBy(n).ceil().subtract([1,1]))},_noTilesToLoad:function(){for(var t in this._tiles)if(!this._tiles[t].loaded)return!1;return!0}});function Fc(t){return new $s(t)}var hs=$s.extend({options:{minZoom:0,maxZoom:18,subdomains:"abc",errorTileUrl:"",zoomOffset:0,tms:!1,zoomReverse:!1,detectRetina:!1,crossOrigin:!1,referrerPolicy:!1},initialize:function(t,n){this._url=t,n=q(this,n),n.detectRetina&&it.retina&&n.maxZoom>0?(n.tileSize=Math.floor(n.tileSize/2),n.zoomReverse?(n.zoomOffset--,n.minZoom=Math.min(n.maxZoom,n.minZoom+1)):(n.zoomOffset++,n.maxZoom=Math.max(n.minZoom,n.maxZoom-1)),n.minZoom=Math.max(0,n.minZoom)):n.zoomReverse?n.minZoom=Math.min(n.maxZoom,n.minZoom):n.maxZoom=Math.max(n.minZoom,n.maxZoom),typeof n.subdomains=="string"&&(n.subdomains=n.subdomains.split("")),this.on("tileunload",this._onTileRemove)},setUrl:function(t,n){return this._url===t&&n===void 0&&(n=!0),this._url=t,n||this.redraw(),this},createTile:function(t,n){var s=document.createElement("img");return St(s,"load",h(this._tileOnLoad,this,n,s)),St(s,"error",h(this._tileOnError,this,n,s)),(this.options.crossOrigin||this.options.crossOrigin==="")&&(s.crossOrigin=this.options.crossOrigin===!0?"":this.options.crossOrigin),typeof this.options.referrerPolicy=="string"&&(s.referrerPolicy=this.options.referrerPolicy),s.alt="",s.src=this.getTileUrl(t),s},getTileUrl:function(t){var n={r:it.retina?"@2x":"",s:this._getSubdomain(t),x:t.x,y:t.y,z:this._getZoomForUrl()};if(this._map&&!this._map.options.crs.infinite){var s=this._globalTileRange.max.y-t.y;this.options.tms&&(n.y=s),n["-y"]=s}return bt(this._url,l(n,this.options))},_tileOnLoad:function(t,n){it.ielt9?setTimeout(h(t,this,null,n),0):t(null,n)},_tileOnError:function(t,n,s){var r=this.options.errorTileUrl;r&&n.getAttribute("src")!==r&&(n.src=r),t(s,n)},_onTileRemove:function(t){t.tile.onload=null},_getZoomForUrl:function(){var t=this._tileZoom,n=this.options.maxZoom,s=this.options.zoomReverse,r=this.options.zoomOffset;return s&&(t=n-t),t+r},_getSubdomain:function(t){var n=Math.abs(t.x+t.y)%this.options.subdomains.length;return this.options.subdomains[n]},_abortLoading:function(){var t,n;for(t in this._tiles)if(this._tiles[t].coords.z!==this._tileZoom&&(n=this._tiles[t].el,n.onload=O,n.onerror=O,!n.complete)){n.src=ft;var s=this._tiles[t].coords;Yt(n),delete this._tiles[t],this.fire("tileabort",{tile:n,coords:s})}},_removeTile:function(t){var n=this._tiles[t];if(n)return n.el.setAttribute("src",ft),$s.prototype._removeTile.call(this,t)},_tileReady:function(t,n,s){if(!(!this._map||s&&s.getAttribute("src")===ft))return $s.prototype._tileReady.call(this,t,n,s)}});function Za(t,n){return new hs(t,n)}var $a=hs.extend({defaultWmsParams:{service:"WMS",request:"GetMap",layers:"",styles:"",format:"image/jpeg",transparent:!1,version:"1.1.1"},options:{crs:null,uppercase:!1},initialize:function(t,n){this._url=t;var s=l({},this.defaultWmsParams);for(var r in n)r in this.options||(s[r]=n[r]);n=q(this,n);var u=n.detectRetina&&it.retina?2:1,p=this.getTileSize();s.width=p.x*u,s.height=p.y*u,this.wmsParams=s},onAdd:function(t){this._crs=this.options.crs||t.options.crs,this._wmsVersion=parseFloat(this.wmsParams.version);var n=this._wmsVersion>=1.3?"crs":"srs";this.wmsParams[n]=this._crs.code,hs.prototype.onAdd.call(this,t)},getTileUrl:function(t){var n=this._tileCoordsToNwSe(t),s=this._crs,r=Kt(s.project(n[0]),s.project(n[1])),u=r.min,p=r.max,S=(this._wmsVersion>=1.3&&this._crs===Vs?[u.y,u.x,p.y,p.x]:[u.x,u.y,p.x,p.y]).join(","),C=hs.prototype.getTileUrl.call(this,t);return C+At(this.wmsParams,C,this.options.uppercase)+(this.options.uppercase?"&BBOX=":"&bbox=")+S},setParams:function(t,n){return l(this.wmsParams,t),n||this.redraw(),this}});function Vc(t,n){return new $a(t,n)}hs.WMS=$a,Za.wms=Vc;var Qn=Qe.extend({options:{padding:.1},initialize:function(t){q(this,t),v(this),this._layers=this._layers||{}},onAdd:function(){this._container||(this._initContainer(),mt(this._container,"leaflet-zoom-animated")),this.getPane().appendChild(this._container),this._update(),this.on("update",this._updatePaths,this)},onRemove:function(){this.off("update",this._updatePaths,this),this._destroyContainer()},getEvents:function(){var t={viewreset:this._reset,zoom:this._onZoom,moveend:this._update,zoomend:this._onZoomEnd};return this._zoomAnimated&&(t.zoomanim=this._onAnimZoom),t},_onAnimZoom:function(t){this._updateTransform(t.center,t.zoom)},_onZoom:function(){this._updateTransform(this._map.getCenter(),this._map.getZoom())},_updateTransform:function(t,n){var s=this._map.getZoomScale(n,this._zoom),r=this._map.getSize().multiplyBy(.5+this.options.padding),u=this._map.project(this._center,n),p=r.multiplyBy(-s).add(u).subtract(this._map._getNewPixelOrigin(t,n));it.any3d?ye(this._container,p,s):le(this._container,p)},_reset:function(){this._update(),this._updateTransform(this._center,this._zoom);for(var t in this._layers)this._layers[t]._reset()},_onZoomEnd:function(){for(var t in this._layers)this._layers[t]._project()},_updatePaths:function(){for(var t in this._layers)this._layers[t]._update()},_update:function(){var t=this.options.padding,n=this._map.getSize(),s=this._map.containerPointToLayerPoint(n.multiplyBy(-t)).round();this._bounds=new yt(s,s.add(n.multiplyBy(1+t*2)).round()),this._center=this._map.getCenter(),this._zoom=this._map.getZoom()}}),Ha=Qn.extend({options:{tolerance:0},getEvents:function(){var t=Qn.prototype.getEvents.call(this);return t.viewprereset=this._onViewPreReset,t},_onViewPreReset:function(){this._postponeUpdatePaths=!0},onAdd:function(){Qn.prototype.onAdd.call(this),this._draw()},_initContainer:function(){var t=this._container=document.createElement("canvas");St(t,"mousemove",this._onMouseMove,this),St(t,"click dblclick mousedown mouseup contextmenu",this._onClick,this),St(t,"mouseout",this._handleMouseOut,this),t._leaflet_disable_events=!0,this._ctx=t.getContext("2d")},_destroyContainer:function(){G(this._redrawRequest),delete this._ctx,Yt(this._container),Jt(this._container),delete this._container},_updatePaths:function(){if(!this._postponeUpdatePaths){var t;this._redrawBounds=null;for(var n in this._layers)t=this._layers[n],t._update();this._redraw()}},_update:function(){if(!(this._map._animatingZoom&&this._bounds)){Qn.prototype._update.call(this);var t=this._bounds,n=this._container,s=t.getSize(),r=it.retina?2:1;le(n,t.min),n.width=r*s.x,n.height=r*s.y,n.style.width=s.x+"px",n.style.height=s.y+"px",it.retina&&this._ctx.scale(2,2),this._ctx.translate(-t.min.x,-t.min.y),this.fire("update")}},_reset:function(){Qn.prototype._reset.call(this),this._postponeUpdatePaths&&(this._postponeUpdatePaths=!1,this._updatePaths())},_initPath:function(t){this._updateDashArray(t),this._layers[v(t)]=t;var n=t._order={layer:t,prev:this._drawLast,next:null};this._drawLast&&(this._drawLast.next=n),this._drawLast=n,this._drawFirst=this._drawFirst||this._drawLast},_addPath:function(t){this._requestRedraw(t)},_removePath:function(t){var n=t._order,s=n.next,r=n.prev;s?s.prev=r:this._drawLast=r,r?r.next=s:this._drawFirst=s,delete t._order,delete this._layers[v(t)],this._requestRedraw(t)},_updatePath:function(t){this._extendRedrawBounds(t),t._project(),t._update(),this._requestRedraw(t)},_updateStyle:function(t){this._updateDashArray(t),this._requestRedraw(t)},_updateDashArray:function(t){if(typeof t.options.dashArray=="string"){var n=t.options.dashArray.split(/[, ]+/),s=[],r,u;for(u=0;u')}}catch{}return function(t){return document.createElement("<"+t+' xmlns="urn:schemas-microsoft.com:vml" class="lvml">')}})(),Zc={_initContainer:function(){this._container=W("div","leaflet-vml-container")},_update:function(){this._map._animatingZoom||(Qn.prototype._update.call(this),this.fire("update"))},_initPath:function(t){var n=t._container=Hs("shape");mt(n,"leaflet-vml-shape "+(this.options.className||"")),n.coordsize="1 1",t._path=Hs("path"),n.appendChild(t._path),this._updateStyle(t),this._layers[v(t)]=t},_addPath:function(t){var n=t._container;this._container.appendChild(n),t.options.interactive&&t.addInteractiveTarget(n)},_removePath:function(t){var n=t._container;Yt(n),t.removeInteractiveTarget(n),delete this._layers[v(t)]},_updateStyle:function(t){var n=t._stroke,s=t._fill,r=t.options,u=t._container;u.stroked=!!r.stroke,u.filled=!!r.fill,r.stroke?(n||(n=t._stroke=Hs("stroke")),u.appendChild(n),n.weight=r.weight+"px",n.color=r.color,n.opacity=r.opacity,r.dashArray?n.dashStyle=ut(r.dashArray)?r.dashArray.join(" "):r.dashArray.replace(/( *, *)/g," "):n.dashStyle="",n.endcap=r.lineCap.replace("butt","flat"),n.joinstyle=r.lineJoin):n&&(u.removeChild(n),t._stroke=null),r.fill?(s||(s=t._fill=Hs("fill")),u.appendChild(s),s.color=r.fillColor||r.color,s.opacity=r.fillOpacity):s&&(u.removeChild(s),t._fill=null)},_updateCircle:function(t){var n=t._point.round(),s=Math.round(t._radius),r=Math.round(t._radiusY||s);this._setPath(t,t._empty()?"M0 0":"AL "+n.x+","+n.y+" "+s+","+r+" 0,"+65535*360)},_setPath:function(t,n){t._path.v=n},_bringToFront:function(t){rn(t._container)},_bringToBack:function(t){yn(t._container)}},Zo=it.vml?Hs:B,Us=Qn.extend({_initContainer:function(){this._container=Zo("svg"),this._container.setAttribute("pointer-events","none"),this._rootGroup=Zo("g"),this._container.appendChild(this._rootGroup)},_destroyContainer:function(){Yt(this._container),Jt(this._container),delete this._container,delete this._rootGroup,delete this._svgSize},_update:function(){if(!(this._map._animatingZoom&&this._bounds)){Qn.prototype._update.call(this);var t=this._bounds,n=t.getSize(),s=this._container;(!this._svgSize||!this._svgSize.equals(n))&&(this._svgSize=n,s.setAttribute("width",n.x),s.setAttribute("height",n.y)),le(s,t.min),s.setAttribute("viewBox",[t.min.x,t.min.y,n.x,n.y].join(" ")),this.fire("update")}},_initPath:function(t){var n=t._path=Zo("path");t.options.className&&mt(n,t.options.className),t.options.interactive&&mt(n,"leaflet-interactive"),this._updateStyle(t),this._layers[v(t)]=t},_addPath:function(t){this._rootGroup||this._initContainer(),this._rootGroup.appendChild(t._path),t.addInteractiveTarget(t._path)},_removePath:function(t){Yt(t._path),t.removeInteractiveTarget(t._path),delete this._layers[v(t)]},_updatePath:function(t){t._project(),t._update()},_updateStyle:function(t){var n=t._path,s=t.options;n&&(s.stroke?(n.setAttribute("stroke",s.color),n.setAttribute("stroke-opacity",s.opacity),n.setAttribute("stroke-width",s.weight),n.setAttribute("stroke-linecap",s.lineCap),n.setAttribute("stroke-linejoin",s.lineJoin),s.dashArray?n.setAttribute("stroke-dasharray",s.dashArray):n.removeAttribute("stroke-dasharray"),s.dashOffset?n.setAttribute("stroke-dashoffset",s.dashOffset):n.removeAttribute("stroke-dashoffset")):n.setAttribute("stroke","none"),s.fill?(n.setAttribute("fill",s.fillColor||s.color),n.setAttribute("fill-opacity",s.fillOpacity),n.setAttribute("fill-rule",s.fillRule||"evenodd")):n.setAttribute("fill","none"))},_updatePoly:function(t,n){this._setPath(t,I(t._parts,n))},_updateCircle:function(t){var n=t._point,s=Math.max(Math.round(t._radius),1),r=Math.max(Math.round(t._radiusY),1)||s,u="a"+s+","+r+" 0 1,0 ",p=t._empty()?"M0 0":"M"+(n.x-s)+","+n.y+u+s*2+",0 "+u+-s*2+",0 ";this._setPath(t,p)},_setPath:function(t,n){t._path.setAttribute("d",n)},_bringToFront:function(t){rn(t._path)},_bringToBack:function(t){yn(t._path)}});it.vml&&Us.include(Zc);function ja(t){return it.svg||it.vml?new Us(t):null}zt.include({getRenderer:function(t){var n=t.options.renderer||this._getPaneRenderer(t.options.pane)||this.options.renderer||this._renderer;return n||(n=this._renderer=this._createRenderer()),this.hasLayer(n)||this.addLayer(n),n},_getPaneRenderer:function(t){if(t==="overlayPane"||t===void 0)return!1;var n=this._paneRenderers[t];return n===void 0&&(n=this._createRenderer({pane:t}),this._paneRenderers[t]=n),n},_createRenderer:function(t){return this.options.preferCanvas&&Ua(t)||ja(t)}});var Wa=ds.extend({initialize:function(t,n){ds.prototype.initialize.call(this,this._boundsToLatLngs(t),n)},setBounds:function(t){return this.setLatLngs(this._boundsToLatLngs(t))},_boundsToLatLngs:function(t){return t=qt(t),[t.getSouthWest(),t.getNorthWest(),t.getNorthEast(),t.getSouthEast()]}});function $c(t,n){return new Wa(t,n)}Us.create=Zo,Us.pointsToPath=I,Xn.geometryToLayer=Io,Xn.coordsToLatLng=Zr,Xn.coordsToLatLngs=No,Xn.latLngToCoords=$r,Xn.latLngsToCoords=Bo,Xn.getFeature=fs,Xn.asFeature=Do,zt.mergeOptions({boxZoom:!0});var Ka=ee.extend({initialize:function(t){this._map=t,this._container=t._container,this._pane=t._panes.overlayPane,this._resetStateTimeout=0,t.on("unload",this._destroy,this)},addHooks:function(){St(this._container,"mousedown",this._onMouseDown,this)},removeHooks:function(){Jt(this._container,"mousedown",this._onMouseDown,this)},moved:function(){return this._moved},_destroy:function(){Yt(this._pane),delete this._pane},_resetState:function(){this._resetStateTimeout=0,this._moved=!1},_clearDeferredResetState:function(){this._resetStateTimeout!==0&&(clearTimeout(this._resetStateTimeout),this._resetStateTimeout=0)},_onMouseDown:function(t){if(!t.shiftKey||t.which!==1&&t.button!==1)return!1;this._clearDeferredResetState(),this._resetState(),mi(),Ns(),this._startPoint=this._map.mouseEventToContainerPoint(t),St(document,{contextmenu:xn,mousemove:this._onMouseMove,mouseup:this._onMouseUp,keydown:this._onKeyDown},this)},_onMouseMove:function(t){this._moved||(this._moved=!0,this._box=W("div","leaflet-zoom-box",this._container),mt(this._container,"leaflet-crosshair"),this._map.fire("boxzoomstart")),this._point=this._map.mouseEventToContainerPoint(t);var n=new yt(this._point,this._startPoint),s=n.getSize();le(this._box,n.min),this._box.style.width=s.x+"px",this._box.style.height=s.y+"px"},_finish:function(){this._moved&&(Yt(this._box),te(this._container,"leaflet-crosshair")),Ei(),Bs(),Jt(document,{contextmenu:xn,mousemove:this._onMouseMove,mouseup:this._onMouseUp,keydown:this._onKeyDown},this)},_onMouseUp:function(t){if(!(t.which!==1&&t.button!==1)&&(this._finish(),!!this._moved)){this._clearDeferredResetState(),this._resetStateTimeout=setTimeout(h(this._resetState,this),0);var n=new ge(this._map.containerPointToLatLng(this._startPoint),this._map.containerPointToLatLng(this._point));this._map.fitBounds(n).fire("boxzoomend",{boxZoomBounds:n})}},_onKeyDown:function(t){t.keyCode===27&&(this._finish(),this._clearDeferredResetState(),this._resetState())}});zt.addInitHook("addHandler","boxZoom",Ka),zt.mergeOptions({doubleClickZoom:!0});var qa=ee.extend({addHooks:function(){this._map.on("dblclick",this._onDoubleClick,this)},removeHooks:function(){this._map.off("dblclick",this._onDoubleClick,this)},_onDoubleClick:function(t){var n=this._map,s=n.getZoom(),r=n.options.zoomDelta,u=t.originalEvent.shiftKey?s-r:s+r;n.options.doubleClickZoom==="center"?n.setZoom(u):n.setZoomAround(t.containerPoint,u)}});zt.addInitHook("addHandler","doubleClickZoom",qa),zt.mergeOptions({dragging:!0,inertia:!0,inertiaDeceleration:3400,inertiaMaxSpeed:1/0,easeLinearity:.2,worldCopyJump:!1,maxBoundsViscosity:0});var Ga=ee.extend({addHooks:function(){if(!this._draggable){var t=this._map;this._draggable=new qe(t._mapPane,t._container),this._draggable.on({dragstart:this._onDragStart,drag:this._onDrag,dragend:this._onDragEnd},this),this._draggable.on("predrag",this._onPreDragLimit,this),t.options.worldCopyJump&&(this._draggable.on("predrag",this._onPreDragWrap,this),t.on("zoomend",this._onZoomEnd,this),t.whenReady(this._onZoomEnd,this))}mt(this._map._container,"leaflet-grab leaflet-touch-drag"),this._draggable.enable(),this._positions=[],this._times=[]},removeHooks:function(){te(this._map._container,"leaflet-grab"),te(this._map._container,"leaflet-touch-drag"),this._draggable.disable()},moved:function(){return this._draggable&&this._draggable._moved},moving:function(){return this._draggable&&this._draggable._moving},_onDragStart:function(){var t=this._map;if(t._stop(),this._map.options.maxBounds&&this._map.options.maxBoundsViscosity){var n=qt(this._map.options.maxBounds);this._offsetLimit=Kt(this._map.latLngToContainerPoint(n.getNorthWest()).multiplyBy(-1),this._map.latLngToContainerPoint(n.getSouthEast()).multiplyBy(-1).add(this._map.getSize())),this._viscosity=Math.min(1,Math.max(0,this._map.options.maxBoundsViscosity))}else this._offsetLimit=null;t.fire("movestart").fire("dragstart"),t.options.inertia&&(this._positions=[],this._times=[])},_onDrag:function(t){if(this._map.options.inertia){var n=this._lastTime=+new Date,s=this._lastPos=this._draggable._absPos||this._draggable._newPos;this._positions.push(s),this._times.push(n),this._prunePositions(n)}this._map.fire("move",t).fire("drag",t)},_prunePositions:function(t){for(;this._positions.length>1&&t-this._times[0]>50;)this._positions.shift(),this._times.shift()},_onZoomEnd:function(){var t=this._map.getSize().divideBy(2),n=this._map.latLngToLayerPoint([0,0]);this._initialWorldOffset=n.subtract(t).x,this._worldWidth=this._map.getPixelWorldBounds().getSize().x},_viscousLimit:function(t,n){return t-(t-n)*this._viscosity},_onPreDragLimit:function(){if(!(!this._viscosity||!this._offsetLimit)){var t=this._draggable._newPos.subtract(this._draggable._startPos),n=this._offsetLimit;t.xn.max.x&&(t.x=this._viscousLimit(t.x,n.max.x)),t.y>n.max.y&&(t.y=this._viscousLimit(t.y,n.max.y)),this._draggable._newPos=this._draggable._startPos.add(t)}},_onPreDragWrap:function(){var t=this._worldWidth,n=Math.round(t/2),s=this._initialWorldOffset,r=this._draggable._newPos.x,u=(r-n+s)%t+n-s,p=(r+n+s)%t-n-s,S=Math.abs(u+s)0?p:-p))-n;this._delta=0,this._startTime=null,S&&(t.options.scrollWheelZoom==="center"?t.setZoom(n+S):t.setZoomAround(this._lastMousePos,n+S))}});zt.addInitHook("addHandler","scrollWheelZoom",Ja);var Hc=600;zt.mergeOptions({tapHold:it.touchNative&&it.safari&&it.mobile,tapTolerance:15});var Xa=ee.extend({addHooks:function(){St(this._map._container,"touchstart",this._onDown,this)},removeHooks:function(){Jt(this._map._container,"touchstart",this._onDown,this)},_onDown:function(t){if(clearTimeout(this._holdTimeout),t.touches.length===1){var n=t.touches[0];this._startPos=this._newPos=new Y(n.clientX,n.clientY),this._holdTimeout=setTimeout(h(function(){this._cancel(),this._isTapValid()&&(St(document,"touchend",Se),St(document,"touchend touchcancel",this._cancelClickPrevent),this._simulateEvent("contextmenu",n))},this),Hc),St(document,"touchend touchcancel contextmenu",this._cancel,this),St(document,"touchmove",this._onMove,this)}},_cancelClickPrevent:function t(){Jt(document,"touchend",Se),Jt(document,"touchend touchcancel",t)},_cancel:function(){clearTimeout(this._holdTimeout),Jt(document,"touchend touchcancel contextmenu",this._cancel,this),Jt(document,"touchmove",this._onMove,this)},_onMove:function(t){var n=t.touches[0];this._newPos=new Y(n.clientX,n.clientY)},_isTapValid:function(){return this._newPos.distanceTo(this._startPos)<=this._map.options.tapTolerance},_simulateEvent:function(t,n){var s=new MouseEvent(t,{bubbles:!0,cancelable:!0,view:window,screenX:n.screenX,screenY:n.screenY,clientX:n.clientX,clientY:n.clientY});s._simulated=!0,n.target.dispatchEvent(s)}});zt.addInitHook("addHandler","tapHold",Xa),zt.mergeOptions({touchZoom:it.touch,bounceAtZoomLimits:!0});var Qa=ee.extend({addHooks:function(){mt(this._map._container,"leaflet-touch-zoom"),St(this._map._container,"touchstart",this._onTouchStart,this)},removeHooks:function(){te(this._map._container,"leaflet-touch-zoom"),Jt(this._map._container,"touchstart",this._onTouchStart,this)},_onTouchStart:function(t){var n=this._map;if(!(!t.touches||t.touches.length!==2||n._animatingZoom||this._zooming)){var s=n.mouseEventToContainerPoint(t.touches[0]),r=n.mouseEventToContainerPoint(t.touches[1]);this._centerPoint=n.getSize()._divideBy(2),this._startLatLng=n.containerPointToLatLng(this._centerPoint),n.options.touchZoom!=="center"&&(this._pinchStartLatLng=n.containerPointToLatLng(s.add(r)._divideBy(2))),this._startDist=s.distanceTo(r),this._startZoom=n.getZoom(),this._moved=!1,this._zooming=!0,n._stop(),St(document,"touchmove",this._onTouchMove,this),St(document,"touchend touchcancel",this._onTouchEnd,this),Se(t)}},_onTouchMove:function(t){if(!(!t.touches||t.touches.length!==2||!this._zooming)){var n=this._map,s=n.mouseEventToContainerPoint(t.touches[0]),r=n.mouseEventToContainerPoint(t.touches[1]),u=s.distanceTo(r)/this._startDist;if(this._zoom=n.getScaleZoom(u,this._startZoom),!n.options.bounceAtZoomLimits&&(this._zoomn.getMaxZoom()&&u>1)&&(this._zoom=n._limitZoom(this._zoom)),n.options.touchZoom==="center"){if(this._center=this._startLatLng,u===1)return}else{var p=s._add(r)._divideBy(2)._subtract(this._centerPoint);if(u===1&&p.x===0&&p.y===0)return;this._center=n.unproject(n.project(this._pinchStartLatLng,this._zoom).subtract(p),this._zoom)}this._moved||(n._moveStart(!0,!1),this._moved=!0),G(this._animRequest);var S=h(n._move,n,this._center,this._zoom,{pinch:!0,round:!1},void 0);this._animRequest=Tt(S,this,!0),Se(t)}},_onTouchEnd:function(){if(!this._moved||!this._zooming){this._zooming=!1;return}this._zooming=!1,G(this._animRequest),Jt(document,"touchmove",this._onTouchMove,this),Jt(document,"touchend touchcancel",this._onTouchEnd,this),this._map.options.zoomAnimation?this._map._animateZoom(this._center,this._map._limitZoom(this._zoom),!0,this._map.options.zoomSnap):this._map._resetView(this._center,this._map._limitZoom(this._zoom))}});zt.addInitHook("addHandler","touchZoom",Qa),zt.BoxZoom=Ka,zt.DoubleClickZoom=qa,zt.Drag=Ga,zt.Keyboard=Ya,zt.ScrollWheelZoom=Ja,zt.TapHold=Xa,zt.TouchZoom=Qa,o.Bounds=yt,o.Browser=it,o.CRS=Pe,o.Canvas=Ha,o.Circle=Ht,o.CircleMarker=Z,o.Class=It,o.Control=$e,o.DivIcon=Va,o.DivOverlay=Nn,o.DomEvent=Lo,o.DomUtil=zr,o.Draggable=qe,o.Evented=Vt,o.FeatureGroup=Sn,o.GeoJSON=Xn,o.GridLayer=$s,o.Handler=ee,o.Icon=ln,o.ImageOverlay=Ro,o.LatLng=Ut,o.LatLngBounds=ge,o.Layer=Qe,o.LayerGroup=bi,o.LineUtil=wn,o.Map=zt,o.Marker=cs,o.Mixin=vi,o.Path=d,o.Point=Y,o.PolyUtil=Oo,o.Polygon=ds,o.Polyline=Jn,o.Popup=Fo,o.PosAnimation=Ai,o.Projection=Eo,o.Rectangle=Wa,o.Renderer=Qn,o.SVG=Us,o.SVGOverlay=Fa,o.TileLayer=hs,o.Tooltip=Vo,o.Transformation=gn,o.Util=st,o.VideoOverlay=Ra,o.bind=h,o.bounds=Kt,o.canvas=Ua,o.circle=Cc,o.circleMarker=w,o.control=Ii,o.divIcon=Rc,o.extend=l,o.featureGroup=se,o.geoJSON=Da,o.geoJson=zc,o.gridLayer=Fc,o.icon=Zs,o.imageOverlay=Ac,o.latLng=Pt,o.latLngBounds=qt,o.layerGroup=us,o.map=ss,o.marker=y,o.point=rt,o.polygon=Ec,o.polyline=Oc,o.popup=Bc,o.rectangle=$c,o.setOptions=q,o.stamp=v,o.svg=ja,o.svgOverlay=Nc,o.tileLayer=Za,o.tooltip=Dc,o.transformation=_,o.version=a,o.videoOverlay=Ic;var Uc=window.L;o.noConflict=function(){return window.L=Uc,this},window.L=o}))})(Js,Js.exports)),Js.exports}var Cp=Mp();const qo=Tp(Cp),Xl={__name:"DeviceMap",props:{position:{type:Object,default:null},trail:{type:Array,default:()=>[]}},setup(e){const i=e,o=J(null);let a,l,c;function h(){if(!a)return;const g=i.position;if(g&&(g.lat||g.lng)){const v=[g.lat,g.lng];l?l.setLatLng(v):(l=qo.marker(v).addTo(a),a.setView(v,17))}if(c&&c.remove(),i.trail.length){const v=getComputedStyle(document.documentElement).getPropertyValue("--accent").trim()||"#3D7BF0";c=qo.polyline(i.trail,{color:v,weight:3}).addTo(a)}}return Ls(()=>{a=qo.map(o.value,{zoomControl:!0}).setView([20,0],2),qo.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",{attribution:"© OpenStreetMap",maxZoom:19}).addTo(a),setTimeout(()=>a.invalidateSize(),60),h()}),Je(()=>i.position,h,{deep:!0}),Je(()=>i.trail,h,{deep:!0}),(g,v)=>(b(),x("div",{ref_key:"el",ref:o,class:"h-[320px] w-full rounded-lg"},null,512))}},Op=["width","height","stroke-width"],Ep=["d"],tt={__name:"Icon",props:{name:{type:String,required:!0},size:{type:[Number,String],default:18},stroke:{type:[Number,String],default:2}},setup(e){const a=({grid:"M3 3h7v7H3zM14 3h7v7h-7zM14 14h7v7h-7zM3 14h7v7H3z",radio:"M4.9 19.1a10 10 0 0 1 0-14.2M7.8 16.2a6 6 0 0 1 0-8.4M16.2 7.8a6 6 0 0 1 0 8.4M19.1 4.9a10 10 0 0 1 0 14.2M12 13a1 1 0 1 0 0-2 1 1 0 0 0 0 2z",route:"M6 19a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM18 11a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM6 13V9a4 4 0 0 1 4-4h4",calendar:"M8 2v4M16 2v4M3 10h18M5 4h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z",book:"M4 19.5A2.5 2.5 0 0 1 6.5 17H20M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z",fileText:"M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8zM14 2v6h6M16 13H8M16 17H8M10 9H8",settings:"M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z",search:"M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16zM21 21l-4.3-4.3",plus:"M12 5v14M5 12h14",battery:"M3 8h14a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H3a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1zM22 11v2",signal:"M2 20h.01M7 20v-4M12 20v-8M17 20V8M22 20V4",clock:"M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM12 7v5l3 2",chevronRight:"M9 6l6 6-6 6",play:"M6 3l14 9-14 9V3z",logout:"M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9",wind:"M12.8 19.6A2 2 0 1 0 14 16H2M17.5 8a2.5 2.5 0 1 1 2 4H2M9.6 4.6A2 2 0 1 1 11 8H2",drone:"M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8zM6 6 4 4M18 6l2-2M6 18l-2 2M18 18l2 2",user:"M20 21a8 8 0 1 0-16 0M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z",users:"M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75",shield:"M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z",sliders:"M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6",alertTriangle:"M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0zM12 9v4M12 17h.01",download:"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3",upload:"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M17 8l-5-5-5 5M12 3v12",trash:"M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6M10 11v6M14 11v6",check:"M20 6 9 17l-5-5",mail:"M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2zM22 6l-10 7L2 6",lock:"M5 11h14a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-6a2 2 0 0 1 2-2zM7 11V7a5 5 0 0 1 10 0v4",monitor:"M3 4h18a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1zM8 21h8M12 17v4",globe:"M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM3 12h18M12 3a15 15 0 0 1 0 18 15 15 0 0 1 0-18z",smartphone:"M7 2h10a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1zM11 18h2",image:"M4 4h16a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1zM8.5 11a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3zM21 15l-5-5L5 21",type:"M4 7V4h16v3M9 20h6M12 4v16",sun:"M12 17a5 5 0 1 0 0-10 5 5 0 0 0 0 10zM12 1v2M12 21v2M4.2 4.2l1.4 1.4M18.4 18.4l1.4 1.4M1 12h2M21 12h2M4.2 19.8l1.4-1.4M18.4 5.6l1.4-1.4",moon:"M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z",x:"M18 6 6 18M6 6l12 12",server:"M20 4H4a2 2 0 0 0-2 2v3a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2zM20 13H4a2 2 0 0 0-2 2v3a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2zM6 7.5h.01M6 16.5h.01",cloud:"M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9z"}[e.name]||"").split(" M").map((l,c)=>c?"M"+l:l);return(l,c)=>(b(),x("svg",{width:e.size,height:e.size,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor","stroke-width":e.stroke,"stroke-linecap":"round","stroke-linejoin":"round",style:{flex:"none"},"aria-hidden":"true"},[(b(!0),x(wt,null,ce(Ct(a),(h,g)=>(b(),x("path",{key:g,d:h},null,8,Ep))),128))],8,Op))}},zp=["aria-checked","disabled"],tn={__name:"Toggle",props:{modelValue:{type:Boolean,default:!1},disabled:{type:Boolean,default:!1}},emits:["update:modelValue"],setup(e,{emit:i}){const o=i;return(a,l)=>(b(),x("button",{type:"button",role:"switch","aria-checked":e.modelValue,disabled:e.disabled,class:Ot(["relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition disabled:opacity-40",e.modelValue?"bg-accent":"bg-surface-2 border border-line-strong"]),onClick:l[0]||(l[0]=c=>o("update:modelValue",!e.modelValue))},[f("span",{class:Ot(["inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition",e.modelValue?"translate-x-6":"translate-x-1"])},null,2)],10,zp))}},Ap={class:"inline-flex rounded-[10px] border border-line bg-surface-2 p-0.5"},Ip=["onClick"],dn={__name:"Segmented",props:{modelValue:{type:[String,Number],default:""},options:{type:Array,default:()=>[]}},emits:["update:modelValue"],setup(e,{emit:i}){const o=i;return(a,l)=>(b(),x("div",Ap,[(b(!0),x(wt,null,ce(e.options,c=>(b(),x("button",{key:c.value,type:"button",class:Ot(["inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-[13px] font-semibold transition",e.modelValue===c.value?"bg-surface-1 text-ink shadow-xs":"text-ink-secondary hover:text-ink"]),onClick:h=>o("update:modelValue",c.value)},[c.icon?(b(),oe(tt,{key:0,name:c.icon,size:15},null,8,["name"])):V("",!0),N(" "+M(c.label),1)],10,Ip))),128))]))}},Np={class:"text-sm font-semibold text-ink"},Bp={key:0,class:"mt-0.5 text-xs text-ink-muted"},lt={__name:"Row",props:{title:{type:String,default:""},desc:{type:String,default:""},keywords:{type:String,default:""},block:{type:Boolean,default:!1}},setup(e){const i=e,o=eo("settingsSearch",{value:""}),a=ht(()=>{const l=(o.value||"").trim().toLowerCase();return l?`${i.title} ${i.desc} ${i.keywords}`.toLowerCase().includes(l):!0});return(l,c)=>a.value?(b(),x("div",{key:0,class:Ot(["border-b border-line py-4 last:border-0",e.block?"":"flex items-center justify-between gap-6"])},[f("div",{class:Ot(e.block?"mb-3":"min-w-0")},[f("div",Np,M(e.title),1),e.desc?(b(),x("div",Bp,M(e.desc),1)):V("",!0)],2),f("div",{class:Ot(e.block?"":"shrink-0")},[af(l.$slots,"default")],2)],2)):V("",!0)}},Dp=(e,i)=>{const o=e.__vccOpts||e;for(const[a,l]of i)o[a]=l;return o},Rp={class:"mx-auto max-w-[1280px] p-7"},Fp={class:"mb-5 flex flex-wrap items-end justify-between gap-4"},Vp={class:"flex h-10 w-full max-w-[280px] items-center gap-2 rounded border border-line-strong bg-surface-1 px-3"},Zp={class:"grid grid-cols-[210px_1fr] gap-6 max-[760px]:grid-cols-1"},$p={class:"flex flex-col gap-0.5 max-[760px]:flex-row max-[760px]:overflow-x-auto"},Hp=["onClick"],Up={class:"whitespace-nowrap"},jp={class:"min-w-0"},Wp={key:0,class:"panel p-10 text-center text-sm text-ink-muted"},Kp={key:0,class:"eyebrow mb-2 mt-5 first:mt-0 flex items-center gap-2"},qp={key:1,class:"panel mb-5 p-5"},Gp={class:"flex items-center gap-1"},Yp={class:"flex items-center gap-2"},Jp={class:"font-mono text-sm text-ink"},Xp={class:"inline-flex items-center gap-1 rounded-full bg-amber-soft px-2 py-0.5 text-[11px] font-semibold text-amber-fg"},Qp={key:0,class:"mt-2 text-xs text-ink-muted"},tm={class:"grid max-w-[420px] gap-2"},em={class:"flex items-center gap-3"},nm={key:2,class:"panel mb-5 p-5"},im=["value"],sm=["value"],om=["value"],rm={class:"font-mono text-sm text-ink"},am={key:3},lm={key:0,class:"mb-5 flex items-center gap-1 overflow-x-auto border-b border-line"},um=["onClick"],cm={key:1,class:"panel mb-5 p-5"},dm={class:"mb-4 flex items-start gap-3 border-b border-line pb-4"},fm={class:"grid h-10 w-10 place-items-center rounded-lg bg-accent-soft text-accent-soft-fg"},hm={key:0,class:"mb-4 rounded-lg border border-line bg-surface-2 px-4 py-3 text-xs text-ink-secondary"},pm={key:1,class:"mb-4 flex flex-col gap-2 border-b border-line pb-4 sm:flex-row sm:items-center sm:justify-between"},mm={key:4,class:"border-b border-line py-3 text-xs text-amber-fg"},gm={key:0},_m={key:5,class:"border-b border-line py-3 text-xs text-ink-muted"},vm={class:"font-semibold text-ink-secondary"},ym={key:6,class:"border-b border-line py-3 text-xs text-ink-muted"},bm={key:7,class:"my-4 rounded-lg border border-line bg-surface-2 p-4","data-keywords":"credits usage quota remaining daily allowance rate limit"},xm={class:"flex items-center justify-between gap-3"},wm={class:"flex items-center gap-2 text-sm font-semibold text-ink"},km={key:0,class:"text-[11px] text-ink-muted"},Sm={class:"mt-2 flex items-baseline gap-1.5"},Pm={class:"font-mono text-2xl font-semibold text-ink"},Tm={class:"text-sm text-ink-muted"},Lm={class:"mt-2 h-2 w-full overflow-hidden rounded-full bg-line"},Mm={class:"mt-2 text-xs text-ink-muted"},Cm={class:"mt-2 text-sm text-ink"},Om={class:"font-semibold"},Em={class:"mt-1 text-xs text-ink-muted"},zm={key:1,class:"mt-2 text-xs text-ink-muted"},Am={key:0,class:"inline-flex items-center gap-2 text-sm text-ink"},Im={key:0,class:"inline-flex items-center gap-1 rounded-full bg-surface-2 px-2 py-0.5 text-[10px] font-semibold text-ink-secondary"},Nm={key:0,class:"inline-flex items-center gap-2 font-mono text-sm text-ink"},Bm={key:0,class:"inline-flex items-center gap-1 rounded-full bg-surface-2 px-2 py-0.5 text-[10px] font-semibold text-ink-secondary"},Dm={key:0,class:"inline-flex items-center gap-2 font-mono text-sm text-ink"},Rm={key:0,class:"inline-flex items-center gap-1 rounded-full bg-surface-2 px-2 py-0.5 text-[10px] font-semibold text-ink-secondary"},Fm={key:0,class:"inline-flex items-center gap-2 font-mono text-sm text-ink"},Vm={key:0,class:"inline-flex items-center gap-1 rounded-full bg-surface-2 px-2 py-0.5 text-[10px] font-semibold text-ink-secondary"},Zm={key:8,class:"border-b border-line py-3 text-xs text-amber-fg"},$m={class:"mt-4 flex flex-wrap items-center gap-3"},Hm=["disabled"],Um=["disabled"],jm={key:2,class:"text-xs text-danger-fg"},Wm={class:"panel mb-5 p-5"},Km={class:"mb-4 flex items-start gap-3 border-b border-line pb-4"},qm={class:"grid h-10 w-10 place-items-center rounded-lg bg-accent-soft text-accent-soft-fg"},Gm={key:0,class:"mb-4 rounded-lg border border-line bg-surface-2 px-4 py-3 text-xs text-ink-secondary"},Ym={key:1,class:"mb-4 flex flex-col gap-2 border-b border-line pb-4 sm:flex-row sm:items-center sm:justify-between"},Jm={key:4,class:"border-b border-line py-3 text-xs text-amber-fg"},Xm={key:0},Qm={key:5,class:"border-b border-line py-3 text-xs text-ink-muted"},tg={class:"font-semibold text-ink-secondary"},eg={key:6,class:"border-b border-line py-3 text-xs text-ink-muted"},ng={key:0,class:"inline-flex items-center gap-2 text-sm text-ink"},ig={key:0,class:"inline-flex items-center gap-1 rounded-full bg-surface-2 px-2 py-0.5 text-[10px] font-semibold text-ink-secondary"},sg={key:0,class:"inline-flex items-center gap-2 font-mono text-sm text-ink"},og={key:0,class:"inline-flex items-center gap-1 rounded-full bg-surface-2 px-2 py-0.5 text-[10px] font-semibold text-ink-secondary"},rg={key:0,class:"inline-flex items-center gap-2 font-mono text-sm text-ink"},ag={key:0,class:"inline-flex items-center gap-1 rounded-full bg-surface-2 px-2 py-0.5 text-[10px] font-semibold text-ink-secondary"},lg={key:0,class:"inline-flex items-center gap-2 font-mono text-sm text-ink"},ug={key:0,class:"inline-flex items-center gap-1 rounded-full bg-surface-2 px-2 py-0.5 text-[10px] font-semibold text-ink-secondary"},cg={key:0,class:"inline-flex items-center gap-2 font-mono text-sm text-ink"},dg={key:0,class:"inline-flex items-center gap-1 rounded-full bg-surface-2 px-2 py-0.5 text-[10px] font-semibold text-ink-secondary"},fg={key:0,class:"inline-flex items-center gap-2 font-mono text-sm text-ink"},hg={key:0,class:"inline-flex items-center gap-1 rounded-full bg-surface-2 px-2 py-0.5 text-[10px] font-semibold text-ink-secondary"},pg={key:0,class:"inline-flex items-center gap-2 font-mono text-sm text-ink"},mg={key:0,class:"inline-flex items-center gap-1 rounded-full bg-surface-2 px-2 py-0.5 text-[10px] font-semibold text-ink-secondary"},gg={key:0,class:"inline-flex items-center gap-2 font-mono text-sm text-ink"},_g={key:0,class:"inline-flex items-center gap-1 rounded-full bg-surface-2 px-2 py-0.5 text-[10px] font-semibold text-ink-secondary"},vg={key:0,class:"inline-flex items-center gap-2 text-sm text-ink"},yg={key:0,class:"inline-flex items-center gap-1 rounded-full bg-surface-2 px-2 py-0.5 text-[10px] font-semibold text-ink-secondary"},bg={key:0,class:"inline-flex items-center gap-2 font-mono text-sm text-ink"},xg={key:0,class:"inline-flex items-center gap-1 rounded-full bg-surface-2 px-2 py-0.5 text-[10px] font-semibold text-ink-secondary"},wg={class:"mt-4 flex flex-wrap items-center gap-3"},kg=["disabled"],Sg=["disabled"],Pg={key:2,class:"text-xs text-danger-fg"},Tg={key:3,class:"text-[11px] text-ink-muted"},Lg={class:"panel mb-5 p-5"},Mg={class:"mb-4 flex items-start gap-3 border-b border-line pb-4"},Cg={class:"grid h-10 w-10 place-items-center rounded-lg bg-accent-soft text-accent-soft-fg"},Og={key:0,class:"mb-4 rounded-lg border border-line bg-surface-2 px-4 py-3 text-xs text-ink-secondary"},Eg={key:1,class:"mb-4 flex flex-col gap-2 border-b border-line pb-4 sm:flex-row sm:items-center sm:justify-between"},zg={key:4,class:"border-b border-line py-3 text-xs text-amber-fg"},Ag={key:0},Ig={key:5,class:"border-b border-line py-3 text-xs text-ink-muted"},Ng={class:"font-semibold text-ink-secondary"},Bg={key:6,class:"border-b border-line py-3 text-xs text-ink-muted"},Dg={key:0,class:"inline-flex items-center gap-2 break-all font-mono text-sm text-ink"},Rg={key:0,class:"inline-flex items-center gap-1 rounded-full bg-surface-2 px-2 py-0.5 text-[10px] font-semibold text-ink-secondary"},Fg={key:0,class:"inline-flex items-center gap-2 font-mono text-sm text-ink"},Vg={key:0,class:"inline-flex items-center gap-1 rounded-full bg-surface-2 px-2 py-0.5 text-[10px] font-semibold text-ink-secondary"},Zg={key:0,class:"inline-flex items-center gap-2 font-mono text-sm text-ink"},$g={key:0,class:"inline-flex items-center gap-1 rounded-full bg-surface-2 px-2 py-0.5 text-[10px] font-semibold text-ink-secondary"},Hg={key:0,class:"inline-flex items-center gap-2 text-sm text-ink"},Ug={key:0,class:"inline-flex items-center gap-1 rounded-full bg-surface-2 px-2 py-0.5 text-[10px] font-semibold text-ink-secondary"},jg={key:0,class:"inline-flex items-center gap-2 font-mono text-sm text-ink"},Wg={key:0,class:"inline-flex items-center gap-1 rounded-full bg-surface-2 px-2 py-0.5 text-[10px] font-semibold text-ink-secondary"},Kg={class:"mt-4 flex flex-wrap items-center gap-3"},qg=["disabled"],Gg=["disabled"],Yg={key:2,class:"text-xs text-danger-fg"},Jg={key:3,class:"text-[11px] text-ink-muted"},Xg={key:3,class:"panel mb-5 p-5"},Qg={class:"mb-4 flex items-start gap-3 border-b border-line pb-4"},t_={class:"grid h-10 w-10 place-items-center rounded-lg bg-accent-soft text-accent-soft-fg"},e_={key:0,class:"mb-4 rounded-lg border border-line bg-surface-2 px-4 py-3 text-xs text-ink-secondary"},n_={key:1,class:"mb-4 rounded-lg border border-line bg-surface-2 px-4 py-3 text-xs text-ink-secondary"},i_={key:2,class:"mb-4 flex flex-col gap-2 border-b border-line pb-4 sm:flex-row sm:items-center sm:justify-between"},s_={key:5,class:"border-b border-line py-3 text-xs text-amber-fg"},o_={key:0},r_={key:6,class:"border-b border-line py-3 text-xs text-ink-muted"},a_={class:"font-semibold text-ink-secondary"},l_={key:7,class:"border-b border-line py-3 text-xs text-ink-muted"},u_={class:"flex w-full flex-col gap-2"},c_={class:"break-all font-mono text-sm text-ink"},d_={key:0,class:"inline-flex items-center gap-1 rounded-full bg-surface-2 px-2 py-0.5 text-[10px] font-semibold text-ink-secondary"},f_={key:1,class:"inline-flex items-center gap-1 rounded-full bg-surface-2 px-2 py-0.5 text-[10px] font-semibold text-ink-secondary"},h_={key:0,class:"text-xs text-ink-muted"},p_={key:1,class:"border-b border-line py-3 text-xs text-ink-muted"},m_={key:0,class:"inline-flex items-center gap-2 text-sm text-ink"},g_={key:0,class:"inline-flex items-center gap-1 rounded-full bg-surface-2 px-2 py-0.5 text-[10px] font-semibold text-ink-secondary"},__={class:"mt-4 flex flex-wrap items-center gap-3"},v_=["disabled"],y_=["disabled"],b_={key:2,class:"text-xs text-danger-fg"},x_={key:3,class:"text-[11px] text-ink-muted"},w_={key:4,class:"panel mb-5 p-5"},k_={class:"flex items-center gap-4"},S_=["src"],P_={key:1,class:"grid h-16 w-16 place-items-center rounded-full bg-[var(--navy-800)] text-lg font-bold text-white"},T_={class:"flex gap-2"},L_={class:"btn-ghost cursor-pointer"},M_={class:"mt-1 text-right text-[11px] text-ink-muted"},C_={key:5,class:"panel mb-5 p-5"},O_={class:"flex items-center gap-3"},E_={key:0,class:"mt-4 rounded-lg border border-line bg-surface-2 p-4"},z_={class:"flex flex-wrap items-center gap-4"},A_={class:"min-w-0"},I_={class:"mt-1 select-all font-mono text-sm font-bold text-ink"},N_={class:"mt-3 flex items-center gap-2"},B_={key:0,class:"mt-2 text-xs text-danger-fg"},D_={key:1,class:"mt-4 rounded-lg border border-line bg-surface-2 p-4"},R_={class:"mt-2 grid grid-cols-2 gap-1 font-mono text-xs text-ink-secondary sm:grid-cols-4"},F_={class:"rounded-lg border border-line bg-surface-2 p-3"},V_={class:"flex items-center gap-3"},Z_={class:"grid h-9 w-9 place-items-center rounded-full bg-accent-soft text-accent-soft-fg"},$_={class:"min-w-0 flex-1"},H_={class:"text-sm font-semibold text-ink"},U_={class:"font-mono text-[11px] text-ink-muted"},j_={key:6,class:"mb-5"},W_={key:0,class:"panel mb-5 p-5"},K_={class:"grid max-w-[520px] gap-2"},q_={class:"flex flex-wrap gap-2"},G_=["disabled","title"],Y_=["value"],J_=["value"],X_={class:"flex items-center gap-2 py-1 text-sm text-ink-secondary"},Q_={class:"flex items-center gap-3"},tv=["disabled"],ev={key:0,class:"text-xs text-danger-fg"},nv={key:1,class:"text-xs text-ink-muted"},iv={key:1,class:"panel mb-5 p-5"},sv={class:"grid max-w-[520px] gap-2"},ov={class:"flex flex-wrap gap-2"},rv=["value"],av=["value"],lv={key:1,class:"text-xs text-ink-muted"},uv={class:"font-semibold text-ink-secondary"},cv={class:"flex items-center gap-3"},dv=["disabled"],fv={key:0,class:"text-xs text-danger-fg"},hv={class:"panel overflow-hidden p-0"},pv={class:"flex items-center justify-between px-5 py-4"},mv=["disabled"],gv={key:0,class:"px-5 pb-5 text-sm text-danger-fg"},_v={key:1,class:"px-5 pb-8 text-sm text-ink-muted"},vv={key:2,class:"overflow-x-auto"},yv={class:"w-full border-collapse text-sm"},bv={class:"text-left"},xv={class:"px-5 py-3"},wv={class:"text-ink"},kv={key:0,class:"ml-1.5 text-[11px] text-ink-muted"},Sv={class:"px-5 py-3"},Pv={class:"px-5 py-3"},Tv={class:"px-5 py-3"},Lv={class:"px-5 py-3 text-right"},Mv=["onClick"],Cv={key:1,class:"inline-flex items-center gap-1.5"},Ov=["onClick"],Ev=["onClick"],zv={key:7,class:"mb-5"},Av={key:0,class:"panel mb-5 p-5"},Iv={class:"grid max-w-[520px] gap-2"},Nv={class:"flex items-center gap-3"},Bv={key:0,class:"text-xs text-danger-fg"},Dv={key:1,class:"panel mb-5 p-5"},Rv={class:"grid max-w-[520px] gap-2"},Fv={class:"flex items-center gap-3"},Vv=["disabled"],Zv={key:0,class:"text-xs text-danger-fg"},$v={class:"panel overflow-hidden p-0"},Hv={key:0,class:"px-5 pb-8 text-sm text-ink-muted"},Uv={key:1,class:"overflow-x-auto"},jv={class:"w-full border-collapse text-sm"},Wv={class:"text-left"},Kv={class:"px-5 py-3"},qv={class:"inline-flex items-center gap-2 text-ink"},Gv={class:"px-5 py-3 text-ink-secondary"},Yv={class:"px-5 py-3 text-right"},Jv=["onClick"],Xv={key:1,class:"inline-flex items-center gap-1.5"},Qv=["onClick"],ty=["disabled","title","onClick"],ey={key:8,class:"mb-5"},ny={class:"panel mb-5 p-5"},iy={class:"btn-ghost cursor-pointer"},sy={key:0,class:"mt-2 text-xs text-ink-muted"},oy={class:"rounded-lg border p-5",style:{"border-color":"color-mix(in srgb, var(--danger) 35%, transparent)",background:"var(--danger-soft)"}},ry={class:"flex items-center gap-2 text-danger-fg"},ay={class:"mt-4 rounded-lg border border-line bg-surface-1 p-4"},ly={class:"mt-3 flex items-start gap-2 text-sm text-ink-secondary"},uy={class:"mt-3"},cy={class:"eyebrow mb-1 block"},dy={class:"text-ink"},fy=["placeholder"],hy={class:"mt-4 flex flex-wrap items-center gap-3"},py=["disabled"],my=["disabled"],gy={key:2,class:"text-xs text-ink-muted"},_y={key:0,class:"mt-3 rounded border border-line bg-surface-2 px-3 py-2 text-xs text-ink-secondary"},vy={key:0,class:"fixed bottom-5 right-5 z-20 flex items-center gap-2 rounded-lg border border-line bg-surface-1 px-4 py-2.5 text-sm text-ink shadow-md"},Ql="pv.opensky.health",tu="pv.filetransfer.health",eu="pv.webdav.health",nu="pv.localstorage.health",yy={__name:"Settings",props:{email:{type:String,default:""},role:{type:String,default:"user"},organization:{type:String,default:""},organizationName:{type:String,default:""}},emits:["logout"],setup(e,{emit:i}){const o=e,a=i,l=ht(()=>o.role==="superadmin"),c=ht(()=>o.role==="admin"||o.role==="superadmin");function h(y){return y==="superadmin"?"Superadmin":y==="admin"?"Admin":"User"}function g(y){return y==="superadmin"||y==="admin"?"shield":"user"}function v(y){return y==="superadmin"||y==="admin"?P.accent:P.neutral}const P={success:"bg-success-soft text-success-fg",warning:"bg-amber-soft text-amber-fg",danger:"bg-danger-soft text-danger-fg",accent:"bg-accent-soft text-accent-soft-fg",neutral:"bg-surface-2 text-ink-secondary"},k=ht(()=>{const y=[{id:"account",label:"Account",icon:"user",kw:"name username email password verification login credentials role"},{id:"appearance",label:"Appearance",icon:"sliders",kw:"theme light dark system language region font size accessibility date time format motion"},{id:"integrations",label:"Integrations",icon:"radio",kw:"opensky flights adsb aircraft plugin oauth credentials bounding box plan connection ftp sftp ftps file transfer server host upload download local storage folder drive isolated private read only webdav nextcloud owncloud dav url https"},{id:"profile",label:"Profile",icon:"image",kw:"avatar photo display name bio public"},{id:"security",label:"Privacy & Security",icon:"shield",kw:"two factor authentication 2fa sessions devices logout security privacy"}];return c.value&&y.push({id:"team",label:"User management",icon:"users",kw:"users team members add remove create delete role admin permissions rights organization"}),l.value&&y.push({id:"organizations",label:"Organizations",icon:"grid",kw:"organization org tenant company create rename delete members"}),y.push({id:"advanced",label:"Advanced",icon:"alertTriangle",kw:"export import data delete account danger zone",danger:!0}),y}),O=J("account"),$=J("");Au("settingsSearch",$);const F=ht(()=>$.value.trim().length>0),nt=ht(()=>$.value.trim().toLowerCase());function q(y){return nt.value?(y.label+" "+y.kw).toLowerCase().includes(nt.value)||Dt(y.id):!0}const At={account:["full name","username","email address verification verify","password change current new"],appearance:["theme light dark system","language","region","font size accessibility","reduce motion","date format","time format clock"],integrations:["opensky live flights","enable plugin","oauth client id secret","plan credits","bounding box","test connection","file transfer ftp sftp ftps","server host port username password","private key passphrase","base path directory","local storage folder drive","private isolated folder","read only access mode","webdav nextcloud owncloud dav","server url username password tls","base path directory folder"],profile:["profile photo avatar","display name","bio about","show email public"],security:["two factor authentication","active sessions devices","sign out"],team:["add user create account","members list role admin remove delete","organization org assign"],organizations:["add organization create","rename organization","delete organization members"],advanced:["export data download","import data upload","delete account permanent danger"]};function Dt(y){return nt.value?(At[y]||[]).some(d=>d.includes(nt.value)):!0}const bt=ht(()=>F.value?k.value.filter(q):k.value.filter(y=>y.id===O.value)),ut=ht({get:()=>Gi.value,set:y=>ar(y)}),X=[{value:"light",label:"Light",icon:"sun"},{value:"dark",label:"Dark",icon:"moon"},{value:"system",label:"System",icon:"monitor"}],ft=[{value:"sm",label:"Small"},{value:"md",label:"Default"},{value:"lg",label:"Large"}],jt=[{value:"12",label:"12-hour"},{value:"24",label:"24-hour"}],de=[["en","English"],["es","Español"],["de","Deutsch"],["fr","Français"],["pl","Polski"],["ja","日本語"]],me=[["US","United States"],["GB","United Kingdom"],["EU","European Union"],["CA","Canada"],["AU","Australia"],["JP","Japan"]],vt=[["MDY","MM/DD/YYYY"],["DMY","DD/MM/YYYY"],["YMD","YYYY/MM/DD"],["ISO","YYYY-MM-DD"]],Ft=J(Date.now());let Tt=null;const G=ht(()=>Gl(Ft.value)),st=xe({loaded:!1,available:!1,orgEnabled:!0,allowAnonymous:!0,enabled:!1,canEditOrg:!1,isSuperadmin:!1,scopes:{}}),It=J("user"),Wt=xe({clientId:"",clientSecret:"",plan:"",bbox:""}),kt=J(""),Vt=J(!1),Y=J(!1),ue=J(null),rt=J(null),yt=ht(()=>ue.value&&ue.value.credits||null),Kt=ht(()=>{const y=yt.value;return!y||!y.daily||y.remaining==null?null:Math.max(0,Math.min(100,Math.round(y.remaining/y.daily*100)))}),ge=ht(()=>{const y=Kt.value;return y==null?"bg-accent":y<=10?"bg-danger":y<=30?"bg-amber":"bg-success"});function qt(y){return typeof y=="number"?y.toLocaleString():y}function Ut(){if(!rt.value)return"";const y=Math.max(0,Math.round((Date.now()-rt.value)/1e3));if(y<60)return"just now";const d=Math.round(y/60);if(d<60)return`${d} min ago`;const Z=Math.round(d/60);return Z<24?`${Z} h ago`:`${Math.round(Z/24)} d ago`}function Pt(){try{ue.value&&localStorage.setItem(Ql,JSON.stringify({health:ue.value,ts:rt.value}))}catch{}}function Pe(){try{const y=localStorage.getItem(Ql);if(!y)return;const d=JSON.parse(y);d&&d.health&&(ue.value=d.health,rt.value=d.ts||null)}catch{}}const Te=[{value:"",label:"Not set"},{value:"anonymous",label:"Anonymous"},{value:"standard",label:"Standard"},{value:"contributor",label:"Contributor"}],nn=[{value:"user",label:"My settings",icon:"user"},{value:"org",label:"Organization",icon:"users"}],we=ht(()=>st.isSuperadmin),gn=ht(()=>st.isSuperadmin?"user":It.value),_=ht(()=>st.scopes[gn.value]||{editableLayer:"user",fields:{}}),m=ht(()=>gn.value==="org");function T(y){return _.value.fields[y]||{effective:"",own:"",source:"unset",locked:!1}}function B(y){return we.value||T(y).locked}function I(y){const d=T(y).source;return d==="global"?"Set by administrator":d==="org"?"Set by your organization":""}function D(){Wt.clientId=T("clientId").own||"",Wt.clientSecret=T("clientSecret").own||"",Wt.plan=T("plan").own||"",Wt.bbox=T("bbox").own||""}function j(y){st.available=!!y.available,st.orgEnabled=y.orgEnabled!==!1,st.allowAnonymous=!!y.allowAnonymous,st.enabled=!!y.enabled,st.canEditOrg=!!y.canEditOrg,st.isSuperadmin=!!y.isSuperadmin,st.scopes=y.scopes||{},It.value==="org"&&!st.canEditOrg&&(It.value="user"),D(),st.loaded=!0}Je(It,()=>{kt.value="",D()});async function A(){Pe();const{ok:y,body:d}=await Gh();y&&j(d)}async function U(y){const d=m.value;d?st.orgEnabled=y:st.enabled=y;const{ok:Z,body:w}=await jl(d?{scope:"org",enabled:y}:{scope:"user",enabled:y});Z?(j(w),Zt(d?y?"OpenSky enabled for your organization.":"OpenSky disabled for your organization.":y?"OpenSky enabled.":"OpenSky disabled.")):(d?st.orgEnabled=!y:st.enabled=!y,Zt(w.error||"Could not update."))}async function R(){kt.value="",Vt.value=!0;const y={};for(const Ht of["clientId","clientSecret","plan","bbox"])B(Ht)||(y[Ht]=Wt[Ht]);const d={scope:gn.value,config:y};m.value||(d.enabled=st.enabled);const{ok:Z,body:w}=await jl(d);if(Vt.value=!1,!Z){kt.value=w.error||"Could not save settings.";return}j(w),Zt(m.value?"Organization OpenSky settings saved.":"OpenSky settings saved.")}async function ct(){Y.value=!0,ue.value=null;const{ok:y,body:d}=await Yh();Y.value=!1,ue.value=y&&d.health?d.health:{status:"down",detail:d.error||"Probe failed."},rt.value=Date.now(),Pt()}function Q(y){return y==="ok"?P.success:y==="degraded"?P.warning:P.danger}const K=xe({loaded:!1,available:!1,orgEnabled:!0,enabled:!1,canEditOrg:!1,isSuperadmin:!1,scopes:{}}),dt=J("user"),Mt=["protocol","host","port","username","password","privateKey","keyPassphrase","hostKeyFingerprint","insecureSkipVerify","basePath"],at=xe(Object.fromEntries(Mt.map(y=>[y,""]))),Rt=J(""),ne=J(!1),ae=J(!1),fe=J(null),ke=J(null),_n=[{value:"sftp",label:"SFTP"},{value:"ftps",label:"FTPS"},{value:"ftp",label:"FTP"}],ui=[{value:"false",label:"Verify certificate"},{value:"true",label:"Skip verification"}],ve=ht(()=>K.isSuperadmin),Ae=ht(()=>K.isSuperadmin?"user":dt.value),Cn=ht(()=>K.scopes[Ae.value]||{editableLayer:"user",fields:{}}),Fe=ht(()=>Ae.value==="org"),Yi=ht(()=>(Ve("protocol")?Oe("protocol").effective:at.protocol)||"sftp");function Oe(y){return Cn.value.fields[y]||{effective:"",own:"",source:"unset",locked:!1}}function Ve(y){return ve.value||Oe(y).locked}function he(y){const d=Oe(y).source;return d==="global"?"Set by administrator":d==="org"?"Set by your organization":""}function xr(y){return(_n.find(d=>d.value===y)||{}).label||y||"—"}function _o(){for(const y of Mt)at[y]=Oe(y).own||"";at.protocol||(at.protocol="sftp"),at.insecureSkipVerify||(at.insecureSkipVerify="false")}function Ms(y){K.available=!!y.available,K.orgEnabled=y.orgEnabled!==!1,K.enabled=!!y.enabled,K.canEditOrg=!!y.canEditOrg,K.isSuperadmin=!!y.isSuperadmin,K.scopes=y.scopes||{},dt.value==="org"&&!K.canEditOrg&&(dt.value="user"),_o(),K.loaded=!0}Je(dt,()=>{Rt.value="",_o()});function wr(){if(!ke.value)return"";const y=Math.max(0,Math.round((Date.now()-ke.value)/1e3));if(y<60)return"just now";const d=Math.round(y/60);if(d<60)return`${d} min ago`;const Z=Math.round(d/60);return Z<24?`${Z} h ago`:`${Math.round(Z/24)} d ago`}function kr(){try{fe.value&&localStorage.setItem(tu,JSON.stringify({health:fe.value,ts:ke.value}))}catch{}}function Sr(){try{const y=localStorage.getItem(tu);if(!y)return;const d=JSON.parse(y);d&&d.health&&(fe.value=d.health,ke.value=d.ts||null)}catch{}}async function Cs(){Sr();const{ok:y,body:d}=await Jh();y&&Ms(d)}async function vo(y){const d=Fe.value;d?K.orgEnabled=y:K.enabled=y;const{ok:Z,body:w}=await Wl(d?{scope:"org",enabled:y}:{scope:"user",enabled:y});Z?(Ms(w),Zt(d?y?"File transfer enabled for your organization.":"File transfer disabled for your organization.":y?"File transfer enabled.":"File transfer disabled.")):(d?K.orgEnabled=!y:K.enabled=!y,Zt(w.error||"Could not update."))}async function Pr(){Rt.value="",ne.value=!0;const y={};for(const Ht of Mt)Ve(Ht)||(y[Ht]=at[Ht]);const d={scope:Ae.value,config:y};Fe.value||(d.enabled=K.enabled);const{ok:Z,body:w}=await Wl(d);if(ne.value=!1,!Z){Rt.value=w.error||"Could not save settings.";return}Ms(w),Zt(Fe.value?"Organization file-transfer settings saved.":"File-transfer settings saved.")}async function Tr(){ae.value=!0,fe.value=null;const{ok:y,body:d}=await Xh();ae.value=!1,fe.value=y&&d.health?d.health:{status:"down",detail:d.error||"Probe failed."},ke.value=Date.now(),kr()}function Lr(y){return y==="ok"?P.success:y==="degraded"?P.warning:P.danger}const Et=xe({loaded:!1,available:!1,orgEnabled:!0,enabled:!1,canEditOrg:!1,isSuperadmin:!1,scopes:{}}),it=J("user"),Ji=["baseURL","username","password","insecureSkipVerify","basePath"],Ee=xe(Object.fromEntries(Ji.map(y=>[y,""]))),ci=J(""),Mi=J(!1),di=J(!1),sn=J(null),Xe=J(null),yo=[{value:"false",label:"Verify certificate"},{value:"true",label:"Skip verification"}],Os=ht(()=>Et.isSuperadmin),Es=ht(()=>Et.isSuperadmin?"user":it.value),Mr=ht(()=>Et.scopes[Es.value]||{editableLayer:"user",fields:{}}),on=ht(()=>Es.value==="org");function vn(y){return Mr.value.fields[y]||{effective:"",own:"",source:"unset",locked:!1}}function fi(y){return Os.value||vn(y).locked}function Ze(y){const d=vn(y).source;return d==="global"?"Set by administrator":d==="org"?"Set by your organization":""}function bo(){for(const y of Ji)Ee[y]=vn(y).own||"";Ee.insecureSkipVerify||(Ee.insecureSkipVerify="false")}function zs(y){Et.available=!!y.available,Et.orgEnabled=y.orgEnabled!==!1,Et.enabled=!!y.enabled,Et.canEditOrg=!!y.canEditOrg,Et.isSuperadmin=!!y.isSuperadmin,Et.scopes=y.scopes||{},it.value==="org"&&!Et.canEditOrg&&(it.value="user"),bo(),Et.loaded=!0}Je(it,()=>{ci.value="",bo()});function Cr(){if(!Xe.value)return"";const y=Math.max(0,Math.round((Date.now()-Xe.value)/1e3));if(y<60)return"just now";const d=Math.round(y/60);if(d<60)return`${d} min ago`;const Z=Math.round(d/60);return Z<24?`${Z} h ago`:`${Math.round(Z/24)} d ago`}function Or(){try{sn.value&&localStorage.setItem(eu,JSON.stringify({health:sn.value,ts:Xe.value}))}catch{}}function Er(){try{const y=localStorage.getItem(eu);if(!y)return;const d=JSON.parse(y);d&&d.health&&(sn.value=d.health,Xe.value=d.ts||null)}catch{}}async function As(){Er();const{ok:y,body:d}=await ep();y&&zs(d)}async function hi(y){const d=on.value;d?Et.orgEnabled=y:Et.enabled=y;const{ok:Z,body:w}=await Kl(d?{scope:"org",enabled:y}:{scope:"user",enabled:y});Z?(zs(w),Zt(d?y?"WebDAV enabled for your organization.":"WebDAV disabled for your organization.":y?"WebDAV enabled.":"WebDAV disabled.")):(d?Et.orgEnabled=!y:Et.enabled=!y,Zt(w.error||"Could not update."))}async function xo(){ci.value="",Mi.value=!0;const y={};for(const Ht of Ji)fi(Ht)||(y[Ht]=Ee[Ht]);const d={scope:Es.value,config:y};on.value||(d.enabled=Et.enabled);const{ok:Z,body:w}=await Kl(d);if(Mi.value=!1,!Z){ci.value=w.error||"Could not save settings.";return}zs(w),Zt(on.value?"Organization WebDAV settings saved.":"WebDAV settings saved.")}async function wo(){di.value=!0,sn.value=null;const{ok:y,body:d}=await np();di.value=!1,sn.value=y&&d.health?d.health:{status:"down",detail:d.error||"Probe failed."},Xe.value=Date.now(),Or()}function Ci(y){return y==="ok"?P.success:y==="degraded"?P.warning:P.danger}const W=xe({loaded:!1,available:!1,orgEnabled:!0,enabled:!1,canEditOrg:!1,isSuperadmin:!1,isOrgUser:!1,mounts:[],privateFolder:!1,privateEnabled:!1,allowPrivate:!0,rootConfigured:!1,scopes:{}}),Yt=J("user"),Un=J(""),rn=J(""),yn=J(!1),pi=J(!1),mt=J(null),te=J(null),jn=J({}),Oi=[{value:"",label:"Inherit"},{value:"false",label:"Read-write"},{value:"true",label:"Read-only"}],Ie=ht(()=>W.isSuperadmin),Is=ht(()=>W.isSuperadmin?"user":Yt.value),Xi=ht(()=>W.scopes[Is.value]||{editableLayer:"user",fields:{}}),ye=ht(()=>Is.value==="org");function le(y){return Xi.value.fields[y]||{effective:"",own:"",source:"unset",locked:!1}}function On(y){return Ie.value||le(y).locked}function mi(y){const d=le(y).source;return d==="global"?"Set by administrator":d==="org"?"Set by your organization":""}function Ei(y){return(Oi.find(d=>d.value===y)||{}).label||"Inherit"}function Qi(){Un.value=le("readOnly").own||""}function bn(y){W.available=!!y.available,W.orgEnabled=y.orgEnabled!==!1,W.enabled=!!y.enabled,W.canEditOrg=!!y.canEditOrg,W.isSuperadmin=!!y.isSuperadmin,W.isOrgUser=!!y.isOrgUser,W.mounts=Array.isArray(y.mounts)?y.mounts:[],W.privateFolder=!!y.privateFolder,W.privateEnabled=!!y.privateEnabled,W.allowPrivate=y.allowPrivate!==!1,W.rootConfigured=!!y.rootConfigured,W.scopes=y.scopes||{},Yt.value==="org"&&!W.canEditOrg&&(Yt.value="user"),Qi(),W.loaded=!0}Je(Yt,()=>{rn.value="",Qi()});function Ns(){if(!te.value)return"";const y=Math.max(0,Math.round((Date.now()-te.value)/1e3));if(y<60)return"just now";const d=Math.round(y/60);if(d<60)return`${d} min ago`;const Z=Math.round(d/60);return Z<24?`${Z} h ago`:`${Math.round(Z/24)} d ago`}function Bs(){try{mt.value&&localStorage.setItem(nu,JSON.stringify({health:mt.value,ts:te.value}))}catch{}}function ts(){try{const y=localStorage.getItem(nu);if(!y)return;const d=JSON.parse(y);d&&d.health&&(mt.value=d.health,te.value=d.ts||null)}catch{}}async function Ds(){ts();const{ok:y,body:d}=await Qh();y&&bn(d)}async function es(y){const d=ye.value;d?W.orgEnabled=y:W.enabled=y;const{ok:Z,body:w}=await Ko(d?{scope:"org",enabled:y}:{scope:"user",enabled:y});Z?(bn(w),Zt(d?y?"Local storage enabled for your organization.":"Local storage disabled for your organization.":y?"Local storage enabled.":"Local storage disabled.")):(d?W.orgEnabled=!y:W.enabled=!y,Zt(w.error||"Could not update."))}async function ns(y){W.privateFolder=y;const{ok:d,body:Z}=await Ko({scope:"user",privateFolder:y});d?(bn(Z),Zt(y?"Private folder enabled.":"Private folder disabled.")):(W.privateFolder=!y,Zt(Z.error||"Could not update."))}async function ko(y){W.allowPrivate=y;const{ok:d,body:Z}=await Ko({scope:"org",allowPrivate:y});d?(bn(Z),Zt(y?"Members may now create private folders.":"Private folders disabled for your organization.")):(W.allowPrivate=!y,Zt(Z.error||"Could not update."))}async function Rs(){rn.value="",yn.value=!0;const y={};On("readOnly")||(y.readOnly=Un.value);const d={scope:Is.value,config:y};ye.value||(d.enabled=W.enabled);const{ok:Z,body:w}=await Ko(d);if(yn.value=!1,!Z){rn.value=w.error||"Could not save settings.";return}bn(w),Zt(ye.value?"Organization local-storage settings saved.":"Local-storage settings saved.")}async function zr(){pi.value=!0,mt.value=null,jn.value={};const{ok:y,body:d}=await tp();pi.value=!1,mt.value=y&&d.health?d.health:{status:"down",detail:d.error||"Probe failed."};const Z={};if(Array.isArray(d.mounts))for(const w of d.mounts)Z[w.id]={status:w.status,detail:w.detail};jn.value=Z,te.value=Date.now(),Bs()}function St(y){return y==="ok"?P.success:y==="degraded"?P.warning:P.danger}const an=[{id:"apis-external",label:"APIs — External",icon:"globe"},{id:"drives-external",label:"Drives — External",icon:"server"},{id:"drives-local",label:"Drives — Local",icon:"monitor"}],Jt=J("apis-external");function is(y){return F.value||Jt.value===y}const gi=J("");let zi=null;function Zt(y){gi.value=y,clearTimeout(zi),zi=setTimeout(()=>gi.value="",2200)}const _e=xe({current:"",next:"",confirm:""}),En=J(""),_i=J(!1);function Se(){if(_i.value=!1,!_e.current)return En.value="Enter your current password.";if(_e.next.length<8)return En.value="New password must be at least 8 characters.";if(_e.next!==_e.confirm)return En.value="New passwords do not match.";En.value="Validated. Connecting to the account service is pending — no password endpoint yet.",_e.current=_e.next=_e.confirm=""}const xn=J("");function So(){xn.value="Verification link would be sent once the account service is wired up."}function Po(y){const d=y.target.files&&y.target.files[0];if(!d)return;if(d.size>1.5*1024*1024){Zt("Image too large (max ~1.5 MB).");return}const Z=new FileReader;Z.onload=()=>{gt.avatar=String(Z.result),Zt("Photo updated.")},Z.readAsDataURL(d)}function Ar(){gt.avatar="",Zt("Photo removed.")}const To=ht(()=>{var Z,w,Ht;const d=(gt.displayName||gt.name||o.email||"PV").split("@")[0].split(/[.\-_ ]+/).filter(Boolean);return((((Z=d[0])==null?void 0:Z[0])||"P")+(((w=d[1])==null?void 0:w[0])||((Ht=d[0])==null?void 0:Ht[1])||"V")).toUpperCase()}),Wn=J(!1),Lo=J(""),Ai=J(""),zt=J(""),ss=J([]);function $e(y){const d="ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";let Z="";for(let w=0;w$e(4).toLowerCase()+"-"+$e(4).toLowerCase()),zt.value=""}function Ir(){gt.twoFactor=!1,ss.value=[],Wn.value=!1}const Ke=navigator.userAgent;function Nr(){return/Edg\//.test(Ke)?"Edge":/OPR\//.test(Ke)?"Opera":/Chrome\//.test(Ke)?"Chrome":/Firefox\//.test(Ke)?"Firefox":/Safari\//.test(Ke)?"Safari":"Browser"}function Co(){return/Windows/.test(Ke)?"Windows":/Mac OS X/.test(Ke)?"macOS":/Android/.test(Ke)?"Android":/iPhone|iPad/.test(Ke)?"iOS":/Linux/.test(Ke)?"Linux":"Unknown OS"}const Br=Date.now(),os=J([]),Kn=J(!1),rs=J(""),ee=xe({email:"",password:"",role:"user",organization:""}),vi=J(""),Ni=J(!1),qe=J(""),Fs=ht(()=>{const y=[{value:"user",label:"User"},{value:"admin",label:"Admin"}];return l.value&&y.push({value:"superadmin",label:"Superadmin"}),y}),Bi=J([]);async function qn(){if(!c.value)return;const y=await Hh();y.ok&&(Bi.value=y.organizations.slice().sort((d,Z)=>d.name.localeCompare(Z.name)))}const Oo=ht(()=>{const y=Bi.value.map(d=>({value:d.id,label:d.name}));return l.value&&y.unshift({value:"",label:"No organization"}),y});async function Gn(){if(!c.value)return;Kn.value=!0,rs.value="";const y=await Fh();if(Kn.value=!1,!y.ok){rs.value=y.status===403?"Manager role required.":"Could not load users.";return}os.value=y.users.slice().sort((d,Z)=>d.email.localeCompare(Z.email))}function Di(y){try{const d=y.data||{},Z=Object.keys(d)[0];return Z&&d[Z]&&d[Z].message||y.message||y.error||"Invalid input."}catch{return y.error||"Could not create user."}}async function Dr(){vi.value="";const y=ee.email.trim().toLowerCase();if(!y.includes("@"))return vi.value="Enter a valid email.";if(ee.password.length<8)return vi.value="Password must be at least 8 characters.";Ni.value=!0;const d=l.value?ee.organization:o.organization,{ok:Z,body:w}=await Vh(y,ee.password,ee.role,d);if(Ni.value=!1,!Z)return vi.value=Di(w);ee.email="",ee.password="",ee.role="user",ee.organization="",Zt("User created."),Gn()}async function Rr(y){const{ok:d,body:Z}=await $h(y.id);if(qe.value="",!d)return Zt(Z.error||"Could not remove user.");Zt("User removed."),Gn()}const $t=xe({id:"",email:"",role:"user",verified:!1,password:"",organization:""}),zn=J(""),Ri=J(!1),as=ht(()=>!!$t.id&&$t.email===o.email);function ls(y){qe.value="",$t.id=y.id,$t.email=y.email,$t.role=y.role||"user",$t.verified=!!y.verified,$t.password="",$t.organization=y.organization||"",zn.value=""}function An(){$t.id="",zn.value=""}async function Fr(){zn.value="";const y=$t.email.trim().toLowerCase();if(!y.includes("@"))return zn.value="Enter a valid email.";if($t.password&&$t.password.length<8)return zn.value="New password must be at least 8 characters (or leave blank).";const d={email:y,role:$t.role,verified:$t.verified};l.value&&(d.organization=$t.organization),$t.password&&(d.password=$t.password),Ri.value=!0;const{ok:Z,body:w}=await Zh($t.id,d);if(Ri.value=!1,!Z)return zn.value=Di(w);Zt("User updated."),An(),Gn()}const In=xe({name:""}),Le=J(""),Fi=J(!1),yi=J(""),wn=xe({id:"",name:""}),kn=J(""),Vi=ht(()=>{const y={};for(const d of os.value)d.organization&&(y[d.organization]=(y[d.organization]||0)+1);return y});async function Eo(){Le.value="";const y=In.name.trim();if(!y)return Le.value="Enter an organization name.";Fi.value=!0;const{ok:d,body:Z}=await Uh(y);if(Fi.value=!1,!d)return Le.value=Di(Z);In.name="",Zt("Organization created."),qn()}function Vr(y){yi.value="",wn.id=y.id,wn.name=y.name,kn.value=""}function Vs(){wn.id="",kn.value=""}async function zo(){kn.value="";const y=wn.name.trim();if(!y)return kn.value="Enter an organization name.";const{ok:d,body:Z}=await jh(wn.id,y);if(!d)return kn.value=Di(Z);Zt("Organization renamed."),Vs(),qn(),Gn()}async function Qe(y){const{ok:d,body:Z}=await Wh(y.id);if(yi.value="",!d)return Zt(Z.error||"Could not delete organization.");Zt("Organization deleted."),qn()}function bi(){const y={_app:"PilotVault",_kind:"settings-export",exportedAt:new Date().toISOString(),email:o.email,prefs:{...gt},themeMode:Gi.value},d=new Blob([JSON.stringify(y,null,2)],{type:"application/json"}),Z=URL.createObjectURL(d),w=document.createElement("a");w.href=Z,w.download=`pilotvault-settings-${new Date().toISOString().slice(0,10)}.json`,document.body.appendChild(w),w.click(),w.remove(),URL.revokeObjectURL(Z),Zt("Settings exported.")}const us=J("");function Sn(y){const d=y.target.files&&y.target.files[0];if(!d)return;const Z=new FileReader;Z.onload=()=>{try{const w=JSON.parse(String(Z.result)),Ht=w.prefs||w;if(!Tc(Ht))throw new Error("bad shape");w.themeMode&&ar(w.themeMode),za(gt.fontSize),Aa(gt.reduceMotion),us.value="Settings imported and applied."}catch{us.value="That file is not a valid PilotVault settings export."}},Z.readAsText(d),y.target.value=""}const se=xe({understand:!1,typed:"",cooldown:0,armed:!1,msg:""});let ln=null;const Zs=ht(()=>o.email||"DELETE MY ACCOUNT"),Yn=ht(()=>se.understand&&se.typed===Zs.value);function Ao(){Yn.value&&(se.armed=!0,se.cooldown=5,clearInterval(ln),ln=setInterval(()=>{se.cooldown--,se.cooldown<=0&&clearInterval(ln)},1e3))}Je(Yn,y=>{!y&&se.armed&&(se.armed=!1,se.cooldown=0,clearInterval(ln))});function cs(){if(!(!se.armed||se.cooldown>0)){try{localStorage.removeItem("pv_prefs")}catch{}se.msg="Account deletion requires the account service. Local data was cleared and you were signed out.",setTimeout(()=>a("logout"),900)}}return Ls(()=>{Tt=setInterval(()=>Ft.value=Date.now(),1e3),qn(),Gn(),A(),Cs(),As(),Ds()}),_r(()=>{clearInterval(Tt),clearInterval(ln),clearTimeout(zi)}),(y,d)=>(b(),x("div",Rp,[f("div",Fp,[d[62]||(d[62]=f("div",null,[f("div",{class:"eyebrow"},"Preferences"),f("h2",{class:"mt-0.5 text-[22px] font-bold tracking-tightest text-ink"},"Settings")],-1)),f("div",Vp,[E(tt,{name:"search",size:16,class:"text-ink-muted"}),xt(f("input",{"onUpdate:modelValue":d[0]||(d[0]=Z=>$.value=Z),placeholder:"Search settings…",class:"w-full border-none bg-transparent text-sm text-ink outline-none placeholder:text-ink-muted"},null,512),[[Bt,$.value]]),$.value?(b(),x("button",{key:0,class:"text-ink-muted hover:text-ink","aria-label":"Clear search",onClick:d[1]||(d[1]=Z=>$.value="")},[E(tt,{name:"x",size:15})])):V("",!0)])]),f("div",Zp,[xt(f("nav",$p,[(b(!0),x(wt,null,ce(k.value,Z=>(b(),x("button",{key:Z.id,class:Ot(["flex items-center gap-2.5 rounded px-3 py-2.5 text-left text-sm transition",[O.value===Z.id?Z.danger?"bg-danger-soft font-semibold text-danger-fg":"bg-accent-soft font-semibold text-accent-soft-fg":Z.danger?"font-medium text-danger-fg hover:bg-danger-soft":"font-medium text-ink-secondary hover:bg-surface-2"]]),onClick:w=>O.value=Z.id},[E(tt,{name:Z.icon,size:17},null,8,["name"]),f("span",Up,M(Z.label),1)],10,Hp))),128))],512),[[oh,!F.value]]),f("div",jp,[F.value&&!bt.value.length?(b(),x("div",Wp," No settings match “"+M($.value)+"”. ",1)):V("",!0),(b(!0),x(wt,null,ce(bt.value,Z=>(b(),x(wt,{key:Z.id},[F.value?(b(),x("div",Kp,[E(tt,{name:Z.icon,size:14},null,8,["name"]),N(" "+M(Z.label),1)])):V("",!0),Z.id==="account"?(b(),x("div",qp,[E(lt,{title:"Full name",desc:"Shown to your team on flights and audit logs.",keywords:"full name account"},{default:ot(()=>[xt(f("input",{"onUpdate:modelValue":d[2]||(d[2]=w=>Ct(gt).name=w),class:"field w-56",placeholder:"Jane Operator",onBlur:d[3]||(d[3]=w=>Zt("Saved."))},null,544),[[Bt,Ct(gt).name]])]),_:1}),E(lt,{title:"Username",desc:"Your unique handle within PilotVault.",keywords:"username handle"},{default:ot(()=>[f("div",Gp,[d[63]||(d[63]=f("span",{class:"text-sm text-ink-muted"},"@",-1)),xt(f("input",{"onUpdate:modelValue":d[4]||(d[4]=w=>Ct(gt).username=w),class:"field w-48",placeholder:"jane",onBlur:d[5]||(d[5]=w=>Zt("Saved."))},null,544),[[Bt,Ct(gt).username]])])]),_:1}),E(lt,{title:"Email address",desc:"Used for sign-in and notifications.",keywords:"email verification verify"},{default:ot(()=>[f("div",Yp,[f("span",Jp,M(e.email||"—"),1),f("span",Xp,[E(tt,{name:"mail",size:12}),d[64]||(d[64]=N(" Unverified ",-1))])])]),_:1}),E(lt,{title:"Role",desc:"Your access level in PilotVault.",keywords:"role admin user superadmin access rights permissions"},{default:ot(()=>[f("span",{class:Ot(["inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-semibold",v(e.role)])},[E(tt,{name:g(e.role),size:12},null,8,["name"]),N(M(h(e.role)),1)],2)]),_:1}),E(lt,{title:"Organization",desc:"The organization your account belongs to.",keywords:"organization org tenant company"},{default:ot(()=>[f("span",{class:Ot(["text-sm",e.organizationName?"text-ink":"text-ink-muted"])},M(e.organizationName||(l.value?"All organizations":"None")),3)]),_:1}),E(lt,{block:"",title:"Verify email",desc:"Confirm ownership to enable password resets and alerts.",keywords:"verify email resend"},{default:ot(()=>[f("button",{class:"btn-ghost",onClick:So},"Send verification link"),xn.value?(b(),x("p",Qp,M(xn.value),1)):V("",!0)]),_:1}),E(lt,{block:"",title:"Change password",desc:"Use at least 8 characters.",keywords:"password change current new"},{default:ot(()=>[f("div",tm,[xt(f("input",{"onUpdate:modelValue":d[6]||(d[6]=w=>_e.current=w),type:"password",class:"field",placeholder:"Current password"},null,512),[[Bt,_e.current]]),xt(f("input",{"onUpdate:modelValue":d[7]||(d[7]=w=>_e.next=w),type:"password",class:"field",placeholder:"New password"},null,512),[[Bt,_e.next]]),xt(f("input",{"onUpdate:modelValue":d[8]||(d[8]=w=>_e.confirm=w),type:"password",class:"field",placeholder:"Confirm new password"},null,512),[[Bt,_e.confirm]]),f("div",em,[f("button",{class:"btn-accent",onClick:Se},"Update password"),En.value?(b(),x("span",{key:0,class:Ot(["text-xs",_i.value?"text-success-fg":"text-ink-muted"])},M(En.value),3)):V("",!0)])])]),_:1})])):Z.id==="appearance"?(b(),x("div",nm,[E(lt,{title:"Theme",desc:"Light, dark, or follow your system.",keywords:"theme light dark system appearance"},{default:ot(()=>[E(dn,{modelValue:ut.value,"onUpdate:modelValue":d[9]||(d[9]=w=>ut.value=w),options:X},null,8,["modelValue"])]),_:1}),E(lt,{title:"Font size",desc:"Scales the entire interface for readability.",keywords:"font size accessibility text"},{default:ot(()=>[E(dn,{modelValue:Ct(gt).fontSize,"onUpdate:modelValue":d[10]||(d[10]=w=>Ct(gt).fontSize=w),options:ft},null,8,["modelValue"])]),_:1}),E(lt,{title:"Reduce motion",desc:"Minimise animations and transitions.",keywords:"reduce motion accessibility animation"},{default:ot(()=>[E(tn,{modelValue:Ct(gt).reduceMotion,"onUpdate:modelValue":d[11]||(d[11]=w=>Ct(gt).reduceMotion=w)},null,8,["modelValue"])]),_:1}),E(lt,{title:"Language",desc:"Interface language.",keywords:"language locale"},{default:ot(()=>[xt(f("select",{"onUpdate:modelValue":d[12]||(d[12]=w=>Ct(gt).language=w),class:"field w-48"},[(b(),x(wt,null,ce(de,([w,Ht])=>f("option",{key:w,value:w},M(Ht),9,im)),64))],512),[[wi,Ct(gt).language]])]),_:1}),E(lt,{title:"Region",desc:"Affects number, unit and date defaults.",keywords:"region country locale"},{default:ot(()=>[xt(f("select",{"onUpdate:modelValue":d[13]||(d[13]=w=>Ct(gt).region=w),class:"field w-48"},[(b(),x(wt,null,ce(me,([w,Ht])=>f("option",{key:w,value:w},M(Ht),9,sm)),64))],512),[[wi,Ct(gt).region]])]),_:1}),E(lt,{title:"Date format",desc:"How calendar dates are displayed.",keywords:"date format"},{default:ot(()=>[xt(f("select",{"onUpdate:modelValue":d[14]||(d[14]=w=>Ct(gt).dateFormat=w),class:"field w-48"},[(b(),x(wt,null,ce(vt,([w,Ht])=>f("option",{key:w,value:w},M(Ht),9,om)),64))],512),[[wi,Ct(gt).dateFormat]])]),_:1}),E(lt,{title:"Time format",desc:"12- or 24-hour clock.",keywords:"time format clock 12 24 hour"},{default:ot(()=>[E(dn,{modelValue:Ct(gt).timeFormat,"onUpdate:modelValue":d[15]||(d[15]=w=>Ct(gt).timeFormat=w),options:jt},null,8,["modelValue"])]),_:1}),E(lt,{title:"Preview",desc:"How timestamps appear across the app.",keywords:"preview date time"},{default:ot(()=>[f("span",rm,M(G.value),1)]),_:1}),d[65]||(d[65]=f("p",{class:"mt-3 text-xs text-ink-muted"}," Language & region are stored now; full localisation ships with the account service. ",-1))])):Z.id==="integrations"?(b(),x("div",am,[F.value?V("",!0):(b(),x("div",lm,[(b(),x(wt,null,ce(an,w=>f("button",{key:w.id,type:"button",class:Ot(["-mb-px inline-flex items-center gap-1.5 whitespace-nowrap border-b-2 px-3 py-2 text-sm font-semibold transition",Jt.value===w.id?"border-accent text-ink":"border-transparent text-ink-secondary hover:text-ink"]),onClick:Ht=>Jt.value=w.id},[E(tt,{name:w.icon,size:16},null,8,["name"]),N(M(w.label),1)],10,um)),64))])),is("apis-external")?(b(),x("div",cm,[f("div",dm,[f("div",fm,[E(tt,{name:"radio",size:20})]),d[66]||(d[66]=f("div",{class:"min-w-0"},[f("div",{class:"text-sm font-semibold text-ink"},"OpenSky Network"),f("div",{class:"mt-0.5 text-xs text-ink-muted"}," Live ADS-B aircraft data. Configure your own OAuth2 credentials, plan and default bounding box. ")],-1))]),st.loaded&&!st.available?(b(),x("div",hm,[E(tt,{name:"lock",size:14,class:"mr-1 inline"}),d[67]||(d[67]=N(" OpenSky is currently disabled by your administrator. Contact them to enable it. ",-1))])):V("",!0),st.canEditOrg?(b(),x("div",pm,[d[68]||(d[68]=f("div",{class:"text-xs text-ink-muted"},"Manage your own settings, or organization-wide settings that apply to every user.",-1)),E(dn,{modelValue:It.value,"onUpdate:modelValue":d[16]||(d[16]=w=>It.value=w),options:nn},null,8,["modelValue"])])):V("",!0),m.value?(b(),oe(lt,{key:2,title:"Enable OpenSky (organization-wide)",desc:"Turn OpenSky on or off for everyone in your organization.",keywords:"enable disable plugin opensky organization"},{default:ot(()=>[E(tn,{"model-value":st.orgEnabled,disabled:!st.available,"onUpdate:modelValue":U},null,8,["model-value","disabled"])]),_:1})):(b(),oe(lt,{key:3,title:"Enable OpenSky",desc:"Turn the plugin on for your account in the Web App.",keywords:"enable disable plugin opensky"},{default:ot(()=>[E(tn,{"model-value":st.enabled,disabled:!st.available||!st.orgEnabled,"onUpdate:modelValue":U},null,8,["model-value","disabled"])]),_:1})),!m.value&&st.available&&!st.orgEnabled?(b(),x("div",mm,[E(tt,{name:"lock",size:13,class:"mr-1 inline"}),d[70]||(d[70]=N("OpenSky is turned off for your organization",-1)),st.canEditOrg?(b(),x("span",gm,[...d[69]||(d[69]=[N(" — switch to ",-1),f("span",{class:"font-semibold"},"Organization",-1),N(" to turn it back on",-1)])])):V("",!0),d[71]||(d[71]=N(". ",-1))])):V("",!0),m.value?(b(),x("div",_m,[E(tt,{name:"users",size:13,class:"mr-1 inline"}),d[72]||(d[72]=N("These are organization-wide settings — they apply to everyone in ",-1)),f("span",vm,M(e.organizationName||"your organization"),1),d[73]||(d[73]=N(". Leave a field blank to let each user choose their own; a value set here overrides the user's. ",-1))])):we.value?(b(),x("div",ym," As a superadmin you manage the global OpenSky configuration in the API Server panel. The effective configuration is shown below. ")):V("",!0),st.available&&!m.value?(b(),x("div",bm,[f("div",xm,[f("div",wm,[E(tt,{name:"signal",size:15}),d[74]||(d[74]=N("Credit usage ",-1))]),rt.value?(b(),x("span",km,"Checked "+M(Ut()),1)):V("",!0)]),yt.value?(b(),x(wt,{key:0},[yt.value.remaining!=null?(b(),x(wt,{key:0},[f("div",Sm,[f("span",Pm,M(qt(yt.value.remaining)),1),f("span",Tm,"/ "+M(qt(yt.value.daily))+" credits left today",1)]),f("div",Lm,[f("div",{class:Ot(["h-full rounded-full transition-all",ge.value]),style:ks({width:Kt.value+"%"})},null,6)]),f("div",Mm," Used "+M(qt(yt.value.daily-yt.value.remaining))+" today · "+M(yt.value.probeCost)+" credit"+M(yt.value.probeCost===1?"":"s")+" per query · "+M(yt.value.mode),1)],64)):(b(),x(wt,{key:1},[f("div",Cm,[d[75]||(d[75]=N("Daily allowance: ",-1)),f("span",Om,M(qt(yt.value.daily)),1),d[76]||(d[76]=N(" credits",-1))]),f("div",Em,M(yt.value.probeCost)+" credit"+M(yt.value.probeCost===1?"":"s")+" per query · "+M(yt.value.mode)+". OpenSky only reports live remaining credits for authenticated requests — add OAuth2 credentials below to track usage. ",1)],64))],64)):(b(),x("div",zm,[...d[77]||(d[77]=[N(" Run ",-1),f("span",{class:"font-semibold text-ink-secondary"},"Test connection",-1),N(" below to fetch your live OpenSky credit balance. ",-1)])]))])):V("",!0),E(lt,{title:"OpenSky plan",desc:"Your account tier — sets the daily credit allowance.",keywords:"plan tier credits"},{default:ot(()=>[B("plan")?(b(),x("span",Am,[N(M((Te.find(w=>w.value===T("plan").effective)||{}).label||T("plan").effective||"—")+" ",1),I("plan")?(b(),x("span",Im,[E(tt,{name:"lock",size:10}),N(M(I("plan")),1)])):V("",!0)])):(b(),oe(dn,{key:1,modelValue:Wt.plan,"onUpdate:modelValue":d[17]||(d[17]=w=>Wt.plan=w),options:Te},null,8,["modelValue"]))]),_:1}),E(lt,{title:"Default bounding box",desc:"lamin,lomin,lamax,lomax — used for live queries and the health probe.",keywords:"bounding box bbox area"},{default:ot(()=>[B("bbox")?(b(),x("span",Nm,[N(M(T("bbox").effective||"—")+" ",1),I("bbox")?(b(),x("span",Bm,[E(tt,{name:"lock",size:10}),N(M(I("bbox")),1)])):V("",!0)])):xt((b(),x("input",{key:1,"onUpdate:modelValue":d[18]||(d[18]=w=>Wt.bbox=w),class:"field w-64 font-mono",placeholder:"50.5,3.2,53.7,7.3"},null,512)),[[Bt,Wt.bbox]])]),_:1}),E(lt,{title:"OAuth2 client ID",desc:"Optional — leave blank for anonymous access (lower limits).",keywords:"oauth client id credentials"},{default:ot(()=>[B("clientId")?(b(),x("span",Dm,[N(M(T("clientId").effective||"—")+" ",1),I("clientId")?(b(),x("span",Rm,[E(tt,{name:"lock",size:10}),N(M(I("clientId")),1)])):V("",!0)])):xt((b(),x("input",{key:1,"onUpdate:modelValue":d[19]||(d[19]=w=>Wt.clientId=w),class:"field w-64",placeholder:"your-api-client"},null,512)),[[Bt,Wt.clientId]])]),_:1}),E(lt,{title:"OAuth2 client secret",desc:"Paired with the client ID for authenticated access.",keywords:"oauth client secret credentials password"},{default:ot(()=>[B("clientSecret")?(b(),x("span",Fm,[N(M(T("clientSecret").effective||"—")+" ",1),I("clientSecret")?(b(),x("span",Vm,[E(tt,{name:"lock",size:10}),N(M(I("clientSecret")),1)])):V("",!0)])):xt((b(),x("input",{key:1,"onUpdate:modelValue":d[20]||(d[20]=w=>Wt.clientSecret=w),type:"password",class:"field w-64",placeholder:"••••••••"},null,512)),[[Bt,Wt.clientSecret]])]),_:1}),st.available&&!st.allowAnonymous?(b(),x("div",Zm," Anonymous access is disabled by the administrator — OpenSky needs OAuth2 credentials from some layer to work. ")):V("",!0),f("div",$m,[we.value?V("",!0):(b(),x("button",{key:0,class:"btn-accent",disabled:Vt.value||!st.available,onClick:R},M(Vt.value?"Saving…":m.value?"Save organization settings":"Save settings"),9,Hm)),m.value?V("",!0):(b(),x("button",{key:1,class:"btn-ghost",disabled:Y.value||!st.available,onClick:ct},M(Y.value?"Testing…":"Test connection"),9,Um)),kt.value?(b(),x("span",jm,M(kt.value),1)):V("",!0),ue.value&&!m.value?(b(),x("span",{key:3,class:Ot(["inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-semibold",Q(ue.value.status)])},[d[78]||(d[78]=f("span",{class:"h-1.5 w-1.5 rounded-full bg-current"},null,-1)),N(M(ue.value.detail||ue.value.status),1)],2)):V("",!0)])])):V("",!0),is("drives-external")?(b(),x(wt,{key:2},[f("div",Wm,[f("div",Km,[f("div",qm,[E(tt,{name:"server",size:20})]),d[79]||(d[79]=f("div",{class:"min-w-0"},[f("div",{class:"text-sm font-semibold text-ink"},"File Transfer (FTP / SFTP)"),f("div",{class:"mt-0.5 text-xs text-ink-muted"}," Connect to an FTP, FTPS or SFTP server. Configure the connection your account uses for file transfers. ")],-1))]),K.loaded&&!K.available?(b(),x("div",Gm,[E(tt,{name:"lock",size:14,class:"mr-1 inline"}),d[80]||(d[80]=N(" File transfer is currently disabled by your administrator. Contact them to enable it. ",-1))])):V("",!0),K.canEditOrg?(b(),x("div",Ym,[d[81]||(d[81]=f("div",{class:"text-xs text-ink-muted"},"Manage your own settings, or organization-wide settings that apply to every user.",-1)),E(dn,{modelValue:dt.value,"onUpdate:modelValue":d[21]||(d[21]=w=>dt.value=w),options:nn},null,8,["modelValue"])])):V("",!0),Fe.value?(b(),oe(lt,{key:2,title:"Enable file transfer (organization-wide)",desc:"Turn it on or off for everyone in your organization.",keywords:"enable disable plugin ftp sftp organization"},{default:ot(()=>[E(tn,{"model-value":K.orgEnabled,disabled:!K.available,"onUpdate:modelValue":vo},null,8,["model-value","disabled"])]),_:1})):(b(),oe(lt,{key:3,title:"Enable file transfer",desc:"Turn the plugin on for your account in the Web App.",keywords:"enable disable plugin ftp sftp"},{default:ot(()=>[E(tn,{"model-value":K.enabled,disabled:!K.available||!K.orgEnabled,"onUpdate:modelValue":vo},null,8,["model-value","disabled"])]),_:1})),!Fe.value&&K.available&&!K.orgEnabled?(b(),x("div",Jm,[E(tt,{name:"lock",size:13,class:"mr-1 inline"}),d[83]||(d[83]=N("File transfer is turned off for your organization",-1)),K.canEditOrg?(b(),x("span",Xm,[...d[82]||(d[82]=[N(" — switch to ",-1),f("span",{class:"font-semibold"},"Organization",-1),N(" to turn it back on",-1)])])):V("",!0),d[84]||(d[84]=N(". ",-1))])):V("",!0),Fe.value?(b(),x("div",Qm,[E(tt,{name:"users",size:13,class:"mr-1 inline"}),d[85]||(d[85]=N("These are organization-wide settings — they apply to everyone in ",-1)),f("span",tg,M(e.organizationName||"your organization"),1),d[86]||(d[86]=N(". Leave the connection blank to let each user configure their own; a connection set here overrides the user's. ",-1))])):ve.value?(b(),x("div",eg," As a superadmin you manage the global file-transfer configuration in the API Server panel. The effective configuration is shown below. ")):V("",!0),E(lt,{title:"Protocol",desc:"SFTP (over SSH), FTPS (FTP over TLS), or plain FTP.",keywords:"protocol sftp ftps ftp"},{default:ot(()=>[Ve("protocol")?(b(),x("span",ng,[N(M(xr(Oe("protocol").effective))+" ",1),he("protocol")?(b(),x("span",ig,[E(tt,{name:"lock",size:10}),N(M(he("protocol")),1)])):V("",!0)])):(b(),oe(dn,{key:1,modelValue:at.protocol,"onUpdate:modelValue":d[22]||(d[22]=w=>at.protocol=w),options:_n},null,8,["modelValue"]))]),_:1}),E(lt,{title:"Host",desc:"Server hostname or IP address.",keywords:"host server address"},{default:ot(()=>[Ve("host")?(b(),x("span",sg,[N(M(Oe("host").effective||"—")+" ",1),he("host")?(b(),x("span",og,[E(tt,{name:"lock",size:10}),N(M(he("host")),1)])):V("",!0)])):xt((b(),x("input",{key:1,"onUpdate:modelValue":d[23]||(d[23]=w=>at.host=w),class:"field w-64",placeholder:"files.example.com"},null,512)),[[Bt,at.host]])]),_:1}),E(lt,{title:"Port",desc:"Leave blank for the protocol default (22 for SFTP, 21 for FTP/FTPS).",keywords:"port"},{default:ot(()=>[Ve("port")?(b(),x("span",rg,[N(M(Oe("port").effective||"default")+" ",1),he("port")?(b(),x("span",ag,[E(tt,{name:"lock",size:10}),N(M(he("port")),1)])):V("",!0)])):xt((b(),x("input",{key:1,"onUpdate:modelValue":d[24]||(d[24]=w=>at.port=w),inputmode:"numeric",class:"field w-24 font-mono",placeholder:"22"},null,512)),[[Bt,at.port]])]),_:1}),E(lt,{title:"Username",desc:"Account used to authenticate.",keywords:"username login account"},{default:ot(()=>[Ve("username")?(b(),x("span",lg,[N(M(Oe("username").effective||"—")+" ",1),he("username")?(b(),x("span",ug,[E(tt,{name:"lock",size:10}),N(M(he("username")),1)])):V("",!0)])):xt((b(),x("input",{key:1,"onUpdate:modelValue":d[25]||(d[25]=w=>at.username=w),class:"field w-64",placeholder:"user"},null,512)),[[Bt,at.username]])]),_:1}),E(lt,{title:"Password",desc:"Password auth for FTP/FTPS, or SFTP password login. Leave blank to use a key.",keywords:"password secret credentials"},{default:ot(()=>[Ve("password")?(b(),x("span",cg,[N(M(Oe("password").effective||"—")+" ",1),he("password")?(b(),x("span",dg,[E(tt,{name:"lock",size:10}),N(M(he("password")),1)])):V("",!0)])):xt((b(),x("input",{key:1,"onUpdate:modelValue":d[26]||(d[26]=w=>at.password=w),type:"password",class:"field w-64",placeholder:"••••••••"},null,512)),[[Bt,at.password]])]),_:1}),Yi.value==="sftp"?(b(),oe(lt,{key:7,block:"",title:"SSH private key",desc:"PEM key for SFTP key auth — used instead of, or alongside, a password.",keywords:"private key ssh pem identity"},{default:ot(()=>[Ve("privateKey")?(b(),x("span",fg,[N(M(Oe("privateKey").effective||"—")+" ",1),he("privateKey")?(b(),x("span",hg,[E(tt,{name:"lock",size:10}),N(M(he("privateKey")),1)])):V("",!0)])):xt((b(),x("textarea",{key:1,"onUpdate:modelValue":d[27]||(d[27]=w=>at.privateKey=w),rows:"3",class:"field w-full font-mono text-xs",placeholder:"-----BEGIN OPENSSH PRIVATE KEY-----"},null,512)),[[Bt,at.privateKey]])]),_:1})):V("",!0),Yi.value==="sftp"?(b(),oe(lt,{key:8,title:"Private key passphrase",desc:"Passphrase protecting the SSH private key, if any.",keywords:"passphrase key secret"},{default:ot(()=>[Ve("keyPassphrase")?(b(),x("span",pg,[N(M(Oe("keyPassphrase").effective||"—")+" ",1),he("keyPassphrase")?(b(),x("span",mg,[E(tt,{name:"lock",size:10}),N(M(he("keyPassphrase")),1)])):V("",!0)])):xt((b(),x("input",{key:1,"onUpdate:modelValue":d[28]||(d[28]=w=>at.keyPassphrase=w),type:"password",class:"field w-64",placeholder:"••••••••"},null,512)),[[Bt,at.keyPassphrase]])]),_:1})):V("",!0),Yi.value==="sftp"?(b(),oe(lt,{key:9,title:"Host key fingerprint",desc:"Optional SHA256:… fingerprint to pin the server's host key. Blank accepts any key.",keywords:"host key fingerprint verify trust"},{default:ot(()=>[Ve("hostKeyFingerprint")?(b(),x("span",gg,[N(M(Oe("hostKeyFingerprint").effective||"—")+" ",1),he("hostKeyFingerprint")?(b(),x("span",_g,[E(tt,{name:"lock",size:10}),N(M(he("hostKeyFingerprint")),1)])):V("",!0)])):xt((b(),x("input",{key:1,"onUpdate:modelValue":d[29]||(d[29]=w=>at.hostKeyFingerprint=w),class:"field w-full font-mono text-xs",placeholder:"SHA256:…"},null,512)),[[Bt,at.hostKeyFingerprint]])]),_:1})):V("",!0),Yi.value==="ftps"?(b(),oe(lt,{key:10,title:"TLS verification",desc:"Skip only for self-signed test servers.",keywords:"tls certificate verify insecure ftps"},{default:ot(()=>[Ve("insecureSkipVerify")?(b(),x("span",vg,[N(M(Oe("insecureSkipVerify").effective==="true"?"Skip verification":"Verify certificate")+" ",1),he("insecureSkipVerify")?(b(),x("span",yg,[E(tt,{name:"lock",size:10}),N(M(he("insecureSkipVerify")),1)])):V("",!0)])):(b(),oe(dn,{key:1,modelValue:at.insecureSkipVerify,"onUpdate:modelValue":d[30]||(d[30]=w=>at.insecureSkipVerify=w),options:ui},null,8,["modelValue"]))]),_:1})):V("",!0),E(lt,{title:"Base path",desc:"Working directory and health-check target, e.g. /uploads.",keywords:"base path directory folder root"},{default:ot(()=>[Ve("basePath")?(b(),x("span",bg,[N(M(Oe("basePath").effective||"—")+" ",1),he("basePath")?(b(),x("span",xg,[E(tt,{name:"lock",size:10}),N(M(he("basePath")),1)])):V("",!0)])):xt((b(),x("input",{key:1,"onUpdate:modelValue":d[31]||(d[31]=w=>at.basePath=w),class:"field w-64 font-mono",placeholder:"/uploads"},null,512)),[[Bt,at.basePath]])]),_:1}),f("div",wg,[ve.value?V("",!0):(b(),x("button",{key:0,class:"btn-accent",disabled:ne.value||!K.available,onClick:Pr},M(ne.value?"Saving…":Fe.value?"Save organization settings":"Save settings"),9,kg)),Fe.value?V("",!0):(b(),x("button",{key:1,class:"btn-ghost",disabled:ae.value||!K.available,onClick:Tr},M(ae.value?"Testing…":"Test connection"),9,Sg)),Rt.value?(b(),x("span",Pg,M(Rt.value),1)):V("",!0),ke.value&&!Fe.value?(b(),x("span",Tg,"Checked "+M(wr()),1)):V("",!0),fe.value&&!Fe.value?(b(),x("span",{key:4,class:Ot(["inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-semibold",Lr(fe.value.status)])},[d[87]||(d[87]=f("span",{class:"h-1.5 w-1.5 rounded-full bg-current"},null,-1)),N(M(fe.value.detail||fe.value.status),1)],2)):V("",!0)])]),f("div",Lg,[f("div",Mg,[f("div",Cg,[E(tt,{name:"cloud",size:20})]),d[88]||(d[88]=f("div",{class:"min-w-0"},[f("div",{class:"text-sm font-semibold text-ink"},"WebDAV"),f("div",{class:"mt-0.5 text-xs text-ink-muted"}," Connect to a WebDAV server (Nextcloud, ownCloud, IIS, …). Configure the connection your account uses. ")],-1))]),Et.loaded&&!Et.available?(b(),x("div",Og,[E(tt,{name:"lock",size:14,class:"mr-1 inline"}),d[89]||(d[89]=N(" WebDAV is currently disabled by your administrator. Contact them to enable it. ",-1))])):V("",!0),Et.canEditOrg?(b(),x("div",Eg,[d[90]||(d[90]=f("div",{class:"text-xs text-ink-muted"},"Manage your own settings, or organization-wide settings that apply to every user.",-1)),E(dn,{modelValue:it.value,"onUpdate:modelValue":d[32]||(d[32]=w=>it.value=w),options:nn},null,8,["modelValue"])])):V("",!0),on.value?(b(),oe(lt,{key:2,title:"Enable WebDAV (organization-wide)",desc:"Turn it on or off for everyone in your organization.",keywords:"enable disable plugin webdav organization"},{default:ot(()=>[E(tn,{"model-value":Et.orgEnabled,disabled:!Et.available,"onUpdate:modelValue":hi},null,8,["model-value","disabled"])]),_:1})):(b(),oe(lt,{key:3,title:"Enable WebDAV",desc:"Turn the plugin on for your account in the Web App.",keywords:"enable disable plugin webdav"},{default:ot(()=>[E(tn,{"model-value":Et.enabled,disabled:!Et.available||!Et.orgEnabled,"onUpdate:modelValue":hi},null,8,["model-value","disabled"])]),_:1})),!on.value&&Et.available&&!Et.orgEnabled?(b(),x("div",zg,[E(tt,{name:"lock",size:13,class:"mr-1 inline"}),d[92]||(d[92]=N("WebDAV is turned off for your organization",-1)),Et.canEditOrg?(b(),x("span",Ag,[...d[91]||(d[91]=[N(" — switch to ",-1),f("span",{class:"font-semibold"},"Organization",-1),N(" to turn it back on",-1)])])):V("",!0),d[93]||(d[93]=N(". ",-1))])):V("",!0),on.value?(b(),x("div",Ig,[E(tt,{name:"users",size:13,class:"mr-1 inline"}),d[94]||(d[94]=N("These are organization-wide settings — they apply to everyone in ",-1)),f("span",Ng,M(e.organizationName||"your organization"),1),d[95]||(d[95]=N(". Leave the connection blank to let each user configure their own; a connection set here overrides the user's. ",-1))])):Os.value?(b(),x("div",Bg," As a superadmin you manage the global WebDAV configuration in the API Server panel. The effective configuration is shown below. ")):V("",!0),E(lt,{block:"",title:"Server URL",desc:"WebDAV endpoint including scheme, e.g. https://cloud.example.com/remote.php/dav/files/alice/.",keywords:"url server address endpoint webdav host"},{default:ot(()=>[fi("baseURL")?(b(),x("span",Dg,[N(M(vn("baseURL").effective||"—")+" ",1),Ze("baseURL")?(b(),x("span",Rg,[E(tt,{name:"lock",size:10}),N(M(Ze("baseURL")),1)])):V("",!0)])):xt((b(),x("input",{key:1,"onUpdate:modelValue":d[33]||(d[33]=w=>Ee.baseURL=w),class:"field w-full font-mono text-xs",placeholder:"https://cloud.example.com/remote.php/dav/files/alice/"},null,512)),[[Bt,Ee.baseURL]])]),_:1}),E(lt,{title:"Username",desc:"Account used to authenticate (leave blank for a public share).",keywords:"username login account"},{default:ot(()=>[fi("username")?(b(),x("span",Fg,[N(M(vn("username").effective||"—")+" ",1),Ze("username")?(b(),x("span",Vg,[E(tt,{name:"lock",size:10}),N(M(Ze("username")),1)])):V("",!0)])):xt((b(),x("input",{key:1,"onUpdate:modelValue":d[34]||(d[34]=w=>Ee.username=w),class:"field w-64",placeholder:"user"},null,512)),[[Bt,Ee.username]])]),_:1}),E(lt,{title:"Password",desc:"Password or app-specific token for HTTP Basic auth.",keywords:"password secret credentials token"},{default:ot(()=>[fi("password")?(b(),x("span",Zg,[N(M(vn("password").effective||"—")+" ",1),Ze("password")?(b(),x("span",$g,[E(tt,{name:"lock",size:10}),N(M(Ze("password")),1)])):V("",!0)])):xt((b(),x("input",{key:1,"onUpdate:modelValue":d[35]||(d[35]=w=>Ee.password=w),type:"password",class:"field w-64",placeholder:"••••••••"},null,512)),[[Bt,Ee.password]])]),_:1}),E(lt,{title:"TLS verification",desc:"Only affects HTTPS. Skip only for self-signed test servers.",keywords:"tls certificate verify insecure https"},{default:ot(()=>[fi("insecureSkipVerify")?(b(),x("span",Hg,[N(M(vn("insecureSkipVerify").effective==="true"?"Skip verification":"Verify certificate")+" ",1),Ze("insecureSkipVerify")?(b(),x("span",Ug,[E(tt,{name:"lock",size:10}),N(M(Ze("insecureSkipVerify")),1)])):V("",!0)])):(b(),oe(dn,{key:1,modelValue:Ee.insecureSkipVerify,"onUpdate:modelValue":d[36]||(d[36]=w=>Ee.insecureSkipVerify=w),options:yo},null,8,["modelValue"]))]),_:1}),E(lt,{title:"Base path",desc:"Working directory under the server URL and health-check target, e.g. /Documents.",keywords:"base path directory folder root"},{default:ot(()=>[fi("basePath")?(b(),x("span",jg,[N(M(vn("basePath").effective||"—")+" ",1),Ze("basePath")?(b(),x("span",Wg,[E(tt,{name:"lock",size:10}),N(M(Ze("basePath")),1)])):V("",!0)])):xt((b(),x("input",{key:1,"onUpdate:modelValue":d[37]||(d[37]=w=>Ee.basePath=w),class:"field w-64 font-mono",placeholder:"/Documents"},null,512)),[[Bt,Ee.basePath]])]),_:1}),f("div",Kg,[Os.value?V("",!0):(b(),x("button",{key:0,class:"btn-accent",disabled:Mi.value||!Et.available,onClick:xo},M(Mi.value?"Saving…":on.value?"Save organization settings":"Save settings"),9,qg)),on.value?V("",!0):(b(),x("button",{key:1,class:"btn-ghost",disabled:di.value||!Et.available,onClick:wo},M(di.value?"Testing…":"Test connection"),9,Gg)),ci.value?(b(),x("span",Yg,M(ci.value),1)):V("",!0),Xe.value&&!on.value?(b(),x("span",Jg,"Checked "+M(Cr()),1)):V("",!0),sn.value&&!on.value?(b(),x("span",{key:4,class:Ot(["inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-semibold",Ci(sn.value.status)])},[d[96]||(d[96]=f("span",{class:"h-1.5 w-1.5 rounded-full bg-current"},null,-1)),N(M(sn.value.detail||sn.value.status),1)],2)):V("",!0)])])],64)):V("",!0),is("drives-local")?(b(),x("div",Xg,[f("div",Qg,[f("div",t_,[E(tt,{name:"monitor",size:20})]),d[97]||(d[97]=f("div",{class:"min-w-0"},[f("div",{class:"text-sm font-semibold text-ink"},"Local Storage"),f("div",{class:"mt-0.5 text-xs text-ink-muted"}," A private folder on the server for your files. Each user has their own; members of an organization share one. ")],-1))]),W.loaded&&!W.available?(b(),x("div",e_,[E(tt,{name:"lock",size:14,class:"mr-1 inline"}),d[98]||(d[98]=N(" Local storage is currently disabled by your administrator. Contact them to enable it. ",-1))])):W.loaded&&!W.rootConfigured?(b(),x("div",n_,[E(tt,{name:"alertTriangle",size:14,class:"mr-1 inline"}),d[99]||(d[99]=N(" No storage root has been configured by your administrator yet. ",-1))])):V("",!0),W.canEditOrg?(b(),x("div",i_,[d[100]||(d[100]=f("div",{class:"text-xs text-ink-muted"},"Manage your own settings, or organization-wide settings that apply to every user.",-1)),E(dn,{modelValue:Yt.value,"onUpdate:modelValue":d[38]||(d[38]=w=>Yt.value=w),options:nn},null,8,["modelValue"])])):V("",!0),ye.value?(b(),oe(lt,{key:3,title:"Enable local storage (organization-wide)",desc:"Turn it on or off for everyone in your organization.",keywords:"enable disable plugin local storage folder organization"},{default:ot(()=>[E(tn,{"model-value":W.orgEnabled,disabled:!W.available,"onUpdate:modelValue":es},null,8,["model-value","disabled"])]),_:1})):(b(),oe(lt,{key:4,title:"Enable local storage",desc:"Turn the plugin on for your account in the Web App.",keywords:"enable disable plugin local storage folder"},{default:ot(()=>[E(tn,{"model-value":W.enabled,disabled:!W.available||!W.orgEnabled,"onUpdate:modelValue":es},null,8,["model-value","disabled"])]),_:1})),!ye.value&&W.available&&!W.orgEnabled?(b(),x("div",s_,[E(tt,{name:"lock",size:13,class:"mr-1 inline"}),d[102]||(d[102]=N("Local storage is turned off for your organization",-1)),W.canEditOrg?(b(),x("span",o_,[...d[101]||(d[101]=[N(" — switch to ",-1),f("span",{class:"font-semibold"},"Organization",-1),N(" to turn it back on",-1)])])):V("",!0),d[103]||(d[103]=N(". ",-1))])):V("",!0),ye.value?(b(),x("div",r_,[E(tt,{name:"users",size:13,class:"mr-1 inline"}),d[104]||(d[104]=N("These are organization-wide settings — they apply to everyone in ",-1)),f("span",a_,M(e.organizationName||"your organization"),1),d[105]||(d[105]=N(", who all share the organization folder. Members can additionally enable a private folder inside it. ",-1))])):Ie.value?(b(),x("div",l_," As a superadmin you manage the global storage root in the API Server panel. The effective configuration is shown below. ")):V("",!0),ye.value?(b(),oe(lt,{key:8,title:"Private folders",desc:"Let members create a private folder inside the organization folder, reachable only by them.",keywords:"private folder members allow policy organization"},{default:ot(()=>[E(tn,{"model-value":W.allowPrivate,disabled:!W.available,"onUpdate:modelValue":ko},null,8,["model-value","disabled"])]),_:1})):V("",!0),ye.value?V("",!0):(b(),x(wt,{key:9},[E(lt,{block:"",title:"Your folders",desc:"Assigned automatically and isolated — no one else can reach your private folder.",keywords:"folder directory path storage location isolated private shared"},{default:ot(()=>[f("div",u_,[(b(!0),x(wt,null,ce(W.mounts,w=>(b(),x("div",{key:w.id,class:"flex flex-wrap items-center gap-2"},[f("span",c_,M(w.path),1),w.kind==="shared"?(b(),x("span",d_,[E(tt,{name:"users",size:10}),d[106]||(d[106]=N("Shared with your organization",-1))])):(b(),x("span",f_,[E(tt,{name:"lock",size:10}),d[107]||(d[107]=N("Private to you",-1))])),jn.value[w.id]?(b(),x("span",{key:2,class:Ot(["inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[10px] font-semibold",St(jn.value[w.id].status)])},[d[108]||(d[108]=f("span",{class:"h-1.5 w-1.5 rounded-full bg-current"},null,-1)),N(M(jn.value[w.id].status),1)],2)):V("",!0)]))),128)),W.mounts.length?V("",!0):(b(),x("div",h_,M(W.rootConfigured?"No folder assigned yet.":"Waiting for the administrator to configure a storage root."),1))])]),_:1}),W.isOrgUser&&W.allowPrivate?(b(),oe(lt,{key:0,title:"My private folder",desc:"Add a private folder inside the organization folder, reachable only by you — you keep the shared folder too.",keywords:"private folder personal isolated organization inside"},{default:ot(()=>[E(tn,{"model-value":W.privateFolder,disabled:!W.available||!W.orgEnabled,"onUpdate:modelValue":ns},null,8,["model-value","disabled"])]),_:1})):W.isOrgUser&&!W.allowPrivate?(b(),x("div",p_,[E(tt,{name:"lock",size:13,class:"mr-1 inline"}),d[109]||(d[109]=N("Private folders are turned off by your organization. ",-1))])):V("",!0)],64)),E(lt,{title:"Access mode",desc:"Read-only prevents uploads, deletes and folder creation.",keywords:"read only write access mode permission"},{default:ot(()=>[On("readOnly")?(b(),x("span",m_,[N(M(Ei(le("readOnly").effective))+" ",1),mi("readOnly")?(b(),x("span",g_,[E(tt,{name:"lock",size:10}),N(M(mi("readOnly")),1)])):V("",!0)])):(b(),oe(dn,{key:1,modelValue:Un.value,"onUpdate:modelValue":d[39]||(d[39]=w=>Un.value=w),options:Oi},null,8,["modelValue"]))]),_:1}),f("div",__,[Ie.value?V("",!0):(b(),x("button",{key:0,class:"btn-accent",disabled:yn.value||!W.available,onClick:Rs},M(yn.value?"Saving…":ye.value?"Save organization settings":"Save settings"),9,v_)),ye.value?V("",!0):(b(),x("button",{key:1,class:"btn-ghost",disabled:pi.value||!W.available,onClick:zr},M(pi.value?"Testing…":"Test folder"),9,y_)),rn.value?(b(),x("span",b_,M(rn.value),1)):V("",!0),te.value&&!ye.value?(b(),x("span",x_,"Checked "+M(Ns()),1)):V("",!0),mt.value&&!ye.value?(b(),x("span",{key:4,class:Ot(["inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-semibold",St(mt.value.status)])},[d[110]||(d[110]=f("span",{class:"h-1.5 w-1.5 rounded-full bg-current"},null,-1)),N(M(mt.value.detail||mt.value.status),1)],2)):V("",!0)])])):V("",!0)])):Z.id==="profile"?(b(),x("div",w_,[E(lt,{block:"",title:"Profile photo",desc:"PNG or JPG, up to ~1.5 MB. Stored on this device.",keywords:"avatar photo picture"},{default:ot(()=>[f("div",k_,[Ct(gt).avatar?(b(),x("img",{key:0,src:Ct(gt).avatar,alt:"Avatar",class:"h-16 w-16 rounded-full object-cover"},null,8,S_)):(b(),x("div",P_,M(To.value),1)),f("div",T_,[f("label",L_,[E(tt,{name:"upload",size:15,class:"mr-1.5 inline"}),d[111]||(d[111]=N("Upload ",-1)),f("input",{type:"file",accept:"image/*",class:"hidden",onChange:Po},null,32)]),Ct(gt).avatar?(b(),x("button",{key:0,class:"btn-ghost",onClick:Ar},"Remove")):V("",!0)])])]),_:1}),E(lt,{title:"Display name",desc:"The name shown on your public profile.",keywords:"display name profile"},{default:ot(()=>[xt(f("input",{"onUpdate:modelValue":d[40]||(d[40]=w=>Ct(gt).displayName=w),class:"field w-56",placeholder:"Jane O.",onBlur:d[41]||(d[41]=w=>Zt("Saved."))},null,544),[[Bt,Ct(gt).displayName]])]),_:1}),E(lt,{block:"",title:"Bio",desc:"A short description others can see.",keywords:"bio about description"},{default:ot(()=>[xt(f("textarea",{"onUpdate:modelValue":d[42]||(d[42]=w=>Ct(gt).bio=w),rows:"3",maxlength:"240",class:"field w-full resize-none",placeholder:"Flight director, North yard operations…",onBlur:d[43]||(d[43]=w=>Zt("Saved."))},null,544),[[Bt,Ct(gt).bio]]),f("div",M_,M((Ct(gt).bio||"").length)+"/240",1)]),_:1}),E(lt,{title:"Show email on profile",desc:"Let teammates see your email address.",keywords:"show email public visibility"},{default:ot(()=>[E(tn,{modelValue:Ct(gt).showEmail,"onUpdate:modelValue":d[44]||(d[44]=w=>Ct(gt).showEmail=w)},null,8,["modelValue"])]),_:1})])):Z.id==="security"?(b(),x("div",C_,[E(lt,{block:"",title:"Two-factor authentication",desc:"Require a one-time code at sign-in.",keywords:"two factor 2fa authentication security"},{default:ot(()=>[f("div",O_,[f("span",{class:Ot(["inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-semibold",Ct(gt).twoFactor?"bg-success-soft text-success-fg":"bg-surface-2 text-ink-secondary"])},[d[112]||(d[112]=f("span",{class:"h-1.5 w-1.5 rounded-full bg-current"},null,-1)),N(M(Ct(gt).twoFactor?"Enabled":"Disabled"),1)],2),!Ct(gt).twoFactor&&!Wn.value?(b(),x("button",{key:0,class:"btn-accent",onClick:Ii},"Enable 2FA")):Ct(gt).twoFactor?(b(),x("button",{key:1,class:"btn-ghost",onClick:Ir},"Disable")):V("",!0)]),Wn.value?(b(),x("div",E_,[f("div",z_,[d[114]||(d[114]=f("div",{class:"grid h-28 w-28 place-items-center rounded bg-white p-2"},[f("svg",{viewBox:"0 0 100 100",class:"h-full w-full"},[f("rect",{width:"100",height:"100",fill:"#fff"}),f("g",{fill:"#0F1E3D"},[f("rect",{x:"6",y:"6",width:"24",height:"24"}),f("rect",{x:"70",y:"6",width:"24",height:"24"}),f("rect",{x:"6",y:"70",width:"24",height:"24"}),f("rect",{x:"12",y:"12",width:"12",height:"12",fill:"#fff"}),f("rect",{x:"76",y:"12",width:"12",height:"12",fill:"#fff"}),f("rect",{x:"12",y:"76",width:"12",height:"12",fill:"#fff"}),f("rect",{x:"40",y:"10",width:"8",height:"8"}),f("rect",{x:"52",y:"20",width:"8",height:"8"}),f("rect",{x:"40",y:"40",width:"8",height:"8"}),f("rect",{x:"60",y:"44",width:"8",height:"8"}),f("rect",{x:"44",y:"60",width:"8",height:"8"}),f("rect",{x:"70",y:"60",width:"8",height:"8"}),f("rect",{x:"80",y:"72",width:"8",height:"8"}),f("rect",{x:"60",y:"80",width:"8",height:"8"})])])],-1)),f("div",A_,[d[113]||(d[113]=f("div",{class:"text-xs text-ink-secondary"},"Scan with an authenticator app, or enter this secret:",-1)),f("div",I_,M(Lo.value),1),f("div",N_,[xt(f("input",{"onUpdate:modelValue":d[45]||(d[45]=w=>Ai.value=w),inputmode:"numeric",maxlength:"6",class:"field w-28 font-mono tracking-[0.3em]",placeholder:"000000"},null,512),[[Bt,Ai.value]]),f("button",{class:"btn-accent",onClick:Mo},"Verify & enable")]),zt.value?(b(),x("p",B_,M(zt.value),1)):V("",!0)])])])):V("",!0),Ct(gt).twoFactor&&ss.value.length?(b(),x("div",D_,[d[115]||(d[115]=f("div",{class:"text-xs font-semibold text-ink"},"Recovery codes",-1)),d[116]||(d[116]=f("div",{class:"mt-0.5 text-xs text-ink-muted"},"Store these somewhere safe — each works once.",-1)),f("div",R_,[(b(!0),x(wt,null,ce(ss.value,w=>(b(),x("span",{key:w,class:"select-all"},M(w),1))),128))])])):V("",!0),d[117]||(d[117]=f("p",{class:"mt-2 text-xs text-ink-muted"},"Prototype — codes are generated locally until the account service verifies them.",-1))]),_:1}),E(lt,{block:"",title:"Active sessions",desc:"Devices currently signed in to your account.",keywords:"sessions devices logout sign out remote"},{default:ot(()=>[f("div",F_,[f("div",V_,[f("div",Z_,[E(tt,{name:"monitor",size:18})]),f("div",$_,[f("div",H_,[N(M(Nr())+" on "+M(Co())+" ",1),d[118]||(d[118]=f("span",{class:"ml-1 rounded-full bg-success-soft px-2 py-0.5 text-[10px] font-semibold text-success-fg"},"This device",-1))]),f("div",U_,"Signed in "+M(Ct(Gl)(Ct(Br))),1)]),f("button",{class:"btn-ghost",onClick:d[46]||(d[46]=w=>a("logout"))},"Log out")])]),d[119]||(d[119]=f("button",{class:"btn-ghost mt-2 opacity-60",disabled:"",title:"Requires the account service"}," Log out all other devices ",-1)),d[120]||(d[120]=f("p",{class:"mt-2 text-xs text-ink-muted"}," Only this session is visible from the browser; enumerating and revoking remote sessions needs the account service. ",-1))]),_:1})])):Z.id==="team"?(b(),x("div",j_,[$t.id?(b(),x("div",W_,[E(lt,{block:"",title:`Edit user — ${$t.email}`,desc:"Update details, change role, reset password, or set verified.",keywords:"edit user update role password verified organization"},{default:ot(()=>[f("div",K_,[f("div",q_,[xt(f("input",{"onUpdate:modelValue":d[47]||(d[47]=w=>$t.email=w),type:"email",class:"field flex-1",placeholder:"name@pilotvault.local"},null,512),[[Bt,$t.email]]),xt(f("select",{"onUpdate:modelValue":d[48]||(d[48]=w=>$t.role=w),class:"field w-32",disabled:as.value,title:as.value?"You cannot change your own role":""},[(b(!0),x(wt,null,ce(Fs.value,w=>(b(),x("option",{key:w.value,value:w.value},M(w.label),9,Y_))),128))],8,G_),[[wi,$t.role]])]),l.value?xt((b(),x("select",{key:0,"onUpdate:modelValue":d[49]||(d[49]=w=>$t.organization=w),class:"field",title:"Organization"},[(b(!0),x(wt,null,ce(Oo.value,w=>(b(),x("option",{key:w.value,value:w.value},M(w.label),9,J_))),128))],512)),[[wi,$t.organization]]):V("",!0),xt(f("input",{"onUpdate:modelValue":d[50]||(d[50]=w=>$t.password=w),type:"password",class:"field",placeholder:"New password (leave blank to keep current)"},null,512),[[Bt,$t.password]]),f("label",X_,[E(tn,{modelValue:$t.verified,"onUpdate:modelValue":d[51]||(d[51]=w=>$t.verified=w)},null,8,["modelValue"]),d[121]||(d[121]=N(" Email verified ",-1))]),f("div",Q_,[f("button",{class:"btn-accent",disabled:Ri.value,onClick:Fr},M(Ri.value?"Saving…":"Save changes"),9,tv),f("button",{class:"btn-ghost",onClick:An},"Cancel"),zn.value?(b(),x("span",ev,M(zn.value),1)):V("",!0),as.value?(b(),x("span",nv,"Editing your own account — role locked.")):V("",!0)])])]),_:1},8,["title"])])):(b(),x("div",iv,[E(lt,{block:"",title:"Add user",desc:"Create a new account. Admins add users within their organization; superadmins can target any.",keywords:"add user create account role admin organization"},{default:ot(()=>[f("div",sv,[f("div",ov,[xt(f("input",{"onUpdate:modelValue":d[52]||(d[52]=w=>ee.email=w),type:"email",class:"field flex-1",placeholder:"name@pilotvault.local"},null,512),[[Bt,ee.email]]),xt(f("select",{"onUpdate:modelValue":d[53]||(d[53]=w=>ee.role=w),class:"field w-32"},[(b(!0),x(wt,null,ce(Fs.value,w=>(b(),x("option",{key:w.value,value:w.value},M(w.label),9,rv))),128))],512),[[wi,ee.role]])]),l.value?xt((b(),x("select",{key:0,"onUpdate:modelValue":d[54]||(d[54]=w=>ee.organization=w),class:"field",title:"Organization"},[(b(!0),x(wt,null,ce(Oo.value,w=>(b(),x("option",{key:w.value,value:w.value},M(w.label),9,av))),128))],512)),[[wi,ee.organization]]):(b(),x("div",lv,[d[122]||(d[122]=N(" New users join your organization: ",-1)),f("span",uv,M(e.organizationName||"—"),1)])),xt(f("input",{"onUpdate:modelValue":d[55]||(d[55]=w=>ee.password=w),type:"password",class:"field",placeholder:"Temporary password (min 8 chars)"},null,512),[[Bt,ee.password]]),f("div",cv,[f("button",{class:"btn-accent",disabled:Ni.value,onClick:Dr},M(Ni.value?"Creating…":"Create user"),9,dv),vi.value?(b(),x("span",fv,M(vi.value),1)):V("",!0)])])]),_:1})])),f("div",hv,[f("div",pv,[d[123]||(d[123]=f("div",null,[f("div",{class:"eyebrow"},"Team"),f("div",{class:"mt-0.5 text-base font-semibold text-ink"},"All users")],-1)),f("button",{class:"btn-ghost",disabled:Kn.value,onClick:Gn},M(Kn.value?"Loading…":"Refresh"),9,mv)]),rs.value?(b(),x("div",gv,M(rs.value),1)):!os.value.length&&!Kn.value?(b(),x("div",_v,"No users yet.")):(b(),x("div",vv,[f("table",yv,[f("thead",null,[f("tr",bv,[(b(),x(wt,null,ce(["User","Role","Organization","Status",""],w=>f("th",{key:w,class:"border-y border-line bg-surface-2 px-5 py-2.5 font-mono text-[10.5px] font-normal uppercase tracking-caps text-ink-muted"},M(w),1)),64))])]),f("tbody",null,[(b(!0),x(wt,null,ce(os.value,w=>(b(),x("tr",{key:w.id,class:Ot(["border-b border-line last:border-0",$t.id===w.id?"bg-accent-soft":""])},[f("td",xv,[f("span",wv,M(w.email),1),w.email===e.email?(b(),x("span",kv,"(you)")):V("",!0)]),f("td",Sv,[f("span",{class:Ot(["inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-semibold",v(w.role||"user")])},[E(tt,{name:g(w.role||"user"),size:12},null,8,["name"]),N(M(h(w.role||"user")),1)],2)]),f("td",Pv,[f("span",{class:Ot(["text-sm",w.organizationName?"text-ink-secondary":"text-ink-muted"])},M(w.organizationName||"—"),3)]),f("td",Tv,[f("span",{class:Ot(["text-xs",w.verified?"text-success-fg":"text-ink-muted"])},M(w.verified?"Verified":"Unverified"),3)]),f("td",Lv,[qe.value===w.id?(b(),x(wt,{key:0},[d[124]||(d[124]=f("span",{class:"mr-2 text-xs text-ink-muted"},"Remove?",-1)),f("button",{class:"btn-ghost mr-1",onClick:d[56]||(d[56]=Ht=>qe.value="")},"Cancel"),f("button",{class:"rounded bg-danger px-3 py-1.5 text-sm font-semibold text-white transition hover:brightness-110",onClick:Ht=>Rr(w)}," Remove ",8,Mv)],64)):(b(),x("div",Cv,[f("button",{class:"btn-ghost inline-flex items-center gap-1.5",onClick:Ht=>ls(w)},[E(tt,{name:"settings",size:14}),d[125]||(d[125]=N(" Edit ",-1))],8,Ov),w.email!==e.email?(b(),x("button",{key:0,class:"btn-ghost inline-flex items-center gap-1.5",onClick:Ht=>qe.value=w.id},[E(tt,{name:"trash",size:14}),d[126]||(d[126]=N(" Remove ",-1))],8,Ev)):V("",!0)]))])],2))),128))])])]))])])):Z.id==="organizations"?(b(),x("div",zv,[wn.id?(b(),x("div",Av,[E(lt,{block:"",title:"Rename organization",desc:"Update the organization's display name.",keywords:"rename organization edit"},{default:ot(()=>[f("div",Iv,[xt(f("input",{"onUpdate:modelValue":d[57]||(d[57]=w=>wn.name=w),class:"field",placeholder:"Organization name",onKeyup:Vl(zo,["enter"])},null,544),[[Bt,wn.name]]),f("div",Nv,[f("button",{class:"btn-accent",onClick:zo},"Save changes"),f("button",{class:"btn-ghost",onClick:Vs},"Cancel"),kn.value?(b(),x("span",Bv,M(kn.value),1)):V("",!0)])])]),_:1})])):(b(),x("div",Dv,[E(lt,{block:"",title:"Add organization",desc:"Create a new organization. Assign admins and users to it from User management.",keywords:"add organization create tenant company"},{default:ot(()=>[f("div",Rv,[xt(f("input",{"onUpdate:modelValue":d[58]||(d[58]=w=>In.name=w),class:"field",placeholder:"e.g. Northwind Aerial",onKeyup:Vl(Eo,["enter"])},null,544),[[Bt,In.name]]),f("div",Fv,[f("button",{class:"btn-accent",disabled:Fi.value,onClick:Eo},M(Fi.value?"Creating…":"Create organization"),9,Vv),Le.value?(b(),x("span",Zv,M(Le.value),1)):V("",!0)])])]),_:1})])),f("div",$v,[f("div",{class:"flex items-center justify-between px-5 py-4"},[d[127]||(d[127]=f("div",null,[f("div",{class:"eyebrow"},"Tenancy"),f("div",{class:"mt-0.5 text-base font-semibold text-ink"},"All organizations")],-1)),f("button",{class:"btn-ghost",onClick:qn},"Refresh")]),Bi.value.length?(b(),x("div",Uv,[f("table",jv,[f("thead",null,[f("tr",Wv,[(b(),x(wt,null,ce(["Organization","Members",""],w=>f("th",{key:w,class:"border-y border-line bg-surface-2 px-5 py-2.5 font-mono text-[10.5px] font-normal uppercase tracking-caps text-ink-muted"},M(w),1)),64))])]),f("tbody",null,[(b(!0),x(wt,null,ce(Bi.value,w=>(b(),x("tr",{key:w.id,class:Ot(["border-b border-line last:border-0",wn.id===w.id?"bg-accent-soft":""])},[f("td",Kv,[f("span",qv,[E(tt,{name:"grid",size:14,class:"text-ink-muted"}),N(M(w.name),1)])]),f("td",Gv,M(Vi.value[w.id]||0),1),f("td",Yv,[yi.value===w.id?(b(),x(wt,{key:0},[d[128]||(d[128]=f("span",{class:"mr-2 text-xs text-ink-muted"},"Delete?",-1)),f("button",{class:"btn-ghost mr-1",onClick:d[59]||(d[59]=Ht=>yi.value="")},"Cancel"),f("button",{class:"rounded bg-danger px-3 py-1.5 text-sm font-semibold text-white transition hover:brightness-110",onClick:Ht=>Qe(w)}," Delete ",8,Jv)],64)):(b(),x("div",Xv,[f("button",{class:"btn-ghost inline-flex items-center gap-1.5",onClick:Ht=>Vr(w)},[E(tt,{name:"settings",size:14}),d[129]||(d[129]=N(" Rename ",-1))],8,Qv),f("button",{class:"btn-ghost inline-flex items-center gap-1.5",disabled:(Vi.value[w.id]||0)>0,title:(Vi.value[w.id]||0)>0?"Reassign or remove members first":"",onClick:Ht=>yi.value=w.id},[E(tt,{name:"trash",size:14}),d[130]||(d[130]=N(" Delete ",-1))],8,ty)]))])],2))),128))])])])):(b(),x("div",Hv,"No organizations yet."))])])):Z.id==="advanced"?(b(),x("div",ey,[f("div",ny,[E(lt,{title:"Export data",desc:"Download your settings and profile as JSON.",keywords:"export data download backup"},{default:ot(()=>[f("button",{class:"btn-ghost",onClick:bi},[E(tt,{name:"download",size:15,class:"mr-1.5 inline"}),d[131]||(d[131]=N("Export",-1))])]),_:1}),E(lt,{block:"",title:"Import data",desc:"Restore settings from a previous export.",keywords:"import data upload restore"},{default:ot(()=>[f("label",iy,[E(tt,{name:"upload",size:15,class:"mr-1.5 inline"}),d[132]||(d[132]=N("Choose file… ",-1)),f("input",{type:"file",accept:"application/json,.json",class:"hidden",onChange:Sn},null,32)]),us.value?(b(),x("p",sy,M(us.value),1)):V("",!0)]),_:1})]),f("div",oy,[f("div",ry,[E(tt,{name:"alertTriangle",size:18}),d[133]||(d[133]=f("h3",{class:"text-sm font-bold uppercase tracking-caps"},"Danger zone",-1))]),d[138]||(d[138]=f("p",{class:"mt-1 text-xs text-ink-secondary"},"Deleting your account is permanent and cannot be undone.",-1)),f("div",ay,[d[137]||(d[137]=f("div",{class:"text-sm font-semibold text-ink"},"Delete account",-1)),f("label",ly,[xt(f("input",{"onUpdate:modelValue":d[60]||(d[60]=w=>se.understand=w),type:"checkbox",class:"mt-0.5 h-4 w-4 accent-[var(--danger)]"},null,512),[[_c,se.understand]]),d[134]||(d[134]=N(" I understand this permanently deletes my account and all associated data. ",-1))]),f("div",uy,[f("label",cy,[d[135]||(d[135]=N("Type ",-1)),f("span",dy,M(Zs.value),1),d[136]||(d[136]=N(" to confirm",-1))]),xt(f("input",{"onUpdate:modelValue":d[61]||(d[61]=w=>se.typed=w),class:"field w-full max-w-[360px] font-mono",placeholder:Zs.value},null,8,fy),[[Bt,se.typed]])]),f("div",hy,[se.armed?(b(),x("button",{key:1,class:"rounded bg-danger px-4 py-2.5 text-sm font-semibold text-white transition enabled:hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50",disabled:se.cooldown>0,onClick:cs},M(se.cooldown>0?`Confirm in ${se.cooldown}s…`:"Permanently delete account"),9,my)):(b(),x("button",{key:0,class:"rounded bg-danger px-4 py-2.5 text-sm font-semibold text-white transition enabled:hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-40",disabled:!Yn.value,onClick:Ao}," Delete account… ",8,py)),se.armed&&se.cooldown>0?(b(),x("span",gy,"Cooling-off period — read once more.")):V("",!0)]),se.msg?(b(),x("p",_y,M(se.msg),1)):V("",!0)])])])):V("",!0)],64))),128))])]),E(Qf,{name:"fade"},{default:ot(()=>[gi.value?(b(),x("div",vy,[E(tt,{name:"check",size:16,class:"text-success-fg"}),N(M(gi.value),1)])):V("",!0)]),_:1})]))}},by=Dp(yy,[["__scopeId","data-v-4fe25eb7"]]),xy={class:"grid h-full grid-cols-[248px_1fr] max-[900px]:grid-cols-1"},wy={class:"flex flex-col border-r border-line bg-surface-1 px-4 py-5 max-[900px]:hidden"},ky={class:"flex items-center gap-2.5 px-2 pb-5"},Sy={class:"flex flex-col gap-0.5"},Py=["onClick"],Ty={class:"mt-auto flex flex-col gap-2.5"},Ly={class:"rounded-lg bg-surface-2 p-3"},My={class:"flex items-center gap-2"},Cy={class:"text-xs font-semibold text-ink"},Oy={class:"mt-1.5 block font-mono text-[10.5px] text-ink-muted"},Ey={class:"flex items-center gap-2.5 px-2 py-1"},zy={class:"grid h-8 w-8 place-items-center rounded-full bg-[var(--navy-800)] text-xs font-bold text-white"},Ay={class:"min-w-0 flex-1"},Iy={class:"truncate text-[13px] font-semibold text-ink"},Ny={class:"flex items-center gap-1.5 text-[11px] text-ink-muted"},By=["title"],Dy={class:"overflow-y-auto"},Ry={class:"sticky top-0 z-10 flex items-center gap-4 border-b border-line px-7 py-3.5",style:{background:"color-mix(in srgb, var(--bg-app) 82%, transparent)","backdrop-filter":"blur(10px)"}},Fy={class:"mt-0.5 text-[22px] font-bold tracking-tightest text-ink"},Vy={class:"ml-auto flex items-center gap-3"},Zy={class:"flex h-10 w-60 items-center gap-2 rounded border border-line-strong bg-surface-1 px-3 max-[1100px]:hidden"},$y={key:0,class:"mx-auto flex max-w-[1240px] flex-col gap-5 p-7"},Hy={class:"grid grid-cols-4 gap-4 max-[1100px]:grid-cols-2 max-[560px]:grid-cols-1"},Uy={class:"flex items-center justify-between"},jy={class:"eyebrow"},Wy={class:"mt-2.5 text-[34px] font-bold leading-none tracking-tightest text-ink"},Ky={class:"grid grid-cols-[1.6fr_1fr] gap-5 max-[1100px]:grid-cols-1"},qy={class:"panel p-5"},Gy={class:"mb-3.5 flex items-center justify-between"},Yy={class:"panel p-5"},Jy={class:"mb-3.5 flex items-center justify-between"},Xy={class:"grid place-items-center py-10 text-center"},Qy={class:"panel overflow-hidden p-0"},t1={class:"flex items-center justify-between px-5 py-4"},e1={class:"flex gap-2"},n1={key:0,class:"px-5 py-12 text-center text-sm text-ink-muted"},i1={key:1,class:"overflow-x-auto"},s1={class:"w-full border-collapse text-sm"},o1={class:"text-left"},r1=["onClick"],a1={class:"px-5 py-3 font-mono font-bold text-ink"},l1={class:"px-5 py-3 text-ink-secondary"},u1={class:"px-5 py-3"},c1={class:"px-5 py-3 font-mono text-ink-secondary"},d1={class:"px-5 py-3"},f1={key:0,class:"flex items-center gap-2"},h1={class:"h-1.5 w-12 overflow-hidden rounded bg-surface-2"},p1={class:"font-mono text-xs text-ink-secondary"},m1={key:1,class:"font-mono text-xs text-ink-muted"},g1={class:"px-5 py-3 font-mono text-ink-secondary"},_1={class:"px-5 py-3 text-right"},v1=["onClick"],y1={key:1,class:"p-7"},b1={class:"mb-4 flex flex-wrap items-center gap-3"},x1={class:"font-mono text-mode font-bold text-ink"},w1={key:0,class:"rounded-full bg-danger-soft px-2.5 py-0.5 font-mono text-[10px] font-bold uppercase tracking-caps text-danger-fg"},k1={key:1,class:"ml-auto flex flex-wrap gap-1.5"},S1=["onClick"],P1={key:0,class:"panel grid place-items-center p-16 text-center"},T1={class:"pill"},L1={class:"pill"},M1={class:"pill"},C1={class:"mt-1 text-sm font-semibold text-ink"},O1={class:"pill"},E1={class:"mt-1 font-mono text-sm font-bold tabular text-ink"},z1={class:"grid grid-cols-2 gap-4 max-[820px]:grid-cols-1"},A1={class:"panel p-4"},I1={class:"flex items-center gap-4"},N1={class:"h-9 flex-1 overflow-hidden rounded border border-line bg-surface-2"},B1={class:"readout"},D1={class:"panel p-4"},R1={class:"readout"},F1={class:"panel p-4"},V1={class:"space-y-1.5 text-sm"},Z1={class:"flex justify-between"},$1={class:"text-ink"},H1={class:"flex justify-between"},U1={class:"text-ink"},j1={class:"flex justify-between"},W1={class:"font-mono tabular text-ink"},K1={class:"flex justify-between"},q1={class:"font-mono tabular text-ink"},G1={class:"panel p-4"},Y1={class:"space-y-1.5 text-sm"},J1={class:"flex justify-between"},X1={class:"font-mono tabular text-ink"},Q1={class:"flex justify-between"},tb={class:"font-mono tabular text-ink"},eb={class:"flex justify-between"},nb={class:"font-mono tabular text-ink"},ib={class:"panel col-span-2 p-4 max-[820px]:col-span-1"},sb={class:"panel p-4"},ob={class:"flex flex-wrap gap-2"},rb={class:"mt-2 min-h-[16px] text-xs text-ink-muted"},ab={class:"panel p-4"},lb={class:"h-[180px] overflow-y-auto font-mono text-xs"},ub={class:"text-ink-muted"},cb={class:"font-semibold text-accent"},db={class:"break-all text-ink"},fb={key:3,class:"p-7"},hb={class:"panel grid place-items-center p-16 text-center"},pb={class:"mt-3 text-sm font-medium text-ink-secondary"},mb={key:0,class:"mt-1 text-xs text-ink-muted"},gb={key:1,class:"mt-1 text-xs text-ink-muted"},_b={__name:"Dashboard",props:{email:{type:String,default:""},role:{type:String,default:"user"},organization:{type:String,default:""},organizationName:{type:String,default:""}},emits:["logout"],setup(e,{emit:i}){const o=e,a=i,l=xe({}),c=xe({}),h=J(null),g=J(!1),v=xe([]),P=J(""),k=J("Overview"),O=[["grid","Overview"],["radio","Live flights"],["route","Routes"],["calendar","Schedule"],["book","Logbook"],["fileText","Documents"],["server","Drives"],["settings","Settings"]],$=ht(()=>(O.find(([,_])=>_===k.value)||["grid"])[0]),F=J(""),nt=J(""),q=J("");let At=null,Dt=null,bt=!1;const ut=ht(()=>Object.keys(l).sort((_,m)=>(l[m].online?1:0)-(l[_].online?1:0)||_.localeCompare(m))),X=ht(()=>h.value?l[h.value]:null),ft=ht(()=>X.value&&X.value.telemetry||{}),jt=ht(()=>!!(X.value&&X.value.online)),de=ht(()=>{const _=ft.value;return typeof _.latitude=="number"&&typeof _.longitude=="number"&&(_.latitude||_.longitude)?{lat:_.latitude,lng:_.longitude}:null}),me=ht(()=>h.value&&c[h.value]||[]),vt=ht(()=>{const _=ft.value;return typeof _.velocityX=="number"&&typeof _.velocityY=="number"?Math.hypot(_.velocityX,_.velocityY):null});function Ft(_){return _.online?_.connected?["In flight","success"]:["Standby","accent"]:["Offline","neutral"]}function Tt(_){const m=_&&_.telemetry||{};return typeof m.velocityX=="number"&&typeof m.velocityY=="number"?Math.hypot(m.velocityX,m.velocityY):null}const G=ht(()=>ut.value.map(_=>{const m=l[_],T=m.telemetry||{},[B,I]=Ft(m);return{id:_,mission:m.model||(m.connected?"Drone linked":m.online?"App online":"No signal"),status:B,tone:I,alt:typeof T.altitude=="number"?T.altitude.toFixed(0)+" m":"—",battery:typeof T.batteryPercent=="number"?T.batteryPercent:null,speed:Tt(m)}})),st=ht(()=>ut.value.filter(_=>l[_].online).length),It=ht(()=>ut.value.filter(_=>l[_].online&&l[_].connected).length),Wt=ht(()=>ut.value.filter(_=>!l[_].online).length),kt=ht(()=>{const _=ut.value.map(m=>{var T;return(T=l[m].telemetry)==null?void 0:T.batteryPercent}).filter(m=>typeof m=="number");return _.length?Math.round(_.reduce((m,T)=>m+T,0)/_.length):null}),Vt=ht(()=>[{label:"Active flights",value:String(It.value),delta:`${st.value} online`,tone:"success",icon:"radio"},{label:"Avg battery",value:kt.value==null?"—":kt.value+"%",delta:kt.value==null?"no telemetry":kt.value<40?"low — watch":"nominal",tone:kt.value!=null&&kt.value<40?"danger":"neutral",icon:"battery"},{label:"Fleet size",value:String(ut.value.length),delta:`${It.value} in flight`,tone:"neutral",icon:"grid"},{label:"Offline",value:String(Wt.value),delta:Wt.value?"needs attention":"all reachable",tone:Wt.value?"warning":"success",icon:"signal"}]),Y={success:"bg-success-soft text-success-fg",warning:"bg-amber-soft text-amber-fg",danger:"bg-danger-soft text-danger-fg",accent:"bg-accent-soft text-accent-soft-fg",neutral:"bg-surface-2 text-ink-secondary"},ue={success:"text-success-fg",danger:"text-danger-fg",warning:"text-amber-fg",neutral:"text-ink-muted",accent:"text-accent-soft-fg"},rt=ht(()=>{var T,B,I;const m=(o.email||"PV").split("@")[0].split(/[.\-_ ]+/).filter(Boolean);return((((T=m[0])==null?void 0:T[0])||"P")+(((B=m[1])==null?void 0:B[0])||((I=m[0])==null?void 0:I[1])||"V")).toUpperCase()}),yt={superadmin:"Superadmin",admin:"Admin",user:"Operator"},Kt=ht(()=>yt[o.role]||"Operator"),ge=ht(()=>o.organizationName||(o.role==="superadmin"?"All organizations":"No organization"));function qt(_){var T;l[_.deviceId]=_;const m=_.telemetry||{};typeof m.latitude=="number"&&typeof m.longitude=="number"&&(m.latitude||m.longitude)&&(c[_.deviceId]||(c[_.deviceId]=[]),c[_.deviceId].push([m.latitude,m.longitude]),c[_.deviceId].length>1e3&&c[_.deviceId].shift()),(!h.value||_.online&&!((T=l[h.value])!=null&&T.online))&&(h.value=_.deviceId)}function Ut(_){delete l[_],delete c[_],h.value===_&&(h.value=ut.value[0]||null)}function Pt(_){v.unshift({t:ql(Date.now()),tag:_.type||"?",text:JSON.stringify(Pe(_))}),v.length>200&&v.pop()}function Pe(_){const m={..._};return delete m.type,m}function Te(){const _=location.protocol==="https:"?"wss":"ws";At=new WebSocket(`${_}://${location.host}/bff/ws`),At.onopen=()=>g.value=!0,At.onclose=()=>{g.value=!1,bt||(Dt=setTimeout(Te,1500))},At.onerror=()=>At&&At.close(),At.onmessage=m=>{let T;try{T=JSON.parse(m.data)}catch{return}T.type==="snapshot"?(T.devices||[]).forEach(qt):T.type==="update"&&T.device?(qt(T.device),T.event&&T.device.deviceId===h.value&&Pt(T.event)):T.type==="removed"&&T.deviceId&&Ut(T.deviceId)}}async function nn(){if(!h.value)return q.value="No device selected.";if(!F.value.trim())return q.value="Enter a command name.";let _;if(nt.value.trim())try{_=JSON.parse(nt.value)}catch{return q.value="Payload is not valid JSON."}const{ok:m,body:T}=await ip(h.value,F.value.trim(),_);q.value=m?`Sent "${F.value.trim()}".`:`Error: ${T.error||"failed"}`}function we(_,m,T=""){return typeof _=="number"?_.toFixed(m)+T:"—"}function gn(_){h.value=_,k.value="Live flights"}return Ls(async()=>{(await Rh()).forEach(qt),Te()}),_r(()=>{bt=!0,Dt&&clearTimeout(Dt),At&&At.close()}),(_,m)=>{var T,B,I,D,j;return b(),x("div",xy,[f("aside",wy,[f("div",ky,[E(Mc,{size:26}),m[7]||(m[7]=f("span",{class:"text-[19px] tracking-tightest"},[f("span",{class:"font-medium text-ink-secondary"},"Pilot"),f("span",{class:"font-bold text-ink"},"Vault")],-1))]),f("nav",Sy,[(b(),x(wt,null,ce(O,([A,U])=>f("button",{key:U,class:Ot(["flex items-center gap-3 rounded px-3 py-2.5 text-left text-sm transition",k.value===U?"bg-accent-soft font-semibold text-accent-soft-fg":"font-medium text-ink-secondary hover:bg-surface-2"]),onClick:R=>k.value=U},[E(tt,{name:A,size:18,stroke:k.value===U?2.2:1.8},null,8,["name","stroke"]),N(" "+M(U),1)],10,Py)),64))]),f("div",Ty,[f("div",Ly,[f("div",My,[f("span",{class:Ot(["h-2 w-2 rounded-full",g.value?"bg-ready":"bg-caution"])},null,2),f("span",Cy,M(g.value?"Link healthy":"Reconnecting…"),1)]),f("span",Oy,"API gateway · "+M(g.value?"streaming":"retrying"),1)]),f("div",Ey,[f("div",zy,M(rt.value),1),f("div",Ay,[f("div",Iy,M(e.email||"Operator"),1),f("div",Ny,[E(tt,{name:"grid",size:11,class:"shrink-0"}),f("span",{class:"truncate",title:`${Kt.value} · ${ge.value}`},M(Kt.value)+" · "+M(ge.value),9,By)])]),f("button",{class:"text-ink-muted transition hover:text-ink",title:"Log out","aria-label":"Log out",onClick:m[0]||(m[0]=A=>a("logout"))},[E(tt,{name:"logout",size:16})])])])]),f("main",Dy,[f("header",Ry,[f("div",null,[m[8]||(m[8]=f("div",{class:"eyebrow"},"Live operations",-1)),f("h1",Fy,M(k.value),1)]),f("div",Vy,[f("div",Zy,[E(tt,{name:"search",size:16,class:"text-ink-muted"}),xt(f("input",{"onUpdate:modelValue":m[1]||(m[1]=A=>P.value=A),placeholder:"Search drones, routes…",class:"w-full border-none bg-transparent text-sm text-ink outline-none placeholder:text-ink-muted"},null,512),[[Bt,P.value]])]),f("button",{class:"btn-accent flex items-center gap-2",onClick:m[2]||(m[2]=A=>k.value="Live flights")},[E(tt,{name:"radio",size:16}),m[9]||(m[9]=N(" Live flights ",-1))])])]),k.value==="Overview"?(b(),x("div",$y,[f("div",Hy,[(b(!0),x(wt,null,ce(Vt.value,A=>(b(),x("div",{key:A.label,class:"panel p-5"},[f("div",Uy,[f("span",jy,M(A.label),1),E(tt,{name:A.icon,size:16,class:"text-ink-muted"},null,8,["name"])]),f("div",Wy,M(A.value),1),f("span",{class:Ot(["mt-2 block font-mono text-[11px]",ue[A.tone]])},M(A.delta),3)]))),128))]),f("div",Ky,[f("div",qy,[f("div",Gy,[m[11]||(m[11]=f("div",null,[f("div",{class:"eyebrow"},"Airspace"),f("div",{class:"mt-0.5 text-base font-semibold text-ink"},"Live map")],-1)),It.value?(b(),x("span",{key:0,class:Ot(["inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-semibold",Y.success])},[m[10]||(m[10]=f("span",{class:"h-1.5 w-1.5 rounded-full bg-current"},null,-1)),N(M(It.value)+" airborne ",1)],2)):V("",!0)]),E(Xl,{position:de.value,trail:me.value},null,8,["position","trail"])]),f("div",Yy,[f("div",Jy,[m[12]||(m[12]=f("div",null,[f("div",{class:"eyebrow"},"Today"),f("div",{class:"mt-0.5 text-base font-semibold text-ink"},"Schedule")],-1)),E(tt,{name:"clock",size:16,class:"text-ink-muted"})]),f("div",Xy,[E(tt,{name:"calendar",size:24,class:"text-ink-muted"}),m[13]||(m[13]=f("div",{class:"mt-2 text-sm font-medium text-ink-secondary"},"No missions scheduled",-1)),m[14]||(m[14]=f("div",{class:"mt-0.5 text-xs text-ink-muted"},"Scheduling is not wired to a backend yet.",-1))])])]),f("div",Qy,[f("div",t1,[m[17]||(m[17]=f("div",null,[f("div",{class:"eyebrow"},"Fleet"),f("div",{class:"mt-0.5 text-base font-semibold text-ink"},"Aircraft status")],-1)),f("div",e1,[f("span",{class:Ot(["inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-semibold",Y.success])},[m[15]||(m[15]=f("span",{class:"h-1.5 w-1.5 rounded-full bg-current"},null,-1)),N(M(It.value)+" in flight ",1)],2),Wt.value?(b(),x("span",{key:0,class:Ot(["inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-semibold",Y.warning])},[m[16]||(m[16]=f("span",{class:"h-1.5 w-1.5 rounded-full bg-current"},null,-1)),N(M(Wt.value)+" offline ",1)],2)):V("",!0)])]),G.value.length?(b(),x("div",i1,[f("table",s1,[f("thead",null,[f("tr",o1,[(b(),x(wt,null,ce(["Aircraft","Mission","Status","Alt","Battery","Speed",""],A=>f("th",{key:A,class:"border-y border-line bg-surface-2 px-5 py-2.5 font-mono text-[10.5px] font-normal uppercase tracking-caps text-ink-muted"},M(A),1)),64))])]),f("tbody",null,[(b(!0),x(wt,null,ce(G.value,(A,U)=>(b(),x("tr",{key:A.id,class:Ot(["cursor-pointer transition hover:bg-surface-2",Ugn(A.id)},[f("td",a1,M(A.id),1),f("td",l1,M(A.mission),1),f("td",u1,[f("span",{class:Ot(["inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-semibold",Y[A.tone]])},[m[18]||(m[18]=f("span",{class:"h-1.5 w-1.5 rounded-full bg-current"},null,-1)),N(M(A.status),1)],2)]),f("td",c1,M(A.alt),1),f("td",d1,[A.battery!=null?(b(),x("div",f1,[f("div",h1,[f("div",{class:Ot(["h-full",A.battery<40?"bg-caution":"bg-ready"]),style:ks({width:A.battery+"%"})},null,6)]),f("span",p1,M(A.battery)+"%",1)])):(b(),x("span",m1,"—"))]),f("td",g1,[N(M(A.speed==null?"—":A.speed.toFixed(1))+" ",1),m[19]||(m[19]=f("span",{class:"text-ink-muted"},"m/s",-1))]),f("td",_1,[f("button",{class:"btn-ghost inline-flex items-center gap-1.5 whitespace-nowrap",onClick:yc(R=>gn(A.id),["stop"])},[E(tt,{name:"play",size:14}),m[20]||(m[20]=N(" Track ",-1))],8,v1)])],10,r1))),128))])])])):(b(),x("div",n1," No aircraft connected yet. Devices appear here as they come online. "))])])):k.value==="Live flights"?(b(),x("div",y1,[f("div",b1,[f("span",x1,M(h.value||"No device selected"),1),X.value&&!jt.value?(b(),x("span",w1,"Offline")):V("",!0),ut.value.length?(b(),x("div",k1,[(b(!0),x(wt,null,ce(ut.value,A=>(b(),x("button",{key:A,class:Ot(["flex items-center gap-2 rounded border px-3 py-1.5 font-mono text-xs font-bold transition",A===h.value?"border-accent bg-accent-soft text-accent-soft-fg":"border-line bg-surface-1 text-ink-secondary hover:border-line-strong"]),onClick:U=>h.value=A},[f("span",{class:Ot(["h-2 w-2 rounded-full",l[A].online?"bg-ready":"bg-ink-muted"])},null,2),N(" "+M(A),1)],10,S1))),128))])):V("",!0)]),ut.value.length?(b(),x(wt,{key:1},[f("div",{class:Ot(["mb-4 grid gap-3",!jt.value&&X.value?"opacity-60":""]),style:{"grid-template-columns":"repeat(auto-fit, minmax(150px, 1fr))"}},[f("div",T1,[m[23]||(m[23]=f("div",{class:"eyebrow"},"Registration",-1)),f("div",{class:Ot(["mt-1 text-sm font-semibold",jt.value?((T=X.value)==null?void 0:T.registration)==="success"?"text-success-fg":"text-danger-fg":"text-ink"])},M(jt.value&&((B=X.value)!=null&&B.registration)?X.value.registration:"—"),3)]),f("div",L1,[m[24]||(m[24]=f("div",{class:"eyebrow"},"Drone link",-1)),f("div",{class:Ot(["mt-1 text-sm font-semibold",jt.value?(I=X.value)!=null&&I.connected?"text-success-fg":"text-danger-fg":"text-ink"])},M(X.value?jt.value?X.value.connected?"connected":"no drone":"app offline":"—"),3)]),f("div",M1,[m[25]||(m[25]=f("div",{class:"eyebrow"},"Model",-1)),f("div",C1,M(((D=X.value)==null?void 0:D.model)||"—"),1)]),f("div",O1,[m[26]||(m[26]=f("div",{class:"eyebrow"},"Last update",-1)),f("div",E1,M((j=X.value)!=null&&j.lastSeenMs?Ct(ql)(X.value.lastSeenMs):"—"),1)])],2),f("div",z1,[f("div",A1,[m[28]||(m[28]=f("div",{class:"mb-3 eyebrow"},"Battery",-1)),f("div",I1,[f("div",N1,[f("div",{class:Ot(["h-full transition-all",typeof ft.value.batteryPercent=="number"?ft.value.batteryPercent<20?"bg-warning":ft.value.batteryPercent<40?"bg-caution":"bg-ready":""]),style:ks({width:(typeof ft.value.batteryPercent=="number"?ft.value.batteryPercent:0)+"%"})},null,6)]),f("div",B1,[N(M(typeof ft.value.batteryPercent=="number"?ft.value.batteryPercent:"—"),1),m[27]||(m[27]=f("span",{class:"text-sm text-ink-secondary"},"%",-1))])])]),f("div",D1,[m[30]||(m[30]=f("div",{class:"mb-3 eyebrow"},"Altitude",-1)),f("div",R1,[N(M(we(ft.value.altitude,1)),1),m[29]||(m[29]=f("span",{class:"text-sm text-ink-secondary"}," m",-1))])]),f("div",F1,[m[35]||(m[35]=f("div",{class:"mb-3 eyebrow"},"Flight",-1)),f("div",V1,[f("div",Z1,[m[31]||(m[31]=f("span",{class:"text-ink-secondary"},"Mode",-1)),f("b",$1,M(ft.value.flightMode||"—"),1)]),f("div",H1,[m[32]||(m[32]=f("span",{class:"text-ink-secondary"},"Flying",-1)),f("b",U1,M(ft.value.isFlying==null?"—":ft.value.isFlying?"yes":"no"),1)]),f("div",j1,[m[33]||(m[33]=f("span",{class:"text-ink-secondary"},"GPS sats",-1)),f("b",W1,M(ft.value.satelliteCount==null?"—":ft.value.satelliteCount),1)]),f("div",K1,[m[34]||(m[34]=f("span",{class:"text-ink-secondary"},"Speed (H)",-1)),f("b",q1,M(vt.value==null?"—":we(vt.value,2," m/s")),1)])])]),f("div",G1,[m[39]||(m[39]=f("div",{class:"mb-3 eyebrow"},"Position",-1)),f("div",Y1,[f("div",J1,[m[36]||(m[36]=f("span",{class:"text-ink-secondary"},"Latitude",-1)),f("b",X1,M(we(ft.value.latitude,6)),1)]),f("div",Q1,[m[37]||(m[37]=f("span",{class:"text-ink-secondary"},"Longitude",-1)),f("b",tb,M(we(ft.value.longitude,6)),1)]),f("div",eb,[m[38]||(m[38]=f("span",{class:"text-ink-secondary"},"Vert. speed",-1)),f("b",nb,M(we(typeof ft.value.velocityZ=="number"?-ft.value.velocityZ:void 0,2," m/s")),1)])])]),f("div",ib,[m[40]||(m[40]=f("div",{class:"mb-3 eyebrow"},"Track",-1)),E(Xl,{position:de.value,trail:me.value},null,8,["position","trail"])]),f("div",sb,[m[41]||(m[41]=f("div",{class:"mb-3 eyebrow"},"Send command",-1)),f("div",ob,[xt(f("input",{"onUpdate:modelValue":m[3]||(m[3]=A=>F.value=A),class:"field flex-1",placeholder:"command (e.g. startConnection)"},null,512),[[Bt,F.value]]),xt(f("input",{"onUpdate:modelValue":m[4]||(m[4]=A=>nt.value=A),class:"field flex-1",placeholder:"payload JSON (optional)"},null,512),[[Bt,nt.value]]),f("button",{class:"btn-accent",onClick:nn},"Send")]),f("div",rb,M(q.value),1)]),f("div",ab,[m[42]||(m[42]=f("div",{class:"mb-3 eyebrow"},"Event log",-1)),f("div",lb,[(b(!0),x(wt,null,ce(v,(A,U)=>(b(),x("div",{key:U,class:"border-b border-line py-1"},[f("span",ub,M(A.t),1),f("span",cb,M(A.tag),1),f("span",db,M(A.text),1)]))),128))])])])],64)):(b(),x("div",P1,[E(tt,{name:"radio",size:28,class:"text-ink-muted"}),m[21]||(m[21]=f("div",{class:"mt-3 text-sm font-medium text-ink-secondary"},"No aircraft online",-1)),m[22]||(m[22]=f("div",{class:"mt-1 text-xs text-ink-muted"},"Live telemetry appears here once a drone connects.",-1))]))])):k.value==="Settings"?(b(),oe(by,{key:2,email:e.email,role:e.role,organization:e.organization,"organization-name":e.organizationName,onLogout:m[5]||(m[5]=A=>a("logout"))},null,8,["email","role","organization","organization-name"])):(b(),x("div",fb,[f("div",hb,[E(tt,{name:$.value,size:28,class:"text-ink-muted"},null,8,["name"]),f("div",pb,M(k.value),1),k.value==="Drives"?(b(),x("div",mb,[m[43]||(m[43]=N(" Browse and transfer files here once a drive is connected. Configure drives in ",-1)),f("button",{class:"font-semibold text-accent hover:underline",onClick:m[6]||(m[6]=A=>k.value="Settings")},"Settings → Integrations"),m[44]||(m[44]=N(". ",-1))])):(b(),x("div",gb,"This section is part of the console shell and has no backend yet."))])]))])])}}},vb={key:0,class:"h-full"},yb={key:1,class:"grid h-full place-items-center text-ink-muted text-sm"},bb={__name:"App",setup(e){const i=J(!1),o=J(null),a=J("user"),l=J(""),c=J(""),h=J("");function g(k){a.value=k&&k.role||"user",l.value=k&&k.organization||"",c.value=k&&k.organizationName||""}Ls(async()=>{h.value=(await Nh()).apiBase||"";const k=await Ul();k&&(o.value=k.email,g(k),await Yl()),i.value=!0});async function v(k){o.value=k,g(await Ul()),await Yl()}async function P(){lp(),await Dh(),o.value=null,a.value="user",l.value="",c.value=""}return(k,O)=>i.value?(b(),x("div",vb,[o.value?(b(),oe(_b,{key:0,email:o.value,role:a.value,organization:l.value,"organization-name":c.value,onLogout:P},null,8,["email","role","organization","organization-name"])):(b(),oe(Pp,{key:1,"default-api-base":h.value,onSignedIn:v},null,8,["default-api-base"]))])):(b(),x("div",yb,"Loading…"))}};Eh(bb).mount("#app"); diff --git a/Web App/server/dist/assets/index-DBe0h801.css b/Web App/server/dist/assets/index-DBe0h801.css new file mode 100644 index 0000000..9abcdd0 --- /dev/null +++ b/Web App/server/dist/assets/index-DBe0h801.css @@ -0,0 +1 @@ +:root,[data-theme=light]{--navy-950: #0B1730;--navy-900: #0F1E3D;--navy-800: #1B2E52;--navy-700: #26406E;--blue-50: #EAF1FE;--blue-100: #D6E3FD;--blue-200: #B4CDFA;--blue-300: #8FB4F6;--blue-400: #5B93F5;--blue-500: #3D7BF0;--blue-600: #2B62CC;--blue-700: #1F4CA0;--slate-0: #FFFFFF;--slate-50: #F6F7F9;--slate-100: #EEF0F3;--slate-150: #E6E9EE;--slate-200: #DCE0E7;--slate-300: #C5CCD7;--slate-400: #97A1B0;--slate-500: #6B7688;--slate-600: #4C5566;--slate-700: #333B4A;--slate-800: #1E2635;--slate-900: #131A28;--slate-950: #0B111C;--steel: #5A6B85;--green-500: #1F8A5B;--green-100: #DCF1E7;--green-600:#177049;--amber-500: #D9852B;--amber-100: #FBEBD5;--amber-600:#B86C1B;--red-500: #D64545;--red-100: #FBE0E0;--red-600: #B83232;--bg-app: var(--slate-100);--bg-subtle: var(--slate-50);--surface: var(--slate-0);--surface-2: var(--slate-50);--surface-inset: var(--slate-100);--border: var(--slate-200);--border-strong: var(--slate-300);--border-subtle: var(--slate-150);--text-primary: var(--navy-900);--text-secondary:var(--steel);--text-tertiary: var(--slate-400);--text-inverse: var(--slate-0);--text-on-accent:#FFFFFF;--accent: var(--blue-500);--accent-hover: var(--blue-600);--accent-active: var(--blue-700);--accent-soft: var(--blue-50);--accent-soft-fg:var(--blue-700);--focus-ring: color-mix(in srgb, var(--blue-500) 45%, transparent);--success: var(--green-500);--success-soft: var(--green-100);--success-fg: var(--green-600);--warning: var(--amber-500);--warning-soft: var(--amber-100);--warning-fg: var(--amber-600);--danger: var(--red-500);--danger-soft: var(--red-100);--danger-fg: var(--red-600);--overlay: color-mix(in srgb, var(--navy-950) 55%, transparent);--font-sans: "Space Grotesk", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;--font-mono: "Space Mono", ui-monospace, "SF Mono", "JetBrains Mono", monospace;--radius-xs: 4px;--radius-sm: 6px;--radius-md: 10px;--radius-lg: 14px;--radius-xl: 20px;--radius-pill: 999px;--shadow-xs: 0 1px 2px rgba(15,30,61,.06);--shadow-sm: 0 1px 2px rgba(15,30,61,.06), 0 1px 3px rgba(15,30,61,.04);--shadow-md: 0 2px 4px rgba(15,30,61,.06), 0 6px 16px rgba(15,30,61,.08);--shadow-lg: 0 8px 24px rgba(15,30,61,.1), 0 2px 6px rgba(15,30,61,.06);--ease-standard: cubic-bezier(.4, 0, .2, 1);--ease-out: cubic-bezier(.16, 1, .3, 1);--dur-fast: .12s;--dur-base: .2s;color-scheme:light}[data-theme=dark]{--bg-app: var(--navy-950);--bg-subtle: var(--slate-950);--surface: #10203F;--surface-2: #142748;--surface-inset: var(--navy-950);--border: color-mix(in srgb, #FFFFFF 10%, transparent);--border-strong: color-mix(in srgb, #FFFFFF 18%, transparent);--border-subtle: color-mix(in srgb, #FFFFFF 6%, transparent);--text-primary: #F4F7FC;--text-secondary:#8FA0BE;--text-tertiary: #5E6E8C;--text-inverse: var(--navy-900);--text-on-accent:#FFFFFF;--accent: var(--blue-400);--accent-hover: var(--blue-300);--accent-active: var(--blue-200);--accent-soft: color-mix(in srgb, var(--blue-500) 18%, transparent);--accent-soft-fg:var(--blue-300);--focus-ring: color-mix(in srgb, var(--blue-400) 55%, transparent);--success:var(--green-500);--success-soft: color-mix(in srgb, var(--green-500) 22%, transparent);--success-fg:#5FD3A0;--warning:var(--amber-500);--warning-soft: color-mix(in srgb, var(--amber-500) 22%, transparent);--warning-fg:#F0B26A;--danger: var(--red-500);--danger-soft: color-mix(in srgb, var(--red-500) 22%, transparent);--danger-fg: #F08A8A;--overlay: color-mix(in srgb, #000000 62%, transparent);--shadow-xs: 0 1px 2px rgba(0,0,0,.35);--shadow-sm: 0 1px 3px rgba(0,0,0,.4);--shadow-md: 0 4px 12px rgba(0,0,0,.45);--shadow-lg: 0 12px 32px rgba(0,0,0,.5);color-scheme:dark}*,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:Space Grotesk,ui-sans-serif,system-ui,-apple-system,Segoe UI,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:Space Mono,ui-monospace,SF Mono,JetBrains Mono,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.tabular{font-variant-numeric:tabular-nums}.eyebrow{font-family:Space Mono,ui-monospace,SF Mono,JetBrains Mono,monospace;font-size:11px;text-transform:uppercase;letter-spacing:.14em;color:var(--text-tertiary)}.readout{font-variant-numeric:tabular-nums;font-family:Space Mono,ui-monospace,SF Mono,JetBrains Mono,monospace;font-size:30px;line-height:1;font-weight:500;color:var(--text-primary)}.panel{border-radius:14px;border-width:1px;border-color:var(--border);background-color:var(--surface);--tw-shadow: var(--shadow-xs);--tw-shadow-colored: var(--shadow-xs);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.pill{border-radius:10px;border-width:1px;border-color:var(--border);background-color:var(--surface-2);padding:.5rem .75rem}.field{width:100%;border-radius:10px;border-width:1px;border-color:var(--border);background-color:var(--surface-2);padding:.625rem .75rem;font-size:.875rem;line-height:1.25rem;color:var(--text-primary);outline:2px solid transparent;outline-offset:2px;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.field::-moz-placeholder{color:var(--text-tertiary)}.field::placeholder{color:var(--text-tertiary)}.field{transition-duration:var(--dur-fast)}.field:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--focus-ring)}.btn-accent{border-radius:10px;background-color:var(--accent);padding:.625rem 1rem;font-size:.875rem;line-height:1.25rem;font-weight:600;--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1));transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.btn-accent:hover{background-color:var(--accent-hover)}.btn-accent:active{--tw-translate-y: 1px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.btn-accent:disabled{opacity:.5}.btn-accent{transition-duration:var(--dur-fast)}.btn-ghost{border-radius:10px;border-width:1px;border-color:var(--border);background-color:var(--surface);padding:.375rem .75rem;font-size:.875rem;line-height:1.25rem;color:var(--text-secondary);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.btn-ghost:hover{border-color:var(--border-strong);color:var(--text-primary)}.btn-ghost:active{--tw-translate-y: 1px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.btn-ghost{transition-duration:var(--dur-fast)}.btn-icon{display:grid;height:2.25rem;width:2.25rem;place-items:center;border-radius:10px;border-width:1px;border-color:var(--border);background-color:var(--surface);color:var(--text-secondary);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.btn-icon:hover{border-color:var(--border-strong);color:var(--text-primary)}.btn-icon{transition-duration:var(--dur-fast)}.visible{visibility:visible}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-y-0{top:0;bottom:0}.bottom-5{bottom:1.25rem}.right-0{right:0}.right-5{right:1.25rem}.top-0{top:0}.top-5{top:1.25rem}.z-10{z-index:10}.z-20{z-index:20}.col-span-2{grid-column:span 2 / span 2}.mx-auto{margin-left:auto;margin-right:auto}.my-4{margin-top:1rem;margin-bottom:1rem}.-mb-px{margin-bottom:-1px}.mb-1{margin-bottom:.25rem}.mb-1\.5{margin-bottom:.375rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-3\.5{margin-bottom:.875rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-6{margin-bottom:1.5rem}.ml-1{margin-left:.25rem}.ml-1\.5{margin-left:.375rem}.ml-auto{margin-left:auto}.mr-1{margin-right:.25rem}.mr-1\.5{margin-right:.375rem}.mr-2{margin-right:.5rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-2{margin-top:.5rem}.mt-2\.5{margin-top:.625rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-auto{margin-top:auto}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-1\.5{height:.375rem}.h-10{height:2.5rem}.h-16{height:4rem}.h-2{height:.5rem}.h-28{height:7rem}.h-4{height:1rem}.h-6{height:1.5rem}.h-8{height:2rem}.h-9{height:2.25rem}.h-\[180px\]{height:180px}.h-\[18px\]{height:18px}.h-\[320px\]{height:320px}.h-full{height:100%}.min-h-\[16px\]{min-height:16px}.w-1\.5{width:.375rem}.w-10{width:2.5rem}.w-11{width:2.75rem}.w-12{width:3rem}.w-16{width:4rem}.w-2{width:.5rem}.w-24{width:6rem}.w-28{width:7rem}.w-32{width:8rem}.w-4{width:1rem}.w-48{width:12rem}.w-56{width:14rem}.w-60{width:15rem}.w-64{width:16rem}.w-8{width:2rem}.w-9{width:2.25rem}.w-\[18px\]{width:18px}.w-\[380px\]{width:380px}.w-full{width:100%}.min-w-0{min-width:0px}.max-w-\[1240px\]{max-width:1240px}.max-w-\[1280px\]{max-width:1280px}.max-w-\[280px\]{max-width:280px}.max-w-\[360px\]{max-width:360px}.max-w-\[420px\]{max-width:420px}.max-w-\[520px\]{max-width:520px}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}.border-collapse{border-collapse:collapse}.translate-x-1{--tw-translate-x: .25rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.translate-x-6{--tw-translate-x: 1.5rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-pointer{cursor:pointer}.select-all{-webkit-user-select:all;-moz-user-select:all;user-select:all}.resize-none{resize:none}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.grid-cols-\[1\.6fr_1fr\]{grid-template-columns:1.6fr 1fr}.grid-cols-\[210px_1fr\]{grid-template-columns:210px 1fr}.grid-cols-\[248px_1fr\]{grid-template-columns:248px 1fr}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.place-items-center{place-items:center}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-between{justify-content:space-between}.gap-0\.5{gap:.125rem}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-2\.5{gap:.625rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.space-y-1\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.375rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.375rem * var(--tw-space-y-reverse))}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.whitespace-nowrap{white-space:nowrap}.break-all{word-break:break-all}.rounded,.rounded-\[10px\]{border-radius:10px}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:14px}.border{border-width:1px}.border-y{border-top-width:1px;border-bottom-width:1px}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-r{border-right-width:1px}.border-none{border-style:none}.border-accent{border-color:var(--accent)}.border-line{border-color:var(--border)}.border-line-strong{border-color:var(--border-strong)}.border-transparent{border-color:transparent}.bg-\[var\(--navy-800\)\]{background-color:var(--navy-800)}.bg-accent{background-color:var(--accent)}.bg-accent-soft{background-color:var(--accent-soft)}.bg-amber{background-color:var(--warning)}.bg-amber-soft{background-color:var(--warning-soft)}.bg-caution{background-color:var(--warning)}.bg-current{background-color:currentColor}.bg-danger{background-color:var(--danger)}.bg-danger-soft{background-color:var(--danger-soft)}.bg-ink-muted{background-color:var(--text-tertiary)}.bg-line{background-color:var(--border)}.bg-ready,.bg-success{background-color:var(--success)}.bg-success-soft{background-color:var(--success-soft)}.bg-surface-1{background-color:var(--surface)}.bg-surface-2{background-color:var(--surface-2)}.bg-transparent{background-color:transparent}.bg-warning{background-color:var(--danger)}.bg-white{--tw-bg-opacity: 1;background-color:rgb(255 255 255 / var(--tw-bg-opacity, 1))}.object-cover{-o-object-fit:cover;object-fit:cover}.p-0{padding:0}.p-0\.5{padding:.125rem}.p-10{padding:2.5rem}.p-16{padding:4rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-7{padding:1.75rem}.p-8{padding:2rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-7{padding-left:1.75rem;padding-right:1.75rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-10{padding-top:2.5rem;padding-bottom:2.5rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-3\.5{padding-top:.875rem;padding-bottom:.875rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-5{padding-top:1.25rem;padding-bottom:1.25rem}.pb-4{padding-bottom:1rem}.pb-5{padding-bottom:1.25rem}.pb-8{padding-bottom:2rem}.pr-10{padding-right:2.5rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.font-mono{font-family:Space Mono,ui-monospace,SF Mono,JetBrains Mono,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-\[10\.5px\]{font-size:10.5px}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[13px\]{font-size:13px}.text-\[19px\]{font-size:19px}.text-\[22px\]{font-size:22px}.text-\[34px\]{font-size:34px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-mode{font-size:18px;line-height:1.2;letter-spacing:-.02em;font-weight:600}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.leading-none{line-height:1}.leading-tight{line-height:1.25}.tracking-\[0\.3em\]{letter-spacing:.3em}.tracking-caps{letter-spacing:.14em}.tracking-tightest{letter-spacing:-.02em}.text-accent{color:var(--accent)}.text-accent-soft-fg{color:var(--accent-soft-fg)}.text-amber-fg{color:var(--warning-fg)}.text-danger-fg{color:var(--danger-fg)}.text-ink{color:var(--text-primary)}.text-ink-muted{color:var(--text-tertiary)}.text-ink-secondary{color:var(--text-secondary)}.text-success-fg{color:var(--success-fg)}.text-white{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.accent-\[var\(--danger\)\]{accent-color:var(--danger)}.opacity-60{opacity:.6}.shadow-md{--tw-shadow: var(--shadow-md);--tw-shadow-colored: var(--shadow-md);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow: var(--shadow-sm);--tw-shadow-colored: var(--shadow-sm);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-xs{--tw-shadow: var(--shadow-xs);--tw-shadow-colored: var(--shadow-xs);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.outline-none{outline:2px solid transparent;outline-offset:2px}.blur{--tw-blur: blur(8px);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-filter{-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}html,body,#app{height:100%}html{background-color:var(--bg-app);transition:background-color var(--dur-base) var(--ease-standard)}body{font-family:Space Grotesk,ui-sans-serif,system-ui,-apple-system,Segoe UI,sans-serif;color:var(--text-primary);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}#app{background-color:var(--bg-app);min-height:100vh;transition:background-color var(--dur-base) var(--ease-standard)}html.reduce-motion *,html.reduce-motion *:before,html.reduce-motion *:after{transition-duration:.01ms!important;animation-duration:.01ms!important;animation-iteration-count:1!important;scroll-behavior:auto!important}.leaflet-container{background:var(--surface-inset);font-family:var(--font-sans)}.leaflet-control-attribution{background:color-mix(in srgb,var(--surface) 82%,transparent)!important;color:var(--text-tertiary)!important}.leaflet-control-attribution a{color:var(--text-secondary)!important}.placeholder\:text-ink-muted::-moz-placeholder{color:var(--text-tertiary)}.placeholder\:text-ink-muted::placeholder{color:var(--text-tertiary)}.first\:mt-0:first-child{margin-top:0}.last\:border-0:last-child{border-width:0px}.hover\:border-line-strong:hover{border-color:var(--border-strong)}.hover\:bg-danger-soft:hover{background-color:var(--danger-soft)}.hover\:bg-surface-2:hover{background-color:var(--surface-2)}.hover\:text-ink:hover{color:var(--text-primary)}.hover\:text-ink-secondary:hover{color:var(--text-secondary)}.hover\:underline:hover{text-decoration-line:underline}.hover\:brightness-110:hover{--tw-brightness: brightness(1.1);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.enabled\:hover\:brightness-110:hover:enabled{--tw-brightness: brightness(1.1);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-40:disabled{opacity:.4}.disabled\:opacity-50:disabled{opacity:.5}@media(max-width:1100px){.max-\[1100px\]\:hidden{display:none}.max-\[1100px\]\:grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.max-\[1100px\]\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:900px){.max-\[900px\]\:hidden{display:none}.max-\[900px\]\:grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}}@media(max-width:820px){.max-\[820px\]\:col-span-1{grid-column:span 1 / span 1}.max-\[820px\]\:grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}}@media(max-width:760px){.max-\[760px\]\:grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.max-\[760px\]\:flex-row{flex-direction:row}.max-\[760px\]\:overflow-x-auto{overflow-x:auto}}@media(max-width:560px){.max-\[560px\]\:grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}}@media(min-width:640px){.sm\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}.sm\:justify-between{justify-content:space-between}}.fade-enter-active[data-v-4fe25eb7],.fade-leave-active[data-v-4fe25eb7]{transition:opacity .2s}.fade-enter-from[data-v-4fe25eb7],.fade-leave-to[data-v-4fe25eb7]{opacity:0} diff --git a/Web App/server/dist/favicon.svg b/Web App/server/dist/favicon.svg new file mode 100644 index 0000000..65cdb50 --- /dev/null +++ b/Web App/server/dist/favicon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Web App/server/dist/index.html b/Web App/server/dist/index.html new file mode 100644 index 0000000..6f0d199 --- /dev/null +++ b/Web App/server/dist/index.html @@ -0,0 +1,44 @@ + + + + + + + + + + + + PilotVault — Control Panel + + + + +

+ + diff --git a/Web App/server/go.mod b/Web App/server/go.mod new file mode 100644 index 0000000..466acbc --- /dev/null +++ b/Web App/server/go.mod @@ -0,0 +1,5 @@ +module pilotvault/webapp + +go 1.24 + +require github.com/gorilla/websocket v1.5.3 diff --git a/Web App/server/go.sum b/Web App/server/go.sum new file mode 100644 index 0000000..25a9fc4 --- /dev/null +++ b/Web App/server/go.sum @@ -0,0 +1,2 @@ +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= diff --git a/Web App/server/main.go b/Web App/server/main.go new file mode 100644 index 0000000..a0ef395 --- /dev/null +++ b/Web App/server/main.go @@ -0,0 +1,140 @@ +// Command webapp is the PilotVault control-panel BFF. It serves the embedded +// Vue single-page app and proxies /bff/* to the API Server, so the browser only +// ever talks to this server (same-origin). The API Server is the gateway to +// PocketBase and to the live Fly App data; the browser never contacts either +// directly. +package main + +import ( + "embed" + "io/fs" + "log" + "net/http" + "os" + "strings" + "time" +) + +//go:embed all:dist +var distFS embed.FS + +// App is the control-panel BFF. It talks ONLY to the API Server; the API Server +// is the gateway to PocketBase and to the live Fly App data. The browser never +// contacts PocketBase or the API Server directly. +type App struct { + apiBase string // e.g. http://localhost:8080 +} + +func main() { + log.SetFlags(log.LstdFlags | log.Lmsgprefix) + log.SetPrefix("[web] ") + + loadDotEnv(".env") + addr := envOr("ADDR", ":8090") + app := &App{apiBase: strings.TrimRight(envOr("API_BASE", "http://localhost:8080"), "/")} + + mux := http.NewServeMux() + // Auth (proxied to API Server → PocketBase) + mux.HandleFunc("POST /bff/login", app.handleLogin) + mux.HandleFunc("POST /bff/logout", app.handleLogout) + mux.HandleFunc("GET /bff/me", app.handleMe) + mux.HandleFunc("GET /bff/config", app.handleConfig) + // Data (proxied to the API Server; gated by session cookie) + mux.HandleFunc("GET /bff/devices", app.requireAuth(app.handleDevices)) + mux.HandleFunc("GET /bff/devices/{id}/track", app.requireAuth(app.handleTrack)) + mux.HandleFunc("POST /bff/devices/{id}/command", app.requireAuth(app.handleCommand)) + // User preferences (persisted in PocketBase via the API Server; needs the token) + mux.HandleFunc("GET /bff/preferences", app.requireAuth(app.handleGetPrefs)) + mux.HandleFunc("PUT /bff/preferences", app.requireAuth(app.handlePutPrefs)) + // Plugin integrations (OpenSky) — per-user/per-org settings via the API Server + mux.HandleFunc("GET /bff/integrations/opensky", app.requireAuth(app.handleGetOpenSky)) + mux.HandleFunc("PUT /bff/integrations/opensky", app.requireAuth(app.handlePutOpenSky)) + mux.HandleFunc("POST /bff/integrations/opensky/health", app.requireAuth(app.handleOpenSkyHealth)) + // Plugin integrations (File transfer: FTP/SFTP) — per-user/per-org settings + mux.HandleFunc("GET /bff/integrations/filetransfer", app.requireAuth(app.handleGetFileTransfer)) + mux.HandleFunc("PUT /bff/integrations/filetransfer", app.requireAuth(app.handlePutFileTransfer)) + mux.HandleFunc("POST /bff/integrations/filetransfer/health", app.requireAuth(app.handleFileTransferHealth)) + // Plugin integrations (Local storage: host filesystem) — per-user/per-org isolated folders + mux.HandleFunc("GET /bff/integrations/localstorage", app.requireAuth(app.handleGetLocalStorage)) + mux.HandleFunc("PUT /bff/integrations/localstorage", app.requireAuth(app.handlePutLocalStorage)) + mux.HandleFunc("POST /bff/integrations/localstorage/health", app.requireAuth(app.handleLocalStorageHealth)) + // Plugin integrations (WebDAV) — per-user/per-org settings + mux.HandleFunc("GET /bff/integrations/webdav", app.requireAuth(app.handleGetWebDav)) + mux.HandleFunc("PUT /bff/integrations/webdav", app.requireAuth(app.handlePutWebDav)) + mux.HandleFunc("POST /bff/integrations/webdav/health", app.requireAuth(app.handleWebDavHealth)) + // User-management (role + org scoping enforced by the API Server) + mux.HandleFunc("GET /bff/users", app.requireAuth(app.handleListUsers)) + mux.HandleFunc("POST /bff/users", app.requireAuth(app.handleCreateUser)) + mux.HandleFunc("PATCH /bff/users/{id}", app.requireAuth(app.handleUpdateUser)) + mux.HandleFunc("DELETE /bff/users/{id}", app.requireAuth(app.handleDeleteUser)) + // Organizations (create/edit/delete are superadmin-only upstream) + mux.HandleFunc("GET /bff/orgs", app.requireAuth(app.handleListOrgs)) + mux.HandleFunc("POST /bff/orgs", app.requireAuth(app.handleCreateOrg)) + mux.HandleFunc("PATCH /bff/orgs/{id}", app.requireAuth(app.handleUpdateOrg)) + mux.HandleFunc("DELETE /bff/orgs/{id}", app.requireAuth(app.handleDeleteOrg)) + mux.HandleFunc("GET /bff/ws", app.handleWS) + mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "api": app.apiBase}) + }) + + dist, err := fs.Sub(distFS, "dist") + if err != nil { + log.Fatalf("embed dist: %v", err) + } + mux.Handle("/", noCache(http.FileServer(http.FS(dist)))) + + srv := &http.Server{ + Addr: addr, + Handler: logRequests(mux), + ReadHeaderTimeout: 10 * time.Second, + } + log.Printf("Web App (control panel) on http://localhost%s → API Server %s", addr, app.apiBase) + log.Fatal(srv.ListenAndServe()) +} + +func envOr(k, d string) string { + if v := os.Getenv(k); v != "" { + return v + } + return d +} + +// loadDotEnv loads KEY=VALUE pairs from a .env file into the process env if they +// are not already set. It is intentionally minimal (no quoting rules beyond +// trimming surrounding quotes). +func loadDotEnv(path string) { + data, err := os.ReadFile(path) + if err != nil { + return + } + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + key, val, ok := strings.Cut(line, "=") + if !ok { + continue + } + key = strings.TrimSpace(key) + val = strings.Trim(strings.TrimSpace(val), `"'`) + if _, exists := os.LookupEnv(key); !exists { + _ = os.Setenv(key, val) + } + } +} + +func noCache(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store, must-revalidate") + next.ServeHTTP(w, r) + }) +} + +func logRequests(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + next.ServeHTTP(w, r) + log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start).Round(time.Millisecond)) + }) +} diff --git a/Web App/web/.gitignore b/Web App/web/.gitignore new file mode 100644 index 0000000..e5537be --- /dev/null +++ b/Web App/web/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +*.local diff --git a/Web App/web/index.html b/Web App/web/index.html new file mode 100644 index 0000000..e5d5a75 --- /dev/null +++ b/Web App/web/index.html @@ -0,0 +1,43 @@ + + + + + + + + + + + + PilotVault — Control Panel + + +
+ + + diff --git a/Web App/web/package-lock.json b/Web App/web/package-lock.json new file mode 100644 index 0000000..66455c8 --- /dev/null +++ b/Web App/web/package-lock.json @@ -0,0 +1,2418 @@ +{ + "name": "web-app-ui", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "web-app-ui", + "dependencies": { + "leaflet": "^1.9.4", + "vue": "^3.5.13" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.1", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.17", + "vite": "^6.0.7" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.39.tgz", + "integrity": "sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/shared": "3.5.39", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.39.tgz", + "integrity": "sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.39.tgz", + "integrity": "sha512-d0ki86iOyN8LoZPBmk5SJWNwHP19CnDDCfuo//+2WJa2g5Ke0Jay983PIBIcSSzldC68I8DrD5GrHV3OSDfodg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/compiler-core": "3.5.39", + "@vue/compiler-dom": "3.5.39", + "@vue/compiler-ssr": "3.5.39", + "@vue/shared": "3.5.39", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.15", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.39.tgz", + "integrity": "sha512-Ce7/wvwMHai74bdszfXExdazFigYnlF9zgCmEQUcM1j0fOymlouZ7XilTYNo8oUjhlnjYOZbGrcYKuqjz89Ucw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.39.tgz", + "integrity": "sha512-TpsuBJ9gGlZa5d23XcM2y8EXanz9dZeVDQBXRwzy46ItgvM+rWpzs+UVM0wcRLxGvcav0HE5jz2gNL53xlRAog==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.39.tgz", + "integrity": "sha512-9GLtNyRvPAUMbX+7ono0RC2j0guo2LXVi8LvcmAooImACUKm0oFf0jjwbX8/H0AE/t1nxhAkn8RSl9PMCzzxZw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.39.tgz", + "integrity": "sha512-7Y6aAGboKcXAZ3ECuUy7RrS5yy2r47dhTp2SKaJmYxjopImaVFaNa5Ne66NwGovsrxVAl5S5rwc7m22UG7Lmww==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.39", + "@vue/runtime-core": "3.5.39", + "@vue/shared": "3.5.39", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.39.tgz", + "integrity": "sha512-yZSakiAGw85rZfG7UM8akMnIF+FmeiNk47uvHf2nVBBSe+dIKUhZuZq9+XgJhbV3nS5Z4ALH23/MpXofW+mbcw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.39", + "@vue/shared": "3.5.39" + }, + "peerDependencies": { + "vue": "3.5.39" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.39.tgz", + "integrity": "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==", + "license": "MIT" + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.5.2", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.2.tgz", + "integrity": "sha512-rD5t5DwOjJdmSORcTq64j8MawTC+tbQ+HHqjR4NDumamy/ambn1UJrlKL+KdwujWxMkFjPM3pPHOEA9tl4767Q==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.4", + "caniuse-lite": "^1.0.30001799", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.40", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz", + "integrity": "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", + "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.38", + "caniuse-lite": "^1.0.30001799", + "electron-to-chromium": "^1.5.376", + "node-releases": "^2.0.48", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.381", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.381.tgz", + "integrity": "sha512-n9Wa6yB+vDsGuA8AKbl/0z7HbvWqt5jxIdvr1IUicd0ryPrk7/xzwqLv8D9AbbvZ6avVNtXYLTfmgFHkwkyelg==", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.50", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", + "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vue": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.39.tgz", + "integrity": "sha512-xmZCYabFGcirU8r0fTuvl/LICc1OU620rnqepaJDL/a141ZigkG7AyaxQLdqJ02ZRYzWe6YPaDHeQx7MfknQfA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.39", + "@vue/compiler-sfc": "3.5.39", + "@vue/runtime-dom": "3.5.39", + "@vue/server-renderer": "3.5.39", + "@vue/shared": "3.5.39" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + } + } +} diff --git a/Web App/web/package.json b/Web App/web/package.json new file mode 100644 index 0000000..5760145 --- /dev/null +++ b/Web App/web/package.json @@ -0,0 +1,20 @@ +{ + "name": "web-app-panel", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build" + }, + "dependencies": { + "leaflet": "^1.9.4", + "vue": "^3.5.13" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.1", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.17", + "vite": "^6.0.7" + } +} diff --git a/Web App/web/postcss.config.js b/Web App/web/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/Web App/web/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/Web App/web/public/favicon.svg b/Web App/web/public/favicon.svg new file mode 100644 index 0000000..65cdb50 --- /dev/null +++ b/Web App/web/public/favicon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Web App/web/src/App.vue b/Web App/web/src/App.vue new file mode 100644 index 0000000..c92bcda --- /dev/null +++ b/Web App/web/src/App.vue @@ -0,0 +1,53 @@ + + + diff --git a/Web App/web/src/api.js b/Web App/web/src/api.js new file mode 100644 index 0000000..f63a067 --- /dev/null +++ b/Web App/web/src/api.js @@ -0,0 +1,263 @@ +// All requests go to the Web App's BFF (same origin). The BFF forwards to the +// API Server, which is the gateway to PocketBase + the live Mobile App data. + +export async function getConfig() { + try { + const r = await fetch('/bff/config') + return r.ok ? await r.json() : { apiBase: '' } + } catch { + return { apiBase: '' } + } +} + +export async function getMe() { + try { + const r = await fetch('/bff/me') + return r.ok ? await r.json() : null + } catch { + return null + } +} + +export async function login(email, password, apiBase) { + const r = await fetch('/bff/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password, apiBase }), + }) + return { ok: r.ok, status: r.status, body: await r.json().catch(() => ({})) } +} + +export async function logout() { + try { + await fetch('/bff/logout', { method: 'POST' }) + } catch { + /* ignore */ + } +} + +export async function getDevices() { + try { + const r = await fetch('/bff/devices') + return r.ok ? await r.json() : [] + } catch { + return [] + } +} + +export async function getUsers() { + try { + const r = await fetch('/bff/users') + if (!r.ok) return { ok: false, status: r.status, users: [] } + const d = await r.json() + return { ok: true, status: 200, users: d.users || [] } + } catch { + return { ok: false, status: 0, users: [] } + } +} + +export async function createUser(email, password, role, organization) { + const r = await fetch('/bff/users', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password, role, organization }), + }) + return { ok: r.ok, status: r.status, body: await r.json().catch(() => ({})) } +} + +export async function updateUser(id, changes) { + const r = await fetch(`/bff/users/${encodeURIComponent(id)}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(changes), + }) + return { ok: r.ok, status: r.status, body: await r.json().catch(() => ({})) } +} + +export async function deleteUser(id) { + const r = await fetch(`/bff/users/${encodeURIComponent(id)}`, { method: 'DELETE' }) + return { ok: r.ok, status: r.status, body: await r.json().catch(() => ({})) } +} + +export async function getOrgs() { + try { + const r = await fetch('/bff/orgs') + if (!r.ok) return { ok: false, status: r.status, organizations: [] } + const d = await r.json() + return { ok: true, status: 200, organizations: d.organizations || [] } + } catch { + return { ok: false, status: 0, organizations: [] } + } +} + +export async function createOrg(name) { + const r = await fetch('/bff/orgs', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }), + }) + return { ok: r.ok, status: r.status, body: await r.json().catch(() => ({})) } +} + +export async function updateOrg(id, name) { + const r = await fetch(`/bff/orgs/${encodeURIComponent(id)}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }), + }) + return { ok: r.ok, status: r.status, body: await r.json().catch(() => ({})) } +} + +export async function deleteOrg(id) { + const r = await fetch(`/bff/orgs/${encodeURIComponent(id)}`, { method: 'DELETE' }) + return { ok: r.ok, status: r.status, body: await r.json().catch(() => ({})) } +} + +export async function getPreferences() { + try { + const r = await fetch('/bff/preferences') + if (!r.ok) return null + const d = await r.json() + return d && typeof d.preferences === 'object' ? d.preferences : null + } catch { + return null + } +} + +export async function savePreferences(preferences) { + try { + const r = await fetch('/bff/preferences', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ preferences }), + }) + return r.ok + } catch { + return false + } +} + +/* ---------- Plugin integrations: OpenSky ---------- */ + +// Resolved OpenSky settings for the current user (cascade + masked secrets). +export async function getOpenSky() { + try { + const r = await fetch('/bff/integrations/opensky') + if (!r.ok) return { ok: false, status: r.status, body: await r.json().catch(() => ({})) } + return { ok: true, status: 200, body: await r.json() } + } catch { + return { ok: false, status: 0, body: {} } + } +} + +// Save the caller's editable layer. payload: { enabled?, config? }. +export async function saveOpenSky(payload) { + const r = await fetch('/bff/integrations/opensky', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) + return { ok: r.ok, status: r.status, body: await r.json().catch(() => ({})) } +} + +// Run a live health probe against the caller's resolved config. +export async function testOpenSky() { + const r = await fetch('/bff/integrations/opensky/health', { method: 'POST' }) + return { ok: r.ok, status: r.status, body: await r.json().catch(() => ({})) } +} + +/* ---------- Plugin integrations: File transfer (FTP/SFTP) ---------- */ + +// Resolved file-transfer settings for the current user (cascade + masked secrets). +export async function getFileTransfer() { + try { + const r = await fetch('/bff/integrations/filetransfer') + if (!r.ok) return { ok: false, status: r.status, body: await r.json().catch(() => ({})) } + return { ok: true, status: 200, body: await r.json() } + } catch { + return { ok: false, status: 0, body: {} } + } +} + +// Save the caller's editable layer. payload: { scope?, enabled?, config? }. +export async function saveFileTransfer(payload) { + const r = await fetch('/bff/integrations/filetransfer', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) + return { ok: r.ok, status: r.status, body: await r.json().catch(() => ({})) } +} + +// Run a live connect/list probe against the caller's resolved config. +export async function testFileTransfer() { + const r = await fetch('/bff/integrations/filetransfer/health', { method: 'POST' }) + return { ok: r.ok, status: r.status, body: await r.json().catch(() => ({})) } +} + +/* ---------- Plugin integrations: Local storage (host filesystem) ---------- */ + +// Resolved local-storage settings for the current user (isolated folder + cascade). +export async function getLocalStorage() { + try { + const r = await fetch('/bff/integrations/localstorage') + if (!r.ok) return { ok: false, status: r.status, body: await r.json().catch(() => ({})) } + return { ok: true, status: 200, body: await r.json() } + } catch { + return { ok: false, status: 0, body: {} } + } +} + +// Save the caller's editable layer. payload: { scope?, enabled?, config? }. +export async function saveLocalStorage(payload) { + const r = await fetch('/bff/integrations/localstorage', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) + return { ok: r.ok, status: r.status, body: await r.json().catch(() => ({})) } +} + +// Run a live probe against the caller's isolated folder. +export async function testLocalStorage() { + const r = await fetch('/bff/integrations/localstorage/health', { method: 'POST' }) + return { ok: r.ok, status: r.status, body: await r.json().catch(() => ({})) } +} + +/* ---------- Plugin integrations: WebDAV ---------- */ + +// Resolved WebDAV settings for the current user (cascade + masked secrets). +export async function getWebDav() { + try { + const r = await fetch('/bff/integrations/webdav') + if (!r.ok) return { ok: false, status: r.status, body: await r.json().catch(() => ({})) } + return { ok: true, status: 200, body: await r.json() } + } catch { + return { ok: false, status: 0, body: {} } + } +} + +// Save the caller's editable layer. payload: { scope?, enabled?, config? }. +export async function saveWebDav(payload) { + const r = await fetch('/bff/integrations/webdav', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) + return { ok: r.ok, status: r.status, body: await r.json().catch(() => ({})) } +} + +// Run a live connect/list probe against the caller's resolved config. +export async function testWebDav() { + const r = await fetch('/bff/integrations/webdav/health', { method: 'POST' }) + return { ok: r.ok, status: r.status, body: await r.json().catch(() => ({})) } +} + +export async function sendCommand(id, command, payload) { + const r = await fetch(`/bff/devices/${encodeURIComponent(id)}/command`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ command, payload }), + }) + return { ok: r.ok, body: await r.json().catch(() => ({})) } +} diff --git a/Web App/web/src/components/BrandMark.vue b/Web App/web/src/components/BrandMark.vue new file mode 100644 index 0000000..fc14883 --- /dev/null +++ b/Web App/web/src/components/BrandMark.vue @@ -0,0 +1,14 @@ + + + diff --git a/Web App/web/src/components/Dashboard.vue b/Web App/web/src/components/Dashboard.vue new file mode 100644 index 0000000..9faf718 --- /dev/null +++ b/Web App/web/src/components/Dashboard.vue @@ -0,0 +1,647 @@ + + + diff --git a/Web App/web/src/components/DeviceMap.vue b/Web App/web/src/components/DeviceMap.vue new file mode 100644 index 0000000..1afbb1d --- /dev/null +++ b/Web App/web/src/components/DeviceMap.vue @@ -0,0 +1,49 @@ + + + diff --git a/Web App/web/src/components/Icon.vue b/Web App/web/src/components/Icon.vue new file mode 100644 index 0000000..f02a39c --- /dev/null +++ b/Web App/web/src/components/Icon.vue @@ -0,0 +1,69 @@ + + + diff --git a/Web App/web/src/components/LoginView.vue b/Web App/web/src/components/LoginView.vue new file mode 100644 index 0000000..e052cc1 --- /dev/null +++ b/Web App/web/src/components/LoginView.vue @@ -0,0 +1,89 @@ + + + diff --git a/Web App/web/src/components/Settings.vue b/Web App/web/src/components/Settings.vue new file mode 100644 index 0000000..c6abba9 --- /dev/null +++ b/Web App/web/src/components/Settings.vue @@ -0,0 +1,2373 @@ + + + + + diff --git a/Web App/web/src/components/ThemeToggle.vue b/Web App/web/src/components/ThemeToggle.vue new file mode 100644 index 0000000..6e34065 --- /dev/null +++ b/Web App/web/src/components/ThemeToggle.vue @@ -0,0 +1,25 @@ + + + diff --git a/Web App/web/src/components/settings/Row.vue b/Web App/web/src/components/settings/Row.vue new file mode 100644 index 0000000..58d9c84 --- /dev/null +++ b/Web App/web/src/components/settings/Row.vue @@ -0,0 +1,35 @@ + + + diff --git a/Web App/web/src/components/settings/Segmented.vue b/Web App/web/src/components/settings/Segmented.vue new file mode 100644 index 0000000..567afe4 --- /dev/null +++ b/Web App/web/src/components/settings/Segmented.vue @@ -0,0 +1,28 @@ + + + diff --git a/Web App/web/src/components/settings/Toggle.vue b/Web App/web/src/components/settings/Toggle.vue new file mode 100644 index 0000000..424c9e2 --- /dev/null +++ b/Web App/web/src/components/settings/Toggle.vue @@ -0,0 +1,24 @@ + + + diff --git a/Web App/web/src/main.js b/Web App/web/src/main.js new file mode 100644 index 0000000..d770844 --- /dev/null +++ b/Web App/web/src/main.js @@ -0,0 +1,6 @@ +import { createApp } from 'vue' +import './style.css' +import './prefs.js' // register + apply persisted preferences (font size, motion) +import App from './App.vue' + +createApp(App).mount('#app') diff --git a/Web App/web/src/prefs.js b/Web App/web/src/prefs.js new file mode 100644 index 0000000..c15c991 --- /dev/null +++ b/Web App/web/src/prefs.js @@ -0,0 +1,163 @@ +// PilotVault client-side preferences store. +// +// Everything here persists in localStorage and applies immediately in the +// browser — no backend required. Account/profile/security fields that will +// eventually live on the account service are kept here too so the Settings +// panel is a working prototype: wire them to real endpoints when they exist. +import { reactive, watch } from 'vue' +import { themeMode, setThemeMode } from './theme.js' +import { getPreferences, savePreferences } from './api.js' + +const KEY = 'pv_prefs' + +const defaults = { + // Account (prototype — mirror to the account service when available) + name: '', + username: '', + // Profile + displayName: '', + bio: '', + avatar: '', // data URL + showEmail: false, + // Appearance / accessibility (fully client-side, applied live) + fontSize: 'md', // sm | md | lg + language: 'en', + region: 'US', + dateFormat: 'MDY', // MDY | DMY | YMD | ISO + timeFormat: '24', // 12 | 24 + reduceMotion: false, + // Security (prototype) + twoFactor: false, +} + +function load() { + try { + return { ...defaults, ...(JSON.parse(localStorage.getItem(KEY) || '{}') || {}) } + } catch { + return { ...defaults } + } +} + +export const prefs = reactive(load()) + +export function savePrefs() { + try { + localStorage.setItem(KEY, JSON.stringify(prefs)) + } catch { + /* quota / private mode — ignore */ + } +} + +export function resetPrefs() { + Object.assign(prefs, defaults) +} + +export function importPrefs(obj) { + if (!obj || typeof obj !== 'object') return false + // Only accept known keys — ignore anything unexpected in the imported file. + for (const k of Object.keys(defaults)) { + if (k in obj) prefs[k] = obj[k] + } + return true +} + +// ---- Font size (accessibility): scales the whole rem-based UI ---- +const FONT_PX = { sm: 15, md: 16, lg: 18 } +export function applyFontSize(size) { + document.documentElement.style.fontSize = (FONT_PX[size] || 16) + 'px' +} + +// ---- Reduce motion ---- +export function applyReduceMotion(on) { + document.documentElement.classList.toggle('reduce-motion', !!on) +} + +// ---- Date / time formatting (respects the user's format prefs) ---- +function parts(ms) { + const d = new Date(ms) + const yyyy = d.getFullYear() + const mm = String(d.getMonth() + 1).padStart(2, '0') + const dd = String(d.getDate()).padStart(2, '0') + let date + switch (prefs.dateFormat) { + case 'DMY': date = `${dd}/${mm}/${yyyy}`; break + case 'YMD': date = `${yyyy}/${mm}/${dd}`; break + case 'ISO': date = `${yyyy}-${mm}-${dd}`; break + default: date = `${mm}/${dd}/${yyyy}` + } + let time + if (prefs.timeFormat === '12') { + time = d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', second: '2-digit', hour12: true }) + } else { + const hh = String(d.getHours()).padStart(2, '0') + time = `${hh}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}` + } + return { date, time } +} +export function formatTime(ms) { + return parts(ms).time +} +export function formatDateTime(ms) { + const p = parts(ms) + return `${p.date} ${p.time}` +} + +// ---- PocketBase sync ---- +// localStorage is the always-on local cache (also drives pre-paint). When the +// user is authenticated we additionally mirror everything to their PocketBase +// user record: pull on sign-in (server wins), debounced push on every change. +// Theme mode lives in its own store but rides along in the same blob. +let syncing = false +let applyingRemote = false +let pushTimer = null + +function snapshot() { + return { ...JSON.parse(JSON.stringify(prefs)), themeMode: themeMode.value } +} + +function schedulePush() { + if (!syncing || applyingRemote) return + clearTimeout(pushTimer) + pushTimer = setTimeout(() => { + savePreferences(snapshot()) + }, 600) +} + +function applyRemote(remote) { + applyingRemote = true + try { + importPrefs(remote) // copies known keys only + if (remote.themeMode) setThemeMode(remote.themeMode) + applyFontSize(prefs.fontSize) + applyReduceMotion(prefs.reduceMotion) + savePrefs() + } finally { + applyingRemote = false + } +} + +// Enable syncing and reconcile with the server. If the server has a saved blob +// it wins; if it's empty, seed it with whatever is stored locally. +export async function enableSync() { + syncing = true + const remote = await getPreferences() + if (remote && Object.keys(remote).length) { + applyRemote(remote) + } else { + schedulePush() + } +} + +export function disableSync() { + syncing = false + clearTimeout(pushTimer) +} + +// Persist locally, keep live-applied prefs in sync, and mirror to the server. +watch(prefs, () => { + savePrefs() + schedulePush() +}, { deep: true }) +watch(themeMode, schedulePush) +watch(() => prefs.fontSize, applyFontSize, { immediate: true }) +watch(() => prefs.reduceMotion, applyReduceMotion, { immediate: true }) diff --git a/Web App/web/src/style.css b/Web App/web/src/style.css new file mode 100644 index 0000000..1529d34 --- /dev/null +++ b/Web App/web/src/style.css @@ -0,0 +1,102 @@ +@import './design/tokens.css'; + +@tailwind base; +@tailwind components; +@tailwind utilities; + +html, +body, +#app { + height: 100%; +} + +/* Paint the app ground on (and #app): unlike , html's background + fills the viewport canvas and reliably repaints when the theme custom + property flips on this same element. */ +html { + @apply bg-surface-0; + transition: background-color var(--dur-base) var(--ease-standard); +} + +body { + @apply text-ink font-sans antialiased; +} + +#app { + @apply bg-surface-0; + min-height: 100vh; /* cover the viewport so the (canvas-cached) body ground never shows through */ + transition: background-color var(--dur-base) var(--ease-standard); +} + +@layer components { + .tabular { + font-variant-numeric: tabular-nums; + } + /* Mono ALL-CAPS eyebrow — telemetry field names & section labels */ + .eyebrow { + @apply font-mono text-[11px] uppercase tracking-caps text-ink-muted; + } + /* Large mono telemetry value */ + .readout { + @apply font-mono tabular text-telemetry text-ink; + } + /* Card surface — solid, hairline border, cool shadow */ + .panel { + @apply rounded-lg border border-line bg-surface-1 shadow-xs; + } + /* Inset status tile */ + .pill { + @apply rounded border border-line bg-surface-2 px-3 py-2; + } + /* Text input */ + .field { + @apply w-full rounded border border-line bg-surface-2 px-3 py-2.5 text-sm text-ink + placeholder:text-ink-muted outline-none transition; + transition-duration: var(--dur-fast); + } + .field:focus { + @apply border-accent; + box-shadow: 0 0 0 3px var(--focus-ring); + } + /* Primary action — Signal Blue */ + .btn-accent { + @apply rounded bg-accent px-4 py-2.5 text-sm font-semibold text-white transition + hover:bg-accent-hover active:translate-y-px disabled:opacity-50; + transition-duration: var(--dur-fast); + } + /* Secondary / ghost action — outlined surface */ + .btn-ghost { + @apply rounded border border-line bg-surface-1 px-3 py-1.5 text-sm text-ink-secondary transition + hover:border-line-strong hover:text-ink active:translate-y-px; + transition-duration: var(--dur-fast); + } + /* Small square icon button (theme toggle etc.) */ + .btn-icon { + @apply grid h-9 w-9 place-items-center rounded border border-line bg-surface-1 text-ink-secondary + transition hover:text-ink hover:border-line-strong; + transition-duration: var(--dur-fast); + } +} + +/* Accessibility: honour the "reduce motion" preference (toggle in Settings). */ +html.reduce-motion *, +html.reduce-motion *::before, +html.reduce-motion *::after { + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + scroll-behavior: auto !important; +} + +/* Leaflet — themed map chrome */ +.leaflet-container { + background: var(--surface-inset); + font-family: var(--font-sans); +} +.leaflet-control-attribution { + background: color-mix(in srgb, var(--surface) 82%, transparent) !important; + color: var(--text-tertiary) !important; +} +.leaflet-control-attribution a { + color: var(--text-secondary) !important; +} diff --git a/Web App/web/src/theme.js b/Web App/web/src/theme.js new file mode 100644 index 0000000..5357ee2 --- /dev/null +++ b/Web App/web/src/theme.js @@ -0,0 +1,73 @@ +// Theme store — light is the PilotVault default. The user picks a *mode* +// (light | dark | system); `theme` is the resolved value (light | dark) that +// actually drives [data-theme] on . The pre-paint script in index.html +// resolves the same way to avoid a flash before hydration. +import { ref } from 'vue' + +const KEY = 'pv_theme' + +// Concrete ground colors (--bg-app per theme), set inline on so the +// viewport canvas repaints reliably when the theme flips (Chromium quirk). +const GROUND = { light: '#EEF0F3', dark: '#0B1730' } + +const media = typeof window !== 'undefined' && window.matchMedia + ? window.matchMedia('(prefers-color-scheme: dark)') + : null + +function systemTheme() { + return media && media.matches ? 'dark' : 'light' +} + +function initialMode() { + try { + return localStorage.getItem(KEY) || 'light' + } catch { + return 'light' + } +} + +function resolve(mode) { + return mode === 'system' ? systemTheme() : mode +} + +function paint(resolved) { + const html = document.documentElement + html.setAttribute('data-theme', resolved) + html.style.backgroundColor = GROUND[resolved] || GROUND.light +} + +// The user's chosen mode, and the concrete resolved theme. +export const themeMode = ref(initialMode()) +export const theme = ref(resolve(themeMode.value)) + +export function setThemeMode(mode) { + themeMode.value = mode + const resolved = resolve(mode) + theme.value = resolved + paint(resolved) + try { + localStorage.setItem(KEY, mode) + } catch { + /* ignore */ + } +} + +// Back-compat alias used by older call sites. +export const applyTheme = setThemeMode + +// Simple light/dark flip for the header toggle. From "system" it commits to the +// opposite of whatever is currently showing. +export function toggleTheme() { + setThemeMode(theme.value === 'dark' ? 'light' : 'dark') +} + +// Follow the OS while in "system" mode. +if (media) { + media.addEventListener('change', () => { + if (themeMode.value === 'system') { + const resolved = systemTheme() + theme.value = resolved + paint(resolved) + } + }) +} diff --git a/Web App/web/tailwind.config.js b/Web App/web/tailwind.config.js new file mode 100644 index 0000000..bf4585a --- /dev/null +++ b/Web App/web/tailwind.config.js @@ -0,0 +1,76 @@ +/** + * PilotVault design system — Tailwind mapping. + * Colors resolve to CSS variables from src/design/tokens.css so utilities + * follow the active theme (light default, [data-theme="dark"] to flip). + */ +export default { + content: ['./index.html', './src/**/*.{vue,js}'], + theme: { + extend: { + colors: { + // App/chrome surfaces (numeric scale kept for minimal churn) + surface: { + 0: 'var(--bg-app)', // app ground + 1: 'var(--surface)', // chrome: header / sidebar / cards + 2: 'var(--surface-2)', // inset tiles / inputs + DEFAULT: 'var(--surface)', + }, + // Text tones + ink: { + DEFAULT: 'var(--text-primary)', + secondary: 'var(--text-secondary)', + muted: 'var(--text-tertiary)', + }, + // Hairline borders + line: { + DEFAULT: 'var(--border)', + strong: 'var(--border-strong)', + }, + // Single accent — Signal Blue + accent: { + DEFAULT: 'var(--accent)', + hover: 'var(--accent-hover)', + soft: 'var(--accent-soft)', + 'soft-fg': 'var(--accent-soft-fg)', + }, + // Status hues (aliases preserve existing class names) + ready: 'var(--success)', // green + caution: 'var(--warning)', // amber + warning: 'var(--danger)', // red + success: { DEFAULT: 'var(--success)', soft: 'var(--success-soft)', fg: 'var(--success-fg)' }, + amber: { DEFAULT: 'var(--warning)', soft: 'var(--warning-soft)', fg: 'var(--warning-fg)' }, + danger: { DEFAULT: 'var(--danger)', soft: 'var(--danger-soft)', fg: 'var(--danger-fg)' }, + }, + fontFamily: { + sans: ['"Space Grotesk"', 'ui-sans-serif', 'system-ui', '-apple-system', '"Segoe UI"', 'sans-serif'], + mono: ['"Space Mono"', 'ui-monospace', '"SF Mono"', '"JetBrains Mono"', 'monospace'], + }, + fontSize: { + // Big mono telemetry readouts + section titles + telemetry: ['30px', { lineHeight: '1', fontWeight: '500' }], + mode: ['18px', { lineHeight: '1.2', fontWeight: '600', letterSpacing: '-0.02em' }], + }, + letterSpacing: { + caps: '0.14em', // mono eyebrow labels + tightest: '-0.02em', + }, + borderRadius: { + DEFAULT: '10px', // controls (radius-md) + sm: '6px', + lg: '14px', // cards (radius-lg) + xl: '20px', // large surfaces (radius-xl) + }, + boxShadow: { + xs: 'var(--shadow-xs)', + sm: 'var(--shadow-sm)', + md: 'var(--shadow-md)', + lg: 'var(--shadow-lg)', + }, + transitionTimingFunction: { + out: 'var(--ease-out)', + standard: 'var(--ease-standard)', + }, + }, + }, + plugins: [], +} diff --git a/Web App/web/vite.config.js b/Web App/web/vite.config.js new file mode 100644 index 0000000..b3e4d43 --- /dev/null +++ b/Web App/web/vite.config.js @@ -0,0 +1,20 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +// The production build is written into ../server/dist so the Go BFF can embed +// it via //go:embed all:dist and serve it at the server root. +export default defineConfig({ + plugins: [vue()], + base: './', + build: { + outDir: '../server/dist', + emptyOutDir: true, + }, + server: { + port: 5175, + proxy: { + // Dev-mode proxy to a locally running Web App BFF. + '/bff': 'http://localhost:8090', + }, + }, +})