diff --git a/.github/workflows/ambar-core.yaml b/.github/workflows/ambar-core.yaml index f6cbc32..b7bb839 100644 --- a/.github/workflows/ambar-core.yaml +++ b/.github/workflows/ambar-core.yaml @@ -33,8 +33,8 @@ jobs: - name: Prettier check run: pnpm run format:check - - name: Test - run: cd core && pnpm test - - name: Build run: cd core && pnpm build + + - name: Test + run: cd core && pnpm test diff --git a/.github/workflows/ambar-task-explorer.yaml b/.github/workflows/ambar-task-explorer.yaml new file mode 100644 index 0000000..3a1536d --- /dev/null +++ b/.github/workflows/ambar-task-explorer.yaml @@ -0,0 +1,138 @@ +name: ambar-task-explorer + +on: + pull_request: + types: [opened, reopened, synchronize] + paths: + - 'task-explorer/**' + push: + branches: [main] + paths: + - 'task-explorer/**' + +run-name: ${{ github.workflow }} | ${{ github.event.pull_request.number }} ${{ github.event.pull_request.title || github.event.head_commit.message }} + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ambarltd/task-explorer + +jobs: + test: + name: Test + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Install + run: pnpm install + env: + GITHUB_TOKEN: ${{ secrets.READ_ACCESS_TO_REPOS }} + + - name: Prettier check + run: pnpm run format:check + + - name: Build + run: cd task-explorer && ./utils.sh build + + - name: Backend unit tests + run: cd task-explorer && ./utils.sh backend test + + - name: Frontend tests + run: cd task-explorer && ./utils.sh frontend test + + test-integration: + name: Integration Tests + runs-on: ubuntu-24.04 + needs: test + services: + postgres: + image: docker.io/postgres:16.4 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Install + run: pnpm install + env: + GITHUB_TOKEN: ${{ secrets.READ_ACCESS_TO_REPOS }} + + - name: Backend integration tests + # Postgres is provided as a CI service — skip docker-compose, run tsx directly + # (same command utils.sh backend test:integration runs internally) + run: cd task-explorer/backend && pnpm exec tsx tests/integration/main.ts + env: + TASKS_DB_HOST: localhost + TASKS_DB_PORT: "5432" + TASKS_DB_USER: postgres + TASKS_DB_PASSWORD: postgres + TASKS_DB_NAME: postgres + TASKS_DB_NAMESPACE: event_store + AUTH_USERNAME: admin + AUTH_PASSWORD: admin + SESSION_SECRET: test-secret + + build-image: + name: Build Docker Image + runs-on: ubuntu-24.04 + needs: [test, test-integration] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build (PR) + if: github.event_name == 'pull_request' + uses: docker/build-push-action@v6 + with: + context: ./task-explorer + push: false + build-args: | + GITHUB_TOKEN=${{ secrets.READ_ACCESS_TO_REPOS }} + + - name: Extract version + if: github.ref == 'refs/heads/main' + id: version + run: echo "value=$(node -p "require('./task-explorer/package.json').version")" >> $GITHUB_OUTPUT + + - name: Build and push (main) + if: github.ref == 'refs/heads/main' + uses: docker/build-push-action@v6 + with: + context: ./task-explorer + push: true + tags: | + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.value }} + build-args: | + GITHUB_TOKEN=${{ secrets.READ_ACCESS_TO_REPOS }} diff --git a/.gitignore b/.gitignore index de02cc4..6c333c3 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,9 @@ Thumbs.db .env .env.local .env.*.local +# task-explorer uses placeholder-only .env files — safe to commit +!task-explorer/backend/.env +!task-explorer/backend/.env.test # Test coverage coverage/ diff --git a/.prettierignore b/.prettierignore index 9e28491..363a91b 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,3 +2,6 @@ node_modules dist pnpm-lock.yaml *.log + +# TanStack Router auto-generated file +**/routeTree.gen.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 51e396b..6cc1e5a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -55,6 +55,164 @@ importers: specifier: ^5.0.0 version: 5.9.3 + task-explorer: + dependencies: + concurrently: + specifier: 8.2.2 + version: 8.2.2 + ts-pattern: + specifier: 5.9.0 + version: 5.9.0 + + task-explorer/backend: + dependencies: + '@ambarltd/core': + specifier: ^0.1.12 + version: 0.1.12 + '@ambarltd/tasks': + specifier: ^0.1.0 + version: 0.1.0 + '@optique/core': + specifier: 0.6.3 + version: 0.6.3 + '@optique/run': + specifier: 0.6.3 + version: 0.6.3 + cookie-session: + specifier: 2.1.1 + version: 2.1.1 + express: + specifier: 4.21.2 + version: 4.21.2 + luxon: + specifier: 3.7.2 + version: 3.7.2 + pg: + specifier: 8.16.3 + version: 8.16.3 + ts-pattern: + specifier: 5.9.0 + version: 5.9.0 + tsc-alias: + specifier: 1.8.16 + version: 1.8.16 + tsconfig-paths: + specifier: 4.2.0 + version: 4.2.0 + tsx: + specifier: 4.20.6 + version: 4.20.6 + devDependencies: + '@types/cookie-session': + specifier: 2.0.49 + version: 2.0.49 + '@types/express': + specifier: 4.17.25 + version: 4.17.25 + '@types/luxon': + specifier: 3.7.1 + version: 3.7.1 + '@types/node': + specifier: 24.10.1 + version: 24.10.1 + '@types/pg': + specifier: 8.15.6 + version: 8.15.6 + '@types/supertest': + specifier: 6.0.3 + version: 6.0.3 + dotenv: + specifier: 16.6.1 + version: 16.6.1 + nodemon: + specifier: 3.1.11 + version: 3.1.11 + supertest: + specifier: 7.2.2 + version: 7.2.2 + typescript: + specifier: 5.9.3 + version: 5.9.3 + + task-explorer/frontend: + dependencies: + '@ambarltd/core': + specifier: ^0.1.12 + version: 0.1.12 + '@tailwindcss/vite': + specifier: 4.1.17 + version: 4.1.17(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) + '@tanstack/react-query': + specifier: 5.90.11 + version: 5.90.11(react@19.2.0) + '@tanstack/react-router': + specifier: 1.139.10 + version: 1.139.10(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@tanstack/react-router-devtools': + specifier: 1.139.10 + version: 1.139.10(@tanstack/react-router@1.139.10(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(@tanstack/router-core@1.139.10)(@types/node@24.10.1)(csstype@3.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(solid-js@1.9.12)(tsx@4.21.0) + date-fns: + specifier: 4.1.0 + version: 4.1.0 + react: + specifier: 19.2.0 + version: 19.2.0 + react-dom: + specifier: 19.2.0 + version: 19.2.0(react@19.2.0) + vis-data: + specifier: 7.1.10 + version: 7.1.10(uuid@11.1.0)(vis-util@5.0.7(@egjs/hammerjs@2.0.17)(component-emitter@1.3.1)) + vis-timeline: + specifier: 7.7.4 + version: 7.7.4(@egjs/hammerjs@2.0.17)(component-emitter@1.3.1)(keycharm@0.4.0)(moment@2.30.1)(propagating-hammerjs@2.0.1(@egjs/hammerjs@2.0.17))(uuid@11.1.0)(vis-data@7.1.10(uuid@11.1.0)(vis-util@5.0.7(@egjs/hammerjs@2.0.17)(component-emitter@1.3.1)))(vis-util@5.0.7(@egjs/hammerjs@2.0.17)(component-emitter@1.3.1))(xss@1.0.15) + devDependencies: + '@tanstack/router-plugin': + specifier: 1.139.10 + version: 1.139.10(@tanstack/react-router@1.139.10(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) + '@testing-library/jest-dom': + specifier: 6.9.1 + version: 6.9.1 + '@testing-library/react': + specifier: 16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@testing-library/user-event': + specifier: 14.6.1 + version: 14.6.1(@testing-library/dom@10.4.1) + '@types/node': + specifier: 24.10.1 + version: 24.10.1 + '@types/react': + specifier: 19.2.7 + version: 19.2.7 + '@types/react-dom': + specifier: 19.2.3 + version: 19.2.3(@types/react@19.2.7) + '@vitejs/plugin-react': + specifier: 5.1.1 + version: 5.1.1(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) + '@vitest/coverage-v8': + specifier: 2.1.9 + version: 2.1.9(vitest@2.1.9) + '@vitest/ui': + specifier: 2.1.9 + version: 2.1.9(vitest@2.1.9) + jsdom: + specifier: 25.0.1 + version: 25.0.1 + tailwindcss: + specifier: 4.1.17 + version: 4.1.17 + typescript: + specifier: 5.9.3 + version: 5.9.3 + vite: + specifier: 7.2.4 + version: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + vitest: + specifier: 2.1.9 + version: 2.1.9(@types/node@24.10.1)(@vitest/ui@2.1.9)(jsdom@25.0.1)(lightningcss@1.30.2) + tasks: dependencies: '@ambarltd/core': @@ -91,197 +249,1136 @@ importers: packages: + '@adobe/css-tools@4.4.4': + resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + '@ambarltd/core@0.1.11': resolution: {integrity: sha512-gC1d8/XhKoEVYfnO8676dyyKs5K9TUfQYU8ZKAKivUbFGWdepH3knAznfL+tDLJSleyzl51nbxMXSFjtAnqnoQ==, tarball: https://npm.pkg.github.com/download/@ambarltd/core/0.1.11/f4c217be6f5928123a2ff26910307fe7ba9b9985} + '@ambarltd/core@0.1.12': + resolution: {integrity: sha512-Ohif3clsW+RV4maWgNeQQU0t27rrWyu2G2sGiTHHpiKTx8ORx5zJUn66yOXllxiqbzwYvyJ7aFhfA4ZYkNjJcg==, tarball: https://npm.pkg.github.com/download/@ambarltd/core/0.1.12/5365562b3a80277d3020df6e0e38a14e58929e91} + + '@ambarltd/tasks@0.1.0': + resolution: {integrity: sha512-P9Hu+d6OpOQ6BjrztMvWxZqpg41eUPW9GxjT8rGh+9WBUsaa1wdodFnjNgWDWGjKE3TObH6l5I0roEGVcsdeMw==, tarball: https://npm.pkg.github.com/download/@ambarltd/tasks/0.1.0/aad7fb0d8cea9208df5d143fce854d5ae9203b05} + + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.0': + resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.0': + resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-annotate-as-pure@7.27.3': + resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-create-class-features-plugin@7.28.6': + resolution: {integrity: sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-member-expression-to-functions@7.28.5': + resolution: {integrity: sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-optimise-call-expression@7.27.1': + resolution: {integrity: sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-plugin-utils@7.28.6': + resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} + engines: {node: '>=6.9.0'} + + '@babel/helper-replace-supers@7.28.6': + resolution: {integrity: sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': + resolution: {integrity: sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.2': + resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.2': + resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-syntax-jsx@7.28.6': + resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.28.6': + resolution: {integrity: sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-commonjs@7.28.6': + resolution: {integrity: sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-self@7.27.1': + resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.27.1': + resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-typescript@7.28.6': + resolution: {integrity: sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/preset-typescript@7.28.5': + resolution: {integrity: sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@0.2.3': + resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + + '@egjs/hammerjs@2.0.17': + resolution: {integrity: sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==} + engines: {node: '>=0.8.0'} + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.27.4': resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.27.4': resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.27.4': resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.27.4': resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.27.4': resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.27.4': resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.27.4': resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.27.4': resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.27.4': resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.27.4': resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.27.4': resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.27.4': resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.27.4': resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.27.4': resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.27.4': resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.27.4': resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.27.4': resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==} engines: {node: '>=18'} cpu: [x64] os: [linux] + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-arm64@0.27.4': resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.27.4': resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-arm64@0.27.4': resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.27.4': resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.27.4': + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/openharmony-arm64@0.27.4': resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.27.4': resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.27.4': resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.27.4': resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.27.4': resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==} engines: {node: '>=18'} cpu: [x64] os: [win32] + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@istanbuljs/schema@0.1.3': + resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} + engines: {node: '>=8'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + '@optique/core@0.6.11': resolution: {integrity: sha512-GVLFihzBA1j78NFlkU5N1Lu0jRqET0k6Z66WK8VQKG/a3cxmCInVGSKMIdQG8i6pgC8wD5OizF6Y3QMztmhAxg==} engines: {bun: '>=1.2.0', deno: '>=2.3.0', node: '>=20.0.0'} + '@optique/core@0.6.3': + resolution: {integrity: sha512-Aw7naHPeZr+YThJRrfk+Ds3OuAd7HUD1chsGncay+6crmLyrraXh0s0xDuJ52kCfNPdCTKAW3MG0XLcuEIr8qQ==} + engines: {bun: '>=1.2.0', deno: '>=2.3.0', node: '>=20.0.0'} + '@optique/run@0.6.11': resolution: {integrity: sha512-tsXBEygGSzNpFK2gjsRlXBn7FiScUeLFWIZNpoAZ8iG85Km0/3K9xgqlQAXoQ+uEZBe4XplnzyCDvmEgbyNT8w==} engines: {bun: '>=1.2.0', deno: '>=2.3.0', node: '>=20.0.0'} + '@optique/run@0.6.3': + resolution: {integrity: sha512-6zesLs4XLyFtG8s/JoG2VO/+plJVZhqbLK20yDm0hFEXrqcP+VCKTsoFAlWoWv4Us48V170SKH+8DZcxw8raJw==} + engines: {bun: '>=1.2.0', deno: '>=2.3.0', node: '>=20.0.0'} + + '@paralleldrive/cuid2@2.3.1': + resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + + '@rolldown/pluginutils@1.0.0-beta.47': + resolution: {integrity: sha512-8QagwMH3kNCuzD8EWL8R2YPW5e4OrHNSAHRFDdmFqEwEaD/KcNKjVoumo+gP2vW5eKB2UPbM6vTYiGZX0ixLnw==} + + '@rollup/rollup-android-arm-eabi@4.60.1': + resolution: {integrity: sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.1': + resolution: {integrity: sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.1': + resolution: {integrity: sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.1': + resolution: {integrity: sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.1': + resolution: {integrity: sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.1': + resolution: {integrity: sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.1': + resolution: {integrity: sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.60.1': + resolution: {integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.60.1': + resolution: {integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.60.1': + resolution: {integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.60.1': + resolution: {integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.60.1': + resolution: {integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.60.1': + resolution: {integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.60.1': + resolution: {integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.60.1': + resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.60.1': + resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.60.1': + resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.60.1': + resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.60.1': + resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.60.1': + resolution: {integrity: sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.1': + resolution: {integrity: sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.1': + resolution: {integrity: sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.1': + resolution: {integrity: sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.1': + resolution: {integrity: sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.1': + resolution: {integrity: sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==} + cpu: [x64] + os: [win32] + + '@tailwindcss/node@4.1.17': + resolution: {integrity: sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg==} + + '@tailwindcss/oxide-android-arm64@4.1.17': + resolution: {integrity: sha512-BMqpkJHgOZ5z78qqiGE6ZIRExyaHyuxjgrJ6eBO5+hfrfGkuya0lYfw8fRHG77gdTjWkNWEEm+qeG2cDMxArLQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.1.17': + resolution: {integrity: sha512-EquyumkQweUBNk1zGEU/wfZo2qkp/nQKRZM8bUYO0J+Lums5+wl2CcG1f9BgAjn/u9pJzdYddHWBiFXJTcxmOg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.1.17': + resolution: {integrity: sha512-gdhEPLzke2Pog8s12oADwYu0IAw04Y2tlmgVzIN0+046ytcgx8uZmCzEg4VcQh+AHKiS7xaL8kGo/QTiNEGRog==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.1.17': + resolution: {integrity: sha512-hxGS81KskMxML9DXsaXT1H0DyA+ZBIbyG/sSAjWNe2EDl7TkPOBI42GBV3u38itzGUOmFfCzk1iAjDXds8Oh0g==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.17': + resolution: {integrity: sha512-k7jWk5E3ldAdw0cNglhjSgv501u7yrMf8oeZ0cElhxU6Y2o7f8yqelOp3fhf7evjIS6ujTI3U8pKUXV2I4iXHQ==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.1.17': + resolution: {integrity: sha512-HVDOm/mxK6+TbARwdW17WrgDYEGzmoYayrCgmLEw7FxTPLcp/glBisuyWkFz/jb7ZfiAXAXUACfyItn+nTgsdQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-arm64-musl@4.1.17': + resolution: {integrity: sha512-HvZLfGr42i5anKtIeQzxdkw/wPqIbpeZqe7vd3V9vI3RQxe3xU1fLjss0TjyhxWcBaipk7NYwSrwTwK1hJARMg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-linux-x64-gnu@4.1.17': + resolution: {integrity: sha512-M3XZuORCGB7VPOEDH+nzpJ21XPvK5PyjlkSFkFziNHGLc5d6g3di2McAAblmaSUNl8IOmzYwLx9NsE7bplNkwQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-x64-musl@4.1.17': + resolution: {integrity: sha512-k7f+pf9eXLEey4pBlw+8dgfJHY4PZ5qOUFDyNf7SI6lHjQ9Zt7+NcscjpwdCEbYi6FI5c2KDTDWyf2iHcCSyyQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-wasm32-wasi@4.1.17': + resolution: {integrity: sha512-cEytGqSSoy7zK4JRWiTCx43FsKP/zGr0CsuMawhH67ONlH+T79VteQeJQRO/X7L0juEUA8ZyuYikcRBf0vsxhg==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.1.17': + resolution: {integrity: sha512-JU5AHr7gKbZlOGvMdb4722/0aYbU+tN6lv1kONx0JK2cGsh7g148zVWLM0IKR3NeKLv+L90chBVYcJ8uJWbC9A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.1.17': + resolution: {integrity: sha512-SKWM4waLuqx0IH+FMDUw6R66Hu4OuTALFgnleKbqhgGU30DY20NORZMZUKgLRjQXNN2TLzKvh48QXTig4h4bGw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.1.17': + resolution: {integrity: sha512-F0F7d01fmkQhsTjXezGBLdrl1KresJTcI3DB8EkScCldyKp3Msz4hub4uyYaVnk88BAS1g5DQjjF6F5qczheLA==} + engines: {node: '>= 10'} + + '@tailwindcss/vite@4.1.17': + resolution: {integrity: sha512-4+9w8ZHOiGnpcGI6z1TVVfWaX/koK7fKeSYF3qlYg2xpBtbteP2ddBxiarL+HVgfSJGeK5RIxRQmKm4rTJJAwA==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 + + '@tanstack/history@1.139.0': + resolution: {integrity: sha512-l6wcxwDBeh/7Dhles23U1O8lp9kNJmAb2yNjekR6olZwCRNAVA8TCXlVCrueELyFlYZqvQkh0ofxnzG62A1Kkg==} + engines: {node: '>=12'} + + '@tanstack/query-core@5.90.11': + resolution: {integrity: sha512-f9z/nXhCgWDF4lHqgIE30jxLe4sYv15QodfdPDKYAk7nAEjNcndy4dHz3ezhdUaR23BpWa4I2EH4/DZ0//Uf8A==} + + '@tanstack/react-query@5.90.11': + resolution: {integrity: sha512-3uyzz01D1fkTLXuxF3JfoJoHQMU2fxsfJwE+6N5hHy0dVNoZOvwKP8Z2k7k1KDeD54N20apcJnG75TBAStIrBA==} + peerDependencies: + react: ^18 || ^19 + + '@tanstack/react-router-devtools@1.139.10': + resolution: {integrity: sha512-DEpVb6pCS7Kxls+hRqWctBDHlIcpVILf04T5GyAd7SXB0x0KMpdneyh+zBbaiydynBElwQhVxwoAR6QwPlZBVg==} + engines: {node: '>=12'} + peerDependencies: + '@tanstack/react-router': ^1.139.10 + '@tanstack/router-core': ^1.139.10 + react: '>=18.0.0 || >=19.0.0' + react-dom: '>=18.0.0 || >=19.0.0' + peerDependenciesMeta: + '@tanstack/router-core': + optional: true + + '@tanstack/react-router@1.139.10': + resolution: {integrity: sha512-SVEH2n38XPtQSbW3BgKpK8G1GdLzbsmo3B/epfjuRk2XlYkSFjj7P8DIoHKOgkaCh3T0hKh/CEEj+D130YmGUw==} + engines: {node: '>=12'} + peerDependencies: + react: '>=18.0.0 || >=19.0.0' + react-dom: '>=18.0.0 || >=19.0.0' + + '@tanstack/react-store@0.8.1': + resolution: {integrity: sha512-XItJt+rG8c5Wn/2L/bnxys85rBpm0BfMbhb4zmPVLXAKY9POrp1xd6IbU4PKoOI+jSEGc3vntPRfLGSgXfE2Ig==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/router-core@1.139.10': + resolution: {integrity: sha512-gougqlYumNOn98d2ZhyoRJTNT8RvFip97z6T2T3/JTPrErwOsKaIA2FwlkfLJmJY1JQtUuF38IREJdfQrTJiqg==} + engines: {node: '>=12'} + + '@tanstack/router-devtools-core@1.139.10': + resolution: {integrity: sha512-rAUAhTvwivA49dkYR4bRUPRxqShO9dTD1+r3tZsnt23XlpmGtFvxBw8FYY2C9BvqaRLu+2RxACJDaXETVfm3OA==} + engines: {node: '>=12'} + peerDependencies: + '@tanstack/router-core': ^1.139.10 + csstype: ^3.0.10 + solid-js: '>=1.9.5' + peerDependenciesMeta: + csstype: + optional: true + + '@tanstack/router-generator@1.139.10': + resolution: {integrity: sha512-Uo0xmz6w1Ayv1AMyWLsT0ngXmjB8yAKv5khOaci/ZxAZNyvz3t84jqI7XXlG9fwtDRdTF4G/qBmXlPEmPk6Wfg==} + engines: {node: '>=12'} + + '@tanstack/router-plugin@1.139.10': + resolution: {integrity: sha512-0c9wzBKuz2U1jO+oAszT6VRaQDWPLfCJuPeXX7MCisM0nV2LVaxdb/y9YaWSKJ7zlQ7pwFkh37KYqcJhPXug/A==} + engines: {node: '>=12'} + peerDependencies: + '@rsbuild/core': '>=1.0.2' + '@tanstack/react-router': ^1.139.10 + vite: '>=5.0.0 || >=6.0.0 || >=7.0.0' + vite-plugin-solid: ^2.11.10 + webpack: '>=5.92.0' + peerDependenciesMeta: + '@rsbuild/core': + optional: true + '@tanstack/react-router': + optional: true + vite: + optional: true + vite-plugin-solid: + optional: true + webpack: + optional: true + + '@tanstack/router-utils@1.139.0': + resolution: {integrity: sha512-jT7D6NimWqoFSkid4vCno8gvTyfL1+NHpgm3es0B2UNhKKRV3LngOGilm1m6v8Qvk/gy6Fh/tvB+s+hBl6GhOg==} + engines: {node: '>=12'} + + '@tanstack/store@0.8.1': + resolution: {integrity: sha512-PtOisLjUZPz5VyPRSCGjNOlwTvabdTBQ2K80DpVL1chGVr35WRxfeavAPdNq6pm/t7F8GhoR2qtmkkqtCEtHYw==} + + '@tanstack/virtual-file-routes@1.139.0': + resolution: {integrity: sha512-9PImF1d1tovTUIpjFVa0W7Fwj/MHif7BaaczgJJfbv3sDt1Gh+oW9W9uCw9M3ndEJynnp5ZD/TTs0RGubH5ssg==} + engines: {node: '>=12'} + + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@testing-library/user-event@14.6.1': + resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/cookie-session@2.0.49': + resolution: {integrity: sha512-4E/bBjlqLhU5l4iGPR+NkVJH593hpNsT4dC3DJDr+ODm6Qpe13kZQVkezRIb+TYDXaBMemS3yLQ+0leba3jlkQ==} + + '@types/cookiejar@2.1.5': + resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/express-serve-static-core@4.19.8': resolution: {integrity: sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==} '@types/express@4.17.25': resolution: {integrity: sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==} + '@types/hammerjs@2.0.46': + resolution: {integrity: sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==} + '@types/http-errors@2.0.5': resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + '@types/keygrip@1.0.6': + resolution: {integrity: sha512-lZuNAY9xeJt7Bx4t4dx0rYCDqGPW8RXhQZK1td7d4H6E9zYbLoOtjBvfwdTKpsyxQI/2jv+armjX/RW+ZNpXOQ==} + '@types/luxon@3.7.1': resolution: {integrity: sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==} + '@types/methods@1.1.4': + resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} + '@types/mime@1.3.5': resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} + '@types/node@24.10.1': + resolution: {integrity: sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==} + '@types/node@24.12.0': resolution: {integrity: sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==} + '@types/pg@8.15.6': + resolution: {integrity: sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==} + '@types/pg@8.20.0': resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} @@ -291,6 +1388,14 @@ packages: '@types/range-parser@1.2.7': resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.7': + resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==} + '@types/send@0.17.6': resolution: {integrity: sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==} @@ -300,422 +1405,4429 @@ packages: '@types/serve-static@1.15.10': resolution: {integrity: sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==} - esbuild@0.27.4: - resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==} - engines: {node: '>=18'} - hasBin: true + '@types/superagent@8.1.9': + resolution: {integrity: sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==} - fast-check@4.6.0: - resolution: {integrity: sha512-h7H6Dm0Fy+H4ciQYFxFjXnXkzR2kr9Fb22c0UBpHnm59K2zpr2t13aPTHlltFiNT6zuxp6HMPAVVvgur4BLdpA==} - engines: {node: '>=12.17.0'} + '@types/supertest@6.0.3': + resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==} - fluture@14.0.0: - resolution: {integrity: sha512-pENtLF948a8DfduVKugT8edTAbFi4rBS94xjHwzLanQqIu5PYtLGl+xqs6H8TaIRL7z/B0cDpswdINzH/HRUGA==} - engines: {node: '>=4.0.0'} + '@vitejs/plugin-react@5.1.1': + resolution: {integrity: sha512-WQfkSw0QbQ5aJ2CHYw23ZGkqnRwqKHD/KYsMeTkZzPT4Jcf0DcBxBtwMJxnu6E7oxw5+JC6ZAiePgh28uJ1HBA==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 - fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] + '@vitest/coverage-v8@2.1.9': + resolution: {integrity: sha512-Z2cOr0ksM00MpEfyVE8KXIYPEcBFxdbLSs56L8PO0QQMxt/6bDj45uQfxoc96v05KW3clk7vvgP0qfDit9DmfQ==} + peerDependencies: + '@vitest/browser': 2.1.9 + vitest: 2.1.9 + peerDependenciesMeta: + '@vitest/browser': + optional: true - get-tsconfig@4.13.6: - resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} + '@vitest/expect@2.1.9': + resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} - json5@2.2.3: - resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} - engines: {node: '>=6'} + '@vitest/mocker@2.1.9': + resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@2.1.9': + resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + + '@vitest/runner@2.1.9': + resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} + + '@vitest/snapshot@2.1.9': + resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} + + '@vitest/spy@2.1.9': + resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} + + '@vitest/ui@2.1.9': + resolution: {integrity: sha512-izzd2zmnk8Nl5ECYkW27328RbQ1nKvkm6Bb5DAaz1Gk59EbLkiCMa6OLT0NoaAYTjOFS6N+SMYW1nh4/9ljPiw==} + peerDependencies: + vitest: 2.1.9 + + '@vitest/utils@2.1.9': + resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} hasBin: true - luxon@3.7.2: - resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} engines: {node: '>=12'} - minimist@1.2.8: - resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} - pg-cloudflare@1.3.0: - resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} - pg-connection-string@2.12.0: - resolution: {integrity: sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==} + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} - pg-int8@1.0.1: - resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} - engines: {node: '>=4.0.0'} + ansis@4.2.0: + resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} + engines: {node: '>=14'} - pg-pool@3.13.0: - resolution: {integrity: sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==} - peerDependencies: - pg: '>=8.0' + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} - pg-protocol@1.13.0: - resolution: {integrity: sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} - pg-types@2.2.0: - resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + array-flatten@1.1.1: + resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} + + array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + + asap@2.0.6: + resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + ast-types@0.16.1: + resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} engines: {node: '>=4'} - pg@8.20.0: - resolution: {integrity: sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==} - engines: {node: '>= 16.0.0'} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + babel-dead-code-elimination@1.0.12: + resolution: {integrity: sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + baseline-browser-mapping@2.10.17: + resolution: {integrity: sha512-HdrkN8eVG2CXxeifv/VdJ4A4RSra1DTW8dc/hdxzhGHN8QePs6gKaWM9pHPcpCoxYZJuOZ8drHmbdpLHjCYjLA==} + engines: {node: '>=6.0.0'} + hasBin: true + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + body-parser@1.20.3: + resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + brace-expansion@1.1.13: + resolution: {integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==} + + brace-expansion@2.0.3: + resolution: {integrity: sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==} + + brace-expansion@5.0.5: + resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + engines: {node: 18 || 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + caniuse-lite@1.0.30001787: + resolution: {integrity: sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + + commander@9.5.0: + resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} + engines: {node: ^12.20.0 || >=14} + + component-emitter@1.3.1: + resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + concurrently@8.2.2: + resolution: {integrity: sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg==} + engines: {node: ^14.13.0 || >=16.0.0} + hasBin: true + + content-disposition@0.5.4: + resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} + engines: {node: '>= 0.6'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie-es@2.0.1: + resolution: {integrity: sha512-aVf4A4hI2w70LnF7GG+7xDQUkliwiXWXFvTjkip4+b64ygDQ2sJPRSKFDHbxn8o0xu9QzPkMuuiWIXyFSE2slA==} + + cookie-session@2.1.1: + resolution: {integrity: sha512-ji3kym/XZaFVew1+tIZk5ZLp9Z/fLv9rK1aZmpug0FsgE7Cu3ZDrUdRo7FT9vFjMYfNimrrUHJzywDwT7XEFlg==} + engines: {node: '>= 0.10'} + + cookie-signature@1.0.6: + resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.1: + resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==} + engines: {node: '>= 0.6'} + + cookiejar@2.1.4: + resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} + + cookies@0.9.1: + resolution: {integrity: sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==} + engines: {node: '>= 0.8'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + + cssfilter@0.0.10: + resolution: {integrity: sha512-FAaLDaplstoRsDR8XGYH51znUN0UY7nMc6Z9/fvE8EXGwvJE9hu7W2vHwx1+bd6gCYnln9nLbzxFTrcO9YQDZw==} + + cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + + date-fns@2.30.0: + resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==} + engines: {node: '>=0.11'} + + date-fns@4.1.0: + resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} + + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} peerDependencies: - pg-native: '>=3.0.1' + supports-color: '*' peerDependenciesMeta: - pg-native: + supports-color: optional: true - pgpass@1.0.5: - resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + debug@3.2.7: + resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true - postgres-array@2.0.0: - resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} - engines: {node: '>=4'} + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true - postgres-bytea@1.0.1: - resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} - engines: {node: '>=0.10.0'} + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} - postgres-date@1.0.7: - resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} - engines: {node: '>=0.10.0'} + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} - postgres-interval@1.2.0: - resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} - engines: {node: '>=0.10.0'} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} - prettier@3.8.1: - resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} - engines: {node: '>=14'} - hasBin: true + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} - pure-rand@8.2.0: - resolution: {integrity: sha512-KHnUjm68KSO/hqpWlVwagMDPrIjnDNY9r0DbKN79xEa5RU2MLUe0lICBGpWDF8cwmhUiN8r9A8DLGPVcFB62/A==} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} - resolve-pkg-maps@1.0.0: - resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} - sanctuary-show@2.0.0: - resolution: {integrity: sha512-REj4ZiioUXnDLj6EpJ9HcYDIEGaEexmB9Fg5o6InZR9f0x5PfnnC21QeU9SZ9E7G8zXSZPNjy8VRUK4safbesw==} + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} - sanctuary-type-identifiers@3.0.0: - resolution: {integrity: sha512-YFXYcG0Ura1dSPd/1xLYtE2XAWUEsBHhMTZvYBOvwT8MeFQwdUOCMm2DC+r94z6H93FVq0qxDac8/D7QpJj6Mg==} + dezalgo@1.0.4: + resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} - sorted-btree@1.8.1: - resolution: {integrity: sha512-395+XIP+wqNn3USkFSrNz7G3Ss/MXlZEqesxvzCRFwL14h6e8LukDHdLBePn5pwbm5OQ9vGu8mDyz2lLDIqamQ==} + diff@8.0.4: + resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} + engines: {node: '>=0.3.1'} - split2@4.2.0: - resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} - engines: {node: '>= 10.x'} + dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} - strip-bom@3.0.0: - resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} - engines: {node: '>=4'} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} - ts-pattern@5.9.0: - resolution: {integrity: sha512-6s5V71mX8qBUmlgbrfL33xDUwO0fq48rxAu2LBE11WBeGdpCPOsXksQbZJHvHwhrd3QjUusd3mAOM5Gg0mFBLg==} + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} - tsconfig-paths@4.2.0: - resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + electron-to-chromium@1.5.335: + resolution: {integrity: sha512-q9n5T4BR4Xwa2cwbrwcsDJtHD/enpQ5S1xF1IAtdqf5AAgqDFmR/aakqH3ChFdqd/QXJhS3rnnXFtexU7rax6Q==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + encodeurl@1.0.2: + resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} + engines: {node: '>= 0.8'} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + enhanced-resolve@5.20.1: + resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} + engines: {node: '>=10.13.0'} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + esbuild@0.27.4: + resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} - tsx@4.21.0: - resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} - engines: {node: '>=18.0.0'} + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} hasBin: true - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} - engines: {node: '>=14.17'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + express@4.21.2: + resolution: {integrity: sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==} + engines: {node: '>= 0.10.0'} + + fast-check@4.6.0: + resolution: {integrity: sha512-h7H6Dm0Fy+H4ciQYFxFjXnXkzR2kr9Fb22c0UBpHnm59K2zpr2t13aPTHlltFiNT6zuxp6HMPAVVvgur4BLdpA==} + engines: {node: '>=12.17.0'} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + finalhandler@1.3.1: + resolution: {integrity: sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==} + engines: {node: '>= 0.8'} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + fluture@14.0.0: + resolution: {integrity: sha512-pENtLF948a8DfduVKugT8edTAbFi4rBS94xjHwzLanQqIu5PYtLGl+xqs6H8TaIRL7z/B0cDpswdINzH/HRUGA==} + engines: {node: '>=4.0.0'} + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + + formidable@3.5.4: + resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==} + engines: {node: '>=14.0.0'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-tsconfig@4.13.6: + resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true - undici-types@7.16.0: - resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + + goober@2.1.18: + resolution: {integrity: sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==} + peerDependencies: + csstype: ^3.0.10 + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-flag@3.0.0: + resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} + engines: {node: '>=4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + http-errors@2.0.0: + resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} + engines: {node: '>= 0.8'} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + iconv-lite@0.4.24: + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + engines: {node: '>=0.10.0'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + ignore-by-default@1.0.1: + resolution: {integrity: sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + isbot@5.1.37: + resolution: {integrity: sha512-5bcicX81xf6NlTEV8rWdg7Pk01LFizDetuYGHx6d/f6y3lR2/oo8IfxjzJqn1UdDEyCcwT9e7NRloj8DwCYujQ==} + engines: {node: '>=18'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-lib-source-maps@5.0.6: + resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsdom@25.0.1: + resolution: {integrity: sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^2.11.2 + peerDependenciesMeta: + canvas: + optional: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + keycharm@0.4.0: + resolution: {integrity: sha512-TyQTtsabOVv3MeOpR92sIKk/br9wxS+zGj4BG7CR8YbK4jM3tyIBaF0zhzeBUMx36/Q/iQLOKKOT+3jOQtemRQ==} + + keygrip@1.1.0: + resolution: {integrity: sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==} + engines: {node: '>= 0.6'} + + lightningcss-android-arm64@1.30.2: + resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.30.2: + resolution: {integrity: sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.30.2: + resolution: {integrity: sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.30.2: + resolution: {integrity: sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.30.2: + resolution: {integrity: sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.30.2: + resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.30.2: + resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.30.2: + resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.30.2: + resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.30.2: + resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.30.2: + resolution: {integrity: sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.30.2: + resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==} + engines: {node: '>= 12.0.0'} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + luxon@3.7.2: + resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} + engines: {node: '>=12'} + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magicast@0.3.5: + resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + + merge-descriptors@1.0.3: + resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + + mime@2.6.0: + resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} + engines: {node: '>=4.0.0'} + hasBin: true + + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + moment@2.30.1: + resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==} + + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mylas@2.1.14: + resolution: {integrity: sha512-BzQguy9W9NJgoVn2mRWzbFrFWWztGCcng2QI9+41frfk+Athwgx3qhqhvStz7ExeUUu7Kzw427sNzHpEZNINog==} + engines: {node: '>=16.0.0'} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + + node-releases@2.0.37: + resolution: {integrity: sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==} + + nodemon@3.1.11: + resolution: {integrity: sha512-is96t8F/1//UHAjNPHpbsNY46ELPpftGUoSVNXwUfMk/qdjSylYrWSu1XavVTBOn526kFiOR733ATgNBCQyH0g==} + engines: {node: '>=10'} + hasBin: true + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + nwsapi@2.2.23: + resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + on-headers@1.1.0: + resolution: {integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + path-to-regexp@0.1.12: + resolution: {integrity: sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + pg-cloudflare@1.3.0: + resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==} + + pg-connection-string@2.12.0: + resolution: {integrity: sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.13.0: + resolution: {integrity: sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.13.0: + resolution: {integrity: sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.16.3: + resolution: {integrity: sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pg@8.20.0: + resolution: {integrity: sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + plimit-lit@1.6.1: + resolution: {integrity: sha512-B7+VDyb8Tl6oMJT9oSO2CW8XC/T4UcJGrwOVoNGwOQsQYhlpfajmrMj5xeejqaASq3V/EqThyOeATEOMuSEXiA==} + engines: {node: '>=12'} + + postcss@8.5.9: + resolution: {integrity: sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==} + engines: {node: ^10 || ^12 || >=14} + + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + + prettier@3.8.1: + resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} + engines: {node: '>=14'} + hasBin: true + + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + propagating-hammerjs@2.0.1: + resolution: {integrity: sha512-PH3zG5whbSxMocphXJzVtvKr+vWAgfkqVvtuwjSJ/apmEACUoiw6auBAT5HYXpZOR0eGcTAfYG5Yl8h91O5Elg==} + peerDependencies: + '@egjs/hammerjs': ^2.0.17 + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + pstree.remy@1.1.8: + resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + pure-rand@8.2.0: + resolution: {integrity: sha512-KHnUjm68KSO/hqpWlVwagMDPrIjnDNY9r0DbKN79xEa5RU2MLUe0lICBGpWDF8cwmhUiN8r9A8DLGPVcFB62/A==} + + qs@6.13.0: + resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==} + engines: {node: '>=0.6'} + + qs@6.15.1: + resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==} + engines: {node: '>=0.6'} + + queue-lit@1.5.2: + resolution: {integrity: sha512-tLc36IOPeMAubu8BkW8YDBV+WyIgKlYU7zUNs0J5Vk9skSZ4JfGlPOqplP0aHdfv7HL0B2Pg6nwiq60Qc6M2Hw==} + engines: {node: '>=12'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@2.5.2: + resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==} + engines: {node: '>= 0.8'} + + react-dom@19.2.0: + resolution: {integrity: sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==} + peerDependencies: + react: ^19.2.0 + + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + + react-refresh@0.18.0: + resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} + engines: {node: '>=0.10.0'} + + react@19.2.0: + resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==} + engines: {node: '>=0.10.0'} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + recast@0.23.11: + resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==} + engines: {node: '>= 4'} + + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rollup@4.60.1: + resolution: {integrity: sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + rrweb-cssom@0.7.1: + resolution: {integrity: sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==} + + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + sanctuary-show@2.0.0: + resolution: {integrity: sha512-REj4ZiioUXnDLj6EpJ9HcYDIEGaEexmB9Fg5o6InZR9f0x5PfnnC21QeU9SZ9E7G8zXSZPNjy8VRUK4safbesw==} + + sanctuary-type-identifiers@3.0.0: + resolution: {integrity: sha512-YFXYcG0Ura1dSPd/1xLYtE2XAWUEsBHhMTZvYBOvwT8MeFQwdUOCMm2DC+r94z6H93FVq0qxDac8/D7QpJj6Mg==} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + send@0.19.0: + resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==} + engines: {node: '>= 0.8.0'} + + seroval-plugins@1.5.2: + resolution: {integrity: sha512-qpY0Cl+fKYFn4GOf3cMiq6l72CpuVaawb6ILjubOQ+diJ54LfOWaSSPsaswN8DRPIPW4Yq+tE1k5aKd7ILyaFg==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + + seroval@1.5.2: + resolution: {integrity: sha512-xcRN39BdsnO9Tf+VzsE7b3JyTJASItIV1FVFewJKCFcW4s4haIKS3e6vj8PGB9qBwC7tnuOywQMdv5N4qkzi7Q==} + engines: {node: '>=10'} + + serve-static@1.16.2: + resolution: {integrity: sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==} + engines: {node: '>= 0.8.0'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + shell-quote@1.8.3: + resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} + engines: {node: '>= 0.4'} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + simple-update-notifier@2.0.0: + resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} + engines: {node: '>=10'} + + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + solid-js@1.9.12: + resolution: {integrity: sha512-QzKaSJq2/iDrWR1As6MHZQ8fQkdOBf8GReYb7L5iKwMGceg7HxDcaOHk0at66tNgn9U2U7dXo8ZZpLIAmGMzgw==} + + sorted-btree@1.8.1: + resolution: {integrity: sha512-395+XIP+wqNn3USkFSrNz7G3Ss/MXlZEqesxvzCRFwL14h6e8LukDHdLBePn5pwbm5OQ9vGu8mDyz2lLDIqamQ==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + + spawn-command@0.0.2: + resolution: {integrity: sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + statuses@2.0.1: + resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} + engines: {node: '>= 0.8'} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + + superagent@10.3.0: + resolution: {integrity: sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==} + engines: {node: '>=14.18.0'} + + supertest@7.2.2: + resolution: {integrity: sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==} + engines: {node: '>=14.18.0'} + + supports-color@5.5.0: + resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} + engines: {node: '>=4'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + tailwindcss@4.1.17: + resolution: {integrity: sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==} + + tapable@2.3.2: + resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==} + engines: {node: '>=6'} + + test-exclude@7.0.2: + resolution: {integrity: sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==} + engines: {node: '>=18'} + + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + + tiny-warning@1.0.3: + resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + engines: {node: '>=14.0.0'} + + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + + touch@3.1.1: + resolution: {integrity: sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==} + hasBin: true + + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + ts-pattern@5.9.0: + resolution: {integrity: sha512-6s5V71mX8qBUmlgbrfL33xDUwO0fq48rxAu2LBE11WBeGdpCPOsXksQbZJHvHwhrd3QjUusd3mAOM5Gg0mFBLg==} + + tsc-alias@1.8.16: + resolution: {integrity: sha512-QjCyu55NFyRSBAl6+MTFwplpFcnm2Pq01rR/uxfqJoLMm6X3O14KEGtaSDZpJYaE1bJBGDjD0eSuiIWPe2T58g==} + engines: {node: '>=16.20.2'} + hasBin: true + + tsconfig-paths@4.2.0: + resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} + engines: {node: '>=6'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsscmp@1.0.6: + resolution: {integrity: sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==} + engines: {node: '>=0.6.x'} + + tsx@4.20.6: + resolution: {integrity: sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==} + engines: {node: '>=18.0.0'} + hasBin: true + + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undefsafe@2.0.5: + resolution: {integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==} + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + unplugin@2.3.11: + resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} + engines: {node: '>=18.12.0'} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + + uuid@11.1.0: + resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} + hasBin: true + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + vis-data@7.1.10: + resolution: {integrity: sha512-23juM9tdCaHTX5vyIQ7XBzsfZU0Hny+gSTwniLrfFcmw9DOm7pi3+h9iEBsoZMp5rX6KNqWwc1MF0fkAmWVuoQ==} + peerDependencies: + uuid: ^3.4.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + vis-util: ^5.0.1 + + vis-timeline@7.7.4: + resolution: {integrity: sha512-JkL1Qf2lkXze7M+NjbUmGrlJtYqCKoP49PQ0GOPIayjj/sXNpTNuIPHkJASZu1SH6XlRBYfU0hev5rRsSErHOQ==} + peerDependencies: + '@egjs/hammerjs': ^2.0.0 + component-emitter: ^1.3.0 + keycharm: ^0.2.0 || ^0.3.0 || ^0.4.0 + moment: ^2.24.0 + propagating-hammerjs: ^1.4.0 || ^2.0.0 + uuid: ^3.4.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + vis-data: ^6.3.0 || ^7.0.0 + vis-util: ^5.0.1 + xss: ^1.0.0 + + vis-util@5.0.7: + resolution: {integrity: sha512-E3L03G3+trvc/X4LXvBfih3YIHcKS2WrP0XTdZefr6W6Qi/2nNCqZfe4JFfJU6DcQLm6Gxqj2Pfl+02859oL5A==} + engines: {node: '>=8'} + peerDependencies: + '@egjs/hammerjs': ^2.0.0 + component-emitter: ^1.3.0 || ^2.0.0 + + vite-node@2.1.9: + resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vite@7.2.4: + resolution: {integrity: sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + 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 + + vitest@2.1.9: + resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 2.1.9 + '@vitest/ui': 2.1.9 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + webpack-virtual-modules@0.6.2: + resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@8.20.0: + resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + xss@1.0.15: + resolution: {integrity: sha512-FVdlVVC67WOIPvfOwhoMETV72f6GbW7aOabBC3WxN/oUdoEMDyLz4OgRv5/gck2ZeNqEQu+Tb0kloovXOfpYVg==} + engines: {node: '>= 0.10.0'} + hasBin: true + + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + +snapshots: + + '@adobe/css-tools@4.4.4': {} + + '@ambarltd/core@0.1.11': + dependencies: + '@optique/core': 0.6.3 + '@optique/run': 0.6.3 + fluture: 14.0.0 + luxon: 3.7.2 + sorted-btree: 1.8.1 + + '@ambarltd/core@0.1.12': + dependencies: + '@optique/core': 0.6.3 + '@optique/run': 0.6.3 + fluture: 14.0.0 + luxon: 3.7.2 + sorted-btree: 1.8.1 + + '@ambarltd/tasks@0.1.0': + dependencies: + '@ambarltd/core': 0.1.12 + luxon: 3.7.2 + pg: 8.16.3 + ts-pattern: 5.9.0 + transitivePeerDependencies: + - pg-native + + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@asamuzakjp/css-color@3.2.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.0': {} + + '@babel/core@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helpers': 7.29.2 + '@babel/parser': 7.29.2 + '@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.4.3(supports-color@5.5.0) + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.1': + dependencies: + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-annotate-as-pure@7.27.3': + dependencies: + '@babel/types': 7.29.0 + + '@babel/helper-compilation-targets@7.28.6': + dependencies: + '@babel/compat-data': 7.29.0 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.2 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-create-class-features-plugin@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0) + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/traverse': 7.29.0 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-member-expression-to-functions@7.28.5': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-optimise-call-expression@7.27.1': + dependencies: + '@babel/types': 7.29.0 + + '@babel/helper-plugin-utils@7.28.6': {} + + '@babel/helper-replace-supers@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.29.2': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + + '@babel/parser@7.29.2': + dependencies: + '@babel/types': 7.29.0 + + '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-typescript@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/preset-typescript@7.28.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-validator-option': 7.27.1 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/runtime@7.29.2': {} + + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.2 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3(supports-color@5.5.0) + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@bcoe/v8-coverage@0.2.3': {} + + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-tokenizer@3.0.4': {} + + '@egjs/hammerjs@2.0.17': + dependencies: + '@types/hammerjs': 2.0.46 + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/aix-ppc64@0.27.4': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.27.4': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-arm@0.27.4': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/android-x64@0.27.4': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.27.4': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.27.4': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.27.4': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.27.4': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.27.4': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-arm@0.27.4': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.27.4': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.27.4': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.27.4': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.27.4': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.27.4': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.27.4': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/linux-x64@0.27.4': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.27.4': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.27.4': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.27.4': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.27.4': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.27.4': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.27.4': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.27.4': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.27.4': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@esbuild/win32-x64@0.27.4': + optional: true + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.2.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@istanbuljs/schema@0.1.3': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@noble/hashes@1.8.0': {} + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@optique/core@0.6.11': {} + + '@optique/core@0.6.3': {} + + '@optique/run@0.6.11': + dependencies: + '@optique/core': 0.6.11 + + '@optique/run@0.6.3': + dependencies: + '@optique/core': 0.6.3 + + '@paralleldrive/cuid2@2.3.1': + dependencies: + '@noble/hashes': 1.8.0 + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@polka/url@1.0.0-next.29': {} + + '@rolldown/pluginutils@1.0.0-beta.47': {} + + '@rollup/rollup-android-arm-eabi@4.60.1': + optional: true + + '@rollup/rollup-android-arm64@4.60.1': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.1': + optional: true + + '@rollup/rollup-darwin-x64@4.60.1': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.1': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.1': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.1': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.1': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.1': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.1': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.1': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.1': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.1': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.1': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.1': + optional: true + + '@tailwindcss/node@4.1.17': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.20.1 + jiti: 2.6.1 + lightningcss: 1.30.2 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.1.17 + + '@tailwindcss/oxide-android-arm64@4.1.17': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.1.17': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.1.17': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.1.17': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.17': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.1.17': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.1.17': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.1.17': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.1.17': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.1.17': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.1.17': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.1.17': + optional: true + + '@tailwindcss/oxide@4.1.17': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.1.17 + '@tailwindcss/oxide-darwin-arm64': 4.1.17 + '@tailwindcss/oxide-darwin-x64': 4.1.17 + '@tailwindcss/oxide-freebsd-x64': 4.1.17 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.17 + '@tailwindcss/oxide-linux-arm64-gnu': 4.1.17 + '@tailwindcss/oxide-linux-arm64-musl': 4.1.17 + '@tailwindcss/oxide-linux-x64-gnu': 4.1.17 + '@tailwindcss/oxide-linux-x64-musl': 4.1.17 + '@tailwindcss/oxide-wasm32-wasi': 4.1.17 + '@tailwindcss/oxide-win32-arm64-msvc': 4.1.17 + '@tailwindcss/oxide-win32-x64-msvc': 4.1.17 + + '@tailwindcss/vite@4.1.17(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))': + dependencies: + '@tailwindcss/node': 4.1.17 + '@tailwindcss/oxide': 4.1.17 + tailwindcss: 4.1.17 + vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + + '@tanstack/history@1.139.0': {} + + '@tanstack/query-core@5.90.11': {} + + '@tanstack/react-query@5.90.11(react@19.2.0)': + dependencies: + '@tanstack/query-core': 5.90.11 + react: 19.2.0 + + '@tanstack/react-router-devtools@1.139.10(@tanstack/react-router@1.139.10(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(@tanstack/router-core@1.139.10)(@types/node@24.10.1)(csstype@3.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(solid-js@1.9.12)(tsx@4.21.0)': + dependencies: + '@tanstack/react-router': 1.139.10(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@tanstack/router-devtools-core': 1.139.10(@tanstack/router-core@1.139.10)(@types/node@24.10.1)(csstype@3.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(solid-js@1.9.12)(tsx@4.21.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + optionalDependencies: + '@tanstack/router-core': 1.139.10 + transitivePeerDependencies: + - '@types/node' + - csstype + - jiti + - less + - lightningcss + - sass + - sass-embedded + - solid-js + - stylus + - sugarss + - terser + - tsx + - yaml + + '@tanstack/react-router@1.139.10(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@tanstack/history': 1.139.0 + '@tanstack/react-store': 0.8.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@tanstack/router-core': 1.139.10 + isbot: 5.1.37 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + tiny-invariant: 1.3.3 + tiny-warning: 1.0.3 + + '@tanstack/react-store@0.8.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@tanstack/store': 0.8.1 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + use-sync-external-store: 1.6.0(react@19.2.0) + + '@tanstack/router-core@1.139.10': + dependencies: + '@tanstack/history': 1.139.0 + '@tanstack/store': 0.8.1 + cookie-es: 2.0.1 + seroval: 1.5.2 + seroval-plugins: 1.5.2(seroval@1.5.2) + tiny-invariant: 1.3.3 + tiny-warning: 1.0.3 + + '@tanstack/router-devtools-core@1.139.10(@tanstack/router-core@1.139.10)(@types/node@24.10.1)(csstype@3.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(solid-js@1.9.12)(tsx@4.21.0)': + dependencies: + '@tanstack/router-core': 1.139.10 + clsx: 2.1.1 + goober: 2.1.18(csstype@3.2.3) + solid-js: 1.9.12 + tiny-invariant: 1.3.3 + vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + optionalDependencies: + csstype: 3.2.3 + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + + '@tanstack/router-generator@1.139.10': + dependencies: + '@tanstack/router-core': 1.139.10 + '@tanstack/router-utils': 1.139.0 + '@tanstack/virtual-file-routes': 1.139.0 + prettier: 3.8.1 + recast: 0.23.11 + source-map: 0.7.6 + tsx: 4.20.6 + zod: 3.25.76 + transitivePeerDependencies: + - supports-color + + '@tanstack/router-plugin@1.139.10(@tanstack/react-router@1.139.10(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@tanstack/router-core': 1.139.10 + '@tanstack/router-generator': 1.139.10 + '@tanstack/router-utils': 1.139.0 + '@tanstack/virtual-file-routes': 1.139.0 + babel-dead-code-elimination: 1.0.12 + chokidar: 3.6.0 + unplugin: 2.3.11 + zod: 3.25.76 + optionalDependencies: + '@tanstack/react-router': 1.139.10(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + transitivePeerDependencies: + - supports-color + + '@tanstack/router-utils@1.139.0': + dependencies: + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/parser': 7.29.2 + '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0) + ansis: 4.2.0 + diff: 8.0.4 + pathe: 2.0.3 + tinyglobby: 0.2.16 + transitivePeerDependencies: + - supports-color + + '@tanstack/store@0.8.1': {} + + '@tanstack/virtual-file-routes@1.139.0': {} + + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/runtime': 7.29.2 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.9.1': + dependencies: + '@adobe/css-tools': 4.4.4 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@babel/runtime': 7.29.2 + '@testing-library/dom': 10.4.1 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.7 + '@types/react-dom': 19.2.3(@types/react@19.2.7) + + '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': + dependencies: + '@testing-library/dom': 10.4.1 + + '@types/aria-query@5.0.4': {} + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.0 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.0 + + '@types/body-parser@1.19.6': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 24.10.1 + + '@types/connect@3.4.38': + dependencies: + '@types/node': 24.10.1 + + '@types/cookie-session@2.0.49': + dependencies: + '@types/express': 4.17.25 + '@types/keygrip': 1.0.6 + + '@types/cookiejar@2.1.5': {} + + '@types/estree@1.0.8': {} + + '@types/express-serve-static-core@4.19.8': + dependencies: + '@types/node': 24.10.1 + '@types/qs': 6.15.0 + '@types/range-parser': 1.2.7 + '@types/send': 1.2.1 + + '@types/express@4.17.25': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 4.19.8 + '@types/qs': 6.15.0 + '@types/serve-static': 1.15.10 + + '@types/hammerjs@2.0.46': {} + + '@types/http-errors@2.0.5': {} + + '@types/keygrip@1.0.6': {} + + '@types/luxon@3.7.1': {} + + '@types/methods@1.1.4': {} + + '@types/mime@1.3.5': {} + + '@types/node@24.10.1': + dependencies: + undici-types: 7.16.0 + + '@types/node@24.12.0': + dependencies: + undici-types: 7.16.0 + + '@types/pg@8.15.6': + dependencies: + '@types/node': 24.10.1 + pg-protocol: 1.13.0 + pg-types: 2.2.0 + + '@types/pg@8.20.0': + dependencies: + '@types/node': 24.10.1 + pg-protocol: 1.13.0 + pg-types: 2.2.0 + + '@types/qs@6.15.0': {} + + '@types/range-parser@1.2.7': {} + + '@types/react-dom@19.2.3(@types/react@19.2.7)': + dependencies: + '@types/react': 19.2.7 + + '@types/react@19.2.7': + dependencies: + csstype: 3.2.3 + + '@types/send@0.17.6': + dependencies: + '@types/mime': 1.3.5 + '@types/node': 24.10.1 + + '@types/send@1.2.1': + dependencies: + '@types/node': 24.10.1 + + '@types/serve-static@1.15.10': + dependencies: + '@types/http-errors': 2.0.5 + '@types/node': 24.10.1 + '@types/send': 0.17.6 + + '@types/superagent@8.1.9': + dependencies: + '@types/cookiejar': 2.1.5 + '@types/methods': 1.1.4 + '@types/node': 24.10.1 + form-data: 4.0.5 + + '@types/supertest@6.0.3': + dependencies: + '@types/methods': 1.1.4 + '@types/superagent': 8.1.9 + + '@vitejs/plugin-react@5.1.1(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) + '@rolldown/pluginutils': 1.0.0-beta.47 + '@types/babel__core': 7.20.5 + react-refresh: 0.18.0 + vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + transitivePeerDependencies: + - supports-color + + '@vitest/coverage-v8@2.1.9(vitest@2.1.9)': + dependencies: + '@ampproject/remapping': 2.3.0 + '@bcoe/v8-coverage': 0.2.3 + debug: 4.4.3(supports-color@5.5.0) + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.2.0 + magic-string: 0.30.21 + magicast: 0.3.5 + std-env: 3.10.0 + test-exclude: 7.0.2 + tinyrainbow: 1.2.0 + vitest: 2.1.9(@types/node@24.10.1)(@vitest/ui@2.1.9)(jsdom@25.0.1)(lightningcss@1.30.2) + transitivePeerDependencies: + - supports-color + + '@vitest/expect@2.1.9': + dependencies: + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + tinyrainbow: 1.2.0 + + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@24.10.1)(lightningcss@1.30.2))': + dependencies: + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21(@types/node@24.10.1)(lightningcss@1.30.2) + + '@vitest/pretty-format@2.1.9': + dependencies: + tinyrainbow: 1.2.0 + + '@vitest/runner@2.1.9': + dependencies: + '@vitest/utils': 2.1.9 + pathe: 1.1.2 + + '@vitest/snapshot@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + magic-string: 0.30.21 + pathe: 1.1.2 + + '@vitest/spy@2.1.9': + dependencies: + tinyspy: 3.0.2 + + '@vitest/ui@2.1.9(vitest@2.1.9)': + dependencies: + '@vitest/utils': 2.1.9 + fflate: 0.8.2 + flatted: 3.4.2 + pathe: 1.1.2 + sirv: 3.0.2 + tinyglobby: 0.2.16 + tinyrainbow: 1.2.0 + vitest: 2.1.9(@types/node@24.10.1)(@vitest/ui@2.1.9)(jsdom@25.0.1)(lightningcss@1.30.2) + + '@vitest/utils@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + loupe: 3.2.1 + tinyrainbow: 1.2.0 + + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + + acorn@8.16.0: {} + + agent-base@7.1.4: {} + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@5.2.0: {} + + ansi-styles@6.2.3: {} + + ansis@4.2.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + + array-flatten@1.1.1: {} + + array-union@2.1.0: {} + + asap@2.0.6: {} + + assertion-error@2.0.1: {} + + ast-types@0.16.1: + dependencies: + tslib: 2.8.1 + + asynckit@0.4.0: {} + + babel-dead-code-elimination@1.0.12: + dependencies: + '@babel/core': 7.29.0 + '@babel/parser': 7.29.2 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + baseline-browser-mapping@2.10.17: {} + + binary-extensions@2.3.0: {} + + body-parser@1.20.3: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + http-errors: 2.0.0 + iconv-lite: 0.4.24 + on-finished: 2.4.1 + qs: 6.13.0 + raw-body: 2.5.2 + type-is: 1.6.18 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + brace-expansion@1.1.13: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.0.3: + dependencies: + balanced-match: 1.0.2 + + brace-expansion@5.0.5: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.2: + dependencies: + baseline-browser-mapping: 2.10.17 + caniuse-lite: 1.0.30001787 + electron-to-chromium: 1.5.335 + node-releases: 2.0.37 + update-browserslist-db: 1.2.3(browserslist@4.28.2) + + bytes@3.1.2: {} + + cac@6.7.14: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + caniuse-lite@1.0.30001787: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + check-error@2.1.3: {} + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + clsx@2.1.1: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + commander@2.20.3: {} + + commander@9.5.0: {} + + component-emitter@1.3.1: {} + + concat-map@0.0.1: {} + + concurrently@8.2.2: + dependencies: + chalk: 4.1.2 + date-fns: 2.30.0 + lodash: 4.18.1 + rxjs: 7.8.2 + shell-quote: 1.8.3 + spawn-command: 0.0.2 + supports-color: 8.1.1 + tree-kill: 1.2.2 + yargs: 17.7.2 + + content-disposition@0.5.4: + dependencies: + safe-buffer: 5.2.1 + + content-type@1.0.5: {} + + convert-source-map@2.0.0: {} + + cookie-es@2.0.1: {} + + cookie-session@2.1.1: + dependencies: + cookies: 0.9.1 + debug: 3.2.7 + on-headers: 1.1.0 + safe-buffer: 5.2.1 + transitivePeerDependencies: + - supports-color + + cookie-signature@1.0.6: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.1: {} + + cookiejar@2.1.4: {} + + cookies@0.9.1: + dependencies: + depd: 2.0.0 + keygrip: 1.1.0 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + css.escape@1.5.1: {} + + cssfilter@0.0.10: {} + + cssstyle@4.6.0: + dependencies: + '@asamuzakjp/css-color': 3.2.0 + rrweb-cssom: 0.8.0 + + csstype@3.2.3: {} + + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + + date-fns@2.30.0: + dependencies: + '@babel/runtime': 7.29.2 + + date-fns@4.1.0: {} + + debug@2.6.9: + dependencies: + ms: 2.0.0 + + debug@3.2.7: + dependencies: + ms: 2.1.3 + + debug@4.4.3(supports-color@5.5.0): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 5.5.0 + + decimal.js@10.6.0: {} + + deep-eql@5.0.2: {} + + delayed-stream@1.0.0: {} + + depd@2.0.0: {} + + dequal@2.0.3: {} + + destroy@1.2.0: {} + + detect-libc@2.1.2: {} + + dezalgo@1.0.4: + dependencies: + asap: 2.0.6 + wrappy: 1.0.2 + + diff@8.0.4: {} + + dir-glob@3.0.1: + dependencies: + path-type: 4.0.0 + + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + + dotenv@16.6.1: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + eastasianwidth@0.2.0: {} + + ee-first@1.1.1: {} + + electron-to-chromium@1.5.335: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + encodeurl@1.0.2: {} + + encodeurl@2.0.0: {} + + enhanced-resolve@5.20.1: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.2 + + entities@6.0.1: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@1.7.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + esbuild@0.25.12: + 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 + + esbuild@0.27.4: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.4 + '@esbuild/android-arm': 0.27.4 + '@esbuild/android-arm64': 0.27.4 + '@esbuild/android-x64': 0.27.4 + '@esbuild/darwin-arm64': 0.27.4 + '@esbuild/darwin-x64': 0.27.4 + '@esbuild/freebsd-arm64': 0.27.4 + '@esbuild/freebsd-x64': 0.27.4 + '@esbuild/linux-arm': 0.27.4 + '@esbuild/linux-arm64': 0.27.4 + '@esbuild/linux-ia32': 0.27.4 + '@esbuild/linux-loong64': 0.27.4 + '@esbuild/linux-mips64el': 0.27.4 + '@esbuild/linux-ppc64': 0.27.4 + '@esbuild/linux-riscv64': 0.27.4 + '@esbuild/linux-s390x': 0.27.4 + '@esbuild/linux-x64': 0.27.4 + '@esbuild/netbsd-arm64': 0.27.4 + '@esbuild/netbsd-x64': 0.27.4 + '@esbuild/openbsd-arm64': 0.27.4 + '@esbuild/openbsd-x64': 0.27.4 + '@esbuild/openharmony-arm64': 0.27.4 + '@esbuild/sunos-x64': 0.27.4 + '@esbuild/win32-arm64': 0.27.4 + '@esbuild/win32-ia32': 0.27.4 + '@esbuild/win32-x64': 0.27.4 + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + esprima@4.0.1: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + etag@1.8.1: {} + + expect-type@1.3.0: {} + + express@4.21.2: + dependencies: + accepts: 1.3.8 + array-flatten: 1.1.1 + body-parser: 1.20.3 + content-disposition: 0.5.4 + content-type: 1.0.5 + cookie: 0.7.1 + cookie-signature: 1.0.6 + debug: 2.6.9 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 1.3.1 + fresh: 0.5.2 + http-errors: 2.0.0 + merge-descriptors: 1.0.3 + methods: 1.1.2 + on-finished: 2.4.1 + parseurl: 1.3.3 + path-to-regexp: 0.1.12 + proxy-addr: 2.0.7 + qs: 6.13.0 + range-parser: 1.2.1 + safe-buffer: 5.2.1 + send: 0.19.0 + serve-static: 1.16.2 + setprototypeof: 1.2.0 + statuses: 2.0.1 + type-is: 1.6.18 + utils-merge: 1.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + fast-check@4.6.0: + dependencies: + pure-rand: 8.2.0 + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-safe-stringify@2.1.1: {} + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fflate@0.8.2: {} + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + finalhandler@1.3.1: + dependencies: + debug: 2.6.9 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.1 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + flatted@3.4.2: {} + + fluture@14.0.0: + dependencies: + sanctuary-show: 2.0.0 + sanctuary-type-identifiers: 3.0.0 + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + + formidable@3.5.4: + dependencies: + '@paralleldrive/cuid2': 2.3.1 + dezalgo: 1.0.4 + once: 1.4.0 + + forwarded@0.2.0: {} + + fresh@0.5.2: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + gensync@1.0.0-beta.2: {} + + get-caller-file@2.0.5: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + get-tsconfig@4.13.6: + dependencies: + resolve-pkg-maps: 1.0.0 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + + globby@11.1.0: + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.3.3 + ignore: 5.3.2 + merge2: 1.4.1 + slash: 3.0.0 + + goober@2.1.18(csstype@3.2.3): + dependencies: + csstype: 3.2.3 + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + has-flag@3.0.0: {} + + has-flag@4.0.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + + html-escaper@2.0.2: {} + + http-errors@2.0.0: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.1 + toidentifier: 1.0.1 + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3(supports-color@5.5.0) + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3(supports-color@5.5.0) + transitivePeerDependencies: + - supports-color + + iconv-lite@0.4.24: + dependencies: + safer-buffer: 2.1.2 + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + ignore-by-default@1.0.1: {} + + ignore@5.3.2: {} + + indent-string@4.0.0: {} + + inherits@2.0.4: {} + + ipaddr.js@1.9.1: {} + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + + is-potential-custom-element-name@1.0.1: {} + + isbot@5.1.37: {} + + isexe@2.0.0: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@5.0.6: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + debug: 4.4.3(supports-color@5.5.0) + istanbul-lib-coverage: 3.2.2 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + jiti@2.6.1: {} + + js-tokens@4.0.0: {} + + jsdom@25.0.1: + dependencies: + cssstyle: 4.6.0 + data-urls: 5.0.0 + decimal.js: 10.6.0 + form-data: 4.0.5 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.23 + parse5: 7.3.0 + rrweb-cssom: 0.7.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.1.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.20.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + jsesc@3.1.0: {} + + json5@2.2.3: {} + + keycharm@0.4.0: {} + + keygrip@1.1.0: + dependencies: + tsscmp: 1.0.6 + + lightningcss-android-arm64@1.30.2: + optional: true + + lightningcss-darwin-arm64@1.30.2: + optional: true + + lightningcss-darwin-x64@1.30.2: + optional: true + + lightningcss-freebsd-x64@1.30.2: + optional: true + + lightningcss-linux-arm-gnueabihf@1.30.2: + optional: true + + lightningcss-linux-arm64-gnu@1.30.2: + optional: true + + lightningcss-linux-arm64-musl@1.30.2: + optional: true + + lightningcss-linux-x64-gnu@1.30.2: + optional: true + + lightningcss-linux-x64-musl@1.30.2: + optional: true + + lightningcss-win32-arm64-msvc@1.30.2: + optional: true + + lightningcss-win32-x64-msvc@1.30.2: + optional: true + + lightningcss@1.30.2: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.30.2 + lightningcss-darwin-arm64: 1.30.2 + lightningcss-darwin-x64: 1.30.2 + lightningcss-freebsd-x64: 1.30.2 + lightningcss-linux-arm-gnueabihf: 1.30.2 + lightningcss-linux-arm64-gnu: 1.30.2 + lightningcss-linux-arm64-musl: 1.30.2 + lightningcss-linux-x64-gnu: 1.30.2 + lightningcss-linux-x64-musl: 1.30.2 + lightningcss-win32-arm64-msvc: 1.30.2 + lightningcss-win32-x64-msvc: 1.30.2 + + lodash@4.18.1: {} + + loupe@3.2.1: {} + + lru-cache@10.4.3: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + luxon@3.7.2: {} + + lz-string@1.5.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magicast@0.3.5: + dependencies: + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.7.4 + + math-intrinsics@1.1.0: {} + + media-typer@0.3.0: {} + + merge-descriptors@1.0.3: {} + + merge2@1.4.1: {} + + methods@1.1.2: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime@1.6.0: {} + + mime@2.6.0: {} + + min-indent@1.0.1: {} + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.5 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.13 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.0.3 + + minimist@1.2.8: {} + + minipass@7.1.3: {} + + moment@2.30.1: {} + + mrmime@2.0.1: {} + + ms@2.0.0: {} + + ms@2.1.3: {} + + mylas@2.1.14: {} + + nanoid@3.3.11: {} + + negotiator@0.6.3: {} + + node-releases@2.0.37: {} + + nodemon@3.1.11: + dependencies: + chokidar: 3.6.0 + debug: 4.4.3(supports-color@5.5.0) + ignore-by-default: 1.0.1 + minimatch: 3.1.5 + pstree.remy: 1.1.8 + semver: 7.7.4 + simple-update-notifier: 2.0.0 + supports-color: 5.5.0 + touch: 3.1.1 + undefsafe: 2.0.5 + + normalize-path@3.0.0: {} + + nwsapi@2.2.23: {} + + object-inspect@1.13.4: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + on-headers@1.1.0: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + package-json-from-dist@1.0.1: {} + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + parseurl@1.3.3: {} + + path-key@3.1.1: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + + path-to-regexp@0.1.12: {} + + path-type@4.0.0: {} + + pathe@1.1.2: {} + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + pg-cloudflare@1.3.0: + optional: true + + pg-connection-string@2.12.0: {} + + pg-int8@1.0.1: {} + + pg-pool@3.13.0(pg@8.16.3): + dependencies: + pg: 8.16.3 + + pg-pool@3.13.0(pg@8.20.0): + dependencies: + pg: 8.20.0 + + pg-protocol@1.13.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.16.3: + dependencies: + pg-connection-string: 2.12.0 + pg-pool: 3.13.0(pg@8.16.3) + pg-protocol: 1.13.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.3.0 + + pg@8.20.0: + dependencies: + pg-connection-string: 2.12.0 + pg-pool: 3.13.0(pg@8.20.0) + pg-protocol: 1.13.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.3.0 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.4: {} + + plimit-lit@1.6.1: + dependencies: + queue-lit: 1.5.2 + + postcss@8.5.9: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + postgres-array@2.0.0: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + + prettier@3.8.1: {} + + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + + propagating-hammerjs@2.0.1(@egjs/hammerjs@2.0.17): + dependencies: + '@egjs/hammerjs': 2.0.17 + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + pstree.remy@1.1.8: {} + + punycode@2.3.1: {} + + pure-rand@8.2.0: {} + + qs@6.13.0: + dependencies: + side-channel: 1.1.0 + + qs@6.15.1: + dependencies: + side-channel: 1.1.0 + + queue-lit@1.5.2: {} + + queue-microtask@1.2.3: {} + + range-parser@1.2.1: {} + + raw-body@2.5.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.0 + iconv-lite: 0.4.24 + unpipe: 1.0.0 + + react-dom@19.2.0(react@19.2.0): + dependencies: + react: 19.2.0 + scheduler: 0.27.0 + + react-is@17.0.2: {} + + react-refresh@0.18.0: {} + + react@19.2.0: {} + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.2 + + recast@0.23.11: + dependencies: + ast-types: 0.16.1 + esprima: 4.0.1 + source-map: 0.6.1 + tiny-invariant: 1.3.3 + tslib: 2.8.1 + + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + + require-directory@2.1.1: {} + + resolve-pkg-maps@1.0.0: {} + + reusify@1.1.0: {} + + rollup@4.60.1: + dependencies: + '@types/estree': 1.0.8 + 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.3 + + rrweb-cssom@0.7.1: {} + + rrweb-cssom@0.8.0: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + + safe-buffer@5.2.1: {} + + safer-buffer@2.1.2: {} + + sanctuary-show@2.0.0: {} + + sanctuary-type-identifiers@3.0.0: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + semver@7.7.4: {} - xtend@4.0.2: - resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} - engines: {node: '>=0.4'} + send@0.19.0: + dependencies: + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 1.0.2 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 2.0.0 + mime: 1.6.0 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.1 + transitivePeerDependencies: + - supports-color + + seroval-plugins@1.5.2(seroval@1.5.2): + dependencies: + seroval: 1.5.2 -snapshots: + seroval@1.5.2: {} - '@ambarltd/core@0.1.11': + serve-static@1.16.2: dependencies: - '@optique/core': 0.6.11 - '@optique/run': 0.6.11 - fluture: 14.0.0 - luxon: 3.7.2 - sorted-btree: 1.8.1 + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 0.19.0 + transitivePeerDependencies: + - supports-color - '@esbuild/aix-ppc64@0.27.4': - optional: true + setprototypeof@1.2.0: {} - '@esbuild/android-arm64@0.27.4': - optional: true + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 - '@esbuild/android-arm@0.27.4': - optional: true + shebang-regex@3.0.0: {} - '@esbuild/android-x64@0.27.4': - optional: true + shell-quote@1.8.3: {} - '@esbuild/darwin-arm64@0.27.4': - optional: true + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 - '@esbuild/darwin-x64@0.27.4': - optional: true + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 - '@esbuild/freebsd-arm64@0.27.4': - optional: true + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 - '@esbuild/freebsd-x64@0.27.4': - optional: true + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 - '@esbuild/linux-arm64@0.27.4': - optional: true + siginfo@2.0.0: {} - '@esbuild/linux-arm@0.27.4': - optional: true + signal-exit@4.1.0: {} - '@esbuild/linux-ia32@0.27.4': - optional: true + simple-update-notifier@2.0.0: + dependencies: + semver: 7.7.4 - '@esbuild/linux-loong64@0.27.4': - optional: true + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 - '@esbuild/linux-mips64el@0.27.4': - optional: true + slash@3.0.0: {} - '@esbuild/linux-ppc64@0.27.4': - optional: true + solid-js@1.9.12: + dependencies: + csstype: 3.2.3 + seroval: 1.5.2 + seroval-plugins: 1.5.2(seroval@1.5.2) - '@esbuild/linux-riscv64@0.27.4': - optional: true + sorted-btree@1.8.1: {} - '@esbuild/linux-s390x@0.27.4': - optional: true + source-map-js@1.2.1: {} - '@esbuild/linux-x64@0.27.4': - optional: true + source-map@0.6.1: {} - '@esbuild/netbsd-arm64@0.27.4': - optional: true + source-map@0.7.6: {} - '@esbuild/netbsd-x64@0.27.4': - optional: true + spawn-command@0.0.2: {} - '@esbuild/openbsd-arm64@0.27.4': - optional: true + split2@4.2.0: {} - '@esbuild/openbsd-x64@0.27.4': - optional: true + stackback@0.0.2: {} - '@esbuild/openharmony-arm64@0.27.4': - optional: true + statuses@2.0.1: {} - '@esbuild/sunos-x64@0.27.4': - optional: true + std-env@3.10.0: {} - '@esbuild/win32-arm64@0.27.4': - optional: true + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 - '@esbuild/win32-ia32@0.27.4': - optional: true + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 - '@esbuild/win32-x64@0.27.4': - optional: true + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 - '@optique/core@0.6.11': {} + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 - '@optique/run@0.6.11': + strip-bom@3.0.0: {} + + strip-indent@3.0.0: dependencies: - '@optique/core': 0.6.11 + min-indent: 1.0.1 - '@types/body-parser@1.19.6': + superagent@10.3.0: dependencies: - '@types/connect': 3.4.38 - '@types/node': 24.12.0 + component-emitter: 1.3.1 + cookiejar: 2.1.4 + debug: 4.4.3(supports-color@5.5.0) + fast-safe-stringify: 2.1.1 + form-data: 4.0.5 + formidable: 3.5.4 + methods: 1.1.2 + mime: 2.6.0 + qs: 6.15.1 + transitivePeerDependencies: + - supports-color + + supertest@7.2.2: + dependencies: + cookie-signature: 1.2.2 + methods: 1.1.2 + superagent: 10.3.0 + transitivePeerDependencies: + - supports-color - '@types/connect@3.4.38': + supports-color@5.5.0: dependencies: - '@types/node': 24.12.0 + has-flag: 3.0.0 - '@types/express-serve-static-core@4.19.8': + supports-color@7.2.0: dependencies: - '@types/node': 24.12.0 - '@types/qs': 6.15.0 - '@types/range-parser': 1.2.7 - '@types/send': 1.2.1 + has-flag: 4.0.0 - '@types/express@4.17.25': + supports-color@8.1.1: dependencies: - '@types/body-parser': 1.19.6 - '@types/express-serve-static-core': 4.19.8 - '@types/qs': 6.15.0 - '@types/serve-static': 1.15.10 + has-flag: 4.0.0 - '@types/http-errors@2.0.5': {} + symbol-tree@3.2.4: {} - '@types/luxon@3.7.1': {} + tailwindcss@4.1.17: {} - '@types/mime@1.3.5': {} + tapable@2.3.2: {} - '@types/node@24.12.0': + test-exclude@7.0.2: dependencies: - undici-types: 7.16.0 + '@istanbuljs/schema': 0.1.3 + glob: 10.5.0 + minimatch: 10.2.5 - '@types/pg@8.20.0': + tiny-invariant@1.3.3: {} + + tiny-warning@1.0.3: {} + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.16: dependencies: - '@types/node': 24.12.0 - pg-protocol: 1.13.0 - pg-types: 2.2.0 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 - '@types/qs@6.15.0': {} + tinypool@1.1.1: {} - '@types/range-parser@1.2.7': {} + tinyrainbow@1.2.0: {} - '@types/send@0.17.6': + tinyspy@3.0.2: {} + + tldts-core@6.1.86: {} + + tldts@6.1.86: dependencies: - '@types/mime': 1.3.5 - '@types/node': 24.12.0 + tldts-core: 6.1.86 - '@types/send@1.2.1': + to-regex-range@5.0.1: dependencies: - '@types/node': 24.12.0 + is-number: 7.0.0 - '@types/serve-static@1.15.10': + toidentifier@1.0.1: {} + + totalist@3.0.1: {} + + touch@3.1.1: {} + + tough-cookie@5.1.2: dependencies: - '@types/http-errors': 2.0.5 - '@types/node': 24.12.0 - '@types/send': 0.17.6 + tldts: 6.1.86 - esbuild@0.27.4: - optionalDependencies: - '@esbuild/aix-ppc64': 0.27.4 - '@esbuild/android-arm': 0.27.4 - '@esbuild/android-arm64': 0.27.4 - '@esbuild/android-x64': 0.27.4 - '@esbuild/darwin-arm64': 0.27.4 - '@esbuild/darwin-x64': 0.27.4 - '@esbuild/freebsd-arm64': 0.27.4 - '@esbuild/freebsd-x64': 0.27.4 - '@esbuild/linux-arm': 0.27.4 - '@esbuild/linux-arm64': 0.27.4 - '@esbuild/linux-ia32': 0.27.4 - '@esbuild/linux-loong64': 0.27.4 - '@esbuild/linux-mips64el': 0.27.4 - '@esbuild/linux-ppc64': 0.27.4 - '@esbuild/linux-riscv64': 0.27.4 - '@esbuild/linux-s390x': 0.27.4 - '@esbuild/linux-x64': 0.27.4 - '@esbuild/netbsd-arm64': 0.27.4 - '@esbuild/netbsd-x64': 0.27.4 - '@esbuild/openbsd-arm64': 0.27.4 - '@esbuild/openbsd-x64': 0.27.4 - '@esbuild/openharmony-arm64': 0.27.4 - '@esbuild/sunos-x64': 0.27.4 - '@esbuild/win32-arm64': 0.27.4 - '@esbuild/win32-ia32': 0.27.4 - '@esbuild/win32-x64': 0.27.4 + tr46@5.1.1: + dependencies: + punycode: 2.3.1 - fast-check@4.6.0: + tree-kill@1.2.2: {} + + ts-pattern@5.9.0: {} + + tsc-alias@1.8.16: dependencies: - pure-rand: 8.2.0 + chokidar: 3.6.0 + commander: 9.5.0 + get-tsconfig: 4.13.6 + globby: 11.1.0 + mylas: 2.1.14 + normalize-path: 3.0.0 + plimit-lit: 1.6.1 - fluture@14.0.0: + tsconfig-paths@4.2.0: dependencies: - sanctuary-show: 2.0.0 - sanctuary-type-identifiers: 3.0.0 + json5: 2.2.3 + minimist: 1.2.8 + strip-bom: 3.0.0 - fsevents@2.3.3: - optional: true + tslib@2.8.1: {} - get-tsconfig@4.13.6: + tsscmp@1.0.6: {} + + tsx@4.20.6: dependencies: - resolve-pkg-maps: 1.0.0 + esbuild: 0.25.12 + get-tsconfig: 4.13.6 + optionalDependencies: + fsevents: 2.3.3 - json5@2.2.3: {} + tsx@4.21.0: + dependencies: + esbuild: 0.27.4 + get-tsconfig: 4.13.6 + optionalDependencies: + fsevents: 2.3.3 - luxon@3.7.2: {} + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 - minimist@1.2.8: {} + typescript@5.9.3: {} - pg-cloudflare@1.3.0: - optional: true + undefsafe@2.0.5: {} - pg-connection-string@2.12.0: {} + undici-types@7.16.0: {} - pg-int8@1.0.1: {} + unpipe@1.0.0: {} - pg-pool@3.13.0(pg@8.20.0): + unplugin@2.3.11: dependencies: - pg: 8.20.0 + '@jridgewell/remapping': 2.3.5 + acorn: 8.16.0 + picomatch: 4.0.4 + webpack-virtual-modules: 0.6.2 - pg-protocol@1.13.0: {} + update-browserslist-db@1.2.3(browserslist@4.28.2): + dependencies: + browserslist: 4.28.2 + escalade: 3.2.0 + picocolors: 1.1.1 - pg-types@2.2.0: + use-sync-external-store@1.6.0(react@19.2.0): dependencies: - pg-int8: 1.0.1 - postgres-array: 2.0.0 - postgres-bytea: 1.0.1 - postgres-date: 1.0.7 - postgres-interval: 1.2.0 + react: 19.2.0 - pg@8.20.0: + utils-merge@1.0.1: {} + + uuid@11.1.0: {} + + vary@1.1.2: {} + + vis-data@7.1.10(uuid@11.1.0)(vis-util@5.0.7(@egjs/hammerjs@2.0.17)(component-emitter@1.3.1)): dependencies: - pg-connection-string: 2.12.0 - pg-pool: 3.13.0(pg@8.20.0) - pg-protocol: 1.13.0 - pg-types: 2.2.0 - pgpass: 1.0.5 + uuid: 11.1.0 + vis-util: 5.0.7(@egjs/hammerjs@2.0.17)(component-emitter@1.3.1) + + vis-timeline@7.7.4(@egjs/hammerjs@2.0.17)(component-emitter@1.3.1)(keycharm@0.4.0)(moment@2.30.1)(propagating-hammerjs@2.0.1(@egjs/hammerjs@2.0.17))(uuid@11.1.0)(vis-data@7.1.10(uuid@11.1.0)(vis-util@5.0.7(@egjs/hammerjs@2.0.17)(component-emitter@1.3.1)))(vis-util@5.0.7(@egjs/hammerjs@2.0.17)(component-emitter@1.3.1))(xss@1.0.15): + dependencies: + '@egjs/hammerjs': 2.0.17 + component-emitter: 1.3.1 + keycharm: 0.4.0 + moment: 2.30.1 + propagating-hammerjs: 2.0.1(@egjs/hammerjs@2.0.17) + uuid: 11.1.0 + vis-data: 7.1.10(uuid@11.1.0)(vis-util@5.0.7(@egjs/hammerjs@2.0.17)(component-emitter@1.3.1)) + vis-util: 5.0.7(@egjs/hammerjs@2.0.17)(component-emitter@1.3.1) + xss: 1.0.15 + + vis-util@5.0.7(@egjs/hammerjs@2.0.17)(component-emitter@1.3.1): + dependencies: + '@egjs/hammerjs': 2.0.17 + component-emitter: 1.3.1 + + vite-node@2.1.9(@types/node@24.10.1)(lightningcss@1.30.2): + dependencies: + cac: 6.7.14 + debug: 4.4.3(supports-color@5.5.0) + es-module-lexer: 1.7.0 + pathe: 1.1.2 + vite: 5.4.21(@types/node@24.10.1)(lightningcss@1.30.2) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vite@5.4.21(@types/node@24.10.1)(lightningcss@1.30.2): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.9 + rollup: 4.60.1 optionalDependencies: - pg-cloudflare: 1.3.0 + '@types/node': 24.10.1 + fsevents: 2.3.3 + lightningcss: 1.30.2 - pgpass@1.0.5: + vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0): dependencies: - split2: 4.2.0 + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.9 + rollup: 4.60.1 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 24.10.1 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.30.2 + tsx: 4.21.0 - postgres-array@2.0.0: {} + vitest@2.1.9(@types/node@24.10.1)(@vitest/ui@2.1.9)(jsdom@25.0.1)(lightningcss@1.30.2): + dependencies: + '@vitest/expect': 2.1.9 + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@24.10.1)(lightningcss@1.30.2)) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.9 + '@vitest/snapshot': 2.1.9 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + debug: 4.4.3(supports-color@5.5.0) + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 1.1.2 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 1.2.0 + vite: 5.4.21(@types/node@24.10.1)(lightningcss@1.30.2) + vite-node: 2.1.9(@types/node@24.10.1)(lightningcss@1.30.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.10.1 + '@vitest/ui': 2.1.9(vitest@2.1.9) + jsdom: 25.0.1 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 - postgres-bytea@1.0.1: {} + webidl-conversions@7.0.0: {} - postgres-date@1.0.7: {} + webpack-virtual-modules@0.6.2: {} - postgres-interval@1.2.0: + whatwg-encoding@3.1.1: dependencies: - xtend: 4.0.2 + iconv-lite: 0.6.3 - prettier@3.8.1: {} + whatwg-mimetype@4.0.0: {} - pure-rand@8.2.0: {} + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 - resolve-pkg-maps@1.0.0: {} + which@2.0.2: + dependencies: + isexe: 2.0.0 - sanctuary-show@2.0.0: {} + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 - sanctuary-type-identifiers@3.0.0: {} + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 - sorted-btree@1.8.1: {} + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 - split2@4.2.0: {} + wrappy@1.0.2: {} - strip-bom@3.0.0: {} + ws@8.20.0: {} - ts-pattern@5.9.0: {} + xml-name-validator@5.0.0: {} - tsconfig-paths@4.2.0: - dependencies: - json5: 2.2.3 - minimist: 1.2.8 - strip-bom: 3.0.0 + xmlchars@2.2.0: {} - tsx@4.21.0: + xss@1.0.15: dependencies: - esbuild: 0.27.4 - get-tsconfig: 4.13.6 - optionalDependencies: - fsevents: 2.3.3 + commander: 2.20.3 + cssfilter: 0.0.10 - typescript@5.9.3: {} + xtend@4.0.2: {} - undici-types@7.16.0: {} + y18n@5.0.8: {} - xtend@4.0.2: {} + yallist@3.1.1: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + zod@3.25.76: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 4d84d61..2103da2 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,9 @@ packages: - core - tasks + - task-explorer + - task-explorer/backend + - task-explorer/frontend onlyBuiltDependencies: - esbuild diff --git a/task-explorer/.gitignore b/task-explorer/.gitignore new file mode 100644 index 0000000..e5e3180 --- /dev/null +++ b/task-explorer/.gitignore @@ -0,0 +1,5 @@ +node_modules +dist +*.local + +.DS_Store diff --git a/task-explorer/.npmrc b/task-explorer/.npmrc new file mode 100644 index 0000000..54cd01d --- /dev/null +++ b/task-explorer/.npmrc @@ -0,0 +1,2 @@ +@ambarltd:registry=https://npm.pkg.github.com +//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN} diff --git a/task-explorer/.prettierignore b/task-explorer/.prettierignore new file mode 100644 index 0000000..9718ffe --- /dev/null +++ b/task-explorer/.prettierignore @@ -0,0 +1,7 @@ +node_modules +dist +pnpm-lock.yaml +*.log + +# TanStack Router auto-generated file (formatted by plugin, not prettier) +frontend/src/routeTree.gen.ts diff --git a/task-explorer/Dockerfile b/task-explorer/Dockerfile new file mode 100644 index 0000000..6a4fad2 --- /dev/null +++ b/task-explorer/Dockerfile @@ -0,0 +1,63 @@ +FROM node:24.0.0-alpine AS frontend-builder +WORKDIR /app/frontend + +RUN apk add --no-cache git +RUN npm install --global corepack@latest +RUN corepack enable pnpm +RUN corepack use pnpm@latest-10 + +ARG GITHUB_TOKEN +RUN : "${GITHUB_TOKEN:?Required build argument GITHUB_TOKEN not set}" + +COPY ./frontend/package.json ./package.json +COPY ./.npmrc ./.npmrc +RUN --mount=type=cache,id=pnpm-store-arm64,target=/root/.local/share/pnpm/store pnpm install + +COPY ./frontend/src ./src +COPY ./frontend/public ./public +COPY ./frontend/*.json ./frontend/*.ts ./frontend/*.js ./frontend/*.html ./ + +ARG VITE_BASE_PATH=/ +ENV VITE_BASE_PATH=${VITE_BASE_PATH} +RUN pnpm run build + +FROM node:24.0.0-alpine AS backend-builder +WORKDIR /app/backend + +RUN apk add --no-cache git +RUN npm install --global corepack@latest +RUN corepack enable pnpm +RUN corepack use pnpm@latest-10 + +ARG GITHUB_TOKEN +RUN : "${GITHUB_TOKEN:?Required build argument GITHUB_TOKEN not set}" + +COPY ./backend/package.json ./package.json +COPY ./.npmrc ./.npmrc +RUN --mount=type=cache,id=pnpm-store-arm64,target=/root/.local/share/pnpm/store pnpm install + +COPY ./backend/src ./src +COPY ./backend/tests ./tests +COPY ./backend/*.json ./ + +FROM node:24.0.0-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production +ENV FRONTEND_DIST=/app/frontend/dist + +RUN apk add --no-cache wget +RUN npm install --global corepack@latest +RUN corepack enable pnpm +RUN corepack use pnpm@latest-10 + +COPY ./package.json ./package.json + +COPY --from=frontend-builder /app/frontend/dist ./frontend/dist +COPY --from=backend-builder /app/backend/package.json ./backend/package.json +COPY --from=backend-builder /app/backend/src ./backend/src +COPY --from=backend-builder /app/backend/node_modules ./backend/node_modules +COPY --from=backend-builder /app/backend/tsconfig.json ./backend/tsconfig.json + +EXPOSE 3000 + +CMD ["pnpm", "run", "start"] diff --git a/task-explorer/README.md b/task-explorer/README.md new file mode 100644 index 0000000..ce885e1 --- /dev/null +++ b/task-explorer/README.md @@ -0,0 +1,173 @@ +# Task Explorer + +A debugging tool for viewing and managing tasks and workflows created by the `@ambarltd/tasks` library. Provides a web UI with an Express backend and React frontend. + +## Features + +- **Task List View**: Browse actions and workflows with filtering, search, and pagination +- **Time Range Filtering**: Query by `run_at` timestamp with preset buttons (last hour, last 24h, last week, etc.) +- **Search**: Wildcard search by task name or exact UUID match +- **Task Details**: Full task info including input/output, events, subtasks, and Gantt chart +- **Statistics Widget**: Counts by type and status +- **Cancel Tasks**: Cancel running or pending tasks via the UI +- **Authentication**: Session-based login protecting all routes +- **URL-Based State**: Filters persist in URL for sharing + +## Prerequisites + +- Node.js 24+ +- pnpm 10+ +- Docker (for integration tests and running the full stack) + +## Install + +```bash +pnpm install +``` + +## Development + +```bash +# Edit backend/.env with your local database credentials (committed with placeholder values) + +# Run frontend (Vite :5173) and backend (Express :3000) concurrently +pnpm dev + +# Or individually +pnpm dev:frontend # Vite dev server — proxies /api to :3000 +pnpm dev:api # Express API server +``` + +## Build + +```bash +./utils.sh build +``` + +Compiles backend TypeScript (via `tsc` + `tsc-alias`) and bundles the frontend (via Vite). Fails on type errors. + +## Test + +### Unit tests (no database required) + +```bash +./utils.sh backend test # 10 backend unit tests +./utils.sh frontend test # 64 frontend unit tests +``` + +Unit tests cover decoders, API schemas, environment validation, and stats logic. They use dummy credentials from `backend/.env.test` and never connect to a database. + +### Integration tests (requires Docker) + +```bash +./utils.sh backend test:integration +``` + +Spins up a PostgreSQL container via Docker Compose, runs the full API test suite against a real database (auth, tasks endpoints), then tears it down. Postgres is exposed on port `5434` to avoid conflicts with other local services. + +To filter by test name: + +```bash +./utils.sh backend test:integration --match "auth" +``` + +### Typecheck only (faster than build) + +```bash +./utils.sh backend typecheck +./utils.sh frontend typecheck +``` + +## Docker + +### Build the image locally + +```bash +./utils.sh docker:build +``` + +Requires `GITHUB_TOKEN` in the environment with read access to GitHub Packages (to install `@ambarltd/core` and `@ambarltd/tasks`). + +### Run the image + +```bash +docker run -p 8085:3000 \ + -e TASKS_DB_HOST=host.docker.internal \ + -e TASKS_DB_PORT=5432 \ + -e TASKS_DB_USER=my_es_username \ + -e TASKS_DB_PASSWORD=my_es_password \ + -e TASKS_DB_NAME=my_es_database \ + -e TASKS_DB_NAMESPACE=event_store \ + -e AUTH_USERNAME=admin \ + -e AUTH_PASSWORD=changeme123 \ + -e SESSION_SECRET=your-secret-key \ + ghcr.io/ambarltd/task-explorer:latest +``` + +Then open http://localhost:8085. + +### Published image + +The CI pipeline publishes two tags on every merge to `main`: + +- `ghcr.io/ambarltd/task-explorer:latest` — always points to the latest build +- `ghcr.io/ambarltd/task-explorer:` — pinned to the version in `package.json` (e.g., `0.1.0`) + +Pull either tag directly in docker-compose without a local build. Use a pinned version in production to avoid unexpected updates. + +Authenticate with the GitHub Container Registry once before pulling: + +```bash +docker login ghcr.io +``` + +```yaml +services: + task-explorer: + image: ghcr.io/ambarltd/task-explorer:latest + environment: + AUTH_USERNAME: admin + AUTH_PASSWORD: changeme123 + SESSION_SECRET: your-super-secret-session-key + TASKS_DB_HOST: postgres + TASKS_DB_PORT: 5432 + TASKS_DB_USER: my_es_username + TASKS_DB_PASSWORD: my_es_password + TASKS_DB_NAME: my_es_database + TASKS_DB_NAMESPACE: event_store + ports: + - "8085:3000" +``` + +## Seeding Test Data + +Populate the database with sample tasks: + +```bash +cd backend +SEED_CLEAR=true SEED_ACTIONS=500 SEED_WORKFLOWS=50 pnpm run seed +``` + +| Variable | Description | +| ----------------- | ---------------------------------------------------- | +| `SEED_CLEAR` | Clear existing tasks before seeding (`true`/`false`) | +| `SEED_ACTIONS` | Number of action tasks to create | +| `SEED_WORKFLOWS` | Number of workflow tasks to create | +| `SEED_CLEAN_ONLY` | Clear without populating (`true`/`false`) | + +## Environment Variables + +| Variable | Required | Description | +| -------------------- | -------- | -------------------------------------------------------------------------------- | +| `TASKS_DB_HOST` | Yes | PostgreSQL host | +| `TASKS_DB_PORT` | Yes | PostgreSQL port | +| `TASKS_DB_USER` | Yes | PostgreSQL user | +| `TASKS_DB_PASSWORD` | Yes | PostgreSQL password | +| `TASKS_DB_NAME` | Yes | PostgreSQL database name | +| `TASKS_DB_NAMESPACE` | Yes | Tasks table prefix (e.g., `event_store`) | +| `AUTH_USERNAME` | Yes | Login username | +| `AUTH_PASSWORD` | Yes | Login password | +| `SESSION_SECRET` | Yes | Cookie session secret (use a long random string in production) | +| `PORT` | No | Server port (default: `3000`) | +| `FRONTEND_DIST` | No | Path to frontend build (default: `../../frontend/dist`; baked into Docker image) | +| `BASE_PATH` | No | URL base path for non-root deployments (e.g., `/task-explorer`) | diff --git a/task-explorer/backend/.dockerignore b/task-explorer/backend/.dockerignore new file mode 100644 index 0000000..85075a6 --- /dev/null +++ b/task-explorer/backend/.dockerignore @@ -0,0 +1,5 @@ +node_modules +dist +*.log +.env +.DS_Store diff --git a/task-explorer/backend/.env b/task-explorer/backend/.env new file mode 100644 index 0000000..0c4daba --- /dev/null +++ b/task-explorer/backend/.env @@ -0,0 +1,20 @@ +# Task Explorer - Local Development Environment +# All values are placeholders. Override with real credentials for your local DB. +# Production credentials are injected via Docker environment variables — never hardcode them here. + +# PostgreSQL Database (Tasks) +TASKS_DB_HOST=localhost +TASKS_DB_PORT=5432 +TASKS_DB_USER=my_es_username +TASKS_DB_PASSWORD=my_es_password +TASKS_DB_NAME=my_es_database +TASKS_DB_NAMESPACE=event_store + +# Server +PORT=3000 +FRONTEND_DIST=../frontend/dist + +# Authentication +AUTH_USERNAME=admin +AUTH_PASSWORD=changeme123 +SESSION_SECRET=your-super-secret-session-key-change-this-in-production diff --git a/task-explorer/backend/.env.test b/task-explorer/backend/.env.test new file mode 100644 index 0000000..0d69fd4 --- /dev/null +++ b/task-explorer/backend/.env.test @@ -0,0 +1,14 @@ +# Task Explorer - Unit Test Environment +# Dummy credentials used by unit tests (tsx --env-file=.env.test). +# Unit tests never connect to a database — these values just satisfy environment validation. + +AUTH_USERNAME=test-admin +AUTH_PASSWORD=test-password +SESSION_SECRET=test-session-secret-key + +TASKS_DB_HOST=localhost +TASKS_DB_PORT=5432 +TASKS_DB_USER=test_user +TASKS_DB_PASSWORD=test_password +TASKS_DB_NAME=test_database +TASKS_DB_NAMESPACE=test_namespace diff --git a/task-explorer/backend/package.json b/task-explorer/backend/package.json new file mode 100644 index 0000000..7ac5b87 --- /dev/null +++ b/task-explorer/backend/package.json @@ -0,0 +1,43 @@ +{ + "name": "task-explorer-backend", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "nodemon --watch src --ext ts --exec \"tsx --env-file=.env src/index.ts\"", + "build": "tsc -p tsconfig.json && tsc-alias -p tsconfig.json", + "start": "NODE_ENV=production tsx src/index.ts", + "test": "npm run test:unit", + "test:unit": "tsx --env-file=.env.test tests/unit/main.ts", + "test:unit:watch": "nodemon --watch src --watch tests --ext ts --exec \"tsx --env-file=.env.test tests/unit/main.ts\"", + "test:integration": "tsx tests/integration/main.ts", + "test:integration:watch": "nodemon --watch src --watch tests --ext ts --exec \"tsx tests/integration/main.ts\"", + "seed": "tsx --env-file=.env src/scripts/seed.ts" + }, + "dependencies": { + "@ambarltd/core": "^0.1.12", + "@ambarltd/tasks": "^0.1.0", + "@optique/core": "0.6.3", + "@optique/run": "0.6.3", + "cookie-session": "2.1.1", + "express": "4.21.2", + "luxon": "3.7.2", + "pg": "8.16.3", + "ts-pattern": "5.9.0", + "tsc-alias": "1.8.16", + "tsconfig-paths": "4.2.0", + "tsx": "4.20.6" + }, + "devDependencies": { + "@types/cookie-session": "2.0.49", + "@types/express": "4.17.25", + "@types/luxon": "3.7.1", + "@types/node": "24.10.1", + "@types/pg": "8.15.6", + "@types/supertest": "6.0.3", + "dotenv": "16.6.1", + "nodemon": "3.1.11", + "supertest": "7.2.2", + "typescript": "5.9.3" + } +} diff --git a/task-explorer/backend/src/index.ts b/task-explorer/backend/src/index.ts new file mode 100644 index 0000000..3098c84 --- /dev/null +++ b/task-explorer/backend/src/index.ts @@ -0,0 +1,603 @@ +/** + * Task Explorer Backend + * + * Express server that provides API endpoints for viewing and managing background tasks. + * Serves both the API and the frontend static files. + */ + +import express, { type Express, type Request, type Response, type NextFunction, Router } from "express"; +import cookieSession from "cookie-session"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { DateTime } from "luxon"; +import { initializeDatabase } from "@be/lib/database"; +import { POSIX } from "@ambarltd/core/time"; +import { TaskActionId, TaskWorkflowId } from "@ambarltd/tasks/store"; +import * as d from "@ambarltd/core/json/decoder"; +import { listTasksQueryDecoder, searchTasksQueryDecoder, timelineQueryDecoder } from "@be/lib/decoders"; +import { + encodeTaskAction, + encodeTaskWorkflow, + encodeTaskActionSummary, + encodeTaskWorkflowSummary, + encodeTaskEvent, +} from "@be/lib/api-schemas"; +import environment from "@be/lib/environment"; + +const EXCLUDE_PAYLOAD_COLUMNS = ["input", "output"]; + +/** Parse an optional string query param to a number, returning undefined if missing or invalid. */ +const parseIntParam = (value: unknown): number | undefined => { + if (typeof value !== "string") return undefined; + const n = Number(value); + return Number.isInteger(n) && n >= 0 ? n : undefined; +}; + +// ============================================================================= +// Configuration & Constants +// ============================================================================= + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +// Use process.env directly for optional config (not in environment decoder) +const distDir = process.env["FRONTEND_DIST"] ?? path.resolve(__dirname, "../../frontend/dist"); +const PORT = Number(process.env["PORT"] ?? 3000); + +// Normalize BASE_PATH: remove trailing slashes, default to empty string for root +const BASE_PATH = process.env["BASE_PATH"]?.replace(/\/+$/, "") ?? ""; + +// Constants for pagination and validation +const DEFAULT_PAGE_SIZE = 250; +const MAX_PAGE_SIZE = 1000; + +// Task status type +type TaskStatus = "pending" | "blocked" | "running" | "completed" | "failed" | "cancelled"; +const VALID_STATUSES: Set = new Set(["pending", "blocked", "running", "completed", "failed", "cancelled"]); + +// ============================================================================= +// Helper Functions +// ============================================================================= + +function decode(decoder: d.Decoder, raw: unknown): T { + return d + .decode(raw, decoder) + .unwrap(failure => `Decoding error: ${failure}\n When decoding:\n${JSON.stringify(raw)}`); +} + +// ============================================================================= +// Express App Setup +// ============================================================================= + +const app: Express = express(); +app.use(express.json()); +app.use( + cookieSession({ + name: "session", + secret: environment.SESSION_SECRET, + httpOnly: true, + secure: false, // Allow HTTP for internal Task Explorer (typically accessed via localhost or internal network) + sameSite: "lax", + maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days + }), +); + +// ============================================================================= +// Database Initialization +// ============================================================================= + +type DB = Awaited>; +let store: DB["store"]; +let executor: DB["executor"]; +let explorer: DB["explorer"]; +let postgres: DB["postgres"]; +let initialized = false; +let startupPromise: Promise | null = null; + +async function startup(): Promise { + if (!startupPromise) { + startupPromise = (async () => { + console.log("Initializing database connections..."); + const db = await initializeDatabase(); + store = db.store; + executor = db.executor; + explorer = db.explorer; + postgres = db.postgres; + initialized = true; + console.log("Database initialized successfully"); + })().catch(error => { + startupPromise = null; // Reset so the next call retries from scratch + throw error; + }); + } + return startupPromise; +} + +// ============================================================================= +// Middleware +// ============================================================================= + +app.use((req, _res, next) => { + // Check if this is a health or info endpoint (works regardless of BASE_PATH) + if (!initialized && !req.path.endsWith("/api/health") && !req.path.endsWith("/api/info")) { + _res.status(503).json({ error: "Server initializing, please try again shortly" }); + return; + } + next(); +}); + +// Auth middleware - protect routes that require authentication +function requireAuth(req: Request, res: Response, next: NextFunction): void { + if (req.session?.["userId"]) { + next(); + } else { + res.status(401).json({ error: "Unauthorized" }); + } +} + +// ============================================================================= +// API Router - all routes defined with simple paths, mounted at BASE_PATH/api +// ============================================================================= +const apiRouter = Router(); + +apiRouter.get("/health", async (_req: Request, res: Response) => { + if (!initialized) { + res.status(503).json({ status: "error", database: "disconnected", timestamp: new Date().toISOString() }); + return; + } + try { + await postgres.withConnectionP(c => c.query("SELECT 1")); + res.json({ + status: "ok", + database: "connected", + timestamp: new Date().toISOString(), + }); + } catch (error) { + res.status(503).json({ + status: "error", + database: "disconnected", + error: (error as Error).message, + }); + } +}); + +apiRouter.get("/info", (_req: Request, res: Response) => { + res.json({ + name: "task-explorer", + version: "1.0", + timestamp: new Date().toISOString(), + }); +}); + +// ============================================================================= +// Auth Routes +// ============================================================================= + +apiRouter.post("/auth/login", (req: Request, res: Response): void => { + const { username, password } = req.body; + + if (username === environment.AUTH_USERNAME && password === environment.AUTH_PASSWORD) { + req.session!["userId"] = username; + res.json({ success: true }); + } else { + res.status(401).json({ error: "Invalid credentials" }); + } +}); + +apiRouter.post("/auth/logout", (req: Request, res: Response): void => { + req.session = null; + res.json({ success: true }); +}); + +apiRouter.get("/auth/status", (req: Request, res: Response): void => { + res.json({ authenticated: !!req.session?.["userId"] }); +}); + +// ============================================================================= +// Protected Task Routes +// ============================================================================= + +// GET /api/tasks - List tasks with filters +// Note: Filters by run_at time range (when tasks are scheduled to execute), not created_at +apiRouter.get("/tasks", requireAuth, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const query = decode(listTasksQueryDecoder, req.query); + + const status = + typeof query.status === "string" ? + query.status.split(",").filter((s): s is TaskStatus => VALID_STATUSES.has(s)) + : undefined; + const types = + typeof query.types === "string" ? + query.types.split(",").filter((t): t is "action" | "workflow" => t === "action" || t === "workflow") + : undefined; + + const startTime = query.startTime ? POSIX.fromDate(DateTime.fromISO(query.startTime).toJSDate()) : undefined; + + const endTime = query.endTime ? POSIX.fromDate(DateTime.fromISO(query.endTime).toJSDate()) : undefined; + + const limit = Math.min(Math.max(1, parseIntParam(query.limit) ?? DEFAULT_PAGE_SIZE), MAX_PAGE_SIZE); + const offset = Math.max(0, parseIntParam(query.offset) ?? 0); + + // Extract search query (if provided) + const search = typeof query.search === "string" ? query.search.trim() : undefined; + + // Use listTasksInTimeRange, which filters by run_at. This ensures we can see tasks scheduled in the future + if (query.startTime && query.endTime) { + const result = await explorer.listTasksInTimeRange(query.startTime, query.endTime, { + limit, + offset, + excludeColumns: EXCLUDE_PAYLOAD_COLUMNS, + ...(status ? { status } : {}), + ...(search ? { search } : {}), + ...(types ? { types } : {}), + }); + + res.json({ + actions: result.actions.map(encodeTaskActionSummary), + workflows: result.workflows.map(encodeTaskWorkflowSummary), + total: result.total, + stats: result.stats, + pageOrder: result.pageOrder, + limit, + offset, + }); + } else { + // Fallback to listTasks for backward compatibility when no time range + const params: Parameters[0] = { + limit, + offset, + excludeColumns: EXCLUDE_PAYLOAD_COLUMNS, + }; + if (status !== undefined) params.status = status; + if (startTime !== undefined) params.startTime = startTime; + if (endTime !== undefined) params.endTime = endTime; + + const result = await explorer.listTasks(params); + + res.json({ + actions: result.actions.map(encodeTaskActionSummary), + workflows: result.workflows.map(encodeTaskWorkflowSummary), + total: result.actions.length + result.workflows.length, + limit, + offset, + }); + } + } catch (error) { + next(error); + } +}); + +// GET /api/tasks/search - Search tasks +apiRouter.get("/tasks/search", requireAuth, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.query["q"]) { + res.status(400).json({ error: "Query parameter 'q' is required" }); + return; + } + const query = decode(searchTasksQueryDecoder, req.query); + + const limit = Math.min(Math.max(1, parseIntParam(query.limit) ?? DEFAULT_PAGE_SIZE), MAX_PAGE_SIZE); + const offset = Math.max(0, parseIntParam(query.offset) ?? 0); + + // Try to parse as UUID for an exact match + const isUUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(query.q); + + // Build search params conditionally to avoid passing undefined with exactOptionalPropertyTypes + const searchParams: Parameters[0] = { + limit, + offset, + excludeColumns: EXCLUDE_PAYLOAD_COLUMNS, + }; + if (isUUID) { + searchParams.taskId = query.q; + } else { + searchParams.actionName = query.q; + searchParams.workflowName = query.q; + } + + const result = await explorer.searchTasks(searchParams); + + res.json({ + actions: result.actions.map(encodeTaskActionSummary), + workflows: result.workflows.map(encodeTaskWorkflowSummary), + }); + } catch (error) { + next(error); + } +}); + +// GET /api/tasks/:type/:id - Get task details +apiRouter.get( + "/tasks/:type/:id", + requireAuth, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const { type, id } = req.params; + + if (!id) { + res.status(400).json({ error: "Task ID is required" }); + return; + } + + if (type !== "action" && type !== "workflow") { + res.status(400).json({ error: 'Type must be "action" or "workflow"' }); + return; + } + + let task; + let events; + let subTasks: Awaited> = { actions: [], workflows: [] }; + const subTaskEvents: Record = {}; + + if (type === "action") { + task = await store.getTaskAction(new TaskActionId(id)); + if (task) { + events = await explorer.taskActionEvents(new TaskActionId(id)); + } + } else { + task = await store.getTaskWorkflow(new TaskWorkflowId(id)); + if (task) { + events = await explorer.taskWorkflowEvents(new TaskWorkflowId(id)); + subTasks = await explorer.listSubtasks(new TaskWorkflowId(id), { + limit: 1000, + includeAllStates: true, + excludeColumns: EXCLUDE_PAYLOAD_COLUMNS, + }); + + for (const action of subTasks.actions) { + const actionEvents = await explorer.taskActionEvents(action.id); + subTaskEvents[action.id.value] = actionEvents || []; + } + for (const workflow of subTasks.workflows) { + const workflowEvents = await explorer.taskWorkflowEvents(workflow.id); + subTaskEvents[workflow.id.value] = workflowEvents || []; + } + } + } + + if (!task) { + res.status(404).json({ error: "Task not found" }); + return; + } + + const encodedTask = type === "action" ? encodeTaskAction(task as any) : encodeTaskWorkflow(task as any); + + res.json({ + task: encodedTask, + events: events ? events.map(encodeTaskEvent) : [], + subTasks: { + actions: subTasks.actions.map(encodeTaskActionSummary), + workflows: subTasks.workflows.map(encodeTaskWorkflowSummary), + }, + subTaskEvents: Object.fromEntries( + Object.entries(subTaskEvents).map(([taskId, taskEvents]) => [taskId, taskEvents.map(encodeTaskEvent)]), + ), + }); + } catch (error) { + next(error); + } + }, +); + +// POST /api/tasks/:type/:id/cancel - Cancel task +apiRouter.post( + "/tasks/:type/:id/cancel", + requireAuth, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const { type, id } = req.params; + + if (!id) { + res.status(400).json({ error: "Task ID is required" }); + return; + } + + if (type !== "action" && type !== "workflow") { + res.status(400).json({ error: 'Type must be "action" or "workflow"' }); + return; + } + + // Use cancelTaskAction/cancelTaskWorkflow which cancel by ID regardless of current worker. + // abortTaskAction/abortTaskWorkflow use zombie fencing (worker = $2) — they're intended for + // worker self-cleanup and would always fail here since no running task is owned by the + // task-explorer's worker ID. + let result; + + if (type === "action") { + const task = await store.getTaskAction(new TaskActionId(id)); + if (!task) { + res.status(404).json({ error: "Task not found" }); + return; + } + result = await executor.cancelTaskAction(task); + } else { + const task = await store.getTaskWorkflow(new TaskWorkflowId(id)); + if (!task) { + res.status(404).json({ error: "Task not found" }); + return; + } + result = await executor.cancelTaskWorkflow(task); + } + + if (!result) { + res.status(400).json({ + error: "Task could not be cancelled (may already be in terminal state)", + }); + return; + } + + res.json({ success: true }); + } catch (error) { + next(error); + } + }, +); + +// GET /api/tasks/timeline - Get tasks in time range for timeline visualization +apiRouter.get( + "/tasks/timeline", + requireAuth, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const query = decode(timelineQueryDecoder, req.query); + + const limit = Math.min(Math.max(1, parseIntParam(query.limit) ?? DEFAULT_PAGE_SIZE), MAX_PAGE_SIZE); + const offset = Math.max(0, parseIntParam(query.offset) ?? 0); + + // Validate ISO timestamps + const startTime = query.startTime; + const endTime = query.endTime; + + if (!DateTime.fromISO(startTime).isValid) { + res.status(400).json({ error: "Invalid startTime format. Use ISO 8601 format." }); + return; + } + + if (!DateTime.fromISO(endTime).isValid) { + res.status(400).json({ error: "Invalid endTime format. Use ISO 8601 format." }); + return; + } + + const result = await explorer.listTasksInTimeRange(startTime, endTime, { + limit, + offset, + excludeColumns: EXCLUDE_PAYLOAD_COLUMNS, + }); + + // Default duration for pending tasks (5 minutes in milliseconds) + const DEFAULT_PENDING_DURATION_MS = 5 * 60 * 1000; + + // Transform tasks for timeline visualization + const transformTask = (task: any, type: "action" | "workflow") => { + const start = + task.run_at ? new Date(task.run_at.value).toISOString() : new Date(task.created_at.value).toISOString(); + let end: string; + let duration: number; + + if (task.finished_at) { + // Completed/failed/cancelled task - use actual finish time + end = new Date(task.finished_at.value).toISOString(); + duration = new Date(end).getTime() - new Date(start).getTime(); + } else if (task.state === "running") { + // Running task - use current time as end time + end = new Date().toISOString(); + duration = new Date(end).getTime() - new Date(start).getTime(); + } else { + // Pending task - estimate duration + end = new Date(new Date(start).getTime() + DEFAULT_PENDING_DURATION_MS).toISOString(); + duration = DEFAULT_PENDING_DURATION_MS; + } + + return { + id: task.id.value, + type, + name: type === "action" ? task.action.value : task.workflow.value, + state: task.state, + start, + end, + duration, + parent: task.parent ? task.parent.value : null, + parent_step: task.parent_step, + group: task.parent ? task.parent.value : "root", + }; + }; + + const tasks = [ + ...result.actions.map(task => transformTask(task, "action")), + ...result.workflows.map(task => transformTask(task, "workflow")), + ]; + + // Sort by start time + tasks.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime()); + + res.json({ + tasks, + defaultPendingDuration: DEFAULT_PENDING_DURATION_MS, + }); + } catch (error) { + next(error); + } + }, +); + +// ============================================================================= +// Static Files & SPA Fallback +// ============================================================================= + +const apiMountPath = BASE_PATH + "/api"; +app.use(apiMountPath, apiRouter); + +// Serve frontend static files at the base path +const staticMountPath = BASE_PATH || "/"; +app.use(staticMountPath, express.static(distDir)); + +// SPA fallback - serve index.html for all non-API routes under the base path +const spaFallbackPath = BASE_PATH + "/*"; +app.get(spaFallbackPath, (_req: Request, res: Response) => { + res.sendFile(path.join(distDir, "index.html")); +}); + +// ============================================================================= +// Error Handling +// ============================================================================= + +app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => { + console.error("API Error:", err); + res.status(500).json({ + error: "Internal server error", + message: err.message, + }); +}); + +// ============================================================================= +// Server Startup & Graceful Shutdown +// ============================================================================= + +// Export app and startup for testing +export { app, startup }; + +// Only start the server if not in test mode +if (process.env["NODE_ENV"] !== "test") { + // Listen immediately — the 503 middleware handles requests until the DB is ready + app.listen(PORT, () => { + console.log(`Task Explorer backend listening on http://localhost:${PORT}`); + console.log(` API mounted at: ${apiMountPath}`); + console.log(` Static files at: ${staticMountPath}`); + }); + + // Initialize database in background. + // Production: crash on first failure (Docker will restart the container). + // Development: retry every 5 seconds so the server stays up while the DB starts. + (async () => { + while (!initialized) { + try { + await startup(); + } catch (error) { + if (process.env["NODE_ENV"] === "production") { + console.error("Failed to initialize database:", error); + process.exit(1); + } + console.warn(`Database unavailable (${(error as Error).message}), retrying in 5s...`); + await new Promise(resolve => setTimeout(resolve, 5000)); + } + } + })(); + + // Graceful shutdown handler + async function shutdown(signal: string) { + console.log(`${signal === "SIGINT" ? "\n" : ""}Shutting down gracefully...`); + try { + if (initialized) { + await postgres.disconnect(); + console.log("Database connections closed"); + } + process.exit(0); + } catch (error) { + console.error("Error during shutdown:", error); + process.exit(1); + } + } + + process.on("SIGTERM", () => shutdown("SIGTERM")); + process.on("SIGINT", () => shutdown("SIGINT")); +} diff --git a/task-explorer/backend/src/lib/api-schemas.ts b/task-explorer/backend/src/lib/api-schemas.ts new file mode 100644 index 0000000..f302298 --- /dev/null +++ b/task-explorer/backend/src/lib/api-schemas.ts @@ -0,0 +1,134 @@ +/** + * API Response Encoding Utilities + * + * Simple encoding functions to convert database types (branded IDs, POSIX timestamps) + * into plain JSON-serializable types for API responses. + */ + +export { encodeTaskAction, encodeTaskWorkflow, encodeTaskActionSummary, encodeTaskWorkflowSummary, encodeTaskEvent }; + +import type { POSIX } from "@ambarltd/core/time"; +import type { TaskAction, TaskWorkflow, TaskActionEvent, TaskWorkflowEvent } from "@ambarltd/tasks/store"; +import type { TaskActionSummary, TaskWorkflowSummary } from "@ambarltd/tasks/explorer"; + +/** + * Convert POSIX timestamp to ISO 8601 string + */ +function encodePOSIX(posix: POSIX): string { + return new Date(posix.value).toISOString(); +} + +/** + * Convert nullable POSIX timestamp to ISO 8601 string or null + */ +function encodeNullablePOSIX(posix: POSIX | null): string | null { + return posix ? encodePOSIX(posix) : null; +} + +/** + * Encode TaskAction for API response + */ +function encodeTaskAction(task: TaskAction): any { + return { + id: task.id.value, + action: task.action.value, + created_at: encodePOSIX(task.created_at), + input: task.input, + run_at: encodePOSIX(task.run_at), + timeout_seconds: task.timeout_seconds, + max_retries: task.max_retries, + parent: task.parent ? task.parent.value : null, + parent_step: task.parent_step, + attempts: task.attempts, + state: task.state, + worker: task.worker ? task.worker.value : null, + output: task.output, + error: task.error, + started_at: null, // TaskAction doesn't have started_at in the schema + finished_at: encodeNullablePOSIX(task.finished_at), + }; +} + +/** + * Encode TaskWorkflow for API response + */ +function encodeTaskWorkflow(task: TaskWorkflow): any { + return { + id: task.id.value, + workflow: task.workflow.value, + created_at: encodePOSIX(task.created_at), + input: task.input, + run_at: encodePOSIX(task.run_at), + timeout_seconds: task.timeout_seconds, + parent: task.parent ? task.parent.value : null, + parent_step: task.parent_step, + pending_steps: task.pending_steps, + state: task.state, + worker: task.worker ? task.worker.value : null, + output: task.output, + error: task.error, + started_at: encodeNullablePOSIX(task.started_at), + finished_at: encodeNullablePOSIX(task.finished_at), + }; +} + +/** + * Encode TaskAction for list/search API responses, omitting large input/output fields. + */ +function encodeTaskActionSummary(task: TaskAction | TaskActionSummary): any { + return { + id: task.id.value, + action: task.action.value, + created_at: encodePOSIX(task.created_at), + run_at: encodePOSIX(task.run_at), + timeout_seconds: task.timeout_seconds, + max_retries: task.max_retries, + parent: task.parent ? task.parent.value : null, + parent_step: task.parent_step, + attempts: task.attempts, + state: task.state, + worker: task.worker ? task.worker.value : null, + error: task.error, + started_at: null, + finished_at: encodeNullablePOSIX(task.finished_at), + }; +} + +/** + * Encode TaskWorkflow for list/search API responses, omitting large input/output fields. + */ +function encodeTaskWorkflowSummary(task: TaskWorkflow | TaskWorkflowSummary): any { + return { + id: task.id.value, + workflow: task.workflow.value, + created_at: encodePOSIX(task.created_at), + run_at: encodePOSIX(task.run_at), + timeout_seconds: task.timeout_seconds, + parent: task.parent ? task.parent.value : null, + parent_step: task.parent_step, + pending_steps: task.pending_steps, + state: task.state, + worker: task.worker ? task.worker.value : null, + error: task.error, + started_at: encodeNullablePOSIX(task.started_at), + finished_at: encodeNullablePOSIX(task.finished_at), + }; +} + +/** + * Encode task event (action or workflow) for API response + */ +function encodeTaskEvent(event: TaskActionEvent | TaskWorkflowEvent): any { + // Determine if it's an action or workflow event + const isActionEvent = "action_task" in event; + const taskId = + isActionEvent ? (event as TaskActionEvent).action_task.value : (event as TaskWorkflowEvent).workflow_task.value; + const worker = event.worker ? event.worker.value : null; + + return { + id: `${taskId}-${event.event}-${encodePOSIX(event.time)}`, + event_type: event.event, + created_at: encodePOSIX(event.time), + details: worker ? { worker } : null, + }; +} diff --git a/task-explorer/backend/src/lib/database.ts b/task-explorer/backend/src/lib/database.ts new file mode 100644 index 0000000..153541b --- /dev/null +++ b/task-explorer/backend/src/lib/database.ts @@ -0,0 +1,24 @@ +import { Postgres, defaultPoolSettings } from "@ambarltd/tasks/postgres"; +import { Store, Namespace } from "@ambarltd/tasks/store"; +import { Executor, Environment } from "@ambarltd/tasks/definition"; +import { createExplorer } from "@ambarltd/tasks/explorer"; +import environment from "@be/lib/environment"; + +export async function initializeDatabase() { + const postgres = new Postgres({ + user: environment.TASKS_DB_USER, + password: environment.TASKS_DB_PASSWORD, + host: environment.TASKS_DB_HOST, + port: environment.TASKS_DB_PORT, + database: environment.TASKS_DB_NAME, + poolSettings: defaultPoolSettings, + }); + + const namespace = new Namespace(environment.TASKS_DB_NAMESPACE); + const store = await Store.initialize(postgres, namespace); + const taskEnvironment = new Environment<{}>(); + const executor = new Executor(store, taskEnvironment, {}); + const explorer = createExplorer(postgres, namespace); + + return { postgres, store, executor, explorer }; +} diff --git a/task-explorer/backend/src/lib/decoders.ts b/task-explorer/backend/src/lib/decoders.ts new file mode 100644 index 0000000..d042591 --- /dev/null +++ b/task-explorer/backend/src/lib/decoders.ts @@ -0,0 +1,27 @@ +export { listTasksQueryDecoder, searchTasksQueryDecoder, timelineQueryDecoder }; + +import * as d from "@ambarltd/core/json/decoder"; + +// Query parameter decoders for API endpoints +const listTasksQueryDecoder = d.object({ + status: d.optional(d.string), + types: d.optional(d.string), + startTime: d.optional(d.string), + endTime: d.optional(d.string), + limit: d.optional(d.string), + offset: d.optional(d.string), + search: d.optional(d.string), +}); + +const searchTasksQueryDecoder = d.object({ + q: d.string, + limit: d.optional(d.string), + offset: d.optional(d.string), +}); + +const timelineQueryDecoder = d.object({ + startTime: d.string, + endTime: d.string, + limit: d.optional(d.string), + offset: d.optional(d.string), +}); diff --git a/task-explorer/backend/src/lib/environment.ts b/task-explorer/backend/src/lib/environment.ts new file mode 100644 index 0000000..011ae7b --- /dev/null +++ b/task-explorer/backend/src/lib/environment.ts @@ -0,0 +1,38 @@ +// Environment variables +// All environment variables used by the task-explorer backend are here. +// +// This module ensures all environment variables are: +// +// - Type-checked +// - Decoded at the beginning of the program. +// - Handled consistently +// - Visible in a single place. + +import * as D from "@ambarltd/core/json/decoder"; + +type Environment = D.Infer; + +const string = D.string; +const number = D.stringNumber; + +// All expected environment variables are checked at program initialization. +const envDecoder = D.object({ + // PostgreSQL - Tasks Database + TASKS_DB_USER: string, + TASKS_DB_PASSWORD: string, + TASKS_DB_HOST: string, + TASKS_DB_PORT: number, + TASKS_DB_NAME: string, + TASKS_DB_NAMESPACE: string, + + // Authentication + AUTH_USERNAME: string, + AUTH_PASSWORD: string, + SESSION_SECRET: string, +}); + +const environment: Environment = D.decode(process.env, envDecoder).unwrap(err => { + throw new Error(`Unable to parse environment variables:\n${err}`); +}); + +export default environment; diff --git a/task-explorer/backend/src/scripts/seed.ts b/task-explorer/backend/src/scripts/seed.ts new file mode 100644 index 0000000..de475e0 --- /dev/null +++ b/task-explorer/backend/src/scripts/seed.ts @@ -0,0 +1,702 @@ +// Seed script to generate sample task data for testing +import { initializeDatabase } from "@be/lib/database"; +import { WorkerId, ActionId, WorkflowId } from "@ambarltd/tasks/store"; +import { POSIX } from "@ambarltd/core/time"; + +// Configurable dataset size via environment variables +const ACTION_COUNT = parseInt(process.env["SEED_ACTIONS"] || "30"); +const WORKFLOW_COUNT = parseInt(process.env["SEED_WORKFLOWS"] || "20"); +const CLEAR_DB = process.env["SEED_CLEAR"] === "true"; +const CLEAN_ONLY = process.env["SEED_CLEAN_ONLY"] === "true"; // Clean without populating + +// Constants for task configuration +const DEFAULT_TASK_TIMEOUT_SECONDS = 300; // 5 minutes +const WORKFLOW_TIMEOUT_SECONDS = 172800; // 48 hours + +// Sample task names +const actionNames = [ + "send_email", + "process_payment", + "validate_user", + "fetch_data", + "update_database", + "generate_report", + "compress_image", + "send_notification", +]; + +const workflowNames = [ + "user_onboarding", + "order_fulfillment", + "data_pipeline", + "content_moderation", + "backup_workflow", + "deployment_pipeline", +]; + +// Helper to get random item from array +function randomItem(arr: T[]): T { + return arr[Math.floor(Math.random() * arr.length)]!; +} + +// Helper to get random int between min and max +function randomInt(min: number, max: number): number { + return Math.floor(Math.random() * (max - min + 1)) + min; +} + +// Helper to generate random date in the past N days +function randomPastDate(daysAgo: number): Date { + const now = new Date(); + const pastMs = now.getTime() - daysAgo * 24 * 60 * 60 * 1000; + const randomMs = pastMs + Math.random() * (daysAgo * 24 * 60 * 60 * 1000); + return new Date(randomMs); +} + +// Helper to generate random date with weighted time distribution +function randomPastDateWeighted(): Date { + const rand = Math.random(); + if (rand < 0.4) return randomPastDate(1); // 40% last 24 hours + if (rand < 0.7) return randomPastDate(7); // 30% last 7 days + if (rand < 0.9) return randomPastDate(30); // 20% last 30 days + return randomPastDate(90); // 10% last 90 days +} + +function randomFutureDateFromDate(date: Date, addDays = 0): Date { + const futureDateMs = date.getTime() + addDays * 24 * 60 * 60 * 1000; + const futureDateRandomMs = futureDateMs + Math.random() * (24 * 60 * 60 * 1000); + return new Date(futureDateRandomMs); +} + +// Helper to generate random date with weighted time distribution +function randomFutureDateWeighted(currentDate: Date): Date { + const rand = Math.random(); + if (rand < 0.4) return currentDate; // 40% now + if (rand < 0.7) return randomFutureDateFromDate(currentDate); // 30% within next 24hrs + if (rand < 0.9) return randomFutureDateFromDate(currentDate, 1); // 20% within 1 day from now + return randomFutureDateFromDate(currentDate, Math.floor(Math.random() * 7)); // 10% within random 7 days from now +} + +async function seed() { + console.log("🌱 Starting database seed..."); + + if (CLEAN_ONLY) { + console.log("🗑️ Mode: CLEAN ONLY (no data will be populated)\n"); + } else { + console.log(`📊 Configuration: ${ACTION_COUNT} actions, ${WORKFLOW_COUNT} workflows`); + console.log(`🗑️ Clear database: ${CLEAR_DB ? "yes" : "no"}\n`); + } + + const { store, postgres, explorer } = await initializeDatabase(); + const namespaceName = process.env["TASKS_DB_NAMESPACE"] || "event_store"; + + // Validate namespace to prevent SQL injection + const ALLOWED_NAMESPACES = ["event_store", "tasks", "test_store"]; + if (!ALLOWED_NAMESPACES.includes(namespaceName)) { + throw new Error(`Invalid namespace: ${namespaceName}. Allowed values: ${ALLOWED_NAMESPACES.join(", ")}`); + } + + // Statistics tracking + const stats = { + actions: { + created: 0, + pending: 0, + running: 0, + completed: 0, + failed: 0, + cancelled: 0, + }, + workflows: { + created: 0, + pending: 0, + running: 0, + completed: 0, + failed: 0, + cancelled: 0, + blocked: 0, + }, + subtasks: 0, + retries: 0, + }; + + const statsAc = stats.actions; + const statsWf = stats.workflows; + + try { + // Clear existing data + if (CLEAR_DB || CLEAN_ONLY) { + console.log("\n🗑️ Clearing existing data..."); + await postgres.withConnectionP(async conn => { + await conn.query(`DELETE FROM ${namespaceName}_action_event`); + await conn.query(`DELETE FROM ${namespaceName}_workflow_event`); + await conn.query(`DELETE FROM ${namespaceName}_task`); + await conn.query(`DELETE FROM ${namespaceName}_workflow`); + }); + console.log("✅ Database cleared"); + + // If clean-only mode, exit early + if (CLEAN_ONLY) { + console.log("\n✨ Clean completed successfully!"); + return; + } + } + + // Generate sample actions sequentially + console.log("\n📝 Creating sample actions..."); + const createdActions = []; + + for (let i = 0; i < ACTION_COUNT; i++) { + const actionName = randomItem(actionNames); + const createdAt = randomPastDateWeighted(); + + const input = { + userId: `user_${randomInt(1, 100)}`, + data: `Sample data for ${actionName}`, + timestamp: createdAt.toISOString(), + }; + + const action = await store.createTaskAction({ + action: new ActionId(actionName), + input, + worker: null, + run_at: POSIX.fromDate(randomFutureDateWeighted(createdAt)), + timeout: 300, // 5 minutes + max_retries: 3, + parent: null, + }); + + if (action) { + createdActions.push(action); + statsAc.created++; + } + + // Progress indicator for large datasets + if ((i + 1) % 100 === 0) { + console.log(` Progress: ${i + 1}/${ACTION_COUNT} actions created`); + } + } + + console.log(`✅ Created ${createdActions.length} sample actions`); + + // Create workflows first, then sub-tasks, then simulate all action states + console.log("\n📋 Creating sample workflows..."); + const createdWorkflows = []; + + for (let i = 0; i < WORKFLOW_COUNT; i++) { + const workflowName = randomItem(workflowNames); + const createdAt = randomPastDateWeighted(); + + const input = { + workflowId: `wf_${randomInt(1000, 9999)}`, + config: { + retries: 3, + timeout: 3600, + }, + metadata: { + user: `user_${randomInt(1, 100)}`, + environment: randomItem(["production", "staging", "development"]), + }, + }; + + const workflow = await store.createTaskWorkflow({ + workflow: new WorkflowId(workflowName), + input, + worker: null, + run_at: POSIX.fromDate(randomFutureDateWeighted(createdAt)), + timeout: WORKFLOW_TIMEOUT_SECONDS, + parent: null, + }); + + if (workflow) { + createdWorkflows.push(workflow); + statsWf.created++; + } + + if ((i + 1) % 100 === 0) { + console.log(` Progress: ${i + 1}/${WORKFLOW_COUNT} workflows created`); + } + } + + console.log(`✅ Created ${createdWorkflows.length} sample workflows`); + + // Create subtasks for workflows (parent-child relationships) + console.log("\n🔗 Creating sub-tasks for workflows..."); + const workflowsWithSubtasks = Math.min(5, createdWorkflows.length); + + for (let i = 0; i < workflowsWithSubtasks; i++) { + const parentWorkflow = createdWorkflows[i]; + if (parentWorkflow) { + const numSubtasks = randomInt(2, 5); + // Start subtasks at the parent workflow's run_at time + const workflowStartTime = new Date(parentWorkflow.run_at.value); + + for (let step = 0; step < numSubtasks; step++) { + // Add random offset for each step (sequential with some overlap) + // Earlier steps start sooner, later steps start later + const stepOffsetMs = step * randomInt(30000, 300000); // 30s to 5min per step + const subtaskRunAt = new Date(workflowStartTime.getTime() + stepOffsetMs); + + await store.createTaskAction({ + action: new ActionId(randomItem(actionNames)), + input: { + step, + parentWorkflow: parentWorkflow.id.value, + data: `Sub-task ${step + 1} of ${parentWorkflow.workflow.value}`, + }, + worker: null, + run_at: POSIX.fromDate(subtaskRunAt), + timeout: DEFAULT_TASK_TIMEOUT_SECONDS, + max_retries: 3, + parent: { id: parentWorkflow.id, step }, + }); + stats.subtasks++; + } + } + } + console.log(`✅ Created ${stats.subtasks} sub-tasks for ${workflowsWithSubtasks} workflows`); + + // Now let's simulate some tasks being taken, completed, or failed (sequentially) + console.log("\n⚙️ Simulating action task execution states..."); + + const workers = [new WorkerId("worker-1"), new WorkerId("worker-2"), new WorkerId("worker-3")]; + const taskTableName = `${namespaceName}_task`; + + // Fetch ALL actions including sub-tasks from the database + const allActions = await explorer.listTasks({ limit: 10000 }); + + // Shuffle actions for random state assignment + const shuffledActions = [...allActions.actions].sort(() => Math.random() - 0.5); + + // Calculate target counts based on percentages + const totalActions = shuffledActions.length; + const targetCounts = { + completed: Math.floor(totalActions * 0.4), // 40% completed + failed: Math.floor(totalActions * 0.15), // 15% failed + cancelled: Math.floor(totalActions * 0.05), // 5% cancelled + running: Math.floor(totalActions * 0.1), // 10% running + // Rest will be pending + }; + + let actionIndex = 0; + + // Complete tasks (40%) - directly update DB with realistic timestamps + console.log(` Completing ${targetCounts.completed} tasks...`); + for (let i = 0; i < targetCounts.completed && actionIndex < totalActions; i++) { + const action = shuffledActions[actionIndex]; + if (action) { + const worker = randomItem(workers); + // Create realistic execution timeline with random duration + const startedAt = new Date(action.run_at.value); + const durationMs = randomInt(1000, 300000); // 1s to 5min + const finishedAt = new Date(startedAt.getTime() + durationMs); + + await postgres.withConnectionP(async conn => { + await conn.query( + `UPDATE ${taskTableName} + SET state = 'completed', + worker = $1, + finished_at = $2::timestamptz, + output = $3 + WHERE id = $4`, + [ + worker.value, + finishedAt.toISOString(), + JSON.stringify({ + success: true, + result: `${action.action.value} completed successfully`, + duration: durationMs, + }), + action.id.value, + ], + ); + }); + statsAc.completed++; + } + actionIndex++; + + if ((i + 1) % 100 === 0) { + console.log(` Progress: ${i + 1}/${targetCounts.completed} completed`); + } + } + + // Fail tasks (15%) - directly update DB with realistic timestamps + console.log(` Failing ${targetCounts.failed} tasks...`); + for (let i = 0; i < targetCounts.failed && actionIndex < totalActions; i++) { + const action = shuffledActions[actionIndex]; + if (action) { + const worker = randomItem(workers); + const startedAt = new Date(action.run_at.value); + const durationMs = randomInt(500, 30000); // 0.5s to 30s (failures are usually faster) + const finishedAt = new Date(startedAt.getTime() + durationMs); + + await postgres.withConnectionP(async conn => { + await conn.query( + `UPDATE ${taskTableName} + SET state = 'failed', + worker = $1, + finished_at = $2::timestamptz, + error = $3 + WHERE id = $4`, + [ + worker.value, + finishedAt.toISOString(), + `Error in ${action.action.value}: ${randomItem([ + "Connection timeout", + "Invalid input", + "Service unavailable", + "Permission denied", + ])}`, + action.id.value, + ], + ); + }); + statsAc.failed++; + } + actionIndex++; + + if ((i + 1) % 100 === 0) { + console.log(` Progress: ${i + 1}/${targetCounts.failed} failed`); + } + } + + // Cancel tasks (5%) - directly update DB + console.log(` Cancelling ${targetCounts.cancelled} tasks...`); + for (let i = 0; i < targetCounts.cancelled && actionIndex < totalActions; i++) { + const action = shuffledActions[actionIndex]; + if (action) { + const worker = randomItem(workers); + const startedAt = new Date(action.run_at.value); + const durationMs = randomInt(100, 10000); // 0.1s to 10s (cancellations are quick) + const finishedAt = new Date(startedAt.getTime() + durationMs); + + await postgres.withConnectionP(async conn => { + await conn.query( + `UPDATE ${taskTableName} + SET state = 'cancelled', + worker = $1, + finished_at = $2::timestamptz + WHERE id = $3`, + [worker.value, finishedAt.toISOString(), action.id.value], + ); + }); + statsAc.cancelled++; + } + actionIndex++; + + if ((i + 1) % 100 === 0) { + console.log(` Progress: ${i + 1}/${targetCounts.cancelled} cancelled`); + } + } + + // Make tasks "running" (10%) - only for tasks with run_at in the past + // Use task-explorer-cancel worker so tasks can be cancelled from UI + const cancelWorker = new WorkerId(process.env["CANCEL_WORKER_ID"] ?? "task-explorer-cancel"); + console.log(` Taking ${targetCounts.running} tasks for execution...`); + for (let i = 0; i < targetCounts.running && actionIndex < totalActions; i++) { + const action = shuffledActions[actionIndex]; + if (action && action.run_at.value <= Date.now()) { + // Only make past/present tasks running + await postgres.withConnectionP(async conn => { + await conn.query( + `UPDATE ${taskTableName} + SET state = 'running', + worker = $1 + WHERE id = $2`, + [cancelWorker.value, action.id.value], + ); + }); + statsAc.running++; + } + actionIndex++; + + if ((i + 1) % 100 === 0) { + console.log(` Progress: ${i + 1}/${targetCounts.running} running`); + } + } + + // Remaining are pending + statsAc.pending = totalActions - statsAc.completed - statsAc.failed - statsAc.cancelled - statsAc.running; + + // Simulate workflow states (sequentially) + console.log("\n⚙️ Simulating workflow execution states..."); + + const workflowTableName = `${namespaceName}_workflow`; + + // Shuffle workflows for random state assignment + const shuffledWorkflows = [...createdWorkflows].sort(() => Math.random() - 0.5); + + // Calculate target counts based on percentages + const totalWorkflows = shuffledWorkflows.length; + const workflowTargetCounts = { + completed: Math.floor(totalWorkflows * 0.35), // 35% completed + failed: Math.floor(totalWorkflows * 0.1), // 10% failed + cancelled: Math.floor(totalWorkflows * 0.05), // 5% cancelled + running: Math.floor(totalWorkflows * 0.15), // 15% running + blocked: Math.floor(totalWorkflows * 0.1), // 10% blocked + // Rest will be pending + }; + + let workflowIndex = 0; + + // Complete workflows (35%) - directly update DB with realistic timestamps + console.log(` Completing ${workflowTargetCounts.completed} workflows...`); + for (let i = 0; i < workflowTargetCounts.completed && workflowIndex < totalWorkflows; i++) { + const workflow = shuffledWorkflows[workflowIndex]; + if (workflow) { + const worker = randomItem(workers); + const startedAt = new Date(workflow.run_at.value); + const durationMs = randomInt(60000, 3600000); // 1min to 1hour for workflows + const finishedAt = new Date(startedAt.getTime() + durationMs); + + await postgres.withConnectionP(async conn => { + await conn.query( + `UPDATE ${workflowTableName} + SET state = 'completed', + worker = $1, + started_at = $2::timestamptz, + finished_at = $3::timestamptz, + output = $4 + WHERE id = $5`, + [ + worker.value, + startedAt.toISOString(), + finishedAt.toISOString(), + JSON.stringify({ + success: true, + stepsCompleted: randomInt(3, 10), + duration: durationMs, + }), + workflow.id.value, + ], + ); + }); + statsWf.completed++; + } + workflowIndex++; + + if ((i + 1) % 50 === 0) { + console.log(` Progress: ${i + 1}/${workflowTargetCounts.completed} completed`); + } + } + + // Fail workflows (10%) - directly update DB with realistic timestamps + console.log(` Failing ${workflowTargetCounts.failed} workflows...`); + for (let i = 0; i < workflowTargetCounts.failed && workflowIndex < totalWorkflows; i++) { + const workflow = shuffledWorkflows[workflowIndex]; + if (workflow) { + const worker = randomItem(workers); + const startedAt = new Date(workflow.run_at.value); + const durationMs = randomInt(5000, 180000); // 5s to 3min (failures are faster) + const finishedAt = new Date(startedAt.getTime() + durationMs); + + await postgres.withConnectionP(async conn => { + await conn.query( + `UPDATE ${workflowTableName} + SET state = 'failed', + worker = $1, + started_at = $2::timestamptz, + finished_at = $3::timestamptz, + error = $4 + WHERE id = $5`, + [ + worker.value, + startedAt.toISOString(), + finishedAt.toISOString(), + `Workflow ${workflow.workflow.value} failed: ${randomItem([ + "Step 3 failed after max retries", + "Timeout exceeded", + "Critical error in sub-workflow", + "Resource not found", + ])}`, + workflow.id.value, + ], + ); + }); + statsWf.failed++; + } + workflowIndex++; + + if ((i + 1) % 50 === 0) { + console.log(` Progress: ${i + 1}/${workflowTargetCounts.failed} failed`); + } + } + + // Cancel workflows (5%) - directly update DB + console.log(` Cancelling ${workflowTargetCounts.cancelled} workflows...`); + for (let i = 0; i < workflowTargetCounts.cancelled && workflowIndex < totalWorkflows; i++) { + const workflow = shuffledWorkflows[workflowIndex]; + if (workflow) { + const worker = randomItem(workers); + const startedAt = new Date(workflow.run_at.value); + const durationMs = randomInt(1000, 30000); // 1s to 30s (cancellations are quick) + const finishedAt = new Date(startedAt.getTime() + durationMs); + + await postgres.withConnectionP(async conn => { + await conn.query( + `UPDATE ${workflowTableName} + SET state = 'cancelled', + worker = $1, + started_at = $2::timestamptz, + finished_at = $3::timestamptz + WHERE id = $4`, + [worker.value, startedAt.toISOString(), finishedAt.toISOString(), workflow.id.value], + ); + }); + statsWf.cancelled++; + } + workflowIndex++; + + if ((i + 1) % 50 === 0) { + console.log(` Progress: ${i + 1}/${workflowTargetCounts.cancelled} cancelled`); + } + } + + // Make workflows "running" (15%) - only for tasks with run_at in the past + // Use task-explorer-cancel worker so tasks can be cancelled from UI + console.log(` Taking ${workflowTargetCounts.running} workflows for execution...`); + for (let i = 0; i < workflowTargetCounts.running && workflowIndex < totalWorkflows; i++) { + const workflow = shuffledWorkflows[workflowIndex]; + if (workflow && workflow.run_at.value <= Date.now()) { + // Only make past/present tasks running + const startedAt = new Date(workflow.run_at.value); + + await postgres.withConnectionP(async conn => { + await conn.query( + `UPDATE ${workflowTableName} + SET state = 'running', + worker = $1, + started_at = $2::timestamptz + WHERE id = $3`, + [cancelWorker.value, startedAt.toISOString(), workflow.id.value], + ); + }); + statsWf.running++; + } + workflowIndex++; + + if ((i + 1) % 50 === 0) { + console.log(` Progress: ${i + 1}/${workflowTargetCounts.running} running`); + } + } + + // Simulate retry attempts on actions (sequentially) + console.log("\n🔄 Simulating retry attempts..."); + const retryCount = Math.min(3, Math.floor(totalActions * 0.02)); // 2% of actions + + for (let i = 0; i < retryCount; i++) { + const worker = randomItem(workers); + const task = await store.takeReadyTaskAction(worker); + if (task) { + // Fail first attempt (sequential) + await store.failTaskAction({ + worker, + task, + error: `Attempt 1 failed: ${randomItem([ + "Connection timeout", + "Temporary network error", + "Service temporarily unavailable", + ])}`, + }); + + // Retry - take again and complete successfully (sequential) + const retryTask = await store.takeReadyTaskAction(worker); + if (retryTask && retryTask.id.value === task.id.value) { + await store.completeTaskAction({ + worker, + task: retryTask, + output: { + success: true, + retriedSuccessfully: true, + attempts: retryTask.attempts, + message: `${retryTask.action.value} succeeded after retry`, + }, + }); + stats.retries++; + } + } + } + console.log(`✅ Simulated ${stats.retries} successful retry attempts`); + + // Create blocked workflows with pending steps (sequentially) + console.log(`\n🚧 Creating ${workflowTargetCounts.blocked} blocked workflows with pending steps...`); + + for (let i = 0; i < workflowTargetCounts.blocked && workflowIndex < totalWorkflows; i++) { + const wf = shuffledWorkflows[workflowIndex]; + if (wf) { + // Sequential database update + await postgres.withConnectionP(async conn => { + await conn.query( + `UPDATE ${workflowTableName} + SET state = 'blocked', pending_steps = ARRAY[0, 1, 2] + WHERE id = $1`, + [wf.id.value], + ); + }); + statsWf.blocked++; + } + workflowIndex++; + + if ((i + 1) % 50 === 0) { + console.log(` Progress: ${i + 1}/${workflowTargetCounts.blocked} blocked`); + } + } + console.log(`✅ Created ${statsWf.blocked} blocked workflows with pending steps`); + + // Remaining workflows are pending + statsWf.pending = + totalWorkflows - statsWf.completed - statsWf.failed - statsWf.cancelled - statsWf.running - statsWf.blocked; + + console.log("\n✨ Seed completed successfully!"); + console.log("\n" + "=".repeat(60)); + console.log("📊 SEED STATISTICS"); + console.log("=".repeat(60) + "\n"); + + const totalTasks = statsAc.created + statsWf.created + stats.subtasks; + + console.log("📦 TASKS CREATED:"); + console.log(` Actions: ${statsAc.created}`); + console.log(` Workflows: ${statsWf.created}`); + console.log(` Sub-tasks: ${stats.subtasks}`); + console.log(` ─────────────────`); + console.log(` TOTAL: ${totalTasks}\n`); + + console.log("🎯 ACTION STATES:"); + console.log(` Pending: ${statsAc.pending} (${((statsAc.pending / statsAc.created) * 100).toFixed(1)}%)`); + console.log(` Running: ${statsAc.running} (${((statsAc.running / statsAc.created) * 100).toFixed(1)}%)`); + console.log(` Completed: ${statsAc.completed} (${((statsAc.completed / statsAc.created) * 100).toFixed(1)}%)`); + console.log(` Failed: ${statsAc.failed} (${((statsAc.failed / statsAc.created) * 100).toFixed(1)}%)`); + console.log(` Cancelled: ${statsAc.cancelled} (${((statsAc.cancelled / statsAc.created) * 100).toFixed(1)}%)`); + console.log(` ─────────────────`); + console.log(` TOTAL: ${statsAc.created}\n`); + + console.log("🔄 WORKFLOW STATES:"); + console.log(` Pending: ${statsWf.pending} (${((statsWf.pending / statsWf.created) * 100).toFixed(1)}%)`); + console.log(` Running: ${statsWf.running} (${((statsWf.running / statsWf.created) * 100).toFixed(1)}%)`); + console.log(` Blocked: ${statsWf.blocked} (${((statsWf.blocked / statsWf.created) * 100).toFixed(1)}%)`); + console.log(` Completed: ${statsWf.completed} (${((statsWf.completed / statsWf.created) * 100).toFixed(1)}%)`); + console.log(` Failed: ${statsWf.failed} (${((statsWf.failed / statsWf.created) * 100).toFixed(1)}%)`); + console.log(` Cancelled: ${statsWf.cancelled} (${((statsWf.cancelled / statsWf.created) * 100).toFixed(1)}%)`); + console.log(` ─────────────────`); + console.log(` TOTAL: ${statsWf.created}\n`); + + console.log("🔁 OTHER STATISTICS:"); + console.log(` Retry attempts: ${stats.retries}\n`); + + console.log("=".repeat(60)); + } catch (error) { + console.error("❌ Seed failed:", error); + throw error; + } finally { + await postgres.disconnect(); + } +} + +// Run seed +seed() + .then(() => { + console.log("\n🎉 Done!"); + process.exit(0); + }) + .catch(err => { + console.error("\n💥 Fatal error:", err); + process.exit(1); + }); diff --git a/task-explorer/backend/tests/helpers.ts b/task-explorer/backend/tests/helpers.ts new file mode 100644 index 0000000..0267f8f --- /dev/null +++ b/task-explorer/backend/tests/helpers.ts @@ -0,0 +1,13 @@ +export { extractCookies }; + +import type request from "supertest"; + +// Helper to extract cookies from response headers +function extractCookies(response: request.Response): string[] { + const setCookie = response.headers["set-cookie"]; + return ( + Array.isArray(setCookie) ? setCookie + : setCookie ? [setCookie] + : [] + ); +} diff --git a/task-explorer/backend/tests/integration/.env.test b/task-explorer/backend/tests/integration/.env.test new file mode 100644 index 0000000..69f43d4 --- /dev/null +++ b/task-explorer/backend/tests/integration/.env.test @@ -0,0 +1,13 @@ +# Task Explorer - Integration Test Environment +# Credentials must match docker-compose.test.yml + +AUTH_USERNAME=admin +AUTH_PASSWORD=admin +SESSION_SECRET=integration-test-secret + +TASKS_DB_HOST=localhost +TASKS_DB_PORT=5434 +TASKS_DB_USER=postgres +TASKS_DB_PASSWORD=postgres +TASKS_DB_NAME=postgres +TASKS_DB_NAMESPACE=test diff --git a/task-explorer/backend/tests/integration/api.test.ts b/task-explorer/backend/tests/integration/api.test.ts new file mode 100644 index 0000000..f5e1a71 --- /dev/null +++ b/task-explorer/backend/tests/integration/api.test.ts @@ -0,0 +1,337 @@ +import { group, test, expect } from "@ambarltd/core/test"; +import request from "supertest"; +import { app, startup } from "@be/index"; +import { initializeDatabase } from "@be/lib/database"; +import { ActionId, WorkflowId } from "@ambarltd/tasks/store"; +import { POSIX } from "@ambarltd/core/time"; +import { extractCookies } from "../helpers"; + +// Integration tests - require a running PostgreSQL database +// Run via ./utils.sh backend test:integration which provides isolated Docker infrastructure + +// Initialize the database and create known test fixtures once when module loads +await startup(); + +const { store } = await initializeDatabase(); + +// Create a known pending action and workflow to use for deterministic assertions +const testAction = await store.createTaskAction({ + action: new ActionId("test_action"), + input: { test: true }, + worker: null, + run_at: POSIX.fromDate(new Date()), + timeout: 300, + max_retries: 0, + parent: null, +}); + +const testWorkflow = await store.createTaskWorkflow({ + workflow: new WorkflowId("test_workflow"), + input: { test: true }, + worker: null, + run_at: POSIX.fromDate(new Date()), + timeout: 300, + parent: null, +}); + +// Login once and reuse the session for all tests +const loginResponse = await request(app).post("/api/auth/login").send({ + username: process.env["AUTH_USERNAME"], + password: process.env["AUTH_PASSWORD"], +}); +const authCookie = extractCookies(loginResponse); + +export const tests = group("Task API Endpoints", [ + group("GET /api/health", [ + test("should return health status", async () => { + const response = await request(app).get("/api/health"); + + expect.equals(response.status, 200); + expect.not_equals(response.body.status, undefined); + expect.not_equals(response.body.database, undefined); + expect.not_equals(response.body.timestamp, undefined); + }), + ]), + + group("GET /api/info", [ + test("should return service info", async () => { + const response = await request(app).get("/api/info"); + + expect.equals(response.status, 200); + expect.equals(response.body.name, "task-explorer"); + expect.equals(response.body.version, "1.0"); + expect.equals(typeof response.body.timestamp, "string"); + }), + ]), + + group("GET /api/tasks", [ + test("should return tasks list", async () => { + const response = await request(app).get("/api/tasks").set("Cookie", authCookie); + + expect.equals(response.status, 200); + expect.equals(Array.isArray(response.body.actions), true); + expect.equals(Array.isArray(response.body.workflows), true); + }), + + test("should include known pending action in unfiltered results", async () => { + const response = await request(app).get("/api/tasks").set("Cookie", authCookie); + + expect.equals(response.status, 200); + const ids = response.body.actions.map((a: { id: string }) => a.id); + expect.equals(ids.includes(testAction!.id.value), true); + }), + + test("should return only pending tasks when filtered by status=pending", async () => { + const response = await request(app).get("/api/tasks").query({ status: "pending" }).set("Cookie", authCookie); + + expect.equals(response.status, 200); + // Known task is pending — must appear + const ids = response.body.actions.map((a: { id: string }) => a.id); + expect.equals(ids.includes(testAction!.id.value), true); + // Every returned action must be pending + for (const action of response.body.actions) { + expect.equals(action.state, "pending"); + } + for (const workflow of response.body.workflows) { + expect.equals(workflow.state, "pending"); + } + }), + + test("should exclude pending tasks when filtered by status=completed", async () => { + const response = await request(app).get("/api/tasks").query({ status: "completed" }).set("Cookie", authCookie); + + expect.equals(response.status, 200); + // Known pending task must NOT appear in completed results + const ids = response.body.actions.map((a: { id: string }) => a.id); + expect.equals(ids.includes(testAction!.id.value), false); + }), + + test("should filter by time range", async () => { + const now = new Date(); + const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000); + + const response = await request(app) + .get("/api/tasks") + .query({ + startTime: yesterday.toISOString(), + endTime: now.toISOString(), + }) + .set("Cookie", authCookie); + + expect.equals(response.status, 200); + // Known task was created just now — must appear in last 24h + const ids = response.body.actions.map((a: { id: string }) => a.id); + expect.equals(ids.includes(testAction!.id.value), true); + }), + + test("should support pagination", async () => { + const response = await request(app).get("/api/tasks").query({ limit: 10, offset: 0 }).set("Cookie", authCookie); + + expect.equals(response.status, 200); + if (response.body.actions.length > 10) { + expect.fail(`Expected actions.length <= 10, got ${response.body.actions.length}`); + } + }), + + test("should enforce maximum limit", async () => { + const response = await request(app).get("/api/tasks").query({ limit: 10000 }).set("Cookie", authCookie); + + expect.equals(response.status, 200); + if (response.body.actions.length > 1000) { + expect.fail(`Expected actions.length <= 1000, got ${response.body.actions.length}`); + } + if (response.body.workflows.length > 1000) { + expect.fail(`Expected workflows.length <= 1000, got ${response.body.workflows.length}`); + } + }), + ]), + + group("GET /api/tasks/search", [ + test("should search tasks by query", async () => { + const response = await request(app).get("/api/tasks/search").query({ q: "test" }).set("Cookie", authCookie); + + expect.equals(response.status, 200); + expect.equals(Array.isArray(response.body.actions), true); + expect.equals(Array.isArray(response.body.workflows), true); + }), + + test("should find known task by name", async () => { + const response = await request(app) + .get("/api/tasks/search") + .query({ q: "test_action" }) + .set("Cookie", authCookie); + + expect.equals(response.status, 200); + const ids = response.body.actions.map((a: { id: string }) => a.id); + expect.equals(ids.includes(testAction!.id.value), true); + }), + + test("should return 400 when query param is missing", async () => { + const response = await request(app).get("/api/tasks/search").set("Cookie", authCookie); + + expect.equals(response.status, 400); + }), + ]), + + group("GET /api/tasks/:type/:id", [ + test("should return 400 for invalid task type", async () => { + const response = await request(app).get("/api/tasks/invalid/123").set("Cookie", authCookie); + + expect.equals(response.status, 400); + expect.not_equals(response.body.error, undefined); + }), + + test("should return action task details with events and subtasks", async () => { + const response = await request(app).get(`/api/tasks/action/${testAction!.id.value}`).set("Cookie", authCookie); + + expect.equals(response.status, 200); + expect.equals(response.body.task.id, testAction!.id.value); + expect.equals(response.body.task.action, "test_action"); + expect.equals(Array.isArray(response.body.events), true); + expect.not_equals(response.body.subTasks, undefined); + }), + + test("should return workflow task details with events and subtasks", async () => { + const response = await request(app) + .get(`/api/tasks/workflow/${testWorkflow!.id.value}`) + .set("Cookie", authCookie); + + expect.equals(response.status, 200); + expect.equals(response.body.task.id, testWorkflow!.id.value); + expect.equals(response.body.task.workflow, "test_workflow"); + expect.equals(Array.isArray(response.body.events), true); + expect.not_equals(response.body.subTasks, undefined); + }), + + test("should return 404 for non-existent task", async () => { + const fakeUUID = "00000000-0000-0000-0000-000000000000"; + const response = await request(app).get(`/api/tasks/action/${fakeUUID}`).set("Cookie", authCookie); + + expect.equals(response.status, 404); + }), + ]), + + group("GET /api/tasks/timeline", [ + test("should return timeline data for valid time range", async () => { + const now = new Date(); + const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000); + + const response = await request(app) + .get("/api/tasks/timeline") + .query({ + startTime: yesterday.toISOString(), + endTime: now.toISOString(), + }) + .set("Cookie", authCookie); + + expect.equals(response.status, 200); + expect.equals(Array.isArray(response.body.tasks), true); + }), + + test("should reject invalid startTime format", async () => { + const response = await request(app) + .get("/api/tasks/timeline") + .query({ + startTime: "invalid-date", + endTime: new Date().toISOString(), + }) + .set("Cookie", authCookie); + + expect.equals(response.status, 400); + expect.contains("Invalid startTime", response.body.error); + }), + + test("should reject invalid endTime format", async () => { + const response = await request(app) + .get("/api/tasks/timeline") + .query({ + startTime: new Date().toISOString(), + endTime: "not-a-date", + }) + .set("Cookie", authCookie); + + expect.equals(response.status, 400); + expect.contains("Invalid endTime", response.body.error); + }), + + test("should support pagination parameters", async () => { + const now = new Date(); + const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000); + + const response = await request(app) + .get("/api/tasks/timeline") + .query({ + startTime: yesterday.toISOString(), + endTime: now.toISOString(), + limit: 5, + offset: 0, + }) + .set("Cookie", authCookie); + + expect.equals(response.status, 200); + expect.equals(Array.isArray(response.body.tasks), true); + }), + ]), + + group("POST /api/tasks/:type/:id/cancel", [ + test("should return 400 for invalid task type", async () => { + const response = await request(app).post("/api/tasks/invalid/123/cancel").set("Cookie", authCookie); + + expect.equals(response.status, 400); + expect.not_equals(response.body.error, undefined); + }), + + test("should return 404 for non-existent action task", async () => { + const fakeUUID = "00000000-0000-0000-0000-000000000000"; + const response = await request(app).post(`/api/tasks/action/${fakeUUID}/cancel`).set("Cookie", authCookie); + + expect.equals(response.status, 404); + expect.contains("Task not found", response.body.error); + }), + + test("should return 404 for non-existent workflow task", async () => { + const fakeUUID = "00000000-0000-0000-0000-000000000000"; + const response = await request(app).post(`/api/tasks/workflow/${fakeUUID}/cancel`).set("Cookie", authCookie); + + expect.equals(response.status, 404); + expect.contains("Task not found", response.body.error); + }), + + test("should successfully cancel an existing pending action task", async () => { + const cancelTarget = await store.createTaskAction({ + action: new ActionId("cancel_test_action"), + input: {}, + worker: null, + run_at: POSIX.fromDate(new Date()), + timeout: 300, + max_retries: 0, + parent: null, + }); + + const response = await request(app) + .post(`/api/tasks/action/${cancelTarget!.id.value}/cancel`) + .set("Cookie", authCookie); + + expect.equals(response.status, 200); + expect.deep_equals(response.body, { success: true }); + }), + + test("should successfully cancel an existing pending workflow task", async () => { + const cancelTarget = await store.createTaskWorkflow({ + workflow: new WorkflowId("cancel_test_workflow"), + input: {}, + worker: null, + run_at: POSIX.fromDate(new Date()), + timeout: 300, + parent: null, + }); + + const response = await request(app) + .post(`/api/tasks/workflow/${cancelTarget!.id.value}/cancel`) + .set("Cookie", authCookie); + + expect.equals(response.status, 200); + expect.deep_equals(response.body, { success: true }); + }), + ]), +]); diff --git a/task-explorer/backend/tests/integration/auth.test.ts b/task-explorer/backend/tests/integration/auth.test.ts new file mode 100644 index 0000000..c9879a5 --- /dev/null +++ b/task-explorer/backend/tests/integration/auth.test.ts @@ -0,0 +1,142 @@ +import { group, test, expect } from "@ambarltd/core/test"; +import request from "supertest"; +import { app, startup } from "@be/index"; +import { extractCookies } from "../helpers"; + +// Integration tests - require a running PostgreSQL database +// Run via ./integration-tests.sh test which provides isolated Docker infrastructure + +// Initialize the database once when module loads +await startup(); + +export const tests = group("Authentication API", [ + group("POST /api/auth/login", [ + test("should login with correct credentials", async () => { + const response = await request(app).post("/api/auth/login").send({ + username: process.env["AUTH_USERNAME"], + password: process.env["AUTH_PASSWORD"], + }); + + expect.equals(response.status, 200); + expect.deep_equals(response.body, { success: true }); + expect.not_equals(response.headers["set-cookie"], undefined); + }), + + test("should reject invalid username", async () => { + const response = await request(app).post("/api/auth/login").send({ + username: "wronguser", + password: process.env["AUTH_PASSWORD"], + }); + + expect.equals(response.status, 401); + expect.equals(response.body.error, "Invalid credentials"); + }), + + test("should reject invalid password", async () => { + const response = await request(app).post("/api/auth/login").send({ + username: process.env["AUTH_USERNAME"], + password: "wrongpassword", + }); + + expect.equals(response.status, 401); + expect.equals(response.body.error, "Invalid credentials"); + }), + + test("should reject missing credentials", async () => { + const response = await request(app).post("/api/auth/login").send({}); + + expect.equals(response.status, 401); + expect.not_equals(response.body.error, undefined); + }), + ]), + + group("GET /api/auth/status", [ + test("should return not authenticated without session", async () => { + const response = await request(app).get("/api/auth/status"); + + expect.equals(response.status, 200); + expect.deep_equals(response.body, { authenticated: false }); + }), + + test("should return authenticated with valid session", async () => { + // First login to get session cookie + const loginResponse = await request(app).post("/api/auth/login").send({ + username: process.env["AUTH_USERNAME"], + password: process.env["AUTH_PASSWORD"], + }); + + const cookies = extractCookies(loginResponse); + + // Then check auth status with the session cookie + const response = await request(app).get("/api/auth/status").set("Cookie", cookies); + + expect.equals(response.status, 200); + expect.deep_equals(response.body, { authenticated: true }); + }), + ]), + + group("POST /api/auth/logout", [ + test("should logout successfully", async () => { + // First login to get session cookie + const loginResponse = await request(app).post("/api/auth/login").send({ + username: process.env["AUTH_USERNAME"], + password: process.env["AUTH_PASSWORD"], + }); + + const cookies = extractCookies(loginResponse); + + // Then logout + const logoutResponse = await request(app).post("/api/auth/logout").set("Cookie", cookies); + + expect.equals(logoutResponse.status, 200); + expect.deep_equals(logoutResponse.body, { success: true }); + + // Verify session is cleared - use cookies from logout response (which clears the session cookie) + const logoutCookies = extractCookies(logoutResponse); + const statusResponse = await request(app).get("/api/auth/status").set("Cookie", logoutCookies); + + expect.deep_equals(statusResponse.body, { authenticated: false }); + }), + ]), + + group("Protected Routes", [ + test("should deny access to /api/tasks without authentication", async () => { + const response = await request(app).get("/api/tasks"); + + expect.equals(response.status, 401); + expect.equals(response.body.error, "Unauthorized"); + }), + + test("should allow access to /api/tasks with authentication", async () => { + // First login + const loginResponse = await request(app).post("/api/auth/login").send({ + username: process.env["AUTH_USERNAME"], + password: process.env["AUTH_PASSWORD"], + }); + + const cookies = extractCookies(loginResponse); + + // Then access the protected route + const response = await request(app).get("/api/tasks").set("Cookie", cookies); + + // Should not be 401 (it may be 200 or other valid status depending on data) + expect.not_equals(response.status, 401); + }), + ]), + + group("Public Routes", [ + test("should allow access to /api/health without authentication", async () => { + const response = await request(app).get("/api/health"); + + expect.equals(response.status, 200); + expect.not_equals(response.body.status, undefined); + }), + + test("should allow access to /api/info without authentication", async () => { + const response = await request(app).get("/api/info"); + + expect.equals(response.status, 200); + expect.equals(response.body.name, "task-explorer"); + }), + ]), +]); diff --git a/task-explorer/backend/tests/integration/docker-compose.test.yml b/task-explorer/backend/tests/integration/docker-compose.test.yml new file mode 100644 index 0000000..d814b4e --- /dev/null +++ b/task-explorer/backend/tests/integration/docker-compose.test.yml @@ -0,0 +1,14 @@ +services: + postgres: + image: docker.io/postgres:16.4 + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + ports: + - "5434:5432" + healthcheck: + test: pg_isready -U postgres + interval: 2s + timeout: 3s + retries: 10 diff --git a/task-explorer/backend/tests/integration/main.ts b/task-explorer/backend/tests/integration/main.ts new file mode 100644 index 0000000..8e9c81b --- /dev/null +++ b/task-explorer/backend/tests/integration/main.ts @@ -0,0 +1,16 @@ +import { parseArgs, run } from "@ambarltd/core/test"; +import * as authTests from "./auth.test"; +import * as apiTests from "./api.test"; + +async function main() { + console.log("Running integration tests"); + console.log(""); + + const options = parseArgs(process.argv.slice(2)); + + const testSuites = [authTests.tests, apiTests.tests]; + + await run(options, testSuites); +} + +main(); diff --git a/task-explorer/backend/tests/unit/main.ts b/task-explorer/backend/tests/unit/main.ts new file mode 100644 index 0000000..d67c5f6 --- /dev/null +++ b/task-explorer/backend/tests/unit/main.ts @@ -0,0 +1,15 @@ +import { parseArgs, run } from "@ambarltd/core/test"; +import * as utilitiesTests from "./utilities.test"; + +async function main() { + console.log("Running unit tests"); + console.log(""); + + const options = parseArgs(process.argv.slice(2)); + + const testSuites = [utilitiesTests.tests]; + + await run(options, testSuites); +} + +main(); diff --git a/task-explorer/backend/tests/unit/utilities.test.ts b/task-explorer/backend/tests/unit/utilities.test.ts new file mode 100644 index 0000000..892c1be --- /dev/null +++ b/task-explorer/backend/tests/unit/utilities.test.ts @@ -0,0 +1,233 @@ +import { group, test, expect } from "@ambarltd/core/test"; +import * as d from "@ambarltd/core/json/decoder"; +import { listTasksQueryDecoder, searchTasksQueryDecoder } from "@be/lib/decoders"; +import { encodeTaskAction, encodeTaskWorkflow, encodeTaskEvent } from "@be/lib/api-schemas"; +import { POSIX } from "@ambarltd/core/time"; +import { TaskActionId, TaskWorkflowId, ActionId, WorkflowId, WorkerId } from "@ambarltd/tasks/store"; + +export const tests = group("Backend Utilities", [ + group("Decoders", [ + group("listTasksQueryDecoder", [ + test("should decode valid query parameters", () => { + const input = { + status: "completed,failed", + startTime: "2024-01-01T00:00:00Z", + endTime: "2024-01-02T00:00:00Z", + limit: "50", + offset: "10", + }; + + const result = d.decode(input, listTasksQueryDecoder); + expect.equals(result.isSuccess(), true); + + if (result.isSuccess()) { + expect.deep_equals((result as any).value, input); + } + }), + + test("should accept missing optional parameters", () => { + const input = {}; + + const result = d.decode(input, listTasksQueryDecoder); + expect.equals(result.isSuccess(), true); + + if (result.isSuccess()) { + const value = (result as any).value; + expect.equals(value.status, undefined); + expect.equals(value.startTime, undefined); + expect.equals(value.endTime, undefined); + } + }), + ]), + + group("searchTasksQueryDecoder", [ + test("should decode valid search query", () => { + const input = { + q: "search term", + limit: "20", + offset: "5", + }; + + const result = d.decode(input, searchTasksQueryDecoder); + expect.equals(result.isSuccess(), true); + + if (result.isSuccess()) { + const value = (result as any).value; + expect.equals(value.q, "search term"); + expect.equals(value.limit, "20"); + } + }), + + test("should reject missing required parameter", () => { + const input = { + limit: "20", + }; + + const result = d.decode(input, searchTasksQueryDecoder); + expect.equals(result.isFailure(), true); + }), + ]), + ]), + + group("API Schemas", [ + group("encodeTaskAction", [ + test("should encode TaskAction correctly", () => { + const mockAction = { + id: { value: "action-123" } as TaskActionId, + action: { value: "test-action" } as ActionId, + created_at: POSIX.fromDate(new Date("2024-01-01T00:00:00Z")), + input: { key: "value" }, + run_at: POSIX.fromDate(new Date("2024-01-01T00:01:00Z")), + timeout_seconds: 300, + max_retries: 3, + parent: { value: "parent-123" } as TaskWorkflowId, + parent_step: 1, + attempts: 1, + state: "completed" as const, + worker: { value: "worker-1" } as WorkerId, + output: { result: "success" }, + error: null, + finished_at: POSIX.fromDate(new Date("2024-01-01T00:02:00Z")), + }; + + const encoded = encodeTaskAction(mockAction); + + expect.deep_equals(encoded, { + id: "action-123", + action: "test-action", + created_at: "2024-01-01T00:00:00.000Z", + input: { key: "value" }, + run_at: "2024-01-01T00:01:00.000Z", + timeout_seconds: 300, + max_retries: 3, + parent: "parent-123", + parent_step: 1, + attempts: 1, + state: "completed", + worker: "worker-1", + output: { result: "success" }, + error: null, + started_at: null, + finished_at: "2024-01-01T00:02:00.000Z", + }); + }), + + test("should handle null optional fields", () => { + const mockAction = { + id: { value: "action-456" } as TaskActionId, + action: { value: "test-action" } as ActionId, + created_at: POSIX.fromDate(new Date("2024-01-01T00:00:00Z")), + input: null, + run_at: POSIX.fromDate(new Date("2024-01-01T00:01:00Z")), + timeout_seconds: 300, + max_retries: 3, + parent: null, + parent_step: null, + attempts: 0, + state: "pending" as const, + worker: null, + output: null, + error: null, + finished_at: null, + }; + + const encoded = encodeTaskAction(mockAction); + + expect.equals(encoded.parent, null); + expect.equals(encoded.worker, null); + expect.equals(encoded.finished_at, null); + }), + ]), + + group("encodeTaskWorkflow", [ + test("should encode TaskWorkflow correctly", () => { + const mockWorkflow = { + id: { value: "workflow-123" } as TaskWorkflowId, + workflow: { value: "test-workflow" } as WorkflowId, + created_at: POSIX.fromDate(new Date("2024-01-01T00:00:00Z")), + input: { data: "test" }, + run_at: POSIX.fromDate(new Date("2024-01-01T00:01:00Z")), + timeout_seconds: 600, + parent: null, + parent_step: null, + pending_steps: [1, 2], + state: "running" as const, + worker: { value: "worker-2" } as WorkerId, + output: null, + error: null, + started_at: POSIX.fromDate(new Date("2024-01-01T00:01:30Z")), + finished_at: null, + }; + + const encoded = encodeTaskWorkflow(mockWorkflow); + + expect.deep_equals(encoded, { + id: "workflow-123", + workflow: "test-workflow", + created_at: "2024-01-01T00:00:00.000Z", + input: { data: "test" }, + run_at: "2024-01-01T00:01:00.000Z", + timeout_seconds: 600, + parent: null, + parent_step: null, + pending_steps: [1, 2], + state: "running", + worker: "worker-2", + output: null, + error: null, + started_at: "2024-01-01T00:01:30.000Z", + finished_at: null, + }); + }), + ]), + + group("encodeTaskEvent", [ + test("should encode action event correctly", () => { + const mockEvent = { + action_task: { value: "action-123" } as TaskActionId, + event: "running" as const, + time: POSIX.fromDate(new Date("2024-01-01T00:00:00Z")), + worker: { value: "worker-1" } as WorkerId, + }; + + const encoded = encodeTaskEvent(mockEvent as any); + + expect.deep_equals(encoded, { + id: "action-123-running-2024-01-01T00:00:00.000Z", + event_type: "running", + created_at: "2024-01-01T00:00:00.000Z", + details: { worker: "worker-1" }, + }); + }), + + test("should encode workflow event correctly", () => { + const mockEvent = { + workflow_task: { value: "workflow-456" } as TaskWorkflowId, + event: "completed" as const, + time: POSIX.fromDate(new Date("2024-01-01T00:05:00Z")), + worker: null, + }; + + const encoded = encodeTaskEvent(mockEvent as any); + + expect.deep_equals(encoded, { + id: "workflow-456-completed-2024-01-01T00:05:00.000Z", + event_type: "completed", + created_at: "2024-01-01T00:05:00.000Z", + details: null, + }); + }), + ]), + ]), + + group("Environment", [ + test("should have required environment variables", () => { + // Environment is loaded via tsx --env-file flag + expect.not_equals(process.env["AUTH_USERNAME"], undefined); + expect.not_equals(process.env["AUTH_PASSWORD"], undefined); + expect.not_equals(process.env["SESSION_SECRET"], undefined); + expect.not_equals(process.env["TASKS_DB_USER"], undefined); + expect.not_equals(process.env["TASKS_DB_HOST"], undefined); + }), + ]), +]); diff --git a/task-explorer/backend/tsconfig.json b/task-explorer/backend/tsconfig.json new file mode 100644 index 0000000..dda2e5c --- /dev/null +++ b/task-explorer/backend/tsconfig.json @@ -0,0 +1,46 @@ +{ + "compilerOptions": { + // module options + "target": "ES2020", + "module": "esnext", + "moduleResolution": "bundler", + "moduleDetection": "force", + "isolatedModules": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + + // type checking + "strict": true, + "noImplicitAny": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "exactOptionalPropertyTypes": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "allowUnusedLabels": false, + "allowUnreachableCode": false, + + "composite": true, + "declaration": true, + "declarationMap": true, + "outDir": "dist", + + // decorators + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + + // paths + "checkJs": true, + "baseUrl": ".", + "paths": { + "@be/*": ["./src/*"], + "@tests/*": ["./tests/*"] + } + }, + "include": ["src", "tests"] +} diff --git a/task-explorer/backend/utils.sh b/task-explorer/backend/utils.sh new file mode 100755 index 0000000..2d63547 --- /dev/null +++ b/task-explorer/backend/utils.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")" && pwd)" +INTEGRATION_COMPOSE="${ROOT_DIR}/tests/integration/docker-compose.test.yml" +INTEGRATION_ENV="${ROOT_DIR}/tests/integration/.env.test" + +cd "$ROOT_DIR" || exit 1 + +usage() { + cat < [args] + +Commands: + build Compile TypeScript to dist/ + typecheck Type-check without emitting + test Run unit tests + test:integration Run integration tests (spins up postgres via Docker) + clean Remove node_modules and dist/ + +Examples: + ./utils.sh test + ./utils.sh test:integration + ./utils.sh test:integration --match "auth" +EOF +} + +case "${1:-help}" in + build) + pnpm build + ;; + + typecheck) + pnpm exec tsc --noEmit + ;; + + test) + pnpm test:unit + ;; + + test:integration) + shift || true + docker-compose -f "$INTEGRATION_COMPOSE" up -d --wait + + set +e + pnpm exec tsx --env-file="$INTEGRATION_ENV" tests/integration/main.ts "$@" + EXIT_CODE=$? + set -e + + docker-compose -f "$INTEGRATION_COMPOSE" down -v + exit $EXIT_CODE + ;; + + clean) + echo "Removing backend node_modules and dist..." + rm -rf "./node_modules" "./dist" + echo "Done." + ;; + + help|--help|-h) + usage + ;; + + *) + echo "Error: Unknown command '${1}'" >&2 + usage + exit 1 + ;; +esac diff --git a/task-explorer/frontend/.dockerignore b/task-explorer/frontend/.dockerignore new file mode 100644 index 0000000..85075a6 --- /dev/null +++ b/task-explorer/frontend/.dockerignore @@ -0,0 +1,5 @@ +node_modules +dist +*.log +.env +.DS_Store diff --git a/task-explorer/frontend/index.html b/task-explorer/frontend/index.html new file mode 100644 index 0000000..5b3b105 --- /dev/null +++ b/task-explorer/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Task Explorer + + +
+ + + diff --git a/task-explorer/frontend/package.json b/task-explorer/frontend/package.json new file mode 100644 index 0000000..7b1665a --- /dev/null +++ b/task-explorer/frontend/package.json @@ -0,0 +1,45 @@ +{ + "name": "task-explorer-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "serve": "vite preview", + "start": "vite", + "test": "vitest run", + "test:watch": "vitest", + "test:ui": "vitest --ui", + "test:coverage": "vitest run --coverage" + }, + "dependencies": { + "@ambarltd/core": "^0.1.12", + "@tailwindcss/vite": "4.1.17", + "@tanstack/react-query": "5.90.11", + "@tanstack/react-router": "1.139.10", + "@tanstack/react-router-devtools": "1.139.10", + "date-fns": "4.1.0", + "react": "19.2.0", + "react-dom": "19.2.0", + "vis-data": "7.1.10", + "vis-timeline": "7.7.4" + }, + "devDependencies": { + "@tanstack/router-plugin": "1.139.10", + "@testing-library/jest-dom": "6.9.1", + "@testing-library/react": "16.3.2", + "@testing-library/user-event": "14.6.1", + "@types/node": "24.10.1", + "@types/react": "19.2.7", + "@types/react-dom": "19.2.3", + "@vitejs/plugin-react": "5.1.1", + "@vitest/coverage-v8": "2.1.9", + "@vitest/ui": "2.1.9", + "jsdom": "25.0.1", + "tailwindcss": "4.1.17", + "typescript": "5.9.3", + "vite": "7.2.4", + "vitest": "2.1.9" + } +} diff --git a/task-explorer/frontend/public/.gitkeep b/task-explorer/frontend/public/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/task-explorer/frontend/src/components/AutoRefreshSelector.tsx b/task-explorer/frontend/src/components/AutoRefreshSelector.tsx new file mode 100644 index 0000000..e3e70fa --- /dev/null +++ b/task-explorer/frontend/src/components/AutoRefreshSelector.tsx @@ -0,0 +1,170 @@ +export { AutoRefreshSelector }; + +import { useState, useRef, useEffect } from "react"; +import { useClickOutside } from "@/lib/useClickOutside"; + +type RefreshInterval = "manual" | 1000 | 3000 | 5000 | 10000 | 30000 | 60000; + +interface AutoRefreshSelectorProps { + onRefresh: () => void; +} + +function AutoRefreshSelector({ onRefresh }: AutoRefreshSelectorProps) { + const [refreshInterval, setRefreshInterval] = useState("manual"); + const [showDropdown, setShowDropdown] = useState(false); + const [progress, setProgress] = useState(0); + const dropdownRef = useRef(null); + const refreshIntervalRef = useRef(null); + const progressIntervalRef = useRef(null); + const startTimeRef = useRef(Date.now()); + + // Handle click outside to close the dropdown + useClickOutside(dropdownRef, () => setShowDropdown(false), showDropdown); + + // Set up auto-refresh interval + useEffect(() => { + // Clear existing intervals + if (refreshIntervalRef.current) { + clearInterval(refreshIntervalRef.current); + refreshIntervalRef.current = null; + } + if (progressIntervalRef.current) { + clearInterval(progressIntervalRef.current); + progressIntervalRef.current = null; + } + + // Reset progress and start time + setProgress(0); + startTimeRef.current = Date.now(); + + if (refreshInterval === "manual") { + return; + } + + // Set up refresh interval + refreshIntervalRef.current = setInterval(() => { + onRefresh(); + startTimeRef.current = Date.now(); + setProgress(0); + }, refreshInterval); + + // Set up progress update interval (60fps) + progressIntervalRef.current = setInterval(() => { + const elapsed = Date.now() - startTimeRef.current; + const newProgress = Math.min((elapsed / refreshInterval) * 100, 100); + setProgress(newProgress); + }, 16); // ~60fps + + // Cleanup on unmount or when dependencies change + return () => { + if (refreshIntervalRef.current) { + clearInterval(refreshIntervalRef.current); + } + if (progressIntervalRef.current) { + clearInterval(progressIntervalRef.current); + } + }; + }, [refreshInterval, onRefresh]); + + const options: { value: RefreshInterval; label: string }[] = [ + { value: "manual", label: "Manual Refresh" }, + { value: 1000, label: "1s" }, + { value: 3000, label: "3s" }, + { value: 5000, label: "5s" }, + { value: 10000, label: "10s" }, + { value: 30000, label: "30s" }, + { value: 60000, label: "60s" }, + ]; + + const getLabel = () => { + const option = options.find(opt => opt.value === refreshInterval)!; + return refreshInterval === "manual" ? "Refresh" : `Auto: ${option.label}`; + }; + + return ( +
+
+ {/* Main button - always triggers refresh */} + + + {/* Dropdown toggle button */} + +
+ + {/* Dropdown Menu */} + {showDropdown && ( +
+ {options.map((option, index) => ( + + ))} +
+ )} +
+ ); +} diff --git a/task-explorer/frontend/src/components/LoginForm.tsx b/task-explorer/frontend/src/components/LoginForm.tsx new file mode 100644 index 0000000..26032f9 --- /dev/null +++ b/task-explorer/frontend/src/components/LoginForm.tsx @@ -0,0 +1,112 @@ +export { LoginForm }; + +import { useState, type FormEvent } from "react"; +import { useAuth } from "@/lib/AuthContext"; + +// Use BASE_URL for non-root deployments (e.g., /task-explorer) +const basePath = import.meta.env.BASE_URL.replace(/\/$/, ""); + +type Props = { + onSuccess: () => void; +}; + +function LoginForm({ onSuccess }: Props) { + const { checkAuth } = useAuth(); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + setError(""); + setLoading(true); + + try { + const response = await fetch(`${basePath}/api/auth/login`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + body: JSON.stringify({ username, password }), + }); + + if (response.ok) { + // Verify session with server before navigating + await checkAuth(); + onSuccess(); + } else { + const data = await response.json(); + setError(data.error || "Invalid credentials"); + } + } catch (err) { + setError("Failed to login. Please try again."); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+

Task Explorer

+

Sign in to access the task dashboard

+
+
+
+
+ + setUsername(e.target.value)} + disabled={loading} + /> +
+
+ + setPassword(e.target.value)} + disabled={loading} + /> +
+
+ + {error && ( +
+
{error}
+
+ )} + +
+ +
+
+
+
+ ); +} diff --git a/task-explorer/frontend/src/components/MetadataField.tsx b/task-explorer/frontend/src/components/MetadataField.tsx new file mode 100644 index 0000000..dcc1890 --- /dev/null +++ b/task-explorer/frontend/src/components/MetadataField.tsx @@ -0,0 +1,16 @@ +export { MetadataField }; + +interface MetadataFieldProps { + label: string; + value: string; + mono?: boolean; +} + +function MetadataField({ label, value, mono = false }: MetadataFieldProps) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/task-explorer/frontend/src/components/Pagination.tsx b/task-explorer/frontend/src/components/Pagination.tsx new file mode 100644 index 0000000..636c047 --- /dev/null +++ b/task-explorer/frontend/src/components/Pagination.tsx @@ -0,0 +1,82 @@ +export { Pagination }; + +interface PaginationProps { + currentPage: number; + totalPages: number; + totalItems: number; + pageSize: number; + onPageChange: (page: number) => void; + onPageSizeChange: (pageSize: number) => void; +} + +const PAGE_SIZE_OPTIONS = [50, 100, 250, 500, 1000]; + +function Pagination({ + currentPage, + totalPages, + totalItems, + pageSize, + onPageChange, + onPageSizeChange, +}: PaginationProps) { + const startItem = totalItems === 0 ? 0 : (currentPage - 1) * pageSize + 1; + const endItem = Math.min(currentPage * pageSize, totalItems); + + return ( +
+
+ + {startItem}–{endItem} of {totalItems} + +
+ Per page: + +
+
+ +
+ + + + Page {currentPage} of {totalPages} + + + +
+
+ ); +} diff --git a/task-explorer/frontend/src/components/StatsWidget.tsx b/task-explorer/frontend/src/components/StatsWidget.tsx new file mode 100644 index 0000000..d8a995c --- /dev/null +++ b/task-explorer/frontend/src/components/StatsWidget.tsx @@ -0,0 +1,138 @@ +export { StatsWidget }; + +import { useState, useMemo } from "react"; +import type { TaskState, TaskStatEntry } from "@/types/task"; + +interface StatsWidgetProps { + stats?: TaskStatEntry[]; +} + +function StatsWidget({ stats }: StatsWidgetProps) { + const [isExpanded, setIsExpanded] = useState(false); + + const data = useMemo(() => { + const actions: Record = { + pending: 0, + blocked: 0, + running: 0, + completed: 0, + failed: 0, + cancelled: 0, + }; + const workflows: Record = { + pending: 0, + blocked: 0, + running: 0, + completed: 0, + failed: 0, + cancelled: 0, + }; + + if (stats) { + for (const entry of stats) { + if (entry.type === "action" && entry.state in actions) { + actions[entry.state] = entry.count; + } else if (entry.type === "workflow" && entry.state in workflows) { + workflows[entry.state] = entry.count; + } + } + } + + return { actions, workflows }; + }, [stats]); + + // Calculate totals + const totalActions = Object.values(data.actions).reduce((sum, count) => sum + (count || 0), 0); + const totalWorkflows = Object.values(data.workflows).reduce((sum, count) => sum + (count || 0), 0); + const totalTasks = totalActions + totalWorkflows; + + // Status colors + const statusColors: Record = { + pending: "bg-gray-500", + blocked: "bg-orange-500", + running: "bg-blue-500", + completed: "bg-green-500", + failed: "bg-red-500", + cancelled: "bg-gray-400", + }; + + const statuses: TaskState[] = ["pending", "blocked", "running", "completed", "failed", "cancelled"]; + + return ( +
+ + + {isExpanded && ( +
+ {/* Overview */} +
+ + + +
+ + {/* Status breakdown */} +
+

By Status

+ + {statuses.map(status => { + const actionCount = data.actions[status] || 0; + const workflowCount = data.workflows[status] || 0; + const total = actionCount + workflowCount; + + if (total === 0) return null; + + const percentage = totalTasks > 0 ? ((total / totalTasks) * 100).toFixed(1) : "0"; + + return ( +
+
+
+
+ {status} + + {total} ({percentage}%) + +
+
+
+
+
+ Actions: {actionCount} + Workflows: {workflowCount} +
+
+
+ ); + })} +
+
+ )} +
+ ); +} + +function StatCard({ label, value }: { label: string; value: number }) { + return ( +
+
{value}
+
{label}
+
+ ); +} diff --git a/task-explorer/frontend/src/components/TaskDetails.tsx b/task-explorer/frontend/src/components/TaskDetails.tsx new file mode 100644 index 0000000..443d5e3 --- /dev/null +++ b/task-explorer/frontend/src/components/TaskDetails.tsx @@ -0,0 +1,267 @@ +export { TaskDetails }; + +import type { TaskAction, TaskWorkflow, TaskEvent, TaskType } from "@/types/task"; +import { TaskStatusBadge } from "@/components/TaskStatusBadge"; +import { MetadataField } from "@/components/MetadataField"; +import { WorkflowGanttChart } from "@/components/WorkflowGanttChart"; +import { getTaskDuration } from "@/lib/taskUtils"; +import { formatDuration, useFormatTimestamp } from "@/lib/taskFormatUtils"; +import { calculateActionStats, calculateWorkflowStats } from "@/lib/taskStatsUtils"; + +interface TaskDetailsProps { + task: TaskAction | TaskWorkflow; + events: TaskEvent[]; + subTasks: { + actions: TaskAction[]; + workflows: TaskWorkflow[]; + }; + subTaskEvents: Record; + taskType: TaskType; + cancelError?: string | null; + cancelSuccess?: boolean; + onSubTaskClick: (taskId: string, taskType: TaskType) => void; +} + +function TaskDetails({ + task, + events, + subTasks, + subTaskEvents, + taskType, + cancelError, + cancelSuccess, + onSubTaskClick, +}: TaskDetailsProps) { + const formatTimestamp = useFormatTimestamp(); + const actionTask = taskType === "action" ? (task as TaskAction) : null; + const workflowTask = taskType === "workflow" ? (task as TaskWorkflow) : null; + const hasSubtasks = subTasks.actions.length > 0 || subTasks.workflows.length > 0; + + // Calculate stats based on the task type + const actionStats = actionTask ? calculateActionStats(events, actionTask) : null; + + // Convert subTaskEvents Record to Map for workflow stats calculation + const allSubTaskEvents = new Map(Object.entries(subTaskEvents)); + const workflowStats = workflowTask ? calculateWorkflowStats(events, workflowTask, subTasks, allSubTaskEvents) : null; + + return ( +
+ {/* Status Messages */} + {cancelError && ( +
Failed to cancel: {cancelError}
+ )} + + {cancelSuccess && ( +
+ {taskType === "workflow" ? "Workflow" : "Task"} cancelled successfully +
+ )} + + {/* Metadata Grid */} +
+
+ +
+
+ +
+
+ +
+
+ +
+ + {task.started_at && ( +
+ +
+ )} + {task.finished_at && ( +
+ +
+ )} +
+ +
+ + {actionTask && ( + <> +
+ +
+
+ +
+ + )} + + {workflowTask && ( +
+ 0 ? workflowTask.pending_steps.join(", ") : "None"} + /> +
+ )} + + {task.parent && ( + <> +
+ +
+
+ +
+ + )} +
+ + {/* Stats Section */} + {(actionStats || workflowStats) && ( +
+

Stats

+
+ {actionStats && ( + <> +
+ +
+
+ +
+ + )} + {workflowStats && ( + <> +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + )} +
+
+ )} + + {/* Gantt Chart for Workflows */} + {taskType === "workflow" && hasSubtasks && ( +
+ Workflow Timeline +
+ +
+
+ )} + + {/* Input */} + {task.input !== undefined && ( +
+ Input +
+            {JSON.stringify(task.input, null, 2)}
+          
+
+ )} + + {/* Output */} + {task.output && ( +
+ Output +
+            {JSON.stringify(task.output, null, 2)}
+          
+
+ )} + + {/* Error */} + {task.error && ( +
+ Error +
{task.error}
+
+ )} + + {/* Sub-tasks list (collapsible) */} + {hasSubtasks && ( +
+ Sub-tasks +
+ {[ + ...subTasks.actions.map(task => ({ task, type: "action" as const, name: task.action })), + ...subTasks.workflows.map(task => ({ task, type: "workflow" as const, name: task.workflow })), + ].map(({ task: subtask, type, name }) => ( +
onSubTaskClick(subtask.id, type)} + > +
+
+ Step {subtask.parent_step} + {name} + {subtask.id} +
+ +
+
+
+ Start: {formatTimestamp(subtask.run_at)} + {getTaskDuration(subtask)} +
+ {subtask.finished_at && ( +
+ End:   {formatTimestamp(subtask.finished_at)} +
+ )} +
+
+ ))} +
+
+ )} + + {/* Event History (collapsible) */} + {events && events.length > 0 && ( +
+ Event History +
+ {events.map((event: TaskEvent, index: number) => ( +
+
+
+ {event.event_type} +

{formatTimestamp(event.created_at)}

+
+ #{index + 1} +
+ {!!event.details && ( +
+                    {JSON.stringify(event.details, null, 2)}
+                  
+ )} +
+ ))} +
+
+ )} +
+ ); +} diff --git a/task-explorer/frontend/src/components/TaskStatusBadge.tsx b/task-explorer/frontend/src/components/TaskStatusBadge.tsx new file mode 100644 index 0000000..f39e0fa --- /dev/null +++ b/task-explorer/frontend/src/components/TaskStatusBadge.tsx @@ -0,0 +1,26 @@ +export { TaskStatusBadge }; + +import type { TaskState } from "@/types/task"; + +interface TaskStatusBadgeProps { + status: TaskState; +} + +const statusStyles: Record = { + pending: "bg-gray-700 text-gray-200", + blocked: "bg-orange-900 text-orange-200", + running: "bg-blue-900 text-blue-200", + completed: "bg-green-900 text-green-200", + failed: "bg-red-900 text-red-200", + cancelled: "bg-gray-600 text-gray-300", +}; + +function TaskStatusBadge({ status }: TaskStatusBadgeProps) { + const styles = statusStyles[status] || statusStyles.pending; + + return ( + + {status} + + ); +} diff --git a/task-explorer/frontend/src/components/TasksList.tsx b/task-explorer/frontend/src/components/TasksList.tsx new file mode 100644 index 0000000..1e429ac --- /dev/null +++ b/task-explorer/frontend/src/components/TasksList.tsx @@ -0,0 +1,213 @@ +export { TasksList }; + +import { Link } from "@tanstack/react-router"; +import { useState, memo } from "react"; +import type { TaskAction, TaskWorkflow } from "@/types/task"; +import { TaskStatusBadge } from "@/components/TaskStatusBadge"; +import { getTaskDuration, getTaskStartTime, getTaskName } from "@/lib/taskUtils"; +import { useFormatTimestamp } from "@/lib/taskFormatUtils"; + +interface TasksListProps { + tasks: (TaskAction | TaskWorkflow)[]; +} + +type SortColumn = "type" | "name" | "status" | "created" | "start" | "finish" | "duration"; +type SortDirection = "asc" | "desc"; + +// Sort indicator component (defined outside to avoid re-creation on each render) +const SortIndicator = memo( + ({ + column, + sortColumn, + sortDirection, + }: { + column: SortColumn; + sortColumn: SortColumn; + sortDirection: SortDirection; + }) => { + if (sortColumn !== column) return null; + return {sortDirection === "asc" ? "↑" : "↓"}; + }, +); + +function TasksList({ tasks }: TasksListProps) { + const formatTimestamp = useFormatTimestamp(); + const [sortColumn, setSortColumn] = useState("created"); + const [sortDirection, setSortDirection] = useState("desc"); + if (tasks.length === 0) { + return
No tasks found. Try adjusting your filters.
; + } + + // Handle column header click + const handleSort = (column: SortColumn) => { + if (sortColumn === column) { + // Toggle direction if same column + setSortDirection(sortDirection === "asc" ? "desc" : "asc"); + } else { + // Set a new column, default to ascending + setSortColumn(column); + setSortDirection("asc"); + } + }; + + // Sort tasks based on the current sort column and direction + const sortedTasks = [...tasks].sort((a, b) => { + let aValue: string | number | Date; + let bValue: string | number | Date; + + switch (sortColumn) { + case "type": + aValue = a.type; + bValue = b.type; + break; + case "name": + aValue = getTaskName(a); + bValue = getTaskName(b); + break; + case "status": + aValue = a.state; + bValue = b.state; + break; + case "created": + aValue = new Date(a.created_at).getTime(); + bValue = new Date(b.created_at).getTime(); + break; + case "start": + aValue = new Date(getTaskStartTime(a)).getTime(); + bValue = new Date(getTaskStartTime(b)).getTime(); + break; + case "finish": + aValue = a.finished_at ? new Date(a.finished_at).getTime() : 0; + bValue = b.finished_at ? new Date(b.finished_at).getTime() : 0; + break; + case "duration": { + const aStart = new Date(getTaskStartTime(a)); + const bStart = new Date(getTaskStartTime(b)); + aValue = a.finished_at ? new Date(a.finished_at).getTime() - aStart.getTime() : 0; + bValue = b.finished_at ? new Date(b.finished_at).getTime() - bStart.getTime() : 0; + break; + } + default: + aValue = a.created_at; + bValue = b.created_at; + } + + // Compare values + if (typeof aValue === "string" && typeof bValue === "string") { + return sortDirection === "asc" ? aValue.localeCompare(bValue) : bValue.localeCompare(aValue); + } + + const numA = typeof aValue === "number" ? aValue : 0; + const numB = typeof bValue === "number" ? bValue : 0; + return sortDirection === "asc" ? numA - numB : numB - numA; + }); + + return ( +
+ + + + + + + + + + + + + + {sortedTasks.map(task => { + // Get start time - for workflows use started_at, for actions use run_at + const startTime = getTaskStartTime(task); + + return ( + + + + + + + + + + ); + })} + +
handleSort("type")} + > + Type + + handleSort("name")} + > + Name + + handleSort("status")} + > + Status + + handleSort("created")} + > + Created + + handleSort("start")} + > + Start + + handleSort("finish")} + > + Finish + + handleSort("duration")} + > + Duration + +
+ + + {task.type} + + + + +
{getTaskName(task)}
+
{task.id}
+ +
+ + + + + + {formatTimestamp(task.created_at)} + + + + {formatTimestamp(startTime)} + + + + {task.finished_at ? formatTimestamp(task.finished_at) : -} + + + + {task.finished_at ? getTaskDuration(task) : "-"} + +
+
+ ); +} diff --git a/task-explorer/frontend/src/components/TimezoneSelector.tsx b/task-explorer/frontend/src/components/TimezoneSelector.tsx new file mode 100644 index 0000000..165bfd5 --- /dev/null +++ b/task-explorer/frontend/src/components/TimezoneSelector.tsx @@ -0,0 +1,62 @@ +export { TimezoneSelector }; + +import { useState, useRef } from "react"; +import { useTimezone } from "@/lib/TimezoneContext"; +import { TIMEZONE } from "@/lib/taskFormatUtils"; +import { useClickOutside } from "@/lib/useClickOutside"; + +function TimezoneSelector() { + const { timezone, setTimezone } = useTimezone(); + const [showDropdown, setShowDropdown] = useState(false); + const dropdownRef = useRef(null); + + // Handle click outside to close dropdown + useClickOutside(dropdownRef, () => setShowDropdown(false), showDropdown); + + return ( +
+ + + {/* Dropdown Menu */} + {showDropdown && ( +
+ + +
+ )} +
+ ); +} diff --git a/task-explorer/frontend/src/components/WorkflowGanttChart.module.css b/task-explorer/frontend/src/components/WorkflowGanttChart.module.css new file mode 100644 index 0000000..6481b53 --- /dev/null +++ b/task-explorer/frontend/src/components/WorkflowGanttChart.module.css @@ -0,0 +1,9 @@ +/* Workflow Gantt Chart Styles */ + +/* + * Override vis-timeline's default label padding to prevent overlap with scrollbar + * !important is necessary because vis-timeline applies inline styles + */ +.timelineContainer :global(.vis-label) { + padding-left: 15px !important; +} diff --git a/task-explorer/frontend/src/components/WorkflowGanttChart.tsx b/task-explorer/frontend/src/components/WorkflowGanttChart.tsx new file mode 100644 index 0000000..f2a0bc9 --- /dev/null +++ b/task-explorer/frontend/src/components/WorkflowGanttChart.tsx @@ -0,0 +1,408 @@ +export { WorkflowGanttChart }; + +import { useEffect, useRef, useCallback } from "react"; +import { Timeline } from "vis-timeline/standalone"; +import { DataSet } from "vis-data"; +import type { TaskAction, TaskWorkflow, TaskState, TaskType } from "@/types/task"; +import { useFormatTimestamp, formatDuration } from "@/lib/taskFormatUtils"; +import "vis-timeline/styles/vis-timeline-graph2d.css"; +import styles from "./WorkflowGanttChart.module.css"; + +interface WorkflowGanttChartProps { + parentWorkflow?: TaskWorkflow; + subTasks: { + actions: TaskAction[]; + workflows: TaskWorkflow[]; + }; + onTaskClick: (taskId: string, taskType: TaskType) => void; +} + +// Custom event for chart refresh +const CHART_REFRESH_EVENT = "gantt-chart-refresh"; + +// Branded types for type-safe timeline item IDs +type WorkflowParentId = `workflow-parent-${string}`; +type ActionTimelineId = `action-${string}`; +type WorkflowTimelineId = `workflow-${string}`; +type TimelineItemId = WorkflowParentId | ActionTimelineId | WorkflowTimelineId; + +// Type-safe ID factory functions +const TimelineId = { + workflowParent: (id: string): WorkflowParentId => `workflow-parent-${id}`, + action: (id: string): ActionTimelineId => `action-${id}`, + workflow: (id: string): WorkflowTimelineId => `workflow-${id}`, +} as const; + +interface TimelineItem { + id: TimelineItemId; + content: string; + start: Date; + end: Date; + group: string; + className: string; + style: string; + title: string; + taskData: { + id: string; + type: TaskType; + state: TaskState; + }; +} + +interface TimelineSelectEvent { + items: string[]; + event: Event; +} + +// State colors matching the main timeline +const STATE_COLORS: Record = { + pending: "#9CA3AF", // gray + blocked: "#8B5CF6", // purple + running: "#3B82F6", // blue + completed: "#10B981", // green + failed: "#EF4444", // red + cancelled: "#F59E0B", // orange +}; + +// Chart configuration constants +const CHART_CONFIG = { + MAX_HEIGHT_PX: 400, // Maximum chart height in pixels + PADDING_RATIO: 0.05, // Padding on each side of timeline items (5%) + DEFAULT_PENDING_DURATION_MS: 5 * 60 * 1000, // Default duration for pending tasks (5 minutes) +} as const; + +function WorkflowGanttChart({ parentWorkflow, subTasks, onTaskClick }: WorkflowGanttChartProps) { + const formatTimestamp = useFormatTimestamp(); + const timelineRef = useRef(null); + const timelineInstanceRef = useRef(null); + const itemsDataSetRef = useRef | null>(null); + + // Store window padding info for reset capability + const windowPaddingRef = useRef<{ minTime: number; maxTime: number; padding: number } | null>(null); + + useEffect(() => { + if (!timelineRef.current) return; + + // Helper to calculate start/end times for a task + const calculateTaskTimes = (task: TaskAction | TaskWorkflow) => { + const start = task.run_at ? new Date(task.run_at) : new Date(task.created_at); + let end: Date; + + if (task.finished_at) { + // Completed/failed/canceled task - use actual finish time + end = new Date(task.finished_at); + } else if (task.state === "running") { + // Running task - use current time as the end + end = new Date(); + } else { + // Pending/blocked task - estimate duration + end = new Date(start.getTime() + CHART_CONFIG.DEFAULT_PENDING_DURATION_MS); + } + + return { start, end }; + }; + + // For sub-workflows, calculate the union of all their children's times + const calculateWorkflowTimeSpan = (workflow: TaskWorkflow) => { + // For now, we just use the workflow's own times + // In a more complex implementation, we could fetch the workflow's children + // and calculate the union of their start/end times + return calculateTaskTimes(workflow); + }; + + // Calculate overall workflow span from all subtasks + let workflowStart: Date | null = null; + let workflowEnd: Date | null = null; + + if (parentWorkflow) { + // Collect all subtask times to determine the workflow span + const allTimes = [ + ...subTasks.actions.map(calculateTaskTimes), + ...subTasks.workflows.map(calculateWorkflowTimeSpan), + ]; + + if (allTimes.length > 0) { + workflowStart = new Date(Math.min(...allTimes.map(t => t.start.getTime()))); + workflowEnd = new Date(Math.max(...allTimes.map(t => t.end.getTime()))); + } else { + // Fallback: If the workflow has no subtasks, use the parent workflow's own times + const parentTimes = calculateTaskTimes(parentWorkflow); + workflowStart = parentTimes.start; + workflowEnd = parentTimes.end; + } + } + + // Convert subtasks to timeline items + const items = [ + ...subTasks.actions.map(action => { + const times = calculateTaskTimes(action); + const durationMs = times.end.getTime() - times.start.getTime(); + + return { + id: TimelineId.action(action.id), + content: `⚙️ ${action.action}`, + start: times.start, + end: times.end, + group: action.parent_step?.toString() || "0", + className: `task-${action.state}`, + style: `background-color: ${STATE_COLORS[action.state]}; border-color: ${STATE_COLORS[action.state]}; color: white;`, + title: `
+ ${action.action}
+ State: ${action.state}
+ Step: ${action.parent_step}
+ Start: ${formatTimestamp(times.start)}
+ End: ${formatTimestamp(times.end)}
+ Duration: ${formatDuration(durationMs)} +
`, + taskData: { id: action.id, type: "action" as const, state: action.state }, + }; + }), + ...subTasks.workflows.map(workflow => { + const times = calculateWorkflowTimeSpan(workflow); + const durationMs = times.end.getTime() - times.start.getTime(); + + return { + id: TimelineId.workflow(workflow.id), + content: `🔄 ${workflow.workflow}`, + start: times.start, + end: times.end, + group: workflow.parent_step?.toString() || "0", + className: `task-${workflow.state}`, + style: `background-color: ${STATE_COLORS[workflow.state]}; border-color: ${STATE_COLORS[workflow.state]}; color: white;`, + title: `
+ ${workflow.workflow}
+ State: ${workflow.state}
+ Step: ${workflow.parent_step}
+ Start: ${formatTimestamp(times.start)}
+ End: ${formatTimestamp(times.end)}
+ Duration: ${formatDuration(durationMs)} +
`, + taskData: { id: workflow.id, type: "workflow" as const, state: workflow.state }, + }; + }), + ]; + + // Add workflow-level entry if we have a parent workflow and calculated span + if (parentWorkflow && workflowStart && workflowEnd) { + const workflowDurationMs = workflowEnd.getTime() - workflowStart.getTime(); + items.unshift({ + id: TimelineId.workflowParent(parentWorkflow.id), + content: `📊 ${parentWorkflow.workflow}`, + start: workflowStart, + end: workflowEnd, + group: "workflow", + className: `task-${parentWorkflow.state} workflow-parent`, + style: `background-color: ${STATE_COLORS[parentWorkflow.state]}; border-color: ${STATE_COLORS[parentWorkflow.state]}; color: white; font-weight: bold;`, + title: `
+ ${parentWorkflow.workflow} (Overall)
+ State: ${parentWorkflow.state}
+ Start: ${formatTimestamp(workflowStart)}
+ End: ${formatTimestamp(workflowEnd)}
+ Duration: ${formatDuration(workflowDurationMs)} +
`, + taskData: { id: parentWorkflow.id, type: "workflow" as const, state: parentWorkflow.state }, + }); + } + + if (items.length === 0) { + // No subtasks to display + return; + } + + const itemsDataSet = new DataSet(items); + itemsDataSetRef.current = itemsDataSet; + + // Create groups based on parent_step + const stepNumbers = new Set(); + [...subTasks.actions, ...subTasks.workflows].forEach(task => { + if (task.parent_step != null) { + stepNumbers.add(task.parent_step.toString()); + } + }); + + const groupsArray = Array.from(stepNumbers) + .sort((a, b) => parseInt(a) - parseInt(b)) + .map(step => ({ + id: step, + content: `Step ${step}`, + order: parseInt(step), + })); + + // Add a workflow group at the top if we have a parent workflow + if (parentWorkflow && workflowStart && workflowEnd) { + groupsArray.unshift({ + id: "workflow", + content: "Workflow", + order: -1, // Ensure it appears at the top + }); + } + + const groups = new DataSet(groupsArray); + + // Timeline options + const options = { + width: "100%", + maxHeight: `${CHART_CONFIG.MAX_HEIGHT_PX}px`, + verticalScroll: true, + zoomKey: "ctrlKey" as const, + margin: { + item: { horizontal: 10, vertical: 5 }, + }, + orientation: "top" as const, + zoomMin: 10, // 10 milliseconds (0.01 seconds) + zoomMax: 1000 * 60 * 60 * 24 * 365, // 1 year + stack: true, + stackSubgroups: true, + showCurrentTime: true, + tooltip: { + followMouse: true, + overflowMethod: "cap" as const, + }, + format: { + minorLabels: { + millisecond: "SSS[ms]", + second: "s[s]", + minute: "HH:mm", + hour: "HH:mm", + weekday: "ddd D", + day: "D", + week: "w", + month: "MMM", + year: "YYYY", + }, + majorLabels: { + millisecond: "HH:mm:ss", + second: "HH:mm:ss", + minute: "ddd D MMMM", + hour: "ddd D MMMM", + weekday: "MMMM YYYY", + day: "MMMM YYYY", + week: "MMMM YYYY", + month: "YYYY", + year: "", + }, + }, + }; + + // Calculate the actual time span of items for proper padding + const itemTimes = items.map(item => ({ + start: new Date(item.start).getTime(), + end: new Date(item.end).getTime(), + })); + const minTime = Math.min(...itemTimes.map(t => t.start)); + const maxTime = Math.max(...itemTimes.map(t => t.end)); + const actualRange = maxTime - minTime; + + // Add padding on each side of the actual item span + const padding = actualRange * CHART_CONFIG.PADDING_RATIO; + + // Store window padding info for event-based reset + windowPaddingRef.current = { minTime, maxTime, padding }; + + // Create or update the timeline + if (!timelineInstanceRef.current) { + timelineInstanceRef.current = new Timeline(timelineRef.current, itemsDataSet, groups, options); + } else { + timelineInstanceRef.current.setItems(itemsDataSet); + timelineInstanceRef.current.setGroups(groups); + } + + // Set the window with padding + // Use setTimeout to ensure it happens after vis-timeline's internal fit() operations complete + const timeoutId = setTimeout(() => { + if (timelineInstanceRef.current) { + timelineInstanceRef.current.setWindow(new Date(minTime - padding), new Date(maxTime + padding), { + animation: false, + }); + } + }, 0); + + // Cleanup: clear timeout after unmount or before the next effect run + return () => { + clearTimeout(timeoutId); + }; + }, [parentWorkflow, subTasks, formatTimestamp]); + + // Separate cleanup effect for timeline destruction on unmount + useEffect(() => { + return () => { + if (timelineInstanceRef.current) { + timelineInstanceRef.current.destroy(); + timelineInstanceRef.current = null; + } + }; + }, []); + + // Listen for global refresh events to reset the chart view + useEffect(() => { + const handleRefresh = () => { + if (timelineInstanceRef.current && windowPaddingRef.current) { + const { minTime, maxTime, padding } = windowPaddingRef.current; + timelineInstanceRef.current.setWindow(new Date(minTime - padding), new Date(maxTime + padding), { + animation: false, + }); + } + }; + + window.addEventListener(CHART_REFRESH_EVENT, handleRefresh); + + return () => { + window.removeEventListener(CHART_REFRESH_EVENT, handleRefresh); + }; + }, []); + + // Separate effect for event handler registration to avoid unnecessary timeline re-initialization + const handleSelect = useCallback( + (properties: TimelineSelectEvent) => { + if (properties.items.length > 0) { + const itemId = properties.items[0] as TimelineItemId; + const item = itemsDataSetRef.current?.get(itemId) as TimelineItem | null; + if (item?.taskData) { + onTaskClick(item.taskData.id, item.taskData.type); + } + } + }, + [onTaskClick], + ); + + useEffect(() => { + if (!timelineInstanceRef.current) return; + + timelineInstanceRef.current.off("select"); + timelineInstanceRef.current.on("select", handleSelect); + + return () => { + timelineInstanceRef.current?.off("select"); + }; + }, [handleSelect]); + + // Check if there are any subtasks + const hasSubtasks = subTasks.actions.length > 0 || subTasks.workflows.length > 0; + + if (!hasSubtasks) { + return ( +
+

No sub-tasks to display

+
+ ); + } + + return ( +
+ {/* State Legend */} +
+ {Object.entries(STATE_COLORS).map(([state, color]) => ( +
+
+ {state} +
+ ))} +
+ + {/* Timeline Chart */} +
+
+
+
+ ); +} diff --git a/task-explorer/frontend/src/index.css b/task-explorer/frontend/src/index.css new file mode 100644 index 0000000..3b0f5fc --- /dev/null +++ b/task-explorer/frontend/src/index.css @@ -0,0 +1,28 @@ +@import 'tailwindcss'; + +@layer base { + *, + ::after, + ::before, + ::backdrop, + ::file-selector-button { + border-color: var(--color-gray-200, currentcolor); + } +} + +html { + color-scheme: dark; + font-size: 14px; /* Smaller base font size (default is 16px) */ +} + +* { + @apply border-gray-800; +} + +body { + @apply bg-gray-900 text-gray-200; +} + +.collapsible-heading { + @apply text-lg font-bold text-gray-100 mb-4 cursor-pointer select-none; +} diff --git a/task-explorer/frontend/src/lib/AuthContext.tsx b/task-explorer/frontend/src/lib/AuthContext.tsx new file mode 100644 index 0000000..a3bc483 --- /dev/null +++ b/task-explorer/frontend/src/lib/AuthContext.tsx @@ -0,0 +1,64 @@ +export { AuthProvider, useAuth }; + +import { createContext, useContext, useState, useEffect, ReactNode } from "react"; + +interface AuthContextType { + isAuthenticated: boolean; + loading: boolean; + logout: () => Promise; + checkAuth: () => Promise; +} + +const AuthContext = createContext(undefined); + +// Use BASE_URL for non-root deployments (e.g., /task-explorer) +const basePath = import.meta.env.BASE_URL.replace(/\/$/, ""); + +function AuthProvider({ children }: { children: ReactNode }) { + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [loading, setLoading] = useState(true); + + const checkAuth = async () => { + try { + const response = await fetch(`${basePath}/api/auth/status`, { + credentials: "include", + }); + const data = await response.json(); + setIsAuthenticated(data.authenticated); + } catch (error) { + setIsAuthenticated(false); + } finally { + setLoading(false); + } + }; + + const logout = async () => { + // Always clear local auth state, regardless of API response + // This prevents UI/state mismatch if the request fails + setIsAuthenticated(false); + try { + await fetch(`${basePath}/api/auth/logout`, { + method: "POST", + credentials: "include", + }); + } catch (error) { + console.error("Logout request failed:", error); + } + }; + + useEffect(() => { + checkAuth(); + }, []); + + return ( + {children} + ); +} + +function useAuth() { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error("useAuth must be used within an AuthProvider"); + } + return context; +} diff --git a/task-explorer/frontend/src/lib/TimezoneContext.tsx b/task-explorer/frontend/src/lib/TimezoneContext.tsx new file mode 100644 index 0000000..aae4c01 --- /dev/null +++ b/task-explorer/frontend/src/lib/TimezoneContext.tsx @@ -0,0 +1,43 @@ +import { createContext, useContext, useState, useEffect, type ReactNode } from "react"; +import { TIMEZONE, type Timezone } from "@/lib/taskFormatUtils"; + +interface TimezoneContextType { + timezone: Timezone; + setTimezone: (tz: Timezone) => void; +} + +const TimezoneContext = createContext(undefined); + +/** + * Parse timezone from localStorage with fallback to local + */ +function parseTimezone(value: string | null): Timezone { + return value === TIMEZONE.UTC ? TIMEZONE.UTC : TIMEZONE.Local; +} + +export function TimezoneProvider({ children }: { children: ReactNode }) { + // Load timezone from localStorage or default to Local + const [timezone, setTimezoneState] = useState(() => { + const stored = localStorage.getItem("task-explorer-timezone"); + return parseTimezone(stored); + }); + + // Save to localStorage whenever it changes + useEffect(() => { + localStorage.setItem("task-explorer-timezone", timezone); + }, [timezone]); + + const setTimezone = (tz: Timezone) => { + setTimezoneState(tz); + }; + + return {children}; +} + +export function useTimezone(): TimezoneContextType { + const context = useContext(TimezoneContext); + if (context === undefined) { + throw new Error("useTimezone must be used within a TimezoneProvider"); + } + return context; +} diff --git a/task-explorer/frontend/src/lib/api.ts b/task-explorer/frontend/src/lib/api.ts new file mode 100644 index 0000000..b06a242 --- /dev/null +++ b/task-explorer/frontend/src/lib/api.ts @@ -0,0 +1,90 @@ +export { listTasks, getTaskDetails, cancelTask }; + +import type { + ListTasksResponse, + TaskDetailsResponse, + CancelTaskResponse, + ListTasksParams, + TaskType, +} from "@/types/task"; + +// API Base URL Configuration +// Uses relative paths for cloud-agnostic routing - infrastructure handles the rest: +// - Dev: Vite proxy forwards /api to localhost:3000 (see vite.config.ts server.proxy) +// - Prod: Ingress/reverse proxy routes requests to the backend +// The base path (e.g., /tasks) is derived from Vite's BASE_URL config +const basePath = import.meta.env.BASE_URL.replace(/\/$/, ""); +const API_BASE_URL = `${basePath}/api`; + +// Generic fetch wrapper with error handling +async function fetchAPI(endpoint: string, options?: RequestInit): Promise { + const url = `${API_BASE_URL}${endpoint}`; + + try { + const response = await fetch(url, { + credentials: "include", // Include cookies for authentication + headers: { + "Content-Type": "application/json", + ...options?.headers, + }, + ...options, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ error: response.statusText })); + throw new Error(errorData.error || `HTTP ${response.status}: ${response.statusText}`); + } + + return response.json(); + } catch (error) { + if (error instanceof Error) { + throw error; + } + throw new Error("An unknown error occurred"); + } +} + +// Build query string from the params object +function buildQueryString(params: Record & { [key: string]: unknown }): string { + const searchParams = new URLSearchParams(); + + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + if (Array.isArray(value)) { + // For arrays, join with commas (backend expects comma-separated string) + searchParams.append(key, value.join(",")); + } else { + searchParams.append(key, String(value)); + } + } + }); + + const queryString = searchParams.toString(); + return queryString ? `?${queryString}` : ""; +} + +// API Client functions + +/** + * List tasks with optional filters + */ +async function listTasks(params: ListTasksParams = {}): Promise { + const queryString = buildQueryString(params); + return fetchAPI(`/tasks${queryString}`); +} + +/** + * Get task details by type and ID + */ +async function getTaskDetails(type: TaskType, id: string): Promise { + return fetchAPI(`/tasks/${type}/${id}`); +} + +/** + * Cancel a task by type and ID + */ +async function cancelTask(type: TaskType, id: string): Promise { + return fetchAPI(`/tasks/${type}/${id}/cancel`, { + method: "POST", + }); +} diff --git a/task-explorer/frontend/src/lib/queryClient.ts b/task-explorer/frontend/src/lib/queryClient.ts new file mode 100644 index 0000000..91b878e --- /dev/null +++ b/task-explorer/frontend/src/lib/queryClient.ts @@ -0,0 +1,28 @@ +import { QueryClient } from "@tanstack/react-query"; + +// Query caching constants +const QUERY_STALE_TIME_MS = 30 * 1000; // 30 seconds +const QUERY_GC_TIME_MS = 5 * 60 * 1000; // 5 minutes +const QUERY_RETRY_COUNT = 1; // Retry once on failure + +// Create a React Query client with default options +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + // Data is considered stale after 30 seconds + staleTime: QUERY_STALE_TIME_MS, + // Cache data for 5 minutes + gcTime: QUERY_GC_TIME_MS, + // Retry failed requests once + retry: QUERY_RETRY_COUNT, + // Refetch on window focus for fresh data + refetchOnWindowFocus: true, + // Don't refetch on mount if data is fresh + refetchOnMount: false, + }, + mutations: { + // Don't retry mutations by default + retry: false, + }, + }, +}); diff --git a/task-explorer/frontend/src/lib/taskFormatUtils.ts b/task-explorer/frontend/src/lib/taskFormatUtils.ts new file mode 100644 index 0000000..77d9307 --- /dev/null +++ b/task-explorer/frontend/src/lib/taskFormatUtils.ts @@ -0,0 +1,127 @@ +export { TIMEZONE, type Timezone, formatTimestampWithMs, formatDuration, useFormatTimestamp }; + +import { useCallback } from "react"; +import { useTimezone } from "@/lib/TimezoneContext"; + +/** + * Timezone constants + */ +const TIMEZONE = { + Local: "local", + UTC: "utc", +} as const; + +/** + * Timezone type - either "local" or "utc" + */ +type Timezone = (typeof TIMEZONE)[keyof typeof TIMEZONE]; + +/** + * Format timestamp with millisecond precision + * @param timestamp - ISO string or Date object + * @param timezone - Timezone ("local" or "utc") + * Format (UTC): "2025-12-17T08:46:44.397Z" + * Format (Local): "2025-12-17 09:46:44.397-05:00" (with timezone offset) + */ +function formatTimestampWithMs(timestamp: string | Date, timezone: Timezone): string { + const date = new Date(timestamp); + + if (timezone === TIMEZONE.UTC) { + return date.toISOString(); + } + + // Local timezone formatting with milliseconds and offset + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + const hours = String(date.getHours()).padStart(2, "0"); + const minutes = String(date.getMinutes()).padStart(2, "0"); + const seconds = String(date.getSeconds()).padStart(2, "0"); + const ms = String(date.getMilliseconds()).padStart(3, "0"); + + // Get timezone offset in minutes and format as ±HH:MM + const offsetMinutes = -date.getTimezoneOffset(); // Note: getTimezoneOffset returns inverse + const offsetSign = offsetMinutes >= 0 ? "+" : "-"; + const offsetHours = String(Math.floor(Math.abs(offsetMinutes) / 60)).padStart(2, "0"); + const offsetMins = String(Math.abs(offsetMinutes) % 60).padStart(2, "0"); + const offset = `${offsetSign}${offsetHours}:${offsetMins}`; + + return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${ms}${offset}`; +} + +/** + * Format duration in milliseconds to human-readable verbose format + * Shows all non-zero time units: years, months, days, hours, minutes, seconds, milliseconds + * @param durationMs - Duration in milliseconds + * @param showMs - Whether to always include milliseconds (default: true) + * Examples: "234ms", "5s 230ms", "2h 34m 56s 120ms", "1y 2m 3d 12h 34m 56s 789ms" + * With showMs=false: "5s", "2h 34m 56s", "1y 2m 3d 12h 34m 56s" + */ +function formatDuration(durationMs: number, showMs: boolean = true): string { + if (durationMs < 0) return showMs ? "0ms" : "0s"; + + // Time unit constants (approximations for months/years) + const msPerSecond = 1000; + const msPerMinute = msPerSecond * 60; + const msPerHour = msPerMinute * 60; + const msPerDay = msPerHour * 24; + const msPerMonth = msPerDay * 30; // Approximate + const msPerYear = msPerDay * 365; // Approximate + + let remaining = Math.floor(durationMs); + + // Calculate each unit + const years = Math.floor(remaining / msPerYear); + remaining -= years * msPerYear; + + const months = Math.floor(remaining / msPerMonth); + remaining -= months * msPerMonth; + + const days = Math.floor(remaining / msPerDay); + remaining -= days * msPerDay; + + const hours = Math.floor(remaining / msPerHour); + remaining -= hours * msPerHour; + + const minutes = Math.floor(remaining / msPerMinute); + remaining -= minutes * msPerMinute; + + const seconds = Math.floor(remaining / msPerSecond); + remaining -= seconds * msPerSecond; + + const milliseconds = remaining; + + // Build output, omitting zero values + const parts: string[] = []; + if (years > 0) parts.push(`${years}y`); + if (months > 0) parts.push(`${months}m`); + if (days > 0) parts.push(`${days}d`); + if (hours > 0) parts.push(`${hours}h`); + if (minutes > 0) parts.push(`${minutes}m`); + if (seconds > 0) parts.push(`${seconds}s`); + + // Handle milliseconds based on showMs flag + if (showMs || parts.length === 0) { + // Include ms when showMs is true, or when there are no other units + parts.push(`${milliseconds}ms`); + } + // Otherwise, omit ms when showMs is false and there are other units + + return parts.join(" "); +} + +/** + * Hook that provides timezone-aware timestamp formatting + * Uses the current timezone setting from TimezoneContext + * @returns A format function that automatically applies the current timezone + */ +function useFormatTimestamp() { + const { timezone } = useTimezone(); + + return useCallback( + (timestamp: string | Date): string => { + return formatTimestampWithMs(timestamp, timezone); + }, + [timezone], + ); +} diff --git a/task-explorer/frontend/src/lib/taskStatsUtils.ts b/task-explorer/frontend/src/lib/taskStatsUtils.ts new file mode 100644 index 0000000..1cadd85 --- /dev/null +++ b/task-explorer/frontend/src/lib/taskStatsUtils.ts @@ -0,0 +1,208 @@ +export { calculateActionStats, calculateWorkflowStats }; +export type { ActionStats, WorkflowStats }; + +import type { TaskAction, TaskEvent, TaskWorkflow } from "@/types/task"; + +// Types for stats +interface ActionStats { + runTime: number; // milliseconds + waitTime: number; // milliseconds +} + +interface WorkflowStats { + workflowRunTime: number; // milliseconds + totalRunTime: number; // milliseconds + workflowWaitTime: number; // milliseconds + totalWaitTime: number; // milliseconds + wakeups: number; // count +} + +/** + * Calculate stats for an action task based on its events + * Wait time = time from created/run_at until first "running" event (initial queue time) + * Run time = sum of all time spent in "running" state + * @param events - List of events for the action (must be sorted by created_at ascending) + * @param task - The action task + * @returns ActionStats with runTime and waitTime in milliseconds + */ +function calculateActionStats(events: TaskEvent[], task: TaskAction): ActionStats { + // Sort events by time to ensure the correct order + const sortedEvents = [...events].sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()); + + let runTime = 0; + let waitTime = 0; + let hasStartedRunning = false; + + // Wait time starts from run_at time or created time, whichever is later + const runAtTime = new Date(task.run_at).getTime(); + const createdTime = new Date(task.created_at).getTime(); + const waitStartsAt = Math.max(runAtTime, createdTime); + + // Track running periods + let runningStartTime: number | null = null; + + // Process each event + for (const event of sortedEvents) { + const eventTime = new Date(event.created_at).getTime(); + const eventType = event.event_type; + + // First "running" event ends wait time + if (eventType === "running" && !hasStartedRunning) { + waitTime = eventTime - waitStartsAt; + hasStartedRunning = true; + runningStartTime = eventTime; + } else if (eventType === "running" && hasStartedRunning) { + // Subsequent running events (after worker-failure) + runningStartTime = eventTime; + } + + // Events that end running state + if ( + eventType === "completed" + || eventType === "failed" + || eventType === "cancelled" + || eventType === "worker-failure" + ) { + // End running time if we were running + if (runningStartTime !== null) { + runTime += eventTime - runningStartTime; + runningStartTime = null; + } + } + } + + // If still running, add time up to now + if (runningStartTime !== null) { + runTime += Date.now() - runningStartTime; + } + + // If never started running, wait time is from start until now + if (!hasStartedRunning) { + waitTime = Date.now() - waitStartsAt; + } + + return { runTime, waitTime }; +} + +/** + * Calculate stats for a workflow task based on its events and subtasks + * Wait time = time from created/run_at until first "running" event (initial queue time) + * Run time = sum of all time spent in "running" state + * Blocked/suspended time is not counted in either metric + * @param events - List of events for the workflow (must be sorted by created_at ascending) + * @param task - The workflow task + * @param subTasks - Subtasks of the workflow (actions and workflows) + * @param allSubTaskEvents - Map of task ID to its events (for recursive calculation) + * @returns WorkflowStats with various timing metrics + */ +function calculateWorkflowStats( + events: TaskEvent[], + task: TaskWorkflow, + subTasks?: { actions: TaskAction[]; workflows: TaskWorkflow[] }, + allSubTaskEvents?: Map, +): WorkflowStats { + // Sort events by time to ensure the correct order + const sortedEvents = [...events].sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()); + + let workflowRunTime = 0; + let workflowWaitTime = 0; + let wakeups = 0; + let hasStartedRunning = false; + + // Wait time starts from run_at time or created time, whichever is later + const runAtTime = new Date(task.run_at).getTime(); + const createdTime = new Date(task.created_at).getTime(); + const waitStartsAt = Math.max(runAtTime, createdTime); + + // Track running periods + let runningStartTime: number | null = null; + + // Process each event + for (const event of sortedEvents) { + const eventTime = new Date(event.created_at).getTime(); + const eventType = event.event_type; + + // Handle running state + if (eventType === "running") { + wakeups++; + + // First "running" event ends wait time + if (!hasStartedRunning) { + workflowWaitTime = eventTime - waitStartsAt; + hasStartedRunning = true; + } + + // Start tracking running time + runningStartTime = eventTime; + } + + // Suspended state ends running (workflow-specific) + if (eventType === "suspended") { + // End running time if we were running + if (runningStartTime !== null) { + workflowRunTime += eventTime - runningStartTime; + runningStartTime = null; + } + // Note: blocked/suspended time is not counted as wait time + } + + // Terminal events + if (eventType === "completed" || eventType === "failed" || eventType === "cancelled") { + // End running time if we were running + if (runningStartTime !== null) { + workflowRunTime += eventTime - runningStartTime; + runningStartTime = null; + } + } + + // worker-failure returns workflow to pending + if (eventType === "worker-failure") { + // End running time if we were running + if (runningStartTime !== null) { + workflowRunTime += eventTime - runningStartTime; + runningStartTime = null; + } + // Note: time after worker-failure is not counted as wait time + } + } + + // If still running, add time up to now + if (runningStartTime !== null) { + workflowRunTime += Date.now() - runningStartTime; + } + + // If never started running, wait time is from start until now + if (!hasStartedRunning) { + workflowWaitTime = Date.now() - waitStartsAt; + } + + // Calculate total run time and wait time recursively + let totalRunTime = workflowRunTime; + let totalWaitTime = workflowWaitTime; + + if (subTasks && allSubTaskEvents) { + // Sum up all subtask run times and wait times + for (const action of subTasks.actions) { + const actionEvents = allSubTaskEvents.get(action.id) || []; + const actionStats = calculateActionStats(actionEvents, action); + totalRunTime += actionStats.runTime; + totalWaitTime += actionStats.waitTime; + } + + for (const workflow of subTasks.workflows) { + const workflowEvents = allSubTaskEvents.get(workflow.id) || []; + // Recursive call for nested workflows + const workflowStats = calculateWorkflowStats(workflowEvents, workflow, undefined, allSubTaskEvents); + totalRunTime += workflowStats.totalRunTime; + totalWaitTime += workflowStats.totalWaitTime; + } + } + + return { + workflowRunTime, + totalRunTime, + workflowWaitTime, + totalWaitTime, + wakeups, + }; +} diff --git a/task-explorer/frontend/src/lib/taskUtils.ts b/task-explorer/frontend/src/lib/taskUtils.ts new file mode 100644 index 0000000..b86d442 --- /dev/null +++ b/task-explorer/frontend/src/lib/taskUtils.ts @@ -0,0 +1,64 @@ +export { getTaskStartTime, getTaskDuration, getTaskName, canCancelTask }; + +import type { TaskAction, TaskWorkflow } from "@/types/task"; +import { isTaskAction, isTaskWorkflow } from "@/types/task"; +import { formatDuration } from "@/lib/taskFormatUtils"; + +/** + * Get the start time for a task (workflows use started_at if available, actions use run_at) + */ +function getTaskStartTime(task: TaskAction | TaskWorkflow): string { + return isTaskWorkflow(task) && task.started_at ? task.started_at : task.run_at; +} + +/** + * Calculate task duration in human-readable format with milliseconds + * Duration is the total elapsed time from when the task is ready to run (max of run_at or created_at) + * to when it finishes (or current time if still running). + * + * Duration = Wait Time + Run Time + Other Time, where: + * - Wait Time = initial queue time until first "running" event + * - Run Time = time spent in "running" state + * - Other Time = time spent blocked, suspended, or retrying (Duration - Wait - Run) + * + * Examples: "234ms", "5.23s (5,230ms)", "5.40m (324,000ms)" + */ +function getTaskDuration(task: TaskAction | TaskWorkflow): string { + if (!task.run_at) return "Not started"; + + // Duration starts from when a task is ready to run (consistent with stats calculation) + const runAtTime = new Date(task.run_at).getTime(); + const createdTime = new Date(task.created_at).getTime(); + const startTime = Math.max(runAtTime, createdTime); + + const start = new Date(startTime); + const end = task.finished_at ? new Date(task.finished_at) : new Date(); + const durationMs = end.getTime() - start.getTime(); + + // Handle negative durations (clock skew, data corruption, etc.) + if (durationMs < 0) return "Invalid duration"; + + return formatDuration(durationMs); +} + +/** + * Get task name from action or workflow + */ +function getTaskName(task: TaskAction | TaskWorkflow): string { + if (isTaskAction(task)) { + return task.action; + } + return task.workflow; +} + +/** + * Check if the task can be canceled. + * Actions: only when running. + * Workflows: when running or blocked (waiting on children). + */ +function canCancelTask(task: TaskAction | TaskWorkflow): boolean { + if (isTaskWorkflow(task)) { + return task.state === "running" || task.state === "blocked"; + } + return task.state === "running"; +} diff --git a/task-explorer/frontend/src/lib/useClickOutside.ts b/task-explorer/frontend/src/lib/useClickOutside.ts new file mode 100644 index 0000000..aaa8782 --- /dev/null +++ b/task-explorer/frontend/src/lib/useClickOutside.ts @@ -0,0 +1,38 @@ +export { useClickOutside }; + +import { useEffect, useRef } from "react"; + +/** + * Custom hook that handles clicks outside of a referenced element + * @param ref - React ref object pointing to the element + * @param handler - Callback function to execute when clicking outside + * @param enabled - Whether the hook is currently active (default: true) + */ +function useClickOutside( + ref: { current: T | null }, + handler: () => void, + enabled: boolean = true, +) { + // Stabilize handler to avoid re-registering event listeners on every render + const handlerRef = useRef(handler); + + useEffect(() => { + handlerRef.current = handler; + }, [handler]); + + useEffect(() => { + if (!enabled) return; + + const handleClickOutside = (event: MouseEvent) => { + if (ref.current && !ref.current.contains(event.target as Node)) { + handlerRef.current(); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [ref, enabled]); +} diff --git a/task-explorer/frontend/src/lib/useDebounce.ts b/task-explorer/frontend/src/lib/useDebounce.ts new file mode 100644 index 0000000..3a04798 --- /dev/null +++ b/task-explorer/frontend/src/lib/useDebounce.ts @@ -0,0 +1,14 @@ +export { useDebounce }; + +import { useState, useEffect } from "react"; + +function useDebounce(value: T, delayMs: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => setDebouncedValue(value), delayMs); + return () => clearTimeout(timer); + }, [value, delayMs]); + + return debouncedValue; +} diff --git a/task-explorer/frontend/src/main.tsx b/task-explorer/frontend/src/main.tsx new file mode 100644 index 0000000..9407ee7 --- /dev/null +++ b/task-explorer/frontend/src/main.tsx @@ -0,0 +1,32 @@ +import { StrictMode } from "react"; +import ReactDOM from "react-dom/client"; +import { RouterProvider, createRouter } from "@tanstack/react-router"; +import { routeTree } from "./routeTree.gen"; +import "./index.css"; + +// Set up a Router instance +const basepath = import.meta.env.BASE_URL.replace(/\/$/, "") || "/"; +const router = createRouter({ + routeTree, + basepath, + defaultPreload: "intent", + scrollRestoration: true, +}); + +// Register things for typesafety +declare module "@tanstack/react-router" { + interface Register { + router: typeof router; + } +} + +const rootElement = document.getElementById("app")!; + +if (!rootElement.innerHTML) { + const root = ReactDOM.createRoot(rootElement); + root.render( + + + , + ); +} diff --git a/task-explorer/frontend/src/routeTree.gen.ts b/task-explorer/frontend/src/routeTree.gen.ts new file mode 100644 index 0000000..2b042cf --- /dev/null +++ b/task-explorer/frontend/src/routeTree.gen.ts @@ -0,0 +1,113 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as LoginRouteImport } from './routes/login' +import { Route as IndexRouteImport } from './routes/index' +import { Route as TasksIndexRouteImport } from './routes/tasks/index' +import { Route as TasksTypeIdRouteImport } from './routes/tasks/$type.$id' + +const LoginRoute = LoginRouteImport.update({ + id: '/login', + path: '/login', + getParentRoute: () => rootRouteImport, +} as any) +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) +const TasksIndexRoute = TasksIndexRouteImport.update({ + id: '/tasks/', + path: '/tasks/', + getParentRoute: () => rootRouteImport, +} as any) +const TasksTypeIdRoute = TasksTypeIdRouteImport.update({ + id: '/tasks/$type/$id', + path: '/tasks/$type/$id', + getParentRoute: () => rootRouteImport, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/login': typeof LoginRoute + '/tasks': typeof TasksIndexRoute + '/tasks/$type/$id': typeof TasksTypeIdRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/login': typeof LoginRoute + '/tasks': typeof TasksIndexRoute + '/tasks/$type/$id': typeof TasksTypeIdRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/login': typeof LoginRoute + '/tasks/': typeof TasksIndexRoute + '/tasks/$type/$id': typeof TasksTypeIdRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: '/' | '/login' | '/tasks' | '/tasks/$type/$id' + fileRoutesByTo: FileRoutesByTo + to: '/' | '/login' | '/tasks' | '/tasks/$type/$id' + id: '__root__' | '/' | '/login' | '/tasks/' | '/tasks/$type/$id' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + LoginRoute: typeof LoginRoute + TasksIndexRoute: typeof TasksIndexRoute + TasksTypeIdRoute: typeof TasksTypeIdRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/login': { + id: '/login' + path: '/login' + fullPath: '/login' + preLoaderRoute: typeof LoginRouteImport + parentRoute: typeof rootRouteImport + } + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + '/tasks/': { + id: '/tasks/' + path: '/tasks' + fullPath: '/tasks' + preLoaderRoute: typeof TasksIndexRouteImport + parentRoute: typeof rootRouteImport + } + '/tasks/$type/$id': { + id: '/tasks/$type/$id' + path: '/tasks/$type/$id' + fullPath: '/tasks/$type/$id' + preLoaderRoute: typeof TasksTypeIdRouteImport + parentRoute: typeof rootRouteImport + } + } +} + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + LoginRoute: LoginRoute, + TasksIndexRoute: TasksIndexRoute, + TasksTypeIdRoute: TasksTypeIdRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() diff --git a/task-explorer/frontend/src/routes/__root.tsx b/task-explorer/frontend/src/routes/__root.tsx new file mode 100644 index 0000000..2d5eed2 --- /dev/null +++ b/task-explorer/frontend/src/routes/__root.tsx @@ -0,0 +1,104 @@ +export const Route = createRootRoute({ + component: RootComponent, +}); + +import { Component, type ErrorInfo, type ReactNode, useEffect } from "react"; +import { Outlet, createRootRoute, useNavigate, useLocation } from "@tanstack/react-router"; +import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { queryClient } from "@/lib/queryClient"; +import { TimezoneProvider } from "@/lib/TimezoneContext"; +import { AuthProvider, useAuth } from "@/lib/AuthContext"; + +interface ErrorBoundaryProps { + children: ReactNode; +} + +interface ErrorBoundaryState { + hasError: boolean; + error: Error | null; +} + +class ErrorBoundary extends Component { + constructor(props: ErrorBoundaryProps) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo): void { + console.error("App error:", error, errorInfo); + } + + render(): ReactNode { + if (this.state.hasError) { + return ( +
+
+

Something went wrong

+

An unexpected error occurred. Please try refreshing the page.

+ {this.state.error && ( +
+                {this.state.error.toString()}
+              
+ )} + +
+
+ ); + } + + return this.props.children; + } +} + +function ProtectedRoute() { + const { isAuthenticated, loading } = useAuth(); + const navigate = useNavigate(); + const location = useLocation(); + + useEffect(() => { + if (!loading && !isAuthenticated && location.pathname !== "/login") { + navigate({ to: "/login" }); + } + }, [isAuthenticated, loading, location.pathname, navigate]); + + if (loading) { + return ( +
+
Loading...
+
+ ); + } + + // Block protected content until redirect completes + // Prevents flashing cached data or triggering unauthorized API calls + if (!isAuthenticated && location.pathname !== "/login") { + return null; + } + + return ; +} + +function RootComponent() { + return ( + + + + + + + + + + + ); +} diff --git a/task-explorer/frontend/src/routes/index.tsx b/task-explorer/frontend/src/routes/index.tsx new file mode 100644 index 0000000..ce57fd0 --- /dev/null +++ b/task-explorer/frontend/src/routes/index.tsx @@ -0,0 +1,7 @@ +import { createFileRoute, redirect } from "@tanstack/react-router"; + +export const Route = createFileRoute("/")({ + beforeLoad: () => { + throw redirect({ to: "/tasks" }); + }, +}); diff --git a/task-explorer/frontend/src/routes/login.tsx b/task-explorer/frontend/src/routes/login.tsx new file mode 100644 index 0000000..b5bd77f --- /dev/null +++ b/task-explorer/frontend/src/routes/login.tsx @@ -0,0 +1,13 @@ +export { Route }; + +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { LoginForm } from "@/components/LoginForm"; + +const Route = createFileRoute("/login")({ + component: LoginPage, +}); + +function LoginPage() { + const navigate = useNavigate(); + return navigate({ to: "/tasks" })} />; +} diff --git a/task-explorer/frontend/src/routes/tasks/$type.$id.tsx b/task-explorer/frontend/src/routes/tasks/$type.$id.tsx new file mode 100644 index 0000000..d582ed2 --- /dev/null +++ b/task-explorer/frontend/src/routes/tasks/$type.$id.tsx @@ -0,0 +1,163 @@ +export const Route = createFileRoute("/tasks/$type/$id")({ + component: TaskDetailPage, +}); + +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { getTaskDetails, cancelTask } from "@/lib/api"; +import { TaskStatusBadge } from "@/components/TaskStatusBadge"; +import { TaskDetails } from "@/components/TaskDetails"; +import { getTaskName, canCancelTask } from "@/lib/taskUtils"; +import { useAuth } from "@/lib/AuthContext"; +import type { TaskType } from "@/types/task"; + +function TaskDetailPage() { + const { type, id } = Route.useParams(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const { isAuthenticated } = useAuth(); + const [cancelError, setCancelError] = useState(null); + + // Fetch task details + // structuralSharing: false ensures re-render on every refetch, even if data is identical. + // This is needed so run time in Stats (which calls Date.now()) updates when refreshing a running task. + const { data, isLoading, error } = useQuery({ + queryKey: ["task", type, id], + enabled: isAuthenticated, + queryFn: () => getTaskDetails(type as TaskType, id), + structuralSharing: false, + }); + + // Cancel task mutation + const cancelMutation = useMutation({ + mutationFn: () => cancelTask(type as TaskType, id), + onSuccess: () => { + // Invalidate and refetch + queryClient.invalidateQueries({ queryKey: ["task", type, id] }); + queryClient.invalidateQueries({ queryKey: ["tasks"] }); + }, + onError: (err: Error) => { + setCancelError(err.message); + }, + }); + + const handleCancel = () => { + if (window.confirm(`Are you sure you want to cancel this ${type}?`)) { + setCancelError(null); + cancelMutation.mutate(); + } + }; + + const handleSubTaskClick = (taskId: string, taskType: TaskType) => { + navigate({ to: "/tasks/$type/$id", params: { type: taskType, id: taskId } }); + }; + + const handleBackClick = () => { + if (window.history.length > 1) { + window.history.back(); + } else { + navigate({ to: "/tasks" }); + } + }; + + const handleRefresh = () => { + setCancelError(null); + queryClient.invalidateQueries({ queryKey: ["task", type, id] }); + // Dispatch custom event to refresh Gantt chart + window.dispatchEvent(new Event("gantt-chart-refresh")); + }; + + if (isLoading) { + return ( +
+
Loading task details...
+
+ ); + } + + if (error) { + return ( +
+
Error loading task: {error.message}
+ +
+ ); + } + + if (!data) { + return ( +
+
Task not found
+
+ ); + } + + const { task, events, subTasks, subTaskEvents } = data; + const taskName = getTaskName(task); + const canCancel = canCancelTask(task); + + return ( +
+ {/* Breadcrumb */} +
+ +
+ + {/* Header */} +
+
+
+
+ + {type} + + +
+

{taskName}

+

{task.id}

+
+ +
+ {canCancel && ( + + )} + + +
+
+ + +
+
+ ); +} diff --git a/task-explorer/frontend/src/routes/tasks/index.tsx b/task-explorer/frontend/src/routes/tasks/index.tsx new file mode 100644 index 0000000..f128649 --- /dev/null +++ b/task-explorer/frontend/src/routes/tasks/index.tsx @@ -0,0 +1,617 @@ +export { Route }; + +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { useQuery } from "@tanstack/react-query"; +import { useEffect, useRef } from "react"; +import { addHours, addDays, addWeeks, startOfDay, endOfDay, startOfWeek, endOfWeek } from "date-fns"; +import { listTasks } from "@/lib/api"; +import { TasksList } from "@/components/TasksList"; +import { StatsWidget } from "@/components/StatsWidget"; +import { TimezoneSelector } from "@/components/TimezoneSelector"; +import { AutoRefreshSelector } from "@/components/AutoRefreshSelector"; +import { Pagination } from "@/components/Pagination"; +import type { TaskState, TaskType } from "@/types/task"; +import { useTimezone } from "@/lib/TimezoneContext"; +import { TIMEZONE } from "@/lib/taskFormatUtils"; +import { useAuth } from "@/lib/AuthContext"; +import { useDebounce } from "@/lib/useDebounce"; + +// Search params schema +type TasksSearchParams = { + startTime?: string; + endTime?: string; + statuses?: string; + types?: string; + q?: string; + page?: number; + pageSize?: number; +}; + +const Route = createFileRoute("/tasks/")({ + component: TasksListPage, + validateSearch: (search: Record): TasksSearchParams => { + const pageRaw = Number(search.page); + const pageSizeRaw = Number(search.pageSize); + return { + startTime: typeof search.startTime === "string" ? search.startTime : undefined, + endTime: typeof search.endTime === "string" ? search.endTime : undefined, + statuses: typeof search.statuses === "string" ? search.statuses : undefined, + types: typeof search.types === "string" ? search.types : undefined, + q: typeof search.q === "string" ? search.q : undefined, + page: !isNaN(pageRaw) && pageRaw >= 1 ? pageRaw : undefined, + pageSize: !isNaN(pageSizeRaw) && pageSizeRaw >= 1 ? pageSizeRaw : undefined, + }; + }, +}); + +function TasksListPage() { + const navigate = useNavigate({ from: Route.fullPath }); + const search = Route.useSearch(); + const { timezone } = useTimezone(); + const { logout } = useAuth(); + + // Initialize from URL or defaults + const now = new Date(); + const defaultStartTime = new Date(now.getTime() - 24 * 60 * 60 * 1000); // Last 24h + const defaultEndTime = now; + + // Parse time from URL or use defaults + const startTime = search.startTime ? new Date(search.startTime) : defaultStartTime; + const endTime = search.endTime ? new Date(search.endTime) : defaultEndTime; + + // Parse statuses from URL or use all by default + const selectedStatuses: Set = + search.statuses && search.statuses.length > 0 ? new Set(search.statuses.split(",") as TaskState[]) + : search.statuses === "" ? new Set() + : new Set(["pending", "blocked", "running", "completed", "failed", "cancelled"]); + + // Parse types from URL or use all by default + const selectedTypes: Set = + search.types && search.types.length > 0 ? new Set(search.types.split(",") as TaskType[]) + : search.types === "" ? new Set() + : new Set(["action", "workflow"]); + + // Pagination state from URL + const currentPage = search.page ?? 1; + const pageSize = search.pageSize ?? 250; + const offset = (currentPage - 1) * pageSize; + + // Get search query from URL + const searchQuery = search.q?.trim() || ""; + + // Debounce search query to avoid flooding server while typing + const debouncedSearch = useDebounce(searchQuery, 500); + + // Reset to page 1 when debounced search changes + const prevDebouncedSearch = useRef(debouncedSearch); + useEffect(() => { + if (prevDebouncedSearch.current !== debouncedSearch) { + prevDebouncedSearch.current = debouncedSearch; + navigate({ + search: prev => ({ ...prev, page: 1 }), + replace: true, + resetScroll: false, + }); + } + }, [debouncedSearch, navigate]); + + // Helper to update URL search params + const updateSearch = (updates: Partial) => { + navigate({ + search: prev => ({ ...prev, ...updates }), + replace: true, + resetScroll: false, + }); + }; + + // Initialize URL params if not present (only run on mount) + // eslint-disable-next-line react-hooks/exhaustive-deps -- intentional: read initial values once, don't re-run on changes + useEffect(() => { + if (!search.startTime || !search.endTime || !search.statuses || !search.types) { + updateSearch({ + startTime: search.startTime || startTime.toISOString(), + endTime: search.endTime || endTime.toISOString(), + statuses: search.statuses || Array.from(selectedStatuses).join(","), + types: search.types || Array.from(selectedTypes).join(","), + }); + } + }, []); + + const { isAuthenticated } = useAuth(); + + // Fetch tasks list with pagination and filters + const { data, isLoading, isFetching, error, refetch } = useQuery({ + queryKey: [ + "tasks", + { + startTime: startTime.toISOString(), + endTime: endTime.toISOString(), + statuses: Array.from(selectedStatuses).join(","), + types: Array.from(selectedTypes).join(","), + search: debouncedSearch, // Use debounced search in query key + offset, + limit: pageSize, + }, + ], + enabled: isAuthenticated, + placeholderData: prev => prev, // Keep previous data visible while loading next page + queryFn: () => + listTasks({ + startTime: startTime.toISOString(), + endTime: endTime.toISOString(), + status: Array.from(selectedStatuses).join(","), + types: Array.from(selectedTypes).join(","), + limit: pageSize, + offset, + search: debouncedSearch, // Send debounced search to backend + }), + }); + + // Combine actions and workflows in server-computed order + const filteredTasks = (() => { + if (!data) return []; + if (!data.pageOrder) { + // Fallback for non-time-range queries (no pageOrder) + return [ + ...data.actions.map(a => ({ ...a, type: "action" as const })), + ...data.workflows.map(w => ({ ...w, type: "workflow" as const })), + ]; + } + const actionMap = new Map(data.actions.map(a => [a.id, { ...a, type: "action" as const }])); + const workflowMap = new Map(data.workflows.map(w => [w.id, { ...w, type: "workflow" as const }])); + return data.pageOrder + .map(({ id, type }) => (type === "action" ? actionMap.get(id) : workflowMap.get(id))) + .filter((t): t is NonNullable => t != null); + })(); + + // Calculate total pages + const totalPages = data ? Math.ceil(data.total / pageSize) : 0; + + // Handle time range preset buttons (reset to page 1 when changing time range) + const setQuickTimeRange = ( + range: + | "last-1h" + | "last-24h" + | "last-week" + | "next-1h" + | "next-24h" + | "next-week" + | "today" + | "this-week" + | "last-and-next-24h" + | "last-and-next-1h", + ) => { + const now = new Date(); + let start: Date; + let end: Date; + + switch (range) { + case "last-1h": + start = new Date(now.getTime() - 60 * 60 * 1000); + end = now; + break; + case "last-24h": + start = new Date(now.getTime() - 24 * 60 * 60 * 1000); + end = now; + break; + case "last-week": + start = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); + end = now; + break; + case "next-1h": + start = now; + end = addHours(now, 1); + break; + case "next-24h": + start = now; + end = addDays(now, 1); + break; + case "next-week": + start = now; + end = addWeeks(now, 1); + break; + case "today": + start = startOfDay(now); + end = endOfDay(now); + break; + case "this-week": + start = startOfWeek(now); + end = endOfWeek(now); + break; + case "last-and-next-24h": + start = new Date(now.getTime() - 24 * 60 * 60 * 1000); + end = new Date(now.getTime() + 24 * 60 * 60 * 1000); + break; + case "last-and-next-1h": + start = new Date(now.getTime() - 60 * 60 * 1000); + end = new Date(now.getTime() + 60 * 60 * 1000); + break; + } + + updateSearch({ + startTime: start.toISOString(), + endTime: end.toISOString(), + page: 1, // Reset to first page when changing time range + }); + }; + + // Handle status filter toggle (reset to page 1 when changing filters) + const toggleStatus = (status: TaskState) => { + const newStatuses = new Set(selectedStatuses); + if (newStatuses.has(status)) { + newStatuses.delete(status); + } else { + newStatuses.add(status); + } + updateSearch({ statuses: Array.from(newStatuses).join(","), page: 1 }); + }; + + // Select all status filters + const selectAllStatuses = () => { + updateSearch({ statuses: "pending,blocked,running,completed,failed,cancelled", page: 1 }); + }; + + // Deselect all status filters + const selectNoneStatuses = () => { + updateSearch({ statuses: "", page: 1 }); + }; + + // Handle type filter toggle (reset to page 1 when changing filters) + const toggleType = (type: TaskType) => { + const newTypes = new Set(selectedTypes); + if (newTypes.has(type)) { + newTypes.delete(type); + } else { + newTypes.add(type); + } + updateSearch({ types: Array.from(newTypes).join(","), page: 1 }); + }; + + // Select all type filters + const selectAllTypes = () => { + updateSearch({ types: "action,workflow", page: 1 }); + }; + + // Deselect all type filters + const selectNoneTypes = () => { + updateSearch({ types: "", page: 1 }); + }; + + // Reset all filters to defaults + const resetFilters = () => { + const now = new Date(); + const defaultStart = new Date(now.getTime() - 24 * 60 * 60 * 1000); + updateSearch({ + startTime: defaultStart.toISOString(), + endTime: now.toISOString(), + statuses: "pending,blocked,running,completed,failed,cancelled", + types: "action,workflow", + q: "", + page: 1, + pageSize: 250, + }); + }; + + // Handle page change + const handlePageChange = (newPage: number) => { + updateSearch({ page: newPage }); + }; + + // Handle page size change (reset to page 1 when changing page size) + const handlePageSizeChange = (newPageSize: number) => { + updateSearch({ pageSize: newPageSize, page: 1 }); + }; + + // Handle logout + const handleLogout = async () => { + await logout(); + navigate({ to: "/login" }); + }; + + // Format date for datetime-local input (respects timezone setting) + const formatDateTimeInput = (date: Date): string => { + const isUTC = timezone === TIMEZONE.UTC; + const year = isUTC ? date.getUTCFullYear() : date.getFullYear(); + const month = String((isUTC ? date.getUTCMonth() : date.getMonth()) + 1).padStart(2, "0"); + const day = String(isUTC ? date.getUTCDate() : date.getDate()).padStart(2, "0"); + const hours = String(isUTC ? date.getUTCHours() : date.getHours()).padStart(2, "0"); + const minutes = String(isUTC ? date.getUTCMinutes() : date.getMinutes()).padStart(2, "0"); + return `${year}-${month}-${day}T${hours}:${minutes}`; + }; + + // Parse datetime-local input based on timezone setting + const parseDateTimeInput = (dateTimeString: string): string => { + if (timezone === TIMEZONE.UTC) { + // Interpret as UTC time + return new Date(dateTimeString + "Z").toISOString(); + } else { + // Interpret as local time + return new Date(dateTimeString).toISOString(); + } + }; + + const statusOptions: TaskState[] = ["pending", "blocked", "running", "completed", "failed", "cancelled"]; + + return ( +
+ {/* Header */} +
+
+
+

Tasks

+

Browse and filter tasks and workflows by time range and status

+
+
+ + + + refetch()} /> +
+
+
+ + {/* Search Filter */} +
+
+ +
+ updateSearch({ q: e.target.value })} + className="w-full px-4 py-2 pr-10 border border-gray-600 rounded-md text-sm text-gray-100 bg-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + {search.q && ( + + )} +
+

+ Filters tasks by name (partial match) or ID (exact match). Case-insensitive. +

+
+
+ + {/* Time Range Controls */} +
+
+ {/* Date/Time Inputs */} +
+
+ + updateSearch({ startTime: parseDateTimeInput(e.target.value) })} + className="w-full px-3 py-2 border border-gray-600 rounded-md text-sm text-gray-100 bg-gray-900" + /> +
+
+ + updateSearch({ endTime: parseDateTimeInput(e.target.value) })} + className="w-full px-3 py-2 border border-gray-600 rounded-md text-sm text-gray-100 bg-gray-900" + /> +
+
+ + {/* Preset Buttons */} +
+
+ Past: + + + +
+ +
+ Future: + + + +
+ +
+ Other: + + + + +
+
+
+
+ + {/* Type Filters */} +
+
+ Filter by Type: + + + + +
+
+ + {/* Status Filters */} +
+
+ Filter by Status: + {statusOptions.map(status => ( + + ))} + + +
+
+ + {/* Content Area */} +
+ {/* Stats Widget */} +
+ +
+ + {/* Loading state */} + {isLoading && ( +
+
Loading tasks...
+
+ )} + + {/* Error state */} + {error &&
Error loading tasks: {error.message}
} + + {/* Tasks list */} + {!isLoading && !error && ( +
+ {data && data.total > 0 && ( + + )} + + {data && data.total > 0 && ( + + )} +
+ )} +
+
+ ); +} diff --git a/task-explorer/frontend/src/types/task.ts b/task-explorer/frontend/src/types/task.ts new file mode 100644 index 0000000..f4a838d --- /dev/null +++ b/task-explorer/frontend/src/types/task.ts @@ -0,0 +1,130 @@ +export type { + TaskState, + TaskType, + TaskAction, + TaskWorkflow, + Task, + TaskActionEvent, + TaskWorkflowEvent, + TaskEvent, + ListTasksResponse, + TaskDetailsResponse, + CancelTaskResponse, + ListTasksParams, + TaskStatEntry, +}; +export { isTaskAction, isTaskWorkflow }; + +// Task state enum +type TaskState = "pending" | "blocked" | "running" | "completed" | "failed" | "cancelled"; + +// Task type enum +type TaskType = "action" | "workflow"; + +// Base task fields common to both actions and workflows +interface BaseTask { + id: string; + created_at: string; + input?: Record; + run_at: string; + timeout_seconds: number; + parent: string | null; + parent_step: number | null; + state: TaskState; + worker: string | null; + output?: Record | null; + error: string | null; + started_at: string | null; + finished_at: string | null; +} + +// Task Action type +interface TaskAction extends BaseTask { + type: "action"; + action: string; + max_retries: number; + attempts: number; +} + +// Task Workflow type +interface TaskWorkflow extends BaseTask { + type: "workflow"; + workflow: string; + pending_steps: number[]; +} + +// Union type for tasks +type Task = TaskAction | TaskWorkflow; + +// Type guards +function isTaskAction(task: Task): task is TaskAction { + return "action" in task; +} + +function isTaskWorkflow(task: Task): task is TaskWorkflow { + return "workflow" in task; +} + +// Event types for task actions +interface TaskActionEvent { + id: string; + task_id: string; + created_at: string; + event_type: string; + details: Record | null; +} + +// Event types for task workflows +interface TaskWorkflowEvent { + id: string; + task_id: string; + created_at: string; + event_type: string; + details: Record | null; +} + +type TaskEvent = TaskActionEvent | TaskWorkflowEvent; + +// API Response types +interface TaskStatEntry { + type: "action" | "workflow"; + state: TaskState; + count: number; +} + +interface ListTasksResponse { + actions: TaskAction[]; + workflows: TaskWorkflow[]; + total: number; // Total count across all pages + stats?: TaskStatEntry[]; // Counts by type+state + pageOrder?: { id: string; type: "action" | "workflow" }[]; // Server-computed ordering + limit: number; // Page size + offset: number; // Current offset +} + +interface TaskDetailsResponse { + task: TaskAction | TaskWorkflow; + events: TaskEvent[]; + subTasks: { + actions: TaskAction[]; + workflows: TaskWorkflow[]; + }; + subTaskEvents: Record; +} + +interface CancelTaskResponse { + success: boolean; + task: TaskAction | TaskWorkflow; +} + +// Filter and search parameters +interface ListTasksParams { + status?: string; + types?: string; + startTime?: string; + endTime?: string; + limit?: number; + offset?: number; + search?: string; // Search by task name or ID + [key: string]: unknown; +} diff --git a/task-explorer/frontend/tests/auth/AuthContext.test.tsx b/task-explorer/frontend/tests/auth/AuthContext.test.tsx new file mode 100644 index 0000000..ec8b282 --- /dev/null +++ b/task-explorer/frontend/tests/auth/AuthContext.test.tsx @@ -0,0 +1,145 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor, fireEvent } from "@testing-library/react"; +import { AuthProvider, useAuth } from "@/lib/AuthContext"; +import { ReactNode } from "react"; + +// Test component that uses AuthContext +function TestComponent() { + const { isAuthenticated, loading, logout } = useAuth(); + + if (loading) return
Loading...
; + + return ( +
+
{isAuthenticated ? "Authenticated" : "Not Authenticated"}
+ +
+ ); +} + +describe("AuthContext", () => { + beforeEach(() => { + // Reset fetch mock before each test + vi.resetAllMocks(); + }); + + it("should provide initial loading state", async () => { + global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ authenticated: false }), + } as Response), + ); + + render( + + + , + ); + + expect(screen.getByText("Loading...")).toBeInTheDocument(); + + // Wait for async state update to complete to avoid act() warnings + await waitFor(() => { + expect(screen.queryByText("Loading...")).not.toBeInTheDocument(); + }); + }); + + it("should check auth status on mount", async () => { + global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ authenticated: false }), + } as Response), + ); + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId("auth-status")).toHaveTextContent("Not Authenticated"); + }); + + expect(fetch).toHaveBeenCalledWith("/api/auth/status", { + credentials: "include", + }); + }); + + it("should set authenticated state when user is logged in", async () => { + global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ authenticated: true }), + } as Response), + ); + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId("auth-status")).toHaveTextContent("Authenticated"); + }); + }); + + it("should handle auth check errors gracefully", async () => { + global.fetch = vi.fn(() => Promise.reject(new Error("Network error"))); + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId("auth-status")).toHaveTextContent("Not Authenticated"); + }); + }); + + it("should logout and update state", async () => { + // First set authenticated state + global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ authenticated: true }), + } as Response), + ); + + const { rerender } = render( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId("auth-status")).toHaveTextContent("Authenticated"); + }); + + // Now mock logout + global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ success: true }), + } as Response), + ); + + const logoutButton = screen.getByText("Logout"); + fireEvent.click(logoutButton); + + await waitFor(() => { + expect(fetch).toHaveBeenCalledWith("/api/auth/logout", { + method: "POST", + credentials: "include", + }); + }); + + await waitFor(() => { + expect(screen.getByTestId("auth-status")).toHaveTextContent("Not Authenticated"); + }); + }); +}); diff --git a/task-explorer/frontend/tests/auth/login.test.tsx b/task-explorer/frontend/tests/auth/login.test.tsx new file mode 100644 index 0000000..362087f --- /dev/null +++ b/task-explorer/frontend/tests/auth/login.test.tsx @@ -0,0 +1,169 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { AuthProvider } from "@/lib/AuthContext"; +import { LoginForm } from "@/components/LoginForm"; + +// LoginForm contains all the form logic. LoginPage (the route) is just a thin wrapper +// that passes navigate({ to: "/tasks" }) as the onSuccess callback. + +const onSuccess = vi.fn(); + +function renderLoginForm() { + return render( + + + , + ); +} + +describe("Login Form", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it("should render login form", async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ authenticated: false }), + }); + + renderLoginForm(); + + expect(screen.getByPlaceholderText("Username")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("Password")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /sign in/i })).toBeInTheDocument(); + + // Wait for async auth check to complete to avoid act() warnings + await waitFor(() => { + expect(fetch).toHaveBeenCalledTimes(1); + }); + }); + + it("should call onSuccess and verify session after successful login", async () => { + const user = userEvent.setup(); + + global.fetch = vi + .fn() + // Mock initial auth check (AuthProvider on mount) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ authenticated: false }), + }) + // Mock login request + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ success: true }), + }) + // Mock checkAuth() called after successful login + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ authenticated: true }), + }); + + renderLoginForm(); + + await user.type(screen.getByPlaceholderText("Username"), "admin"); + await user.type(screen.getByPlaceholderText("Password"), "password123"); + await user.click(screen.getByRole("button", { name: /sign in/i })); + + await waitFor(() => { + expect(fetch).toHaveBeenCalledWith( + "/api/auth/login", + expect.objectContaining({ + method: "POST", + credentials: "include", + body: JSON.stringify({ username: "admin", password: "password123" }), + }), + ); + expect(onSuccess).toHaveBeenCalledOnce(); + }); + }); + + it("should display error on failed login", async () => { + const user = userEvent.setup(); + + global.fetch = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ authenticated: false }), + }) + .mockResolvedValueOnce({ + ok: false, + status: 401, + json: () => Promise.resolve({ error: "Invalid credentials" }), + }); + + renderLoginForm(); + + await user.type(screen.getByPlaceholderText("Username"), "wrong"); + await user.type(screen.getByPlaceholderText("Password"), "wrong"); + await user.click(screen.getByRole("button", { name: /sign in/i })); + + await waitFor(() => { + expect(screen.getByRole("alert")).toHaveTextContent("Invalid credentials"); + }); + }); + + it("should disable form during submission", async () => { + const user = userEvent.setup(); + + let resolveLogin: (value: unknown) => void; + const loginPromise = new Promise(resolve => { + resolveLogin = resolve; + }); + + global.fetch = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ authenticated: false }), + }) + .mockReturnValueOnce(loginPromise); + + renderLoginForm(); + + await user.type(screen.getByPlaceholderText("Username"), "admin"); + await user.type(screen.getByPlaceholderText("Password"), "password"); + await user.click(screen.getByRole("button", { name: /sign in/i })); + + await waitFor(() => { + expect(screen.getByPlaceholderText("Username")).toBeDisabled(); + expect(screen.getByPlaceholderText("Password")).toBeDisabled(); + expect(screen.getByRole("button")).toBeDisabled(); + expect(screen.getByRole("button")).toHaveTextContent("Signing in..."); + }); + + resolveLogin!({ + ok: true, + json: () => Promise.resolve({ success: true }), + }); + + await waitFor(() => { + expect(screen.getByPlaceholderText("Username")).not.toBeDisabled(); + }); + }); + + it("should handle network errors", async () => { + const user = userEvent.setup(); + + global.fetch = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ authenticated: false }), + }) + .mockRejectedValueOnce(new Error("Network error")); + + renderLoginForm(); + + await user.type(screen.getByPlaceholderText("Username"), "admin"); + await user.type(screen.getByPlaceholderText("Password"), "password"); + await user.click(screen.getByRole("button", { name: /sign in/i })); + + await waitFor(() => { + expect(screen.getByRole("alert")).toHaveTextContent("Failed to login. Please try again."); + }); + }); +}); diff --git a/task-explorer/frontend/tests/components/TaskStatusBadge.test.tsx b/task-explorer/frontend/tests/components/TaskStatusBadge.test.tsx new file mode 100644 index 0000000..54e9c3f --- /dev/null +++ b/task-explorer/frontend/tests/components/TaskStatusBadge.test.tsx @@ -0,0 +1,47 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { TaskStatusBadge } from "@/components/TaskStatusBadge"; + +describe("TaskStatusBadge", () => { + it("should render pending status", () => { + render(); + const badge = screen.getByText("pending"); + expect(badge).toBeInTheDocument(); + expect(badge).toHaveClass("bg-gray-700"); + }); + + it("should render running status", () => { + render(); + const badge = screen.getByText("running"); + expect(badge).toBeInTheDocument(); + expect(badge).toHaveClass("bg-blue-900"); + }); + + it("should render completed status", () => { + render(); + const badge = screen.getByText("completed"); + expect(badge).toBeInTheDocument(); + expect(badge).toHaveClass("bg-green-900"); + }); + + it("should render failed status", () => { + render(); + const badge = screen.getByText("failed"); + expect(badge).toBeInTheDocument(); + expect(badge).toHaveClass("bg-red-900"); + }); + + it("should render blocked status", () => { + render(); + const badge = screen.getByText("blocked"); + expect(badge).toBeInTheDocument(); + expect(badge).toHaveClass("bg-orange-900"); + }); + + it("should render cancelled status", () => { + render(); + const badge = screen.getByText("cancelled"); + expect(badge).toBeInTheDocument(); + expect(badge).toHaveClass("bg-gray-600"); + }); +}); diff --git a/task-explorer/frontend/tests/setup.ts b/task-explorer/frontend/tests/setup.ts new file mode 100644 index 0000000..5bf84db --- /dev/null +++ b/task-explorer/frontend/tests/setup.ts @@ -0,0 +1,12 @@ +// Test setup file for Vitest +import "@testing-library/jest-dom"; +import { expect, afterEach } from "vitest"; +import { cleanup } from "@testing-library/react"; + +// Cleanup after each test to prevent test pollution +afterEach(() => { + cleanup(); +}); + +// Extend Vitest's expect with jest-dom matchers +// This allows us to use matchers like .toBeInTheDocument(), .toHaveTextContent(), etc. diff --git a/task-explorer/frontend/tests/utils/api.test.ts b/task-explorer/frontend/tests/utils/api.test.ts new file mode 100644 index 0000000..9065569 --- /dev/null +++ b/task-explorer/frontend/tests/utils/api.test.ts @@ -0,0 +1,211 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { listTasks, getTaskDetails, cancelTask } from "@/lib/api"; + +describe("API Client", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + describe("listTasks", () => { + it("should fetch tasks with default params", async () => { + const mockResponse = { + tasks: [], + pagination: { limit: 100, offset: 0, total: 0 }, + }; + + global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockResponse), + } as Response), + ); + + const result = await listTasks(); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining("/api/tasks"), + expect.objectContaining({ + credentials: "include", + }), + ); + expect(result).toEqual(mockResponse); + }); + + it("should fetch tasks with filters", async () => { + const mockResponse = { + tasks: [], + pagination: { limit: 50, offset: 10, total: 100 }, + }; + + global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockResponse), + } as Response), + ); + + const params = { + status: "completed,failed", + startTime: "2024-01-01T00:00:00Z", + endTime: "2024-01-02T00:00:00Z", + limit: 50, + offset: 10, + }; + + const result = await listTasks(params); + + expect(fetch).toHaveBeenCalledWith(expect.stringContaining("status=completed%2Cfailed"), expect.any(Object)); + expect(fetch).toHaveBeenCalledWith(expect.stringContaining("limit=50"), expect.any(Object)); + expect(result).toEqual(mockResponse); + }); + + it("should throw error on failed request", async () => { + global.fetch = vi.fn(() => + Promise.resolve({ + ok: false, + status: 401, + statusText: "Unauthorized", + json: () => Promise.resolve({ error: "Unauthorized" }), + } as Response), + ); + + await expect(listTasks()).rejects.toThrow("Unauthorized"); + }); + + it("should include credentials in request", async () => { + global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ tasks: [], pagination: {} }), + } as Response), + ); + + await listTasks(); + + expect(fetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + credentials: "include", + }), + ); + }); + }); + + describe("getTaskDetails", () => { + it("should fetch action task details", async () => { + const mockResponse = { + task: { id: "action-1", type: "action" }, + events: [], + }; + + global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockResponse), + } as Response), + ); + + const result = await getTaskDetails("action", "action-1"); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining("/api/tasks/action/action-1"), + expect.objectContaining({ + credentials: "include", + }), + ); + expect(result).toEqual(mockResponse); + }); + + it("should fetch workflow task details", async () => { + const mockResponse = { + task: { id: "workflow-1", type: "workflow" }, + events: [], + subTasks: {}, + subTaskEvents: {}, + }; + + global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockResponse), + } as Response), + ); + + const result = await getTaskDetails("workflow", "workflow-1"); + + expect(fetch).toHaveBeenCalledWith(expect.stringContaining("/api/tasks/workflow/workflow-1"), expect.any(Object)); + expect(result).toEqual(mockResponse); + }); + + it("should throw error on 404", async () => { + global.fetch = vi.fn(() => + Promise.resolve({ + ok: false, + status: 404, + statusText: "Not Found", + json: () => Promise.resolve({ error: "Task not found" }), + } as Response), + ); + + await expect(getTaskDetails("action", "nonexistent")).rejects.toThrow("Task not found"); + }); + }); + + describe("cancelTask", () => { + it("should cancel action task", async () => { + const mockResponse = { success: true }; + + global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockResponse), + } as Response), + ); + + const result = await cancelTask("action", "action-1"); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining("/api/tasks/action/action-1/cancel"), + expect.objectContaining({ + method: "POST", + credentials: "include", + }), + ); + expect(result).toEqual(mockResponse); + }); + + it("should cancel workflow task", async () => { + const mockResponse = { success: true }; + + global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockResponse), + } as Response), + ); + + const result = await cancelTask("workflow", "workflow-1"); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining("/api/tasks/workflow/workflow-1/cancel"), + expect.objectContaining({ + method: "POST", + }), + ); + expect(result).toEqual(mockResponse); + }); + + it("should throw error if cancel fails", async () => { + global.fetch = vi.fn(() => + Promise.resolve({ + ok: false, + status: 500, + statusText: "Internal Server Error", + json: () => Promise.resolve({ error: "Failed to cancel" }), + } as Response), + ); + + await expect(cancelTask("action", "action-1")).rejects.toThrow("Failed to cancel"); + }); + }); +}); diff --git a/task-explorer/frontend/tests/utils/taskFormatUtils.test.ts b/task-explorer/frontend/tests/utils/taskFormatUtils.test.ts new file mode 100644 index 0000000..da046a0 --- /dev/null +++ b/task-explorer/frontend/tests/utils/taskFormatUtils.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect } from "vitest"; +import { formatTimestampWithMs, formatDuration, TIMEZONE } from "@/lib/taskFormatUtils"; + +describe("Task Format Utils", () => { + describe("formatTimestampWithMs", () => { + it("should format timestamp in UTC", () => { + const timestamp = "2024-01-15T10:30:45.123Z"; + const result = formatTimestampWithMs(timestamp, TIMEZONE.UTC); + + expect(result).toBe("2024-01-15T10:30:45.123Z"); + }); + + it("should format timestamp in local timezone", () => { + const timestamp = "2024-01-15T10:30:45.123Z"; + const result = formatTimestampWithMs(timestamp, TIMEZONE.Local); + + // Should include local time and timezone offset + expect(result).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}[+-]\d{2}:\d{2}$/); + }); + + it("should handle Date object input", () => { + const date = new Date("2024-01-15T10:30:45.123Z"); + const result = formatTimestampWithMs(date, TIMEZONE.UTC); + + expect(result).toBe("2024-01-15T10:30:45.123Z"); + }); + + it("should preserve millisecond precision", () => { + const timestamp = "2024-01-15T10:30:45.999Z"; + const result = formatTimestampWithMs(timestamp, TIMEZONE.UTC); + + expect(result).toContain(".999"); + }); + }); + + describe("formatDuration", () => { + it("should format milliseconds only", () => { + expect(formatDuration(123)).toBe("123ms"); + expect(formatDuration(0)).toBe("0ms"); + }); + + it("should format seconds and milliseconds", () => { + expect(formatDuration(5230)).toBe("5s 230ms"); + }); + + it("should format minutes, seconds, and milliseconds", () => { + expect(formatDuration(125230)).toBe("2m 5s 230ms"); + }); + + it("should format hours, minutes, seconds, and milliseconds", () => { + expect(formatDuration(7325230)).toBe("2h 2m 5s 230ms"); + }); + + it("should format complex durations with days", () => { + const duration = + 2 * 24 * 60 * 60 * 1000 // 2 days + + 3 * 60 * 60 * 1000 // 3 hours + + 15 * 60 * 1000 // 15 minutes + + 30 * 1000 // 30 seconds + + 500; // 500ms + + expect(formatDuration(duration)).toBe("2d 3h 15m 30s 500ms"); + }); + + it("should handle negative durations", () => { + expect(formatDuration(-1000)).toBe("0ms"); + }); + + it("should omit zero values", () => { + const duration = 2 * 60 * 60 * 1000 + 30 * 1000; // 2h 0m 30s + expect(formatDuration(duration)).toBe("2h 30s 0ms"); + }); + + it("should handle showMs=false", () => { + expect(formatDuration(5230, false)).toBe("5s"); + expect(formatDuration(125230, false)).toBe("2m 5s"); + expect(formatDuration(123, false)).toBe("123ms"); // Always show ms if no other units + }); + + it("should handle large durations with years and months", () => { + const duration = + 365 * 24 * 60 * 60 * 1000 // 1 year + + 30 * 24 * 60 * 60 * 1000 // 1 month + + 5 * 24 * 60 * 60 * 1000 // 5 days + + 1000; // 1 second + + const result = formatDuration(duration); + expect(result).toContain("1y"); + expect(result).toContain("1m"); + expect(result).toContain("5d"); + expect(result).toContain("1s"); + }); + + it("should format exactly 1 second", () => { + expect(formatDuration(1000)).toBe("1s 0ms"); + }); + + it("should format exactly 1 minute", () => { + expect(formatDuration(60000)).toBe("1m 0ms"); + }); + + it("should format exactly 1 hour", () => { + expect(formatDuration(3600000)).toBe("1h 0ms"); + }); + }); +}); diff --git a/task-explorer/frontend/tests/utils/taskStatsUtils.test.ts b/task-explorer/frontend/tests/utils/taskStatsUtils.test.ts new file mode 100644 index 0000000..82d467c --- /dev/null +++ b/task-explorer/frontend/tests/utils/taskStatsUtils.test.ts @@ -0,0 +1,199 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { calculateActionStats, calculateWorkflowStats } from "@/lib/taskStatsUtils"; +import type { TaskAction, TaskWorkflow, TaskEvent } from "@/types/task"; + +describe("Task Stats Utils", () => { + beforeEach(() => { + // Mock Date.now() for consistent test results + vi.useFakeTimers(); + vi.setSystemTime(new Date("2024-01-01T10:00:30.000Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe("calculateActionStats", () => { + it("should calculate wait and run time for completed action", () => { + const task: TaskAction = { + id: "action-1", + action: "test-action", + created_at: "2024-01-01T10:00:00.000Z", + run_at: "2024-01-01T10:00:00.000Z", + state: "completed", + type: "action", + } as TaskAction; + + const events: TaskEvent[] = [ + { + id: "e1", + event_type: "created", + created_at: "2024-01-01T10:00:00.000Z", + }, + { + id: "e2", + event_type: "running", + created_at: "2024-01-01T10:00:05.000Z", + }, + { + id: "e3", + event_type: "completed", + created_at: "2024-01-01T10:00:25.000Z", + }, + ]; + + const stats = calculateActionStats(events, task); + + expect(stats.waitTime).toBe(5000); // 5 seconds + expect(stats.runTime).toBe(20000); // 20 seconds + }); + + it("should handle action with retry (worker-failure)", () => { + const task: TaskAction = { + id: "action-2", + action: "test-action", + created_at: "2024-01-01T10:00:00.000Z", + run_at: "2024-01-01T10:00:00.000Z", + state: "completed", + type: "action", + } as TaskAction; + + const events: TaskEvent[] = [ + { id: "e1", event_type: "created", created_at: "2024-01-01T10:00:00.000Z" }, + { id: "e2", event_type: "running", created_at: "2024-01-01T10:00:05.000Z" }, + { id: "e3", event_type: "worker-failure", created_at: "2024-01-01T10:00:15.000Z" }, + { id: "e4", event_type: "running", created_at: "2024-01-01T10:00:18.000Z" }, + { id: "e5", event_type: "completed", created_at: "2024-01-01T10:00:26.000Z" }, + ]; + + const stats = calculateActionStats(events, task); + + expect(stats.waitTime).toBe(5000); // 5s (only initial queue time) + expect(stats.runTime).toBe(18000); // 10s + 8s = 18s total running time + // Note: 3s between worker-failure and next running is NOT counted + }); + + it("should handle pending action (never started)", () => { + const task: TaskAction = { + id: "action-3", + action: "test-action", + created_at: "2024-01-01T10:00:00.000Z", + run_at: "2024-01-01T10:00:00.000Z", + state: "pending", + type: "action", + } as TaskAction; + + const events: TaskEvent[] = [ + { + id: "e1", + event_type: "created", + created_at: "2024-01-01T10:00:00.000Z", + }, + ]; + + const stats = calculateActionStats(events, task); + + // Currently waiting (30 seconds since created, based on mocked time) + expect(stats.waitTime).toBe(30000); + expect(stats.runTime).toBe(0); + }); + + it("should handle currently running action", () => { + const task: TaskAction = { + id: "action-4", + action: "test-action", + created_at: "2024-01-01T10:00:00.000Z", + run_at: "2024-01-01T10:00:00.000Z", + state: "running", + type: "action", + } as TaskAction; + + const events: TaskEvent[] = [ + { id: "e1", event_type: "created", created_at: "2024-01-01T10:00:00.000Z" }, + { id: "e2", event_type: "running", created_at: "2024-01-01T10:00:05.000Z" }, + ]; + + const stats = calculateActionStats(events, task); + + expect(stats.waitTime).toBe(5000); // 5s + // Currently running for 25 seconds (from 10:00:05 to mocked 10:00:30) + expect(stats.runTime).toBe(25000); + }); + + it("should use run_at if later than created_at", () => { + const task: TaskAction = { + id: "action-5", + action: "test-action", + created_at: "2024-01-01T10:00:00.000Z", + run_at: "2024-01-01T10:00:10.000Z", // 10 seconds after creation + state: "completed", + type: "action", + } as TaskAction; + + const events: TaskEvent[] = [ + { id: "e1", event_type: "created", created_at: "2024-01-01T10:00:00.000Z" }, + { id: "e2", event_type: "running", created_at: "2024-01-01T10:00:15.000Z" }, + { id: "e3", event_type: "completed", created_at: "2024-01-01T10:00:20.000Z" }, + ]; + + const stats = calculateActionStats(events, task); + + // Wait time from run_at (10:00:10) to running (10:00:15) = 5s + expect(stats.waitTime).toBe(5000); + expect(stats.runTime).toBe(5000); + }); + }); + + describe("calculateWorkflowStats", () => { + it("should calculate workflow stats without subtasks", () => { + const task: TaskWorkflow = { + id: "workflow-1", + workflow: "test-workflow", + created_at: "2024-01-01T10:00:00.000Z", + run_at: "2024-01-01T10:00:00.000Z", + state: "completed", + type: "workflow", + } as TaskWorkflow; + + const events: TaskEvent[] = [ + { id: "e1", event_type: "created", created_at: "2024-01-01T10:00:00.000Z" }, + { id: "e2", event_type: "running", created_at: "2024-01-01T10:00:02.000Z" }, + { id: "e3", event_type: "completed", created_at: "2024-01-01T10:00:10.000Z" }, + ]; + + const stats = calculateWorkflowStats(events, task, { actions: [], workflows: [] }, new Map()); + + expect(stats.workflowWaitTime).toBe(2000); // 2s + expect(stats.workflowRunTime).toBe(8000); // 8s + expect(stats.totalWaitTime).toBe(2000); // Same as workflow (no subtasks) + expect(stats.totalRunTime).toBe(8000); // Same as workflow (no subtasks) + expect(stats.wakeups).toBe(1); // One running event + }); + + it("should count multiple wakeups (suspended/unblocked)", () => { + const task: TaskWorkflow = { + id: "workflow-2", + workflow: "test-workflow", + created_at: "2024-01-01T10:00:00.000Z", + run_at: "2024-01-01T10:00:00.000Z", + state: "completed", + type: "workflow", + } as TaskWorkflow; + + const events: TaskEvent[] = [ + { id: "e1", event_type: "created", created_at: "2024-01-01T10:00:00.000Z" }, + { id: "e2", event_type: "running", created_at: "2024-01-01T10:00:02.000Z" }, + { id: "e3", event_type: "suspended", created_at: "2024-01-01T10:00:07.000Z" }, + { id: "e4", event_type: "unblocked", created_at: "2024-01-01T10:00:09.000Z" }, + { id: "e5", event_type: "running", created_at: "2024-01-01T10:00:11.000Z" }, + { id: "e6", event_type: "completed", created_at: "2024-01-01T10:00:14.000Z" }, + ]; + + const stats = calculateWorkflowStats(events, task, { actions: [], workflows: [] }, new Map()); + + expect(stats.wakeups).toBe(2); // Two running events + expect(stats.workflowWaitTime).toBe(2000); // 2s + expect(stats.workflowRunTime).toBe(8000); // 5s + 3s = 8s (only running periods) + }); + }); +}); diff --git a/task-explorer/frontend/tests/utils/taskUtils.test.ts b/task-explorer/frontend/tests/utils/taskUtils.test.ts new file mode 100644 index 0000000..9ab1029 --- /dev/null +++ b/task-explorer/frontend/tests/utils/taskUtils.test.ts @@ -0,0 +1,207 @@ +import { describe, it, expect } from "vitest"; +import { getTaskStartTime, getTaskDuration, getTaskName, canCancelTask } from "@/lib/taskUtils"; +import type { TaskAction, TaskWorkflow } from "@/types/task"; + +describe("Task Utils", () => { + describe("getTaskStartTime", () => { + it("should return run_at for action tasks", () => { + const task: TaskAction = { + id: "action-1", + action: "test-action", + type: "action", + state: "completed", + created_at: "2024-01-01T10:00:00.000Z", + run_at: "2024-01-01T10:05:00.000Z", + } as TaskAction; + + expect(getTaskStartTime(task)).toBe("2024-01-01T10:05:00.000Z"); + }); + + it("should return started_at for workflow tasks if available", () => { + const task: TaskWorkflow = { + id: "workflow-1", + workflow: "test-workflow", + type: "workflow", + state: "completed", + created_at: "2024-01-01T10:00:00.000Z", + run_at: "2024-01-01T10:05:00.000Z", + started_at: "2024-01-01T10:06:00.000Z", + } as TaskWorkflow; + + expect(getTaskStartTime(task)).toBe("2024-01-01T10:06:00.000Z"); + }); + + it("should return run_at for workflow tasks if started_at is not available", () => { + const task: TaskWorkflow = { + id: "workflow-1", + workflow: "test-workflow", + type: "workflow", + state: "pending", + created_at: "2024-01-01T10:00:00.000Z", + run_at: "2024-01-01T10:05:00.000Z", + started_at: null, + } as TaskWorkflow; + + expect(getTaskStartTime(task)).toBe("2024-01-01T10:05:00.000Z"); + }); + }); + + describe("getTaskDuration", () => { + it("should calculate duration for completed task", () => { + const task: TaskAction = { + id: "action-1", + action: "test-action", + type: "action", + state: "completed", + created_at: "2024-01-01T10:00:00.000Z", + run_at: "2024-01-01T10:00:00.000Z", + finished_at: "2024-01-01T10:00:05.000Z", + } as TaskAction; + + expect(getTaskDuration(task)).toBe("5s 0ms"); + }); + + it("should calculate duration for running task using current time", () => { + const now = new Date(); + const fiveSecondsAgo = new Date(now.getTime() - 5000); + + const task: TaskAction = { + id: "action-1", + action: "test-action", + type: "action", + state: "running", + created_at: fiveSecondsAgo.toISOString(), + run_at: fiveSecondsAgo.toISOString(), + finished_at: null, + } as TaskAction; + + const duration = getTaskDuration(task); + // Should be approximately 5 seconds + expect(duration).toMatch(/[45]s/); + }); + + it("should use max of run_at and created_at as start time", () => { + const task: TaskAction = { + id: "action-1", + action: "test-action", + type: "action", + state: "completed", + created_at: "2024-01-01T10:00:10.000Z", // created 10s after run_at + run_at: "2024-01-01T10:00:00.000Z", + finished_at: "2024-01-01T10:00:15.000Z", + } as TaskAction; + + // Duration should be from created_at (10s) to finished_at (15s) = 5s + expect(getTaskDuration(task)).toBe("5s 0ms"); + }); + + it("should return 'Not started' when run_at is not set", () => { + const task: TaskAction = { + id: "action-1", + action: "test-action", + type: "action", + state: "pending", + created_at: "2024-01-01T10:00:00.000Z", + run_at: null, + finished_at: null, + } as TaskAction; + + expect(getTaskDuration(task)).toBe("Not started"); + }); + + it("should handle invalid duration (negative)", () => { + const task: TaskAction = { + id: "action-1", + action: "test-action", + type: "action", + state: "completed", + created_at: "2024-01-01T10:00:10.000Z", + run_at: "2024-01-01T10:00:10.000Z", + finished_at: "2024-01-01T10:00:05.000Z", // finished before started + } as TaskAction; + + expect(getTaskDuration(task)).toBe("Invalid duration"); + }); + }); + + describe("getTaskName", () => { + it("should return action name for action tasks", () => { + const task: TaskAction = { + id: "action-1", + action: "send-email", + type: "action", + state: "completed", + } as TaskAction; + + expect(getTaskName(task)).toBe("send-email"); + }); + + it("should return workflow name for workflow tasks", () => { + const task: TaskWorkflow = { + id: "workflow-1", + workflow: "order-processing", + type: "workflow", + state: "running", + } as TaskWorkflow; + + expect(getTaskName(task)).toBe("order-processing"); + }); + }); + + describe("canCancelTask", () => { + it("should return true for running tasks", () => { + const task: TaskAction = { + id: "action-1", + action: "test-action", + type: "action", + state: "running", + } as TaskAction; + + expect(canCancelTask(task)).toBe(true); + }); + + it("should return false for completed tasks", () => { + const task: TaskAction = { + id: "action-1", + action: "test-action", + type: "action", + state: "completed", + } as TaskAction; + + expect(canCancelTask(task)).toBe(false); + }); + + it("should return false for pending tasks", () => { + const task: TaskAction = { + id: "action-1", + action: "test-action", + type: "action", + state: "pending", + } as TaskAction; + + expect(canCancelTask(task)).toBe(false); + }); + + it("should return false for failed tasks", () => { + const task: TaskAction = { + id: "action-1", + action: "test-action", + type: "action", + state: "failed", + } as TaskAction; + + expect(canCancelTask(task)).toBe(false); + }); + + it("should return false for cancelled tasks", () => { + const task: TaskAction = { + id: "action-1", + action: "test-action", + type: "action", + state: "cancelled", + } as TaskAction; + + expect(canCancelTask(task)).toBe(false); + }); + }); +}); diff --git a/task-explorer/frontend/tsconfig.app.json b/task-explorer/frontend/tsconfig.app.json new file mode 100644 index 0000000..4f86de7 --- /dev/null +++ b/task-explorer/frontend/tsconfig.app.json @@ -0,0 +1,26 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "types": ["vite/client"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/task-explorer/frontend/tsconfig.json b/task-explorer/frontend/tsconfig.json new file mode 100644 index 0000000..7036885 --- /dev/null +++ b/task-explorer/frontend/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "files": [], + "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }] +} diff --git a/task-explorer/frontend/tsconfig.node.json b/task-explorer/frontend/tsconfig.node.json new file mode 100644 index 0000000..8a67f62 --- /dev/null +++ b/task-explorer/frontend/tsconfig.node.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/task-explorer/frontend/utils.sh b/task-explorer/frontend/utils.sh new file mode 100755 index 0000000..e610176 --- /dev/null +++ b/task-explorer/frontend/utils.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")" && pwd)" + +cd "$ROOT_DIR" || exit 1 + +usage() { + cat < + +Commands: + build Compile and bundle for production + typecheck Type-check without emitting + test Run Vitest test suite + clean Remove node_modules and dist/ +EOF +} + +case "${1:-help}" in + build) + pnpm build + ;; + + typecheck) + pnpm exec tsc -b + ;; + + test) + pnpm test + ;; + + clean) + echo "Removing frontend node_modules and dist..." + rm -rf "./node_modules" "./dist" + echo "Done." + ;; + + help|--help|-h) + usage + ;; + + *) + echo "Error: Unknown command '${1}'" >&2 + usage + exit 1 + ;; +esac diff --git a/task-explorer/frontend/vite.config.ts b/task-explorer/frontend/vite.config.ts new file mode 100644 index 0000000..bcdd335 --- /dev/null +++ b/task-explorer/frontend/vite.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import tailwindcss from "@tailwindcss/vite"; +import { tanstackRouter } from "@tanstack/router-plugin/vite"; +import path from "path"; + +export default defineConfig({ + base: process.env.VITE_BASE_PATH || "/", + plugins: [tanstackRouter({ target: "react", autoCodeSplitting: true }), react(), tailwindcss()], + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, + server: { + // Proxy API requests to backend in development + // This allows frontend to use relative /api paths without CORS issues + proxy: { + "/api": { + target: "http://localhost:3000", + changeOrigin: true, + }, + }, + }, +}); diff --git a/task-explorer/frontend/vitest.config.ts b/task-explorer/frontend/vitest.config.ts new file mode 100644 index 0000000..88764f6 --- /dev/null +++ b/task-explorer/frontend/vitest.config.ts @@ -0,0 +1,29 @@ +import { defineConfig } from "vitest/config"; +import react from "@vitejs/plugin-react"; +import tailwindcss from "@tailwindcss/vite"; +import { tanstackRouter } from "@tanstack/router-plugin/vite"; +import path from "path"; + +export default defineConfig({ + plugins: [tanstackRouter({ target: "react", autoCodeSplitting: true }), react(), tailwindcss()], + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, + test: { + globals: true, + environment: "jsdom", + setupFiles: ["./tests/setup.ts"], + // Use thread pool for parallel test execution + pool: "threads", + // Coverage configuration + coverage: { + provider: "v8", + reporter: ["text", "json", "html"], + exclude: ["node_modules/", "dist/", "tests/", "**/*.config.ts", "**/*.d.ts", "**/routeTree.gen.ts"], + }, + // Test patterns + include: ["tests/**/*.test.ts", "tests/**/*.test.tsx", "src/**/*.test.ts", "src/**/*.test.tsx"], + }, +}); diff --git a/task-explorer/package.json b/task-explorer/package.json new file mode 100644 index 0000000..702e9b4 --- /dev/null +++ b/task-explorer/package.json @@ -0,0 +1,17 @@ +{ + "name": "task-explorer", + "private": true, + "version": "0.1.0", + "scripts": { + "dev:frontend": "pnpm --filter './frontend' dev -- --host 0.0.0.0", + "dev:api": "pnpm --filter './backend' dev", + "dev": "concurrently \"pnpm run dev:frontend\" \"pnpm run dev:api\"", + "build": "pnpm --filter './frontend' build && pnpm --filter './backend' build", + "start": "pnpm --filter './backend' start", + "test": "pnpm --filter './backend' test && pnpm --filter './frontend' test" + }, + "dependencies": { + "concurrently": "8.2.2", + "ts-pattern": "5.9.0" + } +} diff --git a/task-explorer/tsconfig.json b/task-explorer/tsconfig.json new file mode 100644 index 0000000..64594c9 --- /dev/null +++ b/task-explorer/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + // module options + "target": "ES2020", + "module": "esnext", + "moduleResolution": "bundler", + "moduleDetection": "force", + "isolatedModules": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + + // type checking + "strict": true, + "noImplicitAny": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "exactOptionalPropertyTypes": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "allowUnusedLabels": false, + "allowUnreachableCode": false + }, + "references": [{ "path": "./backend" }], + "files": [] +} diff --git a/task-explorer/utils.sh b/task-explorer/utils.sh new file mode 100755 index 0000000..141c973 --- /dev/null +++ b/task-explorer/utils.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")" && pwd)" +BACKEND_DIR="${ROOT_DIR}/backend" +FRONTEND_DIR="${ROOT_DIR}/frontend" + +usage() { + cat < [args] + +Workspace Commands: + build Build backend and frontend + typecheck Typecheck backend and frontend + test Run all unit tests + test:integration Run backend integration tests (requires Docker) + docker:build Build the Docker image locally (requires GITHUB_TOKEN in env) + format Format all code with Prettier + clean Remove all node_modules and dist/ + +Backend Commands: +$(cd "$BACKEND_DIR" && ./utils.sh --help | tail -n +3) + +Frontend Commands: +$(cd "$FRONTEND_DIR" && ./utils.sh --help | tail -n +3) + +Examples: + ./utils.sh build + ./utils.sh test + ./utils.sh backend test:integration + ./utils.sh backend test:integration --match "auth" + ./utils.sh frontend test +EOF +} + +case "${1:-help}" in + build) + cd "$BACKEND_DIR" && ./utils.sh build + cd "$FRONTEND_DIR" && ./utils.sh build + ;; + + typecheck) + cd "$BACKEND_DIR" && ./utils.sh typecheck + cd "$FRONTEND_DIR" && ./utils.sh typecheck + ;; + + test) + cd "$BACKEND_DIR" && ./utils.sh test + cd "$FRONTEND_DIR" && ./utils.sh test + ;; + + test:integration) + shift || true + cd "$BACKEND_DIR" && ./utils.sh test:integration "$@" + ;; + + docker:build) + if [ -z "${GITHUB_TOKEN:-}" ]; then + echo "Error: GITHUB_TOKEN is not set in the environment." >&2 + exit 1 + fi + docker build --build-arg GITHUB_TOKEN="$GITHUB_TOKEN" \ + -t "ghcr.io/ambarltd/task-explorer:local" \ + "$ROOT_DIR" + ;; + + format) + cd "$ROOT_DIR" && pnpm exec prettier --write "**/*.{ts,tsx,json,md}" + ;; + + clean) + cd "$BACKEND_DIR" && ./utils.sh clean + cd "$FRONTEND_DIR" && ./utils.sh clean + echo "✓ Cleaned." + ;; + + backend) + shift || true + cd "$BACKEND_DIR" && ./utils.sh "$@" + ;; + + frontend) + shift || true + cd "$FRONTEND_DIR" && ./utils.sh "$@" + ;; + + help|--help|-h) + usage + ;; + + *) + echo "Error: Unknown scope '${1}'" >&2 + usage + exit 1 + ;; +esac