From c99c2d0694992acc90505a6b04cb7bb65059b901 Mon Sep 17 00:00:00 2001 From: Can Tuncay Date: Fri, 10 Apr 2026 23:54:00 -0500 Subject: [PATCH 1/4] Migrating task-explorer (AKA task-viewer) to core repo as a separate package --- .github/workflows/ambar-task-explorer.yaml | 100 + .prettierignore | 3 + pnpm-lock.yaml | 5651 ++++++++++++++++- pnpm-workspace.yaml | 3 + task-explorer/.gitignore | 5 + task-explorer/.npmrc | 2 + task-explorer/Dockerfile | 63 + task-explorer/README.md | 116 + task-explorer/backend/.dockerignore | 5 + task-explorer/backend/.env.example | 28 + task-explorer/backend/.env.test | 22 + task-explorer/backend/package.json | 43 + task-explorer/backend/src/index.ts | 572 ++ task-explorer/backend/src/lib/api-schemas.ts | 134 + task-explorer/backend/src/lib/database.ts | 24 + task-explorer/backend/src/lib/decoders.ts | 27 + task-explorer/backend/src/lib/environment.ts | 38 + task-explorer/backend/src/scripts/seed.ts | 702 ++ task-explorer/backend/tests/helpers.ts | 13 + .../backend/tests/integration/api.test.ts | 238 + .../backend/tests/integration/auth.test.ts | 142 + .../backend/tests/integration/main.ts | 16 + task-explorer/backend/tests/unit/main.ts | 16 + .../tests/unit/stats-requirements.test.ts | 184 + .../backend/tests/unit/utilities.test.ts | 233 + task-explorer/backend/tsconfig.json | 46 + task-explorer/frontend/.dockerignore | 5 + task-explorer/frontend/index.html | 13 + task-explorer/frontend/package.json | 45 + task-explorer/frontend/public/.gitkeep | 0 .../public/prevou-logo-icon-574x574.png | Bin 0 -> 113512 bytes .../src/components/AutoRefreshSelector.tsx | 170 + .../frontend/src/components/MetadataField.tsx | 16 + .../frontend/src/components/Pagination.tsx | 82 + .../frontend/src/components/StatsWidget.tsx | 138 + .../frontend/src/components/TaskDetails.tsx | 267 + .../src/components/TaskStatusBadge.tsx | 26 + .../frontend/src/components/TasksList.tsx | 213 + .../src/components/TimezoneSelector.tsx | 62 + .../components/WorkflowGanttChart.module.css | 9 + .../src/components/WorkflowGanttChart.tsx | 408 ++ task-explorer/frontend/src/index.css | 28 + .../frontend/src/lib/AuthContext.tsx | 64 + .../frontend/src/lib/TimezoneContext.tsx | 43 + task-explorer/frontend/src/lib/api.ts | 90 + task-explorer/frontend/src/lib/queryClient.ts | 28 + .../frontend/src/lib/taskFormatUtils.ts | 127 + .../frontend/src/lib/taskStatsUtils.ts | 208 + task-explorer/frontend/src/lib/taskUtils.ts | 64 + .../frontend/src/lib/useClickOutside.ts | 38 + task-explorer/frontend/src/lib/useDebounce.ts | 14 + task-explorer/frontend/src/main.tsx | 32 + task-explorer/frontend/src/routeTree.gen.ts | 113 + task-explorer/frontend/src/routes/__root.tsx | 104 + task-explorer/frontend/src/routes/index.tsx | 9 + task-explorer/frontend/src/routes/login.tsx | 114 + .../frontend/src/routes/tasks/$type.$id.tsx | 160 + .../frontend/src/routes/tasks/index.tsx | 614 ++ task-explorer/frontend/src/types/task.ts | 130 + .../frontend/tests/auth/AuthContext.test.tsx | 145 + .../frontend/tests/auth/login.test.tsx | 238 + .../tests/components/TaskStatusBadge.test.tsx | 47 + task-explorer/frontend/tests/setup.ts | 12 + .../frontend/tests/utils/api.test.ts | 211 + .../tests/utils/taskFormatUtils.test.ts | 106 + .../tests/utils/taskStatsUtils.test.ts | 199 + .../frontend/tests/utils/taskUtils.test.ts | 207 + task-explorer/frontend/tsconfig.app.json | 26 + task-explorer/frontend/tsconfig.json | 10 + task-explorer/frontend/tsconfig.node.json | 26 + task-explorer/frontend/vite.config.ts | 25 + task-explorer/frontend/vitest.config.ts | 29 + task-explorer/package.json | 17 + task-explorer/tsconfig.json | 30 + 74 files changed, 12907 insertions(+), 281 deletions(-) create mode 100644 .github/workflows/ambar-task-explorer.yaml create mode 100644 task-explorer/.gitignore create mode 100644 task-explorer/.npmrc create mode 100644 task-explorer/Dockerfile create mode 100644 task-explorer/README.md create mode 100644 task-explorer/backend/.dockerignore create mode 100644 task-explorer/backend/.env.example create mode 100644 task-explorer/backend/.env.test create mode 100644 task-explorer/backend/package.json create mode 100644 task-explorer/backend/src/index.ts create mode 100644 task-explorer/backend/src/lib/api-schemas.ts create mode 100644 task-explorer/backend/src/lib/database.ts create mode 100644 task-explorer/backend/src/lib/decoders.ts create mode 100644 task-explorer/backend/src/lib/environment.ts create mode 100644 task-explorer/backend/src/scripts/seed.ts create mode 100644 task-explorer/backend/tests/helpers.ts create mode 100644 task-explorer/backend/tests/integration/api.test.ts create mode 100644 task-explorer/backend/tests/integration/auth.test.ts create mode 100644 task-explorer/backend/tests/integration/main.ts create mode 100644 task-explorer/backend/tests/unit/main.ts create mode 100644 task-explorer/backend/tests/unit/stats-requirements.test.ts create mode 100644 task-explorer/backend/tests/unit/utilities.test.ts create mode 100644 task-explorer/backend/tsconfig.json create mode 100644 task-explorer/frontend/.dockerignore create mode 100644 task-explorer/frontend/index.html create mode 100644 task-explorer/frontend/package.json create mode 100644 task-explorer/frontend/public/.gitkeep create mode 100644 task-explorer/frontend/public/prevou-logo-icon-574x574.png create mode 100644 task-explorer/frontend/src/components/AutoRefreshSelector.tsx create mode 100644 task-explorer/frontend/src/components/MetadataField.tsx create mode 100644 task-explorer/frontend/src/components/Pagination.tsx create mode 100644 task-explorer/frontend/src/components/StatsWidget.tsx create mode 100644 task-explorer/frontend/src/components/TaskDetails.tsx create mode 100644 task-explorer/frontend/src/components/TaskStatusBadge.tsx create mode 100644 task-explorer/frontend/src/components/TasksList.tsx create mode 100644 task-explorer/frontend/src/components/TimezoneSelector.tsx create mode 100644 task-explorer/frontend/src/components/WorkflowGanttChart.module.css create mode 100644 task-explorer/frontend/src/components/WorkflowGanttChart.tsx create mode 100644 task-explorer/frontend/src/index.css create mode 100644 task-explorer/frontend/src/lib/AuthContext.tsx create mode 100644 task-explorer/frontend/src/lib/TimezoneContext.tsx create mode 100644 task-explorer/frontend/src/lib/api.ts create mode 100644 task-explorer/frontend/src/lib/queryClient.ts create mode 100644 task-explorer/frontend/src/lib/taskFormatUtils.ts create mode 100644 task-explorer/frontend/src/lib/taskStatsUtils.ts create mode 100644 task-explorer/frontend/src/lib/taskUtils.ts create mode 100644 task-explorer/frontend/src/lib/useClickOutside.ts create mode 100644 task-explorer/frontend/src/lib/useDebounce.ts create mode 100644 task-explorer/frontend/src/main.tsx create mode 100644 task-explorer/frontend/src/routeTree.gen.ts create mode 100644 task-explorer/frontend/src/routes/__root.tsx create mode 100644 task-explorer/frontend/src/routes/index.tsx create mode 100644 task-explorer/frontend/src/routes/login.tsx create mode 100644 task-explorer/frontend/src/routes/tasks/$type.$id.tsx create mode 100644 task-explorer/frontend/src/routes/tasks/index.tsx create mode 100644 task-explorer/frontend/src/types/task.ts create mode 100644 task-explorer/frontend/tests/auth/AuthContext.test.tsx create mode 100644 task-explorer/frontend/tests/auth/login.test.tsx create mode 100644 task-explorer/frontend/tests/components/TaskStatusBadge.test.tsx create mode 100644 task-explorer/frontend/tests/setup.ts create mode 100644 task-explorer/frontend/tests/utils/api.test.ts create mode 100644 task-explorer/frontend/tests/utils/taskFormatUtils.test.ts create mode 100644 task-explorer/frontend/tests/utils/taskStatsUtils.test.ts create mode 100644 task-explorer/frontend/tests/utils/taskUtils.test.ts create mode 100644 task-explorer/frontend/tsconfig.app.json create mode 100644 task-explorer/frontend/tsconfig.json create mode 100644 task-explorer/frontend/tsconfig.node.json create mode 100644 task-explorer/frontend/vite.config.ts create mode 100644 task-explorer/frontend/vitest.config.ts create mode 100644 task-explorer/package.json create mode 100644 task-explorer/tsconfig.json diff --git a/.github/workflows/ambar-task-explorer.yaml b/.github/workflows/ambar-task-explorer.yaml new file mode 100644 index 0000000..e90b847 --- /dev/null +++ b/.github/workflows/ambar-task-explorer.yaml @@ -0,0 +1,100 @@ +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: Backend typecheck + run: cd task-explorer/backend && pnpm exec tsc --noEmit + + - name: Backend unit tests + run: cd task-explorer && pnpm --filter './backend' test:unit + env: + # Provide dummy env vars for unit tests that don't need real DB + TASKS_DB_USER: test + TASKS_DB_PASSWORD: test + TASKS_DB_HOST: localhost + TASKS_DB_PORT: "5432" + TASKS_DB_NAME: test + TASKS_DB_NAMESPACE: test + AUTH_USERNAME: admin + AUTH_PASSWORD: admin + SESSION_SECRET: test-secret + + - name: Frontend typecheck + run: cd task-explorer/frontend && pnpm exec tsc -b + + - name: Frontend tests + run: cd task-explorer && pnpm --filter './frontend' test + + build-image: + name: Build Docker Image + runs-on: ubuntu-24.04 + needs: test + 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: 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 }}:${{ github.sha }} + build-args: | + GITHUB_TOKEN=${{ secrets.READ_ACCESS_TO_REPOS }} diff --git a/.prettierignore b/.prettierignore index 9e28491..d72b23f 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,3 +2,6 @@ node_modules dist pnpm-lock.yaml *.log + +# TanStack Router generated files for task-explorer (formatted by plugin) +**/routeTree.gen.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 51e396b..b7ded70 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: workspace:* + version: link:../../core + '@ambarltd/tasks': + specifier: workspace:* + version: link:../../tasks + '@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: workspace:* + version: link:../../core + '@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,1130 @@ 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} + '@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': - resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==} + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.27.4': + '@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 +1382,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 +1399,4412 @@ 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 + + '@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/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..639f9e2 --- /dev/null +++ b/task-explorer/README.md @@ -0,0 +1,116 @@ +# 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 + +## Development + +### Prerequisites + +- Node.js 24+ +- pnpm 10+ +- A PostgreSQL database with `@ambarltd/tasks` tables (e.g., from a running virtual-agent stack) + +### Setup + +```bash +pnpm install + +# Configure environment +cd backend +cp .env.example .env +# Edit .env with your database credentials +``` + +### Running + +```bash +# Frontend (Vite) + Backend (Express) concurrently +pnpm dev + +# Or individually +pnpm dev:frontend # Vite dev server on :5173, proxies /api to :3000 +pnpm dev:api # Express on :3000 +``` + +### Testing + +```bash +# All tests +pnpm test + +# Backend unit tests only +pnpm --filter './backend' test:unit + +# Frontend tests only +pnpm --filter './frontend' test + +# Frontend tests with watch mode +pnpm --filter './frontend' test:watch +``` + +### Typecheck + +```bash +cd backend && pnpm exec tsc --noEmit +cd frontend && pnpm exec tsc -b +``` + +## Docker + +Build and run standalone: + +```bash +docker build --build-arg GITHUB_TOKEN=$GITHUB_TOKEN -t task-explorer . +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 \ + task-explorer +``` + +Then open http://localhost:8085. + +## Seeding Test Data + +Populate the database with sample tasks for testing: + +```bash +cd backend +SEED_CLEAR=true SEED_ACTIONS=500 SEED_WORKFLOWS=50 pnpm run seed +``` + +Environment variables: `SEED_CLEAR=true` (clear first), `SEED_ACTIONS=N`, `SEED_WORKFLOWS=N`, `SEED_CLEAN_ONLY=true` (clear without populating). + +## 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 | +| `PORT` | No | Server port (default: 3000) | +| `FRONTEND_DIST` | No | Path to frontend build (default: `../../frontend/dist`) | +| `BASE_PATH` | No | URL base path for non-root deployments | 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.example b/task-explorer/backend/.env.example new file mode 100644 index 0000000..29b8aa2 --- /dev/null +++ b/task-explorer/backend/.env.example @@ -0,0 +1,28 @@ +# ============================================================================= +# Task Viewer - Environment Variables Configuration +# ============================================================================= +# Copy this file to .env and replace placeholder values with your actual credentials +# NEVER commit .env to version control! + +# ============================================================================= +# 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 Configuration +# ============================================================================= +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..9f25b9c --- /dev/null +++ b/task-explorer/backend/.env.test @@ -0,0 +1,22 @@ +# ============================================================================= +# Task Viewer - Test Environment Configuration +# ============================================================================= +# Copy this file to .env.test for running unit tests locally +# Integration tests get env vars from docker-compose + +# ============================================================================= +# Authentication +# ============================================================================= +AUTH_USERNAME=test-admin +AUTH_PASSWORD=test-password +SESSION_SECRET=test-session-secret-key + +# ============================================================================= +# Database (PostgreSQL) +# ============================================================================= +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..6401b38 --- /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 src/index.ts\"", + "build": "(tsc -p tsconfig.json || true) && 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 src/scripts/seed.ts" + }, + "dependencies": { + "@ambarltd/core": "workspace:*", + "@ambarltd/tasks": "workspace:*", + "@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..fd8fcd4 --- /dev/null +++ b/task-explorer/backend/src/index.ts @@ -0,0 +1,572 @@ +/** + * 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 viewer (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; + +async function startup() { + try { + 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) { + console.error("Failed to initialize database:", error); + process.exit(1); + } +} + +// ============================================================================= +// 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) => { + 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 { + 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-viewer'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, task: result }); + } 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") { + await startup(); + + 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}`); + }); + + // Graceful shutdown handler + async function shutdown(signal: string) { + console.log(`${signal === "SIGINT" ? "\n" : ""}Shutting down gracefully...`); + try { + 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..c5c4f69 --- /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, // Use task ID as event ID for consistency + 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..47228e4 --- /dev/null +++ b/task-explorer/backend/src/lib/environment.ts @@ -0,0 +1,38 @@ +// Environment variables +// All environment variables used by the task-viewer 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..63b31ca --- /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-viewer-cancel worker so tasks can be cancelled from UI + const cancelWorker = new WorkerId(process.env["CANCEL_WORKER_ID"] ?? "task-viewer-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-viewer-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/api.test.ts b/task-explorer/backend/tests/integration/api.test.ts new file mode 100644 index 0000000..681d252 --- /dev/null +++ b/task-explorer/backend/tests/integration/api.test.ts @@ -0,0 +1,238 @@ +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 database once when module loads +await startup(); + +// 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-viewer"); + 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.not_equals(response.body.actions, undefined); + expect.not_equals(response.body.workflows, undefined); + expect.equals(Array.isArray(response.body.actions), true); + expect.equals(Array.isArray(response.body.workflows), true); + }), + + test("should filter by status", async () => { + const response = await request(app) + .get("/api/tasks") + .query({ status: "completed,failed" }) + .set("Cookie", authCookie); + + expect.equals(response.status, 200); + expect.not_equals(response.body.actions, undefined); + expect.not_equals(response.body.workflows, undefined); + }), + + 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); + expect.not_equals(response.body.actions, undefined); + expect.not_equals(response.body.workflows, undefined); + }), + + 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); + expect.not_equals(response.body.actions, undefined); + expect.not_equals(response.body.workflows, undefined); + // API returns limited results but doesn't include pagination metadata in response + 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); + // Should clamp to max limit (1000) for each type + 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.not_equals(response.body.actions, undefined); + expect.not_equals(response.body.workflows, undefined); + }), + ]), + + 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 task details if task exists", async () => { + // First, get a list of tasks to find a valid ID + const listResponse = await request(app).get("/api/tasks").query({ limit: 1 }).set("Cookie", authCookie); + + // Try to get an action task + if (listResponse.body.actions.length > 0) { + const task = listResponse.body.actions[0]; + const response = await request(app).get(`/api/tasks/action/${task.id}`).set("Cookie", authCookie); + + if (response.status === 200) { + expect.not_equals(response.body.task, undefined); + expect.not_equals(response.body.events, undefined); + expect.equals(response.body.task.id, task.id); + } + } + }), + ]), + + 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.not_equals(response.body.tasks, undefined); + 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.not_equals(response.body.error, undefined); + 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.not_equals(response.body.error, undefined); + 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.not_equals(response.body.tasks, undefined); + expect.equals(Array.isArray(response.body.tasks), true); + // Timeline endpoint returns both actions and workflows, limit applies per type + // So we can have up to 10 total tasks (5 actions + 5 workflows) + }), + ]), + + 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 () => { + // Use a valid UUID format that doesn't exist + 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.not_equals(response.body.error, undefined); + 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.not_equals(response.body.error, undefined); + expect.contains("Task not found", response.body.error); + }), + ]), +]); 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..1e3b457 --- /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-viewer"); + }), + ]), +]); 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..f460537 --- /dev/null +++ b/task-explorer/backend/tests/unit/main.ts @@ -0,0 +1,16 @@ +import { parseArgs, run } from "@ambarltd/core/test"; +import * as utilitiesTests from "./utilities.test"; +import * as statsTests from "./stats-requirements.test"; + +async function main() { + console.log("Running unit tests"); + console.log(""); + + const options = parseArgs(process.argv.slice(2)); + + const testSuites = [utilitiesTests.tests, statsTests.tests]; + + await run(options, testSuites); +} + +main(); diff --git a/task-explorer/backend/tests/unit/stats-requirements.test.ts b/task-explorer/backend/tests/unit/stats-requirements.test.ts new file mode 100644 index 0000000..80a7299 --- /dev/null +++ b/task-explorer/backend/tests/unit/stats-requirements.test.ts @@ -0,0 +1,184 @@ +import { group, test, expect } from "@ambarltd/core/test"; + +/** + * Tests documenting stats calculation requirements + * Note: Actual stats calculation happens in frontend (taskStatsUtils.ts) + * Backend only provides raw events data + */ + +export const tests = group("Stats Calculation Requirements", [ + test("Backend should provide all events needed for stats calculation", () => { + // Example task events for stats calculation + // Task: created_at=10:00:00, finished_at=10:00:26 + const events = [ + { 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" }, + ]; + + // Backend provides all events + expect.equals(events.length, 5); + expect.equals(events[0]?.event_type, "created"); + expect.equals(events[1]?.event_type, "running"); + expect.equals(events[2]?.event_type, "worker-failure"); + expect.equals(events[3]?.event_type, "running"); + expect.equals(events[4]?.event_type, "completed"); + + // Frontend will calculate: + // Wait Time = 5s (00s → 05s, until first "running") + // Run Time = 18s (05s → 15s = 10s, 18s → 26s = 8s) + // Duration = 26s (total elapsed) + // Other Time = 3s (15s → 18s, after worker-failure, NOT counted as wait) + }), + + test("Workflow events should include suspended/unblocked for stats", () => { + // Workflow with a suspended state + const events = [ + { 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" }, + ]; + + // Backend provides all events including suspended/unblocked + expect.equals(events.length, 6); + expect.equals( + events.some(e => e.event_type === "suspended"), + true, + ); + expect.equals( + events.some(e => e.event_type === "unblocked"), + true, + ); + + // Frontend will calculate: + // Wait Time = 2s (00s → 02s, until first "running") + // Run Time = 8s (02s → 07s = 5s, 11s → 14s = 3s) + // Duration = 14s (total elapsed) + // Other Time = 4s (07s → 11s, suspended/blocked, NOT counted as wait) + // Wakeups = 2 (two "running" events) + }), + + test("Stats calculation logic - Wait Time definition", () => { + // Requirement: Wait Time = ONLY initial queue time + // From created/run_at until the first "running" event + + const scenario = { + name: "Action with retry", + timeline: [ + { time: "00s", event: "created" }, + { time: "05s", event: "running", note: "Wait Time ends here (5s)" }, + { time: "15s", event: "worker-failure" }, + { time: "18s", event: "running", note: "3s delay is NOT wait time" }, + { time: "26s", event: "completed" }, + ], + expected: { + waitTime: 5, + runTime: 18, + duration: 26, + otherTime: 3, + }, + }; + + expect.equals(scenario.expected.waitTime, 5); + expect.equals(scenario.expected.runTime, 18); + expect.equals( + scenario.expected.waitTime + scenario.expected.runTime + scenario.expected.otherTime, + scenario.expected.duration, + ); + }), + + test("Stats calculation logic - Run Time definition", () => { + // Requirement: Run Time = Sum of all time in the "running" state + + const scenario = { + name: "Multiple running periods", + timeline: [ + { time: "00s", event: "created" }, + { time: "05s", event: "running" }, + { time: "15s", event: "worker-failure", note: "Run period 1: 10s" }, + { time: "18s", event: "running" }, + { time: "26s", event: "completed", note: "Run period 2: 8s" }, + ], + expected: { + runTimePeriod1: 10, + runTimePeriod2: 8, + totalRunTime: 18, + }, + }; + + expect.equals(scenario.expected.runTimePeriod1 + scenario.expected.runTimePeriod2, scenario.expected.totalRunTime); + }), + + test("Stats calculation logic - Excluded time", () => { + // Requirement: Blocked/suspended/retry time is NOT counted in wait or run + + const excludedTimeTypes = [ + "Time between worker-failure and next running event", + "Time in suspended state (workflows)", + "Time in blocked state (workflows)", + "Time between unblocked and running events", + ]; + + // These times appear in Duration but not in Wait or Run + expect.greater_than(excludedTimeTypes.length, 0); + + // Formula: Duration = Wait Time + Run Time + Other + // Where Other Time = all the excluded time types above + const formula = "Duration = Wait + Run + (Blocked/Suspended/Retry)"; + expect.contains("Blocked", formula); + expect.contains("Suspended", formula); + }), + + test("Recursive stats calculation requirement", () => { + // Requirement: Total metrics include all subtasks recursively + // Example workflow structure: + // workflow-1 (wait=2, run=5) + // ├─ action-1 (wait=1, run=10) + // ├─ action-2 (wait=2, run=15) + // └─ workflow-2 (wait=1, run=3) + // └─ action-3 (wait=1, run=5) + + // Calculate expected totals recursively + const expectedTotalWaitTime = 2 + 1 + 2 + 1 + 1; // 7s + const expectedTotalRunTime = 5 + 10 + 15 + 3 + 5; // 38s + + expect.equals(expectedTotalWaitTime, 7); + expect.equals(expectedTotalRunTime, 38); + + // Backend provides all subtask events via subTaskEvents field + expect.equals(true, true); // Backend includes subTaskEvents in API response + }), + + test("SubTask events are required for recursive calculation", () => { + // The recent backend change added the subTaskEvents field + const apiResponse = { + task: { id: "workflow-123", type: "workflow" }, + events: [ + /* workflow events */ + ], + subTasks: { + actions: [{ id: "action-1" }, { id: "action-2" }], + workflows: [{ id: "workflow-2" }], + }, + subTaskEvents: { + "action-1": [ + /* action-1 events */ + ], + "action-2": [ + /* action-2 events */ + ], + "workflow-2": [ + /* workflow-2 events */ + ], + }, + }; + + expect.not_equals(apiResponse.subTaskEvents, undefined); + expect.equals(Object.keys(apiResponse.subTaskEvents).length, 3); + }), +]); 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..55ff62c --- /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", + 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", + 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/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..43f8ccc --- /dev/null +++ b/task-explorer/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Async Tasks Viewer + + +
+ + + diff --git a/task-explorer/frontend/package.json b/task-explorer/frontend/package.json new file mode 100644 index 0000000..64f354a --- /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": "workspace:*", + "@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/public/prevou-logo-icon-574x574.png b/task-explorer/frontend/public/prevou-logo-icon-574x574.png new file mode 100644 index 0000000000000000000000000000000000000000..8ea84e08ffb86cc0b42d67e5303d3dde5d0e52bc GIT binary patch literal 113512 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

cx$H^)@TrPz4MA-{U^DstIDkU~Ew$ufJ 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 IM6N<$f<#Q)dH?_b literal 0 HcmV?d00001 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/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..5635228 --- /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-viewer) +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..167ad2c --- /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-viewer-timezone"); + return parseTimezone(stored); + }); + + // Save to localStorage whenever it changes + useEffect(() => { + localStorage.setItem("task-viewer-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..2533fd3 --- /dev/null +++ b/task-explorer/frontend/src/routes/index.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/")({ + beforeLoad: ({ navigate }) => { + navigate({ + 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..6d5e2ed --- /dev/null +++ b/task-explorer/frontend/src/routes/login.tsx @@ -0,0 +1,114 @@ +export { Route }; + +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { useState, FormEvent } from "react"; +import { useAuth } from "@/lib/AuthContext"; + +const Route = createFileRoute("/login")({ + component: LoginPage, +}); + +// Use BASE_URL for non-root deployments (e.g., /task-viewer) +const basePath = import.meta.env.BASE_URL.replace(/\/$/, ""); + +function LoginPage() { + const navigate = useNavigate(); + 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(); + navigate({ to: "/tasks" }); + } 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 Viewer

+

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/routes/tasks/$type.$id.tsx b/task-explorer/frontend/src/routes/tasks/$type.$id.tsx new file mode 100644 index 0000000..f33d650 --- /dev/null +++ b/task-explorer/frontend/src/routes/tasks/$type.$id.tsx @@ -0,0 +1,160 @@ +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 type { TaskType } from "@/types/task"; + +function TaskDetailPage() { + const { type, id } = Route.useParams(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + 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], + 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..5ecc595 --- /dev/null +++ b/task-explorer/frontend/src/routes/tasks/index.tsx @@ -0,0 +1,614 @@ +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(","), + }); + } + }, []); + + // 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, + }, + ], + 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..d41f222 --- /dev/null +++ b/task-explorer/frontend/tests/auth/login.test.tsx @@ -0,0 +1,238 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { RouterProvider, createRouter, createRootRoute, createRoute } from "@tanstack/react-router"; +import { AuthProvider } from "@/lib/AuthContext"; +import React from "react"; + +// Mock LoginPage component for testing +function LoginPageComponent() { + const [username, setUsername] = React.useState(""); + const [password, setPassword] = React.useState(""); + const [error, setError] = React.useState(""); + const [loading, setLoading] = React.useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + setLoading(true); + + try { + const response = await fetch("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ username, password }), + }); + + if (response.ok) { + // Navigate would happen here + } 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 Viewer

+
+ setUsername(e.target.value)} disabled={loading} /> + setPassword(e.target.value)} + disabled={loading} + /> + {error &&
{error}
} + +
+
+ ); +} + +// Simplified render function +function renderLoginPage() { + return render( + + + , + ); +} + +describe("Login Page", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it("should render login form", async () => { + global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ authenticated: false }), + } as Response), + ); + + renderLoginPage(); + + 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 handle successful login", async () => { + const user = userEvent.setup(); + + // Mock auth check (initial) + global.fetch = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ authenticated: false }), + }) + // Mock login + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ success: true }), + }); + + renderLoginPage(); + + const usernameInput = screen.getByPlaceholderText("Username"); + const passwordInput = screen.getByPlaceholderText("Password"); + const submitButton = screen.getByRole("button", { name: /sign in/i }); + + await user.type(usernameInput, "admin"); + await user.type(passwordInput, "password123"); + await user.click(submitButton); + + await waitFor(() => { + expect(fetch).toHaveBeenCalledWith( + "/api/auth/login", + expect.objectContaining({ + method: "POST", + credentials: "include", + body: JSON.stringify({ username: "admin", password: "password123" }), + }), + ); + }); + }); + + 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" }), + }); + + renderLoginPage(); + + const usernameInput = screen.getByPlaceholderText("Username"); + const passwordInput = screen.getByPlaceholderText("Password"); + const submitButton = screen.getByRole("button", { name: /sign in/i }); + + await user.type(usernameInput, "wrong"); + await user.type(passwordInput, "wrong"); + await user.click(submitButton); + + await waitFor(() => { + expect(screen.getByRole("alert")).toHaveTextContent("Invalid credentials"); + }); + }); + + it("should disable form during submission", async () => { + const user = userEvent.setup(); + + // Create a promise that we can control + let resolveLogin: any; + const loginPromise = new Promise(resolve => { + resolveLogin = resolve; + }); + + global.fetch = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ authenticated: false }), + }) + .mockReturnValueOnce(loginPromise); + + renderLoginPage(); + + const usernameInput = screen.getByPlaceholderText("Username"); + const passwordInput = screen.getByPlaceholderText("Password"); + const submitButton = screen.getByRole("button", { name: /sign in/i }); + + await user.type(usernameInput, "admin"); + await user.type(passwordInput, "password"); + await user.click(submitButton); + + // Form should be disabled during submission + await waitFor(() => { + expect(usernameInput).toBeDisabled(); + expect(passwordInput).toBeDisabled(); + expect(submitButton).toBeDisabled(); + expect(submitButton).toHaveTextContent("Signing in..."); + }); + + // Resolve the promise + resolveLogin({ + ok: true, + json: () => Promise.resolve({ success: true }), + }); + + // Form should be enabled again + await waitFor(() => { + expect(usernameInput).not.toBeDisabled(); + expect(passwordInput).not.toBeDisabled(); + expect(submitButton).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")); + + renderLoginPage(); + + const usernameInput = screen.getByPlaceholderText("Username"); + const passwordInput = screen.getByPlaceholderText("Password"); + const submitButton = screen.getByRole("button", { name: /sign in/i }); + + await user.type(usernameInput, "admin"); + await user.type(passwordInput, "password"); + await user.click(submitButton); + + 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/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": [] +} From 5a001b8c199484ae2185ab97c99db8eaea526972 Mon Sep 17 00:00:00 2001 From: Can Tuncay Date: Wed, 15 Apr 2026 13:58:55 -0500 Subject: [PATCH 2/4] Use published packages for core and tasks libraries. --- pnpm-lock.yaml | 35 ++++++++++++++++++++++++----- task-explorer/backend/package.json | 4 ++-- task-explorer/frontend/package.json | 2 +- 3 files changed, 32 insertions(+), 9 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b7ded70..6cc1e5a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -67,11 +67,11 @@ importers: task-explorer/backend: dependencies: '@ambarltd/core': - specifier: workspace:* - version: link:../../core + specifier: ^0.1.12 + version: 0.1.12 '@ambarltd/tasks': - specifier: workspace:* - version: link:../../tasks + specifier: ^0.1.0 + version: 0.1.0 '@optique/core': specifier: 0.6.3 version: 0.6.3 @@ -137,8 +137,8 @@ importers: task-explorer/frontend: dependencies: '@ambarltd/core': - specifier: workspace:* - version: link:../../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)) @@ -255,6 +255,12 @@ packages: '@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'} @@ -3130,6 +3136,23 @@ snapshots: 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 diff --git a/task-explorer/backend/package.json b/task-explorer/backend/package.json index 6401b38..154a79c 100644 --- a/task-explorer/backend/package.json +++ b/task-explorer/backend/package.json @@ -15,8 +15,8 @@ "seed": "tsx src/scripts/seed.ts" }, "dependencies": { - "@ambarltd/core": "workspace:*", - "@ambarltd/tasks": "workspace:*", + "@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", diff --git a/task-explorer/frontend/package.json b/task-explorer/frontend/package.json index 64f354a..7b1665a 100644 --- a/task-explorer/frontend/package.json +++ b/task-explorer/frontend/package.json @@ -14,7 +14,7 @@ "test:coverage": "vitest run --coverage" }, "dependencies": { - "@ambarltd/core": "workspace:*", + "@ambarltd/core": "^0.1.12", "@tailwindcss/vite": "4.1.17", "@tanstack/react-query": "5.90.11", "@tanstack/react-router": "1.139.10", From 4c442f7625b906e1c8cdfcd753deb73cd1cef3c3 Mon Sep 17 00:00:00 2001 From: Can Tuncay Date: Wed, 15 Apr 2026 15:42:23 -0500 Subject: [PATCH 3/4] Consistent CI builds, test coverage, cleaned up .env files, updated README. --- .github/workflows/ambar-core.yaml | 6 +- .github/workflows/ambar-task-explorer.yaml | 63 ++++-- .gitignore | 3 + .prettierignore | 2 +- task-explorer/.prettierignore | 7 + task-explorer/README.md | 142 +++++++++----- task-explorer/backend/.env | 20 ++ task-explorer/backend/.env.example | 28 --- task-explorer/backend/.env.test | 14 +- task-explorer/backend/package.json | 6 +- task-explorer/backend/src/index.ts | 73 +++++-- task-explorer/backend/src/lib/api-schemas.ts | 2 +- task-explorer/backend/src/lib/environment.ts | 2 +- task-explorer/backend/src/scripts/seed.ts | 6 +- .../backend/tests/integration/.env.test | 13 ++ .../backend/tests/integration/api.test.ts | 183 +++++++++++++---- .../backend/tests/integration/auth.test.ts | 2 +- .../tests/integration/docker-compose.test.yml | 14 ++ task-explorer/backend/tests/unit/main.ts | 3 +- .../tests/unit/stats-requirements.test.ts | 184 ------------------ .../backend/tests/unit/utilities.test.ts | 4 +- task-explorer/backend/utils.sh | 69 +++++++ task-explorer/frontend/index.html | 3 +- .../public/prevou-logo-icon-574x574.png | Bin 113512 -> 0 bytes .../frontend/src/components/LoginForm.tsx | 112 +++++++++++ .../frontend/src/lib/AuthContext.tsx | 2 +- .../frontend/src/lib/TimezoneContext.tsx | 4 +- task-explorer/frontend/src/routes/index.tsx | 8 +- task-explorer/frontend/src/routes/login.tsx | 105 +--------- .../frontend/src/routes/tasks/$type.$id.tsx | 3 + .../frontend/src/routes/tasks/index.tsx | 3 + .../frontend/tests/auth/login.test.tsx | 161 +++++---------- task-explorer/frontend/utils.sh | 48 +++++ task-explorer/utils.sh | 94 +++++++++ 34 files changed, 798 insertions(+), 591 deletions(-) create mode 100644 task-explorer/.prettierignore create mode 100644 task-explorer/backend/.env delete mode 100644 task-explorer/backend/.env.example create mode 100644 task-explorer/backend/tests/integration/.env.test create mode 100644 task-explorer/backend/tests/integration/docker-compose.test.yml delete mode 100644 task-explorer/backend/tests/unit/stats-requirements.test.ts create mode 100755 task-explorer/backend/utils.sh delete mode 100644 task-explorer/frontend/public/prevou-logo-icon-574x574.png create mode 100644 task-explorer/frontend/src/components/LoginForm.tsx create mode 100755 task-explorer/frontend/utils.sh create mode 100755 task-explorer/utils.sh 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 index e90b847..a55a1e5 100644 --- a/.github/workflows/ambar-task-explorer.yaml +++ b/.github/workflows/ambar-task-explorer.yaml @@ -37,33 +37,66 @@ jobs: - name: Prettier check run: pnpm run format:check - - name: Backend typecheck - run: cd task-explorer/backend && pnpm exec tsc --noEmit + - name: Build + run: cd task-explorer && ./utils.sh build - name: Backend unit tests - run: cd task-explorer && pnpm --filter './backend' test:unit + 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: - # Provide dummy env vars for unit tests that don't need real DB - TASKS_DB_USER: test - TASKS_DB_PASSWORD: test TASKS_DB_HOST: localhost TASKS_DB_PORT: "5432" - TASKS_DB_NAME: test - TASKS_DB_NAMESPACE: test + 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 - - name: Frontend typecheck - run: cd task-explorer/frontend && pnpm exec tsc -b - - - name: Frontend tests - run: cd task-explorer && pnpm --filter './frontend' test - build-image: name: Build Docker Image runs-on: ubuntu-24.04 - needs: test + needs: [test, test-integration] steps: - name: Checkout uses: actions/checkout@v4 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 d72b23f..363a91b 100644 --- a/.prettierignore +++ b/.prettierignore @@ -3,5 +3,5 @@ dist pnpm-lock.yaml *.log -# TanStack Router generated files for task-explorer (formatted by plugin) +# TanStack Router auto-generated file **/routeTree.gen.ts 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/README.md b/task-explorer/README.md index 639f9e2..5c03712 100644 --- a/task-explorer/README.md +++ b/task-explorer/README.md @@ -13,65 +13,84 @@ A debugging tool for viewing and managing tasks and workflows created by the `@a - **Authentication**: Session-based login protecting all routes - **URL-Based State**: Filters persist in URL for sharing -## Development - -### Prerequisites +## Prerequisites - Node.js 24+ - pnpm 10+ -- A PostgreSQL database with `@ambarltd/tasks` tables (e.g., from a running virtual-agent stack) +- Docker (for integration tests and running the full stack) -### Setup +## Install ```bash pnpm install - -# Configure environment -cd backend -cp .env.example .env -# Edit .env with your database credentials ``` -### Running +## Development ```bash -# Frontend (Vite) + Backend (Express) concurrently +# 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 on :5173, proxies /api to :3000 -pnpm dev:api # Express on :3000 +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 ``` -### Testing +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 -# All tests -pnpm test +./utils.sh backend test:integration +``` -# Backend unit tests only -pnpm --filter './backend' test:unit +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. -# Frontend tests only -pnpm --filter './frontend' test +To filter by test name: -# Frontend tests with watch mode -pnpm --filter './frontend' test:watch +```bash +./utils.sh backend test:integration --match "auth" ``` -### Typecheck +### Typecheck only (faster than build) ```bash -cd backend && pnpm exec tsc --noEmit -cd frontend && pnpm exec tsc -b +./utils.sh backend typecheck +./utils.sh frontend typecheck ``` ## Docker -Build and run standalone: +### 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 build --build-arg GITHUB_TOKEN=$GITHUB_TOKEN -t task-explorer . docker run -p 8085:3000 \ -e TASKS_DB_HOST=host.docker.internal \ -e TASKS_DB_PORT=5432 \ @@ -82,35 +101,68 @@ docker run -p 8085:3000 \ -e AUTH_USERNAME=admin \ -e AUTH_PASSWORD=changeme123 \ -e SESSION_SECRET=your-secret-key \ - task-explorer + ghcr.io/ambarltd/task-explorer:latest ``` Then open http://localhost:8085. +### Published image + +The CI pipeline publishes `ghcr.io/ambarltd/task-explorer:latest` on every merge to `main`. Pull it directly in docker-compose without a local build. + +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 for testing: +Populate the database with sample tasks: ```bash cd backend SEED_CLEAR=true SEED_ACTIONS=500 SEED_WORKFLOWS=50 pnpm run seed ``` -Environment variables: `SEED_CLEAR=true` (clear first), `SEED_ACTIONS=N`, `SEED_WORKFLOWS=N`, `SEED_CLEAN_ONLY=true` (clear without populating). +| 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 | -| `PORT` | No | Server port (default: 3000) | -| `FRONTEND_DIST` | No | Path to frontend build (default: `../../frontend/dist`) | -| `BASE_PATH` | No | URL base path for non-root deployments | +| 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/.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.example b/task-explorer/backend/.env.example deleted file mode 100644 index 29b8aa2..0000000 --- a/task-explorer/backend/.env.example +++ /dev/null @@ -1,28 +0,0 @@ -# ============================================================================= -# Task Viewer - Environment Variables Configuration -# ============================================================================= -# Copy this file to .env and replace placeholder values with your actual credentials -# NEVER commit .env to version control! - -# ============================================================================= -# 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 Configuration -# ============================================================================= -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 index 9f25b9c..0d69fd4 100644 --- a/task-explorer/backend/.env.test +++ b/task-explorer/backend/.env.test @@ -1,19 +1,11 @@ -# ============================================================================= -# Task Viewer - Test Environment Configuration -# ============================================================================= -# Copy this file to .env.test for running unit tests locally -# Integration tests get env vars from docker-compose +# 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. -# ============================================================================= -# Authentication -# ============================================================================= AUTH_USERNAME=test-admin AUTH_PASSWORD=test-password SESSION_SECRET=test-session-secret-key -# ============================================================================= -# Database (PostgreSQL) -# ============================================================================= TASKS_DB_HOST=localhost TASKS_DB_PORT=5432 TASKS_DB_USER=test_user diff --git a/task-explorer/backend/package.json b/task-explorer/backend/package.json index 154a79c..7ac5b87 100644 --- a/task-explorer/backend/package.json +++ b/task-explorer/backend/package.json @@ -4,15 +4,15 @@ "private": true, "type": "module", "scripts": { - "dev": "nodemon --watch src --ext ts --exec \"tsx src/index.ts\"", - "build": "(tsc -p tsconfig.json || true) && tsc-alias -p tsconfig.json", + "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 src/scripts/seed.ts" + "seed": "tsx --env-file=.env src/scripts/seed.ts" }, "dependencies": { "@ambarltd/core": "^0.1.12", diff --git a/task-explorer/backend/src/index.ts b/task-explorer/backend/src/index.ts index fd8fcd4..3098c84 100644 --- a/task-explorer/backend/src/index.ts +++ b/task-explorer/backend/src/index.ts @@ -75,7 +75,7 @@ app.use( name: "session", secret: environment.SESSION_SECRET, httpOnly: true, - secure: false, // Allow HTTP for internal task viewer (typically accessed via localhost or internal network) + 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 }), @@ -91,21 +91,25 @@ let executor: DB["executor"]; let explorer: DB["explorer"]; let postgres: DB["postgres"]; let initialized = false; - -async function startup() { - try { - 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) { - console.error("Failed to initialize database:", error); - process.exit(1); +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; } // ============================================================================= @@ -136,6 +140,10 @@ function requireAuth(req: Request, res: Response, next: NextFunction): void { 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({ @@ -262,6 +270,10 @@ apiRouter.get("/tasks", requireAuth, async (req: Request, res: Response, next: N // 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); @@ -388,7 +400,7 @@ apiRouter.post( // 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-viewer's worker ID. + // task-explorer's worker ID. let result; if (type === "action") { @@ -414,7 +426,7 @@ apiRouter.post( return; } - res.json({ success: true, task: result }); + res.json({ success: true }); } catch (error) { next(error); } @@ -546,20 +558,39 @@ export { app, startup }; // Only start the server if not in test mode if (process.env["NODE_ENV"] !== "test") { - await startup(); - + // 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 { - await postgres.disconnect(); - console.log("Database connections closed"); + if (initialized) { + await postgres.disconnect(); + console.log("Database connections closed"); + } process.exit(0); } catch (error) { console.error("Error during shutdown:", error); diff --git a/task-explorer/backend/src/lib/api-schemas.ts b/task-explorer/backend/src/lib/api-schemas.ts index c5c4f69..f302298 100644 --- a/task-explorer/backend/src/lib/api-schemas.ts +++ b/task-explorer/backend/src/lib/api-schemas.ts @@ -126,7 +126,7 @@ function encodeTaskEvent(event: TaskActionEvent | TaskWorkflowEvent): any { const worker = event.worker ? event.worker.value : null; return { - id: taskId, // Use task ID as event ID for consistency + 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/environment.ts b/task-explorer/backend/src/lib/environment.ts index 47228e4..011ae7b 100644 --- a/task-explorer/backend/src/lib/environment.ts +++ b/task-explorer/backend/src/lib/environment.ts @@ -1,5 +1,5 @@ // Environment variables -// All environment variables used by the task-viewer backend are here. +// All environment variables used by the task-explorer backend are here. // // This module ensures all environment variables are: // diff --git a/task-explorer/backend/src/scripts/seed.ts b/task-explorer/backend/src/scripts/seed.ts index 63b31ca..de475e0 100644 --- a/task-explorer/backend/src/scripts/seed.ts +++ b/task-explorer/backend/src/scripts/seed.ts @@ -388,8 +388,8 @@ async function seed() { } // Make tasks "running" (10%) - only for tasks with run_at in the past - // Use task-viewer-cancel worker so tasks can be cancelled from UI - const cancelWorker = new WorkerId(process.env["CANCEL_WORKER_ID"] ?? "task-viewer-cancel"); + // 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]; @@ -551,7 +551,7 @@ async function seed() { } // Make workflows "running" (15%) - only for tasks with run_at in the past - // Use task-viewer-cancel worker so tasks can be cancelled from UI + // 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]; 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 index 681d252..f5e1a71 100644 --- a/task-explorer/backend/tests/integration/api.test.ts +++ b/task-explorer/backend/tests/integration/api.test.ts @@ -1,14 +1,39 @@ 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 ./integration-tests.sh test which provides isolated Docker infrastructure +// Run via ./utils.sh backend test:integration which provides isolated Docker infrastructure -// Initialize database once when module loads +// 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"], @@ -33,7 +58,7 @@ export const tests = group("Task API Endpoints", [ const response = await request(app).get("/api/info"); expect.equals(response.status, 200); - expect.equals(response.body.name, "task-viewer"); + expect.equals(response.body.name, "task-explorer"); expect.equals(response.body.version, "1.0"); expect.equals(typeof response.body.timestamp, "string"); }), @@ -44,21 +69,41 @@ export const tests = group("Task API Endpoints", [ const response = await request(app).get("/api/tasks").set("Cookie", authCookie); expect.equals(response.status, 200); - expect.not_equals(response.body.actions, undefined); - expect.not_equals(response.body.workflows, undefined); expect.equals(Array.isArray(response.body.actions), true); expect.equals(Array.isArray(response.body.workflows), true); }), - test("should filter by status", async () => { - const response = await request(app) - .get("/api/tasks") - .query({ status: "completed,failed" }) - .set("Cookie", authCookie); + 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); - expect.not_equals(response.body.actions, undefined); - expect.not_equals(response.body.workflows, undefined); + // 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 () => { @@ -74,17 +119,15 @@ export const tests = group("Task API Endpoints", [ .set("Cookie", authCookie); expect.equals(response.status, 200); - expect.not_equals(response.body.actions, undefined); - expect.not_equals(response.body.workflows, undefined); + // 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); - expect.not_equals(response.body.actions, undefined); - expect.not_equals(response.body.workflows, undefined); - // API returns limited results but doesn't include pagination metadata in response if (response.body.actions.length > 10) { expect.fail(`Expected actions.length <= 10, got ${response.body.actions.length}`); } @@ -94,7 +137,6 @@ export const tests = group("Task API Endpoints", [ const response = await request(app).get("/api/tasks").query({ limit: 10000 }).set("Cookie", authCookie); expect.equals(response.status, 200); - // Should clamp to max limit (1000) for each type if (response.body.actions.length > 1000) { expect.fail(`Expected actions.length <= 1000, got ${response.body.actions.length}`); } @@ -109,8 +151,25 @@ export const tests = group("Task API Endpoints", [ const response = await request(app).get("/api/tasks/search").query({ q: "test" }).set("Cookie", authCookie); expect.equals(response.status, 200); - expect.not_equals(response.body.actions, undefined); - expect.not_equals(response.body.workflows, undefined); + 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); }), ]), @@ -122,21 +181,33 @@ export const tests = group("Task API Endpoints", [ expect.not_equals(response.body.error, undefined); }), - test("should return task details if task exists", async () => { - // First, get a list of tasks to find a valid ID - const listResponse = await request(app).get("/api/tasks").query({ limit: 1 }).set("Cookie", authCookie); + 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); - // Try to get an action task - if (listResponse.body.actions.length > 0) { - const task = listResponse.body.actions[0]; - const response = await request(app).get(`/api/tasks/action/${task.id}`).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); + }), - if (response.status === 200) { - expect.not_equals(response.body.task, undefined); - expect.not_equals(response.body.events, undefined); - expect.equals(response.body.task.id, task.id); - } - } + 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); }), ]), @@ -154,7 +225,6 @@ export const tests = group("Task API Endpoints", [ .set("Cookie", authCookie); expect.equals(response.status, 200); - expect.not_equals(response.body.tasks, undefined); expect.equals(Array.isArray(response.body.tasks), true); }), @@ -168,7 +238,6 @@ export const tests = group("Task API Endpoints", [ .set("Cookie", authCookie); expect.equals(response.status, 400); - expect.not_equals(response.body.error, undefined); expect.contains("Invalid startTime", response.body.error); }), @@ -182,7 +251,6 @@ export const tests = group("Task API Endpoints", [ .set("Cookie", authCookie); expect.equals(response.status, 400); - expect.not_equals(response.body.error, undefined); expect.contains("Invalid endTime", response.body.error); }), @@ -201,10 +269,7 @@ export const tests = group("Task API Endpoints", [ .set("Cookie", authCookie); expect.equals(response.status, 200); - expect.not_equals(response.body.tasks, undefined); expect.equals(Array.isArray(response.body.tasks), true); - // Timeline endpoint returns both actions and workflows, limit applies per type - // So we can have up to 10 total tasks (5 actions + 5 workflows) }), ]), @@ -217,12 +282,10 @@ export const tests = group("Task API Endpoints", [ }), test("should return 404 for non-existent action task", async () => { - // Use a valid UUID format that doesn't exist 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.not_equals(response.body.error, undefined); expect.contains("Task not found", response.body.error); }), @@ -231,8 +294,44 @@ export const tests = group("Task API Endpoints", [ const response = await request(app).post(`/api/tasks/workflow/${fakeUUID}/cancel`).set("Cookie", authCookie); expect.equals(response.status, 404); - expect.not_equals(response.body.error, undefined); 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 index 1e3b457..c9879a5 100644 --- a/task-explorer/backend/tests/integration/auth.test.ts +++ b/task-explorer/backend/tests/integration/auth.test.ts @@ -136,7 +136,7 @@ export const tests = group("Authentication API", [ const response = await request(app).get("/api/info"); expect.equals(response.status, 200); - expect.equals(response.body.name, "task-viewer"); + 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/unit/main.ts b/task-explorer/backend/tests/unit/main.ts index f460537..d67c5f6 100644 --- a/task-explorer/backend/tests/unit/main.ts +++ b/task-explorer/backend/tests/unit/main.ts @@ -1,6 +1,5 @@ import { parseArgs, run } from "@ambarltd/core/test"; import * as utilitiesTests from "./utilities.test"; -import * as statsTests from "./stats-requirements.test"; async function main() { console.log("Running unit tests"); @@ -8,7 +7,7 @@ async function main() { const options = parseArgs(process.argv.slice(2)); - const testSuites = [utilitiesTests.tests, statsTests.tests]; + const testSuites = [utilitiesTests.tests]; await run(options, testSuites); } diff --git a/task-explorer/backend/tests/unit/stats-requirements.test.ts b/task-explorer/backend/tests/unit/stats-requirements.test.ts deleted file mode 100644 index 80a7299..0000000 --- a/task-explorer/backend/tests/unit/stats-requirements.test.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { group, test, expect } from "@ambarltd/core/test"; - -/** - * Tests documenting stats calculation requirements - * Note: Actual stats calculation happens in frontend (taskStatsUtils.ts) - * Backend only provides raw events data - */ - -export const tests = group("Stats Calculation Requirements", [ - test("Backend should provide all events needed for stats calculation", () => { - // Example task events for stats calculation - // Task: created_at=10:00:00, finished_at=10:00:26 - const events = [ - { 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" }, - ]; - - // Backend provides all events - expect.equals(events.length, 5); - expect.equals(events[0]?.event_type, "created"); - expect.equals(events[1]?.event_type, "running"); - expect.equals(events[2]?.event_type, "worker-failure"); - expect.equals(events[3]?.event_type, "running"); - expect.equals(events[4]?.event_type, "completed"); - - // Frontend will calculate: - // Wait Time = 5s (00s → 05s, until first "running") - // Run Time = 18s (05s → 15s = 10s, 18s → 26s = 8s) - // Duration = 26s (total elapsed) - // Other Time = 3s (15s → 18s, after worker-failure, NOT counted as wait) - }), - - test("Workflow events should include suspended/unblocked for stats", () => { - // Workflow with a suspended state - const events = [ - { 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" }, - ]; - - // Backend provides all events including suspended/unblocked - expect.equals(events.length, 6); - expect.equals( - events.some(e => e.event_type === "suspended"), - true, - ); - expect.equals( - events.some(e => e.event_type === "unblocked"), - true, - ); - - // Frontend will calculate: - // Wait Time = 2s (00s → 02s, until first "running") - // Run Time = 8s (02s → 07s = 5s, 11s → 14s = 3s) - // Duration = 14s (total elapsed) - // Other Time = 4s (07s → 11s, suspended/blocked, NOT counted as wait) - // Wakeups = 2 (two "running" events) - }), - - test("Stats calculation logic - Wait Time definition", () => { - // Requirement: Wait Time = ONLY initial queue time - // From created/run_at until the first "running" event - - const scenario = { - name: "Action with retry", - timeline: [ - { time: "00s", event: "created" }, - { time: "05s", event: "running", note: "Wait Time ends here (5s)" }, - { time: "15s", event: "worker-failure" }, - { time: "18s", event: "running", note: "3s delay is NOT wait time" }, - { time: "26s", event: "completed" }, - ], - expected: { - waitTime: 5, - runTime: 18, - duration: 26, - otherTime: 3, - }, - }; - - expect.equals(scenario.expected.waitTime, 5); - expect.equals(scenario.expected.runTime, 18); - expect.equals( - scenario.expected.waitTime + scenario.expected.runTime + scenario.expected.otherTime, - scenario.expected.duration, - ); - }), - - test("Stats calculation logic - Run Time definition", () => { - // Requirement: Run Time = Sum of all time in the "running" state - - const scenario = { - name: "Multiple running periods", - timeline: [ - { time: "00s", event: "created" }, - { time: "05s", event: "running" }, - { time: "15s", event: "worker-failure", note: "Run period 1: 10s" }, - { time: "18s", event: "running" }, - { time: "26s", event: "completed", note: "Run period 2: 8s" }, - ], - expected: { - runTimePeriod1: 10, - runTimePeriod2: 8, - totalRunTime: 18, - }, - }; - - expect.equals(scenario.expected.runTimePeriod1 + scenario.expected.runTimePeriod2, scenario.expected.totalRunTime); - }), - - test("Stats calculation logic - Excluded time", () => { - // Requirement: Blocked/suspended/retry time is NOT counted in wait or run - - const excludedTimeTypes = [ - "Time between worker-failure and next running event", - "Time in suspended state (workflows)", - "Time in blocked state (workflows)", - "Time between unblocked and running events", - ]; - - // These times appear in Duration but not in Wait or Run - expect.greater_than(excludedTimeTypes.length, 0); - - // Formula: Duration = Wait Time + Run Time + Other - // Where Other Time = all the excluded time types above - const formula = "Duration = Wait + Run + (Blocked/Suspended/Retry)"; - expect.contains("Blocked", formula); - expect.contains("Suspended", formula); - }), - - test("Recursive stats calculation requirement", () => { - // Requirement: Total metrics include all subtasks recursively - // Example workflow structure: - // workflow-1 (wait=2, run=5) - // ├─ action-1 (wait=1, run=10) - // ├─ action-2 (wait=2, run=15) - // └─ workflow-2 (wait=1, run=3) - // └─ action-3 (wait=1, run=5) - - // Calculate expected totals recursively - const expectedTotalWaitTime = 2 + 1 + 2 + 1 + 1; // 7s - const expectedTotalRunTime = 5 + 10 + 15 + 3 + 5; // 38s - - expect.equals(expectedTotalWaitTime, 7); - expect.equals(expectedTotalRunTime, 38); - - // Backend provides all subtask events via subTaskEvents field - expect.equals(true, true); // Backend includes subTaskEvents in API response - }), - - test("SubTask events are required for recursive calculation", () => { - // The recent backend change added the subTaskEvents field - const apiResponse = { - task: { id: "workflow-123", type: "workflow" }, - events: [ - /* workflow events */ - ], - subTasks: { - actions: [{ id: "action-1" }, { id: "action-2" }], - workflows: [{ id: "workflow-2" }], - }, - subTaskEvents: { - "action-1": [ - /* action-1 events */ - ], - "action-2": [ - /* action-2 events */ - ], - "workflow-2": [ - /* workflow-2 events */ - ], - }, - }; - - expect.not_equals(apiResponse.subTaskEvents, undefined); - expect.equals(Object.keys(apiResponse.subTaskEvents).length, 3); - }), -]); diff --git a/task-explorer/backend/tests/unit/utilities.test.ts b/task-explorer/backend/tests/unit/utilities.test.ts index 55ff62c..892c1be 100644 --- a/task-explorer/backend/tests/unit/utilities.test.ts +++ b/task-explorer/backend/tests/unit/utilities.test.ts @@ -193,7 +193,7 @@ export const tests = group("Backend Utilities", [ const encoded = encodeTaskEvent(mockEvent as any); expect.deep_equals(encoded, { - id: "action-123", + 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" }, @@ -211,7 +211,7 @@ export const tests = group("Backend Utilities", [ const encoded = encodeTaskEvent(mockEvent as any); expect.deep_equals(encoded, { - id: "workflow-456", + id: "workflow-456-completed-2024-01-01T00:05:00.000Z", event_type: "completed", created_at: "2024-01-01T00:05:00.000Z", details: null, 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/index.html b/task-explorer/frontend/index.html index 43f8ccc..5b3b105 100644 --- a/task-explorer/frontend/index.html +++ b/task-explorer/frontend/index.html @@ -2,9 +2,8 @@ - - Async Tasks Viewer + Task Explorer
diff --git a/task-explorer/frontend/public/prevou-logo-icon-574x574.png b/task-explorer/frontend/public/prevou-logo-icon-574x574.png deleted file mode 100644 index 8ea84e08ffb86cc0b42d67e5303d3dde5d0e52bc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 113512 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 z
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

cx$H^)@TrPz4MA-{U^DstIDkU~Ew$ufJ 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 IM6N<$f<#Q)dH?_b 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/lib/AuthContext.tsx b/task-explorer/frontend/src/lib/AuthContext.tsx index 5635228..a3bc483 100644 --- a/task-explorer/frontend/src/lib/AuthContext.tsx +++ b/task-explorer/frontend/src/lib/AuthContext.tsx @@ -11,7 +11,7 @@ interface AuthContextType { const AuthContext = createContext(undefined); -// Use BASE_URL for non-root deployments (e.g., /task-viewer) +// Use BASE_URL for non-root deployments (e.g., /task-explorer) const basePath = import.meta.env.BASE_URL.replace(/\/$/, ""); function AuthProvider({ children }: { children: ReactNode }) { diff --git a/task-explorer/frontend/src/lib/TimezoneContext.tsx b/task-explorer/frontend/src/lib/TimezoneContext.tsx index 167ad2c..aae4c01 100644 --- a/task-explorer/frontend/src/lib/TimezoneContext.tsx +++ b/task-explorer/frontend/src/lib/TimezoneContext.tsx @@ -18,13 +18,13 @@ function parseTimezone(value: string | null): Timezone { export function TimezoneProvider({ children }: { children: ReactNode }) { // Load timezone from localStorage or default to Local const [timezone, setTimezoneState] = useState(() => { - const stored = localStorage.getItem("task-viewer-timezone"); + const stored = localStorage.getItem("task-explorer-timezone"); return parseTimezone(stored); }); // Save to localStorage whenever it changes useEffect(() => { - localStorage.setItem("task-viewer-timezone", timezone); + localStorage.setItem("task-explorer-timezone", timezone); }, [timezone]); const setTimezone = (tz: Timezone) => { diff --git a/task-explorer/frontend/src/routes/index.tsx b/task-explorer/frontend/src/routes/index.tsx index 2533fd3..ce57fd0 100644 --- a/task-explorer/frontend/src/routes/index.tsx +++ b/task-explorer/frontend/src/routes/index.tsx @@ -1,9 +1,7 @@ -import { createFileRoute } from "@tanstack/react-router"; +import { createFileRoute, redirect } from "@tanstack/react-router"; export const Route = createFileRoute("/")({ - beforeLoad: ({ navigate }) => { - navigate({ - to: "/tasks", - }); + beforeLoad: () => { + throw redirect({ to: "/tasks" }); }, }); diff --git a/task-explorer/frontend/src/routes/login.tsx b/task-explorer/frontend/src/routes/login.tsx index 6d5e2ed..b5bd77f 100644 --- a/task-explorer/frontend/src/routes/login.tsx +++ b/task-explorer/frontend/src/routes/login.tsx @@ -1,114 +1,13 @@ export { Route }; import { createFileRoute, useNavigate } from "@tanstack/react-router"; -import { useState, FormEvent } from "react"; -import { useAuth } from "@/lib/AuthContext"; +import { LoginForm } from "@/components/LoginForm"; const Route = createFileRoute("/login")({ component: LoginPage, }); -// Use BASE_URL for non-root deployments (e.g., /task-viewer) -const basePath = import.meta.env.BASE_URL.replace(/\/$/, ""); - function LoginPage() { const navigate = useNavigate(); - 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(); - navigate({ to: "/tasks" }); - } 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 Viewer

-

Sign in to access the task dashboard

-
-
-
-
- - setUsername(e.target.value)} - disabled={loading} - /> -
-
- - setPassword(e.target.value)} - disabled={loading} - /> -
-
- - {error && ( -
-
{error}
-
- )} - -
- -
-
-
-
- ); + 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 index f33d650..d582ed2 100644 --- a/task-explorer/frontend/src/routes/tasks/$type.$id.tsx +++ b/task-explorer/frontend/src/routes/tasks/$type.$id.tsx @@ -9,12 +9,14 @@ 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 @@ -22,6 +24,7 @@ function TaskDetailPage() { // 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, }); diff --git a/task-explorer/frontend/src/routes/tasks/index.tsx b/task-explorer/frontend/src/routes/tasks/index.tsx index 5ecc595..f128649 100644 --- a/task-explorer/frontend/src/routes/tasks/index.tsx +++ b/task-explorer/frontend/src/routes/tasks/index.tsx @@ -117,6 +117,8 @@ function TasksListPage() { } }, []); + const { isAuthenticated } = useAuth(); + // Fetch tasks list with pagination and filters const { data, isLoading, isFetching, error, refetch } = useQuery({ queryKey: [ @@ -131,6 +133,7 @@ function TasksListPage() { limit: pageSize, }, ], + enabled: isAuthenticated, placeholderData: prev => prev, // Keep previous data visible while loading next page queryFn: () => listTasks({ diff --git a/task-explorer/frontend/tests/auth/login.test.tsx b/task-explorer/frontend/tests/auth/login.test.tsx index d41f222..362087f 100644 --- a/task-explorer/frontend/tests/auth/login.test.tsx +++ b/task-explorer/frontend/tests/auth/login.test.tsx @@ -1,87 +1,34 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { RouterProvider, createRouter, createRootRoute, createRoute } from "@tanstack/react-router"; import { AuthProvider } from "@/lib/AuthContext"; -import React from "react"; - -// Mock LoginPage component for testing -function LoginPageComponent() { - const [username, setUsername] = React.useState(""); - const [password, setPassword] = React.useState(""); - const [error, setError] = React.useState(""); - const [loading, setLoading] = React.useState(false); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setError(""); - setLoading(true); - - try { - const response = await fetch("/api/auth/login", { - method: "POST", - headers: { "Content-Type": "application/json" }, - credentials: "include", - body: JSON.stringify({ username, password }), - }); +import { LoginForm } from "@/components/LoginForm"; - if (response.ok) { - // Navigate would happen here - } 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 Viewer

-
- setUsername(e.target.value)} disabled={loading} /> - setPassword(e.target.value)} - disabled={loading} - /> - {error &&
{error}
} - -
-
- ); -} +// 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(); -// Simplified render function -function renderLoginPage() { +function renderLoginForm() { return render( - + , ); } -describe("Login Page", () => { +describe("Login Form", () => { beforeEach(() => { vi.resetAllMocks(); }); it("should render login form", async () => { - global.fetch = vi.fn(() => - Promise.resolve({ - ok: true, - json: () => Promise.resolve({ authenticated: false }), - } as Response), - ); + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ authenticated: false }), + }); - renderLoginPage(); + renderLoginForm(); expect(screen.getByPlaceholderText("Username")).toBeInTheDocument(); expect(screen.getByPlaceholderText("Password")).toBeInTheDocument(); @@ -93,31 +40,32 @@ describe("Login Page", () => { }); }); - it("should handle successful login", async () => { + it("should call onSuccess and verify session after successful login", async () => { const user = userEvent.setup(); - // Mock auth check (initial) global.fetch = vi .fn() + // Mock initial auth check (AuthProvider on mount) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ authenticated: false }), }) - // Mock login + // 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 }), }); - renderLoginPage(); - - const usernameInput = screen.getByPlaceholderText("Username"); - const passwordInput = screen.getByPlaceholderText("Password"); - const submitButton = screen.getByRole("button", { name: /sign in/i }); + renderLoginForm(); - await user.type(usernameInput, "admin"); - await user.type(passwordInput, "password123"); - await user.click(submitButton); + 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( @@ -128,6 +76,7 @@ describe("Login Page", () => { body: JSON.stringify({ username: "admin", password: "password123" }), }), ); + expect(onSuccess).toHaveBeenCalledOnce(); }); }); @@ -146,15 +95,11 @@ describe("Login Page", () => { json: () => Promise.resolve({ error: "Invalid credentials" }), }); - renderLoginPage(); + renderLoginForm(); - const usernameInput = screen.getByPlaceholderText("Username"); - const passwordInput = screen.getByPlaceholderText("Password"); - const submitButton = screen.getByRole("button", { name: /sign in/i }); - - await user.type(usernameInput, "wrong"); - await user.type(passwordInput, "wrong"); - await user.click(submitButton); + 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"); @@ -164,8 +109,7 @@ describe("Login Page", () => { it("should disable form during submission", async () => { const user = userEvent.setup(); - // Create a promise that we can control - let resolveLogin: any; + let resolveLogin: (value: unknown) => void; const loginPromise = new Promise(resolve => { resolveLogin = resolve; }); @@ -178,35 +122,26 @@ describe("Login Page", () => { }) .mockReturnValueOnce(loginPromise); - renderLoginPage(); - - const usernameInput = screen.getByPlaceholderText("Username"); - const passwordInput = screen.getByPlaceholderText("Password"); - const submitButton = screen.getByRole("button", { name: /sign in/i }); + renderLoginForm(); - await user.type(usernameInput, "admin"); - await user.type(passwordInput, "password"); - await user.click(submitButton); + await user.type(screen.getByPlaceholderText("Username"), "admin"); + await user.type(screen.getByPlaceholderText("Password"), "password"); + await user.click(screen.getByRole("button", { name: /sign in/i })); - // Form should be disabled during submission await waitFor(() => { - expect(usernameInput).toBeDisabled(); - expect(passwordInput).toBeDisabled(); - expect(submitButton).toBeDisabled(); - expect(submitButton).toHaveTextContent("Signing in..."); + expect(screen.getByPlaceholderText("Username")).toBeDisabled(); + expect(screen.getByPlaceholderText("Password")).toBeDisabled(); + expect(screen.getByRole("button")).toBeDisabled(); + expect(screen.getByRole("button")).toHaveTextContent("Signing in..."); }); - // Resolve the promise - resolveLogin({ + resolveLogin!({ ok: true, json: () => Promise.resolve({ success: true }), }); - // Form should be enabled again await waitFor(() => { - expect(usernameInput).not.toBeDisabled(); - expect(passwordInput).not.toBeDisabled(); - expect(submitButton).not.toBeDisabled(); + expect(screen.getByPlaceholderText("Username")).not.toBeDisabled(); }); }); @@ -221,15 +156,11 @@ describe("Login Page", () => { }) .mockRejectedValueOnce(new Error("Network error")); - renderLoginPage(); - - const usernameInput = screen.getByPlaceholderText("Username"); - const passwordInput = screen.getByPlaceholderText("Password"); - const submitButton = screen.getByRole("button", { name: /sign in/i }); + renderLoginForm(); - await user.type(usernameInput, "admin"); - await user.type(passwordInput, "password"); - await user.click(submitButton); + 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/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/utils.sh b/task-explorer/utils.sh new file mode 100755 index 0000000..c457163 --- /dev/null +++ b/task-explorer/utils.sh @@ -0,0 +1,94 @@ +#!/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 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 From 518702dfd7d3060fc07dab8e325fd8c053d85df7 Mon Sep 17 00:00:00 2001 From: Can Tuncay Date: Wed, 15 Apr 2026 23:38:35 -0500 Subject: [PATCH 4/4] Versioned docker image in github image repo --- .github/workflows/ambar-task-explorer.yaml | 7 ++++++- task-explorer/README.md | 7 ++++++- task-explorer/utils.sh | 4 +++- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ambar-task-explorer.yaml b/.github/workflows/ambar-task-explorer.yaml index a55a1e5..3a1536d 100644 --- a/.github/workflows/ambar-task-explorer.yaml +++ b/.github/workflows/ambar-task-explorer.yaml @@ -120,6 +120,11 @@ jobs: 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 @@ -128,6 +133,6 @@ jobs: push: true tags: | ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest - ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.value }} build-args: | GITHUB_TOKEN=${{ secrets.READ_ACCESS_TO_REPOS }} diff --git a/task-explorer/README.md b/task-explorer/README.md index 5c03712..ce885e1 100644 --- a/task-explorer/README.md +++ b/task-explorer/README.md @@ -108,7 +108,12 @@ Then open http://localhost:8085. ### Published image -The CI pipeline publishes `ghcr.io/ambarltd/task-explorer:latest` on every merge to `main`. Pull it directly in docker-compose without a local build. +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: diff --git a/task-explorer/utils.sh b/task-explorer/utils.sh index c457163..141c973 100755 --- a/task-explorer/utils.sh +++ b/task-explorer/utils.sh @@ -59,7 +59,9 @@ case "${1:-help}" in echo "Error: GITHUB_TOKEN is not set in the environment." >&2 exit 1 fi - docker build --build-arg GITHUB_TOKEN="$GITHUB_TOKEN" -t task-explorer:local "$ROOT_DIR" + docker build --build-arg GITHUB_TOKEN="$GITHUB_TOKEN" \ + -t "ghcr.io/ambarltd/task-explorer:local" \ + "$ROOT_DIR" ;; format)