Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e36b75785e | |||
| dee6f1d09e | |||
| b192b71b93 | |||
| a9163dfd9d | |||
| 540e1c86a4 | |||
| 2af5c860de | |||
| a09038b066 | |||
| 2135dc56d6 | |||
| 4a0fb2fc06 | |||
| e1f8134f44 | |||
| 10d6f01843 | |||
| 24b92d3b68 | |||
| 8003923598 | |||
| cff45664fc | |||
| 51b6c3a580 | |||
| d5a27f08ed | |||
| 719a44da01 | |||
| 92dfeb37b3 | |||
| b1e78a6590 | |||
| 5612383684 | |||
| 4451fcb84c | |||
| 863dbe8d60 | |||
| 39dd91b098 | |||
| cf6b6e7063 | |||
| bed3789c31 | |||
| a482153365 | |||
| fe193418d0 | |||
| e9fb8e3b52 | |||
| c05b30adfe | |||
| f37bdaac84 | |||
| 3c0644a3b8 | |||
| 19733c8338 | |||
| 9ba608d2cf | |||
| 89c73ed8ba | |||
| 97bf89873e | |||
| b60788df82 | |||
| 6409fea6ac | |||
| ae46e3f7c8 | |||
| 69dc7bcde8 | |||
| 3a7054628f | |||
| 676525279a | |||
| 79f8cdef72 |
@@ -0,0 +1,13 @@
|
||||
---
|
||||
paths:
|
||||
- "internal-packages/database/**"
|
||||
---
|
||||
|
||||
# Database Migration Safety
|
||||
|
||||
- When adding indexes to **existing tables**, use `CREATE INDEX CONCURRENTLY IF NOT EXISTS` to avoid table locks. These must be in their own separate migration file (one index per file).
|
||||
- Indexes on **newly created tables** (same migration as `CREATE TABLE`) do not need CONCURRENTLY.
|
||||
- When indexing a **new column on an existing table**, split into two migrations: first `ADD COLUMN IF NOT EXISTS`, then `CREATE INDEX CONCURRENTLY IF NOT EXISTS` in a separate file.
|
||||
- After generating a migration with Prisma, remove extraneous lines for: `_BackgroundWorkerToBackgroundWorkerFile`, `_BackgroundWorkerToTaskQueue`, `_TaskRunToTaskRunTag`, `_WaitpointRunConnections`, `_completedWaitpoints`, `SecretStore_key_idx`, and unrelated TaskRun indexes.
|
||||
- Never drop columns or tables without explicit approval.
|
||||
- New code should target `RunEngineVersion.V2` only.
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
paths:
|
||||
- "docs/**"
|
||||
---
|
||||
|
||||
# Documentation Writing Rules
|
||||
|
||||
- Use Mintlify MDX format. Frontmatter: `title`, `description`, `sidebarTitle` (optional).
|
||||
- After creating a new page, add it to `docs.json` navigation under the correct group.
|
||||
- Use Mintlify components: `<Note>`, `<Warning>`, `<Info>`, `<Tip>`, `<CodeGroup>`, `<Expandable>`, `<Steps>`/`<Step>`.
|
||||
- Code examples should be complete and runnable where possible.
|
||||
- Always import from `@trigger.dev/sdk`, never `@trigger.dev/sdk/v3`.
|
||||
- Keep paragraphs short. Use headers to break up content.
|
||||
- Link to related pages using relative paths (e.g., `[Tasks](/tasks/overview)`).
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
paths:
|
||||
- "apps/webapp/app/v3/**"
|
||||
---
|
||||
|
||||
# Legacy V1 Engine Code in `app/v3/`
|
||||
|
||||
The `v3/` directory name is misleading - most code here is actively used by the current V2 engine. Only the specific files below are legacy V1-only code.
|
||||
|
||||
## V1-Only Files - Never Modify
|
||||
|
||||
- `marqs/` directory (entire MarQS queue system: sharedQueueConsumer, devQueueConsumer, fairDequeuingStrategy, devPubSub)
|
||||
- `legacyRunEngineWorker.server.ts` (V1 background job worker)
|
||||
- `services/triggerTaskV1.server.ts` (deprecated V1 task triggering)
|
||||
- `services/cancelTaskRunV1.server.ts` (deprecated V1 cancellation)
|
||||
- `authenticatedSocketConnection.server.ts` (V1 dev WebSocket using DevQueueConsumer)
|
||||
- `sharedSocketConnection.ts` (V1 shared queue socket using SharedQueueConsumer)
|
||||
|
||||
## V1/V2 Branching Pattern
|
||||
|
||||
Some services act as routers that branch on `RunEngineVersion`:
|
||||
- `services/cancelTaskRun.server.ts` - calls V1 service or `engine.cancelRun()` for V2
|
||||
- `services/batchTriggerV3.server.ts` - uses marqs for V1 path, run-engine for V2
|
||||
|
||||
When editing these shared services, only modify V2 code paths.
|
||||
|
||||
## V2 Modern Stack
|
||||
|
||||
- **Run lifecycle**: `@internal/run-engine` (internal-packages/run-engine)
|
||||
- **Background jobs**: `@trigger.dev/redis-worker` (not graphile-worker/zodworker)
|
||||
- **Queue operations**: RunQueue inside run-engine (not MarQS)
|
||||
- **V2 engine singleton**: `runEngine.server.ts`, `runEngineHandlers.server.ts`
|
||||
- **V2 workers**: `commonWorker.server.ts`, `alertsWorker.server.ts`, `batchTriggerWorker.server.ts`
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
paths:
|
||||
- "packages/**"
|
||||
---
|
||||
|
||||
# Public Package Rules
|
||||
|
||||
- Changes to `packages/` are **customer-facing**. Always add a changeset: `pnpm run changeset:add`
|
||||
- Default to **patch**. Get maintainer approval for minor. Never select major without explicit approval.
|
||||
- `@trigger.dev/core`: **Never import the root**. Always use subpath imports (e.g., `@trigger.dev/core/v3`).
|
||||
- Do NOT update `rules/` or `.claude/skills/trigger-dev-tasks/` unless explicitly asked. These are maintained in separate dedicated passes.
|
||||
- Test changes using `references/hello-world` reference project.
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
paths:
|
||||
- "apps/**"
|
||||
---
|
||||
|
||||
# Server App Changes
|
||||
|
||||
When modifying server apps (webapp, supervisor, coordinator, etc.) with **no package changes**, add a `.server-changes/` file instead of a changeset:
|
||||
|
||||
```bash
|
||||
cat > .server-changes/descriptive-name.md << 'EOF'
|
||||
---
|
||||
area: webapp
|
||||
type: fix
|
||||
---
|
||||
|
||||
Brief description of what changed and why.
|
||||
EOF
|
||||
```
|
||||
|
||||
- **area**: `webapp` | `supervisor` | `coordinator` | `kubernetes-provider` | `docker-provider`
|
||||
- **type**: `feature` | `fix` | `improvement` | `breaking`
|
||||
- If the PR also touches `packages/`, just the changeset is sufficient (no `.server-changes/` needed).
|
||||
@@ -7,6 +7,7 @@ on:
|
||||
paths:
|
||||
- "packages/**"
|
||||
- ".changeset/**"
|
||||
- ".server-changes/**"
|
||||
- "package.json"
|
||||
- "pnpm-lock.yaml"
|
||||
|
||||
@@ -50,7 +51,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Update PR title with version
|
||||
- name: Update PR title and enhance body
|
||||
if: steps.changesets.outputs.published != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -61,6 +62,15 @@ jobs:
|
||||
# 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"
|
||||
|
||||
# Enhance the PR body with a clean, deduplicated summary
|
||||
RAW_BODY=$(gh pr view "$PR_NUMBER" --json body --jq '.body')
|
||||
ENHANCED_BODY=$(CHANGESET_PR_BODY="$RAW_BODY" node scripts/enhance-release-pr.mjs "$VERSION")
|
||||
if [ -n "$ENHANCED_BODY" ]; then
|
||||
gh api repos/triggerdotdev/trigger.dev/pulls/"$PR_NUMBER" \
|
||||
-X PATCH \
|
||||
-f body="$ENHANCED_BODY"
|
||||
fi
|
||||
fi
|
||||
|
||||
update-lockfile:
|
||||
@@ -88,15 +98,26 @@ jobs:
|
||||
- name: Install and update lockfile
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
|
||||
- name: Commit and push lockfile
|
||||
- name: Clean up consumed .server-changes/ files
|
||||
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"
|
||||
shopt -s nullglob
|
||||
files=(.server-changes/*.md)
|
||||
for f in "${files[@]}"; do
|
||||
if [ "$(basename "$f")" != "README.md" ]; then
|
||||
git rm --ignore-unmatch "$f"
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Commit and push lockfile + server-changes cleanup
|
||||
run: |
|
||||
set -e
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add pnpm-lock.yaml
|
||||
if ! git diff --cached --quiet; then
|
||||
git commit -m "chore: update lockfile and clean up .server-changes/ for release"
|
||||
git push origin changeset-release/main
|
||||
else
|
||||
echo "No changes to commit"
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
name: 📝 CLAUDE.md Audit
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, ready_for_review, synchronize]
|
||||
paths-ignore:
|
||||
- "docs/**"
|
||||
- ".changeset/**"
|
||||
- ".server-changes/**"
|
||||
- "**/*.md"
|
||||
- "references/**"
|
||||
|
||||
concurrency:
|
||||
group: claude-md-audit-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
audit:
|
||||
if: github.event.pull_request.draft == false
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
use_sticky_comment: true
|
||||
|
||||
claude_args: |
|
||||
--max-turns 15
|
||||
--allowedTools "Read,Glob,Grep,Bash(git diff:*)"
|
||||
|
||||
prompt: |
|
||||
You are reviewing a PR to check whether any CLAUDE.md files or .claude/rules/ files need updating.
|
||||
|
||||
## Your task
|
||||
|
||||
1. Run `git diff origin/main...HEAD --name-only` to see which files changed in this PR.
|
||||
2. For each changed directory, check if there's a CLAUDE.md in that directory or a parent directory.
|
||||
3. Determine if any CLAUDE.md or .claude/rules/ file should be updated based on the changes. Consider:
|
||||
- New files/directories that aren't covered by existing documentation
|
||||
- Changed architecture or patterns that contradict current CLAUDE.md guidance
|
||||
- New dependencies, services, or infrastructure that Claude should know about
|
||||
- Renamed or moved files that are referenced in CLAUDE.md
|
||||
- Changes to build commands, test patterns, or development workflows
|
||||
|
||||
## Response format
|
||||
|
||||
If NO updates are needed, respond with exactly:
|
||||
✅ CLAUDE.md files look current for this PR.
|
||||
|
||||
If updates ARE needed, respond with a short list:
|
||||
📝 **CLAUDE.md updates suggested:**
|
||||
- `path/to/CLAUDE.md`: [what should be added/changed]
|
||||
- `.claude/rules/file.md`: [what should be added/changed]
|
||||
|
||||
Keep suggestions specific and brief. Only flag things that would actually mislead Claude in future sessions.
|
||||
Do NOT suggest updates for trivial changes (bug fixes, small refactors within existing patterns).
|
||||
Do NOT suggest creating new CLAUDE.md files - only updates to existing ones.
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
uses: changesets/action@v1
|
||||
with:
|
||||
publish: pnpm run changeset:release
|
||||
createGithubReleases: true
|
||||
createGithubReleases: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -122,6 +122,19 @@ jobs:
|
||||
package_version=$(echo '${{ steps.changesets.outputs.publishedPackages }}' | jq -r '.[0].version')
|
||||
echo "package_version=${package_version}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create unified GitHub release
|
||||
if: steps.changesets.outputs.published == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_PR_BODY: ${{ github.event.pull_request.body }}
|
||||
run: |
|
||||
VERSION="${{ steps.get_version.outputs.package_version }}"
|
||||
node scripts/generate-github-release.mjs "$VERSION" > /tmp/release-body.md
|
||||
gh release create "v${VERSION}" \
|
||||
--title "trigger.dev v${VERSION}" \
|
||||
--notes-file /tmp/release-body.md \
|
||||
--target main
|
||||
|
||||
- name: Create and push Docker tag
|
||||
if: steps.changesets.outputs.published == 'true'
|
||||
run: |
|
||||
@@ -140,6 +153,62 @@ jobs:
|
||||
with:
|
||||
image_tag: v${{ needs.release.outputs.published_package_version }}
|
||||
|
||||
# After Docker images are published, update the GitHub release with the exact GHCR tag URL.
|
||||
# The GHCR package version ID is only known after the image is pushed, so we query for it here.
|
||||
update-release:
|
||||
name: 🔗 Update release Docker link
|
||||
needs: [release, publish-docker]
|
||||
if: needs.release.outputs.published == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
packages: read
|
||||
steps:
|
||||
- name: Update GitHub release with Docker image link
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -e
|
||||
VERSION="${{ needs.release.outputs.published_package_version }}"
|
||||
TAG="v${VERSION}"
|
||||
|
||||
# Query GHCR for the version ID matching this tag
|
||||
VERSION_ID=$(gh api --paginate -H "Accept: application/vnd.github+json" \
|
||||
/orgs/triggerdotdev/packages/container/trigger.dev/versions \
|
||||
--jq ".[] | select(.metadata.container.tags[] == \"${TAG}\") | .id" \
|
||||
| head -1)
|
||||
|
||||
if [ -z "$VERSION_ID" ]; then
|
||||
echo "Warning: Could not find GHCR version ID for tag ${TAG}, skipping update"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
DOCKER_URL="https://github.com/triggerdotdev/trigger.dev/pkgs/container/trigger.dev/${VERSION_ID}?tag=${TAG}"
|
||||
GENERIC_URL="https://github.com/triggerdotdev/trigger.dev/pkgs/container/trigger.dev"
|
||||
|
||||
# Get current release body and replace the generic link with the tag-specific one.
|
||||
# Use word boundary after GENERIC_URL (closing paren) to avoid matching URLs that
|
||||
# already have a version ID appended (idempotent on re-runs).
|
||||
gh release view "${TAG}" --json body --jq '.body' > /tmp/release-body.md
|
||||
sed -i "s|${GENERIC_URL})|${DOCKER_URL})|g" /tmp/release-body.md
|
||||
|
||||
gh release edit "${TAG}" --notes-file /tmp/release-body.md
|
||||
|
||||
# Dispatch changelog entry creation to the marketing site repo.
|
||||
# Runs after update-release so the GitHub release body already has the exact Docker image URL.
|
||||
dispatch-changelog:
|
||||
name: 📝 Dispatch changelog PR
|
||||
needs: [release, update-release]
|
||||
if: needs.release.outputs.published == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: peter-evans/repository-dispatch@v3
|
||||
with:
|
||||
token: ${{ secrets.CROSS_REPO_PAT }}
|
||||
repository: triggerdotdev/trigger.dev-site-v3
|
||||
event-type: new-release
|
||||
client-payload: '{"version": "${{ needs.release.outputs.published_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
|
||||
|
||||
@@ -13,8 +13,7 @@ jobs:
|
||||
check-pr:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: mitchellh/vouch/action/check-pr@main
|
||||
- uses: mitchellh/vouch/action/check-pr@c6d80ead49839655b61b422700b7a3bc9d0804a9 # v1.4.2
|
||||
with:
|
||||
pr-number: ${{ github.event.pull_request.number }}
|
||||
auto-close: true
|
||||
|
||||
@@ -16,8 +16,7 @@ jobs:
|
||||
contains(github.event.comment.body, 'denounce') ||
|
||||
contains(github.event.comment.body, 'unvouch')
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: mitchellh/vouch/action/manage-by-issue@main
|
||||
- uses: mitchellh/vouch/action/manage-by-issue@c6d80ead49839655b61b422700b7a3bc9d0804a9 # v1.4.2
|
||||
with:
|
||||
comment-id: ${{ github.event.comment.id }}
|
||||
issue-id: ${{ github.event.issue.number }}
|
||||
|
||||
+2
-1
@@ -67,4 +67,5 @@ apps/**/public/build
|
||||
**/.claude/settings.local.json
|
||||
.mcp.log
|
||||
.mcp.json
|
||||
.cursor/debug.log
|
||||
.cursor/debug.log
|
||||
ailogger-output.log
|
||||
@@ -0,0 +1,81 @@
|
||||
# Server Changes
|
||||
|
||||
This directory tracks changes to server-only components (webapp, supervisor, coordinator, etc.) that are not captured by changesets. Changesets only track published npm packages — server changes would otherwise go undocumented.
|
||||
|
||||
## When to add a file
|
||||
|
||||
**Server-only PRs**: If your PR only changes `apps/webapp/`, `apps/supervisor/`, `apps/coordinator/`, or other server components (and does NOT change anything in `packages/`), add a `.server-changes/` file.
|
||||
|
||||
**Mixed PRs** (both packages and server): Just add a changeset as usual. No `.server-changes/` file needed — the changeset covers it.
|
||||
|
||||
**Package-only PRs**: Just add a changeset as usual.
|
||||
|
||||
## File format
|
||||
|
||||
Create a markdown file with a descriptive name:
|
||||
|
||||
```
|
||||
.server-changes/fix-batch-queue-stalls.md
|
||||
```
|
||||
|
||||
With this format:
|
||||
|
||||
```markdown
|
||||
---
|
||||
area: webapp
|
||||
type: fix
|
||||
---
|
||||
|
||||
Speed up batch queue processing by removing stalls and fixing retry race
|
||||
```
|
||||
|
||||
### Fields
|
||||
|
||||
- **area** (required): `webapp` | `supervisor` | `coordinator` | `kubernetes-provider` | `docker-provider`
|
||||
- **type** (required): `feature` | `fix` | `improvement` | `breaking`
|
||||
|
||||
### Description
|
||||
|
||||
The body text (below the frontmatter) is a one-line description of the change. Keep it concise — it will appear in release notes.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
1. Engineer adds a `.server-changes/` file in their PR
|
||||
2. Files accumulate on `main` as PRs merge
|
||||
3. The changeset release PR includes these in its summary
|
||||
4. After the release merges, CI cleans up the consumed files
|
||||
|
||||
## Examples
|
||||
|
||||
**New feature:**
|
||||
|
||||
```markdown
|
||||
---
|
||||
area: webapp
|
||||
type: feature
|
||||
---
|
||||
|
||||
TRQL query language and the Query page
|
||||
```
|
||||
|
||||
**Bug fix:**
|
||||
|
||||
```markdown
|
||||
---
|
||||
area: webapp
|
||||
type: fix
|
||||
---
|
||||
|
||||
Fix schedule limit counting for orgs with custom limits
|
||||
```
|
||||
|
||||
**Improvement:**
|
||||
|
||||
```markdown
|
||||
---
|
||||
area: webapp
|
||||
type: improvement
|
||||
---
|
||||
|
||||
Use the replica for API auth queries to reduce primary load
|
||||
```
|
||||
+34
-9
@@ -1,24 +1,49 @@
|
||||
# Changesets
|
||||
# Changesets and Server Changes
|
||||
|
||||
Trigger.dev uses [changesets](https://github.com/changesets/changesets) to manage updated our packages and releasing them to npm.
|
||||
Trigger.dev uses [changesets](https://github.com/changesets/changesets) to manage package versions and releasing them to npm. For server-only changes, we use a lightweight `.server-changes/` convention.
|
||||
|
||||
## Adding a changeset
|
||||
## Adding a changeset (package changes)
|
||||
|
||||
To add a changeset, use `pnpm run changeset:add` and follow the instructions [here](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md). Please only ever select one of our public packages when adding a changeset.
|
||||
|
||||
## Release instructions (local only)
|
||||
## Adding a server change (server-only changes)
|
||||
|
||||
Based on the instructions [here](https://github.com/changesets/changesets/blob/main/docs/intro-to-using-changesets.md)
|
||||
If your PR only changes server components (`apps/webapp/`, `apps/supervisor/`, etc.) and does NOT change any published packages, add a `.server-changes/` file instead of a changeset:
|
||||
|
||||
1. Run `pnpm run changeset:version`
|
||||
2. Run `pnpm run changeset:release`
|
||||
```sh
|
||||
cat > .server-changes/fix-batch-queue-stalls.md << 'EOF'
|
||||
---
|
||||
area: webapp
|
||||
type: fix
|
||||
---
|
||||
|
||||
Speed up batch queue processing by removing stalls and fixing retry race
|
||||
EOF
|
||||
```
|
||||
|
||||
- `area`: `webapp` | `supervisor` | `coordinator` | `kubernetes-provider` | `docker-provider`
|
||||
- `type`: `feature` | `fix` | `improvement` | `breaking`
|
||||
|
||||
For **mixed PRs** (both packages and server): just add a changeset. No `.server-changes/` file needed.
|
||||
|
||||
See `.server-changes/README.md` for full documentation.
|
||||
|
||||
## When to add which
|
||||
|
||||
| PR changes | What to add |
|
||||
|---|---|
|
||||
| Only packages (`packages/`) | Changeset (`pnpm run changeset:add`) |
|
||||
| Only server (`apps/`) | `.server-changes/` file |
|
||||
| Both packages and server | Just the changeset |
|
||||
|
||||
## Release instructions (CI)
|
||||
|
||||
Please follow the best-practice of adding changesets in the same commit as the code making the change with `pnpm run changeset:add`, as it will allow our release.yml CI workflow to function properly:
|
||||
|
||||
- Anytime new changesets are added in a commit in the `main` branch, the [release.yml](./.github/workflows/release.yml) workflow will run and will automatically create/update a PR with a fresh run of `pnpm run changeset:version`.
|
||||
- When the version PR is merged into `main`, the release.yml workflow will automatically run `pnpm run changeset:release` to build and release packages to npm.
|
||||
- Anytime new changesets are added in a commit in the `main` branch, the [changesets-pr.yml](./.github/workflows/changesets-pr.yml) workflow will run and will automatically create/update a PR with a fresh run of `pnpm run changeset:version`.
|
||||
- The release PR body is automatically enhanced with a clean, deduplicated summary that includes both package changes and `.server-changes/` entries.
|
||||
- Consumed `.server-changes/` files are removed on the `changeset-release/main` branch — the same way changesets deletes `.changeset/*.md` files. When the release PR merges, they're gone from main.
|
||||
- When the version PR is merged into `main`, the [release.yml](./.github/workflows/release.yml) workflow will automatically build, release packages to npm, and create a single unified GitHub release.
|
||||
|
||||
## Pre-release instructions
|
||||
|
||||
|
||||
@@ -1,73 +1,47 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
This file provides guidance to Claude Code when working with this repository. Subdirectory CLAUDE.md files provide deeper context when you navigate into specific areas.
|
||||
|
||||
## Build and Development Commands
|
||||
|
||||
This is a pnpm 10.23.0 monorepo using Turborepo. Run commands from root with `pnpm run`.
|
||||
|
||||
### Essential Commands
|
||||
|
||||
```bash
|
||||
# Start Docker services (PostgreSQL, Redis, Electric)
|
||||
pnpm run docker
|
||||
|
||||
# Run database migrations
|
||||
pnpm run db:migrate
|
||||
|
||||
# Seed the database (required for reference projects)
|
||||
pnpm run db:seed
|
||||
pnpm run docker # Start Docker services (PostgreSQL, Redis, Electric)
|
||||
pnpm run db:migrate # Run database migrations
|
||||
pnpm run db:seed # Seed the database (required for reference projects)
|
||||
|
||||
# Build packages (required before running)
|
||||
pnpm run build --filter webapp && pnpm run build --filter trigger.dev && pnpm run build --filter @trigger.dev/sdk
|
||||
|
||||
# Run webapp in development mode (http://localhost:3030)
|
||||
pnpm run dev --filter webapp
|
||||
|
||||
# Build and watch for changes (CLI and packages)
|
||||
pnpm run dev --filter trigger.dev --filter "@trigger.dev/*"
|
||||
pnpm run dev --filter webapp # Run webapp (http://localhost:3030)
|
||||
pnpm run dev --filter trigger.dev --filter "@trigger.dev/*" # Watch CLI and packages
|
||||
```
|
||||
|
||||
### Testing
|
||||
## Testing
|
||||
|
||||
We use vitest exclusively. **Never mock anything** - use testcontainers instead.
|
||||
|
||||
```bash
|
||||
# Run all tests for a package
|
||||
pnpm run test --filter webapp
|
||||
|
||||
# Run a single test file (preferred - cd into directory first)
|
||||
pnpm run test --filter webapp # All tests for a package
|
||||
cd internal-packages/run-engine
|
||||
pnpm run test ./src/engine/tests/ttl.test.ts --run
|
||||
|
||||
# May need to build dependencies first
|
||||
pnpm run build --filter @internal/run-engine
|
||||
pnpm run test ./src/engine/tests/ttl.test.ts --run # Single test file
|
||||
pnpm run build --filter @internal/run-engine # May need to build deps first
|
||||
```
|
||||
|
||||
Test files go next to source files (e.g., `MyService.ts` → `MyService.test.ts`).
|
||||
Test files go next to source files (e.g., `MyService.ts` -> `MyService.test.ts`).
|
||||
|
||||
#### Testcontainers for Redis/PostgreSQL
|
||||
### Testcontainers for Redis/PostgreSQL
|
||||
|
||||
```typescript
|
||||
import { redisTest, postgresTest, containerTest } from "@internal/testcontainers";
|
||||
|
||||
// Redis only
|
||||
redisTest("should use redis", async ({ redisOptions }) => {
|
||||
/* ... */
|
||||
});
|
||||
|
||||
// PostgreSQL only
|
||||
postgresTest("should use postgres", async ({ prisma }) => {
|
||||
/* ... */
|
||||
});
|
||||
|
||||
// Both Redis and PostgreSQL
|
||||
containerTest("should use both", async ({ prisma, redisOptions }) => {
|
||||
/* ... */
|
||||
});
|
||||
redisTest("should use redis", async ({ redisOptions }) => { /* ... */ });
|
||||
postgresTest("should use postgres", async ({ prisma }) => { /* ... */ });
|
||||
containerTest("should use both", async ({ prisma, redisOptions }) => { /* ... */ });
|
||||
```
|
||||
|
||||
### Changesets
|
||||
## Changesets and Server Changes
|
||||
|
||||
When modifying any public package (`packages/*` or `integrations/*`), add a changeset:
|
||||
|
||||
@@ -77,227 +51,112 @@ pnpm run changeset:add
|
||||
|
||||
- Default to **patch** for bug fixes and minor changes
|
||||
- Confirm with maintainers before selecting **minor** (new features)
|
||||
- **Never** select major (breaking changes) without explicit approval
|
||||
- **Never** select major without explicit approval
|
||||
|
||||
When modifying only server components (`apps/webapp/`, `apps/supervisor/`, etc.) with no package changes, add a `.server-changes/` file instead. See `.server-changes/README.md` for format and documentation.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Request Flow
|
||||
|
||||
User API call -> Webapp routes -> Services -> RunEngine -> Redis Queue -> Supervisor -> Container execution -> Results back through RunEngine -> ClickHouse (analytics) + PostgreSQL (state)
|
||||
|
||||
### Apps
|
||||
|
||||
- **apps/webapp**: Remix 2.1.0 app - main API, dashboard, and Docker image. Uses Express server.
|
||||
- **apps/supervisor**: Node.js app handling task execution, interfacing with Docker/Kubernetes.
|
||||
- **apps/webapp**: Remix 2.1.0 app - main API, dashboard, orchestration. Uses Express server.
|
||||
- **apps/supervisor**: Manages task execution containers (Docker/Kubernetes).
|
||||
|
||||
### Public Packages
|
||||
|
||||
- **packages/trigger-sdk** (`@trigger.dev/sdk`): Main SDK
|
||||
- **packages/cli-v3** (`trigger.dev`): CLI package
|
||||
- **packages/core** (`@trigger.dev/core`): Shared code between SDK and webapp. Import subpaths only (never root).
|
||||
- **packages/build**: Build extensions and types
|
||||
- **packages/trigger-sdk** (`@trigger.dev/sdk`): Main SDK for writing tasks
|
||||
- **packages/cli-v3** (`trigger.dev`): CLI - also bundles code that goes into customer task images
|
||||
- **packages/core** (`@trigger.dev/core`): Shared types. **Import subpaths only** (never root).
|
||||
- **packages/build** (`@trigger.dev/build`): Build extensions and types
|
||||
- **packages/react-hooks**: React hooks for realtime and triggering
|
||||
- **packages/redis-worker** (`@trigger.dev/redis-worker`): Custom Redis-based background job system
|
||||
- **packages/redis-worker** (`@trigger.dev/redis-worker`): Redis-based background job system
|
||||
|
||||
### Internal Packages
|
||||
|
||||
- **internal-packages/database** (`@trigger.dev/database`): Prisma 6.14.0 client and schema
|
||||
- **internal-packages/clickhouse** (`@internal/clickhouse`): ClickHouse client and schema migrations
|
||||
- **internal-packages/run-engine** (`@internal/run-engine`): "Run Engine 2.0" - run lifecycle management
|
||||
- **internal-packages/redis** (`@internal/redis`): Redis client creation utilities
|
||||
- **internal-packages/testcontainers** (`@internal/testcontainers`): Test helpers for Redis/PostgreSQL containers
|
||||
- **internal-packages/zodworker** (`@internal/zodworker`): Graphile-worker wrapper (being replaced by redis-worker)
|
||||
- **internal-packages/database**: Prisma 6.14.0 client and schema (PostgreSQL)
|
||||
- **internal-packages/clickhouse**: ClickHouse client, schema migrations, analytics queries
|
||||
- **internal-packages/run-engine**: "Run Engine 2.0" - core run lifecycle management
|
||||
- **internal-packages/redis**: Redis client creation utilities (ioredis)
|
||||
- **internal-packages/testcontainers**: Test helpers for Redis/PostgreSQL containers
|
||||
- **internal-packages/schedule-engine**: Durable cron scheduling
|
||||
- **internal-packages/zodworker**: Graphile-worker wrapper (DEPRECATED - use redis-worker)
|
||||
|
||||
### Legacy V1 Engine Code
|
||||
|
||||
The `apps/webapp/app/v3/` directory name is misleading - most code there is actively used by V2. Only specific files are V1-only legacy (MarQS queue, triggerTaskV1, cancelTaskRunV1, etc.). See `apps/webapp/CLAUDE.md` for the exact list. When you encounter V1/V2 branching in services, only modify V2 code paths. All new work uses Run Engine 2.0 (`@internal/run-engine`) and redis-worker.
|
||||
|
||||
### Documentation
|
||||
|
||||
Docs live in `docs/` as a Mintlify site (MDX format). See `docs/CLAUDE.md` for conventions.
|
||||
|
||||
### Reference Projects
|
||||
|
||||
The `references/` directory contains test workspaces for developing and testing new SDK and platform features. Use these projects (e.g., `references/hello-world`) to manually test changes to the CLI, SDK, core packages, and webapp before submitting PRs.
|
||||
|
||||
## Webapp Development
|
||||
|
||||
### Key Locations
|
||||
|
||||
- Trigger API: `apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts`
|
||||
- Batch trigger: `apps/webapp/app/routes/api.v1.tasks.batch.ts`
|
||||
- Prisma setup: `apps/webapp/app/db.server.ts`
|
||||
- Run engine config: `apps/webapp/app/v3/runEngine.server.ts`
|
||||
- Services: `apps/webapp/app/v3/services/**/*.server.ts`
|
||||
- Presenters: `apps/webapp/app/v3/presenters/**/*.server.ts`
|
||||
- OTEL endpoints: `apps/webapp/app/routes/otel.v1.logs.ts`, `otel.v1.traces.ts`
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Access via `env` export from `apps/webapp/app/env.server.ts`, never `process.env` directly.
|
||||
|
||||
For testable code, **never import env.server.ts** in test files. Pass configuration as options instead. Example pattern:
|
||||
|
||||
- `realtimeClient.server.ts` (testable service)
|
||||
- `realtimeClientGlobal.server.ts` (configuration)
|
||||
|
||||
### Legacy vs Run Engine 2.0
|
||||
|
||||
The codebase is transitioning from the "legacy run engine" (spread across codebase) to "Run Engine 2.0" (`@internal/run-engine`). Focus on Run Engine 2.0 for new work.
|
||||
The `references/` directory contains test workspaces for testing SDK and platform features. Use `references/hello-world` to manually test changes before submitting PRs.
|
||||
|
||||
## Docker Image Guidelines
|
||||
|
||||
When updating Docker image references in `docker/Dockerfile` or other container files:
|
||||
When updating Docker image references:
|
||||
|
||||
- **Always use multiplatform/index digests**, not architecture-specific digests
|
||||
- Architecture-specific digests (e.g., for `linux/amd64` only) will cause CI failures on different build environments
|
||||
- On Docker Hub, the multiplatform digest is shown on the main image page, while architecture-specific digests are listed under "OS/ARCH"
|
||||
- Example: Use `node:20.20-bullseye-slim@sha256:abc123...` where the digest is from the multiplatform index, not from a specific OS/ARCH variant
|
||||
|
||||
## Database Migrations (PostgreSQL)
|
||||
|
||||
1. Edit `internal-packages/database/prisma/schema.prisma`
|
||||
2. Create migration:
|
||||
```bash
|
||||
cd internal-packages/database
|
||||
pnpm run db:migrate:dev:create --name "add_new_column"
|
||||
```
|
||||
3. **Important**: Generated migration includes extraneous changes. Remove lines related to:
|
||||
- `_BackgroundWorkerToBackgroundWorkerFile`
|
||||
- `_BackgroundWorkerToTaskQueue`
|
||||
- `_TaskRunToTaskRunTag`
|
||||
- `_WaitpointRunConnections`
|
||||
- `_completedWaitpoints`
|
||||
- `SecretStore_key_idx`
|
||||
- Various `TaskRun` indexes unless you added them
|
||||
4. Apply migration:
|
||||
```bash
|
||||
pnpm run db:migrate:deploy && pnpm run generate
|
||||
```
|
||||
|
||||
### Index Migration Rules
|
||||
|
||||
- Indexes **must use CONCURRENTLY** to avoid table locks
|
||||
- **CONCURRENTLY indexes must be in their own separate migration file** - they cannot be combined with other schema changes
|
||||
|
||||
## ClickHouse Migrations
|
||||
|
||||
ClickHouse migrations use Goose format and live in `internal-packages/clickhouse/schema/`.
|
||||
|
||||
1. Create a new numbered SQL file (e.g., `010_add_new_column.sql`)
|
||||
2. Use Goose markers:
|
||||
|
||||
```sql
|
||||
-- +goose Up
|
||||
ALTER TABLE trigger_dev.your_table
|
||||
ADD COLUMN new_column String DEFAULT '';
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE trigger_dev.your_table
|
||||
DROP COLUMN new_column;
|
||||
```
|
||||
|
||||
Follow naming conventions in `internal-packages/clickhouse/README.md`:
|
||||
|
||||
- `raw_` prefix for input tables
|
||||
- `_v1`, `_v2` suffixes for versioning
|
||||
- `_mv_v1` suffix for materialized views
|
||||
- Architecture-specific digests cause CI failures on different build environments
|
||||
- Use the digest from the main Docker Hub page, not from a specific OS/ARCH variant
|
||||
|
||||
## Writing Trigger.dev Tasks
|
||||
|
||||
Always import from `@trigger.dev/sdk`. Never use `@trigger.dev/sdk/v3` or deprecated `client.defineJob` pattern.
|
||||
Always import from `@trigger.dev/sdk`. Never use `@trigger.dev/sdk/v3` or deprecated `client.defineJob`.
|
||||
|
||||
```typescript
|
||||
import { task } from "@trigger.dev/sdk";
|
||||
|
||||
// Every task must be exported
|
||||
export const myTask = task({
|
||||
id: "my-task", // Unique ID
|
||||
id: "my-task",
|
||||
run: async (payload: { message: string }) => {
|
||||
// Task logic - no timeouts
|
||||
// Task logic
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### SDK Documentation Rules
|
||||
|
||||
The `rules/` directory contains versioned documentation for writing Trigger.dev tasks, distributed to users via the SDK installer. Current version is defined in `rules/manifest.json`.
|
||||
|
||||
- `rules/4.3.0/` - Latest: batch trigger v2 (1,000 items, 3MB payloads), debouncing
|
||||
- `rules/4.1.0/` - Realtime streams v2, updated config
|
||||
- `rules/4.0.0/` - Base v4 SDK documentation
|
||||
|
||||
When adding new SDK features, create a new version directory with only the files that changed from the previous version. Update `manifest.json` to point unchanged files to previous versions.
|
||||
|
||||
### Claude Code Skill
|
||||
|
||||
The `.claude/skills/trigger-dev-tasks/` skill provides Claude Code with Trigger.dev task expertise. It includes:
|
||||
|
||||
- `SKILL.md` - Core instructions and patterns
|
||||
- Reference files for basic tasks, advanced tasks, scheduled tasks, realtime, and config
|
||||
|
||||
Keep the skill in sync with the latest rules version when SDK features change.
|
||||
The `rules/` directory contains versioned SDK documentation distributed via the SDK installer. Current version: `rules/manifest.json`. Do NOT update `rules/` or `.claude/skills/trigger-dev-tasks/` unless explicitly asked - these are maintained in separate dedicated passes.
|
||||
|
||||
## Testing with hello-world Reference Project
|
||||
|
||||
First-time setup:
|
||||
|
||||
1. Run `pnpm run db:seed` to seed the database (creates the hello-world project)
|
||||
1. `pnpm run db:seed` to seed the database
|
||||
2. Build CLI: `pnpm run build --filter trigger.dev && pnpm i`
|
||||
3. Authorize CLI: `cd references/hello-world && pnpm exec trigger login -a http://localhost:3030`
|
||||
3. Authorize: `cd references/hello-world && pnpm exec trigger login -a http://localhost:3030`
|
||||
|
||||
Running:
|
||||
|
||||
```bash
|
||||
cd references/hello-world
|
||||
pnpm exec trigger dev # or with --log-level debug
|
||||
```
|
||||
Running: `cd references/hello-world && pnpm exec trigger dev`
|
||||
|
||||
## Local Task Testing Workflow
|
||||
|
||||
This workflow enables Claude Code to run the webapp and trigger dev simultaneously, trigger tasks, and inspect results for testing code changes.
|
||||
|
||||
### Step 1: Start Webapp in Background
|
||||
|
||||
```bash
|
||||
# Run from repo root with run_in_background: true
|
||||
pnpm run dev --filter webapp
|
||||
```
|
||||
|
||||
Verify webapp is running:
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:3030/healthcheck # Should return 200
|
||||
curl -s http://localhost:3030/healthcheck # Verify running
|
||||
```
|
||||
|
||||
### Step 2: Start Trigger Dev in Background
|
||||
|
||||
```bash
|
||||
# Run from hello-world directory with run_in_background: true
|
||||
cd references/hello-world && pnpm exec trigger dev
|
||||
# Wait for "Local worker ready [node]"
|
||||
```
|
||||
|
||||
The worker will build and register tasks. Check output for "Local worker ready [node]" message.
|
||||
|
||||
### Step 3: Trigger and Monitor Tasks via MCP
|
||||
|
||||
Use the Trigger.dev MCP tools to interact with tasks:
|
||||
|
||||
```
|
||||
# Get current worker and registered tasks
|
||||
mcp__trigger__get_current_worker(projectRef: "proj_rrkpdguyagvsoktglnod", environment: "dev")
|
||||
|
||||
# Trigger a task
|
||||
mcp__trigger__trigger_task(
|
||||
projectRef: "proj_rrkpdguyagvsoktglnod",
|
||||
environment: "dev",
|
||||
taskId: "hello-world",
|
||||
payload: {"message": "Hello from Claude"}
|
||||
)
|
||||
|
||||
# List runs to see status
|
||||
mcp__trigger__list_runs(
|
||||
projectRef: "proj_rrkpdguyagvsoktglnod",
|
||||
environment: "dev",
|
||||
taskIdentifier: "hello-world",
|
||||
limit: 5
|
||||
)
|
||||
mcp__trigger__trigger_task(projectRef: "proj_rrkpdguyagvsoktglnod", environment: "dev", taskId: "hello-world", payload: {"message": "Hello"})
|
||||
mcp__trigger__list_runs(projectRef: "proj_rrkpdguyagvsoktglnod", environment: "dev", taskIdentifier: "hello-world", limit: 5)
|
||||
```
|
||||
|
||||
### Step 4: Monitor Execution
|
||||
|
||||
- Check trigger dev output file for real-time execution logs
|
||||
- Successful runs show: `Task | Run ID | Success (Xms)`
|
||||
- Dashboard available at: http://localhost:3030/orgs/references-9dfd/projects/hello-world-97DT/env/dev/runs
|
||||
|
||||
### Key Project Refs
|
||||
|
||||
- hello-world: `proj_rrkpdguyagvsoktglnod`
|
||||
Dashboard: http://localhost:3030/orgs/references-9dfd/projects/hello-world-97DT/env/dev/runs
|
||||
|
||||
@@ -267,6 +267,39 @@ You will be prompted to select which packages to include in the changeset. Only
|
||||
|
||||
Most of the time the changes you'll make are likely to be categorized as patch releases. If you feel like there is the need for a minor or major release of the package based on the changes being made, add the changeset as such and it will be discussed during PR review.
|
||||
|
||||
## Adding server changes
|
||||
|
||||
Changesets only track published npm packages. If your PR only changes server components (`apps/webapp/`, `apps/supervisor/`, `apps/coordinator/`, etc.) with no package changes, add a `.server-changes/` file so the change appears in release notes.
|
||||
|
||||
Create a markdown file with a descriptive name:
|
||||
|
||||
```sh
|
||||
cat > .server-changes/fix-batch-queue-stalls.md << 'EOF'
|
||||
---
|
||||
area: webapp
|
||||
type: fix
|
||||
---
|
||||
|
||||
Speed up batch queue processing by removing stalls and fixing retry race
|
||||
EOF
|
||||
```
|
||||
|
||||
**Fields:**
|
||||
- `area` (required): `webapp` | `supervisor` | `coordinator` | `kubernetes-provider` | `docker-provider`
|
||||
- `type` (required): `feature` | `fix` | `improvement` | `breaking`
|
||||
|
||||
The body text (below the frontmatter) is a one-line description of the change. Keep it concise — it will appear in release notes.
|
||||
|
||||
**When to add which:**
|
||||
|
||||
| PR changes | What to add |
|
||||
|---|---|
|
||||
| Only packages (`packages/`) | Changeset |
|
||||
| Only server (`apps/`) | `.server-changes/` file |
|
||||
| Both packages and server | Just the changeset |
|
||||
|
||||
See `.server-changes/README.md` for more details.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### EADDRINUSE: address already in use :::3030
|
||||
|
||||
+23
@@ -1,5 +1,28 @@
|
||||
## Guide on releasing a new version
|
||||
|
||||
### Automated release (v4+)
|
||||
|
||||
Releases are fully automated via CI:
|
||||
|
||||
1. PRs merge to `main` with changesets (for package changes) and/or `.server-changes/` files (for server-only changes).
|
||||
2. The [changesets-pr.yml](./.github/workflows/changesets-pr.yml) workflow automatically creates/updates the `changeset-release/main` PR with version bumps and an enhanced summary of all changes. Consumed `.server-changes/` files are removed on the release branch (same approach changesets uses for `.changeset/` files — they're deleted on the branch, so merging the PR cleans them up).
|
||||
3. When ready to release, merge the changeset release PR into `main`.
|
||||
4. The [release.yml](./.github/workflows/release.yml) workflow automatically:
|
||||
- Publishes all packages to npm
|
||||
- Creates a single unified GitHub release (e.g., "trigger.dev v4.3.4")
|
||||
- Tags and triggers Docker image builds
|
||||
- After Docker images are pushed, updates the GitHub release with the exact GHCR tag link
|
||||
|
||||
### What engineers need to do
|
||||
|
||||
- **Package changes**: Add a changeset with `pnpm run changeset:add`
|
||||
- **Server-only changes**: Add a `.server-changes/` file (see `.server-changes/README.md`)
|
||||
- **Mixed PRs**: Just the changeset is enough
|
||||
|
||||
See `CHANGESETS.md` for full details on changesets and server changes.
|
||||
|
||||
### Legacy release (v3)
|
||||
|
||||
1. Merge in the changeset PR into main, making sure to cancel both the release and publish github actions from that merge.
|
||||
2. Pull the changes locally into main
|
||||
3. Run `pnpm i` which will update the pnpm lock file with the new versions
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# Supervisor
|
||||
|
||||
Node.js app that manages task execution containers. Receives work from the platform, starts Docker/Kubernetes containers, monitors execution, and reports results.
|
||||
|
||||
## Key Directories
|
||||
|
||||
- `src/services/` - Core service logic
|
||||
- `src/workloadManager/` - Container orchestration abstraction (Docker or Kubernetes)
|
||||
- `src/workloadServer/` - HTTP server for workload communication (heartbeats, snapshots)
|
||||
- `src/clients/` - Platform communication (webapp/coordinator)
|
||||
- `src/env.ts` - Environment configuration
|
||||
|
||||
## Architecture
|
||||
|
||||
- **WorkloadManager**: Abstracts Docker vs Kubernetes execution
|
||||
- **SupervisorSession**: Manages the dequeue loop with EWMA-based dynamic scaling
|
||||
- **ResourceMonitor**: Tracks CPU/memory during execution
|
||||
- **PodCleaner/FailedPodHandler**: Kubernetes-specific cleanup
|
||||
|
||||
Communicates with the platform via Socket.io and HTTP. Receives task assignments through the dequeue protocol from the webapp.
|
||||
@@ -0,0 +1,58 @@
|
||||
# Webapp
|
||||
|
||||
Remix 2.1.0 app serving as the main API, dashboard, and orchestration engine. Uses an Express server (`server.ts`).
|
||||
|
||||
## Key File Locations
|
||||
|
||||
- **Trigger API**: `app/routes/api.v1.tasks.$taskId.trigger.ts`
|
||||
- **Batch trigger**: `app/routes/api.v1.tasks.batch.ts`
|
||||
- **OTEL endpoints**: `app/routes/otel.v1.logs.ts`, `app/routes/otel.v1.traces.ts`
|
||||
- **Prisma setup**: `app/db.server.ts`
|
||||
- **Run engine config**: `app/v3/runEngine.server.ts`
|
||||
- **Services**: `app/v3/services/**/*.server.ts`
|
||||
- **Presenters**: `app/v3/presenters/**/*.server.ts`
|
||||
|
||||
## Route Convention
|
||||
|
||||
Routes use Remix flat-file convention with dot-separated segments:
|
||||
`api.v1.tasks.$taskId.trigger.ts` -> `/api/v1/tasks/:taskId/trigger`
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Access via `env` export from `app/env.server.ts`. **Never use `process.env` directly.**
|
||||
|
||||
For testable code, **never import env.server.ts** in test files. Pass configuration as options instead:
|
||||
- `realtimeClient.server.ts` (testable service, takes config as constructor arg)
|
||||
- `realtimeClientGlobal.server.ts` (creates singleton with env config)
|
||||
|
||||
## Run Engine 2.0
|
||||
|
||||
The webapp integrates `@internal/run-engine` via `app/v3/runEngine.server.ts`. This is the singleton engine instance. Services in `app/v3/services/` call engine methods for all run lifecycle operations (triggering, completing, cancelling, etc.).
|
||||
|
||||
The `engineVersion.server.ts` file determines V1 vs V2 for a given environment. New code should always target V2.
|
||||
|
||||
## Background Workers
|
||||
|
||||
Background job workers use `@trigger.dev/redis-worker`:
|
||||
- `app/v3/commonWorker.server.ts`
|
||||
- `app/v3/alertsWorker.server.ts`
|
||||
- `app/v3/batchTriggerWorker.server.ts`
|
||||
|
||||
Do NOT add new jobs using zodworker/graphile-worker (legacy).
|
||||
|
||||
## Real-time
|
||||
|
||||
- Socket.io: `app/v3/handleSocketIo.server.ts`, `app/v3/handleWebsockets.server.ts`
|
||||
- Electric SQL: Powers real-time data sync for the dashboard
|
||||
|
||||
## Legacy V1 Code
|
||||
|
||||
The `app/v3/` directory name is misleading - most code is actively used by V2. Only these specific files are V1-only legacy:
|
||||
- `app/v3/marqs/` (old MarQS queue system)
|
||||
- `app/v3/legacyRunEngineWorker.server.ts`
|
||||
- `app/v3/services/triggerTaskV1.server.ts`
|
||||
- `app/v3/services/cancelTaskRunV1.server.ts`
|
||||
- `app/v3/authenticatedSocketConnection.server.ts`
|
||||
- `app/v3/sharedSocketConnection.ts`
|
||||
|
||||
Some services (e.g., `cancelTaskRun.server.ts`, `batchTriggerV3.server.ts`) branch on `RunEngineVersion` to support both V1 and V2. When editing these, only modify V2 code paths.
|
||||
@@ -889,13 +889,15 @@ export const QueryResultsChart = memo(function QueryResultsChart({
|
||||
const cfg: ChartConfig = {};
|
||||
sortedSeries.forEach((s, i) => {
|
||||
const statusColor = groupByIsRunStatus ? getRunStatusHexColor(s) : undefined;
|
||||
const originalIndex = config.yAxisColumns.indexOf(s);
|
||||
const colorIndex = originalIndex >= 0 ? originalIndex : i;
|
||||
cfg[s] = {
|
||||
label: s,
|
||||
color: statusColor ?? config.seriesColors?.[s] ?? getSeriesColor(i),
|
||||
color: statusColor ?? config.seriesColors?.[s] ?? getSeriesColor(colorIndex),
|
||||
};
|
||||
});
|
||||
return cfg;
|
||||
}, [sortedSeries, groupByIsRunStatus, config.seriesColors]);
|
||||
}, [sortedSeries, groupByIsRunStatus, config.seriesColors, config.yAxisColumns]);
|
||||
|
||||
// Custom tooltip label formatter for better date display
|
||||
const tooltipLabelFormatter = useMemo(() => {
|
||||
|
||||
@@ -133,6 +133,7 @@ function getFormattedValue(value: unknown, column: OutputColumnMetadata): string
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
timeZone: "UTC",
|
||||
});
|
||||
} catch {
|
||||
return String(value);
|
||||
@@ -667,7 +668,7 @@ function CellValue({
|
||||
|
||||
if (isDateTimeType(type)) {
|
||||
if (typeof value === "string") {
|
||||
return <DateTimeAccurate date={value} showTooltip={hovered} />;
|
||||
return <DateTimeAccurate date={value} showTooltip={hovered} timeZone="UTC" />;
|
||||
}
|
||||
return <span>{String(value)}</span>;
|
||||
}
|
||||
|
||||
@@ -34,31 +34,31 @@ export function BuildSettingsFields({
|
||||
<>
|
||||
{/* Pull env vars before build */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div>
|
||||
<div className="mb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Pull env vars before build</Label>
|
||||
<Hint>
|
||||
Select which environments should pull environment variables from Vercel before each
|
||||
build.{" "}
|
||||
{envVarsConfigLink && (
|
||||
<>
|
||||
<TextLink to={envVarsConfigLink}>Configure which variables to pull</TextLink>.
|
||||
</>
|
||||
)}
|
||||
</Hint>
|
||||
{availableEnvSlugs.length > 1 && (
|
||||
<Switch
|
||||
variant="small"
|
||||
checked={
|
||||
availableEnvSlugs.length > 0 &&
|
||||
availableEnvSlugs.every((s) => pullEnvVarsBeforeBuild.includes(s))
|
||||
}
|
||||
onCheckedChange={(checked) => {
|
||||
onPullEnvVarsChange(checked ? [...availableEnvSlugs] : []);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{availableEnvSlugs.length > 1 && (
|
||||
<Switch
|
||||
variant="small"
|
||||
checked={
|
||||
availableEnvSlugs.length > 0 &&
|
||||
availableEnvSlugs.every((s) => pullEnvVarsBeforeBuild.includes(s))
|
||||
}
|
||||
onCheckedChange={(checked) => {
|
||||
onPullEnvVarsChange(checked ? [...availableEnvSlugs] : []);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Hint className="pr-6">
|
||||
Select which environments should pull environment variables from Vercel before each
|
||||
build.{" "}
|
||||
{envVarsConfigLink && (
|
||||
<>
|
||||
<TextLink to={envVarsConfigLink}>Configure which variables to pull</TextLink>.
|
||||
</>
|
||||
)}
|
||||
</Hint>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 rounded border bg-charcoal-800 p-3">
|
||||
{availableEnvSlugs.map((slug) => {
|
||||
@@ -90,34 +90,34 @@ export function BuildSettingsFields({
|
||||
|
||||
{/* Discover new env vars */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div>
|
||||
<div className="mb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Discover new env vars</Label>
|
||||
<Hint>
|
||||
Select which environments should automatically discover and create new environment
|
||||
variables from Vercel during builds.
|
||||
</Hint>
|
||||
{availableEnvSlugs.length > 1 && (
|
||||
<Switch
|
||||
variant="small"
|
||||
checked={
|
||||
availableEnvSlugs.length > 0 &&
|
||||
availableEnvSlugs.every(
|
||||
(s) => discoverEnvVars.includes(s) || !pullEnvVarsBeforeBuild.includes(s)
|
||||
) &&
|
||||
availableEnvSlugs.some((s) => discoverEnvVars.includes(s))
|
||||
}
|
||||
disabled={!availableEnvSlugs.some((s) => pullEnvVarsBeforeBuild.includes(s))}
|
||||
onCheckedChange={(checked) => {
|
||||
onDiscoverEnvVarsChange(
|
||||
checked
|
||||
? availableEnvSlugs.filter((s) => pullEnvVarsBeforeBuild.includes(s))
|
||||
: []
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{availableEnvSlugs.length > 1 && (
|
||||
<Switch
|
||||
variant="small"
|
||||
checked={
|
||||
availableEnvSlugs.length > 0 &&
|
||||
availableEnvSlugs.every(
|
||||
(s) => discoverEnvVars.includes(s) || !pullEnvVarsBeforeBuild.includes(s)
|
||||
) &&
|
||||
availableEnvSlugs.some((s) => discoverEnvVars.includes(s))
|
||||
}
|
||||
disabled={!availableEnvSlugs.some((s) => pullEnvVarsBeforeBuild.includes(s))}
|
||||
onCheckedChange={(checked) => {
|
||||
onDiscoverEnvVarsChange(
|
||||
checked
|
||||
? availableEnvSlugs.filter((s) => pullEnvVarsBeforeBuild.includes(s))
|
||||
: []
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Hint className="pr-6">
|
||||
Select which environments should automatically discover and create new environment
|
||||
variables from Vercel during builds.
|
||||
</Hint>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 rounded border bg-charcoal-800 p-3">
|
||||
{availableEnvSlugs.map((slug) => {
|
||||
@@ -155,13 +155,7 @@ export function BuildSettingsFields({
|
||||
{/* Atomic deployments */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Atomic deployments</Label>
|
||||
<Hint>
|
||||
When enabled, production deployments wait for Vercel deployment to complete before
|
||||
promoting the Trigger.dev deployment.
|
||||
</Hint>
|
||||
</div>
|
||||
<Label>Atomic deployments</Label>
|
||||
<Switch
|
||||
variant="small"
|
||||
checked={atomicBuilds.includes("prod")}
|
||||
@@ -170,6 +164,16 @@ export function BuildSettingsFields({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Hint className="pr-6">
|
||||
When enabled, production deployments wait for Vercel deployment to complete before
|
||||
promoting the Trigger.dev deployment. This will disable the "Auto-assign Custom
|
||||
Production Domains" option in your Vercel project settings to perform staged
|
||||
deployments.{" "}
|
||||
<TextLink href="https://trigger.dev/docs/vercel-integration#atomic-deployments" target="_blank">
|
||||
Learn more
|
||||
</TextLink>
|
||||
.
|
||||
</Hint>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { VercelLogo } from "./VercelLogo";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
|
||||
export function VercelLink({ vercelDeploymentUrl }: { vercelDeploymentUrl: string }) {
|
||||
return (
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<LinkButton
|
||||
variant="minimal/small"
|
||||
LeadingIcon={<VercelLogo className="size-3.5" />}
|
||||
iconSpacing="gap-x-1"
|
||||
to={vercelDeploymentUrl}
|
||||
className="pl-1"
|
||||
>
|
||||
Vercel
|
||||
</LinkButton>
|
||||
}
|
||||
content="View on Vercel"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -44,7 +44,7 @@ import {
|
||||
} from "~/v3/vercel/vercelProjectIntegrationSchema";
|
||||
import { type VercelCustomEnvironment } from "~/models/vercelIntegration.server";
|
||||
import { type VercelOnboardingData } from "~/presenters/v3/VercelSettingsPresenter.server";
|
||||
import { vercelAppInstallPath, v3ProjectSettingsPath, githubAppInstallPath, vercelResourcePath } from "~/utils/pathBuilder";
|
||||
import { vercelAppInstallPath, v3ProjectSettingsIntegrationsPath, githubAppInstallPath, vercelResourcePath } from "~/utils/pathBuilder";
|
||||
import type { loader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel";
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { usePostHogTracking } from "~/hooks/usePostHog";
|
||||
@@ -102,6 +102,7 @@ export function VercelOnboardingModal({
|
||||
hasOrgIntegration,
|
||||
nextUrl,
|
||||
onDataReload,
|
||||
vercelManageAccessUrl,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
@@ -114,6 +115,7 @@ export function VercelOnboardingModal({
|
||||
hasOrgIntegration: boolean;
|
||||
nextUrl?: string;
|
||||
onDataReload?: (vercelStagingEnvironment?: string) => void;
|
||||
vercelManageAccessUrl?: string;
|
||||
}) {
|
||||
const { capture, startSessionRecording } = usePostHogTracking();
|
||||
const navigation = useNavigation();
|
||||
@@ -122,7 +124,8 @@ export function VercelOnboardingModal({
|
||||
const completeOnboardingFetcher = useFetcher();
|
||||
const { Form: CompleteOnboardingForm } = completeOnboardingFetcher;
|
||||
const [searchParams] = useSearchParams();
|
||||
const fromMarketplaceContext = searchParams.get("origin") === "marketplace";
|
||||
const origin = searchParams.get("origin");
|
||||
const fromMarketplaceContext = origin === "marketplace";
|
||||
|
||||
const availableProjects = onboardingData?.availableProjects || [];
|
||||
const hasProjectSelected = onboardingData?.hasProjectSelected ?? false;
|
||||
@@ -543,8 +546,15 @@ export function VercelOnboardingModal({
|
||||
|
||||
if (!isGitHubConnectedForOnboarding) {
|
||||
setState("github-connection");
|
||||
capture("vercel onboarding github step viewed", {
|
||||
origin: fromMarketplaceContext ? "marketplace" : "dashboard",
|
||||
step: "github-connection",
|
||||
organization_slug: organizationSlug,
|
||||
project_slug: projectSlug,
|
||||
github_app_installed: gitHubAppInstallations.length > 0,
|
||||
});
|
||||
}
|
||||
}, [vercelStagingEnvironment, pullEnvVarsBeforeBuild, atomicBuilds, discoverEnvVars, syncEnvVarsMapping, nextUrl, fromMarketplaceContext, isGitHubConnectedForOnboarding, completeOnboardingFetcher, actionUrl, trackOnboarding]);
|
||||
}, [vercelStagingEnvironment, pullEnvVarsBeforeBuild, atomicBuilds, discoverEnvVars, syncEnvVarsMapping, nextUrl, fromMarketplaceContext, isGitHubConnectedForOnboarding, completeOnboardingFetcher, actionUrl, trackOnboarding, capture, organizationSlug, projectSlug, gitHubAppInstallations.length]);
|
||||
|
||||
const handleFinishOnboarding = useCallback((e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
@@ -639,7 +649,7 @@ export function VercelOnboardingModal({
|
||||
onClose();
|
||||
}
|
||||
}}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogContent className="max-w-lg" onInteractOutside={(e) => e.preventDefault()}>
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<VercelLogo className="size-5" />
|
||||
@@ -669,7 +679,7 @@ export function VercelOnboardingModal({
|
||||
onClose();
|
||||
}
|
||||
}}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogContent className="max-w-lg" onInteractOutside={(e) => e.preventDefault()}>
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<VercelLogo className="size-5" />
|
||||
@@ -727,14 +737,25 @@ export function VercelOnboardingModal({
|
||||
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button
|
||||
variant="primary/medium"
|
||||
onClick={handleProjectSelection}
|
||||
disabled={!selectedVercelProject || fetcher.state !== "idle"}
|
||||
LeadingIcon={fetcher.state !== "idle" ? SpinnerWhite : undefined}
|
||||
>
|
||||
{fetcher.state !== "idle" ? "Connecting..." : "Connect Project"}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{vercelManageAccessUrl && !origin && (
|
||||
<LinkButton
|
||||
to={vercelManageAccessUrl}
|
||||
variant="tertiary/medium"
|
||||
target="_self"
|
||||
>
|
||||
Manage access
|
||||
</LinkButton>
|
||||
)}
|
||||
<Button
|
||||
variant="primary/medium"
|
||||
onClick={handleProjectSelection}
|
||||
disabled={!selectedVercelProject || fetcher.state !== "idle"}
|
||||
LeadingIcon={fetcher.state !== "idle" ? SpinnerWhite : undefined}
|
||||
>
|
||||
{fetcher.state !== "idle" ? "Connecting..." : "Connect Project"}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
cancelButton={
|
||||
<Button
|
||||
@@ -779,6 +800,20 @@ export function VercelOnboardingModal({
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<Callout variant="info">
|
||||
<p className="text-xs">
|
||||
If you skip this step, the{" "}
|
||||
<code className="rounded bg-charcoal-700 px-1 py-0.5 text-text-bright">TRIGGER_SECRET_KEY</code>{" "}
|
||||
will not be installed for the staging environment in Vercel. You can configure this later in
|
||||
project settings.
|
||||
</p>
|
||||
</Callout>
|
||||
|
||||
<Paragraph className="text-xs text-text-dimmed">
|
||||
Make sure the staging branch in your Vercel project's Git settings matches the staging branch
|
||||
configured in your GitHub integration.
|
||||
</Paragraph>
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Button
|
||||
variant="tertiary/medium"
|
||||
@@ -813,6 +848,7 @@ export function VercelOnboardingModal({
|
||||
<Header3>Pull Environment Variables</Header3>
|
||||
<Paragraph className="text-sm">
|
||||
Select which environment variables to pull from Vercel now. This is a one-time pull.
|
||||
Later on environment variables can be pulled before each build.
|
||||
</Paragraph>
|
||||
|
||||
<div className="flex gap-4 text-sm">
|
||||
@@ -1057,7 +1093,7 @@ export function VercelOnboardingModal({
|
||||
</Callout>
|
||||
|
||||
{(() => {
|
||||
const baseSettingsPath = v3ProjectSettingsPath(
|
||||
const baseSettingsPath = v3ProjectSettingsIntegrationsPath(
|
||||
{ slug: organizationSlug },
|
||||
{ slug: projectSlug },
|
||||
{ slug: environmentSlug }
|
||||
@@ -1081,6 +1117,7 @@ export function VercelOnboardingModal({
|
||||
)}
|
||||
variant="secondary/medium"
|
||||
LeadingIcon={OctoKitty}
|
||||
onClick={() => trackOnboarding("vercel onboarding github app install clicked")}
|
||||
>
|
||||
Install GitHub app
|
||||
</LinkButton>
|
||||
@@ -1110,6 +1147,7 @@ export function VercelOnboardingModal({
|
||||
<Button
|
||||
variant="primary/medium"
|
||||
onClick={() => {
|
||||
trackOnboarding("vercel onboarding github completed");
|
||||
setState("completed");
|
||||
const validUrl = safeRedirectUrl(nextUrl);
|
||||
if (validUrl) {
|
||||
@@ -1123,6 +1161,7 @@ export function VercelOnboardingModal({
|
||||
<Button
|
||||
variant="tertiary/medium"
|
||||
onClick={() => {
|
||||
trackOnboarding("vercel onboarding github skipped");
|
||||
setState("completed");
|
||||
if (fromMarketplaceContext && nextUrl) {
|
||||
const validUrl = safeRedirectUrl(nextUrl);
|
||||
@@ -1141,6 +1180,7 @@ export function VercelOnboardingModal({
|
||||
<Button
|
||||
variant="tertiary/medium"
|
||||
onClick={() => {
|
||||
trackOnboarding("vercel onboarding github skipped");
|
||||
setState("completed");
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -186,7 +186,7 @@ function DetailsTab({
|
||||
<CopyableText value={log.runId} copyValue={log.runId} asChild />
|
||||
<LinkButton
|
||||
to={runPath}
|
||||
variant="tertiary/small"
|
||||
variant="secondary/small"
|
||||
shortcut={{ key: "v" }}
|
||||
className="mt-2"
|
||||
>
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
TableRow,
|
||||
type TableVariant,
|
||||
} from "../primitives/Table";
|
||||
import { RunsIcon } from "~/assets/icons/RunsIcon";
|
||||
|
||||
type LogsTableProps = {
|
||||
logs: LogEntry[];
|
||||
@@ -124,6 +125,7 @@ export function LogsTable({
|
||||
<TableHeaderCell
|
||||
className="min-w-24 whitespace-nowrap"
|
||||
tooltip={<LogLevelTooltipInfo />}
|
||||
disableTooltipHoverableContent
|
||||
>
|
||||
Level
|
||||
</TableHeaderCell>
|
||||
@@ -165,7 +167,7 @@ export function LogsTable({
|
||||
>
|
||||
<DateTimeAccurate date={log.triggeredTimestamp} hour12={false} />
|
||||
</TableCell>
|
||||
<TableCell className="min-w-24">
|
||||
<TableCell className="min-w-24" onClick={handleRowClick} hasAction>
|
||||
<TruncatedCopyableValue value={log.runId} />
|
||||
</TableCell>
|
||||
<TableCell className="min-w-32" onClick={handleRowClick} hasAction>
|
||||
@@ -185,9 +187,11 @@ export function LogsTable({
|
||||
<LinkButton
|
||||
to={runPath}
|
||||
variant="minimal/small"
|
||||
TrailingIcon={ArrowTopRightOnSquareIcon}
|
||||
TrailingIcon={RunsIcon}
|
||||
trailingIconClassName="text-text-bright"
|
||||
className="h-[1.375rem] pl-1.5 pr-2"
|
||||
>
|
||||
View run
|
||||
<span className="text-[0.6875rem] text-text-bright">View run</span>
|
||||
</LinkButton>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -26,6 +26,46 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../pri
|
||||
import { v3BillingPath } from "~/utils/pathBuilder";
|
||||
import { type SideMenuEnvironment, type SideMenuProject } from "./SideMenu";
|
||||
|
||||
function useCreateDashboard({
|
||||
organization,
|
||||
project,
|
||||
environment,
|
||||
}: {
|
||||
organization: { slug: string };
|
||||
project: { slug: string };
|
||||
environment: { slug: string };
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const navigation = useNavigation();
|
||||
const limits = useDashboardLimits();
|
||||
const plan = useCurrentPlan();
|
||||
|
||||
const isAtLimit = limits.used >= limits.limit;
|
||||
const planLimits = (plan?.v3Subscription?.plan?.limits as any)?.metricDashboards;
|
||||
const canExceed = typeof planLimits === "object" && planLimits.canExceed === true;
|
||||
const canUpgrade = plan?.v3Subscription?.plan && !canExceed;
|
||||
const isFreePlan = plan?.v3Subscription?.isPaying === false;
|
||||
|
||||
const formAction = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dashboards/create`;
|
||||
|
||||
useEffect(() => {
|
||||
if (navigation.formAction === formAction && navigation.state === "loading") {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}, [navigation.formAction, navigation.state, formAction]);
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
isAtLimit,
|
||||
canUpgrade: !!canUpgrade,
|
||||
isFreePlan,
|
||||
formAction,
|
||||
limits,
|
||||
organization,
|
||||
};
|
||||
}
|
||||
|
||||
export function CreateDashboardButton({
|
||||
organization,
|
||||
project,
|
||||
@@ -37,29 +77,12 @@ export function CreateDashboardButton({
|
||||
environment: SideMenuEnvironment;
|
||||
isCollapsed: boolean;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const navigation = useNavigation();
|
||||
const limits = useDashboardLimits();
|
||||
const plan = useCurrentPlan();
|
||||
|
||||
const isAtLimit = limits.used >= limits.limit;
|
||||
const planLimits = (plan?.v3Subscription?.plan?.limits as any)?.metricDashboards;
|
||||
const canExceed = typeof planLimits === "object" && planLimits.canExceed === true;
|
||||
const canUpgrade = plan?.v3Subscription?.plan && !canExceed;
|
||||
|
||||
const formAction = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dashboards/create`;
|
||||
|
||||
// Close dialog when form submission starts (redirect is happening)
|
||||
useEffect(() => {
|
||||
if (navigation.formAction === formAction && navigation.state === "loading") {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}, [navigation.formAction, navigation.state, formAction]);
|
||||
const dashboard = useCreateDashboard({ organization, project, environment });
|
||||
|
||||
if (isCollapsed) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<Dialog open={dashboard.isOpen} onOpenChange={dashboard.setIsOpen}>
|
||||
<TooltipProvider disableHoverableContent>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -77,15 +100,47 @@ export function CreateDashboardButton({
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
{isAtLimit ? (
|
||||
{dashboard.isAtLimit ? (
|
||||
<CreateDashboardUpgradeDialog
|
||||
limits={limits}
|
||||
canUpgrade={!!canUpgrade}
|
||||
isFreePlan={plan?.v3Subscription?.isPaying === false}
|
||||
organization={organization}
|
||||
limits={dashboard.limits}
|
||||
canUpgrade={dashboard.canUpgrade}
|
||||
isFreePlan={dashboard.isFreePlan}
|
||||
organization={dashboard.organization}
|
||||
/>
|
||||
) : (
|
||||
<CreateDashboardDialog formAction={formAction} limits={limits} />
|
||||
<CreateDashboardDialog formAction={dashboard.formAction} limits={dashboard.limits} />
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function CreateDashboardPageButton({
|
||||
organization,
|
||||
project,
|
||||
environment,
|
||||
}: {
|
||||
organization: { slug: string };
|
||||
project: { slug: string };
|
||||
environment: { slug: string };
|
||||
}) {
|
||||
const dashboard = useCreateDashboard({ organization, project, environment });
|
||||
|
||||
return (
|
||||
<Dialog open={dashboard.isOpen} onOpenChange={dashboard.setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="primary/small" LeadingIcon={PlusIcon}>
|
||||
Create custom dashboard
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
{dashboard.isAtLimit ? (
|
||||
<CreateDashboardUpgradeDialog
|
||||
limits={dashboard.limits}
|
||||
canUpgrade={dashboard.canUpgrade}
|
||||
isFreePlan={dashboard.isFreePlan}
|
||||
organization={dashboard.organization}
|
||||
/>
|
||||
) : (
|
||||
<CreateDashboardDialog formAction={dashboard.formAction} limits={dashboard.limits} />
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
@@ -105,7 +160,7 @@ function CreateDashboardUpgradeDialog({
|
||||
limits: { used: number; limit: number };
|
||||
canUpgrade: boolean;
|
||||
isFreePlan: boolean;
|
||||
organization: MatchedOrganization;
|
||||
organization: { slug: string };
|
||||
}) {
|
||||
|
||||
if (isFreePlan) {
|
||||
|
||||
@@ -3,15 +3,17 @@ import {
|
||||
ChartBarIcon,
|
||||
Cog8ToothIcon,
|
||||
CreditCardIcon,
|
||||
PuzzlePieceIcon,
|
||||
UserGroupIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { ArrowLeftIcon } from "@heroicons/react/24/solid";
|
||||
import { SlackIcon } from "@trigger.dev/companyicons";
|
||||
import { VercelLogo } from "~/components/integrations/VercelLogo";
|
||||
import { useFeatures } from "~/hooks/useFeatures";
|
||||
import { type MatchedOrganization } from "~/hooks/useOrganizations";
|
||||
import { cn } from "~/utils/cn";
|
||||
import {
|
||||
organizationSettingsPath,
|
||||
organizationSlackIntegrationPath,
|
||||
organizationTeamPath,
|
||||
organizationVercelIntegrationPath,
|
||||
rootPath,
|
||||
@@ -115,13 +117,25 @@ export function OrganizationSettingsSideMenu({
|
||||
to={organizationSettingsPath(organization)}
|
||||
data-action="settings"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="mb-1">
|
||||
<SideMenuHeader title="Integrations" />
|
||||
</div>
|
||||
<SideMenuItem
|
||||
name="Integrations"
|
||||
icon={PuzzlePieceIcon}
|
||||
activeIconColor="text-blue-500"
|
||||
name="Vercel"
|
||||
icon={VercelLogo}
|
||||
activeIconColor="text-white"
|
||||
to={organizationVercelIntegrationPath(organization)}
|
||||
data-action="integrations"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Slack"
|
||||
icon={SlackIcon}
|
||||
activeIconColor="text-white"
|
||||
to={organizationSlackIntegrationPath(organization)}
|
||||
data-action="integrations"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<SideMenuHeader title="App version" />
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Cog8ToothIcon,
|
||||
CogIcon,
|
||||
ExclamationTriangleIcon,
|
||||
PuzzlePieceIcon,
|
||||
FolderIcon,
|
||||
FolderOpenIcon,
|
||||
GlobeAmericasIcon,
|
||||
@@ -74,7 +75,8 @@ import {
|
||||
v3LogsPath,
|
||||
v3ProjectAlertsPath,
|
||||
v3ProjectPath,
|
||||
v3ProjectSettingsPath,
|
||||
v3ProjectSettingsGeneralPath,
|
||||
v3ProjectSettingsIntegrationsPath,
|
||||
v3QueuesPath,
|
||||
v3RunsPath,
|
||||
v3SchedulesPath,
|
||||
@@ -127,7 +129,7 @@ type SideMenuUser = Pick<
|
||||
};
|
||||
export type SideMenuProject = Pick<
|
||||
MatchedProject,
|
||||
"id" | "name" | "slug" | "version" | "environments" | "engine"
|
||||
"id" | "name" | "slug" | "version" | "environments" | "engine" | "createdAt"
|
||||
>;
|
||||
export type SideMenuEnvironment = MatchedEnvironment;
|
||||
|
||||
@@ -451,7 +453,7 @@ export function SideMenu({
|
||||
|
||||
{(user.admin || user.isImpersonating || featureFlags.hasQueryAccess) && (
|
||||
<SideMenuSection
|
||||
title="Insights"
|
||||
title="Observability"
|
||||
isSideMenuCollapsed={isCollapsed}
|
||||
itemSpacingClassName="space-y-0"
|
||||
initialCollapsed={getSectionCollapsed(
|
||||
@@ -482,7 +484,7 @@ export function SideMenu({
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Metrics"
|
||||
name="Dashboards"
|
||||
icon={ChartBarIcon}
|
||||
activeIconColor="text-metrics"
|
||||
inactiveIconColor="text-metrics"
|
||||
@@ -589,13 +591,34 @@ export function SideMenu({
|
||||
data-action="limits"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
</SideMenuSection>
|
||||
|
||||
<SideMenuSection
|
||||
title="Project settings"
|
||||
isSideMenuCollapsed={isCollapsed}
|
||||
itemSpacingClassName="space-y-0"
|
||||
initialCollapsed={getSectionCollapsed(
|
||||
user.dashboardPreferences.sideMenu,
|
||||
"project-settings"
|
||||
)}
|
||||
onCollapseToggle={handleSectionToggle("project-settings")}
|
||||
>
|
||||
<SideMenuItem
|
||||
name="Project settings"
|
||||
name="General"
|
||||
icon={Cog8ToothIcon}
|
||||
activeIconColor="text-text-bright"
|
||||
inactiveIconColor="text-text-dimmed"
|
||||
to={v3ProjectSettingsPath(organization, project, environment)}
|
||||
data-action="project-settings"
|
||||
to={v3ProjectSettingsGeneralPath(organization, project, environment)}
|
||||
data-action="project-settings-general"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Integrations"
|
||||
icon={PuzzlePieceIcon}
|
||||
activeIconColor="text-text-bright"
|
||||
inactiveIconColor="text-text-dimmed"
|
||||
to={v3ProjectSettingsIntegrationsPath(organization, project, environment)}
|
||||
data-action="project-settings-integrations"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
</SideMenuSection>
|
||||
@@ -611,6 +634,7 @@ export function SideMenu({
|
||||
<V3DeprecationPanel
|
||||
isCollapsed={isCollapsed}
|
||||
isV3={isV3Project}
|
||||
projectCreatedAt={project.createdAt}
|
||||
hasIncident={incidentStatus.hasIncident}
|
||||
isManagedCloud={incidentStatus.isManagedCloud}
|
||||
/>
|
||||
@@ -641,15 +665,21 @@ export function SideMenu({
|
||||
function V3DeprecationPanel({
|
||||
isCollapsed,
|
||||
isV3,
|
||||
projectCreatedAt,
|
||||
hasIncident,
|
||||
isManagedCloud,
|
||||
}: {
|
||||
isCollapsed: boolean;
|
||||
isV3: boolean;
|
||||
projectCreatedAt: Date;
|
||||
hasIncident: boolean;
|
||||
isManagedCloud: boolean;
|
||||
}) {
|
||||
if (!isManagedCloud || !isV3 || hasIncident) {
|
||||
// Only show for projects created before v4 was released
|
||||
const V4_RELEASE_DATE = new Date("2025-09-01");
|
||||
const isLikelyV3 = isV3 && new Date(projectCreatedAt) < V4_RELEASE_DATE;
|
||||
|
||||
if (!isManagedCloud || !isLikelyV3 || hasIncident) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
// Valid section IDs that can have their collapsed state toggled
|
||||
export const SideMenuSectionIdSchema = z.enum(["manage", "metrics"]);
|
||||
export const SideMenuSectionIdSchema = z.enum(["manage", "metrics", "project-settings"]);
|
||||
|
||||
// Inferred type from the schema
|
||||
export type SideMenuSectionId = z.infer<typeof SideMenuSectionIdSchema>;
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
import * as Ariakit from "@ariakit/react";
|
||||
import {
|
||||
XMarkIcon,
|
||||
PlusIcon,
|
||||
CubeIcon,
|
||||
MagnifyingGlassIcon,
|
||||
ChevronDownIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { CheckboxIndicator } from "~/components/primitives/CheckboxIndicator";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { matchSorter } from "match-sorter";
|
||||
import { ShortcutKey } from "~/components/primitives/ShortcutKey";
|
||||
|
||||
const pillColors = [
|
||||
"bg-green-800/40 border-green-600/50",
|
||||
"bg-teal-800/40 border-teal-600/50",
|
||||
"bg-blue-800/40 border-blue-600/50",
|
||||
"bg-indigo-800/40 border-indigo-600/50",
|
||||
"bg-violet-800/40 border-violet-600/50",
|
||||
"bg-purple-800/40 border-purple-600/50",
|
||||
"bg-fuchsia-800/40 border-fuchsia-600/50",
|
||||
"bg-pink-800/40 border-pink-600/50",
|
||||
"bg-rose-800/40 border-rose-600/50",
|
||||
"bg-orange-800/40 border-orange-600/50",
|
||||
"bg-amber-800/40 border-amber-600/50",
|
||||
"bg-yellow-800/40 border-yellow-600/50",
|
||||
"bg-lime-800/40 border-lime-600/50",
|
||||
"bg-emerald-800/40 border-emerald-600/50",
|
||||
"bg-cyan-800/40 border-cyan-600/50",
|
||||
"bg-sky-800/40 border-sky-600/50",
|
||||
];
|
||||
|
||||
function getPillColor(value: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
hash = (hash << 5) - hash + value.charCodeAt(i);
|
||||
hash |= 0;
|
||||
}
|
||||
return pillColors[Math.abs(hash) % pillColors.length];
|
||||
}
|
||||
|
||||
export const TECHNOLOGY_OPTIONS = [
|
||||
"Airflow",
|
||||
"Angular",
|
||||
"Anthropic",
|
||||
"Astro",
|
||||
"Auth0",
|
||||
"AWS",
|
||||
"AWS SQS",
|
||||
"Azure",
|
||||
"BigQuery",
|
||||
"BullMQ",
|
||||
"Bun",
|
||||
"Cassandra",
|
||||
"Celery",
|
||||
"ClickHouse",
|
||||
"Clerk",
|
||||
"Cloudflare",
|
||||
"CockroachDB",
|
||||
"Cohere",
|
||||
"Convex",
|
||||
"Databricks",
|
||||
"Datadog",
|
||||
"DeepSeek",
|
||||
"Deno",
|
||||
"DigitalOcean",
|
||||
"Django",
|
||||
"Docker",
|
||||
"Drizzle",
|
||||
"DynamoDB",
|
||||
"Elasticsearch",
|
||||
"Electron",
|
||||
"Elevenlabs",
|
||||
"Expo",
|
||||
"Express",
|
||||
"FastAPI",
|
||||
"Fastify",
|
||||
"Firebase",
|
||||
"Flask",
|
||||
"Fly.io",
|
||||
"Gatsby",
|
||||
"GCP",
|
||||
"Go",
|
||||
"Google Cloud Tasks",
|
||||
"Google Gemini",
|
||||
"GraphQL",
|
||||
"Groq",
|
||||
"Heroku",
|
||||
"Hono",
|
||||
"htmx",
|
||||
"Hugging Face",
|
||||
"Inngest",
|
||||
"Kafka",
|
||||
"Kubernetes",
|
||||
"LangChain",
|
||||
"Laravel",
|
||||
"LlamaIndex",
|
||||
"MariaDB",
|
||||
"Midjourney",
|
||||
"Mistral",
|
||||
"MongoDB",
|
||||
"Mongoose",
|
||||
"MySQL",
|
||||
"Neo4j",
|
||||
"Neon",
|
||||
"Nest.js",
|
||||
"Netlify",
|
||||
"Next.js",
|
||||
"Node.js",
|
||||
"Nuxt",
|
||||
"Ollama",
|
||||
"OpenAI",
|
||||
"Perplexity",
|
||||
"PHP",
|
||||
"Pinecone",
|
||||
"PlanetScale",
|
||||
"Python",
|
||||
"PostHog",
|
||||
"PostgreSQL",
|
||||
"Prisma",
|
||||
"Pulumi",
|
||||
"RabbitMQ",
|
||||
"Railway",
|
||||
"React",
|
||||
"React Native",
|
||||
"Redis",
|
||||
"Redshift",
|
||||
"Remix",
|
||||
"Render",
|
||||
"Replicate",
|
||||
"Resend",
|
||||
"Ruby on Rails",
|
||||
"Rust",
|
||||
"SendGrid",
|
||||
"Sentry",
|
||||
"Sidekiq",
|
||||
"Snowflake",
|
||||
"Solid.js",
|
||||
"Spring Boot",
|
||||
"SQLite",
|
||||
"Stability AI",
|
||||
"Stripe",
|
||||
"Supabase",
|
||||
"Svelte",
|
||||
"SvelteKit",
|
||||
"Tailwind CSS",
|
||||
"Temporal",
|
||||
"Terraform",
|
||||
"Together AI",
|
||||
"tRPC",
|
||||
"Turso",
|
||||
"Twilio",
|
||||
"TypeORM",
|
||||
"Upstash",
|
||||
"Vercel",
|
||||
"Vercel AI SDK",
|
||||
"Vite",
|
||||
"Vue",
|
||||
"Weaviate",
|
||||
] as const;
|
||||
|
||||
type TechnologyPickerProps = {
|
||||
value: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
customValues: string[];
|
||||
onCustomValuesChange: (values: string[]) => void;
|
||||
};
|
||||
|
||||
export function TechnologyPicker({
|
||||
value,
|
||||
onChange,
|
||||
customValues,
|
||||
onCustomValuesChange,
|
||||
}: TechnologyPickerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
const [otherInputValue, setOtherInputValue] = useState("");
|
||||
const [showOtherInput, setShowOtherInput] = useState(false);
|
||||
const otherInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const allSelected = useMemo(() => [...value, ...customValues], [value, customValues]);
|
||||
|
||||
const filteredOptions = useMemo(() => {
|
||||
if (!searchValue) return TECHNOLOGY_OPTIONS;
|
||||
return matchSorter([...TECHNOLOGY_OPTIONS], searchValue);
|
||||
}, [searchValue]);
|
||||
|
||||
const toggleOption = useCallback(
|
||||
(option: string) => {
|
||||
if (value.includes(option)) {
|
||||
onChange(value.filter((v) => v !== option));
|
||||
} else {
|
||||
onChange([...value, option]);
|
||||
}
|
||||
},
|
||||
[value, onChange]
|
||||
);
|
||||
|
||||
const removeItem = useCallback(
|
||||
(item: string) => {
|
||||
if (value.includes(item)) {
|
||||
onChange(value.filter((v) => v !== item));
|
||||
} else {
|
||||
onCustomValuesChange(customValues.filter((v) => v !== item));
|
||||
}
|
||||
},
|
||||
[value, onChange, customValues, onCustomValuesChange]
|
||||
);
|
||||
|
||||
const addCustomValue = useCallback(() => {
|
||||
const trimmed = otherInputValue.trim();
|
||||
if (trimmed && !customValues.includes(trimmed) && !value.includes(trimmed)) {
|
||||
onCustomValuesChange([...customValues, trimmed]);
|
||||
setOtherInputValue("");
|
||||
}
|
||||
}, [otherInputValue, customValues, onCustomValuesChange, value]);
|
||||
|
||||
const handleOtherKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
addCustomValue();
|
||||
}
|
||||
},
|
||||
[addCustomValue]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{allSelected.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{allSelected.map((item) => (
|
||||
<span
|
||||
key={item}
|
||||
className={cn(
|
||||
"flex items-center gap-1 rounded-sm border py-0.5 pl-1.5 pr-1 text-xs font-medium text-text-bright",
|
||||
getPillColor(item)
|
||||
)}
|
||||
>
|
||||
{item}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeItem(item)}
|
||||
aria-label={`Remove ${item}`}
|
||||
className="ml-0.5 flex items-center transition hover:text-text-bright/70"
|
||||
>
|
||||
<XMarkIcon className="size-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Ariakit.ComboboxProvider
|
||||
resetValueOnHide
|
||||
setValue={(val) => {
|
||||
setSearchValue(val);
|
||||
}}
|
||||
>
|
||||
<Ariakit.SelectProvider
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
value={value}
|
||||
setValue={(v) => {
|
||||
if (Array.isArray(v)) {
|
||||
onChange(v);
|
||||
}
|
||||
}}
|
||||
virtualFocus
|
||||
>
|
||||
<Ariakit.Select className="group flex h-8 w-full items-center rounded bg-charcoal-750 pl-2 pr-2.5 text-sm text-text-dimmed ring-charcoal-600 transition focus-custom hover:bg-charcoal-650 hover:ring-1">
|
||||
<div className="flex grow items-center">
|
||||
<CubeIcon className="mr-1.5 size-4 flex-none text-text-dimmed" />
|
||||
<span>Select your technologies…</span>
|
||||
</div>
|
||||
<ChevronDownIcon className="size-4 flex-none text-text-dimmed transition group-hover:text-text-bright" />
|
||||
</Ariakit.Select>
|
||||
|
||||
<Ariakit.SelectPopover
|
||||
gutter={5}
|
||||
unmountOnHide
|
||||
className={cn(
|
||||
"z-50 flex flex-col overflow-clip rounded border border-charcoal-700 bg-background-bright shadow-md outline-none animate-in fade-in-40",
|
||||
"min-w-[max(180px,var(--popover-anchor-width))]",
|
||||
"max-w-[min(480px,var(--popover-available-width))]",
|
||||
"max-h-[min(400px,var(--popover-available-height))]"
|
||||
)}
|
||||
>
|
||||
<div className="flex h-9 w-full flex-none items-center gap-2 border-b border-grid-dimmed bg-transparent px-3 text-xs text-text-dimmed outline-none">
|
||||
<MagnifyingGlassIcon className="size-3.5 flex-none text-text-dimmed" />
|
||||
<Ariakit.Combobox
|
||||
autoSelect
|
||||
placeholder="Search technologies…"
|
||||
className="flex-1 bg-transparent text-xs text-text-dimmed outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Ariakit.ComboboxList className="overflow-y-auto overscroll-contain scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600 focus-custom">
|
||||
{filteredOptions.map((option) => (
|
||||
<Ariakit.ComboboxItem
|
||||
key={option}
|
||||
className="group cursor-pointer px-1 pt-1 text-2sm text-text-dimmed focus-custom last:pb-1"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
toggleOption(option);
|
||||
}}
|
||||
>
|
||||
<div className="flex h-8 w-full items-center gap-2 rounded-sm px-2 group-data-[active-item=true]:bg-tertiary hover:bg-tertiary">
|
||||
<CheckboxIndicator checked={value.includes(option)} />
|
||||
<span className="grow truncate text-text-bright">{option}</span>
|
||||
</div>
|
||||
</Ariakit.ComboboxItem>
|
||||
))}
|
||||
|
||||
{filteredOptions.length === 0 && searchValue && (
|
||||
<div className="px-3 py-2 text-xs text-text-dimmed">
|
||||
No matches for “{searchValue}”
|
||||
</div>
|
||||
)}
|
||||
</Ariakit.ComboboxList>
|
||||
|
||||
<div className="sticky bottom-0 border-t border-charcoal-700 bg-background-bright px-1 py-1">
|
||||
{showOtherInput ? (
|
||||
<div className="flex h-8 w-full items-center rounded-sm bg-tertiary pl-0 pr-2 ring-1 ring-charcoal-650">
|
||||
<input
|
||||
ref={otherInputRef}
|
||||
type="text"
|
||||
value={otherInputValue}
|
||||
onChange={(e) => setOtherInputValue(e.target.value)}
|
||||
onKeyDown={handleOtherKeyDown}
|
||||
placeholder="Type and press Enter to add"
|
||||
className="flex-1 border-none bg-transparent pl-2 text-2sm text-text-bright shadow-none outline-none ring-0 placeholder:text-text-dimmed focus:border-none focus:outline-none focus:ring-0"
|
||||
autoFocus
|
||||
/>
|
||||
<ShortcutKey
|
||||
shortcut={{ key: "Enter" }}
|
||||
variant="small"
|
||||
className={cn(
|
||||
"mr-1.5 transition-opacity duration-150",
|
||||
otherInputValue.length > 0 ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setOtherInputValue("");
|
||||
setShowOtherInput(false);
|
||||
}}
|
||||
className="flex items-center text-text-dimmed hover:text-text-bright"
|
||||
>
|
||||
<XMarkIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 w-full cursor-pointer items-center gap-2 rounded-sm px-2 text-2sm text-text-dimmed hover:bg-tertiary"
|
||||
onClick={() => {
|
||||
setShowOtherInput(true);
|
||||
setTimeout(() => otherInputRef.current?.focus(), 0);
|
||||
}}
|
||||
>
|
||||
<PlusIcon className="size-4 flex-none" />
|
||||
<span>Other (not listed)</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Ariakit.SelectPopover>
|
||||
</Ariakit.SelectProvider>
|
||||
</Ariakit.ComboboxProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import {
|
||||
BoltIcon,
|
||||
BuildingOffice2Icon,
|
||||
CodeBracketSquareIcon,
|
||||
FaceSmileIcon,
|
||||
FireIcon,
|
||||
GlobeAltIcon,
|
||||
RocketLaunchIcon,
|
||||
StarIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
@@ -25,7 +27,8 @@ export const AvatarData = z.discriminatedUnion("type", [
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal(AvatarType.enum.image),
|
||||
url: z.string().url(),
|
||||
url: z.string(),
|
||||
lastIconHex: z.string().optional(),
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -85,6 +88,7 @@ export const avatarIcons: Record<string, React.ComponentType<React.SVGProps<SVGS
|
||||
"hero:fire": FireIcon,
|
||||
"hero:star": StarIcon,
|
||||
"hero:face-smile": FaceSmileIcon,
|
||||
"hero:bolt": BoltIcon,
|
||||
};
|
||||
|
||||
export const defaultAvatarColors = [
|
||||
@@ -179,9 +183,21 @@ function AvatarIcon({
|
||||
}
|
||||
|
||||
function AvatarImage({ avatar, size }: { avatar: ImageAvatar; size: number }) {
|
||||
if (!avatar.url) {
|
||||
return (
|
||||
<span className="grid shrink-0 place-items-center" style={styleFromSize(size)}>
|
||||
<GlobeAltIcon className="size-[90%] text-text-dimmed" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="grid place-items-center" style={styleFromSize(size)}>
|
||||
<img src={avatar.url} alt="Organization avatar" className="size-6" />
|
||||
<span className="grid shrink-0 place-items-center" style={styleFromSize(size)}>
|
||||
<img
|
||||
src={avatar.url}
|
||||
alt="Organization avatar"
|
||||
className="size-full rounded-[10%] object-contain"
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
export function CheckboxIndicator({ checked }: { checked: boolean }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-4 flex-none items-center justify-center rounded border",
|
||||
checked ? "border-indigo-500 bg-indigo-600" : "border-charcoal-600 bg-charcoal-700"
|
||||
)}
|
||||
>
|
||||
{checked && (
|
||||
<svg className="size-3 text-white" viewBox="0 0 12 12" fill="none">
|
||||
<path
|
||||
d="M2.5 6L5 8.5L9.5 3.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -338,9 +338,9 @@ export function SelectTrigger({
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="flex grow items-center gap-0.5">
|
||||
{icon && <div className="-ml-1 flex-none">{icon}</div>}
|
||||
<div className="truncate">{content}</div>
|
||||
<div className="flex min-w-0 grow items-center gap-0.5 overflow-hidden">
|
||||
{icon && <div className="flex-none">{icon}</div>}
|
||||
<div className="min-w-0 truncate">{content}</div>
|
||||
</div>
|
||||
{dropdownIcon === true ? (
|
||||
<ChevronDown
|
||||
@@ -443,21 +443,33 @@ export function SelectList(props: SelectListProps) {
|
||||
export interface SelectItemProps extends Ariakit.SelectItemProps {
|
||||
icon?: React.ReactNode;
|
||||
checkIcon?: React.ReactNode;
|
||||
checkPosition?: "left" | "right";
|
||||
shortcut?: ShortcutDefinition;
|
||||
}
|
||||
|
||||
const selectItemClasses =
|
||||
"group cursor-pointer px-1 pt-1 text-2sm text-text-dimmed focus-custom last:pb-1";
|
||||
|
||||
import { CheckboxIndicator } from "./CheckboxIndicator";
|
||||
|
||||
export function SelectItem({
|
||||
icon,
|
||||
checkIcon = <Ariakit.SelectItemCheck className="size-8 flex-none text-text-bright" />,
|
||||
checkPosition = "right",
|
||||
shortcut,
|
||||
...props
|
||||
}: SelectItemProps) {
|
||||
const combobox = Ariakit.useComboboxContext();
|
||||
const render = combobox ? <Ariakit.ComboboxItem render={props.render} /> : undefined;
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
const select = Ariakit.useSelectContext();
|
||||
const selectValue = select?.useState("value");
|
||||
|
||||
const isChecked = React.useMemo(() => {
|
||||
if (!props.value || selectValue == null) return false;
|
||||
if (Array.isArray(selectValue)) return selectValue.includes(props.value);
|
||||
return selectValue === props.value;
|
||||
}, [selectValue, props.value]);
|
||||
|
||||
useShortcutKeys({
|
||||
shortcut: shortcut,
|
||||
@@ -484,10 +496,16 @@ export function SelectItem({
|
||||
)}
|
||||
ref={ref}
|
||||
>
|
||||
<div className="flex h-8 w-full items-center gap-1 rounded-sm px-2 group-data-[active-item=true]:bg-tertiary">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-8 w-full items-center rounded-sm px-2 group-data-[active-item=true]:bg-tertiary hover:bg-tertiary",
|
||||
checkPosition === "left" ? "gap-2" : "gap-1"
|
||||
)}
|
||||
>
|
||||
{checkPosition === "left" && <CheckboxIndicator checked={isChecked} />}
|
||||
{icon}
|
||||
<div className="grow truncate">{props.children || props.value}</div>
|
||||
{checkIcon}
|
||||
{checkPosition === "right" && checkIcon}
|
||||
{shortcut && (
|
||||
<ShortcutKey
|
||||
className={cn("size-4 flex-none transition duration-0 group-hover:border-charcoal-600")}
|
||||
|
||||
@@ -176,10 +176,22 @@ type TableCellBasicProps = {
|
||||
type TableHeaderCellProps = TableCellBasicProps & {
|
||||
hiddenLabel?: boolean;
|
||||
tooltip?: ReactNode;
|
||||
disableTooltipHoverableContent?: boolean;
|
||||
};
|
||||
|
||||
export const TableHeaderCell = forwardRef<HTMLTableCellElement, TableHeaderCellProps>(
|
||||
({ className, alignment = "left", children, colSpan, hiddenLabel = false, tooltip }, ref) => {
|
||||
(
|
||||
{
|
||||
className,
|
||||
alignment = "left",
|
||||
children,
|
||||
colSpan,
|
||||
hiddenLabel = false,
|
||||
tooltip,
|
||||
disableTooltipHoverableContent = false,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const { variant } = useContext(TableContext);
|
||||
let alignmentClassName = "text-left";
|
||||
switch (alignment) {
|
||||
@@ -222,6 +234,7 @@ export const TableHeaderCell = forwardRef<HTMLTableCellElement, TableHeaderCellP
|
||||
content={tooltip}
|
||||
contentClassName="normal-case tracking-normal"
|
||||
enabled={isHovered}
|
||||
disableHoverableContent={disableTooltipHoverableContent}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -69,21 +69,26 @@ export function ToastUI({
|
||||
width: toastWidth,
|
||||
}}
|
||||
>
|
||||
<div className="flex w-full items-start gap-2 rounded-lg p-3">
|
||||
<div
|
||||
className={cn("flex w-full gap-2 rounded-lg p-3", title ? "items-start" : "items-center")}
|
||||
>
|
||||
{variant === "success" ? (
|
||||
<CheckCircleIcon className="mt-1 size-4 min-w-4 text-success" />
|
||||
<CheckCircleIcon className={cn("size-4 min-w-4 text-success", title && "mt-1")} />
|
||||
) : (
|
||||
<ExclamationCircleIcon className="mt-1 size-4 min-w-4 text-error" />
|
||||
<ExclamationCircleIcon className={cn("size-4 min-w-4 text-error", title && "mt-1")} />
|
||||
)}
|
||||
<div className="flex flex-col">
|
||||
{title && <Header2 className="pt-0">{title}</Header2>}
|
||||
<Paragraph variant="small/dimmed" className="pb-1 pt-0.5">
|
||||
<Paragraph
|
||||
variant={title ? "small/dimmed" : "small/bright"}
|
||||
className={title ? "pb-1 pt-0.5" : ""}
|
||||
>
|
||||
{message}
|
||||
</Paragraph>
|
||||
<Action action={action} toastId={t} className="my-2" />
|
||||
</div>
|
||||
<button
|
||||
className="hover:bg-midnight-800 -mr-1 -mt-1 ms-auto rounded p-2 text-text-dimmed transition hover:text-text-bright"
|
||||
className={cn("-mr-1 ms-auto rounded p-2 text-text-dimmed transition hover:text-text-bright", title && "-mt-1")}
|
||||
onClick={() => toast.dismiss(t)}
|
||||
>
|
||||
<XMarkIcon className="size-4" />
|
||||
|
||||
@@ -77,12 +77,12 @@ export function ChartLegendCompound({
|
||||
const currentTotal = useMemo((): number | null => {
|
||||
if (!activePayload?.length) return grandTotal;
|
||||
|
||||
// Collect all series values from the hovered data point, preserving nulls
|
||||
const rawValues = activePayload
|
||||
.filter((item) => item.value !== undefined && dataKeys.includes(item.dataKey as string))
|
||||
.map((item) => item.value);
|
||||
// Use the full data row so the total covers ALL dataKeys, not just visibleSeries
|
||||
const dataRow = activePayload[0]?.payload;
|
||||
if (!dataRow) return grandTotal;
|
||||
|
||||
const rawValues = dataKeys.map((key) => dataRow[key]);
|
||||
|
||||
// Filter to non-null values only
|
||||
const values = rawValues
|
||||
.filter((v): v is number => v != null)
|
||||
.map((v) => Number(v) || 0);
|
||||
@@ -91,7 +91,6 @@ export function ChartLegendCompound({
|
||||
if (values.length === 0) return null;
|
||||
|
||||
if (!aggregation) {
|
||||
// Default: sum
|
||||
return values.reduce((a, b) => a + b, 0);
|
||||
}
|
||||
return aggregateValues(values, aggregation);
|
||||
@@ -116,24 +115,24 @@ export function ChartLegendCompound({
|
||||
const currentData = useMemo((): Record<string, number | null> => {
|
||||
if (!activePayload?.length) return totals;
|
||||
|
||||
// If we have activePayload data from hovering over a bar/line
|
||||
const hoverData = activePayload.reduce(
|
||||
(acc, item) => {
|
||||
if (item.dataKey && item.value !== undefined) {
|
||||
// Preserve null for gap-filled points instead of coercing to 0
|
||||
acc[item.dataKey] = item.value != null ? Number(item.value) || 0 : null;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number | null>
|
||||
);
|
||||
// Use the full data row so ALL dataKeys are resolved from the hovered point,
|
||||
// not just the visibleSeries present in activePayload.
|
||||
const dataRow = activePayload[0]?.payload;
|
||||
if (!dataRow) return totals;
|
||||
|
||||
const hoverData: Record<string, number | null> = {};
|
||||
for (const key of dataKeys) {
|
||||
const value = dataRow[key];
|
||||
if (value !== undefined) {
|
||||
hoverData[key] = value != null ? Number(value) || 0 : null;
|
||||
}
|
||||
}
|
||||
|
||||
// Return a merged object - totals for keys not in the hover data
|
||||
return {
|
||||
...totals,
|
||||
...hoverData,
|
||||
};
|
||||
}, [activePayload, totals]);
|
||||
}, [activePayload, totals, dataKeys]);
|
||||
|
||||
// Prepare legend items with capped display
|
||||
const legendItems = useMemo(() => {
|
||||
|
||||
@@ -945,11 +945,9 @@ export function QueryEditor({
|
||||
<ResizableHandle id="query-handle" />
|
||||
<ResizablePanel
|
||||
id="query-help"
|
||||
min="200px"
|
||||
collapsible
|
||||
collapsedSize="20px"
|
||||
min="380px"
|
||||
default="400px"
|
||||
max="500px"
|
||||
max="800px"
|
||||
className="w-full"
|
||||
>
|
||||
<QueryHelpSidebar
|
||||
|
||||
@@ -39,6 +39,7 @@ const S2EnvSchema = z.preprocess(
|
||||
S2_ENABLED: z.literal("1"),
|
||||
S2_ACCESS_TOKEN: z.string(),
|
||||
S2_DEPLOYMENT_LOGS_BASIN_NAME: z.string(),
|
||||
S2_DEPLOYMENT_STREAMS_LOCAL: z.string().default("0"),
|
||||
}),
|
||||
z.object({
|
||||
S2_ENABLED: z.literal("0"),
|
||||
@@ -547,6 +548,9 @@ const EnvironmentSchema = z
|
||||
|
||||
MAXIMUM_DEV_QUEUE_SIZE: z.coerce.number().int().optional(),
|
||||
MAXIMUM_DEPLOYED_QUEUE_SIZE: z.coerce.number().int().optional(),
|
||||
QUEUE_SIZE_CACHE_TTL_MS: z.coerce.number().int().optional().default(1_000), // 1 second
|
||||
QUEUE_SIZE_CACHE_MAX_SIZE: z.coerce.number().int().optional().default(5_000),
|
||||
QUEUE_SIZE_CACHE_ENABLED: z.coerce.number().int().optional().default(1),
|
||||
MAX_BATCH_V2_TRIGGER_ITEMS: z.coerce.number().int().default(500),
|
||||
MAX_BATCH_AND_WAIT_V2_TRIGGER_ITEMS: z.coerce.number().int().default(500),
|
||||
|
||||
@@ -556,7 +560,7 @@ const EnvironmentSchema = z
|
||||
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),
|
||||
BATCH_CONCURRENCY_LIMIT_DEFAULT: z.coerce.number().int().default(5),
|
||||
|
||||
REALTIME_STREAM_VERSION: z.enum(["v1", "v2"]).default("v1"),
|
||||
REALTIME_STREAM_MAX_LENGTH: z.coerce.number().int().default(1000),
|
||||
@@ -603,6 +607,20 @@ const EnvironmentSchema = z
|
||||
RUN_ENGINE_CONCURRENCY_SWEEPER_SCAN_JITTER_IN_MS: z.coerce.number().int().optional(),
|
||||
RUN_ENGINE_CONCURRENCY_SWEEPER_PROCESS_MARKED_JITTER_IN_MS: z.coerce.number().int().optional(),
|
||||
|
||||
// TTL System settings for automatic run expiration
|
||||
RUN_ENGINE_TTL_SYSTEM_DISABLED: BoolEnv.default(false),
|
||||
RUN_ENGINE_TTL_SYSTEM_SHARD_COUNT: z.coerce.number().int().optional(),
|
||||
RUN_ENGINE_TTL_SYSTEM_POLL_INTERVAL_MS: z.coerce.number().int().default(1_000),
|
||||
RUN_ENGINE_TTL_SYSTEM_BATCH_SIZE: z.coerce.number().int().default(100),
|
||||
RUN_ENGINE_TTL_WORKER_CONCURRENCY: z.coerce.number().int().default(1),
|
||||
RUN_ENGINE_TTL_WORKER_BATCH_MAX_SIZE: z.coerce.number().int().default(50),
|
||||
RUN_ENGINE_TTL_CONSUMERS_DISABLED: BoolEnv.default(false),
|
||||
RUN_ENGINE_TTL_WORKER_BATCH_MAX_WAIT_MS: z.coerce.number().int().default(5_000),
|
||||
|
||||
/** Optional maximum TTL for all runs (e.g. "14d"). If set, runs without an explicit TTL
|
||||
* will use this as their TTL, and runs with a TTL larger than this will be clamped. */
|
||||
RUN_ENGINE_DEFAULT_MAX_TTL: z.string().optional(),
|
||||
|
||||
RUN_ENGINE_RUN_LOCK_DURATION: z.coerce.number().int().default(5000),
|
||||
RUN_ENGINE_RUN_LOCK_AUTOMATIC_EXTENSION_THRESHOLD: z.coerce.number().int().default(1000),
|
||||
RUN_ENGINE_RUN_LOCK_MAX_RETRIES: z.coerce.number().int().default(10),
|
||||
@@ -975,6 +993,9 @@ const EnvironmentSchema = z
|
||||
// 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(),
|
||||
// Max items in the worker queue before claiming pauses (protects visibility timeouts)
|
||||
// If not set, no depth limit is applied
|
||||
BATCH_QUEUE_WORKER_QUEUE_MAX_DEPTH: 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),
|
||||
@@ -1327,6 +1348,8 @@ const EnvironmentSchema = z
|
||||
|
||||
REALTIME_STREAMS_S2_BASIN: z.string().optional(),
|
||||
REALTIME_STREAMS_S2_ACCESS_TOKEN: z.string().optional(),
|
||||
REALTIME_STREAMS_S2_ENDPOINT: z.string().optional(),
|
||||
REALTIME_STREAMS_S2_SKIP_ACCESS_TOKENS: z.enum(["true", "false"]).default("false"),
|
||||
REALTIME_STREAMS_S2_ACCESS_TOKEN_EXPIRATION_IN_MS: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { extractDomain, faviconUrl } from "~/utils/favicon";
|
||||
|
||||
function resolve(input: string, size: number): string | null {
|
||||
const domain = extractDomain(input);
|
||||
return domain && domain.includes(".") ? faviconUrl(domain, size) : null;
|
||||
}
|
||||
|
||||
export function useFaviconUrl(urlInput: string, size: number = 64) {
|
||||
const [url, setUrl] = useState<string | null>(() => resolve(urlInput, size));
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
useEffect(() => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setUrl(resolve(urlInput, size));
|
||||
}, 400);
|
||||
return () => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
};
|
||||
}, [urlInput, size]);
|
||||
|
||||
return url;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
Organization,
|
||||
OrgMember,
|
||||
Prisma,
|
||||
Project,
|
||||
RuntimeEnvironment,
|
||||
User,
|
||||
@@ -22,8 +23,12 @@ export async function createOrganization(
|
||||
title,
|
||||
userId,
|
||||
companySize,
|
||||
onboardingData,
|
||||
avatar,
|
||||
}: Pick<Organization, "title" | "companySize"> & {
|
||||
userId: User["id"];
|
||||
onboardingData?: Prisma.InputJsonValue;
|
||||
avatar?: Prisma.InputJsonValue;
|
||||
},
|
||||
attemptCount = 0
|
||||
): Promise<Organization> {
|
||||
@@ -47,6 +52,8 @@ export async function createOrganization(
|
||||
title,
|
||||
userId,
|
||||
companySize,
|
||||
onboardingData,
|
||||
avatar,
|
||||
},
|
||||
attemptCount + 1
|
||||
);
|
||||
@@ -59,6 +66,8 @@ export async function createOrganization(
|
||||
title,
|
||||
slug: uniqueOrgSlug,
|
||||
companySize,
|
||||
onboardingData: onboardingData ?? undefined,
|
||||
avatar: avatar ?? undefined,
|
||||
maximumConcurrencyLimit: env.DEFAULT_ORG_EXECUTION_CONCURRENCY_LIMIT,
|
||||
members: {
|
||||
create: {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { nanoid, customAlphabet } from "nanoid";
|
||||
import slug from "slug";
|
||||
import { $replica, prisma } from "~/db.server";
|
||||
import type { Project } from "@trigger.dev/database";
|
||||
import { Organization, createEnvironment } from "./organization.server";
|
||||
import type { Prisma, Project } from "@trigger.dev/database";
|
||||
import { type Organization, createEnvironment } from "./organization.server";
|
||||
import { env } from "~/env.server";
|
||||
import { projectCreated } from "~/services/platform.v3.server";
|
||||
export type { Project } from "@trigger.dev/database";
|
||||
@@ -14,6 +14,7 @@ type Options = {
|
||||
name: string;
|
||||
userId: string;
|
||||
version: "v2" | "v3";
|
||||
onboardingData?: Prisma.InputJsonValue;
|
||||
};
|
||||
|
||||
export class ExceededProjectLimitError extends Error {
|
||||
@@ -24,7 +25,7 @@ export class ExceededProjectLimitError extends Error {
|
||||
}
|
||||
|
||||
export async function createProject(
|
||||
{ organizationSlug, name, userId, version }: Options,
|
||||
{ organizationSlug, name, userId, version, onboardingData }: Options,
|
||||
attemptCount = 0
|
||||
): Promise<Project & { organization: Organization }> {
|
||||
//check the user has permissions to do this
|
||||
@@ -84,6 +85,7 @@ export async function createProject(
|
||||
name,
|
||||
userId,
|
||||
version,
|
||||
onboardingData,
|
||||
},
|
||||
attemptCount + 1
|
||||
);
|
||||
@@ -100,6 +102,7 @@ export async function createProject(
|
||||
},
|
||||
externalRef: `proj_${externalRefGenerator()}`,
|
||||
version: version === "v3" ? "V3" : "V2",
|
||||
onboardingData,
|
||||
},
|
||||
include: {
|
||||
organization: {
|
||||
|
||||
@@ -332,13 +332,15 @@ export function updateUser({
|
||||
email,
|
||||
marketingEmails,
|
||||
referralSource,
|
||||
onboardingData,
|
||||
}: Pick<User, "id" | "name" | "email"> & {
|
||||
marketingEmails?: boolean;
|
||||
referralSource?: string;
|
||||
onboardingData?: Prisma.InputJsonValue;
|
||||
}) {
|
||||
return prisma.user.update({
|
||||
where: { id },
|
||||
data: { name, email, marketingEmails, referralSource, confirmedBasicDetails: true },
|
||||
data: { name, email, marketingEmails, referralSource, onboardingData, confirmedBasicDetails: true },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,11 @@ import {
|
||||
envTypeToVercelTarget,
|
||||
} from "~/v3/vercel/vercelProjectIntegrationSchema";
|
||||
import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server";
|
||||
import {
|
||||
callVercelWithRecovery,
|
||||
wrapVercelCallWithRecovery,
|
||||
VercelSchemas,
|
||||
} from "./vercelSdkRecovery.server";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure helpers
|
||||
@@ -314,17 +319,21 @@ export class VercelIntegrationRepository {
|
||||
teamId: string | null
|
||||
): ResultAsync<string, VercelApiError> {
|
||||
if (teamId) {
|
||||
return wrapVercelCall(
|
||||
return wrapVercelCallWithRecovery(
|
||||
client.teams.getTeam({ teamId }),
|
||||
VercelSchemas.getTeam,
|
||||
"Failed to fetch Vercel team",
|
||||
{ teamId }
|
||||
{ teamId },
|
||||
toVercelApiError
|
||||
).map((response) => response.slug);
|
||||
}
|
||||
|
||||
return wrapVercelCall(
|
||||
return wrapVercelCallWithRecovery(
|
||||
client.user.getAuthUser(),
|
||||
VercelSchemas.getAuthUser,
|
||||
"Failed to fetch Vercel user",
|
||||
{}
|
||||
{},
|
||||
toVercelApiError
|
||||
).map((response) => response?.user.username ?? "unknown");
|
||||
}
|
||||
|
||||
@@ -333,10 +342,11 @@ export class VercelIntegrationRepository {
|
||||
): ResultAsync<{ isValid: boolean }, VercelApiError> {
|
||||
return this.getVercelClient(integration)
|
||||
.andThen((client) =>
|
||||
ResultAsync.fromPromise(
|
||||
callVercelWithRecovery(
|
||||
client.user.getAuthUser(),
|
||||
toVercelApiError
|
||||
)
|
||||
VercelSchemas.getAuthUser,
|
||||
{ context: "validateVercelToken" }
|
||||
).mapErr(toVercelApiError)
|
||||
)
|
||||
.map(() => ({ isValid: true }))
|
||||
.orElse((error) =>
|
||||
@@ -420,13 +430,15 @@ export class VercelIntegrationRepository {
|
||||
projectId: string,
|
||||
teamId?: string | null
|
||||
): ResultAsync<VercelCustomEnvironment[], VercelApiError> {
|
||||
return wrapVercelCall(
|
||||
return wrapVercelCallWithRecovery(
|
||||
client.environment.getV9ProjectsIdOrNameCustomEnvironments({
|
||||
idOrName: projectId,
|
||||
...(teamId && { teamId }),
|
||||
}),
|
||||
VercelSchemas.getCustomEnvironments,
|
||||
"Failed to fetch Vercel custom environments",
|
||||
{ projectId, teamId }
|
||||
{ projectId, teamId },
|
||||
toVercelApiError
|
||||
).map((response) => (response.environments || []).map(toVercelCustomEnvironment));
|
||||
}
|
||||
|
||||
@@ -435,13 +447,15 @@ export class VercelIntegrationRepository {
|
||||
projectId: string,
|
||||
teamId?: string | null,
|
||||
): ResultAsync<VercelEnvironmentVariable[], VercelApiError> {
|
||||
return wrapVercelCall(
|
||||
return wrapVercelCallWithRecovery(
|
||||
client.projects.filterProjectEnvs({
|
||||
idOrName: projectId,
|
||||
...(teamId && { teamId }),
|
||||
}),
|
||||
VercelSchemas.filterProjectEnvs,
|
||||
"Failed to fetch Vercel environment variables",
|
||||
{ projectId, teamId }
|
||||
{ projectId, teamId },
|
||||
toVercelApiError
|
||||
).map((response) => {
|
||||
// Warn if response is paginated (more data exists that we're not fetching)
|
||||
if (
|
||||
@@ -467,13 +481,15 @@ export class VercelIntegrationRepository {
|
||||
/** If provided, only include keys that pass this filter */
|
||||
shouldIncludeKey?: (key: string) => boolean
|
||||
): ResultAsync<VercelEnvironmentVariableValue[], VercelApiError> {
|
||||
return wrapVercelCall(
|
||||
return wrapVercelCallWithRecovery(
|
||||
client.projects.filterProjectEnvs({
|
||||
idOrName: projectId,
|
||||
...(teamId && { teamId }),
|
||||
}),
|
||||
VercelSchemas.filterProjectEnvs,
|
||||
"Failed to fetch Vercel environment variable values",
|
||||
{ projectId, teamId, target }
|
||||
{ projectId, teamId, target },
|
||||
toVercelApiError
|
||||
).andThen((response) => {
|
||||
// Apply all filters BEFORE decryption to avoid unnecessary API calls
|
||||
const filteredEnvs = extractVercelEnvs(response).filter((env) => {
|
||||
@@ -510,13 +526,14 @@ export class VercelIntegrationRepository {
|
||||
|
||||
// Encrypted vars: fetch decrypted value via individual endpoint
|
||||
// (list endpoint's decrypt param is deprecated)
|
||||
const result = await ResultAsync.fromPromise(
|
||||
const result = await callVercelWithRecovery(
|
||||
client.projects.getProjectEnv({
|
||||
idOrName: projectId,
|
||||
id: env.id,
|
||||
...(teamId && { teamId }),
|
||||
}),
|
||||
(error) => error
|
||||
VercelSchemas.getProjectEnv,
|
||||
{ context: "resolveEnvVarValue" }
|
||||
);
|
||||
|
||||
if (result.isErr()) {
|
||||
@@ -552,13 +569,15 @@ export class VercelIntegrationRepository {
|
||||
isSecret: boolean;
|
||||
target: string[];
|
||||
}>, VercelApiError> {
|
||||
return wrapVercelCall(
|
||||
return wrapVercelCallWithRecovery(
|
||||
client.environment.listSharedEnvVariable({
|
||||
teamId,
|
||||
...(projectId && { projectId }),
|
||||
}),
|
||||
VercelSchemas.listSharedEnvVariable,
|
||||
"Failed to fetch Vercel shared environment variables",
|
||||
{ teamId, projectId }
|
||||
{ teamId, projectId },
|
||||
toVercelApiError
|
||||
).map((response) => {
|
||||
const envVars = response.data || [];
|
||||
return envVars
|
||||
@@ -593,13 +612,15 @@ export class VercelIntegrationRepository {
|
||||
}>,
|
||||
VercelApiError
|
||||
> {
|
||||
return wrapVercelCall(
|
||||
return wrapVercelCallWithRecovery(
|
||||
client.environment.listSharedEnvVariable({
|
||||
teamId,
|
||||
...(projectId && { projectId }),
|
||||
}),
|
||||
VercelSchemas.listSharedEnvVariable,
|
||||
"Failed to fetch Vercel shared environment variable values",
|
||||
{ teamId, projectId }
|
||||
{ teamId, projectId },
|
||||
toVercelApiError
|
||||
).andThen((listResponse) => {
|
||||
const envVars = listResponse.data || [];
|
||||
if (envVars.length === 0) {
|
||||
@@ -635,12 +656,13 @@ export class VercelIntegrationRepository {
|
||||
}
|
||||
|
||||
// Try to get the decrypted value for this shared env var
|
||||
const getResult = await ResultAsync.fromPromise(
|
||||
const getResult = await callVercelWithRecovery(
|
||||
client.environment.getSharedEnvVar({
|
||||
id: envId,
|
||||
teamId,
|
||||
}),
|
||||
(error) => error
|
||||
VercelSchemas.getSharedEnvVar,
|
||||
{ context: "getSharedEnvVar" }
|
||||
);
|
||||
|
||||
if (getResult.isOk()) {
|
||||
@@ -655,47 +677,12 @@ export class VercelIntegrationRepository {
|
||||
};
|
||||
}
|
||||
|
||||
// Workaround: Vercel SDK may throw ResponseValidationError even when the API response
|
||||
// is valid (e.g., deletedAt: null vs expected number). Extract value from rawValue.
|
||||
const error = getResult.error;
|
||||
let errorValue: string | undefined;
|
||||
if (error && typeof error === "object" && "rawValue" in error) {
|
||||
const rawValue = (error as any).rawValue;
|
||||
if (rawValue && typeof rawValue === "object" && "value" in rawValue) {
|
||||
errorValue = rawValue.value as string | undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackValue = errorValue || listValue;
|
||||
|
||||
if (fallbackValue) {
|
||||
logger.warn("getSharedEnvVar failed validation, using value from error.rawValue or list response", {
|
||||
teamId,
|
||||
envId,
|
||||
envKey,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
hasErrorRawValue: !!errorValue,
|
||||
hasListValue: !!listValue,
|
||||
valueLength: fallbackValue.length,
|
||||
});
|
||||
return {
|
||||
key: envKey,
|
||||
value: fallbackValue,
|
||||
target: normalizeTarget(env.target),
|
||||
type,
|
||||
isSecret,
|
||||
applyToAllCustomEnvironments: applyToAllCustomEnvs,
|
||||
};
|
||||
}
|
||||
|
||||
logger.warn("Failed to get decrypted value for shared env var, no fallback available", {
|
||||
logger.warn("Failed to get decrypted value for shared env var", {
|
||||
teamId,
|
||||
projectId,
|
||||
envId,
|
||||
envKey,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
errorStack: error instanceof Error ? error.stack : undefined,
|
||||
hasRawValue: error && typeof error === "object" && "rawValue" in error,
|
||||
error: getResult.error instanceof Error ? getResult.error.message : String(getResult.error),
|
||||
});
|
||||
return null;
|
||||
})
|
||||
@@ -723,11 +710,18 @@ export class VercelIntegrationRepository {
|
||||
let from: string | undefined;
|
||||
|
||||
do {
|
||||
const response = await client.projects.getProjects({
|
||||
...(teamId && { teamId }),
|
||||
limit: "100",
|
||||
...(from && { from }),
|
||||
});
|
||||
const response = await callVercelWithRecovery(
|
||||
client.projects.getProjects({
|
||||
...(teamId && { teamId }),
|
||||
limit: "100",
|
||||
...(from && { from }),
|
||||
}),
|
||||
VercelSchemas.getProjects,
|
||||
{ context: "getVercelProjects" }
|
||||
).match(
|
||||
(val) => val,
|
||||
(err) => { throw err; }
|
||||
);
|
||||
|
||||
const projects = Array.isArray(response)
|
||||
? response
|
||||
@@ -975,6 +969,13 @@ export class VercelIntegrationRepository {
|
||||
return { created: 0, updated: 0, errors: [] as string[] };
|
||||
}
|
||||
|
||||
await this.removeAllVercelEnvVarsByKey({
|
||||
client,
|
||||
vercelProjectId: params.vercelProjectId,
|
||||
teamId: params.teamId,
|
||||
key: "TRIGGER_SECRET_KEY",
|
||||
});
|
||||
|
||||
const result = await this.batchUpsertVercelEnvVars({
|
||||
client,
|
||||
vercelProjectId: params.vercelProjectId,
|
||||
@@ -1081,6 +1082,111 @@ export class VercelIntegrationRepository {
|
||||
);
|
||||
}
|
||||
|
||||
static upsertEnvVarForCustomEnvironment(params: {
|
||||
orgIntegration: OrganizationIntegration & { tokenReference: SecretReference };
|
||||
vercelProjectId: string;
|
||||
teamId: string | null;
|
||||
key: string;
|
||||
value: string;
|
||||
customEnvironmentId: string;
|
||||
type: "sensitive" | "encrypted" | "plain";
|
||||
}): ResultAsync<void, VercelApiError> {
|
||||
return this.getVercelClient(params.orgIntegration).andThen((client) =>
|
||||
ResultAsync.fromPromise(
|
||||
(async () => {
|
||||
const { vercelProjectId, teamId, key, value, customEnvironmentId, type } = params;
|
||||
|
||||
const existingEnvs = await callVercelWithRecovery(
|
||||
client.projects.filterProjectEnvs({
|
||||
idOrName: vercelProjectId,
|
||||
...(teamId && { teamId }),
|
||||
}),
|
||||
VercelSchemas.filterProjectEnvs,
|
||||
{ context: "upsertEnvVarForCustomEnvironment" }
|
||||
).match(
|
||||
(val) => val,
|
||||
(err) => { throw err; }
|
||||
);
|
||||
|
||||
const envs = extractVercelEnvs(existingEnvs);
|
||||
|
||||
const existingEnv = envs.find((env) => {
|
||||
if (env.key !== key) return false;
|
||||
return (env as any).customEnvironmentIds?.includes(customEnvironmentId);
|
||||
});
|
||||
|
||||
if (existingEnv && existingEnv.id) {
|
||||
await client.projects.editProjectEnv({
|
||||
idOrName: vercelProjectId,
|
||||
id: existingEnv.id,
|
||||
...(teamId && { teamId }),
|
||||
requestBody: {
|
||||
value,
|
||||
type,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await client.projects.createProjectEnv({
|
||||
idOrName: vercelProjectId,
|
||||
...(teamId && { teamId }),
|
||||
requestBody: {
|
||||
key,
|
||||
value,
|
||||
type,
|
||||
customEnvironmentIds: [customEnvironmentId],
|
||||
} as any,
|
||||
});
|
||||
}
|
||||
})(),
|
||||
(error) => toVercelApiError(error)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
static removeEnvVarForCustomEnvironment(params: {
|
||||
orgIntegration: OrganizationIntegration & { tokenReference: SecretReference };
|
||||
vercelProjectId: string;
|
||||
teamId: string | null;
|
||||
key: string;
|
||||
customEnvironmentId: string;
|
||||
}): ResultAsync<void, VercelApiError> {
|
||||
return this.getVercelClient(params.orgIntegration).andThen((client) =>
|
||||
ResultAsync.fromPromise(
|
||||
(async () => {
|
||||
const { vercelProjectId, teamId, key, customEnvironmentId } = params;
|
||||
|
||||
const existingEnvs = await callVercelWithRecovery(
|
||||
client.projects.filterProjectEnvs({
|
||||
idOrName: vercelProjectId,
|
||||
...(teamId && { teamId }),
|
||||
}),
|
||||
VercelSchemas.filterProjectEnvs,
|
||||
{ context: "removeEnvVarForCustomEnvironment" }
|
||||
).match(
|
||||
(val) => val,
|
||||
(err) => { throw err; }
|
||||
);
|
||||
|
||||
const envs = extractVercelEnvs(existingEnvs);
|
||||
|
||||
const existingEnv = envs.find((env) => {
|
||||
if (env.key !== key) return false;
|
||||
return (env as any).customEnvironmentIds?.includes(customEnvironmentId);
|
||||
});
|
||||
|
||||
if (existingEnv && existingEnv.id) {
|
||||
await client.projects.batchRemoveProjectEnv({
|
||||
idOrName: vercelProjectId,
|
||||
...(teamId && { teamId }),
|
||||
requestBody: { ids: [existingEnv.id] },
|
||||
});
|
||||
}
|
||||
})(),
|
||||
(error) => toVercelApiError(error)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
static pullEnvVarsFromVercel(params: {
|
||||
projectId: string;
|
||||
vercelProjectId: string;
|
||||
@@ -1409,10 +1515,17 @@ export class VercelIntegrationRepository {
|
||||
return { created: 0, updated: 0, errors: [] };
|
||||
}
|
||||
|
||||
const existingEnvs = await client.projects.filterProjectEnvs({
|
||||
idOrName: vercelProjectId,
|
||||
...(teamId && { teamId }),
|
||||
});
|
||||
const existingEnvs = await callVercelWithRecovery(
|
||||
client.projects.filterProjectEnvs({
|
||||
idOrName: vercelProjectId,
|
||||
...(teamId && { teamId }),
|
||||
}),
|
||||
VercelSchemas.filterProjectEnvs,
|
||||
{ context: "batchUpsertVercelEnvVars" }
|
||||
).match(
|
||||
(val) => val,
|
||||
(err) => { throw err; }
|
||||
);
|
||||
|
||||
const existingEnvsList = extractVercelEnvs(existingEnvs);
|
||||
|
||||
@@ -1526,6 +1639,42 @@ export class VercelIntegrationRepository {
|
||||
return { created, updated, errors };
|
||||
}
|
||||
|
||||
private static async removeAllVercelEnvVarsByKey(params: {
|
||||
client: Vercel;
|
||||
vercelProjectId: string;
|
||||
teamId: string | null;
|
||||
key: string;
|
||||
}): Promise<void> {
|
||||
const { client, vercelProjectId, teamId, key } = params;
|
||||
|
||||
const existingEnvs = await callVercelWithRecovery(
|
||||
client.projects.filterProjectEnvs({
|
||||
idOrName: vercelProjectId,
|
||||
...(teamId && { teamId }),
|
||||
}),
|
||||
VercelSchemas.filterProjectEnvs,
|
||||
{ context: "removeAllVercelEnvVarsByKey" }
|
||||
).match(
|
||||
(val) => val,
|
||||
(err) => { throw err; }
|
||||
);
|
||||
|
||||
const envs = extractVercelEnvs(existingEnvs);
|
||||
const idsToRemove = envs
|
||||
.filter((env) => env.key === key && env.id)
|
||||
.map((env) => env.id!);
|
||||
|
||||
if (idsToRemove.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await client.projects.batchRemoveProjectEnv({
|
||||
idOrName: vercelProjectId,
|
||||
...(teamId && { teamId }),
|
||||
requestBody: { ids: idsToRemove },
|
||||
});
|
||||
}
|
||||
|
||||
private static async upsertVercelEnvVar(params: {
|
||||
client: Vercel;
|
||||
vercelProjectId: string;
|
||||
@@ -1537,10 +1686,17 @@ export class VercelIntegrationRepository {
|
||||
}): Promise<void> {
|
||||
const { client, vercelProjectId, teamId, key, value, target, type } = params;
|
||||
|
||||
const existingEnvs = await client.projects.filterProjectEnvs({
|
||||
idOrName: vercelProjectId,
|
||||
...(teamId && { teamId }),
|
||||
});
|
||||
const existingEnvs = await callVercelWithRecovery(
|
||||
client.projects.filterProjectEnvs({
|
||||
idOrName: vercelProjectId,
|
||||
...(teamId && { teamId }),
|
||||
}),
|
||||
VercelSchemas.filterProjectEnvs,
|
||||
{ context: "upsertVercelEnvVar" }
|
||||
).match(
|
||||
(val) => val,
|
||||
(err) => { throw err; }
|
||||
);
|
||||
|
||||
const envs = extractVercelEnvs(existingEnvs);
|
||||
|
||||
@@ -1584,14 +1740,16 @@ export class VercelIntegrationRepository {
|
||||
teamId?: string | null
|
||||
): ResultAsync<boolean | null, VercelApiError> {
|
||||
// Vercel SDK lacks a getProject method — updateProject with empty body reads without modifying.
|
||||
return wrapVercelCall(
|
||||
return wrapVercelCallWithRecovery(
|
||||
client.projects.updateProject({
|
||||
idOrName: vercelProjectId,
|
||||
...(teamId && { teamId }),
|
||||
requestBody: {},
|
||||
}),
|
||||
VercelSchemas.updateProject,
|
||||
"Failed to get Vercel project autoAssignCustomDomains",
|
||||
{ vercelProjectId, teamId }
|
||||
{ vercelProjectId, teamId },
|
||||
toVercelApiError
|
||||
).map((project) => project.autoAssignCustomDomains ?? null);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import { z } from "zod";
|
||||
import { ResultAsync, okAsync, errAsync } from "neverthrow";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import type { VercelApiError } from "./vercelIntegration.server";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Recovery utilities for Vercel SDK validation errors
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// The Vercel SDK (Speakeasy-generated) validates API responses with strict Zod
|
||||
// schemas. When the API returns valid data but a field doesn't match the SDK's
|
||||
// type (e.g., `deletedAt: null` vs `number`), a `ResponseValidationError` is
|
||||
// thrown — even though the response contains all the data we need.
|
||||
//
|
||||
// Error hierarchy:
|
||||
// VercelError.body → raw HTTP body text (HTTP errors — never recover)
|
||||
// ResponseValidationError.rawValue → parsed JSON that failed validation
|
||||
// SDKValidationError.rawValue → same pattern, different base class
|
||||
//
|
||||
// Recovery: gate on validation error type → extract rawValue → validate → return.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Only attempt recovery for SDK validation errors — not HTTP errors (401/403).
|
||||
*
|
||||
* ResponseValidationError and SDKValidationError both carry `rawValue` with the
|
||||
* parsed JSON that failed schema validation. VercelError (HTTP errors) carries
|
||||
* `body` instead — we must NOT recover from those since the response is an error
|
||||
* payload, not the data we asked for.
|
||||
*/
|
||||
function isValidationError(error: unknown): boolean {
|
||||
if (!error || typeof error !== "object") return false;
|
||||
if (!(error instanceof Error)) return false;
|
||||
|
||||
return (
|
||||
error.constructor.name === "ResponseValidationError" ||
|
||||
error.constructor.name === "SDKValidationError" ||
|
||||
"rawValue" in error
|
||||
);
|
||||
}
|
||||
|
||||
function extractRawValue(error: unknown): unknown | undefined {
|
||||
if (!error || typeof error !== "object") return undefined;
|
||||
if ("rawValue" in error) {
|
||||
return (error as { rawValue: unknown }).rawValue;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to recover usable data from a Vercel SDK error.
|
||||
*
|
||||
* Returns the validated data on success, or `undefined` if recovery fails.
|
||||
*/
|
||||
export function recoverFromVercelSdkError<T>(
|
||||
error: unknown,
|
||||
schema: z.ZodType<any>,
|
||||
options?: { context?: string }
|
||||
): T | undefined {
|
||||
if (!isValidationError(error)) return undefined;
|
||||
|
||||
const raw = extractRawValue(error);
|
||||
if (raw === undefined) return undefined;
|
||||
|
||||
const result = schema.safeParse(raw);
|
||||
if (!result.success) return undefined;
|
||||
|
||||
logger.warn("Recovered data from Vercel SDK validation error", {
|
||||
context: options?.context,
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
errorType: error?.constructor?.name,
|
||||
});
|
||||
|
||||
return result.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a Vercel SDK promise with automatic recovery on validation errors.
|
||||
*
|
||||
* On success: returns the SDK result as-is.
|
||||
* On error: attempts recovery via rawValue + schema validation (validation errors only).
|
||||
*/
|
||||
export function callVercelWithRecovery<T>(
|
||||
sdkCall: Promise<T>,
|
||||
schema: z.ZodType<any>,
|
||||
options?: { context?: string }
|
||||
): ResultAsync<T, unknown> {
|
||||
return ResultAsync.fromPromise(sdkCall, (error) => error).orElse((error) => {
|
||||
const recovered = recoverFromVercelSdkError<T>(error, schema, options);
|
||||
if (recovered !== undefined) {
|
||||
return okAsync(recovered);
|
||||
}
|
||||
return errAsync(error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop-in replacement for `wrapVercelCall` with SDK error recovery.
|
||||
*
|
||||
* Wraps a Vercel SDK promise in ResultAsync with structured error logging,
|
||||
* attempting to recover from validation errors before treating as failure.
|
||||
*/
|
||||
export function wrapVercelCallWithRecovery<T>(
|
||||
promise: Promise<T>,
|
||||
schema: z.ZodType<any>,
|
||||
message: string,
|
||||
context: Record<string, unknown>,
|
||||
toError: (error: unknown) => VercelApiError
|
||||
): ResultAsync<T, VercelApiError> {
|
||||
return callVercelWithRecovery(promise, schema, { context: message }).mapErr((error) => {
|
||||
const apiError = toError(error);
|
||||
logger.error(message, { ...context, error, authInvalid: apiError.authInvalid });
|
||||
return apiError;
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal Zod schemas — validate only the fields we actually use.
|
||||
// All use .passthrough() to preserve extra fields from the API response.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const VercelSchemas = {
|
||||
getTeam: z.object({ slug: z.string() }).passthrough(),
|
||||
|
||||
getAuthUser: z
|
||||
.object({ user: z.object({ username: z.string() }).passthrough() })
|
||||
.passthrough(),
|
||||
|
||||
getCustomEnvironments: z
|
||||
.object({
|
||||
environments: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
id: z.string(),
|
||||
slug: z.string(),
|
||||
description: z.string().optional(),
|
||||
branchMatcher: z.unknown().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
|
||||
filterProjectEnvs: z
|
||||
.union([
|
||||
z
|
||||
.object({
|
||||
envs: z.array(z.record(z.unknown())),
|
||||
pagination: z.unknown().optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
z.array(z.record(z.unknown())),
|
||||
])
|
||||
.transform((val) => (Array.isArray(val) ? { envs: val } : val)),
|
||||
|
||||
getProjectEnv: z.object({ key: z.string(), value: z.string().optional() }).passthrough(),
|
||||
|
||||
getProjects: z.union([
|
||||
z.array(z.object({ id: z.string(), name: z.string() }).passthrough()),
|
||||
z
|
||||
.object({
|
||||
projects: z.array(
|
||||
z.object({ id: z.string(), name: z.string() }).passthrough()
|
||||
),
|
||||
pagination: z.unknown().optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
]),
|
||||
|
||||
listSharedEnvVariable: z
|
||||
.object({
|
||||
data: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
id: z.string().optional(),
|
||||
key: z.string().optional(),
|
||||
type: z.string().optional(),
|
||||
target: z.unknown().optional(),
|
||||
value: z.string().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
|
||||
getSharedEnvVar: z.object({ value: z.string().optional() }).passthrough(),
|
||||
|
||||
updateProject: z
|
||||
.object({ id: z.string(), name: z.string(), autoAssignCustomDomains: z.boolean().optional() })
|
||||
.passthrough(),
|
||||
} as const;
|
||||
@@ -112,6 +112,7 @@ export class OrganizationsPresenter {
|
||||
organization,
|
||||
project: {
|
||||
...fullProject,
|
||||
createdAt: fullProject.createdAt,
|
||||
environments: sortEnvironments(
|
||||
fullProject.environments.filter((env) => {
|
||||
if (env.type !== "DEVELOPMENT") return true;
|
||||
|
||||
@@ -10,7 +10,10 @@ import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { type User } from "~/models/user.server";
|
||||
import { processGitMetadata } from "./BranchesPresenter.server";
|
||||
import { BranchTrackingConfigSchema, getTrackedBranchForEnvironment } from "~/v3/github";
|
||||
import { VercelProjectIntegrationDataSchema } from "~/v3/vercel/vercelProjectIntegrationSchema";
|
||||
import {
|
||||
VercelProjectIntegrationDataSchema,
|
||||
buildVercelDeploymentUrl,
|
||||
} from "~/v3/vercel/vercelProjectIntegrationSchema";
|
||||
|
||||
const pageSize = 20;
|
||||
|
||||
@@ -232,8 +235,11 @@ LIMIT ${pageSize} OFFSET ${pageSize * (page - 1)};`;
|
||||
|
||||
let vercelDeploymentUrl: string | null = null;
|
||||
if (hasVercelIntegration && deployment.integrationDeploymentId && vercelTeamSlug && vercelProjectName) {
|
||||
const vercelId = deployment.integrationDeploymentId.replace(/^dpl_/, "");
|
||||
vercelDeploymentUrl = `https://vercel.com/${vercelTeamSlug}/${vercelProjectName}/${vercelId}`;
|
||||
vercelDeploymentUrl = buildVercelDeploymentUrl(
|
||||
vercelTeamSlug,
|
||||
vercelProjectName,
|
||||
deployment.integrationDeploymentId
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { type User } from "~/models/user.server";
|
||||
import { getUsername } from "~/utils/username";
|
||||
import { processGitMetadata } from "./BranchesPresenter.server";
|
||||
import { VercelProjectIntegrationDataSchema } from "~/v3/vercel/vercelProjectIntegrationSchema";
|
||||
import { S2 } from "@s2-dev/streamstore";
|
||||
import { env } from "~/env.server";
|
||||
import { createRedisClient } from "~/redis.server";
|
||||
@@ -161,6 +162,51 @@ export class DeploymentPresenter {
|
||||
});
|
||||
|
||||
const gitMetadata = processGitMetadata(deployment.git);
|
||||
|
||||
// Look up Vercel integration data to construct a deployment URL
|
||||
let vercelDeploymentUrl: string | undefined;
|
||||
const vercelProjectIntegration =
|
||||
await this.#prismaClient.organizationProjectIntegration.findFirst({
|
||||
where: {
|
||||
projectId: project.id,
|
||||
deletedAt: null,
|
||||
organizationIntegration: {
|
||||
service: "VERCEL",
|
||||
deletedAt: null,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
integrationData: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (vercelProjectIntegration) {
|
||||
const parsed = VercelProjectIntegrationDataSchema.safeParse(
|
||||
vercelProjectIntegration.integrationData
|
||||
);
|
||||
|
||||
if (parsed.success && parsed.data.vercelTeamSlug) {
|
||||
const integrationDeployment =
|
||||
await this.#prismaClient.integrationDeployment.findFirst({
|
||||
where: {
|
||||
deploymentId: deployment.id,
|
||||
integrationName: "vercel",
|
||||
},
|
||||
select: {
|
||||
integrationDeploymentId: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
if (integrationDeployment) {
|
||||
const vercelId = integrationDeployment.integrationDeploymentId;
|
||||
vercelDeploymentUrl = `https://vercel.com/${parsed.data.vercelTeamSlug}/${parsed.data.vercelProjectName}/${vercelId}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const externalBuildData = deployment.externalBuildData
|
||||
? ExternalBuildData.safeParse(deployment.externalBuildData)
|
||||
: undefined;
|
||||
@@ -171,7 +217,7 @@ export class DeploymentPresenter {
|
||||
let eventStream = undefined;
|
||||
if (
|
||||
env.S2_ENABLED === "1" &&
|
||||
(buildServerMetadata || gitMetadata?.source === "trigger_github_app")
|
||||
(buildServerMetadata || gitMetadata?.source === "trigger_github_app" || env.S2_DEPLOYMENT_STREAMS_LOCAL === "1")
|
||||
) {
|
||||
const [error, accessToken] = await tryCatch(this.getS2AccessToken(project.externalRef));
|
||||
|
||||
@@ -227,6 +273,7 @@ export class DeploymentPresenter {
|
||||
type: deployment.type,
|
||||
git: gitMetadata,
|
||||
triggeredVia: deployment.triggeredVia,
|
||||
vercelDeploymentUrl,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -243,9 +290,9 @@ export class DeploymentPresenter {
|
||||
return cachedToken;
|
||||
}
|
||||
|
||||
const { access_token: accessToken } = await s2.accessTokens.issue({
|
||||
const { accessToken } = await s2.accessTokens.issue({
|
||||
id: `${projectRef}-${new Date().getTime()}`,
|
||||
expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(), // 1 hour
|
||||
expiresAt: new Date(Date.now() + 60 * 60 * 1000), // 1 hour
|
||||
scope: {
|
||||
ops: ["read"],
|
||||
basins: {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { marqs } from "~/v3/marqs/index.server";
|
||||
import { engine } from "~/v3/runEngine.server";
|
||||
import { getQueueSizeLimit } from "~/v3/utils/queueLimits.server";
|
||||
import { BasePresenter } from "./basePresenter.server";
|
||||
|
||||
export type Environment = {
|
||||
@@ -9,6 +10,7 @@ export type Environment = {
|
||||
concurrencyLimit: number;
|
||||
burstFactor: number;
|
||||
runsEnabled: boolean;
|
||||
queueSizeLimit: number | null;
|
||||
};
|
||||
|
||||
export class EnvironmentQueuePresenter extends BasePresenter {
|
||||
@@ -30,6 +32,8 @@ export class EnvironmentQueuePresenter extends BasePresenter {
|
||||
},
|
||||
select: {
|
||||
runsEnabled: true,
|
||||
maximumDevQueueSize: true,
|
||||
maximumDeployedQueueSize: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -37,12 +41,15 @@ export class EnvironmentQueuePresenter extends BasePresenter {
|
||||
throw new Error("Organization not found");
|
||||
}
|
||||
|
||||
const queueSizeLimit = getQueueSizeLimit(environment.type, organization);
|
||||
|
||||
return {
|
||||
running,
|
||||
queued,
|
||||
concurrencyLimit: environment.maximumConcurrencyLimit,
|
||||
burstFactor: environment.concurrencyLimitBurstFactor.toNumber(),
|
||||
runsEnabled: environment.type === "DEVELOPMENT" || organization.runsEnabled,
|
||||
queueSizeLimit,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Ratelimit } from "@upstash/ratelimit";
|
||||
import { RuntimeEnvironmentType } from "@trigger.dev/database";
|
||||
import { createHash } from "node:crypto";
|
||||
import { env } from "~/env.server";
|
||||
import { getCurrentPlan } from "~/services/platform.v3.server";
|
||||
@@ -12,6 +13,8 @@ import { BasePresenter } from "./basePresenter.server";
|
||||
import { singleton } from "~/utils/singleton";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { CheckScheduleService } from "~/v3/services/checkSchedule.server";
|
||||
import { engine } from "~/v3/runEngine.server";
|
||||
import { getQueueSizeLimit, getQueueSizeLimitSource } from "~/v3/utils/queueLimits.server";
|
||||
|
||||
// Create a singleton Redis client for rate limit queries
|
||||
const rateLimitRedisClient = singleton("rateLimitQueryRedisClient", () =>
|
||||
@@ -66,8 +69,7 @@ export type LimitsResult = {
|
||||
logRetentionDays: QuotaInfo | null;
|
||||
realtimeConnections: QuotaInfo | null;
|
||||
batchProcessingConcurrency: QuotaInfo;
|
||||
devQueueSize: QuotaInfo;
|
||||
deployedQueueSize: QuotaInfo;
|
||||
queueSize: QuotaInfo;
|
||||
metricDashboards: QuotaInfo | null;
|
||||
metricWidgetsPerDashboard: QuotaInfo | null;
|
||||
queryPeriodDays: QuotaInfo | null;
|
||||
@@ -87,11 +89,13 @@ export class LimitsPresenter extends BasePresenter {
|
||||
organizationId,
|
||||
projectId,
|
||||
environmentId,
|
||||
environmentType,
|
||||
environmentApiKey,
|
||||
}: {
|
||||
organizationId: string;
|
||||
projectId: string;
|
||||
environmentId: string;
|
||||
environmentType: RuntimeEnvironmentType;
|
||||
environmentApiKey: string;
|
||||
}): Promise<LimitsResult> {
|
||||
// Get organization with all limit-related fields
|
||||
@@ -175,6 +179,30 @@ export class LimitsPresenter extends BasePresenter {
|
||||
batchRateLimitConfig
|
||||
);
|
||||
|
||||
// Get current queue size for this environment
|
||||
// We need the runtime environment fields for the engine query
|
||||
const runtimeEnv = await this._replica.runtimeEnvironment.findFirst({
|
||||
where: { id: environmentId },
|
||||
select: {
|
||||
id: true,
|
||||
maximumConcurrencyLimit: true,
|
||||
concurrencyLimitBurstFactor: true,
|
||||
},
|
||||
});
|
||||
|
||||
let currentQueueSize = 0;
|
||||
if (runtimeEnv) {
|
||||
const engineEnv = {
|
||||
id: runtimeEnv.id,
|
||||
type: environmentType,
|
||||
maximumConcurrencyLimit: runtimeEnv.maximumConcurrencyLimit,
|
||||
concurrencyLimitBurstFactor: runtimeEnv.concurrencyLimitBurstFactor,
|
||||
organization: { id: organizationId },
|
||||
project: { id: projectId },
|
||||
};
|
||||
currentQueueSize = (await engine.lengthOfEnvQueue(engineEnv)) ?? 0;
|
||||
}
|
||||
|
||||
// Get plan-level limits
|
||||
const schedulesLimit = limits?.schedules?.number ?? null;
|
||||
const teamMembersLimit = limits?.teamMembers?.number ?? null;
|
||||
@@ -217,95 +245,90 @@ export class LimitsPresenter extends BasePresenter {
|
||||
schedules:
|
||||
schedulesLimit !== null
|
||||
? {
|
||||
name: "Schedules",
|
||||
description: "Maximum number of schedules per project",
|
||||
limit: schedulesLimit,
|
||||
currentUsage: scheduleCount,
|
||||
source: "plan",
|
||||
canExceed: limits?.schedules?.canExceed,
|
||||
isUpgradable: true,
|
||||
}
|
||||
name: "Schedules",
|
||||
description: "Maximum number of schedules per project",
|
||||
limit: schedulesLimit,
|
||||
currentUsage: scheduleCount,
|
||||
source: "plan",
|
||||
canExceed: limits?.schedules?.canExceed,
|
||||
isUpgradable: true,
|
||||
}
|
||||
: null,
|
||||
teamMembers:
|
||||
teamMembersLimit !== null
|
||||
? {
|
||||
name: "Team members",
|
||||
description: "Maximum number of team members in this organization",
|
||||
limit: teamMembersLimit,
|
||||
currentUsage: organization._count.members,
|
||||
source: "plan",
|
||||
canExceed: limits?.teamMembers?.canExceed,
|
||||
isUpgradable: true,
|
||||
}
|
||||
name: "Team members",
|
||||
description: "Maximum number of team members in this organization",
|
||||
limit: teamMembersLimit,
|
||||
currentUsage: organization._count.members,
|
||||
source: "plan",
|
||||
canExceed: limits?.teamMembers?.canExceed,
|
||||
isUpgradable: true,
|
||||
}
|
||||
: null,
|
||||
alerts:
|
||||
alertsLimit !== null
|
||||
? {
|
||||
name: "Alert channels",
|
||||
description: "Maximum number of alert channels per project",
|
||||
limit: alertsLimit,
|
||||
currentUsage: alertChannelCount,
|
||||
source: "plan",
|
||||
canExceed: limits?.alerts?.canExceed,
|
||||
isUpgradable: true,
|
||||
}
|
||||
name: "Alert channels",
|
||||
description: "Maximum number of alert channels per project",
|
||||
limit: alertsLimit,
|
||||
currentUsage: alertChannelCount,
|
||||
source: "plan",
|
||||
canExceed: limits?.alerts?.canExceed,
|
||||
isUpgradable: true,
|
||||
}
|
||||
: null,
|
||||
branches:
|
||||
branchesLimit !== null
|
||||
? {
|
||||
name: "Preview branches",
|
||||
description: "Maximum number of active preview branches per project",
|
||||
limit: branchesLimit,
|
||||
currentUsage: activeBranchCount,
|
||||
source: "plan",
|
||||
canExceed: limits?.branches?.canExceed,
|
||||
isUpgradable: true,
|
||||
}
|
||||
name: "Preview branches",
|
||||
description: "Maximum number of active preview branches per project",
|
||||
limit: branchesLimit,
|
||||
currentUsage: activeBranchCount,
|
||||
source: "plan",
|
||||
canExceed: limits?.branches?.canExceed,
|
||||
isUpgradable: true,
|
||||
}
|
||||
: null,
|
||||
logRetentionDays:
|
||||
logRetentionDaysLimit !== null
|
||||
? {
|
||||
name: "Log retention",
|
||||
description: "Number of days logs are retained",
|
||||
limit: logRetentionDaysLimit,
|
||||
currentUsage: 0, // Not applicable - this is a duration, not a count
|
||||
source: "plan",
|
||||
}
|
||||
name: "Log retention",
|
||||
description: "Number of days logs are retained",
|
||||
limit: logRetentionDaysLimit,
|
||||
currentUsage: 0, // Not applicable - this is a duration, not a count
|
||||
source: "plan",
|
||||
}
|
||||
: null,
|
||||
realtimeConnections:
|
||||
realtimeConnectionsLimit !== null
|
||||
? {
|
||||
name: "Realtime connections",
|
||||
description: "Maximum concurrent Realtime connections",
|
||||
limit: realtimeConnectionsLimit,
|
||||
currentUsage: 0, // Would need to query realtime service for this
|
||||
source: "plan",
|
||||
canExceed: limits?.realtimeConcurrentConnections?.canExceed,
|
||||
isUpgradable: true,
|
||||
}
|
||||
name: "Realtime connections",
|
||||
description: "Maximum concurrent Realtime connections",
|
||||
limit: realtimeConnectionsLimit,
|
||||
currentUsage: 0, // Would need to query realtime service for this
|
||||
source: "plan",
|
||||
canExceed: limits?.realtimeConcurrentConnections?.canExceed,
|
||||
isUpgradable: true,
|
||||
}
|
||||
: null,
|
||||
batchProcessingConcurrency: {
|
||||
name: "Batch processing concurrency",
|
||||
description: "Controls how many batch items can be processed simultaneously.",
|
||||
name: "Batch trigger processing concurrency",
|
||||
description:
|
||||
"When you send a batch trigger, we convert it into individual runs in parallel. This is the maximum number of batches being converted into runs at once. It does not limit how many batch runs can be executing.",
|
||||
limit: batchConcurrencyConfig.processingConcurrency,
|
||||
currentUsage: 0,
|
||||
source: batchConcurrencySource,
|
||||
canExceed: true,
|
||||
isUpgradable: true,
|
||||
},
|
||||
devQueueSize: {
|
||||
name: "Dev queue size",
|
||||
description: "Maximum pending runs in development environments",
|
||||
limit: organization.maximumDevQueueSize ?? null,
|
||||
currentUsage: 0, // Would need to query Redis for this
|
||||
source: organization.maximumDevQueueSize ? "override" : "default",
|
||||
},
|
||||
deployedQueueSize: {
|
||||
name: "Deployed queue size",
|
||||
description: "Maximum pending runs in deployed environments",
|
||||
limit: organization.maximumDeployedQueueSize ?? null,
|
||||
currentUsage: 0, // Would need to query Redis for this
|
||||
source: organization.maximumDeployedQueueSize ? "override" : "default",
|
||||
queueSize: {
|
||||
name: "Max queued runs",
|
||||
description: "Maximum pending runs per individual queue in this environment",
|
||||
limit: getQueueSizeLimit(environmentType, organization),
|
||||
currentUsage: currentQueueSize,
|
||||
source: getQueueSizeLimitSource(environmentType, organization),
|
||||
isUpgradable: true,
|
||||
},
|
||||
metricDashboards:
|
||||
metricDashboardsLimit !== null
|
||||
|
||||
@@ -509,8 +509,7 @@ export class SpanPresenter extends BasePresenter {
|
||||
taskIdentifier: true,
|
||||
spanId: true,
|
||||
createdAt: true,
|
||||
number: true,
|
||||
taskVersion: true,
|
||||
status: true,
|
||||
},
|
||||
where: {
|
||||
parentSpanId: spanId,
|
||||
@@ -630,6 +629,41 @@ export class SpanPresenter extends BasePresenter {
|
||||
},
|
||||
};
|
||||
}
|
||||
case "input-stream": {
|
||||
if (!span.entity.id) {
|
||||
logger.error(`SpanPresenter: No input stream id`, {
|
||||
spanId,
|
||||
inputStreamId: span.entity.id,
|
||||
});
|
||||
return { ...data, entity: null };
|
||||
}
|
||||
|
||||
const [runId, streamId] = span.entity.id.split(":");
|
||||
|
||||
if (!runId || !streamId) {
|
||||
logger.error(`SpanPresenter: Invalid input stream id`, {
|
||||
spanId,
|
||||
inputStreamId: span.entity.id,
|
||||
});
|
||||
return { ...data, entity: null };
|
||||
}
|
||||
|
||||
// Translate user-facing stream ID to internal S2 stream name
|
||||
const s2StreamKey = `$trigger.input:${streamId}`;
|
||||
|
||||
return {
|
||||
...data,
|
||||
entity: {
|
||||
type: "realtime-stream" as const,
|
||||
object: {
|
||||
runId,
|
||||
streamKey: s2StreamKey,
|
||||
displayName: streamId,
|
||||
metadata: undefined,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
default:
|
||||
return { ...data, entity: null };
|
||||
}
|
||||
|
||||
@@ -40,6 +40,8 @@ export type VercelSettingsResult = {
|
||||
customEnvironments: VercelCustomEnvironment[];
|
||||
/** Whether autoAssignCustomDomains is enabled on the Vercel project. null if unknown. */
|
||||
autoAssignCustomDomains?: boolean | null;
|
||||
/** URL to manage Vercel integration access (project sharing) on vercel.com */
|
||||
vercelManageAccessUrl?: string;
|
||||
};
|
||||
|
||||
export type VercelAvailableProject = {
|
||||
@@ -242,11 +244,12 @@ export class VercelSettingsPresenter extends BasePresenter {
|
||||
checkPreviewEnvironment(),
|
||||
getVercelProjectIntegration(),
|
||||
]).andThen(([hasOrgIntegration, isGitHubConnected, hasStagingEnvironment, hasPreviewEnvironment, connectedProject]) => {
|
||||
const fetchCustomEnvsAndProjectSettings = async (): Promise<{
|
||||
const fetchVercelData = async (): Promise<{
|
||||
customEnvironments: VercelCustomEnvironment[];
|
||||
autoAssignCustomDomains: boolean | null;
|
||||
vercelManageAccessUrl?: string;
|
||||
}> => {
|
||||
if (!connectedProject || !orgIntegration) {
|
||||
if (!orgIntegration) {
|
||||
return { customEnvironments: [], autoAssignCustomDomains: null };
|
||||
}
|
||||
const clientResult = await VercelIntegrationRepository.getVercelClient(orgIntegration);
|
||||
@@ -255,6 +258,26 @@ export class VercelSettingsPresenter extends BasePresenter {
|
||||
}
|
||||
const client = clientResult.value;
|
||||
const teamId = await VercelIntegrationRepository.getTeamIdFromIntegration(orgIntegration);
|
||||
|
||||
// Build manage access URL
|
||||
let vercelManageAccessUrl: string | undefined;
|
||||
const appSlug = env.VERCEL_INTEGRATION_APP_SLUG;
|
||||
const integrationData = orgIntegration.integrationData as Record<string, unknown> | null;
|
||||
const installationId =
|
||||
typeof integrationData?.installationId === "string"
|
||||
? integrationData.installationId
|
||||
: undefined;
|
||||
if (appSlug && installationId && teamId) {
|
||||
const teamSlugResult = await VercelIntegrationRepository.getTeamSlug(client, teamId);
|
||||
if (teamSlugResult.isOk()) {
|
||||
vercelManageAccessUrl = `https://vercel.com/${teamSlugResult.value}/~/integrations/${appSlug}/${installationId}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!connectedProject) {
|
||||
return { customEnvironments: [], autoAssignCustomDomains: null, vercelManageAccessUrl };
|
||||
}
|
||||
|
||||
const [customEnvsResult, autoAssignResult] = await Promise.all([
|
||||
VercelIntegrationRepository.getVercelCustomEnvironments(
|
||||
client,
|
||||
@@ -270,13 +293,14 @@ export class VercelSettingsPresenter extends BasePresenter {
|
||||
return {
|
||||
customEnvironments: customEnvsResult.isOk() ? customEnvsResult.value : [],
|
||||
autoAssignCustomDomains: autoAssignResult.isOk() ? autoAssignResult.value : null,
|
||||
vercelManageAccessUrl,
|
||||
};
|
||||
};
|
||||
|
||||
return fromPromise(
|
||||
fetchCustomEnvsAndProjectSettings(),
|
||||
fetchVercelData(),
|
||||
(error) => ({ type: "other" as const, cause: error })
|
||||
).map(({ customEnvironments, autoAssignCustomDomains }) => ({
|
||||
).map(({ customEnvironments, autoAssignCustomDomains, vercelManageAccessUrl }) => ({
|
||||
enabled: true,
|
||||
hasOrgIntegration,
|
||||
authInvalid: false,
|
||||
@@ -286,6 +310,7 @@ export class VercelSettingsPresenter extends BasePresenter {
|
||||
hasPreviewEnvironment,
|
||||
customEnvironments,
|
||||
autoAssignCustomDomains,
|
||||
vercelManageAccessUrl,
|
||||
} as VercelSettingsResult));
|
||||
}).mapErr((error) => {
|
||||
// Log the error and return a safe fallback
|
||||
|
||||
+33
-21
@@ -1,36 +1,37 @@
|
||||
import { type LoaderFunctionArgs } from "@remix-run/node";
|
||||
import type { TaskTriggerSource } from "@trigger.dev/database";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import ReactGridLayout from "react-grid-layout";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { LogsTaskFilter } from "~/components/logs/LogsTaskFilter";
|
||||
import { type WidgetData } from "~/components/metrics/QueryWidget";
|
||||
import { QueuesFilter } from "~/components/metrics/QueuesFilter";
|
||||
import { ScopeFilter } from "~/components/metrics/ScopeFilter";
|
||||
import { TitleWidget } from "~/components/metrics/TitleWidget";
|
||||
import { CreateDashboardPageButton } from "~/components/navigation/DashboardDialogs";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { TimeFilter } from "~/components/runs/v3/SharedFilters";
|
||||
import { $replica } from "~/db.server";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { getAllTaskIdentifiers } from "~/models/task.server";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
|
||||
import {
|
||||
type LayoutItem,
|
||||
type Widget,
|
||||
MetricDashboardPresenter,
|
||||
} from "~/presenters/v3/MetricDashboardPresenter.server";
|
||||
import { type LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { z } from "zod";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import ReactGridLayout from "react-grid-layout";
|
||||
import { MetricWidget } from "../resources.metric";
|
||||
import { TitleWidget } from "~/components/metrics/TitleWidget";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { TimeFilter } from "~/components/runs/v3/SharedFilters";
|
||||
import { LogsTaskFilter } from "~/components/logs/LogsTaskFilter";
|
||||
import { ScopeFilter } from "~/components/metrics/ScopeFilter";
|
||||
import { QueuesFilter } from "~/components/metrics/QueuesFilter";
|
||||
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import { type WidgetData } from "~/components/metrics/QueryWidget";
|
||||
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
|
||||
import { QueryScopeSchema } from "~/v3/querySchemas";
|
||||
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
|
||||
import { MetricWidget } from "../resources.metric";
|
||||
|
||||
const ParamSchema = EnvironmentParamSchema.extend({
|
||||
dashboardKey: z.string(),
|
||||
@@ -82,10 +83,21 @@ export default function Page() {
|
||||
possibleTasks,
|
||||
} = useTypedLoaderData<typeof loader>();
|
||||
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
<PageTitle title={title} />
|
||||
<PageAccessories>
|
||||
<CreateDashboardPageButton
|
||||
organization={organization}
|
||||
project={project}
|
||||
environment={environment}
|
||||
/>
|
||||
</PageAccessories>
|
||||
</NavBar>
|
||||
<PageBody scrollable={false}>
|
||||
<div className="h-full">
|
||||
+1
-1
@@ -55,7 +55,7 @@ import {
|
||||
v3BillingPath,
|
||||
v3BuiltInDashboardPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { MetricDashboard } from "../_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.$dashboardKey/route";
|
||||
import { MetricDashboard } from "../_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardKey/route";
|
||||
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
|
||||
import { Type } from "lucide-react";
|
||||
|
||||
+16
-8
@@ -15,6 +15,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { ExitIcon } from "~/assets/icons/ExitIcon";
|
||||
import { GitMetadata } from "~/components/GitMetadata";
|
||||
import { VercelLink } from "~/components/integrations/VercelLink";
|
||||
import { RuntimeIcon } from "~/components/RuntimeIcon";
|
||||
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
|
||||
import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
|
||||
@@ -197,17 +198,14 @@ export default function Page() {
|
||||
|
||||
const readSession = await stream.readSession(
|
||||
{
|
||||
seq_num: 0,
|
||||
wait: 60,
|
||||
as: "bytes",
|
||||
start: { from: { seqNum: 0 }, clamp: true },
|
||||
stop: { waitSecs: 60 },
|
||||
},
|
||||
{ signal: abortController.signal }
|
||||
);
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
for await (const record of readSession) {
|
||||
const decoded = decoder.decode(record.body);
|
||||
const decoded = record.body;
|
||||
const result = DeploymentEventFromString.safeParse(decoded);
|
||||
|
||||
if (!result.success) {
|
||||
@@ -216,8 +214,8 @@ export default function Page() {
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
if (record.headers) {
|
||||
for (const [nameBytes, valueBytes] of record.headers) {
|
||||
headers[decoder.decode(nameBytes)] = decoder.decode(valueBytes);
|
||||
for (const [name, value] of record.headers) {
|
||||
headers[name] = value;
|
||||
}
|
||||
}
|
||||
const level = (headers["level"]?.toLowerCase() as LogEntry["level"]) ?? "info";
|
||||
@@ -516,6 +514,16 @@ export default function Page() {
|
||||
})()}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
{deployment.vercelDeploymentUrl && (
|
||||
<Property.Item>
|
||||
<Property.Label>Linked</Property.Label>
|
||||
<Property.Value>
|
||||
<div className="-ml-1 mt-0.5 flex flex-col">
|
||||
<VercelLink vercelDeploymentUrl={deployment.vercelDeploymentUrl} />
|
||||
</div>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
)}
|
||||
</Property.Table>
|
||||
</div>
|
||||
|
||||
|
||||
+11
-18
@@ -19,7 +19,7 @@ import { useEffect } from "react";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { PromoteIcon } from "~/assets/icons/PromoteIcon";
|
||||
import { VercelLogo } from "~/components/integrations/VercelLogo";
|
||||
import { VercelLink } from "~/components/integrations/VercelLink";
|
||||
import { DeploymentsNone, DeploymentsNoneDev } from "~/components/BlankStatePanels";
|
||||
import { OctoKitty } from "~/components/GitHubLoginButton";
|
||||
import { GitMetadata } from "~/components/GitMetadata";
|
||||
@@ -56,7 +56,6 @@ import {
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
} from "~/components/primitives/Table";
|
||||
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import {
|
||||
DeploymentStatus,
|
||||
deploymentStatusDescription,
|
||||
@@ -76,7 +75,7 @@ import {
|
||||
EnvironmentParamSchema,
|
||||
docsPath,
|
||||
v3DeploymentPath,
|
||||
v3ProjectSettingsPath,
|
||||
v3ProjectSettingsIntegrationsPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { createSearchParams } from "~/utils/searchParams";
|
||||
import { compareDeploymentVersions } from "~/v3/utils/deploymentVersions";
|
||||
@@ -314,20 +313,14 @@ export default function Page() {
|
||||
{hasVercelIntegration && (
|
||||
<TableCell isSelected={isSelected}>
|
||||
{deployment.vercelDeploymentUrl ? (
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<a
|
||||
href={deployment.vercelDeploymentUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="flex items-center text-text-dimmed transition-colors hover:text-text-bright"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<VercelLogo className="size-3.5" />
|
||||
</a>
|
||||
}
|
||||
content="View on Vercel"
|
||||
/>
|
||||
<div
|
||||
className="-ml-1 flex items-center"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<VercelLink
|
||||
vercelDeploymentUrl={deployment.vercelDeploymentUrl}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
"–"
|
||||
)}
|
||||
@@ -377,7 +370,7 @@ export default function Page() {
|
||||
<LinkButton
|
||||
variant="minimal/small"
|
||||
LeadingIcon={CogIcon}
|
||||
to={v3ProjectSettingsPath(organization, project, environment)}
|
||||
to={v3ProjectSettingsIntegrationsPath(organization, project, environment)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
+9
-6
@@ -82,6 +82,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
organizationId: project.organizationId,
|
||||
projectId: project.id,
|
||||
environmentId: environment.id,
|
||||
environmentType: environment.type,
|
||||
environmentApiKey: environment.apiKey,
|
||||
})
|
||||
);
|
||||
@@ -507,9 +508,8 @@ function QuotasSection({
|
||||
// Include batch processing concurrency
|
||||
quotaRows.push(quotas.batchProcessingConcurrency);
|
||||
|
||||
// Add queue size quotas if set
|
||||
if (quotas.devQueueSize.limit !== null) quotaRows.push(quotas.devQueueSize);
|
||||
if (quotas.deployedQueueSize.limit !== null) quotaRows.push(quotas.deployedQueueSize);
|
||||
// Add queue size quota if set
|
||||
if (quotas.queueSize.limit !== null) quotaRows.push(quotas.queueSize);
|
||||
|
||||
// Metric & query quotas
|
||||
if (quotas.metricDashboards) quotaRows.push(quotas.metricDashboards);
|
||||
@@ -565,8 +565,11 @@ function QuotaRow({
|
||||
const isDurationQuota = quota.name === "Log retention" || quota.name === "Query period";
|
||||
const isPerItemQuota = quota.name === "Charts per dashboard";
|
||||
const isRetentionQuota = isDurationQuota || isPerItemQuota;
|
||||
const isQueueSizeQuota = quota.name === "Max queued runs";
|
||||
const hideCurrentUsage = isRetentionQuota || isQueueSizeQuota;
|
||||
|
||||
const percentage =
|
||||
!isRetentionQuota && quota.limit && quota.limit > 0 ? quota.currentUsage / quota.limit : null;
|
||||
!hideCurrentUsage && quota.limit && quota.limit > 0 ? quota.currentUsage / quota.limit : null;
|
||||
|
||||
// Special handling for duration-based quotas (Log retention, Query period)
|
||||
if (isDurationQuota) {
|
||||
@@ -667,10 +670,10 @@ function QuotaRow({
|
||||
alignment="right"
|
||||
className={cn(
|
||||
"tabular-nums",
|
||||
isRetentionQuota ? "text-text-dimmed" : getUsageColorClass(percentage, "usage")
|
||||
hideCurrentUsage ? "text-text-dimmed" : getUsageColorClass(percentage, "usage")
|
||||
)}
|
||||
>
|
||||
{isRetentionQuota ? "–" : formatNumber(quota.currentUsage)}
|
||||
{hideCurrentUsage ? "–" : formatNumber(quota.currentUsage)}
|
||||
</TableCell>
|
||||
<TableCell alignment="right">
|
||||
<SourceBadge source={quota.source} />
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import { redirect, type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { EnvironmentParamSchema, v3BuiltInDashboardPath } from "~/utils/pathBuilder";
|
||||
|
||||
const ParamSchema = EnvironmentParamSchema.extend({
|
||||
dashboardKey: z.string(),
|
||||
});
|
||||
|
||||
export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
const { organizationSlug, projectParam, envParam, dashboardKey } = ParamSchema.parse(params);
|
||||
return redirect(
|
||||
v3BuiltInDashboardPath(
|
||||
{ slug: organizationSlug },
|
||||
{ slug: projectParam },
|
||||
{ slug: envParam },
|
||||
dashboardKey
|
||||
),
|
||||
301
|
||||
);
|
||||
};
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import { redirect, type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { EnvironmentParamSchema, v3CustomDashboardPath } from "~/utils/pathBuilder";
|
||||
|
||||
const ParamSchema = EnvironmentParamSchema.extend({
|
||||
dashboardId: z.string(),
|
||||
});
|
||||
|
||||
export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
const { organizationSlug, projectParam, envParam, dashboardId } = ParamSchema.parse(params);
|
||||
return redirect(
|
||||
v3CustomDashboardPath(
|
||||
{ slug: organizationSlug },
|
||||
{ slug: projectParam },
|
||||
{ slug: envParam },
|
||||
{ friendlyId: dashboardId }
|
||||
),
|
||||
301
|
||||
);
|
||||
};
|
||||
+16
-7
@@ -345,7 +345,7 @@ export default function Page() {
|
||||
<BigNumber
|
||||
title="Queued"
|
||||
value={environment.queued}
|
||||
suffix={env.paused && environment.queued > 0 ? "paused" : undefined}
|
||||
suffix={env.paused ? <span className="text-warning">paused</span> : undefined}
|
||||
animate
|
||||
accessory={
|
||||
<div className="flex items-start gap-1">
|
||||
@@ -364,7 +364,7 @@ export default function Page() {
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
valueClassName={cn(env.paused ? "text-warning" : undefined, "tabular-nums")}
|
||||
valueClassName={env.paused ? "text-warning tabular-nums" : "tabular-nums"}
|
||||
compactThreshold={1000000}
|
||||
/>
|
||||
<BigNumber
|
||||
@@ -509,7 +509,10 @@ export default function Page() {
|
||||
{queues.length > 0 ? (
|
||||
queues.map((queue) => {
|
||||
const limit = queue.concurrencyLimit ?? environment.concurrencyLimit;
|
||||
const isAtLimit = queue.running >= limit;
|
||||
const isAtConcurrencyLimit = queue.running >= limit;
|
||||
const isAtQueueLimit =
|
||||
environment.queueSizeLimit !== null &&
|
||||
queue.queued >= environment.queueSizeLimit;
|
||||
const queueFilterableName = `${queue.type === "task" ? "task/" : ""}${
|
||||
queue.name
|
||||
}`;
|
||||
@@ -535,7 +538,12 @@ export default function Page() {
|
||||
Paused
|
||||
</Badge>
|
||||
) : null}
|
||||
{isAtLimit ? (
|
||||
{isAtQueueLimit ? (
|
||||
<Badge variant="extra-small" className="text-error">
|
||||
At queue limit
|
||||
</Badge>
|
||||
) : null}
|
||||
{isAtConcurrencyLimit ? (
|
||||
<Badge variant="extra-small" className="text-warning">
|
||||
At concurrency limit
|
||||
</Badge>
|
||||
@@ -546,7 +554,8 @@ export default function Page() {
|
||||
alignment="right"
|
||||
className={cn(
|
||||
"w-[1%] pl-16 tabular-nums",
|
||||
queue.paused ? "opacity-50" : undefined
|
||||
queue.paused ? "opacity-50" : undefined,
|
||||
isAtQueueLimit && "text-error"
|
||||
)}
|
||||
>
|
||||
{queue.queued}
|
||||
@@ -557,7 +566,7 @@ export default function Page() {
|
||||
"w-[1%] pl-16 tabular-nums",
|
||||
queue.paused ? "opacity-50" : undefined,
|
||||
queue.running > 0 && "text-text-bright",
|
||||
isAtLimit && "text-warning"
|
||||
isAtConcurrencyLimit && "text-warning"
|
||||
)}
|
||||
>
|
||||
{queue.running}
|
||||
@@ -577,7 +586,7 @@ export default function Page() {
|
||||
className={cn(
|
||||
"w-[1%] pl-16",
|
||||
queue.paused ? "opacity-50" : undefined,
|
||||
isAtLimit && "text-warning",
|
||||
isAtConcurrencyLimit && "text-warning",
|
||||
queue.concurrency?.overriddenAt && "font-medium text-text-bright"
|
||||
)}
|
||||
>
|
||||
|
||||
+293
@@ -0,0 +1,293 @@
|
||||
import { conform, useForm } from "@conform-to/react";
|
||||
import { parse } from "@conform-to/zod";
|
||||
import { ExclamationTriangleIcon, FolderIcon, TrashIcon } from "@heroicons/react/20/solid";
|
||||
import { Form, useActionData, useNavigation } from "@remix-run/react";
|
||||
import { type ActionFunction, json } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { InlineCode } from "~/components/code/InlineCode";
|
||||
import { MainHorizontallyCenteredContainer } from "~/components/layout/AppLayout";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { ClipboardField } from "~/components/primitives/ClipboardField";
|
||||
import { Fieldset } from "~/components/primitives/Fieldset";
|
||||
import { FormButtons } from "~/components/primitives/FormButtons";
|
||||
import { FormError } from "~/components/primitives/FormError";
|
||||
import { Header2 } from "~/components/primitives/Headers";
|
||||
import { Hint } from "~/components/primitives/Hint";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { InputGroup } from "~/components/primitives/InputGroup";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { SpinnerWhite } from "~/components/primitives/Spinner";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import {
|
||||
redirectWithErrorMessage,
|
||||
redirectWithSuccessMessage,
|
||||
} from "~/models/message.server";
|
||||
import { ProjectSettingsService } from "~/services/projectSettings.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { organizationPath, v3ProjectPath } from "~/utils/pathBuilder";
|
||||
import { useState } from "react";
|
||||
|
||||
function createSchema(
|
||||
constraints: {
|
||||
getSlugMatch?: (slug: string) => { isMatch: boolean; projectSlug: string };
|
||||
} = {}
|
||||
) {
|
||||
return z.discriminatedUnion("action", [
|
||||
z.object({
|
||||
action: z.literal("rename"),
|
||||
projectName: z.string().min(3, "Project name must have at least 3 characters").max(50),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal("delete"),
|
||||
projectSlug: z.string().superRefine((slug, ctx) => {
|
||||
if (constraints.getSlugMatch === undefined) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: conform.VALIDATION_UNDEFINED,
|
||||
});
|
||||
} else {
|
||||
const { isMatch, projectSlug } = constraints.getSlugMatch(slug);
|
||||
if (isMatch) {
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `The slug must match ${projectSlug}`,
|
||||
});
|
||||
}
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam } = params;
|
||||
if (!organizationSlug || !projectParam) {
|
||||
return json({ errors: { body: "organizationSlug and projectParam are required" } }, { status: 400 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
|
||||
const schema = createSchema({
|
||||
getSlugMatch: (slug) => {
|
||||
return { isMatch: slug === projectParam, projectSlug: projectParam };
|
||||
},
|
||||
});
|
||||
const submission = parse(formData, { schema });
|
||||
|
||||
if (!submission.value || submission.intent !== "submit") {
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
const projectSettingsService = new ProjectSettingsService();
|
||||
const membershipResultOrFail = await projectSettingsService.verifyProjectMembership(
|
||||
organizationSlug,
|
||||
projectParam,
|
||||
userId
|
||||
);
|
||||
|
||||
if (membershipResultOrFail.isErr()) {
|
||||
return json({ errors: { body: membershipResultOrFail.error.type } }, { status: 404 });
|
||||
}
|
||||
|
||||
const { projectId } = membershipResultOrFail.value;
|
||||
|
||||
switch (submission.value.action) {
|
||||
case "rename": {
|
||||
const resultOrFail = await projectSettingsService.renameProject(
|
||||
projectId,
|
||||
submission.value.projectName
|
||||
);
|
||||
|
||||
if (resultOrFail.isErr()) {
|
||||
switch (resultOrFail.error.type) {
|
||||
case "other":
|
||||
default: {
|
||||
resultOrFail.error.type satisfies "other";
|
||||
|
||||
logger.error("Failed to rename project", {
|
||||
error: resultOrFail.error,
|
||||
});
|
||||
return json({ errors: { body: "Failed to rename project" } }, { status: 400 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return redirectWithSuccessMessage(
|
||||
v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }),
|
||||
request,
|
||||
`Project renamed to ${submission.value.projectName}`
|
||||
);
|
||||
}
|
||||
case "delete": {
|
||||
const resultOrFail = await projectSettingsService.deleteProject(projectId, userId);
|
||||
|
||||
if (resultOrFail.isErr()) {
|
||||
switch (resultOrFail.error.type) {
|
||||
case "other":
|
||||
default: {
|
||||
resultOrFail.error.type satisfies "other";
|
||||
|
||||
logger.error("Failed to delete project", {
|
||||
error: resultOrFail.error,
|
||||
});
|
||||
return redirectWithErrorMessage(
|
||||
v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }),
|
||||
request,
|
||||
`Project ${projectParam} could not be deleted`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return redirectWithSuccessMessage(
|
||||
organizationPath({ slug: organizationSlug }),
|
||||
request,
|
||||
"Project deleted"
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default function GeneralSettingsPage() {
|
||||
const project = useProject();
|
||||
const lastSubmission = useActionData();
|
||||
const navigation = useNavigation();
|
||||
|
||||
const [hasRenameFormChanges, setHasRenameFormChanges] = useState(false);
|
||||
|
||||
const [renameForm, { projectName }] = useForm({
|
||||
id: "rename-project",
|
||||
// TODO: type this
|
||||
lastSubmission: lastSubmission as any,
|
||||
shouldRevalidate: "onSubmit",
|
||||
onValidate({ formData }) {
|
||||
return parse(formData, {
|
||||
schema: createSchema(),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const isRenameLoading =
|
||||
navigation.formData?.get("action") === "rename" &&
|
||||
(navigation.state === "submitting" || navigation.state === "loading");
|
||||
|
||||
const [deleteForm, { projectSlug }] = useForm({
|
||||
id: "delete-project",
|
||||
// TODO: type this
|
||||
lastSubmission: lastSubmission as any,
|
||||
shouldValidate: "onInput",
|
||||
shouldRevalidate: "onSubmit",
|
||||
onValidate({ formData }) {
|
||||
return parse(formData, {
|
||||
schema: createSchema({
|
||||
getSlugMatch: (slug) => ({ isMatch: slug === project.slug, projectSlug: project.slug }),
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const isDeleteLoading =
|
||||
navigation.formData?.get("action") === "delete" &&
|
||||
(navigation.state === "submitting" || navigation.state === "loading");
|
||||
|
||||
const [deleteInputValue, setDeleteInputValue] = useState("");
|
||||
|
||||
return (
|
||||
<MainHorizontallyCenteredContainer className="md:mt-6">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<Header2 spacing>General</Header2>
|
||||
<div className="w-full rounded-sm border border-grid-dimmed p-4">
|
||||
<Fieldset className="mb-5">
|
||||
<InputGroup fullWidth>
|
||||
<Label>Project ref</Label>
|
||||
<ClipboardField value={project.externalRef} variant={"secondary/medium"} />
|
||||
<Hint>
|
||||
This goes in your{" "}
|
||||
<InlineCode variant="extra-extra-small">trigger.config</InlineCode> file.
|
||||
</Hint>
|
||||
</InputGroup>
|
||||
</Fieldset>
|
||||
<Form method="post" {...renameForm.props}>
|
||||
<Fieldset>
|
||||
<InputGroup fullWidth>
|
||||
<Label htmlFor={projectName.id}>Project name</Label>
|
||||
<Input
|
||||
{...conform.input(projectName, { type: "text" })}
|
||||
defaultValue={project.name}
|
||||
placeholder="Project name"
|
||||
icon={FolderIcon}
|
||||
autoFocus
|
||||
onChange={(e) => {
|
||||
setHasRenameFormChanges(e.target.value !== project.name);
|
||||
}}
|
||||
/>
|
||||
<FormError id={projectName.errorId}>{projectName.error}</FormError>
|
||||
</InputGroup>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button
|
||||
type="submit"
|
||||
name="action"
|
||||
value="rename"
|
||||
variant={"secondary/small"}
|
||||
disabled={isRenameLoading || !hasRenameFormChanges}
|
||||
LeadingIcon={isRenameLoading ? SpinnerWhite : undefined}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Fieldset>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Header2 spacing>Danger zone</Header2>
|
||||
<div className="w-full rounded-sm border border-rose-500/40 p-4">
|
||||
<Form method="post" {...deleteForm.props}>
|
||||
<Fieldset>
|
||||
<InputGroup fullWidth>
|
||||
<Label htmlFor={projectSlug.id}>Delete project</Label>
|
||||
<Input
|
||||
{...conform.input(projectSlug, { type: "text" })}
|
||||
placeholder="Your project slug"
|
||||
icon={ExclamationTriangleIcon}
|
||||
onChange={(e) => setDeleteInputValue(e.target.value)}
|
||||
/>
|
||||
<FormError id={projectSlug.errorId}>{projectSlug.error}</FormError>
|
||||
<FormError>{deleteForm.error}</FormError>
|
||||
<Hint>
|
||||
This change is irreversible, so please be certain. Type in the Project slug
|
||||
<InlineCode variant="extra-small">{project.slug}</InlineCode> and then press
|
||||
Delete.
|
||||
</Hint>
|
||||
</InputGroup>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button
|
||||
type="submit"
|
||||
name="action"
|
||||
value="delete"
|
||||
variant={"danger/small"}
|
||||
LeadingIcon={isDeleteLoading ? SpinnerWhite : TrashIcon}
|
||||
leadingIconClassName="text-white"
|
||||
disabled={isDeleteLoading || deleteInputValue !== project.slug}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Fieldset>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MainHorizontallyCenteredContainer>
|
||||
);
|
||||
}
|
||||
+500
@@ -0,0 +1,500 @@
|
||||
import { conform, useForm } from "@conform-to/react";
|
||||
import { parse } from "@conform-to/zod";
|
||||
import { Form, useActionData, useNavigation } from "@remix-run/react";
|
||||
import { type ActionFunction, type LoaderFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import { typedjson, useTypedLoaderData, useTypedFetcher } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { MainHorizontallyCenteredContainer } from "~/components/layout/AppLayout";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { CheckboxWithLabel } from "~/components/primitives/Checkbox";
|
||||
import { Fieldset } from "~/components/primitives/Fieldset";
|
||||
import { FormButtons } from "~/components/primitives/FormButtons";
|
||||
import { FormError } from "~/components/primitives/FormError";
|
||||
import { Header2 } from "~/components/primitives/Headers";
|
||||
import { Hint } from "~/components/primitives/Hint";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { InputGroup } from "~/components/primitives/InputGroup";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { SpinnerWhite } from "~/components/primitives/Spinner";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import {
|
||||
redirectBackWithErrorMessage,
|
||||
redirectBackWithSuccessMessage,
|
||||
} from "~/models/message.server";
|
||||
import { ProjectSettingsService } from "~/services/projectSettings.server";
|
||||
import { ProjectSettingsPresenter } from "~/services/projectSettingsPresenter.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { EnvironmentParamSchema, v3BillingPath, vercelResourcePath } from "~/utils/pathBuilder";
|
||||
import React, { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { useSearchParams } from "@remix-run/react";
|
||||
import { type BuildSettings } from "~/v3/buildSettings";
|
||||
import { GitHubSettingsPanel } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github";
|
||||
import {
|
||||
VercelSettingsPanel,
|
||||
VercelOnboardingModal,
|
||||
} from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel";
|
||||
import type { loader as vercelLoader } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel";
|
||||
import { OrgIntegrationRepository } from "~/models/orgIntegration.server";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { projectParam, organizationSlug } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
const projectSettingsPresenter = new ProjectSettingsPresenter();
|
||||
const resultOrFail = await projectSettingsPresenter.getProjectSettings(
|
||||
organizationSlug,
|
||||
projectParam,
|
||||
userId
|
||||
);
|
||||
|
||||
if (resultOrFail.isErr()) {
|
||||
switch (resultOrFail.error.type) {
|
||||
case "project_not_found": {
|
||||
throw new Response(undefined, {
|
||||
status: 404,
|
||||
statusText: "Project not found",
|
||||
});
|
||||
}
|
||||
case "other":
|
||||
default: {
|
||||
resultOrFail.error.type satisfies "other";
|
||||
|
||||
logger.error("Failed loading project settings", {
|
||||
error: resultOrFail.error,
|
||||
});
|
||||
throw new Response(undefined, {
|
||||
status: 400,
|
||||
statusText: "Something went wrong, please try again!",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { gitHubApp, buildSettings } = resultOrFail.value;
|
||||
|
||||
return typedjson({
|
||||
githubAppEnabled: gitHubApp.enabled,
|
||||
buildSettings,
|
||||
vercelIntegrationEnabled: OrgIntegrationRepository.isVercelSupported,
|
||||
});
|
||||
};
|
||||
|
||||
const UpdateBuildSettingsFormSchema = z.object({
|
||||
action: z.literal("update-build-settings"),
|
||||
triggerConfigFilePath: z
|
||||
.string()
|
||||
.trim()
|
||||
.optional()
|
||||
.transform((val) => (val ? val.replace(/^\/+/, "") : val))
|
||||
.refine((val) => !val || val.length <= 255, {
|
||||
message: "Config file path must not exceed 255 characters",
|
||||
}),
|
||||
installCommand: z
|
||||
.string()
|
||||
.trim()
|
||||
.optional()
|
||||
.refine((val) => !val || !val.includes("\n"), {
|
||||
message: "Install command must be a single line",
|
||||
})
|
||||
.refine((val) => !val || val.length <= 500, {
|
||||
message: "Install command must not exceed 500 characters",
|
||||
}),
|
||||
preBuildCommand: z
|
||||
.string()
|
||||
.trim()
|
||||
.optional()
|
||||
.refine((val) => !val || !val.includes("\n"), {
|
||||
message: "Pre-build command must be a single line",
|
||||
})
|
||||
.refine((val) => !val || val.length <= 500, {
|
||||
message: "Pre-build command must not exceed 500 characters",
|
||||
}),
|
||||
useNativeBuildServer: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val) => val === "on"),
|
||||
});
|
||||
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam } = params;
|
||||
if (!organizationSlug || !projectParam) {
|
||||
return json({ errors: { body: "organizationSlug and projectParam are required" } }, { status: 400 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const submission = parse(formData, { schema: UpdateBuildSettingsFormSchema });
|
||||
|
||||
if (!submission.value || submission.intent !== "submit") {
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
const projectSettingsService = new ProjectSettingsService();
|
||||
const membershipResultOrFail = await projectSettingsService.verifyProjectMembership(
|
||||
organizationSlug,
|
||||
projectParam,
|
||||
userId
|
||||
);
|
||||
|
||||
if (membershipResultOrFail.isErr()) {
|
||||
return json({ errors: { body: membershipResultOrFail.error.type } }, { status: 404 });
|
||||
}
|
||||
|
||||
const { projectId } = membershipResultOrFail.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()) {
|
||||
switch (resultOrFail.error.type) {
|
||||
case "other":
|
||||
default: {
|
||||
resultOrFail.error.type satisfies "other";
|
||||
|
||||
logger.error("Failed to update build settings", {
|
||||
error: resultOrFail.error,
|
||||
});
|
||||
return redirectBackWithErrorMessage(request, "Failed to update build settings");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return redirectBackWithSuccessMessage(request, "Build settings updated successfully");
|
||||
};
|
||||
|
||||
export default function IntegrationsSettingsPage() {
|
||||
const { githubAppEnabled, buildSettings, vercelIntegrationEnabled } =
|
||||
useTypedLoaderData<typeof loader>();
|
||||
const project = useProject();
|
||||
const organization = useOrganization();
|
||||
const environment = useEnvironment();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
// Vercel onboarding modal state
|
||||
const hasQueryParam = searchParams.get("vercelOnboarding") === "true";
|
||||
const nextUrl = searchParams.get("next");
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const vercelFetcher = useTypedFetcher<typeof vercelLoader>();
|
||||
|
||||
// Helper to open modal and ensure query param is present
|
||||
const openVercelOnboarding = useCallback(() => {
|
||||
setIsModalOpen(true);
|
||||
// Ensure query param is present to maintain state during form submissions
|
||||
if (!hasQueryParam) {
|
||||
setSearchParams((prev) => {
|
||||
prev.set("vercelOnboarding", "true");
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
}, [hasQueryParam, setSearchParams]);
|
||||
|
||||
const closeVercelOnboarding = useCallback(() => {
|
||||
// Remove query param if present
|
||||
if (hasQueryParam) {
|
||||
setSearchParams((prev) => {
|
||||
prev.delete("vercelOnboarding");
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
// Close modal
|
||||
setIsModalOpen(false);
|
||||
}, [hasQueryParam, setSearchParams]);
|
||||
|
||||
// When query param is present, handle modal opening
|
||||
// Note: We don't close the modal based on data state during onboarding - only when explicitly closed
|
||||
useEffect(() => {
|
||||
if (hasQueryParam && vercelIntegrationEnabled) {
|
||||
// Ensure query param is present and modal is open
|
||||
if (vercelFetcher.data?.onboardingData && vercelFetcher.state === "idle") {
|
||||
// Data is loaded, ensure modal is open (query param takes precedence)
|
||||
if (!isModalOpen) {
|
||||
openVercelOnboarding();
|
||||
}
|
||||
} else if (vercelFetcher.state === "idle" && vercelFetcher.data === undefined) {
|
||||
// Load onboarding data
|
||||
vercelFetcher.load(
|
||||
`${vercelResourcePath(organization.slug, project.slug, environment.slug)}?vercelOnboarding=true`
|
||||
);
|
||||
}
|
||||
} else if (!hasQueryParam && isModalOpen) {
|
||||
// Query param removed but modal is open, close modal
|
||||
setIsModalOpen(false);
|
||||
}
|
||||
}, [hasQueryParam, vercelIntegrationEnabled, organization.slug, project.slug, environment.slug, vercelFetcher.data, vercelFetcher.state, isModalOpen, openVercelOnboarding]);
|
||||
|
||||
// Ensure modal stays open when query param is present (even after data reloads)
|
||||
// This is a safeguard to prevent the modal from closing during form submissions
|
||||
useEffect(() => {
|
||||
if (hasQueryParam && !isModalOpen) {
|
||||
// Query param is present but modal is closed, open it
|
||||
// This ensures the modal stays open during the onboarding flow
|
||||
openVercelOnboarding();
|
||||
}
|
||||
}, [hasQueryParam, isModalOpen, openVercelOnboarding]);
|
||||
|
||||
// When data finishes loading (from query param), ensure modal is open
|
||||
useEffect(() => {
|
||||
if (hasQueryParam && vercelFetcher.data?.onboardingData && vercelFetcher.state === "idle") {
|
||||
// Data loaded and query param is present, ensure modal is open
|
||||
if (!isModalOpen) {
|
||||
openVercelOnboarding();
|
||||
}
|
||||
}
|
||||
}, [hasQueryParam, vercelFetcher.data, vercelFetcher.state, isModalOpen, openVercelOnboarding]);
|
||||
|
||||
// Track if we're waiting for data from button click (not query param)
|
||||
const waitingForButtonClickRef = useRef(false);
|
||||
|
||||
// Handle opening modal from button click (without query param)
|
||||
const handleOpenVercelModal = useCallback(() => {
|
||||
// Add query param to maintain state during form submissions
|
||||
if (!hasQueryParam) {
|
||||
setSearchParams((prev) => {
|
||||
prev.set("vercelOnboarding", "true");
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
|
||||
if (vercelFetcher.data && vercelFetcher.data.onboardingData) {
|
||||
// Data already loaded, open modal immediately
|
||||
openVercelOnboarding();
|
||||
} else {
|
||||
// Need to load data first, mark that we're waiting for button click
|
||||
waitingForButtonClickRef.current = true;
|
||||
vercelFetcher.load(
|
||||
`${vercelResourcePath(organization.slug, project.slug, environment.slug)}?vercelOnboarding=true`
|
||||
);
|
||||
}
|
||||
}, [organization.slug, project.slug, environment.slug, vercelFetcher, setSearchParams, hasQueryParam, openVercelOnboarding]);
|
||||
|
||||
// When data loads from button click, open modal
|
||||
useEffect(() => {
|
||||
if (waitingForButtonClickRef.current && vercelFetcher.data?.onboardingData && vercelFetcher.state === "idle") {
|
||||
// Data loaded from button click, open modal and ensure query param is present
|
||||
waitingForButtonClickRef.current = false;
|
||||
openVercelOnboarding();
|
||||
}
|
||||
}, [vercelFetcher.data, vercelFetcher.state, openVercelOnboarding]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MainHorizontallyCenteredContainer className="md:mt-6">
|
||||
<div className="flex flex-col gap-6">
|
||||
{githubAppEnabled && (
|
||||
<React.Fragment>
|
||||
<div>
|
||||
<Header2 spacing>Git settings</Header2>
|
||||
<div className="w-full rounded-sm border border-grid-dimmed p-4">
|
||||
<GitHubSettingsPanel
|
||||
organizationSlug={organization.slug}
|
||||
projectSlug={project.slug}
|
||||
environmentSlug={environment.slug}
|
||||
billingPath={v3BillingPath({ slug: organization.slug })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{vercelIntegrationEnabled && (
|
||||
<div>
|
||||
<Header2 spacing>Vercel integration</Header2>
|
||||
<div className="w-full rounded-sm border border-grid-dimmed p-4">
|
||||
<VercelSettingsPanel
|
||||
organizationSlug={organization.slug}
|
||||
projectSlug={project.slug}
|
||||
environmentSlug={environment.slug}
|
||||
onOpenVercelModal={handleOpenVercelModal}
|
||||
isLoadingVercelData={vercelFetcher.state === "loading" || vercelFetcher.state === "submitting"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Header2 spacing>Build settings</Header2>
|
||||
<div className="w-full rounded-sm border border-grid-dimmed p-4">
|
||||
<BuildSettingsForm buildSettings={buildSettings ?? {}} />
|
||||
</div>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</div>
|
||||
</MainHorizontallyCenteredContainer>
|
||||
|
||||
{/* Vercel Onboarding Modal */}
|
||||
{vercelIntegrationEnabled && (
|
||||
<VercelOnboardingModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={closeVercelOnboarding}
|
||||
onboardingData={vercelFetcher.data?.onboardingData ?? null}
|
||||
organizationSlug={organization.slug}
|
||||
projectSlug={project.slug}
|
||||
environmentSlug={environment.slug}
|
||||
hasStagingEnvironment={vercelFetcher.data?.hasStagingEnvironment ?? false}
|
||||
hasPreviewEnvironment={vercelFetcher.data?.hasPreviewEnvironment ?? false}
|
||||
hasOrgIntegration={vercelFetcher.data?.hasOrgIntegration ?? false}
|
||||
nextUrl={nextUrl ?? undefined}
|
||||
vercelManageAccessUrl={vercelFetcher.data?.vercelManageAccessUrl}
|
||||
onDataReload={(vercelEnvironmentId) => {
|
||||
vercelFetcher.load(
|
||||
`${vercelResourcePath(organization.slug, project.slug, environment.slug)}?vercelOnboarding=true${
|
||||
vercelEnvironmentId ? `&vercelEnvironmentId=${encodeURIComponent(vercelEnvironmentId)}` : ""
|
||||
}`
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function BuildSettingsForm({ buildSettings }: { buildSettings: BuildSettings }) {
|
||||
const lastSubmission = useActionData() as any;
|
||||
const navigation = useNavigation();
|
||||
|
||||
const [hasBuildSettingsChanges, setHasBuildSettingsChanges] = useState(false);
|
||||
const [buildSettingsValues, setBuildSettingsValues] = useState({
|
||||
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.useNativeBuildServer !== (buildSettings?.useNativeBuildServer || false);
|
||||
setHasBuildSettingsChanges(hasChanges);
|
||||
}, [buildSettingsValues, buildSettings]);
|
||||
|
||||
const [buildSettingsForm, fields] = useForm({
|
||||
id: "update-build-settings",
|
||||
lastSubmission: lastSubmission,
|
||||
shouldRevalidate: "onSubmit",
|
||||
onValidate({ formData }) {
|
||||
return parse(formData, {
|
||||
schema: UpdateBuildSettingsFormSchema,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const isBuildSettingsLoading =
|
||||
navigation.formData?.get("action") === "update-build-settings" &&
|
||||
(navigation.state === "submitting" || navigation.state === "loading");
|
||||
|
||||
return (
|
||||
<Form method="post" {...buildSettingsForm.props}>
|
||||
<Fieldset>
|
||||
<InputGroup fullWidth>
|
||||
<Label htmlFor={fields.triggerConfigFilePath.id}>Trigger config file</Label>
|
||||
<Input
|
||||
{...conform.input(fields.triggerConfigFilePath, { type: "text" })}
|
||||
defaultValue={buildSettings?.triggerConfigFilePath || ""}
|
||||
placeholder="trigger.config.ts"
|
||||
onChange={(e) => {
|
||||
setBuildSettingsValues((prev) => ({
|
||||
...prev,
|
||||
triggerConfigFilePath: e.target.value,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<Hint>
|
||||
Path to your Trigger configuration file, relative to the root directory of your repo.
|
||||
</Hint>
|
||||
<FormError id={fields.triggerConfigFilePath.errorId}>
|
||||
{fields.triggerConfigFilePath.error}
|
||||
</FormError>
|
||||
</InputGroup>
|
||||
|
||||
<InputGroup fullWidth>
|
||||
<Label htmlFor={fields.installCommand.id}>Install command</Label>
|
||||
<Input
|
||||
{...conform.input(fields.installCommand, { type: "text" })}
|
||||
defaultValue={buildSettings?.installCommand || ""}
|
||||
placeholder="e.g., `npm install`, `pnpm install`, or `bun install`"
|
||||
onChange={(e) => {
|
||||
setBuildSettingsValues((prev) => ({
|
||||
...prev,
|
||||
installCommand: e.target.value,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<Hint>
|
||||
Command to install your project dependencies. This will be run from the root directory
|
||||
of your repo. Auto-detected by default.
|
||||
</Hint>
|
||||
<FormError id={fields.installCommand.errorId}>{fields.installCommand.error}</FormError>
|
||||
</InputGroup>
|
||||
<InputGroup fullWidth>
|
||||
<Label htmlFor={fields.preBuildCommand.id}>Pre-build command</Label>
|
||||
<Input
|
||||
{...conform.input(fields.preBuildCommand, { type: "text" })}
|
||||
defaultValue={buildSettings?.preBuildCommand || ""}
|
||||
placeholder="e.g., `npm run prisma:generate`"
|
||||
onChange={(e) => {
|
||||
setBuildSettingsValues((prev) => ({
|
||||
...prev,
|
||||
preBuildCommand: e.target.value,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<Hint>
|
||||
Any command that needs to run before we build and deploy your project. This will be run
|
||||
from the root directory of your repo.
|
||||
</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={
|
||||
<Button
|
||||
type="submit"
|
||||
name="action"
|
||||
value="update-build-settings"
|
||||
variant="secondary/small"
|
||||
disabled={isBuildSettingsLoading || !hasBuildSettingsChanges}
|
||||
LeadingIcon={isBuildSettingsLoading ? SpinnerWhite : undefined}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Fieldset>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
+20
-727
@@ -1,57 +1,13 @@
|
||||
import { conform, useForm } from "@conform-to/react";
|
||||
import { parse } from "@conform-to/zod";
|
||||
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 {
|
||||
MainHorizontallyCenteredContainer,
|
||||
PageBody,
|
||||
PageContainer,
|
||||
} from "~/components/layout/AppLayout";
|
||||
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";
|
||||
import { FormError } from "~/components/primitives/FormError";
|
||||
import { Header2 } from "~/components/primitives/Headers";
|
||||
import { Hint } from "~/components/primitives/Hint";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { InputGroup } from "~/components/primitives/InputGroup";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { Outlet, type MetaFunction } from "@remix-run/react";
|
||||
import { type LoaderFunctionArgs, redirect } from "@remix-run/server-runtime";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import * as Property from "~/components/primitives/PropertyTable";
|
||||
import { SpinnerWhite } from "~/components/primitives/Spinner";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import {
|
||||
redirectBackWithErrorMessage,
|
||||
redirectBackWithSuccessMessage,
|
||||
redirectWithErrorMessage,
|
||||
redirectWithSuccessMessage,
|
||||
} from "~/models/message.server";
|
||||
import { ProjectSettingsService } from "~/services/projectSettings.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { organizationPath, v3ProjectPath, EnvironmentParamSchema, v3BillingPath, vercelResourcePath } from "~/utils/pathBuilder";
|
||||
import React, { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { useSearchParams } from "@remix-run/react";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { ProjectSettingsPresenter } from "~/services/projectSettingsPresenter.server";
|
||||
import { type BuildSettings } from "~/v3/buildSettings";
|
||||
import { GitHubSettingsPanel } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github";
|
||||
import {
|
||||
VercelSettingsPanel,
|
||||
VercelOnboardingModal,
|
||||
} from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel";
|
||||
import type { loader as vercelLoader } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel";
|
||||
import { OrgIntegrationRepository } from "~/models/orgIntegration.server";
|
||||
import { useTypedFetcher } from "remix-typedjson";
|
||||
import { EnvironmentParamSchema, v3ProjectSettingsGeneralPath, v3ProjectSettingsIntegrationsPath } from "~/utils/pathBuilder";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
@@ -62,397 +18,28 @@ export const meta: MetaFunction = () => {
|
||||
};
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { projectParam, organizationSlug } = EnvironmentParamSchema.parse(params);
|
||||
await requireUserId(request);
|
||||
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
const projectSettingsPresenter = new ProjectSettingsPresenter();
|
||||
const resultOrFail = await projectSettingsPresenter.getProjectSettings(
|
||||
organizationSlug,
|
||||
projectParam,
|
||||
userId
|
||||
);
|
||||
// Redirect /settings to /settings/general (or /settings/integrations for Vercel onboarding)
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname.endsWith("/settings") || url.pathname.endsWith("/settings/")) {
|
||||
const org = { slug: organizationSlug };
|
||||
const project = { slug: projectParam };
|
||||
const env = { slug: envParam };
|
||||
|
||||
if (resultOrFail.isErr()) {
|
||||
switch (resultOrFail.error.type) {
|
||||
case "project_not_found": {
|
||||
throw new Response(undefined, {
|
||||
status: 404,
|
||||
statusText: "Project not found",
|
||||
});
|
||||
}
|
||||
case "other":
|
||||
default: {
|
||||
resultOrFail.error.type satisfies "other";
|
||||
const basePath = url.searchParams.has("vercelOnboarding")
|
||||
? v3ProjectSettingsIntegrationsPath(org, project, env)
|
||||
: v3ProjectSettingsGeneralPath(org, project, env);
|
||||
|
||||
logger.error("Failed loading project settings", {
|
||||
error: resultOrFail.error,
|
||||
});
|
||||
throw new Response(undefined, {
|
||||
status: 400,
|
||||
statusText: "Something went wrong, please try again!",
|
||||
});
|
||||
}
|
||||
}
|
||||
return redirect(`${basePath}${url.search}`);
|
||||
}
|
||||
|
||||
const { gitHubApp, buildSettings } = resultOrFail.value;
|
||||
|
||||
return typedjson({
|
||||
githubAppEnabled: gitHubApp.enabled,
|
||||
buildSettings,
|
||||
vercelIntegrationEnabled: OrgIntegrationRepository.isVercelSupported,
|
||||
});
|
||||
return null;
|
||||
};
|
||||
|
||||
const UpdateBuildSettingsFormSchema = z.object({
|
||||
action: z.literal("update-build-settings"),
|
||||
triggerConfigFilePath: z
|
||||
.string()
|
||||
.trim()
|
||||
.optional()
|
||||
.transform((val) => (val ? val.replace(/^\/+/, "") : val))
|
||||
.refine((val) => !val || val.length <= 255, {
|
||||
message: "Config file path must not exceed 255 characters",
|
||||
}),
|
||||
installCommand: z
|
||||
.string()
|
||||
.trim()
|
||||
.optional()
|
||||
.refine((val) => !val || !val.includes("\n"), {
|
||||
message: "Install command must be a single line",
|
||||
})
|
||||
.refine((val) => !val || val.length <= 500, {
|
||||
message: "Install command must not exceed 500 characters",
|
||||
}),
|
||||
preBuildCommand: z
|
||||
.string()
|
||||
.trim()
|
||||
.optional()
|
||||
.refine((val) => !val || !val.includes("\n"), {
|
||||
message: "Pre-build command must be a single line",
|
||||
})
|
||||
.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>;
|
||||
|
||||
export function createSchema(
|
||||
constraints: {
|
||||
getSlugMatch?: (slug: string) => { isMatch: boolean; projectSlug: string };
|
||||
} = {}
|
||||
) {
|
||||
return z.discriminatedUnion("action", [
|
||||
z.object({
|
||||
action: z.literal("rename"),
|
||||
projectName: z.string().min(3, "Project name must have at least 3 characters").max(50),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal("delete"),
|
||||
projectSlug: z.string().superRefine((slug, ctx) => {
|
||||
if (constraints.getSlugMatch === undefined) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: conform.VALIDATION_UNDEFINED,
|
||||
});
|
||||
} else {
|
||||
const { isMatch, projectSlug } = constraints.getSlugMatch(slug);
|
||||
if (isMatch) {
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `The slug must match ${projectSlug}`,
|
||||
});
|
||||
}
|
||||
}),
|
||||
}),
|
||||
UpdateBuildSettingsFormSchema,
|
||||
]);
|
||||
}
|
||||
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam } = params;
|
||||
if (!organizationSlug || !projectParam) {
|
||||
return json({ errors: { body: "organizationSlug is required" } }, { status: 400 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
|
||||
const schema = createSchema({
|
||||
getSlugMatch: (slug) => {
|
||||
return { isMatch: slug === projectParam, projectSlug: projectParam };
|
||||
},
|
||||
});
|
||||
const submission = parse(formData, { schema });
|
||||
|
||||
if (!submission.value || submission.intent !== "submit") {
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
const projectSettingsService = new ProjectSettingsService();
|
||||
const membershipResultOrFail = await projectSettingsService.verifyProjectMembership(
|
||||
organizationSlug,
|
||||
projectParam,
|
||||
userId
|
||||
);
|
||||
|
||||
if (membershipResultOrFail.isErr()) {
|
||||
return json({ errors: { body: membershipResultOrFail.error.type } }, { status: 404 });
|
||||
}
|
||||
|
||||
const { projectId } = membershipResultOrFail.value;
|
||||
|
||||
switch (submission.value.action) {
|
||||
case "rename": {
|
||||
const resultOrFail = await projectSettingsService.renameProject(
|
||||
projectId,
|
||||
submission.value.projectName
|
||||
);
|
||||
|
||||
if (resultOrFail.isErr()) {
|
||||
switch (resultOrFail.error.type) {
|
||||
case "other":
|
||||
default: {
|
||||
resultOrFail.error.type satisfies "other";
|
||||
|
||||
logger.error("Failed to rename project", {
|
||||
error: resultOrFail.error,
|
||||
});
|
||||
return json({ errors: { body: "Failed to rename project" } }, { status: 400 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return redirectWithSuccessMessage(
|
||||
v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }),
|
||||
request,
|
||||
`Project renamed to ${submission.value.projectName}`
|
||||
);
|
||||
}
|
||||
case "delete": {
|
||||
const resultOrFail = await projectSettingsService.deleteProject(projectParam, userId);
|
||||
|
||||
if (resultOrFail.isErr()) {
|
||||
switch (resultOrFail.error.type) {
|
||||
case "other":
|
||||
default: {
|
||||
resultOrFail.error.type satisfies "other";
|
||||
|
||||
logger.error("Failed to delete project", {
|
||||
error: resultOrFail.error,
|
||||
});
|
||||
return redirectWithErrorMessage(
|
||||
v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }),
|
||||
request,
|
||||
`Project ${projectParam} could not be deleted`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return redirectWithSuccessMessage(
|
||||
organizationPath({ slug: organizationSlug }),
|
||||
request,
|
||||
"Project deleted"
|
||||
);
|
||||
}
|
||||
case "update-build-settings": {
|
||||
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()) {
|
||||
switch (resultOrFail.error.type) {
|
||||
case "other":
|
||||
default: {
|
||||
resultOrFail.error.type satisfies "other";
|
||||
|
||||
logger.error("Failed to update build settings", {
|
||||
error: resultOrFail.error,
|
||||
});
|
||||
return redirectBackWithErrorMessage(request, "Failed to update build settings");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return redirectBackWithSuccessMessage(request, "Build settings updated successfully");
|
||||
}
|
||||
default: {
|
||||
submission.value satisfies never;
|
||||
return redirectBackWithErrorMessage(request, "Failed to process request");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { githubAppEnabled, buildSettings, vercelIntegrationEnabled } =
|
||||
useTypedLoaderData<typeof loader>();
|
||||
export default function SettingsLayout() {
|
||||
const project = useProject();
|
||||
const organization = useOrganization();
|
||||
const environment = useEnvironment();
|
||||
const lastSubmission = useActionData();
|
||||
const navigation = useNavigation();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
// Vercel onboarding modal state
|
||||
const hasQueryParam = searchParams.get("vercelOnboarding") === "true";
|
||||
const nextUrl = searchParams.get("next");
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const vercelFetcher = useTypedFetcher<typeof vercelLoader>();
|
||||
|
||||
// Helper to open modal and ensure query param is present
|
||||
const openVercelOnboarding = useCallback(() => {
|
||||
setIsModalOpen(true);
|
||||
// Ensure query param is present to maintain state during form submissions
|
||||
if (!hasQueryParam) {
|
||||
setSearchParams((prev) => {
|
||||
prev.set("vercelOnboarding", "true");
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
}, [hasQueryParam, setSearchParams]);
|
||||
|
||||
const closeVercelOnboarding = useCallback(() => {
|
||||
// Remove query param if present
|
||||
if (hasQueryParam) {
|
||||
setSearchParams((prev) => {
|
||||
prev.delete("vercelOnboarding");
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
// Close modal
|
||||
setIsModalOpen(false);
|
||||
}, [hasQueryParam, setSearchParams]);
|
||||
|
||||
// When query param is present, handle modal opening
|
||||
// Note: We don't close the modal based on data state during onboarding - only when explicitly closed
|
||||
useEffect(() => {
|
||||
if (hasQueryParam && vercelIntegrationEnabled) {
|
||||
// Ensure query param is present and modal is open
|
||||
if (vercelFetcher.data?.onboardingData && vercelFetcher.state === "idle") {
|
||||
// Data is loaded, ensure modal is open (query param takes precedence)
|
||||
if (!isModalOpen) {
|
||||
openVercelOnboarding();
|
||||
}
|
||||
} else if (vercelFetcher.state === "idle" && vercelFetcher.data === undefined) {
|
||||
// Load onboarding data
|
||||
vercelFetcher.load(
|
||||
`${vercelResourcePath(organization.slug, project.slug, environment.slug)}?vercelOnboarding=true`
|
||||
);
|
||||
}
|
||||
} else if (!hasQueryParam && isModalOpen) {
|
||||
// Query param removed but modal is open, close modal
|
||||
setIsModalOpen(false);
|
||||
}
|
||||
}, [hasQueryParam, vercelIntegrationEnabled, organization.slug, project.slug, environment.slug, vercelFetcher.data, vercelFetcher.state, isModalOpen, openVercelOnboarding]);
|
||||
|
||||
// Ensure modal stays open when query param is present (even after data reloads)
|
||||
// This is a safeguard to prevent the modal from closing during form submissions
|
||||
useEffect(() => {
|
||||
if (hasQueryParam && !isModalOpen) {
|
||||
// Query param is present but modal is closed, open it
|
||||
// This ensures the modal stays open during the onboarding flow
|
||||
openVercelOnboarding();
|
||||
}
|
||||
}, [hasQueryParam, isModalOpen, openVercelOnboarding]);
|
||||
|
||||
// When data finishes loading (from query param), ensure modal is open
|
||||
useEffect(() => {
|
||||
if (hasQueryParam && vercelFetcher.data?.onboardingData && vercelFetcher.state === "idle") {
|
||||
// Data loaded and query param is present, ensure modal is open
|
||||
if (!isModalOpen) {
|
||||
openVercelOnboarding();
|
||||
}
|
||||
}
|
||||
}, [hasQueryParam, vercelFetcher.data, vercelFetcher.state, isModalOpen, openVercelOnboarding]);
|
||||
|
||||
|
||||
// Track if we're waiting for data from button click (not query param)
|
||||
const waitingForButtonClickRef = useRef(false);
|
||||
|
||||
// Handle opening modal from button click (without query param)
|
||||
const handleOpenVercelModal = useCallback(() => {
|
||||
// Add query param to maintain state during form submissions
|
||||
if (!hasQueryParam) {
|
||||
setSearchParams((prev) => {
|
||||
prev.set("vercelOnboarding", "true");
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
|
||||
if (vercelFetcher.data && vercelFetcher.data.onboardingData) {
|
||||
// Data already loaded, open modal immediately
|
||||
openVercelOnboarding();
|
||||
} else {
|
||||
// Need to load data first, mark that we're waiting for button click
|
||||
waitingForButtonClickRef.current = true;
|
||||
vercelFetcher.load(
|
||||
`${vercelResourcePath(organization.slug, project.slug, environment.slug)}?vercelOnboarding=true`
|
||||
);
|
||||
}
|
||||
}, [organization.slug, project.slug, environment.slug, vercelFetcher, setSearchParams, hasQueryParam, openVercelOnboarding]);
|
||||
|
||||
// When data loads from button click, open modal
|
||||
useEffect(() => {
|
||||
if (waitingForButtonClickRef.current && vercelFetcher.data?.onboardingData && vercelFetcher.state === "idle") {
|
||||
// Data loaded from button click, open modal and ensure query param is present
|
||||
waitingForButtonClickRef.current = false;
|
||||
openVercelOnboarding();
|
||||
}
|
||||
}, [vercelFetcher.data, vercelFetcher.state, openVercelOnboarding]);
|
||||
|
||||
const [hasRenameFormChanges, setHasRenameFormChanges] = useState(false);
|
||||
|
||||
const [renameForm, { projectName }] = useForm({
|
||||
id: "rename-project",
|
||||
// TODO: type this
|
||||
lastSubmission: lastSubmission as any,
|
||||
shouldRevalidate: "onSubmit",
|
||||
onValidate({ formData }) {
|
||||
return parse(formData, {
|
||||
schema: createSchema(),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const isRenameLoading =
|
||||
navigation.formData?.get("action") === "rename" &&
|
||||
(navigation.state === "submitting" || navigation.state === "loading");
|
||||
|
||||
const [deleteForm, { projectSlug }] = useForm({
|
||||
id: "delete-project",
|
||||
// TODO: type this
|
||||
lastSubmission: lastSubmission as any,
|
||||
shouldValidate: "onInput",
|
||||
shouldRevalidate: "onSubmit",
|
||||
onValidate({ formData }) {
|
||||
return parse(formData, {
|
||||
schema: createSchema({
|
||||
getSlugMatch: (slug) => ({ isMatch: slug === project.slug, projectSlug: project.slug }),
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const isDeleteLoading =
|
||||
navigation.formData?.get("action") === "delete" &&
|
||||
(navigation.state === "submitting" || navigation.state === "loading");
|
||||
|
||||
const [deleteInputValue, setDeleteInputValue] = useState("");
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
@@ -479,302 +66,8 @@ export default function Page() {
|
||||
</NavBar>
|
||||
|
||||
<PageBody>
|
||||
<MainHorizontallyCenteredContainer className="md:mt-6">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<Header2 spacing>General</Header2>
|
||||
<div className="w-full rounded-sm border border-grid-dimmed p-4">
|
||||
<Fieldset className="mb-5">
|
||||
<InputGroup fullWidth>
|
||||
<Label>Project ref</Label>
|
||||
<ClipboardField value={project.externalRef} variant={"secondary/medium"} />
|
||||
<Hint>
|
||||
This goes in your{" "}
|
||||
<InlineCode variant="extra-extra-small">trigger.config</InlineCode> file.
|
||||
</Hint>
|
||||
</InputGroup>
|
||||
</Fieldset>
|
||||
<Form method="post" {...renameForm.props}>
|
||||
<Fieldset>
|
||||
<InputGroup fullWidth>
|
||||
<Label htmlFor={projectName.id}>Project name</Label>
|
||||
<Input
|
||||
{...conform.input(projectName, { type: "text" })}
|
||||
defaultValue={project.name}
|
||||
placeholder="Project name"
|
||||
icon={FolderIcon}
|
||||
autoFocus
|
||||
onChange={(e) => {
|
||||
setHasRenameFormChanges(e.target.value !== project.name);
|
||||
}}
|
||||
/>
|
||||
<FormError id={projectName.errorId}>{projectName.error}</FormError>
|
||||
</InputGroup>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button
|
||||
type="submit"
|
||||
name="action"
|
||||
value="rename"
|
||||
variant={"secondary/small"}
|
||||
disabled={isRenameLoading || !hasRenameFormChanges}
|
||||
LeadingIcon={isRenameLoading ? SpinnerWhite : undefined}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Fieldset>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{githubAppEnabled && (
|
||||
<React.Fragment>
|
||||
<div>
|
||||
<Header2 spacing>Git settings</Header2>
|
||||
<div className="w-full rounded-sm border border-grid-dimmed p-4">
|
||||
<GitHubSettingsPanel
|
||||
organizationSlug={organization.slug}
|
||||
projectSlug={project.slug}
|
||||
environmentSlug={environment.slug}
|
||||
billingPath={v3BillingPath({ slug: organization.slug })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{vercelIntegrationEnabled && (
|
||||
<div>
|
||||
<Header2 spacing>Vercel integration</Header2>
|
||||
<div className="w-full rounded-sm border border-grid-dimmed p-4">
|
||||
<VercelSettingsPanel
|
||||
organizationSlug={organization.slug}
|
||||
projectSlug={project.slug}
|
||||
environmentSlug={environment.slug}
|
||||
onOpenVercelModal={handleOpenVercelModal}
|
||||
isLoadingVercelData={vercelFetcher.state === "loading" || vercelFetcher.state === "submitting"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Header2 spacing>Build settings</Header2>
|
||||
<div className="w-full rounded-sm border border-grid-dimmed p-4">
|
||||
<BuildSettingsForm buildSettings={buildSettings ?? {}} />
|
||||
</div>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Header2 spacing>Danger zone</Header2>
|
||||
<div className="w-full rounded-sm border border-rose-500/40 p-4">
|
||||
<Form method="post" {...deleteForm.props}>
|
||||
<Fieldset>
|
||||
<InputGroup fullWidth>
|
||||
<Label htmlFor={projectSlug.id}>Delete project</Label>
|
||||
<Input
|
||||
{...conform.input(projectSlug, { type: "text" })}
|
||||
placeholder="Your project slug"
|
||||
icon={ExclamationTriangleIcon}
|
||||
onChange={(e) => setDeleteInputValue(e.target.value)}
|
||||
/>
|
||||
<FormError id={projectSlug.errorId}>{projectSlug.error}</FormError>
|
||||
<FormError>{deleteForm.error}</FormError>
|
||||
<Hint>
|
||||
This change is irreversible, so please be certain. Type in the Project slug
|
||||
<InlineCode variant="extra-small">{project.slug}</InlineCode> and then press
|
||||
Delete.
|
||||
</Hint>
|
||||
</InputGroup>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button
|
||||
type="submit"
|
||||
name="action"
|
||||
value="delete"
|
||||
variant={"danger/small"}
|
||||
LeadingIcon={isDeleteLoading ? SpinnerWhite : TrashIcon}
|
||||
leadingIconClassName="text-white"
|
||||
disabled={isDeleteLoading || deleteInputValue !== project.slug}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Fieldset>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MainHorizontallyCenteredContainer>
|
||||
<Outlet />
|
||||
</PageBody>
|
||||
|
||||
{/* Vercel Onboarding Modal */}
|
||||
{vercelIntegrationEnabled && (
|
||||
<VercelOnboardingModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={closeVercelOnboarding}
|
||||
onboardingData={vercelFetcher.data?.onboardingData ?? null}
|
||||
organizationSlug={organization.slug}
|
||||
projectSlug={project.slug}
|
||||
environmentSlug={environment.slug}
|
||||
hasStagingEnvironment={vercelFetcher.data?.hasStagingEnvironment ?? false}
|
||||
hasPreviewEnvironment={vercelFetcher.data?.hasPreviewEnvironment ?? false}
|
||||
hasOrgIntegration={vercelFetcher.data?.hasOrgIntegration ?? false}
|
||||
nextUrl={nextUrl ?? undefined}
|
||||
onDataReload={(vercelEnvironmentId) => {
|
||||
vercelFetcher.load(
|
||||
`${vercelResourcePath(organization.slug, project.slug, environment.slug)}?vercelOnboarding=true${
|
||||
vercelEnvironmentId ? `&vercelEnvironmentId=${vercelEnvironmentId}` : ""
|
||||
}`
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function BuildSettingsForm({ buildSettings }: { buildSettings: BuildSettings }) {
|
||||
const lastSubmission = useActionData() as any;
|
||||
const navigation = useNavigation();
|
||||
|
||||
const [hasBuildSettingsChanges, setHasBuildSettingsChanges] = useState(false);
|
||||
const [buildSettingsValues, setBuildSettingsValues] = useState({
|
||||
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.useNativeBuildServer !== (buildSettings?.useNativeBuildServer || false);
|
||||
setHasBuildSettingsChanges(hasChanges);
|
||||
}, [buildSettingsValues, buildSettings]);
|
||||
|
||||
const [buildSettingsForm, fields] = useForm({
|
||||
id: "update-build-settings",
|
||||
lastSubmission: lastSubmission,
|
||||
shouldRevalidate: "onSubmit",
|
||||
onValidate({ formData }) {
|
||||
return parse(formData, {
|
||||
schema: UpdateBuildSettingsFormSchema,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const isBuildSettingsLoading =
|
||||
navigation.formData?.get("action") === "update-build-settings" &&
|
||||
(navigation.state === "submitting" || navigation.state === "loading");
|
||||
|
||||
return (
|
||||
<Form method="post" {...buildSettingsForm.props}>
|
||||
<Fieldset>
|
||||
<InputGroup fullWidth>
|
||||
<Label htmlFor={fields.triggerConfigFilePath.id}>Trigger config file</Label>
|
||||
<Input
|
||||
{...conform.input(fields.triggerConfigFilePath, { type: "text" })}
|
||||
defaultValue={buildSettings?.triggerConfigFilePath || ""}
|
||||
placeholder="trigger.config.ts"
|
||||
onChange={(e) => {
|
||||
setBuildSettingsValues((prev) => ({
|
||||
...prev,
|
||||
triggerConfigFilePath: e.target.value,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<Hint>
|
||||
Path to your Trigger configuration file, relative to the root directory of your repo.
|
||||
</Hint>
|
||||
<FormError id={fields.triggerConfigFilePath.errorId}>
|
||||
{fields.triggerConfigFilePath.error}
|
||||
</FormError>
|
||||
</InputGroup>
|
||||
|
||||
<InputGroup fullWidth>
|
||||
<Label htmlFor={fields.installCommand.id}>Install command</Label>
|
||||
<Input
|
||||
{...conform.input(fields.installCommand, { type: "text" })}
|
||||
defaultValue={buildSettings?.installCommand || ""}
|
||||
placeholder="e.g., `npm install`, `pnpm install`, or `bun install`"
|
||||
onChange={(e) => {
|
||||
setBuildSettingsValues((prev) => ({
|
||||
...prev,
|
||||
installCommand: e.target.value,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<Hint>
|
||||
Command to install your project dependencies. This will be run from the root directory
|
||||
of your repo. Auto-detected by default.
|
||||
</Hint>
|
||||
<FormError id={fields.installCommand.errorId}>{fields.installCommand.error}</FormError>
|
||||
</InputGroup>
|
||||
<InputGroup fullWidth>
|
||||
<Label htmlFor={fields.preBuildCommand.id}>Pre-build command</Label>
|
||||
<Input
|
||||
{...conform.input(fields.preBuildCommand, { type: "text" })}
|
||||
defaultValue={buildSettings?.preBuildCommand || ""}
|
||||
placeholder="e.g., `npm run prisma:generate`"
|
||||
onChange={(e) => {
|
||||
setBuildSettingsValues((prev) => ({
|
||||
...prev,
|
||||
preBuildCommand: e.target.value,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<Hint>
|
||||
Any command that needs to run before we build and deploy your project. This will be run
|
||||
from the root directory of your repo.
|
||||
</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={
|
||||
<Button
|
||||
type="submit"
|
||||
name="action"
|
||||
value="update-build-settings"
|
||||
variant="secondary/small"
|
||||
disabled={isBuildSettingsLoading || !hasBuildSettingsChanges}
|
||||
LeadingIcon={isBuildSettingsLoading ? SpinnerWhite : undefined}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Fieldset>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,10 +5,12 @@ import {
|
||||
CheckIcon,
|
||||
ExclamationTriangleIcon,
|
||||
FolderIcon,
|
||||
GlobeAltIcon,
|
||||
TrashIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { Form, type MetaFunction, useActionData, useNavigation } from "@remix-run/react";
|
||||
import { Form, type MetaFunction, useActionData, useNavigation, useSubmit } from "@remix-run/react";
|
||||
import { type ActionFunction, json, type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { InlineCode } from "~/components/code/InlineCode";
|
||||
@@ -26,35 +28,31 @@ import {
|
||||
parseAvatar,
|
||||
defaultAvatarHex,
|
||||
defaultAvatarColors,
|
||||
type Avatar as AvatarT,
|
||||
} from "~/components/primitives/Avatar";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
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 { Header2 } from "~/components/primitives/Headers";
|
||||
import { Hint } from "~/components/primitives/Hint";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { InputGroup } from "~/components/primitives/InputGroup";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverCustomTrigger,
|
||||
PopoverTrigger,
|
||||
} from "~/components/primitives/Popover";
|
||||
import { Spinner, SpinnerWhite } from "~/components/primitives/Spinner";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "~/components/primitives/Popover";
|
||||
import { SpinnerWhite } from "~/components/primitives/Spinner";
|
||||
import { prisma } from "~/db.server";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useFaviconUrl } from "~/hooks/useFaviconUrl";
|
||||
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { clearCurrentProject } from "~/services/dashboardPreferences.server";
|
||||
import { DeleteOrganizationService } from "~/services/deleteOrganization.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { requireUser, requireUserId } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { extractDomain, faviconUrl as buildFaviconUrl } from "~/utils/favicon";
|
||||
import {
|
||||
OrganizationParamsSchema,
|
||||
organizationPath,
|
||||
organizationSettingsPath,
|
||||
rootPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
@@ -79,6 +77,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
slug: true,
|
||||
title: true,
|
||||
avatar: true,
|
||||
onboardingData: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -86,8 +85,21 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
throw new Response("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
const onboardingData = toRecord(organization.onboardingData);
|
||||
|
||||
const parsedAvatar = parseAvatar(organization.avatar, defaultAvatar);
|
||||
const lastIconHex =
|
||||
parsedAvatar.type === "image" && parsedAvatar.lastIconHex
|
||||
? parsedAvatar.lastIconHex
|
||||
: defaultAvatarHex;
|
||||
|
||||
return typedjson({
|
||||
organization: { ...organization, avatar: parseAvatar(organization.avatar, defaultAvatar) },
|
||||
organization: {
|
||||
...organization,
|
||||
avatar: parsedAvatar,
|
||||
companyUrl: typeof onboardingData.companyUrl === "string" ? onboardingData.companyUrl : "",
|
||||
lastIconHex,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -102,6 +114,7 @@ export function createSchema(
|
||||
type: AvatarType,
|
||||
name: z.string().optional(),
|
||||
hex: z.string().optional(),
|
||||
url: z.string().optional(),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal("rename"),
|
||||
@@ -199,6 +212,43 @@ export const action: ActionFunction = async ({ request, params }) => {
|
||||
}
|
||||
}
|
||||
case "avatar": {
|
||||
const orgWhere = {
|
||||
slug: organizationSlug,
|
||||
members: { some: { userId: user.id } },
|
||||
};
|
||||
|
||||
if (submission.value.type === "image") {
|
||||
const url = submission.value.url ?? "";
|
||||
const domain = url ? extractDomain(url) : null;
|
||||
|
||||
const existing = await prisma.organization.findFirst({
|
||||
where: orgWhere,
|
||||
select: { avatar: true, onboardingData: true },
|
||||
});
|
||||
|
||||
const existingData = toRecord(existing?.onboardingData);
|
||||
const existingAvatar = parseAvatar(existing?.avatar ?? null, defaultAvatar);
|
||||
const lastIconHex = extractLastIconHex(existingAvatar);
|
||||
|
||||
await prisma.organization.update({
|
||||
where: orgWhere,
|
||||
data: {
|
||||
avatar: {
|
||||
type: "image",
|
||||
url: domain ? buildFaviconUrl(domain) : "",
|
||||
...(lastIconHex ? { lastIconHex } : {}),
|
||||
},
|
||||
onboardingData: { ...existingData, companyUrl: url },
|
||||
},
|
||||
});
|
||||
|
||||
return redirectWithSuccessMessage(
|
||||
organizationSettingsPath({ slug: organizationSlug }),
|
||||
request,
|
||||
`Updated logo`
|
||||
);
|
||||
}
|
||||
|
||||
const avatar = AvatarData.safeParse(submission.value);
|
||||
|
||||
if (!avatar.success) {
|
||||
@@ -210,14 +260,7 @@ export const action: ActionFunction = async ({ request, params }) => {
|
||||
}
|
||||
|
||||
await prisma.organization.update({
|
||||
where: {
|
||||
slug: organizationSlug,
|
||||
members: {
|
||||
some: {
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: orgWhere,
|
||||
data: {
|
||||
avatar: avatar.data,
|
||||
},
|
||||
@@ -226,12 +269,13 @@ export const action: ActionFunction = async ({ request, params }) => {
|
||||
return redirectWithSuccessMessage(
|
||||
organizationSettingsPath({ slug: organizationSlug }),
|
||||
request,
|
||||
`Updated icon`
|
||||
`Updated logo`
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
return json({ errors: { body: error.message } }, { status: 400 });
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "An unexpected error occurred";
|
||||
return json({ errors: { body: message } }, { status: 400 });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -374,89 +418,164 @@ export default function Page() {
|
||||
);
|
||||
}
|
||||
|
||||
function LogoForm({ organization }: { organization: { avatar: Avatar; title: string } }) {
|
||||
function LogoForm({
|
||||
organization,
|
||||
}: {
|
||||
organization: { avatar: AvatarT; title: string; companyUrl: string; lastIconHex: string };
|
||||
}) {
|
||||
const navigation = useNavigation();
|
||||
|
||||
const isSubmitting =
|
||||
navigation.state != "idle" && navigation.formData?.get("action") === "avatar";
|
||||
|
||||
const avatar = navigation.formData
|
||||
? avatarFromFormData(navigation.formData) ?? organization.avatar
|
||||
: organization.avatar;
|
||||
|
||||
const hex = "hex" in avatar ? avatar.hex : defaultAvatarHex;
|
||||
const hex =
|
||||
"hex" in avatar
|
||||
? avatar.hex
|
||||
: avatar.type === "image" && avatar.lastIconHex
|
||||
? avatar.lastIconHex
|
||||
: organization.lastIconHex;
|
||||
const mode: "logo" | "icon" = avatar.type === "image" ? "logo" : "icon";
|
||||
|
||||
const [companyUrl, setCompanyUrl] = useState(organization.companyUrl);
|
||||
const faviconPreview = useFaviconUrl(companyUrl);
|
||||
const [faviconError, setFaviconError] = useState(false);
|
||||
const logoFormRef = useRef<HTMLFormElement>(null);
|
||||
const submit = useSubmit();
|
||||
const prevFaviconRef = useRef(faviconPreview);
|
||||
|
||||
useEffect(() => {
|
||||
if (faviconPreview === prevFaviconRef.current) return;
|
||||
prevFaviconRef.current = faviconPreview;
|
||||
if (mode === "logo" && logoFormRef.current) {
|
||||
submit(logoFormRef.current);
|
||||
}
|
||||
}, [faviconPreview, mode, submit]);
|
||||
|
||||
const showFavicon = faviconPreview && !faviconError;
|
||||
|
||||
return (
|
||||
<Fieldset>
|
||||
<InputGroup fullWidth>
|
||||
<Label>Icon</Label>
|
||||
<div className="flex w-full items-end justify-between gap-2">
|
||||
<div className="grid place-items-center overflow-hidden rounded-sm border border-charcoal-750 bg-background-bright">
|
||||
<Avatar avatar={avatar} size={5} includePadding orgName={organization.title} />
|
||||
</div>
|
||||
{/* Letters */}
|
||||
<Form method="post">
|
||||
<Label>Logo</Label>
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Row 1: Logo from URL */}
|
||||
<Form ref={logoFormRef} method="post" className="flex items-center gap-3">
|
||||
<input type="hidden" name="action" value="avatar" />
|
||||
<input type="hidden" name="type" value="letters" />
|
||||
<input type="hidden" name="hex" value={hex} />
|
||||
<button
|
||||
type="submit"
|
||||
className={cn(
|
||||
"box-content grid size-10 place-items-center rounded-sm border-2 bg-charcoal-775",
|
||||
avatar.type === "letters"
|
||||
? undefined
|
||||
: "border-charcoal-775 hover:border-charcoal-600"
|
||||
)}
|
||||
style={{
|
||||
borderColor: avatar.type === "letters" ? hex : undefined,
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
avatar={{
|
||||
type: "letters",
|
||||
hex,
|
||||
}}
|
||||
size={2.5}
|
||||
includePadding
|
||||
orgName={organization.title}
|
||||
/>
|
||||
<input type="hidden" name="type" value="image" />
|
||||
<input type="hidden" name="url" value={companyUrl} />
|
||||
<button type="submit" className="flex shrink-0 items-center gap-3">
|
||||
<RadioDot active={mode === "logo"} />
|
||||
</button>
|
||||
</Form>
|
||||
{/* Icons */}
|
||||
{Object.entries(avatarIcons).map(([name]) => (
|
||||
<Form key={name} method="post">
|
||||
<input type="hidden" name="action" value="avatar" />
|
||||
<input type="hidden" name="type" value="icon" />
|
||||
<input type="hidden" name="name" value={name} />
|
||||
<input type="hidden" name="hex" value={hex} />
|
||||
<div className="flex flex-1 items-center gap-1.5">
|
||||
<button
|
||||
type="submit"
|
||||
className={cn(
|
||||
"box-content grid size-10 place-items-center rounded-sm border-2 bg-charcoal-775",
|
||||
avatar.type === "icon" && avatar.name === name
|
||||
? undefined
|
||||
iconTileClass,
|
||||
mode === "logo"
|
||||
? "border-indigo-500"
|
||||
: "border-charcoal-775 hover:border-charcoal-600"
|
||||
)}
|
||||
style={{
|
||||
borderColor: avatar.type === "icon" && avatar.name === name ? hex : undefined,
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
key={name}
|
||||
avatar={{
|
||||
type: "icon",
|
||||
name,
|
||||
hex,
|
||||
}}
|
||||
size={2.5}
|
||||
includePadding
|
||||
orgName={organization.title}
|
||||
/>
|
||||
{showFavicon ? (
|
||||
<img
|
||||
src={faviconPreview}
|
||||
alt=""
|
||||
width={28}
|
||||
height={28}
|
||||
className="rounded-sm"
|
||||
onError={() => setFaviconError(true)}
|
||||
onLoad={() => setFaviconError(false)}
|
||||
/>
|
||||
) : (
|
||||
<GlobeAltIcon className="size-6 text-text-dimmed" />
|
||||
)}
|
||||
</button>
|
||||
<Input
|
||||
type="text"
|
||||
value={companyUrl}
|
||||
onChange={(e) => {
|
||||
setCompanyUrl(e.target.value);
|
||||
setFaviconError(false);
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (mode !== "logo" && logoFormRef.current) {
|
||||
submit(logoFormRef.current);
|
||||
}
|
||||
}}
|
||||
placeholder="Enter your company URL to generate a logo"
|
||||
variant="medium"
|
||||
containerClassName="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
{/* Row 2: Icon picker */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Form method="post" className="shrink-0">
|
||||
<input type="hidden" name="action" value="avatar" />
|
||||
<input type="hidden" name="type" value="letters" />
|
||||
<input type="hidden" name="hex" value={hex} />
|
||||
<button type="submit">
|
||||
<RadioDot active={mode === "icon"} />
|
||||
</button>
|
||||
</Form>
|
||||
))}
|
||||
{/* Hex */}
|
||||
<HexPopover avatar={avatar} hex={hex} />
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{/* Letters */}
|
||||
<Form method="post">
|
||||
<input type="hidden" name="action" value="avatar" />
|
||||
<input type="hidden" name="type" value="letters" />
|
||||
<input type="hidden" name="hex" value={hex} />
|
||||
<button
|
||||
type="submit"
|
||||
className={cn(
|
||||
iconTileClass,
|
||||
avatar.type !== "letters" && "border-charcoal-775 hover:border-charcoal-600"
|
||||
)}
|
||||
style={{
|
||||
borderColor: avatar.type === "letters" ? hex : undefined,
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
avatar={{ type: "letters", hex }}
|
||||
size={2.5}
|
||||
includePadding
|
||||
orgName={organization.title}
|
||||
/>
|
||||
</button>
|
||||
</Form>
|
||||
{/* Icons */}
|
||||
{Object.entries(avatarIcons).map(([name]) => (
|
||||
<Form key={name} method="post">
|
||||
<input type="hidden" name="action" value="avatar" />
|
||||
<input type="hidden" name="type" value="icon" />
|
||||
<input type="hidden" name="name" value={name} />
|
||||
<input type="hidden" name="hex" value={hex} />
|
||||
<button
|
||||
type="submit"
|
||||
className={cn(
|
||||
iconTileClass,
|
||||
!(avatar.type === "icon" && avatar.name === name) &&
|
||||
"border-charcoal-775 hover:border-charcoal-600"
|
||||
)}
|
||||
style={{
|
||||
borderColor:
|
||||
avatar.type === "icon" && avatar.name === name ? hex : undefined,
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
avatar={{ type: "icon", name, hex }}
|
||||
size={2.5}
|
||||
includePadding
|
||||
orgName={organization.title}
|
||||
/>
|
||||
</button>
|
||||
</Form>
|
||||
))}
|
||||
{/* Color picker */}
|
||||
<HexPopover avatar={avatar} hex={hex} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</InputGroup>
|
||||
</Fieldset>
|
||||
@@ -466,7 +585,7 @@ function LogoForm({ organization }: { organization: { avatar: Avatar; title: str
|
||||
function HexPopover({ avatar, hex }: { avatar: Avatar; hex: string }) {
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger className="box-content grid size-10 place-items-center rounded-sm border-2 border-charcoal-775 bg-charcoal-775 hover:border-charcoal-600">
|
||||
<PopoverTrigger className={cn(iconTileClass, "border-charcoal-775 hover:border-charcoal-600")}>
|
||||
<img src={colorWheelIcon} className="m-0 block size-[30px] p-0" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
@@ -474,10 +593,10 @@ function HexPopover({ avatar, hex }: { avatar: Avatar; hex: string }) {
|
||||
align="start"
|
||||
style={{ maxHeight: `calc(var(--radix-popover-content-available-height) - 10vh)` }}
|
||||
>
|
||||
<Form method="post" className="flex w-fit min-w-40 flex-col gap-1 ">
|
||||
<Form method="post" className="flex w-fit min-w-40 flex-col gap-1">
|
||||
<input type="hidden" name="action" value="avatar" />
|
||||
<input type="hidden" name="type" value={avatar.type} />
|
||||
{"name" in avatar && <input type="hidden" name="name" value={avatar.name} />}
|
||||
<input type="hidden" name="type" value={avatar.type === "image" ? "letters" : avatar.type} />
|
||||
{avatar.type === "icon" && <input type="hidden" name="name" value={avatar.name} />}
|
||||
{defaultAvatarColors.map((color) => (
|
||||
<Button
|
||||
key={color.hex}
|
||||
@@ -511,29 +630,52 @@ function HexPopover({ avatar, hex }: { avatar: Avatar; hex: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function avatarFromFormData(formData: FormData): Avatar | undefined {
|
||||
const action = formData.get("action");
|
||||
if (!action || action !== "avatar") {
|
||||
return undefined;
|
||||
}
|
||||
function RadioDot({ active }: { active: boolean }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 items-center justify-center rounded-full border-2 p-0.5 transition",
|
||||
active ? "border-indigo-500" : "border-charcoal-700 hover:border-charcoal-600"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="size-2 rounded-full"
|
||||
style={{ backgroundColor: active ? "#6366f1" : "transparent" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const type = formData.get("type");
|
||||
const hex = formData.get("hex");
|
||||
const iconTileClass = "box-content grid size-10 shrink-0 place-items-center rounded-sm border-2 bg-charcoal-775";
|
||||
|
||||
if (type === "letters") {
|
||||
return {
|
||||
type: "letters",
|
||||
hex: hex as string,
|
||||
};
|
||||
}
|
||||
|
||||
if (type === "icon") {
|
||||
return {
|
||||
type: "icon",
|
||||
name: formData.get("name") as string,
|
||||
hex: hex as string,
|
||||
};
|
||||
}
|
||||
function toRecord(json: unknown): Record<string, unknown> {
|
||||
return json && typeof json === "object" ? (json as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
function extractLastIconHex(avatar: AvatarT): string | undefined {
|
||||
if ("hex" in avatar) return avatar.hex;
|
||||
if (avatar.type === "image") return avatar.lastIconHex;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function avatarFromFormData(formData: FormData): AvatarT | undefined {
|
||||
const action = formData.get("action");
|
||||
if (action !== "avatar") return undefined;
|
||||
|
||||
const type = formData.get("type");
|
||||
const hex = formData.get("hex") as string;
|
||||
|
||||
switch (type) {
|
||||
case "letters":
|
||||
return { type: "letters", hex };
|
||||
case "icon":
|
||||
return { type: "icon", name: formData.get("name") as string, hex };
|
||||
case "image": {
|
||||
const url = formData.get("url") as string;
|
||||
const domain = url ? extractDomain(url) : null;
|
||||
return { type: "image", url: domain ? buildFaviconUrl(domain) : "" };
|
||||
}
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { json, redirect } from "@remix-run/node";
|
||||
import { fromPromise } from "neverthrow";
|
||||
import { Form, useActionData, useNavigation } from "@remix-run/react";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { DialogClose } from "@radix-ui/react-dialog";
|
||||
import { SlackIcon } from "@trigger.dev/companyicons";
|
||||
import { TrashIcon } from "@heroicons/react/20/solid";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "~/components/primitives/Dialog";
|
||||
import { FormButtons } from "~/components/primitives/FormButtons";
|
||||
import { Header1 } from "~/components/primitives/Headers";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
} from "~/components/primitives/Table";
|
||||
import { EnabledStatus } from "~/components/runs/v3/EnabledStatus";
|
||||
import { $transaction, prisma } from "~/db.server";
|
||||
import { requireOrganization } from "~/services/org.server";
|
||||
import { OrganizationParamsSchema, organizationSettingsPath } from "~/utils/pathBuilder";
|
||||
import { logger } from "~/services/logger.server";
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: true,
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const { organizationSlug } = OrganizationParamsSchema.parse(params);
|
||||
const { organization } = await requireOrganization(request, organizationSlug);
|
||||
|
||||
const slackIntegration = await prisma.organizationIntegration.findFirst({
|
||||
where: {
|
||||
organizationId: organization.id,
|
||||
service: "SLACK",
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (!slackIntegration) {
|
||||
return typedjson({
|
||||
organization,
|
||||
slackIntegration: null,
|
||||
alertChannels: [],
|
||||
teamName: null,
|
||||
});
|
||||
}
|
||||
|
||||
const integrationData = slackIntegration.integrationData as any;
|
||||
const teamName = integrationData?.team?.name ?? null;
|
||||
|
||||
const alertChannels = await prisma.projectAlertChannel.findMany({
|
||||
where: {
|
||||
type: "SLACK",
|
||||
project: { organizationId: organization.id },
|
||||
OR: [
|
||||
{ integrationId: slackIntegration.id },
|
||||
{
|
||||
properties: {
|
||||
path: ["integrationId"],
|
||||
equals: slackIntegration.id,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
return typedjson({
|
||||
organization,
|
||||
slackIntegration,
|
||||
alertChannels,
|
||||
teamName,
|
||||
});
|
||||
};
|
||||
|
||||
const ActionSchema = z.object({
|
||||
intent: z.literal("uninstall"),
|
||||
});
|
||||
|
||||
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
const { organizationSlug } = OrganizationParamsSchema.parse(params);
|
||||
const { organization, userId } = await requireOrganization(request, organizationSlug);
|
||||
|
||||
const formData = await request.formData();
|
||||
const result = ActionSchema.safeParse({ intent: formData.get("intent") });
|
||||
if (!result.success) {
|
||||
return json({ error: "Invalid action" }, { status: 400 });
|
||||
}
|
||||
|
||||
const slackIntegration = await prisma.organizationIntegration.findFirst({
|
||||
where: {
|
||||
organizationId: organization.id,
|
||||
service: "SLACK",
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (!slackIntegration) {
|
||||
return json({ error: "Slack integration not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const txResult = await fromPromise(
|
||||
$transaction(prisma, async (tx) => {
|
||||
await tx.projectAlertChannel.updateMany({
|
||||
where: {
|
||||
type: "SLACK",
|
||||
OR: [
|
||||
{ integrationId: slackIntegration.id },
|
||||
{
|
||||
properties: {
|
||||
path: ["integrationId"],
|
||||
equals: slackIntegration.id,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
data: {
|
||||
enabled: false,
|
||||
integrationId: null,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.organizationIntegration.update({
|
||||
where: { id: slackIntegration.id },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
}),
|
||||
(error) => error
|
||||
);
|
||||
|
||||
if (txResult.isErr()) {
|
||||
logger.error("Failed to remove Slack integration", {
|
||||
organizationId: organization.id,
|
||||
organizationSlug,
|
||||
userId,
|
||||
integrationId: slackIntegration.id,
|
||||
error: txResult.error instanceof Error ? txResult.error.message : String(txResult.error),
|
||||
});
|
||||
|
||||
return json(
|
||||
{ error: "Failed to remove Slack integration. Please try again." },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
logger.info("Slack integration removed successfully", {
|
||||
organizationId: organization.id,
|
||||
organizationSlug,
|
||||
userId,
|
||||
integrationId: slackIntegration.id,
|
||||
});
|
||||
|
||||
return redirect(organizationSettingsPath({ slug: organizationSlug }));
|
||||
};
|
||||
|
||||
export default function SlackIntegrationPage() {
|
||||
const { slackIntegration, alertChannels, teamName } =
|
||||
useTypedLoaderData<typeof loader>();
|
||||
const actionData = useActionData<typeof action>();
|
||||
const navigation = useNavigation();
|
||||
const isUninstalling =
|
||||
navigation.state === "submitting" && navigation.formData?.get("intent") === "uninstall";
|
||||
|
||||
if (!slackIntegration) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageBody>
|
||||
<div className="flex flex-col items-center justify-center py-8">
|
||||
<Header1>No Slack Integration Found</Header1>
|
||||
<Paragraph className="mt-2 text-center text-text-dimmed">
|
||||
This organization doesn't have a Slack integration configured. You can connect Slack
|
||||
when setting up alert channels in your project settings.
|
||||
</Paragraph>
|
||||
</div>
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageBody>
|
||||
<div className="mb-8">
|
||||
<Header1>Slack Integration</Header1>
|
||||
<Paragraph className="mt-2 text-text-dimmed">
|
||||
Manage your organization's Slack integration and connected alert channels.
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
{/* Integration Info Section */}
|
||||
<div className="mb-8 rounded-lg border border-grid-bright bg-background-bright p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-medium text-text-bright">Integration Details</h2>
|
||||
<div className="mt-2 space-y-1 text-sm text-text-dimmed">
|
||||
{teamName && (
|
||||
<div>
|
||||
<span className="font-medium">Slack Workspace:</span> {teamName}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="font-medium">Installed:</span>{" "}
|
||||
{formatDate(new Date(slackIntegration.createdAt))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="danger/medium" LeadingIcon={TrashIcon} disabled={isUninstalling}>
|
||||
Remove Integration
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Remove Slack Integration</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogDescription>
|
||||
This will remove the Slack integration and disable all connected alert channels.
|
||||
This action cannot be undone.
|
||||
</DialogDescription>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Form method="post">
|
||||
<input type="hidden" name="intent" value="uninstall" />
|
||||
<Button
|
||||
variant="danger/medium"
|
||||
LeadingIcon={TrashIcon}
|
||||
type="submit"
|
||||
disabled={isUninstalling}
|
||||
>
|
||||
{isUninstalling ? "Removing..." : "Remove Integration"}
|
||||
</Button>
|
||||
</Form>
|
||||
}
|
||||
cancelButton={
|
||||
<DialogClose asChild>
|
||||
<Button variant="tertiary/medium">Cancel</Button>
|
||||
</DialogClose>
|
||||
}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{actionData?.error && (
|
||||
<Paragraph variant="small" className="text-error">
|
||||
{actionData.error}
|
||||
</Paragraph>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connected Alert Channels Section */}
|
||||
<div>
|
||||
<h2 className="mb-4 text-lg font-medium text-text-bright">
|
||||
Connected Alert Channels ({alertChannels.length})
|
||||
</h2>
|
||||
|
||||
{alertChannels.length === 0 ? (
|
||||
<div className="rounded-lg border border-grid-bright bg-background-bright p-6 text-center">
|
||||
<Paragraph className="text-text-dimmed">
|
||||
No alert channels are currently connected to this Slack integration.
|
||||
</Paragraph>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Channel Name</TableHeaderCell>
|
||||
<TableHeaderCell>Project</TableHeaderCell>
|
||||
<TableHeaderCell>Status</TableHeaderCell>
|
||||
<TableHeaderCell>Created</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{alertChannels.map((channel) => (
|
||||
<TableRow key={channel.id}>
|
||||
<TableCell>{channel.name}</TableCell>
|
||||
<TableCell>{channel.project.name}</TableCell>
|
||||
<TableCell>
|
||||
<EnabledStatus enabled={channel.enabled} />
|
||||
</TableCell>
|
||||
<TableCell>{formatDate(new Date(channel.createdAt))}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -28,7 +28,7 @@ import { requireOrganization } from "~/services/org.server";
|
||||
import { OrganizationParamsSchema } from "~/utils/pathBuilder";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { TrashIcon } from "@heroicons/react/20/solid";
|
||||
import { v3ProjectSettingsPath } from "~/utils/pathBuilder";
|
||||
import { v3ProjectSettingsIntegrationsPath } from "~/utils/pathBuilder";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
@@ -354,7 +354,7 @@ export default function VercelIntegrationPage() {
|
||||
<TableCell>
|
||||
<LinkButton
|
||||
variant="minimal/small"
|
||||
to={v3ProjectSettingsPath(
|
||||
to={v3ProjectSettingsIntegrationsPath(
|
||||
organization,
|
||||
projectIntegration.project,
|
||||
{ slug: "prod" } // Default to production environment
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { conform, useForm } from "@conform-to/react";
|
||||
import { parse } from "@conform-to/zod";
|
||||
import { FolderIcon } from "@heroicons/react/20/solid";
|
||||
import type { ActionFunction, LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { json } from "@remix-run/node";
|
||||
import { CommandLineIcon, FolderIcon } from "@heroicons/react/20/solid";
|
||||
import { json, type ActionFunction, type LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { Form, useActionData, useNavigation } from "@remix-run/react";
|
||||
import type { Prisma } from "@trigger.dev/database";
|
||||
import React, { useEffect, useState } from "react";
|
||||
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 { TechnologyPicker } from "~/components/onboarding/TechnologyPicker";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Callout } from "~/components/primitives/Callout";
|
||||
import { Fieldset } from "~/components/primitives/Fieldset";
|
||||
@@ -19,6 +21,7 @@ import { FormTitle } from "~/components/primitives/FormTitle";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { InputGroup } from "~/components/primitives/InputGroup";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { Select, SelectItem } from "~/components/primitives/Select";
|
||||
import { ButtonSpinner } from "~/components/primitives/Spinner";
|
||||
import { prisma } from "~/db.server";
|
||||
import { featuresForRequest } from "~/features.server";
|
||||
@@ -34,6 +37,78 @@ import {
|
||||
} from "~/utils/pathBuilder";
|
||||
import { generateVercelOAuthState } from "~/v3/vercel/vercelOAuthState.server";
|
||||
|
||||
const WORKING_ON_OTHER = "Other/not sure yet";
|
||||
const GOALS_OTHER = "Other/not sure yet";
|
||||
|
||||
const workingOnOptions = [
|
||||
"AI agent",
|
||||
"Media processing pipeline",
|
||||
"Media generation with AI",
|
||||
"Event-driven workflow",
|
||||
"Realtime streaming",
|
||||
"Internal tool or background job",
|
||||
WORKING_ON_OTHER,
|
||||
] as const;
|
||||
|
||||
const goalOptions = [
|
||||
"Ship a production workflow",
|
||||
"Prototype or explore",
|
||||
"Migrate an existing system",
|
||||
"Learn how Trigger works",
|
||||
"Evaluate against alternatives",
|
||||
GOALS_OTHER,
|
||||
] as const;
|
||||
|
||||
function shuffleArray<T>(arr: T[]): T[] {
|
||||
const shuffled = [...arr];
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||
}
|
||||
return shuffled;
|
||||
}
|
||||
|
||||
function MultiSelectField({
|
||||
value,
|
||||
setValue,
|
||||
items,
|
||||
icon,
|
||||
}: {
|
||||
value: string[];
|
||||
setValue: (value: string[]) => void;
|
||||
items: string[];
|
||||
icon: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Select<string[], string>
|
||||
value={value}
|
||||
setValue={setValue}
|
||||
placeholder="Select some options"
|
||||
variant="secondary/small"
|
||||
dropdownIcon
|
||||
icon={icon}
|
||||
items={items}
|
||||
className="h-8 min-w-0 border-0 bg-charcoal-750 pl-2 text-sm text-text-dimmed ring-charcoal-600 transition hover:bg-charcoal-650 hover:text-text-dimmed hover:ring-1"
|
||||
text={(v) =>
|
||||
v.length === 0 ? undefined : (
|
||||
<span className="flex min-w-0 items-center text-text-bright">
|
||||
<span className="truncate">{v.slice(0, 2).join(", ")}</span>
|
||||
{v.length > 2 && <span className="ml-1 flex-none">+{v.length - 2} more</span>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
>
|
||||
{(items) =>
|
||||
items.map((item) => (
|
||||
<SelectItem key={item} value={item} checkPosition="left">
|
||||
<span className="text-text-bright">{item}</span>
|
||||
</SelectItem>
|
||||
))
|
||||
}
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
export async function loader({ params, request }: LoaderFunctionArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug } = OrganizationParamsSchema.parse(params);
|
||||
@@ -62,14 +137,12 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
|
||||
throw new Response(null, { status: 404, statusText: "Organization not found" });
|
||||
}
|
||||
|
||||
//if you don't have v3 access, you must select a plan
|
||||
const { isManagedCloud } = featuresForRequest(request);
|
||||
if (isManagedCloud && !organization.v3Enabled) {
|
||||
return redirect(selectPlanPath({ slug: organizationSlug }));
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
|
||||
const message = url.searchParams.get("message");
|
||||
|
||||
return typedjson({
|
||||
@@ -90,6 +163,12 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
|
||||
const schema = z.object({
|
||||
projectName: z.string().min(3, "Project name must have at least 3 characters").max(50),
|
||||
projectVersion: z.enum(["v2", "v3"]),
|
||||
workingOn: z.string().optional(),
|
||||
workingOnOther: z.string().optional(),
|
||||
technologies: z.string().optional(),
|
||||
technologiesOther: z.string().optional(),
|
||||
goals: z.string().optional(),
|
||||
goalsOther: z.string().optional(),
|
||||
});
|
||||
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
@@ -104,21 +183,54 @@ export const action: ActionFunction = async ({ request, params }) => {
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
// Check for Vercel integration params in URL
|
||||
const url = new URL(request.url);
|
||||
const code = url.searchParams.get("code");
|
||||
const configurationId = url.searchParams.get("configurationId");
|
||||
const next = url.searchParams.get("next");
|
||||
|
||||
const stringArraySchema = z.array(z.string());
|
||||
|
||||
function safeParseStringArray(value: string | undefined): string[] | undefined {
|
||||
if (!value) return undefined;
|
||||
try {
|
||||
const result = stringArraySchema.safeParse(JSON.parse(value));
|
||||
return result.success && result.data.length > 0 ? result.data : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const onboardingData: Record<string, Prisma.InputJsonValue> = {};
|
||||
|
||||
const workingOn = safeParseStringArray(submission.value.workingOn);
|
||||
if (workingOn) onboardingData.workingOn = workingOn;
|
||||
|
||||
if (submission.value.workingOnOther) {
|
||||
onboardingData.workingOnOther = submission.value.workingOnOther;
|
||||
}
|
||||
|
||||
const technologies = safeParseStringArray(submission.value.technologies);
|
||||
if (technologies) onboardingData.technologies = technologies;
|
||||
|
||||
const technologiesOther = safeParseStringArray(submission.value.technologiesOther);
|
||||
if (technologiesOther) onboardingData.technologiesOther = technologiesOther;
|
||||
|
||||
const goals = safeParseStringArray(submission.value.goals);
|
||||
if (goals) onboardingData.goals = goals;
|
||||
|
||||
if (submission.value.goalsOther) {
|
||||
onboardingData.goalsOther = submission.value.goalsOther;
|
||||
}
|
||||
|
||||
try {
|
||||
const project = await createProject({
|
||||
organizationSlug: organizationSlug,
|
||||
name: submission.value.projectName,
|
||||
userId,
|
||||
version: submission.value.projectVersion,
|
||||
onboardingData: Object.keys(onboardingData).length > 0 ? onboardingData : undefined,
|
||||
});
|
||||
|
||||
// If this is a Vercel integration flow, generate state and redirect to connect
|
||||
if (code && configurationId) {
|
||||
const environment = await prisma.runtimeEnvironment.findFirst({
|
||||
where: {
|
||||
@@ -195,7 +307,6 @@ export default function Page() {
|
||||
|
||||
const [form, { projectName, projectVersion }] = useForm({
|
||||
id: "create-project",
|
||||
// TODO: type this
|
||||
lastSubmission: lastSubmission as any,
|
||||
onValidate({ formData }) {
|
||||
return parse(formData, { schema });
|
||||
@@ -205,10 +316,31 @@ export default function Page() {
|
||||
const navigation = useNavigation();
|
||||
const isLoading = navigation.state === "submitting" || navigation.state === "loading";
|
||||
|
||||
const [selectedWorkingOn, setSelectedWorkingOn] = useState<string[]>([]);
|
||||
const [workingOnOther, setWorkingOnOther] = useState("");
|
||||
const [selectedTechnologies, setSelectedTechnologies] = useState<string[]>([]);
|
||||
const [customTechnologies, setCustomTechnologies] = useState<string[]>([]);
|
||||
const [selectedGoals, setSelectedGoals] = useState<string[]>([]);
|
||||
const [goalsOther, setGoalsOther] = useState("");
|
||||
|
||||
const [shuffledWorkingOn, setShuffledWorkingOn] = useState<string[]>([...workingOnOptions]);
|
||||
const [shuffledGoals, setShuffledGoals] = useState<string[]>([...goalOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
const nonOther = workingOnOptions.filter((o) => o !== WORKING_ON_OTHER);
|
||||
setShuffledWorkingOn([...shuffleArray(nonOther), WORKING_ON_OTHER]);
|
||||
|
||||
const nonOtherGoals = goalOptions.filter((o) => o !== GOALS_OTHER);
|
||||
setShuffledGoals([...shuffleArray(nonOtherGoals), GOALS_OTHER]);
|
||||
}, []);
|
||||
|
||||
const showWorkingOnOther = selectedWorkingOn.includes(WORKING_ON_OTHER);
|
||||
const showGoalsOther = selectedGoals.includes(GOALS_OTHER);
|
||||
|
||||
return (
|
||||
<AppContainer className="bg-charcoal-900">
|
||||
<BackgroundWrapper>
|
||||
<MainCenteredContainer className="max-w-[26rem] rounded-lg border border-grid-bright bg-background-dimmed p-5 shadow-lg">
|
||||
<MainCenteredContainer className="max-w-[29rem] rounded-lg border border-grid-bright bg-background-dimmed p-5 shadow-lg">
|
||||
<div>
|
||||
<FormTitle
|
||||
LeadingIcon={<FolderIcon className="size-7 text-indigo-500" />}
|
||||
@@ -223,7 +355,9 @@ export default function Page() {
|
||||
)}
|
||||
<Fieldset>
|
||||
<InputGroup>
|
||||
<Label htmlFor={projectName.id}>Project name</Label>
|
||||
<Label htmlFor={projectName.id}>
|
||||
Project name <span className="text-text-bright">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
{...conform.input(projectName, { type: "text" })}
|
||||
placeholder="Your project name"
|
||||
@@ -237,6 +371,78 @@ export default function Page() {
|
||||
) : (
|
||||
<input {...conform.input(projectVersion, { type: "hidden" })} value={"v2"} />
|
||||
)}
|
||||
|
||||
<div className="border-t border-charcoal-700" />
|
||||
<InputGroup>
|
||||
<Label>What are you working on?</Label>
|
||||
<input type="hidden" name="workingOn" value={JSON.stringify(selectedWorkingOn)} />
|
||||
<MultiSelectField
|
||||
value={selectedWorkingOn}
|
||||
setValue={setSelectedWorkingOn}
|
||||
items={shuffledWorkingOn}
|
||||
icon={<CommandLineIcon className="mr-1 size-4 text-text-dimmed" />}
|
||||
/>
|
||||
{showWorkingOnOther && (
|
||||
<>
|
||||
<input type="hidden" name="workingOnOther" value={workingOnOther} />
|
||||
<Input
|
||||
type="text"
|
||||
variant="small"
|
||||
value={workingOnOther}
|
||||
onChange={(e) => setWorkingOnOther(e.target.value)}
|
||||
placeholder="Tell us what you're working on"
|
||||
spellCheck={false}
|
||||
containerClassName="h-8"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</InputGroup>
|
||||
|
||||
<InputGroup>
|
||||
<Label>What technologies are you using?</Label>
|
||||
<input
|
||||
type="hidden"
|
||||
name="technologies"
|
||||
value={JSON.stringify(selectedTechnologies)}
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
name="technologiesOther"
|
||||
value={JSON.stringify(customTechnologies)}
|
||||
/>
|
||||
<TechnologyPicker
|
||||
value={selectedTechnologies}
|
||||
onChange={setSelectedTechnologies}
|
||||
customValues={customTechnologies}
|
||||
onCustomValuesChange={setCustomTechnologies}
|
||||
/>
|
||||
</InputGroup>
|
||||
|
||||
<InputGroup>
|
||||
<Label>What are you trying to do with Trigger.dev?</Label>
|
||||
<input type="hidden" name="goals" value={JSON.stringify(selectedGoals)} />
|
||||
<MultiSelectField
|
||||
value={selectedGoals}
|
||||
setValue={setSelectedGoals}
|
||||
items={shuffledGoals}
|
||||
icon={<CommandLineIcon className="mr-1 size-4 text-text-dimmed" />}
|
||||
/>
|
||||
{showGoalsOther && (
|
||||
<>
|
||||
<input type="hidden" name="goalsOther" value={goalsOther} />
|
||||
<Input
|
||||
type="text"
|
||||
variant="small"
|
||||
value={goalsOther}
|
||||
onChange={(e) => setGoalsOther(e.target.value)}
|
||||
placeholder="Tell us what you're trying to do with Trigger.dev"
|
||||
spellCheck={false}
|
||||
containerClassName="h-8"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</InputGroup>
|
||||
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { conform, useForm } from "@conform-to/react";
|
||||
import { parse } from "@conform-to/zod";
|
||||
import { BuildingOffice2Icon } from "@heroicons/react/20/solid";
|
||||
import { BuildingOffice2Icon, GlobeAltIcon } from "@heroicons/react/20/solid";
|
||||
import { RadioGroup } from "@radix-ui/react-radio-group";
|
||||
import type { ActionFunction, LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { json, redirect } from "@remix-run/node";
|
||||
import { json, redirect, type ActionFunction, type LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { Form, useActionData, useNavigation } from "@remix-run/react";
|
||||
import { useState } from "react";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { BackgroundWrapper } from "~/components/BackgroundWrapper";
|
||||
@@ -19,18 +19,18 @@ import { Input } from "~/components/primitives/Input";
|
||||
import { InputGroup } from "~/components/primitives/InputGroup";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { RadioGroupItem } from "~/components/primitives/RadioButton";
|
||||
import { TextArea } from "~/components/primitives/TextArea";
|
||||
import { useFaviconUrl } from "~/hooks/useFaviconUrl";
|
||||
import { useFeatures } from "~/hooks/useFeatures";
|
||||
import { createOrganization } from "~/models/organization.server";
|
||||
import { NewOrganizationPresenter } from "~/presenters/NewOrganizationPresenter.server";
|
||||
import { requireUser, requireUserId } from "~/services/session.server";
|
||||
import { sendNewOrgMessage } from "~/services/slack.server";
|
||||
import { extractDomain, faviconUrl } from "~/utils/favicon";
|
||||
import { organizationPath, rootPath } from "~/utils/pathBuilder";
|
||||
|
||||
const schema = z.object({
|
||||
orgName: z.string().min(3).max(50),
|
||||
companySize: z.string().optional(),
|
||||
whyUseUs: z.string().optional(),
|
||||
companyUrl: z.string().optional(),
|
||||
});
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
@@ -53,23 +53,32 @@ export const action: ActionFunction = async ({ request }) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const companySize = submission.value.companySize ?? null;
|
||||
|
||||
const onboardingData: Record<string, string> = {};
|
||||
if (submission.value.companyUrl) {
|
||||
onboardingData.companyUrl = submission.value.companyUrl;
|
||||
}
|
||||
if (submission.value.companySize) {
|
||||
onboardingData.companySize = submission.value.companySize;
|
||||
}
|
||||
|
||||
let avatar: { type: "image"; url: string } | undefined;
|
||||
if (submission.value.companyUrl) {
|
||||
const domain = extractDomain(submission.value.companyUrl);
|
||||
if (domain) {
|
||||
avatar = { type: "image", url: faviconUrl(domain) };
|
||||
}
|
||||
}
|
||||
|
||||
const organization = await createOrganization({
|
||||
title: submission.value.orgName,
|
||||
userId: user.id,
|
||||
companySize: submission.value.companySize ?? null,
|
||||
companySize,
|
||||
onboardingData: Object.keys(onboardingData).length > 0 ? onboardingData : undefined,
|
||||
avatar,
|
||||
});
|
||||
|
||||
const whyUseUs = formData.get("whyUseUs");
|
||||
|
||||
if (whyUseUs) {
|
||||
await sendNewOrgMessage({
|
||||
orgName: submission.value.orgName,
|
||||
whyUseUs: whyUseUs.toString(),
|
||||
userEmail: user.email,
|
||||
});
|
||||
}
|
||||
|
||||
// Preserve Vercel integration params if present
|
||||
const url = new URL(request.url);
|
||||
const code = url.searchParams.get("code");
|
||||
const configurationId = url.searchParams.get("configurationId");
|
||||
@@ -77,7 +86,6 @@ export const action: ActionFunction = async ({ request }) => {
|
||||
const next = url.searchParams.get("next");
|
||||
|
||||
if (code && configurationId && integration === "vercel") {
|
||||
// Redirect to projects/new with params preserved
|
||||
const params = new URLSearchParams({
|
||||
code,
|
||||
configurationId,
|
||||
@@ -101,10 +109,12 @@ export default function NewOrganizationPage() {
|
||||
const lastSubmission = useActionData();
|
||||
const { isManagedCloud } = useFeatures();
|
||||
const navigation = useNavigation();
|
||||
const [companyUrl, setCompanyUrl] = useState("");
|
||||
const faviconUrl = useFaviconUrl(companyUrl);
|
||||
const [faviconError, setFaviconError] = useState(false);
|
||||
|
||||
const [form, { orgName }] = useForm({
|
||||
id: "create-organization",
|
||||
// TODO: type this
|
||||
lastSubmission: lastSubmission as any,
|
||||
onValidate({ formData }) {
|
||||
return parse(formData, { schema });
|
||||
@@ -115,6 +125,21 @@ export default function NewOrganizationPage() {
|
||||
|
||||
const isLoading = navigation.state === "submitting" || navigation.state === "loading";
|
||||
|
||||
const urlIcon =
|
||||
faviconUrl && !faviconError ? (
|
||||
<img
|
||||
src={faviconUrl}
|
||||
alt=""
|
||||
width={16}
|
||||
height={16}
|
||||
className="ml-0.5 shrink-0 rounded-sm"
|
||||
onError={() => setFaviconError(true)}
|
||||
onLoad={() => setFaviconError(false)}
|
||||
/>
|
||||
) : (
|
||||
GlobeAltIcon
|
||||
);
|
||||
|
||||
return (
|
||||
<AppContainer className="bg-charcoal-900">
|
||||
<BackgroundWrapper>
|
||||
@@ -126,20 +151,36 @@ export default function NewOrganizationPage() {
|
||||
<Form method="post" {...form.props}>
|
||||
<Fieldset>
|
||||
<InputGroup>
|
||||
<Label htmlFor={orgName.id}>Organization name</Label>
|
||||
<Label htmlFor={orgName.id}>Organization name *</Label>
|
||||
<Input
|
||||
{...conform.input(orgName, { type: "text" })}
|
||||
placeholder="Your Organization name"
|
||||
icon={BuildingOffice2Icon}
|
||||
autoFocus
|
||||
/>
|
||||
<Hint>E.g. your company name or your workspace name.</Hint>
|
||||
<Hint>Normally your company name.</Hint>
|
||||
<FormError id={orgName.errorId}>{orgName.error}</FormError>
|
||||
</InputGroup>
|
||||
{isManagedCloud && (
|
||||
<>
|
||||
<InputGroup>
|
||||
<Label htmlFor={"companySize"}>Number of employees</Label>
|
||||
<Label htmlFor="companyUrl">URL</Label>
|
||||
<Input
|
||||
id="companyUrl"
|
||||
name="companyUrl"
|
||||
type="url"
|
||||
placeholder="Your Organization URL"
|
||||
icon={urlIcon}
|
||||
value={companyUrl}
|
||||
onChange={(e) => {
|
||||
setCompanyUrl(e.target.value);
|
||||
setFaviconError(false);
|
||||
}}
|
||||
/>
|
||||
<Hint>Add your company URL and we'll use it as your organization's logo.</Hint>
|
||||
</InputGroup>
|
||||
<InputGroup>
|
||||
<Label htmlFor="companySize">Number of employees</Label>
|
||||
<RadioGroup
|
||||
name="companySize"
|
||||
className="flex items-center justify-between gap-2"
|
||||
@@ -174,13 +215,6 @@ export default function NewOrganizationPage() {
|
||||
/>
|
||||
</RadioGroup>
|
||||
</InputGroup>
|
||||
<InputGroup>
|
||||
<Label htmlFor={"whyUseUs"}>What problem are you trying to solve?</Label>
|
||||
<TextArea name="whyUseUs" rows={4} spellCheck={false} />
|
||||
<Hint>
|
||||
Your answer will help us understand your use case and provide better support.
|
||||
</Hint>
|
||||
</InputGroup>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -192,7 +226,7 @@ export default function NewOrganizationPage() {
|
||||
}
|
||||
cancelButton={
|
||||
hasOrganizations ? (
|
||||
<LinkButton to={rootPath()} variant={"tertiary/small"}>
|
||||
<LinkButton to={rootPath()} variant={"secondary/small"}>
|
||||
Cancel
|
||||
</LinkButton>
|
||||
) : null
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
CreateInputStreamWaitpointRequestBody,
|
||||
type CreateInputStreamWaitpointResponseBody,
|
||||
} from "@trigger.dev/core/v3";
|
||||
import { WaitpointId } from "@trigger.dev/core/v3/isomorphic";
|
||||
import { $replica } from "~/db.server";
|
||||
import { createWaitpointTag, MAX_TAGS_PER_WAITPOINT } from "~/models/waitpointTag.server";
|
||||
import {
|
||||
deleteInputStreamWaitpoint,
|
||||
setInputStreamWaitpoint,
|
||||
} from "~/services/inputStreamWaitpointCache.server";
|
||||
import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server";
|
||||
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
||||
import { parseDelay } from "~/utils/delays";
|
||||
import { resolveIdempotencyKeyTTL } from "~/utils/idempotencyKeys.server";
|
||||
import { engine } from "~/v3/runEngine.server";
|
||||
import { ServiceValidationError } from "~/v3/services/baseService.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
runFriendlyId: z.string(),
|
||||
});
|
||||
|
||||
const { action, loader } = createActionApiRoute(
|
||||
{
|
||||
params: ParamsSchema,
|
||||
body: CreateInputStreamWaitpointRequestBody,
|
||||
maxContentLength: 1024 * 10, // 10KB
|
||||
method: "POST",
|
||||
},
|
||||
async ({ authentication, body, params }) => {
|
||||
try {
|
||||
const run = await $replica.taskRun.findFirst({
|
||||
where: {
|
||||
friendlyId: params.runFriendlyId,
|
||||
runtimeEnvironmentId: authentication.environment.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
friendlyId: true,
|
||||
realtimeStreamsVersion: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!run) {
|
||||
return json({ error: "Run not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const idempotencyKeyExpiresAt = body.idempotencyKeyTTL
|
||||
? resolveIdempotencyKeyTTL(body.idempotencyKeyTTL)
|
||||
: undefined;
|
||||
|
||||
const timeout = await parseDelay(body.timeout);
|
||||
|
||||
// Process tags (same pattern as api.v1.waitpoints.tokens.ts)
|
||||
const bodyTags = typeof body.tags === "string" ? [body.tags] : body.tags;
|
||||
|
||||
if (bodyTags && bodyTags.length > MAX_TAGS_PER_WAITPOINT) {
|
||||
throw new ServiceValidationError(
|
||||
`Waitpoints can only have ${MAX_TAGS_PER_WAITPOINT} tags, you're trying to set ${bodyTags.length}.`
|
||||
);
|
||||
}
|
||||
|
||||
if (bodyTags && bodyTags.length > 0) {
|
||||
for (const tag of bodyTags) {
|
||||
await createWaitpointTag({
|
||||
tag,
|
||||
environmentId: authentication.environment.id,
|
||||
projectId: authentication.environment.projectId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Step 1: Create the waitpoint
|
||||
const result = await engine.createManualWaitpoint({
|
||||
environmentId: authentication.environment.id,
|
||||
projectId: authentication.environment.projectId,
|
||||
idempotencyKey: body.idempotencyKey,
|
||||
idempotencyKeyExpiresAt,
|
||||
timeout,
|
||||
tags: bodyTags,
|
||||
});
|
||||
|
||||
// Step 2: Cache the mapping in Redis for fast lookup from .send()
|
||||
const ttlMs = timeout ? timeout.getTime() - Date.now() : undefined;
|
||||
await setInputStreamWaitpoint(
|
||||
run.friendlyId,
|
||||
body.streamId,
|
||||
result.waitpoint.id,
|
||||
ttlMs && ttlMs > 0 ? ttlMs : undefined
|
||||
);
|
||||
|
||||
// Step 3: Check if data was already sent to this input stream (race condition handling).
|
||||
// If .send() landed before .wait(), the data is in the S2 stream but no waitpoint
|
||||
// existed to complete. We check from the client's last known position.
|
||||
if (!result.isCached) {
|
||||
try {
|
||||
const realtimeStream = getRealtimeStreamInstance(
|
||||
authentication.environment,
|
||||
run.realtimeStreamsVersion
|
||||
);
|
||||
|
||||
const records = await realtimeStream.readRecords(
|
||||
run.friendlyId,
|
||||
`$trigger.input:${body.streamId}`,
|
||||
body.lastSeqNum
|
||||
);
|
||||
|
||||
if (records.length > 0) {
|
||||
const record = records[0]!;
|
||||
|
||||
// Record data is the raw user payload — no wrapper to unwrap
|
||||
await engine.completeWaitpoint({
|
||||
id: result.waitpoint.id,
|
||||
output: {
|
||||
value: record.data,
|
||||
type: "application/json",
|
||||
isError: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Clean up the Redis cache since we completed it ourselves
|
||||
await deleteInputStreamWaitpoint(run.friendlyId, body.streamId);
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal: if the S2 check fails, the waitpoint is still PENDING.
|
||||
// The next .send() will complete it via the Redis cache path.
|
||||
}
|
||||
}
|
||||
|
||||
return json<CreateInputStreamWaitpointResponseBody>({
|
||||
waitpointId: WaitpointId.toFriendlyId(result.waitpoint.id),
|
||||
isCached: result.isCached,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ServiceValidationError) {
|
||||
return json({ error: error.message }, { status: 422 });
|
||||
} else if (error instanceof Error) {
|
||||
return json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return json({ error: "Something went wrong" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export { action, loader };
|
||||
@@ -166,7 +166,7 @@ async function responseHeaders(
|
||||
const claims = {
|
||||
sub: environment.id,
|
||||
pub: true,
|
||||
scopes: [`read:runs:${run.friendlyId}`],
|
||||
scopes: [`read:runs:${run.friendlyId}`, `write:inputStreams:${run.friendlyId}`],
|
||||
realtime,
|
||||
};
|
||||
|
||||
|
||||
@@ -99,11 +99,8 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
||||
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")
|
||||
) {
|
||||
// Check for stream parsing errors (e.g. invalid JSON)
|
||||
if (error.message.includes("Invalid JSON")) {
|
||||
return json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { conform, useForm } from "@conform-to/react";
|
||||
import { parse } from "@conform-to/zod";
|
||||
import { ArrowRightIcon, EnvelopeIcon, HeartIcon, UserIcon } from "@heroicons/react/20/solid";
|
||||
import { ArrowRightIcon, EnvelopeIcon, UserGroupIcon, UserIcon } from "@heroicons/react/20/solid";
|
||||
import { HandRaisedIcon } from "@heroicons/react/24/solid";
|
||||
import { ActionFunction, json } from "@remix-run/node";
|
||||
import { RadioGroup } from "@radix-ui/react-radio-group";
|
||||
import { json, type ActionFunction } from "@remix-run/node";
|
||||
import { Form, useActionData } from "@remix-run/react";
|
||||
import { motion } from "framer-motion";
|
||||
import { forwardRef, useState } from "react";
|
||||
import { forwardRef, useEffect, useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { AppContainer, MainCenteredContainer } from "~/components/layout/AppLayout";
|
||||
import { BackgroundWrapper } from "~/components/BackgroundWrapper";
|
||||
@@ -18,6 +19,8 @@ import { Hint } from "~/components/primitives/Hint";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { InputGroup } from "~/components/primitives/InputGroup";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { RadioGroupItem } from "~/components/primitives/RadioButton";
|
||||
import { Select, SelectItem } from "~/components/primitives/Select";
|
||||
import { prisma } from "~/db.server";
|
||||
import { useFeatures } from "~/hooks/useFeatures";
|
||||
import { useUser } from "~/hooks/useUser";
|
||||
@@ -27,6 +30,40 @@ import { requireUserId } from "~/services/session.server";
|
||||
import { rootPath } from "~/utils/pathBuilder";
|
||||
import { getVercelInstallParams } from "~/v3/vercel";
|
||||
|
||||
const referralSourceOptions = [
|
||||
"Search engine",
|
||||
"YouTube",
|
||||
"Twitter/X",
|
||||
"LinkedIn",
|
||||
"Word of mouth",
|
||||
"AI assistant/LLM",
|
||||
"Blog/article",
|
||||
"Event",
|
||||
"Other",
|
||||
] as const;
|
||||
|
||||
const roleOptions = [
|
||||
"Founder",
|
||||
"Staff/principal engineer",
|
||||
"Senior software engineer",
|
||||
"Software engineer",
|
||||
"AI/ML engineer",
|
||||
"Engineering manager",
|
||||
"Product engineer",
|
||||
"Non technical builder using AI tools",
|
||||
"Student/learner",
|
||||
"Other",
|
||||
] as const;
|
||||
|
||||
function shuffleArray<T>(arr: T[]): T[] {
|
||||
const shuffled = [...arr];
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||
}
|
||||
return shuffled;
|
||||
}
|
||||
|
||||
function createSchema(
|
||||
constraints: {
|
||||
isEmailUnique?: (email: string) => Promise<boolean>;
|
||||
@@ -40,13 +77,11 @@ function createSchema(
|
||||
.email()
|
||||
.superRefine((email, ctx) => {
|
||||
if (constraints.isEmailUnique === undefined) {
|
||||
//client-side validation skips this
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: conform.VALIDATION_UNDEFINED,
|
||||
});
|
||||
} else {
|
||||
// Tell zod this is an async validation by returning the promise
|
||||
return constraints.isEmailUnique(email).then((isUnique) => {
|
||||
if (isUnique) {
|
||||
return;
|
||||
@@ -61,6 +96,9 @@ function createSchema(
|
||||
}),
|
||||
confirmEmail: z.string(),
|
||||
referralSource: z.string().optional(),
|
||||
referralSourceOther: z.string().optional(),
|
||||
role: z.string().optional(),
|
||||
roleOther: z.string().optional(),
|
||||
})
|
||||
.refine((value) => value.email === value.confirmEmail, {
|
||||
message: "Emails must match",
|
||||
@@ -99,19 +137,39 @@ export const action: ActionFunction = async ({ request }) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const updatedUser = await updateUser({
|
||||
const onboardingData: Record<string, string | undefined> = {};
|
||||
|
||||
if (submission.value.referralSource) {
|
||||
onboardingData.referralSource = submission.value.referralSource;
|
||||
if (submission.value.referralSource === "Other" && submission.value.referralSourceOther) {
|
||||
onboardingData.referralSourceOther = submission.value.referralSourceOther;
|
||||
}
|
||||
}
|
||||
|
||||
if (submission.value.role) {
|
||||
onboardingData.role = submission.value.role;
|
||||
if (submission.value.role === "Other" && submission.value.roleOther) {
|
||||
onboardingData.roleOther = submission.value.roleOther;
|
||||
}
|
||||
}
|
||||
|
||||
const referralSourceForLegacy =
|
||||
submission.value.referralSource === "Other" && submission.value.referralSourceOther
|
||||
? `Other: ${submission.value.referralSourceOther}`
|
||||
: submission.value.referralSource;
|
||||
|
||||
await updateUser({
|
||||
id: userId,
|
||||
name: submission.value.name,
|
||||
email: submission.value.email,
|
||||
referralSource: submission.value.referralSource,
|
||||
referralSource: referralSourceForLegacy,
|
||||
onboardingData: Object.keys(onboardingData).length > 0 ? onboardingData : undefined,
|
||||
});
|
||||
|
||||
// Preserve Vercel integration params if present
|
||||
const vercelParams = getVercelInstallParams(request);
|
||||
let redirectUrl = rootPath();
|
||||
|
||||
if (vercelParams) {
|
||||
// Redirect to orgs/new with params preserved
|
||||
const params = new URLSearchParams({
|
||||
code: vercelParams.code,
|
||||
configurationId: vercelParams.configurationId,
|
||||
@@ -143,10 +201,24 @@ export default function Page() {
|
||||
const lastSubmission = useActionData();
|
||||
const [enteredEmail, setEnteredEmail] = useState<string>(user.email ?? "");
|
||||
const { isManagedCloud } = useFeatures();
|
||||
const [selectedReferralSource, setSelectedReferralSource] = useState<string | undefined>();
|
||||
const [selectedRole, setSelectedRole] = useState<string>("");
|
||||
|
||||
const [form, { name, email, confirmEmail, referralSource }] = useForm({
|
||||
const [shuffledReferralSources, setShuffledReferralSources] = useState<string[]>([
|
||||
...referralSourceOptions,
|
||||
]);
|
||||
const [shuffledRoles, setShuffledRoles] = useState<string[]>([...roleOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
const nonOtherReferral = referralSourceOptions.filter((r) => r !== "Other");
|
||||
setShuffledReferralSources([...shuffleArray(nonOtherReferral), "Other"]);
|
||||
|
||||
const nonOtherRoles = roleOptions.filter((r) => r !== "Other");
|
||||
setShuffledRoles([...shuffleArray(nonOtherRoles), "Other"]);
|
||||
}, []);
|
||||
|
||||
const [form, { name, email, confirmEmail }] = useForm({
|
||||
id: "confirm-basic-details",
|
||||
// TODO: type this
|
||||
lastSubmission: lastSubmission as any,
|
||||
onValidate({ formData }) {
|
||||
return parse(formData, { schema: createSchema() });
|
||||
@@ -159,7 +231,7 @@ export default function Page() {
|
||||
return (
|
||||
<AppContainer className="bg-charcoal-900">
|
||||
<BackgroundWrapper>
|
||||
<MainCenteredContainer className="max-w-[26rem] rounded-lg border border-grid-bright bg-background-dimmed p-5 shadow-lg">
|
||||
<MainCenteredContainer className="max-w-[29rem] rounded-lg border border-grid-bright bg-background-dimmed p-5 shadow-lg">
|
||||
<Form method="post" {...form.props}>
|
||||
<FormTitle
|
||||
title="Welcome to Trigger.dev"
|
||||
@@ -187,7 +259,9 @@ export default function Page() {
|
||||
/>
|
||||
<Fieldset>
|
||||
<InputGroup>
|
||||
<Label htmlFor={name.id}>Full name</Label>
|
||||
<Label htmlFor={name.id}>
|
||||
Full name <span className="text-text-bright">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
{...conform.input(name, { type: "text" })}
|
||||
defaultValue={user.name ?? ""}
|
||||
@@ -195,11 +269,12 @@ export default function Page() {
|
||||
icon={UserIcon}
|
||||
autoFocus
|
||||
/>
|
||||
<Hint>Your team will see this name and we'll use it to contact you.</Hint>
|
||||
<FormError id={name.errorId}>{name.error}</FormError>
|
||||
</InputGroup>
|
||||
<InputGroup>
|
||||
<Label htmlFor={email.id}>Email</Label>
|
||||
<Label htmlFor={email.id}>
|
||||
Email <span className="text-text-bright">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
{...conform.input(email, { type: "email" })}
|
||||
defaultValue={enteredEmail}
|
||||
@@ -210,9 +285,6 @@ export default function Page() {
|
||||
icon={EnvelopeIcon}
|
||||
spellCheck={false}
|
||||
/>
|
||||
{!shouldShowConfirm && (
|
||||
<Hint>Confirm this is the email you'd like for your Trigger.dev account.</Hint>
|
||||
)}
|
||||
<FormError id={email.errorId}>{email.error}</FormError>
|
||||
</InputGroup>
|
||||
|
||||
@@ -225,9 +297,6 @@ export default function Page() {
|
||||
icon={EnvelopeIcon}
|
||||
spellCheck={false}
|
||||
/>
|
||||
<Hint>
|
||||
Check this is the email you'd like associated with your Trigger.dev account.
|
||||
</Hint>
|
||||
<FormError id={confirmEmail.errorId}>{confirmEmail.error}</FormError>
|
||||
</InputGroup>
|
||||
) : (
|
||||
@@ -235,16 +304,82 @@ export default function Page() {
|
||||
<input {...conform.input(confirmEmail, { type: "hidden" })} value={user.email} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{isManagedCloud && (
|
||||
<InputGroup>
|
||||
<Label htmlFor={confirmEmail.id}>How did you hear about us?</Label>
|
||||
<Input
|
||||
{...conform.input(referralSource, { type: "text" })}
|
||||
placeholder="LLM, Google, X (Twitter)…?"
|
||||
icon={HeartIcon}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</InputGroup>
|
||||
<>
|
||||
<div className="border-t border-charcoal-700" />
|
||||
<InputGroup>
|
||||
<Label className="mb-0.5" id="referral-label">
|
||||
How did you hear about us?
|
||||
</Label>
|
||||
<input
|
||||
type="hidden"
|
||||
name="referralSource"
|
||||
value={selectedReferralSource ?? ""}
|
||||
/>
|
||||
<RadioGroup
|
||||
value={selectedReferralSource}
|
||||
onValueChange={setSelectedReferralSource}
|
||||
className="flex flex-wrap gap-2"
|
||||
aria-labelledby="referral-label"
|
||||
>
|
||||
{shuffledReferralSources.map((option) => (
|
||||
<RadioGroupItem
|
||||
key={option}
|
||||
id={`referral-${option}`}
|
||||
label={option}
|
||||
value={option}
|
||||
variant="button/small"
|
||||
/>
|
||||
))}
|
||||
</RadioGroup>
|
||||
{selectedReferralSource === "Other" && (
|
||||
<div className="mt-2">
|
||||
<Input
|
||||
name="referralSourceOther"
|
||||
type="text"
|
||||
placeholder="What was the source?"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</InputGroup>
|
||||
|
||||
<InputGroup className="mt-1">
|
||||
<Label id="role-label">What role fits you best?</Label>
|
||||
<input type="hidden" name="role" value={selectedRole} />
|
||||
<Select<string, string>
|
||||
value={selectedRole}
|
||||
setValue={setSelectedRole}
|
||||
placeholder="Select an option"
|
||||
aria-labelledby="role-label"
|
||||
variant="secondary/small"
|
||||
dropdownIcon
|
||||
icon={<UserGroupIcon className="mr-1 size-4.5 text-text-dimmed" />}
|
||||
items={shuffledRoles}
|
||||
className="h-8 min-w-0 border-0 bg-charcoal-750 pl-2 text-sm text-text-dimmed ring-charcoal-600 transition hover:bg-charcoal-650 hover:text-text-dimmed hover:ring-1"
|
||||
text={(v) => (v ? <span className="text-text-bright">{v}</span> : undefined)}
|
||||
>
|
||||
{(items) =>
|
||||
items.map((item) => (
|
||||
<SelectItem key={item} value={item}>
|
||||
<span className="text-text-bright">{item}</span>
|
||||
</SelectItem>
|
||||
))
|
||||
}
|
||||
</Select>
|
||||
{selectedRole === "Other" && (
|
||||
<div>
|
||||
<Input
|
||||
name="roleOther"
|
||||
type="text"
|
||||
placeholder="What's your role?"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</InputGroup>
|
||||
</>
|
||||
)}
|
||||
|
||||
<FormButtons
|
||||
|
||||
@@ -2,7 +2,7 @@ import { redirect, type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { prisma } from "~/db.server";
|
||||
import { SelectBestEnvironmentPresenter } from "~/presenters/SelectBestEnvironmentPresenter.server";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
import { ProjectParamSchema, v3ProjectSettingsPath } from "~/utils/pathBuilder";
|
||||
import { ProjectParamSchema, v3ProjectSettingsGeneralPath } from "~/utils/pathBuilder";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const user = await requireUser(request);
|
||||
@@ -39,5 +39,5 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const selector = new SelectBestEnvironmentPresenter();
|
||||
const environment = await selector.selectBestEnvironment(project.id, user, project.environments);
|
||||
|
||||
return redirect(v3ProjectSettingsPath({ slug: organizationSlug }, project, environment));
|
||||
return redirect(v3ProjectSettingsGeneralPath({ slug: organizationSlug }, project, environment));
|
||||
};
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { $replica } from "~/db.server";
|
||||
import {
|
||||
getInputStreamWaitpoint,
|
||||
deleteInputStreamWaitpoint,
|
||||
} from "~/services/inputStreamWaitpointCache.server";
|
||||
import {
|
||||
createActionApiRoute,
|
||||
createLoaderApiRoute,
|
||||
} from "~/services/routeBuilders/apiBuilder.server";
|
||||
import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server";
|
||||
import { engine } from "~/v3/runEngine.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
runId: z.string(),
|
||||
streamId: z.string(),
|
||||
});
|
||||
|
||||
const BodySchema = z.object({
|
||||
data: z.unknown(),
|
||||
});
|
||||
|
||||
// POST: Send data to an input stream
|
||||
const { action } = createActionApiRoute(
|
||||
{
|
||||
params: ParamsSchema,
|
||||
maxContentLength: 1024 * 1024, // 1MB max
|
||||
allowJWT: true,
|
||||
corsStrategy: "all",
|
||||
authorization: {
|
||||
action: "write",
|
||||
resource: (params) => ({ inputStreams: params.runId }),
|
||||
superScopes: ["write:inputStreams", "write:all", "admin"],
|
||||
},
|
||||
},
|
||||
async ({ request, params, authentication }) => {
|
||||
const run = await $replica.taskRun.findFirst({
|
||||
where: {
|
||||
friendlyId: params.runId,
|
||||
runtimeEnvironmentId: authentication.environment.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
friendlyId: true,
|
||||
completedAt: true,
|
||||
realtimeStreamsVersion: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!run) {
|
||||
return json({ ok: false, error: "Run not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (run.completedAt) {
|
||||
return json(
|
||||
{ ok: false, error: "Cannot send to input stream on a completed run" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = BodySchema.safeParse(await request.json());
|
||||
|
||||
if (!body.success) {
|
||||
return json({ ok: false, error: "Invalid request body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const realtimeStream = getRealtimeStreamInstance(
|
||||
authentication.environment,
|
||||
run.realtimeStreamsVersion
|
||||
);
|
||||
|
||||
// Build the input stream record (raw user data, no wrapper)
|
||||
const recordId = `inp_${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
|
||||
const record = JSON.stringify(body.data.data);
|
||||
|
||||
// Append the record to the per-stream S2 stream (auto-creates on first write)
|
||||
await realtimeStream.appendPart(
|
||||
record,
|
||||
recordId,
|
||||
run.friendlyId,
|
||||
`$trigger.input:${params.streamId}`
|
||||
);
|
||||
|
||||
// Check Redis cache for a linked .wait() waitpoint (fast, no DB hit if none)
|
||||
// Get first, complete, then delete — so the mapping survives if completeWaitpoint throws
|
||||
const waitpointId = await getInputStreamWaitpoint(params.runId, params.streamId);
|
||||
if (waitpointId) {
|
||||
await engine.completeWaitpoint({
|
||||
id: waitpointId,
|
||||
output: {
|
||||
value: JSON.stringify(body.data.data),
|
||||
type: "application/json",
|
||||
isError: false,
|
||||
},
|
||||
});
|
||||
await deleteInputStreamWaitpoint(params.runId, params.streamId);
|
||||
}
|
||||
|
||||
return json({ ok: true });
|
||||
}
|
||||
);
|
||||
|
||||
// GET: SSE stream for reading input stream data (used by the in-task SSE tail)
|
||||
const loader = createLoaderApiRoute(
|
||||
{
|
||||
params: ParamsSchema,
|
||||
allowJWT: true,
|
||||
corsStrategy: "all",
|
||||
findResource: async (params, auth) => {
|
||||
return $replica.taskRun.findFirst({
|
||||
where: {
|
||||
friendlyId: params.runId,
|
||||
runtimeEnvironmentId: auth.environment.id,
|
||||
},
|
||||
include: {
|
||||
batch: {
|
||||
select: {
|
||||
friendlyId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
authorization: {
|
||||
action: "read",
|
||||
resource: (run) => ({
|
||||
runs: run.friendlyId,
|
||||
tags: run.runTags,
|
||||
batch: run.batch?.friendlyId,
|
||||
tasks: run.taskIdentifier,
|
||||
}),
|
||||
superScopes: ["read:runs", "read:all", "admin"],
|
||||
},
|
||||
},
|
||||
async ({ params, request, resource: run, authentication }) => {
|
||||
const lastEventId = request.headers.get("Last-Event-ID") || undefined;
|
||||
|
||||
const timeoutInSecondsRaw = request.headers.get("Timeout-Seconds") ?? undefined;
|
||||
const timeoutInSeconds =
|
||||
timeoutInSecondsRaw !== undefined ? parseInt(timeoutInSecondsRaw, 10) : undefined;
|
||||
|
||||
if (timeoutInSeconds !== undefined && isNaN(timeoutInSeconds)) {
|
||||
return new Response("Invalid timeout seconds", { status: 400 });
|
||||
}
|
||||
|
||||
if (timeoutInSeconds !== undefined && timeoutInSeconds < 1) {
|
||||
return new Response("Timeout seconds must be greater than 0", { status: 400 });
|
||||
}
|
||||
|
||||
if (timeoutInSeconds !== undefined && timeoutInSeconds > 600) {
|
||||
return new Response("Timeout seconds must be less than 600", { status: 400 });
|
||||
}
|
||||
|
||||
const realtimeStream = getRealtimeStreamInstance(
|
||||
authentication.environment,
|
||||
run.realtimeStreamsVersion
|
||||
);
|
||||
|
||||
// Read from the internal S2 stream name (prefixed to avoid user stream collisions)
|
||||
return realtimeStream.streamResponse(
|
||||
request,
|
||||
run.friendlyId,
|
||||
`$trigger.input:${params.streamId}`,
|
||||
request.signal,
|
||||
{
|
||||
lastEventId,
|
||||
timeoutInSeconds,
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export { action, loader };
|
||||
@@ -173,6 +173,7 @@ export function MetricWidget({
|
||||
const [response, setResponse] = useState<MetricWidgetActionResponse | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const isDirtyRef = useRef(false);
|
||||
|
||||
// Track the latest props so the submit callback always uses fresh values
|
||||
// without needing to be recreated (which would cause useInterval to re-register listeners).
|
||||
@@ -180,8 +181,11 @@ export function MetricWidget({
|
||||
propsRef.current = props;
|
||||
|
||||
const submit = useCallback(() => {
|
||||
// Skip fetching if the widget is not visible on screen
|
||||
if (!isVisibleRef.current) return;
|
||||
if (!isVisibleRef.current) {
|
||||
isDirtyRef.current = true;
|
||||
return;
|
||||
}
|
||||
isDirtyRef.current = false;
|
||||
|
||||
// Abort any in-flight request for this widget
|
||||
abortControllerRef.current?.abort();
|
||||
@@ -225,7 +229,7 @@ export function MetricWidget({
|
||||
// When a widget scrolls into view and has no data yet, trigger a load.
|
||||
const { ref: visibilityRef, isVisibleRef } = useElementVisibility({
|
||||
onVisibilityChange: (visible) => {
|
||||
if (visible && !response) {
|
||||
if (visible && (!response || isDirtyRef.current)) {
|
||||
submit();
|
||||
}
|
||||
},
|
||||
|
||||
+3
-3
@@ -43,7 +43,7 @@ import { requireUserId } from "~/services/session.server";
|
||||
import {
|
||||
githubAppInstallPath,
|
||||
EnvironmentParamSchema,
|
||||
v3ProjectSettingsPath,
|
||||
v3ProjectSettingsIntegrationsPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { type BranchTrackingConfig } from "~/v3/github";
|
||||
@@ -459,7 +459,7 @@ export function ConnectGitHubRepoModal({
|
||||
navigate(
|
||||
githubAppInstallPath(
|
||||
organizationSlug,
|
||||
`${v3ProjectSettingsPath(
|
||||
`${v3ProjectSettingsIntegrationsPath(
|
||||
{ slug: organizationSlug },
|
||||
{ slug: projectSlug },
|
||||
{ slug: environmentSlug }
|
||||
@@ -567,7 +567,7 @@ export function GitHubConnectionPrompt({
|
||||
redirectUrl?: string;
|
||||
}) {
|
||||
|
||||
const githubInstallationRedirect = redirectUrl || v3ProjectSettingsPath({ slug: organizationSlug }, { slug: projectSlug }, { slug: environmentSlug });
|
||||
const githubInstallationRedirect = redirectUrl || v3ProjectSettingsIntegrationsPath({ slug: organizationSlug }, { slug: projectSlug }, { slug: environmentSlug });
|
||||
return (
|
||||
<Fieldset>
|
||||
<InputGroup fullWidth>
|
||||
|
||||
+63
-49
@@ -58,6 +58,7 @@ import { RunTimeline, RunTimelineEvent, SpanTimeline } from "~/components/run/Ru
|
||||
import { PacketDisplay } from "~/components/runs/v3/PacketDisplay";
|
||||
import { RunIcon } from "~/components/runs/v3/RunIcon";
|
||||
import { RunTag } from "~/components/runs/v3/RunTag";
|
||||
import { TruncatedCopyableValue } from "~/components/primitives/TruncatedCopyableValue";
|
||||
import { SpanEvents } from "~/components/runs/v3/SpanEvents";
|
||||
import { SpanTitle } from "~/components/runs/v3/SpanTitle";
|
||||
import { TaskRunAttemptStatusCombo } from "~/components/runs/v3/TaskRunAttemptStatus";
|
||||
@@ -126,7 +127,19 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
organizationSlug,
|
||||
runParam,
|
||||
spanParam,
|
||||
error,
|
||||
linkedRunId,
|
||||
error:
|
||||
error instanceof Error
|
||||
? {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
cause:
|
||||
error.cause instanceof Error
|
||||
? { name: error.cause.name, message: error.cause.message }
|
||||
: error.cause,
|
||||
}
|
||||
: error,
|
||||
});
|
||||
return redirectWithErrorMessage(
|
||||
v3RunPath(
|
||||
@@ -992,7 +1005,7 @@ function RunBody({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center flex-wrap py-2 justify-between gap-2 border-t border-grid-dimmed px-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-grid-dimmed px-2 py-2">
|
||||
<div className="flex items-center gap-4">
|
||||
{run.friendlyId !== runParam && (
|
||||
<LinkButton
|
||||
@@ -1036,9 +1049,11 @@ function RunBody({
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="min-w-[140px] p-1" align="end">
|
||||
<PopoverMenuItem
|
||||
to={`${v3LogsPath(organization, project, environment)}?runId=${runParam}&from=${
|
||||
new Date(run.createdAt).getTime() - 60000
|
||||
}`}
|
||||
to={`${v3LogsPath(
|
||||
organization,
|
||||
project,
|
||||
environment
|
||||
)}?runId=${runParam}&from=${new Date(run.createdAt).getTime() - 60000}`}
|
||||
title="View logs"
|
||||
icon={ArrowRightIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
@@ -1189,50 +1204,6 @@ function SpanEntity({ span }: { span: Span }) {
|
||||
<Property.Label>Message</Property.Label>
|
||||
<Property.Value className="whitespace-pre-wrap">{span.message}</Property.Value>
|
||||
</Property.Item>
|
||||
{span.triggeredRuns.length > 0 && (
|
||||
<Property.Item>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Header3>Triggered runs</Header3>
|
||||
<Table containerClassName="max-h-[12.5rem]">
|
||||
<TableHeader className="bg-background-bright">
|
||||
<TableRow>
|
||||
<TableHeaderCell>Run #</TableHeaderCell>
|
||||
<TableHeaderCell>Task</TableHeaderCell>
|
||||
<TableHeaderCell>Version</TableHeaderCell>
|
||||
<TableHeaderCell>Created at</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{span.triggeredRuns.map((run) => {
|
||||
const path = v3RunSpanPath(
|
||||
organization,
|
||||
project,
|
||||
environment,
|
||||
{ friendlyId: run.friendlyId },
|
||||
{ spanId: run.spanId }
|
||||
);
|
||||
return (
|
||||
<TableRow key={run.friendlyId}>
|
||||
<TableCell to={path} actionClassName="py-1.5" rowHoverStyle="bright">
|
||||
{run.number}
|
||||
</TableCell>
|
||||
<TableCell to={path} actionClassName="py-1.5" rowHoverStyle="bright">
|
||||
{run.taskIdentifier}
|
||||
</TableCell>
|
||||
<TableCell to={path} actionClassName="py-1.5" rowHoverStyle="bright">
|
||||
{run.taskVersion ?? "–"}
|
||||
</TableCell>
|
||||
<TableCell to={path} actionClassName="py-1.5" rowHoverStyle="bright">
|
||||
<DateTime date={run.createdAt} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</Property.Item>
|
||||
)}
|
||||
</Property.Table>
|
||||
{span.events.length > 0 && <SpanEvents spanEvents={span.events} />}
|
||||
{span.properties !== undefined ? (
|
||||
@@ -1257,6 +1228,48 @@ function SpanEntity({ span }: { span: Span }) {
|
||||
showOpenInModal
|
||||
/>
|
||||
) : null}
|
||||
{span.triggeredRuns.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Header3>Runs</Header3>
|
||||
<Table containerClassName="max-h-[12.5rem]">
|
||||
<TableHeader className="bg-background-bright">
|
||||
<TableRow>
|
||||
<TableHeaderCell>ID</TableHeaderCell>
|
||||
<TableHeaderCell>Task</TableHeaderCell>
|
||||
<TableHeaderCell>Status</TableHeaderCell>
|
||||
<TableHeaderCell>Created</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{span.triggeredRuns.map((run) => {
|
||||
const path = v3RunSpanPath(
|
||||
organization,
|
||||
project,
|
||||
environment,
|
||||
{ friendlyId: run.friendlyId },
|
||||
{ spanId: run.spanId }
|
||||
);
|
||||
return (
|
||||
<TableRow key={run.friendlyId}>
|
||||
<TableCell to={path} actionClassName="py-1.5" rowHoverStyle="bright">
|
||||
<TruncatedCopyableValue value={run.friendlyId} />
|
||||
</TableCell>
|
||||
<TableCell to={path} actionClassName="py-1.5" rowHoverStyle="bright">
|
||||
{run.taskIdentifier}
|
||||
</TableCell>
|
||||
<TableCell to={path} actionClassName="py-1.5" rowHoverStyle="bright">
|
||||
<TaskRunStatusCombo status={run.status} />
|
||||
</TableCell>
|
||||
<TableCell to={path} actionClassName="py-1.5" rowHoverStyle="bright">
|
||||
<DateTimeAccurate date={run.createdAt} hour12={false} hideDate={true} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1335,6 +1348,7 @@ function SpanEntity({ span }: { span: Span }) {
|
||||
runId={span.entity.object.runId}
|
||||
streamKey={span.entity.object.streamKey}
|
||||
metadata={span.entity.object.metadata}
|
||||
displayName={span.entity.object.displayName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+7
-2
@@ -98,10 +98,12 @@ export function RealtimeStreamViewer({
|
||||
runId,
|
||||
streamKey,
|
||||
metadata,
|
||||
displayName,
|
||||
}: {
|
||||
runId: string;
|
||||
streamKey: string;
|
||||
metadata: Record<string, unknown> | undefined;
|
||||
displayName?: string;
|
||||
}) {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
@@ -244,8 +246,8 @@ export function RealtimeStreamViewer({
|
||||
variant="small/bright"
|
||||
className="mb-0 flex min-w-0 items-center gap-1 truncate whitespace-nowrap"
|
||||
>
|
||||
<span>Stream:</span>
|
||||
<span className="truncate font-mono text-text-dimmed">{streamKey}</span>
|
||||
<span>{displayName ? "Input stream:" : "Stream:"}</span>
|
||||
<span className="truncate font-mono text-text-dimmed">{displayName ?? streamKey}</span>
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div className="flex w-full flex-wrap items-center justify-between gap-3 @[300px]:w-auto @[300px]:flex-nowrap">
|
||||
@@ -487,6 +489,9 @@ function useRealtimeStream(resourcePath: string, startIndex?: number) {
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setChunks([]);
|
||||
setError(null);
|
||||
|
||||
const abortController = new AbortController();
|
||||
let reader: ReadableStreamDefaultReader<SSEStreamPart<unknown>> | null = null;
|
||||
|
||||
|
||||
+23
-2
@@ -44,7 +44,7 @@ import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { sanitizeVercelNextUrl } from "~/v3/vercel/vercelUrls.server";
|
||||
import { EnvironmentParamSchema, v3ProjectSettingsPath, vercelAppInstallPath, vercelResourcePath } from "~/utils/pathBuilder";
|
||||
import { EnvironmentParamSchema, v3ProjectSettingsIntegrationsPath, vercelAppInstallPath, vercelResourcePath } from "~/utils/pathBuilder";
|
||||
import {
|
||||
VercelSettingsPresenter,
|
||||
type VercelOnboardingData,
|
||||
@@ -224,7 +224,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
const settingsPath = v3ProjectSettingsPath(
|
||||
const settingsPath = v3ProjectSettingsIntegrationsPath(
|
||||
{ slug: organizationSlug },
|
||||
{ slug: projectParam },
|
||||
{ slug: envParam }
|
||||
@@ -244,6 +244,12 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
||||
|
||||
const parsedStagingEnv = parseVercelStagingEnvironment(vercelStagingEnvironment);
|
||||
|
||||
// Get the previous staging environment before updating
|
||||
const previousIntegration = await vercelService.getVercelProjectIntegration(project.id);
|
||||
const previousStagingEnvId =
|
||||
previousIntegration?.parsedIntegrationData.config?.vercelStagingEnvironment?.environmentId ?? null;
|
||||
const newStagingEnvId = parsedStagingEnv?.environmentId ?? null;
|
||||
|
||||
const result = await vercelService.updateVercelIntegrationConfig(project.id, {
|
||||
atomicBuilds,
|
||||
pullEnvVarsBeforeBuild,
|
||||
@@ -252,6 +258,15 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
||||
});
|
||||
|
||||
if (result) {
|
||||
// Sync staging TRIGGER_SECRET_KEY if the custom environment changed
|
||||
if (previousStagingEnvId !== newStagingEnvId) {
|
||||
await vercelService.syncStagingKeyForCustomEnvironment(
|
||||
project.id,
|
||||
previousStagingEnvId,
|
||||
newStagingEnvId
|
||||
);
|
||||
}
|
||||
|
||||
return redirectWithSuccessMessage(settingsPath, request, "Vercel settings updated successfully");
|
||||
}
|
||||
|
||||
@@ -321,6 +336,12 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
||||
});
|
||||
|
||||
if (result) {
|
||||
// During onboarding there's no previous custom environment — just upsert
|
||||
await vercelService.syncStagingKeyForCustomEnvironment(
|
||||
project.id,
|
||||
null,
|
||||
parsedStagingEnv?.environmentId ?? null
|
||||
);
|
||||
return json({ success: true });
|
||||
}
|
||||
|
||||
|
||||
@@ -210,7 +210,7 @@ const pricingDefinitions = {
|
||||
},
|
||||
additionalRealtimeConnections: {
|
||||
title: "Additional Realtime connections",
|
||||
content: "Then $10/month per 100",
|
||||
content: "Then $10/month per 1000",
|
||||
},
|
||||
additionalSeats: {
|
||||
title: "Additional seats",
|
||||
|
||||
@@ -7,7 +7,7 @@ import { VercelIntegrationRepository, type TokenResponse } from "~/models/vercel
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { requestUrl } from "~/utils/requestUrl.server";
|
||||
import { v3ProjectSettingsPath } from "~/utils/pathBuilder";
|
||||
import { v3ProjectSettingsIntegrationsPath } from "~/utils/pathBuilder";
|
||||
import { validateVercelOAuthState } from "~/v3/vercel/vercelOAuthState.server";
|
||||
|
||||
const VercelConnectSchema = z.object({
|
||||
@@ -139,7 +139,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
throw new Response("Environment not found", { status: 404 });
|
||||
}
|
||||
|
||||
const settingsPath = v3ProjectSettingsPath(
|
||||
const settingsPath = v3ProjectSettingsIntegrationsPath(
|
||||
{ slug: stateData.organizationSlug },
|
||||
{ slug: stateData.projectSlug },
|
||||
{ slug: environment.slug }
|
||||
|
||||
@@ -4,6 +4,7 @@ import { z } from "zod";
|
||||
import { $replica } from "~/db.server";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { loopsClient } from "~/services/loops.server";
|
||||
import { OrgIntegrationRepository } from "~/models/orgIntegration.server";
|
||||
import { generateVercelOAuthState } from "~/v3/vercel/vercelOAuthState.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
@@ -65,6 +66,15 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
projectSlug: project_slug,
|
||||
});
|
||||
|
||||
// Send Loops.so event (fire-and-forget, don't block the redirect)
|
||||
loopsClient
|
||||
?.vercelIntegrationStarted({
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
// Generate Vercel install URL
|
||||
const vercelInstallUrl = OrgIntegrationRepository.vercelInstallUrl(stateToken);
|
||||
|
||||
|
||||
@@ -79,11 +79,21 @@ export class IdempotencyKeyConcern {
|
||||
}
|
||||
|
||||
// We have an idempotent run, so we return it
|
||||
const associatedWaitpoint = existingRun.associatedWaitpoint;
|
||||
const parentRunId = request.body.options?.parentRunId;
|
||||
const resumeParentOnCompletion = request.body.options?.resumeParentOnCompletion;
|
||||
|
||||
//We're using `andWait` so we need to block the parent run with a waitpoint
|
||||
if (associatedWaitpoint && resumeParentOnCompletion && parentRunId) {
|
||||
if (resumeParentOnCompletion && parentRunId) {
|
||||
// Get or create waitpoint lazily (existing run may not have one if it was standalone)
|
||||
let associatedWaitpoint = existingRun.associatedWaitpoint;
|
||||
if (!associatedWaitpoint) {
|
||||
associatedWaitpoint = await this.engine.getOrCreateRunWaitpoint({
|
||||
runId: existingRun.id,
|
||||
projectId: request.environment.projectId,
|
||||
environmentId: request.environment.id,
|
||||
});
|
||||
}
|
||||
|
||||
await this.traceEventConcern.traceIdempotentRun(
|
||||
request,
|
||||
parentStore,
|
||||
@@ -98,13 +108,13 @@ export class IdempotencyKeyConcern {
|
||||
request.options?.parentAsLinkType === "replay"
|
||||
? event.spanId
|
||||
: event.traceparent?.spanId
|
||||
? `${event.traceparent.spanId}:${event.spanId}`
|
||||
: event.spanId;
|
||||
? `${event.traceparent.spanId}:${event.spanId}`
|
||||
: event.spanId;
|
||||
|
||||
//block run with waitpoint
|
||||
await this.engine.blockRunWithWaitpoint({
|
||||
runId: RunId.fromFriendlyId(parentRunId),
|
||||
waitpoints: associatedWaitpoint.id,
|
||||
waitpoints: associatedWaitpoint!.id,
|
||||
spanIdToComplete: spanId,
|
||||
batch: request.options?.batchId
|
||||
? {
|
||||
|
||||
@@ -15,6 +15,22 @@ import type { RunEngine } from "~/v3/runEngine.server";
|
||||
import { env } from "~/env.server";
|
||||
import { tryCatch } from "@trigger.dev/core/v3";
|
||||
import { ServiceValidationError } from "~/v3/services/common.server";
|
||||
import { createCache, createLRUMemoryStore, DefaultStatefulContext, Namespace } from "@internal/cache";
|
||||
import { singleton } from "~/utils/singleton";
|
||||
|
||||
// LRU cache for environment queue sizes to reduce Redis calls
|
||||
const queueSizeCache = singleton("queueSizeCache", () => {
|
||||
const ctx = new DefaultStatefulContext();
|
||||
const memory = createLRUMemoryStore(env.QUEUE_SIZE_CACHE_MAX_SIZE, "queue-size-cache");
|
||||
|
||||
return createCache({
|
||||
queueSize: new Namespace<number>(ctx, {
|
||||
stores: [memory],
|
||||
fresh: env.QUEUE_SIZE_CACHE_TTL_MS,
|
||||
stale: env.QUEUE_SIZE_CACHE_TTL_MS + 1000,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Extract the queue name from a queue option that may be:
|
||||
@@ -49,7 +65,7 @@ export class DefaultQueueManager implements QueueManager {
|
||||
constructor(
|
||||
private readonly prisma: PrismaClientOrTransaction,
|
||||
private readonly engine: RunEngine
|
||||
) {}
|
||||
) { }
|
||||
|
||||
async resolveQueueProperties(
|
||||
request: TriggerTaskRequest,
|
||||
@@ -75,8 +91,7 @@ export class DefaultQueueManager implements QueueManager {
|
||||
|
||||
if (!specifiedQueue) {
|
||||
throw new ServiceValidationError(
|
||||
`Specified queue '${specifiedQueueName}' not found or not associated with locked version '${
|
||||
lockedBackgroundWorker.version ?? "<unknown>"
|
||||
`Specified queue '${specifiedQueueName}' not found or not associated with locked version '${lockedBackgroundWorker.version ?? "<unknown>"
|
||||
}'.`
|
||||
);
|
||||
}
|
||||
@@ -98,8 +113,7 @@ export class DefaultQueueManager implements QueueManager {
|
||||
|
||||
if (!lockedTask) {
|
||||
throw new ServiceValidationError(
|
||||
`Task '${request.taskId}' not found on locked version '${
|
||||
lockedBackgroundWorker.version ?? "<unknown>"
|
||||
`Task '${request.taskId}' not found on locked version '${lockedBackgroundWorker.version ?? "<unknown>"
|
||||
}'.`
|
||||
);
|
||||
}
|
||||
@@ -113,8 +127,7 @@ export class DefaultQueueManager implements QueueManager {
|
||||
version: lockedBackgroundWorker.version,
|
||||
});
|
||||
throw new ServiceValidationError(
|
||||
`Default queue configuration for task '${request.taskId}' missing on locked version '${
|
||||
lockedBackgroundWorker.version ?? "<unknown>"
|
||||
`Default queue configuration for task '${request.taskId}' missing on locked version '${lockedBackgroundWorker.version ?? "<unknown>"
|
||||
}'.`
|
||||
);
|
||||
}
|
||||
@@ -210,12 +223,19 @@ export class DefaultQueueManager implements QueueManager {
|
||||
|
||||
async validateQueueLimits(
|
||||
environment: AuthenticatedEnvironment,
|
||||
queueName: string,
|
||||
itemsToAdd?: number
|
||||
): Promise<QueueValidationResult> {
|
||||
const queueSizeGuard = await guardQueueSizeLimitsForEnv(this.engine, environment, itemsToAdd);
|
||||
const queueSizeGuard = await guardQueueSizeLimitsForQueue(
|
||||
this.engine,
|
||||
environment,
|
||||
queueName,
|
||||
itemsToAdd
|
||||
);
|
||||
|
||||
logger.debug("Queue size guard result", {
|
||||
queueSizeGuard,
|
||||
queueName,
|
||||
environment: {
|
||||
id: environment.id,
|
||||
type: environment.type,
|
||||
@@ -263,7 +283,7 @@ export class DefaultQueueManager implements QueueManager {
|
||||
}
|
||||
}
|
||||
|
||||
function getMaximumSizeForEnvironment(environment: AuthenticatedEnvironment): number | undefined {
|
||||
export function getMaximumSizeForEnvironment(environment: AuthenticatedEnvironment): number | undefined {
|
||||
if (environment.type === "DEVELOPMENT") {
|
||||
return environment.organization.maximumDevQueueSize ?? env.MAXIMUM_DEV_QUEUE_SIZE;
|
||||
} else {
|
||||
@@ -271,9 +291,10 @@ function getMaximumSizeForEnvironment(environment: AuthenticatedEnvironment): nu
|
||||
}
|
||||
}
|
||||
|
||||
async function guardQueueSizeLimitsForEnv(
|
||||
async function guardQueueSizeLimitsForQueue(
|
||||
engine: RunEngine,
|
||||
environment: AuthenticatedEnvironment,
|
||||
queueName: string,
|
||||
itemsToAdd: number = 1
|
||||
) {
|
||||
const maximumSize = getMaximumSizeForEnvironment(environment);
|
||||
@@ -282,7 +303,7 @@ async function guardQueueSizeLimitsForEnv(
|
||||
return { isWithinLimits: true };
|
||||
}
|
||||
|
||||
const queueSize = await engine.lengthOfEnvQueue(environment);
|
||||
const queueSize = await getCachedQueueSize(engine, environment, queueName);
|
||||
const projectedSize = queueSize + itemsToAdd;
|
||||
|
||||
return {
|
||||
@@ -291,3 +312,20 @@ async function guardQueueSizeLimitsForEnv(
|
||||
queueSize,
|
||||
};
|
||||
}
|
||||
|
||||
async function getCachedQueueSize(
|
||||
engine: RunEngine,
|
||||
environment: AuthenticatedEnvironment,
|
||||
queueName: string
|
||||
): Promise<number> {
|
||||
if (!env.QUEUE_SIZE_CACHE_ENABLED) {
|
||||
return engine.lengthOfQueue(environment, queueName);
|
||||
}
|
||||
|
||||
const cacheKey = `${environment.id}:${queueName}`;
|
||||
const result = await queueSizeCache.queueSize.swr(cacheKey, async () => {
|
||||
return engine.lengthOfQueue(environment, queueName);
|
||||
});
|
||||
|
||||
return result.val ?? 0;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type IOPacket,
|
||||
packetRequiresOffloading,
|
||||
parsePacket,
|
||||
TaskRunErrorCodes,
|
||||
} from "@trigger.dev/core/v3";
|
||||
import { BatchId, RunId } from "@trigger.dev/core/v3/isomorphic";
|
||||
import { type BatchTaskRun, Prisma } from "@trigger.dev/database";
|
||||
@@ -15,12 +16,11 @@ import { env } from "~/env.server";
|
||||
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { batchTriggerWorker } from "~/v3/batchTriggerWorker.server";
|
||||
import { DefaultQueueManager } from "../concerns/queues.server";
|
||||
import { DefaultTriggerTaskValidator } from "../validators/triggerTaskValidator";
|
||||
import { downloadPacketFromObjectStore, uploadPacketToObjectStore } from "../../v3/r2.server";
|
||||
import { ServiceValidationError, WithRunEngine } from "../../v3/services/baseService.server";
|
||||
import { TriggerTaskService } from "../../v3/services/triggerTask.server";
|
||||
import { startActiveSpan } from "../../v3/tracer.server";
|
||||
import { TriggerFailedTaskService } from "./triggerFailedTask.server";
|
||||
|
||||
const PROCESSING_BATCH_SIZE = 50;
|
||||
const ASYNC_BATCH_PROCESS_SIZE_THRESHOLD = 20;
|
||||
@@ -56,8 +56,6 @@ export type BatchTriggerTaskServiceOptions = {
|
||||
export class RunEngineBatchTriggerService extends WithRunEngine {
|
||||
private _batchProcessingStrategy: BatchProcessingStrategy;
|
||||
public onBatchTaskRunCreated: Evt<BatchTaskRun> = new Evt();
|
||||
private readonly queueConcern: DefaultQueueManager;
|
||||
private readonly validator: DefaultTriggerTaskValidator;
|
||||
|
||||
constructor(
|
||||
batchProcessingStrategy?: BatchProcessingStrategy,
|
||||
@@ -65,9 +63,6 @@ export class RunEngineBatchTriggerService extends WithRunEngine {
|
||||
) {
|
||||
super({ prisma });
|
||||
|
||||
this.queueConcern = new DefaultQueueManager(this._prisma, this._engine);
|
||||
this.validator = new DefaultTriggerTaskValidator();
|
||||
|
||||
// Eric note: We need to force sequential processing because when doing parallel, we end up with high-contention on the parent run lock
|
||||
// becuase we are triggering a lot of runs at once, and each one is trying to lock the parent run.
|
||||
// by forcing sequential, we are only ever locking the parent run for a single run at a time.
|
||||
@@ -88,18 +83,6 @@ export class RunEngineBatchTriggerService extends WithRunEngine {
|
||||
|
||||
span.setAttribute("batchId", friendlyId);
|
||||
|
||||
// Validate entitlement and extract planType for batch runs
|
||||
const entitlementValidation = await this.validator.validateEntitlement({
|
||||
environment,
|
||||
});
|
||||
|
||||
if (!entitlementValidation.ok) {
|
||||
throw entitlementValidation.error;
|
||||
}
|
||||
|
||||
// Extract plan type from entitlement response
|
||||
const planType = entitlementValidation.plan?.type;
|
||||
|
||||
// Upload to object store
|
||||
const payloadPacket = await this.#handlePayloadPacket(
|
||||
body.items,
|
||||
@@ -112,8 +95,7 @@ export class RunEngineBatchTriggerService extends WithRunEngine {
|
||||
payloadPacket,
|
||||
environment,
|
||||
body,
|
||||
options,
|
||||
planType
|
||||
options
|
||||
);
|
||||
|
||||
if (!batch) {
|
||||
@@ -166,8 +148,7 @@ export class RunEngineBatchTriggerService extends WithRunEngine {
|
||||
payloadPacket: IOPacket,
|
||||
environment: AuthenticatedEnvironment,
|
||||
body: BatchTriggerTaskV2RequestBody,
|
||||
options: BatchTriggerTaskServiceOptions = {},
|
||||
planType?: string
|
||||
options: BatchTriggerTaskServiceOptions = {}
|
||||
) {
|
||||
if (body.items.length <= ASYNC_BATCH_PROCESS_SIZE_THRESHOLD) {
|
||||
const batch = await this._prisma.batchTaskRun.create({
|
||||
@@ -206,7 +187,6 @@ export class RunEngineBatchTriggerService extends WithRunEngine {
|
||||
options,
|
||||
parentRunId: body.parentRunId,
|
||||
resumeParentOnCompletion: body.resumeParentOnCompletion,
|
||||
planType,
|
||||
});
|
||||
|
||||
switch (result.status) {
|
||||
@@ -236,7 +216,6 @@ export class RunEngineBatchTriggerService extends WithRunEngine {
|
||||
strategy: "sequential",
|
||||
parentRunId: body.parentRunId,
|
||||
resumeParentOnCompletion: body.resumeParentOnCompletion,
|
||||
planType,
|
||||
});
|
||||
|
||||
return batch;
|
||||
@@ -259,7 +238,6 @@ export class RunEngineBatchTriggerService extends WithRunEngine {
|
||||
strategy: "sequential",
|
||||
parentRunId: body.parentRunId,
|
||||
resumeParentOnCompletion: body.resumeParentOnCompletion,
|
||||
planType,
|
||||
});
|
||||
|
||||
return batch;
|
||||
@@ -303,7 +281,6 @@ export class RunEngineBatchTriggerService extends WithRunEngine {
|
||||
strategy: this._batchProcessingStrategy,
|
||||
parentRunId: body.parentRunId,
|
||||
resumeParentOnCompletion: body.resumeParentOnCompletion,
|
||||
planType,
|
||||
});
|
||||
|
||||
break;
|
||||
@@ -326,7 +303,6 @@ export class RunEngineBatchTriggerService extends WithRunEngine {
|
||||
strategy: this._batchProcessingStrategy,
|
||||
parentRunId: body.parentRunId,
|
||||
resumeParentOnCompletion: body.resumeParentOnCompletion,
|
||||
planType,
|
||||
})
|
||||
)
|
||||
);
|
||||
@@ -430,7 +406,6 @@ export class RunEngineBatchTriggerService extends WithRunEngine {
|
||||
options: $options,
|
||||
parentRunId: options.parentRunId,
|
||||
resumeParentOnCompletion: options.resumeParentOnCompletion,
|
||||
planType: options.planType,
|
||||
});
|
||||
|
||||
switch (result.status) {
|
||||
@@ -464,7 +439,6 @@ export class RunEngineBatchTriggerService extends WithRunEngine {
|
||||
strategy: options.strategy,
|
||||
parentRunId: options.parentRunId,
|
||||
resumeParentOnCompletion: options.resumeParentOnCompletion,
|
||||
planType: options.planType,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -492,7 +466,6 @@ export class RunEngineBatchTriggerService extends WithRunEngine {
|
||||
strategy: options.strategy,
|
||||
parentRunId: options.parentRunId,
|
||||
resumeParentOnCompletion: options.resumeParentOnCompletion,
|
||||
planType: options.planType,
|
||||
});
|
||||
} else {
|
||||
await this.#enqueueBatchTaskRun({
|
||||
@@ -509,7 +482,6 @@ export class RunEngineBatchTriggerService extends WithRunEngine {
|
||||
strategy: options.strategy,
|
||||
parentRunId: options.parentRunId,
|
||||
resumeParentOnCompletion: options.resumeParentOnCompletion,
|
||||
planType: options.planType,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -527,7 +499,6 @@ export class RunEngineBatchTriggerService extends WithRunEngine {
|
||||
options,
|
||||
parentRunId,
|
||||
resumeParentOnCompletion,
|
||||
planType,
|
||||
}: {
|
||||
batch: BatchTaskRun;
|
||||
environment: AuthenticatedEnvironment;
|
||||
@@ -537,7 +508,6 @@ export class RunEngineBatchTriggerService extends WithRunEngine {
|
||||
options?: BatchTriggerTaskServiceOptions;
|
||||
parentRunId?: string | undefined;
|
||||
resumeParentOnCompletion?: boolean | undefined;
|
||||
planType?: string;
|
||||
}): Promise<
|
||||
| { status: "COMPLETE" }
|
||||
| { status: "INCOMPLETE"; workingIndex: number }
|
||||
@@ -546,35 +516,6 @@ export class RunEngineBatchTriggerService extends WithRunEngine {
|
||||
// Grab the next PROCESSING_BATCH_SIZE items
|
||||
const itemsToProcess = items.slice(currentIndex, currentIndex + batchSize);
|
||||
|
||||
const newRunCount = await this.#countNewRuns(environment, itemsToProcess);
|
||||
|
||||
// Only validate queue size if we have new runs to create, i.e. they're not all cached
|
||||
if (newRunCount > 0) {
|
||||
const queueSizeGuard = await this.queueConcern.validateQueueLimits(environment, newRunCount);
|
||||
|
||||
logger.debug("Queue size guard result for chunk", {
|
||||
batchId: batch.friendlyId,
|
||||
currentIndex,
|
||||
runCount: batch.runCount,
|
||||
newRunCount,
|
||||
queueSizeGuard,
|
||||
});
|
||||
|
||||
if (!queueSizeGuard.ok) {
|
||||
return {
|
||||
status: "ERROR",
|
||||
error: `Cannot trigger ${newRunCount} new tasks as the queue size limit for this environment has been reached. The maximum size is ${queueSizeGuard.maximumSize}`,
|
||||
workingIndex: currentIndex,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
logger.debug("[RunEngineBatchTrigger][processBatchTaskRun] All runs are cached", {
|
||||
batchId: batch.friendlyId,
|
||||
currentIndex,
|
||||
runCount: batch.runCount,
|
||||
});
|
||||
}
|
||||
|
||||
logger.debug("[RunEngineBatchTrigger][processBatchTaskRun] Processing batch items", {
|
||||
batchId: batch.friendlyId,
|
||||
currentIndex,
|
||||
@@ -585,7 +526,14 @@ export class RunEngineBatchTriggerService extends WithRunEngine {
|
||||
|
||||
let runIds: string[] = [];
|
||||
|
||||
const triggerFailedTaskService = new TriggerFailedTaskService({
|
||||
prisma: this._prisma,
|
||||
engine: this._engine,
|
||||
});
|
||||
|
||||
for (const item of itemsToProcess) {
|
||||
let runFriendlyId: string | null = null;
|
||||
|
||||
try {
|
||||
const run = await this.#processBatchTaskRunItem({
|
||||
batch,
|
||||
@@ -595,34 +543,58 @@ export class RunEngineBatchTriggerService extends WithRunEngine {
|
||||
options,
|
||||
parentRunId,
|
||||
resumeParentOnCompletion,
|
||||
planType,
|
||||
});
|
||||
|
||||
if (!run) {
|
||||
logger.error("[RunEngineBatchTrigger][processBatchTaskRun] Failed to process item", {
|
||||
if (run) {
|
||||
runFriendlyId = run.friendlyId;
|
||||
}
|
||||
} catch (error) {
|
||||
// Trigger failed - will try to create pre-failed run below
|
||||
runFriendlyId = null;
|
||||
}
|
||||
|
||||
if (!runFriendlyId) {
|
||||
const errorMessage =
|
||||
"Trigger failed for batch item (queue limit, entitlement, or validation error)";
|
||||
logger.debug("[RunEngineBatchTrigger][processBatchTaskRun] Item trigger failed, creating pre-failed run", {
|
||||
batchId: batch.friendlyId,
|
||||
currentIndex: workingIndex,
|
||||
task: item.task,
|
||||
});
|
||||
|
||||
const failedRunId = await triggerFailedTaskService.call({
|
||||
taskId: item.task,
|
||||
environment,
|
||||
payload: item.payload,
|
||||
payloadType: item.options?.payloadType,
|
||||
errorMessage,
|
||||
parentRunId,
|
||||
resumeParentOnCompletion,
|
||||
batch: { id: batch.id, index: workingIndex },
|
||||
options: item.options as Record<string, unknown>,
|
||||
traceContext: options?.traceContext as Record<string, unknown> | undefined,
|
||||
spanParentAsLink: options?.spanParentAsLink,
|
||||
errorCode: TaskRunErrorCodes.BATCH_ITEM_COULD_NOT_TRIGGER,
|
||||
});
|
||||
|
||||
if (failedRunId) {
|
||||
runFriendlyId = failedRunId;
|
||||
} else {
|
||||
logger.error("[RunEngineBatchTrigger][processBatchTaskRun] Failed to create pre-failed run", {
|
||||
batchId: batch.friendlyId,
|
||||
currentIndex: workingIndex,
|
||||
});
|
||||
|
||||
throw new Error("[RunEngineBatchTrigger][processBatchTaskRun] Failed to process item");
|
||||
return {
|
||||
status: "ERROR",
|
||||
error: "Could not trigger item and could not create pre-failed run",
|
||||
workingIndex,
|
||||
};
|
||||
}
|
||||
|
||||
runIds.push(run.friendlyId);
|
||||
|
||||
workingIndex++;
|
||||
} catch (error) {
|
||||
logger.error("[RunEngineBatchTrigger][processBatchTaskRun] Failed to process item", {
|
||||
batchId: batch.friendlyId,
|
||||
currentIndex: workingIndex,
|
||||
error,
|
||||
});
|
||||
|
||||
return {
|
||||
status: "ERROR",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
workingIndex,
|
||||
};
|
||||
}
|
||||
|
||||
runIds.push(runFriendlyId);
|
||||
workingIndex++;
|
||||
}
|
||||
|
||||
//add the run ids to the batch
|
||||
@@ -671,7 +643,6 @@ export class RunEngineBatchTriggerService extends WithRunEngine {
|
||||
options,
|
||||
parentRunId,
|
||||
resumeParentOnCompletion,
|
||||
planType,
|
||||
}: {
|
||||
batch: BatchTaskRun;
|
||||
environment: AuthenticatedEnvironment;
|
||||
@@ -680,7 +651,6 @@ export class RunEngineBatchTriggerService extends WithRunEngine {
|
||||
options?: BatchTriggerTaskServiceOptions;
|
||||
parentRunId: string | undefined;
|
||||
resumeParentOnCompletion: boolean | undefined;
|
||||
planType?: string;
|
||||
}) {
|
||||
logger.debug("[RunEngineBatchTrigger][processBatchTaskRunItem] Processing item", {
|
||||
batchId: batch.friendlyId,
|
||||
@@ -707,8 +677,6 @@ export class RunEngineBatchTriggerService extends WithRunEngine {
|
||||
spanParentAsLink: options?.spanParentAsLink,
|
||||
batchId: batch.id,
|
||||
batchIndex: currentIndex,
|
||||
skipChecks: true, // Skip entitlement and queue checks since we already validated at batch/chunk level
|
||||
planType, // Pass planType from batch-level entitlement check
|
||||
realtimeStreamsVersion: options?.realtimeStreamsVersion,
|
||||
},
|
||||
"V2"
|
||||
@@ -752,85 +720,4 @@ export class RunEngineBatchTriggerService extends WithRunEngine {
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
#groupItemsByTaskIdentifier(
|
||||
items: BatchTriggerTaskV2RequestBody["items"]
|
||||
): Record<string, BatchTriggerTaskV2RequestBody["items"]> {
|
||||
return items.reduce((acc, item) => {
|
||||
if (!item.options?.idempotencyKey) return acc;
|
||||
|
||||
if (!acc[item.task]) {
|
||||
acc[item.task] = [];
|
||||
}
|
||||
acc[item.task].push(item);
|
||||
return acc;
|
||||
}, {} as Record<string, BatchTriggerTaskV2RequestBody["items"]>);
|
||||
}
|
||||
|
||||
async #countNewRuns(
|
||||
environment: AuthenticatedEnvironment,
|
||||
items: BatchTriggerTaskV2RequestBody["items"]
|
||||
): Promise<number> {
|
||||
// If cached runs check is disabled, return the total number of items
|
||||
if (!env.BATCH_TRIGGER_CACHED_RUNS_CHECK_ENABLED) {
|
||||
return items.length;
|
||||
}
|
||||
|
||||
// Group items by taskIdentifier for efficient lookup
|
||||
const itemsByTask = this.#groupItemsByTaskIdentifier(items);
|
||||
|
||||
// If no items have idempotency keys, all are new runs
|
||||
if (Object.keys(itemsByTask).length === 0) {
|
||||
return items.length;
|
||||
}
|
||||
|
||||
// Fetch cached runs for each task identifier separately to make use of the index
|
||||
const cachedRuns = await Promise.all(
|
||||
Object.entries(itemsByTask).map(([taskIdentifier, taskItems]) =>
|
||||
this._prisma.taskRun.findMany({
|
||||
where: {
|
||||
runtimeEnvironmentId: environment.id,
|
||||
taskIdentifier,
|
||||
idempotencyKey: {
|
||||
in: taskItems.map((i) => i.options?.idempotencyKey).filter(Boolean),
|
||||
},
|
||||
},
|
||||
select: {
|
||||
idempotencyKey: true,
|
||||
idempotencyKeyExpiresAt: true,
|
||||
},
|
||||
})
|
||||
)
|
||||
).then((results) => results.flat());
|
||||
|
||||
// Create a Map for O(1) lookups instead of O(m) find operations
|
||||
const cachedRunsMap = new Map(cachedRuns.map((run) => [run.idempotencyKey, run]));
|
||||
|
||||
// Count items that are NOT cached (or have expired cache)
|
||||
let newRunCount = 0;
|
||||
const now = new Date();
|
||||
|
||||
for (const item of items) {
|
||||
const idempotencyKey = item.options?.idempotencyKey;
|
||||
|
||||
if (!idempotencyKey) {
|
||||
// No idempotency key = always a new run
|
||||
newRunCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const cachedRun = cachedRunsMap.get(idempotencyKey);
|
||||
|
||||
if (!cachedRun) {
|
||||
// No cached run = new run
|
||||
newRunCount++;
|
||||
} else if (cachedRun.idempotencyKeyExpiresAt && cachedRun.idempotencyKeyExpiresAt < now) {
|
||||
// Expired cached run = new run
|
||||
newRunCount++;
|
||||
}
|
||||
// else: valid cached run = not a new run
|
||||
}
|
||||
|
||||
return newRunCount;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,17 +90,8 @@ export class CreateBatchService extends WithRunEngine {
|
||||
);
|
||||
}
|
||||
|
||||
// Validate queue limits for the expected batch size
|
||||
const queueSizeGuard = await this.queueConcern.validateQueueLimits(
|
||||
environment,
|
||||
body.runCount
|
||||
);
|
||||
|
||||
if (!queueSizeGuard.ok) {
|
||||
throw new ServiceValidationError(
|
||||
`Cannot create batch with ${body.runCount} items as the queue size limit for this environment has been reached. The maximum size is ${queueSizeGuard.maximumSize}`
|
||||
);
|
||||
}
|
||||
// Note: Queue size limits are validated per-queue when batch items are processed,
|
||||
// since we don't know which queues items will go to until they're streamed.
|
||||
|
||||
// Create BatchTaskRun in Postgres with PENDING status
|
||||
// The batch will be sealed (status -> PROCESSING) when items are streamed
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
type BatchItemNDJSON,
|
||||
type StreamBatchItemsResponse,
|
||||
BatchItemNDJSON as BatchItemNDJSONSchema,
|
||||
} from "@trigger.dev/core/v3";
|
||||
@@ -15,6 +14,14 @@ export type StreamBatchItemsServiceOptions = {
|
||||
maxItemBytes: number;
|
||||
};
|
||||
|
||||
export type OversizedItemMarker = {
|
||||
__batchItemError: "OVERSIZED";
|
||||
index: number;
|
||||
task: string;
|
||||
actualSize: number;
|
||||
maxSize: number;
|
||||
};
|
||||
|
||||
export type StreamBatchItemsServiceConstructorOptions = {
|
||||
prisma?: PrismaClientOrTransaction;
|
||||
engine?: RunEngine;
|
||||
@@ -111,6 +118,41 @@ export class StreamBatchItemsService extends WithRunEngine {
|
||||
|
||||
// Process items from the stream
|
||||
for await (const rawItem of itemsIterator) {
|
||||
// Check for oversized item markers from the NDJSON parser
|
||||
if (rawItem && typeof rawItem === "object" && "__batchItemError" in rawItem) {
|
||||
const marker = rawItem as OversizedItemMarker;
|
||||
const itemIndex = marker.index >= 0 ? marker.index : lastIndex + 1;
|
||||
|
||||
const errorMessage = `Batch item payload is too large (${(marker.actualSize / 1024).toFixed(1)} KB). Maximum allowed size is ${(marker.maxSize / 1024).toFixed(1)} KB. Reduce the payload size or offload large data to external storage.`;
|
||||
|
||||
// Enqueue with __error metadata - processItemCallback will detect this
|
||||
// and use TriggerFailedTaskService to create a pre-failed run
|
||||
const batchItem: BatchItem = {
|
||||
task: marker.task,
|
||||
payload: "{}",
|
||||
payloadType: "application/json",
|
||||
options: {
|
||||
__error: errorMessage,
|
||||
__errorCode: "PAYLOAD_TOO_LARGE",
|
||||
},
|
||||
};
|
||||
|
||||
const result = await this._engine.enqueueBatchItem(
|
||||
batchId,
|
||||
environment.id,
|
||||
itemIndex,
|
||||
batchItem
|
||||
);
|
||||
|
||||
if (result.enqueued) {
|
||||
itemsAccepted++;
|
||||
} else {
|
||||
itemsDeduplicated++;
|
||||
}
|
||||
lastIndex = itemIndex;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse and validate the item
|
||||
const parseResult = BatchItemNDJSONSchema.safeParse(rawItem);
|
||||
if (!parseResult.success) {
|
||||
@@ -169,6 +211,34 @@ export class StreamBatchItemsService extends WithRunEngine {
|
||||
|
||||
// Validate we received the expected number of items
|
||||
if (enqueuedCount !== batch.runCount) {
|
||||
// The batch queue consumers may have already processed all items and
|
||||
// cleaned up the Redis keys before we got here (especially likely when
|
||||
// items include pre-failed runs that complete instantly). Check if the
|
||||
// batch was already sealed/completed in Postgres.
|
||||
const currentBatch = await this._prisma.batchTaskRun.findUnique({
|
||||
where: { id: batchId },
|
||||
select: { sealed: true, status: true },
|
||||
});
|
||||
|
||||
if (currentBatch?.sealed) {
|
||||
logger.info("Batch already sealed before count check (fast completion)", {
|
||||
batchId: batchFriendlyId,
|
||||
itemsAccepted,
|
||||
itemsDeduplicated,
|
||||
enqueuedCount,
|
||||
expectedCount: batch.runCount,
|
||||
batchStatus: currentBatch.status,
|
||||
});
|
||||
|
||||
return {
|
||||
id: batchFriendlyId,
|
||||
itemsAccepted,
|
||||
itemsDeduplicated,
|
||||
sealed: true,
|
||||
runCount: batch.runCount,
|
||||
};
|
||||
}
|
||||
|
||||
logger.warn("Batch item count mismatch", {
|
||||
batchId: batchFriendlyId,
|
||||
expected: batch.runCount,
|
||||
@@ -186,6 +256,7 @@ export class StreamBatchItemsService extends WithRunEngine {
|
||||
sealed: false,
|
||||
enqueuedCount,
|
||||
expectedCount: batch.runCount,
|
||||
runCount: batch.runCount,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -237,6 +308,7 @@ export class StreamBatchItemsService extends WithRunEngine {
|
||||
itemsAccepted,
|
||||
itemsDeduplicated,
|
||||
sealed: true,
|
||||
runCount: batch.runCount,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -273,12 +345,128 @@ export class StreamBatchItemsService extends WithRunEngine {
|
||||
itemsAccepted,
|
||||
itemsDeduplicated,
|
||||
sealed: true,
|
||||
runCount: batch.runCount,
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract `index` and `task` from raw JSON bytes without decoding the full line.
|
||||
* Scans at most 512 bytes, tracking JSON nesting depth to only match top-level keys.
|
||||
*/
|
||||
export function extractIndexAndTask(bytes: Uint8Array): { index: number; task: string } {
|
||||
let index = -1;
|
||||
let task = "unknown";
|
||||
let depth = 0;
|
||||
let foundIndex = false;
|
||||
let foundTask = false;
|
||||
const limit = Math.min(bytes.byteLength, 512);
|
||||
|
||||
const QUOTE = 0x22; // "
|
||||
const COLON = 0x3a; // :
|
||||
const LBRACE = 0x7b; // {
|
||||
const RBRACE = 0x7d; // }
|
||||
const LBRACKET = 0x5b; // [
|
||||
const RBRACKET = 0x5d; // ]
|
||||
const BACKSLASH = 0x5c; // \
|
||||
|
||||
// Byte patterns for "index" and "task" (without quotes)
|
||||
const INDEX_BYTES = [0x69, 0x6e, 0x64, 0x65, 0x78]; // index
|
||||
const TASK_BYTES = [0x74, 0x61, 0x73, 0x6b]; // task
|
||||
|
||||
let i = 0;
|
||||
while (i < limit && !(foundIndex && foundTask)) {
|
||||
const b = bytes[i];
|
||||
|
||||
if (b === LBRACE || b === LBRACKET) {
|
||||
depth++;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (b === RBRACE || b === RBRACKET) {
|
||||
depth--;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only match keys at depth 1 (top-level object)
|
||||
if (b === QUOTE && depth === 1) {
|
||||
// Read the key inside quotes
|
||||
const keyStart = i + 1;
|
||||
let keyEnd = keyStart;
|
||||
while (keyEnd < limit && bytes[keyEnd] !== QUOTE) {
|
||||
if (bytes[keyEnd] === BACKSLASH) keyEnd++; // skip escaped char
|
||||
keyEnd++;
|
||||
}
|
||||
|
||||
const keyLen = keyEnd - keyStart;
|
||||
|
||||
// Check if this key matches "index" or "task"
|
||||
const isIndex =
|
||||
!foundIndex &&
|
||||
keyLen === INDEX_BYTES.length &&
|
||||
INDEX_BYTES.every((b, j) => bytes[keyStart + j] === b);
|
||||
const isTask =
|
||||
!foundTask &&
|
||||
keyLen === TASK_BYTES.length &&
|
||||
TASK_BYTES.every((b, j) => bytes[keyStart + j] === b);
|
||||
|
||||
if (isIndex || isTask) {
|
||||
// Skip past closing quote and find colon
|
||||
let pos = keyEnd + 1;
|
||||
while (pos < limit && bytes[pos] !== COLON) pos++;
|
||||
pos++; // skip colon
|
||||
// Skip whitespace
|
||||
while (pos < limit && (bytes[pos] === 0x20 || bytes[pos] === 0x09)) pos++;
|
||||
|
||||
if (isIndex) {
|
||||
// Parse digits
|
||||
let num = 0;
|
||||
let hasDigit = false;
|
||||
while (pos < limit && bytes[pos] >= 0x30 && bytes[pos] <= 0x39) {
|
||||
num = num * 10 + (bytes[pos] - 0x30);
|
||||
hasDigit = true;
|
||||
pos++;
|
||||
}
|
||||
if (hasDigit) {
|
||||
index = num;
|
||||
foundIndex = true;
|
||||
}
|
||||
} else {
|
||||
// Parse quoted string value
|
||||
if (pos < limit && bytes[pos] === QUOTE) {
|
||||
const valStart = pos + 1;
|
||||
let valEnd = valStart;
|
||||
while (valEnd < limit && bytes[valEnd] !== QUOTE) {
|
||||
if (bytes[valEnd] === BACKSLASH) valEnd++;
|
||||
valEnd++;
|
||||
}
|
||||
// Decode just this slice
|
||||
try {
|
||||
task = new TextDecoder("utf-8", { fatal: true }).decode(
|
||||
bytes.slice(valStart, valEnd)
|
||||
);
|
||||
foundTask = true;
|
||||
} catch {
|
||||
// Leave as "unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Skip past the key's closing quote
|
||||
i = keyEnd + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
return { index, task };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an NDJSON parser transform stream.
|
||||
*
|
||||
@@ -303,6 +491,9 @@ export function createNdjsonParserStream(
|
||||
let chunks: Uint8Array[] = [];
|
||||
let totalBytes = 0;
|
||||
let lineNumber = 0;
|
||||
// When an oversized incomplete line is detected (Case 2), we must discard
|
||||
// all remaining bytes of that line until the next newline delimiter.
|
||||
let skipUntilNewline = false;
|
||||
|
||||
const NEWLINE_BYTE = 0x0a; // '\n'
|
||||
|
||||
@@ -396,6 +587,24 @@ export function createNdjsonParserStream(
|
||||
|
||||
return new TransformStream<Uint8Array, unknown>({
|
||||
transform(chunk, controller) {
|
||||
// If we're skipping the remainder of an oversized line, scan for the
|
||||
// next newline in this chunk and discard everything before it.
|
||||
if (skipUntilNewline) {
|
||||
const nlPos = chunk.indexOf(NEWLINE_BYTE);
|
||||
if (nlPos === -1) {
|
||||
// Entire chunk is still part of the oversized line — discard it
|
||||
return;
|
||||
}
|
||||
// Found the newline — keep everything after it
|
||||
skipUntilNewline = false;
|
||||
const remaining = chunk.slice(nlPos + 1);
|
||||
if (remaining.byteLength === 0) {
|
||||
return;
|
||||
}
|
||||
// Replace chunk with the remainder and fall through to normal processing
|
||||
chunk = remaining;
|
||||
}
|
||||
|
||||
// Append chunk to buffer
|
||||
chunks.push(chunk);
|
||||
totalBytes += chunk.byteLength;
|
||||
@@ -405,11 +614,19 @@ export function createNdjsonParserStream(
|
||||
while ((newlineIndex = findNewlineIndex()) !== -1) {
|
||||
// Check size limit BEFORE extracting/decoding (bytes up to newline)
|
||||
if (newlineIndex > maxItemBytes) {
|
||||
throw new Error(
|
||||
`Item at line ${
|
||||
lineNumber + 1
|
||||
} exceeds maximum size of ${maxItemBytes} bytes (actual: ${newlineIndex})`
|
||||
);
|
||||
// Case 1: Complete line exceeds limit - emit marker instead of throwing
|
||||
const lineBytes = extractLine(newlineIndex);
|
||||
const extracted = extractIndexAndTask(lineBytes);
|
||||
const marker: OversizedItemMarker = {
|
||||
__batchItemError: "OVERSIZED",
|
||||
index: extracted.index,
|
||||
task: extracted.task,
|
||||
actualSize: newlineIndex,
|
||||
maxSize: maxItemBytes,
|
||||
};
|
||||
controller.enqueue(marker);
|
||||
lineNumber++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const lineBytes = extractLine(newlineIndex);
|
||||
@@ -419,11 +636,23 @@ export function createNdjsonParserStream(
|
||||
// Check if the remaining buffer (incomplete line) exceeds the limit
|
||||
// This prevents OOM from a single huge line without newlines
|
||||
if (totalBytes > maxItemBytes) {
|
||||
throw new Error(
|
||||
`Item at line ${
|
||||
lineNumber + 1
|
||||
} exceeds maximum size of ${maxItemBytes} bytes (buffered: ${totalBytes}, no newline found)`
|
||||
);
|
||||
// Case 2: Incomplete line exceeds limit - emit marker instead of throwing
|
||||
const extracted = extractIndexAndTask(concatenateChunks());
|
||||
const marker: OversizedItemMarker = {
|
||||
__batchItemError: "OVERSIZED",
|
||||
index: extracted.index,
|
||||
task: extracted.task,
|
||||
actualSize: totalBytes,
|
||||
maxSize: maxItemBytes,
|
||||
};
|
||||
controller.enqueue(marker);
|
||||
lineNumber++;
|
||||
// Clear buffer and skip remaining bytes of this oversized line
|
||||
// until the next newline delimiter is found in a subsequent chunk
|
||||
chunks = [];
|
||||
totalBytes = 0;
|
||||
skipUntilNewline = true;
|
||||
return;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -439,11 +668,17 @@ export function createNdjsonParserStream(
|
||||
|
||||
// Check size limit before processing final line
|
||||
if (totalBytes > maxItemBytes) {
|
||||
throw new Error(
|
||||
`Item at line ${
|
||||
lineNumber + 1
|
||||
} exceeds maximum size of ${maxItemBytes} bytes (actual: ${totalBytes})`
|
||||
);
|
||||
// Case 3: Flush with oversized remaining - emit marker instead of throwing
|
||||
const extracted = extractIndexAndTask(concatenateChunks());
|
||||
const marker: OversizedItemMarker = {
|
||||
__batchItemError: "OVERSIZED",
|
||||
index: extracted.index,
|
||||
task: extracted.task,
|
||||
actualSize: totalBytes,
|
||||
maxSize: maxItemBytes,
|
||||
};
|
||||
controller.enqueue(marker);
|
||||
return;
|
||||
}
|
||||
|
||||
const finalBytes = concatenateChunks();
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
import { RunEngine } from "@internal/run-engine";
|
||||
import { TaskRunErrorCodes, type TaskRunError } from "@trigger.dev/core/v3";
|
||||
import { RunId } from "@trigger.dev/core/v3/isomorphic";
|
||||
import type { RuntimeEnvironmentType, TaskRun } from "@trigger.dev/database";
|
||||
import type { PrismaClientOrTransaction } from "@trigger.dev/database";
|
||||
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { getEventRepository } from "~/v3/eventRepository/index.server";
|
||||
import { DefaultQueueManager } from "../concerns/queues.server";
|
||||
import type { TriggerTaskRequest } from "../types";
|
||||
|
||||
export type TriggerFailedTaskRequest = {
|
||||
/** The task identifier (e.g. "my-task") */
|
||||
taskId: string;
|
||||
/** The fully-resolved authenticated environment */
|
||||
environment: AuthenticatedEnvironment;
|
||||
/** Raw payload — string or object */
|
||||
payload: unknown;
|
||||
/** MIME type of the payload (defaults to "application/json") */
|
||||
payloadType?: string;
|
||||
/** Error message describing why the run failed */
|
||||
errorMessage: string;
|
||||
/** Parent run friendly ID (e.g. "run_xxxx") */
|
||||
parentRunId?: string;
|
||||
/** Whether completing this run should resume the parent */
|
||||
resumeParentOnCompletion?: boolean;
|
||||
/** Batch association */
|
||||
batch?: { id: string; index: number };
|
||||
/** Trigger options from the original request (queue config, etc.) */
|
||||
options?: Record<string, unknown>;
|
||||
/** Trace context for span correlation */
|
||||
traceContext?: Record<string, unknown>;
|
||||
/** Whether the span parent should be treated as a link rather than a parent */
|
||||
spanParentAsLink?: boolean;
|
||||
|
||||
errorCode?: TaskRunErrorCodes;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a pre-failed TaskRun with a trace event span.
|
||||
*
|
||||
* This is used when a task cannot be triggered (e.g. queue limit reached, validation
|
||||
* error, etc.) but we still need to record the failure so that:
|
||||
* - Batch completion can track the item
|
||||
* - Parent runs get unblocked
|
||||
* - The failed run shows up in the run logs view
|
||||
*
|
||||
* This service resolves the parent run (for rootTaskRunId/depth) and queue properties
|
||||
* the same way triggerTask does, so the run is correctly associated in the task tree
|
||||
* and the SpanPresenter can find the TaskQueue.
|
||||
*/
|
||||
export class TriggerFailedTaskService {
|
||||
private readonly prisma: PrismaClientOrTransaction;
|
||||
private readonly engine: RunEngine;
|
||||
|
||||
constructor(opts: { prisma: PrismaClientOrTransaction; engine: RunEngine }) {
|
||||
this.prisma = opts.prisma;
|
||||
this.engine = opts.engine;
|
||||
}
|
||||
|
||||
async call(request: TriggerFailedTaskRequest): Promise<string | null> {
|
||||
const failedRunFriendlyId = RunId.generate().friendlyId;
|
||||
const taskRunError: TaskRunError = {
|
||||
type: "INTERNAL_ERROR" as const,
|
||||
code: request.errorCode ?? TaskRunErrorCodes.UNSPECIFIED_ERROR,
|
||||
message: request.errorMessage,
|
||||
};
|
||||
|
||||
try {
|
||||
const { repository, store } = await getEventRepository(
|
||||
request.environment.organization.featureFlags as Record<string, unknown>,
|
||||
undefined
|
||||
);
|
||||
|
||||
// Resolve parent run for rootTaskRunId and depth (same as triggerTask.server.ts)
|
||||
const parentRun = request.parentRunId
|
||||
? await this.prisma.taskRun.findFirst({
|
||||
where: {
|
||||
id: RunId.fromFriendlyId(request.parentRunId),
|
||||
runtimeEnvironmentId: request.environment.id,
|
||||
},
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const depth = parentRun ? parentRun.depth + 1 : 0;
|
||||
const rootTaskRunId = parentRun?.rootTaskRunId ?? parentRun?.id;
|
||||
|
||||
// Resolve queue properties (same as triggerTask) so span presenter can find TaskQueue.
|
||||
// Best-effort: if resolution throws (e.g. request shape, missing worker), we still create
|
||||
// the run without queue/lockedQueueId so run creation and trace events never regress.
|
||||
let queueName: string | undefined;
|
||||
let lockedQueueId: string | undefined;
|
||||
try {
|
||||
const queueConcern = new DefaultQueueManager(this.prisma, this.engine);
|
||||
const bodyOptions = request.options as TriggerTaskRequest["body"]["options"];
|
||||
const triggerRequest: TriggerTaskRequest = {
|
||||
taskId: request.taskId,
|
||||
friendlyId: failedRunFriendlyId,
|
||||
environment: request.environment,
|
||||
body: {
|
||||
payload:
|
||||
typeof request.payload === "string"
|
||||
? request.payload
|
||||
: JSON.stringify(request.payload ?? {}),
|
||||
options: bodyOptions,
|
||||
},
|
||||
};
|
||||
|
||||
// Resolve the locked background worker if lockToVersion is set (same as triggerTask).
|
||||
// resolveQueueProperties requires the worker to be passed when lockToVersion is present.
|
||||
const lockedToBackgroundWorker = bodyOptions?.lockToVersion
|
||||
? await this.prisma.backgroundWorker.findFirst({
|
||||
where: {
|
||||
projectId: request.environment.projectId,
|
||||
runtimeEnvironmentId: request.environment.id,
|
||||
version: bodyOptions.lockToVersion,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
version: true,
|
||||
sdkVersion: true,
|
||||
cliVersion: true,
|
||||
},
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const resolved = await queueConcern.resolveQueueProperties(
|
||||
triggerRequest,
|
||||
lockedToBackgroundWorker ?? undefined
|
||||
);
|
||||
queueName = resolved.queueName;
|
||||
lockedQueueId = resolved.lockedQueueId;
|
||||
} catch (queueResolveError) {
|
||||
const err =
|
||||
queueResolveError instanceof Error
|
||||
? queueResolveError
|
||||
: new Error(String(queueResolveError));
|
||||
logger.warn("TriggerFailedTaskService: queue resolution failed, using defaults", {
|
||||
taskId: request.taskId,
|
||||
friendlyId: failedRunFriendlyId,
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
|
||||
// Create the failed run inside a trace event span so it shows up in run logs
|
||||
const failedRun: TaskRun = await repository.traceEvent(
|
||||
request.taskId,
|
||||
{
|
||||
context: request.traceContext,
|
||||
spanParentAsLink: request.spanParentAsLink,
|
||||
kind: "SERVER",
|
||||
environment: {
|
||||
id: request.environment.id,
|
||||
type: request.environment.type,
|
||||
organizationId: request.environment.organizationId,
|
||||
projectId: request.environment.projectId,
|
||||
project: { externalRef: request.environment.project.externalRef },
|
||||
},
|
||||
taskSlug: request.taskId,
|
||||
attributes: {
|
||||
properties: {},
|
||||
style: { icon: "task" },
|
||||
},
|
||||
incomplete: false,
|
||||
isError: true,
|
||||
immediate: true,
|
||||
},
|
||||
async (event, traceContext) => {
|
||||
event.setAttribute("runId", failedRunFriendlyId);
|
||||
event.failWithError(taskRunError);
|
||||
|
||||
return await this.engine.createFailedTaskRun({
|
||||
friendlyId: failedRunFriendlyId,
|
||||
environment: {
|
||||
id: request.environment.id,
|
||||
type: request.environment.type,
|
||||
project: { id: request.environment.project.id },
|
||||
organization: { id: request.environment.organization.id },
|
||||
},
|
||||
taskIdentifier: request.taskId,
|
||||
payload:
|
||||
typeof request.payload === "string"
|
||||
? request.payload
|
||||
: JSON.stringify(request.payload ?? ""),
|
||||
payloadType: request.payloadType ?? "application/json",
|
||||
error: taskRunError,
|
||||
parentTaskRunId: parentRun?.id,
|
||||
rootTaskRunId,
|
||||
depth,
|
||||
resumeParentOnCompletion: request.resumeParentOnCompletion,
|
||||
batch: request.batch,
|
||||
traceId: event.traceId,
|
||||
spanId: event.spanId,
|
||||
traceContext: traceContext as Record<string, unknown>,
|
||||
taskEventStore: store,
|
||||
...(queueName !== undefined && { queue: queueName }),
|
||||
...(lockedQueueId !== undefined && { lockedQueueId }),
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
return failedRun.friendlyId;
|
||||
} catch (createError) {
|
||||
const createErrorMsg =
|
||||
createError instanceof Error ? createError.message : String(createError);
|
||||
logger.error("TriggerFailedTaskService: failed to create pre-failed TaskRun", {
|
||||
taskId: request.taskId,
|
||||
friendlyId: failedRunFriendlyId,
|
||||
originalError: request.errorMessage,
|
||||
createError: createErrorMsg,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a pre-failed run without trace events.
|
||||
* Used when the environment can't be fully resolved (e.g. environment not found)
|
||||
* and we can't create trace events or look up parent runs.
|
||||
*/
|
||||
async callWithoutTraceEvents(opts: {
|
||||
environmentId: string;
|
||||
environmentType: RuntimeEnvironmentType;
|
||||
projectId: string;
|
||||
organizationId: string;
|
||||
taskId: string;
|
||||
payload: unknown;
|
||||
payloadType?: string;
|
||||
errorMessage: string;
|
||||
parentRunId?: string;
|
||||
resumeParentOnCompletion?: boolean;
|
||||
batch?: { id: string; index: number };
|
||||
errorCode?: TaskRunErrorCodes;
|
||||
}): Promise<string | null> {
|
||||
const failedRunFriendlyId = RunId.generate().friendlyId;
|
||||
|
||||
try {
|
||||
// Best-effort parent run lookup for rootTaskRunId/depth
|
||||
let parentTaskRunId: string | undefined;
|
||||
let rootTaskRunId: string | undefined;
|
||||
let depth = 0;
|
||||
|
||||
if (opts.parentRunId) {
|
||||
const parentRun = await this.prisma.taskRun.findFirst({
|
||||
where: {
|
||||
id: RunId.fromFriendlyId(opts.parentRunId),
|
||||
runtimeEnvironmentId: opts.environmentId,
|
||||
},
|
||||
});
|
||||
|
||||
if (parentRun) {
|
||||
parentTaskRunId = parentRun.id;
|
||||
rootTaskRunId = parentRun.rootTaskRunId ?? parentRun.id;
|
||||
depth = parentRun.depth + 1;
|
||||
} else {
|
||||
parentTaskRunId = RunId.fromFriendlyId(opts.parentRunId);
|
||||
}
|
||||
}
|
||||
|
||||
await this.engine.createFailedTaskRun({
|
||||
friendlyId: failedRunFriendlyId,
|
||||
environment: {
|
||||
id: opts.environmentId,
|
||||
type: opts.environmentType,
|
||||
project: { id: opts.projectId },
|
||||
organization: { id: opts.organizationId },
|
||||
},
|
||||
taskIdentifier: opts.taskId,
|
||||
payload:
|
||||
typeof opts.payload === "string"
|
||||
? opts.payload
|
||||
: JSON.stringify(opts.payload ?? ""),
|
||||
payloadType: opts.payloadType ?? "application/json",
|
||||
error: {
|
||||
type: "INTERNAL_ERROR" as const,
|
||||
code: opts.errorCode ?? TaskRunErrorCodes.UNSPECIFIED_ERROR,
|
||||
message: opts.errorMessage,
|
||||
},
|
||||
parentTaskRunId,
|
||||
rootTaskRunId,
|
||||
depth,
|
||||
resumeParentOnCompletion: opts.resumeParentOnCompletion,
|
||||
batch: opts.batch,
|
||||
});
|
||||
|
||||
return failedRunFriendlyId;
|
||||
} catch (createError) {
|
||||
logger.error("TriggerFailedTaskService: failed to create pre-failed TaskRun (no trace)", {
|
||||
taskId: opts.taskId,
|
||||
friendlyId: failedRunFriendlyId,
|
||||
originalError: opts.errorMessage,
|
||||
createError: createError instanceof Error ? createError.message : String(createError),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -234,24 +234,6 @@ export class RunEngineTriggerTaskService {
|
||||
});
|
||||
}
|
||||
|
||||
if (!options.skipChecks) {
|
||||
const queueSizeGuard = await this.queueConcern.validateQueueLimits(environment);
|
||||
|
||||
if (!queueSizeGuard.ok) {
|
||||
throw new ServiceValidationError(
|
||||
`Cannot trigger ${taskId} as the queue size limit for this environment has been reached. The maximum size is ${queueSizeGuard.maximumSize}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const metadataPacket = body.options?.metadata
|
||||
? handleMetadataPacket(
|
||||
body.options?.metadata,
|
||||
body.options?.metadataType ?? "application/json",
|
||||
this.metadataMaximumSize
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const lockedToBackgroundWorker = body.options?.lockToVersion
|
||||
? await this.prisma.backgroundWorker.findFirst({
|
||||
where: {
|
||||
@@ -273,6 +255,27 @@ export class RunEngineTriggerTaskService {
|
||||
lockedToBackgroundWorker ?? undefined
|
||||
);
|
||||
|
||||
if (!options.skipChecks) {
|
||||
const queueSizeGuard = await this.queueConcern.validateQueueLimits(
|
||||
environment,
|
||||
queueName
|
||||
);
|
||||
|
||||
if (!queueSizeGuard.ok) {
|
||||
throw new ServiceValidationError(
|
||||
`Cannot trigger ${taskId} as the queue size limit for this environment has been reached. The maximum size is ${queueSizeGuard.maximumSize}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const metadataPacket = body.options?.metadata
|
||||
? handleMetadataPacket(
|
||||
body.options?.metadata,
|
||||
body.options?.metadataType ?? "application/json",
|
||||
this.metadataMaximumSize
|
||||
)
|
||||
: undefined;
|
||||
|
||||
//upsert tags
|
||||
const tags = await createTags(
|
||||
{
|
||||
|
||||
@@ -64,6 +64,7 @@ export interface QueueManager {
|
||||
getQueueName(request: TriggerTaskRequest): Promise<string>;
|
||||
validateQueueLimits(
|
||||
env: AuthenticatedEnvironment,
|
||||
queueName: string,
|
||||
itemsToAdd?: number
|
||||
): Promise<QueueValidationResult>;
|
||||
getWorkerQueue(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export type AuthorizationAction = "read" | "write" | string; // Add more actions as needed
|
||||
|
||||
const ResourceTypes = ["tasks", "tags", "runs", "batch", "waitpoints", "deployments"] as const;
|
||||
const ResourceTypes = ["tasks", "tags", "runs", "batch", "waitpoints", "deployments", "inputStreams"] as const;
|
||||
|
||||
export type AuthorizationResources = {
|
||||
[key in (typeof ResourceTypes)[number]]?: string | string[];
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Redis } from "ioredis";
|
||||
import { env } from "~/env.server";
|
||||
import { singleton } from "~/utils/singleton";
|
||||
import { logger } from "./logger.server";
|
||||
|
||||
const KEY_PREFIX = "isw:";
|
||||
const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
|
||||
function buildKey(runFriendlyId: string, streamId: string): string {
|
||||
return `${KEY_PREFIX}${runFriendlyId}:${streamId}`;
|
||||
}
|
||||
|
||||
function initializeRedis(): Redis | undefined {
|
||||
const host = env.CACHE_REDIS_HOST;
|
||||
if (!host) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return new Redis({
|
||||
connectionName: "inputStreamWaitpointCache",
|
||||
host,
|
||||
port: env.CACHE_REDIS_PORT,
|
||||
username: env.CACHE_REDIS_USERNAME,
|
||||
password: env.CACHE_REDIS_PASSWORD,
|
||||
keyPrefix: "tr:",
|
||||
enableAutoPipelining: true,
|
||||
...(env.CACHE_REDIS_TLS_DISABLED === "true" ? {} : { tls: {} }),
|
||||
});
|
||||
}
|
||||
|
||||
const redis = singleton("inputStreamWaitpointCache", initializeRedis);
|
||||
|
||||
/**
|
||||
* Store a mapping from input stream to waitpoint ID in Redis.
|
||||
* Called when `.wait()` creates a new waitpoint.
|
||||
*/
|
||||
export async function setInputStreamWaitpoint(
|
||||
runFriendlyId: string,
|
||||
streamId: string,
|
||||
waitpointId: string,
|
||||
ttlMs?: number
|
||||
): Promise<void> {
|
||||
if (!redis) return;
|
||||
|
||||
try {
|
||||
const key = buildKey(runFriendlyId, streamId);
|
||||
await redis.set(key, waitpointId, "PX", ttlMs ?? DEFAULT_TTL_MS);
|
||||
} catch (error) {
|
||||
logger.error("Failed to set input stream waitpoint cache", {
|
||||
runFriendlyId,
|
||||
streamId,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the waitpoint ID for an input stream without deleting it.
|
||||
* Called from the `.send()` route before completing the waitpoint.
|
||||
*/
|
||||
export async function getInputStreamWaitpoint(
|
||||
runFriendlyId: string,
|
||||
streamId: string
|
||||
): Promise<string | null> {
|
||||
if (!redis) return null;
|
||||
|
||||
try {
|
||||
const key = buildKey(runFriendlyId, streamId);
|
||||
return await redis.get(key);
|
||||
} catch (error) {
|
||||
logger.error("Failed to get input stream waitpoint cache", {
|
||||
runFriendlyId,
|
||||
streamId,
|
||||
error,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the cache entry for an input stream waitpoint.
|
||||
* Called when a waitpoint is completed or timed out.
|
||||
*/
|
||||
export async function deleteInputStreamWaitpoint(
|
||||
runFriendlyId: string,
|
||||
streamId: string
|
||||
): Promise<void> {
|
||||
if (!redis) return;
|
||||
|
||||
try {
|
||||
const key = buildKey(runFriendlyId, streamId);
|
||||
await redis.del(key);
|
||||
} catch (error) {
|
||||
logger.error("Failed to delete input stream waitpoint cache", {
|
||||
runFriendlyId,
|
||||
streamId,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,24 @@ class LoopsClient {
|
||||
});
|
||||
}
|
||||
|
||||
async vercelIntegrationStarted({
|
||||
userId,
|
||||
email,
|
||||
name,
|
||||
}: {
|
||||
userId: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
}) {
|
||||
logger.info(`Loops send "vercel-integration" event`, { userId, email, name });
|
||||
return this.#sendEvent({
|
||||
email,
|
||||
userId,
|
||||
firstName: name?.split(" ").at(0),
|
||||
eventName: "vercel-integration",
|
||||
});
|
||||
}
|
||||
|
||||
async #sendEvent({
|
||||
email,
|
||||
userId,
|
||||
|
||||
@@ -30,10 +30,10 @@ export class ProjectSettingsService {
|
||||
);
|
||||
}
|
||||
|
||||
deleteProject(projectSlug: string, userId: string) {
|
||||
deleteProject(projectId: string, userId: string) {
|
||||
const deleteProjectService = new DeleteProjectService(this.#prismaClient);
|
||||
|
||||
return fromPromise(deleteProjectService.call({ projectSlug, userId }), (error) => ({
|
||||
return fromPromise(deleteProjectService.call({ projectId, userId }), (error) => ({
|
||||
type: "other" as const,
|
||||
cause: error,
|
||||
}));
|
||||
|
||||
@@ -466,4 +466,8 @@ export class RedisRealtimeStreams implements StreamIngestor, StreamResponder {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async readRecords(): Promise<never> {
|
||||
throw new Error("readRecords is not implemented for Redis realtime streams");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// app/realtime/S2RealtimeStreams.ts
|
||||
import type { UnkeyCache } from "@internal/cache";
|
||||
import { StreamIngestor, StreamResponder, StreamResponseOptions } from "./types";
|
||||
import { StreamIngestor, StreamRecord, StreamResponder, StreamResponseOptions } from "./types";
|
||||
import { Logger, LogLevel } from "@trigger.dev/core/logger";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
@@ -10,6 +10,12 @@ export type S2RealtimeStreamsOptions = {
|
||||
accessToken: string; // "Bearer" token issued in S2 console
|
||||
streamPrefix?: string; // defaults to ""
|
||||
|
||||
// Custom endpoint for s2-lite (self-hosted)
|
||||
endpoint?: string; // e.g., "http://localhost:4566/v1"
|
||||
|
||||
// Skip access token issuance (s2-lite doesn't support /access-tokens)
|
||||
skipAccessTokens?: boolean;
|
||||
|
||||
// Read behavior
|
||||
s2WaitSeconds?: number;
|
||||
|
||||
@@ -37,8 +43,11 @@ type S2AppendAck = {
|
||||
export class S2RealtimeStreams implements StreamResponder, StreamIngestor {
|
||||
private readonly basin: string;
|
||||
private readonly baseUrl: string;
|
||||
private readonly accountUrl: string;
|
||||
private readonly endpoint?: string;
|
||||
private readonly token: string;
|
||||
private readonly streamPrefix: string;
|
||||
private readonly skipAccessTokens: boolean;
|
||||
|
||||
private readonly s2WaitSeconds: number;
|
||||
|
||||
@@ -56,9 +65,12 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor {
|
||||
|
||||
constructor(opts: S2RealtimeStreamsOptions) {
|
||||
this.basin = opts.basin;
|
||||
this.baseUrl = `https://${this.basin}.b.aws.s2.dev/v1`;
|
||||
this.baseUrl = opts.endpoint ?? `https://${this.basin}.b.aws.s2.dev/v1`;
|
||||
this.accountUrl = opts.endpoint ?? `https://aws.s2.dev/v1`;
|
||||
this.endpoint = opts.endpoint;
|
||||
this.token = opts.accessToken;
|
||||
this.streamPrefix = opts.streamPrefix ?? "";
|
||||
this.skipAccessTokens = opts.skipAccessTokens ?? false;
|
||||
|
||||
this.s2WaitSeconds = opts.s2WaitSeconds ?? 60;
|
||||
|
||||
@@ -80,17 +92,20 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor {
|
||||
runId: string,
|
||||
streamId: string
|
||||
): Promise<{ responseHeaders?: Record<string, string> }> {
|
||||
const id = randomUUID();
|
||||
|
||||
const accessToken = await this.getS2AccessToken(id);
|
||||
const accessToken = this.skipAccessTokens
|
||||
? this.token
|
||||
: await this.getS2AccessToken(randomUUID());
|
||||
|
||||
return {
|
||||
responseHeaders: {
|
||||
"X-S2-Access-Token": accessToken,
|
||||
"X-S2-Stream-Name": `/runs/${runId}/${streamId}`,
|
||||
"X-S2-Stream-Name": this.skipAccessTokens
|
||||
? this.toStreamName(runId, streamId)
|
||||
: `/runs/${runId}/${streamId}`,
|
||||
"X-S2-Basin": this.basin,
|
||||
"X-S2-Flush-Interval-Ms": this.flushIntervalMs.toString(),
|
||||
"X-S2-Max-Retries": this.maxRetries.toString(),
|
||||
...(this.endpoint ? { "X-S2-Endpoint": this.endpoint } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -121,6 +136,88 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor {
|
||||
throw new Error("S2 streams are written to S2 via the client, not from the server");
|
||||
}
|
||||
|
||||
async readRecords(
|
||||
runId: string,
|
||||
streamId: string,
|
||||
afterSeqNum?: number
|
||||
): Promise<StreamRecord[]> {
|
||||
const s2Stream = this.toStreamName(runId, streamId);
|
||||
const startSeq = afterSeqNum != null ? afterSeqNum + 1 : 0;
|
||||
|
||||
const qs = new URLSearchParams();
|
||||
qs.set("seq_num", String(startSeq));
|
||||
qs.set("clamp", "true");
|
||||
qs.set("wait", "0"); // Non-blocking: return immediately with existing records
|
||||
|
||||
const res = await fetch(
|
||||
`${this.baseUrl}/streams/${encodeURIComponent(s2Stream)}/records?${qs}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
Accept: "text/event-stream",
|
||||
"S2-Format": "raw",
|
||||
"S2-Basin": this.basin,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
// Stream may not exist yet (no data sent)
|
||||
if (res.status === 404) {
|
||||
return [];
|
||||
}
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`S2 readRecords failed: ${res.status} ${res.statusText} ${text}`);
|
||||
}
|
||||
|
||||
// Parse the SSE response body to extract records
|
||||
const body = await res.text();
|
||||
return this.parseSSEBatchRecords(body);
|
||||
}
|
||||
|
||||
private parseSSEBatchRecords(sseText: string): StreamRecord[] {
|
||||
const records: StreamRecord[] = [];
|
||||
|
||||
// SSE events are separated by double newlines
|
||||
const events = sseText.split("\n\n").filter((e) => e.trim());
|
||||
|
||||
for (const event of events) {
|
||||
const lines = event.split("\n");
|
||||
let eventType: string | undefined;
|
||||
let data: string | undefined;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("event:")) {
|
||||
eventType = line.slice(6).trim();
|
||||
} else if (line.startsWith("data:")) {
|
||||
data = line.slice(5).trim();
|
||||
}
|
||||
}
|
||||
|
||||
if (eventType === "batch" && data) {
|
||||
try {
|
||||
const parsed = JSON.parse(data) as {
|
||||
records: Array<{ body: string; seq_num: number; timestamp: number }>;
|
||||
};
|
||||
|
||||
for (const record of parsed.records) {
|
||||
const parsedBody = JSON.parse(record.body) as { data: string; id: string };
|
||||
records.push({
|
||||
data: parsedBody.data,
|
||||
id: parsedBody.id,
|
||||
seqNum: record.seq_num,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Skip malformed events
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
// ---------- Serve SSE from S2 ----------
|
||||
|
||||
async streamResponse(
|
||||
@@ -155,7 +252,8 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor {
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
"Content-Type": "application/json",
|
||||
"S2-Format": "raw", // UTF-8 JSON encoding (no base64 overhead) when your data is text. :contentReference[oaicite:8]{index=8}
|
||||
"S2-Format": "raw",
|
||||
"S2-Basin": this.basin,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
@@ -184,7 +282,7 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor {
|
||||
|
||||
private async s2IssueAccessToken(id: string): Promise<string> {
|
||||
// POST /v1/access-tokens
|
||||
const res = await fetch(`https://aws.s2.dev/v1/access-tokens`, {
|
||||
const res = await fetch(`${this.accountUrl}/access-tokens`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
@@ -235,6 +333,7 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
Accept: "text/event-stream",
|
||||
"S2-Format": "raw",
|
||||
"S2-Basin": this.basin,
|
||||
},
|
||||
signal: opts.signal,
|
||||
});
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
export type StreamRecord = {
|
||||
data: string;
|
||||
id: string;
|
||||
seqNum: number;
|
||||
};
|
||||
|
||||
// Interface for stream ingestion
|
||||
export interface StreamIngestor {
|
||||
initializeStream(
|
||||
@@ -16,6 +22,12 @@ export interface StreamIngestor {
|
||||
appendPart(part: string, partId: string, runId: string, streamId: string): Promise<void>;
|
||||
|
||||
getLastChunkIndex(runId: string, streamId: string, clientId: string): Promise<number>;
|
||||
|
||||
readRecords(
|
||||
runId: string,
|
||||
streamId: string,
|
||||
afterSeqNum?: number
|
||||
): Promise<StreamRecord[]>;
|
||||
}
|
||||
|
||||
export type StreamResponseOptions = {
|
||||
|
||||
@@ -36,10 +36,16 @@ export function getRealtimeStreamInstance(
|
||||
if (streamVersion === "v1") {
|
||||
return v1RealtimeStreams;
|
||||
} else {
|
||||
if (env.REALTIME_STREAMS_S2_BASIN && env.REALTIME_STREAMS_S2_ACCESS_TOKEN) {
|
||||
if (
|
||||
env.REALTIME_STREAMS_S2_BASIN &&
|
||||
(env.REALTIME_STREAMS_S2_ACCESS_TOKEN ||
|
||||
env.REALTIME_STREAMS_S2_SKIP_ACCESS_TOKENS === "true")
|
||||
) {
|
||||
return new S2RealtimeStreams({
|
||||
basin: env.REALTIME_STREAMS_S2_BASIN,
|
||||
accessToken: env.REALTIME_STREAMS_S2_ACCESS_TOKEN,
|
||||
accessToken: env.REALTIME_STREAMS_S2_ACCESS_TOKEN ?? "",
|
||||
endpoint: env.REALTIME_STREAMS_S2_ENDPOINT,
|
||||
skipAccessTokens: env.REALTIME_STREAMS_S2_SKIP_ACCESS_TOKENS === "true",
|
||||
streamPrefix: [
|
||||
"org",
|
||||
environment.organization.id,
|
||||
@@ -68,7 +74,7 @@ export function determineRealtimeStreamsVersion(streamVersion?: string): "v1" |
|
||||
if (
|
||||
streamVersion === "v2" &&
|
||||
env.REALTIME_STREAMS_S2_BASIN &&
|
||||
env.REALTIME_STREAMS_S2_ACCESS_TOKEN
|
||||
(env.REALTIME_STREAMS_S2_ACCESS_TOKEN || env.REALTIME_STREAMS_S2_SKIP_ACCESS_TOKENS === "true")
|
||||
) {
|
||||
return "v2";
|
||||
}
|
||||
|
||||
@@ -6,7 +6,18 @@ import { getImpersonationId } from "./impersonation.server";
|
||||
export async function getUserId(request: Request): Promise<string | undefined> {
|
||||
const impersonatedUserId = await getImpersonationId(request);
|
||||
|
||||
if (impersonatedUserId) return impersonatedUserId;
|
||||
if (impersonatedUserId) {
|
||||
// Verify the real user (from the session cookie) is still an admin
|
||||
const authUser = await authenticator.isAuthenticated(request);
|
||||
if (authUser?.userId) {
|
||||
const realUser = await getUserById(authUser.userId);
|
||||
if (realUser?.admin) {
|
||||
return impersonatedUserId;
|
||||
}
|
||||
}
|
||||
// Admin revoked or session invalid — fall through to return the real user's ID
|
||||
return authUser?.userId;
|
||||
}
|
||||
|
||||
let authUser = await authenticator.isAuthenticated(request);
|
||||
return authUser?.userId;
|
||||
@@ -54,7 +65,7 @@ export async function requireUser(request: Request) {
|
||||
dashboardPreferences: user.dashboardPreferences,
|
||||
confirmedBasicDetails: user.confirmedBasicDetails,
|
||||
mfaEnabledAt: user.mfaEnabledAt,
|
||||
isImpersonating: !!impersonationId,
|
||||
isImpersonating: !!impersonationId && impersonationId === userId,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user