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..fcd4e0b 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": "YYYY-MM-DDTHH:MM:SSZ" } ``` -**Response**: `200 OK` +Response: ```json { @@ -505,232 +173,42 @@ 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=YYYY-MM-DDTHH:MM:SSZ,EVENT_ID` -See [`.cursor/rules/generalguidelines.mdc`](.cursor/rules/generalguidelines.mdc) for: +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. -- 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..fde3379 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,13 @@ +DEBUG=True +# 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 +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..546dd8f --- /dev/null +++ b/backend/core/api/events.py @@ -0,0 +1,97 @@ +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 + +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() + + +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: + 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: str | None = None): + if limit < 1: + raise HttpError(400, "limit must be greater than zero") + + return list_recent_events(limit=limit, before=_parse_before_cursor(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={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 new file mode 100644 index 0000000..49d9497 --- /dev/null +++ b/backend/core/api/schemas.py @@ -0,0 +1,53 @@ +from datetime import datetime +from typing import Any +from uuid import UUID + +from ninja import Schema +from pydantic import ConfigDict, Field + + +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(Schema): + model_config = ConfigDict(from_attributes=True) + + id: int + distinct_id: str + event_name: str + properties: dict[str, Any] + timestamp: datetime + uuid: str + created_at: datetime + + @staticmethod + def resolve_uuid(obj: Any) -> 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..d58a55f --- /dev/null +++ b/backend/core/selectors/events.py @@ -0,0 +1,56 @@ +from datetime import datetime, timedelta + +from django.conf import settings +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: 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: + 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]) + + +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..e19e7e2 --- /dev/null +++ b/backend/core/services/ingestion.py @@ -0,0 +1,66 @@ +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 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] + process_event_batch_task.delay([_serialize_event(event) for event in normalized]) + + 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..4d7f814 --- /dev/null +++ b/backend/core/tasks/events.py @@ -0,0 +1,137 @@ +from datetime import date, datetime, time +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 isinstance(raw_value, datetime): + if timezone.is_naive(raw_value): + return timezone.make_aware(raw_value, timezone.get_current_timezone()) + return 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") + + if timezone.is_naive(parsed): + return timezone.make_aware(parsed, timezone.get_current_timezone()) + + return parsed + + +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 = [_build_event(event_data) for event_data in events_data] + + Event.objects.bulk_create( + events_to_create, + batch_size=len(events_to_create), + 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": processed_count, + }, + ) + return processed_count + + +@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: + 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( + 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..c2e54ba --- /dev/null +++ b/backend/core/tests/test_api.py @@ -0,0 +1,251 @@ +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 + + +@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": f"{newest.timestamp.isoformat()},{newest.id}"}, + ) + + 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_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 + 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" + + +@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 new file mode 100644 index 0000000..d3a9e7a --- /dev/null +++ b/backend/core/tests/test_tasks.py @@ -0,0 +1,99 @@ +from datetime import date, 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, process_event_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 + + +@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 + + +@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/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/backend/test.sqlite3 b/backend/test.sqlite3 new file mode 100644 index 0000000..a052a60 Binary files /dev/null and b/backend/test.sqlite3 differ 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..77e77d5 --- /dev/null +++ b/frontend/openapi.json @@ -0,0 +1,384 @@ +{ + "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": [ + { + "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" + } + } + } + }, + "503": { + "description": "Service Unavailable", + "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": { + "id": { + "title": "Id", + "type": "integer" + }, + "distinct_id": { + "title": "Distinct Id", + "type": "string" + }, + "event_name": { + "title": "Event Name", + "type": "string" + }, + "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", + "type": "string" + } + }, + "required": [ + "id", + "distinct_id", + "event_name", + "properties", + "timestamp", + "uuid", + "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..41d052c --- /dev/null +++ b/frontend/src/shared/api/generated.ts @@ -0,0 +1,343 @@ +/** + * 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: { + /** Id */ + id: number; + /** Distinct Id */ + distinct_id: string; + /** Event Name */ + event_name: string; + /** Properties */ + properties: { + [key: string]: unknown; + }; + /** + * Timestamp + * Format: date-time + */ + timestamp: string; + /** Uuid */ + uuid: 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"]; + }; + }; + /** @description Service Unavailable */ + 503: { + 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..16ce6bc --- /dev/null +++ b/frontend/src/test/test-utils.tsx @@ -0,0 +1,23 @@ +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] = useState( + () => + 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..a0f0556 100644 --- a/package.json +++ b/package.json @@ -18,20 +18,24 @@ "seed:clean": "bash seed.sh --clean", "build": "pnpm --filter ./frontend build", "build:frontend": "pnpm --filter ./frontend build", + "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 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", + "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 run pytest", + "test:frontend": "pnpm --filter ./frontend test", + "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", - "validate:frontend": "pnpm --filter ./frontend lint && pnpm --filter ./frontend type-check", - "validate:all": "pnpm validate:backend && pnpm validate:frontend" + "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..ce8b23c --- /dev/null +++ b/sdk/pyproject.toml @@ -0,0 +1,20 @@ +[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.setuptools] +include-package-data = true + +[tool.setuptools.packages.find] +where = ["."] +include = ["telemetry_taco*"] + +[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..d1742f0 --- /dev/null +++ b/sdk/telemetry_taco/client.py @@ -0,0 +1,290 @@ +import json +import logging +import queue +import threading +import time +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass +from datetime import UTC, datetime +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, + } + + +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, + 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 = _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 + 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._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( + 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: + 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 + 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: + with self._state_lock: + if self._closed or self._closing: + return + self._closing = True + + 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 + + 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": + 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() + stop_requested = False + + while True: + try: + item = self._queue.get(timeout=0.1) + except queue.Empty: + item = None + + if item is _STOP: + self._queue.task_done() + 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: + self._flush_batch(batch) + 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 + + 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() + + def _send_batch(self, batch: list[QueuedEvent]) -> None: + payload = {"events": [event.as_dict() for event in batch]} + 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, + 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..8185239 --- /dev/null +++ b/sdk/tests/test_client.py @@ -0,0 +1,163 @@ +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 _STOP, 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" + + +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 = [] + + 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(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" + + +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" 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 ..