From e17921a122368907478e397c7f2208ca09f61d6f Mon Sep 17 00:00:00 2001 From: Diseri Pearson Date: Mon, 18 May 2026 09:30:30 +0200 Subject: [PATCH] Add portal: customer-facing white-labeled monitoring stack New top-level portal/ project, peer to console/ and firmware/. Delivers a .NET 10 + React 18 + TimescaleDB + Grafana stack, one container set per customer behind Traefik. Built in 12 phases per FrontEndPrompt spec; no changes to existing console or firmware. Backend (src/Tau.Acuvim.Portal/): - .NET 10 minimal API, Serilog, ASP.NET Identity (cookie auth, lockout). - Single AppDbContext with identity / app / monitoring schemas. - MigrateAsync + TimescaleBootstrapper (idempotent hypertable creation) + IdentityBootstrapper (seeded admin + branding) on startup. - Pure CostCalculator + DB-backed RateService for tariffs (effective-dated, TOU periods, VAT, fixed charges, per-municipality timezone). - BrandingService with logo upload to mounted volume. - Time-series ingest + bucketed query services (time_bucket aggregates, ON CONFLICT for idempotent re-delivery). - ConfigOverviewService with redaction-by-construction (passwords never in payload). - DataProtection keys persisted to /data/keys volume for cookie survival across container restarts. Frontend (frontend/): - React 18 + TypeScript + Vite + Ant Design 5 + TanStack Query. - BrandingProvider + ThemedRoot for live re-themed white-labelling. - RequireAuth / RequireRole guards. - Pages: Login, Dashboard, Dashboards (embedded Grafana), Sites (admin), Settings tabs (Branding / Rates / Users / Grafana / App config). Infra: - Dev (docker-compose.yml) and prod (docker-compose.prod.yml) compose files. Three services per customer; Traefik subdomain + same-origin /grafana path-prefix routing wired with labels. - Grafana 11 with provisioned timescaledb datasource (uid pinned) and starter power-overview.json dashboard with device template variable. - Compose project name documented as lowercase (Compose v2 requirement). Tests (tests/Tau.Acuvim.Portal.Tests/): - xUnit, 40 tests. Covers CostCalculator (period match, TZ, overlap, VAT, fixed), ConnectionStringResolver (all 4 precedence branches incl. Production refusal), TariffValidator, DayOfWeekFlag. - All passing locally against .NET 10. Docs: - README.md (onboarding + 11 spec sections), OPERATIONS.md (per-customer provisioning, secret rotation, backup, troubleshooting), TESTING.md (manual integration scenarios, frontend test scaffolding recipe). Production safety guards: - Refuses to start if Authentication:DefaultAdminPassword is unchanged default in Production. - Refuses to start if Database:AutoProvisionLocalTimescaleDb=true in Production. - Prod Grafana ships with anonymous off and auth mode unset (three options documented in README Security) so iframe refuses to load until a deliberate prod auth choice is made. Co-Authored-By: Claude Opus 4.7 (1M context) --- portal/.dockerignore | 16 + portal/.env.example | 28 + portal/.gitignore | 29 + portal/Dockerfile | 28 + portal/OPERATIONS.md | 319 ++ portal/README.md | 369 ++ portal/TESTING.md | 129 + portal/docker-compose.prod.yml | 111 + portal/docker-compose.yml | 70 + portal/frontend/.gitignore | 11 + portal/frontend/index.html | 12 + portal/frontend/package-lock.json | 3282 +++++++++++++++++ portal/frontend/package.json | 27 + portal/frontend/src/App.tsx | 64 + portal/frontend/src/api/adminConfig.ts | 31 + portal/frontend/src/api/auth.ts | 34 + portal/frontend/src/api/branding.ts | 38 + portal/frontend/src/api/client.ts | 7 + portal/frontend/src/api/grafana.ts | 18 + portal/frontend/src/api/rates.ts | 104 + portal/frontend/src/api/sites.ts | 69 + portal/frontend/src/api/users.ts | 45 + .../frontend/src/components/RequireAuth.tsx | 23 + .../frontend/src/components/RequireRole.tsx | 19 + portal/frontend/src/components/ThemedRoot.tsx | 19 + .../src/components/layout/AppLayout.tsx | 89 + .../src/components/settings/BrandingForm.tsx | 192 + .../settings/ConfigOverviewCard.tsx | 85 + .../components/settings/GrafanaInfoCard.tsx | 81 + .../settings/rates/MunicipalityList.tsx | 245 ++ .../settings/rates/PeriodEditor.tsx | 119 + .../settings/rates/TariffDrawer.tsx | 181 + .../src/components/sites/DeviceFormModal.tsx | 76 + .../src/components/sites/SiteFormModal.tsx | 82 + .../src/components/users/UserFormDrawer.tsx | 103 + portal/frontend/src/hooks/useAuth.tsx | 44 + portal/frontend/src/hooks/useBranding.tsx | 47 + portal/frontend/src/main.tsx | 10 + portal/frontend/src/pages/AdminSitesPage.tsx | 217 ++ portal/frontend/src/pages/DashboardPage.tsx | 15 + portal/frontend/src/pages/DashboardsPage.tsx | 87 + portal/frontend/src/pages/LoginPage.tsx | 87 + portal/frontend/src/pages/SettingsPage.tsx | 23 + portal/frontend/src/pages/UsersPage.tsx | 198 + portal/frontend/src/vite-env.d.ts | 1 + portal/frontend/tsconfig.json | 22 + portal/frontend/tsconfig.node.json | 11 + portal/frontend/vite.config.ts | 23 + portal/grafana/dashboards/.gitkeep | 0 portal/grafana/dashboards/power-overview.json | 150 + .../provisioning/dashboards/dashboards.yml | 13 + .../provisioning/datasources/timescaledb.yml | 18 + .../Configuration/ConnectionStringResolver.cs | 42 + .../Configuration/MonitoringOptions.cs | 10 + .../Configuration/PortalOptions.cs | 74 + .../src/Tau.Acuvim.Portal/Constants/Roles.cs | 11 + portal/src/Tau.Acuvim.Portal/DTOs/AuthDtos.cs | 5 + .../Tau.Acuvim.Portal/DTOs/BrandingDtos.cs | 19 + .../DTOs/ConfigOverviewDtos.cs | 35 + .../src/Tau.Acuvim.Portal/DTOs/GrafanaDtos.cs | 8 + .../Tau.Acuvim.Portal/DTOs/MonitoringDtos.cs | 45 + .../src/Tau.Acuvim.Portal/DTOs/RatesDtos.cs | 51 + portal/src/Tau.Acuvim.Portal/DTOs/UserDtos.cs | 22 + .../Tau.Acuvim.Portal/Data/AppDbContext.cs | 105 + .../Domain/Branding/WhiteLabelSettings.cs | 30 + .../Domain/Identity/ApplicationUser.cs | 14 + .../Domain/Monitoring/Device.cs | 24 + .../Domain/Monitoring/PowerMeasurement.cs | 24 + .../Domain/Monitoring/Site.cs | 24 + .../Domain/Rates/ConsumptionSample.cs | 3 + .../Domain/Rates/CostBreakdown.cs | 8 + .../Domain/Rates/DayOfWeekFlag.cs | 32 + .../Domain/Rates/Municipality.cs | 20 + .../Tau.Acuvim.Portal/Domain/Rates/Tariff.cs | 27 + .../Domain/Rates/TariffPeriod.cs | 21 + .../Endpoints/AdminConfigEndpoints.cs | 16 + .../Endpoints/AdminRatesEndpoints.cs | 60 + .../Endpoints/AdminUserEndpoints.cs | 121 + .../Endpoints/AuthEndpoints.cs | 66 + .../Endpoints/BrandingEndpoints.cs | 65 + .../Endpoints/GrafanaEndpoints.cs | 15 + .../Endpoints/MeasurementsEndpoints.cs | 45 + .../Endpoints/RatesEndpoints.cs | 35 + .../Endpoints/SitesEndpoints.cs | 123 + .../20260518064632_InitialCreate.Designer.cs | 631 ++++ .../20260518064632_InitialCreate.cs | 510 +++ .../Migrations/AppDbContextModelSnapshot.cs | 628 ++++ portal/src/Tau.Acuvim.Portal/Program.cs | 231 ++ .../Properties/launchSettings.json | 13 + .../Services/BrandingService.cs | 84 + .../Services/ConfigOverviewService.cs | 82 + .../Services/CostCalculator.cs | 53 + .../Services/GrafanaService.cs | 18 + .../Services/IdentityBootstrapper.cs | 63 + .../Services/MeasurementIngestService.cs | 91 + .../Services/MeasurementQueryService.cs | 71 + .../Tau.Acuvim.Portal/Services/RateService.cs | 146 + .../Services/TariffValidator.cs | 31 + .../Services/TimescaleBootstrapper.cs | 59 + .../Tau.Acuvim.Portal.csproj | 25 + .../appsettings.Development.json | 16 + portal/src/Tau.Acuvim.Portal/appsettings.json | 12 + .../appsettings.template.json | 54 + .../ConnectionStringResolverTests.cs | 83 + .../CostCalculatorTests.cs | 180 + .../DayOfWeekFlagTests.cs | 42 + .../Helpers/FakeHostEnvironment.cs | 14 + .../TariffValidatorTests.cs | 109 + .../Tau.Acuvim.Portal.Tests.csproj | 26 + 109 files changed, 11593 insertions(+) create mode 100644 portal/.dockerignore create mode 100644 portal/.env.example create mode 100644 portal/.gitignore create mode 100644 portal/Dockerfile create mode 100644 portal/OPERATIONS.md create mode 100644 portal/README.md create mode 100644 portal/TESTING.md create mode 100644 portal/docker-compose.prod.yml create mode 100644 portal/docker-compose.yml create mode 100644 portal/frontend/.gitignore create mode 100644 portal/frontend/index.html create mode 100644 portal/frontend/package-lock.json create mode 100644 portal/frontend/package.json create mode 100644 portal/frontend/src/App.tsx create mode 100644 portal/frontend/src/api/adminConfig.ts create mode 100644 portal/frontend/src/api/auth.ts create mode 100644 portal/frontend/src/api/branding.ts create mode 100644 portal/frontend/src/api/client.ts create mode 100644 portal/frontend/src/api/grafana.ts create mode 100644 portal/frontend/src/api/rates.ts create mode 100644 portal/frontend/src/api/sites.ts create mode 100644 portal/frontend/src/api/users.ts create mode 100644 portal/frontend/src/components/RequireAuth.tsx create mode 100644 portal/frontend/src/components/RequireRole.tsx create mode 100644 portal/frontend/src/components/ThemedRoot.tsx create mode 100644 portal/frontend/src/components/layout/AppLayout.tsx create mode 100644 portal/frontend/src/components/settings/BrandingForm.tsx create mode 100644 portal/frontend/src/components/settings/ConfigOverviewCard.tsx create mode 100644 portal/frontend/src/components/settings/GrafanaInfoCard.tsx create mode 100644 portal/frontend/src/components/settings/rates/MunicipalityList.tsx create mode 100644 portal/frontend/src/components/settings/rates/PeriodEditor.tsx create mode 100644 portal/frontend/src/components/settings/rates/TariffDrawer.tsx create mode 100644 portal/frontend/src/components/sites/DeviceFormModal.tsx create mode 100644 portal/frontend/src/components/sites/SiteFormModal.tsx create mode 100644 portal/frontend/src/components/users/UserFormDrawer.tsx create mode 100644 portal/frontend/src/hooks/useAuth.tsx create mode 100644 portal/frontend/src/hooks/useBranding.tsx create mode 100644 portal/frontend/src/main.tsx create mode 100644 portal/frontend/src/pages/AdminSitesPage.tsx create mode 100644 portal/frontend/src/pages/DashboardPage.tsx create mode 100644 portal/frontend/src/pages/DashboardsPage.tsx create mode 100644 portal/frontend/src/pages/LoginPage.tsx create mode 100644 portal/frontend/src/pages/SettingsPage.tsx create mode 100644 portal/frontend/src/pages/UsersPage.tsx create mode 100644 portal/frontend/src/vite-env.d.ts create mode 100644 portal/frontend/tsconfig.json create mode 100644 portal/frontend/tsconfig.node.json create mode 100644 portal/frontend/vite.config.ts create mode 100644 portal/grafana/dashboards/.gitkeep create mode 100644 portal/grafana/dashboards/power-overview.json create mode 100644 portal/grafana/provisioning/dashboards/dashboards.yml create mode 100644 portal/grafana/provisioning/datasources/timescaledb.yml create mode 100644 portal/src/Tau.Acuvim.Portal/Configuration/ConnectionStringResolver.cs create mode 100644 portal/src/Tau.Acuvim.Portal/Configuration/MonitoringOptions.cs create mode 100644 portal/src/Tau.Acuvim.Portal/Configuration/PortalOptions.cs create mode 100644 portal/src/Tau.Acuvim.Portal/Constants/Roles.cs create mode 100644 portal/src/Tau.Acuvim.Portal/DTOs/AuthDtos.cs create mode 100644 portal/src/Tau.Acuvim.Portal/DTOs/BrandingDtos.cs create mode 100644 portal/src/Tau.Acuvim.Portal/DTOs/ConfigOverviewDtos.cs create mode 100644 portal/src/Tau.Acuvim.Portal/DTOs/GrafanaDtos.cs create mode 100644 portal/src/Tau.Acuvim.Portal/DTOs/MonitoringDtos.cs create mode 100644 portal/src/Tau.Acuvim.Portal/DTOs/RatesDtos.cs create mode 100644 portal/src/Tau.Acuvim.Portal/DTOs/UserDtos.cs create mode 100644 portal/src/Tau.Acuvim.Portal/Data/AppDbContext.cs create mode 100644 portal/src/Tau.Acuvim.Portal/Domain/Branding/WhiteLabelSettings.cs create mode 100644 portal/src/Tau.Acuvim.Portal/Domain/Identity/ApplicationUser.cs create mode 100644 portal/src/Tau.Acuvim.Portal/Domain/Monitoring/Device.cs create mode 100644 portal/src/Tau.Acuvim.Portal/Domain/Monitoring/PowerMeasurement.cs create mode 100644 portal/src/Tau.Acuvim.Portal/Domain/Monitoring/Site.cs create mode 100644 portal/src/Tau.Acuvim.Portal/Domain/Rates/ConsumptionSample.cs create mode 100644 portal/src/Tau.Acuvim.Portal/Domain/Rates/CostBreakdown.cs create mode 100644 portal/src/Tau.Acuvim.Portal/Domain/Rates/DayOfWeekFlag.cs create mode 100644 portal/src/Tau.Acuvim.Portal/Domain/Rates/Municipality.cs create mode 100644 portal/src/Tau.Acuvim.Portal/Domain/Rates/Tariff.cs create mode 100644 portal/src/Tau.Acuvim.Portal/Domain/Rates/TariffPeriod.cs create mode 100644 portal/src/Tau.Acuvim.Portal/Endpoints/AdminConfigEndpoints.cs create mode 100644 portal/src/Tau.Acuvim.Portal/Endpoints/AdminRatesEndpoints.cs create mode 100644 portal/src/Tau.Acuvim.Portal/Endpoints/AdminUserEndpoints.cs create mode 100644 portal/src/Tau.Acuvim.Portal/Endpoints/AuthEndpoints.cs create mode 100644 portal/src/Tau.Acuvim.Portal/Endpoints/BrandingEndpoints.cs create mode 100644 portal/src/Tau.Acuvim.Portal/Endpoints/GrafanaEndpoints.cs create mode 100644 portal/src/Tau.Acuvim.Portal/Endpoints/MeasurementsEndpoints.cs create mode 100644 portal/src/Tau.Acuvim.Portal/Endpoints/RatesEndpoints.cs create mode 100644 portal/src/Tau.Acuvim.Portal/Endpoints/SitesEndpoints.cs create mode 100644 portal/src/Tau.Acuvim.Portal/Migrations/20260518064632_InitialCreate.Designer.cs create mode 100644 portal/src/Tau.Acuvim.Portal/Migrations/20260518064632_InitialCreate.cs create mode 100644 portal/src/Tau.Acuvim.Portal/Migrations/AppDbContextModelSnapshot.cs create mode 100644 portal/src/Tau.Acuvim.Portal/Program.cs create mode 100644 portal/src/Tau.Acuvim.Portal/Properties/launchSettings.json create mode 100644 portal/src/Tau.Acuvim.Portal/Services/BrandingService.cs create mode 100644 portal/src/Tau.Acuvim.Portal/Services/ConfigOverviewService.cs create mode 100644 portal/src/Tau.Acuvim.Portal/Services/CostCalculator.cs create mode 100644 portal/src/Tau.Acuvim.Portal/Services/GrafanaService.cs create mode 100644 portal/src/Tau.Acuvim.Portal/Services/IdentityBootstrapper.cs create mode 100644 portal/src/Tau.Acuvim.Portal/Services/MeasurementIngestService.cs create mode 100644 portal/src/Tau.Acuvim.Portal/Services/MeasurementQueryService.cs create mode 100644 portal/src/Tau.Acuvim.Portal/Services/RateService.cs create mode 100644 portal/src/Tau.Acuvim.Portal/Services/TariffValidator.cs create mode 100644 portal/src/Tau.Acuvim.Portal/Services/TimescaleBootstrapper.cs create mode 100644 portal/src/Tau.Acuvim.Portal/Tau.Acuvim.Portal.csproj create mode 100644 portal/src/Tau.Acuvim.Portal/appsettings.Development.json create mode 100644 portal/src/Tau.Acuvim.Portal/appsettings.json create mode 100644 portal/src/Tau.Acuvim.Portal/appsettings.template.json create mode 100644 portal/tests/Tau.Acuvim.Portal.Tests/ConnectionStringResolverTests.cs create mode 100644 portal/tests/Tau.Acuvim.Portal.Tests/CostCalculatorTests.cs create mode 100644 portal/tests/Tau.Acuvim.Portal.Tests/DayOfWeekFlagTests.cs create mode 100644 portal/tests/Tau.Acuvim.Portal.Tests/Helpers/FakeHostEnvironment.cs create mode 100644 portal/tests/Tau.Acuvim.Portal.Tests/TariffValidatorTests.cs create mode 100644 portal/tests/Tau.Acuvim.Portal.Tests/Tau.Acuvim.Portal.Tests.csproj diff --git a/portal/.dockerignore b/portal/.dockerignore new file mode 100644 index 0000000..ea97810 --- /dev/null +++ b/portal/.dockerignore @@ -0,0 +1,16 @@ +**/bin +**/obj +**/node_modules +**/dist +**/.vite +**/.vs +**/.idea +**/.git +**/.gitignore +**/.dockerignore +**/Dockerfile* +**/docker-compose*.yml +**/appsettings.Local.json +**/.env +**/.env.local +README.md diff --git a/portal/.env.example b/portal/.env.example new file mode 100644 index 0000000..0ab3e97 --- /dev/null +++ b/portal/.env.example @@ -0,0 +1,28 @@ +# Copy to .env for local docker compose use. +# DO NOT commit .env. + +# ── Common ─────────────────────────────────────────────────────────────── +# Compose project + container prefix. MUST be lowercase (Docker Compose v2 requirement). +# For a customer ID like ABC0001, set the project name to its lowercase form: abc0001. +# Containers then come out as abc0001_portal / abc0001_grafana / abc0001_timescale. +COMPOSE_PROJECT_NAME=portal-dev + +# ── Production-only ────────────────────────────────────────────────────── +# Public hostname Traefik routes to this customer's portal. +# Required by docker-compose.prod.yml. Ignored by the dev compose. +CUSTOMER_HOST=abc0001.portal.example.com + +# ── Database ───────────────────────────────────────────────────────────── +POSTGRES_DB=power_monitoring +POSTGRES_USER=power_user +POSTGRES_PASSWORD=change_me_for_local_only + +# ── Portal authentication ─────────────────────────────────────────────── +# In Production, DefaultAdminPassword MUST be changed or the app refuses to start. +Authentication__DefaultAdminEmail=admin@example.com +Authentication__DefaultAdminPassword=ChangeMe123! + +# ── Grafana ────────────────────────────────────────────────────────────── +GRAFANA_ADMIN_PASSWORD=admin +# Path prefix Grafana is mounted at behind Traefik. Same-origin embed in the SPA. +Grafana__EmbedPathPrefix=/grafana diff --git a/portal/.gitignore b/portal/.gitignore new file mode 100644 index 0000000..74f811a --- /dev/null +++ b/portal/.gitignore @@ -0,0 +1,29 @@ +# .NET +bin/ +obj/ +*.user +*.suo +.vs/ + +# Node / Vite +node_modules/ +dist/ +.vite/ + +# Local config (never commit secrets) +appsettings.Local.json +appsettings.*.Local.json +.env +.env.local +.env.*.local + +# Uploaded branding assets (per-dev local storage) +App_Data/ + +# IDE +.idea/ +*.swp + +# OS +.DS_Store +Thumbs.db diff --git a/portal/Dockerfile b/portal/Dockerfile new file mode 100644 index 0000000..90d442c --- /dev/null +++ b/portal/Dockerfile @@ -0,0 +1,28 @@ +FROM node:22-alpine AS frontend +WORKDIR /app/frontend +COPY frontend/package*.json ./ +RUN npm ci +COPY frontend/ ./ +RUN npm run build + +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY src/Tau.Acuvim.Portal/Tau.Acuvim.Portal.csproj ./Tau.Acuvim.Portal/ +RUN dotnet restore Tau.Acuvim.Portal/Tau.Acuvim.Portal.csproj +COPY src/Tau.Acuvim.Portal/ ./Tau.Acuvim.Portal/ +RUN dotnet publish Tau.Acuvim.Portal/Tau.Acuvim.Portal.csproj \ + -c Release -o /app/publish /p:UseAppHost=false + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime +WORKDIR /app +COPY --from=build /app/publish . +COPY --from=frontend /app/frontend/dist ./wwwroot/ + +RUN mkdir -p /data/keys /data/branding && \ + chown -R app:app /data +USER app + +ENV ASPNETCORE_URLS=http://+:8080 +EXPOSE 8080 + +ENTRYPOINT ["dotnet", "Tau.Acuvim.Portal.dll"] diff --git a/portal/OPERATIONS.md b/portal/OPERATIONS.md new file mode 100644 index 0000000..c65fbed --- /dev/null +++ b/portal/OPERATIONS.md @@ -0,0 +1,319 @@ +# Tau Acuvim Portal — Operations + +Per-customer deployment loop. For background, architecture, and security model, read the [README](./README.md) first. + +--- + +## Contents + +1. [Prerequisites (per host)](#prerequisites-per-host) +2. [Provisioning a new customer](#provisioning-a-new-customer) +3. [Updating a customer's stack](#updating-a-customers-stack) +4. [Rotating secrets](#rotating-secrets) +5. [Backup & restore](#backup--restore) +6. [Health & monitoring](#health--monitoring) +7. [Troubleshooting](#troubleshooting) +8. [Decommissioning a customer](#decommissioning-a-customer) + +--- + +## Prerequisites (per host) + +These exist once on the host running customer stacks; not per customer. + +1. **Docker Engine** (or Docker Desktop on Windows hosts). +2. **External Traefik instance** — running on the same host, joined to a Docker network named `traefik-public`. Configured with: + - Two entrypoints: `web` (80), `websecure` (443). + - A certificate resolver named `le` (Let's Encrypt via DNS-01 or HTTP-01). + - HTTP → HTTPS redirect. + - Docker provider with `exposedByDefault: false`. +3. **Wildcard DNS + TLS cert** for `*.portal.example.com` (or whatever your customer subdomain pattern is). +4. **The `traefik-public` Docker network exists**: + ``` + docker network create traefik-public # one-time + ``` +5. **The portal image is built** (or pull-able from a registry): + ``` + cd /path/to/portal + docker compose -f docker-compose.prod.yml build + ``` + +--- + +## Provisioning a new customer + +Goal: spin up an isolated stack for customer `ABC0001` (Compose project `abc0001` — lowercase required) at `abc0001.portal.example.com`. + +### 1. Create the customer directory + +A common pattern: one directory per customer holding only an `.env` file (the compose files are shared from the repo). Adjust to your fleet-management tool of choice (Ansible, Portainer, Helm-on-K8s later). + +``` +/srv/portal/abc0001/ + └── .env +``` + +### 2. Generate strong secrets + +```bash +openssl rand -base64 32 # POSTGRES_PASSWORD +openssl rand -base64 32 # GRAFANA_ADMIN_PASSWORD +openssl rand -base64 32 # Authentication__DefaultAdminPassword +``` + +### 3. Fill in `.env` + +```ini +COMPOSE_PROJECT_NAME=abc0001 +CUSTOMER_HOST=abc0001.portal.example.com + +POSTGRES_DB=power_monitoring +POSTGRES_USER=power_user +POSTGRES_PASSWORD= + +Authentication__DefaultAdminEmail=admin@abc0001.example.com +Authentication__DefaultAdminPassword= + +GRAFANA_ADMIN_PASSWORD= +Grafana__EmbedPathPrefix=/grafana +``` + +### 4. Decide Grafana auth mode + +Anonymous is **off** in the prod compose by default. Pick one of the three options from the README's Security notes and wire it before exposing the stack to anyone: + +- (a) Traefik `forwardAuth` → add the middleware to the `traefik.http.routers.${COMPOSE_PROJECT_NAME}-grafana` labels and implement `/api/auth/check` on the portal. +- (b) Grafana `auth.proxy` → set `GF_AUTH_PROXY_ENABLED=true`, `GF_AUTH_PROXY_HEADER_NAME=X-WEBAUTH-USER` env vars; ensure Traefik (or the portal) sets the header and that no client can. +- (c) Render tokens → minted by a portal endpoint; SPA appends `?auth_token=...` to the iframe URL. + +Without any of these, Grafana refuses anonymous access in prod (intended safe default — iframe will show a login page). + +### 5. Bring it up + +```bash +cd /srv/portal/abc0001 +docker compose --env-file .env -f /path/to/portal/docker-compose.prod.yml up -d +``` + +### 6. Verify + +```bash +# Wait for healthy +docker ps --filter "label=com.docker.compose.project=abc0001" + +# Health checks +curl -fs https://abc0001.portal.example.com/health # → Healthy +curl -fs https://abc0001.portal.example.com/health/ready # → Healthy + +# Migration + seed in the logs +docker logs abc0001_portal | grep -E "Applied migration|Seeded|hypertable" +# expect: +# Applied migration 'InitialCreate' +# TimescaleDB hypertable for monitoring.PowerMeasurements is ready +# Seeded default admin admin@abc0001.example.com +``` + +### 7. First login + handover + +1. Sign in as `Authentication__DefaultAdminEmail` with the password from step 2. +2. **Settings → Users** → create the customer's real admin account; toggle Admin on. +3. Sign out, sign in as the customer admin, **change the default admin password** (or delete the default admin account if the customer admin is the only one needed). +4. **Settings → Branding** → upload customer logo, apply colours. +5. **Settings → Rates** → seed at least one municipality + tariff for cost calc. +6. **Sites** → create the customer's sites/devices so the ingest pipeline knows where measurements belong. + +--- + +## Updating a customer's stack + +### Code-only update (no migrations, no compose changes) + +```bash +docker compose --env-file .env -f /path/to/portal/docker-compose.prod.yml \ + up -d --build portal +``` + +Brief downtime while the new container starts. The DB is untouched. + +### Update with new migrations + +Same command — `MigrateAsync` on startup applies pending migrations before the app accepts traffic. Watch the logs: + +```bash +docker logs -f abc0001_portal | grep -E "Applied migration|Failed|hypertable" +``` + +If a migration fails the container will exit; fix forward, push a corrected image, retry. + +### Compose changes (env vars, ports, labels) + +Edit the customer's `.env` (or the central `docker-compose.prod.yml`) and: + +```bash +docker compose --env-file .env -f /path/to/portal/docker-compose.prod.yml up -d +``` + +Compose recreates only the containers whose definition changed. + +### Rolling many customers + +There's no built-in fan-out — pick your orchestrator (Ansible playbook, simple bash loop, Portainer stacks). Update one customer first, verify, then roll the rest. + +--- + +## Rotating secrets + +### Database password + +```bash +# 1. Change the password inside Postgres +docker exec -it abc0001_timescale psql -U power_user -d power_monitoring \ + -c "ALTER USER power_user WITH PASSWORD '';" + +# 2. Update .env +sed -i 's/^POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=/' .env + +# 3. Recreate the portal + grafana to pick up new env vars +docker compose --env-file .env -f /path/to/portal/docker-compose.prod.yml \ + up -d portal grafana +``` + +### Grafana admin password + +```bash +sed -i 's/^GRAFANA_ADMIN_PASSWORD=.*/GRAFANA_ADMIN_PASSWORD=/' .env +docker compose --env-file .env -f /path/to/portal/docker-compose.prod.yml \ + up -d grafana +``` + +`GF_SECURITY_ADMIN_PASSWORD` is re-applied on container start. + +### Default admin password + +Once the customer admin exists and has changed their own password, the default admin can be deleted from the **Settings → Users** UI. After that, `Authentication__DefaultAdminPassword` is only used if the row is re-seeded (which happens only when no account with that email exists). + +--- + +## Backup & restore + +### What to back up + +| Volume | What's in it | Frequency | +|---|---|---| +| `_timescale-data` | All customer data (Identity, branding, tariffs, sites, devices, measurements) | Daily, more for high-write customers | +| `_grafana-data` | Grafana's internal SQLite (user prefs, plugin state). Dashboards re-provision from JSON so this is **not authoritative**. | Weekly is plenty | +| `_portal-branding` | Uploaded logos | Daily | +| `_portal-keys` | Data Protection key ring (cookie signing). Losing this invalidates all sessions but doesn't lose data. | Weekly | + +### Postgres dump + +```bash +docker exec abc0001_timescale \ + pg_dump -U power_user -d power_monitoring -F c -f /tmp/backup.dump +docker cp abc0001_timescale:/tmp/backup.dump ./abc0001-$(date +%Y%m%d).dump +docker exec abc0001_timescale rm /tmp/backup.dump +``` + +For consistent hypertable backups, prefer Timescale's `pg_dump` (supports hypertables natively as of PG12+; the above works). + +### Volume snapshot + +For non-DB volumes, simplest is a `tar` from the volume's mountpoint, or use your storage layer's snapshot facility (LVM, ZFS, EBS, etc.). + +### Restore + +```bash +# Fresh DB +docker compose -f /path/to/portal/docker-compose.prod.yml --env-file .env down timescaledb +docker volume rm abc0001_timescale-data +docker compose -f /path/to/portal/docker-compose.prod.yml --env-file .env up -d timescaledb + +# Restore +docker cp abc0001-YYYYMMDD.dump abc0001_timescale:/tmp/backup.dump +docker exec abc0001_timescale \ + pg_restore -U power_user -d power_monitoring --clean --if-exists /tmp/backup.dump +docker exec abc0001_timescale rm /tmp/backup.dump + +# Start everything +docker compose -f /path/to/portal/docker-compose.prod.yml --env-file .env up -d +``` + +The TimescaleBootstrapper is idempotent — it will not error on a restored hypertable. + +--- + +## Health & monitoring + +### Liveness / readiness + +- `GET /health` — liveness. Use as Traefik / load-balancer health check. +- `GET /health/ready` — readiness (DB reachable). Use for orchestration "in service" decisions. + +### Logs + +Serilog writes JSON to stdout; the Docker logging driver of your choice (json-file, journald, gelf to a central log store) picks it up. + +```bash +docker logs abc0001_portal --tail 200 --follow +``` + +Notable lines: +- `Database connection resolved via …` — confirms how this container resolved its DB at startup. +- `Applied migration '…'` — one per pending migration. +- `TimescaleDB hypertable for monitoring.PowerMeasurements is ready` — bootstrapper succeeded. +- `Seeded default admin …` — first start only; absence on subsequent starts is correct. + +### DB health from the host + +```bash +docker exec abc0001_timescale pg_isready -U power_user -d power_monitoring +``` + +### TimescaleDB chunks + +```bash +docker exec -it abc0001_timescale psql -U power_user -d power_monitoring -c \ + "SELECT chunk_name, range_start, range_end, total_bytes + FROM chunks_detailed_size('monitoring.\"PowerMeasurements\"');" +``` + +--- + +## Troubleshooting + +| Symptom | First check | +|---|---| +| Portal container restart-looping | `docker logs _portal` — usually a missing env var (default-admin password in prod, missing Postgres password) or a migration failure. | +| `/health/ready` returns Unhealthy | Postgres container down, or wrong creds. `docker logs _timescale`. | +| Grafana iframe loads but no charts | Datasource UID mismatch — confirm `grafana/provisioning/datasources/timescaledb.yml` has `uid: timescaledb` and the dashboard JSON references the same. | +| Grafana iframe shows login screen in prod | Expected if no auth mode is wired yet (anonymous off by default). Pick a mode (see README → Security). | +| Branded logo missing after restart | `_portal-branding` volume not mounted, or filesystem perms wrong. The container runs as user `app`; volume must be writable by uid 1000. | +| Ingest returns `accepted: 0, rejected: N` | Devices don't exist for those `externalId`s. Create them via the Sites screen first. | +| Cookie auth seems random / sessions lost on restart | `_portal-keys` volume not mounted — Data Protection re-keys on every start. | +| Hypertable error on startup | Pre-existing non-empty plain table being converted. `migrate_data => TRUE` should handle it; if not, restore from backup and check for manual `monitoring."PowerMeasurements"` schema changes. | + +--- + +## Decommissioning a customer + +```bash +# Final backup +docker exec abc0001_timescale pg_dump -U power_user -d power_monitoring \ + -F c -f /tmp/final.dump +docker cp abc0001_timescale:/tmp/final.dump ./abc0001-final-$(date +%Y%m%d).dump + +# Stop and remove containers +docker compose --env-file .env -f /path/to/portal/docker-compose.prod.yml down + +# Remove volumes (destroys data — confirm backup first) +docker volume rm \ + abc0001_timescale-data \ + abc0001_grafana-data \ + abc0001_portal-branding \ + abc0001_portal-keys + +# Remove customer dir +rm -rf /srv/portal/abc0001 + +# DNS record + cert (manual or via your DNS automation) +``` diff --git a/portal/README.md b/portal/README.md new file mode 100644 index 0000000..a6f5867 --- /dev/null +++ b/portal/README.md @@ -0,0 +1,369 @@ +# Tau Acuvim Portal + +Customer-facing, white-labeled power monitoring portal. One stack per customer, deployed behind Traefik with the customer ID (lowercased — Docker Compose v2 requirement) as the container prefix: customer `ABC0001` produces `abc0001_portal`, `abc0001_grafana`, `abc0001_timescale`. + +This project lives next to `console/` (internal management interface) and `firmware/` (ESP32) in the same repo. The three projects share no code; the portal stands alone. + +--- + +## Contents + +1. [Overview](#overview) +2. [Architecture](#architecture) +3. [Configuration template](#configuration-template) +4. [Local setup](#local-setup) +5. [Docker Compose](#docker-compose) +6. [Database migrations](#database-migrations) +7. [Accessing the app](#accessing-the-app) +8. [Accessing Grafana](#accessing-grafana) +9. [Default local test credentials](#default-local-test-credentials) +10. [Production deployment notes](#production-deployment-notes) +11. [Security notes](#security-notes) +12. [Testing](#testing) +13. [Operations](#operations) + +--- + +## Overview + +A customer signs in to their own branded portal, sees their meters' live + historical power readings via embedded Grafana dashboards, and (for admins) configures branding, municipality tariffs, users, sites, and devices. Each customer gets a fully isolated stack: their own database, their own Grafana, their own branding. Traefik routes `.portal.example.com` to the right containers. + +### Tech stack + +| Layer | Technology | +|---|---| +| Backend | .NET 10 minimal API, EF Core 10, Npgsql, ASP.NET Core Identity, Serilog | +| Frontend | React 18 + TypeScript + Vite, Ant Design 5, TanStack Query, react-router | +| Database | TimescaleDB 2.17 on PostgreSQL 16 | +| Graphing | Grafana 11 (provisioned datasource + dashboards) | +| Container | Docker / Docker Compose; Traefik for routing | +| Auth | Cookie-based via ASP.NET Identity (SPA-friendly, 401/403 not redirects) | + +--- + +## Architecture + +### Containers, per customer + +``` + ┌────────────────────────────────────────────────────┐ + │ Traefik │ + │ host: .portal.example.com │ + └────────────────────────────────────────────────────┘ + ┌─────────────────────────┬─────────────────────────┐ + ▼ ▼ ▼ +┌───────────────────┐ ┌───────────────────┐ ┌───────────────────┐ +│ _portal │ │ _grafana │ │_timescale │ +│ .NET API + SPA │──▶│ Grafana 11 │──▶│ TimescaleDB + Pg16│ +│ :8080 │ │ :3000 │ │ :5432 │ +└───────────────────┘ └───────────────────┘ └───────────────────┘ +``` + +`` = the customer's 7-digit ID **lowercased** (e.g. `abc0001` for customer `ABC0001`), set via `COMPOSE_PROJECT_NAME`. Compose v2 rejects uppercase project names. + +### Backend layout + +- **Combined container** — Dockerfile builds the React SPA, then a multi-stage .NET build, then copies the SPA into `wwwroot`. One image, one process per customer. +- **Single `AppDbContext`** with three schemas: + - `identity` — ASP.NET Identity tables. + - `app` — branding, municipalities, tariffs, periods. + - `monitoring` — sites, devices, power measurements (hypertable). +- **Minimal API** with endpoints grouped under `Endpoints/*.cs`. Services in `Services/*.cs`. Typed options in `Configuration/`. + +### Frontend layout + +- `src/pages/` — page-level components (`DashboardsPage`, `SettingsPage`, etc.). +- `src/components/` — shared + feature components. +- `src/api/` — typed axios calls. +- `src/hooks/useAuth`, `useBranding` — global state via Context. +- `RequireAuth` / `RequireRole` — route guards. +- AntD `ConfigProvider` is themed dynamically from `BrandingProvider` (white-labelling). + +--- + +## Configuration template + +All configurable values are declared in `src/Tau.Acuvim.Portal/appsettings.template.json` — checked in, no secrets. It's the lowest-priority configuration source: everything overrides it. + +### Precedence (lowest → highest) + +1. `appsettings.template.json` — shippable defaults (always loaded). +2. `appsettings.json` — runtime infra (Serilog, AllowedHosts). +3. `appsettings.{Environment}.json` — per-environment overrides. +4. `appsettings.Local.json` — gitignored, your local overrides. +5. Environment variables — use `__` as section separator (e.g. `Authentication__DefaultAdminPassword=...`). Production secrets go here. + +### Sections + +| Section | Purpose | +|---|---| +| `Application` | Name, environment, public URL | +| `Database` | Provider, connection string, `MigrateOnStartup`, `AutoProvisionLocalTimescaleDb` | +| `TimescaleDb` | Host/port/db/user/password for auto-provision in dev | +| `Grafana` | Base URL, internal URL, path prefix, embed mode, dashboard list | +| `WhiteLabel` | App name, logo URL, colours, footer, logo storage path | +| `Authentication` | Cookie name, lockout, default admin email/password | +| `Monitoring` | Hypertable chunk interval, aggregate flag | + +### Database connection resolution + +1. If `Database:ConnectionString` is non-empty → use it. +2. Else if `Database:AutoProvisionLocalTimescaleDb=true` AND env is not `Production` → build from the `TimescaleDb:*` block (Host/Port/Database/Username/Password). +3. Otherwise → app refuses to start with a clear error. + +`AutoProvisionLocalTimescaleDb=true` in Production is a hard failure — production must supply its own connection string via env var or secret. + +--- + +## Local setup + +### Prerequisites + +- .NET 10 SDK +- Node 22+ / npm +- Docker Desktop +- `dotnet-ef` tool: `dotnet tool install --global dotnet-ef` + +### First-time: generate the initial migration + +Identity / branding / rates / monitoring entities are defined in code; the migration files themselves are generated artifacts. Run once: + +```powershell +cd C:\AcuvimDev\Tau.Acuvim\portal +dotnet ef migrations add InitialCreate ` + --project src/Tau.Acuvim.Portal/Tau.Acuvim.Portal.csproj ` + --output-dir Migrations +``` + +Commit the resulting `Migrations/` folder. From then on, `MigrateAsync` on startup applies whatever exists — no manual step at deploy time. + +### Option A: full stack in Docker (recommended) + +```powershell +cd C:\AcuvimDev\Tau.Acuvim\portal +Copy-Item .env.example .env +docker compose up --build -d +docker compose ps +``` + +Then: +- Portal: http://localhost:8080 +- Grafana: http://localhost:3001 (anonymous Viewer in dev) +- TimescaleDB: `localhost:5433` (user/db from `.env`) + +Stop + wipe: +```powershell +docker compose down -v +``` + +### Option B: backend + DB in Docker, frontend via Vite + +Same `docker compose up`, then in another terminal: + +```powershell +cd C:\AcuvimDev\Tau.Acuvim\portal\frontend +npm install +npm run dev +``` + +Vite serves at http://localhost:5174 and proxies `/api` + `/health` to the .NET container on `:8080`. + +### Option C: everything local (no Docker for the app) + +Postgres still needs Docker (or a local install): + +```powershell +docker compose up -d timescaledb +cd C:\AcuvimDev\Tau.Acuvim\portal\src\Tau.Acuvim.Portal +dotnet run # listens on :8080 +# in another terminal +cd C:\AcuvimDev\Tau.Acuvim\portal\frontend +npm run dev # :5174 +``` + +--- + +## Docker Compose + +### Dev — `docker-compose.yml` + +Three services: `portal`, `timescaledb`, `grafana`. Persistent named volumes (`timescale-data`, `grafana-data`, `portal-keys`, `portal-branding`). Healthcheck on Postgres; portal waits for healthy. Grafana ships with anonymous Viewer for easy local access; provisioned `TimescaleDB` datasource (uid: `timescaledb`) and any JSON dashboards under `grafana/dashboards/`. + +Host port mappings: `8080→portal`, `5433→timescaledb`, `3001→grafana`. Chosen to coexist with the console stack. + +### Prod — `docker-compose.prod.yml` + +Same services, no host port mappings. Joins external `traefik-public` network. Per-customer Traefik labels (subdomain routing for portal, same-origin path-prefix routing for Grafana at `/grafana`). Grafana sub-path + `GF_SERVER_ROOT_URL` configured. All secrets via env vars. + +Run: +```powershell +docker network create traefik-public # once on the host +docker compose -f docker-compose.prod.yml --env-file .env up -d +``` + +See [OPERATIONS.md](./OPERATIONS.md) for the full per-customer deployment loop. + +--- + +## Database migrations + +`MigrateAsync` runs on startup (controlled by `Database:MigrateOnStartup`, default `true`). Immediately after, `TimescaleBootstrapper` runs an idempotent block: + +1. `CREATE EXTENSION IF NOT EXISTS timescaledb` (defensive). +2. `SELECT create_hypertable('monitoring."PowerMeasurements"', 'Time', if_not_exists => TRUE, migrate_data => TRUE)`. +3. `SELECT set_chunk_time_interval('monitoring."PowerMeasurements"', INTERVAL '')`. + +Safe to re-run on every start. + +### Adding a new migration + +When you change the entity model: +```powershell +cd C:\AcuvimDev\Tau.Acuvim\portal +dotnet ef migrations add ` + --project src/Tau.Acuvim.Portal/Tau.Acuvim.Portal.csproj ` + --output-dir Migrations +``` +Commit. Next deploy applies it automatically. + +--- + +## Accessing the app + +| Environment | URL | +|---|---| +| Local (Docker combined) | http://localhost:8080 | +| Local (Vite dev) | http://localhost:5174 | +| Production | `https://` (e.g. `https://abc0001.portal.example.com`) | + +API base path: `/api`. Swagger UI in dev: `/swagger`. + +### Health endpoints + +- `GET /health` — liveness (process alive). Returns `Healthy` if the app responds. +- `GET /health/ready` — readiness. Returns `Healthy` only if TimescaleDB answers. + +### Nav surface + +| Page | Who sees it | +|---|---| +| Dashboard | Any authenticated user | +| Dashboards (embedded Grafana) | Any authenticated user | +| Sites | Admin only | +| Settings (Branding / Rates / Users / Grafana / App config) | Admin only | + +--- + +## Accessing Grafana + +### Local dev + +- Direct: http://localhost:3001 — anonymous Viewer can browse provisioned dashboards. Admin/`GRAFANA_ADMIN_PASSWORD` for editing. +- Embedded: portal **Dashboards** page — iframe `src` points at the local Grafana base URL. + +### Production + +- Direct browser access to `/grafana/*` is gated by your chosen auth mode (see Security notes). Anonymous is **off** in the prod compose. +- Embedded: portal **Dashboards** page — same-origin iframe via Traefik path prefix `/grafana`. + +### Provisioning + +- Datasource: `grafana/provisioning/datasources/timescaledb.yml` (uid: `timescaledb`). +- Dashboard provider: `grafana/provisioning/dashboards/dashboards.yml` (auto-discovers JSON in `grafana/dashboards/`, refresh 30s). +- Starter dashboard: `grafana/dashboards/power-overview.json` — active power + cumulative energy + latest-power stat, parameterised by a `device` template variable. + +To add a dashboard: +1. Drop the JSON into `grafana/dashboards/`. +2. Add an entry to `Grafana.Dashboards` in `appsettings.template.json` (or override in `appsettings.Local.json`) with the same `Uid`. The portal's Dashboards page picks it up after a refresh. + +--- + +## Default local test credentials + +Generated locally — change before publishing the stack to anyone. + +- Email: `admin@example.com` +- Password: `ChangeMe123!` + +Defined in `appsettings.template.json` → `Authentication`. The bootstrapper seeds this account only if no account with that email exists, and never overwrites a changed password. + +**Production guard:** if `ASPNETCORE_ENVIRONMENT=Production` and the default password is still `ChangeMe123!`, the app refuses to start with an explicit error. Override `Authentication__DefaultAdminPassword` via env var before deploying. + +--- + +## Production deployment notes + +For the per-customer deployment loop see [OPERATIONS.md](./OPERATIONS.md). The short version: + +1. **One Compose project per customer.** Set `COMPOSE_PROJECT_NAME=abc0001` (lowercase form of the customer ID — Compose v2 rejects uppercase). Containers are named `abc0001_portal`, `abc0001_grafana`, `abc0001_timescale`. +2. **One subdomain per customer.** Set `CUSTOMER_HOST=abc0001.portal.example.com`. Wildcard DNS + wildcard TLS cert via Traefik's resolver (`certresolver=le`). +3. **Decide your Grafana auth mode** (see Security notes). The prod compose deliberately leaves Grafana auth **off** so the iframe refuses to load until you pick. +4. **Set all secrets via env vars** (not files): + - `POSTGRES_PASSWORD` + - `GRAFANA_ADMIN_PASSWORD` + - `Authentication__DefaultAdminPassword` +5. **External `traefik-public` Docker network must exist** (created once on the host running Traefik). +6. **Up the stack:** + ``` + docker compose -f docker-compose.prod.yml --env-file .env up -d + ``` +7. **Verify** the three containers report healthy and `https:///health/ready` returns `Healthy`. + +--- + +## Security notes + +### What's protected by default + +- **ASP.NET Core Identity** with lockout (5 failed attempts → 15 min) and strong password requirements (8+ chars, upper + lower + digit). +- **Cookies are HttpOnly + SameSite=Lax + Secure in prod**, scoped to the portal subdomain. +- **Admin-only endpoints** are gated by an `AdminOnly` policy (`RequireRole("Admin")`). Confirmed at backend; nav hidden on frontend. +- **Cannot delete your own account** — backend block, not just UI. +- **`GET /api/admin/config-overview`** is admin-only; the DTO never includes the connection string or any password. Redaction by construction, not filtering. +- **Branding logo upload** rejects files >2 MB and extensions outside `{png, jpg, jpeg, svg, webp}`. +- **Anti-forgery is left on by default** on cookie-authenticated endpoints; the logo upload explicitly opts out (multipart needs it disabled). Other admin endpoints accept JSON over `same-site Lax` cookies, which is CSRF-safe for state-changing same-origin SPA requests. +- **Security headers** (`X-Content-Type-Options`, `X-Frame-Options: SAMEORIGIN`, `Referrer-Policy: strict-origin-when-cross-origin`) on every response. HSTS in prod. + +### Production refuse-to-start guards + +- App refuses to start in `Production` if `Authentication:DefaultAdminPassword` is still `ChangeMe123!`. +- App refuses to start in `Production` if `Database:AutoProvisionLocalTimescaleDb=true` (you must supply an explicit connection string). +- App refuses to start if no connection string can be resolved at all. + +### Grafana embedding — three production auth options + +The dev compose runs Grafana with anonymous Viewer (safe on `localhost`). The prod compose has anonymous **off** and leaves the auth mode unset on purpose — pick one before publishing: + +| Option | What it does | Trade-off | +|---|---|---| +| **(a) Traefik `forwardAuth`** | Traefik middleware calls a portal `/api/auth/check` endpoint on every Grafana request; portal cookie required, else 401 | Zero changes to Grafana. Best when "any portal user = same dashboards." | +| **(b) Grafana `auth.proxy`** | `GF_AUTH_PROXY_ENABLED=true`; trust an `X-WEBAUTH-USER` header set by Traefik | Maps portal user → Grafana user, gets per-user folders/perms. Sanitise the header — never let a client set it directly. | +| **(c) Service-account API key + render tokens** | Portal mints short-lived render tokens; SPA embeds via `?auth_token=...` | Most moving parts. Right when dashboards are stitched into custom UI per-panel rather than full Grafana. | + +Until one is wired, prod-mode Grafana refuses anonymous access and the iframe shows a login page — the intended safe default. + +### Other considerations + +- Same-origin embed (prod path-prefix routing through Traefik) sidesteps third-party-cookie blockers that increasingly break cross-origin Grafana iframes. +- Provisioned datasource is `editable: false` — admins cannot accidentally rewire Grafana from its UI. +- Default password complexity is tunable in `Program.cs` → `IdentityOptions`. Lockout is tunable in the same block. +- **TimescaleDB licensing** — we use Apache-licensed `timescale/timescaledb:*-pg16`. Stay on community features (hypertables, continuous aggregates) if you ever sell this as managed DBaaS. + +--- + +## Testing + +Backend unit tests under `tests/Tau.Acuvim.Portal.Tests/` cover cost calculation, rate validation, connection-string resolution, day-of-week math: + +```powershell +cd C:\AcuvimDev\Tau.Acuvim\portal\tests\Tau.Acuvim.Portal.Tests +dotnet test +``` + +See [TESTING.md](./TESTING.md) for the full manual integration scenario, frontend test scaffolding recipe, and edge-case checklist. + +--- + +## Operations + +For per-customer provisioning, secret rotation, backups, and health monitoring see [OPERATIONS.md](./OPERATIONS.md). diff --git a/portal/TESTING.md b/portal/TESTING.md new file mode 100644 index 0000000..a352741 --- /dev/null +++ b/portal/TESTING.md @@ -0,0 +1,129 @@ +# Tau Acuvim Portal — Testing + +This file covers what's checked-in (backend unit tests) and what to do manually or add later (integration, frontend). + +## Backend unit tests + +xUnit project under `portal/tests/Tau.Acuvim.Portal.Tests`, matching the console's test stack (xUnit 2.9.3, Microsoft.NET.Test.Sdk 17.14.1, EF Core InMemory 10.0.8, coverlet 6.0.4). + +```powershell +cd C:\AcuvimDev\Tau.Acuvim\portal\tests\Tau.Acuvim.Portal.Tests +dotnet test +``` + +What's covered: + +| Suite | What it locks down | +|---|---| +| `CostCalculatorTests` | Period selection by day-of-week + time, overlap-first-wins, default-rate fallback, VAT, fixed charges, `includeFixedMonthlyCharge=false`, timezone conversion (sample inside vs outside the local-time window), empty-samples edge case, null-tariff guard. | +| `ConnectionStringResolverTests` | All four precedence branches: explicit conn string wins; auto-provision in non-Production builds from `TimescaleDb` block; auto-provision in Production throws; no conn string + auto-provision off throws. | +| `TariffValidatorTests` | Tariff-level rules (name, effective dates, non-negative rates, VAT 0–100) and per-period rules (name, days, time order, midnight-wrap rejection, negative rate, bad time format). | +| `DayOfWeekFlagTests` | Each weekday maps correctly; `Weekdays`/`Weekends`/`All` compose. | + +## What's not covered by code (deliberate) + +### Authorization smoke tests +A `WebApplicationFactory` test that signs in a fake user and hits an admin endpoint would catch role-attribute regressions. Building one cleanly means swapping the EF provider, neutering `MigrateAsync` / `TimescaleBootstrapper` / the NpgSql health check, and stubbing cookie sign-in. That's a fair amount of plumbing for "the policy attribute is wired." + +**Add it if you start seeing role bypass regressions.** Skeleton: + +```csharp +public class AuthorizationSmokeTests : IClassFixture> { … } +``` + +— with `WebApplicationFactoryConfigureWebHost` swapping `AppDbContext` for InMemory, removing the hosted services that need Timescale, and configuring a test authentication scheme. + +### Frontend component tests +Recommend Vitest + React Testing Library + jsdom. Not scaffolded in this repo on purpose — a half-built test runner is worse than none. + +When you want them: + +```powershell +cd portal/frontend +npm install -D vitest @testing-library/react @testing-library/jest-dom jsdom @vitest/coverage-v8 +``` + +Add to `vite.config.ts`: + +```ts +test: { environment: 'jsdom', setupFiles: ['./src/test-setup.ts'], globals: true } +``` + +High-value first tests: +- `RequireAuth` — renders children when authed, redirects to `/login` when not. +- `RequireRole` — renders 403 panel when role missing, children when present. +- `BrandingProvider` — applies CSS variables and document.title after fetch resolves. +- `PeriodEditor` — toggling day-of-week buttons updates the bitmask correctly (this is the trickiest pure-logic spot in the UI). + +## Manual integration scenario + +End-to-end smoke test. Run after any phase change. + +### 0. Reset + +```powershell +cd C:\AcuvimDev\Tau.Acuvim\portal +docker compose down -v # wipe DB + Grafana volumes +docker compose up -d +``` + +### 1. Migrate + seed + +```powershell +cd src/Tau.Acuvim.Portal +dotnet run +``` + +Expect in the log: +- `Database connection resolved via …` +- `Applied migration ''` (one per pending) +- `TimescaleDB hypertable for monitoring.PowerMeasurements is ready (chunk interval: 7 days)` +- `Seeded default admin admin@example.com` + +### 2. Frontend up + +```powershell +cd portal/frontend +npm install +npm run dev +``` + +### 3. Walk the surface + +1. Browse `http://localhost:5174/login` → branded card from template defaults. +2. Sign in as `admin@example.com` / `ChangeMe123!`. +3. **Settings → Branding** → change primary colour + footer + upload a small PNG. Save → re-themes everywhere without reload. +4. **Settings → Rates** → create municipality "Cape Town" TZ `Africa/Johannesburg` → add tariff with three periods (peak/standard/off-peak per the README example). +5. **Settings → Users** → create `user@example.com` / `Password1`, not admin. Sign out, log in as user → only **Dashboard** and **Dashboards** in nav; `/admin/sites` shows 403. Sign back in as admin. +6. **Sites** → create "Warehouse A" linked to Cape Town → add device with external ID `ABC0001-MAIN-01`. +7. Ingest via Swagger (`/swagger`) or: + ``` + POST /api/ingest/measurements + [ + {"time":"2026-05-17T10:00:00Z","deviceExternalId":"ABC0001-MAIN-01","activePowerKw":12.5,"energyImportedKwh":1000}, + {"time":"2026-05-17T10:15:00Z","deviceExternalId":"ABC0001-MAIN-01","activePowerKw":13.1,"energyImportedKwh":1003.3} + ] + ``` + → `{accepted:2, rejected:0}`. Re-post → same result, no duplicates (`ON CONFLICT`). +8. `GET /api/measurements?deviceId=&from=2026-05-17T00:00:00Z&to=2026-05-17T23:59:59Z&bucket=hour` → one bucket with `sampleCount: 2`. +9. **Dashboards** → "Power Overview" iframe loads; device dropdown lists the device; chart renders the two samples. +10. **Settings → App config** → confirm DB host = `timescaledb`, port `5432`, db `power_monitoring`, **no password anywhere**. + +### 4. Edges to poke + +| Check | Expected | +|---|---| +| Wrong password 6 times | 6th try locks the account for 15 min (Identity lockout). | +| `dotnet run` with `ASPNETCORE_ENVIRONMENT=Production` + default admin password | App refuses to start with explicit error. | +| Restart `dotnet run` against existing DB | "Seeded default admin" line absent (idempotent). Bootstrapper re-confirms Timescale hypertable without error. | +| Delete a site with devices+measurements | Cascade removes everything; UI updates. | +| Create a period with EndTime ≤ StartTime in **Settings → Rates** | Backend 400 with "no midnight wrap" message. | +| `GET /api/admin/config-overview` as non-admin | 403. | +| Upload a 3 MB JPG to branding logo | 400 "Logo must be <= 2 MB". | + +## Local dev troubleshooting + +- **EF migration error on startup** — usually a schema drift since the last migration. Run `dotnet ef migrations add ` to capture the diff, commit, restart. +- **`TimescaleBootstrapper` complains about hypertable** — the table was already created by a previous run with different settings. Drop the table or recreate the DB volume. +- **`POST /api/ingest/measurements` returns `{accepted: 0, rejected: N}`** — the device's `ExternalId` doesn't match anything in the `monitoring.Devices` table. Create the device first under Sites. +- **Dashboards page shows iframe but no chart** — datasource UID mismatch. Confirm `grafana/provisioning/datasources/timescaledb.yml` sets `uid: timescaledb` and the dashboard JSON's panels reference the same. diff --git a/portal/docker-compose.prod.yml b/portal/docker-compose.prod.yml new file mode 100644 index 0000000..ad72263 --- /dev/null +++ b/portal/docker-compose.prod.yml @@ -0,0 +1,111 @@ +# Production stack: one per customer. +# Requires: +# - COMPOSE_PROJECT_NAME=abc0001 (LOWERCASE; Compose v2 rejects uppercase. Use the +# lowercase form of the customer ID — abc0001 for ABC0001.) +# - CUSTOMER_HOST=abc0001.portal.example.com (Traefik routes by this Host) +# - POSTGRES_PASSWORD, GRAFANA_ADMIN_PASSWORD set in .env +# - An external Docker network `traefik-public` created by your Traefik stack +# +# Run with: +# docker compose -f docker-compose.prod.yml --env-file .env up -d + +services: + portal: + build: . + image: tau-acuvim-portal:latest + container_name: ${COMPOSE_PROJECT_NAME}_portal + restart: unless-stopped + environment: + - ASPNETCORE_ENVIRONMENT=Production + - Application__PublicUrl=https://${CUSTOMER_HOST} + - Database__ConnectionString=Host=timescaledb;Port=5432;Database=${POSTGRES_DB:-power_monitoring};Username=${POSTGRES_USER:-power_user};Password=${POSTGRES_PASSWORD} + - Database__AutoProvisionLocalTimescaleDb=false + - Authentication__DefaultAdminEmail=${Authentication__DefaultAdminEmail} + - Authentication__DefaultAdminPassword=${Authentication__DefaultAdminPassword} + - Grafana__BaseUrl=https://${CUSTOMER_HOST}${Grafana__EmbedPathPrefix:-/grafana} + - Grafana__InternalUrl=http://grafana:3000 + - Grafana__EmbedPathPrefix=${Grafana__EmbedPathPrefix:-/grafana} + depends_on: + timescaledb: + condition: service_healthy + volumes: + - portal-keys:/data/keys + - portal-branding:/data/branding + networks: + - default + - traefik-public + labels: + - "traefik.enable=true" + - "traefik.docker.network=traefik-public" + - "traefik.http.routers.${COMPOSE_PROJECT_NAME}-portal.rule=Host(`${CUSTOMER_HOST}`)" + - "traefik.http.routers.${COMPOSE_PROJECT_NAME}-portal.entrypoints=websecure" + - "traefik.http.routers.${COMPOSE_PROJECT_NAME}-portal.tls=true" + - "traefik.http.routers.${COMPOSE_PROJECT_NAME}-portal.tls.certresolver=le" + - "traefik.http.routers.${COMPOSE_PROJECT_NAME}-portal.priority=10" + - "traefik.http.services.${COMPOSE_PROJECT_NAME}-portal.loadbalancer.server.port=8080" + + timescaledb: + image: timescale/timescaledb:2.17.2-pg16 + container_name: ${COMPOSE_PROJECT_NAME}_timescale + restart: unless-stopped + environment: + - POSTGRES_DB=${POSTGRES_DB:-power_monitoring} + - POSTGRES_USER=${POSTGRES_USER:-power_user} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} + volumes: + - timescale-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-power_user} -d ${POSTGRES_DB:-power_monitoring}"] + interval: 10s + timeout: 5s + retries: 10 + + grafana: + image: grafana/grafana:11.4.0 + container_name: ${COMPOSE_PROJECT_NAME}_grafana + restart: unless-stopped + environment: + - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD} + - GF_SECURITY_ALLOW_EMBEDDING=true + - GF_SERVER_ROOT_URL=https://${CUSTOMER_HOST}${Grafana__EmbedPathPrefix:-/grafana} + - GF_SERVER_SERVE_FROM_SUB_PATH=true + # PROD AUTH IS NOT WIRED YET (Phase 9 risk). + # Anonymous is OFF so dashboards do NOT serve until you choose: + # (a) Traefik forwardAuth middleware → /api/auth/check + # (b) Grafana auth.proxy (GF_AUTH_PROXY_*) with X-WEBAUTH-USER from portal/Traefik + # (c) Service-account API key + portal-minted render tokens + # See README "Embedding Grafana — production auth options". + - GF_AUTH_ANONYMOUS_ENABLED=false + - GF_USERS_ALLOW_SIGN_UP=false + - POSTGRES_DB=${POSTGRES_DB:-power_monitoring} + - POSTGRES_USER=${POSTGRES_USER:-power_user} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} + volumes: + - grafana-data:/var/lib/grafana + - ./grafana/provisioning:/etc/grafana/provisioning:ro + - ./grafana/dashboards:/var/lib/grafana/dashboards:ro + depends_on: + timescaledb: + condition: service_healthy + networks: + - default + - traefik-public + labels: + - "traefik.enable=true" + - "traefik.docker.network=traefik-public" + - "traefik.http.routers.${COMPOSE_PROJECT_NAME}-grafana.rule=Host(`${CUSTOMER_HOST}`) && PathPrefix(`${Grafana__EmbedPathPrefix:-/grafana}`)" + - "traefik.http.routers.${COMPOSE_PROJECT_NAME}-grafana.entrypoints=websecure" + - "traefik.http.routers.${COMPOSE_PROJECT_NAME}-grafana.tls=true" + - "traefik.http.routers.${COMPOSE_PROJECT_NAME}-grafana.tls.certresolver=le" + - "traefik.http.routers.${COMPOSE_PROJECT_NAME}-grafana.priority=20" + - "traefik.http.services.${COMPOSE_PROJECT_NAME}-grafana.loadbalancer.server.port=3000" + +volumes: + portal-keys: + portal-branding: + timescale-data: + grafana-data: + +networks: + traefik-public: + external: true diff --git a/portal/docker-compose.yml b/portal/docker-compose.yml new file mode 100644 index 0000000..c47dcfb --- /dev/null +++ b/portal/docker-compose.yml @@ -0,0 +1,70 @@ +# Local development stack. +# For production, see docker-compose.prod.yml (Traefik labels, no host ports, no anon Grafana). +services: + portal: + build: . + container_name: ${COMPOSE_PROJECT_NAME:-portal-dev}_portal + ports: + - "8080:8080" + environment: + - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Development} + - Database__ConnectionString=Host=timescaledb;Port=5432;Database=${POSTGRES_DB:-power_monitoring};Username=${POSTGRES_USER:-power_user};Password=${POSTGRES_PASSWORD:-change_me_for_local_only} + - Database__AutoProvisionLocalTimescaleDb=false + # In the container the writable volume is /data/branding (Dockerfile chowns it). + # The appsettings.Development.json override of LogoStoragePath is for local `dotnet run`, not Docker. + - WhiteLabel__LogoStoragePath=/data/branding + - Authentication__DefaultAdminEmail=${Authentication__DefaultAdminEmail:-admin@example.com} + - Authentication__DefaultAdminPassword=${Authentication__DefaultAdminPassword:-ChangeMe123!} + - Grafana__BaseUrl=http://localhost:3001 + - Grafana__InternalUrl=http://grafana:3000 + depends_on: + timescaledb: + condition: service_healthy + volumes: + - portal-keys:/data/keys + - portal-branding:/data/branding + + timescaledb: + image: timescale/timescaledb:2.17.2-pg16 + container_name: ${COMPOSE_PROJECT_NAME:-portal-dev}_timescale + ports: + - "5433:5432" + environment: + - POSTGRES_DB=${POSTGRES_DB:-power_monitoring} + - POSTGRES_USER=${POSTGRES_USER:-power_user} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-change_me_for_local_only} + volumes: + - timescale-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-power_user} -d ${POSTGRES_DB:-power_monitoring}"] + interval: 5s + timeout: 5s + retries: 10 + + grafana: + image: grafana/grafana:11.4.0 + container_name: ${COMPOSE_PROJECT_NAME:-portal-dev}_grafana + ports: + - "3001:3000" + environment: + - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD:-admin} + - GF_SECURITY_ALLOW_EMBEDDING=true + - GF_AUTH_ANONYMOUS_ENABLED=true + - GF_AUTH_ANONYMOUS_ORG_ROLE=Viewer + - GF_USERS_ALLOW_SIGN_UP=false + - POSTGRES_DB=${POSTGRES_DB:-power_monitoring} + - POSTGRES_USER=${POSTGRES_USER:-power_user} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-change_me_for_local_only} + volumes: + - grafana-data:/var/lib/grafana + - ./grafana/provisioning:/etc/grafana/provisioning:ro + - ./grafana/dashboards:/var/lib/grafana/dashboards:ro + depends_on: + timescaledb: + condition: service_healthy + +volumes: + portal-keys: + portal-branding: + timescale-data: + grafana-data: diff --git a/portal/frontend/.gitignore b/portal/frontend/.gitignore new file mode 100644 index 0000000..4ba5645 --- /dev/null +++ b/portal/frontend/.gitignore @@ -0,0 +1,11 @@ +node_modules/ +dist/ +.vite/ +*.log +*.tsbuildinfo +# Build artefacts of vite.config.ts (we keep the .ts source only) +vite.config.js +vite.config.d.ts +.env +.env.local +.env.*.local diff --git a/portal/frontend/index.html b/portal/frontend/index.html new file mode 100644 index 0000000..5ae8dbc --- /dev/null +++ b/portal/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Tau Acuvim Portal + + +
+ + + diff --git a/portal/frontend/package-lock.json b/portal/frontend/package-lock.json new file mode 100644 index 0000000..94b083f --- /dev/null +++ b/portal/frontend/package-lock.json @@ -0,0 +1,3282 @@ +{ + "name": "tau-acuvim-portal", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "tau-acuvim-portal", + "version": "0.1.0", + "dependencies": { + "@ant-design/icons": "^5.6.1", + "@tanstack/react-query": "^5.62.0", + "antd": "^5.22.0", + "axios": "^1.7.9", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.0" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "~5.6.2", + "vite": "^6.0.0" + } + }, + "node_modules/@ant-design/colors": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-7.2.1.tgz", + "integrity": "sha512-lCHDcEzieu4GA3n8ELeZ5VQ8pKQAWcGGLRTQ50aQM2iqPpq2evTxER84jfdPvsPAtEcZ7m44NI45edFMo8oOYQ==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^2.0.6" + } + }, + "node_modules/@ant-design/cssinjs": { + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/@ant-design/cssinjs/-/cssinjs-1.24.0.tgz", + "integrity": "sha512-K4cYrJBsgvL+IoozUXYjbT6LHHNt+19a9zkvpBPxLjFHas1UpPM2A5MlhROb0BT8N8WoavM5VsP9MeSeNK/3mg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "@emotion/hash": "^0.8.0", + "@emotion/unitless": "^0.7.5", + "classnames": "^2.3.1", + "csstype": "^3.1.3", + "rc-util": "^5.35.0", + "stylis": "^4.3.4" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/cssinjs-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@ant-design/cssinjs-utils/-/cssinjs-utils-1.1.3.tgz", + "integrity": "sha512-nOoQMLW1l+xR1Co8NFVYiP8pZp3VjIIzqV6D6ShYF2ljtdwWJn5WSsH+7kvCktXL/yhEtWURKOfH5Xz/gzlwsg==", + "license": "MIT", + "dependencies": { + "@ant-design/cssinjs": "^1.21.0", + "@babel/runtime": "^7.23.2", + "rc-util": "^5.38.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@ant-design/fast-color": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@ant-design/fast-color/-/fast-color-2.0.6.tgz", + "integrity": "sha512-y2217gk4NqL35giHl72o6Zzqji9O7vHh9YmhUVkPtAOpoTCH4uWxo/pr4VE8t0+ChEPs0qo4eJRC5Q1eXWo3vA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@ant-design/icons": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-5.6.1.tgz", + "integrity": "sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^7.0.0", + "@ant-design/icons-svg": "^4.4.0", + "@babel/runtime": "^7.24.8", + "classnames": "^2.2.6", + "rc-util": "^5.31.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/icons-svg": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.4.2.tgz", + "integrity": "sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA==", + "license": "MIT" + }, + "node_modules/@ant-design/react-slick": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@ant-design/react-slick/-/react-slick-1.1.2.tgz", + "integrity": "sha512-EzlvzE6xQUBrZuuhSAFTdsr4P2bBBHGZwKFemEfq8gIGyIQCxalYfZW/T2ORbtQx5rU69o+WycP3exY/7T1hGA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.4", + "classnames": "^2.2.5", + "json2mq": "^0.2.0", + "resize-observer-polyfill": "^1.5.1", + "throttle-debounce": "^5.0.0" + }, + "peerDependencies": { + "react": ">=16.9.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", + "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==", + "license": "MIT" + }, + "node_modules/@emotion/unitless": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz", + "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rc-component/async-validator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@rc-component/async-validator/-/async-validator-5.1.0.tgz", + "integrity": "sha512-n4HcR5siNUXRX23nDizbZBQPO0ZM/5oTtmKZ6/eqL0L2bo747cklFdZGRN2f+c9qWGICwDzrhW0H7tE9PptdcA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.4" + }, + "engines": { + "node": ">=14.x" + } + }, + "node_modules/@rc-component/color-picker": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@rc-component/color-picker/-/color-picker-2.0.1.tgz", + "integrity": "sha512-WcZYwAThV/b2GISQ8F+7650r5ZZJ043E57aVBFkQ+kSY4C6wdofXgB0hBx+GPGpIU0Z81eETNoDUJMr7oy/P8Q==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^2.0.6", + "@babel/runtime": "^7.23.6", + "classnames": "^2.2.6", + "rc-util": "^5.38.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/context": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@rc-component/context/-/context-1.4.0.tgz", + "integrity": "sha512-kFcNxg9oLRMoL3qki0OMxK+7g5mypjgaaJp/pkOis/6rVxma9nJBF/8kCIuTYHUQNr0ii7MxqE33wirPZLJQ2w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/mini-decimal": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rc-component/mini-decimal/-/mini-decimal-1.1.3.tgz", + "integrity": "sha512-bk/FJ09fLf+NLODMAFll6CfYrHPBioTedhW6lxDBuuWucJEqFUd4l/D/5JgIi3dina6sYahB8iuPAZTNz2pMxw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@rc-component/mutate-observer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rc-component/mutate-observer/-/mutate-observer-1.1.0.tgz", + "integrity": "sha512-QjrOsDXQusNwGZPf4/qRQasg7UFEj06XiCJ8iuiq/Io7CrHrgVi6Uuetw60WAMG1799v+aM8kyc+1L/GBbHSlw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0", + "classnames": "^2.3.2", + "rc-util": "^5.24.4" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/portal": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rc-component/portal/-/portal-1.1.2.tgz", + "integrity": "sha512-6f813C0IsasTZms08kfA8kPAGxbbkYToa8ALaiDIGGECU4i9hj8Plgbx0sNJDrey3EtHO30hmdaxtT0138xZcg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0", + "classnames": "^2.3.2", + "rc-util": "^5.24.4" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/qrcode": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@rc-component/qrcode/-/qrcode-1.1.1.tgz", + "integrity": "sha512-LfLGNymzKdUPjXUbRP+xOhIWY4jQ+YMj5MmWAcgcAq1Ij8XP7tRmAXqyuv96XvLUBE/5cA8hLFl9eO1JQMujrA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/tour": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@rc-component/tour/-/tour-1.15.1.tgz", + "integrity": "sha512-Tr2t7J1DKZUpfJuDZWHxyxWpfmj8EZrqSgyMZ+BCdvKZ6r1UDsfU46M/iWAAFBy961Ssfom2kv5f3UcjIL2CmQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0", + "@rc-component/portal": "^1.0.0-9", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.3.2", + "rc-util": "^5.24.4" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/trigger": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@rc-component/trigger/-/trigger-2.3.1.tgz", + "integrity": "sha512-ORENF39PeXTzM+gQEshuk460Z8N4+6DkjpxlpE7Q3gYy1iBpLrx0FOJz3h62ryrJZ/3zCAUIkT1Pb/8hHWpb3A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2", + "@rc-component/portal": "^1.1.0", + "classnames": "^2.3.2", + "rc-motion": "^2.0.0", + "rc-resize-observer": "^1.3.1", + "rc-util": "^5.44.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tanstack/query-core": { + "version": "5.100.10", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.10.tgz", + "integrity": "sha512-8UR0yJR+GiQ40m3lPhUr0xbfAupe6GSQiksSBSa9SM2NjezFyxXCIA69/lz8cSoNKZLrw1/PktIyQBJcVeMi3w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.100.10", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.10.tgz", + "integrity": "sha512-FLaZf2RCrA/Zgp4aiu5tG3TyasTRO7aZ99skxQpr3Hg/zXOhu6yq5FZCYQ/tRaJtM9ylnoK8tFK7PolXQadv6Q==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.100.10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/antd": { + "version": "5.29.3", + "resolved": "https://registry.npmjs.org/antd/-/antd-5.29.3.tgz", + "integrity": "sha512-3DdbGCa9tWAJGcCJ6rzR8EJFsv2CtyEbkVabZE14pfgUHfCicWCj0/QzQVLDYg8CPfQk9BH7fHCoTXHTy7MP/A==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^7.2.1", + "@ant-design/cssinjs": "^1.23.0", + "@ant-design/cssinjs-utils": "^1.1.3", + "@ant-design/fast-color": "^2.0.6", + "@ant-design/icons": "^5.6.1", + "@ant-design/react-slick": "~1.1.2", + "@babel/runtime": "^7.26.0", + "@rc-component/color-picker": "~2.0.1", + "@rc-component/mutate-observer": "^1.1.0", + "@rc-component/qrcode": "~1.1.0", + "@rc-component/tour": "~1.15.1", + "@rc-component/trigger": "^2.3.0", + "classnames": "^2.5.1", + "copy-to-clipboard": "^3.3.3", + "dayjs": "^1.11.11", + "rc-cascader": "~3.34.0", + "rc-checkbox": "~3.5.0", + "rc-collapse": "~3.9.0", + "rc-dialog": "~9.6.0", + "rc-drawer": "~7.3.0", + "rc-dropdown": "~4.2.1", + "rc-field-form": "~2.7.1", + "rc-image": "~7.12.0", + "rc-input": "~1.8.0", + "rc-input-number": "~9.5.0", + "rc-mentions": "~2.20.0", + "rc-menu": "~9.16.1", + "rc-motion": "^2.9.5", + "rc-notification": "~5.6.4", + "rc-pagination": "~5.1.0", + "rc-picker": "~4.11.3", + "rc-progress": "~4.0.0", + "rc-rate": "~2.13.1", + "rc-resize-observer": "^1.4.3", + "rc-segmented": "~2.7.0", + "rc-select": "~14.16.8", + "rc-slider": "~11.1.9", + "rc-steps": "~6.0.1", + "rc-switch": "~4.1.0", + "rc-table": "~7.54.0", + "rc-tabs": "~15.7.0", + "rc-textarea": "~1.10.2", + "rc-tooltip": "~6.4.0", + "rc-tree": "~5.13.1", + "rc-tree-select": "~5.27.0", + "rc-upload": "~4.11.0", + "rc-util": "^5.44.4", + "scroll-into-view-if-needed": "^3.1.0", + "throttle-debounce": "^5.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ant-design" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", + "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.30", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.30.tgz", + "integrity": "sha512-xjOFN16Ha1+Rz4nFYKqHU/LSB+gx/Vi3yQLX7r7sAW+Wa+8hhF2h4pvqTrTMc8+WcDBEunnUurr46Jvv0jk3Vg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/compute-scroll-into-view": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz", + "integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==", + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/copy-to-clipboard": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz", + "integrity": "sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==", + "license": "MIT", + "dependencies": { + "toggle-selection": "^1.0.6" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.20", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.357", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.357.tgz", + "integrity": "sha512-NHlTIQDK8fmVwHwuIzmXYEJ1Ewq3D9wDNc0cWXxDGysP6Pb21giwGNkxiTifyKy/4SoPuN5l6GLP1W9Sv7zB2g==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json2mq": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/json2mq/-/json2mq-0.2.0.tgz", + "integrity": "sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA==", + "license": "MIT", + "dependencies": { + "string-convert": "^0.2.0" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.44", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.44.tgz", + "integrity": "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/rc-cascader": { + "version": "3.34.0", + "resolved": "https://registry.npmjs.org/rc-cascader/-/rc-cascader-3.34.0.tgz", + "integrity": "sha512-KpXypcvju9ptjW9FaN2NFcA2QH9E9LHKq169Y0eWtH4e/wHQ5Wh5qZakAgvb8EKZ736WZ3B0zLLOBsrsja5Dag==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.7", + "classnames": "^2.3.1", + "rc-select": "~14.16.2", + "rc-tree": "~5.13.0", + "rc-util": "^5.43.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-checkbox": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/rc-checkbox/-/rc-checkbox-3.5.0.tgz", + "integrity": "sha512-aOAQc3E98HteIIsSqm6Xk2FPKIER6+5vyEFMZfo73TqM+VVAIqOkHoPjgKLqSNtVLWScoaM7vY2ZrGEheI79yg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.3.2", + "rc-util": "^5.25.2" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-collapse": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/rc-collapse/-/rc-collapse-3.9.0.tgz", + "integrity": "sha512-swDdz4QZ4dFTo4RAUMLL50qP0EY62N2kvmk2We5xYdRwcRn8WcYtuetCJpwpaCbUfUt5+huLpVxhvmnK+PHrkA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "rc-motion": "^2.3.4", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-dialog": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/rc-dialog/-/rc-dialog-9.6.0.tgz", + "integrity": "sha512-ApoVi9Z8PaCQg6FsUzS8yvBEQy0ZL2PkuvAgrmohPkN3okps5WZ5WQWPc1RNuiOKaAYv8B97ACdsFU5LizzCqg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/portal": "^1.0.0-8", + "classnames": "^2.2.6", + "rc-motion": "^2.3.0", + "rc-util": "^5.21.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-drawer": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/rc-drawer/-/rc-drawer-7.3.0.tgz", + "integrity": "sha512-DX6CIgiBWNpJIMGFO8BAISFkxiuKitoizooj4BDyee8/SnBn0zwO2FHrNDpqqepj0E/TFTDpmEBCyFuTgC7MOg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@rc-component/portal": "^1.1.1", + "classnames": "^2.2.6", + "rc-motion": "^2.6.1", + "rc-util": "^5.38.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-dropdown": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/rc-dropdown/-/rc-dropdown-4.2.1.tgz", + "integrity": "sha512-YDAlXsPv3I1n42dv1JpdM7wJ+gSUBfeyPK59ZpBD9jQhK9jVuxpjj3NmWQHOBceA1zEPVX84T2wbdb2SD0UjmA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.2.6", + "rc-util": "^5.44.1" + }, + "peerDependencies": { + "react": ">=16.11.0", + "react-dom": ">=16.11.0" + } + }, + "node_modules/rc-field-form": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rc-field-form/-/rc-field-form-2.7.1.tgz", + "integrity": "sha512-vKeSifSJ6HoLaAB+B8aq/Qgm8a3dyxROzCtKNCsBQgiverpc4kWDQihoUwzUj+zNWJOykwSY4dNX3QrGwtVb9A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0", + "@rc-component/async-validator": "^5.0.3", + "rc-util": "^5.32.2" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-image": { + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/rc-image/-/rc-image-7.12.0.tgz", + "integrity": "sha512-cZ3HTyyckPnNnUb9/DRqduqzLfrQRyi+CdHjdqgsyDpI3Ln5UX1kXnAhPBSJj9pVRzwRFgqkN7p9b6HBDjmu/Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.2", + "@rc-component/portal": "^1.0.2", + "classnames": "^2.2.6", + "rc-dialog": "~9.6.0", + "rc-motion": "^2.6.2", + "rc-util": "^5.34.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-input": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/rc-input/-/rc-input-1.8.0.tgz", + "integrity": "sha512-KXvaTbX+7ha8a/k+eg6SYRVERK0NddX8QX7a7AnRvUa/rEH0CNMlpcBzBkhI0wp2C8C4HlMoYl8TImSN+fuHKA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-util": "^5.18.1" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/rc-input-number": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/rc-input-number/-/rc-input-number-9.5.0.tgz", + "integrity": "sha512-bKaEvB5tHebUURAEXw35LDcnRZLq3x1k7GxfAqBMzmpHkDGzjAtnUL8y4y5N15rIFIg5IJgwr211jInl3cipag==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/mini-decimal": "^1.0.1", + "classnames": "^2.2.5", + "rc-input": "~1.8.0", + "rc-util": "^5.40.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-mentions": { + "version": "2.20.0", + "resolved": "https://registry.npmjs.org/rc-mentions/-/rc-mentions-2.20.0.tgz", + "integrity": "sha512-w8HCMZEh3f0nR8ZEd466ATqmXFCMGMN5UFCzEUL0bM/nGw/wOS2GgRzKBcm19K++jDyuWCOJOdgcKGXU3fXfbQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.22.5", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.2.6", + "rc-input": "~1.8.0", + "rc-menu": "~9.16.0", + "rc-textarea": "~1.10.0", + "rc-util": "^5.34.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-menu": { + "version": "9.16.1", + "resolved": "https://registry.npmjs.org/rc-menu/-/rc-menu-9.16.1.tgz", + "integrity": "sha512-ghHx6/6Dvp+fw8CJhDUHFHDJ84hJE3BXNCzSgLdmNiFErWSOaZNsihDAsKq9ByTALo/xkNIwtDFGIl6r+RPXBg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/trigger": "^2.0.0", + "classnames": "2.x", + "rc-motion": "^2.4.3", + "rc-overflow": "^1.3.1", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-motion": { + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/rc-motion/-/rc-motion-2.9.5.tgz", + "integrity": "sha512-w+XTUrfh7ArbYEd2582uDrEhmBHwK1ZENJiSJVb7uRxdE7qJSYjbO2eksRXmndqyKqKoYPc9ClpPh5242mV1vA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-util": "^5.44.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-notification": { + "version": "5.6.4", + "resolved": "https://registry.npmjs.org/rc-notification/-/rc-notification-5.6.4.tgz", + "integrity": "sha512-KcS4O6B4qzM3KH7lkwOB7ooLPZ4b6J+VMmQgT51VZCeEcmghdeR4IrMcFq0LG+RPdnbe/ArT086tGM8Snimgiw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "rc-motion": "^2.9.0", + "rc-util": "^5.20.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-overflow": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/rc-overflow/-/rc-overflow-1.5.0.tgz", + "integrity": "sha512-Lm/v9h0LymeUYJf0x39OveU52InkdRXqnn2aYXfWmo8WdOonIKB2kfau+GF0fWq6jPgtdO9yMqveGcK6aIhJmg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.37.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-pagination": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/rc-pagination/-/rc-pagination-5.1.0.tgz", + "integrity": "sha512-8416Yip/+eclTFdHXLKTxZvn70duYVGTvUUWbckCCZoIl3jagqke3GLsFrMs0bsQBikiYpZLD9206Ej4SOdOXQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.3.2", + "rc-util": "^5.38.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-picker": { + "version": "4.11.3", + "resolved": "https://registry.npmjs.org/rc-picker/-/rc-picker-4.11.3.tgz", + "integrity": "sha512-MJ5teb7FlNE0NFHTncxXQ62Y5lytq6sh5nUw0iH8OkHL/TjARSEvSHpr940pWgjGANpjCwyMdvsEV55l5tYNSg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.2.1", + "rc-overflow": "^1.3.2", + "rc-resize-observer": "^1.4.0", + "rc-util": "^5.43.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "date-fns": ">= 2.x", + "dayjs": ">= 1.x", + "luxon": ">= 3.x", + "moment": ">= 2.x", + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + }, + "peerDependenciesMeta": { + "date-fns": { + "optional": true + }, + "dayjs": { + "optional": true + }, + "luxon": { + "optional": true + }, + "moment": { + "optional": true + } + } + }, + "node_modules/rc-progress": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/rc-progress/-/rc-progress-4.0.0.tgz", + "integrity": "sha512-oofVMMafOCokIUIBnZLNcOZFsABaUw8PPrf1/y0ZBvKZNpOiu5h4AO9vv11Sw0p4Hb3D0yGWuEattcQGtNJ/aw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.6", + "rc-util": "^5.16.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-rate": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/rc-rate/-/rc-rate-2.13.1.tgz", + "integrity": "sha512-QUhQ9ivQ8Gy7mtMZPAjLbxBt5y9GRp65VcUyGUMF3N3fhiftivPHdpuDIaWIMOTEprAjZPC08bls1dQB+I1F2Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.5", + "rc-util": "^5.0.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-resize-observer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/rc-resize-observer/-/rc-resize-observer-1.4.3.tgz", + "integrity": "sha512-YZLjUbyIWox8E9i9C3Tm7ia+W7euPItNWSPX5sCcQTYbnwDb5uNpnLHQCG1f22oZWUhLw4Mv2tFmeWe68CDQRQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.7", + "classnames": "^2.2.1", + "rc-util": "^5.44.1", + "resize-observer-polyfill": "^1.5.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-segmented": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rc-segmented/-/rc-segmented-2.7.1.tgz", + "integrity": "sha512-izj1Nw/Dw2Vb7EVr+D/E9lUTkBe+kKC+SAFSU9zqr7WV2W5Ktaa9Gc7cB2jTqgk8GROJayltaec+DBlYKc6d+g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-motion": "^2.4.4", + "rc-util": "^5.17.0" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/rc-select": { + "version": "14.16.8", + "resolved": "https://registry.npmjs.org/rc-select/-/rc-select-14.16.8.tgz", + "integrity": "sha512-NOV5BZa1wZrsdkKaiK7LHRuo5ZjZYMDxPP6/1+09+FB4KoNi8jcG1ZqLE3AVCxEsYMBe65OBx71wFoHRTP3LRg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/trigger": "^2.1.1", + "classnames": "2.x", + "rc-motion": "^2.0.1", + "rc-overflow": "^1.3.1", + "rc-util": "^5.16.1", + "rc-virtual-list": "^3.5.2" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/rc-slider": { + "version": "11.1.9", + "resolved": "https://registry.npmjs.org/rc-slider/-/rc-slider-11.1.9.tgz", + "integrity": "sha512-h8IknhzSh3FEM9u8ivkskh+Ef4Yo4JRIY2nj7MrH6GQmrwV6mcpJf5/4KgH5JaVI1H3E52yCdpOlVyGZIeph5A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.5", + "rc-util": "^5.36.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-steps": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/rc-steps/-/rc-steps-6.0.1.tgz", + "integrity": "sha512-lKHL+Sny0SeHkQKKDJlAjV5oZ8DwCdS2hFhAkIjuQt1/pB81M0cA0ErVFdHq9+jmPmFw1vJB2F5NBzFXLJxV+g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.16.7", + "classnames": "^2.2.3", + "rc-util": "^5.16.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-switch": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/rc-switch/-/rc-switch-4.1.0.tgz", + "integrity": "sha512-TI8ufP2Az9oEbvyCeVE4+90PDSljGyuwix3fV58p7HV2o4wBnVToEyomJRVyTaZeqNPAp+vqeo4Wnj5u0ZZQBg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.21.0", + "classnames": "^2.2.1", + "rc-util": "^5.30.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-table": { + "version": "7.54.0", + "resolved": "https://registry.npmjs.org/rc-table/-/rc-table-7.54.0.tgz", + "integrity": "sha512-/wDTkki6wBTjwylwAGjpLKYklKo9YgjZwAU77+7ME5mBoS32Q4nAwoqhA2lSge6fobLW3Tap6uc5xfwaL2p0Sw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/context": "^1.4.0", + "classnames": "^2.2.5", + "rc-resize-observer": "^1.1.0", + "rc-util": "^5.44.3", + "rc-virtual-list": "^3.14.2" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-tabs": { + "version": "15.7.0", + "resolved": "https://registry.npmjs.org/rc-tabs/-/rc-tabs-15.7.0.tgz", + "integrity": "sha512-ZepiE+6fmozYdWf/9gVp7k56PKHB1YYoDsKeQA1CBlJ/POIhjkcYiv0AGP0w2Jhzftd3AVvZP/K+V+Lpi2ankA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.2", + "classnames": "2.x", + "rc-dropdown": "~4.2.0", + "rc-menu": "~9.16.0", + "rc-motion": "^2.6.2", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.34.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-textarea": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/rc-textarea/-/rc-textarea-1.10.2.tgz", + "integrity": "sha512-HfaeXiaSlpiSp0I/pvWpecFEHpVysZ9tpDLNkxQbMvMz6gsr7aVZ7FpWP9kt4t7DB+jJXesYS0us1uPZnlRnwQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.1", + "rc-input": "~1.8.0", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-tooltip": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/rc-tooltip/-/rc-tooltip-6.4.0.tgz", + "integrity": "sha512-kqyivim5cp8I5RkHmpsp1Nn/Wk+1oeloMv9c7LXNgDxUpGm+RbXJGL+OPvDlcRnx9DBeOe4wyOIl4OKUERyH1g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.2", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.3.1", + "rc-util": "^5.44.3" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-tree": { + "version": "5.13.1", + "resolved": "https://registry.npmjs.org/rc-tree/-/rc-tree-5.13.1.tgz", + "integrity": "sha512-FNhIefhftobCdUJshO7M8uZTA9F4OPGVXqGfZkkD/5soDeOhwO06T/aKTrg0WD8gRg/pyfq+ql3aMymLHCTC4A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "rc-motion": "^2.0.1", + "rc-util": "^5.16.1", + "rc-virtual-list": "^3.5.1" + }, + "engines": { + "node": ">=10.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/rc-tree-select": { + "version": "5.27.0", + "resolved": "https://registry.npmjs.org/rc-tree-select/-/rc-tree-select-5.27.0.tgz", + "integrity": "sha512-2qTBTzwIT7LRI1o7zLyrCzmo5tQanmyGbSaGTIf7sYimCklAToVVfpMC6OAldSKolcnjorBYPNSKQqJmN3TCww==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.7", + "classnames": "2.x", + "rc-select": "~14.16.2", + "rc-tree": "~5.13.0", + "rc-util": "^5.43.0" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/rc-upload": { + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/rc-upload/-/rc-upload-4.11.0.tgz", + "integrity": "sha512-ZUyT//2JAehfHzjWowqROcwYJKnZkIUGWaTE/VogVrepSl7AFNbQf4+zGfX4zl9Vrj/Jm8scLO0R6UlPDKK4wA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "classnames": "^2.2.5", + "rc-util": "^5.2.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-util": { + "version": "5.44.4", + "resolved": "https://registry.npmjs.org/rc-util/-/rc-util-5.44.4.tgz", + "integrity": "sha512-resueRJzmHG9Q6rI/DfK6Kdv9/Lfls05vzMs1Sk3M2P+3cJa+MakaZyWY8IPfehVuhPJFKrIY1IK4GqbiaiY5w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "react-is": "^18.2.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-virtual-list": { + "version": "3.19.2", + "resolved": "https://registry.npmjs.org/rc-virtual-list/-/rc-virtual-list-3.19.2.tgz", + "integrity": "sha512-Ys6NcjwGkuwkeaWBDqfI3xWuZ7rDiQXlH1o2zLfFzATfEgXcqpk8CkgMfbJD81McqjcJVez25a3kPxCR807evA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.0", + "classnames": "^2.2.6", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.36.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/scroll-into-view-if-needed": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz", + "integrity": "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==", + "license": "MIT", + "dependencies": { + "compute-scroll-into-view": "^3.0.2" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-convert": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz", + "integrity": "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==", + "license": "MIT" + }, + "node_modules/stylis": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz", + "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==", + "license": "MIT" + }, + "node_modules/throttle-debounce": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz", + "integrity": "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==", + "license": "MIT", + "engines": { + "node": ">=12.22" + } + }, + "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, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/toggle-selection": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz", + "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/portal/frontend/package.json b/portal/frontend/package.json new file mode 100644 index 0000000..0469f02 --- /dev/null +++ b/portal/frontend/package.json @@ -0,0 +1,27 @@ +{ + "name": "tau-acuvim-portal", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@ant-design/icons": "^5.6.1", + "@tanstack/react-query": "^5.62.0", + "antd": "^5.22.0", + "axios": "^1.7.9", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.0" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "~5.6.2", + "vite": "^6.0.0" + } +} diff --git a/portal/frontend/src/App.tsx b/portal/frontend/src/App.tsx new file mode 100644 index 0000000..aff97d5 --- /dev/null +++ b/portal/frontend/src/App.tsx @@ -0,0 +1,64 @@ +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { AuthProvider } from './hooks/useAuth'; +import { BrandingProvider } from './hooks/useBranding'; +import { ThemedRoot } from './components/ThemedRoot'; +import { RequireAuth } from './components/RequireAuth'; +import { RequireRole } from './components/RequireRole'; +import { AppLayout } from './components/layout/AppLayout'; +import { LoginPage } from './pages/LoginPage'; +import { DashboardPage } from './pages/DashboardPage'; +import { DashboardsPage } from './pages/DashboardsPage'; +import { AdminSitesPage } from './pages/AdminSitesPage'; +import { SettingsPage } from './pages/SettingsPage'; + +const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } }, +}); + +export default function App() { + return ( + + + + + + + } /> + + + + } + > + } /> + } /> + + + + } + /> + + + + } + /> + } /> + + } /> + + + + + + + ); +} diff --git a/portal/frontend/src/api/adminConfig.ts b/portal/frontend/src/api/adminConfig.ts new file mode 100644 index 0000000..b8eb3af --- /dev/null +++ b/portal/frontend/src/api/adminConfig.ts @@ -0,0 +1,31 @@ +import { api } from './client'; + +export interface ConfigOverview { + application: { name: string; environment: string; publicUrl: string }; + database: { + provider: string; + host: string; + port: number; + database: string; + migrateOnStartup: boolean; + autoProvisionLocalTimescaleDb: boolean; + resolvedVia: string; + }; + grafana: { + baseUrl: string; + internalUrl: string; + embedPathPrefix: string; + embedMode: string; + defaultDashboardUid: string; + authMode: string; + dashboardCount: number; + }; + monitoring: { chunkTimeInterval: string; enableHourlyAggregates: boolean }; + authentication: { cookieName: string; requireConfirmedEmail: boolean; defaultAdminEmail: string }; + build: { assemblyVersion: string; framework: string; startedAtUtc: string }; +} + +export async function fetchConfigOverview(): Promise { + const { data } = await api.get('/admin/config-overview'); + return data; +} diff --git a/portal/frontend/src/api/auth.ts b/portal/frontend/src/api/auth.ts new file mode 100644 index 0000000..c05d157 --- /dev/null +++ b/portal/frontend/src/api/auth.ts @@ -0,0 +1,34 @@ +import { api } from './client'; + +export interface CurrentUser { + email: string; + displayName: string; + roles: string[]; +} + +export async function login(email: string, password: string): Promise { + const { data } = await api.post('/auth/login', { email, password }); + return data; +} + +export async function logout(): Promise { + await api.post('/auth/logout'); +} + +export async function fetchCurrentUser(): Promise { + try { + const { data } = await api.get('/auth/me'); + return data; + } catch (err: unknown) { + if (axiosStatus(err) === 401) return null; + throw err; + } +} + +function axiosStatus(err: unknown): number | undefined { + if (typeof err === 'object' && err !== null && 'response' in err) { + const resp = (err as { response?: { status?: number } }).response; + return resp?.status; + } + return undefined; +} diff --git a/portal/frontend/src/api/branding.ts b/portal/frontend/src/api/branding.ts new file mode 100644 index 0000000..88bf8fe --- /dev/null +++ b/portal/frontend/src/api/branding.ts @@ -0,0 +1,38 @@ +import { api } from './client'; + +export interface Branding { + applicationName: string; + logoUrl: string; + primaryColor: string; + secondaryColor: string; + accentColor: string; + footerText: string; +} + +export interface UpdateBrandingPayload { + applicationName: string; + primaryColor: string; + secondaryColor: string; + accentColor: string; + footerText: string; + logoUrl?: string; +} + +export async function fetchBranding(): Promise { + const { data } = await api.get('/branding'); + return data; +} + +export async function updateBranding(payload: UpdateBrandingPayload): Promise { + const { data } = await api.put('/branding/', payload); + return data; +} + +export async function uploadBrandingLogo(file: File): Promise<{ logoUrl: string }> { + const form = new FormData(); + form.append('file', file); + const { data } = await api.post<{ logoUrl: string }>('/branding/logo', form, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + return data; +} diff --git a/portal/frontend/src/api/client.ts b/portal/frontend/src/api/client.ts new file mode 100644 index 0000000..adf1d02 --- /dev/null +++ b/portal/frontend/src/api/client.ts @@ -0,0 +1,7 @@ +import axios from 'axios'; + +export const api = axios.create({ + baseURL: '/api', + withCredentials: true, + headers: { 'Content-Type': 'application/json' }, +}); diff --git a/portal/frontend/src/api/grafana.ts b/portal/frontend/src/api/grafana.ts new file mode 100644 index 0000000..64bcbc6 --- /dev/null +++ b/portal/frontend/src/api/grafana.ts @@ -0,0 +1,18 @@ +import { api } from './client'; + +export interface GrafanaDashboard { + uid: string; + title: string; + description: string; +} + +export interface GrafanaConfig { + baseUrl: string; + defaultDashboardUid: string; + dashboards: GrafanaDashboard[]; +} + +export async function fetchGrafanaConfig(): Promise { + const { data } = await api.get('/grafana/config'); + return data; +} diff --git a/portal/frontend/src/api/rates.ts b/portal/frontend/src/api/rates.ts new file mode 100644 index 0000000..a5342d3 --- /dev/null +++ b/portal/frontend/src/api/rates.ts @@ -0,0 +1,104 @@ +import { api } from './client'; + +export interface Municipality { + id: number; + name: string; + timeZoneId: string | null; + isActive: boolean; + tariffCount: number; +} + +export interface TariffPeriod { + name: string; + daysOfWeek: number; + startTime: string; + endTime: string; + ratePerKwh: number; +} + +export interface TariffSummary { + id: number; + name: string; + effectiveFrom: string; + effectiveTo: string | null; + defaultRatePerKwh: number; + fixedMonthlyCharge: number; + vatPercentage: number; + isActive: boolean; + periodCount: number; +} + +export interface TariffDetail { + id: number; + municipalityId: number; + name: string; + effectiveFrom: string; + effectiveTo: string | null; + defaultRatePerKwh: number; + fixedMonthlyCharge: number; + vatPercentage: number; + isActive: boolean; + periods: TariffPeriod[]; +} + +export interface UpsertMunicipality { + name: string; + timeZoneId: string | null; + isActive: boolean; +} + +export interface UpsertTariff { + name: string; + effectiveFrom: string; + effectiveTo: string | null; + defaultRatePerKwh: number; + fixedMonthlyCharge: number; + vatPercentage: number; + isActive: boolean; + periods: TariffPeriod[]; +} + +export async function listMunicipalities(): Promise { + const { data } = await api.get('/rates/municipalities'); + return data; +} + +export async function createMunicipality(payload: UpsertMunicipality): Promise { + const { data } = await api.post('/admin/rates/municipalities', payload); + return data; +} + +export async function updateMunicipality(id: number, payload: UpsertMunicipality): Promise { + await api.put(`/admin/rates/municipalities/${id}`, payload); +} + +export async function deleteMunicipality(id: number): Promise { + await api.delete(`/admin/rates/municipalities/${id}`); +} + +export async function listTariffs(municipalityId: number): Promise { + const { data } = await api.get(`/rates/municipalities/${municipalityId}/tariffs`); + return data; +} + +export async function getTariff(tariffId: number): Promise { + const { data } = await api.get(`/rates/tariffs/${tariffId}`); + return data; +} + +export async function createTariff(municipalityId: number, payload: UpsertTariff): Promise { + const { data } = await api.post( + `/admin/rates/municipalities/${municipalityId}/tariffs`, + payload, + ); + return data; +} + +export async function updateTariff(tariffId: number, payload: UpsertTariff): Promise { + const { data } = await api.put(`/admin/rates/tariffs/${tariffId}`, payload); + return data; +} + +export async function deleteTariff(tariffId: number): Promise { + await api.delete(`/admin/rates/tariffs/${tariffId}`); +} diff --git a/portal/frontend/src/api/sites.ts b/portal/frontend/src/api/sites.ts new file mode 100644 index 0000000..3733819 --- /dev/null +++ b/portal/frontend/src/api/sites.ts @@ -0,0 +1,69 @@ +import { api } from './client'; + +export interface Site { + id: string; + name: string; + address: string | null; + municipalityId: number | null; + isActive: boolean; + deviceCount: number; +} + +export interface Device { + id: string; + siteId: string; + name: string; + externalId: string; + description: string | null; + isActive: boolean; +} + +export interface UpsertSite { + name: string; + address: string | null; + municipalityId: number | null; + isActive: boolean; +} + +export interface UpsertDevice { + name: string; + externalId: string; + description: string | null; + isActive: boolean; +} + +export async function listSites(): Promise { + const { data } = await api.get('/sites/'); + return data; +} + +export async function listSiteDevices(siteId: string): Promise { + const { data } = await api.get(`/sites/${siteId}/devices`); + return data; +} + +export async function createSite(payload: UpsertSite): Promise { + const { data } = await api.post('/admin/sites/', payload); + return data; +} + +export async function updateSite(id: string, payload: UpsertSite): Promise { + await api.put(`/admin/sites/${id}`, payload); +} + +export async function deleteSite(id: string): Promise { + await api.delete(`/admin/sites/${id}`); +} + +export async function createDevice(siteId: string, payload: UpsertDevice): Promise { + const { data } = await api.post(`/admin/sites/${siteId}/devices`, payload); + return data; +} + +export async function updateDevice(deviceId: string, payload: UpsertDevice): Promise { + await api.put(`/admin/sites/devices/${deviceId}`, payload); +} + +export async function deleteDevice(deviceId: string): Promise { + await api.delete(`/admin/sites/devices/${deviceId}`); +} diff --git a/portal/frontend/src/api/users.ts b/portal/frontend/src/api/users.ts new file mode 100644 index 0000000..ddd72bd --- /dev/null +++ b/portal/frontend/src/api/users.ts @@ -0,0 +1,45 @@ +import { api } from './client'; + +export interface UserListItem { + id: string; + email: string; + displayName: string; + isActive: boolean; + createdAt: string; + roles: string[]; +} + +export interface CreateUserPayload { + email: string; + displayName: string; + password: string; + isAdmin: boolean; +} + +export interface UpdateUserPayload { + displayName: string; + isActive: boolean; + isAdmin: boolean; +} + +export async function listUsers(): Promise { + const { data } = await api.get('/admin/users/'); + return data; +} + +export async function createUser(payload: CreateUserPayload): Promise { + const { data } = await api.post('/admin/users/', payload); + return data; +} + +export async function updateUser(id: string, payload: UpdateUserPayload): Promise { + await api.put(`/admin/users/${id}`, payload); +} + +export async function resetUserPassword(id: string, newPassword: string): Promise { + await api.post(`/admin/users/${id}/reset-password`, { newPassword }); +} + +export async function deleteUser(id: string): Promise { + await api.delete(`/admin/users/${id}`); +} diff --git a/portal/frontend/src/components/RequireAuth.tsx b/portal/frontend/src/components/RequireAuth.tsx new file mode 100644 index 0000000..ed0ae31 --- /dev/null +++ b/portal/frontend/src/components/RequireAuth.tsx @@ -0,0 +1,23 @@ +import { Navigate, useLocation } from 'react-router-dom'; +import type { ReactNode } from 'react'; +import { Spin } from 'antd'; +import { useAuth } from '../hooks/useAuth'; + +export function RequireAuth({ children }: { children: ReactNode }) { + const { user, loading } = useAuth(); + const location = useLocation(); + + if (loading) { + return ( +
+ +
+ ); + } + + if (!user) { + return ; + } + + return <>{children}; +} diff --git a/portal/frontend/src/components/RequireRole.tsx b/portal/frontend/src/components/RequireRole.tsx new file mode 100644 index 0000000..d610707 --- /dev/null +++ b/portal/frontend/src/components/RequireRole.tsx @@ -0,0 +1,19 @@ +import type { ReactNode } from 'react'; +import { Result } from 'antd'; +import { useAuth } from '../hooks/useAuth'; + +export function RequireRole({ role, children }: { role: string; children: ReactNode }) { + const { user } = useAuth(); + + if (!user || !user.roles.includes(role)) { + return ( + + ); + } + + return <>{children}; +} diff --git a/portal/frontend/src/components/ThemedRoot.tsx b/portal/frontend/src/components/ThemedRoot.tsx new file mode 100644 index 0000000..8e08221 --- /dev/null +++ b/portal/frontend/src/components/ThemedRoot.tsx @@ -0,0 +1,19 @@ +import type { ReactNode } from 'react'; +import { ConfigProvider } from 'antd'; +import { useBranding } from '../hooks/useBranding'; + +export function ThemedRoot({ children }: { children: ReactNode }) { + const { branding } = useBranding(); + return ( + + {children} + + ); +} diff --git a/portal/frontend/src/components/layout/AppLayout.tsx b/portal/frontend/src/components/layout/AppLayout.tsx new file mode 100644 index 0000000..bdffa4a --- /dev/null +++ b/portal/frontend/src/components/layout/AppLayout.tsx @@ -0,0 +1,89 @@ +import { Layout, Menu, Button, Typography, Space } from 'antd'; +import { + DashboardOutlined, SettingOutlined, LogoutOutlined, + LineChartOutlined, ApartmentOutlined, +} from '@ant-design/icons'; +import { Outlet, useLocation, useNavigate } from 'react-router-dom'; +import { useAuth } from '../../hooks/useAuth'; +import { useBranding } from '../../hooks/useBranding'; + +const { Header, Sider, Content, Footer } = Layout; +const { Text } = Typography; + +export function AppLayout() { + const { user, logout } = useAuth(); + const { branding } = useBranding(); + const navigate = useNavigate(); + const location = useLocation(); + const isAdmin = user?.roles.includes('Admin') ?? false; + + const handleLogout = async () => { + await logout(); + navigate('/login', { replace: true }); + }; + + const items = [ + { key: '/', icon: , label: 'Dashboard' }, + { key: '/dashboards', icon: , label: 'Dashboards' }, + ...(isAdmin + ? [ + { key: '/admin/sites', icon: , label: 'Sites' }, + { key: '/settings', icon: , label: 'Settings' }, + ] + : []), + ]; + + return ( + +
+ + {branding.logoUrl && ( + + )} + + {branding.applicationName} + + + + {user?.displayName ?? user?.email} + + +
+ + + navigate(e.key)} + style={{ height: '100%', borderRight: 0 }} + items={items} + /> + + + + + + {branding.footerText && ( +
+ {branding.footerText} +
+ )} +
+ + + ); +} diff --git a/portal/frontend/src/components/settings/BrandingForm.tsx b/portal/frontend/src/components/settings/BrandingForm.tsx new file mode 100644 index 0000000..25ed69b --- /dev/null +++ b/portal/frontend/src/components/settings/BrandingForm.tsx @@ -0,0 +1,192 @@ +import { useEffect, useState } from 'react'; +import { Form, Input, Button, Row, Col, Card, Upload, ColorPicker, Space, message, Typography } from 'antd'; +import type { UploadFile } from 'antd/es/upload/interface'; +import { UploadOutlined, SaveOutlined } from '@ant-design/icons'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + fetchBranding, + updateBranding, + uploadBrandingLogo, + type Branding, + type UpdateBrandingPayload, +} from '../../api/branding'; + +const { Text } = Typography; + +interface FormShape { + applicationName: string; + primaryColor: string; + secondaryColor: string; + accentColor: string; + footerText: string; +} + +export function BrandingForm() { + const qc = useQueryClient(); + const [form] = Form.useForm(); + const [preview, setPreview] = useState(null); + + const { data: branding, isLoading } = useQuery({ queryKey: ['branding'], queryFn: fetchBranding }); + + useEffect(() => { + if (!branding) return; + form.setFieldsValue({ + applicationName: branding.applicationName, + primaryColor: branding.primaryColor, + secondaryColor: branding.secondaryColor, + accentColor: branding.accentColor, + footerText: branding.footerText, + }); + setPreview(branding); + }, [branding, form]); + + const saveMut = useMutation({ + mutationFn: (payload: UpdateBrandingPayload) => updateBranding(payload), + onSuccess: (updated) => { + qc.setQueryData(['branding'], updated); + message.success('Branding saved'); + }, + onError: () => message.error('Save failed'), + }); + + const uploadMut = useMutation({ + mutationFn: (file: File) => uploadBrandingLogo(file), + onSuccess: ({ logoUrl }) => { + qc.invalidateQueries({ queryKey: ['branding'] }); + setPreview((p) => (p ? { ...p, logoUrl } : p)); + message.success('Logo uploaded'); + }, + onError: () => message.error('Logo upload failed'), + }); + + const handleValuesChange = (_: Partial, all: FormShape) => { + setPreview((p) => (p ? { ...p, ...all } : p)); + }; + + const handleSave = (values: FormShape) => { + saveMut.mutate({ + applicationName: values.applicationName, + primaryColor: values.primaryColor, + secondaryColor: values.secondaryColor, + accentColor: values.accentColor, + footerText: values.footerText, + }); + }; + + const beforeUpload = (file: UploadFile) => { + if (file instanceof File) uploadMut.mutate(file); + else if ('originFileObj' in file && file.originFileObj) uploadMut.mutate(file.originFileObj as File); + return false; + }; + + return ( + + + + form={form} + layout="vertical" + onFinish={handleSave} + onValuesChange={handleValuesChange} + disabled={isLoading} + > + + + + + + + {preview?.logoUrl && ( + Current logo + )} + + + + PNG / JPG / SVG / WEBP, max 2 MB. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ {preview?.logoUrl && ( + + )} + {preview?.applicationName || 'Application name'} +
+
+ Sidebar / secondary surface +
+ + {preview?.footerText && ( +
{preview.footerText}
+ )} +
+ +
+ ); +} + +function hexFromPicker(value: unknown): string { + if (typeof value === 'string') return value; + if (value && typeof value === 'object' && 'toHexString' in value) { + return (value as { toHexString: () => string }).toHexString(); + } + return ''; +} diff --git a/portal/frontend/src/components/settings/ConfigOverviewCard.tsx b/portal/frontend/src/components/settings/ConfigOverviewCard.tsx new file mode 100644 index 0000000..ed389d5 --- /dev/null +++ b/portal/frontend/src/components/settings/ConfigOverviewCard.tsx @@ -0,0 +1,85 @@ +import { Card, Descriptions, Alert, Typography, Tag } from 'antd'; +import { useQuery } from '@tanstack/react-query'; +import { fetchConfigOverview } from '../../api/adminConfig'; + +const { Text } = Typography; + +export function ConfigOverviewCard() { + const { data, isLoading, error } = useQuery({ + queryKey: ['admin-config-overview'], + queryFn: fetchConfigOverview, + }); + + if (error) return ; + + return ( + + + + {data && ( + <> + + {data.application.name} + + + {data.application.environment} + + + {data.application.publicUrl} + + + + {data.database.provider} + + {data.database.resolvedVia} + + {data.database.host} + {data.database.port} + {data.database.database} + + {data.database.migrateOnStartup ? Yes : No} + + + {data.database.autoProvisionLocalTimescaleDb ? Yes : No} + + + + + {data.grafana.baseUrl} + {data.grafana.internalUrl} + {data.grafana.embedPathPrefix} + {data.grafana.embedMode} + {data.grafana.authMode} + {data.grafana.defaultDashboardUid || '(unset)'} + {data.grafana.dashboardCount} + + + + {data.monitoring.chunkTimeInterval} + + {data.monitoring.enableHourlyAggregates ? Flag set (not implemented) : 'No'} + + + + + {data.authentication.cookieName} + {data.authentication.requireConfirmedEmail ? 'Yes' : 'No'} + {data.authentication.defaultAdminEmail} + + + + {data.build.assemblyVersion} + {data.build.framework} + {new Date(data.build.startedAtUtc).toLocaleString()} + + + )} + + ); +} diff --git a/portal/frontend/src/components/settings/GrafanaInfoCard.tsx b/portal/frontend/src/components/settings/GrafanaInfoCard.tsx new file mode 100644 index 0000000..5a2f32f --- /dev/null +++ b/portal/frontend/src/components/settings/GrafanaInfoCard.tsx @@ -0,0 +1,81 @@ +import { Card, Descriptions, Table, Typography, Button, Space, message } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { CopyOutlined, LinkOutlined } from '@ant-design/icons'; +import { useQuery } from '@tanstack/react-query'; +import { fetchGrafanaConfig, type GrafanaDashboard } from '../../api/grafana'; + +const { Text, Paragraph } = Typography; + +interface DashboardRow extends GrafanaDashboard { + url: string; +} + +export function GrafanaInfoCard() { + const { data, isLoading } = useQuery({ queryKey: ['grafana-config'], queryFn: fetchGrafanaConfig }); + + const baseUrl = data?.baseUrl?.replace(/\/$/, '') ?? ''; + const rows: DashboardRow[] = (data?.dashboards ?? []).map((d) => ({ + ...d, + url: baseUrl ? `${baseUrl}/d/${encodeURIComponent(d.uid)}?orgId=1&kiosk=tv&theme=light` : '', + })); + + const copy = async (text: string) => { + try { + await navigator.clipboard.writeText(text); + message.success('Copied'); + } catch { + message.error('Copy failed'); + } + }; + + const columns: ColumnsType = [ + { title: 'Title', dataIndex: 'title', key: 'title' }, + { title: 'UID', dataIndex: 'uid', key: 'uid' }, + { title: 'Description', dataIndex: 'description', key: 'desc' }, + { + title: 'Embed URL', + dataIndex: 'url', + key: 'url', + render: (url: string) => ( + + {url} + + )} + + + + {data?.defaultDashboardUid || '(unset)'} + + {data?.dashboards.length ?? 0} + + + + Add dashboards by dropping JSON into grafana/dashboards/ and adding a matching entry + to Grafana.Dashboards in configuration. + + + + rowKey="uid" + size="small" + columns={columns} + dataSource={rows} + pagination={false} + /> + + ); +} diff --git a/portal/frontend/src/components/settings/rates/MunicipalityList.tsx b/portal/frontend/src/components/settings/rates/MunicipalityList.tsx new file mode 100644 index 0000000..ebce8a5 --- /dev/null +++ b/portal/frontend/src/components/settings/rates/MunicipalityList.tsx @@ -0,0 +1,245 @@ +import { useState } from 'react'; +import { + Table, Button, Space, Modal, Input, Switch, Tag, Popconfirm, Form, Typography, message, Card, +} from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + listMunicipalities, createMunicipality, updateMunicipality, deleteMunicipality, + listTariffs, getTariff, deleteTariff, + type Municipality, type TariffSummary, type UpsertMunicipality, type TariffDetail, +} from '../../../api/rates'; +import { TariffDrawer } from './TariffDrawer'; + +const { Text } = Typography; + +interface MuniFormShape { + name: string; + timeZoneId: string; + isActive: boolean; +} + +type DrawerMode = + | { kind: 'create'; municipalityId: number } + | { kind: 'edit'; tariff: TariffDetail }; + +export function MunicipalityList() { + const qc = useQueryClient(); + const [muniModal, setMuniModal] = useState<{ open: boolean; editing: Municipality | null }>({ + open: false, + editing: null, + }); + const [muniForm] = Form.useForm(); + const [expandedKeys, setExpandedKeys] = useState([]); + const [drawerMode, setDrawerMode] = useState(null); + + const { data: munis = [], isLoading } = useQuery({ + queryKey: ['municipalities'], + queryFn: listMunicipalities, + }); + + const createMut = useMutation({ + mutationFn: (payload: UpsertMunicipality) => createMunicipality(payload), + onSuccess: () => { message.success('Created'); closeMuni(); qc.invalidateQueries({ queryKey: ['municipalities'] }); }, + onError: () => message.error('Create failed'), + }); + const updateMut = useMutation({ + mutationFn: ({ id, payload }: { id: number; payload: UpsertMunicipality }) => updateMunicipality(id, payload), + onSuccess: () => { message.success('Updated'); closeMuni(); qc.invalidateQueries({ queryKey: ['municipalities'] }); }, + onError: () => message.error('Update failed'), + }); + const deleteMut = useMutation({ + mutationFn: (id: number) => deleteMunicipality(id), + onSuccess: () => { message.success('Deleted'); qc.invalidateQueries({ queryKey: ['municipalities'] }); }, + onError: () => message.error('Delete failed (any tariffs are removed with it)'), + }); + const deleteTariffMut = useMutation({ + mutationFn: ({ municipalityId, tariffId }: { municipalityId: number; tariffId: number }) => + deleteTariff(tariffId).then(() => municipalityId), + onSuccess: (municipalityId) => { + message.success('Tariff deleted'); + qc.invalidateQueries({ queryKey: ['tariffs', municipalityId] }); + qc.invalidateQueries({ queryKey: ['municipalities'] }); + }, + onError: () => message.error('Delete failed'), + }); + + const openCreateMuni = () => { + muniForm.resetFields(); + muniForm.setFieldsValue({ name: '', timeZoneId: '', isActive: true }); + setMuniModal({ open: true, editing: null }); + }; + const openEditMuni = (m: Municipality) => { + muniForm.setFieldsValue({ name: m.name, timeZoneId: m.timeZoneId ?? '', isActive: m.isActive }); + setMuniModal({ open: true, editing: m }); + }; + const closeMuni = () => setMuniModal({ open: false, editing: null }); + + const onMuniSubmit = (values: MuniFormShape) => { + const payload: UpsertMunicipality = { + name: values.name.trim(), + timeZoneId: values.timeZoneId.trim() || null, + isActive: values.isActive, + }; + if (muniModal.editing) updateMut.mutate({ id: muniModal.editing.id, payload }); + else createMut.mutate(payload); + }; + + const openEditTariff = async (tariffId: number) => { + const detail = await getTariff(tariffId); + setDrawerMode({ kind: 'edit', tariff: detail }); + }; + + const columns: ColumnsType = [ + { title: 'Name', dataIndex: 'name', key: 'name' }, + { title: 'Time zone', dataIndex: 'timeZoneId', key: 'tz', render: (v: string | null) => v ?? UTC }, + { title: 'Tariffs', dataIndex: 'tariffCount', key: 'count' }, + { + title: 'Active', + dataIndex: 'isActive', + key: 'isActive', + render: (v: boolean) => (v ? Active : Disabled), + }, + { + title: 'Actions', + key: 'actions', + render: (_, m) => ( + + + deleteMut.mutate(m.id)} + > + + + + ), + }, + ]; + + return ( + <> + + Configure municipalities and their tariffs. Expand a row to see tariffs. + + + + + rowKey="id" + columns={columns} + dataSource={munis} + loading={isLoading} + pagination={false} + expandable={{ + expandedRowKeys: expandedKeys, + onExpandedRowsChange: (keys) => setExpandedKeys(keys as number[]), + expandedRowRender: (m) => ( + setDrawerMode({ kind: 'create', municipalityId: m.id })} + onEdit={openEditTariff} + onDelete={(tariffId) => deleteTariffMut.mutate({ municipalityId: m.id, tariffId })} + /> + ), + }} + /> + + muniForm.submit()} + confirmLoading={createMut.isPending || updateMut.isPending} + > + form={muniForm} layout="vertical" onFinish={onMuniSubmit} requiredMark={false}> + + + + + + + + + + + + + setDrawerMode(null)} + /> + + ); +} + +function TariffSubTable({ + municipality, + onCreate, + onEdit, + onDelete, +}: { + municipality: Municipality; + onCreate: () => void; + onEdit: (id: number) => void; + onDelete: (id: number) => void; +}) { + const { data: tariffs = [], isLoading } = useQuery({ + queryKey: ['tariffs', municipality.id], + queryFn: () => listTariffs(municipality.id), + }); + + const columns: ColumnsType = [ + { title: 'Name', dataIndex: 'name', key: 'name' }, + { title: 'Effective', key: 'effective', render: (_, t) => `${t.effectiveFrom}${t.effectiveTo ? ' → ' + t.effectiveTo : ' →'}` }, + { title: 'Default rate', dataIndex: 'defaultRatePerKwh', key: 'rate', render: (v: number) => v.toFixed(4) }, + { title: 'Fixed', dataIndex: 'fixedMonthlyCharge', key: 'fixed', render: (v: number) => v.toFixed(2) }, + { title: 'VAT %', dataIndex: 'vatPercentage', key: 'vat', render: (v: number) => v.toFixed(2) }, + { title: 'Periods', dataIndex: 'periodCount', key: 'periods' }, + { + title: 'Active', + dataIndex: 'isActive', + render: (v: boolean) => (v ? Active : Inactive), + }, + { + title: 'Actions', + key: 'actions', + render: (_, t) => ( + + + onDelete(t.id)} + > + + + + ), + }, + ]; + + return ( + + + + + + rowKey="id" + size="small" + columns={columns} + dataSource={tariffs} + loading={isLoading} + pagination={false} + /> + + ); +} diff --git a/portal/frontend/src/components/settings/rates/PeriodEditor.tsx b/portal/frontend/src/components/settings/rates/PeriodEditor.tsx new file mode 100644 index 0000000..5d0a0ae --- /dev/null +++ b/portal/frontend/src/components/settings/rates/PeriodEditor.tsx @@ -0,0 +1,119 @@ +import { Button, Input, InputNumber, Space, TimePicker, Typography, Tooltip } from 'antd'; +import { DeleteOutlined, PlusOutlined } from '@ant-design/icons'; +import dayjs from 'dayjs'; +import type { TariffPeriod } from '../../../api/rates'; + +const { Text } = Typography; + +const DAY_OPTIONS: Array<{ label: string; value: number }> = [ + { label: 'M', value: 1 }, + { label: 'T', value: 2 }, + { label: 'W', value: 4 }, + { label: 'T', value: 8 }, + { label: 'F', value: 16 }, + { label: 'S', value: 32 }, + { label: 'S', value: 64 }, +]; + +interface Props { + value: TariffPeriod[]; + onChange: (next: TariffPeriod[]) => void; +} + +export function PeriodEditor({ value, onChange }: Props) { + const update = (idx: number, patch: Partial) => { + const next = value.map((p, i) => (i === idx ? { ...p, ...patch } : p)); + onChange(next); + }; + + const remove = (idx: number) => onChange(value.filter((_, i) => i !== idx)); + + const add = () => + onChange([ + ...value, + { name: 'New period', daysOfWeek: 31, startTime: '08:00', endTime: '17:00', ratePerKwh: 0 }, + ]); + + const toggleDay = (idx: number, dayValue: number) => { + const current = value[idx].daysOfWeek; + const next = (current & dayValue) !== 0 ? current & ~dayValue : current | dayValue; + update(idx, { daysOfWeek: next }); + }; + + return ( +
+ + {value.length === 0 && ( + No periods. The tariff's default rate applies to all time. + )} + {value.map((p, idx) => ( +
+ update(idx, { name: e.target.value })} + /> + + {DAY_OPTIONS.map((d, dIdx) => { + const active = (p.daysOfWeek & d.value) !== 0; + return ( + + + + ); + })} + + v && update(idx, { startTime: v.format('HH:mm') })} + /> + v && update(idx, { endTime: v.format('HH:mm') })} + /> + update(idx, { ratePerKwh: Number(v ?? 0) })} + addonAfter="/kWh" + /> +
+ ))} + + + Times are local to the municipality's time zone. No midnight wrap — to model 22:00–06:00, + add two rows (22:00–23:59 and 00:00–06:00). + +
+
+ ); +} diff --git a/portal/frontend/src/components/settings/rates/TariffDrawer.tsx b/portal/frontend/src/components/settings/rates/TariffDrawer.tsx new file mode 100644 index 0000000..80136b0 --- /dev/null +++ b/portal/frontend/src/components/settings/rates/TariffDrawer.tsx @@ -0,0 +1,181 @@ +import { useEffect, useState } from 'react'; +import { + Drawer, Form, Input, InputNumber, Switch, DatePicker, Row, Col, Button, Space, Alert, Typography, +} from 'antd'; +import dayjs from 'dayjs'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { createTariff, updateTariff, type TariffDetail, type TariffPeriod, type UpsertTariff } from '../../../api/rates'; +import { PeriodEditor } from './PeriodEditor'; + +const { Title } = Typography; + +type Mode = + | { kind: 'create'; municipalityId: number } + | { kind: 'edit'; tariff: TariffDetail }; + +interface Props { + open: boolean; + mode: Mode | null; + onClose: () => void; +} + +interface FormShape { + name: string; + effectiveFrom: dayjs.Dayjs; + effectiveTo: dayjs.Dayjs | null; + defaultRatePerKwh: number; + fixedMonthlyCharge: number; + vatPercentage: number; + isActive: boolean; +} + +export function TariffDrawer({ open, mode, onClose }: Props) { + const qc = useQueryClient(); + const [form] = Form.useForm(); + const [periods, setPeriods] = useState([]); + const [error, setError] = useState(null); + + useEffect(() => { + if (!open || !mode) return; + setError(null); + if (mode.kind === 'edit') { + form.setFieldsValue({ + name: mode.tariff.name, + effectiveFrom: dayjs(mode.tariff.effectiveFrom), + effectiveTo: mode.tariff.effectiveTo ? dayjs(mode.tariff.effectiveTo) : null, + defaultRatePerKwh: mode.tariff.defaultRatePerKwh, + fixedMonthlyCharge: mode.tariff.fixedMonthlyCharge, + vatPercentage: mode.tariff.vatPercentage, + isActive: mode.tariff.isActive, + }); + setPeriods(mode.tariff.periods); + } else { + form.resetFields(); + form.setFieldsValue({ + effectiveFrom: dayjs(), + defaultRatePerKwh: 0, + fixedMonthlyCharge: 0, + vatPercentage: 15, + isActive: true, + }); + setPeriods([]); + } + }, [open, mode, form]); + + const muniId = mode?.kind === 'create' ? mode.municipalityId : mode?.tariff.municipalityId; + + const saveMut = useMutation({ + mutationFn: (payload: UpsertTariff) => + mode?.kind === 'edit' + ? updateTariff(mode.tariff.id, payload) + : createTariff((mode as { municipalityId: number }).municipalityId, payload), + onSuccess: () => { + if (muniId !== undefined) qc.invalidateQueries({ queryKey: ['tariffs', muniId] }); + qc.invalidateQueries({ queryKey: ['municipalities'] }); + onClose(); + }, + onError: (err: unknown) => setError(extractError(err)), + }); + + const handleSubmit = (values: FormShape) => { + setError(null); + saveMut.mutate({ + name: values.name, + effectiveFrom: values.effectiveFrom.format('YYYY-MM-DD'), + effectiveTo: values.effectiveTo ? values.effectiveTo.format('YYYY-MM-DD') : null, + defaultRatePerKwh: values.defaultRatePerKwh, + fixedMonthlyCharge: values.fixedMonthlyCharge, + vatPercentage: values.vatPercentage, + isActive: values.isActive, + periods, + }); + }; + + return ( + + + + + } + > + {error && } + form={form} layout="vertical" onFinish={handleSubmit} requiredMark={false}> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Time-of-use periods + + + + ); +} + +function extractError(err: unknown): string { + if (typeof err === 'object' && err !== null && 'response' in err) { + const data = (err as { response?: { data?: { error?: string } } }).response?.data; + if (data?.error) return data.error; + } + return 'Save failed.'; +} diff --git a/portal/frontend/src/components/sites/DeviceFormModal.tsx b/portal/frontend/src/components/sites/DeviceFormModal.tsx new file mode 100644 index 0000000..d6d4595 --- /dev/null +++ b/portal/frontend/src/components/sites/DeviceFormModal.tsx @@ -0,0 +1,76 @@ +import { useEffect } from 'react'; +import { Modal, Form, Input, Switch } from 'antd'; +import type { Device, UpsertDevice } from '../../api/sites'; + +interface Props { + open: boolean; + editing: Device | null; + submitting: boolean; + onClose: () => void; + onSubmit: (payload: UpsertDevice) => void; +} + +interface FormShape { + name: string; + externalId: string; + description?: string; + isActive: boolean; +} + +export function DeviceFormModal({ open, editing, submitting, onClose, onSubmit }: Props) { + const [form] = Form.useForm(); + + useEffect(() => { + if (!open) return; + if (editing) { + form.setFieldsValue({ + name: editing.name, + externalId: editing.externalId, + description: editing.description ?? undefined, + isActive: editing.isActive, + }); + } else { + form.resetFields(); + form.setFieldsValue({ isActive: true }); + } + }, [open, editing, form]); + + const handleFinish = (values: FormShape) => { + onSubmit({ + name: values.name.trim(), + externalId: values.externalId.trim(), + description: values.description?.trim() || null, + isActive: values.isActive, + }); + }; + + return ( + form.submit()} + confirmLoading={submitting} + > + form={form} layout="vertical" onFinish={handleFinish} requiredMark={false}> + + + + + + + + + + + + + + + ); +} diff --git a/portal/frontend/src/components/sites/SiteFormModal.tsx b/portal/frontend/src/components/sites/SiteFormModal.tsx new file mode 100644 index 0000000..66b02e7 --- /dev/null +++ b/portal/frontend/src/components/sites/SiteFormModal.tsx @@ -0,0 +1,82 @@ +import { useEffect } from 'react'; +import { Modal, Form, Input, Switch, Select } from 'antd'; +import { useQuery } from '@tanstack/react-query'; +import { listMunicipalities } from '../../api/rates'; +import type { Site, UpsertSite } from '../../api/sites'; + +interface Props { + open: boolean; + editing: Site | null; + submitting: boolean; + onClose: () => void; + onSubmit: (payload: UpsertSite) => void; +} + +interface FormShape { + name: string; + address?: string; + municipalityId?: number; + isActive: boolean; +} + +export function SiteFormModal({ open, editing, submitting, onClose, onSubmit }: Props) { + const [form] = Form.useForm(); + const { data: munis = [] } = useQuery({ + queryKey: ['municipalities'], + queryFn: listMunicipalities, + enabled: open, + }); + + useEffect(() => { + if (!open) return; + if (editing) { + form.setFieldsValue({ + name: editing.name, + address: editing.address ?? undefined, + municipalityId: editing.municipalityId ?? undefined, + isActive: editing.isActive, + }); + } else { + form.resetFields(); + form.setFieldsValue({ isActive: true }); + } + }, [open, editing, form]); + + const handleFinish = (values: FormShape) => { + onSubmit({ + name: values.name.trim(), + address: values.address?.trim() || null, + municipalityId: values.municipalityId ?? null, + isActive: values.isActive, + }); + }; + + return ( + form.submit()} + confirmLoading={submitting} + > + form={form} layout="vertical" onFinish={handleFinish} requiredMark={false}> + + + + + + + + + + + + + {!isEdit && ( + + + + )} + + + + {isEdit && ( + + + + )} + + + ); +} diff --git a/portal/frontend/src/hooks/useAuth.tsx b/portal/frontend/src/hooks/useAuth.tsx new file mode 100644 index 0000000..b1edaf7 --- /dev/null +++ b/portal/frontend/src/hooks/useAuth.tsx @@ -0,0 +1,44 @@ +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import type { ReactNode } from 'react'; +import { fetchCurrentUser, login as apiLogin, logout as apiLogout } from '../api/auth'; +import type { CurrentUser } from '../api/auth'; + +interface AuthContextValue { + user: CurrentUser | null; + loading: boolean; + login: (email: string, password: string) => Promise; + logout: () => Promise; +} + +const AuthContext = createContext(undefined); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetchCurrentUser() + .then(setUser) + .finally(() => setLoading(false)); + }, []); + + const login = useCallback(async (email: string, password: string) => { + const u = await apiLogin(email, password); + setUser(u); + }, []); + + const logout = useCallback(async () => { + await apiLogout(); + setUser(null); + }, []); + + const value = useMemo(() => ({ user, loading, login, logout }), [user, loading, login, logout]); + + return {children}; +} + +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error('useAuth must be used inside AuthProvider'); + return ctx; +} diff --git a/portal/frontend/src/hooks/useBranding.tsx b/portal/frontend/src/hooks/useBranding.tsx new file mode 100644 index 0000000..a2798a8 --- /dev/null +++ b/portal/frontend/src/hooks/useBranding.tsx @@ -0,0 +1,47 @@ +import { createContext, useContext, useEffect, useMemo } from 'react'; +import type { ReactNode } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { fetchBranding, type Branding } from '../api/branding'; + +const FALLBACK: Branding = { + applicationName: 'Power Monitoring Portal', + logoUrl: '', + primaryColor: '#1f2937', + secondaryColor: '#374151', + accentColor: '#2563eb', + footerText: '', +}; + +interface BrandingContextValue { + branding: Branding; + loading: boolean; +} + +const BrandingContext = createContext(undefined); + +export function BrandingProvider({ children }: { children: ReactNode }) { + const { data, isLoading } = useQuery({ + queryKey: ['branding'], + queryFn: fetchBranding, + staleTime: 60_000, + }); + + const branding = useMemo(() => data ?? FALLBACK, [data]); + + useEffect(() => { + document.title = branding.applicationName; + const root = document.documentElement; + root.style.setProperty('--brand-primary', branding.primaryColor); + root.style.setProperty('--brand-secondary', branding.secondaryColor); + root.style.setProperty('--brand-accent', branding.accentColor); + }, [branding]); + + const value = useMemo(() => ({ branding, loading: isLoading }), [branding, isLoading]); + return {children}; +} + +export function useBranding(): BrandingContextValue { + const ctx = useContext(BrandingContext); + if (!ctx) throw new Error('useBranding must be used inside BrandingProvider'); + return ctx; +} diff --git a/portal/frontend/src/main.tsx b/portal/frontend/src/main.tsx new file mode 100644 index 0000000..4b7d6e0 --- /dev/null +++ b/portal/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import App from './App'; +import 'antd/dist/reset.css'; + +createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/portal/frontend/src/pages/AdminSitesPage.tsx b/portal/frontend/src/pages/AdminSitesPage.tsx new file mode 100644 index 0000000..d3fc99f --- /dev/null +++ b/portal/frontend/src/pages/AdminSitesPage.tsx @@ -0,0 +1,217 @@ +import { useState } from 'react'; +import { Card, Table, Button, Space, Tag, Popconfirm, Typography, message } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + listSites, createSite, updateSite, deleteSite, + listSiteDevices, createDevice, updateDevice, deleteDevice, + type Site, type Device, type UpsertSite, type UpsertDevice, +} from '../api/sites'; +import { SiteFormModal } from '../components/sites/SiteFormModal'; +import { DeviceFormModal } from '../components/sites/DeviceFormModal'; + +const { Text } = Typography; + +export function AdminSitesPage() { + const qc = useQueryClient(); + const [siteModal, setSiteModal] = useState<{ open: boolean; editing: Site | null }>({ open: false, editing: null }); + const [deviceModal, setDeviceModal] = useState<{ open: boolean; siteId: string | null; editing: Device | null }>({ + open: false, siteId: null, editing: null, + }); + const [expanded, setExpanded] = useState([]); + + const { data: sites = [], isLoading } = useQuery({ queryKey: ['sites'], queryFn: listSites }); + + const createSiteMut = useMutation({ + mutationFn: (p: UpsertSite) => createSite(p), + onSuccess: () => { message.success('Site created'); setSiteModal({ open: false, editing: null }); qc.invalidateQueries({ queryKey: ['sites'] }); }, + onError: () => message.error('Create failed'), + }); + const updateSiteMut = useMutation({ + mutationFn: ({ id, payload }: { id: string; payload: UpsertSite }) => updateSite(id, payload), + onSuccess: () => { message.success('Site updated'); setSiteModal({ open: false, editing: null }); qc.invalidateQueries({ queryKey: ['sites'] }); }, + onError: () => message.error('Update failed'), + }); + const deleteSiteMut = useMutation({ + mutationFn: (id: string) => deleteSite(id), + onSuccess: () => { message.success('Site deleted'); qc.invalidateQueries({ queryKey: ['sites'] }); }, + onError: () => message.error('Delete failed'), + }); + + const createDeviceMut = useMutation({ + mutationFn: ({ siteId, payload }: { siteId: string; payload: UpsertDevice }) => createDevice(siteId, payload), + onSuccess: (_, vars) => { + message.success('Device created'); + setDeviceModal({ open: false, siteId: null, editing: null }); + qc.invalidateQueries({ queryKey: ['devices', vars.siteId] }); + qc.invalidateQueries({ queryKey: ['sites'] }); + }, + onError: () => message.error('Create failed'), + }); + const updateDeviceMut = useMutation({ + mutationFn: ({ deviceId, payload }: { deviceId: string; payload: UpsertDevice }) => updateDevice(deviceId, payload), + onSuccess: () => { + message.success('Device updated'); + setDeviceModal({ open: false, siteId: null, editing: null }); + qc.invalidateQueries({ queryKey: ['devices'] }); + qc.invalidateQueries({ queryKey: ['sites'] }); + }, + onError: () => message.error('Update failed'), + }); + const deleteDeviceMut = useMutation({ + mutationFn: ({ deviceId }: { deviceId: string; siteId: string }) => deleteDevice(deviceId), + onSuccess: (_, vars) => { + message.success('Device deleted'); + qc.invalidateQueries({ queryKey: ['devices', vars.siteId] }); + qc.invalidateQueries({ queryKey: ['sites'] }); + }, + onError: () => message.error('Delete failed'), + }); + + const onSiteSubmit = (payload: UpsertSite) => { + if (siteModal.editing) updateSiteMut.mutate({ id: siteModal.editing.id, payload }); + else createSiteMut.mutate(payload); + }; + + const onDeviceSubmit = (payload: UpsertDevice) => { + if (deviceModal.editing) { + updateDeviceMut.mutate({ deviceId: deviceModal.editing.id, payload }); + } else if (deviceModal.siteId) { + createDeviceMut.mutate({ siteId: deviceModal.siteId, payload }); + } + }; + + const columns: ColumnsType = [ + { title: 'Name', dataIndex: 'name', key: 'name' }, + { title: 'Address', dataIndex: 'address', key: 'address', render: (v) => v ?? }, + { title: 'Devices', dataIndex: 'deviceCount', key: 'deviceCount' }, + { + title: 'Active', + dataIndex: 'isActive', + render: (v: boolean) => (v ? Active : Disabled), + }, + { + title: 'Actions', + key: 'actions', + render: (_, site) => ( + + + deleteSiteMut.mutate(site.id)} + > + + + + ), + }, + ]; + + return ( + } onClick={() => setSiteModal({ open: true, editing: null })}> + New site + + } + > + + rowKey="id" + columns={columns} + dataSource={sites} + loading={isLoading} + pagination={false} + expandable={{ + expandedRowKeys: expanded, + onExpandedRowsChange: (keys) => setExpanded(keys as string[]), + expandedRowRender: (site) => ( + setDeviceModal({ open: true, siteId: site.id, editing: null })} + onEdit={(d) => setDeviceModal({ open: true, siteId: site.id, editing: d })} + onDelete={(d) => deleteDeviceMut.mutate({ deviceId: d.id, siteId: site.id })} + /> + ), + }} + /> + + setSiteModal({ open: false, editing: null })} + onSubmit={onSiteSubmit} + /> + + setDeviceModal({ open: false, siteId: null, editing: null })} + onSubmit={onDeviceSubmit} + /> + + ); +} + +function DeviceSubTable({ + site, onCreate, onEdit, onDelete, +}: { + site: Site; + onCreate: () => void; + onEdit: (d: Device) => void; + onDelete: (d: Device) => void; +}) { + const { data: devices = [], isLoading } = useQuery({ + queryKey: ['devices', site.id], + queryFn: () => listSiteDevices(site.id), + }); + + const columns: ColumnsType = [ + { title: 'Name', dataIndex: 'name', key: 'name' }, + { title: 'External ID', dataIndex: 'externalId', key: 'externalId' }, + { title: 'Description', dataIndex: 'description', key: 'desc' }, + { + title: 'Active', + dataIndex: 'isActive', + render: (v: boolean) => (v ? Active : Inactive), + }, + { + title: 'Actions', + key: 'actions', + render: (_, d) => ( + + + onDelete(d)} + > + + + + ), + }, + ]; + + return ( + + + + + + rowKey="id" + size="small" + columns={columns} + dataSource={devices} + loading={isLoading} + pagination={false} + /> + + ); +} diff --git a/portal/frontend/src/pages/DashboardPage.tsx b/portal/frontend/src/pages/DashboardPage.tsx new file mode 100644 index 0000000..e865d4f --- /dev/null +++ b/portal/frontend/src/pages/DashboardPage.tsx @@ -0,0 +1,15 @@ +import { Card, Typography } from 'antd'; + +const { Title, Paragraph } = Typography; + +export function DashboardPage() { + return ( + + Dashboard + + This is the authenticated landing page. Real telemetry, cost summaries, and embedded + Grafana dashboards land in later phases. + + + ); +} diff --git a/portal/frontend/src/pages/DashboardsPage.tsx b/portal/frontend/src/pages/DashboardsPage.tsx new file mode 100644 index 0000000..ac3b758 --- /dev/null +++ b/portal/frontend/src/pages/DashboardsPage.tsx @@ -0,0 +1,87 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Card, Empty, Layout, Menu, Spin, Typography } from 'antd'; +import { LineChartOutlined } from '@ant-design/icons'; +import { useQuery } from '@tanstack/react-query'; +import { fetchGrafanaConfig, type GrafanaDashboard } from '../api/grafana'; + +const { Sider, Content } = Layout; +const { Text } = Typography; + +export function DashboardsPage() { + const { data, isLoading } = useQuery({ + queryKey: ['grafana-config'], + queryFn: fetchGrafanaConfig, + staleTime: 60_000, + }); + + const dashboards = data?.dashboards ?? []; + const baseUrl = data?.baseUrl?.replace(/\/$/, '') ?? ''; + const [selected, setSelected] = useState(null); + + useEffect(() => { + if (selected) return; + const initial = data?.defaultDashboardUid && dashboards.some((d) => d.uid === data.defaultDashboardUid) + ? data.defaultDashboardUid + : dashboards[0]?.uid ?? null; + if (initial) setSelected(initial); + }, [data, dashboards, selected]); + + const iframeSrc = useMemo(() => { + if (!baseUrl || !selected) return null; + return `${baseUrl}/d/${encodeURIComponent(selected)}?orgId=1&kiosk=tv&theme=light`; + }, [baseUrl, selected]); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (dashboards.length === 0) { + return ( + + + + ); + } + + return ( + + + setSelected(e.key)} + items={dashboards.map((d: GrafanaDashboard) => ({ + key: d.uid, + icon: , + label: ( +
+
{d.title}
+ {d.description && ( + {d.description} + )} +
+ ), + }))} + /> + + + + {iframeSrc ? ( +