Compare commits
126 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f73138fe6c | |||
| 7c2e78c9de | |||
| 6dfbe1d762 | |||
| 3f19b98f9f | |||
| 09460ab37d | |||
| 062766e974 | |||
| c68eb2d951 | |||
| 583d299891 | |||
| 17338050b1 | |||
| c7a238c664 | |||
| 8ba7526d51 | |||
| 47bed15255 | |||
| 752ad32293 | |||
| db0df17a6a | |||
| edf5b142fc | |||
| 4c3dfac223 | |||
| 7ccbbdb368 | |||
| 71279a7b12 | |||
| 2eba36c086 | |||
| 29827e96f2 | |||
| d416f340ad | |||
| 7a54a843e2 | |||
| 52e9baede5 | |||
| deb80890fe | |||
| d82089686c | |||
| acc10e847c | |||
| f1a83cffc4 | |||
| 61fee91830 | |||
| caa40ce925 | |||
| ba9b0e17c1 | |||
| 469808cf09 | |||
| 7574c69c2d | |||
| 06cbe6e3ca | |||
| 3875bb292a | |||
| ff80742ab7 | |||
| 11366e658c | |||
| e751f8832e | |||
| a999d9ea3f | |||
| 28a66ac021 | |||
| 7d34817473 | |||
| 6d6ed471d1 | |||
| 7f7f993587 | |||
| d28707826c | |||
| 8b00198f99 | |||
| 74e9246bfa | |||
| 07a31d3732 | |||
| da111e220f | |||
| 2c3cb4a43a | |||
| b71bf89444 | |||
| 28c0c78257 | |||
| c021d1db63 | |||
| f62cdfe00e | |||
| 66c6da7114 | |||
| 7fba9e9f6b | |||
| cf63fc9cd2 | |||
| d1c3bfb9c9 | |||
| d8f5853457 | |||
| a52566d9cc | |||
| 07a1d04d52 | |||
| 185f4ecaf9 | |||
| 0d764d4c46 | |||
| beb52b9800 | |||
| 3401a1d0a9 | |||
| 3f982ed366 | |||
| 4dc956470d | |||
| 7fddadcce9 | |||
| 6e038d4d1e | |||
| 05b53967ea | |||
| 702f3b4bca | |||
| 8fcd93001d | |||
| b01b8740cc | |||
| 357aa99309 | |||
| 7cbf82a4ae | |||
| e7fec4097f | |||
| d279988e38 | |||
| 748ae658f7 | |||
| 249878ed92 | |||
| 117b1d5a53 | |||
| 04173a93b9 | |||
| da4c753b68 | |||
| 6ae3b69745 | |||
| 652d95c7eb | |||
| 341e27d213 | |||
| 9821d02af7 | |||
| 255a73a2fe | |||
| c5f7a8daf7 | |||
| 8b0f51b317 | |||
| 53f21e1330 | |||
| 331882f59c | |||
| 1276491a83 | |||
| 5b7dfe23b5 | |||
| 49b2f683f4 | |||
| af9b3e1c99 | |||
| df4ab97d59 | |||
| 9f27422472 | |||
| 2f1a72b109 | |||
| 3c326a4b4a | |||
| 6ae1317b69 | |||
| 2e1c4f6df6 | |||
| 2bf86dc20e | |||
| 485782cae1 | |||
| 61b338bea7 | |||
| 83ddf721a4 | |||
| a4dd2562d2 | |||
| 5ff21a758c | |||
| 47a64c0335 | |||
| fc351cb6c4 | |||
| c4f2a9d065 | |||
| 2762c542c2 | |||
| 72e286af2f | |||
| f7240a99e7 | |||
| 6e47377766 | |||
| a8563ca534 | |||
| 5e5c97ea4c | |||
| abd99aa27f | |||
| 4347499799 | |||
| abee783d3f | |||
| 6464eeed53 | |||
| bee59de3a0 | |||
| 3af7303156 | |||
| 6231ddc67a | |||
| 15fef916f6 | |||
| e2a703bfa8 | |||
| 1a7ee24b9e | |||
| bb99af52cb | |||
| 01797a1668 |
@@ -0,0 +1,11 @@
|
||||
# Remove AI code slop
|
||||
|
||||
Check the diff against main, and remove all AI generated slop introduced in this branch.
|
||||
|
||||
This includes:
|
||||
- Extra comments that a human wouldn't add or is inconsistent with the rest of the file
|
||||
- Extra defensive checks or try/catch blocks that are abnormal for that area of the codebase (especially if called by trusted / validated codepaths)
|
||||
- Casts to any to get around type issues
|
||||
- Any other style that is inconsistent with the file
|
||||
|
||||
Report at the end with only a 1-3 sentence summary of what you changed
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
description: how to create and apply database migrations
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
Follow our [migrations.md](mdc:ai/references/migrations.md) guide for how to create and apply database migrations.
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
description: Guidelines for creating OpenTelemetry metrics to avoid cardinality issues
|
||||
globs:
|
||||
- "**/*.ts"
|
||||
---
|
||||
|
||||
# OpenTelemetry Metrics Guidelines
|
||||
|
||||
When creating or editing OTEL metrics (counters, histograms, gauges), always ensure metric attributes have **low cardinality**.
|
||||
|
||||
## What is Cardinality?
|
||||
|
||||
Cardinality refers to the number of unique values an attribute can have. Each unique combination of attribute values creates a new time series, which consumes memory and storage in your metrics backend.
|
||||
|
||||
## Rules
|
||||
|
||||
### DO use low-cardinality attributes:
|
||||
- **Enums**: `environment_type` (PRODUCTION, STAGING, DEVELOPMENT, PREVIEW)
|
||||
- **Booleans**: `hasFailures`, `streaming`, `success`
|
||||
- **Bounded error codes**: A finite, controlled set of error types
|
||||
- **Shard IDs**: When sharding is bounded (e.g., 0-15)
|
||||
|
||||
### DO NOT use high-cardinality attributes:
|
||||
- **UUIDs/IDs**: `envId`, `userId`, `runId`, `projectId`, `organizationId`
|
||||
- **Unbounded integers**: `itemCount`, `batchSize`, `retryCount`
|
||||
- **Timestamps**: `createdAt`, `startTime`
|
||||
- **Free-form strings**: `errorMessage`, `taskName`, `queueName`
|
||||
|
||||
## Example
|
||||
|
||||
```typescript
|
||||
// BAD - High cardinality
|
||||
this.counter.add(1, {
|
||||
envId: options.environmentId, // UUID - unbounded
|
||||
itemCount: options.runCount, // Integer - unbounded
|
||||
});
|
||||
|
||||
// GOOD - Low cardinality
|
||||
this.counter.add(1, {
|
||||
environment_type: options.environmentType, // Enum - 4 values
|
||||
streaming: true, // Boolean - 2 values
|
||||
});
|
||||
```
|
||||
|
||||
## Reference
|
||||
|
||||
See the schedule engine (`internal-packages/schedule-engine/src/engine/index.ts`) for a good example of low-cardinality metric attributes.
|
||||
|
||||
High cardinality metrics can cause:
|
||||
- Memory bloat in metrics backends (Axiom, Prometheus, etc.)
|
||||
- Slow queries and dashboard timeouts
|
||||
- Increased costs (many backends charge per time series)
|
||||
- Potential data loss or crashes at scale
|
||||
+9
-3
@@ -34,9 +34,9 @@ DEPLOY_REGISTRY_HOST=localhost:5000
|
||||
|
||||
# OPTIONAL VARIABLES
|
||||
# This is used for validating emails that are allowed to log in. Every email that do not match this regex will be rejected.
|
||||
# WHITELISTED_EMAILS="authorized@yahoo\.com|authorized@gmail\.com"
|
||||
# WHITELISTED_EMAILS="^(authorized@yahoo\.com|authorized@gmail\.com)$"
|
||||
# Accounts with these emails will get global admin rights. This grants access to the admin UI.
|
||||
# ADMIN_EMAILS="admin@example\.com|another-admin@example\.com"
|
||||
# ADMIN_EMAILS="^(admin@example\.com|another-admin@example\.com)$"
|
||||
# This is used for logging in via GitHub. You can leave these commented out if you don't want to use GitHub for authentication.
|
||||
# AUTH_GITHUB_CLIENT_ID=
|
||||
# AUTH_GITHUB_CLIENT_SECRET=
|
||||
@@ -85,4 +85,10 @@ POSTHOG_PROJECT_KEY=
|
||||
# These control the server-side internal telemetry
|
||||
# INTERNAL_OTEL_TRACE_EXPORTER_URL=<URL to send traces to>
|
||||
# INTERNAL_OTEL_TRACE_LOGGING_ENABLED=1
|
||||
# INTERNAL_OTEL_TRACE_INSTRUMENT_PRISMA_ENABLED=0,
|
||||
# INTERNAL_OTEL_TRACE_INSTRUMENT_PRISMA_ENABLED=0
|
||||
|
||||
# Enable local observability stack (requires `pnpm run docker` to start otel-collector)
|
||||
# Uncomment these to send metrics to the local Prometheus via OTEL Collector:
|
||||
# INTERNAL_OTEL_METRIC_EXPORTER_ENABLED=1
|
||||
# INTERNAL_OTEL_METRIC_EXPORTER_URL=http://localhost:4318/v1/metrics
|
||||
# INTERNAL_OTEL_METRIC_EXPORTER_INTERVAL_MS=15000
|
||||
@@ -0,0 +1,102 @@
|
||||
name: 🦋 Changesets PR
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "packages/**"
|
||||
- ".changeset/**"
|
||||
- "package.json"
|
||||
- "pnpm-lock.yaml"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
release-pr:
|
||||
name: Create Release PR
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
if: github.repository == 'triggerdotdev/trigger.dev'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: 20.19.0
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Create release PR
|
||||
id: changesets
|
||||
uses: changesets/action@v1
|
||||
with:
|
||||
version: pnpm run changeset:version
|
||||
commit: "chore: release"
|
||||
title: "chore: release"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Update PR title with version
|
||||
if: steps.changesets.outputs.published != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
PR_NUMBER=$(gh pr list --head changeset-release/main --json number --jq '.[0].number')
|
||||
if [ -n "$PR_NUMBER" ]; then
|
||||
git fetch origin changeset-release/main
|
||||
# we arbitrarily reference the version of the cli package here; it is the same for all package releases
|
||||
VERSION=$(git show origin/changeset-release/main:packages/cli-v3/package.json | jq -r '.version')
|
||||
gh pr edit "$PR_NUMBER" --title "chore: release v$VERSION"
|
||||
fi
|
||||
|
||||
update-lockfile:
|
||||
name: Update lockfile on release PR
|
||||
runs-on: ubuntu-latest
|
||||
needs: release-pr
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout release branch
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: changeset-release/main
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.23.0
|
||||
|
||||
- name: Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: 20.19.0
|
||||
|
||||
- name: Install and update lockfile
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
|
||||
- name: Commit and push lockfile
|
||||
run: |
|
||||
set -e
|
||||
if git diff --quiet pnpm-lock.yaml; then
|
||||
echo "No lockfile changes"
|
||||
else
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add pnpm-lock.yaml
|
||||
git commit -m "chore: update lockfile for release"
|
||||
git push origin changeset-release/main
|
||||
fi
|
||||
@@ -0,0 +1,59 @@
|
||||
name: Claude Code Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
# Optional: Only run on specific file changes
|
||||
# paths:
|
||||
# - "src/**/*.ts"
|
||||
# - "src/**/*.tsx"
|
||||
# - "src/**/*.js"
|
||||
# - "src/**/*.jsx"
|
||||
|
||||
jobs:
|
||||
claude-review:
|
||||
# Optional: Filter by PR author
|
||||
# if: |
|
||||
# github.event.pull_request.user.login == 'external-contributor' ||
|
||||
# github.event.pull_request.user.login == 'new-developer' ||
|
||||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code Review
|
||||
id: claude-review
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
prompt: |
|
||||
REPO: ${{ github.repository }}
|
||||
PR NUMBER: ${{ github.event.pull_request.number }}
|
||||
|
||||
Please review this pull request and provide feedback on:
|
||||
- Code quality and best practices
|
||||
- Potential bugs or issues
|
||||
- Performance considerations
|
||||
- Security concerns
|
||||
- Test coverage
|
||||
|
||||
Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback.
|
||||
|
||||
Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR.
|
||||
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||
claude_args: |
|
||||
--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"
|
||||
--model claude-opus-4-5-20251101
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
name: Claude Code
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
types: [opened, assigned]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
jobs:
|
||||
claude:
|
||||
if: |
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
|
||||
# This is an optional setting that allows Claude to read CI results on PRs
|
||||
additional_permissions: |
|
||||
actions: read
|
||||
|
||||
claude_args: |
|
||||
--model claude-opus-4-5-20251101
|
||||
|
||||
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
|
||||
# prompt: 'Update the pull request description to include a summary of changes.'
|
||||
|
||||
# Optional: Add claude_args to customize behavior and configuration
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||
# claude_args: '--allowed-tools Bash(gh pr:*)'
|
||||
|
||||
@@ -31,12 +31,12 @@ jobs:
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8.15.5
|
||||
version: 10.23.0
|
||||
|
||||
- name: ⎔ Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: 20.11.1
|
||||
node-version: 20.19.0
|
||||
|
||||
- name: 📥 Download deps
|
||||
run: pnpm install --frozen-lockfile --filter trigger.dev...
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
name: 🚀 Publish Trigger.dev Docker
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
inputs:
|
||||
image_tag:
|
||||
|
||||
+135
-48
@@ -1,98 +1,185 @@
|
||||
name: 🦋 Changesets Release
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- "docs/**"
|
||||
- "**.md"
|
||||
- ".github/CODEOWNERS"
|
||||
- ".github/ISSUE_TEMPLATE/**"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
type:
|
||||
description: "Select release type"
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- release
|
||||
- prerelease
|
||||
default: "prerelease"
|
||||
ref:
|
||||
description: "The ref (branch, tag, or SHA) to checkout and release from"
|
||||
required: true
|
||||
type: string
|
||||
prerelease_tag:
|
||||
description: "The npm dist-tag for the prerelease (e.g., 'v4-prerelease')"
|
||||
required: false
|
||||
type: string
|
||||
default: "prerelease"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
group: ${{ github.workflow }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: 🦋 Changesets Release
|
||||
show-release-summary:
|
||||
name: 📋 Release Summary
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
github.repository == 'triggerdotdev/trigger.dev' &&
|
||||
github.event_name == 'pull_request' &&
|
||||
github.event.pull_request.merged == true &&
|
||||
github.event.pull_request.head.ref == 'changeset-release/main'
|
||||
steps:
|
||||
- name: Show release summary
|
||||
env:
|
||||
PR_BODY: ${{ github.event.pull_request.body }}
|
||||
run: |
|
||||
echo "$PR_BODY" | sed -n '/^# Releases/,$p' >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
release:
|
||||
name: 🚀 Release npm packages
|
||||
runs-on: ubuntu-latest
|
||||
environment: npm-publish
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
pull-requests: write
|
||||
if: github.repository == 'triggerdotdev/trigger.dev'
|
||||
id-token: write
|
||||
if: |
|
||||
github.repository == 'triggerdotdev/trigger.dev' &&
|
||||
(
|
||||
(github.event_name == 'workflow_dispatch' && github.event.inputs.type == 'release') ||
|
||||
(github.event_name == 'pull_request' && github.event.pull_request.merged == true && github.event.pull_request.head.ref == 'changeset-release/main')
|
||||
)
|
||||
outputs:
|
||||
published: ${{ steps.changesets.outputs.published }}
|
||||
published_packages: ${{ steps.changesets.outputs.publishedPackages }}
|
||||
published_package_version: ${{ steps.get_version.outputs.package_version }}
|
||||
steps:
|
||||
- name: ⬇️ Checkout repo
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.ref || github.sha }}
|
||||
|
||||
- name: ⎔ Setup pnpm
|
||||
- name: Verify ref is on main
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
run: |
|
||||
if ! git merge-base --is-ancestor ${{ github.event.inputs.ref }} origin/main; then
|
||||
echo "Error: ref must be an ancestor of main (i.e., already merged)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8.15.5
|
||||
version: 10.23.0
|
||||
|
||||
- name: ⎔ Setup node
|
||||
- name: Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: 20.11.1
|
||||
node-version: 20.19.0
|
||||
cache: "pnpm"
|
||||
|
||||
- name: 📥 Download deps
|
||||
# npm v11.5.1 or newer is required for OIDC support
|
||||
# https://github.blog/changelog/2025-07-31-npm-trusted-publishing-with-oidc-is-generally-available/#whats-new
|
||||
- name: Setup npm 11.x for OIDC
|
||||
run: npm install -g npm@11.6.4
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: 📀 Generate Prisma Client
|
||||
- name: Generate Prisma client
|
||||
run: pnpm run generate
|
||||
|
||||
- name: 🏗️ Build
|
||||
- name: Build
|
||||
run: pnpm run build --filter "@trigger.dev/*" --filter "trigger.dev"
|
||||
|
||||
- name: 🔎 Type check
|
||||
- name: Type check
|
||||
run: pnpm run typecheck --filter "@trigger.dev/*" --filter "trigger.dev"
|
||||
|
||||
- name: 🔐 Setup npm auth
|
||||
run: |
|
||||
echo "registry=https://registry.npmjs.org" >> ~/.npmrc
|
||||
echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" >> ~/.npmrc
|
||||
|
||||
# This action has two responsibilities. The first time the workflow runs
|
||||
# (initial push to the `main` branch) it will create a new branch and
|
||||
# then open a PR with the related changes for the new version. After the
|
||||
# PR is merged, the workflow will run again and this action will build +
|
||||
# publish to npm.
|
||||
- name: 🚀 PR / Publish
|
||||
if: ${{ !env.ACT }}
|
||||
- name: Publish
|
||||
id: changesets
|
||||
uses: changesets/action@v1
|
||||
with:
|
||||
version: pnpm run changeset:version
|
||||
commit: "chore: Update version for release"
|
||||
title: "chore: Update version for release"
|
||||
publish: pnpm run changeset:release
|
||||
createGithubReleases: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
# - name: 🚀 PR / Publish (mock)
|
||||
# if: ${{ env.ACT }}
|
||||
# id: changesets
|
||||
# run: |
|
||||
# echo "published=true" >> "$GITHUB_OUTPUT"
|
||||
# echo "publishedPackages=[{\"name\": \"@xx/xx\", \"version\": \"1.2.0\"}, {\"name\": \"@xx/xy\", \"version\": \"0.8.9\"}]" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 📦 Get package version
|
||||
- name: Show package version
|
||||
if: steps.changesets.outputs.published == 'true'
|
||||
id: get_version
|
||||
run: |
|
||||
package_version=$(echo '${{ steps.changesets.outputs.publishedPackages }}' | jq -r '.[0].version')
|
||||
echo "package_version=${package_version}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# this triggers the publish workflow for the docker images
|
||||
- name: Create and push Docker tag
|
||||
if: steps.changesets.outputs.published == 'true'
|
||||
run: |
|
||||
set -e
|
||||
git tag "v.docker.${{ steps.get_version.outputs.package_version }}"
|
||||
git push origin "v.docker.${{ steps.get_version.outputs.package_version }}"
|
||||
|
||||
# The prerelease job needs to be on the same workflow file due to a limitation related to how npm verifies OIDC claims.
|
||||
prerelease:
|
||||
name: 🧪 Prerelease
|
||||
runs-on: ubuntu-latest
|
||||
environment: npm-publish
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
if: github.repository == 'triggerdotdev/trigger.dev' && github.event_name == 'workflow_dispatch' && github.event.inputs.type == 'prerelease'
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event.inputs.ref }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.23.0
|
||||
|
||||
- name: Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: 20.19.0
|
||||
cache: "pnpm"
|
||||
|
||||
# npm v11.5.1 or newer is required for OIDC support
|
||||
# https://github.blog/changelog/2025-07-31-npm-trusted-publishing-with-oidc-is-generally-available/#whats-new
|
||||
- name: Setup npm 11.x for OIDC
|
||||
run: npm install -g npm@11.6.4
|
||||
|
||||
- name: Download deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Generate Prisma Client
|
||||
run: pnpm run generate
|
||||
|
||||
- name: Snapshot version
|
||||
run: pnpm exec changeset version --snapshot ${{ github.event.inputs.prerelease_tag }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Clean
|
||||
run: pnpm run clean --filter "@trigger.dev/*" --filter "trigger.dev"
|
||||
|
||||
- name: Build
|
||||
run: pnpm run build --filter "@trigger.dev/*" --filter "trigger.dev"
|
||||
|
||||
- name: Publish prerelease
|
||||
run: pnpm exec changeset publish --no-git-tag --snapshot --tag ${{ github.event.inputs.prerelease_tag }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -19,12 +19,12 @@ jobs:
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8.15.5
|
||||
version: 10.23.0
|
||||
|
||||
- name: ⎔ Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: 20.11.1
|
||||
node-version: 20.19.0
|
||||
cache: "pnpm"
|
||||
|
||||
- name: 📥 Download deps
|
||||
@@ -35,6 +35,8 @@ jobs:
|
||||
|
||||
- name: 🔎 Type check
|
||||
run: pnpm run typecheck
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
|
||||
- name: 🔎 Check exports
|
||||
run: pnpm run check-exports
|
||||
|
||||
@@ -53,12 +53,12 @@ jobs:
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8.15.5
|
||||
version: 10.23.0
|
||||
|
||||
- name: ⎔ Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: 20.11.1
|
||||
node-version: 20.19.0
|
||||
cache: "pnpm"
|
||||
|
||||
# ..to avoid rate limits when pulling images
|
||||
@@ -111,12 +111,12 @@ jobs:
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8.15.5
|
||||
version: 10.23.0
|
||||
|
||||
- name: ⎔ Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: 20.11.1
|
||||
node-version: 20.19.0
|
||||
# no cache enabled, we're not installing deps
|
||||
|
||||
- name: Download blob reports from GitHub Actions Artifacts
|
||||
|
||||
@@ -53,12 +53,12 @@ jobs:
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8.15.5
|
||||
version: 10.23.0
|
||||
|
||||
- name: ⎔ Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: 20.11.1
|
||||
node-version: 20.19.0
|
||||
cache: "pnpm"
|
||||
|
||||
# ..to avoid rate limits when pulling images
|
||||
@@ -111,12 +111,12 @@ jobs:
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8.15.5
|
||||
version: 10.23.0
|
||||
|
||||
- name: ⎔ Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: 20.11.1
|
||||
node-version: 20.19.0
|
||||
# no cache enabled, we're not installing deps
|
||||
|
||||
- name: Download blob reports from GitHub Actions Artifacts
|
||||
|
||||
@@ -53,12 +53,12 @@ jobs:
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8.15.5
|
||||
version: 10.23.0
|
||||
|
||||
- name: ⎔ Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: 20.11.1
|
||||
node-version: 20.19.0
|
||||
cache: "pnpm"
|
||||
|
||||
# ..to avoid rate limits when pulling images
|
||||
@@ -119,12 +119,12 @@ jobs:
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8.15.5
|
||||
version: 10.23.0
|
||||
|
||||
- name: ⎔ Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: 20.11.1
|
||||
node-version: 20.19.0
|
||||
# no cache enabled, we're not installing deps
|
||||
|
||||
- name: Download blob reports from GitHub Actions Artifacts
|
||||
|
||||
+5
-6
@@ -29,12 +29,10 @@ yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env.docker
|
||||
.env
|
||||
.env.*
|
||||
.docker/*.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
!.env.example
|
||||
|
||||
# turbo
|
||||
.turbo
|
||||
@@ -64,4 +62,5 @@ apps/**/public/build
|
||||
/packages/trigger-sdk/src/package.json
|
||||
/packages/python/src/package.json
|
||||
.claude
|
||||
.mcp.log
|
||||
.mcp.log
|
||||
.cursor/debug.log
|
||||
@@ -1,5 +0,0 @@
|
||||
link-workspace-packages=false
|
||||
public-hoist-pattern[]=*prisma*
|
||||
prefer-workspace-packages=true
|
||||
update-notifier=false
|
||||
side-effects-cache=false
|
||||
@@ -13,7 +13,7 @@ This repository is a pnpm monorepo managed with Turbo. It contains multiple apps
|
||||
See `ai/references/repo.md` for a more complete explanation of the workspaces.
|
||||
|
||||
## Development setup
|
||||
1. Install dependencies with `pnpm i` (pnpm `8.15.5` and Node.js `20.11.1` are required).
|
||||
1. Install dependencies with `pnpm i` (pnpm `10.23.0` and Node.js `20.11.1` are required).
|
||||
2. Copy `.env.example` to `.env` and generate a random 16 byte hex string for `ENCRYPTION_KEY` (`openssl rand -hex 16`). Update other secrets if needed.
|
||||
3. Start the local services with Docker:
|
||||
```bash
|
||||
|
||||
+2
-2
@@ -15,7 +15,7 @@ branch are tagged into a release periodically.
|
||||
### Prerequisites
|
||||
|
||||
- [Node.js](https://nodejs.org/en) version 20.11.1
|
||||
- [pnpm package manager](https://pnpm.io/installation) version 8.15.5
|
||||
- [pnpm package manager](https://pnpm.io/installation) version 10.23.0
|
||||
- [Docker](https://www.docker.com/get-started/)
|
||||
- [protobuf](https://github.com/protocolbuffers/protobuf)
|
||||
|
||||
@@ -36,7 +36,7 @@ branch are tagged into a release periodically.
|
||||
```
|
||||
3. Ensure you are on the correct version of Node.js (20.11.1). If you are using `nvm`, there is an `.nvmrc` file that will automatically select the correct version of Node.js when you navigate to the repository.
|
||||
|
||||
4. Run `corepack enable` to use the correct version of pnpm (`8.15.5`) as specified in the root `package.json` file.
|
||||
4. Run `corepack enable` to use the correct version of pnpm (`10.23.0`) as specified in the root `package.json` file.
|
||||
|
||||
5. Install the required packages using pnpm.
|
||||
```
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
### Build and deploy fully‑managed AI agents and workflows
|
||||
|
||||
[Website](https://trigger.dev) | [Docs](https://trigger.dev/docs) | [Issues](https://github.com/triggerdotdev/trigger.dev/issues) | [Feature requests](https://triggerdev.featurebase.app/) | [Public roadmap](https://triggerdev.featurebase.app/roadmap) | [Self-hosting](https://trigger.dev/docs/self-hosting/overview)
|
||||
[Website](https://trigger.dev) | [Docs](https://trigger.dev/docs) | [Issues](https://github.com/triggerdotdev/trigger.dev/issues) | [Example projects](https://github.com/triggerdotdev/examples) | [Feature requests](https://triggerdev.featurebase.app/) | [Public roadmap](https://triggerdev.featurebase.app/roadmap) | [Self-hosting](https://trigger.dev/docs/self-hosting/overview)
|
||||
|
||||
[](https://github.com/triggerdotdev/trigger.dev)
|
||||
[](https://github.com/triggerdotdev/trigger.dev/blob/main/LICENSE)
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
## Creating and applying migrations
|
||||
|
||||
We use prisma migrations to manage the database schema. Please follow the following steps when editing the `internal-packages/database/prisma/schema.prisma` file:
|
||||
|
||||
Edit the `schema.prisma` file to add or modify the schema.
|
||||
|
||||
Create a new migration file but don't apply it yet:
|
||||
|
||||
```bash
|
||||
cd internal-packages/database
|
||||
pnpm run db:migrate:dev:create --name "add_new_column_to_table"
|
||||
```
|
||||
|
||||
The migration file will be created in the `prisma/migrations` directory, but it will have a bunch of edits to the schema that are not needed and will need to be removed before we can apply the migration. Here's an example of what the migration file might look like:
|
||||
|
||||
```sql
|
||||
-- AlterEnum
|
||||
ALTER TYPE "public"."TaskRunExecutionStatus" ADD VALUE 'DELAYED';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."TaskRun" ADD COLUMN "debounce" JSONB;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."_BackgroundWorkerToBackgroundWorkerFile" ADD CONSTRAINT "_BackgroundWorkerToBackgroundWorkerFile_AB_pkey" PRIMARY KEY ("A", "B");
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "public"."_BackgroundWorkerToBackgroundWorkerFile_AB_unique";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."_BackgroundWorkerToTaskQueue" ADD CONSTRAINT "_BackgroundWorkerToTaskQueue_AB_pkey" PRIMARY KEY ("A", "B");
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "public"."_BackgroundWorkerToTaskQueue_AB_unique";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."_TaskRunToTaskRunTag" ADD CONSTRAINT "_TaskRunToTaskRunTag_AB_pkey" PRIMARY KEY ("A", "B");
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "public"."_TaskRunToTaskRunTag_AB_unique";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."_WaitpointRunConnections" ADD CONSTRAINT "_WaitpointRunConnections_AB_pkey" PRIMARY KEY ("A", "B");
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "public"."_WaitpointRunConnections_AB_unique";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."_completedWaitpoints" ADD CONSTRAINT "_completedWaitpoints_AB_pkey" PRIMARY KEY ("A", "B");
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "public"."_completedWaitpoints_AB_unique";
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SecretStore_key_idx" ON "public"."SecretStore"("key" text_pattern_ops);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TaskRun_runtimeEnvironmentId_id_idx" ON "public"."TaskRun"("runtimeEnvironmentId", "id" DESC);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TaskRun_runtimeEnvironmentId_createdAt_idx" ON "public"."TaskRun"("runtimeEnvironmentId", "createdAt" DESC);
|
||||
```
|
||||
|
||||
All the following lines should be removed:
|
||||
|
||||
```sql
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."_BackgroundWorkerToBackgroundWorkerFile" ADD CONSTRAINT "_BackgroundWorkerToBackgroundWorkerFile_AB_pkey" PRIMARY KEY ("A", "B");
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "public"."_BackgroundWorkerToBackgroundWorkerFile_AB_unique";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."_BackgroundWorkerToTaskQueue" ADD CONSTRAINT "_BackgroundWorkerToTaskQueue_AB_pkey" PRIMARY KEY ("A", "B");
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "public"."_BackgroundWorkerToTaskQueue_AB_unique";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."_TaskRunToTaskRunTag" ADD CONSTRAINT "_TaskRunToTaskRunTag_AB_pkey" PRIMARY KEY ("A", "B");
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "public"."_TaskRunToTaskRunTag_AB_unique";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."_WaitpointRunConnections" ADD CONSTRAINT "_WaitpointRunConnections_AB_pkey" PRIMARY KEY ("A", "B");
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "public"."_WaitpointRunConnections_AB_unique";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."_completedWaitpoints" ADD CONSTRAINT "_completedWaitpoints_AB_pkey" PRIMARY KEY ("A", "B");
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "public"."_completedWaitpoints_AB_unique";
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SecretStore_key_idx" ON "public"."SecretStore"("key" text_pattern_ops);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TaskRun_runtimeEnvironmentId_id_idx" ON "public"."TaskRun"("runtimeEnvironmentId", "id" DESC);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TaskRun_runtimeEnvironmentId_createdAt_idx" ON "public"."TaskRun"("runtimeEnvironmentId", "createdAt" DESC);
|
||||
```
|
||||
|
||||
Leaving only this:
|
||||
|
||||
```sql
|
||||
-- AlterEnum
|
||||
ALTER TYPE "public"."TaskRunExecutionStatus" ADD VALUE 'DELAYED';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."TaskRun" ADD COLUMN "debounce" JSONB;
|
||||
```
|
||||
|
||||
After editing the migration file, apply the migration:
|
||||
|
||||
```bash
|
||||
cd internal-packages/database
|
||||
pnpm run db:migrate:deploy && pnpm run generate
|
||||
```
|
||||
@@ -1,6 +1,6 @@
|
||||
## Repo Overview
|
||||
|
||||
This is a pnpm 8.15.5 monorepo that uses turborepo @turbo.json. The following workspaces are relevant
|
||||
This is a pnpm 10.23.0 monorepo that uses turborepo @turbo.json. The following workspaces are relevant
|
||||
|
||||
## Apps
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ WORKDIR /app
|
||||
FROM node-22-alpine AS pruner
|
||||
|
||||
COPY --chown=node:node . .
|
||||
RUN npx -q turbo@1.10.9 prune --scope=supervisor --docker
|
||||
RUN npx -q turbo@2.5.4 prune --scope=supervisor --docker
|
||||
|
||||
FROM node-22-alpine AS base
|
||||
|
||||
@@ -16,7 +16,7 @@ COPY --from=pruner --chown=node:node /app/out/json/ .
|
||||
COPY --from=pruner --chown=node:node /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||
COPY --from=pruner --chown=node:node /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml
|
||||
|
||||
RUN corepack enable && corepack prepare --activate
|
||||
RUN corepack enable && corepack prepare pnpm@10.23.0 --activate
|
||||
|
||||
FROM base AS deps-fetcher
|
||||
RUN apk add --no-cache python3-dev py3-setuptools make g++ gcc linux-headers
|
||||
@@ -37,7 +37,7 @@ COPY --chown=node:node scripts/updateVersion.ts scripts/updateVersion.ts
|
||||
|
||||
RUN pnpm run generate && \
|
||||
pnpm run --filter supervisor... build&& \
|
||||
pnpm deploy --filter=supervisor --prod /prod/supervisor
|
||||
pnpm deploy --legacy --filter=supervisor --prod /prod/supervisor
|
||||
|
||||
FROM base AS runner
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ const Env = z.object({
|
||||
RESOURCE_MONITOR_OVERRIDE_MEMORY_TOTAL_GB: z.coerce.number().optional(),
|
||||
|
||||
// Docker settings
|
||||
DOCKER_API_VERSION: z.string().default("v1.41"),
|
||||
DOCKER_API_VERSION: z.string().optional(),
|
||||
DOCKER_PLATFORM: z.string().optional(), // e.g. linux/amd64, linux/arm64
|
||||
DOCKER_STRIP_IMAGE_DIGEST: BoolEnv.default(true),
|
||||
DOCKER_REGISTRY_USERNAME: z.string().optional(),
|
||||
|
||||
@@ -244,6 +244,12 @@ class ManagedSupervisor {
|
||||
}
|
||||
|
||||
try {
|
||||
if (!message.deployment.friendlyId) {
|
||||
// mostly a type guard, deployments always exists for deployed environments
|
||||
// a proper fix would be to use a discriminated union schema to differentiate between dequeued runs in dev and in deployed environments.
|
||||
throw new Error("Deployment is missing");
|
||||
}
|
||||
|
||||
await this.workloadManager.create({
|
||||
dequeuedAt: message.dequeuedAt,
|
||||
envId: message.environment.id,
|
||||
@@ -252,6 +258,8 @@ class ManagedSupervisor {
|
||||
machine: message.run.machine,
|
||||
orgId: message.organization.id,
|
||||
projectId: message.project.id,
|
||||
deploymentFriendlyId: message.deployment.friendlyId,
|
||||
deploymentVersion: message.backgroundWorker.version,
|
||||
runId: message.run.id,
|
||||
runFriendlyId: message.run.friendlyId,
|
||||
version: message.version,
|
||||
|
||||
@@ -72,6 +72,8 @@ export class DockerWorkloadManager implements WorkloadManager {
|
||||
`TRIGGER_DEQUEUED_AT_MS=${opts.dequeuedAt.getTime()}`,
|
||||
`TRIGGER_POD_SCHEDULED_AT_MS=${Date.now()}`,
|
||||
`TRIGGER_ENV_ID=${opts.envId}`,
|
||||
`TRIGGER_DEPLOYMENT_ID=${opts.deploymentFriendlyId}`,
|
||||
`TRIGGER_DEPLOYMENT_VERSION=${opts.deploymentVersion}`,
|
||||
`TRIGGER_RUN_ID=${opts.runFriendlyId}`,
|
||||
`TRIGGER_SNAPSHOT_ID=${opts.snapshotFriendlyId}`,
|
||||
`TRIGGER_SUPERVISOR_API_PROTOCOL=${this.opts.workloadApiProtocol}`,
|
||||
|
||||
@@ -123,6 +123,14 @@ export class KubernetesWorkloadManager implements WorkloadManager {
|
||||
name: "TRIGGER_ENV_ID",
|
||||
value: opts.envId,
|
||||
},
|
||||
{
|
||||
name: "TRIGGER_DEPLOYMENT_ID",
|
||||
value: opts.deploymentFriendlyId,
|
||||
},
|
||||
{
|
||||
name: "TRIGGER_DEPLOYMENT_VERSION",
|
||||
value: opts.deploymentVersion,
|
||||
},
|
||||
{
|
||||
name: "TRIGGER_SNAPSHOT_ID",
|
||||
value: opts.snapshotFriendlyId,
|
||||
|
||||
@@ -29,6 +29,8 @@ export interface WorkloadManagerCreateOptions {
|
||||
envType: EnvironmentType;
|
||||
orgId: string;
|
||||
projectId: string;
|
||||
deploymentFriendlyId: string;
|
||||
deploymentVersion: string;
|
||||
runId: string;
|
||||
runFriendlyId: string;
|
||||
snapshotId: string;
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
export function ChevronExtraSmallDown({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M15 6L9.75926 12.1142C9.36016 12.5798 8.63984 12.5798 8.24074 12.1142L3 6"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeMiterlimit="1.00244"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export function ChevronExtraSmallUp({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M3 12L8.24074 5.8858C8.63984 5.42019 9.36016 5.42019 9.75926 5.8858L15 12"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeMiterlimit="1.00244"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export function ConcurrencyIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="3.75" cy="3.75" r="2.25" fill="currentColor" />
|
||||
<circle cx="9" cy="3.75" r="2.25" fill="currentColor" />
|
||||
<circle cx="14.25" cy="3.75" r="2.25" fill="currentColor" />
|
||||
<circle cx="3.75" cy="9" r="2.25" fill="currentColor" />
|
||||
<circle cx="9" cy="9" r="2.25" fill="currentColor" />
|
||||
<circle cx="9" cy="14.25" r="1.75" stroke="currentColor" />
|
||||
<circle cx="14.25" cy="9" r="2.25" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export function GoogleLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M19.9075 21.0983C22.7427 18.4521 24.0028 14.0417 23.2468 9.82031H11.9688V14.4827H18.3953C18.1433 15.9949 17.2612 17.255 16.0011 18.0741L19.9075 21.0983Z"
|
||||
fill="#4285F4"
|
||||
/>
|
||||
<path
|
||||
d="M1.25781 17.3802C2.08665 19.013 3.27532 20.4362 4.73421 21.5428C6.1931 22.6493 7.88415 23.4102 9.67988 23.7681C11.4756 24.1261 13.3292 24.0717 15.1008 23.6091C16.8725 23.1465 18.516 22.2877 19.9075 21.0976L16.0011 18.0733C12.6618 20.2785 7.11734 19.4594 5.22717 14.293L1.25781 17.3802Z"
|
||||
fill="#34A853"
|
||||
/>
|
||||
<path
|
||||
d="M5.22701 14.2922C4.72297 12.717 4.72297 11.2679 5.22701 9.69275L1.25765 6.60547C-0.191479 9.50373 -0.632519 13.5991 1.25765 17.3794L5.22701 14.2922Z"
|
||||
fill="#FBBC02"
|
||||
/>
|
||||
<path
|
||||
d="M5.22717 9.69209C6.6133 5.34469 12.5358 2.82446 16.5052 6.5418L19.9705 3.13949C15.0561 -1.58594 5.47919 -1.39692 1.25781 6.60481L5.22717 9.69209Z"
|
||||
fill="#EA4335"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -52,6 +52,13 @@ import {
|
||||
} from "./SetupCommands";
|
||||
import { StepContentContainer } from "./StepContentContainer";
|
||||
import { V4Badge } from "./V4Badge";
|
||||
import {
|
||||
ClientTabs,
|
||||
ClientTabsContent,
|
||||
ClientTabsList,
|
||||
ClientTabsTrigger,
|
||||
} from "./primitives/ClientTabs";
|
||||
import { GitHubSettingsPanel } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github";
|
||||
|
||||
export function HasNoTasksDev() {
|
||||
return (
|
||||
@@ -93,62 +100,7 @@ export function HasNoTasksDev() {
|
||||
}
|
||||
|
||||
export function HasNoTasksDeployed({ environment }: { environment: MinimumEnvironment }) {
|
||||
return (
|
||||
<PackageManagerProvider>
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between border-b">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<EnvironmentIcon environment={environment} className="-ml-1 size-8" />
|
||||
<Header1>Deploy your tasks to {environmentFullTitle(environment)}</Header1>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<LinkButton
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
to={docsPath("deployment/overview")}
|
||||
/>
|
||||
}
|
||||
content="Deploy docs"
|
||||
/>
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<LinkButton
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={QuestionMarkCircleIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
to={docsPath("troubleshooting#deployment")}
|
||||
/>
|
||||
}
|
||||
content="Troubleshooting docs"
|
||||
/>
|
||||
<AskAI />
|
||||
</div>
|
||||
</div>
|
||||
<StepNumber stepNumber="1a" title="Run the CLI 'deploy' command" />
|
||||
<StepContentContainer>
|
||||
<Paragraph spacing>
|
||||
This will deploy your tasks to the {environmentFullTitle(environment)} environment. Read
|
||||
the <TextLink to={docsPath("deployment/overview")}>full guide</TextLink>.
|
||||
</Paragraph>
|
||||
<TriggerDeployStep environment={environment} />
|
||||
</StepContentContainer>
|
||||
<StepNumber stepNumber="1b" title="Or deploy using GitHub Actions" />
|
||||
<StepContentContainer>
|
||||
<Paragraph spacing>
|
||||
Read the <TextLink to={docsPath("github-actions")}>GitHub Actions guide</TextLink> to
|
||||
get started.
|
||||
</Paragraph>
|
||||
</StepContentContainer>
|
||||
<StepNumber stepNumber="2" title="Waiting for tasks to deploy" displaySpinner />
|
||||
<StepContentContainer>
|
||||
<Paragraph>This page will automatically refresh when your tasks are deployed.</Paragraph>
|
||||
</StepContentContainer>
|
||||
</div>
|
||||
</PackageManagerProvider>
|
||||
);
|
||||
return <DeploymentOnboardingSteps />;
|
||||
}
|
||||
|
||||
export function SchedulesNoPossibleTaskPanel() {
|
||||
@@ -266,45 +218,7 @@ export function TestHasNoTasks() {
|
||||
}
|
||||
|
||||
export function DeploymentsNone() {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
return (
|
||||
<InfoPanel
|
||||
icon={ServerStackIcon}
|
||||
iconClassName="text-deployments"
|
||||
title="Deploy for the first time"
|
||||
panelClassName="max-w-full"
|
||||
>
|
||||
<Paragraph spacing variant="small">
|
||||
There are several ways to deploy your tasks. You can use the CLI or a Continuous Integration
|
||||
service like GitHub Actions. Make sure you{" "}
|
||||
<TextLink href={v3EnvironmentVariablesPath(organization, project, environment)}>
|
||||
set your environment variables
|
||||
</TextLink>{" "}
|
||||
first.
|
||||
</Paragraph>
|
||||
<div className="flex gap-3">
|
||||
<LinkButton
|
||||
to={docsPath("v3/cli-deploy")}
|
||||
variant="docs/medium"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
className="inline-flex"
|
||||
>
|
||||
Deploy with the CLI
|
||||
</LinkButton>
|
||||
<LinkButton
|
||||
to={docsPath("v3/github-actions")}
|
||||
variant="docs/medium"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
className="inline-flex"
|
||||
>
|
||||
Deploy with GitHub actions
|
||||
</LinkButton>
|
||||
</div>
|
||||
</InfoPanel>
|
||||
);
|
||||
return <DeploymentOnboardingSteps />;
|
||||
}
|
||||
|
||||
export function DeploymentsNoneDev() {
|
||||
@@ -313,46 +227,52 @@ export function DeploymentsNoneDev() {
|
||||
const environment = useEnvironment();
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<InfoPanel
|
||||
icon={ServerStackIcon}
|
||||
iconClassName="text-deployments"
|
||||
title="Deploying tasks"
|
||||
panelClassName="max-w-full"
|
||||
>
|
||||
<Paragraph spacing variant="small">
|
||||
<>
|
||||
<div className="mb-6 flex items-center justify-between border-b">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<EnvironmentIcon environment={environment} className="-ml-1 size-8" />
|
||||
<Header1>Deploy your tasks</Header1>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<LinkButton
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
to={docsPath("deployment/overview")}
|
||||
/>
|
||||
}
|
||||
content="Deploy docs"
|
||||
/>
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<LinkButton
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={QuestionMarkCircleIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
to={docsPath("troubleshooting#deployment")}
|
||||
/>
|
||||
}
|
||||
content="Troubleshooting docs"
|
||||
/>
|
||||
<AskAI />
|
||||
</div>
|
||||
</div>
|
||||
<StepNumber stepNumber="→" title="Switch to a deployed environment" />
|
||||
<StepContentContainer className="mb-4 flex flex-col gap-4">
|
||||
<Paragraph>
|
||||
This is the Development environment. When you're ready to deploy your tasks, switch to a
|
||||
different environment.
|
||||
</Paragraph>
|
||||
<Paragraph spacing variant="small">
|
||||
There are several ways to deploy your tasks. You can use the CLI or a Continuous
|
||||
Integration service like GitHub Actions. Make sure you{" "}
|
||||
<TextLink href={v3EnvironmentVariablesPath(organization, project, environment)}>
|
||||
set your environment variables
|
||||
</TextLink>{" "}
|
||||
first.
|
||||
</Paragraph>
|
||||
<div className="flex gap-3">
|
||||
<LinkButton
|
||||
to={docsPath("v3/cli-deploy")}
|
||||
variant="docs/medium"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
className="inline-flex"
|
||||
>
|
||||
Deploy with the CLI
|
||||
</LinkButton>
|
||||
<LinkButton
|
||||
to={docsPath("v3/github-actions")}
|
||||
variant="docs/medium"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
className="inline-flex"
|
||||
>
|
||||
Deploy with GitHub actions
|
||||
</LinkButton>
|
||||
</div>
|
||||
</InfoPanel>
|
||||
<SwitcherPanel />
|
||||
</div>
|
||||
<EnvironmentSelector
|
||||
organization={organization}
|
||||
project={project}
|
||||
environment={environment}
|
||||
className="w-fit border border-charcoal-600 bg-secondary hover:border-charcoal-550 hover:bg-charcoal-600"
|
||||
/>
|
||||
</StepContentContainer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -670,3 +590,99 @@ export function BulkActionsNone() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeploymentOnboardingSteps() {
|
||||
const environment = useEnvironment();
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
|
||||
return (
|
||||
<PackageManagerProvider>
|
||||
<div className="mb-2 flex items-center justify-between border-b">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<EnvironmentIcon environment={environment} className="-ml-1 size-8" />
|
||||
<Header1>Deploy your tasks to {environmentFullTitle(environment)}</Header1>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<LinkButton
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
to={docsPath("deployment/overview")}
|
||||
/>
|
||||
}
|
||||
content="Deploy docs"
|
||||
/>
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<LinkButton
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={QuestionMarkCircleIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
to={docsPath("troubleshooting#deployment")}
|
||||
/>
|
||||
}
|
||||
content="Troubleshooting docs"
|
||||
/>
|
||||
<AskAI />
|
||||
</div>
|
||||
</div>
|
||||
<ClientTabs defaultValue="github">
|
||||
<ClientTabsList variant="segmented" className="mb-6">
|
||||
<ClientTabsTrigger value={"github"} variant="segmented" layoutId="deploy-tabs">
|
||||
GitHub
|
||||
</ClientTabsTrigger>
|
||||
<ClientTabsTrigger value={"cli"} variant="segmented" layoutId="deploy-tabs">
|
||||
Manual
|
||||
</ClientTabsTrigger>
|
||||
<ClientTabsTrigger value={"github-actions"} variant="segmented" layoutId="deploy-tabs">
|
||||
GitHub Actions
|
||||
</ClientTabsTrigger>
|
||||
</ClientTabsList>
|
||||
<ClientTabsContent value={"github"}>
|
||||
<StepNumber stepNumber="1" title="Connect your GitHub repository" />
|
||||
<StepContentContainer>
|
||||
<Paragraph spacing>
|
||||
Deploy automatically with every push. Read the{" "}
|
||||
<TextLink to={docsPath("github-integration")}>full guide</TextLink>.
|
||||
</Paragraph>
|
||||
<div className="w-fit">
|
||||
<GitHubSettingsPanel
|
||||
organizationSlug={organization.slug}
|
||||
projectSlug={project.slug}
|
||||
environmentSlug={environment.slug}
|
||||
billingPath={v3BillingPath({ slug: organization.slug })}
|
||||
/>
|
||||
</div>
|
||||
</StepContentContainer>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent value={"cli"}>
|
||||
<StepNumber stepNumber="1" title="Run the CLI 'deploy' command" />
|
||||
<StepContentContainer>
|
||||
<Paragraph spacing>
|
||||
This will deploy your tasks to the {environmentFullTitle(environment)} environment.
|
||||
Read the <TextLink to={docsPath("deployment/overview")}>full guide</TextLink>.
|
||||
</Paragraph>
|
||||
<TriggerDeployStep environment={environment} />
|
||||
</StepContentContainer>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent value={"github-actions"}>
|
||||
<StepNumber stepNumber="1" title="Deploy using GitHub Actions" />
|
||||
<StepContentContainer>
|
||||
<Paragraph spacing>
|
||||
Read the <TextLink to={docsPath("github-actions")}>GitHub Actions guide</TextLink> to
|
||||
get started.
|
||||
</Paragraph>
|
||||
</StepContentContainer>
|
||||
</ClientTabsContent>
|
||||
</ClientTabs>
|
||||
|
||||
<StepNumber stepNumber="2" title="Waiting for tasks to deploy" displaySpinner />
|
||||
<StepContentContainer>
|
||||
<Paragraph>This page will automatically refresh when your tasks are deployed.</Paragraph>
|
||||
</StepContentContainer>
|
||||
</PackageManagerProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { HomeIcon } from "@heroicons/react/20/solid";
|
||||
import { isRouteErrorResponse, useRouteError } from "@remix-run/react";
|
||||
import { motion } from "framer-motion";
|
||||
import { friendlyErrorDisplay } from "~/utils/httpErrors";
|
||||
import { LinkButton } from "./primitives/Buttons";
|
||||
import { Header1 } from "./primitives/Headers";
|
||||
import { Paragraph } from "./primitives/Paragraph";
|
||||
import Spline from "@splinetool/react-spline";
|
||||
import { TriggerRotatingLogo } from "./TriggerRotatingLogo";
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
type ErrorDisplayOptions = {
|
||||
@@ -57,14 +56,7 @@ export function ErrorDisplay({ title, message, button }: DisplayOptionsProps) {
|
||||
{button ? button.title : "Go to homepage"}
|
||||
</LinkButton>
|
||||
</div>
|
||||
<motion.div
|
||||
className="pointer-events-none absolute inset-0 overflow-hidden"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.5, duration: 2, ease: "easeOut" }}
|
||||
>
|
||||
<Spline scene="https://prod.spline.design/wRly8TZN-e0Twb8W/scene.splinecode" />
|
||||
</motion.div>
|
||||
<TriggerRotatingLogo />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { conform, useForm } from "@conform-to/react";
|
||||
import { parse } from "@conform-to/zod";
|
||||
import { InformationCircleIcon, ArrowUpCircleIcon } from "@heroicons/react/20/solid";
|
||||
import { EnvelopeIcon } from "@heroicons/react/24/solid";
|
||||
import { Form, useActionData, useLocation, useNavigation } from "@remix-run/react";
|
||||
import { Form, useActionData, useLocation, useNavigation, useSearchParams } from "@remix-run/react";
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
import { type FeedbackType, feedbackTypeLabel, schema } from "~/routes/resources.feedback";
|
||||
import { Button } from "./primitives/Buttons";
|
||||
@@ -23,10 +23,12 @@ import { DialogClose } from "@radix-ui/react-dialog";
|
||||
type FeedbackProps = {
|
||||
button: ReactNode;
|
||||
defaultValue?: FeedbackType;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export function Feedback({ button, defaultValue = "bug" }: FeedbackProps) {
|
||||
export function Feedback({ button, defaultValue = "bug", onOpenChange }: FeedbackProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const location = useLocation();
|
||||
const lastSubmission = useActionData();
|
||||
const navigation = useNavigation();
|
||||
@@ -52,8 +54,26 @@ export function Feedback({ button, defaultValue = "bug" }: FeedbackProps) {
|
||||
}
|
||||
}, [navigation, form]);
|
||||
|
||||
// Handle URL param functionality
|
||||
useEffect(() => {
|
||||
const open = searchParams.get("feedbackPanel");
|
||||
if (open) {
|
||||
setType(open as FeedbackType);
|
||||
setOpen(true);
|
||||
// Clone instead of mutating in place
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.delete("feedbackPanel");
|
||||
setSearchParams(next);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
setOpen(value);
|
||||
onOpenChange?.(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger asChild>{button}</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>Contact us</DialogHeader>
|
||||
|
||||
@@ -134,6 +134,10 @@ function ShortcutContent() {
|
||||
<ShortcutKey shortcut={{ key: "arrowleft" }} variant="medium/bright" />
|
||||
<ShortcutKey shortcut={{ key: "arrowright" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Jump to next/previous run">
|
||||
<ShortcutKey shortcut={{ key: "[" }} variant="medium/bright" />
|
||||
<ShortcutKey shortcut={{ key: "]" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Expand all">
|
||||
<ShortcutKey shortcut={{ key: "e" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
declare global {
|
||||
namespace JSX {
|
||||
interface IntrinsicElements {
|
||||
"spline-viewer": React.DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLElement> & {
|
||||
url?: string;
|
||||
"loading-anim-type"?: string;
|
||||
},
|
||||
HTMLElement
|
||||
>;
|
||||
}
|
||||
}
|
||||
|
||||
interface Window {
|
||||
__splineLoader?: Promise<void>;
|
||||
}
|
||||
}
|
||||
|
||||
export function TriggerRotatingLogo() {
|
||||
const [isSplineReady, setIsSplineReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Already registered from a previous render
|
||||
if (customElements.get("spline-viewer")) {
|
||||
setIsSplineReady(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Another mount already started loading - share the same promise
|
||||
if (window.__splineLoader) {
|
||||
window.__splineLoader.then(() => setIsSplineReady(true)).catch(() => setIsSplineReady(false));
|
||||
return;
|
||||
}
|
||||
|
||||
// First mount: create script and shared loader promise
|
||||
const script = document.createElement("script");
|
||||
script.type = "module";
|
||||
// Version pinned; SRI hash omitted as unpkg doesn't guarantee hash stability across deploys
|
||||
script.src = "https://unpkg.com/@splinetool/viewer@1.12.29/build/spline-viewer.js";
|
||||
|
||||
window.__splineLoader = new Promise<void>((resolve, reject) => {
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => reject();
|
||||
});
|
||||
|
||||
window.__splineLoader.then(() => setIsSplineReady(true)).catch(() => setIsSplineReady(false));
|
||||
|
||||
document.head.appendChild(script);
|
||||
|
||||
// Intentionally no cleanup: once the custom element is registered globally,
|
||||
// removing the script would break re-mounts while providing no benefit
|
||||
}, []);
|
||||
|
||||
if (!isSplineReady) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="pointer-events-none absolute inset-0 overflow-hidden"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.5, duration: 2, ease: "easeOut" }}
|
||||
>
|
||||
<spline-viewer
|
||||
loading-anim-type="spinner-small-light"
|
||||
url="https://prod.spline.design/wRly8TZN-e0Twb8W/scene.splinecode"
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ export function UserAvatar({
|
||||
className={cn("aspect-square rounded-full p-[7%]")}
|
||||
src={avatarUrl}
|
||||
alt={name ?? "User"}
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -51,10 +51,14 @@ export function EnvironmentCombo({
|
||||
environment,
|
||||
className,
|
||||
iconClassName,
|
||||
tooltipSideOffset,
|
||||
tooltipSide,
|
||||
}: {
|
||||
environment: Environment;
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
tooltipSideOffset?: number;
|
||||
tooltipSide?: "top" | "right" | "bottom" | "left";
|
||||
}) {
|
||||
return (
|
||||
<span className={cn("flex items-center gap-1.5 text-sm text-text-bright", className)}>
|
||||
@@ -62,7 +66,11 @@ export function EnvironmentCombo({
|
||||
environment={environment}
|
||||
className={cn("size-4.5 shrink-0", iconClassName)}
|
||||
/>
|
||||
<EnvironmentLabel environment={environment} />
|
||||
<EnvironmentLabel
|
||||
environment={environment}
|
||||
tooltipSideOffset={tooltipSideOffset}
|
||||
tooltipSide={tooltipSide}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -70,9 +78,13 @@ export function EnvironmentCombo({
|
||||
export function EnvironmentLabel({
|
||||
environment,
|
||||
className,
|
||||
tooltipSideOffset = 34,
|
||||
tooltipSide = "right",
|
||||
}: {
|
||||
environment: Environment;
|
||||
className?: string;
|
||||
tooltipSideOffset?: number;
|
||||
tooltipSide?: "top" | "right" | "bottom" | "left";
|
||||
}) {
|
||||
const spanRef = useRef<HTMLSpanElement>(null);
|
||||
const [isTruncated, setIsTruncated] = useState(false);
|
||||
@@ -115,9 +127,10 @@ export function EnvironmentLabel({
|
||||
{text}
|
||||
</span>
|
||||
}
|
||||
side="right"
|
||||
side={tooltipSide}
|
||||
variant="dark"
|
||||
sideOffset={34}
|
||||
sideOffset={tooltipSideOffset}
|
||||
disableHoverableContent
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import { Link, useNavigation } from "@remix-run/react";
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import simplur from "simplur";
|
||||
import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons";
|
||||
import { ConcurrencyIcon } from "~/assets/icons/ConcurrencyIcon";
|
||||
import { ListCheckedIcon } from "~/assets/icons/ListCheckedIcon";
|
||||
import { RunsIconExtraSmall } from "~/assets/icons/RunsIcon";
|
||||
import { TaskIconSmall } from "~/assets/icons/TaskIcon";
|
||||
@@ -43,6 +44,7 @@ import {
|
||||
accountPath,
|
||||
adminPath,
|
||||
branchesPath,
|
||||
concurrencyPath,
|
||||
logoutPath,
|
||||
newOrganizationPath,
|
||||
newProjectPath,
|
||||
@@ -122,6 +124,7 @@ export function SideMenu({
|
||||
const { isConnected } = useDevPresence();
|
||||
const isFreeUser = currentPlan?.v3Subscription?.isPaying === false;
|
||||
const isAdmin = useHasAdminAccess();
|
||||
const { isManagedCloud } = useFeatures();
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
@@ -313,6 +316,15 @@ export function SideMenu({
|
||||
data-action="preview-branches"
|
||||
badge={<V4Badge />}
|
||||
/>
|
||||
{isManagedCloud && (
|
||||
<SideMenuItem
|
||||
name="Concurrency"
|
||||
icon={ConcurrencyIcon}
|
||||
activeIconColor="text-amber-500"
|
||||
to={concurrencyPath(organization, project, environment)}
|
||||
data-action="concurrency"
|
||||
/>
|
||||
)}
|
||||
<SideMenuItem
|
||||
name="Regions"
|
||||
icon={GlobeAmericasIcon}
|
||||
|
||||
@@ -331,7 +331,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonPropsType>(
|
||||
type LinkPropsType = Pick<
|
||||
LinkProps,
|
||||
"to" | "target" | "onClick" | "onMouseDown" | "onMouseEnter" | "onMouseLeave" | "download"
|
||||
> & { disabled?: boolean } & React.ComponentProps<typeof ButtonContent>;
|
||||
> & { disabled?: boolean; replace?: boolean } & React.ComponentProps<typeof ButtonContent>;
|
||||
export const LinkButton = ({
|
||||
to,
|
||||
onClick,
|
||||
@@ -340,6 +340,7 @@ export const LinkButton = ({
|
||||
onMouseLeave,
|
||||
download,
|
||||
disabled = false,
|
||||
replace,
|
||||
...props
|
||||
}: LinkPropsType) => {
|
||||
const innerRef = useRef<HTMLAnchorElement>(null);
|
||||
@@ -372,7 +373,7 @@ export const LinkButton = ({
|
||||
<ExtLink
|
||||
href={to.toString()}
|
||||
ref={innerRef}
|
||||
className={cn("group/button focus-custom", props.fullWidth ? "w-full" : "")}
|
||||
className={cn("group/button block focus-custom", props.fullWidth ? "w-full" : "")}
|
||||
onClick={onClick}
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseEnter={onMouseEnter}
|
||||
@@ -387,7 +388,8 @@ export const LinkButton = ({
|
||||
<Link
|
||||
to={to}
|
||||
ref={innerRef}
|
||||
className={cn("group/button focus-custom", props.fullWidth ? "w-full" : "")}
|
||||
replace={replace}
|
||||
className={cn("group/button block focus-custom", props.fullWidth ? "w-full" : "w-fit")}
|
||||
onClick={onClick}
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseEnter={onMouseEnter}
|
||||
@@ -408,7 +410,7 @@ export const NavLinkButton = ({ to, className, target, ...props }: NavLinkPropsT
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
className={cn("group/button outline-none", props.fullWidth ? "w-full" : "")}
|
||||
className={cn("group/button outline-none block", props.fullWidth ? "w-full" : "")}
|
||||
target={target}
|
||||
>
|
||||
{({ isActive, isPending }) => (
|
||||
|
||||
@@ -1,41 +1,185 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { motion } from "framer-motion";
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
import * as React from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { type Variants } from "./Tabs";
|
||||
|
||||
type ClientTabsContextValue = {
|
||||
value?: string;
|
||||
};
|
||||
|
||||
const ClientTabsContext = React.createContext<ClientTabsContextValue | undefined>(undefined);
|
||||
|
||||
function useClientTabsContext() {
|
||||
return React.useContext(ClientTabsContext);
|
||||
}
|
||||
|
||||
const ClientTabs = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Root>
|
||||
>((props, ref) => <TabsPrimitive.Root ref={ref} {...props} />);
|
||||
>(({ onValueChange, value: valueProp, defaultValue, ...props }, ref) => {
|
||||
const [value, setValue] = React.useState<string | undefined>(valueProp ?? defaultValue);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (valueProp !== undefined) {
|
||||
setValue(valueProp);
|
||||
}
|
||||
}, [valueProp]);
|
||||
|
||||
const handleValueChange = React.useCallback(
|
||||
(nextValue: string) => {
|
||||
if (valueProp === undefined) {
|
||||
setValue(nextValue);
|
||||
}
|
||||
onValueChange?.(nextValue);
|
||||
},
|
||||
[onValueChange, valueProp]
|
||||
);
|
||||
|
||||
const controlledProps =
|
||||
valueProp !== undefined
|
||||
? { value: valueProp }
|
||||
: defaultValue !== undefined
|
||||
? { defaultValue }
|
||||
: {};
|
||||
|
||||
const contextValue = React.useMemo<ClientTabsContextValue>(() => ({ value }), [value]);
|
||||
|
||||
return (
|
||||
<ClientTabsContext.Provider value={contextValue}>
|
||||
<TabsPrimitive.Root
|
||||
ref={ref}
|
||||
onValueChange={handleValueChange}
|
||||
{...controlledProps}
|
||||
{...props}
|
||||
/>
|
||||
</ClientTabsContext.Provider>
|
||||
);
|
||||
});
|
||||
ClientTabs.displayName = TabsPrimitive.Root.displayName;
|
||||
|
||||
const ClientTabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn("inline-flex items-center justify-center transition duration-100", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> & {
|
||||
variant?: Variants;
|
||||
}
|
||||
>(({ className, variant = "pipe-divider", ...props }, ref) => {
|
||||
const variantClassName = (() => {
|
||||
switch (variant) {
|
||||
case "segmented":
|
||||
return "relative flex h-10 w-full items-center rounded bg-charcoal-700/50 p-1";
|
||||
case "underline":
|
||||
return "flex gap-x-6 border-b border-grid-bright";
|
||||
default:
|
||||
return "inline-flex items-center justify-center transition duration-100";
|
||||
}
|
||||
})();
|
||||
|
||||
return <TabsPrimitive.List ref={ref} className={cn(variantClassName, className)} {...props} />;
|
||||
});
|
||||
ClientTabsList.displayName = TabsPrimitive.List.displayName;
|
||||
|
||||
const ClientTabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"ring-offset-background focus-visible:ring-ring inline-flex items-center justify-center whitespace-nowrap border-r border-charcoal-700 px-2 text-sm transition-all first:pl-0 last:border-none data-[state=active]:text-indigo-500 data-[state=inactive]:text-text-dimmed data-[state=inactive]:hover:text-text-bright focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger> & {
|
||||
variant?: Variants;
|
||||
layoutId?: string;
|
||||
}
|
||||
>(({ className, variant = "pipe-divider", layoutId, children, ...props }, ref) => {
|
||||
const context = useClientTabsContext();
|
||||
const activeValue = context?.value;
|
||||
const isActive = activeValue === props.value;
|
||||
|
||||
if (variant === "segmented") {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"group relative flex h-full grow items-center justify-center focus-custom disabled:pointer-events-none disabled:opacity-50",
|
||||
"flex-1 basis-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="relative z-10 flex h-full w-full items-center justify-center px-3 py-[0.13rem]">
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm transition duration-200",
|
||||
isActive
|
||||
? "text-text-bright"
|
||||
: "text-text-dimmed transition group-hover:text-text-bright"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
</div>
|
||||
{isActive ? (
|
||||
layoutId ? (
|
||||
<motion.div
|
||||
layoutId={layoutId}
|
||||
transition={{ duration: 0.4, type: "spring" }}
|
||||
className="absolute inset-0 rounded-[2px] border border-charcoal-500/50 bg-charcoal-600"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 rounded-[2px] border border-charcoal-500/50 bg-charcoal-600" />
|
||||
)
|
||||
) : null}
|
||||
</TabsPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "underline") {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"group flex flex-col items-center pt-1 focus-custom disabled:pointer-events-none disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm transition duration-200",
|
||||
isActive ? "text-text-bright" : "text-text-dimmed hover:text-text-bright"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
{layoutId ? (
|
||||
isActive ? (
|
||||
<motion.div
|
||||
layoutId={layoutId}
|
||||
transition={{ type: "spring", stiffness: 500, damping: 30 }}
|
||||
className="mt-1 h-0.5 w-full bg-indigo-500"
|
||||
/>
|
||||
) : (
|
||||
<div className="mt-1 h-0.5 w-full bg-charcoal-500 opacity-0 transition duration-200 group-hover:opacity-100" />
|
||||
)
|
||||
) : isActive ? (
|
||||
<div className="mt-1 h-0.5 w-full bg-indigo-500" />
|
||||
) : (
|
||||
<div className="mt-1 h-0.5 w-full bg-charcoal-500 opacity-0 transition duration-200 group-hover:opacity-100" />
|
||||
)}
|
||||
</TabsPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"ring-offset-background focus-visible:ring-ring inline-flex items-center justify-center whitespace-nowrap border-r border-charcoal-700 px-2 text-sm transition-all first:pl-0 last:border-none data-[state=active]:text-indigo-500 data-[state=inactive]:text-text-dimmed data-[state=inactive]:hover:text-text-bright focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</TabsPrimitive.Trigger>
|
||||
);
|
||||
});
|
||||
ClientTabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||
|
||||
const ClientTabsContent = React.forwardRef<
|
||||
@@ -61,39 +205,7 @@ export type TabsProps = {
|
||||
currentValue: string;
|
||||
className?: string;
|
||||
layoutId: string;
|
||||
variant?: Variants;
|
||||
};
|
||||
|
||||
export function ClientTabsWithUnderline({ className, tabs, currentValue, layoutId }: TabsProps) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
className={cn(`flex flex-row gap-x-6 border-b border-charcoal-700`, className)}
|
||||
>
|
||||
{tabs.map((tab, index) => {
|
||||
const isActive = currentValue === tab.value;
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
key={tab.value}
|
||||
value={tab.value}
|
||||
className={cn(`group flex flex-col items-center`, className)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm transition duration-200",
|
||||
isActive ? "text-indigo-500" : "text-charcoal-200"
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</span>
|
||||
{isActive ? (
|
||||
<motion.div layoutId={layoutId} className="mt-1 h-0.5 w-full bg-indigo-500" />
|
||||
) : (
|
||||
<div className="mt-1 h-0.5 w-full bg-charcoal-500 opacity-0 transition duration-200 group-hover:opacity-100" />
|
||||
)}
|
||||
</TabsPrimitive.Trigger>
|
||||
);
|
||||
})}
|
||||
</TabsPrimitive.List>
|
||||
);
|
||||
}
|
||||
|
||||
export { ClientTabs, ClientTabsList, ClientTabsTrigger, ClientTabsContent };
|
||||
export { ClientTabs, ClientTabsContent, ClientTabsList, ClientTabsTrigger };
|
||||
|
||||
@@ -3,59 +3,96 @@ import { useState } from "react";
|
||||
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import { useCopy } from "~/hooks/useCopy";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Button } from "./Buttons";
|
||||
|
||||
export function CopyableText({
|
||||
value,
|
||||
copyValue,
|
||||
className,
|
||||
asChild,
|
||||
variant,
|
||||
}: {
|
||||
value: string;
|
||||
copyValue?: string;
|
||||
className?: string;
|
||||
asChild?: boolean;
|
||||
variant?: "icon-right" | "text-below";
|
||||
}) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const { copy, copied } = useCopy(copyValue ?? value);
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn("group relative inline-flex h-6 items-center", className)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<span onMouseEnter={() => setIsHovered(true)}>{value}</span>
|
||||
const resolvedVariant = variant ?? "icon-right";
|
||||
|
||||
if (resolvedVariant === "icon-right") {
|
||||
return (
|
||||
<span
|
||||
onClick={copy}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className={cn(
|
||||
"absolute -right-6 top-0 z-10 size-6 font-sans",
|
||||
isHovered ? "flex" : "hidden"
|
||||
)}
|
||||
className={cn("group relative inline-flex h-6 items-center", className)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<span
|
||||
className={cn(
|
||||
"ml-1 flex size-6 items-center justify-center rounded border border-charcoal-650 bg-charcoal-750",
|
||||
asChild && "p-1",
|
||||
copied
|
||||
? "text-green-500"
|
||||
: "text-text-dimmed hover:border-charcoal-600 hover:bg-charcoal-700 hover:text-text-bright"
|
||||
)}
|
||||
>
|
||||
{copied ? (
|
||||
<ClipboardCheckIcon className="size-3.5" />
|
||||
) : (
|
||||
<ClipboardIcon className="size-3.5" />
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
content={copied ? "Copied!" : "Copy"}
|
||||
className="font-sans"
|
||||
disableHoverableContent
|
||||
asChild={asChild}
|
||||
/>
|
||||
<span onMouseEnter={() => setIsHovered(true)}>{value}</span>
|
||||
<span
|
||||
onClick={copy}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className={cn(
|
||||
"absolute -right-6 top-0 z-10 size-6 font-sans",
|
||||
isHovered ? "flex" : "hidden"
|
||||
)}
|
||||
>
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<span
|
||||
className={cn(
|
||||
"ml-1 flex size-6 items-center justify-center rounded border border-charcoal-650 bg-charcoal-750",
|
||||
asChild && "p-1",
|
||||
copied
|
||||
? "text-green-500"
|
||||
: "text-text-dimmed hover:border-charcoal-600 hover:bg-charcoal-700 hover:text-text-bright"
|
||||
)}
|
||||
>
|
||||
{copied ? (
|
||||
<ClipboardCheckIcon className="size-3.5" />
|
||||
) : (
|
||||
<ClipboardIcon className="size-3.5" />
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
content={copied ? "Copied!" : "Copy"}
|
||||
className="font-sans"
|
||||
disableHoverableContent
|
||||
asChild={asChild}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
if (resolvedVariant === "text-below") {
|
||||
return (
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<Button
|
||||
variant="minimal/small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
copy();
|
||||
}}
|
||||
className={cn(
|
||||
"cursor-pointer bg-transparent px-1 py-0 text-left text-text-dimmed transition-colors hover:bg-transparent",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className="transition-colors group-hover/button:text-text-bright">{value}</span>
|
||||
</Button>
|
||||
}
|
||||
content={copied ? "Copied" : "Copy"}
|
||||
className="px-2 py-1 font-sans"
|
||||
disableHoverableContent
|
||||
open={isHovered || copied}
|
||||
onOpenChange={setIsHovered}
|
||||
asChild
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -3,10 +3,12 @@ import { cn } from "~/utils/cn";
|
||||
export function FormButtons({
|
||||
cancelButton,
|
||||
confirmButton,
|
||||
defaultAction,
|
||||
className,
|
||||
}: {
|
||||
cancelButton?: React.ReactNode;
|
||||
confirmButton: React.ReactNode;
|
||||
defaultAction?: { name: string; value: string; disabled?: boolean };
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
@@ -16,6 +18,17 @@ export function FormButtons({
|
||||
className
|
||||
)}
|
||||
>
|
||||
{defaultAction && (
|
||||
<button
|
||||
type="submit"
|
||||
name={defaultAction.name}
|
||||
value={defaultAction.value}
|
||||
disabled={defaultAction.disabled}
|
||||
className="hidden"
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{cancelButton ? cancelButton : <div />} {confirmButton}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -44,6 +44,24 @@ const variants = {
|
||||
iconSize: "size-3 ml-0.5",
|
||||
accessory: "pr-0.5",
|
||||
},
|
||||
"outline/large": {
|
||||
container: "px-1 h-10 w-full rounded border border-grid-bright hover:border-charcoal-550",
|
||||
input: "px-2 rounded text-sm",
|
||||
iconSize: "size-4 ml-1",
|
||||
accessory: "pr-1",
|
||||
},
|
||||
"outline/medium": {
|
||||
container: "px-1 h-8 w-full rounded border border-grid-bright hover:border-charcoal-550",
|
||||
input: "px-1 rounded text-sm",
|
||||
iconSize: "size-4 ml-0.5",
|
||||
accessory: "pr-1",
|
||||
},
|
||||
"outline/small": {
|
||||
container: "px-1 h-6 w-full rounded border border-grid-bright hover:border-charcoal-550",
|
||||
input: "px-1 rounded text-xs",
|
||||
iconSize: "size-3 ml-0.5",
|
||||
accessory: "pr-0.5",
|
||||
},
|
||||
};
|
||||
|
||||
export type InputProps = React.InputHTMLAttributes<HTMLInputElement> & {
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import { MinusIcon, PlusIcon } from "@heroicons/react/20/solid";
|
||||
import { type ChangeEvent, useRef } from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
type InputNumberStepperProps = Omit<JSX.IntrinsicElements["input"], "min" | "max" | "step"> & {
|
||||
step?: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
round?: boolean;
|
||||
controlSize?: "base" | "large";
|
||||
};
|
||||
|
||||
export function InputNumberStepper({
|
||||
value,
|
||||
onChange,
|
||||
step = 50,
|
||||
min,
|
||||
max,
|
||||
round = true,
|
||||
controlSize = "base",
|
||||
name,
|
||||
id,
|
||||
disabled = false,
|
||||
readOnly = false,
|
||||
className,
|
||||
placeholder = "Type a number",
|
||||
...props
|
||||
}: InputNumberStepperProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleStepUp = () => {
|
||||
if (!inputRef.current || disabled) return;
|
||||
|
||||
// If rounding is enabled, ensure we start from a rounded base before stepping
|
||||
if (round) {
|
||||
// If field is empty, treat as 0 (or min if provided) before stepping up
|
||||
if (inputRef.current.value === "") {
|
||||
inputRef.current.value = String(min ?? 0);
|
||||
} else {
|
||||
commitRoundedFromInput();
|
||||
}
|
||||
}
|
||||
inputRef.current.stepUp();
|
||||
const event = new Event("change", { bubbles: true });
|
||||
inputRef.current.dispatchEvent(event);
|
||||
};
|
||||
|
||||
const handleStepDown = () => {
|
||||
if (!inputRef.current || disabled) return;
|
||||
|
||||
// If rounding is enabled, ensure we start from a rounded base before stepping
|
||||
if (round) {
|
||||
// If field is empty, treat as 0 (or min if provided) before stepping down
|
||||
if (inputRef.current.value === "") {
|
||||
inputRef.current.value = String(min ?? 0);
|
||||
} else {
|
||||
commitRoundedFromInput();
|
||||
}
|
||||
}
|
||||
inputRef.current.stepDown();
|
||||
const event = new Event("change", { bubbles: true });
|
||||
inputRef.current.dispatchEvent(event);
|
||||
};
|
||||
|
||||
const numericValue = value === "" ? NaN : (value as number);
|
||||
const isMinDisabled = min !== undefined && !Number.isNaN(numericValue) && numericValue <= min;
|
||||
const isMaxDisabled = max !== undefined && !Number.isNaN(numericValue) && numericValue >= max;
|
||||
|
||||
function clamp(val: number): number {
|
||||
if (Number.isNaN(val)) return typeof value === "number" ? value : min ?? 0;
|
||||
let next = val;
|
||||
if (min !== undefined) next = Math.max(min, next);
|
||||
if (max !== undefined) next = Math.min(max, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
function roundToStep(val: number): number {
|
||||
if (step <= 0) return val;
|
||||
const base = min ?? 0;
|
||||
const shifted = val - base;
|
||||
const quotient = shifted / step;
|
||||
const floored = Math.floor(quotient);
|
||||
const ceiled = Math.ceil(quotient);
|
||||
const down = base + floored * step;
|
||||
const up = base + ceiled * step;
|
||||
const distDown = Math.abs(val - down);
|
||||
const distUp = Math.abs(up - val);
|
||||
return distUp < distDown ? up : down;
|
||||
}
|
||||
|
||||
function commitRoundedFromInput() {
|
||||
if (!inputRef.current || disabled || readOnly) return;
|
||||
const el = inputRef.current;
|
||||
const raw = el.value;
|
||||
if (raw === "") return; // do not coerce empty to 0; keep placeholder visible
|
||||
const numeric = Number(raw);
|
||||
if (Number.isNaN(numeric)) return; // ignore non-numeric
|
||||
const rounded = clamp(roundToStep(numeric));
|
||||
if (String(rounded) === String(value)) return;
|
||||
// Update the real input's value for immediate UI feedback
|
||||
el.value = String(rounded);
|
||||
// Invoke consumer onChange with the real element as target/currentTarget
|
||||
onChange?.({
|
||||
target: el,
|
||||
currentTarget: el,
|
||||
} as unknown as ChangeEvent<HTMLInputElement>);
|
||||
}
|
||||
|
||||
const sizeStyles = {
|
||||
base: {
|
||||
container: "h-9",
|
||||
input: "text-sm px-3",
|
||||
button: "size-6",
|
||||
icon: "size-3.5",
|
||||
gap: "gap-1 pr-1.5",
|
||||
},
|
||||
large: {
|
||||
container: "h-11 rounded-md",
|
||||
input: "text-base px-3.5",
|
||||
button: "size-8",
|
||||
icon: "size-5",
|
||||
gap: "gap-[0.3125rem] pr-[0.3125rem]",
|
||||
},
|
||||
} as const;
|
||||
|
||||
const size = sizeStyles[controlSize];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center rounded border border-charcoal-600 bg-tertiary transition hover:border-charcoal-550/80 hover:bg-charcoal-600/80",
|
||||
size.container,
|
||||
"has-[:focus-visible]:outline has-[:focus-visible]:outline-1 has-[:focus-visible]:outline-offset-0 has-[:focus-visible]:outline-text-link",
|
||||
disabled && "cursor-not-allowed opacity-50",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="number"
|
||||
id={id}
|
||||
name={name}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => {
|
||||
// Allow empty string to pass through so user can clear the field
|
||||
if (e.currentTarget.value === "") {
|
||||
// reflect emptiness in the input and notify consumer as empty
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
onChange?.({
|
||||
target: e.currentTarget,
|
||||
currentTarget: e.currentTarget,
|
||||
} as ChangeEvent<HTMLInputElement>);
|
||||
return;
|
||||
}
|
||||
onChange?.(e);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
// If blur is caused by clicking our step buttons, we prevent pointerdown
|
||||
// so blur shouldn't fire. This is for safety in case of keyboard focus move.
|
||||
if (round) commitRoundedFromInput();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && round) {
|
||||
e.preventDefault();
|
||||
commitRoundedFromInput();
|
||||
}
|
||||
}}
|
||||
step={step}
|
||||
min={min}
|
||||
max={max}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
className={cn(
|
||||
"placeholder:text-muted-foreground h-full grow border-0 bg-transparent text-left text-text-bright outline-none ring-0 focus:border-0 focus:outline-none focus:ring-0 disabled:cursor-not-allowed",
|
||||
size.input,
|
||||
// Hide number input arrows
|
||||
"[type=number]:border-0 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
<div className={cn("flex items-center", size.gap)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleStepDown}
|
||||
onPointerDown={(e) => e.preventDefault()}
|
||||
disabled={disabled || isMinDisabled}
|
||||
aria-label={`Decrease by ${step}`}
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded border border-error/30 bg-error/20 transition",
|
||||
size.button,
|
||||
"hover:border-error/50 hover:bg-error/30",
|
||||
"disabled:cursor-not-allowed disabled:opacity-40",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-text-link"
|
||||
)}
|
||||
>
|
||||
<MinusIcon className={cn("text-error", size.icon)} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleStepUp}
|
||||
onPointerDown={(e) => e.preventDefault()}
|
||||
disabled={disabled || isMaxDisabled}
|
||||
aria-label={`Increase by ${step}`}
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded border border-success/30 bg-success/10 transition",
|
||||
size.button,
|
||||
"hover:border-success/40 hover:bg-success/20",
|
||||
"disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-text-link"
|
||||
)}
|
||||
>
|
||||
<PlusIcon className={cn("text-success", size.icon)} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,11 +9,11 @@ import { useOperatingSystem } from "./OperatingSystemProvider";
|
||||
import { KeyboardEnterIcon } from "~/assets/icons/KeyboardEnterIcon";
|
||||
|
||||
const medium =
|
||||
"text-[0.75rem] font-medium min-w-[17px] rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1.5 border border-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-dimmed/60 transition uppercase";
|
||||
"justify-center min-w-[1.25rem] min-h-[1.25rem] text-[0.65rem] font-mono font-medium rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1.5 border border-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-dimmed/60 transition uppercase";
|
||||
|
||||
export const variants = {
|
||||
small:
|
||||
"text-[0.6rem] font-medium min-w-[17px] rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1 border border-text-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-text-dimmed/60 transition uppercase",
|
||||
"justify-center text-[0.6rem] font-mono font-medium min-w-[1rem] min-h-[1rem] rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1 border border-text-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-text-dimmed/60 transition uppercase",
|
||||
medium: cn(medium, "group-hover:border-charcoal-550"),
|
||||
"medium/bright": cn(medium, "bg-charcoal-750 text-text-bright border-charcoal-650"),
|
||||
};
|
||||
@@ -57,7 +57,7 @@ export function ShortcutKey({ shortcut, variant, className }: ShortcutKeyProps)
|
||||
function keyString(key: string, isMac: boolean, variant: "small" | "medium" | "medium/bright") {
|
||||
key = key.toLowerCase();
|
||||
|
||||
const className = variant === "small" ? "w-2.5 h-4" : "w-3 h-5";
|
||||
const className = variant === "small" ? "w-2.5 h-4" : "w-2.5 h-4.5";
|
||||
|
||||
switch (key) {
|
||||
case "enter":
|
||||
|
||||
@@ -15,6 +15,14 @@ const variants = {
|
||||
menuButtonDivider: "group-hover/table-row:border-charcoal-600/70",
|
||||
rowSelected: "bg-charcoal-750 group-hover/table-row:bg-charcoal-750",
|
||||
},
|
||||
"bright/no-hover": {
|
||||
header: "bg-transparent",
|
||||
cell: "group-hover/table-row:bg-transparent",
|
||||
stickyCell: "bg-background-bright",
|
||||
menuButton: "bg-background-bright",
|
||||
menuButtonDivider: "",
|
||||
rowSelected: "bg-charcoal-750",
|
||||
},
|
||||
dimmed: {
|
||||
header: "bg-background-dimmed",
|
||||
cell: "group-hover/table-row:bg-charcoal-800 group-has-[[tabindex='0']:focus]/table-row:bg-background-bright",
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { NavLink } from "@remix-run/react";
|
||||
import { motion } from "framer-motion";
|
||||
import { ReactNode, useRef } from "react";
|
||||
import { ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys";
|
||||
import { type ReactNode, useRef } from "react";
|
||||
import { type ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { ShortcutKey } from "./ShortcutKey";
|
||||
|
||||
export type Variants = "underline" | "pipe-divider" | "segmented";
|
||||
|
||||
export type TabsProps = {
|
||||
tabs: {
|
||||
label: string;
|
||||
@@ -12,13 +14,14 @@ export type TabsProps = {
|
||||
}[];
|
||||
className?: string;
|
||||
layoutId: string;
|
||||
variant?: Variants;
|
||||
};
|
||||
|
||||
export function Tabs({ tabs, className, layoutId }: TabsProps) {
|
||||
export function Tabs({ tabs, className, layoutId, variant = "underline" }: TabsProps) {
|
||||
return (
|
||||
<TabContainer className={className}>
|
||||
<TabContainer className={className} variant={variant}>
|
||||
{tabs.map((tab, index) => (
|
||||
<TabLink key={index} to={tab.to} layoutId={layoutId}>
|
||||
<TabLink key={index} to={tab.to} layoutId={layoutId} variant={variant}>
|
||||
{tab.label}
|
||||
</TabLink>
|
||||
))}
|
||||
@@ -26,23 +29,107 @@ export function Tabs({ tabs, className, layoutId }: TabsProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function TabContainer({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<div className={cn(`flex flex-row gap-x-6 border-b border-grid-bright`, className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
export function TabContainer({
|
||||
children,
|
||||
className,
|
||||
variant = "underline",
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
variant?: Variants;
|
||||
}) {
|
||||
if (variant === "segmented") {
|
||||
return (
|
||||
<div
|
||||
className={cn("relative flex h-10 items-center rounded bg-charcoal-700/50 p-1", className)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "underline") {
|
||||
return (
|
||||
<div className={cn(`flex gap-x-6 border-b border-grid-bright`, className)}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className={cn(`flex`, className)}>{children}</div>;
|
||||
}
|
||||
|
||||
export function TabLink({
|
||||
to,
|
||||
children,
|
||||
layoutId,
|
||||
variant = "underline",
|
||||
}: {
|
||||
to: string;
|
||||
children: ReactNode;
|
||||
layoutId: string;
|
||||
variant?: Variants;
|
||||
}) {
|
||||
if (variant === "segmented") {
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
className="group relative flex h-full grow items-center justify-center focus-custom"
|
||||
end
|
||||
>
|
||||
{({ isActive, isPending }) => {
|
||||
const active = isActive || isPending;
|
||||
return (
|
||||
<>
|
||||
<div className="relative z-10 flex h-full w-full items-center justify-center px-3 py-[0.13rem]">
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm transition duration-200",
|
||||
active
|
||||
? "text-text-bright"
|
||||
: "text-text-dimmed transition group-hover:text-text-bright"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
</div>
|
||||
{active && (
|
||||
<motion.div
|
||||
layoutId={layoutId}
|
||||
transition={{ duration: 0.4, type: "spring" }}
|
||||
className="absolute inset-0 rounded-[2px] border border-charcoal-500/50 bg-charcoal-600"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "pipe-divider") {
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
className="group flex flex-col items-center border-r border-charcoal-700 px-2 pt-1 focus-custom first:pl-0 last:border-none"
|
||||
end
|
||||
>
|
||||
{({ isActive, isPending }) => {
|
||||
const active = isActive || isPending;
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm transition duration-200",
|
||||
active ? "text-text-link" : "text-text-dimmed transition hover:text-text-bright"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}}
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
// underline variant (default)
|
||||
return (
|
||||
<NavLink to={to} className="group flex flex-col items-center pt-1 focus-custom" end>
|
||||
{({ isActive, isPending }) => {
|
||||
@@ -51,13 +138,19 @@ export function TabLink({
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm transition duration-200",
|
||||
isActive || isPending ? "text-text-bright" : "text-text-bright"
|
||||
isActive || isPending
|
||||
? "text-text-bright"
|
||||
: "text-text-dimmed hover:text-text-bright"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
{isActive || isPending ? (
|
||||
<motion.div layoutId={layoutId} className="mt-1 h-0.5 w-full bg-indigo-500" />
|
||||
<motion.div
|
||||
layoutId={layoutId}
|
||||
transition={{ type: "spring", stiffness: 500, damping: 30 }}
|
||||
className="mt-1 h-0.5 w-full bg-indigo-500"
|
||||
/>
|
||||
) : (
|
||||
<div className="mt-1 h-0.5 w-full bg-charcoal-500 opacity-0 transition duration-200 group-hover:opacity-100" />
|
||||
)}
|
||||
@@ -106,17 +199,18 @@ export function TabButton({
|
||||
<>
|
||||
<div className="flex items-center gap-1">
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm transition duration-200",
|
||||
isActive ? "text-text-bright" : "text-text-bright"
|
||||
)}
|
||||
className={"text-sm transition duration-200 text-text-bright"}
|
||||
>
|
||||
{props.children}
|
||||
</span>
|
||||
{shortcut && <ShortcutKey className={cn("")} shortcut={shortcut} variant={"small"} />}
|
||||
</div>
|
||||
{isActive ? (
|
||||
<motion.div layoutId={layoutId} className="mt-1 h-0.5 w-full bg-indigo-500" />
|
||||
<motion.div
|
||||
layoutId={layoutId}
|
||||
transition={{ type: "spring", stiffness: 500, damping: 30 }}
|
||||
className="mt-1 h-0.5 w-full bg-indigo-500"
|
||||
/>
|
||||
) : (
|
||||
<div className="mt-1 h-0.5 w-full bg-charcoal-500 opacity-0 transition duration-200 group-hover:opacity-100" />
|
||||
)}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { ExclamationCircleIcon, XMarkIcon } from "@heroicons/react/20/solid";
|
||||
import { EnvelopeIcon, ExclamationCircleIcon, XMarkIcon } from "@heroicons/react/20/solid";
|
||||
import { CheckCircleIcon } from "@heroicons/react/24/solid";
|
||||
import { Toaster, toast } from "sonner";
|
||||
|
||||
import { useTypedLoaderData } from "remix-typedjson";
|
||||
import { loader } from "~/root";
|
||||
import { useSearchParams } from "@remix-run/react";
|
||||
import { useEffect } from "react";
|
||||
import { Paragraph } from "./Paragraph";
|
||||
import { useTypedLoaderData } from "remix-typedjson";
|
||||
import { Toaster, toast } from "sonner";
|
||||
import { type ToastMessageAction } from "~/models/message.server";
|
||||
import { type loader } from "~/root";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Button, LinkButton } from "./Buttons";
|
||||
import { Header2 } from "./Headers";
|
||||
import { Paragraph } from "./Paragraph";
|
||||
|
||||
const defaultToastDuration = 5000;
|
||||
const permanentToastDuration = 60 * 60 * 24 * 1000;
|
||||
@@ -19,9 +22,22 @@ export function Toast() {
|
||||
}
|
||||
const { message, type, options } = toastMessage;
|
||||
|
||||
toast.custom((t) => <ToastUI variant={type} message={message} t={t as string} />, {
|
||||
duration: options.ephemeral ? defaultToastDuration : permanentToastDuration,
|
||||
});
|
||||
const ephemeral = options.action ? false : options.ephemeral;
|
||||
|
||||
toast.custom(
|
||||
(t) => (
|
||||
<ToastUI
|
||||
variant={type}
|
||||
message={message}
|
||||
t={t as string}
|
||||
title={options.title}
|
||||
action={options.action}
|
||||
/>
|
||||
),
|
||||
{
|
||||
duration: ephemeral ? defaultToastDuration : permanentToastDuration,
|
||||
}
|
||||
);
|
||||
}, [toastMessage]);
|
||||
|
||||
return <Toaster />;
|
||||
@@ -32,11 +48,15 @@ export function ToastUI({
|
||||
message,
|
||||
t,
|
||||
toastWidth = 356, // Default width, matches what sonner provides by default
|
||||
title,
|
||||
action,
|
||||
}: {
|
||||
variant: "error" | "success";
|
||||
message: string;
|
||||
t: string;
|
||||
toastWidth?: string | number;
|
||||
title?: string;
|
||||
action?: ToastMessageAction;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
@@ -51,13 +71,19 @@ export function ToastUI({
|
||||
>
|
||||
<div className="flex w-full items-start gap-2 rounded-lg p-3">
|
||||
{variant === "success" ? (
|
||||
<CheckCircleIcon className="mt-1 size-6 min-w-6 text-success" />
|
||||
<CheckCircleIcon className="mt-1 size-4 min-w-4 text-success" />
|
||||
) : (
|
||||
<ExclamationCircleIcon className="mt-1 size-6 min-w-6 text-error" />
|
||||
<ExclamationCircleIcon className="mt-1 size-4 min-w-4 text-error" />
|
||||
)}
|
||||
<Paragraph className="py-1 text-text-bright">{message}</Paragraph>
|
||||
<div className="flex flex-col">
|
||||
{title && <Header2 className="pt-0">{title}</Header2>}
|
||||
<Paragraph variant="small/dimmed" className="pb-1 pt-0.5">
|
||||
{message}
|
||||
</Paragraph>
|
||||
<Action action={action} toastId={t} className="my-2" />
|
||||
</div>
|
||||
<button
|
||||
className="hover:bg-midnight-800 ms-auto rounded p-2 text-text-dimmed transition hover:text-text-bright"
|
||||
className="hover:bg-midnight-800 -mr-1 -mt-1 ms-auto rounded p-2 text-text-dimmed transition hover:text-text-bright"
|
||||
onClick={() => toast.dismiss(t)}
|
||||
>
|
||||
<XMarkIcon className="size-4" />
|
||||
@@ -66,3 +92,49 @@ export function ToastUI({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Action({
|
||||
action,
|
||||
toastId,
|
||||
className,
|
||||
}: {
|
||||
action?: ToastMessageAction;
|
||||
toastId: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const [_, setSearchParams] = useSearchParams();
|
||||
|
||||
if (!action) return null;
|
||||
|
||||
switch (action.action.type) {
|
||||
case "link": {
|
||||
return (
|
||||
<LinkButton
|
||||
className={className}
|
||||
variant={action.variant ?? "secondary/small"}
|
||||
to={action.action.path}
|
||||
>
|
||||
{action.label}
|
||||
</LinkButton>
|
||||
);
|
||||
}
|
||||
case "help": {
|
||||
const feedbackType = action.action.feedbackType;
|
||||
return (
|
||||
<Button
|
||||
className={className}
|
||||
variant={action.variant ?? "secondary/small"}
|
||||
LeadingIcon={EnvelopeIcon}
|
||||
onClick={() => {
|
||||
setSearchParams({
|
||||
feedbackPanel: feedbackType,
|
||||
});
|
||||
toast.dismiss(toastId);
|
||||
}}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,8 @@ function SimpleTooltip({
|
||||
buttonStyle,
|
||||
asChild = false,
|
||||
sideOffset,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
button: React.ReactNode;
|
||||
content: React.ReactNode;
|
||||
@@ -76,11 +78,14 @@ function SimpleTooltip({
|
||||
buttonStyle?: React.CSSProperties;
|
||||
asChild?: boolean;
|
||||
sideOffset?: number;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<TooltipProvider disableHoverableContent={disableHoverableContent}>
|
||||
<Tooltip>
|
||||
<Tooltip open={open} onOpenChange={onOpenChange}>
|
||||
<TooltipTrigger
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
className={cn("h-fit", buttonClassName)}
|
||||
style={buttonStyle}
|
||||
@@ -107,11 +112,13 @@ export function InfoIconTooltip({
|
||||
buttonClassName,
|
||||
contentClassName,
|
||||
variant = "basic",
|
||||
disableHoverableContent = false,
|
||||
}: {
|
||||
content: React.ReactNode;
|
||||
buttonClassName?: string;
|
||||
contentClassName?: string;
|
||||
variant?: Variant;
|
||||
disableHoverableContent?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<SimpleTooltip
|
||||
@@ -121,6 +128,7 @@ export function InfoIconTooltip({
|
||||
content={content}
|
||||
variant={variant}
|
||||
className={contentClassName}
|
||||
disableHoverableContent={disableHoverableContent}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -423,6 +423,10 @@ export function useTree<TData, TFilterValue>({
|
||||
}
|
||||
case "Left":
|
||||
case "ArrowLeft": {
|
||||
if (e.metaKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
const selected = selectedIdFromState(state.nodes);
|
||||
|
||||
@@ -39,14 +39,14 @@ export function concreteStateFromInput({
|
||||
}
|
||||
const nodes = concreteStateFromPartialState(tree, state);
|
||||
|
||||
return {
|
||||
return applyFilterToState({
|
||||
tree,
|
||||
nodes,
|
||||
changes: { selectedId },
|
||||
filter,
|
||||
filteredNodes: nodes,
|
||||
visibleNodeIds: visibleNodes(tree, nodes).map((node) => node.id),
|
||||
};
|
||||
visibleNodeIds: [],
|
||||
});
|
||||
}
|
||||
|
||||
export function concreteStateFromPartialState<TData>(
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
import { CheckCircleIcon, XCircleIcon } from "@heroicons/react/20/solid";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
XCircleIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import type { BatchTaskRunStatus } from "@trigger.dev/database";
|
||||
import assertNever from "assert-never";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
export const allBatchStatuses = ["PENDING", "COMPLETED", "ABORTED"] as const satisfies Readonly<
|
||||
Array<BatchTaskRunStatus>
|
||||
>;
|
||||
export const allBatchStatuses = [
|
||||
"PROCESSING",
|
||||
"PENDING",
|
||||
"COMPLETED",
|
||||
"PARTIAL_FAILED",
|
||||
"ABORTED",
|
||||
] as const satisfies Readonly<Array<BatchTaskRunStatus>>;
|
||||
|
||||
const descriptions: Record<BatchTaskRunStatus, string> = {
|
||||
PROCESSING: "The batch is being processed and runs are being created.",
|
||||
PENDING: "The batch has child runs that have not yet completed.",
|
||||
COMPLETED: "All the batch child runs have finished.",
|
||||
ABORTED: "The batch was aborted because some child tasks could not be triggered.",
|
||||
PARTIAL_FAILED: "Some runs failed to be created. Successfully created runs are still executing.",
|
||||
ABORTED: "The batch was aborted because child tasks could not be triggered.",
|
||||
};
|
||||
|
||||
export function descriptionForBatchStatus(status: BatchTaskRunStatus): string {
|
||||
@@ -47,10 +57,14 @@ export function BatchStatusIcon({
|
||||
className: string;
|
||||
}) {
|
||||
switch (status) {
|
||||
case "PROCESSING":
|
||||
return <Spinner className={cn(batchStatusColor(status), className)} />;
|
||||
case "PENDING":
|
||||
return <Spinner className={cn(batchStatusColor(status), className)} />;
|
||||
case "COMPLETED":
|
||||
return <CheckCircleIcon className={cn(batchStatusColor(status), className)} />;
|
||||
case "PARTIAL_FAILED":
|
||||
return <ExclamationTriangleIcon className={cn(batchStatusColor(status), className)} />;
|
||||
case "ABORTED":
|
||||
return <XCircleIcon className={cn(batchStatusColor(status), className)} />;
|
||||
default: {
|
||||
@@ -61,10 +75,14 @@ export function BatchStatusIcon({
|
||||
|
||||
export function batchStatusColor(status: BatchTaskRunStatus): string {
|
||||
switch (status) {
|
||||
case "PROCESSING":
|
||||
return "text-blue-500";
|
||||
case "PENDING":
|
||||
return "text-pending";
|
||||
case "COMPLETED":
|
||||
return "text-success";
|
||||
case "PARTIAL_FAILED":
|
||||
return "text-warning";
|
||||
case "ABORTED":
|
||||
return "text-error";
|
||||
default: {
|
||||
@@ -75,10 +93,14 @@ export function batchStatusColor(status: BatchTaskRunStatus): string {
|
||||
|
||||
export function batchStatusTitle(status: BatchTaskRunStatus): string {
|
||||
switch (status) {
|
||||
case "PROCESSING":
|
||||
return "Processing";
|
||||
case "PENDING":
|
||||
return "In progress";
|
||||
case "COMPLETED":
|
||||
return "Completed";
|
||||
case "PARTIAL_FAILED":
|
||||
return "Partial failure";
|
||||
case "ABORTED":
|
||||
return "Aborted";
|
||||
default: {
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
type NextRunListItem,
|
||||
} from "~/presenters/v3/NextRunListPresenter.server";
|
||||
import { formatCurrencyAccurate } from "~/utils/numberFormatter";
|
||||
import { docsPath, v3RunSpanPath, v3TestPath } from "~/utils/pathBuilder";
|
||||
import { docsPath, v3RunSpanPath, v3TestPath,v3TestTaskPath } from "~/utils/pathBuilder";
|
||||
import { DateTime } from "../../primitives/DateTime";
|
||||
import { Paragraph } from "../../primitives/Paragraph";
|
||||
import { Spinner } from "../../primitives/Spinner";
|
||||
@@ -55,6 +55,8 @@ import {
|
||||
filterableTaskRunStatuses,
|
||||
TaskRunStatusCombo,
|
||||
} from "./TaskRunStatus";
|
||||
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
|
||||
type RunsTableProps = {
|
||||
total: number;
|
||||
@@ -62,9 +64,11 @@ type RunsTableProps = {
|
||||
filters: NextRunListAppliedFilters;
|
||||
showJob?: boolean;
|
||||
runs: NextRunListItem[];
|
||||
rootOnlyDefault?: boolean;
|
||||
isLoading?: boolean;
|
||||
allowSelection?: boolean;
|
||||
variant?: TableVariant;
|
||||
disableAdjacentRows?: boolean;
|
||||
};
|
||||
|
||||
export function TaskRunsTable({
|
||||
@@ -72,6 +76,8 @@ export function TaskRunsTable({
|
||||
hasFilters,
|
||||
filters,
|
||||
runs,
|
||||
rootOnlyDefault,
|
||||
disableAdjacentRows = false,
|
||||
isLoading = false,
|
||||
allowSelection = false,
|
||||
variant = "dimmed",
|
||||
@@ -81,6 +87,12 @@ export function TaskRunsTable({
|
||||
const checkboxes = useRef<(HTMLInputElement | null)[]>([]);
|
||||
const { has, hasAll, select, deselect, toggle } = useSelectedItems(allowSelection);
|
||||
const { isManagedCloud } = useFeatures();
|
||||
const { value } = useSearchParams();
|
||||
const location = useOptimisticLocation();
|
||||
const rootOnly = value("rootOnly") ? `` : `rootOnly=${rootOnlyDefault}`;
|
||||
const search = rootOnly ? `${rootOnly}&${location.search}` : location.search;
|
||||
/** TableState has to be encoded as a separate URI component, so it's merged under one, 'tableState' param */
|
||||
const tableStateParam = disableAdjacentRows ? '' : encodeURIComponent(search);
|
||||
|
||||
const showCompute = isManagedCloud;
|
||||
|
||||
@@ -293,16 +305,20 @@ export function TaskRunsTable({
|
||||
<BlankState isLoading={isLoading} filters={filters} />
|
||||
) : (
|
||||
runs.map((run, index) => {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (tableStateParam) {
|
||||
searchParams.set("tableState", tableStateParam);
|
||||
}
|
||||
const path = v3RunSpanPath(organization, project, run.environment, run, {
|
||||
spanId: run.spanId,
|
||||
});
|
||||
}, searchParams);
|
||||
return (
|
||||
<TableRow key={run.id}>
|
||||
{allowSelection && (
|
||||
<TableCell className="pl-3 pr-0">
|
||||
<Checkbox
|
||||
checked={has(run.friendlyId)}
|
||||
onChange={(element) => {
|
||||
onChange={() => {
|
||||
toggle(run.friendlyId);
|
||||
}}
|
||||
ref={(r) => {
|
||||
@@ -565,6 +581,8 @@ function BlankState({ isLoading, filters }: Pick<RunsTableProps, "isLoading" | "
|
||||
if (isLoading) return <TableBlankRow colSpan={15}></TableBlankRow>;
|
||||
|
||||
const { tasks, from, to, ...otherFilters } = filters;
|
||||
const singleTaskFromFilters = filters.tasks.length === 1 ? filters.tasks[0] : null;
|
||||
const testPath = singleTaskFromFilters ? v3TestTaskPath(organization, project, environment, {taskIdentifier: singleTaskFromFilters}) : v3TestPath(organization, project, environment);
|
||||
|
||||
if (
|
||||
filters.tasks.length === 1 &&
|
||||
@@ -579,7 +597,7 @@ function BlankState({ isLoading, filters }: Pick<RunsTableProps, "isLoading" | "
|
||||
</Paragraph>
|
||||
<div className="mt-6 flex items-center justify-center gap-2">
|
||||
<LinkButton
|
||||
to={v3TestPath(organization, project, environment)}
|
||||
to={testPath}
|
||||
variant="tertiary/medium"
|
||||
LeadingIcon={BeakerIcon}
|
||||
className="inline-flex"
|
||||
@@ -620,7 +638,7 @@ function BlankState({ isLoading, filters }: Pick<RunsTableProps, "isLoading" | "
|
||||
<LinkButton
|
||||
LeadingIcon={BeakerIcon}
|
||||
variant="tertiary/medium"
|
||||
to={v3TestPath(organization, project, environment)}
|
||||
to={testPath}
|
||||
>
|
||||
Run a test
|
||||
</LinkButton>
|
||||
|
||||
@@ -1,22 +1,28 @@
|
||||
import {
|
||||
createReadableStreamFromReadable,
|
||||
type DataFunctionArgs,
|
||||
type EntryContext,
|
||||
} from "@remix-run/node"; // or cloudflare/deno
|
||||
import { createReadableStreamFromReadable, type EntryContext } from "@remix-run/node"; // or cloudflare/deno
|
||||
import { RemixServer } from "@remix-run/react";
|
||||
import { wrapHandleErrorWithSentry } from "@sentry/remix";
|
||||
import { parseAcceptLanguage } from "intl-parse-accept-language";
|
||||
import isbot from "isbot";
|
||||
import { renderToPipeableStream } from "react-dom/server";
|
||||
import { PassThrough } from "stream";
|
||||
import * as Worker from "~/services/worker.server";
|
||||
import { bootstrap } from "./bootstrap";
|
||||
import { LocaleContextProvider } from "./components/primitives/LocaleProvider";
|
||||
import {
|
||||
OperatingSystemContextProvider,
|
||||
OperatingSystemPlatform,
|
||||
} from "./components/primitives/OperatingSystemProvider";
|
||||
import { Prisma } from "./db.server";
|
||||
import { env } from "./env.server";
|
||||
import { eventLoopMonitor } from "./eventLoopMonitor.server";
|
||||
import { logger } from "./services/logger.server";
|
||||
import { resourceMonitor } from "./services/resourceMonitor.server";
|
||||
import { singleton } from "./utils/singleton";
|
||||
import { bootstrap } from "./bootstrap";
|
||||
import { wrapHandleErrorWithSentry } from "@sentry/remix";
|
||||
import { remoteBuildsEnabled } from "./v3/remoteImageBuilder.server";
|
||||
import {
|
||||
registerRunEngineEventBusHandlers,
|
||||
setupBatchQueueCallbacks,
|
||||
} from "./v3/runEngineHandlers.server";
|
||||
|
||||
const ABORT_DELAY = 30000;
|
||||
|
||||
@@ -228,19 +234,13 @@ process.on("uncaughtException", (error, origin) => {
|
||||
});
|
||||
|
||||
singleton("RunEngineEventBusHandlers", registerRunEngineEventBusHandlers);
|
||||
singleton("SetupBatchQueueCallbacks", setupBatchQueueCallbacks);
|
||||
|
||||
export { apiRateLimiter } from "./services/apiRateLimit.server";
|
||||
export { engineRateLimiter } from "./services/engineRateLimit.server";
|
||||
export { runWithHttpContext } from "./services/httpAsyncStorage.server";
|
||||
export { socketIo } from "./v3/handleSocketIo.server";
|
||||
export { wss } from "./v3/handleWebsockets.server";
|
||||
export { runWithHttpContext } from "./services/httpAsyncStorage.server";
|
||||
import { eventLoopMonitor } from "./eventLoopMonitor.server";
|
||||
import { env } from "./env.server";
|
||||
import { logger } from "./services/logger.server";
|
||||
import { Prisma } from "./db.server";
|
||||
import { registerRunEngineEventBusHandlers } from "./v3/runEngineHandlers.server";
|
||||
import { remoteBuildsEnabled } from "./v3/remoteImageBuilder.server";
|
||||
import { resourceMonitor } from "./services/resourceMonitor.server";
|
||||
|
||||
if (env.EVENT_LOOP_MONITOR_ENABLED === "1") {
|
||||
eventLoopMonitor.enable();
|
||||
|
||||
@@ -94,6 +94,8 @@ const EnvironmentSchema = z
|
||||
TRIGGER_TELEMETRY_DISABLED: z.string().optional(),
|
||||
AUTH_GITHUB_CLIENT_ID: z.string().optional(),
|
||||
AUTH_GITHUB_CLIENT_SECRET: z.string().optional(),
|
||||
AUTH_GOOGLE_CLIENT_ID: z.string().optional(),
|
||||
AUTH_GOOGLE_CLIENT_SECRET: z.string().optional(),
|
||||
EMAIL_TRANSPORT: z.enum(["resend", "smtp", "aws-ses"]).optional(),
|
||||
FROM_EMAIL: z.string().optional(),
|
||||
REPLY_TO_EMAIL: z.string().optional(),
|
||||
@@ -345,6 +347,12 @@ const EnvironmentSchema = z
|
||||
OBJECT_STORE_SECRET_ACCESS_KEY: z.string().optional(),
|
||||
OBJECT_STORE_REGION: z.string().optional(),
|
||||
OBJECT_STORE_SERVICE: z.string().default("s3"),
|
||||
|
||||
ARTIFACTS_OBJECT_STORE_BUCKET: z.string().optional(),
|
||||
ARTIFACTS_OBJECT_STORE_BASE_URL: z.string().optional(),
|
||||
ARTIFACTS_OBJECT_STORE_ACCESS_KEY_ID: z.string().optional(),
|
||||
ARTIFACTS_OBJECT_STORE_SECRET_ACCESS_KEY: z.string().optional(),
|
||||
ARTIFACTS_OBJECT_STORE_REGION: z.string().optional(),
|
||||
EVENTS_BATCH_SIZE: z.coerce.number().int().default(100),
|
||||
EVENTS_BATCH_INTERVAL: z.coerce.number().int().default(1000),
|
||||
EVENTS_DEFAULT_LOG_RETENTION: z.coerce.number().int().default(7),
|
||||
@@ -520,6 +528,7 @@ const EnvironmentSchema = z
|
||||
MAXIMUM_TRACE_SUMMARY_VIEW_COUNT: z.coerce.number().int().default(25_000),
|
||||
MAXIMUM_TRACE_DETAILED_SUMMARY_VIEW_COUNT: z.coerce.number().int().default(10_000),
|
||||
TASK_PAYLOAD_OFFLOAD_THRESHOLD: z.coerce.number().int().default(524_288), // 512KB
|
||||
BATCH_PAYLOAD_OFFLOAD_THRESHOLD: z.coerce.number().int().optional(), // Defaults to TASK_PAYLOAD_OFFLOAD_THRESHOLD if not set
|
||||
TASK_PAYLOAD_MAXIMUM_SIZE: z.coerce.number().int().default(3_145_728), // 3MB
|
||||
BATCH_TASK_PAYLOAD_MAXIMUM_SIZE: z.coerce.number().int().default(1_000_000), // 1MB
|
||||
TASK_RUN_METADATA_MAXIMUM_SIZE: z.coerce.number().int().default(262_144), // 256KB
|
||||
@@ -529,6 +538,14 @@ const EnvironmentSchema = z
|
||||
MAX_BATCH_V2_TRIGGER_ITEMS: z.coerce.number().int().default(500),
|
||||
MAX_BATCH_AND_WAIT_V2_TRIGGER_ITEMS: z.coerce.number().int().default(500),
|
||||
|
||||
// 2-phase batch API settings
|
||||
STREAMING_BATCH_MAX_ITEMS: z.coerce.number().int().default(1_000), // Max items in streaming batch
|
||||
STREAMING_BATCH_ITEM_MAXIMUM_SIZE: z.coerce.number().int().default(3_145_728),
|
||||
BATCH_RATE_LIMIT_REFILL_RATE: z.coerce.number().int().default(100),
|
||||
BATCH_RATE_LIMIT_MAX: z.coerce.number().int().default(1200),
|
||||
BATCH_RATE_LIMIT_REFILL_INTERVAL: z.string().default("10s"),
|
||||
BATCH_CONCURRENCY_LIMIT_DEFAULT: z.coerce.number().int().default(1),
|
||||
|
||||
REALTIME_STREAM_VERSION: z.enum(["v1", "v2"]).default("v1"),
|
||||
REALTIME_STREAM_MAX_LENGTH: z.coerce.number().int().default(1000),
|
||||
REALTIME_STREAM_TTL: z.coerce
|
||||
@@ -594,6 +611,12 @@ const EnvironmentSchema = z
|
||||
.default(60_000),
|
||||
RUN_ENGINE_SUSPENDED_HEARTBEAT_RETRIES_FACTOR: z.coerce.number().default(2),
|
||||
|
||||
/** Maximum duration in milliseconds that a run can be debounced. Default: 1 hour (3,600,000ms) */
|
||||
RUN_ENGINE_MAXIMUM_DEBOUNCE_DURATION_MS: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.default(60_000 * 60), // 1 hour
|
||||
|
||||
RUN_ENGINE_WORKER_REDIS_HOST: z
|
||||
.string()
|
||||
.optional()
|
||||
@@ -923,6 +946,23 @@ const EnvironmentSchema = z
|
||||
.default(process.env.REDIS_TLS_DISABLED ?? "false"),
|
||||
BATCH_TRIGGER_WORKER_REDIS_CLUSTER_MODE_ENABLED: z.string().default("0"),
|
||||
|
||||
// BatchQueue DRR settings (Run Engine v2)
|
||||
BATCH_QUEUE_DRR_QUANTUM: z.coerce.number().int().default(25),
|
||||
BATCH_QUEUE_MAX_DEFICIT: z.coerce.number().int().default(100),
|
||||
BATCH_QUEUE_CONSUMER_COUNT: z.coerce.number().int().default(3),
|
||||
BATCH_QUEUE_CONSUMER_INTERVAL_MS: z.coerce.number().int().default(50),
|
||||
// Number of master queue shards for horizontal scaling
|
||||
BATCH_QUEUE_SHARD_COUNT: z.coerce.number().int().default(1),
|
||||
// Maximum queues to fetch from master queue per iteration
|
||||
BATCH_QUEUE_MASTER_QUEUE_LIMIT: z.coerce.number().int().default(1000),
|
||||
// Enable worker queue for two-stage processing (claim messages, push to worker queue, process from worker queue)
|
||||
BATCH_QUEUE_WORKER_QUEUE_ENABLED: BoolEnv.default(true),
|
||||
// Worker queue blocking timeout in seconds (for two-stage processing, only used when BATCH_QUEUE_WORKER_QUEUE_ENABLED is true)
|
||||
BATCH_QUEUE_WORKER_QUEUE_TIMEOUT_SECONDS: z.coerce.number().int().default(10),
|
||||
// Global rate limit: max items processed per second across all consumers
|
||||
// If not set, no global rate limiting is applied
|
||||
BATCH_QUEUE_GLOBAL_RATE_LIMIT: z.coerce.number().int().positive().optional(),
|
||||
|
||||
ADMIN_WORKER_ENABLED: z.string().default(process.env.WORKER_ENABLED ?? "true"),
|
||||
ADMIN_WORKER_CONCURRENCY_WORKERS: z.coerce.number().int().default(2),
|
||||
ADMIN_WORKER_CONCURRENCY_TASKS_PER_WORKER: z.coerce.number().int().default(10),
|
||||
@@ -1149,8 +1189,15 @@ const EnvironmentSchema = z
|
||||
EVENTS_CLICKHOUSE_WAIT_FOR_ASYNC_INSERT: z.string().default("1"),
|
||||
EVENTS_CLICKHOUSE_ASYNC_INSERT_MAX_DATA_SIZE: z.coerce.number().int().default(10485760),
|
||||
EVENTS_CLICKHOUSE_ASYNC_INSERT_BUSY_TIMEOUT_MS: z.coerce.number().int().default(5000),
|
||||
EVENTS_CLICKHOUSE_START_TIME_MAX_AGE_MS: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.default(60_000 * 5), // 5 minutes
|
||||
EVENT_REPOSITORY_CLICKHOUSE_ROLLOUT_PERCENT: z.coerce.number().optional(),
|
||||
EVENT_REPOSITORY_DEFAULT_STORE: z.enum(["postgres", "clickhouse"]).default("postgres"),
|
||||
EVENT_REPOSITORY_DEFAULT_STORE: z
|
||||
.enum(["postgres", "clickhouse", "clickhouse_v2"])
|
||||
.default("postgres"),
|
||||
EVENT_REPOSITORY_DEBUG_LOGS_DISABLED: BoolEnv.default(false),
|
||||
EVENTS_CLICKHOUSE_MAX_TRACE_SUMMARY_VIEW_COUNT: z.coerce.number().int().default(25_000),
|
||||
EVENTS_CLICKHOUSE_MAX_TRACE_DETAILED_SUMMARY_VIEW_COUNT: z.coerce.number().int().default(5_000),
|
||||
EVENTS_CLICKHOUSE_MAX_LIVE_RELOADING_SETTING: z.coerce.number().int().default(2000),
|
||||
@@ -1221,6 +1268,7 @@ const EnvironmentSchema = z
|
||||
EVENT_LOOP_MONITOR_THRESHOLD_MS: z.coerce.number().int().default(100),
|
||||
EVENT_LOOP_MONITOR_UTILIZATION_INTERVAL_MS: z.coerce.number().int().default(1000),
|
||||
EVENT_LOOP_MONITOR_UTILIZATION_SAMPLE_RATE: z.coerce.number().default(0.05),
|
||||
EVENT_LOOP_MONITOR_NOTIFY_ENABLED: z.string().default("0"),
|
||||
|
||||
VERY_SLOW_QUERY_THRESHOLD_MS: z.coerce.number().int().optional(),
|
||||
|
||||
|
||||
@@ -9,6 +9,23 @@ import { signalsEmitter } from "./services/signals.server";
|
||||
|
||||
const THRESHOLD_NS = env.EVENT_LOOP_MONITOR_THRESHOLD_MS * 1e6;
|
||||
|
||||
// ANSI color codes for terminal output
|
||||
const RED = "\x1b[31m";
|
||||
const YELLOW = "\x1b[33m";
|
||||
const RESET = "\x1b[0m";
|
||||
|
||||
function notifyEventLoopBlocked(timeMs: number, asyncType: string): void {
|
||||
if (env.EVENT_LOOP_MONITOR_NOTIFY_ENABLED !== "1") {
|
||||
return;
|
||||
}
|
||||
|
||||
console.warn(
|
||||
`${RED}⚠️ Event loop blocked${RESET} for ${YELLOW}${timeMs.toFixed(
|
||||
1
|
||||
)}ms${RESET} (${asyncType})`
|
||||
);
|
||||
}
|
||||
|
||||
const cache = new Map<number, { type: string; start?: [number, number]; parentCtx?: Context }>();
|
||||
|
||||
function init(asyncId: number, type: string, triggerAsyncId: number, resource: any) {
|
||||
@@ -66,6 +83,8 @@ function after(asyncId: number) {
|
||||
);
|
||||
|
||||
newSpan.end();
|
||||
|
||||
notifyEventLoopBlocked(time, cached.type);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { json, Session } from "@remix-run/node";
|
||||
import { createCookieSessionStorage } from "@remix-run/node";
|
||||
import { json, createCookieSessionStorage, type Session } from "@remix-run/node";
|
||||
import { redirect, typedjson } from "remix-typedjson";
|
||||
import { ButtonVariant } from "~/components/primitives/Buttons";
|
||||
import { env } from "~/env.server";
|
||||
import { type FeedbackType } from "~/routes/resources.feedback";
|
||||
|
||||
export type ToastMessage = {
|
||||
message: string;
|
||||
@@ -9,9 +10,26 @@ export type ToastMessage = {
|
||||
options: Required<ToastMessageOptions>;
|
||||
};
|
||||
|
||||
export type ToastMessageAction = {
|
||||
label: string;
|
||||
variant?: ButtonVariant;
|
||||
action:
|
||||
| {
|
||||
type: "link";
|
||||
path: string;
|
||||
}
|
||||
| {
|
||||
type: "help";
|
||||
feedbackType: FeedbackType;
|
||||
};
|
||||
};
|
||||
|
||||
export type ToastMessageOptions = {
|
||||
title?: string;
|
||||
/** Ephemeral means it disappears after a delay, defaults to true */
|
||||
ephemeral?: boolean;
|
||||
/** This display a button and make it not ephemeral, unless ephemeral is explicitlyset to false */
|
||||
action?: ToastMessageAction;
|
||||
};
|
||||
|
||||
const ONE_YEAR = 1000 * 60 * 60 * 24 * 365;
|
||||
@@ -36,6 +54,7 @@ export function setSuccessMessage(
|
||||
message,
|
||||
type: "success",
|
||||
options: {
|
||||
...options,
|
||||
ephemeral: options?.ephemeral ?? true,
|
||||
},
|
||||
} as ToastMessage);
|
||||
@@ -46,6 +65,7 @@ export function setErrorMessage(session: Session, message: string, options?: Toa
|
||||
message,
|
||||
type: "error",
|
||||
options: {
|
||||
...options,
|
||||
ephemeral: options?.ephemeral ?? true,
|
||||
},
|
||||
} as ToastMessage);
|
||||
|
||||
@@ -12,7 +12,7 @@ import { prisma, type PrismaClientOrTransaction } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import { featuresForUrl } from "~/features.server";
|
||||
import { createApiKeyForEnv, createPkApiKeyForEnv, envSlug } from "./api-key.server";
|
||||
|
||||
import { getDefaultEnvironmentConcurrencyLimit } from "~/services/platform.v3.server";
|
||||
export type { Organization };
|
||||
|
||||
const nanoid = customAlphabet("1234567890abcdef", 4);
|
||||
@@ -96,6 +96,8 @@ export async function createEnvironment({
|
||||
const pkApiKey = createPkApiKeyForEnv(type);
|
||||
const shortcode = createShortcode().join("-");
|
||||
|
||||
const limit = await getDefaultEnvironmentConcurrencyLimit(organization.id, type);
|
||||
|
||||
return await prismaClient.runtimeEnvironment.create({
|
||||
data: {
|
||||
slug,
|
||||
@@ -103,7 +105,7 @@ export async function createEnvironment({
|
||||
pkApiKey,
|
||||
shortcode,
|
||||
autoEnableInternalSources: type !== "DEVELOPMENT",
|
||||
maximumConcurrencyLimit: organization.maximumConcurrencyLimit / 3,
|
||||
maximumConcurrencyLimit: limit,
|
||||
organization: {
|
||||
connect: {
|
||||
id: organization.id,
|
||||
|
||||
@@ -16,12 +16,26 @@ type Options = {
|
||||
version: "v2" | "v3";
|
||||
};
|
||||
|
||||
export class ExceededProjectLimitError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ExceededProjectLimitError";
|
||||
}
|
||||
}
|
||||
|
||||
export async function createProject(
|
||||
{ organizationSlug, name, userId, version }: Options,
|
||||
attemptCount = 0
|
||||
): Promise<Project & { organization: Organization }> {
|
||||
//check the user has permissions to do this
|
||||
const organization = await prisma.organization.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
v3Enabled: true,
|
||||
maximumConcurrencyLimit: true,
|
||||
maximumProjectCount: true,
|
||||
},
|
||||
where: {
|
||||
slug: organizationSlug,
|
||||
members: { some: { userId } },
|
||||
@@ -40,6 +54,19 @@ export async function createProject(
|
||||
}
|
||||
}
|
||||
|
||||
const projectCount = await prisma.project.count({
|
||||
where: {
|
||||
organizationId: organization.id,
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (projectCount >= organization.maximumProjectCount) {
|
||||
throw new ExceededProjectLimitError(
|
||||
`This organization has reached the maximum number of projects (${organization.maximumProjectCount}).`
|
||||
);
|
||||
}
|
||||
|
||||
//ensure the slug is globally unique
|
||||
const uniqueProjectSlug = `${slug(name)}-${nanoid(4)}`;
|
||||
const projectWithSameSlug = await prisma.project.findFirst({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Prisma, User } from "@trigger.dev/database";
|
||||
import type { GitHubProfile } from "remix-auth-github";
|
||||
import type { GoogleProfile } from "remix-auth-google";
|
||||
import { prisma } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import {
|
||||
@@ -8,6 +9,8 @@ import {
|
||||
} from "~/services/dashboardPreferences.server";
|
||||
export type { User } from "@trigger.dev/database";
|
||||
import { assertEmailAllowed } from "~/utils/email";
|
||||
import { logger } from "~/services/logger.server";
|
||||
|
||||
type FindOrCreateMagicLink = {
|
||||
authenticationMethod: "MAGIC_LINK";
|
||||
email: string;
|
||||
@@ -20,7 +23,14 @@ type FindOrCreateGithub = {
|
||||
authenticationExtraParams: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type FindOrCreateUser = FindOrCreateMagicLink | FindOrCreateGithub;
|
||||
type FindOrCreateGoogle = {
|
||||
authenticationMethod: "GOOGLE";
|
||||
email: User["email"];
|
||||
authenticationProfile: GoogleProfile;
|
||||
authenticationExtraParams: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type FindOrCreateUser = FindOrCreateMagicLink | FindOrCreateGithub | FindOrCreateGoogle;
|
||||
|
||||
type LoggedInUser = {
|
||||
user: User;
|
||||
@@ -35,6 +45,9 @@ export async function findOrCreateUser(input: FindOrCreateUser): Promise<LoggedI
|
||||
case "MAGIC_LINK": {
|
||||
return findOrCreateMagicLinkUser(input);
|
||||
}
|
||||
case "GOOGLE": {
|
||||
return findOrCreateGoogleUser(input);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,6 +175,134 @@ export async function findOrCreateGithubUser({
|
||||
};
|
||||
}
|
||||
|
||||
export async function findOrCreateGoogleUser({
|
||||
email,
|
||||
authenticationProfile,
|
||||
authenticationExtraParams,
|
||||
}: FindOrCreateGoogle): Promise<LoggedInUser> {
|
||||
assertEmailAllowed(email);
|
||||
|
||||
const name = authenticationProfile._json.name;
|
||||
let avatarUrl: string | undefined = undefined;
|
||||
if (authenticationProfile.photos[0]) {
|
||||
avatarUrl = authenticationProfile.photos[0].value;
|
||||
}
|
||||
const displayName = authenticationProfile.displayName;
|
||||
const authProfile = authenticationProfile
|
||||
? (authenticationProfile as unknown as Prisma.JsonObject)
|
||||
: undefined;
|
||||
const authExtraParams = authenticationExtraParams
|
||||
? (authenticationExtraParams as unknown as Prisma.JsonObject)
|
||||
: undefined;
|
||||
|
||||
const authIdentifier = `google:${authenticationProfile.id}`;
|
||||
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: {
|
||||
authIdentifier,
|
||||
},
|
||||
});
|
||||
|
||||
const existingEmailUser = await prisma.user.findUnique({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingEmailUser && !existingUser) {
|
||||
// Link existing email account to Google auth, preserving original authenticationMethod
|
||||
const user = await prisma.user.update({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
data: {
|
||||
authenticationProfile: authProfile,
|
||||
authenticationExtraParams: authExtraParams,
|
||||
avatarUrl,
|
||||
authIdentifier,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
user,
|
||||
isNewUser: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (existingEmailUser && existingUser) {
|
||||
// Check if email user and auth user are the same
|
||||
if (existingEmailUser.id !== existingUser.id) {
|
||||
// Different users: email is taken by one user, Google auth belongs to another
|
||||
logger.error(
|
||||
`Google auth conflict: Google ID ${authenticationProfile.id} belongs to user ${existingUser.id} but email ${email} is taken by user ${existingEmailUser.id}`,
|
||||
{
|
||||
email,
|
||||
existingEmailUserId: existingEmailUser.id,
|
||||
existingAuthUserId: existingUser.id,
|
||||
authIdentifier,
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
user: existingUser,
|
||||
isNewUser: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Same user: update all profile fields
|
||||
const user = await prisma.user.update({
|
||||
where: {
|
||||
id: existingUser.id,
|
||||
},
|
||||
data: {
|
||||
email,
|
||||
displayName,
|
||||
name,
|
||||
avatarUrl,
|
||||
authenticationProfile: authProfile,
|
||||
authenticationExtraParams: authExtraParams,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
user,
|
||||
isNewUser: false,
|
||||
};
|
||||
}
|
||||
|
||||
// When the IDP user (Google) already exists, the "update" path will be taken and the email will be updated
|
||||
// It's not possible that the email is already taken by a different user because that would have been handled
|
||||
// by one of the if statements above.
|
||||
const user = await prisma.user.upsert({
|
||||
where: {
|
||||
authIdentifier,
|
||||
},
|
||||
update: {
|
||||
email,
|
||||
displayName,
|
||||
name,
|
||||
avatarUrl,
|
||||
authenticationProfile: authProfile,
|
||||
authenticationExtraParams: authExtraParams,
|
||||
},
|
||||
create: {
|
||||
authenticationProfile: authProfile,
|
||||
authenticationExtraParams: authExtraParams,
|
||||
name,
|
||||
avatarUrl,
|
||||
displayName,
|
||||
authIdentifier,
|
||||
email,
|
||||
authenticationMethod: "GOOGLE",
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
user,
|
||||
isNewUser: !existingUser,
|
||||
};
|
||||
}
|
||||
|
||||
export type UserWithDashboardPreferences = User & {
|
||||
dashboardPreferences: DashboardPreferences;
|
||||
};
|
||||
|
||||
@@ -195,7 +195,7 @@ WHERE
|
||||
throw new Error(`Environment not found for Batch ${batch.id}`);
|
||||
}
|
||||
|
||||
const hasFinished = batch.status !== "PENDING";
|
||||
const hasFinished = batch.status !== "PENDING" && batch.status !== "PROCESSING";
|
||||
|
||||
return {
|
||||
id: batch.id,
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { type BatchTaskRunStatus } from "@trigger.dev/database";
|
||||
import { displayableEnvironment } from "~/models/runtimeEnvironment.server";
|
||||
import { engine } from "~/v3/runEngine.server";
|
||||
import { BasePresenter } from "./basePresenter.server";
|
||||
|
||||
type BatchPresenterOptions = {
|
||||
environmentId: string;
|
||||
batchId: string;
|
||||
userId?: string;
|
||||
};
|
||||
|
||||
export type BatchPresenterData = Awaited<ReturnType<BatchPresenter["call"]>>;
|
||||
|
||||
export class BatchPresenter extends BasePresenter {
|
||||
public async call({ environmentId, batchId, userId }: BatchPresenterOptions) {
|
||||
const batch = await this._replica.batchTaskRun.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
friendlyId: true,
|
||||
status: true,
|
||||
runCount: true,
|
||||
batchVersion: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
completedAt: true,
|
||||
processingStartedAt: true,
|
||||
processingCompletedAt: true,
|
||||
successfulRunCount: true,
|
||||
failedRunCount: true,
|
||||
idempotencyKey: true,
|
||||
runtimeEnvironment: {
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
slug: true,
|
||||
orgMember: {
|
||||
select: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
displayName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
errors: {
|
||||
select: {
|
||||
id: true,
|
||||
index: true,
|
||||
taskIdentifier: true,
|
||||
error: true,
|
||||
errorCode: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: {
|
||||
index: "asc",
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
runtimeEnvironmentId: environmentId,
|
||||
friendlyId: batchId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!batch) {
|
||||
throw new Error("Batch not found");
|
||||
}
|
||||
|
||||
const hasFinished = batch.status !== "PENDING" && batch.status !== "PROCESSING";
|
||||
const isV2 = batch.batchVersion === "runengine:v2";
|
||||
|
||||
// For v2 batches in PROCESSING state, get live progress from Redis
|
||||
// This provides real-time updates without waiting for the batch to complete
|
||||
let liveSuccessCount = batch.successfulRunCount ?? 0;
|
||||
let liveFailureCount = batch.failedRunCount ?? 0;
|
||||
|
||||
if (isV2 && batch.status === "PROCESSING") {
|
||||
const liveProgress = await engine.getBatchQueueProgress(batch.id);
|
||||
if (liveProgress) {
|
||||
liveSuccessCount = liveProgress.successCount;
|
||||
liveFailureCount = liveProgress.failureCount;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: batch.id,
|
||||
friendlyId: batch.friendlyId,
|
||||
status: batch.status as BatchTaskRunStatus,
|
||||
runCount: batch.runCount,
|
||||
batchVersion: batch.batchVersion,
|
||||
isV2,
|
||||
createdAt: batch.createdAt.toISOString(),
|
||||
updatedAt: batch.updatedAt.toISOString(),
|
||||
completedAt: batch.completedAt?.toISOString(),
|
||||
processingStartedAt: batch.processingStartedAt?.toISOString(),
|
||||
processingCompletedAt: batch.processingCompletedAt?.toISOString(),
|
||||
finishedAt: batch.completedAt
|
||||
? batch.completedAt.toISOString()
|
||||
: hasFinished
|
||||
? batch.updatedAt.toISOString()
|
||||
: undefined,
|
||||
hasFinished,
|
||||
successfulRunCount: liveSuccessCount,
|
||||
failedRunCount: liveFailureCount,
|
||||
idempotencyKey: batch.idempotencyKey,
|
||||
environment: displayableEnvironment(batch.runtimeEnvironment, userId),
|
||||
errors: batch.errors.map((error) => ({
|
||||
id: error.id,
|
||||
index: error.index,
|
||||
taskIdentifier: error.taskIdentifier,
|
||||
error: error.error,
|
||||
errorCode: error.errorCode,
|
||||
createdAt: error.createdAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
BuildServerMetadata,
|
||||
DeploymentErrorData,
|
||||
ExternalBuildData,
|
||||
prepareDeploymentError,
|
||||
@@ -154,32 +155,40 @@ export class DeploymentPresenter {
|
||||
avatarUrl: true,
|
||||
},
|
||||
},
|
||||
buildServerMetadata: true,
|
||||
},
|
||||
});
|
||||
|
||||
const gitMetadata = processGitMetadata(deployment.git);
|
||||
|
||||
const externalBuildData = deployment.externalBuildData
|
||||
? ExternalBuildData.safeParse(deployment.externalBuildData)
|
||||
: undefined;
|
||||
const buildServerMetadata = deployment.buildServerMetadata
|
||||
? BuildServerMetadata.safeParse(deployment.buildServerMetadata)
|
||||
: undefined;
|
||||
|
||||
let s2Logs = undefined;
|
||||
if (env.S2_ENABLED === "1" && gitMetadata?.source === "trigger_github_app") {
|
||||
let eventStream = undefined;
|
||||
if (
|
||||
env.S2_ENABLED === "1" &&
|
||||
(buildServerMetadata || gitMetadata?.source === "trigger_github_app")
|
||||
) {
|
||||
const [error, accessToken] = await tryCatch(this.getS2AccessToken(project.externalRef));
|
||||
|
||||
if (error) {
|
||||
logger.error("Failed getting S2 access token", { error });
|
||||
} else {
|
||||
s2Logs = {
|
||||
basin: env.S2_DEPLOYMENT_LOGS_BASIN_NAME,
|
||||
stream: `projects/${project.externalRef}/deployments/${deployment.shortCode}`,
|
||||
accessToken,
|
||||
eventStream = {
|
||||
s2: {
|
||||
basin: env.S2_DEPLOYMENT_LOGS_BASIN_NAME,
|
||||
stream: `projects/${project.externalRef}/deployments/${deployment.shortCode}`,
|
||||
accessToken,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
s2Logs,
|
||||
eventStream,
|
||||
deployment: {
|
||||
id: deployment.id,
|
||||
shortCode: deployment.shortCode,
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { type PrismaClient } from "@trigger.dev/database";
|
||||
import { err, fromPromise, ok, ResultAsync } from "neverthrow";
|
||||
import { env } from "~/env.server";
|
||||
import { BranchTrackingConfigSchema } from "~/v3/github";
|
||||
import { BasePresenter } from "./basePresenter.server";
|
||||
|
||||
type GitHubSettingsOptions = {
|
||||
projectId: string;
|
||||
organizationId: string;
|
||||
};
|
||||
|
||||
export class GitHubSettingsPresenter extends BasePresenter {
|
||||
public call({ projectId, organizationId }: GitHubSettingsOptions) {
|
||||
const githubAppEnabled = env.GITHUB_APP_ENABLED === "1";
|
||||
|
||||
if (!githubAppEnabled) {
|
||||
return ok({
|
||||
enabled: false,
|
||||
connectedRepository: undefined,
|
||||
installations: undefined,
|
||||
isPreviewEnvironmentEnabled: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const findConnectedGithubRepository = () =>
|
||||
fromPromise(
|
||||
(this._replica as PrismaClient).connectedGithubRepository.findFirst({
|
||||
where: {
|
||||
projectId,
|
||||
repository: {
|
||||
installation: {
|
||||
deletedAt: null,
|
||||
suspendedAt: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
branchTracking: true,
|
||||
previewDeploymentsEnabled: true,
|
||||
createdAt: true,
|
||||
repository: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
fullName: true,
|
||||
htmlUrl: true,
|
||||
private: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
(error) => ({
|
||||
type: "other" as const,
|
||||
cause: error,
|
||||
})
|
||||
).map((connectedGithubRepository) => {
|
||||
if (!connectedGithubRepository) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const branchTrackingOrFailure = BranchTrackingConfigSchema.safeParse(
|
||||
connectedGithubRepository.branchTracking
|
||||
);
|
||||
const branchTracking = branchTrackingOrFailure.success
|
||||
? branchTrackingOrFailure.data
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
...connectedGithubRepository,
|
||||
branchTracking,
|
||||
};
|
||||
});
|
||||
|
||||
const listGithubAppInstallations = () =>
|
||||
fromPromise(
|
||||
(this._replica as PrismaClient).githubAppInstallation.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
deletedAt: null,
|
||||
suspendedAt: null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
accountHandle: true,
|
||||
targetType: true,
|
||||
appInstallationId: true,
|
||||
repositories: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
fullName: true,
|
||||
htmlUrl: true,
|
||||
private: true,
|
||||
},
|
||||
take: 200,
|
||||
},
|
||||
},
|
||||
take: 20,
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
}),
|
||||
(error) => ({
|
||||
type: "other" as const,
|
||||
cause: error,
|
||||
})
|
||||
);
|
||||
|
||||
const isPreviewEnvironmentEnabled = () =>
|
||||
fromPromise(
|
||||
(this._replica as PrismaClient).runtimeEnvironment.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
where: {
|
||||
projectId: projectId,
|
||||
slug: "preview",
|
||||
},
|
||||
}),
|
||||
(error) => ({
|
||||
type: "other" as const,
|
||||
cause: error,
|
||||
})
|
||||
).map((previewEnvironment) => previewEnvironment !== null);
|
||||
|
||||
return ResultAsync.combine([
|
||||
isPreviewEnvironmentEnabled(),
|
||||
findConnectedGithubRepository(),
|
||||
listGithubAppInstallations(),
|
||||
]).map(([isPreviewEnvironmentEnabled, connectedGithubRepository, githubAppInstallations]) => ({
|
||||
enabled: true,
|
||||
connectedRepository: connectedGithubRepository,
|
||||
installations: githubAppInstallations,
|
||||
isPreviewEnvironmentEnabled,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { type RuntimeEnvironmentType } from "@trigger.dev/database";
|
||||
import {
|
||||
getCurrentPlan,
|
||||
getDefaultEnvironmentLimitFromPlan,
|
||||
getPlans,
|
||||
} from "~/services/platform.v3.server";
|
||||
import { BasePresenter } from "./basePresenter.server";
|
||||
import { sortEnvironments } from "~/utils/environmentSort";
|
||||
|
||||
export type ConcurrencyResult = {
|
||||
canAddConcurrency: boolean;
|
||||
environments: EnvironmentWithConcurrency[];
|
||||
extraConcurrency: number;
|
||||
extraAllocatedConcurrency: number;
|
||||
extraUnallocatedConcurrency: number;
|
||||
maxQuota: number;
|
||||
concurrencyPricing: {
|
||||
stepSize: number;
|
||||
centsPerStep: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type EnvironmentWithConcurrency = {
|
||||
id: string;
|
||||
type: RuntimeEnvironmentType;
|
||||
isBranchableEnvironment: boolean;
|
||||
branchName: string | null;
|
||||
parentEnvironmentId: string | null;
|
||||
maximumConcurrencyLimit: number;
|
||||
planConcurrencyLimit: number;
|
||||
};
|
||||
|
||||
export class ManageConcurrencyPresenter extends BasePresenter {
|
||||
public async call({
|
||||
userId,
|
||||
projectId,
|
||||
organizationId,
|
||||
}: {
|
||||
userId: string;
|
||||
projectId: string;
|
||||
organizationId: string;
|
||||
}): Promise<ConcurrencyResult> {
|
||||
// Get plan
|
||||
const currentPlan = await getCurrentPlan(organizationId);
|
||||
if (!currentPlan) {
|
||||
throw new Error("No plan found");
|
||||
}
|
||||
|
||||
const canAddConcurrency =
|
||||
currentPlan.v3Subscription.plan?.limits.concurrentRuns.canExceed === true;
|
||||
|
||||
const environments = await this._replica.runtimeEnvironment.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
projectId: true,
|
||||
type: true,
|
||||
branchName: true,
|
||||
parentEnvironmentId: true,
|
||||
isBranchableEnvironment: true,
|
||||
maximumConcurrencyLimit: true,
|
||||
orgMember: {
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
},
|
||||
project: {
|
||||
select: {
|
||||
deletedAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
organizationId,
|
||||
archivedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
const extraConcurrency = currentPlan?.v3Subscription.addOns?.concurrentRuns?.purchased ?? 0;
|
||||
|
||||
// Go through all environments and add up extra concurrency above their allowed allocation
|
||||
let extraAllocatedConcurrency = 0;
|
||||
const projectEnvironments: EnvironmentWithConcurrency[] = [];
|
||||
for (const environment of environments) {
|
||||
// Don't count parent environments
|
||||
if (environment.isBranchableEnvironment) continue;
|
||||
|
||||
// Don't count deleted projects
|
||||
if (environment.project.deletedAt) continue;
|
||||
|
||||
const limit = currentPlan
|
||||
? getDefaultEnvironmentLimitFromPlan(environment.type, currentPlan)
|
||||
: 0;
|
||||
if (!limit) continue;
|
||||
|
||||
// If it's not DEV and they've increased, track that
|
||||
// You can't spend money to increase DEV concurrency
|
||||
if (environment.type !== "DEVELOPMENT" && environment.maximumConcurrencyLimit > limit) {
|
||||
extraAllocatedConcurrency += environment.maximumConcurrencyLimit - limit;
|
||||
}
|
||||
|
||||
// We only want to show this project's environments
|
||||
if (environment.projectId === projectId) {
|
||||
if (environment.type === "DEVELOPMENT" && environment.orgMember?.userId !== userId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
projectEnvironments.push({
|
||||
id: environment.id,
|
||||
type: environment.type,
|
||||
isBranchableEnvironment: environment.isBranchableEnvironment,
|
||||
branchName: environment.branchName,
|
||||
parentEnvironmentId: environment.parentEnvironmentId,
|
||||
maximumConcurrencyLimit: environment.maximumConcurrencyLimit,
|
||||
planConcurrencyLimit: limit,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const extraAllocated = Math.min(extraConcurrency, extraAllocatedConcurrency);
|
||||
|
||||
const plans = await getPlans();
|
||||
if (!plans) {
|
||||
throw new Error("Couldn't retrieve add on pricing");
|
||||
}
|
||||
|
||||
return {
|
||||
canAddConcurrency,
|
||||
extraConcurrency,
|
||||
extraAllocatedConcurrency: extraAllocated,
|
||||
extraUnallocatedConcurrency: extraConcurrency - extraAllocated,
|
||||
maxQuota: currentPlan.v3Subscription.addOns?.concurrentRuns?.quota ?? 0,
|
||||
environments: sortEnvironments(projectEnvironments, [
|
||||
"PRODUCTION",
|
||||
"STAGING",
|
||||
"PREVIEW",
|
||||
"DEVELOPMENT",
|
||||
]),
|
||||
concurrencyPricing: plans.addOnPricing.concurrency,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -234,6 +234,7 @@ export class SpanPresenter extends BasePresenter {
|
||||
environmentId: run.runtimeEnvironment.id,
|
||||
idempotencyKey: run.idempotencyKey,
|
||||
idempotencyKeyExpiresAt: run.idempotencyKeyExpiresAt,
|
||||
debounce: run.debounce as { key: string; delay: string; createdAt: Date } | null,
|
||||
schedule: await this.resolveSchedule(run.scheduleId ?? undefined),
|
||||
queue: {
|
||||
name: run.queue,
|
||||
@@ -273,6 +274,7 @@ export class SpanPresenter extends BasePresenter {
|
||||
spanId: run.spanId,
|
||||
isCached: !!originalRunId,
|
||||
machinePreset: machine?.name,
|
||||
taskEventStore: run.taskEventStore,
|
||||
externalTraceId,
|
||||
};
|
||||
}
|
||||
@@ -356,6 +358,8 @@ export class SpanPresenter extends BasePresenter {
|
||||
//idempotency
|
||||
idempotencyKey: true,
|
||||
idempotencyKeyExpiresAt: true,
|
||||
//debounce
|
||||
debounce: true,
|
||||
//delayed
|
||||
delayUntil: true,
|
||||
//ttl
|
||||
@@ -497,7 +501,18 @@ export class SpanPresenter extends BasePresenter {
|
||||
duration: span.duration,
|
||||
events: span.events,
|
||||
style: span.style,
|
||||
properties: span.properties ? JSON.stringify(span.properties, null, 2) : undefined,
|
||||
properties:
|
||||
span.properties &&
|
||||
typeof span.properties === "object" &&
|
||||
Object.keys(span.properties).length > 0
|
||||
? JSON.stringify(span.properties, null, 2)
|
||||
: undefined,
|
||||
resourceProperties:
|
||||
span.resourceProperties &&
|
||||
typeof span.resourceProperties === "object" &&
|
||||
Object.keys(span.resourceProperties).length > 0
|
||||
? JSON.stringify(span.resourceProperties, null, 2)
|
||||
: undefined,
|
||||
entity: span.entity,
|
||||
metadata: span.metadata,
|
||||
triggeredRuns,
|
||||
|
||||
+307
@@ -0,0 +1,307 @@
|
||||
import { ArrowRightIcon, ExclamationTriangleIcon } from "@heroicons/react/20/solid";
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { tryCatch } from "@trigger.dev/core";
|
||||
import { motion } from "framer-motion";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { ExitIcon } from "~/assets/icons/ExitIcon";
|
||||
import { RunsIcon } from "~/assets/icons/RunsIcon";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
import { CopyableText } from "~/components/primitives/CopyableText";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import { Header2, Header3 } from "~/components/primitives/Headers";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import * as Property from "~/components/primitives/PropertyTable";
|
||||
import {
|
||||
BatchStatusCombo,
|
||||
descriptionForBatchStatus,
|
||||
} from "~/components/runs/v3/BatchStatus";
|
||||
import { useAutoRevalidate } from "~/hooks/useAutoRevalidate";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { BatchPresenter, type BatchPresenterData } from "~/presenters/v3/BatchPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { formatNumber } from "~/utils/numberFormatter";
|
||||
import { EnvironmentParamSchema, v3BatchesPath, v3BatchRunsPath } from "~/utils/pathBuilder";
|
||||
|
||||
const BatchParamSchema = EnvironmentParamSchema.extend({
|
||||
batchParam: z.string(),
|
||||
});
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
|
||||
const { organizationSlug, projectParam, envParam, batchParam } =
|
||||
BatchParamSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
if (!project) {
|
||||
throw new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
||||
if (!environment) {
|
||||
throw new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
try {
|
||||
const presenter = new BatchPresenter();
|
||||
const [error, data] = await tryCatch(
|
||||
presenter.call({
|
||||
environmentId: environment.id,
|
||||
batchId: batchParam,
|
||||
userId,
|
||||
})
|
||||
);
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message);
|
||||
}
|
||||
|
||||
return typedjson({ batch: data });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw new Response(undefined, {
|
||||
status: 400,
|
||||
statusText: "Something went wrong, if this problem persists please contact support.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { batch } = useTypedLoaderData<typeof loader>();
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
// Auto-reload when batch is still in progress
|
||||
useAutoRevalidate({
|
||||
interval: 1000,
|
||||
onFocus: true,
|
||||
disabled: batch.hasFinished,
|
||||
});
|
||||
|
||||
const showProgressMeter = batch.isV2 && (batch.status === "PROCESSING" || batch.status === "PARTIAL_FAILED");
|
||||
|
||||
return (
|
||||
<div className="grid h-full max-h-full grid-rows-[2.5rem_2.5rem_1fr_3.25rem] overflow-hidden bg-background-bright">
|
||||
{/* Header */}
|
||||
<div className="mx-3 flex items-center justify-between gap-2 overflow-x-hidden border-b border-grid-dimmed">
|
||||
<Header2 className={cn("truncate whitespace-nowrap")}>{batch.friendlyId}</Header2>
|
||||
<LinkButton
|
||||
to={v3BatchesPath(organization, project, environment)}
|
||||
variant="minimal/small"
|
||||
TrailingIcon={ExitIcon}
|
||||
shortcut={{ key: "esc" }}
|
||||
shortcutPosition="before-trailing-icon"
|
||||
className="pl-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status bar */}
|
||||
<div className="flex items-center justify-between gap-2 border-b border-grid-dimmed px-3 text-sm">
|
||||
<BatchStatusCombo status={batch.status} />
|
||||
<Paragraph variant="extra-small" className="text-text-dimmed">
|
||||
{descriptionForBatchStatus(batch.status)}
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div className="overflow-y-scroll scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
<div className="space-y-3">
|
||||
{/* Progress meter for v2 batches */}
|
||||
{showProgressMeter && (
|
||||
<div className="px-3 pt-3">
|
||||
<BatchProgressMeter
|
||||
successCount={batch.successfulRunCount}
|
||||
failureCount={batch.failedRunCount}
|
||||
totalCount={batch.runCount}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Properties */}
|
||||
<div className="px-3 py-3">
|
||||
<Property.Table>
|
||||
<Property.Item>
|
||||
<Property.Label>ID</Property.Label>
|
||||
<Property.Value>
|
||||
<CopyableText value={batch.friendlyId} />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Status</Property.Label>
|
||||
<Property.Value>
|
||||
<BatchStatusCombo status={batch.status} />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Version</Property.Label>
|
||||
<Property.Value>
|
||||
{batch.isV2 ? "v2 (Run Engine)" : "v1 (Legacy)"}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Total runs</Property.Label>
|
||||
<Property.Value>{formatNumber(batch.runCount)}</Property.Value>
|
||||
</Property.Item>
|
||||
{batch.isV2 && (
|
||||
<>
|
||||
<Property.Item>
|
||||
<Property.Label>Successfully created</Property.Label>
|
||||
<Property.Value className="text-success">
|
||||
{formatNumber(batch.successfulRunCount)}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
{batch.failedRunCount > 0 && (
|
||||
<Property.Item>
|
||||
<Property.Label>Failed to create</Property.Label>
|
||||
<Property.Value className="text-error">
|
||||
{formatNumber(batch.failedRunCount)}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{batch.idempotencyKey && (
|
||||
<Property.Item>
|
||||
<Property.Label>Idempotency key</Property.Label>
|
||||
<Property.Value>
|
||||
<CopyableText value={batch.idempotencyKey} className="font-mono text-xs" />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
)}
|
||||
<Property.Item>
|
||||
<Property.Label>Created</Property.Label>
|
||||
<Property.Value>
|
||||
<DateTime date={batch.createdAt} />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
{batch.processingStartedAt && (
|
||||
<Property.Item>
|
||||
<Property.Label>Processing started</Property.Label>
|
||||
<Property.Value>
|
||||
<DateTime date={batch.processingStartedAt} />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
)}
|
||||
{batch.processingCompletedAt && (
|
||||
<Property.Item>
|
||||
<Property.Label>Processing completed</Property.Label>
|
||||
<Property.Value>
|
||||
<DateTime date={batch.processingCompletedAt} />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
)}
|
||||
<Property.Item>
|
||||
<Property.Label>Finished</Property.Label>
|
||||
<Property.Value>
|
||||
{batch.finishedAt ? <DateTime date={batch.finishedAt} /> : "–"}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
</Property.Table>
|
||||
</div>
|
||||
|
||||
{/* Errors section */}
|
||||
{batch.errors.length > 0 && (
|
||||
<div className="px-3 pb-3">
|
||||
<Header3 className="mb-2 flex items-center gap-1.5 text-warning">
|
||||
<ExclamationTriangleIcon className="size-4" />
|
||||
Run creation errors ({batch.errors.length})
|
||||
</Header3>
|
||||
<div className="divide-y divide-grid-dimmed rounded-md border border-grid-dimmed bg-charcoal-900">
|
||||
{batch.errors.map((error) => (
|
||||
<div key={error.id} className="px-3 py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs text-text-dimmed">
|
||||
Item #{error.index}
|
||||
</span>
|
||||
<span className="text-sm text-text-bright">{error.taskIdentifier}</span>
|
||||
</div>
|
||||
{error.errorCode && (
|
||||
<span className="rounded bg-charcoal-750 px-1.5 py-0.5 font-mono text-xs text-text-dimmed">
|
||||
{error.errorCode}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Paragraph variant="small" className="mt-1 text-error">
|
||||
{error.error}
|
||||
</Paragraph>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-2 border-t border-grid-dimmed px-2">
|
||||
<LinkButton
|
||||
variant="tertiary/medium"
|
||||
to={v3BatchRunsPath(organization, project, environment, batch)}
|
||||
LeadingIcon={RunsIcon}
|
||||
leadingIconClassName="text-indigo-500"
|
||||
TrailingIcon={ArrowRightIcon}
|
||||
>
|
||||
View runs
|
||||
</LinkButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type BatchProgressMeterProps = {
|
||||
successCount: number;
|
||||
failureCount: number;
|
||||
totalCount: number;
|
||||
};
|
||||
|
||||
function BatchProgressMeter({ successCount, failureCount, totalCount }: BatchProgressMeterProps) {
|
||||
const processedCount = successCount + failureCount;
|
||||
const successPercentage = totalCount === 0 ? 0 : (successCount / totalCount) * 100;
|
||||
const failurePercentage = totalCount === 0 ? 0 : (failureCount / totalCount) * 100;
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Paragraph variant="small/bright">Run creation progress</Paragraph>
|
||||
<Paragraph variant="extra-small">
|
||||
{formatNumber(processedCount)}/{formatNumber(totalCount)}
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div className="relative h-4 w-full overflow-hidden rounded-sm bg-charcoal-900">
|
||||
<motion.div
|
||||
className="absolute left-0 top-0 h-full bg-success"
|
||||
initial={{ width: `${successPercentage}%` }}
|
||||
animate={{ width: `${successPercentage}%` }}
|
||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||
/>
|
||||
<motion.div
|
||||
className="absolute top-0 h-full bg-error"
|
||||
initial={{ width: `${failurePercentage}%`, left: `${successPercentage}%` }}
|
||||
animate={{ width: `${failurePercentage}%`, left: `${successPercentage}%` }}
|
||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="h-2 w-2 rounded-[1px] bg-success" />
|
||||
<Paragraph variant="extra-small">{formatNumber(successCount)} created</Paragraph>
|
||||
</div>
|
||||
{failureCount > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="h-2 w-2 rounded-[1px] bg-error" />
|
||||
<Paragraph variant="extra-small">{formatNumber(failureCount)} failed</Paragraph>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+72
-81
@@ -1,10 +1,6 @@
|
||||
import {
|
||||
ArrowPathRoundedSquareIcon,
|
||||
ArrowRightIcon,
|
||||
ExclamationCircleIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { ArrowRightIcon, ExclamationCircleIcon } from "@heroicons/react/20/solid";
|
||||
import { BookOpenIcon } from "@heroicons/react/24/solid";
|
||||
import { type MetaFunction, useLocation, useNavigation } from "@remix-run/react";
|
||||
import { type MetaFunction, Outlet, useNavigation, useParams, useLocation } from "@remix-run/react";
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { formatDuration } from "@trigger.dev/core/v3/utils/durations";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
@@ -12,12 +8,15 @@ import { BatchesNone } from "~/components/BlankStatePanels";
|
||||
import { ListPagination } from "~/components/ListPagination";
|
||||
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
|
||||
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import { Dialog, DialogTrigger } from "~/components/primitives/Dialog";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { PopoverMenuItem } from "~/components/primitives/Popover";
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "~/components/primitives/Resizable";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import {
|
||||
Table,
|
||||
@@ -36,7 +35,6 @@ import {
|
||||
BatchStatusCombo,
|
||||
descriptionForBatchStatus,
|
||||
} from "~/components/runs/v3/BatchStatus";
|
||||
import { CheckBatchCompletionDialog } from "~/components/runs/v3/CheckBatchCompletionDialog";
|
||||
import { LiveTimer } from "~/components/runs/v3/LiveTimer";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
@@ -44,13 +42,14 @@ import { useProject } from "~/hooks/useProject";
|
||||
import { redirectWithErrorMessage } from "~/models/message.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import {
|
||||
type BatchList,
|
||||
type BatchListItem,
|
||||
BatchListPresenter,
|
||||
} from "~/presenters/v3/BatchListPresenter.server";
|
||||
import { type BatchList, BatchListPresenter } from "~/presenters/v3/BatchListPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { docsPath, EnvironmentParamSchema, v3BatchRunsPath } from "~/utils/pathBuilder";
|
||||
import {
|
||||
docsPath,
|
||||
EnvironmentParamSchema,
|
||||
v3BatchPath,
|
||||
v3BatchRunsPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
@@ -101,6 +100,8 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
export default function Page() {
|
||||
const { batches, hasFilters, hasAnyBatches, filters, pagination } =
|
||||
useTypedLoaderData<typeof loader>();
|
||||
const { batchParam } = useParams();
|
||||
const isShowingInspector = batchParam !== undefined;
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
@@ -123,22 +124,34 @@ export default function Page() {
|
||||
<BatchesNone />
|
||||
</MainCenteredContainer>
|
||||
) : (
|
||||
<div className="grid h-full max-h-full grid-rows-[auto_1fr] overflow-hidden">
|
||||
<div className="flex items-start justify-between gap-x-2 p-2">
|
||||
<BatchFilters hasFilters={hasFilters} />
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
<ListPagination list={{ pagination }} />
|
||||
</div>
|
||||
</div>
|
||||
<ResizablePanelGroup orientation="horizontal" className="max-h-full">
|
||||
<ResizablePanel id="batches-main" min={"100px"}>
|
||||
<div className="grid h-full max-h-full grid-rows-[auto_1fr] overflow-hidden">
|
||||
<div className="flex items-start justify-between gap-x-2 p-2">
|
||||
<BatchFilters hasFilters={hasFilters} />
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
<ListPagination list={{ pagination }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BatchesTable
|
||||
batches={batches}
|
||||
filters={filters}
|
||||
hasFilters={hasFilters}
|
||||
pagination={pagination}
|
||||
hasAnyBatches={hasAnyBatches}
|
||||
/>
|
||||
</div>
|
||||
<BatchesTable
|
||||
batches={batches}
|
||||
filters={filters}
|
||||
hasFilters={hasFilters}
|
||||
pagination={pagination}
|
||||
hasAnyBatches={hasAnyBatches}
|
||||
/>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
{isShowingInspector && (
|
||||
<>
|
||||
<ResizableHandle id="batches-handle" />
|
||||
<ResizablePanel id="batches-inspector" min="100px" default="500px">
|
||||
<Outlet />
|
||||
</ResizablePanel>
|
||||
</>
|
||||
)}
|
||||
</ResizablePanelGroup>
|
||||
)}
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
@@ -147,10 +160,14 @@ export default function Page() {
|
||||
|
||||
function BatchesTable({ batches, hasFilters, filters }: BatchList) {
|
||||
const navigation = useNavigation();
|
||||
const isLoading = navigation.state !== "idle";
|
||||
const location = useLocation();
|
||||
const isLoading =
|
||||
navigation.state !== "idle" && navigation.location?.pathname === location.pathname;
|
||||
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
const { batchParam } = useParams();
|
||||
|
||||
return (
|
||||
<Table className="max-h-full overflow-y-auto">
|
||||
@@ -195,15 +212,19 @@ function BatchesTable({ batches, hasFilters, filters }: BatchList) {
|
||||
</div>
|
||||
</TableBlankRow>
|
||||
) : (
|
||||
batches.map((batch, index) => {
|
||||
const path = v3BatchRunsPath(organization, project, environment, batch);
|
||||
batches.map((batch) => {
|
||||
const basePath = v3BatchPath(organization, project, environment, batch);
|
||||
const inspectorPath = `${basePath}${location.search}`;
|
||||
const runsPath = v3BatchRunsPath(organization, project, environment, batch);
|
||||
const isSelected = batchParam === batch.friendlyId;
|
||||
|
||||
return (
|
||||
<TableRow key={batch.id}>
|
||||
<TableCell to={path} isTabbableCell>
|
||||
<TableRow key={batch.id} className={isSelected ? "bg-grid-dimmed" : undefined}>
|
||||
<TableCell to={inspectorPath} isTabbableCell>
|
||||
{batch.friendlyId}
|
||||
</TableCell>
|
||||
|
||||
<TableCell to={path}>
|
||||
<TableCell to={inspectorPath}>
|
||||
{batch.batchVersion === "v1" ? (
|
||||
<SimpleTooltip
|
||||
content="Upgrade to the latest SDK for batch statuses to appear."
|
||||
@@ -223,8 +244,12 @@ function BatchesTable({ batches, hasFilters, filters }: BatchList) {
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell to={path}>{batch.runCount}</TableCell>
|
||||
<TableCell to={path} className="w-[1%]" actionClassName="pr-0 tabular-nums">
|
||||
<TableCell to={inspectorPath}>{batch.runCount}</TableCell>
|
||||
<TableCell
|
||||
to={inspectorPath}
|
||||
className="w-[1%]"
|
||||
actionClassName="pr-0 tabular-nums"
|
||||
>
|
||||
{batch.finishedAt ? (
|
||||
formatDuration(new Date(batch.createdAt), new Date(batch.finishedAt), {
|
||||
style: "short",
|
||||
@@ -233,13 +258,13 @@ function BatchesTable({ batches, hasFilters, filters }: BatchList) {
|
||||
<LiveTimer startTime={new Date(batch.createdAt)} />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
<TableCell to={inspectorPath}>
|
||||
<DateTime date={batch.createdAt} />
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
<TableCell to={inspectorPath}>
|
||||
{batch.finishedAt ? <DateTime date={batch.finishedAt} /> : "–"}
|
||||
</TableCell>
|
||||
<BatchActionsCell batch={batch} path={path} />
|
||||
<BatchActionsCell runsPath={runsPath} />
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
@@ -257,48 +282,14 @@ function BatchesTable({ batches, hasFilters, filters }: BatchList) {
|
||||
);
|
||||
}
|
||||
|
||||
function BatchActionsCell({ batch, path }: { batch: BatchListItem; path: string }) {
|
||||
const location = useLocation();
|
||||
|
||||
if (batch.hasFinished || batch.environment.type === "DEVELOPMENT") {
|
||||
return <TableCell to={path}>{""}</TableCell>;
|
||||
}
|
||||
|
||||
function BatchActionsCell({ runsPath }: { runsPath: string }) {
|
||||
return (
|
||||
<TableCellMenu
|
||||
isSticky
|
||||
popoverContent={
|
||||
<>
|
||||
<PopoverMenuItem
|
||||
to={path}
|
||||
icon={ArrowRightIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
title="View batch"
|
||||
/>
|
||||
{!batch.hasFinished && (
|
||||
<Dialog>
|
||||
<DialogTrigger
|
||||
asChild
|
||||
className="size-6 rounded-sm p-1 text-text-dimmed transition hover:bg-charcoal-700 hover:text-text-bright"
|
||||
>
|
||||
<Button
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={ArrowPathRoundedSquareIcon}
|
||||
leadingIconClassName="text-success"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
className="w-full px-1.5 py-[0.9rem]"
|
||||
>
|
||||
Try and resume
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<CheckBatchCompletionDialog
|
||||
batchId={batch.id}
|
||||
redirectPath={`${location.pathname}${location.search}`}
|
||||
/>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
hiddenButtons={
|
||||
<LinkButton to={runsPath} variant="minimal/small" LeadingIcon={ArrowRightIcon}>
|
||||
View runs
|
||||
</LinkButton>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
+835
@@ -0,0 +1,835 @@
|
||||
import { conform, useFieldList, useForm } from "@conform-to/react";
|
||||
import { parse } from "@conform-to/zod";
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
EnvelopeIcon,
|
||||
ExclamationTriangleIcon,
|
||||
InformationCircleIcon,
|
||||
PlusIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { DialogClose } from "@radix-ui/react-dialog";
|
||||
import {
|
||||
Form,
|
||||
useActionData,
|
||||
useNavigate,
|
||||
useNavigation,
|
||||
useSearchParams,
|
||||
type MetaFunction,
|
||||
} from "@remix-run/react";
|
||||
import { json, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { tryCatch } from "@trigger.dev/core";
|
||||
import { useEffect, useState } from "react";
|
||||
import simplur from "simplur";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
|
||||
import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
|
||||
import {
|
||||
MainHorizontallyCenteredContainer,
|
||||
PageBody,
|
||||
PageContainer,
|
||||
} from "~/components/layout/AppLayout";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog";
|
||||
import { Fieldset } from "~/components/primitives/Fieldset";
|
||||
import { FormButtons } from "~/components/primitives/FormButtons";
|
||||
import { FormError } from "~/components/primitives/FormError";
|
||||
import { Header2, Header3 } from "~/components/primitives/Headers";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { InputGroup } from "~/components/primitives/InputGroup";
|
||||
import { InputNumberStepper } from "~/components/primitives/InputNumberStepper";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import * as Property from "~/components/primitives/PropertyTable";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
} from "~/components/primitives/Table";
|
||||
import { InfoIconTooltip } from "~/components/primitives/Tooltip";
|
||||
import { useFeatures } from "~/hooks/useFeatures";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import {
|
||||
ManageConcurrencyPresenter,
|
||||
type ConcurrencyResult,
|
||||
type EnvironmentWithConcurrency,
|
||||
} from "~/presenters/v3/ManageConcurrencyPresenter.server";
|
||||
import { getPlans } from "~/services/platform.v3.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { formatCurrency, formatNumber } from "~/utils/numberFormatter";
|
||||
import { concurrencyPath, EnvironmentParamSchema, v3BillingPath } from "~/utils/pathBuilder";
|
||||
import { SetConcurrencyAddOnService } from "~/v3/services/setConcurrencyAddOn.server";
|
||||
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
|
||||
import { SpinnerWhite } from "~/components/primitives/Spinner";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { AllocateConcurrencyService } from "~/v3/services/allocateConcurrency.server";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
{
|
||||
title: `Manage concurrency | Trigger.dev`,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
if (!project) {
|
||||
throw new Response(undefined, {
|
||||
status: 404,
|
||||
statusText: "Project not found",
|
||||
});
|
||||
}
|
||||
|
||||
const presenter = new ManageConcurrencyPresenter();
|
||||
const [error, result] = await tryCatch(
|
||||
presenter.call({
|
||||
userId: userId,
|
||||
projectId: project.id,
|
||||
organizationId: project.organizationId,
|
||||
})
|
||||
);
|
||||
|
||||
if (error) {
|
||||
throw new Response(undefined, {
|
||||
status: 400,
|
||||
statusText: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
const plans = await tryCatch(getPlans());
|
||||
if (!plans) {
|
||||
throw new Response(null, { status: 404, statusText: "Plans not found" });
|
||||
}
|
||||
|
||||
return typedjson(result);
|
||||
};
|
||||
|
||||
const FormSchema = z.discriminatedUnion("action", [
|
||||
z.object({
|
||||
action: z.enum(["purchase"]),
|
||||
amount: z.coerce.number().min(0, "Amount must be 0 or more"),
|
||||
}),
|
||||
z.object({
|
||||
action: z.enum(["quota-increase"]),
|
||||
amount: z.coerce.number().min(1, "Amount must be greater than 0"),
|
||||
}),
|
||||
z.object({
|
||||
action: z.enum(["allocate"]),
|
||||
// It will only update environments that are passed in
|
||||
environments: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
amount: z.coerce.number().min(0, "Amount must be 0 or more"),
|
||||
})
|
||||
),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
const redirectPath = concurrencyPath(
|
||||
{ slug: organizationSlug },
|
||||
{ slug: projectParam },
|
||||
{ slug: envParam }
|
||||
);
|
||||
|
||||
if (!project) {
|
||||
throw redirectWithErrorMessage(redirectPath, request, "Project not found");
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const submission = parse(formData, { schema: FormSchema });
|
||||
|
||||
if (!submission.value || submission.intent !== "submit") {
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
if (submission.value.action === "allocate") {
|
||||
const allocate = new AllocateConcurrencyService();
|
||||
const [error, result] = await tryCatch(
|
||||
allocate.call({
|
||||
userId,
|
||||
projectId: project.id,
|
||||
organizationId: project.organizationId,
|
||||
environments: submission.value.environments,
|
||||
})
|
||||
);
|
||||
|
||||
if (error) {
|
||||
submission.error.environments = [error instanceof Error ? error.message : "Unknown error"];
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
submission.error.environments = [result.error];
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
return redirectWithSuccessMessage(
|
||||
`${redirectPath}?success=true`,
|
||||
request,
|
||||
"Concurrency allocated successfully"
|
||||
);
|
||||
}
|
||||
|
||||
const service = new SetConcurrencyAddOnService();
|
||||
const [error, result] = await tryCatch(
|
||||
service.call({
|
||||
userId,
|
||||
projectId: project.id,
|
||||
organizationId: project.organizationId,
|
||||
action: submission.value.action,
|
||||
amount: submission.value.amount,
|
||||
})
|
||||
);
|
||||
|
||||
if (error) {
|
||||
submission.error.amount = [error instanceof Error ? error.message : "Unknown error"];
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
submission.error.amount = [result.error];
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
return redirectWithSuccessMessage(
|
||||
`${redirectPath}?success=true`,
|
||||
request,
|
||||
submission.value.action === "purchase"
|
||||
? "Concurrency updated successfully"
|
||||
: "Requested extra concurrency, we'll get back to you soon."
|
||||
);
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const {
|
||||
canAddConcurrency,
|
||||
extraConcurrency,
|
||||
extraAllocatedConcurrency,
|
||||
extraUnallocatedConcurrency,
|
||||
environments,
|
||||
concurrencyPricing,
|
||||
maxQuota,
|
||||
} = useTypedLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
<PageTitle title="Concurrency" />
|
||||
<PageAccessories>
|
||||
<AdminDebugTooltip>
|
||||
<Property.Table>
|
||||
{environments.map((environment) => (
|
||||
<Property.Item key={environment.id}>
|
||||
<Property.Label>
|
||||
{environment.type}{" "}
|
||||
{environment.branchName ? ` (${environment.branchName})` : ""}
|
||||
</Property.Label>
|
||||
<Property.Value>{environment.id}</Property.Value>
|
||||
</Property.Item>
|
||||
))}
|
||||
</Property.Table>
|
||||
</AdminDebugTooltip>
|
||||
</PageAccessories>
|
||||
</NavBar>
|
||||
<PageBody scrollable={true}>
|
||||
<MainHorizontallyCenteredContainer>
|
||||
{canAddConcurrency ? (
|
||||
<Upgradable
|
||||
canAddConcurrency={canAddConcurrency}
|
||||
extraConcurrency={extraConcurrency}
|
||||
extraAllocatedConcurrency={extraAllocatedConcurrency}
|
||||
extraUnallocatedConcurrency={extraUnallocatedConcurrency}
|
||||
environments={environments}
|
||||
concurrencyPricing={concurrencyPricing}
|
||||
maxQuota={maxQuota}
|
||||
/>
|
||||
) : (
|
||||
<NotUpgradable environments={environments} />
|
||||
)}
|
||||
</MainHorizontallyCenteredContainer>
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function initialAllocation(environments: ConcurrencyResult["environments"]) {
|
||||
return new Map<string, number>(
|
||||
environments
|
||||
.filter((e) => e.type !== "DEVELOPMENT")
|
||||
.map((e) => [e.id, Math.max(0, e.maximumConcurrencyLimit - e.planConcurrencyLimit)])
|
||||
);
|
||||
}
|
||||
|
||||
function allocationTotal(environments: ConcurrencyResult["environments"]) {
|
||||
const allocation = initialAllocation(environments);
|
||||
return Array.from(allocation.values()).reduce((e, acc) => e + acc, 0);
|
||||
}
|
||||
|
||||
function Upgradable({
|
||||
extraConcurrency,
|
||||
extraAllocatedConcurrency,
|
||||
extraUnallocatedConcurrency,
|
||||
environments,
|
||||
concurrencyPricing,
|
||||
maxQuota,
|
||||
}: ConcurrencyResult) {
|
||||
const lastSubmission = useActionData();
|
||||
const [form, { environments: formEnvironments }] = useForm({
|
||||
id: "purchase-concurrency",
|
||||
// TODO: type this
|
||||
lastSubmission: lastSubmission as any,
|
||||
onValidate({ formData }) {
|
||||
return parse(formData, { schema: FormSchema });
|
||||
},
|
||||
shouldRevalidate: "onSubmit",
|
||||
});
|
||||
|
||||
const navigation = useNavigation();
|
||||
const isLoading = navigation.state !== "idle" && navigation.formMethod === "POST";
|
||||
|
||||
const [allocation, setAllocation] = useState(initialAllocation(environments));
|
||||
|
||||
const allocatedInProject = Array.from(allocation.values()).reduce((e, acc) => e + acc, 0);
|
||||
const initialAllocationInProject = allocationTotal(environments);
|
||||
const changeInAllocation = allocatedInProject - initialAllocationInProject;
|
||||
const unallocated = extraUnallocatedConcurrency - changeInAllocation;
|
||||
const allocationModified = changeInAllocation !== 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="border-b border-grid-dimmed pb-1">
|
||||
<Header2>Manage your concurrency</Header2>
|
||||
</div>
|
||||
<Paragraph variant="small">
|
||||
Concurrency limits determine how many runs you can execute at the same time. You can add
|
||||
extra concurrency to your organization which you can allocate to environments in your
|
||||
projects.
|
||||
</Paragraph>
|
||||
<div className="mt-3 flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center first-letter:pb-1">
|
||||
<Header3 className="grow">Extra concurrency</Header3>
|
||||
<PurchaseConcurrencyModal
|
||||
concurrencyPricing={concurrencyPricing}
|
||||
extraConcurrency={extraConcurrency}
|
||||
extraUnallocatedConcurrency={extraUnallocatedConcurrency}
|
||||
maxQuota={maxQuota}
|
||||
disabled={unallocated < 0 ? false : allocationModified}
|
||||
/>
|
||||
</div>
|
||||
<Table variant="bright/no-hover">
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell className="pl-0 text-text-bright">Extra concurrency purchased</TableCell>
|
||||
<TableCell alignment="right" className="tabular-nums text-text-bright">
|
||||
{extraConcurrency}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>Allocated concurrency</TableCell>
|
||||
<TableCell alignment="right" className={"tabular-nums text-text-bright"}>
|
||||
{allocationModified ? (
|
||||
<>
|
||||
<span className="text-text-dimmed line-through">
|
||||
{extraAllocatedConcurrency}
|
||||
</span>{" "}
|
||||
{extraAllocatedConcurrency + changeInAllocation}
|
||||
</>
|
||||
) : (
|
||||
extraAllocatedConcurrency
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>Unallocated concurrency</TableCell>
|
||||
<TableCell
|
||||
alignment="right"
|
||||
className={cn(
|
||||
"tabular-nums",
|
||||
unallocated > 0
|
||||
? "text-success"
|
||||
: unallocated < 0
|
||||
? "text-error"
|
||||
: "text-text-bright"
|
||||
)}
|
||||
>
|
||||
{allocationModified ? (
|
||||
<>
|
||||
<span className="text-text-dimmed line-through">
|
||||
{extraUnallocatedConcurrency}
|
||||
</span>{" "}
|
||||
{extraUnallocatedConcurrency - changeInAllocation}
|
||||
</>
|
||||
) : (
|
||||
extraUnallocatedConcurrency
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow
|
||||
className={
|
||||
allocationModified || unallocated > 0 ? undefined : "after:bg-transparent"
|
||||
}
|
||||
>
|
||||
<TableCell
|
||||
colSpan={2}
|
||||
className={cn("py-0", (unallocated > 0 || allocationModified) && "pr-0")}
|
||||
>
|
||||
<div className="flex h-10 items-center">
|
||||
{allocationModified ? (
|
||||
unallocated < 0 ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<ExclamationTriangleIcon className="size-4 text-error" />
|
||||
<span className="text-error">
|
||||
You're trying to allocate more concurrency than your total purchased
|
||||
amount.
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex w-full items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<InformationCircleIcon className="size-4 text-text-dimmed" />
|
||||
<span>
|
||||
Save your changes or{" "}
|
||||
<button
|
||||
className="inline text-indigo-500 hover:text-indigo-300"
|
||||
onClick={() => {
|
||||
setAllocation(initialAllocation(environments));
|
||||
}}
|
||||
>
|
||||
reset
|
||||
</button>
|
||||
.
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary/small"
|
||||
type="submit"
|
||||
form="allocate"
|
||||
disabled={unallocated < 0 || isLoading}
|
||||
LeadingIcon={isLoading ? SpinnerWhite : undefined}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
) : unallocated > 0 ? (
|
||||
<div className="flex h-full w-full items-center justify-between bg-success/10 px-2.5">
|
||||
<div className="flex items-center justify-start gap-1">
|
||||
<InformationCircleIcon className="size-4 text-success" />
|
||||
<span className="text-success">
|
||||
You have {unallocated} extra concurrency available to allocate below.
|
||||
</span>
|
||||
</div>
|
||||
<ArrowDownIcon className="size-4 animate-bounce text-success" />
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
<FormError id={formEnvironments.id}>{formEnvironments.error}</FormError>
|
||||
</div>
|
||||
<Form className="flex flex-col gap-2" method="post" {...form.props} id="allocate">
|
||||
<input type="hidden" name="action" value="allocate" />
|
||||
<div className="flex items-center pb-1">
|
||||
<Header3 className="grow">Concurrency allocation</Header3>
|
||||
</div>
|
||||
<Table variant="bright/no-hover">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell className="pl-0">Environment</TableHeaderCell>
|
||||
<TableHeaderCell alignment="right">
|
||||
<span className="flex items-center justify-end gap-x-1">
|
||||
Included{" "}
|
||||
<InfoIconTooltip content="This is the included concurrency based on your plan." />
|
||||
</span>
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell alignment="right">Extra concurrency</TableHeaderCell>
|
||||
<TableHeaderCell alignment="right">Total</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{environments.map((environment, index) => (
|
||||
<TableRow key={environment.id}>
|
||||
<TableCell>
|
||||
<EnvironmentCombo
|
||||
environment={environment}
|
||||
className="max-w-[18ch]"
|
||||
tooltipSideOffset={6}
|
||||
tooltipSide="top"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell alignment="right">{environment.planConcurrencyLimit}</TableCell>
|
||||
<TableCell alignment="right">
|
||||
<div className="flex items-center justify-end">
|
||||
{environment.type === "DEVELOPMENT" ? (
|
||||
Math.max(
|
||||
0,
|
||||
environment.maximumConcurrencyLimit - environment.planConcurrencyLimit
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<input
|
||||
type="hidden"
|
||||
name={`environments[${index}].id`}
|
||||
value={environment.id}
|
||||
/>
|
||||
<Input
|
||||
name={`environments[${index}].amount`}
|
||||
type="number"
|
||||
variant="outline/small"
|
||||
className="text-right"
|
||||
containerClassName="w-16"
|
||||
fullWidth={false}
|
||||
value={allocation.get(environment.id)}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value === "" ? 0 : Number(e.target.value);
|
||||
setAllocation(new Map(allocation).set(environment.id, value));
|
||||
}}
|
||||
min={0}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell alignment="right">
|
||||
{environment.planConcurrencyLimit + (allocation.get(environment.id) ?? 0)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NotUpgradable({ environments }: { environments: EnvironmentWithConcurrency[] }) {
|
||||
const { isManagedCloud } = useFeatures();
|
||||
const plan = useCurrentPlan();
|
||||
const organization = useOrganization();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="border-b border-grid-dimmed pb-1">
|
||||
<Header2>Your concurrency</Header2>
|
||||
</div>
|
||||
{isManagedCloud ? (
|
||||
<>
|
||||
<Paragraph variant="small">
|
||||
Concurrency limits determine how many runs you can execute at the same time. You can
|
||||
upgrade your plan to get more concurrency. You are currently on the{" "}
|
||||
{plan?.v3Subscription?.plan?.title ?? "Free"} plan.
|
||||
</Paragraph>
|
||||
<LinkButton variant="primary/small" to={v3BillingPath(organization)}>
|
||||
Upgrade for more concurrency
|
||||
</LinkButton>
|
||||
</>
|
||||
) : null}
|
||||
<div className="mt-3 flex flex-col gap-3">
|
||||
<Table variant="bright/no-hover">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell className="pl-0">Environment</TableHeaderCell>
|
||||
<TableHeaderCell alignment="right">Concurrency limit</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{environments.map((environment) => (
|
||||
<TableRow key={environment.id}>
|
||||
<TableCell className="pl-0">
|
||||
<EnvironmentCombo environment={environment} />
|
||||
</TableCell>
|
||||
<TableCell alignment="right">{environment.maximumConcurrencyLimit}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PurchaseConcurrencyModal({
|
||||
concurrencyPricing,
|
||||
extraConcurrency,
|
||||
extraUnallocatedConcurrency,
|
||||
maxQuota,
|
||||
disabled,
|
||||
}: {
|
||||
concurrencyPricing: {
|
||||
stepSize: number;
|
||||
centsPerStep: number;
|
||||
};
|
||||
extraConcurrency: number;
|
||||
extraUnallocatedConcurrency: number;
|
||||
maxQuota: number;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const lastSubmission = useActionData();
|
||||
const [form, { amount }] = useForm({
|
||||
id: "purchase-concurrency",
|
||||
// TODO: type this
|
||||
lastSubmission: lastSubmission as any,
|
||||
onValidate({ formData }) {
|
||||
return parse(formData, { schema: FormSchema });
|
||||
},
|
||||
shouldRevalidate: "onSubmit",
|
||||
});
|
||||
|
||||
const [amountValue, setAmountValue] = useState(extraConcurrency);
|
||||
const navigation = useNavigation();
|
||||
const isLoading = navigation.state !== "idle" && navigation.formMethod === "POST";
|
||||
|
||||
// Close the panel, when we've succeeded
|
||||
// This is required because a redirect to the same path doesn't clear state
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [open, setOpen] = useState(false);
|
||||
useEffect(() => {
|
||||
const success = searchParams.get("success");
|
||||
if (success) {
|
||||
setOpen(false);
|
||||
setSearchParams((s) => {
|
||||
s.delete("success");
|
||||
return s;
|
||||
});
|
||||
}
|
||||
}, [searchParams.get("success")]);
|
||||
|
||||
const state = updateState({
|
||||
value: amountValue,
|
||||
existingValue: extraConcurrency,
|
||||
quota: maxQuota,
|
||||
extraUnallocatedConcurrency,
|
||||
});
|
||||
const changeClassName =
|
||||
state === "decrease" ? "text-error" : state === "increase" ? "text-success" : undefined;
|
||||
|
||||
const title = extraConcurrency === 0 ? "Purchase extra concurrency" : "Add/remove concurrency";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="primary/small"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>{title}</DialogHeader>
|
||||
<Form method="post" {...form.props}>
|
||||
<div className="flex flex-col gap-4 pt-2">
|
||||
<Paragraph variant="base/bright" spacing>
|
||||
You can purchase bundles of {concurrencyPricing.stepSize} concurrency for{" "}
|
||||
{formatCurrency(concurrencyPricing.centsPerStep / 100, false)}/month. Or you can
|
||||
remove any extra concurrency after you have unallocated it from your environments
|
||||
first.
|
||||
</Paragraph>
|
||||
<Fieldset>
|
||||
<InputGroup fullWidth>
|
||||
<Label htmlFor="amount" className="text-text-dimmed">
|
||||
Total extra concurrency
|
||||
</Label>
|
||||
<InputNumberStepper
|
||||
{...conform.input(amount, { type: "number" })}
|
||||
step={concurrencyPricing.stepSize}
|
||||
min={0}
|
||||
max={undefined}
|
||||
value={amountValue}
|
||||
onChange={(e) => setAmountValue(Number(e.target.value))}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<FormError id={amount.errorId}>{amount.error}</FormError>
|
||||
<FormError>{form.error}</FormError>
|
||||
</InputGroup>
|
||||
</Fieldset>
|
||||
{state === "need_to_increase_unallocated" ? (
|
||||
<div className="flex flex-col pb-3">
|
||||
<Paragraph variant="small" className="text-warning" spacing>
|
||||
You need to unallocate{" "}
|
||||
{formatNumber(extraConcurrency - amountValue - extraUnallocatedConcurrency)} more
|
||||
concurrency from your environments in order to remove{" "}
|
||||
{formatNumber(extraConcurrency - amountValue)} concurrency from your account.
|
||||
</Paragraph>
|
||||
</div>
|
||||
) : state === "above_quota" ? (
|
||||
<div className="flex flex-col pb-3">
|
||||
<Paragraph variant="small" className="text-warning" spacing>
|
||||
Currently you can only have up to {maxQuota} extra concurrency. Send a request
|
||||
below to lift your current limit. We'll get back to you soon.
|
||||
</Paragraph>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col pb-3 tabular-nums">
|
||||
<div className="grid grid-cols-2 border-b border-grid-dimmed pb-1">
|
||||
<Header3 className="font-normal text-text-dimmed">Summary</Header3>
|
||||
<Header3 className="justify-self-end font-normal text-text-dimmed">Total</Header3>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 pt-2">
|
||||
<Header3 className="pb-0 font-normal text-text-dimmed">
|
||||
<span className="text-text-bright">{formatNumber(extraConcurrency)}</span>{" "}
|
||||
current total
|
||||
</Header3>
|
||||
<Header3 className="justify-self-end font-normal text-text-bright">
|
||||
{formatCurrency(
|
||||
(extraConcurrency * concurrencyPricing.centsPerStep) /
|
||||
concurrencyPricing.stepSize /
|
||||
100,
|
||||
true
|
||||
)}
|
||||
</Header3>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 text-xs">
|
||||
<span className="text-text-dimmed">
|
||||
({simplur`${extraConcurrency / concurrencyPricing.stepSize} bundle[|s]`})
|
||||
</span>
|
||||
<span className="justify-self-end text-text-dimmed">/mth</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 pt-2">
|
||||
<Header3 className={cn("pb-0 font-normal", changeClassName)}>
|
||||
{state === "increase" ? "+" : null}
|
||||
{formatNumber(amountValue - extraConcurrency)}
|
||||
</Header3>
|
||||
<Header3 className={cn("justify-self-end font-normal", changeClassName)}>
|
||||
{state === "increase" ? "+" : null}
|
||||
{formatCurrency(
|
||||
((amountValue - extraConcurrency) * concurrencyPricing.centsPerStep) /
|
||||
concurrencyPricing.stepSize /
|
||||
100,
|
||||
true
|
||||
)}
|
||||
</Header3>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 text-xs">
|
||||
<span className="text-text-dimmed">
|
||||
(
|
||||
{simplur`${
|
||||
(amountValue - extraConcurrency) / concurrencyPricing.stepSize
|
||||
} bundle[|s]`}{" "}
|
||||
@ {formatCurrency(concurrencyPricing.centsPerStep / 100, true)}/mth)
|
||||
</span>
|
||||
<span className="justify-self-end text-text-dimmed">/mth</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 pt-2">
|
||||
<Header3 className="pb-0 font-normal text-text-dimmed">
|
||||
<span className="text-text-bright">{formatNumber(amountValue)}</span> new total
|
||||
</Header3>
|
||||
<Header3 className="justify-self-end font-normal text-text-bright">
|
||||
{formatCurrency(
|
||||
(amountValue * concurrencyPricing.centsPerStep) /
|
||||
concurrencyPricing.stepSize /
|
||||
100,
|
||||
true
|
||||
)}
|
||||
</Header3>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 text-xs">
|
||||
<span className="text-text-dimmed">
|
||||
({simplur`${amountValue / concurrencyPricing.stepSize} bundle[|s]`})
|
||||
</span>
|
||||
<span className="justify-self-end text-text-dimmed">/mth</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
state === "above_quota" ? (
|
||||
<>
|
||||
<input type="hidden" name="action" value="quota-increase" />
|
||||
<Button
|
||||
LeadingIcon={isLoading ? SpinnerWhite : EnvelopeIcon}
|
||||
variant="primary/medium"
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{`Send request for ${formatNumber(amountValue)}`}
|
||||
</Button>
|
||||
</>
|
||||
) : state === "decrease" || state === "need_to_increase_unallocated" ? (
|
||||
<>
|
||||
<input type="hidden" name="action" value="purchase" />
|
||||
<Button
|
||||
variant="danger/medium"
|
||||
type="submit"
|
||||
disabled={isLoading || state === "need_to_increase_unallocated"}
|
||||
LeadingIcon={isLoading ? SpinnerWhite : undefined}
|
||||
>
|
||||
{`Remove ${formatNumber(extraConcurrency - amountValue)} concurrency`}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<input type="hidden" name="action" value="purchase" />
|
||||
<Button
|
||||
variant="primary/medium"
|
||||
type="submit"
|
||||
disabled={isLoading || state === "no_change"}
|
||||
LeadingIcon={isLoading ? SpinnerWhite : undefined}
|
||||
>
|
||||
{`Purchase ${formatNumber(amountValue - extraConcurrency)} concurrency`}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
cancelButton={
|
||||
<DialogClose asChild>
|
||||
<Button variant="tertiary/medium" disabled={isLoading}>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
}
|
||||
/>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function updateState({
|
||||
value,
|
||||
existingValue,
|
||||
quota,
|
||||
extraUnallocatedConcurrency,
|
||||
}: {
|
||||
value: number;
|
||||
existingValue: number;
|
||||
quota: number;
|
||||
extraUnallocatedConcurrency: number;
|
||||
}): "no_change" | "increase" | "decrease" | "above_quota" | "need_to_increase_unallocated" {
|
||||
if (value === existingValue) return "no_change";
|
||||
if (value < existingValue) {
|
||||
const difference = existingValue - value;
|
||||
if (difference > extraUnallocatedConcurrency) {
|
||||
return "need_to_increase_unallocated";
|
||||
}
|
||||
return "decrease";
|
||||
}
|
||||
if (value > quota) return "above_quota";
|
||||
return "increase";
|
||||
}
|
||||
+50
-26
@@ -40,6 +40,7 @@ import { cn } from "~/utils/cn";
|
||||
import { v3DeploymentParams, v3DeploymentsPath, v3RunsPath } from "~/utils/pathBuilder";
|
||||
import { capitalizeWord } from "~/utils/string";
|
||||
import { UserTag } from "../_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route";
|
||||
import { DeploymentEventFromString } from "@trigger.dev/core/v3/schemas";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
@@ -48,7 +49,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
|
||||
try {
|
||||
const presenter = new DeploymentPresenter();
|
||||
const { deployment, s2Logs } = await presenter.call({
|
||||
const { deployment, eventStream } = await presenter.call({
|
||||
userId,
|
||||
organizationSlug,
|
||||
projectSlug: projectParam,
|
||||
@@ -56,7 +57,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
deploymentShortCode: deploymentParam,
|
||||
});
|
||||
|
||||
return typedjson({ deployment, s2Logs });
|
||||
return typedjson({ deployment, eventStream });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw new Response(undefined, {
|
||||
@@ -69,21 +70,22 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
type LogEntry = {
|
||||
message: string;
|
||||
timestamp: Date;
|
||||
level: "info" | "error" | "warn";
|
||||
level: "info" | "error" | "warn" | "debug";
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { deployment, s2Logs } = useTypedLoaderData<typeof loader>();
|
||||
const { deployment, eventStream } = useTypedLoaderData<typeof loader>();
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
const location = useLocation();
|
||||
const page = new URLSearchParams(location.search).get("page");
|
||||
|
||||
const logsDisabled = s2Logs === undefined;
|
||||
const logsDisabled = eventStream === undefined;
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [isStreaming, setIsStreaming] = useState(true);
|
||||
const [streamError, setStreamError] = useState<string | null>(null);
|
||||
const isPending = deployment.status === "PENDING";
|
||||
|
||||
useEffect(() => {
|
||||
if (logsDisabled) return;
|
||||
@@ -96,9 +98,9 @@ export default function Page() {
|
||||
|
||||
const streamLogs = async () => {
|
||||
try {
|
||||
const s2 = new S2({ accessToken: s2Logs.accessToken });
|
||||
const basin = s2.basin(s2Logs.basin);
|
||||
const stream = basin.stream(s2Logs.stream);
|
||||
const s2 = new S2({ accessToken: eventStream.s2.accessToken });
|
||||
const basin = s2.basin(eventStream.s2.basin);
|
||||
const stream = basin.stream(eventStream.s2.stream);
|
||||
|
||||
const readSession = await stream.readSession(
|
||||
{
|
||||
@@ -112,27 +114,49 @@ export default function Page() {
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
for await (const record of readSession) {
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
const decoded = decoder.decode(record.body);
|
||||
const result = DeploymentEventFromString.safeParse(decoded);
|
||||
|
||||
if (record.headers) {
|
||||
for (const [nameBytes, valueBytes] of record.headers) {
|
||||
headers[decoder.decode(nameBytes)] = decoder.decode(valueBytes);
|
||||
if (!result.success) {
|
||||
// fallback to the previous format in s2 logs for compatibility
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
if (record.headers) {
|
||||
for (const [nameBytes, valueBytes] of record.headers) {
|
||||
headers[decoder.decode(nameBytes)] = decoder.decode(valueBytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
const level = (headers["level"]?.toLowerCase() as LogEntry["level"]) ?? "info";
|
||||
const level = (headers["level"]?.toLowerCase() as LogEntry["level"]) ?? "info";
|
||||
|
||||
setLogs((prevLogs) => [
|
||||
...prevLogs,
|
||||
{
|
||||
timestamp: new Date(record.timestamp),
|
||||
message: decoder.decode(record.body),
|
||||
level,
|
||||
},
|
||||
]);
|
||||
} catch (err) {
|
||||
console.error("Failed to parse log record:", err);
|
||||
setLogs((prevLogs) => [
|
||||
...prevLogs,
|
||||
{
|
||||
timestamp: new Date(record.timestamp),
|
||||
message: decoded,
|
||||
level,
|
||||
},
|
||||
]);
|
||||
} catch (err) {
|
||||
console.error("Failed to parse log record:", err);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
const event = result.data;
|
||||
if (event.type !== "log") {
|
||||
continue;
|
||||
}
|
||||
|
||||
setLogs((prevLogs) => [
|
||||
...prevLogs,
|
||||
{
|
||||
timestamp: new Date(record.timestamp),
|
||||
message: event.data.message,
|
||||
level: event.data.level,
|
||||
},
|
||||
]);
|
||||
}
|
||||
} catch (error) {
|
||||
if (abortController.signal.aborted) return;
|
||||
@@ -157,7 +181,7 @@ export default function Page() {
|
||||
return () => {
|
||||
abortController.abort();
|
||||
};
|
||||
}, [s2Logs?.basin, s2Logs?.stream, s2Logs?.accessToken]);
|
||||
}, [eventStream?.s2?.basin, eventStream?.s2?.stream, eventStream?.s2?.accessToken, isPending]);
|
||||
|
||||
return (
|
||||
<div className="grid h-full max-h-full grid-rows-[2.5rem_1fr] overflow-hidden bg-background-bright">
|
||||
|
||||
+2
-2
@@ -359,11 +359,11 @@ export default function Page() {
|
||||
</div>
|
||||
</div>
|
||||
) : environment.type === "DEVELOPMENT" ? (
|
||||
<MainCenteredContainer className="max-w-md">
|
||||
<MainCenteredContainer className="max-w-prose">
|
||||
<DeploymentsNoneDev />
|
||||
</MainCenteredContainer>
|
||||
) : (
|
||||
<MainCenteredContainer className="max-w-md">
|
||||
<MainCenteredContainer className="max-w-prose">
|
||||
<DeploymentsNone />
|
||||
</MainCenteredContainer>
|
||||
)}
|
||||
|
||||
+27
-19
@@ -68,11 +68,18 @@ import { EnvironmentQueuePresenter } from "~/presenters/v3/EnvironmentQueuePrese
|
||||
import { QueueListPresenter } from "~/presenters/v3/QueueListPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { docsPath, EnvironmentParamSchema, v3BillingPath, v3RunsPath } from "~/utils/pathBuilder";
|
||||
import {
|
||||
concurrencyPath,
|
||||
docsPath,
|
||||
EnvironmentParamSchema,
|
||||
v3BillingPath,
|
||||
v3RunsPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { concurrencySystem } from "~/v3/services/concurrencySystemInstance.server";
|
||||
import { PauseEnvironmentService } from "~/v3/services/pauseEnvironment.server";
|
||||
import { PauseQueueService } from "~/v3/services/pauseQueue.server";
|
||||
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
|
||||
import { ConcurrencyIcon } from "~/assets/icons/ConcurrencyIcon";
|
||||
|
||||
const SearchParamsSchema = z.object({
|
||||
query: z.string().optional(),
|
||||
@@ -385,7 +392,7 @@ export default function Page() {
|
||||
period: "30d",
|
||||
rootOnly: false,
|
||||
})}
|
||||
tooltip="View runs"
|
||||
tooltip="View running runs"
|
||||
/>
|
||||
}
|
||||
compactThreshold={1000000}
|
||||
@@ -406,18 +413,14 @@ export default function Page() {
|
||||
accessory={
|
||||
plan ? (
|
||||
plan?.v3Subscription?.plan?.limits.concurrentRuns.canExceed ? (
|
||||
<Feedback
|
||||
button={
|
||||
<Button
|
||||
variant="tertiary/small"
|
||||
LeadingIcon={ChatBubbleLeftEllipsisIcon}
|
||||
leadingIconClassName="text-indigo-500"
|
||||
>
|
||||
Increase limit…
|
||||
</Button>
|
||||
}
|
||||
defaultValue="concurrency"
|
||||
/>
|
||||
<LinkButton
|
||||
to={concurrencyPath(organization, project, env)}
|
||||
variant="tertiary/small"
|
||||
LeadingIcon={ConcurrencyIcon}
|
||||
leadingIconClassName="text-amber-500"
|
||||
>
|
||||
Increase limit
|
||||
</LinkButton>
|
||||
) : (
|
||||
<LinkButton
|
||||
to={v3BillingPath(organization, "Upgrade your plan for more concurrency")}
|
||||
@@ -496,7 +499,7 @@ export default function Page() {
|
||||
>
|
||||
Limited by
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell className="w-[1%] pl-24">
|
||||
<TableHeaderCell className="w-[1%] pl-32">
|
||||
<span className="sr-only">Pause/resume</span>
|
||||
</TableHeaderCell>
|
||||
</TableRow>
|
||||
@@ -568,7 +571,7 @@ export default function Page() {
|
||||
<TableCell
|
||||
alignment="right"
|
||||
className={cn(
|
||||
"w-[1%] tabular-nums",
|
||||
"w-[1%] pl-16 tabular-nums",
|
||||
queue.paused ? "opacity-50" : undefined
|
||||
)}
|
||||
>
|
||||
@@ -577,7 +580,7 @@ export default function Page() {
|
||||
<TableCell
|
||||
alignment="right"
|
||||
className={cn(
|
||||
"w-[1%] tabular-nums",
|
||||
"w-[1%] pl-16 tabular-nums",
|
||||
queue.paused ? "opacity-50" : undefined,
|
||||
queue.running > 0 && "text-text-bright",
|
||||
isAtLimit && "text-warning"
|
||||
@@ -588,7 +591,7 @@ export default function Page() {
|
||||
<TableCell
|
||||
alignment="right"
|
||||
className={cn(
|
||||
"w-[1%] tabular-nums",
|
||||
"w-[1%] pl-16 tabular-nums",
|
||||
queue.paused ? "opacity-50" : undefined,
|
||||
queue.concurrency?.overriddenAt && "font-medium text-text-bright"
|
||||
)}
|
||||
@@ -598,7 +601,7 @@ export default function Page() {
|
||||
<TableCell
|
||||
alignment="right"
|
||||
className={cn(
|
||||
"w-[1%]",
|
||||
"w-[1%] pl-16",
|
||||
queue.paused ? "opacity-50" : undefined,
|
||||
isAtLimit && "text-warning",
|
||||
queue.concurrency?.overriddenAt && "font-medium text-text-bright"
|
||||
@@ -1000,6 +1003,11 @@ function QueueOverrideConcurrencyButton({
|
||||
</div>
|
||||
|
||||
<FormButtons
|
||||
defaultAction={{
|
||||
name: "action",
|
||||
value: "queue-override",
|
||||
disabled: isLoading || !concurrencyLimit,
|
||||
}}
|
||||
confirmButton={
|
||||
<Button
|
||||
type="submit"
|
||||
|
||||
+326
-29
@@ -11,6 +11,7 @@ import {
|
||||
MagnifyingGlassPlusIcon,
|
||||
StopCircleIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
|
||||
import { useLoaderData, useRevalidator } from "@remix-run/react";
|
||||
import { type LoaderFunctionArgs, type SerializeFrom, json } from "@remix-run/server-runtime";
|
||||
import { type Virtualizer } from "@tanstack/react-virtual";
|
||||
@@ -25,6 +26,8 @@ import { motion } from "framer-motion";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useHotkeys } from "react-hotkeys-hook";
|
||||
import { redirect } from "remix-typedjson";
|
||||
import { ChevronExtraSmallDown } from "~/assets/icons/ChevronExtraSmallDown";
|
||||
import { ChevronExtraSmallUp } from "~/assets/icons/ChevronExtraSmallUp";
|
||||
import { MoveToTopIcon } from "~/assets/icons/MoveToTopIcon";
|
||||
import { MoveUpIcon } from "~/assets/icons/MoveUpIcon";
|
||||
import tileBgPath from "~/assets/images/error-banner-tile@2x.png";
|
||||
@@ -34,6 +37,7 @@ import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
|
||||
import { PageBody } from "~/components/layout/AppLayout";
|
||||
import { Badge } from "~/components/primitives/Badge";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { CopyableText } from "~/components/primitives/CopyableText";
|
||||
import { DateTimeShort } from "~/components/primitives/DateTime";
|
||||
import { Dialog, DialogTrigger } from "~/components/primitives/Dialog";
|
||||
import { Header3 } from "~/components/primitives/Headers";
|
||||
@@ -61,6 +65,7 @@ import {
|
||||
import { type NodesState } from "~/components/primitives/TreeView/reducer";
|
||||
import { CancelRunDialog } from "~/components/runs/v3/CancelRunDialog";
|
||||
import { ReplayRunDialog } from "~/components/runs/v3/ReplayRunDialog";
|
||||
import { getRunFiltersFromSearchParams } from "~/components/runs/v3/RunFilters";
|
||||
import { RunIcon } from "~/components/runs/v3/RunIcon";
|
||||
import {
|
||||
SpanTitle,
|
||||
@@ -68,7 +73,7 @@ import {
|
||||
eventBorderClassName,
|
||||
} from "~/components/runs/v3/SpanTitle";
|
||||
import { TaskRunStatusIcon, runStatusClassNameColor } from "~/components/runs/v3/TaskRunStatus";
|
||||
import { env } from "~/env.server";
|
||||
import { $replica } from "~/db.server";
|
||||
import { useDebounce } from "~/hooks/useDebounce";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useEventSource } from "~/hooks/useEventSource";
|
||||
@@ -76,10 +81,16 @@ import { useInitialDimensions } from "~/hooks/useInitialDimensions";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useReplaceSearchParams } from "~/hooks/useReplaceSearchParams";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import { type Shortcut, useShortcutKeys } from "~/hooks/useShortcutKeys";
|
||||
import { useHasAdminAccess } from "~/hooks/useUser";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { NextRunListPresenter } from "~/presenters/v3/NextRunListPresenter.server";
|
||||
import { RunEnvironmentMismatchError, RunPresenter } from "~/presenters/v3/RunPresenter.server";
|
||||
import { clickhouseClient } from "~/services/clickhouseInstance.server";
|
||||
import { getImpersonationId } from "~/services/impersonation.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { getResizableSnapshot } from "~/services/resizablePanel.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
@@ -88,16 +99,15 @@ import {
|
||||
docsPath,
|
||||
v3BillingPath,
|
||||
v3RunParamsSchema,
|
||||
v3RunPath,
|
||||
v3RunRedirectPath,
|
||||
v3RunSpanPath,
|
||||
v3RunStreamingPath,
|
||||
v3RunsPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import type { SpanOverride } from "~/v3/eventRepository/eventRepository.types";
|
||||
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
|
||||
import { SpanView } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import { CopyableText } from "~/components/primitives/CopyableText";
|
||||
import type { SpanOverride } from "~/v3/eventRepository/eventRepository.types";
|
||||
|
||||
const resizableSettings = {
|
||||
parent: {
|
||||
@@ -131,6 +141,106 @@ const resizableSettings = {
|
||||
|
||||
type TraceEvent = NonNullable<SerializeFrom<typeof loader>["trace"]>["events"][0];
|
||||
|
||||
type RunsListNavigation = {
|
||||
runs: Array<{ friendlyId: string; spanId: string }>;
|
||||
pagination: { next?: string; previous?: string };
|
||||
prevPageLastRun?: { friendlyId: string; spanId: string; cursor: string };
|
||||
nextPageFirstRun?: { friendlyId: string; spanId: string; cursor: string };
|
||||
};
|
||||
|
||||
async function getRunsListFromTableState({
|
||||
tableStateParam,
|
||||
organizationSlug,
|
||||
projectParam,
|
||||
envParam,
|
||||
runParam,
|
||||
userId,
|
||||
}: {
|
||||
tableStateParam: string | null;
|
||||
organizationSlug: string;
|
||||
projectParam: string;
|
||||
envParam: string;
|
||||
runParam: string;
|
||||
userId: string;
|
||||
}): Promise<RunsListNavigation | null> {
|
||||
if (!tableStateParam) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const tableStateSearchParams = new URLSearchParams(decodeURIComponent(tableStateParam));
|
||||
const filters = getRunFiltersFromSearchParams(tableStateSearchParams);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
const environment = await findEnvironmentBySlug(project?.id ?? "", envParam, userId);
|
||||
|
||||
if (!project || !environment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const runsListPresenter = new NextRunListPresenter($replica, clickhouseClient);
|
||||
const currentPageResult = await runsListPresenter.call(project.organizationId, environment.id, {
|
||||
userId,
|
||||
projectId: project.id,
|
||||
...filters,
|
||||
pageSize: 25, // Load enough runs to provide navigation context
|
||||
});
|
||||
|
||||
const runsList: RunsListNavigation = {
|
||||
runs: currentPageResult.runs,
|
||||
pagination: currentPageResult.pagination,
|
||||
};
|
||||
|
||||
const currentRunIndex = currentPageResult.runs.findIndex((r) => r.friendlyId === runParam);
|
||||
|
||||
if (currentRunIndex === 0 && currentPageResult.pagination.previous) {
|
||||
const prevPageResult = await runsListPresenter.call(project.organizationId, environment.id, {
|
||||
userId,
|
||||
projectId: project.id,
|
||||
...filters,
|
||||
cursor: currentPageResult.pagination.previous,
|
||||
direction: "backward",
|
||||
pageSize: 1, // We only need the last run from the previous page
|
||||
});
|
||||
|
||||
if (prevPageResult.runs.length > 0) {
|
||||
runsList.prevPageLastRun = {
|
||||
friendlyId: prevPageResult.runs[0].friendlyId,
|
||||
spanId: prevPageResult.runs[0].spanId,
|
||||
cursor: currentPageResult.pagination.previous,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
currentRunIndex === currentPageResult.runs.length - 1 &&
|
||||
currentPageResult.pagination.next
|
||||
) {
|
||||
const nextPageResult = await runsListPresenter.call(project.organizationId, environment.id, {
|
||||
userId,
|
||||
projectId: project.id,
|
||||
...filters,
|
||||
cursor: currentPageResult.pagination.next,
|
||||
direction: "forward",
|
||||
pageSize: 1, // We only need the first run from the next page
|
||||
});
|
||||
|
||||
if (nextPageResult.runs.length > 0) {
|
||||
runsList.nextPageFirstRun = {
|
||||
friendlyId: nextPageResult.runs[0].friendlyId,
|
||||
spanId: nextPageResult.runs[0].spanId,
|
||||
cursor: currentPageResult.pagination.next,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return runsList;
|
||||
} catch (error) {
|
||||
logger.error("Error loading runs list from tableState:", { error });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const impersonationId = await getImpersonationId(request);
|
||||
@@ -169,6 +279,15 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const parent = await getResizableSnapshot(request, resizableSettings.parent.autosaveId);
|
||||
const tree = await getResizableSnapshot(request, resizableSettings.tree.autosaveId);
|
||||
|
||||
const runsList = await getRunsListFromTableState({
|
||||
tableStateParam: url.searchParams.get("tableState"),
|
||||
organizationSlug,
|
||||
projectParam,
|
||||
envParam,
|
||||
runParam,
|
||||
userId,
|
||||
});
|
||||
|
||||
return json({
|
||||
run: result.run,
|
||||
trace: result.trace,
|
||||
@@ -177,13 +296,14 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
parent,
|
||||
tree,
|
||||
},
|
||||
runsList,
|
||||
});
|
||||
};
|
||||
|
||||
type LoaderData = SerializeFrom<typeof loader>;
|
||||
|
||||
export default function Page() {
|
||||
const { run, trace, resizable, maximumLiveReloadingSetting } = useLoaderData<typeof loader>();
|
||||
const { run, trace, maximumLiveReloadingSetting, runsList } = useLoaderData<typeof loader>();
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
@@ -191,16 +311,47 @@ export default function Page() {
|
||||
logCount: trace?.events.length ?? 0,
|
||||
isCompleted: run.completedAt !== null,
|
||||
});
|
||||
const { value } = useSearchParams();
|
||||
const tableState = decodeURIComponent(value("tableState") ?? "");
|
||||
const tableStateSearchParams = new URLSearchParams(tableState);
|
||||
const filters = getRunFiltersFromSearchParams(tableStateSearchParams);
|
||||
const tabParam = value("tab") ?? undefined;
|
||||
const spanParam = value("span") ?? undefined;
|
||||
|
||||
const [previousRunPath, nextRunPath] = useAdjacentRunPaths({
|
||||
organization,
|
||||
project,
|
||||
environment,
|
||||
tableState,
|
||||
run,
|
||||
runsList,
|
||||
tabParam,
|
||||
useSpan: !!spanParam,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<NavBar>
|
||||
<PageTitle
|
||||
backButton={{
|
||||
to: v3RunsPath(organization, project, environment),
|
||||
to: v3RunsPath(organization, project, environment, filters),
|
||||
text: "Runs",
|
||||
}}
|
||||
title={<CopyableText value={run.friendlyId} />}
|
||||
title={
|
||||
<div className="flex items-center gap-x-0">
|
||||
<CopyableText
|
||||
value={run.friendlyId}
|
||||
variant="text-below"
|
||||
className="-ml-[0.4375rem] h-6 px-1.5 font-mono text-xs hover:text-text-bright"
|
||||
/>
|
||||
{tableState && (
|
||||
<div className="flex">
|
||||
<PreviousRunButton to={previousRunPath} />
|
||||
<NextRunButton to={nextRunPath} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{environment.type === "DEVELOPMENT" && <DevDisconnectedBanner isConnected={isConnected} />}
|
||||
<PageAccessories>
|
||||
@@ -276,22 +427,20 @@ export default function Page() {
|
||||
run={run}
|
||||
trace={trace}
|
||||
maximumLiveReloadingSetting={maximumLiveReloadingSetting}
|
||||
resizable={resizable}
|
||||
/>
|
||||
) : (
|
||||
<NoLogsView
|
||||
run={run}
|
||||
trace={trace}
|
||||
maximumLiveReloadingSetting={maximumLiveReloadingSetting}
|
||||
resizable={resizable}
|
||||
/>
|
||||
<NoLogsView run={run} />
|
||||
)}
|
||||
</PageBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TraceView({ run, trace, maximumLiveReloadingSetting, resizable }: LoaderData) {
|
||||
function TraceView({
|
||||
run,
|
||||
trace,
|
||||
maximumLiveReloadingSetting,
|
||||
}: Pick<LoaderData, "run" | "trace" | "maximumLiveReloadingSetting">) {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
@@ -385,7 +534,7 @@ function TraceView({ run, trace, maximumLiveReloadingSetting, resizable }: Loade
|
||||
);
|
||||
}
|
||||
|
||||
function NoLogsView({ run, resizable }: LoaderData) {
|
||||
function NoLogsView({ run }: Pick<LoaderData, "run">) {
|
||||
const plan = useCurrentPlan();
|
||||
const organization = useOrganization();
|
||||
|
||||
@@ -750,7 +899,7 @@ function TasksTreeView({
|
||||
</ResizablePanelGroup>
|
||||
<div className="flex items-center justify-between gap-2 border-t border-grid-dimmed px-4">
|
||||
<div className="grow @container">
|
||||
<div className="hidden items-center gap-4 @[42rem]:flex">
|
||||
<div className="hidden items-center gap-4 @[48rem]:flex">
|
||||
<KeyboardShortcuts
|
||||
expandAllBelowDepth={expandAllBelowDepth}
|
||||
collapseAllBelowDepth={collapseAllBelowDepth}
|
||||
@@ -758,7 +907,7 @@ function TasksTreeView({
|
||||
setShowDurations={setShowDurations}
|
||||
/>
|
||||
</div>
|
||||
<div className="@[42rem]:hidden">
|
||||
<div className="@[48rem]:hidden">
|
||||
<Popover>
|
||||
<PopoverArrowTrigger>Shortcuts</PopoverArrowTrigger>
|
||||
<PopoverContent
|
||||
@@ -819,7 +968,6 @@ function TimelineView({
|
||||
scale,
|
||||
rootSpanStatus,
|
||||
rootStartedAt,
|
||||
parentRef,
|
||||
timelineScrollRef,
|
||||
virtualizer,
|
||||
events,
|
||||
@@ -835,6 +983,7 @@ function TimelineView({
|
||||
const initialTimelineDimensions = useInitialDimensions(timelineContainerRef);
|
||||
const minTimelineWidth = initialTimelineDimensions?.width ?? 300;
|
||||
const maxTimelineWidth = minTimelineWidth * 10;
|
||||
const disableSpansAnimations = rootSpanStatus !== "executing";
|
||||
|
||||
//we want to live-update the duration if the root span is still executing
|
||||
const [duration, setDuration] = useState(queueAdjustedNs(totalDuration, queuedDuration));
|
||||
@@ -1006,7 +1155,10 @@ function TimelineView({
|
||||
"-ml-[0.5px] h-[0.5625rem] w-px rounded-none",
|
||||
eventBackgroundClassName(node.data)
|
||||
)}
|
||||
layoutId={`${node.id}-${event.name}`}
|
||||
layoutId={
|
||||
disableSpansAnimations ? undefined : `${node.id}-${event.name}`
|
||||
}
|
||||
animate={disableSpansAnimations ? false : undefined}
|
||||
/>
|
||||
)}
|
||||
</Timeline.Point>
|
||||
@@ -1024,7 +1176,10 @@ function TimelineView({
|
||||
"-ml-[0.1562rem] size-[0.3125rem] rounded-full border bg-background-bright",
|
||||
eventBorderClassName(node.data)
|
||||
)}
|
||||
layoutId={`${node.id}-${event.name}`}
|
||||
layoutId={
|
||||
disableSpansAnimations ? undefined : `${node.id}-${event.name}`
|
||||
}
|
||||
animate={disableSpansAnimations ? false : undefined}
|
||||
/>
|
||||
)}
|
||||
</Timeline.Point>
|
||||
@@ -1043,7 +1198,8 @@ function TimelineView({
|
||||
>
|
||||
<motion.div
|
||||
className={cn("h-px w-full", eventBackgroundClassName(node.data))}
|
||||
layoutId={`mark-${node.id}`}
|
||||
layoutId={disableSpansAnimations ? undefined : `mark-${node.id}`}
|
||||
animate={disableSpansAnimations ? false : undefined}
|
||||
/>
|
||||
</Timeline.Span>
|
||||
) : null}
|
||||
@@ -1066,6 +1222,7 @@ function TimelineView({
|
||||
}
|
||||
node={node}
|
||||
fadeLeft={isTopSpan && queuedDuration !== undefined}
|
||||
disableAnimations={disableSpansAnimations}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
@@ -1080,7 +1237,8 @@ function TimelineView({
|
||||
"-ml-0.5 size-3 rounded-full border-2 border-background-bright",
|
||||
eventBackgroundClassName(node.data)
|
||||
)}
|
||||
layoutId={node.id}
|
||||
layoutId={disableSpansAnimations ? undefined : node.id}
|
||||
animate={disableSpansAnimations ? false : undefined}
|
||||
/>
|
||||
)}
|
||||
</Timeline.Point>
|
||||
@@ -1312,8 +1470,14 @@ function SpanWithDuration({
|
||||
showDuration,
|
||||
node,
|
||||
fadeLeft,
|
||||
disableAnimations,
|
||||
...props
|
||||
}: Timeline.SpanProps & { node: TraceEvent; showDuration: boolean; fadeLeft: boolean }) {
|
||||
}: Timeline.SpanProps & {
|
||||
node: TraceEvent;
|
||||
showDuration: boolean;
|
||||
fadeLeft: boolean;
|
||||
disableAnimations?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Timeline.Span {...props}>
|
||||
<motion.div
|
||||
@@ -1323,7 +1487,8 @@ function SpanWithDuration({
|
||||
fadeLeft ? "rounded-r-sm bg-gradient-to-r from-black/50 to-transparent" : "rounded-sm"
|
||||
)}
|
||||
style={{ backgroundSize: "20px 100%", backgroundRepeat: "no-repeat" }}
|
||||
layoutId={node.id}
|
||||
layoutId={disableAnimations ? undefined : node.id}
|
||||
animate={disableAnimations ? false : undefined}
|
||||
>
|
||||
{node.data.isPartial && (
|
||||
<div
|
||||
@@ -1336,10 +1501,12 @@ function SpanWithDuration({
|
||||
"sticky left-0 z-10 transition-opacity group-hover:opacity-100",
|
||||
!showDuration && "opacity-0"
|
||||
)}
|
||||
animate={disableAnimations ? false : undefined}
|
||||
>
|
||||
<motion.div
|
||||
className="whitespace-nowrap rounded-sm px-1 py-0.5 text-xxs text-text-bright text-shadow-custom"
|
||||
layout="position"
|
||||
layout={disableAnimations ? undefined : "position"}
|
||||
animate={disableAnimations ? false : undefined}
|
||||
>
|
||||
{formatDurationMilliseconds(props.durationMs, {
|
||||
style: "short",
|
||||
@@ -1422,16 +1589,16 @@ function KeyboardShortcuts({
|
||||
expandAllBelowDepth,
|
||||
collapseAllBelowDepth,
|
||||
toggleExpandLevel,
|
||||
setShowDurations,
|
||||
}: {
|
||||
expandAllBelowDepth: (depth: number) => void;
|
||||
collapseAllBelowDepth: (depth: number) => void;
|
||||
toggleExpandLevel: (depth: number) => void;
|
||||
setShowDurations: (show: (show: boolean) => boolean) => void;
|
||||
setShowDurations?: (show: (show: boolean) => boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<ArrowKeyShortcuts />
|
||||
<AdjacentRunsShortcuts />
|
||||
<ShortcutWithAction
|
||||
shortcut={{ key: "e" }}
|
||||
action={() => expandAllBelowDepth(0)}
|
||||
@@ -1448,6 +1615,18 @@ function KeyboardShortcuts({
|
||||
);
|
||||
}
|
||||
|
||||
function AdjacentRunsShortcuts() {
|
||||
return (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<ShortcutKey shortcut={{ key: "[" }} variant="medium" className="ml-0 mr-0 px-1" />
|
||||
<ShortcutKey shortcut={{ key: "]" }} variant="medium" className="ml-0 mr-0 px-1" />
|
||||
<Paragraph variant="extra-small" className="ml-1.5 whitespace-nowrap">
|
||||
Next/previous run
|
||||
</Paragraph>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ArrowKeyShortcuts() {
|
||||
return (
|
||||
<div className="flex items-center gap-0.5">
|
||||
@@ -1494,7 +1673,7 @@ function NumberShortcuts({ toggleLevel }: { toggleLevel: (depth: number) => void
|
||||
return (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<span className={cn(variants.medium, "ml-0 mr-0")}>0</span>
|
||||
<span className="text-[0.75rem] text-text-dimmed">–</span>
|
||||
<span className="text-[0.65rem] text-text-dimmed">–</span>
|
||||
<span className={cn(variants.medium, "ml-0 mr-0")}>9</span>
|
||||
<Paragraph variant="extra-small" className="ml-1.5 whitespace-nowrap">
|
||||
Toggle level
|
||||
@@ -1526,3 +1705,121 @@ function SearchField({ onChange }: { onChange: (value: string) => void }) {
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function useAdjacentRunPaths({
|
||||
organization,
|
||||
project,
|
||||
environment,
|
||||
tableState,
|
||||
run,
|
||||
runsList,
|
||||
tabParam,
|
||||
useSpan,
|
||||
}: {
|
||||
organization: { slug: string };
|
||||
project: { slug: string };
|
||||
environment: { slug: string };
|
||||
tableState: string;
|
||||
run: { friendlyId: string; spanId: string };
|
||||
runsList: RunsListNavigation | null;
|
||||
tabParam?: string;
|
||||
useSpan?: boolean;
|
||||
}): [string | null, string | null] {
|
||||
if (!runsList || runsList.runs.length === 0) {
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
const currentIndex = runsList.runs.findIndex((r) => r.friendlyId === run.friendlyId);
|
||||
|
||||
if (currentIndex === -1) {
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
// Determine previous run: use prevPageLastRun if at first position, otherwise use previous run in list
|
||||
let previousRun: { friendlyId: string; spanId: string } | null = null;
|
||||
const previousRunTableState = new URLSearchParams(tableState);
|
||||
if (currentIndex > 0) {
|
||||
previousRun = runsList.runs[currentIndex - 1];
|
||||
} else if (runsList.prevPageLastRun) {
|
||||
previousRun = runsList.prevPageLastRun;
|
||||
// Update tableState with the new cursor for the previous page
|
||||
previousRunTableState.set("cursor", runsList.prevPageLastRun.cursor);
|
||||
previousRunTableState.set("direction", "backward");
|
||||
}
|
||||
|
||||
// Determine next run: use nextPageFirstRun if at last position, otherwise use next run in list
|
||||
let nextRun: { friendlyId: string; spanId: string } | null = null;
|
||||
const nextRunTableState = new URLSearchParams(tableState);
|
||||
if (currentIndex < runsList.runs.length - 1) {
|
||||
nextRun = runsList.runs[currentIndex + 1];
|
||||
} else if (runsList.nextPageFirstRun) {
|
||||
nextRun = runsList.nextPageFirstRun;
|
||||
// Update tableState with the new cursor for the next page
|
||||
nextRunTableState.set("cursor", runsList.nextPageFirstRun.cursor);
|
||||
nextRunTableState.set("direction", "forward");
|
||||
}
|
||||
|
||||
const previousURLSearchParams = new URLSearchParams();
|
||||
previousURLSearchParams.set("tableState", previousRunTableState.toString());
|
||||
if (previousRun && useSpan) {
|
||||
previousURLSearchParams.set("span", previousRun.spanId);
|
||||
}
|
||||
if (tabParam && useSpan) {
|
||||
previousURLSearchParams.set("tab", tabParam);
|
||||
}
|
||||
const previousRunPath = previousRun
|
||||
? v3RunPath(organization, project, environment, previousRun, previousURLSearchParams)
|
||||
: null;
|
||||
|
||||
const nextURLSearchParams = new URLSearchParams();
|
||||
nextURLSearchParams.set("tableState", nextRunTableState.toString());
|
||||
if (nextRun && useSpan) {
|
||||
nextURLSearchParams.set("span", nextRun.spanId);
|
||||
}
|
||||
if (tabParam && useSpan) {
|
||||
nextURLSearchParams.set("tab", tabParam);
|
||||
}
|
||||
const nextRunPath = nextRun
|
||||
? v3RunPath(organization, project, environment, nextRun, nextURLSearchParams)
|
||||
: null;
|
||||
|
||||
return [previousRunPath, nextRunPath];
|
||||
}
|
||||
|
||||
function PreviousRunButton({ to }: { to: string | null }) {
|
||||
return (
|
||||
<div className={cn("peer/prev order-1", !to && "pointer-events-none")}>
|
||||
<LinkButton
|
||||
to={to ? to : "#"}
|
||||
variant={"minimal/small"}
|
||||
LeadingIcon={ChevronExtraSmallUp}
|
||||
leadingIconClassName="size-3 group-hover/button:text-text-bright transition-colors"
|
||||
className={cn("flex size-6 max-w-6 items-center", !to && "cursor-not-allowed opacity-50")}
|
||||
onClick={(e) => !to && e.preventDefault()}
|
||||
shortcut={{ key: "[" }}
|
||||
tooltip="Previous Run"
|
||||
disabled={!to}
|
||||
replace
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NextRunButton({ to }: { to: string | null }) {
|
||||
return (
|
||||
<div className={cn("peer/next order-3", !to && "pointer-events-none")}>
|
||||
<LinkButton
|
||||
to={to ? to : "#"}
|
||||
variant={"minimal/small"}
|
||||
LeadingIcon={ChevronExtraSmallDown}
|
||||
leadingIconClassName="size-3 group-hover/button:text-text-bright transition-colors"
|
||||
className={cn("flex size-6 max-w-6 items-center", !to && "cursor-not-allowed opacity-50")}
|
||||
onClick={(e) => !to && e.preventDefault()}
|
||||
shortcut={{ key: "]" }}
|
||||
tooltip="Next Run"
|
||||
disabled={!to}
|
||||
replace
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+15
-3
@@ -55,6 +55,7 @@ import {
|
||||
v3CreateBulkActionPath,
|
||||
v3ProjectPath,
|
||||
v3TestPath,
|
||||
v3TestTaskPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { ListPagination } from "../../components/ListPagination";
|
||||
import { CreateBulkActionInspector } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction";
|
||||
@@ -235,7 +236,13 @@ function RunsList({
|
||||
list.possibleTasks.length === 0 ? (
|
||||
<CreateFirstTaskInstructions />
|
||||
) : (
|
||||
<RunTaskInstructions />
|
||||
<RunTaskInstructions
|
||||
task={
|
||||
list.filters.tasks.length === 1
|
||||
? list.possibleTasks.find((t) => t.slug === list.filters.tasks[0])
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className={cn("grid h-full max-h-full grid-rows-[auto_1fr] overflow-hidden")}>
|
||||
@@ -291,6 +298,7 @@ function RunsList({
|
||||
runs={list.runs}
|
||||
isLoading={isLoading}
|
||||
allowSelection
|
||||
rootOnlyDefault={rootOnlyDefault}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -339,7 +347,7 @@ function CreateFirstTaskInstructions() {
|
||||
);
|
||||
}
|
||||
|
||||
function RunTaskInstructions() {
|
||||
function RunTaskInstructions({ task }: { task?: { slug: string } }) {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
@@ -352,7 +360,11 @@ function RunTaskInstructions() {
|
||||
Perform a test run with a payload directly from the dashboard.
|
||||
</Paragraph>
|
||||
<LinkButton
|
||||
to={v3TestPath(organization, project, environment)}
|
||||
to={
|
||||
task
|
||||
? v3TestTaskPath(organization, project, environment, { taskIdentifier: task.slug })
|
||||
: v3TestPath(organization, project, environment)
|
||||
}
|
||||
variant="secondary/medium"
|
||||
LeadingIcon={BeakerIcon}
|
||||
leadingIconClassName="text-lime-500"
|
||||
|
||||
+1
@@ -318,6 +318,7 @@ export default function Page() {
|
||||
runs={schedule.runs}
|
||||
isLoading={false}
|
||||
variant="bright"
|
||||
disableAdjacentRows
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 pt-2">
|
||||
|
||||
+48
-667
@@ -1,35 +1,19 @@
|
||||
import { conform, useForm } from "@conform-to/react";
|
||||
import { parse } from "@conform-to/zod";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
FolderIcon,
|
||||
TrashIcon,
|
||||
LockClosedIcon,
|
||||
PlusIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import {
|
||||
Form,
|
||||
type MetaFunction,
|
||||
useActionData,
|
||||
useNavigation,
|
||||
useNavigate,
|
||||
useSearchParams,
|
||||
} from "@remix-run/react";
|
||||
import { ExclamationTriangleIcon, FolderIcon, TrashIcon } from "@heroicons/react/20/solid";
|
||||
import { Form, type MetaFunction, useActionData, useNavigation } from "@remix-run/react";
|
||||
import { type ActionFunction, type LoaderFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
|
||||
import { InlineCode } from "~/components/code/InlineCode";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog";
|
||||
import { DialogClose } from "@radix-ui/react-dialog";
|
||||
import { OctoKitty } from "~/components/GitHubLoginButton";
|
||||
import {
|
||||
MainHorizontallyCenteredContainer,
|
||||
PageBody,
|
||||
PageContainer,
|
||||
} from "~/components/layout/AppLayout";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { CheckboxWithLabel } from "~/components/primitives/Checkbox";
|
||||
import { ClipboardField } from "~/components/primitives/ClipboardField";
|
||||
import { Fieldset } from "~/components/primitives/Fieldset";
|
||||
import { FormButtons } from "~/components/primitives/FormButtons";
|
||||
@@ -54,32 +38,12 @@ import {
|
||||
import { ProjectSettingsService } from "~/services/projectSettings.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import {
|
||||
organizationPath,
|
||||
v3ProjectPath,
|
||||
githubAppInstallPath,
|
||||
EnvironmentParamSchema,
|
||||
v3ProjectSettingsPath,
|
||||
docsPath,
|
||||
v3BillingPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { organizationPath, v3ProjectPath, EnvironmentParamSchema, v3BillingPath } from "~/utils/pathBuilder";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Select, SelectItem } from "~/components/primitives/Select";
|
||||
import { Switch } from "~/components/primitives/Switch";
|
||||
import { type BranchTrackingConfig } from "~/v3/github";
|
||||
import {
|
||||
EnvironmentIcon,
|
||||
environmentFullTitle,
|
||||
environmentTextClassName,
|
||||
} from "~/components/environments/EnvironmentLabel";
|
||||
import { GitBranchIcon } from "lucide-react";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { ProjectSettingsPresenter } from "~/services/projectSettingsPresenter.server";
|
||||
import { type BuildSettings } from "~/v3/buildSettings";
|
||||
import { InfoIconTooltip } from "~/components/primitives/Tooltip";
|
||||
import { GitHubSettingsPanel } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
@@ -127,29 +91,10 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
|
||||
return typedjson({
|
||||
githubAppEnabled: gitHubApp.enabled,
|
||||
githubAppInstallations: gitHubApp.installations,
|
||||
connectedGithubRepository: gitHubApp.connectedRepository,
|
||||
isPreviewEnvironmentEnabled: gitHubApp.isPreviewEnvironmentEnabled,
|
||||
buildSettings,
|
||||
});
|
||||
};
|
||||
|
||||
const ConnectGitHubRepoFormSchema = z.object({
|
||||
action: z.literal("connect-repo"),
|
||||
installationId: z.string(),
|
||||
repositoryId: z.string(),
|
||||
});
|
||||
|
||||
const UpdateGitSettingsFormSchema = z.object({
|
||||
action: z.literal("update-git-settings"),
|
||||
productionBranch: z.string().trim().optional(),
|
||||
stagingBranch: z.string().trim().optional(),
|
||||
previewDeploymentsEnabled: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val) => val === "on"),
|
||||
});
|
||||
|
||||
const UpdateBuildSettingsFormSchema = z.object({
|
||||
action: z.literal("update-build-settings"),
|
||||
triggerConfigFilePath: z
|
||||
@@ -180,6 +125,10 @@ const UpdateBuildSettingsFormSchema = z.object({
|
||||
.refine((val) => !val || val.length <= 500, {
|
||||
message: "Pre-build command must not exceed 500 characters",
|
||||
}),
|
||||
useNativeBuildServer: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val) => val === "on"),
|
||||
});
|
||||
|
||||
type UpdateBuildSettingsFormSchema = z.infer<typeof UpdateBuildSettingsFormSchema>;
|
||||
@@ -215,12 +164,7 @@ export function createSchema(
|
||||
}
|
||||
}),
|
||||
}),
|
||||
ConnectGitHubRepoFormSchema,
|
||||
UpdateGitSettingsFormSchema,
|
||||
UpdateBuildSettingsFormSchema,
|
||||
z.object({
|
||||
action: z.literal("disconnect-repo"),
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -255,7 +199,7 @@ export const action: ActionFunction = async ({ request, params }) => {
|
||||
return json({ errors: { body: membershipResultOrFail.error.type } }, { status: 404 });
|
||||
}
|
||||
|
||||
const { projectId, organizationId } = membershipResultOrFail.value;
|
||||
const { projectId } = membershipResultOrFail.value;
|
||||
|
||||
switch (submission.value.action) {
|
||||
case "rename": {
|
||||
@@ -311,108 +255,15 @@ export const action: ActionFunction = async ({ request, params }) => {
|
||||
"Project deleted"
|
||||
);
|
||||
}
|
||||
case "disconnect-repo": {
|
||||
const resultOrFail = await projectSettingsService.disconnectGitHubRepo(projectId);
|
||||
|
||||
if (resultOrFail.isErr()) {
|
||||
switch (resultOrFail.error.type) {
|
||||
case "other":
|
||||
default: {
|
||||
resultOrFail.error.type satisfies "other";
|
||||
|
||||
logger.error("Failed to disconnect GitHub repository", {
|
||||
error: resultOrFail.error,
|
||||
});
|
||||
return redirectBackWithErrorMessage(request, "Failed to disconnect GitHub repository");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return redirectBackWithSuccessMessage(request, "GitHub repository disconnected successfully");
|
||||
}
|
||||
case "update-git-settings": {
|
||||
const { productionBranch, stagingBranch, previewDeploymentsEnabled } = submission.value;
|
||||
|
||||
const resultOrFail = await projectSettingsService.updateGitSettings(
|
||||
projectId,
|
||||
productionBranch,
|
||||
stagingBranch,
|
||||
previewDeploymentsEnabled
|
||||
);
|
||||
|
||||
if (resultOrFail.isErr()) {
|
||||
switch (resultOrFail.error.type) {
|
||||
case "github_app_not_enabled": {
|
||||
return redirectBackWithErrorMessage(request, "GitHub app is not enabled");
|
||||
}
|
||||
case "connected_gh_repository_not_found": {
|
||||
return redirectBackWithErrorMessage(request, "Connected GitHub repository not found");
|
||||
}
|
||||
case "production_tracking_branch_not_found": {
|
||||
return redirectBackWithErrorMessage(request, "Production tracking branch not found");
|
||||
}
|
||||
case "staging_tracking_branch_not_found": {
|
||||
return redirectBackWithErrorMessage(request, "Staging tracking branch not found");
|
||||
}
|
||||
case "other":
|
||||
default: {
|
||||
resultOrFail.error.type satisfies "other";
|
||||
|
||||
logger.error("Failed to update Git settings", {
|
||||
error: resultOrFail.error,
|
||||
});
|
||||
return redirectBackWithErrorMessage(request, "Failed to update Git settings");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return redirectBackWithSuccessMessage(request, "Git settings updated successfully");
|
||||
}
|
||||
case "connect-repo": {
|
||||
const { repositoryId, installationId } = submission.value;
|
||||
|
||||
const resultOrFail = await projectSettingsService.connectGitHubRepo(
|
||||
projectId,
|
||||
organizationId,
|
||||
repositoryId,
|
||||
installationId
|
||||
);
|
||||
|
||||
if (resultOrFail.isErr()) {
|
||||
switch (resultOrFail.error.type) {
|
||||
case "gh_repository_not_found": {
|
||||
return redirectBackWithErrorMessage(request, "GitHub repository not found");
|
||||
}
|
||||
case "project_already_has_connected_repository": {
|
||||
return redirectBackWithErrorMessage(
|
||||
request,
|
||||
"Project already has a connected repository"
|
||||
);
|
||||
}
|
||||
case "other":
|
||||
default: {
|
||||
resultOrFail.error.type satisfies "other";
|
||||
|
||||
logger.error("Failed to connect GitHub repository", {
|
||||
error: resultOrFail.error,
|
||||
});
|
||||
return redirectBackWithErrorMessage(request, "Failed to connect GitHub repository");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return json({
|
||||
...submission,
|
||||
success: true,
|
||||
});
|
||||
}
|
||||
case "update-build-settings": {
|
||||
const { installCommand, preBuildCommand, triggerConfigFilePath } = submission.value;
|
||||
const { installCommand, preBuildCommand, triggerConfigFilePath, useNativeBuildServer } =
|
||||
submission.value;
|
||||
|
||||
const resultOrFail = await projectSettingsService.updateBuildSettings(projectId, {
|
||||
installCommand: installCommand || undefined,
|
||||
preBuildCommand: preBuildCommand || undefined,
|
||||
triggerConfigFilePath: triggerConfigFilePath || undefined,
|
||||
useNativeBuildServer: useNativeBuildServer,
|
||||
});
|
||||
|
||||
if (resultOrFail.isErr()) {
|
||||
@@ -439,13 +290,7 @@ export const action: ActionFunction = async ({ request, params }) => {
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const {
|
||||
githubAppInstallations,
|
||||
connectedGithubRepository,
|
||||
githubAppEnabled,
|
||||
buildSettings,
|
||||
isPreviewEnvironmentEnabled,
|
||||
} = useTypedLoaderData<typeof loader>();
|
||||
const { githubAppEnabled, buildSettings } = useTypedLoaderData<typeof loader>();
|
||||
const project = useProject();
|
||||
const organization = useOrganization();
|
||||
const environment = useEnvironment();
|
||||
@@ -571,19 +416,12 @@ export default function Page() {
|
||||
<div>
|
||||
<Header2 spacing>Git settings</Header2>
|
||||
<div className="w-full rounded-sm border border-grid-dimmed p-4">
|
||||
{connectedGithubRepository ? (
|
||||
<ConnectedGitHubRepoForm
|
||||
connectedGitHubRepo={connectedGithubRepository}
|
||||
previewEnvironmentEnabled={isPreviewEnvironmentEnabled}
|
||||
/>
|
||||
) : (
|
||||
<GitHubConnectionPrompt
|
||||
gitHubAppInstallations={githubAppInstallations ?? []}
|
||||
organizationSlug={organization.slug}
|
||||
projectSlug={project.slug}
|
||||
environmentSlug={environment.slug}
|
||||
/>
|
||||
)}
|
||||
<GitHubSettingsPanel
|
||||
organizationSlug={organization.slug}
|
||||
projectSlug={project.slug}
|
||||
environmentSlug={environment.slug}
|
||||
billingPath={v3BillingPath({ slug: organization.slug })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -643,489 +481,6 @@ export default function Page() {
|
||||
);
|
||||
}
|
||||
|
||||
type GitHubRepository = {
|
||||
id: string;
|
||||
name: string;
|
||||
fullName: string;
|
||||
private: boolean;
|
||||
htmlUrl: string;
|
||||
};
|
||||
|
||||
type GitHubAppInstallation = {
|
||||
id: string;
|
||||
appInstallationId: bigint;
|
||||
targetType: string;
|
||||
accountHandle: string;
|
||||
repositories: GitHubRepository[];
|
||||
};
|
||||
|
||||
function ConnectGitHubRepoModal({
|
||||
gitHubAppInstallations,
|
||||
organizationSlug,
|
||||
projectSlug,
|
||||
environmentSlug,
|
||||
}: {
|
||||
gitHubAppInstallations: GitHubAppInstallation[];
|
||||
organizationSlug: string;
|
||||
projectSlug: string;
|
||||
environmentSlug: string;
|
||||
open?: boolean;
|
||||
}) {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const lastSubmission = useActionData() as any;
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [selectedInstallation, setSelectedInstallation] = useState<
|
||||
GitHubAppInstallation | undefined
|
||||
>(gitHubAppInstallations.at(0));
|
||||
|
||||
const [selectedRepository, setSelectedRepository] = useState<GitHubRepository | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
const navigation = useNavigation();
|
||||
const isConnectRepositoryLoading =
|
||||
navigation.formData?.get("action") === "connect-repo" &&
|
||||
(navigation.state === "submitting" || navigation.state === "loading");
|
||||
|
||||
const [form, { installationId, repositoryId }] = useForm({
|
||||
id: "connect-repo",
|
||||
lastSubmission: lastSubmission,
|
||||
shouldRevalidate: "onSubmit",
|
||||
onValidate({ formData }) {
|
||||
return parse(formData, {
|
||||
schema: ConnectGitHubRepoFormSchema,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
|
||||
if (params.get("openGithubRepoModal") === "1") {
|
||||
setIsModalOpen(true);
|
||||
params.delete("openGithubRepoModal");
|
||||
setSearchParams(params);
|
||||
}
|
||||
}, [searchParams, setSearchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (lastSubmission && "success" in lastSubmission && lastSubmission.success === true) {
|
||||
setIsModalOpen(false);
|
||||
}
|
||||
}, [lastSubmission]);
|
||||
|
||||
return (
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button" variant={"secondary/medium"} LeadingIcon={OctoKitty}>
|
||||
Connect GitHub repo
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>Connect GitHub repository</DialogHeader>
|
||||
<div className="mt-2 flex flex-col gap-4">
|
||||
<Form method="post" {...form.props} className="w-full">
|
||||
<Paragraph className="mb-3">
|
||||
Choose a GitHub repository to connect to your project.
|
||||
</Paragraph>
|
||||
<Fieldset className="max-w-full gap-y-3">
|
||||
<InputGroup className="max-w-full">
|
||||
<Label htmlFor={installationId.id}>Account</Label>
|
||||
<Select
|
||||
name={installationId.name}
|
||||
id={installationId.id}
|
||||
value={selectedInstallation?.id}
|
||||
defaultValue={gitHubAppInstallations.at(0)?.id}
|
||||
setValue={(value) => {
|
||||
if (Array.isArray(value)) return;
|
||||
const installation = gitHubAppInstallations.find((i) => i.id === value);
|
||||
setSelectedInstallation(installation);
|
||||
setSelectedRepository(undefined);
|
||||
}}
|
||||
items={gitHubAppInstallations}
|
||||
variant="tertiary/small"
|
||||
placeholder="Select account"
|
||||
dropdownIcon
|
||||
text={selectedInstallation ? selectedInstallation.accountHandle : undefined}
|
||||
>
|
||||
{[
|
||||
...gitHubAppInstallations.map((installation) => (
|
||||
<SelectItem
|
||||
key={installation.id}
|
||||
value={installation.id}
|
||||
icon={<OctoKitty className="size-3 text-text-dimmed" />}
|
||||
>
|
||||
{installation.accountHandle}
|
||||
</SelectItem>
|
||||
)),
|
||||
<SelectItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
navigate(
|
||||
githubAppInstallPath(
|
||||
organizationSlug,
|
||||
`${v3ProjectSettingsPath(
|
||||
{ slug: organizationSlug },
|
||||
{ slug: projectSlug },
|
||||
{ slug: environmentSlug }
|
||||
)}?openGithubRepoModal=1`
|
||||
)
|
||||
);
|
||||
}}
|
||||
key="new-account"
|
||||
icon={<PlusIcon className="size-3 text-text-dimmed" />}
|
||||
>
|
||||
Add account
|
||||
</SelectItem>,
|
||||
]}
|
||||
</Select>
|
||||
<FormError id={installationId.errorId}>{installationId.error}</FormError>
|
||||
</InputGroup>
|
||||
<InputGroup className="max-w-full">
|
||||
<Label htmlFor={repositoryId.id}>Repository</Label>
|
||||
<Select
|
||||
name={repositoryId.name}
|
||||
id={repositoryId.id}
|
||||
value={selectedRepository ? selectedRepository.id : undefined}
|
||||
setValue={(value) => {
|
||||
if (Array.isArray(value)) return;
|
||||
const repository = selectedInstallation?.repositories.find(
|
||||
(r) => r.id === value
|
||||
);
|
||||
setSelectedRepository(repository);
|
||||
}}
|
||||
variant="tertiary/small"
|
||||
placeholder="Select repository"
|
||||
heading="Filter repositories"
|
||||
dropdownIcon
|
||||
items={selectedInstallation?.repositories ?? []}
|
||||
filter={{ keys: ["name"] }}
|
||||
disabled={!selectedInstallation || selectedInstallation.repositories.length === 0}
|
||||
text={selectedRepository ? selectedRepository.name : null}
|
||||
>
|
||||
{(matches) =>
|
||||
matches.map((repo) => (
|
||||
<SelectItem key={repo.id} value={repo.id}>
|
||||
<div className="flex items-center gap-1">
|
||||
{repo.name}
|
||||
{repo.private && <LockClosedIcon className="size-3 text-text-dimmed" />}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))
|
||||
}
|
||||
</Select>
|
||||
<Hint className={cn("invisible", selectedInstallation && "visible")}>
|
||||
Configure repository access in{" "}
|
||||
<TextLink
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
to={`https://github.com/settings/installations/${selectedInstallation?.appInstallationId}`}
|
||||
>
|
||||
GitHub
|
||||
</TextLink>
|
||||
.
|
||||
</Hint>
|
||||
<FormError id={repositoryId.errorId}>{repositoryId.error}</FormError>
|
||||
</InputGroup>
|
||||
<FormError>{form.error}</FormError>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button
|
||||
type="submit"
|
||||
name="action"
|
||||
value="connect-repo"
|
||||
variant="primary/medium"
|
||||
LeadingIcon={isConnectRepositoryLoading ? SpinnerWhite : undefined}
|
||||
leadingIconClassName="text-white"
|
||||
disabled={isConnectRepositoryLoading}
|
||||
>
|
||||
Connect repository
|
||||
</Button>
|
||||
}
|
||||
cancelButton={
|
||||
<DialogClose asChild>
|
||||
<Button variant="tertiary/medium">Cancel</Button>
|
||||
</DialogClose>
|
||||
}
|
||||
/>
|
||||
</Fieldset>
|
||||
</Form>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function GitHubConnectionPrompt({
|
||||
gitHubAppInstallations,
|
||||
organizationSlug,
|
||||
projectSlug,
|
||||
environmentSlug,
|
||||
}: {
|
||||
gitHubAppInstallations: GitHubAppInstallation[];
|
||||
organizationSlug: string;
|
||||
projectSlug: string;
|
||||
environmentSlug: string;
|
||||
}) {
|
||||
return (
|
||||
<Fieldset>
|
||||
<InputGroup fullWidth>
|
||||
{gitHubAppInstallations.length === 0 && (
|
||||
<LinkButton
|
||||
to={githubAppInstallPath(
|
||||
organizationSlug,
|
||||
`${v3ProjectSettingsPath(
|
||||
{ slug: organizationSlug },
|
||||
{ slug: projectSlug },
|
||||
{ slug: environmentSlug }
|
||||
)}?openGithubRepoModal=1`
|
||||
)}
|
||||
variant={"secondary/medium"}
|
||||
LeadingIcon={OctoKitty}
|
||||
>
|
||||
Install GitHub app
|
||||
</LinkButton>
|
||||
)}
|
||||
{gitHubAppInstallations.length !== 0 && (
|
||||
<div className="flex items-center gap-3">
|
||||
<ConnectGitHubRepoModal
|
||||
gitHubAppInstallations={gitHubAppInstallations}
|
||||
organizationSlug={organizationSlug}
|
||||
projectSlug={projectSlug}
|
||||
environmentSlug={environmentSlug}
|
||||
/>
|
||||
<span className="flex items-center gap-1 text-xs text-text-dimmed">
|
||||
<CheckCircleIcon className="size-4 text-success" /> GitHub app is installed
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Hint>Connect your GitHub repository to automatically deploy your changes.</Hint>
|
||||
</InputGroup>
|
||||
</Fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
type ConnectedGitHubRepo = {
|
||||
branchTracking: BranchTrackingConfig | undefined;
|
||||
previewDeploymentsEnabled: boolean;
|
||||
createdAt: Date;
|
||||
repository: GitHubRepository;
|
||||
};
|
||||
|
||||
function ConnectedGitHubRepoForm({
|
||||
connectedGitHubRepo,
|
||||
previewEnvironmentEnabled,
|
||||
}: {
|
||||
connectedGitHubRepo: ConnectedGitHubRepo;
|
||||
previewEnvironmentEnabled?: boolean;
|
||||
}) {
|
||||
const lastSubmission = useActionData() as any;
|
||||
const navigation = useNavigation();
|
||||
const organization = useOrganization();
|
||||
|
||||
const [hasGitSettingsChanges, setHasGitSettingsChanges] = useState(false);
|
||||
const [gitSettingsValues, setGitSettingsValues] = useState({
|
||||
productionBranch: connectedGitHubRepo.branchTracking?.prod?.branch || "",
|
||||
stagingBranch: connectedGitHubRepo.branchTracking?.staging?.branch || "",
|
||||
previewDeploymentsEnabled: connectedGitHubRepo.previewDeploymentsEnabled,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const hasChanges =
|
||||
gitSettingsValues.productionBranch !==
|
||||
(connectedGitHubRepo.branchTracking?.prod?.branch || "") ||
|
||||
gitSettingsValues.stagingBranch !==
|
||||
(connectedGitHubRepo.branchTracking?.staging?.branch || "") ||
|
||||
gitSettingsValues.previewDeploymentsEnabled !== connectedGitHubRepo.previewDeploymentsEnabled;
|
||||
setHasGitSettingsChanges(hasChanges);
|
||||
}, [gitSettingsValues, connectedGitHubRepo]);
|
||||
|
||||
const [gitSettingsForm, fields] = useForm({
|
||||
id: "update-git-settings",
|
||||
lastSubmission: lastSubmission,
|
||||
shouldRevalidate: "onSubmit",
|
||||
onValidate({ formData }) {
|
||||
return parse(formData, {
|
||||
schema: UpdateGitSettingsFormSchema,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const isGitSettingsLoading =
|
||||
navigation.formData?.get("action") === "update-git-settings" &&
|
||||
(navigation.state === "submitting" || navigation.state === "loading");
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-between rounded-sm border bg-grid-dimmed p-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<OctoKitty className="size-4" />
|
||||
<a
|
||||
href={connectedGitHubRepo.repository.htmlUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="max-w-52 truncate text-sm text-text-bright hover:underline"
|
||||
>
|
||||
{connectedGitHubRepo.repository.fullName}
|
||||
</a>
|
||||
{connectedGitHubRepo.repository.private && (
|
||||
<LockClosedIcon className="size-3 text-text-dimmed" />
|
||||
)}
|
||||
<span className="text-xs text-text-dimmed">
|
||||
<DateTime
|
||||
date={connectedGitHubRepo.createdAt}
|
||||
includeTime={false}
|
||||
includeSeconds={false}
|
||||
showTimezone={false}
|
||||
showTooltip={false}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="minimal/small">Disconnect</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>Disconnect GitHub repository</DialogHeader>
|
||||
<div className="flex flex-col gap-3 pt-3">
|
||||
<Paragraph className="mb-1">
|
||||
Are you sure you want to disconnect{" "}
|
||||
<span className="font-semibold">{connectedGitHubRepo.repository.fullName}</span>?
|
||||
This will stop automatic deployments from GitHub.
|
||||
</Paragraph>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Form method="post">
|
||||
<input type="hidden" name="action" value="disconnect-repo" />
|
||||
<Button type="submit" variant="danger/medium">
|
||||
Disconnect repository
|
||||
</Button>
|
||||
</Form>
|
||||
}
|
||||
cancelButton={
|
||||
<DialogClose asChild>
|
||||
<Button variant="tertiary/medium">Cancel</Button>
|
||||
</DialogClose>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<Form method="post" {...gitSettingsForm.props}>
|
||||
<Fieldset>
|
||||
<InputGroup fullWidth>
|
||||
<Hint>
|
||||
Every push to the selected tracking branch creates a deployment in the corresponding
|
||||
environment.
|
||||
</Hint>
|
||||
<div className="mt-1 grid grid-cols-[120px_1fr] gap-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<EnvironmentIcon environment={{ type: "PRODUCTION" }} className="size-4" />
|
||||
<span className={`text-sm ${environmentTextClassName({ type: "PRODUCTION" })}`}>
|
||||
{environmentFullTitle({ type: "PRODUCTION" })}
|
||||
</span>
|
||||
</div>
|
||||
<Input
|
||||
{...conform.input(fields.productionBranch, { type: "text" })}
|
||||
defaultValue={connectedGitHubRepo.branchTracking?.prod?.branch}
|
||||
placeholder="none"
|
||||
variant="tertiary"
|
||||
className="font-mono"
|
||||
icon={GitBranchIcon}
|
||||
onChange={(e) => {
|
||||
setGitSettingsValues((prev) => ({
|
||||
...prev,
|
||||
productionBranch: e.target.value,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<EnvironmentIcon environment={{ type: "STAGING" }} className="size-4" />
|
||||
<span className={`text-sm ${environmentTextClassName({ type: "STAGING" })}`}>
|
||||
{environmentFullTitle({ type: "STAGING" })}
|
||||
</span>
|
||||
</div>
|
||||
<Input
|
||||
{...conform.input(fields.stagingBranch, { type: "text" })}
|
||||
defaultValue={connectedGitHubRepo.branchTracking?.staging?.branch}
|
||||
placeholder="none"
|
||||
variant="tertiary"
|
||||
className="font-mono"
|
||||
icon={GitBranchIcon}
|
||||
onChange={(e) => {
|
||||
setGitSettingsValues((prev) => ({
|
||||
...prev,
|
||||
stagingBranch: e.target.value,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<EnvironmentIcon environment={{ type: "PREVIEW" }} className="size-4" />
|
||||
<span className={`text-sm ${environmentTextClassName({ type: "PREVIEW" })}`}>
|
||||
{environmentFullTitle({ type: "PREVIEW" })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Switch
|
||||
name="previewDeploymentsEnabled"
|
||||
disabled={!previewEnvironmentEnabled}
|
||||
defaultChecked={
|
||||
connectedGitHubRepo.previewDeploymentsEnabled && previewEnvironmentEnabled
|
||||
}
|
||||
variant="small"
|
||||
label="Create preview deployments for pull requests"
|
||||
labelPosition="right"
|
||||
onCheckedChange={(checked) => {
|
||||
setGitSettingsValues((prev) => ({
|
||||
...prev,
|
||||
previewDeploymentsEnabled: checked,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
{!previewEnvironmentEnabled && (
|
||||
<InfoIconTooltip
|
||||
content={
|
||||
<span className="text-xs">
|
||||
<TextLink to={v3BillingPath(organization)}>Upgrade</TextLink> your plan to
|
||||
enable preview branches
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<FormError>{fields.productionBranch?.error}</FormError>
|
||||
<FormError>{fields.stagingBranch?.error}</FormError>
|
||||
<FormError>{fields.previewDeploymentsEnabled?.error}</FormError>
|
||||
<FormError>{gitSettingsForm.error}</FormError>
|
||||
</InputGroup>
|
||||
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button
|
||||
type="submit"
|
||||
name="action"
|
||||
value="update-git-settings"
|
||||
variant="secondary/small"
|
||||
disabled={isGitSettingsLoading || !hasGitSettingsChanges}
|
||||
LeadingIcon={isGitSettingsLoading ? SpinnerWhite : undefined}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Fieldset>
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function BuildSettingsForm({ buildSettings }: { buildSettings: BuildSettings }) {
|
||||
const lastSubmission = useActionData() as any;
|
||||
const navigation = useNavigation();
|
||||
@@ -1135,13 +490,15 @@ function BuildSettingsForm({ buildSettings }: { buildSettings: BuildSettings })
|
||||
preBuildCommand: buildSettings?.preBuildCommand || "",
|
||||
installCommand: buildSettings?.installCommand || "",
|
||||
triggerConfigFilePath: buildSettings?.triggerConfigFilePath || "",
|
||||
useNativeBuildServer: buildSettings?.useNativeBuildServer || false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const hasChanges =
|
||||
buildSettingsValues.preBuildCommand !== (buildSettings?.preBuildCommand || "") ||
|
||||
buildSettingsValues.installCommand !== (buildSettings?.installCommand || "") ||
|
||||
buildSettingsValues.triggerConfigFilePath !== (buildSettings?.triggerConfigFilePath || "");
|
||||
buildSettingsValues.triggerConfigFilePath !== (buildSettings?.triggerConfigFilePath || "") ||
|
||||
buildSettingsValues.useNativeBuildServer !== (buildSettings?.useNativeBuildServer || false);
|
||||
setHasBuildSettingsChanges(hasChanges);
|
||||
}, [buildSettingsValues, buildSettings]);
|
||||
|
||||
@@ -1222,6 +579,30 @@ function BuildSettingsForm({ buildSettings }: { buildSettings: BuildSettings })
|
||||
</Hint>
|
||||
<FormError id={fields.preBuildCommand.errorId}>{fields.preBuildCommand.error}</FormError>
|
||||
</InputGroup>
|
||||
<div className="border-t border-grid-dimmed pt-4">
|
||||
<InputGroup>
|
||||
<CheckboxWithLabel
|
||||
id={fields.useNativeBuildServer.id}
|
||||
{...conform.input(fields.useNativeBuildServer, { type: "checkbox" })}
|
||||
label="Use native build server"
|
||||
variant="simple/small"
|
||||
defaultChecked={buildSettings?.useNativeBuildServer || false}
|
||||
onChange={(isChecked) => {
|
||||
setBuildSettingsValues((prev) => ({
|
||||
...prev,
|
||||
useNativeBuildServer: isChecked,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<Hint>
|
||||
Native build server builds do not rely on external build providers and will become the
|
||||
default in the future. Version 4.2.0 or newer is required.
|
||||
</Hint>
|
||||
<FormError id={fields.useNativeBuildServer.errorId}>
|
||||
{fields.useNativeBuildServer.error}
|
||||
</FormError>
|
||||
</InputGroup>
|
||||
</div>
|
||||
<FormError>{buildSettingsForm.error}</FormError>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
|
||||
+1
@@ -126,6 +126,7 @@ export default function Page() {
|
||||
runs={waitpoint.connectedRuns}
|
||||
isLoading={false}
|
||||
variant="bright"
|
||||
disableAdjacentRows
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+37
-1
@@ -24,12 +24,13 @@ import { Label } from "~/components/primitives/Label";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
import { InfoIconTooltip } from "~/components/primitives/Tooltip";
|
||||
import { prisma } from "~/db.server";
|
||||
import { featuresForRequest } from "~/features.server";
|
||||
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { getBillingAlerts, setBillingAlert } from "~/services/platform.v3.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { formatCurrency } from "~/utils/numberFormatter";
|
||||
import { formatCurrency, formatNumber } from "~/utils/numberFormatter";
|
||||
import {
|
||||
docsPath,
|
||||
OrganizationParamsSchema,
|
||||
@@ -183,6 +184,8 @@ export default function Page() {
|
||||
|
||||
const checkboxLevels = [0.75, 0.9, 1.0, 2.0, 5.0];
|
||||
|
||||
const spikeAlertLevels = [10.0, 20.0, 50.0, 100.0];
|
||||
|
||||
useEffect(() => {
|
||||
if (alerts.emails.length > 0) {
|
||||
requestIntent(form.ref.current ?? undefined, list.append(emails.name));
|
||||
@@ -272,6 +275,39 @@ export default function Page() {
|
||||
))}
|
||||
<FormError id={alertLevels.errorId}>{alertLevels.error}</FormError>
|
||||
</InputGroup>
|
||||
<InputGroup fullWidth>
|
||||
<div className="flex items-center gap-1">
|
||||
<Label>Spike alerts</Label>
|
||||
<InfoIconTooltip
|
||||
content={
|
||||
"Catch runaway usage from bugs or errors. We recommend keeping these enabled as a safety net."
|
||||
}
|
||||
disableHoverableContent
|
||||
/>
|
||||
</div>
|
||||
{spikeAlertLevels.map((level) => (
|
||||
<CheckboxWithLabel
|
||||
name={alertLevels.name}
|
||||
id={`level_${level}`}
|
||||
key={level}
|
||||
value={level.toString()}
|
||||
variant="simple/small"
|
||||
label={
|
||||
<span>
|
||||
{formatNumber(level * 100)}%{" "}
|
||||
<span className="text-text-dimmed">
|
||||
({formatCurrency(Number(dollarAmount) * level, false)})
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
defaultChecked={
|
||||
alerts.alertLevels.includes(level) ||
|
||||
!spikeAlertLevels.some((l) => alerts.alertLevels.includes(l))
|
||||
}
|
||||
className="pr-0"
|
||||
/>
|
||||
))}
|
||||
</InputGroup>
|
||||
<InputGroup fullWidth>
|
||||
<Label htmlFor={emails.id}>Email addresses</Label>
|
||||
{emailFields.map((email, index) => (
|
||||
|
||||
@@ -83,6 +83,7 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
|
||||
export default function ChoosePlanPage() {
|
||||
const {
|
||||
plans,
|
||||
addOnPricing,
|
||||
v3Subscription,
|
||||
organizationSlug,
|
||||
periodStart,
|
||||
@@ -141,6 +142,7 @@ export default function ChoosePlanPage() {
|
||||
<div>
|
||||
<PricingPlans
|
||||
plans={plans}
|
||||
concurrencyAddOnPricing={addOnPricing.concurrency}
|
||||
subscription={v3Subscription}
|
||||
organizationSlug={organizationSlug}
|
||||
hasPromotedPlan={false}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import invariant from "tiny-invariant";
|
||||
import { z } from "zod";
|
||||
import { BackgroundWrapper } from "~/components/BackgroundWrapper";
|
||||
import { Feedback } from "~/components/Feedback";
|
||||
import { AppContainer, MainCenteredContainer } from "~/components/layout/AppLayout";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Callout } from "~/components/primitives/Callout";
|
||||
@@ -21,10 +22,11 @@ import { Label } from "~/components/primitives/Label";
|
||||
import { ButtonSpinner } from "~/components/primitives/Spinner";
|
||||
import { prisma } from "~/db.server";
|
||||
import { featuresForRequest } from "~/features.server";
|
||||
import { redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { createProject } from "~/models/project.server";
|
||||
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { createProject, ExceededProjectLimitError } from "~/models/project.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import {
|
||||
newProjectPath,
|
||||
OrganizationParamsSchema,
|
||||
organizationPath,
|
||||
selectPlanPath,
|
||||
@@ -114,8 +116,29 @@ export const action: ActionFunction = async ({ request, params }) => {
|
||||
request,
|
||||
`${submission.value.projectName} created`
|
||||
);
|
||||
} catch (error: any) {
|
||||
return json({ errors: { body: error.message } }, { status: 400 });
|
||||
} catch (error) {
|
||||
if (error instanceof ExceededProjectLimitError) {
|
||||
return redirectWithErrorMessage(
|
||||
newProjectPath({ slug: organizationSlug }),
|
||||
request,
|
||||
error.message,
|
||||
{
|
||||
title: "Failed to create project",
|
||||
action: {
|
||||
label: "Request more projects",
|
||||
variant: "secondary/small",
|
||||
action: { type: "help", feedbackType: "help" },
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return redirectWithErrorMessage(
|
||||
newProjectPath({ slug: organizationSlug }),
|
||||
request,
|
||||
error instanceof Error ? error.message : "Something went wrong",
|
||||
{ ephemeral: false }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -191,6 +214,7 @@ export default function Page() {
|
||||
</Fieldset>
|
||||
</Form>
|
||||
</div>
|
||||
<Feedback button={<></>} />
|
||||
</MainCenteredContainer>
|
||||
</BackgroundWrapper>
|
||||
</AppContainer>
|
||||
|
||||
@@ -45,7 +45,7 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
|
||||
}
|
||||
|
||||
export default function ChoosePlanPage() {
|
||||
const { plans, v3Subscription, organizationSlug, periodEnd } =
|
||||
const { plans, v3Subscription, organizationSlug, periodEnd, addOnPricing } =
|
||||
useTypedLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
@@ -57,6 +57,7 @@ export default function ChoosePlanPage() {
|
||||
<div className="w-full rounded-lg border border-grid-bright bg-background-dimmed p-5 shadow-lg">
|
||||
<PricingPlans
|
||||
plans={plans}
|
||||
concurrencyAddOnPricing={addOnPricing.concurrency}
|
||||
subscription={v3Subscription}
|
||||
organizationSlug={organizationSlug}
|
||||
hasPromotedPlan
|
||||
|
||||
@@ -74,6 +74,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
||||
limit = body.development;
|
||||
break;
|
||||
}
|
||||
case "PREVIEW":
|
||||
case "STAGING": {
|
||||
limit = body.staging;
|
||||
break;
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import {
|
||||
type CreateArtifactResponseBody,
|
||||
CreateArtifactRequestBody,
|
||||
tryCatch,
|
||||
} from "@trigger.dev/core/v3";
|
||||
import { authenticateRequest } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { ArtifactsService } from "~/v3/services/artifacts.server";
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
if (request.method.toUpperCase() !== "POST") {
|
||||
return json({ error: "Method Not Allowed" }, { status: 405 });
|
||||
}
|
||||
|
||||
const authenticationResult = await authenticateRequest(request, {
|
||||
apiKey: true,
|
||||
organizationAccessToken: false,
|
||||
personalAccessToken: false,
|
||||
});
|
||||
|
||||
if (!authenticationResult || !authenticationResult.result.ok) {
|
||||
logger.info("Invalid or missing api key", { url: request.url });
|
||||
return json({ error: "Invalid or Missing API key" }, { status: 401 });
|
||||
}
|
||||
|
||||
const [, rawBody] = await tryCatch(request.json());
|
||||
const body = CreateArtifactRequestBody.safeParse(rawBody ?? {});
|
||||
|
||||
if (!body.success) {
|
||||
return json({ error: "Invalid request body", issues: body.error.issues }, { status: 400 });
|
||||
}
|
||||
|
||||
const { environment: authenticatedEnv } = authenticationResult.result;
|
||||
|
||||
const service = new ArtifactsService();
|
||||
return await service
|
||||
.createArtifact(body.data.type, authenticatedEnv, body.data.contentLength)
|
||||
.match(
|
||||
(result) => {
|
||||
return json(
|
||||
{
|
||||
artifactKey: result.artifactKey,
|
||||
uploadUrl: result.uploadUrl,
|
||||
uploadFields: result.uploadFields,
|
||||
expiresAt: result.expiresAt.toISOString(),
|
||||
} satisfies CreateArtifactResponseBody,
|
||||
{ status: 201 }
|
||||
);
|
||||
},
|
||||
(error) => {
|
||||
switch (error.type) {
|
||||
case "artifact_size_exceeds_limit": {
|
||||
logger.warn("Artifact size exceeds limit", { error });
|
||||
const sizeMB = parseFloat((error.contentLength / (1024 * 1024)).toFixed(1));
|
||||
const limitMB = parseFloat((error.sizeLimit / (1024 * 1024)).toFixed(1));
|
||||
|
||||
let errorMessage;
|
||||
|
||||
switch (body.data.type) {
|
||||
case "deployment_context":
|
||||
errorMessage = `Artifact size (${sizeMB} MB) exceeds the allowed limit of ${limitMB} MB. Make sure you are in the correct directory of your Trigger.dev project. Reach out to us if you are seeing this error consistently.`;
|
||||
break;
|
||||
default:
|
||||
body.data.type satisfies never;
|
||||
errorMessage = `Artifact size (${sizeMB} MB) exceeds the allowed limit of ${limitMB} MB`;
|
||||
}
|
||||
return json(
|
||||
{
|
||||
error: errorMessage,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
case "failed_to_create_presigned_post": {
|
||||
logger.error("Failed to create presigned POST", { error });
|
||||
return json({ error: "Failed to generate artifact upload URL" }, { status: 500 });
|
||||
}
|
||||
case "artifacts_bucket_not_configured": {
|
||||
logger.error("Artifacts bucket not configured", { error });
|
||||
return json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
default: {
|
||||
error satisfies never;
|
||||
logger.error("Failed creating artifact", { error });
|
||||
return json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,9 @@ export const loader = createLoaderApiRoute(
|
||||
friendlyId: params.batchId,
|
||||
runtimeEnvironmentId: auth.environment.id,
|
||||
},
|
||||
include: {
|
||||
errors: true,
|
||||
},
|
||||
});
|
||||
},
|
||||
authorization: {
|
||||
@@ -35,6 +38,18 @@ export const loader = createLoaderApiRoute(
|
||||
updatedAt: batch.updatedAt,
|
||||
runCount: batch.runCount,
|
||||
runs: batch.runIds,
|
||||
// Include error details for PARTIAL_FAILED batches
|
||||
successfulRunCount: batch.successfulRunCount ?? undefined,
|
||||
failedRunCount: batch.failedRunCount ?? undefined,
|
||||
errors:
|
||||
batch.errors.length > 0
|
||||
? batch.errors.map((err) => ({
|
||||
index: err.index,
|
||||
taskIdentifier: err.taskIdentifier,
|
||||
error: err.error,
|
||||
errorCode: err.errorCode ?? undefined,
|
||||
}))
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
@@ -37,7 +37,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
||||
const service = new InitializeDeploymentService();
|
||||
|
||||
try {
|
||||
const { deployment, imageRef } = await service.call(authenticatedEnv, body.data);
|
||||
const { deployment, imageRef, eventStream } = await service.call(authenticatedEnv, body.data);
|
||||
|
||||
const responseBody: InitializeDeploymentResponseBody = {
|
||||
id: deployment.friendlyId,
|
||||
@@ -48,6 +48,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
||||
deployment.externalBuildData as InitializeDeploymentResponseBody["externalBuildData"],
|
||||
imageTag: imageRef,
|
||||
imagePlatform: deployment.imagePlatform,
|
||||
eventStream,
|
||||
};
|
||||
|
||||
return json(responseBody, { status: 200 });
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { ServiceValidationError } from "~/v3/services/baseService.server";
|
||||
import { z } from "zod";
|
||||
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
||||
import { ResetIdempotencyKeyService } from "~/v3/services/resetIdempotencyKey.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
key: z.string(),
|
||||
});
|
||||
|
||||
const BodySchema = z.object({
|
||||
taskIdentifier: z.string().min(1, "Task identifier is required"),
|
||||
});
|
||||
|
||||
export const { action } = createActionApiRoute(
|
||||
{
|
||||
params: ParamsSchema,
|
||||
body: BodySchema,
|
||||
allowJWT: true,
|
||||
corsStrategy: "all",
|
||||
authorization: {
|
||||
action: "write",
|
||||
resource: () => ({}),
|
||||
superScopes: ["write:runs", "admin"],
|
||||
},
|
||||
},
|
||||
async ({ params, body, authentication }) => {
|
||||
const service = new ResetIdempotencyKeyService();
|
||||
|
||||
try {
|
||||
const result = await service.call(
|
||||
params.key,
|
||||
body.taskIdentifier,
|
||||
authentication.environment
|
||||
);
|
||||
return json(result, { status: 200 });
|
||||
} catch (error) {
|
||||
if (error instanceof ServiceValidationError) {
|
||||
return json({ error: error.message }, { status: error.status ?? 400 });
|
||||
}
|
||||
|
||||
logger.error("Failed to reset idempotency key via API", {
|
||||
error: error instanceof Error ? { name: error.name, message: error.message, stack: error.stack } : String(error),
|
||||
});
|
||||
|
||||
return json({ error: "Internal Server Error" }, { status: 500 });
|
||||
}
|
||||
|
||||
}
|
||||
);
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
CreateProjectRequestBody,
|
||||
GetProjectResponseBody,
|
||||
GetProjectsResponseBody,
|
||||
tryCatch,
|
||||
} from "@trigger.dev/core/v3";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "~/db.server";
|
||||
@@ -99,12 +100,18 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
||||
return json({ error: "Invalid request body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const project = await createProject({
|
||||
organizationSlug: organization.slug,
|
||||
name: parsedBody.data.name,
|
||||
userId: authenticationResult.userId,
|
||||
version: "v3",
|
||||
});
|
||||
const [error, project] = await tryCatch(
|
||||
createProject({
|
||||
organizationSlug: organization.slug,
|
||||
name: parsedBody.data.name,
|
||||
userId: authenticationResult.userId,
|
||||
version: "v3",
|
||||
})
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
const result: GetProjectResponseBody = {
|
||||
id: project.id,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { env as processEnv } from "~/env.server";
|
||||
import {
|
||||
authenticatedEnvironmentForAuthentication,
|
||||
authenticateRequest,
|
||||
branchNameFromRequest,
|
||||
} from "~/services/apiAuth.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
@@ -32,7 +33,8 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
const environment = await authenticatedEnvironmentForAuthentication(
|
||||
authenticationResult,
|
||||
projectRef,
|
||||
env
|
||||
env,
|
||||
branchNameFromRequest(request)
|
||||
);
|
||||
|
||||
const result: GetProjectEnvResponse = {
|
||||
|
||||
+3
-1
@@ -4,6 +4,7 @@ import { prisma } from "~/db.server";
|
||||
import {
|
||||
authenticateRequest,
|
||||
authenticatedEnvironmentForAuthentication,
|
||||
branchNameFromRequest,
|
||||
} from "~/services/apiAuth.server";
|
||||
import zlib from "node:zlib";
|
||||
|
||||
@@ -29,7 +30,8 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
|
||||
const environment = await authenticatedEnvironmentForAuthentication(
|
||||
authenticationResult,
|
||||
parsedParams.data.projectRef,
|
||||
parsedParams.data.envSlug
|
||||
parsedParams.data.envSlug,
|
||||
branchNameFromRequest(request)
|
||||
);
|
||||
|
||||
// Find the background worker and tasks and files
|
||||
|
||||
@@ -5,6 +5,7 @@ import { prisma } from "~/db.server";
|
||||
import {
|
||||
authenticateRequest,
|
||||
authenticatedEnvironmentForAuthentication,
|
||||
branchNameFromRequest,
|
||||
} from "~/services/apiAuth.server";
|
||||
import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server";
|
||||
|
||||
@@ -30,7 +31,8 @@ export async function action({ params, request }: ActionFunctionArgs) {
|
||||
const environment = await authenticatedEnvironmentForAuthentication(
|
||||
authenticationResult,
|
||||
parsedParams.data.projectRef,
|
||||
parsedParams.data.slug
|
||||
parsedParams.data.slug,
|
||||
branchNameFromRequest(request)
|
||||
);
|
||||
|
||||
// Find the environment variable
|
||||
@@ -106,7 +108,8 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
|
||||
const environment = await authenticatedEnvironmentForAuthentication(
|
||||
authenticationResult,
|
||||
parsedParams.data.projectRef,
|
||||
parsedParams.data.slug
|
||||
parsedParams.data.slug,
|
||||
branchNameFromRequest(request)
|
||||
);
|
||||
|
||||
// Find the environment variable
|
||||
|
||||
@@ -4,6 +4,7 @@ import { z } from "zod";
|
||||
import {
|
||||
authenticateRequest,
|
||||
authenticatedEnvironmentForAuthentication,
|
||||
branchNameFromRequest,
|
||||
} from "~/services/apiAuth.server";
|
||||
import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server";
|
||||
|
||||
@@ -28,7 +29,8 @@ export async function action({ params, request }: ActionFunctionArgs) {
|
||||
const environment = await authenticatedEnvironmentForAuthentication(
|
||||
authenticationResult,
|
||||
parsedParams.data.projectRef,
|
||||
parsedParams.data.slug
|
||||
parsedParams.data.slug,
|
||||
branchNameFromRequest(request)
|
||||
);
|
||||
|
||||
const jsonBody = await request.json();
|
||||
@@ -75,7 +77,8 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
|
||||
const environment = await authenticatedEnvironmentForAuthentication(
|
||||
authenticationResult,
|
||||
parsedParams.data.projectRef,
|
||||
parsedParams.data.slug
|
||||
parsedParams.data.slug,
|
||||
branchNameFromRequest(request)
|
||||
);
|
||||
|
||||
const repository = new EnvironmentVariablesRepository();
|
||||
|
||||
@@ -11,6 +11,7 @@ import { prisma } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import { ApiAuthenticationResultSuccess, getOneTimeUseToken } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { extractJwtSigningSecretKey } from "~/services/realtime/jwtAuth.server";
|
||||
import { determineRealtimeStreamsVersion } from "~/services/realtime/v1StreamsGlobal.server";
|
||||
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
||||
import { resolveIdempotencyKeyTTL } from "~/utils/idempotencyKeys.server";
|
||||
@@ -85,7 +86,7 @@ const { action, loader } = createActionApiRoute(
|
||||
isCached: false,
|
||||
}),
|
||||
buildResponseHeaders: async (responseBody, cachedEntity) => {
|
||||
return await responseHeaders(cachedEntity, authentication, triggerClient);
|
||||
return await responseHeaders(cachedEntity, authentication);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -102,18 +103,6 @@ const { action, loader } = createActionApiRoute(
|
||||
|
||||
const oneTimeUseToken = await getOneTimeUseToken(authentication);
|
||||
|
||||
logger.debug("Triggering task", {
|
||||
taskId: params.taskId,
|
||||
idempotencyKey,
|
||||
idempotencyKeyTTL,
|
||||
triggerVersion,
|
||||
headers,
|
||||
options: body.options,
|
||||
isFromWorker,
|
||||
traceContext,
|
||||
realtimeStreamsVersion,
|
||||
});
|
||||
|
||||
const idempotencyKeyExpiresAt = resolveIdempotencyKeyTTL(idempotencyKeyTTL);
|
||||
|
||||
const result = await service.call(
|
||||
@@ -140,7 +129,7 @@ const { action, loader } = createActionApiRoute(
|
||||
|
||||
await saveRequestIdempotency(requestIdempotencyKey, "trigger", result.run.id);
|
||||
|
||||
const $responseHeaders = await responseHeaders(result.run, authentication, triggerClient);
|
||||
const $responseHeaders = await responseHeaders(result.run, authentication);
|
||||
|
||||
return json(
|
||||
{
|
||||
@@ -170,39 +159,26 @@ const { action, loader } = createActionApiRoute(
|
||||
|
||||
async function responseHeaders(
|
||||
run: Pick<TaskRun, "friendlyId">,
|
||||
authentication: ApiAuthenticationResultSuccess,
|
||||
triggerClient?: string | null
|
||||
authentication: ApiAuthenticationResultSuccess
|
||||
): Promise<Record<string, string>> {
|
||||
const { environment, realtime } = authentication;
|
||||
|
||||
const claimsHeader = JSON.stringify({
|
||||
const claims = {
|
||||
sub: environment.id,
|
||||
pub: true,
|
||||
scopes: [`read:runs:${run.friendlyId}`],
|
||||
realtime,
|
||||
};
|
||||
|
||||
const jwt = await internal_generateJWT({
|
||||
secretKey: extractJwtSigningSecretKey(environment),
|
||||
payload: claims,
|
||||
expirationTime: "1h",
|
||||
});
|
||||
|
||||
if (triggerClient === "browser") {
|
||||
const claims = {
|
||||
sub: environment.id,
|
||||
pub: true,
|
||||
scopes: [`read:runs:${run.friendlyId}`],
|
||||
realtime,
|
||||
};
|
||||
|
||||
const jwt = await internal_generateJWT({
|
||||
secretKey: environment.apiKey,
|
||||
payload: claims,
|
||||
expirationTime: "1h",
|
||||
});
|
||||
|
||||
return {
|
||||
"x-trigger-jwt-claims": claimsHeader,
|
||||
"x-trigger-jwt": jwt,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
"x-trigger-jwt-claims": claimsHeader,
|
||||
"x-trigger-jwt-claims": JSON.stringify(claims),
|
||||
"x-trigger-jwt": jwt,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { OutOfEntitlementError } from "~/v3/services/triggerTask.server";
|
||||
import { HeadersSchema } from "./api.v1.tasks.$taskId.trigger";
|
||||
import { determineRealtimeStreamsVersion } from "~/services/realtime/v1StreamsGlobal.server";
|
||||
import { extractJwtSigningSecretKey } from "~/services/realtime/jwtAuth.server";
|
||||
|
||||
const { action, loader } = createActionApiRoute(
|
||||
{
|
||||
@@ -163,7 +164,7 @@ async function responseHeaders(
|
||||
};
|
||||
|
||||
const jwt = await generateJWT({
|
||||
secretKey: environment.apiKey,
|
||||
secretKey: extractJwtSigningSecretKey(environment),
|
||||
payload: claims,
|
||||
expirationTime: "1h",
|
||||
});
|
||||
|
||||
@@ -18,6 +18,9 @@ export const loader = createLoaderApiRoute(
|
||||
friendlyId: params.batchId,
|
||||
runtimeEnvironmentId: auth.environment.id,
|
||||
},
|
||||
include: {
|
||||
errors: true,
|
||||
},
|
||||
});
|
||||
},
|
||||
authorization: {
|
||||
@@ -33,8 +36,21 @@ export const loader = createLoaderApiRoute(
|
||||
idempotencyKey: batch.idempotencyKey ?? undefined,
|
||||
createdAt: batch.createdAt,
|
||||
updatedAt: batch.updatedAt,
|
||||
processingCompletedAt: batch.processingCompletedAt ?? undefined,
|
||||
runCount: batch.runCount,
|
||||
runs: batch.runIds,
|
||||
processing: {
|
||||
completedAt: batch.processingCompletedAt ?? undefined,
|
||||
errors:
|
||||
batch.errors.length > 0
|
||||
? batch.errors.map((err) => ({
|
||||
index: err.index,
|
||||
taskIdentifier: err.taskIdentifier,
|
||||
error: err.error,
|
||||
errorCode: err.errorCode ?? undefined,
|
||||
}))
|
||||
: [],
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
@@ -19,6 +19,7 @@ import { BatchProcessingStrategy } from "~/v3/services/batchTriggerV3.server";
|
||||
import { OutOfEntitlementError } from "~/v3/services/triggerTask.server";
|
||||
import { HeadersSchema } from "./api.v1.tasks.$taskId.trigger";
|
||||
import { determineRealtimeStreamsVersion } from "~/services/realtime/v1StreamsGlobal.server";
|
||||
import { extractJwtSigningSecretKey } from "~/services/realtime/jwtAuth.server";
|
||||
|
||||
const { action, loader } = createActionApiRoute(
|
||||
{
|
||||
@@ -109,6 +110,8 @@ const { action, loader } = createActionApiRoute(
|
||||
? { traceparent, tracestate }
|
||||
: { external: { traceparent, tracestate } };
|
||||
|
||||
// Note: SDK v4.3+ uses the 2-phase batch API (POST /api/v3/batches + streaming items)
|
||||
// This endpoint is for backwards compatibility with older SDK versions
|
||||
const service = new RunEngineBatchTriggerService(batchProcessingStrategy ?? undefined);
|
||||
|
||||
service.onBatchTaskRunCreated.attachOnce(async (batch) => {
|
||||
@@ -178,7 +181,7 @@ async function responseHeaders(
|
||||
};
|
||||
|
||||
const jwt = await generateJWT({
|
||||
secretKey: environment.apiKey,
|
||||
secretKey: extractJwtSigningSecretKey(environment),
|
||||
payload: claims,
|
||||
expirationTime: "1h",
|
||||
});
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { json, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { env } from "~/env.server";
|
||||
import {
|
||||
StreamBatchItemsService,
|
||||
createNdjsonParserStream,
|
||||
streamToAsyncIterable,
|
||||
} from "~/runEngine/services/streamBatchItems.server";
|
||||
import { authenticateApiRequestWithFailure } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { ServiceValidationError } from "~/v3/services/baseService.server";
|
||||
import { engine } from "~/v3/runEngine.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
batchId: z.string(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Phase 2 of 2-phase batch API: Stream batch items.
|
||||
*
|
||||
* POST /api/v3/batches/:batchId/items
|
||||
*
|
||||
* Accepts an NDJSON stream of batch items and enqueues them to the BatchQueue.
|
||||
* Each line in the body should be a valid BatchItemNDJSON object.
|
||||
*
|
||||
* The stream is processed with backpressure - items are enqueued as they arrive.
|
||||
* The batch is sealed when the stream completes successfully.
|
||||
*/
|
||||
export async function action({ request, params }: ActionFunctionArgs) {
|
||||
// Validate params
|
||||
const paramsResult = ParamsSchema.safeParse(params);
|
||||
if (!paramsResult.success) {
|
||||
return json({ error: "Invalid batch ID" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { batchId } = paramsResult.data;
|
||||
|
||||
// Validate content type
|
||||
const contentType = request.headers.get("content-type") || "";
|
||||
if (
|
||||
!contentType.includes("application/x-ndjson") &&
|
||||
!contentType.includes("application/ndjson")
|
||||
) {
|
||||
return json(
|
||||
{
|
||||
error: "Content-Type must be application/x-ndjson or application/ndjson",
|
||||
},
|
||||
{ status: 415 }
|
||||
);
|
||||
}
|
||||
|
||||
// Authenticate the request
|
||||
const authResult = await authenticateApiRequestWithFailure(request, {
|
||||
allowPublicKey: true,
|
||||
});
|
||||
|
||||
if (!authResult.ok) {
|
||||
return json({ error: authResult.error }, { status: 401 });
|
||||
}
|
||||
|
||||
// Get the request body stream
|
||||
const body = request.body;
|
||||
if (!body) {
|
||||
return json({ error: "Request body is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
logger.debug("Stream batch items request", {
|
||||
batchId,
|
||||
contentType,
|
||||
envId: authResult.environment.id,
|
||||
});
|
||||
|
||||
try {
|
||||
// Create NDJSON parser transform stream
|
||||
const parser = createNdjsonParserStream(env.STREAMING_BATCH_ITEM_MAXIMUM_SIZE);
|
||||
|
||||
// Pipe the request body through the parser
|
||||
const parsedStream = body.pipeThrough(parser);
|
||||
|
||||
// Convert to async iterable for the service
|
||||
const itemsIterator = streamToAsyncIterable(parsedStream);
|
||||
|
||||
// Process the stream
|
||||
const service = new StreamBatchItemsService();
|
||||
const result = await service.call(authResult.environment, batchId, itemsIterator, {
|
||||
maxItemBytes: env.STREAMING_BATCH_ITEM_MAXIMUM_SIZE,
|
||||
});
|
||||
|
||||
return json(result, { status: 200 });
|
||||
} catch (error) {
|
||||
logger.error("Stream batch items error", {
|
||||
batchId,
|
||||
error: {
|
||||
message: (error as Error).message,
|
||||
stack: (error as Error).stack,
|
||||
},
|
||||
});
|
||||
|
||||
if (error instanceof ServiceValidationError) {
|
||||
return json({ error: error.message }, { status: 422 });
|
||||
} else if (error instanceof Error) {
|
||||
// Check for stream parsing errors
|
||||
if (
|
||||
error.message.includes("Invalid JSON") ||
|
||||
error.message.includes("exceeds maximum size")
|
||||
) {
|
||||
return json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
return json(
|
||||
{ error: error.message },
|
||||
{ status: 500, headers: { "x-should-retry": "false" } }
|
||||
);
|
||||
}
|
||||
|
||||
return json({ error: "Something went wrong" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
// Return 405 for GET requests - only POST is allowed
|
||||
return json(
|
||||
{
|
||||
error: "Method not allowed. Use POST to stream batch items.",
|
||||
},
|
||||
{ status: 405 }
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { CreateBatchRequestBody, CreateBatchResponse, generateJWT } from "@trigger.dev/core/v3";
|
||||
import { prisma } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import { BatchRateLimitExceededError } from "~/runEngine/concerns/batchLimits.server";
|
||||
import { CreateBatchService } from "~/runEngine/services/createBatch.server";
|
||||
import { AuthenticatedEnvironment, getOneTimeUseToken } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
||||
import {
|
||||
handleRequestIdempotency,
|
||||
saveRequestIdempotency,
|
||||
} from "~/utils/requestIdempotency.server";
|
||||
import { ServiceValidationError } from "~/v3/services/baseService.server";
|
||||
import { OutOfEntitlementError } from "~/v3/services/triggerTask.server";
|
||||
import { HeadersSchema } from "./api.v1.tasks.$taskId.trigger";
|
||||
import { determineRealtimeStreamsVersion } from "~/services/realtime/v1StreamsGlobal.server";
|
||||
import { extractJwtSigningSecretKey } from "~/services/realtime/jwtAuth.server";
|
||||
import { engine } from "~/v3/runEngine.server";
|
||||
|
||||
/**
|
||||
* Phase 1 of 2-phase batch API: Create a batch.
|
||||
*
|
||||
* POST /api/v3/batches
|
||||
*
|
||||
* Creates a batch record and optionally blocks the parent run for batchTriggerAndWait.
|
||||
* Items are streamed separately via POST /api/v3/batches/:batchId/items
|
||||
*/
|
||||
const { action, loader } = createActionApiRoute(
|
||||
{
|
||||
headers: HeadersSchema,
|
||||
body: CreateBatchRequestBody,
|
||||
allowJWT: true,
|
||||
maxContentLength: 131_072, // 128KB is plenty for the batch metadata
|
||||
authorization: {
|
||||
action: "batchTrigger",
|
||||
resource: () => ({
|
||||
// No specific tasks to authorize at batch creation time
|
||||
// Tasks are validated when items are streamed
|
||||
tasks: [],
|
||||
}),
|
||||
superScopes: ["write:tasks", "admin"],
|
||||
},
|
||||
corsStrategy: "all",
|
||||
},
|
||||
async ({ body, headers, authentication }) => {
|
||||
// Validate runCount
|
||||
if (body.runCount <= 0) {
|
||||
return json({ error: "runCount must be a positive integer" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check runCount against limit
|
||||
if (body.runCount > env.STREAMING_BATCH_MAX_ITEMS) {
|
||||
return json(
|
||||
{
|
||||
error: `Batch runCount of ${body.runCount} exceeds maximum allowed of ${env.STREAMING_BATCH_MAX_ITEMS}.`,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
"trigger-version": triggerVersion,
|
||||
"x-trigger-span-parent-as-link": spanParentAsLink,
|
||||
"x-trigger-worker": isFromWorker,
|
||||
"x-trigger-client": triggerClient,
|
||||
"x-trigger-realtime-streams-version": realtimeStreamsVersion,
|
||||
traceparent,
|
||||
tracestate,
|
||||
} = headers;
|
||||
|
||||
const oneTimeUseToken = await getOneTimeUseToken(authentication);
|
||||
|
||||
logger.debug("Create batch request", {
|
||||
runCount: body.runCount,
|
||||
parentRunId: body.parentRunId,
|
||||
resumeParentOnCompletion: body.resumeParentOnCompletion,
|
||||
idempotencyKey: body.idempotencyKey,
|
||||
triggerVersion,
|
||||
isFromWorker,
|
||||
triggerClient,
|
||||
});
|
||||
|
||||
// Handle idempotency for the batch creation
|
||||
const cachedResponse = await handleRequestIdempotency<
|
||||
{ friendlyId: string; runCount: number },
|
||||
CreateBatchResponse
|
||||
>(body.idempotencyKey, {
|
||||
requestType: "create-batch",
|
||||
findCachedEntity: async (cachedRequestId) => {
|
||||
return await prisma.batchTaskRun.findFirst({
|
||||
where: {
|
||||
id: cachedRequestId,
|
||||
runtimeEnvironmentId: authentication.environment.id,
|
||||
},
|
||||
select: {
|
||||
friendlyId: true,
|
||||
runCount: true,
|
||||
},
|
||||
});
|
||||
},
|
||||
buildResponse: (cachedBatch) => ({
|
||||
id: cachedBatch.friendlyId,
|
||||
runCount: cachedBatch.runCount,
|
||||
isCached: true,
|
||||
}),
|
||||
buildResponseHeaders: async (responseBody) => {
|
||||
return await responseHeaders(responseBody, authentication.environment, triggerClient);
|
||||
},
|
||||
});
|
||||
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
}
|
||||
|
||||
const traceContext = isFromWorker
|
||||
? { traceparent, tracestate }
|
||||
: { external: { traceparent, tracestate } };
|
||||
|
||||
const service = new CreateBatchService();
|
||||
|
||||
service.onBatchTaskRunCreated.attachOnce(async (batch) => {
|
||||
await saveRequestIdempotency(body.idempotencyKey, "create-batch", batch.id);
|
||||
});
|
||||
|
||||
try {
|
||||
const batch = await service.call(authentication.environment, body, {
|
||||
triggerVersion: triggerVersion ?? undefined,
|
||||
traceContext,
|
||||
spanParentAsLink: spanParentAsLink === 1,
|
||||
oneTimeUseToken,
|
||||
realtimeStreamsVersion: determineRealtimeStreamsVersion(
|
||||
realtimeStreamsVersion ?? undefined
|
||||
),
|
||||
});
|
||||
|
||||
const $responseHeaders = await responseHeaders(
|
||||
batch,
|
||||
authentication.environment,
|
||||
triggerClient
|
||||
);
|
||||
|
||||
return json(batch, {
|
||||
status: 202,
|
||||
headers: $responseHeaders,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof BatchRateLimitExceededError) {
|
||||
logger.info("Batch rate limit exceeded", {
|
||||
limit: error.limit,
|
||||
remaining: error.remaining,
|
||||
resetAt: error.resetAt.toISOString(),
|
||||
itemCount: error.itemCount,
|
||||
});
|
||||
return json(
|
||||
{ error: error.message },
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
"X-RateLimit-Limit": error.limit.toString(),
|
||||
"X-RateLimit-Remaining": Math.max(0, error.remaining).toString(),
|
||||
"X-RateLimit-Reset": Math.floor(error.resetAt.getTime() / 1000).toString(),
|
||||
"Retry-After": Math.max(
|
||||
1,
|
||||
Math.ceil((error.resetAt.getTime() - Date.now()) / 1000)
|
||||
).toString(),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
logger.error("Create batch error", {
|
||||
error: {
|
||||
message: (error as Error).message,
|
||||
stack: (error as Error).stack,
|
||||
},
|
||||
});
|
||||
|
||||
if (error instanceof ServiceValidationError) {
|
||||
return json({ error: error.message }, { status: 422 });
|
||||
} else if (error instanceof OutOfEntitlementError) {
|
||||
return json({ error: error.message }, { status: 422 });
|
||||
} else if (error instanceof Error) {
|
||||
return json(
|
||||
{ error: error.message },
|
||||
{ status: 500, headers: { "x-should-retry": "false" } }
|
||||
);
|
||||
}
|
||||
|
||||
return json({ error: "Something went wrong" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
async function responseHeaders(
|
||||
batch: CreateBatchResponse,
|
||||
environment: AuthenticatedEnvironment,
|
||||
triggerClient?: string | null
|
||||
): Promise<Record<string, string>> {
|
||||
const claimsHeader = JSON.stringify({
|
||||
sub: environment.id,
|
||||
pub: true,
|
||||
});
|
||||
|
||||
if (triggerClient === "browser") {
|
||||
const claims = {
|
||||
sub: environment.id,
|
||||
pub: true,
|
||||
scopes: [`read:batch:${batch.id}`, `write:batch:${batch.id}`],
|
||||
};
|
||||
|
||||
const jwt = await generateJWT({
|
||||
secretKey: extractJwtSigningSecretKey(environment),
|
||||
payload: claims,
|
||||
expirationTime: "1h",
|
||||
});
|
||||
|
||||
return {
|
||||
"x-trigger-jwt-claims": claimsHeader,
|
||||
"x-trigger-jwt": jwt,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
"x-trigger-jwt-claims": claimsHeader,
|
||||
};
|
||||
}
|
||||
|
||||
export { action, loader };
|
||||
@@ -3,6 +3,7 @@ import { redirect } from "@remix-run/node";
|
||||
import { prisma } from "~/db.server";
|
||||
import { getSession, redirectWithErrorMessage } from "~/models/message.server";
|
||||
import { authenticator } from "~/services/auth.server";
|
||||
import { setLastAuthMethodHeader } from "~/services/lastAuthMethod.server";
|
||||
import { commitSession } from "~/services/sessionStorage.server";
|
||||
import { redirectCookie } from "./auth.github";
|
||||
import { sanitizeRedirectPath } from "~/utils";
|
||||
@@ -41,19 +42,19 @@ export let loader: LoaderFunction = async ({ request }) => {
|
||||
session.set("pending-mfa-user-id", userRecord.id);
|
||||
session.set("pending-mfa-redirect-to", redirectTo);
|
||||
|
||||
return redirect("/login/mfa", {
|
||||
headers: {
|
||||
"Set-Cookie": await commitSession(session),
|
||||
},
|
||||
});
|
||||
const headers = new Headers();
|
||||
headers.append("Set-Cookie", await commitSession(session));
|
||||
headers.append("Set-Cookie", await setLastAuthMethodHeader("github"));
|
||||
|
||||
return redirect("/login/mfa", { headers });
|
||||
}
|
||||
|
||||
// and store the user data
|
||||
session.set(authenticator.sessionKey, auth);
|
||||
|
||||
return redirect(redirectTo, {
|
||||
headers: {
|
||||
"Set-Cookie": await commitSession(session),
|
||||
},
|
||||
});
|
||||
const headers = new Headers();
|
||||
headers.append("Set-Cookie", await commitSession(session));
|
||||
headers.append("Set-Cookie", await setLastAuthMethodHeader("github"));
|
||||
|
||||
return redirect(redirectTo, { headers });
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user