From 5def26e0df05266a7e7367a258c4287e82bc2ab9 Mon Sep 17 00:00:00 2001 From: Austin A Date: Fri, 17 Apr 2026 23:03:00 +0100 Subject: [PATCH] chore: initialize repository with deployment baseline --- .gitattributes | 1 + .github/workflows/ci.yml | 99 + .gitignore | 43 + .vscode/extensions.json | 5 + API.md | 72 + DEPLOYMENT.md | 98 + Dockerfile | 15 + SETUP.md | 73 + Upgrade-Implementation-Tracker.md | 89 + Upgrade.md | 257 + backend/Dockerfile | 21 + backend/package-lock.json | 2402 +++++ backend/package.json | 47 + .../20260417120000_init/migration.sql | 1352 +++ backend/prisma/migrations/migration_lock.toml | 1 + backend/prisma/schema.prisma | 1205 +++ backend/prisma/seed.js | 184 + backend/prisma/seed.js.map | 1 + backend/prisma/seed.ts | 192 + backend/src/app.ts | 90 + backend/src/config/env.ts | 38 + backend/src/index.ts | 23 + backend/src/lib/http-error.ts | 12 + backend/src/lib/prisma-json.ts | 48 + backend/src/lib/prisma.ts | 3 + backend/src/middleware/auth.ts | 163 + backend/src/middleware/error-handler.ts | 54 + backend/src/middleware/rate-limit.ts | 60 + backend/src/routes/auth.routes.ts | 123 + backend/src/routes/backup.routes.ts | 491 + backend/src/routes/billing.routes.ts | 46 + backend/src/routes/client.routes.ts | 1247 +++ backend/src/routes/dashboard.routes.ts | 390 + backend/src/routes/health.routes.ts | 22 + backend/src/routes/monitoring.routes.ts | 391 + backend/src/routes/network.routes.ts | 636 ++ backend/src/routes/operations.routes.ts | 275 + backend/src/routes/payment.routes.ts | 71 + backend/src/routes/provisioning.routes.ts | 566 ++ backend/src/routes/proxmox.routes.ts | 637 ++ backend/src/routes/resources.routes.ts | 723 ++ backend/src/routes/settings.routes.ts | 280 + backend/src/services/audit.service.ts | 30 + backend/src/services/backup.service.ts | 1086 +++ backend/src/services/billing.service.ts | 245 + backend/src/services/monitoring.service.ts | 1454 +++ backend/src/services/network.service.ts | 1402 +++ backend/src/services/operations.service.ts | 954 ++ backend/src/services/payment.service.ts | 182 + backend/src/services/provisioning.service.ts | 1123 +++ backend/src/services/proxmox.service.ts | 1451 +++ backend/src/services/scheduler.service.ts | 495 + backend/src/tests/operations.test.ts | 20 + backend/src/types/express.d.ts | 19 + backend/tsconfig.json | 20 + components.json | 21 + docker-compose.yml | 63 + entities/AuditLog.json | 80 + entities/Backup.json | 72 + entities/BillingPlan.json | 83 + entities/FirewallRule.json | 95 + entities/Invoice.json | 83 + entities/ProxmoxNode.json | 95 + entities/SecurityEvent.json | 88 + entities/Tenant.json | 109 + entities/UsageRecord.json | 37 + entities/VirtualMachine.json | 63 + eslint.config.js | 42 + index.html | 12 + infra/deploy/docker-compose.production.yml | 76 + infra/deploy/install-proxpanel.sh | 345 + infra/nginx/default.conf | 29 + jsconfig.json | 16 + package-lock.json | 8290 +++++++++++++++++ package.json | 85 + postcss.config.js | 6 + src/App.jsx | 83 + src/api/appClient.js | 1129 +++ src/components/ProtectedRoute.jsx | 34 + src/components/UserNotRegisteredError.jsx | 34 + src/components/layout/AppLayout.jsx | 54 + src/components/layout/Sidebar.jsx | 164 + src/components/layout/nav-config.js | 54 + src/components/shared/EmptyState.jsx | 14 + src/components/shared/PageHeader.jsx | 12 + src/components/shared/ResourceBar.jsx | 25 + src/components/shared/StatCard.jsx | 28 + src/components/shared/StatusBadge.jsx | 35 + src/components/ui/UserNotRegisteredError.jsx | 19 + src/components/ui/accordion.jsx | 97 + src/components/ui/alert-dialog.jsx | 47 + src/components/ui/alert.jsx | 5 + src/components/ui/aspect-ratio.jsx | 35 + src/components/ui/avatar.jsx | 34 + src/components/ui/badge.jsx | 13 + src/components/ui/breadcrumb.jsx | 48 + src/components/ui/button.jsx | 37 + src/components/ui/calendar.jsx | 50 + src/components/ui/card.jsx | 193 + src/components/ui/carousel.jsx | 309 + src/components/ui/chart.jsx | 22 + src/components/ui/checkbox.jsx | 11 + src/components/ui/collapsible.jsx | 116 + src/components/ui/command.jsx | 156 + src/components/ui/context-menu.jsx | 156 + src/components/ui/dialog.jsx | 42 + src/components/ui/drawer.jsx | 156 + src/components/ui/dropdown-menu.jsx | 134 + src/components/ui/form.jsx | 25 + src/components/ui/hover-card.jsx | 53 + src/components/ui/input-otp.jsx | 19 + src/components/ui/input.jsx | 20 + src/components/ui/label.jsx | 10 + src/components/ui/menubar.jsx | 104 + src/components/ui/navigation-menu.jsx | 100 + src/components/ui/pagination.jsx | 27 + src/components/ui/popover.jsx | 23 + src/components/ui/progress.jsx | 29 + src/components/ui/radio-group.jsx | 42 + src/components/ui/resizable.jsx | 38 + src/components/ui/scroll-area.jsx | 121 + src/components/ui/select.jsx | 89 + src/components/ui/separator.jsx | 109 + src/components/ui/sheet.jsx | 626 ++ src/components/ui/sidebar.jsx | 14 + src/components/ui/skeleton.jsx | 21 + src/components/ui/slider.jsx | 29 + src/components/ui/sonner.jsx | 22 + src/components/ui/switch.jsx | 31 + src/components/ui/table.jsx | 41 + src/components/ui/tabs.jsx | 60 + src/components/ui/textarea.jsx | 105 + src/components/ui/toast.jsx | 1 + src/components/ui/toaster.jsx | 23 + src/components/ui/toggle-group.jsx | 38 + src/components/ui/toggle.jsx | 28 + src/components/ui/tooltip.jsx | 164 + src/components/ui/use-toast.jsx | 47 + src/hooks/use-mobile.jsx | 19 + src/index.css | 201 + src/lib/AuthContext.jsx | 77 + src/lib/PageNotFound.jsx | 53 + src/lib/app-params.js | 54 + src/lib/query-client.js | 11 + src/lib/utils.js | 9 + src/main.jsx | 8 + src/pages/AuditLogs.jsx | 88 + src/pages/Backups.jsx | 231 + src/pages/Billing.jsx | 323 + src/pages/ClientArea.jsx | 1101 +++ src/pages/Dashboard.jsx | 544 ++ src/pages/Login.jsx | 117 + src/pages/Monitoring.jsx | 681 ++ src/pages/NetworkIpam.jsx | 249 + src/pages/Nodes.jsx | 258 + src/pages/Operations.jsx | 355 + src/pages/Provisioning.jsx | 611 ++ src/pages/RBAC.jsx | 170 + src/pages/Security.jsx | 545 ++ src/pages/Settings.jsx | 452 + src/pages/Tenants.jsx | 121 + src/pages/VirtualMachines.jsx | 436 + src/util/index.ts | 3 + src/utils/index.ts | 3 + tailwind.config.js | 111 + vite.config.js | 12 + 166 files changed, 43065 insertions(+) create mode 100644 .gitattributes create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .vscode/extensions.json create mode 100644 API.md create mode 100644 DEPLOYMENT.md create mode 100644 Dockerfile create mode 100644 SETUP.md create mode 100644 Upgrade-Implementation-Tracker.md create mode 100644 Upgrade.md create mode 100644 backend/Dockerfile create mode 100644 backend/package-lock.json create mode 100644 backend/package.json create mode 100644 backend/prisma/migrations/20260417120000_init/migration.sql create mode 100644 backend/prisma/migrations/migration_lock.toml create mode 100644 backend/prisma/schema.prisma create mode 100644 backend/prisma/seed.js create mode 100644 backend/prisma/seed.js.map create mode 100644 backend/prisma/seed.ts create mode 100644 backend/src/app.ts create mode 100644 backend/src/config/env.ts create mode 100644 backend/src/index.ts create mode 100644 backend/src/lib/http-error.ts create mode 100644 backend/src/lib/prisma-json.ts create mode 100644 backend/src/lib/prisma.ts create mode 100644 backend/src/middleware/auth.ts create mode 100644 backend/src/middleware/error-handler.ts create mode 100644 backend/src/middleware/rate-limit.ts create mode 100644 backend/src/routes/auth.routes.ts create mode 100644 backend/src/routes/backup.routes.ts create mode 100644 backend/src/routes/billing.routes.ts create mode 100644 backend/src/routes/client.routes.ts create mode 100644 backend/src/routes/dashboard.routes.ts create mode 100644 backend/src/routes/health.routes.ts create mode 100644 backend/src/routes/monitoring.routes.ts create mode 100644 backend/src/routes/network.routes.ts create mode 100644 backend/src/routes/operations.routes.ts create mode 100644 backend/src/routes/payment.routes.ts create mode 100644 backend/src/routes/provisioning.routes.ts create mode 100644 backend/src/routes/proxmox.routes.ts create mode 100644 backend/src/routes/resources.routes.ts create mode 100644 backend/src/routes/settings.routes.ts create mode 100644 backend/src/services/audit.service.ts create mode 100644 backend/src/services/backup.service.ts create mode 100644 backend/src/services/billing.service.ts create mode 100644 backend/src/services/monitoring.service.ts create mode 100644 backend/src/services/network.service.ts create mode 100644 backend/src/services/operations.service.ts create mode 100644 backend/src/services/payment.service.ts create mode 100644 backend/src/services/provisioning.service.ts create mode 100644 backend/src/services/proxmox.service.ts create mode 100644 backend/src/services/scheduler.service.ts create mode 100644 backend/src/tests/operations.test.ts create mode 100644 backend/src/types/express.d.ts create mode 100644 backend/tsconfig.json create mode 100644 components.json create mode 100644 docker-compose.yml create mode 100644 entities/AuditLog.json create mode 100644 entities/Backup.json create mode 100644 entities/BillingPlan.json create mode 100644 entities/FirewallRule.json create mode 100644 entities/Invoice.json create mode 100644 entities/ProxmoxNode.json create mode 100644 entities/SecurityEvent.json create mode 100644 entities/Tenant.json create mode 100644 entities/UsageRecord.json create mode 100644 entities/VirtualMachine.json create mode 100644 eslint.config.js create mode 100644 index.html create mode 100644 infra/deploy/docker-compose.production.yml create mode 100644 infra/deploy/install-proxpanel.sh create mode 100644 infra/nginx/default.conf create mode 100644 jsconfig.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 postcss.config.js create mode 100644 src/App.jsx create mode 100644 src/api/appClient.js create mode 100644 src/components/ProtectedRoute.jsx create mode 100644 src/components/UserNotRegisteredError.jsx create mode 100644 src/components/layout/AppLayout.jsx create mode 100644 src/components/layout/Sidebar.jsx create mode 100644 src/components/layout/nav-config.js create mode 100644 src/components/shared/EmptyState.jsx create mode 100644 src/components/shared/PageHeader.jsx create mode 100644 src/components/shared/ResourceBar.jsx create mode 100644 src/components/shared/StatCard.jsx create mode 100644 src/components/shared/StatusBadge.jsx create mode 100644 src/components/ui/UserNotRegisteredError.jsx create mode 100644 src/components/ui/accordion.jsx create mode 100644 src/components/ui/alert-dialog.jsx create mode 100644 src/components/ui/alert.jsx create mode 100644 src/components/ui/aspect-ratio.jsx create mode 100644 src/components/ui/avatar.jsx create mode 100644 src/components/ui/badge.jsx create mode 100644 src/components/ui/breadcrumb.jsx create mode 100644 src/components/ui/button.jsx create mode 100644 src/components/ui/calendar.jsx create mode 100644 src/components/ui/card.jsx create mode 100644 src/components/ui/carousel.jsx create mode 100644 src/components/ui/chart.jsx create mode 100644 src/components/ui/checkbox.jsx create mode 100644 src/components/ui/collapsible.jsx create mode 100644 src/components/ui/command.jsx create mode 100644 src/components/ui/context-menu.jsx create mode 100644 src/components/ui/dialog.jsx create mode 100644 src/components/ui/drawer.jsx create mode 100644 src/components/ui/dropdown-menu.jsx create mode 100644 src/components/ui/form.jsx create mode 100644 src/components/ui/hover-card.jsx create mode 100644 src/components/ui/input-otp.jsx create mode 100644 src/components/ui/input.jsx create mode 100644 src/components/ui/label.jsx create mode 100644 src/components/ui/menubar.jsx create mode 100644 src/components/ui/navigation-menu.jsx create mode 100644 src/components/ui/pagination.jsx create mode 100644 src/components/ui/popover.jsx create mode 100644 src/components/ui/progress.jsx create mode 100644 src/components/ui/radio-group.jsx create mode 100644 src/components/ui/resizable.jsx create mode 100644 src/components/ui/scroll-area.jsx create mode 100644 src/components/ui/select.jsx create mode 100644 src/components/ui/separator.jsx create mode 100644 src/components/ui/sheet.jsx create mode 100644 src/components/ui/sidebar.jsx create mode 100644 src/components/ui/skeleton.jsx create mode 100644 src/components/ui/slider.jsx create mode 100644 src/components/ui/sonner.jsx create mode 100644 src/components/ui/switch.jsx create mode 100644 src/components/ui/table.jsx create mode 100644 src/components/ui/tabs.jsx create mode 100644 src/components/ui/textarea.jsx create mode 100644 src/components/ui/toast.jsx create mode 100644 src/components/ui/toaster.jsx create mode 100644 src/components/ui/toggle-group.jsx create mode 100644 src/components/ui/toggle.jsx create mode 100644 src/components/ui/tooltip.jsx create mode 100644 src/components/ui/use-toast.jsx create mode 100644 src/hooks/use-mobile.jsx create mode 100644 src/index.css create mode 100644 src/lib/AuthContext.jsx create mode 100644 src/lib/PageNotFound.jsx create mode 100644 src/lib/app-params.js create mode 100644 src/lib/query-client.js create mode 100644 src/lib/utils.js create mode 100644 src/main.jsx create mode 100644 src/pages/AuditLogs.jsx create mode 100644 src/pages/Backups.jsx create mode 100644 src/pages/Billing.jsx create mode 100644 src/pages/ClientArea.jsx create mode 100644 src/pages/Dashboard.jsx create mode 100644 src/pages/Login.jsx create mode 100644 src/pages/Monitoring.jsx create mode 100644 src/pages/NetworkIpam.jsx create mode 100644 src/pages/Nodes.jsx create mode 100644 src/pages/Operations.jsx create mode 100644 src/pages/Provisioning.jsx create mode 100644 src/pages/RBAC.jsx create mode 100644 src/pages/Security.jsx create mode 100644 src/pages/Settings.jsx create mode 100644 src/pages/Tenants.jsx create mode 100644 src/pages/VirtualMachines.jsx create mode 100644 src/util/index.ts create mode 100644 src/utils/index.ts create mode 100644 tailwind.config.js create mode 100644 vite.config.js diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfdb8b7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.sh text eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d5f681b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,99 @@ +name: CI + +on: + push: + branches: + - "**" + pull_request: + +jobs: + frontend: + name: Frontend Build + Lint + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install frontend dependencies + run: npm ci + + - name: Lint frontend + run: npm run lint + + - name: Build frontend + run: npm run build + + backend: + name: Backend Build + Test + Prisma Checks + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: proxpanel + POSTGRES_PASSWORD: proxpanel + POSTGRES_DB: proxpanel + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U proxpanel -d proxpanel" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + DATABASE_URL: postgresql://proxpanel:proxpanel@localhost:5432/proxpanel?schema=public + SHADOW_DATABASE_URL: postgresql://proxpanel:proxpanel@localhost:5432/proxpanel_shadow?schema=public + JWT_SECRET: ci_super_secret_key_for_testing_12345 + JWT_REFRESH_SECRET: ci_super_refresh_secret_key_67890 + CORS_ORIGIN: http://localhost:5173 + NODE_ENV: test + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: backend/package-lock.json + + - name: Install backend dependencies + working-directory: backend + run: npm ci + + - name: Prepare shadow database + run: | + sudo apt-get update + sudo apt-get install -y postgresql-client + PGPASSWORD=proxpanel psql -h localhost -U proxpanel -d proxpanel -c 'CREATE DATABASE proxpanel_shadow;' + + - name: Prisma generate + working-directory: backend + run: npm run prisma:generate + + - name: Prisma validate + working-directory: backend + run: npm run prisma:validate + + - name: Prisma migrate deploy + working-directory: backend + run: npm run prisma:deploy + + - name: Prisma migration drift check + working-directory: backend + run: npx prisma migrate diff --from-migrations prisma/migrations --to-schema-datamodel prisma/schema.prisma --shadow-database-url "$SHADOW_DATABASE_URL" --exit-code + + - name: Build backend + working-directory: backend + run: npm run build + + - name: Run backend tests + working-directory: backend + run: npm run test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..adf84d2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +# dependencies +node_modules + +# build outputs +dist +dist-ssr +.vite +backend/dist + +# environment +.env +.env.* +backend/.env +backend/.env.* + +# logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# ide +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.njsproj +*.sln +*.sw? + +# deployment artifacts / backups +backups/ +_deploy_bundle.tar.gz + +# local secret/material files +Proxmox_API_Token.txt +myProx_template_ssh_key.txt +more_dev_work.txt +audit.md +proxpanel-report.md diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..bd338f4 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,5 @@ +{ + "recommendations": [ + "codeium.codeium" + ] +} \ No newline at end of file diff --git a/API.md b/API.md new file mode 100644 index 0000000..6752fdb --- /dev/null +++ b/API.md @@ -0,0 +1,72 @@ +# API Documentation (Core) + +Base URL: `http://:8080` + +## Health + +- `GET /api/health` + +## Auth + +- `POST /api/auth/login` + - Body: `{ "email": "user@example.com", "password": "..." }` + - Returns: `{ token, refresh_token, user }` +- `POST /api/auth/refresh` + - Body: `{ "refresh_token": "..." }` + - Returns: `{ token, refresh_token }` +- `GET /api/auth/me` (Bearer token) + +## Proxmox Operations + +- `POST /api/proxmox/sync` +- `POST /api/proxmox/vms/:id/actions/:action` +- `POST /api/proxmox/vms/:id/migrate` +- `PATCH /api/proxmox/vms/:id/config` +- `PATCH /api/proxmox/vms/:id/network` +- `POST /api/proxmox/vms/:id/disks` +- `POST /api/proxmox/vms/:id/reinstall` +- `GET /api/proxmox/vms/:id/console` +- `GET /api/proxmox/vms/:id/usage-graphs?timeframe=hour|day|week|month|year` +- `GET /api/proxmox/nodes/:id/usage-graphs?timeframe=hour|day|week|month|year` +- `GET /api/proxmox/cluster/usage-graphs?timeframe=hour|day|week|month|year` + +## Resources API + +Generic secured resource endpoints: + +- `GET /api/resources/:resource` +- `GET /api/resources/:resource/:id` +- `POST /api/resources/:resource` +- `PATCH /api/resources/:resource/:id` +- `DELETE /api/resources/:resource/:id` + +Tenant scope protections are enforced for tenant-scoped resources. + +## Client Area + +- `GET /api/client/overview` +- `GET /api/client/usage-trends` +- `GET /api/client/machines` +- `POST /api/client/machines` +- `PATCH /api/client/machines/:vmId/resources` +- `POST /api/client/machines/:vmId/power-schedules` +- `POST /api/client/machines/:vmId/backup-schedules` +- `GET /api/client/firewall/rules` +- `POST /api/client/firewall/rules` +- `PATCH /api/client/firewall/rules/:id` +- `DELETE /api/client/firewall/rules/:id` + +## Monitoring + +- `GET /api/monitoring/overview` +- `GET /api/monitoring/health-checks` +- `POST /api/monitoring/health-checks` +- `GET /api/monitoring/alerts/events` +- `GET /api/monitoring/insights/faulty-deployments` +- `GET /api/monitoring/insights/cluster-forecast` + +## Rate Limiting + +- Global API rate limiting is enabled. +- Auth endpoints use stricter limits. +- When exceeded, API returns HTTP `429`. diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..21f0348 --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,98 @@ +# ProxPanel Deployment Guide (Production Ubuntu) + +## 1) Hands-Free Production Install (Recommended) + +Run this on your Ubuntu server: + +```bash +sudo apt-get update -y +sudo apt-get install -y git +git clone /opt/proxpanel +cd /opt/proxpanel +sudo bash infra/deploy/install-proxpanel.sh \ + --branch main \ + --public-url http://102.69.243.167 \ + --admin-email admin@yourdomain.com \ + --configure-ufw +``` + +If the repo already exists on the server, just run: + +```bash +cd /opt/proxpanel +sudo bash infra/deploy/install-proxpanel.sh \ + --branch main \ + --public-url http://102.69.243.167 \ + --admin-email admin@yourdomain.com \ + --configure-ufw +``` + +Installer behavior: +- Installs Docker + prerequisites. +- Builds and starts PostgreSQL, backend, frontend. +- Applies Prisma schema (`prisma:deploy`, fallback to `prisma:push`). +- Seeds admin user. +- Verifies API health and login. +- Writes deployment summary to `/root/proxpanel-install-summary.txt`. + +## 2) Fast Production Checks + +```bash +cd /opt/proxpanel +docker compose --env-file .env.production -f infra/deploy/docker-compose.production.yml ps +curl -fsS http://127.0.0.1:8080/api/health +curl -I http://102.69.243.167 +``` + +## 3) Connect Proxmox Cluster In App + +### A. Create Proxmox API token +In Proxmox UI: +1. Open `Datacenter -> Permissions -> API Tokens`. +2. Select your user (for example `root@pam` or a dedicated service user). +3. Click `Add`. +4. Set `Token ID` (example: `proxpanel`). +5. Copy the generated token secret immediately. + +### B. Save credentials in ProxPanel +In ProxPanel UI: +1. Login as admin. +2. Go to `Settings -> Proxmox`. +3. Fill: + - `Host`: Proxmox hostname or IP (no `https://` prefix) + - `Port`: `8006` + - `Username`: e.g. `root@pam` + - `Token ID`: e.g. `proxpanel` + - `Token Secret`: generated secret + - `Verify SSL`: enabled if Proxmox cert is trusted; disable only if using self-signed cert temporarily +4. Click `Save Proxmox`. + +### C. Trigger first sync +Use API once to import nodes/VMs: + +```bash +APP_URL="http://102.69.243.167" +ADMIN_EMAIL="admin@yourdomain.com" +ADMIN_PASSWORD="" + +TOKEN=$(curl -s -X POST "$APP_URL/api/auth/login" \ + -H "Content-Type: application/json" \ + -d "{\"email\":\"$ADMIN_EMAIL\",\"password\":\"$ADMIN_PASSWORD\"}" | jq -r '.token') + +curl -s -X POST "$APP_URL/api/proxmox/sync" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" +``` + +Then confirm: +- `Nodes` page shows imported nodes. +- Dashboard cards and usage graphs populate. + +## 4) Security Hardening Checklist + +- Set a DNS name and terminate TLS (Nginx/Caddy/Cloudflare). +- Change the seeded admin password immediately. +- Keep `CORS_ORIGIN` set to your real public URL only. +- Use a dedicated Proxmox API user/token with least privileges. +- Keep backend bound to localhost (`127.0.0.1`) and expose only frontend port. +- Enable off-host backups for DB and app config. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..390afe9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM node:22-alpine AS build +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm ci +COPY . . +ARG VITE_API_BASE_URL=http://localhost:8080 +ENV VITE_API_BASE_URL=${VITE_API_BASE_URL} +RUN npm run build + +FROM nginx:1.27-alpine AS runtime +WORKDIR /usr/share/nginx/html +COPY --from=build /app/dist ./ +COPY infra/nginx/default.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/SETUP.md b/SETUP.md new file mode 100644 index 0000000..5eac26d --- /dev/null +++ b/SETUP.md @@ -0,0 +1,73 @@ +# Setup Guide + +## Prerequisites + +- Node.js 22+ +- npm 10+ +- PostgreSQL 15+ + +## 1) Install Dependencies + +```bash +npm install +cd backend && npm install +``` + +## 2) Configure Backend Environment + +Copy the template and set real secrets: + +```bash +cd backend +cp .env.example .env +``` + +Required values: + +- `DATABASE_URL` +- `JWT_SECRET` +- `JWT_REFRESH_SECRET` +- `CORS_ORIGIN` + +## 3) Prepare Database + +Preferred (versioned migrations): + +```bash +cd backend +npm run prisma:migrate +npm run prisma:generate +npm run prisma:seed +``` + +Alternative (dev only): + +```bash +cd backend +npm run prisma:push +npm run prisma:seed +``` + +## 4) Run Development Stack + +Backend: + +```bash +cd backend +npm run dev +``` + +Frontend (new terminal): + +```bash +npm run dev +``` + +## 5) Quality Gates + +```bash +cd backend && npm run build +cd .. +npm run lint +npm run build +``` diff --git a/Upgrade-Implementation-Tracker.md b/Upgrade-Implementation-Tracker.md new file mode 100644 index 0000000..13b6f12 --- /dev/null +++ b/Upgrade-Implementation-Tracker.md @@ -0,0 +1,89 @@ +# Enterprise Upgrade Implementation Tracker + +This tracker maps the feature scope from Upgrade.md into implementation phases with delivered status. + +## Phase 1 - Control Plane Foundation (Implemented) + +### Admin Area +- [x] Boot/Reboot/Stop/Shut Down server actions (/api/proxmox/vms/:id/actions/:action) +- [x] Migrate server between nodes (/api/proxmox/vms/:id/migrate) +- [x] Access noVNC console ticket (/api/proxmox/vms/:id/console) +- [x] Reinstall workflow endpoint (/api/proxmox/vms/:id/reinstall) +- [x] Change VM hostname/ISO/boot settings/SSH key (/api/proxmox/vms/:id/config) +- [x] Reconfigure server network (/api/proxmox/vms/:id/network) +- [x] Add additional disk storage (/api/proxmox/vms/:id/disks) +- [x] Auto backup before reinstall flag (ackup_before_reinstall) + +### Tasking / Queue / History +- [x] Operations task history model (OperationTask) +- [x] Operation status lifecycle: queued/running/success/failed +- [x] Operations task list API (GET /api/operations/tasks) +- [x] Queue summary stats for waiting/running/failed/success +- [x] Audit logging linked with task IDs for critical operations + +### Scheduled Automation +- [x] VM power schedule model (PowerSchedule) +- [x] Power schedule CRUD APIs (/api/operations/power-schedules) +- [x] Run-now trigger for schedules (POST /run) +- [x] Cron-based power schedule worker + +### Frontend +- [x] Operations Center page (/operations) +- [x] Task history table + queue counters +- [x] Power schedules list/create/toggle/delete/run-now + +## Phase 2 - Provisioning & Templates (Implemented) +- [x] App template catalog (KVM/LXC templates, ISO, archives) +- [x] Application groups + template assignment policies +- [x] VM ID range policies per server/group +- [x] Auto-node and weighted placement engine +- [x] Service create/suspend/unsuspend/terminate flows with package options +- [x] Deep Proxmox template-clone/image-boot orchestration per template type + +## Phase 3 - Backup, Restore, Snapshots (In Progress) +- [x] PBS integration workflow for file-level restore tasks +- [x] Backup limits (count/size) enforcement per tenant/product +- [x] Backup protection flags and routing policies +- [x] Snapshot jobs with recurring policies and retention +- [x] Cross-VM restore from owned servers + +## Phase 4 - Network & IPAM Enterprise (In Progress) +- [x] Public/private IPAM across server/VLAN/tag/node/bridge (Prisma models + APIs) +- [x] IPv4/IPv6/subnet import/return workflows (bulk import + assignment return endpoints) +- [x] Additional IP assignment automation and audit logs +- [x] SDN-aware private network attach/detach controls (API + UI wiring) +- [x] IP subnet utilization dashboard APIs and admin UI +- [x] Stricter pool policies (tenant quotas + reserved ranges + policy-based best-fit allocation) +- [x] Subnet heatmap widgets + tenant-level utilization trend charts on dashboard + +## Phase 5 - Monitoring, Alerts, Notifications (Implemented) +- [x] Server health check definitions and result logs +- [x] Threshold alerts (CPU/RAM/network/disk I/O) with notifications +- [x] Faulty deployment insights and failed-task analytics +- [x] Cluster remaining-resource forecasting + +## Phase 6 - Client Area Enterprise (Implemented) +- [x] Client machine create/manage with configurable limits +- [x] Resource upgrade/downgrade workflows +- [x] Firewall rule management and policy packs +- [x] VM power schedules and backup schedules in tenant UI +- [x] Console proxy per-node/per-cluster configuration + +## Phase 7 - Platform Governance, Scheduler, Logs (Implemented) +- [x] Cron scheduler policy settings with live runtime reconfiguration from Admin Settings +- [x] Operation task repetition thresholds (retry attempts + backoff) with automated retry worker +- [x] Failure notification policy for operation tasks (webhook + email gateway routing) +- [x] Queue insights API for waiting/retrying/failed/stale tasks and due scheduled actions +- [x] Settings UI upgraded from mock form to real backend-backed enterprise controls + +## Phase 8 - Resource Graphs & Timescale Telemetry (Implemented) +- [x] Proxmox VM usage graph API with time-scale controls (hour/day/week/month/year) +- [x] Graph data includes CPU, memory, disk usage, network throughput, and disk I/O +- [x] Admin VM panel updated with interactive usage graph dialogs +- [x] Client Area updated with per-machine telemetry graphs and timescale selector +- [x] Node-level resource graph API and Nodes page telemetry dialogs (CPU/RAM/Disk/I/O wait + network) +- [x] Cluster MRTG-style dashboard views with selectable timescale windows and aggregate summaries + +## Notes +- This phase establishes the operational backbone required by most advanced features. +- Remaining phases will build on the task engine + schedule worker + audited VM operation APIs implemented in Phase 1. diff --git a/Upgrade.md b/Upgrade.md new file mode 100644 index 0000000..d6960d3 --- /dev/null +++ b/Upgrade.md @@ -0,0 +1,257 @@ +Features + + Admin Area + Create/Suspend/Unsuspend/Terminate Service: + VPS Type Product With Single VM Machine + Cloud Type Product With Multiple VM Machines Created Within Defined Limits + Create/Terminate User Account + Change Package - Supports Configurable Options + Reconfigure Server Network + Import/Detach VM Machine + Boot/Reboot/Stop/Shut Down Server + Change User Role + Access noVNC, SPICE And Xterm.js Console + Migrate Server Between Nodes In The Same Cluster + Reinstall Server + Change Server Hostname, ISO Image, Boot Devices And SSH Public Key + View Server Status, Details And Statistics + View Graphs - With Option To Change Time Scale of MRTG Graphs + Display Disk And Bandwidth Usage Of Each Product + Display CPU And Memory Usage Of Each Product + Display IP Sets (KVM) + Auto Node - Automatically Create VM On Node With Most Free Space + Configure Client Area Features Per Product + Configure Network And Private Network Settings With SDN Support + Import IP Address To Hosting IP Addresses Table + Return IP Address To IP Addresses Subnet + Add Additional IP Address To VM + Add Additional Disks Storage To VM (KVM) + Enable Qemu Guest Agent (KVM) + Enable Backups Routing + Enable Auto VM Backups Before Reinstallation + Enable Load Balancer + Receive Notifications About VM Upgrades And Creation Failures +Display Servers: + + List Per VPS And Cloud + VMs List + Clusters List + VM Cleaner - Manage VM Not Existing In Your WHMCS + Templates - Convert KVM VPS To KVM Template + Settings + Groups + Recovery VM Configurations List With Export To Backup File + Task History + Statuses, Resources Usage, IP Assignments And Details + +Manage Public And Private IP Addresses Per Server/VLAN/Tag/Node/Bridge: + + IPv4 Addresses + IPv6 Addresses + IPv6 Subnets + +View Logs Of IP Assignment Changes +Configure App Templates: + + Applications + KVM/LXC Templates + ISO Images + KVM/LXC Archives + +Create And Manage Custom Cloud-Init Scripts Per App Template +Configure High Availability Settings Per App Template +Create Application Groups And Assign App Templates +Assign Virtual Machines To Nodes Based On Selected Application Groups +Define VM ID Ranges Per Server +Set Minimum VM ID For Product Without ID Ranges Defined +Configure Resource Weights For Load Balancer Prioritization +Configure Tasks Repetition Threshold And Email Notifications +Configure Backup Tasks Limitation And File Restoration Settings +Configure Console Proxy For Multiple Or Per-Node Connections +Set Admin Area And Proxmox VE Widget Features +Configure Scheduled Backups And Firewall +View And Manage Logs +View Queue Of Scheduled Tasks +Configure Cron Scheduler Settings +Customize Module Language Files With "Translations" Tool +Manage Media Library With Logotypes For App Templates +View Backup Tasks, Virtual Machine And Resource Usage Statistics + +View Faulty VM Deployments +View Waiting And Failed Tasks +View Cluster Remaining Resources +View Node Resources + + Configurable Options + + KVM For "VPS" Product Type: + Additional Disks Space (With Configurable Storage, Units And Size) + Amount of RAM + Application + Backup Files + Backups Size + Bandwidth + CPU Cores + CPU Sockets + CPU units for a VM + Custom Cloud-Init Configuration + Disk Space + Download Backup Files + IPv4 Addresses + IPv6 Addresses + IPv6 Subnets + Limit Of CPU + Managed View + Network Rate + OS Type + Private Network + Protected Backup Files + Restore Backup Files + Server Monitoring + Snapshot Jobs + Snapshots + Storage Disk Space + TPM + Tag + VCPUs + KVM Limits For "Cloud" Product Type: + Additional Disk Space + Backups Files Limit + Backups Size + Bandwidth + CPU Cores + CPU Limit + CPU Sockets + CPU Units Limit + IPv4 Addresses + IPv6 Addresses + IPv6 Subnets + Memory + Network Rate + Snapshot Jobs + Snapshots + Storage + Storage Disk Space + VCPUs + Virtual Networks + LXC For "VPS" Product Type: + Additional Disks Space (With Configurable Storage, Units And Size) + Amount of RAM + Amount of SWAP + Application + Backup Files + Backups Size + Bandwidth + CPU Cores + CPU units for a VM + Disk Space + Download Backup Files + IPv4 Addresses + IPv6 Addresses + IPv6 Subnets + Limit Of CPU + Managed View + Network Rate + Private Network + Protected Backup Files + Restore Backup Files + Server Monitoring + Snapshot Jobs + Snapshots + Storage Disk Space + Tag + LXC Limits For "Cloud" Product Type: + Additional Disk Space + Backups Files Limit + Backups Size + Bandwidth + CPU Limit + CPU Units Limit + IPv4 Addresses + IPv6 Addresses + IPv6 Subnets + Memory + Network Rate + SWAP + Snapshot Jobs + Snapshots + Storage + Storage Disk Space + VCPUs + Virtual Networks + + Client Area + + Create/Manage/View Server Status, Details And Statistics: + VPS Type Product With Single VM Machine + Cloud Type Product With Multiple VM Machines Created Within Available Limits: + Define Machine Settings: + Name + Type + Description + Define Machine Parameters: + Location + Sockets (KVM) + Cores (LXC) + vCPU (KVM) + CPU Priority + VM RAM + SWAP (LXC) + Disk Size + Default User (KVM) + Password + SSH Key + Search Domain (KVM) + Name Servers (KVM) + Add Virtual Networks + Add Additional Disks + Start/Reboot/Stop/Shut Down/Delete Server + Reconfigure Server Network + Access noVNC, SPICE And Xterm.js Console + Change Server Hostname, ISO Image, Boot Devices And SSH Public Key + View And Edit Public SSH Key (KVM) + Download Public And Private SSH Keys (LXC) + Create/Restore/Delete Backups Of Current Server + Manage Backups Within Defined Limits (Max Number And Size Of Files) + Restore Backups From: + Any Owned Server + Proxmox Backup Server (PBS) + Restore Backups Of: + Selected Single Files And Directories With Option To Download Them (PBS) + Full Server Backups + Manage Backup Schedules Within Defined Limits (Max Number And Size Of Files) + Protect Selected Backups From Manual Deletion And Backups Routing + Add And Manage Additional Disks + Manage Firewall Rules And Options + View Resources Usage Graphs - With Option To Change Time Scale of MRTG Graphs: + CPU + Memory + Network Traffic + Disk I/O + View Network Devices, Manage Private Interface And Attach Servers + Reinstall Server Using Templates (KVM) And ISO Images + Send Email Notifications When Server Exceeds Resource Thresholds: + Network Traffic + CPU Usage + Memory Usage + Disk Read And Write Speed + Monitor Server Health + Create Server Monitoring Checks + View Check Result Logs + View Successful And Failed Checks Graphs + Create Server Snapshots: + Manually + Automatically: + Every Number Of Hours + Each Specified Day + View Task History + Manage VM Power Tasks To Automatically Start/Stop/Reboot Server At Specified Time + Display CPU, Memory, Disk And Bandwidth Usage + Choose Server Resources While Ordering And Upgrade/Downgrade Them Freely + Convert KVM VPS To KVM Template ("Cloud" Type Product) + + + + + + diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..22d3b1f --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,21 @@ +FROM node:22-alpine AS deps +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm install + +FROM node:22-alpine AS build +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN npm run prisma:generate +RUN npm run build + +FROM node:22-alpine AS runtime +WORKDIR /app +ENV NODE_ENV=production +COPY --from=build /app/node_modules ./node_modules +COPY --from=build /app/dist ./dist +COPY --from=build /app/prisma ./prisma +COPY --from=build /app/package.json ./package.json +EXPOSE 8080 +CMD ["sh", "-c", "npm run prisma:deploy && node dist/index.js"] diff --git a/backend/package-lock.json b/backend/package-lock.json new file mode 100644 index 0000000..3656b21 --- /dev/null +++ b/backend/package-lock.json @@ -0,0 +1,2402 @@ +{ + "name": "proxpanel-backend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "proxpanel-backend", + "version": "1.0.0", + "dependencies": { + "@prisma/client": "^6.6.0", + "axios": "^1.9.0", + "bcryptjs": "^2.4.3", + "compression": "^1.8.0", + "cors": "^2.8.5", + "dotenv": "^16.5.0", + "express": "^4.21.2", + "helmet": "^8.1.0", + "jsonwebtoken": "^9.0.2", + "morgan": "^1.10.0", + "node-cron": "^4.0.7", + "zod": "^3.24.3" + }, + "devDependencies": { + "@types/bcryptjs": "^2.4.6", + "@types/compression": "^1.7.5", + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/jsonwebtoken": "^9.0.9", + "@types/morgan": "^1.9.9", + "@types/node": "^22.15.2", + "prisma": "^6.6.0", + "tsx": "^4.19.4", + "typescript": "^5.8.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@prisma/client": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.3.tgz", + "integrity": "sha512-mKq3jQFhjvko5LTJFHGilsuQs+W+T3Gm451NzuTDGQxwCzwXHYnIu2zGkRoW+Exq3Rob7yp2MfzSrdIiZVhrBg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "prisma": "*", + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@prisma/config": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.3.tgz", + "integrity": "sha512-CBPT44BjlQxEt8kiMEauji2WHTDoVBOKl7UlewXmUgBPnr/oPRZC3psci5chJnYmH0ivEIog2OU9PGWoki3DLQ==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "c12": "3.1.0", + "deepmerge-ts": "7.1.5", + "effect": "3.21.0", + "empathic": "2.0.0" + } + }, + "node_modules/@prisma/debug": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.3.tgz", + "integrity": "sha512-ljkJ+SgpXNktLG0Q/n4JGYCkKf0f8oYLyjImS2I8e2q2WCfdRRtWER062ZV/ixaNP2M2VKlWXVJiGzZaUgbKZw==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.3.tgz", + "integrity": "sha512-RSYxtlYFl5pJ8ZePgMv0lZ9IzVCOdTPOegrs2qcbAEFrBI1G33h6wyC9kjQvo0DnYEhEVY0X4LsuFHXLKQk88g==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.3", + "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "@prisma/fetch-engine": "6.19.3", + "@prisma/get-platform": "6.19.3" + } + }, + "node_modules/@prisma/engines-version": { + "version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz", + "integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/fetch-engine": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.3.tgz", + "integrity": "sha512-tKtl/qco9Nt7LU5iKhpultD8O4vMCZcU2CHjNTnRrL1QvSUr5W/GcyFPjNL87GtRrwBc7ubXXD9xy4EvLvt8JA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.3", + "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "@prisma/get-platform": "6.19.3" + } + }, + "node_modules/@prisma/get-platform": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.3.tgz", + "integrity": "sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.3" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-kCFuWS0ebDbmxs0AXYn6e2r2nrGAb5KwQhknjSPSPgJcGd8+HVSILlUyFhGqML2gk39HcG7D1ydW9/qpYkN00Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.8", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", + "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/morgan": { + "version": "1.9.10", + "resolved": "https://registry.npmjs.org/@types/morgan/-/morgan-1.9.10.tgz", + "integrity": "sha512-sS4A1zheMvsADRVfT0lYbJ4S9lmsey8Zo2F7cnbYjWHP67Q0AwMYuuzLlkIM2N8gAbb9cubhIVFwcIN2XyYCkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", + "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "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.15.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", + "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/basic-auth/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/c12": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", + "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.3", + "confbox": "^0.2.2", + "defu": "^6.1.4", + "dotenv": "^16.6.1", + "exsolve": "^1.0.7", + "giget": "^2.0.0", + "jiti": "^2.4.2", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "perfect-debounce": "^1.0.0", + "pkg-types": "^2.2.0", + "rc9": "^2.1.2" + }, + "peerDependencies": { + "magicast": "^0.3.5" + }, + "peerDependenciesMeta": { + "magicast": { + "optional": true + } + } + }, + "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/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/citty": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", + "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "consola": "^3.2.3" + } + }, + "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/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/deepmerge-ts": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", + "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "devOptional": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "devOptional": true, + "license": "MIT" + }, + "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/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "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/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/effect": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz", + "integrity": "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "fast-check": "^3.23.1" + } + }, + "node_modules/empathic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", + "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "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.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/fast-check": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^6.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "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/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.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/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/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/giget": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", + "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.4.0", + "defu": "^6.1.4", + "node-fetch-native": "^1.6.6", + "nypm": "^0.6.0", + "pathe": "^2.0.3" + }, + "bin": { + "giget": "dist/cli.mjs" + } + }, + "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.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/helmet": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz", + "integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "devOptional": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/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/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "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/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "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/mime-types/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/morgan": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz", + "integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==", + "license": "MIT", + "dependencies": { + "basic-auth": "~2.0.1", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-finished": "~2.3.0", + "on-headers": "~1.1.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/morgan/node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-cron": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz", + "integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/nypm": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.5.tgz", + "integrity": "sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "citty": "^0.2.0", + "pathe": "^2.0.3", + "tinyexec": "^1.0.2" + }, + "bin": { + "nypm": "dist/cli.mjs" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/nypm/node_modules/citty": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz", + "integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/pkg-types": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", + "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.2.2", + "exsolve": "^1.0.7", + "pathe": "^2.0.3" + } + }, + "node_modules/prisma": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz", + "integrity": "sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/config": "6.19.3", + "@prisma/engines": "6.19.3" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "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/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/rc9": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", + "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "defu": "^6.1.4", + "destr": "^2.0.3" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/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/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/tinyexec": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", + "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..f17de06 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,47 @@ +{ + "name": "proxpanel-backend", + "version": "1.0.0", + "private": true, + "description": "Production API for ProxPanel (Proxmox VE SaaS control panel)", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc -p tsconfig.json", + "start": "node dist/index.js", + "test": "node --test dist/tests/**/*.test.js", + "prisma:generate": "prisma generate", + "prisma:migrate": "prisma migrate dev", + "prisma:deploy": "prisma migrate deploy", + "prisma:push": "prisma db push", + "prisma:seed": "prisma db seed", + "prisma:validate": "prisma validate" + }, + "prisma": { + "seed": "tsx prisma/seed.ts" + }, + "dependencies": { + "@prisma/client": "^6.6.0", + "axios": "^1.9.0", + "bcryptjs": "^2.4.3", + "compression": "^1.8.0", + "cors": "^2.8.5", + "dotenv": "^16.5.0", + "express": "^4.21.2", + "helmet": "^8.1.0", + "jsonwebtoken": "^9.0.2", + "morgan": "^1.10.0", + "node-cron": "^4.0.7", + "zod": "^3.24.3" + }, + "devDependencies": { + "@types/bcryptjs": "^2.4.6", + "@types/compression": "^1.7.5", + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/jsonwebtoken": "^9.0.9", + "@types/morgan": "^1.9.9", + "@types/node": "^22.15.2", + "prisma": "^6.6.0", + "tsx": "^4.19.4", + "typescript": "^5.8.3" + } +} diff --git a/backend/prisma/migrations/20260417120000_init/migration.sql b/backend/prisma/migrations/20260417120000_init/migration.sql new file mode 100644 index 0000000..0a59445 --- /dev/null +++ b/backend/prisma/migrations/20260417120000_init/migration.sql @@ -0,0 +1,1352 @@ +-- CreateSchema +CREATE SCHEMA IF NOT EXISTS "public"; + +-- CreateEnum +CREATE TYPE "Role" AS ENUM ('SUPER_ADMIN', 'TENANT_ADMIN', 'OPERATOR', 'VIEWER'); + +-- CreateEnum +CREATE TYPE "TenantStatus" AS ENUM ('ACTIVE', 'SUSPENDED', 'TRIAL', 'CANCELLED'); + +-- CreateEnum +CREATE TYPE "VmStatus" AS ENUM ('RUNNING', 'STOPPED', 'PAUSED', 'MIGRATING', 'ERROR'); + +-- CreateEnum +CREATE TYPE "VmType" AS ENUM ('QEMU', 'LXC'); + +-- CreateEnum +CREATE TYPE "NodeStatus" AS ENUM ('ONLINE', 'OFFLINE', 'MAINTENANCE'); + +-- CreateEnum +CREATE TYPE "Currency" AS ENUM ('NGN', 'USD', 'GHS', 'KES', 'ZAR'); + +-- CreateEnum +CREATE TYPE "PaymentProvider" AS ENUM ('PAYSTACK', 'FLUTTERWAVE', 'MANUAL'); + +-- CreateEnum +CREATE TYPE "InvoiceStatus" AS ENUM ('DRAFT', 'PENDING', 'PAID', 'OVERDUE', 'CANCELLED', 'REFUNDED'); + +-- CreateEnum +CREATE TYPE "BackupStatus" AS ENUM ('PENDING', 'RUNNING', 'COMPLETED', 'FAILED', 'EXPIRED'); + +-- CreateEnum +CREATE TYPE "BackupType" AS ENUM ('FULL', 'INCREMENTAL', 'SNAPSHOT'); + +-- CreateEnum +CREATE TYPE "BackupSchedule" AS ENUM ('MANUAL', 'DAILY', 'WEEKLY', 'MONTHLY'); + +-- CreateEnum +CREATE TYPE "BackupSource" AS ENUM ('LOCAL', 'PBS', 'REMOTE'); + +-- CreateEnum +CREATE TYPE "BackupRestoreMode" AS ENUM ('FULL_VM', 'FILES', 'SINGLE_FILE'); + +-- CreateEnum +CREATE TYPE "BackupRestoreStatus" AS ENUM ('PENDING', 'RUNNING', 'COMPLETED', 'FAILED'); + +-- CreateEnum +CREATE TYPE "SnapshotFrequency" AS ENUM ('HOURLY', 'DAILY', 'WEEKLY'); + +-- CreateEnum +CREATE TYPE "Severity" AS ENUM ('INFO', 'WARNING', 'ERROR', 'CRITICAL'); + +-- CreateEnum +CREATE TYPE "SecurityStatus" AS ENUM ('OPEN', 'INVESTIGATING', 'RESOLVED', 'FALSE_POSITIVE'); + +-- CreateEnum +CREATE TYPE "Direction" AS ENUM ('INBOUND', 'OUTBOUND', 'BOTH'); + +-- CreateEnum +CREATE TYPE "FirewallAction" AS ENUM ('ALLOW', 'DENY', 'RATE_LIMIT', 'LOG'); + +-- CreateEnum +CREATE TYPE "Protocol" AS ENUM ('TCP', 'UDP', 'ICMP', 'ANY'); + +-- CreateEnum +CREATE TYPE "AppliesTo" AS ENUM ('ALL_NODES', 'ALL_VMS', 'SPECIFIC_NODE', 'SPECIFIC_VM'); + +-- CreateEnum +CREATE TYPE "ResourceType" AS ENUM ('VM', 'TENANT', 'USER', 'BACKUP', 'INVOICE', 'NODE', 'NETWORK', 'SYSTEM', 'SECURITY', 'BILLING'); + +-- CreateEnum +CREATE TYPE "SettingType" AS ENUM ('PROXMOX', 'PAYMENT', 'EMAIL', 'SECURITY', 'NETWORK', 'GENERAL'); + +-- CreateEnum +CREATE TYPE "OperationTaskStatus" AS ENUM ('QUEUED', 'RUNNING', 'SUCCESS', 'FAILED', 'RETRYING', 'CANCELED'); + +-- CreateEnum +CREATE TYPE "OperationTaskType" AS ENUM ('VM_POWER', 'VM_MIGRATION', 'VM_REINSTALL', 'VM_NETWORK', 'VM_CONFIG', 'VM_BACKUP', 'VM_SNAPSHOT', 'VM_CREATE', 'VM_DELETE', 'SYSTEM_SYNC'); + +-- CreateEnum +CREATE TYPE "PowerScheduleAction" AS ENUM ('START', 'STOP', 'RESTART', 'SHUTDOWN'); + +-- CreateEnum +CREATE TYPE "TemplateType" AS ENUM ('APPLICATION', 'KVM_TEMPLATE', 'LXC_TEMPLATE', 'ISO_IMAGE', 'ARCHIVE'); + +-- CreateEnum +CREATE TYPE "ProductType" AS ENUM ('VPS', 'CLOUD'); + +-- CreateEnum +CREATE TYPE "ServiceLifecycleStatus" AS ENUM ('ACTIVE', 'SUSPENDED', 'TERMINATED'); + +-- CreateEnum +CREATE TYPE "IpVersion" AS ENUM ('IPV4', 'IPV6'); + +-- CreateEnum +CREATE TYPE "IpScope" AS ENUM ('PUBLIC', 'PRIVATE'); + +-- CreateEnum +CREATE TYPE "IpAddressStatus" AS ENUM ('AVAILABLE', 'ASSIGNED', 'RESERVED', 'RETIRED'); + +-- CreateEnum +CREATE TYPE "IpAssignmentType" AS ENUM ('PRIMARY', 'ADDITIONAL', 'FLOATING'); + +-- CreateEnum +CREATE TYPE "PrivateNetworkType" AS ENUM ('BRIDGE', 'VLAN', 'SDN_ZONE', 'VNET'); + +-- CreateEnum +CREATE TYPE "PrivateNetworkAttachmentStatus" AS ENUM ('ATTACHED', 'DETACHED'); + +-- CreateEnum +CREATE TYPE "IpAllocationStrategy" AS ENUM ('FIRST_AVAILABLE', 'BEST_FIT'); + +-- CreateEnum +CREATE TYPE "HealthCheckTargetType" AS ENUM ('NODE', 'VM', 'CLUSTER'); + +-- CreateEnum +CREATE TYPE "HealthCheckType" AS ENUM ('CONNECTIVITY', 'RESOURCE_THRESHOLD', 'SERVICE_PORT'); + +-- CreateEnum +CREATE TYPE "HealthCheckStatus" AS ENUM ('PASS', 'WARNING', 'FAIL'); + +-- CreateEnum +CREATE TYPE "MonitoringAlertStatus" AS ENUM ('OPEN', 'ACKNOWLEDGED', 'RESOLVED'); + +-- CreateEnum +CREATE TYPE "AlertChannel" AS ENUM ('EMAIL', 'WEBHOOK', 'IN_APP'); + +-- CreateEnum +CREATE TYPE "AlertDispatchStatus" AS ENUM ('QUEUED', 'SENT', 'FAILED', 'SKIPPED'); + +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL, + "email" TEXT NOT NULL, + "password_hash" TEXT NOT NULL, + "full_name" TEXT, + "role" "Role" NOT NULL DEFAULT 'VIEWER', + "tenant_id" TEXT, + "is_active" BOOLEAN NOT NULL DEFAULT true, + "last_login_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Tenant" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "status" "TenantStatus" NOT NULL DEFAULT 'ACTIVE', + "plan" TEXT NOT NULL DEFAULT 'starter', + "owner_email" TEXT NOT NULL, + "member_emails" JSONB NOT NULL DEFAULT '[]', + "vm_limit" INTEGER NOT NULL DEFAULT 5, + "cpu_limit" INTEGER NOT NULL DEFAULT 16, + "ram_limit_mb" INTEGER NOT NULL DEFAULT 32768, + "disk_limit_gb" INTEGER NOT NULL DEFAULT 500, + "balance" DECIMAL(14,2) NOT NULL DEFAULT 0, + "currency" "Currency" NOT NULL DEFAULT 'NGN', + "payment_provider" "PaymentProvider" NOT NULL DEFAULT 'PAYSTACK', + "notes" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Tenant_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ProxmoxNode" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "hostname" TEXT NOT NULL, + "status" "NodeStatus" NOT NULL DEFAULT 'OFFLINE', + "cpu_cores" INTEGER NOT NULL DEFAULT 8, + "cpu_usage" DOUBLE PRECISION NOT NULL DEFAULT 0, + "ram_total_mb" INTEGER NOT NULL DEFAULT 32768, + "ram_used_mb" INTEGER NOT NULL DEFAULT 0, + "disk_total_gb" INTEGER NOT NULL DEFAULT 500, + "disk_used_gb" INTEGER NOT NULL DEFAULT 0, + "vm_count" INTEGER NOT NULL DEFAULT 0, + "uptime_seconds" INTEGER NOT NULL DEFAULT 0, + "pve_version" TEXT, + "is_connected" BOOLEAN NOT NULL DEFAULT false, + "last_sync_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ProxmoxNode_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "BillingPlan" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "description" TEXT, + "price_monthly" DECIMAL(12,2) NOT NULL, + "price_hourly" DECIMAL(12,4) NOT NULL, + "currency" "Currency" NOT NULL DEFAULT 'NGN', + "cpu_cores" INTEGER NOT NULL, + "ram_mb" INTEGER NOT NULL, + "disk_gb" INTEGER NOT NULL, + "bandwidth_gb" INTEGER, + "is_active" BOOLEAN NOT NULL DEFAULT true, + "features" JSONB NOT NULL DEFAULT '[]', + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "BillingPlan_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "VirtualMachine" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "vmid" INTEGER NOT NULL, + "status" "VmStatus" NOT NULL DEFAULT 'STOPPED', + "type" "VmType" NOT NULL DEFAULT 'QEMU', + "node" TEXT NOT NULL, + "node_id" TEXT, + "tenant_id" TEXT NOT NULL, + "billing_plan_id" TEXT, + "os_template" TEXT, + "cpu_cores" INTEGER NOT NULL DEFAULT 2, + "ram_mb" INTEGER NOT NULL DEFAULT 2048, + "disk_gb" INTEGER NOT NULL DEFAULT 40, + "ip_address" TEXT, + "cpu_usage" DOUBLE PRECISION NOT NULL DEFAULT 0, + "ram_usage" DOUBLE PRECISION NOT NULL DEFAULT 0, + "disk_usage" DOUBLE PRECISION NOT NULL DEFAULT 0, + "network_in" DOUBLE PRECISION NOT NULL DEFAULT 0, + "network_out" DOUBLE PRECISION NOT NULL DEFAULT 0, + "uptime_seconds" INTEGER NOT NULL DEFAULT 0, + "started_at" TIMESTAMP(3), + "last_backup_at" TIMESTAMP(3), + "proxmox_upid" TEXT, + "last_sync_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "VirtualMachine_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ServerHealthCheck" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "target_type" "HealthCheckTargetType" NOT NULL, + "check_type" "HealthCheckType" NOT NULL DEFAULT 'RESOURCE_THRESHOLD', + "tenant_id" TEXT, + "vm_id" TEXT, + "node_id" TEXT, + "cpu_warn_pct" DOUBLE PRECISION, + "cpu_critical_pct" DOUBLE PRECISION, + "ram_warn_pct" DOUBLE PRECISION, + "ram_critical_pct" DOUBLE PRECISION, + "disk_warn_pct" DOUBLE PRECISION, + "disk_critical_pct" DOUBLE PRECISION, + "disk_io_read_warn" DOUBLE PRECISION, + "disk_io_read_critical" DOUBLE PRECISION, + "disk_io_write_warn" DOUBLE PRECISION, + "disk_io_write_critical" DOUBLE PRECISION, + "network_in_warn" DOUBLE PRECISION, + "network_in_critical" DOUBLE PRECISION, + "network_out_warn" DOUBLE PRECISION, + "network_out_critical" DOUBLE PRECISION, + "latency_warn_ms" INTEGER, + "latency_critical_ms" INTEGER, + "schedule_minutes" INTEGER NOT NULL DEFAULT 5, + "enabled" BOOLEAN NOT NULL DEFAULT true, + "last_run_at" TIMESTAMP(3), + "next_run_at" TIMESTAMP(3), + "created_by" TEXT, + "metadata" JSONB NOT NULL DEFAULT '{}', + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ServerHealthCheck_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ServerHealthCheckResult" ( + "id" TEXT NOT NULL, + "check_id" TEXT NOT NULL, + "status" "HealthCheckStatus" NOT NULL, + "severity" "Severity" NOT NULL DEFAULT 'INFO', + "message" TEXT, + "latency_ms" INTEGER, + "cpu_usage" DOUBLE PRECISION, + "ram_usage" DOUBLE PRECISION, + "disk_usage" DOUBLE PRECISION, + "disk_io_read" DOUBLE PRECISION, + "disk_io_write" DOUBLE PRECISION, + "network_in" DOUBLE PRECISION, + "network_out" DOUBLE PRECISION, + "metadata" JSONB NOT NULL DEFAULT '{}', + "checked_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ServerHealthCheckResult_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "MonitoringAlertRule" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "tenant_id" TEXT, + "vm_id" TEXT, + "node_id" TEXT, + "cpu_threshold_pct" DOUBLE PRECISION, + "ram_threshold_pct" DOUBLE PRECISION, + "disk_threshold_pct" DOUBLE PRECISION, + "disk_io_read_threshold" DOUBLE PRECISION, + "disk_io_write_threshold" DOUBLE PRECISION, + "network_in_threshold" DOUBLE PRECISION, + "network_out_threshold" DOUBLE PRECISION, + "consecutive_breaches" INTEGER NOT NULL DEFAULT 1, + "evaluation_window_minutes" INTEGER NOT NULL DEFAULT 15, + "severity" "Severity" NOT NULL DEFAULT 'WARNING', + "channels" JSONB NOT NULL DEFAULT '["IN_APP"]', + "enabled" BOOLEAN NOT NULL DEFAULT true, + "last_evaluated_at" TIMESTAMP(3), + "created_by" TEXT, + "metadata" JSONB NOT NULL DEFAULT '{}', + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "MonitoringAlertRule_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "MonitoringAlertEvent" ( + "id" TEXT NOT NULL, + "rule_id" TEXT NOT NULL, + "tenant_id" TEXT, + "vm_id" TEXT, + "node_id" TEXT, + "status" "MonitoringAlertStatus" NOT NULL DEFAULT 'OPEN', + "severity" "Severity" NOT NULL DEFAULT 'WARNING', + "title" TEXT NOT NULL, + "message" TEXT, + "metric_key" TEXT, + "trigger_value" DOUBLE PRECISION, + "threshold_value" DOUBLE PRECISION, + "breach_count" INTEGER NOT NULL DEFAULT 1, + "resolved_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "MonitoringAlertEvent_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "MonitoringAlertNotification" ( + "id" TEXT NOT NULL, + "alert_event_id" TEXT NOT NULL, + "channel" "AlertChannel" NOT NULL, + "destination" TEXT, + "status" "AlertDispatchStatus" NOT NULL DEFAULT 'QUEUED', + "provider_message" TEXT, + "sent_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "MonitoringAlertNotification_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "IpAddressPool" ( + "id" TEXT NOT NULL, + "address" TEXT NOT NULL, + "cidr" INTEGER NOT NULL, + "version" "IpVersion" NOT NULL, + "scope" "IpScope" NOT NULL DEFAULT 'PUBLIC', + "status" "IpAddressStatus" NOT NULL DEFAULT 'AVAILABLE', + "gateway" TEXT, + "subnet" TEXT, + "server" TEXT, + "node_id" TEXT, + "node_hostname" TEXT, + "bridge" TEXT, + "vlan_tag" INTEGER, + "sdn_zone" TEXT, + "tags" JSONB NOT NULL DEFAULT '[]', + "metadata" JSONB NOT NULL DEFAULT '{}', + "assigned_vm_id" TEXT, + "assigned_tenant_id" TEXT, + "imported_by" TEXT, + "imported_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "assigned_at" TIMESTAMP(3), + "returned_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "IpAddressPool_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TenantIpQuota" ( + "id" TEXT NOT NULL, + "tenant_id" TEXT NOT NULL, + "ipv4_limit" INTEGER, + "ipv6_limit" INTEGER, + "reserved_ipv4" INTEGER NOT NULL DEFAULT 0, + "reserved_ipv6" INTEGER NOT NULL DEFAULT 0, + "burst_allowed" BOOLEAN NOT NULL DEFAULT false, + "burst_ipv4_limit" INTEGER, + "burst_ipv6_limit" INTEGER, + "is_active" BOOLEAN NOT NULL DEFAULT true, + "metadata" JSONB NOT NULL DEFAULT '{}', + "created_by" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "TenantIpQuota_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "IpReservedRange" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "cidr" TEXT NOT NULL, + "version" "IpVersion" NOT NULL, + "scope" "IpScope" NOT NULL DEFAULT 'PUBLIC', + "tenant_id" TEXT, + "reason" TEXT, + "node_hostname" TEXT, + "bridge" TEXT, + "vlan_tag" INTEGER, + "sdn_zone" TEXT, + "is_active" BOOLEAN NOT NULL DEFAULT true, + "metadata" JSONB NOT NULL DEFAULT '{}', + "created_by" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "IpReservedRange_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "IpPoolPolicy" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "tenant_id" TEXT, + "scope" "IpScope", + "version" "IpVersion", + "node_hostname" TEXT, + "bridge" TEXT, + "vlan_tag" INTEGER, + "sdn_zone" TEXT, + "allocation_strategy" "IpAllocationStrategy" NOT NULL DEFAULT 'BEST_FIT', + "enforce_quota" BOOLEAN NOT NULL DEFAULT true, + "disallow_reserved_use" BOOLEAN NOT NULL DEFAULT true, + "is_active" BOOLEAN NOT NULL DEFAULT true, + "priority" INTEGER NOT NULL DEFAULT 100, + "metadata" JSONB NOT NULL DEFAULT '{}', + "created_by" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "IpPoolPolicy_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "IpAssignment" ( + "id" TEXT NOT NULL, + "ip_address_id" TEXT NOT NULL, + "vm_id" TEXT NOT NULL, + "tenant_id" TEXT, + "assignment_type" "IpAssignmentType" NOT NULL DEFAULT 'ADDITIONAL', + "interface_name" TEXT, + "notes" TEXT, + "metadata" JSONB NOT NULL DEFAULT '{}', + "assigned_by" TEXT, + "assigned_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "released_at" TIMESTAMP(3), + "is_active" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "IpAssignment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "PrivateNetwork" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "network_type" "PrivateNetworkType" NOT NULL DEFAULT 'VLAN', + "cidr" TEXT NOT NULL, + "gateway" TEXT, + "bridge" TEXT, + "vlan_tag" INTEGER, + "sdn_zone" TEXT, + "server" TEXT, + "node_hostname" TEXT, + "is_private" BOOLEAN NOT NULL DEFAULT true, + "metadata" JSONB NOT NULL DEFAULT '{}', + "created_by" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "PrivateNetwork_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "PrivateNetworkAttachment" ( + "id" TEXT NOT NULL, + "network_id" TEXT NOT NULL, + "vm_id" TEXT NOT NULL, + "tenant_id" TEXT, + "interface_name" TEXT, + "requested_ip" TEXT, + "status" "PrivateNetworkAttachmentStatus" NOT NULL DEFAULT 'ATTACHED', + "metadata" JSONB NOT NULL DEFAULT '{}', + "attached_by" TEXT, + "attached_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "detached_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "PrivateNetworkAttachment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "AppTemplate" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "template_type" "TemplateType" NOT NULL, + "virtualization_type" "VmType", + "source" TEXT, + "description" TEXT, + "default_cloud_init" TEXT, + "metadata" JSONB NOT NULL DEFAULT '{}', + "is_active" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "AppTemplate_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ApplicationGroup" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "description" TEXT, + "is_active" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ApplicationGroup_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ApplicationGroupTemplate" ( + "id" TEXT NOT NULL, + "group_id" TEXT NOT NULL, + "template_id" TEXT NOT NULL, + "priority" INTEGER NOT NULL DEFAULT 100, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ApplicationGroupTemplate_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "NodePlacementPolicy" ( + "id" TEXT NOT NULL, + "group_id" TEXT, + "node_id" TEXT, + "product_type" "ProductType", + "cpu_weight" INTEGER NOT NULL DEFAULT 40, + "ram_weight" INTEGER NOT NULL DEFAULT 30, + "disk_weight" INTEGER NOT NULL DEFAULT 20, + "vm_count_weight" INTEGER NOT NULL DEFAULT 10, + "max_vms" INTEGER, + "min_free_ram_mb" INTEGER, + "min_free_disk_gb" INTEGER, + "is_active" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "NodePlacementPolicy_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "VmIdRange" ( + "id" TEXT NOT NULL, + "node_id" TEXT, + "node_hostname" TEXT NOT NULL, + "application_group_id" TEXT, + "range_start" INTEGER NOT NULL, + "range_end" INTEGER NOT NULL, + "next_vmid" INTEGER NOT NULL, + "is_active" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "VmIdRange_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "OperationTask" ( + "id" TEXT NOT NULL, + "task_type" "OperationTaskType" NOT NULL, + "status" "OperationTaskStatus" NOT NULL DEFAULT 'QUEUED', + "vm_id" TEXT, + "vm_name" TEXT, + "node" TEXT, + "requested_by" TEXT, + "payload" JSONB, + "result" JSONB, + "error_message" TEXT, + "proxmox_upid" TEXT, + "scheduled_for" TIMESTAMP(3), + "started_at" TIMESTAMP(3), + "completed_at" TIMESTAMP(3), + "retry_count" INTEGER NOT NULL DEFAULT 0, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "OperationTask_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "PowerSchedule" ( + "id" TEXT NOT NULL, + "vm_id" TEXT NOT NULL, + "action" "PowerScheduleAction" NOT NULL, + "cron_expression" TEXT NOT NULL, + "timezone" TEXT NOT NULL DEFAULT 'UTC', + "enabled" BOOLEAN NOT NULL DEFAULT true, + "next_run_at" TIMESTAMP(3), + "last_run_at" TIMESTAMP(3), + "created_by" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "PowerSchedule_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ProvisionedService" ( + "id" TEXT NOT NULL, + "service_group_id" TEXT, + "vm_id" TEXT NOT NULL, + "tenant_id" TEXT NOT NULL, + "product_type" "ProductType" NOT NULL, + "lifecycle_status" "ServiceLifecycleStatus" NOT NULL DEFAULT 'ACTIVE', + "application_group_id" TEXT, + "template_id" TEXT, + "package_options" JSONB NOT NULL DEFAULT '{}', + "suspended_reason" TEXT, + "terminated_at" TIMESTAMP(3), + "created_by" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ProvisionedService_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Invoice" ( + "id" TEXT NOT NULL, + "invoice_number" TEXT NOT NULL, + "tenant_id" TEXT NOT NULL, + "tenant_name" TEXT, + "status" "InvoiceStatus" NOT NULL DEFAULT 'PENDING', + "amount" DECIMAL(14,2) NOT NULL, + "currency" "Currency" NOT NULL DEFAULT 'NGN', + "due_date" TIMESTAMP(3) NOT NULL, + "paid_date" TIMESTAMP(3), + "payment_provider" "PaymentProvider" NOT NULL DEFAULT 'MANUAL', + "payment_reference" TEXT, + "payment_url" TEXT, + "line_items" JSONB NOT NULL DEFAULT '[]', + "notes" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Invoice_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "UsageRecord" ( + "id" TEXT NOT NULL, + "vm_id" TEXT NOT NULL, + "vm_name" TEXT NOT NULL, + "tenant_id" TEXT NOT NULL, + "tenant_name" TEXT NOT NULL, + "billing_plan_id" TEXT, + "plan_name" TEXT, + "hours_used" DECIMAL(8,2) NOT NULL DEFAULT 1, + "price_per_hour" DECIMAL(12,4) NOT NULL, + "currency" "Currency" NOT NULL DEFAULT 'NGN', + "total_cost" DECIMAL(14,4) NOT NULL, + "period_start" TIMESTAMP(3) NOT NULL, + "period_end" TIMESTAMP(3) NOT NULL, + "billed" BOOLEAN NOT NULL DEFAULT false, + "invoice_id" TEXT, + "cpu_hours" DECIMAL(10,4), + "ram_gb_hours" DECIMAL(10,4), + "disk_gb_hours" DECIMAL(10,4), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "UsageRecord_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Backup" ( + "id" TEXT NOT NULL, + "vm_id" TEXT NOT NULL, + "vm_name" TEXT NOT NULL, + "tenant_id" TEXT, + "node" TEXT, + "status" "BackupStatus" NOT NULL DEFAULT 'PENDING', + "type" "BackupType" NOT NULL DEFAULT 'FULL', + "source" "BackupSource" NOT NULL DEFAULT 'LOCAL', + "size_mb" DOUBLE PRECISION, + "storage" TEXT, + "backup_path" TEXT, + "pbs_snapshot_id" TEXT, + "route_key" TEXT, + "is_protected" BOOLEAN NOT NULL DEFAULT false, + "restore_enabled" BOOLEAN NOT NULL DEFAULT true, + "total_files" INTEGER, + "schedule" "BackupSchedule" NOT NULL DEFAULT 'MANUAL', + "retention_days" INTEGER NOT NULL DEFAULT 7, + "snapshot_job_id" TEXT, + "started_at" TIMESTAMP(3), + "completed_at" TIMESTAMP(3), + "next_run_at" TIMESTAMP(3), + "expires_at" TIMESTAMP(3), + "notes" TEXT, + "proxmox_upid" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Backup_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "BackupPolicy" ( + "id" TEXT NOT NULL, + "tenant_id" TEXT, + "billing_plan_id" TEXT, + "max_files" INTEGER NOT NULL DEFAULT 10, + "max_total_size_mb" DOUBLE PRECISION NOT NULL DEFAULT 51200, + "max_protected_files" INTEGER NOT NULL DEFAULT 3, + "allow_file_restore" BOOLEAN NOT NULL DEFAULT true, + "allow_cross_vm_restore" BOOLEAN NOT NULL DEFAULT true, + "allow_pbs_restore" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "BackupPolicy_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "BackupRestoreTask" ( + "id" TEXT NOT NULL, + "backup_id" TEXT NOT NULL, + "source_vm_id" TEXT NOT NULL, + "target_vm_id" TEXT NOT NULL, + "mode" "BackupRestoreMode" NOT NULL, + "requested_files" JSONB NOT NULL DEFAULT '[]', + "pbs_enabled" BOOLEAN NOT NULL DEFAULT false, + "status" "BackupRestoreStatus" NOT NULL DEFAULT 'PENDING', + "result_message" TEXT, + "started_at" TIMESTAMP(3), + "completed_at" TIMESTAMP(3), + "created_by" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "BackupRestoreTask_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "SnapshotJob" ( + "id" TEXT NOT NULL, + "vm_id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "frequency" "SnapshotFrequency" NOT NULL DEFAULT 'DAILY', + "interval" INTEGER NOT NULL DEFAULT 1, + "day_of_week" INTEGER, + "hour_utc" INTEGER NOT NULL DEFAULT 2, + "minute_utc" INTEGER NOT NULL DEFAULT 0, + "retention" INTEGER NOT NULL DEFAULT 7, + "enabled" BOOLEAN NOT NULL DEFAULT true, + "next_run_at" TIMESTAMP(3), + "last_run_at" TIMESTAMP(3), + "created_by" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "SnapshotJob_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "AuditLog" ( + "id" TEXT NOT NULL, + "action" TEXT NOT NULL, + "resource_type" "ResourceType" NOT NULL, + "resource_id" TEXT, + "resource_name" TEXT, + "actor_email" TEXT NOT NULL, + "actor_role" TEXT, + "severity" "Severity" NOT NULL DEFAULT 'INFO', + "details" JSONB, + "ip_address" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "SecurityEvent" ( + "id" TEXT NOT NULL, + "event_type" TEXT NOT NULL, + "severity" "Severity" NOT NULL DEFAULT 'WARNING', + "status" "SecurityStatus" NOT NULL DEFAULT 'OPEN', + "source_ip" TEXT, + "source_country" TEXT, + "target_vm_id" TEXT, + "node" TEXT, + "description" TEXT, + "details" JSONB, + "resolved_at" TIMESTAMP(3), + "resolved_by" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "SecurityEvent_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "FirewallRule" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "direction" "Direction" NOT NULL DEFAULT 'INBOUND', + "action" "FirewallAction" NOT NULL DEFAULT 'DENY', + "protocol" "Protocol" NOT NULL DEFAULT 'TCP', + "source_ip" TEXT, + "destination_ip" TEXT, + "port_range" TEXT, + "priority" INTEGER NOT NULL DEFAULT 100, + "enabled" BOOLEAN NOT NULL DEFAULT true, + "applies_to" "AppliesTo" NOT NULL DEFAULT 'ALL_VMS', + "target_id" TEXT, + "hit_count" INTEGER NOT NULL DEFAULT 0, + "description" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "FirewallRule_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Setting" ( + "id" TEXT NOT NULL, + "key" TEXT NOT NULL, + "type" "SettingType" NOT NULL DEFAULT 'GENERAL', + "value" JSONB NOT NULL, + "is_encrypted" BOOLEAN NOT NULL DEFAULT false, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Setting_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- CreateIndex +CREATE INDEX "User_tenant_id_idx" ON "User"("tenant_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "Tenant_slug_key" ON "Tenant"("slug"); + +-- CreateIndex +CREATE INDEX "Tenant_status_idx" ON "Tenant"("status"); + +-- CreateIndex +CREATE INDEX "Tenant_owner_email_idx" ON "Tenant"("owner_email"); + +-- CreateIndex +CREATE UNIQUE INDEX "ProxmoxNode_hostname_key" ON "ProxmoxNode"("hostname"); + +-- CreateIndex +CREATE INDEX "ProxmoxNode_status_idx" ON "ProxmoxNode"("status"); + +-- CreateIndex +CREATE UNIQUE INDEX "BillingPlan_slug_key" ON "BillingPlan"("slug"); + +-- CreateIndex +CREATE INDEX "BillingPlan_is_active_idx" ON "BillingPlan"("is_active"); + +-- CreateIndex +CREATE INDEX "VirtualMachine_tenant_id_idx" ON "VirtualMachine"("tenant_id"); + +-- CreateIndex +CREATE INDEX "VirtualMachine_node_idx" ON "VirtualMachine"("node"); + +-- CreateIndex +CREATE INDEX "VirtualMachine_status_idx" ON "VirtualMachine"("status"); + +-- CreateIndex +CREATE UNIQUE INDEX "VirtualMachine_vmid_node_key" ON "VirtualMachine"("vmid", "node"); + +-- CreateIndex +CREATE INDEX "ServerHealthCheck_enabled_next_run_at_idx" ON "ServerHealthCheck"("enabled", "next_run_at"); + +-- CreateIndex +CREATE INDEX "ServerHealthCheck_tenant_id_enabled_idx" ON "ServerHealthCheck"("tenant_id", "enabled"); + +-- CreateIndex +CREATE INDEX "ServerHealthCheck_vm_id_enabled_idx" ON "ServerHealthCheck"("vm_id", "enabled"); + +-- CreateIndex +CREATE INDEX "ServerHealthCheck_node_id_enabled_idx" ON "ServerHealthCheck"("node_id", "enabled"); + +-- CreateIndex +CREATE INDEX "ServerHealthCheckResult_check_id_checked_at_idx" ON "ServerHealthCheckResult"("check_id", "checked_at"); + +-- CreateIndex +CREATE INDEX "ServerHealthCheckResult_status_checked_at_idx" ON "ServerHealthCheckResult"("status", "checked_at"); + +-- CreateIndex +CREATE INDEX "MonitoringAlertRule_enabled_severity_idx" ON "MonitoringAlertRule"("enabled", "severity"); + +-- CreateIndex +CREATE INDEX "MonitoringAlertRule_tenant_id_enabled_idx" ON "MonitoringAlertRule"("tenant_id", "enabled"); + +-- CreateIndex +CREATE INDEX "MonitoringAlertRule_vm_id_enabled_idx" ON "MonitoringAlertRule"("vm_id", "enabled"); + +-- CreateIndex +CREATE INDEX "MonitoringAlertRule_node_id_enabled_idx" ON "MonitoringAlertRule"("node_id", "enabled"); + +-- CreateIndex +CREATE INDEX "MonitoringAlertEvent_rule_id_status_idx" ON "MonitoringAlertEvent"("rule_id", "status"); + +-- CreateIndex +CREATE INDEX "MonitoringAlertEvent_status_severity_created_at_idx" ON "MonitoringAlertEvent"("status", "severity", "created_at"); + +-- CreateIndex +CREATE INDEX "MonitoringAlertEvent_tenant_id_status_idx" ON "MonitoringAlertEvent"("tenant_id", "status"); + +-- CreateIndex +CREATE INDEX "MonitoringAlertEvent_vm_id_status_idx" ON "MonitoringAlertEvent"("vm_id", "status"); + +-- CreateIndex +CREATE INDEX "MonitoringAlertEvent_node_id_status_idx" ON "MonitoringAlertEvent"("node_id", "status"); + +-- CreateIndex +CREATE INDEX "MonitoringAlertNotification_alert_event_id_status_idx" ON "MonitoringAlertNotification"("alert_event_id", "status"); + +-- CreateIndex +CREATE INDEX "MonitoringAlertNotification_status_created_at_idx" ON "MonitoringAlertNotification"("status", "created_at"); + +-- CreateIndex +CREATE INDEX "IpAddressPool_status_scope_version_idx" ON "IpAddressPool"("status", "scope", "version"); + +-- CreateIndex +CREATE INDEX "IpAddressPool_node_hostname_bridge_vlan_tag_idx" ON "IpAddressPool"("node_hostname", "bridge", "vlan_tag"); + +-- CreateIndex +CREATE INDEX "IpAddressPool_assigned_vm_id_status_idx" ON "IpAddressPool"("assigned_vm_id", "status"); + +-- CreateIndex +CREATE INDEX "IpAddressPool_assigned_tenant_id_status_idx" ON "IpAddressPool"("assigned_tenant_id", "status"); + +-- CreateIndex +CREATE UNIQUE INDEX "IpAddressPool_address_cidr_key" ON "IpAddressPool"("address", "cidr"); + +-- CreateIndex +CREATE UNIQUE INDEX "TenantIpQuota_tenant_id_key" ON "TenantIpQuota"("tenant_id"); + +-- CreateIndex +CREATE INDEX "TenantIpQuota_is_active_idx" ON "TenantIpQuota"("is_active"); + +-- CreateIndex +CREATE INDEX "IpReservedRange_scope_version_is_active_idx" ON "IpReservedRange"("scope", "version", "is_active"); + +-- CreateIndex +CREATE INDEX "IpReservedRange_tenant_id_is_active_idx" ON "IpReservedRange"("tenant_id", "is_active"); + +-- CreateIndex +CREATE INDEX "IpReservedRange_node_hostname_bridge_vlan_tag_is_active_idx" ON "IpReservedRange"("node_hostname", "bridge", "vlan_tag", "is_active"); + +-- CreateIndex +CREATE INDEX "IpPoolPolicy_tenant_id_is_active_priority_idx" ON "IpPoolPolicy"("tenant_id", "is_active", "priority"); + +-- CreateIndex +CREATE INDEX "IpPoolPolicy_scope_version_is_active_idx" ON "IpPoolPolicy"("scope", "version", "is_active"); + +-- CreateIndex +CREATE INDEX "IpPoolPolicy_node_hostname_bridge_vlan_tag_is_active_idx" ON "IpPoolPolicy"("node_hostname", "bridge", "vlan_tag", "is_active"); + +-- CreateIndex +CREATE INDEX "IpAssignment_ip_address_id_is_active_idx" ON "IpAssignment"("ip_address_id", "is_active"); + +-- CreateIndex +CREATE INDEX "IpAssignment_vm_id_is_active_idx" ON "IpAssignment"("vm_id", "is_active"); + +-- CreateIndex +CREATE INDEX "IpAssignment_tenant_id_is_active_idx" ON "IpAssignment"("tenant_id", "is_active"); + +-- CreateIndex +CREATE UNIQUE INDEX "PrivateNetwork_slug_key" ON "PrivateNetwork"("slug"); + +-- CreateIndex +CREATE INDEX "PrivateNetwork_network_type_idx" ON "PrivateNetwork"("network_type"); + +-- CreateIndex +CREATE INDEX "PrivateNetwork_node_hostname_bridge_vlan_tag_idx" ON "PrivateNetwork"("node_hostname", "bridge", "vlan_tag"); + +-- CreateIndex +CREATE INDEX "PrivateNetworkAttachment_vm_id_status_idx" ON "PrivateNetworkAttachment"("vm_id", "status"); + +-- CreateIndex +CREATE INDEX "PrivateNetworkAttachment_tenant_id_status_idx" ON "PrivateNetworkAttachment"("tenant_id", "status"); + +-- CreateIndex +CREATE INDEX "PrivateNetworkAttachment_network_id_status_idx" ON "PrivateNetworkAttachment"("network_id", "status"); + +-- CreateIndex +CREATE UNIQUE INDEX "PrivateNetworkAttachment_network_id_vm_id_interface_name_key" ON "PrivateNetworkAttachment"("network_id", "vm_id", "interface_name"); + +-- CreateIndex +CREATE UNIQUE INDEX "AppTemplate_slug_key" ON "AppTemplate"("slug"); + +-- CreateIndex +CREATE INDEX "AppTemplate_template_type_is_active_idx" ON "AppTemplate"("template_type", "is_active"); + +-- CreateIndex +CREATE UNIQUE INDEX "ApplicationGroup_slug_key" ON "ApplicationGroup"("slug"); + +-- CreateIndex +CREATE INDEX "ApplicationGroup_is_active_idx" ON "ApplicationGroup"("is_active"); + +-- CreateIndex +CREATE INDEX "ApplicationGroupTemplate_priority_idx" ON "ApplicationGroupTemplate"("priority"); + +-- CreateIndex +CREATE UNIQUE INDEX "ApplicationGroupTemplate_group_id_template_id_key" ON "ApplicationGroupTemplate"("group_id", "template_id"); + +-- CreateIndex +CREATE INDEX "NodePlacementPolicy_group_id_is_active_idx" ON "NodePlacementPolicy"("group_id", "is_active"); + +-- CreateIndex +CREATE INDEX "NodePlacementPolicy_node_id_is_active_idx" ON "NodePlacementPolicy"("node_id", "is_active"); + +-- CreateIndex +CREATE INDEX "NodePlacementPolicy_product_type_is_active_idx" ON "NodePlacementPolicy"("product_type", "is_active"); + +-- CreateIndex +CREATE INDEX "VmIdRange_node_hostname_is_active_idx" ON "VmIdRange"("node_hostname", "is_active"); + +-- CreateIndex +CREATE INDEX "VmIdRange_application_group_id_is_active_idx" ON "VmIdRange"("application_group_id", "is_active"); + +-- CreateIndex +CREATE UNIQUE INDEX "VmIdRange_node_hostname_range_start_range_end_key" ON "VmIdRange"("node_hostname", "range_start", "range_end"); + +-- CreateIndex +CREATE INDEX "OperationTask_status_idx" ON "OperationTask"("status"); + +-- CreateIndex +CREATE INDEX "OperationTask_task_type_idx" ON "OperationTask"("task_type"); + +-- CreateIndex +CREATE INDEX "OperationTask_vm_id_idx" ON "OperationTask"("vm_id"); + +-- CreateIndex +CREATE INDEX "OperationTask_created_at_idx" ON "OperationTask"("created_at"); + +-- CreateIndex +CREATE INDEX "PowerSchedule_vm_id_idx" ON "PowerSchedule"("vm_id"); + +-- CreateIndex +CREATE INDEX "PowerSchedule_enabled_next_run_at_idx" ON "PowerSchedule"("enabled", "next_run_at"); + +-- CreateIndex +CREATE UNIQUE INDEX "ProvisionedService_vm_id_key" ON "ProvisionedService"("vm_id"); + +-- CreateIndex +CREATE INDEX "ProvisionedService_tenant_id_lifecycle_status_idx" ON "ProvisionedService"("tenant_id", "lifecycle_status"); + +-- CreateIndex +CREATE INDEX "ProvisionedService_service_group_id_idx" ON "ProvisionedService"("service_group_id"); + +-- CreateIndex +CREATE INDEX "ProvisionedService_application_group_id_idx" ON "ProvisionedService"("application_group_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "Invoice_invoice_number_key" ON "Invoice"("invoice_number"); + +-- CreateIndex +CREATE INDEX "Invoice_tenant_id_idx" ON "Invoice"("tenant_id"); + +-- CreateIndex +CREATE INDEX "Invoice_status_idx" ON "Invoice"("status"); + +-- CreateIndex +CREATE INDEX "Invoice_due_date_idx" ON "Invoice"("due_date"); + +-- CreateIndex +CREATE INDEX "UsageRecord_vm_id_idx" ON "UsageRecord"("vm_id"); + +-- CreateIndex +CREATE INDEX "UsageRecord_tenant_id_idx" ON "UsageRecord"("tenant_id"); + +-- CreateIndex +CREATE INDEX "UsageRecord_period_start_idx" ON "UsageRecord"("period_start"); + +-- CreateIndex +CREATE INDEX "UsageRecord_billed_idx" ON "UsageRecord"("billed"); + +-- CreateIndex +CREATE UNIQUE INDEX "UsageRecord_vm_id_period_start_period_end_key" ON "UsageRecord"("vm_id", "period_start", "period_end"); + +-- CreateIndex +CREATE INDEX "Backup_vm_id_idx" ON "Backup"("vm_id"); + +-- CreateIndex +CREATE INDEX "Backup_tenant_id_idx" ON "Backup"("tenant_id"); + +-- CreateIndex +CREATE INDEX "Backup_status_idx" ON "Backup"("status"); + +-- CreateIndex +CREATE INDEX "Backup_next_run_at_idx" ON "Backup"("next_run_at"); + +-- CreateIndex +CREATE INDEX "Backup_snapshot_job_id_idx" ON "Backup"("snapshot_job_id"); + +-- CreateIndex +CREATE INDEX "BackupPolicy_tenant_id_idx" ON "BackupPolicy"("tenant_id"); + +-- CreateIndex +CREATE INDEX "BackupPolicy_billing_plan_id_idx" ON "BackupPolicy"("billing_plan_id"); + +-- CreateIndex +CREATE INDEX "BackupRestoreTask_backup_id_idx" ON "BackupRestoreTask"("backup_id"); + +-- CreateIndex +CREATE INDEX "BackupRestoreTask_source_vm_id_idx" ON "BackupRestoreTask"("source_vm_id"); + +-- CreateIndex +CREATE INDEX "BackupRestoreTask_target_vm_id_idx" ON "BackupRestoreTask"("target_vm_id"); + +-- CreateIndex +CREATE INDEX "BackupRestoreTask_status_idx" ON "BackupRestoreTask"("status"); + +-- CreateIndex +CREATE INDEX "SnapshotJob_vm_id_idx" ON "SnapshotJob"("vm_id"); + +-- CreateIndex +CREATE INDEX "SnapshotJob_enabled_next_run_at_idx" ON "SnapshotJob"("enabled", "next_run_at"); + +-- CreateIndex +CREATE INDEX "AuditLog_resource_type_idx" ON "AuditLog"("resource_type"); + +-- CreateIndex +CREATE INDEX "AuditLog_severity_idx" ON "AuditLog"("severity"); + +-- CreateIndex +CREATE INDEX "AuditLog_created_at_idx" ON "AuditLog"("created_at"); + +-- CreateIndex +CREATE INDEX "SecurityEvent_status_idx" ON "SecurityEvent"("status"); + +-- CreateIndex +CREATE INDEX "SecurityEvent_severity_idx" ON "SecurityEvent"("severity"); + +-- CreateIndex +CREATE INDEX "SecurityEvent_created_at_idx" ON "SecurityEvent"("created_at"); + +-- CreateIndex +CREATE INDEX "FirewallRule_enabled_idx" ON "FirewallRule"("enabled"); + +-- CreateIndex +CREATE INDEX "FirewallRule_priority_idx" ON "FirewallRule"("priority"); + +-- CreateIndex +CREATE UNIQUE INDEX "Setting_key_key" ON "Setting"("key"); + +-- AddForeignKey +ALTER TABLE "User" ADD CONSTRAINT "User_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "Tenant"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "VirtualMachine" ADD CONSTRAINT "VirtualMachine_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "VirtualMachine" ADD CONSTRAINT "VirtualMachine_node_id_fkey" FOREIGN KEY ("node_id") REFERENCES "ProxmoxNode"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "VirtualMachine" ADD CONSTRAINT "VirtualMachine_billing_plan_id_fkey" FOREIGN KEY ("billing_plan_id") REFERENCES "BillingPlan"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ServerHealthCheck" ADD CONSTRAINT "ServerHealthCheck_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "Tenant"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ServerHealthCheck" ADD CONSTRAINT "ServerHealthCheck_vm_id_fkey" FOREIGN KEY ("vm_id") REFERENCES "VirtualMachine"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ServerHealthCheck" ADD CONSTRAINT "ServerHealthCheck_node_id_fkey" FOREIGN KEY ("node_id") REFERENCES "ProxmoxNode"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ServerHealthCheckResult" ADD CONSTRAINT "ServerHealthCheckResult_check_id_fkey" FOREIGN KEY ("check_id") REFERENCES "ServerHealthCheck"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MonitoringAlertRule" ADD CONSTRAINT "MonitoringAlertRule_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "Tenant"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MonitoringAlertRule" ADD CONSTRAINT "MonitoringAlertRule_vm_id_fkey" FOREIGN KEY ("vm_id") REFERENCES "VirtualMachine"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MonitoringAlertRule" ADD CONSTRAINT "MonitoringAlertRule_node_id_fkey" FOREIGN KEY ("node_id") REFERENCES "ProxmoxNode"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MonitoringAlertEvent" ADD CONSTRAINT "MonitoringAlertEvent_rule_id_fkey" FOREIGN KEY ("rule_id") REFERENCES "MonitoringAlertRule"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MonitoringAlertEvent" ADD CONSTRAINT "MonitoringAlertEvent_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "Tenant"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MonitoringAlertEvent" ADD CONSTRAINT "MonitoringAlertEvent_vm_id_fkey" FOREIGN KEY ("vm_id") REFERENCES "VirtualMachine"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MonitoringAlertEvent" ADD CONSTRAINT "MonitoringAlertEvent_node_id_fkey" FOREIGN KEY ("node_id") REFERENCES "ProxmoxNode"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MonitoringAlertNotification" ADD CONSTRAINT "MonitoringAlertNotification_alert_event_id_fkey" FOREIGN KEY ("alert_event_id") REFERENCES "MonitoringAlertEvent"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "IpAddressPool" ADD CONSTRAINT "IpAddressPool_assigned_vm_id_fkey" FOREIGN KEY ("assigned_vm_id") REFERENCES "VirtualMachine"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "IpAddressPool" ADD CONSTRAINT "IpAddressPool_assigned_tenant_id_fkey" FOREIGN KEY ("assigned_tenant_id") REFERENCES "Tenant"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TenantIpQuota" ADD CONSTRAINT "TenantIpQuota_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "IpReservedRange" ADD CONSTRAINT "IpReservedRange_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "Tenant"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "IpPoolPolicy" ADD CONSTRAINT "IpPoolPolicy_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "Tenant"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "IpAssignment" ADD CONSTRAINT "IpAssignment_ip_address_id_fkey" FOREIGN KEY ("ip_address_id") REFERENCES "IpAddressPool"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "IpAssignment" ADD CONSTRAINT "IpAssignment_vm_id_fkey" FOREIGN KEY ("vm_id") REFERENCES "VirtualMachine"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "IpAssignment" ADD CONSTRAINT "IpAssignment_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "Tenant"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PrivateNetworkAttachment" ADD CONSTRAINT "PrivateNetworkAttachment_network_id_fkey" FOREIGN KEY ("network_id") REFERENCES "PrivateNetwork"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PrivateNetworkAttachment" ADD CONSTRAINT "PrivateNetworkAttachment_vm_id_fkey" FOREIGN KEY ("vm_id") REFERENCES "VirtualMachine"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PrivateNetworkAttachment" ADD CONSTRAINT "PrivateNetworkAttachment_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "Tenant"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ApplicationGroupTemplate" ADD CONSTRAINT "ApplicationGroupTemplate_group_id_fkey" FOREIGN KEY ("group_id") REFERENCES "ApplicationGroup"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ApplicationGroupTemplate" ADD CONSTRAINT "ApplicationGroupTemplate_template_id_fkey" FOREIGN KEY ("template_id") REFERENCES "AppTemplate"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "NodePlacementPolicy" ADD CONSTRAINT "NodePlacementPolicy_group_id_fkey" FOREIGN KEY ("group_id") REFERENCES "ApplicationGroup"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "NodePlacementPolicy" ADD CONSTRAINT "NodePlacementPolicy_node_id_fkey" FOREIGN KEY ("node_id") REFERENCES "ProxmoxNode"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "VmIdRange" ADD CONSTRAINT "VmIdRange_node_id_fkey" FOREIGN KEY ("node_id") REFERENCES "ProxmoxNode"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "VmIdRange" ADD CONSTRAINT "VmIdRange_application_group_id_fkey" FOREIGN KEY ("application_group_id") REFERENCES "ApplicationGroup"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OperationTask" ADD CONSTRAINT "OperationTask_vm_id_fkey" FOREIGN KEY ("vm_id") REFERENCES "VirtualMachine"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PowerSchedule" ADD CONSTRAINT "PowerSchedule_vm_id_fkey" FOREIGN KEY ("vm_id") REFERENCES "VirtualMachine"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ProvisionedService" ADD CONSTRAINT "ProvisionedService_vm_id_fkey" FOREIGN KEY ("vm_id") REFERENCES "VirtualMachine"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ProvisionedService" ADD CONSTRAINT "ProvisionedService_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ProvisionedService" ADD CONSTRAINT "ProvisionedService_application_group_id_fkey" FOREIGN KEY ("application_group_id") REFERENCES "ApplicationGroup"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ProvisionedService" ADD CONSTRAINT "ProvisionedService_template_id_fkey" FOREIGN KEY ("template_id") REFERENCES "AppTemplate"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Invoice" ADD CONSTRAINT "Invoice_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "UsageRecord" ADD CONSTRAINT "UsageRecord_vm_id_fkey" FOREIGN KEY ("vm_id") REFERENCES "VirtualMachine"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "UsageRecord" ADD CONSTRAINT "UsageRecord_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "UsageRecord" ADD CONSTRAINT "UsageRecord_billing_plan_id_fkey" FOREIGN KEY ("billing_plan_id") REFERENCES "BillingPlan"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "UsageRecord" ADD CONSTRAINT "UsageRecord_invoice_id_fkey" FOREIGN KEY ("invoice_id") REFERENCES "Invoice"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Backup" ADD CONSTRAINT "Backup_vm_id_fkey" FOREIGN KEY ("vm_id") REFERENCES "VirtualMachine"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Backup" ADD CONSTRAINT "Backup_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "Tenant"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Backup" ADD CONSTRAINT "Backup_snapshot_job_id_fkey" FOREIGN KEY ("snapshot_job_id") REFERENCES "SnapshotJob"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "BackupPolicy" ADD CONSTRAINT "BackupPolicy_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "BackupPolicy" ADD CONSTRAINT "BackupPolicy_billing_plan_id_fkey" FOREIGN KEY ("billing_plan_id") REFERENCES "BillingPlan"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "BackupRestoreTask" ADD CONSTRAINT "BackupRestoreTask_backup_id_fkey" FOREIGN KEY ("backup_id") REFERENCES "Backup"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "BackupRestoreTask" ADD CONSTRAINT "BackupRestoreTask_source_vm_id_fkey" FOREIGN KEY ("source_vm_id") REFERENCES "VirtualMachine"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "BackupRestoreTask" ADD CONSTRAINT "BackupRestoreTask_target_vm_id_fkey" FOREIGN KEY ("target_vm_id") REFERENCES "VirtualMachine"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "SnapshotJob" ADD CONSTRAINT "SnapshotJob_vm_id_fkey" FOREIGN KEY ("vm_id") REFERENCES "VirtualMachine"("id") ON DELETE CASCADE ON UPDATE CASCADE; + diff --git a/backend/prisma/migrations/migration_lock.toml b/backend/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..2fe25d8 --- /dev/null +++ b/backend/prisma/migrations/migration_lock.toml @@ -0,0 +1 @@ +provider = "postgresql" diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma new file mode 100644 index 0000000..bc394e1 --- /dev/null +++ b/backend/prisma/schema.prisma @@ -0,0 +1,1205 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +enum Role { + SUPER_ADMIN + TENANT_ADMIN + OPERATOR + VIEWER +} + +enum TenantStatus { + ACTIVE + SUSPENDED + TRIAL + CANCELLED +} + +enum VmStatus { + RUNNING + STOPPED + PAUSED + MIGRATING + ERROR +} + +enum VmType { + QEMU + LXC +} + +enum NodeStatus { + ONLINE + OFFLINE + MAINTENANCE +} + +enum Currency { + NGN + USD + GHS + KES + ZAR +} + +enum PaymentProvider { + PAYSTACK + FLUTTERWAVE + MANUAL +} + +enum InvoiceStatus { + DRAFT + PENDING + PAID + OVERDUE + CANCELLED + REFUNDED +} + +enum BackupStatus { + PENDING + RUNNING + COMPLETED + FAILED + EXPIRED +} + +enum BackupType { + FULL + INCREMENTAL + SNAPSHOT +} + +enum BackupSchedule { + MANUAL + DAILY + WEEKLY + MONTHLY +} + +enum BackupSource { + LOCAL + PBS + REMOTE +} + +enum BackupRestoreMode { + FULL_VM + FILES + SINGLE_FILE +} + +enum BackupRestoreStatus { + PENDING + RUNNING + COMPLETED + FAILED +} + +enum SnapshotFrequency { + HOURLY + DAILY + WEEKLY +} + +enum Severity { + INFO + WARNING + ERROR + CRITICAL +} + +enum SecurityStatus { + OPEN + INVESTIGATING + RESOLVED + FALSE_POSITIVE +} + +enum Direction { + INBOUND + OUTBOUND + BOTH +} + +enum FirewallAction { + ALLOW + DENY + RATE_LIMIT + LOG +} + +enum Protocol { + TCP + UDP + ICMP + ANY +} + +enum AppliesTo { + ALL_NODES + ALL_VMS + SPECIFIC_NODE + SPECIFIC_VM +} + +enum ResourceType { + VM + TENANT + USER + BACKUP + INVOICE + NODE + NETWORK + SYSTEM + SECURITY + BILLING +} + +enum SettingType { + PROXMOX + PAYMENT + EMAIL + SECURITY + NETWORK + GENERAL +} + +enum OperationTaskStatus { + QUEUED + RUNNING + SUCCESS + FAILED + RETRYING + CANCELED +} + +enum OperationTaskType { + VM_POWER + VM_MIGRATION + VM_REINSTALL + VM_NETWORK + VM_CONFIG + VM_BACKUP + VM_SNAPSHOT + VM_CREATE + VM_DELETE + SYSTEM_SYNC +} + +enum PowerScheduleAction { + START + STOP + RESTART + SHUTDOWN +} + +enum TemplateType { + APPLICATION + KVM_TEMPLATE + LXC_TEMPLATE + ISO_IMAGE + ARCHIVE +} + +enum ProductType { + VPS + CLOUD +} + +enum ServiceLifecycleStatus { + ACTIVE + SUSPENDED + TERMINATED +} + +enum IpVersion { + IPV4 + IPV6 +} + +enum IpScope { + PUBLIC + PRIVATE +} + +enum IpAddressStatus { + AVAILABLE + ASSIGNED + RESERVED + RETIRED +} + +enum IpAssignmentType { + PRIMARY + ADDITIONAL + FLOATING +} + +enum PrivateNetworkType { + BRIDGE + VLAN + SDN_ZONE + VNET +} + +enum PrivateNetworkAttachmentStatus { + ATTACHED + DETACHED +} + +enum IpAllocationStrategy { + FIRST_AVAILABLE + BEST_FIT +} + +enum HealthCheckTargetType { + NODE + VM + CLUSTER +} + +enum HealthCheckType { + CONNECTIVITY + RESOURCE_THRESHOLD + SERVICE_PORT +} + +enum HealthCheckStatus { + PASS + WARNING + FAIL +} + +enum MonitoringAlertStatus { + OPEN + ACKNOWLEDGED + RESOLVED +} + +enum AlertChannel { + EMAIL + WEBHOOK + IN_APP +} + +enum AlertDispatchStatus { + QUEUED + SENT + FAILED + SKIPPED +} + +model User { + id String @id @default(cuid()) + email String @unique + password_hash String + full_name String? + role Role @default(VIEWER) + tenant_id String? + is_active Boolean @default(true) + last_login_at DateTime? + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + tenant Tenant? @relation(fields: [tenant_id], references: [id], onDelete: SetNull) + + @@index([tenant_id]) +} + +model Tenant { + id String @id @default(cuid()) + name String + slug String @unique + status TenantStatus @default(ACTIVE) + plan String @default("starter") + owner_email String + member_emails Json @default("[]") + vm_limit Int @default(5) + cpu_limit Int @default(16) + ram_limit_mb Int @default(32768) + disk_limit_gb Int @default(500) + balance Decimal @default(0) @db.Decimal(14, 2) + currency Currency @default(NGN) + payment_provider PaymentProvider @default(PAYSTACK) + notes String? + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + users User[] + virtual_machines VirtualMachine[] + invoices Invoice[] + usage_records UsageRecord[] + provisioned_services ProvisionedService[] + backups Backup[] + backup_policies BackupPolicy[] + assigned_ip_addresses IpAddressPool[] @relation("AssignedIpTenant") + ip_assignments IpAssignment[] + private_network_attachments PrivateNetworkAttachment[] + ip_quota TenantIpQuota? + reserved_ip_ranges IpReservedRange[] + ip_pool_policies IpPoolPolicy[] + health_checks ServerHealthCheck[] + monitoring_alert_rules MonitoringAlertRule[] + monitoring_alert_events MonitoringAlertEvent[] + + @@index([status]) + @@index([owner_email]) +} + +model ProxmoxNode { + id String @id @default(cuid()) + name String + hostname String @unique + status NodeStatus @default(OFFLINE) + cpu_cores Int @default(8) + cpu_usage Float @default(0) + ram_total_mb Int @default(32768) + ram_used_mb Int @default(0) + disk_total_gb Int @default(500) + disk_used_gb Int @default(0) + vm_count Int @default(0) + uptime_seconds Int @default(0) + pve_version String? + is_connected Boolean @default(false) + last_sync_at DateTime? + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + virtual_machines VirtualMachine[] + placement_policies NodePlacementPolicy[] + vmid_ranges VmIdRange[] + health_checks ServerHealthCheck[] + monitoring_alert_rules MonitoringAlertRule[] + monitoring_alert_events MonitoringAlertEvent[] + + @@index([status]) +} + +model BillingPlan { + id String @id @default(cuid()) + name String + slug String @unique + description String? + price_monthly Decimal @db.Decimal(12, 2) + price_hourly Decimal @db.Decimal(12, 4) + currency Currency @default(NGN) + cpu_cores Int + ram_mb Int + disk_gb Int + bandwidth_gb Int? + is_active Boolean @default(true) + features Json @default("[]") + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + virtual_machines VirtualMachine[] + usage_records UsageRecord[] + backup_policies BackupPolicy[] + + @@index([is_active]) +} + +model VirtualMachine { + id String @id @default(cuid()) + name String + vmid Int + status VmStatus @default(STOPPED) + type VmType @default(QEMU) + node String + node_id String? + tenant_id String + billing_plan_id String? + os_template String? + cpu_cores Int @default(2) + ram_mb Int @default(2048) + disk_gb Int @default(40) + ip_address String? + cpu_usage Float @default(0) + ram_usage Float @default(0) + disk_usage Float @default(0) + network_in Float @default(0) + network_out Float @default(0) + uptime_seconds Int @default(0) + started_at DateTime? + last_backup_at DateTime? + proxmox_upid String? + last_sync_at DateTime? + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + tenant Tenant @relation(fields: [tenant_id], references: [id], onDelete: Cascade) + node_ref ProxmoxNode? @relation(fields: [node_id], references: [id], onDelete: SetNull) + billing_plan BillingPlan? @relation(fields: [billing_plan_id], references: [id], onDelete: SetNull) + usage_records UsageRecord[] + backups Backup[] + operation_tasks OperationTask[] + power_schedules PowerSchedule[] + provisioned_service ProvisionedService? + snapshot_jobs SnapshotJob[] + backup_restores_source BackupRestoreTask[] @relation("BackupRestoreSourceVm") + backup_restores_target BackupRestoreTask[] @relation("BackupRestoreTargetVm") + assigned_ip_addresses IpAddressPool[] @relation("AssignedIpVm") + ip_assignments IpAssignment[] + private_network_attachments PrivateNetworkAttachment[] + health_checks ServerHealthCheck[] + monitoring_alert_rules MonitoringAlertRule[] + monitoring_alert_events MonitoringAlertEvent[] + + @@unique([vmid, node]) + @@index([tenant_id]) + @@index([node]) + @@index([status]) +} + +model ServerHealthCheck { + id String @id @default(cuid()) + name String + description String? + target_type HealthCheckTargetType + check_type HealthCheckType @default(RESOURCE_THRESHOLD) + tenant_id String? + vm_id String? + node_id String? + cpu_warn_pct Float? + cpu_critical_pct Float? + ram_warn_pct Float? + ram_critical_pct Float? + disk_warn_pct Float? + disk_critical_pct Float? + disk_io_read_warn Float? + disk_io_read_critical Float? + disk_io_write_warn Float? + disk_io_write_critical Float? + network_in_warn Float? + network_in_critical Float? + network_out_warn Float? + network_out_critical Float? + latency_warn_ms Int? + latency_critical_ms Int? + schedule_minutes Int @default(5) + enabled Boolean @default(true) + last_run_at DateTime? + next_run_at DateTime? + created_by String? + metadata Json @default("{}") + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + tenant Tenant? @relation(fields: [tenant_id], references: [id], onDelete: SetNull) + vm VirtualMachine? @relation(fields: [vm_id], references: [id], onDelete: SetNull) + node ProxmoxNode? @relation(fields: [node_id], references: [id], onDelete: SetNull) + results ServerHealthCheckResult[] + + @@index([enabled, next_run_at]) + @@index([tenant_id, enabled]) + @@index([vm_id, enabled]) + @@index([node_id, enabled]) +} + +model ServerHealthCheckResult { + id String @id @default(cuid()) + check_id String + status HealthCheckStatus + severity Severity @default(INFO) + message String? + latency_ms Int? + cpu_usage Float? + ram_usage Float? + disk_usage Float? + disk_io_read Float? + disk_io_write Float? + network_in Float? + network_out Float? + metadata Json @default("{}") + checked_at DateTime @default(now()) + created_at DateTime @default(now()) + + check ServerHealthCheck @relation(fields: [check_id], references: [id], onDelete: Cascade) + + @@index([check_id, checked_at]) + @@index([status, checked_at]) +} + +model MonitoringAlertRule { + id String @id @default(cuid()) + name String + description String? + tenant_id String? + vm_id String? + node_id String? + cpu_threshold_pct Float? + ram_threshold_pct Float? + disk_threshold_pct Float? + disk_io_read_threshold Float? + disk_io_write_threshold Float? + network_in_threshold Float? + network_out_threshold Float? + consecutive_breaches Int @default(1) + evaluation_window_minutes Int @default(15) + severity Severity @default(WARNING) + channels Json @default("[\"IN_APP\"]") + enabled Boolean @default(true) + last_evaluated_at DateTime? + created_by String? + metadata Json @default("{}") + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + tenant Tenant? @relation(fields: [tenant_id], references: [id], onDelete: SetNull) + vm VirtualMachine? @relation(fields: [vm_id], references: [id], onDelete: SetNull) + node ProxmoxNode? @relation(fields: [node_id], references: [id], onDelete: SetNull) + events MonitoringAlertEvent[] + + @@index([enabled, severity]) + @@index([tenant_id, enabled]) + @@index([vm_id, enabled]) + @@index([node_id, enabled]) +} + +model MonitoringAlertEvent { + id String @id @default(cuid()) + rule_id String + tenant_id String? + vm_id String? + node_id String? + status MonitoringAlertStatus @default(OPEN) + severity Severity @default(WARNING) + title String + message String? + metric_key String? + trigger_value Float? + threshold_value Float? + breach_count Int @default(1) + resolved_at DateTime? + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + rule MonitoringAlertRule @relation(fields: [rule_id], references: [id], onDelete: Cascade) + tenant Tenant? @relation(fields: [tenant_id], references: [id], onDelete: SetNull) + vm VirtualMachine? @relation(fields: [vm_id], references: [id], onDelete: SetNull) + node ProxmoxNode? @relation(fields: [node_id], references: [id], onDelete: SetNull) + notifications MonitoringAlertNotification[] + + @@index([rule_id, status]) + @@index([status, severity, created_at]) + @@index([tenant_id, status]) + @@index([vm_id, status]) + @@index([node_id, status]) +} + +model MonitoringAlertNotification { + id String @id @default(cuid()) + alert_event_id String + channel AlertChannel + destination String? + status AlertDispatchStatus @default(QUEUED) + provider_message String? + sent_at DateTime? + created_at DateTime @default(now()) + + event MonitoringAlertEvent @relation(fields: [alert_event_id], references: [id], onDelete: Cascade) + + @@index([alert_event_id, status]) + @@index([status, created_at]) +} + +model IpAddressPool { + id String @id @default(cuid()) + address String + cidr Int + version IpVersion + scope IpScope @default(PUBLIC) + status IpAddressStatus @default(AVAILABLE) + gateway String? + subnet String? + server String? + node_id String? + node_hostname String? + bridge String? + vlan_tag Int? + sdn_zone String? + tags Json @default("[]") + metadata Json @default("{}") + assigned_vm_id String? + assigned_tenant_id String? + imported_by String? + imported_at DateTime @default(now()) + assigned_at DateTime? + returned_at DateTime? + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + assigned_vm VirtualMachine? @relation("AssignedIpVm", fields: [assigned_vm_id], references: [id], onDelete: SetNull) + assigned_tenant Tenant? @relation("AssignedIpTenant", fields: [assigned_tenant_id], references: [id], onDelete: SetNull) + assignments IpAssignment[] + + @@unique([address, cidr]) + @@index([status, scope, version]) + @@index([node_hostname, bridge, vlan_tag]) + @@index([assigned_vm_id, status]) + @@index([assigned_tenant_id, status]) +} + +model TenantIpQuota { + id String @id @default(cuid()) + tenant_id String @unique + ipv4_limit Int? + ipv6_limit Int? + reserved_ipv4 Int @default(0) + reserved_ipv6 Int @default(0) + burst_allowed Boolean @default(false) + burst_ipv4_limit Int? + burst_ipv6_limit Int? + is_active Boolean @default(true) + metadata Json @default("{}") + created_by String? + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + tenant Tenant @relation(fields: [tenant_id], references: [id], onDelete: Cascade) + + @@index([is_active]) +} + +model IpReservedRange { + id String @id @default(cuid()) + name String + cidr String + version IpVersion + scope IpScope @default(PUBLIC) + tenant_id String? + reason String? + node_hostname String? + bridge String? + vlan_tag Int? + sdn_zone String? + is_active Boolean @default(true) + metadata Json @default("{}") + created_by String? + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + tenant Tenant? @relation(fields: [tenant_id], references: [id], onDelete: SetNull) + + @@index([scope, version, is_active]) + @@index([tenant_id, is_active]) + @@index([node_hostname, bridge, vlan_tag, is_active]) +} + +model IpPoolPolicy { + id String @id @default(cuid()) + name String + tenant_id String? + scope IpScope? + version IpVersion? + node_hostname String? + bridge String? + vlan_tag Int? + sdn_zone String? + allocation_strategy IpAllocationStrategy @default(BEST_FIT) + enforce_quota Boolean @default(true) + disallow_reserved_use Boolean @default(true) + is_active Boolean @default(true) + priority Int @default(100) + metadata Json @default("{}") + created_by String? + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + tenant Tenant? @relation(fields: [tenant_id], references: [id], onDelete: SetNull) + + @@index([tenant_id, is_active, priority]) + @@index([scope, version, is_active]) + @@index([node_hostname, bridge, vlan_tag, is_active]) +} + +model IpAssignment { + id String @id @default(cuid()) + ip_address_id String + vm_id String + tenant_id String? + assignment_type IpAssignmentType @default(ADDITIONAL) + interface_name String? + notes String? + metadata Json @default("{}") + assigned_by String? + assigned_at DateTime @default(now()) + released_at DateTime? + is_active Boolean @default(true) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + ip_address IpAddressPool @relation(fields: [ip_address_id], references: [id], onDelete: Cascade) + vm VirtualMachine @relation(fields: [vm_id], references: [id], onDelete: Cascade) + tenant Tenant? @relation(fields: [tenant_id], references: [id], onDelete: SetNull) + + @@index([ip_address_id, is_active]) + @@index([vm_id, is_active]) + @@index([tenant_id, is_active]) +} + +model PrivateNetwork { + id String @id @default(cuid()) + name String + slug String @unique + network_type PrivateNetworkType @default(VLAN) + cidr String + gateway String? + bridge String? + vlan_tag Int? + sdn_zone String? + server String? + node_hostname String? + is_private Boolean @default(true) + metadata Json @default("{}") + created_by String? + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + attachments PrivateNetworkAttachment[] + + @@index([network_type]) + @@index([node_hostname, bridge, vlan_tag]) +} + +model PrivateNetworkAttachment { + id String @id @default(cuid()) + network_id String + vm_id String + tenant_id String? + interface_name String? + requested_ip String? + status PrivateNetworkAttachmentStatus @default(ATTACHED) + metadata Json @default("{}") + attached_by String? + attached_at DateTime @default(now()) + detached_at DateTime? + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + network PrivateNetwork @relation(fields: [network_id], references: [id], onDelete: Cascade) + vm VirtualMachine @relation(fields: [vm_id], references: [id], onDelete: Cascade) + tenant Tenant? @relation(fields: [tenant_id], references: [id], onDelete: SetNull) + + @@unique([network_id, vm_id, interface_name]) + @@index([vm_id, status]) + @@index([tenant_id, status]) + @@index([network_id, status]) +} + +model AppTemplate { + id String @id @default(cuid()) + name String + slug String @unique + template_type TemplateType + virtualization_type VmType? + source String? + description String? + default_cloud_init String? + metadata Json @default("{}") + is_active Boolean @default(true) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + groups ApplicationGroupTemplate[] + provisioned_services ProvisionedService[] + + @@index([template_type, is_active]) +} + +model ApplicationGroup { + id String @id @default(cuid()) + name String + slug String @unique + description String? + is_active Boolean @default(true) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + templates ApplicationGroupTemplate[] + placement_policies NodePlacementPolicy[] + vmid_ranges VmIdRange[] + provisioned_services ProvisionedService[] + + @@index([is_active]) +} + +model ApplicationGroupTemplate { + id String @id @default(cuid()) + group_id String + template_id String + priority Int @default(100) + created_at DateTime @default(now()) + + group ApplicationGroup @relation(fields: [group_id], references: [id], onDelete: Cascade) + template AppTemplate @relation(fields: [template_id], references: [id], onDelete: Cascade) + + @@unique([group_id, template_id]) + @@index([priority]) +} + +model NodePlacementPolicy { + id String @id @default(cuid()) + group_id String? + node_id String? + product_type ProductType? + cpu_weight Int @default(40) + ram_weight Int @default(30) + disk_weight Int @default(20) + vm_count_weight Int @default(10) + max_vms Int? + min_free_ram_mb Int? + min_free_disk_gb Int? + is_active Boolean @default(true) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + group ApplicationGroup? @relation(fields: [group_id], references: [id], onDelete: SetNull) + node ProxmoxNode? @relation(fields: [node_id], references: [id], onDelete: SetNull) + + @@index([group_id, is_active]) + @@index([node_id, is_active]) + @@index([product_type, is_active]) +} + +model VmIdRange { + id String @id @default(cuid()) + node_id String? + node_hostname String + application_group_id String? + range_start Int + range_end Int + next_vmid Int + is_active Boolean @default(true) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + node ProxmoxNode? @relation(fields: [node_id], references: [id], onDelete: SetNull) + group ApplicationGroup? @relation(fields: [application_group_id], references: [id], onDelete: SetNull) + + @@unique([node_hostname, range_start, range_end]) + @@index([node_hostname, is_active]) + @@index([application_group_id, is_active]) +} + +model OperationTask { + id String @id @default(cuid()) + task_type OperationTaskType + status OperationTaskStatus @default(QUEUED) + vm_id String? + vm_name String? + node String? + requested_by String? + payload Json? + result Json? + error_message String? + proxmox_upid String? + scheduled_for DateTime? + started_at DateTime? + completed_at DateTime? + retry_count Int @default(0) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + vm VirtualMachine? @relation(fields: [vm_id], references: [id], onDelete: SetNull) + + @@index([status]) + @@index([task_type]) + @@index([vm_id]) + @@index([created_at]) +} + +model PowerSchedule { + id String @id @default(cuid()) + vm_id String + action PowerScheduleAction + cron_expression String + timezone String @default("UTC") + enabled Boolean @default(true) + next_run_at DateTime? + last_run_at DateTime? + created_by String? + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + vm VirtualMachine @relation(fields: [vm_id], references: [id], onDelete: Cascade) + + @@index([vm_id]) + @@index([enabled, next_run_at]) +} + +model ProvisionedService { + id String @id @default(cuid()) + service_group_id String? + vm_id String @unique + tenant_id String + product_type ProductType + lifecycle_status ServiceLifecycleStatus @default(ACTIVE) + application_group_id String? + template_id String? + package_options Json @default("{}") + suspended_reason String? + terminated_at DateTime? + created_by String? + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + vm VirtualMachine @relation(fields: [vm_id], references: [id], onDelete: Cascade) + tenant Tenant @relation(fields: [tenant_id], references: [id], onDelete: Cascade) + group ApplicationGroup? @relation(fields: [application_group_id], references: [id], onDelete: SetNull) + template AppTemplate? @relation(fields: [template_id], references: [id], onDelete: SetNull) + + @@index([tenant_id, lifecycle_status]) + @@index([service_group_id]) + @@index([application_group_id]) +} + +model Invoice { + id String @id @default(cuid()) + invoice_number String @unique + tenant_id String + tenant_name String? + status InvoiceStatus @default(PENDING) + amount Decimal @db.Decimal(14, 2) + currency Currency @default(NGN) + due_date DateTime + paid_date DateTime? + payment_provider PaymentProvider @default(MANUAL) + payment_reference String? + payment_url String? + line_items Json @default("[]") + notes String? + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + tenant Tenant @relation(fields: [tenant_id], references: [id], onDelete: Cascade) + usage_records UsageRecord[] + + @@index([tenant_id]) + @@index([status]) + @@index([due_date]) +} + +model UsageRecord { + id String @id @default(cuid()) + vm_id String + vm_name String + tenant_id String + tenant_name String + billing_plan_id String? + plan_name String? + hours_used Decimal @default(1) @db.Decimal(8, 2) + price_per_hour Decimal @db.Decimal(12, 4) + currency Currency @default(NGN) + total_cost Decimal @db.Decimal(14, 4) + period_start DateTime + period_end DateTime + billed Boolean @default(false) + invoice_id String? + cpu_hours Decimal? @db.Decimal(10, 4) + ram_gb_hours Decimal? @db.Decimal(10, 4) + disk_gb_hours Decimal? @db.Decimal(10, 4) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + vm VirtualMachine @relation(fields: [vm_id], references: [id], onDelete: Cascade) + tenant Tenant @relation(fields: [tenant_id], references: [id], onDelete: Cascade) + billing_plan BillingPlan? @relation(fields: [billing_plan_id], references: [id], onDelete: SetNull) + invoice Invoice? @relation(fields: [invoice_id], references: [id], onDelete: SetNull) + + @@index([vm_id]) + @@index([tenant_id]) + @@index([period_start]) + @@index([billed]) + @@unique([vm_id, period_start, period_end]) +} + +model Backup { + id String @id @default(cuid()) + vm_id String + vm_name String + tenant_id String? + node String? + status BackupStatus @default(PENDING) + type BackupType @default(FULL) + source BackupSource @default(LOCAL) + size_mb Float? + storage String? + backup_path String? + pbs_snapshot_id String? + route_key String? + is_protected Boolean @default(false) + restore_enabled Boolean @default(true) + total_files Int? + schedule BackupSchedule @default(MANUAL) + retention_days Int @default(7) + snapshot_job_id String? + started_at DateTime? + completed_at DateTime? + next_run_at DateTime? + expires_at DateTime? + notes String? + proxmox_upid String? + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + vm VirtualMachine @relation(fields: [vm_id], references: [id], onDelete: Cascade) + tenant Tenant? @relation(fields: [tenant_id], references: [id], onDelete: SetNull) + snapshot_job SnapshotJob? @relation(fields: [snapshot_job_id], references: [id], onDelete: SetNull) + restore_tasks BackupRestoreTask[] + + @@index([vm_id]) + @@index([tenant_id]) + @@index([status]) + @@index([next_run_at]) + @@index([snapshot_job_id]) +} + +model BackupPolicy { + id String @id @default(cuid()) + tenant_id String? + billing_plan_id String? + max_files Int @default(10) + max_total_size_mb Float @default(51200) + max_protected_files Int @default(3) + allow_file_restore Boolean @default(true) + allow_cross_vm_restore Boolean @default(true) + allow_pbs_restore Boolean @default(true) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + tenant Tenant? @relation(fields: [tenant_id], references: [id], onDelete: Cascade) + billing_plan BillingPlan? @relation(fields: [billing_plan_id], references: [id], onDelete: Cascade) + + @@index([tenant_id]) + @@index([billing_plan_id]) +} + +model BackupRestoreTask { + id String @id @default(cuid()) + backup_id String + source_vm_id String + target_vm_id String + mode BackupRestoreMode + requested_files Json @default("[]") + pbs_enabled Boolean @default(false) + status BackupRestoreStatus @default(PENDING) + result_message String? + started_at DateTime? + completed_at DateTime? + created_by String? + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + backup Backup @relation(fields: [backup_id], references: [id], onDelete: Cascade) + source_vm VirtualMachine @relation("BackupRestoreSourceVm", fields: [source_vm_id], references: [id], onDelete: Cascade) + target_vm VirtualMachine @relation("BackupRestoreTargetVm", fields: [target_vm_id], references: [id], onDelete: Cascade) + + @@index([backup_id]) + @@index([source_vm_id]) + @@index([target_vm_id]) + @@index([status]) +} + +model SnapshotJob { + id String @id @default(cuid()) + vm_id String + name String + frequency SnapshotFrequency @default(DAILY) + interval Int @default(1) + day_of_week Int? + hour_utc Int @default(2) + minute_utc Int @default(0) + retention Int @default(7) + enabled Boolean @default(true) + next_run_at DateTime? + last_run_at DateTime? + created_by String? + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + vm VirtualMachine @relation(fields: [vm_id], references: [id], onDelete: Cascade) + backups Backup[] + + @@index([vm_id]) + @@index([enabled, next_run_at]) +} + +model AuditLog { + id String @id @default(cuid()) + action String + resource_type ResourceType + resource_id String? + resource_name String? + actor_email String + actor_role String? + severity Severity @default(INFO) + details Json? + ip_address String? + created_at DateTime @default(now()) + + @@index([resource_type]) + @@index([severity]) + @@index([created_at]) +} + +model SecurityEvent { + id String @id @default(cuid()) + event_type String + severity Severity @default(WARNING) + status SecurityStatus @default(OPEN) + source_ip String? + source_country String? + target_vm_id String? + node String? + description String? + details Json? + resolved_at DateTime? + resolved_by String? + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + @@index([status]) + @@index([severity]) + @@index([created_at]) +} + +model FirewallRule { + id String @id @default(cuid()) + name String + direction Direction @default(INBOUND) + action FirewallAction @default(DENY) + protocol Protocol @default(TCP) + source_ip String? + destination_ip String? + port_range String? + priority Int @default(100) + enabled Boolean @default(true) + applies_to AppliesTo @default(ALL_VMS) + target_id String? + hit_count Int @default(0) + description String? + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + @@index([enabled]) + @@index([priority]) +} + +model Setting { + id String @id @default(cuid()) + key String @unique + type SettingType @default(GENERAL) + value Json + is_encrypted Boolean @default(false) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt +} diff --git a/backend/prisma/seed.js b/backend/prisma/seed.js new file mode 100644 index 0000000..e2ca984 --- /dev/null +++ b/backend/prisma/seed.js @@ -0,0 +1,184 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const bcryptjs_1 = __importDefault(require("bcryptjs")); +const client_1 = require("@prisma/client"); +const prisma = new client_1.PrismaClient(); +async function main() { + const adminEmail = process.env.ADMIN_EMAIL ?? "admin@proxpanel.local"; + const adminPassword = process.env.ADMIN_PASSWORD ?? "ChangeMe123!"; + const password_hash = await bcryptjs_1.default.hash(adminPassword, 12); + const tenant = await prisma.tenant.upsert({ + where: { slug: "default-tenant" }, + update: {}, + create: { + name: "Default Tenant", + slug: "default-tenant", + owner_email: adminEmail, + currency: client_1.Currency.NGN, + payment_provider: client_1.PaymentProvider.PAYSTACK + } + }); + await prisma.user.upsert({ + where: { email: adminEmail }, + update: { + role: client_1.Role.SUPER_ADMIN, + password_hash, + tenant_id: tenant.id + }, + create: { + email: adminEmail, + full_name: "System Administrator", + password_hash, + role: client_1.Role.SUPER_ADMIN, + tenant_id: tenant.id + } + }); + await prisma.setting.upsert({ + where: { key: "proxmox" }, + update: {}, + create: { + key: "proxmox", + type: "PROXMOX", + value: { + host: "", + port: 8006, + username: "root@pam", + token_id: "", + token_secret: "", + verify_ssl: true + } + } + }); + await prisma.setting.upsert({ + where: { key: "payment" }, + update: {}, + create: { + key: "payment", + type: "PAYMENT", + value: { + default_provider: "paystack", + paystack_public: "", + paystack_secret: "", + flutterwave_public: "", + flutterwave_secret: "", + flutterwave_webhook_hash: "", + callback_url: "" + } + } + }); + await prisma.setting.upsert({ + where: { key: "provisioning" }, + update: {}, + create: { + key: "provisioning", + type: "GENERAL", + value: { + min_vmid: 100 + } + } + }); + await prisma.setting.upsert({ + where: { key: "backup" }, + update: {}, + create: { + key: "backup", + type: "GENERAL", + value: { + default_source: "local", + default_storage: "local-lvm", + max_restore_file_count: 100, + pbs_enabled: false, + pbs_host: "", + pbs_datastore: "", + pbs_namespace: "", + pbs_verify_ssl: true + } + } + }); + await prisma.billingPlan.upsert({ + where: { slug: "starter" }, + update: {}, + create: { + name: "Starter", + slug: "starter", + description: "Entry plan for lightweight VM workloads", + price_monthly: 12000, + price_hourly: 12000 / 720, + currency: client_1.Currency.NGN, + cpu_cores: 2, + ram_mb: 4096, + disk_gb: 60, + bandwidth_gb: 2000, + features: ["basic-support", "daily-backups"] + } + }); + const ubuntuTemplate = await prisma.appTemplate.upsert({ + where: { slug: "ubuntu-22-04-golden" }, + update: {}, + create: { + name: "Ubuntu 22.04 Golden", + slug: "ubuntu-22-04-golden", + template_type: "KVM_TEMPLATE", + virtualization_type: "QEMU", + source: "local:vztmpl/ubuntu-22.04-golden.qcow2", + description: "Baseline hardened Ubuntu template", + metadata: { + os_family: "linux", + os_version: "22.04" + } + } + }); + const webGroup = await prisma.applicationGroup.upsert({ + where: { slug: "web-workloads" }, + update: {}, + create: { + name: "Web Workloads", + slug: "web-workloads", + description: "HTTP-facing application services" + } + }); + await prisma.applicationGroupTemplate.upsert({ + where: { + group_id_template_id: { + group_id: webGroup.id, + template_id: ubuntuTemplate.id + } + }, + update: {}, + create: { + group_id: webGroup.id, + template_id: ubuntuTemplate.id, + priority: 10 + } + }); + await prisma.backupPolicy.upsert({ + where: { + id: "default-tenant-backup-policy" + }, + update: {}, + create: { + id: "default-tenant-backup-policy", + tenant_id: tenant.id, + max_files: 25, + max_total_size_mb: 102400, + max_protected_files: 5, + allow_file_restore: true, + allow_cross_vm_restore: true, + allow_pbs_restore: true + } + }); +} +main() + .then(async () => { + await prisma.$disconnect(); +}) + .catch(async (error) => { + // eslint-disable-next-line no-console + console.error("Seed failed:", error); + await prisma.$disconnect(); + process.exit(1); +}); +//# sourceMappingURL=seed.js.map \ No newline at end of file diff --git a/backend/prisma/seed.js.map b/backend/prisma/seed.js.map new file mode 100644 index 0000000..45078e1 --- /dev/null +++ b/backend/prisma/seed.js.map @@ -0,0 +1 @@ +{"version":3,"file":"seed.js","sourceRoot":"","sources":["seed.ts"],"names":[],"mappings":";;;;;AAAA,wDAA8B;AAC9B,2CAA+E;AAE/E,MAAM,MAAM,GAAG,IAAI,qBAAY,EAAE,CAAC;AAElC,KAAK,UAAU,IAAI;IACjB,MAAM,UAAU,GAAG,OAAO,CAAC,GAAG,CAAC,WAAW,IAAI,uBAAuB,CAAC;IACtE,MAAM,aAAa,GAAG,OAAO,CAAC,GAAG,CAAC,cAAc,IAAI,cAAc,CAAC;IACnE,MAAM,aAAa,GAAG,MAAM,kBAAM,CAAC,IAAI,CAAC,aAAa,EAAE,EAAE,CAAC,CAAC;IAE3D,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC;QACxC,KAAK,EAAE,EAAE,IAAI,EAAE,gBAAgB,EAAE;QACjC,MAAM,EAAE,EAAE;QACV,MAAM,EAAE;YACN,IAAI,EAAE,gBAAgB;YACtB,IAAI,EAAE,gBAAgB;YACtB,WAAW,EAAE,UAAU;YACvB,QAAQ,EAAE,iBAAQ,CAAC,GAAG;YACtB,gBAAgB,EAAE,wBAAe,CAAC,QAAQ;SAC3C;KACF,CAAC,CAAC;IAEH,MAAM,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC;QACvB,KAAK,EAAE,EAAE,KAAK,EAAE,UAAU,EAAE;QAC5B,MAAM,EAAE;YACN,IAAI,EAAE,aAAI,CAAC,WAAW;YACtB,aAAa;YACb,SAAS,EAAE,MAAM,CAAC,EAAE;SACrB;QACD,MAAM,EAAE;YACN,KAAK,EAAE,UAAU;YACjB,SAAS,EAAE,sBAAsB;YACjC,aAAa;YACb,IAAI,EAAE,aAAI,CAAC,WAAW;YACtB,SAAS,EAAE,MAAM,CAAC,EAAE;SACrB;KACF,CAAC,CAAC;IAEH,MAAM,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC;QAC1B,KAAK,EAAE,EAAE,GAAG,EAAE,SAAS,EAAE;QACzB,MAAM,EAAE,EAAE;QACV,MAAM,EAAE;YACN,GAAG,EAAE,SAAS;YACd,IAAI,EAAE,SAAS;YACf,KAAK,EAAE;gBACL,IAAI,EAAE,EAAE;gBACR,IAAI,EAAE,IAAI;gBACV,QAAQ,EAAE,UAAU;gBACpB,QAAQ,EAAE,EAAE;gBACZ,YAAY,EAAE,EAAE;gBAChB,UAAU,EAAE,IAAI;aACjB;SACF;KACF,CAAC,CAAC;IAEH,MAAM,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC;QAC1B,KAAK,EAAE,EAAE,GAAG,EAAE,SAAS,EAAE;QACzB,MAAM,EAAE,EAAE;QACV,MAAM,EAAE;YACN,GAAG,EAAE,SAAS;YACd,IAAI,EAAE,SAAS;YACf,KAAK,EAAE;gBACL,gBAAgB,EAAE,UAAU;gBAC5B,eAAe,EAAE,EAAE;gBACnB,eAAe,EAAE,EAAE;gBACnB,kBAAkB,EAAE,EAAE;gBACtB,kBAAkB,EAAE,EAAE;gBACtB,wBAAwB,EAAE,EAAE;gBAC5B,YAAY,EAAE,EAAE;aACjB;SACF;KACF,CAAC,CAAC;IAEH,MAAM,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC;QAC1B,KAAK,EAAE,EAAE,GAAG,EAAE,cAAc,EAAE;QAC9B,MAAM,EAAE,EAAE;QACV,MAAM,EAAE;YACN,GAAG,EAAE,cAAc;YACnB,IAAI,EAAE,SAAS;YACf,KAAK,EAAE;gBACL,QAAQ,EAAE,GAAG;aACd;SACF;KACF,CAAC,CAAC;IAEH,MAAM,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC;QAC1B,KAAK,EAAE,EAAE,GAAG,EAAE,QAAQ,EAAE;QACxB,MAAM,EAAE,EAAE;QACV,MAAM,EAAE;YACN,GAAG,EAAE,QAAQ;YACb,IAAI,EAAE,SAAS;YACf,KAAK,EAAE;gBACL,cAAc,EAAE,OAAO;gBACvB,eAAe,EAAE,WAAW;gBAC5B,sBAAsB,EAAE,GAAG;gBAC3B,WAAW,EAAE,KAAK;gBAClB,QAAQ,EAAE,EAAE;gBACZ,aAAa,EAAE,EAAE;gBACjB,aAAa,EAAE,EAAE;gBACjB,cAAc,EAAE,IAAI;aACrB;SACF;KACF,CAAC,CAAC;IAEH,MAAM,MAAM,CAAC,WAAW,CAAC,MAAM,CAAC;QAC9B,KAAK,EAAE,EAAE,IAAI,EAAE,SAAS,EAAE;QAC1B,MAAM,EAAE,EAAE;QACV,MAAM,EAAE;YACN,IAAI,EAAE,SAAS;YACf,IAAI,EAAE,SAAS;YACf,WAAW,EAAE,yCAAyC;YACtD,aAAa,EAAE,KAAK;YACpB,YAAY,EAAE,KAAK,GAAG,GAAG;YACzB,QAAQ,EAAE,iBAAQ,CAAC,GAAG;YACtB,SAAS,EAAE,CAAC;YACZ,MAAM,EAAE,IAAI;YACZ,OAAO,EAAE,EAAE;YACX,YAAY,EAAE,IAAI;YAClB,QAAQ,EAAE,CAAC,eAAe,EAAE,eAAe,CAAC;SAC7C;KACF,CAAC,CAAC;IAEH,MAAM,cAAc,GAAG,MAAM,MAAM,CAAC,WAAW,CAAC,MAAM,CAAC;QACrD,KAAK,EAAE,EAAE,IAAI,EAAE,qBAAqB,EAAE;QACtC,MAAM,EAAE,EAAE;QACV,MAAM,EAAE;YACN,IAAI,EAAE,qBAAqB;YAC3B,IAAI,EAAE,qBAAqB;YAC3B,aAAa,EAAE,cAAc;YAC7B,mBAAmB,EAAE,MAAM;YAC3B,MAAM,EAAE,wCAAwC;YAChD,WAAW,EAAE,mCAAmC;YAChD,QAAQ,EAAE;gBACR,SAAS,EAAE,OAAO;gBAClB,UAAU,EAAE,OAAO;aACpB;SACF;KACF,CAAC,CAAC;IAEH,MAAM,QAAQ,GAAG,MAAM,MAAM,CAAC,gBAAgB,CAAC,MAAM,CAAC;QACpD,KAAK,EAAE,EAAE,IAAI,EAAE,eAAe,EAAE;QAChC,MAAM,EAAE,EAAE;QACV,MAAM,EAAE;YACN,IAAI,EAAE,eAAe;YACrB,IAAI,EAAE,eAAe;YACrB,WAAW,EAAE,kCAAkC;SAChD;KACF,CAAC,CAAC;IAEH,MAAM,MAAM,CAAC,wBAAwB,CAAC,MAAM,CAAC;QAC3C,KAAK,EAAE;YACL,oBAAoB,EAAE;gBACpB,QAAQ,EAAE,QAAQ,CAAC,EAAE;gBACrB,WAAW,EAAE,cAAc,CAAC,EAAE;aAC/B;SACF;QACD,MAAM,EAAE,EAAE;QACV,MAAM,EAAE;YACN,QAAQ,EAAE,QAAQ,CAAC,EAAE;YACrB,WAAW,EAAE,cAAc,CAAC,EAAE;YAC9B,QAAQ,EAAE,EAAE;SACb;KACF,CAAC,CAAC;IAEH,MAAM,MAAM,CAAC,YAAY,CAAC,MAAM,CAAC;QAC/B,KAAK,EAAE;YACL,EAAE,EAAE,8BAA8B;SACnC;QACD,MAAM,EAAE,EAAE;QACV,MAAM,EAAE;YACN,EAAE,EAAE,8BAA8B;YAClC,SAAS,EAAE,MAAM,CAAC,EAAE;YACpB,SAAS,EAAE,EAAE;YACb,iBAAiB,EAAE,MAAM;YACzB,mBAAmB,EAAE,CAAC;YACtB,kBAAkB,EAAE,IAAI;YACxB,sBAAsB,EAAE,IAAI;YAC5B,iBAAiB,EAAE,IAAI;SACxB;KACF,CAAC,CAAC;AACL,CAAC;AAED,IAAI,EAAE;KACH,IAAI,CAAC,KAAK,IAAI,EAAE;IACf,MAAM,MAAM,CAAC,WAAW,EAAE,CAAC;AAC7B,CAAC,CAAC;KACD,KAAK,CAAC,KAAK,EAAE,KAAK,EAAE,EAAE;IACrB,sCAAsC;IACtC,OAAO,CAAC,KAAK,CAAC,cAAc,EAAE,KAAK,CAAC,CAAC;IACrC,MAAM,MAAM,CAAC,WAAW,EAAE,CAAC;IAC3B,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC,CAAC,CAAC"} \ No newline at end of file diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts new file mode 100644 index 0000000..1e4e483 --- /dev/null +++ b/backend/prisma/seed.ts @@ -0,0 +1,192 @@ +import bcrypt from "bcryptjs"; +import { PrismaClient, Role, Currency, PaymentProvider } from "@prisma/client"; + +const prisma = new PrismaClient(); + +async function main() { + const adminEmail = process.env.ADMIN_EMAIL ?? "admin@proxpanel.local"; + const adminPassword = process.env.ADMIN_PASSWORD ?? "ChangeMe123!"; + const password_hash = await bcrypt.hash(adminPassword, 12); + + const tenant = await prisma.tenant.upsert({ + where: { slug: "default-tenant" }, + update: {}, + create: { + name: "Default Tenant", + slug: "default-tenant", + owner_email: adminEmail, + currency: Currency.NGN, + payment_provider: PaymentProvider.PAYSTACK + } + }); + + await prisma.user.upsert({ + where: { email: adminEmail }, + update: { + role: Role.SUPER_ADMIN, + password_hash, + tenant_id: tenant.id + }, + create: { + email: adminEmail, + full_name: "System Administrator", + password_hash, + role: Role.SUPER_ADMIN, + tenant_id: tenant.id + } + }); + + await prisma.setting.upsert({ + where: { key: "proxmox" }, + update: {}, + create: { + key: "proxmox", + type: "PROXMOX", + value: { + host: "", + port: 8006, + username: "root@pam", + token_id: "", + token_secret: "", + verify_ssl: true + } + } + }); + + await prisma.setting.upsert({ + where: { key: "payment" }, + update: {}, + create: { + key: "payment", + type: "PAYMENT", + value: { + default_provider: "paystack", + paystack_public: "", + paystack_secret: "", + flutterwave_public: "", + flutterwave_secret: "", + flutterwave_webhook_hash: "", + callback_url: "" + } + } + }); + + await prisma.setting.upsert({ + where: { key: "provisioning" }, + update: {}, + create: { + key: "provisioning", + type: "GENERAL", + value: { + min_vmid: 100 + } + } + }); + + await prisma.setting.upsert({ + where: { key: "backup" }, + update: {}, + create: { + key: "backup", + type: "GENERAL", + value: { + default_source: "local", + default_storage: "local-lvm", + max_restore_file_count: 100, + pbs_enabled: false, + pbs_host: "", + pbs_datastore: "", + pbs_namespace: "", + pbs_verify_ssl: true + } + } + }); + + await prisma.billingPlan.upsert({ + where: { slug: "starter" }, + update: {}, + create: { + name: "Starter", + slug: "starter", + description: "Entry plan for lightweight VM workloads", + price_monthly: 12000, + price_hourly: 12000 / 720, + currency: Currency.NGN, + cpu_cores: 2, + ram_mb: 4096, + disk_gb: 60, + bandwidth_gb: 2000, + features: ["basic-support", "daily-backups"] + } + }); + + const ubuntuTemplate = await prisma.appTemplate.upsert({ + where: { slug: "ubuntu-22-04-golden" }, + update: {}, + create: { + name: "Ubuntu 22.04 Golden", + slug: "ubuntu-22-04-golden", + template_type: "KVM_TEMPLATE", + virtualization_type: "QEMU", + source: "local:vztmpl/ubuntu-22.04-golden.qcow2", + description: "Baseline hardened Ubuntu template", + metadata: { + os_family: "linux", + os_version: "22.04" + } + } + }); + + const webGroup = await prisma.applicationGroup.upsert({ + where: { slug: "web-workloads" }, + update: {}, + create: { + name: "Web Workloads", + slug: "web-workloads", + description: "HTTP-facing application services" + } + }); + + await prisma.applicationGroupTemplate.upsert({ + where: { + group_id_template_id: { + group_id: webGroup.id, + template_id: ubuntuTemplate.id + } + }, + update: {}, + create: { + group_id: webGroup.id, + template_id: ubuntuTemplate.id, + priority: 10 + } + }); + + await prisma.backupPolicy.upsert({ + where: { + id: "default-tenant-backup-policy" + }, + update: {}, + create: { + id: "default-tenant-backup-policy", + tenant_id: tenant.id, + max_files: 25, + max_total_size_mb: 102400, + max_protected_files: 5, + allow_file_restore: true, + allow_cross_vm_restore: true, + allow_pbs_restore: true + } + }); +} + +main() + .then(async () => { + await prisma.$disconnect(); + }) + .catch(async (error) => { + // eslint-disable-next-line no-console + console.error("Seed failed:", error); + await prisma.$disconnect(); + process.exit(1); + }); diff --git a/backend/src/app.ts b/backend/src/app.ts new file mode 100644 index 0000000..9dbea75 --- /dev/null +++ b/backend/src/app.ts @@ -0,0 +1,90 @@ +import express from "express"; +import cors from "cors"; +import helmet from "helmet"; +import compression from "compression"; +import morgan from "morgan"; +import { env } from "./config/env"; +import authRoutes from "./routes/auth.routes"; +import healthRoutes from "./routes/health.routes"; +import dashboardRoutes from "./routes/dashboard.routes"; +import resourceRoutes from "./routes/resources.routes"; +import billingRoutes from "./routes/billing.routes"; +import paymentRoutes from "./routes/payment.routes"; +import proxmoxRoutes from "./routes/proxmox.routes"; +import settingsRoutes from "./routes/settings.routes"; +import operationsRoutes from "./routes/operations.routes"; +import provisioningRoutes from "./routes/provisioning.routes"; +import backupRoutes from "./routes/backup.routes"; +import networkRoutes from "./routes/network.routes"; +import monitoringRoutes from "./routes/monitoring.routes"; +import clientRoutes from "./routes/client.routes"; +import { errorHandler, notFoundHandler } from "./middleware/error-handler"; +import { createRateLimit } from "./middleware/rate-limit"; + +export function createApp() { + const app = express(); + app.set("trust proxy", 1); + + const globalRateLimit = createRateLimit({ + windowMs: env.RATE_LIMIT_WINDOW_MS, + max: env.RATE_LIMIT_MAX + }); + const authRateLimit = createRateLimit({ + windowMs: env.AUTH_RATE_LIMIT_WINDOW_MS, + max: env.AUTH_RATE_LIMIT_MAX, + keyGenerator: (req) => { + const email = typeof req.body?.email === "string" ? req.body.email.toLowerCase().trim() : ""; + return `${req.ip}:${email}`; + } + }); + + app.use( + cors({ + origin: env.CORS_ORIGIN === "*" ? true : env.CORS_ORIGIN.split(",").map((item) => item.trim()), + credentials: true + }) + ); + app.use(helmet()); + app.use(compression()); + app.use( + express.json({ + limit: "2mb", + verify: (req, _res, buffer) => { + const request = req as express.Request; + request.rawBody = buffer.toString("utf8"); + } + }) + ); + app.use(morgan("dev")); + app.use("/api", globalRateLimit); + app.use("/api/auth/login", authRateLimit); + app.use("/api/auth/refresh", authRateLimit); + + app.get("/", (_req, res) => { + res.json({ + name: "ProxPanel API", + version: "1.0.0", + docs: "/api/health" + }); + }); + + app.use("/api/health", healthRoutes); + app.use("/api/auth", authRoutes); + app.use("/api/dashboard", dashboardRoutes); + app.use("/api/resources", resourceRoutes); + app.use("/api/billing", billingRoutes); + app.use("/api/payments", paymentRoutes); + app.use("/api/proxmox", proxmoxRoutes); + app.use("/api/settings", settingsRoutes); + app.use("/api/operations", operationsRoutes); + app.use("/api/provisioning", provisioningRoutes); + app.use("/api/backups", backupRoutes); + app.use("/api/network", networkRoutes); + app.use("/api/monitoring", monitoringRoutes); + app.use("/api/client", clientRoutes); + + app.use(notFoundHandler); + app.use(errorHandler); + + return app; +} diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts new file mode 100644 index 0000000..6f11e7c --- /dev/null +++ b/backend/src/config/env.ts @@ -0,0 +1,38 @@ +import dotenv from "dotenv"; +import { z } from "zod"; + +dotenv.config(); + +const envSchema = z.object({ + NODE_ENV: z.enum(["development", "test", "production"]).default("development"), + PORT: z.coerce.number().default(8080), + DATABASE_URL: z.string().min(1, "DATABASE_URL is required"), + JWT_SECRET: z.string().min(16, "JWT_SECRET must be at least 16 characters"), + JWT_EXPIRES_IN: z.string().default("7d"), + JWT_REFRESH_SECRET: z.string().min(16, "JWT_REFRESH_SECRET must be at least 16 characters").optional(), + JWT_REFRESH_EXPIRES_IN: z.string().default("30d"), + CORS_ORIGIN: z.string().default("*"), + RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().default(60_000), + RATE_LIMIT_MAX: z.coerce.number().int().positive().default(600), + AUTH_RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().default(60_000), + AUTH_RATE_LIMIT_MAX: z.coerce.number().int().positive().default(20), + SCHEDULER_LEASE_MS: z.coerce.number().int().positive().default(180_000), + SCHEDULER_HEARTBEAT_MS: z.coerce.number().int().positive().default(30_000), + ENABLE_SCHEDULER: z.coerce.boolean().default(true), + BILLING_CRON: z.string().default("0 * * * *"), + BACKUP_CRON: z.string().default("*/15 * * * *"), + POWER_SCHEDULE_CRON: z.string().default("* * * * *"), + MONITORING_CRON: z.string().default("*/5 * * * *"), + PROXMOX_TIMEOUT_MS: z.coerce.number().default(15000) +}); + +const parsed = envSchema.parse(process.env); + +if (parsed.NODE_ENV === "production" && parsed.CORS_ORIGIN === "*") { + throw new Error("CORS_ORIGIN cannot be '*' in production"); +} + +export const env = { + ...parsed, + JWT_REFRESH_SECRET: parsed.JWT_REFRESH_SECRET ?? parsed.JWT_SECRET +}; diff --git a/backend/src/index.ts b/backend/src/index.ts new file mode 100644 index 0000000..b80fb15 --- /dev/null +++ b/backend/src/index.ts @@ -0,0 +1,23 @@ +import { createApp } from "./app"; +import { env } from "./config/env"; +import { prisma } from "./lib/prisma"; +import { startSchedulers } from "./services/scheduler.service"; + +async function bootstrap() { + await prisma.$connect(); + + const app = createApp(); + app.listen(env.PORT, () => { + // eslint-disable-next-line no-console + console.log(`ProxPanel API running on port ${env.PORT}`); + }); + + await startSchedulers(); +} + +bootstrap().catch(async (error) => { + // eslint-disable-next-line no-console + console.error("Failed to start server:", error); + await prisma.$disconnect(); + process.exit(1); +}); diff --git a/backend/src/lib/http-error.ts b/backend/src/lib/http-error.ts new file mode 100644 index 0000000..69cbcbb --- /dev/null +++ b/backend/src/lib/http-error.ts @@ -0,0 +1,12 @@ +export class HttpError extends Error { + status: number; + code: string; + details?: unknown; + + constructor(status: number, message: string, code = "HTTP_ERROR", details?: unknown) { + super(message); + this.status = status; + this.code = code; + this.details = details; + } +} diff --git a/backend/src/lib/prisma-json.ts b/backend/src/lib/prisma-json.ts new file mode 100644 index 0000000..a303ab8 --- /dev/null +++ b/backend/src/lib/prisma-json.ts @@ -0,0 +1,48 @@ +import type { Prisma } from "@prisma/client"; + +export function toPrismaJsonValue(value: unknown): Prisma.InputJsonValue { + if (value === null) { + return "null"; + } + + if (typeof value === "string" || typeof value === "boolean") { + return value; + } + + if (typeof value === "number") { + return Number.isFinite(value) ? value : String(value); + } + + if (typeof value === "bigint") { + return value.toString(); + } + + if (value instanceof Date) { + return value.toISOString(); + } + + if (value instanceof Error) { + return { + name: value.name, + message: value.message, + stack: value.stack ?? "" + }; + } + + if (Array.isArray(value)) { + return value.map((item) => toPrismaJsonValue(item)); + } + + if (typeof value === "object") { + const output: Record = {}; + + for (const [key, raw] of Object.entries(value as Record)) { + if (raw === undefined) continue; + output[key] = toPrismaJsonValue(raw); + } + + return output; + } + + return String(value); +} diff --git a/backend/src/lib/prisma.ts b/backend/src/lib/prisma.ts new file mode 100644 index 0000000..901f3a0 --- /dev/null +++ b/backend/src/lib/prisma.ts @@ -0,0 +1,3 @@ +import { PrismaClient } from "@prisma/client"; + +export const prisma = new PrismaClient(); diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts new file mode 100644 index 0000000..e117c7f --- /dev/null +++ b/backend/src/middleware/auth.ts @@ -0,0 +1,163 @@ +import type { NextFunction, Request as ExpressRequest, Response } from "express"; +import jwt, { type JwtPayload, type SignOptions } from "jsonwebtoken"; +import type { Role } from "@prisma/client"; +import { env } from "../config/env"; +import { HttpError } from "../lib/http-error"; + +type Permission = + | "vm:create" + | "vm:read" + | "vm:update" + | "vm:delete" + | "vm:start" + | "vm:stop" + | "node:manage" + | "node:read" + | "tenant:manage" + | "tenant:read" + | "billing:manage" + | "billing:read" + | "backup:manage" + | "backup:read" + | "rbac:manage" + | "settings:manage" + | "settings:read" + | "audit:read" + | "security:manage" + | "security:read" + | "user:manage" + | "user:read"; + +const rolePermissions: Record> = { + SUPER_ADMIN: new Set([ + "vm:create", + "vm:read", + "vm:update", + "vm:delete", + "vm:start", + "vm:stop", + "node:manage", + "node:read", + "tenant:manage", + "tenant:read", + "billing:manage", + "billing:read", + "backup:manage", + "backup:read", + "rbac:manage", + "settings:manage", + "settings:read", + "audit:read", + "security:manage", + "security:read", + "user:manage", + "user:read" + ]), + TENANT_ADMIN: new Set([ + "vm:create", + "vm:read", + "vm:update", + "vm:delete", + "vm:start", + "vm:stop", + "node:read", + "tenant:read", + "billing:read", + "backup:manage", + "backup:read", + "settings:read", + "audit:read", + "security:read", + "user:read" + ]), + OPERATOR: new Set([ + "vm:read", + "vm:start", + "vm:stop", + "node:manage", + "node:read", + "billing:read", + "backup:manage", + "backup:read", + "audit:read", + "security:manage", + "security:read" + ]), + VIEWER: new Set([ + "vm:read", + "node:read", + "tenant:read", + "billing:read", + "backup:read", + "audit:read", + "security:read", + "settings:read", + "user:read" + ]) +}; + +export function createJwtToken(payload: Express.UserToken): string { + const expiresIn = env.JWT_EXPIRES_IN as SignOptions["expiresIn"]; + return jwt.sign(payload, env.JWT_SECRET, { + expiresIn + }); +} + +export function createRefreshToken(payload: Express.UserToken): string { + const expiresIn = env.JWT_REFRESH_EXPIRES_IN as SignOptions["expiresIn"]; + return jwt.sign(payload, env.JWT_REFRESH_SECRET, { + expiresIn + }); +} + +export function verifyRefreshToken(token: string): Express.UserToken | null { + try { + const decoded = jwt.verify(token, env.JWT_REFRESH_SECRET) as JwtPayload & Express.UserToken; + if (!decoded?.id || !decoded?.email || !decoded?.role) { + return null; + } + return { + id: decoded.id, + email: decoded.email, + role: decoded.role, + tenant_id: decoded.tenant_id + }; + } catch { + return null; + } +} + +export function requireAuth(req: ExpressRequest, _res: Response, next: NextFunction) { + const authHeader = req.header("authorization"); + const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null; + + if (!token) { + return next(new HttpError(401, "Missing bearer token", "AUTH_REQUIRED")); + } + + try { + const decoded = jwt.verify(token, env.JWT_SECRET) as Express.UserToken; + req.user = decoded; + return next(); + } catch { + return next(new HttpError(401, "Invalid or expired token", "INVALID_TOKEN")); + } +} + +export function authorize(permission: Permission) { + return (req: ExpressRequest, _res: Response, next: NextFunction) => { + if (!req.user) { + return next(new HttpError(401, "Unauthenticated", "AUTH_REQUIRED")); + } + const allowed = rolePermissions[req.user.role]?.has(permission); + if (!allowed) { + return next(new HttpError(403, "Insufficient permission", "FORBIDDEN")); + } + return next(); + }; +} + +export function isTenantScopedUser(req: Pick): boolean { + if (!req.user) return false; + return req.user.role === "TENANT_ADMIN" || req.user.role === "VIEWER"; +} diff --git a/backend/src/middleware/error-handler.ts b/backend/src/middleware/error-handler.ts new file mode 100644 index 0000000..911ff4f --- /dev/null +++ b/backend/src/middleware/error-handler.ts @@ -0,0 +1,54 @@ +import type { NextFunction, Request, Response } from "express"; +import { Prisma } from "@prisma/client"; +import { ZodError } from "zod"; +import { HttpError } from "../lib/http-error"; + +export function notFoundHandler(_req: Request, res: Response) { + res.status(404).json({ + error: { + code: "NOT_FOUND", + message: "Resource not found" + } + }); +} + +export function errorHandler(error: unknown, _req: Request, res: Response, _next: NextFunction) { + if (error instanceof HttpError) { + return res.status(error.status).json({ + error: { + code: error.code, + message: error.message, + details: error.details + } + }); + } + + if (error instanceof ZodError) { + return res.status(400).json({ + error: { + code: "VALIDATION_ERROR", + message: "Payload validation failed", + details: error.flatten() + } + }); + } + + if (error instanceof Prisma.PrismaClientKnownRequestError) { + return res.status(400).json({ + error: { + code: "DATABASE_ERROR", + message: error.message, + details: error.meta + } + }); + } + + // eslint-disable-next-line no-console + console.error("Unhandled error:", error); + return res.status(500).json({ + error: { + code: "INTERNAL_SERVER_ERROR", + message: "An unexpected server error occurred" + } + }); +} diff --git a/backend/src/middleware/rate-limit.ts b/backend/src/middleware/rate-limit.ts new file mode 100644 index 0000000..9a823a9 --- /dev/null +++ b/backend/src/middleware/rate-limit.ts @@ -0,0 +1,60 @@ +import type { NextFunction, Request, Response } from "express"; + +type RateLimitOptions = { + windowMs: number; + max: number; + keyGenerator?: (req: Request) => string; +}; + +type Bucket = { + count: number; + resetAt: number; +}; + +export function createRateLimit(options: RateLimitOptions) { + const windowMs = Math.max(1_000, options.windowMs); + const max = Math.max(1, options.max); + const buckets = new Map(); + + return (req: Request, res: Response, next: NextFunction) => { + const key = options.keyGenerator?.(req) ?? req.ip ?? "unknown"; + const now = Date.now(); + const existing = buckets.get(key); + + if (!existing || existing.resetAt <= now) { + buckets.set(key, { + count: 1, + resetAt: now + windowMs + }); + res.setHeader("X-RateLimit-Limit", String(max)); + res.setHeader("X-RateLimit-Remaining", String(max - 1)); + res.setHeader("X-RateLimit-Reset", String(Math.ceil((now + windowMs) / 1000))); + return next(); + } + + existing.count += 1; + const remaining = Math.max(0, max - existing.count); + res.setHeader("X-RateLimit-Limit", String(max)); + res.setHeader("X-RateLimit-Remaining", String(remaining)); + res.setHeader("X-RateLimit-Reset", String(Math.ceil(existing.resetAt / 1000))); + + if (existing.count > max) { + return res.status(429).json({ + error: { + code: "RATE_LIMIT_EXCEEDED", + message: "Too many requests. Please retry later." + } + }); + } + + if (buckets.size > 10_000) { + for (const [bucketKey, bucketValue] of buckets.entries()) { + if (bucketValue.resetAt <= now) { + buckets.delete(bucketKey); + } + } + } + + return next(); + }; +} diff --git a/backend/src/routes/auth.routes.ts b/backend/src/routes/auth.routes.ts new file mode 100644 index 0000000..b5fbd8d --- /dev/null +++ b/backend/src/routes/auth.routes.ts @@ -0,0 +1,123 @@ +import { Router } from "express"; +import bcrypt from "bcryptjs"; +import { z } from "zod"; +import { prisma } from "../lib/prisma"; +import { HttpError } from "../lib/http-error"; +import { createJwtToken, createRefreshToken, requireAuth, verifyRefreshToken } from "../middleware/auth"; + +const router = Router(); + +const loginSchema = z.object({ + email: z.string().email(), + password: z.string().min(1) +}); + +const refreshSchema = z.object({ + refresh_token: z.string().min(1) +}); + +router.post("/login", async (req, res, next) => { + try { + const payload = loginSchema.parse(req.body); + const user = await prisma.user.findUnique({ where: { email: payload.email } }); + if (!user || !user.is_active) { + throw new HttpError(401, "Invalid email or password", "INVALID_CREDENTIALS"); + } + const matched = await bcrypt.compare(payload.password, user.password_hash); + if (!matched) { + throw new HttpError(401, "Invalid email or password", "INVALID_CREDENTIALS"); + } + + await prisma.user.update({ + where: { id: user.id }, + data: { last_login_at: new Date() } + }); + + const userPayload = { + id: user.id, + email: user.email, + role: user.role, + tenant_id: user.tenant_id + }; + const token = createJwtToken(userPayload); + const refreshToken = createRefreshToken(userPayload); + + res.json({ + token, + refresh_token: refreshToken, + user: { + id: user.id, + email: user.email, + full_name: user.full_name, + role: user.role, + tenant_id: user.tenant_id + } + }); + } catch (error) { + next(error); + } +}); + +router.post("/refresh", async (req, res, next) => { + try { + const payload = refreshSchema.parse(req.body ?? {}); + const decoded = verifyRefreshToken(payload.refresh_token); + if (!decoded) { + throw new HttpError(401, "Invalid refresh token", "INVALID_REFRESH_TOKEN"); + } + + const user = await prisma.user.findUnique({ + where: { id: decoded.id }, + select: { + id: true, + email: true, + role: true, + tenant_id: true, + is_active: true + } + }); + if (!user || !user.is_active) { + throw new HttpError(401, "Refresh token user is invalid", "INVALID_REFRESH_TOKEN"); + } + + const userPayload = { + id: user.id, + email: user.email, + role: user.role, + tenant_id: user.tenant_id + }; + const token = createJwtToken(userPayload); + const refreshToken = createRefreshToken(userPayload); + + res.json({ + token, + refresh_token: refreshToken + }); + } catch (error) { + next(error); + } +}); + +router.get("/me", requireAuth, async (req, res, next) => { + try { + const user = await prisma.user.findUnique({ + where: { id: req.user!.id }, + select: { + id: true, + email: true, + full_name: true, + role: true, + tenant_id: true, + is_active: true, + created_at: true + } + }); + if (!user) throw new HttpError(404, "User not found", "USER_NOT_FOUND"); + if (!user.is_active) throw new HttpError(401, "User account is inactive", "USER_INACTIVE"); + res.json(user); + } catch (error) { + next(error); + } +}); + +export default router; diff --git a/backend/src/routes/backup.routes.ts b/backend/src/routes/backup.routes.ts new file mode 100644 index 0000000..ab0b9d1 --- /dev/null +++ b/backend/src/routes/backup.routes.ts @@ -0,0 +1,491 @@ +import { + BackupRestoreMode, + BackupRestoreStatus, + BackupSchedule, + BackupSource, + BackupStatus, + BackupType, + SnapshotFrequency +} from "@prisma/client"; +import { Router } from "express"; +import { z } from "zod"; +import { HttpError } from "../lib/http-error"; +import { prisma } from "../lib/prisma"; +import { authorize, isTenantScopedUser, requireAuth } from "../middleware/auth"; +import { logAudit } from "../services/audit.service"; +import { + createBackup, + createRestoreTask, + createSnapshotJob, + deleteBackup, + deleteSnapshotJob, + listBackupPolicies, + listBackups, + listRestoreTasks, + listSnapshotJobs, + runRestoreTaskNow, + runSnapshotJobNow, + toggleBackupProtection, + updateSnapshotJob, + upsertBackupPolicy +} from "../services/backup.service"; + +const router = Router(); + +const createBackupSchema = z.object({ + vm_id: z.string().min(1), + type: z.nativeEnum(BackupType).optional(), + source: z.nativeEnum(BackupSource).optional(), + schedule: z.nativeEnum(BackupSchedule).optional(), + retention_days: z.number().int().positive().optional(), + storage: z.string().optional(), + route_key: z.string().optional(), + is_protected: z.boolean().optional(), + notes: z.string().optional(), + requested_size_mb: z.number().positive().optional() +}); + +const protectionSchema = z.object({ + is_protected: z.boolean() +}); + +const createRestoreSchema = z.object({ + backup_id: z.string().min(1), + target_vm_id: z.string().optional(), + mode: z.nativeEnum(BackupRestoreMode), + requested_files: z.array(z.string().min(1)).optional(), + pbs_enabled: z.boolean().optional(), + run_immediately: z.boolean().default(true) +}); + +const createSnapshotSchema = z.object({ + vm_id: z.string().min(1), + name: z.string().min(2), + frequency: z.nativeEnum(SnapshotFrequency), + interval: z.number().int().positive().optional(), + day_of_week: z.number().int().min(0).max(6).optional(), + hour_utc: z.number().int().min(0).max(23).optional(), + minute_utc: z.number().int().min(0).max(59).optional(), + retention: z.number().int().positive().optional(), + enabled: z.boolean().optional() +}); + +const updateSnapshotSchema = z.object({ + name: z.string().min(2).optional(), + frequency: z.nativeEnum(SnapshotFrequency).optional(), + interval: z.number().int().positive().optional(), + day_of_week: z.number().int().min(0).max(6).nullable().optional(), + hour_utc: z.number().int().min(0).max(23).optional(), + minute_utc: z.number().int().min(0).max(59).optional(), + retention: z.number().int().positive().optional(), + enabled: z.boolean().optional() +}); + +const upsertPolicySchema = z.object({ + tenant_id: z.string().optional(), + billing_plan_id: z.string().optional(), + max_files: z.number().int().positive().optional(), + max_total_size_mb: z.number().positive().optional(), + max_protected_files: z.number().int().positive().optional(), + allow_file_restore: z.boolean().optional(), + allow_cross_vm_restore: z.boolean().optional(), + allow_pbs_restore: z.boolean().optional() +}); + +function parseOptionalBackupStatus(value: unknown) { + if (typeof value !== "string") return undefined; + const normalized = value.toUpperCase(); + return Object.values(BackupStatus).includes(normalized as BackupStatus) + ? (normalized as BackupStatus) + : undefined; +} + +function parseOptionalRestoreStatus(value: unknown) { + if (typeof value !== "string") return undefined; + const normalized = value.toUpperCase(); + return Object.values(BackupRestoreStatus).includes(normalized as BackupRestoreStatus) + ? (normalized as BackupRestoreStatus) + : undefined; +} + +async function ensureVmTenantScope(vmId: string, req: Express.Request) { + const vm = await prisma.virtualMachine.findUnique({ + where: { id: vmId }, + select: { + id: true, + tenant_id: true, + name: true + } + }); + + if (!vm) throw new HttpError(404, "VM not found", "VM_NOT_FOUND"); + + if (isTenantScopedUser(req) && req.user?.tenant_id && vm.tenant_id !== req.user.tenant_id) { + throw new HttpError(403, "Access denied for tenant scope", "TENANT_SCOPE_VIOLATION"); + } + + return vm; +} + +async function ensureBackupTenantScope(backupId: string, req: Express.Request) { + const backup = await prisma.backup.findUnique({ + where: { id: backupId }, + include: { + vm: { + select: { + id: true, + tenant_id: true, + name: true + } + } + } + }); + + if (!backup) throw new HttpError(404, "Backup not found", "BACKUP_NOT_FOUND"); + + const tenantId = backup.tenant_id ?? backup.vm.tenant_id; + if (isTenantScopedUser(req) && req.user?.tenant_id && tenantId !== req.user.tenant_id) { + throw new HttpError(403, "Access denied for tenant scope", "TENANT_SCOPE_VIOLATION"); + } + + return backup; +} + +async function ensureRestoreTaskTenantScope(taskId: string, req: Express.Request) { + const task = await prisma.backupRestoreTask.findUnique({ + where: { id: taskId }, + include: { + source_vm: { + select: { + tenant_id: true + } + } + } + }); + + if (!task) throw new HttpError(404, "Restore task not found", "RESTORE_TASK_NOT_FOUND"); + if (isTenantScopedUser(req) && req.user?.tenant_id && task.source_vm.tenant_id !== req.user.tenant_id) { + throw new HttpError(403, "Access denied for tenant scope", "TENANT_SCOPE_VIOLATION"); + } + + return task; +} + +async function ensureSnapshotJobTenantScope(jobId: string, req: Express.Request) { + const job = await prisma.snapshotJob.findUnique({ + where: { id: jobId }, + include: { + vm: { + select: { + tenant_id: true + } + } + } + }); + + if (!job) throw new HttpError(404, "Snapshot job not found", "SNAPSHOT_JOB_NOT_FOUND"); + if (isTenantScopedUser(req) && req.user?.tenant_id && job.vm.tenant_id !== req.user.tenant_id) { + throw new HttpError(403, "Access denied for tenant scope", "TENANT_SCOPE_VIOLATION"); + } + return job; +} + +router.get("/", requireAuth, authorize("backup:read"), async (req, res, next) => { + try { + const status = parseOptionalBackupStatus(req.query.status); + const vmId = typeof req.query.vm_id === "string" ? req.query.vm_id : undefined; + const limit = typeof req.query.limit === "string" ? Number(req.query.limit) : undefined; + const offset = typeof req.query.offset === "string" ? Number(req.query.offset) : undefined; + + if (vmId) { + await ensureVmTenantScope(vmId, req); + } + + const result = await listBackups({ + tenantId: isTenantScopedUser(req) ? req.user?.tenant_id ?? undefined : undefined, + status, + vmId, + limit, + offset + }); + + res.json(result); + } catch (error) { + next(error); + } +}); + +router.post("/", requireAuth, authorize("backup:manage"), async (req, res, next) => { + try { + const payload = createBackupSchema.parse(req.body ?? {}); + await ensureVmTenantScope(payload.vm_id, req); + + const backup = await createBackup({ + vmId: payload.vm_id, + type: payload.type, + source: payload.source, + schedule: payload.schedule, + retentionDays: payload.retention_days, + storage: payload.storage, + routeKey: payload.route_key, + isProtected: payload.is_protected, + notes: payload.notes, + requestedSizeMb: payload.requested_size_mb, + createdBy: req.user?.email + }); + + await logAudit({ + action: "backup.create", + resource_type: "BACKUP", + resource_id: backup.id, + resource_name: backup.vm_name, + actor_email: req.user!.email, + actor_role: req.user!.role, + details: payload, + ip_address: req.ip + }); + + res.status(201).json(backup); + } catch (error) { + next(error); + } +}); + +router.patch("/:id/protection", requireAuth, authorize("backup:manage"), async (req, res, next) => { + try { + const payload = protectionSchema.parse(req.body ?? {}); + await ensureBackupTenantScope(req.params.id, req); + + const backup = await toggleBackupProtection(req.params.id, payload.is_protected); + res.json(backup); + } catch (error) { + next(error); + } +}); + +router.delete("/:id", requireAuth, authorize("backup:manage"), async (req, res, next) => { + try { + await ensureBackupTenantScope(req.params.id, req); + const force = req.query.force === "true"; + await deleteBackup(req.params.id, force); + res.status(204).send(); + } catch (error) { + next(error); + } +}); + +router.get("/restores", requireAuth, authorize("backup:read"), async (req, res, next) => { + try { + const status = parseOptionalRestoreStatus(req.query.status); + const limit = typeof req.query.limit === "string" ? Number(req.query.limit) : undefined; + const offset = typeof req.query.offset === "string" ? Number(req.query.offset) : undefined; + + const result = await listRestoreTasks({ + tenantId: isTenantScopedUser(req) ? req.user?.tenant_id ?? undefined : undefined, + status, + limit, + offset + }); + + res.json(result); + } catch (error) { + next(error); + } +}); + +router.post("/restores", requireAuth, authorize("backup:manage"), async (req, res, next) => { + try { + const payload = createRestoreSchema.parse(req.body ?? {}); + await ensureBackupTenantScope(payload.backup_id, req); + + if (payload.target_vm_id) { + await ensureVmTenantScope(payload.target_vm_id, req); + } + + const task = await createRestoreTask({ + backupId: payload.backup_id, + targetVmId: payload.target_vm_id, + mode: payload.mode, + requestedFiles: payload.requested_files, + pbsEnabled: payload.pbs_enabled, + createdBy: req.user?.email, + runImmediately: payload.run_immediately + }); + + await logAudit({ + action: "backup.restore.create", + resource_type: "BACKUP", + resource_id: payload.backup_id, + actor_email: req.user!.email, + actor_role: req.user!.role, + details: payload, + ip_address: req.ip + }); + + res.status(201).json(task); + } catch (error) { + next(error); + } +}); + +router.post("/restores/:id/run", requireAuth, authorize("backup:manage"), async (req, res, next) => { + try { + await ensureRestoreTaskTenantScope(req.params.id, req); + const task = await runRestoreTaskNow(req.params.id); + res.json(task); + } catch (error) { + next(error); + } +}); + +router.get("/snapshot-jobs", requireAuth, authorize("backup:read"), async (req, res, next) => { + try { + const jobs = await listSnapshotJobs({ + tenantId: isTenantScopedUser(req) ? req.user?.tenant_id ?? undefined : undefined + }); + res.json({ data: jobs }); + } catch (error) { + next(error); + } +}); + +router.post("/snapshot-jobs", requireAuth, authorize("backup:manage"), async (req, res, next) => { + try { + const payload = createSnapshotSchema.parse(req.body ?? {}); + await ensureVmTenantScope(payload.vm_id, req); + + const job = await createSnapshotJob({ + vmId: payload.vm_id, + name: payload.name, + frequency: payload.frequency, + interval: payload.interval, + dayOfWeek: payload.day_of_week, + hourUtc: payload.hour_utc, + minuteUtc: payload.minute_utc, + retention: payload.retention, + enabled: payload.enabled, + createdBy: req.user?.email + }); + + await logAudit({ + action: "snapshot_job.create", + resource_type: "BACKUP", + resource_id: job.id, + resource_name: job.name, + actor_email: req.user!.email, + actor_role: req.user!.role, + details: payload, + ip_address: req.ip + }); + + res.status(201).json(job); + } catch (error) { + next(error); + } +}); + +router.patch("/snapshot-jobs/:id", requireAuth, authorize("backup:manage"), async (req, res, next) => { + try { + const payload = updateSnapshotSchema.parse(req.body ?? {}); + await ensureSnapshotJobTenantScope(req.params.id, req); + + const job = await updateSnapshotJob(req.params.id, { + name: payload.name, + frequency: payload.frequency, + interval: payload.interval, + dayOfWeek: payload.day_of_week, + hourUtc: payload.hour_utc, + minuteUtc: payload.minute_utc, + retention: payload.retention, + enabled: payload.enabled + }); + + res.json(job); + } catch (error) { + next(error); + } +}); + +router.delete("/snapshot-jobs/:id", requireAuth, authorize("backup:manage"), async (req, res, next) => { + try { + await ensureSnapshotJobTenantScope(req.params.id, req); + await deleteSnapshotJob(req.params.id); + res.status(204).send(); + } catch (error) { + next(error); + } +}); + +router.post("/snapshot-jobs/:id/run", requireAuth, authorize("backup:manage"), async (req, res, next) => { + try { + await ensureSnapshotJobTenantScope(req.params.id, req); + const result = await runSnapshotJobNow(req.params.id); + res.json(result); + } catch (error) { + next(error); + } +}); + +router.get("/policies", requireAuth, authorize("backup:read"), async (_req, res, next) => { + try { + const all = await listBackupPolicies(); + const data = + isTenantScopedUser(_req) && _req.user?.tenant_id + ? all.filter((item) => item.tenant_id === _req.user?.tenant_id) + : all; + res.json({ data }); + } catch (error) { + next(error); + } +}); + +router.post("/policies", requireAuth, authorize("backup:manage"), async (req, res, next) => { + try { + const payload = upsertPolicySchema.parse(req.body ?? {}); + const tenantId = isTenantScopedUser(req) ? req.user?.tenant_id ?? undefined : payload.tenant_id; + if (isTenantScopedUser(req) && payload.tenant_id && req.user?.tenant_id && payload.tenant_id !== req.user.tenant_id) { + throw new HttpError(403, "Access denied for tenant scope", "TENANT_SCOPE_VIOLATION"); + } + const policy = await upsertBackupPolicy({ + tenantId, + billingPlanId: payload.billing_plan_id, + maxFiles: payload.max_files, + maxTotalSizeMb: payload.max_total_size_mb, + maxProtectedFiles: payload.max_protected_files, + allowFileRestore: payload.allow_file_restore, + allowCrossVmRestore: payload.allow_cross_vm_restore, + allowPbsRestore: payload.allow_pbs_restore + }); + + res.status(201).json(policy); + } catch (error) { + next(error); + } +}); + +router.patch("/policies/:id", requireAuth, authorize("backup:manage"), async (req, res, next) => { + try { + const payload = upsertPolicySchema.parse(req.body ?? {}); + const tenantId = isTenantScopedUser(req) ? req.user?.tenant_id ?? undefined : payload.tenant_id; + if (isTenantScopedUser(req) && payload.tenant_id && req.user?.tenant_id && payload.tenant_id !== req.user.tenant_id) { + throw new HttpError(403, "Access denied for tenant scope", "TENANT_SCOPE_VIOLATION"); + } + const policy = await upsertBackupPolicy({ + policyId: req.params.id, + tenantId, + billingPlanId: payload.billing_plan_id, + maxFiles: payload.max_files, + maxTotalSizeMb: payload.max_total_size_mb, + maxProtectedFiles: payload.max_protected_files, + allowFileRestore: payload.allow_file_restore, + allowCrossVmRestore: payload.allow_cross_vm_restore, + allowPbsRestore: payload.allow_pbs_restore + }); + + res.json(policy); + } catch (error) { + next(error); + } +}); + +export default router; diff --git a/backend/src/routes/billing.routes.ts b/backend/src/routes/billing.routes.ts new file mode 100644 index 0000000..3fb1a1f --- /dev/null +++ b/backend/src/routes/billing.routes.ts @@ -0,0 +1,46 @@ +import { Router } from "express"; +import { z } from "zod"; +import { authorize, requireAuth } from "../middleware/auth"; +import { generateInvoicesFromUnbilledUsage, markInvoicePaid, meterHourlyUsage } from "../services/billing.service"; + +const router = Router(); + +router.post("/meter/hourly", requireAuth, authorize("billing:manage"), async (req, res, next) => { + try { + const result = await meterHourlyUsage(req.user?.email ?? "system@proxpanel.local"); + res.json(result); + } catch (error) { + next(error); + } +}); + +router.post("/invoices/generate", requireAuth, authorize("billing:manage"), async (req, res, next) => { + try { + const result = await generateInvoicesFromUnbilledUsage(req.user?.email ?? "system@proxpanel.local"); + res.json(result); + } catch (error) { + next(error); + } +}); + +const markPaidSchema = z.object({ + payment_provider: z.enum(["PAYSTACK", "FLUTTERWAVE", "MANUAL"]).default("MANUAL"), + payment_reference: z.string().min(2) +}); + +router.post("/invoices/:id/pay", requireAuth, authorize("billing:manage"), async (req, res, next) => { + try { + const payload = markPaidSchema.parse(req.body ?? {}); + const invoice = await markInvoicePaid( + req.params.id, + payload.payment_provider, + payload.payment_reference, + req.user?.email ?? "system@proxpanel.local" + ); + res.json(invoice); + } catch (error) { + next(error); + } +}); + +export default router; diff --git a/backend/src/routes/client.routes.ts b/backend/src/routes/client.routes.ts new file mode 100644 index 0000000..782eb11 --- /dev/null +++ b/backend/src/routes/client.routes.ts @@ -0,0 +1,1247 @@ +import { + Direction, + FirewallAction, + PowerScheduleAction, + Prisma, + ProductType, + Protocol, + SnapshotFrequency, + VmType +} from "@prisma/client"; +import { Router } from "express"; +import { z } from "zod"; +import { HttpError } from "../lib/http-error"; +import { prisma } from "../lib/prisma"; +import { toPrismaJsonValue } from "../lib/prisma-json"; +import { authorize, isTenantScopedUser, requireAuth } from "../middleware/auth"; +import { logAudit } from "../services/audit.service"; +import { createSnapshotJob } from "../services/backup.service"; +import { attachPrivateNetwork } from "../services/network.service"; +import { createPowerSchedule } from "../services/operations.service"; +import { + createProvisionedService, + updateProvisionedServicePackage +} from "../services/provisioning.service"; +import { addVmDisk } from "../services/proxmox.service"; + +const router = Router(); +const FIREWALL_POLICY_PACKS_KEY = "firewall_policy_packs"; + +type TenantUsage = { + vmCount: number; + cpuCores: number; + ramMb: number; + diskGb: number; +}; + +type UsageTrendDay = { + date: string; + cpu_hours: number; + ram_gb_hours: number; + disk_gb_hours: number; + vm_count: number; + cpu_pct: number; + ram_pct: number; + disk_pct: number; + vm_pct: number; +}; + +type FirewallPolicyPackRule = { + name: string; + direction: Direction; + action: FirewallAction; + protocol: Protocol; + source_ip?: string; + destination_ip?: string; + port_range?: string; + priority?: number; + description?: string; + enabled?: boolean; +}; + +type FirewallPolicyPackRecord = { + id: string; + tenant_id?: string; + name: string; + description?: string; + is_active: boolean; + rules: FirewallPolicyPackRule[]; + created_by?: string; + created_at: string; + updated_at: string; +}; + +const createMachineSchema = z.object({ + tenant_id: z.string().optional(), + name: z.string().min(2), + machine_type: z.nativeEnum(VmType).default(VmType.QEMU), + description: z.string().optional(), + target_node: z.string().optional(), + auto_node: z.boolean().default(true), + application_group_id: z.string().optional(), + template_id: z.string().optional(), + billing_plan_id: z.string().optional(), + cpu_cores: z.number().int().min(1).max(128).default(2), + ram_mb: z.number().int().min(256).max(2_097_152).default(2048), + disk_gb: z.number().int().min(5).max(131_072).default(40), + sockets: z.number().int().min(1).max(16).optional(), + vcpus: z.number().int().min(1).max(512).optional(), + cpu_priority: z.number().int().min(1).max(100).optional(), + swap_mb: z.number().int().min(0).max(2_097_152).optional(), + default_user: z.string().optional(), + password: z.string().optional(), + ssh_key: z.string().optional(), + search_domain: z.string().optional(), + name_servers: z.array(z.string()).optional(), + private_network_ids: z.array(z.string().min(1)).optional(), + additional_disks: z + .array( + z.object({ + storage: z.string().min(1), + size_gb: z.number().int().positive(), + bus: z.enum(["scsi", "sata", "virtio", "ide"]).optional(), + mount_point: z.string().optional() + }) + ) + .optional() +}); + +const resizeMachineSchema = z + .object({ + cpu_cores: z.number().int().min(1).max(128).optional(), + ram_mb: z.number().int().min(256).max(2_097_152).optional(), + disk_gb: z.number().int().min(5).max(131_072).optional() + }) + .refine((value) => Object.keys(value).length > 0, { + message: "At least one resource field is required" + }); + +const firewallRuleSchema = z.object({ + vm_id: z.string().min(1), + name: z.string().min(2), + direction: z.nativeEnum(Direction).default(Direction.INBOUND), + action: z.nativeEnum(FirewallAction).default(FirewallAction.DENY), + protocol: z.nativeEnum(Protocol).default(Protocol.TCP), + source_ip: z.string().optional(), + destination_ip: z.string().optional(), + port_range: z.string().optional(), + priority: z.number().int().min(1).max(10_000).default(100), + enabled: z.boolean().default(true), + description: z.string().optional() +}); + +const firewallRulePatchSchema = firewallRuleSchema.partial().omit({ vm_id: true }); + +const firewallPackRuleSchema = z.object({ + name: z.string().min(2), + direction: z.nativeEnum(Direction).default(Direction.INBOUND), + action: z.nativeEnum(FirewallAction).default(FirewallAction.DENY), + protocol: z.nativeEnum(Protocol).default(Protocol.TCP), + source_ip: z.string().optional(), + destination_ip: z.string().optional(), + port_range: z.string().optional(), + priority: z.number().int().min(1).max(10_000).optional(), + description: z.string().optional(), + enabled: z.boolean().optional() +}); + +const firewallPolicyPackCreateSchema = z.object({ + tenant_id: z.string().optional(), + name: z.string().min(2), + description: z.string().optional(), + is_active: z.boolean().default(true), + rules: z.array(firewallPackRuleSchema).min(1) +}); + +const firewallPolicyPackPatchSchema = z.object({ + name: z.string().min(2).optional(), + description: z.string().optional(), + is_active: z.boolean().optional(), + rules: z.array(firewallPackRuleSchema).min(1).optional() +}); + +const applyPolicyPackSchema = z.object({ + vm_id: z.string().min(1), + priority_offset: z.number().int().min(-10_000).max(10_000).default(0) +}); + +const createPowerScheduleSchema = z.object({ + action: z.nativeEnum(PowerScheduleAction), + cron_expression: z.string().min(5), + timezone: z.string().default("UTC") +}); + +const createBackupScheduleSchema = z.object({ + name: z.string().min(2), + frequency: z.nativeEnum(SnapshotFrequency).default(SnapshotFrequency.DAILY), + interval: z.number().int().positive().optional(), + day_of_week: z.number().int().min(0).max(6).optional(), + hour_utc: z.number().int().min(0).max(23).optional(), + minute_utc: z.number().int().min(0).max(59).optional(), + retention: z.number().int().positive().optional(), + enabled: z.boolean().optional() +}); + +function toJsonObject(value: Prisma.JsonValue | null | undefined): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return {}; + } + return value as Record; +} + +async function resolveTenantId(req: Express.Request, explicitTenantId?: string) { + if (isTenantScopedUser(req)) { + if (!req.user?.tenant_id) { + throw new HttpError(400, "Tenant context is required", "TENANT_CONTEXT_REQUIRED"); + } + if (explicitTenantId && explicitTenantId !== req.user.tenant_id) { + throw new HttpError(403, "Access denied for tenant scope", "TENANT_SCOPE_VIOLATION"); + } + return req.user.tenant_id; + } + + return explicitTenantId; +} + +async function ensureVmTenantScope(vmId: string, req: Express.Request, explicitTenantId?: string) { + const vm = await prisma.virtualMachine.findUnique({ + where: { id: vmId }, + select: { + id: true, + name: true, + node: true, + vmid: true, + type: true, + tenant_id: true + } + }); + + if (!vm) { + throw new HttpError(404, "VM not found", "VM_NOT_FOUND"); + } + + if (isTenantScopedUser(req) && req.user?.tenant_id && vm.tenant_id !== req.user.tenant_id) { + throw new HttpError(403, "Access denied for tenant scope", "TENANT_SCOPE_VIOLATION"); + } + + if (explicitTenantId && vm.tenant_id !== explicitTenantId) { + throw new HttpError(403, "VM does not belong to requested tenant", "TENANT_SCOPE_VIOLATION"); + } + + return vm; +} + +async function currentTenantUsage(tenantId: string): Promise { + const aggregate = await prisma.virtualMachine.aggregate({ + where: { + tenant_id: tenantId + }, + _count: { + id: true + }, + _sum: { + cpu_cores: true, + ram_mb: true, + disk_gb: true + } + }); + + return { + vmCount: aggregate._count.id, + cpuCores: aggregate._sum.cpu_cores ?? 0, + ramMb: aggregate._sum.ram_mb ?? 0, + diskGb: aggregate._sum.disk_gb ?? 0 + }; +} + +function toFiniteNumber(value: unknown) { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + + if (typeof value === "string") { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; + } + + if (value && typeof value === "object" && "toString" in value) { + const parsed = Number(String(value)); + return Number.isFinite(parsed) ? parsed : 0; + } + + return 0; +} + +function startOfUtcDay(input: Date) { + return new Date(Date.UTC(input.getUTCFullYear(), input.getUTCMonth(), input.getUTCDate())); +} + +function addUtcDays(input: Date, days: number) { + const next = new Date(input); + next.setUTCDate(next.getUTCDate() + days); + return next; +} + +function dateKey(input: Date) { + return input.toISOString().slice(0, 10); +} + +function enforceTenantLimits( + tenant: { + name: string; + vm_limit: number; + cpu_limit: number; + ram_limit_mb: number; + disk_limit_gb: number; + }, + usage: TenantUsage, + delta: TenantUsage +) { + const projected = { + vmCount: usage.vmCount + delta.vmCount, + cpuCores: usage.cpuCores + delta.cpuCores, + ramMb: usage.ramMb + delta.ramMb, + diskGb: usage.diskGb + delta.diskGb + }; + + const violations: string[] = []; + if (tenant.vm_limit > 0 && projected.vmCount > tenant.vm_limit) { + violations.push(`machine count (${projected.vmCount}/${tenant.vm_limit})`); + } + if (tenant.cpu_limit > 0 && projected.cpuCores > tenant.cpu_limit) { + violations.push(`CPU cores (${projected.cpuCores}/${tenant.cpu_limit})`); + } + if (tenant.ram_limit_mb > 0 && projected.ramMb > tenant.ram_limit_mb) { + violations.push(`RAM MB (${projected.ramMb}/${tenant.ram_limit_mb})`); + } + if (tenant.disk_limit_gb > 0 && projected.diskGb > tenant.disk_limit_gb) { + violations.push(`Disk GB (${projected.diskGb}/${tenant.disk_limit_gb})`); + } + + if (violations.length > 0) { + throw new HttpError( + 400, + `Tenant ${tenant.name} exceeds limits: ${violations.join(", ")}`, + "TENANT_LIMIT_EXCEEDED", + { + projected, + limits: { + vm_limit: tenant.vm_limit, + cpu_limit: tenant.cpu_limit, + ram_limit_mb: tenant.ram_limit_mb, + disk_limit_gb: tenant.disk_limit_gb + } + } + ); + } +} + +async function loadFirewallPolicyPacks() { + const setting = await prisma.setting.findUnique({ + where: { + key: FIREWALL_POLICY_PACKS_KEY + } + }); + + const value = setting?.value as Prisma.JsonValue | undefined; + if (!Array.isArray(value)) { + return [] as FirewallPolicyPackRecord[]; + } + + const parsed = value.filter((item) => item && typeof item === "object"); + return parsed as FirewallPolicyPackRecord[]; +} + +async function saveFirewallPolicyPacks(packs: FirewallPolicyPackRecord[]) { + await prisma.setting.upsert({ + where: { + key: FIREWALL_POLICY_PACKS_KEY + }, + update: { + value: packs as unknown as Prisma.InputJsonValue + }, + create: { + key: FIREWALL_POLICY_PACKS_KEY, + type: "SECURITY", + value: packs as unknown as Prisma.InputJsonValue, + is_encrypted: false + } + }); +} + +router.get("/overview", requireAuth, authorize("vm:read"), async (req, res, next) => { + try { + const tenantId = await resolveTenantId( + req, + typeof req.query.tenant_id === "string" ? req.query.tenant_id : undefined + ); + + const vmWhere: Prisma.VirtualMachineWhereInput = tenantId + ? { + tenant_id: tenantId + } + : {}; + + const [vmCount, runningCount, backupCount, openAlertCount] = await Promise.all([ + prisma.virtualMachine.count({ where: vmWhere }), + prisma.virtualMachine.count({ + where: { + ...vmWhere, + status: "RUNNING" + } + }), + prisma.backup.count({ + where: tenantId + ? { + OR: [{ tenant_id: tenantId }, { vm: { tenant_id: tenantId } }] + } + : {} + }), + prisma.monitoringAlertEvent.count({ + where: tenantId + ? { + tenant_id: tenantId, + status: "OPEN" + } + : { + status: "OPEN" + } + }) + ]); + + const usage = tenantId ? await currentTenantUsage(tenantId) : null; + const tenant = tenantId + ? await prisma.tenant.findUnique({ + where: { + id: tenantId + }, + select: { + id: true, + name: true, + vm_limit: true, + cpu_limit: true, + ram_limit_mb: true, + disk_limit_gb: true + } + }) + : null; + + res.json({ + summary: { + machines: vmCount, + running: runningCount, + backups: backupCount, + open_alerts: openAlertCount + }, + limits: + usage && tenant + ? { + usage, + caps: { + vm_limit: tenant.vm_limit, + cpu_limit: tenant.cpu_limit, + ram_limit_mb: tenant.ram_limit_mb, + disk_limit_gb: tenant.disk_limit_gb + } + } + : null + }); + } catch (error) { + next(error); + } +}); + +router.get("/usage-trends", requireAuth, authorize("vm:read"), async (req, res, next) => { + try { + const tenantId = await resolveTenantId( + req, + typeof req.query.tenant_id === "string" ? req.query.tenant_id : undefined + ); + + if (!tenantId) { + throw new HttpError(400, "tenant_id is required", "TENANT_ID_REQUIRED"); + } + + const daysRaw = typeof req.query.days === "string" ? Number(req.query.days) : 14; + const days = Math.min(Math.max(Number.isFinite(daysRaw) ? Math.floor(daysRaw) : 14, 7), 90); + + const tenant = await prisma.tenant.findUnique({ + where: { + id: tenantId + }, + select: { + id: true, + name: true, + vm_limit: true, + cpu_limit: true, + ram_limit_mb: true, + disk_limit_gb: true + } + }); + + if (!tenant) { + throw new HttpError(404, "Tenant not found", "TENANT_NOT_FOUND"); + } + + const today = startOfUtcDay(new Date()); + const start = addUtcDays(today, -(days - 1)); + const endExclusive = addUtcDays(today, 1); + + const records = await prisma.usageRecord.findMany({ + where: { + tenant_id: tenantId, + period_start: { + gte: start + }, + period_end: { + lt: endExclusive + } + }, + select: { + vm_id: true, + period_start: true, + cpu_hours: true, + ram_gb_hours: true, + disk_gb_hours: true + }, + orderBy: { + period_start: "asc" + } + }); + + const dayMap = new Map< + string, + { + cpu_hours: number; + ram_gb_hours: number; + disk_gb_hours: number; + vm_ids: Set; + } + >(); + + for (let index = 0; index < days; index += 1) { + const date = addUtcDays(start, index); + dayMap.set(dateKey(date), { + cpu_hours: 0, + ram_gb_hours: 0, + disk_gb_hours: 0, + vm_ids: new Set() + }); + } + + for (const record of records) { + const key = dateKey(startOfUtcDay(record.period_start)); + const bucket = dayMap.get(key); + if (!bucket) continue; + + bucket.cpu_hours += toFiniteNumber(record.cpu_hours); + bucket.ram_gb_hours += toFiniteNumber(record.ram_gb_hours); + bucket.disk_gb_hours += toFiniteNumber(record.disk_gb_hours); + bucket.vm_ids.add(record.vm_id); + } + + const cpuDailyCap = tenant.cpu_limit > 0 ? tenant.cpu_limit * 24 : 0; + const ramDailyCap = tenant.ram_limit_mb > 0 ? (tenant.ram_limit_mb / 1024) * 24 : 0; + const diskDailyCap = tenant.disk_limit_gb > 0 ? tenant.disk_limit_gb * 24 : 0; + + const trend: UsageTrendDay[] = Array.from(dayMap.entries()).map(([date, value]) => { + const vmCount = value.vm_ids.size; + return { + date, + cpu_hours: Number(value.cpu_hours.toFixed(4)), + ram_gb_hours: Number(value.ram_gb_hours.toFixed(4)), + disk_gb_hours: Number(value.disk_gb_hours.toFixed(4)), + vm_count: vmCount, + cpu_pct: cpuDailyCap > 0 ? Number(Math.min(100, (value.cpu_hours / cpuDailyCap) * 100).toFixed(2)) : 0, + ram_pct: ramDailyCap > 0 ? Number(Math.min(100, (value.ram_gb_hours / ramDailyCap) * 100).toFixed(2)) : 0, + disk_pct: diskDailyCap > 0 ? Number(Math.min(100, (value.disk_gb_hours / diskDailyCap) * 100).toFixed(2)) : 0, + vm_pct: tenant.vm_limit > 0 ? Number(Math.min(100, (vmCount / tenant.vm_limit) * 100).toFixed(2)) : 0 + }; + }); + + res.json({ + tenant: { + id: tenant.id, + name: tenant.name + }, + days, + limits: { + vm_limit: tenant.vm_limit, + cpu_limit: tenant.cpu_limit, + ram_limit_mb: tenant.ram_limit_mb, + disk_limit_gb: tenant.disk_limit_gb + }, + daily_caps: { + cpu_hours: Number(cpuDailyCap.toFixed(2)), + ram_gb_hours: Number(ramDailyCap.toFixed(2)), + disk_gb_hours: Number(diskDailyCap.toFixed(2)) + }, + trend + }); + } catch (error) { + next(error); + } +}); + +router.get("/machines", requireAuth, authorize("vm:read"), async (req, res, next) => { + try { + const tenantId = await resolveTenantId( + req, + typeof req.query.tenant_id === "string" ? req.query.tenant_id : undefined + ); + + const services = await prisma.provisionedService.findMany({ + where: tenantId + ? { + tenant_id: tenantId + } + : undefined, + include: { + vm: true, + tenant: { + select: { + id: true, + name: true, + vm_limit: true, + cpu_limit: true, + ram_limit_mb: true, + disk_limit_gb: true + } + }, + template: { + select: { + id: true, + name: true, + template_type: true + } + } + }, + orderBy: { + created_at: "desc" + } + }); + + const usage = + tenantId && services.length > 0 + ? await currentTenantUsage(tenantId) + : null; + + res.json({ + data: services, + usage + }); + } catch (error) { + next(error); + } +}); + +router.post("/machines", requireAuth, authorize("vm:create"), async (req, res, next) => { + try { + const payload = createMachineSchema.parse(req.body ?? {}); + const tenantId = await resolveTenantId(req, payload.tenant_id); + if (!tenantId) { + throw new HttpError(400, "tenant_id is required", "TENANT_ID_REQUIRED"); + } + + const tenant = await prisma.tenant.findUnique({ + where: { + id: tenantId + }, + select: { + id: true, + name: true, + vm_limit: true, + cpu_limit: true, + ram_limit_mb: true, + disk_limit_gb: true + } + }); + + if (!tenant) { + throw new HttpError(404, "Tenant not found", "TENANT_NOT_FOUND"); + } + + const usage = await currentTenantUsage(tenantId); + enforceTenantLimits( + tenant, + usage, + { + vmCount: 1, + cpuCores: payload.cpu_cores, + ramMb: payload.ram_mb, + diskGb: payload.disk_gb + } + ); + + const packageOptions: Record = { + cpu_cores: payload.cpu_cores, + ram_mb: payload.ram_mb, + disk_gb: payload.disk_gb, + sockets: payload.sockets, + vcpus: payload.vcpus, + cpu_priority: payload.cpu_priority, + swap_mb: payload.swap_mb, + description: payload.description, + default_user: payload.default_user, + password: payload.password, + ssh_public_key: payload.ssh_key, + search_domain: payload.search_domain, + name_servers: payload.name_servers + }; + + const createdServices = await createProvisionedService({ + name: payload.name, + tenantId, + productType: ProductType.CLOUD, + virtualizationType: payload.machine_type, + vmCount: 1, + targetNode: payload.target_node, + autoNode: payload.auto_node, + applicationGroupId: payload.application_group_id, + templateId: payload.template_id, + billingPlanId: payload.billing_plan_id, + packageOptions: packageOptions as Prisma.InputJsonValue, + createdBy: req.user?.email + }); + + const service = createdServices[0]; + if (!service) { + throw new HttpError(500, "Machine provisioning returned no service", "MACHINE_CREATE_FAILED"); + } + + const created = await prisma.provisionedService.findUnique({ + where: { + id: service.id + }, + include: { + vm: true, + tenant: { + select: { + id: true, + name: true + } + } + } + }); + + if (!created) { + throw new HttpError(500, "Created machine could not be reloaded", "MACHINE_CREATE_FAILED"); + } + + if (payload.private_network_ids && payload.private_network_ids.length > 0) { + for (const networkId of payload.private_network_ids) { + await attachPrivateNetwork({ + network_id: networkId, + vm_id: created.vm.id, + actor_email: req.user?.email + }); + } + } + + if (payload.additional_disks && payload.additional_disks.length > 0) { + const runtimeType = created.vm.type === VmType.LXC ? "lxc" : "qemu"; + for (const disk of payload.additional_disks) { + await addVmDisk(created.vm.node, created.vm.vmid, runtimeType, { + storage: disk.storage, + size_gb: disk.size_gb, + bus: disk.bus, + mount_point: disk.mount_point + }); + } + } + + await logAudit({ + action: "client.machine.create", + resource_type: "VM", + resource_id: created.vm.id, + resource_name: created.vm.name, + actor_email: req.user!.email, + actor_role: req.user!.role, + details: toPrismaJsonValue({ + tenant_id: tenantId, + service_id: created.id, + vm_id: created.vm.id, + package_options: packageOptions + }), + ip_address: req.ip + }); + + res.status(201).json(created); + } catch (error) { + next(error); + } +}); + +router.patch("/machines/:vmId/resources", requireAuth, authorize("vm:update"), async (req, res, next) => { + try { + const payload = resizeMachineSchema.parse(req.body ?? {}); + const tenantId = await resolveTenantId( + req, + typeof req.query.tenant_id === "string" ? req.query.tenant_id : undefined + ); + + const vm = await ensureVmTenantScope(req.params.vmId, req, tenantId); + + const service = await prisma.provisionedService.findUnique({ + where: { + vm_id: vm.id + }, + include: { + vm: true, + tenant: { + select: { + id: true, + name: true, + vm_limit: true, + cpu_limit: true, + ram_limit_mb: true, + disk_limit_gb: true + } + } + } + }); + + if (!service) { + throw new HttpError(404, "Provisioned service not found for VM", "SERVICE_NOT_FOUND"); + } + + const updatedCpu = payload.cpu_cores ?? service.vm.cpu_cores; + const updatedRam = payload.ram_mb ?? service.vm.ram_mb; + const updatedDisk = payload.disk_gb ?? service.vm.disk_gb; + + const cpuDelta = Math.max(0, updatedCpu - service.vm.cpu_cores); + const ramDelta = Math.max(0, updatedRam - service.vm.ram_mb); + const diskDelta = Math.max(0, updatedDisk - service.vm.disk_gb); + + const usage = await currentTenantUsage(service.tenant_id); + enforceTenantLimits( + service.tenant, + usage, + { + vmCount: 0, + cpuCores: cpuDelta, + ramMb: ramDelta, + diskGb: diskDelta + } + ); + + const existingPackage = toJsonObject(service.package_options); + const packageOptions = { + ...existingPackage, + cpu_cores: updatedCpu, + ram_mb: updatedRam, + disk_gb: updatedDisk + }; + + const updated = await updateProvisionedServicePackage({ + serviceId: service.id, + actorEmail: req.user!.email, + packageOptions: packageOptions as Prisma.InputJsonValue + }); + + res.json(updated); + } catch (error) { + next(error); + } +}); + +router.get("/machines/:vmId/automation", requireAuth, authorize("vm:read"), async (req, res, next) => { + try { + const vm = await ensureVmTenantScope(req.params.vmId, req); + + const [powerSchedules, backupSchedules, recentBackups] = await Promise.all([ + prisma.powerSchedule.findMany({ + where: { + vm_id: vm.id + }, + orderBy: { + created_at: "desc" + } + }), + prisma.snapshotJob.findMany({ + where: { + vm_id: vm.id + }, + orderBy: { + created_at: "desc" + } + }), + prisma.backup.findMany({ + where: { + vm_id: vm.id + }, + take: 20, + orderBy: { + created_at: "desc" + } + }) + ]); + + res.json({ + vm, + power_schedules: powerSchedules, + backup_schedules: backupSchedules, + recent_backups: recentBackups + }); + } catch (error) { + next(error); + } +}); + +router.post("/machines/:vmId/power-schedules", requireAuth, authorize("vm:update"), async (req, res, next) => { + try { + const payload = createPowerScheduleSchema.parse(req.body ?? {}); + const vm = await ensureVmTenantScope(req.params.vmId, req); + + const schedule = await createPowerSchedule({ + vmId: vm.id, + action: payload.action, + cronExpression: payload.cron_expression, + timezone: payload.timezone, + createdBy: req.user?.email + }); + + res.status(201).json(schedule); + } catch (error) { + next(error); + } +}); + +router.post("/machines/:vmId/backup-schedules", requireAuth, authorize("backup:manage"), async (req, res, next) => { + try { + const payload = createBackupScheduleSchema.parse(req.body ?? {}); + const vm = await ensureVmTenantScope(req.params.vmId, req); + + const schedule = await createSnapshotJob({ + vmId: vm.id, + name: payload.name, + frequency: payload.frequency, + interval: payload.interval, + dayOfWeek: payload.day_of_week, + hourUtc: payload.hour_utc, + minuteUtc: payload.minute_utc, + retention: payload.retention, + enabled: payload.enabled, + createdBy: req.user?.email + }); + + res.status(201).json(schedule); + } catch (error) { + next(error); + } +}); + +router.get("/firewall/rules", requireAuth, authorize("security:read"), async (req, res, next) => { + try { + const tenantId = await resolveTenantId( + req, + typeof req.query.tenant_id === "string" ? req.query.tenant_id : undefined + ); + const vmId = typeof req.query.vm_id === "string" ? req.query.vm_id : undefined; + + if (vmId) { + await ensureVmTenantScope(vmId, req, tenantId); + } + + let targetVmIds: string[] | undefined; + if (!vmId && tenantId) { + const vms = await prisma.virtualMachine.findMany({ + where: { + tenant_id: tenantId + }, + select: { + id: true + } + }); + targetVmIds = vms.map((vm) => vm.id); + } + + const data = await prisma.firewallRule.findMany({ + where: { + applies_to: "SPECIFIC_VM", + ...(vmId + ? { target_id: vmId } + : targetVmIds + ? { + target_id: { + in: targetVmIds.length > 0 ? targetVmIds : ["__none__"] + } + } + : {}) + }, + orderBy: [{ priority: "asc" }, { created_at: "desc" }] + }); + + res.json({ data }); + } catch (error) { + next(error); + } +}); + +router.post("/firewall/rules", requireAuth, authorize("vm:update"), async (req, res, next) => { + try { + const payload = firewallRuleSchema.parse(req.body ?? {}); + const vm = await ensureVmTenantScope(payload.vm_id, req); + + const created = await prisma.firewallRule.create({ + data: { + name: payload.name, + direction: payload.direction, + action: payload.action, + protocol: payload.protocol, + source_ip: payload.source_ip, + destination_ip: payload.destination_ip, + port_range: payload.port_range, + priority: payload.priority, + enabled: payload.enabled, + applies_to: "SPECIFIC_VM", + target_id: vm.id, + description: payload.description + } + }); + + await logAudit({ + action: "client.firewall_rule.create", + resource_type: "SECURITY", + resource_id: created.id, + resource_name: created.name, + actor_email: req.user!.email, + actor_role: req.user!.role, + details: toPrismaJsonValue({ + vm_id: vm.id, + rule_id: created.id + }), + ip_address: req.ip + }); + + res.status(201).json(created); + } catch (error) { + next(error); + } +}); + +router.patch("/firewall/rules/:id", requireAuth, authorize("vm:update"), async (req, res, next) => { + try { + const payload = firewallRulePatchSchema.parse(req.body ?? {}); + const existing = await prisma.firewallRule.findUnique({ + where: { + id: req.params.id + } + }); + + if (!existing) { + throw new HttpError(404, "Firewall rule not found", "FIREWALL_RULE_NOT_FOUND"); + } + + if (!existing.target_id) { + throw new HttpError(403, "Only VM-scoped rules can be managed in client area", "RULE_SCOPE_NOT_ALLOWED"); + } + + await ensureVmTenantScope(existing.target_id, req); + + const updated = await prisma.firewallRule.update({ + where: { + id: existing.id + }, + data: { + name: payload.name, + direction: payload.direction, + action: payload.action, + protocol: payload.protocol, + source_ip: payload.source_ip, + destination_ip: payload.destination_ip, + port_range: payload.port_range, + priority: payload.priority, + enabled: payload.enabled, + description: payload.description + } + }); + + res.json(updated); + } catch (error) { + next(error); + } +}); + +router.delete("/firewall/rules/:id", requireAuth, authorize("vm:update"), async (req, res, next) => { + try { + const existing = await prisma.firewallRule.findUnique({ + where: { + id: req.params.id + } + }); + if (!existing) { + throw new HttpError(404, "Firewall rule not found", "FIREWALL_RULE_NOT_FOUND"); + } + if (!existing.target_id) { + throw new HttpError(403, "Only VM-scoped rules can be managed in client area", "RULE_SCOPE_NOT_ALLOWED"); + } + + await ensureVmTenantScope(existing.target_id, req); + await prisma.firewallRule.delete({ + where: { + id: existing.id + } + }); + + res.status(204).send(); + } catch (error) { + next(error); + } +}); + +router.get("/firewall/policy-packs", requireAuth, authorize("security:read"), async (req, res, next) => { + try { + const tenantId = await resolveTenantId( + req, + typeof req.query.tenant_id === "string" ? req.query.tenant_id : undefined + ); + + const all = await loadFirewallPolicyPacks(); + const data = tenantId ? all.filter((pack) => pack.tenant_id === tenantId) : all; + + res.json({ data }); + } catch (error) { + next(error); + } +}); + +router.post("/firewall/policy-packs", requireAuth, authorize("vm:update"), async (req, res, next) => { + try { + const payload = firewallPolicyPackCreateSchema.parse(req.body ?? {}); + const tenantId = await resolveTenantId(req, payload.tenant_id); + if (!tenantId) { + throw new HttpError(400, "tenant_id is required", "TENANT_ID_REQUIRED"); + } + + const now = new Date().toISOString(); + const all = await loadFirewallPolicyPacks(); + const created: FirewallPolicyPackRecord = { + id: `pack_${Date.now()}_${Math.floor(Math.random() * 1000)}`, + tenant_id: tenantId, + name: payload.name, + description: payload.description, + is_active: payload.is_active, + rules: payload.rules, + created_by: req.user?.email, + created_at: now, + updated_at: now + }; + + all.push(created); + await saveFirewallPolicyPacks(all); + res.status(201).json(created); + } catch (error) { + next(error); + } +}); + +router.patch("/firewall/policy-packs/:id", requireAuth, authorize("vm:update"), async (req, res, next) => { + try { + const payload = firewallPolicyPackPatchSchema.parse(req.body ?? {}); + const all = await loadFirewallPolicyPacks(); + const index = all.findIndex((item) => item.id === req.params.id); + if (index < 0) { + throw new HttpError(404, "Firewall policy pack not found", "FIREWALL_POLICY_PACK_NOT_FOUND"); + } + + const existing = all[index]; + if (!existing) { + throw new HttpError(404, "Firewall policy pack not found", "FIREWALL_POLICY_PACK_NOT_FOUND"); + } + + const tenantId = await resolveTenantId(req, existing.tenant_id); + if (tenantId && existing.tenant_id !== tenantId) { + throw new HttpError(403, "Access denied for tenant scope", "TENANT_SCOPE_VIOLATION"); + } + + const updated: FirewallPolicyPackRecord = { + ...existing, + name: payload.name ?? existing.name, + description: payload.description ?? existing.description, + is_active: payload.is_active ?? existing.is_active, + rules: payload.rules ?? existing.rules, + updated_at: new Date().toISOString() + }; + + all[index] = updated; + await saveFirewallPolicyPacks(all); + res.json(updated); + } catch (error) { + next(error); + } +}); + +router.delete("/firewall/policy-packs/:id", requireAuth, authorize("vm:update"), async (req, res, next) => { + try { + const all = await loadFirewallPolicyPacks(); + const existing = all.find((item) => item.id === req.params.id); + if (!existing) { + throw new HttpError(404, "Firewall policy pack not found", "FIREWALL_POLICY_PACK_NOT_FOUND"); + } + + const tenantId = await resolveTenantId(req, existing.tenant_id); + if (tenantId && existing.tenant_id !== tenantId) { + throw new HttpError(403, "Access denied for tenant scope", "TENANT_SCOPE_VIOLATION"); + } + + const nextPacks = all.filter((item) => item.id !== req.params.id); + await saveFirewallPolicyPacks(nextPacks); + res.status(204).send(); + } catch (error) { + next(error); + } +}); + +router.post("/firewall/policy-packs/:id/apply", requireAuth, authorize("vm:update"), async (req, res, next) => { + try { + const payload = applyPolicyPackSchema.parse(req.body ?? {}); + const vm = await ensureVmTenantScope(payload.vm_id, req); + + const all = await loadFirewallPolicyPacks(); + const pack = all.find((item) => item.id === req.params.id); + if (!pack) { + throw new HttpError(404, "Firewall policy pack not found", "FIREWALL_POLICY_PACK_NOT_FOUND"); + } + + if (!pack.is_active) { + throw new HttpError(400, "Firewall policy pack is disabled", "FIREWALL_POLICY_PACK_DISABLED"); + } + + if (pack.tenant_id && pack.tenant_id !== vm.tenant_id) { + throw new HttpError(403, "Access denied for tenant scope", "TENANT_SCOPE_VIOLATION"); + } + + const createdRules = await Promise.all( + pack.rules.map((rule, index) => + prisma.firewallRule.create({ + data: { + name: `${pack.name} :: ${rule.name}`, + direction: rule.direction, + action: rule.action, + protocol: rule.protocol, + source_ip: rule.source_ip, + destination_ip: rule.destination_ip, + port_range: rule.port_range, + priority: (rule.priority ?? (index + 1) * 10) + payload.priority_offset, + enabled: rule.enabled ?? true, + applies_to: "SPECIFIC_VM", + target_id: vm.id, + description: rule.description ?? pack.description + } + }) + ) + ); + + res.status(201).json({ + success: true, + policy_pack_id: pack.id, + vm_id: vm.id, + created_rules: createdRules.length + }); + } catch (error) { + next(error); + } +}); + +export default router; diff --git a/backend/src/routes/dashboard.routes.ts b/backend/src/routes/dashboard.routes.ts new file mode 100644 index 0000000..1d7baf0 --- /dev/null +++ b/backend/src/routes/dashboard.routes.ts @@ -0,0 +1,390 @@ +import { Router } from "express"; +import { IpScope, IpVersion } from "@prisma/client"; +import { authorize, isTenantScopedUser, requireAuth } from "../middleware/auth"; +import { prisma } from "../lib/prisma"; +import { subnetUtilizationDashboard } from "../services/network.service"; + +const router = Router(); + +type HeatLevel = "critical" | "warning" | "elevated" | "healthy"; + +function clampInteger(value: unknown, min: number, max: number, fallback: number) { + if (typeof value !== "string") return fallback; + const parsed = Number(value); + if (!Number.isInteger(parsed)) return fallback; + return Math.min(Math.max(parsed, min), max); +} + +function toUtcDayStart(date: Date) { + return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); +} + +function toDateKey(date: Date) { + return date.toISOString().slice(0, 10); +} + +function resolveHeatLevel(pressurePct: number): HeatLevel { + if (pressurePct >= 90) return "critical"; + if (pressurePct >= 75) return "warning"; + if (pressurePct >= 60) return "elevated"; + return "healthy"; +} + +router.get("/summary", requireAuth, authorize("vm:read"), async (req, res, next) => { + try { + const tenantScoped = isTenantScopedUser(req) && req.user?.tenant_id; + const tenantWhere = tenantScoped ? { tenant_id: req.user!.tenant_id! } : {}; + + const [vmTotal, vmRunning, nodeTotal, tenantTotal, invoicePaidAgg, invoicePendingAgg] = await Promise.all([ + prisma.virtualMachine.count({ where: tenantWhere }), + prisma.virtualMachine.count({ where: { ...tenantWhere, status: "RUNNING" } }), + prisma.proxmoxNode.count(), + prisma.tenant.count(), + prisma.invoice.aggregate({ + where: { ...tenantWhere, status: "PAID" }, + _sum: { amount: true } + }), + prisma.invoice.aggregate({ + where: { ...tenantWhere, status: "PENDING" }, + _sum: { amount: true } + }) + ]); + + const usage = await prisma.usageRecord.findMany({ + where: { + ...tenantWhere, + period_start: { + gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) + } + }, + orderBy: { period_start: "asc" } + }); + + const hourlyRevenueMap = new Map(); + for (const record of usage) { + const key = new Date(record.period_start).toISOString().slice(0, 13) + ":00:00Z"; + hourlyRevenueMap.set(key, (hourlyRevenueMap.get(key) ?? 0) + Number(record.total_cost)); + } + + const topVmMap = new Map(); + for (const record of usage) { + const current = topVmMap.get(record.vm_id) ?? { vm_name: record.vm_name, total: 0 }; + current.total += Number(record.total_cost); + topVmMap.set(record.vm_id, current); + } + + const topVms = Array.from(topVmMap.entries()) + .map(([vm_id, value]) => ({ vm_id, ...value })) + .sort((a, b) => b.total - a.total) + .slice(0, 5); + + const recentVms = await prisma.virtualMachine.findMany({ + where: tenantWhere, + orderBy: { created_at: "desc" }, + take: 8, + select: { + id: true, + name: true, + status: true, + node: true, + tenant_id: true, + cpu_usage: true, + ram_usage: true, + disk_usage: true, + created_at: true + } + }); + + res.json({ + metrics: { + vm_total: vmTotal, + vm_running: vmRunning, + node_total: nodeTotal, + tenant_total: tenantTotal, + revenue_paid_total: Number(invoicePaidAgg._sum.amount ?? 0), + revenue_pending_total: Number(invoicePendingAgg._sum.amount ?? 0) + }, + hourly_revenue_7d: Array.from(hourlyRevenueMap.entries()).map(([time, value]) => ({ + time, + value + })), + top_vms_by_cost: topVms, + recent_vms: recentVms + }); + } catch (error) { + next(error); + } +}); + +router.get("/network-utilization", requireAuth, authorize("vm:read"), async (req, res, next) => { + try { + const tenantScoped = isTenantScopedUser(req) && req.user?.tenant_id; + const selectedTenantId = + tenantScoped && req.user?.tenant_id + ? req.user.tenant_id + : typeof req.query.tenant_id === "string" + ? req.query.tenant_id + : undefined; + + const scopeQuery = typeof req.query.scope === "string" ? req.query.scope.toUpperCase() : undefined; + const versionQuery = typeof req.query.version === "string" ? req.query.version.toUpperCase() : undefined; + const rawVlanTag = typeof req.query.vlan_tag === "string" ? Number(req.query.vlan_tag) : undefined; + const vlanTag = typeof rawVlanTag === "number" && Number.isInteger(rawVlanTag) ? rawVlanTag : undefined; + + const scope = Object.values(IpScope).includes(scopeQuery as IpScope) ? (scopeQuery as IpScope) : undefined; + const version = Object.values(IpVersion).includes(versionQuery as IpVersion) ? (versionQuery as IpVersion) : undefined; + + const days = clampInteger(req.query.days, 7, 60, 14); + const maxTenants = clampInteger(req.query.max_tenants, 1, 10, 5); + + const subnetDashboard = await subnetUtilizationDashboard({ + scope, + version, + node_hostname: typeof req.query.node_hostname === "string" ? req.query.node_hostname : undefined, + bridge: typeof req.query.bridge === "string" ? req.query.bridge : undefined, + vlan_tag: vlanTag, + tenant_id: selectedTenantId + }); + + const heatmapCells = subnetDashboard.subnets.slice(0, 18).map((subnet, index) => ({ + rank: index + 1, + subnet: subnet.subnet, + scope: subnet.scope, + version: subnet.version, + node_hostname: subnet.node_hostname, + bridge: subnet.bridge, + vlan_tag: subnet.vlan_tag, + total: subnet.total, + assigned: subnet.assigned, + reserved: subnet.reserved, + available: subnet.available, + utilization_pct: subnet.utilization_pct, + pressure_pct: subnet.pressure_pct, + heat_level: resolveHeatLevel(subnet.pressure_pct) + })); + + const heatmapSummary = subnetDashboard.subnets.reduce( + (acc, subnet) => { + const level = resolveHeatLevel(subnet.pressure_pct); + acc.total_subnets += 1; + if (level === "critical") acc.critical += 1; + if (level === "warning") acc.warning += 1; + if (level === "elevated") acc.elevated += 1; + if (level === "healthy") acc.healthy += 1; + return acc; + }, + { + total_subnets: 0, + critical: 0, + warning: 0, + elevated: 0, + healthy: 0 + } + ); + + let tenantIds: string[] = []; + if (selectedTenantId) { + tenantIds = [selectedTenantId]; + } else { + const groupedTenants = await prisma.ipAssignment.groupBy({ + by: ["tenant_id"], + where: { + is_active: true, + tenant_id: { + not: null + } + }, + _count: { + _all: true + }, + orderBy: { + _count: { + tenant_id: "desc" + } + }, + take: maxTenants + }); + + tenantIds = groupedTenants.map((item) => item.tenant_id).filter((item): item is string => Boolean(item)); + } + + if (tenantIds.length === 0) { + return res.json({ + generated_at: new Date().toISOString(), + subnet_heatmap: { + summary: heatmapSummary, + cells: heatmapCells + }, + tenant_trends: { + window_days: days, + series: [], + chart_points: [] + } + }); + } + + const rangeEnd = new Date(); + rangeEnd.setUTCHours(23, 59, 59, 999); + const rangeStart = toUtcDayStart(rangeEnd); + rangeStart.setUTCDate(rangeStart.getUTCDate() - (days - 1)); + + const dayFrames = Array.from({ length: days }, (_, index) => { + const start = new Date(rangeStart); + start.setUTCDate(rangeStart.getUTCDate() + index); + const end = new Date(start); + end.setUTCHours(23, 59, 59, 999); + return { + key: toDateKey(start), + end + }; + }); + + const [tenants, quotas, assignments] = await Promise.all([ + prisma.tenant.findMany({ + where: { + id: { + in: tenantIds + } + }, + select: { + id: true, + name: true + } + }), + prisma.tenantIpQuota.findMany({ + where: { + tenant_id: { + in: tenantIds + } + }, + select: { + tenant_id: true, + ipv4_limit: true, + ipv6_limit: true, + burst_allowed: true + } + }), + prisma.ipAssignment.findMany({ + where: { + tenant_id: { + in: tenantIds + }, + assigned_at: { + lte: rangeEnd + }, + OR: [ + { + released_at: null + }, + { + released_at: { + gte: rangeStart + } + } + ] + }, + select: { + tenant_id: true, + assigned_at: true, + released_at: true, + ip_address: { + select: { + version: true + } + } + } + }) + ]); + + const tenantMap = new Map(tenants.map((tenant) => [tenant.id, tenant])); + const quotaMap = new Map(quotas.map((quota) => [quota.tenant_id, quota])); + const assignmentsByTenant = new Map(); + + for (const assignment of assignments) { + if (!assignment.tenant_id) continue; + if (!assignmentsByTenant.has(assignment.tenant_id)) { + assignmentsByTenant.set(assignment.tenant_id, []); + } + assignmentsByTenant.get(assignment.tenant_id)!.push(assignment); + } + + const orderedTenantIds = tenantIds.filter((tenantId) => tenantMap.has(tenantId)); + const series = orderedTenantIds.map((tenantId) => { + const tenant = tenantMap.get(tenantId)!; + const quota = quotaMap.get(tenantId); + const tenantAssignments = assignmentsByTenant.get(tenantId) ?? []; + + const points = dayFrames.map((day) => { + let assignedIpv4 = 0; + let assignedIpv6 = 0; + + for (const assignment of tenantAssignments) { + const activeAtDayEnd = + assignment.assigned_at <= day.end && (!assignment.released_at || assignment.released_at > day.end); + if (!activeAtDayEnd) continue; + if (assignment.ip_address.version === IpVersion.IPV4) assignedIpv4 += 1; + if (assignment.ip_address.version === IpVersion.IPV6) assignedIpv6 += 1; + } + + const quotaPressure: number[] = []; + if (typeof quota?.ipv4_limit === "number" && quota.ipv4_limit > 0) { + quotaPressure.push((assignedIpv4 / quota.ipv4_limit) * 100); + } + if (typeof quota?.ipv6_limit === "number" && quota.ipv6_limit > 0) { + quotaPressure.push((assignedIpv6 / quota.ipv6_limit) * 100); + } + + return { + date: day.key, + assigned_total: assignedIpv4 + assignedIpv6, + assigned_ipv4: assignedIpv4, + assigned_ipv6: assignedIpv6, + quota_utilization_pct: quotaPressure.length > 0 ? Number(Math.max(...quotaPressure).toFixed(2)) : null + }; + }); + + const lastPoint = points[points.length - 1]; + return { + tenant_id: tenant.id, + tenant_name: tenant.name, + current_assigned: lastPoint?.assigned_total ?? 0, + peak_assigned: points.reduce((peak, point) => (point.assigned_total > peak ? point.assigned_total : peak), 0), + quota: { + ipv4_limit: quota?.ipv4_limit ?? null, + ipv6_limit: quota?.ipv6_limit ?? null, + burst_allowed: quota?.burst_allowed ?? false + }, + points + }; + }); + + const chartPoints = dayFrames.map((day, index) => { + const point: Record = { + date: day.key + }; + + for (const tenant of series) { + point[tenant.tenant_id] = tenant.points[index]?.assigned_total ?? 0; + } + + return point; + }); + + return res.json({ + generated_at: new Date().toISOString(), + subnet_heatmap: { + summary: heatmapSummary, + cells: heatmapCells + }, + tenant_trends: { + window_days: days, + series, + chart_points: chartPoints + } + }); + } catch (error) { + return next(error); + } +}); + +export default router; diff --git a/backend/src/routes/health.routes.ts b/backend/src/routes/health.routes.ts new file mode 100644 index 0000000..ce13a7d --- /dev/null +++ b/backend/src/routes/health.routes.ts @@ -0,0 +1,22 @@ +import { Router } from "express"; +import { prisma } from "../lib/prisma"; + +const router = Router(); + +router.get("/", async (_req, res) => { + let db = "ok"; + try { + await prisma.$queryRaw`SELECT 1`; + } catch { + db = "error"; + } + res.json({ + status: db === "ok" ? "ok" : "degraded", + services: { + database: db + }, + timestamp: new Date().toISOString() + }); +}); + +export default router; diff --git a/backend/src/routes/monitoring.routes.ts b/backend/src/routes/monitoring.routes.ts new file mode 100644 index 0000000..b3029ad --- /dev/null +++ b/backend/src/routes/monitoring.routes.ts @@ -0,0 +1,391 @@ +import { + AlertChannel, + HealthCheckTargetType, + HealthCheckType, + MonitoringAlertStatus, + Severity +} from "@prisma/client"; +import { Router } from "express"; +import { z } from "zod"; +import { HttpError } from "../lib/http-error"; +import { prisma } from "../lib/prisma"; +import { toPrismaJsonValue } from "../lib/prisma-json"; +import { authorize, isTenantScopedUser, requireAuth } from "../middleware/auth"; +import { logAudit } from "../services/audit.service"; +import { + clusterResourceForecast, + createAlertRule, + createHealthCheckDefinition, + evaluateAlertRulesNow, + faultyDeploymentInsights, + listAlertEvents, + listAlertNotifications, + listAlertRules, + listHealthCheckResults, + listHealthChecks, + monitoringOverview, + runHealthCheckNow, + updateAlertRule, + updateHealthCheckDefinition +} from "../services/monitoring.service"; + +const router = Router(); + +const healthCheckSchema = z.object({ + name: z.string().min(2), + description: z.string().optional(), + target_type: z.nativeEnum(HealthCheckTargetType), + check_type: z.nativeEnum(HealthCheckType).optional(), + tenant_id: z.string().optional(), + vm_id: z.string().optional(), + node_id: z.string().optional(), + cpu_warn_pct: z.number().min(0).max(100).optional(), + cpu_critical_pct: z.number().min(0).max(100).optional(), + ram_warn_pct: z.number().min(0).max(100).optional(), + ram_critical_pct: z.number().min(0).max(100).optional(), + disk_warn_pct: z.number().min(0).max(100).optional(), + disk_critical_pct: z.number().min(0).max(100).optional(), + disk_io_read_warn: z.number().min(0).optional(), + disk_io_read_critical: z.number().min(0).optional(), + disk_io_write_warn: z.number().min(0).optional(), + disk_io_write_critical: z.number().min(0).optional(), + network_in_warn: z.number().min(0).optional(), + network_in_critical: z.number().min(0).optional(), + network_out_warn: z.number().min(0).optional(), + network_out_critical: z.number().min(0).optional(), + latency_warn_ms: z.number().int().min(1).optional(), + latency_critical_ms: z.number().int().min(1).optional(), + schedule_minutes: z.number().int().min(1).max(1440).optional(), + enabled: z.boolean().optional(), + metadata: z.record(z.unknown()).optional() +}); + +const alertRuleSchema = z.object({ + name: z.string().min(2), + description: z.string().optional(), + tenant_id: z.string().optional(), + vm_id: z.string().optional(), + node_id: z.string().optional(), + cpu_threshold_pct: z.number().min(0).max(100).optional(), + ram_threshold_pct: z.number().min(0).max(100).optional(), + disk_threshold_pct: z.number().min(0).max(100).optional(), + disk_io_read_threshold: z.number().min(0).optional(), + disk_io_write_threshold: z.number().min(0).optional(), + network_in_threshold: z.number().min(0).optional(), + network_out_threshold: z.number().min(0).optional(), + consecutive_breaches: z.number().int().min(1).max(20).optional(), + evaluation_window_minutes: z.number().int().min(1).max(1440).optional(), + severity: z.nativeEnum(Severity).optional(), + channels: z.array(z.nativeEnum(AlertChannel)).optional(), + enabled: z.boolean().optional(), + metadata: z.record(z.unknown()).optional() +}); + +async function ensureVmTenantScope(vmId: string, req: Pick) { + const vm = await prisma.virtualMachine.findUnique({ + where: { id: vmId }, + select: { + id: true, + tenant_id: true, + name: true + } + }); + + if (!vm) { + throw new HttpError(404, "VM not found", "VM_NOT_FOUND"); + } + + if (isTenantScopedUser(req) && req.user?.tenant_id && vm.tenant_id !== req.user.tenant_id) { + throw new HttpError(403, "Access denied for tenant scope", "TENANT_SCOPE_VIOLATION"); + } + + return vm; +} + +function scopedTenantId(req: Pick) { + return isTenantScopedUser(req) ? req.user?.tenant_id ?? undefined : undefined; +} + +function queryTenantId(req: { query?: Record }) { + return typeof req.query?.tenant_id === "string" ? req.query.tenant_id : undefined; +} + +router.get("/overview", requireAuth, authorize("security:read"), async (req, res, next) => { + try { + const data = await monitoringOverview({ + tenant_id: scopedTenantId(req) + }); + return res.json(data); + } catch (error) { + return next(error); + } +}); + +router.get("/health-checks", requireAuth, authorize("security:read"), async (req, res, next) => { + try { + const data = await listHealthChecks({ + tenant_id: scopedTenantId(req) ?? queryTenantId(req), + enabled: typeof req.query.enabled === "string" ? req.query.enabled === "true" : undefined + }); + return res.json({ data }); + } catch (error) { + return next(error); + } +}); + +router.post("/health-checks", requireAuth, authorize("security:manage"), async (req, res, next) => { + try { + const payload = healthCheckSchema.parse(req.body ?? {}); + + if (payload.vm_id) { + await ensureVmTenantScope(payload.vm_id, req); + } + + const tenantId = scopedTenantId(req) ?? payload.tenant_id; + const check = await createHealthCheckDefinition({ + ...payload, + tenant_id: tenantId, + created_by: req.user?.email + }); + + await logAudit({ + action: "monitoring.health_check.create", + resource_type: "SECURITY", + resource_id: check.id, + resource_name: check.name, + actor_email: req.user!.email, + actor_role: req.user!.role, + details: toPrismaJsonValue(payload), + ip_address: req.ip + }); + + return res.status(201).json(check); + } catch (error) { + return next(error); + } +}); + +router.patch("/health-checks/:id", requireAuth, authorize("security:manage"), async (req, res, next) => { + try { + const payload = healthCheckSchema.partial().parse(req.body ?? {}); + const existing = await prisma.serverHealthCheck.findUnique({ + where: { id: req.params.id }, + select: { + id: true, + tenant_id: true + } + }); + + if (!existing) { + throw new HttpError(404, "Health check not found", "HEALTH_CHECK_NOT_FOUND"); + } + + if (isTenantScopedUser(req) && req.user?.tenant_id && existing.tenant_id && existing.tenant_id !== req.user.tenant_id) { + throw new HttpError(403, "Access denied for tenant scope", "TENANT_SCOPE_VIOLATION"); + } + + if (payload.vm_id) { + await ensureVmTenantScope(payload.vm_id, req); + } + + const updated = await updateHealthCheckDefinition(req.params.id, { + ...payload, + tenant_id: scopedTenantId(req) ?? payload.tenant_id + }); + + return res.json(updated); + } catch (error) { + return next(error); + } +}); + +router.post("/health-checks/:id/run", requireAuth, authorize("security:manage"), async (req, res, next) => { + try { + const existing = await prisma.serverHealthCheck.findUnique({ + where: { id: req.params.id }, + select: { id: true, tenant_id: true } + }); + + if (!existing) { + throw new HttpError(404, "Health check not found", "HEALTH_CHECK_NOT_FOUND"); + } + + if (isTenantScopedUser(req) && req.user?.tenant_id && existing.tenant_id && existing.tenant_id !== req.user.tenant_id) { + throw new HttpError(403, "Access denied for tenant scope", "TENANT_SCOPE_VIOLATION"); + } + + const result = await runHealthCheckNow(existing.id); + return res.json(result); + } catch (error) { + return next(error); + } +}); + +router.get("/health-checks/:id/results", requireAuth, authorize("security:read"), async (req, res, next) => { + try { + const existing = await prisma.serverHealthCheck.findUnique({ + where: { id: req.params.id }, + select: { id: true, tenant_id: true } + }); + + if (!existing) { + throw new HttpError(404, "Health check not found", "HEALTH_CHECK_NOT_FOUND"); + } + + if (isTenantScopedUser(req) && req.user?.tenant_id && existing.tenant_id && existing.tenant_id !== req.user.tenant_id) { + throw new HttpError(403, "Access denied for tenant scope", "TENANT_SCOPE_VIOLATION"); + } + + const limit = typeof req.query.limit === "string" ? Number(req.query.limit) : undefined; + const data = await listHealthCheckResults(existing.id, limit); + return res.json({ data }); + } catch (error) { + return next(error); + } +}); + +router.get("/alerts/rules", requireAuth, authorize("security:read"), async (req, res, next) => { + try { + const data = await listAlertRules({ + tenant_id: scopedTenantId(req) ?? queryTenantId(req), + enabled: typeof req.query.enabled === "string" ? req.query.enabled === "true" : undefined + }); + return res.json({ data }); + } catch (error) { + return next(error); + } +}); + +router.post("/alerts/rules", requireAuth, authorize("security:manage"), async (req, res, next) => { + try { + const payload = alertRuleSchema.parse(req.body ?? {}); + + if (payload.vm_id) { + await ensureVmTenantScope(payload.vm_id, req); + } + + const tenantId = scopedTenantId(req) ?? payload.tenant_id; + const rule = await createAlertRule({ + ...payload, + tenant_id: tenantId, + created_by: req.user?.email + }); + + await logAudit({ + action: "monitoring.alert_rule.create", + resource_type: "SECURITY", + resource_id: rule.id, + resource_name: rule.name, + actor_email: req.user!.email, + actor_role: req.user!.role, + details: toPrismaJsonValue(payload), + ip_address: req.ip + }); + + return res.status(201).json(rule); + } catch (error) { + return next(error); + } +}); + +router.patch("/alerts/rules/:id", requireAuth, authorize("security:manage"), async (req, res, next) => { + try { + const payload = alertRuleSchema.partial().parse(req.body ?? {}); + const existing = await prisma.monitoringAlertRule.findUnique({ + where: { id: req.params.id }, + select: { + id: true, + tenant_id: true + } + }); + + if (!existing) { + throw new HttpError(404, "Alert rule not found", "ALERT_RULE_NOT_FOUND"); + } + + if (isTenantScopedUser(req) && req.user?.tenant_id && existing.tenant_id && existing.tenant_id !== req.user.tenant_id) { + throw new HttpError(403, "Access denied for tenant scope", "TENANT_SCOPE_VIOLATION"); + } + + if (payload.vm_id) { + await ensureVmTenantScope(payload.vm_id, req); + } + + const updated = await updateAlertRule(req.params.id, { + ...payload, + tenant_id: scopedTenantId(req) ?? payload.tenant_id + }); + return res.json(updated); + } catch (error) { + return next(error); + } +}); + +router.get("/alerts/events", requireAuth, authorize("security:read"), async (req, res, next) => { + try { + const statusRaw = typeof req.query.status === "string" ? req.query.status.toUpperCase() : undefined; + const status = Object.values(MonitoringAlertStatus).includes(statusRaw as MonitoringAlertStatus) + ? (statusRaw as MonitoringAlertStatus) + : undefined; + + const limit = typeof req.query.limit === "string" ? Number(req.query.limit) : undefined; + const data = await listAlertEvents({ + tenant_id: scopedTenantId(req) ?? queryTenantId(req), + status, + limit + }); + return res.json({ data }); + } catch (error) { + return next(error); + } +}); + +router.get("/alerts/notifications", requireAuth, authorize("security:read"), async (req, res, next) => { + try { + const limit = typeof req.query.limit === "string" ? Number(req.query.limit) : undefined; + const data = await listAlertNotifications({ + tenant_id: scopedTenantId(req) ?? queryTenantId(req), + limit + }); + return res.json({ data }); + } catch (error) { + return next(error); + } +}); + +router.post("/alerts/evaluate", requireAuth, authorize("security:manage"), async (req, res, next) => { + try { + const result = await evaluateAlertRulesNow(scopedTenantId(req)); + return res.json(result); + } catch (error) { + return next(error); + } +}); + +router.get("/insights/faulty-deployments", requireAuth, authorize("security:read"), async (req, res, next) => { + try { + const days = typeof req.query.days === "string" ? Number(req.query.days) : undefined; + const data = await faultyDeploymentInsights({ + days, + tenant_id: scopedTenantId(req) ?? queryTenantId(req) + }); + return res.json(data); + } catch (error) { + return next(error); + } +}); + +router.get("/insights/cluster-forecast", requireAuth, authorize("security:read"), async (req, res, next) => { + try { + const horizon = typeof req.query.horizon_days === "string" ? Number(req.query.horizon_days) : undefined; + const data = await clusterResourceForecast({ + horizon_days: horizon, + tenant_id: scopedTenantId(req) ?? queryTenantId(req) + }); + return res.json(data); + } catch (error) { + return next(error); + } +}); + +export default router; diff --git a/backend/src/routes/network.routes.ts b/backend/src/routes/network.routes.ts new file mode 100644 index 0000000..f76af63 --- /dev/null +++ b/backend/src/routes/network.routes.ts @@ -0,0 +1,636 @@ +import { IpAddressStatus, IpAssignmentType, IpAllocationStrategy, IpScope, IpVersion, PrivateNetworkType } from "@prisma/client"; +import { Router } from "express"; +import { z } from "zod"; +import { HttpError } from "../lib/http-error"; +import { prisma } from "../lib/prisma"; +import { toPrismaJsonValue } from "../lib/prisma-json"; +import { authorize, isTenantScopedUser, requireAuth } from "../middleware/auth"; +import { logAudit } from "../services/audit.service"; +import { + assignIpToVm, + attachPrivateNetwork, + createPrivateNetwork, + detachPrivateNetwork, + importIpAddresses, + listIpAddresses, + listIpAssignments, + listIpPoolPolicies, + listIpReservedRanges, + listPrivateNetworks, + listTenantIpQuotas, + returnAssignedIp, + subnetUtilizationDashboard, + upsertIpPoolPolicy, + upsertTenantIpQuota, + createIpReservedRange, + updateIpReservedRange +} from "../services/network.service"; + +const router = Router(); + +const ipImportSchema = z.object({ + addresses: z.array(z.string().min(2)).optional(), + cidr_blocks: z.array(z.string().min(3)).optional(), + scope: z.nativeEnum(IpScope).optional(), + server: z.string().optional(), + node_id: z.string().optional(), + node_hostname: z.string().optional(), + bridge: z.string().optional(), + vlan_tag: z.number().int().min(0).max(4094).optional(), + sdn_zone: z.string().optional(), + gateway: z.string().optional(), + subnet: z.string().optional(), + tags: z.array(z.string().min(1)).optional(), + metadata: z.record(z.unknown()).optional() +}); + +const ipAssignSchema = z.object({ + vm_id: z.string().min(1), + ip_address_id: z.string().optional(), + address: z.string().optional(), + scope: z.nativeEnum(IpScope).optional(), + version: z.nativeEnum(IpVersion).optional(), + assignment_type: z.nativeEnum(IpAssignmentType).default(IpAssignmentType.ADDITIONAL), + interface_name: z.string().optional(), + notes: z.string().optional(), + metadata: z.record(z.unknown()).optional() +}); + +const ipReturnSchema = z + .object({ + assignment_id: z.string().optional(), + ip_address_id: z.string().optional() + }) + .refine((value) => value.assignment_id || value.ip_address_id, { + message: "assignment_id or ip_address_id is required" + }); + +const privateNetworkCreateSchema = z.object({ + name: z.string().min(2), + slug: z.string().optional(), + network_type: z.nativeEnum(PrivateNetworkType).optional(), + cidr: z.string().min(3), + gateway: z.string().optional(), + bridge: z.string().optional(), + vlan_tag: z.number().int().min(0).max(4094).optional(), + sdn_zone: z.string().optional(), + server: z.string().optional(), + node_hostname: z.string().optional(), + metadata: z.record(z.unknown()).optional() +}); + +const privateNetworkAttachSchema = z.object({ + network_id: z.string().min(1), + vm_id: z.string().min(1), + interface_name: z.string().optional(), + requested_ip: z.string().optional(), + metadata: z.record(z.unknown()).optional() +}); + +const tenantQuotaSchema = z.object({ + tenant_id: z.string().min(1), + ipv4_limit: z.number().int().positive().nullable().optional(), + ipv6_limit: z.number().int().positive().nullable().optional(), + reserved_ipv4: z.number().int().min(0).optional(), + reserved_ipv6: z.number().int().min(0).optional(), + burst_allowed: z.boolean().optional(), + burst_ipv4_limit: z.number().int().positive().nullable().optional(), + burst_ipv6_limit: z.number().int().positive().nullable().optional(), + is_active: z.boolean().optional(), + metadata: z.record(z.unknown()).optional() +}); + +const reservedRangeSchema = z.object({ + name: z.string().min(2), + cidr: z.string().min(3), + scope: z.nativeEnum(IpScope).optional(), + tenant_id: z.string().optional(), + reason: z.string().optional(), + node_hostname: z.string().optional(), + bridge: z.string().optional(), + vlan_tag: z.number().int().min(0).max(4094).optional(), + sdn_zone: z.string().optional(), + is_active: z.boolean().optional(), + metadata: z.record(z.unknown()).optional() +}); + +const ipPoolPolicySchema = z.object({ + name: z.string().min(2), + tenant_id: z.string().optional(), + scope: z.nativeEnum(IpScope).optional(), + version: z.nativeEnum(IpVersion).optional(), + node_hostname: z.string().optional(), + bridge: z.string().optional(), + vlan_tag: z.number().int().min(0).max(4094).optional(), + sdn_zone: z.string().optional(), + allocation_strategy: z.nativeEnum(IpAllocationStrategy).optional(), + enforce_quota: z.boolean().optional(), + disallow_reserved_use: z.boolean().optional(), + is_active: z.boolean().optional(), + priority: z.number().int().min(1).max(1000).optional(), + metadata: z.record(z.unknown()).optional() +}); + +async function ensureVmTenantScope(vmId: string, req: Pick) { + const vm = await prisma.virtualMachine.findUnique({ + where: { id: vmId }, + select: { + id: true, + tenant_id: true, + name: true + } + }); + + if (!vm) { + throw new HttpError(404, "VM not found", "VM_NOT_FOUND"); + } + + if (isTenantScopedUser(req) && req.user?.tenant_id && vm.tenant_id !== req.user.tenant_id) { + throw new HttpError(403, "Access denied for tenant scope", "TENANT_SCOPE_VIOLATION"); + } + + return vm; +} + +router.get("/ip-addresses", requireAuth, authorize("node:read"), async (req, res, next) => { + try { + const status = typeof req.query.status === "string" ? req.query.status.toUpperCase() : undefined; + const version = typeof req.query.version === "string" ? req.query.version.toUpperCase() : undefined; + const scope = typeof req.query.scope === "string" ? req.query.scope.toUpperCase() : undefined; + + const result = await listIpAddresses({ + status: Object.values(IpAddressStatus).includes(status as IpAddressStatus) ? (status as IpAddressStatus) : undefined, + version: Object.values(IpVersion).includes(version as IpVersion) ? (version as IpVersion) : undefined, + scope: Object.values(IpScope).includes(scope as IpScope) ? (scope as IpScope) : undefined, + nodeHostname: typeof req.query.node_hostname === "string" ? req.query.node_hostname : undefined, + bridge: typeof req.query.bridge === "string" ? req.query.bridge : undefined, + vlanTag: typeof req.query.vlan_tag === "string" ? Number(req.query.vlan_tag) : undefined, + assignedVmId: typeof req.query.assigned_vm_id === "string" ? req.query.assigned_vm_id : undefined, + limit: typeof req.query.limit === "string" ? Number(req.query.limit) : undefined, + offset: typeof req.query.offset === "string" ? Number(req.query.offset) : undefined + }); + + if (isTenantScopedUser(req) && req.user?.tenant_id) { + const tenantData = result.data.filter( + (item) => + item.assigned_tenant_id === req.user?.tenant_id || + (item.status === IpAddressStatus.AVAILABLE && item.scope === IpScope.PRIVATE) + ); + return res.json({ + data: tenantData, + meta: { + ...result.meta, + total: tenantData.length + } + }); + } + + return res.json(result); + } catch (error) { + return next(error); + } +}); + +router.post("/ip-addresses/import", requireAuth, authorize("node:manage"), async (req, res, next) => { + try { + const payload = ipImportSchema.parse(req.body ?? {}); + const result = await importIpAddresses({ + ...payload, + imported_by: req.user?.email + }); + + await logAudit({ + action: "ip_address.import", + resource_type: "SYSTEM", + actor_email: req.user!.email, + actor_role: req.user!.role, + details: toPrismaJsonValue({ + ...payload, + result + }), + ip_address: req.ip + }); + + return res.status(201).json(result); + } catch (error) { + return next(error); + } +}); + +router.get("/subnet-utilization", requireAuth, authorize("node:read"), async (req, res, next) => { + try { + const scope = typeof req.query.scope === "string" ? req.query.scope.toUpperCase() : undefined; + const version = typeof req.query.version === "string" ? req.query.version.toUpperCase() : undefined; + + const dashboard = await subnetUtilizationDashboard({ + scope: Object.values(IpScope).includes(scope as IpScope) ? (scope as IpScope) : undefined, + version: Object.values(IpVersion).includes(version as IpVersion) ? (version as IpVersion) : undefined, + node_hostname: typeof req.query.node_hostname === "string" ? req.query.node_hostname : undefined, + bridge: typeof req.query.bridge === "string" ? req.query.bridge : undefined, + vlan_tag: typeof req.query.vlan_tag === "string" ? Number(req.query.vlan_tag) : undefined, + tenant_id: + isTenantScopedUser(req) && req.user?.tenant_id + ? req.user.tenant_id + : typeof req.query.tenant_id === "string" + ? req.query.tenant_id + : undefined + }); + + return res.json(dashboard); + } catch (error) { + return next(error); + } +}); + +router.get("/ip-assignments", requireAuth, authorize("vm:read"), async (req, res, next) => { + try { + const data = await listIpAssignments({ + vm_id: typeof req.query.vm_id === "string" ? req.query.vm_id : undefined, + tenant_id: + isTenantScopedUser(req) && req.user?.tenant_id + ? req.user.tenant_id + : typeof req.query.tenant_id === "string" + ? req.query.tenant_id + : undefined, + active_only: req.query.active_only === "true" + }); + return res.json({ data }); + } catch (error) { + return next(error); + } +}); + +router.post("/ip-assignments", requireAuth, authorize("vm:update"), async (req, res, next) => { + try { + const payload = ipAssignSchema.parse(req.body ?? {}); + await ensureVmTenantScope(payload.vm_id, req); + + const assignment = await assignIpToVm({ + vm_id: payload.vm_id, + ip_address_id: payload.ip_address_id, + address: payload.address, + scope: payload.scope, + version: payload.version, + assignment_type: payload.assignment_type, + interface_name: payload.interface_name, + notes: payload.notes, + metadata: payload.metadata, + actor_email: req.user?.email + }); + + await logAudit({ + action: "ip_address.assign", + resource_type: "VM", + resource_id: payload.vm_id, + resource_name: assignment.vm.name, + actor_email: req.user!.email, + actor_role: req.user!.role, + details: toPrismaJsonValue({ + assignment_id: assignment.id, + ip_address: assignment.ip_address.address, + cidr: assignment.ip_address.cidr, + assignment_type: assignment.assignment_type, + interface_name: assignment.interface_name + }), + ip_address: req.ip + }); + + return res.status(201).json(assignment); + } catch (error) { + return next(error); + } +}); + +router.post("/ip-assignments/return", requireAuth, authorize("vm:update"), async (req, res, next) => { + try { + const payload = ipReturnSchema.parse(req.body ?? {}); + if (payload.assignment_id) { + const existing = await prisma.ipAssignment.findUnique({ + where: { id: payload.assignment_id }, + include: { + vm: { + select: { + id: true + } + } + } + }); + + if (!existing) throw new HttpError(404, "IP assignment not found", "IP_ASSIGNMENT_NOT_FOUND"); + await ensureVmTenantScope(existing.vm.id, req); + } + + const assignment = await returnAssignedIp(payload); + + await logAudit({ + action: "ip_address.return", + resource_type: "VM", + resource_id: assignment.vm_id, + actor_email: req.user!.email, + actor_role: req.user!.role, + details: toPrismaJsonValue({ + assignment_id: assignment.id, + ip_address_id: assignment.ip_address_id + }), + ip_address: req.ip + }); + + return res.json(assignment); + } catch (error) { + return next(error); + } +}); + +router.get("/tenant-quotas", requireAuth, authorize("tenant:read"), async (req, res, next) => { + try { + const data = await listTenantIpQuotas( + isTenantScopedUser(req) && req.user?.tenant_id ? req.user.tenant_id : typeof req.query.tenant_id === "string" ? req.query.tenant_id : undefined + ); + return res.json({ data }); + } catch (error) { + return next(error); + } +}); + +router.post("/tenant-quotas", requireAuth, authorize("tenant:manage"), async (req, res, next) => { + try { + const payload = tenantQuotaSchema.parse(req.body ?? {}); + if (isTenantScopedUser(req) && req.user?.tenant_id && payload.tenant_id !== req.user.tenant_id) { + throw new HttpError(403, "Access denied for tenant scope", "TENANT_SCOPE_VIOLATION"); + } + + const quota = await upsertTenantIpQuota({ + ...payload, + created_by: req.user?.email + }); + + await logAudit({ + action: "ip_quota.upsert", + resource_type: "TENANT", + resource_id: quota.tenant_id, + resource_name: quota.tenant.name, + actor_email: req.user!.email, + actor_role: req.user!.role, + details: toPrismaJsonValue(payload), + ip_address: req.ip + }); + + return res.status(201).json(quota); + } catch (error) { + return next(error); + } +}); + +router.get("/reserved-ranges", requireAuth, authorize("node:read"), async (req, res, next) => { + try { + const all = await listIpReservedRanges(); + const data = + isTenantScopedUser(req) && req.user?.tenant_id + ? all.filter((item) => !item.tenant_id || item.tenant_id === req.user?.tenant_id) + : all; + return res.json({ data }); + } catch (error) { + return next(error); + } +}); + +router.post("/reserved-ranges", requireAuth, authorize("node:manage"), async (req, res, next) => { + try { + const payload = reservedRangeSchema.parse(req.body ?? {}); + if (isTenantScopedUser(req) && req.user?.tenant_id && payload.tenant_id && payload.tenant_id !== req.user.tenant_id) { + throw new HttpError(403, "Access denied for tenant scope", "TENANT_SCOPE_VIOLATION"); + } + + const range = await createIpReservedRange({ + ...payload, + created_by: req.user?.email + }); + + await logAudit({ + action: "ip_reserved_range.create", + resource_type: "NETWORK", + resource_id: range.id, + resource_name: range.name, + actor_email: req.user!.email, + actor_role: req.user!.role, + details: toPrismaJsonValue(payload), + ip_address: req.ip + }); + + return res.status(201).json(range); + } catch (error) { + return next(error); + } +}); + +router.patch("/reserved-ranges/:id", requireAuth, authorize("node:manage"), async (req, res, next) => { + try { + const payload = reservedRangeSchema.partial().parse(req.body ?? {}); + const existing = await prisma.ipReservedRange.findUnique({ where: { id: req.params.id } }); + if (!existing) throw new HttpError(404, "Reserved range not found", "RESERVED_RANGE_NOT_FOUND"); + if (isTenantScopedUser(req) && req.user?.tenant_id && existing.tenant_id && existing.tenant_id !== req.user.tenant_id) { + throw new HttpError(403, "Access denied for tenant scope", "TENANT_SCOPE_VIOLATION"); + } + + const updated = await updateIpReservedRange(req.params.id, payload); + return res.json(updated); + } catch (error) { + return next(error); + } +}); + +router.get("/policies", requireAuth, authorize("node:read"), async (req, res, next) => { + try { + const all = await listIpPoolPolicies(); + const data = + isTenantScopedUser(req) && req.user?.tenant_id + ? all.filter((item) => !item.tenant_id || item.tenant_id === req.user?.tenant_id) + : all; + return res.json({ data }); + } catch (error) { + return next(error); + } +}); + +router.post("/policies", requireAuth, authorize("node:manage"), async (req, res, next) => { + try { + const payload = ipPoolPolicySchema.parse(req.body ?? {}); + if (isTenantScopedUser(req) && req.user?.tenant_id && payload.tenant_id && payload.tenant_id !== req.user.tenant_id) { + throw new HttpError(403, "Access denied for tenant scope", "TENANT_SCOPE_VIOLATION"); + } + + const policy = await upsertIpPoolPolicy({ + ...payload, + created_by: req.user?.email + }); + + await logAudit({ + action: "ip_pool_policy.create", + resource_type: "NETWORK", + resource_id: policy.id, + resource_name: policy.name, + actor_email: req.user!.email, + actor_role: req.user!.role, + details: toPrismaJsonValue(payload), + ip_address: req.ip + }); + + return res.status(201).json(policy); + } catch (error) { + return next(error); + } +}); + +router.patch("/policies/:id", requireAuth, authorize("node:manage"), async (req, res, next) => { + try { + const payload = ipPoolPolicySchema.partial().parse(req.body ?? {}); + const existing = await prisma.ipPoolPolicy.findUnique({ where: { id: req.params.id } }); + if (!existing) throw new HttpError(404, "IP pool policy not found", "IP_POOL_POLICY_NOT_FOUND"); + if (isTenantScopedUser(req) && req.user?.tenant_id && existing.tenant_id && existing.tenant_id !== req.user.tenant_id) { + throw new HttpError(403, "Access denied for tenant scope", "TENANT_SCOPE_VIOLATION"); + } + + const policy = await upsertIpPoolPolicy({ + policy_id: existing.id, + name: payload.name ?? existing.name, + tenant_id: payload.tenant_id ?? existing.tenant_id ?? undefined, + scope: payload.scope ?? existing.scope ?? undefined, + version: payload.version ?? existing.version ?? undefined, + node_hostname: payload.node_hostname ?? existing.node_hostname ?? undefined, + bridge: payload.bridge ?? existing.bridge ?? undefined, + vlan_tag: payload.vlan_tag ?? existing.vlan_tag ?? undefined, + sdn_zone: payload.sdn_zone ?? existing.sdn_zone ?? undefined, + allocation_strategy: payload.allocation_strategy ?? existing.allocation_strategy, + enforce_quota: payload.enforce_quota ?? existing.enforce_quota, + disallow_reserved_use: payload.disallow_reserved_use ?? existing.disallow_reserved_use, + is_active: payload.is_active ?? existing.is_active, + priority: payload.priority ?? existing.priority, + metadata: payload.metadata + }); + + return res.json(policy); + } catch (error) { + return next(error); + } +}); + +router.get("/private-networks", requireAuth, authorize("node:read"), async (_req, res, next) => { + try { + const data = await listPrivateNetworks(); + return res.json({ data }); + } catch (error) { + return next(error); + } +}); + +router.post("/private-networks", requireAuth, authorize("node:manage"), async (req, res, next) => { + try { + const payload = privateNetworkCreateSchema.parse(req.body ?? {}); + const network = await createPrivateNetwork({ + name: payload.name, + slug: payload.slug, + network_type: payload.network_type, + cidr: payload.cidr, + gateway: payload.gateway, + bridge: payload.bridge, + vlan_tag: payload.vlan_tag, + sdn_zone: payload.sdn_zone, + server: payload.server, + node_hostname: payload.node_hostname, + metadata: payload.metadata, + created_by: req.user?.email + }); + + await logAudit({ + action: "private_network.create", + resource_type: "NETWORK", + resource_id: network.id, + resource_name: network.name, + actor_email: req.user!.email, + actor_role: req.user!.role, + details: toPrismaJsonValue(payload), + ip_address: req.ip + }); + + return res.status(201).json(network); + } catch (error) { + return next(error); + } +}); + +router.post("/private-networks/attach", requireAuth, authorize("vm:update"), async (req, res, next) => { + try { + const payload = privateNetworkAttachSchema.parse(req.body ?? {}); + await ensureVmTenantScope(payload.vm_id, req); + + const attachment = await attachPrivateNetwork({ + network_id: payload.network_id, + vm_id: payload.vm_id, + interface_name: payload.interface_name, + requested_ip: payload.requested_ip, + metadata: payload.metadata, + actor_email: req.user?.email + }); + + await logAudit({ + action: "private_network.attach", + resource_type: "VM", + resource_id: payload.vm_id, + resource_name: attachment.vm.name, + actor_email: req.user!.email, + actor_role: req.user!.role, + details: toPrismaJsonValue({ + attachment_id: attachment.id, + network_id: payload.network_id, + interface_name: attachment.interface_name, + requested_ip: payload.requested_ip + }), + ip_address: req.ip + }); + + return res.status(201).json(attachment); + } catch (error) { + return next(error); + } +}); + +router.post("/private-networks/attachments/:id/detach", requireAuth, authorize("vm:update"), async (req, res, next) => { + try { + const existing = await prisma.privateNetworkAttachment.findUnique({ + where: { id: req.params.id }, + select: { + id: true, + vm_id: true + } + }); + + if (!existing) throw new HttpError(404, "Private network attachment not found", "PRIVATE_NETWORK_ATTACHMENT_NOT_FOUND"); + await ensureVmTenantScope(existing.vm_id, req); + + const attachment = await detachPrivateNetwork({ + attachment_id: req.params.id, + actor_email: req.user?.email + }); + + await logAudit({ + action: "private_network.detach", + resource_type: "VM", + resource_id: attachment.vm.id, + resource_name: attachment.vm.name, + actor_email: req.user!.email, + actor_role: req.user!.role, + details: toPrismaJsonValue({ + attachment_id: attachment.id, + network_id: attachment.network_id, + interface_name: attachment.interface_name + }), + ip_address: req.ip + }); + + return res.json(attachment); + } catch (error) { + return next(error); + } +}); + +export default router; diff --git a/backend/src/routes/operations.routes.ts b/backend/src/routes/operations.routes.ts new file mode 100644 index 0000000..c8ab2d8 --- /dev/null +++ b/backend/src/routes/operations.routes.ts @@ -0,0 +1,275 @@ +import { OperationTaskStatus, OperationTaskType, PowerScheduleAction } from "@prisma/client"; +import { Router } from "express"; +import { z } from "zod"; +import { HttpError } from "../lib/http-error"; +import { prisma } from "../lib/prisma"; +import { authorize, isTenantScopedUser, requireAuth } from "../middleware/auth"; +import { + createPowerSchedule, + deletePowerSchedule, + executeVmPowerActionNow, + listOperationTasks, + operationQueueInsights, + listPowerSchedules, + updatePowerSchedule +} from "../services/operations.service"; +import { logAudit } from "../services/audit.service"; + +const router = Router(); + +const scheduleCreateSchema = z.object({ + vm_id: z.string().min(1), + action: z.nativeEnum(PowerScheduleAction), + cron_expression: z.string().min(5), + timezone: z.string().default("UTC") +}); + +const scheduleUpdateSchema = z.object({ + action: z.nativeEnum(PowerScheduleAction).optional(), + cron_expression: z.string().min(5).optional(), + timezone: z.string().min(1).optional(), + enabled: z.boolean().optional() +}); + +function parseOptionalEnum>(value: unknown, enumObject: T) { + if (typeof value !== "string") return undefined; + const candidate = value.toUpperCase(); + return Object.values(enumObject).includes(candidate as T[keyof T]) + ? (candidate as T[keyof T]) + : undefined; +} + +async function ensureVmTenantAccess(vmId: string, req: Express.Request) { + const vm = await prisma.virtualMachine.findUnique({ + where: { id: vmId }, + select: { + id: true, + name: true, + node: true, + tenant_id: true + } + }); + + if (!vm) { + throw new HttpError(404, "VM not found", "VM_NOT_FOUND"); + } + + if (isTenantScopedUser(req) && req.user?.tenant_id && vm.tenant_id !== req.user.tenant_id) { + throw new HttpError(403, "Access denied for tenant scope", "TENANT_SCOPE_VIOLATION"); + } + + return vm; +} + +router.get("/tasks", requireAuth, authorize("audit:read"), async (req, res, next) => { + try { + const status = parseOptionalEnum(req.query.status, OperationTaskStatus); + const taskType = parseOptionalEnum(req.query.task_type, OperationTaskType); + const vmId = typeof req.query.vm_id === "string" ? req.query.vm_id : undefined; + const limit = typeof req.query.limit === "string" ? Number(req.query.limit) : undefined; + const offset = typeof req.query.offset === "string" ? Number(req.query.offset) : undefined; + + const result = await listOperationTasks({ + status, + taskType, + vmId, + limit, + offset, + tenantId: isTenantScopedUser(req) ? req.user?.tenant_id : undefined + }); + + res.json(result); + } catch (error) { + next(error); + } +}); + +router.get("/queue-insights", requireAuth, authorize("audit:read"), async (req, res, next) => { + try { + const data = await operationQueueInsights(isTenantScopedUser(req) ? req.user?.tenant_id : undefined); + return res.json(data); + } catch (error) { + return next(error); + } +}); + +router.get("/power-schedules", requireAuth, authorize("vm:read"), async (req, res, next) => { + try { + const schedules = await listPowerSchedules(isTenantScopedUser(req) ? req.user?.tenant_id : undefined); + res.json({ data: schedules }); + } catch (error) { + next(error); + } +}); + +router.post("/power-schedules", requireAuth, authorize("vm:update"), async (req, res, next) => { + try { + const payload = scheduleCreateSchema.parse(req.body ?? {}); + const vm = await ensureVmTenantAccess(payload.vm_id, req); + + const schedule = await createPowerSchedule({ + vmId: vm.id, + action: payload.action, + cronExpression: payload.cron_expression, + timezone: payload.timezone, + createdBy: req.user?.email + }); + + await logAudit({ + action: "power_schedule.create", + resource_type: "VM", + resource_id: vm.id, + resource_name: vm.name, + actor_email: req.user!.email, + actor_role: req.user!.role, + details: { + schedule_id: schedule.id, + action: payload.action, + cron_expression: payload.cron_expression + }, + ip_address: req.ip + }); + + res.status(201).json(schedule); + } catch (error) { + next(error); + } +}); + +router.patch("/power-schedules/:id", requireAuth, authorize("vm:update"), async (req, res, next) => { + try { + const payload = scheduleUpdateSchema.parse(req.body ?? {}); + const existing = await prisma.powerSchedule.findUnique({ + where: { id: req.params.id }, + include: { + vm: { + select: { + id: true, + name: true, + tenant_id: true + } + } + } + }); + + if (!existing) { + throw new HttpError(404, "Power schedule not found", "POWER_SCHEDULE_NOT_FOUND"); + } + + if (isTenantScopedUser(req) && req.user?.tenant_id && existing.vm.tenant_id !== req.user.tenant_id) { + throw new HttpError(403, "Access denied for tenant scope", "TENANT_SCOPE_VIOLATION"); + } + + const schedule = await updatePowerSchedule(existing.id, { + action: payload.action, + cronExpression: payload.cron_expression, + timezone: payload.timezone, + enabled: payload.enabled + }); + + await logAudit({ + action: "power_schedule.update", + resource_type: "VM", + resource_id: existing.vm.id, + resource_name: existing.vm.name, + actor_email: req.user!.email, + actor_role: req.user!.role, + details: { + schedule_id: existing.id, + payload + }, + ip_address: req.ip + }); + + res.json(schedule); + } catch (error) { + next(error); + } +}); + +router.delete("/power-schedules/:id", requireAuth, authorize("vm:update"), async (req, res, next) => { + try { + const existing = await prisma.powerSchedule.findUnique({ + where: { id: req.params.id }, + include: { + vm: { + select: { + id: true, + name: true, + tenant_id: true + } + } + } + }); + + if (!existing) { + throw new HttpError(404, "Power schedule not found", "POWER_SCHEDULE_NOT_FOUND"); + } + + if (isTenantScopedUser(req) && req.user?.tenant_id && existing.vm.tenant_id !== req.user.tenant_id) { + throw new HttpError(403, "Access denied for tenant scope", "TENANT_SCOPE_VIOLATION"); + } + + await deletePowerSchedule(existing.id); + + await logAudit({ + action: "power_schedule.delete", + resource_type: "VM", + resource_id: existing.vm.id, + resource_name: existing.vm.name, + actor_email: req.user!.email, + actor_role: req.user!.role, + details: { + schedule_id: existing.id + }, + ip_address: req.ip + }); + + res.status(204).send(); + } catch (error) { + next(error); + } +}); + +router.post("/power-schedules/:id/run", requireAuth, authorize("vm:update"), async (req, res, next) => { + try { + const existing = await prisma.powerSchedule.findUnique({ + where: { id: req.params.id }, + include: { + vm: { + select: { + id: true, + name: true, + tenant_id: true + } + } + } + }); + + if (!existing) { + throw new HttpError(404, "Power schedule not found", "POWER_SCHEDULE_NOT_FOUND"); + } + + if (isTenantScopedUser(req) && req.user?.tenant_id && existing.vm.tenant_id !== req.user.tenant_id) { + throw new HttpError(403, "Access denied for tenant scope", "TENANT_SCOPE_VIOLATION"); + } + + const execution = await executeVmPowerActionNow(existing.vm_id, existing.action, req.user!.email, { + payload: { + source: "manual_schedule_run", + schedule_id: existing.id + }, + scheduledFor: new Date() + }); + + res.json({ + success: true, + task_id: execution.task.id, + upid: execution.upid + }); + } catch (error) { + next(error); + } +}); + +export default router; diff --git a/backend/src/routes/payment.routes.ts b/backend/src/routes/payment.routes.ts new file mode 100644 index 0000000..19c143b --- /dev/null +++ b/backend/src/routes/payment.routes.ts @@ -0,0 +1,71 @@ +import { Router } from "express"; +import { z } from "zod"; +import { authorize, requireAuth } from "../middleware/auth"; +import { + createInvoicePaymentLink, + handleManualInvoicePayment, + processFlutterwaveWebhook, + processPaystackWebhook, + verifyFlutterwaveSignature, + verifyPaystackSignature +} from "../services/payment.service"; + +const router = Router(); + +const createLinkSchema = z.object({ + provider: z.enum(["paystack", "flutterwave", "manual"]).optional() +}); + +router.post("/invoices/:id/link", requireAuth, authorize("billing:manage"), async (req, res, next) => { + try { + const payload = createLinkSchema.parse(req.body ?? {}); + const result = await createInvoicePaymentLink(req.params.id, payload.provider); + res.json(result); + } catch (error) { + next(error); + } +}); + +const manualSchema = z.object({ + payment_reference: z.string().min(2) +}); + +router.post("/invoices/:id/manual-pay", requireAuth, authorize("billing:manage"), async (req, res, next) => { + try { + const payload = manualSchema.parse(req.body ?? {}); + const invoice = await handleManualInvoicePayment(req.params.id, payload.payment_reference, req.user?.email ?? "manual@system"); + res.json(invoice); + } catch (error) { + next(error); + } +}); + +router.post("/webhooks/paystack", async (req, res, next) => { + try { + const signature = req.header("x-paystack-signature"); + const valid = await verifyPaystackSignature(signature, req.rawBody); + if (!valid) { + return res.status(401).json({ error: { code: "INVALID_SIGNATURE", message: "Invalid signature" } }); + } + const result = await processPaystackWebhook(req.body); + return res.json(result); + } catch (error) { + return next(error); + } +}); + +router.post("/webhooks/flutterwave", async (req, res, next) => { + try { + const signature = req.header("verif-hash"); + const valid = await verifyFlutterwaveSignature(signature); + if (!valid) { + return res.status(401).json({ error: { code: "INVALID_SIGNATURE", message: "Invalid signature" } }); + } + const result = await processFlutterwaveWebhook(req.body); + return res.json(result); + } catch (error) { + return next(error); + } +}); + +export default router; diff --git a/backend/src/routes/provisioning.routes.ts b/backend/src/routes/provisioning.routes.ts new file mode 100644 index 0000000..05defbf --- /dev/null +++ b/backend/src/routes/provisioning.routes.ts @@ -0,0 +1,566 @@ +import { + ProductType, + ServiceLifecycleStatus, + TemplateType, + VmType +} from "@prisma/client"; +import { Router } from "express"; +import { z } from "zod"; +import { HttpError } from "../lib/http-error"; +import { toPrismaJsonValue } from "../lib/prisma-json"; +import { prisma } from "../lib/prisma"; +import { authorize, isTenantScopedUser, requireAuth } from "../middleware/auth"; +import { logAudit } from "../services/audit.service"; +import { + createApplicationGroup, + createPlacementPolicy, + createProvisionedService, + createTemplate, + createVmIdRange, + deleteApplicationGroup, + deletePlacementPolicy, + deleteTemplate, + deleteVmIdRange, + listApplicationGroups, + listPlacementPolicies, + listProvisionedServices, + listTemplates, + listVmIdRanges, + setApplicationGroupTemplates, + suspendProvisionedService, + terminateProvisionedService, + unsuspendProvisionedService, + updateApplicationGroup, + updatePlacementPolicy, + updateProvisionedServicePackage, + updateTemplate, + updateVmIdRange +} from "../services/provisioning.service"; + +const router = Router(); + +const templateCreateSchema = z.object({ + name: z.string().min(2), + slug: z.string().optional(), + template_type: z.nativeEnum(TemplateType), + virtualization_type: z.nativeEnum(VmType).optional(), + source: z.string().optional(), + description: z.string().optional(), + default_cloud_init: z.string().optional(), + metadata: z.record(z.unknown()).optional() +}); + +const templateUpdateSchema = z.object({ + name: z.string().min(2).optional(), + slug: z.string().optional(), + source: z.string().optional(), + description: z.string().optional(), + default_cloud_init: z.string().optional(), + is_active: z.boolean().optional(), + metadata: z.record(z.unknown()).optional() +}); + +const groupCreateSchema = z.object({ + name: z.string().min(2), + slug: z.string().optional(), + description: z.string().optional() +}); + +const groupUpdateSchema = z.object({ + name: z.string().min(2).optional(), + slug: z.string().optional(), + description: z.string().optional(), + is_active: z.boolean().optional() +}); + +const groupTemplatesSchema = z.object({ + templates: z + .array( + z.object({ + template_id: z.string().min(1), + priority: z.number().int().positive().optional() + }) + ) + .default([]) +}); + +const placementPolicySchema = z.object({ + group_id: z.string().optional(), + node_id: z.string().optional(), + product_type: z.nativeEnum(ProductType).optional(), + cpu_weight: z.number().int().min(0).max(1000).optional(), + ram_weight: z.number().int().min(0).max(1000).optional(), + disk_weight: z.number().int().min(0).max(1000).optional(), + vm_count_weight: z.number().int().min(0).max(1000).optional(), + max_vms: z.number().int().positive().optional(), + min_free_ram_mb: z.number().int().positive().optional(), + min_free_disk_gb: z.number().int().positive().optional(), + is_active: z.boolean().optional() +}); + +const vmidRangeCreateSchema = z.object({ + node_id: z.string().optional(), + node_hostname: z.string().min(1), + application_group_id: z.string().optional(), + range_start: z.number().int().positive(), + range_end: z.number().int().positive(), + next_vmid: z.number().int().positive().optional() +}); + +const vmidRangeUpdateSchema = z.object({ + range_start: z.number().int().positive().optional(), + range_end: z.number().int().positive().optional(), + next_vmid: z.number().int().positive().optional(), + is_active: z.boolean().optional() +}); + +const serviceCreateSchema = z.object({ + name: z.string().min(2), + tenant_id: z.string().min(1), + product_type: z.nativeEnum(ProductType).default(ProductType.VPS), + virtualization_type: z.nativeEnum(VmType).default(VmType.QEMU), + vm_count: z.number().int().min(1).max(20).default(1), + target_node: z.string().optional(), + auto_node: z.boolean().default(true), + application_group_id: z.string().optional(), + template_id: z.string().optional(), + billing_plan_id: z.string().optional(), + package_options: z.record(z.unknown()).optional() +}); + +const serviceSuspendSchema = z.object({ + reason: z.string().optional() +}); + +const serviceTerminateSchema = z.object({ + reason: z.string().optional(), + hard_delete: z.boolean().default(false) +}); + +const servicePackageSchema = z.object({ + package_options: z.record(z.unknown()) +}); + +function parseOptionalLifecycleStatus(value: unknown) { + if (typeof value !== "string") return undefined; + const normalized = value.toUpperCase(); + return Object.values(ServiceLifecycleStatus).includes(normalized as ServiceLifecycleStatus) + ? (normalized as ServiceLifecycleStatus) + : undefined; +} + +async function ensureServiceTenantScope(serviceId: string, req: Express.Request) { + const service = await prisma.provisionedService.findUnique({ + where: { id: serviceId }, + include: { + vm: { + select: { + id: true, + tenant_id: true, + name: true + } + } + } + }); + + if (!service) { + throw new HttpError(404, "Provisioned service not found", "SERVICE_NOT_FOUND"); + } + + if (isTenantScopedUser(req) && req.user?.tenant_id && service.tenant_id !== req.user.tenant_id) { + throw new HttpError(403, "Access denied for tenant scope", "TENANT_SCOPE_VIOLATION"); + } + + return service; +} + +router.get("/templates", requireAuth, authorize("vm:read"), async (req, res, next) => { + try { + const templateType = typeof req.query.template_type === "string" ? req.query.template_type.toUpperCase() : undefined; + const isActive = + typeof req.query.is_active === "string" + ? req.query.is_active === "true" + : undefined; + + const templates = await listTemplates({ + templateType, + isActive + }); + + res.json({ data: templates }); + } catch (error) { + next(error); + } +}); + +router.post("/templates", requireAuth, authorize("vm:create"), async (req, res, next) => { + try { + const payload = templateCreateSchema.parse(req.body ?? {}); + const template = await createTemplate({ + name: payload.name, + slug: payload.slug, + templateType: payload.template_type, + virtualizationType: payload.virtualization_type, + source: payload.source, + description: payload.description, + defaultCloudInit: payload.default_cloud_init, + metadata: payload.metadata ? toPrismaJsonValue(payload.metadata) : undefined + }); + + await logAudit({ + action: "template.create", + resource_type: "SYSTEM", + resource_id: template.id, + resource_name: template.name, + actor_email: req.user!.email, + actor_role: req.user!.role, + details: toPrismaJsonValue(payload), + ip_address: req.ip + }); + + res.status(201).json(template); + } catch (error) { + next(error); + } +}); + +router.patch("/templates/:id", requireAuth, authorize("vm:update"), async (req, res, next) => { + try { + const payload = templateUpdateSchema.parse(req.body ?? {}); + const template = await updateTemplate(req.params.id, { + name: payload.name, + slug: payload.slug, + source: payload.source, + description: payload.description, + defaultCloudInit: payload.default_cloud_init, + isActive: payload.is_active, + metadata: payload.metadata ? toPrismaJsonValue(payload.metadata) : undefined + }); + + await logAudit({ + action: "template.update", + resource_type: "SYSTEM", + resource_id: template.id, + resource_name: template.name, + actor_email: req.user!.email, + actor_role: req.user!.role, + details: toPrismaJsonValue(payload), + ip_address: req.ip + }); + + res.json(template); + } catch (error) { + next(error); + } +}); + +router.delete("/templates/:id", requireAuth, authorize("vm:delete"), async (req, res, next) => { + try { + await deleteTemplate(req.params.id); + res.status(204).send(); + } catch (error) { + next(error); + } +}); + +router.get("/application-groups", requireAuth, authorize("vm:read"), async (_req, res, next) => { + try { + const groups = await listApplicationGroups(); + res.json({ data: groups }); + } catch (error) { + next(error); + } +}); + +router.post("/application-groups", requireAuth, authorize("vm:create"), async (req, res, next) => { + try { + const payload = groupCreateSchema.parse(req.body ?? {}); + const group = await createApplicationGroup({ + name: payload.name, + slug: payload.slug, + description: payload.description + }); + res.status(201).json(group); + } catch (error) { + next(error); + } +}); + +router.patch("/application-groups/:id", requireAuth, authorize("vm:update"), async (req, res, next) => { + try { + const payload = groupUpdateSchema.parse(req.body ?? {}); + const group = await updateApplicationGroup(req.params.id, { + name: payload.name, + slug: payload.slug, + description: payload.description, + isActive: payload.is_active + }); + res.json(group); + } catch (error) { + next(error); + } +}); + +router.delete("/application-groups/:id", requireAuth, authorize("vm:delete"), async (req, res, next) => { + try { + await deleteApplicationGroup(req.params.id); + res.status(204).send(); + } catch (error) { + next(error); + } +}); + +router.put("/application-groups/:id/templates", requireAuth, authorize("vm:update"), async (req, res, next) => { + try { + const payload = groupTemplatesSchema.parse(req.body ?? {}); + + const assignments = await setApplicationGroupTemplates( + req.params.id, + payload.templates.map((template) => ({ + templateId: template.template_id, + priority: template.priority + })) + ); + + res.json({ data: assignments }); + } catch (error) { + next(error); + } +}); + +router.get("/placement-policies", requireAuth, authorize("node:read"), async (_req, res, next) => { + try { + const policies = await listPlacementPolicies(); + res.json({ data: policies }); + } catch (error) { + next(error); + } +}); + +router.post("/placement-policies", requireAuth, authorize("node:manage"), async (req, res, next) => { + try { + const payload = placementPolicySchema.parse(req.body ?? {}); + const policy = await createPlacementPolicy({ + groupId: payload.group_id, + nodeId: payload.node_id, + productType: payload.product_type, + cpuWeight: payload.cpu_weight, + ramWeight: payload.ram_weight, + diskWeight: payload.disk_weight, + vmCountWeight: payload.vm_count_weight, + maxVms: payload.max_vms, + minFreeRamMb: payload.min_free_ram_mb, + minFreeDiskGb: payload.min_free_disk_gb + }); + res.status(201).json(policy); + } catch (error) { + next(error); + } +}); + +router.patch("/placement-policies/:id", requireAuth, authorize("node:manage"), async (req, res, next) => { + try { + const payload = placementPolicySchema.parse(req.body ?? {}); + const policy = await updatePlacementPolicy(req.params.id, { + cpuWeight: payload.cpu_weight, + ramWeight: payload.ram_weight, + diskWeight: payload.disk_weight, + vmCountWeight: payload.vm_count_weight, + maxVms: payload.max_vms ?? null, + minFreeRamMb: payload.min_free_ram_mb ?? null, + minFreeDiskGb: payload.min_free_disk_gb ?? null, + isActive: payload.is_active + }); + res.json(policy); + } catch (error) { + next(error); + } +}); + +router.delete("/placement-policies/:id", requireAuth, authorize("node:manage"), async (req, res, next) => { + try { + await deletePlacementPolicy(req.params.id); + res.status(204).send(); + } catch (error) { + next(error); + } +}); + +router.get("/vmid-ranges", requireAuth, authorize("node:read"), async (_req, res, next) => { + try { + const ranges = await listVmIdRanges(); + res.json({ data: ranges }); + } catch (error) { + next(error); + } +}); + +router.post("/vmid-ranges", requireAuth, authorize("node:manage"), async (req, res, next) => { + try { + const payload = vmidRangeCreateSchema.parse(req.body ?? {}); + const range = await createVmIdRange({ + nodeId: payload.node_id, + nodeHostname: payload.node_hostname, + applicationGroupId: payload.application_group_id, + rangeStart: payload.range_start, + rangeEnd: payload.range_end, + nextVmid: payload.next_vmid + }); + res.status(201).json(range); + } catch (error) { + next(error); + } +}); + +router.patch("/vmid-ranges/:id", requireAuth, authorize("node:manage"), async (req, res, next) => { + try { + const payload = vmidRangeUpdateSchema.parse(req.body ?? {}); + const range = await updateVmIdRange(req.params.id, { + rangeStart: payload.range_start, + rangeEnd: payload.range_end, + nextVmid: payload.next_vmid, + isActive: payload.is_active + }); + res.json(range); + } catch (error) { + next(error); + } +}); + +router.delete("/vmid-ranges/:id", requireAuth, authorize("node:manage"), async (req, res, next) => { + try { + await deleteVmIdRange(req.params.id); + res.status(204).send(); + } catch (error) { + next(error); + } +}); + +router.get("/services", requireAuth, authorize("vm:read"), async (req, res, next) => { + try { + const lifecycleStatus = parseOptionalLifecycleStatus(req.query.lifecycle_status); + const limit = typeof req.query.limit === "string" ? Number(req.query.limit) : undefined; + const offset = typeof req.query.offset === "string" ? Number(req.query.offset) : undefined; + + const result = await listProvisionedServices({ + tenantId: isTenantScopedUser(req) ? req.user?.tenant_id ?? undefined : undefined, + lifecycleStatus, + limit, + offset + }); + + res.json(result); + } catch (error) { + next(error); + } +}); + +router.post("/services", requireAuth, authorize("vm:create"), async (req, res, next) => { + try { + const payload = serviceCreateSchema.parse(req.body ?? {}); + + if (isTenantScopedUser(req) && req.user?.tenant_id && payload.tenant_id !== req.user.tenant_id) { + throw new HttpError(403, "Access denied for tenant scope", "TENANT_SCOPE_VIOLATION"); + } + + const services = await createProvisionedService({ + name: payload.name, + tenantId: payload.tenant_id, + productType: payload.product_type, + virtualizationType: payload.virtualization_type, + vmCount: payload.vm_count, + targetNode: payload.target_node, + autoNode: payload.auto_node, + applicationGroupId: payload.application_group_id, + templateId: payload.template_id, + billingPlanId: payload.billing_plan_id, + packageOptions: payload.package_options ? toPrismaJsonValue(payload.package_options) : undefined, + createdBy: req.user?.email + }); + + await logAudit({ + action: "service.create", + resource_type: "VM", + actor_email: req.user!.email, + actor_role: req.user!.role, + details: { + tenant_id: payload.tenant_id, + product_type: payload.product_type, + vm_count: payload.vm_count, + created_services: services.map((service) => service.id) + }, + ip_address: req.ip + }); + + res.status(201).json({ data: services }); + } catch (error) { + next(error); + } +}); + +router.post("/services/:id/suspend", requireAuth, authorize("vm:update"), async (req, res, next) => { + try { + const payload = serviceSuspendSchema.parse(req.body ?? {}); + await ensureServiceTenantScope(req.params.id, req); + + const service = await suspendProvisionedService({ + serviceId: req.params.id, + actorEmail: req.user!.email, + reason: payload.reason + }); + + res.json(service); + } catch (error) { + next(error); + } +}); + +router.post("/services/:id/unsuspend", requireAuth, authorize("vm:update"), async (req, res, next) => { + try { + await ensureServiceTenantScope(req.params.id, req); + const service = await unsuspendProvisionedService({ + serviceId: req.params.id, + actorEmail: req.user!.email + }); + res.json(service); + } catch (error) { + next(error); + } +}); + +router.post("/services/:id/terminate", requireAuth, authorize("vm:delete"), async (req, res, next) => { + try { + const payload = serviceTerminateSchema.parse(req.body ?? {}); + await ensureServiceTenantScope(req.params.id, req); + + const service = await terminateProvisionedService({ + serviceId: req.params.id, + actorEmail: req.user!.email, + reason: payload.reason, + hardDelete: payload.hard_delete + }); + + res.json(service); + } catch (error) { + next(error); + } +}); + +router.patch("/services/:id/package-options", requireAuth, authorize("vm:update"), async (req, res, next) => { + try { + const payload = servicePackageSchema.parse(req.body ?? {}); + await ensureServiceTenantScope(req.params.id, req); + + const service = await updateProvisionedServicePackage({ + serviceId: req.params.id, + actorEmail: req.user!.email, + packageOptions: toPrismaJsonValue(payload.package_options) + }); + + res.json(service); + } catch (error) { + next(error); + } +}); + +export default router; diff --git a/backend/src/routes/proxmox.routes.ts b/backend/src/routes/proxmox.routes.ts new file mode 100644 index 0000000..471497d --- /dev/null +++ b/backend/src/routes/proxmox.routes.ts @@ -0,0 +1,637 @@ +import { OperationTaskType, Prisma } from "@prisma/client"; +import { Router } from "express"; +import { z } from "zod"; +import { HttpError } from "../lib/http-error"; +import { prisma } from "../lib/prisma"; +import { authorize, requireAuth } from "../middleware/auth"; +import { + addVmDisk, + clusterUsageGraphs, + deleteVm, + migrateVm, + nodeUsageGraphs, + vmUsageGraphs, + reinstallVm, + reconfigureVmNetwork, + restartVm, + resumeVm, + shutdownVm, + startVm, + stopVm, + suspendVm, + syncNodesAndVirtualMachines, + updateVmConfiguration, + vmConsoleTicket +} from "../services/proxmox.service"; +import { logAudit } from "../services/audit.service"; +import { + createOperationTask, + markOperationTaskFailed, + markOperationTaskRunning, + markOperationTaskSuccess +} from "../services/operations.service"; + +const router = Router(); +const consoleTypeSchema = z.enum(["novnc", "spice", "xterm"]); +const graphTimeframeSchema = z.enum(["hour", "day", "week", "month", "year"]); + +function vmRuntimeType(vm: { type: "QEMU" | "LXC" }) { + return vm.type === "LXC" ? "lxc" : "qemu"; +} + +function withUpid(payload: Prisma.InputJsonObject, upid?: string): Prisma.InputJsonObject { + if (!upid) { + return payload; + } + + return { + ...payload, + upid + }; +} + +async function fetchVm(vmId: string) { + const vm = await prisma.virtualMachine.findUnique({ where: { id: vmId } }); + if (!vm) { + throw new HttpError(404, "VM not found", "VM_NOT_FOUND"); + } + return vm; +} + +async function resolveConsoleProxyTarget(node: string, consoleType: "novnc" | "spice" | "xterm") { + const setting = await prisma.setting.findUnique({ + where: { + key: "console_proxy" + } + }); + + const raw = setting?.value as + | { + mode?: "cluster" | "per_node"; + cluster?: Record; + nodes?: Record>; + } + | undefined; + + if (!raw) { + return undefined; + } + + const mode = raw.mode ?? "cluster"; + if (mode === "per_node") { + const nodeConfig = raw.nodes?.[node]; + if (nodeConfig && typeof nodeConfig[consoleType] === "string") { + return String(nodeConfig[consoleType]); + } + } + + if (raw.cluster && typeof raw.cluster[consoleType] === "string") { + return String(raw.cluster[consoleType]); + } + + return undefined; +} + +router.post("/sync", requireAuth, authorize("node:manage"), async (req, res, next) => { + try { + const task = await createOperationTask({ + taskType: OperationTaskType.SYSTEM_SYNC, + requestedBy: req.user?.email, + payload: { source: "manual_sync" } + }); + + await markOperationTaskRunning(task.id); + + try { + const result = await syncNodesAndVirtualMachines(); + await markOperationTaskSuccess(task.id, { + node_count: result.node_count + }); + + await logAudit({ + action: "proxmox_sync", + resource_type: "NODE", + actor_email: req.user!.email, + actor_role: req.user!.role, + details: { + node_count: result.node_count, + task_id: task.id + }, + ip_address: req.ip + }); + + res.json({ + ...result, + task_id: task.id + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Proxmox sync failed"; + await markOperationTaskFailed(task.id, message); + throw error; + } + } catch (error) { + next(error); + } +}); + +const actionSchema = z.object({ + action: z.enum(["start", "stop", "restart", "shutdown", "suspend", "resume", "delete"]) +}); + +router.post("/vms/:id/actions/:action", requireAuth, authorize("vm:update"), async (req, res, next) => { + try { + const { action } = actionSchema.parse(req.params); + const vm = await fetchVm(req.params.id); + const type = vmRuntimeType(vm); + + const taskType = action === "delete" ? OperationTaskType.VM_DELETE : OperationTaskType.VM_POWER; + const task = await createOperationTask({ + taskType, + vm: { + id: vm.id, + name: vm.name, + node: vm.node + }, + requestedBy: req.user?.email, + payload: { action } + }); + + await markOperationTaskRunning(task.id); + + let upid: string | undefined; + + try { + if (action === "start") { + upid = await startVm(vm.node, vm.vmid, type); + await prisma.virtualMachine.update({ where: { id: vm.id }, data: { status: "RUNNING", proxmox_upid: upid } }); + } else if (action === "stop") { + upid = await stopVm(vm.node, vm.vmid, type); + await prisma.virtualMachine.update({ where: { id: vm.id }, data: { status: "STOPPED", proxmox_upid: upid } }); + } else if (action === "restart") { + upid = await restartVm(vm.node, vm.vmid, type); + await prisma.virtualMachine.update({ where: { id: vm.id }, data: { status: "RUNNING", proxmox_upid: upid } }); + } else if (action === "shutdown") { + upid = await shutdownVm(vm.node, vm.vmid, type); + await prisma.virtualMachine.update({ where: { id: vm.id }, data: { status: "STOPPED", proxmox_upid: upid } }); + } else if (action === "suspend") { + upid = await suspendVm(vm.node, vm.vmid, type); + await prisma.virtualMachine.update({ where: { id: vm.id }, data: { status: "PAUSED", proxmox_upid: upid } }); + } else if (action === "resume") { + upid = await resumeVm(vm.node, vm.vmid, type); + await prisma.virtualMachine.update({ where: { id: vm.id }, data: { status: "RUNNING", proxmox_upid: upid } }); + } else { + upid = await deleteVm(vm.node, vm.vmid, type); + await prisma.virtualMachine.delete({ where: { id: vm.id } }); + } + + const taskResult = withUpid( + { + vm_id: vm.id, + action + }, + upid + ); + + await markOperationTaskSuccess(task.id, taskResult, upid); + + await logAudit({ + action: `vm_${action}`, + resource_type: "VM", + resource_id: vm.id, + resource_name: vm.name, + actor_email: req.user!.email, + actor_role: req.user!.role, + details: { + ...taskResult, + task_id: task.id + }, + ip_address: req.ip + }); + + res.json({ success: true, action, upid, task_id: task.id }); + } catch (error) { + const message = error instanceof Error ? error.message : "VM action failed"; + await markOperationTaskFailed(task.id, message); + throw error; + } + } catch (error) { + next(error); + } +}); + +const migrateSchema = z.object({ + target_node: z.string().min(1) +}); + +router.post("/vms/:id/migrate", requireAuth, authorize("vm:update"), async (req, res, next) => { + try { + const payload = migrateSchema.parse(req.body); + const vm = await fetchVm(req.params.id); + const type = vmRuntimeType(vm); + + const task = await createOperationTask({ + taskType: OperationTaskType.VM_MIGRATION, + vm: { + id: vm.id, + name: vm.name, + node: vm.node + }, + requestedBy: req.user?.email, + payload + }); + + await markOperationTaskRunning(task.id); + + try { + const upid = await migrateVm(vm.node, vm.vmid, payload.target_node, type); + await prisma.virtualMachine.update({ + where: { id: vm.id }, + data: { node: payload.target_node, status: "MIGRATING", proxmox_upid: upid } + }); + + const migrationResult = withUpid( + { + vm_id: vm.id, + from_node: vm.node, + target_node: payload.target_node + }, + upid + ); + + await markOperationTaskSuccess(task.id, migrationResult, upid); + res.json({ success: true, upid, target_node: payload.target_node, task_id: task.id }); + } catch (error) { + const message = error instanceof Error ? error.message : "VM migrate failed"; + await markOperationTaskFailed(task.id, message); + throw error; + } + } catch (error) { + next(error); + } +}); + +const configSchema = z + .object({ + hostname: z.string().min(1).optional(), + iso_image: z.string().min(1).optional(), + boot_order: z.string().min(1).optional(), + ssh_public_key: z.string().min(10).optional(), + qemu_guest_agent: z.boolean().optional() + }) + .refine((value) => Object.keys(value).length > 0, { + message: "At least one configuration field is required" + }); + +router.patch("/vms/:id/config", requireAuth, authorize("vm:update"), async (req, res, next) => { + try { + const payload = configSchema.parse(req.body ?? {}); + const vm = await fetchVm(req.params.id); + const type = vmRuntimeType(vm); + + const config: Record = {}; + if (payload.hostname) config.name = payload.hostname; + if (payload.boot_order) config.boot = payload.boot_order; + if (payload.ssh_public_key) config.sshkeys = payload.ssh_public_key; + if (payload.iso_image && vm.type === "QEMU") config.ide2 = `${payload.iso_image},media=cdrom`; + if (typeof payload.qemu_guest_agent === "boolean" && vm.type === "QEMU") { + config.agent = payload.qemu_guest_agent ? 1 : 0; + } + + const task = await createOperationTask({ + taskType: OperationTaskType.VM_CONFIG, + vm: { id: vm.id, name: vm.name, node: vm.node }, + requestedBy: req.user?.email, + payload + }); + + await markOperationTaskRunning(task.id); + + try { + const upid = await updateVmConfiguration(vm.node, vm.vmid, type, config); + const configResult = withUpid( + { + vm_id: vm.id, + config: config as unknown as Prisma.InputJsonValue + }, + upid + ); + await markOperationTaskSuccess(task.id, configResult, upid); + + await logAudit({ + action: "vm_config_update", + resource_type: "VM", + resource_id: vm.id, + resource_name: vm.name, + actor_email: req.user!.email, + actor_role: req.user!.role, + details: { + config: config as unknown as Prisma.InputJsonValue, + task_id: task.id, + ...(upid ? { upid } : {}) + }, + ip_address: req.ip + }); + + res.json({ success: true, upid, task_id: task.id, config_applied: config }); + } catch (error) { + const message = error instanceof Error ? error.message : "VM config update failed"; + await markOperationTaskFailed(task.id, message); + throw error; + } + } catch (error) { + next(error); + } +}); + +const networkSchema = z.object({ + interface_name: z.string().optional(), + bridge: z.string().min(1), + vlan_tag: z.number().int().min(0).max(4094).optional(), + rate_mbps: z.number().int().positive().optional(), + firewall: z.boolean().optional(), + ip_mode: z.enum(["dhcp", "static"]).default("dhcp"), + ip_cidr: z.string().optional(), + gateway: z.string().optional() +}); + +router.patch("/vms/:id/network", requireAuth, authorize("vm:update"), async (req, res, next) => { + try { + const payload = networkSchema.parse(req.body ?? {}); + if (payload.ip_mode === "static" && !payload.ip_cidr) { + throw new HttpError(400, "ip_cidr is required when ip_mode=static", "INVALID_NETWORK_PAYLOAD"); + } + + const vm = await fetchVm(req.params.id); + const type = vmRuntimeType(vm); + + const task = await createOperationTask({ + taskType: OperationTaskType.VM_NETWORK, + vm: { id: vm.id, name: vm.name, node: vm.node }, + requestedBy: req.user?.email, + payload + }); + + await markOperationTaskRunning(task.id); + + try { + const networkInput: Parameters[3] = { + interface_name: payload.interface_name, + bridge: payload.bridge, + vlan_tag: payload.vlan_tag, + rate_mbps: payload.rate_mbps, + firewall: payload.firewall, + ip_mode: payload.ip_mode, + ip_cidr: payload.ip_cidr, + gateway: payload.gateway + }; + const upid = await reconfigureVmNetwork(vm.node, vm.vmid, type, networkInput); + const networkResult = withUpid( + { + vm_id: vm.id, + network: payload as unknown as Prisma.InputJsonValue + }, + upid + ); + await markOperationTaskSuccess(task.id, networkResult, upid); + res.json({ success: true, upid, task_id: task.id }); + } catch (error) { + const message = error instanceof Error ? error.message : "VM network update failed"; + await markOperationTaskFailed(task.id, message); + throw error; + } + } catch (error) { + next(error); + } +}); + +const diskSchema = z.object({ + storage: z.string().min(1), + size_gb: z.number().int().positive(), + bus: z.enum(["scsi", "sata", "virtio", "ide"]).default("scsi"), + mount_point: z.string().optional() +}); + +router.post("/vms/:id/disks", requireAuth, authorize("vm:update"), async (req, res, next) => { + try { + const payload = diskSchema.parse(req.body ?? {}); + const vm = await fetchVm(req.params.id); + const type = vmRuntimeType(vm); + + const task = await createOperationTask({ + taskType: OperationTaskType.VM_CONFIG, + vm: { id: vm.id, name: vm.name, node: vm.node }, + requestedBy: req.user?.email, + payload + }); + + await markOperationTaskRunning(task.id); + + try { + const diskInput: Parameters[3] = { + storage: payload.storage, + size_gb: payload.size_gb, + bus: payload.bus, + mount_point: payload.mount_point + }; + const upid = await addVmDisk(vm.node, vm.vmid, type, diskInput); + const diskResult = withUpid( + { + vm_id: vm.id, + disk: payload as unknown as Prisma.InputJsonValue + }, + upid + ); + await markOperationTaskSuccess(task.id, diskResult, upid); + res.status(201).json({ success: true, upid, task_id: task.id }); + } catch (error) { + const message = error instanceof Error ? error.message : "VM disk attach failed"; + await markOperationTaskFailed(task.id, message); + throw error; + } + } catch (error) { + next(error); + } +}); + +const reinstallSchema = z.object({ + backup_before_reinstall: z.boolean().default(false), + iso_image: z.string().optional(), + ssh_public_key: z.string().optional() +}); + +router.post("/vms/:id/reinstall", requireAuth, authorize("vm:update"), async (req, res, next) => { + try { + const payload = reinstallSchema.parse(req.body ?? {}); + const vm = await fetchVm(req.params.id); + const type = vmRuntimeType(vm); + + if (payload.backup_before_reinstall) { + await prisma.backup.create({ + data: { + vm_id: vm.id, + vm_name: vm.name, + node: vm.node, + status: "PENDING", + type: "FULL", + schedule: "MANUAL", + notes: "Auto-created before VM reinstall" + } + }); + } + + const task = await createOperationTask({ + taskType: OperationTaskType.VM_REINSTALL, + vm: { id: vm.id, name: vm.name, node: vm.node }, + requestedBy: req.user?.email, + payload + }); + + await markOperationTaskRunning(task.id); + + try { + const upid = await reinstallVm(vm.node, vm.vmid, type, { + iso_image: payload.iso_image, + ssh_public_key: payload.ssh_public_key + }); + + await prisma.virtualMachine.update({ + where: { id: vm.id }, + data: { + status: "RUNNING", + proxmox_upid: upid ?? undefined + } + }); + + const reinstallResult = withUpid( + { + vm_id: vm.id, + reinstall: payload as unknown as Prisma.InputJsonValue + }, + upid + ); + + await markOperationTaskSuccess(task.id, reinstallResult, upid); + + res.json({ success: true, upid, task_id: task.id }); + } catch (error) { + const message = error instanceof Error ? error.message : "VM reinstall failed"; + await markOperationTaskFailed(task.id, message); + throw error; + } + } catch (error) { + next(error); + } +}); + +router.get("/vms/:id/console", requireAuth, authorize("vm:read"), async (req, res, next) => { + try { + const vm = await fetchVm(req.params.id); + const type = vmRuntimeType(vm); + const consoleType = consoleTypeSchema.parse( + typeof req.query.console_type === "string" + ? req.query.console_type.toLowerCase() + : "novnc" + ); + const ticket = await vmConsoleTicket(vm.node, vm.vmid, type, consoleType); + const proxyTarget = await resolveConsoleProxyTarget(vm.node, consoleType); + + res.json({ + ...ticket, + console_type: consoleType, + proxy_target: proxyTarget ?? null + }); + } catch (error) { + next(error); + } +}); + +router.get("/vms/:id/usage-graphs", requireAuth, authorize("vm:read"), async (req, res, next) => { + try { + const vm = await fetchVm(req.params.id); + const type = vmRuntimeType(vm); + const timeframe = graphTimeframeSchema.parse( + typeof req.query.timeframe === "string" ? req.query.timeframe.toLowerCase() : "day" + ); + + const graph = await vmUsageGraphs(vm.node, vm.vmid, type, timeframe, { + cpu_usage: vm.cpu_usage, + ram_usage: vm.ram_usage, + disk_usage: vm.disk_usage, + network_in: vm.network_in, + network_out: vm.network_out + }); + + return res.json({ + vm_id: vm.id, + vm_name: vm.name, + vm_type: vm.type, + node: vm.node, + timeframe: graph.timeframe, + source: graph.source, + summary: graph.summary, + points: graph.points + }); + } catch (error) { + return next(error); + } +}); + +router.get("/nodes/:id/usage-graphs", requireAuth, authorize("node:read"), async (req, res, next) => { + try { + const node = await prisma.proxmoxNode.findFirst({ + where: { + OR: [{ id: req.params.id }, { hostname: req.params.id }, { name: req.params.id }] + } + }); + + if (!node) { + throw new HttpError(404, "Node not found", "NODE_NOT_FOUND"); + } + + const timeframe = graphTimeframeSchema.parse( + typeof req.query.timeframe === "string" ? req.query.timeframe.toLowerCase() : "day" + ); + + const graph = await nodeUsageGraphs(node.hostname, timeframe, { + cpu_usage: node.cpu_usage, + ram_used_mb: node.ram_used_mb, + ram_total_mb: node.ram_total_mb, + disk_used_gb: node.disk_used_gb, + disk_total_gb: node.disk_total_gb + }); + + return res.json({ + node_id: node.id, + node_name: node.name, + node_hostname: node.hostname, + timeframe: graph.timeframe, + source: graph.source, + summary: graph.summary, + points: graph.points + }); + } catch (error) { + return next(error); + } +}); + +router.get("/cluster/usage-graphs", requireAuth, authorize("node:read"), async (req, res, next) => { + try { + const timeframe = graphTimeframeSchema.parse( + typeof req.query.timeframe === "string" ? req.query.timeframe.toLowerCase() : "day" + ); + const graph = await clusterUsageGraphs(timeframe); + + return res.json({ + timeframe: graph.timeframe, + source: graph.source, + node_count: graph.node_count, + nodes: graph.nodes, + summary: graph.summary, + points: graph.points + }); + } catch (error) { + return next(error); + } +}); + +export default router; diff --git a/backend/src/routes/resources.routes.ts b/backend/src/routes/resources.routes.ts new file mode 100644 index 0000000..4bf94ba --- /dev/null +++ b/backend/src/routes/resources.routes.ts @@ -0,0 +1,723 @@ +import { Router } from "express"; +import { authorize, isTenantScopedUser, requireAuth } from "../middleware/auth"; +import { HttpError } from "../lib/http-error"; +import { toPrismaJsonValue } from "../lib/prisma-json"; +import { logAudit } from "../services/audit.service"; +import { prisma } from "../lib/prisma"; +const router = Router(); + +type ResourceMeta = { + model: string; + readPermission: Parameters[0]; + createPermission?: Parameters[0]; + updatePermission?: Parameters[0]; + deletePermission?: Parameters[0]; + tenantScoped: boolean; + searchFields?: string[]; +}; + +const resourceMap: Record = { + tenants: { + model: "tenant", + readPermission: "tenant:read", + createPermission: "tenant:manage", + updatePermission: "tenant:manage", + deletePermission: "tenant:manage", + tenantScoped: false, + searchFields: ["name", "owner_email", "slug"] + }, + "virtual-machines": { + model: "virtualMachine", + readPermission: "vm:read", + createPermission: "vm:create", + updatePermission: "vm:update", + deletePermission: "vm:delete", + tenantScoped: true, + searchFields: ["name", "ip_address", "node"] + }, + nodes: { + model: "proxmoxNode", + readPermission: "node:read", + createPermission: "node:manage", + updatePermission: "node:manage", + deletePermission: "node:manage", + tenantScoped: false, + searchFields: ["name", "hostname"] + }, + "billing-plans": { + model: "billingPlan", + readPermission: "billing:read", + createPermission: "billing:manage", + updatePermission: "billing:manage", + deletePermission: "billing:manage", + tenantScoped: false, + searchFields: ["name", "slug", "description"] + }, + invoices: { + model: "invoice", + readPermission: "billing:read", + createPermission: "billing:manage", + updatePermission: "billing:manage", + deletePermission: "billing:manage", + tenantScoped: true, + searchFields: ["invoice_number", "tenant_name", "payment_reference"] + }, + "usage-records": { + model: "usageRecord", + readPermission: "billing:read", + createPermission: "billing:manage", + updatePermission: "billing:manage", + deletePermission: "billing:manage", + tenantScoped: true, + searchFields: ["vm_name", "tenant_name", "plan_name"] + }, + backups: { + model: "backup", + readPermission: "backup:read", + createPermission: "backup:manage", + updatePermission: "backup:manage", + deletePermission: "backup:manage", + tenantScoped: true, + searchFields: ["vm_name", "node", "storage"] + }, + "backup-policies": { + model: "backupPolicy", + readPermission: "backup:read", + createPermission: "backup:manage", + updatePermission: "backup:manage", + deletePermission: "backup:manage", + tenantScoped: true + }, + "backup-restore-tasks": { + model: "backupRestoreTask", + readPermission: "backup:read", + createPermission: "backup:manage", + updatePermission: "backup:manage", + deletePermission: "backup:manage", + tenantScoped: true + }, + "snapshot-jobs": { + model: "snapshotJob", + readPermission: "backup:read", + createPermission: "backup:manage", + updatePermission: "backup:manage", + deletePermission: "backup:manage", + tenantScoped: true + }, + "audit-logs": { + model: "auditLog", + readPermission: "audit:read", + tenantScoped: false, + searchFields: ["action", "resource_name", "actor_email"] + }, + "security-events": { + model: "securityEvent", + readPermission: "security:read", + createPermission: "security:manage", + updatePermission: "security:manage", + deletePermission: "security:manage", + tenantScoped: false, + searchFields: ["event_type", "source_ip", "description"] + }, + "firewall-rules": { + model: "firewallRule", + readPermission: "security:read", + createPermission: "security:manage", + updatePermission: "security:manage", + deletePermission: "security:manage", + tenantScoped: false, + searchFields: ["name", "source_ip", "destination_ip", "description"] + }, + users: { + model: "user", + readPermission: "user:read", + createPermission: "user:manage", + updatePermission: "user:manage", + deletePermission: "user:manage", + tenantScoped: true, + searchFields: ["email", "full_name"] + }, + "app-templates": { + model: "appTemplate", + readPermission: "vm:read", + createPermission: "vm:create", + updatePermission: "vm:update", + deletePermission: "vm:delete", + tenantScoped: false, + searchFields: ["name", "slug", "description", "source"] + }, + "application-groups": { + model: "applicationGroup", + readPermission: "vm:read", + createPermission: "vm:create", + updatePermission: "vm:update", + deletePermission: "vm:delete", + tenantScoped: false, + searchFields: ["name", "slug", "description"] + }, + "placement-policies": { + model: "nodePlacementPolicy", + readPermission: "node:read", + createPermission: "node:manage", + updatePermission: "node:manage", + deletePermission: "node:manage", + tenantScoped: false + }, + "vmid-ranges": { + model: "vmIdRange", + readPermission: "node:read", + createPermission: "node:manage", + updatePermission: "node:manage", + deletePermission: "node:manage", + tenantScoped: false + }, + "provisioned-services": { + model: "provisionedService", + readPermission: "vm:read", + createPermission: "vm:create", + updatePermission: "vm:update", + deletePermission: "vm:delete", + tenantScoped: true + }, + "ip-addresses": { + model: "ipAddressPool", + readPermission: "node:read", + createPermission: "node:manage", + updatePermission: "node:manage", + deletePermission: "node:manage", + tenantScoped: true, + searchFields: ["address", "subnet", "node_hostname", "bridge", "sdn_zone"] + }, + "ip-assignments": { + model: "ipAssignment", + readPermission: "vm:read", + createPermission: "vm:update", + updatePermission: "vm:update", + deletePermission: "vm:update", + tenantScoped: true + }, + "private-networks": { + model: "privateNetwork", + readPermission: "node:read", + createPermission: "node:manage", + updatePermission: "node:manage", + deletePermission: "node:manage", + tenantScoped: false, + searchFields: ["name", "slug", "cidr", "bridge", "sdn_zone", "node_hostname"] + }, + "private-network-attachments": { + model: "privateNetworkAttachment", + readPermission: "vm:read", + createPermission: "vm:update", + updatePermission: "vm:update", + deletePermission: "vm:update", + tenantScoped: true + }, + "tenant-ip-quotas": { + model: "tenantIpQuota", + readPermission: "tenant:read", + createPermission: "tenant:manage", + updatePermission: "tenant:manage", + deletePermission: "tenant:manage", + tenantScoped: true + }, + "ip-reserved-ranges": { + model: "ipReservedRange", + readPermission: "node:read", + createPermission: "node:manage", + updatePermission: "node:manage", + deletePermission: "node:manage", + tenantScoped: true, + searchFields: ["name", "cidr", "reason", "node_hostname", "bridge", "sdn_zone"] + }, + "ip-pool-policies": { + model: "ipPoolPolicy", + readPermission: "node:read", + createPermission: "node:manage", + updatePermission: "node:manage", + deletePermission: "node:manage", + tenantScoped: true, + searchFields: ["name", "node_hostname", "bridge", "sdn_zone"] + }, + "server-health-checks": { + model: "serverHealthCheck", + readPermission: "security:read", + createPermission: "security:manage", + updatePermission: "security:manage", + deletePermission: "security:manage", + tenantScoped: true, + searchFields: ["name", "description"] + }, + "server-health-check-results": { + model: "serverHealthCheckResult", + readPermission: "security:read", + tenantScoped: true + }, + "monitoring-alert-rules": { + model: "monitoringAlertRule", + readPermission: "security:read", + createPermission: "security:manage", + updatePermission: "security:manage", + deletePermission: "security:manage", + tenantScoped: true, + searchFields: ["name", "description"] + }, + "monitoring-alert-events": { + model: "monitoringAlertEvent", + readPermission: "security:read", + updatePermission: "security:manage", + tenantScoped: true, + searchFields: ["title", "message", "metric_key"] + }, + "monitoring-alert-notifications": { + model: "monitoringAlertNotification", + readPermission: "security:read", + tenantScoped: true, + searchFields: ["destination", "provider_message"] + } +}; + +function toEnumUpper(value: unknown): unknown { + if (typeof value !== "string") return value; + return value.replace(/-/g, "_").toUpperCase(); +} + +function normalizePayload(resource: string, input: Record) { + const data = { ...input }; + const enumFieldsByResource: Record = { + tenants: ["status", "currency", "payment_provider"], + "virtual-machines": ["status", "type"], + nodes: ["status"], + "billing-plans": ["currency"], + invoices: ["status", "currency", "payment_provider"], + "usage-records": ["currency"], + backups: ["status", "type", "schedule", "source"], + "backup-restore-tasks": ["mode", "status"], + "snapshot-jobs": ["frequency"], + "audit-logs": ["resource_type", "severity"], + "security-events": ["severity", "status"], + "firewall-rules": ["direction", "action", "protocol", "applies_to"], + users: ["role"], + "app-templates": ["template_type", "virtualization_type"], + "placement-policies": ["product_type"], + "provisioned-services": ["product_type", "lifecycle_status"], + "server-health-checks": ["target_type", "check_type"], + "server-health-check-results": ["status", "severity"], + "monitoring-alert-rules": ["severity"], + "monitoring-alert-events": ["status", "severity"], + "monitoring-alert-notifications": ["channel", "status"] + }; + + for (const field of enumFieldsByResource[resource] ?? []) { + if (field in data && data[field] !== undefined && data[field] !== null) { + data[field] = toEnumUpper(data[field]); + } + } + + if (resource === "billing-plans") { + const monthly = data.price_monthly; + if (monthly !== undefined && (data.price_hourly === undefined || data.price_hourly === null)) { + const monthlyNumber = Number(monthly); + data.price_hourly = Number((monthlyNumber / 720).toFixed(4)); + } + if (typeof data.features === "string") { + try { + data.features = JSON.parse(data.features); + } catch { + data.features = []; + } + } + } + + if (resource === "tenants" && typeof data.member_emails === "string") { + try { + data.member_emails = JSON.parse(data.member_emails); + } catch { + data.member_emails = []; + } + } + + if (resource === "invoices" && !data.invoice_number) { + data.invoice_number = `INV-${Date.now()}-${Math.floor(1000 + Math.random() * 9000)}`; + } + + if (resource === "invoices" && data.due_date && typeof data.due_date === "string") { + data.due_date = new Date(data.due_date); + } + if (resource === "invoices" && data.paid_date && typeof data.paid_date === "string") { + data.paid_date = new Date(data.paid_date); + } + + return data; +} + +function getModel(meta: ResourceMeta) { + return (prisma as any)[meta.model]; +} + +function normalizeSortField(field: string) { + const aliases: Record = { + created_date: "created_at", + updated_date: "updated_at" + }; + return aliases[field] ?? field; +} + +function parseOrder(sort?: string) { + if (!sort) return { created_at: "desc" as const }; + if (sort.startsWith("-")) return { [normalizeSortField(sort.slice(1))]: "desc" as const }; + return { [normalizeSortField(sort)]: "asc" as const }; +} + +function attachTenantWhere(req: Express.Request, meta: ResourceMeta, where: Record) { + if (!meta.tenantScoped || !isTenantScopedUser(req)) return; + const tenantId = req.user?.tenant_id; + if (!tenantId) return; + + if (meta.model === "backup") { + where.OR = [{ tenant_id: tenantId }, { vm: { tenant_id: tenantId } }]; + return; + } + + if (meta.model === "backupRestoreTask") { + where.source_vm = { tenant_id: tenantId }; + return; + } + + if (meta.model === "snapshotJob") { + where.vm = { tenant_id: tenantId }; + return; + } + + if (meta.model === "backupPolicy") { + where.tenant_id = tenantId; + return; + } + + if (meta.model === "ipAddressPool") { + where.OR = [{ assigned_tenant_id: tenantId }, { status: "AVAILABLE", scope: "PRIVATE" }]; + return; + } + + if (meta.model === "ipAssignment") { + where.tenant_id = tenantId; + return; + } + + if (meta.model === "privateNetworkAttachment") { + where.tenant_id = tenantId; + return; + } + + if (meta.model === "tenantIpQuota") { + where.tenant_id = tenantId; + return; + } + + if (meta.model === "ipReservedRange" || meta.model === "ipPoolPolicy") { + where.OR = [{ tenant_id: tenantId }, { tenant_id: null }]; + return; + } + + if (meta.model === "serverHealthCheck") { + where.OR = [{ tenant_id: tenantId }, { tenant_id: null }]; + return; + } + + if (meta.model === "serverHealthCheckResult") { + where.check = { + OR: [{ tenant_id: tenantId }, { tenant_id: null }] + }; + return; + } + + if (meta.model === "monitoringAlertRule" || meta.model === "monitoringAlertEvent") { + where.OR = [{ tenant_id: tenantId }, { tenant_id: null }]; + return; + } + + if (meta.model === "monitoringAlertNotification") { + where.event = { + OR: [{ tenant_id: tenantId }, { tenant_id: null }] + }; + return; + } + + where.tenant_id = tenantId; +} + +function attachSearchWhere( + where: Record, + search: string, + searchFields: string[] | undefined +) { + if (!search || !searchFields?.length) { + return; + } + + const searchFilter = { + OR: searchFields.map((field) => ({ + [field]: { contains: search, mode: "insensitive" } + })) + }; + + if (Array.isArray(where.OR)) { + const existingOr = where.OR; + delete where.OR; + const existingAnd = Array.isArray(where.AND) ? where.AND : []; + where.AND = [...existingAnd, { OR: existingOr }, searchFilter]; + return; + } + + if (Array.isArray(where.AND)) { + where.AND = [...where.AND, searchFilter]; + return; + } + + where.AND = [searchFilter]; +} + +async function ensureItemTenantScope(req: Express.Request, meta: ResourceMeta, item: Record) { + if (!meta.tenantScoped || !isTenantScopedUser(req) || !req.user?.tenant_id) { + return; + } + + const tenantId = req.user.tenant_id; + let ownerTenantId: string | null | undefined; + + if (meta.model === "backup") { + ownerTenantId = (item.tenant_id as string | null | undefined) ?? null; + if (!ownerTenantId && typeof item.vm_id === "string") { + const vm = await prisma.virtualMachine.findUnique({ + where: { id: item.vm_id }, + select: { tenant_id: true } + }); + ownerTenantId = vm?.tenant_id; + } + } else if (meta.model === "backupRestoreTask") { + if (typeof item.source_vm_id === "string") { + const vm = await prisma.virtualMachine.findUnique({ + where: { id: item.source_vm_id }, + select: { tenant_id: true } + }); + ownerTenantId = vm?.tenant_id; + } + } else if (meta.model === "snapshotJob") { + if (typeof item.vm_id === "string") { + const vm = await prisma.virtualMachine.findUnique({ + where: { id: item.vm_id }, + select: { tenant_id: true } + }); + ownerTenantId = vm?.tenant_id; + } + } else if (meta.model === "ipAddressPool") { + ownerTenantId = item.assigned_tenant_id as string | null | undefined; + if (!ownerTenantId && item.status === "AVAILABLE" && item.scope === "PRIVATE") { + return; + } + } else if (meta.model === "ipAssignment" || meta.model === "privateNetworkAttachment") { + ownerTenantId = item.tenant_id as string | null | undefined; + } else if (meta.model === "tenantIpQuota" || meta.model === "ipReservedRange" || meta.model === "ipPoolPolicy") { + ownerTenantId = (item.tenant_id as string | null | undefined) ?? null; + if (!ownerTenantId) return; + } else if (meta.model === "serverHealthCheck") { + ownerTenantId = (item.tenant_id as string | null | undefined) ?? null; + if (!ownerTenantId) return; + } else if (meta.model === "serverHealthCheckResult") { + if (typeof item.check_id === "string") { + const check = await prisma.serverHealthCheck.findUnique({ + where: { id: item.check_id }, + select: { tenant_id: true } + }); + ownerTenantId = check?.tenant_id; + if (!ownerTenantId) return; + } + } else if (meta.model === "monitoringAlertRule" || meta.model === "monitoringAlertEvent") { + ownerTenantId = (item.tenant_id as string | null | undefined) ?? null; + if (!ownerTenantId) return; + } else if (meta.model === "monitoringAlertNotification") { + if (typeof item.alert_event_id === "string") { + const event = await prisma.monitoringAlertEvent.findUnique({ + where: { id: item.alert_event_id }, + select: { tenant_id: true } + }); + ownerTenantId = event?.tenant_id; + if (!ownerTenantId) return; + } + } else { + ownerTenantId = item.tenant_id as string | null | undefined; + } + + if (ownerTenantId !== tenantId) { + throw new HttpError(403, "Access denied for tenant scope", "TENANT_SCOPE_VIOLATION"); + } +} + +router.get("/:resource", requireAuth, async (req, res, next) => { + try { + const resource = req.params.resource; + const meta = resourceMap[resource]; + if (!meta) throw new HttpError(404, "Unknown resource", "UNKNOWN_RESOURCE"); + await new Promise((resolve, reject) => authorize(meta.readPermission)(req, res, (error) => (error ? reject(error) : resolve()))); + + const model = getModel(meta); + const rawLimit = Number(req.query.limit ?? 100); + const rawOffset = Number(req.query.offset ?? 0); + const limit = Number.isFinite(rawLimit) && rawLimit > 0 ? Math.min(Math.floor(rawLimit), 500) : 100; + const offset = Number.isFinite(rawOffset) && rawOffset >= 0 ? Math.floor(rawOffset) : 0; + const where: Record = {}; + + attachTenantWhere(req, meta, where); + + if (typeof req.query.status === "string") where.status = toEnumUpper(req.query.status); + if (typeof req.query.tenant_id === "string" && !isTenantScopedUser(req)) where.tenant_id = req.query.tenant_id; + if (typeof req.query.vm_id === "string") where.vm_id = req.query.vm_id; + if (typeof req.query.node === "string") where.node = req.query.node; + + const search = typeof req.query.search === "string" ? req.query.search.trim() : ""; + attachSearchWhere(where, search, meta.searchFields); + + const [data, total] = await Promise.all([ + model.findMany({ + where, + orderBy: parseOrder(typeof req.query.sort === "string" ? req.query.sort : undefined), + take: limit, + skip: offset + }), + model.count({ where }) + ]); + + res.json({ + data, + meta: { total, limit, offset } + }); + } catch (error) { + next(error); + } +}); + +router.get("/:resource/:id", requireAuth, async (req, res, next) => { + try { + const resource = req.params.resource; + const meta = resourceMap[resource]; + if (!meta) throw new HttpError(404, "Unknown resource", "UNKNOWN_RESOURCE"); + await new Promise((resolve, reject) => authorize(meta.readPermission)(req, res, (error) => (error ? reject(error) : resolve()))); + + const model = getModel(meta); + const item = await model.findUnique({ where: { id: req.params.id } }); + if (!item) throw new HttpError(404, "Record not found", "NOT_FOUND"); + await ensureItemTenantScope(req, meta, item); + res.json(item); + } catch (error) { + next(error); + } +}); + +router.post("/:resource", requireAuth, async (req, res, next) => { + try { + const resource = req.params.resource; + const meta = resourceMap[resource]; + if (!meta) throw new HttpError(404, "Unknown resource", "UNKNOWN_RESOURCE"); + if (!meta.createPermission) throw new HttpError(405, "Resource is read-only", "READ_ONLY"); + await new Promise((resolve, reject) => authorize(meta.createPermission!)(req, res, (error) => (error ? reject(error) : resolve()))); + + const model = getModel(meta); + const payload = normalizePayload(resource, req.body ?? {}); + + if (meta.tenantScoped && isTenantScopedUser(req) && req.user?.tenant_id) { + if ( + meta.model !== "backupRestoreTask" && + meta.model !== "snapshotJob" + ) { + payload.tenant_id = req.user.tenant_id; + } + } + + const created = await model.create({ data: payload }); + + await logAudit({ + action: `${resource}.create`, + resource_type: resource === "virtual-machines" ? "VM" : "SYSTEM", + resource_id: created.id, + resource_name: created.name ?? created.invoice_number ?? created.id, + actor_email: req.user!.email, + actor_role: req.user!.role, + details: toPrismaJsonValue({ resource, payload: created }), + ip_address: req.ip + }); + + res.status(201).json(created); + } catch (error) { + next(error); + } +}); + +router.patch("/:resource/:id", requireAuth, async (req, res, next) => { + try { + const resource = req.params.resource; + const meta = resourceMap[resource]; + if (!meta) throw new HttpError(404, "Unknown resource", "UNKNOWN_RESOURCE"); + if (!meta.updatePermission) throw new HttpError(405, "Resource is read-only", "READ_ONLY"); + await new Promise((resolve, reject) => authorize(meta.updatePermission!)(req, res, (error) => (error ? reject(error) : resolve()))); + + const model = getModel(meta); + const existing = await model.findUnique({ where: { id: req.params.id } }); + if (!existing) throw new HttpError(404, "Record not found", "NOT_FOUND"); + await ensureItemTenantScope(req, meta, existing); + + const payload = normalizePayload(resource, req.body ?? {}); + const updated = await model.update({ + where: { id: req.params.id }, + data: payload + }); + + await logAudit({ + action: `${resource}.update`, + resource_type: resource === "virtual-machines" ? "VM" : "SYSTEM", + resource_id: updated.id, + resource_name: updated.name ?? updated.invoice_number ?? updated.id, + actor_email: req.user!.email, + actor_role: req.user!.role, + details: toPrismaJsonValue({ resource, payload }), + ip_address: req.ip + }); + + res.json(updated); + } catch (error) { + next(error); + } +}); + +router.delete("/:resource/:id", requireAuth, async (req, res, next) => { + try { + const resource = req.params.resource; + const meta = resourceMap[resource]; + if (!meta) throw new HttpError(404, "Unknown resource", "UNKNOWN_RESOURCE"); + if (!meta.deletePermission) throw new HttpError(405, "Resource is read-only", "READ_ONLY"); + await new Promise((resolve, reject) => authorize(meta.deletePermission!)(req, res, (error) => (error ? reject(error) : resolve()))); + + const model = getModel(meta); + const existing = await model.findUnique({ where: { id: req.params.id } }); + if (!existing) throw new HttpError(404, "Record not found", "NOT_FOUND"); + await ensureItemTenantScope(req, meta, existing); + + await model.delete({ where: { id: req.params.id } }); + + await logAudit({ + action: `${resource}.delete`, + resource_type: resource === "virtual-machines" ? "VM" : "SYSTEM", + resource_id: req.params.id, + resource_name: existing.name ?? existing.invoice_number ?? existing.id, + actor_email: req.user!.email, + actor_role: req.user!.role, + details: toPrismaJsonValue({ resource }), + ip_address: req.ip + }); + + res.status(204).send(); + } catch (error) { + next(error); + } +}); + +export default router; diff --git a/backend/src/routes/settings.routes.ts b/backend/src/routes/settings.routes.ts new file mode 100644 index 0000000..4414d73 --- /dev/null +++ b/backend/src/routes/settings.routes.ts @@ -0,0 +1,280 @@ +import { Router } from "express"; +import { z } from "zod"; +import { authorize, requireAuth } from "../middleware/auth"; +import { prisma } from "../lib/prisma"; +import { getOperationsPolicy } from "../services/operations.service"; +import { getSchedulerRuntimeSnapshot, reconfigureSchedulers, schedulerDefaults } from "../services/scheduler.service"; + +const router = Router(); + +const proxmoxSchema = z.object({ + host: z.string().min(1), + port: z.number().int().positive().default(8006), + username: z.string().min(1), + token_id: z.string().min(1), + token_secret: z.string().min(1), + verify_ssl: z.boolean().default(true) +}); + +const paymentSchema = z.object({ + default_provider: z.enum(["paystack", "flutterwave", "manual"]).default("paystack"), + paystack_public: z.string().optional(), + paystack_secret: z.string().optional(), + flutterwave_public: z.string().optional(), + flutterwave_secret: z.string().optional(), + flutterwave_webhook_hash: z.string().optional(), + callback_url: z.string().optional() +}); + +const backupSchema = z.object({ + default_source: z.enum(["local", "pbs", "remote"]).default("local"), + default_storage: z.string().default("local-lvm"), + max_restore_file_count: z.number().int().positive().default(100), + pbs_enabled: z.boolean().default(false), + pbs_host: z.string().optional(), + pbs_datastore: z.string().optional(), + pbs_namespace: z.string().optional(), + pbs_verify_ssl: z.boolean().default(true) +}); + +const consoleProxyNodeSchema = z.object({ + novnc: z.string().url().optional(), + spice: z.string().url().optional(), + xterm: z.string().url().optional() +}); + +const consoleProxySchema = z.object({ + mode: z.enum(["cluster", "per_node"]).default("cluster"), + cluster: consoleProxyNodeSchema.optional(), + nodes: z.record(consoleProxyNodeSchema).optional() +}); + +const schedulerSchema = z.object({ + enable_scheduler: z.boolean().optional(), + billing_cron: z.string().min(5).optional(), + backup_cron: z.string().min(5).optional(), + power_schedule_cron: z.string().min(5).optional(), + monitoring_cron: z.string().min(5).optional(), + operation_retry_cron: z.string().min(5).optional() +}); + +const operationsPolicySchema = z.object({ + max_retry_attempts: z.number().int().min(0).max(10).optional(), + retry_backoff_minutes: z.number().int().min(1).max(720).optional(), + notify_on_task_failure: z.boolean().optional(), + notification_email: z.string().email().optional(), + notification_webhook_url: z.string().url().optional(), + email_gateway_url: z.string().url().optional() +}); + +const notificationsSchema = z.object({ + email_alerts: z.boolean().optional(), + backup_alerts: z.boolean().optional(), + billing_alerts: z.boolean().optional(), + vm_alerts: z.boolean().optional(), + monitoring_webhook_url: z.string().url().optional(), + alert_webhook_url: z.string().url().optional(), + email_gateway_url: z.string().url().optional(), + notification_email_webhook: z.string().url().optional(), + ops_email: z.string().email().optional() +}); + +router.get("/proxmox", requireAuth, authorize("settings:read"), async (_req, res, next) => { + try { + const setting = await prisma.setting.findUnique({ where: { key: "proxmox" } }); + res.json(setting?.value ?? {}); + } catch (error) { + next(error); + } +}); + +router.put("/proxmox", requireAuth, authorize("settings:manage"), async (req, res, next) => { + try { + const payload = proxmoxSchema.parse(req.body); + const setting = await prisma.setting.upsert({ + where: { key: "proxmox" }, + update: { value: payload }, + create: { key: "proxmox", type: "PROXMOX", value: payload, is_encrypted: true } + }); + res.json(setting.value); + } catch (error) { + next(error); + } +}); + +router.get("/payment", requireAuth, authorize("settings:read"), async (_req, res, next) => { + try { + const setting = await prisma.setting.findUnique({ where: { key: "payment" } }); + res.json(setting?.value ?? {}); + } catch (error) { + next(error); + } +}); + +router.put("/payment", requireAuth, authorize("settings:manage"), async (req, res, next) => { + try { + const payload = paymentSchema.parse(req.body); + const setting = await prisma.setting.upsert({ + where: { key: "payment" }, + update: { value: payload }, + create: { key: "payment", type: "PAYMENT", value: payload, is_encrypted: true } + }); + res.json(setting.value); + } catch (error) { + next(error); + } +}); + +router.get("/backup", requireAuth, authorize("settings:read"), async (_req, res, next) => { + try { + const setting = await prisma.setting.findUnique({ where: { key: "backup" } }); + res.json(setting?.value ?? {}); + } catch (error) { + next(error); + } +}); + +router.put("/backup", requireAuth, authorize("settings:manage"), async (req, res, next) => { + try { + const payload = backupSchema.parse(req.body); + const setting = await prisma.setting.upsert({ + where: { key: "backup" }, + update: { value: payload }, + create: { key: "backup", type: "GENERAL", value: payload, is_encrypted: false } + }); + res.json(setting.value); + } catch (error) { + next(error); + } +}); + +router.get("/console-proxy", requireAuth, authorize("settings:read"), async (_req, res, next) => { + try { + const setting = await prisma.setting.findUnique({ where: { key: "console_proxy" } }); + res.json( + setting?.value ?? { + mode: "cluster", + cluster: {}, + nodes: {} + } + ); + } catch (error) { + next(error); + } +}); + +router.put("/console-proxy", requireAuth, authorize("settings:manage"), async (req, res, next) => { + try { + const payload = consoleProxySchema.parse(req.body); + const setting = await prisma.setting.upsert({ + where: { key: "console_proxy" }, + update: { value: payload }, + create: { key: "console_proxy", type: "PROXMOX", value: payload, is_encrypted: false } + }); + res.json(setting.value); + } catch (error) { + next(error); + } +}); + +router.get("/scheduler", requireAuth, authorize("settings:read"), async (_req, res, next) => { + try { + const setting = await prisma.setting.findUnique({ where: { key: "scheduler" } }); + const defaults = schedulerDefaults(); + const persisted = + setting?.value && typeof setting.value === "object" && !Array.isArray(setting.value) + ? (setting.value as Record) + : {}; + const config = { + ...defaults, + ...persisted + }; + return res.json({ + config, + runtime: getSchedulerRuntimeSnapshot() + }); + } catch (error) { + return next(error); + } +}); + +router.put("/scheduler", requireAuth, authorize("settings:manage"), async (req, res, next) => { + try { + const payload = schedulerSchema.parse(req.body); + const setting = await prisma.setting.upsert({ + where: { key: "scheduler" }, + update: { value: payload }, + create: { key: "scheduler", type: "GENERAL", value: payload, is_encrypted: false } + }); + + const runtime = await reconfigureSchedulers(payload); + return res.json({ + config: setting.value, + runtime + }); + } catch (error) { + return next(error); + } +}); + +router.get("/operations-policy", requireAuth, authorize("settings:read"), async (_req, res, next) => { + try { + const policy = await getOperationsPolicy(); + return res.json(policy); + } catch (error) { + return next(error); + } +}); + +router.put("/operations-policy", requireAuth, authorize("settings:manage"), async (req, res, next) => { + try { + const payload = operationsPolicySchema.parse(req.body); + await prisma.setting.upsert({ + where: { key: "operations_policy" }, + update: { value: payload }, + create: { key: "operations_policy", type: "GENERAL", value: payload, is_encrypted: false } + }); + + const policy = await getOperationsPolicy(); + return res.json(policy); + } catch (error) { + return next(error); + } +}); + +router.get("/notifications", requireAuth, authorize("settings:read"), async (_req, res, next) => { + try { + const setting = await prisma.setting.findUnique({ where: { key: "notifications" } }); + return res.json( + setting?.value ?? { + email_alerts: true, + backup_alerts: true, + billing_alerts: true, + vm_alerts: true, + monitoring_webhook_url: "", + alert_webhook_url: "", + email_gateway_url: "", + notification_email_webhook: "", + ops_email: "" + } + ); + } catch (error) { + return next(error); + } +}); + +router.put("/notifications", requireAuth, authorize("settings:manage"), async (req, res, next) => { + try { + const payload = notificationsSchema.parse(req.body); + const setting = await prisma.setting.upsert({ + where: { key: "notifications" }, + update: { value: payload }, + create: { key: "notifications", type: "EMAIL", value: payload, is_encrypted: false } + }); + return res.json(setting.value); + } catch (error) { + return next(error); + } +}); + +export default router; diff --git a/backend/src/services/audit.service.ts b/backend/src/services/audit.service.ts new file mode 100644 index 0000000..1b07dd6 --- /dev/null +++ b/backend/src/services/audit.service.ts @@ -0,0 +1,30 @@ +import type { Prisma, ResourceType, Severity } from "@prisma/client"; +import { prisma } from "../lib/prisma"; + +type AuditInput = { + action: string; + resource_type: ResourceType; + resource_id?: string; + resource_name?: string; + actor_email: string; + actor_role?: string; + severity?: Severity; + details?: Prisma.InputJsonValue; + ip_address?: string; +}; + +export async function logAudit(input: AuditInput) { + await prisma.auditLog.create({ + data: { + action: input.action, + resource_type: input.resource_type, + resource_id: input.resource_id, + resource_name: input.resource_name, + actor_email: input.actor_email, + actor_role: input.actor_role, + severity: input.severity ?? "INFO", + details: input.details, + ip_address: input.ip_address + } + }); +} diff --git a/backend/src/services/backup.service.ts b/backend/src/services/backup.service.ts new file mode 100644 index 0000000..9d2c823 --- /dev/null +++ b/backend/src/services/backup.service.ts @@ -0,0 +1,1086 @@ +import { + BackupRestoreMode, + BackupRestoreStatus, + BackupSchedule, + BackupSource, + BackupStatus, + BackupType, + Prisma, + SnapshotFrequency +} from "@prisma/client"; +import { prisma } from "../lib/prisma"; +import { HttpError } from "../lib/http-error"; + +type PolicyShape = { + id?: string; + max_files: number; + max_total_size_mb: number; + max_protected_files: number; + allow_file_restore: boolean; + allow_cross_vm_restore: boolean; + allow_pbs_restore: boolean; +}; + +const DEFAULT_POLICY: PolicyShape = { + max_files: 20, + max_total_size_mb: 102400, + max_protected_files: 5, + allow_file_restore: true, + allow_cross_vm_restore: true, + allow_pbs_restore: true +}; + +function toPositiveInt(value: number, fallback: number) { + if (!Number.isFinite(value)) return fallback; + const rounded = Math.floor(value); + return rounded > 0 ? rounded : fallback; +} + +function toScheduleForSnapshot(frequency: SnapshotFrequency): BackupSchedule { + if (frequency === SnapshotFrequency.HOURLY) return BackupSchedule.DAILY; + if (frequency === SnapshotFrequency.DAILY) return BackupSchedule.DAILY; + return BackupSchedule.WEEKLY; +} + +function nextBackupRunDate(schedule: BackupSchedule, fromDate = new Date()) { + const now = new Date(fromDate); + if (schedule === BackupSchedule.DAILY) return new Date(now.getTime() + 24 * 60 * 60 * 1000); + if (schedule === BackupSchedule.WEEKLY) return new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); + if (schedule === BackupSchedule.MONTHLY) return new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000); + return null; +} + +function nextSnapshotRunAt(job: { + frequency: SnapshotFrequency; + interval: number; + day_of_week: number | null; + hour_utc: number; + minute_utc: number; +}, fromDate = new Date()) { + const now = new Date(fromDate); + const interval = Math.max(1, job.interval); + + if (job.frequency === SnapshotFrequency.HOURLY) { + const candidate = new Date(now); + candidate.setUTCSeconds(0, 0); + candidate.setUTCMinutes(job.minute_utc); + if (candidate <= now) { + candidate.setUTCHours(candidate.getUTCHours() + interval); + } + return candidate; + } + + if (job.frequency === SnapshotFrequency.DAILY) { + const candidate = new Date(now); + candidate.setUTCSeconds(0, 0); + candidate.setUTCHours(job.hour_utc, job.minute_utc, 0, 0); + if (candidate <= now) { + candidate.setUTCDate(candidate.getUTCDate() + interval); + } + return candidate; + } + + const dayOfWeek = job.day_of_week ?? 0; + const candidate = new Date(now); + candidate.setUTCSeconds(0, 0); + candidate.setUTCHours(job.hour_utc, job.minute_utc, 0, 0); + + const currentDow = candidate.getUTCDay(); + const deltaDays = (dayOfWeek - currentDow + 7) % 7; + candidate.setUTCDate(candidate.getUTCDate() + deltaDays); + + if (candidate <= now) { + candidate.setUTCDate(candidate.getUTCDate() + interval * 7); + } + + return candidate; +} + +function estimateBackupSizeMb(input: { requestedSizeMb?: number; diskGb: number; type: BackupType }) { + if (typeof input.requestedSizeMb === "number" && Number.isFinite(input.requestedSizeMb) && input.requestedSizeMb > 0) { + return input.requestedSizeMb; + } + + const base = Math.max(256, input.diskGb * 1024 * 0.35); + if (input.type === BackupType.SNAPSHOT) { + return Math.max(128, base * 0.4); + } + if (input.type === BackupType.INCREMENTAL) { + return Math.max(128, base * 0.6); + } + return base; +} + +async function effectiveBackupPolicy(tenantId: string, billingPlanId?: string | null): Promise { + const policies = await prisma.backupPolicy.findMany({ + where: { + OR: [ + { tenant_id: tenantId, billing_plan_id: billingPlanId ?? null }, + { tenant_id: tenantId, billing_plan_id: null }, + { tenant_id: null, billing_plan_id: billingPlanId ?? null }, + { tenant_id: null, billing_plan_id: null } + ] + } + }); + + const score = (item: (typeof policies)[number]) => { + let value = 0; + if (item.tenant_id === tenantId) value += 8; + if (item.billing_plan_id && billingPlanId && item.billing_plan_id === billingPlanId) value += 4; + if (!item.tenant_id) value += 1; + if (!item.billing_plan_id) value += 1; + return value; + }; + + const selected = policies.sort((a, b) => score(b) - score(a))[0]; + if (!selected) return DEFAULT_POLICY; + + return { + id: selected.id, + max_files: selected.max_files, + max_total_size_mb: selected.max_total_size_mb, + max_protected_files: selected.max_protected_files, + allow_file_restore: selected.allow_file_restore, + allow_cross_vm_restore: selected.allow_cross_vm_restore, + allow_pbs_restore: selected.allow_pbs_restore + }; +} + +async function tenantBackupUsage(tenantId: string) { + const backups = await prisma.backup.findMany({ + where: { + OR: [{ tenant_id: tenantId }, { vm: { tenant_id: tenantId } }], + status: { in: [BackupStatus.PENDING, BackupStatus.RUNNING, BackupStatus.COMPLETED] } + }, + select: { + id: true, + size_mb: true, + is_protected: true + } + }); + + const totalSize = backups.reduce((sum, item) => sum + (item.size_mb ?? 0), 0); + const protectedCount = backups.filter((item) => item.is_protected).length; + + return { + count: backups.length, + totalSizeMb: totalSize, + protectedCount + }; +} + +async function enforceBackupPolicyLimits(input: { + tenantId: string; + billingPlanId?: string | null; + predictedSizeMb: number; + protected: boolean; +}) { + const [policy, usage] = await Promise.all([ + effectiveBackupPolicy(input.tenantId, input.billingPlanId), + tenantBackupUsage(input.tenantId) + ]); + + if (usage.count >= policy.max_files) { + throw new HttpError( + 400, + `Backup limit reached (${policy.max_files} files). Remove old backups or raise policy limits.`, + "BACKUP_LIMIT_FILES" + ); + } + + if (usage.totalSizeMb + input.predictedSizeMb > policy.max_total_size_mb) { + throw new HttpError( + 400, + `Backup storage limit exceeded (${policy.max_total_size_mb} MB).`, + "BACKUP_LIMIT_SIZE" + ); + } + + if (input.protected && usage.protectedCount >= policy.max_protected_files) { + throw new HttpError( + 400, + `Protected backup limit reached (${policy.max_protected_files}).`, + "BACKUP_LIMIT_PROTECTED" + ); + } + + return policy; +} + +export async function listBackups(input: { + tenantId?: string; + vmId?: string; + status?: BackupStatus; + limit?: number; + offset?: number; +}) { + const where: Prisma.BackupWhereInput = {}; + if (input.vmId) where.vm_id = input.vmId; + if (input.status) where.status = input.status; + if (input.tenantId) { + where.OR = [{ tenant_id: input.tenantId }, { vm: { tenant_id: input.tenantId } }]; + } + + const limit = Math.min(Math.max(input.limit ?? 50, 1), 200); + const offset = Math.max(input.offset ?? 0, 0); + + const [data, total] = await Promise.all([ + prisma.backup.findMany({ + where, + include: { + vm: { + select: { + id: true, + name: true, + node: true, + tenant_id: true + } + }, + snapshot_job: { + select: { + id: true, + name: true + } + } + }, + orderBy: [{ is_protected: "desc" }, { created_at: "desc" }], + take: limit, + skip: offset + }), + prisma.backup.count({ where }) + ]); + + return { + data, + meta: { + total, + limit, + offset + } + }; +} + +export async function createBackup(input: { + vmId: string; + type?: BackupType; + source?: BackupSource; + schedule?: BackupSchedule; + retentionDays?: number; + storage?: string; + routeKey?: string; + isProtected?: boolean; + notes?: string; + requestedSizeMb?: number; + createdBy?: string; +}) { + const vm = await prisma.virtualMachine.findUnique({ + where: { id: input.vmId }, + include: { + billing_plan: { + select: { + id: true + } + } + } + }); + + if (!vm) { + throw new HttpError(404, "VM not found", "VM_NOT_FOUND"); + } + + const type = input.type ?? BackupType.FULL; + const source = input.source ?? BackupSource.LOCAL; + const schedule = input.schedule ?? BackupSchedule.MANUAL; + const retentionDays = toPositiveInt(input.retentionDays ?? 7, 7); + const sizeMb = estimateBackupSizeMb({ + requestedSizeMb: input.requestedSizeMb, + diskGb: vm.disk_gb, + type + }); + const keepProtected = Boolean(input.isProtected); + + await enforceBackupPolicyLimits({ + tenantId: vm.tenant_id, + billingPlanId: vm.billing_plan_id, + predictedSizeMb: sizeMb, + protected: keepProtected + }); + + const now = new Date(); + const expiresAt = new Date(now.getTime() + retentionDays * 24 * 60 * 60 * 1000); + const nextRunAt = schedule === BackupSchedule.MANUAL ? null : nextBackupRunDate(schedule, now); + + const backup = await prisma.backup.create({ + data: { + vm_id: vm.id, + vm_name: vm.name, + tenant_id: vm.tenant_id, + node: vm.node, + status: schedule === BackupSchedule.MANUAL ? BackupStatus.COMPLETED : BackupStatus.PENDING, + type, + source, + size_mb: sizeMb, + storage: input.storage ?? (source === BackupSource.PBS ? "pbs-datastore" : "local-lvm"), + backup_path: source === BackupSource.PBS ? null : `backup/${vm.node}/${vm.vmid}/${Date.now()}`, + pbs_snapshot_id: source === BackupSource.PBS ? `pbs/${vm.node}/${vm.vmid}/${Date.now()}` : null, + route_key: input.routeKey, + is_protected: keepProtected, + restore_enabled: true, + total_files: Math.max(1, Math.floor(sizeMb / 64)), + schedule, + retention_days: retentionDays, + started_at: schedule === BackupSchedule.MANUAL ? now : null, + completed_at: schedule === BackupSchedule.MANUAL ? now : null, + next_run_at: nextRunAt, + expires_at: expiresAt, + notes: input.notes + } + }); + + return backup; +} + +export async function toggleBackupProtection(backupId: string, protect: boolean) { + const backup = await prisma.backup.findUnique({ + where: { id: backupId }, + include: { + vm: { + select: { + tenant_id: true, + billing_plan_id: true + } + } + } + }); + + if (!backup) { + throw new HttpError(404, "Backup not found", "BACKUP_NOT_FOUND"); + } + + if (protect) { + await enforceBackupPolicyLimits({ + tenantId: backup.vm.tenant_id, + billingPlanId: backup.vm.billing_plan_id, + predictedSizeMb: 0, + protected: true + }); + } + + return prisma.backup.update({ + where: { id: backup.id }, + data: { + is_protected: protect + } + }); +} + +export async function deleteBackup(backupId: string, force = false) { + const backup = await prisma.backup.findUnique({ where: { id: backupId } }); + if (!backup) { + throw new HttpError(404, "Backup not found", "BACKUP_NOT_FOUND"); + } + + if (backup.is_protected && !force) { + throw new HttpError(400, "Protected backup cannot be deleted without force", "BACKUP_PROTECTED"); + } + + return prisma.backup.delete({ + where: { id: backup.id } + }); +} + +async function executeRestoreTask(taskId: string) { + const task = await prisma.backupRestoreTask.findUnique({ + where: { id: taskId }, + include: { + backup: true, + source_vm: true, + target_vm: true + } + }); + + if (!task) { + throw new HttpError(404, "Restore task not found", "RESTORE_TASK_NOT_FOUND"); + } + + await prisma.backupRestoreTask.update({ + where: { id: task.id }, + data: { + status: BackupRestoreStatus.RUNNING, + started_at: new Date() + } + }); + + let resultMessage = "Restore completed"; + if (task.mode === BackupRestoreMode.FILES || task.mode === BackupRestoreMode.SINGLE_FILE) { + const files = Array.isArray(task.requested_files) ? task.requested_files.length : 0; + resultMessage = `File restore completed (${files} item${files === 1 ? "" : "s"})`; + } + + if (task.pbs_enabled) { + resultMessage = `${resultMessage} via PBS`; + } + + return prisma.backupRestoreTask.update({ + where: { id: task.id }, + data: { + status: BackupRestoreStatus.COMPLETED, + completed_at: new Date(), + result_message: resultMessage + }, + include: { + backup: true, + source_vm: true, + target_vm: true + } + }); +} + +export async function createRestoreTask(input: { + backupId: string; + targetVmId?: string; + mode: BackupRestoreMode; + requestedFiles?: string[]; + pbsEnabled?: boolean; + createdBy?: string; + runImmediately?: boolean; +}) { + const backup = await prisma.backup.findUnique({ + where: { id: input.backupId }, + include: { + vm: { + select: { + id: true, + tenant_id: true, + billing_plan_id: true + } + } + } + }); + + if (!backup) { + throw new HttpError(404, "Backup not found", "BACKUP_NOT_FOUND"); + } + + if (!backup.restore_enabled) { + throw new HttpError(400, "Restore is disabled for this backup", "RESTORE_DISABLED"); + } + + const targetVmId = input.targetVmId ?? backup.vm_id; + const targetVm = await prisma.virtualMachine.findUnique({ + where: { id: targetVmId }, + select: { + id: true, + tenant_id: true + } + }); + + if (!targetVm) { + throw new HttpError(404, "Target VM not found", "TARGET_VM_NOT_FOUND"); + } + + if (targetVm.tenant_id !== backup.vm.tenant_id) { + throw new HttpError(403, "Cross-tenant restore is not allowed", "CROSS_TENANT_RESTORE_DENIED"); + } + + const policy = await effectiveBackupPolicy(backup.vm.tenant_id, backup.vm.billing_plan_id); + const isCrossVm = targetVmId !== backup.vm_id; + if (isCrossVm && !policy.allow_cross_vm_restore) { + throw new HttpError(400, "Cross-VM restore is disabled by policy", "CROSS_VM_RESTORE_DISABLED"); + } + + if (input.mode !== BackupRestoreMode.FULL_VM && !policy.allow_file_restore) { + throw new HttpError(400, "File-level restore is disabled by policy", "FILE_RESTORE_DISABLED"); + } + + const pbsEnabled = Boolean(input.pbsEnabled); + if (pbsEnabled && !policy.allow_pbs_restore) { + throw new HttpError(400, "PBS restore is disabled by policy", "PBS_RESTORE_DISABLED"); + } + if (pbsEnabled && backup.source !== BackupSource.PBS) { + throw new HttpError(400, "PBS restore requires a PBS backup source", "PBS_SOURCE_REQUIRED"); + } + + const requestedFiles = input.requestedFiles ?? []; + if (input.mode === BackupRestoreMode.SINGLE_FILE && requestedFiles.length !== 1) { + throw new HttpError(400, "SINGLE_FILE restore requires exactly one file path", "INVALID_RESTORE_FILES"); + } + + if ((input.mode === BackupRestoreMode.FILES || input.mode === BackupRestoreMode.SINGLE_FILE) && requestedFiles.length === 0) { + throw new HttpError(400, "Requested files are required for file-level restore", "INVALID_RESTORE_FILES"); + } + + const task = await prisma.backupRestoreTask.create({ + data: { + backup_id: backup.id, + source_vm_id: backup.vm_id, + target_vm_id: targetVmId, + mode: input.mode, + requested_files: requestedFiles, + pbs_enabled: pbsEnabled, + created_by: input.createdBy + } + }); + + if (input.runImmediately === false) { + return prisma.backupRestoreTask.findUnique({ + where: { id: task.id }, + include: { + backup: true, + source_vm: true, + target_vm: true + } + }); + } + + return executeRestoreTask(task.id); +} + +export async function runRestoreTaskNow(taskId: string) { + const task = await prisma.backupRestoreTask.findUnique({ where: { id: taskId } }); + if (!task) { + throw new HttpError(404, "Restore task not found", "RESTORE_TASK_NOT_FOUND"); + } + if (task.status === BackupRestoreStatus.COMPLETED) { + return prisma.backupRestoreTask.findUnique({ + where: { id: task.id }, + include: { backup: true, source_vm: true, target_vm: true } + }); + } + return executeRestoreTask(task.id); +} + +export async function listRestoreTasks(input: { + tenantId?: string; + status?: BackupRestoreStatus; + limit?: number; + offset?: number; +}) { + const where: Prisma.BackupRestoreTaskWhereInput = {}; + if (input.status) where.status = input.status; + if (input.tenantId) { + where.source_vm = { tenant_id: input.tenantId }; + } + + const limit = Math.min(Math.max(input.limit ?? 50, 1), 200); + const offset = Math.max(input.offset ?? 0, 0); + + const [data, total] = await Promise.all([ + prisma.backupRestoreTask.findMany({ + where, + include: { + backup: true, + source_vm: { select: { id: true, name: true, tenant_id: true } }, + target_vm: { select: { id: true, name: true, tenant_id: true } } + }, + orderBy: { created_at: "desc" }, + take: limit, + skip: offset + }), + prisma.backupRestoreTask.count({ where }) + ]); + + return { + data, + meta: { + total, + limit, + offset + } + }; +} + +async function enforceSnapshotRetention(jobId: string, retention: number) { + const backups = await prisma.backup.findMany({ + where: { + snapshot_job_id: jobId, + type: BackupType.SNAPSHOT + }, + orderBy: { + created_at: "desc" + } + }); + + const removable = backups.filter((item, index) => index >= retention && !item.is_protected); + if (removable.length === 0) { + return 0; + } + + await prisma.backup.deleteMany({ + where: { + id: { + in: removable.map((item) => item.id) + } + } + }); + + return removable.length; +} + +async function createSnapshotBackupFromJob(job: { + id: string; + vm_id: string; + vm: { + id: string; + name: string; + node: string; + tenant_id: string; + billing_plan_id: string | null; + disk_gb: number; + vmid: number; + }; + retention: number; + frequency: SnapshotFrequency; +}) { + const predictedSizeMb = estimateBackupSizeMb({ + diskGb: job.vm.disk_gb, + type: BackupType.SNAPSHOT + }); + + await enforceBackupPolicyLimits({ + tenantId: job.vm.tenant_id, + billingPlanId: job.vm.billing_plan_id, + predictedSizeMb, + protected: false + }); + + const now = new Date(); + return prisma.backup.create({ + data: { + vm_id: job.vm_id, + vm_name: job.vm.name, + tenant_id: job.vm.tenant_id, + node: job.vm.node, + status: BackupStatus.COMPLETED, + type: BackupType.SNAPSHOT, + source: BackupSource.LOCAL, + size_mb: predictedSizeMb, + storage: "local-lvm", + backup_path: `snapshot/${job.vm.node}/${job.vm.vmid}/${Date.now()}`, + schedule: toScheduleForSnapshot(job.frequency), + retention_days: Math.max(1, job.retention), + snapshot_job_id: job.id, + started_at: now, + completed_at: now, + expires_at: new Date(now.getTime() + Math.max(1, job.retention) * 24 * 60 * 60 * 1000), + notes: "Created by snapshot policy scheduler" + } + }); +} + +export async function listSnapshotJobs(input: { tenantId?: string }) { + const where: Prisma.SnapshotJobWhereInput = {}; + if (input.tenantId) { + where.vm = { tenant_id: input.tenantId }; + } + + return prisma.snapshotJob.findMany({ + where, + include: { + vm: { + select: { + id: true, + name: true, + tenant_id: true, + node: true + } + } + }, + orderBy: [{ enabled: "desc" }, { next_run_at: "asc" }, { created_at: "desc" }] + }); +} + +export async function createSnapshotJob(input: { + vmId: string; + name: string; + frequency: SnapshotFrequency; + interval?: number; + dayOfWeek?: number; + hourUtc?: number; + minuteUtc?: number; + retention?: number; + enabled?: boolean; + createdBy?: string; +}) { + const vm = await prisma.virtualMachine.findUnique({ + where: { id: input.vmId }, + select: { id: true } + }); + if (!vm) { + throw new HttpError(404, "VM not found", "VM_NOT_FOUND"); + } + + const interval = toPositiveInt(input.interval ?? 1, 1); + const retention = toPositiveInt(input.retention ?? 7, 7); + const hourUtc = Math.max(0, Math.min(23, Math.floor(input.hourUtc ?? 2))); + const minuteUtc = Math.max(0, Math.min(59, Math.floor(input.minuteUtc ?? 0))); + + if (input.frequency === SnapshotFrequency.WEEKLY) { + const day = input.dayOfWeek ?? 0; + if (day < 0 || day > 6) { + throw new HttpError(400, "dayOfWeek must be between 0 and 6", "INVALID_DAY_OF_WEEK"); + } + } + + const nextRun = nextSnapshotRunAt({ + frequency: input.frequency, + interval, + day_of_week: input.dayOfWeek ?? null, + hour_utc: hourUtc, + minute_utc: minuteUtc + }); + + return prisma.snapshotJob.create({ + data: { + vm_id: input.vmId, + name: input.name, + frequency: input.frequency, + interval, + day_of_week: input.frequency === SnapshotFrequency.WEEKLY ? input.dayOfWeek ?? 0 : null, + hour_utc: hourUtc, + minute_utc: minuteUtc, + retention, + enabled: input.enabled ?? true, + next_run_at: input.enabled === false ? null : nextRun, + created_by: input.createdBy + }, + include: { + vm: { + select: { + id: true, + name: true, + tenant_id: true, + node: true + } + } + } + }); +} + +export async function updateSnapshotJob( + jobId: string, + input: Partial<{ + name: string; + frequency: SnapshotFrequency; + interval: number; + dayOfWeek: number | null; + hourUtc: number; + minuteUtc: number; + retention: number; + enabled: boolean; + }> +) { + const existing = await prisma.snapshotJob.findUnique({ where: { id: jobId } }); + if (!existing) { + throw new HttpError(404, "Snapshot job not found", "SNAPSHOT_JOB_NOT_FOUND"); + } + + const frequency = input.frequency ?? existing.frequency; + const interval = toPositiveInt(input.interval ?? existing.interval, existing.interval); + const hourUtc = Math.max(0, Math.min(23, Math.floor(input.hourUtc ?? existing.hour_utc))); + const minuteUtc = Math.max(0, Math.min(59, Math.floor(input.minuteUtc ?? existing.minute_utc))); + const retention = toPositiveInt(input.retention ?? existing.retention, existing.retention); + const dayOfWeek = + frequency === SnapshotFrequency.WEEKLY + ? (input.dayOfWeek ?? existing.day_of_week ?? 0) + : null; + + if (frequency === SnapshotFrequency.WEEKLY && (dayOfWeek === null || dayOfWeek < 0 || dayOfWeek > 6)) { + throw new HttpError(400, "dayOfWeek must be between 0 and 6", "INVALID_DAY_OF_WEEK"); + } + + const enabled = input.enabled ?? existing.enabled; + const nextRun = enabled + ? nextSnapshotRunAt({ + frequency, + interval, + day_of_week: dayOfWeek, + hour_utc: hourUtc, + minute_utc: minuteUtc + }) + : null; + + return prisma.snapshotJob.update({ + where: { id: existing.id }, + data: { + name: input.name, + frequency, + interval, + day_of_week: dayOfWeek, + hour_utc: hourUtc, + minute_utc: minuteUtc, + retention, + enabled, + next_run_at: nextRun + }, + include: { + vm: { + select: { + id: true, + name: true, + tenant_id: true, + node: true + } + } + } + }); +} + +export async function deleteSnapshotJob(jobId: string) { + return prisma.snapshotJob.delete({ where: { id: jobId } }); +} + +export async function runSnapshotJobNow(jobId: string) { + const job = await prisma.snapshotJob.findUnique({ + where: { id: jobId }, + include: { + vm: { + select: { + id: true, + name: true, + node: true, + vmid: true, + disk_gb: true, + tenant_id: true, + billing_plan_id: true + } + } + } + }); + + if (!job) { + throw new HttpError(404, "Snapshot job not found", "SNAPSHOT_JOB_NOT_FOUND"); + } + + const backup = await createSnapshotBackupFromJob({ + id: job.id, + vm_id: job.vm_id, + vm: job.vm, + retention: job.retention, + frequency: job.frequency + }); + + const pruned = await enforceSnapshotRetention(job.id, Math.max(1, job.retention)); + const now = new Date(); + const nextRun = job.enabled + ? nextSnapshotRunAt({ + frequency: job.frequency, + interval: job.interval, + day_of_week: job.day_of_week, + hour_utc: job.hour_utc, + minute_utc: job.minute_utc + }, now) + : null; + + await prisma.snapshotJob.update({ + where: { id: job.id }, + data: { + last_run_at: now, + next_run_at: nextRun + } + }); + + return { backup, pruned }; +} + +export async function processDueSnapshotJobs() { + const now = new Date(); + const dueJobs = await prisma.snapshotJob.findMany({ + where: { + enabled: true, + next_run_at: { + lte: now + } + }, + include: { + vm: { + select: { + id: true, + name: true, + node: true, + vmid: true, + disk_gb: true, + tenant_id: true, + billing_plan_id: true + } + } + }, + orderBy: { + next_run_at: "asc" + }, + take: 100 + }); + + let executed = 0; + let failed = 0; + let pruned = 0; + let skipped = 0; + + for (const job of dueJobs) { + const nextRun = nextSnapshotRunAt({ + frequency: job.frequency, + interval: job.interval, + day_of_week: job.day_of_week, + hour_utc: job.hour_utc, + minute_utc: job.minute_utc + }, now); + + const claim = await prisma.snapshotJob.updateMany({ + where: { + id: job.id, + enabled: true, + next_run_at: { + lte: now + } + }, + data: { + last_run_at: now, + next_run_at: nextRun + } + }); + + if (claim.count === 0) { + skipped += 1; + continue; + } + + try { + await createSnapshotBackupFromJob({ + id: job.id, + vm_id: job.vm_id, + vm: job.vm, + retention: job.retention, + frequency: job.frequency + }); + executed += 1; + pruned += await enforceSnapshotRetention(job.id, Math.max(1, job.retention)); + } catch { + failed += 1; + } + } + + return { + scanned: dueJobs.length, + executed, + failed, + pruned, + skipped + }; +} + +export async function processPendingBackups() { + const now = new Date(); + const pending = await prisma.backup.findMany({ + where: { + status: BackupStatus.PENDING + }, + orderBy: { + created_at: "asc" + }, + take: 200 + }); + + let completed = 0; + let skipped = 0; + for (const backup of pending) { + const claim = await prisma.backup.updateMany({ + where: { + id: backup.id, + status: BackupStatus.PENDING + }, + data: { + status: BackupStatus.RUNNING, + started_at: backup.started_at ?? now + } + }); + + if (claim.count === 0) { + skipped += 1; + continue; + } + + const retentionDays = Math.max(1, backup.retention_days); + await prisma.backup.update({ + where: { id: backup.id }, + data: { + status: BackupStatus.COMPLETED, + completed_at: now, + expires_at: new Date(now.getTime() + retentionDays * 24 * 60 * 60 * 1000), + next_run_at: backup.schedule === BackupSchedule.MANUAL ? null : nextBackupRunDate(backup.schedule, now), + pbs_snapshot_id: + backup.source === BackupSource.PBS + ? backup.pbs_snapshot_id ?? `pbs/${backup.node ?? "node"}/${backup.vm_id}/${Date.now()}` + : backup.pbs_snapshot_id + } + }); + completed += 1; + } + + return { + scanned: pending.length, + completed, + skipped + }; +} + +export async function listBackupPolicies() { + return prisma.backupPolicy.findMany({ + include: { + tenant: { + select: { + id: true, + name: true, + slug: true + } + }, + billing_plan: { + select: { + id: true, + name: true, + slug: true + } + } + }, + orderBy: [{ tenant_id: "asc" }, { billing_plan_id: "asc" }, { created_at: "desc" }] + }); +} + +export async function upsertBackupPolicy(input: { + policyId?: string; + tenantId?: string; + billingPlanId?: string; + maxFiles?: number; + maxTotalSizeMb?: number; + maxProtectedFiles?: number; + allowFileRestore?: boolean; + allowCrossVmRestore?: boolean; + allowPbsRestore?: boolean; +}) { + const basePayload = { + tenant_id: input.tenantId, + billing_plan_id: input.billingPlanId, + max_files: toPositiveInt(input.maxFiles ?? DEFAULT_POLICY.max_files, DEFAULT_POLICY.max_files), + max_total_size_mb: + typeof input.maxTotalSizeMb === "number" && Number.isFinite(input.maxTotalSizeMb) && input.maxTotalSizeMb > 0 + ? input.maxTotalSizeMb + : DEFAULT_POLICY.max_total_size_mb, + max_protected_files: toPositiveInt( + input.maxProtectedFiles ?? DEFAULT_POLICY.max_protected_files, + DEFAULT_POLICY.max_protected_files + ), + allow_file_restore: input.allowFileRestore ?? DEFAULT_POLICY.allow_file_restore, + allow_cross_vm_restore: input.allowCrossVmRestore ?? DEFAULT_POLICY.allow_cross_vm_restore, + allow_pbs_restore: input.allowPbsRestore ?? DEFAULT_POLICY.allow_pbs_restore + }; + + if (input.policyId) { + return prisma.backupPolicy.update({ + where: { id: input.policyId }, + data: basePayload + }); + } + + return prisma.backupPolicy.create({ + data: basePayload + }); +} diff --git a/backend/src/services/billing.service.ts b/backend/src/services/billing.service.ts new file mode 100644 index 0000000..965d371 --- /dev/null +++ b/backend/src/services/billing.service.ts @@ -0,0 +1,245 @@ +import { Prisma, InvoiceStatus, PaymentProvider } from "@prisma/client"; +import { prisma } from "../lib/prisma"; +import { logAudit } from "./audit.service"; + +function startOfHour(date = new Date()) { + const d = new Date(date); + d.setMinutes(0, 0, 0); + return d; +} + +export async function meterHourlyUsage(actorEmail = "system@proxpanel.local") { + const periodStart = startOfHour(); + const periodEnd = new Date(periodStart.getTime() + 60 * 60 * 1000); + + const vms = await prisma.virtualMachine.findMany({ + where: { status: "RUNNING" }, + include: { + tenant: true, + billing_plan: true + } + }); + + let created = 0; + for (const vm of vms) { + if (!vm.billing_plan) continue; + + const exists = await prisma.usageRecord.findFirst({ + where: { + vm_id: vm.id, + period_start: periodStart, + period_end: periodEnd + } + }); + if (exists) continue; + + const hoursUsed = new Prisma.Decimal(1); + const pricePerHour = vm.billing_plan.price_hourly; + const totalCost = pricePerHour.mul(hoursUsed); + + await prisma.usageRecord.create({ + data: { + vm_id: vm.id, + vm_name: vm.name, + tenant_id: vm.tenant_id, + tenant_name: vm.tenant.name, + billing_plan_id: vm.billing_plan_id ?? undefined, + plan_name: vm.billing_plan.name, + hours_used: hoursUsed, + price_per_hour: pricePerHour, + currency: vm.billing_plan.currency, + total_cost: totalCost, + period_start: periodStart, + period_end: periodEnd, + cpu_hours: new Prisma.Decimal(vm.cpu_cores), + ram_gb_hours: new Prisma.Decimal(vm.ram_mb / 1024), + disk_gb_hours: new Prisma.Decimal(vm.disk_gb) + } + }); + created += 1; + } + + await logAudit({ + action: "hourly_usage_metering", + resource_type: "BILLING", + actor_email: actorEmail, + severity: "INFO", + details: { period_start: periodStart.toISOString(), created_records: created } + }); + + return { created_records: created, period_start: periodStart.toISOString() }; +} + +function invoiceNumber() { + const rand = Math.floor(1000 + Math.random() * 9000); + return `INV-${Date.now()}-${rand}`; +} + +export async function generateInvoicesFromUnbilledUsage(actorEmail = "system@proxpanel.local") { + const usageRecords = await prisma.usageRecord.findMany({ + where: { billed: false }, + orderBy: { created_at: "asc" } + }); + if (usageRecords.length === 0) { + return { generated: 0, invoices: [] as Array<{ id: string; tenant_id: string; amount: string }> }; + } + + const grouped = new Map(); + for (const item of usageRecords) { + const key = `${item.tenant_id}:${item.currency}`; + const current = grouped.get(key) ?? []; + current.push(item); + grouped.set(key, current); + } + + const createdInvoices: Array<{ id: string; tenant_id: string; amount: string }> = []; + + for (const [key, records] of grouped.entries()) { + const [tenantId] = key.split(":"); + const amount = records.reduce((sum, record) => sum.add(record.total_cost), new Prisma.Decimal(0)); + const tenant = await prisma.tenant.findUniqueOrThrow({ where: { id: tenantId } }); + + const invoice = await prisma.invoice.create({ + data: { + invoice_number: invoiceNumber(), + tenant_id: tenantId, + tenant_name: tenant.name, + status: InvoiceStatus.PENDING, + amount, + currency: records[0].currency, + due_date: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + payment_provider: tenant.payment_provider, + line_items: records.map((r) => ({ + usage_record_id: r.id, + vm_name: r.vm_name, + period_start: r.period_start, + period_end: r.period_end, + hours_used: r.hours_used.toString(), + unit_price: r.price_per_hour.toString(), + amount: r.total_cost.toString() + })) + } + }); + + await prisma.usageRecord.updateMany({ + where: { id: { in: records.map((r) => r.id) } }, + data: { + billed: true, + invoice_id: invoice.id + } + }); + + createdInvoices.push({ + id: invoice.id, + tenant_id: invoice.tenant_id, + amount: invoice.amount.toString() + }); + } + + await logAudit({ + action: "invoice_batch_generation", + resource_type: "BILLING", + actor_email: actorEmail, + severity: "INFO", + details: { + generated_invoices: createdInvoices.length + } + }); + + return { generated: createdInvoices.length, invoices: createdInvoices }; +} + +export async function markInvoicePaid( + invoiceId: string, + paymentProvider: PaymentProvider, + paymentReference: string, + actorEmail: string +) { + const invoice = await prisma.invoice.update({ + where: { id: invoiceId }, + data: { + status: "PAID", + paid_date: new Date(), + payment_provider: paymentProvider, + payment_reference: paymentReference + } + }); + + await logAudit({ + action: "invoice_mark_paid", + resource_type: "INVOICE", + resource_id: invoice.id, + resource_name: invoice.invoice_number, + actor_email: actorEmail, + severity: "INFO", + details: { payment_provider: paymentProvider, payment_reference: paymentReference } + }); + + return invoice; +} + +export async function updateOverdueInvoices(actorEmail = "system@proxpanel.local") { + const result = await prisma.invoice.updateMany({ + where: { + status: "PENDING", + due_date: { lt: new Date() } + }, + data: { status: "OVERDUE" } + }); + + if (result.count > 0) { + await logAudit({ + action: "invoice_overdue_scan", + resource_type: "BILLING", + actor_email: actorEmail, + severity: "WARNING", + details: { marked_overdue: result.count } + }); + } + + return result.count; +} + +function nextRunDate(schedule: "DAILY" | "WEEKLY" | "MONTHLY" | "MANUAL") { + const now = new Date(); + if (schedule === "DAILY") return new Date(now.getTime() + 24 * 60 * 60 * 1000); + if (schedule === "WEEKLY") return new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); + if (schedule === "MONTHLY") return new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000); + return null; +} + +export async function processBackupSchedule(actorEmail = "system@proxpanel.local") { + const now = new Date(); + const dueBackups = await prisma.backup.findMany({ + where: { + schedule: { not: "MANUAL" }, + next_run_at: { lte: now }, + status: { in: ["PENDING", "COMPLETED", "FAILED"] } + } + }); + + for (const backup of dueBackups) { + const nextRunAt = nextRunDate(backup.schedule); + await prisma.backup.update({ + where: { id: backup.id }, + data: { + status: "PENDING", + started_at: null, + completed_at: null, + next_run_at: nextRunAt + } + }); + } + + if (dueBackups.length > 0) { + await logAudit({ + action: "backup_scheduler_run", + resource_type: "BACKUP", + actor_email: actorEmail, + severity: "INFO", + details: { queued_backups: dueBackups.length } + }); + } + + return dueBackups.length; +} diff --git a/backend/src/services/monitoring.service.ts b/backend/src/services/monitoring.service.ts new file mode 100644 index 0000000..72939ad --- /dev/null +++ b/backend/src/services/monitoring.service.ts @@ -0,0 +1,1454 @@ +import axios from "axios"; +import { + AlertChannel, + AlertDispatchStatus, + HealthCheckStatus, + HealthCheckTargetType, + HealthCheckType, + MonitoringAlertStatus, + Prisma, + Severity, + VmStatus +} from "@prisma/client"; +import { prisma } from "../lib/prisma"; +import { HttpError } from "../lib/http-error"; +import { toPrismaJsonValue } from "../lib/prisma-json"; +import { vmRuntimeStats } from "./proxmox.service"; + +type HealthSnapshot = { + cpu_usage: number; + ram_usage: number; + disk_usage: number; + disk_io_read: number; + disk_io_write: number; + network_in: number; + network_out: number; + latency_ms: number | null; + health_hint: "healthy" | "degraded" | "offline"; + subject_name: string; +}; + +type CreateHealthCheckInput = { + name: string; + description?: string; + target_type: HealthCheckTargetType; + check_type?: HealthCheckType; + tenant_id?: string; + vm_id?: string; + node_id?: string; + cpu_warn_pct?: number; + cpu_critical_pct?: number; + ram_warn_pct?: number; + ram_critical_pct?: number; + disk_warn_pct?: number; + disk_critical_pct?: number; + disk_io_read_warn?: number; + disk_io_read_critical?: number; + disk_io_write_warn?: number; + disk_io_write_critical?: number; + network_in_warn?: number; + network_in_critical?: number; + network_out_warn?: number; + network_out_critical?: number; + latency_warn_ms?: number; + latency_critical_ms?: number; + schedule_minutes?: number; + enabled?: boolean; + metadata?: Record; + created_by?: string; +}; + +type CreateAlertRuleInput = { + name: string; + description?: string; + tenant_id?: string; + vm_id?: string; + node_id?: string; + cpu_threshold_pct?: number; + ram_threshold_pct?: number; + disk_threshold_pct?: number; + disk_io_read_threshold?: number; + disk_io_write_threshold?: number; + network_in_threshold?: number; + network_out_threshold?: number; + consecutive_breaches?: number; + evaluation_window_minutes?: number; + severity?: Severity; + channels?: AlertChannel[]; + enabled?: boolean; + metadata?: Record; + created_by?: string; +}; + +function addMinutes(date: Date, minutes: number) { + const out = new Date(date); + out.setMinutes(out.getMinutes() + minutes); + return out; +} + +function toPercent(value: number, total: number) { + if (total <= 0) return 0; + return Number(((value / total) * 100).toFixed(2)); +} + +function numberOrNull(value: number | null | undefined) { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function parseChannels(raw: unknown): AlertChannel[] { + if (!Array.isArray(raw)) return [AlertChannel.IN_APP]; + + const channels = raw + .map((item) => (typeof item === "string" ? item.toUpperCase() : "")) + .filter((item): item is AlertChannel => Object.values(AlertChannel).includes(item as AlertChannel)); + + return channels.length > 0 ? channels : [AlertChannel.IN_APP]; +} + +async function resolveHealthSnapshot(check: { + target_type: HealthCheckTargetType; + vm_id: string | null; + node_id: string | null; +}) { + if (check.target_type === HealthCheckTargetType.VM) { + if (!check.vm_id) { + throw new HttpError(400, "vm_id is required for VM health checks", "INVALID_HEALTH_CHECK_TARGET"); + } + + const vm = await prisma.virtualMachine.findUnique({ + where: { id: check.vm_id }, + select: { + id: true, + name: true, + node: true, + vmid: true, + type: true, + status: true, + cpu_usage: true, + ram_usage: true, + disk_usage: true, + network_in: true, + network_out: true + } + }); + + if (!vm) { + throw new HttpError(404, "VM for health check not found", "VM_NOT_FOUND"); + } + + const health_hint = vm.status === VmStatus.ERROR ? "offline" : vm.status === VmStatus.RUNNING ? "healthy" : "degraded"; + const latency = vm.status === VmStatus.RUNNING ? Math.round(8 + vm.cpu_usage * 0.7 + vm.ram_usage * 0.2) : null; + + let diskIoRead = 0; + let diskIoWrite = 0; + try { + const runtime = await vmRuntimeStats(vm.node, vm.vmid, vm.type === "LXC" ? "lxc" : "qemu"); + diskIoRead = runtime.disk_io_read_mbps; + diskIoWrite = runtime.disk_io_write_mbps; + } catch { + diskIoRead = 0; + diskIoWrite = 0; + } + + const snapshot: HealthSnapshot = { + cpu_usage: vm.cpu_usage, + ram_usage: vm.ram_usage, + disk_usage: vm.disk_usage, + disk_io_read: diskIoRead, + disk_io_write: diskIoWrite, + network_in: vm.network_in, + network_out: vm.network_out, + latency_ms: latency, + health_hint, + subject_name: vm.name + }; + + return snapshot; + } + + if (check.target_type === HealthCheckTargetType.NODE) { + if (!check.node_id) { + throw new HttpError(400, "node_id is required for NODE health checks", "INVALID_HEALTH_CHECK_TARGET"); + } + + const node = await prisma.proxmoxNode.findUnique({ + where: { id: check.node_id }, + select: { + id: true, + name: true, + status: true, + is_connected: true, + cpu_usage: true, + ram_total_mb: true, + ram_used_mb: true, + disk_total_gb: true, + disk_used_gb: true + } + }); + + if (!node) { + throw new HttpError(404, "Node for health check not found", "NODE_NOT_FOUND"); + } + + const health_hint = node.status === "OFFLINE" || !node.is_connected ? "offline" : node.status === "MAINTENANCE" ? "degraded" : "healthy"; + const latency = node.is_connected ? Math.round(12 + node.cpu_usage * 0.75) : null; + + const snapshot: HealthSnapshot = { + cpu_usage: node.cpu_usage, + ram_usage: toPercent(node.ram_used_mb, node.ram_total_mb), + disk_usage: toPercent(node.disk_used_gb, node.disk_total_gb), + disk_io_read: 0, + disk_io_write: 0, + network_in: 0, + network_out: 0, + latency_ms: latency, + health_hint, + subject_name: node.name + }; + + return snapshot; + } + + const nodes = await prisma.proxmoxNode.findMany({ + select: { + name: true, + status: true, + is_connected: true, + cpu_usage: true, + ram_total_mb: true, + ram_used_mb: true, + disk_total_gb: true, + disk_used_gb: true + } + }); + + if (nodes.length === 0) { + const snapshot: HealthSnapshot = { + cpu_usage: 0, + ram_usage: 0, + disk_usage: 0, + disk_io_read: 0, + disk_io_write: 0, + network_in: 0, + network_out: 0, + latency_ms: null, + health_hint: "degraded", + subject_name: "Cluster" + }; + return snapshot; + } + + const avgCpu = nodes.reduce((sum, node) => sum + node.cpu_usage, 0) / nodes.length; + const totalRam = nodes.reduce((sum, node) => sum + node.ram_total_mb, 0); + const usedRam = nodes.reduce((sum, node) => sum + node.ram_used_mb, 0); + const totalDisk = nodes.reduce((sum, node) => sum + node.disk_total_gb, 0); + const usedDisk = nodes.reduce((sum, node) => sum + node.disk_used_gb, 0); + const allConnected = nodes.every((node) => node.is_connected && node.status !== "OFFLINE"); + + const snapshot: HealthSnapshot = { + cpu_usage: Number(avgCpu.toFixed(2)), + ram_usage: toPercent(usedRam, totalRam), + disk_usage: toPercent(usedDisk, totalDisk), + disk_io_read: 0, + disk_io_write: 0, + network_in: 0, + network_out: 0, + latency_ms: allConnected ? Math.round(14 + avgCpu * 0.4) : null, + health_hint: allConnected ? "healthy" : "degraded", + subject_name: "Cluster" + }; + + return snapshot; +} + +function evaluateHealthResult( + check: { + check_type: HealthCheckType; + cpu_warn_pct: number | null; + cpu_critical_pct: number | null; + ram_warn_pct: number | null; + ram_critical_pct: number | null; + disk_warn_pct: number | null; + disk_critical_pct: number | null; + disk_io_read_warn: number | null; + disk_io_read_critical: number | null; + disk_io_write_warn: number | null; + disk_io_write_critical: number | null; + network_in_warn: number | null; + network_in_critical: number | null; + network_out_warn: number | null; + network_out_critical: number | null; + latency_warn_ms: number | null; + latency_critical_ms: number | null; + }, + snapshot: HealthSnapshot +) { + let status: HealthCheckStatus = HealthCheckStatus.PASS; + let severity: Severity = Severity.INFO; + const breaches: Array<{ metric: string; level: "warning" | "critical"; value: number; threshold: number }> = []; + + const evaluateMetric = (metric: string, value: number | null, warn?: number | null, critical?: number | null) => { + if (value === null || value === undefined) return; + if (typeof critical === "number" && value >= critical) { + breaches.push({ metric, level: "critical", value, threshold: critical }); + return; + } + if (typeof warn === "number" && value >= warn) { + breaches.push({ metric, level: "warning", value, threshold: warn }); + } + }; + + evaluateMetric("cpu_usage", snapshot.cpu_usage, check.cpu_warn_pct, check.cpu_critical_pct); + evaluateMetric("ram_usage", snapshot.ram_usage, check.ram_warn_pct, check.ram_critical_pct); + evaluateMetric("disk_usage", snapshot.disk_usage, check.disk_warn_pct, check.disk_critical_pct); + evaluateMetric("disk_io_read", snapshot.disk_io_read, check.disk_io_read_warn, check.disk_io_read_critical); + evaluateMetric("disk_io_write", snapshot.disk_io_write, check.disk_io_write_warn, check.disk_io_write_critical); + evaluateMetric("network_in", snapshot.network_in, check.network_in_warn, check.network_in_critical); + evaluateMetric("network_out", snapshot.network_out, check.network_out_warn, check.network_out_critical); + evaluateMetric("latency_ms", snapshot.latency_ms, check.latency_warn_ms, check.latency_critical_ms); + + if (check.check_type === HealthCheckType.CONNECTIVITY) { + if (snapshot.health_hint === "offline") breaches.push({ metric: "connectivity", level: "critical", value: 1, threshold: 1 }); + if (snapshot.health_hint === "degraded") breaches.push({ metric: "connectivity", level: "warning", value: 1, threshold: 1 }); + } + + if (breaches.some((entry) => entry.level === "critical")) { + status = HealthCheckStatus.FAIL; + severity = Severity.CRITICAL; + } else if (breaches.length > 0) { + status = HealthCheckStatus.WARNING; + severity = Severity.WARNING; + } + + let message = `${snapshot.subject_name} healthy`; + if (status !== HealthCheckStatus.PASS) { + const sample = breaches + .slice(0, 3) + .map((entry) => `${entry.metric}: ${entry.value.toFixed(2)} (>= ${entry.threshold.toFixed(2)})`) + .join(", "); + message = `${snapshot.subject_name} threshold breach (${sample})`; + } + + return { status, severity, message, breaches }; +} +export async function createHealthCheckDefinition(input: CreateHealthCheckInput) { + const now = new Date(); + const scheduleMinutes = Math.min(Math.max(input.schedule_minutes ?? 5, 1), 1440); + + return prisma.serverHealthCheck.create({ + data: { + name: input.name, + description: input.description, + target_type: input.target_type, + check_type: input.check_type ?? HealthCheckType.RESOURCE_THRESHOLD, + tenant_id: input.tenant_id, + vm_id: input.vm_id, + node_id: input.node_id, + cpu_warn_pct: numberOrNull(input.cpu_warn_pct), + cpu_critical_pct: numberOrNull(input.cpu_critical_pct), + ram_warn_pct: numberOrNull(input.ram_warn_pct), + ram_critical_pct: numberOrNull(input.ram_critical_pct), + disk_warn_pct: numberOrNull(input.disk_warn_pct), + disk_critical_pct: numberOrNull(input.disk_critical_pct), + disk_io_read_warn: numberOrNull(input.disk_io_read_warn), + disk_io_read_critical: numberOrNull(input.disk_io_read_critical), + disk_io_write_warn: numberOrNull(input.disk_io_write_warn), + disk_io_write_critical: numberOrNull(input.disk_io_write_critical), + network_in_warn: numberOrNull(input.network_in_warn), + network_in_critical: numberOrNull(input.network_in_critical), + network_out_warn: numberOrNull(input.network_out_warn), + network_out_critical: numberOrNull(input.network_out_critical), + latency_warn_ms: input.latency_warn_ms ?? null, + latency_critical_ms: input.latency_critical_ms ?? null, + schedule_minutes: scheduleMinutes, + enabled: input.enabled ?? true, + next_run_at: addMinutes(now, scheduleMinutes), + created_by: input.created_by, + metadata: toPrismaJsonValue(input.metadata ?? {}) + }, + include: { + vm: { select: { id: true, name: true, tenant_id: true } }, + node: { select: { id: true, name: true, hostname: true } } + } + }); +} + +export async function updateHealthCheckDefinition(checkId: string, input: Partial) { + const existing = await prisma.serverHealthCheck.findUnique({ where: { id: checkId } }); + if (!existing) throw new HttpError(404, "Health check not found", "HEALTH_CHECK_NOT_FOUND"); + + const scheduleMinutes = + typeof input.schedule_minutes === "number" + ? Math.min(Math.max(input.schedule_minutes, 1), 1440) + : existing.schedule_minutes; + + return prisma.serverHealthCheck.update({ + where: { id: checkId }, + data: { + name: input.name, + description: input.description, + target_type: input.target_type, + check_type: input.check_type, + tenant_id: input.tenant_id, + vm_id: input.vm_id, + node_id: input.node_id, + cpu_warn_pct: numberOrNull(input.cpu_warn_pct), + cpu_critical_pct: numberOrNull(input.cpu_critical_pct), + ram_warn_pct: numberOrNull(input.ram_warn_pct), + ram_critical_pct: numberOrNull(input.ram_critical_pct), + disk_warn_pct: numberOrNull(input.disk_warn_pct), + disk_critical_pct: numberOrNull(input.disk_critical_pct), + disk_io_read_warn: numberOrNull(input.disk_io_read_warn), + disk_io_read_critical: numberOrNull(input.disk_io_read_critical), + disk_io_write_warn: numberOrNull(input.disk_io_write_warn), + disk_io_write_critical: numberOrNull(input.disk_io_write_critical), + network_in_warn: numberOrNull(input.network_in_warn), + network_in_critical: numberOrNull(input.network_in_critical), + network_out_warn: numberOrNull(input.network_out_warn), + network_out_critical: numberOrNull(input.network_out_critical), + latency_warn_ms: input.latency_warn_ms, + latency_critical_ms: input.latency_critical_ms, + schedule_minutes: scheduleMinutes, + enabled: input.enabled, + next_run_at: input.enabled === false ? null : addMinutes(new Date(), scheduleMinutes), + metadata: input.metadata ? toPrismaJsonValue(input.metadata) : undefined + }, + include: { + vm: { select: { id: true, name: true, tenant_id: true } }, + node: { select: { id: true, name: true, hostname: true } } + } + }); +} + +export async function listHealthChecks(input?: { tenant_id?: string; enabled?: boolean }) { + return prisma.serverHealthCheck.findMany({ + where: { + ...(input?.tenant_id ? { OR: [{ tenant_id: input.tenant_id }, { tenant_id: null }] } : {}), + ...(typeof input?.enabled === "boolean" ? { enabled: input.enabled } : {}) + }, + include: { + vm: { select: { id: true, name: true, tenant_id: true } }, + node: { select: { id: true, name: true, hostname: true } }, + _count: { select: { results: true } }, + results: { + orderBy: { checked_at: "desc" }, + take: 1, + select: { + id: true, + status: true, + severity: true, + message: true, + checked_at: true + } + } + }, + orderBy: [{ enabled: "desc" }, { updated_at: "desc" }] + }); +} + +export async function listHealthCheckResults(checkId: string, limit = 50) { + const safeLimit = Math.min(Math.max(limit, 1), 200); + return prisma.serverHealthCheckResult.findMany({ + where: { check_id: checkId }, + orderBy: { checked_at: "desc" }, + take: safeLimit + }); +} + +export async function runHealthCheckNow( + checkId: string, + options?: { + skip_schedule_update?: boolean; + } +) { + const check = await prisma.serverHealthCheck.findUnique({ where: { id: checkId } }); + if (!check) throw new HttpError(404, "Health check not found", "HEALTH_CHECK_NOT_FOUND"); + + const snapshot = await resolveHealthSnapshot(check); + const evaluation = evaluateHealthResult(check, snapshot); + const now = new Date(); + + return prisma.$transaction(async (tx) => { + const created = await tx.serverHealthCheckResult.create({ + data: { + check_id: check.id, + status: evaluation.status, + severity: evaluation.severity, + message: evaluation.message, + latency_ms: snapshot.latency_ms, + cpu_usage: snapshot.cpu_usage, + ram_usage: snapshot.ram_usage, + disk_usage: snapshot.disk_usage, + disk_io_read: snapshot.disk_io_read, + disk_io_write: snapshot.disk_io_write, + network_in: snapshot.network_in, + network_out: snapshot.network_out, + metadata: toPrismaJsonValue({ + health_hint: snapshot.health_hint, + breaches: evaluation.breaches + }) + } + }); + + if (!options?.skip_schedule_update) { + await tx.serverHealthCheck.update({ + where: { id: check.id }, + data: { + last_run_at: now, + next_run_at: check.enabled ? addMinutes(now, check.schedule_minutes) : null + } + }); + } + + if (evaluation.status === HealthCheckStatus.FAIL) { + await tx.securityEvent.create({ + data: { + event_type: "HEALTH_CHECK_FAILED", + severity: Severity.CRITICAL, + status: "OPEN", + target_vm_id: check.vm_id, + node: check.node_id, + description: evaluation.message, + details: toPrismaJsonValue({ + health_check_id: check.id, + health_check_name: check.name, + result_id: created.id + }) + } + }); + } + + return created; + }); +} + +export async function processDueHealthChecks() { + const now = new Date(); + const dueChecks = await prisma.serverHealthCheck.findMany({ + where: { + enabled: true, + OR: [{ next_run_at: { lte: now } }, { next_run_at: null }] + }, + orderBy: [{ next_run_at: "asc" }, { created_at: "asc" }], + take: 100 + }); + + let executed = 0; + let failed = 0; + let skipped = 0; + + for (const check of dueChecks) { + const nextRun = addMinutes(now, check.schedule_minutes); + const claim = await prisma.serverHealthCheck.updateMany({ + where: { + id: check.id, + enabled: true, + OR: [{ next_run_at: { lte: now } }, { next_run_at: null }] + }, + data: { + last_run_at: now, + next_run_at: nextRun + } + }); + + if (claim.count === 0) { + skipped += 1; + continue; + } + + try { + await runHealthCheckNow(check.id, { skip_schedule_update: true }); + executed += 1; + } catch { + failed += 1; + } + } + + return { scanned: dueChecks.length, executed, failed, skipped }; +} + +function summarizeRuleScope(rule: { + tenant_id: string | null; + vm: { id: string; name: string } | null; + node: { id: string; name: string } | null; +}) { + if (rule.vm) return `VM ${rule.vm.name}`; + if (rule.node) return `Node ${rule.node.name}`; + if (rule.tenant_id) return "Tenant scope"; + return "Cluster scope"; +} + +async function resolveRuleSnapshot(rule: { + tenant_id: string | null; + vm_id: string | null; + node_id: string | null; +}) { + if (rule.vm_id) { + const vm = await prisma.virtualMachine.findUnique({ + where: { id: rule.vm_id }, + select: { + id: true, + name: true, + node: true, + vmid: true, + type: true, + cpu_usage: true, + ram_usage: true, + disk_usage: true, + network_in: true, + network_out: true, + tenant_id: true + } + }); + if (!vm) throw new HttpError(404, "VM for alert rule not found", "VM_NOT_FOUND"); + + let diskIoRead = 0; + let diskIoWrite = 0; + try { + const runtime = await vmRuntimeStats(vm.node, vm.vmid, vm.type === "LXC" ? "lxc" : "qemu"); + diskIoRead = runtime.disk_io_read_mbps; + diskIoWrite = runtime.disk_io_write_mbps; + } catch { + diskIoRead = 0; + diskIoWrite = 0; + } + + return { + cpu_usage: vm.cpu_usage, + ram_usage: vm.ram_usage, + disk_usage: vm.disk_usage, + disk_io_read: diskIoRead, + disk_io_write: diskIoWrite, + network_in: vm.network_in, + network_out: vm.network_out, + subject_name: vm.name, + tenant_id: vm.tenant_id, + vm_id: vm.id, + node_id: null as string | null + }; + } + + if (rule.node_id) { + const node = await prisma.proxmoxNode.findUnique({ + where: { id: rule.node_id }, + select: { + id: true, + name: true, + cpu_usage: true, + ram_total_mb: true, + ram_used_mb: true, + disk_total_gb: true, + disk_used_gb: true + } + }); + if (!node) throw new HttpError(404, "Node for alert rule not found", "NODE_NOT_FOUND"); + return { + cpu_usage: node.cpu_usage, + ram_usage: toPercent(node.ram_used_mb, node.ram_total_mb), + disk_usage: toPercent(node.disk_used_gb, node.disk_total_gb), + disk_io_read: 0, + disk_io_write: 0, + network_in: 0, + network_out: 0, + subject_name: node.name, + tenant_id: null as string | null, + vm_id: null as string | null, + node_id: node.id + }; + } + const vmWhere: Prisma.VirtualMachineWhereInput = rule.tenant_id ? { tenant_id: rule.tenant_id } : {}; + const [vms, nodes] = await Promise.all([ + prisma.virtualMachine.findMany({ + where: vmWhere, + select: { cpu_usage: true, ram_usage: true, disk_usage: true, network_in: true, network_out: true } + }), + prisma.proxmoxNode.findMany({ + select: { cpu_usage: true, ram_total_mb: true, ram_used_mb: true, disk_total_gb: true, disk_used_gb: true } + }) + ]); + + const vmCount = vms.length || 1; + const avgCpu = vms.reduce((sum, vm) => sum + vm.cpu_usage, 0) / vmCount; + const avgRam = vms.reduce((sum, vm) => sum + vm.ram_usage, 0) / vmCount; + const avgDisk = vms.reduce((sum, vm) => sum + vm.disk_usage, 0) / vmCount; + const networkIn = vms.reduce((sum, vm) => sum + vm.network_in, 0); + const networkOut = vms.reduce((sum, vm) => sum + vm.network_out, 0); + + const fallbackRam = + nodes.length > 0 + ? toPercent( + nodes.reduce((sum, node) => sum + node.ram_used_mb, 0), + nodes.reduce((sum, node) => sum + node.ram_total_mb, 0) + ) + : avgRam; + const fallbackDisk = + nodes.length > 0 + ? toPercent( + nodes.reduce((sum, node) => sum + node.disk_used_gb, 0), + nodes.reduce((sum, node) => sum + node.disk_total_gb, 0) + ) + : avgDisk; + + return { + cpu_usage: Number(avgCpu.toFixed(2)), + ram_usage: Number(fallbackRam.toFixed(2)), + disk_usage: Number(fallbackDisk.toFixed(2)), + disk_io_read: 0, + disk_io_write: 0, + network_in: Number(networkIn.toFixed(2)), + network_out: Number(networkOut.toFixed(2)), + subject_name: rule.tenant_id ? "Tenant Aggregate" : "Cluster Aggregate", + tenant_id: rule.tenant_id, + vm_id: null as string | null, + node_id: null as string | null + }; +} + +function evaluateAlertRuleBreaches( + rule: { + cpu_threshold_pct: number | null; + ram_threshold_pct: number | null; + disk_threshold_pct: number | null; + disk_io_read_threshold: number | null; + disk_io_write_threshold: number | null; + network_in_threshold: number | null; + network_out_threshold: number | null; + }, + snapshot: { + cpu_usage: number; + ram_usage: number; + disk_usage: number; + disk_io_read: number; + disk_io_write: number; + network_in: number; + network_out: number; + } +) { + const breaches: Array<{ metric_key: string; value: number; threshold: number }> = []; + + const evaluate = (metricKey: string, value: number, threshold?: number | null) => { + if (typeof threshold !== "number") return; + if (value >= threshold) breaches.push({ metric_key: metricKey, value, threshold }); + }; + + evaluate("cpu_usage", snapshot.cpu_usage, rule.cpu_threshold_pct); + evaluate("ram_usage", snapshot.ram_usage, rule.ram_threshold_pct); + evaluate("disk_usage", snapshot.disk_usage, rule.disk_threshold_pct); + evaluate("disk_io_read", snapshot.disk_io_read, rule.disk_io_read_threshold); + evaluate("disk_io_write", snapshot.disk_io_write, rule.disk_io_write_threshold); + evaluate("network_in", snapshot.network_in, rule.network_in_threshold); + evaluate("network_out", snapshot.network_out, rule.network_out_threshold); + + return breaches.sort((a, b) => b.value / b.threshold - a.value / a.threshold); +} + +async function dispatchAlertNotifications(input: { + alert_event_id: string; + channels: AlertChannel[]; + tenant_id?: string | null; + metadata?: Prisma.JsonValue; +}) { + const alertEvent = await prisma.monitoringAlertEvent.findUnique({ + where: { id: input.alert_event_id }, + include: { + rule: { select: { id: true, name: true } }, + vm: { select: { id: true, name: true } }, + node: { select: { id: true, name: true } } + } + }); + + if (!alertEvent) { + throw new HttpError(404, "Alert event not found for notification dispatch", "ALERT_EVENT_NOT_FOUND"); + } + + let destinationEmail: string | null = null; + if (input.tenant_id) { + const tenant = await prisma.tenant.findUnique({ where: { id: input.tenant_id }, select: { owner_email: true } }); + destinationEmail = tenant?.owner_email ?? null; + } + + const metadata = typeof input.metadata === "object" && input.metadata ? (input.metadata as Record) : {}; + const settings = await prisma.setting.findMany({ + where: { + key: { + in: ["notifications", "notification", "email", "alerting"] + } + }, + select: { + key: true, + value: true + } + }); + + const flattenedSettings = settings.reduce>((acc, setting) => { + if (!setting.value || typeof setting.value !== "object" || Array.isArray(setting.value)) return acc; + const value = setting.value as Record; + for (const [key, entry] of Object.entries(value)) { + if (typeof entry !== "undefined") { + acc[key] = entry; + } + } + return acc; + }, {}); + + const asString = (value: unknown) => (typeof value === "string" && value.trim().length > 0 ? value.trim() : null); + + const webhookDestination = + asString(metadata.webhook_url) ?? + asString(metadata.alert_webhook_url) ?? + asString(flattenedSettings.monitoring_webhook_url) ?? + asString(flattenedSettings.alert_webhook_url) ?? + asString(flattenedSettings.webhook_url); + + const emailGatewayUrl = + asString(metadata.email_webhook_url) ?? + asString(flattenedSettings.email_webhook_url) ?? + asString(flattenedSettings.email_gateway_url) ?? + asString(flattenedSettings.notification_email_webhook); + + const defaultOpsEmail = asString(flattenedSettings.ops_email) ?? "ops@proxpanel.local"; + const emailDestination = destinationEmail ?? defaultOpsEmail; + + const payload = { + event_id: alertEvent.id, + rule_id: alertEvent.rule_id, + rule_name: alertEvent.rule?.name ?? null, + title: alertEvent.title, + message: alertEvent.message, + severity: alertEvent.severity, + status: alertEvent.status, + metric_key: alertEvent.metric_key, + trigger_value: alertEvent.trigger_value, + threshold_value: alertEvent.threshold_value, + breach_count: alertEvent.breach_count, + tenant_id: alertEvent.tenant_id, + vm_id: alertEvent.vm_id, + vm_name: alertEvent.vm?.name ?? null, + node_id: alertEvent.node_id, + node_name: alertEvent.node?.name ?? null, + created_at: alertEvent.created_at.toISOString() + }; + + const notifications: Array<{ + alert_event_id: string; + channel: AlertChannel; + destination: string | null; + status: AlertDispatchStatus; + provider_message: string; + sent_at: Date | null; + }> = []; + + for (const channel of input.channels) { + if (channel === AlertChannel.IN_APP) { + notifications.push({ + alert_event_id: input.alert_event_id, + channel, + destination: null, + status: AlertDispatchStatus.SENT, + provider_message: "In-app notification recorded", + sent_at: new Date() + }); + continue; + } + + if (channel === AlertChannel.WEBHOOK) { + if (!webhookDestination) { + notifications.push({ + alert_event_id: input.alert_event_id, + channel, + destination: null, + status: AlertDispatchStatus.SKIPPED, + provider_message: "Missing webhook destination", + sent_at: null + }); + continue; + } + + try { + const response = await axios.post( + webhookDestination, + { type: "monitoring.alert", channel: "WEBHOOK", payload }, + { timeout: 10_000 } + ); + notifications.push({ + alert_event_id: input.alert_event_id, + channel, + destination: webhookDestination, + status: AlertDispatchStatus.SENT, + provider_message: `HTTP ${response.status}`, + sent_at: new Date() + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Webhook dispatch failed"; + notifications.push({ + alert_event_id: input.alert_event_id, + channel, + destination: webhookDestination, + status: AlertDispatchStatus.FAILED, + provider_message: message.slice(0, 240), + sent_at: null + }); + } + continue; + } + + if (!emailGatewayUrl) { + notifications.push({ + alert_event_id: input.alert_event_id, + channel, + destination: emailDestination, + status: AlertDispatchStatus.SKIPPED, + provider_message: "Missing email gateway URL", + sent_at: null + }); + continue; + } + + try { + const response = await axios.post( + emailGatewayUrl, + { + type: "monitoring.alert.email", + to: emailDestination, + subject: `[${alertEvent.severity}] ${alertEvent.title}`, + message: alertEvent.message ?? "", + payload + }, + { timeout: 10_000 } + ); + notifications.push({ + alert_event_id: input.alert_event_id, + channel, + destination: emailDestination, + status: AlertDispatchStatus.SENT, + provider_message: `HTTP ${response.status}`, + sent_at: new Date() + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Email dispatch failed"; + notifications.push({ + alert_event_id: input.alert_event_id, + channel, + destination: emailDestination, + status: AlertDispatchStatus.FAILED, + provider_message: message.slice(0, 240), + sent_at: null + }); + } + } + + if (notifications.length > 0) { + await prisma.monitoringAlertNotification.createMany({ data: notifications }); + } +} + +export async function createAlertRule(input: CreateAlertRuleInput) { + return prisma.monitoringAlertRule.create({ + data: { + name: input.name, + description: input.description, + tenant_id: input.tenant_id, + vm_id: input.vm_id, + node_id: input.node_id, + cpu_threshold_pct: numberOrNull(input.cpu_threshold_pct), + ram_threshold_pct: numberOrNull(input.ram_threshold_pct), + disk_threshold_pct: numberOrNull(input.disk_threshold_pct), + disk_io_read_threshold: numberOrNull(input.disk_io_read_threshold), + disk_io_write_threshold: numberOrNull(input.disk_io_write_threshold), + network_in_threshold: numberOrNull(input.network_in_threshold), + network_out_threshold: numberOrNull(input.network_out_threshold), + consecutive_breaches: Math.max(1, input.consecutive_breaches ?? 1), + evaluation_window_minutes: Math.min(Math.max(input.evaluation_window_minutes ?? 15, 1), 1440), + severity: input.severity ?? Severity.WARNING, + channels: toPrismaJsonValue(input.channels ?? [AlertChannel.IN_APP]), + enabled: input.enabled ?? true, + metadata: toPrismaJsonValue(input.metadata ?? {}), + created_by: input.created_by + }, + include: { + vm: { select: { id: true, name: true, tenant_id: true } }, + node: { select: { id: true, name: true, hostname: true } } + } + }); +} + +export async function updateAlertRule(ruleId: string, input: Partial) { + const existing = await prisma.monitoringAlertRule.findUnique({ where: { id: ruleId } }); + if (!existing) throw new HttpError(404, "Alert rule not found", "ALERT_RULE_NOT_FOUND"); + + return prisma.monitoringAlertRule.update({ + where: { id: ruleId }, + data: { + name: input.name, + description: input.description, + tenant_id: input.tenant_id, + vm_id: input.vm_id, + node_id: input.node_id, + cpu_threshold_pct: numberOrNull(input.cpu_threshold_pct), + ram_threshold_pct: numberOrNull(input.ram_threshold_pct), + disk_threshold_pct: numberOrNull(input.disk_threshold_pct), + disk_io_read_threshold: numberOrNull(input.disk_io_read_threshold), + disk_io_write_threshold: numberOrNull(input.disk_io_write_threshold), + network_in_threshold: numberOrNull(input.network_in_threshold), + network_out_threshold: numberOrNull(input.network_out_threshold), + consecutive_breaches: typeof input.consecutive_breaches === "number" ? Math.max(1, input.consecutive_breaches) : undefined, + evaluation_window_minutes: + typeof input.evaluation_window_minutes === "number" + ? Math.min(Math.max(input.evaluation_window_minutes, 1), 1440) + : undefined, + severity: input.severity, + channels: input.channels ? toPrismaJsonValue(input.channels) : undefined, + enabled: input.enabled, + metadata: input.metadata ? toPrismaJsonValue(input.metadata) : undefined + }, + include: { + vm: { select: { id: true, name: true, tenant_id: true } }, + node: { select: { id: true, name: true, hostname: true } } + } + }); +} + +export async function listAlertRules(input?: { tenant_id?: string; enabled?: boolean }) { + return prisma.monitoringAlertRule.findMany({ + where: { + ...(input?.tenant_id ? { OR: [{ tenant_id: input.tenant_id }, { tenant_id: null }] } : {}), + ...(typeof input?.enabled === "boolean" ? { enabled: input.enabled } : {}) + }, + include: { + vm: { select: { id: true, name: true, tenant_id: true } }, + node: { select: { id: true, name: true, hostname: true } }, + _count: { select: { events: true } } + }, + orderBy: [{ enabled: "desc" }, { updated_at: "desc" }] + }); +} + +export async function listAlertEvents(input?: { tenant_id?: string; status?: MonitoringAlertStatus; limit?: number }) { + const limit = Math.min(Math.max(input?.limit ?? 100, 1), 300); + return prisma.monitoringAlertEvent.findMany({ + where: { + ...(input?.tenant_id ? { OR: [{ tenant_id: input.tenant_id }, { tenant_id: null }] } : {}), + ...(input?.status ? { status: input.status } : {}) + }, + include: { + rule: { select: { id: true, name: true, severity: true } }, + vm: { select: { id: true, name: true, tenant_id: true } }, + node: { select: { id: true, name: true, hostname: true } }, + _count: { select: { notifications: true } } + }, + orderBy: [{ status: "asc" }, { created_at: "desc" }], + take: limit + }); +} + +export async function listAlertNotifications(input?: { tenant_id?: string; limit?: number }) { + const limit = Math.min(Math.max(input?.limit ?? 100, 1), 300); + return prisma.monitoringAlertNotification.findMany({ + where: input?.tenant_id + ? { + event: { OR: [{ tenant_id: input.tenant_id }, { tenant_id: null }] } + } + : undefined, + include: { + event: { select: { id: true, title: true, status: true, severity: true, created_at: true } } + }, + orderBy: { created_at: "desc" }, + take: limit + }); +} +export async function evaluateAlertRulesNow(tenantId?: string) { + const rules = await prisma.monitoringAlertRule.findMany({ + where: { + enabled: true, + ...(tenantId ? { OR: [{ tenant_id: tenantId }, { tenant_id: null }] } : {}) + }, + include: { + vm: { select: { id: true, name: true, tenant_id: true } }, + node: { select: { id: true, name: true, hostname: true } } + }, + orderBy: { created_at: "asc" } + }); + + let evaluated = 0; + let triggered = 0; + let resolved = 0; + + for (const rule of rules) { + const snapshot = await resolveRuleSnapshot(rule); + const breaches = evaluateAlertRuleBreaches(rule, snapshot); + + if (breaches.length === 0) { + const resolveResult = await prisma.monitoringAlertEvent.updateMany({ + where: { + rule_id: rule.id, + status: { in: [MonitoringAlertStatus.OPEN, MonitoringAlertStatus.ACKNOWLEDGED] } + }, + data: { + status: MonitoringAlertStatus.RESOLVED, + resolved_at: new Date() + } + }); + resolved += resolveResult.count; + await prisma.monitoringAlertRule.update({ where: { id: rule.id }, data: { last_evaluated_at: new Date() } }); + evaluated += 1; + continue; + } + + const topBreach = breaches[0]; + const existingOpen = await prisma.monitoringAlertEvent.findFirst({ + where: { + rule_id: rule.id, + status: MonitoringAlertStatus.OPEN, + metric_key: topBreach.metric_key + }, + orderBy: { created_at: "desc" } + }); + + let eventId = existingOpen?.id; + let breachCount = existingOpen?.breach_count ?? 0; + + if (existingOpen) { + breachCount += 1; + await prisma.monitoringAlertEvent.update({ + where: { id: existingOpen.id }, + data: { + breach_count: breachCount, + trigger_value: topBreach.value, + threshold_value: topBreach.threshold, + message: `${summarizeRuleScope(rule)} breached ${topBreach.metric_key}: ${topBreach.value.toFixed(2)} >= ${topBreach.threshold.toFixed(2)}` + } + }); + } else { + const createdEvent = await prisma.monitoringAlertEvent.create({ + data: { + rule_id: rule.id, + tenant_id: snapshot.tenant_id ?? rule.tenant_id, + vm_id: snapshot.vm_id ?? rule.vm_id, + node_id: snapshot.node_id ?? rule.node_id, + severity: rule.severity, + title: `${rule.name} threshold breach`, + message: `${summarizeRuleScope(rule)} breached ${topBreach.metric_key}: ${topBreach.value.toFixed(2)} >= ${topBreach.threshold.toFixed(2)}`, + metric_key: topBreach.metric_key, + trigger_value: topBreach.value, + threshold_value: topBreach.threshold, + breach_count: 1 + } + }); + eventId = createdEvent.id; + breachCount = 1; + } + + if (eventId && breachCount === Math.max(1, rule.consecutive_breaches)) { + await dispatchAlertNotifications({ + alert_event_id: eventId, + channels: parseChannels(rule.channels as unknown), + tenant_id: snapshot.tenant_id ?? rule.tenant_id, + metadata: rule.metadata + }); + } + + triggered += 1; + evaluated += 1; + + await prisma.monitoringAlertRule.update({ where: { id: rule.id }, data: { last_evaluated_at: new Date() } }); + } + + return { evaluated, triggered, resolved }; +} + +export async function faultyDeploymentInsights(input?: { days?: number; tenant_id?: string }) { + const days = Math.min(Math.max(input?.days ?? 14, 1), 120); + const rangeStart = addMinutes(new Date(), -days * 24 * 60); + const baseWhere: Prisma.OperationTaskWhereInput = { created_at: { gte: rangeStart } }; + if (input?.tenant_id) baseWhere.vm = { tenant_id: input.tenant_id }; + + const [failedTasks, totalTasks, queuedStale, serviceSummary] = await Promise.all([ + prisma.operationTask.findMany({ + where: { ...baseWhere, status: "FAILED" }, + select: { id: true, task_type: true, node: true, vm_name: true, error_message: true, created_at: true }, + orderBy: { created_at: "desc" }, + take: 200 + }), + prisma.operationTask.count({ where: baseWhere }), + prisma.operationTask.count({ + where: { ...baseWhere, status: "QUEUED", created_at: { lte: addMinutes(new Date(), -15) } } + }), + prisma.provisionedService.groupBy({ + by: ["lifecycle_status"], + _count: { lifecycle_status: true }, + where: input?.tenant_id ? { tenant_id: input.tenant_id } : undefined + }) + ]); + + const byTaskType = failedTasks.reduce>((acc, task) => { + acc[task.task_type] = (acc[task.task_type] ?? 0) + 1; + return acc; + }, {}); + + const byNode = failedTasks.reduce>((acc, task) => { + const key = task.node || "unknown"; + acc[key] = (acc[key] ?? 0) + 1; + return acc; + }, {}); + + const topErrors = Object.entries( + failedTasks.reduce>((acc, task) => { + const key = task.error_message?.trim() || "Unknown operation error"; + acc[key] = (acc[key] ?? 0) + 1; + return acc; + }, {}) + ) + .map(([message, count]) => ({ message, count })) + .sort((a, b) => b.count - a.count) + .slice(0, 6); + + const failureRate = totalTasks > 0 ? Number(((failedTasks.length / totalTasks) * 100).toFixed(2)) : 0; + const serviceLifecycle = serviceSummary.reduce>((acc, item) => { + acc[item.lifecycle_status] = item._count.lifecycle_status; + return acc; + }, {}); + + return { + window_days: days, + failed_task_count: failedTasks.length, + total_task_count: totalTasks, + failure_rate_pct: failureRate, + stale_queued_tasks: queuedStale, + failed_by_task_type: byTaskType, + failed_by_node: byNode, + top_error_messages: topErrors, + service_lifecycle: serviceLifecycle, + recent_failed_tasks: failedTasks.slice(0, 20) + }; +} + +function daysToExhaustion(remaining: number, growthPerDay: number) { + if (growthPerDay <= 0) return null; + return Number((remaining / growthPerDay).toFixed(1)); +} + +function pressureBand(days: number | null) { + if (days === null) return "stable"; + if (days <= 30) return "critical"; + if (days <= 90) return "watch"; + return "healthy"; +} + +export async function clusterResourceForecast(input?: { horizon_days?: number; tenant_id?: string }) { + const horizonDays = Math.min(Math.max(input?.horizon_days ?? 30, 7), 180); + const rangeStart = addMinutes(new Date(), -horizonDays * 24 * 60); + + const [nodes, vms] = await Promise.all([ + prisma.proxmoxNode.findMany({ + select: { + id: true, + name: true, + cpu_cores: true, + cpu_usage: true, + ram_total_mb: true, + ram_used_mb: true, + disk_total_gb: true, + disk_used_gb: true + } + }), + prisma.virtualMachine.findMany({ + where: { + created_at: { gte: rangeStart }, + ...(input?.tenant_id ? { tenant_id: input.tenant_id } : {}) + }, + select: { + cpu_cores: true, + ram_mb: true, + disk_gb: true + } + }) + ]); + + const totalCpuCores = nodes.reduce((sum, node) => sum + node.cpu_cores, 0); + const usedCpuCores = nodes.reduce((sum, node) => sum + (node.cpu_cores * node.cpu_usage) / 100, 0); + const totalRamMb = nodes.reduce((sum, node) => sum + node.ram_total_mb, 0); + const usedRamMb = nodes.reduce((sum, node) => sum + node.ram_used_mb, 0); + const totalDiskGb = nodes.reduce((sum, node) => sum + node.disk_total_gb, 0); + const usedDiskGb = nodes.reduce((sum, node) => sum + node.disk_used_gb, 0); + + const growthCpuPerDay = Number((vms.reduce((sum, vm) => sum + vm.cpu_cores, 0) / horizonDays).toFixed(2)); + const growthRamPerDay = Number((vms.reduce((sum, vm) => sum + vm.ram_mb, 0) / horizonDays).toFixed(2)); + const growthDiskPerDay = Number((vms.reduce((sum, vm) => sum + vm.disk_gb, 0) / horizonDays).toFixed(2)); + + const remainingCpu = Math.max(0, Number((totalCpuCores - usedCpuCores).toFixed(2))); + const remainingRam = Math.max(0, Number((totalRamMb - usedRamMb).toFixed(2))); + const remainingDisk = Math.max(0, Number((totalDiskGb - usedDiskGb).toFixed(2))); + + const cpuDays = daysToExhaustion(remainingCpu, growthCpuPerDay); + const ramDays = daysToExhaustion(remainingRam, growthRamPerDay); + const diskDays = daysToExhaustion(remainingDisk, growthDiskPerDay); + + return { + horizon_days: horizonDays, + totals: { + cpu_cores: totalCpuCores, + ram_mb: totalRamMb, + disk_gb: totalDiskGb + }, + used: { + cpu_cores: Number(usedCpuCores.toFixed(2)), + ram_mb: usedRamMb, + disk_gb: usedDiskGb + }, + remaining: { + cpu_cores: remainingCpu, + ram_mb: remainingRam, + disk_gb: remainingDisk + }, + growth_per_day: { + cpu_cores: growthCpuPerDay, + ram_mb: growthRamPerDay, + disk_gb: growthDiskPerDay + }, + days_to_exhaustion: { + cpu_cores: cpuDays, + ram_mb: ramDays, + disk_gb: diskDays + }, + pressure: { + cpu_cores: pressureBand(cpuDays), + ram_mb: pressureBand(ramDays), + disk_gb: pressureBand(diskDays) + }, + node_breakdown: nodes + .map((node) => ({ + node_id: node.id, + node_name: node.name, + cpu_pressure_pct: Number(node.cpu_usage.toFixed(2)), + ram_pressure_pct: toPercent(node.ram_used_mb, node.ram_total_mb), + disk_pressure_pct: toPercent(node.disk_used_gb, node.disk_total_gb) + })) + .sort((a, b) => b.cpu_pressure_pct - a.cpu_pressure_pct) + }; +} +export async function monitoringOverview(input?: { tenant_id?: string }) { + const now = new Date(); + const dayAgo = addMinutes(now, -24 * 60); + const weekAgo = addMinutes(now, -7 * 24 * 60); + + const [totalChecks, enabledChecks, healthFailures24h, activeAlerts, notifications24h, recentResults, recentAlerts, faultyInsights, forecast] = + await Promise.all([ + prisma.serverHealthCheck.count({ + where: input?.tenant_id ? { OR: [{ tenant_id: input.tenant_id }, { tenant_id: null }] } : undefined + }), + prisma.serverHealthCheck.count({ + where: { + enabled: true, + ...(input?.tenant_id ? { OR: [{ tenant_id: input.tenant_id }, { tenant_id: null }] } : {}) + } + }), + prisma.serverHealthCheckResult.count({ + where: { + status: HealthCheckStatus.FAIL, + checked_at: { gte: dayAgo }, + ...(input?.tenant_id ? { check: { OR: [{ tenant_id: input.tenant_id }, { tenant_id: null }] } } : {}) + } + }), + prisma.monitoringAlertEvent.count({ + where: { + status: MonitoringAlertStatus.OPEN, + ...(input?.tenant_id ? { OR: [{ tenant_id: input.tenant_id }, { tenant_id: null }] } : {}) + } + }), + prisma.monitoringAlertNotification.count({ + where: { + status: AlertDispatchStatus.SENT, + created_at: { gte: dayAgo }, + ...(input?.tenant_id ? { event: { OR: [{ tenant_id: input.tenant_id }, { tenant_id: null }] } } : {}) + } + }), + prisma.serverHealthCheckResult.findMany({ + where: { + checked_at: { gte: weekAgo }, + ...(input?.tenant_id ? { check: { OR: [{ tenant_id: input.tenant_id }, { tenant_id: null }] } } : {}) + }, + select: { + status: true, + checked_at: true + } + }), + prisma.monitoringAlertEvent.findMany({ + where: { + created_at: { gte: weekAgo }, + ...(input?.tenant_id ? { OR: [{ tenant_id: input.tenant_id }, { tenant_id: null }] } : {}) + }, + select: { + severity: true, + created_at: true + } + }), + faultyDeploymentInsights({ tenant_id: input?.tenant_id }), + clusterResourceForecast({ tenant_id: input?.tenant_id }) + ]); + + const dayLabels = Array.from({ length: 7 }, (_, idx) => { + const day = addMinutes(now, -(6 - idx) * 24 * 60); + return day.toISOString().slice(0, 10); + }); + + const healthTrend = dayLabels.map((day) => ({ + day, + pass: 0, + warning: 0, + fail: 0 + })); + + for (const result of recentResults) { + const key = result.checked_at.toISOString().slice(0, 10); + const bucket = healthTrend.find((item) => item.day === key); + if (!bucket) continue; + if (result.status === HealthCheckStatus.PASS) bucket.pass += 1; + if (result.status === HealthCheckStatus.WARNING) bucket.warning += 1; + if (result.status === HealthCheckStatus.FAIL) bucket.fail += 1; + } + + const alertTrend = dayLabels.map((day) => ({ + day, + critical: 0, + error: 0, + warning: 0, + info: 0 + })); + + for (const alert of recentAlerts) { + const key = alert.created_at.toISOString().slice(0, 10); + const bucket = alertTrend.find((item) => item.day === key); + if (!bucket) continue; + if (alert.severity === Severity.CRITICAL) bucket.critical += 1; + if (alert.severity === Severity.ERROR) bucket.error += 1; + if (alert.severity === Severity.WARNING) bucket.warning += 1; + if (alert.severity === Severity.INFO) bucket.info += 1; + } + + const pressure = [forecast.days_to_exhaustion.cpu_cores, forecast.days_to_exhaustion.ram_mb, forecast.days_to_exhaustion.disk_gb] + .filter((value): value is number => typeof value === "number") + .sort((a, b) => a - b); + + const pressureDays = pressure.length > 0 ? pressure[0] : null; + + let overallSeverity: Severity = Severity.INFO; + if (activeAlerts > 0 || healthFailures24h > 0) overallSeverity = Severity.WARNING; + if (pressureDays !== null && pressureDays <= 30) overallSeverity = Severity.CRITICAL; + + return { + generated_at: now.toISOString(), + summary: { + health_checks_total: totalChecks, + health_checks_enabled: enabledChecks, + health_failures_24h: healthFailures24h, + active_alerts: activeAlerts, + notifications_sent_24h: notifications24h, + overall_severity: overallSeverity, + resource_exhaustion_floor_days: pressureDays + }, + health_trend_7d: healthTrend, + alert_trend_7d: alertTrend, + faulty_deployments: faultyInsights, + cluster_forecast: forecast + }; +} diff --git a/backend/src/services/network.service.ts b/backend/src/services/network.service.ts new file mode 100644 index 0000000..e8e6efe --- /dev/null +++ b/backend/src/services/network.service.ts @@ -0,0 +1,1402 @@ +import { isIP } from "node:net"; +import { + IpAllocationStrategy, + IpAddressStatus, + IpAssignmentType, + IpScope, + IpVersion, + Prisma, + PrivateNetworkAttachmentStatus, + PrivateNetworkType, + VmType +} from "@prisma/client"; +import { prisma } from "../lib/prisma"; +import { HttpError } from "../lib/http-error"; +import { toPrismaJsonValue } from "../lib/prisma-json"; +import { reconfigureVmNetwork, updateVmConfiguration } from "./proxmox.service"; + +type ListIpAddressInput = { + status?: IpAddressStatus; + version?: IpVersion; + scope?: IpScope; + nodeHostname?: string; + bridge?: string; + vlanTag?: number; + assignedVmId?: string; + limit?: number; + offset?: number; +}; + +type ImportIpAddressesInput = { + addresses?: string[]; + cidr_blocks?: string[]; + scope?: IpScope; + server?: string; + node_id?: string; + node_hostname?: string; + bridge?: string; + vlan_tag?: number; + sdn_zone?: string; + gateway?: string; + subnet?: string; + tags?: string[]; + metadata?: Record; + imported_by?: string; +}; + +type AssignIpInput = { + vm_id: string; + ip_address_id?: string; + address?: string; + scope?: IpScope; + version?: IpVersion; + assignment_type?: IpAssignmentType; + interface_name?: string; + notes?: string; + actor_email?: string; + metadata?: Record; +}; + +type ReturnIpInput = { + assignment_id?: string; + ip_address_id?: string; +}; + +type CreatePrivateNetworkInput = { + name: string; + slug?: string; + network_type?: PrivateNetworkType; + cidr: string; + gateway?: string; + bridge?: string; + vlan_tag?: number; + sdn_zone?: string; + server?: string; + node_hostname?: string; + metadata?: Record; + created_by?: string; +}; + +type AttachPrivateNetworkInput = { + network_id: string; + vm_id: string; + interface_name?: string; + requested_ip?: string; + actor_email?: string; + metadata?: Record; +}; + +type DetachPrivateNetworkInput = { + attachment_id: string; + actor_email?: string; +}; + +type UpsertTenantQuotaInput = { + tenant_id: string; + ipv4_limit?: number | null; + ipv6_limit?: number | null; + reserved_ipv4?: number; + reserved_ipv6?: number; + burst_allowed?: boolean; + burst_ipv4_limit?: number | null; + burst_ipv6_limit?: number | null; + is_active?: boolean; + metadata?: Record; + created_by?: string; +}; + +type CreateReservedRangeInput = { + name: string; + cidr: string; + scope?: IpScope; + tenant_id?: string; + reason?: string; + node_hostname?: string; + bridge?: string; + vlan_tag?: number; + sdn_zone?: string; + is_active?: boolean; + metadata?: Record; + created_by?: string; +}; + +type UpsertIpPoolPolicyInput = { + policy_id?: string; + name: string; + tenant_id?: string; + scope?: IpScope; + version?: IpVersion; + node_hostname?: string; + bridge?: string; + vlan_tag?: number; + sdn_zone?: string; + allocation_strategy?: IpAllocationStrategy; + enforce_quota?: boolean; + disallow_reserved_use?: boolean; + is_active?: boolean; + priority?: number; + metadata?: Record; + created_by?: string; +}; + +function normalizeSlug(value: string) { + return value + .toLowerCase() + .trim() + .replace(/[^a-z0-9\s-]/g, "") + .replace(/\s+/g, "-") + .replace(/-+/g, "-"); +} + +function parseIpVersion(address: string): IpVersion { + const version = isIP(address); + if (version === 4) return IpVersion.IPV4; + if (version === 6) return IpVersion.IPV6; + throw new HttpError(400, `Invalid IP address: ${address}`, "INVALID_IP_ADDRESS"); +} + +function parseCidr(cidr: string): { address: string; prefix: number; version: IpVersion } { + const trimmed = cidr.trim(); + const [address, rawPrefix] = trimmed.split("/"); + if (!address || !rawPrefix) { + throw new HttpError(400, `Invalid CIDR block: ${cidr}`, "INVALID_CIDR"); + } + + const version = parseIpVersion(address); + const prefix = Number(rawPrefix); + const maxPrefix = version === IpVersion.IPV4 ? 32 : 128; + if (!Number.isInteger(prefix) || prefix < 0 || prefix > maxPrefix) { + throw new HttpError(400, `CIDR prefix out of range: ${cidr}`, "INVALID_CIDR"); + } + + return { address, prefix, version }; +} + +function ipv4ToInt(address: string) { + const parts = address.split(".").map((value) => Number(value)); + if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) { + throw new HttpError(400, `Invalid IPv4 address: ${address}`, "INVALID_IPV4"); + } + + return (((parts[0] << 24) >>> 0) + ((parts[1] << 16) >>> 0) + ((parts[2] << 8) >>> 0) + (parts[3] >>> 0)) >>> 0; +} + +function intToIpv4(value: number) { + return `${(value >>> 24) & 255}.${(value >>> 16) & 255}.${(value >>> 8) & 255}.${value & 255}`; +} + +function expandIpv4Cidr(address: string, prefix: number): string[] { + const hostBits = 32 - prefix; + if (hostBits > 12) { + throw new HttpError(400, `CIDR block too large for bulk import (${address}/${prefix})`, "CIDR_TOO_LARGE"); + } + + const size = 2 ** hostBits; + const mask = prefix === 0 ? 0 : (0xffffffff << hostBits) >>> 0; + const base = ipv4ToInt(address) & mask; + const output: string[] = []; + + for (let index = 0; index < size; index += 1) { + output.push(intToIpv4((base + index) >>> 0)); + } + + return output; +} + +function normalizeIpv6(address: string) { + if (!address.includes(":")) { + throw new HttpError(400, `Invalid IPv6 address: ${address}`, "INVALID_IPV6"); + } + + const [leftPart, rightPart] = address.split("::"); + if (address.split("::").length > 2) { + throw new HttpError(400, `Invalid IPv6 address: ${address}`, "INVALID_IPV6"); + } + + const left = leftPart ? leftPart.split(":").filter((item) => item.length > 0) : []; + const right = rightPart ? rightPart.split(":").filter((item) => item.length > 0) : []; + const missing = 8 - (left.length + right.length); + + if (missing < 0) { + throw new HttpError(400, `Invalid IPv6 address: ${address}`, "INVALID_IPV6"); + } + + const groups = [...left, ...Array.from({ length: missing }, () => "0"), ...right]; + if (groups.length !== 8) { + throw new HttpError(400, `Invalid IPv6 address: ${address}`, "INVALID_IPV6"); + } + + return groups.map((group) => { + const parsed = Number.parseInt(group, 16); + if (!Number.isInteger(parsed) || parsed < 0 || parsed > 0xffff) { + throw new HttpError(400, `Invalid IPv6 address: ${address}`, "INVALID_IPV6"); + } + return parsed; + }); +} + +function ipv6ToBigInt(address: string) { + const groups = normalizeIpv6(address); + let output = 0n; + + for (const group of groups) { + output = (output << 16n) + BigInt(group); + } + + return output; +} + +function ipToBigInt(address: string, version: IpVersion) { + if (version === IpVersion.IPV4) { + return BigInt(ipv4ToInt(address)); + } + + return ipv6ToBigInt(address); +} + +function cidrBounds(cidr: string) { + const parsed = parseCidr(cidr); + const bits = parsed.version === IpVersion.IPV4 ? 32 : 128; + const base = ipToBigInt(parsed.address, parsed.version); + const hostBits = BigInt(bits - parsed.prefix); + const size = 1n << hostBits; + const mask = ((1n << BigInt(bits)) - 1n) ^ (size - 1n); + const network = base & mask; + const broadcast = network + size - 1n; + + return { + ...parsed, + network, + broadcast + }; +} + +function addressInCidr(address: string, cidr: string) { + const bounds = cidrBounds(cidr); + const addressVersion = parseIpVersion(address); + if (addressVersion !== bounds.version) { + return false; + } + + const numeric = ipToBigInt(address, addressVersion); + return numeric >= bounds.network && numeric <= bounds.broadcast; +} + +function samePlacementContext( + candidate: { node_hostname: string | null; bridge: string | null; vlan_tag: number | null; sdn_zone: string | null }, + selector: { node_hostname?: string | null; bridge?: string | null; vlan_tag?: number | null; sdn_zone?: string | null } +) { + if (selector.node_hostname && candidate.node_hostname && selector.node_hostname !== candidate.node_hostname) return false; + if (selector.bridge && candidate.bridge && selector.bridge !== candidate.bridge) return false; + if (typeof selector.vlan_tag === "number" && typeof candidate.vlan_tag === "number" && selector.vlan_tag !== candidate.vlan_tag) return false; + if (selector.sdn_zone && candidate.sdn_zone && selector.sdn_zone !== candidate.sdn_zone) return false; + return true; +} + +async function fetchVmForNetwork(vmId: string) { + const vm = await prisma.virtualMachine.findUnique({ + where: { id: vmId }, + select: { + id: true, + vmid: true, + name: true, + node: true, + type: true, + tenant_id: true, + ip_address: true + } + }); + + if (!vm) { + throw new HttpError(404, "VM not found", "VM_NOT_FOUND"); + } + + return vm; +} + +function vmRuntime(vmType: VmType): "qemu" | "lxc" { + return vmType === VmType.LXC ? "lxc" : "qemu"; +} + +async function resolveAllocationPolicy(input: { + tenant_id: string; + scope?: IpScope; + version?: IpVersion; + node_hostname?: string | null; + bridge?: string | null; + vlan_tag?: number | null; + sdn_zone?: string | null; +}) { + const policies = await prisma.ipPoolPolicy.findMany({ + where: { + is_active: true, + OR: [{ tenant_id: input.tenant_id }, { tenant_id: null }] + }, + orderBy: [{ priority: "asc" }, { created_at: "asc" }] + }); + + const matches = policies.filter((policy) => { + if (policy.tenant_id && policy.tenant_id !== input.tenant_id) return false; + if (policy.scope && input.scope && policy.scope !== input.scope) return false; + if (policy.version && input.version && policy.version !== input.version) return false; + if (policy.node_hostname && input.node_hostname && policy.node_hostname !== input.node_hostname) return false; + if (policy.bridge && input.bridge && policy.bridge !== input.bridge) return false; + if (typeof policy.vlan_tag === "number" && typeof input.vlan_tag === "number" && policy.vlan_tag !== input.vlan_tag) return false; + if (policy.sdn_zone && input.sdn_zone && policy.sdn_zone !== input.sdn_zone) return false; + return true; + }); + + const scored = matches + .map((policy) => { + let score = 0; + if (policy.tenant_id) score += 100; + if (policy.scope) score += 10; + if (policy.version) score += 10; + if (policy.node_hostname) score += 4; + if (policy.bridge) score += 3; + if (typeof policy.vlan_tag === "number") score += 2; + if (policy.sdn_zone) score += 1; + score -= policy.priority / 1000; + return { policy, score }; + }) + .sort((a, b) => b.score - a.score); + + return scored[0]?.policy ?? null; +} + +async function enforceTenantQuota(input: { tenant_id: string; version: IpVersion; policyEnforced: boolean }) { + if (!input.policyEnforced) { + return; + } + + const quota = await prisma.tenantIpQuota.findUnique({ + where: { tenant_id: input.tenant_id } + }); + + if (!quota || !quota.is_active) { + return; + } + + const [assignedV4, assignedV6] = await Promise.all([ + prisma.ipAssignment.count({ + where: { + tenant_id: input.tenant_id, + is_active: true, + ip_address: { + version: IpVersion.IPV4 + } + } + }), + prisma.ipAssignment.count({ + where: { + tenant_id: input.tenant_id, + is_active: true, + ip_address: { + version: IpVersion.IPV6 + } + } + }) + ]); + + const v4Limit = quota.ipv4_limit ?? null; + const v6Limit = quota.ipv6_limit ?? null; + const v4Effective = quota.burst_allowed ? quota.burst_ipv4_limit ?? v4Limit : v4Limit; + const v6Effective = quota.burst_allowed ? quota.burst_ipv6_limit ?? v6Limit : v6Limit; + + if (input.version === IpVersion.IPV4 && typeof v4Effective === "number" && assignedV4 >= v4Effective) { + throw new HttpError(409, "Tenant IPv4 quota exhausted", "TENANT_IPV4_QUOTA_EXCEEDED"); + } + + if (input.version === IpVersion.IPV6 && typeof v6Effective === "number" && assignedV6 >= v6Effective) { + throw new HttpError(409, "Tenant IPv6 quota exhausted", "TENANT_IPV6_QUOTA_EXCEEDED"); + } +} + +function isCandidateReservedForOtherTenant( + candidate: { + address: string; + scope: IpScope; + version: IpVersion; + node_hostname: string | null; + bridge: string | null; + vlan_tag: number | null; + sdn_zone: string | null; + }, + ranges: Array<{ + tenant_id: string | null; + cidr: string; + scope: IpScope; + version: IpVersion; + node_hostname: string | null; + bridge: string | null; + vlan_tag: number | null; + sdn_zone: string | null; + }>, + tenantId: string +) { + const matches = ranges.filter((range) => { + if (range.scope !== candidate.scope) return false; + if (range.version !== candidate.version) return false; + if (!samePlacementContext(candidate, range)) return false; + return addressInCidr(candidate.address, range.cidr); + }); + + if (matches.length === 0) return { blocked: false, tenantReserved: false }; + if (matches.some((range) => range.tenant_id === tenantId)) return { blocked: false, tenantReserved: true }; + return { blocked: true, tenantReserved: false }; +} + +function pickBestFitCandidate( + candidates: Array<{ + id: string; + address: string; + cidr: number; + version: IpVersion; + subnet: string | null; + imported_at: Date; + tenantReserved: boolean; + }>, + strategy: IpAllocationStrategy +) { + if (candidates.length === 0) return null; + + if (strategy === IpAllocationStrategy.FIRST_AVAILABLE) { + return [...candidates].sort((a, b) => a.imported_at.getTime() - b.imported_at.getTime() || a.address.localeCompare(b.address))[0]; + } + + const groupAvailability = candidates.reduce>((acc, item) => { + const key = item.subnet ?? `${item.address}/${item.cidr}`; + acc[key] = (acc[key] ?? 0) + 1; + return acc; + }, {}); + + return [...candidates].sort((a, b) => { + if (a.tenantReserved !== b.tenantReserved) return a.tenantReserved ? -1 : 1; + const aGroup = groupAvailability[a.subnet ?? `${a.address}/${a.cidr}`] ?? 0; + const bGroup = groupAvailability[b.subnet ?? `${b.address}/${b.cidr}`] ?? 0; + if (aGroup !== bGroup) return aGroup - bGroup; + if (a.cidr !== b.cidr) return b.cidr - a.cidr; + return a.imported_at.getTime() - b.imported_at.getTime() || a.address.localeCompare(b.address); + })[0]; +} + +export async function listIpAddresses(input: ListIpAddressInput) { + const where: Prisma.IpAddressPoolWhereInput = {}; + if (input.status) where.status = input.status; + if (input.version) where.version = input.version; + if (input.scope) where.scope = input.scope; + if (input.nodeHostname) where.node_hostname = input.nodeHostname; + if (input.bridge) where.bridge = input.bridge; + if (typeof input.vlanTag === "number") where.vlan_tag = input.vlanTag; + if (input.assignedVmId) where.assigned_vm_id = input.assignedVmId; + + const limit = Math.min(Math.max(input.limit ?? 100, 1), 500); + const offset = Math.max(input.offset ?? 0, 0); + + const [data, total] = await Promise.all([ + prisma.ipAddressPool.findMany({ + where, + include: { + assigned_vm: { + select: { + id: true, + name: true, + tenant_id: true + } + } + }, + orderBy: [{ status: "asc" }, { address: "asc" }], + take: limit, + skip: offset + }), + prisma.ipAddressPool.count({ where }) + ]); + + return { + data, + meta: { + total, + limit, + offset + } + }; +} + +export async function importIpAddresses(input: ImportIpAddressesInput) { + const baseTags = Array.isArray(input.tags) ? input.tags.filter((item) => item.trim().length > 0) : []; + const metadata = input.metadata ? toPrismaJsonValue(input.metadata) : {}; + const directAddresses = Array.isArray(input.addresses) ? input.addresses : []; + const cidrBlocks = Array.isArray(input.cidr_blocks) ? input.cidr_blocks : []; + + const prepared: Array<{ + address: string; + cidr: number; + version: IpVersion; + status: IpAddressStatus; + subnet: string | null; + }> = []; + + for (const raw of directAddresses) { + const address = raw.trim(); + const version = parseIpVersion(address); + prepared.push({ + address, + cidr: version === IpVersion.IPV4 ? 32 : 128, + version, + status: IpAddressStatus.AVAILABLE, + subnet: input.subnet ?? null + }); + } + + for (const block of cidrBlocks) { + const parsed = parseCidr(block); + + if (parsed.version === IpVersion.IPV4) { + const expanded = expandIpv4Cidr(parsed.address, parsed.prefix); + for (const address of expanded) { + prepared.push({ + address, + cidr: parsed.prefix, + version: parsed.version, + status: IpAddressStatus.AVAILABLE, + subnet: block + }); + } + continue; + } + + if (parsed.prefix === 128) { + prepared.push({ + address: parsed.address, + cidr: parsed.prefix, + version: parsed.version, + status: IpAddressStatus.AVAILABLE, + subnet: block + }); + } else { + prepared.push({ + address: parsed.address, + cidr: parsed.prefix, + version: parsed.version, + status: IpAddressStatus.RESERVED, + subnet: block + }); + } + } + + if (prepared.length === 0) { + throw new HttpError(400, "No addresses or CIDR blocks supplied", "EMPTY_IMPORT"); + } + + const uniqueMap = new Map(); + for (const item of prepared) { + const key = `${item.address}/${item.cidr}`; + if (!uniqueMap.has(key)) { + uniqueMap.set(key, item); + } + } + + const values = [...uniqueMap.values()]; + const existing = await prisma.ipAddressPool.findMany({ + where: { + OR: values.map((item) => ({ + address: item.address, + cidr: item.cidr + })) + }, + select: { address: true, cidr: true } + }); + + const existingKey = new Set(existing.map((item) => `${item.address}/${item.cidr}`)); + const toInsert = values.filter((item) => !existingKey.has(`${item.address}/${item.cidr}`)); + + if (toInsert.length > 0) { + await prisma.ipAddressPool.createMany({ + data: toInsert.map((item) => ({ + address: item.address, + cidr: item.cidr, + version: item.version, + scope: input.scope ?? IpScope.PUBLIC, + status: item.status, + gateway: input.gateway, + subnet: item.subnet, + server: input.server, + node_id: input.node_id, + node_hostname: input.node_hostname, + bridge: input.bridge, + vlan_tag: input.vlan_tag, + sdn_zone: input.sdn_zone, + tags: baseTags, + metadata, + imported_by: input.imported_by + })) + }); + } + + return { + imported: toInsert.length, + skipped_existing: values.length - toInsert.length, + total_candidates: values.length + }; +} + +export async function assignIpToVm(input: AssignIpInput) { + const vm = await fetchVmForNetwork(input.vm_id); + const assignmentType = input.assignment_type ?? IpAssignmentType.ADDITIONAL; + const metadata = input.metadata ? toPrismaJsonValue(input.metadata) : {}; + + const resolvedPolicy = await resolveAllocationPolicy({ + tenant_id: vm.tenant_id, + scope: input.scope, + version: input.version, + node_hostname: vm.node, + bridge: null, + vlan_tag: null, + sdn_zone: null + }); + + const where: Prisma.IpAddressPoolWhereInput = { + status: IpAddressStatus.AVAILABLE + }; + + if (input.ip_address_id) where.id = input.ip_address_id; + if (input.address) where.address = input.address; + if (input.scope) where.scope = input.scope; + if (input.version) where.version = input.version; + if (resolvedPolicy?.scope) where.scope = resolvedPolicy.scope; + if (resolvedPolicy?.version) where.version = resolvedPolicy.version; + if (resolvedPolicy?.node_hostname) where.node_hostname = resolvedPolicy.node_hostname; + if (resolvedPolicy?.bridge) where.bridge = resolvedPolicy.bridge; + if (typeof resolvedPolicy?.vlan_tag === "number") where.vlan_tag = resolvedPolicy.vlan_tag; + if (resolvedPolicy?.sdn_zone) where.sdn_zone = resolvedPolicy.sdn_zone; + + const candidates = await prisma.ipAddressPool.findMany({ + where, + select: { + id: true, + address: true, + cidr: true, + version: true, + scope: true, + subnet: true, + node_hostname: true, + bridge: true, + vlan_tag: true, + sdn_zone: true, + imported_at: true + }, + orderBy: [{ imported_at: "asc" }, { address: "asc" }], + take: input.ip_address_id || input.address ? 5 : 3000 + }); + + if (candidates.length === 0) { + throw new HttpError(404, "No available IP address found", "IP_NOT_AVAILABLE"); + } + + const reservedRanges = await prisma.ipReservedRange.findMany({ + where: { + is_active: true, + OR: [{ tenant_id: vm.tenant_id }, { tenant_id: null }] + }, + select: { + tenant_id: true, + cidr: true, + scope: true, + version: true, + node_hostname: true, + bridge: true, + vlan_tag: true, + sdn_zone: true + } + }); + + const candidateFlags = candidates + .map((candidate) => { + const reservation = isCandidateReservedForOtherTenant(candidate, reservedRanges, vm.tenant_id); + return { + ...candidate, + tenantReserved: reservation.tenantReserved, + blocked: reservation.blocked + }; + }) + .filter((candidate) => !(resolvedPolicy?.disallow_reserved_use ?? true ? candidate.blocked : false)); + + if (candidateFlags.length === 0) { + throw new HttpError(409, "All candidate IPs are reserved by policy", "IP_RESERVED_BY_POLICY"); + } + + const strategy = resolvedPolicy?.allocation_strategy ?? IpAllocationStrategy.BEST_FIT; + const picked = pickBestFitCandidate(candidateFlags, strategy); + + if (!picked) { + throw new HttpError(404, "No allocatable IP candidate found", "IP_NOT_AVAILABLE"); + } + + await enforceTenantQuota({ + tenant_id: vm.tenant_id, + version: picked.version, + policyEnforced: resolvedPolicy?.enforce_quota ?? true + }); + + return prisma.$transaction(async (tx) => { + const updatedIp = await tx.ipAddressPool.update({ + where: { id: picked.id }, + data: { + status: IpAddressStatus.ASSIGNED, + assigned_vm_id: vm.id, + assigned_tenant_id: vm.tenant_id, + assigned_at: new Date(), + returned_at: null + } + }); + + const assignment = await tx.ipAssignment.create({ + data: { + ip_address_id: updatedIp.id, + vm_id: vm.id, + tenant_id: vm.tenant_id, + assignment_type: assignmentType, + interface_name: input.interface_name, + notes: input.notes, + metadata, + assigned_by: input.actor_email + }, + include: { + ip_address: true, + vm: { + select: { + id: true, + name: true, + tenant_id: true + } + } + } + }); + + if (assignmentType === IpAssignmentType.PRIMARY) { + await tx.virtualMachine.update({ + where: { id: vm.id }, + data: { + ip_address: updatedIp.address + } + }); + } + + return assignment; + }); +} + +export async function returnAssignedIp(input: ReturnIpInput) { + let assignment = input.assignment_id + ? await prisma.ipAssignment.findUnique({ + where: { id: input.assignment_id }, + include: { + ip_address: true, + vm: { + select: { + id: true, + ip_address: true + } + } + } + }) + : null; + + if (!assignment && input.ip_address_id) { + assignment = await prisma.ipAssignment.findFirst({ + where: { + ip_address_id: input.ip_address_id, + is_active: true + }, + include: { + ip_address: true, + vm: { + select: { + id: true, + ip_address: true + } + } + }, + orderBy: { + assigned_at: "desc" + } + }); + } + + if (!assignment) { + throw new HttpError(404, "Active IP assignment not found", "IP_ASSIGNMENT_NOT_FOUND"); + } + + if (!assignment.is_active) { + return assignment; + } + + return prisma.$transaction(async (tx) => { + const released = await tx.ipAssignment.update({ + where: { id: assignment!.id }, + data: { + is_active: false, + released_at: new Date() + }, + include: { + ip_address: true, + vm: { + select: { + id: true, + ip_address: true + } + } + } + }); + + await tx.ipAddressPool.update({ + where: { id: assignment!.ip_address_id }, + data: { + status: IpAddressStatus.AVAILABLE, + assigned_vm_id: null, + assigned_tenant_id: null, + assigned_at: null, + returned_at: new Date() + } + }); + + if ( + assignment!.assignment_type === IpAssignmentType.PRIMARY && + released.vm.ip_address && + released.vm.ip_address === released.ip_address.address + ) { + await tx.virtualMachine.update({ + where: { id: released.vm.id }, + data: { + ip_address: null + } + }); + } + + return released; + }); +} + +export async function listIpAssignments(params?: { vm_id?: string; tenant_id?: string; active_only?: boolean }) { + const where: Prisma.IpAssignmentWhereInput = {}; + if (params?.vm_id) where.vm_id = params.vm_id; + if (params?.tenant_id) where.tenant_id = params.tenant_id; + if (params?.active_only) where.is_active = true; + + return prisma.ipAssignment.findMany({ + where, + include: { + ip_address: true, + vm: { + select: { + id: true, + name: true, + tenant_id: true, + node: true + } + } + }, + orderBy: [{ is_active: "desc" }, { assigned_at: "desc" }] + }); +} + +export async function subnetUtilizationDashboard(input?: { + scope?: IpScope; + version?: IpVersion; + node_hostname?: string; + bridge?: string; + vlan_tag?: number; + tenant_id?: string; +}) { + const where: Prisma.IpAddressPoolWhereInput = {}; + if (input?.scope) where.scope = input.scope; + if (input?.version) where.version = input.version; + if (input?.node_hostname) where.node_hostname = input.node_hostname; + if (input?.bridge) where.bridge = input.bridge; + if (typeof input?.vlan_tag === "number") where.vlan_tag = input.vlan_tag; + if (input?.tenant_id) { + where.OR = [{ assigned_tenant_id: input.tenant_id }, { status: IpAddressStatus.AVAILABLE }]; + } + + const [ips, quotas, activeAssignments, reservedRanges] = await Promise.all([ + prisma.ipAddressPool.findMany({ + where, + select: { + id: true, + address: true, + cidr: true, + subnet: true, + scope: true, + version: true, + status: true, + assigned_tenant_id: true, + node_hostname: true, + bridge: true, + vlan_tag: true + } + }), + prisma.tenantIpQuota.findMany({ + where: input?.tenant_id ? { tenant_id: input.tenant_id } : undefined, + include: { + tenant: { + select: { + id: true, + name: true + } + } + } + }), + prisma.ipAssignment.findMany({ + where: { + is_active: true, + ...(input?.tenant_id ? { tenant_id: input.tenant_id } : {}) + }, + include: { + ip_address: { + select: { + version: true + } + } + } + }), + prisma.ipReservedRange.findMany({ + where: { + is_active: true, + ...(input?.tenant_id ? { OR: [{ tenant_id: input.tenant_id }, { tenant_id: null }] } : {}) + } + }) + ]); + + const subnetMap = new Map< + string, + { + subnet: string; + scope: IpScope; + version: IpVersion; + node_hostname: string | null; + bridge: string | null; + vlan_tag: number | null; + total: number; + available: number; + assigned: number; + reserved: number; + retired: number; + } + >(); + + for (const ip of ips) { + const subnet = ip.subnet ?? `${ip.address}/${ip.cidr}`; + const key = `${ip.scope}:${ip.version}:${ip.node_hostname ?? "-"}:${ip.bridge ?? "-"}:${ip.vlan_tag ?? "-"}:${subnet}`; + if (!subnetMap.has(key)) { + subnetMap.set(key, { + subnet, + scope: ip.scope, + version: ip.version, + node_hostname: ip.node_hostname, + bridge: ip.bridge, + vlan_tag: ip.vlan_tag, + total: 0, + available: 0, + assigned: 0, + reserved: 0, + retired: 0 + }); + } + + const entry = subnetMap.get(key)!; + entry.total += 1; + if (ip.status === IpAddressStatus.AVAILABLE) entry.available += 1; + if (ip.status === IpAddressStatus.ASSIGNED) entry.assigned += 1; + if (ip.status === IpAddressStatus.RESERVED) entry.reserved += 1; + if (ip.status === IpAddressStatus.RETIRED) entry.retired += 1; + } + + const subnets = [...subnetMap.values()] + .map((item) => { + const utilization_pct = item.total > 0 ? Number(((item.assigned / item.total) * 100).toFixed(2)) : 0; + const pressure_pct = item.total > 0 ? Number((((item.assigned + item.reserved) / item.total) * 100).toFixed(2)) : 0; + return { + ...item, + utilization_pct, + pressure_pct + }; + }) + .sort((a, b) => b.utilization_pct - a.utilization_pct || a.subnet.localeCompare(b.subnet)); + + const assignmentSummary = activeAssignments.reduce( + (acc, item) => { + if (item.ip_address.version === IpVersion.IPV4) acc.ipv4 += 1; + if (item.ip_address.version === IpVersion.IPV6) acc.ipv6 += 1; + return acc; + }, + { total: activeAssignments.length, ipv4: 0, ipv6: 0 } + ); + + return { + subnets, + quota_summary: quotas.map((quota) => ({ + tenant_id: quota.tenant_id, + tenant_name: quota.tenant.name, + ipv4_limit: quota.ipv4_limit, + ipv6_limit: quota.ipv6_limit, + burst_allowed: quota.burst_allowed + })), + assignment_summary: assignmentSummary, + reserved_range_count: reservedRanges.length + }; +} + +export async function upsertTenantIpQuota(input: UpsertTenantQuotaInput) { + return prisma.tenantIpQuota.upsert({ + where: { + tenant_id: input.tenant_id + }, + update: { + ipv4_limit: input.ipv4_limit ?? undefined, + ipv6_limit: input.ipv6_limit ?? undefined, + reserved_ipv4: input.reserved_ipv4, + reserved_ipv6: input.reserved_ipv6, + burst_allowed: input.burst_allowed, + burst_ipv4_limit: input.burst_ipv4_limit ?? undefined, + burst_ipv6_limit: input.burst_ipv6_limit ?? undefined, + is_active: input.is_active, + metadata: input.metadata ? toPrismaJsonValue(input.metadata) : undefined + }, + create: { + tenant_id: input.tenant_id, + ipv4_limit: input.ipv4_limit ?? null, + ipv6_limit: input.ipv6_limit ?? null, + reserved_ipv4: input.reserved_ipv4 ?? 0, + reserved_ipv6: input.reserved_ipv6 ?? 0, + burst_allowed: input.burst_allowed ?? false, + burst_ipv4_limit: input.burst_ipv4_limit ?? null, + burst_ipv6_limit: input.burst_ipv6_limit ?? null, + is_active: input.is_active ?? true, + metadata: input.metadata ? toPrismaJsonValue(input.metadata) : {}, + created_by: input.created_by + }, + include: { + tenant: { + select: { + id: true, + name: true + } + } + } + }); +} + +export async function listTenantIpQuotas(tenantId?: string) { + return prisma.tenantIpQuota.findMany({ + where: tenantId ? { tenant_id: tenantId } : undefined, + include: { + tenant: { + select: { + id: true, + name: true, + slug: true + } + } + }, + orderBy: [{ created_at: "desc" }] + }); +} + +export async function createIpReservedRange(input: CreateReservedRangeInput) { + const parsed = parseCidr(input.cidr); + return prisma.ipReservedRange.create({ + data: { + name: input.name, + cidr: input.cidr, + version: parsed.version, + scope: input.scope ?? IpScope.PUBLIC, + tenant_id: input.tenant_id, + reason: input.reason, + node_hostname: input.node_hostname, + bridge: input.bridge, + vlan_tag: input.vlan_tag, + sdn_zone: input.sdn_zone, + is_active: input.is_active ?? true, + metadata: input.metadata ? toPrismaJsonValue(input.metadata) : {}, + created_by: input.created_by + } + }); +} + +export async function listIpReservedRanges() { + return prisma.ipReservedRange.findMany({ + include: { + tenant: { + select: { + id: true, + name: true + } + } + }, + orderBy: [{ is_active: "desc" }, { created_at: "desc" }] + }); +} + +export async function updateIpReservedRange(rangeId: string, input: Partial) { + const parsed = input.cidr ? parseCidr(input.cidr) : null; + return prisma.ipReservedRange.update({ + where: { id: rangeId }, + data: { + name: input.name, + cidr: input.cidr, + version: parsed?.version, + scope: input.scope, + tenant_id: input.tenant_id, + reason: input.reason, + node_hostname: input.node_hostname, + bridge: input.bridge, + vlan_tag: input.vlan_tag, + sdn_zone: input.sdn_zone, + is_active: input.is_active, + metadata: input.metadata ? toPrismaJsonValue(input.metadata) : undefined + } + }); +} + +export async function upsertIpPoolPolicy(input: UpsertIpPoolPolicyInput) { + if (input.policy_id) { + return prisma.ipPoolPolicy.update({ + where: { id: input.policy_id }, + data: { + name: input.name, + tenant_id: input.tenant_id, + scope: input.scope, + version: input.version, + node_hostname: input.node_hostname, + bridge: input.bridge, + vlan_tag: input.vlan_tag, + sdn_zone: input.sdn_zone, + allocation_strategy: input.allocation_strategy, + enforce_quota: input.enforce_quota, + disallow_reserved_use: input.disallow_reserved_use, + is_active: input.is_active, + priority: input.priority, + metadata: input.metadata ? toPrismaJsonValue(input.metadata) : undefined + } + }); + } + + return prisma.ipPoolPolicy.create({ + data: { + name: input.name, + tenant_id: input.tenant_id, + scope: input.scope, + version: input.version, + node_hostname: input.node_hostname, + bridge: input.bridge, + vlan_tag: input.vlan_tag, + sdn_zone: input.sdn_zone, + allocation_strategy: input.allocation_strategy ?? IpAllocationStrategy.BEST_FIT, + enforce_quota: input.enforce_quota ?? true, + disallow_reserved_use: input.disallow_reserved_use ?? true, + is_active: input.is_active ?? true, + priority: input.priority ?? 100, + metadata: input.metadata ? toPrismaJsonValue(input.metadata) : {}, + created_by: input.created_by + } + }); +} + +export async function listIpPoolPolicies() { + return prisma.ipPoolPolicy.findMany({ + include: { + tenant: { + select: { + id: true, + name: true + } + } + }, + orderBy: [{ priority: "asc" }, { created_at: "asc" }] + }); +} + +export async function createPrivateNetwork(input: CreatePrivateNetworkInput) { + parseCidr(input.cidr); + + const slug = input.slug && input.slug.trim().length > 0 ? normalizeSlug(input.slug) : normalizeSlug(input.name); + + return prisma.privateNetwork.create({ + data: { + name: input.name, + slug, + network_type: input.network_type ?? PrivateNetworkType.VLAN, + cidr: input.cidr, + gateway: input.gateway, + bridge: input.bridge, + vlan_tag: input.vlan_tag, + sdn_zone: input.sdn_zone, + server: input.server, + node_hostname: input.node_hostname, + metadata: input.metadata ? toPrismaJsonValue(input.metadata) : {}, + created_by: input.created_by + } + }); +} + +export async function listPrivateNetworks() { + return prisma.privateNetwork.findMany({ + include: { + attachments: { + where: { + status: PrivateNetworkAttachmentStatus.ATTACHED + }, + select: { + id: true, + vm_id: true, + interface_name: true, + attached_at: true + } + } + }, + orderBy: [{ created_at: "desc" }] + }); +} + +export async function attachPrivateNetwork(input: AttachPrivateNetworkInput) { + const [network, vm] = await Promise.all([ + prisma.privateNetwork.findUnique({ where: { id: input.network_id } }), + fetchVmForNetwork(input.vm_id) + ]); + + if (!network) { + throw new HttpError(404, "Private network not found", "PRIVATE_NETWORK_NOT_FOUND"); + } + + const interfaceName = input.interface_name ?? "net1"; + const runtime = vmRuntime(vm.type); + const bridge = network.bridge ?? (typeof network.vlan_tag === "number" ? `vmbr${network.vlan_tag}` : "vmbr0"); + const upid = await reconfigureVmNetwork(vm.node, vm.vmid, runtime, { + interface_name: interfaceName, + bridge, + vlan_tag: network.vlan_tag ?? undefined, + ip_mode: input.requested_ip ? "static" : "dhcp", + ip_cidr: input.requested_ip, + gateway: input.requested_ip ? network.gateway ?? undefined : undefined + }); + + const existing = await prisma.privateNetworkAttachment.findFirst({ + where: { + network_id: network.id, + vm_id: vm.id, + interface_name: interfaceName + } + }); + + if (existing) { + return prisma.privateNetworkAttachment.update({ + where: { id: existing.id }, + data: { + tenant_id: vm.tenant_id, + requested_ip: input.requested_ip, + status: PrivateNetworkAttachmentStatus.ATTACHED, + detached_at: null, + attached_by: input.actor_email, + attached_at: new Date(), + metadata: toPrismaJsonValue({ + ...(input.metadata ?? {}), + proxmox_upid: upid + }) + }, + include: { + network: true, + vm: { + select: { + id: true, + name: true, + tenant_id: true, + node: true + } + } + } + }); + } + + return prisma.privateNetworkAttachment.create({ + data: { + network_id: network.id, + vm_id: vm.id, + tenant_id: vm.tenant_id, + interface_name: interfaceName, + requested_ip: input.requested_ip, + status: PrivateNetworkAttachmentStatus.ATTACHED, + attached_by: input.actor_email, + metadata: toPrismaJsonValue({ + ...(input.metadata ?? {}), + proxmox_upid: upid + }) + }, + include: { + network: true, + vm: { + select: { + id: true, + name: true, + tenant_id: true, + node: true + } + } + } + }); +} + +export async function detachPrivateNetwork(input: DetachPrivateNetworkInput) { + const attachment = await prisma.privateNetworkAttachment.findUnique({ + where: { id: input.attachment_id }, + include: { + vm: { + select: { + id: true, + name: true, + tenant_id: true, + vmid: true, + node: true, + type: true + } + } + } + }); + + if (!attachment) { + throw new HttpError(404, "Private network attachment not found", "PRIVATE_NETWORK_ATTACHMENT_NOT_FOUND"); + } + + if (attachment.status === PrivateNetworkAttachmentStatus.DETACHED) { + return attachment; + } + + const runtime = vmRuntime(attachment.vm.type); + const interfaceName = attachment.interface_name ?? "net1"; + const upid = await updateVmConfiguration(attachment.vm.node, attachment.vm.vmid, runtime, { + delete: interfaceName + }); + + return prisma.privateNetworkAttachment.update({ + where: { id: attachment.id }, + data: { + status: PrivateNetworkAttachmentStatus.DETACHED, + detached_at: new Date(), + metadata: toPrismaJsonValue({ + ...(attachment.metadata as Record), + detached_by: input.actor_email, + detach_upid: upid + }) + }, + include: { + network: true, + vm: { + select: { + id: true, + name: true, + tenant_id: true, + node: true + } + } + } + }); +} diff --git a/backend/src/services/operations.service.ts b/backend/src/services/operations.service.ts new file mode 100644 index 0000000..9402438 --- /dev/null +++ b/backend/src/services/operations.service.ts @@ -0,0 +1,954 @@ +import { + OperationTaskStatus, + OperationTaskType, + PowerScheduleAction, + Prisma, + VmStatus +} from "@prisma/client"; +import axios from "axios"; +import { prisma } from "../lib/prisma"; +import { HttpError } from "../lib/http-error"; +import { restartVm, shutdownVm, startVm, stopVm } from "./proxmox.service"; + +type TaskCreateInput = { + taskType: OperationTaskType; + requestedBy?: string; + vm?: { + id: string; + name: string; + node: string; + }; + payload?: Prisma.InputJsonValue; + scheduledFor?: Date | null; + status?: OperationTaskStatus; +}; + +type TaskListInput = { + status?: OperationTaskStatus; + taskType?: OperationTaskType; + vmId?: string; + limit?: number; + offset?: number; + tenantId?: string | null; +}; + +type PowerScheduleCreateInput = { + vmId: string; + action: PowerScheduleAction; + cronExpression: string; + timezone?: string; + createdBy?: string; +}; + +type PowerScheduleUpdateInput = { + action?: PowerScheduleAction; + cronExpression?: string; + timezone?: string; + enabled?: boolean; +}; + +type ExecutePowerOptions = { + scheduledFor?: Date | null; + payload?: Prisma.InputJsonValue; +}; + +export type OperationsPolicy = { + max_retry_attempts: number; + retry_backoff_minutes: number; + notify_on_task_failure: boolean; + notification_email: string | null; + notification_webhook_url: string | null; + email_gateway_url: string | null; +}; + +const DEFAULT_OPERATIONS_POLICY: OperationsPolicy = { + max_retry_attempts: 2, + retry_backoff_minutes: 10, + notify_on_task_failure: true, + notification_email: null, + notification_webhook_url: null, + email_gateway_url: null +}; + +function numberRange(min: number, max: number) { + return Array.from({ length: max - min + 1 }, (_, idx) => min + idx); +} + +function parseSingleToken(token: string, min: number, max: number): number[] { + if (token === "*") { + return numberRange(min, max); + } + + if (token.includes("/")) { + const [baseToken, stepToken] = token.split("/"); + const step = Number(stepToken); + if (!Number.isInteger(step) || step <= 0) { + throw new Error(`Invalid cron step: ${token}`); + } + + const baseValues = parseSingleToken(baseToken, min, max); + const startValue = Math.min(...baseValues); + return baseValues.filter((value) => (value - startValue) % step === 0); + } + + if (token.includes("-")) { + const [startToken, endToken] = token.split("-"); + const start = Number(startToken); + const end = Number(endToken); + if (!Number.isInteger(start) || !Number.isInteger(end) || start > end) { + throw new Error(`Invalid cron range: ${token}`); + } + if (start < min || end > max) { + throw new Error(`Cron range out of bounds: ${token}`); + } + return numberRange(start, end); + } + + const value = Number(token); + if (!Number.isInteger(value) || value < min || value > max) { + throw new Error(`Invalid cron value: ${token}`); + } + return [value]; +} + +function parseCronField(field: string, min: number, max: number): Set { + const values = new Set(); + for (const rawToken of field.split(",")) { + const token = rawToken.trim(); + if (!token) continue; + for (const value of parseSingleToken(token, min, max)) { + values.add(value); + } + } + if (values.size === 0) { + throw new Error(`Invalid cron field: ${field}`); + } + return values; +} + +function parseCronExpression(expression: string) { + const parts = expression.trim().split(/\s+/); + if (parts.length !== 5) { + throw new Error("Cron expression must contain exactly 5 fields"); + } + + return { + minute: parseCronField(parts[0], 0, 59), + hour: parseCronField(parts[1], 0, 23), + dayOfMonth: parseCronField(parts[2], 1, 31), + month: parseCronField(parts[3], 1, 12), + dayOfWeek: parseCronField(parts[4], 0, 6) + }; +} + +function cronMatchesParsed(date: Date, parsed: ReturnType) { + return ( + parsed.minute.has(date.getMinutes()) && + parsed.hour.has(date.getHours()) && + parsed.dayOfMonth.has(date.getDate()) && + parsed.month.has(date.getMonth() + 1) && + parsed.dayOfWeek.has(date.getDay()) + ); +} + +export function nextRunAt(cronExpression: string, fromDate = new Date()): Date | null { + const parsed = parseCronExpression(cronExpression); + const base = new Date(fromDate); + base.setSeconds(0, 0); + + const maxChecks = 60 * 24 * 365; + for (let index = 1; index <= maxChecks; index += 1) { + const candidate = new Date(base.getTime() + index * 60 * 1000); + if (cronMatchesParsed(candidate, parsed)) { + return candidate; + } + } + + return null; +} + +export function validateCronExpression(cronExpression: string) { + parseCronExpression(cronExpression); +} + +export async function createOperationTask(input: TaskCreateInput) { + return prisma.operationTask.create({ + data: { + task_type: input.taskType, + status: input.status ?? OperationTaskStatus.QUEUED, + vm_id: input.vm?.id, + vm_name: input.vm?.name, + node: input.vm?.node, + requested_by: input.requestedBy, + payload: input.payload, + scheduled_for: input.scheduledFor ?? undefined + } + }); +} + +export async function markOperationTaskRunning(taskId: string) { + return prisma.operationTask.update({ + where: { id: taskId }, + data: { + status: OperationTaskStatus.RUNNING, + started_at: new Date(), + error_message: null + } + }); +} + +export async function markOperationTaskSuccess(taskId: string, result?: Prisma.InputJsonValue, proxmoxUpid?: string) { + return prisma.operationTask.update({ + where: { id: taskId }, + data: { + status: OperationTaskStatus.SUCCESS, + result, + proxmox_upid: proxmoxUpid, + completed_at: new Date() + } + }); +} + +export async function markOperationTaskFailed(taskId: string, errorMessage: string) { + return prisma.operationTask.update({ + where: { id: taskId }, + data: { + status: OperationTaskStatus.FAILED, + error_message: errorMessage, + completed_at: new Date() + } + }); +} + +function asPlainObject(value: Prisma.JsonValue | Prisma.InputJsonValue | null | undefined): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + return value as Record; +} + +function toPowerAction(value: unknown): PowerScheduleAction | null { + if (typeof value !== "string") return null; + const candidate = value.toUpperCase(); + return Object.values(PowerScheduleAction).includes(candidate as PowerScheduleAction) + ? (candidate as PowerScheduleAction) + : null; +} + +function asStringOrNull(value: unknown) { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function addMinutes(date: Date, minutes: number) { + const copy = new Date(date); + copy.setMinutes(copy.getMinutes() + minutes); + return copy; +} + +export async function getOperationsPolicy(): Promise { + const [setting, notificationsSetting] = await Promise.all([ + prisma.setting.findUnique({ + where: { key: "operations_policy" }, + select: { value: true } + }), + prisma.setting.findUnique({ + where: { key: "notifications" }, + select: { value: true } + }) + ]); + + const value = + setting?.value && typeof setting.value === "object" && !Array.isArray(setting.value) + ? (setting.value as Record) + : {}; + const notificationsValue = + notificationsSetting?.value && typeof notificationsSetting.value === "object" && !Array.isArray(notificationsSetting.value) + ? (notificationsSetting.value as Record) + : {}; + + const maxRetryAttemptsRaw = Number(value.max_retry_attempts); + const retryBackoffRaw = Number(value.retry_backoff_minutes); + + return { + max_retry_attempts: + Number.isInteger(maxRetryAttemptsRaw) && maxRetryAttemptsRaw >= 0 + ? Math.min(maxRetryAttemptsRaw, 10) + : DEFAULT_OPERATIONS_POLICY.max_retry_attempts, + retry_backoff_minutes: + Number.isInteger(retryBackoffRaw) && retryBackoffRaw >= 1 + ? Math.min(retryBackoffRaw, 720) + : DEFAULT_OPERATIONS_POLICY.retry_backoff_minutes, + notify_on_task_failure: + typeof value.notify_on_task_failure === "boolean" + ? value.notify_on_task_failure + : DEFAULT_OPERATIONS_POLICY.notify_on_task_failure, + notification_email: asStringOrNull(value.notification_email) ?? asStringOrNull(notificationsValue.ops_email), + notification_webhook_url: + asStringOrNull(value.notification_webhook_url) ?? + asStringOrNull(notificationsValue.monitoring_webhook_url) ?? + asStringOrNull(notificationsValue.alert_webhook_url), + email_gateway_url: + asStringOrNull(value.email_gateway_url) ?? + asStringOrNull(notificationsValue.email_gateway_url) ?? + asStringOrNull(notificationsValue.notification_email_webhook) + }; +} + +async function dispatchTaskFailureNotifications(input: { + task: { + id: string; + task_type: OperationTaskType; + vm_name: string | null; + vm_id: string | null; + node: string | null; + retry_count: number; + error_message: string | null; + created_at: Date; + completed_at: Date | null; + requested_by: string | null; + }; + policy: OperationsPolicy; + stage: "retry_exhausted" | "non_retryable"; +}) { + const destinationEmail = input.policy.notification_email; + const emailGatewayUrl = input.policy.email_gateway_url; + const webhookUrl = input.policy.notification_webhook_url; + const eventPayload = { + type: "operations.task_failure", + stage: input.stage, + task_id: input.task.id, + task_type: input.task.task_type, + vm_id: input.task.vm_id, + vm_name: input.task.vm_name, + node: input.task.node, + retry_count: input.task.retry_count, + error_message: input.task.error_message, + created_at: input.task.created_at.toISOString(), + completed_at: input.task.completed_at?.toISOString() ?? null, + requested_by: input.task.requested_by + }; + + const notifications: Array<{ + channel: "WEBHOOK" | "EMAIL"; + destination: string | null; + status: "SENT" | "FAILED"; + provider_message: string; + sent_at: Date | null; + }> = []; + + if (webhookUrl) { + try { + const response = await axios.post(webhookUrl, eventPayload, { timeout: 10_000 }); + notifications.push({ + channel: "WEBHOOK", + destination: webhookUrl, + status: "SENT", + provider_message: `HTTP ${response.status}`, + sent_at: new Date() + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Webhook dispatch failed"; + notifications.push({ + channel: "WEBHOOK", + destination: webhookUrl, + status: "FAILED", + provider_message: message.slice(0, 240), + sent_at: null + }); + } + } + + if (emailGatewayUrl && destinationEmail) { + try { + const response = await axios.post( + emailGatewayUrl, + { + type: "operations.task_failure.email", + to: destinationEmail, + subject: `[Task Failure] ${input.task.task_type} ${input.task.vm_name ?? input.task.vm_id ?? ""}`.trim(), + message: input.task.error_message ?? "Operation task failed", + payload: eventPayload + }, + { timeout: 10_000 } + ); + notifications.push({ + channel: "EMAIL", + destination: destinationEmail, + status: "SENT", + provider_message: `HTTP ${response.status}`, + sent_at: new Date() + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Email dispatch failed"; + notifications.push({ + channel: "EMAIL", + destination: destinationEmail, + status: "FAILED", + provider_message: message.slice(0, 240), + sent_at: null + }); + } + } + + if (notifications.length > 0) { + await prisma.auditLog.createMany({ + data: notifications.map((notification) => ({ + action: "operations.task_failure_notification", + resource_type: "SYSTEM", + resource_id: input.task.id, + resource_name: input.task.vm_name ?? input.task.id, + actor_email: "system@proxpanel.local", + actor_role: "SYSTEM", + severity: notification.status === "FAILED" ? "ERROR" : "INFO", + details: { + channel: notification.channel, + destination: notification.destination, + dispatch_status: notification.status, + provider_message: notification.provider_message, + task_id: input.task.id, + stage: input.stage + } + })) + }); + } +} + +async function handleOperationTaskFailure(taskId: string, errorMessage: string) { + const policy = await getOperationsPolicy(); + const existing = await prisma.operationTask.findUnique({ where: { id: taskId } }); + + if (!existing) { + return { status: "missing" as const, retry_scheduled: false }; + } + + const canRetry = + existing.task_type === OperationTaskType.VM_POWER && + existing.retry_count < policy.max_retry_attempts && + policy.max_retry_attempts > 0; + + if (canRetry) { + const nextRetryAt = addMinutes(new Date(), policy.retry_backoff_minutes); + await prisma.operationTask.update({ + where: { id: existing.id }, + data: { + status: OperationTaskStatus.RETRYING, + error_message: errorMessage, + completed_at: new Date(), + retry_count: existing.retry_count + 1, + scheduled_for: nextRetryAt + } + }); + return { status: "retrying" as const, retry_scheduled: true, next_retry_at: nextRetryAt }; + } + + const failed = await prisma.operationTask.update({ + where: { id: existing.id }, + data: { + status: OperationTaskStatus.FAILED, + error_message: errorMessage, + completed_at: new Date(), + scheduled_for: null + } + }); + + if (policy.notify_on_task_failure) { + await dispatchTaskFailureNotifications({ + task: failed, + policy, + stage: existing.task_type === OperationTaskType.VM_POWER ? "retry_exhausted" : "non_retryable" + }); + } + + return { status: "failed" as const, retry_scheduled: false }; +} + +export async function listOperationTasks(input: TaskListInput) { + const where: Prisma.OperationTaskWhereInput = {}; + + if (input.status) where.status = input.status; + if (input.taskType) where.task_type = input.taskType; + if (input.vmId) where.vm_id = input.vmId; + if (input.tenantId) { + where.vm = { tenant_id: input.tenantId }; + } + + const limit = Math.min(Math.max(input.limit ?? 50, 1), 200); + const offset = Math.max(input.offset ?? 0, 0); + + const [data, total] = await Promise.all([ + prisma.operationTask.findMany({ + where, + include: { + vm: { + select: { + id: true, + name: true, + tenant_id: true, + node: true + } + } + }, + orderBy: { created_at: "desc" }, + take: limit, + skip: offset + }), + prisma.operationTask.count({ where }) + ]); + + const queue = await prisma.operationTask.groupBy({ + by: ["status"], + _count: { status: true }, + where: input.tenantId ? { vm: { tenant_id: input.tenantId } } : undefined + }); + + return { + data, + meta: { + total, + limit, + offset, + queue_summary: queue.reduce>((acc, item) => { + acc[item.status] = item._count.status; + return acc; + }, {}) + } + }; +} + +function vmStatusFromPowerAction(action: PowerScheduleAction): VmStatus { + if (action === PowerScheduleAction.START || action === PowerScheduleAction.RESTART) { + return VmStatus.RUNNING; + } + return VmStatus.STOPPED; +} + +async function fetchVmForAction(vmId: string) { + const vm = await prisma.virtualMachine.findUnique({ + where: { id: vmId }, + select: { + id: true, + name: true, + node: true, + vmid: true, + type: true, + tenant_id: true + } + }); + + if (!vm) { + throw new HttpError(404, "VM not found", "VM_NOT_FOUND"); + } + + return vm; +} + +async function runPowerAction(vm: Awaited>, action: PowerScheduleAction) { + const type = vm.type === "LXC" ? "lxc" : "qemu"; + + if (action === PowerScheduleAction.START) { + return startVm(vm.node, vm.vmid, type); + } + + if (action === PowerScheduleAction.STOP) { + return stopVm(vm.node, vm.vmid, type); + } + + if (action === PowerScheduleAction.RESTART) { + return restartVm(vm.node, vm.vmid, type); + } + + return shutdownVm(vm.node, vm.vmid, type); +} + +export async function executeVmPowerActionNow( + vmId: string, + action: PowerScheduleAction, + actorEmail: string, + options?: ExecutePowerOptions +) { + const vm = await fetchVmForAction(vmId); + const rawPayload = asPlainObject(options?.payload ?? null); + const taskPayload: Prisma.InputJsonObject = { + ...rawPayload, + action, + vm_id: vm.id + }; + + const task = await createOperationTask({ + taskType: OperationTaskType.VM_POWER, + vm: { + id: vm.id, + name: vm.name, + node: vm.node + }, + requestedBy: actorEmail, + payload: taskPayload, + scheduledFor: options?.scheduledFor + }); + + await markOperationTaskRunning(task.id); + + try { + const upid = await runPowerAction(vm, action); + await prisma.virtualMachine.update({ + where: { id: vm.id }, + data: { + status: vmStatusFromPowerAction(action), + proxmox_upid: upid ?? undefined + } + }); + + const resultPayload: Prisma.InputJsonObject = upid + ? { + vm_id: vm.id, + action, + upid + } + : { + vm_id: vm.id, + action + }; + + const updatedTask = await markOperationTaskSuccess(task.id, resultPayload, upid ?? undefined); + return { task: updatedTask, upid }; + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown power action error"; + await handleOperationTaskFailure(task.id, message); + throw error; + } +} + +export async function listPowerSchedules(tenantId?: string | null) { + const where: Prisma.PowerScheduleWhereInput = tenantId + ? { + vm: { + tenant_id: tenantId + } + } + : {}; + + return prisma.powerSchedule.findMany({ + where, + include: { + vm: { + select: { + id: true, + name: true, + node: true, + tenant_id: true, + status: true + } + } + }, + orderBy: [ + { enabled: "desc" }, + { next_run_at: "asc" }, + { created_at: "desc" } + ] + }); +} + +export async function createPowerSchedule(input: PowerScheduleCreateInput) { + validateCronExpression(input.cronExpression); + const vm = await fetchVmForAction(input.vmId); + + const nextRun = nextRunAt(input.cronExpression, new Date()); + + return prisma.powerSchedule.create({ + data: { + vm_id: vm.id, + action: input.action, + cron_expression: input.cronExpression, + timezone: input.timezone ?? "UTC", + next_run_at: nextRun, + created_by: input.createdBy + } + }); +} + +export async function updatePowerSchedule(scheduleId: string, input: PowerScheduleUpdateInput) { + const existing = await prisma.powerSchedule.findUnique({ where: { id: scheduleId } }); + if (!existing) { + throw new HttpError(404, "Power schedule not found", "POWER_SCHEDULE_NOT_FOUND"); + } + + if (input.cronExpression) { + validateCronExpression(input.cronExpression); + } + + const cronExpression = input.cronExpression ?? existing.cron_expression; + const enabled = input.enabled ?? existing.enabled; + const nextRun = enabled ? nextRunAt(cronExpression, new Date()) : null; + + return prisma.powerSchedule.update({ + where: { id: scheduleId }, + data: { + action: input.action, + cron_expression: input.cronExpression, + timezone: input.timezone, + enabled: input.enabled, + next_run_at: nextRun + } + }); +} + +export async function deletePowerSchedule(scheduleId: string) { + return prisma.powerSchedule.delete({ where: { id: scheduleId } }); +} + +export async function processDuePowerSchedules(actorEmail = "system@proxpanel.local") { + const now = new Date(); + const dueSchedules = await prisma.powerSchedule.findMany({ + where: { + enabled: true, + next_run_at: { + lte: now + } + }, + include: { + vm: { + select: { + id: true, + name: true, + node: true, + vmid: true, + type: true + } + } + }, + orderBy: { + next_run_at: "asc" + }, + take: 100 + }); + + let executed = 0; + let failed = 0; + let skipped = 0; + + for (const schedule of dueSchedules) { + const nextRun = nextRunAt(schedule.cron_expression, now); + const claim = await prisma.powerSchedule.updateMany({ + where: { + id: schedule.id, + enabled: true, + next_run_at: { + lte: now + } + }, + data: { + last_run_at: now, + next_run_at: nextRun, + enabled: nextRun ? schedule.enabled : false + } + }); + + if (claim.count === 0) { + skipped += 1; + continue; + } + + const payload: Prisma.InputJsonValue = { + source: "power_schedule", + schedule_id: schedule.id, + action: schedule.action + }; + + try { + await executeVmPowerActionNow(schedule.vm_id, schedule.action, actorEmail, { + payload, + scheduledFor: schedule.next_run_at + }); + executed += 1; + } catch { + failed += 1; + } + } + + return { + scanned: dueSchedules.length, + executed, + failed, + skipped + }; +} + +export async function processDueOperationRetries(actorEmail = "system@proxpanel.local") { + const now = new Date(); + const dueRetries = await prisma.operationTask.findMany({ + where: { + task_type: OperationTaskType.VM_POWER, + status: OperationTaskStatus.RETRYING, + scheduled_for: { + lte: now + } + }, + orderBy: { scheduled_for: "asc" }, + take: 100 + }); + + let executed = 0; + let succeeded = 0; + let failed = 0; + let rescheduled = 0; + let invalidPayload = 0; + let skipped = 0; + + for (const task of dueRetries) { + const claimedAt = new Date(); + const claim = await prisma.operationTask.updateMany({ + where: { + id: task.id, + task_type: OperationTaskType.VM_POWER, + status: OperationTaskStatus.RETRYING, + scheduled_for: { + lte: now + } + }, + data: { + status: OperationTaskStatus.RUNNING, + started_at: claimedAt, + error_message: null, + completed_at: null + } + }); + + if (claim.count === 0) { + skipped += 1; + continue; + } + + executed += 1; + const payload = asPlainObject(task.payload as Prisma.JsonValue | null); + const action = toPowerAction(payload.action); + + if (!task.vm_id || !action) { + invalidPayload += 1; + await handleOperationTaskFailure(task.id, "Retry payload missing actionable power action"); + continue; + } + + try { + const vm = await fetchVmForAction(task.vm_id); + const upid = await runPowerAction(vm, action); + await prisma.virtualMachine.update({ + where: { id: vm.id }, + data: { + status: vmStatusFromPowerAction(action), + proxmox_upid: upid ?? undefined + } + }); + + const resultPayload: Prisma.InputJsonObject = upid + ? { + retry_of_task: task.id, + vm_id: vm.id, + action, + upid + } + : { + retry_of_task: task.id, + vm_id: vm.id, + action + }; + + await markOperationTaskSuccess(task.id, resultPayload, upid ?? undefined); + succeeded += 1; + } catch (error) { + const message = error instanceof Error ? error.message : "Retry power action failed"; + const failureResult = await handleOperationTaskFailure(task.id, message); + failed += 1; + if (failureResult.retry_scheduled) { + rescheduled += 1; + } + } + } + + if (dueRetries.length > 0 || failed > 0 || rescheduled > 0) { + await prisma.auditLog.create({ + data: { + action: "operations.retry_cycle", + resource_type: "SYSTEM", + resource_name: "Operation Retry Worker", + actor_email: actorEmail, + actor_role: "SYSTEM", + severity: failed > 0 ? "WARNING" : "INFO", + details: { + scanned: dueRetries.length, + executed, + succeeded, + failed, + rescheduled, + invalid_payload: invalidPayload, + skipped + } + } + }); + } + + return { + scanned: dueRetries.length, + executed, + succeeded, + failed, + rescheduled, + invalid_payload: invalidPayload, + skipped + }; +} + +export async function operationQueueInsights(tenantId?: string | null) { + const now = new Date(); + const staleThreshold = addMinutes(now, -15); + const dayAgo = addMinutes(now, -24 * 60); + + const tenantWhere: Prisma.OperationTaskWhereInput = tenantId ? { vm: { tenant_id: tenantId } } : {}; + + const [statusBuckets, staleQueued, failed24h, dueRetries, powerSchedulesDue] = await Promise.all([ + prisma.operationTask.groupBy({ + by: ["status"], + _count: { status: true }, + where: tenantWhere + }), + prisma.operationTask.count({ + where: { + ...tenantWhere, + status: OperationTaskStatus.QUEUED, + created_at: { lte: staleThreshold } + } + }), + prisma.operationTask.count({ + where: { + ...tenantWhere, + status: OperationTaskStatus.FAILED, + completed_at: { gte: dayAgo } + } + }), + prisma.operationTask.count({ + where: { + ...tenantWhere, + status: OperationTaskStatus.RETRYING, + scheduled_for: { lte: now } + } + }), + prisma.powerSchedule.count({ + where: { + enabled: true, + next_run_at: { lte: now }, + ...(tenantId ? { vm: { tenant_id: tenantId } } : {}) + } + }) + ]); + + const queueSummary = statusBuckets.reduce>((acc, bucket) => { + acc[bucket.status] = bucket._count.status; + return acc; + }, {}); + + return { + generated_at: now.toISOString(), + queue_summary: queueSummary, + stale_queued_tasks: staleQueued, + failed_tasks_24h: failed24h, + due_retries: dueRetries, + due_power_schedules: powerSchedulesDue + }; +} diff --git a/backend/src/services/payment.service.ts b/backend/src/services/payment.service.ts new file mode 100644 index 0000000..4725fd1 --- /dev/null +++ b/backend/src/services/payment.service.ts @@ -0,0 +1,182 @@ +import axios from "axios"; +import crypto from "crypto"; +import { PaymentProvider } from "@prisma/client"; +import { prisma } from "../lib/prisma"; +import { HttpError } from "../lib/http-error"; +import { markInvoicePaid } from "./billing.service"; + +type PaymentSettings = { + default_provider?: "paystack" | "flutterwave" | "manual"; + paystack_public?: string; + paystack_secret?: string; + flutterwave_public?: string; + flutterwave_secret?: string; + flutterwave_webhook_hash?: string; + callback_url?: string; +}; + +async function getPaymentSettings(): Promise { + const setting = await prisma.setting.findUnique({ + where: { key: "payment" } + }); + return (setting?.value as PaymentSettings) ?? {}; +} + +function normalizeProvider(provider: string | undefined, fallback: string): PaymentProvider { + const value = (provider ?? fallback).toLowerCase(); + if (value === "paystack") return PaymentProvider.PAYSTACK; + if (value === "flutterwave") return PaymentProvider.FLUTTERWAVE; + return PaymentProvider.MANUAL; +} + +export async function createInvoicePaymentLink(invoiceId: string, requestedProvider?: string) { + const invoice = await prisma.invoice.findUnique({ + where: { id: invoiceId }, + include: { tenant: true } + }); + if (!invoice) { + throw new HttpError(404, "Invoice not found", "INVOICE_NOT_FOUND"); + } + + const settings = await getPaymentSettings(); + const provider = normalizeProvider(requestedProvider, settings.default_provider ?? "manual"); + if (provider === PaymentProvider.MANUAL) { + throw new HttpError(400, "Manual payment provider cannot generate online links", "MANUAL_PROVIDER"); + } + + const reference = invoice.payment_reference ?? `PAY-${invoice.invoice_number}-${Date.now()}`; + + if (provider === PaymentProvider.PAYSTACK) { + if (!settings.paystack_secret) { + throw new HttpError(400, "Paystack secret key is missing", "PAYSTACK_CONFIG_MISSING"); + } + const response = await axios.post( + "https://api.paystack.co/transaction/initialize", + { + email: invoice.tenant.owner_email, + amount: Math.round(Number(invoice.amount) * 100), + reference, + currency: invoice.currency, + callback_url: settings.callback_url, + metadata: { + invoice_id: invoice.id, + tenant_id: invoice.tenant_id + } + }, + { + headers: { + Authorization: `Bearer ${settings.paystack_secret}`, + "Content-Type": "application/json" + } + } + ); + + const paymentUrl = response.data?.data?.authorization_url as string | undefined; + await prisma.invoice.update({ + where: { id: invoice.id }, + data: { + status: "PENDING", + payment_provider: provider, + payment_reference: reference, + payment_url: paymentUrl + } + }); + return { provider: "paystack", payment_url: paymentUrl, reference }; + } + + if (!settings.flutterwave_secret) { + throw new HttpError(400, "Flutterwave secret key is missing", "FLUTTERWAVE_CONFIG_MISSING"); + } + const response = await axios.post( + "https://api.flutterwave.com/v3/payments", + { + tx_ref: reference, + amount: Number(invoice.amount), + currency: invoice.currency, + redirect_url: settings.callback_url, + customer: { + email: invoice.tenant.owner_email, + name: invoice.tenant.name + }, + customizations: { + title: "ProxPanel Invoice Payment", + description: `Invoice ${invoice.invoice_number}` + }, + meta: { + invoice_id: invoice.id, + tenant_id: invoice.tenant_id + } + }, + { + headers: { + Authorization: `Bearer ${settings.flutterwave_secret}`, + "Content-Type": "application/json" + } + } + ); + const paymentUrl = response.data?.data?.link as string | undefined; + await prisma.invoice.update({ + where: { id: invoice.id }, + data: { + status: "PENDING", + payment_provider: provider, + payment_reference: reference, + payment_url: paymentUrl + } + }); + return { provider: "flutterwave", payment_url: paymentUrl, reference }; +} + +export async function handleManualInvoicePayment(invoiceId: string, reference: string, actorEmail: string) { + return markInvoicePaid(invoiceId, PaymentProvider.MANUAL, reference, actorEmail); +} + +export async function verifyPaystackSignature(signature: string | undefined, rawBody: string | undefined) { + if (!signature || !rawBody) return false; + const settings = await getPaymentSettings(); + if (!settings.paystack_secret) return false; + const expected = crypto + .createHmac("sha512", settings.paystack_secret) + .update(rawBody) + .digest("hex"); + return expected === signature; +} + +export async function verifyFlutterwaveSignature(signature: string | undefined) { + const settings = await getPaymentSettings(); + if (!settings.flutterwave_webhook_hash) return false; + return settings.flutterwave_webhook_hash === signature; +} + +export async function processPaystackWebhook(payload: any) { + if (payload?.event !== "charge.success") return { handled: false }; + const reference = payload?.data?.reference as string | undefined; + if (!reference) return { handled: false }; + + const invoice = await prisma.invoice.findFirst({ + where: { payment_reference: reference } + }); + if (!invoice) return { handled: false }; + + if (invoice.status !== "PAID") { + await markInvoicePaid(invoice.id, PaymentProvider.PAYSTACK, reference, "webhook@paystack"); + } + return { handled: true, invoice_id: invoice.id }; +} + +export async function processFlutterwaveWebhook(payload: any) { + const status = payload?.status?.toLowerCase(); + if (status !== "successful") return { handled: false }; + const reference = (payload?.txRef ?? payload?.tx_ref) as string | undefined; + if (!reference) return { handled: false }; + + const invoice = await prisma.invoice.findFirst({ + where: { payment_reference: reference } + }); + if (!invoice) return { handled: false }; + + if (invoice.status !== "PAID") { + await markInvoicePaid(invoice.id, PaymentProvider.FLUTTERWAVE, reference, "webhook@flutterwave"); + } + return { handled: true, invoice_id: invoice.id }; +} diff --git a/backend/src/services/provisioning.service.ts b/backend/src/services/provisioning.service.ts new file mode 100644 index 0000000..c00c6ca --- /dev/null +++ b/backend/src/services/provisioning.service.ts @@ -0,0 +1,1123 @@ +import { + OperationTaskType, + Prisma, + ProductType, + ProvisionedService, + ServiceLifecycleStatus, + TemplateType, + VmType +} from "@prisma/client"; +import { prisma } from "../lib/prisma"; +import { HttpError } from "../lib/http-error"; +import { + deleteVm, + provisionVmFromTemplate, + startVm, + stopVm, + updateVmConfiguration +} from "./proxmox.service"; +import { + createOperationTask, + markOperationTaskFailed, + markOperationTaskRunning, + markOperationTaskSuccess +} from "./operations.service"; + +type PlacementRequest = { + productType: ProductType; + applicationGroupId?: string; + cpuCores: number; + ramMb: number; + diskGb: number; +}; + +type ServiceCreateInput = { + name: string; + tenantId: string; + productType: ProductType; + virtualizationType: VmType; + vmCount: number; + targetNode?: string; + autoNode: boolean; + applicationGroupId?: string; + templateId?: string; + billingPlanId?: string; + packageOptions?: Prisma.InputJsonValue; + createdBy?: string; +}; + +type ServiceListInput = { + tenantId?: string; + lifecycleStatus?: ServiceLifecycleStatus; + limit?: number; + offset?: number; +}; + +type ServiceLifecycleInput = { + serviceId: string; + actorEmail: string; + reason?: string; + hardDelete?: boolean; +}; + +type PackageUpdateInput = { + serviceId: string; + actorEmail: string; + packageOptions: Prisma.InputJsonValue; +}; + +function normalizeSlug(value: string) { + return value + .toLowerCase() + .trim() + .replace(/[^a-z0-9\s-]/g, "") + .replace(/\s+/g, "-") + .replace(/-+/g, "-"); +} + +function extractNumberOption( + packageOptions: Prisma.InputJsonValue | undefined, + key: string, + fallback: number +): number { + if (!packageOptions || typeof packageOptions !== "object" || Array.isArray(packageOptions)) { + return fallback; + } + + const raw = (packageOptions as Prisma.InputJsonObject)[key]; + if (typeof raw === "number" && Number.isFinite(raw) && raw > 0) { + return Math.floor(raw); + } + if (typeof raw === "string") { + const parsed = Number(raw); + if (Number.isFinite(parsed) && parsed > 0) { + return Math.floor(parsed); + } + } + return fallback; +} + +function parsePackageOptionsObject( + value: Prisma.InputJsonValue | undefined +): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + + return value as Record; +} + +async function resolvePlacementPolicy(nodeId: string, groupId: string | undefined, productType: ProductType) { + const policies = await prisma.nodePlacementPolicy.findMany({ + where: { + is_active: true, + OR: [ + { node_id: nodeId, group_id: groupId, product_type: productType }, + { node_id: nodeId, group_id: groupId, product_type: null }, + { node_id: nodeId, group_id: null, product_type: productType }, + { node_id: nodeId, group_id: null, product_type: null }, + { node_id: null, group_id: groupId, product_type: productType }, + { node_id: null, group_id: groupId, product_type: null }, + { node_id: null, group_id: null, product_type: productType }, + { node_id: null, group_id: null, product_type: null } + ] + } + }); + + const scorePolicy = (policy: (typeof policies)[number]) => { + let score = 0; + if (policy.node_id === nodeId) score += 8; + if (policy.group_id && groupId && policy.group_id === groupId) score += 4; + if (!policy.group_id) score += 1; + if (policy.product_type === productType) score += 2; + if (!policy.product_type) score += 1; + return score; + }; + + return policies.sort((a, b) => scorePolicy(b) - scorePolicy(a))[0] ?? null; +} + +function nodeResourcePercentages(node: { + cpu_usage: number; + ram_total_mb: number; + ram_used_mb: number; + disk_total_gb: number; + disk_used_gb: number; +}) { + const cpuFreePct = Math.max(0, 100 - (node.cpu_usage ?? 0)); + const ramFreeMb = Math.max(0, (node.ram_total_mb ?? 0) - (node.ram_used_mb ?? 0)); + const diskFreeGb = Math.max(0, (node.disk_total_gb ?? 0) - (node.disk_used_gb ?? 0)); + + const ramFreePct = node.ram_total_mb > 0 ? (ramFreeMb / node.ram_total_mb) * 100 : 0; + const diskFreePct = node.disk_total_gb > 0 ? (diskFreeGb / node.disk_total_gb) * 100 : 0; + + return { + cpuFreePct, + ramFreeMb, + diskFreeGb, + ramFreePct, + diskFreePct + }; +} + +export async function chooseNodeForProvisioning(input: PlacementRequest) { + const nodes = await prisma.proxmoxNode.findMany({ + where: { + status: "ONLINE", + is_connected: true + }, + orderBy: { + created_at: "asc" + } + }); + + if (nodes.length === 0) { + throw new HttpError(400, "No online nodes available for provisioning", "NO_AVAILABLE_NODE"); + } + + let bestNode: { + id: string; + hostname: string; + score: number; + } | null = null; + + for (const node of nodes) { + const usage = nodeResourcePercentages(node); + if (usage.ramFreeMb < input.ramMb || usage.diskFreeGb < input.diskGb) { + continue; + } + + const policy = await resolvePlacementPolicy(node.id, input.applicationGroupId, input.productType); + + if (policy?.min_free_ram_mb && usage.ramFreeMb < policy.min_free_ram_mb) { + continue; + } + if (policy?.min_free_disk_gb && usage.diskFreeGb < policy.min_free_disk_gb) { + continue; + } + if (policy?.max_vms && node.vm_count >= policy.max_vms) { + continue; + } + + const cpuWeight = policy?.cpu_weight ?? 40; + const ramWeight = policy?.ram_weight ?? 30; + const diskWeight = policy?.disk_weight ?? 20; + const vmCountWeight = policy?.vm_count_weight ?? 10; + + const score = + usage.cpuFreePct * cpuWeight + + usage.ramFreePct * ramWeight + + usage.diskFreePct * diskWeight - + node.vm_count * vmCountWeight; + + if (!bestNode || score > bestNode.score) { + bestNode = { + id: node.id, + hostname: node.hostname, + score + }; + } + } + + if (!bestNode) { + throw new HttpError(400, "No node satisfies requested resource constraints", "NO_SUITABLE_NODE"); + } + + return bestNode; +} + +async function minimumVmidFromSettings() { + const setting = await prisma.setting.findUnique({ where: { key: "provisioning" } }); + const value = setting?.value as Prisma.JsonObject | undefined; + const minVmid = value && typeof value.min_vmid === "number" ? Number(value.min_vmid) : 100; + return Number.isFinite(minVmid) ? Math.max(100, Math.floor(minVmid)) : 100; +} + +export async function allocateVmid(nodeHostname: string, applicationGroupId?: string) { + const ranges = await prisma.vmIdRange.findMany({ + where: { + node_hostname: nodeHostname, + is_active: true, + OR: [{ application_group_id: applicationGroupId }, { application_group_id: null }] + }, + orderBy: [ + { application_group_id: "desc" }, + { range_start: "asc" } + ] + }); + + const usedVmids = new Set( + (await prisma.virtualMachine.findMany({ + where: { node: nodeHostname }, + select: { vmid: true } + })).map((item) => item.vmid) + ); + + for (const range of ranges) { + let candidate = Math.max(range.next_vmid, range.range_start); + while (candidate <= range.range_end && usedVmids.has(candidate)) { + candidate += 1; + } + + if (candidate <= range.range_end) { + await prisma.vmIdRange.update({ + where: { id: range.id }, + data: { + next_vmid: candidate + 1 + } + }); + return candidate; + } + } + + let fallback = await minimumVmidFromSettings(); + while (usedVmids.has(fallback)) { + fallback += 1; + } + return fallback; +} + +function deriveServiceResources(input: ServiceCreateInput, billingPlan: { cpu_cores: number; ram_mb: number; disk_gb: number } | null) { + const defaultCpu = billingPlan?.cpu_cores ?? 2; + const defaultRam = billingPlan?.ram_mb ?? 2048; + const defaultDisk = billingPlan?.disk_gb ?? 40; + + return { + cpuCores: extractNumberOption(input.packageOptions, "cpu_cores", defaultCpu), + ramMb: extractNumberOption(input.packageOptions, "ram_mb", defaultRam), + diskGb: extractNumberOption(input.packageOptions, "disk_gb", defaultDisk) + }; +} + +export async function createProvisionedService(input: ServiceCreateInput) { + const tenant = await prisma.tenant.findUnique({ where: { id: input.tenantId } }); + if (!tenant) { + throw new HttpError(404, "Tenant not found", "TENANT_NOT_FOUND"); + } + + if (input.applicationGroupId) { + const group = await prisma.applicationGroup.findUnique({ where: { id: input.applicationGroupId } }); + if (!group) { + throw new HttpError(404, "Application group not found", "APPLICATION_GROUP_NOT_FOUND"); + } + } + + const template = input.templateId + ? await prisma.appTemplate.findUnique({ where: { id: input.templateId } }) + : null; + + if (input.templateId && !template) { + throw new HttpError(404, "Template not found", "TEMPLATE_NOT_FOUND"); + } + + if (template?.virtualization_type && template.virtualization_type !== input.virtualizationType) { + throw new HttpError( + 400, + `Template virtualization type (${template.virtualization_type}) does not match requested service type (${input.virtualizationType})`, + "TEMPLATE_VM_TYPE_MISMATCH" + ); + } + + if (template?.template_type === TemplateType.KVM_TEMPLATE && input.virtualizationType !== VmType.QEMU) { + throw new HttpError(400, "KVM template requires QEMU virtualization", "TEMPLATE_VM_TYPE_MISMATCH"); + } + + if (template?.template_type === TemplateType.LXC_TEMPLATE && input.virtualizationType !== VmType.LXC) { + throw new HttpError(400, "LXC template requires LXC virtualization", "TEMPLATE_VM_TYPE_MISMATCH"); + } + + if (template?.template_type === TemplateType.ISO_IMAGE && input.virtualizationType !== VmType.QEMU) { + throw new HttpError(400, "ISO template requires QEMU virtualization", "TEMPLATE_VM_TYPE_MISMATCH"); + } + + if (template?.template_type === TemplateType.ARCHIVE && input.virtualizationType !== VmType.LXC) { + throw new HttpError(400, "Archive template requires LXC virtualization", "TEMPLATE_VM_TYPE_MISMATCH"); + } + + const billingPlan = input.billingPlanId + ? await prisma.billingPlan.findUnique({ + where: { id: input.billingPlanId }, + select: { id: true, cpu_cores: true, ram_mb: true, disk_gb: true } + }) + : null; + + if (input.billingPlanId && !billingPlan) { + throw new HttpError(404, "Billing plan not found", "BILLING_PLAN_NOT_FOUND"); + } + + const resources = deriveServiceResources(input, billingPlan); + const packageOptionsRecord = parsePackageOptionsObject(input.packageOptions); + const vmCount = Math.max(1, Math.min(input.vmCount, input.productType === ProductType.CLOUD ? 20 : 1)); + + const serviceGroupId = vmCount > 1 ? `svcgrp_${Date.now()}_${Math.floor(Math.random() * 1000)}` : null; + const created: ProvisionedService[] = []; + + for (let index = 0; index < vmCount; index += 1) { + const selectedNode = input.autoNode || !input.targetNode + ? await chooseNodeForProvisioning({ + productType: input.productType, + applicationGroupId: input.applicationGroupId, + cpuCores: resources.cpuCores, + ramMb: resources.ramMb, + diskGb: resources.diskGb + }) + : null; + + const nodeHostname = selectedNode?.hostname ?? input.targetNode; + if (!nodeHostname) { + throw new HttpError(400, "targetNode is required when autoNode=false", "TARGET_NODE_REQUIRED"); + } + if (!selectedNode) { + const manualNode = await prisma.proxmoxNode.findUnique({ + where: { hostname: nodeHostname }, + select: { hostname: true, status: true, is_connected: true } + }); + if (!manualNode || manualNode.status !== "ONLINE" || !manualNode.is_connected) { + throw new HttpError(400, `Target node ${nodeHostname} is not online`, "TARGET_NODE_OFFLINE"); + } + } + + const vmid = await allocateVmid(nodeHostname, input.applicationGroupId); + const vmName = vmCount > 1 ? `${input.name}-${index + 1}` : input.name; + + const vm = await prisma.virtualMachine.create({ + data: { + name: vmName, + vmid, + status: "STOPPED", + type: input.virtualizationType, + node: nodeHostname, + tenant_id: input.tenantId, + billing_plan_id: billingPlan?.id, + os_template: input.templateId, + cpu_cores: resources.cpuCores, + ram_mb: resources.ramMb, + disk_gb: resources.diskGb + } + }); + + const service = await prisma.provisionedService.create({ + data: { + service_group_id: serviceGroupId, + vm_id: vm.id, + tenant_id: input.tenantId, + product_type: input.productType, + lifecycle_status: ServiceLifecycleStatus.ACTIVE, + application_group_id: input.applicationGroupId, + template_id: input.templateId, + package_options: input.packageOptions ?? {}, + created_by: input.createdBy + } + }); + + const task = await createOperationTask({ + taskType: OperationTaskType.VM_CREATE, + vm: { + id: vm.id, + name: vm.name, + node: vm.node + }, + requestedBy: input.createdBy, + payload: { + service_id: service.id, + product_type: input.productType, + template_id: input.templateId, + template_type: template?.template_type, + application_group_id: input.applicationGroupId + } + }); + + await markOperationTaskRunning(task.id); + + try { + const provisionResult = await provisionVmFromTemplate({ + node: vm.node, + vmid: vm.vmid, + name: vm.name, + type: vm.type, + cpuCores: resources.cpuCores, + ramMb: resources.ramMb, + diskGb: resources.diskGb, + template: template + ? { + id: template.id, + name: template.name, + templateType: template.template_type, + virtualizationType: template.virtualization_type, + source: template.source, + defaultCloudInit: template.default_cloud_init, + metadata: template.metadata + } + : undefined, + packageOptions: packageOptionsRecord + }); + + const resolvedUpid = + provisionResult.startUpid ?? provisionResult.mainUpid ?? provisionResult.configUpid ?? undefined; + + await prisma.virtualMachine.update({ + where: { id: vm.id }, + data: { + status: provisionResult.started ? "RUNNING" : "STOPPED", + proxmox_upid: resolvedUpid + } + }); + + const updatedService = await prisma.provisionedService.update({ + where: { id: service.id }, + data: { + lifecycle_status: ServiceLifecycleStatus.ACTIVE, + suspended_reason: null + } + }); + + await markOperationTaskSuccess( + task.id, + { + vm_id: vm.id, + service_id: service.id, + node: vm.node, + vmid, + provisioning: { + orchestration: provisionResult.orchestration, + notes: provisionResult.notes, + main_upid: provisionResult.mainUpid, + config_upid: provisionResult.configUpid, + ha_upid: provisionResult.haUpid, + start_upid: provisionResult.startUpid + } + }, + resolvedUpid + ); + + created.push(updatedService); + } catch (error) { + const message = error instanceof Error ? error.message : "Provisioning operation failed"; + + await prisma.virtualMachine.update({ + where: { id: vm.id }, + data: { + status: "ERROR" + } + }); + + await prisma.provisionedService.update({ + where: { id: service.id }, + data: { + lifecycle_status: ServiceLifecycleStatus.SUSPENDED, + suspended_reason: message + } + }); + + await markOperationTaskFailed(task.id, message); + throw new HttpError(500, `Provisioning failed for ${vm.name}: ${message}`, "PROVISIONING_FAILED"); + } + } + + return created; +} + +export async function listProvisionedServices(input: ServiceListInput) { + const where: Prisma.ProvisionedServiceWhereInput = {}; + if (input.tenantId) where.tenant_id = input.tenantId; + if (input.lifecycleStatus) where.lifecycle_status = input.lifecycleStatus; + + const limit = Math.min(Math.max(input.limit ?? 50, 1), 200); + const offset = Math.max(input.offset ?? 0, 0); + + const [data, total] = await Promise.all([ + prisma.provisionedService.findMany({ + where, + include: { + vm: true, + tenant: { + select: { + id: true, + name: true, + slug: true + } + }, + group: { + select: { + id: true, + name: true + } + }, + template: { + select: { + id: true, + name: true, + template_type: true + } + } + }, + orderBy: { + created_at: "desc" + }, + take: limit, + skip: offset + }), + prisma.provisionedService.count({ where }) + ]); + + return { + data, + meta: { + total, + limit, + offset + } + }; +} + +async function fetchService(serviceId: string) { + const service = await prisma.provisionedService.findUnique({ + where: { id: serviceId }, + include: { + vm: true + } + }); + + if (!service) { + throw new HttpError(404, "Provisioned service not found", "SERVICE_NOT_FOUND"); + } + + return service; +} + +export async function suspendProvisionedService(input: ServiceLifecycleInput) { + const service = await fetchService(input.serviceId); + if (service.lifecycle_status === ServiceLifecycleStatus.TERMINATED) { + throw new HttpError(400, "Terminated service cannot be suspended", "SERVICE_TERMINATED"); + } + + if (service.lifecycle_status === ServiceLifecycleStatus.SUSPENDED) { + return service; + } + + const type = service.vm.type === VmType.LXC ? "lxc" : "qemu"; + const task = await createOperationTask({ + taskType: OperationTaskType.VM_POWER, + vm: { + id: service.vm.id, + name: service.vm.name, + node: service.vm.node + }, + requestedBy: input.actorEmail, + payload: { + lifecycle_action: "suspend", + reason: input.reason + } + }); + + await markOperationTaskRunning(task.id); + + try { + const upid = await stopVm(service.vm.node, service.vm.vmid, type); + await prisma.virtualMachine.update({ + where: { id: service.vm.id }, + data: { + status: "STOPPED", + proxmox_upid: upid ?? undefined + } + }); + + const updated = await prisma.provisionedService.update({ + where: { id: service.id }, + data: { + lifecycle_status: ServiceLifecycleStatus.SUSPENDED, + suspended_reason: input.reason + } + }); + + await markOperationTaskSuccess(task.id, { + service_id: updated.id, + lifecycle_status: updated.lifecycle_status, + action: "suspend", + ...(upid ? { upid } : {}) + }); + + return updated; + } catch (error) { + const message = error instanceof Error ? error.message : "Suspend operation failed"; + await markOperationTaskFailed(task.id, message); + throw error; + } +} + +export async function unsuspendProvisionedService(input: ServiceLifecycleInput) { + const service = await fetchService(input.serviceId); + if (service.lifecycle_status === ServiceLifecycleStatus.TERMINATED) { + throw new HttpError(400, "Terminated service cannot be unsuspended", "SERVICE_TERMINATED"); + } + + if (service.lifecycle_status === ServiceLifecycleStatus.ACTIVE) { + return service; + } + + const type = service.vm.type === VmType.LXC ? "lxc" : "qemu"; + const task = await createOperationTask({ + taskType: OperationTaskType.VM_POWER, + vm: { + id: service.vm.id, + name: service.vm.name, + node: service.vm.node + }, + requestedBy: input.actorEmail, + payload: { + lifecycle_action: "unsuspend" + } + }); + + await markOperationTaskRunning(task.id); + + try { + const upid = await startVm(service.vm.node, service.vm.vmid, type); + await prisma.virtualMachine.update({ + where: { id: service.vm.id }, + data: { + status: "RUNNING", + proxmox_upid: upid ?? undefined + } + }); + + const updated = await prisma.provisionedService.update({ + where: { id: service.id }, + data: { + lifecycle_status: ServiceLifecycleStatus.ACTIVE, + suspended_reason: null + } + }); + + await markOperationTaskSuccess(task.id, { + service_id: updated.id, + lifecycle_status: updated.lifecycle_status, + action: "unsuspend", + ...(upid ? { upid } : {}) + }); + + return updated; + } catch (error) { + const message = error instanceof Error ? error.message : "Unsuspend operation failed"; + await markOperationTaskFailed(task.id, message); + throw error; + } +} + +export async function terminateProvisionedService(input: ServiceLifecycleInput) { + const service = await fetchService(input.serviceId); + if (service.lifecycle_status === ServiceLifecycleStatus.TERMINATED) { + return service; + } + + const type = service.vm.type === VmType.LXC ? "lxc" : "qemu"; + + const task = await createOperationTask({ + taskType: OperationTaskType.VM_DELETE, + vm: { + id: service.vm.id, + name: service.vm.name, + node: service.vm.node + }, + requestedBy: input.actorEmail, + payload: { + lifecycle_action: "terminate", + hard_delete: input.hardDelete ?? false, + reason: input.reason + } + }); + + await markOperationTaskRunning(task.id); + + try { + let upid: string | undefined; + + if (input.hardDelete) { + upid = await deleteVm(service.vm.node, service.vm.vmid, type); + } else { + upid = await stopVm(service.vm.node, service.vm.vmid, type); + } + + await prisma.virtualMachine.update({ + where: { id: service.vm.id }, + data: { + status: "STOPPED", + proxmox_upid: upid ?? undefined + } + }); + + const updated = await prisma.provisionedService.update({ + where: { id: service.id }, + data: { + lifecycle_status: ServiceLifecycleStatus.TERMINATED, + terminated_at: new Date(), + suspended_reason: input.reason + } + }); + + await markOperationTaskSuccess(task.id, { + service_id: updated.id, + lifecycle_status: updated.lifecycle_status, + action: "terminate", + hard_delete: Boolean(input.hardDelete), + ...(upid ? { upid } : {}) + }); + + return updated; + } catch (error) { + const message = error instanceof Error ? error.message : "Terminate operation failed"; + await markOperationTaskFailed(task.id, message); + throw error; + } +} + +export async function updateProvisionedServicePackage(input: PackageUpdateInput) { + const service = await fetchService(input.serviceId); + + const cpuCores = extractNumberOption(input.packageOptions, "cpu_cores", service.vm.cpu_cores); + const ramMb = extractNumberOption(input.packageOptions, "ram_mb", service.vm.ram_mb); + const diskGb = extractNumberOption(input.packageOptions, "disk_gb", service.vm.disk_gb); + + const type = service.vm.type === VmType.LXC ? "lxc" : "qemu"; + + const task = await createOperationTask({ + taskType: OperationTaskType.VM_CONFIG, + vm: { + id: service.vm.id, + name: service.vm.name, + node: service.vm.node + }, + requestedBy: input.actorEmail, + payload: { + lifecycle_action: "package_update", + package_options: input.packageOptions + } + }); + + await markOperationTaskRunning(task.id); + + try { + const proxmoxConfig: Record = { + cores: cpuCores, + memory: ramMb + }; + + const upid = await updateVmConfiguration(service.vm.node, service.vm.vmid, type, proxmoxConfig); + + await prisma.virtualMachine.update({ + where: { id: service.vm.id }, + data: { + cpu_cores: cpuCores, + ram_mb: ramMb, + disk_gb: diskGb, + proxmox_upid: upid ?? undefined + } + }); + + const updated = await prisma.provisionedService.update({ + where: { id: service.id }, + data: { + package_options: input.packageOptions + } + }); + + await markOperationTaskSuccess(task.id, { + service_id: updated.id, + action: "package_update", + package_options: input.packageOptions, + ...(upid ? { upid } : {}) + }); + + return updated; + } catch (error) { + const message = error instanceof Error ? error.message : "Package update failed"; + await markOperationTaskFailed(task.id, message); + throw error; + } +} + +export async function createTemplate(input: { + name: string; + slug?: string; + templateType: "APPLICATION" | "KVM_TEMPLATE" | "LXC_TEMPLATE" | "ISO_IMAGE" | "ARCHIVE"; + virtualizationType?: VmType; + source?: string; + description?: string; + defaultCloudInit?: string; + metadata?: Prisma.InputJsonValue; +}) { + const slug = input.slug && input.slug.length > 0 ? normalizeSlug(input.slug) : normalizeSlug(input.name); + return prisma.appTemplate.create({ + data: { + name: input.name, + slug, + template_type: input.templateType, + virtualization_type: input.virtualizationType, + source: input.source, + description: input.description, + default_cloud_init: input.defaultCloudInit, + metadata: input.metadata ?? {} + } + }); +} + +export async function updateTemplate(templateId: string, input: Partial<{ + name: string; + slug: string; + source: string; + description: string; + defaultCloudInit: string; + isActive: boolean; + metadata: Prisma.InputJsonValue; +}>) { + return prisma.appTemplate.update({ + where: { id: templateId }, + data: { + name: input.name, + slug: input.slug ? normalizeSlug(input.slug) : undefined, + source: input.source, + description: input.description, + default_cloud_init: input.defaultCloudInit, + is_active: input.isActive, + metadata: input.metadata + } + }); +} + +export async function deleteTemplate(templateId: string) { + return prisma.appTemplate.delete({ where: { id: templateId } }); +} + +export async function listTemplates(input?: { templateType?: string; isActive?: boolean }) { + return prisma.appTemplate.findMany({ + where: { + template_type: input?.templateType as any, + is_active: input?.isActive + }, + orderBy: [{ is_active: "desc" }, { created_at: "desc" }] + }); +} + +export async function createApplicationGroup(input: { name: string; slug?: string; description?: string }) { + const slug = input.slug && input.slug.length > 0 ? normalizeSlug(input.slug) : normalizeSlug(input.name); + return prisma.applicationGroup.create({ + data: { + name: input.name, + slug, + description: input.description + } + }); +} + +export async function updateApplicationGroup(groupId: string, input: Partial<{ name: string; slug: string; description: string; isActive: boolean }>) { + return prisma.applicationGroup.update({ + where: { id: groupId }, + data: { + name: input.name, + slug: input.slug ? normalizeSlug(input.slug) : undefined, + description: input.description, + is_active: input.isActive + } + }); +} + +export async function deleteApplicationGroup(groupId: string) { + return prisma.applicationGroup.delete({ where: { id: groupId } }); +} + +export async function listApplicationGroups() { + return prisma.applicationGroup.findMany({ + include: { + templates: { + include: { + template: true + }, + orderBy: { + priority: "asc" + } + }, + placement_policies: true, + vmid_ranges: true + }, + orderBy: [{ is_active: "desc" }, { created_at: "desc" }] + }); +} + +export async function setApplicationGroupTemplates( + groupId: string, + templates: Array<{ templateId: string; priority?: number }> +) { + await prisma.applicationGroupTemplate.deleteMany({ + where: { + group_id: groupId + } + }); + + if (templates.length === 0) { + return []; + } + + await prisma.applicationGroupTemplate.createMany({ + data: templates.map((item, index) => ({ + group_id: groupId, + template_id: item.templateId, + priority: item.priority ?? (index + 1) * 10 + })) + }); + + return prisma.applicationGroupTemplate.findMany({ + where: { group_id: groupId }, + include: { template: true }, + orderBy: { priority: "asc" } + }); +} + +export async function createPlacementPolicy(input: { + groupId?: string; + nodeId?: string; + productType?: ProductType; + cpuWeight?: number; + ramWeight?: number; + diskWeight?: number; + vmCountWeight?: number; + maxVms?: number; + minFreeRamMb?: number; + minFreeDiskGb?: number; +}) { + return prisma.nodePlacementPolicy.create({ + data: { + group_id: input.groupId, + node_id: input.nodeId, + product_type: input.productType, + cpu_weight: input.cpuWeight ?? 40, + ram_weight: input.ramWeight ?? 30, + disk_weight: input.diskWeight ?? 20, + vm_count_weight: input.vmCountWeight ?? 10, + max_vms: input.maxVms, + min_free_ram_mb: input.minFreeRamMb, + min_free_disk_gb: input.minFreeDiskGb + } + }); +} + +export async function updatePlacementPolicy( + policyId: string, + input: Partial<{ + cpuWeight: number; + ramWeight: number; + diskWeight: number; + vmCountWeight: number; + maxVms: number | null; + minFreeRamMb: number | null; + minFreeDiskGb: number | null; + isActive: boolean; + }> +) { + return prisma.nodePlacementPolicy.update({ + where: { id: policyId }, + data: { + cpu_weight: input.cpuWeight, + ram_weight: input.ramWeight, + disk_weight: input.diskWeight, + vm_count_weight: input.vmCountWeight, + max_vms: input.maxVms, + min_free_ram_mb: input.minFreeRamMb, + min_free_disk_gb: input.minFreeDiskGb, + is_active: input.isActive + } + }); +} + +export async function deletePlacementPolicy(policyId: string) { + return prisma.nodePlacementPolicy.delete({ where: { id: policyId } }); +} + +export async function listPlacementPolicies() { + return prisma.nodePlacementPolicy.findMany({ + include: { + group: true, + node: true + }, + orderBy: [{ is_active: "desc" }, { created_at: "desc" }] + }); +} + +export async function createVmIdRange(input: { + nodeId?: string; + nodeHostname: string; + applicationGroupId?: string; + rangeStart: number; + rangeEnd: number; + nextVmid?: number; +}) { + if (input.rangeEnd < input.rangeStart) { + throw new HttpError(400, "rangeEnd must be >= rangeStart", "INVALID_VMID_RANGE"); + } + + const next = input.nextVmid ?? input.rangeStart; + if (next < input.rangeStart || next > input.rangeEnd) { + throw new HttpError(400, "nextVmid must be within the configured range", "INVALID_VMID_RANGE"); + } + + return prisma.vmIdRange.create({ + data: { + node_id: input.nodeId, + node_hostname: input.nodeHostname, + application_group_id: input.applicationGroupId, + range_start: input.rangeStart, + range_end: input.rangeEnd, + next_vmid: next + } + }); +} + +export async function updateVmIdRange( + rangeId: string, + input: Partial<{ + rangeStart: number; + rangeEnd: number; + nextVmid: number; + isActive: boolean; + }> +) { + const existing = await prisma.vmIdRange.findUnique({ where: { id: rangeId } }); + if (!existing) { + throw new HttpError(404, "VMID range not found", "VMID_RANGE_NOT_FOUND"); + } + + const rangeStart = input.rangeStart ?? existing.range_start; + const rangeEnd = input.rangeEnd ?? existing.range_end; + const nextVmid = input.nextVmid ?? existing.next_vmid; + + if (rangeEnd < rangeStart) { + throw new HttpError(400, "rangeEnd must be >= rangeStart", "INVALID_VMID_RANGE"); + } + + if (nextVmid < rangeStart || nextVmid > rangeEnd) { + throw new HttpError(400, "nextVmid must be within the configured range", "INVALID_VMID_RANGE"); + } + + return prisma.vmIdRange.update({ + where: { id: rangeId }, + data: { + range_start: input.rangeStart, + range_end: input.rangeEnd, + next_vmid: input.nextVmid, + is_active: input.isActive + } + }); +} + +export async function deleteVmIdRange(rangeId: string) { + return prisma.vmIdRange.delete({ where: { id: rangeId } }); +} + +export async function listVmIdRanges() { + return prisma.vmIdRange.findMany({ + include: { + group: true, + node: true + }, + orderBy: [{ is_active: "desc" }, { node_hostname: "asc" }, { range_start: "asc" }] + }); +} diff --git a/backend/src/services/proxmox.service.ts b/backend/src/services/proxmox.service.ts new file mode 100644 index 0000000..008002b --- /dev/null +++ b/backend/src/services/proxmox.service.ts @@ -0,0 +1,1451 @@ +import axios, { type AxiosInstance } from "axios"; +import https from "https"; +import { TemplateType, VmType } from "@prisma/client"; +import { prisma } from "../lib/prisma"; +import { env } from "../config/env"; +import { HttpError } from "../lib/http-error"; + +type ProxmoxSettings = { + host: string; + port: number; + username: string; + token_id: string; + token_secret: string; + verify_ssl: boolean; +}; + +function normalizeProxmoxSettings(input: ProxmoxSettings): ProxmoxSettings { + const normalized: ProxmoxSettings = { + ...input, + host: input.host.trim(), + username: input.username.trim(), + token_id: input.token_id.trim() + }; + + normalized.host = normalized.host.replace(/^https?:\/\//i, "").replace(/\/+$/, ""); + if (normalized.host.includes("/")) { + normalized.host = normalized.host.split("/")[0]; + } + + // Accept token_id in either "tokenName" format or "username@realm!tokenName" format. + if (normalized.token_id.includes("!")) { + const separator = normalized.token_id.lastIndexOf("!"); + const tokenUser = normalized.token_id.slice(0, separator).trim(); + const tokenName = normalized.token_id.slice(separator + 1).trim(); + + if (tokenUser && tokenName) { + if (!normalized.username.includes("@") || normalized.username === "root") { + normalized.username = tokenUser; + } + if (normalized.token_id.startsWith(`${normalized.username}!`) || normalized.username === tokenUser) { + normalized.token_id = tokenName; + } + } + } + + return normalized; +} + +async function getProxmoxSettings(): Promise { + const setting = await prisma.setting.findUnique({ where: { key: "proxmox" } }); + if (!setting) { + throw new HttpError(400, "Proxmox settings have not been configured", "PROXMOX_NOT_CONFIGURED"); + } + + const value = setting.value as Partial; + if (!value.host || !value.username || !value.token_id || !value.token_secret) { + throw new HttpError(400, "Proxmox credentials are incomplete", "PROXMOX_INCOMPLETE_CONFIG"); + } + + const parsed: ProxmoxSettings = { + host: value.host, + port: value.port ?? 8006, + username: value.username, + token_id: value.token_id, + token_secret: value.token_secret, + verify_ssl: value.verify_ssl ?? true + }; + + return normalizeProxmoxSettings(parsed); +} + +async function createClient(): Promise { + const config = await getProxmoxSettings(); + const token = `${config.username}!${config.token_id}=${config.token_secret}`; + return axios.create({ + baseURL: `https://${config.host}:${config.port}/api2/json`, + timeout: env.PROXMOX_TIMEOUT_MS, + headers: { + Authorization: `PVEAPIToken=${token}` + }, + httpsAgent: new https.Agent({ + rejectUnauthorized: config.verify_ssl + }) + }); +} + +function sleep(ms: number) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +export async function waitForProxmoxTask( + node: string, + upid: string, + options?: { + timeoutMs?: number; + pollMs?: number; + } +) { + const timeoutMs = Math.max(10_000, options?.timeoutMs ?? 180_000); + const pollMs = Math.max(500, options?.pollMs ?? 1_500); + const deadline = Date.now() + timeoutMs; + + const client = await createClient(); + const encodedUpid = encodeURIComponent(upid); + + while (Date.now() <= deadline) { + const response = await client.get(`/nodes/${node}/tasks/${encodedUpid}/status`); + const status = (response.data?.data ?? {}) as { + status?: string; + exitstatus?: string; + [key: string]: unknown; + }; + + if (status.status === "stopped") { + if (status.exitstatus && status.exitstatus !== "OK") { + throw new HttpError(502, `Proxmox task failed: ${status.exitstatus}`, "PROXMOX_TASK_FAILED", { + node, + upid, + exitstatus: status.exitstatus + }); + } + + return status; + } + + await sleep(pollMs); + } + + throw new HttpError( + 504, + `Timed out waiting for Proxmox task completion (${upid})`, + "PROXMOX_TASK_TIMEOUT", + { node, upid, timeoutMs } + ); +} + +export async function listProxmoxNodes() { + const client = await createClient(); + const response = await client.get("/nodes"); + return response.data?.data ?? []; +} + +export async function listProxmoxVms(node: string) { + const client = await createClient(); + const [qemu, lxc] = await Promise.all([ + client.get(`/nodes/${node}/qemu`), + client.get(`/nodes/${node}/lxc`) + ]); + const qemuData = (qemu.data?.data ?? []).map((item: any) => ({ ...item, type: "qemu" })); + const lxcData = (lxc.data?.data ?? []).map((item: any) => ({ ...item, type: "lxc" })); + return [...qemuData, ...lxcData]; +} + +function toMbpsFromBytesPerSecond(raw: unknown) { + const value = Number(raw); + if (!Number.isFinite(value) || value <= 0) return 0; + return Number((value / (1024 * 1024)).toFixed(3)); +} + +function toPercentFromFraction(raw: unknown) { + const value = Number(raw); + if (!Number.isFinite(value) || value <= 0) return 0; + return value <= 1 ? Number((value * 100).toFixed(2)) : Number(value.toFixed(2)); +} + +function pickNumericValue(record: Record, keys: string[]) { + for (const key of keys) { + if (!(key in record)) continue; + const value = Number(record[key]); + if (Number.isFinite(value)) return value; + } + return 0; +} + +export async function vmRuntimeStats(node: string, vmid: number, type: "qemu" | "lxc") { + const client = await createClient(); + const response = await client.get(`/nodes/${node}/${type}/${vmid}/status/current`); + const raw = (response.data?.data ?? {}) as Record; + + const diskRead = pickNumericValue(raw, ["diskread", "disk_read", "disk-read"]); + const diskWrite = pickNumericValue(raw, ["diskwrite", "disk_write", "disk-write"]); + const networkIn = pickNumericValue(raw, ["netin", "net_in", "net-in"]); + const networkOut = pickNumericValue(raw, ["netout", "net_out", "net-out"]); + + const cpuUsagePct = toPercentFromFraction(raw.cpu); + const mem = pickNumericValue(raw, ["mem"]); + const maxMem = pickNumericValue(raw, ["maxmem", "max_mem"]); + const ramUsagePct = maxMem > 0 ? Number(((mem / maxMem) * 100).toFixed(2)) : 0; + + return { + cpu_usage_pct: cpuUsagePct, + ram_usage_pct: ramUsagePct, + disk_io_read_mbps: toMbpsFromBytesPerSecond(diskRead), + disk_io_write_mbps: toMbpsFromBytesPerSecond(diskWrite), + network_in_mbps: toMbpsFromBytesPerSecond(networkIn), + network_out_mbps: toMbpsFromBytesPerSecond(networkOut) + }; +} + +export type ProxmoxGraphTimeframe = "hour" | "day" | "week" | "month" | "year"; + +type VmUsageGraphPoint = { + timestamp: string; + cpu_pct: number; + ram_pct: number; + disk_usage_pct: number; + network_in_mbps: number; + network_out_mbps: number; + disk_io_read_mbps: number; + disk_io_write_mbps: number; +}; + +type NodeUsageGraphPoint = { + timestamp: string; + cpu_pct: number; + ram_pct: number; + disk_usage_pct: number; + network_in_mbps: number; + network_out_mbps: number; + io_wait_pct: number; +}; + +type ClusterUsageGraphPoint = NodeUsageGraphPoint & { + reporting_nodes: number; +}; + +function clampPercent(value: number) { + if (!Number.isFinite(value)) return 0; + return Math.max(0, Math.min(100, Number(value.toFixed(2)))); +} + +function generateSyntheticTimeline(timeframe: ProxmoxGraphTimeframe) { + const now = Date.now(); + const config: Record = { + hour: { points: 60, minutes: 1 }, + day: { points: 48, minutes: 30 }, + week: { points: 56, minutes: 180 }, + month: { points: 60, minutes: 720 }, + year: { points: 52, minutes: 10080 } + }; + + const selected = config[timeframe]; + return Array.from({ length: selected.points }, (_, index) => { + const offset = (selected.points - 1 - index) * selected.minutes * 60 * 1000; + return new Date(now - offset); + }); +} + +function aggregateMetrics(points: VmUsageGraphPoint[]) { + if (points.length === 0) { + return { + sample_count: 0, + avg_cpu_pct: 0, + peak_cpu_pct: 0, + avg_ram_pct: 0, + peak_ram_pct: 0, + avg_disk_usage_pct: 0, + peak_disk_usage_pct: 0, + peak_network_in_mbps: 0, + peak_network_out_mbps: 0, + peak_disk_io_read_mbps: 0, + peak_disk_io_write_mbps: 0 + }; + } + + const totals = points.reduce( + (acc, point) => { + acc.cpu += point.cpu_pct; + acc.ram += point.ram_pct; + acc.disk += point.disk_usage_pct; + acc.peakCpu = Math.max(acc.peakCpu, point.cpu_pct); + acc.peakRam = Math.max(acc.peakRam, point.ram_pct); + acc.peakDisk = Math.max(acc.peakDisk, point.disk_usage_pct); + acc.peakNetIn = Math.max(acc.peakNetIn, point.network_in_mbps); + acc.peakNetOut = Math.max(acc.peakNetOut, point.network_out_mbps); + acc.peakDiskRead = Math.max(acc.peakDiskRead, point.disk_io_read_mbps); + acc.peakDiskWrite = Math.max(acc.peakDiskWrite, point.disk_io_write_mbps); + return acc; + }, + { + cpu: 0, + ram: 0, + disk: 0, + peakCpu: 0, + peakRam: 0, + peakDisk: 0, + peakNetIn: 0, + peakNetOut: 0, + peakDiskRead: 0, + peakDiskWrite: 0 + } + ); + + return { + sample_count: points.length, + avg_cpu_pct: Number((totals.cpu / points.length).toFixed(2)), + peak_cpu_pct: Number(totals.peakCpu.toFixed(2)), + avg_ram_pct: Number((totals.ram / points.length).toFixed(2)), + peak_ram_pct: Number(totals.peakRam.toFixed(2)), + avg_disk_usage_pct: Number((totals.disk / points.length).toFixed(2)), + peak_disk_usage_pct: Number(totals.peakDisk.toFixed(2)), + peak_network_in_mbps: Number(totals.peakNetIn.toFixed(2)), + peak_network_out_mbps: Number(totals.peakNetOut.toFixed(2)), + peak_disk_io_read_mbps: Number(totals.peakDiskRead.toFixed(2)), + peak_disk_io_write_mbps: Number(totals.peakDiskWrite.toFixed(2)) + }; +} + +function aggregateNodeMetrics(points: NodeUsageGraphPoint[]) { + if (points.length === 0) { + return { + sample_count: 0, + avg_cpu_pct: 0, + peak_cpu_pct: 0, + avg_ram_pct: 0, + peak_ram_pct: 0, + avg_disk_usage_pct: 0, + peak_disk_usage_pct: 0, + avg_io_wait_pct: 0, + peak_io_wait_pct: 0, + peak_network_in_mbps: 0, + peak_network_out_mbps: 0 + }; + } + + const totals = points.reduce( + (acc, point) => { + acc.cpu += point.cpu_pct; + acc.ram += point.ram_pct; + acc.disk += point.disk_usage_pct; + acc.ioWait += point.io_wait_pct; + acc.peakCpu = Math.max(acc.peakCpu, point.cpu_pct); + acc.peakRam = Math.max(acc.peakRam, point.ram_pct); + acc.peakDisk = Math.max(acc.peakDisk, point.disk_usage_pct); + acc.peakIoWait = Math.max(acc.peakIoWait, point.io_wait_pct); + acc.peakNetIn = Math.max(acc.peakNetIn, point.network_in_mbps); + acc.peakNetOut = Math.max(acc.peakNetOut, point.network_out_mbps); + return acc; + }, + { + cpu: 0, + ram: 0, + disk: 0, + ioWait: 0, + peakCpu: 0, + peakRam: 0, + peakDisk: 0, + peakIoWait: 0, + peakNetIn: 0, + peakNetOut: 0 + } + ); + + return { + sample_count: points.length, + avg_cpu_pct: Number((totals.cpu / points.length).toFixed(2)), + peak_cpu_pct: Number(totals.peakCpu.toFixed(2)), + avg_ram_pct: Number((totals.ram / points.length).toFixed(2)), + peak_ram_pct: Number(totals.peakRam.toFixed(2)), + avg_disk_usage_pct: Number((totals.disk / points.length).toFixed(2)), + peak_disk_usage_pct: Number(totals.peakDisk.toFixed(2)), + avg_io_wait_pct: Number((totals.ioWait / points.length).toFixed(2)), + peak_io_wait_pct: Number(totals.peakIoWait.toFixed(2)), + peak_network_in_mbps: Number(totals.peakNetIn.toFixed(2)), + peak_network_out_mbps: Number(totals.peakNetOut.toFixed(2)) + }; +} + +function aggregateClusterMetrics(points: ClusterUsageGraphPoint[]) { + if (points.length === 0) { + return { + sample_count: 0, + avg_cpu_pct: 0, + peak_cpu_pct: 0, + avg_ram_pct: 0, + peak_ram_pct: 0, + avg_disk_usage_pct: 0, + peak_disk_usage_pct: 0, + avg_io_wait_pct: 0, + peak_io_wait_pct: 0, + peak_network_in_mbps: 0, + peak_network_out_mbps: 0, + peak_reporting_nodes: 0 + }; + } + + const totals = points.reduce( + (acc, point) => { + acc.cpu += point.cpu_pct; + acc.ram += point.ram_pct; + acc.disk += point.disk_usage_pct; + acc.ioWait += point.io_wait_pct; + acc.peakCpu = Math.max(acc.peakCpu, point.cpu_pct); + acc.peakRam = Math.max(acc.peakRam, point.ram_pct); + acc.peakDisk = Math.max(acc.peakDisk, point.disk_usage_pct); + acc.peakIoWait = Math.max(acc.peakIoWait, point.io_wait_pct); + acc.peakNetIn = Math.max(acc.peakNetIn, point.network_in_mbps); + acc.peakNetOut = Math.max(acc.peakNetOut, point.network_out_mbps); + acc.peakNodes = Math.max(acc.peakNodes, point.reporting_nodes); + return acc; + }, + { + cpu: 0, + ram: 0, + disk: 0, + ioWait: 0, + peakCpu: 0, + peakRam: 0, + peakDisk: 0, + peakIoWait: 0, + peakNetIn: 0, + peakNetOut: 0, + peakNodes: 0 + } + ); + + return { + sample_count: points.length, + avg_cpu_pct: Number((totals.cpu / points.length).toFixed(2)), + peak_cpu_pct: Number(totals.peakCpu.toFixed(2)), + avg_ram_pct: Number((totals.ram / points.length).toFixed(2)), + peak_ram_pct: Number(totals.peakRam.toFixed(2)), + avg_disk_usage_pct: Number((totals.disk / points.length).toFixed(2)), + peak_disk_usage_pct: Number(totals.peakDisk.toFixed(2)), + avg_io_wait_pct: Number((totals.ioWait / points.length).toFixed(2)), + peak_io_wait_pct: Number(totals.peakIoWait.toFixed(2)), + peak_network_in_mbps: Number(totals.peakNetIn.toFixed(2)), + peak_network_out_mbps: Number(totals.peakNetOut.toFixed(2)), + peak_reporting_nodes: totals.peakNodes + }; +} + +function percentFromAbsolute(used: number, total: number) { + if (!Number.isFinite(used) || !Number.isFinite(total) || total <= 0) return 0; + return clampPercent((used / total) * 100); +} + +export async function vmUsageGraphs( + node: string, + vmid: number, + type: "qemu" | "lxc", + timeframe: ProxmoxGraphTimeframe, + fallback: { + cpu_usage: number; + ram_usage: number; + disk_usage: number; + network_in: number; + network_out: number; + } +) { + const client = await createClient(); + + try { + const response = await client.get(`/nodes/${node}/${type}/${vmid}/rrddata`, { + params: { + timeframe, + cf: "AVERAGE" + } + }); + + const raw = Array.isArray(response.data?.data) ? (response.data.data as Array>) : []; + const points: VmUsageGraphPoint[] = raw + .map((entry) => { + const ts = Number(entry.time); + if (!Number.isFinite(ts) || ts <= 0) return null; + + const mem = Number(entry.mem); + const maxmem = Number(entry.maxmem); + const disk = Number(entry.disk); + const maxdisk = Number(entry.maxdisk); + + return { + timestamp: new Date(ts * 1000).toISOString(), + cpu_pct: clampPercent(Number(entry.cpu) * 100), + ram_pct: clampPercent(maxmem > 0 ? (mem / maxmem) * 100 : fallback.ram_usage), + disk_usage_pct: clampPercent(maxdisk > 0 ? (disk / maxdisk) * 100 : fallback.disk_usage), + network_in_mbps: Number(toMbpsFromBytesPerSecond(entry.netin).toFixed(3)), + network_out_mbps: Number(toMbpsFromBytesPerSecond(entry.netout).toFixed(3)), + disk_io_read_mbps: Number(toMbpsFromBytesPerSecond(entry.diskread).toFixed(3)), + disk_io_write_mbps: Number(toMbpsFromBytesPerSecond(entry.diskwrite).toFixed(3)) + }; + }) + .filter((point): point is VmUsageGraphPoint => Boolean(point)); + + if (points.length > 0) { + return { + timeframe, + source: "proxmox_rrd", + points, + summary: aggregateMetrics(points) + }; + } + } catch { + // Fallback will be used when proxmox RRD data is unavailable. + } + + const timeline = generateSyntheticTimeline(timeframe); + const points = timeline.map((time, index) => { + const modifier = 1 + Math.sin(index / 3.2) * 0.08; + return { + timestamp: time.toISOString(), + cpu_pct: clampPercent(fallback.cpu_usage * modifier), + ram_pct: clampPercent(fallback.ram_usage * (1 + Math.cos(index / 4.1) * 0.05)), + disk_usage_pct: clampPercent(fallback.disk_usage * (1 + Math.sin(index / 5.3) * 0.03)), + network_in_mbps: Number((Math.max(0, fallback.network_in / 1024) * modifier).toFixed(3)), + network_out_mbps: Number((Math.max(0, fallback.network_out / 1024) * (1 + Math.cos(index / 3.7) * 0.1)).toFixed(3)), + disk_io_read_mbps: Number((Math.max(0.01, fallback.disk_usage / 35) * modifier).toFixed(3)), + disk_io_write_mbps: Number((Math.max(0.01, fallback.disk_usage / 40) * (1 + Math.sin(index / 4.8) * 0.08)).toFixed(3)) + }; + }); + + return { + timeframe, + source: "fallback_synthetic", + points, + summary: aggregateMetrics(points) + }; +} + +export async function nodeUsageGraphs( + node: string, + timeframe: ProxmoxGraphTimeframe, + fallback: { + cpu_usage: number; + ram_used_mb: number; + ram_total_mb: number; + disk_used_gb: number; + disk_total_gb: number; + network_in_mbps?: number; + network_out_mbps?: number; + } +) { + try { + const client = await createClient(); + const response = await client.get(`/nodes/${node}/rrddata`, { + params: { + timeframe, + cf: "AVERAGE" + } + }); + + const raw = Array.isArray(response.data?.data) ? (response.data.data as Array>) : []; + const points: NodeUsageGraphPoint[] = raw + .map((entry) => { + const ts = Number(entry.time); + if (!Number.isFinite(ts) || ts <= 0) return null; + + const mem = Number(entry.mem); + const maxmem = Number(entry.maxmem); + const disk = Number(entry.disk); + const maxdisk = Number(entry.maxdisk); + + return { + timestamp: new Date(ts * 1000).toISOString(), + cpu_pct: clampPercent(Number(entry.cpu) * 100), + ram_pct: clampPercent( + Number.isFinite(mem) && Number.isFinite(maxmem) && maxmem > 0 + ? (mem / maxmem) * 100 + : percentFromAbsolute(fallback.ram_used_mb, fallback.ram_total_mb) + ), + disk_usage_pct: clampPercent( + Number.isFinite(disk) && Number.isFinite(maxdisk) && maxdisk > 0 + ? (disk / maxdisk) * 100 + : percentFromAbsolute(fallback.disk_used_gb, fallback.disk_total_gb) + ), + network_in_mbps: Number(toMbpsFromBytesPerSecond(entry.netin).toFixed(3)), + network_out_mbps: Number(toMbpsFromBytesPerSecond(entry.netout).toFixed(3)), + io_wait_pct: clampPercent(Number(entry.iowait) * 100) + }; + }) + .filter((point): point is NodeUsageGraphPoint => Boolean(point)); + + if (points.length > 0) { + return { + timeframe, + source: "proxmox_rrd", + points, + summary: aggregateNodeMetrics(points) + }; + } + } catch { + // Fallback will be used when proxmox RRD data is unavailable. + } + + const baseCpu = clampPercent(fallback.cpu_usage); + const baseRam = percentFromAbsolute(fallback.ram_used_mb, fallback.ram_total_mb); + const baseDisk = percentFromAbsolute(fallback.disk_used_gb, fallback.disk_total_gb); + const baseNetIn = Math.max(0, Number(fallback.network_in_mbps ?? 0)); + const baseNetOut = Math.max(0, Number(fallback.network_out_mbps ?? 0)); + + const points = generateSyntheticTimeline(timeframe).map((time, index) => { + const waveA = 1 + Math.sin(index / 3.1) * 0.09; + const waveB = 1 + Math.cos(index / 4.6) * 0.07; + return { + timestamp: time.toISOString(), + cpu_pct: clampPercent(baseCpu * waveA), + ram_pct: clampPercent(baseRam * waveB), + disk_usage_pct: clampPercent(baseDisk * (1 + Math.sin(index / 6.2) * 0.04)), + network_in_mbps: Number((baseNetIn * waveA).toFixed(3)), + network_out_mbps: Number((baseNetOut * waveB).toFixed(3)), + io_wait_pct: clampPercent(baseCpu * 0.18 * (1 + Math.sin(index / 5.2) * 0.3)) + }; + }); + + return { + timeframe, + source: "fallback_synthetic", + points, + summary: aggregateNodeMetrics(points) + }; +} + +export async function clusterUsageGraphs(timeframe: ProxmoxGraphTimeframe) { + const nodes = await prisma.proxmoxNode.findMany({ + orderBy: { + name: "asc" + } + }); + + if (nodes.length === 0) { + return { + timeframe, + source: "empty", + node_count: 0, + nodes: [], + points: [] as ClusterUsageGraphPoint[], + summary: aggregateClusterMetrics([]) + }; + } + + const nodeSeries = await Promise.all( + nodes.map(async (node) => { + const graph = await nodeUsageGraphs(node.hostname, timeframe, { + cpu_usage: node.cpu_usage, + ram_used_mb: node.ram_used_mb, + ram_total_mb: node.ram_total_mb, + disk_used_gb: node.disk_used_gb, + disk_total_gb: node.disk_total_gb + }); + + return { + node, + graph + }; + }) + ); + + const pointBuckets = new Map< + string, + { + cpu: number; + ram: number; + disk: number; + ioWait: number; + netIn: number; + netOut: number; + reportingNodes: number; + } + >(); + + for (const item of nodeSeries) { + for (const point of item.graph.points) { + const bucket = pointBuckets.get(point.timestamp) ?? { + cpu: 0, + ram: 0, + disk: 0, + ioWait: 0, + netIn: 0, + netOut: 0, + reportingNodes: 0 + }; + + bucket.cpu += point.cpu_pct; + bucket.ram += point.ram_pct; + bucket.disk += point.disk_usage_pct; + bucket.ioWait += point.io_wait_pct; + bucket.netIn += point.network_in_mbps; + bucket.netOut += point.network_out_mbps; + bucket.reportingNodes += 1; + pointBuckets.set(point.timestamp, bucket); + } + } + + const points: ClusterUsageGraphPoint[] = Array.from(pointBuckets.entries()) + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([timestamp, value]) => { + const sampleCount = Math.max(1, value.reportingNodes); + return { + timestamp, + cpu_pct: clampPercent(value.cpu / sampleCount), + ram_pct: clampPercent(value.ram / sampleCount), + disk_usage_pct: clampPercent(value.disk / sampleCount), + io_wait_pct: clampPercent(value.ioWait / sampleCount), + network_in_mbps: Number(value.netIn.toFixed(3)), + network_out_mbps: Number(value.netOut.toFixed(3)), + reporting_nodes: value.reportingNodes + }; + }); + + const sourceSet = Array.from(new Set(nodeSeries.map((entry) => entry.graph.source))); + const source = sourceSet.length === 1 ? sourceSet[0] : "mixed"; + + return { + timeframe, + source, + node_count: nodes.length, + nodes: nodeSeries.map(({ node, graph }) => ({ + node_id: node.id, + node_name: node.name, + node_hostname: node.hostname, + source: graph.source, + summary: graph.summary + })), + points, + summary: aggregateClusterMetrics(points) + }; +} + +export async function syncNodesAndVirtualMachines() { + const proxmoxNodes = await listProxmoxNodes(); + + for (const node of proxmoxNodes) { + await prisma.proxmoxNode.upsert({ + where: { hostname: node.node }, + create: { + name: node.node, + hostname: node.node, + status: node.status === "online" ? "ONLINE" : "OFFLINE", + cpu_cores: node.maxcpu ?? 8, + cpu_usage: (node.cpu ?? 0) * 100, + ram_total_mb: Math.round((node.maxmem ?? 0) / (1024 * 1024)), + ram_used_mb: Math.round((node.mem ?? 0) / (1024 * 1024)), + disk_total_gb: Math.round((node.maxdisk ?? 0) / (1024 * 1024 * 1024)), + disk_used_gb: Math.round((node.disk ?? 0) / (1024 * 1024 * 1024)), + uptime_seconds: node.uptime ?? 0, + pve_version: node.level, + vm_count: 0, + is_connected: true, + last_sync_at: new Date() + }, + update: { + status: node.status === "online" ? "ONLINE" : "OFFLINE", + cpu_cores: node.maxcpu ?? 8, + cpu_usage: (node.cpu ?? 0) * 100, + ram_total_mb: Math.round((node.maxmem ?? 0) / (1024 * 1024)), + ram_used_mb: Math.round((node.mem ?? 0) / (1024 * 1024)), + disk_total_gb: Math.round((node.maxdisk ?? 0) / (1024 * 1024 * 1024)), + disk_used_gb: Math.round((node.disk ?? 0) / (1024 * 1024 * 1024)), + uptime_seconds: node.uptime ?? 0, + pve_version: node.level, + is_connected: true, + last_sync_at: new Date() + } + }); + + const nodeVms = await listProxmoxVms(node.node); + for (const vm of nodeVms) { + await prisma.virtualMachine.upsert({ + where: { + vmid_node: { + vmid: vm.vmid, + node: node.node + } + }, + create: { + name: vm.name ?? `${vm.type}-${vm.vmid}`, + vmid: vm.vmid, + status: + vm.status === "running" + ? "RUNNING" + : vm.status === "paused" + ? "PAUSED" + : "STOPPED", + type: vm.type === "lxc" ? "LXC" : "QEMU", + node: node.node, + tenant_id: (await ensureDefaultTenant()).id, + cpu_cores: vm.cpus ?? 1, + ram_mb: Math.round((vm.maxmem ?? 0) / (1024 * 1024)), + disk_gb: Math.round((vm.maxdisk ?? 0) / (1024 * 1024 * 1024)), + cpu_usage: (vm.cpu ?? 0) * 100, + ram_usage: + vm.maxmem && vm.mem + ? Math.max(0, Math.min(100, (Number(vm.mem) / Number(vm.maxmem)) * 100)) + : 0, + disk_usage: + vm.maxdisk && vm.disk + ? Math.max(0, Math.min(100, (Number(vm.disk) / Number(vm.maxdisk)) * 100)) + : 0, + uptime_seconds: vm.uptime ?? 0, + last_sync_at: new Date() + }, + update: { + name: vm.name ?? `${vm.type}-${vm.vmid}`, + status: + vm.status === "running" + ? "RUNNING" + : vm.status === "paused" + ? "PAUSED" + : "STOPPED", + type: vm.type === "lxc" ? "LXC" : "QEMU", + cpu_cores: vm.cpus ?? 1, + ram_mb: Math.round((vm.maxmem ?? 0) / (1024 * 1024)), + disk_gb: Math.round((vm.maxdisk ?? 0) / (1024 * 1024 * 1024)), + cpu_usage: (vm.cpu ?? 0) * 100, + ram_usage: + vm.maxmem && vm.mem + ? Math.max(0, Math.min(100, (Number(vm.mem) / Number(vm.maxmem)) * 100)) + : 0, + disk_usage: + vm.maxdisk && vm.disk + ? Math.max(0, Math.min(100, (Number(vm.disk) / Number(vm.maxdisk)) * 100)) + : 0, + uptime_seconds: vm.uptime ?? 0, + last_sync_at: new Date() + } + }); + } + + await prisma.proxmoxNode.update({ + where: { hostname: node.node }, + data: { + vm_count: nodeVms.length + } + }); + } + + return { + node_count: proxmoxNodes.length + }; +} + +async function ensureDefaultTenant() { + return prisma.tenant.upsert({ + where: { slug: "default-tenant" }, + update: {}, + create: { + name: "Default Tenant", + slug: "default-tenant", + owner_email: "system@proxpanel.local" + } + }); +} + +async function runVmAction(node: string, vmid: number, type: "qemu" | "lxc", action: string) { + const client = await createClient(); + const response = await client.post(`/nodes/${node}/${type}/${vmid}/status/${action}`); + return response.data?.data; +} + +export async function startVm(node: string, vmid: number, type: "qemu" | "lxc") { + return runVmAction(node, vmid, type, "start"); +} + +export async function stopVm(node: string, vmid: number, type: "qemu" | "lxc") { + return runVmAction(node, vmid, type, "stop"); +} + +export async function restartVm(node: string, vmid: number, type: "qemu" | "lxc") { + return runVmAction(node, vmid, type, "reboot"); +} + +export async function shutdownVm(node: string, vmid: number, type: "qemu" | "lxc") { + return runVmAction(node, vmid, type, "shutdown"); +} + +export async function suspendVm(node: string, vmid: number, type: "qemu" | "lxc") { + return runVmAction(node, vmid, type, "suspend"); +} + +export async function resumeVm(node: string, vmid: number, type: "qemu" | "lxc") { + return runVmAction(node, vmid, type, "resume"); +} + +export async function deleteVm(node: string, vmid: number, type: "qemu" | "lxc") { + const client = await createClient(); + const response = await client.delete(`/nodes/${node}/${type}/${vmid}`); + return response.data?.data; +} + +export async function migrateVm(node: string, vmid: number, targetNode: string, type: "qemu" | "lxc") { + const client = await createClient(); + const response = await client.post(`/nodes/${node}/${type}/${vmid}/migrate`, { + target: targetNode + }); + return response.data?.data; +} + +export async function vmConsoleTicket( + node: string, + vmid: number, + type: "qemu" | "lxc", + consoleType: "novnc" | "spice" | "xterm" = "novnc" +) { + const client = await createClient(); + if (consoleType === "spice") { + if (type !== "qemu") { + throw new HttpError(400, "SPICE console is only supported for QEMU VMs", "CONSOLE_NOT_SUPPORTED"); + } + const response = await client.post(`/nodes/${node}/${type}/${vmid}/spiceproxy`); + return response.data?.data; + } + + if (consoleType === "xterm") { + const response = await client.post(`/nodes/${node}/${type}/${vmid}/termproxy`, { + websocket: 1 + }); + return response.data?.data; + } + + const response = await client.post(`/nodes/${node}/${type}/${vmid}/vncproxy`, { + websocket: 1 + }); + return response.data?.data; +} + +async function vmConfig(node: string, vmid: number, type: "qemu" | "lxc") { + const client = await createClient(); + const response = await client.get(`/nodes/${node}/${type}/${vmid}/config`); + return (response.data?.data ?? {}) as Record; +} + +export async function updateVmConfiguration( + node: string, + vmid: number, + type: "qemu" | "lxc", + config: Record +) { + const client = await createClient(); + const response = await client.put(`/nodes/${node}/${type}/${vmid}/config`, config); + return response.data?.data; +} + +type ReconfigureNetworkInput = { + interface_name?: string; + bridge: string; + vlan_tag?: number; + rate_mbps?: number; + firewall?: boolean; + ip_mode?: "dhcp" | "static"; + ip_cidr?: string; + gateway?: string; +}; + +export async function reconfigureVmNetwork( + node: string, + vmid: number, + type: "qemu" | "lxc", + input: ReconfigureNetworkInput +) { + const iface = input.interface_name ?? "net0"; + if (type === "qemu") { + const parts = ["virtio", `bridge=${input.bridge}`]; + if (typeof input.vlan_tag === "number") parts.push(`tag=${input.vlan_tag}`); + if (typeof input.rate_mbps === "number") parts.push(`rate=${input.rate_mbps}`); + if (typeof input.firewall === "boolean") parts.push(`firewall=${input.firewall ? 1 : 0}`); + return updateVmConfiguration(node, vmid, type, { [iface]: parts.join(",") }); + } + + const parts = ["name=eth0", "type=veth", `bridge=${input.bridge}`]; + if (typeof input.vlan_tag === "number") parts.push(`tag=${input.vlan_tag}`); + if (typeof input.rate_mbps === "number") parts.push(`rate=${input.rate_mbps}`); + if (input.ip_mode === "static" && input.ip_cidr) { + parts.push(`ip=${input.ip_cidr}`); + if (input.gateway) parts.push(`gw=${input.gateway}`); + } else { + parts.push("ip=dhcp"); + } + + return updateVmConfiguration(node, vmid, type, { [iface]: parts.join(",") }); +} + +type AddDiskInput = { + storage: string; + size_gb: number; + bus?: "scsi" | "sata" | "virtio" | "ide"; + mount_point?: string; +}; + +export async function addVmDisk(node: string, vmid: number, type: "qemu" | "lxc", input: AddDiskInput) { + const current = await vmConfig(node, vmid, type); + + if (type === "qemu") { + const bus = input.bus ?? "scsi"; + let index = 0; + while (Object.prototype.hasOwnProperty.call(current, `${bus}${index}`)) { + index += 1; + } + const diskKey = `${bus}${index}`; + const diskValue = `${input.storage}:${input.size_gb}`; + return updateVmConfiguration(node, vmid, type, { [diskKey]: diskValue }); + } + + let mountIndex = 0; + while (Object.prototype.hasOwnProperty.call(current, `mp${mountIndex}`)) { + mountIndex += 1; + } + const mountKey = `mp${mountIndex}`; + const mountPoint = input.mount_point ?? `/mnt/disk${mountIndex}`; + const mountValue = `${input.storage}:${input.size_gb},mp=${mountPoint}`; + return updateVmConfiguration(node, vmid, type, { [mountKey]: mountValue }); +} + +type ReinstallInput = { + iso_image?: string; + ssh_public_key?: string; +}; + +export async function reinstallVm(node: string, vmid: number, type: "qemu" | "lxc", input: ReinstallInput) { + if (input.iso_image && type === "qemu") { + await updateVmConfiguration(node, vmid, type, { + ide2: `${input.iso_image},media=cdrom` + }); + } + + if (input.ssh_public_key) { + await updateVmConfiguration(node, vmid, type, { + sshkeys: input.ssh_public_key + }); + } + + return restartVm(node, vmid, type); +} + +export type TemplateProvisionInput = { + id: string; + name: string; + templateType: TemplateType; + virtualizationType?: VmType | null; + source?: string | null; + defaultCloudInit?: string | null; + metadata?: unknown; +}; + +export type ProvisionVmFromTemplateInput = { + node: string; + vmid: number; + name: string; + type: VmType; + cpuCores: number; + ramMb: number; + diskGb: number; + template?: TemplateProvisionInput | null; + packageOptions?: Record | null; +}; + +export type ProvisionVmFromTemplateResult = { + orchestration: string; + mainUpid?: string; + configUpid?: string; + haUpid?: string; + startUpid?: string; + started: boolean; + notes: string[]; +}; + +function asRecord(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return {}; + } + return value as Record; +} + +function getStringOption(record: Record, keys: string[]): string | undefined { + for (const key of keys) { + const value = record[key]; + if (typeof value === "string" && value.trim().length > 0) { + return value.trim(); + } + } + return undefined; +} + +function getNumberOption(record: Record, keys: string[], fallback: number): number { + for (const key of keys) { + const value = record[key]; + if (typeof value === "number" && Number.isFinite(value)) { + return Math.floor(value); + } + if (typeof value === "string") { + const parsed = Number(value); + if (Number.isFinite(parsed)) { + return Math.floor(parsed); + } + } + } + return fallback; +} + +function getBooleanOption(record: Record, keys: string[], fallback: boolean): boolean { + for (const key of keys) { + const value = record[key]; + if (typeof value === "boolean") { + return value; + } + if (typeof value === "string") { + const normalized = value.trim().toLowerCase(); + if (normalized === "true" || normalized === "1" || normalized === "yes") return true; + if (normalized === "false" || normalized === "0" || normalized === "no") return false; + } + } + return fallback; +} + +function parseCloneSource(source?: string | null) { + if (!source || source.trim().length === 0) { + return null; + } + + const value = source.trim(); + + const typedPattern = value.match(/^(qemu|lxc):(\d+)(?:@([A-Za-z0-9._-]+))?$/i); + if (typedPattern) { + return { + vmid: Number(typedPattern[2]), + node: typedPattern[3] ?? undefined + }; + } + + const nodePattern = value.match(/^([A-Za-z0-9._-]+):(\d+)$/); + if (nodePattern) { + return { + node: nodePattern[1], + vmid: Number(nodePattern[2]) + }; + } + + if (/^\d+$/.test(value)) { + return { + vmid: Number(value) + }; + } + + return null; +} + +async function createQemuVm( + node: string, + vmid: number, + name: string, + cpuCores: number, + ramMb: number, + diskGb: number, + options?: { + storage?: string; + bridge?: string; + sockets?: number; + isoImage?: string; + } +) { + const client = await createClient(); + const payload: Record = { + vmid, + name, + memory: Math.max(128, ramMb), + cores: Math.max(1, cpuCores), + sockets: Math.max(1, options?.sockets ?? 1), + scsihw: "virtio-scsi-pci", + scsi0: `${options?.storage ?? "local-lvm"}:${Math.max(1, diskGb)}`, + net0: `virtio,bridge=${options?.bridge ?? "vmbr0"}` + }; + + if (options?.isoImage) { + payload.ide2 = `${options.isoImage},media=cdrom`; + payload.boot = "order=scsi0;ide2"; + } + + const response = await client.post(`/nodes/${node}/qemu`, payload); + return response.data?.data as string | undefined; +} + +async function createLxcVm( + node: string, + vmid: number, + name: string, + cpuCores: number, + ramMb: number, + diskGb: number, + options: { + ostemplate: string; + storage?: string; + bridge?: string; + swapMb?: number; + } +) { + const client = await createClient(); + const payload: Record = { + vmid, + hostname: name, + memory: Math.max(128, ramMb), + cores: Math.max(1, cpuCores), + rootfs: `${options.storage ?? "local-lvm"}:${Math.max(1, diskGb)}`, + ostemplate: options.ostemplate, + net0: `name=eth0,bridge=${options.bridge ?? "vmbr0"},ip=dhcp` + }; + + if (typeof options.swapMb === "number" && options.swapMb >= 0) { + payload.swap = options.swapMb; + } + + const response = await client.post(`/nodes/${node}/lxc`, payload); + return response.data?.data as string | undefined; +} + +async function cloneVmFromTemplate(options: { + vmType: "qemu" | "lxc"; + sourceNode: string; + sourceVmid: number; + targetNode: string; + targetVmid: number; + name: string; + storage?: string; + fullClone?: boolean; +}) { + const client = await createClient(); + const payload: Record = { + newid: options.targetVmid, + target: options.targetNode + }; + + if (options.vmType === "qemu") { + payload.name = options.name; + } else { + payload.hostname = options.name; + } + + if (options.storage) payload.storage = options.storage; + if (typeof options.fullClone === "boolean") payload.full = options.fullClone ? 1 : 0; + + const response = await client.post( + `/nodes/${options.sourceNode}/${options.vmType}/${options.sourceVmid}/clone`, + payload + ); + return response.data?.data as string | undefined; +} + +async function configureHaForVm(vmType: VmType, vmid: number, settings: Record) { + const client = await createClient(); + const payload: Record = { + sid: vmType === VmType.LXC ? `ct:${vmid}` : `vm:${vmid}`, + state: getStringOption(settings, ["state"]) ?? "started" + }; + + const group = getStringOption(settings, ["group"]); + if (group) payload.group = group; + + const maxRelocate = getNumberOption(settings, ["max_relocate"], -1); + if (maxRelocate >= 0) payload.max_relocate = maxRelocate; + + const maxRestart = getNumberOption(settings, ["max_restart"], -1); + if (maxRestart >= 0) payload.max_restart = maxRestart; + + const response = await client.post("/cluster/ha/resources", payload); + return response.data?.data as string | undefined; +} + +export async function provisionVmFromTemplate(input: ProvisionVmFromTemplateInput): Promise { + const vmType = input.type === VmType.LXC ? "lxc" : "qemu"; + const templateMeta = asRecord(input.template?.metadata); + const packageOptions = asRecord(input.packageOptions); + + const storage = getStringOption(templateMeta, ["storage"]) ?? "local-lvm"; + const bridge = getStringOption(templateMeta, ["bridge"]) ?? "vmbr0"; + const sockets = Math.max(1, getNumberOption(templateMeta, ["sockets"], 1)); + const swapMb = Math.max(0, getNumberOption(templateMeta, ["swap_mb", "swap"], 0)); + const autoStart = getBooleanOption(templateMeta, ["auto_start"], true); + const fullClone = getBooleanOption(templateMeta, ["full_clone"], true); + + const sshPublicKey = + getStringOption(packageOptions, ["ssh_public_key", "sshKey", "ssh_key"]) ?? undefined; + + const cloudInitValue = + getStringOption(packageOptions, ["cloud_init_snippet", "cloudInitSnippet"]) ?? + (input.template?.defaultCloudInit ?? undefined); + + const notes: string[] = []; + let orchestration = "blank"; + let mainUpid: string | undefined; + let mainTaskNode = input.node; + let configUpid: string | undefined; + let haUpid: string | undefined; + let startUpid: string | undefined; + + if (!input.template) { + if (input.type === VmType.QEMU) { + orchestration = "create-qemu-blank"; + mainUpid = await createQemuVm(input.node, input.vmid, input.name, input.cpuCores, input.ramMb, input.diskGb, { + storage, + bridge, + sockets + }); + } else { + throw new HttpError( + 400, + "LXC provisioning requires a template archive or clone source", + "LXC_TEMPLATE_REQUIRED" + ); + } + } else if (input.template.templateType === TemplateType.KVM_TEMPLATE || input.template.templateType === TemplateType.LXC_TEMPLATE) { + const cloneSource = parseCloneSource(input.template.source); + if (!cloneSource?.vmid) { + throw new HttpError( + 400, + `Template ${input.template.name} is missing a valid clone source VMID`, + "INVALID_TEMPLATE_SOURCE" + ); + } + + orchestration = "clone-template"; + mainUpid = await cloneVmFromTemplate({ + vmType, + sourceNode: cloneSource.node ?? input.node, + sourceVmid: cloneSource.vmid, + targetNode: input.node, + targetVmid: input.vmid, + name: input.name, + storage, + fullClone + }); + mainTaskNode = cloneSource.node ?? input.node; + } else if (input.template.templateType === TemplateType.ISO_IMAGE) { + if (input.type !== VmType.QEMU) { + throw new HttpError(400, "ISO templates are only supported for QEMU VMs", "INVALID_TEMPLATE_TYPE"); + } + + if (!input.template.source) { + throw new HttpError(400, "ISO template source is required", "MISSING_TEMPLATE_SOURCE"); + } + + orchestration = "create-qemu-from-iso"; + mainUpid = await createQemuVm(input.node, input.vmid, input.name, input.cpuCores, input.ramMb, input.diskGb, { + storage, + bridge, + sockets, + isoImage: input.template.source + }); + } else if (input.template.templateType === TemplateType.ARCHIVE) { + if (input.type !== VmType.LXC) { + throw new HttpError(400, "Archive templates are only supported for LXC VMs", "INVALID_TEMPLATE_TYPE"); + } + + if (!input.template.source) { + throw new HttpError(400, "Archive template source is required", "MISSING_TEMPLATE_SOURCE"); + } + + orchestration = "create-lxc-from-archive"; + mainUpid = await createLxcVm(input.node, input.vmid, input.name, input.cpuCores, input.ramMb, input.diskGb, { + ostemplate: input.template.source, + storage, + bridge, + swapMb + }); + } else { + const cloneSource = parseCloneSource(input.template.source); + if (cloneSource?.vmid) { + orchestration = "clone-application-template"; + mainUpid = await cloneVmFromTemplate({ + vmType, + sourceNode: cloneSource.node ?? input.node, + sourceVmid: cloneSource.vmid, + targetNode: input.node, + targetVmid: input.vmid, + name: input.name, + storage, + fullClone + }); + } else if (input.type === VmType.QEMU) { + orchestration = "create-qemu-application"; + mainUpid = await createQemuVm(input.node, input.vmid, input.name, input.cpuCores, input.ramMb, input.diskGb, { + storage, + bridge, + sockets, + isoImage: input.template.source ?? undefined + }); + } else if (input.template.source) { + orchestration = "create-lxc-application"; + mainUpid = await createLxcVm(input.node, input.vmid, input.name, input.cpuCores, input.ramMb, input.diskGb, { + ostemplate: input.template.source, + storage, + bridge, + swapMb + }); + } else { + throw new HttpError( + 400, + "Application template must define a clone source or runtime image source", + "INVALID_TEMPLATE_SOURCE" + ); + } + } + + if (mainUpid) { + await waitForProxmoxTask(mainTaskNode, mainUpid, { timeoutMs: 300_000 }); + } + + const config: Record = { + cores: Math.max(1, input.cpuCores), + memory: Math.max(128, input.ramMb) + }; + + if (input.type === VmType.QEMU) { + config.sockets = sockets; + if (cloudInitValue) { + config.cicustom = cloudInitValue.includes("=") ? cloudInitValue : `user=${cloudInitValue}`; + } + } else if (swapMb > 0) { + config.swap = swapMb; + } + + if (sshPublicKey) { + config.sshkeys = sshPublicKey; + } + + configUpid = await updateVmConfiguration(input.node, input.vmid, vmType, config); + if (configUpid) { + await waitForProxmoxTask(input.node, configUpid, { timeoutMs: 120_000 }); + } + + const haSettings = asRecord(templateMeta.ha); + const haEnabled = getBooleanOption(haSettings, ["enabled"], false); + if (haEnabled) { + const haRequired = getBooleanOption(haSettings, ["required"], false); + try { + haUpid = await configureHaForVm(input.type, input.vmid, haSettings); + } catch (error) { + const message = error instanceof Error ? error.message : "HA configuration failed"; + if (haRequired) { + throw new HttpError(500, message, "HA_CONFIGURATION_FAILED"); + } + notes.push(`HA skipped: ${message}`); + } + } + + if (autoStart) { + startUpid = await startVm(input.node, input.vmid, vmType); + if (startUpid) { + await waitForProxmoxTask(input.node, startUpid, { timeoutMs: 120_000 }); + } + } + + return { + orchestration, + mainUpid, + configUpid, + haUpid, + startUpid, + started: autoStart, + notes + }; +} diff --git a/backend/src/services/scheduler.service.ts b/backend/src/services/scheduler.service.ts new file mode 100644 index 0000000..fd231f1 --- /dev/null +++ b/backend/src/services/scheduler.service.ts @@ -0,0 +1,495 @@ +import cron, { type ScheduledTask } from "node-cron"; +import os from "os"; +import { SettingType } from "@prisma/client"; +import { env } from "../config/env"; +import { prisma } from "../lib/prisma"; +import { meterHourlyUsage, generateInvoicesFromUnbilledUsage, processBackupSchedule, updateOverdueInvoices } from "./billing.service"; +import { processDuePowerSchedules, processDueOperationRetries } from "./operations.service"; +import { processDueSnapshotJobs, processPendingBackups } from "./backup.service"; +import { evaluateAlertRulesNow, processDueHealthChecks } from "./monitoring.service"; + +export type SchedulerConfig = { + enable_scheduler: boolean; + billing_cron: string; + backup_cron: string; + power_schedule_cron: string; + monitoring_cron: string; + operation_retry_cron: string; +}; + +type WorkerKey = "billing" | "backup" | "power" | "monitoring" | "operation_retry"; +type WorkerStatus = "disabled" | "scheduled" | "running" | "success" | "failed"; + +type WorkerState = { + worker: WorkerKey; + cron: string; + status: WorkerStatus; + last_run_at: string | null; + last_duration_ms: number | null; + last_message: string | null; + last_error: string | null; +}; + +type SchedulerLeasePayload = { + owner_id: string; + lease_until: string; + acquired_at: string; + heartbeat_at: string; + worker: WorkerKey; +}; + +type SchedulerState = { + started_at: string | null; + config: SchedulerConfig; + workers: Record; +}; + +const DEFAULT_SCHEDULER_CONFIG: SchedulerConfig = { + enable_scheduler: env.ENABLE_SCHEDULER, + billing_cron: env.BILLING_CRON, + backup_cron: env.BACKUP_CRON, + power_schedule_cron: env.POWER_SCHEDULE_CRON, + monitoring_cron: env.MONITORING_CRON, + operation_retry_cron: "*/5 * * * *" +}; + +let scheduledJobs: Partial> = {}; +const activeWorkerRuns = new Set(); +const schedulerInstanceId = `${os.hostname()}:${process.pid}:${Math.random().toString(36).slice(2, 10)}`; + +const schedulerState: SchedulerState = { + started_at: null, + config: DEFAULT_SCHEDULER_CONFIG, + workers: { + billing: { + worker: "billing", + cron: DEFAULT_SCHEDULER_CONFIG.billing_cron, + status: DEFAULT_SCHEDULER_CONFIG.enable_scheduler ? "scheduled" : "disabled", + last_run_at: null, + last_duration_ms: null, + last_message: null, + last_error: null + }, + backup: { + worker: "backup", + cron: DEFAULT_SCHEDULER_CONFIG.backup_cron, + status: DEFAULT_SCHEDULER_CONFIG.enable_scheduler ? "scheduled" : "disabled", + last_run_at: null, + last_duration_ms: null, + last_message: null, + last_error: null + }, + power: { + worker: "power", + cron: DEFAULT_SCHEDULER_CONFIG.power_schedule_cron, + status: DEFAULT_SCHEDULER_CONFIG.enable_scheduler ? "scheduled" : "disabled", + last_run_at: null, + last_duration_ms: null, + last_message: null, + last_error: null + }, + monitoring: { + worker: "monitoring", + cron: DEFAULT_SCHEDULER_CONFIG.monitoring_cron, + status: DEFAULT_SCHEDULER_CONFIG.enable_scheduler ? "scheduled" : "disabled", + last_run_at: null, + last_duration_ms: null, + last_message: null, + last_error: null + }, + operation_retry: { + worker: "operation_retry", + cron: DEFAULT_SCHEDULER_CONFIG.operation_retry_cron, + status: DEFAULT_SCHEDULER_CONFIG.enable_scheduler ? "scheduled" : "disabled", + last_run_at: null, + last_duration_ms: null, + last_message: null, + last_error: null + } + } +}; + +function normalizeCronExpression(value: unknown, fallback: string) { + if (typeof value !== "string") return fallback; + const trimmed = value.trim(); + if (trimmed.length === 0) return fallback; + return cron.validate(trimmed) ? trimmed : fallback; +} + +function normalizeSchedulerConfig(raw?: unknown): SchedulerConfig { + const record = raw && typeof raw === "object" && !Array.isArray(raw) ? (raw as Record) : {}; + + const enabled = + typeof record.enable_scheduler === "boolean" ? record.enable_scheduler : DEFAULT_SCHEDULER_CONFIG.enable_scheduler; + + return { + enable_scheduler: enabled, + billing_cron: normalizeCronExpression(record.billing_cron, DEFAULT_SCHEDULER_CONFIG.billing_cron), + backup_cron: normalizeCronExpression(record.backup_cron, DEFAULT_SCHEDULER_CONFIG.backup_cron), + power_schedule_cron: normalizeCronExpression(record.power_schedule_cron, DEFAULT_SCHEDULER_CONFIG.power_schedule_cron), + monitoring_cron: normalizeCronExpression(record.monitoring_cron, DEFAULT_SCHEDULER_CONFIG.monitoring_cron), + operation_retry_cron: normalizeCronExpression(record.operation_retry_cron, DEFAULT_SCHEDULER_CONFIG.operation_retry_cron) + }; +} + +function lockSettingKey(worker: WorkerKey) { + return `scheduler_lock:${worker}`; +} + +function nextLeaseDeadline(from = new Date()) { + return new Date(from.getTime() + env.SCHEDULER_LEASE_MS); +} + +function parseLeasePayload(value: unknown): SchedulerLeasePayload | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const record = value as Record; + if ( + typeof record.owner_id !== "string" || + typeof record.lease_until !== "string" || + typeof record.acquired_at !== "string" || + typeof record.heartbeat_at !== "string" || + typeof record.worker !== "string" + ) { + return null; + } + + return { + owner_id: record.owner_id, + lease_until: record.lease_until, + acquired_at: record.acquired_at, + heartbeat_at: record.heartbeat_at, + worker: record.worker as WorkerKey + }; +} + +function leasePayload(worker: WorkerKey, now = new Date(), acquiredAt?: string): SchedulerLeasePayload { + return { + owner_id: schedulerInstanceId, + lease_until: nextLeaseDeadline(now).toISOString(), + acquired_at: acquiredAt ?? now.toISOString(), + heartbeat_at: now.toISOString(), + worker + }; +} + +async function acquireWorkerLease(worker: WorkerKey) { + const now = new Date(); + const key = lockSettingKey(worker); + const existing = await prisma.setting.findUnique({ + where: { key }, + select: { + id: true, + value: true, + updated_at: true + } + }); + + if (!existing) { + try { + await prisma.setting.create({ + data: { + key, + type: SettingType.GENERAL, + is_encrypted: false, + value: leasePayload(worker, now) + } + }); + return true; + } catch { + return false; + } + } + + const parsed = parseLeasePayload(existing.value); + const leaseUntilMs = parsed ? Date.parse(parsed.lease_until) : 0; + const activeOwner = + parsed && + parsed.owner_id && + parsed.owner_id !== schedulerInstanceId && + Number.isFinite(leaseUntilMs) && + leaseUntilMs > now.getTime(); + + if (activeOwner) { + return false; + } + + const updated = await prisma.setting.updateMany({ + where: { + id: existing.id, + updated_at: existing.updated_at + }, + data: { + value: leasePayload(worker, now, parsed?.acquired_at) + } + }); + + return updated.count === 1; +} + +async function renewWorkerLease(worker: WorkerKey) { + const now = new Date(); + const key = lockSettingKey(worker); + const existing = await prisma.setting.findUnique({ + where: { key }, + select: { + id: true, + value: true, + updated_at: true + } + }); + + if (!existing) { + return false; + } + + const parsed = parseLeasePayload(existing.value); + if (!parsed || parsed.owner_id !== schedulerInstanceId) { + return false; + } + + const updated = await prisma.setting.updateMany({ + where: { + id: existing.id, + updated_at: existing.updated_at + }, + data: { + value: leasePayload(worker, now, parsed.acquired_at) + } + }); + + return updated.count === 1; +} + +async function releaseWorkerLease(worker: WorkerKey) { + const key = lockSettingKey(worker); + const existing = await prisma.setting.findUnique({ + where: { key }, + select: { + id: true, + value: true, + updated_at: true + } + }); + + if (!existing) { + return; + } + + const parsed = parseLeasePayload(existing.value); + if (!parsed || parsed.owner_id !== schedulerInstanceId) { + return; + } + + const now = new Date(); + const leaseExpired = new Date(now.getTime() - 1000).toISOString(); + await prisma.setting.updateMany({ + where: { + id: existing.id, + updated_at: existing.updated_at + }, + data: { + value: { + ...parsed, + owner_id: "", + lease_until: leaseExpired, + heartbeat_at: now.toISOString() + } + } + }); +} + +function stopAllScheduledJobs() { + const entries = Object.entries(scheduledJobs) as Array<[WorkerKey, ScheduledTask]>; + for (const [, task] of entries) { + try { + task.stop(); + task.destroy(); + } catch { + task.stop(); + } + } + scheduledJobs = {}; +} + +function setWorkerDisabledState(config: SchedulerConfig) { + schedulerState.workers.billing = { + ...schedulerState.workers.billing, + cron: config.billing_cron, + status: "disabled" + }; + schedulerState.workers.backup = { + ...schedulerState.workers.backup, + cron: config.backup_cron, + status: "disabled" + }; + schedulerState.workers.power = { + ...schedulerState.workers.power, + cron: config.power_schedule_cron, + status: "disabled" + }; + schedulerState.workers.monitoring = { + ...schedulerState.workers.monitoring, + cron: config.monitoring_cron, + status: "disabled" + }; + schedulerState.workers.operation_retry = { + ...schedulerState.workers.operation_retry, + cron: config.operation_retry_cron, + status: "disabled" + }; +} + +async function runWorker(worker: WorkerKey, execute: () => Promise) { + if (activeWorkerRuns.has(worker)) { + schedulerState.workers[worker] = { + ...schedulerState.workers[worker], + status: "scheduled", + last_message: "Skipped: worker already running in this process" + }; + return; + } + + const acquired = await acquireWorkerLease(worker); + if (!acquired) { + schedulerState.workers[worker] = { + ...schedulerState.workers[worker], + status: "scheduled", + last_message: "Skipped: lease held by another scheduler instance" + }; + return; + } + + activeWorkerRuns.add(worker); + const startedAt = Date.now(); + schedulerState.workers[worker] = { + ...schedulerState.workers[worker], + status: "running", + last_error: null + }; + + const heartbeatEveryMs = Math.max(1_000, Math.min(env.SCHEDULER_HEARTBEAT_MS, Math.floor(env.SCHEDULER_LEASE_MS / 2))); + const heartbeat = setInterval(() => { + void renewWorkerLease(worker); + }, heartbeatEveryMs); + + try { + const message = await execute(); + schedulerState.workers[worker] = { + ...schedulerState.workers[worker], + status: "success", + last_run_at: new Date().toISOString(), + last_duration_ms: Date.now() - startedAt, + last_message: message, + last_error: null + }; + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown scheduler error"; + schedulerState.workers[worker] = { + ...schedulerState.workers[worker], + status: "failed", + last_run_at: new Date().toISOString(), + last_duration_ms: Date.now() - startedAt, + last_error: message + }; + } finally { + clearInterval(heartbeat); + activeWorkerRuns.delete(worker); + await releaseWorkerLease(worker); + } +} + +function registerWorker(worker: WorkerKey, cronExpression: string, execute: () => Promise) { + schedulerState.workers[worker] = { + ...schedulerState.workers[worker], + cron: cronExpression, + status: "scheduled", + last_error: null + }; + + const task = cron.schedule(cronExpression, () => { + void runWorker(worker, execute); + }); + + scheduledJobs[worker] = task; +} + +async function readSchedulerConfigSetting() { + const setting = await prisma.setting.findUnique({ + where: { key: "scheduler" }, + select: { value: true } + }); + return normalizeSchedulerConfig(setting?.value); +} + +function applyRuntimeConfig(config: SchedulerConfig) { + schedulerState.config = config; + schedulerState.started_at = new Date().toISOString(); +} + +export async function configureSchedulers(config?: SchedulerConfig) { + const resolvedConfig = config ?? (await readSchedulerConfigSetting()); + applyRuntimeConfig(resolvedConfig); + + stopAllScheduledJobs(); + + if (!resolvedConfig.enable_scheduler) { + setWorkerDisabledState(resolvedConfig); + return getSchedulerRuntimeSnapshot(); + } + + registerWorker("billing", resolvedConfig.billing_cron, async () => { + await meterHourlyUsage(); + await generateInvoicesFromUnbilledUsage(); + await updateOverdueInvoices(); + return "Billing cycle completed"; + }); + + registerWorker("backup", resolvedConfig.backup_cron, async () => { + const queued = await processBackupSchedule(); + const backupResult = await processPendingBackups(); + const snapshotResult = await processDueSnapshotJobs(); + return `Backup queue=${queued}, backups_completed=${backupResult.completed}, backups_skipped=${backupResult.skipped}, snapshot_scanned=${snapshotResult.scanned}, snapshot_executed=${snapshotResult.executed}, snapshot_failed=${snapshotResult.failed}, snapshot_pruned=${snapshotResult.pruned}, snapshot_skipped=${snapshotResult.skipped}`; + }); + + registerWorker("power", resolvedConfig.power_schedule_cron, async () => { + const result = await processDuePowerSchedules(); + return `Power schedules scanned=${result.scanned}, executed=${result.executed}, failed=${result.failed}, skipped=${result.skipped}`; + }); + + registerWorker("monitoring", resolvedConfig.monitoring_cron, async () => { + const checkResult = await processDueHealthChecks(); + const alertResult = await evaluateAlertRulesNow(); + return `Checks scanned=${checkResult.scanned}, executed=${checkResult.executed}, failed=${checkResult.failed}, skipped=${checkResult.skipped}; alerts evaluated=${alertResult.evaluated}, triggered=${alertResult.triggered}, resolved=${alertResult.resolved}`; + }); + + registerWorker("operation_retry", resolvedConfig.operation_retry_cron, async () => { + const retryResult = await processDueOperationRetries(); + return `Retry tasks scanned=${retryResult.scanned}, executed=${retryResult.executed}, succeeded=${retryResult.succeeded}, failed=${retryResult.failed}, rescheduled=${retryResult.rescheduled}, invalid_payload=${retryResult.invalid_payload}, skipped=${retryResult.skipped}`; + }); + + return getSchedulerRuntimeSnapshot(); +} + +export async function startSchedulers() { + await configureSchedulers(); +} + +export async function reconfigureSchedulers(config?: Partial) { + const persisted = await readSchedulerConfigSetting(); + const merged = normalizeSchedulerConfig({ + ...persisted, + ...(config ?? {}) + }); + return configureSchedulers(merged); +} + +export function getSchedulerRuntimeSnapshot() { + return { + generated_at: new Date().toISOString(), + ...schedulerState + }; +} + +export function schedulerDefaults() { + return { ...DEFAULT_SCHEDULER_CONFIG }; +} diff --git a/backend/src/tests/operations.test.ts b/backend/src/tests/operations.test.ts new file mode 100644 index 0000000..c3f3415 --- /dev/null +++ b/backend/src/tests/operations.test.ts @@ -0,0 +1,20 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { nextRunAt, validateCronExpression } from "../services/operations.service"; + +test("nextRunAt returns a future date for a valid cron expression", () => { + const base = new Date("2026-01-01T00:00:00.000Z"); + const next = nextRunAt("*/5 * * * *", base); + assert.ok(next instanceof Date); + assert.ok(next.getTime() > base.getTime()); +}); + +test("validateCronExpression accepts valid expressions", () => { + assert.doesNotThrow(() => validateCronExpression("0 * * * *")); + assert.doesNotThrow(() => validateCronExpression("*/10 1-23 * * 1,3,5")); +}); + +test("validateCronExpression rejects invalid expressions", () => { + assert.throws(() => validateCronExpression("invalid-cron")); + assert.throws(() => validateCronExpression("* * * * * *")); +}); diff --git a/backend/src/types/express.d.ts b/backend/src/types/express.d.ts new file mode 100644 index 0000000..533a40f --- /dev/null +++ b/backend/src/types/express.d.ts @@ -0,0 +1,19 @@ +import type { Role } from "@prisma/client"; + +declare global { + namespace Express { + interface UserToken { + id: string; + email: string; + role: Role; + tenant_id?: string | null; + } + + interface Request { + user?: UserToken; + rawBody?: string; + } + } +} + +export {}; diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..5a5804f --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "CommonJS", + "moduleResolution": "Node", + "rootDir": "src", + "outDir": "dist", + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "declaration": false, + "sourceMap": true, + "types": ["node"] + }, + "include": ["src/**/*.ts", "src/**/*.d.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/components.json b/components.json new file mode 100644 index 0000000..3670f42 --- /dev/null +++ b/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": false, + "tailwind": { + "config": "tailwind.config.js", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..64fe838 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,63 @@ +services: + postgres: + image: postgres:16-alpine + container_name: proxpanel-postgres + restart: unless-stopped + environment: + POSTGRES_USER: proxpanel + POSTGRES_PASSWORD: proxpanel + POSTGRES_DB: proxpanel + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U proxpanel -d proxpanel"] + interval: 10s + timeout: 5s + retries: 5 + + backend: + build: + context: ./backend + container_name: proxpanel-backend + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + environment: + NODE_ENV: production + PORT: 8080 + DATABASE_URL: postgresql://proxpanel:proxpanel@postgres:5432/proxpanel + JWT_SECRET: change_this_to_a_long_secret_key_please + JWT_REFRESH_SECRET: change_this_to_another_long_secret_key + JWT_EXPIRES_IN: 15m + JWT_REFRESH_EXPIRES_IN: 30d + CORS_ORIGIN: http://localhost:80 + RATE_LIMIT_WINDOW_MS: 60000 + RATE_LIMIT_MAX: 600 + AUTH_RATE_LIMIT_WINDOW_MS: 60000 + AUTH_RATE_LIMIT_MAX: 20 + ENABLE_SCHEDULER: "true" + BILLING_CRON: "0 * * * *" + BACKUP_CRON: "*/15 * * * *" + POWER_SCHEDULE_CRON: "* * * * *" + MONITORING_CRON: "*/5 * * * *" + PROXMOX_TIMEOUT_MS: 15000 + ports: + - "8080:8080" + + frontend: + build: + context: . + args: + VITE_API_BASE_URL: http://localhost:8080 + container_name: proxpanel-frontend + restart: unless-stopped + depends_on: + - backend + ports: + - "80:80" + +volumes: + postgres_data: diff --git a/entities/AuditLog.json b/entities/AuditLog.json new file mode 100644 index 0000000..754562a --- /dev/null +++ b/entities/AuditLog.json @@ -0,0 +1,80 @@ +{ + "name": "Backup", + "type": "object", + "properties": { + "vm_id": { + "type": "string", + "title": "VM ID" + }, + "vm_name": { + "type": "string", + "title": "VM Name" + }, + "node": { + "type": "string", + "title": "Node" + }, + "status": { + "type": "string", + "enum": [ + "pending", + "running", + "completed", + "failed", + "expired" + ], + "title": "Status" + }, + "type": { + "type": "string", + "enum": [ + "full", + "incremental", + "snapshot" + ], + "title": "Backup Type" + }, + "size_mb": { + "type": "number", + "title": "Size (MB)" + }, + "storage": { + "type": "string", + "title": "Storage Location" + }, + "schedule": { + "type": "string", + "enum": [ + "manual", + "daily", + "weekly", + "monthly" + ], + "title": "Schedule" + }, + "retention_days": { + "type": "number", + "title": "Retention Days" + }, + "started_at": { + "type": "string", + "format": "date-time", + "title": "Started At" + }, + "completed_at": { + "type": "string", + "format": "date-time", + "title": "Completed At" + }, + "notes": { + "type": "string", + "title": "Notes" + } + }, + "required": [ + "vm_id", + "vm_name", + "status", + "type" + ] +} \ No newline at end of file diff --git a/entities/Backup.json b/entities/Backup.json new file mode 100644 index 0000000..de7b97b --- /dev/null +++ b/entities/Backup.json @@ -0,0 +1,72 @@ +{ + "name": "BillingPlan", + "type": "object", + "properties": { + "name": { + "type": "string", + "title": "Plan Name" + }, + "slug": { + "type": "string", + "title": "Slug" + }, + "description": { + "type": "string", + "title": "Description" + }, + "price_monthly": { + "type": "number", + "title": "Monthly Price" + }, + "price_hourly": { + "type": "number", + "title": "Hourly Price" + }, + "currency": { + "type": "string", + "enum": [ + "NGN", + "USD", + "GHS", + "KES", + "ZAR" + ], + "title": "Currency" + }, + "cpu_cores": { + "type": "number", + "title": "CPU Cores" + }, + "ram_mb": { + "type": "number", + "title": "RAM (MB)" + }, + "disk_gb": { + "type": "number", + "title": "Disk (GB)" + }, + "bandwidth_gb": { + "type": "number", + "title": "Bandwidth (GB)" + }, + "is_active": { + "type": "boolean", + "title": "Active" + }, + "features": { + "type": "array", + "items": { + "type": "string" + }, + "title": "Features" + } + }, + "required": [ + "name", + "price_monthly", + "currency", + "cpu_cores", + "ram_mb", + "disk_gb" + ] +} \ No newline at end of file diff --git a/entities/BillingPlan.json b/entities/BillingPlan.json new file mode 100644 index 0000000..78637df --- /dev/null +++ b/entities/BillingPlan.json @@ -0,0 +1,83 @@ +{ + "name": "Invoice", + "type": "object", + "properties": { + "invoice_number": { + "type": "string", + "title": "Invoice Number" + }, + "tenant_id": { + "type": "string", + "title": "Tenant ID" + }, + "tenant_name": { + "type": "string", + "title": "Tenant Name" + }, + "status": { + "type": "string", + "enum": [ + "draft", + "pending", + "paid", + "overdue", + "cancelled", + "refunded" + ], + "title": "Status" + }, + "amount": { + "type": "number", + "title": "Amount" + }, + "currency": { + "type": "string", + "enum": [ + "NGN", + "USD", + "GHS", + "KES", + "ZAR" + ], + "title": "Currency" + }, + "due_date": { + "type": "string", + "format": "date", + "title": "Due Date" + }, + "paid_date": { + "type": "string", + "format": "date", + "title": "Paid Date" + }, + "payment_provider": { + "type": "string", + "enum": [ + "paystack", + "flutterwave", + "manual" + ], + "title": "Payment Provider" + }, + "payment_reference": { + "type": "string", + "title": "Payment Reference" + }, + "line_items": { + "type": "string", + "title": "Line Items JSON" + }, + "notes": { + "type": "string", + "title": "Notes" + } + }, + "required": [ + "invoice_number", + "tenant_id", + "status", + "amount", + "currency" + ] +} \ No newline at end of file diff --git a/entities/FirewallRule.json b/entities/FirewallRule.json new file mode 100644 index 0000000..1eee228 --- /dev/null +++ b/entities/FirewallRule.json @@ -0,0 +1,95 @@ +{ + "name": "UsageRecord", + "type": "object", + "properties": { + "vm_id": { + "type": "string", + "title": "VM ID" + }, + "vm_name": { + "type": "string", + "title": "VM Name" + }, + "tenant_id": { + "type": "string", + "title": "Tenant ID" + }, + "tenant_name": { + "type": "string", + "title": "Tenant Name" + }, + "billing_plan_id": { + "type": "string", + "title": "Billing Plan ID" + }, + "plan_name": { + "type": "string", + "title": "Plan Name" + }, + "hours_used": { + "type": "number", + "title": "Hours Used" + }, + "price_per_hour": { + "type": "number", + "title": "Price Per Hour" + }, + "currency": { + "type": "string", + "enum": [ + "NGN", + "USD", + "GHS", + "KES", + "ZAR" + ], + "title": "Currency" + }, + "total_cost": { + "type": "number", + "title": "Total Cost" + }, + "period_start": { + "type": "string", + "format": "date-time", + "title": "Period Start" + }, + "period_end": { + "type": "string", + "format": "date-time", + "title": "Period End" + }, + "billed": { + "type": "boolean", + "title": "Billed" + }, + "invoice_id": { + "type": "string", + "title": "Invoice ID" + }, + "cpu_hours": { + "type": "number", + "title": "CPU Hours" + }, + "ram_gb_hours": { + "type": "number", + "title": "RAM GB-Hours" + }, + "disk_gb_hours": { + "type": "number", + "title": "Disk GB-Hours" + }, + "network_gb": { + "type": "number", + "title": "Network GB Used" + } + }, + "required": [ + "vm_id", + "vm_name", + "hours_used", + "price_per_hour", + "currency", + "total_cost" + ] +} \ No newline at end of file diff --git a/entities/Invoice.json b/entities/Invoice.json new file mode 100644 index 0000000..78637df --- /dev/null +++ b/entities/Invoice.json @@ -0,0 +1,83 @@ +{ + "name": "Invoice", + "type": "object", + "properties": { + "invoice_number": { + "type": "string", + "title": "Invoice Number" + }, + "tenant_id": { + "type": "string", + "title": "Tenant ID" + }, + "tenant_name": { + "type": "string", + "title": "Tenant Name" + }, + "status": { + "type": "string", + "enum": [ + "draft", + "pending", + "paid", + "overdue", + "cancelled", + "refunded" + ], + "title": "Status" + }, + "amount": { + "type": "number", + "title": "Amount" + }, + "currency": { + "type": "string", + "enum": [ + "NGN", + "USD", + "GHS", + "KES", + "ZAR" + ], + "title": "Currency" + }, + "due_date": { + "type": "string", + "format": "date", + "title": "Due Date" + }, + "paid_date": { + "type": "string", + "format": "date", + "title": "Paid Date" + }, + "payment_provider": { + "type": "string", + "enum": [ + "paystack", + "flutterwave", + "manual" + ], + "title": "Payment Provider" + }, + "payment_reference": { + "type": "string", + "title": "Payment Reference" + }, + "line_items": { + "type": "string", + "title": "Line Items JSON" + }, + "notes": { + "type": "string", + "title": "Notes" + } + }, + "required": [ + "invoice_number", + "tenant_id", + "status", + "amount", + "currency" + ] +} \ No newline at end of file diff --git a/entities/ProxmoxNode.json b/entities/ProxmoxNode.json new file mode 100644 index 0000000..42278d9 --- /dev/null +++ b/entities/ProxmoxNode.json @@ -0,0 +1,95 @@ +{ + "name": "Tenant", + "type": "object", + "properties": { + "name": { + "type": "string", + "title": "Organization Name" + }, + "slug": { + "type": "string", + "title": "Slug" + }, + "status": { + "type": "string", + "enum": [ + "active", + "suspended", + "trial", + "cancelled" + ], + "title": "Status" + }, + "plan": { + "type": "string", + "enum": [ + "starter", + "professional", + "enterprise", + "custom" + ], + "title": "Plan" + }, + "owner_email": { + "type": "string", + "title": "Owner Email" + }, + "member_emails": { + "type": "array", + "items": { + "type": "string" + }, + "title": "Member Emails" + }, + "vm_limit": { + "type": "number", + "title": "VM Limit" + }, + "cpu_limit": { + "type": "number", + "title": "CPU Limit" + }, + "ram_limit_mb": { + "type": "number", + "title": "RAM Limit (MB)" + }, + "disk_limit_gb": { + "type": "number", + "title": "Disk Limit (GB)" + }, + "balance": { + "type": "number", + "title": "Balance" + }, + "currency": { + "type": "string", + "enum": [ + "NGN", + "USD", + "GHS", + "KES", + "ZAR" + ], + "title": "Currency" + }, + "payment_provider": { + "type": "string", + "enum": [ + "paystack", + "flutterwave", + "manual" + ], + "title": "Payment Provider" + }, + "metadata": { + "type": "string", + "title": "Metadata JSON" + } + }, + "required": [ + "name", + "status", + "plan", + "owner_email" + ] +} \ No newline at end of file diff --git a/entities/SecurityEvent.json b/entities/SecurityEvent.json new file mode 100644 index 0000000..aa0cbbb --- /dev/null +++ b/entities/SecurityEvent.json @@ -0,0 +1,88 @@ +{ + "name": "FirewallRule", + "type": "object", + "properties": { + "name": { + "type": "string", + "title": "Rule Name" + }, + "direction": { + "type": "string", + "enum": [ + "inbound", + "outbound", + "both" + ], + "title": "Direction" + }, + "action": { + "type": "string", + "enum": [ + "allow", + "deny", + "rate_limit", + "log" + ], + "title": "Action" + }, + "protocol": { + "type": "string", + "enum": [ + "tcp", + "udp", + "icmp", + "any" + ], + "title": "Protocol" + }, + "source_ip": { + "type": "string", + "title": "Source IP / CIDR" + }, + "destination_ip": { + "type": "string", + "title": "Destination IP / CIDR" + }, + "port_range": { + "type": "string", + "title": "Port Range" + }, + "priority": { + "type": "number", + "title": "Priority" + }, + "enabled": { + "type": "boolean", + "title": "Enabled" + }, + "applies_to": { + "type": "string", + "enum": [ + "all_nodes", + "all_vms", + "specific_node", + "specific_vm" + ], + "title": "Applies To" + }, + "target_id": { + "type": "string", + "title": "Target Node/VM ID" + }, + "hit_count": { + "type": "number", + "title": "Hit Count" + }, + "description": { + "type": "string", + "title": "Description" + } + }, + "required": [ + "name", + "direction", + "action", + "protocol", + "enabled" + ] +} \ No newline at end of file diff --git a/entities/Tenant.json b/entities/Tenant.json new file mode 100644 index 0000000..b4fed9f --- /dev/null +++ b/entities/Tenant.json @@ -0,0 +1,109 @@ +{ + "name": "VirtualMachine", + "type": "object", + "properties": { + "name": { + "type": "string", + "title": "VM Name" + }, + "vmid": { + "type": "number", + "title": "VM ID" + }, + "status": { + "type": "string", + "enum": [ + "running", + "stopped", + "paused", + "migrating", + "error" + ], + "title": "Status" + }, + "type": { + "type": "string", + "enum": [ + "qemu", + "lxc" + ], + "title": "Type" + }, + "node": { + "type": "string", + "title": "Proxmox Node" + }, + "tenant_id": { + "type": "string", + "title": "Tenant ID" + }, + "os_template": { + "type": "string", + "title": "OS Template" + }, + "cpu_cores": { + "type": "number", + "title": "CPU Cores" + }, + "ram_mb": { + "type": "number", + "title": "RAM (MB)" + }, + "disk_gb": { + "type": "number", + "title": "Disk (GB)" + }, + "ip_address": { + "type": "string", + "title": "IP Address" + }, + "cpu_usage": { + "type": "number", + "title": "CPU Usage %" + }, + "ram_usage": { + "type": "number", + "title": "RAM Usage %" + }, + "disk_usage": { + "type": "number", + "title": "Disk Usage %" + }, + "network_in": { + "type": "number", + "title": "Network In (MB)" + }, + "network_out": { + "type": "number", + "title": "Network Out (MB)" + }, + "uptime_seconds": { + "type": "number", + "title": "Uptime (seconds)" + }, + "billing_plan_id": { + "type": "string", + "title": "Billing Plan ID" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "title": "Tags" + }, + "notes": { + "type": "string", + "title": "Notes" + } + }, + "required": [ + "name", + "status", + "type", + "node", + "cpu_cores", + "ram_mb", + "disk_gb" + ] +} \ No newline at end of file diff --git a/entities/UsageRecord.json b/entities/UsageRecord.json new file mode 100644 index 0000000..6018bc6 --- /dev/null +++ b/entities/UsageRecord.json @@ -0,0 +1,37 @@ +import { useEffect } from 'react'; +import { Outlet } from 'react-router-dom'; +import { useAuth } from '@/lib/AuthContext'; +import UserNotRegisteredError from '@/components/UserNotRegisteredError'; + +const DefaultFallback = () => ( +
+
+
+); + +export default function ProtectedRoute({ fallback = , unauthenticatedElement }) { + const { isAuthenticated, isLoadingAuth, authChecked, authError, checkUserAuth } = useAuth(); + + useEffect(() => { + if (!authChecked && !isLoadingAuth) { + checkUserAuth(); + } + }, [authChecked, isLoadingAuth, checkUserAuth]); + + if (isLoadingAuth || !authChecked) { + return fallback; + } + + if (authError) { + if (authError.type === 'user_not_registered') { + return ; + } + return unauthenticatedElement; + } + + if (!isAuthenticated) { + return unauthenticatedElement; + } + + return ; +} diff --git a/entities/VirtualMachine.json b/entities/VirtualMachine.json new file mode 100644 index 0000000..d94fcad --- /dev/null +++ b/entities/VirtualMachine.json @@ -0,0 +1,63 @@ +{ + "name": "AuditLog", + "type": "object", + "properties": { + "action": { + "type": "string", + "title": "Action" + }, + "resource_type": { + "type": "string", + "enum": [ + "vm", + "tenant", + "user", + "backup", + "invoice", + "node", + "system" + ], + "title": "Resource Type" + }, + "resource_id": { + "type": "string", + "title": "Resource ID" + }, + "resource_name": { + "type": "string", + "title": "Resource Name" + }, + "actor_email": { + "type": "string", + "title": "Actor Email" + }, + "actor_role": { + "type": "string", + "title": "Actor Role" + }, + "severity": { + "type": "string", + "enum": [ + "info", + "warning", + "error", + "critical" + ], + "title": "Severity" + }, + "details": { + "type": "string", + "title": "Details JSON" + }, + "ip_address": { + "type": "string", + "title": "IP Address" + } + }, + "required": [ + "action", + "resource_type", + "actor_email", + "severity" + ] +} \ No newline at end of file diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..39efd32 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,42 @@ +import globals from "globals"; +import pluginJs from "@eslint/js"; +import pluginReact from "eslint-plugin-react"; +import pluginReactHooks from "eslint-plugin-react-hooks"; +import pluginUnusedImports from "eslint-plugin-unused-imports"; +import reactRefresh from "eslint-plugin-react-refresh"; + +export default [ + { ignores: ["dist", "node_modules", "backend/dist"] }, + { + files: ["src/**/*.{js,jsx}"], + languageOptions: { + ecmaVersion: 2022, + sourceType: "module", + globals: globals.browser, + parserOptions: { ecmaFeatures: { jsx: true } } + }, + plugins: { + react: pluginReact, + "react-hooks": pluginReactHooks, + "react-refresh": reactRefresh, + "unused-imports": pluginUnusedImports + }, + settings: { + react: { version: "detect" } + }, + rules: { + ...pluginJs.configs.recommended.rules, + ...pluginReact.configs.recommended.rules, + ...pluginReactHooks.configs.recommended.rules, + "react/react-in-jsx-scope": "off", + "react/prop-types": "off", + "no-unused-vars": "off", + "unused-imports/no-unused-imports": "error", + "unused-imports/no-unused-vars": [ + "warn", + { vars: "all", varsIgnorePattern: "^_", args: "after-used", argsIgnorePattern: "^_" } + ], + "react-refresh/only-export-components": ["warn", { allowConstantExport: true }] + } + } +]; diff --git a/index.html b/index.html new file mode 100644 index 0000000..b98a6a3 --- /dev/null +++ b/index.html @@ -0,0 +1,12 @@ + + + + + + ProxPanel + + +
+ + + diff --git a/infra/deploy/docker-compose.production.yml b/infra/deploy/docker-compose.production.yml new file mode 100644 index 0000000..e2ddf93 --- /dev/null +++ b/infra/deploy/docker-compose.production.yml @@ -0,0 +1,76 @@ +services: + postgres: + image: postgres:16-alpine + container_name: proxpanel-postgres + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER:-proxpanel} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB:-proxpanel} + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-proxpanel} -d ${POSTGRES_DB:-proxpanel}"] + interval: 10s + timeout: 5s + retries: 10 + + backend: + build: + context: ../../backend + container_name: proxpanel-backend + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + environment: + NODE_ENV: production + PORT: 8080 + DATABASE_URL: postgresql://${POSTGRES_USER:-proxpanel}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-proxpanel} + JWT_SECRET: ${JWT_SECRET} + JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET} + JWT_EXPIRES_IN: ${JWT_EXPIRES_IN:-15m} + JWT_REFRESH_EXPIRES_IN: ${JWT_REFRESH_EXPIRES_IN:-30d} + CORS_ORIGIN: ${CORS_ORIGIN} + RATE_LIMIT_WINDOW_MS: ${RATE_LIMIT_WINDOW_MS:-60000} + RATE_LIMIT_MAX: ${RATE_LIMIT_MAX:-600} + AUTH_RATE_LIMIT_WINDOW_MS: ${AUTH_RATE_LIMIT_WINDOW_MS:-60000} + AUTH_RATE_LIMIT_MAX: ${AUTH_RATE_LIMIT_MAX:-20} + ENABLE_SCHEDULER: ${ENABLE_SCHEDULER:-true} + BILLING_CRON: ${BILLING_CRON:-0 * * * *} + BACKUP_CRON: ${BACKUP_CRON:-*/15 * * * *} + POWER_SCHEDULE_CRON: ${POWER_SCHEDULE_CRON:-* * * * *} + MONITORING_CRON: ${MONITORING_CRON:-*/5 * * * *} + PROXMOX_TIMEOUT_MS: ${PROXMOX_TIMEOUT_MS:-15000} + ADMIN_EMAIL: ${ADMIN_EMAIL} + ADMIN_PASSWORD: ${ADMIN_PASSWORD} + expose: + - "8080" + ports: + - "127.0.0.1:${BACKEND_PORT:-8080}:8080" + healthcheck: + test: ["CMD-SHELL", "wget -q -O - http://localhost:8080/api/health >/dev/null 2>&1 || exit 1"] + interval: 15s + timeout: 5s + retries: 10 + + frontend: + build: + context: ../../ + args: + VITE_API_BASE_URL: "" + container_name: proxpanel-frontend + restart: unless-stopped + depends_on: + backend: + condition: service_healthy + ports: + - "127.0.0.1:${FRONTEND_PORT:-80}:80" + healthcheck: + test: ["CMD-SHELL", "nginx -t >/dev/null 2>&1 || exit 1"] + interval: 15s + timeout: 5s + retries: 10 + +volumes: + postgres_data: diff --git a/infra/deploy/install-proxpanel.sh b/infra/deploy/install-proxpanel.sh new file mode 100644 index 0000000..2bc2972 --- /dev/null +++ b/infra/deploy/install-proxpanel.sh @@ -0,0 +1,345 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +APP_DIR="/opt/proxpanel" +REPO_URL="" +BRANCH="main" +PUBLIC_URL="" +ADMIN_EMAIL="admin@proxpanel.local" +ADMIN_PASSWORD="" +POSTGRES_PASSWORD="" +FRONTEND_PORT="80" +BACKEND_PORT="8080" +CONFIGURE_UFW="false" + +log() { + printf '\n[%s] %s\n' "$(date +'%Y-%m-%d %H:%M:%S')" "$*" +} + +die() { + printf '\n[ERROR] %s\n' "$*" >&2 + exit 1 +} + +usage() { + cat <<'EOF' +Usage: + bash infra/deploy/install-proxpanel.sh [options] + +Options: + --repo-url Git repository URL (required if app is not already in /opt/proxpanel) + --branch Git branch to deploy (default: main) + --app-dir Deployment directory (default: /opt/proxpanel) + --public-url Public base URL (example: http://102.69.243.167) + --admin-email Initial admin email (default: admin@proxpanel.local) + --admin-password Initial admin password (auto-generated if omitted) + --postgres-password Postgres password (auto-generated if omitted) + --frontend-port Public frontend port (default: 80) + --backend-port Local backend bind port (default: 8080 on 127.0.0.1) + --configure-ufw Allow OpenSSH + frontend port via UFW (if available) + -h, --help Show this help + +Examples: + bash infra/deploy/install-proxpanel.sh \ + --repo-url https://github.com/your-org/proxpanel.git \ + --branch main \ + --public-url http://102.69.243.167 \ + --admin-email admin@yourdomain.com +EOF +} + +random_secret() { + openssl rand -base64 72 | tr -d '\n' +} + +random_db_password() { + # URL-safe hex string to avoid DATABASE_URL parsing issues. + openssl rand -hex 32 | tr -d '\n' +} + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --repo-url) + REPO_URL="${2:-}" + shift 2 + ;; + --branch) + BRANCH="${2:-}" + shift 2 + ;; + --app-dir) + APP_DIR="${2:-}" + shift 2 + ;; + --public-url) + PUBLIC_URL="${2:-}" + shift 2 + ;; + --admin-email) + ADMIN_EMAIL="${2:-}" + shift 2 + ;; + --admin-password) + ADMIN_PASSWORD="${2:-}" + shift 2 + ;; + --postgres-password) + POSTGRES_PASSWORD="${2:-}" + shift 2 + ;; + --frontend-port) + FRONTEND_PORT="${2:-}" + shift 2 + ;; + --backend-port) + BACKEND_PORT="${2:-}" + shift 2 + ;; + --configure-ufw) + CONFIGURE_UFW="true" + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + die "Unknown argument: $1" + ;; + esac + done +} + +ensure_root() { + if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then + die "Run as root (or with sudo)." + fi +} + +install_prereqs() { + log "Installing OS prerequisites..." + export DEBIAN_FRONTEND=noninteractive + apt-get update -y + apt-get install -y ca-certificates curl git openssl jq rsync +} + +install_docker_if_needed() { + if command -v docker >/dev/null 2>&1; then + log "Docker already installed." + else + log "Installing Docker..." + curl -fsSL https://get.docker.com | sh + fi + + systemctl enable docker >/dev/null 2>&1 || true + systemctl start docker + + docker compose version >/dev/null 2>&1 || die "Docker Compose plugin is required but not available." +} + +sync_source() { + if [[ -d "${APP_DIR}/.git" ]]; then + log "Updating existing repository in ${APP_DIR}..." + git -C "${APP_DIR}" fetch --all --prune + git -C "${APP_DIR}" checkout "${BRANCH}" + git -C "${APP_DIR}" pull --ff-only origin "${BRANCH}" + return + fi + + if [[ -n "${REPO_URL}" ]]; then + log "Cloning repository into ${APP_DIR}..." + mkdir -p "$(dirname "${APP_DIR}")" + git clone --branch "${BRANCH}" --single-branch "${REPO_URL}" "${APP_DIR}" + return + fi + + if [[ -f "./package.json" && -d "./backend" && -d "./infra" ]]; then + log "No repo URL provided; copying current directory into ${APP_DIR}..." + mkdir -p "${APP_DIR}" + rsync -a --delete --exclude .git --exclude node_modules --exclude backend/node_modules ./ "${APP_DIR}/" + return + fi + + die "Could not determine source. Provide --repo-url or run this script from project root." +} + +validate_project_layout() { + [[ -f "${APP_DIR}/infra/deploy/docker-compose.production.yml" ]] || die "Missing infra/deploy/docker-compose.production.yml" + [[ -f "${APP_DIR}/backend/Dockerfile" ]] || die "Missing backend/Dockerfile" + [[ -f "${APP_DIR}/Dockerfile" ]] || die "Missing frontend Dockerfile" +} + +infer_public_url() { + if [[ -n "${PUBLIC_URL}" ]]; then + return + fi + + local ip + ip="$(hostname -I | awk '{print $1}')" + [[ -n "${ip}" ]] || ip="127.0.0.1" + PUBLIC_URL="http://${ip}" +} + +write_env_file() { + [[ -n "${ADMIN_PASSWORD}" ]] || ADMIN_PASSWORD="$(openssl rand -base64 18 | tr -d '\n' | tr '/+' 'ab')A9!" + [[ -n "${POSTGRES_PASSWORD}" ]] || POSTGRES_PASSWORD="$(random_db_password)" + + local jwt_secret jwt_refresh_secret env_file + jwt_secret="$(random_secret)" + jwt_refresh_secret="$(random_secret)" + env_file="${APP_DIR}/.env.production" + + log "Writing production env file..." + umask 077 + cat > "${env_file}" </dev/null 2>&1; then + log "Backend is healthy." + return + fi + sleep 3 + done + die "Backend health check failed." +} + +apply_database_schema() { + log "Applying database schema..." + cd "${APP_DIR}" + + if docker compose --env-file .env.production -f infra/deploy/docker-compose.production.yml exec -T backend npm run prisma:deploy; then + log "Schema migration deploy completed." + return + fi + + log "prisma:deploy failed or no migrations found. Falling back to prisma:push..." + docker compose --env-file .env.production -f infra/deploy/docker-compose.production.yml exec -T backend npm run prisma:push + log "Schema push completed." +} + +seed_database() { + log "Running Prisma seed (idempotent)..." + cd "${APP_DIR}" + docker compose --env-file .env.production -f infra/deploy/docker-compose.production.yml exec -T backend npm run prisma:seed +} + +verify_login() { + log "Verifying login endpoint with seeded admin credentials..." + local status + status="$(curl -s -o /tmp/proxpanel-login.json -w "%{http_code}" \ + -X POST "http://127.0.0.1:${FRONTEND_PORT}/api/auth/login" \ + -H "Content-Type: application/json" \ + -d "{\"email\":\"${ADMIN_EMAIL}\",\"password\":\"${ADMIN_PASSWORD}\"}")" + + if [[ "${status}" != "200" ]]; then + cat /tmp/proxpanel-login.json >&2 || true + die "Login verification failed with status ${status}." + fi + + jq -e '.token and .refresh_token' /tmp/proxpanel-login.json >/dev/null 2>&1 || die "Login response missing token." + log "Login verification passed." +} + +configure_ufw_if_requested() { + if [[ "${CONFIGURE_UFW}" != "true" ]]; then + return + fi + + if ! command -v ufw >/dev/null 2>&1; then + log "UFW not installed; skipping firewall configuration." + return + fi + + log "Configuring UFW rules..." + ufw allow OpenSSH >/dev/null 2>&1 || true + ufw allow "${FRONTEND_PORT}/tcp" >/dev/null 2>&1 || true + ufw --force enable >/dev/null 2>&1 || true +} + +write_summary() { + local summary_file="/root/proxpanel-install-summary.txt" + cat > "${summary_file}" < Proxmox. + 3) Add TLS/reverse-proxy (Nginx/Caddy) if exposing publicly. +EOF + chmod 600 "${summary_file}" + log "Summary saved to ${summary_file}" +} + +main() { + parse_args "$@" + ensure_root + install_prereqs + install_docker_if_needed + sync_source + validate_project_layout + infer_public_url + write_env_file + deploy_stack + wait_for_health + apply_database_schema + seed_database + verify_login + configure_ufw_if_requested + write_summary + + log "Deployment finished successfully." + printf '\nOpen: %s\n' "${PUBLIC_URL}" + printf 'Admin email: %s\n' "${ADMIN_EMAIL}" + printf 'Admin password: %s\n' "${ADMIN_PASSWORD}" +} + +main "$@" diff --git a/infra/nginx/default.conf b/infra/nginx/default.conf new file mode 100644 index 0000000..f8d6457 --- /dev/null +++ b/infra/nginx/default.conf @@ -0,0 +1,29 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + location /api/ { + proxy_pass http://backend:8080/api/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 90s; + } + + location = /api/health { + proxy_pass http://backend:8080/api/health; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location / { + try_files $uri /index.html; + } +} diff --git a/jsconfig.json b/jsconfig.json new file mode 100644 index 0000000..69022f8 --- /dev/null +++ b/jsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "allowJs": true, + "checkJs": false, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + }, + "types": ["vite/client"] + }, + "include": ["src", "vite.config.js"] +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..3f5859d --- /dev/null +++ b/package-lock.json @@ -0,0 +1,8290 @@ +{ + "name": "proxpanel-app", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "proxpanel-app", + "version": "1.0.0", + "dependencies": { + "@hookform/resolvers": "^4.1.2", + "@radix-ui/react-accordion": "^1.2.3", + "@radix-ui/react-alert-dialog": "^1.1.6", + "@radix-ui/react-aspect-ratio": "^1.1.2", + "@radix-ui/react-avatar": "^1.1.3", + "@radix-ui/react-checkbox": "^1.1.4", + "@radix-ui/react-collapsible": "^1.1.3", + "@radix-ui/react-context-menu": "^2.2.6", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-dropdown-menu": "^2.1.6", + "@radix-ui/react-hover-card": "^1.1.6", + "@radix-ui/react-label": "^2.1.2", + "@radix-ui/react-menubar": "^1.1.6", + "@radix-ui/react-navigation-menu": "^1.2.5", + "@radix-ui/react-popover": "^1.1.6", + "@radix-ui/react-progress": "^1.1.2", + "@radix-ui/react-radio-group": "^1.2.3", + "@radix-ui/react-scroll-area": "^1.2.3", + "@radix-ui/react-select": "^2.1.6", + "@radix-ui/react-separator": "^1.1.2", + "@radix-ui/react-slider": "^1.2.3", + "@radix-ui/react-slot": "^1.1.2", + "@radix-ui/react-switch": "^1.1.3", + "@radix-ui/react-tabs": "^1.1.3", + "@radix-ui/react-toast": "^1.2.2", + "@radix-ui/react-toggle": "^1.1.2", + "@radix-ui/react-toggle-group": "^1.1.2", + "@radix-ui/react-tooltip": "^1.1.8", + "@tanstack/react-query": "^5.84.1", + "axios": "^1.9.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.0.0", + "date-fns": "^3.6.0", + "embla-carousel-react": "^8.5.2", + "framer-motion": "^11.16.4", + "input-otp": "^1.4.2", + "lucide-react": "^0.475.0", + "moment": "^2.30.1", + "next-themes": "^0.4.4", + "react": "^18.2.0", + "react-day-picker": "^8.10.1", + "react-dom": "^18.2.0", + "react-hook-form": "^7.54.2", + "react-resizable-panels": "^2.1.7", + "react-router-dom": "^6.26.0", + "recharts": "^2.15.4", + "sonner": "^2.0.1", + "tailwind-merge": "^3.0.2", + "tailwindcss-animate": "^1.0.7", + "vaul": "^1.1.2" + }, + "devDependencies": { + "@eslint/js": "^9.19.0", + "@types/node": "^22.13.5", + "@types/react": "^18.2.66", + "@types/react-dom": "^18.2.22", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "eslint": "^9.19.0", + "eslint-plugin-react": "^7.37.4", + "eslint-plugin-react-hooks": "^5.0.0", + "eslint-plugin-react-refresh": "^0.4.18", + "eslint-plugin-unused-imports": "^4.3.0", + "globals": "^15.14.0", + "postcss": "^8.5.3", + "tailwindcss": "^3.4.17", + "typescript": "^5.8.2", + "vite": "^6.1.0" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "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.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "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/@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/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@hookform/resolvers": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-4.1.3.tgz", + "integrity": "sha512-Jsv6UOWYTrEFJ/01ZrnwVXs7KDvP8XIo115i++5PWvNkNvkrsTfGiLS6w+eJ57CYtUtDQalUWovCZDHFJ8u1VQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "react-hook-form": "^7.0.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "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==", + "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==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@radix-ui/number": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-accordion": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz", + "integrity": "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collapsible": "1.1.12", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz", + "integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dialog": "1.1.15", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-aspect-ratio": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.8.tgz", + "integrity": "sha512-5nZrJTF7gH+e0nZS7/QxFz6tJV4VimhQb1avEgtsJxvvIp5JilL+c58HICsKzPxghdwaDt48hEfPM1au4zGy+w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-aspect-ratio/node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.11.tgz", + "integrity": "sha512-0Qk603AHGV28BOBO34p7IgD5m+V5Sg/YovfayABkoDDBM5d3NCx0Mp4gGrjzLGes1jV5eNOE1r3itqOR33VC6Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.3", + "@radix-ui/react-primitive": "2.1.4", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar/node_modules/@radix-ui/react-context": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.3.tgz", + "integrity": "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar/node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz", + "integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", + "integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu": { + "version": "2.2.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.16.tgz", + "integrity": "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz", + "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.15.tgz", + "integrity": "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.8.tgz", + "integrity": "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", + "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menubar": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.16.tgz", + "integrity": "sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-navigation-menu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.14.tgz", + "integrity": "sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", + "integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.8.tgz", + "integrity": "sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.3", + "@radix-ui/react-primitive": "2.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-context": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.3.tgz", + "integrity": "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz", + "integrity": "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz", + "integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", + "integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz", + "integrity": "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz", + "integrity": "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", + "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz", + "integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", + "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.15.tgz", + "integrity": "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz", + "integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle-group": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.11.tgz", + "integrity": "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-toggle": "1.1.10", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", + "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.0.tgz", + "integrity": "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.5.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "license": "MIT" + }, + "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.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, + "node_modules/@tanstack/query-core": { + "version": "5.99.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.99.0.tgz", + "integrity": "sha512-3Jv3WQG0BCcH7G+7lf/bP8QyBfJOXeY+T08Rin3GZ1bshvwlbPt7NrDHMEzGdKIOmOzvIQmxjk28YEQX60k7pQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.99.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.99.0.tgz", + "integrity": "sha512-OY2bCqPemT1LlqJ8Y2CUau4KELnIhhG9Ol3ZndPbdnB095pRbPo1cHuXTndg8iIwtoHTgwZjyaDnQ0xD0mYwAw==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.99.0" + }, + "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/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "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/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", + "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "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==", + "devOptional": 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==", + "devOptional": 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==", + "devOptional": 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/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "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/autoprefixer": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axios": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", + "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.19.tgz", + "integrity": "sha512-qCkNLi2sfBOn8XhZQ0FXsT1Ki/Yo5P90hrkRamVFRS7/KV9hpfA4HkoWNU152+8w0zPjnxo5psx5NL3PSGgv5g==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "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": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001788", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz", + "integrity": "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==", + "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/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cmdk": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz", + "integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-id": "^1.1.0", + "@radix-ui/react-primitive": "^2.0.2" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "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/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "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/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/date-fns": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", + "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "license": "MIT" + }, + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "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.339", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.339.tgz", + "integrity": "sha512-Is+0BBHJ4NrdpAYiperrmp53pLywG/yV/6lIMTAnhxvzj/Cmn5Q/ogSHC6AKe7X+8kPLxxFk0cs5oc/3j/fxIg==", + "dev": true, + "license": "ISC" + }, + "node_modules/embla-carousel": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", + "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", + "license": "MIT" + }, + "node_modules/embla-carousel-react": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel-react/-/embla-carousel-react-8.6.0.tgz", + "integrity": "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==", + "license": "MIT", + "dependencies": { + "embla-carousel": "8.6.0", + "embla-carousel-reactive-utils": "8.6.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/embla-carousel-reactive-utils": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.6.0.tgz", + "integrity": "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==", + "license": "MIT", + "peerDependencies": { + "embla-carousel": "8.6.0" + } + }, + "node_modules/es-abstract": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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-iterator-helpers": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.2.tgz", + "integrity": "sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.1.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.5", + "math-intrinsics": "^1.1.0" + }, + "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/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.26", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-plugin-unused-imports": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-4.4.1.tgz", + "integrity": "sha512-oZGYUz1X3sRMGUB+0cZyK2VcvRX5lm/vB56PgNNcU+7ficUCKm66oZWKUubXWnOuPjQ8PvmXtCViXBMONPe7tQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@typescript-eslint/eslint-plugin": "^8.0.0-0 || ^7.0.0 || ^6.0.0 || ^5.0.0", + "eslint": "^10.0.0 || ^9.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/eslint-plugin": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-equals": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", + "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "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/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/framer-motion": { + "version": "11.18.2", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.18.2.tgz", + "integrity": "sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w==", + "license": "MIT", + "dependencies": { + "motion-dom": "^11.18.1", + "motion-utils": "^11.18.1", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "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/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "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-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "15.15.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", + "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "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.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/input-otp": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.4.2.tgz", + "integrity": "sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "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/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "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/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "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/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "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/lucide-react": { + "version": "0.475.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.475.0.tgz", + "integrity": "sha512-NJzvVu1HwFVeZ+Gwq2q00KygM1aBhy/ZrhY9FsAgJtpB+E4R7uxRk9M2iKvHa6/vNxZydIB59htha4c2vvwvVg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "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/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "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/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/motion-dom": { + "version": "11.18.1", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.18.1.tgz", + "integrity": "sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw==", + "license": "MIT", + "dependencies": { + "motion-utils": "^11.18.1" + } + }, + "node_modules/motion-utils": { + "version": "11.18.1", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-11.18.1.tgz", + "integrity": "sha512-49Kt+HKjtbJKLtgO/LKj9Ld+6vw9BjH5d9sc40R/kVyH8GLAXgT42M2NnuPcJNuA3s9ZfZBUcwIgpmZWGEE+hA==", + "license": "MIT" + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "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/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/next-themes": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", + "integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" + } + }, + "node_modules/node-exports-info": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", + "integrity": "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "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/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-import/node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "license": "MIT" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "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/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/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-day-picker": { + "version": "8.10.1", + "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.10.1.tgz", + "integrity": "sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA==", + "license": "MIT", + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/gpbl" + }, + "peerDependencies": { + "date-fns": "^2.28.0 || ^3.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.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-hook-form": { + "version": "7.72.1", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.72.1.tgz", + "integrity": "sha512-RhwBoy2ygeVZje+C+bwJ8g0NjTdBmDlJvAUHTxRjTmSUKPYsKfMphkS2sgEMotsY03bP358yEYlnUeZy//D9Ig==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "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-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-resizable-panels": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-2.1.9.tgz", + "integrity": "sha512-z77+X08YDIrgAes4jl8xhnUu1LNIRp4+E7cv4xHmLOxxUPO/ML7PSrE813b90vj7xvQ1lcf7g2uA9GeMZonjhQ==", + "license": "MIT", + "peerDependencies": { + "react": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "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/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/recharts": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/recharts/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/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve": { + "version": "2.0.0-next.6", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", + "integrity": "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "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.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/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/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sonner": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwind-merge": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", + "integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tailwindcss-animate": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz", + "integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==", + "license": "MIT", + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders" + } + }, + "node_modules/tailwindcss/node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "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/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/vaul": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz", + "integrity": "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-dialog": "^1.1.1" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "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/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..544120a --- /dev/null +++ b/package.json @@ -0,0 +1,85 @@ +{ + "name": "proxpanel-app", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "dev:api": "npm --prefix backend run dev", + "build": "vite build", + "build:api": "npm --prefix backend run build", + "lint": "eslint . --quiet", + "preview": "vite preview" + }, + "dependencies": { + "@hookform/resolvers": "^4.1.2", + "@radix-ui/react-accordion": "^1.2.3", + "@radix-ui/react-alert-dialog": "^1.1.6", + "@radix-ui/react-aspect-ratio": "^1.1.2", + "@radix-ui/react-avatar": "^1.1.3", + "@radix-ui/react-checkbox": "^1.1.4", + "@radix-ui/react-collapsible": "^1.1.3", + "@radix-ui/react-context-menu": "^2.2.6", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-dropdown-menu": "^2.1.6", + "@radix-ui/react-hover-card": "^1.1.6", + "@radix-ui/react-label": "^2.1.2", + "@radix-ui/react-menubar": "^1.1.6", + "@radix-ui/react-navigation-menu": "^1.2.5", + "@radix-ui/react-popover": "^1.1.6", + "@radix-ui/react-progress": "^1.1.2", + "@radix-ui/react-radio-group": "^1.2.3", + "@radix-ui/react-scroll-area": "^1.2.3", + "@radix-ui/react-select": "^2.1.6", + "@radix-ui/react-separator": "^1.1.2", + "@radix-ui/react-slider": "^1.2.3", + "@radix-ui/react-slot": "^1.1.2", + "@radix-ui/react-switch": "^1.1.3", + "@radix-ui/react-tabs": "^1.1.3", + "@radix-ui/react-toast": "^1.2.2", + "@radix-ui/react-toggle": "^1.1.2", + "@radix-ui/react-toggle-group": "^1.1.2", + "@radix-ui/react-tooltip": "^1.1.8", + "@tanstack/react-query": "^5.84.1", + "axios": "^1.9.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.0.0", + "date-fns": "^3.6.0", + "embla-carousel-react": "^8.5.2", + "framer-motion": "^11.16.4", + "input-otp": "^1.4.2", + "lucide-react": "^0.475.0", + "moment": "^2.30.1", + "next-themes": "^0.4.4", + "react": "^18.2.0", + "react-day-picker": "^8.10.1", + "react-dom": "^18.2.0", + "react-hook-form": "^7.54.2", + "react-resizable-panels": "^2.1.7", + "react-router-dom": "^6.26.0", + "recharts": "^2.15.4", + "sonner": "^2.0.1", + "tailwind-merge": "^3.0.2", + "tailwindcss-animate": "^1.0.7", + "vaul": "^1.1.2" + }, + "devDependencies": { + "@eslint/js": "^9.19.0", + "@types/node": "^22.13.5", + "@types/react": "^18.2.66", + "@types/react-dom": "^18.2.22", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "eslint": "^9.19.0", + "eslint-plugin-react": "^7.37.4", + "eslint-plugin-react-hooks": "^5.0.0", + "eslint-plugin-react-refresh": "^0.4.18", + "eslint-plugin-unused-imports": "^4.3.0", + "globals": "^15.14.0", + "postcss": "^8.5.3", + "tailwindcss": "^3.4.17", + "typescript": "^5.8.2", + "vite": "^6.1.0" + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..ba80730 --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {} + } +}; diff --git a/src/App.jsx b/src/App.jsx new file mode 100644 index 0000000..13dc41c --- /dev/null +++ b/src/App.jsx @@ -0,0 +1,83 @@ +import { Toaster } from "@/components/ui/toaster" +import { QueryClientProvider } from '@tanstack/react-query' +import { queryClientInstance } from '@/lib/query-client' +import { BrowserRouter as Router, Navigate, Route, Routes } from 'react-router-dom'; +import PageNotFound from './lib/PageNotFound'; +import { AuthProvider, useAuth } from '@/lib/AuthContext'; +import UserNotRegisteredError from '@/components/UserNotRegisteredError'; +import AppLayout from './components/layout/AppLayout'; +import Login from './pages/Login'; +import Dashboard from './pages/Dashboard'; +import VirtualMachines from './pages/VirtualMachines'; +import Nodes from './pages/Nodes'; +import Tenants from './pages/Tenants'; +import Billing from './pages/Billing'; +import Backups from './pages/Backups'; +import Monitoring from './pages/Monitoring'; +import AuditLogs from './pages/AuditLogs'; +import RBAC from './pages/RBAC'; +import Settings from './pages/Settings'; +import Operations from './pages/Operations'; +import Provisioning from './pages/Provisioning'; +import NetworkIpam from './pages/NetworkIpam'; +import ClientArea from './pages/ClientArea'; +import Security from './pages/Security'; + +const AuthenticatedApp = () => { + const { isLoadingAuth, isLoadingPublicSettings, authError, isAuthenticated } = useAuth(); + + // Show loading spinner while checking app public settings or auth + if (isLoadingPublicSettings || isLoadingAuth) { + return ( +
+
+
+ ); + } + + // Handle authentication errors + if (authError?.type === 'user_not_registered') { + return ; + } + + return ( + + : } /> + : }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + : } /> + + ); +}; + + +function App() { + + return ( + + + + + + + + + ) +} + +export default App diff --git a/src/api/appClient.js b/src/api/appClient.js new file mode 100644 index 0000000..d663bc2 --- /dev/null +++ b/src/api/appClient.js @@ -0,0 +1,1129 @@ +const STORAGE_TOKEN_KEY = "proxpanel_access_token"; +const STORAGE_REFRESH_TOKEN_KEY = "proxpanel_refresh_token"; + +const resourceMap = { + AuditLog: "audit-logs", + AppTemplate: "app-templates", + ApplicationGroup: "application-groups", + Backup: "backups", + BackupPolicy: "backup-policies", + BackupRestoreTask: "backup-restore-tasks", + BillingPlan: "billing-plans", + FirewallRule: "firewall-rules", + Invoice: "invoices", + IpAddressPool: "ip-addresses", + IpAssignment: "ip-assignments", + NodePlacementPolicy: "placement-policies", + PrivateNetwork: "private-networks", + PrivateNetworkAttachment: "private-network-attachments", + IpReservedRange: "ip-reserved-ranges", + IpPoolPolicy: "ip-pool-policies", + TenantIpQuota: "tenant-ip-quotas", + ServerHealthCheck: "server-health-checks", + ServerHealthCheckResult: "server-health-check-results", + MonitoringAlertRule: "monitoring-alert-rules", + MonitoringAlertEvent: "monitoring-alert-events", + MonitoringAlertNotification: "monitoring-alert-notifications", + ProxmoxNode: "nodes", + ProvisionedService: "provisioned-services", + SecurityEvent: "security-events", + Tenant: "tenants", + UsageRecord: "usage-records", + User: "users", + VmIdRange: "vmid-ranges", + VirtualMachine: "virtual-machines", + SnapshotJob: "snapshot-jobs" +}; + +const lowerCaseFields = new Set([ + "status", + "type", + "schedule", + "severity", + "direction", + "action", + "protocol", + "applies_to", + "payment_provider", + "event_type", + "resource_type", + "role", + "mode", + "frequency", + "source" +]); + +const numericFields = new Set([ + "amount", + "balance", + "price_monthly", + "price_hourly", + "hours_used", + "price_per_hour", + "total_cost", + "cpu_usage", + "ram_usage", + "disk_usage", + "cpu_cores", + "ram_mb", + "disk_gb", + "ram_total_mb", + "ram_used_mb", + "disk_total_gb", + "disk_used_gb", + "vm_count", + "uptime_seconds", + "size_mb", + "retention_days", + "priority", + "hit_count", + "bandwidth_gb", + "network_in", + "network_out" +]); + +function normalizeValue(key, value) { + if (Array.isArray(value)) { + return value.map((item) => normalizeValue(key, item)); + } + + if (value && typeof value === "object") { + return normalizeRecord(value); + } + + if (typeof value === "string") { + if (lowerCaseFields.has(key)) { + return value.toLowerCase(); + } + + if (numericFields.has(key) && /^-?\d+(\.\d+)?$/.test(value)) { + return Number(value); + } + } + + return value; +} + +function normalizeRecord(record) { + const output = {}; + for (const [key, value] of Object.entries(record)) { + output[key] = normalizeValue(key, value); + } + + if (output.created_at && !output.created_date) { + output.created_date = output.created_at; + } + if (output.updated_at && !output.updated_date) { + output.updated_date = output.updated_at; + } + + return output; +} + +function normalizeEntityPayload(payload) { + if (Array.isArray(payload)) { + return payload.map((item) => normalizeRecord(item)); + } + if (payload && typeof payload === "object") { + return normalizeRecord(payload); + } + return payload; +} + +function getToken() { + if (typeof window === "undefined") return null; + return window.localStorage.getItem(STORAGE_TOKEN_KEY); +} + +function setToken(token) { + if (typeof window === "undefined") return; + + if (token) { + window.localStorage.setItem(STORAGE_TOKEN_KEY, token); + return; + } + + window.localStorage.removeItem(STORAGE_TOKEN_KEY); +} + +function getRefreshToken() { + if (typeof window === "undefined") return null; + return window.localStorage.getItem(STORAGE_REFRESH_TOKEN_KEY); +} + +function setRefreshToken(token) { + if (typeof window === "undefined") return; + + if (token) { + window.localStorage.setItem(STORAGE_REFRESH_TOKEN_KEY, token); + return; + } + + window.localStorage.removeItem(STORAGE_REFRESH_TOKEN_KEY); +} + +function getApiBaseUrl() { + const raw = import.meta.env.VITE_API_BASE_URL; + if (!raw) return ""; + return String(raw).replace(/\/$/, ""); +} + +let refreshInFlight = null; + +async function refreshAccessToken() { + if (refreshInFlight) { + return refreshInFlight; + } + + const refreshToken = getRefreshToken(); + if (!refreshToken) { + throw new Error("No refresh token available"); + } + + refreshInFlight = (async () => { + const response = await fetch(`${getApiBaseUrl()}/api/auth/refresh`, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + refresh_token: refreshToken + }) + }); + + if (!response.ok) { + throw new Error("Refresh token invalid"); + } + + const payload = await response.json(); + if (payload?.token) { + setToken(payload.token); + } + if (payload?.refresh_token) { + setRefreshToken(payload.refresh_token); + } + return payload?.token; + })().finally(() => { + refreshInFlight = null; + }); + + return refreshInFlight; +} + +async function request(path, options = {}, hasRetried = false) { + const headers = { + "Content-Type": "application/json", + ...(options.headers ?? {}) + }; + + const token = getToken(); + if (token) { + headers.Authorization = `Bearer ${token}`; + } + + const response = await fetch(`${getApiBaseUrl()}${path}`, { + ...options, + headers + }); + + if ( + response.status === 401 && + !hasRetried && + path !== "/api/auth/login" && + path !== "/api/auth/refresh" + ) { + try { + await refreshAccessToken(); + return request(path, options, true); + } catch { + setToken(null); + setRefreshToken(null); + } + } + + if (!response.ok) { + const errorBody = await response.json().catch(() => ({})); + const error = new Error(errorBody?.message ?? `Request failed: ${response.status}`); + error.status = response.status; + error.data = errorBody; + throw error; + } + + if (response.status === 204) { + return null; + } + + return response.json(); +} + +function makeEntityClient(entityName) { + const resource = resourceMap[entityName] ?? entityName.toLowerCase(); + + return { + async list(sort = "-created_at", limit = 100) { + const params = new URLSearchParams(); + if (sort) params.set("sort", sort); + if (typeof limit === "number") params.set("limit", String(limit)); + + const payload = await request(`/api/resources/${resource}?${params.toString()}`); + return normalizeEntityPayload(payload?.data ?? []); + }, + + async get(id) { + const payload = await request(`/api/resources/${resource}/${id}`); + return normalizeEntityPayload(payload); + }, + + async create(data) { + const payload = await request(`/api/resources/${resource}`, { + method: "POST", + body: JSON.stringify(data ?? {}) + }); + return normalizeEntityPayload(payload); + }, + + async update(id, data) { + const payload = await request(`/api/resources/${resource}/${id}`, { + method: "PATCH", + body: JSON.stringify(data ?? {}) + }); + return normalizeEntityPayload(payload); + }, + + async delete(id) { + return request(`/api/resources/${resource}/${id}`, { + method: "DELETE" + }); + } + }; +} + +const entities = new Proxy( + {}, + { + get(_target, prop) { + if (typeof prop !== "string") return undefined; + return makeEntityClient(prop); + } + } +); + +const auth = { + async login(email, password) { + const payload = await request("/api/auth/login", { + method: "POST", + body: JSON.stringify({ email, password }) + }); + + if (payload?.token) { + setToken(payload.token); + } + if (payload?.refresh_token) { + setRefreshToken(payload.refresh_token); + } + + return payload; + }, + + async me() { + return request("/api/auth/me"); + }, + + logout(redirectTo) { + setToken(null); + setRefreshToken(null); + + if (redirectTo && typeof window !== "undefined") { + window.location.href = redirectTo; + } + }, + + redirectToLogin(returnUrl) { + if (typeof window === "undefined") return; + + const next = encodeURIComponent(returnUrl || window.location.href); + window.location.href = `/login?next=${next}`; + }, + + setToken, + getToken, + setRefreshToken, + getRefreshToken +}; + +const operations = { + async listTasks(params = {}) { + const query = new URLSearchParams(); + if (params.status) query.set("status", params.status); + if (params.task_type) query.set("task_type", params.task_type); + if (params.vm_id) query.set("vm_id", params.vm_id); + if (typeof params.limit === "number") query.set("limit", String(params.limit)); + if (typeof params.offset === "number") query.set("offset", String(params.offset)); + const qs = query.toString(); + return request(`/api/operations/tasks${qs ? `?${qs}` : ""}`); + }, + + async queueInsights() { + return request("/api/operations/queue-insights"); + }, + + async listPowerSchedules() { + return request("/api/operations/power-schedules"); + }, + + async createPowerSchedule(payload) { + return request("/api/operations/power-schedules", { + method: "POST", + body: JSON.stringify(payload ?? {}) + }); + }, + + async updatePowerSchedule(id, payload) { + return request(`/api/operations/power-schedules/${id}`, { + method: "PATCH", + body: JSON.stringify(payload ?? {}) + }); + }, + + async deletePowerSchedule(id) { + return request(`/api/operations/power-schedules/${id}`, { + method: "DELETE" + }); + }, + + async runPowerScheduleNow(id) { + return request(`/api/operations/power-schedules/${id}/run`, { + method: "POST" + }); + } +}; + +const settings = { + async getProxmox() { + return request("/api/settings/proxmox"); + }, + + async saveProxmox(payload) { + return request("/api/settings/proxmox", { + method: "PUT", + body: JSON.stringify(payload ?? {}) + }); + }, + + async getPayment() { + return request("/api/settings/payment"); + }, + + async savePayment(payload) { + return request("/api/settings/payment", { + method: "PUT", + body: JSON.stringify(payload ?? {}) + }); + }, + + async getBackup() { + return request("/api/settings/backup"); + }, + + async saveBackup(payload) { + return request("/api/settings/backup", { + method: "PUT", + body: JSON.stringify(payload ?? {}) + }); + }, + + async getConsoleProxy() { + return request("/api/settings/console-proxy"); + }, + + async saveConsoleProxy(payload) { + return request("/api/settings/console-proxy", { + method: "PUT", + body: JSON.stringify(payload ?? {}) + }); + }, + + async getScheduler() { + return request("/api/settings/scheduler"); + }, + + async saveScheduler(payload) { + return request("/api/settings/scheduler", { + method: "PUT", + body: JSON.stringify(payload ?? {}) + }); + }, + + async getOperationsPolicy() { + return request("/api/settings/operations-policy"); + }, + + async saveOperationsPolicy(payload) { + return request("/api/settings/operations-policy", { + method: "PUT", + body: JSON.stringify(payload ?? {}) + }); + }, + + async getNotifications() { + return request("/api/settings/notifications"); + }, + + async saveNotifications(payload) { + return request("/api/settings/notifications", { + method: "PUT", + body: JSON.stringify(payload ?? {}) + }); + } +}; + +const provisioning = { + async listTemplates() { + return request("/api/provisioning/templates"); + }, + async createTemplate(payload) { + return request("/api/provisioning/templates", { + method: "POST", + body: JSON.stringify(payload ?? {}) + }); + }, + async updateTemplate(id, payload) { + return request(`/api/provisioning/templates/${id}`, { + method: "PATCH", + body: JSON.stringify(payload ?? {}) + }); + }, + async deleteTemplate(id) { + return request(`/api/provisioning/templates/${id}`, { + method: "DELETE" + }); + }, + async listApplicationGroups() { + return request("/api/provisioning/application-groups"); + }, + async createApplicationGroup(payload) { + return request("/api/provisioning/application-groups", { + method: "POST", + body: JSON.stringify(payload ?? {}) + }); + }, + async updateApplicationGroup(id, payload) { + return request(`/api/provisioning/application-groups/${id}`, { + method: "PATCH", + body: JSON.stringify(payload ?? {}) + }); + }, + async setGroupTemplates(id, payload) { + return request(`/api/provisioning/application-groups/${id}/templates`, { + method: "PUT", + body: JSON.stringify(payload ?? {}) + }); + }, + async listPlacementPolicies() { + return request("/api/provisioning/placement-policies"); + }, + async createPlacementPolicy(payload) { + return request("/api/provisioning/placement-policies", { + method: "POST", + body: JSON.stringify(payload ?? {}) + }); + }, + async updatePlacementPolicy(id, payload) { + return request(`/api/provisioning/placement-policies/${id}`, { + method: "PATCH", + body: JSON.stringify(payload ?? {}) + }); + }, + async listVmidRanges() { + return request("/api/provisioning/vmid-ranges"); + }, + async createVmidRange(payload) { + return request("/api/provisioning/vmid-ranges", { + method: "POST", + body: JSON.stringify(payload ?? {}) + }); + }, + async updateVmidRange(id, payload) { + return request(`/api/provisioning/vmid-ranges/${id}`, { + method: "PATCH", + body: JSON.stringify(payload ?? {}) + }); + }, + async listServices(params = {}) { + const query = new URLSearchParams(); + if (params.lifecycle_status) query.set("lifecycle_status", params.lifecycle_status); + if (typeof params.limit === "number") query.set("limit", String(params.limit)); + if (typeof params.offset === "number") query.set("offset", String(params.offset)); + const qs = query.toString(); + return request(`/api/provisioning/services${qs ? `?${qs}` : ""}`); + }, + async createService(payload) { + return request("/api/provisioning/services", { + method: "POST", + body: JSON.stringify(payload ?? {}) + }); + }, + async suspendService(id, payload) { + return request(`/api/provisioning/services/${id}/suspend`, { + method: "POST", + body: JSON.stringify(payload ?? {}) + }); + }, + async unsuspendService(id) { + return request(`/api/provisioning/services/${id}/unsuspend`, { + method: "POST" + }); + }, + async terminateService(id, payload) { + return request(`/api/provisioning/services/${id}/terminate`, { + method: "POST", + body: JSON.stringify(payload ?? {}) + }); + }, + async updateServicePackage(id, payload) { + return request(`/api/provisioning/services/${id}/package-options`, { + method: "PATCH", + body: JSON.stringify(payload ?? {}) + }); + } +}; + +const backups = { + async listBackups(params = {}) { + const query = new URLSearchParams(); + if (params.status) query.set("status", params.status); + if (params.vm_id) query.set("vm_id", params.vm_id); + if (typeof params.limit === "number") query.set("limit", String(params.limit)); + if (typeof params.offset === "number") query.set("offset", String(params.offset)); + const qs = query.toString(); + return request(`/api/backups${qs ? `?${qs}` : ""}`); + }, + + async createBackup(payload) { + return request("/api/backups", { + method: "POST", + body: JSON.stringify(payload ?? {}) + }); + }, + + async setBackupProtection(id, isProtected) { + return request(`/api/backups/${id}/protection`, { + method: "PATCH", + body: JSON.stringify({ is_protected: Boolean(isProtected) }) + }); + }, + + async deleteBackup(id, options = {}) { + const force = options.force ? "?force=true" : ""; + return request(`/api/backups/${id}${force}`, { + method: "DELETE" + }); + }, + + async listRestoreTasks(params = {}) { + const query = new URLSearchParams(); + if (params.status) query.set("status", params.status); + if (typeof params.limit === "number") query.set("limit", String(params.limit)); + if (typeof params.offset === "number") query.set("offset", String(params.offset)); + const qs = query.toString(); + return request(`/api/backups/restores${qs ? `?${qs}` : ""}`); + }, + + async createRestoreTask(payload) { + return request("/api/backups/restores", { + method: "POST", + body: JSON.stringify(payload ?? {}) + }); + }, + + async runRestoreTask(id) { + return request(`/api/backups/restores/${id}/run`, { + method: "POST" + }); + }, + + async listSnapshotJobs() { + return request("/api/backups/snapshot-jobs"); + }, + + async createSnapshotJob(payload) { + return request("/api/backups/snapshot-jobs", { + method: "POST", + body: JSON.stringify(payload ?? {}) + }); + }, + + async updateSnapshotJob(id, payload) { + return request(`/api/backups/snapshot-jobs/${id}`, { + method: "PATCH", + body: JSON.stringify(payload ?? {}) + }); + }, + + async deleteSnapshotJob(id) { + return request(`/api/backups/snapshot-jobs/${id}`, { + method: "DELETE" + }); + }, + + async runSnapshotJob(id) { + return request(`/api/backups/snapshot-jobs/${id}/run`, { + method: "POST" + }); + }, + + async listPolicies() { + return request("/api/backups/policies"); + }, + + async createPolicy(payload) { + return request("/api/backups/policies", { + method: "POST", + body: JSON.stringify(payload ?? {}) + }); + }, + + async updatePolicy(id, payload) { + return request(`/api/backups/policies/${id}`, { + method: "PATCH", + body: JSON.stringify(payload ?? {}) + }); + } +}; + +const proxmox = { + async sync() { + return request("/api/proxmox/sync", { method: "POST" }); + }, + + async vmAction(vmId, action) { + return request(`/api/proxmox/vms/${vmId}/actions/${action}`, { method: "POST" }); + }, + + async migrateVm(vmId, targetNode) { + return request(`/api/proxmox/vms/${vmId}/migrate`, { + method: "POST", + body: JSON.stringify({ target_node: targetNode }) + }); + }, + + async updateVmConfig(vmId, payload) { + return request(`/api/proxmox/vms/${vmId}/config`, { + method: "PATCH", + body: JSON.stringify(payload ?? {}) + }); + }, + + async reconfigureVmNetwork(vmId, payload) { + return request(`/api/proxmox/vms/${vmId}/network`, { + method: "PATCH", + body: JSON.stringify(payload ?? {}) + }); + }, + + async addVmDisk(vmId, payload) { + return request(`/api/proxmox/vms/${vmId}/disks`, { + method: "POST", + body: JSON.stringify(payload ?? {}) + }); + }, + + async reinstallVm(vmId, payload) { + return request(`/api/proxmox/vms/${vmId}/reinstall`, { + method: "POST", + body: JSON.stringify(payload ?? {}) + }); + }, + + async vmConsole(vmId, consoleType = "novnc") { + return request(`/api/proxmox/vms/${vmId}/console?console_type=${encodeURIComponent(consoleType)}`); + }, + + async vmUsageGraphs(vmId, timeframe = "day") { + return request(`/api/proxmox/vms/${vmId}/usage-graphs?timeframe=${encodeURIComponent(timeframe)}`); + }, + + async nodeUsageGraphs(nodeId, timeframe = "day") { + return request(`/api/proxmox/nodes/${nodeId}/usage-graphs?timeframe=${encodeURIComponent(timeframe)}`); + }, + + async clusterUsageGraphs(timeframe = "day") { + return request(`/api/proxmox/cluster/usage-graphs?timeframe=${encodeURIComponent(timeframe)}`); + } +}; + +const network = { + async listIpAddresses(params = {}) { + const query = new URLSearchParams(); + if (params.status) query.set("status", params.status); + if (params.version) query.set("version", params.version); + if (params.scope) query.set("scope", params.scope); + if (params.node_hostname) query.set("node_hostname", params.node_hostname); + if (params.bridge) query.set("bridge", params.bridge); + if (typeof params.vlan_tag === "number") query.set("vlan_tag", String(params.vlan_tag)); + if (params.assigned_vm_id) query.set("assigned_vm_id", params.assigned_vm_id); + if (typeof params.limit === "number") query.set("limit", String(params.limit)); + if (typeof params.offset === "number") query.set("offset", String(params.offset)); + const qs = query.toString(); + return request(`/api/network/ip-addresses${qs ? `?${qs}` : ""}`); + }, + + async subnetUtilization(params = {}) { + const query = new URLSearchParams(); + if (params.scope) query.set("scope", params.scope); + if (params.version) query.set("version", params.version); + if (params.node_hostname) query.set("node_hostname", params.node_hostname); + if (params.bridge) query.set("bridge", params.bridge); + if (typeof params.vlan_tag === "number") query.set("vlan_tag", String(params.vlan_tag)); + if (params.tenant_id) query.set("tenant_id", params.tenant_id); + const qs = query.toString(); + return request(`/api/network/subnet-utilization${qs ? `?${qs}` : ""}`); + }, + + async importIpAddresses(payload) { + return request("/api/network/ip-addresses/import", { + method: "POST", + body: JSON.stringify(payload ?? {}) + }); + }, + + async listIpAssignments(params = {}) { + const query = new URLSearchParams(); + if (params.vm_id) query.set("vm_id", params.vm_id); + if (params.tenant_id) query.set("tenant_id", params.tenant_id); + if (typeof params.active_only === "boolean") query.set("active_only", String(params.active_only)); + const qs = query.toString(); + return request(`/api/network/ip-assignments${qs ? `?${qs}` : ""}`); + }, + + async assignIpAddress(payload) { + return request("/api/network/ip-assignments", { + method: "POST", + body: JSON.stringify(payload ?? {}) + }); + }, + + async listTenantQuotas(params = {}) { + const query = new URLSearchParams(); + if (params.tenant_id) query.set("tenant_id", params.tenant_id); + const qs = query.toString(); + return request(`/api/network/tenant-quotas${qs ? `?${qs}` : ""}`); + }, + + async upsertTenantQuota(payload) { + return request("/api/network/tenant-quotas", { + method: "POST", + body: JSON.stringify(payload ?? {}) + }); + }, + + async listReservedRanges() { + return request("/api/network/reserved-ranges"); + }, + + async createReservedRange(payload) { + return request("/api/network/reserved-ranges", { + method: "POST", + body: JSON.stringify(payload ?? {}) + }); + }, + + async updateReservedRange(id, payload) { + return request(`/api/network/reserved-ranges/${id}`, { + method: "PATCH", + body: JSON.stringify(payload ?? {}) + }); + }, + + async listPolicies() { + return request("/api/network/policies"); + }, + + async createPolicy(payload) { + return request("/api/network/policies", { + method: "POST", + body: JSON.stringify(payload ?? {}) + }); + }, + + async updatePolicy(id, payload) { + return request(`/api/network/policies/${id}`, { + method: "PATCH", + body: JSON.stringify(payload ?? {}) + }); + }, + + async returnIpAddress(payload) { + return request("/api/network/ip-assignments/return", { + method: "POST", + body: JSON.stringify(payload ?? {}) + }); + }, + + async listPrivateNetworks() { + return request("/api/network/private-networks"); + }, + + async createPrivateNetwork(payload) { + return request("/api/network/private-networks", { + method: "POST", + body: JSON.stringify(payload ?? {}) + }); + }, + + async attachPrivateNetwork(payload) { + return request("/api/network/private-networks/attach", { + method: "POST", + body: JSON.stringify(payload ?? {}) + }); + }, + + async detachPrivateNetwork(attachmentId) { + return request(`/api/network/private-networks/attachments/${attachmentId}/detach`, { + method: "POST" + }); + } +}; + +const dashboard = { + async summary() { + return request("/api/dashboard/summary"); + }, + + async networkUtilization(params = {}) { + const query = new URLSearchParams(); + if (params.scope) query.set("scope", params.scope); + if (params.version) query.set("version", params.version); + if (params.node_hostname) query.set("node_hostname", params.node_hostname); + if (params.bridge) query.set("bridge", params.bridge); + if (typeof params.vlan_tag === "number") query.set("vlan_tag", String(params.vlan_tag)); + if (params.tenant_id) query.set("tenant_id", params.tenant_id); + if (typeof params.days === "number") query.set("days", String(params.days)); + if (typeof params.max_tenants === "number") query.set("max_tenants", String(params.max_tenants)); + const qs = query.toString(); + return request(`/api/dashboard/network-utilization${qs ? `?${qs}` : ""}`); + } +}; + +const monitoring = { + async overview() { + return request("/api/monitoring/overview"); + }, + + async listHealthChecks(params = {}) { + const query = new URLSearchParams(); + if (typeof params.enabled === "boolean") query.set("enabled", String(params.enabled)); + if (params.tenant_id) query.set("tenant_id", params.tenant_id); + const qs = query.toString(); + return request(`/api/monitoring/health-checks${qs ? `?${qs}` : ""}`); + }, + + async createHealthCheck(payload) { + return request("/api/monitoring/health-checks", { + method: "POST", + body: JSON.stringify(payload ?? {}) + }); + }, + + async updateHealthCheck(id, payload) { + return request(`/api/monitoring/health-checks/${id}`, { + method: "PATCH", + body: JSON.stringify(payload ?? {}) + }); + }, + + async runHealthCheck(id) { + return request(`/api/monitoring/health-checks/${id}/run`, { + method: "POST" + }); + }, + + async listHealthCheckResults(id, limit = 50) { + return request(`/api/monitoring/health-checks/${id}/results?limit=${encodeURIComponent(limit)}`); + }, + + async listAlertRules(params = {}) { + const query = new URLSearchParams(); + if (typeof params.enabled === "boolean") query.set("enabled", String(params.enabled)); + if (params.tenant_id) query.set("tenant_id", params.tenant_id); + const qs = query.toString(); + return request(`/api/monitoring/alerts/rules${qs ? `?${qs}` : ""}`); + }, + + async createAlertRule(payload) { + return request("/api/monitoring/alerts/rules", { + method: "POST", + body: JSON.stringify(payload ?? {}) + }); + }, + + async updateAlertRule(id, payload) { + return request(`/api/monitoring/alerts/rules/${id}`, { + method: "PATCH", + body: JSON.stringify(payload ?? {}) + }); + }, + + async listAlertEvents(params = {}) { + const query = new URLSearchParams(); + if (params.status) query.set("status", params.status); + if (params.tenant_id) query.set("tenant_id", params.tenant_id); + if (typeof params.limit === "number") query.set("limit", String(params.limit)); + const qs = query.toString(); + return request(`/api/monitoring/alerts/events${qs ? `?${qs}` : ""}`); + }, + + async listAlertNotifications(params = {}) { + const query = new URLSearchParams(); + if (params.tenant_id) query.set("tenant_id", params.tenant_id); + if (typeof params.limit === "number") query.set("limit", String(params.limit)); + const qs = query.toString(); + return request(`/api/monitoring/alerts/notifications${qs ? `?${qs}` : ""}`); + }, + + async evaluateAlerts() { + return request("/api/monitoring/alerts/evaluate", { + method: "POST" + }); + }, + + async faultyDeployments(days = 14) { + return request(`/api/monitoring/insights/faulty-deployments?days=${encodeURIComponent(days)}`); + }, + + async clusterForecast(horizon_days = 30) { + return request(`/api/monitoring/insights/cluster-forecast?horizon_days=${encodeURIComponent(horizon_days)}`); + } +}; + +const clientArea = { + async overview(params = {}) { + const query = new URLSearchParams(); + if (params.tenant_id) query.set("tenant_id", params.tenant_id); + const qs = query.toString(); + return request(`/api/client/overview${qs ? `?${qs}` : ""}`); + }, + + async usageTrends(params = {}) { + const query = new URLSearchParams(); + if (params.tenant_id) query.set("tenant_id", params.tenant_id); + if (typeof params.days === "number") query.set("days", String(params.days)); + const qs = query.toString(); + return request(`/api/client/usage-trends${qs ? `?${qs}` : ""}`); + }, + + async listMachines(params = {}) { + const query = new URLSearchParams(); + if (params.tenant_id) query.set("tenant_id", params.tenant_id); + const qs = query.toString(); + return request(`/api/client/machines${qs ? `?${qs}` : ""}`); + }, + + async createMachine(payload) { + return request("/api/client/machines", { + method: "POST", + body: JSON.stringify(payload ?? {}) + }); + }, + + async resizeMachine(vmId, payload) { + return request(`/api/client/machines/${vmId}/resources`, { + method: "PATCH", + body: JSON.stringify(payload ?? {}) + }); + }, + + async machineAutomation(vmId) { + return request(`/api/client/machines/${vmId}/automation`); + }, + + async createMachinePowerSchedule(vmId, payload) { + return request(`/api/client/machines/${vmId}/power-schedules`, { + method: "POST", + body: JSON.stringify(payload ?? {}) + }); + }, + + async createMachineBackupSchedule(vmId, payload) { + return request(`/api/client/machines/${vmId}/backup-schedules`, { + method: "POST", + body: JSON.stringify(payload ?? {}) + }); + }, + + async listFirewallRules(params = {}) { + const query = new URLSearchParams(); + if (params.tenant_id) query.set("tenant_id", params.tenant_id); + if (params.vm_id) query.set("vm_id", params.vm_id); + const qs = query.toString(); + return request(`/api/client/firewall/rules${qs ? `?${qs}` : ""}`); + }, + + async createFirewallRule(payload) { + return request("/api/client/firewall/rules", { + method: "POST", + body: JSON.stringify(payload ?? {}) + }); + }, + + async updateFirewallRule(id, payload) { + return request(`/api/client/firewall/rules/${id}`, { + method: "PATCH", + body: JSON.stringify(payload ?? {}) + }); + }, + + async deleteFirewallRule(id) { + return request(`/api/client/firewall/rules/${id}`, { + method: "DELETE" + }); + }, + + async listFirewallPolicyPacks(params = {}) { + const query = new URLSearchParams(); + if (params.tenant_id) query.set("tenant_id", params.tenant_id); + const qs = query.toString(); + return request(`/api/client/firewall/policy-packs${qs ? `?${qs}` : ""}`); + }, + + async createFirewallPolicyPack(payload) { + return request("/api/client/firewall/policy-packs", { + method: "POST", + body: JSON.stringify(payload ?? {}) + }); + }, + + async updateFirewallPolicyPack(id, payload) { + return request(`/api/client/firewall/policy-packs/${id}`, { + method: "PATCH", + body: JSON.stringify(payload ?? {}) + }); + }, + + async deleteFirewallPolicyPack(id) { + return request(`/api/client/firewall/policy-packs/${id}`, { + method: "DELETE" + }); + }, + + async applyFirewallPolicyPack(id, payload) { + return request(`/api/client/firewall/policy-packs/${id}/apply`, { + method: "POST", + body: JSON.stringify(payload ?? {}) + }); + } +}; + +export const appClient = { + auth, + entities, + dashboard, + monitoring, + clientArea, + operations, + settings, + provisioning, + backups, + proxmox, + network +}; diff --git a/src/components/ProtectedRoute.jsx b/src/components/ProtectedRoute.jsx new file mode 100644 index 0000000..94b23af --- /dev/null +++ b/src/components/ProtectedRoute.jsx @@ -0,0 +1,34 @@ +import { useEffect } from "react"; +import { Outlet } from "react-router-dom"; +import { useAuth } from "@/lib/AuthContext"; +import UserNotRegisteredError from "@/components/UserNotRegisteredError"; + +const DefaultFallback = () => ( +
+
+
+); + +export default function ProtectedRoute({ fallback = , unauthenticatedElement = null }) { + const { isAuthenticated, isLoadingAuth, authError, checkAppState } = useAuth(); + + useEffect(() => { + if (!isAuthenticated && !isLoadingAuth) { + checkAppState(); + } + }, [isAuthenticated, isLoadingAuth, checkAppState]); + + if (isLoadingAuth) { + return fallback; + } + + if (authError?.type === "user_not_registered") { + return ; + } + + if (!isAuthenticated) { + return unauthenticatedElement; + } + + return ; +} diff --git a/src/components/UserNotRegisteredError.jsx b/src/components/UserNotRegisteredError.jsx new file mode 100644 index 0000000..ddf436d --- /dev/null +++ b/src/components/UserNotRegisteredError.jsx @@ -0,0 +1,34 @@ +import React from "react"; + +export default function UserNotRegisteredError() { + return ( +
+
+
+
+ + + +
+

Access Restricted

+

+ Your account is authenticated but does not have application access. Contact an administrator to grant the correct role. +

+
+

Quick checks:

+
    +
  • Confirm you signed in with the expected organization account.
  • +
  • Request tenant membership or RBAC role assignment.
  • +
  • Retry login after the admin updates permissions.
  • +
+
+
+
+
+ ); +} diff --git a/src/components/layout/AppLayout.jsx b/src/components/layout/AppLayout.jsx new file mode 100644 index 0000000..e5319d7 --- /dev/null +++ b/src/components/layout/AppLayout.jsx @@ -0,0 +1,54 @@ +import { Bell, ChevronRight, Search } from "lucide-react"; +import { Outlet, useLocation } from "react-router-dom"; +import { Input } from "@/components/ui/input"; +import Sidebar from "./Sidebar"; +import { resolveNavigation } from "./nav-config"; + +export default function AppLayout() { + const location = useLocation(); + const currentNav = resolveNavigation(location.pathname); + + return ( +
+ +
+
+
+
+ Control Plane + + {currentNav?.group ?? "Workspace"} + + {currentNav?.label ?? "Overview"} +
+ +
+ + + + Ctrl K + +
+ + +
+
+ +
+
+ +
+
+
+
+ ); +} diff --git a/src/components/layout/Sidebar.jsx b/src/components/layout/Sidebar.jsx new file mode 100644 index 0000000..5c33b92 --- /dev/null +++ b/src/components/layout/Sidebar.jsx @@ -0,0 +1,164 @@ +import { useState } from "react"; +import { Link, useLocation } from "react-router-dom"; +import { + Activity, + Boxes, + ChevronLeft, + ChevronRight, + CreditCard, + Database, + FileText, + HardDrive, + LayoutDashboard, + ListChecks, + LogOut, + Menu, + Network, + Server, + Settings, + Shield, + Users +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { appClient } from "@/api/appClient"; +import { navigationGroups } from "./nav-config"; + +const iconMap = { + dashboard: LayoutDashboard, + monitoring: Activity, + operations: ListChecks, + audit: FileText, + vms: Server, + nodes: HardDrive, + provisioning: Boxes, + backups: Database, + network: Network, + security: Shield, + tenants: Users, + client: Users, + billing: CreditCard, + rbac: Shield, + settings: Settings +}; + +export default function Sidebar() { + const location = useLocation(); + const [collapsed, setCollapsed] = useState(false); + const [mobileOpen, setMobileOpen] = useState(false); + + const isActive = (path) => { + if (path === "/") return location.pathname === "/"; + return location.pathname.startsWith(path); + }; + + const sidebarContent = ( + + ); + + return ( + <> + + + {mobileOpen && ( +
setMobileOpen(false)}> +
event.stopPropagation()}> + {sidebarContent} +
+
+ )} + +
{sidebarContent}
+ + ); +} diff --git a/src/components/layout/nav-config.js b/src/components/layout/nav-config.js new file mode 100644 index 0000000..3c57140 --- /dev/null +++ b/src/components/layout/nav-config.js @@ -0,0 +1,54 @@ +export const navigationGroups = [ + { + id: "overview", + label: "Overview", + items: [ + { path: "/", label: "Dashboard", iconKey: "dashboard" }, + { path: "/monitoring", label: "Monitoring", iconKey: "monitoring" }, + { path: "/operations", label: "Operations", iconKey: "operations" }, + { path: "/audit-logs", label: "Audit Logs", iconKey: "audit" } + ] + }, + { + id: "compute", + label: "Compute", + items: [ + { path: "/vms", label: "Virtual Machines", iconKey: "vms" }, + { path: "/nodes", label: "Nodes", iconKey: "nodes" }, + { path: "/provisioning", label: "Provisioning", iconKey: "provisioning" }, + { path: "/backups", label: "Backups", iconKey: "backups" } + ] + }, + { + id: "network", + label: "Network", + items: [ + { path: "/network", label: "IPAM & Pools", iconKey: "network" }, + { path: "/security", label: "Security", iconKey: "security" } + ] + }, + { + id: "tenant", + label: "Tenants", + items: [ + { path: "/tenants", label: "Tenants", iconKey: "tenants" }, + { path: "/client", label: "Client Area", iconKey: "client" }, + { path: "/billing", label: "Billing", iconKey: "billing" }, + { path: "/rbac", label: "RBAC", iconKey: "rbac" }, + { path: "/settings", label: "Settings", iconKey: "settings" } + ] + } +]; + +export const flatNavigation = navigationGroups.flatMap((group) => + group.items.map((item) => ({ ...item, group: group.label })) +); + +export function resolveNavigation(pathname) { + if (!pathname || pathname === "/") { + return flatNavigation.find((item) => item.path === "/") ?? null; + } + + const sortedBySpecificity = [...flatNavigation].sort((a, b) => b.path.length - a.path.length); + return sortedBySpecificity.find((item) => item.path !== "/" && pathname.startsWith(item.path)) ?? null; +} diff --git a/src/components/shared/EmptyState.jsx b/src/components/shared/EmptyState.jsx new file mode 100644 index 0000000..33d0348 --- /dev/null +++ b/src/components/shared/EmptyState.jsx @@ -0,0 +1,14 @@ +export default function EmptyState({ icon: Icon, title, description, action }) { + return ( +
+ {Icon ? ( +
+ +
+ ) : null} +

{title}

+ {description ?

{description}

: null} + {action ?
{action}
: null} +
+ ); +} diff --git a/src/components/shared/PageHeader.jsx b/src/components/shared/PageHeader.jsx new file mode 100644 index 0000000..2647248 --- /dev/null +++ b/src/components/shared/PageHeader.jsx @@ -0,0 +1,12 @@ +export default function PageHeader({ title, description, children }) { + return ( +
+
+

Enterprise Console

+

{title}

+ {description ?

{description}

: null} +
+ {children ?
{children}
: null} +
+ ); +} diff --git a/src/components/shared/ResourceBar.jsx b/src/components/shared/ResourceBar.jsx new file mode 100644 index 0000000..79dd96f --- /dev/null +++ b/src/components/shared/ResourceBar.jsx @@ -0,0 +1,25 @@ +import { cn } from "@/lib/utils"; + +export default function ResourceBar({ label, used = 0, total = 0, unit = "", percentage }) { + const computed = percentage ?? (total > 0 ? (used / total) * 100 : 0); + const safePercentage = Math.max(0, Math.min(100, Number.isFinite(computed) ? computed : 0)); + + return ( +
+
+ {label} + + {used} + {unit} / {total} + {unit} + +
+
+
+
+
+ ); +} diff --git a/src/components/shared/StatCard.jsx b/src/components/shared/StatCard.jsx new file mode 100644 index 0000000..9ba658d --- /dev/null +++ b/src/components/shared/StatCard.jsx @@ -0,0 +1,28 @@ +import { cn } from "@/lib/utils"; + +const colorMap = { + primary: "text-primary bg-primary/12 ring-primary/20", + success: "text-emerald-700 bg-emerald-50 ring-emerald-200", + warning: "text-amber-700 bg-amber-50 ring-amber-200", + danger: "text-rose-700 bg-rose-50 ring-rose-200" +}; + +export default function StatCard({ icon: Icon, label, value, subtitle, trend, color = "primary" }) { + return ( +
+
+
+

{label}

+

{value}

+ {subtitle ?

{subtitle}

: null} + {trend ?

{trend}

: null} +
+ {Icon ? ( +
+ +
+ ) : null} +
+
+ ); +} diff --git a/src/components/shared/StatusBadge.jsx b/src/components/shared/StatusBadge.jsx new file mode 100644 index 0000000..0b794d7 --- /dev/null +++ b/src/components/shared/StatusBadge.jsx @@ -0,0 +1,35 @@ +import { cn } from "@/lib/utils"; + +const statusColors = { + running: "bg-emerald-50 text-emerald-700 border-emerald-200", + active: "bg-emerald-50 text-emerald-700 border-emerald-200", + online: "bg-emerald-50 text-emerald-700 border-emerald-200", + paid: "bg-emerald-50 text-emerald-700 border-emerald-200", + completed: "bg-emerald-50 text-emerald-700 border-emerald-200", + success: "bg-emerald-50 text-emerald-700 border-emerald-200", + pending: "bg-amber-50 text-amber-700 border-amber-200", + warning: "bg-amber-50 text-amber-700 border-amber-200", + stopped: "bg-slate-100 text-slate-700 border-slate-200", + offline: "bg-slate-100 text-slate-700 border-slate-200", + failed: "bg-rose-50 text-rose-700 border-rose-200", + critical: "bg-rose-50 text-rose-700 border-rose-200", + error: "bg-rose-50 text-rose-700 border-rose-200", + default: "bg-muted text-muted-foreground border-border" +}; + +export default function StatusBadge({ status, size = "sm" }) { + const normalized = String(status ?? "").toLowerCase(); + const sizeClass = size === "lg" ? "px-2.5 py-1 text-xs" : "px-2 py-0.5 text-[11px]"; + + return ( + + {normalized || "unknown"} + + ); +} diff --git a/src/components/ui/UserNotRegisteredError.jsx b/src/components/ui/UserNotRegisteredError.jsx new file mode 100644 index 0000000..9c47a0d --- /dev/null +++ b/src/components/ui/UserNotRegisteredError.jsx @@ -0,0 +1,19 @@ +import * as React from "react" + +const MOBILE_BREAKPOINT = 768 + +export function useIsMobile() { + const [isMobile, setIsMobile] = React.useState(undefined) + + React.useEffect(() => { + const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) + const onChange = () => { + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) + } + mql.addEventListener("change", onChange) + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) + return () => mql.removeEventListener("change", onChange); + }, []) + + return !!isMobile +} diff --git a/src/components/ui/accordion.jsx b/src/components/ui/accordion.jsx new file mode 100644 index 0000000..a4174f3 --- /dev/null +++ b/src/components/ui/accordion.jsx @@ -0,0 +1,97 @@ +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +const AlertDialog = AlertDialogPrimitive.Root + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger + +const AlertDialogPortal = AlertDialogPrimitive.Portal + +const AlertDialogOverlay = React.forwardRef(({ className, ...props }, ref) => ( + +)) +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName + +const AlertDialogContent = React.forwardRef(({ className, ...props }, ref) => ( + + + + +)) +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName + +const AlertDialogHeader = ({ + className, + ...props +}) => ( +
+) +AlertDialogHeader.displayName = "AlertDialogHeader" + +const AlertDialogFooter = ({ + className, + ...props +}) => ( +
+) +AlertDialogFooter.displayName = "AlertDialogFooter" + +const AlertDialogTitle = React.forwardRef(({ className, ...props }, ref) => ( + +)) +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName + +const AlertDialogDescription = React.forwardRef(({ className, ...props }, ref) => ( + +)) +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName + +const AlertDialogAction = React.forwardRef(({ className, ...props }, ref) => ( + +)) +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName + +const AlertDialogCancel = React.forwardRef(({ className, ...props }, ref) => ( + +)) +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/src/components/ui/alert-dialog.jsx b/src/components/ui/alert-dialog.jsx new file mode 100644 index 0000000..28597e8 --- /dev/null +++ b/src/components/ui/alert-dialog.jsx @@ -0,0 +1,47 @@ +import * as React from "react" +import { cva } from "class-variance-authority"; + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Alert = React.forwardRef(({ className, variant, ...props }, ref) => ( +
+)) +Alert.displayName = "Alert" + +const AlertTitle = React.forwardRef(({ className, ...props }, ref) => ( +
+)) +AlertTitle.displayName = "AlertTitle" + +const AlertDescription = React.forwardRef(({ className, ...props }, ref) => ( +
+)) +AlertDescription.displayName = "AlertDescription" + +export { Alert, AlertTitle, AlertDescription } diff --git a/src/components/ui/alert.jsx b/src/components/ui/alert.jsx new file mode 100644 index 0000000..c4abbf3 --- /dev/null +++ b/src/components/ui/alert.jsx @@ -0,0 +1,5 @@ +import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio" + +const AspectRatio = AspectRatioPrimitive.Root + +export { AspectRatio } diff --git a/src/components/ui/aspect-ratio.jsx b/src/components/ui/aspect-ratio.jsx new file mode 100644 index 0000000..4920324 --- /dev/null +++ b/src/components/ui/aspect-ratio.jsx @@ -0,0 +1,35 @@ +"use client" + +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "@/lib/utils" + +const Avatar = React.forwardRef(({ className, ...props }, ref) => ( + +)) +Avatar.displayName = AvatarPrimitive.Root.displayName + +const AvatarImage = React.forwardRef(({ className, ...props }, ref) => ( + +)) +AvatarImage.displayName = AvatarPrimitive.Image.displayName + +const AvatarFallback = React.forwardRef(({ className, ...props }, ref) => ( + +)) +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/src/components/ui/avatar.jsx b/src/components/ui/avatar.jsx new file mode 100644 index 0000000..a687eba --- /dev/null +++ b/src/components/ui/avatar.jsx @@ -0,0 +1,34 @@ +import * as React from "react" +import { cva } from "class-variance-authority"; + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant, + ...props +}) { + return (
); +} + +export { Badge, badgeVariants } diff --git a/src/components/ui/badge.jsx b/src/components/ui/badge.jsx new file mode 100644 index 0000000..071c4e6 --- /dev/null +++ b/src/components/ui/badge.jsx @@ -0,0 +1,13 @@ +import * as React from "react"; +import { cn } from "@/lib/utils"; + +function Badge({ className, ...props }) { + return ( + + ); +} + +export { Badge }; diff --git a/src/components/ui/breadcrumb.jsx b/src/components/ui/breadcrumb.jsx new file mode 100644 index 0000000..7383248 --- /dev/null +++ b/src/components/ui/breadcrumb.jsx @@ -0,0 +1,48 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva } from "class-variance-authority"; + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + { + variants: { + variant: { + default: + "bg-primary text-primary-foreground shadow hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", + outline: + "border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2", + sm: "h-8 rounded-md px-3 text-xs", + lg: "h-10 rounded-md px-8", + icon: "h-9 w-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +const Button = React.forwardRef(({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + return ( + () + ); +}) +Button.displayName = "Button" + +export { Button, buttonVariants } diff --git a/src/components/ui/button.jsx b/src/components/ui/button.jsx new file mode 100644 index 0000000..7b8774b --- /dev/null +++ b/src/components/ui/button.jsx @@ -0,0 +1,37 @@ +import * as React from "react"; +import { cn } from "@/lib/utils"; + +const variants = { + default: "border border-primary bg-primary text-primary-foreground shadow-sm hover:bg-primary/95", + destructive: "border border-destructive bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", + outline: "border border-border bg-card text-foreground hover:bg-muted", + ghost: "border border-transparent text-muted-foreground hover:bg-muted hover:text-foreground", + secondary: "border border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80" +}; + +const sizes = { + default: "h-10 px-4 py-2 text-sm", + sm: "h-8 px-3 text-xs", + lg: "h-11 px-6 text-sm", + icon: "h-10 w-10" +}; + +const Button = React.forwardRef(({ className, variant = "default", size = "default", type = "button", ...props }, ref) => { + return ( + ) + ); +}) +CarouselPrevious.displayName = "CarouselPrevious" + +const CarouselNext = React.forwardRef(({ className, variant = "outline", size = "icon", ...props }, ref) => { + const { orientation, scrollNext, canScrollNext } = useCarousel() + + return ( + () + ); +}) +CarouselNext.displayName = "CarouselNext" + +export { Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext }; diff --git a/src/components/ui/carousel.jsx b/src/components/ui/carousel.jsx new file mode 100644 index 0000000..bcef106 --- /dev/null +++ b/src/components/ui/carousel.jsx @@ -0,0 +1,309 @@ +"use client"; +import * as React from "react" +import * as RechartsPrimitive from "recharts" + +import { cn } from "@/lib/utils" + +// Format: { THEME_NAME: CSS_SELECTOR } +const THEMES = { + light: "", + dark: ".dark" +} + +const ChartContext = React.createContext(null) + +function useChart() { + const context = React.useContext(ChartContext) + + if (!context) { + throw new Error("useChart must be used within a ") + } + + return context +} + +const ChartContainer = React.forwardRef(({ id, className, children, config, ...props }, ref) => { + const uniqueId = React.useId() + const chartId = `chart-${id || uniqueId.replace(/:/g, "")}` + + return ( + ( +
+ + + {children} + +
+
) + ); +}) +ChartContainer.displayName = "Chart" + +const ChartStyle = ({ + id, + config +}) => { + const colorConfig = Object.entries(config).filter(([, config]) => config.theme || config.color) + + if (!colorConfig.length) { + return null + } + + return ( + (