diff --git a/.env.example b/.env.example index 70a1bc83d..080e3e3fa 100644 --- a/.env.example +++ b/.env.example @@ -1,121 +1,134 @@ -# ═══════════════════════════════════════════════════════════ -# Environment Configuration -# Copy this file to .env and fill in your values -# ═══════════════════════════════════════════════════════════ - -# ─────────────────────────────────────────────────────────── -# Required: Core URLs -# ─────────────────────────────────────────────────────────── - -# Application URLs (must be valid URLs) -APP_URL=http://localhost:3000 -API_URL=http://localhost:8080 -GRAPHQL_URL=http://localhost:8080/graphql - -# ─────────────────────────────────────────────────────────── -# Required: Authentication -# ─────────────────────────────────────────────────────────── - -# Session secret (minimum 32 characters) -SESSION_SECRET=your-super-secret-session-key-min-32-chars - -# OIDC Authentication -AUTH_OIDC_ISSUER=https://your-oidc-provider.com -AUTH_OIDC_CLIENT_ID=your-client-id - -# ─────────────────────────────────────────────────────────── -# Required: Feature Services -# ─────────────────────────────────────────────────────────── - -# Prometheus metrics (for proxy metrics dashboard) -PROMETHEUS_URL=http://localhost:9090 - -# CloudValid DNS validation (for domain auto-verification) -CLOUDVALID_API_URL=https://api.cloudvalid.com/api/v2 -CLOUDVALID_API_KEY=your-cloudvalid-api-key -CLOUDVALID_TEMPLATE_ID=your-cloudvalid-template-id - -# ─────────────────────────────────────────────────────────── -# Optional: Runtime Configuration -# ─────────────────────────────────────────────────────────── - -# Environment mode: production | development | test -NODE_ENV=development - -# Application version (e.g., git commit SHA) -# VERSION= - -# Enable debug mode: true | false -# DEBUG=false - -# ─────────────────────────────────────────────────────────── -# Optional: Logging -# ─────────────────────────────────────────────────────────── - -# Log level: debug | info | warn | error -# LOG_LEVEL=debug - -# Log format: json | pretty | compact -# LOG_FORMAT=pretty - -# Generate curl commands for API calls: true | false -# LOG_CURL=true - -# Redact sensitive tokens in logs: true | false -# LOG_REDACT_TOKENS=true - -# Log request/response payloads: true | false -# LOG_PAYLOADS=false - -# ─────────────────────────────────────────────────────────── -# Optional: Observability (graceful degradation when missing) -# ─────────────────────────────────────────────────────────── - -# Sentry error tracking (must be valid URL if provided) -# SENTRY_DSN=https://xxx@sentry.io/xxx -# SENTRY_ENV=development - -# OpenTelemetry tracing (must be valid URL if provided) -# OTEL_ENABLED=false -# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 -# OTEL_EXPORTER_TIMEOUT=10000 -# OTEL_LOG_LEVEL=info - -# ─────────────────────────────────────────────────────────── -# Optional: External Integrations (graceful degradation) -# ─────────────────────────────────────────────────────────── - -# Grafana (must be valid URL if provided) -# GRAFANA_URL=http://localhost:3001 - -# ─────────────────────────────────────────────────────────── -# Optional: Analytics & Support (graceful degradation) -# ─────────────────────────────────────────────────────────── - -# Fathom Analytics -# FATHOM_ID= - -# HelpScout Beacon -# HELPSCOUT_BEACON_ID= -# HELPSCOUT_SECRET_KEY= - -# ─────────────────────────────────────────────────────────── -# Optional: Redis (falls back to in-memory rate limiting) -# ─────────────────────────────────────────────────────────── - -# Redis connection URL (must be valid URL if provided) -# REDIS_URL=redis://localhost:6379 - -# Redis connection settings (have defaults) -# REDIS_MAX_RETRIES=3 -# REDIS_CONNECT_TIMEOUT=5000 -# REDIS_COMMAND_TIMEOUT=3000 -# REDIS_KEY_PREFIX=cloud-portal: - -# ─────────────────────────────────────────────────────────── -# Optional: Chatbot -# ─────────────────────────────────────────────────────────── - -# Enable chatbot: true | false -# CHATBOT_ENABLED=false -# ANTHROPIC_API_KEY=your-anthropic-api-key \ No newline at end of file +# ═══════════════════════════════════════════════════════════ +# Environment Configuration +# Copy this file to .env and fill in your values +# ═══════════════════════════════════════════════════════════ + +# ─────────────────────────────────────────────────────────── +# Required: Core URLs +# ─────────────────────────────────────────────────────────── + +# Application URLs (must be valid URLs) +APP_URL=http://localhost:3000 +API_URL=http://localhost:8080 +GRAPHQL_URL=http://localhost:8080/graphql + +# ─────────────────────────────────────────────────────────── +# Required: Authentication +# ─────────────────────────────────────────────────────────── + +# Session secret (minimum 32 characters) +SESSION_SECRET=your-super-secret-session-key-min-32-chars + +# OIDC Authentication +AUTH_OIDC_ISSUER=https://your-oidc-provider.com +AUTH_OIDC_CLIENT_ID=your-client-id + +# ─────────────────────────────────────────────────────────── +# Required: Feature Services +# ─────────────────────────────────────────────────────────── + +# Prometheus metrics (for proxy metrics dashboard) +PROMETHEUS_URL=http://localhost:9090 + +# CloudValid DNS validation (for domain auto-verification) +CLOUDVALID_API_URL=https://api.cloudvalid.com/api/v2 +CLOUDVALID_API_KEY=your-cloudvalid-api-key +CLOUDVALID_TEMPLATE_ID=your-cloudvalid-template-id + +# ─────────────────────────────────────────────────────────── +# Optional: Runtime Configuration +# ─────────────────────────────────────────────────────────── + +# Environment mode: production | development | test +NODE_ENV=development + +# Application version (e.g., git commit SHA) +# VERSION= + +# Enable debug mode: true | false +# DEBUG=false + +# ─────────────────────────────────────────────────────────── +# Optional: Logging +# ─────────────────────────────────────────────────────────── + +# Log level: debug | info | warn | error +# LOG_LEVEL=debug + +# Log format: json | pretty | compact +# LOG_FORMAT=pretty + +# Generate curl commands for API calls: true | false +# LOG_CURL=true + +# Redact sensitive tokens in logs: true | false +# LOG_REDACT_TOKENS=true + +# Log request/response payloads: true | false +# LOG_PAYLOADS=false + +# ─────────────────────────────────────────────────────────── +# Optional: Observability (graceful degradation when missing) +# ─────────────────────────────────────────────────────────── + +# Sentry error tracking (must be valid URL if provided) +# SENTRY_DSN=https://xxx@sentry.io/xxx +# SENTRY_ENV=development + +# OpenTelemetry tracing (must be valid URL if provided) +# OTEL_ENABLED=false +# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 +# OTEL_EXPORTER_TIMEOUT=10000 +# OTEL_LOG_LEVEL=info + +# ─────────────────────────────────────────────────────────── +# Optional: External Integrations (graceful degradation) +# ─────────────────────────────────────────────────────────── + +# Grafana (must be valid URL if provided) +# GRAFANA_URL=http://localhost:3001 + +# ─────────────────────────────────────────────────────────── +# Optional: Analytics & Support (graceful degradation) +# ─────────────────────────────────────────────────────────── + +# Fathom Analytics +# FATHOM_ID= + +# HelpScout Beacon +# HELPSCOUT_BEACON_ID= +# HELPSCOUT_SECRET_KEY= + +# ─────────────────────────────────────────────────────────── +# Optional: Redis (falls back to in-memory rate limiting) +# ─────────────────────────────────────────────────────────── + +# Redis connection URL (must be valid URL if provided) +# REDIS_URL=redis://localhost:6379 + +# Redis connection settings (have defaults) +# REDIS_MAX_RETRIES=3 +# REDIS_CONNECT_TIMEOUT=5000 +# REDIS_COMMAND_TIMEOUT=3000 +# REDIS_KEY_PREFIX=cloud-portal: + +# ─────────────────────────────────────────────────────────── +# Optional: Chatbot +# ─────────────────────────────────────────────────────────── + +# Enable chatbot: true | false +# CHATBOT_ENABLED=false +# ANTHROPIC_API_KEY=your-anthropic-api-key + +# ─────────────────────────────────────────────────────────── +# Optional: Embedded datumctl terminal (Developer Tools) +# ─────────────────────────────────────────────────────────── + +# Absolute path to a locally-built datumctl binary that supports +# ambient-token mode (DATUMCTL_TOKEN / DATUM_API_HOSTNAME). +# For local development, clone datum-cloud/datumctl as a sibling +# of cloud-portal, check out the cloud-portal/ambient-token branch, +# and run `go build -o bin/datumctl .`, then set: +# DATUMCTL_BIN=/Users/matthewjenkinson/Git/datum/datumctl/bin/datumctl +# When unset, the Developer Tools terminal is disabled. +# DATUMCTL_BIN= \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0a0f9869b..c027a14ae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,370 +1,370 @@ -name: CI - -on: - pull_request: - push: - branches: [main] - paths-ignore: - - 'docs/**' - - 'README.md' - release: - types: ['published'] - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -env: - CYPRESS_CACHE_FOLDER: ~/.cache/Cypress - BUN_CACHE: ~/.bun/install/cache - -jobs: - # ─── Quality ──────────────────────────────────────────────── - install: - name: Install Dependencies - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: oven-sh/setup-bun@v2 - with: { bun-version: latest } - - uses: actions/cache@v5 - id: cache-deps - with: - path: | - ${{ env.BUN_CACHE }} - **/node_modules - key: ${{ runner.os }}-deps-${{ hashFiles('**/bun.lock', '**/bun.lockb', '**/package.json') }} - - if: steps.cache-deps.outputs.cache-hit != 'true' - run: bun install --frozen-lockfile - - lint: - name: Lint & Format - needs: install - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: oven-sh/setup-bun@v2 - - uses: actions/cache@v5 - with: - path: | - ${{ env.BUN_CACHE }} - **/node_modules - key: ${{ runner.os }}-deps-${{ hashFiles('**/bun.lock', '**/bun.lockb', '**/package.json') }} - - run: bun run lint && bun run format - - typecheck: - name: Type Check - needs: install - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: oven-sh/setup-bun@v2 - with: { bun-version: latest } - - uses: actions/cache@v5 - with: - path: | - ${{ env.BUN_CACHE }} - **/node_modules - key: ${{ runner.os }}-deps-${{ hashFiles('**/bun.lock', '**/bun.lockb', '**/package.json') }} - - run: bun typecheck - - build: - name: Build - needs: [lint, typecheck] - runs-on: ubuntu-latest - if: ${{ !failure() && !cancelled() }} - steps: - - uses: actions/checkout@v6 - - uses: oven-sh/setup-bun@v2 - with: { bun-version: latest } - - uses: actions/cache@v5 - with: - path: | - ${{ env.BUN_CACHE }} - **/node_modules - key: ${{ runner.os }}-deps-${{ hashFiles('**/bun.lock', '**/bun.lockb', '**/package.json') }} - - run: bun run build - - uses: actions/cache@v5 - with: - path: build - key: build-${{ github.sha }} - - # ─── Tests ────────────────────────────────────────────────── - unit-tests: - name: Unit Tests - needs: build - runs-on: ubuntu-latest - if: ${{ !failure() && !cancelled() }} - steps: - - uses: actions/checkout@v6 - - uses: oven-sh/setup-bun@v2 - with: { bun-version: latest } - - uses: actions/cache@v5 - with: - path: | - ${{ env.BUN_CACHE }} - **/node_modules - key: ${{ runner.os }}-deps-${{ hashFiles('**/bun.lock', '**/bun.lockb', '**/package.json') }} - - run: bunx cypress install && bunx cypress verify - - run: bun run test:unit:prod - env: - NODE_ENV: test - CYPRESS: 'true' - - e2e-smoke: - name: E2E Smoke - needs: build - runs-on: ubuntu-latest - timeout-minutes: 10 - if: ${{ !failure() && !cancelled() }} - steps: - - uses: actions/checkout@v6 - - uses: oven-sh/setup-bun@v2 - with: { bun-version: latest } - - uses: actions/cache@v5 - with: - path: | - ${{ env.BUN_CACHE }} - **/node_modules - key: ${{ runner.os }}-deps-${{ hashFiles('**/bun.lock', '**/bun.lockb', '**/package.json') }} - - uses: actions/cache@v5 - with: - path: build - key: build-${{ github.sha }} - - name: Setup Environment - env: - SESSION_SECRET: ${{ secrets.SESSION_SECRET }} - ACCESS_TOKEN: ${{ secrets.ACCESS_TOKEN }} - SUB: ${{ secrets.SUB }} - API_URL: ${{ secrets.API_URL }} - GRAPHQL_URL: ${{ secrets.GRAPHQL_URL }} - AUTH_OIDC_ISSUER: ${{ secrets.AUTH_OIDC_ISSUER }} - AUTH_OIDC_CLIENT_ID: ${{ secrets.AUTH_OIDC_CLIENT_ID }} - run: | - cat > .env << EOF - NODE_ENV=test - CYPRESS=true - APP_URL=http://localhost:3000 - API_URL=$API_URL - GRAPHQL_URL=$GRAPHQL_URL - AUTH_OIDC_ISSUER=$AUTH_OIDC_ISSUER - AUTH_OIDC_CLIENT_ID=$AUTH_OIDC_CLIENT_ID - SESSION_SECRET=$SESSION_SECRET - LOG_LEVEL=warn - CYPRESS_BASE_URL=http://localhost:3000 - EOF - - run: bunx cypress install && bunx cypress verify - - name: Run Smoke Tests - env: - NODE_ENV: test - CYPRESS: 'true' - CYPRESS_BASE_URL: http://localhost:3000 - SESSION_SECRET: ${{ secrets.SESSION_SECRET }} - ACCESS_TOKEN: ${{ secrets.ACCESS_TOKEN }} - SUB: ${{ secrets.SUB }} - run: >- - bunx start-server-and-test - 'bun run start' http://localhost:3000/_healthz - 'bunx cypress run --spec "cypress/e2e/smoke/**" --config-file cypress.config.ts' - - if: failure() - uses: actions/upload-artifact@v7 - with: - name: e2e-smoke-artifacts-${{ github.run_id }} - path: | - cypress/videos - cypress/screenshots - retention-days: 7 - if-no-files-found: warn - - e2e-regression: - name: E2E Regression - needs: e2e-smoke - runs-on: ubuntu-latest - timeout-minutes: 20 - if: ${{ !failure() && !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/main' }} - # if: ${{ !failure() && !cancelled() }} - strategy: - fail-fast: false - matrix: - shard: [0, 1, 2, 3] - steps: - - uses: actions/checkout@v6 - - uses: oven-sh/setup-bun@v2 - with: { bun-version: latest } - - uses: actions/cache@v5 - with: - path: | - ${{ env.BUN_CACHE }} - **/node_modules - key: ${{ runner.os }}-deps-${{ hashFiles('**/bun.lock', '**/bun.lockb', '**/package.json') }} - - uses: actions/cache@v5 - with: - path: build - key: build-${{ github.sha }} - - name: Setup Environment - env: - SESSION_SECRET: ${{ secrets.SESSION_SECRET }} - ACCESS_TOKEN: ${{ secrets.ACCESS_TOKEN }} - SUB: ${{ secrets.SUB }} - API_URL: ${{ secrets.API_URL }} - GRAPHQL_URL: ${{ secrets.GRAPHQL_URL }} - AUTH_OIDC_ISSUER: ${{ secrets.AUTH_OIDC_ISSUER }} - AUTH_OIDC_CLIENT_ID: ${{ secrets.AUTH_OIDC_CLIENT_ID }} - run: | - cat > .env << EOF - NODE_ENV=test - CYPRESS=true - APP_URL=http://localhost:3000 - API_URL=$API_URL - GRAPHQL_URL=$GRAPHQL_URL - AUTH_OIDC_ISSUER=$AUTH_OIDC_ISSUER - AUTH_OIDC_CLIENT_ID=$AUTH_OIDC_CLIENT_ID - SESSION_SECRET=$SESSION_SECRET - LOG_LEVEL=warn - CYPRESS_BASE_URL=http://localhost:3000 - EOF - - run: bunx cypress install && bunx cypress verify - - name: Run Regression Tests - env: - NODE_ENV: test - CYPRESS: 'true' - CYPRESS_BASE_URL: http://localhost:3000 - SESSION_SECRET: ${{ secrets.SESSION_SECRET }} - ACCESS_TOKEN: ${{ secrets.ACCESS_TOKEN }} - SUB: ${{ secrets.SUB }} - SPLIT: ${{ strategy.job-total }} - SPLIT_INDEX: ${{ strategy.job-index }} - CYPRESS_specPattern: 'cypress/e2e/regression/**/*.{cy,spec}.{js,jsx,ts,tsx}' - run: >- - bunx start-server-and-test - 'bun run start' http://localhost:3000/_healthz - 'bunx cypress run --config-file cypress.config.ts' - - if: failure() - uses: actions/upload-artifact@v7 - with: - name: e2e-regression-artifacts-${{ github.run_id }}-shard-${{ matrix.shard }} - path: | - cypress/videos - cypress/screenshots - retention-days: 7 - if-no-files-found: warn - - # ─── Status Gate ──────────────────────────────────────────── - status-check: - name: Final Status Check - needs: [install, lint, typecheck, build, unit-tests, e2e-smoke, e2e-regression] - if: always() - runs-on: ubuntu-latest - steps: - - name: Check Workflow Status - run: | - if [[ "${{ contains(needs.*.result, 'failure') }}" == "true" ]]; then - echo "❌ One or more jobs failed" - exit 1 - elif [[ "${{ contains(needs.*.result, 'cancelled') }}" == "true" ]]; then - echo "⚠️ One or more jobs were cancelled" - exit 1 - else - echo "✅ All jobs passed" - fi - - # ─── Publish (only after all checks pass) ────────────────── - publish-container-image: - name: Publish Container Image - needs: [status-check] - if: ${{ !failure() && !cancelled() }} - permissions: - id-token: write - contents: read - packages: write - attestations: write - uses: datum-cloud/actions/.github/workflows/publish-docker.yaml@v1.13.3 - with: - image-name: cloud-portal - secrets: inherit - - publish-kustomize-bundles: - name: Publish Kustomize Bundles - needs: [publish-container-image] - if: ${{ !failure() && !cancelled() }} - permissions: - id-token: write - contents: read - packages: write - uses: datum-cloud/actions/.github/workflows/publish-kustomize-bundle.yaml@v1.13.3 - with: - bundle-name: ghcr.io/datum-cloud/cloud-portal-kustomize - bundle-path: config - image-overlays: config/base - image-name: ghcr.io/datum-cloud/cloud-portal - secrets: inherit - - # ─── PR Comment ───────────────────────────────────────────── - pr-comment: - name: PR Test Summary - needs: [unit-tests, e2e-smoke] - runs-on: ubuntu-latest - if: github.event_name == 'pull_request' - permissions: - contents: read - pull-requests: write - actions: read - steps: - - uses: actions/checkout@v6 - - name: Build test summary - uses: actions/github-script@v9 - with: - script: | - const fs = require('fs'); - const runId = context.runId; - const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - - const { data: jobs } = await github.rest.actions.listJobsForWorkflowRun({ - owner: context.repo.owner, - repo: context.repo.repo, - run_id: runId, - }); - - const testJobs = jobs.jobs.filter(j => - j.name === 'Unit Tests' || j.name.startsWith('E2E') - ); - - const statusEmoji = (conclusion) => { - if (!conclusion || conclusion === 'skipped') return '⏭️'; - if (conclusion === 'success') return '✅'; - if (conclusion === 'failure') return '❌'; - if (conclusion === 'cancelled') return '🚫'; - return '❓'; - }; - - let table = '| Job | Status |\n|-----|--------|\n'; - for (const job of testJobs.sort((a, b) => a.name.localeCompare(b.name))) { - table += `| ${job.name} | ${statusEmoji(job.conclusion)} ${job.conclusion || 'pending'} |\n`; - } - - const { data: artifacts } = await github.rest.actions.listWorkflowRunArtifacts({ - owner: context.repo.owner, - repo: context.repo.repo, - run_id: runId, - }); - - let body = `## 🧪 Test Summary\n\n${table}\n\n`; - body += `[View workflow run](${runUrl})`; - body += `\n\n### 📎 Artifacts\n\n`; - if (artifacts.artifacts.length > 0) { - body += `Videos and screenshots from failed E2E tests:\n\n`; - for (const a of artifacts.artifacts) { - body += `- **${a.name}** – [Download](${runUrl})\n`; - } - } else { - body += `No artifacts (all tests passed).\n`; - } - - fs.writeFileSync('pr-summary.md', body); - - - uses: peter-evans/create-or-update-comment@v5 - with: - issue-number: ${{ github.event.pull_request.number }} - body-path: pr-summary.md - comment-tag: ci-summary - edit-mode: replace +name: CI + +on: + pull_request: + push: + branches: [main] + paths-ignore: + - 'docs/**' + - 'README.md' + release: + types: ['published'] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + CYPRESS_CACHE_FOLDER: ~/.cache/Cypress + BUN_CACHE: ~/.bun/install/cache + +jobs: + # ─── Quality ──────────────────────────────────────────────── + install: + name: Install Dependencies + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: oven-sh/setup-bun@v2 + with: { bun-version: latest } + - uses: actions/cache@v5 + id: cache-deps + with: + path: | + ${{ env.BUN_CACHE }} + **/node_modules + key: ${{ runner.os }}-deps-${{ hashFiles('**/bun.lock', '**/bun.lockb', '**/package.json') }} + - if: steps.cache-deps.outputs.cache-hit != 'true' + run: bun install --frozen-lockfile + + lint: + name: Lint & Format + needs: install + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: oven-sh/setup-bun@v2 + - uses: actions/cache@v5 + with: + path: | + ${{ env.BUN_CACHE }} + **/node_modules + key: ${{ runner.os }}-deps-${{ hashFiles('**/bun.lock', '**/bun.lockb', '**/package.json') }} + - run: bun run lint && bun run format + + typecheck: + name: Type Check + needs: install + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: oven-sh/setup-bun@v2 + with: { bun-version: latest } + - uses: actions/cache@v5 + with: + path: | + ${{ env.BUN_CACHE }} + **/node_modules + key: ${{ runner.os }}-deps-${{ hashFiles('**/bun.lock', '**/bun.lockb', '**/package.json') }} + - run: bun typecheck + + build: + name: Build + needs: [lint, typecheck] + runs-on: ubuntu-latest + if: ${{ !failure() && !cancelled() }} + steps: + - uses: actions/checkout@v6 + - uses: oven-sh/setup-bun@v2 + with: { bun-version: latest } + - uses: actions/cache@v5 + with: + path: | + ${{ env.BUN_CACHE }} + **/node_modules + key: ${{ runner.os }}-deps-${{ hashFiles('**/bun.lock', '**/bun.lockb', '**/package.json') }} + - run: bun run build + - uses: actions/cache@v5 + with: + path: build + key: build-${{ github.sha }} + + # ─── Tests ────────────────────────────────────────────────── + unit-tests: + name: Unit Tests + needs: build + runs-on: ubuntu-latest + if: ${{ !failure() && !cancelled() }} + steps: + - uses: actions/checkout@v6 + - uses: oven-sh/setup-bun@v2 + with: { bun-version: latest } + - uses: actions/cache@v5 + with: + path: | + ${{ env.BUN_CACHE }} + **/node_modules + key: ${{ runner.os }}-deps-${{ hashFiles('**/bun.lock', '**/bun.lockb', '**/package.json') }} + - run: bunx cypress install && bunx cypress verify + - run: bun run test:unit:prod + env: + NODE_ENV: test + CYPRESS: 'true' + + e2e-smoke: + name: E2E Smoke + needs: build + runs-on: ubuntu-latest + timeout-minutes: 10 + if: ${{ !failure() && !cancelled() }} + steps: + - uses: actions/checkout@v6 + - uses: oven-sh/setup-bun@v2 + with: { bun-version: latest } + - uses: actions/cache@v5 + with: + path: | + ${{ env.BUN_CACHE }} + **/node_modules + key: ${{ runner.os }}-deps-${{ hashFiles('**/bun.lock', '**/bun.lockb', '**/package.json') }} + - uses: actions/cache@v5 + with: + path: build + key: build-${{ github.sha }} + - name: Setup Environment + env: + SESSION_SECRET: ${{ secrets.SESSION_SECRET }} + ACCESS_TOKEN: ${{ secrets.ACCESS_TOKEN }} + SUB: ${{ secrets.SUB }} + API_URL: ${{ secrets.API_URL }} + GRAPHQL_URL: ${{ secrets.GRAPHQL_URL }} + AUTH_OIDC_ISSUER: ${{ secrets.AUTH_OIDC_ISSUER }} + AUTH_OIDC_CLIENT_ID: ${{ secrets.AUTH_OIDC_CLIENT_ID }} + run: | + cat > .env << EOF + NODE_ENV=test + CYPRESS=true + APP_URL=http://localhost:3000 + API_URL=$API_URL + GRAPHQL_URL=$GRAPHQL_URL + AUTH_OIDC_ISSUER=$AUTH_OIDC_ISSUER + AUTH_OIDC_CLIENT_ID=$AUTH_OIDC_CLIENT_ID + SESSION_SECRET=$SESSION_SECRET + LOG_LEVEL=warn + CYPRESS_BASE_URL=http://localhost:3000 + EOF + - run: bunx cypress install && bunx cypress verify + - name: Run Smoke Tests + env: + NODE_ENV: test + CYPRESS: 'true' + CYPRESS_BASE_URL: http://localhost:3000 + SESSION_SECRET: ${{ secrets.SESSION_SECRET }} + ACCESS_TOKEN: ${{ secrets.ACCESS_TOKEN }} + SUB: ${{ secrets.SUB }} + run: >- + bunx start-server-and-test + 'bun run start' http://localhost:3000/_healthz + 'bunx cypress run --spec "cypress/e2e/smoke/**" --config-file cypress.config.ts' + - if: failure() + uses: actions/upload-artifact@v7 + with: + name: e2e-smoke-artifacts-${{ github.run_id }} + path: | + cypress/videos + cypress/screenshots + retention-days: 7 + if-no-files-found: warn + + e2e-regression: + name: E2E Regression + needs: e2e-smoke + runs-on: ubuntu-latest + timeout-minutes: 20 + if: ${{ !failure() && !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/main' }} + # if: ${{ !failure() && !cancelled() }} + strategy: + fail-fast: false + matrix: + shard: [0, 1, 2, 3] + steps: + - uses: actions/checkout@v6 + - uses: oven-sh/setup-bun@v2 + with: { bun-version: latest } + - uses: actions/cache@v5 + with: + path: | + ${{ env.BUN_CACHE }} + **/node_modules + key: ${{ runner.os }}-deps-${{ hashFiles('**/bun.lock', '**/bun.lockb', '**/package.json') }} + - uses: actions/cache@v5 + with: + path: build + key: build-${{ github.sha }} + - name: Setup Environment + env: + SESSION_SECRET: ${{ secrets.SESSION_SECRET }} + ACCESS_TOKEN: ${{ secrets.ACCESS_TOKEN }} + SUB: ${{ secrets.SUB }} + API_URL: ${{ secrets.API_URL }} + GRAPHQL_URL: ${{ secrets.GRAPHQL_URL }} + AUTH_OIDC_ISSUER: ${{ secrets.AUTH_OIDC_ISSUER }} + AUTH_OIDC_CLIENT_ID: ${{ secrets.AUTH_OIDC_CLIENT_ID }} + run: | + cat > .env << EOF + NODE_ENV=test + CYPRESS=true + APP_URL=http://localhost:3000 + API_URL=$API_URL + GRAPHQL_URL=$GRAPHQL_URL + AUTH_OIDC_ISSUER=$AUTH_OIDC_ISSUER + AUTH_OIDC_CLIENT_ID=$AUTH_OIDC_CLIENT_ID + SESSION_SECRET=$SESSION_SECRET + LOG_LEVEL=warn + CYPRESS_BASE_URL=http://localhost:3000 + EOF + - run: bunx cypress install && bunx cypress verify + - name: Run Regression Tests + env: + NODE_ENV: test + CYPRESS: 'true' + CYPRESS_BASE_URL: http://localhost:3000 + SESSION_SECRET: ${{ secrets.SESSION_SECRET }} + ACCESS_TOKEN: ${{ secrets.ACCESS_TOKEN }} + SUB: ${{ secrets.SUB }} + SPLIT: ${{ strategy.job-total }} + SPLIT_INDEX: ${{ strategy.job-index }} + CYPRESS_specPattern: 'cypress/e2e/regression/**/*.{cy,spec}.{js,jsx,ts,tsx}' + run: >- + bunx start-server-and-test + 'bun run start' http://localhost:3000/_healthz + 'bunx cypress run --config-file cypress.config.ts' + - if: failure() + uses: actions/upload-artifact@v7 + with: + name: e2e-regression-artifacts-${{ github.run_id }}-shard-${{ matrix.shard }} + path: | + cypress/videos + cypress/screenshots + retention-days: 7 + if-no-files-found: warn + + # ─── Status Gate ──────────────────────────────────────────── + status-check: + name: Final Status Check + needs: [install, lint, typecheck, build, unit-tests, e2e-smoke, e2e-regression] + if: always() + runs-on: ubuntu-latest + steps: + - name: Check Workflow Status + run: | + if [[ "${{ contains(needs.*.result, 'failure') }}" == "true" ]]; then + echo "❌ One or more jobs failed" + exit 1 + elif [[ "${{ contains(needs.*.result, 'cancelled') }}" == "true" ]]; then + echo "⚠️ One or more jobs were cancelled" + exit 1 + else + echo "✅ All jobs passed" + fi + + # ─── Publish (only after all checks pass) ────────────────── + publish-container-image: + name: Publish Container Image + needs: [status-check] + if: ${{ !failure() && !cancelled() }} + permissions: + id-token: write + contents: read + packages: write + attestations: write + uses: datum-cloud/actions/.github/workflows/publish-docker.yaml@v1.13.3 + with: + image-name: cloud-portal + secrets: inherit + + publish-kustomize-bundles: + name: Publish Kustomize Bundles + needs: [publish-container-image] + if: ${{ !failure() && !cancelled() }} + permissions: + id-token: write + contents: read + packages: write + uses: datum-cloud/actions/.github/workflows/publish-kustomize-bundle.yaml@v1.13.3 + with: + bundle-name: ghcr.io/datum-cloud/cloud-portal-kustomize + bundle-path: config + image-overlays: config/base + image-name: ghcr.io/datum-cloud/cloud-portal + secrets: inherit + + # ─── PR Comment ───────────────────────────────────────────── + pr-comment: + name: PR Test Summary + needs: [unit-tests, e2e-smoke] + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + permissions: + contents: read + pull-requests: write + actions: read + steps: + - uses: actions/checkout@v6 + - name: Build test summary + uses: actions/github-script@v9 + with: + script: | + const fs = require('fs'); + const runId = context.runId; + const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; + + const { data: jobs } = await github.rest.actions.listJobsForWorkflowRun({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: runId, + }); + + const testJobs = jobs.jobs.filter(j => + j.name === 'Unit Tests' || j.name.startsWith('E2E') + ); + + const statusEmoji = (conclusion) => { + if (!conclusion || conclusion === 'skipped') return '⏭️'; + if (conclusion === 'success') return '✅'; + if (conclusion === 'failure') return '❌'; + if (conclusion === 'cancelled') return '🚫'; + return '❓'; + }; + + let table = '| Job | Status |\n|-----|--------|\n'; + for (const job of testJobs.sort((a, b) => a.name.localeCompare(b.name))) { + table += `| ${job.name} | ${statusEmoji(job.conclusion)} ${job.conclusion || 'pending'} |\n`; + } + + const { data: artifacts } = await github.rest.actions.listWorkflowRunArtifacts({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: runId, + }); + + let body = `## 🧪 Test Summary\n\n${table}\n\n`; + body += `[View workflow run](${runUrl})`; + body += `\n\n### 📎 Artifacts\n\n`; + if (artifacts.artifacts.length > 0) { + body += `Videos and screenshots from failed E2E tests:\n\n`; + for (const a of artifacts.artifacts) { + body += `- **${a.name}** – [Download](${runUrl})\n`; + } + } else { + body += `No artifacts (all tests passed).\n`; + } + + fs.writeFileSync('pr-summary.md', body); + + - uses: peter-evans/create-or-update-comment@v5 + with: + issue-number: ${{ github.event.pull_request.number }} + body-path: pr-summary.md + comment-tag: ci-summary + edit-mode: replace diff --git a/.github/workflows/set-project-month.yaml b/.github/workflows/set-project-month.yaml index e6d62d26b..7b99cd2d8 100644 --- a/.github/workflows/set-project-month.yaml +++ b/.github/workflows/set-project-month.yaml @@ -1,10 +1,10 @@ -name: Set Project Month - -on: - pull_request: - types: [opened, ready_for_review, reopened] - -jobs: - set-month: - uses: datum-cloud/actions/.github/workflows/set-project-month.yaml@v1.13.2 - secrets: inherit +name: Set Project Month + +on: + pull_request: + types: [opened, ready_for_review, reopened] + +jobs: + set-month: + uses: datum-cloud/actions/.github/workflows/set-project-month.yaml@v1.13.2 + secrets: inherit diff --git a/.gitignore b/.gitignore index e7a147304..8db9ec831 100644 --- a/.gitignore +++ b/.gitignore @@ -1,31 +1,32 @@ -node_modules - -/.cache -/build +node_modules + +/.cache +/build +.DS_Store +.react-router/ + +# AI assistant files +.cursor/ +.windsurf/ +.claude/ +!.claude/settings.json +CLAUDE.MD +.superpowers/ +docs/plans/ +docs/superpowers/ + +# OpenAPI generation temp files +temp-openapi-spec.json + +# GraphQL generation temp files +temp-filtered-schema.graphql + +# Cypress files +cypress/screenshots +cypress/videos +cypress/results + +# Playwright MCP +.playwright-mcp/ .env -.DS_Store -.react-router/ - -# AI assistant files -.cursor/ -.windsurf/ -.claude/ -!.claude/settings.json -CLAUDE.MD -.superpowers/ -docs/plans/ -docs/superpowers/ - -# OpenAPI generation temp files -temp-openapi-spec.json - -# GraphQL generation temp files -temp-filtered-schema.graphql - -# Cypress files -cypress/screenshots -cypress/videos -cypress/results - -# Playwright MCP -.playwright-mcp/ +.env.local diff --git a/.tool-versions b/.tool-versions index ea5d138f0..69c0c1a9d 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,2 @@ -nodejs 24.14.1 -bun 1.3.12 +nodejs 24.14.1 +bun 1.3.12 diff --git a/Dockerfile b/Dockerfile index 83a9b5f5b..2ba599cb4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,71 +1,71 @@ -# ========================================== -# BASE STAGE - Common dependencies and setup -# ========================================== - -# syntax = docker/dockerfile:1 - -# Use the latest Bun version -FROM oven/bun:1.3.12 AS base - -# Install system dependencies and clean up in the same layer -RUN apt-get update && \ - apt-get install -y --no-install-recommends unzip ca-certificates && \ - update-ca-certificates && \ - rm -rf /var/lib/apt/lists/* && \ - apt-get clean - -# Remix app lives here -WORKDIR /app - -# Set production environment -ENV NODE_ENV=production \ - PORT=3000 - -# ========================================== -# BUILD STAGE - Compile and prepare the app -# ========================================== -FROM base AS build - -ARG SENTRY_AUTH_TOKEN -ARG VERSION=dev - -# Set environment variables -ENV VERSION=${VERSION} -ENV SENTRY_AUTH_TOKEN=${SENTRY_AUTH_TOKEN} - -# Install dependencies -COPY --link package.json bun.lock ./ -RUN bun install --frozen-lockfile - -# Copy application code -COPY --link . . - -# Build application -RUN bun run build && \ - bun install --production --frozen-lockfile && \ - touch .env - -# ========================================== -# PRODUCTION STAGE - Final lightweight image -# ========================================== -FROM base - -# Accept VERSION as build argument and set as environment variable -ARG VERSION=dev -ENV VERSION=${VERSION} - -# Copy only necessary files from build stage -COPY --from=build /app /app - -# Start the server by default, this can be overwritten at runtime -EXPOSE ${PORT} - -# Use non-root user for better security -RUN groupadd --gid 1001 datum && \ - useradd --uid 1001 --gid 1001 --no-create-home datum && \ - chown -R datum:datum /app - -USER datum - -# Use the start script from package.json -CMD [ "bun", "run", "start" ] +# ========================================== +# BASE STAGE - Common dependencies and setup +# ========================================== + +# syntax = docker/dockerfile:1 + +# Use the latest Bun version +FROM oven/bun:1.3.12 AS base + +# Install system dependencies and clean up in the same layer +RUN apt-get update && \ + apt-get install -y --no-install-recommends unzip ca-certificates && \ + update-ca-certificates && \ + rm -rf /var/lib/apt/lists/* && \ + apt-get clean + +# Remix app lives here +WORKDIR /app + +# Set production environment +ENV NODE_ENV=production \ + PORT=3000 + +# ========================================== +# BUILD STAGE - Compile and prepare the app +# ========================================== +FROM base AS build + +ARG SENTRY_AUTH_TOKEN +ARG VERSION=dev + +# Set environment variables +ENV VERSION=${VERSION} +ENV SENTRY_AUTH_TOKEN=${SENTRY_AUTH_TOKEN} + +# Install dependencies +COPY --link package.json bun.lock ./ +RUN bun install --frozen-lockfile + +# Copy application code +COPY --link . . + +# Build application +RUN bun run build && \ + bun install --production --frozen-lockfile && \ + touch .env + +# ========================================== +# PRODUCTION STAGE - Final lightweight image +# ========================================== +FROM base + +# Accept VERSION as build argument and set as environment variable +ARG VERSION=dev +ENV VERSION=${VERSION} + +# Copy only necessary files from build stage +COPY --from=build /app /app + +# Start the server by default, this can be overwritten at runtime +EXPOSE ${PORT} + +# Use non-root user for better security +RUN groupadd --gid 1001 datum && \ + useradd --uid 1001 --gid 1001 --no-create-home datum && \ + chown -R datum:datum /app + +USER datum + +# Use the start script from package.json +CMD [ "bun", "run", "start" ] diff --git a/app/components/avatar-stack/avatar-stack.tsx b/app/components/avatar-stack/avatar-stack.tsx index 0e42f98f4..1b315f8ff 100644 --- a/app/components/avatar-stack/avatar-stack.tsx +++ b/app/components/avatar-stack/avatar-stack.tsx @@ -1,74 +1,74 @@ -import { getInitials } from '@/utils/helpers/text.helper'; -import { cn } from '@shadcn/lib/utils'; -import { Avatar, AvatarFallback, AvatarImage } from '@shadcn/ui/avatar'; - -export interface AvatarStackItem { - name: string; - avatarUrl?: string; -} - -export interface AvatarStackProps { - items: AvatarStackItem[]; - max?: number; - size?: 'xs' | 'sm'; -} - -const COLORS = [ - 'bg-blue-500', - 'bg-rose-500', - 'bg-amber-600', - 'bg-purple-500', - 'bg-emerald-500', - 'bg-cyan-500', - 'bg-pink-500', - 'bg-indigo-500', -]; - -function hashName(name: string): number { - let hash = 0; - for (let i = 0; i < name.length; i++) { - hash = (hash << 5) - hash + name.charCodeAt(i); - hash |= 0; - } - return Math.abs(hash); -} - -export function AvatarStack({ items, max = 4, size = 'xs' }: AvatarStackProps) { - if (items.length === 0) return null; - - const visible = items.slice(0, max); - const overflow = items.length - max; - - const sizeClasses = size === 'xs' ? 'size-6 text-[9px]' : 'size-8 text-[11px]'; - - return ( -
- {visible.map((item, i) => ( - 0 && '-ml-1.5')} - style={{ zIndex: visible.length - i }}> - {item.avatarUrl && } - - {getInitials(item.name)} - - - ))} - {overflow > 0 && ( -
- +{overflow} -
- )} -
- ); -} +import { getInitials } from '@/utils/helpers/text.helper'; +import { cn } from '@shadcn/lib/utils'; +import { Avatar, AvatarFallback, AvatarImage } from '@shadcn/ui/avatar'; + +export interface AvatarStackItem { + name: string; + avatarUrl?: string; +} + +export interface AvatarStackProps { + items: AvatarStackItem[]; + max?: number; + size?: 'xs' | 'sm'; +} + +const COLORS = [ + 'bg-blue-500', + 'bg-rose-500', + 'bg-amber-600', + 'bg-purple-500', + 'bg-emerald-500', + 'bg-cyan-500', + 'bg-pink-500', + 'bg-indigo-500', +]; + +function hashName(name: string): number { + let hash = 0; + for (let i = 0; i < name.length; i++) { + hash = (hash << 5) - hash + name.charCodeAt(i); + hash |= 0; + } + return Math.abs(hash); +} + +export function AvatarStack({ items, max = 4, size = 'xs' }: AvatarStackProps) { + if (items.length === 0) return null; + + const visible = items.slice(0, max); + const overflow = items.length - max; + + const sizeClasses = size === 'xs' ? 'size-6 text-[9px]' : 'size-8 text-[11px]'; + + return ( +
+ {visible.map((item, i) => ( + 0 && '-ml-1.5')} + style={{ zIndex: visible.length - i }}> + {item.avatarUrl && } + + {getInitials(item.name)} + + + ))} + {overflow > 0 && ( +
+ +{overflow} +
+ )} +
+ ); +} diff --git a/app/components/back-button/back-button.tsx b/app/components/back-button/back-button.tsx index 6864f3e44..3c11d5d5b 100644 --- a/app/components/back-button/back-button.tsx +++ b/app/components/back-button/back-button.tsx @@ -1,106 +1,106 @@ -import { paths } from '@/utils/config/paths.config'; -import { Button, ButtonProps } from '@datum-ui/components'; -import { Icon } from '@datum-ui/components/icons/icon-wrapper'; -import { ArrowLeft } from 'lucide-react'; -import { useNavigate } from 'react-router'; - -export interface BackButtonProps extends Omit { - /** - * Custom back link to navigate to - * If not provided, will use navigate(-1) or fallback to account root - */ - to?: string; - /** - * Callback function called before navigation - * Return false to prevent navigation - */ - onBeforeNavigate?: () => boolean | void; - /** - * Callback function called after navigation - */ - onAfterNavigate?: () => void; - /** - * Custom icon component - * @default ArrowLeft - */ - customIcon?: React.ReactNode; - /** - * Show icon on the button - * @default true - */ - showIcon?: boolean; - /** - * Fallback path when navigate(-1) is not available - * @default paths.account.root - */ - fallbackPath?: string; -} - -export const BackButton = ({ - to, - onBeforeNavigate, - onAfterNavigate, - customIcon, - showIcon = true, - fallbackPath = paths.account.root, - children = 'Back', - type = 'quaternary', - theme = 'outline', - size = 'xs', - ...buttonProps -}: BackButtonProps) => { - const navigate = useNavigate(); - - const handleClick = () => { - // Call onBeforeNavigate callback if provided - if (onBeforeNavigate) { - const shouldContinue = onBeforeNavigate(); - if (shouldContinue === false) { - return; - } - } - - // Navigate based on priority: - // 1. Custom 'to' prop - // 2. Browser history back (navigate(-1)) - // 3. Fallback path - if (to) { - navigate(to); - } else { - // Check if there's history to go back to - // Note: window.history.length > 1 indicates there's a previous page - const hasHistory = window.history.length > 1; - - if (hasHistory) { - navigate(-1); - } else { - navigate(fallbackPath); - } - } - - // Call onAfterNavigate callback if provided - if (onAfterNavigate) { - onAfterNavigate(); - } - }; - - const icon = showIcon - ? customIcon || - : undefined; - - return ( - - ); -}; - -BackButton.displayName = 'BackButton'; +import { paths } from '@/utils/config/paths.config'; +import { Button, ButtonProps } from '@datum-ui/components'; +import { Icon } from '@datum-ui/components/icons/icon-wrapper'; +import { ArrowLeft } from 'lucide-react'; +import { useNavigate } from 'react-router'; + +export interface BackButtonProps extends Omit { + /** + * Custom back link to navigate to + * If not provided, will use navigate(-1) or fallback to account root + */ + to?: string; + /** + * Callback function called before navigation + * Return false to prevent navigation + */ + onBeforeNavigate?: () => boolean | void; + /** + * Callback function called after navigation + */ + onAfterNavigate?: () => void; + /** + * Custom icon component + * @default ArrowLeft + */ + customIcon?: React.ReactNode; + /** + * Show icon on the button + * @default true + */ + showIcon?: boolean; + /** + * Fallback path when navigate(-1) is not available + * @default paths.account.root + */ + fallbackPath?: string; +} + +export const BackButton = ({ + to, + onBeforeNavigate, + onAfterNavigate, + customIcon, + showIcon = true, + fallbackPath = paths.account.root, + children = 'Back', + type = 'quaternary', + theme = 'outline', + size = 'xs', + ...buttonProps +}: BackButtonProps) => { + const navigate = useNavigate(); + + const handleClick = () => { + // Call onBeforeNavigate callback if provided + if (onBeforeNavigate) { + const shouldContinue = onBeforeNavigate(); + if (shouldContinue === false) { + return; + } + } + + // Navigate based on priority: + // 1. Custom 'to' prop + // 2. Browser history back (navigate(-1)) + // 3. Fallback path + if (to) { + navigate(to); + } else { + // Check if there's history to go back to + // Note: window.history.length > 1 indicates there's a previous page + const hasHistory = window.history.length > 1; + + if (hasHistory) { + navigate(-1); + } else { + navigate(fallbackPath); + } + } + + // Call onAfterNavigate callback if provided + if (onAfterNavigate) { + onAfterNavigate(); + } + }; + + const icon = showIcon + ? customIcon || + : undefined; + + return ( + + ); +}; + +BackButton.displayName = 'BackButton'; diff --git a/app/components/badge/badge-copy.tsx b/app/components/badge/badge-copy.tsx index 1ac900999..f82a4d988 100644 --- a/app/components/badge/badge-copy.tsx +++ b/app/components/badge/badge-copy.tsx @@ -1,95 +1,95 @@ -import { useCopyToClipboard } from '@/hooks/useCopyToClipboard'; -import { Badge, type BadgeProps, toast, Tooltip } from '@datum-ui/components'; -import { Icon } from '@datum-ui/components/icons/icon-wrapper'; -import { cn } from '@shadcn/lib/utils'; -import { CopyIcon } from 'lucide-react'; -import { useState } from 'react'; - -export interface BadgeCopyProps { - value: string; - text?: string; - className?: string; - textClassName?: string; - containerClassName?: string; - badgeType?: BadgeProps['type']; - badgeTheme?: BadgeProps['theme']; - showTooltip?: boolean; - copyButtonClassName?: string; - 'data-e2e'?: string; - wrapperTooltipMessage?: string | React.ReactNode; -} - -export const BadgeCopy = ({ - value, - text, - className, - textClassName, - containerClassName, - badgeType = 'secondary', - badgeTheme = 'light', - showTooltip = true, - copyButtonClassName, - 'data-e2e': dataE2e, - wrapperTooltipMessage, -}: BadgeCopyProps) => { - const [_, copy] = useCopyToClipboard(); - const [copied, setCopied] = useState(false); - - const copyToClipboard = () => { - if (!value) return; - - copy(value).then(() => { - toast.success('Copied to clipboard'); - setCopied(true); - setTimeout(() => { - setCopied(false); - }, 2000); - }); - }; - - const displayText = text ?? value; - - const copyButton = ( - - - - ); - - const badge = ( - { - event.preventDefault(); - event.stopPropagation(); - copyToClipboard(); - }}> - {displayText} - {copyButton} - - ); - - const badgeContent = showTooltip ? ( - {badge} - ) : ( - badge - ); - - const wrappedBadge = ( -
- {badgeContent} -
- ); - - if (wrapperTooltipMessage) { - return {wrappedBadge}; - } - - return wrappedBadge; -}; +import { useCopyToClipboard } from '@/hooks/useCopyToClipboard'; +import { Badge, type BadgeProps, toast, Tooltip } from '@datum-ui/components'; +import { Icon } from '@datum-ui/components/icons/icon-wrapper'; +import { cn } from '@shadcn/lib/utils'; +import { CopyIcon } from 'lucide-react'; +import { useState } from 'react'; + +export interface BadgeCopyProps { + value: string; + text?: string; + className?: string; + textClassName?: string; + containerClassName?: string; + badgeType?: BadgeProps['type']; + badgeTheme?: BadgeProps['theme']; + showTooltip?: boolean; + copyButtonClassName?: string; + 'data-e2e'?: string; + wrapperTooltipMessage?: string | React.ReactNode; +} + +export const BadgeCopy = ({ + value, + text, + className, + textClassName, + containerClassName, + badgeType = 'secondary', + badgeTheme = 'light', + showTooltip = true, + copyButtonClassName, + 'data-e2e': dataE2e, + wrapperTooltipMessage, +}: BadgeCopyProps) => { + const [_, copy] = useCopyToClipboard(); + const [copied, setCopied] = useState(false); + + const copyToClipboard = () => { + if (!value) return; + + copy(value).then(() => { + toast.success('Copied to clipboard'); + setCopied(true); + setTimeout(() => { + setCopied(false); + }, 2000); + }); + }; + + const displayText = text ?? value; + + const copyButton = ( + + + + ); + + const badge = ( + { + event.preventDefault(); + event.stopPropagation(); + copyToClipboard(); + }}> + {displayText} + {copyButton} + + ); + + const badgeContent = showTooltip ? ( + {badge} + ) : ( + badge + ); + + const wrappedBadge = ( +
+ {badgeContent} +
+ ); + + if (wrapperTooltipMessage) { + return {wrappedBadge}; + } + + return wrappedBadge; +}; diff --git a/app/components/badge/badge-status.tsx b/app/components/badge/badge-status.tsx index 28b429dc3..73926b84c 100644 --- a/app/components/badge/badge-status.tsx +++ b/app/components/badge/badge-status.tsx @@ -1,182 +1,182 @@ -import { ControlPlaneStatus, IControlPlaneStatus } from '@/resources/base'; -import { Badge, type BadgeProps, Tooltip, SpinnerIcon } from '@datum-ui/components'; -import { cn } from '@shadcn/lib/utils'; -import { ReactNode } from 'react'; - -interface StatusConfig { - badgeType?: BadgeProps['type']; - badgeTheme?: BadgeProps['theme']; - // Custom colors (overrides badgeType/badgeTheme if provided) - customColor?: { - border: string; - text: string; - bg: string; - }; - icon?: ReactNode; - defaultLabel: string; -} - -// Centralized status configuration -// Customize colors here - all usages will inherit these settings -const STATUS_CONFIG: Record = { - active: { - badgeType: 'success', - badgeTheme: 'light', - defaultLabel: 'Active', - }, - pending: { - // Option 1: Use existing Badge type/theme - badgeType: 'info', - badgeTheme: 'light', - // Option 2: Use custom colors - // customColor: { - // border: 'border-blue-500', - // text: 'text-blue-600 dark:text-blue-400', - // bg: 'bg-blue-500/20 dark:bg-blue-500/20', - // }, - icon: ( -