diff --git a/CHANGELOG.md b/CHANGELOG.md index 0430944..e12435f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,44 @@ # Changelog All notable changes to this project are documented in this file. + +## [Unreleased] + +## [1.1.0] - 2026-06-24 + +### Features + +- Added repeater UI launch buttons that open the repeater web interface from the repeater inform IP, falling back to the Glass connection host. +- Added per-repeater Open Repeater URL overrides so Tailscale/VPN management URLs can be edited without changing the inform/control IP. +- Added command queue checkbox targeting for selected repeaters, plus all-repeaters queueing from the command page. +- Added example repeater policy templates for high airtime logging, low-signal dropping, and trusted pubkey allow-list patterns. +- Added a single-repeater transport key sync action from the repeater detail page. +- Added Glass-side Repeater Runtime Policy management with template storage, validation, visual editing, JSON editing, and policy_sync queueing. +- Added Repeater policy object/group management in Glass, including channel hash groups, pubkey groups, and group-backed rule conditions. +- Added Repeater policy sync status tracking so Glass records queued, dispatched, success, failed, and partial policy_sync results per repeater. +- Added Repeater sensor telemetry rendering in Glass, including generic nested sensor metrics labeled as Sensor Readings. +- Added Repeater-style UI polish for the theme toggle, light-mode sidebar contrast, and runtime policy tables. +- Added Proxmox LXC installer/update helper support for Glass with Repeater-aligned prompt defaults. +- Rebranded Glass UI and deployment assets to openHop. + +### Fixes + +- Fixed Open Repeater links to include the Repeater web UI port and avoid Docker bridge gateway IPs like 172.18.0.1 when building browser targets. +- Switched the Network map base layer to the standard OpenStreetMap tile layer so map tiles render behind repeater markers. +- Exposed repeater inform IP addresses through the API so Glass can link to repeater-local web interfaces. +- Fixed policy editor behavior for HTTP IDs, Repeater-compatible condition fields, rule IDs, object/group references, and sync status formatting. +- Fixed Proxmox installer prompt validation, non-interactive execution guards, update helper expansion, container startup diagnostics, and default resource sizing. +- Preserved existing Glass database and Docker volumes across openHop deployment changes. + +### Documentation + +- Added Repeater policy_sync, sensor telemetry, openHop migration, and expanded project documentation. +- Updated Repeater terminology and removed stale development wording. + +### Chores + +- Updated backend and frontend dependencies to current versions and cleared npm audit findings. +- Cleaned up backend formatting/import ordering so ruff lint passes across app and tests. + ## [1.0.4] - 2026-04-21 ### Chores diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..e11bc5b --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,246 @@ +# pyMC to openHop migration guide + +This guide helps a human operator or an LLM/code agent migrate pyMC-branded deployments, documentation, and code references to the openHop brand without breaking existing Glass data or repeater integrations. + +## Scope + +Use `openHop` for the public brand and product names: + +- `pyMC` -> `openHop` +- `pyMC Glass` -> `openHop Glass` +- `pyMC Repeater` -> `openHop Repeater` +- `pyMC.dev` -> `openHop` or the current openHop project/org name, depending on context + +This repository is the Glass management UI/API. Its current GitHub location may still be under `pyMC-dev/pyMC-Glass` until repository ownership and URLs are moved. Do not assume a GitHub remote has already been renamed just because the product brand changed. + +## Brand rules + +1. Preferred spelling is exactly `openHop`. +2. Product names are title-style after the brand: `openHop Glass`, `openHop Repeater`. +3. Avoid new user-facing `pyMC` text in UI, docs, logs, page titles, installer prompts, generated examples, and release notes. +4. Keep legacy identifiers only when required for compatibility with an existing database, protocol, path, service, package, or deployment. +5. Do not blindly replace every lowercase `pymc` token. Some are stable machine identifiers and may need compatibility handling. + +## Current Glass naming state + +The intended user-facing identity is: + +- App/UI title: `openHop Glass` +- Backend app name: `openHop Glass API` +- Default development admin email: `admin@openhop.glass` +- Docker container prefix: `openhop_glass_` +- Frontend package name: `openhop-glass-frontend` +- Frontend browser title: `openHop Glass` +- Logo assets: `frontend/src/assets/logo/openhop_*` + +Known compatibility identifiers that currently remain: + +- GitHub repo URL examples may still point at `https://github.com/pyMC-dev/pyMC-Glass.git` until the repo is actually renamed or moved. +- The database name is currently `pymc_glass` in `DATABASE_URL` and production Compose overrides. Keep it unless you also perform and verify a database rename/migration. +- `docker-compose.yml` creates both `pymc_glass` and `openhop_glass` databases to support compatibility during transition, but the backend currently connects to `pymc_glass` by default. +- Existing persisted users, repeaters, certificates, MQTT state, snapshots, and audit records should be preserved during a brand-only migration. + +## Migration checklist for humans + +### 1. Back up before changing a live deployment + +From the Glass host: + +```sh +cd /opt/openhop-glass # or the existing install directory + +docker compose ps + +docker compose exec postgres pg_dump -U postgres pymc_glass > glass-before-openhop-migration.sql + +tar -czf glass-pki-before-openhop-migration.tgz pki +``` + +If the install directory is still named `/opt/pymc-glass`, it can remain that way for a brand-only update. Rename the directory only during a separate planned maintenance window. + +### 2. Update source and configuration + +In a source checkout: + +```sh +git pull +make init-env # local/dev only; does not overwrite existing files +make init-prod-env # production only; does not overwrite existing files +``` + +Review generated or existing environment files: + +```text +.env +.env.production +backend/.env +backend/.env.production +frontend/.env +``` + +Expected openHop values include: + +```text +APP_NAME=openHop Glass API +PKI_CA_COMMON_NAME=openHop Glass Local CA +BOOTSTRAP_SEED_ADMIN_EMAIL=admin@openhop.glass +MQTT_BASE_TOPIC=glass +``` + +Do not change `DATABASE_URL` from `pymc_glass` to `openhop_glass` unless you intentionally migrate the database and verify the target database contains the same schema/data. + +### 3. Start or restart the stack + +Development: + +```sh +make dev-up +make dev-logs +``` + +Production: + +```sh +make prod-up +make prod-logs +``` + +Verify health: + +```sh +curl -fsS http://localhost:8080/healthz +``` + +The service name in the health response should identify openHop Glass. + +### 4. Verify UI branding + +Open the UI: + +```text +http://:5173 +``` + +Confirm: + +- Browser title says `openHop Glass`. +- Login screen shows openHop branding and the `Glass` product name. +- Authenticated sidebar/header show openHop Glass branding. +- The browser tab favicon is present. +- There is no new user-facing `pyMC` text on primary screens. + +### 5. Verify data preservation + +After the stack starts, confirm existing operational data is still present: + +- repeaters still appear in inventory +- adoption state is unchanged +- recent packets / MQTT telemetry still arrive +- commands and audit events still render +- PKI/certificate paths still exist under `./pki` + +If data disappears immediately after a branding update, first check that `DATABASE_URL` still points to the existing database. + +## Migration checklist for LLM/code agents + +When asked to rebrand pyMC to openHop: + +1. Inspect before editing. + - Read `README.md`, `docker-compose*.yml`, env examples, frontend `index.html`, package manifests, installer scripts, and affected UI components. + - Search for `pyMC`, `pymc`, `PYMC`, `openHop`, and `openhop`. + +2. Classify every match before changing it. + - User-facing text: usually rename to openHop. + - Repo URLs: only rename if the repo actually exists at the new URL. + - Database names: preserve unless doing a database migration. + - Protocol fields / API contracts / MQTT topics: preserve unless the counterpart implementation has changed. + - Filesystem paths and service names: preserve unless the deployment plan includes a rename and restart. + +3. Avoid destructive migrations. + - Do not delete volumes. + - Do not recreate the database. + - Do not regenerate PKI unless explicitly requested. + - Do not change existing admin credentials. + +4. Prefer compatibility aliases during transition. + - Support old names long enough for existing installs to upgrade. + - Keep legacy DB names or create both old/new databases if needed. + - Document any remaining legacy machine identifiers. + +5. Verify with real commands. + - Build frontend: `npm --prefix frontend run build` or `make frontend-build`. + - Check backend tests/lint when backend code changes: `make backend-check`. + - Start/restart the target stack and check `/healthz`. + - Use a browser or screenshot to verify the UI branding and favicon. + +6. Commit only after live/dev-host verification when requested. + +## Suggested find commands + +Use these to locate brand references: + +```sh +git grep -n -E 'pyMC|pymc|PYMC|openHop|openhop' +``` + +For likely user-facing frontend text: + +```sh +git grep -n -E 'pyMC|pymc|openHop|openhop' -- frontend/src frontend/index.html README.md docs scripts +``` + +For compatibility-sensitive deployment identifiers: + +```sh +git grep -n -E 'pymc_glass|openhop_glass|DATABASE_URL|POSTGRES_DB|COMPOSE_PROJECT_NAME|container_name|volume' -- . +``` + +## Common mappings + +| Old | New | Notes | +| --- | --- | --- | +| pyMC | openHop | Public brand text. | +| pyMC Glass | openHop Glass | Product name. | +| pyMC Repeater | openHop Repeater | Product name. | +| pymc_glass | keep for now | Database compatibility identifier unless explicitly migrated. | +| pyMC-dev/pyMC-Glass | keep until repo moves | Do not invent a new remote URL. | +| admin@pymc.glass | admin@openhop.glass | New default seed account; existing accounts are not automatically renamed. | +| pyMC.dev copyright/org text | openHop project/org text | Update only when ownership/legal text is confirmed. | + +## Optional database rename plan + +A database rename is not required for a brand-only migration. If a future release intentionally moves from `pymc_glass` to `openhop_glass`, do it as a separate migration with downtime: + +1. Stop backend/frontend writers. +2. Back up `pymc_glass`. +3. Create or replace `openhop_glass` from the backup. +4. Update `DATABASE_URL` in all env files and Compose overrides. +5. Start the stack and verify schema, users, repeaters, packets, commands, policies, and audit records. +6. Keep the old backup until the new database has run successfully through an upgrade cycle. + +Example outline, not a copy/paste production procedure: + +```sh +docker compose stop backend frontend + +docker compose exec -T postgres pg_dump -U postgres pymc_glass > pymc_glass.sql + +docker compose exec -T postgres createdb -U postgres openhop_glass + +docker compose exec -T postgres psql -U postgres openhop_glass < pymc_glass.sql + +# edit DATABASE_URL to .../openhop_glass only after restore succeeds + +docker compose up -d +``` + +## Rollback + +For a brand-only change, rollback is usually a Git/deployment rollback: + +```sh +git revert +docker compose up -d --build +``` + +For production, restore the pre-migration database dump and PKI archive only if the change included data/schema/PKI modifications. Do not restore data just to roll back UI text. diff --git a/Makefile b/Makefile index 872fb9e..b388461 100644 --- a/Makefile +++ b/Makefile @@ -13,6 +13,7 @@ init-env: init-prod-env: cp -n .env.production.example .env.production || true cp -n backend/.env.production.example backend/.env.production || true + [ -f backend/.env.production ] && cp backend/.env.production backend/.env || true easy-start: ./scripts/easy-start.sh diff --git a/README.md b/README.md index 243388f..e3b8580 100644 --- a/README.md +++ b/README.md @@ -1,48 +1,464 @@ -# pyMC_Glass -Simple management stack for pyMC repeaters (API + DB + MQTT + frontend). - -## First setup (local) -1. One-command setup/start: - - `make easy-start` -2. (Manual equivalent) Copy local env files: - - `make init-env` -3. Start services: - - `make dev-up` -4. Open the apps: - - frontend: `http://localhost:5173` - - backend health: `http://localhost:8080/healthz` -5. Login with the seeded default admin: - - email: `admin@pymc.glass` - - password: `admin12345678` -6. Stop services: - - `make dev-down` - -## Default login -- Email: `admin@pymc.glass` -- Password: `admin12345678` -- The account is auto-created on backend startup when no users exist. - -## Change default credentials -- Edit `backend/.env` (or `backend/.env.production`) and set: - - `BOOTSTRAP_SEED_ADMIN_EMAIL` - - `BOOTSTRAP_SEED_ADMIN_PASSWORD` -- Seeding only runs when the users table is empty. - - -## Production (Docker Compose) -1. Initialize production env files: - - `make init-prod-env` -2. Set secrets/config: - - `.env.production` (compose-level secrets like `POSTGRES_PASSWORD`) - - `backend/.env.production` (backend runtime config) -3. Start production stack: - - `make prod-up` -4. Open the app: - - frontend: `http://localhost:5173` - - frontend is served by nginx using a built Vite `dist/` bundle (not `npm run dev`) -5. Login with the seeded admin in `backend/.env.production`. -6. View logs: - - `make prod-logs` -7. Stop production stack: - - `make prod-down` +# openHop Glass +openHop Glass is a web management console for openHop Repeater fleets. It provides a control plane for repeater adoption, health monitoring, MQTT telemetry ingest, command dispatch, certificate management, policy distribution, alerting, and encrypted config backups. + +The stack is built for self-hosted deployments and runs as Docker Compose services: a FastAPI backend, Vue frontend, Timescale/PostgreSQL database, Mosquitto MQTT broker, and local PKI initializer. + +## What it does + +- Repeater adoption and inventory + - discover repeaters from `/inform` + - approve/reject pending repeaters + - track firmware, state, location, config hash, certificates, and last inform time + +- Live operations dashboard + - CPU, memory, disk, uptime, radio, counters, airtime, noise floor + - packet list and packet summaries + - topology/neighborhood insights + - MQTT telemetry event stream + - generic sensor reading display for repeater-provided sensor summaries + +- Command and config management + - queue commands for repeaters through the inform control loop + - export encrypted config snapshots + - rotate certificates + - sync transport keys + - inspect command results and audit history + +- Policy and alert management + - alert policy templates, assignments, and node groups + - Repeater Runtime Policy templates + - visual Repeater policy builder with rules, conditions, actions, and object groups + - pre-staged policy groups for channel hashes and pubkeys + +- Managed MQTT and PKI + - local CA generation + - backend MQTT client certificate + - repeater MQTT client certificate lifecycle + - Mosquitto mTLS config for the data plane + +## Architecture + +```text +openHop Repeater(s) + | /inform control loop + v +FastAPI backend <----> PostgreSQL / TimescaleDB + | | + | MQTT mTLS | stored repeaters, packets, + v | commands, policies, snapshots +Mosquitto broker | + ^ | + | telemetry/events | + +-------------------------+ + +Vue frontend ---> FastAPI backend +``` + +Services: + +- `frontend`: Vue 3 + Vite UI. Development uses Vite dev server; production uses nginx serving the built bundle. +- `backend`: FastAPI API and control plane. +- `postgres`: Timescale/PostgreSQL database. +- `mosquitto`: MQTT broker with TLS/mTLS. +- `pki-init`: one-shot service that initializes local PKI material under `./pki`. + +## Repository layout + +```text +. +├── backend/ FastAPI backend, contracts, schemas, tests +├── frontend/ Vue/Vite frontend +├── mosquitto/ Mosquitto broker config +├── scripts/ installers and release helpers +├── docs/ implementation handoff notes and design docs +├── docker-compose.yml local/dev Compose stack +├── docker-compose.prod.yml production Compose overrides +├── Makefile common developer and deployment commands +└── README.md +``` + +## Requirements + +For Docker-based development or production: + +- Docker with Compose v2 +- GNU Make +- Git + +For direct backend/frontend development: + +- Python 3.11+ +- Node.js 22+ +- npm + +For Proxmox LXC installation: + +- Proxmox VE host +- root shell on the Proxmox node +- working `pct`, `pvesh`, `pveam`, and `pvesm` + +## Quick start: local Docker stack + +1. Clone the repository. + + ```sh + git clone https://github.com/pyMC-dev/pyMC-Glass.git openHop-Glass + cd openHop-Glass + ``` + +2. Initialize local environment files. + + ```sh + make init-env + ``` + +3. Start the stack. + + ```sh + make dev-up + ``` + +4. Open the UI. + + ```text + http://localhost:5173 + ``` + +5. Check backend health. + + ```text + http://localhost:8080/healthz + ``` + +6. Log in with the seeded development admin account. + + ```text + Email: admin@openhop.glass + Password: admin12345678 + ``` + +7. View logs or stop the stack. + + ```sh + make dev-logs + make dev-down + ``` + +The shortcut command below initializes env files and starts the stack: + +```sh +make easy-start +``` + +## Production Docker Compose + +1. Create production env files. + + ```sh + make init-prod-env + ``` + +2. Edit production secrets before starting. + + ```text + .env.production + backend/.env.production + ``` + + At minimum, change: + + - `POSTGRES_PASSWORD` + - `BOOTSTRAP_SEED_ADMIN_EMAIL` + - `BOOTSTRAP_SEED_ADMIN_PASSWORD` + - any externally reachable host/URL/TLS settings needed for your deployment + +3. Start the production stack. + + ```sh + make prod-up + ``` + +4. Open the UI. + + ```text + http://:5173 + ``` + +5. View logs or stop the stack. + + ```sh + make prod-logs + make prod-down + ``` + +Production frontend behavior differs from development: `docker-compose.prod.yml` builds the Vite app and serves the static `dist/` bundle with nginx on port `5173`. + +### Docker container timezone + +The Compose stack bind-mounts the host `/etc/localtime` into each service so container-level clocks match the host timezone. This is especially important for the frontend container: the development image is based on `node:22-alpine`, where setting `TZ=America/New_York` by itself is not enough because the image does not include the named timezone database. + +After changing host timezone settings or updating Compose, recreate the stack and verify each container clock: + +```sh +docker compose down +docker compose up -d --build +docker compose exec backend date +docker compose exec frontend date +docker compose exec postgres date +docker compose exec mosquitto date +``` + +## Proxmox LXC installer + +openHop Glass includes an interactive Proxmox installer that creates a Debian 12 LXC container and installs the production Docker Compose stack. + +Run from a Proxmox VE root shell: + +```sh +bash -c "$(curl -fsSL https://raw.githubusercontent.com/pyMC-dev/pyMC-Glass/main/scripts/proxmox-install.sh)" +``` + +Do not run it as `curl ... | bash`; the installer needs interactive stdin for prompts. + +Installer defaults: + +- Debian 12 container +- 4096 MB RAM +- 4 CPU cores +- 10 GB disk +- privileged LXC with Docker nesting enabled +- Tailscale device support preconfigured, but Tailscale is not auto-installed + +The installer asks for container ID, hostname, RAM, disk, CPU cores, bridge, storage, Git branch, and root password before creating anything. + +## Development without Docker containers + +Backend: + +```sh +make backend-install-dev +make backend-dev +``` + +Backend tests and lint: + +```sh +make backend-test +make backend-lint +make backend-check +``` + +Frontend: + +```sh +make frontend-install +make frontend-dev +make frontend-build +``` + +The backend dev server listens on `0.0.0.0:8080`. The frontend dev server listens on `0.0.0.0:5173` and proxies API traffic according to `frontend/.env` / Vite config. + +## Environment files + +Local development templates: + +- `.env.example` -> `.env` +- `backend/.env.example` -> `backend/.env` +- `frontend/.env.example` -> `frontend/.env` + +Production templates: + +- `.env.production.example` -> `.env.production` +- `backend/.env.production.example` -> `backend/.env.production` + +Important backend settings: + +- `DATABASE_URL`: PostgreSQL connection string. +- `MQTT_BROKER_HOST`, `MQTT_BROKER_PORT`: MQTT broker endpoint. +- `MQTT_TLS_*`: backend MQTT TLS client settings. +- `MQTT_REPEATER_TLS_ENABLED`: controls repeater mTLS expectations. +- `PKI_STATE_DIR`: where CA/client cert material is stored in the backend container. +- `BOOTSTRAP_SEED_ADMIN_*`: initial admin seed account. +- `AUTH_TOKEN_TTL_MINUTES`, `AUTH_TOKEN_BYTES`, `AUTH_PASSWORD_MIN_LENGTH`: auth behavior. +- `CONTRACT_VERSION`: active repeater API contract version. + +The seeded admin account is only created when the users table is empty. + +## Repeater integration overview + +Repeaters communicate with Glass over two paths: + +1. Control plane: HTTP `/inform` + - repeater state and stats + - certificate renewal responses + - queued command delivery + - command result ingestion + +2. Data plane: MQTT over TLS/mTLS + - packet telemetry + - event telemetry + - live stream updates in the UI + +A repeater usually starts as `pending_adoption` after its first `/inform`. Once approved in Glass, it can receive commands and managed MQTT/certificate settings. + +Current `/inform` contract support includes: + +- system stats +- radio stats +- packet counters +- settings/config hash +- command results +- optional `sensors` summaries for UPS/battery/environment sensor data + +## Runtime policy workflow + +Glass can build and store Repeater Runtime Policy templates. A policy contains: + +- `enabled` +- `default_action` +- `rules` +- `objects` + +The visual builder supports rule conditions, actions, and object groups such as: + +```json +{ + "objects": { + "channel_hash_groups": { + "blocked_channels": ["0x12"] + }, + "pubkey_groups": { + "trusted_relays": ["0xaabbccdd"] + } + } +} +``` + +Rules can reference those groups with values such as: + +```text +@channel_hash_groups.blocked_channels +``` + +Repeater-side implementation notes for policy sync live in: + +```text +docs/repeater-policy-sync-implementation.md +``` + +## Sensor telemetry workflow + +Glass is ready to accept repeater sensor summaries in the top-level `/inform` field `sensors`. + +Expected shape: + +```json +{ + "sensors": { + "enabled": true, + "configured": 2, + "loaded": 2, + "running": true, + "readings": [ + { + "name": "ups-main", + "type": "waveshare_ups_d", + "ok": true, + "timestamp": "2026-06-20T12:00:00Z", + "data": { + "battery_percent": 87.5, + "voltage_v": 4.08, + "current_ma": 120.0 + } + } + ] + } +} +``` + +Glass renders unknown sensor data fields generically on the repeater detail page. Repeater-side implementation notes live in: + +```text +docs/repeater-sensors-glass-inform-implementation.md +``` + +## Common commands + +```sh +make init-env # copy local env templates +make easy-start # initialize and start the local stack +make dev-up # start local Docker stack +make dev-logs # follow local stack logs +make dev-down # stop local stack + +make init-prod-env # copy production env templates +make prod-up # start production Docker stack +make prod-logs # follow production logs +make prod-down # stop production stack + +make backend-install-dev # create backend venv and install dev deps +make backend-dev # run backend via uvicorn reload +make backend-test # run backend tests +make backend-lint # run ruff +make backend-check # lint + tests + +make frontend-install # npm install in frontend/ +make frontend-dev # run Vite dev server +make frontend-build # build frontend dist +``` + +## Troubleshooting + +### UI cannot reach API + +- Confirm backend health: `http://localhost:8080/healthz`. +- Check frontend env/proxy settings. +- Check backend container logs: `make dev-logs` or `make prod-logs`. + +### Repeater appears connected but no MQTT data arrives + +- Confirm the repeater was adopted, not only discovered. +- Confirm managed MQTT settings are enabled in Glass. +- Check Mosquitto logs and TLS certificate paths. +- Verify the repeater is connecting to the same broker host/address covered by the cert/SAN expectations. +- Inspect backend MQTT ingest logs and `mqtt_ingest_events` before assuming packet parsing is broken. + +### Login does not use the default credentials + +The seed admin is only created when the user table is empty. If users already exist, change the password through the app/API or reset the database in a development environment. + +### Production stack fails on missing secrets + +`docker-compose.prod.yml` requires `POSTGRES_PASSWORD` from `.env.production`. Run `make init-prod-env`, edit the generated file, then start again. + +## Security notes + +- Change all default passwords before production use. +- Treat `./pki`, database volumes, production env files, and encrypted config snapshot keys as sensitive. +- Use HTTPS/reverse proxy controls when exposing the UI outside a trusted LAN/VPN. +- Prefer mTLS for repeater MQTT data-plane traffic. +- Do not commit generated `.env`, `.env.production`, `backend/.env`, `backend/.env.production`, or PKI material. + +## Versioning and changelog + +The backend and frontend currently track version `1.1.0`. + +Changelog helpers: + +```sh +make changelog-preview +make changelog +make changelog-unreleased +``` + +Patch release helper: + +```sh +make patch NOTE="short release note" +make patch-dry-run NOTE="short release note" +``` + +## License + +MIT. See the project source for license details. diff --git a/VERSION b/VERSION index ee90284..9084fa2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.4 +1.1.0 diff --git a/backend/.env.example b/backend/.env.example index 7f63a5e..f05870e 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,5 +1,5 @@ # App -APP_NAME=pyMC_Glass API +APP_NAME=openHop Glass API APP_ENV=development APP_HOST=0.0.0.0 APP_PORT=8080 @@ -21,7 +21,7 @@ MQTT_REPEATER_TLS_ENABLED=true MQTT_INGEST_ENABLED=true MQTT_INGEST_QUEUE_MAXSIZE=2000 PKI_STATE_DIR=/app/data/pki -PKI_CA_COMMON_NAME=pyMC_Glass Local CA +PKI_CA_COMMON_NAME=openHop Glass Local CA PKI_CA_VALID_DAYS=3650 PKI_CLIENT_CERT_VALID_DAYS=90 PKI_RENEW_BEFORE_DAYS=30 @@ -31,7 +31,7 @@ AUTH_TOKEN_TTL_MINUTES=1440 AUTH_TOKEN_BYTES=48 AUTH_PASSWORD_MIN_LENGTH=12 BOOTSTRAP_SEED_ADMIN_ENABLED=true -BOOTSTRAP_SEED_ADMIN_EMAIL=admin@pymc.glass +BOOTSTRAP_SEED_ADMIN_EMAIL=admin@openhop.glass BOOTSTRAP_SEED_ADMIN_PASSWORD=admin12345678 BOOTSTRAP_SEED_ADMIN_DISPLAY_NAME=Admin ALERT_POLICY_MONITOR_ENABLED=true diff --git a/backend/.env.production.example b/backend/.env.production.example index 936b0cb..f258633 100644 --- a/backend/.env.production.example +++ b/backend/.env.production.example @@ -1,5 +1,5 @@ # App runtime -APP_NAME=pyMC_Glass API +APP_NAME=openHop Glass API APP_ENV=production APP_HOST=0.0.0.0 APP_PORT=8080 @@ -21,7 +21,7 @@ MQTT_REPEATER_TLS_ENABLED=true MQTT_INGEST_ENABLED=true MQTT_INGEST_QUEUE_MAXSIZE=2000 PKI_STATE_DIR=/app/data/pki -PKI_CA_COMMON_NAME=pyMC_Glass Local CA +PKI_CA_COMMON_NAME=openHop Glass Local CA PKI_CA_VALID_DAYS=3650 PKI_CLIENT_CERT_VALID_DAYS=90 PKI_RENEW_BEFORE_DAYS=30 @@ -31,7 +31,7 @@ AUTH_TOKEN_TTL_MINUTES=1440 AUTH_TOKEN_BYTES=48 AUTH_PASSWORD_MIN_LENGTH=12 BOOTSTRAP_SEED_ADMIN_ENABLED=true -BOOTSTRAP_SEED_ADMIN_EMAIL=admin@pymc.glass +BOOTSTRAP_SEED_ADMIN_EMAIL=admin@openhop.glass BOOTSTRAP_SEED_ADMIN_PASSWORD=CHANGE_ME_NOW_12345 BOOTSTRAP_SEED_ADMIN_DISPLAY_NAME=Admin ALERT_POLICY_MONITOR_ENABLED=true diff --git a/backend/app/__init__.py b/backend/app/__init__.py index f2c5c36..e4f00e3 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -1,2 +1 @@ -"""pyMC_Glass backend package.""" - +"""openHop Glass backend package.""" diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index 48ffe31..ddbfec8 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -1,2 +1 @@ -"""API package for pyMC_Glass.""" - +"""API package for openHop Glass.""" diff --git a/backend/app/api/routes/__init__.py b/backend/app/api/routes/__init__.py index 216c2a5..f36ec2f 100644 --- a/backend/app/api/routes/__init__.py +++ b/backend/app/api/routes/__init__.py @@ -1,2 +1 @@ """Route modules.""" - diff --git a/backend/app/api/routes/adoption.py b/backend/app/api/routes/adoption.py index bea28eb..0138666 100644 --- a/backend/app/api/routes/adoption.py +++ b/backend/app/api/routes/adoption.py @@ -21,6 +21,8 @@ def _to_repeater_response(repeater: Repeater) -> RepeaterResponse: firmware_version=repeater.firmware_version, location=repeater.location, config_hash=repeater.config_hash, + inform_ip=repeater.inform_ip, + open_url=repeater.open_url, last_inform_at=repeater.last_inform_at, created_at=repeater.created_at, updated_at=repeater.updated_at, @@ -94,4 +96,3 @@ def reject_repeater( node_name=repeater.node_name, status=repeater.status, ) - diff --git a/backend/app/api/routes/alert_actions.py b/backend/app/api/routes/alert_actions.py index afa8259..722980e 100644 --- a/backend/app/api/routes/alert_actions.py +++ b/backend/app/api/routes/alert_actions.py @@ -5,6 +5,7 @@ from sqlalchemy import func, select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session + from app.db.models import ( Alert, AlertActionIntegration, @@ -18,20 +19,20 @@ from app.schemas.alert_actions import ( AlertActionDeliveryResponse, AlertActionDeliverySummaryResponse, - AlertActionIntegrationTestRequest, - AlertActionIntegrationTestResponse, AlertActionIntegrationCreateRequest, AlertActionIntegrationResponse, + AlertActionIntegrationTestRequest, + AlertActionIntegrationTestResponse, AlertActionIntegrationUpdateRequest, - AlertPolicyActionBindingCreateRequest, - AlertPolicyActionBindingResponse, - AlertPolicyActionBindingUpdateRequest, AlertActionProviderCapabilityResponse, + AlertActionTemplateCreateRequest, AlertActionTemplatePreviewRequest, AlertActionTemplatePreviewResponse, - AlertActionTemplateCreateRequest, AlertActionTemplateResponse, AlertActionTemplateUpdateRequest, + AlertPolicyActionBindingCreateRequest, + AlertPolicyActionBindingResponse, + AlertPolicyActionBindingUpdateRequest, ) from app.security.deps import require_roles from app.services.alert_action_delivery import ( @@ -40,8 +41,8 @@ resolve_policy_template_for_alert, ) from app.services.alert_actions import ( - create_action_integration, create_action_binding, + create_action_integration, create_action_template, get_action_binding, get_action_integration, @@ -77,17 +78,11 @@ def _integration_changes_for_audit(changes: dict) -> dict: def _template_changes_for_audit(changes: dict) -> dict: - filtered = { - key: value - for key, value in changes.items() - if key not in {"payload_template"} - } + filtered = {key: value for key, value in changes.items() if key not in {"payload_template"}} if "payload_template" in changes: payload_template = changes["payload_template"] filtered["payload_template_keys"] = ( - sorted(payload_template.keys()) - if isinstance(payload_template, dict) - else [] + sorted(payload_template.keys()) if isinstance(payload_template, dict) else [] ) return filtered @@ -224,11 +219,17 @@ def get_integration( ) -> AlertActionIntegrationResponse: integration = get_action_integration(db, integration_id) if integration is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Alert action integration not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Alert action integration not found" + ) return to_integration_response(integration) -@router.post("/integrations", response_model=AlertActionIntegrationResponse, status_code=status.HTTP_201_CREATED) +@router.post( + "/integrations", + response_model=AlertActionIntegrationResponse, + status_code=status.HTTP_201_CREATED, +) def create_integration( payload: AlertActionIntegrationCreateRequest, db: Session = Depends(get_db_session), @@ -238,7 +239,9 @@ def create_integration( integration = create_action_integration(db, payload) except ValueError as exc: db.rollback() - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc) + ) from exc except IntegrityError as exc: db.rollback() raise HTTPException( @@ -271,13 +274,17 @@ def update_integration( ) -> AlertActionIntegrationResponse: integration = get_action_integration(db, integration_id) if integration is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Alert action integration not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Alert action integration not found" + ) changes = payload.model_dump(exclude_unset=True) try: integration = update_action_integration(db, integration, payload) except ValueError as exc: db.rollback() - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc) + ) from exc except IntegrityError as exc: db.rollback() raise HTTPException( @@ -305,7 +312,9 @@ def delete_integration( ) -> None: integration = get_action_integration(db, integration_id) if integration is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Alert action integration not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Alert action integration not found" + ) integration_name = integration.name provider_type = integration.provider_type db.delete(integration) @@ -336,11 +345,15 @@ def get_template( ) -> AlertActionTemplateResponse: template = get_action_template(db, template_id) if template is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Alert action template not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Alert action template not found" + ) return to_template_response(template) -@router.post("/templates", response_model=AlertActionTemplateResponse, status_code=status.HTTP_201_CREATED) +@router.post( + "/templates", response_model=AlertActionTemplateResponse, status_code=status.HTTP_201_CREATED +) def create_template( payload: AlertActionTemplateCreateRequest, db: Session = Depends(get_db_session), @@ -350,10 +363,14 @@ def create_template( template = create_action_template(db, payload) except ValueError as exc: db.rollback() - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc) + ) from exc except KeyError as exc: db.rollback() - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc) + ) from exc except IntegrityError as exc: db.rollback() raise HTTPException( @@ -386,16 +403,22 @@ def update_template( ) -> AlertActionTemplateResponse: template = get_action_template(db, template_id) if template is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Alert action template not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Alert action template not found" + ) changes = payload.model_dump(exclude_unset=True) try: template = update_action_template(db, template, payload) except ValueError as exc: db.rollback() - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc) + ) from exc except KeyError as exc: db.rollback() - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc) + ) from exc except IntegrityError as exc: db.rollback() raise HTTPException( @@ -423,7 +446,9 @@ def delete_template( ) -> None: template = get_action_template(db, template_id) if template is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Alert action template not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Alert action template not found" + ) template_name = template.name db.delete(template) write_audit_log( @@ -474,7 +499,9 @@ def create_binding( binding = create_action_binding(db, payload) except ValueError as exc: db.rollback() - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc) + ) from exc except IntegrityError as exc: db.rollback() raise HTTPException( @@ -517,7 +544,9 @@ def update_binding( binding = update_action_binding(db, binding, payload) except ValueError as exc: db.rollback() - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc) + ) from exc except IntegrityError as exc: db.rollback() raise HTTPException( @@ -577,16 +606,14 @@ def preview_template( if payload.action_template_id: action_template = get_action_template(db, payload.action_template_id) if action_template is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Alert action template not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Alert action template not found" + ) title_template = ( - action_template.title_template - if action_template is not None - else payload.title_template + action_template.title_template if action_template is not None else payload.title_template ) body_template = ( - action_template.body_template - if action_template is not None - else payload.body_template + action_template.body_template if action_template is not None else payload.body_template ) payload_template = ( parse_action_template_payload(action_template) @@ -659,7 +686,9 @@ def test_integration_send( ) -> AlertActionIntegrationTestResponse: integration = get_action_integration(db, integration_id) if integration is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Alert action integration not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Alert action integration not found" + ) registry = get_notification_provider_registry() try: settings = parse_integration_settings(integration) @@ -731,18 +760,26 @@ def list_deliveries( action_template_ids = sorted( {event.action_template_id for event in events if event.action_template_id} ) - integrations = { - row.id: row - for row in db.scalars( - select(AlertActionIntegration).where(AlertActionIntegration.id.in_(integration_ids)) - ).all() - } if integration_ids else {} - action_templates = { - row.id: row - for row in db.scalars( - select(AlertActionTemplate).where(AlertActionTemplate.id.in_(action_template_ids)) - ).all() - } if action_template_ids else {} + integrations = ( + { + row.id: row + for row in db.scalars( + select(AlertActionIntegration).where(AlertActionIntegration.id.in_(integration_ids)) + ).all() + } + if integration_ids + else {} + ) + action_templates = ( + { + row.id: row + for row in db.scalars( + select(AlertActionTemplate).where(AlertActionTemplate.id.in_(action_template_ids)) + ).all() + } + if action_template_ids + else {} + ) return [ _to_delivery_response( event, @@ -758,9 +795,12 @@ def deliveries_summary( db: Session = Depends(get_db_session), _: User = Depends(require_roles("admin", "operator", "viewer")), ) -> AlertActionDeliverySummaryResponse: - total = db.scalar( - select(func.count(NotificationEvent.id)).where(NotificationEvent.channel == "action") - ) or 0 + total = ( + db.scalar( + select(func.count(NotificationEvent.id)).where(NotificationEvent.channel == "action") + ) + or 0 + ) by_status_rows = db.execute( select(NotificationEvent.status, func.count(NotificationEvent.id)) .where(NotificationEvent.channel == "action") @@ -773,8 +813,7 @@ def deliveries_summary( ).all() by_status = {str(status_key): int(count) for status_key, count in by_status_rows} by_provider = { - str(provider_key or "unknown"): int(count) - for provider_key, count in by_provider_rows + str(provider_key or "unknown"): int(count) for provider_key, count in by_provider_rows } return AlertActionDeliverySummaryResponse( total=int(total), diff --git a/backend/app/api/routes/alert_policy.py b/backend/app/api/routes/alert_policy.py index efb973f..155311a 100644 --- a/backend/app/api/routes/alert_policy.py +++ b/backend/app/api/routes/alert_policy.py @@ -187,9 +187,14 @@ def update_node_group( status_code=status.HTTP_409_CONFLICT, detail="Node group already exists", ) from exc - member_count = db.scalar( - select(func.count(NodeGroupMembership.id)).where(NodeGroupMembership.group_id == group.id) - ) or 0 + member_count = ( + db.scalar( + select(func.count(NodeGroupMembership.id)).where( + NodeGroupMembership.group_id == group.id + ) + ) + or 0 + ) write_audit_log( db, action="node_group_updated", @@ -294,11 +299,15 @@ def list_policy_templates( db: Session = Depends(get_db_session), _: User = Depends(require_roles("admin", "operator", "viewer")), ) -> list[AlertPolicyTemplateResponse]: - templates = db.scalars(select(AlertPolicyTemplate).order_by(AlertPolicyTemplate.name.asc())).all() + templates = db.scalars( + select(AlertPolicyTemplate).order_by(AlertPolicyTemplate.name.asc()) + ).all() return [_to_template_response(item) for item in templates] -@router.post("/templates", response_model=AlertPolicyTemplateResponse, status_code=status.HTTP_201_CREATED) +@router.post( + "/templates", response_model=AlertPolicyTemplateResponse, status_code=status.HTTP_201_CREATED +) def create_policy_template( payload: AlertPolicyTemplateCreateRequest, db: Session = Depends(get_db_session), @@ -348,7 +357,9 @@ def update_policy_template( ) -> AlertPolicyTemplateResponse: template = db.scalar(select(AlertPolicyTemplate).where(AlertPolicyTemplate.id == template_id)) if template is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Alert policy template not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Alert policy template not found" + ) changes = payload.model_dump(exclude_unset=True) if "name" in changes and changes["name"] is not None: template.name = str(changes.pop("name")).strip() @@ -398,7 +409,9 @@ def delete_policy_template( ) -> None: template = db.scalar(select(AlertPolicyTemplate).where(AlertPolicyTemplate.id == template_id)) if template is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Alert policy template not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Alert policy template not found" + ) template_name = template.name db.delete(template) write_audit_log( @@ -448,15 +461,23 @@ def list_policy_assignments( ] -@router.post("/assignments", response_model=AlertPolicyAssignmentResponse, status_code=status.HTTP_201_CREATED) +@router.post( + "/assignments", + response_model=AlertPolicyAssignmentResponse, + status_code=status.HTTP_201_CREATED, +) def create_policy_assignment( payload: AlertPolicyAssignmentCreateRequest, db: Session = Depends(get_db_session), current_user: User = Depends(require_roles("admin", "operator")), ) -> AlertPolicyAssignmentResponse: - template = db.scalar(select(AlertPolicyTemplate).where(AlertPolicyTemplate.id == payload.template_id)) + template = db.scalar( + select(AlertPolicyTemplate).where(AlertPolicyTemplate.id == payload.template_id) + ) if template is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Alert policy template not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Alert policy template not found" + ) if payload.scope_type == "global": scope_id = None else: @@ -469,7 +490,9 @@ def create_policy_assignment( if payload.scope_type == "group": group = db.scalar(select(NodeGroup).where(NodeGroup.id == scope_id)) if group is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Node group not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Node group not found" + ) if payload.scope_type == "node": repeater = db.scalar(select(Repeater).where(Repeater.id == scope_id)) if repeater is None: @@ -538,7 +561,9 @@ def update_policy_assignment( .where(AlertPolicyAssignment.id == assignment_id) ).first() if row is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Alert policy assignment not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Alert policy assignment not found" + ) assignment, template = row changes = payload.model_dump(exclude_unset=True) if "priority" in changes and changes["priority"] is not None: @@ -580,9 +605,13 @@ def delete_policy_assignment( db: Session = Depends(get_db_session), current_user: User = Depends(require_roles("admin", "operator")), ) -> None: - assignment = db.scalar(select(AlertPolicyAssignment).where(AlertPolicyAssignment.id == assignment_id)) + assignment = db.scalar( + select(AlertPolicyAssignment).where(AlertPolicyAssignment.id == assignment_id) + ) if assignment is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Alert policy assignment not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Alert policy assignment not found" + ) db.delete(assignment) write_audit_log( db, @@ -679,7 +708,9 @@ def bootstrap_default_policy_templates( }, { "name": "Default TLS Telemetry Stale", - "description": "Triggers when inform and MQTT telemetry are stale while MQTT TLS is enabled.", + "description": ( + "Triggers when inform and MQTT telemetry are stale while MQTT TLS is enabled." + ), "rule_type": "tls_telemetry_stale", "severity": "warning", "enabled": 1, @@ -754,7 +785,9 @@ def bootstrap_default_policy_templates( }, { "name": "Default High Drop Rate", - "description": "Triggers when packet drop rate exceeds threshold in the evaluation window.", + "description": ( + "Triggers when packet drop rate exceeds threshold in the evaluation window." + ), "rule_type": "high_drop_rate", "severity": "warning", "enabled": 1, @@ -765,7 +798,10 @@ def bootstrap_default_policy_templates( }, { "name": "Default New Zero-Hop Node Detected", - "description": "Triggers when a newly discovered node is observed via zero-hop advert in the policy window.", + "description": ( + "Triggers when a newly discovered node is observed via zero-hop advert in " + "the policy window." + ), "rule_type": "new_zero_hop_node_detected", "severity": "info", "enabled": 1, diff --git a/backend/app/api/routes/audit.py b/backend/app/api/routes/audit.py index 282ee65..12cd0d1 100644 --- a/backend/app/api/routes/audit.py +++ b/backend/app/api/routes/audit.py @@ -31,4 +31,3 @@ def list_audit_events( ) for row in rows ] - diff --git a/backend/app/api/routes/auth.py b/backend/app/api/routes/auth.py index 766c146..ce40402 100644 --- a/backend/app/api/routes/auth.py +++ b/backend/app/api/routes/auth.py @@ -90,4 +90,3 @@ def logout( ) db.commit() return {"success": True} - diff --git a/backend/app/api/routes/bootstrap.py b/backend/app/api/routes/bootstrap.py index b44c27a..a40cc25 100644 --- a/backend/app/api/routes/bootstrap.py +++ b/backend/app/api/routes/bootstrap.py @@ -69,4 +69,3 @@ def bootstrap_admin( db.commit() return BootstrapAdminResponse(user_id=user.id, email=user.email, role=user.role) - diff --git a/backend/app/api/routes/commands.py b/backend/app/api/routes/commands.py index 79e624f..e85aa4a 100644 --- a/backend/app/api/routes/commands.py +++ b/backend/app/api/routes/commands.py @@ -110,4 +110,3 @@ def get_command( item, repeater_node_name = row return _to_response(item, repeater_node_name) - diff --git a/backend/app/api/routes/contracts.py b/backend/app/api/routes/contracts.py index ed78c22..6950aa9 100644 --- a/backend/app/api/routes/contracts.py +++ b/backend/app/api/routes/contracts.py @@ -35,4 +35,3 @@ def schemas_v1() -> dict: "repeater_summary_dto": api_dto.RepeaterSummaryV1.model_json_schema(), "adoption_action_dto": api_dto.AdoptionActionV1.model_json_schema(), } - diff --git a/backend/app/api/routes/health.py b/backend/app/api/routes/health.py index a803dfe..4a1f36d 100644 --- a/backend/app/api/routes/health.py +++ b/backend/app/api/routes/health.py @@ -17,4 +17,3 @@ def healthz() -> dict: "contract_version": settings.contract_version, "timestamp": datetime.now(UTC).isoformat(), } - diff --git a/backend/app/api/routes/inform.py b/backend/app/api/routes/inform.py index edb68e0..9e56e48 100644 --- a/backend/app/api/routes/inform.py +++ b/backend/app/api/routes/inform.py @@ -11,14 +11,18 @@ from app.contracts.v1.inform import InformRequestV1 from app.db.models import Certificate, CommandQueueItem, InformSnapshot, Repeater from app.db.session import get_db_session -from app.services.audit import write_audit_log from app.services.alert_policy import evaluate_policies_for_repeater +from app.services.audit import write_audit_log from app.services.config_snapshot import ( ConfigSnapshotService, SnapshotEncryptionError, SnapshotPayloadError, ) from app.services.pki import PkiService +from app.services.repeater_policy import ( + mark_repeater_policy_sync_dispatched, + mark_repeater_policy_sync_result, +) from app.services.system_settings import ( get_effective_config_snapshot_encryption_keys, get_effective_managed_mqtt_settings, @@ -43,6 +47,26 @@ def _normalize_datetime(value: datetime | None) -> datetime | None: return value.astimezone(UTC) +def _is_docker_bridge_gateway(host: str | None) -> bool: + if not host: + return False + parts = host.split(".") + if len(parts) != 4: + return False + try: + octets = [int(part) for part in parts] + except ValueError: + return False + return octets[0] == 172 and 16 <= octets[1] <= 31 and octets[2:] == [0, 1] + + +def _inform_source_ip(request: Request, current_ip: str | None = None) -> str | None: + source_ip = request.client.host if request.client else None + if _is_docker_bridge_gateway(source_ip) and current_ip and not _is_docker_bridge_gateway(current_ip): + return current_ip + return source_ip or current_ip + + def _compact_json(value: dict[str, Any]) -> str | None: if not value: return None @@ -233,6 +257,9 @@ def inform( ) force_certificate_renewal = False repeater = db.scalar(select(Repeater).where(Repeater.node_name == payload.node_name)) + system_payload = payload.system.model_dump() + if payload.sensors is not None: + system_payload["sensors"] = payload.sensors if repeater is None: location = _normalize_location(payload.location) or _extract_location_from_settings( @@ -249,10 +276,10 @@ def inform( state=payload.state, location=location, config_hash=payload.config_hash, - inform_ip=request.client.host if request.client else None, + inform_ip=_inform_source_ip(request), last_inform_at=now, cert_expires_at=_normalize_datetime(payload.cert_expires_at), - system_json=_compact_json(payload.system.model_dump()), + system_json=_compact_json(system_payload), radio_json=_compact_json(payload.radio.model_dump()), counters_json=_compact_json(payload.counters.model_dump()), settings_json=_compact_json(payload.settings), @@ -275,9 +302,9 @@ def inform( if location is not None: repeater.location = location repeater.config_hash = payload.config_hash - repeater.inform_ip = request.client.host if request.client else repeater.inform_ip + repeater.inform_ip = _inform_source_ip(request, repeater.inform_ip) repeater.last_inform_at = now - repeater.system_json = _compact_json(payload.system.model_dump()) + repeater.system_json = _compact_json(system_payload) repeater.radio_json = _compact_json(payload.radio.model_dump()) repeater.counters_json = _compact_json(payload.counters.model_dump()) if payload.settings: @@ -394,6 +421,15 @@ def inform( message=result.message, completed_at=result.completed_at, ) + if queued.command == "policy_sync": + mark_repeater_policy_sync_result( + db, + repeater_id=repeater.id, + command_id=queued.id, + status=result.status, + message=result.message, + completed_at=result.completed_at, + ) write_audit_log( db, action="command_result_ingested", @@ -439,6 +475,12 @@ def inform( repeater_id=repeater.id, command_id=next_command.id, ) + if next_command.command == "policy_sync": + mark_repeater_policy_sync_dispatched( + db, + repeater_id=repeater.id, + command_id=next_command.id, + ) write_audit_log( db, action="command_dispatched", diff --git a/backend/app/api/routes/insights.py b/backend/app/api/routes/insights.py index 44cef6f..636772e 100644 --- a/backend/app/api/routes/insights.py +++ b/backend/app/api/routes/insights.py @@ -1,9 +1,9 @@ from __future__ import annotations + import asyncio import json import re from collections import Counter - from datetime import UTC, datetime, timedelta from typing import Any, AsyncGenerator @@ -26,6 +26,11 @@ NeighborObservationListResponse, NeighborObservationResponse, NodeDetailResponse, + NodeObserverSnapshotResponse, + NodeTimeseriesPointResponse, + NodeTimeseriesResponse, + TopologyEdgeListResponse, + TopologyEdgeResponse, TopologyPacketGraphEdgeResponse, TopologyPacketGraphNodeResponse, TopologyPacketQualityResponse, @@ -34,11 +39,6 @@ TopologyRepeaterTrafficShareResponse, TopologySignalDistributionBinResponse, TopologySignalTrendPointResponse, - NodeObserverSnapshotResponse, - NodeTimeseriesPointResponse, - NodeTimeseriesResponse, - TopologyEdgeListResponse, - TopologyEdgeResponse, TopologySummaryResponse, ) from app.security.deps import require_roles @@ -176,6 +176,7 @@ def _build_filter_clauses( ) return clauses + def _normalize_count_key(value: Any, *, unknown: str = "unknown") -> str: text = _to_optional_string(value) return text if text is not None else unknown @@ -331,8 +332,12 @@ def _extract_packet_hops(payload: dict[str, Any]) -> tuple[list[str], str | None if hops: return hops, "raw" - src_hash = _normalize_hop_token(payload.get("src_hash")) or _normalize_hop_token(nested_map.get("src_hash")) - dst_hash = _normalize_hop_token(payload.get("dst_hash")) or _normalize_hop_token(nested_map.get("dst_hash")) + src_hash = _normalize_hop_token(payload.get("src_hash")) or _normalize_hop_token( + nested_map.get("src_hash") + ) + dst_hash = _normalize_hop_token(payload.get("dst_hash")) or _normalize_hop_token( + nested_map.get("dst_hash") + ) if src_hash and dst_hash: return [src_hash, dst_hash], "derived" return [], None @@ -350,6 +355,7 @@ def _extract_channel_detail(payload: dict[str, Any]) -> str | None: return f"{key}:{value}" return None + @router.get("/neighbors/latest", response_model=NeighborObservationListResponse) def list_neighbor_observations( hours: int = Query(default=168, ge=1, le=24 * 30), @@ -457,7 +463,9 @@ def get_node_detail( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="pubkey is required", ) - node = db.scalar(select(TopologyNode).where(func.lower(TopologyNode.pubkey) == normalized_pubkey)) + node = db.scalar( + select(TopologyNode).where(func.lower(TopologyNode.pubkey) == normalized_pubkey) + ) if node is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -564,8 +572,7 @@ def get_topology_summary( stale_nodes = int( db.scalar( - select(func.count()) - .select_from( + select(func.count()).select_from( select(TopologyObservation.observed_node_id) .where(TopologyObservation.last_seen_at >= cutoff) .group_by(TopologyObservation.observed_node_id) @@ -633,6 +640,7 @@ def _lag_seconds(latest: Any) -> int | None: top_observer_count=int(top_observer_row[1]) if top_observer_row is not None else None, ) + @router.get("/topology/packet-quality", response_model=TopologyPacketQualityResponse) def get_topology_packet_quality( hours: int = Query(default=24, ge=1, le=24 * 30), @@ -777,9 +785,10 @@ def get_topology_packet_structure( nested_payload = payload.get("payload") nested_map = nested_payload if isinstance(nested_payload, dict) else {} - has_raw = _to_optional_string(payload.get("raw")) is not None or _to_optional_string( - nested_map.get("raw") - ) is not None + has_raw = ( + _to_optional_string(payload.get("raw")) is not None + or _to_optional_string(nested_map.get("raw")) is not None + ) if has_raw: packets_with_raw += 1 @@ -940,7 +949,9 @@ def get_node_timeseries( detail="pubkey is required", ) - node = db.scalar(select(TopologyNode).where(func.lower(TopologyNode.pubkey) == normalized_pubkey)) + node = db.scalar( + select(TopologyNode).where(func.lower(TopologyNode.pubkey) == normalized_pubkey) + ) if node is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, diff --git a/backend/app/api/routes/repeater_policy.py b/backend/app/api/routes/repeater_policy.py new file mode 100644 index 0000000..3b29c0b --- /dev/null +++ b/backend/app/api/routes/repeater_policy.py @@ -0,0 +1,348 @@ +import json +from datetime import UTC, datetime +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.db.models import ( + CommandQueueItem, + Repeater, + RepeaterPolicySyncStatus, + RepeaterPolicyTemplate, + User, +) +from app.db.session import get_db_session +from app.schemas.repeater_policy import ( + RepeaterPolicySyncRequest, + RepeaterPolicySyncResponse, + RepeaterPolicySyncStatusResponse, + RepeaterPolicyTemplateCreateRequest, + RepeaterPolicyTemplateResponse, + RepeaterPolicyTemplateUpdateRequest, + RepeaterPolicyValidateRequest, + RepeaterPolicyValidateResponse, +) +from app.security.deps import require_roles +from app.services.audit import write_audit_log +from app.services.repeater_policy import ( + normalize_policy_document, + policy_payload_hash, + validate_policy_document, +) + +router = APIRouter(prefix="/api/repeater-policies") + + +def _utc_now() -> datetime: + return datetime.now(UTC) + + +def _load_policy_json(template: RepeaterPolicyTemplate) -> dict[str, Any]: + try: + loaded = json.loads(template.policy_json) + except json.JSONDecodeError: + return {} + return loaded if isinstance(loaded, dict) else {} + + +def _to_template_response(template: RepeaterPolicyTemplate) -> RepeaterPolicyTemplateResponse: + return RepeaterPolicyTemplateResponse( + id=template.id, + name=template.name, + description=template.description, + enabled=template.enabled == 1, + policy=_load_policy_json(template), + created_at=template.created_at, + updated_at=template.updated_at, + ) + + +def _to_sync_status_response( + sync_status: RepeaterPolicySyncStatus, + repeater: Repeater, +) -> RepeaterPolicySyncStatusResponse: + return RepeaterPolicySyncStatusResponse( + repeater_id=repeater.id, + node_name=repeater.node_name, + template_id=sync_status.template_id, + command_id=sync_status.command_id, + payload_hash=sync_status.payload_hash, + status=sync_status.status, + error_message=sync_status.error_message, + queued_at=sync_status.queued_at, + dispatched_at=sync_status.dispatched_at, + completed_at=sync_status.completed_at, + updated_at=sync_status.updated_at, + ) + + +@router.get("/templates", response_model=list[RepeaterPolicyTemplateResponse]) +def list_templates( + db: Session = Depends(get_db_session), + _: User = Depends(require_roles("admin", "operator", "viewer")), +) -> list[RepeaterPolicyTemplateResponse]: + templates = db.scalars( + select(RepeaterPolicyTemplate).order_by(RepeaterPolicyTemplate.name.asc()) + ).all() + return [_to_template_response(template) for template in templates] + + +@router.post( + "/templates", + response_model=RepeaterPolicyTemplateResponse, + status_code=status.HTTP_201_CREATED, +) +def create_template( + payload: RepeaterPolicyTemplateCreateRequest, + db: Session = Depends(get_db_session), + current_user: User = Depends(require_roles("admin", "operator")), +) -> RepeaterPolicyTemplateResponse: + try: + normalized_policy = normalize_policy_document(payload.policy) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc) + ) from exc + + template = RepeaterPolicyTemplate( + name=payload.name.strip(), + description=payload.description, + enabled=1 if payload.enabled else 0, + policy_json=json.dumps(normalized_policy, sort_keys=True), + ) + db.add(template) + try: + db.flush() + except IntegrityError as exc: + db.rollback() + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Repeater policy template already exists", + ) from exc + write_audit_log( + db, + action="repeater_policy_template_created", + target_type="repeater_policy_template", + target_id=template.id, + user_id=current_user.id, + details={"name": template.name}, + ) + db.commit() + db.refresh(template) + return _to_template_response(template) + + +@router.patch("/templates/{template_id}", response_model=RepeaterPolicyTemplateResponse) +def update_template( + template_id: str, + payload: RepeaterPolicyTemplateUpdateRequest, + db: Session = Depends(get_db_session), + current_user: User = Depends(require_roles("admin", "operator")), +) -> RepeaterPolicyTemplateResponse: + template = db.scalar( + select(RepeaterPolicyTemplate).where(RepeaterPolicyTemplate.id == template_id) + ) + if template is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Repeater policy template not found" + ) + + changes = payload.model_dump(exclude_unset=True) + if "policy" in changes and changes["policy"] is not None: + try: + changes["policy"] = normalize_policy_document(changes["policy"]) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc) + ) from exc + + if "name" in changes and changes["name"] is not None: + template.name = str(changes["name"]).strip() + if "description" in changes: + template.description = changes["description"] + if "enabled" in changes and changes["enabled"] is not None: + template.enabled = 1 if changes["enabled"] else 0 + if "policy" in changes and changes["policy"] is not None: + template.policy_json = json.dumps(changes["policy"], sort_keys=True) + + try: + db.flush() + except IntegrityError as exc: + db.rollback() + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Repeater policy template already exists", + ) from exc + write_audit_log( + db, + action="repeater_policy_template_updated", + target_type="repeater_policy_template", + target_id=template.id, + user_id=current_user.id, + details={"changes": {key: value for key, value in changes.items() if key != "policy"}}, + ) + db.commit() + db.refresh(template) + return _to_template_response(template) + + +@router.delete("/templates/{template_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_template( + template_id: str, + db: Session = Depends(get_db_session), + current_user: User = Depends(require_roles("admin", "operator")), +) -> None: + template = db.scalar( + select(RepeaterPolicyTemplate).where(RepeaterPolicyTemplate.id == template_id) + ) + if template is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Repeater policy template not found" + ) + template_name = template.name + db.delete(template) + write_audit_log( + db, + action="repeater_policy_template_deleted", + target_type="repeater_policy_template", + target_id=template_id, + user_id=current_user.id, + details={"name": template_name}, + ) + db.commit() + + +@router.post("/validate", response_model=RepeaterPolicyValidateResponse) +def validate_policy( + payload: RepeaterPolicyValidateRequest, + _: User = Depends(require_roles("admin", "operator", "viewer")), +) -> RepeaterPolicyValidateResponse: + errors = validate_policy_document(payload.policy) + if errors: + return RepeaterPolicyValidateResponse(valid=False, errors=errors) + return RepeaterPolicyValidateResponse( + valid=True, + errors=[], + normalized_policy=normalize_policy_document(payload.policy), + ) + + +@router.get("/sync-status", response_model=list[RepeaterPolicySyncStatusResponse]) +def list_sync_status( + db: Session = Depends(get_db_session), + _: User = Depends(require_roles("admin", "operator", "viewer")), +) -> list[RepeaterPolicySyncStatusResponse]: + rows = db.execute( + select(RepeaterPolicySyncStatus, Repeater) + .join(Repeater, Repeater.id == RepeaterPolicySyncStatus.repeater_id) + .order_by(Repeater.node_name.asc()) + ).all() + return [_to_sync_status_response(sync_status, repeater) for sync_status, repeater in rows] + + +@router.post("/sync", response_model=RepeaterPolicySyncResponse) +def sync_policy( + payload: RepeaterPolicySyncRequest, + db: Session = Depends(get_db_session), + current_user: User = Depends(require_roles("admin", "operator")), +) -> RepeaterPolicySyncResponse: + template_id: str | None = None + if payload.template_id: + template = db.scalar( + select(RepeaterPolicyTemplate).where(RepeaterPolicyTemplate.id == payload.template_id) + ) + if template is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Repeater policy template not found" + ) + if template.enabled != 1: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail="Repeater policy template is disabled" + ) + policy = _load_policy_json(template) + template_id = template.id + else: + policy = payload.policy or {} + + try: + normalized_policy = normalize_policy_document(policy) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc) + ) from exc + + repeaters_query = select(Repeater).where(~Repeater.status.in_(["pending_adoption", "rejected"])) + if not payload.all_repeaters: + repeaters_query = repeaters_query.where(Repeater.id.in_(payload.repeater_ids)) + repeaters = db.scalars(repeaters_query.order_by(Repeater.node_name.asc())).all() + if not repeaters: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="No eligible repeaters found" + ) + + now = _utc_now() + payload_hash = policy_payload_hash(normalized_policy) + command_ids: list[str] = [] + responses: list[RepeaterPolicySyncStatusResponse] = [] + for repeater in repeaters: + command_payload = { + "policy": normalized_policy, + "mode": payload.mode, + "validate_only": payload.validate_only, + "source": "openHop Glass", + } + if template_id: + command_payload["template_id"] = template_id + command = CommandQueueItem( + repeater_id=repeater.id, + command="policy_sync", + params_json=json.dumps(command_payload, sort_keys=True), + status="queued", + requested_by=current_user.email, + ) + db.add(command) + db.flush() + command_ids.append(command.id) + + sync_status = db.scalar( + select(RepeaterPolicySyncStatus).where( + RepeaterPolicySyncStatus.repeater_id == repeater.id + ) + ) + if sync_status is None: + sync_status = RepeaterPolicySyncStatus(repeater_id=repeater.id) + db.add(sync_status) + sync_status.template_id = template_id + sync_status.command_id = command.id + sync_status.payload_hash = payload_hash + sync_status.status = "queued" + sync_status.error_message = None + sync_status.queued_at = now + sync_status.dispatched_at = None + sync_status.completed_at = None + sync_status.updated_at = now + responses.append(_to_sync_status_response(sync_status, repeater)) + + write_audit_log( + db, + action="repeater_policy_sync_queued", + target_type="repeater_policy_template" if template_id else "repeater_policy", + target_id=template_id, + user_id=current_user.id, + details={ + "queued_commands": len(command_ids), + "mode": payload.mode, + "validate_only": payload.validate_only, + "reason": payload.reason, + "all_repeaters": payload.all_repeaters, + }, + ) + db.commit() + return RepeaterPolicySyncResponse( + queued_commands=len(command_ids), + command_ids=command_ids, + statuses=responses, + ) diff --git a/backend/app/api/routes/repeaters.py b/backend/app/api/routes/repeaters.py index 4bb0bba..c146f0a 100644 --- a/backend/app/api/routes/repeaters.py +++ b/backend/app/api/routes/repeaters.py @@ -34,6 +34,7 @@ def _utc_now() -> datetime: return datetime.now(UTC) + def _ensure_utc(value: datetime | None) -> datetime | None: if value is None: return None @@ -58,10 +59,12 @@ def _parse_json(value: str | None) -> dict[str, Any] | None: return None return parsed if isinstance(parsed, dict) else None + def _parse_json_object(value: str | None) -> dict[str, Any]: parsed = _parse_json(value) return parsed or {} + def _settings_expect_mqtt_tls(settings: dict[str, Any] | None) -> bool: if not settings: return False @@ -74,8 +77,10 @@ def _settings_expect_mqtt_tls(settings: dict[str, Any] | None) -> bool: mqtt_settings = settings.get("mqtt") if isinstance(mqtt_settings, dict): tls = mqtt_settings.get("tls") - if bool(mqtt_settings.get("enabled")) and isinstance(tls, dict) and bool( - tls.get("enabled") + if ( + bool(mqtt_settings.get("enabled")) + and isinstance(tls, dict) + and bool(tls.get("enabled")) ): return True return False @@ -347,6 +352,8 @@ def _to_response(repeater: Repeater) -> RepeaterResponse: firmware_version=repeater.firmware_version, location=repeater.location, config_hash=repeater.config_hash, + inform_ip=repeater.inform_ip, + open_url=repeater.open_url, last_inform_at=repeater.last_inform_at, created_at=repeater.created_at, updated_at=repeater.updated_at, @@ -385,6 +392,8 @@ def _to_detail_response( firmware_version=repeater.firmware_version, location=repeater.location, config_hash=repeater.config_hash, + inform_ip=repeater.inform_ip, + open_url=repeater.open_url, last_inform_at=repeater.last_inform_at, created_at=repeater.created_at, updated_at=repeater.updated_at, @@ -542,6 +551,9 @@ def update_repeater( ) changes = payload.model_dump(exclude_none=True) + if "open_url" in payload.model_fields_set: + open_url = payload.open_url.strip() if isinstance(payload.open_url, str) else None + changes["open_url"] = open_url or None for key, value in changes.items(): setattr(repeater, key, value) diff --git a/backend/app/api/routes/system_settings.py b/backend/app/api/routes/system_settings.py index ad5400c..191befd 100644 --- a/backend/app/api/routes/system_settings.py +++ b/backend/app/api/routes/system_settings.py @@ -2,14 +2,14 @@ import re from datetime import UTC, datetime -from fastapi import APIRouter, Depends, HTTPException, status from cryptography.fernet import Fernet +from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import select from sqlalchemy.orm import Session +from app.config import get_settings from app.db.models import CommandQueueItem, Repeater, User from app.db.session import get_db_session -from app.config import get_settings from app.schemas.system_settings import ( ConfigSnapshotEncryptionKeyGenerateRequest, ConfigSnapshotEncryptionKeyGenerateResponse, @@ -21,14 +21,15 @@ ) from app.security.deps import require_roles from app.services.audit import write_audit_log +from app.services.pki import PkiService from app.services.system_settings import ( get_effective_config_snapshot_encryption_keys, get_effective_managed_mqtt_settings, + managed_mqtt_repeater_payload, managed_mqtt_view_payload, save_config_snapshot_encryption_keys, save_managed_mqtt_settings, ) -from app.services.pki import PkiService router = APIRouter(prefix="/api/system-settings") _KEY_ID_PATTERN = re.compile(r"^[A-Za-z0-9._-]{1,64}$") @@ -54,6 +55,7 @@ def _to_response( mqtt_broker_port=int(view_payload.get("mqtt_broker_port", 1883)), mqtt_base_topic=str(view_payload.get("mqtt_base_topic", "glass")), mqtt_tls_enabled=bool(view_payload.get("mqtt_tls_enabled", False)), + mqtt_broker_additional_hosts=list(view_payload.get("mqtt_broker_additional_hosts") or []), source=source, updated_at=_normalize_datetime(updated_at), ) @@ -75,7 +77,7 @@ def _queue_update_for_repeaters( payload = { "config": { - "glass_managed": managed_mqtt_view_payload(effective_settings), + "glass_managed": managed_mqtt_repeater_payload(effective_settings), }, "merge_mode": "patch", } @@ -154,10 +156,16 @@ def update_managed_mqtt_settings( "mqtt_broker_port", "mqtt_base_topic", "mqtt_tls_enabled", + "mqtt_broker_additional_hosts", } ) save_payload["mqtt_base_topic"] = str(save_payload["mqtt_base_topic"]).strip().strip("/") save_payload["mqtt_broker_host"] = str(save_payload["mqtt_broker_host"]).strip() + save_payload["mqtt_broker_additional_hosts"] = [ + str(host).strip() + for host in save_payload.get("mqtt_broker_additional_hosts", []) + if str(host).strip() + ] if not save_payload["mqtt_broker_host"]: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, @@ -172,10 +180,15 @@ def update_managed_mqtt_settings( effective_settings, _, _ = get_effective_managed_mqtt_settings(db) broker_host = str(effective_settings.get("mqtt_broker_host", "")).strip() + additional_hosts = [ + str(host).strip() + for host in effective_settings.get("mqtt_broker_additional_hosts", []) + if str(host).strip() + ] pki_service = PkiService(get_settings()) pki_service.ensure_ca() broker_cert_reissued = pki_service.ensure_mqtt_broker_server_certificate( - extra_san_hosts=[broker_host] if broker_host else None + extra_san_hosts=([broker_host] if broker_host else []) + additional_hosts ) queued_commands = 0 if payload.queue_to_repeaters: @@ -198,6 +211,7 @@ def update_managed_mqtt_settings( "mqtt_broker_port": effective_settings.get("mqtt_broker_port"), "mqtt_base_topic": effective_settings.get("mqtt_base_topic"), "mqtt_tls_enabled": effective_settings.get("mqtt_tls_enabled"), + "mqtt_broker_additional_hosts": additional_hosts, "queue_to_repeaters": payload.queue_to_repeaters, "queued_commands": queued_commands, "broker_cert_reissued": broker_cert_reissued, diff --git a/backend/app/api/routes/transport_keys.py b/backend/app/api/routes/transport_keys.py index 377608c..b9e5b77 100644 --- a/backend/app/api/routes/transport_keys.py +++ b/backend/app/api/routes/transport_keys.py @@ -77,7 +77,9 @@ def _name_is_used( def _load_group_or_404(db: Session, group_id: str) -> TransportKeyGroup: group = db.scalar(select(TransportKeyGroup).where(TransportKeyGroup.id == group_id)) if group is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Transport key group not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Transport key group not found" + ) return group @@ -131,9 +133,13 @@ def _descendant_group_ids(db: Session, group_id: str) -> set[str]: def _tree_nodes(db: Session) -> list[TransportKeyTreeNodeResponse]: groups = db.scalars( - select(TransportKeyGroup).order_by(TransportKeyGroup.sort_order.asc(), TransportKeyGroup.name.asc()) + select(TransportKeyGroup).order_by( + TransportKeyGroup.sort_order.asc(), TransportKeyGroup.name.asc() + ) + ).all() + keys = db.scalars( + select(TransportKey).order_by(TransportKey.sort_order.asc(), TransportKey.name.asc()) ).all() - keys = db.scalars(select(TransportKey).order_by(TransportKey.sort_order.asc(), TransportKey.name.asc())).all() nodes: list[TransportKeyTreeNodeResponse] = [] for group in groups: nodes.append( @@ -236,12 +242,16 @@ def list_transport_key_groups( _: User = Depends(require_roles("admin", "operator", "viewer")), ) -> list[TransportKeyGroupResponse]: groups = db.scalars( - select(TransportKeyGroup).order_by(TransportKeyGroup.sort_order.asc(), TransportKeyGroup.name.asc()) + select(TransportKeyGroup).order_by( + TransportKeyGroup.sort_order.asc(), TransportKeyGroup.name.asc() + ) ).all() return [_to_group_response(group) for group in groups] -@router.post("/groups", response_model=TransportKeyGroupResponse, status_code=status.HTTP_201_CREATED) +@router.post( + "/groups", response_model=TransportKeyGroupResponse, status_code=status.HTTP_201_CREATED +) def create_transport_key_group( payload: TransportKeyGroupCreateRequest, db: Session = Depends(get_db_session), @@ -249,7 +259,9 @@ def create_transport_key_group( ) -> TransportKeyGroupResponse: name = payload.name.strip() if _name_is_used(db, candidate=name): - raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Transport key/group name already exists") + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail="Transport key/group name already exists" + ) if payload.parent_group_id: _load_group_or_404(db, payload.parent_group_id) group = TransportKeyGroup( @@ -372,7 +384,9 @@ def delete_transport_key_group( ).all() for child in direct_children: child.parent_group_id = reassign_to_group_id - direct_keys = db.scalars(select(TransportKey).where(TransportKey.group_id == group.id)).all() + direct_keys = db.scalars( + select(TransportKey).where(TransportKey.group_id == group.id) + ).all() for key in direct_keys: key.group_id = reassign_to_group_id db.delete(group) @@ -382,7 +396,9 @@ def delete_transport_key_group( keys = db.scalars(select(TransportKey).where(TransportKey.group_id.in_(subtree_ids))).all() for key in keys: db.delete(key) - groups = db.scalars(select(TransportKeyGroup).where(TransportKeyGroup.id.in_(subtree_ids))).all() + groups = db.scalars( + select(TransportKeyGroup).where(TransportKeyGroup.id.in_(subtree_ids)) + ).all() for child_group in groups: db.delete(child_group) @@ -404,7 +420,9 @@ def list_transport_keys( db: Session = Depends(get_db_session), _: User = Depends(require_roles("admin", "operator", "viewer")), ) -> list[TransportKeyResponse]: - keys = db.scalars(select(TransportKey).order_by(TransportKey.sort_order.asc(), TransportKey.name.asc())).all() + keys = db.scalars( + select(TransportKey).order_by(TransportKey.sort_order.asc(), TransportKey.name.asc()) + ).all() return [_to_key_response(key) for key in keys] @@ -416,7 +434,9 @@ def create_transport_key( ) -> TransportKeyResponse: name = payload.name.strip() if _name_is_used(db, candidate=name): - raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Transport key/group name already exists") + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail="Transport key/group name already exists" + ) if payload.group_id is not None: _load_group_or_404(db, payload.group_id) key = TransportKey( @@ -522,4 +542,3 @@ def trigger_transport_key_sync( ) db.commit() return response - diff --git a/backend/app/config.py b/backend/app/config.py index e3d83e6..b5af586 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -7,7 +7,7 @@ class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") - app_name: str = "pyMC_Glass API" + app_name: str = "openHop Glass API" app_env: str = "development" app_host: str = "0.0.0.0" app_port: int = 8080 @@ -30,7 +30,7 @@ class Settings(BaseSettings): mqtt_ingest_enabled: bool = True mqtt_ingest_queue_maxsize: int = 2000 pki_state_dir: str = "/app/data/pki" - pki_ca_common_name: str = "pyMC_Glass Local CA" + pki_ca_common_name: str = "openHop Glass Local CA" pki_ca_valid_days: int = 3650 pki_client_cert_valid_days: int = 90 pki_renew_before_days: int = 30 @@ -38,7 +38,7 @@ class Settings(BaseSettings): auth_token_bytes: int = 48 auth_password_min_length: int = 12 bootstrap_seed_admin_enabled: bool = True - bootstrap_seed_admin_email: str = "admin@pymc.glass" + bootstrap_seed_admin_email: str = "admin@openhop.glass" bootstrap_seed_admin_password: str = "admin12345678" bootstrap_seed_admin_display_name: str = "Admin" alert_policy_monitor_enabled: bool = True @@ -59,4 +59,3 @@ class Settings(BaseSettings): @lru_cache(maxsize=1) def get_settings() -> Settings: return Settings() - diff --git a/backend/app/contracts/__init__.py b/backend/app/contracts/__init__.py index 5287f07..ad0b37f 100644 --- a/backend/app/contracts/__init__.py +++ b/backend/app/contracts/__init__.py @@ -1,2 +1 @@ """Contract model packages.""" - diff --git a/backend/app/contracts/v1/__init__.py b/backend/app/contracts/v1/__init__.py index cd9a965..9d5e4f3 100644 --- a/backend/app/contracts/v1/__init__.py +++ b/backend/app/contracts/v1/__init__.py @@ -1,2 +1 @@ -"""Versioned contracts for pyMC_Glass.""" - +"""Versioned contracts for openHop Glass.""" diff --git a/backend/app/contracts/v1/api_dto.py b/backend/app/contracts/v1/api_dto.py index 6e7d633..7d550b6 100644 --- a/backend/app/contracts/v1/api_dto.py +++ b/backend/app/contracts/v1/api_dto.py @@ -30,4 +30,3 @@ class AlertRecordV1(BaseModel): message: str = Field(min_length=1, max_length=1024) created_at: datetime resolved_at: Optional[datetime] = None - diff --git a/backend/app/contracts/v1/command.py b/backend/app/contracts/v1/command.py index f8319ad..14e15de 100644 --- a/backend/app/contracts/v1/command.py +++ b/backend/app/contracts/v1/command.py @@ -20,6 +20,8 @@ class CommandAction(str, Enum): export_config = "export_config" export_identity = "export_identity" run_diagnostic = "run_diagnostic" + transport_keys_sync = "transport_keys_sync" + policy_sync = "policy_sync" class QueueCommandRequestV1(BaseModel): @@ -43,4 +45,3 @@ class CommandResultV1(BaseModel): status: str = Field(min_length=1, max_length=32) message: Optional[str] = Field(default=None, max_length=1024) completed_at: datetime - diff --git a/backend/app/contracts/v1/common.py b/backend/app/contracts/v1/common.py index a10c08e..7b785bd 100644 --- a/backend/app/contracts/v1/common.py +++ b/backend/app/contracts/v1/common.py @@ -27,4 +27,3 @@ class Severity(str, Enum): class TimestampedModel(BaseModel): timestamp: datetime = Field(description="RFC3339 UTC timestamp") - diff --git a/backend/app/contracts/v1/inform.py b/backend/app/contracts/v1/inform.py index f182dc5..2a00cc2 100644 --- a/backend/app/contracts/v1/inform.py +++ b/backend/app/contracts/v1/inform.py @@ -55,6 +55,7 @@ class InformRequestV1(BaseModel): radio: InformRadioStatsV1 counters: InformCountersV1 settings: Dict[str, Any] = Field(default_factory=dict) + sensors: Union[Dict[str, Any], List[Dict[str, Any]], None] = None command_results: List[CommandResultPayloadV1] = Field(default_factory=list) @@ -100,4 +101,3 @@ class InformUpgradeResponseV1(BaseModel): InformCertRenewalResponseV1, InformUpgradeResponseV1, ] - diff --git a/backend/app/contracts/v1/mqtt.py b/backend/app/contracts/v1/mqtt.py index 25a7ab2..b4dd2d2 100644 --- a/backend/app/contracts/v1/mqtt.py +++ b/backend/app/contracts/v1/mqtt.py @@ -26,4 +26,3 @@ class MqttEventEnvelopeV1(MqttEnvelopeBaseV1): type: Literal["event"] = "event" event_name: str = Field(min_length=1, max_length=64) payload: Dict[str, Any] = Field(default_factory=dict) - diff --git a/backend/app/db/__init__.py b/backend/app/db/__init__.py index db0ab22..f8080b4 100644 --- a/backend/app/db/__init__.py +++ b/backend/app/db/__init__.py @@ -1,2 +1 @@ -"""Database package for pyMC_Glass.""" - +"""Database package for openHop Glass.""" diff --git a/backend/app/db/base.py b/backend/app/db/base.py index 132805f..92628b8 100644 --- a/backend/app/db/base.py +++ b/backend/app/db/base.py @@ -3,4 +3,3 @@ class Base(DeclarativeBase): """Declarative base for all ORM models.""" - diff --git a/backend/app/db/migrate.py b/backend/app/db/migrate.py index 31d3e3f..72f1761 100644 --- a/backend/app/db/migrate.py +++ b/backend/app/db/migrate.py @@ -62,4 +62,3 @@ def apply_migrations(engine: Engine) -> None: text(insert_migration), {"version": version}, ) - diff --git a/backend/app/db/migrations/0013_repeater_policies.sql b/backend/app/db/migrations/0013_repeater_policies.sql new file mode 100644 index 0000000..0ade7c8 --- /dev/null +++ b/backend/app/db/migrations/0013_repeater_policies.sql @@ -0,0 +1,40 @@ +CREATE TABLE IF NOT EXISTS repeater_policy_templates ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + description TEXT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + policy_json TEXT NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL +); + +CREATE INDEX IF NOT EXISTS ix_repeater_policy_templates_enabled +ON repeater_policy_templates(enabled); + +CREATE TABLE IF NOT EXISTS repeater_policy_sync_status ( + repeater_id TEXT PRIMARY KEY, + template_id TEXT NULL, + command_id TEXT NULL, + payload_hash TEXT NULL, + status TEXT NOT NULL DEFAULT 'idle', + error_message TEXT NULL, + queued_at TIMESTAMP NULL, + dispatched_at TIMESTAMP NULL, + completed_at TIMESTAMP NULL, + updated_at TIMESTAMP NOT NULL, + FOREIGN KEY (repeater_id) REFERENCES repeaters(id) ON DELETE CASCADE, + FOREIGN KEY (template_id) REFERENCES repeater_policy_templates(id) ON DELETE SET NULL, + FOREIGN KEY (command_id) REFERENCES command_queue(id) ON DELETE SET NULL +); + +CREATE INDEX IF NOT EXISTS ix_repeater_policy_sync_status_template_id +ON repeater_policy_sync_status(template_id); + +CREATE INDEX IF NOT EXISTS ix_repeater_policy_sync_status_command_id +ON repeater_policy_sync_status(command_id); + +CREATE INDEX IF NOT EXISTS ix_repeater_policy_sync_status_payload_hash +ON repeater_policy_sync_status(payload_hash); + +CREATE INDEX IF NOT EXISTS ix_repeater_policy_sync_status_status +ON repeater_policy_sync_status(status); diff --git a/backend/app/db/migrations/0014_repeater_open_url.sql b/backend/app/db/migrations/0014_repeater_open_url.sql new file mode 100644 index 0000000..1eaa3c8 --- /dev/null +++ b/backend/app/db/migrations/0014_repeater_open_url.sql @@ -0,0 +1 @@ +ALTER TABLE repeaters ADD COLUMN open_url TEXT NULL; diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 6b32423..26787fb 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -68,6 +68,7 @@ class Repeater(Base): nullable=True, ) inform_ip: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) + open_url: Mapped[Optional[str]] = mapped_column(Text, nullable=True) config_hash: Mapped[Optional[str]] = mapped_column(String(80), nullable=True) cert_serial: Mapped[Optional[str]] = mapped_column(String(128), nullable=True) cert_expires_at: Mapped[Optional[datetime]] = mapped_column( @@ -85,6 +86,7 @@ class Repeater(Base): onupdate=_now_utc, ) + class TopologyObservationSample(Base): __tablename__ = "topology_observation_samples" __table_args__ = ( @@ -113,6 +115,7 @@ class TopologyObservationSample(Base): snr: Mapped[Optional[float]] = mapped_column(Float, nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now_utc) + class TransportKeyGroup(Base): __tablename__ = "transport_key_groups" @@ -172,7 +175,9 @@ class TransportKeySyncStatus(Base): status: Mapped[str] = mapped_column(String(32), default="idle", index=True) error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True) queued_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) - dispatched_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + dispatched_at: Mapped[Optional[datetime]] = mapped_column( + DateTime(timezone=True), nullable=True + ) completed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), @@ -180,6 +185,55 @@ class TransportKeySyncStatus(Base): onupdate=_now_utc, ) + +class RepeaterPolicyTemplate(Base): + __tablename__ = "repeater_policy_templates" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_new_id) + name: Mapped[str] = mapped_column(String(128), unique=True, index=True) + description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + enabled: Mapped[int] = mapped_column(Integer, default=1, index=True) + policy_json: Mapped[str] = mapped_column(Text) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now_utc) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=_now_utc, + onupdate=_now_utc, + ) + + +class RepeaterPolicySyncStatus(Base): + __tablename__ = "repeater_policy_sync_status" + + repeater_id: Mapped[str] = mapped_column( + ForeignKey("repeaters.id", ondelete="CASCADE"), + primary_key=True, + ) + template_id: Mapped[Optional[str]] = mapped_column( + ForeignKey("repeater_policy_templates.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) + command_id: Mapped[Optional[str]] = mapped_column( + ForeignKey("command_queue.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) + payload_hash: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, index=True) + status: Mapped[str] = mapped_column(String(32), default="idle", index=True) + error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + queued_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + dispatched_at: Mapped[Optional[datetime]] = mapped_column( + DateTime(timezone=True), nullable=True + ) + completed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=_now_utc, + onupdate=_now_utc, + ) + + class NodeGroup(Base): __tablename__ = "node_groups" @@ -248,6 +302,7 @@ class Packet(Base): payload: Mapped[Optional[str]] = mapped_column(Text, nullable=True) packet_hash: Mapped[Optional[str]] = mapped_column(String(128), unique=True, nullable=True) + class MqttIngestEvent(Base): __tablename__ = "mqtt_ingest_events" @@ -263,6 +318,8 @@ class MqttIngestEvent(Base): payload_json: Mapped[str] = mapped_column(Text) dedup_key: Mapped[str] = mapped_column(String(128), unique=True, index=True) ingested_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now_utc) + + class TopologyNode(Base): __tablename__ = "topology_nodes" @@ -273,7 +330,9 @@ class TopologyNode(Base): contact_type: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, index=True) latitude: Mapped[Optional[float]] = mapped_column(Float, nullable=True) longitude: Mapped[Optional[float]] = mapped_column(Float, nullable=True) - first_seen_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + first_seen_at: Mapped[Optional[datetime]] = mapped_column( + DateTime(timezone=True), nullable=True + ) last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True) last_observed_by_repeater_id: Mapped[Optional[str]] = mapped_column( ForeignKey("repeaters.id", ondelete="SET NULL"), @@ -314,7 +373,9 @@ class TopologyObservation(Base): longitude: Mapped[Optional[float]] = mapped_column(Float, nullable=True) rssi: Mapped[Optional[float]] = mapped_column(Float, nullable=True) snr: Mapped[Optional[float]] = mapped_column(Float, nullable=True) - first_seen_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + first_seen_at: Mapped[Optional[datetime]] = mapped_column( + DateTime(timezone=True), nullable=True + ) last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True) advert_count: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) last_event_timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True)) @@ -371,6 +432,7 @@ class CommandQueueItem(Base): result_json: Mapped[Optional[str]] = mapped_column(Text, nullable=True) requested_by: Mapped[Optional[str]] = mapped_column(String(128), nullable=True) + class ConfigSnapshot(Base): __tablename__ = "config_snapshots" @@ -434,6 +496,7 @@ class Alert(Base): note: Mapped[Optional[str]] = mapped_column(Text, nullable=True) resolved_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + class AlertPolicyTemplate(Base): __tablename__ = "alert_policy_templates" @@ -476,6 +539,7 @@ class AlertPolicyAssignment(Base): onupdate=_now_utc, ) + class AlertActionIntegration(Base): __tablename__ = "alert_action_integrations" diff --git a/backend/app/db/session.py b/backend/app/db/session.py index acf21ad..1640f6a 100644 --- a/backend/app/db/session.py +++ b/backend/app/db/session.py @@ -51,4 +51,3 @@ def reset_db_caches() -> None: get_session_factory.cache_clear() get_engine.cache_clear() - diff --git a/backend/app/main.py b/backend/app/main.py index 362c566..9d2ce59 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -5,9 +5,9 @@ from app.api.routes.adoption import router as adoption_router from app.api.routes.alert_actions import router as alert_actions_router -from app.api.routes.alerts import router as alerts_router from app.api.routes.alert_policy import groups_router as node_groups_router from app.api.routes.alert_policy import router as alert_policy_router +from app.api.routes.alerts import router as alerts_router from app.api.routes.audit import router as audit_router from app.api.routes.auth import router as auth_router from app.api.routes.bootstrap import router as bootstrap_router @@ -18,6 +18,7 @@ from app.api.routes.inform import router as inform_router from app.api.routes.insights import router as insights_router from app.api.routes.packets import router as packets_router +from app.api.routes.repeater_policy import router as repeater_policy_router from app.api.routes.repeaters import router as repeaters_router from app.api.routes.smoke import router as smoke_router from app.api.routes.system_settings import router as system_settings_router @@ -27,8 +28,8 @@ from app.config import get_settings from app.db.migrate import apply_migrations from app.db.session import get_engine, get_session_factory -from app.services.alert_actions import get_notification_provider_registry from app.services.alert_action_dispatcher import AlertActionDispatcherService +from app.services.alert_actions import get_notification_provider_registry from app.services.alert_policy_monitor import AlertPolicyMonitorService from app.services.bootstrap_seed import seed_default_admin_if_needed from app.services.mqtt_ingest import MqttIngestService @@ -44,13 +45,20 @@ async def lifespan(_: FastAPI): session_factory = get_session_factory() seed_default_admin_if_needed(settings, session_factory) managed_mqtt_host = "" + managed_mqtt_additional_hosts: list[str] = [] with session_factory() as db: managed_mqtt_settings, _, _ = get_effective_managed_mqtt_settings(db) managed_mqtt_host = str(managed_mqtt_settings.get("mqtt_broker_host", "")).strip() + managed_mqtt_additional_hosts = [ + str(host).strip() + for host in managed_mqtt_settings.get("mqtt_broker_additional_hosts", []) + if str(host).strip() + ] pki_service = PkiService(settings) pki_service.ensure_ca() pki_service.ensure_mqtt_broker_server_certificate( - extra_san_hosts=[managed_mqtt_host] if managed_mqtt_host else None + extra_san_hosts=([managed_mqtt_host] if managed_mqtt_host else []) + + managed_mqtt_additional_hosts ) pki_service.ensure_backend_mqtt_client_certificate() mqtt_ingest = MqttIngestService( @@ -80,7 +88,7 @@ def create_app() -> FastAPI: settings = get_settings() app = FastAPI( title=settings.app_name, - version = "1.0.4", + version="1.1.0", docs_url="/docs", redoc_url="/redoc", lifespan=lifespan, @@ -104,6 +112,7 @@ def create_app() -> FastAPI: app.include_router(alert_policy_router, tags=["alert-policies"]) app.include_router(node_groups_router, tags=["node-groups"]) app.include_router(transport_keys_router, tags=["transport-keys"]) + app.include_router(repeater_policy_router, tags=["repeater-policies"]) app.include_router(audit_router, tags=["audit"]) app.include_router(users_router, tags=["users"]) app.include_router(smoke_router, tags=["smoke"]) @@ -128,4 +137,3 @@ def run() -> None: if __name__ == "__main__": run() - diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 8e2820d..84cd2d6 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -1,2 +1 @@ """Pydantic request/response schemas for API routes.""" - diff --git a/backend/app/schemas/adoption.py b/backend/app/schemas/adoption.py index 903931a..6f70215 100644 --- a/backend/app/schemas/adoption.py +++ b/backend/app/schemas/adoption.py @@ -9,4 +9,3 @@ class AdoptionActionResponse(BaseModel): repeater_id: str node_name: str status: str - diff --git a/backend/app/schemas/alert_actions.py b/backend/app/schemas/alert_actions.py index 2a8f3cc..07bd62b 100644 --- a/backend/app/schemas/alert_actions.py +++ b/backend/app/schemas/alert_actions.py @@ -73,6 +73,7 @@ class PushoverIntegrationSettings(BaseModel): priority: int = Field(default=0, ge=-2, le=2) sound: str | None = Field(default=None, max_length=64) + class AppriseIntegrationSettings(BaseModel): api_url: AnyHttpUrl urls: list[str] = Field(default_factory=list) diff --git a/backend/app/schemas/alert_policy.py b/backend/app/schemas/alert_policy.py index 45c71c9..2bb06f9 100644 --- a/backend/app/schemas/alert_policy.py +++ b/backend/app/schemas/alert_policy.py @@ -116,9 +116,7 @@ class AlertPolicyAssignmentCreateRequest(BaseModel): def validate_scope_type(cls, value: str) -> str: normalized = value.strip() if normalized not in VALID_SCOPE_TYPES: - raise ValueError( - f"scope_type must be one of: {', '.join(sorted(VALID_SCOPE_TYPES))}" - ) + raise ValueError(f"scope_type must be one of: {', '.join(sorted(VALID_SCOPE_TYPES))}") return normalized diff --git a/backend/app/schemas/audit.py b/backend/app/schemas/audit.py index d6e3696..cdb89f8 100644 --- a/backend/app/schemas/audit.py +++ b/backend/app/schemas/audit.py @@ -12,4 +12,3 @@ class AuditRecordResponse(BaseModel): target_type: str | None target_id: str | None details: dict[str, Any] - diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index 0535f1c..1c03fab 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -20,4 +20,3 @@ class LoginResponse(BaseModel): token_type: str = "bearer" expires_at: datetime user: UserInfoResponse - diff --git a/backend/app/schemas/bootstrap.py b/backend/app/schemas/bootstrap.py index 8c17cf7..b89b556 100644 --- a/backend/app/schemas/bootstrap.py +++ b/backend/app/schemas/bootstrap.py @@ -16,4 +16,3 @@ class BootstrapAdminResponse(BaseModel): user_id: str email: str role: str - diff --git a/backend/app/schemas/commands.py b/backend/app/schemas/commands.py index 3be862c..b8667f8 100644 --- a/backend/app/schemas/commands.py +++ b/backend/app/schemas/commands.py @@ -15,4 +15,3 @@ class CommandQueueItemResponse(BaseModel): requested_by: str | None = None created_at: datetime completed_at: datetime | None = None - diff --git a/backend/app/schemas/insights.py b/backend/app/schemas/insights.py index cf809b5..d7ec068 100644 --- a/backend/app/schemas/insights.py +++ b/backend/app/schemas/insights.py @@ -78,6 +78,7 @@ class TopologySummaryResponse(BaseModel): top_observer_node_name: str | None = None top_observer_count: int | None = None + class TopologyRepeaterTrafficShareResponse(BaseModel): repeater_id: str repeater_node_name: str diff --git a/backend/app/schemas/repeater.py b/backend/app/schemas/repeater.py index 41f26be..1da1045 100644 --- a/backend/app/schemas/repeater.py +++ b/backend/app/schemas/repeater.py @@ -16,6 +16,7 @@ class RepeaterUpdateRequest(BaseModel): status: str | None = Field(default=None, min_length=1, max_length=32) firmware_version: str | None = Field(default=None, max_length=64) location: str | None = Field(default=None, max_length=255) + open_url: str | None = Field(default=None, max_length=255) config_hash: str | None = Field(default=None, max_length=80) last_inform_at: datetime | None = None @@ -28,6 +29,8 @@ class RepeaterResponse(BaseModel): firmware_version: str | None = None location: str | None = None config_hash: str | None = None + inform_ip: str | None = None + open_url: str | None = None last_inform_at: datetime | None = None created_at: datetime updated_at: datetime @@ -46,6 +49,7 @@ class InformSnapshotPointResponse(BaseModel): dropped: int | None = None airtime_percent: float | None = None + class RepeaterCertDiagnosticLogResponse(BaseModel): timestamp: datetime severity: str @@ -66,4 +70,3 @@ class RepeaterDetailResponse(RepeaterResponse): class DeleteStaleRepeatersResponse(BaseModel): removed: int - diff --git a/backend/app/schemas/repeater_policy.py b/backend/app/schemas/repeater_policy.py new file mode 100644 index 0000000..ea7a7e2 --- /dev/null +++ b/backend/app/schemas/repeater_policy.py @@ -0,0 +1,78 @@ +from datetime import datetime +from typing import Any, Literal + +from pydantic import BaseModel, Field, model_validator + + +class RepeaterPolicyTemplateCreateRequest(BaseModel): + name: str = Field(min_length=1, max_length=128) + description: str | None = Field(default=None, max_length=2048) + enabled: bool = True + policy: dict[str, Any] + + +class RepeaterPolicyTemplateUpdateRequest(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=128) + description: str | None = Field(default=None, max_length=2048) + enabled: bool | None = None + policy: dict[str, Any] | None = None + + +class RepeaterPolicyTemplateResponse(BaseModel): + id: str + name: str + description: str | None = None + enabled: bool + policy: dict[str, Any] + created_at: datetime + updated_at: datetime + + +class RepeaterPolicyValidateRequest(BaseModel): + policy: dict[str, Any] + + +class RepeaterPolicyValidateResponse(BaseModel): + valid: bool + errors: list[str] = Field(default_factory=list) + normalized_policy: dict[str, Any] | None = None + + +class RepeaterPolicySyncRequest(BaseModel): + template_id: str | None = None + policy: dict[str, Any] | None = None + repeater_ids: list[str] = Field(default_factory=list) + all_repeaters: bool = False + mode: Literal["replace", "patch"] = "replace" + validate_only: bool = False + reason: str | None = Field(default=None, max_length=256) + + @model_validator(mode="after") + def validate_payload(self) -> "RepeaterPolicySyncRequest": + if not self.template_id and self.policy is None: + raise ValueError("template_id or policy is required") + if self.template_id and self.policy is not None: + raise ValueError("template_id and policy are mutually exclusive") + if not self.all_repeaters and not self.repeater_ids: + raise ValueError("all_repeaters or repeater_ids is required") + return self + + +class RepeaterPolicySyncStatusResponse(BaseModel): + repeater_id: str + node_name: str + template_id: str | None = None + command_id: str | None = None + payload_hash: str | None = None + status: str + error_message: str | None = None + queued_at: datetime | None = None + dispatched_at: datetime | None = None + completed_at: datetime | None = None + updated_at: datetime | None = None + + +class RepeaterPolicySyncResponse(BaseModel): + queued_commands: int + command_ids: list[str] + statuses: list[RepeaterPolicySyncStatusResponse] diff --git a/backend/app/schemas/system_settings.py b/backend/app/schemas/system_settings.py index cb99ab3..6b009e3 100644 --- a/backend/app/schemas/system_settings.py +++ b/backend/app/schemas/system_settings.py @@ -9,6 +9,7 @@ class ManagedMqttSettingsResponse(BaseModel): mqtt_broker_port: int mqtt_base_topic: str mqtt_tls_enabled: bool + mqtt_broker_additional_hosts: list[str] = Field(default_factory=list) source: str updated_at: datetime | None = None @@ -19,6 +20,7 @@ class ManagedMqttSettingsUpdateRequest(BaseModel): mqtt_broker_port: int = Field(ge=1, le=65535) mqtt_base_topic: str = Field(min_length=1, max_length=255) mqtt_tls_enabled: bool + mqtt_broker_additional_hosts: list[str] = Field(default_factory=list, max_length=16) queue_to_repeaters: bool = False reason: str | None = Field(default=None, max_length=256) diff --git a/backend/app/schemas/transport_keys.py b/backend/app/schemas/transport_keys.py index a20b77f..b70ff76 100644 --- a/backend/app/schemas/transport_keys.py +++ b/backend/app/schemas/transport_keys.py @@ -135,4 +135,3 @@ class TransportKeySyncTriggerResponse(BaseModel): payload_hash: str queued_commands: int skipped_commands: int - diff --git a/backend/app/security/__init__.py b/backend/app/security/__init__.py index 230456f..31772bb 100644 --- a/backend/app/security/__init__.py +++ b/backend/app/security/__init__.py @@ -1,2 +1 @@ """Security helpers for auth/password primitives.""" - diff --git a/backend/app/security/deps.py b/backend/app/security/deps.py index 8790608..0ac80dc 100644 --- a/backend/app/security/deps.py +++ b/backend/app/security/deps.py @@ -11,6 +11,7 @@ bearer_scheme = HTTPBearer(auto_error=False) + def _authenticate_token(raw_token: str, db: Session) -> User: token_hash = hash_token(raw_token) token = db.scalar(select(AuthToken).where(AuthToken.token_hash == token_hash)) @@ -49,15 +50,14 @@ def get_current_user( ) return _authenticate_token(credentials.credentials, db) + def get_current_user_bearer_or_query( credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme), token: str | None = Query(default=None), db: Session = Depends(get_db_session), ) -> User: raw_token = ( - credentials.credentials - if credentials is not None - else (token.strip() if token else None) + credentials.credentials if credentials is not None else (token.strip() if token else None) ) if not raw_token: raise HTTPException( @@ -77,4 +77,3 @@ def _dependency(current_user: User = Depends(get_current_user)) -> User: return current_user return _dependency - diff --git a/backend/app/security/passwords.py b/backend/app/security/passwords.py index 1fa6978..190cde2 100644 --- a/backend/app/security/passwords.py +++ b/backend/app/security/passwords.py @@ -25,4 +25,3 @@ def verify_password(password: str, stored_hash: str) -> bool: return hmac.compare_digest(calculated, expected_digest) except Exception: return False - diff --git a/backend/app/security/tokens.py b/backend/app/security/tokens.py index 4d1bea9..35f0467 100644 --- a/backend/app/security/tokens.py +++ b/backend/app/security/tokens.py @@ -8,4 +8,3 @@ def generate_token(nbytes: int = 48) -> str: def hash_token(raw_token: str) -> str: return hashlib.sha256(raw_token.encode("utf-8")).hexdigest() - diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py index c092105..23f8428 100644 --- a/backend/app/services/__init__.py +++ b/backend/app/services/__init__.py @@ -1,2 +1 @@ """Service-layer helpers.""" - diff --git a/backend/app/services/alert_action_delivery.py b/backend/app/services/alert_action_delivery.py index bead092..fc53a10 100644 --- a/backend/app/services/alert_action_delivery.py +++ b/backend/app/services/alert_action_delivery.py @@ -180,9 +180,7 @@ def _render_action_payload( body = render_template_text(action_template.body_template, context) payload_template = parse_action_template_payload(action_template) rendered_payload_value = ( - render_template_value(payload_template, context) - if payload_template is not None - else {} + render_template_value(payload_template, context) if payload_template is not None else {} ) rendered_payload: dict[str, Any] if isinstance(rendered_payload_value, dict): @@ -217,9 +215,7 @@ def render_action_template_preview( title = render_template_text(title_template, context) body = render_template_text(body_template, context) rendered_payload_value = ( - render_template_value(payload_template, context) - if payload_template is not None - else {} + render_template_value(payload_template, context) if payload_template is not None else {} ) payload: dict[str, Any] if isinstance(rendered_payload_value, dict): @@ -315,9 +311,7 @@ def enqueue_policy_action_notifications( ) ) action_template = db.scalar( - select(AlertActionTemplate).where( - AlertActionTemplate.id == binding.action_template_id - ) + select(AlertActionTemplate).where(AlertActionTemplate.id == binding.action_template_id) ) if integration is None or action_template is None: continue @@ -343,9 +337,7 @@ def enqueue_policy_action_notifications( f"{alert.id}:{binding.id}:{normalized_event_type}:{transition_key.strip()}" ) existing = db.scalar( - select(NotificationEvent.id).where( - NotificationEvent.idempotency_key == idempotency_key - ) + select(NotificationEvent.id).where(NotificationEvent.idempotency_key == idempotency_key) ) if existing is not None: continue @@ -381,9 +373,7 @@ def enqueue_policy_action_notifications( binding_id=binding.id, provider_type=integration.provider_type, idempotency_key=idempotency_key, - rendered_payload={ - "template_error": f"Missing template variable: {missing_token}" - }, + rendered_payload={"template_error": f"Missing template variable: {missing_token}"}, ) event.status = "failed" event.last_error = f"Template rendering failed: missing variable '{missing_token}'" @@ -591,4 +581,3 @@ def run_action_dispatch_batch( provider_message_id=send_result.provider_message_id, ) return processed - diff --git a/backend/app/services/alert_action_dispatcher.py b/backend/app/services/alert_action_dispatcher.py index ba8c639..93fce53 100644 --- a/backend/app/services/alert_action_dispatcher.py +++ b/backend/app/services/alert_action_dispatcher.py @@ -86,4 +86,3 @@ def _dispatch_once(self) -> None: raise if processed > 0: logger.debug("Alert action dispatcher processed %s queued event(s)", processed) - diff --git a/backend/app/services/alert_action_templating.py b/backend/app/services/alert_action_templating.py index 3b03809..ff04689 100644 --- a/backend/app/services/alert_action_templating.py +++ b/backend/app/services/alert_action_templating.py @@ -54,13 +54,9 @@ def render_template_value(value: Any, context: Mapping[str, Any]) -> Any: if isinstance(value, str): return render_template_text(value, context) if isinstance(value, dict): - return { - str(key): render_template_value(item, context) - for key, item in value.items() - } + return {str(key): render_template_value(item, context) for key, item in value.items()} if isinstance(value, list): return [render_template_value(item, context) for item in value] if isinstance(value, tuple): return [render_template_value(item, context) for item in value] return _json_safe_value(value) - diff --git a/backend/app/services/alert_actions.py b/backend/app/services/alert_actions.py index 9d64559..98c3c5c 100644 --- a/backend/app/services/alert_actions.py +++ b/backend/app/services/alert_actions.py @@ -16,15 +16,15 @@ ) from app.schemas.alert_actions import ( VALID_ALERT_ACTION_EVENT_TYPES, - AlertPolicyActionBindingCreateRequest, - AlertPolicyActionBindingResponse, - AlertPolicyActionBindingUpdateRequest, AlertActionIntegrationCreateRequest, AlertActionIntegrationResponse, AlertActionIntegrationUpdateRequest, AlertActionTemplateCreateRequest, AlertActionTemplateResponse, AlertActionTemplateUpdateRequest, + AlertPolicyActionBindingCreateRequest, + AlertPolicyActionBindingResponse, + AlertPolicyActionBindingUpdateRequest, ) from app.services.notification_providers import NotificationProviderRegistry @@ -159,11 +159,15 @@ def to_template_response(template: AlertActionTemplate) -> AlertActionTemplateRe def list_action_integrations(db: Session) -> list[AlertActionIntegration]: - return db.scalars(select(AlertActionIntegration).order_by(AlertActionIntegration.name.asc())).all() + return db.scalars( + select(AlertActionIntegration).order_by(AlertActionIntegration.name.asc()) + ).all() def get_action_integration(db: Session, integration_id: str) -> AlertActionIntegration | None: - return db.scalar(select(AlertActionIntegration).where(AlertActionIntegration.id == integration_id)) + return db.scalar( + select(AlertActionIntegration).where(AlertActionIntegration.id == integration_id) + ) def create_action_integration( @@ -182,11 +186,7 @@ def create_action_integration( description=payload.description, enabled=1 if payload.enabled else 0, settings_json=serialize_integration_settings(validated_settings), - secrets_json=( - serialize_integration_secrets(payload.secrets) - if payload.secrets - else None - ), + secrets_json=(serialize_integration_secrets(payload.secrets) if payload.secrets else None), ) db.add(integration) db.flush() @@ -278,9 +278,7 @@ def update_action_template( if "payload_template" in changes: payload_template = changes["payload_template"] template.payload_template_json = ( - _serialize_json(payload_template) - if isinstance(payload_template, dict) - else None + _serialize_json(payload_template) if isinstance(payload_template, dict) else None ) if "default_event_types" in changes: default_events = changes["default_event_types"] @@ -317,7 +315,9 @@ def list_action_bindings(db: Session) -> list[AlertPolicyActionBinding]: def get_action_binding(db: Session, binding_id: str) -> AlertPolicyActionBinding | None: - return db.scalar(select(AlertPolicyActionBinding).where(AlertPolicyActionBinding.id == binding_id)) + return db.scalar( + select(AlertPolicyActionBinding).where(AlertPolicyActionBinding.id == binding_id) + ) def _validate_binding_relationships( @@ -343,10 +343,7 @@ def _validate_binding_relationships( if action_template is None: raise ValueError("Referenced alert action template was not found") template_provider_type = _normalize_provider_type(action_template.provider_type) - if ( - template_provider_type is not None - and template_provider_type != integration.provider_type - ): + if template_provider_type is not None and template_provider_type != integration.provider_type: raise ValueError( "Action template provider_type must match integration provider_type when specified" ) diff --git a/backend/app/services/alert_policy.py b/backend/app/services/alert_policy.py index 7df9032..57e8ff3 100644 --- a/backend/app/services/alert_policy.py +++ b/backend/app/services/alert_policy.py @@ -118,8 +118,10 @@ def _settings_expect_mqtt_tls(settings: dict[str, Any]) -> bool: mqtt_settings = settings.get("mqtt") if isinstance(mqtt_settings, dict): tls = mqtt_settings.get("tls") - if bool(mqtt_settings.get("enabled")) and isinstance(tls, dict) and bool( - tls.get("enabled") + if ( + bool(mqtt_settings.get("enabled")) + and isinstance(tls, dict) + and bool(tls.get("enabled")) ): return True return False @@ -161,7 +163,9 @@ def list_effective_policies_for_repeater( group_ids = [membership.group_id for membership in membership_rows] filters = [ - and_(AlertPolicyAssignment.scope_type == "global", AlertPolicyAssignment.scope_id.is_(None)), + and_( + AlertPolicyAssignment.scope_type == "global", AlertPolicyAssignment.scope_id.is_(None) + ), and_( AlertPolicyAssignment.scope_type == "node", AlertPolicyAssignment.scope_id == repeater.id, @@ -416,7 +420,10 @@ def _evaluate_tls_telemetry_stale_policy( ) -> tuple[bool, str]: settings = _parse_config(repeater.settings_json) if not _settings_expect_mqtt_tls(settings): - return (False, "TLS telemetry health check skipped: repeater settings do not require MQTT TLS.") + return ( + False, + "TLS telemetry health check skipped: repeater settings do not require MQTT TLS.", + ) grace_seconds = policy.template.offline_grace_seconds or 600 last_inform_at = _normalize_datetime(repeater.last_inform_at) @@ -473,13 +480,15 @@ def _evaluate_tls_telemetry_stale_policy( return ( True, ( - f"Inform heartbeat is stale ({inform_age_seconds}s old) and MQTT telemetry is stale " + f"Inform heartbeat is stale ({inform_age_seconds}s old) and MQTT telemetry " + "is stale " f"({telemetry_age_seconds}s old) after successful TLS configuration update; " "certificate trust or mTLS handshake mismatch is likely." ) if telemetry_age_seconds is not None else ( - f"Inform heartbeat is stale ({inform_age_seconds}s old) and no MQTT telemetry has been " + f"Inform heartbeat is stale ({inform_age_seconds}s old) and no MQTT " + "telemetry has been " "ingested after successful TLS configuration update; certificate trust or mTLS " "handshake mismatch is likely." ), @@ -488,8 +497,9 @@ def _evaluate_tls_telemetry_stale_policy( return ( True, ( - f"Inform heartbeat is stale ({inform_age_seconds}s old) and MQTT telemetry is stale " - f"({telemetry_age_seconds}s old) while TLS is enabled; investigate certificate and broker trust." + f"Inform heartbeat is stale ({inform_age_seconds}s old) and MQTT telemetry " + f"is stale ({telemetry_age_seconds}s old) while TLS is enabled; investigate " + "certificate and broker trust." ) if telemetry_age_seconds is not None else ( @@ -503,7 +513,9 @@ def _evaluate_temperature_policy( repeater: Repeater, policy: EffectivePolicyItem, ) -> tuple[bool, str]: - threshold = policy.template.threshold_value if policy.template.threshold_value is not None else 80.0 + threshold = ( + policy.template.threshold_value if policy.template.threshold_value is not None else 80.0 + ) system_payload = _parse_system_json(repeater.system_json) raw_temperature = system_payload.get("temperature_c") if raw_temperature is None: @@ -524,10 +536,17 @@ def _evaluate_drop_rate_policy( now: datetime, ) -> tuple[bool, str]: window_minutes = policy.template.window_minutes or 15 - threshold = policy.template.threshold_value if policy.template.threshold_value is not None else 0.05 + threshold = ( + policy.template.threshold_value if policy.template.threshold_value is not None else 0.05 + ) window_start = now - timedelta(minutes=window_minutes) rows = db.execute( - select(InformSnapshot.timestamp, InformSnapshot.rx_total, InformSnapshot.tx_total, InformSnapshot.dropped) + select( + InformSnapshot.timestamp, + InformSnapshot.rx_total, + InformSnapshot.tx_total, + InformSnapshot.dropped, + ) .where( InformSnapshot.repeater_id == repeater.id, InformSnapshot.timestamp >= window_start, @@ -552,7 +571,8 @@ def _evaluate_drop_rate_policy( should_alert = drop_rate >= threshold message = ( f"Drop rate {drop_rate * 100:.2f}% over {window_minutes}m " - f"(threshold {threshold * 100:.2f}%, dropped_delta={dropped_delta}, traffic_delta={traffic_delta})." + f"(threshold {threshold * 100:.2f}%, dropped_delta={dropped_delta}, " + f"traffic_delta={traffic_delta})." ) return should_alert, message diff --git a/backend/app/services/audit.py b/backend/app/services/audit.py index 41828f2..b80f7f3 100644 --- a/backend/app/services/audit.py +++ b/backend/app/services/audit.py @@ -24,4 +24,3 @@ def write_audit_log( ) db.add(record) return record - diff --git a/backend/app/services/bootstrap_seed.py b/backend/app/services/bootstrap_seed.py index 01ae0e9..785e537 100644 --- a/backend/app/services/bootstrap_seed.py +++ b/backend/app/services/bootstrap_seed.py @@ -32,9 +32,7 @@ def seed_default_admin_if_needed( "BOOTSTRAP_SEED_ADMIN_PASSWORD must be set when BOOTSTRAP_SEED_ADMIN_ENABLED is true" ) if len(password) < settings.auth_password_min_length: - raise RuntimeError( - "BOOTSTRAP_SEED_ADMIN_PASSWORD is shorter than AUTH_PASSWORD_MIN_LENGTH" - ) + raise RuntimeError("BOOTSTRAP_SEED_ADMIN_PASSWORD is shorter than AUTH_PASSWORD_MIN_LENGTH") with session_factory() as db: total_users = db.scalar(select(func.count()).select_from(User)) or 0 diff --git a/backend/app/services/config_snapshot.py b/backend/app/services/config_snapshot.py index 44b63ef..4162386 100644 --- a/backend/app/services/config_snapshot.py +++ b/backend/app/services/config_snapshot.py @@ -31,6 +31,7 @@ class SnapshotStoreResult: stored_new_snapshot: bool change_control: dict[str, Any] + def _split_encryption_key_entries(raw_value: str | None) -> list[tuple[str, str]]: if not raw_value: return [] @@ -82,9 +83,7 @@ class ConfigSnapshotService: def __init__(self, settings: Settings, *, encryption_keys: str | None = None): self.settings = settings raw_keys = ( - settings.config_snapshot_encryption_keys - if encryption_keys is None - else encryption_keys + settings.config_snapshot_encryption_keys if encryption_keys is None else encryption_keys ) self._keys = parse_config_snapshot_encryption_keys(raw_keys) self._decryptors = {key_id: fernet for key_id, fernet in self._keys} @@ -93,7 +92,6 @@ def __init__(self, settings: Settings, *, encryption_keys: str | None = None): def configured(self) -> bool: return bool(self._keys) - @staticmethod def extract_export_payload(details: dict[str, Any]) -> dict[str, Any]: if not details: @@ -250,9 +248,7 @@ def store_snapshot( payload: dict[str, Any], ) -> SnapshotStoreResult: if not self._keys: - raise SnapshotEncryptionError( - "Config snapshot encryption keys are not configured." - ) + raise SnapshotEncryptionError("Config snapshot encryption keys are not configured.") if not isinstance(payload, dict) or not payload: raise SnapshotPayloadError("Snapshot payload must be a non-empty object.") @@ -373,9 +369,7 @@ def prune_retention(self, db: Session, *, repeater_id: str) -> int: def decrypt_snapshot_payload(self, snapshot: ConfigSnapshot) -> dict[str, Any]: if not self._decryptors: - raise SnapshotEncryptionError( - "Config snapshot encryption keys are not configured." - ) + raise SnapshotEncryptionError("Config snapshot encryption keys are not configured.") ciphertext = snapshot.ciphertext.encode("utf-8") attempted: list[Fernet] = [] diff --git a/backend/app/services/mqtt_ingest.py b/backend/app/services/mqtt_ingest.py index c96fd39..907ed17 100644 --- a/backend/app/services/mqtt_ingest.py +++ b/backend/app/services/mqtt_ingest.py @@ -296,7 +296,9 @@ def _persist_advert_topology( first_seen = last_seen normalized_pubkey = pubkey.strip().lower() - topology_node = db.scalar(select(TopologyNode).where(TopologyNode.pubkey == normalized_pubkey)) + topology_node = db.scalar( + select(TopologyNode).where(TopologyNode.pubkey == normalized_pubkey) + ) if topology_node is None: topology_node = TopologyNode( pubkey=normalized_pubkey, @@ -322,7 +324,9 @@ def _persist_advert_topology( topology_node.latitude = latitude topology_node.longitude = longitude topology_node.first_seen_at = _min_datetime(topology_node.first_seen_at, first_seen) - topology_node.last_seen_at = _max_datetime(topology_node.last_seen_at, last_seen) or last_seen + topology_node.last_seen_at = ( + _max_datetime(topology_node.last_seen_at, last_seen) or last_seen + ) topology_node.last_observed_by_repeater_id = repeater.id observation = db.scalar( @@ -364,14 +368,17 @@ def _persist_advert_topology( if snr is not None: observation.snr = snr observation.first_seen_at = _min_datetime(observation.first_seen_at, first_seen) - observation.last_seen_at = _max_datetime(observation.last_seen_at, last_seen) or last_seen + observation.last_seen_at = ( + _max_datetime(observation.last_seen_at, last_seen) or last_seen + ) if advert_count is not None: if observation.advert_count is None: observation.advert_count = advert_count else: observation.advert_count = max(observation.advert_count, advert_count) observation.last_event_timestamp = ( - _max_datetime(observation.last_event_timestamp, message.timestamp) or message.timestamp + _max_datetime(observation.last_event_timestamp, message.timestamp) + or message.timestamp ) observation.last_ingested_at = ingested_at sample = db.scalar( @@ -620,7 +627,9 @@ async def start(self) -> None: ) if self._settings.mqtt_tls_enabled: tls_kwargs: dict[str, Any] = { - "cert_reqs": ssl.CERT_NONE if self._settings.mqtt_tls_insecure else ssl.CERT_REQUIRED, + "cert_reqs": ssl.CERT_NONE + if self._settings.mqtt_tls_insecure + else ssl.CERT_REQUIRED, "tls_version": ssl.PROTOCOL_TLS_CLIENT, } if self._settings.mqtt_tls_ca_cert: @@ -640,7 +649,8 @@ async def start(self) -> None: ) if bool(tls_kwargs.get("certfile")) != bool(tls_kwargs.get("keyfile")): raise RuntimeError( - "mqtt_tls_client_cert and mqtt_tls_client_key must both be configured when using client certificates" + "mqtt_tls_client_cert and mqtt_tls_client_key must both be configured " + "when using client certificates" ) client.tls_set(**tls_kwargs) if self._settings.mqtt_tls_insecure: diff --git a/backend/app/services/notification_providers/__init__.py b/backend/app/services/notification_providers/__init__.py index 920eeb2..0a53517 100644 --- a/backend/app/services/notification_providers/__init__.py +++ b/backend/app/services/notification_providers/__init__.py @@ -1,9 +1,9 @@ +from app.services.notification_providers.apprise import AppriseNotificationProvider from app.services.notification_providers.base import ( NotificationProviderCapability, NotificationSendRequest, NotificationSendResult, ) -from app.services.notification_providers.apprise import AppriseNotificationProvider from app.services.notification_providers.pushover import PushoverNotificationProvider from app.services.notification_providers.registry import NotificationProviderRegistry from app.services.notification_providers.webhook import WebhookNotificationProvider diff --git a/backend/app/services/notification_providers/apprise.py b/backend/app/services/notification_providers/apprise.py index d454279..3949861 100644 --- a/backend/app/services/notification_providers/apprise.py +++ b/backend/app/services/notification_providers/apprise.py @@ -26,7 +26,7 @@ class AppriseNotificationProvider: supports_send=True, supports_templated_payload=True, ) - _USER_AGENT = "pyMC_Glass/alert-actions" + _USER_AGENT = "openHop Glass/alert-actions" def validate_settings(self, settings: dict[str, Any]) -> dict[str, Any]: validated = AppriseIntegrationSettings.model_validate(settings) @@ -91,7 +91,9 @@ def _resolve_body(self, *, payload: dict[str, Any], request: NotificationSendReq @staticmethod def _resolve_notify_type(*, payload: dict[str, Any], default_notify_type: str) -> str: candidate = payload.get("type", payload.get("notify_type", default_notify_type)) - normalized = str(candidate).strip().lower() if candidate is not None else default_notify_type + normalized = ( + str(candidate).strip().lower() if candidate is not None else default_notify_type + ) if normalized in VALID_APPRISE_NOTIFY_TYPES: return normalized return default_notify_type @@ -231,6 +233,8 @@ def send( error=error_message or f"Apprise request failed with HTTP {exc.code}", ) except urllib_error.URLError as exc: - return NotificationSendResult(status="failed", error=f"Apprise request failed: {exc.reason}") + return NotificationSendResult( + status="failed", error=f"Apprise request failed: {exc.reason}" + ) except TimeoutError: return NotificationSendResult(status="failed", error="Apprise request timed out") diff --git a/backend/app/services/notification_providers/base.py b/backend/app/services/notification_providers/base.py index 42d273f..94ed26c 100644 --- a/backend/app/services/notification_providers/base.py +++ b/backend/app/services/notification_providers/base.py @@ -35,16 +35,13 @@ class NotificationProviderCapability: class NotificationProvider(Protocol): capability: NotificationProviderCapability - def validate_settings(self, settings: dict[str, Any]) -> dict[str, Any]: - ... + def validate_settings(self, settings: dict[str, Any]) -> dict[str, Any]: ... - def build_payload(self, request: NotificationSendRequest) -> dict[str, Any]: - ... + def build_payload(self, request: NotificationSendRequest) -> dict[str, Any]: ... def send( self, *, settings: dict[str, Any], request: NotificationSendRequest, - ) -> NotificationSendResult: - ... + ) -> NotificationSendResult: ... diff --git a/backend/app/services/notification_providers/pushover.py b/backend/app/services/notification_providers/pushover.py index ad690b3..3a231c6 100644 --- a/backend/app/services/notification_providers/pushover.py +++ b/backend/app/services/notification_providers/pushover.py @@ -1,6 +1,6 @@ from __future__ import annotations -import json +import json from typing import Any from urllib import error as urllib_error from urllib import parse as urllib_parse @@ -22,7 +22,7 @@ class PushoverNotificationProvider: supports_templated_payload=True, ) _PUSHOVER_API_URL = "https://api.pushover.net/1/messages.json" - _USER_AGENT = "pyMC_Glass/alert-actions" + _USER_AGENT = "openHop Glass/alert-actions" def validate_settings(self, settings: dict[str, Any]) -> dict[str, Any]: validated = PushoverIntegrationSettings.model_validate(settings) @@ -163,6 +163,8 @@ def send( error=f"Pushover request failed with HTTP {exc.code}", ) except urllib_error.URLError as exc: - return NotificationSendResult(status="failed", error=f"Pushover request failed: {exc.reason}") + return NotificationSendResult( + status="failed", error=f"Pushover request failed: {exc.reason}" + ) except TimeoutError: return NotificationSendResult(status="failed", error="Pushover request timed out") diff --git a/backend/app/services/notification_providers/registry.py b/backend/app/services/notification_providers/registry.py index e47a07f..8fd845b 100644 --- a/backend/app/services/notification_providers/registry.py +++ b/backend/app/services/notification_providers/registry.py @@ -2,13 +2,13 @@ from typing import Any, Iterable +from app.services.notification_providers.apprise import AppriseNotificationProvider from app.services.notification_providers.base import ( NotificationProvider, NotificationProviderCapability, NotificationSendRequest, NotificationSendResult, ) -from app.services.notification_providers.apprise import AppriseNotificationProvider from app.services.notification_providers.pushover import PushoverNotificationProvider from app.services.notification_providers.webhook import WebhookNotificationProvider diff --git a/backend/app/services/notification_providers/webhook.py b/backend/app/services/notification_providers/webhook.py index f0a5afa..a7ca9db 100644 --- a/backend/app/services/notification_providers/webhook.py +++ b/backend/app/services/notification_providers/webhook.py @@ -39,7 +39,9 @@ def send( ) -> NotificationSendResult: validated = WebhookIntegrationSettings.model_validate(settings) payload = self.build_payload(request) - body = json.dumps(payload, separators=(",", ":"), sort_keys=True, default=str).encode("utf-8") + body = json.dumps(payload, separators=(",", ":"), sort_keys=True, default=str).encode( + "utf-8" + ) if len(body) > validated.max_body_bytes: return NotificationSendResult( status="failed", @@ -49,7 +51,7 @@ def send( ), ) - headers = {"Content-Type": "application/json", "User-Agent": "pyMC_Glass/alert-actions"} + headers = {"Content-Type": "application/json", "User-Agent": "openHop Glass/alert-actions"} headers.update(validated.headers) request_obj = urllib_request.Request( url=str(validated.url), @@ -87,7 +89,9 @@ def send( error=f"Webhook request failed with HTTP {exc.code}", ) except urllib_error.URLError as exc: - return NotificationSendResult(status="failed", error=f"Webhook request failed: {exc.reason}") + return NotificationSendResult( + status="failed", error=f"Webhook request failed: {exc.reason}" + ) except TimeoutError: return NotificationSendResult(status="failed", error="Webhook request timed out") diff --git a/backend/app/services/pki.py b/backend/app/services/pki.py index 6ac7dce..58be51a 100644 --- a/backend/app/services/pki.py +++ b/backend/app/services/pki.py @@ -50,7 +50,7 @@ def ensure_ca(self) -> None: subject = x509.Name( [ x509.NameAttribute(NameOID.COUNTRY_NAME, "XX"), - x509.NameAttribute(NameOID.ORGANIZATION_NAME, "pyMC_Glass"), + x509.NameAttribute(NameOID.ORGANIZATION_NAME, "openHop Glass"), x509.NameAttribute(NameOID.COMMON_NAME, self._settings.pki_ca_common_name), ] ) @@ -177,9 +177,7 @@ def ensure_mqtt_broker_server_certificate( ) -> bool: with self._lock: self.ensure_ca() - dns_names, ip_names = self._broker_server_san_entries( - extra_san_hosts=extra_san_hosts - ) + dns_names, ip_names = self._broker_server_san_entries(extra_san_hosts=extra_san_hosts) if ( self._mqtt_broker_key_path.exists() and self._mqtt_broker_cert_path.exists() @@ -213,7 +211,7 @@ def ensure_backend_mqtt_client_certificate(self) -> None: self._issue_leaf_certificate( key_path=self._mqtt_backend_key_path, cert_path=self._mqtt_backend_cert_path, - common_name="pymc-glass-backend", + common_name="openhop-glass-backend", ext_key_usages=[ExtendedKeyUsageOID.CLIENT_AUTH], ) @@ -286,9 +284,7 @@ def _certificate_matches_subject_alt_names( san = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName).value except x509.ExtensionNotFound: return False - existing_dns = { - str(name).strip().lower() for name in san.get_values_for_type(x509.DNSName) - } + existing_dns = {str(name).strip().lower() for name in san.get_values_for_type(x509.DNSName)} existing_ips = {str(name) for name in san.get_values_for_type(x509.IPAddress)} required_dns = {str(name).strip().lower() for name in dns_names if str(name).strip()} required_ips = {str(name).strip() for name in ip_names if str(name).strip()} diff --git a/backend/app/services/repeater_policy.py b/backend/app/services/repeater_policy.py new file mode 100644 index 0000000..a9479a4 --- /dev/null +++ b/backend/app/services/repeater_policy.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +import hashlib +import json +from datetime import UTC, datetime +from typing import Any + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.db.models import RepeaterPolicySyncStatus + +SUPPORTED_ACTIONS = {"allow", "drop", "log_only"} +SUPPORTED_LOGICAL_KEYS = {"all", "any"} + + +def normalize_policy_document(policy: dict[str, Any]) -> dict[str, Any]: + if not isinstance(policy, dict): + raise ValueError("policy must be an object") + + normalized = dict(policy) + normalized["enabled"] = bool(normalized.get("enabled", True)) + default_action = str(normalized.get("default_action", "allow")).strip() + if default_action not in SUPPORTED_ACTIONS: + raise ValueError("default_action must be one of allow, drop, log_only") + normalized["default_action"] = default_action + + rules = normalized.get("rules", []) + if rules is None: + rules = [] + if not isinstance(rules, list): + raise ValueError("rules must be a list") + normalized_rules: list[dict[str, Any]] = [] + for index, rule in enumerate(rules): + normalized_rules.append(_normalize_rule(rule, index=index)) + normalized["rules"] = normalized_rules + + objects = normalized.get("objects", {}) + if objects is None: + objects = {} + if not isinstance(objects, dict): + raise ValueError("objects must be an object") + normalized["objects"] = objects + return normalized + + +def policy_payload_hash(policy: dict[str, Any]) -> str: + encoded = json.dumps(policy, sort_keys=True, separators=(",", ":")).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def _utc_now() -> datetime: + return datetime.now(UTC) + + +def mark_repeater_policy_sync_dispatched( + db: Session, + *, + repeater_id: str, + command_id: str, +) -> None: + row = db.scalar( + select(RepeaterPolicySyncStatus).where(RepeaterPolicySyncStatus.repeater_id == repeater_id) + ) + if row is None: + row = RepeaterPolicySyncStatus(repeater_id=repeater_id) + db.add(row) + now = _utc_now() + row.command_id = command_id + row.status = "dispatched" + row.dispatched_at = now + row.updated_at = now + + +def mark_repeater_policy_sync_result( + db: Session, + *, + repeater_id: str, + command_id: str, + status: str, + message: str | None, + completed_at: datetime | None, +) -> None: + row = db.scalar( + select(RepeaterPolicySyncStatus).where(RepeaterPolicySyncStatus.repeater_id == repeater_id) + ) + if row is None: + row = RepeaterPolicySyncStatus(repeater_id=repeater_id) + db.add(row) + normalized_status = status.lower().strip() + if normalized_status not in {"success", "failed", "partial"}: + normalized_status = "failed" + now = _utc_now() + row.command_id = command_id + row.status = normalized_status + row.error_message = message if normalized_status in {"failed", "partial"} else None + row.completed_at = completed_at or now + row.updated_at = now + + +def validate_policy_document(policy: dict[str, Any]) -> list[str]: + try: + normalize_policy_document(policy) + except ValueError as exc: + return [str(exc)] + return [] + + +def _normalize_rule(rule: Any, *, index: int) -> dict[str, Any]: + if not isinstance(rule, dict): + raise ValueError(f"rules[{index}] must be an object") + normalized = dict(rule) + name = str(normalized.get("name") or f"rule-{index + 1}").strip() + if not name: + raise ValueError(f"rules[{index}].name cannot be empty") + normalized["name"] = name + normalized["enabled"] = bool(normalized.get("enabled", True)) + + condition = normalized.get("if", {}) + if not isinstance(condition, dict): + raise ValueError(f"rules[{index}].if must be an object") + if condition: + logical_keys = [key for key in condition if key in SUPPORTED_LOGICAL_KEYS] + if len(logical_keys) != 1: + raise ValueError(f"rules[{index}].if must contain exactly one of all or any") + entries = condition[logical_keys[0]] + if not isinstance(entries, list) or not entries: + raise ValueError(f"rules[{index}].if.{logical_keys[0]} must be a non-empty list") + for entry_index, entry in enumerate(entries): + if not isinstance(entry, dict): + raise ValueError( + f"rules[{index}].if.{logical_keys[0]}[{entry_index}] must be an object" + ) + if "field" not in entry or not str(entry.get("field") or "").strip(): + raise ValueError( + f"rules[{index}].if.{logical_keys[0]}[{entry_index}].field is required" + ) + if "op" not in entry and "operator" not in entry: + raise ValueError( + f"rules[{index}].if.{logical_keys[0]}[{entry_index}].op is required" + ) + normalized["if"] = condition + + then = normalized.get("then") + if not isinstance(then, dict): + raise ValueError(f"rules[{index}].then must be an object") + action = str(then.get("action", "")).strip() + if action not in SUPPORTED_ACTIONS: + raise ValueError(f"rules[{index}].then.action must be one of allow, drop, log_only") + normalized["then"] = dict(then, action=action) + return normalized diff --git a/backend/app/services/system_settings.py b/backend/app/services/system_settings.py index 92acf13..d9708f5 100644 --- a/backend/app/services/system_settings.py +++ b/backend/app/services/system_settings.py @@ -17,6 +17,14 @@ "mqtt_broker_port", "mqtt_base_topic", "mqtt_tls_enabled", + "mqtt_broker_additional_hosts", +) +_MANAGED_MQTT_REPEATER_FIELDS = ( + "mqtt_enabled", + "mqtt_broker_host", + "mqtt_broker_port", + "mqtt_base_topic", + "mqtt_tls_enabled", ) @@ -32,6 +40,7 @@ def default_managed_mqtt_settings() -> dict[str, Any]: "mqtt_broker_port": settings.mqtt_broker_port, "mqtt_base_topic": settings.mqtt_base_topic, "mqtt_tls_enabled": settings.mqtt_repeater_tls_enabled, + "mqtt_broker_additional_hosts": [], } if settings.mqtt_broker_username: payload["mqtt_username"] = settings.mqtt_broker_username @@ -55,13 +64,37 @@ def sanitize_managed_mqtt_overrides(raw: dict[str, Any]) -> dict[str, Any]: sanitized["mqtt_base_topic"] = str(raw["mqtt_base_topic"] or "").strip().strip("/") if "mqtt_tls_enabled" in raw: sanitized["mqtt_tls_enabled"] = bool(raw["mqtt_tls_enabled"]) + if "mqtt_broker_additional_hosts" in raw: + sanitized["mqtt_broker_additional_hosts"] = _sanitize_host_list( + raw["mqtt_broker_additional_hosts"] + ) return sanitized +def _sanitize_host_list(raw: Any) -> list[str]: + if isinstance(raw, str): + candidates = raw.replace("\n", ",").split(",") + elif isinstance(raw, list): + candidates = raw + else: + return [] + + hosts: list[str] = [] + seen: set[str] = set() + for candidate in candidates: + host = str(candidate or "").strip() + if not host: + continue + key = host.lower() + if key in seen: + continue + seen.add(key) + hosts.append(host) + return hosts + + def _load_stored_overrides(db: Session) -> tuple[dict[str, Any], datetime | None]: - row = db.scalar( - select(SystemSetting).where(SystemSetting.key == MANAGED_MQTT_SETTINGS_KEY) - ) + row = db.scalar(select(SystemSetting).where(SystemSetting.key == MANAGED_MQTT_SETTINGS_KEY)) if row is None: return {}, None try: @@ -163,3 +196,7 @@ def save_config_snapshot_encryption_keys( def managed_mqtt_view_payload(settings_payload: dict[str, Any]) -> dict[str, Any]: return {field: settings_payload.get(field) for field in _MANAGED_MQTT_FIELDS} + + +def managed_mqtt_repeater_payload(settings_payload: dict[str, Any]) -> dict[str, Any]: + return {field: settings_payload.get(field) for field in _MANAGED_MQTT_REPEATER_FIELDS} diff --git a/backend/app/services/transport_keys.py b/backend/app/services/transport_keys.py index 55a5132..acf5bda 100644 --- a/backend/app/services/transport_keys.py +++ b/backend/app/services/transport_keys.py @@ -28,9 +28,13 @@ class TransportKeySyncDispatchResult: def build_transport_key_sync_payload(db: Session) -> tuple[dict, str]: groups = db.scalars( - select(TransportKeyGroup).order_by(TransportKeyGroup.sort_order.asc(), TransportKeyGroup.name.asc()) + select(TransportKeyGroup).order_by( + TransportKeyGroup.sort_order.asc(), TransportKeyGroup.name.asc() + ) + ).all() + keys = db.scalars( + select(TransportKey).order_by(TransportKey.sort_order.asc(), TransportKey.name.asc()) ).all() - keys = db.scalars(select(TransportKey).order_by(TransportKey.sort_order.asc(), TransportKey.name.asc())).all() nodes: list[dict[str, object | None]] = [] for group in groups: @@ -41,7 +45,9 @@ def build_transport_key_sync_payload(db: Session) -> tuple[dict, str]: "name": group.name, "flood_policy": group.flood_policy, "transport_key": group.transport_key, - "parent_node_id": f"group:{group.parent_group_id}" if group.parent_group_id else None, + "parent_node_id": f"group:{group.parent_group_id}" + if group.parent_group_id + else None, "sort_order": group.sort_order, } ) @@ -133,7 +139,9 @@ def mark_transport_key_sync_dispatched( repeater_id: str, command_id: str, ) -> None: - row = db.scalar(select(TransportKeySyncStatus).where(TransportKeySyncStatus.repeater_id == repeater_id)) + row = db.scalar( + select(TransportKeySyncStatus).where(TransportKeySyncStatus.repeater_id == repeater_id) + ) if row is None: row = TransportKeySyncStatus(repeater_id=repeater_id) db.add(row) @@ -153,7 +161,9 @@ def mark_transport_key_sync_result( message: str | None, completed_at: datetime | None, ) -> None: - row = db.scalar(select(TransportKeySyncStatus).where(TransportKeySyncStatus.repeater_id == repeater_id)) + row = db.scalar( + select(TransportKeySyncStatus).where(TransportKeySyncStatus.repeater_id == repeater_id) + ) if row is None: row = TransportKeySyncStatus(repeater_id=repeater_id) db.add(row) @@ -166,4 +176,3 @@ def mark_transport_key_sync_result( row.error_message = message if normalized_status in {"failed", "partial"} else None row.completed_at = completed_at or now row.updated_at = now - diff --git a/backend/pyproject.toml b/backend/pyproject.toml index d9b6b1f..abb1775 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -3,32 +3,32 @@ requires = ["setuptools>=69", "wheel"] build-backend = "setuptools.build_meta" [project] -name = "pymc-glass-backend" -version = "1.0.4" -description = "pyMC_Glass backend service" +name = "openhop-glass-backend" +version = "1.1.0" +description = "openHop Glass backend service" requires-python = ">=3.11" dependencies = [ - "fastapi>=0.116.0", - "uvicorn[standard]>=0.35.0", - "pydantic>=2.11.0", - "pydantic-settings>=2.10.0", - "python-dotenv>=1.1.0", - "sqlalchemy>=2.0.40", - "psycopg[binary]>=3.2.0", + "fastapi>=0.138.0", + "uvicorn[standard]>=0.49.0", + "pydantic>=2.13.4", + "pydantic-settings>=2.14.2", + "python-dotenv>=1.2.2", + "sqlalchemy>=2.0.51", + "psycopg[binary]>=3.3.4", "paho-mqtt>=2.1.0", - "cryptography>=43.0.0", + "cryptography>=49.0.0", ] [project.optional-dependencies] dev = [ - "pytest>=8.4.0", - "pytest-cov>=5.0.0", - "httpx>=0.28.0", - "ruff>=0.11.0", + "pytest>=9.1.1", + "pytest-cov>=7.1.0", + "httpx>=0.28.1", + "ruff>=0.15.19", ] [project.scripts] -pymc-glass-api = "app.main:run" +openhop-glass-api = "app.main:run" [tool.setuptools] package-dir = {"" = "."} diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index ac0d9d7..4834bbc 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -37,4 +37,3 @@ def client(tmp_path, monkeypatch) -> Generator[TestClient, None, None]: get_settings.cache_clear() reset_db_caches() - diff --git a/backend/tests/test_alert_actions_api.py b/backend/tests/test_alert_actions_api.py index 40d7629..3df022c 100644 --- a/backend/tests/test_alert_actions_api.py +++ b/backend/tests/test_alert_actions_api.py @@ -3,8 +3,6 @@ from urllib import parse as urllib_parse import pytest -from sqlalchemy import select - from app.db.models import ( Alert, AlertActionIntegration, @@ -25,6 +23,8 @@ ) from app.services.alerts import queue_notification_event from app.services.notification_providers.base import NotificationSendRequest +from sqlalchemy import select + def _bootstrap_admin(client) -> None: status = client.get("/api/bootstrap/status") @@ -41,7 +41,9 @@ def _bootstrap_admin(client) -> None: assert created.status_code == 200 -def _login(client, *, email: str = "admin@example.com", password: str = "verysecurepassword123") -> str: +def _login( + client, *, email: str = "admin@example.com", password: str = "verysecurepassword123" +) -> str: response = client.post( "/api/auth/login", json={"email": email, "password": password}, @@ -196,7 +198,7 @@ def test_provider_registry_webhook_baseline(client, monkeypatch) -> None: "format": "markdown", "timeout_seconds": 12, "verify_tls": False, - "headers": {"X-Source": "pyMC_Glass"}, + "headers": {"X-Source": "openHop Glass"}, }, registry=registry, ) @@ -331,7 +333,7 @@ def test_alert_action_validation_errors(client) -> None: ) assert serialize_action_events(["invalid", "alert_resolved", "alert_resolved"]) == ( - "[\"alert_resolved\"]" + '["alert_resolved"]' ) @@ -350,7 +352,7 @@ def test_alert_action_integrations_api_crud(client) -> None: "settings": { "url": "https://example.com/hooks/ops", "method": "post", - "headers": {"X-Source": "pymc-glass"}, + "headers": {"X-Source": "openhop-glass"}, "timeout_seconds": 7, "verify_tls": True, "max_body_bytes": 8192, @@ -377,7 +379,7 @@ def test_alert_action_integrations_api_crud(client) -> None: "settings": { "url": "https://example.com/hooks/ops-v2", "method": "put", - "headers": {"X-Source": "pymc-glass-v2"}, + "headers": {"X-Source": "openhop-glass-v2"}, "timeout_seconds": 5, "verify_tls": False, "max_body_bytes": 4096, @@ -398,6 +400,7 @@ def test_alert_action_integrations_api_crud(client) -> None: after_delete = client.get(f"/api/alert-actions/integrations/{integration_id}", headers=headers) assert after_delete.status_code == 404 + def test_alert_action_integration_test_send_apprise(client, monkeypatch) -> None: _bootstrap_admin(client) token = _login(client) @@ -448,7 +451,7 @@ def _fake_apprise_urlopen(request_obj, timeout, context=None): # noqa: ANN001 "format": "markdown", "timeout_seconds": 12, "verify_tls": False, - "headers": {"X-Source": "pyMC_Glass"}, + "headers": {"X-Source": "openHop Glass"}, }, }, headers=headers, @@ -502,7 +505,10 @@ def test_alert_action_templates_api_crud(client) -> None: "description": "Template used for baseline webhook alerts", "title_template": "Alert: {{ alert.alert_type }}", "body_template": "{{ alert.message }}", - "payload_template": {"message": "{{ alert.message }}", "severity": "{{ alert.severity }}"}, + "payload_template": { + "message": "{{ alert.message }}", + "severity": "{{ alert.severity }}", + }, "default_event_types": ["alert_resolved", "alert_activated"], "enabled": True, }, @@ -543,6 +549,7 @@ def test_alert_action_templates_api_crud(client) -> None: after_delete = client.get(f"/api/alert-actions/templates/{template_id}", headers=headers) assert after_delete.status_code == 404 + def test_alert_policy_action_bindings_api_crud(client) -> None: _bootstrap_admin(client) token = _login(client) diff --git a/backend/tests/test_core_api.py b/backend/tests/test_core_api.py index 54eedcb..3011371 100644 --- a/backend/tests/test_core_api.py +++ b/backend/tests/test_core_api.py @@ -1,8 +1,8 @@ -from app.config import get_settings import json from datetime import UTC, datetime, timedelta from pathlib import Path +from app.config import get_settings from app.db.models import Alert, InformSnapshot, Packet, Repeater from app.db.session import get_session_factory from cryptography import x509 @@ -372,6 +372,7 @@ def test_managed_mqtt_settings_update_and_queue_to_repeaters(client) -> None: "mqtt_broker_port": 2883, "mqtt_base_topic": "glass-prod", "mqtt_tls_enabled": True, + "mqtt_broker_additional_hosts": ["100.64.0.10", "glass.example.com"], "queue_to_repeaters": True, "reason": "fix broker host", }, @@ -381,12 +382,20 @@ def test_managed_mqtt_settings_update_and_queue_to_repeaters(client) -> None: assert update.json()["queued_commands"] == 2 assert update.json()["settings"]["mqtt_broker_host"] == "mqtt.internal.example" assert update.json()["settings"]["mqtt_base_topic"] == "glass-prod" + assert update.json()["settings"]["mqtt_broker_additional_hosts"] == [ + "100.64.0.10", + "glass.example.com", + ] loaded = client.get("/api/system-settings/mqtt-managed", headers=headers) assert loaded.status_code == 200 assert loaded.json()["mqtt_broker_host"] == "mqtt.internal.example" assert loaded.json()["mqtt_broker_port"] == 2883 assert loaded.json()["mqtt_tls_enabled"] is True + assert loaded.json()["mqtt_broker_additional_hosts"] == [ + "100.64.0.10", + "glass.example.com", + ] assert loaded.json()["source"] == "override" settings = get_settings() @@ -394,7 +403,10 @@ def test_managed_mqtt_settings_update_and_queue_to_repeaters(client) -> None: cert = x509.load_pem_x509_certificate(broker_cert_path.read_bytes()) san = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName).value dns_names = set(san.get_values_for_type(x509.DNSName)) + ip_names = {str(name) for name in san.get_values_for_type(x509.IPAddress)} assert "mqtt.internal.example" in dns_names + assert "glass.example.com" in dns_names + assert "100.64.0.10" in ip_names commands = client.get("/api/commands?limit=20", headers=headers) assert commands.status_code == 200 @@ -406,6 +418,94 @@ def test_managed_mqtt_settings_update_and_queue_to_repeaters(client) -> None: == "mqtt.internal.example" ] assert len(managed_commands) == 2 + assert ( + "mqtt_broker_additional_hosts" + not in managed_commands[0]["params"]["config"]["glass_managed"] + ) + + +def test_repeater_policy_template_validate_and_sync(client) -> None: + _bootstrap_admin(client) + token = _login(client) + headers = {"Authorization": f"Bearer {token}"} + + connected = client.post( + "/api/repeaters", + json={ + "node_name": "policy-node-1", + "pubkey": "0x" + "E1" * 32, + "status": "connected", + "firmware_version": "1.0.0", + }, + headers=headers, + ) + pending = client.post( + "/api/repeaters", + json={ + "node_name": "policy-node-pending", + "pubkey": "0x" + "E2" * 32, + "status": "pending_adoption", + "firmware_version": "1.0.0", + }, + headers=headers, + ) + assert connected.status_code == 201 + assert pending.status_code == 201 + + policy = { + "enabled": True, + "default_action": "allow", + "rules": [ + { + "name": "Drop blocked channel text", + "if": { + "all": [{"field": "channel_message_body", "op": "contains", "value": "blocked"}] + }, + "then": {"action": "drop"}, + } + ], + "objects": {"channel_hash_groups": {"blocked": ["0x12"]}}, + } + validation = client.post( + "/api/repeater-policies/validate", json={"policy": policy}, headers=headers + ) + assert validation.status_code == 200 + assert validation.json()["valid"] is True + assert validation.json()["normalized_policy"]["rules"][0]["then"]["action"] == "drop" + + created = client.post( + "/api/repeater-policies/templates", + json={ + "name": "Drop blocked", + "description": "packet policy", + "enabled": True, + "policy": policy, + }, + headers=headers, + ) + assert created.status_code == 201 + template_id = created.json()["id"] + + synced = client.post( + "/api/repeater-policies/sync", + json={"template_id": template_id, "all_repeaters": True, "mode": "replace"}, + headers=headers, + ) + assert synced.status_code == 200 + assert synced.json()["queued_commands"] == 1 + assert synced.json()["statuses"][0]["node_name"] == "policy-node-1" + assert synced.json()["statuses"][0]["status"] == "queued" + + commands = client.get("/api/commands?limit=10", headers=headers) + assert commands.status_code == 200 + policy_commands = [item for item in commands.json() if item["action"] == "policy_sync"] + assert len(policy_commands) == 1 + assert policy_commands[0]["params"]["template_id"] == template_id + assert policy_commands[0]["params"]["policy"]["default_action"] == "allow" + + statuses = client.get("/api/repeater-policies/sync-status", headers=headers) + assert statuses.status_code == 200 + assert statuses.json()[0]["command_id"] == policy_commands[0]["command_id"] def test_packets_list_supports_filters(client) -> None: diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py index 8613938..5020f67 100644 --- a/backend/tests/test_health.py +++ b/backend/tests/test_health.py @@ -4,4 +4,3 @@ def test_healthz(client) -> None: body = response.json() assert body["status"] == "ok" assert body["contract_version"] == "v1" - diff --git a/backend/tests/test_inform_control_plane_api.py b/backend/tests/test_inform_control_plane_api.py index c37a766..03f60c3 100644 --- a/backend/tests/test_inform_control_plane_api.py +++ b/backend/tests/test_inform_control_plane_api.py @@ -1,8 +1,11 @@ from datetime import UTC, datetime, timedelta +from types import SimpleNamespace +from typing import Any, cast -from sqlalchemy import select +from app.api.routes.inform import _inform_source_ip from app.db.models import ConfigSnapshot from app.db.session import get_session_factory +from sqlalchemy import select def _bootstrap_admin(client) -> None: @@ -62,6 +65,56 @@ def _inform_payload(node_name: str) -> dict: } +def test_inform_source_ip_preserves_good_ip_when_docker_gateway_reports() -> None: + docker_gateway_request = cast( + Any, + SimpleNamespace(client=SimpleNamespace(host="172.18.0.1")), + ) + real_lan_request = cast(Any, SimpleNamespace(client=SimpleNamespace(host="192.168.20.42"))) + + assert _inform_source_ip(docker_gateway_request, "192.168.20.10") == "192.168.20.10" + assert _inform_source_ip(docker_gateway_request, None) == "172.18.0.1" + assert _inform_source_ip(real_lan_request, "172.18.0.1") == "192.168.20.42" + + +def test_repeater_open_url_override_is_separate_from_inform_ip(client) -> None: + _bootstrap_admin(client) + token = _login(client) + headers = {"Authorization": f"Bearer {token}"} + + payload = _inform_payload("YC-Work-Repeater") + first_inform = client.post("/inform", json=payload) + assert first_inform.status_code == 200 + + pending = client.get("/api/adoption/pending", headers=headers) + assert pending.status_code == 200 + repeater = pending.json()[0] + original_inform_ip = repeater["inform_ip"] + + updated = client.patch( + f"/api/repeaters/{repeater['id']}", + json={"open_url": " 100.64.12.34:8000 "}, + headers=headers, + ) + assert updated.status_code == 200 + assert updated.json()["open_url"] == "100.64.12.34:8000" + assert updated.json()["inform_ip"] == original_inform_ip + + detail = client.get(f"/api/repeaters/{repeater['id']}/detail", headers=headers) + assert detail.status_code == 200 + assert detail.json()["open_url"] == "100.64.12.34:8000" + assert detail.json()["inform_ip"] == original_inform_ip + + cleared = client.patch( + f"/api/repeaters/{repeater['id']}", + json={"open_url": ""}, + headers=headers, + ) + assert cleared.status_code == 200 + assert cleared.json()["open_url"] is None + assert cleared.json()["inform_ip"] == original_inform_ip + + def test_inform_persists_location_and_settings_for_detail(client) -> None: _bootstrap_admin(client) token = _login(client) @@ -92,6 +145,43 @@ def test_inform_persists_location_and_settings_for_detail(client) -> None: assert detail.json()["state"] == "forward" +def test_inform_accepts_pre_staged_sensor_readings(client) -> None: + _bootstrap_admin(client) + token = _login(client) + headers = {"Authorization": f"Bearer {token}"} + + payload = _inform_payload("mesh-repeater-sensors") + payload["sensors"] = { + "enabled": True, + "configured": 2, + "loaded": 2, + "running": True, + "readings": [ + { + "name": "ups-main", + "type": "waveshare_ups_d", + "ok": True, + "timestamp": "2026-06-20T12:00:00Z", + "data": {"battery_percent": 87.5, "voltage_v": 4.08, "current_ma": 120.0}, + } + ], + } + + first_inform = client.post("/inform", json=payload) + assert first_inform.status_code == 200 + + pending = client.get("/api/adoption/pending", headers=headers) + assert pending.status_code == 200 + repeater_id = pending.json()[0]["id"] + + detail = client.get(f"/api/repeaters/{repeater_id}/detail", headers=headers) + assert detail.status_code == 200 + sensors = detail.json()["system"]["sensors"] + assert sensors["loaded"] == 2 + assert sensors["readings"][0]["type"] == "waveshare_ups_d" + assert sensors["readings"][0]["data"]["battery_percent"] == 87.5 + + def test_inform_extracts_location_from_repeater_latitude_longitude(client) -> None: _bootstrap_admin(client) token = _login(client) @@ -275,6 +365,78 @@ def test_inform_to_adoption_and_command_lifecycle(client) -> None: assert "command_queue" in sources +def test_policy_sync_updates_runtime_policy_status(client) -> None: + _bootstrap_admin(client) + token = _login(client) + headers = {"Authorization": f"Bearer {token}"} + node_name = "mesh-repeater-policy-sync" + + first_inform = client.post("/inform", json=_inform_payload(node_name)) + assert first_inform.status_code == 200 + pending = client.get("/api/adoption/pending", headers=headers) + repeater_id = pending.json()[0]["id"] + adopt = client.post( + f"/api/adoption/{repeater_id}/adopt", + json={"note": "approved"}, + headers=headers, + ) + assert adopt.status_code == 200 + + policy = {"enabled": True, "default_action": "allow", "rules": [], "objects": {}} + template = client.post( + "/api/repeater-policies/templates", + json={"name": "No-op test policy", "enabled": True, "policy": policy}, + headers=headers, + ) + assert template.status_code == 201 + sync = client.post( + "/api/repeater-policies/sync", + json={ + "template_id": template.json()["id"], + "repeater_ids": [repeater_id], + "mode": "replace", + }, + headers=headers, + ) + assert sync.status_code == 200 + command_id = sync.json()["command_ids"][0] + + cert_response = client.post("/inform", json=_inform_payload(node_name)) + assert cert_response.status_code == 200 + assert cert_response.json()["type"] == "cert_renewal" + + dispatched = client.post("/inform", json=_inform_payload(node_name)) + assert dispatched.status_code == 200 + assert dispatched.json()["type"] == "command" + assert dispatched.json()["command_id"] == command_id + assert dispatched.json()["action"] == "policy_sync" + + statuses = client.get("/api/repeater-policies/sync-status", headers=headers) + assert statuses.status_code == 200 + assert statuses.json()[0]["status"] == "dispatched" + assert statuses.json()[0]["dispatched_at"] is not None + + result_payload = _inform_payload(node_name) + result_payload["command_results"] = [ + { + "command_id": command_id, + "status": "success", + "message": "Policy synchronized", + "completed_at": "2026-04-15T12:30:45Z", + "details": {"rule_count": 0, "enabled": True, "default_action": "allow"}, + } + ] + completed = client.post("/inform", json=result_payload) + assert completed.status_code == 200 + + completed_statuses = client.get("/api/repeater-policies/sync-status", headers=headers) + assert completed_statuses.status_code == 200 + body = completed_statuses.json()[0] + assert body["status"] == "success" + assert body["error_message"] is None + assert body["completed_at"] == "2026-04-15T12:30:45" + + def test_inform_renews_when_reported_cert_is_near_expiry(client) -> None: _bootstrap_admin(client) token = _login(client) @@ -514,9 +676,9 @@ def test_config_snapshot_export_ingest_encrypted_and_rotates(client) -> None: "command_id": command_id, "status": "success", "message": "config exported", - "completed_at": ( - datetime.now(UTC) + timedelta(seconds=idx) - ).isoformat().replace("+00:00", "Z"), + "completed_at": (datetime.now(UTC) + timedelta(seconds=idx)) + .isoformat() + .replace("+00:00", "Z"), "details": { "config": { "repeater": {"node_name": node_name}, @@ -655,8 +817,7 @@ def test_config_snapshot_request_logs_and_change_control_dedup(client) -> None: assert detail.status_code == 200 diagnostics = detail.json().get("cert_diagnostics", []) assert any( - entry.get("source") == "config_snapshot_request" - and reason in str(entry.get("message", "")) + entry.get("source") == "config_snapshot_request" and reason in str(entry.get("message", "")) for entry in diagnostics ) assert any( diff --git a/backend/tests/test_transport_keys_api.py b/backend/tests/test_transport_keys_api.py index 91de232..1a0b761 100644 --- a/backend/tests/test_transport_keys_api.py +++ b/backend/tests/test_transport_keys_api.py @@ -213,4 +213,3 @@ def test_transport_key_group_move_prevents_cycles(client) -> None: ) assert invalid_move.status_code == 422 assert "cycle" in invalid_move.json()["detail"].lower() - diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 2341dd8..c6df044 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -15,13 +15,14 @@ services: DATABASE_URL: postgresql+psycopg://postgres:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env.production}@postgres:5432/pymc_glass frontend: - image: pymc_glass_frontend_prod:latest + image: openhop_glass_frontend_prod:latest build: context: ./frontend dockerfile: Dockerfile.prod ports: !override - "5173:80" - volumes: !override [] + volumes: !override + - /etc/localtime:/etc/localtime:ro environment: !override {} working_dir: /usr/share/nginx/html command: ["nginx", "-g", "daemon off;"] diff --git a/docker-compose.yml b/docker-compose.yml index df2a277..efab9ec 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,16 +2,17 @@ services: pki-init: build: context: ./backend - container_name: pymc_glass_pki_init + container_name: openhop_glass_pki_init restart: "no" env_file: - ./backend/.env command: ["python", "-m", "app.scripts.pki_init"] volumes: + - /etc/localtime:/etc/localtime:ro - ./pki:/app/data/pki postgres: image: timescale/timescaledb:latest-pg16 - container_name: pymc_glass_postgres + container_name: openhop_glass_postgres restart: unless-stopped environment: POSTGRES_DB: pymc_glass @@ -20,16 +21,39 @@ services: ports: - "5432:5432" volumes: + - /etc/localtime:/etc/localtime:ro - pg_data:/var/lib/postgresql/data healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres -d pymc_glass"] + test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"] interval: 10s timeout: 5s retries: 10 + postgres-init: + image: timescale/timescaledb:latest-pg16 + container_name: openhop_glass_postgres_init + restart: "no" + depends_on: + postgres: + condition: service_healthy + environment: + PGPASSWORD: ${POSTGRES_PASSWORD:-postgres} + volumes: + - /etc/localtime:/etc/localtime:ro + command: + - sh + - -c + - | + if ! psql -h postgres -U postgres -d postgres -tAc "SELECT 1 FROM pg_database WHERE datname = 'pymc_glass'" | grep -qx 1; then + createdb -h postgres -U postgres pymc_glass + fi + if ! psql -h postgres -U postgres -d postgres -tAc "SELECT 1 FROM pg_database WHERE datname = 'openhop_glass'" | grep -qx 1; then + createdb -h postgres -U postgres openhop_glass + fi + mosquitto: image: eclipse-mosquitto:2 - container_name: pymc_glass_mosquitto + container_name: openhop_glass_mosquitto restart: unless-stopped depends_on: pki-init: @@ -37,6 +61,7 @@ services: ports: - "8883:8883" volumes: + - /etc/localtime:/etc/localtime:ro - ./mosquitto/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro - ./pki:/mosquitto/pki:ro - mosquitto_data:/mosquitto/data @@ -50,11 +75,11 @@ services: backend: build: context: ./backend - container_name: pymc_glass_backend + container_name: openhop_glass_backend restart: unless-stopped depends_on: - postgres: - condition: service_healthy + postgres-init: + condition: service_completed_successfully pki-init: condition: service_completed_successfully mosquitto: @@ -64,6 +89,7 @@ services: ports: - "8080:8080" volumes: + - /etc/localtime:/etc/localtime:ro - ./pki:/app/data/pki healthcheck: test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/healthz', timeout=3)"] @@ -73,7 +99,7 @@ services: frontend: image: node:22-alpine - container_name: pymc_glass_frontend + container_name: openhop_glass_frontend restart: unless-stopped working_dir: /app command: sh -c "npm install && npm run dev" @@ -81,6 +107,7 @@ services: VITE_API_PROXY_TARGET: http://backend:8080 VITE_API_BASE_URL: "" volumes: + - /etc/localtime:/etc/localtime:ro - ./frontend:/app ports: - "5173:5173" diff --git a/docs/repeater-policy-sync-implementation.md b/docs/repeater-policy-sync-implementation.md new file mode 100644 index 0000000..67ca34b --- /dev/null +++ b/docs/repeater-policy-sync-implementation.md @@ -0,0 +1,214 @@ +# Repeater handoff: Glass `policy_sync` + +_Last checked against Repeater commit `c0d919c` (`rightup/pyMC_Repeater` `origin/dev`, fetched 2026-06-24)._ + +## Current status + +openHop Glass can build, validate, store, and queue Repeater Policy Engine templates. Glass queues a normal `/inform` command with action `policy_sync`. + +The current Repeater branch does **not** yet execute `policy_sync` in `repeater/data_acquisition/glass_handler.py`. As of `c0d919c`, `GlassHandler._execute_command_action()` supports: + +- `restart_service` +- `send_advert` +- `set_mode` +- `set_inform_interval` +- `rotate_cert` +- `config_update` +- `transport_keys_sync` +- `set_radio` +- `run_diagnostic` +- `export_config` + +So this document remains the implementation contract for adding `policy_sync` to Repeater. + +## Glass command shape + +Glass queues `policy_sync` from `backend/app/api/routes/repeater_policy.py`. The command is delivered in the next `/inform` response as: + +```json +{ + "type": "command", + "command_id": "", + "action": "policy_sync", + "params": { + "policy": { + "enabled": true, + "default_action": "allow", + "rules": [], + "objects": { + "channel_hash_groups": { + "blocked_channels": ["0x12"] + }, + "pubkey_groups": { + "trusted_relays": ["0xaabbccdd"] + } + } + }, + "mode": "replace", + "validate_only": false, + "source": "openHop Glass", + "template_id": "" + } +} +``` + +Notes: + +- `policy` is the direct Repeater `policy_engine` object, not the full `policy.yaml` wrapper. +- `mode` is `replace` or `patch`. +- `validate_only=true` means validate and report the result without writing `policy.yaml` or swapping runtime policy. +- `template_id` is present only when the command came from a stored Glass template. +- Glass computes and tracks its own payload hash in sync status; the current command payload does not include that hash. + +## Repeater policy file shape + +Repeater stores policy config as a wrapper document. The Glass policy object should be written under `policy_engine`: + +```yaml +policy_engine: + enabled: true + default_action: allow + rules: [] + objects: {} +groups: + channel_hashes: [] + pubkeys: [] +``` + +Keep using `policy.yaml`. Do not create a second `policy.json` or Glass-specific policy store. + +## Required Repeater changes + +Implement `policy_sync` in `repeater/data_acquisition/glass_handler.py` inside `GlassHandler._execute_command_action()`. + +Suggested branch point: + +```python +if action == "policy_sync": + success, message, details = self._apply_policy_sync(params) + return success, message, details +``` + +Add `_apply_policy_sync(params)` with this behavior: + +1. Validate `params` is a dict. +2. Extract `policy = params.get("policy")` and require a dict. +3. Accept `mode` values: + - `replace`: replace the existing `policy_engine` object with the Glass policy. + - `patch`: merge provided fields into the existing `policy_engine` while preserving unspecified fields. +4. Accept `validate_only`: + - construct `PolicyEngine(normalized_policy)` and return success/failure without writing or changing runtime when true. +5. Resolve the policy file path the same way Repeater `/api/policy` does. +6. Load the existing full policy document if present; otherwise start from the default wrapper. +7. Preserve existing `groups` from `policy.yaml` unless Glass later sends an explicit `groups` key. +8. Normalize the incoming policy with the same rules used by `/api/policy`. +9. Project policy `groups` into `policy_engine.objects` with the same `/api/policy` behavior. +10. Write the full wrapper document back to YAML: + - `policy_engine: ` + - `groups: ` +11. Apply runtime policy immediately: + - `self.config["policy_engine"] = normalized_policy` + - `self.config["policy_file_path"] = ` + - `self.daemon_instance.repeater_handler.policy_engine = PolicyEngine.from_runtime_config(self.config)` or equivalent to the current `/api/policy` runtime helper. +12. Return command details that Glass can display and store: + +```json +{ + "policy_file": "/etc/pymc_repeater/policy.yaml", + "mode": "replace", + "validate_only": false, + "rule_count": 3, + "enabled": true, + "default_action": "allow" +} +``` + +The path `/etc/pymc_repeater/policy.yaml` is still the current Repeater default path. Do not rename it only for branding. + +## Reuse existing Repeater code + +The newest Repeater branch already has the needed policy helpers in `repeater/web/api_endpoints.py`: + +- `POLICY_GROUP_KINDS` +- `_normalize_policy_groups` +- `_write_policy_document` +- `_sync_policy_engine_objects_from_groups` +- `_get_policy_file_path` +- `_load_policy_document` +- `_normalize_policy_engine` +- `_apply_policy_runtime` + +Best implementation options: + +1. Refactor these helpers into a small shared policy service module and call it from both `APIEndpoints` and `GlassHandler`. +2. If a refactor is too large, copy the exact helper behavior into `GlassHandler` and add tests proving `/api/policy` and `policy_sync` write the same shape. + +Do not diverge from `/api/policy`; Glass sync should produce the same `policy.yaml` and runtime state as a local Repeater API policy update. + +## Compatibility details + +Glass policy editor pre-stages object groups under: + +- `objects.channel_hash_groups` +- `objects.pubkey_groups` + +Repeater `PolicyEngine` currently supports object reference resolution with `@group.key` lookups from `policy_engine.objects`. It also supports channel secret/hash matching through `objects.channels` and `objects.channel_hash_groups`. + +Keep field support aligned with `repeater/policy_engine.py`, including the currently supported condition fields: + +- `payload_hex` +- `channel_message_body` +- `channel_sender` +- `channel_decryptable` +- `transport_code_0` +- `transport_code_1` + +Also preserve existing context-backed fields used by the policy engine, such as packet metadata passed through `policy_context`. + +## Command result contract + +Repeater should report completion on the next `/inform` with Glass' existing command result shape: + +```json +{ + "command_id": "", + "status": "success", + "message": "Policy synchronized", + "completed_at": "2026-06-24T12:00:00Z", + "details": { + "policy_file": "/etc/pymc_repeater/policy.yaml", + "mode": "replace", + "validate_only": false, + "rule_count": 3, + "enabled": true, + "default_action": "allow" + } +} +``` + +Valid statuses are `success`, `failed`, and `partial`. + +## Tests to add in Repeater + +Add focused tests in Repeater around `GlassHandler._execute_command_action("policy_sync", params)` and any shared helper module: + +1. `validate_only=true` validates but does not write `policy.yaml` and does not replace runtime policy. +2. `replace` writes a full wrapper document with `policy_engine` and preserved `groups`. +3. `patch` preserves existing `policy_engine.objects`, rules, and unspecified fields unless explicitly overwritten. +4. Policy `groups` are projected into `policy_engine.objects` the same way `/api/policy` does. +5. Runtime handler receives a new `PolicyEngine` instance after a successful non-validate sync. +6. Invalid policy returns `success=False` and a useful message. +7. Unsupported `mode` returns `success=False`. + +## Acceptance check + +After Repeater implementation: + +1. Create or select a Repeater Runtime Policy template in Glass. +2. Add a policy group and a rule that references it, e.g. `@channel_hash_groups.blocked_channels`. +3. Queue `policy_sync` to a Repeater running the current branch. +4. Confirm the next Repeater `/inform` receives `action="policy_sync"`. +5. Confirm Repeater reports command result `success` on a following `/inform`. +6. Confirm Repeater `policy.yaml` contains the policy under `policy_engine` with preserved/merged `groups`. +7. Confirm Repeater runtime policy behavior changes without requiring a service restart. +8. Confirm Glass sync status transitions from `queued`/`dispatched` to completed. diff --git a/docs/repeater-sensors-glass-inform-implementation.md b/docs/repeater-sensors-glass-inform-implementation.md new file mode 100644 index 0000000..bbe55b6 --- /dev/null +++ b/docs/repeater-sensors-glass-inform-implementation.md @@ -0,0 +1,217 @@ +# Repeater handoff: Sensor telemetry for Glass + +_Last checked against Repeater commit `c0d919c` (`rightup/pyMC_Repeater` `origin/dev`, fetched 2026-06-24)._ + +## Current status + +openHop Glass is ready to accept a top-level `/inform` field named `sensors` and render it on the repeater detail page as “Sensor Readings”. + +The current Repeater branch already has: + +- `SensorManager.get_summary()` in `repeater/sensors/manager.py` +- sensor subsystem config under `sensors` in `config.yaml.example` +- `RepeaterDaemon.get_stats()` adding `stats["sensors"] = sensor_manager.get_summary()` +- sensor plugins including `waveshare_ups_d`, `waveshare_ups_e`, `lafvin_ups_3s`, `ina219`, `ens210`, `hardware_stats`, and `pymc_modem` + +The current Repeater branch does **not** yet include `sensors` in the Glass `/inform` payload built by `repeater/data_acquisition/glass_handler.py`. As of commit `c0d919c`, `_build_inform_payload()` returns `type`, `version`, identity, system/radio/counter fields, `settings`, and `command_results`, but not `sensors`. + +So the remaining Repeater-side work is to attach the existing `SensorManager.get_summary()` output to the Glass inform payload. + +## Current Glass contract + +Glass `/inform` accepts this optional top-level field: + +```json +{ + "type": "inform", + "version": 1, + "node_name": "repeater-1", + "pubkey": "0x...", + "software_version": "1.0.10", + "state": "forward", + "uptime_seconds": 123, + "config_hash": "sha256:<64-hex-chars>", + "system": { "cpu_percent": 10, "memory_percent": 20, "disk_percent": 30 }, + "radio": { "frequency": 869618000, "spreading_factor": 8, "bandwidth": 62500, "tx_power": 14 }, + "counters": { "rx_total": 0, "tx_total": 0, "forwarded": 0, "dropped": 0, "duplicates": 0, "airtime_percent": 0 }, + "settings": {}, + "sensors": { + "enabled": true, + "poll_interval_seconds": 30, + "configured": 2, + "loaded": 2, + "running": true, + "readings": [ + { + "name": "ups-main", + "type": "waveshare_ups_d", + "ok": true, + "timestamp": "2026-06-20T12:00:00+00:00", + "data": { + "battery_percent": 87.5, + "bus_voltage_v": 4.08, + "shunt_voltage_mv": 1.25, + "current_ma": 120.0, + "power_mw": 490.0, + "charge_state": "discharging" + } + } + ] + }, + "command_results": [] +} +``` + +Glass stores this in the latest inform snapshot/system detail and the frontend reads it from `detail.system.sensors`. + +## Required Repeater change + +In `repeater/data_acquisition/glass_handler.py`, update `GlassHandler._build_inform_payload()`. + +Suggested implementation: + +```python +sensors_summary = None +sensor_manager = getattr(self.daemon_instance, "sensor_manager", None) +if sensor_manager is not None: + try: + sensors_summary = sensor_manager.get_summary() + except Exception as exc: + logger.debug("Failed collecting sensor summary for Glass inform: %s", exc) + sensors_summary = { + "enabled": False, + "configured": 0, + "loaded": 0, + "running": False, + "readings": [], + "error": str(exc), + } + +payload = { + ...existing inform fields..., + "settings": settings_snapshot, + "command_results": command_results, +} +if sensors_summary is not None: + payload["sensors"] = sensors_summary +return payload +``` + +Prefer omitting `sensors` when no `sensor_manager` exists. If a manager exists but raises, include the error summary above so Glass can show that sensor collection failed without breaking the inform loop. + +## Preferred payload shape + +Use the current Repeater `SensorManager.get_summary()` shape directly: + +```json +{ + "enabled": true, + "poll_interval_seconds": 30.0, + "configured": 2, + "loaded": 2, + "running": true, + "readings": [ + { + "name": "sensor-name", + "type": "sensor-type", + "ok": true, + "timestamp": "RFC3339 timestamp", + "data": {} + } + ] +} +``` + +Keep values JSON-safe: + +- numbers as JSON numbers +- booleans as booleans +- strings as strings +- arrays/objects only when serializable +- no bytes objects +- no secrets + +## UPS and battery field names in current Repeater + +Glass renders unknown sensor fields generically, so it does not require a fixed UPS schema. However, current Repeater sensors already use these keys and future first-class gauges should prefer them: + +Common: + +- `battery_percent` +- `current_ma` +- `charge_state` (`charging`, `discharging`, `idle`, etc.) + +`waveshare_ups_d` / `lafvin_ups_3s`: + +- `bus_voltage_v` +- `shunt_voltage_mv` +- `current_ma` +- `power_mw` +- `battery_percent` +- `charge_state` + +`waveshare_ups_e`: + +- `charge_state` +- `battery_voltage_mv` +- `battery_current_ma` +- `battery_percent` +- `remaining_capacity_mah` +- `vbus_voltage_mv` +- `vbus_current_ma` +- `vbus_power_mw` +- `cell_voltages_mv` + +`pymc_modem` sensor: + +- passes through battery and modem stats such as `battery_voltage_mv`, `battery_voltage_v`, `battery_percent`, `battery_percentage`, and `solar_charge_rate_percent_per_hour` when present +- computes `battery_percent` from single-cell voltage if the modem payload exposes voltage but not percent + +Avoid inventing new names like `power_w` or `charging` in Repeater unless the sensor really emits them. If adding normalized fields later, keep the existing fields for compatibility. + +## MQTT event telemetry option + +Glass already stores arbitrary MQTT event telemetry in `mqtt_ingest_events`. + +If Repeater later wants higher-rate sensor telemetry over MQTT, publish an event envelope via the existing Glass MQTT publisher: + +```python +glass_handler.publish_telemetry("sensors", sensor_manager.get_summary()) +``` + +That publishes to a topic like: + +```text +glass//event/sensors +``` + +Payload should use the same `SensorManager.get_summary()` shape. + +Recommended approach: + +- `/inform` carries the latest sensor summary for inventory/detail pages. +- MQTT `event/sensors` can carry higher-frequency readings later. +- Keep `/inform` bounded and avoid dumping high-volume sensor history into it. + +## Tests to add in Repeater + +Add tests around `GlassHandler._build_inform_payload()`: + +1. Includes top-level `sensors` when `daemon_instance.sensor_manager` exists. +2. Omits `sensors` when no sensor manager exists. +3. If `sensor_manager.get_summary()` raises, inform still succeeds and includes a useful error summary or intentionally omits `sensors`. +4. A `waveshare_ups_d`, `waveshare_ups_e`, `lafvin_ups_3s`, and `pymc_modem` reading remains JSON serializable. +5. Existing `command_results` behavior is unchanged. +6. Optional: MQTT publishing of `record_type="sensors"` produces `type="event"` and `event_name="sensors"`. + +## Acceptance check + +After Repeater implementation: + +1. Enable at least one Repeater sensor in `config.yaml`. +2. Confirm Repeater `/api/stats` includes `sensors` from `daemon.get_stats()`. +3. Confirm Repeater Glass `/inform` payload includes top-level `sensors`. +4. Confirm Glass accepts `/inform` without contract errors. +5. In Glass, open the repeater detail page. +6. Confirm the “Sensor Readings” section shows each reading and data value. +7. Confirm a sensor exception does not break the Glass inform loop. diff --git a/frontend/index.html b/frontend/index.html index 356e62b..e062ac9 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,7 +3,8 @@ - pyMC Glass + + openHop Glass
diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 002e933..3083ec1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,24 +1,25 @@ { - "name": "pymc-glass-frontend", - "version": "1.0.4", + "name": "openhop-glass-frontend", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "pymc-glass-frontend", - "version": "1.0.4", + "name": "openhop-glass-frontend", + "version": "1.1.0", "dependencies": { - "@types/leaflet": "^1.9.21", "leaflet": "^1.9.4", - "vue": "^3.5.0", - "vue-router": "^4.5.1" + "vue": "^3.5.38", + "vue-router": "^5.1.0" }, "devDependencies": { - "@tailwindcss/postcss": "^4.2.2", - "@vitejs/plugin-vue": "^6.0.0", - "tailwindcss": "^4.2.2", - "typescript": "^5.8.0", - "vite": "^7.0.0" + "@tailwindcss/postcss": "^4.3.1", + "@types/leaflet": "^1.9.21", + "@vitejs/plugin-vue": "^6.0.7", + "esbuild": "^0.28.1", + "tailwindcss": "^4.3.1", + "typescript": "^6.0.3", + "vite": "^8.1.0" } }, "node_modules/@alloc/quick-lru": { @@ -34,31 +35,94 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@babel/generator": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.0.tgz", + "integrity": "sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^8.0.0", + "@babel/types": "^8.0.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "@types/jsesc": "^2.5.0", + "jsesc": "^3.0.2" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/generator/node_modules/@babel/helper-string-parser": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0.tgz", + "integrity": "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==", + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/generator/node_modules/@babel/helper-validator-identifier": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.2.tgz", + "integrity": "sha512-9Fr9QeyCAyi1BR1jKZ6uYQ24EIhQUx5ReHfQU7drOE+TPOb+w11/dsqLkMOT2U29OdCT71XajrOT8xDc1C7orA==", + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/generator/node_modules/@babel/parser": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.0.tgz", + "integrity": "sha512-aLxAE+imI9bCcyaPrUDjBv3uSkWieifjLe0kuFOZF0zli0L6GCsTmsePnTr55adbIAgYz2zhN1vnFimCBUYcRQ==", + "license": "MIT", + "dependencies": { + "@babel/types": "^8.0.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/generator/node_modules/@babel/types": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.0.tgz", + "integrity": "sha512-K8ponJDxBwDHigkeFqaqT5wLGl4bTlwMafR8k7b5CPxr6Ww+UG9ls8Yx6Tcpboxu97eeGVEEyKcHmEyOwN1vSw==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^8.0.0", + "@babel/helper-validator-identifier": "^8.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "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.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "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.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "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.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -68,22 +132,56 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "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.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", - "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -98,9 +196,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", - "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -115,9 +213,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", - "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -132,9 +230,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", - "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -149,9 +247,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", - "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -166,9 +264,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", - "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -183,9 +281,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", - "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -200,9 +298,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", - "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -217,9 +315,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", - "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -234,9 +332,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", - "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -251,9 +349,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", - "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -268,9 +366,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", - "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -285,9 +383,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", - "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -302,9 +400,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", - "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -319,9 +417,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", - "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -336,9 +434,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", - "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -353,9 +451,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", - "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -370,9 +468,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", - "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], @@ -387,9 +485,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", - "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -404,9 +502,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", - "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], @@ -421,9 +519,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", - "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -438,9 +536,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", - "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ "arm64" ], @@ -455,9 +553,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", - "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -472,9 +570,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", - "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -489,9 +587,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", - "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -506,9 +604,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", - "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -526,7 +624,6 @@ "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", @@ -537,7 +634,6 @@ "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", @@ -548,7 +644,6 @@ "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" @@ -564,414 +659,699 @@ "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/@rolldown/pluginutils": { - "version": "1.0.0-rc.13", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.13.tgz", - "integrity": "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==", + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz", + "integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==", + "devOptional": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz", + "integrity": "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.3.tgz", + "integrity": "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.3.tgz", + "integrity": "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.3.tgz", + "integrity": "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.3.tgz", + "integrity": "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.3.tgz", + "integrity": "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==", + "cpu": [ + "arm64" + ], "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.3.tgz", + "integrity": "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.3.tgz", + "integrity": "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.3.tgz", + "integrity": "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.3.tgz", + "integrity": "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.3.tgz", + "integrity": "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.3.tgz", + "integrity": "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.3.tgz", + "integrity": "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.3.tgz", + "integrity": "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.3.tgz", + "integrity": "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "devOptional": true, "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", - "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "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" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", - "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "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" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", - "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "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" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", - "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "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" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", - "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "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" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", - "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "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" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", - "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "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" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", - "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "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" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", - "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "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" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", - "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "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" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", - "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "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" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", - "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "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" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", - "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "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" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", - "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "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" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", - "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "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" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", - "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "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" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", - "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "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" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", - "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "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" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", - "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "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" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", - "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "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" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", - "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "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" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", - "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "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" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", - "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "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" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", - "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "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" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", - "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "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" - ] + ], + "peer": true }, "node_modules/@tailwindcss/node": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", - "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz", + "integrity": "sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "^5.19.0", - "jiti": "^2.6.1", + "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.2.2" + "tailwindcss": "4.3.1" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", - "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.1.tgz", + "integrity": "sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA==", "dev": true, "license": "MIT", "engines": { "node": ">= 20" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.2.2", - "@tailwindcss/oxide-darwin-arm64": "4.2.2", - "@tailwindcss/oxide-darwin-x64": "4.2.2", - "@tailwindcss/oxide-freebsd-x64": "4.2.2", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", - "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", - "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", - "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", - "@tailwindcss/oxide-linux-x64-musl": "4.2.2", - "@tailwindcss/oxide-wasm32-wasi": "4.2.2", - "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", - "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" + "@tailwindcss/oxide-android-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-x64": "4.3.1", + "@tailwindcss/oxide-freebsd-x64": "4.3.1", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.1", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.1", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-x64-musl": "4.3.1", + "@tailwindcss/oxide-wasm32-wasi": "4.3.1", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.1", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.1" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", - "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.1.tgz", + "integrity": "sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ==", "cpu": [ "arm64" ], @@ -986,9 +1366,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", - "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.1.tgz", + "integrity": "sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA==", "cpu": [ "arm64" ], @@ -1003,9 +1383,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", - "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.1.tgz", + "integrity": "sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg==", "cpu": [ "x64" ], @@ -1020,9 +1400,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", - "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.1.tgz", + "integrity": "sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g==", "cpu": [ "x64" ], @@ -1037,9 +1417,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", - "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.1.tgz", + "integrity": "sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg==", "cpu": [ "arm" ], @@ -1054,9 +1434,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", - "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.1.tgz", + "integrity": "sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ==", "cpu": [ "arm64" ], @@ -1071,9 +1451,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", - "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.1.tgz", + "integrity": "sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==", "cpu": [ "arm64" ], @@ -1088,9 +1468,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", - "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.1.tgz", + "integrity": "sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==", "cpu": [ "x64" ], @@ -1105,9 +1485,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", - "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.1.tgz", + "integrity": "sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==", "cpu": [ "x64" ], @@ -1122,9 +1502,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", - "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.1.tgz", + "integrity": "sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -1140,11 +1520,11 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.8.1", - "@emnapi/runtime": "^1.8.1", - "@emnapi/wasi-threads": "^1.1.0", - "@napi-rs/wasm-runtime": "^1.1.1", - "@tybys/wasm-util": "^0.10.1", + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "engines": { @@ -1152,9 +1532,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", - "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.1.tgz", + "integrity": "sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg==", "cpu": [ "arm64" ], @@ -1169,9 +1549,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", - "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.1.tgz", + "integrity": "sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==", "cpu": [ "x64" ], @@ -1186,49 +1566,69 @@ } }, "node_modules/@tailwindcss/postcss": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.2.tgz", - "integrity": "sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.3.1.tgz", + "integrity": "sha512-dNJuNbdEJT/SWRuXTYP1WSamelsz3ztkUsdtWQPjrexysrTpaEPM40P/71knXiXLYEojqPOEGitVLLpPMS5T6A==", "dev": true, "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", - "@tailwindcss/node": "4.2.2", - "@tailwindcss/oxide": "4.2.2", - "postcss": "^8.5.6", - "tailwindcss": "4.2.2" + "@tailwindcss/node": "4.3.1", + "@tailwindcss/oxide": "4.3.1", + "postcss": "8.5.15", + "tailwindcss": "4.3.1" } }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "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==", + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/@types/geojson": { "version": "7946.0.16", "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/jsesc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@types/jsesc/-/jsesc-2.5.1.tgz", + "integrity": "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==", "license": "MIT" }, "node_modules/@types/leaflet": { "version": "1.9.21", "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz", "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==", + "dev": true, "license": "MIT", "dependencies": { "@types/geojson": "*" } }, "node_modules/@vitejs/plugin-vue": { - "version": "6.0.6", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.6.tgz", - "integrity": "sha512-u9HHgfrq3AjXlysn0eINFnWQOJQLO9WN6VprZ8FXl7A2bYisv3Hui9Ij+7QZ41F/WYWarHjwBbXtD7dKg3uxbg==", + "version": "6.0.7", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.7.tgz", + "integrity": "sha512-km+p+XdSz9Sxm5rqUbqcSfZYaAniKxWBj1KURl+Jr7UaPvvX7BmaWMdP69I5rrFDeQGyxAG7NXdc57vz+snhWg==", "dev": true, "license": "MIT", "dependencies": { - "@rolldown/pluginutils": "1.0.0-rc.13" + "@rolldown/pluginutils": "^1.0.1" }, "engines": { "node": "^20.19.0 || >=22.12.0" @@ -1238,110 +1638,233 @@ "vue": "^3.2.25" } }, + "node_modules/@vue-macros/common": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@vue-macros/common/-/common-3.1.2.tgz", + "integrity": "sha512-h9t4ArDdniO9ekYHAD95t9AZcAbb19lEGK+26iAjUODOIJKmObDNBSe4+6ELQAA3vtYiFPPBtHh7+cQCKi3Dng==", + "license": "MIT", + "dependencies": { + "@vue/compiler-sfc": "^3.5.22", + "ast-kit": "^2.1.2", + "local-pkg": "^1.1.2", + "magic-string-ast": "^1.0.2", + "unplugin-utils": "^0.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/vue-macros" + }, + "peerDependencies": { + "vue": "^2.7.0 || ^3.2.25" + }, + "peerDependenciesMeta": { + "vue": { + "optional": true + } + } + }, "node_modules/@vue/compiler-core": { - "version": "3.5.32", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.32.tgz", - "integrity": "sha512-4x74Tbtqnda8s/NSD6e1Dr5p1c8HdMU5RWSjMSUzb8RTcUQqevDCxVAitcLBKT+ie3o0Dl9crc/S/opJM7qBGQ==", + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.38.tgz", + "integrity": "sha512-s99aGxWYig9ErHbct27KXEGhrBYlRI6c4MwAgXErOAbX9xiW37/uMa+XUDO69zLz83dng8UUZ70CTOJrLrYrEQ==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.2", - "@vue/shared": "3.5.32", + "@babel/parser": "^7.29.7", + "@vue/shared": "3.5.38", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "node_modules/@vue/compiler-dom": { - "version": "3.5.32", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.32.tgz", - "integrity": "sha512-ybHAu70NtiEI1fvAUz3oXZqkUYEe5J98GjMDpTGl5iHb0T15wQYLR4wE3h9xfuTNA+Cm2f4czfe8B4s+CCH57Q==", + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.38.tgz", + "integrity": "sha512-JTqp25l8aFfJYF7/KmsXZjAxJz7T+SjmTJLoXVjHtc2BrSgSiW2n9Aem/cWq1OPe68A8JL06B3eVdhlP0H4TVw==", "license": "MIT", "dependencies": { - "@vue/compiler-core": "3.5.32", - "@vue/shared": "3.5.32" + "@vue/compiler-core": "3.5.38", + "@vue/shared": "3.5.38" } }, "node_modules/@vue/compiler-sfc": { - "version": "3.5.32", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.32.tgz", - "integrity": "sha512-8UYUYo71cP/0YHMO814TRZlPuUUw3oifHuMR7Wp9SNoRSrxRQnhMLNlCeaODNn6kNTJsjFoQ/kqIj4qGvya4Xg==", + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.38.tgz", + "integrity": "sha512-DuA2GiZawSEW442iw/9+Fkol8hTgb4Ke5KkhmSry65QA7YuyMbIdy8p0XZRMvNwJdgRz307W8g1CSzdvS4nuNg==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.2", - "@vue/compiler-core": "3.5.32", - "@vue/compiler-dom": "3.5.32", - "@vue/compiler-ssr": "3.5.32", - "@vue/shared": "3.5.32", + "@babel/parser": "^7.29.7", + "@vue/compiler-core": "3.5.38", + "@vue/compiler-dom": "3.5.38", + "@vue/compiler-ssr": "3.5.38", + "@vue/shared": "3.5.38", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", - "postcss": "^8.5.8", + "postcss": "^8.5.15", "source-map-js": "^1.2.1" } }, "node_modules/@vue/compiler-ssr": { - "version": "3.5.32", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.32.tgz", - "integrity": "sha512-Gp4gTs22T3DgRotZ8aA/6m2jMR+GMztvBXUBEUOYOcST+giyGWJ4WvFd7QLHBkzTxkfOt8IELKNdpzITLbA2rw==", + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.38.tgz", + "integrity": "sha512-7s+W5Gc42FGxZMcuwl8H5B29T8BJPMdBT7KHFE+BbAuZ/iTEdTtv7z2XiMjiaUUw4w3ZcCEdHs36RuYJ2VA7bA==", "license": "MIT", "dependencies": { - "@vue/compiler-dom": "3.5.32", - "@vue/shared": "3.5.32" + "@vue/compiler-dom": "3.5.38", + "@vue/shared": "3.5.38" } }, "node_modules/@vue/devtools-api": { - "version": "6.6.4", - "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", - "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-8.1.3.tgz", + "integrity": "sha512-73NMCvxXh8Hyozc/jiwqTFWVcCMyi11U1zmrq4DoukQJnuo8JHt6FsNu4HdeUDa8SpIp5vb7Q22GWgIq0efsXg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^8.1.3" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.1.3.tgz", + "integrity": "sha512-cRn7GXiCQkMYU2Z3h3pM4YO/ndbx9FY1yLDAqIqPLcmIq4H6zAOJHein6tvZU3AfPwgrodqLiPBEF+YQaS8AxA==", + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^8.1.3", + "birpc": "^2.6.1", + "hookable": "^5.5.3", + "perfect-debounce": "^2.0.0" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.1.3.tgz", + "integrity": "sha512-CM3uIPL+v+lrJUk33+pxspYo0MhuMWlCvf7zC9fybifvCPyM2jUbYRPwoYEJgYbwRqPikm5HozbUhp60MF2QuA==", "license": "MIT" }, "node_modules/@vue/reactivity": { - "version": "3.5.32", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.32.tgz", - "integrity": "sha512-/ORasxSGvZ6MN5gc+uE364SxFdJ0+WqVG0CENXaGW58TOCdrAW76WWaplDtECeS1qphvtBZtR+3/o1g1zL4xPQ==", + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.38.tgz", + "integrity": "sha512-pG6LV/NDNRbKizcUjFFLAfjaL8mcv4DmR9avNcUw2gDHBzZneuS2TWCmp633ynzxz9YYKNeEPK2I8Wraqy2HUQ==", "license": "MIT", "dependencies": { - "@vue/shared": "3.5.32" + "@vue/shared": "3.5.38" } }, "node_modules/@vue/runtime-core": { - "version": "3.5.32", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.32.tgz", - "integrity": "sha512-pDrXCejn4UpFDFmMd27AcJEbHaLemaE5o4pbb7sLk79SRIhc6/t34BQA7SGNgYtbMnvbF/HHOftYBgFJtUoJUQ==", + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.38.tgz", + "integrity": "sha512-iyW8WVfF1CpCXxncZY5Ei6rSd6oZr5DgEom//fUjRBRl56AXPD+s9ATvukRt77ZFTuYlnVA1bxY+dJB94tWVYw==", "license": "MIT", "dependencies": { - "@vue/reactivity": "3.5.32", - "@vue/shared": "3.5.32" + "@vue/reactivity": "3.5.38", + "@vue/shared": "3.5.38" } }, "node_modules/@vue/runtime-dom": { - "version": "3.5.32", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.32.tgz", - "integrity": "sha512-1CDVv7tv/IV13V8Nip1k/aaObVbWqRlVCVezTwx3K07p7Vxossp5JU1dcPNhJk3w347gonIUT9jQOGutyJrSVQ==", + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.38.tgz", + "integrity": "sha512-apX2wt9sdfDshS+a2xueFZLVpt0GkRJZSoPmrW/SA4yzXTznhfcMVW59gr7h4YQeY0vJhdJkk2rsIDwgfFgC5A==", "license": "MIT", "dependencies": { - "@vue/reactivity": "3.5.32", - "@vue/runtime-core": "3.5.32", - "@vue/shared": "3.5.32", + "@vue/reactivity": "3.5.38", + "@vue/runtime-core": "3.5.38", + "@vue/shared": "3.5.38", "csstype": "^3.2.3" } }, "node_modules/@vue/server-renderer": { - "version": "3.5.32", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.32.tgz", - "integrity": "sha512-IOjm2+JQwRFS7W28HNuJeXQle9KdZbODFY7hFGVtnnghF51ta20EWAZJHX+zLGtsHhaU6uC9BGPV52KVpYryMQ==", + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.38.tgz", + "integrity": "sha512-vue8vbf2QlV4quHqzwmJy6dWfmRhP1J8l4wtZg60CL6VoKqcPY2oe7may3+1d9qfpedjK5PRLFqd5k3Isj9mUw==", "license": "MIT", "dependencies": { - "@vue/compiler-ssr": "3.5.32", - "@vue/shared": "3.5.32" + "@vue/compiler-ssr": "3.5.38", + "@vue/shared": "3.5.38" }, "peerDependencies": { - "vue": "3.5.32" + "vue": "3.5.38" } }, "node_modules/@vue/shared": { - "version": "3.5.32", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.32.tgz", - "integrity": "sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg==", + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.38.tgz", + "integrity": "sha512-FTW0AFZNaK5/mOqvGBwVfUlNLU38TiQn4+DQgIFUnrBBJQ1crMJ82yeGQLV5jyKFsO8yRukpbuP7x+nRbH6aug==", + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ast-kit": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.2.0.tgz", + "integrity": "sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "pathe": "^2.0.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/ast-walker-scope": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/ast-walker-scope/-/ast-walker-scope-0.9.0.tgz", + "integrity": "sha512-IJdzo2vLiElBxKzwS36VsCue/62d6IdWjnPB2v3nuPKeWGynp6FF/CYoLa5i/3jXH/z97ZDdsXz6abpgM6w07A==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@babel/types": "^7.29.0", + "ast-kit": "^2.2.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", "license": "MIT" }, "node_modules/csstype": { @@ -1354,21 +1877,21 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=8" } }, "node_modules/enhanced-resolve": { - "version": "5.20.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", - "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "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.0" + "tapable": "^2.3.3" }, "engines": { "node": ">=10.13.0" @@ -1387,10 +1910,10 @@ } }, "node_modules/esbuild": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", - "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", - "dev": true, + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "devOptional": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -1400,32 +1923,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.7", - "@esbuild/android-arm": "0.27.7", - "@esbuild/android-arm64": "0.27.7", - "@esbuild/android-x64": "0.27.7", - "@esbuild/darwin-arm64": "0.27.7", - "@esbuild/darwin-x64": "0.27.7", - "@esbuild/freebsd-arm64": "0.27.7", - "@esbuild/freebsd-x64": "0.27.7", - "@esbuild/linux-arm": "0.27.7", - "@esbuild/linux-arm64": "0.27.7", - "@esbuild/linux-ia32": "0.27.7", - "@esbuild/linux-loong64": "0.27.7", - "@esbuild/linux-mips64el": "0.27.7", - "@esbuild/linux-ppc64": "0.27.7", - "@esbuild/linux-riscv64": "0.27.7", - "@esbuild/linux-s390x": "0.27.7", - "@esbuild/linux-x64": "0.27.7", - "@esbuild/netbsd-arm64": "0.27.7", - "@esbuild/netbsd-x64": "0.27.7", - "@esbuild/openbsd-arm64": "0.27.7", - "@esbuild/openbsd-x64": "0.27.7", - "@esbuild/openharmony-arm64": "0.27.7", - "@esbuild/sunos-x64": "0.27.7", - "@esbuild/win32-arm64": "0.27.7", - "@esbuild/win32-ia32": "0.27.7", - "@esbuild/win32-x64": "0.27.7" + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, "node_modules/estree-walker": { @@ -1434,11 +1957,16 @@ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "license": "MIT" }, + "node_modules/exsolve": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.0.tgz", + "integrity": "sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==", + "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" @@ -1456,7 +1984,6 @@ "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, @@ -1474,16 +2001,46 @@ "dev": true, "license": "ISC" }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "license": "MIT" + }, "node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "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/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/leaflet": { "version": "1.9.4", "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", @@ -1494,7 +2051,7 @@ "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", - "dev": true, + "devOptional": true, "license": "MPL-2.0", "dependencies": { "detect-libc": "^2.0.3" @@ -1751,6 +2308,23 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/local-pkg": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.2.1.tgz", + "integrity": "sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==", + "license": "MIT", + "dependencies": { + "mlly": "^1.7.4", + "pkg-types": "^2.3.0", + "quansync": "^0.2.11" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1760,10 +2334,60 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magic-string-ast": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/magic-string-ast/-/magic-string-ast-1.0.3.tgz", + "integrity": "sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==", + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.19" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/mlly/node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "license": "MIT" + }, + "node_modules/mlly/node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "license": "MIT" + }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "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", @@ -1778,6 +2402,18 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", + "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1788,7 +2424,6 @@ "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" @@ -1797,10 +2432,21 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pkg-types": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", + "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", + "license": "MIT", + "dependencies": { + "confbox": "^0.2.4", + "exsolve": "^1.0.8", + "pathe": "^2.0.3" + } + }, "node_modules/postcss": { - "version": "8.5.10", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", - "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "funding": [ { "type": "opencollective", @@ -1817,7 +2463,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -1825,14 +2471,78 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/quansync": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", + "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/antfu" + }, + { + "type": "individual", + "url": "https://github.com/sponsors/sxzz" + } + ], + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rolldown": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz", + "integrity": "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.137.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.3", + "@rolldown/binding-darwin-arm64": "1.1.3", + "@rolldown/binding-darwin-x64": "1.1.3", + "@rolldown/binding-freebsd-x64": "1.1.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.3", + "@rolldown/binding-linux-arm64-gnu": "1.1.3", + "@rolldown/binding-linux-arm64-musl": "1.1.3", + "@rolldown/binding-linux-ppc64-gnu": "1.1.3", + "@rolldown/binding-linux-s390x-gnu": "1.1.3", + "@rolldown/binding-linux-x64-gnu": "1.1.3", + "@rolldown/binding-linux-x64-musl": "1.1.3", + "@rolldown/binding-openharmony-arm64": "1.1.3", + "@rolldown/binding-wasm32-wasi": "1.1.3", + "@rolldown/binding-win32-arm64-msvc": "1.1.3", + "@rolldown/binding-win32-x64-msvc": "1.1.3" + } + }, "node_modules/rollup": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", - "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", - "dev": true, + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { - "@types/estree": "1.0.8" + "@types/estree": "1.0.9" }, "bin": { "rollup": "dist/bin/rollup" @@ -1842,34 +2552,40 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.1", - "@rollup/rollup-android-arm64": "4.60.1", - "@rollup/rollup-darwin-arm64": "4.60.1", - "@rollup/rollup-darwin-x64": "4.60.1", - "@rollup/rollup-freebsd-arm64": "4.60.1", - "@rollup/rollup-freebsd-x64": "4.60.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", - "@rollup/rollup-linux-arm-musleabihf": "4.60.1", - "@rollup/rollup-linux-arm64-gnu": "4.60.1", - "@rollup/rollup-linux-arm64-musl": "4.60.1", - "@rollup/rollup-linux-loong64-gnu": "4.60.1", - "@rollup/rollup-linux-loong64-musl": "4.60.1", - "@rollup/rollup-linux-ppc64-gnu": "4.60.1", - "@rollup/rollup-linux-ppc64-musl": "4.60.1", - "@rollup/rollup-linux-riscv64-gnu": "4.60.1", - "@rollup/rollup-linux-riscv64-musl": "4.60.1", - "@rollup/rollup-linux-s390x-gnu": "4.60.1", - "@rollup/rollup-linux-x64-gnu": "4.60.1", - "@rollup/rollup-linux-x64-musl": "4.60.1", - "@rollup/rollup-openbsd-x64": "4.60.1", - "@rollup/rollup-openharmony-arm64": "4.60.1", - "@rollup/rollup-win32-arm64-msvc": "4.60.1", - "@rollup/rollup-win32-ia32-msvc": "4.60.1", - "@rollup/rollup-win32-x64-gnu": "4.60.1", - "@rollup/rollup-win32-x64-msvc": "4.60.1", + "@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/scule": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz", + "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==", + "license": "MIT" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1880,16 +2596,16 @@ } }, "node_modules/tailwindcss": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", - "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz", + "integrity": "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==", "dev": true, "license": "MIT" }, "node_modules/tapable": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", - "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", + "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": { @@ -1901,10 +2617,9 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", - "dev": true, + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -1917,10 +2632,18 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "devOptional": true, "license": "Apache-2.0", "bin": { @@ -1931,19 +2654,94 @@ "node": ">=14.17" } }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "license": "MIT" + }, + "node_modules/unplugin": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.2.0.tgz", + "integrity": "sha512-6nGlT7EHsS+tTcTdAkYFqXIUwDrMJyJvHFNYGSr4x2/2ySIcV4f5e1RAJUeDyfOJPR8TF0auE8l+82PLhKjqsA==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "picomatch": "^4.0.4", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@farmfe/core": "*", + "@rspack/core": "*", + "bun-types-no-globals": "*", + "esbuild": "*", + "rolldown": "*", + "rollup": "*", + "unloader": "*", + "vite": "*", + "webpack": "*" + }, + "peerDependenciesMeta": { + "@farmfe/core": { + "optional": true + }, + "@rspack/core": { + "optional": true + }, + "bun-types-no-globals": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "rolldown": { + "optional": true + }, + "rollup": { + "optional": true + }, + "unloader": { + "optional": true + }, + "vite": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/unplugin-utils": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/unplugin-utils/-/unplugin-utils-0.3.1.tgz", + "integrity": "sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==", + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, "node_modules/vite": { - "version": "7.3.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", - "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", - "dev": true, + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.0.tgz", + "integrity": "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==", + "devOptional": true, "license": "MIT", "dependencies": { - "esbuild": "^0.27.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "~1.1.2", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" @@ -1959,9 +2757,10 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", - "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", @@ -1974,13 +2773,16 @@ "@types/node": { "optional": true }, - "jiti": { + "@vitejs/devtools": { "optional": true }, - "less": { + "esbuild": { + "optional": true + }, + "jiti": { "optional": true }, - "lightningcss": { + "less": { "optional": true }, "sass": { @@ -2007,16 +2809,16 @@ } }, "node_modules/vue": { - "version": "3.5.32", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.32.tgz", - "integrity": "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==", + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.38.tgz", + "integrity": "sha512-vAMKHfImQlYSy0C+PBue4s3ERZ2xGKfgZg5GXAsLInq1dyh2H78ILVP5sK0KPFPVW4kv+OGCIvBEondcjpZp7A==", "license": "MIT", "dependencies": { - "@vue/compiler-dom": "3.5.32", - "@vue/compiler-sfc": "3.5.32", - "@vue/runtime-dom": "3.5.32", - "@vue/server-renderer": "3.5.32", - "@vue/shared": "3.5.32" + "@vue/compiler-dom": "3.5.38", + "@vue/compiler-sfc": "3.5.38", + "@vue/runtime-dom": "3.5.38", + "@vue/server-renderer": "3.5.38", + "@vue/shared": "3.5.38" }, "peerDependencies": { "typescript": "*" @@ -2028,18 +2830,73 @@ } }, "node_modules/vue-router": { - "version": "4.6.4", - "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", - "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.1.0.tgz", + "integrity": "sha512-HAbiLzLEHQwxPgvsbOJDAwtavszEgLwri6XfyrsPECIFez8+59xc9LofWVdc/HEaSRT822lJ8H9Ns38VVond5g==", "license": "MIT", "dependencies": { - "@vue/devtools-api": "^6.6.4" + "@babel/generator": "^8.0.0-rc.4", + "@vue-macros/common": "^3.1.1", + "@vue/devtools-api": "^8.1.2", + "ast-walker-scope": "^0.9.0", + "chokidar": "^5.0.0", + "json5": "^2.2.3", + "local-pkg": "^1.1.2", + "magic-string": "^0.30.21", + "mlly": "^1.8.2", + "muggle-string": "^0.4.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "scule": "^1.3.0", + "tinyglobby": "^0.2.16", + "unplugin": "^3.0.0", + "unplugin-utils": "^0.3.1", + "yaml": "^2.9.0" }, "funding": { "url": "https://github.com/sponsors/posva" }, "peerDependencies": { - "vue": "^3.5.0" + "@pinia/colada": ">=0.21.2", + "@vue/compiler-sfc": "^3.5.34", + "pinia": "^3.0.4", + "vite": "^7.0.0 || ^8.0.0", + "vue": "^3.5.34" + }, + "peerDependenciesMeta": { + "@pinia/colada": { + "optional": true + }, + "@vue/compiler-sfc": { + "optional": true + }, + "pinia": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "license": "MIT" + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" } } } diff --git a/frontend/package.json b/frontend/package.json index 230245b..5950a06 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { - "name": "pymc-glass-frontend", - "version": "1.0.4", + "name": "openhop-glass-frontend", + "version": "1.1.0", "private": true, "type": "module", "scripts": { @@ -9,16 +9,17 @@ "preview": "vite preview --host 0.0.0.0 --port 5173" }, "dependencies": { - "@types/leaflet": "^1.9.21", "leaflet": "^1.9.4", - "vue": "^3.5.0", - "vue-router": "^4.5.1" + "vue": "^3.5.38", + "vue-router": "^5.1.0" }, "devDependencies": { - "@tailwindcss/postcss": "^4.2.2", - "@vitejs/plugin-vue": "^6.0.0", - "tailwindcss": "^4.2.2", - "typescript": "^5.8.0", - "vite": "^7.0.0" + "@tailwindcss/postcss": "^4.3.1", + "@types/leaflet": "^1.9.21", + "@vitejs/plugin-vue": "^6.0.7", + "esbuild": "^0.28.1", + "tailwindcss": "^4.3.1", + "typescript": "^6.0.3", + "vite": "^8.1.0" } } diff --git a/frontend/public/CHANGELOG.md b/frontend/public/CHANGELOG.md index 0430944..9e1d85a 100644 --- a/frontend/public/CHANGELOG.md +++ b/frontend/public/CHANGELOG.md @@ -1,5 +1,41 @@ # Changelog All notable changes to this project are documented in this file. +## [1.1.0] - 2026-06-24 + +### Features + +- Added repeater UI launch buttons that open the repeater web interface from the repeater inform IP, falling back to the Glass connection host. +- Added per-repeater Open Repeater URL overrides so Tailscale/VPN management URLs can be edited without changing the inform/control IP. +- Added command queue checkbox targeting for selected repeaters, plus all-repeaters queueing from the command page. +- Added example repeater policy templates for high airtime logging, low-signal dropping, and trusted pubkey allow-list patterns. +- Added a single-repeater transport key sync action from the repeater detail page. +- Added Glass-side Repeater Runtime Policy management with template storage, validation, visual editing, JSON editing, and policy_sync queueing. +- Added Repeater policy object/group management in Glass, including channel hash groups, pubkey groups, and group-backed rule conditions. +- Added Repeater policy sync status tracking so Glass records queued, dispatched, success, failed, and partial policy_sync results per repeater. +- Added Repeater sensor telemetry rendering in Glass, including generic nested sensor metrics labeled as Sensor Readings. +- Added Repeater-style UI polish for the theme toggle, light-mode sidebar contrast, and runtime policy tables. +- Added Proxmox LXC installer/update helper support for Glass with Repeater-aligned prompt defaults. +- Rebranded Glass UI and deployment assets to openHop. + +### Fixes + +- Fixed Open Repeater links to include the Repeater web UI port and avoid Docker bridge gateway IPs like 172.18.0.1 when building browser targets. +- Switched the Network map base layer to the standard OpenStreetMap tile layer so map tiles render behind repeater markers. +- Exposed repeater inform IP addresses through the API so Glass can link to repeater-local web interfaces. +- Fixed policy editor behavior for HTTP IDs, Repeater-compatible condition fields, rule IDs, object/group references, and sync status formatting. +- Fixed Proxmox installer prompt validation, non-interactive execution guards, update helper expansion, container startup diagnostics, and default resource sizing. +- Preserved existing Glass database and Docker volumes across openHop deployment changes. + +### Documentation + +- Added Repeater policy_sync, sensor telemetry, openHop migration, and expanded project documentation. +- Updated Repeater terminology and removed stale development wording. + +### Chores + +- Updated backend and frontend dependencies to current versions and cleared npm audit findings. +- Cleaned up backend formatting/import ordering so ruff lint passes across app and tests. + ## [1.0.4] - 2026-04-21 ### Chores diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..46e6432 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,19 @@ + + + openHop logo + Vectorised openHop rabbit logo converted from the supplied image. + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 2ec3dc6..25b648b 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -44,8 +44,9 @@ import { RouterView, useRoute } from "vue-router"; import { onMounted, ref, watch } from "vue"; import { appState, isAuthenticated } from "./state/appState"; import SetupWizardModal from "./components/ui/SetupWizardModal.vue"; +import { useTheme } from "./composables/useTheme"; -const VERSION_COOKIE_NAME = "pymc_glass_seen_version"; +const VERSION_COOKIE_NAME = "openhop_glass_seen_version"; const appVersion = ((import.meta.env.VITE_APP_VERSION as string | undefined) ?? "0.1.0").trim(); const buyMeCoffeeUrl = ( (import.meta.env.VITE_BUYMEACOFFEE_URL as string | undefined) ?? @@ -57,6 +58,9 @@ const releaseNotes = ref("Loading changelog..."); const releaseCheckStarted = ref(false); const route = useRoute(); +// Initialize RepeaterUI-style persisted light/dark theme at app startup. +useTheme(); + function getCookie(name: string): string | null { const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const match = document.cookie.match(new RegExp(`(?:^|; )${escaped}=([^;]*)`)); diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 456e88d..7789dea 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -47,6 +47,14 @@ import type { QueueCommandRequest, RepeaterCreateRequest, RepeaterDetailResponse, + RepeaterPolicySyncRequest, + RepeaterPolicySyncResponse, + RepeaterPolicySyncStatusResponse, + RepeaterPolicyTemplateCreateRequest, + RepeaterPolicyTemplateResponse, + RepeaterPolicyTemplateUpdateRequest, + RepeaterPolicyValidateRequest, + RepeaterPolicyValidateResponse, RepeaterResponse, RepeaterUpdateRequest, ManagedMqttSettingsResponse, @@ -176,6 +184,70 @@ export function updateManagedMqttSettings( }); } +export function listRepeaterPolicyTemplates(token: string): Promise { + return request("/api/repeater-policies/templates", { token }); +} + +export function createRepeaterPolicyTemplate( + token: string, + payload: RepeaterPolicyTemplateCreateRequest, +): Promise { + return request("/api/repeater-policies/templates", { + method: "POST", + token, + body: payload, + }); +} + +export function updateRepeaterPolicyTemplate( + token: string, + templateId: string, + payload: RepeaterPolicyTemplateUpdateRequest, +): Promise { + return request(`/api/repeater-policies/templates/${templateId}`, { + method: "PATCH", + token, + body: payload, + }); +} + +export function deleteRepeaterPolicyTemplate(token: string, templateId: string): Promise { + return request(`/api/repeater-policies/templates/${templateId}`, { + method: "DELETE", + token, + }); +} + +export function validateRepeaterPolicy( + token: string, + payload: RepeaterPolicyValidateRequest, +): Promise { + return request("/api/repeater-policies/validate", { + method: "POST", + token, + body: payload, + }); +} + +export function syncRepeaterPolicy( + token: string, + payload: RepeaterPolicySyncRequest, +): Promise { + return request("/api/repeater-policies/sync", { + method: "POST", + token, + body: payload, + }); +} + +export function listRepeaterPolicySyncStatus( + token: string, +): Promise { + return request("/api/repeater-policies/sync-status", { + token, + }); +} + export function getConfigSnapshotEncryptionSettings( token: string, ): Promise { diff --git a/frontend/src/assets/logo/openhop_logo.png b/frontend/src/assets/logo/openhop_logo.png new file mode 100644 index 0000000..95cada6 Binary files /dev/null and b/frontend/src/assets/logo/openhop_logo.png differ diff --git a/frontend/src/assets/logo/openhop_logo_vector.svg b/frontend/src/assets/logo/openhop_logo_vector.svg new file mode 100644 index 0000000..46e6432 --- /dev/null +++ b/frontend/src/assets/logo/openhop_logo_vector.svg @@ -0,0 +1,19 @@ + + + openHop logo + Vectorised openHop rabbit logo converted from the supplied image. + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/logo/openhop_logo_vector_transparent.svg b/frontend/src/assets/logo/openhop_logo_vector_transparent.svg new file mode 100644 index 0000000..b561430 --- /dev/null +++ b/frontend/src/assets/logo/openhop_logo_vector_transparent.svg @@ -0,0 +1,97 @@ + + + + openHop logo + Vectorised openHop rabbit logo converted from the supplied image. + + + + + + + + + + + + + + + + + openHop logo + + + + diff --git a/frontend/src/assets/logo/openhop_transparent.png b/frontend/src/assets/logo/openhop_transparent.png new file mode 100644 index 0000000..80bdefd Binary files /dev/null and b/frontend/src/assets/logo/openhop_transparent.png differ diff --git a/frontend/src/assets/logo/openhop_transparent_trim.png b/frontend/src/assets/logo/openhop_transparent_trim.png new file mode 100644 index 0000000..a585cc1 Binary files /dev/null and b/frontend/src/assets/logo/openhop_transparent_trim.png differ diff --git a/frontend/src/components/ThemeToggle.vue b/frontend/src/components/ThemeToggle.vue new file mode 100644 index 0000000..c599e54 --- /dev/null +++ b/frontend/src/components/ThemeToggle.vue @@ -0,0 +1,85 @@ + + + + + diff --git a/frontend/src/components/layout/SidebarNav.vue b/frontend/src/components/layout/SidebarNav.vue index f9dcff2..0a09328 100644 --- a/frontend/src/components/layout/SidebarNav.vue +++ b/frontend/src/components/layout/SidebarNav.vue @@ -11,8 +11,9 @@