From dea6824a5b21590940cbfd042bbe14262bbce4d8 Mon Sep 17 00:00:00 2001 From: Barry <43803274+BarryHenryJr@users.noreply.github.com> Date: Fri, 13 Mar 2026 08:24:45 -1000 Subject: [PATCH 1/4] Add backend API, frontend types, SDK & CI updates Large refactor and feature addition across the repo: introduces a structured backend API (core api, selectors, services, management commands, tasks) with tests and new Django settings, adds OpenAPI export and generated frontend types, and ships a small Python SDK with tests. CI workflow was overhauled: renamed/streamlined jobs (validate, security, codeql), upgraded runtimes, pnpm support, automated OpenAPI export/verification, and unified validation steps to run backend/frontend/SDK checks and tests. Makefile and README were updated to reflect the new developer workflow and commands. Removed some legacy files and simplified the project layout to improve developer experience and ensure type-safe frontend integration with the backend API. --- .github/workflows/cicd.yml | 561 +-- Makefile | 9 +- README.md | 784 +--- backend/.env.example | 10 + backend/.env.test | 5 + backend/SETUP.md | 255 +- backend/core/__init__.py | 3 + backend/core/admin.py | 10 +- backend/core/api.py | 155 - backend/core/api/__init__.py | 3 + backend/core/api/events.py | 63 + backend/core/api/schemas.py | 59 + .../commands/export_openapi_schema.py | 26 + .../commands/purge_expired_events.py | 13 + backend/core/selectors/__init__.py | 3 + backend/core/selectors/events.py | 48 + backend/core/services/__init__.py | 4 + backend/core/services/health.py | 31 + backend/core/services/ingestion.py | 73 + backend/core/tasks.py | 31 - backend/core/tasks/__init__.py | 7 + backend/core/tasks/events.py | 112 + backend/core/tests.py | 2 - backend/core/tests/__init__.py | 1 + backend/core/tests/test_api.py | 184 + backend/core/tests/test_tasks.py | 32 + backend/poetry.lock | 71 +- backend/pyproject.toml | 7 +- backend/requirements.txt | 9 - backend/telemetry_taco/api.py | 6 + backend/telemetry_taco/settings.py | 254 - backend/telemetry_taco/settings/__init__.py | 1 + backend/telemetry_taco/settings/base.py | 164 + .../telemetry_taco/settings/development.py | 16 + backend/telemetry_taco/settings/production.py | 12 + backend/telemetry_taco/settings/test.py | 29 + backend/telemetry_taco/urls.py | 6 +- frontend/eslint.config.js | 15 +- frontend/openapi.json | 389 ++ frontend/package-lock.json | 4173 ----------------- frontend/package.json | 14 +- frontend/src/App.tsx | 21 +- frontend/src/app/App.tsx | 83 + frontend/src/app/providers.tsx | 9 + frontend/src/app/query-client.ts | 12 + frontend/src/components/InsightChart.tsx | 173 - frontend/src/components/LiveEventStream.tsx | 104 - .../live-event-stream-card.test.tsx | 48 + .../components/live-event-stream-card.tsx | 120 + frontend/src/features/events/queries.ts | 30 + .../components/insight-chart-card.test.tsx | 37 + .../components/insight-chart-card.tsx | 52 + .../components/insight-line-chart.tsx | 54 + frontend/src/features/insights/queries.ts | 30 + frontend/src/hooks/useEventStream.ts | 83 - frontend/src/index.css | 46 +- frontend/src/main.tsx | 7 +- frontend/src/shared/api/client.ts | 40 + frontend/src/shared/api/generated.ts | 338 ++ frontend/src/shared/api/types.ts | 20 + frontend/src/shared/ui/panel-message.tsx | 19 + frontend/src/test/setup.ts | 1 + frontend/src/test/test-utils.tsx | 20 + frontend/src/vite-env.d.ts | 2 +- frontend/tailwind.config.js | 5 +- frontend/vite.config.ts | 7 +- package.json | 30 +- pnpm-lock.yaml | 974 +++- restart-backend.sh | 3 +- sdk/pyproject.toml | 13 + sdk/telemetry_taco.py | 299 -- sdk/telemetry_taco/__init__.py | 3 + sdk/telemetry_taco/client.py | 212 + sdk/tests/test_client.py | 68 + seed.sh | 5 +- start.sh | 11 +- 76 files changed, 3955 insertions(+), 6674 deletions(-) create mode 100644 backend/.env.example create mode 100644 backend/.env.test delete mode 100644 backend/core/api.py create mode 100644 backend/core/api/__init__.py create mode 100644 backend/core/api/events.py create mode 100644 backend/core/api/schemas.py create mode 100644 backend/core/management/commands/export_openapi_schema.py create mode 100644 backend/core/management/commands/purge_expired_events.py create mode 100644 backend/core/selectors/__init__.py create mode 100644 backend/core/selectors/events.py create mode 100644 backend/core/services/__init__.py create mode 100644 backend/core/services/health.py create mode 100644 backend/core/services/ingestion.py delete mode 100644 backend/core/tasks.py create mode 100644 backend/core/tasks/__init__.py create mode 100644 backend/core/tasks/events.py delete mode 100644 backend/core/tests.py create mode 100644 backend/core/tests/__init__.py create mode 100644 backend/core/tests/test_api.py create mode 100644 backend/core/tests/test_tasks.py delete mode 100644 backend/requirements.txt create mode 100644 backend/telemetry_taco/api.py delete mode 100644 backend/telemetry_taco/settings.py create mode 100644 backend/telemetry_taco/settings/__init__.py create mode 100644 backend/telemetry_taco/settings/base.py create mode 100644 backend/telemetry_taco/settings/development.py create mode 100644 backend/telemetry_taco/settings/production.py create mode 100644 backend/telemetry_taco/settings/test.py create mode 100644 frontend/openapi.json delete mode 100644 frontend/package-lock.json create mode 100644 frontend/src/app/App.tsx create mode 100644 frontend/src/app/providers.tsx create mode 100644 frontend/src/app/query-client.ts delete mode 100644 frontend/src/components/InsightChart.tsx delete mode 100644 frontend/src/components/LiveEventStream.tsx create mode 100644 frontend/src/features/events/components/live-event-stream-card.test.tsx create mode 100644 frontend/src/features/events/components/live-event-stream-card.tsx create mode 100644 frontend/src/features/events/queries.ts create mode 100644 frontend/src/features/insights/components/insight-chart-card.test.tsx create mode 100644 frontend/src/features/insights/components/insight-chart-card.tsx create mode 100644 frontend/src/features/insights/components/insight-line-chart.tsx create mode 100644 frontend/src/features/insights/queries.ts delete mode 100644 frontend/src/hooks/useEventStream.ts create mode 100644 frontend/src/shared/api/client.ts create mode 100644 frontend/src/shared/api/generated.ts create mode 100644 frontend/src/shared/api/types.ts create mode 100644 frontend/src/shared/ui/panel-message.tsx create mode 100644 frontend/src/test/setup.ts create mode 100644 frontend/src/test/test-utils.tsx create mode 100644 sdk/pyproject.toml delete mode 100644 sdk/telemetry_taco.py create mode 100644 sdk/telemetry_taco/__init__.py create mode 100644 sdk/telemetry_taco/client.py create mode 100644 sdk/tests/test_client.py diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 5e8b3a6..e9551ef 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -1,4 +1,4 @@ -name: CI/CD Pipeline +name: CI on: push: @@ -6,222 +6,111 @@ on: pull_request: branches: [main, develop] -# Concurrency control: cancel in-progress runs when a new push/PR update occurs -# This ensures only the latest code is tested, saving CI resources concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true env: - PYTHON_VERSION: "3.11" - POSTGRES_VERSION: "16" - REDIS_VERSION: "7" + PYTHON_VERSION: "3.13" + NODE_VERSION: "20" jobs: - # CodeQL Security Analysis - codeql-analysis: - name: CodeQL Analysis + validate: + name: Validate runs-on: ubuntu-latest - timeout-minutes: 45 - permissions: - actions: read - contents: read - security-events: write - strategy: - fail-fast: false - matrix: - language: ["python", "javascript"] + timeout-minutes: 25 steps: - name: Checkout code uses: actions/checkout@v4 - - name: Initialize CodeQL - uses: github/codeql-action/init@v3 - with: - languages: ${{ matrix.language }} - config-file: ./.github/codeql-config.yml - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # For more details, see: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/customizing-code-scanning#using-queries-in-ql-packs - # queries: security-extended,security-and-quality - - - name: Set up Python (for CodeQL) - if: matrix.language == 'python' + - name: Set up Python uses: actions/setup-python@v5 with: python-version: ${{ env.PYTHON_VERSION }} - - name: Install Poetry (for CodeQL) - if: matrix.language == 'python' + - name: Install Poetry uses: snok/install-poetry@v1 with: version: latest virtualenvs-create: true virtualenvs-in-project: true - - name: Install Python dependencies (for CodeQL) - if: matrix.language == 'python' - working-directory: ./backend - run: | - # Update lock file if pyproject.toml has changed - poetry lock --no-update 2>/dev/null || poetry lock || true - poetry install --no-interaction --no-ansi - - - name: Autobuild (Python) - if: matrix.language == 'python' - uses: github/codeql-action/autobuild@v3 + - name: Set up pnpm + uses: pnpm/action-setup@v4 with: - working-directory: ./backend + version: 10 - - name: Set up pnpm (for CodeQL) - if: matrix.language == 'javascript' - uses: pnpm/action-setup@v2 - with: - version: 9 - - - name: Set up Node.js (for CodeQL) - if: matrix.language == 'javascript' + - name: Set up Node uses: actions/setup-node@v4 with: - node-version: "18" + node-version: ${{ env.NODE_VERSION }} + cache: pnpm - - name: Install JavaScript dependencies (for CodeQL) - if: matrix.language == 'javascript' - run: pnpm install --frozen-lockfile - - - name: Autobuild (JavaScript) - if: matrix.language == 'javascript' - uses: github/codeql-action/autobuild@v3 - with: - working-directory: ./frontend - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 - with: - category: "/language:${{ matrix.language }}" - upload: true - output: ${{ runner.temp }}/codeql-results - - # Code Quality and Linting - lint: - name: Code Quality Checks - runs-on: ubuntu-latest - timeout-minutes: 15 - permissions: - contents: read - issues: write - pull-requests: write - steps: - - name: Checkout code - uses: actions/checkout@v4 + - name: Install backend dependencies + working-directory: ./backend + run: poetry install --no-interaction --no-ansi - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: ${{ env.PYTHON_VERSION }} + - name: Install frontend dependencies + run: pnpm install --frozen-lockfile - - name: Install Poetry - uses: snok/install-poetry@v1 - with: - version: latest - virtualenvs-create: true - virtualenvs-in-project: true + - name: Lint backend + working-directory: ./backend + run: poetry run ruff check . - - name: Load cached venv - id: cached-poetry-dependencies - uses: actions/cache@v4 - with: - path: backend/.venv - key: venv-${{ runner.os }}-${{ env.PYTHON_VERSION }}-${{ hashFiles('backend/pyproject.toml') }} + - name: Check backend formatting + working-directory: ./backend + run: poetry run ruff format --check . - - name: Install dependencies + - name: Django system check working-directory: ./backend - run: | - # Update lock file if pyproject.toml has changed - poetry lock --no-update 2>/dev/null || poetry lock || true - poetry install --no-interaction --no-ansi + env: + DJANGO_SETTINGS_MODULE: telemetry_taco.settings.test + run: poetry run python manage.py check - - name: Run Ruff (linter) + - name: Run backend tests working-directory: ./backend - # --output-format=github creates inline annotations visible in PR "Files changed" tab - run: poetry run ruff check --output-format=github . + env: + POETRY_CACHE_DIR: /tmp/pypoetry-cache + run: poetry run pytest - - name: Run Ruff (formatter check) + - name: Export OpenAPI schema working-directory: ./backend - id: ruff_format - continue-on-error: true - run: | - # Run format check and capture output - poetry run ruff format --check . > format-output.txt 2>&1 || format_exit=$? - - # Parse output and create GitHub annotations for each file that needs formatting - if [ -f format-output.txt ]; then - files_needing_format="" - while IFS= read -r line; do - if [[ $line == "Would reformat: "* ]]; then - file=$(echo "$line" | sed 's/Would reformat: //') - echo "::error file=backend/$file::File needs formatting. Run 'poetry run ruff format .' to fix." - files_needing_format="${files_needing_format}- \`$file\`"$'\n' - fi - done < format-output.txt - - # Show summary - if grep -q "Would reformat:" format-output.txt; then - echo "::error::Some files need formatting. Run 'poetry run ruff format .' in the backend directory to fix." - cat format-output.txt - # Store files list for PR comment - echo "files<> $GITHUB_OUTPUT - echo "$files_needing_format" >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT - fi - fi - - # Exit with the original exit code if there were issues - exit ${format_exit:-0} - - - name: Comment on formatting issues - if: steps.ruff_format.outcome == 'failure' && github.event_name == 'pull_request' - uses: actions/github-script@v7 - with: - script: | - const files = `${{ steps.ruff_format.outputs.files }}`.trim(); - const body = `## ๐Ÿ”ง Code Formatting Required + env: + DJANGO_SETTINGS_MODULE: telemetry_taco.settings.test + run: poetry run python manage.py export_openapi_schema ../frontend/openapi.json - Some files need to be formatted. Please run the following command to fix: + - name: Verify generated frontend API types + working-directory: ./frontend + run: | + pnpm generate:api-types + git diff --exit-code src/shared/api/generated.ts openapi.json - \`\`\`bash - cd backend - poetry run ruff format . - \`\`\` + - name: Lint frontend + working-directory: ./frontend + run: pnpm lint - Then commit and push the changes. + - name: Type-check frontend + working-directory: ./frontend + run: pnpm type-check - **Files needing formatting:** - ${files || 'See annotations above for details'} - `; + - name: Run frontend tests + working-directory: ./frontend + run: pnpm test - github.rest.issues.createComment({ - issue_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - body: body - }); + - name: Build frontend + working-directory: ./frontend + run: pnpm build - - name: Run Django check - working-directory: ./backend - env: - SECRET_KEY: ci-test-secret-key-for-django-check-only-not-for-production-use-this-is-a-long-key-to-meet-validation-requirements - run: poetry run python manage.py check + - name: Run SDK tests + working-directory: ./sdk + run: | + PYTEST_PYTHON=$(poetry -C ../backend run python -c 'import sys; print(sys.executable)') + "$PYTEST_PYTHON" -m pytest tests - # Security Scanning security: - name: Security Scanning + name: Security runs-on: ubuntu-latest - timeout-minutes: 20 - permissions: - contents: read - issues: write - pull-requests: write + timeout-minutes: 15 steps: - name: Checkout code uses: actions/checkout@v4 @@ -238,316 +127,76 @@ jobs: virtualenvs-create: true virtualenvs-in-project: true - - name: Install dependencies + - name: Install backend dependencies working-directory: ./backend - run: | - # Update lock file if pyproject.toml has changed - poetry lock --no-update 2>/dev/null || poetry lock || true - poetry install --no-interaction --no-ansi - - - name: Install Poetry export plugin - run: poetry self add poetry-plugin-export - - - name: Run Safety check - working-directory: ./backend - run: | - pip install safety - poetry export -f requirements.txt --output requirements-export.txt --without-hashes - safety check --file requirements-export.txt + run: poetry install --no-interaction --no-ansi - - name: Run Bandit (security linter) + - name: Run Bandit working-directory: ./backend - continue-on-error: true - id: bandit_scan - run: | - pip install bandit[toml] - # Use bandit.yaml config file to skip false positives - # Generate JSON report for artifact upload - bandit -r . -f json -o bandit-report.json -c bandit.yaml || bandit_exit=$? - # Capture human-readable summary for PR comment - bandit -r . -c bandit.yaml > bandit-summary.txt 2>&1 || true - # Store exit code for later use - echo "exit_code=${bandit_exit:-0}" >> $GITHUB_OUTPUT - # Read summary and store for PR comment (escape for JSON) - if [ -f bandit-summary.txt ]; then - # Escape newlines and quotes for JSON - summary=$(cat bandit-summary.txt | sed 's/\\/\\\\/g' | sed 's/"/\\"/g' | awk '{printf "%s\\n", $0}') - echo "summary<> $GITHUB_OUTPUT - echo "$summary" >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT - fi - - - name: Upload Bandit report - if: always() - uses: actions/upload-artifact@v4 - with: - name: bandit-security-report - path: backend/bandit-report.json - retention-days: 7 + run: poetry run bandit -r . -c bandit.yaml - - name: Comment Bandit results on PR - if: always() && github.event_name == 'pull_request' - uses: actions/github-script@v7 - with: - script: | - const summary = `${{ steps.bandit_scan.outputs.summary }}`.trim(); - const exitCode = parseInt(`${{ steps.bandit_scan.outputs.exit_code }}` || '0'); - - if (!summary) { - console.log('No Bandit summary available'); - return; - } - - // Check if there are actual issues (Bandit exits with 1 if issues found) - const hasIssues = exitCode !== 0 || summary.includes('Issue:') || summary.includes('Severity:'); - - // Format the summary for GitHub markdown - let body; - if (hasIssues) { - body = `## ๐Ÿ”’ Bandit Security Scan Results - - โš ๏ธ **Security issues found!** Please review the findings below: - -
- Click to expand Bandit scan results - - \`\`\` - ${summary} - \`\`\` - -
- - **Note:** This is a non-blocking check. Please review the findings and address any legitimate security concerns. - - Full JSON report is available in the workflow artifacts.`; - } else { - body = `## ๐Ÿ”’ Bandit Security Scan Results - - โœ… **No security issues found!** - -
- Click to view scan summary - - \`\`\` - ${summary} - \`\`\` - -
`; - } - - // Find existing comment to update it - const comments = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - }); - - const botComment = comments.data.find( - comment => comment.user.type === 'Bot' && comment.body.includes('Bandit Security Scan Results') - ); - - if (botComment) { - // Update existing comment - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: botComment.id, - body: body - }); - } else { - // Create new comment - await github.rest.issues.createComment({ - issue_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - body: body - }); - } - - # Unit and Integration Tests - test: - name: Run Tests + codeql-analysis: + name: CodeQL runs-on: ubuntu-latest - timeout-minutes: 30 - services: - postgres: - image: postgres:16 - env: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - POSTGRES_DB: telemetry_taco_test - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - ports: - - 5432:5432 - - redis: - image: redis:7 - options: >- - --health-cmd "redis-cli ping" - --health-interval 10s - --health-timeout 5s - --health-retries 5 - ports: - - 6379:6379 - + timeout-minutes: 45 + permissions: + actions: read + contents: read + security-events: write + strategy: + fail-fast: false + matrix: + language: ["python", "javascript"] steps: - name: Checkout code uses: actions/checkout@v4 + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + config-file: ./.github/codeql-config.yml + - name: Set up Python + if: matrix.language == 'python' uses: actions/setup-python@v5 with: python-version: ${{ env.PYTHON_VERSION }} - name: Install Poetry + if: matrix.language == 'python' uses: snok/install-poetry@v1 with: version: latest virtualenvs-create: true virtualenvs-in-project: true - - name: Load cached venv - id: cached-poetry-dependencies - uses: actions/cache@v4 - with: - path: backend/.venv - key: venv-${{ runner.os }}-${{ env.PYTHON_VERSION }}-${{ hashFiles('backend/pyproject.toml') }} - - - name: Install dependencies - working-directory: ./backend - run: | - # Update lock file if pyproject.toml has changed - poetry lock --no-update 2>/dev/null || poetry lock || true - poetry install --no-interaction --no-ansi - - - name: Run migrations + - name: Install backend dependencies + if: matrix.language == 'python' working-directory: ./backend - env: - DATABASE_URL: postgresql://postgres:postgres@localhost:5432/telemetry_taco_test - REDIS_URL: redis://localhost:6379/0 - SECRET_KEY: ci-test-secret-key-for-testing-only-not-for-production-use-this-is-a-long-key-to-meet-validation-requirements - DEBUG: "False" - run: poetry run python manage.py migrate + run: poetry install --no-interaction --no-ansi - - name: Run tests - working-directory: ./backend - env: - DATABASE_URL: postgresql://postgres:postgres@localhost:5432/telemetry_taco_test - REDIS_URL: redis://localhost:6379/0 - SECRET_KEY: ci-test-secret-key-for-testing-only-not-for-production-use-this-is-a-long-key-to-meet-validation-requirements - DEBUG: "False" - run: poetry run python manage.py test --verbosity=2 - - # Docker Build and Validation - docker-build: - name: Docker Build Test - runs-on: ubuntu-latest - timeout-minutes: 20 - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Build backend Docker image - uses: docker/build-push-action@v5 + - name: Set up pnpm + if: matrix.language == 'javascript' + uses: pnpm/action-setup@v4 with: - context: ./backend - file: ./backend/Dockerfile - push: false - load: true - tags: telemetry-taco-backend:test - cache-from: type=gha - cache-to: type=gha,mode=max - - - name: Test Docker image - run: | - docker run --rm -e SECRET_KEY="ci-test-secret-key-for-docker-build-test-only-not-for-production-use-this-is-a-long-key-to-meet-validation-requirements" telemetry-taco-backend:test python manage.py check - - # Docker Compose Integration Test - docker-compose-test: - name: Docker Compose Integration Test - runs-on: ubuntu-latest - timeout-minutes: 30 - steps: - - name: Checkout code - uses: actions/checkout@v4 + version: 10 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Start services with docker compose - run: | - docker compose up -d db redis - sleep 10 - - - name: Wait for services to be healthy - run: | - timeout 60 bash -c 'until docker compose ps | grep -q "healthy"; do sleep 2; done' - - - name: Build backend image - run: docker compose build backend - - - name: Run migrations in container - env: - SECRET_KEY: ci-test-secret-key-for-docker-compose-test-only-not-for-production-use-this-is-a-long-key-to-meet-validation-requirements - run: | - # Use a one-off container for migrations (more reliable than exec) - docker compose run --rm -e SECRET_KEY="$SECRET_KEY" backend python manage.py migrate - - - name: Run tests in container - env: - SECRET_KEY: ci-test-secret-key-for-docker-compose-test-only-not-for-production-use-this-is-a-long-key-to-meet-validation-requirements - run: | - # Use a one-off container for tests to avoid memory conflicts with running services - # This prevents OOM (exit code 137) issues when running tests - docker compose run --rm -e SECRET_KEY="$SECRET_KEY" backend python manage.py test --verbosity=2 - - - name: Start backend service - run: docker compose up -d backend - - - name: Health check - run: | - sleep 5 - curl -f http://localhost:8000/ || echo "Health check endpoint may not exist" - - - name: Stop services - if: always() - run: docker compose down -v + - name: Set up Node + if: matrix.language == 'javascript' + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm - # Docker Build Validation for PRs (validates build without pushing) - docker-build-validation: - name: Docker Build Validation - runs-on: ubuntu-latest - timeout-minutes: 20 - if: github.event_name == 'pull_request' - needs: [lint, test, security, docker-build] - steps: - - name: Checkout code - uses: actions/checkout@v4 + - name: Install JavaScript dependencies + if: matrix.language == 'javascript' + run: pnpm install --frozen-lockfile - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + - name: Autobuild + uses: github/codeql-action/autobuild@v3 - - name: Validate Docker image build - uses: docker/build-push-action@v5 + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 with: - context: ./backend - file: ./backend/Dockerfile - push: false - load: true - tags: telemetry-taco-backend:pr-validation - cache-from: type=gha - cache-to: type=gha,mode=max - - - name: Test Docker image - run: | - docker run --rm \ - -e SECRET_KEY="ci-test-secret-key-for-docker-validation-only-not-for-production-use-this-is-a-long-key-to-meet-validation-requirements" \ - telemetry-taco-backend:pr-validation \ - python manage.py check + category: "/language:${{ matrix.language }}" diff --git a/Makefile b/Makefile index d465f2b..1b5df57 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help start stop dev services migrate test clean +.PHONY: help start stop dev services migrate test clean validate help: ## Show this help message @echo "TelemetryTaco Development Commands" @@ -67,9 +67,10 @@ clean: stop ## Stop services and clean up logs @echo "โœ… Cleaned up" test: ## Run tests - @echo "๐Ÿงช Running tests..." - cd backend && poetry run python manage.py test - cd frontend && pnpm test || true + @pnpm test + +validate: ## Run backend, frontend, and SDK validation + @pnpm validate:all seed: ## Seed database with historical event data @echo "๐Ÿ“Š Seeding database..." diff --git a/README.md b/README.md index 0a34855..43f03a5 100644 --- a/README.md +++ b/README.md @@ -1,503 +1,171 @@ -# ๐ŸŒฎ TelemetryTaco - -
- -![Python](https://img.shields.io/badge/python-3.11%20%7C%203.12-blue.svg) -![Django](https://img.shields.io/badge/django-5.0+-green.svg) -![React](https://img.shields.io/badge/react-18.2.0-61dafb.svg) -![TypeScript](https://img.shields.io/badge/typescript-5.3.3-3178c6.svg) -![License](https://img.shields.io/badge/license-MIT-blue.svg) -![Poetry](https://img.shields.io/badge/poetry-2.2+-orange.svg) - -**Lightweight, high-performance telemetry tool designed to correlate Feature Usage with System Health in real-time** - -[Features](#-features) โ€ข [Quick Start](#-quick-start) โ€ข [Architecture](#-architecture) โ€ข [Documentation](#-documentation) - -
- ---- - -## โœจ Features - -- ๐Ÿš€ **Sub-10ms Ingestion Latency** - Async architecture ensures API responses return immediately -- ๐Ÿ“Š **Real-time Insights Dashboard** - Beautiful, developer-centric UI built with React + Shadcn UI -- ๐Ÿ”„ **Idempotent Event Processing** - UUID-based deduplication prevents duplicate events -- ๐Ÿ“ฆ **Flexible Event Schema** - JSONB storage allows dynamic properties without migrations -- โšก **Horizontal Scalability** - Celery workers can scale independently from API servers -- ๐ŸŽฏ **Type-Safe SDK** - Python SDK with full type hints for seamless integration -- ๐Ÿณ **Docker-Ready** - One-command deployment with Docker Compose - ---- - -## ๐Ÿ—๏ธ Architecture - -```mermaid -graph LR - SDK[SDK] -->|"HTTP POST"| APIServer["API Server"] - APIServer -->|"Queue Task"| Redis[Redis] - Redis -->|Consume| CeleryWorker["Celery Worker"] - CeleryWorker -->|Write| PostgreSQL[PostgreSQL] - PostgreSQL -->|Query| FrontendDashboard["Frontend Dashboard"] +# TelemetryTaco + +TelemetryTaco is a lightweight self-hosted telemetry MVP built around three concrete workflows: + +- capture single events or batches +- inspect recent events in a live dashboard +- query minute-level insight aggregates over a recent lookback window + +The codebase now targets a strong single-project MVP rather than a broad PostHog clone. The refactor in this repo keeps the current API contract intact while adding real batching, idempotency, generated frontend types, tests, and a cleaner developer workflow. + +## Current Architecture + +```text +Python SDK / API clients + | + v + Django + Django Ninja + | + v + Celery batch task queue + | + v + PostgreSQL event store + | + v + React dashboard (React Query + generated OpenAPI types) ``` -### Component Overview +### Runtime responsibilities -- **SDK** (`sdk/telemetry_taco.py`) - Non-blocking Python client that sends events in background threads -- **API Server** (Django + Django Ninja) - Fast, type-safe REST API with Pydantic validation -- **Redis** - Message broker for Celery task queue -- **Celery Worker** - Async event processor that writes to PostgreSQL -- **PostgreSQL** - Primary data store with JSONB for flexible event properties -- **Frontend Dashboard** (React + TypeScript) - Real-time visualization of telemetry data +- `backend/`: API surface, ingestion service, selectors, Celery tasks, retention commands +- `frontend/`: dashboard UI, React Query polling, OpenAPI-generated TypeScript types +- `sdk/`: queue-backed Python client that batches to `/api/capture/batch` ---- +## What Exists Today -## ๐Ÿš€ Quick Start +- `POST /api/capture`: additive single-event capture endpoint +- `POST /api/capture/batch`: batch capture endpoint used by the SDK +- `GET /api/events`: bounded recent-event feed with optional `before` cursor +- `GET /api/insights`: bounded minute-level aggregate series +- `GET /api/health/live` and `GET /api/health/ready` +- event idempotency via caller-supplied `event_uuid` +- OpenAPI export and generated frontend types +- backend pytest coverage, frontend Vitest coverage, and SDK tests -> **New to the project?** Start here! For detailed setup instructions and troubleshooting, see the sections below. +## Quick Start -### Fastest Way to Start (Recommended) - -After initial setup, start everything with one command: +### Prerequisites -**First time setup** (make scripts executable): +- Python 3.11, 3.12, or 3.13 +- Poetry +- Node.js 18+ and `pnpm` +- Docker and Docker Compose -```bash -chmod +x start.sh stop.sh restart-backend.sh seed.sh clear-rate-limit.sh -``` +### Local development -**Then start everything:** +1. Copy backend environment defaults: ```bash -# Option 1: Using the startup script (easiest) -./start.sh - -# Option 2: Using pnpm -pnpm start - -# Option 3: Using Make -make dev - -# To stop all services: -./stop.sh # or: pnpm stop # or: make stop +cp backend/.env.example backend/.env ``` -The startup script will: - -1. โœ… Start Docker services (PostgreSQL & Redis) -2. โœ… Wait for PostgreSQL to be ready -3. โœ… Create `.env` file if missing -4. โœ… Run database migrations -5. โœ… Start Django backend server (background) -6. โœ… Start Celery worker (background) -7. โœ… Start frontend dev server (foreground) - -**Access the application:** - -- Frontend: http://localhost:5173 -- Backend API: http://localhost:8000 -- API Docs: http://localhost:8000/api/docs - -### Prerequisites - -- Docker & Docker Compose -- Python 3.11 or 3.12 (for local development) -- Poetry (for Python dependency management) -- Node.js 18+ & pnpm (for frontend development) - -### Manual Setup (Step-by-Step) - -If you prefer to start services manually or the startup script doesn't work: +2. Start the database and Redis: ```bash -# Clone the repository -git clone https://github.com/yourusername/TelemetryTaco.git -cd TelemetryTaco - -# 1. Start database and Redis services docker-compose up -d db redis - -# 2. Wait 5-10 seconds for PostgreSQL to initialize, then verify: -docker-compose ps db # Should show "Up" and "healthy" - -# 3. Set up backend (in backend/ directory) -cd backend -poetry env use python3.11 # Required if you have Python 3.14+ -poetry install - -# 4. Generate a secure SECRET_KEY and create .env file -# First, generate a secure SECRET_KEY: -python3 -c 'from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())' - -# Copy the generated key, then create .env file (replace YOUR_SECRET_KEY with the generated key) -# Check your Docker container credentials first: -# docker-compose exec db env | grep POSTGRES -cat > .env << 'EOF' -DEBUG=True -SECRET_KEY=YOUR_SECRET_KEY -DATABASE_URL=postgresql://postgres:postgres@localhost:5432/telemetry_taco -REDIS_URL=redis://localhost:6379/0 -ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0 -EOF -# Replace YOUR_SECRET_KEY with the generated key from above -# Update DATABASE_URL if your Docker container uses different credentials - -# 5. Test database connection -poetry run python check_db.py - -# 6. Run migrations -poetry run python manage.py migrate - -# 7. Start backend server -poetry run python manage.py runserver -# Backend will be available at http://localhost:8000 - -# 8. In another terminal, start Celery worker -cd backend -poetry run celery -A core worker --loglevel=info - -# 9. In another terminal, start frontend -cd frontend -pnpm install -pnpm dev -# Frontend will be available at http://localhost:5173 - -# Access the application -# Frontend: http://localhost:5173 -# Backend API: http://localhost:8000 -# API Docs: http://localhost:8000/api/docs ``` -**Note**: For full Docker deployment (all services in containers), see `docker-compose.yml`. The above setup is recommended for local development. - -### Development Commands Reference - -After initial setup, use these commands for daily development: +3. Start the application stack: ```bash -# Start everything (recommended) -./start.sh # or: pnpm start # or: make dev - -# Start individual services -pnpm dev:frontend # Frontend only -pnpm dev:backend # Backend only -pnpm dev:worker # Celery worker only -pnpm services # Start Docker services (PostgreSQL & Redis) -pnpm services:stop # Stop Docker services - -# Restart services -pnpm restart:backend # or: ./restart-backend.sh (restarts Django backend only) - -# Database operations -pnpm migrate # Run migrations -make migrate # Alternative - -# Seed database with sample data -pnpm seed # or: ./seed.sh -pnpm seed:clean # or: ./seed.sh --clean (cleans existing data first) - -# Code quality and validation -pnpm lint:backend # Lint backend code (ruff check) -pnpm lint:frontend # Lint frontend code (eslint) -pnpm format:backend # Format backend code (ruff format) -pnpm security:backend # Run security scan (bandit) -pnpm validate:backend # Full backend validation (lint + format check + Django check) -pnpm validate:frontend # Full frontend validation (lint + type-check) -pnpm validate:all # Validate both backend and frontend - -# Utility scripts -./clear-rate-limit.sh # Clear Redis rate limit cache (useful after changing rate limits) - -# Stop services -./stop.sh # or: pnpm stop # or: make stop - -# View all Make commands -make help +./start.sh ``` -**Note**: On first run, make the scripts executable: +That script will install backend dependencies, run migrations, start Django and Celery in the background, and run the frontend in the foreground. + +### Useful commands ```bash -chmod +x start.sh stop.sh restart-backend.sh seed.sh clear-rate-limit.sh +pnpm generate:api-types # export backend OpenAPI and regenerate frontend types +pnpm validate:backend # Ruff + format check + Django check + backend pytest +pnpm validate:frontend # OpenAPI type generation + lint + type-check + Vitest +pnpm validate:all # backend + frontend + SDK validation +pnpm test # backend + frontend + SDK tests +pnpm seed # seed realistic sample events +pnpm seed:clean # wipe and reseed events ``` -### Local Development +## Backend Notes -#### Backend Setup +The backend defaults to development settings via `telemetry_taco.settings`. -```bash -cd backend +Available settings modules: -# Configure Poetry to use Python 3.11 (required if you have Python 3.14+) -poetry env use python3.11 -# Or specify full path: poetry env use /opt/homebrew/bin/python3.11 - -# Install dependencies with Poetry -poetry install - -# Set up environment variables -# First, generate a secure SECRET_KEY: -python3 -c 'from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())' - -# Create .env file in backend/ directory (replace YOUR_SECRET_KEY with the generated key) -cat > .env << 'EOF' -DEBUG=True -SECRET_KEY=YOUR_SECRET_KEY -DATABASE_URL=postgresql://postgres:postgres@localhost:5432/telemetry_taco -REDIS_URL=redis://localhost:6379/0 -ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0 -EOF -# Replace YOUR_SECRET_KEY with the generated key from above - -# Note: If your Docker PostgreSQL uses different credentials, update DATABASE_URL accordingly. -# Important: The SECRET_KEY must be at least 50 characters and cannot use example/insecure values. -# Check your Docker container: docker-compose exec db env | grep POSTGRES - -# Start database and Redis (from project root) -cd .. -docker-compose up -d db redis +- `telemetry_taco.settings.development` +- `telemetry_taco.settings.test` +- `telemetry_taco.settings.production` -# Wait 5-10 seconds for PostgreSQL to initialize, then verify: -docker-compose ps db +Important environment variables: -# Test database connection -cd backend -poetry run python check_db.py - -# Run migrations -poetry run python manage.py migrate +- `DATABASE_URL` +- `REDIS_URL` +- `CACHE_URL` +- `MAX_CAPTURE_BATCH_SIZE` +- `MAX_EVENTS_LIMIT` +- `MAX_INSIGHTS_LOOKBACK_MINUTES` +- `EVENT_RETENTION_DAYS` -# Start Django server -poetry run python manage.py runserver +Retention cleanup is exposed as a management command: -# In another terminal, start Celery worker +```bash cd backend -poetry run celery -A core worker --loglevel=info +poetry run python manage.py purge_expired_events ``` -**Troubleshooting**: If you encounter database connection issues, see [Backend Setup Guide](backend/SETUP.md) for detailed troubleshooting steps. - -#### Frontend Setup +OpenAPI export is also explicit: ```bash -cd frontend - -# Install dependencies -pnpm install - -# Start development server -# The Vite dev server is configured to proxy /api/* requests to http://localhost:8000 -pnpm dev +cd backend +DJANGO_SETTINGS_MODULE=telemetry_taco.settings.test poetry run python manage.py export_openapi_schema ../frontend/openapi.json ``` -The frontend will be available at `http://localhost:5173` and will automatically proxy API requests to the backend. +## Frontend Notes -### Database Seeding +The dashboard is a Vite React app that uses: -To populate the database with sample historical event data for testing and development: +- React Query for polling, deduping, and error handling +- lazy loading for the chart surface +- generated API types from `frontend/openapi.json` -```bash -# Seed database with historical events -pnpm seed # or: ./seed.sh +If the backend contract changes, regenerate types before committing: -# Clean existing events and seed fresh data -pnpm seed:clean # or: ./seed.sh --clean +```bash +pnpm generate:api-types ``` -The seed command generates realistic event data with timestamps spanning the past 7 days, useful for testing the dashboard and insights features. - -### Using the SDK +## SDK Example ```python -# Option 1: Direct import (for development) -import sys -sys.path.insert(0, 'path/to/TelemetryTaco/sdk') from telemetry_taco import TelemetryTaco -# Option 2: Install as package (recommended) -# pip install -e ./sdk -# from telemetry_taco import TelemetryTaco - -# Initialize client -client = TelemetryTaco(base_url="http://localhost:8000") - -# Capture an event (non-blocking) -client.capture( - distinct_id="user_123", - event_name="feature_used", - properties={ - "feature_name": "dark_mode", - "page": "settings", - "system_health": { - "cpu_usage": 45.2, - "memory_usage": 62.1 - } - } -) - -# Use context manager to ensure events are sent before exit with TelemetryTaco(base_url="http://localhost:8000") as client: - client.capture(distinct_id="user_123", event_name="page_view") - # Events are automatically flushed on exit -``` - ---- - -## ๐Ÿค” Why Async? - -TelemetryTaco decouples **event ingestion** from **event processing** to achieve optimal performance and reliability. - -### The Problem - -Traditional synchronous architectures force API endpoints to wait for database writes, creating several issues: - -- **High Latency**: Database writes (especially with indexes) can take 50-200ms, directly impacting API response times -- **Poor Scalability**: Database connections become a bottleneck under high load -- **Single Point of Failure**: If the database is slow or unavailable, the entire API becomes unresponsive -- **No Backpressure Handling**: Sudden traffic spikes can overwhelm the database - -### The Solution - -By using Redis as a message broker and Celery for async processing: - -1. **API Returns Immediately** - Endpoints respond in <10ms, regardless of database load -2. **Independent Scaling** - API servers and Celery workers scale independently based on their specific bottlenecks -3. **Resilience** - If the database is temporarily unavailable, events queue in Redis and process when it recovers -4. **Rate Limiting** - Redis can handle millions of operations per second, providing natural backpressure -5. **Idempotency** - UUID-based deduplication in Celery tasks prevents duplicate processing - -### Performance Characteristics - -| Metric | Synchronous | Async (TelemetryTaco) | -| ----------------------- | ----------------- | --------------------- | -| API Response Time | 50-200ms | <10ms | -| Throughput (events/sec) | ~1,000 | 10,000+ | -| Database Load | High (blocking) | Controlled (batched) | -| Failure Recovery | Immediate failure | Graceful degradation | - ---- - -## ๐Ÿ“ˆ Scaling Path - -TelemetryTaco is designed with a clear migration path from MVP to production scale, following proven patterns from [PostHog](https://posthog.com) and other high-scale telemetry platforms. - -### Phase 1: PostgreSQL JSONB (Current) - -**Use Case**: < 1M events/day, single region, real-time queries - -**Architecture**: - -- PostgreSQL with JSONB columns for event properties -- GIN indexes on JSONB fields for efficient querying -- Single PostgreSQL instance with read replicas for scaling reads - -**Advantages**: - -- โœ… Simple setup and operations -- โœ… ACID guarantees for data consistency -- โœ… Excellent for complex queries and aggregations -- โœ… No additional infrastructure required - -**Limitations**: - -- โš ๏ธ Write throughput limited to ~10K events/sec per instance -- โš ๏ธ JSONB query performance degrades with large datasets -- โš ๏ธ Storage costs grow linearly with event volume - -### Phase 2: ClickHouse Migration (Future) - -**Use Case**: > 10M events/day, multi-region, analytical workloads - -**Architecture**: - -- **Dual-Write Pattern**: Write to both PostgreSQL (for real-time) and ClickHouse (for analytics) -- **Event Router**: Celery task writes to both systems in parallel -- **Query Router**: Frontend queries PostgreSQL for recent data (< 24h), ClickHouse for historical -- **Eventual Consistency**: ClickHouse may lag by seconds, but provides 100x better query performance - -**Migration Strategy**: - -```python -# Example: Dual-write pattern in Celery task -@shared_task -def process_event_task(event_data: dict[str, Any]) -> None: - # Write to PostgreSQL (real-time queries) - Event.objects.create(**event_data) - - # Write to ClickHouse (analytical queries) - clickhouse_client.insert('events', [event_data]) + client.capture( + distinct_id="user-123", + event_name="feature_used", + properties={"feature_name": "insights-refresh"}, + ) ``` -**ClickHouse Advantages**: - -- โœ… **Columnar Storage**: 10-100x compression vs row-based storage -- โœ… **Query Performance**: Sub-second queries on billions of events -- โœ… **Horizontal Scaling**: Shard across multiple nodes -- โœ… **Time-Series Optimized**: Built-in functions for time-based aggregations -- โœ… **Cost Effective**: ~$0.01 per million events stored - -**PostHog Architecture Reference**: - -- PostHog uses ClickHouse for events table, PostgreSQL for metadata -- Events are immutable, append-only writes only -- Partitioning by date for efficient data retention policies -- Materialized views for common aggregations (daily/weekly/monthly) - -### Phase 3: Advanced Optimizations - -**Partitioning Strategy**: - -- Partition ClickHouse tables by date (daily partitions) -- Automatic TTL policies for data retention -- Hot/warm storage tiers (SSD for recent, HDD for historical) - -**Query Optimization**: +The SDK batches events in a background worker, attaches `event_uuid` and `sent_at`, and flushes automatically when the context manager exits. -- Materialized views for pre-computed aggregations -- Sampling for exploratory queries on large datasets -- Approximate algorithms (HyperLogLog) for distinct counts +## API Summary -**Multi-Region**: - -- Regional ClickHouse clusters with replication -- Event routing based on user geography -- Cross-region aggregation for global insights - -### Migration Checklist - -When to migrate to ClickHouse: - -- [ ] Event volume exceeds 1M events/day consistently -- [ ] Query performance degrades (> 5s for aggregations) -- [ ] Storage costs become significant (> $500/month) -- [ ] Need for complex analytical queries (cohorts, funnels, retention) -- [ ] Multi-region deployment requirements - ---- - -## ๐Ÿ“š Documentation - -### API Endpoints - -All API endpoints are rate-limited per IP address. Rate limits are configurable via environment variables and vary by environment (development has higher limits for testing). - -#### `POST /api/capture` - -Capture a new telemetry event. Events are queued asynchronously via Celery and return immediately. - -**Request Body**: +### `POST /api/capture` ```json { - "distinct_id": "user_123", - "event_name": "feature_used", + "distinct_id": "user-123", + "event_name": "page_view", "properties": { - "feature_name": "dark_mode", - "page": "settings" - } + "path": "/" + }, + "event_uuid": "optional-uuid", + "sent_at": "2026-03-13T18:00:00Z" } ``` -**Response**: `200 OK` +Response: ```json { @@ -505,232 +173,40 @@ Capture a new telemetry event. Events are queued asynchronously via Celery and r } ``` -**Rate Limiting**: - -- **Production Default**: 1,000 requests per hour per IP address -- **Development Default**: 10,000 requests per hour per IP address -- **Configuration**: Set `RATE_LIMIT_CAPTURE_EVENT` environment variable (format: `"number/period"`, e.g., `"1000/h"`, `"100/m"`, `"5000/d"`) -- **Disable**: Set to `"0"` to disable rate limiting for this endpoint - -#### `GET /api/events?limit=100` - -List recent events (ordered by timestamp, descending). - -**Query Parameters**: - -- `limit` (optional): Maximum number of events to return (default: 100) - -**Response**: `200 OK` - Array of event objects - -**Rate Limiting**: - -- **Production Default**: 10,000 requests per hour per IP address -- **Development Default**: 1,000,000 requests per hour per IP address -- **Configuration**: Set `RATE_LIMIT_LIST_EVENTS` environment variable (format: `"number/period"`, e.g., `"10000/h"`, `"100/m"`, `"50000/d"`) -- **Disable**: Set to `"0"` to disable rate limiting for this endpoint - -#### `GET /api/insights?lookback_minutes=60` - -Get aggregated event counts grouped by minute for the specified lookback period. - -**Query Parameters**: +### `POST /api/capture/batch` -- `lookback_minutes` (optional): Number of minutes to look back from now (default: 60) - -**Response**: `200 OK` - Array of data points with `time` (HH:MM format) and `count` - -**Rate Limiting**: - -- **Production Default**: 300 requests per hour per IP address -- **Development Default**: 1,000 requests per hour per IP address -- **Configuration**: Set `RATE_LIMIT_GET_INSIGHTS` environment variable (format: `"number/period"`, e.g., `"300/h"`, `"50/m"`, `"1000/d"`) -- **Disable**: Set to `"0"` to disable rate limiting for this endpoint - -### SDK Reference - -The Python SDK is located in `sdk/telemetry_taco.py`. To use it: - -```python -# Import from the SDK file -import sys -sys.path.insert(0, 'path/to/TelemetryTaco/sdk') -from telemetry_taco import TelemetryTaco - -# Or install it as a package (recommended for production) -# pip install -e ./sdk +```json +{ + "events": [ + { + "distinct_id": "user-123", + "event_name": "page_view" + } + ] +} ``` -See [SDK Documentation](sdk/telemetry_taco.py) for full API reference. - -### Development Guidelines +### `GET /api/events?limit=100&before=2026-03-13T18:00:00Z` -See [`.cursor/rules/generalguidelines.mdc`](.cursor/rules/generalguidelines.mdc) for: +Returns recent events ordered by `timestamp desc, id desc`. -- Frontend development standards (React + TypeScript) -- Backend development standards (Django + Django Ninja) -- Code quality and testing requirements +### `GET /api/insights?lookback_minutes=60` -### Code Quality & Validation - -Before submitting code, run validation commands to ensure quality: - -```bash -# Validate backend (linting, formatting, Django checks) -pnpm validate:backend +Returns minute buckets shaped like: -# Validate frontend (linting, type checking) -pnpm validate:frontend - -# Validate entire project -pnpm validate:all +```json +[ + { "time": "18:04", "count": 4 } +] ``` -The project uses: - -- **Backend**: `ruff` for linting and formatting, Django's `check` command for configuration validation, `bandit` for security scanning -- **Frontend**: `eslint` for linting, TypeScript compiler for type checking - -### CI/CD - -The project includes a GitHub Actions CI/CD pipeline (`.github/workflows/cicd.yml`) that automatically: - -- Runs CodeQL security analysis for Python and JavaScript -- Validates code quality (linting, formatting, type checking) -- Runs tests for both backend and frontend -- Validates Docker build (builds backend image and runs `python manage.py check` to verify the image) - - For pull requests: Docker build validation runs twice (once in `docker-build` job, once in `docker-build-validation` job) - - For pushes: Docker build validation runs once in the `docker-build` job -- Runs Docker Compose integration tests (full stack test with PostgreSQL, Redis, and backend services) -- Ensures all checks pass before merging - -All pull requests must pass CI/CD validation before merging. - -### Troubleshooting - -#### Database Connection Issues - -If you see "password authentication failed" errors: - -1. **Check Docker PostgreSQL is running:** - - ```bash - docker-compose ps db - ``` - -2. **Verify database credentials:** - - ```bash - docker-compose exec db env | grep POSTGRES - ``` - -3. **Update `.env` file** in `backend/` with correct credentials: - - ```bash - # The DATABASE_URL should match the credentials from step 2 - # Format: postgresql://USER:PASSWORD@localhost:5432/telemetry_taco - ``` - -4. **Test connection using the diagnostic script:** - ```bash - cd backend - poetry run python check_db.py - ``` - -For more detailed troubleshooting, see [Backend Setup Guide](backend/SETUP.md). - -#### Python Version Issues - -If Poetry fails with Python version errors: - -1. **Check your Python version:** - - ```bash - python3 --version - ``` - -2. **Configure Poetry to use Python 3.11:** - - ```bash - cd backend - poetry env use python3.11 - # Or: poetry env use /opt/homebrew/bin/python3.11 - ``` - -3. **Reinstall dependencies:** - ```bash - poetry install - ``` - -#### Frontend Connection Issues - -If you see "Failed to fetch" errors in the browser: - -1. **Verify backend is running** on `http://localhost:8000` -2. **Check Vite proxy configuration** in `frontend/vite.config.ts` -3. **Verify CORS settings** in `backend/telemetry_taco/settings.py` -4. **Restart frontend dev server** after backend changes - ---- - -## ๐Ÿ› ๏ธ Tech Stack - -### Backend - -- **Python 3.11 or 3.12** - Runtime (Python 3.14+ not supported) -- **Django 5.0** - Web framework -- **Django Ninja** - Fast, type-safe API framework -- **Celery** - Distributed task queue -- **Redis** - Message broker and caching -- **PostgreSQL 16** - Primary database with JSONB support -- **Poetry** - Dependency management - -### Frontend - -- **React 18** - UI framework -- **TypeScript 5.3** - Type safety -- **Vite** - Build tool and dev server -- **Tailwind CSS** - Utility-first styling -- **Shadcn UI** - Component library -- **Recharts** - Data visualization - -### Infrastructure - -- **Docker Compose** - Local development environment -- **PostgreSQL 16** - Primary data store with JSONB support -- **Redis 7** - Task queue and caching - ---- - -## ๐Ÿค Contributing - -Contributions are welcome! Please feel free to submit a Pull Request. - -1. Fork the repository -2. Create your feature branch (`git checkout -b feature/amazing-feature`) -3. Commit your changes (`git commit -m 'Add some amazing feature'`) -4. Push to the branch (`git push origin feature/amazing-feature`) -5. Open a Pull Request - ---- - -## ๐Ÿ“„ License - -This project is licensed under the MIT License - see the LICENSE file for details. - ---- - -## ๐Ÿ™ Acknowledgments - -- Inspired by [PostHog](https://posthog.com)'s event ingestion architecture -- Built with modern Django patterns and best practices -- UI design follows developer-tool aesthetic principles - ---- - -
+## Developer Workflow -**Made with ๐ŸŒฎ by developers, for developers** +- Use `pnpm` at the repo root for day-to-day commands. +- Treat `backend/poetry.lock` and `pnpm-lock.yaml` as the dependency source of truth. +- Do not reintroduce `npm` lockfiles or a standalone backend `requirements.txt`. +- Keep frontend API types generated from the backend schema, not hand-maintained. -[Report Bug](https://github.com/yourusername/TelemetryTaco/issues) โ€ข [Request Feature](https://github.com/yourusername/TelemetryTaco/issues) โ€ข [Documentation](https://github.com/yourusername/TelemetryTaco/wiki) +## Status -
+TelemetryTaco is intentionally not solving multi-tenancy, auth, cohorts, funnels, feature flags, or ClickHouse analytics yet. The current code is optimized for a maintainable ingestion-and-dashboard MVP with clean seams for future expansion. diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..4fd5d5a --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,10 @@ +DEBUG=True +SECRET_KEY=dev-only-secret-key-not-for-production-use-please-change-me-12345 +DATABASE_URL=postgresql://postgres:postgres@localhost:5432/telemetry_taco +REDIS_URL=redis://localhost:6379/0 +CACHE_URL=redis://localhost:6379/1 +ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0 +MAX_CAPTURE_BATCH_SIZE=500 +MAX_EVENTS_LIMIT=200 +MAX_INSIGHTS_LOOKBACK_MINUTES=1440 +EVENT_RETENTION_DAYS=30 diff --git a/backend/.env.test b/backend/.env.test new file mode 100644 index 0000000..814b06f --- /dev/null +++ b/backend/.env.test @@ -0,0 +1,5 @@ +DEBUG=False +SECRET_KEY=test-secret-key-not-for-production-use-only-12345678901234567890 +DATABASE_URL=sqlite:////tmp/telemetry_taco_test.sqlite3 +REDIS_URL=redis://localhost:6379/0 +CACHE_URL=locmemcache:// diff --git a/backend/SETUP.md b/backend/SETUP.md index dd35f71..983a14f 100644 --- a/backend/SETUP.md +++ b/backend/SETUP.md @@ -1,220 +1,105 @@ -# Backend Setup Instructions +# Backend Setup -## Quick Start +## Purpose -1. **Configure Poetry to use Python 3.11 (required if you have Python 3.14+):** +The backend handles event ingestion, persistence, recent-event queries, health checks, and retention cleanup. It is a Django 5 app with Django Ninja, Celery, PostgreSQL, and Redis. - ```bash - cd backend - poetry env use python3.11 - # Or specify the full path if needed: - # poetry env use /opt/homebrew/bin/python3.11 - ``` +## Environment -2. **Install dependencies with Poetry:** - - ```bash - poetry install - ``` - -3. **If Poetry fails to connect to PyPI:** - - - Check your internet connection - - Try using a different network - - Check if you're behind a corporate firewall/proxy - - Verify Poetry can access PyPI: `poetry config repositories.pypi https://pypi.org/simple/` - -4. **Set up environment variables:** - - First, generate a secure SECRET_KEY: - - ```bash - python -c 'from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())' - ``` - - Copy the generated key, then create a `.env` file in the `backend/` directory: - - ```bash - # Create .env file (replace YOUR_SECRET_KEY with the generated key) - cat > backend/.env << 'EOF' - DEBUG=True - SECRET_KEY=YOUR_SECRET_KEY - DATABASE_URL=postgresql://postgres:postgres@localhost:5432/telemetry_taco - REDIS_URL=redis://localhost:6379/0 - ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0 - EOF - ``` - - Or manually create `backend/.env` with (replace `YOUR_SECRET_KEY` with the generated key): - - ``` - DEBUG=True - SECRET_KEY=YOUR_SECRET_KEY - DATABASE_URL=postgresql://postgres:postgres@localhost:5432/telemetry_taco - REDIS_URL=redis://localhost:6379/0 - ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0 - ``` - - **Important:** The SECRET_KEY must be a secure, randomly generated value. The application will fail to start if you use example or insecure values. - -5. **Start the database (if using Docker):** - - ```bash - # From project root directory - cd .. - docker-compose up -d db - - # Wait a few seconds for PostgreSQL to be ready - # Verify it's running: - docker-compose ps db - ``` - - **OR if using a local PostgreSQL installation:** - - Make sure your local PostgreSQL is running and update the `.env` file with your actual credentials. - -6. **Run database migrations:** - - ```bash - cd backend - poetry run python manage.py migrate - ``` - -7. **Start the development server:** - ```bash - poetry run python manage.py runserver - ``` - -## Alternative: Using pip (if Poetry fails) - -If Poetry continues to have issues, you can use pip with the `requirements.txt` file: +Copy the example file first: ```bash -# Create a virtual environment -python3.11 -m venv venv -source venv/bin/activate # On Windows: venv\Scripts\activate - -# Install dependencies -pip install -r requirements.txt - -# Note: You may need to update requirements.txt to match pyproject.toml dependencies +cp .env.example .env ``` -## Troubleshooting - -### Python Version Issues - -If you see errors about Python 3.14 or unsupported versions: - -- Ensure you have Python 3.11 or 3.12 installed -- Check your Python version: `python3 --version` -- Set Poetry to use a specific Python version: `poetry env use python3.11` - -### Network/Connection Issues - -If Poetry can't connect to PyPI: - -- Check your internet connection -- Try: `poetry install --no-cache` -- Verify DNS resolution: `ping pypi.org` -- Check for proxy settings: `poetry config http-basic.pypi ` (if behind proxy) +Core variables: + +```env +DEBUG=True +SECRET_KEY=dev-only-secret-key-not-for-production-use-please-change-me-12345 +DATABASE_URL=postgresql://postgres:postgres@localhost:5432/telemetry_taco +REDIS_URL=redis://localhost:6379/0 +CACHE_URL=redis://localhost:6379/1 +MAX_CAPTURE_BATCH_SIZE=500 +MAX_EVENTS_LIMIT=200 +MAX_INSIGHTS_LOOKBACK_MINUTES=1440 +EVENT_RETENTION_DAYS=30 +``` -### Database Connection Issues +Settings modules: -If you see "password authentication failed" errors: +- default local development: `telemetry_taco.settings` +- explicit development: `telemetry_taco.settings.development` +- tests: `telemetry_taco.settings.test` +- production: `telemetry_taco.settings.production` -#### Quick Diagnostic +## Install -Run this to check your database connection: +Use a supported Python version first: ```bash -cd backend -poetry run python check_db.py +poetry env use python3.13 +POETRY_CACHE_DIR=/tmp/pypoetry-cache poetry install ``` -#### Solution 1: Use Docker PostgreSQL (Recommended) - -1. **Stop any local PostgreSQL** (if running): - - ```bash - # On macOS with Homebrew: - brew services stop postgresql@14 # or postgresql@15, etc. - - # Or check what's running: - ps aux | grep postgres - ``` +## Run -2. **Start Docker PostgreSQL:** +Start dependencies from the repo root: - ```bash - # From project root - cd .. - docker-compose up -d db - - # Wait 5-10 seconds, then verify: - docker-compose ps db # Should show "Up" and "healthy" - ``` - -3. **Test the connection:** +```bash +docker-compose up -d db redis +``` - ```bash - cd backend - poetry run python check_db.py - ``` +Run migrations: -4. **Run migrations:** - ```bash - poetry run python manage.py migrate - ``` +```bash +poetry run python manage.py migrate +``` -#### Solution 2: Use Local PostgreSQL +Start the API server: -If you prefer to use a local PostgreSQL installation: +```bash +poetry run python manage.py runserver +``` -1. **Generate a secure SECRET_KEY:** +Start the worker in another shell: - ```bash - cd backend - python -c 'from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())' - ``` +```bash +poetry run celery -A core worker --loglevel=info +``` - Copy the generated key. +## Validation -2. **Create/update `.env` file** with your actual credentials: +```bash +poetry run ruff check . +poetry run ruff format --check . +DJANGO_SETTINGS_MODULE=telemetry_taco.settings.test poetry run python manage.py check +POETRY_CACHE_DIR=/tmp/pypoetry-cache poetry run pytest +``` - ```bash - cat > .env << 'EOF' - DEBUG=True - SECRET_KEY=YOUR_SECRET_KEY - DATABASE_URL=postgresql://YOUR_USER:YOUR_PASSWORD@localhost:5432/telemetry_taco - REDIS_URL=redis://localhost:6379/0 - ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0 - EOF - ``` +## Commands - Replace `YOUR_SECRET_KEY` with the generated key, and `YOUR_USER` and `YOUR_PASSWORD` with your actual PostgreSQL credentials. +Seed sample data: -3. **Create the database** (if it doesn't exist): +```bash +poetry run python manage.py seed_events --count 2000 +``` - ```bash - createdb telemetry_taco - # Or using psql: - psql -U postgres -c "CREATE DATABASE telemetry_taco;" - ``` +Purge expired events: -4. **Test the connection:** +```bash +poetry run python manage.py purge_expired_events +``` - ```bash - poetry run python check_db.py - ``` +Export the OpenAPI schema: -5. **Run migrations:** - ```bash - poetry run python manage.py migrate - ``` +```bash +DJANGO_SETTINGS_MODULE=telemetry_taco.settings.test poetry run python manage.py export_openapi_schema ../frontend/openapi.json +``` -#### Common Issues +## Notes -- **Port 5432 already in use**: Another PostgreSQL instance is running. Stop it or use a different port. -- **Docker container not starting**: Check Docker is running: `docker ps` -- **Wrong password**: Verify credentials match what's in your `.env` file or Docker Compose config. +- The current backend is optimized for a strong single-project MVP. +- `event_uuid` drives idempotency. +- `/api/capture` and `/api/capture/batch` both enqueue through the same ingestion service. +- The dashboard contract should be treated as OpenAPI-first; regenerate frontend types whenever the API changes. diff --git a/backend/core/__init__.py b/backend/core/__init__.py index e69de29..370372a 100644 --- a/backend/core/__init__.py +++ b/backend/core/__init__.py @@ -0,0 +1,3 @@ +from .celery import app as celery_app + +__all__ = ["celery_app"] diff --git a/backend/core/admin.py b/backend/core/admin.py index 2d33be1..73620cd 100644 --- a/backend/core/admin.py +++ b/backend/core/admin.py @@ -1,2 +1,10 @@ -# Register your models here. +from django.contrib import admin +from core.models import Event + + +@admin.register(Event) +class EventAdmin(admin.ModelAdmin): + list_display = ("id", "event_name", "distinct_id", "timestamp", "uuid") + list_filter = ("event_name", "timestamp") + search_fields = ("distinct_id", "event_name", "uuid") diff --git a/backend/core/api.py b/backend/core/api.py deleted file mode 100644 index b04d3d6..0000000 --- a/backend/core/api.py +++ /dev/null @@ -1,155 +0,0 @@ -from datetime import timedelta -from typing import Any - -from django.conf import settings -from django.db.models import Count -from django.db.models.functions import TruncMinute -from django.utils import timezone -from django_ratelimit.decorators import ratelimit -from ninja import ModelSchema, Router, Schema - -from core.models import Event -from core.tasks import process_event_task - -router = Router() - - -class EventSchema(Schema): - """Pydantic schema for event capture endpoint.""" - - distinct_id: str - event_name: str - properties: dict[str, Any] = {} - - -class EventResponseSchema(ModelSchema): - """Pydantic schema for event response using ModelSchema.""" - - uuid: str # Override UUIDField to serialize as string - - class Meta: - model = Event - fields = [ - "id", - "distinct_id", - "event_name", - "properties", - "timestamp", - "uuid", - "created_at", - ] - - @staticmethod - def resolve_uuid(obj: Event) -> str: - """Convert UUID to string for serialization.""" - return str(obj.uuid) - - -class StatusResponse(Schema): - """Response schema for successful event capture.""" - - status: str = "ok" - - -class InsightDataPoint(Schema): - """Schema for a single insight data point.""" - - time: str - count: int - - -@router.post("/capture", response=StatusResponse) -@ratelimit(key="ip", rate=settings.RATE_LIMIT_CAPTURE_EVENT, method="POST", block=True) -def capture_event(request, event: EventSchema) -> StatusResponse: - """ - Capture event endpoint. - - Accepts event data and offloads it to Celery for async processing. - Returns immediately with 200 OK to ensure low latency. - - **Rate Limiting:** - - Default: 1000 requests per hour per IP address - - Configurable via RATE_LIMIT_CAPTURE_EVENT environment variable - - Format: "number/period" (e.g., "1000/h", "100/m", "5000/d") - - Set to "0" to disable rate limiting for this endpoint - - **Note:** This limit is applied per IP address. For high-volume use cases, - consider configuring a higher limit or implementing API key-based authentication - for higher limits. - """ - # Convert Pydantic model to dict for Celery task - # Using model_dump() for Pydantic v2 compatibility (replaces deprecated dict()) - event_data = event.model_dump() - - # Offload to Celery task asynchronously - process_event_task.delay(event_data) - - # Return immediately without waiting for DB write - return StatusResponse(status="ok") - - -@router.get("/events", response=list[EventResponseSchema]) -@ratelimit( - key="ip", - rate=settings.RATE_LIMIT_LIST_EVENTS, - method="GET", - block=True, -) -def list_events(request, limit: int = 100): - """ - List recent events endpoint. - - Returns the most recent events ordered by timestamp (descending). - - **Rate Limiting:** - - Default: 10,000 requests per hour per IP address - - Configurable via RATE_LIMIT_LIST_EVENTS environment variable - - Format: "number/period" (e.g., "10000/h", "100/m", "50000/d") - """ - events = Event.objects.order_by("-timestamp")[:limit] - # Django Ninja's ModelSchema will handle serialization automatically - # The resolve_uuid method will convert UUID to string - return list(events) - - -@router.get("/insights", response=list[InsightDataPoint]) -@ratelimit(key="ip", rate=settings.RATE_LIMIT_GET_INSIGHTS, method="GET", block=True) -def get_insights(request, lookback_minutes: int = 60): - """ - Get event insights endpoint. - - Returns aggregated event counts grouped by minute for the specified lookback period. - Uses database-level aggregation for optimal performance. - - Args: - lookback_minutes: Number of minutes to look back from now (default: 60) - - Returns: - List of data points with time (HH:MM format) and count - - **Rate Limiting:** - - Default: 300 requests per hour per IP address - - Configurable via RATE_LIMIT_GET_INSIGHTS environment variable - - Format: "number/period" (e.g., "300/h", "50/m", "1000/d") - """ - # Calculate the cutoff time - cutoff_time = timezone.now() - timedelta(minutes=lookback_minutes) - - # Database-level aggregation: group by minute and count events - # This is optimized as it happens entirely in the database - aggregated = ( - Event.objects.filter(timestamp__gte=cutoff_time) - .annotate(minute=TruncMinute("timestamp")) - .values("minute") - .annotate(count=Count("id")) - .order_by("minute") - ) - - # Format the results - result = [] - for item in aggregated: - # Format time as HH:MM - time_str = item["minute"].strftime("%H:%M") - result.append({"time": time_str, "count": item["count"]}) - - return result diff --git a/backend/core/api/__init__.py b/backend/core/api/__init__.py new file mode 100644 index 0000000..e1dab4d --- /dev/null +++ b/backend/core/api/__init__.py @@ -0,0 +1,3 @@ +from .events import router + +__all__ = ["router"] diff --git a/backend/core/api/events.py b/backend/core/api/events.py new file mode 100644 index 0000000..67d5035 --- /dev/null +++ b/backend/core/api/events.py @@ -0,0 +1,63 @@ +from datetime import datetime + +from django.conf import settings +from django_ratelimit.decorators import ratelimit +from ninja import Router +from ninja.errors import HttpError + +from core.api.schemas import ( + BatchStatusResponse, + EventBatchCaptureSchema, + EventCaptureSchema, + EventResponseSchema, + HealthStatusResponse, + InsightDataPoint, + StatusResponse, +) +from core.selectors.events import get_insights, list_recent_events +from core.services.health import get_liveness_status, get_readiness_status +from core.services.ingestion import enqueue_events + +router = Router() + + +@router.post("/capture", response=StatusResponse) +@ratelimit(key="ip", rate=settings.RATE_LIMIT_CAPTURE_EVENT, method="POST", block=True) +def capture_event(request, event: EventCaptureSchema) -> StatusResponse: + enqueue_events([event]) + return StatusResponse(status="ok") + + +@router.post("/capture/batch", response=BatchStatusResponse) +@ratelimit(key="ip", rate=settings.RATE_LIMIT_CAPTURE_EVENT, method="POST", block=True) +def capture_event_batch(request, payload: EventBatchCaptureSchema) -> BatchStatusResponse: + accepted = enqueue_events(payload.events) + return BatchStatusResponse(status="ok", accepted=accepted) + + +@router.get("/events", response=list[EventResponseSchema]) +@ratelimit(key="ip", rate=settings.RATE_LIMIT_LIST_EVENTS, method="GET", block=True) +def list_events(request, limit: int = 100, before: datetime | None = None): + if limit < 1: + raise HttpError(400, "limit must be greater than zero") + + return list_recent_events(limit=limit, before=before) + + +@router.get("/insights", response=list[InsightDataPoint]) +@ratelimit(key="ip", rate=settings.RATE_LIMIT_GET_INSIGHTS, method="GET", block=True) +def get_event_insights(request, lookback_minutes: int = 60): + if lookback_minutes < 1: + raise HttpError(400, "lookback_minutes must be greater than zero") + + return get_insights(lookback_minutes=lookback_minutes) + + +@router.get("/health/live", response=HealthStatusResponse) +def liveness(request) -> HealthStatusResponse: + return get_liveness_status() + + +@router.get("/health/ready", response=HealthStatusResponse) +def readiness(request) -> HealthStatusResponse: + return get_readiness_status() diff --git a/backend/core/api/schemas.py b/backend/core/api/schemas.py new file mode 100644 index 0000000..83b9b0e --- /dev/null +++ b/backend/core/api/schemas.py @@ -0,0 +1,59 @@ +from datetime import datetime +from typing import Any +from uuid import UUID + +from ninja import ModelSchema, Schema +from pydantic import Field + +from core.models import Event + + +class EventCaptureSchema(Schema): + distinct_id: str + event_name: str + properties: dict[str, Any] = Field(default_factory=dict) + event_uuid: UUID | None = None + sent_at: datetime | None = None + + +class EventBatchCaptureSchema(Schema): + events: list[EventCaptureSchema] + + +class EventResponseSchema(ModelSchema): + uuid: str + + class Meta: + model = Event + fields = [ + "id", + "distinct_id", + "event_name", + "properties", + "timestamp", + "uuid", + "created_at", + ] + + @staticmethod + def resolve_uuid(obj: Event) -> str: + return str(obj.uuid) + + +class StatusResponse(Schema): + status: str = "ok" + + +class BatchStatusResponse(StatusResponse): + accepted: int + + +class InsightDataPoint(Schema): + time: str + count: int + + +class HealthStatusResponse(Schema): + status: str + database: str + cache: str diff --git a/backend/core/management/commands/export_openapi_schema.py b/backend/core/management/commands/export_openapi_schema.py new file mode 100644 index 0000000..bdb7d3b --- /dev/null +++ b/backend/core/management/commands/export_openapi_schema.py @@ -0,0 +1,26 @@ +import json +from pathlib import Path +from typing import Any + +from django.core.management.base import BaseCommand, CommandError + +from telemetry_taco.api import api + + +class Command(BaseCommand): + help = "Export the Ninja OpenAPI schema to a JSON file." + + def add_arguments(self, parser) -> None: + parser.add_argument("output", type=str, help="Path to write the schema JSON to.") + + def handle(self, *args: Any, **options: Any) -> None: + output_path = Path(options["output"]).resolve() + output_path.parent.mkdir(parents=True, exist_ok=True) + + try: + schema = api.get_openapi_schema() + output_path.write_text(json.dumps(schema, indent=2), encoding="utf-8") + except Exception as exc: # pragma: no cover - surfaced to command caller + raise CommandError(f"Failed to export schema: {exc}") from exc + + self.stdout.write(self.style.SUCCESS(f"Exported OpenAPI schema to {output_path}")) diff --git a/backend/core/management/commands/purge_expired_events.py b/backend/core/management/commands/purge_expired_events.py new file mode 100644 index 0000000..82dc978 --- /dev/null +++ b/backend/core/management/commands/purge_expired_events.py @@ -0,0 +1,13 @@ +from typing import Any + +from django.core.management.base import BaseCommand + +from core.selectors.events import purge_expired_events + + +class Command(BaseCommand): + help = "Delete events older than EVENT_RETENTION_DAYS." + + def handle(self, *args: Any, **options: Any) -> None: + deleted_count = purge_expired_events() + self.stdout.write(self.style.SUCCESS(f"Deleted {deleted_count} expired events.")) diff --git a/backend/core/selectors/__init__.py b/backend/core/selectors/__init__.py new file mode 100644 index 0000000..45cd63e --- /dev/null +++ b/backend/core/selectors/__init__.py @@ -0,0 +1,3 @@ +from .events import get_insights, list_recent_events, purge_expired_events + +__all__ = ["get_insights", "list_recent_events", "purge_expired_events"] diff --git a/backend/core/selectors/events.py b/backend/core/selectors/events.py new file mode 100644 index 0000000..957a8ab --- /dev/null +++ b/backend/core/selectors/events.py @@ -0,0 +1,48 @@ +from datetime import datetime, timedelta + +from django.conf import settings +from django.db.models import Count +from django.db.models.functions import TruncMinute +from django.utils import timezone + +from core.models import Event + + +def list_recent_events(*, limit: int, before: datetime | None = None) -> list[Event]: + bounded_limit = min(limit, settings.MAX_EVENTS_LIMIT) + queryset = Event.objects.order_by("-timestamp", "-id") + + if before is not None: + queryset = queryset.filter(timestamp__lt=before) + + return list(queryset[:bounded_limit]) + + +def get_insights(*, lookback_minutes: int) -> list[dict[str, int | str]]: + bounded_lookback = min(lookback_minutes, settings.MAX_INSIGHTS_LOOKBACK_MINUTES) + cutoff_time = timezone.now() - timedelta(minutes=bounded_lookback) + + aggregated = ( + Event.objects.filter(timestamp__gte=cutoff_time) + .annotate(minute=TruncMinute("timestamp")) + .values("minute") + .annotate(count=Count("id")) + .order_by("minute") + ) + + return [ + { + "time": item["minute"].strftime("%H:%M"), + "count": item["count"], + } + for item in aggregated + ] + + +def purge_expired_events(*, now: datetime | None = None) -> int: + if settings.EVENT_RETENTION_DAYS <= 0: + return 0 + + cutoff_time = (now or timezone.now()) - timedelta(days=settings.EVENT_RETENTION_DAYS) + deleted_count, _ = Event.objects.filter(timestamp__lt=cutoff_time).delete() + return deleted_count diff --git a/backend/core/services/__init__.py b/backend/core/services/__init__.py new file mode 100644 index 0000000..5e958eb --- /dev/null +++ b/backend/core/services/__init__.py @@ -0,0 +1,4 @@ +from .health import get_liveness_status, get_readiness_status +from .ingestion import enqueue_events + +__all__ = ["enqueue_events", "get_liveness_status", "get_readiness_status"] diff --git a/backend/core/services/health.py b/backend/core/services/health.py new file mode 100644 index 0000000..f348747 --- /dev/null +++ b/backend/core/services/health.py @@ -0,0 +1,31 @@ +from django.core.cache import caches +from django.db import connections + +from core.api.schemas import HealthStatusResponse + + +def get_liveness_status() -> HealthStatusResponse: + return HealthStatusResponse(status="ok", database="unchecked", cache="unchecked") + + +def get_readiness_status() -> HealthStatusResponse: + database_status = "ok" + cache_status = "ok" + + try: + with connections["default"].cursor() as cursor: + cursor.execute("SELECT 1") + cursor.fetchone() + except Exception: + database_status = "error" + + try: + cache = caches["default"] + cache.set("healthcheck", "ok", timeout=5) + if cache.get("healthcheck") != "ok": + cache_status = "error" + except Exception: + cache_status = "error" + + status = "ok" if database_status == "ok" and cache_status == "ok" else "degraded" + return HealthStatusResponse(status=status, database=database_status, cache=cache_status) diff --git a/backend/core/services/ingestion.py b/backend/core/services/ingestion.py new file mode 100644 index 0000000..b89abcf --- /dev/null +++ b/backend/core/services/ingestion.py @@ -0,0 +1,73 @@ +from dataclasses import dataclass +from datetime import datetime +from typing import Any +from uuid import UUID, uuid4 + +from django.conf import settings +from django.utils import timezone +from ninja.errors import HttpError + +from core.api.schemas import EventCaptureSchema +from core.tasks import process_event_batch_task + + +@dataclass(frozen=True) +class NormalizedEvent: + distinct_id: str + event_name: str + properties: dict[str, Any] + event_uuid: UUID + timestamp: datetime + + +def _normalize_timestamp(timestamp: datetime | None) -> datetime: + if timestamp is None: + return timezone.now() + + if timezone.is_naive(timestamp): + return timezone.make_aware(timestamp, timezone.get_current_timezone()) + + return timestamp + + +def _normalize_event(event: EventCaptureSchema) -> NormalizedEvent: + return NormalizedEvent( + distinct_id=event.distinct_id, + event_name=event.event_name, + properties=event.properties, + event_uuid=event.event_uuid or uuid4(), + timestamp=_normalize_timestamp(event.sent_at), + ) + + +def _serialize_event(event: NormalizedEvent) -> dict[str, Any]: + return { + "distinct_id": event.distinct_id, + "event_name": event.event_name, + "properties": event.properties, + "event_uuid": str(event.event_uuid), + "timestamp": event.timestamp.isoformat(), + } + + +def _chunked(items: list[NormalizedEvent], *, size: int) -> list[list[NormalizedEvent]]: + return [items[index : index + size] for index in range(0, len(items), size)] + + +def enqueue_events(events: list[EventCaptureSchema]) -> int: + if not events: + raise HttpError(400, "events must contain at least one event") + + if len(events) > settings.MAX_CAPTURE_BATCH_SIZE: + raise HttpError( + 400, + f"batch size exceeds maximum of {settings.MAX_CAPTURE_BATCH_SIZE} events", + ) + + normalized = [_normalize_event(event) for event in events] + chunks = _chunked(normalized, size=settings.MAX_CAPTURE_BATCH_SIZE) + + for chunk in chunks: + process_event_batch_task.delay([_serialize_event(event) for event in chunk]) + + return len(normalized) diff --git a/backend/core/tasks.py b/backend/core/tasks.py deleted file mode 100644 index 3b7b1d4..0000000 --- a/backend/core/tasks.py +++ /dev/null @@ -1,31 +0,0 @@ -from typing import Any - -from celery import shared_task -from django.utils import timezone - -from core.models import Event - - -@shared_task -def process_event_task(event_data: dict[str, Any]) -> None: - """ - Celery task to process and save an event to the database. - - Args: - event_data: Dictionary containing distinct_id, event_name, and properties - """ - # Validate required fields - distinct_id = event_data.get("distinct_id") - event_name = event_data.get("event_name") - - if not distinct_id: - raise ValueError("Missing required field: 'distinct_id'") - if not event_name: - raise ValueError("Missing required field: 'event_name'") - - Event.objects.create( - distinct_id=distinct_id, - event_name=event_name, - properties=event_data.get("properties", {}), - timestamp=event_data.get("timestamp", timezone.now()), - ) diff --git a/backend/core/tasks/__init__.py b/backend/core/tasks/__init__.py new file mode 100644 index 0000000..f57312c --- /dev/null +++ b/backend/core/tasks/__init__.py @@ -0,0 +1,7 @@ +from .events import process_event_batch_task, process_event_task, purge_expired_events_task + +__all__ = [ + "process_event_batch_task", + "process_event_task", + "purge_expired_events_task", +] diff --git a/backend/core/tasks/events.py b/backend/core/tasks/events.py new file mode 100644 index 0000000..09c5c01 --- /dev/null +++ b/backend/core/tasks/events.py @@ -0,0 +1,112 @@ +from typing import Any +from uuid import UUID + +from celery import shared_task +from celery.utils.log import get_task_logger +from django.db import OperationalError +from django.utils import timezone +from django.utils.dateparse import parse_datetime + +from core.models import Event +from core.selectors.events import purge_expired_events + +logger = get_task_logger(__name__) + + +def _parse_timestamp(raw_value: Any): + if raw_value is None: + return timezone.now() + + if hasattr(raw_value, "isoformat"): + return raw_value + + parsed = parse_datetime(str(raw_value)) + if parsed is None: + raise ValueError("timestamp must be an ISO 8601 datetime") + + if timezone.is_naive(parsed): + return timezone.make_aware(parsed, timezone.get_current_timezone()) + + return parsed + + +@shared_task( + bind=True, + autoretry_for=(OperationalError,), + retry_backoff=True, + retry_jitter=True, + retry_kwargs={"max_retries": 5}, +) +def process_event_batch_task(self, events_data: list[dict[str, Any]]) -> int: + if not events_data: + return 0 + + events_to_create = [] + for event_data in events_data: + distinct_id = event_data.get("distinct_id") + event_name = event_data.get("event_name") + event_uuid = event_data.get("event_uuid") or event_data.get("uuid") + + if not distinct_id: + raise ValueError("Missing required field: 'distinct_id'") + if not event_name: + raise ValueError("Missing required field: 'event_name'") + if not event_uuid: + raise ValueError("Missing required field: 'event_uuid'") + + events_to_create.append( + Event( + distinct_id=distinct_id, + event_name=event_name, + properties=event_data.get("properties", {}), + timestamp=_parse_timestamp(event_data.get("timestamp")), + uuid=UUID(str(event_uuid)), + ) + ) + + Event.objects.bulk_create( + events_to_create, + batch_size=len(events_to_create), + ignore_conflicts=True, + ) + + logger.info( + "processed_event_batch", + extra={ + "task_name": self.name, + "task_id": self.request.id, + "event_count": len(events_to_create), + }, + ) + return len(events_to_create) + + +@shared_task( + bind=True, + autoretry_for=(OperationalError,), + retry_backoff=True, + retry_jitter=True, + retry_kwargs={"max_retries": 5}, +) +def process_event_task(self, event_data: dict[str, Any]) -> int: + return process_event_batch_task.run([event_data]) + + +@shared_task( + bind=True, + autoretry_for=(OperationalError,), + retry_backoff=True, + retry_jitter=True, + retry_kwargs={"max_retries": 5}, +) +def purge_expired_events_task(self) -> int: + deleted_count = purge_expired_events() + logger.info( + "purged_expired_events", + extra={ + "task_name": self.name, + "task_id": self.request.id, + "deleted_count": deleted_count, + }, + ) + return deleted_count diff --git a/backend/core/tests.py b/backend/core/tests.py deleted file mode 100644 index fdf7930..0000000 --- a/backend/core/tests.py +++ /dev/null @@ -1,2 +0,0 @@ -# Create your tests here. - diff --git a/backend/core/tests/__init__.py b/backend/core/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/core/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/core/tests/test_api.py b/backend/core/tests/test_api.py new file mode 100644 index 0000000..70649ba --- /dev/null +++ b/backend/core/tests/test_api.py @@ -0,0 +1,184 @@ +from datetime import timedelta +from uuid import uuid4 + +import pytest +from django.core.management import call_command +from django.utils import timezone + +from core.models import Event + + +@pytest.mark.django_db +def test_capture_event_persists_event(client): + response = client.post( + "/api/capture", + data={ + "distinct_id": "user-123", + "event_name": "signup_clicked", + "properties": {"plan": "starter"}, + }, + content_type="application/json", + ) + + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + event = Event.objects.get() + assert event.distinct_id == "user-123" + assert event.event_name == "signup_clicked" + assert event.properties == {"plan": "starter"} + + +@pytest.mark.django_db +def test_capture_event_is_idempotent_with_event_uuid(client): + event_uuid = str(uuid4()) + payload = { + "distinct_id": "user-123", + "event_name": "signup_clicked", + "event_uuid": event_uuid, + "properties": {"plan": "starter"}, + } + + first_response = client.post("/api/capture", data=payload, content_type="application/json") + second_response = client.post("/api/capture", data=payload, content_type="application/json") + + assert first_response.status_code == 200 + assert second_response.status_code == 200 + assert Event.objects.count() == 1 + assert str(Event.objects.get().uuid) == event_uuid + + +@pytest.mark.django_db +def test_capture_batch_persists_multiple_events(client): + response = client.post( + "/api/capture/batch", + data={ + "events": [ + { + "distinct_id": "user-123", + "event_name": "page_view", + "properties": {"path": "/"}, + }, + { + "distinct_id": "user-456", + "event_name": "checkout_success", + "properties": {"total": 42}, + }, + ] + }, + content_type="application/json", + ) + + assert response.status_code == 200 + assert response.json() == {"status": "ok", "accepted": 2} + assert Event.objects.count() == 2 + + +@pytest.mark.django_db +def test_capture_batch_rejects_empty_batch(client): + response = client.post( + "/api/capture/batch", + data={"events": []}, + content_type="application/json", + ) + + assert response.status_code == 400 + assert response.json()["detail"] == "events must contain at least one event" + + +@pytest.mark.django_db +def test_capture_batch_rejects_oversized_batch(client, settings): + settings.MAX_CAPTURE_BATCH_SIZE = 2 + response = client.post( + "/api/capture/batch", + data={ + "events": [ + {"distinct_id": "user-1", "event_name": "page_view"}, + {"distinct_id": "user-2", "event_name": "page_view"}, + {"distinct_id": "user-3", "event_name": "page_view"}, + ] + }, + content_type="application/json", + ) + + assert response.status_code == 400 + assert response.json()["detail"] == "batch size exceeds maximum of 2 events" + + +@pytest.mark.django_db +def test_events_endpoint_caps_limit_and_supports_before_filter(client, settings): + settings.MAX_EVENTS_LIMIT = 2 + now = timezone.now() + newest = Event.objects.create(distinct_id="newest", event_name="page_view", timestamp=now) + middle = Event.objects.create( + distinct_id="middle", + event_name="page_view", + timestamp=now - timedelta(minutes=1), + ) + oldest = Event.objects.create( + distinct_id="oldest", + event_name="page_view", + timestamp=now - timedelta(minutes=2), + ) + + limited = client.get("/api/events?limit=999") + before_filtered = client.get( + "/api/events", + data={"limit": 5, "before": newest.timestamp.isoformat()}, + ) + + assert limited.status_code == 200 + assert len(limited.json()) == 2 + assert limited.json()[0]["id"] == newest.id + assert before_filtered.status_code == 200 + assert [event["id"] for event in before_filtered.json()] == [middle.id, oldest.id] + + +@pytest.mark.django_db +def test_insights_endpoint_respects_max_lookback(client, settings): + settings.MAX_INSIGHTS_LOOKBACK_MINUTES = 30 + now = timezone.now() + Event.objects.create( + distinct_id="recent", + event_name="page_view", + timestamp=now - timedelta(minutes=10), + ) + Event.objects.create( + distinct_id="stale", + event_name="page_view", + timestamp=now - timedelta(minutes=45), + ) + + response = client.get("/api/insights?lookback_minutes=999") + + assert response.status_code == 200 + assert response.json() == [ + {"time": (now - timedelta(minutes=10)).strftime("%H:%M"), "count": 1} + ] + + +@pytest.mark.django_db +def test_purge_expired_events_command_deletes_expired_rows(settings): + settings.EVENT_RETENTION_DAYS = 30 + Event.objects.create( + distinct_id="expired", + event_name="page_view", + timestamp=timezone.now() - timedelta(days=31), + ) + Event.objects.create( + distinct_id="fresh", + event_name="page_view", + timestamp=timezone.now() - timedelta(days=5), + ) + + call_command("purge_expired_events") + + assert list(Event.objects.values_list("distinct_id", flat=True)) == ["fresh"] + + +@pytest.mark.django_db +def test_readiness_reports_dependency_status(client): + response = client.get("/api/health/ready") + + assert response.status_code == 200 + assert response.json()["database"] == "ok" + assert response.json()["cache"] == "ok" diff --git a/backend/core/tests/test_tasks.py b/backend/core/tests/test_tasks.py new file mode 100644 index 0000000..c56684a --- /dev/null +++ b/backend/core/tests/test_tasks.py @@ -0,0 +1,32 @@ +from datetime import timedelta +from uuid import uuid4 + +import pytest +from django.utils import timezone + +from core.models import Event +from core.tasks import process_event_batch_task + + +@pytest.mark.django_db +def test_process_event_batch_task_ignores_duplicate_event_uuids(): + event_uuid = str(uuid4()) + payload = [ + { + "distinct_id": "user-123", + "event_name": "page_view", + "event_uuid": event_uuid, + "timestamp": timezone.now().isoformat(), + }, + { + "distinct_id": "user-123", + "event_name": "page_view", + "event_uuid": event_uuid, + "timestamp": (timezone.now() - timedelta(minutes=1)).isoformat(), + }, + ] + + processed_count = process_event_batch_task.run(payload) + + assert processed_count == 2 + assert Event.objects.count() == 1 diff --git a/backend/poetry.lock b/backend/poetry.lock index f056a2f..02ea700 100644 --- a/backend/poetry.lock +++ b/backend/poetry.lock @@ -225,11 +225,11 @@ description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" groups = ["main", "dev"] -markers = "platform_system == \"Windows\"" files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +markers = {main = "platform_system == \"Windows\"", dev = "platform_system == \"Windows\" or sys_platform == \"win32\""} [[package]] name = "django" @@ -370,6 +370,18 @@ files = [ [package.dependencies] python-dateutil = ">=2.4" +[[package]] +name = "iniconfig" +version = "2.3.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, + {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, +] + [[package]] name = "kombu" version = "5.6.1" @@ -448,12 +460,28 @@ version = "25.0" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, ] +[[package]] +name = "pluggy" +version = "1.6.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["coverage", "pytest", "pytest-benchmark"] + [[package]] name = "prompt-toolkit" version = "3.0.52" @@ -749,6 +777,43 @@ dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pyte docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"] +[[package]] +name = "pytest" +version = "8.4.2" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"}, + {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"}, +] + +[package.dependencies] +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +iniconfig = ">=1" +packaging = ">=20" +pluggy = ">=1.5,<2" +pygments = ">=2.7.2" + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-django" +version = "4.12.0" +description = "A Django plugin for pytest." +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "pytest_django-4.12.0-py3-none-any.whl", hash = "sha256:3ff300c49f8350ba2953b90297d23bf5f589db69545f56f1ec5f8cff5da83e85"}, + {file = "pytest_django-4.12.0.tar.gz", hash = "sha256:df94ec819a83c8979c8f6de13d9cdfbe76e8c21d39473cfe2b40c9fc9be3c758"}, +] + +[package.dependencies] +pytest = ">=7.0.0" + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -1037,4 +1102,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = "^3.11,<3.14" -content-hash = "45dff72e290a705e215e21d442c547f068ccf97ba306f99af76e3495f1401885" +content-hash = "e7117208200143ba78bdf1dd2759cf90f4c24edaee0ff9cb59752b70520c3720" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index f8696ef..e86fe04 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -21,6 +21,8 @@ faker = "^24.0.0" [tool.poetry.group.dev.dependencies] ruff = "^0.4.0" bandit = {extras = ["toml"], version = "^1.7.0"} +pytest = "^8.3.5" +pytest-django = "^4.11.1" [tool.ruff] line-length = 100 @@ -43,7 +45,10 @@ ignore = [ [tool.ruff.lint.isort] known-first-party = ["telemetry_taco", "core"] +[tool.pytest.ini_options] +DJANGO_SETTINGS_MODULE = "telemetry_taco.settings.test" +python_files = ["test_*.py", "*_tests.py"] + [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" - diff --git a/backend/requirements.txt b/backend/requirements.txt deleted file mode 100644 index 48605eb..0000000 --- a/backend/requirements.txt +++ /dev/null @@ -1,9 +0,0 @@ -# Django -Django>=4.2.0,<5.0.0 - -# Database -psycopg2-binary>=2.9.0 - -# Celery -celery>=5.3.0 -redis>=5.0.0 \ No newline at end of file diff --git a/backend/telemetry_taco/api.py b/backend/telemetry_taco/api.py new file mode 100644 index 0000000..bee83a1 --- /dev/null +++ b/backend/telemetry_taco/api.py @@ -0,0 +1,6 @@ +from ninja import NinjaAPI + +from core.api import router as core_router + +api = NinjaAPI(title="TelemetryTaco API", version="1.1.0") +api.add_router("/", core_router) diff --git a/backend/telemetry_taco/settings.py b/backend/telemetry_taco/settings.py deleted file mode 100644 index 74d473d..0000000 --- a/backend/telemetry_taco/settings.py +++ /dev/null @@ -1,254 +0,0 @@ -""" -Django settings for telemetry_taco project. - -Generated by 'django-admin startproject' using Django 5.0. - -For more information on this file, see -https://docs.djangoproject.com/en/5.0/topics/settings/ - -For the full list of settings and their values, see -https://docs.djangoproject.com/en/5.0/ref/settings/ -""" - -import os -from pathlib import Path - -import environ - -# Build paths inside the project like this: BASE_DIR / 'subdir'. -BASE_DIR = Path(__file__).resolve().parent.parent - -# Initialize environment variables -env = environ.Env( - DEBUG=(bool, True) # Default to True for development safety -) - -# Read .env file if it exists -environ.Env.read_env(BASE_DIR / ".env") - -# SECURITY WARNING: don't run with debug turned on in production! -# DEBUG defaults to True for local development convenience. -# Explicitly set DEBUG=False in production environment. -DEBUG = env("DEBUG", default=True) - -# SECURITY WARNING: keep the secret key used in production secret! -# SECRET_KEY must be explicitly set via environment variable in ALL environments -# (both development and production) to prevent accidental deployment with insecure keys. -# No default value is provided as a security measure. -if "SECRET_KEY" not in os.environ: - raise ValueError( - "SECRET_KEY must be explicitly set via environment variable. " - "This is required for security in both development and production environments.\n\n" - "To generate a secure secret key, run:\n" - " python -c 'from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())'\n\n" - "Then add it to your .env file:\n" - " SECRET_KEY=your-generated-secret-key-here\n\n" - "For local development, you can add this to backend/.env" - ) - -# Get the SECRET_KEY from environment -SECRET_KEY = env("SECRET_KEY") - -# Validate that SECRET_KEY is not using any known insecure defaults -# This prevents accidental use of example/insecure values -# Note: start.sh now generates secure keys, but we keep these in the list to catch -# manually set insecure values or values from old .env files -insecure_defaults = [ - "django-insecure-change-me-in-production", - "django-insecure-dev-only-change-me-in-production", - "changeme", - "secret", - "your-secret-key-here", -] - -if SECRET_KEY in insecure_defaults: - raise ValueError( - f"SECRET_KEY cannot use the insecure default value '{SECRET_KEY}'. " - "Generate a secure secret key and set it via the SECRET_KEY environment variable.\n\n" - "To generate a secure secret key, run:\n" - " python -c 'from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())'" - ) - -# Additional validation: ensure SECRET_KEY has minimum length -if len(SECRET_KEY) < 50: - raise ValueError( - "SECRET_KEY appears to be too short. Django secret keys should be at least 50 characters. " - "Generate a secure secret key using:\n" - " python -c 'from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())'" - ) - -ALLOWED_HOSTS = env.list("ALLOWED_HOSTS", default=["localhost", "127.0.0.1", "0.0.0.0"]) - - -# Application definition - -INSTALLED_APPS = [ - "django.contrib.admin", - "django.contrib.auth", - "django.contrib.contenttypes", - "django.contrib.sessions", - "django.contrib.messages", - "django.contrib.staticfiles", - "corsheaders", - "core", -] - -MIDDLEWARE = [ - "django.middleware.security.SecurityMiddleware", - "corsheaders.middleware.CorsMiddleware", - "django.contrib.sessions.middleware.SessionMiddleware", - "django.middleware.common.CommonMiddleware", - "django.middleware.csrf.CsrfViewMiddleware", - "django.contrib.auth.middleware.AuthenticationMiddleware", - "django.contrib.messages.middleware.MessageMiddleware", - "django.middleware.clickjacking.XFrameOptionsMiddleware", -] - -ROOT_URLCONF = "telemetry_taco.urls" - -TEMPLATES = [ - { - "BACKEND": "django.template.backends.django.DjangoTemplates", - "DIRS": [], - "APP_DIRS": True, - "OPTIONS": { - "context_processors": [ - "django.template.context_processors.debug", - "django.template.context_processors.request", - "django.contrib.auth.context_processors.auth", - "django.contrib.messages.context_processors.messages", - ], - }, - }, -] - -WSGI_APPLICATION = "telemetry_taco.wsgi.application" - - -# Database -# https://docs.djangoproject.com/en/5.0/ref/settings/#databases - -DATABASES = { - "default": env.db( - "DATABASE_URL", default="postgresql://postgres:postgres@localhost:5432/telemetry_taco" - ) -} - - -# Password validation -# https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators - -AUTH_PASSWORD_VALIDATORS = [ - { - "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", - }, - { - "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", - }, - { - "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", - }, - { - "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", - }, -] - - -# Internationalization -# https://docs.djangoproject.com/en/5.0/topics/i18n/ - -LANGUAGE_CODE = "en-us" - -TIME_ZONE = "UTC" - -USE_I18N = True - -USE_TZ = True - - -# Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/5.0/howto/static-files/ - -STATIC_URL = "static/" - -# Default primary key field type -# https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field - -DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" - -# CORS Configuration -# In development, allow all localhost origins for flexibility -if DEBUG: - CORS_ALLOWED_ORIGINS = [ - "http://localhost:5173", - "http://127.0.0.1:5173", - "http://localhost:3000", - "http://127.0.0.1:3000", - ] - # Allow all localhost origins in development - CORS_ALLOW_ALL_ORIGINS = False # Explicitly set to False for security -else: - # Production: Only allow specific origins - CORS_ALLOWED_ORIGINS = env.list("CORS_ALLOWED_ORIGINS", default=[]) - -CORS_ALLOW_CREDENTIALS = True -CORS_ALLOW_HEADERS = [ - "accept", - "accept-encoding", - "authorization", - "content-type", - "dnt", - "origin", - "user-agent", - "x-csrftoken", - "x-requested-with", -] - -# Celery Configuration -CELERY_BROKER_URL = env("REDIS_URL", default="redis://localhost:6379/0") -CELERY_RESULT_BACKEND = env("REDIS_URL", default="redis://localhost:6379/0") -CELERY_ACCEPT_CONTENT = ["json"] -CELERY_TASK_SERIALIZER = "json" -CELERY_RESULT_SERIALIZER = "json" -CELERY_TIMEZONE = TIME_ZONE - -# Cache Configuration (for rate limiting) -# Uses Redis for rate limiting storage (separate DB from Celery) -# django-redis provides robust Redis caching with connection pooling -CACHES = { - "default": { - "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": env("REDIS_URL", default="redis://localhost:6379/1"), # Use DB 1 for cache - "OPTIONS": { - "CLIENT_CLASS": "django_redis.client.DefaultClient", - }, - } -} - -# Rate Limiting Configuration -# django-ratelimit uses the default cache backend (Redis) for rate limit tracking -RATELIMIT_USE_CACHE = "default" - -# Environment detection for default rate limits -# Use ENVIRONMENT variable (development/staging/production) instead of DEBUG -# This is safer than using DEBUG, which should never be True in production -ENVIRONMENT = env("ENVIRONMENT", default="production").lower() - -# Configurable rate limits for API endpoints -# Format: "number/period" where period is s (second), m (minute), h (hour), d (day) -# Examples: "1000/h" = 1000 per hour, "100/m" = 100 per minute -# Defaults vary by environment: development has higher limits for testing -if ENVIRONMENT == "development": - DEFAULT_RATE_LIMIT_CAPTURE_EVENT = "10000/h" - DEFAULT_RATE_LIMIT_LIST_EVENTS = "1000000/h" # Very high limit for development - DEFAULT_RATE_LIMIT_GET_INSIGHTS = "1000/h" -else: - # Production/staging defaults (more restrictive) - DEFAULT_RATE_LIMIT_CAPTURE_EVENT = "1000/h" - DEFAULT_RATE_LIMIT_LIST_EVENTS = "10000/h" - DEFAULT_RATE_LIMIT_GET_INSIGHTS = "300/h" - -# Allow explicit override via environment variables (takes precedence over environment-based defaults) -RATE_LIMIT_CAPTURE_EVENT = env("RATE_LIMIT_CAPTURE_EVENT", default=DEFAULT_RATE_LIMIT_CAPTURE_EVENT) -RATE_LIMIT_LIST_EVENTS = env("RATE_LIMIT_LIST_EVENTS", default=DEFAULT_RATE_LIMIT_LIST_EVENTS) -RATE_LIMIT_GET_INSIGHTS = env("RATE_LIMIT_GET_INSIGHTS", default=DEFAULT_RATE_LIMIT_GET_INSIGHTS) diff --git a/backend/telemetry_taco/settings/__init__.py b/backend/telemetry_taco/settings/__init__.py new file mode 100644 index 0000000..f720801 --- /dev/null +++ b/backend/telemetry_taco/settings/__init__.py @@ -0,0 +1 @@ +from .development import * # noqa: F403 diff --git a/backend/telemetry_taco/settings/base.py b/backend/telemetry_taco/settings/base.py new file mode 100644 index 0000000..2b11aee --- /dev/null +++ b/backend/telemetry_taco/settings/base.py @@ -0,0 +1,164 @@ +from pathlib import Path + +import environ + +BASE_DIR = Path(__file__).resolve().parent.parent.parent + +env = environ.Env( + DEBUG=(bool, False), + ALLOWED_HOSTS=(list, ["localhost", "127.0.0.1", "0.0.0.0"]), +) + +environ.Env.read_env(BASE_DIR / ".env") + +SECRET_KEY = env( + "SECRET_KEY", + default="dev-only-secret-key-not-for-production-use-please-change-me-12345", +) +DEBUG = env.bool("DEBUG", default=False) +ALLOWED_HOSTS = env.list("ALLOWED_HOSTS", default=["localhost", "127.0.0.1", "0.0.0.0"]) +TIME_ZONE = env("TIME_ZONE", default="UTC") + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "corsheaders", + "core", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "corsheaders.middleware.CorsMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "telemetry_taco.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + } +] + +WSGI_APPLICATION = "telemetry_taco.wsgi.application" +ASGI_APPLICATION = "telemetry_taco.asgi.application" + +DATABASES = { + "default": env.db( + "DATABASE_URL", + default="postgresql://postgres:postgres@localhost:5432/telemetry_taco", + ) +} + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, +] + +LANGUAGE_CODE = "en-us" +USE_I18N = True +USE_TZ = True + +STATIC_URL = "static/" +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +CORS_ALLOWED_ORIGINS = env.list("CORS_ALLOWED_ORIGINS", default=[]) +CORS_ALLOW_CREDENTIALS = True +CORS_ALLOW_HEADERS = [ + "accept", + "accept-encoding", + "authorization", + "content-type", + "dnt", + "origin", + "user-agent", + "x-csrftoken", + "x-requested-with", +] + +REDIS_URL = env("REDIS_URL", default="redis://localhost:6379/0") + +CELERY_BROKER_URL = REDIS_URL +CELERY_RESULT_BACKEND = REDIS_URL +CELERY_ACCEPT_CONTENT = ["json"] +CELERY_TASK_SERIALIZER = "json" +CELERY_RESULT_SERIALIZER = "json" +CELERY_TIMEZONE = TIME_ZONE +CELERY_TASK_ALWAYS_EAGER = env.bool("CELERY_TASK_ALWAYS_EAGER", default=False) +CELERY_TASK_EAGER_PROPAGATES = env.bool("CELERY_TASK_EAGER_PROPAGATES", default=False) + +CACHES = { + "default": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": env("CACHE_URL", default="redis://localhost:6379/1"), + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", + }, + } +} + +RATELIMIT_USE_CACHE = "default" + +RATE_LIMIT_CAPTURE_EVENT = env("RATE_LIMIT_CAPTURE_EVENT", default="1000/h") +RATE_LIMIT_LIST_EVENTS = env("RATE_LIMIT_LIST_EVENTS", default="10000/h") +RATE_LIMIT_GET_INSIGHTS = env("RATE_LIMIT_GET_INSIGHTS", default="300/h") + +MAX_CAPTURE_BATCH_SIZE = env.int("MAX_CAPTURE_BATCH_SIZE", default=500) +MAX_EVENTS_LIMIT = env.int("MAX_EVENTS_LIMIT", default=200) +MAX_INSIGHTS_LOOKBACK_MINUTES = env.int("MAX_INSIGHTS_LOOKBACK_MINUTES", default=24 * 60) +EVENT_RETENTION_DAYS = env.int("EVENT_RETENTION_DAYS", default=30) + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "structured": { + "format": ( + "%(asctime)s %(levelname)s %(name)s " + "event=%(message)s task=%(task_name)s task_id=%(task_id)s" + ), + }, + "default": { + "format": "%(asctime)s %(levelname)s %(name)s %(message)s", + }, + }, + "handlers": { + "console": { + "class": "logging.StreamHandler", + "formatter": "default", + } + }, + "loggers": { + "core": {"handlers": ["console"], "level": env("LOG_LEVEL", default="INFO")}, + "celery": {"handlers": ["console"], "level": env("LOG_LEVEL", default="INFO")}, + }, +} diff --git a/backend/telemetry_taco/settings/development.py b/backend/telemetry_taco/settings/development.py new file mode 100644 index 0000000..2e2cee5 --- /dev/null +++ b/backend/telemetry_taco/settings/development.py @@ -0,0 +1,16 @@ +from .base import * # noqa: F403 +from .base import env + +DEBUG = env.bool("DEBUG", default=True) + +if not CORS_ALLOWED_ORIGINS: # noqa: F405 + CORS_ALLOWED_ORIGINS = [ # noqa: F405 + "http://localhost:5173", + "http://127.0.0.1:5173", + "http://localhost:3000", + "http://127.0.0.1:3000", + ] + +RATE_LIMIT_CAPTURE_EVENT = env("RATE_LIMIT_CAPTURE_EVENT", default="10000/h") +RATE_LIMIT_LIST_EVENTS = env("RATE_LIMIT_LIST_EVENTS", default="1000000/h") +RATE_LIMIT_GET_INSIGHTS = env("RATE_LIMIT_GET_INSIGHTS", default="1000/h") diff --git a/backend/telemetry_taco/settings/production.py b/backend/telemetry_taco/settings/production.py new file mode 100644 index 0000000..29c94c4 --- /dev/null +++ b/backend/telemetry_taco/settings/production.py @@ -0,0 +1,12 @@ +from .base import * # noqa: F403 +from .base import env + +DEBUG = env.bool("DEBUG", default=False) + +DEFAULT_SECRET_KEY = "dev-only-secret-key-not-for-production-use-please-change-me-12345" + +if SECRET_KEY == DEFAULT_SECRET_KEY: # noqa: F405 + raise ValueError("SECRET_KEY must be set explicitly in production.") + +if len(SECRET_KEY) < 50: # noqa: F405 + raise ValueError("SECRET_KEY must be at least 50 characters long in production.") diff --git a/backend/telemetry_taco/settings/test.py b/backend/telemetry_taco/settings/test.py new file mode 100644 index 0000000..0e51420 --- /dev/null +++ b/backend/telemetry_taco/settings/test.py @@ -0,0 +1,29 @@ +from .base import * # noqa: F403 + +DEBUG = False +SECRET_KEY = "test-secret-key-not-for-production-use-only-12345678901234567890" + +DATABASES = { # noqa: F405 + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "test.sqlite3", # noqa: F405 + } +} + +CACHES = { # noqa: F405 + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + "LOCATION": "telemetry-taco-test", + } +} + +PASSWORD_HASHERS = [ + "django.contrib.auth.hashers.MD5PasswordHasher", +] + +CELERY_TASK_ALWAYS_EAGER = True +CELERY_TASK_EAGER_PROPAGATES = True + +RATE_LIMIT_CAPTURE_EVENT = "999999/h" +RATE_LIMIT_LIST_EVENTS = "999999/h" +RATE_LIMIT_GET_INSIGHTS = "999999/h" diff --git a/backend/telemetry_taco/urls.py b/backend/telemetry_taco/urls.py index eb604fe..310ee89 100644 --- a/backend/telemetry_taco/urls.py +++ b/backend/telemetry_taco/urls.py @@ -17,12 +17,8 @@ from django.contrib import admin from django.urls import path -from ninja import NinjaAPI -from core.api import router - -api = NinjaAPI(title="TelemetryTaco API", version="1.0.0") -api.add_router("/", router) +from telemetry_taco.api import api urlpatterns = [ path("admin/", admin.site.urls), diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index fc9ce0a..2e5d4f1 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -32,6 +32,7 @@ export default [ rules: { ...tsPlugin.configs.recommended.rules, ...reactHooks.configs.recommended.rules, + 'no-undef': 'off', 'react-refresh/only-export-components': [ 'warn', { allowConstantExport: true }, @@ -50,5 +51,17 @@ export default [ ...js.configs.recommended.rules, }, }, + { + files: ['**/*.test.{ts,tsx}', 'src/test/**/*.{ts,tsx}'], + languageOptions: { + globals: { + ...globals.browser, + ...globals.node, + ...globals.vitest, + }, + }, + rules: { + 'react-refresh/only-export-components': 'off', + }, + }, ] - diff --git a/frontend/openapi.json b/frontend/openapi.json new file mode 100644 index 0000000..fefec47 --- /dev/null +++ b/frontend/openapi.json @@ -0,0 +1,389 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "TelemetryTaco API", + "version": "1.1.0", + "description": "" + }, + "paths": { + "/api/capture": { + "post": { + "operationId": "core_api_events_capture_event", + "summary": "Capture Event", + "parameters": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StatusResponse" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EventCaptureSchema" + } + } + }, + "required": true + } + } + }, + "/api/capture/batch": { + "post": { + "operationId": "core_api_events_capture_event_batch", + "summary": "Capture Event Batch", + "parameters": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchStatusResponse" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EventBatchCaptureSchema" + } + } + }, + "required": true + } + } + }, + "/api/events": { + "get": { + "operationId": "core_api_events_list_events", + "summary": "List Events", + "parameters": [ + { + "in": "query", + "name": "limit", + "schema": { + "default": 100, + "title": "Limit", + "type": "integer" + }, + "required": false + }, + { + "in": "query", + "name": "before", + "schema": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Before" + }, + "required": false + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/EventResponseSchema" + }, + "title": "Response", + "type": "array" + } + } + } + } + } + } + }, + "/api/insights": { + "get": { + "operationId": "core_api_events_get_event_insights", + "summary": "Get Event Insights", + "parameters": [ + { + "in": "query", + "name": "lookback_minutes", + "schema": { + "default": 60, + "title": "Lookback Minutes", + "type": "integer" + }, + "required": false + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/InsightDataPoint" + }, + "title": "Response", + "type": "array" + } + } + } + } + } + } + }, + "/api/health/live": { + "get": { + "operationId": "core_api_events_liveness", + "summary": "Liveness", + "parameters": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HealthStatusResponse" + } + } + } + } + } + } + }, + "/api/health/ready": { + "get": { + "operationId": "core_api_events_readiness", + "summary": "Readiness", + "parameters": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HealthStatusResponse" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "StatusResponse": { + "properties": { + "status": { + "default": "ok", + "title": "Status", + "type": "string" + } + }, + "title": "StatusResponse", + "type": "object" + }, + "EventCaptureSchema": { + "properties": { + "distinct_id": { + "title": "Distinct Id", + "type": "string" + }, + "event_name": { + "title": "Event Name", + "type": "string" + }, + "properties": { + "type": "object", + "additionalProperties": true, + "title": "Properties" + }, + "event_uuid": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Event Uuid" + }, + "sent_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Sent At" + } + }, + "required": [ + "distinct_id", + "event_name" + ], + "title": "EventCaptureSchema", + "type": "object" + }, + "BatchStatusResponse": { + "properties": { + "status": { + "default": "ok", + "title": "Status", + "type": "string" + }, + "accepted": { + "title": "Accepted", + "type": "integer" + } + }, + "required": [ + "accepted" + ], + "title": "BatchStatusResponse", + "type": "object" + }, + "EventBatchCaptureSchema": { + "properties": { + "events": { + "items": { + "$ref": "#/components/schemas/EventCaptureSchema" + }, + "title": "Events", + "type": "array" + } + }, + "required": [ + "events" + ], + "title": "EventBatchCaptureSchema", + "type": "object" + }, + "EventResponseSchema": { + "properties": { + "uuid": { + "title": "Uuid", + "type": "string" + }, + "id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "ID" + }, + "distinct_id": { + "description": "User ID", + "maxLength": 255, + "title": "Distinct Id", + "type": "string" + }, + "event_name": { + "maxLength": 255, + "title": "Event Name", + "type": "string" + }, + "properties": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Properties", + "description": "Flexible JSONB field for event properties" + }, + "timestamp": { + "format": "date-time", + "title": "Timestamp", + "type": "string" + }, + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + } + }, + "required": [ + "uuid", + "distinct_id", + "event_name", + "created_at" + ], + "title": "EventResponseSchema", + "type": "object" + }, + "InsightDataPoint": { + "properties": { + "time": { + "title": "Time", + "type": "string" + }, + "count": { + "title": "Count", + "type": "integer" + } + }, + "required": [ + "time", + "count" + ], + "title": "InsightDataPoint", + "type": "object" + }, + "HealthStatusResponse": { + "properties": { + "status": { + "title": "Status", + "type": "string" + }, + "database": { + "title": "Database", + "type": "string" + }, + "cache": { + "title": "Cache", + "type": "string" + } + }, + "required": [ + "status", + "database", + "cache" + ], + "title": "HealthStatusResponse", + "type": "object" + } + } + }, + "servers": [] +} \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json deleted file mode 100644 index bef2646..0000000 --- a/frontend/package-lock.json +++ /dev/null @@ -1,4173 +0,0 @@ -{ - "name": "telemetry-taco-frontend", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "telemetry-taco-frontend", - "version": "0.1.0", - "dependencies": { - "class-variance-authority": "^0.7.0", - "clsx": "^2.0.0", - "lucide-react": "^0.294.0", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "tailwind-merge": "^2.1.0" - }, - "devDependencies": { - "@types/react": "^18.2.43", - "@types/react-dom": "^18.2.17", - "@typescript-eslint/eslint-plugin": "^6.14.0", - "@typescript-eslint/parser": "^6.14.0", - "@vitejs/plugin-react": "^4.2.1", - "autoprefixer": "^10.4.16", - "eslint": "^8.55.0", - "eslint-plugin-react-hooks": "^4.6.0", - "eslint-plugin-react-refresh": "^0.4.5", - "postcss": "^8.4.32", - "tailwindcss": "^3.4.0", - "tailwindcss-animate": "^1.0.7", - "typescript": "^5.2.2", - "vite": "^5.0.8" - } - }, - "node_modules/@alloc/quick-lru": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", - "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", - "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", - "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.28.3", - "@babel/helpers": "^7.28.4", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.5", - "@babel/types": "^7.28.5", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/core/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/generator": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", - "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.28.5", - "@babel/types": "^7.28.5", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", - "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.27.2", - "@babel/helper-validator-option": "^7.27.1", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", - "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", - "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.28.3" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", - "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", - "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.4" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", - "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.5" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", - "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", - "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/template": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", - "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", - "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.5", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", - "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", - "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", - "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^9.6.0", - "globals": "^13.19.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/@eslint/js": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", - "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, - "node_modules/@humanwhocodes/config-array": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", - "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", - "deprecated": "Use @eslint/config-array instead", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanwhocodes/object-schema": "^2.0.3", - "debug": "^4.3.1", - "minimatch": "^3.0.5" - }, - "engines": { - "node": ">=10.10.0" - } - }, - "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/object-schema": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", - "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", - "deprecated": "Use @eslint/object-schema instead", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.27", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", - "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.5.tgz", - "integrity": "sha512-iDGS/h7D8t7tvZ1t6+WPK04KD0MwzLZrG0se1hzBjSi5fyxlsiggoJHwh18PCFNn7tG43OWb6pdZ6Y+rMlmyNQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.5.tgz", - "integrity": "sha512-wrSAViWvZHBMMlWk6EJhvg8/rjxzyEhEdgfMMjREHEq11EtJ6IP6yfcCH57YAEca2Oe3FNCE9DSTgU70EIGmVw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.5.tgz", - "integrity": "sha512-S87zZPBmRO6u1YXQLwpveZm4JfPpAa6oHBX7/ghSiGH3rz/KDgAu1rKdGutV+WUI6tKDMbaBJomhnT30Y2t4VQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.5.tgz", - "integrity": "sha512-YTbnsAaHo6VrAczISxgpTva8EkfQus0VPEVJCEaboHtZRIb6h6j0BNxRBOwnDciFTZLDPW5r+ZBmhL/+YpTZgA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.5.tgz", - "integrity": "sha512-1T8eY2J8rKJWzaznV7zedfdhD1BqVs1iqILhmHDq/bqCUZsrMt+j8VCTHhP0vdfbHK3e1IQ7VYx3jlKqwlf+vw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.5.tgz", - "integrity": "sha512-sHTiuXyBJApxRn+VFMaw1U+Qsz4kcNlxQ742snICYPrY+DDL8/ZbaC4DVIB7vgZmp3jiDaKA0WpBdP0aqPJoBQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.5.tgz", - "integrity": "sha512-dV3T9MyAf0w8zPVLVBptVlzaXxka6xg1f16VAQmjg+4KMSTWDvhimI/Y6mp8oHwNrmnmVl9XxJ/w/mO4uIQONA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.5.tgz", - "integrity": "sha512-wIGYC1x/hyjP+KAu9+ewDI+fi5XSNiUi9Bvg6KGAh2TsNMA3tSEs+Sh6jJ/r4BV/bx/CyWu2ue9kDnIdRyafcQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.5.tgz", - "integrity": "sha512-Y+qVA0D9d0y2FRNiG9oM3Hut/DgODZbU9I8pLLPwAsU0tUKZ49cyV1tzmB/qRbSzGvY8lpgGkJuMyuhH7Ma+Vg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.5.tgz", - "integrity": "sha512-juaC4bEgJsyFVfqhtGLz8mbopaWD+WeSOYr5E16y+1of6KQjc0BpwZLuxkClqY1i8sco+MdyoXPNiCkQou09+g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.5.tgz", - "integrity": "sha512-rIEC0hZ17A42iXtHX+EPJVL/CakHo+tT7W0pbzdAGuWOt2jxDFh7A/lRhsNHBcqL4T36+UiAgwO8pbmn3dE8wA==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.5.tgz", - "integrity": "sha512-T7l409NhUE552RcAOcmJHj3xyZ2h7vMWzcwQI0hvn5tqHh3oSoclf9WgTl+0QqffWFG8MEVZZP1/OBglKZx52Q==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.5.tgz", - "integrity": "sha512-7OK5/GhxbnrMcxIFoYfhV/TkknarkYC1hqUw1wU2xUN3TVRLNT5FmBv4KkheSG2xZ6IEbRAhTooTV2+R5Tk0lQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.5.tgz", - "integrity": "sha512-GwuDBE/PsXaTa76lO5eLJTyr2k8QkPipAyOrs4V/KJufHCZBJ495VCGJol35grx9xryk4V+2zd3Ri+3v7NPh+w==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.5.tgz", - "integrity": "sha512-IAE1Ziyr1qNfnmiQLHBURAD+eh/zH1pIeJjeShleII7Vj8kyEm2PF77o+lf3WTHDpNJcu4IXJxNO0Zluro8bOw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.5.tgz", - "integrity": "sha512-Pg6E+oP7GvZ4XwgRJBuSXZjcqpIW3yCBhK4BcsANvb47qMvAbCjR6E+1a/U2WXz1JJxp9/4Dno3/iSJLcm5auw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.5.tgz", - "integrity": "sha512-txGtluxDKTxaMDzUduGP0wdfng24y1rygUMnmlUJ88fzCCULCLn7oE5kb2+tRB+MWq1QDZT6ObT5RrR8HFRKqg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.5.tgz", - "integrity": "sha512-3DFiLPnTxiOQV993fMc+KO8zXHTcIjgaInrqlG8zDp1TlhYl6WgrOHuJkJQ6M8zHEcntSJsUp1XFZSY8C1DYbg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.5.tgz", - "integrity": "sha512-nggc/wPpNTgjGg75hu+Q/3i32R00Lq1B6N1DO7MCU340MRKL3WZJMjA9U4K4gzy3dkZPXm9E1Nc81FItBVGRlA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.5.tgz", - "integrity": "sha512-U/54pTbdQpPLBdEzCT6NBCFAfSZMvmjr0twhnD9f4EIvlm9wy3jjQ38yQj1AGznrNO65EWQMgm/QUjuIVrYF9w==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.5.tgz", - "integrity": "sha512-2NqKgZSuLH9SXBBV2dWNRCZmocgSOx8OJSdpRaEcRlIfX8YrKxUT6z0F1NpvDVhOsl190UFTRh2F2WDWWCYp3A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.5.tgz", - "integrity": "sha512-JRpZUhCfhZ4keB5v0fe02gQJy05GqboPOaxvjugW04RLSYYoB/9t2lx2u/tMs/Na/1NXfY8QYjgRljRpN+MjTQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", - "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", - "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.2" - } - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/prop-types": { - "version": "15.7.15", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", - "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/react": { - "version": "18.3.27", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", - "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/prop-types": "*", - "csstype": "^3.2.2" - } - }, - "node_modules/@types/react-dom": { - "version": "18.3.7", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", - "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^18.0.0" - } - }, - "node_modules/@types/semver": { - "version": "7.7.1", - "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", - "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz", - "integrity": "sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/regexpp": "^4.5.1", - "@typescript-eslint/scope-manager": "6.21.0", - "@typescript-eslint/type-utils": "6.21.0", - "@typescript-eslint/utils": "6.21.0", - "@typescript-eslint/visitor-keys": "6.21.0", - "debug": "^4.3.4", - "graphemer": "^1.4.0", - "ignore": "^5.2.4", - "natural-compare": "^1.4.0", - "semver": "^7.5.4", - "ts-api-utils": "^1.0.1" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^6.0.0 || ^6.0.0-alpha", - "eslint": "^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/parser": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz", - "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "@typescript-eslint/scope-manager": "6.21.0", - "@typescript-eslint/types": "6.21.0", - "@typescript-eslint/typescript-estree": "6.21.0", - "@typescript-eslint/visitor-keys": "6.21.0", - "debug": "^4.3.4" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz", - "integrity": "sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "6.21.0", - "@typescript-eslint/visitor-keys": "6.21.0" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/type-utils": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.21.0.tgz", - "integrity": "sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/typescript-estree": "6.21.0", - "@typescript-eslint/utils": "6.21.0", - "debug": "^4.3.4", - "ts-api-utils": "^1.0.1" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz", - "integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz", - "integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "@typescript-eslint/types": "6.21.0", - "@typescript-eslint/visitor-keys": "6.21.0", - "debug": "^4.3.4", - "globby": "^11.1.0", - "is-glob": "^4.0.3", - "minimatch": "9.0.3", - "semver": "^7.5.4", - "ts-api-utils": "^1.0.1" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/utils": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.21.0.tgz", - "integrity": "sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "@types/json-schema": "^7.0.12", - "@types/semver": "^7.5.0", - "@typescript-eslint/scope-manager": "6.21.0", - "@typescript-eslint/types": "6.21.0", - "@typescript-eslint/typescript-estree": "6.21.0", - "semver": "^7.5.4" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0" - } - }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz", - "integrity": "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "6.21.0", - "eslint-visitor-keys": "^3.4.1" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@ungap/structured-clone": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", - "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", - "dev": true, - "license": "ISC" - }, - "node_modules/@vitejs/plugin-react": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", - "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.28.0", - "@babel/plugin-transform-react-jsx-self": "^7.27.1", - "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-beta.27", - "@types/babel__core": "^7.20.5", - "react-refresh": "^0.17.0" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" - } - }, - "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/any-promise": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", - "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", - "dev": true, - "license": "MIT" - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/arg": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", - "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", - "dev": true, - "license": "MIT" - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/autoprefixer": { - "version": "10.4.23", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz", - "integrity": "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/autoprefixer" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "browserslist": "^4.28.1", - "caniuse-lite": "^1.0.30001760", - "fraction.js": "^5.3.4", - "picocolors": "^1.1.1", - "postcss-value-parser": "^4.2.0" - }, - "bin": { - "autoprefixer": "bin/autoprefixer" - }, - "engines": { - "node": "^10 || ^12 || >=14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/baseline-browser-mapping": { - "version": "2.9.11", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz", - "integrity": "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.js" - } - }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/camelcase-css": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", - "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001761", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001761.tgz", - "integrity": "sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/chokidar/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/class-variance-authority": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", - "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", - "license": "Apache-2.0", - "dependencies": { - "clsx": "^2.1.1" - }, - "funding": { - "url": "https://polar.sh/cva" - } - }, - "node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/commander": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "dev": true, - "license": "MIT", - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/didyoumean": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", - "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-type": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/dlv": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", - "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", - "dev": true, - "license": "MIT" - }, - "node_modules/doctrine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/electron-to-chromium": { - "version": "1.5.267", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", - "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", - "dev": true, - "license": "ISC" - }, - "node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "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" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", - "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", - "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.6.1", - "@eslint/eslintrc": "^2.1.4", - "@eslint/js": "8.57.1", - "@humanwhocodes/config-array": "^0.13.0", - "@humanwhocodes/module-importer": "^1.0.1", - "@nodelib/fs.walk": "^1.2.8", - "@ungap/structured-clone": "^1.2.0", - "ajv": "^6.12.4", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.2", - "debug": "^4.3.2", - "doctrine": "^3.0.0", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.2.2", - "eslint-visitor-keys": "^3.4.3", - "espree": "^9.6.1", - "esquery": "^1.4.2", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^6.0.1", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "globals": "^13.19.0", - "graphemer": "^1.4.0", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "is-path-inside": "^3.0.3", - "js-yaml": "^4.1.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3", - "strip-ansi": "^6.0.1", - "text-table": "^0.2.0" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-plugin-react-hooks": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.2.tgz", - "integrity": "sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0" - } - }, - "node_modules/eslint-plugin-react-refresh": { - "version": "0.4.26", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", - "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "eslint": ">=8.40" - } - }, - "node_modules/eslint-scope": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", - "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/espree": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", - "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.9.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.4.1" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fastq": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", - "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/file-entry-cache": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", - "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", - "dev": true, - "license": "MIT", - "dependencies": { - "flat-cache": "^3.0.4" - }, - "engines": { - "node": "^10.12.0 || >=12.0.0" - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/flat-cache": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", - "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", - "dev": true, - "license": "MIT", - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.3", - "rimraf": "^3.0.2" - }, - "engines": { - "node": "^10.12.0 || >=12.0.0" - } - }, - "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", - "dev": true, - "license": "ISC" - }, - "node_modules/fraction.js": { - "version": "5.3.4", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", - "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/rawify" - } - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, - "license": "ISC" - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/globals": { - "version": "13.24.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "type-fest": "^0.20.2" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true, - "license": "MIT" - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, - "license": "ISC", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-path-inside": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, - "node_modules/jiti": { - "version": "1.21.7", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", - "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", - "dev": true, - "license": "MIT", - "bin": { - "jiti": "bin/jiti.js" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/lilconfig": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", - "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antonk52" - } - }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true, - "license": "MIT" - }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "license": "MIT", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/lucide-react": { - "version": "0.294.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.294.0.tgz", - "integrity": "sha512-V7o0/VECSGbLHn3/1O67FUgBwWB+hmzshrgDVRJQhMh8uj5D3HBuIvhuAmQTtlupILSplwIZg5FTc4tTKMA2SA==", - "license": "ISC", - "peerDependencies": { - "react": "^16.5.1 || ^17.0.0 || ^18.0.0" - } - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/minimatch": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", - "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/mz": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", - "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0", - "object-assign": "^4.0.1", - "thenify-all": "^1.0.0" - } - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, - "license": "MIT" - }, - "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-hash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", - "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true, - "license": "MIT" - }, - "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/pirates": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", - "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/postcss-import": { - "version": "15.1.0", - "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", - "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", - "dev": true, - "license": "MIT", - "dependencies": { - "postcss-value-parser": "^4.0.0", - "read-cache": "^1.0.0", - "resolve": "^1.1.7" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "postcss": "^8.0.0" - } - }, - "node_modules/postcss-js": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", - "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "camelcase-css": "^2.0.1" - }, - "engines": { - "node": "^12 || ^14 || >= 16" - }, - "peerDependencies": { - "postcss": "^8.4.21" - } - }, - "node_modules/postcss-load-config": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", - "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "lilconfig": "^3.1.1" - }, - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "jiti": ">=1.21.0", - "postcss": ">=8.0.9", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - }, - "postcss": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/postcss-nested": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", - "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "postcss-selector-parser": "^6.1.1" - }, - "engines": { - "node": ">=12.0" - }, - "peerDependencies": { - "postcss": "^8.2.14" - } - }, - "node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/react": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", - "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", - "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.2" - }, - "peerDependencies": { - "react": "^18.3.1" - } - }, - "node_modules/react-refresh": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", - "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/read-cache": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", - "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "pify": "^2.3.0" - } - }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/resolve": { - "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-core-module": "^2.16.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rollup": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.5.tgz", - "integrity": "sha512-iTNAbFSlRpcHeeWu73ywU/8KuU/LZmNCSxp6fjQkJBD3ivUb8tpDrXhIxEzA05HlYMEwmtaUnb3RP+YNv162OQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.53.5", - "@rollup/rollup-android-arm64": "4.53.5", - "@rollup/rollup-darwin-arm64": "4.53.5", - "@rollup/rollup-darwin-x64": "4.53.5", - "@rollup/rollup-freebsd-arm64": "4.53.5", - "@rollup/rollup-freebsd-x64": "4.53.5", - "@rollup/rollup-linux-arm-gnueabihf": "4.53.5", - "@rollup/rollup-linux-arm-musleabihf": "4.53.5", - "@rollup/rollup-linux-arm64-gnu": "4.53.5", - "@rollup/rollup-linux-arm64-musl": "4.53.5", - "@rollup/rollup-linux-loong64-gnu": "4.53.5", - "@rollup/rollup-linux-ppc64-gnu": "4.53.5", - "@rollup/rollup-linux-riscv64-gnu": "4.53.5", - "@rollup/rollup-linux-riscv64-musl": "4.53.5", - "@rollup/rollup-linux-s390x-gnu": "4.53.5", - "@rollup/rollup-linux-x64-gnu": "4.53.5", - "@rollup/rollup-linux-x64-musl": "4.53.5", - "@rollup/rollup-openharmony-arm64": "4.53.5", - "@rollup/rollup-win32-arm64-msvc": "4.53.5", - "@rollup/rollup-win32-ia32-msvc": "4.53.5", - "@rollup/rollup-win32-x64-gnu": "4.53.5", - "@rollup/rollup-win32-x64-msvc": "4.53.5", - "fsevents": "~2.3.2" - } - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, - "node_modules/scheduler": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", - "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - } - }, - "node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/sucrase": { - "version": "3.35.1", - "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", - "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.2", - "commander": "^4.0.0", - "lines-and-columns": "^1.1.6", - "mz": "^2.7.0", - "pirates": "^4.0.1", - "tinyglobby": "^0.2.11", - "ts-interface-checker": "^0.1.9" - }, - "bin": { - "sucrase": "bin/sucrase", - "sucrase-node": "bin/sucrase-node" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/tailwind-merge": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.0.tgz", - "integrity": "sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/dcastil" - } - }, - "node_modules/tailwindcss": { - "version": "3.4.19", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", - "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@alloc/quick-lru": "^5.2.0", - "arg": "^5.0.2", - "chokidar": "^3.6.0", - "didyoumean": "^1.2.2", - "dlv": "^1.1.3", - "fast-glob": "^3.3.2", - "glob-parent": "^6.0.2", - "is-glob": "^4.0.3", - "jiti": "^1.21.7", - "lilconfig": "^3.1.3", - "micromatch": "^4.0.8", - "normalize-path": "^3.0.0", - "object-hash": "^3.0.0", - "picocolors": "^1.1.1", - "postcss": "^8.4.47", - "postcss-import": "^15.1.0", - "postcss-js": "^4.0.1", - "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", - "postcss-nested": "^6.2.0", - "postcss-selector-parser": "^6.1.2", - "resolve": "^1.22.8", - "sucrase": "^3.35.0" - }, - "bin": { - "tailwind": "lib/cli.js", - "tailwindcss": "lib/cli.js" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tailwindcss-animate": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz", - "integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "tailwindcss": ">=3.0.0 || insiders" - } - }, - "node_modules/text-table": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", - "dev": true, - "license": "MIT" - }, - "node_modules/thenify": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", - "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0" - } - }, - "node_modules/thenify-all": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", - "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", - "dev": true, - "license": "MIT", - "dependencies": { - "thenify": ">= 3.1.0 < 4" - }, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/ts-api-utils": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", - "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16" - }, - "peerDependencies": { - "typescript": ">=4.2.0" - } - }, - "node_modules/ts-interface-checker": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", - "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, - "license": "MIT" - }, - "node_modules/vite": { - "version": "5.4.21", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", - "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "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 - } - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true, - "license": "ISC" - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - } - } -} diff --git a/frontend/package.json b/frontend/package.json index e3fb4d6..0b3f928 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,10 +8,14 @@ "build": "tsc && vite build", "lint": "eslint . --max-warnings 0", "type-check": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "generate:api-types": "openapi-typescript ./openapi.json -o ./src/shared/api/generated.ts", "preview": "vite preview", - "validate:all": "pnpm install && pnpm lint && pnpm type-check" + "validate:all": "pnpm lint && pnpm type-check && pnpm test && pnpm build" }, "dependencies": { + "@tanstack/react-query": "^5.76.1", "react": "^18.2.0", "react-dom": "^18.2.0", "class-variance-authority": "^0.7.0", @@ -22,6 +26,8 @@ }, "devDependencies": { "@eslint/js": "^9.0.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.3.0", "@types/react": "^18.2.43", "@types/react-dom": "^18.2.17", "@typescript-eslint/eslint-plugin": "^8.0.0", @@ -32,11 +38,13 @@ "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-refresh": "^0.4.15", "globals": "^15.0.0", + "jsdom": "^26.1.0", + "openapi-typescript": "^7.8.0", "postcss": "^8.4.32", "tailwindcss": "^3.4.0", "tailwindcss-animate": "^1.0.7", "typescript": "^5.3.3", - "vite": "^5.0.8" + "vite": "^5.0.8", + "vitest": "^3.1.1" } } - diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 14d7b10..c38c314 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,20 +1 @@ -import { LiveEventStream } from '@/components/LiveEventStream' -import { InsightChart } from '@/components/InsightChart' - -function App() { - return ( -
-
-

TelemetryTaco

-

Lightweight telemetry tool

-
- -
- -
-
- ) -} - -export default App - +export { default } from '@/app/App' diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx new file mode 100644 index 0000000..547318e --- /dev/null +++ b/frontend/src/app/App.tsx @@ -0,0 +1,83 @@ +import { Suspense, lazy } from 'react' +import { LiveEventStreamCard } from '@/features/events/components/live-event-stream-card' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' + +const InsightChartCard = lazy(async () => { + const module = await import('@/features/insights/components/insight-chart-card') + return { default: module.InsightChartCard } +}) + +function InsightCardFallback() { + return ( + + + Event insights + Loading the analytics surface. + + +
+ Preparing chart module... +
+
+
+ ) +} + +function App() { + return ( +
+
+
+
+
+
+ Single-project MVP + Additive API compatibility + OpenAPI typed frontend +
+
+

TelemetryTaco

+

+ Lightweight telemetry with a clearer ingestion path and a faster dashboard loop. +

+

+ Recent events and minute-level insights share one contract surface now, so the UI stays + thin while the backend handles batching, idempotency, and retention. +

+
+
+ + + Current scope + Strong MVP posture + + +
+ Capture + Single + batch +
+
+ Frontend + React Query polling +
+
+ SDK + Queued batch sender +
+
+
+
+ +
+ }> + + + +
+
+
+ ) +} + +export default App diff --git a/frontend/src/app/providers.tsx b/frontend/src/app/providers.tsx new file mode 100644 index 0000000..64dfb00 --- /dev/null +++ b/frontend/src/app/providers.tsx @@ -0,0 +1,9 @@ +import type { PropsWithChildren } from 'react' +import { QueryClientProvider } from '@tanstack/react-query' +import { createQueryClient } from '@/app/query-client' + +const queryClient = createQueryClient() + +export function AppProviders({ children }: PropsWithChildren) { + return {children} +} diff --git a/frontend/src/app/query-client.ts b/frontend/src/app/query-client.ts new file mode 100644 index 0000000..58e54c5 --- /dev/null +++ b/frontend/src/app/query-client.ts @@ -0,0 +1,12 @@ +import { QueryClient } from '@tanstack/react-query' + +export function createQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { + retry: 1, + refetchOnWindowFocus: false, + }, + }, + }) +} diff --git a/frontend/src/components/InsightChart.tsx b/frontend/src/components/InsightChart.tsx deleted file mode 100644 index ee12047..0000000 --- a/frontend/src/components/InsightChart.tsx +++ /dev/null @@ -1,173 +0,0 @@ -import { useEffect, useState } from 'react' -import { - LineChart, - Line, - XAxis, - YAxis, - CartesianGrid, - Tooltip, - ResponsiveContainer, -} from 'recharts' -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' - -interface InsightDataPoint { - time: string - count: number -} - -// Use relative URL in development (Vite proxy) or explicit URL from env -const API_BASE_URL = import.meta.env.VITE_API_URL || (import.meta.env.DEV ? '' : 'http://localhost:8000') - -interface InsightChartProps { - lookbackMinutes?: number -} - -export function InsightChart({ lookbackMinutes = 60 }: InsightChartProps) { - const [data, setData] = useState([]) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - - useEffect(() => { - const fetchInsights = async () => { - try { - setLoading(true) - const url = `${API_BASE_URL}/api/insights?lookback_minutes=${lookbackMinutes}` - const response = await fetch(url, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - }) - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`) - } - const result = await response.json() - setData(result) - setError(null) - } catch (err) { - const errorMessage = err instanceof Error - ? err.message - : 'Failed to fetch insights' - const detailedError = err instanceof TypeError && err.message.includes('fetch') - ? new Error(`${errorMessage}. Is the backend server running on port 8000?`) - : new Error(errorMessage) - setError(detailedError) - } finally { - setLoading(false) - } - } - - fetchInsights() - - // Refresh every 10 seconds - const intervalId = setInterval(fetchInsights, 10000) - - return () => { - clearInterval(intervalId) - } - }, [lookbackMinutes]) - - // Custom dark mode tooltip - const CustomTooltip = ({ active, payload, label }: { - active?: boolean - payload?: Array<{ value: number; name: string }> - label?: string - }) => { - if (active && payload && payload.length > 0) { - return ( -
-

{`Time: ${label}`}

-

- {`Events: ${payload[0].value}`} -

-
- ) - } - return null - } - - if (error) { - return ( - - - Event Insights - - -
Error: {error.message}
-
-
- ) - } - - if (loading) { - return ( - - - Event Insights - - -
Loading insights...
-
-
- ) - } - - if (data.length === 0) { - return ( - - - Event Insights - - -
-
-

No data available

-

- Events will appear here once they are captured -

-
-
-
-
- ) - } - - return ( - - - - Event Insights ({lookbackMinutes} min lookback) - - - - - - - - - } /> - - - - - - ) -} - diff --git a/frontend/src/components/LiveEventStream.tsx b/frontend/src/components/LiveEventStream.tsx deleted file mode 100644 index 1e38115..0000000 --- a/frontend/src/components/LiveEventStream.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import { useState } from 'react' -import { useEventStream } from '@/hooks/useEventStream' -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' -import { Badge } from '@/components/ui/badge' - -export function LiveEventStream() { - const { events, loading, error } = useEventStream() - const [expandedIds, setExpandedIds] = useState>(new Set()) - - const toggleExpand = (id: number) => { - setExpandedIds((prev) => { - const next = new Set(prev) - if (next.has(id)) { - next.delete(id) - } else { - next.add(id) - } - return next - }) - } - - const formatTimestamp = (timestamp: string) => { - const date = new Date(timestamp) - const timeString = date.toLocaleTimeString('en-US', { - hour12: false, - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - }) - const milliseconds = date.getMilliseconds().toString().padStart(3, '0') - return `${timeString}.${milliseconds}` - } - - if (error) { - return ( - - - Live Event Stream - - -
Error: {error.message}
-
-
- ) - } - - return ( - - - Live Event Stream - - -
- {loading && events.length === 0 ? ( -
Loading events...
- ) : events.length === 0 ? ( -
No events yet. Start sending events to see them here.
- ) : ( -
- {events.map((event) => { - const isExpanded = expandedIds.has(event.id) - return ( -
toggleExpand(event.id)} - > -
-
- - [{formatTimestamp(event.timestamp)}] - - - {event.event_name} - - | - {event.distinct_id} - - {Object.keys(event.properties).length} props - -
- {isExpanded && ( -
-
Properties:
-
-                            {JSON.stringify(event.properties, null, 2)}
-                          
-
- UUID: {event.uuid} -
-
- )} -
-
- ) - })} -
- )} -
-
-
- ) -} - diff --git a/frontend/src/features/events/components/live-event-stream-card.test.tsx b/frontend/src/features/events/components/live-event-stream-card.test.tsx new file mode 100644 index 0000000..493f311 --- /dev/null +++ b/frontend/src/features/events/components/live-event-stream-card.test.tsx @@ -0,0 +1,48 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { screen } from '@testing-library/react' +import { LiveEventStreamCard } from '@/features/events/components/live-event-stream-card' +import { renderWithProviders } from '@/test/test-utils' + +function mockJsonResponse(body: unknown, status = 200) { + return Promise.resolve( + new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }), + ) +} + +describe('LiveEventStreamCard', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('renders the empty state when no events exist', async () => { + vi.spyOn(globalThis, 'fetch').mockImplementation(() => mockJsonResponse([])) + + renderWithProviders() + + expect(await screen.findByText('No events yet')).toBeInTheDocument() + }) + + it('renders fetched events', async () => { + vi.spyOn(globalThis, 'fetch').mockImplementation(() => + mockJsonResponse([ + { + id: 1, + uuid: '5bb31741-4f62-4fab-8fc4-45a8ee3a5487', + distinct_id: 'user-123', + event_name: 'page_view', + properties: { path: '/' }, + timestamp: '2026-03-13T08:00:00Z', + created_at: '2026-03-13T08:00:00Z', + }, + ]), + ) + + renderWithProviders() + + expect(await screen.findByText('page_view')).toBeInTheDocument() + expect(screen.getByText('user-123')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/features/events/components/live-event-stream-card.tsx b/frontend/src/features/events/components/live-event-stream-card.tsx new file mode 100644 index 0000000..c48edc9 --- /dev/null +++ b/frontend/src/features/events/components/live-event-stream-card.tsx @@ -0,0 +1,120 @@ +import { startTransition, useDeferredValue, useState } from 'react' +import { Badge } from '@/components/ui/badge' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { useEventsQuery } from '@/features/events/queries' +import { PanelMessage } from '@/shared/ui/panel-message' +import type { EventRecord } from '@/shared/api/types' + +interface LiveEventStreamCardProps { + limit: number +} + +const timestampFormatter = new Intl.DateTimeFormat('en-US', { + hour12: false, + hour: '2-digit', + minute: '2-digit', + second: '2-digit', +}) + +function formatTimestamp(timestamp: string) { + const date = new Date(timestamp) + return `${timestampFormatter.format(date)}.${date.getMilliseconds().toString().padStart(3, '0')}` +} + +function EventRow({ + event, + expanded, + onToggle, +}: { + event: EventRecord + expanded: boolean + onToggle: (id: number) => void +}) { + return ( + + ) +} + +export function LiveEventStreamCard({ limit }: LiveEventStreamCardProps) { + const { data = [], error, isLoading } = useEventsQuery(limit) + const deferredEvents = useDeferredValue(data) + const [expandedIds, setExpandedIds] = useState>(new Set()) + + function toggleExpand(id: number) { + startTransition(() => { + setExpandedIds((previous) => { + const next = new Set(previous) + if (next.has(id)) { + next.delete(id) + } else { + next.add(id) + } + return next + }) + }) + } + + return ( + + + Live event stream + Polling the last {limit} events every 2 seconds. + + + {error ? ( + + ) : isLoading && deferredEvents.length === 0 ? ( + + ) : deferredEvents.length === 0 ? ( + + ) : ( +
+ {deferredEvents.map((event) => ( + + ))} +
+ )} +
+
+ ) +} diff --git a/frontend/src/features/events/queries.ts b/frontend/src/features/events/queries.ts new file mode 100644 index 0000000..a0df1a9 --- /dev/null +++ b/frontend/src/features/events/queries.ts @@ -0,0 +1,30 @@ +import { useQuery } from '@tanstack/react-query' +import { apiFetch, ApiError } from '@/shared/api/client' +import type { EventRecord } from '@/shared/api/types' + +export const eventsQueryKey = ['events'] as const + +async function fetchEvents(limit: number) { + try { + return await apiFetch(`/api/events?limit=${limit}`) + } catch (error) { + if (error instanceof TypeError) { + throw new Error('Failed to fetch events. Is the backend server running on port 8000?') + } + + if (error instanceof ApiError) { + throw new Error(`Failed to fetch events (HTTP ${error.status}).`) + } + + throw error + } +} + +export function useEventsQuery(limit: number) { + return useQuery({ + queryKey: [...eventsQueryKey, limit], + queryFn: () => fetchEvents(limit), + refetchInterval: 2000, + staleTime: 1000, + }) +} diff --git a/frontend/src/features/insights/components/insight-chart-card.test.tsx b/frontend/src/features/insights/components/insight-chart-card.test.tsx new file mode 100644 index 0000000..cf168ab --- /dev/null +++ b/frontend/src/features/insights/components/insight-chart-card.test.tsx @@ -0,0 +1,37 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { screen } from '@testing-library/react' +import { InsightChartCard } from '@/features/insights/components/insight-chart-card' +import { renderWithProviders } from '@/test/test-utils' + +function mockJsonResponse(body: unknown, status = 200) { + return Promise.resolve( + new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }), + ) +} + +describe('InsightChartCard', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('renders an empty-state message', async () => { + vi.spyOn(globalThis, 'fetch').mockImplementation(() => mockJsonResponse([])) + + renderWithProviders() + + expect(await screen.findByText('No data available')).toBeInTheDocument() + }) + + it('renders an error message on request failure', async () => { + vi.spyOn(globalThis, 'fetch').mockImplementation(() => + Promise.resolve(new Response('oops', { status: 500 })), + ) + + renderWithProviders() + + expect(await screen.findByText('Insight query failed')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/features/insights/components/insight-chart-card.tsx b/frontend/src/features/insights/components/insight-chart-card.tsx new file mode 100644 index 0000000..414b839 --- /dev/null +++ b/frontend/src/features/insights/components/insight-chart-card.tsx @@ -0,0 +1,52 @@ +import { Suspense, lazy } from 'react' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { useInsightsQuery } from '@/features/insights/queries' +import { PanelMessage } from '@/shared/ui/panel-message' + +interface InsightChartCardProps { + lookbackMinutes: number +} + +const LazyInsightLineChart = lazy(async () => { + const module = await import('@/features/insights/components/insight-line-chart') + return { default: module.InsightLineChart } +}) + +export function InsightChartCard({ lookbackMinutes }: InsightChartCardProps) { + const { data = [], error, isLoading } = useInsightsQuery(lookbackMinutes) + + return ( + + + Event insights + Minute-level event counts over the last {lookbackMinutes} minutes. + + + {error ? ( + + ) : isLoading ? ( + + ) : data.length === 0 ? ( + + ) : ( + + } + > + + + )} + + + ) +} diff --git a/frontend/src/features/insights/components/insight-line-chart.tsx b/frontend/src/features/insights/components/insight-line-chart.tsx new file mode 100644 index 0000000..fd7ad83 --- /dev/null +++ b/frontend/src/features/insights/components/insight-line-chart.tsx @@ -0,0 +1,54 @@ +import { + CartesianGrid, + Line, + LineChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts' +import type { InsightPoint } from '@/shared/api/types' + +interface InsightLineChartProps { + data: InsightPoint[] +} + +export function InsightLineChart({ data }: InsightLineChartProps) { + return ( + + + + + + + + + + ) +} diff --git a/frontend/src/features/insights/queries.ts b/frontend/src/features/insights/queries.ts new file mode 100644 index 0000000..45d97e3 --- /dev/null +++ b/frontend/src/features/insights/queries.ts @@ -0,0 +1,30 @@ +import { useQuery } from '@tanstack/react-query' +import { apiFetch, ApiError } from '@/shared/api/client' +import type { InsightPoint } from '@/shared/api/types' + +export const insightsQueryKey = ['insights'] as const + +async function fetchInsights(lookbackMinutes: number) { + try { + return await apiFetch(`/api/insights?lookback_minutes=${lookbackMinutes}`) + } catch (error) { + if (error instanceof TypeError) { + throw new Error('Failed to fetch insights. Is the backend server running on port 8000?') + } + + if (error instanceof ApiError) { + throw new Error(`Failed to fetch insights (HTTP ${error.status}).`) + } + + throw error + } +} + +export function useInsightsQuery(lookbackMinutes: number) { + return useQuery({ + queryKey: [...insightsQueryKey, lookbackMinutes], + queryFn: () => fetchInsights(lookbackMinutes), + refetchInterval: 10000, + staleTime: 5000, + }) +} diff --git a/frontend/src/hooks/useEventStream.ts b/frontend/src/hooks/useEventStream.ts deleted file mode 100644 index 8036d6f..0000000 --- a/frontend/src/hooks/useEventStream.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { useState, useEffect } from 'react' - -/** - * Represents a JSON-serializable value. - * This type ensures type safety while allowing flexible event properties. - */ -type JsonValue = - | string - | number - | boolean - | null - | { [key: string]: JsonValue } - | JsonValue[] - -/** - * Event properties are a flexible JSON object that can contain - * any JSON-serializable values (strings, numbers, booleans, null, objects, arrays). - */ -interface EventProperties { - [key: string]: JsonValue -} - -export interface Event { - id: number - distinct_id: string - event_name: string - properties: EventProperties - timestamp: string - uuid: string - created_at: string -} - -// Use relative URL in development (Vite proxy) or explicit URL from env -const API_BASE_URL = import.meta.env.VITE_API_URL || (import.meta.env.DEV ? '' : 'http://localhost:8000') - -export function useEventStream() { - const [events, setEvents] = useState([]) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - - useEffect(() => { - const fetchEvents = async () => { - try { - const url = `${API_BASE_URL}/api/events` - const response = await fetch(url, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - }) - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`) - } - const data = await response.json() - setEvents(data) - setError(null) - } catch (err) { - const errorMessage = err instanceof Error - ? err.message - : 'Failed to fetch events' - const detailedError = err instanceof TypeError && err.message.includes('fetch') - ? new Error(`${errorMessage}. Is the backend server running on port 8000?`) - : new Error(errorMessage) - setError(detailedError) - } finally { - setLoading(false) - } - } - - // Initial fetch - fetchEvents() - - // Poll every 2 seconds - const intervalId = window.setInterval(fetchEvents, 2000) - - return () => { - clearInterval(intervalId) - } - }, []) - - return { events, loading, error } -} - diff --git a/frontend/src/index.css b/frontend/src/index.css index e1a8fc8..2132391 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -6,26 +6,26 @@ @layer base { :root { - --background: 210 10% 18%; /* #2d2d2d */ - --foreground: 0 0% 98%; - --card: 217 11% 20%; /* #1F2937 */ - --card-foreground: 0 0% 98%; - --popover: 217 11% 20%; - --popover-foreground: 0 0% 98%; - --primary: 15 100% 48%; /* #f54e00 */ - --primary-foreground: 0 0% 98%; - --secondary: 48 100% 50%; /* #ffcc00 */ - --secondary-foreground: 0 0% 9%; - --muted: 217 11% 20%; - --muted-foreground: 0 0% 64%; - --accent: 217 11% 20%; - --accent-foreground: 0 0% 98%; + --background: 220 28% 7%; + --foreground: 35 25% 95%; + --card: 224 22% 12%; + --card-foreground: 35 25% 95%; + --popover: 224 22% 12%; + --popover-foreground: 35 25% 95%; + --primary: 23 100% 58%; + --primary-foreground: 220 28% 7%; + --secondary: 42 95% 63%; + --secondary-foreground: 220 28% 7%; + --muted: 223 16% 18%; + --muted-foreground: 215 12% 68%; + --accent: 223 16% 18%; + --accent-foreground: 35 25% 95%; --destructive: 0 84% 60%; - --destructive-foreground: 0 0% 98%; - --border: 217 11% 30%; - --input: 217 11% 30%; - --ring: 15 100% 48%; - --radius: 0.5rem; + --destructive-foreground: 35 25% 95%; + --border: 224 12% 24%; + --input: 224 12% 24%; + --ring: 23 100% 58%; + --radius: 1rem; } } @@ -33,11 +33,17 @@ * { @apply border-border; } + + html { + color-scheme: dark; + } + body { @apply bg-background text-foreground; font-family: 'JetBrains Mono', 'Courier New', monospace; + min-height: 100vh; } - + .font-mono { font-family: 'JetBrains Mono', 'Courier New', monospace; } diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 4709601..acadb82 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,10 +1,13 @@ import { StrictMode } from 'react' import ReactDOM from 'react-dom/client' import App from './App.tsx' +import { AppProviders } from '@/app/providers' import './index.css' ReactDOM.createRoot(document.getElementById('root')!).render( - + + + , -) \ No newline at end of file +) diff --git a/frontend/src/shared/api/client.ts b/frontend/src/shared/api/client.ts new file mode 100644 index 0000000..7d70bab --- /dev/null +++ b/frontend/src/shared/api/client.ts @@ -0,0 +1,40 @@ +const API_BASE_URL = import.meta.env.VITE_API_URL || '' + +export class ApiError extends Error { + status: number + + constructor(message: string, status: number) { + super(message) + this.name = 'ApiError' + this.status = status + } +} + +export function buildApiUrl(path: string, searchParams?: URLSearchParams) { + const url = new URL(path, API_BASE_URL || window.location.origin) + if (searchParams) { + url.search = searchParams.toString() + } + + if (!API_BASE_URL) { + return `${url.pathname}${url.search}` + } + + return url.toString() +} + +export async function apiFetch(path: string, init?: RequestInit): Promise { + const response = await fetch(buildApiUrl(path), { + ...init, + headers: { + 'Content-Type': 'application/json', + ...init?.headers, + }, + }) + + if (!response.ok) { + throw new ApiError(`Request failed with status ${response.status}`, response.status) + } + + return (await response.json()) as T +} diff --git a/frontend/src/shared/api/generated.ts b/frontend/src/shared/api/generated.ts new file mode 100644 index 0000000..a724dd1 --- /dev/null +++ b/frontend/src/shared/api/generated.ts @@ -0,0 +1,338 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/api/capture": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Capture Event */ + post: operations["core_api_events_capture_event"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/capture/batch": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Capture Event Batch */ + post: operations["core_api_events_capture_event_batch"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/events": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List Events */ + get: operations["core_api_events_list_events"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/insights": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Event Insights */ + get: operations["core_api_events_get_event_insights"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/health/live": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Liveness */ + get: operations["core_api_events_liveness"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/health/ready": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Readiness */ + get: operations["core_api_events_readiness"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + /** StatusResponse */ + StatusResponse: { + /** + * Status + * @default ok + */ + status: string; + }; + /** EventCaptureSchema */ + EventCaptureSchema: { + /** Distinct Id */ + distinct_id: string; + /** Event Name */ + event_name: string; + /** Properties */ + properties?: { + [key: string]: unknown; + }; + /** Event Uuid */ + event_uuid?: string | null; + /** Sent At */ + sent_at?: string | null; + }; + /** BatchStatusResponse */ + BatchStatusResponse: { + /** + * Status + * @default ok + */ + status: string; + /** Accepted */ + accepted: number; + }; + /** EventBatchCaptureSchema */ + EventBatchCaptureSchema: { + /** Events */ + events: components["schemas"]["EventCaptureSchema"][]; + }; + /** EventResponseSchema */ + EventResponseSchema: { + /** Uuid */ + uuid: string; + /** ID */ + id?: number | null; + /** + * Distinct Id + * @description User ID + */ + distinct_id: string; + /** Event Name */ + event_name: string; + /** + * Properties + * @description Flexible JSONB field for event properties + */ + properties?: Record | null; + /** + * Timestamp + * Format: date-time + */ + timestamp?: string; + /** + * Created At + * Format: date-time + */ + created_at: string; + }; + /** InsightDataPoint */ + InsightDataPoint: { + /** Time */ + time: string; + /** Count */ + count: number; + }; + /** HealthStatusResponse */ + HealthStatusResponse: { + /** Status */ + status: string; + /** Database */ + database: string; + /** Cache */ + cache: string; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + core_api_events_capture_event: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["EventCaptureSchema"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["StatusResponse"]; + }; + }; + }; + }; + core_api_events_capture_event_batch: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["EventBatchCaptureSchema"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BatchStatusResponse"]; + }; + }; + }; + }; + core_api_events_list_events: { + parameters: { + query?: { + limit?: number; + before?: string | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EventResponseSchema"][]; + }; + }; + }; + }; + core_api_events_get_event_insights: { + parameters: { + query?: { + lookback_minutes?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["InsightDataPoint"][]; + }; + }; + }; + }; + core_api_events_liveness: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HealthStatusResponse"]; + }; + }; + }; + }; + core_api_events_readiness: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HealthStatusResponse"]; + }; + }; + }; + }; +} diff --git a/frontend/src/shared/api/types.ts b/frontend/src/shared/api/types.ts new file mode 100644 index 0000000..7e676b4 --- /dev/null +++ b/frontend/src/shared/api/types.ts @@ -0,0 +1,20 @@ +import type { components } from '@/shared/api/generated' + +export type JsonValue = + | string + | number + | boolean + | null + | JsonValue[] + | { [key: string]: JsonValue } + +type EventSchema = components['schemas']['EventResponseSchema'] + +export interface EventRecord extends Omit { + id: number + timestamp: string + properties: Record +} + +export type InsightPoint = components['schemas']['InsightDataPoint'] +export type HealthStatus = components['schemas']['HealthStatusResponse'] diff --git a/frontend/src/shared/ui/panel-message.tsx b/frontend/src/shared/ui/panel-message.tsx new file mode 100644 index 0000000..4df9960 --- /dev/null +++ b/frontend/src/shared/ui/panel-message.tsx @@ -0,0 +1,19 @@ +interface PanelMessageProps { + title: string + description: string + tone?: 'default' | 'error' +} + +const toneClassName = { + default: 'text-muted-foreground', + error: 'text-destructive', +} + +export function PanelMessage({ title, description, tone = 'default' }: PanelMessageProps) { + return ( +
+

{title}

+

{description}

+
+ ) +} diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts new file mode 100644 index 0000000..a9d0dd3 --- /dev/null +++ b/frontend/src/test/setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom/vitest' diff --git a/frontend/src/test/test-utils.tsx b/frontend/src/test/test-utils.tsx new file mode 100644 index 0000000..8b0b720 --- /dev/null +++ b/frontend/src/test/test-utils.tsx @@ -0,0 +1,20 @@ +import type { PropsWithChildren, ReactElement } from 'react' +import { render } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' + +function TestProviders({ children }: PropsWithChildren) { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + refetchOnWindowFocus: false, + }, + }, + }) + + return {children} +} + +export function renderWithProviders(ui: ReactElement) { + return render(ui, { wrapper: TestProviders }) +} diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts index ed77210..854a595 100644 --- a/frontend/src/vite-env.d.ts +++ b/frontend/src/vite-env.d.ts @@ -1,2 +1,2 @@ +/// /// - diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index b945b15..1e8515c 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -21,11 +21,11 @@ export default { background: "hsl(var(--background))", foreground: "hsl(var(--foreground))", primary: { - DEFAULT: "#f54e00", + DEFAULT: "hsl(var(--primary))", foreground: "hsl(var(--primary-foreground))", }, secondary: { - DEFAULT: "#ffcc00", + DEFAULT: "hsl(var(--secondary))", foreground: "hsl(var(--secondary-foreground))", }, destructive: { @@ -72,4 +72,3 @@ export default { }, plugins: [require("tailwindcss-animate")], } - diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index ac0c436..4383393 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -24,11 +24,17 @@ export default defineConfig({ }, }, }, + test: { + environment: 'jsdom', + setupFiles: './src/test/setup.ts', + css: true, + }, build: { rollupOptions: { output: { manualChunks: { 'react-vendor': ['react', 'react-dom'], + 'query-vendor': ['@tanstack/react-query'], 'recharts-vendor': ['recharts'], 'ui-vendor': ['class-variance-authority', 'clsx', 'tailwind-merge', 'lucide-react'], }, @@ -37,4 +43,3 @@ export default defineConfig({ chunkSizeWarningLimit: 600, }, }) - diff --git a/package.json b/package.json index 8e7043b..27b12aa 100644 --- a/package.json +++ b/package.json @@ -8,30 +8,34 @@ "start": "bash start.sh", "stop": "bash stop.sh", "dev:frontend": "pnpm --filter ./frontend dev", - "dev:backend": "cd backend && poetry run python manage.py runserver", - "dev:worker": "cd backend && poetry run celery -A core worker --loglevel=info", + "dev:backend": "cd backend && POETRY_CACHE_DIR=/tmp/pypoetry-cache poetry run python manage.py runserver", + "dev:worker": "cd backend && POETRY_CACHE_DIR=/tmp/pypoetry-cache poetry run celery -A core worker --loglevel=info", "restart:backend": "bash restart-backend.sh", "services": "docker-compose up -d db redis", "services:stop": "docker-compose stop db redis", - "migrate": "cd backend && poetry run python manage.py migrate", + "migrate": "cd backend && POETRY_CACHE_DIR=/tmp/pypoetry-cache poetry run python manage.py migrate", "seed": "bash seed.sh", "seed:clean": "bash seed.sh --clean", "build": "pnpm --filter ./frontend build", "build:frontend": "pnpm --filter ./frontend build", + "generate:api-types": "cd backend && POETRY_CACHE_DIR=/tmp/pypoetry-cache DJANGO_SETTINGS_MODULE=telemetry_taco.settings.test poetry run python manage.py export_openapi_schema ../frontend/openapi.json && cd ../frontend && pnpm generate:api-types", "lint": "pnpm --filter ./frontend lint", "lint:frontend": "pnpm --filter ./frontend lint", - "lint:backend": "cd backend && poetry run ruff check .", - "format:backend": "cd backend && poetry run ruff format .", - "format:check:backend": "cd backend && poetry run ruff format --check .", - "check:backend": "cd backend && poetry run python manage.py check", - "security:backend": "cd backend && poetry run bandit -r . -c bandit.yaml", - "security:backend:json": "cd backend && poetry run bandit -r . -f json -o bandit-report.json -c bandit.yaml", - "validate:backend": "pnpm lint:backend && pnpm format:check:backend && pnpm check:backend", - "validate:frontend": "pnpm --filter ./frontend lint && pnpm --filter ./frontend type-check", - "validate:all": "pnpm validate:backend && pnpm validate:frontend" + "lint:backend": "cd backend && POETRY_CACHE_DIR=/tmp/pypoetry-cache poetry run ruff check .", + "format:backend": "cd backend && POETRY_CACHE_DIR=/tmp/pypoetry-cache poetry run ruff format .", + "format:check:backend": "cd backend && POETRY_CACHE_DIR=/tmp/pypoetry-cache poetry run ruff format --check .", + "check:backend": "cd backend && POETRY_CACHE_DIR=/tmp/pypoetry-cache DJANGO_SETTINGS_MODULE=telemetry_taco.settings.test poetry run python manage.py check", + "test": "pnpm test:backend && pnpm test:frontend && pnpm test:sdk", + "test:backend": "cd backend && POETRY_CACHE_DIR=/tmp/pypoetry-cache poetry run pytest", + "test:frontend": "pnpm --filter ./frontend test", + "test:sdk": "cd sdk && PYTEST_PYTHON=$(POETRY_CACHE_DIR=/tmp/pypoetry-cache poetry -C ../backend run python -c 'import sys; print(sys.executable)') && \"$PYTEST_PYTHON\" -m pytest tests", + "security:backend": "cd backend && POETRY_CACHE_DIR=/tmp/pypoetry-cache poetry run bandit -r . -c bandit.yaml", + "security:backend:json": "cd backend && POETRY_CACHE_DIR=/tmp/pypoetry-cache poetry run bandit -r . -f json -o bandit-report.json -c bandit.yaml", + "validate:backend": "pnpm lint:backend && pnpm format:check:backend && pnpm check:backend && pnpm test:backend", + "validate:frontend": "pnpm generate:api-types && pnpm --filter ./frontend lint && pnpm --filter ./frontend type-check && pnpm --filter ./frontend test && pnpm --filter ./frontend build", + "validate:all": "pnpm validate:backend && pnpm validate:frontend && pnpm test:sdk" }, "devDependencies": { "typescript": "^5.3.3" } } - diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5a5c430..268a9d0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: frontend: dependencies: + '@tanstack/react-query': + specifier: ^5.76.1 + version: 5.90.21(react@18.3.1) class-variance-authority: specifier: ^0.7.0 version: 0.7.1 @@ -39,6 +42,12 @@ importers: '@eslint/js': specifier: ^9.0.0 version: 9.39.2 + '@testing-library/jest-dom': + specifier: ^6.6.3 + version: 6.9.1 + '@testing-library/react': + specifier: ^16.3.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/react': specifier: ^18.2.43 version: 18.3.27 @@ -69,6 +78,12 @@ importers: globals: specifier: ^15.0.0 version: 15.15.0 + jsdom: + specifier: ^26.1.0 + version: 26.1.0 + openapi-typescript: + specifier: ^7.8.0 + version: 7.13.0(typescript@5.9.3) postcss: specifier: ^8.4.32 version: 8.5.6 @@ -84,13 +99,22 @@ importers: vite: specifier: ^5.0.8 version: 5.4.21 + vitest: + specifier: ^3.1.1 + version: 3.2.4(jsdom@26.1.0) packages: + '@adobe/css-tools@4.4.4': + resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + '@babel/code-frame@7.27.1': resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} @@ -178,6 +202,34 @@ packages: resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} engines: {node: '>=6.9.0'} + '@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'} + '@esbuild/aix-ppc64@0.21.5': resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} engines: {node: '>=12'} @@ -398,6 +450,16 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@redocly/ajv@8.11.2': + resolution: {integrity: sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==} + + '@redocly/config@0.22.0': + resolution: {integrity: sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==} + + '@redocly/openapi-core@1.34.10': + resolution: {integrity: sha512-XCBR/9WHJ0cpezuunHMZjuFMl4KqUo7eiFwzrQrvm7lTXt0EBd3No8UY+9OyzXpDfreGEMMtxmaLZ+ksVw378g==} + engines: {node: '>=18.17.0', npm: '>=9.5.0'} + '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} @@ -511,6 +573,40 @@ packages: cpu: [x64] os: [win32] + '@tanstack/query-core@5.90.20': + resolution: {integrity: sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==} + + '@tanstack/react-query@5.90.21': + resolution: {integrity: sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==} + peerDependencies: + react: ^18 || ^19 + + '@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 + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -523,6 +619,9 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/d3-array@3.2.2': resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} @@ -550,6 +649,9 @@ packages: '@types/d3-timer@3.0.2': resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -632,6 +734,35 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitest/expect@3.2.4': + resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + + '@vitest/mocker@3.2.4': + resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.4': + resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + + '@vitest/runner@3.2.4': + resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + + '@vitest/snapshot@3.2.4': + resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + + '@vitest/spy@3.2.4': + resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + + '@vitest/utils@3.2.4': + resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -642,13 +773,29 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} @@ -662,6 +809,17 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + autoprefixer@10.4.23: resolution: {integrity: sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==} engines: {node: ^10 || ^12 || >=14} @@ -695,6 +853,10 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} @@ -706,10 +868,21 @@ packages: caniuse-lite@1.0.30001761: resolution: {integrity: sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g==} + 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'} + change-case@5.4.4: + resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==} + + 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'} @@ -728,6 +901,9 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + colorette@1.4.0: + resolution: {integrity: sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==} + commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} @@ -742,11 +918,18 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} hasBin: true + 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==} @@ -794,6 +977,10 @@ packages: resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} engines: {node: '>=12'} + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -806,21 +993,45 @@ packages: decimal.js-light@2.5.1: resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dom-helpers@5.2.1: resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} electron-to-chromium@1.5.267: resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + esbuild@0.21.5: resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} engines: {node: '>=12'} @@ -883,6 +1094,9 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -890,6 +1104,10 @@ packages: eventemitter3@4.0.7: resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -977,6 +1195,22 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + + 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.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -993,6 +1227,14 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + index-to-position@1.2.0: + resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==} + engines: {node: '>=18'} + internmap@2.0.3: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} @@ -1017,6 +1259,9 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -1024,13 +1269,29 @@ packages: resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} hasBin: true + js-levenshtein@1.1.6: + resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==} + engines: {node: '>=0.10.0'} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + js-yaml@4.1.1: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + jsdom@26.1.0: + resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -1042,6 +1303,9 @@ packages: json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -1078,6 +1342,12 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + 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==} @@ -1086,6 +1356,13 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 + 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==} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -1094,9 +1371,17 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + minimatch@9.0.5: resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} engines: {node: '>=16 || 14 >=14.17'} @@ -1122,6 +1407,9 @@ packages: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} + nwsapi@2.2.23: + resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -1130,6 +1418,12 @@ packages: resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} engines: {node: '>= 6'} + openapi-typescript@7.13.0: + resolution: {integrity: sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==} + hasBin: true + peerDependencies: + typescript: ^5.x + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -1146,6 +1440,13 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse-json@8.3.0: + resolution: {integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==} + engines: {node: '>=18'} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -1157,6 +1458,13 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + 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'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1176,6 +1484,10 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} + pluralize@8.0.0: + resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} + engines: {node: '>=4'} + postcss-import@15.1.0: resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} engines: {node: '>=14.0.0'} @@ -1227,6 +1539,10 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + 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} + prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} @@ -1245,6 +1561,9 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} @@ -1285,6 +1604,14 @@ packages: react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -1303,9 +1630,19 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} @@ -1326,19 +1663,39 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + sucrase@3.35.1: resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} engines: {node: '>=16 || 14 >=14.17'} hasBin: true + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -1347,6 +1704,9 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tailwind-merge@2.6.0: resolution: {integrity: sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==} @@ -1370,14 +1730,47 @@ packages: tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + 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.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} 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@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + 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'} + 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'} + ts-api-utils@2.1.0: resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==} engines: {node: '>=18.12'} @@ -1391,6 +1784,10 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -1402,6 +1799,9 @@ packages: peerDependencies: browserslist: '>= 4.21.0' + uri-js-replace@1.0.1: + resolution: {integrity: sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==} + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -1411,6 +1811,11 @@ packages: victory-vendor@36.9.2: resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==} + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + vite@5.4.21: resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} engines: {node: ^18.0.0 || >=20.0.0} @@ -1442,26 +1847,116 @@ packages: terser: optional: true + vitest@3.2.4: + resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.4 + '@vitest/ui': 3.2.4 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + 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'} + + 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 + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + ws@8.19.0: + resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} + 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==} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yaml-ast-parser@0.0.43: + resolution: {integrity: sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} snapshots: + '@adobe/css-tools@4.4.4': {} + '@alloc/quick-lru@5.2.0': {} + '@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.27.1': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -1483,7 +1978,7 @@ snapshots: '@babel/types': 7.28.5 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -1567,7 +2062,7 @@ snapshots: '@babel/parser': 7.28.5 '@babel/template': 7.27.2 '@babel/types': 7.28.5 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -1576,6 +2071,26 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@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': {} + '@esbuild/aix-ppc64@0.21.5': optional: true @@ -1655,7 +2170,7 @@ snapshots: '@eslint/config-array@0.21.1': dependencies: '@eslint/object-schema': 2.1.7 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) minimatch: 3.1.2 transitivePeerDependencies: - supports-color @@ -1671,7 +2186,7 @@ snapshots: '@eslint/eslintrc@3.3.3': dependencies: ajv: 6.12.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 @@ -1733,6 +2248,29 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.19.1 + '@redocly/ajv@8.11.2': + dependencies: + fast-deep-equal: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + uri-js-replace: 1.0.1 + + '@redocly/config@0.22.0': {} + + '@redocly/openapi-core@1.34.10(supports-color@10.2.2)': + dependencies: + '@redocly/ajv': 8.11.2 + '@redocly/config': 0.22.0 + colorette: 1.4.0 + https-proxy-agent: 7.0.6(supports-color@10.2.2) + js-levenshtein: 1.1.6 + js-yaml: 4.1.1 + minimatch: 5.1.9 + pluralize: 8.0.0 + yaml-ast-parser: 0.0.43 + transitivePeerDependencies: + - supports-color + '@rolldown/pluginutils@1.0.0-beta.27': {} '@rollup/rollup-android-arm-eabi@4.53.5': @@ -1801,6 +2339,45 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.53.5': optional: true + '@tanstack/query-core@5.90.20': {} + + '@tanstack/react-query@5.90.21(react@18.3.1)': + dependencies: + '@tanstack/query-core': 5.90.20 + react: 18.3.1 + + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/runtime': 7.28.4 + '@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@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.28.4 + '@testing-library/dom': 10.4.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + '@types/react-dom': 18.3.7(@types/react@18.3.27) + + '@types/aria-query@5.0.4': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.28.5 @@ -1822,6 +2399,11 @@ snapshots: dependencies: '@babel/types': 7.28.5 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/d3-array@3.2.2': {} '@types/d3-color@3.1.3': {} @@ -1846,6 +2428,8 @@ snapshots: '@types/d3-timer@3.0.2': {} + '@types/deep-eql@4.0.2': {} + '@types/estree@1.0.8': {} '@types/json-schema@7.0.15': {} @@ -1883,7 +2467,7 @@ snapshots: '@typescript-eslint/types': 8.50.0 '@typescript-eslint/typescript-estree': 8.50.0(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.50.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) eslint: 9.39.2(jiti@1.21.7) typescript: 5.9.3 transitivePeerDependencies: @@ -1893,7 +2477,7 @@ snapshots: dependencies: '@typescript-eslint/tsconfig-utils': 8.50.0(typescript@5.9.3) '@typescript-eslint/types': 8.50.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -1912,7 +2496,7 @@ snapshots: '@typescript-eslint/types': 8.50.0 '@typescript-eslint/typescript-estree': 8.50.0(typescript@5.9.3) '@typescript-eslint/utils': 8.50.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) eslint: 9.39.2(jiti@1.21.7) ts-api-utils: 2.1.0(typescript@5.9.3) typescript: 5.9.3 @@ -1927,7 +2511,7 @@ snapshots: '@typescript-eslint/tsconfig-utils': 8.50.0(typescript@5.9.3) '@typescript-eslint/types': 8.50.0 '@typescript-eslint/visitor-keys': 8.50.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) minimatch: 9.0.5 semver: 7.7.3 tinyglobby: 0.2.15 @@ -1964,12 +2548,56 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/expect@3.2.4': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.4(vite@5.4.21)': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21 + + '@vitest/pretty-format@3.2.4': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.4': + dependencies: + '@vitest/utils': 3.2.4 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.4': + dependencies: + tinyspy: 4.0.4 + + '@vitest/utils@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + acorn-jsx@5.3.2(acorn@8.15.0): dependencies: acorn: 8.15.0 acorn@8.15.0: {} + agent-base@7.1.4: {} + ajv@6.12.6: dependencies: fast-deep-equal: 3.1.3 @@ -1977,10 +2605,16 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ansi-colors@4.1.3: {} + + ansi-regex@5.0.1: {} + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + any-promise@1.3.0: {} anymatch@3.1.3: @@ -1992,6 +2626,14 @@ snapshots: argparse@2.0.1: {} + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + + assertion-error@2.0.1: {} + autoprefixer@10.4.23(postcss@8.5.6): dependencies: browserslist: 4.28.1 @@ -2028,17 +2670,31 @@ snapshots: node-releases: 2.0.27 update-browserslist-db: 1.2.3(browserslist@4.28.1) + cac@6.7.14: {} + callsites@3.1.0: {} camelcase-css@2.0.1: {} caniuse-lite@1.0.30001761: {} + 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 + change-case@5.4.4: {} + + check-error@2.1.3: {} + chokidar@3.6.0: dependencies: anymatch: 3.1.3 @@ -2063,6 +2719,8 @@ snapshots: color-name@1.1.4: {} + colorette@1.4.0: {} + commander@4.1.1: {} concat-map@0.0.1: {} @@ -2075,8 +2733,15 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css.escape@1.5.1: {} + cssesc@3.0.0: {} + cssstyle@4.6.0: + dependencies: + '@asamuzakjp/css-color': 3.2.0 + rrweb-cssom: 0.8.0 + csstype@3.2.3: {} d3-array@3.2.4: @@ -2117,18 +2782,35 @@ snapshots: d3-timer@3.0.1: {} - debug@4.4.3: + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + + debug@4.4.3(supports-color@10.2.2): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 10.2.2 decimal.js-light@2.5.1: {} + decimal.js@10.6.0: {} + + deep-eql@5.0.2: {} + deep-is@0.1.4: {} + dequal@2.0.3: {} + didyoumean@1.2.2: {} dlv@1.1.3: {} + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + dom-helpers@5.2.1: dependencies: '@babel/runtime': 7.28.4 @@ -2136,6 +2818,10 @@ snapshots: electron-to-chromium@1.5.267: {} + entities@6.0.1: {} + + es-module-lexer@1.7.0: {} + esbuild@0.21.5: optionalDependencies: '@esbuild/aix-ppc64': 0.21.5 @@ -2200,7 +2886,7 @@ snapshots: ajv: 6.12.6 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) escape-string-regexp: 4.0.0 eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 @@ -2240,10 +2926,16 @@ snapshots: estraverse@5.3.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + esutils@2.0.3: {} eventemitter3@4.0.7: {} + expect-type@1.3.0: {} + fast-deep-equal@3.1.3: {} fast-equals@5.4.0: {} @@ -2315,6 +3007,28 @@ snapshots: dependencies: function-bind: 1.1.2 + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6(supports-color@10.2.2): + dependencies: + agent-base: 7.1.4 + debug: 4.4.3(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + ignore@5.3.2: {} ignore@7.0.5: {} @@ -2326,6 +3040,10 @@ snapshots: imurmurhash@0.1.4: {} + indent-string@4.0.0: {} + + index-to-position@1.2.0: {} + internmap@2.0.3: {} is-binary-path@2.1.0: @@ -2344,22 +3062,57 @@ snapshots: is-number@7.0.0: {} + is-potential-custom-element-name@1.0.1: {} + isexe@2.0.0: {} jiti@1.21.7: {} + js-levenshtein@1.1.6: {} + js-tokens@4.0.0: {} + js-tokens@9.0.1: {} + js-yaml@4.1.1: dependencies: argparse: 2.0.1 + jsdom@26.1.0: + dependencies: + cssstyle: 4.6.0 + data-urls: 5.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6(supports-color@10.2.2) + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.23 + parse5: 7.3.0 + rrweb-cssom: 0.8.0 + 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.19.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + jsesc@3.1.0: {} json-buffer@3.0.1: {} json-schema-traverse@0.4.1: {} + json-schema-traverse@1.0.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} json5@2.2.3: {} @@ -2389,6 +3142,10 @@ snapshots: dependencies: js-tokens: 4.0.0 + loupe@3.2.1: {} + + lru-cache@10.4.3: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -2397,6 +3154,12 @@ snapshots: dependencies: react: 18.3.1 + lz-string@1.5.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + merge2@1.4.1: {} micromatch@4.0.8: @@ -2404,10 +3167,16 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 + min-indent@1.0.1: {} + minimatch@3.1.2: dependencies: brace-expansion: 1.1.12 + minimatch@5.1.9: + dependencies: + brace-expansion: 2.0.2 + minimatch@9.0.5: dependencies: brace-expansion: 2.0.2 @@ -2428,10 +3197,22 @@ snapshots: normalize-path@3.0.0: {} + nwsapi@2.2.23: {} + object-assign@4.1.1: {} object-hash@3.0.0: {} + openapi-typescript@7.13.0(typescript@5.9.3): + dependencies: + '@redocly/openapi-core': 1.34.10(supports-color@10.2.2) + ansi-colors: 4.1.3 + change-case: 5.4.4 + parse-json: 8.3.0 + supports-color: 10.2.2 + typescript: 5.9.3 + yargs-parser: 21.1.1 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -2453,12 +3234,26 @@ snapshots: dependencies: callsites: 3.1.0 + parse-json@8.3.0: + dependencies: + '@babel/code-frame': 7.27.1 + index-to-position: 1.2.0 + type-fest: 4.41.0 + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + path-exists@4.0.0: {} path-key@3.1.1: {} path-parse@1.0.7: {} + pathe@2.0.3: {} + + pathval@2.0.1: {} + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -2469,6 +3264,8 @@ snapshots: pirates@4.0.7: {} + pluralize@8.0.0: {} + postcss-import@15.1.0(postcss@8.5.6): dependencies: postcss: 8.5.6 @@ -2508,6 +3305,12 @@ snapshots: prelude-ls@1.2.1: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + prop-types@15.8.1: dependencies: loose-envify: 1.4.0 @@ -2526,6 +3329,8 @@ snapshots: react-is@16.13.1: {} + react-is@17.0.2: {} + react-is@18.3.1: {} react-refresh@0.17.0: {} @@ -2576,6 +3381,13 @@ snapshots: tiny-invariant: 1.3.3 victory-vendor: 36.9.2 + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + + require-from-string@2.0.2: {} + resolve-from@4.0.0: {} resolve@1.22.11: @@ -2614,10 +3426,18 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.53.5 fsevents: 2.3.3 + rrweb-cssom@0.8.0: {} + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 + safer-buffer@2.1.2: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.23.2: dependencies: loose-envify: 1.4.0 @@ -2632,10 +3452,24 @@ snapshots: shebang-regex@3.0.0: {} + siginfo@2.0.0: {} + source-map-js@1.2.1: {} + stackback@0.0.2: {} + + std-env@3.10.0: {} + + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + strip-json-comments@3.1.1: {} + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + sucrase@3.35.1: dependencies: '@jridgewell/gen-mapping': 0.3.13 @@ -2646,12 +3480,16 @@ snapshots: tinyglobby: 0.2.15 ts-interface-checker: 0.1.13 + supports-color@10.2.2: {} + supports-color@7.2.0: dependencies: has-flag: 4.0.0 supports-preserve-symlinks-flag@1.0.0: {} + symbol-tree@3.2.4: {} + tailwind-merge@2.6.0: {} tailwindcss-animate@1.0.7(tailwindcss@3.4.19): @@ -2696,15 +3534,39 @@ snapshots: tiny-invariant@1.3.3: {} + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + + tldts-core@6.1.86: {} + + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 + tough-cookie@5.1.2: + dependencies: + tldts: 6.1.86 + + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + ts-api-utils@2.1.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -2715,6 +3577,8 @@ snapshots: dependencies: prelude-ls: 1.2.1 + type-fest@4.41.0: {} + typescript@5.9.3: {} update-browserslist-db@1.2.3(browserslist@4.28.1): @@ -2723,6 +3587,8 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + uri-js-replace@1.0.1: {} + uri-js@4.4.1: dependencies: punycode: 2.3.1 @@ -2746,6 +3612,24 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 + vite-node@3.2.4: + dependencies: + cac: 6.7.14 + debug: 4.4.3(supports-color@10.2.2) + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 5.4.21 + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + vite@5.4.21: dependencies: esbuild: 0.21.5 @@ -2754,12 +3638,82 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + vitest@3.2.4(jsdom@26.1.0): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@5.4.21) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3(supports-color@10.2.2) + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 5.4.21 + vite-node: 3.2.4 + why-is-node-running: 2.3.0 + optionalDependencies: + jsdom: 26.1.0 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@7.0.0: {} + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + which@2.0.2: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + word-wrap@1.2.5: {} + ws@8.19.0: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + yallist@3.1.1: {} + yaml-ast-parser@0.0.43: {} + + yargs-parser@21.1.1: {} + yocto-queue@0.1.0: {} diff --git a/restart-backend.sh b/restart-backend.sh index c6549bb..fcc8dcd 100755 --- a/restart-backend.sh +++ b/restart-backend.sh @@ -10,6 +10,7 @@ GREEN='\033[0;32m' YELLOW='\033[1;33m' RED='\033[0;31m' NC='\033[0m' # No Color +POETRY_CACHE_DIR="${POETRY_CACHE_DIR:-/tmp/pypoetry-cache}" echo -e "${YELLOW}๐Ÿ”„ Restarting Django backend server...${NC}\n" @@ -28,7 +29,7 @@ fi # Start backend in background echo -e "${YELLOW} Starting new backend server...${NC}" cd backend -poetry run python manage.py runserver > ../.backend.log 2>&1 & +POETRY_CACHE_DIR="${POETRY_CACHE_DIR}" poetry run python manage.py runserver > ../.backend.log 2>&1 & BACKEND_PID=$! echo $BACKEND_PID > ../.backend.pid cd .. diff --git a/sdk/pyproject.toml b/sdk/pyproject.toml new file mode 100644 index 0000000..4fc24f7 --- /dev/null +++ b/sdk/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "telemetry-taco-sdk" +version = "0.1.0" +description = "Python SDK for TelemetryTaco." +requires-python = ">=3.11" +dependencies = [] + +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[tool.pytest.ini_options] +pythonpath = ["."] diff --git a/sdk/telemetry_taco.py b/sdk/telemetry_taco.py deleted file mode 100644 index 8a9ea3f..0000000 --- a/sdk/telemetry_taco.py +++ /dev/null @@ -1,299 +0,0 @@ -import threading -import json -import logging -import time -import urllib.request -import urllib.error -from typing import Any - -# Create a logger for the SDK -# Users can configure this logger using: logging.getLogger('telemetry_taco') -logger = logging.getLogger('telemetry_taco') - - -class TelemetryTaco: - """ - TelemetryTaco SDK for capturing events. - - This SDK sends events to the TelemetryTaco backend in a non-blocking manner - using background threads. Threads are daemon threads to prevent the program - from hanging if network operations get stuck. Use flush() or the context manager - to ensure all events are sent before program exit. - - Logging: - The SDK uses Python's logging module. Configure the logger using: - - ```python - import logging - logging.getLogger('telemetry_taco').setLevel(logging.WARNING) - ``` - - Or configure logging globally: - - ```python - import logging - logging.basicConfig(level=logging.INFO) - ``` - """ - - def __init__(self, base_url: str = "http://localhost:8000"): - """ - Initialize the TelemetryTaco client. - - Args: - base_url: Base URL of the TelemetryTaco backend (default: http://localhost:8000) - """ - self.base_url = base_url.rstrip('/') - self.capture_url = f"{self.base_url}/api/capture" - self._active_threads: list[threading.Thread] = [] - self._threads_lock = threading.Lock() - self._flush_timeout = 5.0 # Default timeout for flush operations - - def capture( - self, - distinct_id: str, - event_name: str, - properties: dict[str, Any] | None = None - ) -> None: - """ - Capture an event in a background thread. - - This method runs the HTTP POST request in a separate thread, ensuring - it doesn't block the main application thread. The thread is a daemon thread - to prevent program hanging. Use flush() or the context manager to ensure - events are sent before program exit. - - Args: - distinct_id: Unique identifier for the user/entity - event_name: Name of the event being captured - properties: Optional dictionary of event properties (default: {}) - """ - if properties is None: - properties = {} - - # Create a daemon thread to handle the HTTP request - # Daemon threads prevent the program from hanging if network operations get stuck - # Use flush() or context manager to ensure events are sent before program exit - thread = threading.Thread( - target=self._send_event_with_cleanup, - args=(distinct_id, event_name, properties), - daemon=True # Daemon to prevent program hanging on exit - ) - - with self._threads_lock: - self._active_threads.append(thread) - - thread.start() - - def _send_event_with_cleanup( - self, - distinct_id: str, - event_name: str, - properties: dict[str, Any] - ) -> None: - """ - Wrapper method that sends the event and removes thread from active list. - - Args: - distinct_id: Unique identifier for the user/entity - event_name: Name of the event being captured - properties: Dictionary of event properties - """ - try: - self._send_event(distinct_id, event_name, properties) - finally: - # Remove this thread from active threads list - current_thread = threading.current_thread() - with self._threads_lock: - if current_thread in self._active_threads: - self._active_threads.remove(current_thread) - - def _send_event( - self, - distinct_id: str, - event_name: str, - properties: dict[str, Any] - ) -> None: - """ - Internal method to send the event via HTTP POST. - - This method runs in a background thread and handles the actual - HTTP request to the backend. - - Args: - distinct_id: Unique identifier for the user/entity - event_name: Name of the event being captured - properties: Dictionary of event properties - """ - payload = { - "distinct_id": distinct_id, - "event_name": event_name, - "properties": properties - } - - try: - # Serialize payload to JSON - json_data = json.dumps(payload).encode('utf-8') - - # Create HTTP request - req = urllib.request.Request( - self.capture_url, - data=json_data, - headers={ - 'Content-Type': 'application/json', - 'Content-Length': str(len(json_data)) - }, - method='POST' - ) - - # Send request (non-blocking in this thread) - with urllib.request.urlopen(req, timeout=5) as response: - # Read response to ensure request completes - response.read() - - except urllib.error.HTTPError as e: - logger.error( - "HTTP error capturing event: %s - %s", - e.code, - e.reason, - extra={ - 'event_name': event_name, - 'distinct_id': distinct_id, - 'status_code': e.code - } - ) - except urllib.error.URLError as e: - logger.warning( - "Network error capturing event: %s", - e.reason, - extra={ - 'event_name': event_name, - 'distinct_id': distinct_id - }, - exc_info=True - ) - except Exception as e: - logger.error( - "Unexpected error capturing event: %s", - e, - extra={ - 'event_name': event_name, - 'distinct_id': distinct_id - }, - exc_info=True - ) - - def flush(self, timeout: float | None = None) -> None: - """ - Wait for all pending event threads to complete. - - This method blocks until all active background threads have finished - sending their events. Use this before program exit to ensure no data loss. - - Args: - timeout: Maximum total time to wait in seconds. - Defaults to 5.0 seconds if None. Set to 0 for no timeout. - This is a total timeout across all threads, not per thread. - - Raises: - TimeoutError: If timeout is exceeded and threads are still active - """ - # Use default timeout if not specified - if timeout is None: - timeout = self._flush_timeout - - # Make a copy of active threads to avoid modification during iteration - with self._threads_lock: - threads_to_wait = list(self._active_threads) - - if not threads_to_wait: - return # No threads to wait for - - # Track start time for total timeout calculation (only when timeout > 0) - start_time: float | None = None - if timeout > 0: - start_time = time.time() - - for thread in threads_to_wait: - # Calculate remaining timeout for this thread - if timeout > 0: - # When timeout > 0, start_time is guaranteed to be set above (line 215) - # start_time is used here, so it's not unused - elapsed = time.time() - start_time - remaining_timeout = timeout - elapsed - - # If we've already exceeded the total timeout, raise immediately - if remaining_timeout <= 0: - with self._threads_lock: - remaining_count = len(self._active_threads) - raise TimeoutError( - f"Total timeout of {timeout}s exceeded. " - f"{remaining_count} threads still active." - ) - else: - # timeout == 0 means no timeout - remaining_timeout = None - - # Join with remaining timeout (or None if no timeout specified) - thread.join(timeout=remaining_timeout) - - if thread.is_alive(): - # Count remaining active threads - with self._threads_lock: - remaining_count = len(self._active_threads) - raise TimeoutError( - f"Timeout waiting for event thread to complete. " - f"{remaining_count} threads still active." - ) - - def __enter__(self) -> "TelemetryTaco": - """Context manager entry.""" - return self - - def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: - """Context manager exit - ensures all events are sent before exit.""" - self.flush() - - -if __name__ == '__main__': - # Example usage - Method 1: Using flush() explicitly - client = TelemetryTaco() - - # Capture a simple event - client.capture( - distinct_id="user_123", - event_name="button_clicked", - properties={ - "button_name": "signup", - "page": "homepage" - } - ) - - # Capture another event with different properties - client.capture( - distinct_id="user_456", - event_name="page_view", - properties={ - "page_url": "/dashboard", - "referrer": "google.com" - } - ) - - # The capture calls are non-blocking, so this will print immediately - print("Events sent in background threads!") - - # Wait for all events to be sent before exit (prevents data loss) - client.flush() - print("All events sent successfully!") - - # Example usage - Method 2: Using context manager (recommended) - print("\n--- Using context manager ---") - with TelemetryTaco() as client2: - client2.capture( - distinct_id="user_789", - event_name="test_event", - properties={"test": True} - ) - print("Event queued, will be sent before context exit") - # All events are automatically flushed when exiting the context - print("Context exited, all events sent!") diff --git a/sdk/telemetry_taco/__init__.py b/sdk/telemetry_taco/__init__.py new file mode 100644 index 0000000..62f87ef --- /dev/null +++ b/sdk/telemetry_taco/__init__.py @@ -0,0 +1,3 @@ +from .client import TelemetryTaco + +__all__ = ["TelemetryTaco"] diff --git a/sdk/telemetry_taco/client.py b/sdk/telemetry_taco/client.py new file mode 100644 index 0000000..cef4a49 --- /dev/null +++ b/sdk/telemetry_taco/client.py @@ -0,0 +1,212 @@ +import json +import logging +import queue +import threading +import time +import urllib.error +import urllib.request +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any, Literal +from uuid import uuid4 + +logger = logging.getLogger("telemetry_taco") + +QueueFullPolicy = Literal["block", "drop_newest", "drop_oldest"] +_STOP = object() + + +@dataclass(frozen=True) +class QueuedEvent: + distinct_id: str + event_name: str + properties: dict[str, Any] + event_uuid: str + sent_at: str + + def as_dict(self) -> dict[str, Any]: + return { + "distinct_id": self.distinct_id, + "event_name": self.event_name, + "properties": self.properties, + "event_uuid": self.event_uuid, + "sent_at": self.sent_at, + } + + +class TelemetryTaco: + def __init__( + self, + base_url: str = "http://localhost:8000", + *, + batch_size: int = 50, + flush_interval: float = 1.0, + max_queue_size: int = 1000, + request_timeout: float = 5.0, + max_retries: int = 2, + queue_full_policy: QueueFullPolicy = "drop_newest", + _start_worker: bool = True, + ): + self.base_url = base_url.rstrip("/") + self.batch_url = f"{self.base_url}/api/capture/batch" + self.batch_size = batch_size + self.flush_interval = flush_interval + self.request_timeout = request_timeout + self.max_retries = max_retries + self.queue_full_policy = queue_full_policy + + self._queue: queue.Queue[QueuedEvent | object] = queue.Queue(maxsize=max_queue_size) + self._flush_requested = threading.Event() + self._closed = False + self._worker = None + if _start_worker: + self._worker = threading.Thread( + target=self._run_worker, + name="telemetry-taco-worker", + daemon=True, + ) + self._worker.start() + + def capture( + self, + distinct_id: str, + event_name: str, + properties: dict[str, Any] | None = None, + ) -> None: + if self._closed: + raise RuntimeError("TelemetryTaco client is closed") + + payload = QueuedEvent( + distinct_id=distinct_id, + event_name=event_name, + properties=properties or {}, + event_uuid=str(uuid4()), + sent_at=datetime.now(timezone.utc).isoformat(), + ) + self._enqueue(payload) + + def flush(self, timeout: float | None = None) -> None: + deadline = None if timeout in (None, 0) else time.monotonic() + timeout + self._flush_requested.set() + + while self._queue.unfinished_tasks: + if deadline is not None and time.monotonic() >= deadline: + raise TimeoutError( + f"Timeout waiting for {self._queue.unfinished_tasks} event(s) to flush." + ) + time.sleep(0.05) + + self._flush_requested.clear() + + def close(self, timeout: float | None = None) -> None: + if self._closed: + return + + self.flush(timeout=timeout) + self._closed = True + self._queue.put(_STOP) + if self._worker is not None: + self._worker.join(timeout=timeout) + + def __enter__(self) -> "TelemetryTaco": + return self + + def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: + self.close() + + def _enqueue(self, payload: QueuedEvent) -> None: + if self.queue_full_policy == "block": + self._queue.put(payload, timeout=self.request_timeout) + return + + try: + self._queue.put_nowait(payload) + except queue.Full: + if self.queue_full_policy == "drop_oldest": + dropped = self._queue.get_nowait() + if dropped is not _STOP: + self._queue.task_done() + self._queue.put_nowait(payload) + logger.warning("TelemetryTaco queue full; dropped oldest event.") + return + + logger.warning("TelemetryTaco queue full; dropped newest event.") + + def _run_worker(self) -> None: + batch: list[QueuedEvent] = [] + last_flush = time.monotonic() + + while True: + try: + item = self._queue.get(timeout=0.1) + except queue.Empty: + item = None + + if item is _STOP: + self._queue.task_done() + self._flush_batch(batch) + return + + if isinstance(item, QueuedEvent): + batch.append(item) + + should_flush = ( + len(batch) >= self.batch_size + or (batch and time.monotonic() - last_flush >= self.flush_interval) + or (batch and self._flush_requested.is_set()) + ) + + if should_flush: + self._flush_batch(batch) + batch = [] + last_flush = time.monotonic() + + def _flush_batch(self, batch: list[QueuedEvent]) -> None: + if not batch: + return + + try: + self._send_batch(batch) + finally: + for _ in batch: + self._queue.task_done() + + def _send_batch(self, batch: list[QueuedEvent]) -> None: + payload = {"events": [event.as_dict() for event in batch]} + body = json.dumps(payload).encode("utf-8") + request = urllib.request.Request( + self.batch_url, + data=body, + headers={ + "Content-Type": "application/json", + "Content-Length": str(len(body)), + }, + method="POST", + ) + + for attempt in range(self.max_retries + 1): + try: + with urllib.request.urlopen(request, timeout=self.request_timeout) as response: + response.read() + return + except urllib.error.HTTPError as exc: + if 400 <= exc.code < 500: + logger.error( + "TelemetryTaco rejected event batch: %s %s", + exc.code, + exc.reason, + ) + return + if attempt >= self.max_retries: + logger.error( + "TelemetryTaco server error after retries: %s %s", + exc.code, + exc.reason, + ) + return + except urllib.error.URLError as exc: + if attempt >= self.max_retries: + logger.warning("TelemetryTaco network error after retries: %s", exc.reason) + return + + time.sleep(min(0.25 * (attempt + 1), 1.0)) diff --git a/sdk/tests/test_client.py b/sdk/tests/test_client.py new file mode 100644 index 0000000..207b196 --- /dev/null +++ b/sdk/tests/test_client.py @@ -0,0 +1,68 @@ +import json +from unittest.mock import patch + +from telemetry_taco import TelemetryTaco +from telemetry_taco.client import QueuedEvent + + +class FakeResponse: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + return None + + def read(self): + return b"{}" + + +def test_sdk_flushes_batched_events(): + requests = [] + + def fake_urlopen(request, timeout): + requests.append((request, timeout)) + return FakeResponse() + + with patch("urllib.request.urlopen", side_effect=fake_urlopen): + client = TelemetryTaco(flush_interval=60, batch_size=10) + client.capture("user-1", "page_view", {"path": "/"}) + client.capture("user-2", "checkout_success", {"total": 42}) + client.flush(timeout=2) + client.close(timeout=2) + + assert len(requests) == 1 + request, timeout = requests[0] + payload = json.loads(request.data.decode("utf-8")) + assert timeout == 5.0 + assert len(payload["events"]) == 2 + assert all("event_uuid" in event for event in payload["events"]) + assert all("sent_at" in event for event in payload["events"]) + + +def test_sdk_drop_oldest_policy_replaces_existing_item(): + client = TelemetryTaco(max_queue_size=1, queue_full_policy="drop_oldest", _start_worker=False) + client._queue.put( # type: ignore[attr-defined] + QueuedEvent( + distinct_id="user-1", + event_name="page_view", + properties={}, + event_uuid="first", + sent_at="2026-01-01T00:00:00+0000", + ) + ) + + client._enqueue( # type: ignore[attr-defined] + QueuedEvent( + distinct_id="user-2", + event_name="signup_clicked", + properties={}, + event_uuid="second", + sent_at="2026-01-01T00:00:01+0000", + ) + ) + + queued = client._queue.get_nowait() # type: ignore[attr-defined] + client._queue.task_done() # type: ignore[attr-defined] + + assert isinstance(queued, QueuedEvent) + assert queued.event_uuid == "second" diff --git a/seed.sh b/seed.sh index 3e1eab5..499c18d 100755 --- a/seed.sh +++ b/seed.sh @@ -10,6 +10,7 @@ GREEN='\033[0;32m' YELLOW='\033[1;33m' RED='\033[0;31m' NC='\033[0m' # No Color +POETRY_CACHE_DIR="${POETRY_CACHE_DIR:-/tmp/pypoetry-cache}" echo -e "${GREEN}๐ŸŒฎ Seeding TelemetryTaco Database${NC}\n" @@ -29,7 +30,7 @@ fi if [ ! -d "backend/.venv" ] && [ ! -f "backend/poetry.lock" ]; then echo -e "${YELLOW}โš ๏ธ Backend dependencies not installed. Installing...${NC}" cd backend - poetry install --no-interaction + POETRY_CACHE_DIR="${POETRY_CACHE_DIR}" poetry install --no-interaction cd .. fi @@ -45,7 +46,7 @@ fi # Run the seed command with all passed arguments echo -e "${YELLOW}๐Ÿ“Š Running seed_events command...${NC}" cd backend -poetry run python manage.py seed_events "$@" +POETRY_CACHE_DIR="${POETRY_CACHE_DIR}" poetry run python manage.py seed_events "$@" cd .. echo -e "\n${GREEN}โœ… Database seeding completed${NC}" diff --git a/start.sh b/start.sh index 0f79077..58db5eb 100755 --- a/start.sh +++ b/start.sh @@ -11,6 +11,7 @@ GREEN='\033[0;32m' YELLOW='\033[1;33m' RED='\033[0;31m' NC='\033[0m' # No Color +POETRY_CACHE_DIR="${POETRY_CACHE_DIR:-/tmp/pypoetry-cache}" echo -e "${GREEN}๐ŸŒฎ Starting TelemetryTaco Development Environment${NC}\n" @@ -84,10 +85,10 @@ if command -v poetry &> /dev/null; then # Check if poetry.lock exists, if not or if pyproject.toml is newer, install if [ ! -f "poetry.lock" ] || [ "pyproject.toml" -nt "poetry.lock" ]; then echo -e "${YELLOW} Installing dependencies (this may take a moment)...${NC}" - poetry install --no-interaction + POETRY_CACHE_DIR="${POETRY_CACHE_DIR}" poetry install --no-interaction else # Just sync to ensure everything is installed - poetry install --no-interaction --sync + POETRY_CACHE_DIR="${POETRY_CACHE_DIR}" poetry install --no-interaction --sync fi else echo -e "${RED}โŒ Poetry not found. Please install Poetry: https://python-poetry.org/docs/#installation${NC}" @@ -98,7 +99,7 @@ cd .. # Step 4: Run migrations echo -e "${YELLOW}๐Ÿ”„ Running database migrations...${NC}" cd backend -poetry run python manage.py migrate --noinput +POETRY_CACHE_DIR="${POETRY_CACHE_DIR}" poetry run python manage.py migrate --noinput cd .. # Step 5: Start services @@ -117,7 +118,7 @@ CELERY_LOG_FILE="${PROJECT_ROOT}/.celery.log" # Start backend in background echo -e "${GREEN}โ–ถ๏ธ Starting Django backend server...${NC}" cd backend -poetry run python manage.py runserver > "${BACKEND_LOG_FILE}" 2>&1 & +POETRY_CACHE_DIR="${POETRY_CACHE_DIR}" poetry run python manage.py runserver > "${BACKEND_LOG_FILE}" 2>&1 & BACKEND_PID=$! echo $BACKEND_PID > "${BACKEND_PID_FILE}" cd .. @@ -125,7 +126,7 @@ cd .. # Start Celery worker in background echo -e "${GREEN}โ–ถ๏ธ Starting Celery worker...${NC}" cd backend -poetry run celery -A core worker --loglevel=info > "${CELERY_LOG_FILE}" 2>&1 & +POETRY_CACHE_DIR="${POETRY_CACHE_DIR}" poetry run celery -A core worker --loglevel=info > "${CELERY_LOG_FILE}" 2>&1 & CELERY_PID=$! echo $CELERY_PID > "${CELERY_PID_FILE}" cd .. From 87985cc90f293def745a2c4fd4725a6935a3efee Mon Sep 17 00:00:00 2001 From: Barry <43803274+BarryHenryJr@users.noreply.github.com> Date: Fri, 13 Mar 2026 08:53:15 -1000 Subject: [PATCH 2/4] Stable pagination, worker fixes, and API/schema updates Add stable cursor pagination and readiness status handling; refactor event ingestion and worker robustness. Key changes: - API: support `before=timestamp,id` cursor parsing and accept plain timestamps for backward compatibility; list_events now accepts string cursors and readiness endpoint can return 503 when dependencies are degraded. - Selectors: list_recent_events updated to handle timestamp+id cursors for stable pagination across equal timestamps. - Schemas/OpenAPI/frontend types: replace ModelSchema with explicit Pydantic/Ninja Schema (from_attributes), adjust EventResponseSchema fields (id, properties, timestamp required) and add 503 response in OpenAPI and generated TS types. - Tasks/Services: refactor event persistence (extract _build_event and _persist_events), remove client-side chunking when enqueuing events, ensure tasks return processed counts and log event_count accordingly, and expose a process_event_task that persists single events. - SDK: improve TelemetryTaco worker shutdown and draining behavior, defend against non-serializable batches when encoding payloads, and add a test ensuring a bad batch doesn't kill the worker. - Tests: add and update tests for stable pagination, invalid cursors, readiness 503 behavior, and process_event_task; adjust existing tests for cursor semantics. - Misc: update backend .env.example guidance for SECRET_KEY generation; add backend test sqlite DB; streamline package.json scripts to use poetry directly; add setuptools package find config in sdk pyproject.toml. These changes improve pagination stability, make background processing more resilient, clarify API contracts, and align generated client types with the server schemas. --- README.md | 6 +- backend/.env.example | 5 +- backend/core/api/events.py | 44 +++++++++++++-- backend/core/api/schemas.py | 30 ++++------ backend/core/selectors/events.py | 15 ++++- backend/core/services/ingestion.py | 9 +-- backend/core/tasks/events.py | 81 ++++++++++++++++----------- backend/core/tests/test_api.py | 69 ++++++++++++++++++++++- backend/core/tests/test_tasks.py | 24 +++++++- backend/test.sqlite3 | Bin 0 -> 155648 bytes frontend/openapi.json | 51 ++++++++--------- frontend/src/shared/api/generated.ts | 33 ++++++----- package.json | 24 ++++---- sdk/pyproject.toml | 7 +++ sdk/telemetry_taco/client.py | 37 +++++++++++- sdk/tests/test_client.py | 21 +++++++ 16 files changed, 327 insertions(+), 129 deletions(-) create mode 100644 backend/test.sqlite3 diff --git a/README.md b/README.md index 43f03a5..fcd4e0b 100644 --- a/README.md +++ b/README.md @@ -161,7 +161,7 @@ The SDK batches events in a background worker, attaches `event_uuid` and `sent_a "path": "/" }, "event_uuid": "optional-uuid", - "sent_at": "2026-03-13T18:00:00Z" + "sent_at": "YYYY-MM-DDTHH:MM:SSZ" } ``` @@ -186,9 +186,11 @@ Response: } ``` -### `GET /api/events?limit=100&before=2026-03-13T18:00:00Z` +### `GET /api/events?limit=100&before=YYYY-MM-DDTHH:MM:SSZ,EVENT_ID` Returns recent events ordered by `timestamp desc, id desc`. +For stable pagination, set `before` to the last event's `timestamp,id` pair. +Plain ISO 8601 timestamps are still accepted for backward compatibility. ### `GET /api/insights?lookback_minutes=60` diff --git a/backend/.env.example b/backend/.env.example index 4fd5d5a..fde3379 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,5 +1,8 @@ DEBUG=True -SECRET_KEY=dev-only-secret-key-not-for-production-use-please-change-me-12345 +# Generate a secret key with: +# python -c 'from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())' +# Use a unique generated value for local development and production. +SECRET_KEY=your-generated-secret-key-here DATABASE_URL=postgresql://postgres:postgres@localhost:5432/telemetry_taco REDIS_URL=redis://localhost:6379/0 CACHE_URL=redis://localhost:6379/1 diff --git a/backend/core/api/events.py b/backend/core/api/events.py index 67d5035..546dd8f 100644 --- a/backend/core/api/events.py +++ b/backend/core/api/events.py @@ -1,6 +1,8 @@ from datetime import datetime from django.conf import settings +from django.utils import timezone +from django.utils.dateparse import parse_datetime from django_ratelimit.decorators import ratelimit from ninja import Router from ninja.errors import HttpError @@ -21,6 +23,34 @@ router = Router() +def _parse_before_cursor(before: str | None) -> tuple[datetime, int | None] | None: + if before is None: + return None + + timestamp_value = before + before_id = None + + if "," in before: + timestamp_candidate, id_candidate = before.rsplit(",", maxsplit=1) + try: + before_id = int(id_candidate) + except ValueError as exc: + raise HttpError( + 400, + "before cursor must be ISO 8601 timestamp or ISO 8601 timestamp,id", + ) from exc + timestamp_value = timestamp_candidate + + parsed_before = parse_datetime(timestamp_value) + if parsed_before is None: + raise HttpError(400, "before cursor must be ISO 8601 timestamp or ISO 8601 timestamp,id") + + if timezone.is_naive(parsed_before): + parsed_before = timezone.make_aware(parsed_before, timezone.get_current_timezone()) + + return parsed_before, before_id + + @router.post("/capture", response=StatusResponse) @ratelimit(key="ip", rate=settings.RATE_LIMIT_CAPTURE_EVENT, method="POST", block=True) def capture_event(request, event: EventCaptureSchema) -> StatusResponse: @@ -37,11 +67,11 @@ def capture_event_batch(request, payload: EventBatchCaptureSchema) -> BatchStatu @router.get("/events", response=list[EventResponseSchema]) @ratelimit(key="ip", rate=settings.RATE_LIMIT_LIST_EVENTS, method="GET", block=True) -def list_events(request, limit: int = 100, before: datetime | None = None): +def list_events(request, limit: int = 100, before: str | None = None): if limit < 1: raise HttpError(400, "limit must be greater than zero") - return list_recent_events(limit=limit, before=before) + return list_recent_events(limit=limit, before=_parse_before_cursor(before)) @router.get("/insights", response=list[InsightDataPoint]) @@ -58,6 +88,10 @@ def liveness(request) -> HealthStatusResponse: return get_liveness_status() -@router.get("/health/ready", response=HealthStatusResponse) -def readiness(request) -> HealthStatusResponse: - return get_readiness_status() +@router.get("/health/ready", response={200: HealthStatusResponse, 503: HealthStatusResponse}) +def readiness(request): + status = get_readiness_status() + if status.status != "ok": + return 503, status + + return status diff --git a/backend/core/api/schemas.py b/backend/core/api/schemas.py index 83b9b0e..49d9497 100644 --- a/backend/core/api/schemas.py +++ b/backend/core/api/schemas.py @@ -2,10 +2,8 @@ from typing import Any from uuid import UUID -from ninja import ModelSchema, Schema -from pydantic import Field - -from core.models import Event +from ninja import Schema +from pydantic import ConfigDict, Field class EventCaptureSchema(Schema): @@ -20,23 +18,19 @@ class EventBatchCaptureSchema(Schema): events: list[EventCaptureSchema] -class EventResponseSchema(ModelSchema): - uuid: str +class EventResponseSchema(Schema): + model_config = ConfigDict(from_attributes=True) - class Meta: - model = Event - fields = [ - "id", - "distinct_id", - "event_name", - "properties", - "timestamp", - "uuid", - "created_at", - ] + id: int + distinct_id: str + event_name: str + properties: dict[str, Any] + timestamp: datetime + uuid: str + created_at: datetime @staticmethod - def resolve_uuid(obj: Event) -> str: + def resolve_uuid(obj: Any) -> str: return str(obj.uuid) diff --git a/backend/core/selectors/events.py b/backend/core/selectors/events.py index 957a8ab..e69b365 100644 --- a/backend/core/selectors/events.py +++ b/backend/core/selectors/events.py @@ -1,19 +1,28 @@ from datetime import datetime, timedelta from django.conf import settings -from django.db.models import Count +from django.db.models import Count, Q from django.db.models.functions import TruncMinute from django.utils import timezone from core.models import Event +EventCursor = tuple[datetime, int | None] -def list_recent_events(*, limit: int, before: datetime | None = None) -> list[Event]: + +def list_recent_events(*, limit: int, before: EventCursor | None = None) -> list[Event]: bounded_limit = min(limit, settings.MAX_EVENTS_LIMIT) queryset = Event.objects.order_by("-timestamp", "-id") if before is not None: - queryset = queryset.filter(timestamp__lt=before) + before_timestamp, before_id = before + if before_id is None: + queryset = queryset.filter(timestamp__lt=before_timestamp) + else: + queryset = queryset.filter( + Q(timestamp__lt=before_timestamp) + | Q(timestamp=before_timestamp, id__lt=before_id) + ) return list(queryset[:bounded_limit]) diff --git a/backend/core/services/ingestion.py b/backend/core/services/ingestion.py index b89abcf..e19e7e2 100644 --- a/backend/core/services/ingestion.py +++ b/backend/core/services/ingestion.py @@ -50,10 +50,6 @@ def _serialize_event(event: NormalizedEvent) -> dict[str, Any]: } -def _chunked(items: list[NormalizedEvent], *, size: int) -> list[list[NormalizedEvent]]: - return [items[index : index + size] for index in range(0, len(items), size)] - - def enqueue_events(events: list[EventCaptureSchema]) -> int: if not events: raise HttpError(400, "events must contain at least one event") @@ -65,9 +61,6 @@ def enqueue_events(events: list[EventCaptureSchema]) -> int: ) normalized = [_normalize_event(event) for event in events] - chunks = _chunked(normalized, size=settings.MAX_CAPTURE_BATCH_SIZE) - - for chunk in chunks: - process_event_batch_task.delay([_serialize_event(event) for event in chunk]) + process_event_batch_task.delay([_serialize_event(event) for event in normalized]) return len(normalized) diff --git a/backend/core/tasks/events.py b/backend/core/tasks/events.py index 09c5c01..061dca4 100644 --- a/backend/core/tasks/events.py +++ b/backend/core/tasks/events.py @@ -30,39 +30,32 @@ def _parse_timestamp(raw_value: Any): return parsed -@shared_task( - bind=True, - autoretry_for=(OperationalError,), - retry_backoff=True, - retry_jitter=True, - retry_kwargs={"max_retries": 5}, -) -def process_event_batch_task(self, events_data: list[dict[str, Any]]) -> int: +def _build_event(event_data: dict[str, Any]) -> Event: + distinct_id = event_data.get("distinct_id") + event_name = event_data.get("event_name") + event_uuid = event_data.get("event_uuid") or event_data.get("uuid") + + if not distinct_id: + raise ValueError("Missing required field: 'distinct_id'") + if not event_name: + raise ValueError("Missing required field: 'event_name'") + if not event_uuid: + raise ValueError("Missing required field: 'event_uuid'") + + return Event( + distinct_id=distinct_id, + event_name=event_name, + properties=event_data.get("properties", {}), + timestamp=_parse_timestamp(event_data.get("timestamp")), + uuid=UUID(str(event_uuid)), + ) + + +def _persist_events(events_data: list[dict[str, Any]]) -> int: if not events_data: return 0 - events_to_create = [] - for event_data in events_data: - distinct_id = event_data.get("distinct_id") - event_name = event_data.get("event_name") - event_uuid = event_data.get("event_uuid") or event_data.get("uuid") - - if not distinct_id: - raise ValueError("Missing required field: 'distinct_id'") - if not event_name: - raise ValueError("Missing required field: 'event_name'") - if not event_uuid: - raise ValueError("Missing required field: 'event_uuid'") - - events_to_create.append( - Event( - distinct_id=distinct_id, - event_name=event_name, - properties=event_data.get("properties", {}), - timestamp=_parse_timestamp(event_data.get("timestamp")), - uuid=UUID(str(event_uuid)), - ) - ) + events_to_create = [_build_event(event_data) for event_data in events_data] Event.objects.bulk_create( events_to_create, @@ -70,15 +63,28 @@ def process_event_batch_task(self, events_data: list[dict[str, Any]]) -> int: ignore_conflicts=True, ) + return len(events_to_create) + + +@shared_task( + bind=True, + autoretry_for=(OperationalError,), + retry_backoff=True, + retry_jitter=True, + retry_kwargs={"max_retries": 5}, +) +def process_event_batch_task(self, events_data: list[dict[str, Any]]) -> int: + processed_count = _persist_events(events_data) + logger.info( "processed_event_batch", extra={ "task_name": self.name, "task_id": self.request.id, - "event_count": len(events_to_create), + "event_count": processed_count, }, ) - return len(events_to_create) + return processed_count @shared_task( @@ -89,7 +95,16 @@ def process_event_batch_task(self, events_data: list[dict[str, Any]]) -> int: retry_kwargs={"max_retries": 5}, ) def process_event_task(self, event_data: dict[str, Any]) -> int: - return process_event_batch_task.run([event_data]) + processed_count = _persist_events([event_data]) + logger.info( + "processed_event", + extra={ + "task_name": self.name, + "task_id": self.request.id, + "event_count": processed_count, + }, + ) + return processed_count @shared_task( diff --git a/backend/core/tests/test_api.py b/backend/core/tests/test_api.py index 70649ba..c2e54ba 100644 --- a/backend/core/tests/test_api.py +++ b/backend/core/tests/test_api.py @@ -1,10 +1,12 @@ from datetime import timedelta +from unittest.mock import patch from uuid import uuid4 import pytest from django.core.management import call_command from django.utils import timezone +from core.api.schemas import HealthStatusResponse from core.models import Event @@ -123,7 +125,7 @@ def test_events_endpoint_caps_limit_and_supports_before_filter(client, settings) limited = client.get("/api/events?limit=999") before_filtered = client.get( "/api/events", - data={"limit": 5, "before": newest.timestamp.isoformat()}, + data={"limit": 5, "before": f"{newest.timestamp.isoformat()},{newest.id}"}, ) assert limited.status_code == 200 @@ -133,6 +135,56 @@ def test_events_endpoint_caps_limit_and_supports_before_filter(client, settings) assert [event["id"] for event in before_filtered.json()] == [middle.id, oldest.id] +@pytest.mark.django_db +def test_events_endpoint_supports_stable_cursor_for_same_timestamp_rows(client): + shared_timestamp = timezone.now() + older = Event.objects.create( + distinct_id="older", + event_name="page_view", + timestamp=shared_timestamp - timedelta(minutes=1), + ) + same_timestamp_lower_id = Event.objects.create( + distinct_id="same-timestamp-lower-id", + event_name="page_view", + timestamp=shared_timestamp, + ) + same_timestamp_higher_id = Event.objects.create( + distinct_id="same-timestamp-higher-id", + event_name="page_view", + timestamp=shared_timestamp, + ) + + first_page = client.get("/api/events?limit=1") + second_page = client.get( + "/api/events", + data={ + "limit": 5, + "before": ( + f"{same_timestamp_higher_id.timestamp.isoformat()}," + f"{same_timestamp_higher_id.id}" + ), + }, + ) + + assert first_page.status_code == 200 + assert [event["id"] for event in first_page.json()] == [same_timestamp_higher_id.id] + assert second_page.status_code == 200 + assert [event["id"] for event in second_page.json()] == [ + same_timestamp_lower_id.id, + older.id, + ] + + +@pytest.mark.django_db +def test_events_endpoint_rejects_invalid_before_cursor(client): + response = client.get("/api/events?before=not-a-cursor") + + assert response.status_code == 400 + assert response.json()["detail"] == ( + "before cursor must be ISO 8601 timestamp or ISO 8601 timestamp,id" + ) + + @pytest.mark.django_db def test_insights_endpoint_respects_max_lookback(client, settings): settings.MAX_INSIGHTS_LOOKBACK_MINUTES = 30 @@ -182,3 +234,18 @@ def test_readiness_reports_dependency_status(client): assert response.status_code == 200 assert response.json()["database"] == "ok" assert response.json()["cache"] == "ok" + + +@pytest.mark.django_db +def test_readiness_returns_503_when_dependencies_are_degraded(client): + degraded_status = HealthStatusResponse(status="degraded", database="error", cache="ok") + + with patch("core.api.events.get_readiness_status", return_value=degraded_status): + response = client.get("/api/health/ready") + + assert response.status_code == 503 + assert response.json() == { + "status": "degraded", + "database": "error", + "cache": "ok", + } diff --git a/backend/core/tests/test_tasks.py b/backend/core/tests/test_tasks.py index c56684a..743affe 100644 --- a/backend/core/tests/test_tasks.py +++ b/backend/core/tests/test_tasks.py @@ -5,7 +5,7 @@ from django.utils import timezone from core.models import Event -from core.tasks import process_event_batch_task +from core.tasks import process_event_batch_task, process_event_task @pytest.mark.django_db @@ -30,3 +30,25 @@ def test_process_event_batch_task_ignores_duplicate_event_uuids(): assert processed_count == 2 assert Event.objects.count() == 1 + + +@pytest.mark.django_db +def test_process_event_task_persists_single_event(): + event_uuid = str(uuid4()) + + processed_count = process_event_task.run( + { + "distinct_id": "user-456", + "event_name": "checkout_success", + "event_uuid": event_uuid, + "properties": {"total": 42}, + "timestamp": timezone.now().isoformat(), + } + ) + + assert processed_count == 1 + stored_event = Event.objects.get() + assert stored_event.distinct_id == "user-456" + assert stored_event.event_name == "checkout_success" + assert stored_event.properties == {"total": 42} + assert str(stored_event.uuid) == event_uuid diff --git a/backend/test.sqlite3 b/backend/test.sqlite3 new file mode 100644 index 0000000000000000000000000000000000000000..cf3662639a50664747e31c0d797abf9a93294f1f GIT binary patch literal 155648 zcmeI5eQX=&eaCqsr4ucYCrg$uiRFl*?Zm9C=*Z*o#c7u?HtVRi<;0eocvx^dk|&uC zUu2T<3(~cvVy8vB4rqrJ*nq9WiUAuo4DB|o7}^3uhoO5x*J5bbtz8H7k1c3{uET(~ z=-L8%p68Byj>n^jXhU^472jpMDu|H^c(f8PgemG2xr5{lnS^81v=-Bti9vS<~ z;8(=&1%GekbHi(a5BtA8^pl}W!bf~x8suoF=i5(Ait>vKLc3Y6mCQTFM(eu1(KH+S zTBE+P-n911CF@WpJK3bFDMh8IZ`8^+-R4tgF6VPA`N-AfrDw0^BTLI?^Up`3ZmVsa zMkAM&`!hu&C!F#s3o#ajiqV|R~D!2VvVe{CImApGv_MB(G`9Kt zxG3jx!o9~^MxkPjQ#Sh9hKp$OYl}N2WLIJ(_tb^F^LpYoq*%#?BvK{~x@I;a%a>Ln z%U3U4h&*$7>0<8kwaEGWwMg#j%B7`c;__mCd4))2PCRp6&c8i>Ilp`+f5lO#T}CW& zmZn~2l9ra2R+e%XE?l!u=Fj$Z!hTJC-Z_h%l@Z%i#zc8xLAW>NLd!;&1E_U8J_w_g z{HB6uT_bkZD$I6=$XX@a#>sdh z6YF+M(n%^tv!z$+Yvo!rQZia*t6Vjm>E)*0+#nr;cG761P_I{xj~8L z`;lPl1})t%YHRhLo3|}#`-mTibG4R{d7Q->#ERxi48W7qI6?@U9T7gv!YkY zlSoBRrwS#tq!!p?h)0HD8)ol}!ozTD&(>3jM>bmbt|mIMV6w<#)tXC!zJ;czUlF?o^Vs zQtrFEkn6zokr3G)I`9df^m*x{Qc0Q%|8w|P!%u~N68d!Lb|^Xi>iEaT-#LD8?2BXf z$5syf2QkDC2!H?xfB*=900@8p2)uy=)I-C*qbG7r(^>~n6eX^gYvoqis3ep`YC%yK z;%X$GIi1X&R<*}6sWe$)s7{UerrWc*V!dIK90|Q)#f<5(Zr-9{?^3zhD%Xl6fN+P* zCHqdRMWdL_vQ(p1B6Gcl>0Mk(Nn{r%hkes0I;Cx+FPYTTrw@`0&*kX6o75+v8~OQ*V&qT&bj+jYhqpS4r10)=Y0iNlnWna%05VQpZ_} z^ro8|WzuErzy)1FY3j6}c~VZQs+xW30C91?<3e-Riw8sX+D65@S0@wMr1oTx_~^8? zl63m$vZ=4Pyepz+(@OfxDDlu~YejW>U~$gv(5mTpD*m)coOC)b8CzE8(eMJU^@65Lh8WU|Ppty8z&E|ets&re*O z=(^bYf)G!ow9MiVVM=na3&J*6xHwsaOr`~bj$U$l&~06kl1^nbI{z1?TYl+V(wC)A zOShyCO1~xj!5er%M^PXE0w4eaAOHd&00JNY0w4eaAh2f%Oa=V&g6lP#nNZL_H_A@3 zXxsP!|EzULqdDO*|D3=BlR*;Ako|sD@OURfoEq{kjM@n-Zb4%f(}I7oW6j@GICOw8 z7NvLjr2mrsLHe5XS?PDBcS#@Iv*1xN5C8!X009sH0T2KI5C8!X009tq!wG}~(=waW zvsaMBLxH15gwBgi_P--DffL7w4PVxG><@Sopv{6_}o5iTqWJ zp@RYWXpn^jIxj@f_5Yc%ulb~Zlm1kCzf_P;OXJ~x5C3`iqv1w)Is907GW6e}e++#h zbU(BbIyV0O@h^}6@%X**UmkyI{Nb_xB^mJp0w4eaAOHd&00JNY0wC}vAn@?auuqta ziMPt;ZR;b4Ge#{!k0NZo5j{fEPb`S!t96t7N(pw3T3E-n%|n(A`K=wbHHVFLY}w2_ zNNi>w71b*p}xqw?lgUj39l=Zwjj)_Lrevq-FBt$Q>R2?xtJcZ z_3#90Ym4G1Pm%8rsI{$(Cs1o!9zXdA`Nn|s+99fupQWZ$C_i(Yd_WL z@#*mJ}>Wltno{O2Jl zAj}Fpl=C1n=?UIEa3~>$hGg8|`~;Ecjd9}Wl$LSNM7&;tP>E_lRO zrmO_pts^orIUNw@1$VsUAXCpnBNI%3GZ-RSXKaTwq7e=UgrheoXuG(?C^gzHWFX@G|6R8fGJ*gIfB*=900@8p z2!H?xfB*=9z|Rc<%>RFGnhHWe00ck)1V8`;KmY_l00ck)1VCWd1nB$!Gtvt_>BrI! zr2mn=BfTnplY9yA73qu8-${QheOCIE^hxQDq~Di5CjF-L>(Ym$_epK(C22#dOJ%7@ zGU5jWKmY_l00ck)1V8`;KmY_l00j0sfnmQOh%;<^gl!*U+Xv}(yo+4eBoPSfr1 zA-0`j+ex-PNVkCrww2g6%(fxA^^ddd7~39T+aTQzjk2xCwj*pi9PkH)0NwcMX2|ak z2C@FX-#aPl4+03H~`f?pZ=T*Y%C2+0fS-^^NtWwO=mjDI=aR zOPPY(;Lw@N`P@oAvb20Q|9m9sPVUPeja*vpH;YD2Mp>!lQZ&|nVp5b}B$Aq2OJWLj zqAiMQwrdR4~?&F8;dadN1x{!}V z-N}?Fff*^+TIQPBh%8@Pi7a2ea3S){<)w?c%hw|3^VcG|t1Fk5mWiW_`Q;T-gL>hH zS!@wC(MZd@(_$I;>4v%9h(>N1jpB8qaWbJOv94EQWE5MZR{CniSc^uQRijcNMMpMj z&GK5!EOjj-XP(ZVIZwp(*#E-YB1()nA)ae%rd~ChO=HdMsIaBe^g5|dt);i_uA59| zw|cZ{m-BDWU(PR|$zSR6aAvTP#3E;@-DSpYX?ba7DR<$*HTz`#EGeba6Z_#gOXzmA z9s9Mb+$vW|)k;RoqR01 zgLH?U-QC}vjS}F+(T3TtgE(2#irIw8nxb#SPkJ;%pOO2rV0&~lL0=Xx{PPo{`~tZG zUF3bKFQ0Xdrx%|>JfRc|t66W|2X~~~a_?}qo#al2Xlzp%W0$^rQ|!{mJ2EqAx{t?g zJ@%!~ootWC{?WVDrH^%K&kN>j8}r>t_RHyW%xrgdHHnQq;(JD(ofTd=){CqQ+z(KDx}ne>n7~Z{eUfN(Cyf5zmw7&9nDiQ ztujj>?Mp82J-W%2YU+?EKT94rG*`u~&c_EyHmzw{Et%{hwx5v>dkha&+#m;Ty_OwS z$@bXwb8~rAl(SjkUYfVBmCEH?1EcS3(4*!1j0SXrt)CCN@?PK0+}9>MaL*~&HQDC2 z9wX1ahvm6Bq5Yy0XghOQl#Aq^d(GAO1N)(o)l8!lcRUEYO&=W8AKZrBzTNTZ?ilV2 z&W;>=Gd{WBkjO>EpA|Nbah!c!kB=zci1a3;6iaGCD=7R5(wB8&@YSQwc1OiHi1#_@ z8IXN_G`9KtxG3jx!oA0Nhhh!pZZ&?raHoVkMyjWaU5!@e>1S|D>uEKQ8AjiIjc3^5 zSbb(_M4q1)t}(F9o0W3Q)SKqb4YO7>U5EZ2gs#MsHKS^tA~|F2Yl0{*E()6l^J&kP zs^ztYL8mRvz7s<|emm1Eo?%xlVyv$_CQzEzGl8O72d9#(m9j}DX8rR9tpB@W8aM<2 z5C8!X009sH0T2KI5C8!X0D(P7Aml&iJLLO}@4#Dwe>nP$(aDi-lhyy~(4YCQ`TuI@ zy}r*3oe%#od^hy1&@YacLPy8G;6U)6I0-V7n5eb*U#vGw-MmE?SWD$*t6VFRu-qMf zHe$y_?6rQL;54@)YCMewN^EazWtjxh=(1-d(WS#r(hKn|yYjR%0YR!p4YtYAYICk#5zD$(}v%sxPq< z8ZRdoL^&2C3wSTTh3sv$lqY(ipuyc;kytmH&D-?`=|!D4IpT>7?@6SKRE%bezHw5n zIlB;f!Gfffn|gDD^n0uLLcLz;zP~^_7ZJ5R=}r+_SP~PnYLqKnWI{{!h@?VV#_DP` z;ub+t=!+J&NHEo%M7zFzgY;(4Kq@J8wcH?e-$GcdeLPAQ*5`W`)_HuJ43VA}RP@AZ zGMzG$)@l*A3-?}0Sy$qz1HCv_E4Vf71+HSrqUM!3QNBAbY)X6~hG*$3pja=NwAQ*3 zHwtQ6Gx!<|&oyF;_E@^*Mmu!960>4+om37m+CbYbA0z%LuTXcxs{k5R*ocQtn0 zn73Z1wAU1zne0-xS*vu`nU7M}`&PZ3G~v91#oABLk~fJ|q5TqTeGefzSx?X_M!~G; zRnqnqJ)J6))RJ0Y4eya5;Mw>dhFg2K+T9}?CDYYJSLvB7(vg=Q7G+fxHnYx!8z)l_ zXGeRR-os*7&Cm4>Y!yqjcQRSAJ?ebd(w5bI!>_v>T+~uYvtXp$1~YcG?B_3ca$o)) z*xh1i)jLarvG&OsQP#=J56?P#O4qi3G$#v1Gh-IqWgpo_nzO;^T5r+zGgzW4M>HnA z*Y}@$|NY4Jp{yuhi3#njvq75Vg-H5#rFmz)OrFu%eaz5`sYEKJ+D&1bJmT39TjlR* z0^5-G5l2~M7^c$fX=U?K=P3kw{yr@O41RZ-)Ys2-d zO;VjQ`yZKOk7q{YOR+%@r@hMcc<=Fv_IOI9&%^DvvBu!fy!I@qrYogV$y9o$NprnT zuw^a_J~v_D*$j+b-%0)Wgdo-qX_lNP+;Te8HMM9Iv)Nub+t1o~E-yLG8B?#GcPA}5 zb8b0Z&AO7!n5vd8@fsc*NT)xSmz=H@>68Igl+TmqiMa(hH0w$ty_(5nSGk1fKnY&? zyhJ$cqV&z8_tHtU_2L{arrSw+CehpX4r13j!bj0w4eaAOHd& z00JNY0w4eadz1j?|9iAyQ7sSv0T2KI5C8!X009sH0T2KI5O5H{{NDisyC47pAOHd& z00JNY0w4eaAOHd&uty1C{=Y{X7S#d)5C8!X009sH0T2KI5C8!X009R9%>NxQunPhp z00JNY0w4eaAOHd&00JNY0(+D|h}27zUh+xbk^WWsd+9UM$D|LCL;QdM2!H?xfB*=9 z00@8p2!H?xfB*=*J_JHyz&9<^U-{2|JO9*3z<2bB{r4)G_P_2DHy1Q;R z9sA<};$WKoSL*RDxFFPV}Uu_v{JvlmF4L1fSeQR zm6F>indR<8YyN-VCw*1=6Y2h7g-8Jb5C8!X009sH0T2KI5C8!X009tqjRZ~xW^=;2 zv1aPG%I57YCjUD%9No&$WB&hDpY(ka0eJN_)&to=00ck)1V8`;KmY_l00ck)1V8`; z-bezH|CsZAf?(J`XC1Sb21bK^*?Kd;UjP5NPx_|xIqBnXq(qbj0w4eaAOHd&00JNY z0w4eaAOHd&;2`j@KPPPQPQcd5qy5P}>;L%u{|?gF1pyEM0T2KI5C8!X009sH0T2Lz zJxajw`~UZ74N)x+009sH0T2KI5C8!X009sH0T2KI%>U5`KmY_l00ck)1V8`;KmY_l L00cl_?-TexMv5s7 literal 0 HcmV?d00001 diff --git a/frontend/openapi.json b/frontend/openapi.json index fefec47..77e77d5 100644 --- a/frontend/openapi.json +++ b/frontend/openapi.json @@ -85,7 +85,6 @@ "schema": { "anyOf": [ { - "format": "date-time", "type": "string" }, { @@ -183,6 +182,16 @@ } } } + }, + "503": { + "description": "Service Unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HealthStatusResponse" + } + } + } } } } @@ -284,49 +293,32 @@ }, "EventResponseSchema": { "properties": { - "uuid": { - "title": "Uuid", - "type": "string" - }, "id": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "ID" + "title": "Id", + "type": "integer" }, "distinct_id": { - "description": "User ID", - "maxLength": 255, "title": "Distinct Id", "type": "string" }, "event_name": { - "maxLength": 255, "title": "Event Name", "type": "string" }, "properties": { - "anyOf": [ - { - "type": "object" - }, - { - "type": "null" - } - ], - "title": "Properties", - "description": "Flexible JSONB field for event properties" + "type": "object", + "additionalProperties": true, + "title": "Properties" }, "timestamp": { "format": "date-time", "title": "Timestamp", "type": "string" }, + "uuid": { + "title": "Uuid", + "type": "string" + }, "created_at": { "format": "date-time", "title": "Created At", @@ -334,9 +326,12 @@ } }, "required": [ - "uuid", + "id", "distinct_id", "event_name", + "properties", + "timestamp", + "uuid", "created_at" ], "title": "EventResponseSchema", diff --git a/frontend/src/shared/api/generated.ts b/frontend/src/shared/api/generated.ts index a724dd1..41d052c 100644 --- a/frontend/src/shared/api/generated.ts +++ b/frontend/src/shared/api/generated.ts @@ -150,27 +150,23 @@ export interface components { }; /** EventResponseSchema */ EventResponseSchema: { - /** Uuid */ - uuid: string; - /** ID */ - id?: number | null; - /** - * Distinct Id - * @description User ID - */ + /** Id */ + id: number; + /** Distinct Id */ distinct_id: string; /** Event Name */ event_name: string; - /** - * Properties - * @description Flexible JSONB field for event properties - */ - properties?: Record | null; + /** Properties */ + properties: { + [key: string]: unknown; + }; /** * Timestamp * Format: date-time */ - timestamp?: string; + timestamp: string; + /** Uuid */ + uuid: string; /** * Created At * Format: date-time @@ -333,6 +329,15 @@ export interface operations { "application/json": components["schemas"]["HealthStatusResponse"]; }; }; + /** @description Service Unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HealthStatusResponse"]; + }; + }; }; }; } diff --git a/package.json b/package.json index 27b12aa..a0f0556 100644 --- a/package.json +++ b/package.json @@ -8,29 +8,29 @@ "start": "bash start.sh", "stop": "bash stop.sh", "dev:frontend": "pnpm --filter ./frontend dev", - "dev:backend": "cd backend && POETRY_CACHE_DIR=/tmp/pypoetry-cache poetry run python manage.py runserver", - "dev:worker": "cd backend && POETRY_CACHE_DIR=/tmp/pypoetry-cache poetry run celery -A core worker --loglevel=info", + "dev:backend": "cd backend && poetry run python manage.py runserver", + "dev:worker": "cd backend && poetry run celery -A core worker --loglevel=info", "restart:backend": "bash restart-backend.sh", "services": "docker-compose up -d db redis", "services:stop": "docker-compose stop db redis", - "migrate": "cd backend && POETRY_CACHE_DIR=/tmp/pypoetry-cache poetry run python manage.py migrate", + "migrate": "cd backend && poetry run python manage.py migrate", "seed": "bash seed.sh", "seed:clean": "bash seed.sh --clean", "build": "pnpm --filter ./frontend build", "build:frontend": "pnpm --filter ./frontend build", - "generate:api-types": "cd backend && POETRY_CACHE_DIR=/tmp/pypoetry-cache DJANGO_SETTINGS_MODULE=telemetry_taco.settings.test poetry run python manage.py export_openapi_schema ../frontend/openapi.json && cd ../frontend && pnpm generate:api-types", + "generate:api-types": "cd backend && DJANGO_SETTINGS_MODULE=telemetry_taco.settings.test poetry run python manage.py export_openapi_schema ../frontend/openapi.json && cd ../frontend && pnpm generate:api-types", "lint": "pnpm --filter ./frontend lint", "lint:frontend": "pnpm --filter ./frontend lint", - "lint:backend": "cd backend && POETRY_CACHE_DIR=/tmp/pypoetry-cache poetry run ruff check .", - "format:backend": "cd backend && POETRY_CACHE_DIR=/tmp/pypoetry-cache poetry run ruff format .", - "format:check:backend": "cd backend && POETRY_CACHE_DIR=/tmp/pypoetry-cache poetry run ruff format --check .", - "check:backend": "cd backend && POETRY_CACHE_DIR=/tmp/pypoetry-cache DJANGO_SETTINGS_MODULE=telemetry_taco.settings.test poetry run python manage.py check", + "lint:backend": "cd backend && poetry run ruff check .", + "format:backend": "cd backend && poetry run ruff format .", + "format:check:backend": "cd backend && poetry run ruff format --check .", + "check:backend": "cd backend && DJANGO_SETTINGS_MODULE=telemetry_taco.settings.test poetry run python manage.py check", "test": "pnpm test:backend && pnpm test:frontend && pnpm test:sdk", - "test:backend": "cd backend && POETRY_CACHE_DIR=/tmp/pypoetry-cache poetry run pytest", + "test:backend": "cd backend && poetry run pytest", "test:frontend": "pnpm --filter ./frontend test", - "test:sdk": "cd sdk && PYTEST_PYTHON=$(POETRY_CACHE_DIR=/tmp/pypoetry-cache poetry -C ../backend run python -c 'import sys; print(sys.executable)') && \"$PYTEST_PYTHON\" -m pytest tests", - "security:backend": "cd backend && POETRY_CACHE_DIR=/tmp/pypoetry-cache poetry run bandit -r . -c bandit.yaml", - "security:backend:json": "cd backend && POETRY_CACHE_DIR=/tmp/pypoetry-cache poetry run bandit -r . -f json -o bandit-report.json -c bandit.yaml", + "test:sdk": "cd sdk && PYTEST_PYTHON=$(poetry -C ../backend run python -c 'import sys; print(sys.executable)') && \"$PYTEST_PYTHON\" -m pytest tests", + "security:backend": "cd backend && poetry run bandit -r . -c bandit.yaml", + "security:backend:json": "cd backend && poetry run bandit -r . -f json -o bandit-report.json -c bandit.yaml", "validate:backend": "pnpm lint:backend && pnpm format:check:backend && pnpm check:backend && pnpm test:backend", "validate:frontend": "pnpm generate:api-types && pnpm --filter ./frontend lint && pnpm --filter ./frontend type-check && pnpm --filter ./frontend test && pnpm --filter ./frontend build", "validate:all": "pnpm validate:backend && pnpm validate:frontend && pnpm test:sdk" diff --git a/sdk/pyproject.toml b/sdk/pyproject.toml index 4fc24f7..ce8b23c 100644 --- a/sdk/pyproject.toml +++ b/sdk/pyproject.toml @@ -9,5 +9,12 @@ dependencies = [] requires = ["setuptools>=68"] build-backend = "setuptools.build_meta" +[tool.setuptools] +include-package-data = true + +[tool.setuptools.packages.find] +where = ["."] +include = ["telemetry_taco*"] + [tool.pytest.ini_options] pythonpath = ["."] diff --git a/sdk/telemetry_taco/client.py b/sdk/telemetry_taco/client.py index cef4a49..a36754c 100644 --- a/sdk/telemetry_taco/client.py +++ b/sdk/telemetry_taco/client.py @@ -135,6 +135,7 @@ def _enqueue(self, payload: QueuedEvent) -> None: def _run_worker(self) -> None: batch: list[QueuedEvent] = [] last_flush = time.monotonic() + stop_requested = False while True: try: @@ -144,16 +145,19 @@ def _run_worker(self) -> None: if item is _STOP: self._queue.task_done() - self._flush_batch(batch) - return + stop_requested = True if isinstance(item, QueuedEvent): batch.append(item) + if self._flush_requested.is_set(): + stop_requested = self._drain_queue(batch) or stop_requested + should_flush = ( len(batch) >= self.batch_size or (batch and time.monotonic() - last_flush >= self.flush_interval) or (batch and self._flush_requested.is_set()) + or (batch and stop_requested) ) if should_flush: @@ -161,6 +165,28 @@ def _run_worker(self) -> None: batch = [] last_flush = time.monotonic() + if stop_requested and not batch: + return + + def _drain_queue(self, batch: list[QueuedEvent]) -> bool: + stop_requested = False + + while len(batch) < self.batch_size: + try: + item = self._queue.get_nowait() + except queue.Empty: + break + + if item is _STOP: + self._queue.task_done() + stop_requested = True + break + + if isinstance(item, QueuedEvent): + batch.append(item) + + return stop_requested + def _flush_batch(self, batch: list[QueuedEvent]) -> None: if not batch: return @@ -173,7 +199,12 @@ def _flush_batch(self, batch: list[QueuedEvent]) -> None: def _send_batch(self, batch: list[QueuedEvent]) -> None: payload = {"events": [event.as_dict() for event in batch]} - body = json.dumps(payload).encode("utf-8") + try: + body = json.dumps(payload).encode("utf-8") + except TypeError as exc: + logger.error("TelemetryTaco failed to serialize event batch: %s", exc, exc_info=True) + return + request = urllib.request.Request( self.batch_url, data=body, diff --git a/sdk/tests/test_client.py b/sdk/tests/test_client.py index 207b196..079f130 100644 --- a/sdk/tests/test_client.py +++ b/sdk/tests/test_client.py @@ -1,3 +1,4 @@ +from datetime import datetime, timezone import json from unittest.mock import patch @@ -66,3 +67,23 @@ def test_sdk_drop_oldest_policy_replaces_existing_item(): assert isinstance(queued, QueuedEvent) assert queued.event_uuid == "second" + + +def test_sdk_drops_non_serializable_batch_without_killing_worker(): + requests = [] + + def fake_urlopen(request, timeout): + requests.append((request, timeout)) + return FakeResponse() + + with patch("urllib.request.urlopen", side_effect=fake_urlopen): + client = TelemetryTaco(flush_interval=60, batch_size=10) + client.capture("user-1", "page_view", {"captured_at": datetime.now(timezone.utc)}) + client.flush(timeout=2) + client.capture("user-2", "page_view", {"path": "/health"}) + client.flush(timeout=2) + client.close(timeout=2) + + assert len(requests) == 1 + payload = json.loads(requests[0][0].data.decode("utf-8")) + assert payload["events"][0]["distinct_id"] == "user-2" From c9a0a6b75f78bdca58dbc2e08be40fc49075be66 Mon Sep 17 00:00:00 2001 From: Barry <43803274+BarryHenryJr@users.noreply.github.com> Date: Fri, 13 Mar 2026 08:58:28 -1000 Subject: [PATCH 3/4] Format event queryset condition Collapse the multiline Q(...) | Q(...) condition in backend/core/selectors/events.py into a single line for readability. This is a pure formatting change with no behavioral impact. --- backend/core/selectors/events.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/backend/core/selectors/events.py b/backend/core/selectors/events.py index e69b365..d58a55f 100644 --- a/backend/core/selectors/events.py +++ b/backend/core/selectors/events.py @@ -20,8 +20,7 @@ def list_recent_events(*, limit: int, before: EventCursor | None = None) -> list queryset = queryset.filter(timestamp__lt=before_timestamp) else: queryset = queryset.filter( - Q(timestamp__lt=before_timestamp) - | Q(timestamp=before_timestamp, id__lt=before_id) + Q(timestamp__lt=before_timestamp) | Q(timestamp=before_timestamp, id__lt=before_id) ) return list(queryset[:bounded_limit]) From 82fd9e32ae74b2ed5fa75368c316108bcd478e3a Mon Sep 17 00:00:00 2001 From: Barry <43803274+BarryHenryJr@users.noreply.github.com> Date: Fri, 13 Mar 2026 09:21:13 -1000 Subject: [PATCH 4/4] Enhance timestamp parsing, test, and TelemetryTaco MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: make event timestamp parsing more robust โ€” accept naive datetimes (make aware), accept date objects (convert to start-of-day aware datetime), require strings for ISO parsing, and raise on unsupported types; added tests for date timestamps and invalid timestamp objects. Frontend: memoize QueryClient with useState to avoid recreating it on each render. SDK: normalize and validate base_url, use UTC constant, add thread-safety for capture/close with a state lock and closing flag, implement dropping/replacing oldest queued event safely, improve error handling/logging when sending batches fails; added and updated tests to cover sentinel preservation, base_url normalization/validation, and failed request handling. --- backend/core/tasks/events.py | 14 ++++- backend/core/tests/test_tasks.py | 47 ++++++++++++++- backend/test.sqlite3 | Bin 155648 -> 155648 bytes frontend/src/test/test-utils.tsx | 21 ++++--- sdk/telemetry_taco/client.py | 99 +++++++++++++++++++++++-------- sdk/tests/test_client.py | 80 ++++++++++++++++++++++++- 6 files changed, 220 insertions(+), 41 deletions(-) diff --git a/backend/core/tasks/events.py b/backend/core/tasks/events.py index 061dca4..4d7f814 100644 --- a/backend/core/tasks/events.py +++ b/backend/core/tasks/events.py @@ -1,3 +1,4 @@ +from datetime import date, datetime, time from typing import Any from uuid import UUID @@ -17,10 +18,19 @@ def _parse_timestamp(raw_value: Any): if raw_value is None: return timezone.now() - if hasattr(raw_value, "isoformat"): + if isinstance(raw_value, datetime): + if timezone.is_naive(raw_value): + return timezone.make_aware(raw_value, timezone.get_current_timezone()) return raw_value - parsed = parse_datetime(str(raw_value)) + if isinstance(raw_value, date): + parsed = datetime.combine(raw_value, time.min) + return timezone.make_aware(parsed, timezone.get_current_timezone()) + + if not isinstance(raw_value, str): + raise ValueError("timestamp must be a datetime, date, or ISO 8601 datetime string") + + parsed = parse_datetime(raw_value) if parsed is None: raise ValueError("timestamp must be an ISO 8601 datetime") diff --git a/backend/core/tests/test_tasks.py b/backend/core/tests/test_tasks.py index 743affe..d3a9e7a 100644 --- a/backend/core/tests/test_tasks.py +++ b/backend/core/tests/test_tasks.py @@ -1,4 +1,4 @@ -from datetime import timedelta +from datetime import date, timedelta from uuid import uuid4 import pytest @@ -52,3 +52,48 @@ def test_process_event_task_persists_single_event(): assert stored_event.event_name == "checkout_success" assert stored_event.properties == {"total": 42} assert str(stored_event.uuid) == event_uuid + + +@pytest.mark.django_db +def test_process_event_task_converts_date_timestamp_to_start_of_day(): + event_uuid = str(uuid4()) + event_date = date(2026, 1, 1) + + process_event_task.run( + { + "distinct_id": "user-789", + "event_name": "daily_summary", + "event_uuid": event_uuid, + "timestamp": event_date, + } + ) + + stored_event = Event.objects.get() + expected_timestamp = timezone.make_aware( + timezone.datetime.combine(event_date, timezone.datetime.min.time()), + timezone.get_current_timezone(), + ) + assert stored_event.timestamp == expected_timestamp + + +@pytest.mark.django_db +def test_process_event_task_rejects_non_datetime_isoformat_objects(): + class FakeTimestamp: + def isoformat(self) -> str: + return "2026-01-01" + + def __str__(self) -> str: + return self.isoformat() + + with pytest.raises( + ValueError, + match="timestamp must be a datetime, date, or ISO 8601 datetime string", + ): + process_event_task.run( + { + "distinct_id": "user-999", + "event_name": "invalid_timestamp", + "event_uuid": str(uuid4()), + "timestamp": FakeTimestamp(), + } + ) diff --git a/backend/test.sqlite3 b/backend/test.sqlite3 index cf3662639a50664747e31c0d797abf9a93294f1f..a052a602e5bbb855dc1d2e7145131870d9d5f26d 100644 GIT binary patch delta 762 zcmZoTz}awsbAmLZ$wV1vMw5*R;rg6Bsd**h%*@7&lMnI;Z@#Q=n83xzU&g>+#`l6R ze6ym0C*S1oc@yg!xw$zRWc3A&eH|T3i&KkqEiElG^U4x)GE?G9GILXlOA>Pns%wo5 zjLdWm41q|&z`zQOQd85+OjC_4Q&WwNObpTzEfbAQlZ_2cOe_q|EDcf(AXQSXE8rgY(&QV7=$K1j)B{4InGQPMpH#f1U601{EObmeb zrlzE&m>QdyS|%qN8JU}!rluMi8<`p>CMO|T2(r(@%Fsa1*wED2%vb}5l!>V+7Xt$W zBmZp%{@eW9__u8qOlanxJaN6I8zFylt1$aA5^@nw(}ryu7+D;EadMx5|33dd{*6Ez zD)^_j{b#fYP+*ZYW`xBC#Dyq9&y7$33?D2Ccp#3y#lU}ye*=Fj(CBjh>8<=s+HsmJ z`o@eH5sbwMZdDd_V@8y)$D#^q(Psw!&-~B$p93vA$v^$NJQKeL53?-X3*2xH(78}8 kFBte=@L%9x4Ae4}fBIs5CS3z@U~0u;2R9CJo<$D~09ORT+5i9m delta 671 zcmaixze^)Q6vt;Wf$Rp^hghUgi~;eKBe%0Vv)NrM7eTNP>{6P|?BqDe>5&s-=?@ZH zmmu3&nBE@*tkuriB8AOa`5)MNbLI}b!<_Yd@ALV-_ja!Ioh$ut9GmgPA~r87WmDJX z`eu$0hCA0IW4$}ms$_{#MzCc(8s%PLYT4+NkK?fsO-F_NXs{9l-@~u6Sl?)N>c=01 zBb=HOQJYiFxL*~a{H2d9mgJ8ad$Hr9pwhH zje4X!0E%- z5&ms@eUHo_d<#JzPNAjHO-Ndh`j{L*YR1!M)A;~@3qQ-XtuM0qx9km0AHkC%Ju1#E zB>j8}W_5!U@SnfShNhF;Ak=Sl7r_JEKv#_%LDDr-Mf&>PFPlt~0RP8*@`L^ajtr=n diff --git a/frontend/src/test/test-utils.tsx b/frontend/src/test/test-utils.tsx index 8b0b720..16ce6bc 100644 --- a/frontend/src/test/test-utils.tsx +++ b/frontend/src/test/test-utils.tsx @@ -1,16 +1,19 @@ -import type { PropsWithChildren, ReactElement } from 'react' +import { useState, type PropsWithChildren, type ReactElement } from 'react' import { render } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' function TestProviders({ children }: PropsWithChildren) { - const queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - refetchOnWindowFocus: false, - }, - }, - }) + const [queryClient] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + refetchOnWindowFocus: false, + }, + }, + }), + ) return {children} } diff --git a/sdk/telemetry_taco/client.py b/sdk/telemetry_taco/client.py index a36754c..d1742f0 100644 --- a/sdk/telemetry_taco/client.py +++ b/sdk/telemetry_taco/client.py @@ -4,9 +4,10 @@ import threading import time import urllib.error +import urllib.parse import urllib.request from dataclasses import dataclass -from datetime import datetime, timezone +from datetime import UTC, datetime from typing import Any, Literal from uuid import uuid4 @@ -34,6 +35,21 @@ def as_dict(self) -> dict[str, Any]: } +def _normalize_base_url(base_url: str) -> str: + normalized = base_url.strip() + if not normalized: + raise ValueError("base_url must be a non-empty http:// or https:// URL") + + if "://" not in normalized: + normalized = f"http://{normalized}" + + parsed = urllib.parse.urlparse(normalized) + if parsed.scheme.lower() not in {"http", "https"} or not parsed.netloc: + raise ValueError("base_url must be an absolute http:// or https:// URL") + + return normalized.rstrip("/") + + class TelemetryTaco: def __init__( self, @@ -47,7 +63,7 @@ def __init__( queue_full_policy: QueueFullPolicy = "drop_newest", _start_worker: bool = True, ): - self.base_url = base_url.rstrip("/") + self.base_url = _normalize_base_url(base_url) self.batch_url = f"{self.base_url}/api/capture/batch" self.batch_size = batch_size self.flush_interval = flush_interval @@ -56,8 +72,10 @@ def __init__( self.queue_full_policy = queue_full_policy self._queue: queue.Queue[QueuedEvent | object] = queue.Queue(maxsize=max_queue_size) + self._state_lock = threading.Lock() self._flush_requested = threading.Event() self._closed = False + self._closing = False self._worker = None if _start_worker: self._worker = threading.Thread( @@ -73,17 +91,18 @@ def capture( event_name: str, properties: dict[str, Any] | None = None, ) -> None: - if self._closed: - raise RuntimeError("TelemetryTaco client is closed") - - payload = QueuedEvent( - distinct_id=distinct_id, - event_name=event_name, - properties=properties or {}, - event_uuid=str(uuid4()), - sent_at=datetime.now(timezone.utc).isoformat(), - ) - self._enqueue(payload) + with self._state_lock: + if self._closed or self._closing: + raise RuntimeError("TelemetryTaco client is closed") + + payload = QueuedEvent( + distinct_id=distinct_id, + event_name=event_name, + properties=properties or {}, + event_uuid=str(uuid4()), + sent_at=datetime.now(UTC).isoformat(), + ) + self._enqueue(payload) def flush(self, timeout: float | None = None) -> None: deadline = None if timeout in (None, 0) else time.monotonic() + timeout @@ -99,14 +118,24 @@ def flush(self, timeout: float | None = None) -> None: self._flush_requested.clear() def close(self, timeout: float | None = None) -> None: - if self._closed: - return + with self._state_lock: + if self._closed or self._closing: + return + self._closing = True - self.flush(timeout=timeout) - self._closed = True - self._queue.put(_STOP) - if self._worker is not None: - self._worker.join(timeout=timeout) + try: + self.flush(timeout=timeout) + self._queue.put(_STOP) + if self._worker is not None: + self._worker.join(timeout=timeout) + except Exception: + with self._state_lock: + self._closing = False + raise + + with self._state_lock: + self._closed = True + self._closing = False def __enter__(self) -> "TelemetryTaco": return self @@ -123,15 +152,28 @@ def _enqueue(self, payload: QueuedEvent) -> None: self._queue.put_nowait(payload) except queue.Full: if self.queue_full_policy == "drop_oldest": - dropped = self._queue.get_nowait() - if dropped is not _STOP: - self._queue.task_done() - self._queue.put_nowait(payload) - logger.warning("TelemetryTaco queue full; dropped oldest event.") - return + if self._replace_oldest_queued_event(payload): + logger.warning("TelemetryTaco queue full; dropped oldest event.") + return + try: + self._queue.put_nowait(payload) + return + except queue.Full: + logger.warning("TelemetryTaco queue full; dropped newest event.") + return logger.warning("TelemetryTaco queue full; dropped newest event.") + def _replace_oldest_queued_event(self, payload: QueuedEvent) -> bool: + with self._queue.mutex: + pending_items = self._queue.queue + if not pending_items or any(item is _STOP for item in pending_items): + return False + + pending_items.popleft() + pending_items.append(payload) + return True + def _run_worker(self) -> None: batch: list[QueuedEvent] = [] last_flush = time.monotonic() @@ -193,6 +235,11 @@ def _flush_batch(self, batch: list[QueuedEvent]) -> None: try: self._send_batch(batch) + except Exception: + logger.exception( + "TelemetryTaco worker failed to send event batch; dropping %s event(s).", + len(batch), + ) finally: for _ in batch: self._queue.task_done() diff --git a/sdk/tests/test_client.py b/sdk/tests/test_client.py index 079f130..8185239 100644 --- a/sdk/tests/test_client.py +++ b/sdk/tests/test_client.py @@ -1,9 +1,10 @@ -from datetime import datetime, timezone import json +import urllib.request +from datetime import UTC, datetime from unittest.mock import patch from telemetry_taco import TelemetryTaco -from telemetry_taco.client import QueuedEvent +from telemetry_taco.client import _STOP, QueuedEvent class FakeResponse: @@ -69,6 +70,28 @@ def test_sdk_drop_oldest_policy_replaces_existing_item(): assert queued.event_uuid == "second" +def test_sdk_drop_oldest_policy_preserves_stop_sentinel(): + client = TelemetryTaco(max_queue_size=1, queue_full_policy="drop_oldest", _start_worker=False) + client._queue.put(_STOP) # type: ignore[arg-type] + + client._enqueue( # type: ignore[attr-defined] + QueuedEvent( + distinct_id="user-2", + event_name="signup_clicked", + properties={}, + event_uuid="second", + sent_at="2026-01-01T00:00:01+0000", + ) + ) + + assert client._queue.unfinished_tasks == 1 # type: ignore[attr-defined] + queued = client._queue.get_nowait() # type: ignore[attr-defined] + assert queued is _STOP + + client._queue.task_done() # type: ignore[attr-defined] + assert client._queue.unfinished_tasks == 0 # type: ignore[attr-defined] + + def test_sdk_drops_non_serializable_batch_without_killing_worker(): requests = [] @@ -78,7 +101,7 @@ def fake_urlopen(request, timeout): with patch("urllib.request.urlopen", side_effect=fake_urlopen): client = TelemetryTaco(flush_interval=60, batch_size=10) - client.capture("user-1", "page_view", {"captured_at": datetime.now(timezone.utc)}) + client.capture("user-1", "page_view", {"captured_at": datetime.now(UTC)}) client.flush(timeout=2) client.capture("user-2", "page_view", {"path": "/health"}) client.flush(timeout=2) @@ -87,3 +110,54 @@ def fake_urlopen(request, timeout): assert len(requests) == 1 payload = json.loads(requests[0][0].data.decode("utf-8")) assert payload["events"][0]["distinct_id"] == "user-2" + + +def test_sdk_normalizes_base_url_without_scheme(): + client = TelemetryTaco(base_url="localhost:8000", _start_worker=False) + + assert client.base_url == "http://localhost:8000" + assert client.batch_url == "http://localhost:8000/api/capture/batch" + + +def test_sdk_rejects_invalid_base_url(): + try: + TelemetryTaco(base_url="ftp://localhost:8000", _start_worker=False) + except ValueError as exc: + assert str(exc) == "base_url must be an absolute http:// or https:// URL" + else: + raise AssertionError("TelemetryTaco should reject invalid base URLs") + + +def test_sdk_drops_failed_request_batch_without_killing_worker(): + requests = [] + original_request = urllib.request.Request + should_fail = True + + def fake_request(*args, **kwargs): + nonlocal should_fail + if should_fail: + should_fail = False + raise ValueError("bad request") + return original_request(*args, **kwargs) + + def fake_urlopen(request, timeout): + requests.append((request, timeout)) + return FakeResponse() + + with ( + patch("urllib.request.Request", side_effect=fake_request), + patch( + "urllib.request.urlopen", + side_effect=fake_urlopen, + ), + ): + client = TelemetryTaco(flush_interval=60, batch_size=10) + client.capture("user-1", "page_view", {"path": "/broken"}) + client.flush(timeout=2) + client.capture("user-2", "page_view", {"path": "/healthy"}) + client.flush(timeout=2) + client.close(timeout=2) + + assert len(requests) == 1 + payload = json.loads(requests[0][0].data.decode("utf-8")) + assert payload["events"][0]["distinct_id"] == "user-2"