Compare commits
287 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 28a66ac021 | |||
| 7d34817473 | |||
| 6d6ed471d1 | |||
| 7f7f993587 | |||
| d28707826c | |||
| 8b00198f99 | |||
| 74e9246bfa | |||
| 07a31d3732 | |||
| da111e220f | |||
| 2c3cb4a43a | |||
| b71bf89444 | |||
| 28c0c78257 | |||
| c021d1db63 | |||
| f62cdfe00e | |||
| 66c6da7114 | |||
| 7fba9e9f6b | |||
| cf63fc9cd2 | |||
| d1c3bfb9c9 | |||
| d8f5853457 | |||
| a52566d9cc | |||
| 07a1d04d52 | |||
| 185f4ecaf9 | |||
| 0d764d4c46 | |||
| beb52b9800 | |||
| 3401a1d0a9 | |||
| 3f982ed366 | |||
| 4dc956470d | |||
| 7fddadcce9 | |||
| 6e038d4d1e | |||
| 05b53967ea | |||
| 702f3b4bca | |||
| 8fcd93001d | |||
| b01b8740cc | |||
| 357aa99309 | |||
| 7cbf82a4ae | |||
| e7fec4097f | |||
| d279988e38 | |||
| 748ae658f7 | |||
| 249878ed92 | |||
| 117b1d5a53 | |||
| 04173a93b9 | |||
| da4c753b68 | |||
| 6ae3b69745 | |||
| 652d95c7eb | |||
| 341e27d213 | |||
| 9821d02af7 | |||
| 255a73a2fe | |||
| c5f7a8daf7 | |||
| 8b0f51b317 | |||
| 53f21e1330 | |||
| 331882f59c | |||
| 1276491a83 | |||
| 5b7dfe23b5 | |||
| 49b2f683f4 | |||
| af9b3e1c99 | |||
| df4ab97d59 | |||
| 9f27422472 | |||
| 2f1a72b109 | |||
| 3c326a4b4a | |||
| 6ae1317b69 | |||
| 2e1c4f6df6 | |||
| 2bf86dc20e | |||
| 485782cae1 | |||
| 61b338bea7 | |||
| 83ddf721a4 | |||
| a4dd2562d2 | |||
| 5ff21a758c | |||
| 47a64c0335 | |||
| fc351cb6c4 | |||
| c4f2a9d065 | |||
| 2762c542c2 | |||
| 72e286af2f | |||
| f7240a99e7 | |||
| 6e47377766 | |||
| a8563ca534 | |||
| 5e5c97ea4c | |||
| abd99aa27f | |||
| 4347499799 | |||
| abee783d3f | |||
| 6464eeed53 | |||
| bee59de3a0 | |||
| 3af7303156 | |||
| 6231ddc67a | |||
| 15fef916f6 | |||
| e2a703bfa8 | |||
| 1a7ee24b9e | |||
| bb99af52cb | |||
| 01797a1668 | |||
| 19fa669318 | |||
| 78fcba518a | |||
| 53047ab648 | |||
| bb5cefa92c | |||
| b8b198579c | |||
| 892bed8c4c | |||
| a94a11f44d | |||
| f116e93e01 | |||
| 6137338da9 | |||
| 8cec3b763b | |||
| 343ba54c69 | |||
| a70ab10809 | |||
| f7cb637b32 | |||
| 668559ec1a | |||
| d0ad38d684 | |||
| 536d9fa217 | |||
| d75c3aeadd | |||
| a342332146 | |||
| 9624465ee2 | |||
| 42f53b12b7 | |||
| 9fdf91a1c4 | |||
| 9593a46364 | |||
| 27376df903 | |||
| e0cece72ae | |||
| 2f3f82f3a9 | |||
| 4264bdf429 | |||
| bf6735bd56 | |||
| dacd53b906 | |||
| d4fe71df34 | |||
| ae8e83b2d0 | |||
| 2283ca6ad3 | |||
| 8fdbbeb02f | |||
| 255ea0a4b3 | |||
| 7f25e82299 | |||
| d90da7abf7 | |||
| 2affe541e8 | |||
| 3157b657c7 | |||
| 41bdab58d5 | |||
| fe3fe01fe8 | |||
| 68d0037e60 | |||
| 885d2d3560 | |||
| a445af1b79 | |||
| 5781783c74 | |||
| cca10c22d3 | |||
| 63b6fc93fa | |||
| aa66462971 | |||
| f6461684ad | |||
| a6896b411a | |||
| 0cabbdd31f | |||
| f8977a7b70 | |||
| f0643f76f5 | |||
| 416dbcd536 | |||
| 679b41dc7e | |||
| be98aecbfd | |||
| 129dc02f2a | |||
| 8917478d3c | |||
| f5caa66348 | |||
| 64fcc88fa7 | |||
| 23dbe282ed | |||
| 692316e82a | |||
| 107f4dc87c | |||
| b90f3e2173 | |||
| b3b2553651 | |||
| cdd1a8838c | |||
| 200b7354d0 | |||
| eeed38d223 | |||
| 0ca092651b | |||
| 53acdf8ef5 | |||
| 128bc437f6 | |||
| 0597691001 | |||
| dae84a0d29 | |||
| f72d63aac2 | |||
| 12cceaa779 | |||
| ddebe4dce0 | |||
| 09d51c6d24 | |||
| 3ceea774a8 | |||
| 05b6a26c4f | |||
| 558fb11b89 | |||
| 9aedda23a4 | |||
| 743b8dbe0c | |||
| eb0263e942 | |||
| 7bf579fa50 | |||
| 69d52db856 | |||
| a3cea1302e | |||
| a3bdd3c64b | |||
| 6d6e98aa11 | |||
| e22c321dd1 | |||
| 59df4af1eb | |||
| 8863ff05c9 | |||
| d10281e655 | |||
| 6798d57e72 | |||
| 480c0d34d3 | |||
| 700a6ea598 | |||
| cc94d121f2 | |||
| 412e80fdde | |||
| 49728b5a5f | |||
| d45696c000 | |||
| 8313800746 | |||
| 28f8cee3a4 | |||
| 87b3603b23 | |||
| 365adc24a6 | |||
| 7d17730b52 | |||
| e4982bfd6d | |||
| a03783d1a0 | |||
| 0178bdbb00 | |||
| ad51168181 | |||
| db87295049 | |||
| a3ef6ea236 | |||
| ae22000409 | |||
| 2bbf8ec6cd | |||
| c8858edf0a | |||
| 9c087646bf | |||
| 691903cf58 | |||
| 885ae5e06f | |||
| 501a383bcd | |||
| 3c199e6d9c | |||
| a55294b7dd | |||
| c9d1aadfc3 | |||
| 08702cd710 | |||
| 04dcb81496 | |||
| 0f9b83db09 | |||
| 7d333e5b3c | |||
| 7c4ce6f76b | |||
| dc42ae7aa4 | |||
| b0b88f1e05 | |||
| 6483a0f1c6 | |||
| 6f1abe058b | |||
| 83bd6f5f9e | |||
| e36d78e4fc | |||
| 3188dc9b28 | |||
| 10e7985fbc | |||
| 2eddda1233 | |||
| 49f2c54031 | |||
| f077d49291 | |||
| 5db583b6cd | |||
| 1227e5463e | |||
| 8e66913e59 | |||
| b9b17e24ac | |||
| 12bef0a938 | |||
| 5567f49846 | |||
| 71060d93b1 | |||
| 89b1d8ba13 | |||
| 97015ba8c8 | |||
| 99660112bd | |||
| e6586d3c1a | |||
| 00d32ed4ee | |||
| a1e9738faa | |||
| 59c17e04e9 | |||
| ed23615aa4 | |||
| 436d951b65 | |||
| cf9398b56e | |||
| 9b1877bef2 | |||
| 0b2b73fc52 | |||
| 0d1eac9406 | |||
| ddbae6b6b4 | |||
| 2b095b1072 | |||
| 847ea866b6 | |||
| fee31f2dc0 | |||
| 10baa396d8 | |||
| 27bc2ad232 | |||
| 3a3e863daf | |||
| f5f14c4206 | |||
| 892adfe81c | |||
| 73e7378459 | |||
| b0b0df69be | |||
| e629810e96 | |||
| e6f6d93e59 | |||
| 6f6ca01584 | |||
| 11cbd1b5a0 | |||
| 82401ec4fe | |||
| 24a915133e | |||
| 688b108ec3 | |||
| 1cc62230ab | |||
| 96243efe16 | |||
| ad26cded99 | |||
| 5e4756f8b7 | |||
| c16cc577b0 | |||
| 5b40c44ca6 | |||
| c7e3811794 | |||
| bf2120d5a7 | |||
| 895e24a0d7 | |||
| 58ff034325 | |||
| 84750f68a4 | |||
| 1087e5ede0 | |||
| f086626a41 | |||
| 8483900832 | |||
| 4264fc066e | |||
| da79c2b185 | |||
| 677b7aed9c | |||
| 9092ca863f | |||
| b41129a24f | |||
| 0261ccbc68 | |||
| 32e3fab5ea | |||
| 0ee4234117 | |||
| 471c96079a | |||
| 0ae59cf7b9 | |||
| 49e6470b6b | |||
| 9dd831f68a | |||
| 112f6f602e |
@@ -0,0 +1,11 @@
|
||||
# Remove AI code slop
|
||||
|
||||
Check the diff against main, and remove all AI generated slop introduced in this branch.
|
||||
|
||||
This includes:
|
||||
- Extra comments that a human wouldn't add or is inconsistent with the rest of the file
|
||||
- Extra defensive checks or try/catch blocks that are abnormal for that area of the codebase (especially if called by trusted / validated codepaths)
|
||||
- Casts to any to get around type issues
|
||||
- Any other style that is inconsistent with the file
|
||||
|
||||
Report at the end with only a 1-3 sentence summary of what you changed
|
||||
+2
-6
@@ -1,7 +1,3 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"trigger.dev": {
|
||||
"url": "http://localhost:3333/sse"
|
||||
}
|
||||
}
|
||||
}
|
||||
"mcpServers": {}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ alwaysApply: false
|
||||
|
||||
The main trigger.dev webapp, which powers it's API and dashboard and makes up the docker image that is produced as an OSS image, is a Remix 2.1.0 app that uses an express server, written in TypeScript. The following subsystems are either included in the webapp or are used by the webapp in another part of the monorepo:
|
||||
|
||||
- `@trigger.dev/database` exports a Prisma 5.4.1 client that is used extensively in the webapp to access a PostgreSQL instance. The schema file is [schema.prisma](mdc:internal-packages/database/prisma/schema.prisma)
|
||||
- `@trigger.dev/database` exports a Prisma 6.14.0 client that is used extensively in the webapp to access a PostgreSQL instance. The schema file is [schema.prisma](mdc:internal-packages/database/prisma/schema.prisma)
|
||||
- `@trigger.dev/core` is a published package and is used to share code between the `@trigger.dev/sdk` and the webapp. It includes functionality but also a load of Zod schemas for data validation. When importing from `@trigger.dev/core` in the webapp, we never import the root `@trigger.dev/core` path, instead we favor one of the subpath exports that you can find in [package.json](mdc:packages/core/package.json)
|
||||
- `@internal/run-engine` has all the code needed to trigger a run and take it through it's lifecycle to completion.
|
||||
- `@trigger.dev/redis-worker` is a custom redis based background job/worker system that's used in the webapp and also used inside the run engine.
|
||||
@@ -31,7 +31,10 @@ We originally the Trigger.dev "Run Engine" not as a single system, but just spre
|
||||
- The batch trigger API endpoint is [api.v1.tasks.batch.ts](mdc:apps/webapp/app/routes/api.v1.tasks.batch.ts)
|
||||
- Setup code for the prisma client is in [db.server.ts](mdc:apps/webapp/app/db.server.ts)
|
||||
- The run engine is configured in [runEngine.server.ts](mdc:apps/webapp/app/v3/runEngine.server.ts)
|
||||
- All the "services" that are found in app/v3/services/**/*.server.ts
|
||||
- All the "services" that are found in app/v3/services/\*_/_.server.ts
|
||||
- The code for the TaskEvent data, which is the otel data sent from tasks to our servers, is in both the [eventRepository.server.ts](mdc:apps/webapp/app/v3/eventRepository.server.ts) and also the [otlpExporter.server.ts](mdc:apps/webapp/app/v3/otlpExporter.server.ts). The otel endpoints which are hit from production and development otel exporters is [otel.v1.logs.ts](mdc:apps/webapp/app/routes/otel.v1.logs.ts) and [otel.v1.traces.ts](mdc:apps/webapp/app/routes/otel.v1.traces.ts)
|
||||
- We use "presenters" to move more complex loader code into a class, and you can find those are app/v3/presenters/**/*.server.ts
|
||||
- We use "presenters" to move more complex loader code into a class, and you can find those are app/v3/presenters/\*_/_.server.ts
|
||||
|
||||
- All the "services" that are found in app/v3/services/\*_/_.server.ts
|
||||
- The code for the TaskEvent data, which is the otel data sent from tasks to our servers, is in both the [eventRepository.server.ts](mdc:apps/webapp/app/v3/eventRepository.server.ts) and also the [otlpExporter.server.ts](mdc:apps/webapp/app/v3/otlpExporter.server.ts). The otel endpoints which are hit from production and development otel exporters is [otel.v1.logs.ts](mdc:apps/webapp/app/routes/otel.v1.logs.ts) and [otel.v1.traces.ts](mdc:apps/webapp/app/routes/otel.v1.traces.ts)
|
||||
- We use "presenters" to move more complex loader code into a class, and you can find those are app/v3/presenters/\*_/_.server.ts
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
**/dist
|
||||
**/node_modules
|
||||
|
||||
**/generated/prisma
|
||||
|
||||
apps/webapp/build
|
||||
apps/webapp/public/build
|
||||
|
||||
|
||||
+7
-2
@@ -13,6 +13,11 @@ APP_ORIGIN=http://localhost:3030
|
||||
ELECTRIC_ORIGIN=http://localhost:3060
|
||||
NODE_ENV=development
|
||||
|
||||
# Clickhouse
|
||||
CLICKHOUSE_URL=http://default:password@localhost:8123
|
||||
RUN_REPLICATION_CLICKHOUSE_URL=http://default:password@localhost:8123
|
||||
RUN_REPLICATION_ENABLED=1
|
||||
|
||||
# Set this to UTC because Node.js uses the system timezone
|
||||
TZ="UTC"
|
||||
|
||||
@@ -29,9 +34,9 @@ DEPLOY_REGISTRY_HOST=localhost:5000
|
||||
|
||||
# OPTIONAL VARIABLES
|
||||
# This is used for validating emails that are allowed to log in. Every email that do not match this regex will be rejected.
|
||||
# WHITELISTED_EMAILS="authorized@yahoo\.com|authorized@gmail\.com"
|
||||
# WHITELISTED_EMAILS="^(authorized@yahoo\.com|authorized@gmail\.com)$"
|
||||
# Accounts with these emails will get global admin rights. This grants access to the admin UI.
|
||||
# ADMIN_EMAILS="admin@example\.com|another-admin@example\.com"
|
||||
# ADMIN_EMAILS="^(admin@example\.com|another-admin@example\.com)$"
|
||||
# This is used for logging in via GitHub. You can leave these commented out if you don't want to use GitHub for authentication.
|
||||
# AUTH_GITHUB_CLIENT_ID=
|
||||
# AUTH_GITHUB_CLIENT_SECRET=
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
name: 🦋 Changesets PR
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "packages/**"
|
||||
- ".changeset/**"
|
||||
- "package.json"
|
||||
- "pnpm-lock.yaml"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
release-pr:
|
||||
name: Create Release PR
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
if: github.repository == 'triggerdotdev/trigger.dev'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: 20.19.0
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Create release PR
|
||||
id: changesets
|
||||
uses: changesets/action@v1
|
||||
with:
|
||||
version: pnpm run changeset:version
|
||||
commit: "chore: release"
|
||||
title: "chore: release"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Update PR title with version
|
||||
if: steps.changesets.outputs.published != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
PR_NUMBER=$(gh pr list --head changeset-release/main --json number --jq '.[0].number')
|
||||
if [ -n "$PR_NUMBER" ]; then
|
||||
git fetch origin changeset-release/main
|
||||
# we arbitrarily reference the version of the cli package here; it is the same for all package releases
|
||||
VERSION=$(git show origin/changeset-release/main:packages/cli-v3/package.json | jq -r '.version')
|
||||
gh pr edit "$PR_NUMBER" --title "chore: release v$VERSION"
|
||||
fi
|
||||
|
||||
update-lockfile:
|
||||
name: Update lockfile on release PR
|
||||
runs-on: ubuntu-latest
|
||||
needs: release-pr
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout release branch
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: changeset-release/main
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.23.0
|
||||
|
||||
- name: Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: 20.19.0
|
||||
|
||||
- name: Install and update lockfile
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
|
||||
- name: Commit and push lockfile
|
||||
run: |
|
||||
set -e
|
||||
if git diff --quiet pnpm-lock.yaml; then
|
||||
echo "No lockfile changes"
|
||||
else
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add pnpm-lock.yaml
|
||||
git commit -m "chore: update lockfile for release"
|
||||
git push origin changeset-release/main
|
||||
fi
|
||||
@@ -31,12 +31,12 @@ jobs:
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8.15.5
|
||||
version: 10.23.0
|
||||
|
||||
- name: ⎔ Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: 20.11.1
|
||||
node-version: 20.19.0
|
||||
|
||||
- name: 📥 Download deps
|
||||
run: pnpm install --frozen-lockfile --filter trigger.dev...
|
||||
|
||||
+135
-48
@@ -1,98 +1,185 @@
|
||||
name: 🦋 Changesets Release
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- "docs/**"
|
||||
- "**.md"
|
||||
- ".github/CODEOWNERS"
|
||||
- ".github/ISSUE_TEMPLATE/**"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
type:
|
||||
description: "Select release type"
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- release
|
||||
- prerelease
|
||||
default: "prerelease"
|
||||
ref:
|
||||
description: "The ref (branch, tag, or SHA) to checkout and release from"
|
||||
required: true
|
||||
type: string
|
||||
prerelease_tag:
|
||||
description: "The npm dist-tag for the prerelease (e.g., 'v4-prerelease')"
|
||||
required: false
|
||||
type: string
|
||||
default: "prerelease"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
group: ${{ github.workflow }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: 🦋 Changesets Release
|
||||
show-release-summary:
|
||||
name: 📋 Release Summary
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
github.repository == 'triggerdotdev/trigger.dev' &&
|
||||
github.event_name == 'pull_request' &&
|
||||
github.event.pull_request.merged == true &&
|
||||
github.event.pull_request.head.ref == 'changeset-release/main'
|
||||
steps:
|
||||
- name: Show release summary
|
||||
env:
|
||||
PR_BODY: ${{ github.event.pull_request.body }}
|
||||
run: |
|
||||
echo "$PR_BODY" | sed -n '/^# Releases/,$p' >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
release:
|
||||
name: 🚀 Release npm packages
|
||||
runs-on: ubuntu-latest
|
||||
environment: npm-publish
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
pull-requests: write
|
||||
if: github.repository == 'triggerdotdev/trigger.dev'
|
||||
id-token: write
|
||||
if: |
|
||||
github.repository == 'triggerdotdev/trigger.dev' &&
|
||||
(
|
||||
(github.event_name == 'workflow_dispatch' && github.event.inputs.type == 'release') ||
|
||||
(github.event_name == 'pull_request' && github.event.pull_request.merged == true && github.event.pull_request.head.ref == 'changeset-release/main')
|
||||
)
|
||||
outputs:
|
||||
published: ${{ steps.changesets.outputs.published }}
|
||||
published_packages: ${{ steps.changesets.outputs.publishedPackages }}
|
||||
published_package_version: ${{ steps.get_version.outputs.package_version }}
|
||||
steps:
|
||||
- name: ⬇️ Checkout repo
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.ref || github.sha }}
|
||||
|
||||
- name: ⎔ Setup pnpm
|
||||
- name: Verify ref is on main
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
run: |
|
||||
if ! git merge-base --is-ancestor ${{ github.event.inputs.ref }} origin/main; then
|
||||
echo "Error: ref must be an ancestor of main (i.e., already merged)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8.15.5
|
||||
version: 10.23.0
|
||||
|
||||
- name: ⎔ Setup node
|
||||
- name: Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: 20.11.1
|
||||
node-version: 20.19.0
|
||||
cache: "pnpm"
|
||||
|
||||
- name: 📥 Download deps
|
||||
# npm v11.5.1 or newer is required for OIDC support
|
||||
# https://github.blog/changelog/2025-07-31-npm-trusted-publishing-with-oidc-is-generally-available/#whats-new
|
||||
- name: Setup npm 11.x for OIDC
|
||||
run: npm install -g npm@11.6.4
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: 📀 Generate Prisma Client
|
||||
- name: Generate Prisma client
|
||||
run: pnpm run generate
|
||||
|
||||
- name: 🏗️ Build
|
||||
- name: Build
|
||||
run: pnpm run build --filter "@trigger.dev/*" --filter "trigger.dev"
|
||||
|
||||
- name: 🔎 Type check
|
||||
- name: Type check
|
||||
run: pnpm run typecheck --filter "@trigger.dev/*" --filter "trigger.dev"
|
||||
|
||||
- name: 🔐 Setup npm auth
|
||||
run: |
|
||||
echo "registry=https://registry.npmjs.org" >> ~/.npmrc
|
||||
echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" >> ~/.npmrc
|
||||
|
||||
# This action has two responsibilities. The first time the workflow runs
|
||||
# (initial push to the `main` branch) it will create a new branch and
|
||||
# then open a PR with the related changes for the new version. After the
|
||||
# PR is merged, the workflow will run again and this action will build +
|
||||
# publish to npm.
|
||||
- name: 🚀 PR / Publish
|
||||
if: ${{ !env.ACT }}
|
||||
- name: Publish
|
||||
id: changesets
|
||||
uses: changesets/action@v1
|
||||
with:
|
||||
version: pnpm run changeset:version
|
||||
commit: "chore: Update version for release"
|
||||
title: "chore: Update version for release"
|
||||
publish: pnpm run changeset:release
|
||||
createGithubReleases: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
# - name: 🚀 PR / Publish (mock)
|
||||
# if: ${{ env.ACT }}
|
||||
# id: changesets
|
||||
# run: |
|
||||
# echo "published=true" >> "$GITHUB_OUTPUT"
|
||||
# echo "publishedPackages=[{\"name\": \"@xx/xx\", \"version\": \"1.2.0\"}, {\"name\": \"@xx/xy\", \"version\": \"0.8.9\"}]" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 📦 Get package version
|
||||
- name: Show package version
|
||||
if: steps.changesets.outputs.published == 'true'
|
||||
id: get_version
|
||||
run: |
|
||||
package_version=$(echo '${{ steps.changesets.outputs.publishedPackages }}' | jq -r '.[0].version')
|
||||
echo "package_version=${package_version}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# this triggers the publish workflow for the docker images
|
||||
- name: Create and push Docker tag
|
||||
if: steps.changesets.outputs.published == 'true'
|
||||
run: |
|
||||
set -e
|
||||
git tag "v.docker.${{ steps.get_version.outputs.package_version }}"
|
||||
git push origin "v.docker.${{ steps.get_version.outputs.package_version }}"
|
||||
|
||||
# The prerelease job needs to be on the same workflow file due to a limitation related to how npm verifies OIDC claims.
|
||||
prerelease:
|
||||
name: 🧪 Prerelease
|
||||
runs-on: ubuntu-latest
|
||||
environment: npm-publish
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
if: github.repository == 'triggerdotdev/trigger.dev' && github.event_name == 'workflow_dispatch' && github.event.inputs.type == 'prerelease'
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event.inputs.ref }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.23.0
|
||||
|
||||
- name: Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: 20.19.0
|
||||
cache: "pnpm"
|
||||
|
||||
# npm v11.5.1 or newer is required for OIDC support
|
||||
# https://github.blog/changelog/2025-07-31-npm-trusted-publishing-with-oidc-is-generally-available/#whats-new
|
||||
- name: Setup npm 11.x for OIDC
|
||||
run: npm install -g npm@11.6.4
|
||||
|
||||
- name: Download deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Generate Prisma Client
|
||||
run: pnpm run generate
|
||||
|
||||
- name: Snapshot version
|
||||
run: pnpm exec changeset version --snapshot ${{ github.event.inputs.prerelease_tag }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Clean
|
||||
run: pnpm run clean --filter "@trigger.dev/*" --filter "trigger.dev"
|
||||
|
||||
- name: Build
|
||||
run: pnpm run build --filter "@trigger.dev/*" --filter "trigger.dev"
|
||||
|
||||
- name: Publish prerelease
|
||||
run: pnpm exec changeset publish --no-git-tag --snapshot --tag ${{ github.event.inputs.prerelease_tag }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -19,12 +19,12 @@ jobs:
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8.15.5
|
||||
version: 10.23.0
|
||||
|
||||
- name: ⎔ Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: 20.11.1
|
||||
node-version: 20.19.0
|
||||
cache: "pnpm"
|
||||
|
||||
- name: 📥 Download deps
|
||||
@@ -35,6 +35,8 @@ jobs:
|
||||
|
||||
- name: 🔎 Type check
|
||||
run: pnpm run typecheck
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
|
||||
- name: 🔎 Check exports
|
||||
run: pnpm run check-exports
|
||||
|
||||
@@ -53,12 +53,12 @@ jobs:
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8.15.5
|
||||
version: 10.23.0
|
||||
|
||||
- name: ⎔ Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: 20.11.1
|
||||
node-version: 20.19.0
|
||||
cache: "pnpm"
|
||||
|
||||
# ..to avoid rate limits when pulling images
|
||||
@@ -111,12 +111,12 @@ jobs:
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8.15.5
|
||||
version: 10.23.0
|
||||
|
||||
- name: ⎔ Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: 20.11.1
|
||||
node-version: 20.19.0
|
||||
# no cache enabled, we're not installing deps
|
||||
|
||||
- name: Download blob reports from GitHub Actions Artifacts
|
||||
|
||||
@@ -53,12 +53,12 @@ jobs:
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8.15.5
|
||||
version: 10.23.0
|
||||
|
||||
- name: ⎔ Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: 20.11.1
|
||||
node-version: 20.19.0
|
||||
cache: "pnpm"
|
||||
|
||||
# ..to avoid rate limits when pulling images
|
||||
@@ -111,12 +111,12 @@ jobs:
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8.15.5
|
||||
version: 10.23.0
|
||||
|
||||
- name: ⎔ Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: 20.11.1
|
||||
node-version: 20.19.0
|
||||
# no cache enabled, we're not installing deps
|
||||
|
||||
- name: Download blob reports from GitHub Actions Artifacts
|
||||
|
||||
@@ -53,12 +53,12 @@ jobs:
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8.15.5
|
||||
version: 10.23.0
|
||||
|
||||
- name: ⎔ Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: 20.11.1
|
||||
node-version: 20.19.0
|
||||
cache: "pnpm"
|
||||
|
||||
# ..to avoid rate limits when pulling images
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
DIRECT_URL: postgresql://postgres:postgres@localhost:5432/postgres
|
||||
SESSION_SECRET: "secret"
|
||||
MAGIC_LINK_SECRET: "secret"
|
||||
ENCRYPTION_KEY: "secret"
|
||||
ENCRYPTION_KEY: "dummy-encryption-keeeey-32-bytes"
|
||||
DEPLOY_REGISTRY_HOST: "docker.io"
|
||||
CLICKHOUSE_URL: "http://default:password@localhost:8123"
|
||||
|
||||
@@ -119,12 +119,12 @@ jobs:
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8.15.5
|
||||
version: 10.23.0
|
||||
|
||||
- name: ⎔ Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: 20.11.1
|
||||
node-version: 20.19.0
|
||||
# no cache enabled, we're not installing deps
|
||||
|
||||
- name: Download blob reports from GitHub Actions Artifacts
|
||||
|
||||
+5
-6
@@ -29,12 +29,10 @@ yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env.docker
|
||||
.env
|
||||
.env.*
|
||||
.docker/*.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
!.env.example
|
||||
|
||||
# turbo
|
||||
.turbo
|
||||
@@ -63,4 +61,5 @@ apps/**/public/build
|
||||
/packages/core/src/package.json
|
||||
/packages/trigger-sdk/src/package.json
|
||||
/packages/python/src/package.json
|
||||
.claude
|
||||
.claude
|
||||
.mcp.log
|
||||
@@ -1,5 +0,0 @@
|
||||
link-workspace-packages=false
|
||||
public-hoist-pattern[]=*prisma*
|
||||
prefer-workspace-packages=true
|
||||
update-notifier=false
|
||||
side-effects-cache=false
|
||||
Vendored
+5
-5
@@ -59,7 +59,7 @@
|
||||
"request": "launch",
|
||||
"name": "Debug V3 Dev CLI",
|
||||
"command": "pnpm exec trigger dev",
|
||||
"cwd": "${workspaceFolder}/references/v3-catalog",
|
||||
"cwd": "${workspaceFolder}/references/hello-world",
|
||||
"sourceMaps": true
|
||||
},
|
||||
{
|
||||
@@ -83,7 +83,7 @@
|
||||
"request": "launch",
|
||||
"name": "Debug V3 Deploy CLI",
|
||||
"command": "pnpm exec trigger deploy --self-hosted --load-image",
|
||||
"cwd": "${workspaceFolder}/references/v3-catalog",
|
||||
"cwd": "${workspaceFolder}/references/hello-world",
|
||||
"sourceMaps": true
|
||||
},
|
||||
{
|
||||
@@ -91,7 +91,7 @@
|
||||
"request": "launch",
|
||||
"name": "Debug V3 list-profiles CLI",
|
||||
"command": "pnpm exec trigger list-profiles --log-level debug",
|
||||
"cwd": "${workspaceFolder}/references/v3-catalog",
|
||||
"cwd": "${workspaceFolder}/references/hello-world",
|
||||
"sourceMaps": true
|
||||
},
|
||||
{
|
||||
@@ -99,7 +99,7 @@
|
||||
"request": "launch",
|
||||
"name": "Debug V3 update CLI",
|
||||
"command": "pnpm exec trigger update",
|
||||
"cwd": "${workspaceFolder}/references/v3-catalog",
|
||||
"cwd": "${workspaceFolder}/references/hello-world",
|
||||
"sourceMaps": true
|
||||
},
|
||||
{
|
||||
@@ -107,7 +107,7 @@
|
||||
"request": "launch",
|
||||
"name": "Debug V3 Management",
|
||||
"command": "pnpm run management",
|
||||
"cwd": "${workspaceFolder}/references/v3-catalog",
|
||||
"cwd": "${workspaceFolder}/references/hello-world",
|
||||
"sourceMaps": true
|
||||
},
|
||||
{
|
||||
|
||||
Vendored
+2
-1
@@ -6,5 +6,6 @@
|
||||
"**/node_modules/**": true,
|
||||
"packages/cli-v3/e2e": true
|
||||
},
|
||||
"vitest.disableWorkspaceWarning": true
|
||||
"vitest.disableWorkspaceWarning": true,
|
||||
"typescript.experimental.useTsgo": false
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ This repository is a pnpm monorepo managed with Turbo. It contains multiple apps
|
||||
See `ai/references/repo.md` for a more complete explanation of the workspaces.
|
||||
|
||||
## Development setup
|
||||
1. Install dependencies with `pnpm i` (pnpm `8.15.5` and Node.js `20.11.1` are required).
|
||||
1. Install dependencies with `pnpm i` (pnpm `10.23.0` and Node.js `20.11.1` are required).
|
||||
2. Copy `.env.example` to `.env` and generate a random 16 byte hex string for `ENCRYPTION_KEY` (`openssl rand -hex 16`). Update other secrets if needed.
|
||||
3. Start the local services with Docker:
|
||||
```bash
|
||||
|
||||
+8
-6
@@ -30,14 +30,16 @@ Please follow the best-practice of adding changesets in the same commit as the c
|
||||
|
||||
## Snapshot instructions
|
||||
|
||||
1. Delete the `.changeset/pre.json` file (if it exists)
|
||||
1. Update the `.changeset/config.json` file to set the `"changelog"` field to this:
|
||||
|
||||
```json
|
||||
"changelog": "@changesets/cli/changelog",
|
||||
```
|
||||
|
||||
2. Do a temporary commit (do NOT push this, you should undo it after)
|
||||
|
||||
3. Copy the `GITHUB_TOKEN` line from the .env file
|
||||
3. Run `./scripts/publish-prerelease.sh prerelease`
|
||||
|
||||
4. Run `GITHUB_TOKEN=github_pat_12345 ./scripts/publish-prerelease.sh re2`
|
||||
You can choose a different tag if you want, but usually `prerelease` is fine.
|
||||
|
||||
Make sure to replace the token with yours. `re2` is the tag that will be used for the pre-release.
|
||||
|
||||
5. Undo the commit where you deleted the pre.json file.
|
||||
5. Undo the commit where you updated the config.json file.
|
||||
|
||||
+17
-72
@@ -15,7 +15,7 @@ branch are tagged into a release periodically.
|
||||
### Prerequisites
|
||||
|
||||
- [Node.js](https://nodejs.org/en) version 20.11.1
|
||||
- [pnpm package manager](https://pnpm.io/installation) version 8.15.5
|
||||
- [pnpm package manager](https://pnpm.io/installation) version 10.23.0
|
||||
- [Docker](https://www.docker.com/get-started/)
|
||||
- [protobuf](https://github.com/protocolbuffers/protobuf)
|
||||
|
||||
@@ -36,7 +36,7 @@ branch are tagged into a release periodically.
|
||||
```
|
||||
3. Ensure you are on the correct version of Node.js (20.11.1). If you are using `nvm`, there is an `.nvmrc` file that will automatically select the correct version of Node.js when you navigate to the repository.
|
||||
|
||||
4. Run `corepack enable` to use the correct version of pnpm (`8.15.5`) as specified in the root `package.json` file.
|
||||
4. Run `corepack enable` to use the correct version of pnpm (`10.23.0`) as specified in the root `package.json` file.
|
||||
|
||||
5. Install the required packages using pnpm.
|
||||
```
|
||||
@@ -84,17 +84,17 @@ branch are tagged into a release periodically.
|
||||
|
||||
2. Once the app is running click the magic link button and enter your email. You will automatically be logged in, since you are running locally. Create an Org and your first project in the dashboard.
|
||||
|
||||
## Manual testing using v3-catalog
|
||||
## Manual testing using hello-world
|
||||
|
||||
We use the `<root>/references/v3-catalog` subdirectory as a staging ground for testing changes to the SDK (`@trigger.dev/sdk` at `<root>/packages/trigger-sdk`), the Core package (`@trigger.dev/core` at `<root>packages/core`), the CLI (`trigger.dev` at `<root>/packages/cli-v3`) and the platform (The remix app at `<root>/apps/webapp`). The instructions below will get you started on using the `v3-catalog` for local development of Trigger.dev (v3).
|
||||
We use the `<root>/references/hello-world` subdirectory as a staging ground for testing changes to the SDK (`@trigger.dev/sdk` at `<root>/packages/trigger-sdk`), the Core package (`@trigger.dev/core` at `<root>packages/core`), the CLI (`trigger.dev` at `<root>/packages/cli-v3`) and the platform (The remix app at `<root>/apps/webapp`). The instructions below will get you started on using the `hello-world` for local development of Trigger.dev.
|
||||
|
||||
### First-time setup
|
||||
|
||||
First, make sure you are running the webapp according to the instructions above. Then:
|
||||
|
||||
1. Visit http://localhost:3030 in your browser and create a new V3 project called "v3-catalog".
|
||||
1. Visit http://localhost:3030 in your browser and create a new V3 project called "hello-world".
|
||||
|
||||
2. In Postgres go to the "Projects" table and for the project you create change the `externalRef` to `yubjwjsfkxnylobaqvqz`.
|
||||
2. In Postgres go to the "Projects" table and for the project you create change the `externalRef` to `proj_rrkpdguyagvsoktglnod`.
|
||||
|
||||
3. Build the CLI
|
||||
|
||||
@@ -105,10 +105,10 @@ pnpm run build --filter trigger.dev
|
||||
pnpm i
|
||||
```
|
||||
|
||||
4. Change into the `<root>/references/v3-catalog` directory and authorize the CLI to the local server:
|
||||
4. Change into the `<root>/references/hello-world` directory and authorize the CLI to the local server:
|
||||
|
||||
```sh
|
||||
cd references/v3-catalog
|
||||
cd references/hello-world
|
||||
cp .env.example .env
|
||||
pnpm exec trigger login -a http://localhost:3030
|
||||
```
|
||||
@@ -118,7 +118,7 @@ This will open a new browser window and authorize the CLI against your local use
|
||||
You can optionally pass a `--profile` flag to the `login` command, which will allow you to use the CLI with separate accounts/servers. We suggest using a profile called `local` for your local development:
|
||||
|
||||
```sh
|
||||
cd references/v3-catalog
|
||||
cd references/hello-world
|
||||
pnpm exec trigger login -a http://localhost:3030 --profile local
|
||||
# later when you run the dev or deploy command:
|
||||
pnpm exec trigger dev --profile local
|
||||
@@ -137,84 +137,29 @@ The following steps should be followed any time you start working on a new featu
|
||||
pnpm run dev --filter trigger.dev --filter "@trigger.dev/*"
|
||||
```
|
||||
|
||||
3. Open another terminal window, and change into the `<root>/references/v3-catalog` directory.
|
||||
3. Open another terminal window, and change into the `<root>/references/hello-world` directory.
|
||||
|
||||
4. You'll need to run the following commands to setup prisma and migrate the database:
|
||||
4. Run the `dev` command, which will register all the local tasks with the platform and allow you to start testing task execution:
|
||||
|
||||
```sh
|
||||
pnpm exec prisma migrate deploy
|
||||
pnpm run generate:prisma
|
||||
```
|
||||
|
||||
5. Run the `dev` command, which will register all the local tasks with the platform and allow you to start testing task execution:
|
||||
|
||||
```sh
|
||||
# in <root>/references/v3-catalog
|
||||
# in <root>/references/hello-world
|
||||
pnpm exec trigger dev
|
||||
```
|
||||
|
||||
If you want additional debug logging, you can use the `--log-level debug` flag:
|
||||
|
||||
```sh
|
||||
# in <root>/references/v3-catalog
|
||||
# in <root>/references/hello-world
|
||||
pnpm exec trigger dev --log-level debug
|
||||
```
|
||||
|
||||
6. If you make any changes in the CLI/Core/SDK, you'll need to `CTRL+C` to exit the `dev` command and restart it to pickup changes. Any changes to the files inside of the `v3-catalog/src/trigger` dir will automatically be rebuilt by the `dev` command.
|
||||
6. If you make any changes in the CLI/Core/SDK, you'll need to `CTRL+C` to exit the `dev` command and restart it to pickup changes. Any changes to the files inside of the `hello-world/src/trigger` dir will automatically be rebuilt by the `dev` command.
|
||||
|
||||
7. Navigate to the `v3-catalog` project in your local dashboard at localhost:3030 and you should see the list of tasks.
|
||||
7. Navigate to the `hello-world` project in your local dashboard at localhost:3030 and you should see the list of tasks.
|
||||
|
||||
8. Go to the "Test" page in the sidebar and select a task. Then enter a payload and click "Run test". You can tell what the payloads should be by looking at the relevant task file inside the `/references/v3-catalog/src/trigger` folder. Many of them accept an empty payload.
|
||||
8. Go to the "Test" page in the sidebar and select a task. Then enter a payload and click "Run test". You can tell what the payloads should be by looking at the relevant task file inside the `/references/hello-world/src/trigger` folder. Many of them accept an empty payload.
|
||||
|
||||
9. Feel free to add additional files in `v3-catalog/src/trigger` to test out specific aspects of the system, or add in edge cases.
|
||||
|
||||
## Running end-to-end webapp tests (deprecated)
|
||||
|
||||
To run the end-to-end tests, follow the steps below:
|
||||
|
||||
1. Set up environment variables (copy example envs into the correct place)
|
||||
|
||||
```sh
|
||||
cp ./.env.example ./.env
|
||||
cp ./references/nextjs-test/.env.example ./references/nextjs-test/.env.local
|
||||
```
|
||||
|
||||
2. Set up dependencies
|
||||
|
||||
```sh
|
||||
# Build packages
|
||||
pnpm run build --filter @references/nextjs-test^...
|
||||
pnpm --filter @trigger.dev/database generate
|
||||
|
||||
# Move trigger-cli bin to correct place
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
# Install playwrite browsers (ONE TIME ONLY)
|
||||
npx playwright install
|
||||
```
|
||||
|
||||
3. Set up the database
|
||||
|
||||
```sh
|
||||
pnpm run docker
|
||||
pnpm run db:migrate
|
||||
pnpm run db:seed
|
||||
```
|
||||
|
||||
4. Run the end-to-end tests
|
||||
|
||||
```sh
|
||||
pnpm run test:e2e
|
||||
```
|
||||
|
||||
### Cleanup
|
||||
|
||||
The end-to-end tests use a `setup` and `teardown` script to seed the database with test data. If the test runner doesn't exit cleanly, then the database can be left in a state where the tests can't run because the `setup` script will try to create data that already exists. If this happens, you can manually delete the `users` and `organizations` from the database using prisma studio:
|
||||
|
||||
```sh
|
||||
# With the database running (i.e. pnpm run docker)
|
||||
pnpm run db:studio
|
||||
```
|
||||
9. Feel free to add additional files in `hello-world/src/trigger` to test out specific aspects of the system, or add in edge cases.
|
||||
|
||||
## Adding and running migrations
|
||||
|
||||
|
||||
@@ -1,44 +1,76 @@
|
||||
<div align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://imagedelivery.net/3TbraffuDZ4aEf8KWOmI_w/a45d1fa2-0ae8-4a39-4409-f4f934bfae00/public">
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://imagedelivery.net/3TbraffuDZ4aEf8KWOmI_w/3f5ad4c1-c4c8-4277-b622-290e7f37bd00/public">
|
||||
<img alt="Trigger.dev logo" src="https://imagedelivery.net/3TbraffuDZ4aEf8KWOmI_w/a45d1fa2-0ae8-4a39-4409-f4f934bfae00/public">
|
||||
</picture>
|
||||
|
||||
### Open source background jobs and AI infrastructure
|
||||
|
||||
[Discord](https://trigger.dev/discord) | [Website](https://trigger.dev) | [Issues](https://github.com/triggerdotdev/trigger.dev/issues) | [Docs](https://trigger.dev/docs)
|
||||

|
||||
|
||||
[](https://twitter.com/triggerdotdev)
|
||||
### Build and deploy fully‑managed AI agents and workflows
|
||||
|
||||
[Website](https://trigger.dev) | [Docs](https://trigger.dev/docs) | [Issues](https://github.com/triggerdotdev/trigger.dev/issues) | [Feature requests](https://triggerdev.featurebase.app/) | [Public roadmap](https://triggerdev.featurebase.app/roadmap) | [Self-hosting](https://trigger.dev/docs/self-hosting/overview)
|
||||
|
||||
[](https://github.com/triggerdotdev/trigger.dev)
|
||||
[](https://github.com/triggerdotdev/trigger.dev/blob/main/LICENSE)
|
||||
[](https://www.npmjs.com/package/@trigger.dev/sdk)
|
||||
[](https://www.npmjs.com/package/@trigger.dev/sdk)
|
||||
|
||||
[](https://twitter.com/triggerdotdev)
|
||||
[](https://discord.gg/nkqV9xBYWy)
|
||||
[](https://deepwiki.com/triggerdotdev/trigger.dev)
|
||||
[](https://github.com/triggerdotdev/trigger.dev)
|
||||
|
||||
</div>
|
||||
|
||||
## About Trigger.dev
|
||||
|
||||
Trigger.dev is an open source platform and SDK which allows you to create long-running background jobs. Write normal async code, deploy, and never hit a timeout.
|
||||
Trigger.dev is the open-source platform for building AI workflows in TypeScript. Long-running tasks with retries, queues, observability, and elastic scaling.
|
||||
|
||||
### Key features:
|
||||
## The platform designed for building AI agents
|
||||
|
||||
- JavaScript and TypeScript SDK
|
||||
- No timeouts
|
||||
- Retries (with exponential backoff)
|
||||
- Queues and concurrency controls
|
||||
- Schedules and crons
|
||||
- Full Observability; logs, live trace views, advanced filtering
|
||||
- React hooks to interact with the Trigger API from your React app
|
||||
- Pipe LLM streams straight to your users through the Realtime API
|
||||
- Trigger tasks and display the run status and metadata anywhere in your app
|
||||
- Custom alerts, get notified by email, Slack or webhooks
|
||||
- No infrastructure to manage
|
||||
- Elastic (scaling)
|
||||
- Works with your existing tech stack
|
||||
Build [AI agents](https://trigger.dev/product/ai-agents) using all the frameworks, services and LLMs you're used to, deploy them to Trigger.dev and get durable, long-running tasks with retries, queues, observability, and elastic scaling out of the box.
|
||||
|
||||
## In your codebase
|
||||
- **Long-running without timeouts**: Execute your tasks with absolutely no timeouts, unlike AWS Lambda, Vercel, and other serverless platforms.
|
||||
|
||||
- **Durability, retries & queues**: Build rock solid agents and AI applications using our durable tasks, retries, queues and idempotency.
|
||||
|
||||
- **True runtime freedom**: Customize your deployed tasks with system packages – run browsers, Python scripts, FFmpeg and more.
|
||||
|
||||
- **Human-in-the-loop**: Programmatically pause your tasks until a human can approve, reject or give feedback.
|
||||
|
||||
- **Realtime apps & streaming**: Move your background jobs to the foreground by subscribing to runs or streaming AI responses to your app.
|
||||
|
||||
- **Observability & monitoring**: Each run has full tracing and logs. Configure error alerts to catch bugs fast.
|
||||
|
||||
## Key features:
|
||||
|
||||
- **[JavaScript and TypeScript SDK](https://trigger.dev/docs/tasks/overview)** - Build background tasks using familiar programming models
|
||||
- **[Long-running tasks](https://trigger.dev/docs/runs/max-duration)** - Handle resource-heavy tasks without timeouts
|
||||
- **[Durable cron schedules](https://trigger.dev/docs/tasks/scheduled#scheduled-tasks-cron)** - Create and attach recurring schedules of up to a year
|
||||
- **[Trigger.dev Realtime](https://trigger.dev/docs/realtime/overview)** - Trigger, subscribe to, and get real-time updates for runs, with LLM streaming support
|
||||
- **[Build extensions](https://trigger.dev/docs/config/extensions/overview#build-extensions)** - Hook directly into the build system and customize the build process. Run Python scripts, FFmpeg, browsers, and more.
|
||||
- **[React hooks](https://trigger.dev/docs/frontend/react-hooks#react-hooks)** - Interact with the Trigger.dev API on your frontend using our React hooks package
|
||||
- **[Batch triggering](https://trigger.dev/docs/triggering#tasks-batchtrigger)** - Use batchTrigger() to initiate multiple runs of a task with custom payloads and options
|
||||
- **[Structured inputs / outputs](https://trigger.dev/docs/tasks/schemaTask#schematask)** - Define precise data schemas for your tasks with runtime payload validation
|
||||
- **[Waits](https://trigger.dev/docs/wait)** - Add waits to your tasks to pause execution for a specified duration
|
||||
- **[Preview branches](https://trigger.dev/docs/deployment/preview-branches)** - Create isolated environments for testing and development. Integrates with Vercel and git workflows
|
||||
- **[Waitpoints](https://trigger.dev/docs/wait-for-token#wait-for-token)** - Add human-in-the-loop judgment at critical decision points without disrupting workflow
|
||||
- **[Concurrency & queues](https://trigger.dev/docs/queue-concurrency#concurrency-and-queues)** - Set concurrency rules to manage how multiple tasks execute
|
||||
- **[Multiple environments](https://trigger.dev/docs/how-it-works#dev-mode)** - Support for DEV, PREVIEW, STAGING, and PROD environments
|
||||
- **[No infrastructure to manage](https://trigger.dev/docs/how-it-works#trigger-dev-architecture)** - Auto-scaling infrastructure that eliminates timeouts and server management
|
||||
- **[Automatic retries](https://trigger.dev/docs/errors-retrying)** - If your task encounters an uncaught error, we automatically attempt to run it again
|
||||
- **[Checkpointing](https://trigger.dev/docs/how-it-works#the-checkpoint-resume-system)** - Tasks are inherently durable, thanks to our checkpointing feature
|
||||
- **[Versioning](https://trigger.dev/docs/versioning)** - Atomic versioning allows you to deploy new versions without affecting running tasks
|
||||
- **[Machines](https://trigger.dev/docs/machines)** - Configure the number of vCPUs and GBs of RAM you want the task to use
|
||||
- **[Observability & monitoring](https://trigger.dev/product/observability-and-monitoring)** - Monitor every aspect of your tasks' performance with comprehensive logging and visualization tools
|
||||
- **[Logging & tracing](https://trigger.dev/docs/logging)** - Comprehensive logging and tracing for all your tasks
|
||||
- **[Tags](https://trigger.dev/docs/tags#tags)** - Attach up to ten tags to each run, allowing you to filter via the dashboard, realtime, and the SDK
|
||||
- **[Run metadata](https://trigger.dev/docs/runs/metadata#run-metadata)** - Attach metadata to runs which updates as the run progresses and is available to use in your frontend for live updates
|
||||
- **[Bulk actions](https://trigger.dev/docs/bulk-actions)** - Perform actions on multiple runs simultaneously, including replaying and cancelling
|
||||
- **[Real-time alerts](https://trigger.dev/docs/troubleshooting-alerts#alerts)** - Choose your preferred notification method for run failures and deployments
|
||||
|
||||
## Write tasks in your codebase
|
||||
|
||||
Create tasks where they belong: in your codebase. Version control, localhost, test and review like you're already used to.
|
||||
|
||||
```ts
|
||||
import { task } from "@trigger.dev/sdk/v3";
|
||||
import { task } from "@trigger.dev/sdk";
|
||||
|
||||
//1. You need to export each task
|
||||
export const helloWorld = task({
|
||||
@@ -58,13 +90,13 @@ Use our SDK to write tasks in your codebase. There's no infrastructure to manage
|
||||
|
||||
## Environments
|
||||
|
||||
We support `Development`, `Staging`, and `Production` environments, allowing you to test your tasks before deploying them to production.
|
||||
We support `Development`, `Staging`, `Preview`, and `Production` environments, allowing you to test your tasks before deploying them to production.
|
||||
|
||||
## Full visibility of every job run
|
||||
|
||||
View every task in every run so you can tell exactly what happened. We provide a full trace view of every task run so you can see what happened at every step.
|
||||
|
||||

|
||||

|
||||
|
||||
# Getting started
|
||||
|
||||
@@ -73,14 +105,19 @@ The quickest way to get started is to create an account and project in our [web
|
||||
### Useful links:
|
||||
|
||||
- [Quick start](https://trigger.dev/docs/quick-start) - get up and running in minutes
|
||||
- [How it works](https://trigger.dev/docs/v3/how-it-works) - understand how Trigger.dev works under the hood
|
||||
- [How it works](https://trigger.dev/docs/how-it-works) - understand how Trigger.dev works under the hood
|
||||
- [Guides and examples](https://trigger.dev/docs/guides/introduction) - walk-through guides and code examples for popular frameworks and use cases
|
||||
|
||||
## Self-hosting
|
||||
|
||||
If you prefer to self-host Trigger.dev, you can follow our [self-hosting guide](https://trigger.dev/docs/v3/open-source-self-hosting#overview).
|
||||
If you prefer to self-host Trigger.dev, you can follow our [self-hosting guides](https://trigger.dev/docs/self-hosting/overview):
|
||||
|
||||
We also have a dedicated self-hosting channel in our [Discord server](https://trigger.dev/discord) for support.
|
||||
- [Docker self-hosting guide](https://trigger.dev/docs/self-hosting/docker) - use Docker Compose to spin up a Trigger.dev instance
|
||||
- [Kubernetes self-hosting guide](https://trigger.dev/docs/self-hosting/kubernetes) - use our official Helm chart to deploy Trigger.dev to your Kubernetes cluster
|
||||
|
||||
## Support and community
|
||||
|
||||
We have a large active community in our official [Discord server](https://trigger.dev/discord) for support, including a dedicated channel for self-hosting.
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
## Repo Overview
|
||||
|
||||
This is a pnpm 8.15.5 monorepo that uses turborepo @turbo.json. The following workspaces are relevant
|
||||
This is a pnpm 10.23.0 monorepo that uses turborepo @turbo.json. The following workspaces are relevant
|
||||
|
||||
## Apps
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ WORKDIR /app
|
||||
FROM node-22-alpine AS pruner
|
||||
|
||||
COPY --chown=node:node . .
|
||||
RUN npx -q turbo@1.10.9 prune --scope=supervisor --docker
|
||||
RUN npx -q turbo@2.5.4 prune --scope=supervisor --docker
|
||||
|
||||
FROM node-22-alpine AS base
|
||||
|
||||
@@ -16,7 +16,7 @@ COPY --from=pruner --chown=node:node /app/out/json/ .
|
||||
COPY --from=pruner --chown=node:node /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||
COPY --from=pruner --chown=node:node /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml
|
||||
|
||||
RUN corepack enable && corepack prepare --activate
|
||||
RUN corepack enable && corepack prepare pnpm@10.23.0 --activate
|
||||
|
||||
FROM base AS deps-fetcher
|
||||
RUN apk add --no-cache python3-dev py3-setuptools make g++ gcc linux-headers
|
||||
@@ -37,7 +37,7 @@ COPY --chown=node:node scripts/updateVersion.ts scripts/updateVersion.ts
|
||||
|
||||
RUN pnpm run generate && \
|
||||
pnpm run --filter supervisor... build&& \
|
||||
pnpm deploy --filter=supervisor --prod /prod/supervisor
|
||||
pnpm deploy --legacy --filter=supervisor --prod /prod/supervisor
|
||||
|
||||
FROM base AS runner
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-ecr": "^3.839.0",
|
||||
"@kubernetes/client-node": "^1.0.0",
|
||||
"@trigger.dev/core": "workspace:*",
|
||||
"dockerode": "^4.0.6",
|
||||
|
||||
@@ -35,8 +35,16 @@ const Env = z.object({
|
||||
TRIGGER_DEQUEUE_ENABLED: BoolEnv.default(true),
|
||||
TRIGGER_DEQUEUE_INTERVAL_MS: z.coerce.number().int().default(250),
|
||||
TRIGGER_DEQUEUE_IDLE_INTERVAL_MS: z.coerce.number().int().default(1000),
|
||||
TRIGGER_DEQUEUE_MAX_RUN_COUNT: z.coerce.number().int().default(10),
|
||||
TRIGGER_DEQUEUE_MAX_CONSUMER_COUNT: z.coerce.number().int().default(1),
|
||||
TRIGGER_DEQUEUE_MAX_RUN_COUNT: z.coerce.number().int().default(1),
|
||||
TRIGGER_DEQUEUE_MIN_CONSUMER_COUNT: z.coerce.number().int().default(1),
|
||||
TRIGGER_DEQUEUE_MAX_CONSUMER_COUNT: z.coerce.number().int().default(10),
|
||||
TRIGGER_DEQUEUE_SCALING_STRATEGY: z.enum(["none", "smooth", "aggressive"]).default("none"),
|
||||
TRIGGER_DEQUEUE_SCALING_UP_COOLDOWN_MS: z.coerce.number().int().default(5000), // 5 seconds
|
||||
TRIGGER_DEQUEUE_SCALING_DOWN_COOLDOWN_MS: z.coerce.number().int().default(30000), // 30 seconds
|
||||
TRIGGER_DEQUEUE_SCALING_TARGET_RATIO: z.coerce.number().default(1.0), // Target ratio of queue items to consumers (1.0 = 1 item per consumer)
|
||||
TRIGGER_DEQUEUE_SCALING_EWMA_ALPHA: z.coerce.number().min(0).max(1).default(0.3), // Smooths queue length measurements (0=historical, 1=current)
|
||||
TRIGGER_DEQUEUE_SCALING_BATCH_WINDOW_MS: z.coerce.number().int().positive().default(1000), // Batch window for metrics processing (ms)
|
||||
TRIGGER_DEQUEUE_SCALING_DAMPING_FACTOR: z.coerce.number().min(0).max(1).default(0.7), // Smooths consumer count changes after EWMA (0=no scaling, 1=immediate)
|
||||
|
||||
// Optional services
|
||||
TRIGGER_WARM_START_URL: z.string().optional(),
|
||||
@@ -49,7 +57,7 @@ const Env = z.object({
|
||||
RESOURCE_MONITOR_OVERRIDE_MEMORY_TOTAL_GB: z.coerce.number().optional(),
|
||||
|
||||
// Docker settings
|
||||
DOCKER_API_VERSION: z.string().default("v1.41"),
|
||||
DOCKER_API_VERSION: z.string().optional(),
|
||||
DOCKER_PLATFORM: z.string().optional(), // e.g. linux/amd64, linux/arm64
|
||||
DOCKER_STRIP_IMAGE_DIGEST: BoolEnv.default(true),
|
||||
DOCKER_REGISTRY_USERNAME: z.string().optional(),
|
||||
@@ -76,6 +84,13 @@ const Env = z.object({
|
||||
KUBERNETES_IMAGE_PULL_SECRETS: z.string().optional(), // csv
|
||||
KUBERNETES_EPHEMERAL_STORAGE_SIZE_LIMIT: z.string().default("10Gi"),
|
||||
KUBERNETES_EPHEMERAL_STORAGE_SIZE_REQUEST: z.string().default("2Gi"),
|
||||
KUBERNETES_STRIP_IMAGE_DIGEST: BoolEnv.default(false),
|
||||
KUBERNETES_CPU_REQUEST_MIN_CORES: z.coerce.number().min(0).default(0),
|
||||
KUBERNETES_CPU_REQUEST_RATIO: z.coerce.number().min(0).max(1).default(0.75), // Ratio of CPU limit, so 0.75 = 75% of CPU limit
|
||||
KUBERNETES_MEMORY_REQUEST_MIN_GB: z.coerce.number().min(0).default(0),
|
||||
KUBERNETES_MEMORY_REQUEST_RATIO: z.coerce.number().min(0).max(1).default(1), // Ratio of memory limit, so 1 = 100% of memory limit
|
||||
KUBERNETES_MEMORY_OVERHEAD_GB: z.coerce.number().min(0).optional(), // Optional memory overhead to add to the limit in GB
|
||||
KUBERNETES_SCHEDULER_NAME: z.string().optional(), // Custom scheduler name for pods
|
||||
|
||||
// Placement tags settings
|
||||
PLACEMENT_TAGS_ENABLED: BoolEnv.default(false),
|
||||
|
||||
@@ -128,7 +128,18 @@ class ManagedSupervisor {
|
||||
dequeueIdleIntervalMs: env.TRIGGER_DEQUEUE_IDLE_INTERVAL_MS,
|
||||
queueConsumerEnabled: env.TRIGGER_DEQUEUE_ENABLED,
|
||||
maxRunCount: env.TRIGGER_DEQUEUE_MAX_RUN_COUNT,
|
||||
maxConsumerCount: env.TRIGGER_DEQUEUE_MAX_CONSUMER_COUNT,
|
||||
metricsRegistry: register,
|
||||
scaling: {
|
||||
strategy: env.TRIGGER_DEQUEUE_SCALING_STRATEGY,
|
||||
minConsumerCount: env.TRIGGER_DEQUEUE_MIN_CONSUMER_COUNT,
|
||||
maxConsumerCount: env.TRIGGER_DEQUEUE_MAX_CONSUMER_COUNT,
|
||||
scaleUpCooldownMs: env.TRIGGER_DEQUEUE_SCALING_UP_COOLDOWN_MS,
|
||||
scaleDownCooldownMs: env.TRIGGER_DEQUEUE_SCALING_DOWN_COOLDOWN_MS,
|
||||
targetRatio: env.TRIGGER_DEQUEUE_SCALING_TARGET_RATIO,
|
||||
ewmaAlpha: env.TRIGGER_DEQUEUE_SCALING_EWMA_ALPHA,
|
||||
batchWindowMs: env.TRIGGER_DEQUEUE_SCALING_BATCH_WINDOW_MS,
|
||||
dampingFactor: env.TRIGGER_DEQUEUE_SCALING_DAMPING_FACTOR,
|
||||
},
|
||||
runNotificationsEnabled: env.TRIGGER_WORKLOAD_API_ENABLED,
|
||||
heartbeatIntervalSeconds: env.TRIGGER_WORKER_HEARTBEAT_INTERVAL_SECONDS,
|
||||
sendRunDebugLogs: env.SEND_RUN_DEBUG_LOGS,
|
||||
@@ -233,6 +244,12 @@ class ManagedSupervisor {
|
||||
}
|
||||
|
||||
try {
|
||||
if (!message.deployment.friendlyId) {
|
||||
// mostly a type guard, deployments always exists for deployed environments
|
||||
// a proper fix would be to use a discriminated union schema to differentiate between dequeued runs in dev and in deployed environments.
|
||||
throw new Error("Deployment is missing");
|
||||
}
|
||||
|
||||
await this.workloadManager.create({
|
||||
dequeuedAt: message.dequeuedAt,
|
||||
envId: message.environment.id,
|
||||
@@ -241,6 +258,8 @@ class ManagedSupervisor {
|
||||
machine: message.run.machine,
|
||||
orgId: message.organization.id,
|
||||
projectId: message.project.id,
|
||||
deploymentFriendlyId: message.deployment.friendlyId,
|
||||
deploymentVersion: message.backgroundWorker.version,
|
||||
runId: message.run.id,
|
||||
runFriendlyId: message.run.friendlyId,
|
||||
version: message.version,
|
||||
|
||||
@@ -25,6 +25,7 @@ export class FailedPodHandler {
|
||||
|
||||
private readonly informer: Informer<V1Pod>;
|
||||
private readonly reconnectIntervalMs: number;
|
||||
private reconnecting = false;
|
||||
|
||||
// Metrics
|
||||
private readonly register: Registry;
|
||||
@@ -250,21 +251,48 @@ export class FailedPodHandler {
|
||||
}
|
||||
|
||||
private makeOnError(informerName: string) {
|
||||
return () => this.onError(informerName);
|
||||
return (err?: unknown) => this.onError(informerName, err);
|
||||
}
|
||||
|
||||
private async onError(informerName: string) {
|
||||
private async onError(informerName: string, err?: unknown) {
|
||||
if (!this.isRunning) {
|
||||
this.logger.warn("onError: informer not running");
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.error("error event fired", { informerName });
|
||||
this.informerEventsTotal.inc({ namespace: this.namespace, verb: "error" });
|
||||
// Guard against multiple simultaneous reconnections
|
||||
if (this.reconnecting) {
|
||||
this.logger.debug("onError: reconnection already in progress, skipping", {
|
||||
informerName,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Reconnect on errors
|
||||
await setTimeout(this.reconnectIntervalMs);
|
||||
await this.informer.start();
|
||||
this.reconnecting = true;
|
||||
|
||||
try {
|
||||
const error = err instanceof Error ? err : undefined;
|
||||
this.logger.error("error event fired", {
|
||||
informerName,
|
||||
error: error?.message,
|
||||
errorType: error?.name,
|
||||
});
|
||||
this.informerEventsTotal.inc({ namespace: this.namespace, verb: "error" });
|
||||
|
||||
// Reconnect on errors
|
||||
await setTimeout(this.reconnectIntervalMs);
|
||||
await this.informer.start();
|
||||
} catch (handlerError) {
|
||||
const error = handlerError instanceof Error ? handlerError : undefined;
|
||||
this.logger.error("onError: reconnection attempt failed", {
|
||||
informerName,
|
||||
error: error?.message,
|
||||
errorType: error?.name,
|
||||
errorStack: error?.stack,
|
||||
});
|
||||
} finally {
|
||||
this.reconnecting = false;
|
||||
}
|
||||
}
|
||||
|
||||
private makeOnConnect(informerName: string) {
|
||||
|
||||
@@ -8,14 +8,16 @@ import { env } from "../env.js";
|
||||
import { getDockerHostDomain, getRunnerId, normalizeDockerHostUrl } from "../util.js";
|
||||
import Docker from "dockerode";
|
||||
import { tryCatch } from "@trigger.dev/core";
|
||||
import { ECRAuthService } from "./ecrAuth.js";
|
||||
|
||||
export class DockerWorkloadManager implements WorkloadManager {
|
||||
private readonly logger = new SimpleStructuredLogger("docker-workload-manager");
|
||||
private readonly docker: Docker;
|
||||
|
||||
private readonly runnerNetworks: string[];
|
||||
private readonly auth?: Docker.AuthConfig;
|
||||
private readonly staticAuth?: Docker.AuthConfig;
|
||||
private readonly platformOverride?: string;
|
||||
private readonly ecrAuthService?: ECRAuthService;
|
||||
|
||||
constructor(private opts: WorkloadManagerOptions) {
|
||||
this.docker = new Docker({
|
||||
@@ -44,13 +46,18 @@ export class DockerWorkloadManager implements WorkloadManager {
|
||||
url: env.DOCKER_REGISTRY_URL,
|
||||
});
|
||||
|
||||
this.auth = {
|
||||
this.staticAuth = {
|
||||
username: env.DOCKER_REGISTRY_USERNAME,
|
||||
password: env.DOCKER_REGISTRY_PASSWORD,
|
||||
serveraddress: env.DOCKER_REGISTRY_URL,
|
||||
};
|
||||
} else if (ECRAuthService.hasAWSCredentials()) {
|
||||
this.logger.info("🐋 AWS credentials found, initializing ECR auth service");
|
||||
this.ecrAuthService = new ECRAuthService();
|
||||
} else {
|
||||
this.logger.warn("🐋 No Docker registry credentials provided, skipping auth");
|
||||
this.logger.warn(
|
||||
"🐋 No Docker registry credentials or AWS credentials provided, skipping auth"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +72,8 @@ export class DockerWorkloadManager implements WorkloadManager {
|
||||
`TRIGGER_DEQUEUED_AT_MS=${opts.dequeuedAt.getTime()}`,
|
||||
`TRIGGER_POD_SCHEDULED_AT_MS=${Date.now()}`,
|
||||
`TRIGGER_ENV_ID=${opts.envId}`,
|
||||
`TRIGGER_DEPLOYMENT_ID=${opts.deploymentFriendlyId}`,
|
||||
`TRIGGER_DEPLOYMENT_VERSION=${opts.deploymentVersion}`,
|
||||
`TRIGGER_RUN_ID=${opts.runFriendlyId}`,
|
||||
`TRIGGER_SNAPSHOT_ID=${opts.snapshotFriendlyId}`,
|
||||
`TRIGGER_SUPERVISOR_API_PROTOCOL=${this.opts.workloadApiProtocol}`,
|
||||
@@ -160,9 +169,12 @@ export class DockerWorkloadManager implements WorkloadManager {
|
||||
imageArchitecture: inspectResult?.Architecture,
|
||||
});
|
||||
|
||||
// Get auth config (static or ECR)
|
||||
const authConfig = await this.getAuthConfig();
|
||||
|
||||
// Ensure the image is present
|
||||
const [createImageError, imageResponseReader] = await tryCatch(
|
||||
this.docker.createImage(this.auth, {
|
||||
this.docker.createImage(authConfig, {
|
||||
fromImage: imageRef,
|
||||
...(this.platformOverride ? { platform: this.platformOverride } : {}),
|
||||
})
|
||||
@@ -216,6 +228,26 @@ export class DockerWorkloadManager implements WorkloadManager {
|
||||
logger.debug("create succeeded", { startResult, containerId: container.id });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get authentication config for Docker operations
|
||||
* Uses static credentials if available, otherwise attempts ECR auth
|
||||
*/
|
||||
private async getAuthConfig(): Promise<Docker.AuthConfig | undefined> {
|
||||
// Use static credentials if available
|
||||
if (this.staticAuth) {
|
||||
return this.staticAuth;
|
||||
}
|
||||
|
||||
// Use ECR auth if service is available
|
||||
if (this.ecrAuthService) {
|
||||
const ecrAuth = await this.ecrAuthService.getAuthConfig();
|
||||
return ecrAuth || undefined;
|
||||
}
|
||||
|
||||
// No auth available
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async attachContainerToNetworks({
|
||||
containerId,
|
||||
networkNames,
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { ECRClient, GetAuthorizationTokenCommand } from "@aws-sdk/client-ecr";
|
||||
import { SimpleStructuredLogger } from "@trigger.dev/core/v3/utils/structuredLogger";
|
||||
import { tryCatch } from "@trigger.dev/core";
|
||||
import Docker from "dockerode";
|
||||
|
||||
interface ECRTokenCache {
|
||||
token: string;
|
||||
username: string;
|
||||
serverAddress: string;
|
||||
expiresAt: Date;
|
||||
}
|
||||
|
||||
export class ECRAuthService {
|
||||
private readonly logger = new SimpleStructuredLogger("ecr-auth-service");
|
||||
private readonly ecrClient: ECRClient;
|
||||
private tokenCache: ECRTokenCache | null = null;
|
||||
|
||||
constructor() {
|
||||
this.ecrClient = new ECRClient();
|
||||
|
||||
this.logger.info("🔐 ECR Auth Service initialized", {
|
||||
region: this.ecrClient.config.region,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we have AWS credentials configured
|
||||
*/
|
||||
static hasAWSCredentials(): boolean {
|
||||
if (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
process.env.AWS_PROFILE ||
|
||||
process.env.AWS_ROLE_ARN ||
|
||||
process.env.AWS_WEB_IDENTITY_TOKEN_FILE
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current token is still valid with a 10-minute buffer
|
||||
*/
|
||||
private isTokenValid(): boolean {
|
||||
if (!this.tokenCache) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const bufferMs = 10 * 60 * 1000; // 10 minute buffer before expiration
|
||||
return now < new Date(this.tokenCache.expiresAt.getTime() - bufferMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a fresh ECR authorization token from AWS
|
||||
*/
|
||||
private async fetchNewToken(): Promise<ECRTokenCache | null> {
|
||||
const [error, response] = await tryCatch(
|
||||
this.ecrClient.send(new GetAuthorizationTokenCommand({}))
|
||||
);
|
||||
|
||||
if (error) {
|
||||
this.logger.error("Failed to get ECR authorization token", { error });
|
||||
return null;
|
||||
}
|
||||
|
||||
const authData = response.authorizationData?.[0];
|
||||
if (!authData?.authorizationToken || !authData.proxyEndpoint) {
|
||||
this.logger.error("Invalid ECR authorization response", { authData });
|
||||
return null;
|
||||
}
|
||||
|
||||
// Decode the base64 token to get username:password
|
||||
const decoded = Buffer.from(authData.authorizationToken, "base64").toString("utf-8");
|
||||
const [username, password] = decoded.split(":", 2);
|
||||
|
||||
if (!username || !password) {
|
||||
this.logger.error("Failed to parse ECR authorization token");
|
||||
return null;
|
||||
}
|
||||
|
||||
const expiresAt = authData.expiresAt || new Date(Date.now() + 12 * 60 * 60 * 1000); // Default 12 hours
|
||||
|
||||
const tokenCache: ECRTokenCache = {
|
||||
token: password,
|
||||
username,
|
||||
serverAddress: authData.proxyEndpoint,
|
||||
expiresAt,
|
||||
};
|
||||
|
||||
this.logger.info("🔐 Successfully fetched ECR token", {
|
||||
username,
|
||||
serverAddress: authData.proxyEndpoint,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
});
|
||||
|
||||
return tokenCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ECR auth config for Docker operations
|
||||
* Returns cached token if valid, otherwise fetches a new one
|
||||
*/
|
||||
async getAuthConfig(): Promise<Docker.AuthConfig | null> {
|
||||
// Check if cached token is still valid
|
||||
if (this.isTokenValid()) {
|
||||
this.logger.debug("Using cached ECR token");
|
||||
return {
|
||||
username: this.tokenCache!.username,
|
||||
password: this.tokenCache!.token,
|
||||
serveraddress: this.tokenCache!.serverAddress,
|
||||
};
|
||||
}
|
||||
|
||||
// Fetch new token
|
||||
this.logger.info("Fetching new ECR authorization token");
|
||||
const newToken = await this.fetchNewToken();
|
||||
|
||||
if (!newToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Cache the new token
|
||||
this.tokenCache = newToken;
|
||||
|
||||
return {
|
||||
username: newToken.username,
|
||||
password: newToken.token,
|
||||
serveraddress: newToken.serverAddress,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the cached token (useful for testing or forcing refresh)
|
||||
*/
|
||||
clearCache(): void {
|
||||
this.tokenCache = null;
|
||||
this.logger.debug("ECR token cache cleared");
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,13 @@ export class KubernetesWorkloadManager implements WorkloadManager {
|
||||
private namespace = env.KUBERNETES_NAMESPACE;
|
||||
private placementTagProcessor: PlacementTagProcessor;
|
||||
|
||||
// Resource settings
|
||||
private readonly cpuRequestMinCores = env.KUBERNETES_CPU_REQUEST_MIN_CORES;
|
||||
private readonly cpuRequestRatio = env.KUBERNETES_CPU_REQUEST_RATIO;
|
||||
private readonly memoryRequestMinGb = env.KUBERNETES_MEMORY_REQUEST_MIN_GB;
|
||||
private readonly memoryRequestRatio = env.KUBERNETES_MEMORY_REQUEST_RATIO;
|
||||
private readonly memoryOverheadGb = env.KUBERNETES_MEMORY_OVERHEAD_GB;
|
||||
|
||||
constructor(private opts: WorkloadManagerOptions) {
|
||||
this.k8s = createK8sApi();
|
||||
this.placementTagProcessor = new PlacementTagProcessor({
|
||||
@@ -49,6 +56,24 @@ export class KubernetesWorkloadManager implements WorkloadManager {
|
||||
};
|
||||
}
|
||||
|
||||
private stripImageDigest(imageRef: string): string {
|
||||
if (!env.KUBERNETES_STRIP_IMAGE_DIGEST) {
|
||||
return imageRef;
|
||||
}
|
||||
|
||||
const atIndex = imageRef.lastIndexOf("@");
|
||||
|
||||
if (atIndex === -1) {
|
||||
return imageRef;
|
||||
}
|
||||
|
||||
return imageRef.substring(0, atIndex);
|
||||
}
|
||||
|
||||
private clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
async create(opts: WorkloadManagerCreateOptions) {
|
||||
this.logger.log("[KubernetesWorkloadManager] Creating container", { opts });
|
||||
|
||||
@@ -74,7 +99,7 @@ export class KubernetesWorkloadManager implements WorkloadManager {
|
||||
containers: [
|
||||
{
|
||||
name: "run-controller",
|
||||
image: opts.image,
|
||||
image: this.stripImageDigest(opts.image),
|
||||
ports: [
|
||||
{
|
||||
containerPort: 8000,
|
||||
@@ -98,6 +123,14 @@ export class KubernetesWorkloadManager implements WorkloadManager {
|
||||
name: "TRIGGER_ENV_ID",
|
||||
value: opts.envId,
|
||||
},
|
||||
{
|
||||
name: "TRIGGER_DEPLOYMENT_ID",
|
||||
value: opts.deploymentFriendlyId,
|
||||
},
|
||||
{
|
||||
name: "TRIGGER_DEPLOYMENT_VERSION",
|
||||
value: opts.deploymentVersion,
|
||||
},
|
||||
{
|
||||
name: "TRIGGER_SNAPSHOT_ID",
|
||||
value: opts.snapshotFriendlyId,
|
||||
@@ -249,6 +282,11 @@ export class KubernetesWorkloadManager implements WorkloadManager {
|
||||
restartPolicy: "Never",
|
||||
automountServiceAccountToken: false,
|
||||
imagePullSecrets: this.getImagePullSecrets(),
|
||||
...(env.KUBERNETES_SCHEDULER_NAME
|
||||
? {
|
||||
schedulerName: env.KUBERNETES_SCHEDULER_NAME,
|
||||
}
|
||||
: {}),
|
||||
...(env.KUBERNETES_WORKER_NODETYPE_LABEL
|
||||
? {
|
||||
nodeSelector: {
|
||||
@@ -277,20 +315,32 @@ export class KubernetesWorkloadManager implements WorkloadManager {
|
||||
envtype: this.#envTypeToLabelValue(opts.envType),
|
||||
org: opts.orgId,
|
||||
project: opts.projectId,
|
||||
machine: opts.machine.name,
|
||||
};
|
||||
}
|
||||
|
||||
#getResourceRequestsForMachine(preset: MachinePreset): ResourceQuantities {
|
||||
const cpuRequest = preset.cpu * this.cpuRequestRatio;
|
||||
const memoryRequest = preset.memory * this.memoryRequestRatio;
|
||||
|
||||
// Clamp between min and max
|
||||
const clampedCpu = this.clamp(cpuRequest, this.cpuRequestMinCores, preset.cpu);
|
||||
const clampedMemory = this.clamp(memoryRequest, this.memoryRequestMinGb, preset.memory);
|
||||
|
||||
return {
|
||||
cpu: `${preset.cpu * 0.75}`,
|
||||
memory: `${preset.memory}G`,
|
||||
cpu: `${clampedCpu}`,
|
||||
memory: `${clampedMemory}G`,
|
||||
};
|
||||
}
|
||||
|
||||
#getResourceLimitsForMachine(preset: MachinePreset): ResourceQuantities {
|
||||
const memoryLimit = this.memoryOverheadGb
|
||||
? preset.memory + this.memoryOverheadGb
|
||||
: preset.memory;
|
||||
|
||||
return {
|
||||
cpu: `${preset.cpu}`,
|
||||
memory: `${preset.memory}G`,
|
||||
memory: `${memoryLimit}G`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,8 @@ export interface WorkloadManagerCreateOptions {
|
||||
envType: EnvironmentType;
|
||||
orgId: string;
|
||||
projectId: string;
|
||||
deploymentFriendlyId: string;
|
||||
deploymentVersion: string;
|
||||
runId: string;
|
||||
runFriendlyId: string;
|
||||
snapshotId: string;
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
type WorkloadRunAttemptCompleteResponseBody,
|
||||
WorkloadRunAttemptStartRequestBody,
|
||||
type WorkloadRunAttemptStartResponseBody,
|
||||
type WorkloadRunLatestSnapshotResponseBody,
|
||||
WorkloadRunSnapshotsSinceResponseBody,
|
||||
type WorkloadServerToClientEvents,
|
||||
type WorkloadSuspendRunResponseBody,
|
||||
@@ -126,7 +125,7 @@ export class WorkloadServer extends EventEmitter<WorkloadServerEvents> {
|
||||
}
|
||||
|
||||
private createHttpServer({ host, port }: { host: string; port: number }) {
|
||||
return new HttpServer({
|
||||
const httpServer = new HttpServer({
|
||||
port,
|
||||
host,
|
||||
metrics: {
|
||||
@@ -322,28 +321,6 @@ export class WorkloadServer extends EventEmitter<WorkloadServerEvents> {
|
||||
},
|
||||
}
|
||||
)
|
||||
.route("/api/v1/workload-actions/runs/:runFriendlyId/snapshots/latest", "GET", {
|
||||
paramsSchema: WorkloadActionParams.pick({ runFriendlyId: true }),
|
||||
handler: async ({ req, reply, params }) => {
|
||||
const latestSnapshotResponse = await this.workerClient.getLatestSnapshot(
|
||||
params.runFriendlyId,
|
||||
this.runnerIdFromRequest(req)
|
||||
);
|
||||
|
||||
if (!latestSnapshotResponse.success) {
|
||||
this.logger.error("Failed to get latest snapshot", {
|
||||
runId: params.runFriendlyId,
|
||||
error: latestSnapshotResponse.error,
|
||||
});
|
||||
reply.empty(500);
|
||||
return;
|
||||
}
|
||||
|
||||
reply.json({
|
||||
execution: latestSnapshotResponse.data.execution,
|
||||
} satisfies WorkloadRunLatestSnapshotResponseBody);
|
||||
},
|
||||
})
|
||||
.route(
|
||||
"/api/v1/workload-actions/runs/:runFriendlyId/snapshots/since/:snapshotFriendlyId",
|
||||
"GET",
|
||||
@@ -369,23 +346,6 @@ export class WorkloadServer extends EventEmitter<WorkloadServerEvents> {
|
||||
},
|
||||
}
|
||||
)
|
||||
.route("/api/v1/workload-actions/runs/:runFriendlyId/logs/debug", "POST", {
|
||||
paramsSchema: WorkloadActionParams.pick({ runFriendlyId: true }),
|
||||
bodySchema: WorkloadDebugLogRequestBody,
|
||||
handler: async ({ req, reply, params, body }) => {
|
||||
reply.empty(204);
|
||||
|
||||
if (!env.SEND_RUN_DEBUG_LOGS) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.workerClient.sendDebugLog(
|
||||
params.runFriendlyId,
|
||||
body,
|
||||
this.runnerIdFromRequest(req)
|
||||
);
|
||||
},
|
||||
})
|
||||
.route("/api/v1/workload-actions/deployments/:deploymentId/dequeue", "GET", {
|
||||
paramsSchema: z.object({
|
||||
deploymentId: z.string(),
|
||||
@@ -410,6 +370,31 @@ export class WorkloadServer extends EventEmitter<WorkloadServerEvents> {
|
||||
reply.json(dequeueResponse.data satisfies WorkloadDequeueFromVersionResponseBody);
|
||||
},
|
||||
});
|
||||
|
||||
if (env.SEND_RUN_DEBUG_LOGS) {
|
||||
httpServer.route("/api/v1/workload-actions/runs/:runFriendlyId/logs/debug", "POST", {
|
||||
paramsSchema: WorkloadActionParams.pick({ runFriendlyId: true }),
|
||||
bodySchema: WorkloadDebugLogRequestBody,
|
||||
handler: async ({ req, reply, params, body }) => {
|
||||
reply.empty(204);
|
||||
|
||||
await this.workerClient.sendDebugLog(
|
||||
params.runFriendlyId,
|
||||
body,
|
||||
this.runnerIdFromRequest(req)
|
||||
);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Lightweight mock route without schemas
|
||||
httpServer.route("/api/v1/workload-actions/runs/:runFriendlyId/logs/debug", "POST", {
|
||||
handler: async ({ reply }) => {
|
||||
reply.empty(204);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return httpServer;
|
||||
}
|
||||
|
||||
private createWebsocketServer() {
|
||||
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.env
|
||||
@@ -9,7 +9,8 @@ node_modules
|
||||
|
||||
/app/styles/tailwind.css
|
||||
|
||||
|
||||
# Ensure the .env symlink is not removed by accident
|
||||
!.env
|
||||
|
||||
# Storybook build outputs
|
||||
build-storybook.log
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
export function ConcurrencyIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="3.75" cy="3.75" r="2.25" fill="currentColor" />
|
||||
<circle cx="9" cy="3.75" r="2.25" fill="currentColor" />
|
||||
<circle cx="14.25" cy="3.75" r="2.25" fill="currentColor" />
|
||||
<circle cx="3.75" cy="9" r="2.25" fill="currentColor" />
|
||||
<circle cx="9" cy="9" r="2.25" fill="currentColor" />
|
||||
<circle cx="9" cy="14.25" r="1.75" stroke="currentColor" />
|
||||
<circle cx="14.25" cy="9" r="2.25" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
export function ListBulletIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M9 5H20"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M9 12H20"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M9 19H20"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle cx="4" cy="5" r="1" fill="currentColor" />
|
||||
<circle cx="4" cy="12" r="1" fill="currentColor" />
|
||||
<circle cx="4" cy="19" r="1" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
export function MoveToBottomIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M12 15L12 3"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M3 21L21 21"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M7.5 12.5L12 17L16.5 12.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
export function MoveToTopIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clipPath="url(#clip0_17186_103975)">
|
||||
<path
|
||||
d="M12 21L12 9"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M3 3L21 3"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M16.5 11.5L12 7L7.5 11.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_17186_103975">
|
||||
<rect width="24" height="24" fill="currentColor" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
export function MoveUpIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clipPath="url(#clip0_17177_110851)">
|
||||
<path
|
||||
d="M12 21L12 13"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M3 3L21 3"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M3 7L21 7"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M16.5 15.5L12 11L7.5 15.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_17177_110851">
|
||||
<rect width="24" height="24" fill="currentColor" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export function SnakedArrowIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M5 5H16C17.6569 5 19 6.34315 19 8L19 8.5C19 10.1569 17.6569 11.5 16 11.5H8C6.34314 11.5 5 12.8431 5 14.5L5 15C4.99999 16.6569 6.34314 18 8 18H18.634"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M16 21L19 18L16 15"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export function StreamsIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3 19C3 19 5.01155 17 8 17C10.9885 17 13 18.9973 16 18.9973C19 18.9973 21 17 21 17" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||
<path d="M3 13.0001C3 13.0001 5.01155 11 8 11C10.9885 11 13 13 16 13C19 13 21 11.0001 21 11.0001" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||
<path d="M3 7C3 7 5.01155 5 8 5C10.9885 5 13 6.9973 16 6.9973C19 6.9973 21 5 21 5" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
export function GoogleLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M19.9075 21.0983C22.7427 18.4521 24.0028 14.0417 23.2468 9.82031H11.9688V14.4827H18.3953C18.1433 15.9949 17.2612 17.255 16.0011 18.0741L19.9075 21.0983Z"
|
||||
fill="#4285F4"
|
||||
/>
|
||||
<path
|
||||
d="M1.25781 17.3802C2.08665 19.013 3.27532 20.4362 4.73421 21.5428C6.1931 22.6493 7.88415 23.4102 9.67988 23.7681C11.4756 24.1261 13.3292 24.0717 15.1008 23.6091C16.8725 23.1465 18.516 22.2877 19.9075 21.0976L16.0011 18.0733C12.6618 20.2785 7.11734 19.4594 5.22717 14.293L1.25781 17.3802Z"
|
||||
fill="#34A853"
|
||||
/>
|
||||
<path
|
||||
d="M5.22701 14.2922C4.72297 12.717 4.72297 11.2679 5.22701 9.69275L1.25765 6.60547C-0.191479 9.50373 -0.632519 13.5991 1.25765 17.3794L5.22701 14.2922Z"
|
||||
fill="#FBBC02"
|
||||
/>
|
||||
<path
|
||||
d="M5.22717 9.69209C6.6133 5.34469 12.5358 2.82446 16.5052 6.5418L19.9705 3.13949C15.0561 -1.58594 5.47919 -1.39692 1.25781 6.60481L5.22717 9.69209Z"
|
||||
fill="#EA4335"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -52,6 +52,13 @@ import {
|
||||
} from "./SetupCommands";
|
||||
import { StepContentContainer } from "./StepContentContainer";
|
||||
import { V4Badge } from "./V4Badge";
|
||||
import {
|
||||
ClientTabs,
|
||||
ClientTabsContent,
|
||||
ClientTabsList,
|
||||
ClientTabsTrigger,
|
||||
} from "./primitives/ClientTabs";
|
||||
import { GitHubSettingsPanel } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github";
|
||||
|
||||
export function HasNoTasksDev() {
|
||||
return (
|
||||
@@ -93,62 +100,7 @@ export function HasNoTasksDev() {
|
||||
}
|
||||
|
||||
export function HasNoTasksDeployed({ environment }: { environment: MinimumEnvironment }) {
|
||||
return (
|
||||
<PackageManagerProvider>
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between border-b">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<EnvironmentIcon environment={environment} className="-ml-1 size-8" />
|
||||
<Header1>Deploy your tasks to {environmentFullTitle(environment)}</Header1>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<LinkButton
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
to={docsPath("deployment/overview")}
|
||||
/>
|
||||
}
|
||||
content="Deploy docs"
|
||||
/>
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<LinkButton
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={QuestionMarkCircleIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
to={docsPath("troubleshooting#deployment")}
|
||||
/>
|
||||
}
|
||||
content="Troubleshooting docs"
|
||||
/>
|
||||
<AskAI />
|
||||
</div>
|
||||
</div>
|
||||
<StepNumber stepNumber="1a" title="Run the CLI 'deploy' command" />
|
||||
<StepContentContainer>
|
||||
<Paragraph spacing>
|
||||
This will deploy your tasks to the {environmentFullTitle(environment)} environment. Read
|
||||
the <TextLink to={docsPath("deployment/overview")}>full guide</TextLink>.
|
||||
</Paragraph>
|
||||
<TriggerDeployStep environment={environment} />
|
||||
</StepContentContainer>
|
||||
<StepNumber stepNumber="1b" title="Or deploy using GitHub Actions" />
|
||||
<StepContentContainer>
|
||||
<Paragraph spacing>
|
||||
Read the <TextLink to={docsPath("github-actions")}>GitHub Actions guide</TextLink> to
|
||||
get started.
|
||||
</Paragraph>
|
||||
</StepContentContainer>
|
||||
<StepNumber stepNumber="2" title="Waiting for tasks to deploy" displaySpinner />
|
||||
<StepContentContainer>
|
||||
<Paragraph>This page will automatically refresh when your tasks are deployed.</Paragraph>
|
||||
</StepContentContainer>
|
||||
</div>
|
||||
</PackageManagerProvider>
|
||||
);
|
||||
return <DeploymentOnboardingSteps />;
|
||||
}
|
||||
|
||||
export function SchedulesNoPossibleTaskPanel() {
|
||||
@@ -266,45 +218,7 @@ export function TestHasNoTasks() {
|
||||
}
|
||||
|
||||
export function DeploymentsNone() {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
return (
|
||||
<InfoPanel
|
||||
icon={ServerStackIcon}
|
||||
iconClassName="text-deployments"
|
||||
title="Deploy for the first time"
|
||||
panelClassName="max-w-full"
|
||||
>
|
||||
<Paragraph spacing variant="small">
|
||||
There are several ways to deploy your tasks. You can use the CLI or a Continuous Integration
|
||||
service like GitHub Actions. Make sure you{" "}
|
||||
<TextLink href={v3EnvironmentVariablesPath(organization, project, environment)}>
|
||||
set your environment variables
|
||||
</TextLink>{" "}
|
||||
first.
|
||||
</Paragraph>
|
||||
<div className="flex gap-3">
|
||||
<LinkButton
|
||||
to={docsPath("v3/cli-deploy")}
|
||||
variant="docs/medium"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
className="inline-flex"
|
||||
>
|
||||
Deploy with the CLI
|
||||
</LinkButton>
|
||||
<LinkButton
|
||||
to={docsPath("v3/github-actions")}
|
||||
variant="docs/medium"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
className="inline-flex"
|
||||
>
|
||||
Deploy with GitHub actions
|
||||
</LinkButton>
|
||||
</div>
|
||||
</InfoPanel>
|
||||
);
|
||||
return <DeploymentOnboardingSteps />;
|
||||
}
|
||||
|
||||
export function DeploymentsNoneDev() {
|
||||
@@ -313,46 +227,52 @@ export function DeploymentsNoneDev() {
|
||||
const environment = useEnvironment();
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<InfoPanel
|
||||
icon={ServerStackIcon}
|
||||
iconClassName="text-deployments"
|
||||
title="Deploying tasks"
|
||||
panelClassName="max-w-full"
|
||||
>
|
||||
<Paragraph spacing variant="small">
|
||||
<>
|
||||
<div className="mb-6 flex items-center justify-between border-b">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<EnvironmentIcon environment={environment} className="-ml-1 size-8" />
|
||||
<Header1>Deploy your tasks</Header1>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<LinkButton
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
to={docsPath("deployment/overview")}
|
||||
/>
|
||||
}
|
||||
content="Deploy docs"
|
||||
/>
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<LinkButton
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={QuestionMarkCircleIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
to={docsPath("troubleshooting#deployment")}
|
||||
/>
|
||||
}
|
||||
content="Troubleshooting docs"
|
||||
/>
|
||||
<AskAI />
|
||||
</div>
|
||||
</div>
|
||||
<StepNumber stepNumber="→" title="Switch to a deployed environment" />
|
||||
<StepContentContainer className="mb-4 flex flex-col gap-4">
|
||||
<Paragraph>
|
||||
This is the Development environment. When you're ready to deploy your tasks, switch to a
|
||||
different environment.
|
||||
</Paragraph>
|
||||
<Paragraph spacing variant="small">
|
||||
There are several ways to deploy your tasks. You can use the CLI or a Continuous
|
||||
Integration service like GitHub Actions. Make sure you{" "}
|
||||
<TextLink href={v3EnvironmentVariablesPath(organization, project, environment)}>
|
||||
set your environment variables
|
||||
</TextLink>{" "}
|
||||
first.
|
||||
</Paragraph>
|
||||
<div className="flex gap-3">
|
||||
<LinkButton
|
||||
to={docsPath("v3/cli-deploy")}
|
||||
variant="docs/medium"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
className="inline-flex"
|
||||
>
|
||||
Deploy with the CLI
|
||||
</LinkButton>
|
||||
<LinkButton
|
||||
to={docsPath("v3/github-actions")}
|
||||
variant="docs/medium"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
className="inline-flex"
|
||||
>
|
||||
Deploy with GitHub actions
|
||||
</LinkButton>
|
||||
</div>
|
||||
</InfoPanel>
|
||||
<SwitcherPanel />
|
||||
</div>
|
||||
<EnvironmentSelector
|
||||
organization={organization}
|
||||
project={project}
|
||||
environment={environment}
|
||||
className="w-fit border border-charcoal-600 bg-secondary hover:border-charcoal-550 hover:bg-charcoal-600"
|
||||
/>
|
||||
</StepContentContainer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -670,3 +590,99 @@ export function BulkActionsNone() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeploymentOnboardingSteps() {
|
||||
const environment = useEnvironment();
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
|
||||
return (
|
||||
<PackageManagerProvider>
|
||||
<div className="mb-2 flex items-center justify-between border-b">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<EnvironmentIcon environment={environment} className="-ml-1 size-8" />
|
||||
<Header1>Deploy your tasks to {environmentFullTitle(environment)}</Header1>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<LinkButton
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
to={docsPath("deployment/overview")}
|
||||
/>
|
||||
}
|
||||
content="Deploy docs"
|
||||
/>
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<LinkButton
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={QuestionMarkCircleIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
to={docsPath("troubleshooting#deployment")}
|
||||
/>
|
||||
}
|
||||
content="Troubleshooting docs"
|
||||
/>
|
||||
<AskAI />
|
||||
</div>
|
||||
</div>
|
||||
<ClientTabs defaultValue="github">
|
||||
<ClientTabsList variant="segmented" className="mb-6">
|
||||
<ClientTabsTrigger value={"github"} variant="segmented" layoutId="deploy-tabs">
|
||||
GitHub
|
||||
</ClientTabsTrigger>
|
||||
<ClientTabsTrigger value={"cli"} variant="segmented" layoutId="deploy-tabs">
|
||||
Manual
|
||||
</ClientTabsTrigger>
|
||||
<ClientTabsTrigger value={"github-actions"} variant="segmented" layoutId="deploy-tabs">
|
||||
GitHub Actions
|
||||
</ClientTabsTrigger>
|
||||
</ClientTabsList>
|
||||
<ClientTabsContent value={"github"}>
|
||||
<StepNumber stepNumber="1" title="Connect your GitHub repository" />
|
||||
<StepContentContainer>
|
||||
<Paragraph spacing>
|
||||
Deploy automatically with every push. Read the{" "}
|
||||
<TextLink to={docsPath("github-integration")}>full guide</TextLink>.
|
||||
</Paragraph>
|
||||
<div className="w-fit">
|
||||
<GitHubSettingsPanel
|
||||
organizationSlug={organization.slug}
|
||||
projectSlug={project.slug}
|
||||
environmentSlug={environment.slug}
|
||||
billingPath={v3BillingPath({ slug: organization.slug })}
|
||||
/>
|
||||
</div>
|
||||
</StepContentContainer>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent value={"cli"}>
|
||||
<StepNumber stepNumber="1" title="Run the CLI 'deploy' command" />
|
||||
<StepContentContainer>
|
||||
<Paragraph spacing>
|
||||
This will deploy your tasks to the {environmentFullTitle(environment)} environment.
|
||||
Read the <TextLink to={docsPath("deployment/overview")}>full guide</TextLink>.
|
||||
</Paragraph>
|
||||
<TriggerDeployStep environment={environment} />
|
||||
</StepContentContainer>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent value={"github-actions"}>
|
||||
<StepNumber stepNumber="1" title="Deploy using GitHub Actions" />
|
||||
<StepContentContainer>
|
||||
<Paragraph spacing>
|
||||
Read the <TextLink to={docsPath("github-actions")}>GitHub Actions guide</TextLink> to
|
||||
get started.
|
||||
</Paragraph>
|
||||
</StepContentContainer>
|
||||
</ClientTabsContent>
|
||||
</ClientTabs>
|
||||
|
||||
<StepNumber stepNumber="2" title="Waiting for tasks to deploy" displaySpinner />
|
||||
<StepContentContainer>
|
||||
<Paragraph>This page will automatically refresh when your tasks are deployed.</Paragraph>
|
||||
</StepContentContainer>
|
||||
</PackageManagerProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,10 +11,11 @@ import { appliedSummary, dateFromString, timeFilterRenderValues } from "./runs/v
|
||||
import { formatNumber } from "~/utils/numberFormatter";
|
||||
import { SpinnerWhite } from "./primitives/Spinner";
|
||||
import { ArrowPathIcon, CheckIcon, XCircleIcon } from "@heroicons/react/20/solid";
|
||||
import { XCircleIcon as XCircleIconOutline } from "@heroicons/react/24/outline";
|
||||
import assertNever from "assert-never";
|
||||
import { AppliedFilter } from "./primitives/AppliedFilter";
|
||||
import { runStatusTitle } from "./runs/v3/TaskRunStatus";
|
||||
import { type TaskRunStatus } from "@trigger.dev/database";
|
||||
import type { TaskRunStatus } from "@trigger.dev/database";
|
||||
|
||||
export const BulkActionMode = z.union([z.literal("selected"), z.literal("filter")]);
|
||||
export type BulkActionMode = z.infer<typeof BulkActionMode>;
|
||||
@@ -244,7 +245,7 @@ function Action({ action }: { action: BulkActionAction }) {
|
||||
case "cancel":
|
||||
return (
|
||||
<span>
|
||||
<XCircleIcon className="mb-0.5 inline-block size-4 text-error" />
|
||||
<XCircleIconOutline className="mb-0.5 inline-block size-4 text-error" />
|
||||
<span className="ml-0.5 text-text-bright">Canceled</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -14,7 +14,7 @@ export function DefinitionTip({
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip disableHoverableContent>
|
||||
<TooltipTrigger>
|
||||
<TooltipTrigger className="text-left">
|
||||
<span className="cursor-default underline decoration-charcoal-500 decoration-dashed underline-offset-4 transition hover:decoration-charcoal-400">
|
||||
{children}
|
||||
</span>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { conform, useForm } from "@conform-to/react";
|
||||
import { parse } from "@conform-to/zod";
|
||||
import { InformationCircleIcon, ArrowUpCircleIcon } from "@heroicons/react/20/solid";
|
||||
import { EnvelopeIcon } from "@heroicons/react/24/solid";
|
||||
import { Form, useActionData, useLocation, useNavigation } from "@remix-run/react";
|
||||
import { Form, useActionData, useLocation, useNavigation, useSearchParams } from "@remix-run/react";
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
import { type FeedbackType, feedbackTypeLabel, schema } from "~/routes/resources.feedback";
|
||||
import { Button } from "./primitives/Buttons";
|
||||
@@ -23,10 +23,12 @@ import { DialogClose } from "@radix-ui/react-dialog";
|
||||
type FeedbackProps = {
|
||||
button: ReactNode;
|
||||
defaultValue?: FeedbackType;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export function Feedback({ button, defaultValue = "bug" }: FeedbackProps) {
|
||||
export function Feedback({ button, defaultValue = "bug", onOpenChange }: FeedbackProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const location = useLocation();
|
||||
const lastSubmission = useActionData();
|
||||
const navigation = useNavigation();
|
||||
@@ -52,8 +54,26 @@ export function Feedback({ button, defaultValue = "bug" }: FeedbackProps) {
|
||||
}
|
||||
}, [navigation, form]);
|
||||
|
||||
// Handle URL param functionality
|
||||
useEffect(() => {
|
||||
const open = searchParams.get("feedbackPanel");
|
||||
if (open) {
|
||||
setType(open as FeedbackType);
|
||||
setOpen(true);
|
||||
// Clone instead of mutating in place
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.delete("feedbackPanel");
|
||||
setSearchParams(next);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
setOpen(value);
|
||||
onOpenChange?.(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger asChild>{button}</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>Contact us</DialogHeader>
|
||||
|
||||
@@ -123,7 +123,7 @@ function ShortcutContent() {
|
||||
<ShortcutKey shortcut={{ key: "d" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Context">
|
||||
<ShortcutKey shortcut={{ key: "c" }} variant="medium/bright" />
|
||||
<ShortcutKey shortcut={{ key: "x" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Metadata">
|
||||
<ShortcutKey shortcut={{ key: "m" }} variant="medium/bright" />
|
||||
@@ -134,6 +134,10 @@ function ShortcutContent() {
|
||||
<ShortcutKey shortcut={{ key: "arrowleft" }} variant="medium/bright" />
|
||||
<ShortcutKey shortcut={{ key: "arrowright" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Jump to adjacent">
|
||||
<ShortcutKey shortcut={{ key: "[" }} variant="medium/bright" />
|
||||
<ShortcutKey shortcut={{ key: "]" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Expand all">
|
||||
<ShortcutKey shortcut={{ key: "e" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
@@ -147,6 +151,12 @@ function ShortcutContent() {
|
||||
</Paragraph>
|
||||
<ShortcutKey shortcut={{ key: "9" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Jump to root run">
|
||||
<ShortcutKey shortcut={{ key: "t" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Jump to parent run">
|
||||
<ShortcutKey shortcut={{ key: "p" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Header3>Schedules page</Header3>
|
||||
|
||||
@@ -22,6 +22,7 @@ export function UserAvatar({
|
||||
className={cn("aspect-square rounded-full p-[7%]")}
|
||||
src={avatarUrl}
|
||||
alt={name ?? "User"}
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { LockClosedIcon, ShieldCheckIcon, UserCircleIcon } from "@heroicons/react/20/solid";
|
||||
import { ArrowLeftIcon } from "@heroicons/react/24/solid";
|
||||
import { type User } from "@trigger.dev/database";
|
||||
import type { User } from "@trigger.dev/database";
|
||||
import { cn } from "~/utils/cn";
|
||||
import {
|
||||
accountPath,
|
||||
|
||||
@@ -13,16 +13,18 @@ import {
|
||||
GlobeAmericasIcon,
|
||||
IdentificationIcon,
|
||||
KeyIcon,
|
||||
PencilSquareIcon,
|
||||
PlusIcon,
|
||||
RectangleStackIcon,
|
||||
ServerStackIcon,
|
||||
Squares2X2Icon,
|
||||
UsersIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { useNavigation } from "@remix-run/react";
|
||||
import { Link, useNavigation } from "@remix-run/react";
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import simplur from "simplur";
|
||||
import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons";
|
||||
import { ConcurrencyIcon } from "~/assets/icons/ConcurrencyIcon";
|
||||
import { ListCheckedIcon } from "~/assets/icons/ListCheckedIcon";
|
||||
import { RunsIconExtraSmall } from "~/assets/icons/RunsIcon";
|
||||
import { TaskIconSmall } from "~/assets/icons/TaskIcon";
|
||||
@@ -42,6 +44,7 @@ import {
|
||||
accountPath,
|
||||
adminPath,
|
||||
branchesPath,
|
||||
concurrencyPath,
|
||||
logoutPath,
|
||||
newOrganizationPath,
|
||||
newProjectPath,
|
||||
@@ -121,6 +124,7 @@ export function SideMenu({
|
||||
const { isConnected } = useDevPresence();
|
||||
const isFreeUser = currentPlan?.v3Subscription?.isPaying === false;
|
||||
const isAdmin = useHasAdminAccess();
|
||||
const { isManagedCloud } = useFeatures();
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
@@ -312,6 +316,15 @@ export function SideMenu({
|
||||
data-action="preview-branches"
|
||||
badge={<V4Badge />}
|
||||
/>
|
||||
{isManagedCloud && (
|
||||
<SideMenuItem
|
||||
name="Concurrency"
|
||||
icon={ConcurrencyIcon}
|
||||
activeIconColor="text-amber-500"
|
||||
to={concurrencyPath(organization, project, environment)}
|
||||
data-action="concurrency"
|
||||
/>
|
||||
)}
|
||||
<SideMenuItem
|
||||
name="Regions"
|
||||
icon={GlobeAmericasIcon}
|
||||
@@ -397,9 +410,15 @@ function ProjectSelector({
|
||||
>
|
||||
<div className="flex flex-col gap-2 bg-charcoal-750 p-2">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="box-content size-10 overflow-clip rounded-sm bg-charcoal-800">
|
||||
<Link
|
||||
to={organizationSettingsPath(organization)}
|
||||
className="group relative box-content size-10 overflow-clip rounded-sm bg-charcoal-800"
|
||||
>
|
||||
<Avatar avatar={organization.avatar} size={2.5} orgName={organization.title} />
|
||||
</div>
|
||||
<div className="absolute inset-0 z-10 grid h-full w-full place-items-center bg-black/50 opacity-0 transition group-hover:opacity-100">
|
||||
<PencilSquareIcon className="size-5 text-text-bright" />
|
||||
</div>
|
||||
</Link>
|
||||
<div className="space-y-0.5">
|
||||
<Paragraph variant="small/bright">{organization.title}</Paragraph>
|
||||
<div className="flex items-baseline gap-2">
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
RocketLaunchIcon,
|
||||
StarIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { type Prisma } from "@trigger.dev/database";
|
||||
import type { Prisma } from "@trigger.dev/database";
|
||||
import { z } from "zod";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
@@ -276,7 +276,7 @@ export function ButtonContent(props: ButtonContentPropsType) {
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{buttonContent}</TooltipTrigger>
|
||||
<TooltipContent className="text-dimmed flex items-center gap-3 py-1.5 pl-2.5 pr-3 text-xs">
|
||||
<TooltipContent className="flex items-center gap-3 py-1.5 pl-2.5 pr-3 text-xs text-text-bright">
|
||||
{tooltip} {shortcut && renderShortcutKey()}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
@@ -298,19 +298,17 @@ export const Button = forwardRef<HTMLButtonElement, ButtonPropsType>(
|
||||
const innerRef = useRef<HTMLButtonElement>(null);
|
||||
useImperativeHandle(ref, () => innerRef.current as HTMLButtonElement);
|
||||
|
||||
if (props.shortcut) {
|
||||
useShortcutKeys({
|
||||
shortcut: props.shortcut,
|
||||
action: (e) => {
|
||||
if (innerRef.current) {
|
||||
innerRef.current.click();
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
},
|
||||
disabled,
|
||||
});
|
||||
}
|
||||
useShortcutKeys({
|
||||
shortcut: props.shortcut,
|
||||
action: (e) => {
|
||||
if (innerRef.current) {
|
||||
innerRef.current.click();
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
},
|
||||
disabled: disabled || !props.shortcut,
|
||||
});
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -333,7 +331,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonPropsType>(
|
||||
type LinkPropsType = Pick<
|
||||
LinkProps,
|
||||
"to" | "target" | "onClick" | "onMouseDown" | "onMouseEnter" | "onMouseLeave" | "download"
|
||||
> & { disabled?: boolean } & React.ComponentProps<typeof ButtonContent>;
|
||||
> & { disabled?: boolean; replace?: boolean } & React.ComponentProps<typeof ButtonContent>;
|
||||
export const LinkButton = ({
|
||||
to,
|
||||
onClick,
|
||||
@@ -342,19 +340,20 @@ export const LinkButton = ({
|
||||
onMouseLeave,
|
||||
download,
|
||||
disabled = false,
|
||||
replace,
|
||||
...props
|
||||
}: LinkPropsType) => {
|
||||
const innerRef = useRef<HTMLAnchorElement>(null);
|
||||
if (props.shortcut) {
|
||||
useShortcutKeys({
|
||||
shortcut: props.shortcut,
|
||||
action: () => {
|
||||
if (innerRef.current) {
|
||||
innerRef.current.click();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
useShortcutKeys({
|
||||
shortcut: props.shortcut,
|
||||
action: () => {
|
||||
if (innerRef.current) {
|
||||
innerRef.current.click();
|
||||
}
|
||||
},
|
||||
disabled: disabled || !props.shortcut,
|
||||
});
|
||||
|
||||
if (disabled) {
|
||||
return (
|
||||
@@ -374,7 +373,7 @@ export const LinkButton = ({
|
||||
<ExtLink
|
||||
href={to.toString()}
|
||||
ref={innerRef}
|
||||
className={cn("group/button focus-custom", props.fullWidth ? "w-full" : "")}
|
||||
className={cn("group/button block focus-custom", props.fullWidth ? "w-full" : "")}
|
||||
onClick={onClick}
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseEnter={onMouseEnter}
|
||||
@@ -389,7 +388,8 @@ export const LinkButton = ({
|
||||
<Link
|
||||
to={to}
|
||||
ref={innerRef}
|
||||
className={cn("group/button focus-custom", props.fullWidth ? "w-full" : "")}
|
||||
replace={replace}
|
||||
className={cn("group/button block focus-custom", props.fullWidth ? "w-full" : "")}
|
||||
onClick={onClick}
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseEnter={onMouseEnter}
|
||||
@@ -410,7 +410,7 @@ export const NavLinkButton = ({ to, className, target, ...props }: NavLinkPropsT
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
className={cn("group/button outline-none", props.fullWidth ? "w-full" : "")}
|
||||
className={cn("group/button outline-none block", props.fullWidth ? "w-full" : "")}
|
||||
target={target}
|
||||
>
|
||||
{({ isActive, isPending }) => (
|
||||
|
||||
@@ -1,41 +1,185 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { motion } from "framer-motion";
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
import * as React from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { type Variants } from "./Tabs";
|
||||
|
||||
type ClientTabsContextValue = {
|
||||
value?: string;
|
||||
};
|
||||
|
||||
const ClientTabsContext = React.createContext<ClientTabsContextValue | undefined>(undefined);
|
||||
|
||||
function useClientTabsContext() {
|
||||
return React.useContext(ClientTabsContext);
|
||||
}
|
||||
|
||||
const ClientTabs = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Root>
|
||||
>((props, ref) => <TabsPrimitive.Root ref={ref} {...props} />);
|
||||
>(({ onValueChange, value: valueProp, defaultValue, ...props }, ref) => {
|
||||
const [value, setValue] = React.useState<string | undefined>(valueProp ?? defaultValue);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (valueProp !== undefined) {
|
||||
setValue(valueProp);
|
||||
}
|
||||
}, [valueProp]);
|
||||
|
||||
const handleValueChange = React.useCallback(
|
||||
(nextValue: string) => {
|
||||
if (valueProp === undefined) {
|
||||
setValue(nextValue);
|
||||
}
|
||||
onValueChange?.(nextValue);
|
||||
},
|
||||
[onValueChange, valueProp]
|
||||
);
|
||||
|
||||
const controlledProps =
|
||||
valueProp !== undefined
|
||||
? { value: valueProp }
|
||||
: defaultValue !== undefined
|
||||
? { defaultValue }
|
||||
: {};
|
||||
|
||||
const contextValue = React.useMemo<ClientTabsContextValue>(() => ({ value }), [value]);
|
||||
|
||||
return (
|
||||
<ClientTabsContext.Provider value={contextValue}>
|
||||
<TabsPrimitive.Root
|
||||
ref={ref}
|
||||
onValueChange={handleValueChange}
|
||||
{...controlledProps}
|
||||
{...props}
|
||||
/>
|
||||
</ClientTabsContext.Provider>
|
||||
);
|
||||
});
|
||||
ClientTabs.displayName = TabsPrimitive.Root.displayName;
|
||||
|
||||
const ClientTabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn("inline-flex items-center justify-center transition duration-100", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> & {
|
||||
variant?: Variants;
|
||||
}
|
||||
>(({ className, variant = "pipe-divider", ...props }, ref) => {
|
||||
const variantClassName = (() => {
|
||||
switch (variant) {
|
||||
case "segmented":
|
||||
return "relative flex h-10 w-full items-center rounded bg-charcoal-700/50 p-1";
|
||||
case "underline":
|
||||
return "flex gap-x-6 border-b border-grid-bright";
|
||||
default:
|
||||
return "inline-flex items-center justify-center transition duration-100";
|
||||
}
|
||||
})();
|
||||
|
||||
return <TabsPrimitive.List ref={ref} className={cn(variantClassName, className)} {...props} />;
|
||||
});
|
||||
ClientTabsList.displayName = TabsPrimitive.List.displayName;
|
||||
|
||||
const ClientTabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"ring-offset-background focus-visible:ring-ring inline-flex items-center justify-center whitespace-nowrap border-r border-charcoal-700 px-2 text-sm transition-all first:pl-0 last:border-none data-[state=active]:text-indigo-500 data-[state=inactive]:text-text-dimmed data-[state=inactive]:hover:text-text-bright focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger> & {
|
||||
variant?: Variants;
|
||||
layoutId?: string;
|
||||
}
|
||||
>(({ className, variant = "pipe-divider", layoutId, children, ...props }, ref) => {
|
||||
const context = useClientTabsContext();
|
||||
const activeValue = context?.value;
|
||||
const isActive = activeValue === props.value;
|
||||
|
||||
if (variant === "segmented") {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"group relative flex h-full grow items-center justify-center focus-custom disabled:pointer-events-none disabled:opacity-50",
|
||||
"flex-1 basis-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="relative z-10 flex h-full w-full items-center justify-center px-3 py-[0.13rem]">
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm transition duration-200",
|
||||
isActive
|
||||
? "text-text-bright"
|
||||
: "text-text-dimmed transition group-hover:text-text-bright"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
</div>
|
||||
{isActive ? (
|
||||
layoutId ? (
|
||||
<motion.div
|
||||
layoutId={layoutId}
|
||||
transition={{ duration: 0.4, type: "spring" }}
|
||||
className="absolute inset-0 rounded-[2px] border border-charcoal-500/50 bg-charcoal-600"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 rounded-[2px] border border-charcoal-500/50 bg-charcoal-600" />
|
||||
)
|
||||
) : null}
|
||||
</TabsPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "underline") {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"group flex flex-col items-center pt-1 focus-custom disabled:pointer-events-none disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm transition duration-200",
|
||||
isActive ? "text-text-bright" : "text-text-dimmed hover:text-text-bright"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
{layoutId ? (
|
||||
isActive ? (
|
||||
<motion.div
|
||||
layoutId={layoutId}
|
||||
transition={{ type: "spring", stiffness: 500, damping: 30 }}
|
||||
className="mt-1 h-0.5 w-full bg-indigo-500"
|
||||
/>
|
||||
) : (
|
||||
<div className="mt-1 h-0.5 w-full bg-charcoal-500 opacity-0 transition duration-200 group-hover:opacity-100" />
|
||||
)
|
||||
) : isActive ? (
|
||||
<div className="mt-1 h-0.5 w-full bg-indigo-500" />
|
||||
) : (
|
||||
<div className="mt-1 h-0.5 w-full bg-charcoal-500 opacity-0 transition duration-200 group-hover:opacity-100" />
|
||||
)}
|
||||
</TabsPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"ring-offset-background focus-visible:ring-ring inline-flex items-center justify-center whitespace-nowrap border-r border-charcoal-700 px-2 text-sm transition-all first:pl-0 last:border-none data-[state=active]:text-indigo-500 data-[state=inactive]:text-text-dimmed data-[state=inactive]:hover:text-text-bright focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</TabsPrimitive.Trigger>
|
||||
);
|
||||
});
|
||||
ClientTabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||
|
||||
const ClientTabsContent = React.forwardRef<
|
||||
@@ -61,39 +205,7 @@ export type TabsProps = {
|
||||
currentValue: string;
|
||||
className?: string;
|
||||
layoutId: string;
|
||||
variant?: Variants;
|
||||
};
|
||||
|
||||
export function ClientTabsWithUnderline({ className, tabs, currentValue, layoutId }: TabsProps) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
className={cn(`flex flex-row gap-x-6 border-b border-charcoal-700`, className)}
|
||||
>
|
||||
{tabs.map((tab, index) => {
|
||||
const isActive = currentValue === tab.value;
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
key={tab.value}
|
||||
value={tab.value}
|
||||
className={cn(`group flex flex-col items-center`, className)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm transition duration-200",
|
||||
isActive ? "text-indigo-500" : "text-charcoal-200"
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</span>
|
||||
{isActive ? (
|
||||
<motion.div layoutId={layoutId} className="mt-1 h-0.5 w-full bg-indigo-500" />
|
||||
) : (
|
||||
<div className="mt-1 h-0.5 w-full bg-charcoal-500 opacity-0 transition duration-200 group-hover:opacity-100" />
|
||||
)}
|
||||
</TabsPrimitive.Trigger>
|
||||
);
|
||||
})}
|
||||
</TabsPrimitive.List>
|
||||
);
|
||||
}
|
||||
|
||||
export { ClientTabs, ClientTabsList, ClientTabsTrigger, ClientTabsContent };
|
||||
export { ClientTabs, ClientTabsContent, ClientTabsList, ClientTabsTrigger };
|
||||
|
||||
@@ -3,55 +3,95 @@ import { useState } from "react";
|
||||
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import { useCopy } from "~/hooks/useCopy";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Button } from "./Buttons";
|
||||
|
||||
export function CopyableText({
|
||||
value,
|
||||
copyValue,
|
||||
className,
|
||||
asChild,
|
||||
variant,
|
||||
}: {
|
||||
value: string;
|
||||
copyValue?: string;
|
||||
className?: string;
|
||||
asChild?: boolean;
|
||||
variant?: "icon-right" | "text-below";
|
||||
}) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const { copy, copied } = useCopy(copyValue ?? value);
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn("group relative inline-flex h-6 items-center", className)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<span onMouseEnter={() => setIsHovered(true)}>{value}</span>
|
||||
const resolvedVariant = variant ?? "icon-right";
|
||||
|
||||
if (resolvedVariant === "icon-right") {
|
||||
return (
|
||||
<span
|
||||
onClick={copy}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className={cn(
|
||||
"absolute -right-6 top-0 z-10 size-6 font-sans",
|
||||
isHovered ? "flex" : "hidden"
|
||||
)}
|
||||
className={cn("group relative inline-flex h-6 items-center", className)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<span
|
||||
className={cn(
|
||||
"ml-1 flex size-6 items-center justify-center rounded border border-charcoal-650 bg-charcoal-750",
|
||||
copied
|
||||
? "text-green-500"
|
||||
: "text-text-dimmed hover:border-charcoal-600 hover:bg-charcoal-700 hover:text-text-bright"
|
||||
)}
|
||||
>
|
||||
{copied ? (
|
||||
<ClipboardCheckIcon className="size-3.5" />
|
||||
) : (
|
||||
<ClipboardIcon className="size-3.5" />
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
content={copied ? "Copied!" : "Copy"}
|
||||
className="font-sans"
|
||||
disableHoverableContent
|
||||
/>
|
||||
<span onMouseEnter={() => setIsHovered(true)}>{value}</span>
|
||||
<span
|
||||
onClick={copy}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className={cn(
|
||||
"absolute -right-6 top-0 z-10 size-6 font-sans",
|
||||
isHovered ? "flex" : "hidden"
|
||||
)}
|
||||
>
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<span
|
||||
className={cn(
|
||||
"ml-1 flex size-6 items-center justify-center rounded border border-charcoal-650 bg-charcoal-750",
|
||||
asChild && "p-1",
|
||||
copied
|
||||
? "text-green-500"
|
||||
: "text-text-dimmed hover:border-charcoal-600 hover:bg-charcoal-700 hover:text-text-bright"
|
||||
)}
|
||||
>
|
||||
{copied ? (
|
||||
<ClipboardCheckIcon className="size-3.5" />
|
||||
) : (
|
||||
<ClipboardIcon className="size-3.5" />
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
content={copied ? "Copied!" : "Copy"}
|
||||
className="font-sans"
|
||||
disableHoverableContent
|
||||
asChild={asChild}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
if (resolvedVariant === "text-below") {
|
||||
return (
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<Button
|
||||
variant="minimal/small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
copy();
|
||||
}}
|
||||
className={cn(
|
||||
"cursor-pointer bg-transparent py-0 px-1 text-left text-text-bright transition-colors hover:text-white hover:bg-transparent",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span>{value}</span>
|
||||
</Button>
|
||||
}
|
||||
content={copied ? "Copied" : "Click to copy"}
|
||||
className="font-sans px-2 py-1"
|
||||
disableHoverableContent
|
||||
open={isHovered || copied}
|
||||
onOpenChange={setIsHovered}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@ type DateTimeProps = {
|
||||
includeTime?: boolean;
|
||||
showTimezone?: boolean;
|
||||
showTooltip?: boolean;
|
||||
hideDate?: boolean;
|
||||
previousDate?: Date | string | null; // Add optional previous date for comparison
|
||||
hour12?: boolean;
|
||||
};
|
||||
|
||||
export const DateTime = ({
|
||||
@@ -23,6 +25,7 @@ export const DateTime = ({
|
||||
includeTime = true,
|
||||
showTimezone = false,
|
||||
showTooltip = true,
|
||||
hour12 = true,
|
||||
}: DateTimeProps) => {
|
||||
const locales = useLocales();
|
||||
const [localTimeZone, setLocalTimeZone] = useState<string>("UTC");
|
||||
@@ -50,7 +53,8 @@ export const DateTime = ({
|
||||
timeZone ?? localTimeZone,
|
||||
locales,
|
||||
includeSeconds,
|
||||
includeTime
|
||||
includeTime,
|
||||
hour12
|
||||
).replace(/\s/g, String.fromCharCode(32))}
|
||||
{showTimezone ? ` (${timeZone ?? "UTC"})` : null}
|
||||
</Fragment>
|
||||
@@ -66,7 +70,8 @@ export function formatDateTime(
|
||||
timeZone: string,
|
||||
locales: string[],
|
||||
includeSeconds: boolean,
|
||||
includeTime: boolean
|
||||
includeTime: boolean,
|
||||
hour12: boolean = true
|
||||
): string {
|
||||
return new Intl.DateTimeFormat(locales, {
|
||||
year: "numeric",
|
||||
@@ -76,6 +81,7 @@ export function formatDateTime(
|
||||
minute: includeTime ? "numeric" : undefined,
|
||||
second: includeTime && includeSeconds ? "numeric" : undefined,
|
||||
timeZone,
|
||||
hour12,
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
@@ -122,7 +128,7 @@ export function formatDateTimeISO(date: Date, timeZone: string): string {
|
||||
}
|
||||
|
||||
// New component that only shows date when it changes
|
||||
export const SmartDateTime = ({ date, previousDate = null, timeZone = "UTC" }: DateTimeProps) => {
|
||||
export const SmartDateTime = ({ date, previousDate = null, timeZone = "UTC", hour12 = true }: DateTimeProps) => {
|
||||
const locales = useLocales();
|
||||
const realDate = typeof date === "string" ? new Date(date) : date;
|
||||
const realPrevDate = previousDate
|
||||
@@ -132,8 +138,8 @@ export const SmartDateTime = ({ date, previousDate = null, timeZone = "UTC" }: D
|
||||
: null;
|
||||
|
||||
// Initial formatted values
|
||||
const initialTimeOnly = formatTimeOnly(realDate, timeZone, locales);
|
||||
const initialWithDate = formatSmartDateTime(realDate, timeZone, locales);
|
||||
const initialTimeOnly = formatTimeOnly(realDate, timeZone, locales, hour12);
|
||||
const initialWithDate = formatSmartDateTime(realDate, timeZone, locales, hour12);
|
||||
|
||||
// State for the formatted time
|
||||
const [formattedDateTime, setFormattedDateTime] = useState<string>(
|
||||
@@ -150,10 +156,10 @@ export const SmartDateTime = ({ date, previousDate = null, timeZone = "UTC" }: D
|
||||
// Format with appropriate function
|
||||
setFormattedDateTime(
|
||||
showDatePart
|
||||
? formatSmartDateTime(realDate, userTimeZone, locales)
|
||||
: formatTimeOnly(realDate, userTimeZone, locales)
|
||||
? formatSmartDateTime(realDate, userTimeZone, locales, hour12)
|
||||
: formatTimeOnly(realDate, userTimeZone, locales, hour12)
|
||||
);
|
||||
}, [locales, realDate, realPrevDate]);
|
||||
}, [locales, realDate, realPrevDate, hour12]);
|
||||
|
||||
return <Fragment>{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}</Fragment>;
|
||||
};
|
||||
@@ -168,7 +174,7 @@ function isSameDay(date1: Date, date2: Date): boolean {
|
||||
}
|
||||
|
||||
// Format with date and time
|
||||
function formatSmartDateTime(date: Date, timeZone: string, locales: string[]): string {
|
||||
function formatSmartDateTime(date: Date, timeZone: string, locales: string[], hour12: boolean = true): string {
|
||||
return new Intl.DateTimeFormat(locales, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
@@ -178,18 +184,20 @@ function formatSmartDateTime(date: Date, timeZone: string, locales: string[]): s
|
||||
timeZone,
|
||||
// @ts-ignore fractionalSecondDigits works in most modern browsers
|
||||
fractionalSecondDigits: 3,
|
||||
hour12,
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
// Format time only
|
||||
function formatTimeOnly(date: Date, timeZone: string, locales: string[]): string {
|
||||
function formatTimeOnly(date: Date, timeZone: string, locales: string[], hour12: boolean = true): string {
|
||||
return new Intl.DateTimeFormat(locales, {
|
||||
hour: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "numeric",
|
||||
second: "numeric",
|
||||
timeZone,
|
||||
// @ts-ignore fractionalSecondDigits works in most modern browsers
|
||||
fractionalSecondDigits: 3,
|
||||
hour12,
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
@@ -198,6 +206,8 @@ export const DateTimeAccurate = ({
|
||||
timeZone = "UTC",
|
||||
previousDate = null,
|
||||
showTooltip = true,
|
||||
hideDate = false,
|
||||
hour12 = true,
|
||||
}: DateTimeProps) => {
|
||||
const locales = useLocales();
|
||||
const [localTimeZone, setLocalTimeZone] = useState<string>("UTC");
|
||||
@@ -214,11 +224,13 @@ export const DateTimeAccurate = ({
|
||||
}, []);
|
||||
|
||||
// Smart formatting based on whether date changed
|
||||
const formattedDateTime = realPrevDate
|
||||
const formattedDateTime = hideDate
|
||||
? formatTimeOnly(realDate, localTimeZone, locales, hour12)
|
||||
: realPrevDate
|
||||
? isSameDay(realDate, realPrevDate)
|
||||
? formatTimeOnly(realDate, localTimeZone, locales)
|
||||
: formatDateTimeAccurate(realDate, localTimeZone, locales)
|
||||
: formatDateTimeAccurate(realDate, localTimeZone, locales);
|
||||
? formatTimeOnly(realDate, localTimeZone, locales, hour12)
|
||||
: formatDateTimeAccurate(realDate, localTimeZone, locales, hour12)
|
||||
: formatDateTimeAccurate(realDate, localTimeZone, locales, hour12);
|
||||
|
||||
if (!showTooltip)
|
||||
return <Fragment>{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}</Fragment>;
|
||||
@@ -241,7 +253,7 @@ export const DateTimeAccurate = ({
|
||||
);
|
||||
};
|
||||
|
||||
function formatDateTimeAccurate(date: Date, timeZone: string, locales: string[]): string {
|
||||
function formatDateTimeAccurate(date: Date, timeZone: string, locales: string[], hour12: boolean = true): string {
|
||||
const formattedDateTime = new Intl.DateTimeFormat(locales, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
@@ -251,26 +263,27 @@ function formatDateTimeAccurate(date: Date, timeZone: string, locales: string[])
|
||||
timeZone,
|
||||
// @ts-ignore fractionalSecondDigits works in most modern browsers
|
||||
fractionalSecondDigits: 3,
|
||||
hour12,
|
||||
}).format(date);
|
||||
|
||||
return formattedDateTime;
|
||||
}
|
||||
|
||||
export const DateTimeShort = ({ date, timeZone = "UTC" }: DateTimeProps) => {
|
||||
export const DateTimeShort = ({ date, timeZone = "UTC", hour12 = true }: DateTimeProps) => {
|
||||
const locales = useLocales();
|
||||
const realDate = typeof date === "string" ? new Date(date) : date;
|
||||
const initialFormattedDateTime = formatDateTimeShort(realDate, timeZone, locales);
|
||||
const initialFormattedDateTime = formatDateTimeShort(realDate, timeZone, locales, hour12);
|
||||
const [formattedDateTime, setFormattedDateTime] = useState<string>(initialFormattedDateTime);
|
||||
|
||||
useEffect(() => {
|
||||
const resolvedOptions = Intl.DateTimeFormat().resolvedOptions();
|
||||
setFormattedDateTime(formatDateTimeShort(realDate, resolvedOptions.timeZone, locales));
|
||||
}, [locales, realDate]);
|
||||
setFormattedDateTime(formatDateTimeShort(realDate, resolvedOptions.timeZone, locales, hour12));
|
||||
}, [locales, realDate, hour12]);
|
||||
|
||||
return <Fragment>{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}</Fragment>;
|
||||
};
|
||||
|
||||
function formatDateTimeShort(date: Date, timeZone: string, locales: string[]): string {
|
||||
function formatDateTimeShort(date: Date, timeZone: string, locales: string[], hour12: boolean = true): string {
|
||||
const formattedDateTime = new Intl.DateTimeFormat(locales, {
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
@@ -278,6 +291,7 @@ function formatDateTimeShort(date: Date, timeZone: string, locales: string[]): s
|
||||
timeZone,
|
||||
// @ts-ignore fractionalSecondDigits works in most modern browsers
|
||||
fractionalSecondDigits: 3,
|
||||
hour12,
|
||||
}).format(date);
|
||||
|
||||
return formattedDateTime;
|
||||
|
||||
@@ -44,6 +44,24 @@ const variants = {
|
||||
iconSize: "size-3 ml-0.5",
|
||||
accessory: "pr-0.5",
|
||||
},
|
||||
"outline/large": {
|
||||
container: "px-1 h-10 w-full rounded border border-grid-bright hover:border-charcoal-550",
|
||||
input: "px-2 rounded text-sm",
|
||||
iconSize: "size-4 ml-1",
|
||||
accessory: "pr-1",
|
||||
},
|
||||
"outline/medium": {
|
||||
container: "px-1 h-8 w-full rounded border border-grid-bright hover:border-charcoal-550",
|
||||
input: "px-1 rounded text-sm",
|
||||
iconSize: "size-4 ml-0.5",
|
||||
accessory: "pr-1",
|
||||
},
|
||||
"outline/small": {
|
||||
container: "px-1 h-6 w-full rounded border border-grid-bright hover:border-charcoal-550",
|
||||
input: "px-1 rounded text-xs",
|
||||
iconSize: "size-3 ml-0.5",
|
||||
accessory: "pr-0.5",
|
||||
},
|
||||
};
|
||||
|
||||
export type InputProps = React.InputHTMLAttributes<HTMLInputElement> & {
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import { MinusIcon, PlusIcon } from "@heroicons/react/20/solid";
|
||||
import { type ChangeEvent, useRef } from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
type InputNumberStepperProps = Omit<JSX.IntrinsicElements["input"], "min" | "max" | "step"> & {
|
||||
step?: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
round?: boolean;
|
||||
controlSize?: "base" | "large";
|
||||
};
|
||||
|
||||
export function InputNumberStepper({
|
||||
value,
|
||||
onChange,
|
||||
step = 50,
|
||||
min,
|
||||
max,
|
||||
round = true,
|
||||
controlSize = "base",
|
||||
name,
|
||||
id,
|
||||
disabled = false,
|
||||
readOnly = false,
|
||||
className,
|
||||
placeholder = "Type a number",
|
||||
...props
|
||||
}: InputNumberStepperProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleStepUp = () => {
|
||||
if (!inputRef.current || disabled) return;
|
||||
|
||||
// If rounding is enabled, ensure we start from a rounded base before stepping
|
||||
if (round) {
|
||||
// If field is empty, treat as 0 (or min if provided) before stepping up
|
||||
if (inputRef.current.value === "") {
|
||||
inputRef.current.value = String(min ?? 0);
|
||||
} else {
|
||||
commitRoundedFromInput();
|
||||
}
|
||||
}
|
||||
inputRef.current.stepUp();
|
||||
const event = new Event("change", { bubbles: true });
|
||||
inputRef.current.dispatchEvent(event);
|
||||
};
|
||||
|
||||
const handleStepDown = () => {
|
||||
if (!inputRef.current || disabled) return;
|
||||
|
||||
// If rounding is enabled, ensure we start from a rounded base before stepping
|
||||
if (round) {
|
||||
// If field is empty, treat as 0 (or min if provided) before stepping down
|
||||
if (inputRef.current.value === "") {
|
||||
inputRef.current.value = String(min ?? 0);
|
||||
} else {
|
||||
commitRoundedFromInput();
|
||||
}
|
||||
}
|
||||
inputRef.current.stepDown();
|
||||
const event = new Event("change", { bubbles: true });
|
||||
inputRef.current.dispatchEvent(event);
|
||||
};
|
||||
|
||||
const numericValue = value === "" ? NaN : (value as number);
|
||||
const isMinDisabled = min !== undefined && !Number.isNaN(numericValue) && numericValue <= min;
|
||||
const isMaxDisabled = max !== undefined && !Number.isNaN(numericValue) && numericValue >= max;
|
||||
|
||||
function clamp(val: number): number {
|
||||
if (Number.isNaN(val)) return typeof value === "number" ? value : min ?? 0;
|
||||
let next = val;
|
||||
if (min !== undefined) next = Math.max(min, next);
|
||||
if (max !== undefined) next = Math.min(max, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
function roundToStep(val: number): number {
|
||||
if (step <= 0) return val;
|
||||
const base = min ?? 0;
|
||||
const shifted = val - base;
|
||||
const quotient = shifted / step;
|
||||
const floored = Math.floor(quotient);
|
||||
const ceiled = Math.ceil(quotient);
|
||||
const down = base + floored * step;
|
||||
const up = base + ceiled * step;
|
||||
const distDown = Math.abs(val - down);
|
||||
const distUp = Math.abs(up - val);
|
||||
return distUp < distDown ? up : down;
|
||||
}
|
||||
|
||||
function commitRoundedFromInput() {
|
||||
if (!inputRef.current || disabled || readOnly) return;
|
||||
const el = inputRef.current;
|
||||
const raw = el.value;
|
||||
if (raw === "") return; // do not coerce empty to 0; keep placeholder visible
|
||||
const numeric = Number(raw);
|
||||
if (Number.isNaN(numeric)) return; // ignore non-numeric
|
||||
const rounded = clamp(roundToStep(numeric));
|
||||
if (String(rounded) === String(value)) return;
|
||||
// Update the real input's value for immediate UI feedback
|
||||
el.value = String(rounded);
|
||||
// Invoke consumer onChange with the real element as target/currentTarget
|
||||
onChange?.({
|
||||
target: el,
|
||||
currentTarget: el,
|
||||
} as unknown as ChangeEvent<HTMLInputElement>);
|
||||
}
|
||||
|
||||
const sizeStyles = {
|
||||
base: {
|
||||
container: "h-9",
|
||||
input: "text-sm px-3",
|
||||
button: "size-6",
|
||||
icon: "size-3.5",
|
||||
gap: "gap-1 pr-1.5",
|
||||
},
|
||||
large: {
|
||||
container: "h-11 rounded-md",
|
||||
input: "text-base px-3.5",
|
||||
button: "size-8",
|
||||
icon: "size-5",
|
||||
gap: "gap-[0.3125rem] pr-[0.3125rem]",
|
||||
},
|
||||
} as const;
|
||||
|
||||
const size = sizeStyles[controlSize];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center rounded border border-charcoal-600 bg-tertiary transition hover:border-charcoal-550/80 hover:bg-charcoal-600/80",
|
||||
size.container,
|
||||
"has-[:focus-visible]:outline has-[:focus-visible]:outline-1 has-[:focus-visible]:outline-offset-0 has-[:focus-visible]:outline-text-link",
|
||||
disabled && "cursor-not-allowed opacity-50",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="number"
|
||||
id={id}
|
||||
name={name}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => {
|
||||
// Allow empty string to pass through so user can clear the field
|
||||
if (e.currentTarget.value === "") {
|
||||
// reflect emptiness in the input and notify consumer as empty
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
onChange?.({
|
||||
target: e.currentTarget,
|
||||
currentTarget: e.currentTarget,
|
||||
} as ChangeEvent<HTMLInputElement>);
|
||||
return;
|
||||
}
|
||||
onChange?.(e);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
// If blur is caused by clicking our step buttons, we prevent pointerdown
|
||||
// so blur shouldn't fire. This is for safety in case of keyboard focus move.
|
||||
if (round) commitRoundedFromInput();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && round) {
|
||||
e.preventDefault();
|
||||
commitRoundedFromInput();
|
||||
}
|
||||
}}
|
||||
step={step}
|
||||
min={min}
|
||||
max={max}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
className={cn(
|
||||
"placeholder:text-muted-foreground h-full grow border-0 bg-transparent text-left text-text-bright outline-none ring-0 focus:border-0 focus:outline-none focus:ring-0 disabled:cursor-not-allowed",
|
||||
size.input,
|
||||
// Hide number input arrows
|
||||
"[type=number]:border-0 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
<div className={cn("flex items-center", size.gap)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleStepDown}
|
||||
onPointerDown={(e) => e.preventDefault()}
|
||||
disabled={disabled || isMinDisabled}
|
||||
aria-label={`Decrease by ${step}`}
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded border border-error/30 bg-error/20 transition",
|
||||
size.button,
|
||||
"hover:border-error/50 hover:bg-error/30",
|
||||
"disabled:cursor-not-allowed disabled:opacity-40",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-text-link"
|
||||
)}
|
||||
>
|
||||
<MinusIcon className={cn("text-error", size.icon)} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleStepUp}
|
||||
onPointerDown={(e) => e.preventDefault()}
|
||||
disabled={disabled || isMaxDisabled}
|
||||
aria-label={`Increase by ${step}`}
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded border border-success/30 bg-success/10 transition",
|
||||
size.button,
|
||||
"hover:border-success/40 hover:bg-success/20",
|
||||
"disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-text-link"
|
||||
)}
|
||||
>
|
||||
<PlusIcon className={cn("text-success", size.icon)} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,10 @@ const paragraphVariants = {
|
||||
text: "font-sans text-sm font-normal text-text-bright",
|
||||
spacing: "mb-2",
|
||||
},
|
||||
"small/dimmed": {
|
||||
text: "font-sans text-sm font-normal text-text-dimmed",
|
||||
spacing: "mb-2",
|
||||
},
|
||||
"extra-small": {
|
||||
text: "font-sans text-xs font-normal text-text-dimmed",
|
||||
spacing: "mb-1.5",
|
||||
@@ -25,6 +29,14 @@ const paragraphVariants = {
|
||||
text: "font-sans text-xs font-normal text-text-bright",
|
||||
spacing: "mb-1.5",
|
||||
},
|
||||
"extra-small/dimmed": {
|
||||
text: "font-sans text-xs font-normal text-text-dimmed",
|
||||
spacing: "mb-1.5",
|
||||
},
|
||||
"extra-small/dimmed/mono": {
|
||||
text: "font-mono text-xs font-normal text-text-dimmed",
|
||||
spacing: "mb-1.5",
|
||||
},
|
||||
"extra-small/mono": {
|
||||
text: "font-mono text-xs font-normal text-text-dimmed",
|
||||
spacing: "mb-1.5",
|
||||
|
||||
@@ -5,9 +5,10 @@ import { EllipsisVerticalIcon } from "@heroicons/react/24/solid";
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||
import * as React from "react";
|
||||
import { DropdownIcon } from "~/assets/icons/DropdownIcon";
|
||||
import { Link } from "@remix-run/react";
|
||||
import * as useShortcutKeys from "~/hooks/useShortcutKeys";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { type ButtonContentPropsType, LinkButton } from "./Buttons";
|
||||
import { type ButtonContentPropsType, Button, ButtonContent } from "./Buttons";
|
||||
import { Paragraph, type ParagraphVariant } from "./Paragraph";
|
||||
import { ShortcutKey } from "./ShortcutKey";
|
||||
import { type RenderIcon } from "./Icon";
|
||||
@@ -52,42 +53,78 @@ function PopoverSectionHeader({
|
||||
);
|
||||
}
|
||||
|
||||
function PopoverMenuItem({
|
||||
to,
|
||||
icon,
|
||||
title,
|
||||
isSelected,
|
||||
variant = { variant: "small-menu-item" },
|
||||
leadingIconClassName,
|
||||
className,
|
||||
}: {
|
||||
to: string;
|
||||
icon?: RenderIcon;
|
||||
title: React.ReactNode;
|
||||
isSelected?: boolean;
|
||||
variant?: ButtonContentPropsType;
|
||||
leadingIconClassName?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<LinkButton
|
||||
to={to}
|
||||
variant={variant.variant}
|
||||
LeadingIcon={icon}
|
||||
leadingIconClassName={leadingIconClassName}
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
TrailingIcon={isSelected ? CheckIcon : undefined}
|
||||
className={cn(
|
||||
const PopoverMenuItem = React.forwardRef<
|
||||
HTMLButtonElement | HTMLAnchorElement,
|
||||
{
|
||||
to?: string;
|
||||
icon?: RenderIcon;
|
||||
title: React.ReactNode;
|
||||
isSelected?: boolean;
|
||||
variant?: ButtonContentPropsType;
|
||||
leadingIconClassName?: string;
|
||||
className?: string;
|
||||
onClick?: React.MouseEventHandler;
|
||||
disabled?: boolean;
|
||||
}
|
||||
>(
|
||||
(
|
||||
{
|
||||
to,
|
||||
icon,
|
||||
title,
|
||||
isSelected,
|
||||
variant = { variant: "small-menu-item" },
|
||||
leadingIconClassName,
|
||||
className,
|
||||
onClick,
|
||||
disabled,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const contentProps = {
|
||||
variant: variant.variant,
|
||||
LeadingIcon: icon,
|
||||
leadingIconClassName,
|
||||
fullWidth: true,
|
||||
textAlignLeft: true,
|
||||
TrailingIcon: isSelected ? CheckIcon : undefined,
|
||||
className: cn(
|
||||
"group-hover:bg-charcoal-700",
|
||||
isSelected ? "bg-charcoal-750 group-hover:bg-charcoal-600/50" : undefined,
|
||||
className
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</LinkButton>
|
||||
);
|
||||
}
|
||||
),
|
||||
} as const;
|
||||
|
||||
if (to) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
ref={ref as React.Ref<HTMLAnchorElement>}
|
||||
className={cn("group/button focus-custom", contentProps.fullWidth ? "w-full" : "")}
|
||||
onClick={onClick as any}
|
||||
>
|
||||
<ButtonContent {...contentProps}>{title}</ButtonContent>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
ref={ref as React.Ref<HTMLButtonElement>}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"group/button outline-none focus-custom",
|
||||
contentProps.fullWidth ? "w-full" : ""
|
||||
)}
|
||||
>
|
||||
<ButtonContent {...contentProps}>{title}</ButtonContent>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
);
|
||||
PopoverMenuItem.displayName = "PopoverMenuItem";
|
||||
|
||||
function PopoverCustomTrigger({
|
||||
isOpen,
|
||||
|
||||
@@ -39,7 +39,7 @@ const variants = {
|
||||
description: {
|
||||
button:
|
||||
"w-full p-2.5 hover:data-[state=checked]:bg-white/[4%] data-[state=checked]:bg-white/[4%] transition data-[disabled]:opacity-70 hover:border-charcoal-600 border-charcoal-650 hover:data-[state=checked]:border-charcoal-600 border rounded-md",
|
||||
label: "text-text-bright font-semibold -mt-1 text-left text-sm",
|
||||
label: "text-text-bright font-semibold -mt-0.5 text-left text-sm",
|
||||
description: "text-text-dimmed -mt-0 text-left",
|
||||
inputPosition: "mt-0",
|
||||
icon: "w-8 h-8 mb-2",
|
||||
|
||||
@@ -9,11 +9,11 @@ import { useOperatingSystem } from "./OperatingSystemProvider";
|
||||
import { KeyboardEnterIcon } from "~/assets/icons/KeyboardEnterIcon";
|
||||
|
||||
const medium =
|
||||
"text-[0.75rem] font-medium min-w-[17px] rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1.5 border border-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-dimmed/60 transition uppercase";
|
||||
"justify-center min-w-[1.25rem] min-h-[1.25rem] text-[0.65rem] font-mono font-medium rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1.5 border border-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-dimmed/60 transition uppercase";
|
||||
|
||||
export const variants = {
|
||||
small:
|
||||
"text-[0.6rem] font-medium min-w-[17px] rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1 border border-text-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-text-dimmed/60 transition uppercase",
|
||||
"justify-center text-[0.6rem] font-mono font-medium min-w-[1rem] min-h-[1rem] rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1 border border-text-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-text-dimmed/60 transition uppercase",
|
||||
medium: cn(medium, "group-hover:border-charcoal-550"),
|
||||
"medium/bright": cn(medium, "bg-charcoal-750 text-text-bright border-charcoal-650"),
|
||||
};
|
||||
@@ -57,7 +57,7 @@ export function ShortcutKey({ shortcut, variant, className }: ShortcutKeyProps)
|
||||
function keyString(key: string, isMac: boolean, variant: "small" | "medium" | "medium/bright") {
|
||||
key = key.toLowerCase();
|
||||
|
||||
const className = variant === "small" ? "w-2.5 h-4" : "w-3 h-5";
|
||||
const className = variant === "small" ? "w-2.5 h-4" : "w-2.5 h-4.5";
|
||||
|
||||
switch (key) {
|
||||
case "enter":
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { NavLink } from "@remix-run/react";
|
||||
import { motion } from "framer-motion";
|
||||
import { ReactNode, useRef } from "react";
|
||||
import { ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys";
|
||||
import { type ReactNode, useRef } from "react";
|
||||
import { type ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { ShortcutKey } from "./ShortcutKey";
|
||||
|
||||
export type Variants = "underline" | "pipe-divider" | "segmented";
|
||||
|
||||
export type TabsProps = {
|
||||
tabs: {
|
||||
label: string;
|
||||
@@ -12,13 +14,14 @@ export type TabsProps = {
|
||||
}[];
|
||||
className?: string;
|
||||
layoutId: string;
|
||||
variant?: Variants;
|
||||
};
|
||||
|
||||
export function Tabs({ tabs, className, layoutId }: TabsProps) {
|
||||
export function Tabs({ tabs, className, layoutId, variant = "underline" }: TabsProps) {
|
||||
return (
|
||||
<TabContainer className={className}>
|
||||
<TabContainer className={className} variant={variant}>
|
||||
{tabs.map((tab, index) => (
|
||||
<TabLink key={index} to={tab.to} layoutId={layoutId}>
|
||||
<TabLink key={index} to={tab.to} layoutId={layoutId} variant={variant}>
|
||||
{tab.label}
|
||||
</TabLink>
|
||||
))}
|
||||
@@ -26,23 +29,107 @@ export function Tabs({ tabs, className, layoutId }: TabsProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function TabContainer({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<div className={cn(`flex flex-row gap-x-6 border-b border-grid-bright`, className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
export function TabContainer({
|
||||
children,
|
||||
className,
|
||||
variant = "underline",
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
variant?: Variants;
|
||||
}) {
|
||||
if (variant === "segmented") {
|
||||
return (
|
||||
<div
|
||||
className={cn("relative flex h-10 items-center rounded bg-charcoal-700/50 p-1", className)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "underline") {
|
||||
return (
|
||||
<div className={cn(`flex gap-x-6 border-b border-grid-bright`, className)}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className={cn(`flex`, className)}>{children}</div>;
|
||||
}
|
||||
|
||||
export function TabLink({
|
||||
to,
|
||||
children,
|
||||
layoutId,
|
||||
variant = "underline",
|
||||
}: {
|
||||
to: string;
|
||||
children: ReactNode;
|
||||
layoutId: string;
|
||||
variant?: Variants;
|
||||
}) {
|
||||
if (variant === "segmented") {
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
className="group relative flex h-full grow items-center justify-center focus-custom"
|
||||
end
|
||||
>
|
||||
{({ isActive, isPending }) => {
|
||||
const active = isActive || isPending;
|
||||
return (
|
||||
<>
|
||||
<div className="relative z-10 flex h-full w-full items-center justify-center px-3 py-[0.13rem]">
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm transition duration-200",
|
||||
active
|
||||
? "text-text-bright"
|
||||
: "text-text-dimmed transition group-hover:text-text-bright"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
</div>
|
||||
{active && (
|
||||
<motion.div
|
||||
layoutId={layoutId}
|
||||
transition={{ duration: 0.4, type: "spring" }}
|
||||
className="absolute inset-0 rounded-[2px] border border-charcoal-500/50 bg-charcoal-600"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "pipe-divider") {
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
className="group flex flex-col items-center border-r border-charcoal-700 px-2 pt-1 focus-custom first:pl-0 last:border-none"
|
||||
end
|
||||
>
|
||||
{({ isActive, isPending }) => {
|
||||
const active = isActive || isPending;
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm transition duration-200",
|
||||
active ? "text-text-link" : "text-text-dimmed transition hover:text-text-bright"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}}
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
// underline variant (default)
|
||||
return (
|
||||
<NavLink to={to} className="group flex flex-col items-center pt-1 focus-custom" end>
|
||||
{({ isActive, isPending }) => {
|
||||
@@ -51,13 +138,19 @@ export function TabLink({
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm transition duration-200",
|
||||
isActive || isPending ? "text-text-bright" : "text-text-bright"
|
||||
isActive || isPending
|
||||
? "text-text-bright"
|
||||
: "text-text-dimmed hover:text-text-bright"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
{isActive || isPending ? (
|
||||
<motion.div layoutId={layoutId} className="mt-1 h-0.5 w-full bg-indigo-500" />
|
||||
<motion.div
|
||||
layoutId={layoutId}
|
||||
transition={{ type: "spring", stiffness: 500, damping: 30 }}
|
||||
className="mt-1 h-0.5 w-full bg-indigo-500"
|
||||
/>
|
||||
) : (
|
||||
<div className="mt-1 h-0.5 w-full bg-charcoal-500 opacity-0 transition duration-200 group-hover:opacity-100" />
|
||||
)}
|
||||
@@ -106,17 +199,18 @@ export function TabButton({
|
||||
<>
|
||||
<div className="flex items-center gap-1">
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm transition duration-200",
|
||||
isActive ? "text-text-bright" : "text-text-bright"
|
||||
)}
|
||||
className={"text-sm transition duration-200 text-text-bright"}
|
||||
>
|
||||
{props.children}
|
||||
</span>
|
||||
{shortcut && <ShortcutKey className={cn("")} shortcut={shortcut} variant={"small"} />}
|
||||
</div>
|
||||
{isActive ? (
|
||||
<motion.div layoutId={layoutId} className="mt-1 h-0.5 w-full bg-indigo-500" />
|
||||
<motion.div
|
||||
layoutId={layoutId}
|
||||
transition={{ type: "spring", stiffness: 500, damping: 30 }}
|
||||
className="mt-1 h-0.5 w-full bg-indigo-500"
|
||||
/>
|
||||
) : (
|
||||
<div className="mt-1 h-0.5 w-full bg-charcoal-500 opacity-0 transition duration-200 group-hover:opacity-100" />
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { Link } from "@remix-run/react";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Icon, type RenderIcon } from "./Icon";
|
||||
import { useRef } from "react";
|
||||
import { type ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys";
|
||||
import { ShortcutKey } from "./ShortcutKey";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./Tooltip";
|
||||
|
||||
const variations = {
|
||||
primary:
|
||||
@@ -17,6 +21,9 @@ type TextLinkProps = {
|
||||
trailingIconClassName?: string;
|
||||
variant?: keyof typeof variations;
|
||||
children: React.ReactNode;
|
||||
shortcut?: ShortcutDefinition;
|
||||
hideShortcutKey?: boolean;
|
||||
tooltip?: React.ReactNode;
|
||||
} & React.AnchorHTMLAttributes<HTMLAnchorElement>;
|
||||
|
||||
export function TextLink({
|
||||
@@ -27,20 +34,61 @@ export function TextLink({
|
||||
trailingIcon,
|
||||
trailingIconClassName,
|
||||
variant = "primary",
|
||||
shortcut,
|
||||
hideShortcutKey,
|
||||
tooltip,
|
||||
...props
|
||||
}: TextLinkProps) {
|
||||
const innerRef = useRef<HTMLAnchorElement>(null);
|
||||
const classes = variations[variant];
|
||||
return to ? (
|
||||
<Link to={to} className={cn(classes, className)} {...props}>
|
||||
|
||||
if (shortcut) {
|
||||
useShortcutKeys({
|
||||
shortcut: shortcut,
|
||||
action: () => {
|
||||
if (innerRef.current) {
|
||||
innerRef.current.click();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const renderShortcutKey = () =>
|
||||
shortcut &&
|
||||
!hideShortcutKey && <ShortcutKey className="ml-1.5" shortcut={shortcut} variant="small" />;
|
||||
|
||||
const linkContent = (
|
||||
<>
|
||||
{children}{" "}
|
||||
{trailingIcon && <Icon icon={trailingIcon} className={cn("size-4", trailingIconClassName)} />}
|
||||
{shortcut && !tooltip && renderShortcutKey()}
|
||||
</>
|
||||
);
|
||||
|
||||
const linkElement = to ? (
|
||||
<Link ref={innerRef} to={to} className={cn(classes, className)} {...props}>
|
||||
{linkContent}
|
||||
</Link>
|
||||
) : href ? (
|
||||
<a href={href} className={cn(classes, className)} {...props}>
|
||||
{children}{" "}
|
||||
{trailingIcon && <Icon icon={trailingIcon} className={cn("size-4", trailingIconClassName)} />}
|
||||
<a ref={innerRef} href={href} className={cn(classes, className)} {...props}>
|
||||
{linkContent}
|
||||
</a>
|
||||
) : (
|
||||
<span>Need to define a path or href</span>
|
||||
);
|
||||
|
||||
if (tooltip) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{linkElement}</TooltipTrigger>
|
||||
<TooltipContent className="text-dimmed flex items-center gap-3 py-1.5 pl-2.5 pr-3 text-xs">
|
||||
{tooltip} {shortcut && renderShortcutKey()}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return linkElement;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { ExclamationCircleIcon, XMarkIcon } from "@heroicons/react/20/solid";
|
||||
import { EnvelopeIcon, ExclamationCircleIcon, XMarkIcon } from "@heroicons/react/20/solid";
|
||||
import { CheckCircleIcon } from "@heroicons/react/24/solid";
|
||||
import { Toaster, toast } from "sonner";
|
||||
|
||||
import { useTypedLoaderData } from "remix-typedjson";
|
||||
import { loader } from "~/root";
|
||||
import { useSearchParams } from "@remix-run/react";
|
||||
import { useEffect } from "react";
|
||||
import { Paragraph } from "./Paragraph";
|
||||
import { useTypedLoaderData } from "remix-typedjson";
|
||||
import { Toaster, toast } from "sonner";
|
||||
import { type ToastMessageAction } from "~/models/message.server";
|
||||
import { type loader } from "~/root";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Button, LinkButton } from "./Buttons";
|
||||
import { Header2 } from "./Headers";
|
||||
import { Paragraph } from "./Paragraph";
|
||||
|
||||
const defaultToastDuration = 5000;
|
||||
const permanentToastDuration = 60 * 60 * 24 * 1000;
|
||||
@@ -19,9 +22,22 @@ export function Toast() {
|
||||
}
|
||||
const { message, type, options } = toastMessage;
|
||||
|
||||
toast.custom((t) => <ToastUI variant={type} message={message} t={t as string} />, {
|
||||
duration: options.ephemeral ? defaultToastDuration : permanentToastDuration,
|
||||
});
|
||||
const ephemeral = options.action ? false : options.ephemeral;
|
||||
|
||||
toast.custom(
|
||||
(t) => (
|
||||
<ToastUI
|
||||
variant={type}
|
||||
message={message}
|
||||
t={t as string}
|
||||
title={options.title}
|
||||
action={options.action}
|
||||
/>
|
||||
),
|
||||
{
|
||||
duration: ephemeral ? defaultToastDuration : permanentToastDuration,
|
||||
}
|
||||
);
|
||||
}, [toastMessage]);
|
||||
|
||||
return <Toaster />;
|
||||
@@ -32,11 +48,15 @@ export function ToastUI({
|
||||
message,
|
||||
t,
|
||||
toastWidth = 356, // Default width, matches what sonner provides by default
|
||||
title,
|
||||
action,
|
||||
}: {
|
||||
variant: "error" | "success";
|
||||
message: string;
|
||||
t: string;
|
||||
toastWidth?: string | number;
|
||||
title?: string;
|
||||
action?: ToastMessageAction;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
@@ -51,13 +71,19 @@ export function ToastUI({
|
||||
>
|
||||
<div className="flex w-full items-start gap-2 rounded-lg p-3">
|
||||
{variant === "success" ? (
|
||||
<CheckCircleIcon className="mt-1 size-6 min-w-6 text-success" />
|
||||
<CheckCircleIcon className="mt-1 size-4 min-w-4 text-success" />
|
||||
) : (
|
||||
<ExclamationCircleIcon className="mt-1 size-6 min-w-6 text-error" />
|
||||
<ExclamationCircleIcon className="mt-1 size-4 min-w-4 text-error" />
|
||||
)}
|
||||
<Paragraph className="py-1 text-text-bright">{message}</Paragraph>
|
||||
<div className="flex flex-col">
|
||||
{title && <Header2 className="pt-0">{title}</Header2>}
|
||||
<Paragraph variant="small/dimmed" className="pb-1 pt-0.5">
|
||||
{message}
|
||||
</Paragraph>
|
||||
<Action action={action} toastId={t} className="my-2" />
|
||||
</div>
|
||||
<button
|
||||
className="hover:bg-midnight-800 ms-auto rounded p-2 text-text-dimmed transition hover:text-text-bright"
|
||||
className="hover:bg-midnight-800 -mr-1 -mt-1 ms-auto rounded p-2 text-text-dimmed transition hover:text-text-bright"
|
||||
onClick={() => toast.dismiss(t)}
|
||||
>
|
||||
<XMarkIcon className="size-4" />
|
||||
@@ -66,3 +92,49 @@ export function ToastUI({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Action({
|
||||
action,
|
||||
toastId,
|
||||
className,
|
||||
}: {
|
||||
action?: ToastMessageAction;
|
||||
toastId: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const [_, setSearchParams] = useSearchParams();
|
||||
|
||||
if (!action) return null;
|
||||
|
||||
switch (action.action.type) {
|
||||
case "link": {
|
||||
return (
|
||||
<LinkButton
|
||||
className={className}
|
||||
variant={action.variant ?? "secondary/small"}
|
||||
to={action.action.path}
|
||||
>
|
||||
{action.label}
|
||||
</LinkButton>
|
||||
);
|
||||
}
|
||||
case "help": {
|
||||
const feedbackType = action.action.feedbackType;
|
||||
return (
|
||||
<Button
|
||||
className={className}
|
||||
variant={action.variant ?? "secondary/small"}
|
||||
LeadingIcon={EnvelopeIcon}
|
||||
onClick={() => {
|
||||
setSearchParams({
|
||||
feedbackPanel: feedbackType,
|
||||
});
|
||||
toast.dismiss(toastId);
|
||||
}}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { cn } from "~/utils/cn";
|
||||
const variantClasses = {
|
||||
basic:
|
||||
"bg-background-bright border border-grid-bright rounded px-3 py-2 text-sm text-text-bright shadow-md fade-in-50",
|
||||
dark: "bg-background-dimmed border border-grid-bright rounded px-3 py-2 text-sm text-text-bright shadow-md fade-in-50",
|
||||
dark: "bg-background-dimmed border border-grid-bright rounded px-3 py-2 text-sm text-text-bright shadow-md fade-in-50"
|
||||
};
|
||||
|
||||
type Variant = keyof typeof variantClasses;
|
||||
@@ -64,6 +64,8 @@ function SimpleTooltip({
|
||||
buttonStyle,
|
||||
asChild = false,
|
||||
sideOffset,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
button: React.ReactNode;
|
||||
content: React.ReactNode;
|
||||
@@ -76,10 +78,12 @@ function SimpleTooltip({
|
||||
buttonStyle?: React.CSSProperties;
|
||||
asChild?: boolean;
|
||||
sideOffset?: number;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<TooltipProvider disableHoverableContent={disableHoverableContent}>
|
||||
<Tooltip>
|
||||
<Tooltip open={open} onOpenChange={onOpenChange}>
|
||||
<TooltipTrigger
|
||||
tabIndex={-1}
|
||||
className={cn("h-fit", buttonClassName)}
|
||||
|
||||
@@ -423,6 +423,10 @@ export function useTree<TData, TFilterValue>({
|
||||
}
|
||||
case "Left":
|
||||
case "ArrowLeft": {
|
||||
if (e.metaKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
const selected = selectedIdFromState(state.nodes);
|
||||
|
||||
@@ -39,14 +39,14 @@ export function concreteStateFromInput({
|
||||
}
|
||||
const nodes = concreteStateFromPartialState(tree, state);
|
||||
|
||||
return {
|
||||
return applyFilterToState({
|
||||
tree,
|
||||
nodes,
|
||||
changes: { selectedId },
|
||||
filter,
|
||||
filteredNodes: nodes,
|
||||
visibleNodeIds: visibleNodes(tree, nodes).map((node) => node.id),
|
||||
};
|
||||
visibleNodeIds: [],
|
||||
});
|
||||
}
|
||||
|
||||
export function concreteStateFromPartialState<TData>(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CheckCircleIcon, XCircleIcon } from "@heroicons/react/20/solid";
|
||||
import { BatchTaskRunStatus } from "@trigger.dev/database";
|
||||
import type { BatchTaskRunStatus } from "@trigger.dev/database";
|
||||
import assertNever from "assert-never";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ArrowPathIcon, CheckCircleIcon, NoSymbolIcon } from "@heroicons/react/20/solid";
|
||||
import { BulkActionStatus, type BulkActionType } from "@trigger.dev/database";
|
||||
import type { BulkActionStatus, BulkActionType } from "@trigger.dev/database";
|
||||
import assertNever from "assert-never";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
@@ -2,9 +2,10 @@ import {
|
||||
CheckCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
NoSymbolIcon,
|
||||
RectangleStackIcon,
|
||||
XCircleIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { WorkerDeploymentStatus } from "@trigger.dev/database";
|
||||
import type { WorkerDeploymentStatus } from "@trigger.dev/database";
|
||||
import assertNever from "assert-never";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { cn } from "~/utils/cn";
|
||||
@@ -49,6 +50,10 @@ export function DeploymentStatusIcon({
|
||||
}) {
|
||||
switch (status) {
|
||||
case "PENDING":
|
||||
return (
|
||||
<RectangleStackIcon className={cn(deploymentStatusClassNameColor(status), className)} />
|
||||
);
|
||||
case "INSTALLING":
|
||||
case "BUILDING":
|
||||
case "DEPLOYING":
|
||||
return <Spinner className={cn(deploymentStatusClassNameColor(status), className)} />;
|
||||
@@ -73,6 +78,8 @@ export function DeploymentStatusIcon({
|
||||
export function deploymentStatusClassNameColor(status: WorkerDeploymentStatus): string {
|
||||
switch (status) {
|
||||
case "PENDING":
|
||||
return "text-charcoal-500";
|
||||
case "INSTALLING":
|
||||
case "BUILDING":
|
||||
case "DEPLOYING":
|
||||
return "text-pending";
|
||||
@@ -92,7 +99,9 @@ export function deploymentStatusClassNameColor(status: WorkerDeploymentStatus):
|
||||
export function deploymentStatusTitle(status: WorkerDeploymentStatus, isBuilt: boolean): string {
|
||||
switch (status) {
|
||||
case "PENDING":
|
||||
return "Pending…";
|
||||
return "Queued…";
|
||||
case "INSTALLING":
|
||||
return "Installing…";
|
||||
case "BUILDING":
|
||||
return "Building…";
|
||||
case "DEPLOYING":
|
||||
@@ -121,17 +130,22 @@ export function deploymentStatusTitle(status: WorkerDeploymentStatus, isBuilt: b
|
||||
|
||||
// PENDING and CANCELED are not used so are ommited from the UI
|
||||
export const deploymentStatuses: WorkerDeploymentStatus[] = [
|
||||
"PENDING",
|
||||
"INSTALLING",
|
||||
"BUILDING",
|
||||
"DEPLOYING",
|
||||
"DEPLOYED",
|
||||
"FAILED",
|
||||
"TIMED_OUT",
|
||||
"CANCELED",
|
||||
];
|
||||
|
||||
export function deploymentStatusDescription(status: WorkerDeploymentStatus): string {
|
||||
switch (status) {
|
||||
case "PENDING":
|
||||
return "The deployment is queued and waiting to be processed.";
|
||||
case "INSTALLING":
|
||||
return "The project dependencies are being installed.";
|
||||
case "BUILDING":
|
||||
return "The code is being built and prepared for deployment.";
|
||||
case "DEPLOYING":
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
import { ArrowPathIcon } from "@heroicons/react/20/solid";
|
||||
import { DialogClose } from "@radix-ui/react-dialog";
|
||||
import { Form, useNavigation } from "@remix-run/react";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import {
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
} from "~/components/primitives/Dialog";
|
||||
import { SpinnerWhite } from "~/components/primitives/Spinner";
|
||||
|
||||
type RollbackDeploymentDialogProps = {
|
||||
projectId: string;
|
||||
deploymentShortCode: string;
|
||||
redirectPath: string;
|
||||
};
|
||||
|
||||
export function RollbackDeploymentDialog({
|
||||
projectId,
|
||||
deploymentShortCode,
|
||||
redirectPath,
|
||||
}: RollbackDeploymentDialogProps) {
|
||||
const navigation = useNavigation();
|
||||
|
||||
const formAction = `/resources/${projectId}/deployments/${deploymentShortCode}/rollback`;
|
||||
const isLoading = navigation.formAction === formAction;
|
||||
|
||||
return (
|
||||
<DialogContent key="rollback">
|
||||
<DialogHeader>Rollback to this deployment?</DialogHeader>
|
||||
<DialogDescription>
|
||||
This deployment will become the default for all future runs. Tasks triggered but not
|
||||
included in this deploy will remain queued until you roll back to or create a new deployment
|
||||
with these tasks included.
|
||||
</DialogDescription>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="tertiary/medium">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Form
|
||||
action={`/resources/${projectId}/deployments/${deploymentShortCode}/rollback`}
|
||||
method="post"
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
name="redirectUrl"
|
||||
value={redirectPath}
|
||||
variant="primary/medium"
|
||||
LeadingIcon={isLoading ? SpinnerWhite : ArrowPathIcon}
|
||||
disabled={isLoading}
|
||||
shortcut={{ modifiers: ["mod"], key: "enter" }}
|
||||
>
|
||||
{isLoading ? "Rolling back..." : "Rollback deployment"}
|
||||
</Button>
|
||||
</Form>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
export function PromoteDeploymentDialog({
|
||||
projectId,
|
||||
deploymentShortCode,
|
||||
redirectPath,
|
||||
}: RollbackDeploymentDialogProps) {
|
||||
const navigation = useNavigation();
|
||||
|
||||
const formAction = `/resources/${projectId}/deployments/${deploymentShortCode}/promote`;
|
||||
const isLoading = navigation.formAction === formAction;
|
||||
|
||||
return (
|
||||
<DialogContent key="promote">
|
||||
<DialogHeader>Promote this deployment?</DialogHeader>
|
||||
<DialogDescription>
|
||||
This deployment will become the default for all future runs not explicitly tied to a
|
||||
specific deployment.
|
||||
</DialogDescription>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="tertiary/medium">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Form
|
||||
action={`/resources/${projectId}/deployments/${deploymentShortCode}/promote`}
|
||||
method="post"
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
name="redirectUrl"
|
||||
value={redirectPath}
|
||||
variant="primary/medium"
|
||||
LeadingIcon={isLoading ? SpinnerWhite : ArrowPathIcon}
|
||||
disabled={isLoading}
|
||||
shortcut={{ modifiers: ["mod"], key: "enter" }}
|
||||
>
|
||||
{isLoading ? "Promoting..." : "Promote deployment"}
|
||||
</Button>
|
||||
</Form>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
@@ -58,10 +58,10 @@ import { useProject } from "~/hooks/useProject";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import { type loader as queuesLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues";
|
||||
import { type loader as versionsLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.versions";
|
||||
import { type loader as tagsLoader } from "~/routes/resources.projects.$projectParam.runs.tags";
|
||||
import { type loader as tagsLoader } from "~/routes/resources.environments.$envId.runs.tags";
|
||||
import { Button } from "../../primitives/Buttons";
|
||||
import { BulkActionTypeCombo } from "./BulkAction";
|
||||
import { appliedSummary, FilterMenuProvider, TimeFilter } from "./SharedFilters";
|
||||
import { appliedSummary, FilterMenuProvider, TimeFilter, timeFilters } from "./SharedFilters";
|
||||
import { AIFilterInput } from "./AIFilterInput";
|
||||
import {
|
||||
allTaskRunStatuses,
|
||||
@@ -280,7 +280,7 @@ export function getRunFiltersFromSearchParams(
|
||||
bulkId: searchParams.get("bulkId") ?? undefined,
|
||||
tags:
|
||||
searchParams.getAll("tags").filter((v) => v.length > 0).length > 0
|
||||
? searchParams.getAll("tags").map((t) => decodeURIComponent(t))
|
||||
? searchParams.getAll("tags")
|
||||
: undefined,
|
||||
from: searchParams.get("from") ?? undefined,
|
||||
to: searchParams.get("to") ?? undefined,
|
||||
@@ -810,8 +810,8 @@ function TagsDropdown({
|
||||
searchValue: string;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const project = useProject();
|
||||
const { values, replace } = useSearchParams();
|
||||
const environment = useEnvironment();
|
||||
const { values, value, replace } = useSearchParams();
|
||||
|
||||
const handleChange = (values: string[]) => {
|
||||
clearSearchValue();
|
||||
@@ -822,6 +822,12 @@ function TagsDropdown({
|
||||
});
|
||||
};
|
||||
|
||||
const { period, from, to } = timeFilters({
|
||||
period: value("period"),
|
||||
from: value("from"),
|
||||
to: value("to"),
|
||||
});
|
||||
|
||||
const tagValues = values("tags").filter((v) => v !== "");
|
||||
const selected = tagValues.length > 0 ? tagValues : undefined;
|
||||
|
||||
@@ -830,25 +836,34 @@ function TagsDropdown({
|
||||
useEffect(() => {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (searchValue) {
|
||||
searchParams.set("name", encodeURIComponent(searchValue));
|
||||
searchParams.set("name", searchValue);
|
||||
}
|
||||
fetcher.load(`/resources/projects/${project.slug}/runs/tags?${searchParams}`);
|
||||
}, [searchValue]);
|
||||
if (period) {
|
||||
searchParams.set("period", period);
|
||||
}
|
||||
if (from) {
|
||||
searchParams.set("from", from.getTime().toString());
|
||||
}
|
||||
if (to) {
|
||||
searchParams.set("to", to.getTime().toString());
|
||||
}
|
||||
fetcher.load(`/resources/environments/${environment.id}/runs/tags?${searchParams}`);
|
||||
}, [environment.id, searchValue, period, from?.getTime(), to?.getTime()]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let items: string[] = [];
|
||||
if (searchValue === "") {
|
||||
items = selected ?? [];
|
||||
items = [...(selected ?? [])];
|
||||
}
|
||||
|
||||
if (fetcher.data === undefined) {
|
||||
return matchSorter(items, searchValue);
|
||||
}
|
||||
|
||||
items.push(...fetcher.data.tags.map((t) => t.name));
|
||||
items.push(...fetcher.data.tags);
|
||||
|
||||
return matchSorter(Array.from(new Set(items)), searchValue);
|
||||
}, [searchValue, fetcher.data]);
|
||||
}, [searchValue, fetcher.data, selected]);
|
||||
|
||||
return (
|
||||
<SelectProvider value={selected ?? []} setValue={handleChange} virtualFocus={true}>
|
||||
@@ -958,7 +973,7 @@ function QueuesDropdown({
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set("per_page", "25");
|
||||
if (searchValue) {
|
||||
searchParams.set("query", encodeURIComponent(s));
|
||||
searchParams.set("query", s);
|
||||
}
|
||||
fetcher.load(
|
||||
`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${
|
||||
@@ -1220,7 +1235,7 @@ function VersionsDropdown({
|
||||
(s) => {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (searchValue) {
|
||||
searchParams.set("query", encodeURIComponent(s));
|
||||
searchParams.set("query", s);
|
||||
}
|
||||
fetcher.load(
|
||||
`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${
|
||||
|
||||
@@ -20,6 +20,7 @@ import { TriggerIcon } from "~/assets/icons/TriggerIcon";
|
||||
import { PythonLogoIcon } from "~/assets/icons/PythonLogoIcon";
|
||||
import { TraceIcon } from "~/assets/icons/TraceIcon";
|
||||
import { WaitpointTokenIcon } from "~/assets/icons/WaitpointTokenIcon";
|
||||
import { StreamsIcon } from "~/assets/icons/StreamsIcon";
|
||||
|
||||
type TaskIconProps = {
|
||||
name: string | undefined;
|
||||
@@ -97,6 +98,7 @@ export function RunIcon({ name, className, spanName }: TaskIconProps) {
|
||||
return <RunFunctionIcon className={cn(className, "text-text-dimmed")} />;
|
||||
case "task-hook-init":
|
||||
case "task-hook-onStart":
|
||||
case "task-hook-onStartAttempt":
|
||||
case "task-hook-onSuccess":
|
||||
case "task-hook-onWait":
|
||||
case "task-hook-onResume":
|
||||
@@ -107,6 +109,8 @@ export function RunIcon({ name, className, spanName }: TaskIconProps) {
|
||||
case "task-hook-onFailure":
|
||||
case "task-hook-catchError":
|
||||
return <FunctionIcon className={cn(className, "text-error")} />;
|
||||
case "streams":
|
||||
return <StreamsIcon className={cn(className, "text-text-dimmed")} />;
|
||||
}
|
||||
|
||||
return <InformationCircleIcon className={cn(className, "text-text-dimmed")} />;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { MagnifyingGlassIcon, XMarkIcon } from "@heroicons/react/20/solid";
|
||||
import { useNavigate } from "@remix-run/react";
|
||||
import { type RuntimeEnvironment } from "@trigger.dev/database";
|
||||
import { useCallback } from "react";
|
||||
import { z } from "zod";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
TrashIcon,
|
||||
XCircleIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { type TaskRunStatus } from "@trigger.dev/database";
|
||||
import type { TaskRunStatus } from "@trigger.dev/database";
|
||||
import assertNever from "assert-never";
|
||||
import { HourglassIcon } from "lucide-react";
|
||||
import { TimedOutIcon } from "~/assets/icons/TimedOutIcon";
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
type NextRunListItem,
|
||||
} from "~/presenters/v3/NextRunListPresenter.server";
|
||||
import { formatCurrencyAccurate } from "~/utils/numberFormatter";
|
||||
import { docsPath, v3RunSpanPath, v3TestPath } from "~/utils/pathBuilder";
|
||||
import { docsPath, v3RunSpanPath, v3TestPath,v3TestTaskPath } from "~/utils/pathBuilder";
|
||||
import { DateTime } from "../../primitives/DateTime";
|
||||
import { Paragraph } from "../../primitives/Paragraph";
|
||||
import { Spinner } from "../../primitives/Spinner";
|
||||
@@ -55,6 +55,8 @@ import {
|
||||
filterableTaskRunStatuses,
|
||||
TaskRunStatusCombo,
|
||||
} from "./TaskRunStatus";
|
||||
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
|
||||
type RunsTableProps = {
|
||||
total: number;
|
||||
@@ -62,9 +64,11 @@ type RunsTableProps = {
|
||||
filters: NextRunListAppliedFilters;
|
||||
showJob?: boolean;
|
||||
runs: NextRunListItem[];
|
||||
rootOnlyDefault?: boolean;
|
||||
isLoading?: boolean;
|
||||
allowSelection?: boolean;
|
||||
variant?: TableVariant;
|
||||
disableAdjacentRows?: boolean;
|
||||
};
|
||||
|
||||
export function TaskRunsTable({
|
||||
@@ -72,6 +76,8 @@ export function TaskRunsTable({
|
||||
hasFilters,
|
||||
filters,
|
||||
runs,
|
||||
rootOnlyDefault,
|
||||
disableAdjacentRows = false,
|
||||
isLoading = false,
|
||||
allowSelection = false,
|
||||
variant = "dimmed",
|
||||
@@ -81,6 +87,12 @@ export function TaskRunsTable({
|
||||
const checkboxes = useRef<(HTMLInputElement | null)[]>([]);
|
||||
const { has, hasAll, select, deselect, toggle } = useSelectedItems(allowSelection);
|
||||
const { isManagedCloud } = useFeatures();
|
||||
const { value } = useSearchParams();
|
||||
const location = useOptimisticLocation();
|
||||
const rootOnly = value("rootOnly") ? `` : `rootOnly=${rootOnlyDefault}`;
|
||||
const search = rootOnly ? `${rootOnly}&${location.search}` : location.search;
|
||||
/** TableState has to be encoded as a separate URI component, so it's merged under one, 'tableState' param */
|
||||
const tableStateParam = disableAdjacentRows ? '' : encodeURIComponent(search);
|
||||
|
||||
const showCompute = isManagedCloud;
|
||||
|
||||
@@ -293,16 +305,20 @@ export function TaskRunsTable({
|
||||
<BlankState isLoading={isLoading} filters={filters} />
|
||||
) : (
|
||||
runs.map((run, index) => {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (tableStateParam) {
|
||||
searchParams.set("tableState", tableStateParam);
|
||||
}
|
||||
const path = v3RunSpanPath(organization, project, run.environment, run, {
|
||||
spanId: run.spanId,
|
||||
});
|
||||
}, searchParams);
|
||||
return (
|
||||
<TableRow key={run.id}>
|
||||
{allowSelection && (
|
||||
<TableCell className="pl-3 pr-0">
|
||||
<Checkbox
|
||||
checked={has(run.friendlyId)}
|
||||
onChange={(element) => {
|
||||
onChange={() => {
|
||||
toggle(run.friendlyId);
|
||||
}}
|
||||
ref={(r) => {
|
||||
@@ -565,6 +581,8 @@ function BlankState({ isLoading, filters }: Pick<RunsTableProps, "isLoading" | "
|
||||
if (isLoading) return <TableBlankRow colSpan={15}></TableBlankRow>;
|
||||
|
||||
const { tasks, from, to, ...otherFilters } = filters;
|
||||
const singleTaskFromFilters = filters.tasks.length === 1 ? filters.tasks[0] : null;
|
||||
const testPath = singleTaskFromFilters ? v3TestTaskPath(organization, project, environment, {taskIdentifier: singleTaskFromFilters}) : v3TestPath(organization, project, environment);
|
||||
|
||||
if (
|
||||
filters.tasks.length === 1 &&
|
||||
@@ -579,7 +597,7 @@ function BlankState({ isLoading, filters }: Pick<RunsTableProps, "isLoading" | "
|
||||
</Paragraph>
|
||||
<div className="mt-6 flex items-center justify-center gap-2">
|
||||
<LinkButton
|
||||
to={v3TestPath(organization, project, environment)}
|
||||
to={testPath}
|
||||
variant="tertiary/medium"
|
||||
LeadingIcon={BeakerIcon}
|
||||
className="inline-flex"
|
||||
@@ -620,7 +638,7 @@ function BlankState({ isLoading, filters }: Pick<RunsTableProps, "isLoading" | "
|
||||
<LinkButton
|
||||
LeadingIcon={BeakerIcon}
|
||||
variant="tertiary/medium"
|
||||
to={v3TestPath(organization, project, environment)}
|
||||
to={testPath}
|
||||
>
|
||||
Run a test
|
||||
</LinkButton>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ClockIcon } from "@heroicons/react/20/solid";
|
||||
import { type TaskTriggerSource } from "@trigger.dev/database";
|
||||
import type { TaskTriggerSource } from "@trigger.dev/database";
|
||||
import { TaskIconSmall } from "~/assets/icons/TaskIcon";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
|
||||
@@ -330,7 +330,7 @@ function TagsDropdown({
|
||||
useEffect(() => {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (searchValue) {
|
||||
searchParams.set("name", encodeURIComponent(searchValue));
|
||||
searchParams.set("name", searchValue);
|
||||
}
|
||||
fetcher.load(
|
||||
`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/waitpoints/tags?${searchParams}`
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import {
|
||||
Prisma,
|
||||
PrismaClient,
|
||||
PrismaClientOrTransaction,
|
||||
PrismaReplicaClient,
|
||||
PrismaTransactionClient,
|
||||
PrismaTransactionOptions,
|
||||
$transaction as transac,
|
||||
type PrismaClientOrTransaction,
|
||||
type PrismaReplicaClient,
|
||||
type PrismaTransactionClient,
|
||||
type PrismaTransactionOptions,
|
||||
} from "@trigger.dev/database";
|
||||
import invariant from "tiny-invariant";
|
||||
import { z } from "zod";
|
||||
@@ -12,9 +13,9 @@ import { env } from "./env.server";
|
||||
import { logger } from "./services/logger.server";
|
||||
import { isValidDatabaseUrl } from "./utils/db";
|
||||
import { singleton } from "./utils/singleton";
|
||||
import { $transaction as transac } from "@trigger.dev/database";
|
||||
import { startActiveSpan } from "./v3/tracer.server";
|
||||
import { Span } from "@opentelemetry/api";
|
||||
import { queryPerformanceMonitor } from "./utils/queryPerformanceMonitor.server";
|
||||
|
||||
export type {
|
||||
PrismaTransactionClient,
|
||||
@@ -153,13 +154,19 @@ function getClient() {
|
||||
},
|
||||
]
|
||||
: []) satisfies Prisma.LogDefinition[]),
|
||||
// verbose
|
||||
...((process.env.VERBOSE_PRISMA_LOGS === "1"
|
||||
// Query performance monitoring
|
||||
...((process.env.VERBOSE_PRISMA_LOGS === "1" ||
|
||||
process.env.VERY_SLOW_QUERY_THRESHOLD_MS !== undefined
|
||||
? [
|
||||
{
|
||||
emit: "event",
|
||||
level: "query",
|
||||
},
|
||||
]
|
||||
: []) satisfies Prisma.LogDefinition[]),
|
||||
// verbose
|
||||
...((process.env.VERBOSE_PRISMA_LOGS === "1"
|
||||
? [
|
||||
{
|
||||
emit: "stdout",
|
||||
level: "query",
|
||||
@@ -201,10 +208,16 @@ function getClient() {
|
||||
message: log.message,
|
||||
target: log.target,
|
||||
},
|
||||
ignoreError: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Add query performance monitoring
|
||||
client.$on("query", (log) => {
|
||||
queryPerformanceMonitor.onQuery("writer", log);
|
||||
});
|
||||
|
||||
// connect eagerly
|
||||
client.$connect();
|
||||
|
||||
@@ -264,13 +277,19 @@ function getReplicaClient() {
|
||||
},
|
||||
]
|
||||
: []) satisfies Prisma.LogDefinition[]),
|
||||
// verbose
|
||||
...((process.env.VERBOSE_PRISMA_LOGS === "1"
|
||||
// Query performance monitoring
|
||||
...((process.env.VERBOSE_PRISMA_LOGS === "1" ||
|
||||
process.env.VERY_SLOW_QUERY_THRESHOLD_MS !== undefined
|
||||
? [
|
||||
{
|
||||
emit: "event",
|
||||
level: "query",
|
||||
},
|
||||
]
|
||||
: []) satisfies Prisma.LogDefinition[]),
|
||||
// verbose
|
||||
...((process.env.VERBOSE_PRISMA_LOGS === "1"
|
||||
? [
|
||||
{
|
||||
emit: "stdout",
|
||||
level: "query",
|
||||
@@ -316,6 +335,11 @@ function getReplicaClient() {
|
||||
});
|
||||
}
|
||||
|
||||
// Add query performance monitoring for replica client
|
||||
replicaClient.$on("query", (log) => {
|
||||
queryPerformanceMonitor.onQuery("replica", log);
|
||||
});
|
||||
|
||||
// connect eagerly
|
||||
replicaClient.$connect();
|
||||
|
||||
|
||||
@@ -240,6 +240,7 @@ import { logger } from "./services/logger.server";
|
||||
import { Prisma } from "./db.server";
|
||||
import { registerRunEngineEventBusHandlers } from "./v3/runEngineHandlers.server";
|
||||
import { remoteBuildsEnabled } from "./v3/remoteImageBuilder.server";
|
||||
import { resourceMonitor } from "./services/resourceMonitor.server";
|
||||
|
||||
if (env.EVENT_LOOP_MONITOR_ENABLED === "1") {
|
||||
eventLoopMonitor.enable();
|
||||
@@ -250,3 +251,7 @@ if (remoteBuildsEnabled()) {
|
||||
} else {
|
||||
console.log("🏗️ Local builds enabled");
|
||||
}
|
||||
|
||||
if (env.RESOURCE_MONITOR_ENABLED === "1") {
|
||||
resourceMonitor.startMonitoring(1000);
|
||||
}
|
||||
|
||||
+1253
-1057
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,15 @@
|
||||
import { createHook } from "node:async_hooks";
|
||||
import { singleton } from "./utils/singleton";
|
||||
import { tracer } from "./v3/tracer.server";
|
||||
import { env } from "./env.server";
|
||||
import { context, Context } from "@opentelemetry/api";
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { logger } from "./services/logger.server";
|
||||
import { signalsEmitter } from "./services/signals.server";
|
||||
|
||||
const THRESHOLD_NS = 1e8; // 100ms
|
||||
const THRESHOLD_NS = env.EVENT_LOOP_MONITOR_THRESHOLD_MS * 1e6;
|
||||
|
||||
const cache = new Map<number, { type: string; start?: [number, number] }>();
|
||||
const cache = new Map<number, { type: string; start?: [number, number]; parentCtx?: Context }>();
|
||||
|
||||
function init(asyncId: number, type: string, triggerAsyncId: number, resource: any) {
|
||||
cache.set(asyncId, {
|
||||
@@ -26,6 +31,7 @@ function before(asyncId: number) {
|
||||
cache.set(asyncId, {
|
||||
...cached,
|
||||
start: process.hrtime(),
|
||||
parentCtx: context.active(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -47,13 +53,17 @@ function after(asyncId: number) {
|
||||
if (diffNs > THRESHOLD_NS) {
|
||||
const time = diffNs / 1e6; // in ms
|
||||
|
||||
const newSpan = tracer.startSpan("event-loop-blocked", {
|
||||
startTime: new Date(new Date().getTime() - time),
|
||||
attributes: {
|
||||
asyncType: cached.type,
|
||||
label: "EventLoopMonitor",
|
||||
const newSpan = tracer.startSpan(
|
||||
"event-loop-blocked",
|
||||
{
|
||||
startTime: new Date(new Date().getTime() - time),
|
||||
attributes: {
|
||||
asyncType: cached.type,
|
||||
label: "EventLoopMonitor",
|
||||
},
|
||||
},
|
||||
});
|
||||
cached.parentCtx
|
||||
);
|
||||
|
||||
newSpan.end();
|
||||
}
|
||||
@@ -62,16 +72,53 @@ function after(asyncId: number) {
|
||||
export const eventLoopMonitor = singleton("eventLoopMonitor", () => {
|
||||
const hook = createHook({ init, before, after, destroy });
|
||||
|
||||
let stopEventLoopUtilizationMonitoring: () => void;
|
||||
|
||||
return {
|
||||
enable: () => {
|
||||
console.log("🥸 Initializing event loop monitor");
|
||||
|
||||
hook.enable();
|
||||
|
||||
stopEventLoopUtilizationMonitoring = startEventLoopUtilizationMonitoring();
|
||||
},
|
||||
disable: () => {
|
||||
console.log("🥸 Disabling event loop monitor");
|
||||
|
||||
hook.disable();
|
||||
|
||||
stopEventLoopUtilizationMonitoring?.();
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
function startEventLoopUtilizationMonitoring() {
|
||||
let lastEventLoopUtilization = performance.eventLoopUtilization();
|
||||
|
||||
const interval = setInterval(() => {
|
||||
const currentEventLoopUtilization = performance.eventLoopUtilization();
|
||||
|
||||
const diff = performance.eventLoopUtilization(
|
||||
currentEventLoopUtilization,
|
||||
lastEventLoopUtilization
|
||||
);
|
||||
const utilization = Number.isFinite(diff.utilization) ? diff.utilization : 0;
|
||||
|
||||
if (Math.random() < env.EVENT_LOOP_MONITOR_UTILIZATION_SAMPLE_RATE) {
|
||||
logger.info("nodejs.event_loop.utilization", { utilization });
|
||||
}
|
||||
|
||||
lastEventLoopUtilization = currentEventLoopUtilization;
|
||||
}, env.EVENT_LOOP_MONITOR_UTILIZATION_INTERVAL_MS);
|
||||
|
||||
signalsEmitter.on("SIGTERM", () => {
|
||||
clearInterval(interval);
|
||||
});
|
||||
signalsEmitter.on("SIGINT", () => {
|
||||
clearInterval(interval);
|
||||
});
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useRevalidator } from "@remix-run/react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
type UseAutoRevalidateOptions = {
|
||||
interval?: number; // in milliseconds
|
||||
onFocus?: boolean;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function useAutoRevalidate(options: UseAutoRevalidateOptions = {}) {
|
||||
const { interval = 5000, onFocus = true, disabled = false } = options;
|
||||
const revalidator = useRevalidator();
|
||||
|
||||
useEffect(() => {
|
||||
if (!interval || interval <= 0 || disabled) return;
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
if (revalidator.state === "loading") {
|
||||
return;
|
||||
}
|
||||
revalidator.revalidate();
|
||||
}, interval);
|
||||
|
||||
return () => clearInterval(intervalId);
|
||||
}, [interval, disabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!onFocus || disabled) return;
|
||||
|
||||
const handleFocus = () => {
|
||||
if (document.visibilityState === "visible" && revalidator.state !== "loading") {
|
||||
revalidator.revalidate();
|
||||
}
|
||||
};
|
||||
|
||||
// Revalidate when the page becomes visible
|
||||
document.addEventListener("visibilitychange", handleFocus);
|
||||
// Revalidate when the window gains focus
|
||||
window.addEventListener("focus", handleFocus);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", handleFocus);
|
||||
window.removeEventListener("focus", handleFocus);
|
||||
};
|
||||
}, [onFocus, disabled]);
|
||||
|
||||
return revalidator;
|
||||
}
|
||||
@@ -33,12 +33,7 @@ export function useSearchParams() {
|
||||
const value = useCallback(
|
||||
(param: string) => {
|
||||
const search = new URLSearchParams(location.search);
|
||||
const val = search.get(param) ?? undefined;
|
||||
if (val === undefined) {
|
||||
return val;
|
||||
}
|
||||
|
||||
return decodeURIComponent(val);
|
||||
return search.get(param) ?? undefined;
|
||||
},
|
||||
[location]
|
||||
);
|
||||
@@ -46,8 +41,7 @@ export function useSearchParams() {
|
||||
const values = useCallback(
|
||||
(param: string) => {
|
||||
const search = new URLSearchParams(location.search);
|
||||
const all = search.getAll(param);
|
||||
return all.map((v) => decodeURIComponent(v));
|
||||
return search.getAll(param);
|
||||
},
|
||||
[location]
|
||||
);
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { prisma } from "~/db.server";
|
||||
import { type Prisma, prisma } from "~/db.server";
|
||||
import { createEnvironment } from "./organization.server";
|
||||
import { customAlphabet } from "nanoid";
|
||||
|
||||
const tokenValueLength = 40;
|
||||
const tokenGenerator = customAlphabet("123456789abcdefghijkmnopqrstuvwxyz", tokenValueLength);
|
||||
|
||||
export async function getTeamMembersAndInvites({
|
||||
userId,
|
||||
@@ -95,14 +99,19 @@ export async function inviteMembers({
|
||||
throw new Error("User does not have access to this organization");
|
||||
}
|
||||
|
||||
const created = await prisma.orgMemberInvite.createMany({
|
||||
data: emails.map((email) => ({
|
||||
email,
|
||||
organizationId: org.id,
|
||||
inviterId: userId,
|
||||
role: "MEMBER",
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
const invites = [...new Set(emails)].map(
|
||||
(email) =>
|
||||
({
|
||||
email,
|
||||
token: tokenGenerator(),
|
||||
organizationId: org.id,
|
||||
inviterId: userId,
|
||||
role: "MEMBER",
|
||||
} satisfies Prisma.OrgMemberInviteCreateManyInput)
|
||||
);
|
||||
|
||||
await prisma.orgMemberInvite.createMany({
|
||||
data: invites,
|
||||
});
|
||||
|
||||
return await prisma.orgMemberInvite.findMany({
|
||||
@@ -147,12 +156,19 @@ export async function getUsersInvites({ email }: { email: string }) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function acceptInvite({ userId, inviteId }: { userId: string; inviteId: string }) {
|
||||
export async function acceptInvite({
|
||||
user,
|
||||
inviteId,
|
||||
}: {
|
||||
user: { id: string; email: string };
|
||||
inviteId: string;
|
||||
}) {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
// 1. Delete the invite and get the invite details
|
||||
const invite = await tx.orgMemberInvite.delete({
|
||||
where: {
|
||||
id: inviteId,
|
||||
email: user.email,
|
||||
},
|
||||
include: {
|
||||
organization: {
|
||||
@@ -167,7 +183,7 @@ export async function acceptInvite({ userId, inviteId }: { userId: string; invit
|
||||
const member = await tx.orgMember.create({
|
||||
data: {
|
||||
organizationId: invite.organizationId,
|
||||
userId,
|
||||
userId: user.id,
|
||||
role: invite.role,
|
||||
},
|
||||
});
|
||||
@@ -187,7 +203,7 @@ export async function acceptInvite({ userId, inviteId }: { userId: string; invit
|
||||
// 4. Check for other invites
|
||||
const remainingInvites = await tx.orgMemberInvite.findMany({
|
||||
where: {
|
||||
email: invite.email,
|
||||
email: user.email,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -195,28 +211,29 @@ export async function acceptInvite({ userId, inviteId }: { userId: string; invit
|
||||
});
|
||||
}
|
||||
|
||||
export async function declineInvite({ userId, inviteId }: { userId: string; inviteId: string }) {
|
||||
export async function declineInvite({
|
||||
user,
|
||||
inviteId,
|
||||
}: {
|
||||
user: { id: string; email: string };
|
||||
inviteId: string;
|
||||
}) {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
//1. delete invite
|
||||
const declinedInvite = await prisma.orgMemberInvite.delete({
|
||||
where: {
|
||||
id: inviteId,
|
||||
email: user.email,
|
||||
},
|
||||
include: {
|
||||
organization: true,
|
||||
},
|
||||
});
|
||||
|
||||
//2. get email
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { email: true },
|
||||
});
|
||||
|
||||
//3. check for other invites
|
||||
//2. check for other invites
|
||||
const remainingInvites = await prisma.orgMemberInvite.findMany({
|
||||
where: {
|
||||
email: user!.email,
|
||||
email: user.email,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -224,10 +241,11 @@ export async function declineInvite({ userId, inviteId }: { userId: string; invi
|
||||
});
|
||||
}
|
||||
|
||||
export async function resendInvite({ inviteId }: { inviteId: string }) {
|
||||
export async function resendInvite({ inviteId, userId }: { inviteId: string; userId: string }) {
|
||||
return await prisma.orgMemberInvite.update({
|
||||
where: {
|
||||
id: inviteId,
|
||||
inviterId: userId,
|
||||
},
|
||||
data: {
|
||||
updatedAt: new Date(),
|
||||
@@ -241,26 +259,27 @@ export async function resendInvite({ inviteId }: { inviteId: string }) {
|
||||
|
||||
export async function revokeInvite({
|
||||
userId,
|
||||
slug,
|
||||
orgSlug,
|
||||
inviteId,
|
||||
}: {
|
||||
userId: string;
|
||||
slug: string;
|
||||
orgSlug: string;
|
||||
inviteId: string;
|
||||
}) {
|
||||
const org = await prisma.organization.findFirst({
|
||||
where: { slug, members: { some: { userId } } },
|
||||
});
|
||||
|
||||
if (!org) {
|
||||
throw new Error("User does not have access to this organization");
|
||||
}
|
||||
const invite = await prisma.orgMemberInvite.delete({
|
||||
const invite = await prisma.orgMemberInvite.findFirst({
|
||||
where: {
|
||||
id: inviteId,
|
||||
organizationId: org.id,
|
||||
organization: {
|
||||
slug: orgSlug,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
organization: true,
|
||||
},
|
||||
@@ -270,5 +289,11 @@ export async function revokeInvite({
|
||||
throw new Error("Invite not found");
|
||||
}
|
||||
|
||||
await prisma.orgMemberInvite.delete({
|
||||
where: {
|
||||
id: invite.id,
|
||||
},
|
||||
});
|
||||
|
||||
return { email: invite.email, organization: invite.organization };
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { json, Session } from "@remix-run/node";
|
||||
import { createCookieSessionStorage } from "@remix-run/node";
|
||||
import { json, createCookieSessionStorage, type Session } from "@remix-run/node";
|
||||
import { redirect, typedjson } from "remix-typedjson";
|
||||
import { ButtonVariant } from "~/components/primitives/Buttons";
|
||||
import { env } from "~/env.server";
|
||||
import { type FeedbackType } from "~/routes/resources.feedback";
|
||||
|
||||
export type ToastMessage = {
|
||||
message: string;
|
||||
@@ -9,9 +10,26 @@ export type ToastMessage = {
|
||||
options: Required<ToastMessageOptions>;
|
||||
};
|
||||
|
||||
export type ToastMessageAction = {
|
||||
label: string;
|
||||
variant?: ButtonVariant;
|
||||
action:
|
||||
| {
|
||||
type: "link";
|
||||
path: string;
|
||||
}
|
||||
| {
|
||||
type: "help";
|
||||
feedbackType: FeedbackType;
|
||||
};
|
||||
};
|
||||
|
||||
export type ToastMessageOptions = {
|
||||
title?: string;
|
||||
/** Ephemeral means it disappears after a delay, defaults to true */
|
||||
ephemeral?: boolean;
|
||||
/** This display a button and make it not ephemeral, unless ephemeral is explicitlyset to false */
|
||||
action?: ToastMessageAction;
|
||||
};
|
||||
|
||||
const ONE_YEAR = 1000 * 60 * 60 * 24 * 365;
|
||||
@@ -36,6 +54,7 @@ export function setSuccessMessage(
|
||||
message,
|
||||
type: "success",
|
||||
options: {
|
||||
...options,
|
||||
ephemeral: options?.ephemeral ?? true,
|
||||
},
|
||||
} as ToastMessage);
|
||||
@@ -46,6 +65,7 @@ export function setErrorMessage(session: Session, message: string, options?: Toa
|
||||
message,
|
||||
type: "error",
|
||||
options: {
|
||||
...options,
|
||||
ephemeral: options?.ephemeral ?? true,
|
||||
},
|
||||
} as ToastMessage);
|
||||
|
||||
@@ -12,7 +12,7 @@ import { prisma, type PrismaClientOrTransaction } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import { featuresForUrl } from "~/features.server";
|
||||
import { createApiKeyForEnv, createPkApiKeyForEnv, envSlug } from "./api-key.server";
|
||||
|
||||
import { getDefaultEnvironmentConcurrencyLimit } from "~/services/platform.v3.server";
|
||||
export type { Organization };
|
||||
|
||||
const nanoid = customAlphabet("1234567890abcdef", 4);
|
||||
@@ -66,7 +66,7 @@ export async function createOrganization(
|
||||
role: "ADMIN",
|
||||
},
|
||||
},
|
||||
v3Enabled: !features.isManagedCloud,
|
||||
v3Enabled: true,
|
||||
},
|
||||
include: {
|
||||
members: true,
|
||||
@@ -96,6 +96,8 @@ export async function createEnvironment({
|
||||
const pkApiKey = createPkApiKeyForEnv(type);
|
||||
const shortcode = createShortcode().join("-");
|
||||
|
||||
const limit = await getDefaultEnvironmentConcurrencyLimit(organization.id, type);
|
||||
|
||||
return await prismaClient.runtimeEnvironment.create({
|
||||
data: {
|
||||
slug,
|
||||
@@ -103,7 +105,7 @@ export async function createEnvironment({
|
||||
pkApiKey,
|
||||
shortcode,
|
||||
autoEnableInternalSources: type !== "DEVELOPMENT",
|
||||
maximumConcurrencyLimit: organization.maximumConcurrencyLimit / 3,
|
||||
maximumConcurrencyLimit: limit,
|
||||
organization: {
|
||||
connect: {
|
||||
id: organization.id,
|
||||
|
||||
@@ -16,12 +16,26 @@ type Options = {
|
||||
version: "v2" | "v3";
|
||||
};
|
||||
|
||||
export class ExceededProjectLimitError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ExceededProjectLimitError";
|
||||
}
|
||||
}
|
||||
|
||||
export async function createProject(
|
||||
{ organizationSlug, name, userId, version }: Options,
|
||||
attemptCount = 0
|
||||
): Promise<Project & { organization: Organization }> {
|
||||
//check the user has permissions to do this
|
||||
const organization = await prisma.organization.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
v3Enabled: true,
|
||||
maximumConcurrencyLimit: true,
|
||||
maximumProjectCount: true,
|
||||
},
|
||||
where: {
|
||||
slug: organizationSlug,
|
||||
members: { some: { userId } },
|
||||
@@ -40,6 +54,19 @@ export async function createProject(
|
||||
}
|
||||
}
|
||||
|
||||
const projectCount = await prisma.project.count({
|
||||
where: {
|
||||
organizationId: organization.id,
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (projectCount >= organization.maximumProjectCount) {
|
||||
throw new ExceededProjectLimitError(
|
||||
`This organization has reached the maximum number of projects (${organization.maximumProjectCount}).`
|
||||
);
|
||||
}
|
||||
|
||||
//ensure the slug is globally unique
|
||||
const uniqueProjectSlug = `${slug(name)}-${nanoid(4)}`;
|
||||
const projectWithSameSlug = await prisma.project.findFirst({
|
||||
|
||||
@@ -37,7 +37,7 @@ export async function findEnvironmentByApiKey(
|
||||
|
||||
if (environment.type === "PREVIEW") {
|
||||
if (!branchName) {
|
||||
logger.error("findEnvironmentByApiKey(): Preview env with no branch name provided", {
|
||||
logger.warn("findEnvironmentByApiKey(): Preview env with no branch name provided", {
|
||||
environmentId: environment.id,
|
||||
});
|
||||
return null;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Prisma, User } from "@trigger.dev/database";
|
||||
import type { GitHubProfile } from "remix-auth-github";
|
||||
import type { GoogleProfile } from "remix-auth-google";
|
||||
import { prisma } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import {
|
||||
@@ -8,6 +9,8 @@ import {
|
||||
} from "~/services/dashboardPreferences.server";
|
||||
export type { User } from "@trigger.dev/database";
|
||||
import { assertEmailAllowed } from "~/utils/email";
|
||||
import { logger } from "~/services/logger.server";
|
||||
|
||||
type FindOrCreateMagicLink = {
|
||||
authenticationMethod: "MAGIC_LINK";
|
||||
email: string;
|
||||
@@ -20,7 +23,14 @@ type FindOrCreateGithub = {
|
||||
authenticationExtraParams: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type FindOrCreateUser = FindOrCreateMagicLink | FindOrCreateGithub;
|
||||
type FindOrCreateGoogle = {
|
||||
authenticationMethod: "GOOGLE";
|
||||
email: User["email"];
|
||||
authenticationProfile: GoogleProfile;
|
||||
authenticationExtraParams: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type FindOrCreateUser = FindOrCreateMagicLink | FindOrCreateGithub | FindOrCreateGoogle;
|
||||
|
||||
type LoggedInUser = {
|
||||
user: User;
|
||||
@@ -35,6 +45,9 @@ export async function findOrCreateUser(input: FindOrCreateUser): Promise<LoggedI
|
||||
case "MAGIC_LINK": {
|
||||
return findOrCreateMagicLinkUser(input);
|
||||
}
|
||||
case "GOOGLE": {
|
||||
return findOrCreateGoogleUser(input);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,6 +175,134 @@ export async function findOrCreateGithubUser({
|
||||
};
|
||||
}
|
||||
|
||||
export async function findOrCreateGoogleUser({
|
||||
email,
|
||||
authenticationProfile,
|
||||
authenticationExtraParams,
|
||||
}: FindOrCreateGoogle): Promise<LoggedInUser> {
|
||||
assertEmailAllowed(email);
|
||||
|
||||
const name = authenticationProfile._json.name;
|
||||
let avatarUrl: string | undefined = undefined;
|
||||
if (authenticationProfile.photos[0]) {
|
||||
avatarUrl = authenticationProfile.photos[0].value;
|
||||
}
|
||||
const displayName = authenticationProfile.displayName;
|
||||
const authProfile = authenticationProfile
|
||||
? (authenticationProfile as unknown as Prisma.JsonObject)
|
||||
: undefined;
|
||||
const authExtraParams = authenticationExtraParams
|
||||
? (authenticationExtraParams as unknown as Prisma.JsonObject)
|
||||
: undefined;
|
||||
|
||||
const authIdentifier = `google:${authenticationProfile.id}`;
|
||||
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: {
|
||||
authIdentifier,
|
||||
},
|
||||
});
|
||||
|
||||
const existingEmailUser = await prisma.user.findUnique({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingEmailUser && !existingUser) {
|
||||
// Link existing email account to Google auth, preserving original authenticationMethod
|
||||
const user = await prisma.user.update({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
data: {
|
||||
authenticationProfile: authProfile,
|
||||
authenticationExtraParams: authExtraParams,
|
||||
avatarUrl,
|
||||
authIdentifier,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
user,
|
||||
isNewUser: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (existingEmailUser && existingUser) {
|
||||
// Check if email user and auth user are the same
|
||||
if (existingEmailUser.id !== existingUser.id) {
|
||||
// Different users: email is taken by one user, Google auth belongs to another
|
||||
logger.error(
|
||||
`Google auth conflict: Google ID ${authenticationProfile.id} belongs to user ${existingUser.id} but email ${email} is taken by user ${existingEmailUser.id}`,
|
||||
{
|
||||
email,
|
||||
existingEmailUserId: existingEmailUser.id,
|
||||
existingAuthUserId: existingUser.id,
|
||||
authIdentifier,
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
user: existingUser,
|
||||
isNewUser: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Same user: update all profile fields
|
||||
const user = await prisma.user.update({
|
||||
where: {
|
||||
id: existingUser.id,
|
||||
},
|
||||
data: {
|
||||
email,
|
||||
displayName,
|
||||
name,
|
||||
avatarUrl,
|
||||
authenticationProfile: authProfile,
|
||||
authenticationExtraParams: authExtraParams,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
user,
|
||||
isNewUser: false,
|
||||
};
|
||||
}
|
||||
|
||||
// When the IDP user (Google) already exists, the "update" path will be taken and the email will be updated
|
||||
// It's not possible that the email is already taken by a different user because that would have been handled
|
||||
// by one of the if statements above.
|
||||
const user = await prisma.user.upsert({
|
||||
where: {
|
||||
authIdentifier,
|
||||
},
|
||||
update: {
|
||||
email,
|
||||
displayName,
|
||||
name,
|
||||
avatarUrl,
|
||||
authenticationProfile: authProfile,
|
||||
authenticationExtraParams: authExtraParams,
|
||||
},
|
||||
create: {
|
||||
authenticationProfile: authProfile,
|
||||
authenticationExtraParams: authExtraParams,
|
||||
name,
|
||||
avatarUrl,
|
||||
displayName,
|
||||
authIdentifier,
|
||||
email,
|
||||
authenticationMethod: "GOOGLE",
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
user,
|
||||
isNewUser: !existingUser,
|
||||
};
|
||||
}
|
||||
|
||||
export type UserWithDashboardPreferences = User & {
|
||||
dashboardPreferences: DashboardPreferences;
|
||||
};
|
||||
|
||||
@@ -75,6 +75,7 @@ export class ApiRetrieveRunPresenter {
|
||||
},
|
||||
select: {
|
||||
...commonRunSelect,
|
||||
traceId: true,
|
||||
payload: true,
|
||||
payloadType: true,
|
||||
output: true,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {
|
||||
Prisma,
|
||||
type Prisma,
|
||||
type WorkerDeploymentStatus,
|
||||
type WorkerInstanceGroupType,
|
||||
} from "@trigger.dev/database";
|
||||
@@ -9,6 +9,7 @@ import { type Project } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { type User } from "~/models/user.server";
|
||||
import { processGitMetadata } from "./BranchesPresenter.server";
|
||||
import { BranchTrackingConfigSchema, getTrackedBranchForEnvironment } from "~/v3/github";
|
||||
|
||||
const pageSize = 20;
|
||||
|
||||
@@ -56,6 +57,18 @@ export class DeploymentListPresenter {
|
||||
},
|
||||
},
|
||||
},
|
||||
connectedGithubRepository: {
|
||||
select: {
|
||||
branchTracking: true,
|
||||
previewDeploymentsEnabled: true,
|
||||
repository: {
|
||||
select: {
|
||||
htmlUrl: true,
|
||||
fullName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
slug: projectSlug,
|
||||
@@ -131,7 +144,7 @@ export class DeploymentListPresenter {
|
||||
wd."git"
|
||||
FROM
|
||||
${sqlDatabaseSchema}."WorkerDeployment" as wd
|
||||
INNER JOIN
|
||||
LEFT JOIN
|
||||
${sqlDatabaseSchema}."User" as u ON wd."triggeredById" = u."id"
|
||||
WHERE
|
||||
wd."projectId" = ${project.id}
|
||||
@@ -140,9 +153,28 @@ ORDER BY
|
||||
string_to_array(wd."version", '.')::int[] DESC
|
||||
LIMIT ${pageSize} OFFSET ${pageSize * (page - 1)};`;
|
||||
|
||||
const { connectedGithubRepository } = project;
|
||||
|
||||
const branchTrackingOrError =
|
||||
connectedGithubRepository &&
|
||||
BranchTrackingConfigSchema.safeParse(connectedGithubRepository.branchTracking);
|
||||
const environmentGitHubBranch =
|
||||
branchTrackingOrError && branchTrackingOrError.success
|
||||
? getTrackedBranchForEnvironment(
|
||||
branchTrackingOrError.data,
|
||||
connectedGithubRepository.previewDeploymentsEnabled,
|
||||
{
|
||||
type: environment.type,
|
||||
branchName: environment.branchName ?? undefined,
|
||||
}
|
||||
)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
currentPage: page,
|
||||
totalPages: Math.ceil(totalCount / pageSize),
|
||||
connectedGithubRepository: project.connectedGithubRepository ?? undefined,
|
||||
environmentGitHubBranch,
|
||||
deployments: deployments.map((deployment, index) => {
|
||||
const label = labeledDeployments.find(
|
||||
(labeledDeployment) => labeledDeployment.deploymentId === deployment.id
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import {
|
||||
BuildServerMetadata,
|
||||
DeploymentErrorData,
|
||||
ExternalBuildData,
|
||||
prepareDeploymentError,
|
||||
} from "@trigger.dev/core/v3";
|
||||
import { RuntimeEnvironment, type WorkerDeployment } from "@trigger.dev/database";
|
||||
import { type RuntimeEnvironment, type WorkerDeployment } from "@trigger.dev/database";
|
||||
import { type PrismaClient, prisma } from "~/db.server";
|
||||
import { type Organization } from "~/models/organization.server";
|
||||
import { type Project } from "~/models/project.server";
|
||||
@@ -11,6 +12,24 @@ import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { type User } from "~/models/user.server";
|
||||
import { getUsername } from "~/utils/username";
|
||||
import { processGitMetadata } from "./BranchesPresenter.server";
|
||||
import { S2 } from "@s2-dev/streamstore";
|
||||
import { env } from "~/env.server";
|
||||
import { createRedisClient } from "~/redis.server";
|
||||
import { tryCatch } from "@trigger.dev/core";
|
||||
import { logger } from "~/services/logger.server";
|
||||
|
||||
const S2_TOKEN_KEY_PREFIX = "s2-token:project:";
|
||||
|
||||
const s2TokenRedis = createRedisClient("s2-token-cache", {
|
||||
host: env.CACHE_REDIS_HOST,
|
||||
port: env.CACHE_REDIS_PORT,
|
||||
username: env.CACHE_REDIS_USERNAME,
|
||||
password: env.CACHE_REDIS_PASSWORD,
|
||||
tlsDisabled: env.CACHE_REDIS_TLS_DISABLED === "true",
|
||||
clusterMode: env.CACHE_REDIS_CLUSTER_MODE_ENABLED === "1",
|
||||
});
|
||||
|
||||
const s2 = env.S2_ENABLED === "1" ? new S2({ accessToken: env.S2_ACCESS_TOKEN }) : undefined;
|
||||
|
||||
export type ErrorData = {
|
||||
name: string;
|
||||
@@ -43,6 +62,7 @@ export class DeploymentPresenter {
|
||||
select: {
|
||||
id: true,
|
||||
organizationId: true,
|
||||
externalRef: true,
|
||||
},
|
||||
where: {
|
||||
slug: projectSlug,
|
||||
@@ -102,6 +122,10 @@ export class DeploymentPresenter {
|
||||
builtAt: true,
|
||||
deployedAt: true,
|
||||
createdAt: true,
|
||||
startedAt: true,
|
||||
installedAt: true,
|
||||
canceledAt: true,
|
||||
canceledReason: true,
|
||||
git: true,
|
||||
promotions: {
|
||||
select: {
|
||||
@@ -131,22 +155,52 @@ export class DeploymentPresenter {
|
||||
avatarUrl: true,
|
||||
},
|
||||
},
|
||||
buildServerMetadata: true,
|
||||
},
|
||||
});
|
||||
|
||||
const gitMetadata = processGitMetadata(deployment.git);
|
||||
const externalBuildData = deployment.externalBuildData
|
||||
? ExternalBuildData.safeParse(deployment.externalBuildData)
|
||||
: undefined;
|
||||
const buildServerMetadata = deployment.buildServerMetadata
|
||||
? BuildServerMetadata.safeParse(deployment.buildServerMetadata)
|
||||
: undefined;
|
||||
|
||||
let eventStream = undefined;
|
||||
if (
|
||||
env.S2_ENABLED === "1" &&
|
||||
(buildServerMetadata || gitMetadata?.source === "trigger_github_app")
|
||||
) {
|
||||
const [error, accessToken] = await tryCatch(this.getS2AccessToken(project.externalRef));
|
||||
|
||||
if (error) {
|
||||
logger.error("Failed getting S2 access token", { error });
|
||||
} else {
|
||||
eventStream = {
|
||||
s2: {
|
||||
basin: env.S2_DEPLOYMENT_LOGS_BASIN_NAME,
|
||||
stream: `projects/${project.externalRef}/deployments/${deployment.shortCode}`,
|
||||
accessToken,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
eventStream,
|
||||
deployment: {
|
||||
id: deployment.id,
|
||||
shortCode: deployment.shortCode,
|
||||
version: deployment.version,
|
||||
status: deployment.status,
|
||||
createdAt: deployment.createdAt,
|
||||
startedAt: deployment.startedAt,
|
||||
installedAt: deployment.installedAt,
|
||||
builtAt: deployment.builtAt,
|
||||
deployedAt: deployment.deployedAt,
|
||||
canceledAt: deployment.canceledAt,
|
||||
canceledReason: deployment.canceledReason,
|
||||
tasks: deployment.worker?.tasks,
|
||||
label: deployment.promotions?.[0]?.label,
|
||||
environment: {
|
||||
@@ -170,11 +224,46 @@ export class DeploymentPresenter {
|
||||
errorData: DeploymentPresenter.prepareErrorData(deployment.errorData),
|
||||
isBuilt: !!deployment.builtAt,
|
||||
type: deployment.type,
|
||||
git: processGitMetadata(deployment.git),
|
||||
git: gitMetadata,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async getS2AccessToken(projectRef: string): Promise<string> {
|
||||
if (env.S2_ENABLED !== "1" || !s2) {
|
||||
throw new Error("Failed getting S2 access token: S2 is not enabled");
|
||||
}
|
||||
|
||||
const redisKey = `${S2_TOKEN_KEY_PREFIX}${projectRef}`;
|
||||
const cachedToken = await s2TokenRedis.get(redisKey);
|
||||
|
||||
if (cachedToken) {
|
||||
return cachedToken;
|
||||
}
|
||||
|
||||
const { access_token: accessToken } = await s2.accessTokens.issue({
|
||||
id: `${projectRef}-${new Date().getTime()}`,
|
||||
expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(), // 1 hour
|
||||
scope: {
|
||||
ops: ["read"],
|
||||
basins: {
|
||||
exact: env.S2_DEPLOYMENT_LOGS_BASIN_NAME,
|
||||
},
|
||||
streams: {
|
||||
prefix: `projects/${projectRef}/deployments/`,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await s2TokenRedis.setex(
|
||||
redisKey,
|
||||
59 * 60, // slightly shorter than the token validity period
|
||||
accessToken
|
||||
);
|
||||
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
public static prepareErrorData(errorData: WorkerDeployment["errorData"]): ErrorData | undefined {
|
||||
if (!errorData) {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { type PrismaClient } from "@trigger.dev/database";
|
||||
import { err, fromPromise, ok, ResultAsync } from "neverthrow";
|
||||
import { env } from "~/env.server";
|
||||
import { BranchTrackingConfigSchema } from "~/v3/github";
|
||||
import { BasePresenter } from "./basePresenter.server";
|
||||
|
||||
type GitHubSettingsOptions = {
|
||||
projectId: string;
|
||||
organizationId: string;
|
||||
};
|
||||
|
||||
export class GitHubSettingsPresenter extends BasePresenter {
|
||||
public call({ projectId, organizationId }: GitHubSettingsOptions) {
|
||||
const githubAppEnabled = env.GITHUB_APP_ENABLED === "1";
|
||||
|
||||
if (!githubAppEnabled) {
|
||||
return ok({
|
||||
enabled: false,
|
||||
connectedRepository: undefined,
|
||||
installations: undefined,
|
||||
isPreviewEnvironmentEnabled: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const findConnectedGithubRepository = () =>
|
||||
fromPromise(
|
||||
(this._replica as PrismaClient).connectedGithubRepository.findFirst({
|
||||
where: {
|
||||
projectId,
|
||||
repository: {
|
||||
installation: {
|
||||
deletedAt: null,
|
||||
suspendedAt: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
branchTracking: true,
|
||||
previewDeploymentsEnabled: true,
|
||||
createdAt: true,
|
||||
repository: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
fullName: true,
|
||||
htmlUrl: true,
|
||||
private: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
(error) => ({
|
||||
type: "other" as const,
|
||||
cause: error,
|
||||
})
|
||||
).map((connectedGithubRepository) => {
|
||||
if (!connectedGithubRepository) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const branchTrackingOrFailure = BranchTrackingConfigSchema.safeParse(
|
||||
connectedGithubRepository.branchTracking
|
||||
);
|
||||
const branchTracking = branchTrackingOrFailure.success
|
||||
? branchTrackingOrFailure.data
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
...connectedGithubRepository,
|
||||
branchTracking,
|
||||
};
|
||||
});
|
||||
|
||||
const listGithubAppInstallations = () =>
|
||||
fromPromise(
|
||||
(this._replica as PrismaClient).githubAppInstallation.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
deletedAt: null,
|
||||
suspendedAt: null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
accountHandle: true,
|
||||
targetType: true,
|
||||
appInstallationId: true,
|
||||
repositories: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
fullName: true,
|
||||
htmlUrl: true,
|
||||
private: true,
|
||||
},
|
||||
take: 200,
|
||||
},
|
||||
},
|
||||
take: 20,
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
}),
|
||||
(error) => ({
|
||||
type: "other" as const,
|
||||
cause: error,
|
||||
})
|
||||
);
|
||||
|
||||
const isPreviewEnvironmentEnabled = () =>
|
||||
fromPromise(
|
||||
(this._replica as PrismaClient).runtimeEnvironment.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
where: {
|
||||
projectId: projectId,
|
||||
slug: "preview",
|
||||
},
|
||||
}),
|
||||
(error) => ({
|
||||
type: "other" as const,
|
||||
cause: error,
|
||||
})
|
||||
).map((previewEnvironment) => previewEnvironment !== null);
|
||||
|
||||
return ResultAsync.combine([
|
||||
isPreviewEnvironmentEnabled(),
|
||||
findConnectedGithubRepository(),
|
||||
listGithubAppInstallations(),
|
||||
]).map(([isPreviewEnvironmentEnabled, connectedGithubRepository, githubAppInstallations]) => ({
|
||||
enabled: true,
|
||||
connectedRepository: connectedGithubRepository,
|
||||
installations: githubAppInstallations,
|
||||
isPreviewEnvironmentEnabled,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { type RuntimeEnvironmentType } from "@trigger.dev/database";
|
||||
import {
|
||||
getCurrentPlan,
|
||||
getDefaultEnvironmentLimitFromPlan,
|
||||
getPlans,
|
||||
} from "~/services/platform.v3.server";
|
||||
import { BasePresenter } from "./basePresenter.server";
|
||||
import { sortEnvironments } from "~/utils/environmentSort";
|
||||
|
||||
export type ConcurrencyResult = {
|
||||
canAddConcurrency: boolean;
|
||||
environments: EnvironmentWithConcurrency[];
|
||||
extraConcurrency: number;
|
||||
extraAllocatedConcurrency: number;
|
||||
extraUnallocatedConcurrency: number;
|
||||
maxQuota: number;
|
||||
concurrencyPricing: {
|
||||
stepSize: number;
|
||||
centsPerStep: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type EnvironmentWithConcurrency = {
|
||||
id: string;
|
||||
type: RuntimeEnvironmentType;
|
||||
isBranchableEnvironment: boolean;
|
||||
branchName: string | null;
|
||||
parentEnvironmentId: string | null;
|
||||
maximumConcurrencyLimit: number;
|
||||
planConcurrencyLimit: number;
|
||||
};
|
||||
|
||||
export class ManageConcurrencyPresenter extends BasePresenter {
|
||||
public async call({
|
||||
userId,
|
||||
projectId,
|
||||
organizationId,
|
||||
}: {
|
||||
userId: string;
|
||||
projectId: string;
|
||||
organizationId: string;
|
||||
}): Promise<ConcurrencyResult> {
|
||||
// Get plan
|
||||
const currentPlan = await getCurrentPlan(organizationId);
|
||||
if (!currentPlan) {
|
||||
throw new Error("No plan found");
|
||||
}
|
||||
|
||||
const canAddConcurrency =
|
||||
currentPlan.v3Subscription.plan?.limits.concurrentRuns.canExceed === true;
|
||||
|
||||
const environments = await this._replica.runtimeEnvironment.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
projectId: true,
|
||||
type: true,
|
||||
branchName: true,
|
||||
parentEnvironmentId: true,
|
||||
isBranchableEnvironment: true,
|
||||
maximumConcurrencyLimit: true,
|
||||
orgMember: {
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
},
|
||||
project: {
|
||||
select: {
|
||||
deletedAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
organizationId,
|
||||
archivedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
const extraConcurrency = currentPlan?.v3Subscription.addOns?.concurrentRuns?.purchased ?? 0;
|
||||
|
||||
// Go through all environments and add up extra concurrency above their allowed allocation
|
||||
let extraAllocatedConcurrency = 0;
|
||||
const projectEnvironments: EnvironmentWithConcurrency[] = [];
|
||||
for (const environment of environments) {
|
||||
// Don't count parent environments
|
||||
if (environment.isBranchableEnvironment) continue;
|
||||
|
||||
// Don't count deleted projects
|
||||
if (environment.project.deletedAt) continue;
|
||||
|
||||
const limit = currentPlan
|
||||
? getDefaultEnvironmentLimitFromPlan(environment.type, currentPlan)
|
||||
: 0;
|
||||
if (!limit) continue;
|
||||
|
||||
// If it's not DEV and they've increased, track that
|
||||
// You can't spend money to increase DEV concurrency
|
||||
if (environment.type !== "DEVELOPMENT" && environment.maximumConcurrencyLimit > limit) {
|
||||
extraAllocatedConcurrency += environment.maximumConcurrencyLimit - limit;
|
||||
}
|
||||
|
||||
// We only want to show this project's environments
|
||||
if (environment.projectId === projectId) {
|
||||
if (environment.type === "DEVELOPMENT" && environment.orgMember?.userId !== userId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
projectEnvironments.push({
|
||||
id: environment.id,
|
||||
type: environment.type,
|
||||
isBranchableEnvironment: environment.isBranchableEnvironment,
|
||||
branchName: environment.branchName,
|
||||
parentEnvironmentId: environment.parentEnvironmentId,
|
||||
maximumConcurrencyLimit: environment.maximumConcurrencyLimit,
|
||||
planConcurrencyLimit: limit,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const extraAllocated = Math.min(extraConcurrency, extraAllocatedConcurrency);
|
||||
|
||||
const plans = await getPlans();
|
||||
if (!plans) {
|
||||
throw new Error("Couldn't retrieve add on pricing");
|
||||
}
|
||||
|
||||
return {
|
||||
canAddConcurrency,
|
||||
extraConcurrency,
|
||||
extraAllocatedConcurrency: extraAllocated,
|
||||
extraUnallocatedConcurrency: extraConcurrency - extraAllocated,
|
||||
maxQuota: currentPlan.v3Subscription.addOns?.concurrentRuns?.quota ?? 0,
|
||||
environments: sortEnvironments(projectEnvironments, [
|
||||
"PRODUCTION",
|
||||
"STAGING",
|
||||
"PREVIEW",
|
||||
"DEVELOPMENT",
|
||||
]),
|
||||
concurrencyPricing: plans.addOnPricing.concurrency,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -114,6 +114,9 @@ export class QueueListPresenter extends BasePresenter {
|
||||
name: true,
|
||||
orderableName: true,
|
||||
concurrencyLimit: true,
|
||||
concurrencyLimitBase: true,
|
||||
concurrencyLimitOverriddenAt: true,
|
||||
concurrencyLimitOverriddenBy: true,
|
||||
type: true,
|
||||
paused: true,
|
||||
},
|
||||
@@ -135,6 +138,17 @@ export class QueueListPresenter extends BasePresenter {
|
||||
),
|
||||
]);
|
||||
|
||||
// Manually "join" the overridden users because there is no way to implement the relationship
|
||||
// in prisma without adding a foreign key constraint
|
||||
const overriddenByIds = queues.map((q) => q.concurrencyLimitOverriddenBy).filter(Boolean);
|
||||
const overriddenByUsers = await this._replica.user.findMany({
|
||||
where: {
|
||||
id: { in: overriddenByIds },
|
||||
},
|
||||
});
|
||||
|
||||
const overriddenByMap = new Map(overriddenByUsers.map((u) => [u.id, u]));
|
||||
|
||||
// Transform queues to include running and queued counts
|
||||
return queues.map((queue) =>
|
||||
toQueueItem({
|
||||
@@ -144,6 +158,11 @@ export class QueueListPresenter extends BasePresenter {
|
||||
running: results[1][queue.name] ?? 0,
|
||||
queued: results[0][queue.name] ?? 0,
|
||||
concurrencyLimit: queue.concurrencyLimit ?? null,
|
||||
concurrencyLimitBase: queue.concurrencyLimitBase ?? null,
|
||||
concurrencyLimitOverriddenAt: queue.concurrencyLimitOverriddenAt ?? null,
|
||||
concurrencyLimitOverriddenBy: queue.concurrencyLimitOverriddenBy
|
||||
? overriddenByMap.get(queue.concurrencyLimitOverriddenBy) ?? null
|
||||
: null,
|
||||
paused: queue.paused,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { type AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { engine } from "~/v3/runEngine.server";
|
||||
import { BasePresenter } from "./basePresenter.server";
|
||||
import { type TaskQueueType } from "@trigger.dev/database";
|
||||
import { TaskQueue, User, type TaskQueueType } from "@trigger.dev/database";
|
||||
import { assertExhaustive } from "@trigger.dev/core";
|
||||
import { determineEngineVersion } from "~/v3/engineVersion.server";
|
||||
import { type QueueItem, type RetrieveQueueParam } from "@trigger.dev/core/v3";
|
||||
import { type Prettify, type QueueItem, type RetrieveQueueParam } from "@trigger.dev/core/v3";
|
||||
import { PrismaClientOrTransaction } from "@trigger.dev/database";
|
||||
|
||||
export type FoundQueue = Prettify<
|
||||
Omit<TaskQueue, "concurrencyLimitOverriddenBy"> & {
|
||||
concurrencyLimitOverriddenBy?: User | null;
|
||||
}
|
||||
>;
|
||||
|
||||
/**
|
||||
* Shared queue lookup logic used by both QueueRetrievePresenter and PauseQueueService
|
||||
*/
|
||||
@@ -16,22 +22,50 @@ export async function getQueue(
|
||||
queue: RetrieveQueueParam
|
||||
) {
|
||||
if (typeof queue === "string") {
|
||||
return prismaClient.taskQueue.findFirst({
|
||||
where: {
|
||||
friendlyId: queue,
|
||||
runtimeEnvironmentId: environment.id,
|
||||
},
|
||||
});
|
||||
return joinQueueWithUser(
|
||||
prismaClient,
|
||||
await prismaClient.taskQueue.findFirst({
|
||||
where: {
|
||||
friendlyId: queue,
|
||||
runtimeEnvironmentId: environment.id,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const queueName =
|
||||
queue.type === "task" ? `task/${queue.name.replace(/^task\//, "")}` : queue.name;
|
||||
return prismaClient.taskQueue.findFirst({
|
||||
where: {
|
||||
name: queueName,
|
||||
runtimeEnvironmentId: environment.id,
|
||||
},
|
||||
return joinQueueWithUser(
|
||||
prismaClient,
|
||||
await prismaClient.taskQueue.findFirst({
|
||||
where: {
|
||||
name: queueName,
|
||||
runtimeEnvironmentId: environment.id,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function joinQueueWithUser(
|
||||
prismaClient: PrismaClientOrTransaction,
|
||||
queue?: TaskQueue | null
|
||||
): Promise<FoundQueue | undefined> {
|
||||
if (!queue) return undefined;
|
||||
if (!queue.concurrencyLimitOverriddenBy) {
|
||||
return {
|
||||
...queue,
|
||||
concurrencyLimitOverriddenBy: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const user = await prismaClient.user.findFirst({
|
||||
where: { id: queue.concurrencyLimitOverriddenBy },
|
||||
});
|
||||
|
||||
return {
|
||||
...queue,
|
||||
concurrencyLimitOverriddenBy: user,
|
||||
};
|
||||
}
|
||||
|
||||
export class QueueRetrievePresenter extends BasePresenter {
|
||||
@@ -75,6 +109,9 @@ export class QueueRetrievePresenter extends BasePresenter {
|
||||
running: results[1]?.[queue.name] ?? 0,
|
||||
queued: results[0]?.[queue.name] ?? 0,
|
||||
concurrencyLimit: queue.concurrencyLimit ?? null,
|
||||
concurrencyLimitBase: queue.concurrencyLimitBase ?? null,
|
||||
concurrencyLimitOverriddenAt: queue.concurrencyLimitOverriddenAt ?? null,
|
||||
concurrencyLimitOverriddenBy: queue.concurrencyLimitOverriddenBy ?? null,
|
||||
paused: queue.paused,
|
||||
}),
|
||||
};
|
||||
@@ -104,6 +141,9 @@ export function toQueueItem(data: {
|
||||
running: number;
|
||||
queued: number;
|
||||
concurrencyLimit: number | null;
|
||||
concurrencyLimitBase: number | null;
|
||||
concurrencyLimitOverriddenAt: Date | null;
|
||||
concurrencyLimitOverriddenBy: User | null;
|
||||
paused: boolean;
|
||||
}): QueueItem & { releaseConcurrencyOnWaitpoint: boolean } {
|
||||
return {
|
||||
@@ -113,9 +153,22 @@ export function toQueueItem(data: {
|
||||
type: queueTypeFromType(data.type),
|
||||
running: data.running,
|
||||
queued: data.queued,
|
||||
concurrencyLimit: data.concurrencyLimit,
|
||||
paused: data.paused,
|
||||
concurrencyLimit: data.concurrencyLimit,
|
||||
concurrency: {
|
||||
current: data.concurrencyLimit,
|
||||
base: data.concurrencyLimitBase,
|
||||
override: data.concurrencyLimitOverriddenAt ? data.concurrencyLimit : null,
|
||||
overriddenBy: toQueueConcurrencyOverriddenBy(data.concurrencyLimitOverriddenBy),
|
||||
overriddenAt: data.concurrencyLimitOverriddenAt,
|
||||
},
|
||||
// TODO: This needs to be removed but keeping this here for now to avoid breaking existing clients
|
||||
releaseConcurrencyOnWaitpoint: true,
|
||||
};
|
||||
}
|
||||
|
||||
function toQueueConcurrencyOverriddenBy(user: User | null) {
|
||||
if (!user) return null;
|
||||
|
||||
return user.displayName ?? user.name ?? null;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { millisecondsToNanoseconds } from "@trigger.dev/core/v3";
|
||||
import { createTreeFromFlatItems, flattenTree } from "~/components/primitives/TreeView/TreeView";
|
||||
import { prisma, PrismaClient } from "~/db.server";
|
||||
import { prisma, type PrismaClient } from "~/db.server";
|
||||
import { createTimelineSpanEventsFromSpanEvents } from "~/utils/timelineSpanEvents";
|
||||
import { getUsername } from "~/utils/username";
|
||||
import { eventRepository } from "~/v3/eventRepository.server";
|
||||
import { resolveEventRepositoryForStore } from "~/v3/eventRepository/index.server";
|
||||
import { SpanSummary } from "~/v3/eventRepository/eventRepository.types";
|
||||
import { getTaskEventStoreTableForRun } from "~/v3/taskEventStore.server";
|
||||
import { isFinalRunStatus } from "~/v3/taskStatus";
|
||||
import { env } from "~/env.server";
|
||||
|
||||
type Result = Awaited<ReturnType<RunPresenter["call"]>>;
|
||||
export type Run = Result["run"];
|
||||
@@ -28,7 +30,6 @@ export class RunPresenter {
|
||||
public async call({
|
||||
userId,
|
||||
projectSlug,
|
||||
organizationSlug,
|
||||
environmentSlug,
|
||||
runFriendlyId,
|
||||
showDeletedLogs,
|
||||
@@ -36,7 +37,6 @@ export class RunPresenter {
|
||||
}: {
|
||||
userId: string;
|
||||
projectSlug: string;
|
||||
organizationSlug: string;
|
||||
environmentSlug: string;
|
||||
runFriendlyId: string;
|
||||
showDeletedLogs: boolean;
|
||||
@@ -47,9 +47,11 @@ export class RunPresenter {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
taskEventStore: true,
|
||||
taskIdentifier: true,
|
||||
number: true,
|
||||
traceId: true,
|
||||
spanId: true,
|
||||
parentSpanId: true,
|
||||
friendlyId: true,
|
||||
status: true,
|
||||
startedAt: true,
|
||||
@@ -58,7 +60,13 @@ export class RunPresenter {
|
||||
rootTaskRun: {
|
||||
select: {
|
||||
friendlyId: true,
|
||||
taskIdentifier: true,
|
||||
spanId: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
parentTaskRun: {
|
||||
select: {
|
||||
friendlyId: true,
|
||||
spanId: true,
|
||||
createdAt: true,
|
||||
},
|
||||
@@ -87,6 +95,13 @@ export class RunPresenter {
|
||||
friendlyId: runFriendlyId,
|
||||
project: {
|
||||
slug: projectSlug,
|
||||
organization: {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -111,6 +126,7 @@ export class RunPresenter {
|
||||
completedAt: run.completedAt,
|
||||
logsDeletedAt: showDeletedLogs ? null : run.logsDeletedAt,
|
||||
rootTaskRun: run.rootTaskRun,
|
||||
parentTaskRun: run.parentTaskRun,
|
||||
environment: {
|
||||
id: run.runtimeEnvironment.id,
|
||||
organizationId: run.runtimeEnvironment.organizationId,
|
||||
@@ -125,21 +141,56 @@ export class RunPresenter {
|
||||
return {
|
||||
run: runData,
|
||||
trace: undefined,
|
||||
maximumLiveReloadingSetting: env.MAXIMUM_LIVE_RELOADING_EVENTS,
|
||||
};
|
||||
}
|
||||
|
||||
const eventRepository = resolveEventRepositoryForStore(run.taskEventStore);
|
||||
|
||||
// get the events
|
||||
const traceSummary = await eventRepository.getTraceSummary(
|
||||
let traceSummary = await eventRepository.getTraceSummary(
|
||||
getTaskEventStoreTableForRun(run),
|
||||
run.runtimeEnvironment.id,
|
||||
run.traceId,
|
||||
run.rootTaskRun?.createdAt ?? run.createdAt,
|
||||
run.completedAt ?? undefined,
|
||||
{ includeDebugLogs: showDebug }
|
||||
);
|
||||
|
||||
if (!traceSummary) {
|
||||
return {
|
||||
run: runData,
|
||||
trace: undefined,
|
||||
const spanSummary: SpanSummary = {
|
||||
id: run.spanId,
|
||||
parentId: run.parentSpanId ?? undefined,
|
||||
runId: run.friendlyId,
|
||||
data: {
|
||||
message: run.taskIdentifier,
|
||||
style: { icon: "task", variant: "primary" },
|
||||
events: [],
|
||||
startTime: run.createdAt,
|
||||
duration: 0,
|
||||
isError:
|
||||
run.status === "COMPLETED_WITH_ERRORS" ||
|
||||
run.status === "CRASHED" ||
|
||||
run.status === "EXPIRED" ||
|
||||
run.status === "SYSTEM_FAILURE" ||
|
||||
run.status === "TIMED_OUT",
|
||||
isPartial:
|
||||
run.status === "DELAYED" ||
|
||||
run.status === "PENDING" ||
|
||||
run.status === "PAUSED" ||
|
||||
run.status === "RETRYING_AFTER_FAILURE" ||
|
||||
run.status === "DEQUEUED" ||
|
||||
run.status === "EXECUTING" ||
|
||||
run.status === "WAITING_TO_RESUME",
|
||||
isCancelled: run.status === "CANCELED",
|
||||
isDebug: false,
|
||||
level: "TRACE",
|
||||
},
|
||||
};
|
||||
|
||||
traceSummary = {
|
||||
rootSpan: spanSummary,
|
||||
spans: [spanSummary],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -202,15 +253,15 @@ export class RunPresenter {
|
||||
trace: {
|
||||
rootSpanStatus,
|
||||
events: events,
|
||||
parentRunFriendlyId:
|
||||
tree?.id === traceSummary.rootSpan.id ? undefined : traceSummary.rootSpan.runId,
|
||||
duration: totalDuration,
|
||||
rootStartedAt: tree?.data.startTime,
|
||||
startedAt: run.startedAt,
|
||||
queuedDuration: run.startedAt
|
||||
? millisecondsToNanoseconds(run.startedAt.getTime() - run.createdAt.getTime())
|
||||
: undefined,
|
||||
overridesBySpanId: traceSummary.overridesBySpanId,
|
||||
},
|
||||
maximumLiveReloadingSetting: eventRepository.maximumLiveReloadingSetting,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { eventStream } from "remix-utils/sse/server";
|
||||
import { PrismaClient, prisma } from "~/db.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { throttle } from "~/utils/throttle";
|
||||
import { eventRepository } from "~/v3/eventRepository.server";
|
||||
import { tracePubSub } from "~/v3/services/tracePubSub.server";
|
||||
|
||||
const pingInterval = 1000;
|
||||
|
||||
@@ -41,7 +41,7 @@ export class RunStreamPresenter {
|
||||
|
||||
let pinger: NodeJS.Timeout | undefined = undefined;
|
||||
|
||||
const { unsubscribe, eventEmitter } = await eventRepository.subscribeToTrace(run.traceId);
|
||||
const { unsubscribe, eventEmitter } = await tracePubSub.subscribeToTrace(run.traceId);
|
||||
|
||||
return eventStream(request.signal, (send, close) => {
|
||||
const safeSend = (args: { event?: string; data: string }) => {
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { RunsRepository } from "~/services/runsRepository/runsRepository.server";
|
||||
import { BasePresenter } from "./basePresenter.server";
|
||||
import { clickhouseClient } from "~/services/clickhouseInstance.server";
|
||||
import { type PrismaClient } from "@trigger.dev/database";
|
||||
import { timeFilters } from "~/components/runs/v3/SharedFilters";
|
||||
|
||||
export type TagListOptions = {
|
||||
userId?: string;
|
||||
organizationId: string;
|
||||
environmentId: string;
|
||||
projectId: string;
|
||||
period?: string;
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
//filters
|
||||
name?: string;
|
||||
//pagination
|
||||
@@ -17,40 +25,39 @@ export type TagListItem = TagList["tags"][number];
|
||||
|
||||
export class RunTagListPresenter extends BasePresenter {
|
||||
public async call({
|
||||
userId,
|
||||
organizationId,
|
||||
environmentId,
|
||||
projectId,
|
||||
name,
|
||||
period,
|
||||
from,
|
||||
to,
|
||||
page = 1,
|
||||
pageSize = DEFAULT_PAGE_SIZE,
|
||||
}: TagListOptions) {
|
||||
const hasFilters = Boolean(name?.trim());
|
||||
|
||||
const tags = await this._replica.taskRunTag.findMany({
|
||||
where: {
|
||||
projectId,
|
||||
name: name
|
||||
? {
|
||||
startsWith: name,
|
||||
mode: "insensitive",
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
orderBy: {
|
||||
id: "desc",
|
||||
},
|
||||
take: pageSize + 1,
|
||||
skip: (page - 1) * pageSize,
|
||||
const runsRepository = new RunsRepository({
|
||||
clickhouse: clickhouseClient,
|
||||
prisma: this._replica as PrismaClient,
|
||||
});
|
||||
|
||||
const tags = await runsRepository.listTags({
|
||||
organizationId,
|
||||
projectId,
|
||||
environmentId,
|
||||
query: name,
|
||||
period,
|
||||
from: from ? from.getTime() : undefined,
|
||||
to: to ? to.getTime() : undefined,
|
||||
offset: (page - 1) * pageSize,
|
||||
limit: pageSize + 1,
|
||||
});
|
||||
|
||||
return {
|
||||
tags: tags
|
||||
.map((tag) => ({
|
||||
id: tag.friendlyId,
|
||||
name: tag.name,
|
||||
}))
|
||||
.slice(0, pageSize),
|
||||
tags: tags.tags,
|
||||
currentPage: page,
|
||||
hasMore: tags.length > pageSize,
|
||||
hasMore: tags.tags.length > pageSize,
|
||||
hasFilters,
|
||||
};
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user