Compare commits
67 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3900ddadce | |||
| ca9e827bd3 | |||
| 04e936b69b | |||
| 98ef170299 | |||
| e69ffd314a | |||
| 782d4f75ae | |||
| b6de469d07 | |||
| 0dd3447c31 | |||
| a5a5d3ae21 | |||
| ee3619bbb1 | |||
| d9ad72446e | |||
| a56f9af9fe | |||
| ece6ca678a | |||
| 6243ae30bb | |||
| cec20b96fd | |||
| ea4d15536c | |||
| 2fa5780076 | |||
| 914a394d15 | |||
| 80b1d8c6b1 | |||
| 129c6022a0 | |||
| 1ca3b99ace | |||
| dbbc7760f3 | |||
| ff3cd97a3b | |||
| 3a1b0c486a | |||
| 1f462eaa2f | |||
| f537778dc7 | |||
| 974bcc49e8 | |||
| 60cbb87b08 | |||
| 1281d40e4b | |||
| 116766f398 | |||
| c815f28c84 | |||
| c092c0f9db | |||
| a86f36cefa | |||
| 5a6e79e0c0 | |||
| 7cb20fbead | |||
| a561bca315 | |||
| 3f45738612 | |||
| 05d3b08ae5 | |||
| 3f8b6d8fce | |||
| 0a7ad232fb | |||
| 04f863c42a | |||
| ae5ec86d0b | |||
| f243eab9c9 | |||
| a5cba375ae | |||
| c9ebe7f114 | |||
| f4f87ad573 | |||
| a98b62652c | |||
| 38965d8921 | |||
| 14cffd4e5c | |||
| 68107565a4 | |||
| 8a263c8ab9 | |||
| 6fecf2dbc0 | |||
| a8db8ce4e4 | |||
| a7ca1222d7 | |||
| c9733f357f | |||
| ba61bfe3b9 | |||
| 89eaef495f | |||
| 6a379e4e91 | |||
| c0b815c363 | |||
| 598906fc4c | |||
| b77ece1567 | |||
| a73dc80177 | |||
| 2496917a7a | |||
| dc53f0f432 | |||
| 87788f29c9 | |||
| 6ede68648a | |||
| f24b5b770d |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
Fixing missing logs when importing client @opentelemetry/api
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@trigger.dev/sdk": patch
|
||||
"trigger.dev": patch
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
v3: Environment variable management API and SDK, along with resolveEnvVars CLI hook
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
trigger.dev: patch
|
||||
---
|
||||
|
||||
Fix TypeScript inclusion in tsconfig.json for `cli-v3 init`
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Fix issue when using SDK in non-node environments by scoping the stream import with node:
|
||||
+13
-1
@@ -55,6 +55,7 @@
|
||||
"clever-apes-collect",
|
||||
"clever-carrots-travel",
|
||||
"clever-donkeys-hunt",
|
||||
"cool-comics-burn",
|
||||
"cool-glasses-bake",
|
||||
"cuddly-feet-approve",
|
||||
"dry-walls-check",
|
||||
@@ -63,9 +64,11 @@
|
||||
"eleven-paws-join",
|
||||
"famous-boats-tease",
|
||||
"few-students-share",
|
||||
"five-toes-destroy",
|
||||
"funny-swans-destroy",
|
||||
"gorgeous-gorillas-compete",
|
||||
"green-bags-wink",
|
||||
"hot-fishes-retire",
|
||||
"khaki-apricots-design",
|
||||
"khaki-poems-lay",
|
||||
"late-icons-lie",
|
||||
@@ -82,6 +85,7 @@
|
||||
"mighty-camels-joke",
|
||||
"mighty-flowers-train",
|
||||
"nasty-jars-pump",
|
||||
"new-pants-beg",
|
||||
"new-rivers-tell",
|
||||
"nice-bulldogs-turn",
|
||||
"ninety-pets-travel",
|
||||
@@ -104,12 +108,15 @@
|
||||
"shiny-coats-cry",
|
||||
"silly-suits-switch",
|
||||
"six-ligers-exist",
|
||||
"sixty-insects-watch",
|
||||
"slow-buses-own",
|
||||
"smart-needles-move",
|
||||
"smart-olives-eat",
|
||||
"spicy-lamps-smoke",
|
||||
"strange-ghosts-matter",
|
||||
"strange-sheep-pull",
|
||||
"strong-lemons-add",
|
||||
"strong-owls-know",
|
||||
"stupid-bulldogs-applaud",
|
||||
"sweet-lizards-press",
|
||||
"swift-dragons-peel",
|
||||
@@ -118,12 +125,17 @@
|
||||
"tender-moose-tell",
|
||||
"tender-oranges-rhyme",
|
||||
"thin-parents-heal",
|
||||
"thirty-islands-kiss",
|
||||
"tidy-balloons-suffer",
|
||||
"tidy-dryers-sleep",
|
||||
"tidy-tomatoes-explain",
|
||||
"tiny-doors-type",
|
||||
"tiny-elephants-scream",
|
||||
"tricky-bulldogs-heal",
|
||||
"tricky-keys-attack",
|
||||
"tricky-ladybugs-unite",
|
||||
"two-pumas-wait"
|
||||
"two-pumas-wait",
|
||||
"warm-olives-provide",
|
||||
"warm-planes-taste"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
Set the deploy timeout to 3mins from 1min
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@trigger.dev/sdk": patch
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
v2: Better handle recovering from platform communication errors by auto-yielding back to the platform in case of temporary API failures
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@trigger.dev/sdk": patch
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
When a v2 run hits the rate limit, reschedule with the reset date
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
Fix for typo in v3 CLI login command
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Fix 3rd party otel propagation from breaking our Task Events data from being properly correlated to the correct trace
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
- Clear paused states before retry
|
||||
- Detect and handle unrecoverable worker errors
|
||||
- Remove checkpoints after successful push
|
||||
- Permanently switch to DO hosted busybox image
|
||||
- Fix IPC timeout issue, or at least handle it more gracefully
|
||||
- Handle checkpoint failures
|
||||
- Basic chaos monkey for checkpoint testing
|
||||
- Stack traces are back in the dashboard
|
||||
- Display final errors on root span
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Improve handling of IPC timeouts and fix checkpoint cancellation after failures
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
v3: Prevent legacy-peer-deps=true from breaking deploys
|
||||
|
||||
When a global `.npmrc` file includes `legacy-peer-deps=true`, deploys would fail on the `npm ci` step because the package-lock.json wouldn't match the `package.json` file. This is because inside the image build, the `.npmrc` file would not be picked up and so `legacy-peer-deps` would end up being false (which is the default). This change forces the `package-lock.json` file to be created using `legacy-peer-deps=false`
|
||||
@@ -11,12 +11,17 @@ REMIX_APP_PORT=3030
|
||||
APP_ENV=development
|
||||
APP_ORIGIN=http://localhost:3030
|
||||
NODE_ENV=development
|
||||
CLOUD_ENV=development
|
||||
V3_ENABLED=true
|
||||
|
||||
# Redis is used for the v3 queuing and v2 concurrency control
|
||||
REDIS_HOST="localhost"
|
||||
REDIS_PORT="6379"
|
||||
REDIS_TLS_DISABLED="true"
|
||||
|
||||
DEV_OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:3030/otel"
|
||||
DEV_OTEL_BATCH_PROCESSING_ENABLED="0"
|
||||
|
||||
# OPTIONAL VARIABLES
|
||||
# This is used for validating emails that are allowed to log in. Every email that do not match this regex will be rejected.
|
||||
# WHITELISTED_EMAILS="authorized@yahoo\.com|authorized@gmail\.com"
|
||||
|
||||
Vendored
+8
@@ -45,6 +45,14 @@
|
||||
"cwd": "${workspaceFolder}/references/v3-catalog",
|
||||
"sourceMaps": true
|
||||
},
|
||||
{
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"name": "Debug V3 Management",
|
||||
"command": "pnpm run management",
|
||||
"cwd": "${workspaceFolder}/references/v3-catalog",
|
||||
"sourceMaps": true
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "attach",
|
||||
|
||||
+106
-88
@@ -10,13 +10,14 @@ Thank you for helping us make Trigger.dev even better! 🤩
|
||||
|
||||
The development branch is `main`. This is the branch that all pull
|
||||
requests should be made against. The changes on the `main`
|
||||
branch are tagged into a release monthly.
|
||||
branch are tagged into a release periodically.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Node.js](https://nodejs.org/en) version >=16.x
|
||||
- [pnpm package manager](https://pnpm.io/installation) version 7
|
||||
- [Node.js](https://nodejs.org/en) version 20.11.1
|
||||
- [pnpm package manager](https://pnpm.io/installation) version 8.15.5
|
||||
- [Docker](https://www.docker.com/get-started/)
|
||||
- [protobuf](https://github.com/protocolbuffers/protobuf)
|
||||
|
||||
### Setup
|
||||
|
||||
@@ -33,15 +34,19 @@ branch are tagged into a release monthly.
|
||||
```
|
||||
cd trigger.dev
|
||||
```
|
||||
3. Install the required packages using pnpm.
|
||||
3. Ensure you are on the correct version of Node.js (20.11.1). If you are using `nvm`, there is an `.nvmrc` file that will automatically select the correct version of Node.js when you navigate to the repository.
|
||||
|
||||
4. Run `corepack enable` to use the correct version of pnpm (`8.15.5`) as specified in the root `package.json` file.
|
||||
|
||||
5. Install the required packages using pnpm.
|
||||
```
|
||||
pnpm i
|
||||
```
|
||||
4. Create your `.env` file
|
||||
6. Create your `.env` file
|
||||
```
|
||||
cp .env.example .env
|
||||
```
|
||||
5. Open it and generate a new value for `ENCRYPTION_KEY`:
|
||||
7. Open it and generate a new value for `ENCRYPTION_KEY`:
|
||||
|
||||
`ENCRYPTION_KEY` is used to two-way encrypt OAuth access tokens and so you'll probably want to actually generate a unique value, and it must be a random 16 byte hex string. You can generate one with the following command:
|
||||
|
||||
@@ -51,7 +56,7 @@ branch are tagged into a release monthly.
|
||||
|
||||
Feel free to update `SESSION_SECRET` and `MAGIC_LINK_SECRET` as well using the same method.
|
||||
|
||||
6. Start Docker. This starts the required services like Postgres. If this is your first time using Docker, consider going through this [guide](DOCKER_INSTALLATION.md)
|
||||
8. Start Docker. This starts the required services like Postgres & Redis. If this is your first time using Docker, consider going through this [guide](DOCKER_INSTALLATION.md)
|
||||
|
||||
```
|
||||
pnpm run docker
|
||||
@@ -59,19 +64,15 @@ branch are tagged into a release monthly.
|
||||
|
||||
This will also start and run a local instance of [pgAdmin](https://www.pgadmin.org/) on [localhost:5480](http://localhost:5480), preconfigured with email `admin@example.com` and pwd `admin`. Then use `postgres` as the password to the Trigger.dev server.
|
||||
|
||||
7. Migrate the database
|
||||
9. Migrate the database
|
||||
```
|
||||
pnpm run db:migrate
|
||||
```
|
||||
8. Build the app
|
||||
```
|
||||
pnpm run build --filter webapp
|
||||
```
|
||||
9. Run the seed script
|
||||
```
|
||||
pnpm run db:seed
|
||||
```
|
||||
10. Run the app. See the section below.
|
||||
10. Build the server app
|
||||
```
|
||||
pnpm run build --filter webapp
|
||||
```
|
||||
11. Run the app. See the section below.
|
||||
|
||||
## Running
|
||||
|
||||
@@ -83,99 +84,96 @@ branch are tagged into a release monthly.
|
||||
|
||||
It should run on port `3030`: [http://localhost:3030](http://localhost:3030/)
|
||||
|
||||
2. Once the app is running click the magic link button and enter your email.
|
||||
3. Check your terminal, the magic link email should have printed out as following:
|
||||
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.
|
||||
|
||||
```sh
|
||||
webapp:dev: Log in to Trigger.dev
|
||||
webapp:dev:
|
||||
webapp:dev: Click here to log in with this magic link
|
||||
webapp:dev: [http://localhost:3030/magic?token=U2FsdGVkX18OvB0JxgaswTLCSbaRz%2FY82TN0EZWhSzFyZYwgG%2BIzKVTkeiaOtWfotPw7F8RwFzCHh53aBpMEu%2B%2B%2FItb%2FcJYh89MSjc3Pz92bevoEjqxSQ%2Ff%2BZbks09JOpqlBbYC3FzGWC8vuSVFBlxqLXxteSDLthZSUaC%2BS2LaA%2BJgp%2BLO7hgjAaC2lXbCHrM7MTgTdXOFt7i0Dvvuwz6%2BWY25RnfomZOPqDsyH0xz8Q2rzPTz0Xu53WSXrZ1hd]
|
||||
webapp:dev:
|
||||
webapp:dev: If you didn't try to log in, you can safely ignore this email.
|
||||
```
|
||||
## Manual testing using v3-catalog
|
||||
|
||||
Paste the magic link shown in your terminal into your browser to login.
|
||||
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).
|
||||
|
||||
## Adding and running migrations
|
||||
### First-time setup
|
||||
|
||||
1. Modify packages/database/prisma/schema.prisma file
|
||||
2. Change directory to the packages/database folder
|
||||
```sh
|
||||
cd packages/database
|
||||
```
|
||||
3. Generate the Prisma client
|
||||
First, make sure you are running the webapp according to the instructions above. Then:
|
||||
|
||||
```sh
|
||||
pnpm run generate
|
||||
```
|
||||
1. In Postgres go to the "Organizations" table and on your org set the `v3Enabled` column to `true`.
|
||||
|
||||
The above updates the prisma client generated into node_modules/.prisma/client folder. This helps with typing of relevant prisma models. It ensures typescript
|
||||
recognizes fields added or removed from a model and type-checks appropriately.
|
||||
2. Visit http://localhost:3030 in your browser and create a new V3 project called "v3-catalog". If you don't see an option for V3, you haven't set the `v3Enabled` flag to true.
|
||||
|
||||
4. Create and apply the migrations
|
||||
3. In Postgres go to the "Projects" table and for the project you create change the `externalRef` to `yubjwjsfkxnylobaqvqz`.
|
||||
|
||||
```
|
||||
pnpm run db:migrate:dev
|
||||
```
|
||||
|
||||
This creates a migration file and executes the migrations against your database and applies changes to the database schema(s)
|
||||
|
||||
5. Commit generated migrations as well as changes to the schema.prisma file
|
||||
6. If you're using VSCode you may need to restart the Typescript server in the webapp to get updated type inference. Open a TypeScript file, then open the Command Palette (View > Command Palette) and run `TypeScript: Restart TS server`.
|
||||
|
||||
## Testing CLI changes
|
||||
|
||||
To test CLI changes, follow the steps below:
|
||||
|
||||
1. Build the CLI and watch for changes
|
||||
4. Build the CLI
|
||||
|
||||
```sh
|
||||
cd packages/cli
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
2. Open a new Terminal window and run the webapp locally and then create a new project in the dashboard. Copy out the dev API key.
|
||||
|
||||
3. Create a new temporary Next.js app in references directory
|
||||
|
||||
```sh
|
||||
cd ./references
|
||||
pnpm create next-app@latest test-cli --ts --no-eslint --tailwind --app --src-dir --import-alias "@/*"
|
||||
```
|
||||
|
||||
4. Then once that's finished, add the `@trigger.dev/cli` to the `devDependencies` of the newly created Next.js app's `package.json` file, like so:
|
||||
|
||||
```json
|
||||
{
|
||||
// other package.json properties
|
||||
"devDependencies": { "@trigger.dev/cli": "workspace:*" }
|
||||
}
|
||||
```
|
||||
|
||||
5. Back in the terminal, navigate into the reference, and initialize the CLI. When prompted, select `self-hosted` and enter `localhost:3030` if you are testing against the local instance of Trigger.dev, or you can just use the Trigger.dev cloud. When asked for an API key, use the key you copied earlier.
|
||||
|
||||
```sh
|
||||
cd ./test-cli
|
||||
# Build the CLI
|
||||
pnpm run build --filter trigger.dev
|
||||
# Make it accessible to `pnpm exec`
|
||||
pnpm i
|
||||
pnpm exec trigger-cli init
|
||||
```
|
||||
|
||||
6. If you are just testing the `init` command, you can stop here. If you'd like to test the `dev` command, first start the Next.js app on port 3000:
|
||||
5. Change into the `<root>/references/v3-catalog` directory and authorize the CLI to the local server:
|
||||
|
||||
```sh
|
||||
pnpm run dev
|
||||
cd references/v3-catalog
|
||||
cp .env.example .env
|
||||
pnpm exec triggerdev login -a http://localhost:3030
|
||||
```
|
||||
|
||||
7. Open a new terminal window, and then run the `dev` command like so:
|
||||
This will open a new browser window and authorize the CLI against your local user account.
|
||||
|
||||
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
|
||||
pnpm exec trigger-cli dev
|
||||
cd references/v3-catalog
|
||||
pnpm exec triggerdev login -a http://localhost:3030 --profile local
|
||||
# later when you run the dev or deploy command:
|
||||
pnpm exec triggerdev dev --profile local
|
||||
pnpm exec triggerdev deploy --profile local
|
||||
```
|
||||
|
||||
8. Please remember to delete the temporary project you created after you've tested the changes, and before you raise a PR.
|
||||
### Running
|
||||
|
||||
## Running end-to-end webapp tests
|
||||
The following steps should be followed any time you start working on a new feature you want to test in v3:
|
||||
|
||||
1. Make sure the webapp is running on localhost:3030
|
||||
|
||||
2. Open a terminal window and build the CLI and watch for changes
|
||||
|
||||
```sh
|
||||
pnpm run dev --filter trigger.dev
|
||||
```
|
||||
|
||||
2. Open a new terminal window, and anytime changes are made to the `@trigger.dev/core` package, you'll need to manually rebuild the CLI:
|
||||
|
||||
```sh
|
||||
pnpm run build --filter trigger.dev
|
||||
```
|
||||
|
||||
Note: You do not need to do the same for `@trigger.dev/sdk`, just core.
|
||||
|
||||
3. Open another terminal window, and change into the `<root>/references/v3-catalog` directory.
|
||||
|
||||
4. 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
|
||||
pnpm exec triggerdev dev
|
||||
```
|
||||
|
||||
If you want additional debug logging, you can use the `--log-level debug` flag:
|
||||
|
||||
```sh
|
||||
# in <root>/references/v3-catalog
|
||||
pnpm exec triggerdev dev --log-level debug
|
||||
```
|
||||
|
||||
5. 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. Navigate to the `v3-catalog` project in your local dashboard at localhost:3030 and you should see the list of tasks.
|
||||
|
||||
7. 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. 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:
|
||||
|
||||
@@ -223,6 +221,26 @@ The end-to-end tests use a `setup` and `teardown` script to seed the database wi
|
||||
pnpm run db:studio
|
||||
```
|
||||
|
||||
## Adding and running migrations
|
||||
|
||||
1. Modify packages/database/prisma/schema.prisma file
|
||||
2. Change directory to the packages/database folder
|
||||
|
||||
```sh
|
||||
cd packages/database
|
||||
```
|
||||
|
||||
3. Create and apply the migrations
|
||||
|
||||
```
|
||||
pnpm run db:migrate:dev
|
||||
```
|
||||
|
||||
This creates a migration file and executes the migrations against your database and applies changes to the database schema(s)
|
||||
|
||||
4. Commit generated migrations as well as changes to the schema.prisma file
|
||||
5. If you're using VSCode you may need to restart the Typescript server in the webapp to get updated type inference. Open a TypeScript file, then open the Command Palette (View > Command Palette) and run `TypeScript: Restart TS server`.
|
||||
|
||||
## Add sample jobs
|
||||
|
||||
The [references/job-catalog](./references/job-catalog/) project defines simple jobs you can get started with.
|
||||
|
||||
+296
-50
@@ -1,5 +1,6 @@
|
||||
import { createServer } from "node:http";
|
||||
import { $ } from "execa";
|
||||
import fs from "node:fs/promises";
|
||||
import { $, type ExecaChildProcess } from "execa";
|
||||
import { nanoid } from "nanoid";
|
||||
import { Server } from "socket.io";
|
||||
import {
|
||||
@@ -19,6 +20,11 @@ collectDefaultMetrics();
|
||||
const HTTP_SERVER_PORT = Number(process.env.HTTP_SERVER_PORT || 8020);
|
||||
const NODE_NAME = process.env.NODE_NAME || "coordinator";
|
||||
const DEFAULT_RETRY_DELAY_THRESHOLD_IN_MS = 30_000;
|
||||
const CHAOS_MONKEY_ENABLED = !!process.env.CHAOS_MONKEY_ENABLED;
|
||||
|
||||
const FORCE_CHECKPOINT_SIMULATION = ["1", "true"].includes(
|
||||
process.env.FORCE_CHECKPOINT_SIMULATION ?? "true"
|
||||
);
|
||||
|
||||
const REGISTRY_HOST = process.env.REGISTRY_HOST || "localhost:5000";
|
||||
const CHECKPOINT_PATH = process.env.CHECKPOINT_PATH || "/checkpoints";
|
||||
@@ -32,6 +38,10 @@ const SECURE_CONNECTION = ["1", "true"].includes(process.env.SECURE_CONNECTION ?
|
||||
|
||||
const logger = new SimpleLogger(`[${NODE_NAME}]`);
|
||||
|
||||
if (CHAOS_MONKEY_ENABLED) {
|
||||
logger.log("🍌 Chaos monkey enabled");
|
||||
}
|
||||
|
||||
type CheckpointerInitializeReturn = {
|
||||
canCheckpoint: boolean;
|
||||
willSimulate: boolean;
|
||||
@@ -49,6 +59,40 @@ type CheckpointData = {
|
||||
docker: boolean;
|
||||
};
|
||||
|
||||
function isExecaChildProcess(maybeExeca: unknown): maybeExeca is Awaited<ExecaChildProcess> {
|
||||
return typeof maybeExeca === "object" && maybeExeca !== null && "escapedCommand" in maybeExeca;
|
||||
}
|
||||
|
||||
async function getFileSize(filePath: string): Promise<number> {
|
||||
try {
|
||||
const stats = await fs.stat(filePath);
|
||||
return stats.size;
|
||||
} catch (error) {
|
||||
console.error("Error getting file size:", error);
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
async function getParsedFileSize(filePath: string) {
|
||||
const sizeInBytes = await getFileSize(filePath);
|
||||
|
||||
let message = `Size in bytes: ${sizeInBytes}`;
|
||||
|
||||
if (sizeInBytes > 1024 * 1024) {
|
||||
const sizeInMB = (sizeInBytes / 1024 / 1024).toFixed(2);
|
||||
message = `Size in MB (rounded): ${sizeInMB}`;
|
||||
} else if (sizeInBytes > 1024) {
|
||||
const sizeInKB = (sizeInBytes / 1024).toFixed(2);
|
||||
message = `Size in KB (rounded): ${sizeInKB}`;
|
||||
}
|
||||
|
||||
return {
|
||||
path: filePath,
|
||||
sizeInBytes,
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
class Checkpointer {
|
||||
#initialized = false;
|
||||
#canCheckpoint = false;
|
||||
@@ -56,6 +100,7 @@ class Checkpointer {
|
||||
|
||||
#logger = new SimpleLogger("[checkptr]");
|
||||
#abortControllers = new Map<string, AbortController>();
|
||||
#failedCheckpoints = new Map<string, unknown>();
|
||||
|
||||
constructor(private opts = { forceSimulate: false }) {}
|
||||
|
||||
@@ -150,7 +195,11 @@ class Checkpointer {
|
||||
success: !!result,
|
||||
});
|
||||
|
||||
return result;
|
||||
if (!result.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
return result.checkpoint;
|
||||
}
|
||||
|
||||
isCheckpointing(runId: string) {
|
||||
@@ -158,6 +207,13 @@ class Checkpointer {
|
||||
}
|
||||
|
||||
cancelCheckpoint(runId: string): boolean {
|
||||
// If the last checkpoint failed, pretend we canceled it
|
||||
// This ensures tasks don't wait for external resume messages to continue
|
||||
if (this.#hasFailedCheckpoint(runId)) {
|
||||
this.#clearFailedCheckpoint(runId);
|
||||
return true;
|
||||
}
|
||||
|
||||
const controller = this.#abortControllers.get(runId);
|
||||
|
||||
if (!controller) {
|
||||
@@ -176,44 +232,58 @@ class Checkpointer {
|
||||
leaveRunning = true, // This mirrors kubernetes behaviour more accurately
|
||||
projectRef,
|
||||
deploymentVersion,
|
||||
}: CheckpointAndPushOptions): Promise<CheckpointData | undefined> {
|
||||
}: CheckpointAndPushOptions): Promise<
|
||||
{ success: true; checkpoint: CheckpointData } | { success: false; reason?: "CANCELED" }
|
||||
> {
|
||||
await this.initialize();
|
||||
|
||||
const options = {
|
||||
runId,
|
||||
leaveRunning,
|
||||
projectRef,
|
||||
deploymentVersion,
|
||||
};
|
||||
|
||||
if (!this.#dockerMode && !this.#canCheckpoint) {
|
||||
this.#logger.error("No checkpoint support. Simulation requires docker.");
|
||||
return;
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
if (this.#abortControllers.has(runId)) {
|
||||
logger.error("Checkpoint procedure already in progress", {
|
||||
options: {
|
||||
runId,
|
||||
leaveRunning,
|
||||
projectRef,
|
||||
deploymentVersion,
|
||||
},
|
||||
});
|
||||
return;
|
||||
logger.error("Checkpoint procedure already in progress", { options });
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
// This is a new checkpoint, clear any last failure for this run
|
||||
this.#clearFailedCheckpoint(runId);
|
||||
|
||||
const controller = new AbortController();
|
||||
this.#abortControllers.set(runId, controller);
|
||||
|
||||
const $$ = $({ signal: controller.signal });
|
||||
|
||||
try {
|
||||
if (CHAOS_MONKEY_ENABLED) {
|
||||
console.log("🍌 Chaos monkey wreaking havoc");
|
||||
|
||||
const random = Math.random();
|
||||
|
||||
if (random < 0.33) {
|
||||
// Fake long checkpoint duration
|
||||
await $$`sleep 300`;
|
||||
} else if (random < 0.66) {
|
||||
// Fake checkpoint error
|
||||
await $$`false`;
|
||||
} else {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
|
||||
const shortCode = nanoid(8);
|
||||
const imageRef = this.#getImageRef(projectRef, deploymentVersion, shortCode);
|
||||
const exportLocation = this.#getExportLocation(projectRef, deploymentVersion, shortCode);
|
||||
|
||||
this.#logger.log("Checkpointing:", {
|
||||
options: {
|
||||
runId,
|
||||
leaveRunning,
|
||||
projectRef,
|
||||
deploymentVersion,
|
||||
},
|
||||
});
|
||||
this.#logger.log("Checkpointing:", { options });
|
||||
|
||||
const containterName = this.#getRunContainerName(runId);
|
||||
|
||||
@@ -234,9 +304,9 @@ class Checkpointer {
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
this.#logger.error(error.stderr);
|
||||
return;
|
||||
} catch (error) {
|
||||
this.#logger.error("Failed while creating docker checkpoint", { exportLocation });
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.#logger.log("checkpoint created:", {
|
||||
@@ -245,8 +315,11 @@ class Checkpointer {
|
||||
});
|
||||
|
||||
return {
|
||||
location: exportLocation,
|
||||
docker: true,
|
||||
success: true,
|
||||
checkpoint: {
|
||||
location: exportLocation,
|
||||
docker: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -266,54 +339,104 @@ class Checkpointer {
|
||||
throw new Error("could not find container id");
|
||||
}
|
||||
|
||||
const start = performance.now();
|
||||
|
||||
// Create checkpoint
|
||||
this.#logger.debug(await $$`crictl checkpoint --export=${exportLocation} ${containerId}`);
|
||||
const postCheckpoint = performance.now();
|
||||
|
||||
// Print checkpoint size
|
||||
const size = await getParsedFileSize(exportLocation);
|
||||
this.#logger.log("checkpoint archive created", { size, options });
|
||||
|
||||
// Create image from checkpoint
|
||||
const container = this.#logger.debug(await $$`buildah from scratch`);
|
||||
const postFrom = performance.now();
|
||||
|
||||
this.#logger.debug(await $$`buildah add ${container} ${exportLocation} /`);
|
||||
const postAdd = performance.now();
|
||||
|
||||
this.#logger.debug(
|
||||
await $$`buildah config --annotation=io.kubernetes.cri-o.annotations.checkpoint.name=counter ${container}`
|
||||
);
|
||||
const postConfig = performance.now();
|
||||
|
||||
this.#logger.debug(await $$`buildah commit ${container} ${imageRef}`);
|
||||
const postCommit = performance.now();
|
||||
|
||||
this.#logger.debug(await $$`buildah rm ${container}`);
|
||||
const postRm = performance.now();
|
||||
|
||||
// Push checkpoint image
|
||||
this.#logger.debug(await $$`buildah push --tls-verify=${REGISTRY_TLS_VERIFY} ${imageRef}`);
|
||||
const postPush = performance.now();
|
||||
|
||||
this.#logger.log("Checkpointed and pushed image to:", { location: imageRef });
|
||||
const perf = {
|
||||
"crictl checkpoint": postCheckpoint - start,
|
||||
"buildah from": postFrom - postCheckpoint,
|
||||
"buildah add": postAdd - postFrom,
|
||||
"buildah config": postConfig - postAdd,
|
||||
"buildah commit": postCommit - postConfig,
|
||||
"buildah rm": postRm - postCommit,
|
||||
"buildah push": postPush - postRm,
|
||||
};
|
||||
|
||||
this.#logger.log("Checkpointed and pushed image to:", { location: imageRef, perf });
|
||||
|
||||
try {
|
||||
await $$`rm ${exportLocation}`;
|
||||
this.#logger.log("Deleted checkpoint archive", { exportLocation });
|
||||
|
||||
// Disabled for now as this will increase restore time by having to pull the image again
|
||||
// await $`buildah rmi ${imageRef}`;
|
||||
// this.#logger.log("Deleted checkpoint image", { imageRef });
|
||||
await $`buildah rmi ${imageRef}`;
|
||||
this.#logger.log("Deleted checkpoint image", { imageRef });
|
||||
} catch (error) {
|
||||
this.#logger.error("Failed during checkpoint cleanup", { exportLocation });
|
||||
this.#logger.debug(error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
location: imageRef,
|
||||
docker: false,
|
||||
success: true,
|
||||
checkpoint: {
|
||||
location: imageRef,
|
||||
docker: false,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
this.#logger.error("checkpoint failed", {
|
||||
options: {
|
||||
runId,
|
||||
leaveRunning,
|
||||
projectRef,
|
||||
deploymentVersion,
|
||||
},
|
||||
error,
|
||||
});
|
||||
return;
|
||||
if (isExecaChildProcess(error)) {
|
||||
if (error.isCanceled) {
|
||||
this.#logger.error("Checkpoint canceled", { options, error });
|
||||
|
||||
return { success: false, reason: "CANCELED" };
|
||||
}
|
||||
|
||||
// Everything that's not a cancellation is a failure
|
||||
this.#failCheckpoint(runId, error);
|
||||
this.#logger.error("Checkpoint command error", { options, error });
|
||||
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
this.#failCheckpoint(runId, error);
|
||||
this.#logger.error("Unhandled checkpoint error", { options, error });
|
||||
|
||||
return { success: false };
|
||||
} finally {
|
||||
this.#abortControllers.delete(runId);
|
||||
}
|
||||
}
|
||||
|
||||
#failCheckpoint(runId: string, error: unknown) {
|
||||
this.#failedCheckpoints.set(runId, error);
|
||||
}
|
||||
|
||||
#clearFailedCheckpoint(runId: string) {
|
||||
this.#failedCheckpoints.delete(runId);
|
||||
}
|
||||
|
||||
#hasFailedCheckpoint(runId: string) {
|
||||
return this.#failedCheckpoints.has(runId);
|
||||
}
|
||||
|
||||
#getRunContainerName(suffix: string) {
|
||||
return `task-run-${suffix}`;
|
||||
}
|
||||
@@ -321,7 +444,7 @@ class Checkpointer {
|
||||
|
||||
class TaskCoordinator {
|
||||
#httpServer: ReturnType<typeof createServer>;
|
||||
#checkpointer = new Checkpointer({ forceSimulate: true });
|
||||
#checkpointer = new Checkpointer({ forceSimulate: FORCE_CHECKPOINT_SIMULATION });
|
||||
|
||||
#prodWorkerNamespace: ZodNamespace<
|
||||
typeof ProdWorkerToCoordinatorMessages,
|
||||
@@ -442,6 +565,28 @@ class TaskCoordinator {
|
||||
|
||||
taskSocket.emit("REQUEST_ATTEMPT_CANCELLATION", message);
|
||||
},
|
||||
REQUEST_RUN_CANCELLATION: async (message) => {
|
||||
const taskSocket = await this.#getRunSocket(message.runId);
|
||||
|
||||
if (!taskSocket) {
|
||||
logger.log("Socket for run not found", {
|
||||
runId: message.runId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.delayInMs) {
|
||||
taskSocket.emit("REQUEST_EXIT", {
|
||||
version: "v2",
|
||||
delayInMs: message.delayInMs,
|
||||
});
|
||||
} else {
|
||||
// If there's no delay, assume the worker doesn't support non-v1 messages
|
||||
taskSocket.emit("REQUEST_EXIT", {
|
||||
version: "v1",
|
||||
});
|
||||
}
|
||||
},
|
||||
READY_FOR_RETRY: async (message) => {
|
||||
const taskSocket = await this.#getRunSocket(message.runId);
|
||||
|
||||
@@ -528,6 +673,20 @@ class TaskCoordinator {
|
||||
onConnection: async (socket, handler, sender) => {
|
||||
const logger = new SimpleLogger(`[prod-worker][${socket.id}]`);
|
||||
|
||||
const crashRun = async (error: { name: string; message: string; stack?: string }) => {
|
||||
try {
|
||||
this.#platformSocket?.send("RUN_CRASHED", {
|
||||
version: "v1",
|
||||
runId: socket.data.runId,
|
||||
error,
|
||||
});
|
||||
} finally {
|
||||
socket.emit("REQUEST_EXIT", {
|
||||
version: "v1",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const checkpointInProgress = () => {
|
||||
return this.#checkpointableTasks.has(socket.data.runId);
|
||||
};
|
||||
@@ -596,8 +755,9 @@ class TaskCoordinator {
|
||||
if (!executionAck) {
|
||||
logger.error("no execution ack", { runId: socket.data.runId });
|
||||
|
||||
socket.emit("REQUEST_EXIT", {
|
||||
version: "v1",
|
||||
await crashRun({
|
||||
name: "ReadyForExecutionError",
|
||||
message: "No execution ack",
|
||||
});
|
||||
|
||||
return;
|
||||
@@ -606,8 +766,9 @@ class TaskCoordinator {
|
||||
if (!executionAck.success) {
|
||||
logger.error("failed to get execution payload", { runId: socket.data.runId });
|
||||
|
||||
socket.emit("REQUEST_EXIT", {
|
||||
version: "v1",
|
||||
await crashRun({
|
||||
name: "ReadyForExecutionError",
|
||||
message: "Failed to get execution payload",
|
||||
});
|
||||
|
||||
return;
|
||||
@@ -624,6 +785,46 @@ class TaskCoordinator {
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("READY_FOR_LAZY_ATTEMPT", async (message) => {
|
||||
logger.log("[READY_FOR_LAZY_ATTEMPT]", message);
|
||||
|
||||
try {
|
||||
const lazyAttempt = await this.#platformSocket?.sendWithAck("READY_FOR_LAZY_ATTEMPT", {
|
||||
...message,
|
||||
envId: socket.data.envId,
|
||||
});
|
||||
|
||||
if (!lazyAttempt) {
|
||||
logger.error("no lazy attempt ack", { runId: socket.data.runId });
|
||||
|
||||
await crashRun({
|
||||
name: "ReadyForLazyAttemptError",
|
||||
message: "No lazy attempt ack",
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!lazyAttempt.success) {
|
||||
logger.error("failed to get lazy attempt payload", { runId: socket.data.runId });
|
||||
|
||||
await crashRun({
|
||||
name: "ReadyForLazyAttemptError",
|
||||
message: "Failed to get lazy attempt payload",
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
socket.emit("EXECUTE_TASK_RUN_LAZY_ATTEMPT", {
|
||||
version: "v1",
|
||||
lazyPayload: lazyAttempt.lazyPayload,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Error", { error });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("READY_FOR_RESUME", async (message) => {
|
||||
logger.log("[READY_FOR_RESUME]", message);
|
||||
|
||||
@@ -714,6 +915,19 @@ class TaskCoordinator {
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("TASK_RUN_FAILED_TO_RUN", async ({ completion }) => {
|
||||
logger.log("completed task", { completionId: completion.id });
|
||||
|
||||
this.#platformSocket?.send("TASK_RUN_FAILED_TO_RUN", {
|
||||
version: "v1",
|
||||
completion,
|
||||
});
|
||||
|
||||
socket.emit("REQUEST_EXIT", {
|
||||
version: "v1",
|
||||
});
|
||||
});
|
||||
|
||||
socket.on("READY_FOR_CHECKPOINT", async (message) => {
|
||||
logger.log("[READY_FOR_CHECKPOINT]", message);
|
||||
|
||||
@@ -890,7 +1104,7 @@ class TaskCoordinator {
|
||||
logger.log("[INDEX_TASKS]", message);
|
||||
|
||||
const workerAck = await this.#platformSocket?.sendWithAck("CREATE_WORKER", {
|
||||
version: "v1",
|
||||
version: "v2",
|
||||
projectRef: socket.data.projectRef,
|
||||
envId: socket.data.envId,
|
||||
deploymentId: message.deploymentId,
|
||||
@@ -899,6 +1113,7 @@ class TaskCoordinator {
|
||||
packageVersion: message.packageVersion,
|
||||
tasks: message.tasks,
|
||||
},
|
||||
supportsLazyAttempts: message.version !== "v1" && message.supportsLazyAttempts,
|
||||
});
|
||||
|
||||
if (!workerAck) {
|
||||
@@ -917,6 +1132,34 @@ class TaskCoordinator {
|
||||
error: message.error,
|
||||
});
|
||||
});
|
||||
|
||||
socket.on("CREATE_TASK_RUN_ATTEMPT", async (message, callback) => {
|
||||
logger.log("[CREATE_TASK_RUN_ATTEMPT]", message);
|
||||
|
||||
const createAttempt = await this.#platformSocket?.sendWithAck("CREATE_TASK_RUN_ATTEMPT", {
|
||||
runId: message.runId,
|
||||
envId: socket.data.envId,
|
||||
});
|
||||
|
||||
if (!createAttempt?.success) {
|
||||
logger.debug("no ack while creating attempt", message);
|
||||
callback({ success: false });
|
||||
return;
|
||||
}
|
||||
|
||||
socket.data.attemptFriendlyId = createAttempt.executionPayload.execution.attempt.id;
|
||||
|
||||
callback({
|
||||
success: true,
|
||||
executionPayload: createAttempt.executionPayload,
|
||||
});
|
||||
});
|
||||
|
||||
socket.on("UNRECOVERABLE_ERROR", async (message) => {
|
||||
logger.log("[UNRECOVERABLE_ERROR]", message);
|
||||
|
||||
await crashRun(message.error);
|
||||
});
|
||||
},
|
||||
onDisconnect: async (socket, handler, sender, logger) => {
|
||||
this.#platformSocket?.send("LOG", {
|
||||
@@ -928,13 +1171,16 @@ class TaskCoordinator {
|
||||
TASK_HEARTBEAT: async (message) => {
|
||||
this.#platformSocket?.send("TASK_HEARTBEAT", message);
|
||||
},
|
||||
TASK_RUN_HEARTBEAT: async (message) => {
|
||||
this.#platformSocket?.send("TASK_RUN_HEARTBEAT", message);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return provider;
|
||||
}
|
||||
|
||||
#cancelCheckpoint(runId: string) {
|
||||
#cancelCheckpoint(runId: string): boolean {
|
||||
const checkpointWait = this.#checkpointableTasks.get(runId);
|
||||
|
||||
if (checkpointWait) {
|
||||
|
||||
@@ -4,6 +4,8 @@ PLATFORM_WS_PORT=3030
|
||||
PLATFORM_SECRET=provider-secret
|
||||
SECURE_CONNECTION=false
|
||||
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=http://0.0.0.0:3030/otel
|
||||
|
||||
# Use this if you are on macOS
|
||||
# COORDINATOR_HOST="host.docker.internal"
|
||||
# OTEL_EXPORTER_OTLP_ENDPOINT="http://host.docker.internal:4318"
|
||||
@@ -13,9 +13,14 @@ import { PostStartCauses, PreStopCauses } from "@trigger.dev/core/v3";
|
||||
const MACHINE_NAME = process.env.MACHINE_NAME || "local";
|
||||
const COORDINATOR_PORT = process.env.COORDINATOR_PORT || 8020;
|
||||
const COORDINATOR_HOST = process.env.COORDINATOR_HOST || "127.0.0.1";
|
||||
|
||||
const OTEL_EXPORTER_OTLP_ENDPOINT =
|
||||
process.env.OTEL_EXPORTER_OTLP_ENDPOINT || "http://0.0.0.0:4318";
|
||||
|
||||
const FORCE_CHECKPOINT_SIMULATION = ["1", "true"].includes(
|
||||
process.env.FORCE_CHECKPOINT_SIMULATION ?? "true"
|
||||
);
|
||||
|
||||
const logger = new SimpleLogger(`[${MACHINE_NAME}]`);
|
||||
|
||||
type InitializeReturn = {
|
||||
@@ -278,7 +283,7 @@ class DockerTaskOperations implements TaskOperations {
|
||||
}
|
||||
|
||||
const provider = new ProviderShell({
|
||||
tasks: new DockerTaskOperations({ forceSimulate: true }),
|
||||
tasks: new DockerTaskOperations({ forceSimulate: FORCE_CHECKPOINT_SIMULATION }),
|
||||
type: "docker",
|
||||
});
|
||||
|
||||
|
||||
@@ -212,7 +212,7 @@ class KubernetesTaskOperations implements TaskOperations {
|
||||
},
|
||||
{
|
||||
name: "populate-taskinfo",
|
||||
image: "docker.io/library/busybox",
|
||||
image: "registry.digitalocean.com/trigger/busybox",
|
||||
imagePullPolicy: "IfNotPresent",
|
||||
command: ["/bin/sh", "-c"],
|
||||
args: ["printenv COORDINATOR_HOST | tee /etc/taskinfo/coordinator-host"],
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
"dev": "wrangler dev"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20230419.0",
|
||||
"@cloudflare/workers-types": "^4.20240512.0",
|
||||
"typescript": "^5.0.4",
|
||||
"wrangler": "^3.0.0"
|
||||
"wrangler": "^3.57.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sqs": "^3.445.0",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { queueEvent } from "./events/queueEvent";
|
||||
import { queueEvents } from "./events/queueEvents";
|
||||
import { applyRateLimit } from "./rateLimit";
|
||||
import { Ratelimit } from "./rateLimiter";
|
||||
|
||||
export interface Env {
|
||||
/** The hostname needs to be changed to allow requests to pass to the Trigger.dev platform */
|
||||
@@ -9,6 +11,8 @@ export interface Env {
|
||||
AWS_SQS_SECRET_ACCESS_KEY: string;
|
||||
AWS_SQS_QUEUE_URL: string;
|
||||
AWS_SQS_REGION: string;
|
||||
//rate limiter
|
||||
API_RATE_LIMITER: Ratelimit;
|
||||
}
|
||||
|
||||
export default {
|
||||
@@ -25,13 +29,13 @@ export default {
|
||||
switch (url.pathname) {
|
||||
case "/api/v1/events": {
|
||||
if (request.method === "POST") {
|
||||
return queueEvent(request, env);
|
||||
return applyRateLimit(request, env, () => queueEvent(request, env));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "/api/v1/events/bulk": {
|
||||
if (request.method === "POST") {
|
||||
return queueEvents(request, env);
|
||||
return applyRateLimit(request, env, () => queueEvents(request, env));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Env } from "src";
|
||||
import { getApiKeyFromRequest } from "./apikey";
|
||||
import { json } from "./json";
|
||||
|
||||
export async function applyRateLimit(
|
||||
request: Request,
|
||||
env: Env,
|
||||
fn: () => Promise<Response>
|
||||
): Promise<Response> {
|
||||
const apiKey = getApiKeyFromRequest(request);
|
||||
if (apiKey) {
|
||||
const result = await env.API_RATE_LIMITER.limit({ key: `apikey-${apiKey.apiKey}` });
|
||||
const { success } = result;
|
||||
console.log(`Rate limiter`, {
|
||||
success,
|
||||
key: `${apiKey.apiKey.substring(0, 12)}...`,
|
||||
});
|
||||
if (!success) {
|
||||
//60s in the future
|
||||
const reset = Date.now() + 60 * 1000;
|
||||
const secondsUntilReset = Math.max(0, (reset - new Date().getTime()) / 1000);
|
||||
|
||||
return json(
|
||||
{
|
||||
title: "Rate Limit Exceeded",
|
||||
status: 429,
|
||||
type: "https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429",
|
||||
detail: `Rate limit exceeded. Retry in ${secondsUntilReset} seconds.`,
|
||||
error: `Rate limit exceeded. Retry in ${secondsUntilReset} seconds.`,
|
||||
reset,
|
||||
},
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
"x-ratelimit-reset": reset.toString(),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.log(`Rate limiter: no API key for request`);
|
||||
}
|
||||
|
||||
//call the original function
|
||||
return fn();
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export interface Ratelimit {
|
||||
/*
|
||||
* The ratelimit function
|
||||
* @param {RatelimitOptions} options
|
||||
* @returns {Promise<RatelimitResponse>}
|
||||
*/
|
||||
limit: (options: RatelimitOptions) => Promise<RatelimitResponse>;
|
||||
}
|
||||
|
||||
export interface RatelimitOptions {
|
||||
/*
|
||||
* The key to identify the user, can be an IP address, user ID, etc.
|
||||
*/
|
||||
key: string;
|
||||
}
|
||||
|
||||
export interface RatelimitResponse {
|
||||
/*
|
||||
* The ratelimit success status
|
||||
* @returns {boolean}
|
||||
*/
|
||||
success: boolean;
|
||||
}
|
||||
@@ -1,7 +1,33 @@
|
||||
name = "proxy"
|
||||
main = "src/index.ts"
|
||||
compatibility_date = "2023-10-30"
|
||||
compatibility_date = "2024-05-13"
|
||||
compatibility_flags = [ "nodejs_compat" ]
|
||||
|
||||
[env.staging]
|
||||
[env.prod]
|
||||
# The rate limiting API is in open beta.
|
||||
[[env.staging.unsafe.bindings]]
|
||||
name = "API_RATE_LIMITER"
|
||||
type = "ratelimit"
|
||||
# An identifier you define, that is unique to your Cloudflare account.
|
||||
# Must be an integer.
|
||||
namespace_id = "1"
|
||||
|
||||
# Limit: the number of tokens allowed within a given period in a single
|
||||
# Cloudflare location
|
||||
# Period: the duration of the period, in seconds. Must be either 10 or 60
|
||||
simple = { limit = 100, period = 60 }
|
||||
|
||||
|
||||
[env.prod]
|
||||
# The rate limiting API is in open beta.
|
||||
[[env.prod.unsafe.bindings]]
|
||||
name = "API_RATE_LIMITER"
|
||||
type = "ratelimit"
|
||||
# An identifier you define, that is unique to your Cloudflare account.
|
||||
# Must be an integer.
|
||||
namespace_id = "2"
|
||||
|
||||
# Limit: the number of tokens allowed within a given period in a single
|
||||
# Cloudflare location
|
||||
# Period: the duration of the period, in seconds. Must be either 10 or 60
|
||||
simple = { limit = 300, period = 60 }
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="0.861435" y="0.861435" width="30.2771" height="30.2771" rx="15.1386" stroke="#D7D9DD" stroke-width="1.72287"/>
|
||||
<path d="M14.9669 12.642L11.6417 21.7732H9.8155L6.49036 12.642H8.04094L10.7286 20.2571L13.4163 12.642H14.9669ZM20.534 14.5544C21.6884 14.5716 23.0666 14.0375 23.0666 12.4525C23.0666 11.3843 22.1018 10.7124 20.534 10.7124C19.2591 10.7124 18.3632 11.3326 18.2082 12.3146L16.6576 12.2113C16.8815 10.4884 18.4838 9.26516 20.534 9.26516C22.946 9.26516 24.6345 10.5745 24.6345 12.4525C24.6345 13.8135 23.8592 14.7267 22.3775 15.1574C24.0831 15.657 25.0479 16.8286 25.0479 18.4136C25.0479 20.55 23.1872 22.0489 20.534 22.0489C18.0876 22.0489 16.313 20.6361 16.2096 18.6203L17.743 18.517C17.8808 20.0159 19.3108 20.6016 20.534 20.6016C21.9812 20.6016 23.4974 19.947 23.4974 18.293C23.4974 16.6563 21.9812 15.9499 20.534 15.9844L19.5865 16.0016V14.5371L20.534 14.5544Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 999 B |
@@ -115,14 +115,7 @@ export function Feedback({ button, defaultValue = "bug" }: FeedbackProps) {
|
||||
Docs
|
||||
</LinkButton>
|
||||
<LinkButton
|
||||
to={docsPath("v3/introduction")}
|
||||
variant="tertiary/medium"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
>
|
||||
v3 Docs (Developer preview)
|
||||
</LinkButton>
|
||||
<LinkButton
|
||||
to={"https://trigger.openstatus.dev/"}
|
||||
to={"https://status.trigger.dev/"}
|
||||
variant="tertiary/medium"
|
||||
LeadingIcon={ActivityIcon}
|
||||
>
|
||||
|
||||
@@ -20,9 +20,11 @@ export function AdminDebugTooltip({ children }: { children: React.ReactNode }) {
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<ShieldCheckIcon className="h-5 w-5" />
|
||||
<ShieldCheckIcon className="size-5" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="flex items-center gap-1">{children}</TooltipContent>
|
||||
<TooltipContent className="flex max-h-[90vh] items-center gap-1 overflow-y-auto">
|
||||
{children}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
import { Clipboard, ClipboardCheck } from "lucide-react";
|
||||
import type { Language, PrismTheme } from "prism-react-renderer";
|
||||
import Highlight, { defaultProps } from "prism-react-renderer";
|
||||
import { Highlight, Prism } from "prism-react-renderer";
|
||||
import { forwardRef, useCallback, useState } from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../primitives/Tooltip";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../primitives/Tooltip";
|
||||
|
||||
//This is a fork of https://github.com/mantinedev/mantine/blob/master/src/mantine-prism/src/Prism/Prism.tsx
|
||||
//it didn't support highlighting lines by dimming the rest of the code, or animations on the highlighting
|
||||
|
||||
async function setup() {
|
||||
(typeof global !== "undefined" ? global : window).Prism = Prism;
|
||||
//@ts-ignore
|
||||
await import("prismjs/components/prism-json");
|
||||
//@ts-ignore
|
||||
await import("prismjs/components/prism-typescript");
|
||||
}
|
||||
setup();
|
||||
|
||||
type CodeBlockProps = {
|
||||
/** Code which will be highlighted */
|
||||
code: string;
|
||||
@@ -238,7 +247,7 @@ export const CodeBlock = forwardRef<HTMLDivElement, CodeBlockProps>(
|
||||
)}
|
||||
|
||||
{shouldHighlight ? (
|
||||
<Highlight {...defaultProps} theme={theme} code={code} language={language}>
|
||||
<Highlight theme={theme} code={code} language={language}>
|
||||
{({
|
||||
className: inheritedClassName,
|
||||
style: inheritedStyle,
|
||||
@@ -283,7 +292,7 @@ export const CodeBlock = forwardRef<HTMLDivElement, CodeBlockProps>(
|
||||
|
||||
return (
|
||||
<div
|
||||
key={lineProps.key}
|
||||
key={lineNumber}
|
||||
{...lineProps}
|
||||
className={cn(
|
||||
"flex w-full justify-start transition-opacity duration-500",
|
||||
@@ -312,7 +321,7 @@ export const CodeBlock = forwardRef<HTMLDivElement, CodeBlockProps>(
|
||||
const tokenProps = getTokenProps({ token, key });
|
||||
return (
|
||||
<span
|
||||
key={tokenProps.key}
|
||||
key={key}
|
||||
{...tokenProps}
|
||||
style={{
|
||||
color: tokenProps?.style?.color as string,
|
||||
|
||||
@@ -9,6 +9,30 @@ const variants = {
|
||||
large: "h-6 text-xs px-1.5 rounded",
|
||||
};
|
||||
|
||||
export function EnvironmentTypeLabel({
|
||||
environment,
|
||||
size = "small",
|
||||
className,
|
||||
}: {
|
||||
environment: Environment;
|
||||
size?: keyof typeof variants;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"text-midnight-900 inline-flex items-center justify-center whitespace-nowrap border font-medium uppercase tracking-wider",
|
||||
environmentBorderClassName(environment),
|
||||
environmentTextClassName(environment),
|
||||
variants[size],
|
||||
className
|
||||
)}
|
||||
>
|
||||
{environmentTypeTitle(environment)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function EnvironmentLabel({
|
||||
environment,
|
||||
size = "small",
|
||||
@@ -116,6 +140,19 @@ export function environmentTitle(environment: Environment, username?: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export function environmentTypeTitle(environment: Environment) {
|
||||
switch (environment.type) {
|
||||
case "PRODUCTION":
|
||||
return "Prod";
|
||||
case "STAGING":
|
||||
return "Staging";
|
||||
case "DEVELOPMENT":
|
||||
return "Dev";
|
||||
case "PREVIEW":
|
||||
return "Preview";
|
||||
}
|
||||
}
|
||||
|
||||
export function environmentColorClassName(environment: Environment) {
|
||||
switch (environment.type) {
|
||||
case "PRODUCTION":
|
||||
|
||||
@@ -12,7 +12,7 @@ import { ApiAuthenticationMethodOAuth2, Integration, Scope } from "~/services/ex
|
||||
import { cn } from "~/utils/cn";
|
||||
import { CodeBlock } from "../code/CodeBlock";
|
||||
import { Button } from "../primitives/Buttons";
|
||||
import { Checkbox } from "../primitives/Checkbox";
|
||||
import { CheckboxWithLabel } from "../primitives/Checkbox";
|
||||
import { Fieldset } from "../primitives/Fieldset";
|
||||
import { FormError } from "../primitives/FormError";
|
||||
import { Header2, Header3 } from "../primitives/Headers";
|
||||
@@ -123,7 +123,7 @@ export function ConnectToOAuthForm({
|
||||
<Paragraph variant="small" className="mb-2">
|
||||
To use your own OAuth app, check the option below and insert the details.
|
||||
</Paragraph>
|
||||
<Checkbox
|
||||
<CheckboxWithLabel
|
||||
id="hasCustomClient"
|
||||
label="Use my OAuth App"
|
||||
variant="simple/small"
|
||||
@@ -200,7 +200,7 @@ export function ConnectToOAuthForm({
|
||||
)}
|
||||
{authMethod.scopes.map((s) => {
|
||||
return (
|
||||
<Checkbox
|
||||
<CheckboxWithLabel
|
||||
key={s.name}
|
||||
id={s.name}
|
||||
value={s.name}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { ApiAuthenticationMethodOAuth2, Integration, Scope } from "~/services/ex
|
||||
import { cn } from "~/utils/cn";
|
||||
import { CodeBlock } from "../code/CodeBlock";
|
||||
import { Button } from "../primitives/Buttons";
|
||||
import { Checkbox } from "../primitives/Checkbox";
|
||||
import { CheckboxWithLabel } from "../primitives/Checkbox";
|
||||
import { Fieldset } from "../primitives/Fieldset";
|
||||
import { FormError } from "../primitives/FormError";
|
||||
import { Header2, Header3 } from "../primitives/Headers";
|
||||
@@ -113,7 +113,7 @@ export function UpdateOAuthForm({
|
||||
<Paragraph variant="small" className="mb-2">
|
||||
To use your own OAuth app, check the option below and insert the details.
|
||||
</Paragraph>
|
||||
<Checkbox
|
||||
<CheckboxWithLabel
|
||||
id="hasCustomClient"
|
||||
label="Use my OAuth App"
|
||||
variant="simple/small"
|
||||
@@ -189,7 +189,7 @@ export function UpdateOAuthForm({
|
||||
)}
|
||||
{authMethod.scopes.map((s) => {
|
||||
return (
|
||||
<Checkbox
|
||||
<CheckboxWithLabel
|
||||
key={s.name}
|
||||
id={s.name}
|
||||
value={s.name}
|
||||
|
||||
@@ -233,31 +233,22 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
|
||||
</div>
|
||||
<hr className="border-charcoal-800" />
|
||||
<div>
|
||||
<StepNumber stepNumber="1" title="Create a new Slack channel" />
|
||||
<StepNumber stepNumber="1" title="Email us" />
|
||||
<StepContentContainer>
|
||||
<Paragraph>
|
||||
In your Slack app, create a new channel from the main menu by going to File{" "}
|
||||
<ArrowRightIcon className="inline h-4 w-4 text-text-dimmed" /> New Channel
|
||||
</Paragraph>
|
||||
</StepContentContainer>
|
||||
<StepNumber stepNumber="2" title="Setup your channel" />
|
||||
<StepContentContainer>
|
||||
<Paragraph>
|
||||
Name your channel, set its visibility and click 'Create'.
|
||||
</Paragraph>
|
||||
</StepContentContainer>
|
||||
<StepNumber stepNumber="3" title="Invite Trigger.dev" />
|
||||
<StepContentContainer>
|
||||
<Paragraph>
|
||||
Invite this email address to your channel:{" "}
|
||||
Send us an email to this address from your Trigger.dev account email
|
||||
address:
|
||||
<ClipboardField
|
||||
variant="primary/medium"
|
||||
value="james@trigger.dev"
|
||||
value="priority-support@trigger.dev"
|
||||
className="my-2"
|
||||
/>
|
||||
</Paragraph>
|
||||
</StepContentContainer>
|
||||
<StepNumber stepNumber="2" title="Look out for an invite from Slack" />
|
||||
<StepContentContainer>
|
||||
<Paragraph>
|
||||
As soon as we can, we'll accept your invitation and say hello!
|
||||
As soon as we can, we'll setup a Slack Connect channel and say hello!
|
||||
</Paragraph>
|
||||
</StepContentContainer>
|
||||
</div>
|
||||
@@ -551,6 +542,8 @@ function V3ProjectSideMenu({
|
||||
project: SideMenuProject;
|
||||
organization: MatchedOrganization;
|
||||
}) {
|
||||
const { alertsEnabled } = useFeatures();
|
||||
|
||||
return (
|
||||
<>
|
||||
<SideMenuHeader title={"Project (v3)"} />
|
||||
@@ -603,13 +596,15 @@ function V3ProjectSideMenu({
|
||||
to={v3DeploymentsPath(organization, project)}
|
||||
data-action="deployments"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Alerts"
|
||||
icon={BellAlertIcon}
|
||||
iconColor="text-red-500"
|
||||
to={v3ProjectAlertsPath(organization, project)}
|
||||
data-action="alerts"
|
||||
/>
|
||||
{alertsEnabled && (
|
||||
<SideMenuItem
|
||||
name="Alerts"
|
||||
icon={BellAlertIcon}
|
||||
iconColor="text-red-500"
|
||||
to={v3ProjectAlertsPath(organization, project)}
|
||||
data-action="alerts"
|
||||
/>
|
||||
)}
|
||||
<SideMenuItem
|
||||
name="Project settings"
|
||||
icon="settings"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { forwardRef, useEffect, useState } from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Badge } from "./Badge";
|
||||
import { Paragraph } from "./Paragraph";
|
||||
@@ -53,18 +53,18 @@ export type CheckboxProps = Omit<
|
||||
React.InputHTMLAttributes<HTMLInputElement>,
|
||||
"checked" | "onChange"
|
||||
> & {
|
||||
id: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
value?: string;
|
||||
variant?: keyof typeof variants;
|
||||
label?: React.ReactNode;
|
||||
label: React.ReactNode;
|
||||
description?: string;
|
||||
badges?: string[];
|
||||
className?: string;
|
||||
onChange?: (isChecked: boolean) => void;
|
||||
};
|
||||
|
||||
export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
|
||||
export const CheckboxWithLabel = React.forwardRef<HTMLInputElement, CheckboxProps>(
|
||||
(
|
||||
{
|
||||
id,
|
||||
@@ -172,3 +172,21 @@ export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
type SimpleCheckboxProps = Omit<React.ComponentProps<"input">, "type">;
|
||||
|
||||
export const Checkbox = forwardRef<HTMLInputElement, SimpleCheckboxProps>(
|
||||
({ className, ...props }: SimpleCheckboxProps, ref) => {
|
||||
return (
|
||||
<input
|
||||
type="checkbox"
|
||||
className={cn(
|
||||
props.readOnly || props.disabled ? "cursor-default" : "cursor-pointer",
|
||||
"read-only:border-charcoal-650 disabled:border-charcoal-650 rounded-sm border border-charcoal-600 bg-transparent transition checked:!bg-indigo-500 read-only:!bg-charcoal-700 group-hover:bg-charcoal-900 group-hover:checked:bg-indigo-500 group-focus:ring-1 focus:ring-indigo-500 focus:ring-offset-0 focus:ring-offset-transparent focus-visible:outline-none focus-visible:ring-indigo-500 disabled:!bg-charcoal-700"
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -43,7 +43,7 @@ const DialogContent = React.forwardRef<
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed z-50 grid w-full gap-4 rounded-b-lg border bg-background-dimmed px-4 pb-4 pt-3.5 shadow-lg animate-in data-[state=open]:fade-in-90 data-[state=open]:slide-in-from-bottom-10 sm:max-w-lg sm:rounded-lg sm:zoom-in-90 data-[state=open]:sm:slide-in-from-bottom-0",
|
||||
"fixed z-50 grid w-full gap-4 rounded-b-lg border bg-background-dimmed px-4 pb-4 pt-2.5 shadow-lg animate-in data-[state=open]:fade-in-90 data-[state=open]:slide-in-from-bottom-10 sm:max-w-lg sm:rounded-lg sm:zoom-in-90 data-[state=open]:sm:slide-in-from-bottom-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -74,7 +74,7 @@ DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("flex flex-col text-left font-medium text-text-dimmed", className)}
|
||||
className={cn("flex flex-col text-left font-medium text-text-bright", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useCallback, useContext, useReducer } from "react";
|
||||
|
||||
type SelectedItemsContext = {
|
||||
selectedItems: Set<string>;
|
||||
select: (items: string | string[]) => void;
|
||||
deselect: (items: string | string[]) => void;
|
||||
toggle: (items: string | string[]) => void;
|
||||
deselectAll: () => void;
|
||||
has: (item: string) => boolean;
|
||||
hasAll: (items: string[]) => boolean;
|
||||
};
|
||||
|
||||
const SelectedItemsContext = createContext<SelectedItemsContext>({} as SelectedItemsContext);
|
||||
|
||||
export function useSelectedItems(enabled = true) {
|
||||
const context = useContext(SelectedItemsContext);
|
||||
if (!context && enabled) {
|
||||
throw new Error("useSelectedItems must be used within a SelectedItemsProvider");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
export function SelectedItemsProvider({
|
||||
initialSelectedItems,
|
||||
maxSelectedItemCount,
|
||||
children,
|
||||
}: {
|
||||
initialSelectedItems: string[];
|
||||
maxSelectedItemCount?: number;
|
||||
children: React.ReactNode | ((context: SelectedItemsContext) => React.ReactNode);
|
||||
}) {
|
||||
const [state, dispatch] = useReducer(selectedItemsReducer, {
|
||||
items: new Set<string>(initialSelectedItems),
|
||||
maxSelectedItemCount,
|
||||
});
|
||||
|
||||
const select = useCallback((items: string | string[]) => {
|
||||
dispatch({ type: "select", items: Array.isArray(items) ? items : [items] });
|
||||
}, []);
|
||||
|
||||
const deselect = useCallback((items: string | string[]) => {
|
||||
dispatch({ type: "deselect", items: Array.isArray(items) ? items : [items] });
|
||||
}, []);
|
||||
|
||||
const toggle = useCallback((items: string | string[]) => {
|
||||
dispatch({ type: "toggle", items: Array.isArray(items) ? items : [items] });
|
||||
}, []);
|
||||
|
||||
const deselectAll = useCallback(() => {
|
||||
dispatch({ type: "deselectAll" });
|
||||
}, []);
|
||||
|
||||
const has = useCallback((item: string) => state.items.has(item), [state]);
|
||||
|
||||
const hasAll = useCallback(
|
||||
(items: string[]) => items.every((item) => state.items.has(item)),
|
||||
[state]
|
||||
);
|
||||
|
||||
return (
|
||||
<SelectedItemsContext.Provider
|
||||
value={{ selectedItems: state.items, select, deselect, toggle, deselectAll, has, hasAll }}
|
||||
>
|
||||
{typeof children === "function"
|
||||
? children({
|
||||
selectedItems: state.items,
|
||||
select,
|
||||
deselect,
|
||||
toggle,
|
||||
deselectAll,
|
||||
has,
|
||||
hasAll,
|
||||
})
|
||||
: children}
|
||||
</SelectedItemsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
type SelectItemsAction = {
|
||||
type: "select";
|
||||
items: string[];
|
||||
};
|
||||
|
||||
type DeSelectItemsAction = {
|
||||
type: "deselect";
|
||||
items: string[];
|
||||
};
|
||||
|
||||
type DeselectAllItemsAction = {
|
||||
type: "deselectAll";
|
||||
};
|
||||
|
||||
type ToggleItemsAction = {
|
||||
type: "toggle";
|
||||
items: string[];
|
||||
};
|
||||
|
||||
type Action = SelectItemsAction | DeSelectItemsAction | ToggleItemsAction | DeselectAllItemsAction;
|
||||
|
||||
function selectedItemsReducer(
|
||||
state: { items: Set<string>; maxSelectedItemCount?: number },
|
||||
action: Action
|
||||
) {
|
||||
switch (action.type) {
|
||||
case "select":
|
||||
const items = new Set([...state.items, ...action.items]);
|
||||
return { ...state, items: cappedSet(items, state.maxSelectedItemCount) };
|
||||
case "deselect":
|
||||
const newItems = new Set(state.items);
|
||||
action.items.forEach((item) => {
|
||||
newItems.delete(item);
|
||||
});
|
||||
return { ...state, items: cappedSet(newItems, state.maxSelectedItemCount) };
|
||||
case "toggle":
|
||||
let newSet = new Set(state.items);
|
||||
action.items.forEach((item) => {
|
||||
if (newSet.has(item)) {
|
||||
newSet.delete(item);
|
||||
} else {
|
||||
newSet.add(item);
|
||||
}
|
||||
});
|
||||
return { ...state, items: cappedSet(newSet, state.maxSelectedItemCount) };
|
||||
case "deselectAll":
|
||||
return { ...state, items: new Set<string>() };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
function cappedSet(set: Set<string>, max?: number) {
|
||||
if (!max) {
|
||||
return set;
|
||||
}
|
||||
|
||||
if (set.size <= max) {
|
||||
return set;
|
||||
}
|
||||
|
||||
console.warn(`Selected items exceeded the maximum count of ${max}.`);
|
||||
|
||||
return new Set([...set].slice(0, max));
|
||||
}
|
||||
@@ -106,7 +106,7 @@ export const TableHeaderCell = forwardRef<HTMLTableCellElement, TableHeaderCellP
|
||||
ref={ref}
|
||||
scope="col"
|
||||
className={cn(
|
||||
"px-4 py-2 align-middle text-xxs font-normal uppercase tracking-wider text-text-dimmed",
|
||||
"px-3 py-2 align-middle text-xxs font-normal uppercase tracking-wider text-text-dimmed",
|
||||
alignmentClassName,
|
||||
className
|
||||
)}
|
||||
@@ -155,7 +155,7 @@ export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
|
||||
}
|
||||
|
||||
const flexClasses = cn(
|
||||
"flex w-full whitespace-nowrap px-4 py-3 text-xs text-text-dimmed",
|
||||
"flex w-full whitespace-nowrap px-3 py-3 text-xs text-text-dimmed",
|
||||
alignment === "left"
|
||||
? "justify-start text-left"
|
||||
: alignment === "center"
|
||||
@@ -170,7 +170,7 @@ export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
|
||||
"text-xs text-charcoal-400",
|
||||
to || onClick || hasAction
|
||||
? "cursor-pointer group-hover/table-row:bg-charcoal-900"
|
||||
: "px-4 py-3 align-middle",
|
||||
: "px-3 py-3 align-middle",
|
||||
!to && !onClick && alignmentClassName,
|
||||
isSticky && stickyStyles,
|
||||
className
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { ArrowPathIcon, NoSymbolIcon } from "@heroicons/react/20/solid";
|
||||
import { BulkActionType } from "@trigger.dev/database";
|
||||
import assertNever from "assert-never";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
export function BulkActionStatusCombo({
|
||||
type,
|
||||
className,
|
||||
iconClassName,
|
||||
}: {
|
||||
type: BulkActionType;
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<span className={cn("flex items-center gap-1", className)}>
|
||||
<BulkActionIcon type={type} className={cn("h-4 w-4", iconClassName)} />
|
||||
<BulkActionLabel type={type} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function BulkActionLabel({ type }: { type: BulkActionType }) {
|
||||
return <span className={bulkActionClassName(type)}>{bulkActionTitle(type)}</span>;
|
||||
}
|
||||
|
||||
export function BulkActionIcon({ type, className }: { type: BulkActionType; className: string }) {
|
||||
switch (type) {
|
||||
case "REPLAY":
|
||||
return <ArrowPathIcon className={cn(bulkActionClassName(type), className)} />;
|
||||
case "CANCEL":
|
||||
return <NoSymbolIcon className={cn(bulkActionClassName(type), className)} />;
|
||||
default: {
|
||||
assertNever(type);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function bulkActionClassName(type: BulkActionType): string {
|
||||
switch (type) {
|
||||
case "REPLAY":
|
||||
return "text-indigo-500";
|
||||
case "CANCEL":
|
||||
return "text-rose-500";
|
||||
default: {
|
||||
assertNever(type);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function bulkActionTitle(type: BulkActionType): string {
|
||||
switch (type) {
|
||||
case "REPLAY":
|
||||
return "Replay";
|
||||
case "CANCEL":
|
||||
return "Cancel";
|
||||
default: {
|
||||
assertNever(type);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function bulkActionVerb(type: BulkActionType): string {
|
||||
switch (type) {
|
||||
case "REPLAY":
|
||||
return "Replaying";
|
||||
case "CANCEL":
|
||||
return "Canceling";
|
||||
default: {
|
||||
assertNever(type);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,31 @@
|
||||
import { BoltSlashIcon, CheckCircleIcon } from "@heroicons/react/20/solid";
|
||||
|
||||
export function EnabledStatus({ enabled }: { enabled: boolean }) {
|
||||
type EnabledStatusProps = {
|
||||
enabled: boolean;
|
||||
enabledIcon?: React.ComponentType<any>;
|
||||
disabledIcon?: React.ComponentType<any>;
|
||||
};
|
||||
|
||||
export function EnabledStatus({
|
||||
enabled,
|
||||
enabledIcon = CheckCircleIcon,
|
||||
disabledIcon = BoltSlashIcon,
|
||||
}: EnabledStatusProps) {
|
||||
const EnabledIcon = enabledIcon;
|
||||
const DisabledIcon = disabledIcon;
|
||||
|
||||
switch (enabled) {
|
||||
case true:
|
||||
return (
|
||||
<div className="flex items-center gap-1 text-xs text-success">
|
||||
<CheckCircleIcon className="h-4 w-4" />
|
||||
<EnabledIcon className="size-4" />
|
||||
Enabled
|
||||
</div>
|
||||
);
|
||||
case false:
|
||||
return (
|
||||
<div className="text-dimmed flex items-center gap-1 text-xs">
|
||||
<BoltSlashIcon className="h-4 w-4" />
|
||||
<DisabledIcon className="size-4" />
|
||||
Disabled
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
import * as Ariakit from "@ariakit/react";
|
||||
import { CalendarIcon, CpuChipIcon, XMarkIcon } from "@heroicons/react/20/solid";
|
||||
import {
|
||||
ArrowPathIcon,
|
||||
CalendarIcon,
|
||||
CpuChipIcon,
|
||||
InboxStackIcon,
|
||||
XMarkIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { Form } from "@remix-run/react";
|
||||
import { RuntimeEnvironment, TaskRunStatus, TaskTriggerSource } from "@trigger.dev/database";
|
||||
import type {
|
||||
RuntimeEnvironment,
|
||||
TaskTriggerSource,
|
||||
TaskRunStatus,
|
||||
BulkActionType,
|
||||
} from "@trigger.dev/database";
|
||||
import { ListFilterIcon } from "lucide-react";
|
||||
import { ReactNode, startTransition, useCallback, useMemo, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { startTransition, useCallback, useMemo, useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { TaskIcon } from "~/assets/icons/TaskIcon";
|
||||
import { EnvironmentLabel, environmentTitle } from "~/components/environments/EnvironmentLabel";
|
||||
@@ -32,12 +44,15 @@ import { Button } from "../../primitives/Buttons";
|
||||
import {
|
||||
TaskRunStatusCombo,
|
||||
allTaskRunStatuses,
|
||||
filterableTaskRunStatuses,
|
||||
descriptionForTaskRunStatus,
|
||||
runStatusTitle,
|
||||
} from "./TaskRunStatus";
|
||||
import { TaskTriggerSourceIcon } from "./TaskTriggerSource";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import { BulkActionStatusCombo } from "./BulkAction";
|
||||
|
||||
export const TaskAttemptStatus = z.nativeEnum(TaskRunStatus);
|
||||
export const TaskAttemptStatus = z.enum(allTaskRunStatuses);
|
||||
|
||||
export const TaskRunListSearchFilters = z.object({
|
||||
cursor: z.string().optional(),
|
||||
@@ -59,6 +74,7 @@ export const TaskRunListSearchFilters = z.object({
|
||||
TaskAttemptStatus.array().optional()
|
||||
),
|
||||
period: z.preprocess((value) => (value === "all" ? undefined : value), z.string().optional()),
|
||||
bulkId: z.string().optional(),
|
||||
from: z.coerce.number().optional(),
|
||||
to: z.coerce.number().optional(),
|
||||
});
|
||||
@@ -72,6 +88,11 @@ type DisplayableEnvironment = Pick<RuntimeEnvironment, "type" | "id"> & {
|
||||
type RunFiltersProps = {
|
||||
possibleEnvironments: DisplayableEnvironment[];
|
||||
possibleTasks: { slug: string; triggerSource: TaskTriggerSource }[];
|
||||
bulkActions: {
|
||||
id: string;
|
||||
type: BulkActionType;
|
||||
createdAt: Date;
|
||||
}[];
|
||||
hasFilters: boolean;
|
||||
};
|
||||
|
||||
@@ -82,7 +103,8 @@ export function RunsFilters(props: RunFiltersProps) {
|
||||
searchParams.has("statuses") ||
|
||||
searchParams.has("environments") ||
|
||||
searchParams.has("tasks") ||
|
||||
searchParams.has("period");
|
||||
searchParams.has("period") ||
|
||||
searchParams.has("bulkId");
|
||||
|
||||
return (
|
||||
<div className="flex flex-row flex-wrap items-center gap-1">
|
||||
@@ -112,7 +134,8 @@ const filterTypes = [
|
||||
{ name: "environments", title: "Environment", icon: <CpuChipIcon className="size-4" /> },
|
||||
{ name: "tasks", title: "Tasks", icon: <TaskIcon className="size-4" /> },
|
||||
{ name: "created", title: "Created", icon: <CalendarIcon className="size-4" /> },
|
||||
];
|
||||
{ name: "bulk", title: "Bulk action", icon: <InboxStackIcon className="size-4" /> },
|
||||
] as const;
|
||||
|
||||
type FilterType = (typeof filterTypes)[number]["name"];
|
||||
|
||||
@@ -123,6 +146,7 @@ function FilterMenu(props: RunFiltersProps) {
|
||||
|
||||
const filterTrigger = (
|
||||
<SelectTrigger
|
||||
autoFocus
|
||||
icon={
|
||||
<div className="flex size-4 items-center justify-center">
|
||||
<ListFilterIcon className="size-3.5" />
|
||||
@@ -180,13 +204,14 @@ function FilterMenuProvider({
|
||||
);
|
||||
}
|
||||
|
||||
function AppliedFilters({ possibleEnvironments, possibleTasks }: RunFiltersProps) {
|
||||
function AppliedFilters({ possibleEnvironments, possibleTasks, bulkActions }: RunFiltersProps) {
|
||||
return (
|
||||
<>
|
||||
<AppliedStatusFilter />
|
||||
<AppliedEnvironmentFilter possibleEnvironments={possibleEnvironments} />
|
||||
<AppliedTaskFilter possibleTasks={possibleTasks} />
|
||||
<AppliedPeriodFilter />
|
||||
<AppliedBulkActionsFilter bulkActions={bulkActions} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -211,6 +236,8 @@ function Menu(props: MenuProps) {
|
||||
return <TasksDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
|
||||
case "created":
|
||||
return <CreatedDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
|
||||
case "bulk":
|
||||
return <BulkActionsDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,7 +273,7 @@ function MainMenu({ searchValue, trigger, clearSearchValue, setFilterType }: Men
|
||||
);
|
||||
}
|
||||
|
||||
const statuses = allTaskRunStatuses.map((status) => ({
|
||||
const statuses = filterableTaskRunStatuses.map((status) => ({
|
||||
title: runStatusTitle(status),
|
||||
value: status,
|
||||
}));
|
||||
@@ -529,6 +556,99 @@ function AppliedTaskFilter({ possibleTasks }: Pick<RunFiltersProps, "possibleTas
|
||||
);
|
||||
}
|
||||
|
||||
function BulkActionsDropdown({
|
||||
trigger,
|
||||
clearSearchValue,
|
||||
searchValue,
|
||||
onClose,
|
||||
bulkActions,
|
||||
}: {
|
||||
trigger: ReactNode;
|
||||
clearSearchValue: () => void;
|
||||
searchValue: string;
|
||||
onClose?: () => void;
|
||||
bulkActions: RunFiltersProps["bulkActions"];
|
||||
}) {
|
||||
const { value, replace } = useSearchParams();
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
clearSearchValue();
|
||||
replace({ bulkId: value, cursor: undefined, direction: undefined });
|
||||
};
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return bulkActions.filter((item) => {
|
||||
return (
|
||||
item.type.toLowerCase().includes(searchValue.toLowerCase()) ||
|
||||
item.createdAt.toISOString().includes(searchValue)
|
||||
);
|
||||
});
|
||||
}, [searchValue, bulkActions]);
|
||||
|
||||
return (
|
||||
<SelectProvider value={value("bulkId")} setValue={handleChange} virtualFocus={true}>
|
||||
{trigger}
|
||||
<SelectPopover
|
||||
className="min-w-0 max-w-[min(240px,var(--popover-available-width))]"
|
||||
hideOnEscape={() => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}}
|
||||
>
|
||||
<ComboBox placeholder={"Filter by bulk action..."} value={searchValue} />
|
||||
<SelectList>
|
||||
<SelectItem value={""}>None</SelectItem>
|
||||
{filtered.map((item, index) => (
|
||||
<SelectItem key={item.id} value={item.id}>
|
||||
<div className="flex gap-3">
|
||||
<BulkActionStatusCombo type={item.type} iconClassName="size-4" />
|
||||
<DateTime date={item.createdAt} />
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectList>
|
||||
</SelectPopover>
|
||||
</SelectProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function AppliedBulkActionsFilter({ bulkActions }: Pick<RunFiltersProps, "bulkActions">) {
|
||||
const { value, del } = useSearchParams();
|
||||
|
||||
const bulkId = value("bulkId");
|
||||
|
||||
if (!bulkId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const action = bulkActions.find((action) => action.id === bulkId);
|
||||
|
||||
return (
|
||||
<FilterMenuProvider>
|
||||
{(search, setSearch) => (
|
||||
<BulkActionsDropdown
|
||||
trigger={
|
||||
<Ariakit.Select render={<div className="group cursor-pointer" />}>
|
||||
<AppliedFilter
|
||||
label="Bulk action"
|
||||
value={bulkId}
|
||||
onRemove={() => del(["bulkId", "cursor", "direction"])}
|
||||
/>
|
||||
</Ariakit.Select>
|
||||
}
|
||||
searchValue={search}
|
||||
clearSearchValue={() => setSearch("")}
|
||||
bulkActions={bulkActions}
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const timePeriods = [
|
||||
{
|
||||
label: "All periods",
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
XCircleIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import type { TaskRunAttemptStatus as TaskRunAttemptStatusType } from "@trigger.dev/database";
|
||||
import { TaskRunAttemptStatus } from "@trigger.dev/database";
|
||||
import { TaskRunAttemptStatus } from "~/database-types";
|
||||
import assertNever from "assert-never";
|
||||
import { SnowflakeIcon } from "lucide-react";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
@@ -17,7 +17,7 @@ export const allTaskRunAttemptStatuses = Object.values(
|
||||
TaskRunAttemptStatus
|
||||
) as TaskRunAttemptStatusType[];
|
||||
|
||||
export type ExtendedTaskAttemptStatus = (typeof allTaskRunAttemptStatuses)[number] | "ENQUEUED";
|
||||
export type ExtendedTaskAttemptStatus = TaskRunAttemptStatusType | "ENQUEUED";
|
||||
|
||||
export function TaskRunAttemptStatusCombo({
|
||||
status,
|
||||
|
||||
@@ -25,9 +25,24 @@ export const allTaskRunStatuses = [
|
||||
"CANCELED",
|
||||
"COMPLETED_WITH_ERRORS",
|
||||
"CRASHED",
|
||||
"PAUSED",
|
||||
"INTERRUPTED",
|
||||
"SYSTEM_FAILURE",
|
||||
] as TaskRunStatus[];
|
||||
] as const satisfies Readonly<Array<TaskRunStatus>>;
|
||||
|
||||
export const filterableTaskRunStatuses = [
|
||||
"WAITING_FOR_DEPLOY",
|
||||
"PENDING",
|
||||
"EXECUTING",
|
||||
"RETRYING_AFTER_FAILURE",
|
||||
"WAITING_TO_RESUME",
|
||||
"COMPLETED_SUCCESSFULLY",
|
||||
"CANCELED",
|
||||
"COMPLETED_WITH_ERRORS",
|
||||
"CRASHED",
|
||||
"INTERRUPTED",
|
||||
"SYSTEM_FAILURE",
|
||||
] as const satisfies Readonly<Array<TaskRunStatus>>;
|
||||
|
||||
const taskRunStatusDescriptions: Record<TaskRunStatus, string> = {
|
||||
PENDING: "Task is waiting to be executed",
|
||||
|
||||
@@ -28,6 +28,11 @@ import { CancelRunDialog } from "./CancelRunDialog";
|
||||
import { ReplayRunDialog } from "./ReplayRunDialog";
|
||||
import { TaskRunStatusCombo } from "./TaskRunStatus";
|
||||
import { LiveTimer } from "./LiveTimer";
|
||||
import { useSelectedItems } from "~/components/primitives/SelectedItemsProvider";
|
||||
import { Checkbox } from "~/components/primitives/Checkbox";
|
||||
import { useCallback, useRef } from "react";
|
||||
import { run } from "@remix-run/dev/dist/cli/run";
|
||||
import { formatNumber } from "~/utils/numberFormatter";
|
||||
|
||||
type RunsTableProps = {
|
||||
total: number;
|
||||
@@ -36,6 +41,7 @@ type RunsTableProps = {
|
||||
showJob?: boolean;
|
||||
runs: RunListItem[];
|
||||
isLoading?: boolean;
|
||||
allowSelection?: boolean;
|
||||
};
|
||||
|
||||
export function TaskRunsTable({
|
||||
@@ -44,15 +50,66 @@ export function TaskRunsTable({
|
||||
filters,
|
||||
runs,
|
||||
isLoading = false,
|
||||
allowSelection = false,
|
||||
}: RunsTableProps) {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const checkboxes = useRef<(HTMLInputElement | null)[]>([]);
|
||||
const { selectedItems, has, hasAll, select, deselect, toggle } = useSelectedItems(allowSelection);
|
||||
|
||||
const navigateCheckboxes = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLInputElement>, index: number) => {
|
||||
//indexes are out by one because of the header row
|
||||
if (event.key === "ArrowUp" && index > 0) {
|
||||
checkboxes.current[index - 1]?.focus();
|
||||
|
||||
if (event.shiftKey) {
|
||||
const oldItem = runs.at(index - 1);
|
||||
const newItem = runs.at(index - 2);
|
||||
const itemsIds = [oldItem?.id, newItem?.id].filter(Boolean);
|
||||
select(itemsIds);
|
||||
}
|
||||
} else if (event.key === "ArrowDown" && index < checkboxes.current.length - 1) {
|
||||
checkboxes.current[index + 1]?.focus();
|
||||
|
||||
if (event.shiftKey) {
|
||||
const oldItem = runs.at(index - 1);
|
||||
const newItem = runs.at(index);
|
||||
const itemsIds = [oldItem?.id, newItem?.id].filter(Boolean);
|
||||
select(itemsIds);
|
||||
}
|
||||
}
|
||||
},
|
||||
[checkboxes, runs]
|
||||
);
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Run</TableHeaderCell>
|
||||
{allowSelection && (
|
||||
<TableHeaderCell className="pl-2 pr-0">
|
||||
{runs.length > 0 && (
|
||||
<Checkbox
|
||||
checked={hasAll(runs.map((r) => r.id))}
|
||||
onChange={(element) => {
|
||||
const ids = runs.map((r) => r.id);
|
||||
const checked = element.currentTarget.checked;
|
||||
if (checked) {
|
||||
select(ids);
|
||||
} else {
|
||||
deselect(ids);
|
||||
}
|
||||
}}
|
||||
ref={(r) => {
|
||||
checkboxes.current[0] = r;
|
||||
}}
|
||||
onKeyDown={(event) => navigateCheckboxes(event, 0)}
|
||||
/>
|
||||
)}
|
||||
</TableHeaderCell>
|
||||
)}
|
||||
<TableHeaderCell alignment="right">Run #</TableHeaderCell>
|
||||
<TableHeaderCell>Task ID</TableHeaderCell>
|
||||
<TableHeaderCell>Version</TableHeaderCell>
|
||||
<TableHeaderCell>Env</TableHeaderCell>
|
||||
@@ -74,11 +131,27 @@ export function TaskRunsTable({
|
||||
) : runs.length === 0 ? (
|
||||
<BlankState isLoading={isLoading} filters={filters} />
|
||||
) : (
|
||||
runs.map((run) => {
|
||||
runs.map((run, index) => {
|
||||
const path = v3RunSpanPath(organization, project, run, { spanId: run.spanId });
|
||||
return (
|
||||
<TableRow key={run.id}>
|
||||
<TableCell to={path}>#{run.number}</TableCell>
|
||||
{allowSelection && (
|
||||
<TableCell className="pl-2 pr-0">
|
||||
<Checkbox
|
||||
checked={has(run.id)}
|
||||
onChange={(element) => {
|
||||
toggle(run.id);
|
||||
}}
|
||||
ref={(r) => {
|
||||
checkboxes.current[index + 1] = r;
|
||||
}}
|
||||
onKeyDown={(event) => navigateCheckboxes(event, index + 1)}
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell to={path} alignment="right">
|
||||
{formatNumber(run.number)}
|
||||
</TableCell>
|
||||
<TableCell to={path}>{run.taskIdentifier}</TableCell>
|
||||
<TableCell to={path}>{run.version ?? "–"}</TableCell>
|
||||
<TableCell to={path}>
|
||||
@@ -194,7 +267,7 @@ function BlankState({ isLoading, filters }: Pick<RunsTableProps, "isLoading" | "
|
||||
) {
|
||||
const environment = envs?.find((env) => env.id === filters.environments[0]);
|
||||
return (
|
||||
<TableBlankRow colSpan={9}>
|
||||
<TableBlankRow colSpan={10}>
|
||||
<div className="py-14">
|
||||
<Paragraph className="w-auto" variant="base/bright" spacing>
|
||||
There are no runs for {filters.tasks[0]}
|
||||
@@ -235,8 +308,21 @@ function BlankState({ isLoading, filters }: Pick<RunsTableProps, "isLoading" | "
|
||||
}
|
||||
|
||||
return (
|
||||
<TableBlankRow colSpan={9}>
|
||||
<NoRuns title="No runs match your filters" />
|
||||
<TableBlankRow colSpan={10}>
|
||||
<div className="flex flex-col items-center justify-center gap-2">
|
||||
<Paragraph className="w-auto" variant="small">
|
||||
No runs currently match your filters. Try refreshing or modifying your filters.
|
||||
</Paragraph>
|
||||
<Button
|
||||
LeadingIcon={ArrowPathIcon}
|
||||
variant="tertiary/small"
|
||||
onClick={() => {
|
||||
window.location.reload();
|
||||
}}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</TableBlankRow>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,3 +12,5 @@ export const MAX_RUN_CHUNK_EXECUTION_LIMIT = 120000; // 2 minutes
|
||||
export const VERCEL_RESPONSE_TIMEOUT_STATUS_CODES = [408, 504];
|
||||
export const MAX_BATCH_TRIGGER_ITEMS = 100;
|
||||
export const MAX_TASK_RUN_ATTEMPTS = 250;
|
||||
export const BULK_ACTION_RUN_LIMIT = 250;
|
||||
export const MAX_JOB_RUN_EXECUTION_COUNT = 250;
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// There's a weird issue with importing values from the prisma client
|
||||
// when using Remix Vite + pnpm + prisma
|
||||
// As long as they're only used as types it's ok
|
||||
// Import types here and validate hardcoded enums
|
||||
|
||||
import type {
|
||||
BatchTaskRunItemStatus as BatchTaskRunItemStatusType,
|
||||
TaskRunAttemptStatus as TaskRunAttemptStatusType,
|
||||
TaskRunStatus as TaskRunStatusType,
|
||||
JobRunStatus as JobRunStatusType,
|
||||
RuntimeEnvironmentType as RuntimeEnvironmentTypeType,
|
||||
} from "@trigger.dev/database";
|
||||
|
||||
export const BatchTaskRunItemStatus = {
|
||||
PENDING: "PENDING",
|
||||
FAILED: "FAILED",
|
||||
CANCELED: "CANCELED",
|
||||
COMPLETED: "COMPLETED",
|
||||
} as const satisfies Record<BatchTaskRunItemStatusType, BatchTaskRunItemStatusType>;
|
||||
|
||||
export const TaskRunAttemptStatus = {
|
||||
PENDING: "PENDING",
|
||||
EXECUTING: "EXECUTING",
|
||||
PAUSED: "PAUSED",
|
||||
FAILED: "FAILED",
|
||||
CANCELED: "CANCELED",
|
||||
COMPLETED: "COMPLETED",
|
||||
} as const satisfies Record<TaskRunAttemptStatusType, TaskRunAttemptStatusType>;
|
||||
|
||||
export const TaskRunStatus = {
|
||||
PENDING: "PENDING",
|
||||
WAITING_FOR_DEPLOY: "WAITING_FOR_DEPLOY",
|
||||
EXECUTING: "EXECUTING",
|
||||
WAITING_TO_RESUME: "WAITING_TO_RESUME",
|
||||
RETRYING_AFTER_FAILURE: "RETRYING_AFTER_FAILURE",
|
||||
PAUSED: "PAUSED",
|
||||
CANCELED: "CANCELED",
|
||||
INTERRUPTED: "INTERRUPTED",
|
||||
COMPLETED_SUCCESSFULLY: "COMPLETED_SUCCESSFULLY",
|
||||
COMPLETED_WITH_ERRORS: "COMPLETED_WITH_ERRORS",
|
||||
SYSTEM_FAILURE: "SYSTEM_FAILURE",
|
||||
CRASHED: "CRASHED",
|
||||
} as const satisfies Record<TaskRunStatusType, TaskRunStatusType>;
|
||||
|
||||
export const JobRunStatus = {
|
||||
PENDING: "PENDING",
|
||||
QUEUED: "QUEUED",
|
||||
WAITING_ON_CONNECTIONS: "WAITING_ON_CONNECTIONS",
|
||||
PREPROCESSING: "PREPROCESSING",
|
||||
STARTED: "STARTED",
|
||||
EXECUTING: "EXECUTING",
|
||||
WAITING_TO_CONTINUE: "WAITING_TO_CONTINUE",
|
||||
WAITING_TO_EXECUTE: "WAITING_TO_EXECUTE",
|
||||
SUCCESS: "SUCCESS",
|
||||
FAILURE: "FAILURE",
|
||||
TIMED_OUT: "TIMED_OUT",
|
||||
ABORTED: "ABORTED",
|
||||
CANCELED: "CANCELED",
|
||||
UNRESOLVED_AUTH: "UNRESOLVED_AUTH",
|
||||
INVALID_PAYLOAD: "INVALID_PAYLOAD",
|
||||
} as const satisfies Record<JobRunStatusType, JobRunStatusType>;
|
||||
|
||||
export const RuntimeEnvironmentType = {
|
||||
PRODUCTION: "PRODUCTION",
|
||||
STAGING: "STAGING",
|
||||
DEVELOPMENT: "DEVELOPMENT",
|
||||
PREVIEW: "PREVIEW",
|
||||
} as const satisfies Record<RuntimeEnvironmentTypeType, RuntimeEnvironmentTypeType>;
|
||||
@@ -70,7 +70,9 @@ export { Prisma };
|
||||
|
||||
export const prisma = singleton("prisma", getClient);
|
||||
|
||||
export const $replica: Omit<PrismaClient, "$transaction"> = singleton(
|
||||
export type PrismaReplicaClient = Omit<PrismaClient, "$transaction">;
|
||||
|
||||
export const $replica: PrismaReplicaClient = singleton(
|
||||
"replica",
|
||||
() => getReplicaClient() ?? prisma
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { SecretStoreOptionsSchema } from "./services/secrets/secretStore.server";
|
||||
import { SecretStoreOptionsSchema } from "./services/secrets/secretStoreOptionsSchema.server";
|
||||
import { isValidRegex } from "./utils/regex";
|
||||
import { isValidDatabaseUrl } from "./utils/db";
|
||||
|
||||
@@ -158,6 +158,14 @@ const EnvironmentSchema = z.object({
|
||||
|
||||
ORG_SLACK_INTEGRATION_CLIENT_ID: z.string().optional(),
|
||||
ORG_SLACK_INTEGRATION_CLIENT_SECRET: z.string().optional(),
|
||||
|
||||
/** These enable the alerts feature in v3 */
|
||||
ALERT_FROM_EMAIL: z.string().optional(),
|
||||
ALERT_RESEND_API_KEY: z.string().optional(),
|
||||
|
||||
MAX_SEQUENTIAL_INDEX_FAILURE_COUNT: z.coerce.number().default(96),
|
||||
|
||||
LOOPS_API_KEY: z.string().optional(),
|
||||
});
|
||||
|
||||
export type Environment = z.infer<typeof EnvironmentSchema>;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { requestUrl } from "./utils/requestUrl.server";
|
||||
export type TriggerFeatures = {
|
||||
isManagedCloud: boolean;
|
||||
v3Enabled: boolean;
|
||||
alertsEnabled: boolean;
|
||||
};
|
||||
|
||||
// If the request host is cloud.trigger.dev then we are on the managed cloud
|
||||
@@ -20,5 +21,6 @@ export function featuresForRequest(request: Request): TriggerFeatures {
|
||||
return {
|
||||
isManagedCloud,
|
||||
v3Enabled: env.V3_ENABLED === "true",
|
||||
alertsEnabled: env.ALERT_FROM_EMAIL !== undefined && env.ALERT_RESEND_API_KEY !== undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,5 +5,5 @@ import type { TriggerFeatures } from "~/features.server";
|
||||
export function useFeatures(): TriggerFeatures {
|
||||
const routeMatch = useTypedRouteLoaderData<typeof loader>("root");
|
||||
|
||||
return routeMatch?.features ?? { isManagedCloud: false, v3Enabled: false };
|
||||
return routeMatch?.features ?? { isManagedCloud: false, v3Enabled: false, alertsEnabled: false };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { RuntimeEnvironmentType, type RuntimeEnvironment } from "@trigger.dev/database";
|
||||
import type { RuntimeEnvironment } from "@trigger.dev/database";
|
||||
import { prisma } from "~/db.server";
|
||||
import { customAlphabet } from "nanoid";
|
||||
import { RuntimeEnvironmentType } from "~/database-types";
|
||||
|
||||
const apiKeyId = customAlphabet(
|
||||
"1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ",
|
||||
|
||||
@@ -27,7 +27,7 @@ type SlackSecret = z.infer<typeof SlackSecretSchema>;
|
||||
|
||||
const REDIRECT_AFTER_AUTH_KEY = "redirect-back-after-auth";
|
||||
|
||||
type OrganizationIntegrationForService<TService extends IntegrationService> = Omit<
|
||||
export type OrganizationIntegrationForService<TService extends IntegrationService> = Omit<
|
||||
AuthenticatableIntegration,
|
||||
"service"
|
||||
> & {
|
||||
@@ -83,7 +83,14 @@ export class OrgIntegrationRepository {
|
||||
|
||||
static slackAuthorizationUrl(
|
||||
state: string,
|
||||
scopes: string[] = ["channels:read", "groups:read", "im:read", "mpim:read", "chat:write"],
|
||||
scopes: string[] = [
|
||||
"channels:read",
|
||||
"groups:read",
|
||||
"im:read",
|
||||
"mpim:read",
|
||||
"chat:write",
|
||||
"chat:write.public",
|
||||
],
|
||||
userScopes: string[] = ["channels:read", "groups:read", "im:read", "mpim:read", "chat:write"]
|
||||
) {
|
||||
return `https://slack.com/oauth/v2/authorize?client_id=${
|
||||
|
||||
@@ -21,16 +21,12 @@ export async function createOrganization(
|
||||
{
|
||||
title,
|
||||
userId,
|
||||
projectName,
|
||||
companySize,
|
||||
projectVersion,
|
||||
}: Pick<Organization, "title" | "companySize"> & {
|
||||
userId: User["id"];
|
||||
projectName: string;
|
||||
projectVersion: "v2" | "v3";
|
||||
},
|
||||
attemptCount = 0
|
||||
): Promise<Organization & { projects: Project[] }> {
|
||||
): Promise<Organization> {
|
||||
if (typeof process.env.BLOCKED_USERS === "string" && process.env.BLOCKED_USERS.includes(userId)) {
|
||||
throw new Error("Organization could not be created.");
|
||||
}
|
||||
@@ -50,9 +46,7 @@ export async function createOrganization(
|
||||
{
|
||||
title,
|
||||
userId,
|
||||
projectName,
|
||||
companySize,
|
||||
projectVersion,
|
||||
},
|
||||
attemptCount + 1
|
||||
);
|
||||
@@ -76,14 +70,7 @@ export async function createOrganization(
|
||||
},
|
||||
});
|
||||
|
||||
const project = await createProject({
|
||||
organizationSlug: organization.slug,
|
||||
name: projectName,
|
||||
userId,
|
||||
version: projectVersion,
|
||||
});
|
||||
|
||||
return { ...organization, projects: [project] };
|
||||
return { ...organization };
|
||||
}
|
||||
|
||||
export async function createEnvironment(
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import {
|
||||
TaskRunError,
|
||||
import type {
|
||||
TaskRunExecutionResult,
|
||||
TaskRunFailedExecutionResult,
|
||||
TaskRunSuccessfulExecutionResult,
|
||||
} from "@trigger.dev/core/v3";
|
||||
import {
|
||||
BatchTaskRunItemStatus,
|
||||
import { TaskRunError } from "@trigger.dev/core/v3";
|
||||
|
||||
import type {
|
||||
TaskRun,
|
||||
TaskRunAttempt,
|
||||
TaskRunAttemptStatus,
|
||||
TaskRunStatus,
|
||||
TaskRunAttemptStatus as TaskRunAttemptStatusType,
|
||||
TaskRunStatus as TaskRunStatusType,
|
||||
BatchTaskRunItemStatus as BatchTaskRunItemStatusType,
|
||||
} from "@trigger.dev/database";
|
||||
|
||||
import { assertNever } from "assert-never";
|
||||
import { BatchTaskRunItemStatus, TaskRunAttemptStatus, TaskRunStatus } from "~/database-types";
|
||||
import { logger } from "~/services/logger.server";
|
||||
|
||||
const SUCCESSFUL_STATUSES = [TaskRunStatus.COMPLETED_SUCCESSFULLY];
|
||||
@@ -104,7 +107,9 @@ export function executionResultForTaskRun(
|
||||
}
|
||||
}
|
||||
|
||||
export function batchTaskRunItemStatusForRunStatus(status: TaskRunStatus): BatchTaskRunItemStatus {
|
||||
export function batchTaskRunItemStatusForRunStatus(
|
||||
status: TaskRunStatusType
|
||||
): BatchTaskRunItemStatusType {
|
||||
switch (status) {
|
||||
case TaskRunStatus.COMPLETED_SUCCESSFULLY:
|
||||
return BatchTaskRunItemStatus.COMPLETED;
|
||||
@@ -113,7 +118,6 @@ export function batchTaskRunItemStatusForRunStatus(status: TaskRunStatus): Batch
|
||||
case TaskRunStatus.COMPLETED_WITH_ERRORS:
|
||||
case TaskRunStatus.SYSTEM_FAILURE:
|
||||
case TaskRunStatus.CRASHED:
|
||||
case TaskRunStatus.COMPLETED_WITH_ERRORS:
|
||||
return BatchTaskRunItemStatus.FAILED;
|
||||
case TaskRunStatus.PENDING:
|
||||
case TaskRunStatus.WAITING_FOR_DEPLOY:
|
||||
|
||||
@@ -577,6 +577,17 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
|
||||
span.recordException(new Error(String(error)));
|
||||
}
|
||||
|
||||
if (job.attempts >= job.max_attempts) {
|
||||
logger.debug("Job failed after max attempts", {
|
||||
job,
|
||||
attempts: job.attempts,
|
||||
max_attempts: job.max_attempts,
|
||||
error: error instanceof Error ? error.message : error,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
span.end();
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { PrismaClient } from "@trigger.dev/database";
|
||||
import { redirect } from "remix-typedjson";
|
||||
import { prisma } from "~/db.server";
|
||||
import { redirectWithErrorMessage } from "~/models/message.server";
|
||||
import {
|
||||
clearCurrentProjectId,
|
||||
commitCurrentProjectSession,
|
||||
getCurrentProjectId,
|
||||
setCurrentProjectId,
|
||||
@@ -10,8 +10,6 @@ import {
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { newProjectPath } from "~/utils/pathBuilder";
|
||||
import { ProjectPresenter } from "./ProjectPresenter.server";
|
||||
import { redirectWithErrorMessage } from "~/models/message.server";
|
||||
import { match } from "assert";
|
||||
|
||||
export class OrganizationsPresenter {
|
||||
#prismaClient: PrismaClient;
|
||||
@@ -64,6 +62,10 @@ export class OrganizationsPresenter {
|
||||
);
|
||||
}
|
||||
|
||||
if (project.organizationId !== organization.id) {
|
||||
throw redirect(newProjectPath({ slug: organizationSlug }), request);
|
||||
}
|
||||
|
||||
return { organizations, organization, project };
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,10 @@ export const ApiAlertType = z.enum(["attempt_failure", "deployment_failure", "de
|
||||
|
||||
export type ApiAlertType = z.infer<typeof ApiAlertType>;
|
||||
|
||||
export const ApiAlertEnvironmentType = z.enum(["STAGING", "PRODUCTION"]);
|
||||
|
||||
export type ApiAlertEnvironmentType = z.infer<typeof ApiAlertEnvironmentType>;
|
||||
|
||||
export const ApiAlertChannel = z.enum(["email", "webhook"]);
|
||||
|
||||
export type ApiAlertChannel = z.infer<typeof ApiAlertChannel>;
|
||||
@@ -34,6 +38,7 @@ export const ApiCreateAlertChannel = z.object({
|
||||
channel: ApiAlertChannel,
|
||||
channelData: ApiAlertChannelData,
|
||||
deduplicationKey: z.string().optional(),
|
||||
environmentTypes: ApiAlertEnvironmentType.array().default(["STAGING", "PRODUCTION"]),
|
||||
});
|
||||
|
||||
export type ApiCreateAlertChannel = z.infer<typeof ApiCreateAlertChannel>;
|
||||
|
||||
@@ -87,7 +87,7 @@ export class EnvironmentVariablesPresenter {
|
||||
);
|
||||
|
||||
const repository = new EnvironmentVariablesRepository(this.#prismaClient);
|
||||
const variables = await repository.getProject(project.id, userId);
|
||||
const variables = await repository.getProject(project.id);
|
||||
|
||||
return {
|
||||
environmentVariables: environmentVariables.map((environmentVariable) => {
|
||||
|
||||
@@ -17,6 +17,7 @@ type RunListOptions = {
|
||||
environments?: string[];
|
||||
scheduleId?: string;
|
||||
period?: string;
|
||||
bulkId?: string;
|
||||
from?: number;
|
||||
to?: number;
|
||||
//pagination
|
||||
@@ -25,7 +26,7 @@ type RunListOptions = {
|
||||
pageSize?: number;
|
||||
};
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 20;
|
||||
const DEFAULT_PAGE_SIZE = 25;
|
||||
|
||||
export type RunList = Awaited<ReturnType<RunListPresenter["call"]>>;
|
||||
export type RunListItem = RunList["runs"][0];
|
||||
@@ -41,6 +42,7 @@ export class RunListPresenter extends BasePresenter {
|
||||
environments,
|
||||
scheduleId,
|
||||
period,
|
||||
bulkId,
|
||||
from,
|
||||
to,
|
||||
direction = "forward",
|
||||
@@ -55,6 +57,7 @@ export class RunListPresenter extends BasePresenter {
|
||||
hasStatusFilters ||
|
||||
(environments !== undefined && environments.length > 0) ||
|
||||
(period !== undefined && period !== "all") ||
|
||||
(bulkId !== undefined && bulkId !== "") ||
|
||||
from !== undefined ||
|
||||
to !== undefined;
|
||||
|
||||
@@ -87,13 +90,55 @@ export class RunListPresenter extends BasePresenter {
|
||||
});
|
||||
|
||||
//get all possible tasks
|
||||
const possibleTasks = await this._replica.backgroundWorkerTask.findMany({
|
||||
const possibleTasksAsync = this._replica.backgroundWorkerTask.findMany({
|
||||
distinct: ["slug"],
|
||||
where: {
|
||||
projectId: project.id,
|
||||
},
|
||||
});
|
||||
|
||||
//get possible bulk actions
|
||||
const bulkActionsAsync = this._replica.bulkActionGroup.findMany({
|
||||
select: {
|
||||
friendlyId: true,
|
||||
type: true,
|
||||
createdAt: true,
|
||||
},
|
||||
where: {
|
||||
projectId: project.id,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
take: 20,
|
||||
});
|
||||
|
||||
const [possibleTasks, bulkActions] = await Promise.all([possibleTasksAsync, bulkActionsAsync]);
|
||||
|
||||
//we can restrict to specific runs using bulkId, or batchId
|
||||
let restrictToRunIds: undefined | string[] = undefined;
|
||||
|
||||
//bulk id
|
||||
if (bulkId) {
|
||||
const bulkAction = await this._replica.bulkActionGroup.findUnique({
|
||||
select: {
|
||||
items: {
|
||||
select: {
|
||||
destinationRunId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
friendlyId: bulkId,
|
||||
},
|
||||
});
|
||||
|
||||
if (bulkAction) {
|
||||
const runIds = bulkAction.items.map((item) => item.destinationRunId).filter(Boolean);
|
||||
restrictToRunIds = runIds;
|
||||
}
|
||||
}
|
||||
|
||||
const periodMs = period ? parse(period) : undefined;
|
||||
|
||||
//get the runs
|
||||
@@ -142,6 +187,13 @@ export class RunListPresenter extends BasePresenter {
|
||||
: Prisma.empty
|
||||
}
|
||||
-- filters
|
||||
${
|
||||
restrictToRunIds
|
||||
? restrictToRunIds.length === 0
|
||||
? Prisma.sql`AND tr.id = ''`
|
||||
: Prisma.sql`AND tr.id IN (${Prisma.join(restrictToRunIds)})`
|
||||
: Prisma.empty
|
||||
}
|
||||
${
|
||||
tasks && tasks.length > 0
|
||||
? Prisma.sql`AND tr."taskIdentifier" IN (${Prisma.join(tasks)})`
|
||||
@@ -240,6 +292,11 @@ export class RunListPresenter extends BasePresenter {
|
||||
.sort((a, b) => {
|
||||
return a.slug.localeCompare(b.slug);
|
||||
}),
|
||||
bulkActions: bulkActions.map((bulkAction) => ({
|
||||
id: bulkAction.friendlyId,
|
||||
type: bulkAction.type,
|
||||
createdAt: bulkAction.createdAt,
|
||||
})),
|
||||
filters: {
|
||||
tasks: tasks || [],
|
||||
versions: versions || [],
|
||||
|
||||
@@ -1,29 +1,26 @@
|
||||
import { prettyPrintPacket } from "@trigger.dev/core/v3";
|
||||
import { PrismaClient, prisma } from "~/db.server";
|
||||
import { eventRepository } from "~/v3/eventRepository.server";
|
||||
import { BasePresenter } from "./basePresenter.server";
|
||||
|
||||
type Result = Awaited<ReturnType<SpanPresenter["call"]>>;
|
||||
export type Span = NonNullable<Result>["event"];
|
||||
|
||||
export class SpanPresenter {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
export class SpanPresenter extends BasePresenter {
|
||||
public async call({
|
||||
userId,
|
||||
projectSlug,
|
||||
organizationSlug,
|
||||
spanId,
|
||||
runFriendlyId,
|
||||
}: {
|
||||
userId: string;
|
||||
projectSlug: string;
|
||||
organizationSlug: string;
|
||||
spanId: string;
|
||||
runFriendlyId: string;
|
||||
}) {
|
||||
const project = await this.#prismaClient.project.findUnique({
|
||||
const project = await this._replica.project.findUnique({
|
||||
where: {
|
||||
slug: projectSlug,
|
||||
},
|
||||
@@ -33,7 +30,20 @@ export class SpanPresenter {
|
||||
throw new Error("Project not found");
|
||||
}
|
||||
|
||||
const span = await eventRepository.getSpan(spanId);
|
||||
const run = await this._prisma.taskRun.findFirst({
|
||||
select: {
|
||||
traceId: true,
|
||||
},
|
||||
where: {
|
||||
friendlyId: runFriendlyId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!run) {
|
||||
return;
|
||||
}
|
||||
|
||||
const span = await eventRepository.getSpan(spanId, run.traceId);
|
||||
|
||||
if (!span) {
|
||||
return;
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import {
|
||||
Prisma,
|
||||
import type {
|
||||
RuntimeEnvironmentType,
|
||||
TaskRunStatus,
|
||||
TaskTriggerSource,
|
||||
TaskRunStatus as TaskRunStatusType,
|
||||
} from "@trigger.dev/database";
|
||||
import { Prisma } from "@trigger.dev/database";
|
||||
import { QUEUED_STATUSES, RUNNING_STATUSES } from "~/components/runs/v3/TaskRunStatus";
|
||||
import { sqlDatabaseSchema } from "~/db.server";
|
||||
import { Organization } from "~/models/organization.server";
|
||||
import { Project } from "~/models/project.server";
|
||||
import type { Organization } from "~/models/organization.server";
|
||||
import type { Project } from "~/models/project.server";
|
||||
import { displayableEnvironments } from "~/models/runtimeEnvironment.server";
|
||||
import { User } from "~/models/user.server";
|
||||
import type { User } from "~/models/user.server";
|
||||
import { sortEnvironments } from "~/utils/environmentSort";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { BasePresenter } from "./basePresenter.server";
|
||||
import { TaskRunStatus } from "~/database-types";
|
||||
|
||||
export type Task = {
|
||||
slug: string;
|
||||
@@ -110,6 +111,14 @@ export class TaskListPresenter extends BasePresenter {
|
||||
acc.push(existingTask);
|
||||
}
|
||||
|
||||
//favour newer tasks
|
||||
if (task.createdAt > existingTask.createdAt) {
|
||||
existingTask.createdAt = task.createdAt;
|
||||
existingTask.exportName = task.exportName;
|
||||
existingTask.filePath = task.filePath;
|
||||
existingTask.triggerSource = task.triggerSource;
|
||||
}
|
||||
|
||||
existingTask.environments.push(displayableEnvironments(environment, userId));
|
||||
|
||||
//order the environments
|
||||
@@ -150,7 +159,7 @@ export class TaskListPresenter extends BasePresenter {
|
||||
const activity = await this._replica.$queryRaw<
|
||||
{
|
||||
taskIdentifier: string;
|
||||
status: TaskRunStatus;
|
||||
status: TaskRunStatusType;
|
||||
day: Date;
|
||||
count: BigInt;
|
||||
}[]
|
||||
@@ -193,7 +202,7 @@ export class TaskListPresenter extends BasePresenter {
|
||||
existingTask.push({
|
||||
day: day.toISOString(),
|
||||
[TaskRunStatus.COMPLETED_SUCCESSFULLY]: 0,
|
||||
} as { day: string } & Record<TaskRunStatus, number>);
|
||||
} as { day: string } & Record<TaskRunStatusType, number>);
|
||||
}
|
||||
|
||||
acc[a.taskIdentifier] = existingTask;
|
||||
@@ -214,7 +223,7 @@ export class TaskListPresenter extends BasePresenter {
|
||||
day[a.status] = Number(a.count);
|
||||
|
||||
return acc;
|
||||
}, {} as Record<string, ({ day: string } & Record<TaskRunStatus, number>)[]>);
|
||||
}, {} as Record<string, ({ day: string } & Record<TaskRunStatusType, number>)[]>);
|
||||
}
|
||||
|
||||
async #getRunningStats(tasks: string[], projectId: string) {
|
||||
@@ -225,7 +234,7 @@ export class TaskListPresenter extends BasePresenter {
|
||||
const statuses = await this._replica.$queryRaw<
|
||||
{
|
||||
taskIdentifier: string;
|
||||
status: TaskRunStatus;
|
||||
status: TaskRunStatusType;
|
||||
count: BigInt;
|
||||
}[]
|
||||
>`
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { LoaderFunctionArgs, redirect } from "@remix-run/server-runtime";
|
||||
import { prisma } from "~/db.server";
|
||||
import { getUsersInvites } from "~/models/member.server";
|
||||
import { SelectBestProjectPresenter } from "~/presenters/SelectBestProjectPresenter.server";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
import { invitesPath, newOrganizationPath, projectPath } from "~/utils/pathBuilder";
|
||||
import { invitesPath, newOrganizationPath, newProjectPath, projectPath } from "~/utils/pathBuilder";
|
||||
|
||||
//this loader chooses the best project to redirect you to, ideally based on the cookie
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
@@ -20,6 +21,24 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
//redirect them to the most appropriate project
|
||||
return redirect(projectPath(organization, project));
|
||||
} catch (e) {
|
||||
const organization = await prisma.organization.findFirst({
|
||||
where: {
|
||||
members: {
|
||||
some: {
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
if (organization) {
|
||||
return redirect(newProjectPath(organization));
|
||||
}
|
||||
|
||||
//this should only happen if the user has no projects, and no invites
|
||||
return redirect(newOrganizationPath());
|
||||
}
|
||||
|
||||
+88
-29
@@ -10,8 +10,8 @@ import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { InlineCode } from "~/components/code/InlineCode";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Callout } from "~/components/primitives/Callout";
|
||||
import { Checkbox } from "~/components/primitives/Checkbox";
|
||||
import { Callout, variantClasses } from "~/components/primitives/Callout";
|
||||
import { CheckboxWithLabel } from "~/components/primitives/Checkbox";
|
||||
import { Dialog, DialogContent, DialogHeader } from "~/components/primitives/Dialog";
|
||||
import { Fieldset } from "~/components/primitives/Fieldset";
|
||||
import { FormButtons } from "~/components/primitives/FormButtons";
|
||||
@@ -20,14 +20,17 @@ import { Hint } from "~/components/primitives/Hint";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { InputGroup } from "~/components/primitives/InputGroup";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import SegmentedControl from "~/components/primitives/SegmentedControl";
|
||||
import { Select, SelectItem } from "~/components/primitives/Select";
|
||||
import { InfoIconTooltip } from "~/components/primitives/Tooltip";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { NewAlertChannelPresenter } from "~/presenters/v3/NewAlertChannelPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { ProjectParamSchema, v3ProjectAlertsPath } from "~/utils/pathBuilder";
|
||||
import {
|
||||
CreateAlertChannelOptions,
|
||||
@@ -40,6 +43,10 @@ const FormSchema = z
|
||||
.array(z.enum(["TASK_RUN_ATTEMPT", "DEPLOYMENT_FAILURE", "DEPLOYMENT_SUCCESS"]))
|
||||
.min(1)
|
||||
.or(z.enum(["TASK_RUN_ATTEMPT", "DEPLOYMENT_FAILURE", "DEPLOYMENT_SUCCESS"])),
|
||||
environmentTypes: z
|
||||
.array(z.enum(["STAGING", "PRODUCTION"]))
|
||||
.min(1)
|
||||
.or(z.enum(["STAGING", "PRODUCTION"])),
|
||||
type: z.enum(["WEBHOOK", "SLACK", "EMAIL"]).default("EMAIL"),
|
||||
channelValue: z.string().nonempty(),
|
||||
integrationId: z.string().optional(),
|
||||
@@ -81,6 +88,9 @@ function formDataToCreateAlertChannelOptions(
|
||||
alertTypes: Array.isArray(formData.alertTypes)
|
||||
? formData.alertTypes
|
||||
: [formData.alertTypes],
|
||||
environmentTypes: Array.isArray(formData.environmentTypes)
|
||||
? formData.environmentTypes
|
||||
: [formData.environmentTypes],
|
||||
channel: {
|
||||
type: "WEBHOOK",
|
||||
url: formData.channelValue,
|
||||
@@ -93,6 +103,9 @@ function formDataToCreateAlertChannelOptions(
|
||||
alertTypes: Array.isArray(formData.alertTypes)
|
||||
? formData.alertTypes
|
||||
: [formData.alertTypes],
|
||||
environmentTypes: Array.isArray(formData.environmentTypes)
|
||||
? formData.environmentTypes
|
||||
: [formData.environmentTypes],
|
||||
channel: {
|
||||
type: "EMAIL",
|
||||
email: formData.channelValue,
|
||||
@@ -107,6 +120,9 @@ function formDataToCreateAlertChannelOptions(
|
||||
alertTypes: Array.isArray(formData.alertTypes)
|
||||
? formData.alertTypes
|
||||
: [formData.alertTypes],
|
||||
environmentTypes: Array.isArray(formData.environmentTypes)
|
||||
? formData.environmentTypes
|
||||
: [formData.environmentTypes],
|
||||
channel: {
|
||||
type: "SLACK",
|
||||
channelId,
|
||||
@@ -193,20 +209,27 @@ export default function Page() {
|
||||
const project = useProject();
|
||||
const [currentAlertChannel, setCurrentAlertChannel] = useState<string | null>(option ?? "EMAIL");
|
||||
|
||||
const [selectedSlackChannelValue, setSelectedSlackChannelValue] = useState<string | undefined>();
|
||||
|
||||
const selectedSlackChannel = slack.channels?.find(
|
||||
(s) => selectedSlackChannelValue === `${s.id}/${s.name}`
|
||||
);
|
||||
|
||||
const isLoading =
|
||||
navigation.state !== "idle" &&
|
||||
navigation.formMethod === "post" &&
|
||||
navigation.formData?.get("action") === "create";
|
||||
|
||||
const [form, { channelValue: channelValue, alertTypes, type, integrationId }] = useForm({
|
||||
id: "create-alert",
|
||||
// TODO: type this
|
||||
lastSubmission: lastSubmission as any,
|
||||
onValidate({ formData }) {
|
||||
return parse(formData, { schema: FormSchema });
|
||||
},
|
||||
shouldRevalidate: "onSubmit",
|
||||
});
|
||||
const [form, { channelValue: channelValue, alertTypes, environmentTypes, type, integrationId }] =
|
||||
useForm({
|
||||
id: "create-alert",
|
||||
// TODO: type this
|
||||
lastSubmission: lastSubmission as any,
|
||||
onValidate({ formData }) {
|
||||
return parse(formData, { schema: FormSchema });
|
||||
},
|
||||
shouldRevalidate: "onSubmit",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setIsOpen(true);
|
||||
@@ -271,6 +294,9 @@ export default function Page() {
|
||||
dropdownIcon
|
||||
variant="tertiary/medium"
|
||||
items={slack.channels}
|
||||
setValue={(value) => {
|
||||
typeof value === "string" && setSelectedSlackChannelValue(value);
|
||||
}}
|
||||
filter={(channel, search) =>
|
||||
channel.name?.toLowerCase().includes(search.toLowerCase()) ?? false
|
||||
}
|
||||
@@ -290,10 +316,19 @@ export default function Page() {
|
||||
</>
|
||||
)}
|
||||
</Select>
|
||||
<Hint className="leading-relaxed">
|
||||
If selecting a private channel, you will need to invite the bot to the channel
|
||||
using <InlineCode variant="extra-small">/invite @Trigger.dev</InlineCode>
|
||||
</Hint>
|
||||
{selectedSlackChannel && selectedSlackChannel.is_private && (
|
||||
<Callout
|
||||
variant="warning"
|
||||
className={cn("text-sm", variantClasses.warning.textColor)}
|
||||
>
|
||||
To receive alerts in the{" "}
|
||||
<InlineCode variant="extra-small">{selectedSlackChannel.name}</InlineCode>{" "}
|
||||
channel, you need to invite the @Trigger.dev Slack Bot. Go to the channel in
|
||||
Slack and type:{" "}
|
||||
<InlineCode variant="extra-small">/invite @Trigger.dev</InlineCode>.
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
<FormError id={channelValue.errorId}>{channelValue.error}</FormError>
|
||||
<input type="hidden" name="integrationId" value={slack.integrationId} />
|
||||
</>
|
||||
@@ -324,39 +359,63 @@ export default function Page() {
|
||||
</InputGroup>
|
||||
)}
|
||||
|
||||
<InputGroup fullWidth>
|
||||
<Label>Events</Label>
|
||||
<InputGroup>
|
||||
<Label>Alert me when</Label>
|
||||
|
||||
<Checkbox
|
||||
name={alertTypes.name}
|
||||
id="TASK_RUN_ATTEMPT"
|
||||
value="TASK_RUN_ATTEMPT"
|
||||
variant="simple/small"
|
||||
label="Task run failure"
|
||||
defaultChecked
|
||||
/>
|
||||
<div className="flex items-center gap-1">
|
||||
<CheckboxWithLabel
|
||||
name={alertTypes.name}
|
||||
id="TASK_RUN_ATTEMPT"
|
||||
value="TASK_RUN_ATTEMPT"
|
||||
variant="simple/small"
|
||||
label="Task run attempts fail"
|
||||
defaultChecked
|
||||
className="pr-0"
|
||||
/>
|
||||
<InfoIconTooltip content="You'll receive an alert every time an attempt fails on a run." />
|
||||
</div>
|
||||
|
||||
<Checkbox
|
||||
<CheckboxWithLabel
|
||||
name={alertTypes.name}
|
||||
id="DEPLOYMENT_FAILURE"
|
||||
value="DEPLOYMENT_FAILURE"
|
||||
variant="simple/small"
|
||||
label="Deployment failure"
|
||||
label="Deployments fail"
|
||||
defaultChecked
|
||||
/>
|
||||
|
||||
<Checkbox
|
||||
<CheckboxWithLabel
|
||||
name={alertTypes.name}
|
||||
id="DEPLOYMENT_SUCCESS"
|
||||
value="DEPLOYMENT_SUCCESS"
|
||||
variant="simple/small"
|
||||
label="Deployment success"
|
||||
label="Deployments succeed"
|
||||
defaultChecked
|
||||
/>
|
||||
|
||||
<FormError id={alertTypes.errorId}>{alertTypes.error}</FormError>
|
||||
</InputGroup>
|
||||
<InputGroup>
|
||||
<Label>Environments</Label>
|
||||
<CheckboxWithLabel
|
||||
name={environmentTypes.name}
|
||||
id="PRODUCTION"
|
||||
value="PRODUCTION"
|
||||
variant="simple/small"
|
||||
label="PROD"
|
||||
defaultChecked
|
||||
/>
|
||||
<CheckboxWithLabel
|
||||
name={environmentTypes.name}
|
||||
id="STAGING"
|
||||
value="STAGING"
|
||||
variant="simple/small"
|
||||
label="STAGING"
|
||||
defaultChecked
|
||||
/>
|
||||
|
||||
<FormError id={environmentTypes.errorId}>{environmentTypes.error}</FormError>
|
||||
</InputGroup>
|
||||
<FormError>{form.error}</FormError>
|
||||
<div className="border-t border-grid-bright pt-3">
|
||||
<FormButtons
|
||||
|
||||
+70
-19
@@ -1,8 +1,9 @@
|
||||
import { useForm } from "@conform-to/react";
|
||||
import { parse } from "@conform-to/zod";
|
||||
import {
|
||||
BoltIcon,
|
||||
BoltSlashIcon,
|
||||
ArrowUpRightIcon,
|
||||
BellAlertIcon,
|
||||
BellSlashIcon,
|
||||
BookOpenIcon,
|
||||
EnvelopeIcon,
|
||||
GlobeAltIcon,
|
||||
@@ -17,10 +18,12 @@ import { ProjectAlertChannelType, ProjectAlertType } from "@trigger.dev/database
|
||||
import assertNever from "assert-never";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { EnvironmentTypeLabel } from "~/components/environments/EnvironmentLabel";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { ClipboardField } from "~/components/primitives/ClipboardField";
|
||||
import { DetailCell } from "~/components/primitives/DetailCell";
|
||||
import { Header2 } from "~/components/primitives/Headers";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import {
|
||||
@@ -155,7 +158,7 @@ export default function Page() {
|
||||
<PageAccessories>
|
||||
<LinkButton
|
||||
LeadingIcon={BookOpenIcon}
|
||||
to={docsPath("v3/project-alerts")}
|
||||
to={docsPath("v3/troubleshooting-alerts")}
|
||||
variant="minimal/small"
|
||||
>
|
||||
Alerts docs
|
||||
@@ -164,21 +167,25 @@ export default function Page() {
|
||||
</NavBar>
|
||||
<PageBody>
|
||||
<div className={cn("flex h-full flex-col gap-3")}>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<LinkButton
|
||||
to={v3NewProjectAlertPath(organization, project)}
|
||||
variant="primary/small"
|
||||
LeadingIcon={PlusIcon}
|
||||
shortcut={{ key: "n" }}
|
||||
>
|
||||
New alert
|
||||
</LinkButton>
|
||||
</div>
|
||||
{alertChannels.length > 0 && alertChannels.length < 10 && (
|
||||
<div className="flex items-end justify-between">
|
||||
<Header2 className="">Project alerts</Header2>
|
||||
<LinkButton
|
||||
to={v3NewProjectAlertPath(organization, project)}
|
||||
variant="primary/small"
|
||||
LeadingIcon={PlusIcon}
|
||||
shortcut={{ key: "n" }}
|
||||
>
|
||||
New alert
|
||||
</LinkButton>
|
||||
</div>
|
||||
)}
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Name</TableHeaderCell>
|
||||
<TableHeaderCell>Alert Types</TableHeaderCell>
|
||||
<TableHeaderCell>Alert types</TableHeaderCell>
|
||||
<TableHeaderCell>Environments</TableHeaderCell>
|
||||
<TableHeaderCell>Channel</TableHeaderCell>
|
||||
<TableHeaderCell>Enabled</TableHeaderCell>
|
||||
<TableHeaderCell hiddenLabel>Actions</TableHeaderCell>
|
||||
@@ -194,11 +201,25 @@ export default function Page() {
|
||||
<TableCell className={alertChannel.enabled ? "" : "opacity-50"}>
|
||||
{alertChannel.alertTypes.map((type) => alertTypeTitle(type)).join(", ")}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className={cn("space-x-2", alertChannel.enabled ? "" : "opacity-50")}
|
||||
>
|
||||
{alertChannel.environmentTypes.map((environmentType) => (
|
||||
<EnvironmentTypeLabel
|
||||
key={environmentType}
|
||||
environment={{ type: environmentType }}
|
||||
/>
|
||||
))}
|
||||
</TableCell>
|
||||
<TableCell className={alertChannel.enabled ? "" : "opacity-50"}>
|
||||
<AlertChannelDetails alertChannel={alertChannel} />
|
||||
</TableCell>
|
||||
<TableCell className={alertChannel.enabled ? "" : "opacity-50"}>
|
||||
<EnabledStatus enabled={alertChannel.enabled} />
|
||||
<EnabledStatus
|
||||
enabled={alertChannel.enabled}
|
||||
enabledIcon={BellAlertIcon}
|
||||
disabledIcon={BellSlashIcon}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCellMenu isSticky>
|
||||
{alertChannel.enabled ? (
|
||||
@@ -214,14 +235,44 @@ export default function Page() {
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5}>
|
||||
<div className="flex items-center justify-center">
|
||||
<Paragraph>No alerts have been created</Paragraph>
|
||||
<div className="flex flex-col items-center justify-center py-6">
|
||||
<Header2 spacing className="text-text-bright">
|
||||
You haven't created any project alerts yet
|
||||
</Header2>
|
||||
<Paragraph variant="small" className="mb-4">
|
||||
Get alerted when runs or deployments fail, or when deployments succeed in
|
||||
both Prod and Staging environments.
|
||||
</Paragraph>
|
||||
<LinkButton
|
||||
to={v3NewProjectAlertPath(organization, project)}
|
||||
variant="primary/medium"
|
||||
LeadingIcon={PlusIcon}
|
||||
shortcut={{ key: "n" }}
|
||||
>
|
||||
New alert
|
||||
</LinkButton>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="mt-4">
|
||||
<Header2 className="mb-1">Platform alerts</Header2>
|
||||
<Paragraph variant="small" className="mb-4">
|
||||
Subscribe to get email notifications when Trigger.dev creates, updates or resolves a
|
||||
platform incident.
|
||||
</Paragraph>
|
||||
<LinkButton
|
||||
variant="tertiary/medium"
|
||||
TrailingIcon={ArrowUpRightIcon}
|
||||
to="https://status.trigger.dev/"
|
||||
target="_blank"
|
||||
className="inline-flex"
|
||||
>
|
||||
Subscribe
|
||||
</LinkButton>
|
||||
</div>
|
||||
</div>
|
||||
<Outlet />
|
||||
</PageBody>
|
||||
@@ -294,7 +345,7 @@ function DisableAlertChannelButton(props: { id: string }) {
|
||||
value="disable"
|
||||
type="submit"
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={BoltSlashIcon}
|
||||
LeadingIcon={BellSlashIcon}
|
||||
leadingIconClassName="text-dimmed"
|
||||
className="text-xs"
|
||||
>
|
||||
@@ -332,7 +383,7 @@ function EnableAlertChannelButton(props: { id: string }) {
|
||||
value="enable"
|
||||
type="submit"
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={BoltIcon}
|
||||
LeadingIcon={BellAlertIcon}
|
||||
leadingIconClassName="text-success"
|
||||
className="text-xs"
|
||||
>
|
||||
|
||||
+14
-7
@@ -18,7 +18,7 @@ import {
|
||||
environmentTitle,
|
||||
} from "~/components/environments/EnvironmentLabel";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Checkbox } from "~/components/primitives/Checkbox";
|
||||
import { CheckboxWithLabel } from "~/components/primitives/Checkbox";
|
||||
import { Dialog, DialogContent, DialogHeader } from "~/components/primitives/Dialog";
|
||||
import { Fieldset } from "~/components/primitives/Fieldset";
|
||||
import { FormButtons } from "~/components/primitives/FormButtons";
|
||||
@@ -70,7 +70,7 @@ const Variable = z.object({
|
||||
type Variable = z.infer<typeof Variable>;
|
||||
|
||||
const schema = z.object({
|
||||
overwrite: z.preprocess((i) => {
|
||||
override: z.preprocess((i) => {
|
||||
if (i === "true") return true;
|
||||
if (i === "false") return false;
|
||||
return;
|
||||
@@ -115,6 +115,13 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
const project = await prisma.project.findUnique({
|
||||
where: {
|
||||
slug: params.projectParam,
|
||||
organization: {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
@@ -126,7 +133,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
}
|
||||
|
||||
const repository = new EnvironmentVariablesRepository(prisma);
|
||||
const result = await repository.create(project.id, userId, submission.value);
|
||||
const result = await repository.create(project.id, submission.value);
|
||||
|
||||
if (!result.success) {
|
||||
if (result.variableErrors) {
|
||||
@@ -195,7 +202,7 @@ export default function Page() {
|
||||
<Label>Environments</Label>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{environments.map((environment) => (
|
||||
<Checkbox
|
||||
<CheckboxWithLabel
|
||||
key={environment.id}
|
||||
id={environment.id}
|
||||
value={environment.id}
|
||||
@@ -249,7 +256,7 @@ export default function Page() {
|
||||
type="submit"
|
||||
variant="primary/small"
|
||||
disabled={isLoading}
|
||||
name="overwrite"
|
||||
name="override"
|
||||
value="false"
|
||||
>
|
||||
{isLoading ? "Saving" : "Save"}
|
||||
@@ -257,10 +264,10 @@ export default function Page() {
|
||||
<Button
|
||||
variant="secondary/small"
|
||||
disabled={isLoading}
|
||||
name="overwrite"
|
||||
name="override"
|
||||
value="true"
|
||||
>
|
||||
{isLoading ? "Overwriting" : "Overwrite"}
|
||||
{isLoading ? "Overriding" : "Override"}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
|
||||
+10
-2
@@ -106,6 +106,13 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
const project = await prisma.project.findUnique({
|
||||
where: {
|
||||
slug: params.projectParam,
|
||||
organization: {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
@@ -119,7 +126,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
switch (submission.value.action) {
|
||||
case "edit": {
|
||||
const repository = new EnvironmentVariablesRepository(prisma);
|
||||
const result = await repository.edit(project.id, userId, submission.value);
|
||||
const result = await repository.edit(project.id, submission.value);
|
||||
|
||||
if (!result.success) {
|
||||
submission.error.key = result.error;
|
||||
@@ -138,7 +145,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
}
|
||||
case "delete": {
|
||||
const repository = new EnvironmentVariablesRepository(prisma);
|
||||
const result = await repository.delete(project.id, userId, submission.value);
|
||||
const result = await repository.delete(project.id, submission.value);
|
||||
|
||||
if (!result.success) {
|
||||
submission.error.key = result.error;
|
||||
@@ -334,6 +341,7 @@ function EditEnvironmentVariablePanel({
|
||||
name={`values[${index}].value`}
|
||||
placeholder="Not set"
|
||||
defaultValue={value}
|
||||
type="password"
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
|
||||
+257
-54
@@ -1,16 +1,33 @@
|
||||
import { ArrowPathIcon, StopCircleIcon } from "@heroicons/react/20/solid";
|
||||
import { BeakerIcon, BookOpenIcon } from "@heroicons/react/24/solid";
|
||||
import { useNavigation } from "@remix-run/react";
|
||||
import { Form, useNavigation } from "@remix-run/react";
|
||||
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { TypedAwait, typeddefer, typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Suspense, useState } from "react";
|
||||
import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson";
|
||||
import { TaskIcon } from "~/assets/icons/TaskIcon";
|
||||
import { BlankstateInstructions } from "~/components/BlankstateInstructions";
|
||||
import { StepContentContainer } from "~/components/StepContentContainer";
|
||||
import { MainCenteredContainer, PageBody } from "~/components/layout/AppLayout";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTrigger,
|
||||
} from "~/components/primitives/Dialog";
|
||||
import { Header1 } from "~/components/primitives/Headers";
|
||||
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import {
|
||||
SelectedItemsProvider,
|
||||
useSelectedItems,
|
||||
} from "~/components/primitives/SelectedItemsProvider";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { StepNumber } from "~/components/primitives/StepNumber";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
import { RunsFilters, TaskRunListSearchFilters } from "~/components/runs/v3/RunFilters";
|
||||
import { TaskRunsTable } from "~/components/runs/v3/TaskRunsTable";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
@@ -19,11 +36,9 @@ import { useUser } from "~/hooks/useUser";
|
||||
import { RunListPresenter } from "~/presenters/v3/RunListPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { ProjectParamSchema, v3ProjectPath, v3TestPath } from "~/utils/pathBuilder";
|
||||
import { ProjectParamSchema, v3ProjectPath, v3RunsPath, v3TestPath } from "~/utils/pathBuilder";
|
||||
import { ListPagination } from "../../components/ListPagination";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { Suspense } from "react";
|
||||
import { BULK_ACTION_RUN_LIMIT } from "~/consts";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
@@ -37,8 +52,9 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
environments: url.searchParams.getAll("environments"),
|
||||
tasks: url.searchParams.getAll("tasks"),
|
||||
period: url.searchParams.get("period") ?? undefined,
|
||||
bulkId: url.searchParams.get("bulkId") ?? undefined,
|
||||
};
|
||||
const { tasks, versions, statuses, environments, period, from, to, cursor, direction } =
|
||||
const { tasks, versions, statuses, environments, period, bulkId, from, to, cursor, direction } =
|
||||
TaskRunListSearchFilters.parse(s);
|
||||
|
||||
const presenter = new RunListPresenter();
|
||||
@@ -50,6 +66,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
statuses,
|
||||
environments,
|
||||
period,
|
||||
bulkId,
|
||||
from,
|
||||
to,
|
||||
direction: direction,
|
||||
@@ -73,60 +90,246 @@ export default function Page() {
|
||||
<NavBar>
|
||||
<PageTitle title="Runs" />
|
||||
</NavBar>
|
||||
<PageBody>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex items-center justify-center py-2">
|
||||
<div className="mx-auto flex items-center gap-2">
|
||||
<Spinner />
|
||||
<Paragraph variant="small">Loading runs</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<PageBody scrollable={false}>
|
||||
<SelectedItemsProvider
|
||||
initialSelectedItems={[]}
|
||||
maxSelectedItemCount={BULK_ACTION_RUN_LIMIT}
|
||||
>
|
||||
<TypedAwait resolve={data}>
|
||||
{(list) => (
|
||||
<>
|
||||
{list.runs.length === 0 && !list.hasFilters ? (
|
||||
list.possibleTasks.length === 0 ? (
|
||||
<CreateFirstTaskInstructions />
|
||||
) : (
|
||||
<RunTaskInstructions />
|
||||
)
|
||||
) : (
|
||||
<div className={cn("grid h-fit grid-cols-1 gap-4")}>
|
||||
<div>
|
||||
<div className="mb-2 flex items-start justify-between gap-x-2">
|
||||
<RunsFilters
|
||||
possibleEnvironments={project.environments}
|
||||
possibleTasks={list.possibleTasks}
|
||||
hasFilters={list.hasFilters}
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
<ListPagination list={list} />
|
||||
</div>
|
||||
{({ selectedItems }) => (
|
||||
<div
|
||||
className={cn(
|
||||
"grid h-full max-h-full overflow-hidden",
|
||||
selectedItems.size === 0 ? "grid-rows-1" : "grid-rows-[1fr_2.5rem]"
|
||||
)}
|
||||
>
|
||||
<div className="overflow-y-auto p-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex items-center justify-center py-2">
|
||||
<div className="mx-auto flex items-center gap-2">
|
||||
<Spinner />
|
||||
<Paragraph variant="small">Loading runs</Paragraph>
|
||||
</div>
|
||||
|
||||
<TaskRunsTable
|
||||
total={list.runs.length}
|
||||
hasFilters={list.hasFilters}
|
||||
filters={list.filters}
|
||||
runs={list.runs}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<ListPagination list={list} className="mt-2 justify-end" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TypedAwait>
|
||||
</Suspense>
|
||||
}
|
||||
>
|
||||
<TypedAwait resolve={data}>
|
||||
{(list) => (
|
||||
<>
|
||||
{list.runs.length === 0 && !list.hasFilters ? (
|
||||
list.possibleTasks.length === 0 ? (
|
||||
<CreateFirstTaskInstructions />
|
||||
) : (
|
||||
<RunTaskInstructions />
|
||||
)
|
||||
) : (
|
||||
<div className={cn("grid h-fit grid-cols-1 gap-4")}>
|
||||
<div>
|
||||
<div className="mb-2 flex items-start justify-between gap-x-2">
|
||||
<RunsFilters
|
||||
possibleEnvironments={project.environments}
|
||||
possibleTasks={list.possibleTasks}
|
||||
bulkActions={list.bulkActions}
|
||||
hasFilters={list.hasFilters}
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
<ListPagination list={list} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TaskRunsTable
|
||||
total={list.runs.length}
|
||||
hasFilters={list.hasFilters}
|
||||
filters={list.filters}
|
||||
runs={list.runs}
|
||||
isLoading={isLoading}
|
||||
allowSelection
|
||||
/>
|
||||
<ListPagination list={list} className="mt-2 justify-end" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TypedAwait>
|
||||
</Suspense>
|
||||
</div>
|
||||
<BulkActionBar />
|
||||
</div>
|
||||
)}
|
||||
</SelectedItemsProvider>
|
||||
</PageBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function BulkActionBar() {
|
||||
const { selectedItems, deselectAll } = useSelectedItems();
|
||||
const [barState, setBarState] = useState<"none" | "replay" | "cancel">("none");
|
||||
|
||||
const hasSelectedMaximum = selectedItems.size >= BULK_ACTION_RUN_LIMIT;
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{selectedItems.size > 0 && (
|
||||
<motion.div
|
||||
initial={{ translateY: "100%" }}
|
||||
animate={{ translateY: 0 }}
|
||||
exit={{ translateY: "100%" }}
|
||||
className="flex items-center justify-between gap-2 border-t border-grid-bright bg-background-bright px-3"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 text-sm text-text-bright">
|
||||
<span className="font-medium">Bulk actions:</span>
|
||||
{hasSelectedMaximum ? (
|
||||
<span className="text-warning">Maximum of {selectedItems.size} runs selected</span>
|
||||
) : (
|
||||
<span>{selectedItems.size} runs selected</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 divide-x divide-charcoal-700">
|
||||
<CancelRuns
|
||||
onOpen={(o) => {
|
||||
if (o) {
|
||||
setBarState("cancel");
|
||||
} else {
|
||||
setBarState("none");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<ReplayRuns
|
||||
onOpen={(o) => {
|
||||
if (o) {
|
||||
setBarState("replay");
|
||||
} else {
|
||||
setBarState("none");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="minimal/medium"
|
||||
shortcut={{ key: "esc", enabledOnInputElements: true }}
|
||||
onClick={() => {
|
||||
if (barState !== "none") return;
|
||||
deselectAll();
|
||||
}}
|
||||
>
|
||||
Clear selection
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
function CancelRuns({ onOpen }: { onOpen: (open: boolean) => void }) {
|
||||
const { selectedItems } = useSelectedItems();
|
||||
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const failedRedirect = v3RunsPath(organization, project);
|
||||
|
||||
const formAction = `/resources/taskruns/bulk/cancel`;
|
||||
|
||||
const navigation = useNavigation();
|
||||
const isLoading = navigation.formAction === formAction;
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={(o) => onOpen(o)}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="minimal/medium"
|
||||
shortcut={{ key: "c", enabledOnInputElements: true }}
|
||||
LeadingIcon={StopCircleIcon}
|
||||
>
|
||||
Cancel runs
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent key="replay">
|
||||
<DialogHeader>Cancel {selectedItems.size} runs?</DialogHeader>
|
||||
<DialogDescription>
|
||||
Canceling these runs will stop them from running. Only runs that are not already finished
|
||||
will be canceled, the others will remain in their existing state.
|
||||
</DialogDescription>
|
||||
<DialogFooter>
|
||||
<Form action={formAction} method="post" reloadDocument>
|
||||
<input type="hidden" name="failedRedirect" value={failedRedirect} />
|
||||
<input type="hidden" name="organizationSlug" value={organization.slug} />
|
||||
<input type="hidden" name="projectSlug" value={project.slug} />
|
||||
{[...selectedItems].map((runId) => (
|
||||
<input key={runId} type="hidden" name="runIds" value={runId} />
|
||||
))}
|
||||
<Button
|
||||
type="submit"
|
||||
variant="danger/small"
|
||||
LeadingIcon={isLoading ? "spinner-white" : StopCircleIcon}
|
||||
disabled={isLoading}
|
||||
shortcut={{ modifiers: ["meta"], key: "enter" }}
|
||||
>
|
||||
{isLoading ? "Canceling..." : `Cancel ${selectedItems.size} runs`}
|
||||
</Button>
|
||||
</Form>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ReplayRuns({ onOpen }: { onOpen: (open: boolean) => void }) {
|
||||
const { selectedItems } = useSelectedItems();
|
||||
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const failedRedirect = v3RunsPath(organization, project);
|
||||
|
||||
const formAction = `/resources/taskruns/bulk/replay`;
|
||||
|
||||
const navigation = useNavigation();
|
||||
const isLoading = navigation.formAction === formAction;
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={(o) => onOpen(o)}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="minimal/medium"
|
||||
shortcut={{ key: "r", enabledOnInputElements: true }}
|
||||
LeadingIcon={ArrowPathIcon}
|
||||
>
|
||||
Replay {selectedItems.size} runs
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent key="replay">
|
||||
<DialogHeader>Replay runs?</DialogHeader>
|
||||
<DialogDescription>
|
||||
Replaying these runs will create a new run for each with the same payload and environment
|
||||
as the original. It will use the latest version of the code for each task.
|
||||
</DialogDescription>
|
||||
<DialogFooter>
|
||||
<Form action={formAction} method="post" reloadDocument>
|
||||
<input type="hidden" name="failedRedirect" value={failedRedirect} />
|
||||
<input type="hidden" name="organizationSlug" value={organization.slug} />
|
||||
<input type="hidden" name="projectSlug" value={project.slug} />
|
||||
{[...selectedItems].map((runId) => (
|
||||
<input key={runId} type="hidden" name="runIds" value={runId} />
|
||||
))}
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary/small"
|
||||
LeadingIcon={isLoading ? "spinner-white" : ArrowPathIcon}
|
||||
disabled={isLoading}
|
||||
shortcut={{ modifiers: ["meta"], key: "enter" }}
|
||||
>
|
||||
{isLoading ? "Replaying..." : `Replay ${selectedItems.size} runs`}
|
||||
</Button>
|
||||
</Form>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateFirstTaskInstructions() {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
|
||||
@@ -16,13 +16,17 @@ import { FormTitle } from "~/components/primitives/FormTitle";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { InputGroup } from "~/components/primitives/InputGroup";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { Select, SelectItem } from "~/components/primitives/Select";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
import { prisma } from "~/db.server";
|
||||
import { useFeatures } from "~/hooks/useFeatures";
|
||||
import { useUser } from "~/hooks/useUser";
|
||||
import { redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { createProject } from "~/models/project.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { OrganizationParamsSchema, organizationPath, projectPath } from "~/utils/pathBuilder";
|
||||
import { RequestV3Access } from "../resources.orgs.$organizationSlug.v3-access";
|
||||
|
||||
export async function loader({ params, request }: LoaderFunctionArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
@@ -34,10 +38,14 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
|
||||
id: true,
|
||||
title: true,
|
||||
v3Enabled: true,
|
||||
v2Enabled: true,
|
||||
hasRequestedV3: true,
|
||||
_count: {
|
||||
select: {
|
||||
projects: {
|
||||
where: { deletedAt: null },
|
||||
where: {
|
||||
deletedAt: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -57,6 +65,8 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
|
||||
slug: organizationSlug,
|
||||
projectsCount: organization._count.projects,
|
||||
v3Enabled: organization.v3Enabled,
|
||||
v2Enabled: organization.v2Enabled,
|
||||
hasRequestedV3: organization.hasRequestedV3,
|
||||
},
|
||||
defaultVersion: url.searchParams.get("version") ?? "v2",
|
||||
});
|
||||
@@ -98,11 +108,23 @@ export const action: ActionFunction = async ({ request, params }) => {
|
||||
};
|
||||
|
||||
export default function NewOrganizationPage() {
|
||||
const { organization, defaultVersion } = useTypedLoaderData<typeof loader>();
|
||||
const { organization } = useTypedLoaderData<typeof loader>();
|
||||
const lastSubmission = useActionData();
|
||||
const { v3Enabled } = useFeatures();
|
||||
const { v3Enabled, isManagedCloud } = useFeatures();
|
||||
|
||||
const canCreateV3Projects = organization.v3Enabled && v3Enabled;
|
||||
const canCreateV2Projects = organization.v2Enabled || !isManagedCloud;
|
||||
const canCreateProjects = canCreateV2Projects || canCreateV3Projects;
|
||||
|
||||
if (!canCreateProjects) {
|
||||
return (
|
||||
<RequestV3Access
|
||||
hasRequestedV3={organization.hasRequestedV3}
|
||||
organizationSlug={organization.slug}
|
||||
projectsCount={organization.projectsCount}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const [form, { projectName, projectVersion }] = useForm({
|
||||
id: "create-project",
|
||||
@@ -119,7 +141,7 @@ export default function NewOrganizationPage() {
|
||||
<FormTitle
|
||||
LeadingIcon="folder"
|
||||
title="Create a new project"
|
||||
description={`This will create a new project in your "${organization.title}" organization. `}
|
||||
description={`This will create a new project in your "${organization.title}" organization.`}
|
||||
/>
|
||||
<Form method="post" {...form.props}>
|
||||
{organization.projectsCount === 0 && (
|
||||
@@ -138,7 +160,7 @@ export default function NewOrganizationPage() {
|
||||
/>
|
||||
<FormError id={projectName.errorId}>{projectName.error}</FormError>
|
||||
</InputGroup>
|
||||
{canCreateV3Projects ? (
|
||||
{canCreateV2Projects && canCreateV3Projects ? (
|
||||
<InputGroup>
|
||||
<Label htmlFor={projectVersion.id}>Project version</Label>
|
||||
<Select
|
||||
@@ -161,8 +183,16 @@ export default function NewOrganizationPage() {
|
||||
</Select>
|
||||
<FormError id={projectVersion.errorId}>{projectVersion.error}</FormError>
|
||||
</InputGroup>
|
||||
) : canCreateV3Projects ? (
|
||||
<>
|
||||
<Callout variant="info">This will be a v3 project</Callout>
|
||||
<input {...conform.input(projectVersion, { type: "hidden" })} value={"v3"} />
|
||||
</>
|
||||
) : (
|
||||
<input {...conform.input(projectVersion, { type: "hidden" })} value="v2" />
|
||||
<>
|
||||
<Callout variant="info">This will be a v2 project</Callout>
|
||||
<input {...conform.input(projectVersion, { type: "hidden" })} value={"v2"} />
|
||||
</>
|
||||
)}
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
|
||||
@@ -17,19 +17,14 @@ import { Input } from "~/components/primitives/Input";
|
||||
import { InputGroup } from "~/components/primitives/InputGroup";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { RadioGroupItem } from "~/components/primitives/RadioButton";
|
||||
import { Select, SelectItem } from "~/components/primitives/Select";
|
||||
import { featuresForRequest } from "~/features.server";
|
||||
import { useFeatures } from "~/hooks/useFeatures";
|
||||
import { createOrganization } from "~/models/organization.server";
|
||||
import { NewOrganizationPresenter } from "~/presenters/NewOrganizationPresenter.server";
|
||||
import { commitCurrentProjectSession, setCurrentProjectId } from "~/services/currentProject.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { projectPath, rootPath, selectPlanPath } from "~/utils/pathBuilder";
|
||||
import { organizationPath, rootPath } from "~/utils/pathBuilder";
|
||||
|
||||
const schema = z.object({
|
||||
orgName: z.string().min(3).max(50),
|
||||
projectName: z.string().min(3).max(50),
|
||||
projectVersion: z.enum(["v2", "v3"]),
|
||||
companySize: z.string().optional(),
|
||||
});
|
||||
|
||||
@@ -57,29 +52,10 @@ export const action: ActionFunction = async ({ request }) => {
|
||||
const organization = await createOrganization({
|
||||
title: submission.value.orgName,
|
||||
userId,
|
||||
projectName: submission.value.projectName,
|
||||
companySize: submission.value.companySize ?? null,
|
||||
projectVersion: submission.value.projectVersion,
|
||||
});
|
||||
|
||||
const project = organization.projects[0];
|
||||
const session = await setCurrentProjectId(project.id, request);
|
||||
|
||||
const { isManagedCloud } = featuresForRequest(request);
|
||||
|
||||
const headers = {
|
||||
"Set-Cookie": await commitCurrentProjectSession(session),
|
||||
};
|
||||
|
||||
if (isManagedCloud && submission.value.projectVersion === "v2") {
|
||||
return redirect(selectPlanPath(organization), {
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
return redirect(projectPath(organization, project), {
|
||||
headers,
|
||||
});
|
||||
return redirect(organizationPath(organization));
|
||||
} catch (error: any) {
|
||||
return json({ errors: { body: error.message } }, { status: 400 });
|
||||
}
|
||||
@@ -91,10 +67,7 @@ export default function NewOrganizationPage() {
|
||||
const { isManagedCloud } = useFeatures();
|
||||
const navigation = useNavigation();
|
||||
|
||||
//this is temporary whilst v3 is invite-only. Switch to the useFeatures value when v3 is generally available.
|
||||
const v3Enabled = false;
|
||||
|
||||
const [form, { orgName, projectName, projectVersion }] = useForm({
|
||||
const [form, { orgName }] = useForm({
|
||||
id: "create-organization",
|
||||
// TODO: type this
|
||||
lastSubmission: lastSubmission as any,
|
||||
@@ -123,45 +96,9 @@ export default function NewOrganizationPage() {
|
||||
<Hint>E.g. your company name or your workspace name.</Hint>
|
||||
<FormError id={orgName.errorId}>{orgName.error}</FormError>
|
||||
</InputGroup>
|
||||
<InputGroup>
|
||||
<Label htmlFor={projectName.id}>Project name</Label>
|
||||
<Input
|
||||
{...conform.input(projectName, { type: "text" })}
|
||||
placeholder="Your Project name"
|
||||
icon="folder"
|
||||
/>
|
||||
<Hint>Your Jobs will live inside this Project.</Hint>
|
||||
<FormError id={projectName.errorId}>{projectName.error}</FormError>
|
||||
</InputGroup>
|
||||
{v3Enabled ? (
|
||||
<InputGroup>
|
||||
<Label htmlFor={projectVersion.id}>Project version</Label>
|
||||
<Select
|
||||
{...conform.select(projectVersion)}
|
||||
defaultValue={undefined}
|
||||
variant="tertiary/medium"
|
||||
placeholder="Select version"
|
||||
dropdownIcon
|
||||
text={(value) => {
|
||||
switch (value) {
|
||||
case "v2":
|
||||
return "Version 2";
|
||||
case "v3":
|
||||
return "Version 3";
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectItem value="v2">Version 2</SelectItem>
|
||||
<SelectItem value="v3">Version 3 (Developer Preview)</SelectItem>
|
||||
</Select>
|
||||
<FormError id={projectVersion.errorId}>{projectVersion.error}</FormError>
|
||||
</InputGroup>
|
||||
) : (
|
||||
<input {...conform.input(projectVersion, { type: "hidden" })} value="v2" />
|
||||
)}
|
||||
{isManagedCloud && (
|
||||
<InputGroup>
|
||||
<Label htmlFor={projectName.id}>Number of employees</Label>
|
||||
<Label htmlFor={"companySize"}>Number of employees</Label>
|
||||
<RadioGroup name="companySize" className="flex items-center justify-between gap-2">
|
||||
<RadioGroupItem
|
||||
id="employees-1-5"
|
||||
|
||||
@@ -6,7 +6,7 @@ import { z } from "zod";
|
||||
import { UserProfilePhoto } from "~/components/UserProfilePhoto";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { Checkbox } from "~/components/primitives/Checkbox";
|
||||
import { CheckboxWithLabel } from "~/components/primitives/Checkbox";
|
||||
import { Fieldset } from "~/components/primitives/Fieldset";
|
||||
import { FormButtons } from "~/components/primitives/FormButtons";
|
||||
import { FormError } from "~/components/primitives/FormError";
|
||||
@@ -158,7 +158,7 @@ export default function Page() {
|
||||
</InputGroup>
|
||||
<InputGroup>
|
||||
<Label>Notifications</Label>
|
||||
<Checkbox
|
||||
<CheckboxWithLabel
|
||||
id="marketingEmails"
|
||||
{...conform.input(marketingEmails, { type: "checkbox" })}
|
||||
label="Receive onboarding emails"
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { LoaderFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import { prisma } from "~/db.server";
|
||||
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
|
||||
import { marqs } from "~/v3/marqs/index.server";
|
||||
|
||||
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 details = await marqs?.getSharedQueueDetails();
|
||||
|
||||
return json(details);
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
import { CreateExternalConnectionBody } from "@trigger.dev/core";
|
||||
import { PrismaClientOrTransaction, prisma } from "~/db.server";
|
||||
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { integrationAuthRepository } from "~/services/externalApis/integrationAuthRepository.server";
|
||||
|
||||
export class CreateExternalConnectionService {
|
||||
#prismaClient: PrismaClientOrTransaction;
|
||||
|
||||
constructor(prismaClient: PrismaClientOrTransaction = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
public async call(
|
||||
accountIdentifier: string,
|
||||
clientSlug: string,
|
||||
environment: AuthenticatedEnvironment,
|
||||
payload: CreateExternalConnectionBody
|
||||
) {
|
||||
const externalAccount = await this.#prismaClient.externalAccount.upsert({
|
||||
where: {
|
||||
environmentId_identifier: {
|
||||
environmentId: environment.id,
|
||||
identifier: accountIdentifier,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
environmentId: environment.id,
|
||||
organizationId: environment.organizationId,
|
||||
identifier: accountIdentifier,
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
|
||||
const integration = await this.#prismaClient.integration.findUniqueOrThrow({
|
||||
where: {
|
||||
organizationId_slug: {
|
||||
organizationId: environment.organizationId,
|
||||
slug: clientSlug,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return await integrationAuthRepository.createConnectionFromToken({
|
||||
externalAccount: externalAccount,
|
||||
integration,
|
||||
token: payload,
|
||||
});
|
||||
}
|
||||
}
|
||||
+3
-53
@@ -1,15 +1,10 @@
|
||||
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import {
|
||||
CreateExternalConnectionBody,
|
||||
CreateExternalConnectionBodySchema,
|
||||
ErrorWithStackSchema,
|
||||
} from "@trigger.dev/core";
|
||||
import { CreateExternalConnectionBodySchema, ErrorWithStackSchema } from "@trigger.dev/core";
|
||||
import { z } from "zod";
|
||||
import { generateErrorMessage } from "zod-error";
|
||||
import { PrismaClientOrTransaction, prisma } from "~/db.server";
|
||||
import { AuthenticatedEnvironment, authenticateApiRequest } from "~/services/apiAuth.server";
|
||||
import { integrationAuthRepository } from "~/services/externalApis/integrationAuthRepository.server";
|
||||
import { authenticateApiRequest } from "~/services/apiAuth.server";
|
||||
import { CreateExternalConnectionService } from "./CreateExternalConnectionService.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
accountId: z.string(),
|
||||
@@ -67,48 +62,3 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
||||
return json({ message: parsedError.data.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
class CreateExternalConnectionService {
|
||||
#prismaClient: PrismaClientOrTransaction;
|
||||
|
||||
constructor(prismaClient: PrismaClientOrTransaction = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
public async call(
|
||||
accountIdentifier: string,
|
||||
clientSlug: string,
|
||||
environment: AuthenticatedEnvironment,
|
||||
payload: CreateExternalConnectionBody
|
||||
) {
|
||||
const externalAccount = await this.#prismaClient.externalAccount.upsert({
|
||||
where: {
|
||||
environmentId_identifier: {
|
||||
environmentId: environment.id,
|
||||
identifier: accountIdentifier,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
environmentId: environment.id,
|
||||
organizationId: environment.organizationId,
|
||||
identifier: accountIdentifier,
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
|
||||
const integration = await this.#prismaClient.integration.findUniqueOrThrow({
|
||||
where: {
|
||||
organizationId_slug: {
|
||||
organizationId: environment.organizationId,
|
||||
slug: clientSlug,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return await integrationAuthRepository.createConnectionFromToken({
|
||||
externalAccount: externalAccount,
|
||||
integration,
|
||||
token: payload,
|
||||
});
|
||||
}
|
||||
}
|
||||
+5
-68
@@ -1,73 +1,10 @@
|
||||
import { ActionFunctionArgs, LoaderFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import { RuntimeEnvironmentType } from "@trigger.dev/database";
|
||||
import { z } from "zod";
|
||||
import { $transaction, PrismaClient, prisma } from "~/db.server";
|
||||
import type { PrismaClient } from "~/db.server";
|
||||
import { $transaction, prisma } from "~/db.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { workerQueue } from "~/services/worker.server";
|
||||
import { safeJsonParse } from "~/utils/json";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
environmentId: z.string(),
|
||||
endpointSlug: z.string(),
|
||||
indexHookIdentifier: z.string(),
|
||||
});
|
||||
|
||||
export async function loader({ params }: LoaderFunctionArgs) {
|
||||
const parsedParams = ParamsSchema.safeParse(params);
|
||||
|
||||
if (!parsedParams.success) {
|
||||
return {
|
||||
status: 400,
|
||||
json: {
|
||||
error: "Invalid params",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const { environmentId, endpointSlug, indexHookIdentifier } = parsedParams.data;
|
||||
|
||||
const service = new TriggerEndpointIndexHookService();
|
||||
|
||||
await service.call({
|
||||
environmentId,
|
||||
endpointSlug,
|
||||
indexHookIdentifier,
|
||||
});
|
||||
|
||||
return json({
|
||||
ok: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function action({ request, params }: ActionFunctionArgs) {
|
||||
const parsedParams = ParamsSchema.safeParse(params);
|
||||
|
||||
if (!parsedParams.success) {
|
||||
return {
|
||||
status: 400,
|
||||
json: {
|
||||
error: "Invalid params",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const { environmentId, endpointSlug, indexHookIdentifier } = parsedParams.data;
|
||||
|
||||
const body = await request.text();
|
||||
|
||||
const service = new TriggerEndpointIndexHookService();
|
||||
|
||||
await service.call({
|
||||
environmentId,
|
||||
endpointSlug,
|
||||
indexHookIdentifier,
|
||||
body: body ? safeJsonParse(body) : undefined,
|
||||
});
|
||||
|
||||
return json({
|
||||
ok: true,
|
||||
});
|
||||
}
|
||||
import { RuntimeEnvironmentType } from "~/database-types";
|
||||
import type { ParamsSchema } from "./route";
|
||||
|
||||
type TriggerEndpointDeployHookOptions = z.infer<typeof ParamsSchema> & {
|
||||
body?: any;
|
||||
@@ -106,7 +43,7 @@ export class TriggerEndpointIndexHookService {
|
||||
},
|
||||
});
|
||||
|
||||
if (!endpoint) {
|
||||
if (!endpoint || !endpoint.url) {
|
||||
throw new Error("Endpoint not found");
|
||||
}
|
||||
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
import { ActionFunctionArgs, LoaderFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { safeJsonParse } from "~/utils/json";
|
||||
import { TriggerEndpointIndexHookService } from "./TriggerEndpointIndexHookService.server";
|
||||
|
||||
export const ParamsSchema = z.object({
|
||||
environmentId: z.string(),
|
||||
endpointSlug: z.string(),
|
||||
indexHookIdentifier: z.string(),
|
||||
});
|
||||
|
||||
export async function loader({ params }: LoaderFunctionArgs) {
|
||||
const parsedParams = ParamsSchema.safeParse(params);
|
||||
|
||||
if (!parsedParams.success) {
|
||||
return {
|
||||
status: 400,
|
||||
json: {
|
||||
error: "Invalid params",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const { environmentId, endpointSlug, indexHookIdentifier } = parsedParams.data;
|
||||
|
||||
const service = new TriggerEndpointIndexHookService();
|
||||
|
||||
await service.call({
|
||||
environmentId,
|
||||
endpointSlug,
|
||||
indexHookIdentifier,
|
||||
});
|
||||
|
||||
return json({
|
||||
ok: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function action({ request, params }: ActionFunctionArgs) {
|
||||
const parsedParams = ParamsSchema.safeParse(params);
|
||||
|
||||
if (!parsedParams.success) {
|
||||
return {
|
||||
status: 400,
|
||||
json: {
|
||||
error: "Invalid params",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const { environmentId, endpointSlug, indexHookIdentifier } = parsedParams.data;
|
||||
|
||||
const body = await request.text();
|
||||
|
||||
const service = new TriggerEndpointIndexHookService();
|
||||
|
||||
await service.call({
|
||||
environmentId,
|
||||
endpointSlug,
|
||||
indexHookIdentifier,
|
||||
body: body ? safeJsonParse(body) : undefined,
|
||||
});
|
||||
|
||||
return json({
|
||||
ok: true,
|
||||
});
|
||||
}
|
||||
@@ -54,6 +54,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
||||
email: body.data.channelData.email,
|
||||
},
|
||||
deduplicationKey: body.data.deduplicationKey,
|
||||
environmentTypes: body.data.environmentTypes,
|
||||
});
|
||||
|
||||
return json(await ApiAlertChannelPresenter.alertChannelToApi(alertChannel));
|
||||
@@ -75,6 +76,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
||||
secret: body.data.channelData.secret,
|
||||
},
|
||||
deduplicationKey: body.data.deduplicationKey,
|
||||
environmentTypes: body.data.environmentTypes,
|
||||
});
|
||||
|
||||
return json(await ApiAlertChannelPresenter.alertChannelToApi(alertChannel));
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { ActionFunctionArgs, LoaderFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import { UpdateEnvironmentVariableRequestBody } from "@trigger.dev/core/v3";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "~/db.server";
|
||||
import {
|
||||
authenticateProjectApiKeyOrPersonalAccessToken,
|
||||
authenticatedEnvironmentForAuthentication,
|
||||
} from "~/services/apiAuth.server";
|
||||
import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
projectRef: z.string(),
|
||||
slug: z.string(),
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
export async function action({ params, request }: ActionFunctionArgs) {
|
||||
const parsedParams = ParamsSchema.safeParse(params);
|
||||
|
||||
if (!parsedParams.success) {
|
||||
return json({ error: "Invalid params" }, { status: 400 });
|
||||
}
|
||||
|
||||
const authenticationResult = await authenticateProjectApiKeyOrPersonalAccessToken(request);
|
||||
|
||||
if (!authenticationResult) {
|
||||
return json({ error: "Invalid or Missing API key" }, { status: 401 });
|
||||
}
|
||||
|
||||
const environment = await authenticatedEnvironmentForAuthentication(
|
||||
authenticationResult,
|
||||
parsedParams.data.projectRef,
|
||||
parsedParams.data.slug
|
||||
);
|
||||
|
||||
// Find the environment variable
|
||||
const variable = await prisma.environmentVariable.findFirst({
|
||||
where: {
|
||||
key: parsedParams.data.name,
|
||||
projectId: environment.project.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!variable) {
|
||||
return json({ error: "Environment variable not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const repository = new EnvironmentVariablesRepository();
|
||||
|
||||
switch (request.method.toUpperCase()) {
|
||||
case "DELETE": {
|
||||
const result = await repository.deleteValue(environment.project.id, {
|
||||
id: variable.id,
|
||||
environmentId: environment.id,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
return json({ success: true });
|
||||
} else {
|
||||
return json({ error: result.error }, { status: 400 });
|
||||
}
|
||||
}
|
||||
case "PUT":
|
||||
case "POST": {
|
||||
const jsonBody = await request.json();
|
||||
|
||||
const body = UpdateEnvironmentVariableRequestBody.safeParse(jsonBody);
|
||||
|
||||
if (!body.success) {
|
||||
return json({ error: "Invalid request body", issues: body.error.issues }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await repository.edit(environment.project.id, {
|
||||
values: [
|
||||
{
|
||||
value: body.data.value,
|
||||
environmentId: environment.id,
|
||||
},
|
||||
],
|
||||
id: variable.id,
|
||||
keepEmptyValues: true,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
return json({ success: true });
|
||||
} else {
|
||||
return json({ error: result.error }, { status: 400 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function loader({ params, request }: LoaderFunctionArgs) {
|
||||
const parsedParams = ParamsSchema.safeParse(params);
|
||||
|
||||
if (!parsedParams.success) {
|
||||
return json({ error: "Invalid params" }, { status: 400 });
|
||||
}
|
||||
|
||||
const authenticationResult = await authenticateProjectApiKeyOrPersonalAccessToken(request);
|
||||
|
||||
if (!authenticationResult) {
|
||||
return json({ error: "Invalid or Missing API key" }, { status: 401 });
|
||||
}
|
||||
|
||||
const environment = await authenticatedEnvironmentForAuthentication(
|
||||
authenticationResult,
|
||||
parsedParams.data.projectRef,
|
||||
parsedParams.data.slug
|
||||
);
|
||||
|
||||
// Find the environment variable
|
||||
const variable = await prisma.environmentVariable.findFirst({
|
||||
where: {
|
||||
key: parsedParams.data.name,
|
||||
projectId: environment.project.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!variable) {
|
||||
return json({ error: "Environment variable not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const repository = new EnvironmentVariablesRepository();
|
||||
|
||||
const variables = await repository.getEnvironment(environment.project.id, environment.id, true);
|
||||
|
||||
const environmentVariable = variables.find((v) => v.key === parsedParams.data.name);
|
||||
|
||||
if (!environmentVariable) {
|
||||
return json({ error: "Environment variable not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return json({
|
||||
value: environmentVariable.value,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { ActionFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import { ImportEnvironmentVariablesRequestBody } from "@trigger.dev/core/v3";
|
||||
import { parse } from "dotenv";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
authenticateProjectApiKeyOrPersonalAccessToken,
|
||||
authenticatedEnvironmentForAuthentication,
|
||||
} from "~/services/apiAuth.server";
|
||||
import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
projectRef: z.string(),
|
||||
slug: z.string(),
|
||||
});
|
||||
|
||||
export async function action({ params, request }: ActionFunctionArgs) {
|
||||
const parsedParams = ParamsSchema.safeParse(params);
|
||||
|
||||
if (!parsedParams.success) {
|
||||
return json({ error: "Invalid params" }, { status: 400 });
|
||||
}
|
||||
|
||||
const authenticationResult = await authenticateProjectApiKeyOrPersonalAccessToken(request);
|
||||
|
||||
if (!authenticationResult) {
|
||||
return json({ error: "Invalid or Missing API key" }, { status: 401 });
|
||||
}
|
||||
|
||||
const environment = await authenticatedEnvironmentForAuthentication(
|
||||
authenticationResult,
|
||||
parsedParams.data.projectRef,
|
||||
parsedParams.data.slug
|
||||
);
|
||||
|
||||
const repository = new EnvironmentVariablesRepository();
|
||||
|
||||
const body = await parseImportBody(request);
|
||||
|
||||
const result = await repository.create(environment.project.id, {
|
||||
override: typeof body.override === "boolean" ? body.override : false,
|
||||
environmentIds: [environment.id],
|
||||
variables: Object.entries(body.variables).map(([key, value]) => ({
|
||||
key,
|
||||
value,
|
||||
})),
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
return json({ success: true });
|
||||
} else {
|
||||
return json({ error: result.error, variableErrors: result.variableErrors }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
async function parseImportBody(request: Request): Promise<ImportEnvironmentVariablesRequestBody> {
|
||||
const contentType = request.headers.get("content-type") ?? "application/json";
|
||||
|
||||
if (contentType.includes("multipart/form-data")) {
|
||||
const formData = await request.formData();
|
||||
|
||||
const file = formData.get("variables");
|
||||
const override = formData.get("override") === "true";
|
||||
|
||||
if (file instanceof File) {
|
||||
const buffer = await file.arrayBuffer();
|
||||
|
||||
const variables = parse(Buffer.from(buffer));
|
||||
|
||||
return { variables, override };
|
||||
} else {
|
||||
throw json({ error: "Invalid file" }, { status: 400 });
|
||||
}
|
||||
} else {
|
||||
const rawBody = await request.json();
|
||||
|
||||
const body = ImportEnvironmentVariablesRequestBody.safeParse(rawBody);
|
||||
|
||||
if (!body.success) {
|
||||
throw json({ error: "Invalid body" }, { status: 400 });
|
||||
}
|
||||
|
||||
return body.data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { ActionFunctionArgs, LoaderFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import { CreateEnvironmentVariableRequestBody } from "@trigger.dev/core/v3";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
authenticateProjectApiKeyOrPersonalAccessToken,
|
||||
authenticatedEnvironmentForAuthentication,
|
||||
} from "~/services/apiAuth.server";
|
||||
import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
projectRef: z.string(),
|
||||
slug: z.string(),
|
||||
});
|
||||
|
||||
export async function action({ params, request }: ActionFunctionArgs) {
|
||||
const parsedParams = ParamsSchema.safeParse(params);
|
||||
|
||||
if (!parsedParams.success) {
|
||||
return json({ error: "Invalid params" }, { status: 400 });
|
||||
}
|
||||
|
||||
const authenticationResult = await authenticateProjectApiKeyOrPersonalAccessToken(request);
|
||||
|
||||
if (!authenticationResult) {
|
||||
return json({ error: "Invalid or Missing API key" }, { status: 401 });
|
||||
}
|
||||
|
||||
const environment = await authenticatedEnvironmentForAuthentication(
|
||||
authenticationResult,
|
||||
parsedParams.data.projectRef,
|
||||
parsedParams.data.slug
|
||||
);
|
||||
|
||||
const jsonBody = await request.json();
|
||||
|
||||
const body = CreateEnvironmentVariableRequestBody.safeParse(jsonBody);
|
||||
|
||||
if (!body.success) {
|
||||
return json({ error: "Invalid request body", issues: body.error.issues }, { status: 400 });
|
||||
}
|
||||
|
||||
const repository = new EnvironmentVariablesRepository();
|
||||
|
||||
const result = await repository.create(environment.project.id, {
|
||||
override: true,
|
||||
environmentIds: [environment.id],
|
||||
variables: [
|
||||
{
|
||||
key: body.data.name,
|
||||
value: body.data.value,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
return json({ success: true });
|
||||
} else {
|
||||
return json({ error: result.error, variableErrors: result.variableErrors }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function loader({ params, request }: LoaderFunctionArgs) {
|
||||
const parsedParams = ParamsSchema.safeParse(params);
|
||||
|
||||
if (!parsedParams.success) {
|
||||
return json({ error: "Invalid params" }, { status: 400 });
|
||||
}
|
||||
|
||||
const authenticationResult = await authenticateProjectApiKeyOrPersonalAccessToken(request);
|
||||
|
||||
if (!authenticationResult) {
|
||||
return json({ error: "Invalid or Missing API key" }, { status: 401 });
|
||||
}
|
||||
|
||||
const environment = await authenticatedEnvironmentForAuthentication(
|
||||
authenticationResult,
|
||||
parsedParams.data.projectRef,
|
||||
parsedParams.data.slug
|
||||
);
|
||||
|
||||
const repository = new EnvironmentVariablesRepository();
|
||||
|
||||
const variables = await repository.getEnvironment(environment.project.id, environment.id, true);
|
||||
|
||||
return json(variables.map((variable) => ({ name: variable.key, value: variable.value })));
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { LogMessage } from "@trigger.dev/core";
|
||||
import type { PrismaClient } from "@trigger.dev/database";
|
||||
import { prisma } from "~/db.server";
|
||||
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
|
||||
export class CreateRunLogService {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
public async call(environment: AuthenticatedEnvironment, runId: string, logMessage: LogMessage) {
|
||||
// @ts-ignore
|
||||
logger.debug(logMessage.message, logMessage.data ?? {});
|
||||
|
||||
return logMessage;
|
||||
}
|
||||
}
|
||||
+2
-21
@@ -1,13 +1,9 @@
|
||||
import type { Organization, RuntimeEnvironment } from "@trigger.dev/database";
|
||||
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import type { LogMessage } from "@trigger.dev/core";
|
||||
import { LogMessageSchema } from "@trigger.dev/core";
|
||||
import { z } from "zod";
|
||||
import type { PrismaClient } from "~/db.server";
|
||||
import { prisma } from "~/db.server";
|
||||
import { authenticateApiRequest, AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { authenticateApiRequest } from "~/services/apiAuth.server";
|
||||
import { CreateRunLogService } from "./CreateRunLogService.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
runId: z.string(),
|
||||
@@ -53,18 +49,3 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
||||
return json({ error: "Something went wrong" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export class CreateRunLogService {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
public async call(environment: AuthenticatedEnvironment, runId: string, logMessage: LogMessage) {
|
||||
// @ts-ignore
|
||||
logger.debug(logMessage.message, logMessage.data ?? {});
|
||||
|
||||
return logMessage;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
StatusUpdate,
|
||||
StatusHistory,
|
||||
StatusHistorySchema,
|
||||
StatusUpdateState,
|
||||
StatusUpdateData,
|
||||
} from "@trigger.dev/core";
|
||||
import { PrismaClient } from "@trigger.dev/database";
|
||||
import { prisma, $transaction } from "~/db.server";
|
||||
|
||||
export class SetStatusService {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
public async call(runId: string, id: string, status: StatusUpdate) {
|
||||
const statusRecord = await $transaction(this.#prismaClient, async (tx) => {
|
||||
const existingStatus = await tx.jobRunStatusRecord.findUnique({
|
||||
where: {
|
||||
runId_key: {
|
||||
runId,
|
||||
key: id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const history: StatusHistory = [];
|
||||
const historyResult = StatusHistorySchema.safeParse(existingStatus?.history);
|
||||
if (historyResult.success) {
|
||||
history.push(...historyResult.data);
|
||||
}
|
||||
if (existingStatus) {
|
||||
history.push({
|
||||
label: existingStatus.label,
|
||||
state: (existingStatus.state ?? undefined) as StatusUpdateState,
|
||||
data: (existingStatus.data ?? undefined) as StatusUpdateData,
|
||||
});
|
||||
}
|
||||
|
||||
const updatedStatus = await tx.jobRunStatusRecord.upsert({
|
||||
where: {
|
||||
runId_key: {
|
||||
runId,
|
||||
key: id,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
key: id,
|
||||
runId,
|
||||
//this shouldn't ever use the id in reality, as the SDK makess it compulsory on the first call
|
||||
label: status.label ?? id,
|
||||
state: status.state,
|
||||
data: status.data as any,
|
||||
history: [],
|
||||
},
|
||||
update: {
|
||||
label: status.label,
|
||||
state: status.state,
|
||||
data: status.data as any,
|
||||
history: history as any[],
|
||||
},
|
||||
});
|
||||
|
||||
return updatedStatus;
|
||||
});
|
||||
|
||||
return statusRecord;
|
||||
}
|
||||
}
|
||||
+2
-72
@@ -1,18 +1,10 @@
|
||||
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import {
|
||||
JobRunStatusRecordSchema,
|
||||
StatusHistory,
|
||||
StatusHistorySchema,
|
||||
StatusUpdate,
|
||||
StatusUpdateData,
|
||||
StatusUpdateSchema,
|
||||
StatusUpdateState,
|
||||
} from "@trigger.dev/core";
|
||||
import { JobRunStatusRecordSchema, StatusUpdateSchema } from "@trigger.dev/core";
|
||||
import { z } from "zod";
|
||||
import { $transaction, PrismaClient, prisma } from "~/db.server";
|
||||
import { authenticateApiRequest } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { SetStatusService } from "./SetStatusService.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
runId: z.string(),
|
||||
@@ -80,65 +72,3 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
||||
return json({ error: "Something went wrong" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export class SetStatusService {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
public async call(runId: string, id: string, status: StatusUpdate) {
|
||||
const statusRecord = await $transaction(this.#prismaClient, async (tx) => {
|
||||
const existingStatus = await tx.jobRunStatusRecord.findUnique({
|
||||
where: {
|
||||
runId_key: {
|
||||
runId,
|
||||
key: id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const history: StatusHistory = [];
|
||||
const historyResult = StatusHistorySchema.safeParse(existingStatus?.history);
|
||||
if (historyResult.success) {
|
||||
history.push(...historyResult.data);
|
||||
}
|
||||
if (existingStatus) {
|
||||
history.push({
|
||||
label: existingStatus.label,
|
||||
state: (existingStatus.state ?? undefined) as StatusUpdateState,
|
||||
data: (existingStatus.data ?? undefined) as StatusUpdateData,
|
||||
});
|
||||
}
|
||||
|
||||
const updatedStatus = await tx.jobRunStatusRecord.upsert({
|
||||
where: {
|
||||
runId_key: {
|
||||
runId,
|
||||
key: id,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
key: id,
|
||||
runId,
|
||||
//this shouldn't ever use the id in reality, as the SDK makess it compulsory on the first call
|
||||
label: status.label ?? id,
|
||||
state: status.state,
|
||||
data: status.data as any,
|
||||
history: [],
|
||||
},
|
||||
update: {
|
||||
label: status.label,
|
||||
state: status.state,
|
||||
data: status.data as any,
|
||||
history: history as any[],
|
||||
},
|
||||
});
|
||||
|
||||
return updatedStatus;
|
||||
});
|
||||
|
||||
return statusRecord;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
import { action } from "./api.v1.tasks.$id.callback.$secret";
|
||||
import { action } from "./api.v1.tasks.$id.callback.$secret/route";
|
||||
|
||||
export { action };
|
||||
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
import type { CompleteTaskBodyOutput, ServerTask } from "@trigger.dev/core";
|
||||
import { PrismaClientOrTransaction, prisma } from "~/db.server";
|
||||
import { taskWithAttemptsToServerTask } from "~/models/task.server";
|
||||
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
|
||||
export class CompleteRunTaskService {
|
||||
#prismaClient: PrismaClientOrTransaction;
|
||||
|
||||
constructor(prismaClient: PrismaClientOrTransaction = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
public async call(
|
||||
environment: AuthenticatedEnvironment,
|
||||
runId: string,
|
||||
id: string,
|
||||
taskBody: CompleteTaskBodyOutput
|
||||
): Promise<ServerTask | undefined> {
|
||||
const existingTask = await this.#prismaClient.task.findUnique({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
include: {
|
||||
run: true,
|
||||
attempts: {
|
||||
where: {
|
||||
status: "PENDING",
|
||||
},
|
||||
orderBy: {
|
||||
number: "desc",
|
||||
},
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingTask) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (existingTask.runId !== runId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (existingTask.run.environmentId !== environment.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
existingTask.status === "COMPLETED" ||
|
||||
existingTask.status === "ERRORED" ||
|
||||
existingTask.status === "CANCELED"
|
||||
) {
|
||||
logger.debug("Task already completed", {
|
||||
existingTask,
|
||||
});
|
||||
|
||||
return taskWithAttemptsToServerTask(existingTask);
|
||||
}
|
||||
|
||||
if (existingTask.attempts.length === 1) {
|
||||
await this.#prismaClient.taskAttempt.update({
|
||||
where: {
|
||||
id: existingTask.attempts[0].id,
|
||||
},
|
||||
data: {
|
||||
status: "COMPLETED",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const updatedTask = await this.#prismaClient.task.update({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
data: {
|
||||
status: "COMPLETED",
|
||||
output: taskBody.output as any,
|
||||
outputIsUndefined: typeof taskBody.output === "undefined",
|
||||
completedAt: new Date(),
|
||||
outputProperties: taskBody.properties,
|
||||
},
|
||||
include: {
|
||||
attempts: true,
|
||||
run: true,
|
||||
},
|
||||
});
|
||||
|
||||
return taskWithAttemptsToServerTask(updatedTask);
|
||||
}
|
||||
}
|
||||
+2
-90
@@ -1,17 +1,16 @@
|
||||
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import type { CompleteTaskBodyOutput, ServerTask } from "@trigger.dev/core";
|
||||
import type { CompleteTaskBodyOutput } from "@trigger.dev/core";
|
||||
import {
|
||||
API_VERSIONS,
|
||||
CompleteTaskBodyInputSchema,
|
||||
CompleteTaskBodyV2InputSchema,
|
||||
} from "@trigger.dev/core";
|
||||
import { z } from "zod";
|
||||
import { PrismaClientOrTransaction, prisma } from "~/db.server";
|
||||
import { taskWithAttemptsToServerTask } from "~/models/task.server";
|
||||
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { authenticateApiRequest } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { CompleteRunTaskService } from "./CompleteRunTaskService.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
runId: z.string(),
|
||||
@@ -118,90 +117,3 @@ async function completeRunTask(
|
||||
return json({ error: "Something went wrong" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export class CompleteRunTaskService {
|
||||
#prismaClient: PrismaClientOrTransaction;
|
||||
|
||||
constructor(prismaClient: PrismaClientOrTransaction = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
public async call(
|
||||
environment: AuthenticatedEnvironment,
|
||||
runId: string,
|
||||
id: string,
|
||||
taskBody: CompleteTaskBodyOutput
|
||||
): Promise<ServerTask | undefined> {
|
||||
const existingTask = await this.#prismaClient.task.findUnique({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
include: {
|
||||
run: true,
|
||||
attempts: {
|
||||
where: {
|
||||
status: "PENDING",
|
||||
},
|
||||
orderBy: {
|
||||
number: "desc",
|
||||
},
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingTask) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (existingTask.runId !== runId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (existingTask.run.environmentId !== environment.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
existingTask.status === "COMPLETED" ||
|
||||
existingTask.status === "ERRORED" ||
|
||||
existingTask.status === "CANCELED"
|
||||
) {
|
||||
logger.debug("Task already completed", {
|
||||
existingTask,
|
||||
});
|
||||
|
||||
return taskWithAttemptsToServerTask(existingTask);
|
||||
}
|
||||
|
||||
if (existingTask.attempts.length === 1) {
|
||||
await this.#prismaClient.taskAttempt.update({
|
||||
where: {
|
||||
id: existingTask.attempts[0].id,
|
||||
},
|
||||
data: {
|
||||
status: "COMPLETED",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const updatedTask = await this.#prismaClient.task.update({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
data: {
|
||||
status: "COMPLETED",
|
||||
output: taskBody.output as any,
|
||||
outputIsUndefined: typeof taskBody.output === "undefined",
|
||||
completedAt: new Date(),
|
||||
outputProperties: taskBody.properties,
|
||||
},
|
||||
include: {
|
||||
attempts: true,
|
||||
run: true,
|
||||
},
|
||||
});
|
||||
|
||||
return taskWithAttemptsToServerTask(updatedTask);
|
||||
}
|
||||
}
|
||||
+3
-68
@@ -1,76 +1,11 @@
|
||||
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { FailTaskBodyInput, FailTaskBodyInputSchema, ServerTask } from "@trigger.dev/core";
|
||||
import { z } from "zod";
|
||||
import { PrismaClient, prisma } from "~/db.server";
|
||||
import { FailTaskBodyInput, ServerTask } from "@trigger.dev/core";
|
||||
import { PrismaClient } from "@trigger.dev/database";
|
||||
import { prisma } from "~/db.server";
|
||||
import { taskWithAttemptsToServerTask } from "~/models/task.server";
|
||||
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { authenticateApiRequest } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { formatError } from "~/utils/formatErrors.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
runId: z.string(),
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
export async function action({ request, params }: ActionFunctionArgs) {
|
||||
// Ensure this is a POST request
|
||||
if (request.method.toUpperCase() !== "POST") {
|
||||
return { status: 405, body: "Method Not Allowed" };
|
||||
}
|
||||
|
||||
// Next authenticate the request
|
||||
const authenticationResult = await authenticateApiRequest(request);
|
||||
|
||||
if (!authenticationResult) {
|
||||
return json({ error: "Invalid or Missing API key" }, { status: 401 });
|
||||
}
|
||||
|
||||
const authenticatedEnv = authenticationResult.environment;
|
||||
|
||||
const { runId, id } = ParamsSchema.parse(params);
|
||||
|
||||
// Now parse the request body
|
||||
const anyBody = await request.json();
|
||||
|
||||
logger.debug("FailRunTaskService.call() request body", {
|
||||
body: anyBody,
|
||||
runId,
|
||||
id,
|
||||
});
|
||||
|
||||
const body = FailTaskBodyInputSchema.safeParse(anyBody);
|
||||
|
||||
if (!body.success) {
|
||||
return json({ error: "Invalid request body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const service = new FailRunTaskService();
|
||||
|
||||
try {
|
||||
const task = await service.call(authenticatedEnv, runId, id, body.data);
|
||||
|
||||
logger.debug("FailRunTaskService.call() response body", {
|
||||
runId,
|
||||
id,
|
||||
task,
|
||||
});
|
||||
|
||||
if (!task) {
|
||||
return json({ message: "Task not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return json(task);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
return json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
return json({ error: "Something went wrong" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export class FailRunTaskService {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { FailTaskBodyInputSchema } from "@trigger.dev/core";
|
||||
import { z } from "zod";
|
||||
import { authenticateApiRequest } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { FailRunTaskService } from "./FailRunTaskService.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
runId: z.string(),
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
export async function action({ request, params }: ActionFunctionArgs) {
|
||||
// Ensure this is a POST request
|
||||
if (request.method.toUpperCase() !== "POST") {
|
||||
return { status: 405, body: "Method Not Allowed" };
|
||||
}
|
||||
|
||||
// Next authenticate the request
|
||||
const authenticationResult = await authenticateApiRequest(request);
|
||||
|
||||
if (!authenticationResult) {
|
||||
return json({ error: "Invalid or Missing API key" }, { status: 401 });
|
||||
}
|
||||
|
||||
const authenticatedEnv = authenticationResult.environment;
|
||||
|
||||
const { runId, id } = ParamsSchema.parse(params);
|
||||
|
||||
// Now parse the request body
|
||||
const anyBody = await request.json();
|
||||
|
||||
logger.debug("FailRunTaskService.call() request body", {
|
||||
body: anyBody,
|
||||
runId,
|
||||
id,
|
||||
});
|
||||
|
||||
const body = FailTaskBodyInputSchema.safeParse(anyBody);
|
||||
|
||||
if (!body.success) {
|
||||
return json({ error: "Invalid request body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const service = new FailRunTaskService();
|
||||
|
||||
try {
|
||||
const task = await service.call(authenticatedEnv, runId, id, body.data);
|
||||
|
||||
logger.debug("FailRunTaskService.call() response body", {
|
||||
runId,
|
||||
id,
|
||||
task,
|
||||
});
|
||||
|
||||
if (!task) {
|
||||
return json({ message: "Task not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return json(task);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
return json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
return json({ error: "Something went wrong" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
import { ServerTask, RunTaskResponseWithCachedTasksBody } from "@trigger.dev/core";
|
||||
import { PrismaClient } from "@trigger.dev/database";
|
||||
import { prisma } from "~/db.server";
|
||||
import { prepareTasksForCaching } from "~/models/task.server";
|
||||
|
||||
export class ChangeRequestLazyLoadedCachedTasks {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
public async call(
|
||||
runId: string,
|
||||
task: ServerTask,
|
||||
cursor?: string | null
|
||||
): Promise<RunTaskResponseWithCachedTasksBody> {
|
||||
if (!cursor) {
|
||||
return {
|
||||
task,
|
||||
};
|
||||
}
|
||||
|
||||
// We need to limit the cached tasks to not be too large >2MB when serialized
|
||||
const TOTAL_CACHED_TASK_BYTE_LIMIT = 2000000;
|
||||
|
||||
const nextTasks = await this.#prismaClient.task.findMany({
|
||||
where: {
|
||||
runId,
|
||||
status: "COMPLETED",
|
||||
noop: false,
|
||||
},
|
||||
take: 250,
|
||||
cursor: {
|
||||
id: cursor,
|
||||
},
|
||||
orderBy: {
|
||||
id: "asc",
|
||||
},
|
||||
});
|
||||
|
||||
const preparedTasks = prepareTasksForCaching(nextTasks, TOTAL_CACHED_TASK_BYTE_LIMIT);
|
||||
|
||||
return {
|
||||
task,
|
||||
cachedTasks: preparedTasks,
|
||||
};
|
||||
}
|
||||
}
|
||||
+2
-53
@@ -1,17 +1,11 @@
|
||||
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import {
|
||||
API_VERSIONS,
|
||||
RunTaskBodyOutputSchema,
|
||||
RunTaskResponseWithCachedTasksBody,
|
||||
ServerTask,
|
||||
} from "@trigger.dev/core";
|
||||
import { API_VERSIONS, RunTaskBodyOutputSchema } from "@trigger.dev/core";
|
||||
import { z } from "zod";
|
||||
import { PrismaClient, prisma } from "~/db.server";
|
||||
import { prepareTasksForCaching } from "~/models/task.server";
|
||||
import { authenticateApiRequest } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { RunTaskService } from "~/services/tasks/runTask.server";
|
||||
import { ChangeRequestLazyLoadedCachedTasks } from "./ChangeRequestLazyLoadedCachedTasks.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
runId: z.string(),
|
||||
@@ -111,48 +105,3 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
||||
return json({ error: "Something went wrong" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
class ChangeRequestLazyLoadedCachedTasks {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
public async call(
|
||||
runId: string,
|
||||
task: ServerTask,
|
||||
cursor?: string | null
|
||||
): Promise<RunTaskResponseWithCachedTasksBody> {
|
||||
if (!cursor) {
|
||||
return {
|
||||
task,
|
||||
};
|
||||
}
|
||||
|
||||
// We need to limit the cached tasks to not be too large >2MB when serialized
|
||||
const TOTAL_CACHED_TASK_BYTE_LIMIT = 2000000;
|
||||
|
||||
const nextTasks = await this.#prismaClient.task.findMany({
|
||||
where: {
|
||||
runId,
|
||||
status: "COMPLETED",
|
||||
noop: false,
|
||||
},
|
||||
take: 250,
|
||||
cursor: {
|
||||
id: cursor,
|
||||
},
|
||||
orderBy: {
|
||||
id: "asc",
|
||||
},
|
||||
});
|
||||
|
||||
const preparedTasks = prepareTasksForCaching(nextTasks, TOTAL_CACHED_TASK_BYTE_LIMIT);
|
||||
|
||||
return {
|
||||
task,
|
||||
cachedTasks: preparedTasks,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { authenticateApiRequest } from "~/services/apiAuth.server";
|
||||
import { ServiceValidationError } from "~/v3/services/baseService.server";
|
||||
import { CreateTaskRunAttemptService } from "~/v3/services/createTaskRunAttempt.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
/* This is the run friendly ID */
|
||||
runParam: z.string(),
|
||||
});
|
||||
|
||||
export async function action({ request, params }: ActionFunctionArgs) {
|
||||
// Authenticate the request
|
||||
const authenticationResult = await authenticateApiRequest(request);
|
||||
|
||||
if (!authenticationResult) {
|
||||
return json({ error: "Invalid or Missing API Key" }, { status: 401 });
|
||||
}
|
||||
|
||||
const parsed = ParamsSchema.safeParse(params);
|
||||
|
||||
if (!parsed.success) {
|
||||
return json({ error: "Invalid or missing run ID" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { runParam } = parsed.data;
|
||||
|
||||
const service = new CreateTaskRunAttemptService();
|
||||
|
||||
try {
|
||||
const { execution } = await service.call(runParam, authenticationResult.environment);
|
||||
|
||||
return json(execution, { status: 200 });
|
||||
} catch (error) {
|
||||
if (error instanceof ServiceValidationError) {
|
||||
return json({ error: error.message }, { status: error.status ?? 422 });
|
||||
}
|
||||
|
||||
return json(
|
||||
{ error: error instanceof Error ? error.message : "Internal Server Error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
-36
@@ -1,44 +1,8 @@
|
||||
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { $transaction, PrismaClient, PrismaClientOrTransaction, prisma } from "~/db.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { ResumeTaskService } from "~/services/tasks/resumeTask.server";
|
||||
import { workerQueue } from "~/services/worker.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
id: z.string(),
|
||||
secret: z.string(),
|
||||
});
|
||||
|
||||
export async function action({ request, params }: ActionFunctionArgs) {
|
||||
// Ensure this is a POST request
|
||||
if (request.method.toUpperCase() !== "POST") {
|
||||
return { status: 405, body: "Method Not Allowed" };
|
||||
}
|
||||
|
||||
const { id } = ParamsSchema.parse(params);
|
||||
|
||||
// Parse body as JSON (no schema parsing)
|
||||
const body = await request.json();
|
||||
|
||||
const service = new CallbackRunTaskService();
|
||||
|
||||
try {
|
||||
// Complete task with request body as output
|
||||
await service.call(id, body, request.url);
|
||||
|
||||
return json({ success: true });
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
logger.error("Error while processing task callback:", { error });
|
||||
|
||||
return json({ error: `Something went wrong: ${error.message}` }, { status: 500 });
|
||||
}
|
||||
return json({ error: "Something went wrong" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export class CallbackRunTaskService {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { CallbackRunTaskService } from "./CallbackRunTaskService.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
id: z.string(),
|
||||
secret: z.string(),
|
||||
});
|
||||
|
||||
export async function action({ request, params }: ActionFunctionArgs) {
|
||||
// Ensure this is a POST request
|
||||
if (request.method.toUpperCase() !== "POST") {
|
||||
return { status: 405, body: "Method Not Allowed" };
|
||||
}
|
||||
|
||||
const { id } = ParamsSchema.parse(params);
|
||||
|
||||
// Parse body as JSON (no schema parsing)
|
||||
const body = await request.json();
|
||||
|
||||
const service = new CallbackRunTaskService();
|
||||
|
||||
try {
|
||||
// Complete task with request body as output
|
||||
await service.call(id, body, request.url);
|
||||
|
||||
return json({ success: true });
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
logger.error("Error while processing task callback:", { error });
|
||||
|
||||
return json({ error: `Something went wrong: ${error.message}` }, { status: 500 });
|
||||
}
|
||||
return json({ error: "Something went wrong" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -11,9 +11,10 @@ const ParamsSchema = z.object({
|
||||
});
|
||||
|
||||
export const HeadersSchema = z.object({
|
||||
"idempotency-key": z.string().optional().nullable(),
|
||||
"trigger-version": z.string().optional().nullable(),
|
||||
"x-trigger-span-parent-as-link": z.coerce.number().optional().nullable(),
|
||||
"idempotency-key": z.string().nullish(),
|
||||
"trigger-version": z.string().nullish(),
|
||||
"x-trigger-span-parent-as-link": z.coerce.number().nullish(),
|
||||
"x-trigger-worker": z.string().nullish(),
|
||||
traceparent: z.string().optional(),
|
||||
tracestate: z.string().optional(),
|
||||
});
|
||||
@@ -45,6 +46,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
||||
"x-trigger-span-parent-as-link": spanParentAsLink,
|
||||
traceparent,
|
||||
tracestate,
|
||||
"x-trigger-worker": isFromWorker,
|
||||
} = headers.data;
|
||||
|
||||
const { taskId } = ParamsSchema.parse(params);
|
||||
@@ -58,20 +60,31 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
||||
return json({ error: "Invalid request body" }, { status: 400 });
|
||||
}
|
||||
|
||||
logger.debug("Triggering task", {
|
||||
taskId,
|
||||
idempotencyKey,
|
||||
triggerVersion,
|
||||
body: body.data,
|
||||
});
|
||||
|
||||
const service = new TriggerTaskService();
|
||||
|
||||
try {
|
||||
const traceContext = traceparent
|
||||
? !triggerVersion // If the trigger version is NOT set, we are in an older version of the SDK
|
||||
? { traceparent, tracestate }
|
||||
: isFromWorker // If the trigger version is set, and the request is from a worker, we should pass the trace context
|
||||
? { traceparent, tracestate }
|
||||
: undefined
|
||||
: undefined;
|
||||
|
||||
logger.debug("Triggering task", {
|
||||
taskId,
|
||||
idempotencyKey,
|
||||
triggerVersion,
|
||||
headers: Object.fromEntries(request.headers),
|
||||
body: body.data,
|
||||
isFromWorker,
|
||||
traceContext,
|
||||
});
|
||||
|
||||
const run = await service.call(taskId, authenticationResult.environment, body.data, {
|
||||
idempotencyKey: idempotencyKey ?? undefined,
|
||||
triggerVersion: triggerVersion ?? undefined,
|
||||
traceContext: traceparent ? { traceparent, tracestate } : undefined,
|
||||
traceContext,
|
||||
spanParentAsLink: spanParentAsLink === 1,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,10 +1,36 @@
|
||||
import type { ActionFunction, LoaderFunction } from "@remix-run/node";
|
||||
import { redirect, type ActionFunction, type LoaderFunction } from "@remix-run/node";
|
||||
import { authenticator } from "~/services/auth.server";
|
||||
import {
|
||||
clearCurrentProjectId,
|
||||
commitCurrentProjectSession,
|
||||
getCurrentProjectId,
|
||||
} from "~/services/currentProject.server";
|
||||
import { logoutPath } from "~/utils/pathBuilder";
|
||||
|
||||
export const action: ActionFunction = async ({ request }) => {
|
||||
const projectId = await getCurrentProjectId(request);
|
||||
if (projectId) {
|
||||
const removeProjectIdSession = await clearCurrentProjectId(request);
|
||||
return redirect(logoutPath(), {
|
||||
headers: {
|
||||
"Set-Cookie": await commitCurrentProjectSession(removeProjectIdSession),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return await authenticator.logout(request, { redirectTo: "/" });
|
||||
};
|
||||
|
||||
export const loader: LoaderFunction = async ({ request }) => {
|
||||
const projectId = await getCurrentProjectId(request);
|
||||
if (projectId) {
|
||||
const removeProjectIdSession = await clearCurrentProjectId(request);
|
||||
return redirect(logoutPath(), {
|
||||
headers: {
|
||||
"Set-Cookie": await commitCurrentProjectSession(removeProjectIdSession),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return await authenticator.logout(request, { redirectTo: "/" });
|
||||
};
|
||||
|
||||
-48
@@ -1,57 +1,9 @@
|
||||
import { LoaderFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import { TaskQueue } from "@trigger.dev/database";
|
||||
import { Gauge, Registry } from "prom-client";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "~/db.server";
|
||||
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
|
||||
import { marqs } from "~/v3/marqs/index.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
projectRef: z.string(),
|
||||
});
|
||||
|
||||
export async function loader({ params, request }: LoaderFunctionArgs) {
|
||||
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
|
||||
|
||||
if (!authenticationResult) {
|
||||
return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
|
||||
}
|
||||
|
||||
const validatedParams = ParamsSchema.parse(params);
|
||||
|
||||
const project = await prisma.project.findFirst({
|
||||
where: {
|
||||
externalRef: validatedParams.projectRef,
|
||||
organization: {
|
||||
members: {
|
||||
some: {
|
||||
userId: authenticationResult.userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
organization: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
const registry = new Registry();
|
||||
// Return prometheus metrics for the project (queues)
|
||||
|
||||
await registerProjectMetrics(registry, project.id, authenticationResult.userId);
|
||||
|
||||
return new Response(await registry.metrics(), {
|
||||
headers: {
|
||||
"Content-Type": registry.contentType,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function registerProjectMetrics(
|
||||
registry: Registry,
|
||||
projectId: string,
|
||||
@@ -0,0 +1,51 @@
|
||||
import { LoaderFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import { Registry } from "prom-client";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "~/db.server";
|
||||
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
|
||||
import { registerProjectMetrics } from "./registerProjectMetrics.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
projectRef: z.string(),
|
||||
});
|
||||
|
||||
export async function loader({ params, request }: LoaderFunctionArgs) {
|
||||
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
|
||||
|
||||
if (!authenticationResult) {
|
||||
return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
|
||||
}
|
||||
|
||||
const validatedParams = ParamsSchema.parse(params);
|
||||
|
||||
const project = await prisma.project.findFirst({
|
||||
where: {
|
||||
externalRef: validatedParams.projectRef,
|
||||
organization: {
|
||||
members: {
|
||||
some: {
|
||||
userId: authenticationResult.userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
organization: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
const registry = new Registry();
|
||||
// Return prometheus metrics for the project (queues)
|
||||
|
||||
await registerProjectMetrics(registry, project.id, authenticationResult.userId);
|
||||
|
||||
return new Response(await registry.metrics(), {
|
||||
headers: {
|
||||
"Content-Type": registry.contentType,
|
||||
},
|
||||
});
|
||||
}
|
||||
+11
-6
@@ -11,7 +11,7 @@ import {
|
||||
environmentTitle,
|
||||
} from "~/components/environments/EnvironmentLabel";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Checkbox } from "~/components/primitives/Checkbox";
|
||||
import { CheckboxWithLabel } from "~/components/primitives/Checkbox";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import { Fieldset } from "~/components/primitives/Fieldset";
|
||||
import { FormError } from "~/components/primitives/FormError";
|
||||
@@ -34,7 +34,7 @@ import { TextLink } from "~/components/primitives/TextLink";
|
||||
import { prisma } from "~/db.server";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { EditableScheduleElements } from "~/presenters/v3/EditSchedulePresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
@@ -92,9 +92,14 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
submission.value?.friendlyId === result.id ? "Schedule updated" : "Schedule created"
|
||||
);
|
||||
} catch (error: any) {
|
||||
submission.error.taskIdentifier =
|
||||
error instanceof Error ? error.message : JSON.stringify(error);
|
||||
return json(submission, { status: 400 });
|
||||
const errorMessage = `Failed: ${
|
||||
error instanceof Error ? error.message : JSON.stringify(error)
|
||||
}`;
|
||||
return redirectWithErrorMessage(
|
||||
v3SchedulesPath({ slug: organizationSlug }, { slug: projectParam }),
|
||||
request,
|
||||
errorMessage
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -265,7 +270,7 @@ export function UpsertScheduleForm({
|
||||
<Label>Environments</Label>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{possibleEnvironments.map((environment) => (
|
||||
<Checkbox
|
||||
<CheckboxWithLabel
|
||||
key={environment.id}
|
||||
id={environment.id}
|
||||
value={environment.id}
|
||||
|
||||
+3
-2
@@ -4,11 +4,11 @@ import {
|
||||
QueueListIcon,
|
||||
StopCircleIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { useFetcher, useParams } from "@remix-run/react";
|
||||
import { useParams } from "@remix-run/react";
|
||||
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { formatDurationNanoseconds, nanosecondsToMilliseconds } from "@trigger.dev/core/v3";
|
||||
import { useEffect } from "react";
|
||||
import { typedjson, useTypedFetcher, useTypedLoaderData } from "remix-typedjson";
|
||||
import { typedjson, useTypedFetcher } from "remix-typedjson";
|
||||
import { ExitIcon } from "~/assets/icons/ExitIcon";
|
||||
import { CodeBlock } from "~/components/code/CodeBlock";
|
||||
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
|
||||
@@ -46,6 +46,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
organizationSlug,
|
||||
projectSlug: projectParam,
|
||||
spanId: spanParam,
|
||||
runFriendlyId: runParam,
|
||||
});
|
||||
|
||||
if (!span) {
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import { Form } from "@remix-run/react";
|
||||
import { ActionFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { PlainClient } from "@team-plain/typescript-sdk";
|
||||
import { z } from "zod";
|
||||
import { MainCenteredContainer } from "~/components/layout/AppLayout";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
import { prisma } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import { useUser } from "~/hooks/useUser";
|
||||
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
import { organizationPath } from "~/utils/pathBuilder";
|
||||
import v3Icon from "~/assets/icons/v3.svg";
|
||||
import { CheckCircleIcon } from "@heroicons/react/20/solid";
|
||||
|
||||
const ParamSchema = z.object({
|
||||
organizationSlug: z.string(),
|
||||
});
|
||||
|
||||
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
if (request.method.toLowerCase() !== "post") {
|
||||
return redirectWithErrorMessage("/", request, "Invalid request method");
|
||||
}
|
||||
|
||||
const user = await requireUser(request);
|
||||
const { organizationSlug } = ParamSchema.parse(params);
|
||||
|
||||
const failedRedirectPath = organizationPath({ slug: organizationSlug });
|
||||
|
||||
try {
|
||||
if (!env.PLAIN_API_KEY) {
|
||||
return redirectWithErrorMessage(
|
||||
failedRedirectPath,
|
||||
request,
|
||||
"Error requesting V3 access: Plain API key"
|
||||
);
|
||||
}
|
||||
|
||||
//mark them as having requested v3
|
||||
const organization = await prisma.organization.update({
|
||||
where: {
|
||||
slug: organizationSlug,
|
||||
members: {
|
||||
some: {
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
data: {
|
||||
hasRequestedV3: true,
|
||||
},
|
||||
});
|
||||
|
||||
//update Plain
|
||||
const client = new PlainClient({
|
||||
apiKey: env.PLAIN_API_KEY,
|
||||
});
|
||||
|
||||
const upsertCustomerRes = await client.upsertCustomer({
|
||||
identifier: {
|
||||
emailAddress: user.email,
|
||||
},
|
||||
onCreate: {
|
||||
fullName: user.name ?? user.email,
|
||||
email: {
|
||||
email: user.email,
|
||||
isVerified: true,
|
||||
},
|
||||
},
|
||||
onUpdate: {},
|
||||
});
|
||||
|
||||
if (upsertCustomerRes.error) {
|
||||
logger.error("Error upserting customer", upsertCustomerRes.error);
|
||||
return redirectWithErrorMessage(failedRedirectPath, request, "Error requesting V3 access");
|
||||
}
|
||||
|
||||
const groupResult = await client.addCustomerToCustomerGroups({
|
||||
customerId: upsertCustomerRes.data.customer.id,
|
||||
customerGroupIdentifiers: [
|
||||
{
|
||||
customerGroupKey: "interested-in-v3",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (groupResult.error) {
|
||||
logger.error("Error adding customer to group", groupResult.error);
|
||||
return redirectWithErrorMessage(failedRedirectPath, request, "Error requesting V3 access");
|
||||
}
|
||||
|
||||
const createThreadRes = await client.createThread({
|
||||
customerIdentifier: {
|
||||
customerId: upsertCustomerRes.data.customer.id,
|
||||
},
|
||||
title: "v3 early access request",
|
||||
components: [
|
||||
{
|
||||
componentText: {
|
||||
text: `${upsertCustomerRes.data.customer.email.email} has been added to the v3 early access group`,
|
||||
},
|
||||
},
|
||||
{
|
||||
componentText: {
|
||||
text: `Company: ${organization.title ?? "–"}`,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (createThreadRes.error) {
|
||||
logger.error("Error creating thread", createThreadRes.error);
|
||||
return redirectWithErrorMessage(failedRedirectPath, request, "Error requesting V3 access");
|
||||
}
|
||||
|
||||
return redirectWithSuccessMessage(
|
||||
organizationPath(organization),
|
||||
request,
|
||||
"V3 access requested"
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error("Error requesting V3 access", { error });
|
||||
return redirectWithErrorMessage(failedRedirectPath, request, "Error requesting V3 access");
|
||||
}
|
||||
};
|
||||
|
||||
export function RequestV3Access({
|
||||
hasRequestedV3,
|
||||
organizationSlug,
|
||||
projectsCount,
|
||||
}: {
|
||||
hasRequestedV3: boolean;
|
||||
organizationSlug: string;
|
||||
projectsCount: number;
|
||||
}) {
|
||||
const user = useUser();
|
||||
|
||||
if (hasRequestedV3) {
|
||||
return (
|
||||
<MainCenteredContainer>
|
||||
<div>
|
||||
<div className="relative mb-4 flex size-9">
|
||||
<img src={v3Icon} alt="v3" width={32} height={32} />
|
||||
<div className="absolute right-0 top-0 size-4 rounded-full bg-background-dimmed">
|
||||
<CheckCircleIcon className="size-4 text-success" />
|
||||
</div>
|
||||
</div>
|
||||
<Paragraph spacing variant="base/bright">
|
||||
We’ve received your request for v3 and we’ll notify you as soon as you have access.
|
||||
We’re granting new users access every day so you won’t be waiting long.
|
||||
</Paragraph>
|
||||
<Paragraph spacing variant="base/bright">
|
||||
Right now v3 is completely free to use but{" "}
|
||||
<TextLink href="https://trigger.dev/blog/v3-developer-preview-launch/#cloud-pricing">
|
||||
paid tiers
|
||||
</TextLink>{" "}
|
||||
will be introduced soon.
|
||||
</Paragraph>
|
||||
<Paragraph spacing variant="base/bright">
|
||||
In the meantime, check out the{" "}
|
||||
<TextLink href="https://trigger.dev/docs">v3 docs</TextLink>, the{" "}
|
||||
<TextLink href="https://trigger.dev/blog/v3-developer-preview-launch/">
|
||||
v3 blog post
|
||||
</TextLink>{" "}
|
||||
and <TextLink href="https://trigger.dev/discord">join our Discord</TextLink>.
|
||||
</Paragraph>
|
||||
</div>
|
||||
</MainCenteredContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MainCenteredContainer>
|
||||
<img src={v3Icon} alt="v3" width={32} height={32} className="mb-4" />
|
||||
<Form action={`/resources/orgs/${organizationSlug}/v3-access`} method="post">
|
||||
{projectsCount > 0 ? (
|
||||
<Paragraph spacing variant="base/bright">
|
||||
You can no longer create v2 projects and your organization doesn't have access to v3
|
||||
yet. We are approving access requests daily.
|
||||
</Paragraph>
|
||||
) : (
|
||||
<Paragraph spacing variant="base/bright">
|
||||
Trigger.dev v3 is currently in Developer Preview and we’re operating a waitlist as we
|
||||
focus on the platform’s reliability and scaleability.
|
||||
</Paragraph>
|
||||
)}
|
||||
<Paragraph spacing variant="base/bright">
|
||||
For more info, check out our{" "}
|
||||
<TextLink href="https://trigger.dev/blog/v3-developer-preview-launch/">
|
||||
v3 blog post
|
||||
</TextLink>
|
||||
.
|
||||
</Paragraph>
|
||||
<div className="mt-2 flex items-center justify-between gap-3">
|
||||
{projectsCount > 0 ? (
|
||||
<LinkButton variant="tertiary/small" to={organizationPath({ slug: organizationSlug })}>
|
||||
Cancel
|
||||
</LinkButton>
|
||||
) : null}
|
||||
<Button variant="primary/small" type="submit">
|
||||
Request access
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</MainCenteredContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { parse } from "@conform-to/zod";
|
||||
import { ActionFunctionArgs } from "@remix-run/router";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "~/db.server";
|
||||
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { v3RunsPath } from "~/utils/pathBuilder";
|
||||
import { CreateBulkActionService } from "~/v3/services/bulk/createBulkAction.server";
|
||||
|
||||
const FormSchema = z.object({
|
||||
organizationSlug: z.string(),
|
||||
projectSlug: z.string(),
|
||||
failedRedirect: z.string(),
|
||||
runIds: z.array(z.string()).or(z.string()),
|
||||
});
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
|
||||
if (request.method.toLowerCase() !== "post") {
|
||||
return redirectWithErrorMessage("/", request, "Invalid method");
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const submission = parse(formData, { schema: FormSchema });
|
||||
|
||||
if (!submission.value) {
|
||||
logger.error("Failed to parse resources/taskruns/bulk/cancel form data", { submission });
|
||||
return redirectWithErrorMessage("/", request, "Failed to parse form data");
|
||||
}
|
||||
|
||||
try {
|
||||
const project = await prisma.project.findUnique({
|
||||
where: {
|
||||
slug: submission.value.projectSlug,
|
||||
organization: {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
return redirectWithErrorMessage(
|
||||
submission.value.failedRedirect,
|
||||
request,
|
||||
"Project not found"
|
||||
);
|
||||
}
|
||||
|
||||
const service = new CreateBulkActionService();
|
||||
const result = await service.call({
|
||||
projectId: project.id,
|
||||
action: "CANCEL",
|
||||
runIds:
|
||||
typeof submission.value.runIds === "string"
|
||||
? [submission.value.runIds]
|
||||
: submission.value.runIds,
|
||||
});
|
||||
|
||||
const path = v3RunsPath(
|
||||
{ slug: submission.value.organizationSlug },
|
||||
{ slug: project.slug },
|
||||
{
|
||||
bulkId: result.friendlyId,
|
||||
}
|
||||
);
|
||||
|
||||
return redirectWithSuccessMessage(path, request, result.message);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
logger.error("Failed to cancel runs", {
|
||||
error: {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
},
|
||||
});
|
||||
return redirectWithErrorMessage(submission.value.failedRedirect, request, error.message);
|
||||
} else {
|
||||
logger.error("Failed to cancel runs", { error });
|
||||
return redirectWithErrorMessage(
|
||||
submission.value.failedRedirect,
|
||||
request,
|
||||
JSON.stringify(error)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user