diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index c31d03d..9bb6927 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -27,6 +27,12 @@ jobs: key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} restore-keys: ${{ runner.os }}-maven- + - name: Build Frontend + run: | + cd ui + npm ci + npm run build + - name: Build and Test (unit tests only) run: >- mvn clean verify -B diff --git a/Dockerfile b/Dockerfile index 9570571..ba12871 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,18 @@ # syntax=docker/dockerfile:1 +FROM node:22-alpine AS frontend +WORKDIR /ui +COPY ui/package.json ui/package-lock.json ui/.npmrc ./ +RUN npm ci +COPY ui/ . +RUN npm run build + FROM eclipse-temurin:21-jdk-alpine AS build WORKDIR /build COPY .mvn/ .mvn/ COPY mvnw pom.xml ./ RUN --mount=type=cache,target=/root/.m2/repository ./mvnw dependency:go-offline -B COPY src/ src/ +COPY --from=frontend /src/main/resources/static/ src/main/resources/static/ RUN --mount=type=cache,target=/root/.m2/repository ./mvnw package -DskipTests -B FROM eclipse-temurin:21-jre-alpine diff --git a/HOWTO.md b/HOWTO.md index 7398635..679d4e4 100644 --- a/HOWTO.md +++ b/HOWTO.md @@ -302,6 +302,52 @@ Query params: `since`, `until` (ISO timestamps), `limit` (default 500), `stream` | HIGH | Unlimited | 50 | 90 days | + Debugger, Replay | | BUSINESS | Unlimited | Unlimited | 365 days | All features | +## Frontend Development + +The SaaS management UI is a React SPA in the `ui/` directory. + +### Setup + +```bash +cd ui +npm install +``` + +### Dev Server + +```bash +cd ui +npm run dev +``` + +The Vite dev server starts on http://localhost:5173 and proxies `/api` to `http://localhost:8080` (the Spring Boot backend). Run the backend in another terminal with `mvn spring-boot:run` or via Docker Compose. + +### Environment Variables + +| Variable | Purpose | Default | +|----------|---------|---------| +| `VITE_LOGTO_ENDPOINT` | Logto OIDC endpoint | `http://localhost:3001` | +| `VITE_LOGTO_CLIENT_ID` | Logto application client ID | (empty) | + +Create a `ui/.env.local` file for local overrides: +```bash +VITE_LOGTO_ENDPOINT=http://localhost:3001 +VITE_LOGTO_CLIENT_ID=your-client-id +``` + +### Production Build + +```bash +cd ui +npm run build +``` + +Output goes to `src/main/resources/static/` (configured in `vite.config.ts`). The subsequent `mvn package` bundles the SPA into the JAR. In Docker builds, the Dockerfile handles this automatically via a multi-stage build. + +### SPA Routing + +Spring Boot serves `index.html` for all non-API routes via `SpaController.java`. React Router handles client-side routing. The SPA lives at `/`, while the observability dashboard (cameleer3-server) is at `/dashboard`. + ## Development ### Running Tests diff --git a/docker-compose.yml b/docker-compose.yml index 3220989..de2b6d4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -79,6 +79,9 @@ services: - traefik.http.services.api.loadbalancer.server.port=8080 - traefik.http.routers.forwardauth.rule=Path(`/auth/verify`) - traefik.http.services.forwardauth.loadbalancer.server.port=8080 + - traefik.http.routers.spa.rule=PathPrefix(`/`) + - traefik.http.routers.spa.priority=1 + - traefik.http.services.spa.loadbalancer.server.port=8080 networks: - cameleer diff --git a/src/main/java/net/siegeln/cameleer/saas/config/SpaController.java b/src/main/java/net/siegeln/cameleer/saas/config/SpaController.java new file mode 100644 index 0000000..64f5bdd --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/config/SpaController.java @@ -0,0 +1,13 @@ +package net.siegeln.cameleer.saas.config; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; + +@Controller +public class SpaController { + + @GetMapping(value = {"/", "/login", "/callback", "/environments/**", "/license"}) + public String spa() { + return "forward:/index.html"; + } +} diff --git a/ui/.gitignore b/ui/.gitignore new file mode 100644 index 0000000..dafa699 --- /dev/null +++ b/ui/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.env +.env.local diff --git a/ui/.npmrc b/ui/.npmrc new file mode 100644 index 0000000..df2d130 --- /dev/null +++ b/ui/.npmrc @@ -0,0 +1 @@ +@cameleer:registry=https://gitea.siegeln.net/api/packages/cameleer/npm/ diff --git a/ui/index.html b/ui/index.html new file mode 100644 index 0000000..3d2455e --- /dev/null +++ b/ui/index.html @@ -0,0 +1,12 @@ + + + + + + Cameleer SaaS + + +
+ + + diff --git a/ui/package-lock.json b/ui/package-lock.json new file mode 100644 index 0000000..b360f93 --- /dev/null +++ b/ui/package-lock.json @@ -0,0 +1,1962 @@ +{ + "name": "cameleer-saas-ui", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cameleer-saas-ui", + "version": "0.1.0", + "dependencies": { + "@cameleer/design-system": "0.1.31", + "@tanstack/react-query": "^5.90.0", + "lucide-react": "^1.7.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router": "^7.13.0", + "zustand": "^5.0.0" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.4.0", + "typescript": "^5.9.0", + "vite": "^6.3.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.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/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/@cameleer/design-system": { + "version": "0.1.31", + "resolved": "https://gitea.siegeln.net/api/packages/cameleer/npm/%40cameleer%2Fdesign-system/-/0.1.31/design-system-0.1.31.tgz", + "integrity": "sha512-yfuMQdLB3RS6loT31YozKCyBUnctxlLPZjpzPzD7UQZhCFU4+ScibMzbPeLFE/MyMkfrpk/j9v8pJCHW7l64TQ==", + "dependencies": { + "lucide-react": "^1.7.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.0.0" + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@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/@tanstack/query-core": { + "version": "5.96.2", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.96.2.tgz", + "integrity": "sha512-hzI6cTVh4KNRk8UtoIBS7Lv9g6BnJPXvBKsvYH1aGWvv0347jT3BnSvztOE+kD76XGvZnRC/t6qdW1CaIfwCeA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.96.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.96.2.tgz", + "integrity": "sha512-sYyzzJT4G0g02azzJ8o55VFFV31XvFpdUpG+unxS0vSaYsJnSPKGoI6WdPwUucJL1wpgGfwfmntNX/Ub1uOViA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.96.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.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/baseline-browser-mapping": { + "version": "2.10.14", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.14.tgz", + "integrity": "sha512-fOVLPAsFTsQfuCkvahZkzq6nf8KvGWanlYoTh0SVA0A/PIUxQGU2AOZAoD95n2gFLVDW/jP6sbGLny95nmEuHA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001785", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001785.tgz", + "integrity": "sha512-blhOL/WNR+Km1RI/LCVAvA73xplXA7ZbjzI4YkMK9pa6T/P3F2GxjNpEkyw5repTw9IvkyrjyHpwjnhZ5FOvYQ==", + "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/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/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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==", + "devOptional": true, + "license": "MIT" + }, + "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/electron-to-chromium": { + "version": "1.5.331", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz", + "integrity": "sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/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/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/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/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/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": "1.7.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.7.0.tgz", + "integrity": "sha512-yI7BeItCLZJTXikmK4KNUGCKoGzSvbKlfCvw44bU4fXAL6v3gYS4uHD1jzsLkfwODYwI6Drw5Tu9Z5ulDe0TSg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "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/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.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/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.14.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.0.tgz", + "integrity": "sha512-m/xR9N4LQLmAS0ZhkY2nkPA1N7gQ5TUVa5n8TgANuDTARbn1gt+zLPXEm7W0XDTbrQ2AJSJKhoa6yx1D8BcpxQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.14.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.0.tgz", + "integrity": "sha512-2G3ajSVSZMEtmTjIklRWlNvo8wICEpLihfD/0YMDxbWK2UyP5EGfnoIn9AIQGnF3G/FX0MRbHXdFcD+rL1ZreQ==", + "license": "MIT", + "dependencies": { + "react-router": "7.14.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "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/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "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-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "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/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/zustand": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz", + "integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 0000000..1a6af20 --- /dev/null +++ b/ui/package.json @@ -0,0 +1,27 @@ +{ + "name": "cameleer-saas-ui", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@cameleer/design-system": "0.1.31", + "@tanstack/react-query": "^5.90.0", + "lucide-react": "^1.7.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router": "^7.13.0", + "zustand": "^5.0.0" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.4.0", + "typescript": "^5.9.0", + "vite": "^6.3.0" + } +} diff --git a/ui/src/api/client.ts b/ui/src/api/client.ts new file mode 100644 index 0000000..c7b9514 --- /dev/null +++ b/ui/src/api/client.ts @@ -0,0 +1,46 @@ +import { useAuthStore } from '../auth/auth-store'; + +const API_BASE = '/api'; + +async function apiFetch(path: string, options: RequestInit = {}): Promise { + const token = useAuthStore.getState().accessToken; + const headers: Record = { + ...(options.headers as Record || {}), + }; + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + if (!headers['Content-Type'] && !(options.body instanceof FormData)) { + headers['Content-Type'] = 'application/json'; + } + + const response = await fetch(`${API_BASE}${path}`, { ...options, headers }); + + if (response.status === 401) { + useAuthStore.getState().logout(); + window.location.href = '/login'; + throw new Error('Unauthorized'); + } + + if (!response.ok) { + const text = await response.text(); + throw new Error(`API error ${response.status}: ${text}`); + } + + if (response.status === 204) return undefined as T; + return response.json(); +} + +export const api = { + get: (path: string) => apiFetch(path), + post: (path: string, body?: unknown) => + apiFetch(path, { + method: 'POST', + body: body instanceof FormData ? body : JSON.stringify(body), + }), + patch: (path: string, body: unknown) => + apiFetch(path, { method: 'PATCH', body: JSON.stringify(body) }), + put: (path: string, body: FormData) => + apiFetch(path, { method: 'PUT', body }), + delete: (path: string) => apiFetch(path, { method: 'DELETE' }), +}; diff --git a/ui/src/api/hooks.ts b/ui/src/api/hooks.ts new file mode 100644 index 0000000..01ad1a8 --- /dev/null +++ b/ui/src/api/hooks.ts @@ -0,0 +1,185 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { api } from './client'; +import type { + TenantResponse, EnvironmentResponse, AppResponse, + DeploymentResponse, LicenseResponse, AgentStatusResponse, + ObservabilityStatusResponse, LogEntry, +} from '../types/api'; + +// Tenant +export function useTenant(tenantId: string) { + return useQuery({ + queryKey: ['tenant', tenantId], + queryFn: () => api.get(`/tenants/${tenantId}`), + enabled: !!tenantId, + }); +} + +// License +export function useLicense(tenantId: string) { + return useQuery({ + queryKey: ['license', tenantId], + queryFn: () => api.get(`/tenants/${tenantId}/license`), + enabled: !!tenantId, + }); +} + +// Environments +export function useEnvironments(tenantId: string) { + return useQuery({ + queryKey: ['environments', tenantId], + queryFn: () => api.get(`/tenants/${tenantId}/environments`), + enabled: !!tenantId, + }); +} + +export function useCreateEnvironment(tenantId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (data: { slug: string; displayName: string }) => + api.post(`/tenants/${tenantId}/environments`, data), + onSuccess: () => qc.invalidateQueries({ queryKey: ['environments', tenantId] }), + }); +} + +export function useUpdateEnvironment(tenantId: string, envId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (data: { displayName: string }) => + api.patch(`/tenants/${tenantId}/environments/${envId}`, data), + onSuccess: () => qc.invalidateQueries({ queryKey: ['environments', tenantId] }), + }); +} + +export function useDeleteEnvironment(tenantId: string, envId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: () => api.delete(`/tenants/${tenantId}/environments/${envId}`), + onSuccess: () => qc.invalidateQueries({ queryKey: ['environments', tenantId] }), + }); +} + +// Apps +export function useApps(environmentId: string) { + return useQuery({ + queryKey: ['apps', environmentId], + queryFn: () => api.get(`/environments/${environmentId}/apps`), + enabled: !!environmentId, + }); +} + +export function useApp(environmentId: string, appId: string) { + return useQuery({ + queryKey: ['app', appId], + queryFn: () => api.get(`/environments/${environmentId}/apps/${appId}`), + enabled: !!appId, + }); +} + +export function useCreateApp(environmentId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (formData: FormData) => + api.post(`/environments/${environmentId}/apps`, formData), + onSuccess: () => qc.invalidateQueries({ queryKey: ['apps', environmentId] }), + }); +} + +export function useDeleteApp(environmentId: string, appId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: () => api.delete(`/environments/${environmentId}/apps/${appId}`), + onSuccess: () => qc.invalidateQueries({ queryKey: ['apps', environmentId] }), + }); +} + +export function useUpdateRouting(environmentId: string, appId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (data: { exposedPort: number | null }) => + api.patch(`/environments/${environmentId}/apps/${appId}/routing`, data), + onSuccess: () => qc.invalidateQueries({ queryKey: ['app', appId] }), + }); +} + +// Deployments +export function useDeploy(appId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: () => api.post(`/apps/${appId}/deploy`), + onSuccess: () => qc.invalidateQueries({ queryKey: ['deployments', appId] }), + }); +} + +export function useDeployments(appId: string) { + return useQuery({ + queryKey: ['deployments', appId], + queryFn: () => api.get(`/apps/${appId}/deployments`), + enabled: !!appId, + }); +} + +export function useDeployment(appId: string, deploymentId: string) { + return useQuery({ + queryKey: ['deployment', deploymentId], + queryFn: () => api.get(`/apps/${appId}/deployments/${deploymentId}`), + enabled: !!deploymentId, + refetchInterval: (query) => { + const status = query.state.data?.observedStatus; + return status === 'BUILDING' || status === 'STARTING' ? 3000 : false; + }, + }); +} + +export function useStop(appId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: () => api.post(`/apps/${appId}/stop`), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['deployments', appId] }); + qc.invalidateQueries({ queryKey: ['app'] }); + }, + }); +} + +export function useRestart(appId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: () => api.post(`/apps/${appId}/restart`), + onSuccess: () => qc.invalidateQueries({ queryKey: ['deployments', appId] }), + }); +} + +// Observability +export function useAgentStatus(appId: string) { + return useQuery({ + queryKey: ['agent-status', appId], + queryFn: () => api.get(`/apps/${appId}/agent-status`), + enabled: !!appId, + refetchInterval: 15_000, + }); +} + +export function useObservabilityStatus(appId: string) { + return useQuery({ + queryKey: ['observability-status', appId], + queryFn: () => api.get(`/apps/${appId}/observability-status`), + enabled: !!appId, + refetchInterval: 30_000, + }); +} + +export function useLogs(appId: string, params?: { since?: string; limit?: number; stream?: string }) { + return useQuery({ + queryKey: ['logs', appId, params], + queryFn: () => { + const qs = new URLSearchParams(); + if (params?.since) qs.set('since', params.since); + if (params?.limit) qs.set('limit', String(params.limit)); + if (params?.stream) qs.set('stream', params.stream); + const query = qs.toString(); + return api.get(`/apps/${appId}/logs${query ? `?${query}` : ''}`); + }, + enabled: !!appId, + }); +} diff --git a/ui/src/auth/CallbackPage.tsx b/ui/src/auth/CallbackPage.tsx new file mode 100644 index 0000000..77aa7c1 --- /dev/null +++ b/ui/src/auth/CallbackPage.tsx @@ -0,0 +1,49 @@ +import { useEffect } from 'react'; +import { useNavigate } from 'react-router'; +import { useAuthStore } from './auth-store'; +import { Spinner } from '@cameleer/design-system'; + +export function CallbackPage() { + const navigate = useNavigate(); + const login = useAuthStore((s) => s.login); + + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const code = params.get('code'); + if (!code) { + navigate('/login'); + return; + } + + const logtoEndpoint = import.meta.env.VITE_LOGTO_ENDPOINT || 'http://localhost:3001'; + const clientId = import.meta.env.VITE_LOGTO_CLIENT_ID || ''; + const redirectUri = `${window.location.origin}/callback`; + + fetch(`${logtoEndpoint}/oidc/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code, + client_id: clientId, + redirect_uri: redirectUri, + }), + }) + .then((r) => r.json()) + .then((data) => { + if (data.access_token) { + login(data.access_token, data.refresh_token || ''); + navigate('/'); + } else { + navigate('/login'); + } + }) + .catch(() => navigate('/login')); + }, [login, navigate]); + + return ( +
+ +
+ ); +} diff --git a/ui/src/auth/LoginPage.tsx b/ui/src/auth/LoginPage.tsx new file mode 100644 index 0000000..a418a29 --- /dev/null +++ b/ui/src/auth/LoginPage.tsx @@ -0,0 +1,29 @@ +import { Button } from '@cameleer/design-system'; + +export function LoginPage() { + const logtoEndpoint = import.meta.env.VITE_LOGTO_ENDPOINT || 'http://localhost:3001'; + const clientId = import.meta.env.VITE_LOGTO_CLIENT_ID || ''; + const redirectUri = `${window.location.origin}/callback`; + + const handleLogin = () => { + const params = new URLSearchParams({ + client_id: clientId, + redirect_uri: redirectUri, + response_type: 'code', + scope: 'openid profile email offline_access', + }); + window.location.href = `${logtoEndpoint}/oidc/auth?${params}`; + }; + + return ( +
+
+

Cameleer SaaS

+

+ Managed Apache Camel Runtime +

+ +
+
+ ); +} diff --git a/ui/src/auth/ProtectedRoute.tsx b/ui/src/auth/ProtectedRoute.tsx new file mode 100644 index 0000000..3752246 --- /dev/null +++ b/ui/src/auth/ProtectedRoute.tsx @@ -0,0 +1,8 @@ +import { Navigate } from 'react-router'; +import { useAuthStore } from './auth-store'; + +export function ProtectedRoute({ children }: { children: React.ReactNode }) { + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + if (!isAuthenticated) return ; + return <>{children}; +} diff --git a/ui/src/auth/auth-store.ts b/ui/src/auth/auth-store.ts new file mode 100644 index 0000000..021a080 --- /dev/null +++ b/ui/src/auth/auth-store.ts @@ -0,0 +1,61 @@ +import { create } from 'zustand'; + +interface AuthState { + accessToken: string | null; + refreshToken: string | null; + username: string | null; + roles: string[]; + tenantId: string | null; + isAuthenticated: boolean; + login: (accessToken: string, refreshToken: string) => void; + logout: () => void; + loadFromStorage: () => void; +} + +function parseJwt(token: string): Record { + try { + const base64 = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'); + return JSON.parse(atob(base64)); + } catch { + return {}; + } +} + +export const useAuthStore = create((set) => ({ + accessToken: null, + refreshToken: null, + username: null, + roles: [], + tenantId: null, + isAuthenticated: false, + + login: (accessToken: string, refreshToken: string) => { + localStorage.setItem('cameleer-access-token', accessToken); + localStorage.setItem('cameleer-refresh-token', refreshToken); + const claims = parseJwt(accessToken); + const username = (claims.sub as string) || (claims.email as string) || 'user'; + const roles = (claims.roles as string[]) || []; + const tenantId = (claims.organization_id as string) || null; + localStorage.setItem('cameleer-username', username); + set({ accessToken, refreshToken, username, roles, tenantId, isAuthenticated: true }); + }, + + logout: () => { + localStorage.removeItem('cameleer-access-token'); + localStorage.removeItem('cameleer-refresh-token'); + localStorage.removeItem('cameleer-username'); + set({ accessToken: null, refreshToken: null, username: null, roles: [], tenantId: null, isAuthenticated: false }); + }, + + loadFromStorage: () => { + const accessToken = localStorage.getItem('cameleer-access-token'); + const refreshToken = localStorage.getItem('cameleer-refresh-token'); + const username = localStorage.getItem('cameleer-username'); + if (accessToken) { + const claims = parseJwt(accessToken); + const roles = (claims.roles as string[]) || []; + const tenantId = (claims.organization_id as string) || null; + set({ accessToken, refreshToken, username, roles, tenantId, isAuthenticated: true }); + } + }, +})); diff --git a/ui/src/components/DeploymentStatusBadge.tsx b/ui/src/components/DeploymentStatusBadge.tsx new file mode 100644 index 0000000..13965cf --- /dev/null +++ b/ui/src/components/DeploymentStatusBadge.tsx @@ -0,0 +1,15 @@ +import { Badge } from '@cameleer/design-system'; + +// Badge color values: 'primary' | 'success' | 'warning' | 'error' | 'running' | 'auto' +const STATUS_COLORS: Record = { + BUILDING: 'warning', + STARTING: 'warning', + RUNNING: 'running', + FAILED: 'error', + STOPPED: 'auto', +}; + +export function DeploymentStatusBadge({ status }: { status: string }) { + const color = STATUS_COLORS[status] ?? 'auto'; + return ; +} diff --git a/ui/src/components/EnvironmentTree.tsx b/ui/src/components/EnvironmentTree.tsx new file mode 100644 index 0000000..a0db25b --- /dev/null +++ b/ui/src/components/EnvironmentTree.tsx @@ -0,0 +1,114 @@ +import { useState, useCallback } from 'react'; +import { useNavigate, useLocation } from 'react-router'; +import { SidebarTree, type SidebarTreeNode } from '@cameleer/design-system'; +import { useAuthStore } from '../auth/auth-store'; +import { useEnvironments, useApps } from '../api/hooks'; +import type { EnvironmentResponse } from '../types/api'; + +/** + * Renders one environment entry as a SidebarTreeNode. + * This is a "render nothing, report data" component: it fetches apps for + * the given environment and invokes `onNode` with the assembled tree node + * whenever the data changes. + * + * Using a dedicated component per env is the idiomatic way to call a hook + * for each item in a dynamic list without violating Rules of Hooks. + */ +function EnvWithApps({ + env, + onNode, +}: { + env: EnvironmentResponse; + onNode: (node: SidebarTreeNode) => void; +}) { + const { data: apps } = useApps(env.id); + + const children: SidebarTreeNode[] = (apps ?? []).map((app) => ({ + id: app.id, + label: app.displayName, + path: `/environments/${env.id}/apps/${app.id}`, + })); + + const node: SidebarTreeNode = { + id: env.id, + label: env.displayName, + path: `/environments/${env.id}`, + children: children.length > 0 ? children : undefined, + }; + + // Calling onNode during render is intentional here: we want the parent to + // collect the latest node on every render. The parent guards against + // infinite loops by doing a shallow equality check before updating state. + onNode(node); + + return null; +} + +export function EnvironmentTree() { + const tenantId = useAuthStore((s) => s.tenantId); + const { data: environments } = useEnvironments(tenantId ?? ''); + const navigate = useNavigate(); + const location = useLocation(); + + const [starred, setStarred] = useState>(new Set()); + const [envNodes, setEnvNodes] = useState>(new Map()); + + const handleToggleStar = useCallback((id: string) => { + setStarred((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }, []); + + const handleNode = useCallback((node: SidebarTreeNode) => { + setEnvNodes((prev) => { + const existing = prev.get(node.id); + // Avoid infinite re-renders: only update when something meaningful changed. + if ( + existing && + existing.label === node.label && + existing.path === node.path && + existing.children?.length === node.children?.length + ) { + return prev; + } + return new Map(prev).set(node.id, node); + }); + }, []); + + const envs = environments ?? []; + + // Build the final node list, falling back to env-only nodes until apps load. + const nodes: SidebarTreeNode[] = envs.map( + (env) => + envNodes.get(env.id) ?? { + id: env.id, + label: env.displayName, + path: `/environments/${env.id}`, + }, + ); + + return ( + <> + {/* Invisible data-fetchers: one per environment */} + {envs.map((env) => ( + + ))} + + starred.has(id)} + onToggleStar={handleToggleStar} + onNavigate={(path) => navigate(path)} + persistKey="env-tree" + autoRevealPath={location.pathname} + /> + + ); +} diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx new file mode 100644 index 0000000..9df1430 --- /dev/null +++ b/ui/src/components/Layout.tsx @@ -0,0 +1,166 @@ +import { useState } from 'react'; +import { Outlet, useNavigate } from 'react-router'; +import { + AppShell, + Sidebar, + TopBar, +} from '@cameleer/design-system'; +import { useAuthStore } from '../auth/auth-store'; +import { EnvironmentTree } from './EnvironmentTree'; + +// Simple SVG logo mark for the sidebar header +function CameleerLogo() { + return ( + + ); +} + +// Nav icon helpers +function DashboardIcon() { + return ( + + ); +} + +function EnvIcon() { + return ( + + ); +} + +function LicenseIcon() { + return ( + + ); +} + +function ObsIcon() { + return ( + + ); +} + +function UserIcon() { + return ( + + ); +} + +export function Layout() { + const navigate = useNavigate(); + const username = useAuthStore((s) => s.username); + const logout = useAuthStore((s) => s.logout); + + const [envSectionOpen, setEnvSectionOpen] = useState(true); + const [collapsed, setCollapsed] = useState(false); + + const sidebar = ( + setCollapsed((c) => !c)}> + } + title="Cameleer SaaS" + onClick={() => navigate('/')} + /> + + {/* Dashboard */} + } + label="Dashboard" + open={false} + onToggle={() => navigate('/')} + > + {null} + + + {/* Environments — expandable tree */} + } + label="Environments" + open={envSectionOpen} + onToggle={() => setEnvSectionOpen((o) => !o)} + > + + + + {/* License */} + } + label="License" + open={false} + onToggle={() => navigate('/license')} + > + {null} + + + + {/* Link to the observability SPA (external) */} + } + label="View Dashboard" + onClick={() => window.open('/dashboard', '_blank', 'noopener')} + /> + + {/* User info + logout */} + } + label={username ?? 'Account'} + onClick={logout} + /> + + + ); + + return ( + + + + + ); +} diff --git a/ui/src/components/RequirePermission.tsx b/ui/src/components/RequirePermission.tsx new file mode 100644 index 0000000..f870bf6 --- /dev/null +++ b/ui/src/components/RequirePermission.tsx @@ -0,0 +1,13 @@ +import { usePermissions } from '../hooks/usePermissions'; + +interface Props { + permission: string; + children: React.ReactNode; + fallback?: React.ReactNode; +} + +export function RequirePermission({ permission, children, fallback }: Props) { + const { has } = usePermissions(); + if (!has(permission)) return fallback ? <>{fallback} : null; + return <>{children}; +} diff --git a/ui/src/hooks/usePermissions.ts b/ui/src/hooks/usePermissions.ts new file mode 100644 index 0000000..6fd5a82 --- /dev/null +++ b/ui/src/hooks/usePermissions.ts @@ -0,0 +1,27 @@ +import { useAuthStore } from '../auth/auth-store'; + +const ROLE_PERMISSIONS: Record = { + OWNER: ['tenant:manage', 'billing:manage', 'team:manage', 'apps:manage', 'apps:deploy', 'secrets:manage', 'observe:read', 'observe:debug', 'settings:manage'], + ADMIN: ['team:manage', 'apps:manage', 'apps:deploy', 'secrets:manage', 'observe:read', 'observe:debug', 'settings:manage'], + DEVELOPER: ['apps:deploy', 'secrets:manage', 'observe:read', 'observe:debug'], + VIEWER: ['observe:read'], +}; + +export function usePermissions() { + const roles = useAuthStore((s) => s.roles); + + const permissions = new Set(); + for (const role of roles) { + const perms = ROLE_PERMISSIONS[role]; + if (perms) perms.forEach((p) => permissions.add(p)); + } + + return { + has: (permission: string) => permissions.has(permission), + canManageApps: permissions.has('apps:manage'), + canDeploy: permissions.has('apps:deploy'), + canManageTenant: permissions.has('tenant:manage'), + canViewObservability: permissions.has('observe:read'), + roles, + }; +} diff --git a/ui/src/main.tsx b/ui/src/main.tsx new file mode 100644 index 0000000..a255f95 --- /dev/null +++ b/ui/src/main.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { BrowserRouter } from 'react-router'; +import { ThemeProvider, ToastProvider, BreadcrumbProvider } from '@cameleer/design-system'; +import '@cameleer/design-system/style.css'; +import { AppRouter } from './router'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 1, + staleTime: 10_000, + }, + }, +}); + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + + + + + + + + + , +); diff --git a/ui/src/pages/AppDetailPage.tsx b/ui/src/pages/AppDetailPage.tsx new file mode 100644 index 0000000..b8ead18 --- /dev/null +++ b/ui/src/pages/AppDetailPage.tsx @@ -0,0 +1,737 @@ +import React, { useRef, useState } from 'react'; +import { useNavigate, useParams, Link } from 'react-router-dom'; +import { + Badge, + Button, + Card, + ConfirmDialog, + DataTable, + EmptyState, + FormField, + Input, + LogViewer, + Modal, + Spinner, + StatusDot, + Tabs, + useToast, +} from '@cameleer/design-system'; +import type { Column, LogEntry as DSLogEntry } from '@cameleer/design-system'; +import { useAuthStore } from '../auth/auth-store'; +import { + useApp, + useDeployment, + useDeployments, + useDeploy, + useStop, + useRestart, + useDeleteApp, + useUpdateRouting, + useAgentStatus, + useObservabilityStatus, + useLogs, + useCreateApp, +} from '../api/hooks'; +import { RequirePermission } from '../components/RequirePermission'; +import { DeploymentStatusBadge } from '../components/DeploymentStatusBadge'; +import { usePermissions } from '../hooks/usePermissions'; +import type { DeploymentResponse } from '../types/api'; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +interface DeploymentRow { + id: string; + version: number; + observedStatus: string; + desiredStatus: string; + deployedAt: string | null; + stoppedAt: string | null; + errorMessage: string | null; + _raw: DeploymentResponse; +} + +// ─── Deployment history columns ─────────────────────────────────────────────── + +const deploymentColumns: Column[] = [ + { + key: 'version', + header: 'Version', + render: (_val, row) => ( + v{row.version} + ), + }, + { + key: 'observedStatus', + header: 'Status', + render: (_val, row) => , + }, + { + key: 'desiredStatus', + header: 'Desired', + render: (_val, row) => ( + + ), + }, + { + key: 'deployedAt', + header: 'Deployed', + render: (_val, row) => + row.deployedAt + ? new Date(row.deployedAt).toLocaleString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) + : '—', + }, + { + key: 'stoppedAt', + header: 'Stopped', + render: (_val, row) => + row.stoppedAt + ? new Date(row.stoppedAt).toLocaleString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) + : '—', + }, + { + key: 'errorMessage', + header: 'Error', + render: (_val, row) => + row.errorMessage ? ( + {row.errorMessage} + ) : ( + '—' + ), + }, +]; + +// ─── Main page component ────────────────────────────────────────────────────── + +export function AppDetailPage() { + const { envId = '', appId = '' } = useParams<{ envId: string; appId: string }>(); + const navigate = useNavigate(); + const { toast } = useToast(); + const tenantId = useAuthStore((s) => s.tenantId); + const { canManageApps, canDeploy } = usePermissions(); + + // Active tab + const [activeTab, setActiveTab] = useState('overview'); + + // App data + const { data: app, isLoading: appLoading } = useApp(envId, appId); + + // Current deployment (auto-polls while BUILDING/STARTING) + const { data: currentDeployment } = useDeployment( + appId, + app?.currentDeploymentId ?? '', + ); + + // Deployment history + const { data: deployments = [] } = useDeployments(appId); + + // Agent and observability status + const { data: agentStatus } = useAgentStatus(appId); + const { data: obsStatus } = useObservabilityStatus(appId); + + // Log stream filter + const [logStream, setLogStream] = useState(undefined); + const { data: logEntries = [] } = useLogs(appId, { + limit: 500, + stream: logStream, + }); + + // Mutations + const deployMutation = useDeploy(appId); + const stopMutation = useStop(appId); + const restartMutation = useRestart(appId); + const deleteMutation = useDeleteApp(envId, appId); + const updateRoutingMutation = useUpdateRouting(envId, appId); + const reuploadMutation = useCreateApp(envId); + + // Dialog / modal state + const [stopConfirmOpen, setStopConfirmOpen] = useState(false); + const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); + const [routingModalOpen, setRoutingModalOpen] = useState(false); + const [reuploadModalOpen, setReuploadModalOpen] = useState(false); + + // Routing form + const [portInput, setPortInput] = useState(''); + + // Re-upload form + const [reuploadFile, setReuploadFile] = useState(null); + const fileInputRef = useRef(null); + + // ─── Handlers ────────────────────────────────────────────────────────────── + + async function handleDeploy() { + try { + await deployMutation.mutateAsync(); + toast({ title: 'Deployment triggered', variant: 'success' }); + } catch { + toast({ title: 'Failed to trigger deployment', variant: 'error' }); + } + } + + async function handleStop() { + try { + await stopMutation.mutateAsync(); + toast({ title: 'App stopped', variant: 'success' }); + setStopConfirmOpen(false); + } catch { + toast({ title: 'Failed to stop app', variant: 'error' }); + } + } + + async function handleRestart() { + try { + await restartMutation.mutateAsync(); + toast({ title: 'App restarting', variant: 'success' }); + } catch { + toast({ title: 'Failed to restart app', variant: 'error' }); + } + } + + async function handleDelete() { + try { + await deleteMutation.mutateAsync(); + toast({ title: 'App deleted', variant: 'success' }); + navigate(`/environments/${envId}`); + } catch { + toast({ title: 'Failed to delete app', variant: 'error' }); + } + } + + async function handleUpdateRouting(e: React.FormEvent) { + e.preventDefault(); + const port = portInput.trim() === '' ? null : parseInt(portInput, 10); + if (port !== null && (isNaN(port) || port < 1 || port > 65535)) { + toast({ title: 'Invalid port number', variant: 'error' }); + return; + } + try { + await updateRoutingMutation.mutateAsync({ exposedPort: port }); + toast({ title: 'Routing updated', variant: 'success' }); + setRoutingModalOpen(false); + } catch { + toast({ title: 'Failed to update routing', variant: 'error' }); + } + } + + function openRoutingModal() { + setPortInput(app?.exposedPort != null ? String(app.exposedPort) : ''); + setRoutingModalOpen(true); + } + + async function handleReupload(e: React.FormEvent) { + e.preventDefault(); + if (!reuploadFile) return; + const formData = new FormData(); + formData.append('jar', reuploadFile); + if (app?.slug) formData.append('slug', app.slug); + if (app?.displayName) formData.append('displayName', app.displayName); + try { + await reuploadMutation.mutateAsync(formData); + toast({ title: 'JAR uploaded', variant: 'success' }); + setReuploadModalOpen(false); + setReuploadFile(null); + } catch { + toast({ title: 'Failed to upload JAR', variant: 'error' }); + } + } + + // ─── Derived data ─────────────────────────────────────────────────────────── + + const deploymentRows: DeploymentRow[] = deployments.map((d) => ({ + id: d.id, + version: d.version, + observedStatus: d.observedStatus, + desiredStatus: d.desiredStatus, + deployedAt: d.deployedAt, + stoppedAt: d.stoppedAt, + errorMessage: d.errorMessage, + _raw: d, + })); + + // Map API LogEntry to design system LogEntry + const dsLogEntries: DSLogEntry[] = logEntries.map((entry) => ({ + timestamp: entry.timestamp, + level: entry.stream === 'stderr' ? ('error' as const) : ('info' as const), + message: entry.message, + })); + + // Agent state → StatusDot variant + function agentDotVariant(): 'live' | 'stale' | 'dead' | 'success' | 'warning' | 'error' | 'running' { + if (!agentStatus?.registered) return 'dead'; + switch (agentStatus.state) { + case 'CONNECTED': return 'live'; + case 'DISCONNECTED': return 'stale'; + default: return 'stale'; + } + } + + // ─── Loading / not-found states ──────────────────────────────────────────── + + if (appLoading) { + return ( +
+ +
+ ); + } + + if (!app) { + return ( + navigate(`/environments/${envId}`)}> + Back to Environment + + } + /> + ); + } + + // ─── Breadcrumb ───────────────────────────────────────────────────────────── + + const breadcrumb = ( + + ); + + // ─── Tabs ─────────────────────────────────────────────────────────────────── + + const tabs = [ + { label: 'Overview', value: 'overview' }, + { label: 'Deployments', value: 'deployments' }, + { label: 'Logs', value: 'logs' }, + ]; + + // ─── Render ────────────────────────────────────────────────────────────────── + + return ( +
+ {/* Breadcrumb */} + {breadcrumb} + + {/* Page header */} +
+
+

{app.displayName}

+
+ + {app.jarOriginalFilename && ( + {app.jarOriginalFilename} + )} +
+
+
+ + {/* Tab navigation */} + + + {/* ── Tab: Overview ── */} + {activeTab === 'overview' && ( +
+ {/* Status card */} + + {!app.currentDeploymentId ? ( +
No deployments yet
+ ) : !currentDeployment ? ( +
+ +
+ ) : ( +
+
+
Version
+ + v{currentDeployment.version} + +
+
+
Status
+ +
+
+
Image
+ + {currentDeployment.imageRef} + +
+ {currentDeployment.deployedAt && ( +
+
Deployed
+ + {new Date(currentDeployment.deployedAt).toLocaleString()} + +
+ )} + {currentDeployment.errorMessage && ( +
+
Error
+ + {currentDeployment.errorMessage} + +
+ )} +
+ )} +
+ + {/* Action bar */} + +
+ + + + + + + + + + + + + + + + + + + +
+
+ + {/* Agent status card */} + +
+
+ + + {agentStatus?.registered ? 'Registered' : 'Not registered'} + + {agentStatus?.state && ( + + )} +
+ + {agentStatus?.lastHeartbeat && ( +
+ Last heartbeat: {new Date(agentStatus.lastHeartbeat).toLocaleString()} +
+ )} + + {agentStatus?.routeIds && agentStatus.routeIds.length > 0 && ( +
+
Routes
+
+ {agentStatus.routeIds.map((rid) => ( + + ))} +
+
+ )} + + {obsStatus && ( +
+ + Traces:{' '} + + {obsStatus.hasTraces ? `${obsStatus.traceCount24h} (24h)` : 'none'} + + + + Metrics:{' '} + + {obsStatus.hasMetrics ? 'yes' : 'none'} + + + + Diagrams:{' '} + + {obsStatus.hasDiagrams ? 'yes' : 'none'} + + +
+ )} + +
+ + View in Dashboard → + +
+
+
+ + {/* Routing card */} + +
+
+ {app.exposedPort ? ( + <> +
Port
+ {app.exposedPort} + + ) : ( + No port configured + )} + {app.routeUrl && ( +
+
Route URL
+ + {app.routeUrl} + +
+ )} +
+ + + +
+
+
+ )} + + {/* ── Tab: Deployments ── */} + {activeTab === 'deployments' && ( + + {deploymentRows.length === 0 ? ( + + ) : ( + + columns={deploymentColumns} + data={deploymentRows} + pageSize={20} + rowAccent={(row) => + row.observedStatus === 'FAILED' ? 'error' : undefined + } + flush + /> + )} + + )} + + {/* ── Tab: Logs ── */} + {activeTab === 'logs' && ( + +
+ {/* Stream filter */} +
+ {[ + { label: 'All', value: undefined }, + { label: 'stdout', value: 'stdout' }, + { label: 'stderr', value: 'stderr' }, + ].map((opt) => ( + + ))} +
+ + {dsLogEntries.length === 0 ? ( + + ) : ( + + )} +
+
+ )} + + {/* ── Dialogs / Modals ── */} + + {/* Stop confirmation */} + setStopConfirmOpen(false)} + onConfirm={handleStop} + title="Stop App" + message={`Are you sure you want to stop "${app.displayName}"?`} + confirmText="Stop" + confirmLabel="Stop" + cancelLabel="Cancel" + variant="warning" + loading={stopMutation.isPending} + /> + + {/* Delete confirmation */} + setDeleteConfirmOpen(false)} + onConfirm={handleDelete} + title="Delete App" + message={`Are you sure you want to delete "${app.displayName}"? This action cannot be undone.`} + confirmText="Delete" + confirmLabel="Delete" + cancelLabel="Cancel" + variant="danger" + loading={deleteMutation.isPending} + /> + + {/* Routing modal */} + setRoutingModalOpen(false)} + title="Edit Routing" + size="sm" + > +
+ + setPortInput(e.target.value)} + placeholder="e.g. 8080" + min={1} + max={65535} + /> + +
+ + +
+
+
+ + {/* Re-upload JAR modal */} + setReuploadModalOpen(false)} + title="Re-upload JAR" + size="sm" + > +
+ + setReuploadFile(e.target.files?.[0] ?? null)} + required + /> + +
+ + +
+
+
+
+ ); +} diff --git a/ui/src/pages/DashboardPage.tsx b/ui/src/pages/DashboardPage.tsx new file mode 100644 index 0000000..62b8a92 --- /dev/null +++ b/ui/src/pages/DashboardPage.tsx @@ -0,0 +1,221 @@ +import React, { useState, useCallback, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + Badge, + Button, + Card, + EmptyState, + KpiStrip, + Spinner, +} from '@cameleer/design-system'; +import { useAuthStore } from '../auth/auth-store'; +import { useTenant, useEnvironments, useApps } from '../api/hooks'; +import { RequirePermission } from '../components/RequirePermission'; +import type { EnvironmentResponse, AppResponse } from '../types/api'; + +// Helper: fetches apps for one environment and reports data upward via effect +function EnvApps({ + environment, + onData, +}: { + environment: EnvironmentResponse; + onData: (envId: string, apps: AppResponse[]) => void; +}) { + const { data } = useApps(environment.id); + useEffect(() => { + if (data) { + onData(environment.id, data); + } + }, [data, environment.id, onData]); + return null; +} + +function tierColor(tier: string): 'primary' | 'success' | 'warning' | 'error' { + switch (tier?.toLowerCase()) { + case 'enterprise': return 'success'; + case 'pro': return 'primary'; + case 'starter': return 'warning'; + default: return 'primary'; + } +} + +export function DashboardPage() { + const navigate = useNavigate(); + const tenantId = useAuthStore((s) => s.tenantId); + + const { data: tenant, isLoading: tenantLoading } = useTenant(tenantId ?? ''); + const { data: environments, isLoading: envsLoading } = useEnvironments(tenantId ?? ''); + + // Collect apps per environment using a ref-like approach via state + callback + const [appsByEnv, setAppsByEnv] = useState>({}); + + const handleAppsData = useCallback((envId: string, apps: AppResponse[]) => { + setAppsByEnv((prev) => { + if (prev[envId] === apps) return prev; // stable reference, no update + return { ...prev, [envId]: apps }; + }); + }, []); + + const allApps = Object.values(appsByEnv).flat(); + const runningApps = allApps.filter((a) => a.currentDeploymentId !== null); + // "Failed" is apps that have a previous deployment but no current (stopped) — approximate heuristic + const failedApps = allApps.filter( + (a) => a.currentDeploymentId === null && a.previousDeploymentId !== null, + ); + + const isLoading = tenantLoading || envsLoading; + + const kpiItems = [ + { + label: 'Environments', + value: environments?.length ?? 0, + subtitle: 'isolated runtime contexts', + }, + { + label: 'Total Apps', + value: allApps.length, + subtitle: 'across all environments', + }, + { + label: 'Running', + value: runningApps.length, + trend: { + label: 'active deployments', + variant: 'success' as const, + }, + }, + { + label: 'Stopped', + value: failedApps.length, + trend: failedApps.length > 0 + ? { label: 'need attention', variant: 'warning' as const } + : { label: 'none', variant: 'muted' as const }, + }, + ]; + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (!tenantId) { + return ( + + ); + } + + return ( +
+ {/* Tenant Header */} +
+
+

+ {tenant?.name ?? tenantId} +

+ {tenant?.tier && ( + + )} +
+
+ + + + +
+
+ + {/* KPI Strip */} + + + {/* Environments overview */} + {environments && environments.length > 0 ? ( + + {/* Render hidden data-fetchers for each environment */} + {environments.map((env) => ( + + ))} +
+ {environments.map((env) => { + const envApps = appsByEnv[env.id] ?? []; + const envRunning = envApps.filter((a) => a.currentDeploymentId !== null).length; + return ( +
navigate(`/environments/${env.id}`)} + > +
+ + {env.displayName} + + +
+
+ {envApps.length} apps + {envRunning} running + +
+
+ ); + })} +
+
+ ) : ( + + + + } + /> + )} + + {/* Recent deployments placeholder */} + + {allApps.length === 0 ? ( + + ) : ( +

+ Select an app from an environment to view its deployment history. +

+ )} +
+
+ ); +} + diff --git a/ui/src/pages/EnvironmentDetailPage.tsx b/ui/src/pages/EnvironmentDetailPage.tsx new file mode 100644 index 0000000..49cffdc --- /dev/null +++ b/ui/src/pages/EnvironmentDetailPage.tsx @@ -0,0 +1,318 @@ +import React, { useRef, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { + Badge, + Button, + Card, + ConfirmDialog, + DataTable, + EmptyState, + FormField, + InlineEdit, + Input, + Modal, + Spinner, + useToast, +} from '@cameleer/design-system'; +import type { Column } from '@cameleer/design-system'; +import { useAuthStore } from '../auth/auth-store'; +import { + useEnvironments, + useUpdateEnvironment, + useDeleteEnvironment, + useApps, + useCreateApp, +} from '../api/hooks'; +import { RequirePermission } from '../components/RequirePermission'; +import { DeploymentStatusBadge } from '../components/DeploymentStatusBadge'; +import type { AppResponse } from '../types/api'; + +interface AppTableRow { + id: string; + displayName: string; + slug: string; + deploymentStatus: string; + updatedAt: string; + _raw: AppResponse; +} + +const appColumns: Column[] = [ + { + key: 'displayName', + header: 'Name', + render: (_val, row) => ( + {row.displayName} + ), + }, + { + key: 'slug', + header: 'Slug', + render: (_val, row) => ( + + ), + }, + { + key: 'deploymentStatus', + header: 'Status', + render: (_val, row) => + row._raw.currentDeploymentId ? ( + + ) : ( + + ), + }, + { + key: 'updatedAt', + header: 'Last Updated', + render: (_val, row) => + new Date(row.updatedAt).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + }), + }, +]; + +export function EnvironmentDetailPage() { + const navigate = useNavigate(); + const { envId } = useParams<{ envId: string }>(); + const { toast } = useToast(); + const tenantId = useAuthStore((s) => s.tenantId); + + const { data: environments, isLoading: envsLoading } = useEnvironments(tenantId ?? ''); + const environment = environments?.find((e) => e.id === envId); + + const { data: apps, isLoading: appsLoading } = useApps(envId ?? ''); + + const updateMutation = useUpdateEnvironment(tenantId ?? '', envId ?? ''); + const deleteMutation = useDeleteEnvironment(tenantId ?? '', envId ?? ''); + const createAppMutation = useCreateApp(envId ?? ''); + + // New app modal + const [newAppOpen, setNewAppOpen] = useState(false); + const [appSlug, setAppSlug] = useState(''); + const [appDisplayName, setAppDisplayName] = useState(''); + const [jarFile, setJarFile] = useState(null); + const fileInputRef = useRef(null); + + // Delete confirm + const [deleteOpen, setDeleteOpen] = useState(false); + + function openNewApp() { + setAppSlug(''); + setAppDisplayName(''); + setJarFile(null); + if (fileInputRef.current) fileInputRef.current.value = ''; + setNewAppOpen(true); + } + + function closeNewApp() { + setNewAppOpen(false); + } + + async function handleCreateApp(e: React.FormEvent) { + e.preventDefault(); + if (!appSlug.trim() || !appDisplayName.trim()) return; + const formData = new FormData(); + formData.append('slug', appSlug.trim()); + formData.append('displayName', appDisplayName.trim()); + if (jarFile) { + formData.append('jar', jarFile); + } + try { + await createAppMutation.mutateAsync(formData); + toast({ title: 'App created', variant: 'success' }); + closeNewApp(); + } catch { + toast({ title: 'Failed to create app', variant: 'error' }); + } + } + + async function handleDeleteEnvironment() { + try { + await deleteMutation.mutateAsync(); + toast({ title: 'Environment deleted', variant: 'success' }); + navigate('/environments'); + } catch { + toast({ title: 'Failed to delete environment', variant: 'error' }); + } + } + + async function handleRename(value: string) { + if (!value.trim() || value === environment?.displayName) return; + try { + await updateMutation.mutateAsync({ displayName: value.trim() }); + toast({ title: 'Environment renamed', variant: 'success' }); + } catch { + toast({ title: 'Failed to rename environment', variant: 'error' }); + } + } + + const isLoading = envsLoading || appsLoading; + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (!environment) { + return ( + navigate('/environments')}> + Back to Environments + + } + /> + ); + } + + const tableData: AppTableRow[] = (apps ?? []).map((app) => ({ + id: app.id, + displayName: app.displayName, + slug: app.slug, + deploymentStatus: app.currentDeploymentId ? 'RUNNING' : 'STOPPED', + updatedAt: app.updatedAt, + _raw: app, + })); + + const hasApps = (apps?.length ?? 0) > 0; + + return ( +
+ {/* Header */} +
+
+ + {environment.displayName} + + } + > + + + + +
+
+ + + + + + +
+
+ + {/* Apps table */} + {tableData.length === 0 ? ( + + + + } + /> + ) : ( + + + columns={appColumns} + data={tableData} + onRowClick={(row) => navigate(`/environments/${envId}/apps/${row.id}`)} + flush + /> + + )} + + {/* New App Modal */} + +
+ + setAppSlug(e.target.value)} + placeholder="e.g. order-router" + required + /> + + + setAppDisplayName(e.target.value)} + placeholder="e.g. Order Router" + required + /> + + + setJarFile(e.target.files?.[0] ?? null)} + /> + +
+ + +
+
+
+ + {/* Delete Confirmation */} + setDeleteOpen(false)} + onConfirm={handleDeleteEnvironment} + title="Delete Environment" + message={`Are you sure you want to delete "${environment.displayName}"? This action cannot be undone.`} + confirmText="Delete" + confirmLabel="Delete" + cancelLabel="Cancel" + variant="danger" + loading={deleteMutation.isPending} + /> +
+ ); +} diff --git a/ui/src/pages/EnvironmentsPage.tsx b/ui/src/pages/EnvironmentsPage.tsx new file mode 100644 index 0000000..183b7fa --- /dev/null +++ b/ui/src/pages/EnvironmentsPage.tsx @@ -0,0 +1,193 @@ +import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + Badge, + Button, + Card, + DataTable, + EmptyState, + FormField, + Input, + Modal, + Spinner, + useToast, +} from '@cameleer/design-system'; +import type { Column } from '@cameleer/design-system'; +import { useAuthStore } from '../auth/auth-store'; +import { useEnvironments, useCreateEnvironment } from '../api/hooks'; +import { RequirePermission } from '../components/RequirePermission'; +import type { EnvironmentResponse } from '../types/api'; + +interface TableRow { + id: string; + displayName: string; + slug: string; + status: string; + createdAt: string; + _raw: EnvironmentResponse; +} + +const columns: Column[] = [ + { + key: 'displayName', + header: 'Name', + render: (_val, row) => ( + {row.displayName} + ), + }, + { + key: 'slug', + header: 'Slug', + render: (_val, row) => ( + + ), + }, + { + key: 'status', + header: 'Status', + render: (_val, row) => ( + + ), + }, + { + key: 'createdAt', + header: 'Created', + render: (_val, row) => + new Date(row.createdAt).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + }), + }, +]; + +export function EnvironmentsPage() { + const navigate = useNavigate(); + const { toast } = useToast(); + const tenantId = useAuthStore((s) => s.tenantId); + + const { data: environments, isLoading } = useEnvironments(tenantId ?? ''); + const createMutation = useCreateEnvironment(tenantId ?? ''); + + const [modalOpen, setModalOpen] = useState(false); + const [slug, setSlug] = useState(''); + const [displayName, setDisplayName] = useState(''); + + const tableData: TableRow[] = (environments ?? []).map((env) => ({ + id: env.id, + displayName: env.displayName, + slug: env.slug, + status: env.status, + createdAt: env.createdAt, + _raw: env, + })); + + function openModal() { + setSlug(''); + setDisplayName(''); + setModalOpen(true); + } + + function closeModal() { + setModalOpen(false); + } + + async function handleCreate(e: React.FormEvent) { + e.preventDefault(); + if (!slug.trim() || !displayName.trim()) return; + try { + await createMutation.mutateAsync({ slug: slug.trim(), displayName: displayName.trim() }); + toast({ title: 'Environment created', variant: 'success' }); + closeModal(); + } catch { + toast({ title: 'Failed to create environment', variant: 'error' }); + } + } + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* Page header */} +
+

Environments

+ + + +
+ + {/* Table / empty state */} + {tableData.length === 0 ? ( + + + + } + /> + ) : ( + + + columns={columns} + data={tableData} + onRowClick={(row) => navigate(`/environments/${row.id}`)} + flush + /> + + )} + + {/* Create Environment Modal */} + +
+ + setSlug(e.target.value)} + placeholder="e.g. production" + required + /> + + + setDisplayName(e.target.value)} + placeholder="e.g. Production" + required + /> + +
+ + +
+
+
+
+ ); +} diff --git a/ui/src/pages/LicensePage.tsx b/ui/src/pages/LicensePage.tsx new file mode 100644 index 0000000..8f1cbd2 --- /dev/null +++ b/ui/src/pages/LicensePage.tsx @@ -0,0 +1,184 @@ +import React, { useState } from 'react'; +import { + Badge, + Card, + EmptyState, + Spinner, +} from '@cameleer/design-system'; +import { useAuthStore } from '../auth/auth-store'; +import { useLicense } from '../api/hooks'; + +const FEATURE_LABELS: Record = { + topology: 'Topology', + lineage: 'Lineage', + correlation: 'Correlation', + debugger: 'Debugger', + replay: 'Replay', +}; + +const LIMIT_LABELS: Record = { + maxAgents: 'Max Agents', + retentionDays: 'Retention Days', + maxEnvironments: 'Max Environments', +}; + +function tierColor(tier: string): 'primary' | 'success' | 'warning' | 'error' { + switch (tier?.toUpperCase()) { + case 'BUSINESS': return 'success'; + case 'HIGH': return 'primary'; + case 'MID': return 'warning'; + case 'LOW': return 'error'; + default: return 'primary'; + } +} + +function daysRemaining(expiresAt: string): number { + const now = Date.now(); + const exp = new Date(expiresAt).getTime(); + return Math.max(0, Math.ceil((exp - now) / (1000 * 60 * 60 * 24))); +} + +export function LicensePage() { + const tenantId = useAuthStore((s) => s.tenantId); + const { data: license, isLoading, isError } = useLicense(tenantId ?? ''); + const [tokenExpanded, setTokenExpanded] = useState(false); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (!tenantId) { + return ( + + ); + } + + if (isError || !license) { + return ( + + ); + } + + const expDate = new Date(license.expiresAt).toLocaleDateString(undefined, { + year: 'numeric', + month: 'long', + day: 'numeric', + }); + const days = daysRemaining(license.expiresAt); + const isExpiringSoon = days <= 30; + const isExpired = days === 0; + + return ( +
+ {/* Header */} +
+

License

+ +
+ + {/* Expiry info */} + +
+
+ Issued + + {new Date(license.issuedAt).toLocaleDateString(undefined, { + year: 'numeric', + month: 'long', + day: 'numeric', + })} + +
+
+ Expires + {expDate} +
+
+ Days remaining + +
+
+
+ + {/* Feature matrix */} + +
+ {Object.entries(FEATURE_LABELS).map(([key, label]) => { + const enabled = license.features[key] ?? false; + return ( +
+ {label} + +
+ ); + })} +
+
+ + {/* Limits */} + +
+ {Object.entries(LIMIT_LABELS).map(([key, label]) => { + const value = license.limits[key]; + return ( +
+ {label} + + {value !== undefined ? value : '—'} + +
+ ); + })} +
+
+ + {/* License token */} + +
+

+ Use this token when registering Cameleer agents with your tenant. +

+ + {tokenExpanded && ( +
+ + {license.token} + +
+ )} +
+
+
+ ); +} diff --git a/ui/src/router.tsx b/ui/src/router.tsx new file mode 100644 index 0000000..e6b625a --- /dev/null +++ b/ui/src/router.tsx @@ -0,0 +1,39 @@ +import { Routes, Route } from 'react-router'; +import { useEffect } from 'react'; +import { useAuthStore } from './auth/auth-store'; +import { LoginPage } from './auth/LoginPage'; +import { CallbackPage } from './auth/CallbackPage'; +import { ProtectedRoute } from './auth/ProtectedRoute'; +import { Layout } from './components/Layout'; +import { DashboardPage } from './pages/DashboardPage'; +import { EnvironmentsPage } from './pages/EnvironmentsPage'; +import { EnvironmentDetailPage } from './pages/EnvironmentDetailPage'; +import { AppDetailPage } from './pages/AppDetailPage'; +import { LicensePage } from './pages/LicensePage'; + +export function AppRouter() { + const loadFromStorage = useAuthStore((s) => s.loadFromStorage); + useEffect(() => { + loadFromStorage(); + }, [loadFromStorage]); + + return ( + + } /> + } /> + + + + } + > + } /> + } /> + } /> + } /> + } /> + + + ); +} diff --git a/ui/src/types/api.ts b/ui/src/types/api.ts new file mode 100644 index 0000000..a009f53 --- /dev/null +++ b/ui/src/types/api.ts @@ -0,0 +1,85 @@ +export interface TenantResponse { + id: string; + name: string; + slug: string; + tier: string; + status: string; + createdAt: string; + updatedAt: string; +} + +export interface EnvironmentResponse { + id: string; + tenantId: string; + slug: string; + displayName: string; + status: string; + createdAt: string; + updatedAt: string; +} + +export interface AppResponse { + id: string; + environmentId: string; + slug: string; + displayName: string; + jarOriginalFilename: string | null; + jarSizeBytes: number | null; + jarChecksum: string | null; + exposedPort: number | null; + routeUrl: string | null; + currentDeploymentId: string | null; + previousDeploymentId: string | null; + createdAt: string; + updatedAt: string; +} + +export interface DeploymentResponse { + id: string; + appId: string; + version: number; + imageRef: string; + desiredStatus: string; + observedStatus: string; + errorMessage: string | null; + orchestratorMetadata: Record; + deployedAt: string | null; + stoppedAt: string | null; + createdAt: string; +} + +export interface LicenseResponse { + id: string; + tenantId: string; + tier: string; + features: Record; + limits: Record; + issuedAt: string; + expiresAt: string; + token: string; +} + +export interface AgentStatusResponse { + registered: boolean; + state: string; + lastHeartbeat: string | null; + routeIds: string[]; + applicationId: string; + environmentId: string; +} + +export interface ObservabilityStatusResponse { + hasTraces: boolean; + hasMetrics: boolean; + hasDiagrams: boolean; + lastTraceAt: string | null; + traceCount24h: number; +} + +export interface LogEntry { + appId: string; + deploymentId: string; + timestamp: string; + stream: string; + message: string; +} diff --git a/ui/src/vite-env.d.ts b/ui/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/ui/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/ui/tsconfig.json b/ui/tsconfig.json new file mode 100644 index 0000000..b32be68 --- /dev/null +++ b/ui/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": false + }, + "include": ["src"] +} diff --git a/ui/vite.config.ts b/ui/vite.config.ts new file mode 100644 index 0000000..5c8ae92 --- /dev/null +++ b/ui/vite.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + proxy: { + '/api': { + target: 'http://localhost:8080', + changeOrigin: true, + }, + }, + }, + build: { + outDir: '../src/main/resources/static', + emptyOutDir: true, + }, +});