Compare commits
191 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| efd77109d9 | |||
| 158392f352 | |||
| 4d0647a7bb | |||
| c1f822a22f | |||
| 87ae16b964 | |||
| 4a6159a3d5 | |||
| bf288519d8 | |||
| a8513011d4 | |||
| fff74e1719 | |||
| cd629524ae | |||
| 86a7f0911d | |||
| dd55ef67aa | |||
| 05439c955a | |||
| 15f0d7a937 | |||
| aaad5f1a36 | |||
| 8a7b74c75e | |||
| dff6abc1a2 | |||
| d9a0638f34 | |||
| 1bf2b55f68 | |||
| 414dd6d635 | |||
| 50c9836cdc | |||
| b2a3fb1490 | |||
| 6f6df7e475 | |||
| 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,5 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
Stop failing attempt spans when a run is cancelled
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
fix(realtime): Upgrade to @electric-sql/client@1.0.14 to prevent cached 409 Conflict errors from breaking realtime updates
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Added INSTALLING status to the deployment status enum.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Fix SIGTERM handling during warm start long poll
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Added support for deployments with local builds.
|
||||
+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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
+2
-1
@@ -63,4 +63,5 @@ apps/**/public/build
|
||||
/packages/core/src/package.json
|
||||
/packages/trigger-sdk/src/package.json
|
||||
/packages/python/src/package.json
|
||||
.claude
|
||||
.claude
|
||||
.mcp.log
|
||||
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
|
||||
}
|
||||
|
||||
+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.
|
||||
|
||||
+15
-70
@@ -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
|
||||
|
||||
|
||||
@@ -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(),
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,9 +167,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 +226,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,
|
||||
@@ -249,6 +274,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 +307,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`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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>
|
||||
);
|
||||
}
|
||||
@@ -52,6 +52,15 @@ import {
|
||||
} from "./SetupCommands";
|
||||
import { StepContentContainer } from "./StepContentContainer";
|
||||
import { V4Badge } from "./V4Badge";
|
||||
import { GitHubConnectionPrompt } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route";
|
||||
import SegmentedControl from "./primitives/SegmentedControl";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ClientTabs,
|
||||
ClientTabsContent,
|
||||
ClientTabsList,
|
||||
ClientTabsTrigger,
|
||||
} from "./primitives/ClientTabs";
|
||||
|
||||
export function HasNoTasksDev() {
|
||||
return (
|
||||
@@ -93,62 +102,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 +220,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 +229,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 +592,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">
|
||||
<GitHubConnectionPrompt
|
||||
gitHubAppInstallations={[]}
|
||||
organizationSlug={organization.slug}
|
||||
projectSlug={project.slug}
|
||||
environmentSlug={environment.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>
|
||||
|
||||
@@ -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" />
|
||||
@@ -147,6 +147,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>
|
||||
|
||||
@@ -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,13 +13,14 @@ 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";
|
||||
@@ -397,9 +398,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
|
||||
@@ -345,16 +343,16 @@ export const LinkButton = ({
|
||||
...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 (
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -8,10 +8,12 @@ export function CopyableText({
|
||||
value,
|
||||
copyValue,
|
||||
className,
|
||||
asChild,
|
||||
}: {
|
||||
value: string;
|
||||
copyValue?: string;
|
||||
className?: string;
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const { copy, copied } = useCopy(copyValue ?? value);
|
||||
@@ -35,6 +37,7 @@ export function CopyableText({
|
||||
<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"
|
||||
@@ -50,6 +53,7 @@ export function CopyableText({
|
||||
content={copied ? "Copied!" : "Copy"}
|
||||
className="font-sans"
|
||||
disableHoverableContent
|
||||
asChild={asChild}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" />
|
||||
)}
|
||||
@@ -116,7 +209,11 @@ export function TabButton({
|
||||
{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,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/${
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+1200
-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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -102,6 +102,10 @@ export class DeploymentPresenter {
|
||||
builtAt: true,
|
||||
deployedAt: true,
|
||||
createdAt: true,
|
||||
startedAt: true,
|
||||
installedAt: true,
|
||||
canceledAt: true,
|
||||
canceledReason: true,
|
||||
git: true,
|
||||
promotions: {
|
||||
select: {
|
||||
@@ -145,8 +149,12 @@ export class DeploymentPresenter {
|
||||
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: {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,13 +10,15 @@ import {
|
||||
import { AttemptId, getMaxDuration, parseTraceparent } from "@trigger.dev/core/v3/isomorphic";
|
||||
import { RUNNING_STATUSES } from "~/components/runs/v3/TaskRunStatus";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { eventRepository, rehydrateAttribute } from "~/v3/eventRepository.server";
|
||||
import { rehydrateAttribute } from "~/v3/eventRepository/eventRepository.server";
|
||||
import { machinePresetFromRun } from "~/v3/machinePresets.server";
|
||||
import { getTaskEventStoreTableForRun, type TaskEventStoreTable } from "~/v3/taskEventStore.server";
|
||||
import { isFailedRunStatus, isFinalRunStatus } from "~/v3/taskStatus";
|
||||
import { BasePresenter } from "./basePresenter.server";
|
||||
import { WaitpointPresenter } from "./WaitpointPresenter.server";
|
||||
import { engine } from "~/v3/runEngine.server";
|
||||
import { resolveEventRepositoryForStore } from "~/v3/eventRepository/index.server";
|
||||
import { IEventRepository, SpanDetail } from "~/v3/eventRepository/eventRepository.types";
|
||||
|
||||
type Result = Awaited<ReturnType<SpanPresenter["call"]>>;
|
||||
export type Span = NonNullable<NonNullable<Result>["span"]>;
|
||||
@@ -24,14 +26,16 @@ export type SpanRun = NonNullable<NonNullable<Result>["run"]>;
|
||||
type FindRunResult = NonNullable<
|
||||
Awaited<ReturnType<InstanceType<typeof SpanPresenter>["findRun"]>>
|
||||
>;
|
||||
type GetSpanResult = NonNullable<Awaited<ReturnType<(typeof eventRepository)["getSpan"]>>>;
|
||||
type GetSpanResult = SpanDetail;
|
||||
|
||||
export class SpanPresenter extends BasePresenter {
|
||||
public async call({
|
||||
userId,
|
||||
projectSlug,
|
||||
spanId,
|
||||
runFriendlyId,
|
||||
}: {
|
||||
userId: string;
|
||||
projectSlug: string;
|
||||
spanId: string;
|
||||
runFriendlyId: string;
|
||||
@@ -39,6 +43,13 @@ export class SpanPresenter extends BasePresenter {
|
||||
const project = await this._replica.project.findFirst({
|
||||
where: {
|
||||
slug: projectSlug,
|
||||
organization: {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -57,6 +68,7 @@ export class SpanPresenter extends BasePresenter {
|
||||
},
|
||||
where: {
|
||||
friendlyId: runFriendlyId,
|
||||
projectId: project.id,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -66,14 +78,18 @@ export class SpanPresenter extends BasePresenter {
|
||||
|
||||
const { traceId } = parentRun;
|
||||
|
||||
const eventRepository = resolveEventRepositoryForStore(parentRun.taskEventStore);
|
||||
|
||||
const eventStore = getTaskEventStoreTableForRun(parentRun);
|
||||
|
||||
const run = await this.getRun({
|
||||
eventStore,
|
||||
traceId,
|
||||
eventRepository,
|
||||
spanId,
|
||||
createdAt: parentRun.createdAt,
|
||||
completedAt: parentRun.completedAt,
|
||||
environmentId: parentRun.runtimeEnvironmentId,
|
||||
});
|
||||
if (run) {
|
||||
return {
|
||||
@@ -82,15 +98,15 @@ export class SpanPresenter extends BasePresenter {
|
||||
};
|
||||
}
|
||||
|
||||
//get the run
|
||||
const span = await this.#getSpan({
|
||||
eventStore,
|
||||
traceId,
|
||||
spanId,
|
||||
traceId,
|
||||
environmentId: parentRun.runtimeEnvironmentId,
|
||||
projectId: parentRun.projectId,
|
||||
createdAt: parentRun.createdAt,
|
||||
completedAt: parentRun.completedAt,
|
||||
eventRepository,
|
||||
});
|
||||
|
||||
if (!span) {
|
||||
@@ -105,30 +121,31 @@ export class SpanPresenter extends BasePresenter {
|
||||
|
||||
async getRun({
|
||||
eventStore,
|
||||
environmentId,
|
||||
traceId,
|
||||
eventRepository,
|
||||
spanId,
|
||||
createdAt,
|
||||
completedAt,
|
||||
}: {
|
||||
eventStore: TaskEventStoreTable;
|
||||
environmentId: string;
|
||||
traceId: string;
|
||||
eventRepository: IEventRepository;
|
||||
spanId: string;
|
||||
createdAt: Date;
|
||||
completedAt: Date | null;
|
||||
}) {
|
||||
const span = await eventRepository.getSpan(
|
||||
const originalRunId = await eventRepository.getSpanOriginalRunId(
|
||||
eventStore,
|
||||
environmentId,
|
||||
spanId,
|
||||
traceId,
|
||||
createdAt,
|
||||
completedAt ?? undefined
|
||||
);
|
||||
|
||||
if (!span) {
|
||||
return;
|
||||
}
|
||||
|
||||
const run = await this.findRun({ span, spanId });
|
||||
const run = await this.findRun({ originalRunId, spanId, environmentId });
|
||||
|
||||
if (!run) {
|
||||
return;
|
||||
@@ -251,8 +268,9 @@ export class SpanPresenter extends BasePresenter {
|
||||
engine: run.engine,
|
||||
region,
|
||||
workerQueue: run.workerQueue,
|
||||
traceId: run.traceId,
|
||||
spanId: run.spanId,
|
||||
isCached: !!span.originalRun,
|
||||
isCached: !!originalRunId,
|
||||
machinePreset: machine?.name,
|
||||
externalTraceId,
|
||||
};
|
||||
@@ -287,7 +305,15 @@ export class SpanPresenter extends BasePresenter {
|
||||
};
|
||||
}
|
||||
|
||||
async findRun({ span, spanId }: { span: GetSpanResult; spanId: string }) {
|
||||
async findRun({
|
||||
originalRunId,
|
||||
spanId,
|
||||
environmentId,
|
||||
}: {
|
||||
originalRunId?: string;
|
||||
spanId: string;
|
||||
environmentId: string;
|
||||
}) {
|
||||
const run = await this._replica.taskRun.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
@@ -397,12 +423,14 @@ export class SpanPresenter extends BasePresenter {
|
||||
},
|
||||
},
|
||||
},
|
||||
where: span.originalRun
|
||||
where: originalRunId
|
||||
? {
|
||||
friendlyId: span.originalRun,
|
||||
friendlyId: originalRunId,
|
||||
runtimeEnvironmentId: environmentId,
|
||||
}
|
||||
: {
|
||||
spanId,
|
||||
runtimeEnvironmentId: environmentId,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -411,6 +439,7 @@ export class SpanPresenter extends BasePresenter {
|
||||
|
||||
async #getSpan({
|
||||
eventStore,
|
||||
eventRepository,
|
||||
traceId,
|
||||
spanId,
|
||||
environmentId,
|
||||
@@ -418,6 +447,7 @@ export class SpanPresenter extends BasePresenter {
|
||||
createdAt,
|
||||
completedAt,
|
||||
}: {
|
||||
eventRepository: IEventRepository;
|
||||
traceId: string;
|
||||
spanId: string;
|
||||
environmentId: string;
|
||||
@@ -428,12 +458,14 @@ export class SpanPresenter extends BasePresenter {
|
||||
}) {
|
||||
const span = await eventRepository.getSpan(
|
||||
eventStore,
|
||||
environmentId,
|
||||
spanId,
|
||||
traceId,
|
||||
createdAt,
|
||||
completedAt ?? undefined,
|
||||
{ includeDebugLogs: true }
|
||||
);
|
||||
|
||||
if (!span) {
|
||||
return;
|
||||
}
|
||||
@@ -445,11 +477,7 @@ export class SpanPresenter extends BasePresenter {
|
||||
spanId: true,
|
||||
createdAt: true,
|
||||
number: true,
|
||||
lockedToVersion: {
|
||||
select: {
|
||||
version: true,
|
||||
},
|
||||
},
|
||||
taskVersion: true,
|
||||
},
|
||||
where: {
|
||||
parentSpanId: spanId,
|
||||
@@ -457,11 +485,21 @@ export class SpanPresenter extends BasePresenter {
|
||||
});
|
||||
|
||||
const data = {
|
||||
...span,
|
||||
spanId: span.spanId,
|
||||
parentId: span.parentId,
|
||||
message: span.message,
|
||||
isError: span.isError,
|
||||
isPartial: span.isPartial,
|
||||
isCancelled: span.isCancelled,
|
||||
level: span.level,
|
||||
startTime: span.startTime,
|
||||
duration: span.duration,
|
||||
events: span.events,
|
||||
style: span.style,
|
||||
properties: span.properties ? JSON.stringify(span.properties, null, 2) : undefined,
|
||||
entity: span.entity,
|
||||
metadata: span.metadata,
|
||||
triggeredRuns,
|
||||
showActionBar: span.show?.actions === true,
|
||||
};
|
||||
|
||||
switch (span.entity.type) {
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { ClickHouse } from "@internal/clickhouse";
|
||||
import { ScheduledTaskPayload, parsePacket, prettyPrintPacket } from "@trigger.dev/core/v3";
|
||||
import {
|
||||
type TaskRunTemplate,
|
||||
type RuntimeEnvironmentType,
|
||||
type TaskRunStatus,
|
||||
type TaskRunTemplate,
|
||||
PrismaClientOrTransaction,
|
||||
} from "@trigger.dev/database";
|
||||
import { type PrismaClient, prisma, sqlDatabaseSchema } from "~/db.server";
|
||||
import parse from "parse-duration";
|
||||
import { type PrismaClient } from "~/db.server";
|
||||
import { RunsRepository } from "~/services/runsRepository/runsRepository.server";
|
||||
import { getTimezones } from "~/utils/timezones.server";
|
||||
import { findCurrentWorkerDeployment } from "~/v3/models/workerDeployment.server";
|
||||
import { queueTypeFromType } from "./QueueRetrievePresenter.server";
|
||||
import parse from "parse-duration";
|
||||
|
||||
export type RunTemplate = TaskRunTemplate & {
|
||||
scheduledTaskPayload?: ScheduledRun["payload"];
|
||||
@@ -20,6 +23,8 @@ type TestTaskOptions = {
|
||||
environment: {
|
||||
id: string;
|
||||
type: RuntimeEnvironmentType;
|
||||
projectId: string;
|
||||
organizationId: string;
|
||||
};
|
||||
taskIdentifier: string;
|
||||
};
|
||||
@@ -111,11 +116,10 @@ export type ScheduledRun = Omit<RawRun, "payload" | "ttl"> & {
|
||||
};
|
||||
|
||||
export class TestTaskPresenter {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
constructor(
|
||||
private readonly replica: PrismaClientOrTransaction,
|
||||
private readonly clickhouse: ClickHouse
|
||||
) {}
|
||||
|
||||
public async call({
|
||||
userId,
|
||||
@@ -128,7 +132,7 @@ export class TestTaskPresenter {
|
||||
? (
|
||||
await findCurrentWorkerDeployment({ environmentId: environment.id })
|
||||
)?.worker?.tasks.find((t) => t.slug === taskIdentifier)
|
||||
: await this.#prismaClient.backgroundWorkerTask.findFirst({
|
||||
: await this.replica.backgroundWorkerTask.findFirst({
|
||||
where: {
|
||||
slug: taskIdentifier,
|
||||
runtimeEnvironmentId: environment.id,
|
||||
@@ -145,7 +149,7 @@ export class TestTaskPresenter {
|
||||
}
|
||||
|
||||
const taskQueue = task.queueId
|
||||
? await this.#prismaClient.taskQueue.findFirst({
|
||||
? await this.replica.taskQueue.findFirst({
|
||||
where: {
|
||||
runtimeEnvironmentId: environment.id,
|
||||
id: task.queueId,
|
||||
@@ -159,7 +163,7 @@ export class TestTaskPresenter {
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const backgroundWorkers = await this.#prismaClient.backgroundWorker.findMany({
|
||||
const backgroundWorkers = await this.replica.backgroundWorker.findMany({
|
||||
where: {
|
||||
runtimeEnvironmentId: environment.id,
|
||||
},
|
||||
@@ -173,7 +177,7 @@ export class TestTaskPresenter {
|
||||
take: 20, // last 20 versions should suffice
|
||||
});
|
||||
|
||||
const taskRunTemplates = await this.#prismaClient.taskRunTemplate.findMany({
|
||||
const taskRunTemplates = await this.replica.taskRunTemplate.findMany({
|
||||
where: {
|
||||
projectId,
|
||||
taskSlug: task.slug,
|
||||
@@ -190,47 +194,55 @@ export class TestTaskPresenter {
|
||||
const disableVersionSelection = environment.type === "DEVELOPMENT";
|
||||
const allowArbitraryQueues = backgroundWorkers[0]?.engine === "V1";
|
||||
|
||||
const latestRuns = await this.#prismaClient.$queryRaw<RawRun[]>`
|
||||
WITH taskruns AS (
|
||||
SELECT
|
||||
tr.*
|
||||
FROM
|
||||
${sqlDatabaseSchema}."TaskRun" as tr
|
||||
JOIN
|
||||
${sqlDatabaseSchema}."BackgroundWorkerTask" as bwt
|
||||
ON
|
||||
tr."taskIdentifier" = bwt.slug
|
||||
WHERE
|
||||
bwt."friendlyId" = ${task.friendlyId} AND
|
||||
tr."runtimeEnvironmentId" = ${environment.id}
|
||||
ORDER BY
|
||||
tr."createdAt" DESC
|
||||
LIMIT 10
|
||||
)
|
||||
SELECT
|
||||
taskr.id,
|
||||
taskr."queue",
|
||||
taskr."friendlyId",
|
||||
taskr."taskIdentifier",
|
||||
taskr."createdAt",
|
||||
taskr.status,
|
||||
taskr.payload,
|
||||
taskr."payloadType",
|
||||
taskr."seedMetadata",
|
||||
taskr."seedMetadataType",
|
||||
taskr."runtimeEnvironmentId",
|
||||
taskr."concurrencyKey",
|
||||
taskr."maxAttempts",
|
||||
taskr."maxDurationInSeconds",
|
||||
taskr."machinePreset",
|
||||
taskr."ttl",
|
||||
taskr."runTags"
|
||||
FROM
|
||||
taskruns AS taskr
|
||||
WHERE
|
||||
taskr."payloadType" = 'application/json' OR taskr."payloadType" = 'application/super+json'
|
||||
ORDER BY
|
||||
taskr."createdAt" DESC;`;
|
||||
// Get the latest runs, for the payloads
|
||||
const runsRepository = new RunsRepository({
|
||||
clickhouse: this.clickhouse,
|
||||
prisma: this.replica as PrismaClient,
|
||||
});
|
||||
|
||||
const runIds = await runsRepository.listRunIds({
|
||||
organizationId: environment.organizationId,
|
||||
environmentId: environment.id,
|
||||
projectId: environment.projectId,
|
||||
tasks: [task.slug],
|
||||
period: "30d",
|
||||
page: {
|
||||
size: 10,
|
||||
},
|
||||
});
|
||||
|
||||
const latestRuns = await this.replica.taskRun.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
queue: true,
|
||||
friendlyId: true,
|
||||
taskIdentifier: true,
|
||||
createdAt: true,
|
||||
status: true,
|
||||
payload: true,
|
||||
payloadType: true,
|
||||
seedMetadata: true,
|
||||
seedMetadataType: true,
|
||||
runtimeEnvironmentId: true,
|
||||
concurrencyKey: true,
|
||||
maxAttempts: true,
|
||||
maxDurationInSeconds: true,
|
||||
machinePreset: true,
|
||||
ttl: true,
|
||||
runTags: true,
|
||||
},
|
||||
where: {
|
||||
id: {
|
||||
in: runIds,
|
||||
},
|
||||
payloadType: {
|
||||
in: ["application/json", "application/super+json"],
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
const taskWithEnvironment = {
|
||||
id: task.id,
|
||||
@@ -258,6 +270,12 @@ export class TestTaskPresenter {
|
||||
async (r) =>
|
||||
({
|
||||
...r,
|
||||
seedMetadata: r.seedMetadata ?? undefined,
|
||||
seedMetadataType: r.seedMetadataType ?? undefined,
|
||||
concurrencyKey: r.concurrencyKey ?? undefined,
|
||||
maxAttempts: r.maxAttempts ?? undefined,
|
||||
maxDurationInSeconds: r.maxDurationInSeconds ?? undefined,
|
||||
machinePreset: r.machinePreset ?? undefined,
|
||||
payload: await prettyPrintPacket(r.payload, r.payloadType),
|
||||
metadata: r.seedMetadata
|
||||
? await prettyPrintPacket(r.seedMetadata, r.seedMetadataType)
|
||||
@@ -300,6 +318,12 @@ export class TestTaskPresenter {
|
||||
if (payload.success) {
|
||||
return {
|
||||
...r,
|
||||
seedMetadata: r.seedMetadata ?? undefined,
|
||||
seedMetadataType: r.seedMetadataType ?? undefined,
|
||||
concurrencyKey: r.concurrencyKey ?? undefined,
|
||||
maxAttempts: r.maxAttempts ?? undefined,
|
||||
maxDurationInSeconds: r.maxDurationInSeconds ?? undefined,
|
||||
machinePreset: r.machinePreset ?? undefined,
|
||||
payload: payload.data,
|
||||
ttlSeconds: r.ttl ? parse(r.ttl, "s") ?? undefined : undefined,
|
||||
} satisfies ScheduledRun;
|
||||
|
||||
@@ -20,10 +20,17 @@ export const links: LinksFunction = () => {
|
||||
return [{ rel: "stylesheet", href: tailwindStylesheetUrl }];
|
||||
};
|
||||
|
||||
export const headers = () => ({
|
||||
"Referrer-Policy": "strict-origin-when-cross-origin",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Permissions-Policy":
|
||||
"geolocation=(), microphone=(), camera=(), accelerometer=(), gyroscope=(), magnetometer=(), payment=(), usb=()",
|
||||
});
|
||||
|
||||
export const meta: MetaFunction = ({ data }) => {
|
||||
const typedData = data as UseDataFunctionReturn<typeof loader>;
|
||||
return [
|
||||
{ title: `Trigger.dev${appEnvTitleTag(typedData.appEnv)}` },
|
||||
{ title: typedData?.appEnv ? `Trigger.dev${appEnvTitleTag(typedData.appEnv)}` : "Trigger.dev" },
|
||||
{
|
||||
name: "viewport",
|
||||
content: "width=1024, initial-scale=1",
|
||||
@@ -84,11 +91,13 @@ export function ErrorBoundary() {
|
||||
<Links />
|
||||
</head>
|
||||
<body className="h-full overflow-hidden bg-background-dimmed">
|
||||
<AppContainer>
|
||||
<MainCenteredContainer>
|
||||
<RouteErrorDisplay />
|
||||
</MainCenteredContainer>
|
||||
</AppContainer>
|
||||
<ShortcutsProvider>
|
||||
<AppContainer>
|
||||
<MainCenteredContainer>
|
||||
<RouteErrorDisplay />
|
||||
</MainCenteredContainer>
|
||||
</AppContainer>
|
||||
</ShortcutsProvider>
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { type LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { z } from "zod";
|
||||
import { validateGitHubAppInstallSession } from "~/services/gitHubSession.server";
|
||||
import { linkGitHubAppInstallation, updateGitHubAppInstallation } from "~/services/gitHub.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { tryCatch } from "@trigger.dev/core";
|
||||
import { $replica } from "~/db.server";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
import { sanitizeRedirectPath } from "~/utils";
|
||||
|
||||
const QuerySchema = z.discriminatedUnion("setup_action", [
|
||||
z.object({
|
||||
setup_action: z.literal("install"),
|
||||
installation_id: z.coerce.number(),
|
||||
state: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
setup_action: z.literal("update"),
|
||||
installation_id: z.coerce.number(),
|
||||
state: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
setup_action: z.literal("request"),
|
||||
state: z.string(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
const url = new URL(request.url);
|
||||
const queryParams = Object.fromEntries(url.searchParams);
|
||||
const cookieHeader = request.headers.get("Cookie");
|
||||
|
||||
const result = QuerySchema.safeParse(queryParams);
|
||||
|
||||
if (!result.success) {
|
||||
logger.warn("GitHub App callback with invalid params", {
|
||||
queryParams,
|
||||
});
|
||||
return redirectWithErrorMessage("/", request, "Failed to install GitHub app");
|
||||
}
|
||||
|
||||
const callbackData = result.data;
|
||||
|
||||
const sessionResult = await validateGitHubAppInstallSession(cookieHeader, callbackData.state);
|
||||
|
||||
if (!sessionResult.valid) {
|
||||
logger.error("GitHub App callback with invalid session", {
|
||||
callbackData,
|
||||
error: sessionResult.error,
|
||||
});
|
||||
|
||||
return redirectWithErrorMessage("/", request, "Failed to install GitHub app");
|
||||
}
|
||||
|
||||
const { organizationId, redirectTo: unsafeRedirectTo } = sessionResult;
|
||||
const redirectTo = sanitizeRedirectPath(unsafeRedirectTo);
|
||||
|
||||
const user = await requireUser(request);
|
||||
const org = await $replica.organization.findFirst({
|
||||
where: { id: organizationId, members: { some: { userId: user.id } }, deletedAt: null },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!org) {
|
||||
// the secure cookie approach should already protect against this
|
||||
// just an additional check
|
||||
logger.error("GitHub app installation attempt on unauthenticated org", {
|
||||
userId: user.id,
|
||||
organizationId,
|
||||
});
|
||||
return redirectWithErrorMessage(redirectTo, request, "Failed to install GitHub app");
|
||||
}
|
||||
|
||||
switch (callbackData.setup_action) {
|
||||
case "install": {
|
||||
const [error] = await tryCatch(
|
||||
linkGitHubAppInstallation(callbackData.installation_id, organizationId)
|
||||
);
|
||||
|
||||
if (error) {
|
||||
logger.error("Failed to link GitHub App installation", {
|
||||
error,
|
||||
});
|
||||
return redirectWithErrorMessage(redirectTo, request, "Failed to install GitHub app");
|
||||
}
|
||||
|
||||
return redirectWithSuccessMessage(redirectTo, request, "GitHub App installed successfully");
|
||||
}
|
||||
|
||||
case "update": {
|
||||
const [error] = await tryCatch(updateGitHubAppInstallation(callbackData.installation_id));
|
||||
|
||||
if (error) {
|
||||
logger.error("Failed to update GitHub App installation", {
|
||||
error,
|
||||
});
|
||||
return redirectWithErrorMessage(redirectTo, request, "Failed to update GitHub App");
|
||||
}
|
||||
|
||||
return redirectWithSuccessMessage(redirectTo, request, "GitHub App updated successfully");
|
||||
}
|
||||
|
||||
case "request": {
|
||||
// This happens when a non-admin user requests installation
|
||||
// The installation_id won't be available until an admin approves
|
||||
logger.info("GitHub App installation requested, awaiting approval", {
|
||||
callbackData,
|
||||
});
|
||||
|
||||
return redirectWithSuccessMessage(redirectTo, request, "GitHub App installation requested");
|
||||
}
|
||||
|
||||
default:
|
||||
callbackData satisfies never;
|
||||
return redirectWithErrorMessage(redirectTo, request, "Failed to install GitHub app");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { redirect } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { $replica } from "~/db.server";
|
||||
import { createGitHubAppInstallSession } from "~/services/gitHubSession.server";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
import { newOrganizationPath } from "~/utils/pathBuilder";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { sanitizeRedirectPath } from "~/utils";
|
||||
|
||||
const QuerySchema = z.object({
|
||||
org_slug: z.string(),
|
||||
redirect_to: z.string().refine((value) => value === sanitizeRedirectPath(value), {
|
||||
message: "Invalid redirect path",
|
||||
}),
|
||||
});
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const searchParams = new URL(request.url).searchParams;
|
||||
const parsed = QuerySchema.safeParse(Object.fromEntries(searchParams));
|
||||
|
||||
if (!parsed.success) {
|
||||
logger.warn("GitHub App installation redirect with invalid params", {
|
||||
searchParams,
|
||||
error: parsed.error,
|
||||
});
|
||||
throw redirect("/");
|
||||
}
|
||||
|
||||
const { org_slug, redirect_to } = parsed.data;
|
||||
const user = await requireUser(request);
|
||||
|
||||
const org = await $replica.organization.findFirst({
|
||||
where: { slug: org_slug, members: { some: { userId: user.id } }, deletedAt: null },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!org) {
|
||||
throw redirect(newOrganizationPath());
|
||||
}
|
||||
|
||||
const { url, cookieHeader } = await createGitHubAppInstallSession(org.id, redirect_to);
|
||||
|
||||
return redirect(url, {
|
||||
headers: {
|
||||
"Set-Cookie": cookieHeader,
|
||||
},
|
||||
});
|
||||
};
|
||||
+1
-1
@@ -14,7 +14,7 @@ import { Link, useRevalidator, useSubmit } from "@remix-run/react";
|
||||
import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { DiscordIcon } from "@trigger.dev/companyicons";
|
||||
import { formatDurationMilliseconds } from "@trigger.dev/core/v3";
|
||||
import { type TaskRunStatus } from "@trigger.dev/database";
|
||||
import type { TaskRunStatus } from "@trigger.dev/database";
|
||||
import { Fragment, Suspense, useEffect, useState } from "react";
|
||||
import { Bar, BarChart, ResponsiveContainer, Tooltip, type TooltipProps } from "recharts";
|
||||
import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson";
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ import {
|
||||
import { Form, type MetaFunction, Outlet, useActionData, useNavigation } from "@remix-run/react";
|
||||
import { type ActionFunctionArgs, type LoaderFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import { SlackIcon } from "@trigger.dev/companyicons";
|
||||
import { type ProjectAlertChannelType, type ProjectAlertType } from "@trigger.dev/database";
|
||||
import type { ProjectAlertChannelType, ProjectAlertType } from "@trigger.dev/database";
|
||||
import assertNever from "assert-never";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
|
||||
+4
-1
@@ -130,7 +130,10 @@ export async function action({ request }: ActionFunctionArgs) {
|
||||
}
|
||||
|
||||
const upsertBranchService = new UpsertBranchService();
|
||||
const result = await upsertBranchService.call(userId, submission.value);
|
||||
const result = await upsertBranchService.call(
|
||||
{ type: "userMembership", userId },
|
||||
submission.value
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
if (result.alreadyExisted) {
|
||||
|
||||
+17
-29
@@ -1,10 +1,9 @@
|
||||
import { ArrowPathIcon } from "@heroicons/react/20/solid";
|
||||
import { Form, useRevalidator } from "@remix-run/react";
|
||||
import { Form } from "@remix-run/react";
|
||||
import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { tryCatch } from "@trigger.dev/core";
|
||||
import { BulkActionStatus, BulkActionType } from "@trigger.dev/database";
|
||||
import type { BulkActionType } from "@trigger.dev/database";
|
||||
import { motion } from "framer-motion";
|
||||
import { useEffect } from "react";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { ExitIcon } from "~/assets/icons/ExitIcon";
|
||||
@@ -18,8 +17,9 @@ import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import * as Property from "~/components/primitives/PropertyTable";
|
||||
import { BulkActionStatusCombo, BulkActionTypeCombo } from "~/components/runs/v3/BulkAction";
|
||||
import { UserAvatar } from "~/components/UserProfilePhoto";
|
||||
import { env } from "~/env.server";
|
||||
import { useAutoRevalidate } from "~/hooks/useAutoRevalidate";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useEventSource } from "~/hooks/useEventSource";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
|
||||
@@ -72,7 +72,9 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
throw new Error(error.message);
|
||||
}
|
||||
|
||||
return typedjson({ bulkAction: data });
|
||||
const autoReloadPollIntervalMs = env.BULK_ACTION_AUTORELOAD_POLL_INTERVAL_MS;
|
||||
|
||||
return typedjson({ bulkAction: data, autoReloadPollIntervalMs });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw new Response(undefined, {
|
||||
@@ -130,30 +132,16 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { bulkAction } = useTypedLoaderData<typeof loader>();
|
||||
const { bulkAction, autoReloadPollIntervalMs } = useTypedLoaderData<typeof loader>();
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
const disabled = bulkAction.status !== BulkActionStatus.PENDING;
|
||||
|
||||
const streamedEvents = useEventSource(
|
||||
`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.id}/runs/bulkaction/${bulkAction.friendlyId}/stream`,
|
||||
{
|
||||
event: "progress",
|
||||
disabled,
|
||||
}
|
||||
);
|
||||
|
||||
const revalidation = useRevalidator();
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled || streamedEvents === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
revalidation.revalidate();
|
||||
}, [streamedEvents, disabled]);
|
||||
useAutoRevalidate({
|
||||
interval: autoReloadPollIntervalMs,
|
||||
onFocus: true,
|
||||
disabled: bulkAction.status !== "PENDING",
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="grid h-full max-h-full grid-rows-[2.5rem_2.5rem_1fr_3.25rem] overflow-hidden bg-background-bright">
|
||||
@@ -239,7 +227,7 @@ export default function Page() {
|
||||
<BulkActionFilterSummary
|
||||
selected={bulkAction.totalCount}
|
||||
mode={bulkAction.mode}
|
||||
action={bulkAction.type === BulkActionType.REPLAY ? "replay" : "cancel"}
|
||||
action={bulkAction.type === "REPLAY" ? "replay" : "cancel"}
|
||||
filters={bulkAction.filters}
|
||||
final={true}
|
||||
/>
|
||||
@@ -327,7 +315,7 @@ function Meter({ type, successCount, failureCount, totalCount }: MeterProps) {
|
||||
<div className="h-2 w-2 rounded-[1px] bg-charcoal-550" />
|
||||
<Paragraph variant="extra-small">
|
||||
{formatNumber(failureCount)} {typeText(type)} failed{" "}
|
||||
{type === BulkActionType.CANCEL ? " (already finished)" : ""}
|
||||
{type === "CANCEL" ? " (already finished)" : ""}
|
||||
</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
@@ -337,9 +325,9 @@ function Meter({ type, successCount, failureCount, totalCount }: MeterProps) {
|
||||
|
||||
function typeText(type: BulkActionType) {
|
||||
switch (type) {
|
||||
case BulkActionType.CANCEL:
|
||||
case "CANCEL":
|
||||
return "canceled";
|
||||
case BulkActionType.REPLAY:
|
||||
case "REPLAY":
|
||||
return "replayed";
|
||||
}
|
||||
}
|
||||
|
||||
+51
-14
@@ -4,7 +4,6 @@ import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { ExitIcon } from "~/assets/icons/ExitIcon";
|
||||
import { GitMetadata } from "~/components/GitMetadata";
|
||||
import { RuntimeIcon } from "~/components/RuntimeIcon";
|
||||
import { UserAvatar } from "~/components/UserProfilePhoto";
|
||||
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
|
||||
import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
|
||||
import { Badge } from "~/components/primitives/Badge";
|
||||
@@ -32,6 +31,7 @@ import { requireUserId } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { v3DeploymentParams, v3DeploymentsPath, v3RunsPath } from "~/utils/pathBuilder";
|
||||
import { capitalizeWord } from "~/utils/string";
|
||||
import { UserTag } from "../_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
@@ -131,7 +131,11 @@ export default function Page() {
|
||||
<Property.Label>Deploy</Property.Label>
|
||||
<Property.Value className="flex items-center gap-2">
|
||||
<span>{deployment.shortCode}</span>
|
||||
{deployment.label && <Badge variant="outline-rounded">{deployment.label}</Badge>}
|
||||
{deployment.label && (
|
||||
<Badge variant="extra-small" className="capitalize">
|
||||
{deployment.label}
|
||||
</Badge>
|
||||
)}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
@@ -154,6 +158,22 @@ export default function Page() {
|
||||
/>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
{deployment.canceledAt && (
|
||||
<Property.Item>
|
||||
<Property.Label>Canceled at</Property.Label>
|
||||
<Property.Value>
|
||||
<>
|
||||
<DateTimeAccurate date={deployment.canceledAt} /> UTC
|
||||
</>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
)}
|
||||
{deployment.canceledReason && (
|
||||
<Property.Item>
|
||||
<Property.Label>Cancelation reason</Property.Label>
|
||||
<Property.Value>{deployment.canceledReason}</Property.Value>
|
||||
</Property.Item>
|
||||
)}
|
||||
<Property.Item>
|
||||
<Property.Label>Tasks</Property.Label>
|
||||
<Property.Value>{deployment.tasks ? deployment.tasks.length : "–"}</Property.Value>
|
||||
@@ -187,7 +207,25 @@ export default function Page() {
|
||||
<Property.Item>
|
||||
<Property.Label>Started at</Property.Label>
|
||||
<Property.Value>
|
||||
<DateTimeAccurate date={deployment.createdAt} /> UTC
|
||||
{deployment.startedAt ? (
|
||||
<>
|
||||
<DateTimeAccurate date={deployment.startedAt} /> UTC
|
||||
</>
|
||||
) : (
|
||||
"–"
|
||||
)}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Installed at</Property.Label>
|
||||
<Property.Value>
|
||||
{deployment.installedAt ? (
|
||||
<>
|
||||
<DateTimeAccurate date={deployment.installedAt} /> UTC
|
||||
</>
|
||||
) : (
|
||||
"–"
|
||||
)}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
@@ -226,17 +264,16 @@ export default function Page() {
|
||||
<Property.Item>
|
||||
<Property.Label>Deployed by</Property.Label>
|
||||
<Property.Value>
|
||||
{deployment.deployedBy ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<UserAvatar
|
||||
avatarUrl={deployment.deployedBy.avatarUrl}
|
||||
name={deployment.deployedBy.name ?? deployment.deployedBy.displayName}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<Paragraph variant="small">
|
||||
{deployment.deployedBy.name ?? deployment.deployedBy.displayName}
|
||||
</Paragraph>
|
||||
</div>
|
||||
{deployment.git?.source === "trigger_github_app" ? (
|
||||
<UserTag
|
||||
name={deployment.git.ghUsername ?? "GitHub Integration"}
|
||||
avatarUrl={deployment.git.ghUserAvatarUrl}
|
||||
/>
|
||||
) : deployment.deployedBy ? (
|
||||
<UserTag
|
||||
name={deployment.deployedBy.name ?? deployment.deployedBy.displayName ?? ""}
|
||||
avatarUrl={deployment.deployedBy.avatarUrl ?? undefined}
|
||||
/>
|
||||
) : (
|
||||
"–"
|
||||
)}
|
||||
|
||||
+240
-24
@@ -1,11 +1,26 @@
|
||||
import { ArrowUturnLeftIcon, BookOpenIcon } from "@heroicons/react/20/solid";
|
||||
import { type MetaFunction, Outlet, useLocation, useNavigate, useParams } from "@remix-run/react";
|
||||
import {
|
||||
ArrowPathIcon,
|
||||
ArrowUturnLeftIcon,
|
||||
BookOpenIcon,
|
||||
NoSymbolIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import {
|
||||
Form,
|
||||
type MetaFunction,
|
||||
Outlet,
|
||||
useLocation,
|
||||
useNavigate,
|
||||
useNavigation,
|
||||
useParams,
|
||||
} from "@remix-run/react";
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { CogIcon, GitBranchIcon } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { PromoteIcon } from "~/assets/icons/PromoteIcon";
|
||||
import { DeploymentsNone, DeploymentsNoneDev } from "~/components/BlankStatePanels";
|
||||
import { OctoKitty } from "~/components/GitHubLoginButton";
|
||||
import { GitMetadata } from "~/components/GitMetadata";
|
||||
import { RuntimeIcon } from "~/components/RuntimeIcon";
|
||||
import { UserAvatar } from "~/components/UserProfilePhoto";
|
||||
@@ -13,7 +28,15 @@ import { MainCenteredContainer, PageBody, PageContainer } from "~/components/lay
|
||||
import { Badge } from "~/components/primitives/Badge";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import { Dialog, DialogTrigger } from "~/components/primitives/Dialog";
|
||||
import { SpinnerWhite } from "~/components/primitives/Spinner";
|
||||
import {
|
||||
Dialog,
|
||||
DialogDescription,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTrigger,
|
||||
DialogFooter,
|
||||
} from "~/components/primitives/Dialog";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { PaginationControls } from "~/components/primitives/Pagination";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
@@ -37,10 +60,6 @@ import {
|
||||
deploymentStatusDescription,
|
||||
deploymentStatuses,
|
||||
} from "~/components/runs/v3/DeploymentStatus";
|
||||
import {
|
||||
PromoteDeploymentDialog,
|
||||
RollbackDeploymentDialog,
|
||||
} from "~/components/runs/v3/RollbackDeploymentDialog";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
@@ -50,9 +69,18 @@ import {
|
||||
} from "~/presenters/v3/DeploymentListPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { titleCase } from "~/utils";
|
||||
import { EnvironmentParamSchema, docsPath, v3DeploymentPath } from "~/utils/pathBuilder";
|
||||
import { cn } from "~/utils/cn";
|
||||
import {
|
||||
EnvironmentParamSchema,
|
||||
docsPath,
|
||||
v3DeploymentPath,
|
||||
v3ProjectSettingsPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { createSearchParams } from "~/utils/searchParams";
|
||||
import { compareDeploymentVersions } from "~/v3/utils/deploymentVersions";
|
||||
import { useAutoRevalidate } from "~/hooks/useAutoRevalidate";
|
||||
import { env } from "~/env.server";
|
||||
import { DialogClose } from "@radix-ui/react-dialog";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
@@ -108,7 +136,9 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
? result.deployments.find((d) => d.version === version)
|
||||
: undefined;
|
||||
|
||||
return typedjson({ ...result, selectedDeployment });
|
||||
const autoReloadPollIntervalMs = env.DEPLOYMENTS_AUTORELOAD_POLL_INTERVAL_MS;
|
||||
|
||||
return typedjson({ ...result, selectedDeployment, autoReloadPollIntervalMs });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw new Response(undefined, {
|
||||
@@ -122,14 +152,23 @@ export default function Page() {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
const { deployments, currentPage, totalPages, selectedDeployment } =
|
||||
useTypedLoaderData<typeof loader>();
|
||||
const {
|
||||
deployments,
|
||||
currentPage,
|
||||
totalPages,
|
||||
selectedDeployment,
|
||||
connectedGithubRepository,
|
||||
environmentGitHubBranch,
|
||||
autoReloadPollIntervalMs,
|
||||
} = useTypedLoaderData<typeof loader>();
|
||||
const hasDeployments = totalPages > 0;
|
||||
|
||||
const { deploymentParam } = useParams();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useAutoRevalidate({ interval: autoReloadPollIntervalMs, onFocus: true });
|
||||
|
||||
// If we have a selected deployment from the version param, show it
|
||||
useEffect(() => {
|
||||
if (selectedDeployment && !deploymentParam) {
|
||||
@@ -160,8 +199,8 @@ export default function Page() {
|
||||
<ResizablePanelGroup orientation="horizontal" className="h-full max-h-full">
|
||||
<ResizablePanel id="deployments-main" min="100px" className="max-h-full">
|
||||
{hasDeployments ? (
|
||||
<div className="grid max-h-full grid-rows-[1fr_auto]">
|
||||
<Table containerClassName="border-t-0">
|
||||
<div className="flex h-full max-h-full flex-col">
|
||||
<Table containerClassName="border-t-0 grow">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Deploy</TableHeaderCell>
|
||||
@@ -286,18 +325,45 @@ export default function Page() {
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{totalPages > 1 && (
|
||||
<div className="-mt-px flex justify-end border-t border-grid-dimmed py-2 pr-2">
|
||||
<PaginationControls currentPage={currentPage} totalPages={totalPages} />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"-mt-px flex flex-wrap justify-end gap-2 border-t border-grid-dimmed px-3 pb-[7px] pt-[6px]",
|
||||
connectedGithubRepository && environmentGitHubBranch && "justify-between"
|
||||
)}
|
||||
>
|
||||
{connectedGithubRepository && environmentGitHubBranch && (
|
||||
<div className="flex flex-nowrap items-center gap-2 whitespace-nowrap text-sm">
|
||||
<OctoKitty className="size-4" />
|
||||
Automatically triggered by pushes to{" "}
|
||||
<div className="flex max-w-32 items-center gap-1 truncate rounded bg-grid-dimmed px-1 font-mono">
|
||||
<GitBranchIcon className="size-3 shrink-0" />
|
||||
<span className="max-w-28 truncate">{environmentGitHubBranch}</span>
|
||||
</div>{" "}
|
||||
in
|
||||
<a
|
||||
href={connectedGithubRepository.repository.htmlUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="max-w-52 truncate text-sm text-text-dimmed underline transition-colors hover:text-text-bright"
|
||||
>
|
||||
{connectedGithubRepository.repository.fullName}
|
||||
</a>
|
||||
<LinkButton
|
||||
variant="minimal/small"
|
||||
LeadingIcon={CogIcon}
|
||||
to={v3ProjectSettingsPath(organization, project, environment)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<PaginationControls currentPage={currentPage} totalPages={totalPages} />
|
||||
</div>
|
||||
</div>
|
||||
) : environment.type === "DEVELOPMENT" ? (
|
||||
<MainCenteredContainer className="max-w-md">
|
||||
<MainCenteredContainer className="max-w-prose">
|
||||
<DeploymentsNoneDev />
|
||||
</MainCenteredContainer>
|
||||
) : (
|
||||
<MainCenteredContainer className="max-w-md">
|
||||
<MainCenteredContainer className="max-w-prose">
|
||||
<DeploymentsNone />
|
||||
</MainCenteredContainer>
|
||||
)}
|
||||
@@ -317,7 +383,7 @@ export default function Page() {
|
||||
);
|
||||
}
|
||||
|
||||
function UserTag({ name, avatarUrl }: { name: string; avatarUrl?: string }) {
|
||||
export function UserTag({ name, avatarUrl }: { name: string; avatarUrl?: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<UserAvatar avatarUrl={avatarUrl} name={name} className="h-4 w-4" />
|
||||
@@ -347,7 +413,10 @@ function DeploymentActionsCell({
|
||||
compareDeploymentVersions(deployment.version, currentDeployment.version) === -1;
|
||||
const canBePromoted = canBeMadeCurrent && !canBeRolledBack;
|
||||
|
||||
if (!canBeRolledBack && !canBePromoted) {
|
||||
const finalStatuses = ["CANCELED", "DEPLOYED", "FAILED", "TIMED_OUT"];
|
||||
const canBeCanceled = !finalStatuses.includes(deployment.status);
|
||||
|
||||
if (!canBeRolledBack && !canBePromoted && !canBeCanceled) {
|
||||
return (
|
||||
<TableCell to={path} isSelected={isSelected}>
|
||||
{""}
|
||||
@@ -371,7 +440,7 @@ function DeploymentActionsCell({
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
>
|
||||
Rollback…
|
||||
Rollback
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<RollbackDeploymentDialog
|
||||
@@ -391,7 +460,7 @@ function DeploymentActionsCell({
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
>
|
||||
Promote…
|
||||
Promote
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<PromoteDeploymentDialog
|
||||
@@ -401,8 +470,155 @@ function DeploymentActionsCell({
|
||||
/>
|
||||
</Dialog>
|
||||
)}
|
||||
{canBeCanceled && (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={NoSymbolIcon}
|
||||
leadingIconClassName="text-error"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<CancelDeploymentDialog
|
||||
projectId={project.id}
|
||||
deploymentShortCode={deployment.shortCode}
|
||||
redirectPath={`${location.pathname}${location.search}`}
|
||||
/>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type RollbackDeploymentDialogProps = {
|
||||
projectId: string;
|
||||
deploymentShortCode: string;
|
||||
redirectPath: string;
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
function CancelDeploymentDialog({
|
||||
projectId,
|
||||
deploymentShortCode,
|
||||
redirectPath,
|
||||
}: RollbackDeploymentDialogProps) {
|
||||
const navigation = useNavigation();
|
||||
|
||||
const formAction = `/resources/${projectId}/deployments/${deploymentShortCode}/cancel`;
|
||||
const isLoading = navigation.formAction === formAction;
|
||||
|
||||
return (
|
||||
<DialogContent key="cancel">
|
||||
<DialogHeader>Cancel this deployment?</DialogHeader>
|
||||
<DialogDescription>Canceling a deployment cannot be undone. Are you sure?</DialogDescription>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="tertiary/medium">Back</Button>
|
||||
</DialogClose>
|
||||
<Form action={formAction} method="post">
|
||||
<Button
|
||||
type="submit"
|
||||
name="redirectUrl"
|
||||
value={redirectPath}
|
||||
variant="danger/medium"
|
||||
LeadingIcon={isLoading ? SpinnerWhite : NoSymbolIcon}
|
||||
disabled={isLoading}
|
||||
shortcut={{ modifiers: ["mod"], key: "enter" }}
|
||||
>
|
||||
{isLoading ? "Canceling..." : "Cancel deployment"}
|
||||
</Button>
|
||||
</Form>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
+324
-94
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
AdjustmentsHorizontalIcon,
|
||||
ArrowUpCircleIcon,
|
||||
BookOpenIcon,
|
||||
ChatBubbleLeftEllipsisIcon,
|
||||
@@ -8,19 +9,13 @@ import {
|
||||
RectangleStackIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { DialogClose } from "@radix-ui/react-dialog";
|
||||
import {
|
||||
Form,
|
||||
useNavigate,
|
||||
useNavigation,
|
||||
useRevalidator,
|
||||
useSearchParams,
|
||||
type MetaFunction,
|
||||
} from "@remix-run/react";
|
||||
import { Form, useNavigation, useSearchParams, type MetaFunction } from "@remix-run/react";
|
||||
import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { type RuntimeEnvironmentType } from "@trigger.dev/database";
|
||||
import type { RuntimeEnvironmentType } from "@trigger.dev/database";
|
||||
import { useEffect, useState } from "react";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { RunsIcon } from "~/assets/icons/RunsIcon";
|
||||
import { TaskIconSmall } from "~/assets/icons/TaskIcon";
|
||||
import upgradeForQueuesPath from "~/assets/images/queues-dashboard.png";
|
||||
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
|
||||
@@ -30,13 +25,16 @@ import { Feedback } from "~/components/Feedback";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { BigNumber } from "~/components/metrics/BigNumber";
|
||||
import { Badge } from "~/components/primitives/Badge";
|
||||
import { Button, ButtonVariant, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Button, LinkButton, type ButtonVariant } from "~/components/primitives/Buttons";
|
||||
import { Callout } from "~/components/primitives/Callout";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog";
|
||||
import { FormButtons } from "~/components/primitives/FormButtons";
|
||||
import { Header3 } from "~/components/primitives/Headers";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { PaginationControls } from "~/components/primitives/Pagination";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { PopoverMenuItem } from "~/components/primitives/Popover";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import {
|
||||
Table,
|
||||
@@ -55,25 +53,25 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "~/components/primitives/Tooltip";
|
||||
import { env } from "~/env.server";
|
||||
import { useAutoRevalidate } from "~/hooks/useAutoRevalidate";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useEventSource } from "~/hooks/useEventSource";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useThrottle } from "~/hooks/useThrottle";
|
||||
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { getUserById } from "~/models/user.server";
|
||||
import { EnvironmentQueuePresenter } from "~/presenters/v3/EnvironmentQueuePresenter.server";
|
||||
import { QueueListPresenter } from "~/presenters/v3/QueueListPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { docsPath, EnvironmentParamSchema, v3BillingPath, v3RunsPath } from "~/utils/pathBuilder";
|
||||
import { concurrencySystem } from "~/v3/services/concurrencySystemInstance.server";
|
||||
import { PauseEnvironmentService } from "~/v3/services/pauseEnvironment.server";
|
||||
import { PauseQueueService } from "~/v3/services/pauseQueue.server";
|
||||
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
|
||||
import { Header3 } from "~/components/primitives/Headers";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { useThrottle } from "~/hooks/useThrottle";
|
||||
import { RunsIcon } from "~/assets/icons/RunsIcon";
|
||||
|
||||
const SearchParamsSchema = z.object({
|
||||
query: z.string().optional(),
|
||||
@@ -121,9 +119,12 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
|
||||
const environmentQueuePresenter = new EnvironmentQueuePresenter();
|
||||
|
||||
const autoReloadPollIntervalMs = env.QUEUES_AUTORELOAD_POLL_INTERVAL_MS;
|
||||
|
||||
return typedjson({
|
||||
...queues,
|
||||
environment: await environmentQueuePresenter.call(environment),
|
||||
autoReloadPollIntervalMs,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@@ -211,34 +212,98 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
`Queue ${action === "queue-pause" ? "paused" : "resumed"}`
|
||||
);
|
||||
}
|
||||
case "queue-override": {
|
||||
const friendlyId = formData.get("friendlyId");
|
||||
const concurrencyLimit = formData.get("concurrencyLimit");
|
||||
|
||||
if (!friendlyId) {
|
||||
return redirectWithErrorMessage(redirectPath, request, "Queue ID is required");
|
||||
}
|
||||
|
||||
if (!concurrencyLimit) {
|
||||
return redirectWithErrorMessage(redirectPath, request, "Concurrency limit is required");
|
||||
}
|
||||
|
||||
const limitNumber = parseInt(concurrencyLimit.toString(), 10);
|
||||
if (isNaN(limitNumber) || limitNumber < 0) {
|
||||
return redirectWithErrorMessage(
|
||||
redirectPath,
|
||||
request,
|
||||
"Concurrency limit must be a valid number"
|
||||
);
|
||||
}
|
||||
|
||||
const user = await getUserById(userId);
|
||||
if (!user) {
|
||||
return redirectWithErrorMessage(redirectPath, request, "User not found");
|
||||
}
|
||||
|
||||
const result = await concurrencySystem.queues.overrideQueueConcurrencyLimit(
|
||||
environment,
|
||||
friendlyId.toString(),
|
||||
limitNumber,
|
||||
user
|
||||
);
|
||||
|
||||
if (!result.isOk()) {
|
||||
return redirectWithErrorMessage(
|
||||
redirectPath,
|
||||
request,
|
||||
"Failed to override queue concurrency limit"
|
||||
);
|
||||
}
|
||||
|
||||
return redirectWithSuccessMessage(
|
||||
redirectPath,
|
||||
request,
|
||||
"Queue concurrency limit overridden"
|
||||
);
|
||||
}
|
||||
case "queue-remove-override": {
|
||||
const friendlyId = formData.get("friendlyId");
|
||||
|
||||
if (!friendlyId) {
|
||||
return redirectWithErrorMessage(redirectPath, request, "Queue ID is required");
|
||||
}
|
||||
|
||||
const result = await concurrencySystem.queues.resetConcurrencyLimit(
|
||||
environment,
|
||||
friendlyId.toString()
|
||||
);
|
||||
|
||||
if (!result.isOk()) {
|
||||
return redirectWithErrorMessage(
|
||||
redirectPath,
|
||||
request,
|
||||
"Failed to reset queue concurrency limit"
|
||||
);
|
||||
}
|
||||
|
||||
return redirectWithSuccessMessage(redirectPath, request, "Queue concurrency limit reset");
|
||||
}
|
||||
default:
|
||||
return redirectWithErrorMessage(redirectPath, request, "Something went wrong");
|
||||
}
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { environment, queues, success, pagination, code, totalQueues, hasFilters } =
|
||||
useTypedLoaderData<typeof loader>();
|
||||
const {
|
||||
environment,
|
||||
queues,
|
||||
success,
|
||||
pagination,
|
||||
code,
|
||||
totalQueues,
|
||||
hasFilters,
|
||||
autoReloadPollIntervalMs,
|
||||
} = useTypedLoaderData<typeof loader>();
|
||||
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const env = useEnvironment();
|
||||
const plan = useCurrentPlan();
|
||||
|
||||
// Reload the page periodically
|
||||
const streamedEvents = useEventSource(
|
||||
`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${env.slug}/queues/stream`,
|
||||
{
|
||||
event: "update",
|
||||
}
|
||||
);
|
||||
|
||||
const revalidation = useRevalidator();
|
||||
useEffect(() => {
|
||||
if (streamedEvents) {
|
||||
revalidation.revalidate();
|
||||
}
|
||||
}, [streamedEvents]);
|
||||
useAutoRevalidate({ interval: autoReloadPollIntervalMs, onFocus: true });
|
||||
|
||||
const limitStatus =
|
||||
environment.running === environment.concurrencyLimit * environment.burstFactor
|
||||
@@ -275,17 +340,19 @@ export default function Page() {
|
||||
animate
|
||||
accessory={
|
||||
<div className="flex items-start gap-1">
|
||||
{environment.runsEnabled ? <EnvironmentPauseResumeButton env={env} /> : null}
|
||||
<LinkButton
|
||||
variant="tertiary/small"
|
||||
variant="secondary/small"
|
||||
LeadingIcon={RunsIcon}
|
||||
leadingIconClassName="text-runs"
|
||||
className="px-2"
|
||||
to={v3RunsPath(organization, project, env, {
|
||||
statuses: ["PENDING"],
|
||||
period: "30d",
|
||||
rootOnly: false,
|
||||
})}
|
||||
>
|
||||
View runs
|
||||
</LinkButton>
|
||||
{environment.runsEnabled ? <EnvironmentPauseResumeButton env={env} /> : null}
|
||||
tooltip="View queued runs"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
valueClassName={env.paused ? "text-warning" : undefined}
|
||||
@@ -308,15 +375,17 @@ export default function Page() {
|
||||
}
|
||||
accessory={
|
||||
<LinkButton
|
||||
variant="tertiary/small"
|
||||
variant="secondary/small"
|
||||
LeadingIcon={RunsIcon}
|
||||
leadingIconClassName="text-runs"
|
||||
className="px-2"
|
||||
to={v3RunsPath(organization, project, env, {
|
||||
statuses: ["DEQUEUED", "EXECUTING"],
|
||||
period: "30d",
|
||||
rootOnly: false,
|
||||
})}
|
||||
>
|
||||
View runs
|
||||
</LinkButton>
|
||||
tooltip="View runs"
|
||||
/>
|
||||
}
|
||||
compactThreshold={1000000}
|
||||
/>
|
||||
@@ -383,7 +452,8 @@ export default function Page() {
|
||||
<TableRow>
|
||||
<TableHeaderCell>Name</TableHeaderCell>
|
||||
<TableHeaderCell alignment="right">Queued</TableHeaderCell>
|
||||
<TableHeaderCell alignment="right">Running/limit</TableHeaderCell>
|
||||
<TableHeaderCell alignment="right">Running</TableHeaderCell>
|
||||
<TableHeaderCell alignment="right">Limit</TableHeaderCell>
|
||||
<TableHeaderCell
|
||||
alignment="right"
|
||||
tooltip={
|
||||
@@ -409,6 +479,17 @@ export default function Page() {
|
||||
This queue is limited by a concurrency limit set in your code.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<Header3>Override</Header3>
|
||||
<Paragraph
|
||||
variant="small"
|
||||
className="!text-wrap text-text-dimmed"
|
||||
spacing
|
||||
>
|
||||
This queue's concurrency limit has been manually overridden from the
|
||||
dashboard or API.
|
||||
</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
@@ -423,7 +504,7 @@ export default function Page() {
|
||||
{queues.length > 0 ? (
|
||||
queues.map((queue) => {
|
||||
const limit = queue.concurrencyLimit ?? environment.concurrencyLimit;
|
||||
const isAtLimit = queue.running === limit;
|
||||
const isAtLimit = queue.running >= limit;
|
||||
const queueFilterableName = `${queue.type === "task" ? "task/" : ""}${
|
||||
queue.name
|
||||
}`;
|
||||
@@ -459,6 +540,18 @@ export default function Page() {
|
||||
<span className={queue.paused ? "opacity-50" : undefined}>
|
||||
{queue.name}
|
||||
</span>
|
||||
{queue.concurrency?.overriddenAt ? (
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<Badge variant="extra-small" className="text-text-bright">
|
||||
Concurrency limit overridden
|
||||
</Badge>
|
||||
}
|
||||
content="This queue's concurrency limit has been manually overridden from the dashboard or API."
|
||||
className="max-w-xs"
|
||||
disableHoverableContent
|
||||
/>
|
||||
) : null}
|
||||
{queue.paused ? (
|
||||
<Badge variant="extra-small" className="text-warning">
|
||||
Paused
|
||||
@@ -473,31 +566,50 @@ export default function Page() {
|
||||
</TableCell>
|
||||
<TableCell
|
||||
alignment="right"
|
||||
className={queue.paused ? "opacity-50" : undefined}
|
||||
className={cn(
|
||||
"w-[1%] tabular-nums",
|
||||
queue.paused ? "opacity-50" : undefined
|
||||
)}
|
||||
>
|
||||
{queue.queued}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
alignment="right"
|
||||
className={cn(
|
||||
queue.paused ? "tabular-nums opacity-50" : undefined,
|
||||
"w-[1%] tabular-nums",
|
||||
queue.paused ? "opacity-50" : undefined,
|
||||
queue.running > 0 && "text-text-bright",
|
||||
isAtLimit && "text-warning"
|
||||
)}
|
||||
>
|
||||
{queue.running}/
|
||||
<span className={cn("tabular-nums", isAtLimit && "text-warning")}>
|
||||
{limit}
|
||||
</span>
|
||||
{queue.running}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
alignment="right"
|
||||
className={cn(
|
||||
"w-[1%] tabular-nums",
|
||||
queue.paused ? "opacity-50" : undefined,
|
||||
isAtLimit && "text-warning"
|
||||
queue.concurrency?.overriddenAt && "font-medium text-text-bright"
|
||||
)}
|
||||
>
|
||||
{queue.concurrencyLimit ? "User" : "Environment"}
|
||||
{limit}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
alignment="right"
|
||||
className={cn(
|
||||
"w-[1%]",
|
||||
queue.paused ? "opacity-50" : undefined,
|
||||
isAtLimit && "text-warning",
|
||||
queue.concurrency?.overriddenAt && "font-medium text-text-bright"
|
||||
)}
|
||||
>
|
||||
{queue.concurrency?.overriddenAt ? (
|
||||
<span className="text-text-bright">Override</span>
|
||||
) : queue.concurrencyLimit ? (
|
||||
"User"
|
||||
) : (
|
||||
"Environment"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCellMenu
|
||||
isSticky
|
||||
@@ -524,50 +636,43 @@ export default function Page() {
|
||||
showTooltip={false}
|
||||
/>
|
||||
)}
|
||||
<LinkButton
|
||||
variant="minimal/small"
|
||||
|
||||
<PopoverMenuItem
|
||||
icon={RunsIcon}
|
||||
leadingIconClassName="text-runs"
|
||||
title="View all runs"
|
||||
to={v3RunsPath(organization, project, env, {
|
||||
queues: [queueFilterableName],
|
||||
period: "30d",
|
||||
rootOnly: false,
|
||||
})}
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
LeadingIcon={RunsIcon}
|
||||
leadingIconClassName="text-indigo-500"
|
||||
>
|
||||
View all runs
|
||||
</LinkButton>
|
||||
<LinkButton
|
||||
variant="minimal/small"
|
||||
/>
|
||||
<PopoverMenuItem
|
||||
icon={RectangleStackIcon}
|
||||
leadingIconClassName="text-queues"
|
||||
title="View queued runs"
|
||||
to={v3RunsPath(organization, project, env, {
|
||||
queues: [queueFilterableName],
|
||||
statuses: ["PENDING"],
|
||||
period: "30d",
|
||||
rootOnly: false,
|
||||
})}
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
LeadingIcon={RectangleStackIcon}
|
||||
leadingIconClassName="text-queues"
|
||||
>
|
||||
View queued runs
|
||||
</LinkButton>
|
||||
<LinkButton
|
||||
variant="minimal/small"
|
||||
/>
|
||||
<PopoverMenuItem
|
||||
icon={Spinner}
|
||||
leadingIconClassName="text-queues animate-none"
|
||||
title="View running runs"
|
||||
to={v3RunsPath(organization, project, env, {
|
||||
queues: [queueFilterableName],
|
||||
statuses: ["DEQUEUED", "EXECUTING"],
|
||||
period: "30d",
|
||||
rootOnly: false,
|
||||
})}
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
LeadingIcon={Spinner}
|
||||
leadingIconClassName="size-4 animate-none"
|
||||
>
|
||||
View running runs
|
||||
</LinkButton>
|
||||
/>
|
||||
<QueueOverrideConcurrencyButton
|
||||
queue={queue}
|
||||
environmentConcurrencyLimit={environment.concurrencyLimit}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
@@ -576,7 +681,7 @@ export default function Page() {
|
||||
})
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6}>
|
||||
<TableCell colSpan={7}>
|
||||
<div className="grid place-items-center py-6 text-text-dimmed">
|
||||
<Paragraph>
|
||||
{hasFilters
|
||||
@@ -733,29 +838,26 @@ function QueuePauseResumeButton({
|
||||
fullWidth?: boolean;
|
||||
showTooltip?: boolean;
|
||||
}) {
|
||||
const navigation = useNavigation();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const button = (
|
||||
<Button
|
||||
type="button"
|
||||
variant={variant}
|
||||
LeadingIcon={queue.paused ? PlayIcon : PauseIcon}
|
||||
leadingIconClassName={queue.paused ? "text-success" : "text-warning"}
|
||||
fullWidth={fullWidth}
|
||||
textAlignLeft={fullWidth}
|
||||
>
|
||||
{queue.paused ? "Resume..." : "Pause..."}
|
||||
</Button>
|
||||
);
|
||||
|
||||
const trigger = showTooltip ? (
|
||||
<div>
|
||||
<TooltipProvider disableHoverableContent={true}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<DialogTrigger asChild>{button}</DialogTrigger>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant={variant}
|
||||
LeadingIcon={queue.paused ? PlayIcon : PauseIcon}
|
||||
leadingIconClassName={queue.paused ? "text-success" : "text-warning"}
|
||||
fullWidth={fullWidth}
|
||||
textAlignLeft={fullWidth}
|
||||
>
|
||||
{queue.paused ? "Resume..." : "Pause..."}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className={"text-xs"}>
|
||||
@@ -767,7 +869,13 @@ function QueuePauseResumeButton({
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
) : (
|
||||
<DialogTrigger asChild>{button}</DialogTrigger>
|
||||
<DialogTrigger asChild>
|
||||
<PopoverMenuItem
|
||||
icon={queue.paused ? PlayIcon : PauseIcon}
|
||||
leadingIconClassName={queue.paused ? "text-success" : "text-warning"}
|
||||
title={queue.paused ? "Resume..." : "Pause..."}
|
||||
/>
|
||||
</DialogTrigger>
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -814,6 +922,128 @@ function QueuePauseResumeButton({
|
||||
);
|
||||
}
|
||||
|
||||
function QueueOverrideConcurrencyButton({
|
||||
queue,
|
||||
environmentConcurrencyLimit,
|
||||
}: {
|
||||
queue: {
|
||||
id: string;
|
||||
name: string;
|
||||
concurrencyLimit: number | null;
|
||||
concurrency?: { overriddenAt: Date | null };
|
||||
};
|
||||
environmentConcurrencyLimit: number;
|
||||
}) {
|
||||
const navigation = useNavigation();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [concurrencyLimit, setConcurrencyLimit] = useState<string>(
|
||||
queue.concurrencyLimit?.toString() ?? environmentConcurrencyLimit.toString()
|
||||
);
|
||||
|
||||
const isOverridden = !!queue.concurrency?.overriddenAt;
|
||||
const currentLimit = queue.concurrencyLimit ?? environmentConcurrencyLimit;
|
||||
|
||||
useEffect(() => {
|
||||
if (navigation.state === "loading" || navigation.state === "idle") {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}, [navigation.state]);
|
||||
|
||||
const isLoading = Boolean(
|
||||
navigation.formData?.get("action") === "queue-override" ||
|
||||
navigation.formData?.get("action") === "queue-remove-override"
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<PopoverMenuItem
|
||||
icon={AdjustmentsHorizontalIcon}
|
||||
title={isOverridden ? "Edit override…" : "Override limit…"}
|
||||
/>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
{isOverridden ? "Edit concurrency override" : "Override concurrency limit"}
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-3 pt-3">
|
||||
{isOverridden ? (
|
||||
<Paragraph>
|
||||
This queue's concurrency limit is currently overridden to {currentLimit}.
|
||||
{queue.concurrencyLimit !== null &&
|
||||
` The original limit set in code was ${queue.concurrencyLimit}.`}{" "}
|
||||
You can update the override or remove it to restore the{" "}
|
||||
{queue.concurrencyLimit !== null
|
||||
? "limit set in code"
|
||||
: "environment concurrency limit"}
|
||||
.
|
||||
</Paragraph>
|
||||
) : (
|
||||
<Paragraph>
|
||||
Override this queue's concurrency limit. The current limit is {currentLimit}, which is
|
||||
set {queue.concurrencyLimit !== null ? "in code" : "by the environment"}.
|
||||
</Paragraph>
|
||||
)}
|
||||
<Form method="post" onSubmit={() => setIsOpen(false)} className="space-y-3">
|
||||
<input type="hidden" name="friendlyId" value={queue.id} />
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="concurrencyLimit" className="text-sm text-text-bright">
|
||||
Concurrency limit
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
name="concurrencyLimit"
|
||||
id="concurrencyLimit"
|
||||
min="0"
|
||||
value={concurrencyLimit}
|
||||
onChange={(e) => setConcurrencyLimit(e.target.value)}
|
||||
placeholder={currentLimit.toString()}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button
|
||||
type="submit"
|
||||
name="action"
|
||||
value="queue-override"
|
||||
disabled={isLoading || !concurrencyLimit}
|
||||
variant="primary/medium"
|
||||
LeadingIcon={isLoading && <Spinner color="white" />}
|
||||
shortcut={{ modifiers: ["mod"], key: "enter" }}
|
||||
>
|
||||
{isOverridden ? "Update override" : "Override limit"}
|
||||
</Button>
|
||||
}
|
||||
cancelButton={
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{isOverridden && (
|
||||
<Button
|
||||
type="submit"
|
||||
name="action"
|
||||
value="queue-remove-override"
|
||||
disabled={isLoading}
|
||||
variant="danger/medium"
|
||||
>
|
||||
Remove override
|
||||
</Button>
|
||||
)}
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="tertiary/medium">
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Form>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function EngineVersionUpgradeCallout() {
|
||||
return (
|
||||
<div className="mt-4 flex max-w-lg flex-col gap-4 rounded-sm border border-grid-bright bg-background-bright px-4">
|
||||
|
||||
+31
-2
@@ -3,6 +3,7 @@ import {
|
||||
ArrowUpCircleIcon,
|
||||
BookOpenIcon,
|
||||
ChatBubbleLeftEllipsisIcon,
|
||||
InformationCircleIcon,
|
||||
MapPinIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { Form } from "@remix-run/react";
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "~/components/primitives/Dialog";
|
||||
import { InfoPanel } from "~/components/primitives/InfoPanel";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import * as Property from "~/components/primitives/PropertyTable";
|
||||
@@ -45,12 +47,13 @@ import {
|
||||
TableRow,
|
||||
} from "~/components/primitives/Table";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
import { useFeatures } from "~/hooks/useFeatures";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useHasAdminAccess } from "~/hooks/useUser";
|
||||
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { type Region, RegionsPresenter } from "~/presenters/v3/RegionsPresenter.server";
|
||||
import { requireUser, requireUserId } from "~/services/session.server";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
import {
|
||||
docsPath,
|
||||
EnvironmentParamSchema,
|
||||
@@ -130,6 +133,7 @@ export default function Page() {
|
||||
const { regions, isPaying } = useTypedLoaderData<typeof loader>();
|
||||
const organization = useOrganization();
|
||||
const isAdmin = useHasAdminAccess();
|
||||
const { isManagedCloud } = useFeatures();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
@@ -158,7 +162,7 @@ export default function Page() {
|
||||
</MainCenteredContainer>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid max-h-full min-h-full grid-rows-[1fr] overflow-x-auto">
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -293,6 +297,31 @@ export default function Page() {
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
{isManagedCloud && (
|
||||
<InfoPanel
|
||||
icon={InformationCircleIcon}
|
||||
iconClassName="size-4"
|
||||
variant="minimal"
|
||||
panelClassName="max-w-full gap-1"
|
||||
>
|
||||
<Paragraph variant="extra-small" className="flex items-baseline gap-x-0.5">
|
||||
Trigger.dev is fully GDPR compliant. Learn more in our{" "}
|
||||
<TextLink to="https://security.trigger.dev">security portal</TextLink> or{" "}
|
||||
<Feedback
|
||||
button={
|
||||
<Paragraph
|
||||
variant="extra-small"
|
||||
className="cursor-pointer text-indigo-500 transition hover:text-indigo-400"
|
||||
>
|
||||
get in touch
|
||||
</Paragraph>
|
||||
}
|
||||
defaultValue="help"
|
||||
/>
|
||||
.
|
||||
</Paragraph>
|
||||
</InfoPanel>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
+132
-68
@@ -11,7 +11,7 @@ import {
|
||||
MagnifyingGlassPlusIcon,
|
||||
StopCircleIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { useLoaderData, useParams, useRevalidator } from "@remix-run/react";
|
||||
import { useLoaderData, useRevalidator } from "@remix-run/react";
|
||||
import { type LoaderFunctionArgs, type SerializeFrom, json } from "@remix-run/server-runtime";
|
||||
import { type Virtualizer } from "@tanstack/react-virtual";
|
||||
import {
|
||||
@@ -20,12 +20,13 @@ import {
|
||||
nanosecondsToMilliseconds,
|
||||
tryCatch,
|
||||
} from "@trigger.dev/core/v3";
|
||||
import { type RuntimeEnvironmentType } from "@trigger.dev/database";
|
||||
import type { RuntimeEnvironmentType } from "@trigger.dev/database";
|
||||
import { motion } from "framer-motion";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useHotkeys } from "react-hotkeys-hook";
|
||||
import { redirect } from "remix-typedjson";
|
||||
import { ShowParentIcon, ShowParentIconSelected } from "~/assets/icons/ShowParentIcon";
|
||||
import { MoveToTopIcon } from "~/assets/icons/MoveToTopIcon";
|
||||
import { MoveUpIcon } from "~/assets/icons/MoveUpIcon";
|
||||
import tileBgPath from "~/assets/images/error-banner-tile@2x.png";
|
||||
import { DevDisconnectedBanner, useCrossEngineIsConnected } from "~/components/DevPresence";
|
||||
import { WarmStartIconWithTooltip } from "~/components/WarmStarts";
|
||||
@@ -87,7 +88,6 @@ import {
|
||||
docsPath,
|
||||
v3BillingPath,
|
||||
v3RunParamsSchema,
|
||||
v3RunPath,
|
||||
v3RunRedirectPath,
|
||||
v3RunSpanPath,
|
||||
v3RunStreamingPath,
|
||||
@@ -97,6 +97,7 @@ import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
|
||||
import { SpanView } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import { CopyableText } from "~/components/primitives/CopyableText";
|
||||
import type { SpanOverride } from "~/v3/eventRepository/eventRepository.types";
|
||||
|
||||
const resizableSettings = {
|
||||
parent: {
|
||||
@@ -142,7 +143,6 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const [error, result] = await tryCatch(
|
||||
presenter.call({
|
||||
userId,
|
||||
organizationSlug,
|
||||
showDeletedLogs: !!impersonationId,
|
||||
projectSlug: projectParam,
|
||||
runFriendlyId: runParam,
|
||||
@@ -172,7 +172,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
return json({
|
||||
run: result.run,
|
||||
trace: result.trace,
|
||||
maximumLiveReloadingSetting: env.MAXIMUM_LIVE_RELOADING_EVENTS,
|
||||
maximumLiveReloadingSetting: result.maximumLiveReloadingSetting,
|
||||
resizable: {
|
||||
parent,
|
||||
tree,
|
||||
@@ -302,7 +302,7 @@ function TraceView({ run, trace, maximumLiveReloadingSetting, resizable }: Loade
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const { events, parentRunFriendlyId, duration, rootSpanStatus, rootStartedAt, queuedDuration } =
|
||||
const { events, duration, rootSpanStatus, rootStartedAt, queuedDuration, overridesBySpanId } =
|
||||
trace;
|
||||
const shouldLiveReload = events.length <= maximumLiveReloadingSetting;
|
||||
|
||||
@@ -325,6 +325,8 @@ function TraceView({ run, trace, maximumLiveReloadingSetting, resizable }: Loade
|
||||
// WARNING Don't put the revalidator in the useEffect deps array or bad things will happen
|
||||
}, [streamedEvents]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const spanOverrides = selectedSpanId ? overridesBySpanId?.[selectedSpanId] : undefined;
|
||||
|
||||
return (
|
||||
<div className={cn("grid h-full max-h-full grid-cols-1 overflow-hidden")}>
|
||||
<ResizablePanelGroup
|
||||
@@ -340,7 +342,6 @@ function TraceView({ run, trace, maximumLiveReloadingSetting, resizable }: Loade
|
||||
selectedId={selectedSpanId}
|
||||
key={events[0]?.id ?? "-"}
|
||||
events={events}
|
||||
parentRunFriendlyId={parentRunFriendlyId}
|
||||
onSelectedIdChanged={(selectedSpan) => {
|
||||
//instantly close the panel if no span is selected
|
||||
if (!selectedSpan) {
|
||||
@@ -358,6 +359,7 @@ function TraceView({ run, trace, maximumLiveReloadingSetting, resizable }: Loade
|
||||
shouldLiveReload={shouldLiveReload}
|
||||
maximumLiveReloadingSetting={maximumLiveReloadingSetting}
|
||||
rootRun={run.rootTaskRun}
|
||||
parentRun={run.parentTaskRun}
|
||||
isCompleted={run.completedAt !== null}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
@@ -373,6 +375,7 @@ function TraceView({ run, trace, maximumLiveReloadingSetting, resizable }: Loade
|
||||
<SpanView
|
||||
runParam={run.friendlyId}
|
||||
spanId={selectedSpanId}
|
||||
spanOverrides={spanOverrides as SpanOverride | undefined}
|
||||
closePanel={() => replaceSearchParam("span")}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
@@ -476,7 +479,6 @@ function NoLogsView({ run, resizable }: LoaderData) {
|
||||
type TasksTreeViewProps = {
|
||||
events: TraceEvent[];
|
||||
selectedId?: string;
|
||||
parentRunFriendlyId?: string;
|
||||
onSelectedIdChanged: (selectedId: string | undefined) => void;
|
||||
totalDuration: number;
|
||||
rootSpanStatus: "executing" | "completed" | "failed";
|
||||
@@ -487,7 +489,10 @@ type TasksTreeViewProps = {
|
||||
maximumLiveReloadingSetting: number;
|
||||
rootRun: {
|
||||
friendlyId: string;
|
||||
taskIdentifier: string;
|
||||
spanId: string;
|
||||
} | null;
|
||||
parentRun: {
|
||||
friendlyId: string;
|
||||
spanId: string;
|
||||
} | null;
|
||||
isCompleted: boolean;
|
||||
@@ -496,7 +501,6 @@ type TasksTreeViewProps = {
|
||||
function TasksTreeView({
|
||||
events,
|
||||
selectedId,
|
||||
parentRunFriendlyId,
|
||||
onSelectedIdChanged,
|
||||
totalDuration,
|
||||
rootSpanStatus,
|
||||
@@ -506,6 +510,7 @@ function TasksTreeView({
|
||||
shouldLiveReload,
|
||||
maximumLiveReloadingSetting,
|
||||
rootRun,
|
||||
parentRun,
|
||||
isCompleted,
|
||||
}: TasksTreeViewProps) {
|
||||
const isAdmin = useHasAdminAccess();
|
||||
@@ -596,20 +601,30 @@ function TasksTreeView({
|
||||
id={resizableSettings.tree.tree.id}
|
||||
default={resizableSettings.tree.tree.default}
|
||||
min={resizableSettings.tree.tree.min}
|
||||
className="pl-3"
|
||||
>
|
||||
<div className="grid h-full grid-rows-[2rem_1fr] overflow-hidden">
|
||||
<div className="flex items-center pr-2">
|
||||
{rootRun ? (
|
||||
<ShowParentLink
|
||||
runFriendlyId={rootRun.friendlyId}
|
||||
isRoot={true}
|
||||
spanId={rootRun.spanId}
|
||||
<div className="flex items-center justify-between pl-1 pr-2">
|
||||
{rootRun || parentRun ? (
|
||||
<ShowParentOrRootLinks
|
||||
relationships={{
|
||||
root: rootRun
|
||||
? {
|
||||
friendlyId: rootRun.friendlyId,
|
||||
spanId: rootRun.spanId,
|
||||
isParent: parentRun ? rootRun.friendlyId === parentRun.friendlyId : true,
|
||||
}
|
||||
: undefined,
|
||||
parent:
|
||||
parentRun && rootRun?.friendlyId !== parentRun.friendlyId
|
||||
? {
|
||||
friendlyId: parentRun.friendlyId,
|
||||
spanId: "",
|
||||
}
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
) : parentRunFriendlyId ? (
|
||||
<ShowParentLink runFriendlyId={parentRunFriendlyId} isRoot={false} />
|
||||
) : (
|
||||
<Paragraph variant="small" className="flex-1 text-charcoal-500">
|
||||
<Paragraph variant="extra-small" className="flex-1 pl-3 text-charcoal-500">
|
||||
This is the root task
|
||||
</Paragraph>
|
||||
)}
|
||||
@@ -628,6 +643,7 @@ function TasksTreeView({
|
||||
nodes={nodes}
|
||||
getNodeProps={getNodeProps}
|
||||
getTreeProps={getTreeProps}
|
||||
parentClassName="pl-3"
|
||||
renderNode={({ node, state, index }) => (
|
||||
<>
|
||||
<div
|
||||
@@ -1139,60 +1155,108 @@ function TaskLine({ isError, isSelected }: { isError: boolean; isSelected: boole
|
||||
return <div className={cn("h-8 w-2 border-r border-grid-bright")} />;
|
||||
}
|
||||
|
||||
function ShowParentLink({
|
||||
runFriendlyId,
|
||||
spanId,
|
||||
isRoot,
|
||||
function ShowParentOrRootLinks({
|
||||
relationships,
|
||||
}: {
|
||||
runFriendlyId: string;
|
||||
spanId?: string;
|
||||
isRoot: boolean;
|
||||
relationships: {
|
||||
root?: {
|
||||
friendlyId: string;
|
||||
spanId: string;
|
||||
isParent?: boolean;
|
||||
};
|
||||
parent?: {
|
||||
friendlyId: string;
|
||||
spanId: string;
|
||||
};
|
||||
};
|
||||
}) {
|
||||
const [mouseOver, setMouseOver] = useState(false);
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
const { spanParam } = useParams();
|
||||
|
||||
const span = spanId ? spanId : spanParam;
|
||||
|
||||
return (
|
||||
<LinkButton
|
||||
variant="minimal/medium"
|
||||
to={
|
||||
span
|
||||
? v3RunSpanPath(
|
||||
organization,
|
||||
project,
|
||||
environment,
|
||||
{
|
||||
friendlyId: runFriendlyId,
|
||||
},
|
||||
{ spanId: span }
|
||||
)
|
||||
: v3RunPath(organization, project, environment, {
|
||||
friendlyId: runFriendlyId,
|
||||
})
|
||||
}
|
||||
onMouseEnter={() => setMouseOver(true)}
|
||||
onMouseLeave={() => setMouseOver(false)}
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
shortcut={{ key: "p" }}
|
||||
className="flex-1"
|
||||
>
|
||||
{mouseOver ? (
|
||||
<ShowParentIconSelected className="h-4 w-4 text-indigo-500" />
|
||||
) : (
|
||||
<ShowParentIcon className="h-4 w-4 text-charcoal-650" />
|
||||
)}
|
||||
<Paragraph
|
||||
variant="small"
|
||||
className={cn(mouseOver ? "text-indigo-500" : "text-charcoal-500")}
|
||||
// Case 1: Root is also the parent
|
||||
if (relationships.root?.isParent === true) {
|
||||
return (
|
||||
<LinkButton
|
||||
variant="minimal/small"
|
||||
to={v3RunSpanPath(
|
||||
organization,
|
||||
project,
|
||||
environment,
|
||||
{ friendlyId: relationships.root.friendlyId },
|
||||
{ spanId: relationships.root.spanId }
|
||||
)}
|
||||
LeadingIcon={MoveToTopIcon}
|
||||
leadingIconClassName="gap-x-2"
|
||||
shortcut={{ key: "p" }}
|
||||
hideShortcutKey
|
||||
tooltip={
|
||||
<div className="-mr-1 flex items-center gap-1">
|
||||
<Paragraph variant="extra-small">Jump to root and parent run</Paragraph>
|
||||
<ShortcutKey shortcut={{ key: "p" }} variant="small" />
|
||||
</div>
|
||||
}
|
||||
className="text-xs"
|
||||
>
|
||||
{isRoot ? "Show root run" : "Show parent run"}
|
||||
</Paragraph>
|
||||
</LinkButton>
|
||||
Root/parent
|
||||
</LinkButton>
|
||||
);
|
||||
}
|
||||
|
||||
// Case 2: Root and Parent are different runs
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
{relationships.root && (
|
||||
<LinkButton
|
||||
variant="minimal/small"
|
||||
to={v3RunSpanPath(
|
||||
organization,
|
||||
project,
|
||||
environment,
|
||||
{ friendlyId: relationships.root.friendlyId },
|
||||
{ spanId: relationships.root.spanId }
|
||||
)}
|
||||
LeadingIcon={MoveToTopIcon}
|
||||
leadingIconClassName="gap-x-2"
|
||||
shortcut={{ key: "t" }}
|
||||
hideShortcutKey
|
||||
tooltip={
|
||||
<div className="-mr-1 flex items-center gap-1">
|
||||
<Paragraph variant="extra-small">Jump to root run</Paragraph>
|
||||
<ShortcutKey shortcut={{ key: "t" }} variant="small" />
|
||||
</div>
|
||||
}
|
||||
className="text-xs"
|
||||
>
|
||||
Root
|
||||
</LinkButton>
|
||||
)}
|
||||
{relationships.parent && (
|
||||
<LinkButton
|
||||
variant="minimal/small"
|
||||
to={v3RunSpanPath(
|
||||
organization,
|
||||
project,
|
||||
environment,
|
||||
{ friendlyId: relationships.parent.friendlyId },
|
||||
{ spanId: relationships.parent.spanId }
|
||||
)}
|
||||
LeadingIcon={MoveUpIcon}
|
||||
leadingIconClassName="gap-x-2"
|
||||
shortcut={{ key: "p" }}
|
||||
hideShortcutKey
|
||||
tooltip={
|
||||
<div className="-mr-1 flex items-center gap-1">
|
||||
<Paragraph variant="extra-small">Jump to parent run</Paragraph>
|
||||
<ShortcutKey shortcut={{ key: "p" }} variant="small" />
|
||||
</div>
|
||||
}
|
||||
className="text-xs"
|
||||
>
|
||||
Parent
|
||||
</LinkButton>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+1072
-132
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -73,6 +73,8 @@ import { DeleteTaskRunTemplateData, RunTemplateData } from "~/v3/taskRunTemplate
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog";
|
||||
import { DialogClose, DialogDescription } from "@radix-ui/react-dialog";
|
||||
import { FormButtons } from "~/components/primitives/FormButtons";
|
||||
import { $replica } from "~/db.server";
|
||||
import { clickhouseClient } from "~/services/clickhouseInstance.server";
|
||||
|
||||
type FormAction = "create-template" | "delete-template" | "run-scheduled" | "run-standard";
|
||||
|
||||
@@ -96,7 +98,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
});
|
||||
}
|
||||
|
||||
const presenter = new TestTaskPresenter();
|
||||
const presenter = new TestTaskPresenter($replica, clickhouseClient);
|
||||
try {
|
||||
const result = await presenter.call({
|
||||
userId,
|
||||
|
||||
+4
-4
@@ -47,7 +47,7 @@ export const meta: MetaFunction = () => {
|
||||
};
|
||||
|
||||
export async function loader({ params, request }: LoaderFunctionArgs) {
|
||||
await requireUserId(request);
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug } = OrganizationParamsSchema.parse(params);
|
||||
|
||||
const { isManagedCloud } = featuresForRequest(request);
|
||||
@@ -55,8 +55,8 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
|
||||
return redirect(organizationPath({ slug: organizationSlug }));
|
||||
}
|
||||
|
||||
const organization = await prisma.organization.findUnique({
|
||||
where: { slug: organizationSlug },
|
||||
const organization = await prisma.organization.findFirst({
|
||||
where: { slug: organizationSlug, members: { some: { userId } } },
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
@@ -181,7 +181,7 @@ export default function Page() {
|
||||
const fieldValues = useRef<string[]>(alerts.emails);
|
||||
const emailFields = useFieldList(form.ref, { ...emails, defaultValue: alerts.emails });
|
||||
|
||||
const checkboxLevels = [0.75, 0.9, 1.0];
|
||||
const checkboxLevels = [0.75, 0.9, 1.0, 2.0, 5.0];
|
||||
|
||||
useEffect(() => {
|
||||
if (alerts.emails.length > 0) {
|
||||
|
||||
@@ -28,7 +28,7 @@ export const meta: MetaFunction = () => {
|
||||
};
|
||||
|
||||
export async function loader({ params, request }: LoaderFunctionArgs) {
|
||||
await requireUserId(request);
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug } = OrganizationParamsSchema.parse(params);
|
||||
|
||||
const { isManagedCloud } = featuresForRequest(request);
|
||||
@@ -41,8 +41,8 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
|
||||
throw new Response(null, { status: 404, statusText: "Plans not found" });
|
||||
}
|
||||
|
||||
const organization = await prisma.organization.findUnique({
|
||||
where: { slug: organizationSlug },
|
||||
const organization = await prisma.organization.findFirst({
|
||||
where: { slug: organizationSlug, members: { some: { userId } } },
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
|
||||
@@ -46,7 +46,7 @@ export const meta: MetaFunction = () => {
|
||||
};
|
||||
|
||||
export async function loader({ params, request }: LoaderFunctionArgs) {
|
||||
await requireUserId(request);
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug } = OrganizationParamsSchema.parse(params);
|
||||
|
||||
const { isManagedCloud } = featuresForRequest(request);
|
||||
@@ -54,8 +54,8 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
|
||||
return redirect(organizationPath({ slug: organizationSlug }));
|
||||
}
|
||||
|
||||
const organization = await prisma.organization.findUnique({
|
||||
where: { slug: organizationSlug },
|
||||
const organization = await prisma.organization.findFirst({
|
||||
where: { slug: organizationSlug, members: { some: { userId } } },
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { BackgroundWrapper } from "~/components/BackgroundWrapper";
|
||||
import { AppContainer } from "~/components/layout/AppLayout";
|
||||
import { AppContainer, MainBody, PageBody } from "~/components/layout/AppLayout";
|
||||
import { Header1 } from "~/components/primitives/Headers";
|
||||
import { prisma } from "~/db.server";
|
||||
import { featuresForRequest } from "~/features.server";
|
||||
@@ -49,22 +49,24 @@ export default function ChoosePlanPage() {
|
||||
useTypedLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<AppContainer className="bg-charcoal-900">
|
||||
<BackgroundWrapper>
|
||||
<div className="mx-auto flex h-full w-full max-w-[80rem] flex-col items-center justify-center gap-8 p-3">
|
||||
<Header1 className="text-center">Subscribe for full access</Header1>
|
||||
<div className="w-full rounded-lg border border-grid-bright bg-background-dimmed p-5 shadow-lg">
|
||||
<PricingPlans
|
||||
plans={plans}
|
||||
subscription={v3Subscription}
|
||||
organizationSlug={organizationSlug}
|
||||
hasPromotedPlan
|
||||
showGithubVerificationBadge
|
||||
periodEnd={periodEnd}
|
||||
/>
|
||||
<AppContainer>
|
||||
<PageBody className="bg-charcoal-900">
|
||||
<BackgroundWrapper>
|
||||
<div className="mx-auto mt-4 flex h-fit min-h-full max-w-[80rem] flex-col items-center justify-center gap-8 lg:mt-0">
|
||||
<Header1 className="text-center">Subscribe for full access</Header1>
|
||||
<div className="w-full rounded-lg border border-grid-bright bg-background-dimmed p-5 shadow-lg">
|
||||
<PricingPlans
|
||||
plans={plans}
|
||||
subscription={v3Subscription}
|
||||
organizationSlug={organizationSlug}
|
||||
hasPromotedPlan
|
||||
showGithubVerificationBadge
|
||||
periodEnd={periodEnd}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BackgroundWrapper>
|
||||
</BackgroundWrapper>
|
||||
</PageBody>
|
||||
</AppContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { CheckCircleIcon } from "@heroicons/react/24/solid";
|
||||
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { title } from "process";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { ErrorIcon } from "~/assets/icons/ErrorIcon";
|
||||
import { AppContainer, MainCenteredContainer } from "~/components/layout/AppLayout";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Callout } from "~/components/primitives/Callout";
|
||||
import { Header1 } from "~/components/primitives/Headers";
|
||||
import { Icon } from "~/components/primitives/Icon";
|
||||
@@ -13,12 +10,16 @@ import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { createPersonalAccessTokenFromAuthorizationCode } from "~/services/personalAccessToken.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { rootPath } from "~/utils/pathBuilder";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
authorizationCode: z.string(),
|
||||
});
|
||||
|
||||
const SearchParamsSchema = z.object({
|
||||
source: z.string().optional(),
|
||||
clientName: z.string().optional(),
|
||||
});
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
|
||||
@@ -32,6 +33,14 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
});
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const searchObject = Object.fromEntries(url.searchParams.entries());
|
||||
|
||||
const searchParams = SearchParamsSchema.safeParse(searchObject);
|
||||
|
||||
const source = (searchParams.success ? searchParams.data.source : undefined) ?? "cli";
|
||||
const clientName = (searchParams.success ? searchParams.data.clientName : undefined) ?? "unknown";
|
||||
|
||||
try {
|
||||
const personalAccessToken = await createPersonalAccessTokenFromAuthorizationCode(
|
||||
parsedParams.data.authorizationCode,
|
||||
@@ -39,6 +48,8 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
);
|
||||
return typedjson({
|
||||
success: true as const,
|
||||
source,
|
||||
clientName,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Response) {
|
||||
@@ -49,6 +60,8 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
return typedjson({
|
||||
success: false as const,
|
||||
error: error.message,
|
||||
source,
|
||||
clientName,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -73,7 +86,7 @@ export default function Page() {
|
||||
<Icon icon={CheckCircleIcon} className="h-6 w-6 text-emerald-500" /> Successfully
|
||||
authenticated
|
||||
</Header1>
|
||||
<Paragraph>Return to your terminal to continue.</Paragraph>
|
||||
<Paragraph>{getInstructionsForSource(result.source, result.clientName)}</Paragraph>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
@@ -91,3 +104,21 @@ export default function Page() {
|
||||
</AppContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const prettyClientNames: Record<string, string> = {
|
||||
"claude-code": "Claude Code",
|
||||
"cursor-vscode": "Cursor",
|
||||
"Visual Studio Code": "VSCode",
|
||||
"windsurf-client": "Windsurf",
|
||||
"claude-ai": "Claude Desktop",
|
||||
};
|
||||
|
||||
function getInstructionsForSource(source: string, clientName: string) {
|
||||
if (source === "mcp") {
|
||||
if (clientName) {
|
||||
return `Return to your ${prettyClientNames[clientName] ?? clientName} to continue.`;
|
||||
}
|
||||
}
|
||||
|
||||
return `Return to your terminal to continue.`;
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ import { parse } from "@conform-to/zod";
|
||||
import { BookOpenIcon, ShieldCheckIcon, TrashIcon } from "@heroicons/react/20/solid";
|
||||
import { ShieldExclamationIcon } from "@heroicons/react/24/solid";
|
||||
import { DialogClose } from "@radix-ui/react-dialog";
|
||||
import { Form, MetaFunction, useActionData, useFetcher } from "@remix-run/react";
|
||||
import { ActionFunction, LoaderFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import { Form, type MetaFunction, useActionData, useFetcher } from "@remix-run/react";
|
||||
import { type ActionFunction, type LoaderFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
@@ -16,7 +16,6 @@ import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components
|
||||
import { Fieldset } from "~/components/primitives/Fieldset";
|
||||
import { FormButtons } from "~/components/primitives/FormButtons";
|
||||
import { FormError } from "~/components/primitives/FormError";
|
||||
import { Header2 } from "~/components/primitives/Headers";
|
||||
import { Hint } from "~/components/primitives/Hint";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { InputGroup } from "~/components/primitives/InputGroup";
|
||||
@@ -36,8 +35,8 @@ import {
|
||||
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import { redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import {
|
||||
CreatedPersonalAccessToken,
|
||||
ObfuscatedPersonalAccessToken,
|
||||
type CreatedPersonalAccessToken,
|
||||
type ObfuscatedPersonalAccessToken,
|
||||
createPersonalAccessToken,
|
||||
getValidPersonalAccessTokens,
|
||||
revokePersonalAccessToken,
|
||||
@@ -53,7 +52,7 @@ export const meta: MetaFunction = () => {
|
||||
];
|
||||
};
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
|
||||
try {
|
||||
@@ -113,7 +112,7 @@ export const action: ActionFunction = async ({ request }) => {
|
||||
}
|
||||
case "revoke": {
|
||||
try {
|
||||
await revokePersonalAccessToken(submission.value.tokenId);
|
||||
await revokePersonalAccessToken(submission.value.tokenId, userId);
|
||||
|
||||
return redirectWithSuccessMessage(
|
||||
personalAccessTokensPath(),
|
||||
@@ -125,6 +124,7 @@ export const action: ActionFunction = async ({ request }) => {
|
||||
}
|
||||
}
|
||||
default: {
|
||||
submission.value satisfies never;
|
||||
return json({ errors: { body: "Invalid action" } }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,13 +138,17 @@ export default function AdminDashboardRoute() {
|
||||
<TableCell isSticky={true}>
|
||||
<Form method="post" reloadDocument>
|
||||
<input type="hidden" name="id" value={user.id} />
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
name="action"
|
||||
value="impersonate"
|
||||
className="mr-2"
|
||||
variant="tertiary/small"
|
||||
shortcut={
|
||||
users.length === 1
|
||||
? { modifiers: ["mod"], key: "enter", enabledOnInputElements: true }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
Impersonate
|
||||
</Button>
|
||||
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
import { ActionFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import pMap from "p-map";
|
||||
import { z } from "zod";
|
||||
import { $replica, prisma } from "~/db.server";
|
||||
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
|
||||
import { determineEngineVersion } from "~/v3/engineVersion.server";
|
||||
import { engine } from "~/v3/runEngine.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
environmentId: z.string(),
|
||||
});
|
||||
|
||||
const BodySchema = z.object({
|
||||
dryRun: z.boolean().default(true),
|
||||
queues: z.array(z.string()).default([]),
|
||||
});
|
||||
|
||||
export async function action({ request, params }: ActionFunctionArgs) {
|
||||
// Next authenticate the request
|
||||
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
|
||||
|
||||
if (!authenticationResult) {
|
||||
return json({ error: "Invalid or Missing API key" }, { status: 401 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: authenticationResult.userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return json({ error: "Invalid or Missing API key" }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!user.admin) {
|
||||
return json({ error: "You must be an admin to perform this action" }, { status: 403 });
|
||||
}
|
||||
|
||||
const parsedParams = ParamsSchema.parse(params);
|
||||
|
||||
const environment = await prisma.runtimeEnvironment.findFirst({
|
||||
where: {
|
||||
id: parsedParams.environmentId,
|
||||
},
|
||||
include: {
|
||||
organization: true,
|
||||
project: true,
|
||||
orgMember: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!environment) {
|
||||
return json({ error: "Environment not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const engineVersion = await determineEngineVersion({ environment });
|
||||
|
||||
if (engineVersion === "V1") {
|
||||
return json({ error: "Engine version is V1" }, { status: 400 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const parsedBody = BodySchema.parse(body);
|
||||
|
||||
const queues = await $replica.taskQueue.findMany({
|
||||
where: {
|
||||
runtimeEnvironmentId: environment.id,
|
||||
version: "V2",
|
||||
name: parsedBody.queues.length > 0 ? { in: parsedBody.queues } : undefined,
|
||||
},
|
||||
select: {
|
||||
friendlyId: true,
|
||||
name: true,
|
||||
concurrencyLimit: true,
|
||||
type: true,
|
||||
paused: true,
|
||||
},
|
||||
orderBy: {
|
||||
orderableName: "asc",
|
||||
},
|
||||
});
|
||||
|
||||
const repairEnvironmentResults = await engine.repairEnvironment(environment, parsedBody.dryRun);
|
||||
|
||||
const repairResults = await pMap(
|
||||
queues,
|
||||
async (queue) => {
|
||||
const repair = await engine.repairQueue(
|
||||
environment,
|
||||
queue.name,
|
||||
parsedBody.dryRun,
|
||||
repairEnvironmentResults.runIds
|
||||
);
|
||||
|
||||
return {
|
||||
queue: queue.name,
|
||||
...repair,
|
||||
};
|
||||
},
|
||||
{ concurrency: 5 }
|
||||
);
|
||||
|
||||
return json({ environment: repairEnvironmentResults, queues: repairResults });
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { json, LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { $replica, prisma } from "~/db.server";
|
||||
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
|
||||
import { determineEngineVersion } from "~/v3/engineVersion.server";
|
||||
import { engine } from "~/v3/runEngine.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
environmentId: z.string(),
|
||||
});
|
||||
|
||||
const SearchParamsSchema = z.object({
|
||||
verbose: z.string().default("0"),
|
||||
page: z.coerce.number().optional(),
|
||||
per_page: z.coerce.number().optional(),
|
||||
});
|
||||
|
||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
// Next authenticate the request
|
||||
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
|
||||
|
||||
if (!authenticationResult) {
|
||||
return json({ error: "Invalid or Missing API key" }, { status: 401 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: authenticationResult.userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return json({ error: "Invalid or Missing API key" }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!user.admin) {
|
||||
return json({ error: "You must be an admin to perform this action" }, { status: 403 });
|
||||
}
|
||||
|
||||
const parsedParams = ParamsSchema.parse(params);
|
||||
|
||||
const environment = await prisma.runtimeEnvironment.findFirst({
|
||||
where: {
|
||||
id: parsedParams.environmentId,
|
||||
},
|
||||
include: {
|
||||
organization: true,
|
||||
project: true,
|
||||
orgMember: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!environment) {
|
||||
return json({ error: "Environment not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const engineVersion = await determineEngineVersion({ environment });
|
||||
|
||||
if (engineVersion === "V1") {
|
||||
return json({ error: "Engine version is V1" }, { status: 400 });
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const searchParams = SearchParamsSchema.parse(Object.fromEntries(url.searchParams));
|
||||
|
||||
const page = searchParams.page ?? 1;
|
||||
const perPage = searchParams.per_page ?? 50;
|
||||
|
||||
const queues = await $replica.taskQueue.findMany({
|
||||
where: {
|
||||
runtimeEnvironmentId: environment.id,
|
||||
version: "V2",
|
||||
},
|
||||
select: {
|
||||
friendlyId: true,
|
||||
name: true,
|
||||
concurrencyLimit: true,
|
||||
type: true,
|
||||
paused: true,
|
||||
},
|
||||
orderBy: {
|
||||
orderableName: "asc",
|
||||
},
|
||||
skip: (page - 1) * perPage,
|
||||
take: perPage,
|
||||
});
|
||||
|
||||
const report = await engine.generateEnvironmentReport(
|
||||
environment,
|
||||
queues,
|
||||
searchParams.verbose === "1"
|
||||
);
|
||||
|
||||
return json(report);
|
||||
}
|
||||
@@ -117,6 +117,11 @@ export default function AdminDashboardRoute() {
|
||||
to={`/@/orgs/${org.slug}`}
|
||||
className="mr-2"
|
||||
variant="tertiary/small"
|
||||
shortcut={
|
||||
organizations.length === 1
|
||||
? { modifiers: ["mod"], key: "enter", enabledOnInputElements: true }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
Impersonate
|
||||
</LinkButton>
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import { CancelDeploymentRequestBody, tryCatch } from "@trigger.dev/core/v3";
|
||||
import { z } from "zod";
|
||||
import { authenticateRequest } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { DeploymentService } from "~/v3/services/deployment.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
deploymentId: z.string(),
|
||||
});
|
||||
|
||||
export async function action({ request, params }: ActionFunctionArgs) {
|
||||
if (request.method.toUpperCase() !== "POST") {
|
||||
return json({ error: "Method Not Allowed" }, { status: 405 });
|
||||
}
|
||||
|
||||
const parsedParams = ParamsSchema.safeParse(params);
|
||||
|
||||
if (!parsedParams.success) {
|
||||
return json({ error: "Invalid params" }, { status: 400 });
|
||||
}
|
||||
|
||||
const authenticationResult = await authenticateRequest(request, {
|
||||
apiKey: true,
|
||||
organizationAccessToken: false,
|
||||
personalAccessToken: false,
|
||||
});
|
||||
|
||||
if (!authenticationResult || !authenticationResult.result.ok) {
|
||||
logger.info("Invalid or missing api key", { url: request.url });
|
||||
return json({ error: "Invalid or Missing API key" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { environment: authenticatedEnv } = authenticationResult.result;
|
||||
const { deploymentId } = parsedParams.data;
|
||||
|
||||
const [, rawBody] = await tryCatch(request.json());
|
||||
const body = CancelDeploymentRequestBody.safeParse(rawBody ?? {});
|
||||
|
||||
if (!body.success) {
|
||||
return json({ error: "Invalid request body", issues: body.error.issues }, { status: 400 });
|
||||
}
|
||||
|
||||
const deploymentService = new DeploymentService();
|
||||
|
||||
return await deploymentService
|
||||
.cancelDeployment(authenticatedEnv, deploymentId, {
|
||||
canceledReason: body.data.reason,
|
||||
})
|
||||
.match(
|
||||
() => {
|
||||
return new Response(null, { status: 204 });
|
||||
},
|
||||
(error) => {
|
||||
switch (error.type) {
|
||||
case "deployment_not_found":
|
||||
return json({ error: "Deployment not found" }, { status: 404 });
|
||||
case "failed_to_delete_deployment_timeout":
|
||||
return new Response(null, { status: 204 }); // not a critical error, ignore
|
||||
case "deployment_cannot_be_cancelled":
|
||||
return json(
|
||||
{ error: "Deployment is already in a final state and cannot be canceled" },
|
||||
{ status: 409 }
|
||||
);
|
||||
case "other":
|
||||
default:
|
||||
error.type satisfies "other";
|
||||
return json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import {
|
||||
type GenerateRegistryCredentialsResponseBody,
|
||||
ProgressDeploymentRequestBody,
|
||||
tryCatch,
|
||||
} from "@trigger.dev/core/v3";
|
||||
import { z } from "zod";
|
||||
import { authenticateRequest } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { DeploymentService } from "~/v3/services/deployment.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
deploymentId: z.string(),
|
||||
});
|
||||
|
||||
export async function action({ request, params }: ActionFunctionArgs) {
|
||||
if (request.method.toUpperCase() !== "POST") {
|
||||
return json({ error: "Method Not Allowed" }, { status: 405 });
|
||||
}
|
||||
|
||||
const parsedParams = ParamsSchema.safeParse(params);
|
||||
|
||||
if (!parsedParams.success) {
|
||||
return json({ error: "Invalid params" }, { status: 400 });
|
||||
}
|
||||
|
||||
const authenticationResult = await authenticateRequest(request, {
|
||||
apiKey: true,
|
||||
organizationAccessToken: false,
|
||||
personalAccessToken: false,
|
||||
});
|
||||
|
||||
if (!authenticationResult || !authenticationResult.result.ok) {
|
||||
logger.info("Invalid or missing api key", { url: request.url });
|
||||
return json({ error: "Invalid or Missing API key" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { environment: authenticatedEnv } = authenticationResult.result;
|
||||
const { deploymentId } = parsedParams.data;
|
||||
|
||||
const [, rawBody] = await tryCatch(request.json());
|
||||
const body = ProgressDeploymentRequestBody.safeParse(rawBody ?? {});
|
||||
|
||||
if (!body.success) {
|
||||
return json({ error: "Invalid request body", issues: body.error.issues }, { status: 400 });
|
||||
}
|
||||
|
||||
const deploymentService = new DeploymentService();
|
||||
|
||||
return await deploymentService.generateRegistryCredentials(authenticatedEnv, deploymentId).match(
|
||||
(result) => {
|
||||
return json(
|
||||
{
|
||||
username: result.username,
|
||||
password: result.password,
|
||||
expiresAt: result.expiresAt.toISOString(),
|
||||
repositoryUri: result.repositoryUri,
|
||||
} satisfies GenerateRegistryCredentialsResponseBody,
|
||||
{ status: 200 }
|
||||
);
|
||||
},
|
||||
(error) => {
|
||||
switch (error.type) {
|
||||
case "deployment_not_found":
|
||||
return json({ error: "Deployment not found" }, { status: 404 });
|
||||
case "deployment_has_no_image_reference":
|
||||
logger.error(
|
||||
"Failed to generate registry credentials: deployment_has_no_image_reference",
|
||||
{ deploymentId }
|
||||
);
|
||||
return json({ error: "Deployment has no image reference" }, { status: 409 });
|
||||
case "deployment_is_already_final":
|
||||
return json(
|
||||
{ error: "Failed to generate registry credentials: deployment_is_already_final" },
|
||||
{ status: 409 }
|
||||
);
|
||||
case "missing_registry_credentials":
|
||||
logger.error("Failed to generate registry credentials: missing_registry_credentials", {
|
||||
deploymentId,
|
||||
});
|
||||
return json({ error: "Missing registry credentials" }, { status: 409 });
|
||||
case "registry_not_supported":
|
||||
logger.error("Failed to generate registry credentials: registry_not_supported", {
|
||||
deploymentId,
|
||||
});
|
||||
return json({ error: "Registry not supported" }, { status: 409 });
|
||||
case "registry_region_not_supported":
|
||||
logger.error("Failed to generate registry credentials: registry_region_not_supported", {
|
||||
deploymentId,
|
||||
});
|
||||
return json({ error: "Registry region not supported" }, { status: 409 });
|
||||
case "other":
|
||||
default:
|
||||
error.type satisfies "other";
|
||||
logger.error("Failed to generate registry credentials", { error: error.cause });
|
||||
return json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import { ProgressDeploymentRequestBody, tryCatch } from "@trigger.dev/core/v3";
|
||||
import { z } from "zod";
|
||||
import { authenticateRequest } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { DeploymentService } from "~/v3/services/deployment.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
deploymentId: z.string(),
|
||||
});
|
||||
|
||||
export async function action({ request, params }: ActionFunctionArgs) {
|
||||
if (request.method.toUpperCase() !== "POST") {
|
||||
return json({ error: "Method Not Allowed" }, { status: 405 });
|
||||
}
|
||||
|
||||
const parsedParams = ParamsSchema.safeParse(params);
|
||||
|
||||
if (!parsedParams.success) {
|
||||
return json({ error: "Invalid params" }, { status: 400 });
|
||||
}
|
||||
|
||||
const authenticationResult = await authenticateRequest(request, {
|
||||
apiKey: true,
|
||||
organizationAccessToken: false,
|
||||
personalAccessToken: false,
|
||||
});
|
||||
|
||||
if (!authenticationResult || !authenticationResult.result.ok) {
|
||||
logger.info("Invalid or missing api key", { url: request.url });
|
||||
return json({ error: "Invalid or Missing API key" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { environment: authenticatedEnv } = authenticationResult.result;
|
||||
const { deploymentId } = parsedParams.data;
|
||||
|
||||
const [, rawBody] = await tryCatch(request.json());
|
||||
const body = ProgressDeploymentRequestBody.safeParse(rawBody ?? {});
|
||||
|
||||
if (!body.success) {
|
||||
return json({ error: "Invalid request body", issues: body.error.issues }, { status: 400 });
|
||||
}
|
||||
|
||||
const deploymentService = new DeploymentService();
|
||||
|
||||
return await deploymentService
|
||||
.progressDeployment(authenticatedEnv, deploymentId, {
|
||||
contentHash: body.data.contentHash,
|
||||
git: body.data.gitMeta,
|
||||
runtime: body.data.runtime,
|
||||
})
|
||||
.match(
|
||||
() => {
|
||||
return new Response(null, { status: 204 });
|
||||
},
|
||||
(error) => {
|
||||
switch (error.type) {
|
||||
case "failed_to_extend_deployment_timeout": {
|
||||
logger.warn("Failed to extend deployment timeout", { error: error.cause });
|
||||
return new Response(null, { status: 204 }); // ignore these errors for now
|
||||
}
|
||||
case "deployment_not_found":
|
||||
return json({ error: "Deployment not found" }, { status: 404 });
|
||||
case "deployment_cannot_be_progressed":
|
||||
return json(
|
||||
{ error: "Deployment is not in a progressable state (PENDING or INSTALLING)" },
|
||||
{ status: 409 }
|
||||
);
|
||||
case "failed_to_create_remote_build": {
|
||||
logger.error("Failed to create remote Depot build", { error: error.cause });
|
||||
return json({ error: "Failed to create remote build" }, { status: 500 });
|
||||
}
|
||||
case "other":
|
||||
default:
|
||||
error.type satisfies "other";
|
||||
return json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { LoaderFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import { type LoaderFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import { type GetDeploymentResponseBody } from "@trigger.dev/core/v3";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "~/db.server";
|
||||
import { authenticateApiRequest } from "~/services/apiAuth.server";
|
||||
@@ -52,7 +53,10 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
shortCode: deployment.shortCode,
|
||||
version: deployment.version,
|
||||
imageReference: deployment.imageReference,
|
||||
errorData: deployment.errorData,
|
||||
imagePlatform: deployment.imagePlatform,
|
||||
externalBuildData:
|
||||
deployment.externalBuildData as GetDeploymentResponseBody["externalBuildData"],
|
||||
errorData: deployment.errorData as GetDeploymentResponseBody["errorData"],
|
||||
worker: deployment.worker
|
||||
? {
|
||||
id: deployment.worker.friendlyId,
|
||||
@@ -65,5 +69,5 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
})),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
} satisfies GetDeploymentResponseBody);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { ActionFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import {
|
||||
ApiDeploymentListSearchParams,
|
||||
InitializeDeploymentRequestBody,
|
||||
InitializeDeploymentResponseBody,
|
||||
type InitializeDeploymentResponseBody,
|
||||
} from "@trigger.dev/core/v3";
|
||||
import { $replica } from "~/db.server";
|
||||
import { authenticateApiRequest } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
||||
import { ServiceValidationError } from "~/v3/services/baseService.server";
|
||||
import { InitializeDeploymentService } from "~/v3/services/initializeDeployment.server";
|
||||
|
||||
@@ -60,3 +63,119 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const loader = createLoaderApiRoute(
|
||||
{
|
||||
searchParams: ApiDeploymentListSearchParams,
|
||||
allowJWT: true,
|
||||
corsStrategy: "none",
|
||||
authorization: {
|
||||
action: "read",
|
||||
resource: () => ({ deployments: "list" }),
|
||||
superScopes: ["read:deployments", "read:all", "admin"],
|
||||
},
|
||||
findResource: async () => 1, // This is a dummy function, we don't need to find a resource
|
||||
},
|
||||
async ({ searchParams, authentication }) => {
|
||||
const limit = Math.max(Math.min(searchParams["page[size]"] ?? 20, 100), 5);
|
||||
|
||||
const afterDeployment = searchParams["page[after]"]
|
||||
? await $replica.workerDeployment.findFirst({
|
||||
where: {
|
||||
friendlyId: searchParams["page[after]"],
|
||||
environmentId: authentication.environment.id,
|
||||
},
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const deployments = await $replica.workerDeployment.findMany({
|
||||
where: {
|
||||
environmentId: authentication.environment.id,
|
||||
...(afterDeployment ? { id: { lt: afterDeployment.id } } : {}),
|
||||
...getCreatedAtFilter(searchParams),
|
||||
...(searchParams.status ? { status: searchParams.status } : {}),
|
||||
},
|
||||
orderBy: {
|
||||
id: "desc",
|
||||
},
|
||||
take: limit + 1,
|
||||
});
|
||||
|
||||
const hasMore = deployments.length > limit;
|
||||
const nextCursor = hasMore ? deployments[limit - 1].friendlyId : undefined;
|
||||
const data = hasMore ? deployments.slice(0, limit) : deployments;
|
||||
|
||||
return json({
|
||||
data: data.map((deployment) => ({
|
||||
id: deployment.friendlyId,
|
||||
createdAt: deployment.createdAt,
|
||||
shortCode: deployment.shortCode,
|
||||
version: deployment.version.toString(),
|
||||
runtime: deployment.runtime,
|
||||
runtimeVersion: deployment.runtimeVersion,
|
||||
status: deployment.status,
|
||||
deployedAt: deployment.deployedAt,
|
||||
git: deployment.git,
|
||||
error: deployment.errorData ?? undefined,
|
||||
})),
|
||||
pagination: {
|
||||
next: nextCursor,
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
import parseDuration from "parse-duration";
|
||||
import { parseDate } from "@trigger.dev/core/v3/isomorphic";
|
||||
|
||||
function getCreatedAtFilter(searchParams: ApiDeploymentListSearchParams) {
|
||||
if (searchParams.period) {
|
||||
const duration = parseDuration(searchParams.period, "ms");
|
||||
|
||||
if (!duration) {
|
||||
throw new ServiceValidationError(
|
||||
`Invalid search query parameter: period=${searchParams.period}`,
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
createdAt: {
|
||||
gte: new Date(Date.now() - duration),
|
||||
lte: new Date(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (searchParams.from && searchParams.to) {
|
||||
const fromDate = safeDateFromString(searchParams.from, "from");
|
||||
const toDate = safeDateFromString(searchParams.to, "to");
|
||||
|
||||
return {
|
||||
createdAt: {
|
||||
gte: fromDate,
|
||||
lte: toDate,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (searchParams.from) {
|
||||
const fromDate = safeDateFromString(searchParams.from, "from");
|
||||
return {
|
||||
createdAt: {
|
||||
gte: fromDate,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
function safeDateFromString(value: string, paramName: string) {
|
||||
const date = parseDate(value);
|
||||
|
||||
if (!date) {
|
||||
throw new ServiceValidationError(`Invalid search query parameter: ${paramName}=${value}`, 400);
|
||||
}
|
||||
return date;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import {
|
||||
CreateProjectRequestBody,
|
||||
GetProjectResponseBody,
|
||||
GetProjectsResponseBody,
|
||||
} from "@trigger.dev/core/v3";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "~/db.server";
|
||||
import { createProject } from "~/models/project.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
|
||||
import { isCuid } from "cuid";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
orgParam: z.string(),
|
||||
});
|
||||
|
||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
logger.info("get projects", { url: request.url });
|
||||
|
||||
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
|
||||
|
||||
if (!authenticationResult) {
|
||||
return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { orgParam } = ParamsSchema.parse(params);
|
||||
|
||||
const projects = await prisma.project.findMany({
|
||||
where: {
|
||||
organization: {
|
||||
...orgParamWhereClause(orgParam),
|
||||
deletedAt: null,
|
||||
members: {
|
||||
some: {
|
||||
userId: authenticationResult.userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
version: "V3",
|
||||
deletedAt: null,
|
||||
},
|
||||
include: {
|
||||
organization: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!projects) {
|
||||
return json({ error: "Projects not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const result: GetProjectsResponseBody = projects.map((project) => ({
|
||||
id: project.id,
|
||||
externalRef: project.externalRef,
|
||||
name: project.name,
|
||||
slug: project.slug,
|
||||
createdAt: project.createdAt,
|
||||
organization: {
|
||||
id: project.organization.id,
|
||||
title: project.organization.title,
|
||||
slug: project.organization.slug,
|
||||
createdAt: project.organization.createdAt,
|
||||
},
|
||||
}));
|
||||
|
||||
return json(result);
|
||||
}
|
||||
|
||||
export async function action({ request, params }: ActionFunctionArgs) {
|
||||
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
|
||||
|
||||
if (!authenticationResult) {
|
||||
return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { orgParam } = ParamsSchema.parse(params);
|
||||
|
||||
const organization = await prisma.organization.findFirst({
|
||||
where: {
|
||||
...orgParamWhereClause(orgParam),
|
||||
deletedAt: null,
|
||||
members: {
|
||||
some: {
|
||||
userId: authenticationResult.userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
return json({ error: "Organization not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const parsedBody = CreateProjectRequestBody.safeParse(body);
|
||||
|
||||
if (!parsedBody.success) {
|
||||
return json({ error: "Invalid request body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const project = await createProject({
|
||||
organizationSlug: organization.slug,
|
||||
name: parsedBody.data.name,
|
||||
userId: authenticationResult.userId,
|
||||
version: "v3",
|
||||
});
|
||||
|
||||
const result: GetProjectResponseBody = {
|
||||
id: project.id,
|
||||
externalRef: project.externalRef,
|
||||
name: project.name,
|
||||
slug: project.slug,
|
||||
createdAt: project.createdAt,
|
||||
organization: {
|
||||
id: project.organization.id,
|
||||
title: project.organization.title,
|
||||
slug: project.organization.slug,
|
||||
createdAt: project.organization.createdAt,
|
||||
},
|
||||
};
|
||||
|
||||
return json(result);
|
||||
}
|
||||
|
||||
function orgParamWhereClause(orgParam: string) {
|
||||
// If the orgParam is an ID, or if it's a slug
|
||||
// IDs are cuid
|
||||
if (isCuid(orgParam)) {
|
||||
return {
|
||||
id: orgParam,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
slug: orgParam,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { GetOrgsResponseBody } from "@trigger.dev/core/v3";
|
||||
import { prisma } from "~/db.server";
|
||||
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
|
||||
|
||||
if (!authenticationResult) {
|
||||
return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
|
||||
}
|
||||
|
||||
const orgs = await prisma.organization.findMany({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
members: {
|
||||
some: {
|
||||
userId: authenticationResult.userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!orgs) {
|
||||
return json({ error: "Orgs not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const result: GetOrgsResponseBody = orgs.map((org) => ({
|
||||
id: org.id,
|
||||
title: org.title,
|
||||
slug: org.slug,
|
||||
createdAt: org.createdAt,
|
||||
}));
|
||||
|
||||
return json(result);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user