Compare commits

...

60 Commits

Author SHA1 Message Date
github-actions[bot] e7bd1ee676 chore: Update version for release (beta) (#1042)
* chore: Update version for release (beta)

* update lockfile

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: nicktrn <55853254+nicktrn@users.noreply.github.com>
2024-04-18 17:53:33 +01:00
nicktrn 584c7da5df v3: prod worker graceful shutdown (#1034)
* graceful exit with timeout

* handle and display graceful timeout errors

* fix for very long waits

* changeset

* increase termination grace period to an hour
2024-04-18 15:46:14 +01:00
nicktrn c9e1a3e9c5 v3: fix dependency trigger and wait (#1030)
* fix dev dependecy resumes

* add catalog entry with multiple trigger waits

* update docker provider lifecycle command

* switch to x for clearing run filters

* Revert "fix dev dependecy resumes"

This reverts commit b4061f2ae71f21889adcf061928e2e77c436f0ed.

* fix dependency resumes, properly this time

* add catalog entry for dependency waits in loops

* advice in docs re parallel dependency waits

* fix link from v3 to v2 docs

* move lifecycle command logging to debug only

* Removed batchOptions from the trigger options and the docs

---------

Co-authored-by: Matt Aitken <matt@mattaitken.com>
2024-04-18 14:48:43 +01:00
nicktrn 2f5b4a8471 v3: fix raw queries for custom schemas (#1033)
* add custom validation to db url env vars

* extract schema from db url and use in all raw queries

* use qualified names in scheduling raw queries

* cook a few raw queries

* Added missing raw query schema specifier to DeploymentListPresenter

---------

Co-authored-by: Matt Aitken <matt@mattaitken.com>
2024-04-18 12:01:38 +01:00
Eric Allam 69f6891687 Fixed rebalancing when message is acked
I’ve also added a “rebalancer worker” that will periodically rebalance parent queues, in case have been any issues with rebalancing (basically a “sweeper of entropy” process)
2024-04-17 21:00:39 +01:00
Matt Aitken acd7681e58 Scheduled task docs (#1039)
* Scheduled docs v1

* Improved copy

* Rejigged the docs some more. Added a dynamic section
2024-04-17 17:41:16 +01:00
Eric Allam 180a5ef01d Fix pnpm lock file 2024-04-17 16:34:14 +01:00
github-actions[bot] 62544d3234 chore: Update version for release (beta) (#1038)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-04-17 16:33:23 +01:00
Eric Allam 9b045071d8 Better errors when trying to delete a schedule that doesn’t exist 2024-04-17 16:05:51 +01:00
Eric Allam 618d2207c6 Fixed the deployed scheduled task check by looking at the slug, not the task.id 2024-04-17 15:38:39 +01:00
Eric Allam 36d8bee14a Don’t delete task runs if the attached task schedule or instance is deleted 2024-04-17 15:26:35 +01:00
Eric Allam 44e1b87547 v3: various schedule fixes (#1040)
* Improve the SDK function types and expose a new APIError instead of the APIResult type

* Skip triggering scheduled tasks if the task isn’t found in the current deployment

Also fixes an issue when editing the environments of a schedule
2024-04-17 15:10:23 +01:00
Matt Aitken 71b0ef8f77 Don’t allow editing the Deduplication key when updating a schedule 2024-04-17 14:42:18 +01:00
Matt Aitken f66543c9c7 When updating a schedule in the UI, it now says “Schedule updated” 2024-04-17 14:33:11 +01:00
Matt Aitken 03b104a3d5 Added JSDocs to the schedule options in the SDK 2024-04-17 14:25:40 +01:00
Eric Allam fde939a30e Fixed empty strings making their way into schedule externalId 2024-04-17 11:59:29 +01:00
Matt Aitken 4986bfda2e Scheduled tasks (#1036)
* Database schema and migrations for schedules

* Added schedules to the side menu

* The pagination can optionally hide the page numbers for a compactive mode

* Filters for the schedule page

* Added triggerSource (“STANDARD”, “SCHEDULED”) to BackgroundWorkerTask

* Added the ability to disabled a LinkButton

* Started work on the schedule page

* Environment buttons

* The new schedule form styles

* Added a cxouple of extra fields

* Allow a checkbox to have a rich label

* Added cronstrue package to the webapp

* WIP creating tasks using the form

* Improved the form styling

* Creating schedules is working in the UI

* Minor improvements

* Basic schedule table is displaying

* Creating tasks with triggerSource = scheduled. Refactored how task metadata is stored and accessed to be cleaner

* Resource route

* Fixes in the form

* Added a gap between the environments

* WIP on OpenAI generating CRON expressions

* AI generated CRON expressions is working

* Fix for the CRON field being uneditable after an AI generation

* Improvements

* Table padding

* useThrottle now behaves correctly

* Added filtering to the schedules list

* Improved the layout and fixed CRON search

* Fixed pagination for the schedules list. Just use a regular Prisma query

* Page size of 20

* Added links to the schedule rows

* Get rid of the Last run column for now

* Implement triggered scheduled tasks

Also implemented superjson payloads and dev runtime environment “presence” with RuntimeEnvironmentSession

* Move CronPattern and CreateSchedule into a common client-accessible file

* Latest UI changes

* Fix for creating a task schedule with a blank dedup key

* Refactor the human to cron stuff into a separate file and use json_object OpenAI response format

* Fix for trying to use a hook on the server-side…

* A couple of fixes to the new schedule form

* WIP on viewing a scheduled run

* Make the filters all optional

* Use the RunListPresenter from the schedule presenter

* Display a table of runs… the wrong runs but still

* Runs from the schedule

* Deleting schedules from the UI

* Tidied imports and fixed name of options object

* Disabling a schedule

* Editing schedules

* Tidied imports

* Added icons to the task list, needs some design love

* Added a tooltip for CRON pattersn

* Show the last run in the schedules table

* Some tweaks

* Added a placeholder to the CRON AI field

* Improved the trigger source icon

* Dim out disabled schedules

* Scheduled tasks have the correct icon in a run

* Added the task source icon to the test task list

* Added the date field component to storybook

* Style improvements to the date field

* Implement Task Schedule API

* added a medium sized variant to the date field

* Fixed replay run for superjson payload types

Also now linking from the replaying run to the original run (using span links)

Also added a project metrics prometheus endpoint to detect the state of the queues

* WIP on allowing different forms for testing

* If you pass a string to prettyPrintPacket which is json or superjson, safeParse it first

* Test page, deal json and superjson. Started splitting UI for schedules

* WIP on schedule form

* Removed the Label from the DateField

* WIP on test schedule form

* Fix for the runs page showing the wrong message when there are no runs from time filtering

* Removed labels from the DateField

* Fixes for the form

* Test runs are working for schedules

* Fix for nextScheduledTimestamps in triggerScheduledTask

* Implement idempotency key support and fix issue with cancelled runs causing concurrency usage

* Added API documentation for the schedule API

* Remove log

* Removed console log from runs page

* Transform the recent runs test data on the server

* Fix for hydration mismatch

* Current date as the default for the test form

* Recent payloads working

* Delete schedule modal

* Deal with empty strings from the form

* Set the initial value for the scheduled test form

* Add option to print console logs in the dev CLI locally (issue #1014)

* Export queue from the SDK

* Fix for schedules list when you have no schedule tasks

* Blank states improved

* Make task schedules more generic, to support additional schedule generators in the future

* Removed log from maqrs

* Removed “v3/schedules” export from the SDK

---------

Co-authored-by: Eric Allam <eallam@icloud.com>
Co-authored-by: James Ritchie <james@jamesritchie.co.uk>
2024-04-17 11:14:23 +01:00
Matt Aitken a3abe4ca08 Documentation edits made through Mintlify web editor 2024-04-17 09:08:08 +01:00
Matt Aitken fad32a79dd Documentation edits made through Mintlify web editor 2024-04-17 09:07:16 +01:00
Matt Aitken 9d843caf83 Documentation edits made through Mintlify web editor 2024-04-16 14:33:07 +01:00
nicktrn 5eaf7bca34 don't require git for build 2024-04-12 19:02:38 +01:00
nicktrn 4a0368dec3 lockfile 2024-04-12 18:18:49 +01:00
github-actions[bot] f10f120e55 chore: Update version for release (beta) (#1028)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-04-12 18:18:03 +01:00
nicktrn d3a18fbdf6 v3: cli and misc fixes for windows (#1027)
* fix: otlp-importer script

* update pluginPath

* Refactor script to ensure compatibility with both Windows and Unix platforms

* Revert command changes

* Fix: Init command was failing on Windows because of bad template paths

* Removed resolveInternalFilePath

* Path fixes for the dev command

* Fix for the import paths being wrong on Windows because the contain backslashes

* The metaOutputKey should have forward slashes in it

* Print the banner immediately, otherwise with bad internet you get a blank console for a long time

* Try normalizing the trigger.config import path

* Allow tsx and jsx files

* Improved task file names and paths

* Removed closing bracket for the upgrade message

* Log out the metafile outputs for debugging

* Log out the entryPointContents before esbuild

* Log the metafile out

* ballmerize the cli

* replace npm-watch with plain old nodemon

* fix text inputs on windows

* changeset

---------

Co-authored-by: Kritik Jiyaviya <kritikjiyaviya07@gmail.com>
Co-authored-by: Matt Aitken <matt@mattaitken.com>
2024-04-12 18:12:01 +01:00
nicktrn b82a07ad1c add missing ca certs to prod worker 2024-04-12 18:07:54 +01:00
Eric Allam fa81bf356a Fix pnpm lock file 2024-04-12 12:34:32 +01:00
github-actions[bot] fec4dc3bef chore: Update version for release (beta) (#1025)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-04-12 12:34:02 +01:00
Eric Allam a80089c88f Fix the package version of trigger.dev with the rest of the @trigger.dev/* packages 2024-04-12 11:19:04 +01:00
nicktrn 63a643b7c9 v3: fix digest extraction (#1024)
* fix image digest extraction

* fix project ref cli arg override

* Create late-steaks-behave.md

---------

Co-authored-by: Eric Allam <eric@trigger.dev>
2024-04-12 11:10:52 +01:00
nicktrn efd970a901 v3: correctly handle triggering tasks prior to deploy (#1019)
* add waiting for deploy status

* sort tasks filter

* marqs: optional timestamp on enqueue

* add new status to consts

* detect and execute tasks waiting for deploy

* display runs list even without any deploys

* ensure we only execute after the run creation tx has completed

* ensure correct execution order after waiting for deploy
2024-04-12 11:03:22 +01:00
Eric Allam 89a5e9f7da Move gc to it’s own admin endpoint 2024-04-11 16:24:40 +01:00
James Ritchie 404931e224 Made the message when you’re in a developer preview project punchier 2024-04-11 14:34:45 +01:00
Eric Allam 57bf98307a Run gc before taking heapsnapshot 2024-04-11 13:53:14 +01:00
Eric Allam 7551adfeb6 Add ability to disable tracing to test if it’s the cause of the memory leak 2024-04-11 13:14:02 +01:00
Matt Aitken 69117e8ba1 Re-arrange the mint.json file so the v3 docs become the default 2024-04-11 11:22:18 +01:00
Eric Allam b580d53b59 Better identifiy heap snapshots 2024-04-11 11:13:36 +01:00
Eric Allam d81e21d2ec Remove admin config endpoint 2024-04-11 10:44:26 +01:00
Matt Aitken 826a64fe6f If your dev run hasn’t started, display an info panel saying you need to run the CLI 2024-04-11 10:36:44 +01:00
Eric Allam d1849b0ea9 Adding admin config endpoint (temp) 2024-04-11 10:30:45 +01:00
Eric Allam 3aa634d2b8 Add a metric for number of event repo subscribers and project subscribers 2024-04-11 10:30:37 +01:00
Matt Aitken 7de3fb01c7 Added more docs for replaying a run 2024-04-10 18:35:43 +01:00
github-actions[bot] 4ae9a3feac chore: Update version for release (beta) (#1023)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-04-10 18:04:20 +01:00
Matt Aitken 5b18298fef Multiple machine CLI support (#1022)
* If a cli PAT exists, associate the authorization code with it

* Improved the error messages
2024-04-10 17:59:21 +01:00
nicktrn 7a9bd18ba2 v3: stop swallowing deployment errors and display them better (#1020)
* exit worker with code 111 for handled errors

* task monitor will ignore previously handled errors

* fix logging of index errors

* deployment error component

* changeset
2024-04-10 17:58:51 +01:00
Matt Aitken 03af44e545 Improved the title of one of the debugger scripts 2024-04-10 11:23:17 +01:00
Matt Aitken 5776257663 Changed the Discord link to use the trigger.dev one. Mainly to bump the GitHub action 2024-04-10 11:08:56 +01:00
Matt Aitken d3997c9fc6 Merge branch 'main' into changeset-release/main 2024-04-10 10:20:56 +01:00
github-actions[bot] 0676ea9668 chore: Update version for release (beta) (#1013)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-04-10 10:14:00 +01:00
github-actions[bot] 7a4721e122 chore: Update version for release (beta) 2024-04-09 16:44:12 +00:00
Eric Allam a5aed0d139 Fix memory leak by calling punsubscribe instead of unsubscribe
Also adding a /metrics endpoint with a few custom metrics and the built-in prom-client ones
2024-04-09 17:42:28 +01:00
Eric Allam f1480595d5 Stop swallowing webapp logs when the span isn’t being recorded 2024-04-09 17:11:09 +01:00
Eric Allam 328947dbfd Use the dashboard url instead of the API url for the View logs link 2024-04-09 15:15:16 +01:00
Eric Allam 279717b092 v3: pnpm deploy & various fixes for dependency resolution (#1012)
* Don’t swallow some error messages when deploying

* Try and resolve dependency versions using pnpm/npm/yarn

* Adding some debug logs around dependency resolution

* Use npm list instead of npm show to resolve deps

* Improve the dependency resolution
2024-04-09 15:03:31 +01:00
Matt Aitken 702d198445 Latest lockfile 2024-04-09 09:25:28 +01:00
github-actions[bot] 803f3c15ab chore: Update version for release (beta) (#1010)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-04-09 09:24:46 +01:00
nicktrn 1c24348f7d v3: add openssl to prod image and fix deploy command auth via env var (#1011)
* add openssl to prod worker

* make deploy command accept auth token via env var
2024-04-08 19:28:35 +01:00
Matt Aitken f854cb90eb Replay\Cancel a run (v3) (#1006)
* WIP on replaying a task from the run page

* Don’t pass the existing runs idempotency key, it will cause the replay to always return the original run

* Replay from the run list

* Don’t use fetchers in the replay/cancel dialogs

* API endpoint for replaying a run

* REST API docs (mostly coming soon) but added replay run

* replayRun function added to the SDK

* Cancel run added to the SDK

* v3-catalog file to test canceling and replaying

* Changed the SDK to be runs.replay and runs.cancel

* Removed comment

* Latest lockfile
2024-04-08 19:08:44 +01:00
github-actions[bot] 7268f17b00 chore: Update version for release (beta) (#1009)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-04-08 14:11:17 +01:00
nicktrn 624ddce32f v3: fix prod worker node_modules permissions (#1008)
* set node user prior to npm install

* changeset

* add ffmpeg and ffprobe catalog entries
2024-04-08 14:05:05 +01:00
Matt Aitken 9be1557bb7 CLI v3 binary changed from trigger.dev to trigger.dev, to fix a Windows issues (#1007)
* Changed the v3 CLI binary name from trigger.dev to triggerdev to fix a Windows issue

* Changed the exec to triggerdev
2024-04-08 14:03:12 +01:00
312 changed files with 11521 additions and 2015 deletions
+2 -1
View File
@@ -4,7 +4,8 @@
"commit": false,
"fixed": [
[
"@trigger.dev/*"
"@trigger.dev/*",
"trigger.dev"
]
],
"linked": [],
+5
View File
@@ -0,0 +1,5 @@
---
"trigger.dev": patch
---
Dont swallow some error messages when deploying
+6
View File
@@ -0,0 +1,6 @@
---
"trigger.dev": patch
"@trigger.dev/core": patch
---
Add option to print console logs in the dev CLI locally (issue #1014)
+5
View File
@@ -0,0 +1,5 @@
---
"trigger.dev": patch
---
Fix permissions inside node_modules
+5
View File
@@ -0,0 +1,5 @@
---
"@trigger.dev/sdk": patch
---
Export queue from the SDK
+5
View File
@@ -0,0 +1,5 @@
---
"trigger.dev": patch
---
v3: fix digest extraction
+6
View File
@@ -0,0 +1,6 @@
---
"@trigger.dev/sdk": patch
"@trigger.dev/core": patch
---
Added replayRun function to the SDK
+6
View File
@@ -0,0 +1,6 @@
---
"@trigger.dev/sdk": patch
"@trigger.dev/core": patch
---
Improve the SDK function types and expose a new APIError instead of the APIResult type
+5
View File
@@ -0,0 +1,5 @@
---
"trigger.dev": patch
---
Use the dashboard url instead of the API url for the View logs link
+21 -1
View File
@@ -48,25 +48,45 @@
"chilled-hornets-move",
"clean-pianos-listen",
"cool-glasses-bake",
"cuddly-feet-approve",
"dry-walls-check",
"eight-pumas-float",
"few-students-share",
"green-bags-wink",
"khaki-apricots-design",
"khaki-poems-lay",
"late-icons-lie",
"late-steaks-behave",
"lemon-jobs-repair",
"light-bulldogs-press",
"light-dragons-complain",
"loud-actors-remember",
"many-ligers-pump",
"mighty-camels-joke",
"new-rivers-tell",
"ninety-pets-travel",
"odd-poets-own",
"polite-ducks-switch",
"poor-flowers-cross",
"rare-roses-float",
"real-planets-stare",
"rotten-dryers-exercise",
"sharp-zebras-serve",
"silly-suits-switch",
"smart-needles-move",
"smart-olives-eat",
"spicy-lamps-smoke",
"strange-ghosts-matter",
"strong-lemons-add",
"stupid-bulldogs-applaud",
"sweet-lizards-press",
"tall-bees-wave"
"swift-dragons-peel",
"tall-bees-wave",
"tame-guests-know",
"tender-oranges-rhyme",
"tidy-balloons-suffer",
"tiny-doors-type",
"tiny-elephants-scream",
"tricky-bulldogs-heal"
]
}
+5
View File
@@ -0,0 +1,5 @@
---
"trigger.dev": patch
---
Add openssl to prod worker image and allow passing auth token via env var for deploy
+7
View File
@@ -0,0 +1,7 @@
---
"@trigger.dev/sdk": patch
"trigger.dev": patch
"@trigger.dev/core": patch
---
Adding task with a triggerSource of schedule
+6
View File
@@ -0,0 +1,6 @@
---
"@trigger.dev/otlp-importer": patch
"trigger.dev": patch
---
Fix package builds and CLI commands on Windows
+5
View File
@@ -0,0 +1,5 @@
---
"@trigger.dev/sdk": patch
---
Remove unimplemented batchOptions
+5
View File
@@ -0,0 +1,5 @@
---
"@trigger.dev/core": patch
---
Make optional schedule object fields nullish
+5
View File
@@ -0,0 +1,5 @@
---
"@trigger.dev/core": patch
---
Added JSDocs to the schedule SDK types
+5
View File
@@ -0,0 +1,5 @@
---
"trigger.dev": patch
---
Changed the binary name from trigger.dev to triggerdev to fix a Windows issue
+5
View File
@@ -0,0 +1,5 @@
---
"trigger.dev": patch
---
Stop swallowing deployment errors and display them better
+5
View File
@@ -0,0 +1,5 @@
---
"trigger.dev": patch
---
Init command was failing on Windows because of bad template paths
+6
View File
@@ -0,0 +1,6 @@
---
"@trigger.dev/sdk": patch
"@trigger.dev/core": patch
---
Added cancelRun to the SDK
+7
View File
@@ -0,0 +1,7 @@
---
"trigger.dev": patch
"@trigger.dev/core": patch
---
- Add graceful exit for prod workers
- Prevent overflow in long waits
+7
View File
@@ -0,0 +1,7 @@
---
"@trigger.dev/sdk": patch
"trigger.dev": patch
"@trigger.dev/core": patch
---
Added a new global - Task Catalog - to better handle task metadata
+3 -3
View File
@@ -23,7 +23,7 @@
{
"type": "node-terminal",
"request": "launch",
"name": "Debug BYO Auth",
"name": "Debug v2 job catalog",
"command": "pnpm run byo-auth",
"envFile": "${workspaceFolder}/references/job-catalog/.env",
"cwd": "${workspaceFolder}/references/job-catalog",
@@ -33,7 +33,7 @@
"type": "node-terminal",
"request": "launch",
"name": "Debug V3 Dev CLI",
"command": "pnpm exec trigger.dev dev --log-level debug",
"command": "pnpm exec triggerdev dev --log-level debug",
"cwd": "${workspaceFolder}/references/v3-catalog",
"sourceMaps": true
},
@@ -41,7 +41,7 @@
"type": "node-terminal",
"request": "launch",
"name": "Debug V3 Deploy CLI",
"command": "pnpm exec trigger.dev deploy",
"command": "pnpm exec triggerdev deploy",
"cwd": "${workspaceFolder}/references/v3-catalog",
"sourceMaps": true
},
+1 -1
View File
@@ -7,7 +7,7 @@
### The open source background jobs framework
[Discord](https://discord.gg/JtBAxBr2m3) | [Website](https://trigger.dev) | [Issues](https://github.com/triggerdotdev/trigger.dev/issues) | [Docs](https://trigger.dev/docs)
[Discord](https://trigger.dev/discord) | [Website](https://trigger.dev) | [Issues](https://github.com/triggerdotdev/trigger.dev/issues) | [Docs](https://trigger.dev/docs)
[![Twitter](https://img.shields.io/twitter/url/https/twitter.com/triggerdotdev.svg?style=social&label=Follow%20%40trigger.dev)](https://twitter.com/triggerdotdev)
[![GitHub Repo stars](https://img.shields.io/github/stars/triggerdotdev/trigger.dev?style=social)](https://github.com/triggerdotdev/trigger.dev)
+1
View File
@@ -257,6 +257,7 @@ class DockerTaskOperations implements TaskOperations {
return await execa("docker", [
"exec",
containerName,
"busybox",
"wget",
"-q",
"-O-",
+2 -1
View File
@@ -133,6 +133,7 @@ class KubernetesTaskOperations implements TaskOperations {
},
spec: {
...this.#defaultPodSpec,
terminationGracePeriodSeconds: 60 * 60,
containers: [
{
name: this.#getRunContainerName(opts.runId),
@@ -409,7 +410,7 @@ class KubernetesTaskOperations implements TaskOperations {
`for i in $(seq ${retries}); do sleep 1; busybox wget -q -O- 127.0.0.1:8000/${type}?cause=${cause} && break; done`,
];
logger.log("getLifecycleCommand()", { exec });
logger.debug("getLifecycleCommand()", { exec });
return exec;
}
+7 -1
View File
@@ -136,6 +136,13 @@ export class TaskMonitor {
const podStatus = this.#getPodStatusSummary(pod.status);
const containerState = this.#getContainerStateSummary(containerStatus.state);
const exitCode = containerState.exitCode ?? -1;
// We use this special exit code to signal any errors were already handled elsewhere
if (exitCode === 111) {
return;
}
const rawLogs = await this.#getLogTail(podName);
this.#logger.log(`${podName} failed with:`, {
@@ -144,7 +151,6 @@ export class TaskMonitor {
rawLogs,
});
const exitCode = containerState.exitCode ?? -1;
const rawReason = podStatus.reason ?? containerState.reason ?? "";
const message = podStatus.message ?? containerState.message ?? "";
@@ -0,0 +1,53 @@
export function AISparkleIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M14.9806 0.803884C14.8871 0.33646 14.4767 0 14 0C13.5233 0 13.1129 0.33646 13.0194 0.803884L12.7809 1.99644C12.7017 2.3923 12.3923 2.70174 11.9964 2.78091L10.8039 3.01942C10.3365 3.1129 10 3.52332 10 4C10 4.47668 10.3365 4.8871 10.8039 4.98058L11.9964 5.21909C12.3923 5.29826 12.7017 5.6077 12.7809 6.00356L13.0194 7.19612C13.1129 7.66354 13.5233 8 14 8C14.4767 8 14.8871 7.66354 14.9806 7.19612L15.2191 6.00356C15.2983 5.6077 15.6077 5.29826 16.0036 5.21909L17.1961 4.98058C17.6635 4.8871 18 4.47668 18 4C18 3.52332 17.6635 3.1129 17.1961 3.01942L16.0036 2.78091C15.6077 2.70174 15.2983 2.3923 15.2191 1.99644L14.9806 0.803884Z"
fill="url(#paint0_linear_11402_36656)"
/>
<path
d="M5.94868 4.68377C5.81257 4.27543 5.43043 4 5 4C4.56957 4 4.18743 4.27543 4.05132 4.68377L3.36754 6.73509C3.26801 7.03369 3.03369 7.26801 2.73509 7.36754L0.683772 8.05132C0.27543 8.18743 0 8.56957 0 9C0 9.43043 0.27543 9.81257 0.683772 9.94868L2.73509 10.6325C3.03369 10.732 3.26801 10.9663 3.36754 11.2649L4.05132 13.3162C4.18743 13.7246 4.56957 14 5 14C5.43043 14 5.81257 13.7246 5.94868 13.3162L6.63246 11.2649C6.73199 10.9663 6.96631 10.732 7.26491 10.6325L9.31623 9.94868C9.72457 9.81257 10 9.43043 10 9C10 8.56957 9.72457 8.18743 9.31623 8.05132L7.26491 7.36754C6.96631 7.26801 6.73199 7.03369 6.63246 6.73509L5.94868 4.68377Z"
fill="url(#paint1_linear_11402_36656)"
/>
<path
d="M12.9487 12.6838C12.8126 12.2754 12.4304 12 12 12C11.5696 12 11.1874 12.2754 11.0513 12.6838L10.8675 13.2351C10.768 13.5337 10.5337 13.768 10.2351 13.8675L9.68377 14.0513C9.27543 14.1874 9 14.5696 9 15C9 15.4304 9.27543 15.8126 9.68377 15.9487L10.2351 16.1325C10.5337 16.232 10.768 16.4663 10.8675 16.7649L11.0513 17.3162C11.1874 17.7246 11.5696 18 12 18C12.4304 18 12.8126 17.7246 12.9487 17.3162L13.1325 16.7649C13.232 16.4663 13.4663 16.232 13.7649 16.1325L14.3162 15.9487C14.7246 15.8126 15 15.4304 15 15C15 14.5696 14.7246 14.1874 14.3162 14.0513L13.7649 13.8675C13.4663 13.768 13.232 13.5337 13.1325 13.2351L12.9487 12.6838Z"
fill="url(#paint2_linear_11402_36656)"
/>
<defs>
<linearGradient
id="paint0_linear_11402_36656"
x1="9"
y1="0"
x2="9"
y2="18"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#E543FF" />
<stop offset="1" stopColor="#286399" />
</linearGradient>
<linearGradient
id="paint1_linear_11402_36656"
x1="9"
y1="0"
x2="9"
y2="18"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#E543FF" />
<stop offset="1" stopColor="#286399" />
</linearGradient>
<linearGradient
id="paint2_linear_11402_36656"
x1="9"
y1="0"
x2="9"
y2="18"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#E543FF" />
<stop offset="1" stopColor="#286399" />
</linearGradient>
</defs>
</svg>
);
}
@@ -4,6 +4,7 @@ import {
ArrowRightOnRectangleIcon,
BeakerIcon,
ChartBarIcon,
ClockIcon,
CursorArrowRaysIcon,
IdentificationIcon,
KeyIcon,
@@ -47,6 +48,7 @@ import {
v3ProjectPath,
v3ProjectSettingsPath,
v3RunsPath,
v3SchedulesPath,
v3TestPath,
} from "~/utils/pathBuilder";
import { Feedback } from "../Feedback";
@@ -189,9 +191,11 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
</div>
</div>
<div className="m-2">
<Callout variant={"info"}>
{`This is a ${project.version === "V2" ? "v2" : "v3"} project`}
</Callout>
{project.version === "V2" ? (
<Callout variant={"info"}>This is a v2 project</Callout>
) : (
<Callout variant={"idea"}>This is a v3 project in Developer Preview</Callout>
)}
</div>
<div className="flex flex-col gap-1 border-t border-grid-bright p-1">
{project.version === "V2" && (
@@ -569,6 +573,13 @@ function V3ProjectSideMenu({
to={v3TestPath(organization, project)}
data-action="test"
/>
<SideMenuItem
name="Schedules"
icon={ClockIcon}
iconColor="text-sun-500"
to={v3SchedulesPath(organization, project)}
data-action="schedules"
/>
<SideMenuItem
name="API keys"
icon={KeyIcon}
@@ -286,8 +286,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonPropsType>(
type LinkPropsType = Pick<
LinkProps,
"to" | "target" | "onClick" | "onMouseDown" | "onMouseEnter" | "onMouseLeave" | "download"
> &
React.ComponentProps<typeof ButtonContent>;
> & { disabled?: boolean } & React.ComponentProps<typeof ButtonContent>;
export const LinkButton = ({
to,
onClick,
@@ -295,6 +294,7 @@ export const LinkButton = ({
onMouseEnter,
onMouseLeave,
download,
disabled = false,
...props
}: LinkPropsType) => {
const innerRef = useRef<HTMLAnchorElement>(null);
@@ -309,6 +309,19 @@ export const LinkButton = ({
});
}
if (disabled) {
return (
<div
className={cn(
"group pointer-events-none cursor-default opacity-40 outline-none",
props.fullWidth ? "w-full" : ""
)}
>
<ButtonContent {...props} />
</div>
);
}
if (to.toString().startsWith("http") || to.toString().startsWith("/resources")) {
return (
<ExtLink
@@ -23,7 +23,7 @@ const variants = {
},
"button/small": {
button:
"flex items-center w-fit h-8 pl-2 pr-3 rounded border border-charcoal-800 hover:bg-charcoal-850 hover:border-charcoal-750 transition",
"flex items-center w-fit h-8 pl-2 pr-3 rounded border border-charcoal-600 hover:bg-charcoal-850 hover:border-charcoal-500 transition",
label: "text-sm text-text-bright select-none",
description: "text-text-dimmed",
inputPosition: "mt-0",
@@ -32,7 +32,7 @@ const variants = {
},
button: {
button:
"w-fit py-2 pl-3 pr-4 rounded border border-charcoal-800 hover:bg-charcoal-850 hover:border-charcoal-750 transition",
"w-fit py-2 pl-3 pr-4 rounded border border-charcoal-600 hover:bg-charcoal-850 hover:border-charcoal-500 transition",
label: "text-text-bright select-none",
description: "text-text-dimmed",
inputPosition: "mt-1",
@@ -57,7 +57,7 @@ export type CheckboxProps = Omit<
name?: string;
value?: string;
variant?: keyof typeof variants;
label?: string;
label?: React.ReactNode;
description?: string;
badges?: string[];
className?: string;
@@ -137,7 +137,7 @@ export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
className={cn(
inputPositionClasses,
props.readOnly || disabled ? "cursor-default" : "cursor-pointer",
"read-only:border-charcoal-650 disabled:border-charcoal-650 rounded-sm border border-charcoal-700 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"
"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"
)}
id={id}
ref={ref}
@@ -1,3 +1,4 @@
import { BellAlertIcon } from "@heroicons/react/20/solid";
import { CalendarDateTime, createCalendar } from "@internationalized/date";
import { useDateField, useDateSegment } from "@react-aria/datepicker";
import type { DateFieldState, DateSegment } from "@react-stately/datepicker";
@@ -5,11 +6,25 @@ import { useDateFieldState } from "@react-stately/datepicker";
import { Granularity } from "@react-types/datepicker";
import { useEffect, useRef, useState } from "react";
import { cn } from "~/utils/cn";
import { useLocales } from "./LocaleProvider";
import { Button } from "./Buttons";
const variants = {
small: {
fieldStyles: "h-5 text-sm rounded-sm px-0.5",
nowButtonVariant: "tertiary/small" as const,
clearButtonVariant: "minimal/small" as const,
},
medium: {
fieldStyles: "h-7 text-base rounded px-1",
nowButtonVariant: "tertiary/medium" as const,
clearButtonVariant: "minimal/medium" as const,
},
};
type Variant = keyof typeof variants;
type DateFieldProps = {
label?: string;
label: string;
defaultValue?: Date;
minValue?: Date;
maxValue?: Date;
@@ -20,6 +35,7 @@ type DateFieldProps = {
showNowButton?: boolean;
showClearButton?: boolean;
onValueChange?: (value: Date | undefined) => void;
variant?: Variant;
};
export function DateField({
@@ -34,6 +50,7 @@ export function DateField({
showGuide = false,
showNowButton = false,
showClearButton = false,
variant = "small",
}: DateFieldProps) {
const [value, setValue] = useState<undefined | CalendarDateTime>(
utcDateToCalendarDate(defaultValue)
@@ -90,48 +107,50 @@ export function DateField({
return (
<div className={`flex flex-col items-start ${className || ""}`}>
<span {...labelProps} className="mb-1 ml-0.5 text-xs text-charcoal-300">
{label}
</span>
<div className="flex flex-row items-center gap-1">
<div className="flex flex-row items-center gap-1" aria-label={label}>
<div
{...fieldProps}
ref={ref}
className={cn(
"flex rounded-sm border border-charcoal-800 bg-charcoal-750 p-0.5 px-1.5 transition-colors focus-within:border-charcoal-500 hover:border-charcoal-700 focus-within:hover:border-charcoal-500",
"flex rounded-sm border bg-charcoal-700 p-0.5 transition focus-within:border-charcoal-600 hover:border-charcoal-600",
fieldClassName
)}
>
<DateSegment segment={yearSegment} state={state} />
<DateSegment segment={literalSegment("/")} state={state} />
<DateSegment segment={monthSegment} state={state} />
<DateSegment segment={literalSegment("/")} state={state} />
<DateSegment segment={daySegment} state={state} />
<DateSegment segment={literalSegment(", ")} state={state} />
<DateSegment segment={hourSegment} state={state} />
<DateSegment segment={literalSegment(":")} state={state} />
<DateSegment segment={minuteSegment} state={state} />
<DateSegment segment={literalSegment(":")} state={state} />
<DateSegment segment={secondSegment} state={state} />
<DateSegment segment={literalSegment(" ")} state={state} />
<DateSegment segment={dayPeriodSegment} state={state} />
<DateSegment segment={yearSegment} state={state} variant={variant} />
<DateSegment segment={literalSegment("/")} state={state} variant={variant} />
<DateSegment segment={monthSegment} state={state} variant={variant} />
<DateSegment segment={literalSegment("/")} state={state} variant={variant} />
<DateSegment segment={daySegment} state={state} variant={variant} />
<DateSegment segment={literalSegment(", ")} state={state} variant={variant} />
<DateSegment segment={hourSegment} state={state} variant={variant} />
<DateSegment segment={literalSegment(":")} state={state} variant={variant} />
<DateSegment segment={minuteSegment} state={state} variant={variant} />
<DateSegment segment={literalSegment(":")} state={state} variant={variant} />
<DateSegment segment={secondSegment} state={state} variant={variant} />
<DateSegment segment={literalSegment(" ")} state={state} variant={variant} />
<DateSegment segment={dayPeriodSegment} state={state} variant={variant} />
</div>
{showNowButton && (
<Button
variant="tertiary/small"
type="button"
variant={variants[variant].nowButtonVariant}
LeadingIcon={BellAlertIcon}
leadingIconClassName="text-text-dimmed group-hover:text-text-bright"
onClick={() => {
const now = new Date();
setValue(utcDateToCalendarDate(new Date()));
onValueChange?.(now);
}}
>
Now
<span className="text-text-dimmed transition group-hover:text-text-bright">Now</span>
</Button>
)}
{showClearButton && (
<Button
variant="tertiary/small"
type="button"
variant={variants[variant].clearButtonVariant}
LeadingIcon={"close"}
leadingIconClassName="-mr-2"
onClick={() => {
setValue(undefined);
onValueChange?.(undefined);
@@ -142,7 +161,9 @@ export function DateField({
state.clearSegment("minute");
state.clearSegment("second");
}}
/>
>
Clear
</Button>
)}
</div>
{showGuide && (
@@ -172,11 +193,13 @@ function utcDateToCalendarDate(date?: Date) {
type DateSegmentProps = {
segment: DateSegment;
state: DateFieldState;
variant: Variant;
};
function DateSegment({ segment, state }: DateSegmentProps) {
function DateSegment({ segment, state, variant }: DateSegmentProps) {
const ref = useRef<null | HTMLDivElement>(null);
const { segmentProps } = useDateSegment(segment, state, ref);
const sizeVariant = variants[variant];
return (
<div
@@ -186,23 +209,27 @@ function DateSegment({ segment, state }: DateSegmentProps) {
...segmentProps.style,
minWidth: minWidthForSegment(segment),
}}
className={`group box-content rounded-sm px-0.5 text-right text-sm tabular-nums outline-none focus:bg-indigo-500 focus:text-white ${
className={cn(
"group box-content text-center tabular-nums outline-none focus:bg-charcoal-600 focus:text-text-bright",
sizeVariant.fieldStyles,
!segment.isEditable ? "text-charcoal-500" : "text-text-bright"
}`}
)}
>
{/* Always reserve space for the placeholder, to prevent layout shift when editing. */}
<span
aria-hidden="true"
className="block text-center italic text-charcoal-500 group-focus:text-white"
className="flex h-full items-center justify-center text-center text-charcoal-500 group-focus:text-text-bright"
style={{
visibility: segment.isPlaceholder ? undefined : "hidden",
height: segment.isPlaceholder ? "" : 0,
height: segment.isPlaceholder ? undefined : 0,
pointerEvents: "none",
}}
>
{segment.placeholder}
</span>
{segment.isPlaceholder ? "" : segment.text}
<span className="flex h-full items-center justify-center">
{segment.isPlaceholder ? "" : segment.text}
</span>
</div>
);
}
@@ -231,7 +258,7 @@ function DateSegmentGuide({ segment }: { segment: DateSegment }) {
style={{
minWidth: minWidthForSegment(segment),
}}
className={`group box-content rounded-sm px-0.5 text-right text-sm tabular-nums outline-none ${
className={`group box-content rounded-sm px-0.5 text-right text-sm tabular-nums text-rose-500 outline-none ${
!segment.isEditable ? "text-charcoal-500" : "text-text-bright"
}`}
>
@@ -10,7 +10,7 @@ type DateTimeProps = {
export const DateTime = ({
date,
timeZone = "UTC",
timeZone,
includeSeconds = true,
includeTime = true,
}: DateTimeProps) => {
@@ -20,7 +20,7 @@ export const DateTime = ({
const initialFormattedDateTime = formatDateTime(
realDate,
timeZone,
timeZone ?? "UTC",
locales,
includeSeconds,
includeTime
@@ -32,7 +32,13 @@ export const DateTime = ({
const resolvedOptions = Intl.DateTimeFormat().resolvedOptions();
setFormattedDateTime(
formatDateTime(realDate, resolvedOptions.timeZone, locales, includeSeconds, includeTime)
formatDateTime(
realDate,
timeZone ?? resolvedOptions.timeZone,
locales,
includeSeconds,
includeTime
)
);
}, [locales, includeSeconds, realDate]);
@@ -1,29 +1,41 @@
import * as React from "react";
import { cn } from "~/utils/cn";
import { InfoIconTooltip, SimpleTooltip } from "./Tooltip";
const labelVariants = {
const variants = {
small: {
text: "font-sans text-sm font-normal",
text: "font-sans text-sm font-normal text-text-bright leading-tight flex items-center gap-1",
},
medium: {
text: "font-sans text-sm leading-5 font-medium",
text: "font-sans text-sm text-text-bright leading-tight flex items-center gap-1",
},
large: {
text: "font-sans text-base leading-6 font-medium",
text: "font-sans text-base font-medium text-text-bright leading-tight flex items-center gap-1",
},
};
type LabelProps = React.AllHTMLAttributes<HTMLLabelElement> & {
className?: string;
children: React.ReactNode;
variant?: keyof typeof labelVariants;
variant?: keyof typeof variants;
required?: boolean;
tooltip?: React.ReactNode;
};
export function Label({ className, children, variant = "medium", ...props }: LabelProps) {
const variation = labelVariants[variant];
export function Label({
className,
children,
variant = "medium",
required = true,
tooltip,
...props
}: LabelProps) {
const variation = variants[variant];
return (
<label className={cn(variation.text, className)} {...props}>
{children}
{tooltip ? <InfoIconTooltip content={tooltip} /> : null}
{!required && <span className="text-text-dimmed"> (optional)</span>}
</label>
);
}
@@ -1,16 +1,18 @@
import { ChevronRightIcon } from "@heroicons/react/24/outline";
import { ChevronLeftIcon } from "@heroicons/react/24/solid";
import { Link, useLocation } from "@remix-run/react";
import { LinkDisabled } from "./LinkWithDisabled";
import { cn } from "~/utils/cn";
import { ButtonContent, LinkButton } from "./Buttons";
import { ButtonContent } from "./Buttons";
import { LinkDisabled } from "./LinkWithDisabled";
export function PaginationControls({
currentPage,
totalPages,
showPageNumbers = true,
}: {
currentPage: number;
totalPages: number;
showPageNumbers?: boolean;
}) {
const location = useLocation();
if (totalPages <= 1) {
@@ -30,9 +32,11 @@ export function PaginationControls({
</ButtonContent>
</LinkDisabled>
{calculatePageLinks(currentPage, totalPages).map((page, i) => (
<PageLinkComponent page={page} key={i} location={location} />
))}
{showPageNumbers
? calculatePageLinks(currentPage, totalPages).map((page, i) => (
<PageLinkComponent page={page} key={i} location={location} />
))
: null}
<LinkDisabled
to={pageUrl(location, currentPage + 1)}
@@ -22,7 +22,7 @@ const variants = {
},
"button/small": {
button:
"flex items-center w-fit h-8 pl-2 pr-3 rounded border border-charcoal-800 hover:bg-charcoal-850 hover:border-charcoal-750 transition data-[disabled]:opacity-70 data-[disabled]:hover:bg-transparent data-[state=checked]:bg-charcoal-850",
"flex items-center w-fit h-8 pl-2 pr-3 rounded border border-charcoal-600 hover:bg-charcoal-850 hover:border-charcoal-500 transition data-[disabled]:opacity-70 data-[disabled]:hover:bg-transparent data-[state=checked]:bg-charcoal-850",
label: "text-sm text-text-bright select-none",
description: "text-text-dimmed",
inputPosition: "mt-0",
@@ -30,7 +30,7 @@ const variants = {
},
button: {
button:
"w-fit py-2 pl-3 pr-4 rounded border border-charcoal-800 hover:bg-charcoal-850 hover:border-charcoal-750 transition data-[state=checked]:bg-charcoal-850 data-[disabled]:opacity-70",
"w-fit py-2 pl-3 pr-4 rounded border border-charcoal-600 hover:bg-charcoal-850 hover:border-charcoal-500 transition data-[state=checked]:bg-charcoal-850 data-[disabled]:opacity-70",
label: "text-text-bright select-none",
description: "text-text-dimmed",
inputPosition: "mt-1",
@@ -38,7 +38,7 @@ const variants = {
},
description: {
button:
"w-full p-2.5 hover:bg-charcoal-850 transition data-[disabled]:opacity-70 data-[state=checked]:bg-charcoal-850 border-charcoal-800 border rounded-sm",
"w-full p-2.5 hover:bg-charcoal-850 transition data-[disabled]:opacity-70 data-[state=checked]:bg-charcoal-850 border-charcoal-600 border rounded-sm",
label: "text-text-bright font-semibold -mt-1 text-left",
description: "text-text-dimmed -mt-0 text-left",
inputPosition: "mt-0",
@@ -46,7 +46,7 @@ const variants = {
},
icon: {
button:
"w-full p-2.5 pb-4 hover:bg-charcoal-850 transition data-[disabled]:opacity-70 data-[state=checked]:bg-charcoal-850 border-charcoal-800 border rounded-sm",
"w-full p-2.5 pb-4 hover:bg-charcoal-850 transition data-[disabled]:opacity-70 data-[state=checked]:bg-charcoal-850 border-charcoal-600 border rounded-sm",
label: "text-text-bright font-semibold -mt-1 text-left",
description: "text-text-dimmed -mt-0 text-left",
inputPosition: "mt-0",
@@ -70,7 +70,7 @@ export function RadioButtonCircle({
return (
<div
className={cn(
"ring-offset-background focus-visible:ring-ring aspect-square h-4 w-4 shrink-0 overflow-hidden rounded-full border border-charcoal-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
"ring-offset-background focus-visible:ring-ring aspect-square h-4 w-4 shrink-0 overflow-hidden rounded-full border border-charcoal-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
boxClassName
)}
>
@@ -129,7 +129,7 @@ export const RadioGroupItem = React.forwardRef<
>
<div
className={cn(
"ring-offset-background focus-visible:ring-ring aspect-square h-4 w-4 shrink-0 overflow-hidden rounded-full border border-charcoal-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
"ring-offset-background focus-visible:ring-ring aspect-square h-4 w-4 shrink-0 overflow-hidden rounded-full border border-charcoal-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
variation.inputPosition
)}
>
@@ -49,7 +49,7 @@ export const TableHeader = forwardRef<HTMLTableSectionElement, TableHeaderProps>
type TableBodyProps = {
className?: string;
children: ReactNode;
children?: ReactNode;
};
export const TableBody = forwardRef<HTMLTableSectionElement, TableBodyProps>(
@@ -1,6 +1,7 @@
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "~/utils/cn";
import { InformationCircleIcon } from "@heroicons/react/20/solid";
const variantClasses = {
basic:
@@ -83,4 +84,22 @@ function SimpleTooltip({
);
}
export function InfoIconTooltip({
content,
buttonClassName,
}: {
content: React.ReactNode;
buttonClassName?: string;
}) {
return (
<SimpleTooltip
button={
<InformationCircleIcon className={cn("h-3.5 w-3.5 text-text-dimmed", buttonClassName)} />
}
content={content}
variant="dark"
/>
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider, TooltipArrow, SimpleTooltip };
@@ -7,6 +7,7 @@ import { DateField } from "../primitives/DateField";
import { formatDateTime } from "../primitives/DateTime";
import { Paragraph } from "../primitives/Paragraph";
import { Popover, PopoverContent, PopoverTrigger } from "../primitives/Popover";
import { Label } from "../primitives/Label";
type RunTimeFrameFilterProps = {
from?: number;
@@ -190,7 +191,7 @@ const timeFrameValues = [
export type RelativeTimeFrameItem = (typeof timeFrameValues)[number];
function AbsoluteTimeFrame({
export function AbsoluteTimeFrame({
from,
to,
onValueChange,
@@ -202,7 +203,8 @@ function AbsoluteTimeFrame({
return (
<div className="flex flex-col gap-2 pt-2">
<div className="flex flex-col justify-start gap-2">
<div className="flex flex-row items-center gap-1">
<div className="space-y-1">
<Label>From (UTC)</Label>
<DateField
label="From (UTC)"
defaultValue={from}
@@ -214,7 +216,8 @@ function AbsoluteTimeFrame({
showClearButton
/>
</div>
<div className="flex flex-row items-center gap-1">
<div className="space-y-1">
<Label>To (UTC)</Label>
<DateField
label="To (UTC)"
defaultValue={to}
@@ -1,5 +1,5 @@
import { StopCircleIcon } from "@heroicons/react/20/solid";
import { useFetcher } from "@remix-run/react";
import { Form, useFetcher, useNavigation } from "@remix-run/react";
import { Button } from "~/components/primitives/Buttons";
import {
DialogContent,
@@ -14,29 +14,32 @@ type CancelRunDialogProps = {
};
export function CancelRunDialog({ runFriendlyId, redirectPath }: CancelRunDialogProps) {
const cancelFetcher = useFetcher();
const navigation = useNavigation();
const formAction = `/resources/taskruns/${runFriendlyId}/cancel`;
const isLoading = navigation.formAction === formAction;
return (
<DialogContent>
<DialogContent key="cancel">
<DialogHeader>Cancel this run?</DialogHeader>
<DialogDescription>
Canceling a run will stop execution. If you want to run this later you will have to replay
the entire run with the original payload.
</DialogDescription>
<DialogFooter>
<cancelFetcher.Form action={`/resources/taskruns/${runFriendlyId}/cancel`} method="post">
<Form action={`/resources/taskruns/${runFriendlyId}/cancel`} method="post">
<Button
type="submit"
name="redirectUrl"
value={redirectPath}
variant="danger/small"
LeadingIcon={cancelFetcher.state === "idle" ? StopCircleIcon : "spinner-white"}
disabled={cancelFetcher.state !== "idle"}
LeadingIcon={isLoading ? "spinner-white" : StopCircleIcon}
disabled={isLoading}
shortcut={{ modifiers: ["meta"], key: "enter" }}
>
{cancelFetcher.state === "idle" ? "Cancel run" : "Canceling..."}
{isLoading ? "Canceling..." : "Cancel run"}
</Button>
</cancelFetcher.Form>
</Form>
</DialogFooter>
</DialogContent>
);
@@ -0,0 +1,39 @@
import { CodeBlock } from "~/components/code/CodeBlock";
import { Callout } from "~/components/primitives/Callout";
import { Header2 } from "~/components/primitives/Headers";
import type { ErrorData } from "~/presenters/v3/DeploymentPresenter.server";
type DeploymentErrorProps = {
errorData: ErrorData;
};
export function DeploymentError({ errorData }: DeploymentErrorProps) {
return (
<div className="flex flex-col gap-2 rounded-sm border border-rose-500/50 p-3">
<DeploymentErrorHeader title={errorData.name ?? "Error"} titleClassName="text-rose-500" />
{errorData.message && <Callout variant="error">{errorData.message}</Callout>}
{errorData.stack && (
<CodeBlock
showCopyButton={false}
showLineNumbers={false}
code={errorData.stack}
maxLines={20}
/>
)}
</div>
);
}
function DeploymentErrorHeader({
title,
titleClassName,
}: {
title: string;
titleClassName?: string;
}) {
return (
<div className="flex items-center justify-between">
<Header2 className={titleClassName}>{title}</Header2>
</div>
);
}
@@ -0,0 +1,20 @@
import { BoltSlashIcon, CheckCircleIcon } from "@heroicons/react/20/solid";
export function EnabledStatus({ enabled }: { enabled: boolean }) {
switch (enabled) {
case true:
return (
<div className="flex items-center gap-1 text-xs text-success">
<CheckCircleIcon className="h-4 w-4" />
Enabled
</div>
);
case false:
return (
<div className="text-dimmed flex items-center gap-1 text-xs">
<BoltSlashIcon className="h-4 w-4" />
Disabled
</div>
);
}
}
@@ -0,0 +1,44 @@
import { ArrowPathIcon } from "@heroicons/react/20/solid";
import { Form, useFetcher, useNavigation } from "@remix-run/react";
import { Button } from "~/components/primitives/Buttons";
import {
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
} from "~/components/primitives/Dialog";
type ReplayRunDialogProps = {
runFriendlyId: string;
failedRedirect: string;
};
export function ReplayRunDialog({ runFriendlyId, failedRedirect }: ReplayRunDialogProps) {
const navigation = useNavigation();
const formAction = `/resources/taskruns/${runFriendlyId}/replay`;
const isLoading = navigation.formAction === formAction;
return (
<DialogContent key="replay">
<DialogHeader>Replay this run?</DialogHeader>
<DialogDescription>
Replaying a run will create a new run with the same payload and environment as the original.
</DialogDescription>
<DialogFooter>
<Form action={formAction} method="post">
<input type="hidden" name="failedRedirect" value={failedRedirect} />
<Button
type="submit"
variant="primary/small"
LeadingIcon={isLoading ? "spinner-white" : ArrowPathIcon}
disabled={isLoading}
shortcut={{ modifiers: ["meta"], key: "enter" }}
>
{isLoading ? "Replaying..." : "Replay run"}
</Button>
</Form>
</DialogFooter>
</DialogContent>
);
}
@@ -1,4 +1,4 @@
import { TrashIcon } from "@heroicons/react/20/solid";
import { XMarkIcon } from "@heroicons/react/20/solid";
import { useNavigate } from "@remix-run/react";
import type { TaskRunStatus as TaskRunStatusType } from "@trigger.dev/database";
import { RuntimeEnvironment, TaskRunAttemptStatus, TaskRunStatus } from "@trigger.dev/database";
@@ -27,6 +27,7 @@ import { TaskRunStatusCombo, descriptionForTaskRunStatus } from "./TaskRunStatus
export const allTaskRunStatuses = [
"PENDING",
"WAITING_FOR_DEPLOY",
"EXECUTING",
"RETRYING_AFTER_FAILURE",
"WAITING_TO_RESUME",
@@ -228,23 +229,25 @@ export function RunsFilters({ possibleEnvironments, possibleTasks }: RunFiltersP
All tasks
</Paragraph>
</SelectItem>
{possibleTasks.map((task) => (
<SelectItem key={task} value={task}>
<Paragraph
variant="extra-small"
className="pl-0.5 transition group-hover:text-text-bright"
>
{task}
</Paragraph>
</SelectItem>
))}
{possibleTasks
.sort((a, b) => a.localeCompare(b)) // 🔤
.map((task) => (
<SelectItem key={task} value={task}>
<Paragraph
variant="extra-small"
className="pl-0.5 transition group-hover:text-text-bright"
>
{task}
</Paragraph>
</SelectItem>
))}
</SelectContent>
</Select>
</SelectGroup>
<TimeFrameFilter from={from} to={to} onRangeChanged={handleTimeFrameChange} />
<Button variant="minimal/small" onClick={() => clearFilters()} LeadingIcon={TrashIcon} />
<Button variant="minimal/small" onClick={() => clearFilters()} LeadingIcon={XMarkIcon} />
</div>
);
}
@@ -1,6 +1,5 @@
import {
ClockIcon,
FolderIcon,
HandRaisedIcon,
InformationCircleIcon,
Squares2X2Icon,
@@ -41,6 +40,8 @@ export function RunIcon({ name, className, spanName }: TaskIconProps) {
switch (name) {
case "task":
return <TaskIcon className={cn(className, "text-blue-500")} />;
case "scheduled":
return <ClockIcon className={cn(className, "text-sun-500")} />;
case "attempt":
return <AttemptIcon className={cn(className, "text-text-dimmed")} />;
case "wait":
@@ -0,0 +1,162 @@
import { TrashIcon, XMarkIcon } from "@heroicons/react/20/solid";
import { useNavigate } from "@remix-run/react";
import { RuntimeEnvironment } from "@trigger.dev/database";
import { useCallback } from "react";
import { z } from "zod";
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
import { EnvironmentLabel } from "../../environments/EnvironmentLabel";
import { Button } from "../../primitives/Buttons";
import { Paragraph } from "../../primitives/Paragraph";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "../../primitives/Select";
import { Input } from "~/components/primitives/Input";
import { useDebounce } from "~/hooks/useDebounce";
import { useThrottle } from "~/hooks/useThrottle";
export const ScheduleListFilters = z.object({
page: z.coerce.number().default(1),
tasks: z
.string()
.optional()
.transform((value) => (value ? value.split(",") : undefined)),
environments: z
.string()
.optional()
.transform((value) => (value ? value.split(",") : undefined)),
search: z.string().optional(),
});
export type ScheduleListFilters = z.infer<typeof ScheduleListFilters>;
const All = "ALL";
type DisplayableEnvironment = Pick<RuntimeEnvironment, "type" | "id"> & {
userName?: string;
};
type ScheduleFiltersProps = {
possibleEnvironments: DisplayableEnvironment[];
possibleTasks: string[];
};
export function ScheduleFilters({ possibleEnvironments, possibleTasks }: ScheduleFiltersProps) {
const navigate = useNavigate();
const location = useOptimisticLocation();
const searchParams = new URLSearchParams(location.search);
const { environments, tasks, page, search } = ScheduleListFilters.parse(
Object.fromEntries(searchParams.entries())
);
const handleFilterChange = useCallback((filterType: string, value: string | undefined) => {
if (value) {
searchParams.set(filterType, value);
} else {
searchParams.delete(filterType);
}
searchParams.delete("page");
navigate(`${location.pathname}?${searchParams.toString()}`);
}, []);
const handleTaskChange = useCallback((value: string | typeof All) => {
handleFilterChange("tasks", value === "ALL" ? undefined : value);
}, []);
const handleEnvironmentChange = useCallback((value: string | typeof All) => {
handleFilterChange("environments", value === "ALL" ? undefined : value);
}, []);
const handleSearchChange = useThrottle((value: string) => {
handleFilterChange("search", value.length === 0 ? undefined : value);
}, 300);
const clearFilters = useCallback(() => {
searchParams.delete("page");
searchParams.delete("enabled");
searchParams.delete("tasks");
searchParams.delete("environments");
searchParams.delete("search");
navigate(`${location.pathname}?${searchParams.toString()}`);
}, []);
return (
<div className="flex w-full flex-row">
<Input
name="search"
placeholder="Search schedule id, external id, deduplication id or CRON pattern"
icon="search"
variant="tertiary"
className="grow"
defaultValue={search}
onChange={(e) => handleSearchChange(e.target.value)}
/>
<SelectGroup>
<Select
name="environment"
value={environments?.at(0) ?? "ALL"}
onValueChange={handleEnvironmentChange}
>
<SelectTrigger size="minimal" width="full">
<SelectValue
placeholder={"Select environment"}
className="ml-2 whitespace-nowrap p-0"
/>
</SelectTrigger>
<SelectContent>
<SelectItem value={"ALL"}>
<Paragraph
variant="extra-small"
className="whitespace-nowrap pl-0.5 transition group-hover:text-text-bright"
>
All environments
</Paragraph>
</SelectItem>
{possibleEnvironments.map((env) => (
<SelectItem key={env.id} value={env.id}>
<div className="flex items-center gap-x-2">
<EnvironmentLabel environment={env} userName={env.userName} />
<Paragraph variant="extra-small">environment</Paragraph>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</SelectGroup>
<SelectGroup>
<Select name="tasks" value={tasks?.at(0) ?? "ALL"} onValueChange={handleTaskChange}>
<SelectTrigger size="minimal" width="full">
<SelectValue placeholder="Select task" className="ml-2 p-0" />
</SelectTrigger>
<SelectContent>
<SelectItem value={"ALL"}>
<Paragraph
variant="extra-small"
className="whitespace-nowrap pl-0.5 transition group-hover:text-text-bright"
>
All tasks
</Paragraph>
</SelectItem>
{possibleTasks.map((task) => (
<SelectItem key={task} value={task}>
<Paragraph
variant="extra-small"
className="pl-0.5 transition group-hover:text-text-bright"
>
{task}
</Paragraph>
</SelectItem>
))}
</SelectContent>
</Select>
</SelectGroup>
<Button variant="minimal/small" onClick={() => clearFilters()} LeadingIcon={XMarkIcon} />
</div>
);
}
@@ -16,6 +16,7 @@ import { cn } from "~/utils/cn";
const taskRunStatusDescriptions: Record<TaskRunStatus, string> = {
PENDING: "Task is waiting to be executed",
WAITING_FOR_DEPLOY: "Task needs to be deployed first to start executing",
EXECUTING: "Task is currently being executed",
RETRYING_AFTER_FAILURE: "Task is being reattempted after a failure",
WAITING_TO_RESUME: "Task has been frozen and is waiting to be resumed",
@@ -63,6 +64,8 @@ export function TaskRunStatusIcon({
switch (status) {
case "PENDING":
return <RectangleStackIcon className={cn(runStatusClassNameColor(status), className)} />;
case "WAITING_FOR_DEPLOY":
return <RectangleStackIcon className={cn(runStatusClassNameColor(status), className)} />;
case "EXECUTING":
return <Spinner className={cn(runStatusClassNameColor(status), className)} />;
case "WAITING_TO_RESUME":
@@ -95,6 +98,8 @@ export function runStatusClassNameColor(status: TaskRunStatus): string {
switch (status) {
case "PENDING":
return "text-charcoal-500";
case "WAITING_FOR_DEPLOY":
return "text-amber-500";
case "EXECUTING":
case "RETRYING_AFTER_FAILURE":
return "text-pending";
@@ -125,6 +130,8 @@ export function runStatusTitle(status: TaskRunStatus): string {
switch (status) {
case "PENDING":
return "Queued";
case "WAITING_FOR_DEPLOY":
return "Waiting for deploy";
case "EXECUTING":
return "Executing";
case "WAITING_TO_RESUME":
@@ -24,10 +24,11 @@ import { formatDuration } from "@trigger.dev/core/v3";
import { TaskRunStatusCombo } from "./TaskRunStatus";
import { useEnvironments } from "~/hooks/useEnvironments";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { StopCircleIcon } from "@heroicons/react/20/solid";
import { ArrowPathIcon, StopCircleIcon } from "@heroicons/react/20/solid";
import { Dialog, DialogTrigger } from "~/components/primitives/Dialog";
import { CancelRunDialog } from "./CancelRunDialog";
import { useLocation } from "@remix-run/react";
import { ReplayRunDialog } from "./ReplayRunDialog";
type RunsTableProps = {
total: number;
@@ -49,7 +50,6 @@ export function TaskRunsTable({
}: RunsTableProps) {
const organization = useOrganization();
const project = useProject();
const location = useLocation();
return (
<Table>
@@ -110,23 +110,7 @@ export function TaskRunsTable({
<TableCell to={path}>
{run.createdAt ? <DateTime date={run.createdAt} /> : ""}
</TableCell>
{run.isCancellable ? (
<TableCellMenu isSticky>
<Dialog>
<DialogTrigger asChild>
<Button variant="small-menu-item" LeadingIcon={StopCircleIcon}>
Cancel run
</Button>
</DialogTrigger>
<CancelRunDialog
runFriendlyId={run.friendlyId}
redirectPath={`${location.pathname}${location.search}`}
/>
</Dialog>
</TableCellMenu>
) : (
<TableCell to={path}>{""}</TableCell>
)}
<RunActionsCell run={run} path={path} />
</TableRow>
);
})
@@ -144,6 +128,43 @@ export function TaskRunsTable({
);
}
function RunActionsCell({ run, path }: { run: RunListItem; path: string }) {
const location = useLocation();
if (!run.isCancellable && !run.isReplayable) return <TableCell to={path}>{""}</TableCell>;
return (
<TableCellMenu isSticky>
{run.isCancellable && (
<Dialog>
<DialogTrigger asChild>
<Button variant="small-menu-item" LeadingIcon={StopCircleIcon}>
Cancel run
</Button>
</DialogTrigger>
<CancelRunDialog
runFriendlyId={run.friendlyId}
redirectPath={`${location.pathname}${location.search}`}
/>
</Dialog>
)}
{run.isReplayable && (
<Dialog>
<DialogTrigger asChild>
<Button variant="small-menu-item" LeadingIcon={ArrowPathIcon}>
Replay run
</Button>
</DialogTrigger>
<ReplayRunDialog
runFriendlyId={run.friendlyId}
failedRedirect={`${location.pathname}${location.search}`}
/>
</Dialog>
)}
</TableCellMenu>
);
}
function NoRuns({ title }: { title: string }) {
return (
<div className="flex items-center justify-center">
@@ -0,0 +1,36 @@
import { ClockIcon } from "@heroicons/react/20/solid";
import { TaskTriggerSource } from "@trigger.dev/database";
import { TaskIcon } from "~/assets/icons/TaskIcon";
import { cn } from "~/utils/cn";
export function TaskTriggerSourceIcon({
source,
className,
}: {
source: TaskTriggerSource;
className?: string;
}) {
switch (source) {
case "STANDARD": {
return (
<div className={cn("grid size-4 place-items-center text-blue-500", className)}>
<TaskIcon className="size-[87.5%]" />
</div>
);
}
case "SCHEDULED": {
return <ClockIcon className={cn("size-4 text-sun-500", className)} />;
}
}
}
export function taskTriggerSourceDescription(source: TaskTriggerSource) {
switch (source) {
case "STANDARD": {
return "Standard task";
}
case "SCHEDULED": {
return "Scheduled task";
}
}
}
+21
View File
@@ -4,6 +4,7 @@ import { z } from "zod";
import { logger } from "./services/logger.server";
import { env } from "./env.server";
import { singleton } from "./utils/singleton";
import { isValidDatabaseUrl } from "./utils/db";
export type PrismaTransactionClient = Omit<
PrismaClient,
@@ -138,3 +139,23 @@ export type { PrismaClient } from "@trigger.dev/database";
export const PrismaErrorSchema = z.object({
code: z.string(),
});
function getDatabaseSchema() {
if (!isValidDatabaseUrl(env.DATABASE_URL)) {
throw new Error("Invalid Database URL");
}
const databaseUrl = new URL(env.DATABASE_URL);
const schemaFromSearchParam = databaseUrl.searchParams.get("schema");
if (!schemaFromSearchParam) {
console.debug("❗ database schema unspecified, will default to `public` schema");
return "public";
}
return schemaFromSearchParam;
}
export const DATABASE_SCHEMA = singleton("DATABASE_SCHEMA", getDatabaseSchema);
export const sqlDatabaseSchema = Prisma.sql([`${DATABASE_SCHEMA}`]);
+15 -2
View File
@@ -1,13 +1,24 @@
import { z } from "zod";
import { SecretStoreOptionsSchema } from "./services/secrets/secretStore.server";
import { isValidRegex } from "./utils/regex";
import { isValidDatabaseUrl } from "./utils/db";
const EnvironmentSchema = z.object({
NODE_ENV: z.union([z.literal("development"), z.literal("production"), z.literal("test")]),
DATABASE_URL: z.string(),
DATABASE_URL: z
.string()
.refine(
isValidDatabaseUrl,
"DATABASE_URL is invalid, for details please check the additional output above this message."
),
DATABASE_CONNECTION_LIMIT: z.coerce.number().int().default(10),
DATABASE_POOL_TIMEOUT: z.coerce.number().int().default(60),
DIRECT_URL: z.string(),
DIRECT_URL: z
.string()
.refine(
isValidDatabaseUrl,
"DIRECT_URL is invalid, for details please check the additional output above this message."
),
SESSION_SECRET: z.string(),
MAGIC_LINK_SECRET: z.string(),
ENCRYPTION_KEY: z.string(),
@@ -54,6 +65,7 @@ const EnvironmentSchema = z.object({
AWS_SQS_BATCH_SIZE: z.coerce.number().int().optional().default(1),
AWS_SQS_WAIT_TIME_MS: z.coerce.number().int().optional().default(100),
DISABLE_SSE: z.string().optional(),
OPENAI_API_KEY: z.string().optional(),
// Redis options
REDIS_HOST: z.string().optional(),
@@ -139,6 +151,7 @@ const EnvironmentSchema = z.object({
// this means 1/20 traces or 5% of traces will be sampled (sampled = recorded)
INTERNAL_OTEL_TRACE_SAMPLING_RATE: z.string().default("20"),
INTERNAL_OTEL_TRACE_INSTRUMENT_PRISMA_ENABLED: z.string().default("0"),
INTERNAL_OTEL_TRACE_DISABLED: z.string().default("0"),
});
export type Environment = z.infer<typeof EnvironmentSchema>;
+17 -8
View File
@@ -1,14 +1,23 @@
import { useRef } from "react";
import { useEffect, useRef } from "react";
export function useThrottle<T extends (...args: any[]) => any>(fn: T, delay: number) {
export function useThrottle(fn: (...args: any[]) => void, duration: number) {
const timeout = useRef<ReturnType<typeof setTimeout>>();
return (...args: Parameters<T>) => {
if (!timeout.current) {
fn(...args);
timeout.current = setTimeout(() => {
timeout.current = undefined;
}, delay);
// Clean up when the component is unmounted
useEffect(() => {
return () => {
if (timeout.current) clearTimeout(timeout.current);
};
}, []);
return (...args: Parameters<typeof fn>) => {
if (timeout.current) {
clearTimeout(timeout.current);
}
timeout.current = setTimeout(() => {
fn(...args);
timeout.current = undefined;
}, duration);
};
}
+19
View File
@@ -0,0 +1,19 @@
import { OpenMetricsContentType, Registry, collectDefaultMetrics, register } from "prom-client";
import { singleton } from "./utils/singleton";
import { env } from "./env.server";
export const metricsRegister = singleton("metricsRegister", initializeMetricsRegister);
function initializeMetricsRegister() {
const registry = new Registry<OpenMetricsContentType>();
register.setDefaultLabels({
serviceName: env.SERVICE_NAME,
});
registry.setContentType("application/openmetrics-text; version=1.0.0; charset=utf-8");
collectDefaultMetrics({ register: registry });
return registry;
}
+13
View File
@@ -97,3 +97,16 @@ export async function createProject(
return project;
}
export async function findProjectBySlug(orgSlug: string, projectSlug: string, userId: string) {
// Find the project scoped to the organization, making sure the user belongs to that org
return await prisma.project.findFirst({
where: {
slug: projectSlug,
organization: {
slug: orgSlug,
members: { some: { userId } },
},
},
});
}
@@ -62,3 +62,59 @@ export async function findEnvironmentById(id: string) {
return environment;
}
export async function createNewSession(environment: RuntimeEnvironment, ipAddress: string) {
return prisma.$transaction(async (tx) => {
const session = await tx.runtimeEnvironmentSession.create({
data: {
environmentId: environment.id,
ipAddress,
},
});
await tx.runtimeEnvironment.update({
where: {
id: environment.id,
},
data: {
currentSessionId: session.id,
},
});
return session;
});
}
export async function disconnectSession(environmentId: string) {
return prisma.$transaction(async (tx) => {
const environment = await tx.runtimeEnvironment.findUnique({
where: {
id: environmentId,
},
});
if (!environment || !environment.currentSessionId) {
return null;
}
const session = await tx.runtimeEnvironmentSession.update({
where: {
id: environment.currentSessionId,
},
data: {
disconnectedAt: new Date(),
},
});
await tx.runtimeEnvironment.update({
where: {
id: environment.id,
},
data: {
currentSessionId: null,
},
});
return session;
});
}
@@ -3,7 +3,7 @@ import {
DisplayPropertySchema,
EventSpecificationSchema,
} from "@trigger.dev/core";
import { PrismaClient, Prisma, prisma } from "~/db.server";
import { PrismaClient, Prisma, prisma, sqlDatabaseSchema } from "~/db.server";
import { Organization } from "~/models/organization.server";
import { Project } from "~/models/project.server";
import { User } from "~/models/user.server";
@@ -122,7 +122,7 @@ export class JobListPresenter {
"jobId",
ROW_NUMBER() OVER(PARTITION BY "jobId" ORDER BY "createdAt" DESC) as rn
FROM
"JobRun"
${sqlDatabaseSchema}."JobRun"
WHERE
"jobId" IN (${Prisma.join(jobs.map((j) => j.id))})
) t
@@ -1,5 +1,5 @@
import { estimate } from "@trigger.dev/billing";
import { PrismaClient, prisma } from "~/db.server";
import { sqlDatabaseSchema, PrismaClient, prisma } from "~/db.server";
import { featuresForRequest } from "~/features.server";
import { BillingService } from "~/services/billing.server";
@@ -53,7 +53,7 @@ export class OrgUsagePresenter {
month: string;
count: number;
}[]
>`SELECT TO_CHAR("createdAt", 'YYYY-MM') as month, COUNT(*) as count FROM "JobRun" WHERE "organizationId" = ${organization.id} AND "createdAt" >= NOW() - INTERVAL '6 months' AND "internal" = FALSE GROUP BY month ORDER BY month ASC`;
>`SELECT TO_CHAR("createdAt", 'YYYY-MM') as month, COUNT(*) as count FROM ${sqlDatabaseSchema}."JobRun" WHERE "organizationId" = ${organization.id} AND "createdAt" >= NOW() - INTERVAL '6 months' AND "internal" = FALSE GROUP BY month ORDER BY month ASC`;
const hasMonthlyRunData = monthlyRunsDataRaw.length > 0;
const monthlyRunsData = monthlyRunsDataRaw.map((obj) => ({
@@ -117,7 +117,7 @@ export class OrgUsagePresenter {
const dailyRunsRawData = await this.#prismaClient.$queryRaw<
{ day: Date; runs: BigInt }[]
>`SELECT date_trunc('day', "createdAt") as day, COUNT(*) as runs FROM "JobRun" WHERE "organizationId" = ${organization.id} AND "createdAt" >= NOW() - INTERVAL '30 days' AND "internal" = FALSE GROUP BY day`;
>`SELECT date_trunc('day', "createdAt") as day, COUNT(*) as runs FROM ${sqlDatabaseSchema}."JobRun" WHERE "organizationId" = ${organization.id} AND "createdAt" >= NOW() - INTERVAL '30 days' AND "internal" = FALSE GROUP BY day`;
const hasDailyRunsData = dailyRunsRawData.length > 0;
const dailyRunsDataFilledIn = fillInMissingDailyRuns(ThirtyDaysAgo, 31, dailyRunsRawData);
@@ -1,5 +1,5 @@
import { WorkerDeploymentStatus } from "@trigger.dev/database";
import { PrismaClient, prisma } from "~/db.server";
import { sqlDatabaseSchema, PrismaClient, prisma } from "~/db.server";
import { Organization } from "~/models/organization.server";
import { Project } from "~/models/project.server";
import { User } from "~/models/user.server";
@@ -97,7 +97,7 @@ export class DeploymentListPresenter {
wd."id",
wd."shortCode",
wd."version",
(SELECT COUNT(*) FROM "BackgroundWorkerTask" WHERE "BackgroundWorkerTask"."workerId" = wd."workerId") AS "tasksCount",
(SELECT COUNT(*) FROM ${sqlDatabaseSchema}."BackgroundWorkerTask" WHERE "BackgroundWorkerTask"."workerId" = wd."workerId") AS "tasksCount",
wd."environmentId",
wd."status",
u."id" AS "userId",
@@ -106,9 +106,9 @@ export class DeploymentListPresenter {
u."avatarUrl" AS "userAvatarUrl",
wd."deployedAt"
FROM
"WorkerDeployment" as wd
${sqlDatabaseSchema}."WorkerDeployment" as wd
INNER JOIN
"User" as u ON wd."triggeredById" = u."id"
${sqlDatabaseSchema}."User" as u ON wd."triggeredById" = u."id"
WHERE
wd."projectId" = ${project.id}
ORDER BY
@@ -12,6 +12,12 @@ import { User } from "~/models/user.server";
import { safeJsonParse } from "~/utils/json";
import { getUsername } from "~/utils/username";
export type ErrorData = {
name: string;
message: string;
stack?: string;
};
export class DeploymentPresenter {
#prismaClient: PrismaClient;
@@ -133,7 +139,7 @@ export class DeploymentPresenter {
};
}
#prepareErrorData(errorData: WorkerDeployment["errorData"]) {
#prepareErrorData(errorData: WorkerDeployment["errorData"]): ErrorData | undefined {
if (!errorData) {
return;
}
@@ -0,0 +1,135 @@
import { RuntimeEnvironmentType } from "@trigger.dev/database";
import { PrismaClient, prisma } from "~/db.server";
type EditScheduleOptions = {
userId: string;
projectSlug: string;
friendlyId?: string;
};
export type EditableScheduleElements = Awaited<ReturnType<EditSchedulePresenter["call"]>>;
type Environment = {
id: string;
type: RuntimeEnvironmentType;
userName?: string;
};
export class EditSchedulePresenter {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call({ userId, projectSlug, friendlyId }: EditScheduleOptions) {
// Find the project scoped to the organization
const project = await this.#prismaClient.project.findFirstOrThrow({
select: {
id: true,
environments: {
select: {
id: true,
type: true,
slug: true,
orgMember: {
select: {
user: {
select: {
id: true,
name: true,
displayName: true,
},
},
},
},
},
},
},
where: {
slug: projectSlug,
organization: {
members: {
some: {
userId,
},
},
},
},
});
const possibleTasks = await this.#prismaClient.backgroundWorkerTask.findMany({
distinct: ["slug"],
where: {
projectId: project.id,
triggerSource: "SCHEDULED",
},
});
const possibleEnvironments = project.environments.map((environment) => {
let userName: undefined | string;
if (environment.orgMember) {
if (environment.orgMember.user.id !== userId) {
userName =
environment.orgMember.user.displayName ?? environment.orgMember.user.name ?? undefined;
}
}
return {
id: environment.id,
type: environment.type,
userName,
};
});
return {
possibleTasks: possibleTasks.map((task) => task.slug),
possibleEnvironments,
schedule: await this.#getExistingSchedule(friendlyId, possibleEnvironments),
};
}
async #getExistingSchedule(scheduleId: string | undefined, possibleEnvironments: Environment[]) {
if (!scheduleId) {
return undefined;
}
const schedule = await this.#prismaClient.taskSchedule.findFirst({
select: {
id: true,
friendlyId: true,
generatorExpression: true,
externalId: true,
deduplicationKey: true,
userProvidedDeduplicationKey: true,
taskIdentifier: true,
instances: {
select: {
environmentId: true,
},
},
active: true,
},
where: {
friendlyId: scheduleId,
},
});
if (!schedule) {
return undefined;
}
return {
...schedule,
cron: schedule.generatorExpression,
environments: schedule.instances.map((instance) => {
const environment = possibleEnvironments.find((env) => env.id === instance.environmentId);
if (!environment) {
throw new Error(`Environment with id ${instance.environmentId} not found`);
}
return environment;
}),
};
}
}
@@ -1,22 +1,22 @@
import { Prisma, TaskRunAttemptStatus, TaskRunStatus } from "@trigger.dev/database";
import { Prisma, TaskRunStatus } from "@trigger.dev/database";
import { Direction } from "~/components/runs/RunStatuses";
import { PrismaClient, prisma } from "~/db.server";
import { sqlDatabaseSchema, PrismaClient, prisma } from "~/db.server";
import { getUsername } from "~/utils/username";
import { CANCELLABLE_STATUSES } from "~/v3/services/cancelTaskRun.server";
type RunListOptions = {
userId: string;
projectSlug: string;
//filters
tasks: string[] | undefined;
versions: string[] | undefined;
statuses: TaskRunStatus[] | undefined;
environments: string[] | undefined;
from: number | undefined;
to: number | undefined;
tasks?: string[];
versions?: string[];
statuses?: TaskRunStatus[];
environments?: string[];
scheduleId?: string;
from?: number;
to?: number;
//pagination
direction: Direction | undefined;
cursor: string | undefined;
direction?: Direction;
cursor?: string;
pageSize?: number;
};
@@ -34,12 +34,12 @@ export class RunListPresenter {
}
public async call({
userId,
projectSlug,
tasks,
versions,
statuses,
environments,
scheduleId,
from,
to,
direction = "forward",
@@ -52,7 +52,9 @@ export class RunListPresenter {
tasks !== undefined ||
versions !== undefined ||
hasStatusFilters ||
environments !== undefined;
environments !== undefined ||
from !== undefined ||
to !== undefined;
// Find the project scoped to the organization
const project = await this.#prismaClient.project.findFirstOrThrow({
@@ -83,11 +85,12 @@ export class RunListPresenter {
});
//get all possible tasks
const possibleTasks = await this.#prismaClient.$queryRaw<{ slug: string }[]>`
SELECT DISTINCT(slug)
FROM "BackgroundWorkerTask"
WHERE "projectId" = ${project.id};
`;
const possibleTasks = await this.#prismaClient.backgroundWorkerTask.findMany({
distinct: ["slug"],
where: {
projectId: project.id,
},
});
//get the runs
let runs = await this.#prismaClient.$queryRaw<
@@ -120,15 +123,15 @@ export class RunListPresenter {
tr."isTest" AS "isTest",
COUNT(tra.id) AS attempts
FROM
"TaskRun" tr
${sqlDatabaseSchema}."TaskRun" tr
LEFT JOIN
(
SELECT *,
ROW_NUMBER() OVER (PARTITION BY "taskRunId" ORDER BY "createdAt" DESC) rn
FROM "TaskRunAttempt"
FROM ${sqlDatabaseSchema}."TaskRunAttempt"
) tra ON tr.id = tra."taskRunId" AND tra.rn = 1
LEFT JOIN
"BackgroundWorker" bw ON tra."backgroundWorkerId" = bw.id
${sqlDatabaseSchema}."BackgroundWorker" bw ON tra."backgroundWorkerId" = bw.id
WHERE
-- project
tr."projectId" = ${project.id}
@@ -160,6 +163,7 @@ export class RunListPresenter {
? Prisma.sql`AND tr."runtimeEnvironmentId" IN (${Prisma.join(environments)})`
: Prisma.empty
}
${scheduleId ? Prisma.sql`AND tr."scheduleId" = ${scheduleId}` : Prisma.empty}
${
from
? Prisma.sql`AND tr."createdAt" >= ${new Date(from).toISOString()}::timestamp`
@@ -222,6 +226,7 @@ export class RunListPresenter {
version: run.version,
taskIdentifier: run.taskIdentifier,
attempts: Number(run.attempts),
isReplayable: true,
isCancellable: CANCELLABLE_STATUSES.includes(run.status),
environment: {
type: environment.type,
@@ -0,0 +1,272 @@
import { Prisma, RuntimeEnvironmentType } from "@trigger.dev/database";
import { ScheduleListFilters } from "~/components/runs/v3/ScheduleFilters";
import { PrismaClient, prisma, sqlDatabaseSchema } from "~/db.server";
import { getUsername } from "~/utils/username";
import { calculateNextScheduledTimestamp } from "~/v3/utils/calculateNextSchedule.server";
type ScheduleListOptions = {
projectId: string;
userId?: string;
pageSize?: number;
} & ScheduleListFilters;
const DEFAULT_PAGE_SIZE = 20;
export type ScheduleListItem = {
id: string;
friendlyId: string;
taskIdentifier: string;
deduplicationKey: string | null;
userProvidedDeduplicationKey: boolean;
cron: string;
cronDescription: string;
externalId: string | null;
nextRun: Date;
lastRun: Date | undefined;
active: boolean;
environments: {
id: string;
type: RuntimeEnvironmentType;
userName?: string;
}[];
};
export type ScheduleList = Awaited<ReturnType<ScheduleListPresenter["call"]>>;
export type ScheduleListAppliedFilters = ScheduleList["filters"];
export class ScheduleListPresenter {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call({
userId,
projectId,
tasks,
environments,
search,
page,
pageSize = DEFAULT_PAGE_SIZE,
}: ScheduleListOptions) {
const hasFilters =
tasks !== undefined || environments !== undefined || (search !== undefined && search !== "");
// Find the project scoped to the organization
const project = await this.#prismaClient.project.findFirstOrThrow({
select: {
id: true,
environments: {
select: {
id: true,
type: true,
slug: true,
orgMember: {
select: {
user: {
select: {
id: true,
name: true,
displayName: true,
},
},
},
},
},
},
},
where: {
id: projectId,
},
});
//get all possible scheduled tasks
const possibleTasks = await this.#prismaClient.backgroundWorkerTask.findMany({
distinct: ["slug"],
where: {
projectId: project.id,
triggerSource: "SCHEDULED",
},
});
//do this here to protect against SQL injection
search = search && search !== "" ? `%${search}%` : undefined;
const totalCount = await this.#prismaClient.taskSchedule.count({
where: {
projectId: project.id,
taskIdentifier: tasks ? { in: tasks } : undefined,
instances: {
some: {
environmentId: environments ? { in: environments } : undefined,
},
},
AND: search
? {
OR: [
{
externalId: {
contains: search,
mode: "insensitive",
},
},
{
friendlyId: {
contains: search,
mode: "insensitive",
},
},
{
deduplicationKey: {
contains: search,
mode: "insensitive",
},
},
{
generatorExpression: {
contains: search,
mode: "insensitive",
},
},
],
}
: undefined,
},
});
const rawSchedules = await this.#prismaClient.taskSchedule.findMany({
select: {
id: true,
friendlyId: true,
taskIdentifier: true,
deduplicationKey: true,
userProvidedDeduplicationKey: true,
generatorExpression: true,
generatorDescription: true,
externalId: true,
instances: {
select: {
environmentId: true,
},
},
active: true,
},
where: {
projectId: project.id,
taskIdentifier: tasks ? { in: tasks } : undefined,
instances: environments
? {
some: {
environmentId: environments ? { in: environments } : undefined,
},
}
: undefined,
AND: search
? {
OR: [
{
externalId: {
contains: search,
mode: "insensitive",
},
},
{
friendlyId: {
contains: search,
mode: "insensitive",
},
},
{
deduplicationKey: {
contains: search,
mode: "insensitive",
},
},
{
generatorExpression: {
contains: search,
mode: "insensitive",
},
},
],
}
: undefined,
},
take: pageSize,
skip: (page - 1) * pageSize,
});
const latestRuns =
rawSchedules.length > 0
? await this.#prismaClient.$queryRaw<{ scheduleId: string; createdAt: Date }[]>`
SELECT t."scheduleId", t."createdAt"
FROM (
SELECT "scheduleId", MAX("createdAt") as "LatestRun"
FROM ${sqlDatabaseSchema}."TaskRun"
WHERE "scheduleId" IN (${Prisma.join(rawSchedules.map((s) => s.id))})
GROUP BY "scheduleId"
) r
JOIN ${sqlDatabaseSchema}."TaskRun" t
ON t."scheduleId" = r."scheduleId" AND t."createdAt" = r."LatestRun";`
: [];
const schedules = rawSchedules.map((schedule) => {
const latestRun = latestRuns.find((r) => r.scheduleId === schedule.id);
return {
id: schedule.id,
friendlyId: schedule.friendlyId,
taskIdentifier: schedule.taskIdentifier,
deduplicationKey: schedule.deduplicationKey,
userProvidedDeduplicationKey: schedule.userProvidedDeduplicationKey,
cron: schedule.generatorExpression,
cronDescription: schedule.generatorDescription,
active: schedule.active,
externalId: schedule.externalId,
lastRun: latestRun?.createdAt,
nextRun: calculateNextScheduledTimestamp(schedule.generatorExpression),
environments: schedule.instances.map((instance) => {
const environment = project.environments.find((env) => env.id === instance.environmentId);
if (!environment) {
throw new Error(
`Environment not found for TaskScheduleInstance env: ${instance.environmentId}`
);
}
return {
id: instance.environmentId,
type: environment.type,
userName:
environment.orgMember?.user.id === userId
? undefined
: getUsername(environment.orgMember?.user),
};
}),
};
});
return {
currentPage: page,
totalPages: Math.ceil(totalCount / pageSize),
totalCount: totalCount,
schedules,
possibleTasks: possibleTasks.map((task) => task.slug),
possibleEnvironments: project.environments.map((environment) => {
return {
id: environment.id,
type: environment.type,
userName:
environment.orgMember?.user.id === userId
? undefined
: getUsername(environment.orgMember?.user),
};
}),
hasFilters,
filters: {
tasks,
environments,
search,
},
};
}
}
@@ -1,5 +1,5 @@
import { Prisma, TaskRunAttemptStatus, TaskRunStatus } from "@trigger.dev/database";
import { PrismaClient, prisma } from "~/db.server";
import { Prisma, TaskRunStatus, TaskTriggerSource } from "@trigger.dev/database";
import { PrismaClient, prisma, sqlDatabaseSchema } from "~/db.server";
import { Organization } from "~/models/organization.server";
import { Project } from "~/models/project.server";
import { User } from "~/models/user.server";
@@ -61,6 +61,7 @@ export class TaskListPresenter {
filePath: string;
runtimeEnvironmentId: string;
createdAt: Date;
triggerSource: TaskTriggerSource;
}[]
>`
SELECT DISTINCT ON(bwt.slug, bwt."runtimeEnvironmentId")
@@ -69,9 +70,10 @@ export class TaskListPresenter {
bwt."exportName",
bwt."filePath",
bwt."runtimeEnvironmentId",
bwt."createdAt"
bwt."createdAt",
bwt."triggerSource"
FROM
"BackgroundWorkerTask" as bwt
${sqlDatabaseSchema}."BackgroundWorkerTask" as bwt
WHERE bwt."projectId" = ${project.id}
ORDER BY
bwt.slug,
@@ -99,7 +101,7 @@ export class TaskListPresenter {
"lockedById",
ROW_NUMBER() OVER (PARTITION BY "lockedById" ORDER BY "updatedAt" DESC) AS rn
FROM
"TaskRun"
${sqlDatabaseSchema}."TaskRun"
WHERE
"lockedById" IN(${Prisma.join(tasks.map((t) => t.id))})
) t
@@ -1,4 +1,5 @@
import { PrismaClient, prisma } from "~/db.server";
import { TaskTriggerSource } from "@trigger.dev/database";
import { sqlDatabaseSchema, PrismaClient, prisma } from "~/db.server";
import { TestSearchParams } from "~/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.test/route";
import { sortEnvironments } from "~/services/environmentSort.server";
import { createSearchParams } from "~/utils/searchParams";
@@ -89,20 +90,21 @@ export class TestPresenter {
filePath: string;
exportName: string;
friendlyId: string;
triggerSource: TaskTriggerSource;
}[]
>`WITH workers AS (
SELECT
bw.*,
ROW_NUMBER() OVER(ORDER BY string_to_array(bw.version, '.')::int[] DESC) AS rn
FROM
"BackgroundWorker" bw
${sqlDatabaseSchema}."BackgroundWorker" bw
WHERE "runtimeEnvironmentId" = ${matchingEnvironment.id}
),
latest_workers AS (SELECT * FROM workers WHERE rn = 1)
SELECT "BackgroundWorkerTask".id, version, slug as "taskIdentifier", "filePath", "exportName", "BackgroundWorkerTask"."friendlyId"
SELECT bwt.id, version, slug as "taskIdentifier", "filePath", "exportName", bwt."friendlyId"
FROM latest_workers
JOIN "BackgroundWorkerTask" ON "BackgroundWorkerTask"."workerId" = latest_workers.id
ORDER BY "BackgroundWorkerTask"."exportName" ASC;
JOIN ${sqlDatabaseSchema}."BackgroundWorkerTask" bwt ON bwt."workerId" = latest_workers.id
ORDER BY bwt."exportName" ASC;
`;
return {
@@ -117,6 +119,7 @@ export class TestPresenter {
filePath: task.filePath,
exportName: task.exportName,
friendlyId: task.friendlyId,
triggerSource: task.triggerSource,
};
}),
};
@@ -1,5 +1,11 @@
import { TaskRunAttemptStatus, TaskRunStatus } from "@trigger.dev/database";
import { PrismaClient, prisma } from "~/db.server";
import { ScheduledTaskPayload, parsePacket, prettyPrintPacket } from "@trigger.dev/core/v3";
import {
RuntimeEnvironmentType,
TaskRunAttemptStatus,
TaskRunStatus,
TaskTriggerSource,
} from "@trigger.dev/database";
import { sqlDatabaseSchema, PrismaClient, prisma } from "~/db.server";
import { getUsername } from "~/utils/username";
type TestTaskOptions = {
@@ -8,7 +14,55 @@ type TestTaskOptions = {
taskFriendlyId: string;
};
export type TestTask = Awaited<ReturnType<TestTaskPresenter["call"]>>;
type Task = {
id: string;
taskIdentifier: string;
filePath: string;
exportName: string;
friendlyId: string;
environment: {
id: string;
type: RuntimeEnvironmentType;
userId?: string;
userName?: string;
};
};
export type TestTask =
| {
triggerSource: "STANDARD";
task: Task;
runs: StandardRun[];
}
| {
triggerSource: "SCHEDULED";
task: Task;
runs: ScheduledRun[];
};
type RawRun = {
id: string;
number: BigInt;
friendlyId: string;
createdAt: Date;
status: TaskRunStatus;
payload: string;
payloadType: string;
runtimeEnvironmentId: string;
};
export type StandardRun = Omit<RawRun, "number"> & {
number: number;
};
export type ScheduledRun = Omit<RawRun, "number" | "payload"> & {
number: number;
payload: {
timestamp: Date;
lastTimestamp?: Date;
externalId?: string;
};
};
export class TestTaskPresenter {
#prismaClient: PrismaClient;
@@ -17,13 +71,14 @@ export class TestTaskPresenter {
this.#prismaClient = prismaClient;
}
public async call({ userId, projectSlug, taskFriendlyId }: TestTaskOptions) {
public async call({ userId, projectSlug, taskFriendlyId }: TestTaskOptions): Promise<TestTask> {
const task = await this.#prismaClient.backgroundWorkerTask.findFirstOrThrow({
select: {
id: true,
filePath: true,
exportName: true,
slug: true,
triggerSource: true,
runtimeEnvironment: {
select: {
id: true,
@@ -47,25 +102,14 @@ export class TestTaskPresenter {
},
});
const latestRuns = await this.#prismaClient.$queryRaw<
{
id: string;
number: BigInt;
friendlyId: string;
createdAt: Date;
status: TaskRunStatus;
payload: string;
payloadType: string;
runtimeEnvironmentId: string;
}[]
>`
const latestRuns = await this.#prismaClient.$queryRaw<RawRun[]>`
WITH taskruns AS (
SELECT
tr.*
FROM
"TaskRun" as tr
${sqlDatabaseSchema}."TaskRun" as tr
JOIN
"BackgroundWorkerTask" as bwt
${sqlDatabaseSchema}."BackgroundWorkerTask" as bwt
ON
tr."taskIdentifier" = bwt.slug
WHERE
@@ -88,32 +132,63 @@ export class TestTaskPresenter {
FROM
taskruns AS taskr
WHERE
taskr."payloadType" = 'application/json'
taskr."payloadType" = 'application/json' OR taskr."payloadType" = 'application/super+json'
ORDER BY
taskr."createdAt" DESC;`;
return {
task: {
id: task.id,
taskIdentifier: task.slug,
filePath: task.filePath,
exportName: task.exportName,
friendlyId: taskFriendlyId,
environment: {
id: task.runtimeEnvironment.id,
type: task.runtimeEnvironment.type,
userId: task.runtimeEnvironment.orgMember?.user.id,
userName: getUsername(task.runtimeEnvironment.orgMember?.user),
},
const taskWithEnvironment = {
id: task.id,
taskIdentifier: task.slug,
filePath: task.filePath,
exportName: task.exportName,
friendlyId: taskFriendlyId,
environment: {
id: task.runtimeEnvironment.id,
type: task.runtimeEnvironment.type,
userId: task.runtimeEnvironment.orgMember?.user.id,
userName: getUsername(task.runtimeEnvironment.orgMember?.user),
},
runs: latestRuns.map((r) => {
//we need to format the code on the server, because we detect if the sample has been edited by comparing the contents
try {
r.payload = JSON.stringify(JSON.parse(r.payload ?? ""), null, 2);
} catch (e) {}
return { ...r, number: Number(r.number) };
}),
};
switch (task.triggerSource) {
case "STANDARD":
return {
triggerSource: "STANDARD",
task: taskWithEnvironment,
runs: await Promise.all(
latestRuns.map(async (r) => {
const number = Number(r.number);
return {
...r,
number,
payload: await prettyPrintPacket(r.payload, r.payloadType),
};
})
),
};
case "SCHEDULED":
return {
triggerSource: "SCHEDULED",
task: taskWithEnvironment,
runs: await Promise.all(
latestRuns.map(async (r) => {
const number = Number(r.number);
return {
...r,
number,
payload: await getScheduleTaskRunPayload(r),
};
})
),
};
}
}
}
async function getScheduleTaskRunPayload(run: RawRun) {
const payload = await parsePacket({ data: run.payload, dataType: run.payloadType });
const parsed = ScheduledTaskPayload.parse(payload);
return parsed;
}
@@ -0,0 +1,131 @@
import { PrismaClient, prisma } from "~/db.server";
import { nextScheduledTimestamps } from "~/v3/utils/calculateNextSchedule.server";
import { RunListPresenter } from "./RunListPresenter.server";
import { ScheduleObject } from "@trigger.dev/core/v3";
type ViewScheduleOptions = {
userId?: string;
projectId: string;
friendlyId: string;
};
export class ViewSchedulePresenter {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call({ userId, projectId, friendlyId }: ViewScheduleOptions) {
const schedule = await this.#prismaClient.taskSchedule.findFirst({
select: {
id: true,
friendlyId: true,
generatorExpression: true,
generatorDescription: true,
externalId: true,
deduplicationKey: true,
userProvidedDeduplicationKey: true,
taskIdentifier: true,
project: {
select: {
slug: true,
},
},
instances: {
select: {
environment: {
select: {
id: true,
type: true,
orgMember: {
select: {
user: {
select: {
id: true,
name: true,
displayName: true,
},
},
},
},
},
},
},
},
active: true,
},
where: {
friendlyId,
projectId,
},
});
if (!schedule) {
return;
}
const nextRuns = schedule.active
? nextScheduledTimestamps(schedule.generatorExpression, new Date(), 5)
: [];
const runPresenter = new RunListPresenter(this.#prismaClient);
const { runs } = await runPresenter.call({
projectSlug: schedule.project.slug,
scheduleId: schedule.id,
pageSize: 5,
});
return {
schedule: {
...schedule,
cron: schedule.generatorExpression,
cronDescription: schedule.generatorDescription,
nextRuns,
runs,
environments: schedule.instances.map((instance) => {
const environment = instance.environment;
let userName: undefined | string;
if (environment.orgMember) {
if (environment.orgMember.user.id !== userId) {
userName =
environment.orgMember.user.displayName ??
environment.orgMember.user.name ??
undefined;
}
}
return {
id: environment.id,
type: environment.type,
userName,
};
}),
},
};
}
public toJSONResponse(result: NonNullable<Awaited<ReturnType<ViewSchedulePresenter["call"]>>>) {
const response: ScheduleObject = {
id: result.schedule.friendlyId,
task: result.schedule.taskIdentifier,
active: result.schedule.active,
nextRun: result.schedule.nextRuns[0],
generator: {
type: "CRON",
expression: result.schedule.cron,
description: result.schedule.cronDescription,
},
externalId: result.schedule.externalId ?? undefined,
deduplicationKey: result.schedule.userProvidedDeduplicationKey
? result.schedule.deduplicationKey ?? undefined
: undefined,
environments: result.schedule.instances.map((instance) => ({
id: instance.environment.id,
type: instance.environment.type,
})),
};
return response;
}
}
@@ -27,8 +27,13 @@ import {
TableHeaderCell,
TableRow,
} from "~/components/primitives/Table";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { TaskFunctionName, TaskPath } from "~/components/runs/v3/TaskPath";
import { TaskRunStatusCombo } from "~/components/runs/v3/TaskRunStatus";
import {
TaskTriggerSourceIcon,
taskTriggerSourceDescription,
} from "~/components/runs/v3/TaskTriggerSource";
import { useDevEnvironment } from "~/hooks/useEnvironments";
import { useEventSource } from "~/hooks/useEventSource";
import { useOrganization } from "~/hooks/useOrganizations";
@@ -121,7 +126,15 @@ export default function Page() {
});
return (
<TableRow key={task.id} className="group">
<TableCell to={path}>{task.slug}</TableCell>
<TableCell to={path}>
<div className="flex items-center gap-2">
<SimpleTooltip
button={<TaskTriggerSourceIcon source={task.triggerSource} />}
content={taskTriggerSourceDescription(task.triggerSource)}
/>
<span>{task.slug}</span>
</div>
</TableCell>
<TableCell to={path}>
<TaskFunctionName
functionName={task.exportName}
@@ -19,6 +19,7 @@ import {
TableHeaderCell,
TableRow,
} from "~/components/primitives/Table";
import { DeploymentError } from "~/components/runs/v3/DeploymentError";
import { DeploymentStatus } from "~/components/runs/v3/DeploymentStatus";
import { TaskFunctionName } from "~/components/runs/v3/TaskPath";
import { useOrganization } from "~/hooks/useOrganizations";
@@ -158,25 +159,7 @@ export default function Page() {
</Table>
</div>
) : deployment.errorData ? (
<div className="flex flex-col">
{deployment.errorData.stack ? (
<CodeBlock
language="markdown"
rowTitle={deployment.errorData.message}
code={deployment.errorData.stack}
maxLines={20}
/>
) : (
<div className="flex flex-col">
<Paragraph
variant="base/bright"
className="w-full border-b border-grid-dimmed py-2.5"
>
{deployment.errorData.message}
</Paragraph>
</div>
)}
</div>
<DeploymentError errorData={deployment.errorData} />
) : null}
</div>
</div>
@@ -1,4 +1,9 @@
import { CloudArrowDownIcon, QueueListIcon, StopCircleIcon } from "@heroicons/react/20/solid";
import {
ArrowPathIcon,
CloudArrowDownIcon,
QueueListIcon,
StopCircleIcon,
} from "@heroicons/react/20/solid";
import { useParams } from "@remix-run/react";
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { formatDurationNanoseconds, nanosecondsToMilliseconds } from "@trigger.dev/core/v3";
@@ -14,6 +19,7 @@ import { Paragraph } from "~/components/primitives/Paragraph";
import { Property, PropertyTable } from "~/components/primitives/PropertyTable";
import { CancelRunDialog } from "~/components/runs/v3/CancelRunDialog";
import { LiveTimer } from "~/components/runs/v3/LiveTimer";
import { ReplayRunDialog } from "~/components/runs/v3/ReplayRunDialog";
import { RunIcon } from "~/components/runs/v3/RunIcon";
import { SpanEvents } from "~/components/runs/v3/SpanEvents";
import { SpanTitle } from "~/components/runs/v3/SpanTitle";
@@ -22,7 +28,7 @@ import { TaskRunAttemptStatusCombo } from "~/components/runs/v3/TaskRunAttemptSt
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { redirectWithErrorMessage } from "~/models/message.server";
import { SpanPresenter } from "~/presenters/v3/SpanPresenter.server";
import { Span, SpanPresenter } from "~/presenters/v3/SpanPresenter.server";
import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import { v3RunPath, v3RunSpanPath, v3SpanParamsSchema, v3TraceSpanPath } from "~/utils/pathBuilder";
@@ -188,24 +194,7 @@ export default function Page() {
)}
</div>
<div className="flex items-center gap-4">
{event.isPartial && runParam && (
<Dialog>
<DialogTrigger asChild>
<Button variant="danger/small" LeadingIcon={StopCircleIcon}>
Cancel run
</Button>
</DialogTrigger>
<CancelRunDialog
runFriendlyId={event.runId}
redirectPath={v3RunSpanPath(
organization,
project,
{ friendlyId: runParam },
{ spanId: event.spanId }
)}
/>
</Dialog>
)}
<RunActionButtons span={event} />
</div>
</div>
) : null}
@@ -213,6 +202,54 @@ export default function Page() {
);
}
function RunActionButtons({ span }: { span: Span }) {
const organization = useOrganization();
const project = useProject();
const { runParam } = useParams();
if (!runParam) return null;
if (span.isPartial) {
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="danger/small" LeadingIcon={StopCircleIcon}>
Cancel run
</Button>
</DialogTrigger>
<CancelRunDialog
runFriendlyId={span.runId}
redirectPath={v3RunSpanPath(
organization,
project,
{ friendlyId: runParam },
{ spanId: span.spanId }
)}
/>
</Dialog>
);
}
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="tertiary/small" LeadingIcon={ArrowPathIcon}>
Replay run
</Button>
</DialogTrigger>
<ReplayRunDialog
runFriendlyId={span.runId}
failedRedirect={v3RunSpanPath(
organization,
project,
{ friendlyId: runParam },
{ spanId: span.spanId }
)}
/>
</Dialog>
);
}
function PacketDisplay({
data,
dataType,
@@ -259,7 +296,7 @@ function Timeline({ startTime, duration, inProgress, isError }: TimelineProps) {
<div className="flex w-full flex-col">
<div className="flex items-center justify-between gap-1">
<Paragraph variant="small">
<DateTimeAccurate date={startTime} /> UTC
<DateTimeAccurate date={startTime} />
</Paragraph>
{state === "pending" ? (
<LiveTimer startTime={startTime} className="" />
@@ -12,16 +12,19 @@ import {
millisecondsToNanoseconds,
nanosecondsToMilliseconds,
} from "@trigger.dev/core/v3";
import { RuntimeEnvironmentType } from "@trigger.dev/database";
import { motion } from "framer-motion";
import { useEffect, useRef, useState } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { ShowParentIcon, ShowParentIconSelected } from "~/assets/icons/ShowParentIcon";
import tileBgPath from "~/assets/images/error-banner-tile@2x.png";
import { BlankstateInstructions } from "~/components/BlankstateInstructions";
import { InlineCode } from "~/components/code/InlineCode";
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
import { MainCenteredContainer, PageBody } from "~/components/layout/AppLayout";
import { Badge } from "~/components/primitives/Badge";
import { LinkButton } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
import { Input } from "~/components/primitives/Input";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
@@ -181,6 +184,7 @@ export default function Page() {
totalDuration={duration}
rootSpanStatus={rootSpanStatus}
rootStartedAt={rootStartedAt}
environmentType={run.environment.type}
/>
) : (
<ResizablePanelGroup
@@ -209,6 +213,7 @@ export default function Page() {
totalDuration={duration}
rootSpanStatus={rootSpanStatus}
rootStartedAt={rootStartedAt}
environmentType={run.environment.type}
/>
</ResizablePanel>
<ResizableHandle withHandle />
@@ -231,6 +236,7 @@ type TasksTreeViewProps = {
totalDuration: number;
rootSpanStatus: "executing" | "completed" | "failed";
rootStartedAt: Date | undefined;
environmentType: RuntimeEnvironmentType;
};
function TasksTreeView({
@@ -241,6 +247,7 @@ function TasksTreeView({
totalDuration,
rootSpanStatus,
rootStartedAt,
environmentType,
}: TasksTreeViewProps) {
const [filterText, setFilterText] = useState("");
const [errorsOnly, setErrorsOnly] = useState(false);
@@ -345,63 +352,68 @@ function TasksTreeView({
getNodeProps={getNodeProps}
getTreeProps={getTreeProps}
renderNode={({ node, state }) => (
<div
className={cn(
"delay-[25ms] flex h-8 cursor-pointer items-center overflow-hidden rounded-l-sm pr-2 transition-colors",
state.selected
? "bg-grid-dimmed hover:bg-grid-bright"
: "bg-transparent hover:bg-grid-dimmed"
)}
onClick={() => {
toggleNodeSelection(node.id);
}}
>
<div className="flex h-8 items-center">
{Array.from({ length: node.level }).map((_, index) => (
<TaskLine
key={index}
isError={node.data.isError}
isSelected={state.selected}
/>
))}
<div
className={cn(
"flex h-8 w-4 items-center",
node.hasChildren && "hover:bg-charcoal-600"
)}
onClick={(e) => {
e.stopPropagation();
toggleExpandNode(node.id);
scrollToNode(node.id);
}}
>
{node.hasChildren ? (
state.expanded ? (
<ChevronDownIcon className="h-4 w-4 text-charcoal-400" />
<>
<div
className={cn(
"delay-[25ms] flex h-8 cursor-pointer items-center overflow-hidden rounded-l-sm pr-2 transition-colors",
state.selected
? "bg-grid-dimmed hover:bg-grid-bright"
: "bg-transparent hover:bg-grid-dimmed"
)}
onClick={() => {
toggleNodeSelection(node.id);
}}
>
<div className="flex h-8 items-center">
{Array.from({ length: node.level }).map((_, index) => (
<TaskLine
key={index}
isError={node.data.isError}
isSelected={state.selected}
/>
))}
<div
className={cn(
"flex h-8 w-4 items-center",
node.hasChildren && "hover:bg-charcoal-600"
)}
onClick={(e) => {
e.stopPropagation();
toggleExpandNode(node.id);
scrollToNode(node.id);
}}
>
{node.hasChildren ? (
state.expanded ? (
<ChevronDownIcon className="h-4 w-4 text-charcoal-400" />
) : (
<ChevronRightIcon className="h-4 w-4 text-charcoal-400" />
)
) : (
<ChevronRightIcon className="h-4 w-4 text-charcoal-400" />
)
) : (
<div className="h-8 w-4" />
)}
<div className="h-8 w-4" />
)}
</div>
</div>
</div>
<div className="flex w-full items-center justify-between gap-2 pl-1">
<div className="flex items-center gap-2 overflow-x-hidden">
<RunIcon
name={node.data.style?.icon}
spanName={node.data.message}
className="h-4 min-h-4 w-4 min-w-4"
/>
<NodeText node={node} />
{node.data.isRoot && <Badge variant="outline-rounded">Root</Badge>}
</div>
<div className="flex items-center gap-1">
<NodeStatusIcon node={node} />
<div className="flex w-full items-center justify-between gap-2 pl-1">
<div className="flex items-center gap-2 overflow-x-hidden">
<RunIcon
name={node.data.style?.icon}
spanName={node.data.message}
className="h-4 min-h-4 w-4 min-w-4"
/>
<NodeText node={node} />
{node.data.isRoot && <Badge variant="outline-rounded">Root</Badge>}
</div>
<div className="flex items-center gap-1">
<NodeStatusIcon node={node} />
</div>
</div>
</div>
</div>
{events.length === 1 && environmentType === "DEVELOPMENT" && (
<ConnectedDevWarning />
)}
</>
)}
onScroll={(scrollTop) => {
//sync the scroll to the tree
@@ -841,3 +853,34 @@ function CurrentTimeIndicator({ totalDuration }: { totalDuration: number }) {
</Timeline.FollowCursor>
);
}
function ConnectedDevWarning() {
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const timer = setTimeout(() => {
setIsVisible(true);
}, 3000);
return () => clearTimeout(timer);
}, []);
return (
<div
className={cn(
"flex items-center overflow-hidden pl-5 pr-2 transition-opacity duration-500",
isVisible ? "opacity-100" : "h-0 opacity-0"
)}
>
<Callout variant="info">
<div className="flex flex-col gap-1">
<Paragraph variant="small">
Runs usually start within 1 second in{" "}
<EnvironmentLabel environment={{ type: "DEVELOPMENT" }} />. Check you're running the
CLI: <InlineCode className="whitespace-nowrap">npx trigger.dev@beta dev</InlineCode>
</Paragraph>
</div>
</Callout>
</div>
);
}
@@ -34,7 +34,6 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const presenter = new RunListPresenter();
const list = await presenter.call({
userId,
projectSlug: projectParam,
tasks,
versions,
@@ -64,10 +63,12 @@ export default function Page() {
<PageTitle title="Runs" />
</NavBar>
<PageBody>
{list.possibleTasks.length === 0 ? (
<CreateFirstTaskInstructions />
) : list.runs.length === 0 && !list.hasFilters ? (
<RunTaskInstructions />
{list.runs.length === 0 && !list.hasFilters ? (
list.possibleTasks.length === 0 ? (
<CreateFirstTaskInstructions />
) : (
<RunTaskInstructions />
)
) : (
<div className={cn("grid h-fit grid-cols-1 gap-4")}>
<div>
@@ -0,0 +1,340 @@
import { parse } from "@conform-to/zod";
import { BoltIcon, BoltSlashIcon, PencilSquareIcon, TrashIcon } from "@heroicons/react/20/solid";
import { DialogDescription } from "@radix-ui/react-dialog";
import { Form, useLocation } from "@remix-run/react";
import { ActionFunctionArgs, LoaderFunctionArgs, json } from "@remix-run/server-runtime";
import { token } from "morgan";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { ExitIcon } from "~/assets/icons/ExitIcon";
import { InlineCode } from "~/components/code/InlineCode";
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { DateTime } from "~/components/primitives/DateTime";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTrigger,
} from "~/components/primitives/Dialog";
import { Header2, Header3 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Property, PropertyTable } from "~/components/primitives/PropertyTable";
import {
Table,
TableBody,
TableCell,
TableHeader,
TableHeaderCell,
TableRow,
} from "~/components/primitives/Table";
import { EnabledStatus } from "~/components/runs/v3/EnabledStatus";
import { TaskRunsTable } from "~/components/runs/v3/TaskRunsTable";
import { prisma } from "~/db.server";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useUser } from "~/hooks/useUser";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import { findProjectBySlug } from "~/models/project.server";
import { ViewSchedulePresenter } from "~/presenters/v3/ViewSchedulePresenter.server";
import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import {
v3EditSchedulePath,
v3ScheduleParams,
v3SchedulePath,
v3SchedulesPath,
} from "~/utils/pathBuilder";
import { DeleteTaskScheduleService } from "~/v3/services/deleteTaskSchedule.server";
import { SetActiveOnTaskScheduleService } from "~/v3/services/setActiveOnTaskSchedule.server";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { projectParam, organizationSlug, scheduleParam } = v3ScheduleParams.parse(params);
// Find the project scoped to the organization
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
return redirectWithErrorMessage("/", request, "Project not found");
}
const presenter = new ViewSchedulePresenter();
const result = await presenter.call({
userId,
projectId: project.id,
friendlyId: scheduleParam,
});
if (!result) {
throw new Error("Schedule not found");
}
return typedjson({ schedule: result.schedule });
};
const schema = z.discriminatedUnion("action", [
z.object({
action: z.literal("delete"),
}),
z.object({
action: z.literal("enable"),
}),
z.object({
action: z.literal("disable"),
}),
]);
export const action = async ({ request, params }: ActionFunctionArgs) => {
const userId = await requireUserId(request);
const { organizationSlug, projectParam, scheduleParam } = v3ScheduleParams.parse(params);
const formData = await request.formData();
const submission = parse(formData, { schema });
if (!submission.value) {
return json(submission);
}
const project = await prisma.project.findFirst({
where: {
slug: projectParam,
},
});
if (!project) {
return redirectWithErrorMessage(
v3SchedulePath(
{ slug: organizationSlug },
{ slug: projectParam },
{ friendlyId: scheduleParam }
),
request,
`No project found with slug ${projectParam}`
);
}
switch (submission.value.action) {
case "delete": {
const deleteService = new DeleteTaskScheduleService();
try {
await deleteService.call({
projectId: project.id,
userId,
friendlyId: scheduleParam,
});
return redirectWithSuccessMessage(
v3SchedulesPath({ slug: organizationSlug }, { slug: projectParam }),
request,
`${scheduleParam} deleted`
);
} catch (e) {
return redirectWithErrorMessage(
v3SchedulePath(
{ slug: organizationSlug },
{ slug: projectParam },
{ friendlyId: scheduleParam }
),
request,
`${scheduleParam} could not be deleted: ${
e instanceof Error ? e.message : JSON.stringify(e)
}`
);
}
}
case "enable":
case "disable": {
const service = new SetActiveOnTaskScheduleService();
const active = submission.value.action === "enable";
try {
await service.call({
projectId: project.id,
userId,
friendlyId: scheduleParam,
active,
});
return redirectWithSuccessMessage(
v3SchedulePath(
{ slug: organizationSlug },
{ slug: projectParam },
{ friendlyId: scheduleParam }
),
request,
`${scheduleParam} ${active ? "enabled" : "disabled"}`
);
} catch (e) {
return redirectWithErrorMessage(
v3SchedulePath(
{ slug: organizationSlug },
{ slug: projectParam },
{ friendlyId: scheduleParam }
),
request,
`${scheduleParam} could not be ${active ? "enabled" : "disabled"}: ${
e instanceof Error ? e.message : JSON.stringify(e)
}`
);
}
}
}
};
export default function Page() {
const { schedule } = useTypedLoaderData<typeof loader>();
const location = useLocation();
const organization = useOrganization();
const project = useProject();
const user = useUser();
return (
<div className="grid h-full max-h-full grid-rows-[2.5rem_1fr_3.25rem] overflow-hidden bg-background-bright">
<div className="mx-3 flex items-center justify-between gap-2 border-b border-grid-dimmed">
<Header2 className={cn("whitespace-nowrap")}>{schedule.friendlyId}</Header2>
<LinkButton
to={`${v3SchedulesPath(organization, project)}${location.search}`}
variant="minimal/medium"
LeadingIcon={ExitIcon}
shortcut={{ key: "esc" }}
/>
</div>
<div className="overflow-y-scroll scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
<div className="p-3">
<div className="space-y-3">
<PropertyTable>
<Property label="Schedule ID">{schedule.friendlyId}</Property>
<Property label="Task ID">{schedule.taskIdentifier}</Property>
<Property label="CRON (UTC)" labelClassName="self-start">
<div className="space-y-2">
<InlineCode variant="extra-small">{schedule.cron}</InlineCode>
<Paragraph variant="small">{schedule.cronDescription}</Paragraph>
</div>
</Property>
<Property label="Environments">
<div className="flex flex-wrap gap-1">
{schedule.environments.map((env) => (
<EnvironmentLabel
key={env.id}
size="small"
environment={env}
userName={env.userName}
/>
))}
</div>
</Property>
<Property label="External ID">
{schedule.externalId ? schedule.externalId : ""}
</Property>
<Property label="Deduplication key">
{schedule.userProvidedDeduplicationKey ? schedule.deduplicationKey : ""}
</Property>
<Property label="Status">
<EnabledStatus enabled={schedule.active} />
</Property>
</PropertyTable>
<div className="flex flex-col gap-1">
<Header3>Last 5 runs</Header3>
<TaskRunsTable
total={schedule.runs.length}
hasFilters={false}
filters={{
tasks: [],
versions: [],
statuses: [],
environments: [],
from: undefined,
to: undefined,
}}
runs={schedule.runs}
isLoading={false}
currentUser={user}
/>
</div>
<div className="flex flex-col gap-1">
<Header3>Next 5 runs</Header3>
<Table>
<TableHeader>
<TableRow>
<TableHeaderCell>UTC</TableHeaderCell>
<TableHeaderCell>Local time</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{schedule.nextRuns.map((run, index) => (
<TableRow key={index}>
<TableCell>
<DateTime date={run} timeZone="UTC" />
</TableCell>
<TableCell>
<DateTime date={run} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</div>
</div>
<div className="flex items-center justify-between gap-2 border-t border-grid-dimmed px-2">
<div className="flex items-center gap-4">
<Form method="post">
<Button
type="submit"
variant="minimal/medium"
LeadingIcon={schedule.active ? BoltSlashIcon : BoltIcon}
leadingIconClassName={schedule.active ? "text-dimmed" : "text-success"}
name="action"
value={schedule.active ? "disable" : "enable"}
>
{schedule.active ? "Disable" : "Enable"}
</Button>
</Form>
<Dialog>
<DialogTrigger asChild>
<Button
type="submit"
variant="minimal/medium"
LeadingIcon={TrashIcon}
leadingIconClassName="text-error"
className="text-error"
name="action"
value="delete"
>
Delete
</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>Delete schedule</DialogHeader>
<DialogDescription>
Are you sure you want to delete this schedule? This can't be reversed.
</DialogDescription>
<DialogFooter>
<Form method="post">
<Button
type="submit"
variant="danger/small"
LeadingIcon={TrashIcon}
name="action"
value="delete"
>
Delete
</Button>
</Form>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
<div className="flex items-center gap-4">
<LinkButton
variant="tertiary/medium"
to={`${v3EditSchedulePath(organization, project, schedule)}${location.search}`}
LeadingIcon={PencilSquareIcon}
>
Edit schedule
</LinkButton>
</div>
</div>
</div>
);
}
@@ -0,0 +1,35 @@
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { EditSchedulePresenter } from "~/presenters/v3/EditSchedulePresenter.server";
import { requireUserId } from "~/services/session.server";
import { ProjectParamSchema, v3ScheduleParams } from "~/utils/pathBuilder";
import { humanToCronSupported } from "~/v3/humanToCron.server";
import { UpsertScheduleForm } from "../resources.orgs.$organizationSlug.projects.$projectParam.schedules.new/route";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { projectParam, organizationSlug, scheduleParam } = v3ScheduleParams.parse(params);
const presenter = new EditSchedulePresenter();
const result = await presenter.call({
userId,
projectSlug: projectParam,
friendlyId: scheduleParam,
});
return typedjson({ ...result, showGenerateField: humanToCronSupported });
};
export default function Page() {
const { schedule, possibleTasks, possibleEnvironments, showGenerateField } =
useTypedLoaderData<typeof loader>();
return (
<UpsertScheduleForm
schedule={schedule}
possibleTasks={possibleTasks}
possibleEnvironments={possibleEnvironments}
showGenerateField={showGenerateField}
/>
);
}
@@ -0,0 +1,34 @@
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { EditSchedulePresenter } from "~/presenters/v3/EditSchedulePresenter.server";
import { requireUserId } from "~/services/session.server";
import { ProjectParamSchema } from "~/utils/pathBuilder";
import { humanToCronSupported } from "~/v3/humanToCron.server";
import { UpsertScheduleForm } from "../resources.orgs.$organizationSlug.projects.$projectParam.schedules.new/route";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { projectParam, organizationSlug } = ProjectParamSchema.parse(params);
const presenter = new EditSchedulePresenter();
const result = await presenter.call({
userId,
projectSlug: projectParam,
});
return typedjson({ ...result, showGenerateField: humanToCronSupported });
};
export default function Page() {
const { schedule, possibleTasks, possibleEnvironments, showGenerateField } =
useTypedLoaderData<typeof loader>();
return (
<UpsertScheduleForm
schedule={schedule}
possibleTasks={possibleTasks}
possibleEnvironments={possibleEnvironments}
showGenerateField={showGenerateField}
/>
);
}
@@ -0,0 +1,290 @@
import { PlusIcon, PlusSmallIcon } from "@heroicons/react/20/solid";
import { BookOpenIcon } from "@heroicons/react/24/solid";
import { Outlet, useLocation, useParams } from "@remix-run/react";
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { BlankstateInstructions } from "~/components/BlankstateInstructions";
import { InlineCode } from "~/components/code/InlineCode";
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
import { LinkButton } from "~/components/primitives/Buttons";
import { DateTime } from "~/components/primitives/DateTime";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { PaginationControls } from "~/components/primitives/Pagination";
import { Paragraph } from "~/components/primitives/Paragraph";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "~/components/primitives/Resizable";
import {
Table,
TableBlankRow,
TableBody,
TableCell,
TableHeader,
TableHeaderCell,
TableRow,
} from "~/components/primitives/Table";
import { EnabledStatus } from "~/components/runs/v3/EnabledStatus";
import { ScheduleFilters, ScheduleListFilters } from "~/components/runs/v3/ScheduleFilters";
import { useOrganization } from "~/hooks/useOrganizations";
import { usePathName } from "~/hooks/usePathName";
import { useProject } from "~/hooks/useProject";
import { redirectWithErrorMessage } from "~/models/message.server";
import { findProjectBySlug } from "~/models/project.server";
import {
ScheduleListItem,
ScheduleListPresenter,
} from "~/presenters/v3/ScheduleListPresenter.server";
import { requireUserId } from "~/services/session.server";
import {
ProjectParamSchema,
docsPath,
v3NewSchedulePath,
v3SchedulePath,
} from "~/utils/pathBuilder";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { projectParam, organizationSlug } = ProjectParamSchema.parse(params);
const url = new URL(request.url);
const s = Object.fromEntries(url.searchParams.entries());
const filters = ScheduleListFilters.parse(s);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
return redirectWithErrorMessage("/", request, "Project not found");
}
const presenter = new ScheduleListPresenter();
const list = await presenter.call({
userId,
projectId: project.id,
...filters,
});
return typedjson(list);
};
export default function Page() {
const {
schedules,
possibleTasks,
possibleEnvironments,
hasFilters,
filters,
currentPage,
totalPages,
} = useTypedLoaderData<typeof loader>();
const location = useLocation();
const organization = useOrganization();
const project = useProject();
const pathName = usePathName();
const { scheduleParam } = useParams();
const isShowingNewPane = pathName.endsWith("/new");
const isShowingSchedule = !!scheduleParam;
return (
<PageContainer>
<NavBar>
<PageTitle title="Schedules" />
<PageAccessories>
<LinkButton
LeadingIcon={PlusIcon}
to={`${v3NewSchedulePath(organization, project)}${location.search}`}
variant="primary/small"
shortcut={{ key: "n" }}
disabled={possibleTasks.length === 0 || isShowingNewPane}
>
New schedule
</LinkButton>
</PageAccessories>
</NavBar>
<PageBody scrollable={false}>
<ResizablePanelGroup direction="horizontal" className="h-full max-h-full">
<ResizablePanel order={1} minSize={20} defaultSize={60}>
{possibleTasks.length === 0 ? (
<CreateScheduledTaskInstructions />
) : schedules.length === 0 && !hasFilters ? (
<AttachYourFirstScheduleInstructions />
) : (
<div className="p-3">
<div className="mb-2 flex items-center justify-between gap-x-2">
<ScheduleFilters
possibleEnvironments={possibleEnvironments}
possibleTasks={possibleTasks}
/>
<div className="flex items-center justify-end gap-x-2">
<PaginationControls
currentPage={currentPage}
totalPages={totalPages}
showPageNumbers={false}
/>
</div>
</div>
<SchedulesTable schedules={schedules} hasFilters={hasFilters} />
<div className="mt-2 justify-end">
<PaginationControls currentPage={currentPage} totalPages={totalPages} />
</div>
</div>
)}
</ResizablePanel>
{(isShowingNewPane || isShowingSchedule) && (
<>
<ResizableHandle withHandle />
<ResizablePanel order={2} minSize={20} defaultSize={40}>
<Outlet />
</ResizablePanel>
</>
)}
</ResizablePanelGroup>
</PageBody>
</PageContainer>
);
}
function CreateScheduledTaskInstructions() {
return (
<MainCenteredContainer className="max-w-prose">
<BlankstateInstructions title="Create your first scheduled task">
<Paragraph spacing>
You have no scheduled tasks in your project. Before you can schedule a task you need to a{" "}
<InlineCode>schedules.task</InlineCode>.
</Paragraph>
<LinkButton
to={docsPath("v3/tasks-scheduled")}
variant="primary/medium"
LeadingIcon={BookOpenIcon}
className="inline-flex"
>
Create scheduled task docs
</LinkButton>
</BlankstateInstructions>
</MainCenteredContainer>
);
}
function AttachYourFirstScheduleInstructions() {
const organization = useOrganization();
const project = useProject();
const location = useLocation();
return (
<MainCenteredContainer className="max-w-prose">
<BlankstateInstructions title="Attach your first schedule">
<Paragraph spacing>
Scheduled tasks will only run automatically if you connect a schedule to them, you can do
this in the dashboard or using the SDK.
</Paragraph>
<div className="flex gap-2">
<LinkButton
to={`${v3NewSchedulePath(organization, project)}${location.search}`}
variant="primary/small"
LeadingIcon={PlusSmallIcon}
className="inline-flex"
>
Create in the dashboard
</LinkButton>
<LinkButton
to={docsPath("v3/tasks-scheduled")}
variant="primary/small"
LeadingIcon={BookOpenIcon}
className="inline-flex"
>
Use the SDK
</LinkButton>
</div>
</BlankstateInstructions>
</MainCenteredContainer>
);
}
function SchedulesTable({
schedules,
hasFilters,
}: {
schedules: ScheduleListItem[];
hasFilters: boolean;
}) {
const organization = useOrganization();
const project = useProject();
const location = useLocation();
const { scheduleParam } = useParams();
return (
<Table>
<TableHeader>
<TableRow>
<TableHeaderCell>ID</TableHeaderCell>
<TableHeaderCell>Task ID</TableHeaderCell>
<TableHeaderCell>CRON</TableHeaderCell>
<TableHeaderCell hiddenLabel>CRON description</TableHeaderCell>
<TableHeaderCell>External ID</TableHeaderCell>
<TableHeaderCell>Deduplication key</TableHeaderCell>
<TableHeaderCell>Next run (UTC)</TableHeaderCell>
<TableHeaderCell>Last run (UTC)</TableHeaderCell>
<TableHeaderCell>Environments</TableHeaderCell>
<TableHeaderCell>Enabled</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{schedules.length === 0 ? (
<TableBlankRow colSpan={10}>There are no matches for your filters</TableBlankRow>
) : (
schedules.map((schedule) => {
const path = `${v3SchedulePath(organization, project, schedule)}${location.search}`;
const isSelected = scheduleParam === schedule.friendlyId;
const cellClass = schedule.active ? "" : "opacity-50";
return (
<TableRow key={schedule.id} className={isSelected ? "bg-grid-dimmed" : undefined}>
<TableCell to={path} className={cellClass}>
{schedule.friendlyId}
</TableCell>
<TableCell to={path} className={cellClass}>
{schedule.taskIdentifier}
</TableCell>
<TableCell to={path} className={cellClass}>
{schedule.cron}
</TableCell>
<TableCell to={path} className={cellClass}>
{schedule.cronDescription}
</TableCell>
<TableCell to={path} className={cellClass}>
{schedule.externalId ? schedule.externalId : ""}
</TableCell>
<TableCell to={path} className={cellClass}>
{schedule.userProvidedDeduplicationKey ? schedule.deduplicationKey : ""}
</TableCell>
<TableCell to={path} className={cellClass}>
<DateTime date={schedule.nextRun} />
</TableCell>
<TableCell to={path} className={cellClass}>
{schedule.lastRun ? <DateTime date={schedule.lastRun} /> : ""}
</TableCell>
<TableCell to={path} className={cellClass}>
<div className="flex gap-1">
{schedule.environments.map((environment) => (
<EnvironmentLabel
key={environment.id}
environment={environment}
userName={environment.userName}
/>
))}
</div>
</TableCell>
<TableCell to={path}>
<EnabledStatus enabled={schedule.active} />
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
);
}
@@ -1,17 +1,25 @@
import { useForm } from "@conform-to/react";
import { conform, useForm } from "@conform-to/react";
import { parse } from "@conform-to/zod";
import { BeakerIcon } from "@heroicons/react/20/solid";
import { Form, useActionData, useNavigation, useSubmit } from "@remix-run/react";
import { Form, useActionData, useSubmit } from "@remix-run/react";
import { ActionFunction, LoaderFunctionArgs, json } from "@remix-run/server-runtime";
import { useCallback, useRef, useState } from "react";
import { TaskRunStatus } from "@trigger.dev/database";
import { useCallback, useEffect, useRef, useState } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { JSONEditor } from "~/components/code/JSONEditor";
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
import { Button } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
import { DateField } from "~/components/primitives/DateField";
import { DateTime } from "~/components/primitives/DateTime";
import { Fieldset } from "~/components/primitives/Fieldset";
import { FormError } from "~/components/primitives/FormError";
import { Header2 } from "~/components/primitives/Headers";
import { Hint } from "~/components/primitives/Hint";
import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { Paragraph } from "~/components/primitives/Paragraph";
import { RadioButtonCircle } from "~/components/primitives/RadioButton";
import {
@@ -19,63 +27,40 @@ import {
ResizablePanel,
ResizablePanelGroup,
} from "~/components/primitives/Resizable";
import { TaskPath } from "~/components/runs/v3/TaskPath";
import { TextLink } from "~/components/primitives/TextLink";
import { TaskRunStatusCombo } from "~/components/runs/v3/TaskRunStatus";
import { redirectBackWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import { TestTaskPresenter } from "~/presenters/v3/TestTaskPresenter.server";
import {
ScheduledRun,
StandardRun,
TestTask,
TestTaskPresenter,
} from "~/presenters/v3/TestTaskPresenter.server";
import { requireUserId } from "~/services/session.server";
import { v3RunPath, v3TaskParamsSchema } from "~/utils/pathBuilder";
import { docsPath, v3RunPath, v3TaskParamsSchema } from "~/utils/pathBuilder";
import { TestTaskService } from "~/v3/services/testTask.server";
import { TestTaskData } from "~/v3/testTask";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { projectParam, organizationSlug, taskParam } = v3TaskParamsSchema.parse(params);
const presenter = new TestTaskPresenter();
const { task, runs } = await presenter.call({
const result = await presenter.call({
userId,
projectSlug: projectParam,
taskFriendlyId: taskParam,
});
return typedjson({
task,
runs,
});
return typedjson(result);
};
const schema = z.object({
payload: z.string().transform((payload, ctx) => {
try {
const data = JSON.parse(payload);
return data as any;
} catch (e) {
console.log("parsing error", e);
if (e instanceof Error) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: e.message,
});
} else {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "This is invalid JSON",
});
}
}
}),
taskIdentifier: z.string(),
environmentId: z.string(),
accountId: z.string().optional(),
});
export const action: ActionFunction = async ({ request, params }) => {
const userId = await requireUserId(request);
const { organizationSlug, projectParam, taskParam } = v3TaskParamsSchema.parse(params);
const formData = await request.formData();
const submission = parse(formData, { schema });
const submission = parse(formData, { schema: TestTaskData });
if (!submission.value) {
return json(submission);
@@ -98,17 +83,27 @@ export const action: ActionFunction = async ({ request, params }) => {
);
};
export default function Page() {
const result = useTypedLoaderData<typeof loader>();
switch (result.triggerSource) {
case "STANDARD": {
return <StandardTaskForm task={result.task} runs={result.runs} />;
}
case "SCHEDULED": {
return <ScheduledTaskForm task={result.task} runs={result.runs} />;
}
}
}
const startingJson = "{\n\n}";
export default function Page() {
const { task, runs } = useTypedLoaderData<typeof loader>();
const navigation = useNavigation();
function StandardTaskForm({ task, runs }: { task: TestTask["task"]; runs: StandardRun[] }) {
//form submission
const submit = useSubmit();
const lastSubmission = useActionData();
//examples
//recent runs
const [selectedCodeSampleId, setSelectedCodeSampleId] = useState(runs.at(0)?.id);
const selectedCodeSample = runs.find((r) => r.id === selectedCodeSampleId)?.payload;
@@ -123,6 +118,7 @@ export default function Page() {
(e: React.FormEvent<HTMLFormElement>) => {
submit(
{
triggerSource: "STANDARD",
payload: currentJson.current,
taskIdentifier: task.taskIdentifier,
environmentId: task.environment.id,
@@ -142,7 +138,7 @@ export default function Page() {
// TODO: type this
lastSubmission: lastSubmission as any,
onValidate({ formData }) {
return parse(formData, { schema });
return parse(formData, { schema: TestTaskData });
},
});
@@ -153,6 +149,7 @@ export default function Page() {
{...form.props}
onSubmit={(e) => submitForm(e)}
>
<input type="hidden" name="triggerSource" value={"STANDARD"} />
<ResizablePanelGroup direction="horizontal">
<ResizablePanel order={1} minSize={30} defaultSize={60}>
<div className="h-full bg-charcoal-900">
@@ -182,53 +179,24 @@ export default function Page() {
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel order={2} minSize={20} defaultSize={40}>
<div className="flex flex-col gap-2 pl-4">
<div className="flex h-10 items-center border-b border-grid-dimmed">
<Header2>Recent payloads</Header2>
</div>
{runs.length === 0 ? (
<Callout variant="info">
Recent payloads will show here once you've completed a Run.
</Callout>
) : (
<div className="flex flex-col divide-y divide-charcoal-850">
{runs.map((run) => (
<button
key={run.id}
type="button"
onClick={(e) => {
setCode(run.payload ?? "");
setSelectedCodeSampleId(run.id);
}}
className="flex items-center gap-2 px-2 py-2"
>
<RadioButtonCircle checked={run.id === selectedCodeSampleId} />
<div className="flex flex-col items-start">
<Paragraph variant="small">
<DateTime date={run.createdAt} />
</Paragraph>
<div className="flex items-center gap-1 text-xs text-text-dimmed">
<div>Run #{run.number}</div>
<TaskRunStatusCombo status={run.status} />
</div>
</div>
</button>
))}
</div>
)}
</div>
<RecentPayloads
runs={runs}
selectedId={selectedCodeSampleId}
onSelected={(id) => {
const payload = runs.find((r) => r.id === id)?.payload;
if (!payload) return;
setCode(payload);
setSelectedCodeSampleId(id);
}}
/>
</ResizablePanel>
</ResizablePanelGroup>
<div className="flex items-center justify-end gap-2 border-t border-grid-bright bg-background-dimmed px-2">
<div className="flex items-center gap-1">
<TaskPath
filePath={task.filePath}
functionName={`${task.exportName}()`}
className="text-xs"
/>
<Paragraph variant="small">will run as a test in your</Paragraph>
<Paragraph variant="small" className="whitespace-nowrap">
This test will run in
</Paragraph>
<EnvironmentLabel environment={task.environment} />
<Paragraph variant="small">environment:</Paragraph>
</div>
<Button
type="submit"
@@ -242,3 +210,210 @@ export default function Page() {
</Form>
);
}
function ScheduledTaskForm({ task, runs }: { task: TestTask["task"]; runs: ScheduledRun[] }) {
const lastSubmission = useActionData();
const [selectedCodeSampleId, setSelectedCodeSampleId] = useState(runs.at(0)?.id);
const [timestampValue, setTimestampValue] = useState<Date | undefined>();
const [lastTimestampValue, setLastTimestampValue] = useState<Date | undefined>();
const [externalIdValue, setExternalIdValue] = useState<string | undefined>();
//set initial values
useEffect(() => {
const initialRun = runs.find((r) => r.id === selectedCodeSampleId);
if (!initialRun) {
setTimestampValue(new Date());
return;
}
setTimestampValue(initialRun.payload.timestamp);
setLastTimestampValue(initialRun.payload.lastTimestamp);
setExternalIdValue(initialRun.payload.externalId);
}, [selectedCodeSampleId]);
const [
form,
{ timestamp, lastTimestamp, externalId, triggerSource, taskIdentifier, environmentId },
] = useForm({
id: "test-task-scheduled",
// TODO: type this
lastSubmission: lastSubmission as any,
onValidate({ formData }) {
return parse(formData, { schema: TestTaskData });
},
});
return (
<Form className="grid h-full max-h-full grid-rows-[1fr_2.5rem]" method="post" {...form.props}>
<input
type="hidden"
{...conform.input(triggerSource, { type: "hidden" })}
value={"SCHEDULED"}
/>
<input
type="hidden"
{...conform.input(taskIdentifier, { type: "hidden" })}
value={task.taskIdentifier}
/>
<input
type="hidden"
{...conform.input(environmentId, { type: "hidden" })}
value={task.environment.id}
/>
<ResizablePanelGroup direction="horizontal">
<ResizablePanel order={1} minSize={30} defaultSize={60}>
<div className="p-3">
<Fieldset>
<InputGroup>
<Label htmlFor={timestamp.id}>Timestamp UTC</Label>
<input
type="hidden"
{...conform.input(timestamp, { type: "hidden" })}
value={timestampValue?.toISOString() ?? ""}
/>
<DateField
label="Timestamp UTC"
defaultValue={timestampValue}
onValueChange={(val) => setTimestampValue(val)}
granularity="second"
showNowButton
variant="medium"
/>
<Hint>
This is the timestamp of the CRON, it will come through to your run in the
payload.
</Hint>
<FormError id={timestamp.errorId}>{timestamp.error}</FormError>
</InputGroup>
<InputGroup>
<Label htmlFor={lastTimestamp.id} required={false}>
Last timestamp UTC
</Label>
<input
type="hidden"
{...conform.input(lastTimestamp, { type: "hidden" })}
value={lastTimestampValue?.toISOString() ?? ""}
/>
<DateField
label="Last timestamp UTC"
defaultValue={lastTimestampValue}
onValueChange={(val) => setLastTimestampValue(val)}
granularity="second"
showNowButton
showClearButton
variant="medium"
/>
<Hint>
This is the timestamp of the previous run. You can use this in your code to find
new data since the previous run. This can be undefined if there hasn't been a
previous run.
</Hint>
<FormError id={lastTimestamp.errorId}>{lastTimestamp.error}</FormError>
</InputGroup>
<InputGroup>
<Label required={false} htmlFor={externalId.id}>
External ID
</Label>
<Input
{...conform.input(externalId, { type: "text" })}
placeholder="Optionally specify your own ID, e.g. user id"
value={externalIdValue ?? ""}
onChange={(e) => setExternalIdValue(e.target.value)}
/>
<Hint>
Optionally, you can specify your own IDs (like a user ID) and then use it inside
the run function of your task. This allows you to have per-user CRON tasks.{" "}
<TextLink to={docsPath("v3/tasks-scheduled")}>Read the docs.</TextLink>
</Hint>
<FormError id={externalId.errorId}>{externalId.error}</FormError>
</InputGroup>
</Fieldset>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel order={2} minSize={20} defaultSize={40}>
<RecentPayloads
runs={runs}
selectedId={selectedCodeSampleId}
onSelected={(id) => {
const run = runs.find((r) => r.id === id);
if (!run) return;
setSelectedCodeSampleId(id);
setTimestampValue(run.payload.timestamp);
setLastTimestampValue(run.payload.lastTimestamp);
setExternalIdValue(run.payload.externalId);
}}
/>
</ResizablePanel>
</ResizablePanelGroup>
<div className="flex items-center justify-end gap-2 border-t border-grid-bright bg-background-dimmed px-2">
<div className="flex items-center gap-1">
<Paragraph variant="small" className="whitespace-nowrap">
This test will run in
</Paragraph>
<EnvironmentLabel environment={task.environment} />
</div>
<Button
type="submit"
variant="primary/small"
LeadingIcon={BeakerIcon}
shortcut={{ key: "enter", modifiers: ["mod"], enabledOnInputElements: true }}
>
Run test
</Button>
</div>
</Form>
);
}
function RecentPayloads({
runs,
selectedId,
onSelected,
}: {
runs: {
id: string;
createdAt: Date;
number: number;
status: TaskRunStatus;
}[];
selectedId?: string;
onSelected: (id: string) => void;
}) {
return (
<div className="flex flex-col gap-2 pl-4">
<div className="flex h-10 items-center border-b border-grid-dimmed">
<Header2>Recent payloads</Header2>
</div>
{runs.length === 0 ? (
<Callout variant="info">
Recent payloads will show here once you've completed a Run.
</Callout>
) : (
<div className="flex flex-col divide-y divide-charcoal-850">
{runs.map((run) => (
<button
key={run.id}
type="button"
onClick={(e) => {
onSelected(run.id);
}}
className="flex items-center gap-2 px-2 py-2"
>
<RadioButtonCircle checked={run.id === selectedId} />
<div className="flex flex-col items-start">
<Paragraph variant="small">
<DateTime date={run.createdAt} />
</Paragraph>
<div className="flex items-center gap-1 text-xs text-text-dimmed">
<div>Run #{run.number}</div>
<TaskRunStatusCombo status={run.status} />
</div>
</div>
</button>
))}
</div>
)}
</div>
);
}
@@ -27,6 +27,7 @@ import {
TableRow,
} from "~/components/primitives/Table";
import { TaskFunctionName } from "~/components/runs/v3/TaskPath";
import { TaskTriggerSourceIcon } from "~/components/runs/v3/TaskTriggerSource";
import { useLinkStatus } from "~/hooks/useLinkStatus";
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
import { useOrganization } from "~/hooks/useOrganizations";
@@ -176,19 +177,22 @@ function TaskSelector({
"z-20 rounded-sm outline outline-1 outline-offset-[-1px] outline-secondary"
)}
>
<TableCell to={path} actionClassName="px-2 py-1">
<TableCell to={path} actionClassName="pl-2.5 pr-1 py-1">
<RadioButtonCircle checked={isActive || isPending} />
</TableCell>
<TableCell to={path} actionClassName="px-2 py-1">
<TableCell to={path} actionClassName="pl-1 pr-2 py-1">
<div className="flex flex-col gap-0.5">
<TaskFunctionName
variant="extra-small"
functionName={t.exportName}
className="-ml-1 inline-flex"
/>
<Paragraph variant="extra-small" className="text-text-dimmed">
{t.taskIdentifier}
</Paragraph>
<div className="flex items-start gap-1">
<TaskTriggerSourceIcon source={t.triggerSource} className="size-3.5" />
<Paragraph variant="extra-small" className="text-text-dimmed">
{t.taskIdentifier}
</Paragraph>
</div>
</div>
</TableCell>
+52
View File
@@ -0,0 +1,52 @@
import { type DataFunctionArgs } from "@remix-run/node";
import { PerformanceObserver } from "node:perf_hooks";
import { runInNewContext } from "node:vm";
import v8 from "v8";
import { requireUser } from "~/services/session.server";
async function waitTillGcFinishes() {
let resolver: (value: PerformanceEntry) => void;
let rejector: (reason?: any) => void;
const promise = new Promise<PerformanceEntry>((resolve, reject) => {
resolver = resolve;
rejector = reject;
});
const obs = new PerformanceObserver((list) => {
const entry = list.getEntries()[0];
if (entry.name === "gc") {
resolver(entry);
}
});
obs.observe({ entryTypes: ["gc"] });
v8.setFlagsFromString("--expose-gc");
const gc = global.gc ?? runInNewContext("gc");
gc();
// disable expose-gc
v8.setFlagsFromString("--noexpose-gc");
return promise;
}
export async function loader({ request }: DataFunctionArgs) {
const user = await requireUser(request);
if (!user.admin) {
throw new Response("You must be an admin to perform this action", { status: 403 });
}
const entry = await waitTillGcFinishes();
return new Response(JSON.stringify(entry), {
status: 200,
headers: {
"Content-Type": "application/json",
},
});
}
@@ -1,11 +1,9 @@
import path from "path";
import os from "os";
import { type DataFunctionArgs } from "@remix-run/node";
import fs from "fs";
import v8 from "v8";
import os from "os";
import path from "path";
import { PassThrough } from "stream";
import { json, type DataFunctionArgs } from "@remix-run/node";
import { prisma } from "~/db.server";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import v8 from "v8";
import { requireUser } from "~/services/session.server";
// Format date as yyyy-MM-dd HH_mm_ss_SSS
@@ -32,10 +30,11 @@ export async function loader({ request }: DataFunctionArgs) {
throw new Response("You must be an admin to perform this action", { status: 403 });
}
const host = request.headers.get("X-Forwarded-Host") ?? request.headers.get("host");
const tempDir = os.tmpdir();
const filepath = path.join(tempDir, `${host}-${formatDate(new Date())}.heapsnapshot`);
const filepath = path.join(
tempDir,
`${getTaskIdentifier()}-${formatDate(new Date())}.heapsnapshot`
);
const snapshotPath = v8.writeHeapSnapshot(filepath);
if (!snapshotPath) {
@@ -57,3 +56,13 @@ export async function loader({ request }: DataFunctionArgs) {
},
});
}
function getTaskIdentifier() {
if (!process.env.ECS_CONTAINER_METADATA_URI) {
return "local";
}
const url = new URL(process.env.ECS_CONTAINER_METADATA_URI);
return url.pathname.split("/")[2].split("-")[0];
}
@@ -0,0 +1,72 @@
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { PrismaErrorSchema, prisma } from "~/db.server";
import { z } from "zod";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { CancelRunService } from "~/services/runs/cancelRun.server";
import { ApiRunPresenter } from "~/presenters/ApiRunPresenter.server";
import { ReplayTaskRunService } from "~/v3/services/replayTaskRun.server";
import { logger } from "~/services/logger.server";
const ParamsSchema = z.object({
/* This is the run friendly ID */
runParam: 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" };
}
// 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;
try {
const taskRun = await prisma.taskRun.findUnique({
where: {
friendlyId: runParam,
},
});
if (!taskRun) {
return json({ error: "Run not found" }, { status: 404 });
}
const service = new ReplayTaskRunService();
const newRun = await service.call(taskRun);
if (!newRun) {
return json({ error: "Failed to create new run" }, { status: 400 });
}
return json({
id: newRun?.friendlyId,
});
} catch (error) {
if (error instanceof Error) {
logger.error("Failed to replay run", {
error: {
name: error.name,
message: error.message,
stack: error.stack,
},
run: runParam,
});
return json({ error: error.message }, { status: 400 });
} else {
logger.error("Failed to replay run", { error: JSON.stringify(error), run: runParam });
return json({ error: JSON.stringify(error) }, { status: 400 });
}
}
}
@@ -0,0 +1,75 @@
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { truncateSync } from "fs";
import { z } from "zod";
import { prisma } from "~/db.server";
import { ViewSchedulePresenter } from "~/presenters/v3/ViewSchedulePresenter.server";
import { authenticateApiRequest } from "~/services/apiAuth.server";
const ParamsSchema = z.object({
scheduleId: 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" };
}
// Authenticate the request
const authenticationResult = await authenticateApiRequest(request);
if (!authenticationResult) {
return json({ error: "Invalid or Missing API Key" }, { status: 401 });
}
const parsedParams = ParamsSchema.safeParse(params);
if (!parsedParams.success) {
return json(
{ error: "Invalid request parameters", issues: parsedParams.error.issues },
{ status: 400 }
);
}
try {
const existingSchedule = await prisma.taskSchedule.findFirst({
where: {
friendlyId: parsedParams.data.scheduleId,
projectId: authenticationResult.environment.projectId,
},
});
if (!existingSchedule) {
return json({ error: "Schedule not found" }, { status: 404 });
}
await prisma.taskSchedule.update({
where: {
friendlyId: parsedParams.data.scheduleId,
projectId: authenticationResult.environment.projectId,
},
data: {
active: true,
},
});
const presenter = new ViewSchedulePresenter();
const result = await presenter.call({
projectId: authenticationResult.environment.projectId,
friendlyId: parsedParams.data.scheduleId,
});
if (!result) {
return json({ error: "Schedule not found" }, { status: 404 });
}
return json(presenter.toJSONResponse(result), { status: 200 });
} catch (error) {
return json(
{ error: error instanceof Error ? error.message : "Internal Server Error" },
{ status: 500 }
);
}
}
@@ -0,0 +1,74 @@
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { z } from "zod";
import { prisma } from "~/db.server";
import { ViewSchedulePresenter } from "~/presenters/v3/ViewSchedulePresenter.server";
import { authenticateApiRequest } from "~/services/apiAuth.server";
const ParamsSchema = z.object({
scheduleId: 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" };
}
// Authenticate the request
const authenticationResult = await authenticateApiRequest(request);
if (!authenticationResult) {
return json({ error: "Invalid or Missing API Key" }, { status: 401 });
}
const parsedParams = ParamsSchema.safeParse(params);
if (!parsedParams.success) {
return json(
{ error: "Invalid request parameters", issues: parsedParams.error.issues },
{ status: 400 }
);
}
try {
const existingSchedule = await prisma.taskSchedule.findFirst({
where: {
friendlyId: parsedParams.data.scheduleId,
projectId: authenticationResult.environment.projectId,
},
});
if (!existingSchedule) {
return json({ error: "Schedule not found" }, { status: 404 });
}
await prisma.taskSchedule.update({
where: {
friendlyId: parsedParams.data.scheduleId,
projectId: authenticationResult.environment.projectId,
},
data: {
active: false,
},
});
const presenter = new ViewSchedulePresenter();
const result = await presenter.call({
projectId: authenticationResult.environment.projectId,
friendlyId: parsedParams.data.scheduleId,
});
if (!result) {
return json({ error: "Schedule not found" }, { status: 404 });
}
return json(presenter.toJSONResponse(result), { status: 200 });
} catch (error) {
return json(
{ error: error instanceof Error ? error.message : "Internal Server Error" },
{ status: 500 }
);
}
}
@@ -0,0 +1,148 @@
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { ScheduleObject, UpdateScheduleOptions } from "@trigger.dev/core/v3";
import { z } from "zod";
import { Prisma, prisma } from "~/db.server";
import { ViewSchedulePresenter } from "~/presenters/v3/ViewSchedulePresenter.server";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { UpsertSchedule } from "~/v3/schedules";
import { ServiceValidationError } from "~/v3/services/baseService.server";
import { UpsertTaskScheduleService } from "~/v3/services/upsertTaskSchedule.server";
const ParamsSchema = z.object({
scheduleId: 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 parsedParams = ParamsSchema.safeParse(params);
if (!parsedParams.success) {
return json(
{ error: "Invalid request parameters", issues: parsedParams.error.issues },
{ status: 400 }
);
}
const method = request.method.toUpperCase();
switch (method) {
case "DELETE": {
try {
const deletedSchedule = await prisma.taskSchedule.delete({
where: {
friendlyId: parsedParams.data.scheduleId,
projectId: authenticationResult.environment.projectId,
},
});
return json(
{
id: deletedSchedule.friendlyId,
},
{ status: 200 }
);
} catch (error) {
// Check if it's a Prisma error
if (error instanceof Prisma.PrismaClientKnownRequestError) {
return json(
{ error: error.code === "P2025" ? "Schedule not found" : error.message },
{ status: error.code === "P2025" ? 404 : 422 }
);
} else {
return json(
{ error: error instanceof Error ? error.message : "Internal Server Error" },
{ status: 500 }
);
}
}
}
case "PUT": {
const rawBody = await request.json();
const body = UpdateScheduleOptions.safeParse(rawBody);
if (!body.success) {
return json({ error: "Invalid request body", issues: body.error.issues }, { status: 400 });
}
const service = new UpsertTaskScheduleService();
try {
const options: UpsertSchedule = {
friendlyId: parsedParams.data.scheduleId,
taskIdentifier: body.data.task,
cron: body.data.cron,
environments: [authenticationResult.environment.id],
externalId: body.data.externalId,
deduplicationKey: body.data.deduplicationKey,
};
const schedule = await service.call(authenticationResult.environment.projectId, options);
const responseObject: ScheduleObject = {
id: schedule.id,
task: schedule.task,
active: schedule.active,
generator: {
type: "CRON",
expression: schedule.cron,
description: schedule.cronDescription,
},
externalId: schedule.externalId ?? undefined,
deduplicationKey: schedule.deduplicationKey,
environments: schedule.environments,
nextRun: schedule.nextRun,
};
return json(responseObject, { status: 200 });
} catch (error) {
if (error instanceof ServiceValidationError) {
return json({ error: error.message }, { status: 422 });
}
return json(
{ error: error instanceof Error ? error.message : "Internal Server Error" },
{ status: 500 }
);
}
}
}
}
export async function loader({ request, params }: LoaderFunctionArgs) {
// Authenticate the request
const authenticationResult = await authenticateApiRequest(request);
if (!authenticationResult) {
return json({ error: "Invalid or Missing API Key" }, { status: 401 });
}
const parsedParams = ParamsSchema.safeParse(params);
if (!parsedParams.success) {
return json(
{ error: "Invalid request parameters", issues: parsedParams.error.issues },
{ status: 400 }
);
}
const presenter = new ViewSchedulePresenter();
const result = await presenter.call({
projectId: authenticationResult.environment.projectId,
friendlyId: parsedParams.data.scheduleId,
});
if (!result) {
return json({ error: "Schedule not found" }, { status: 404 });
}
return json(presenter.toJSONResponse(result), { status: 200 });
}
+128
View File
@@ -0,0 +1,128 @@
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { CreateScheduleOptions, ScheduleObject } from "@trigger.dev/core/v3";
import { z } from "zod";
import { ScheduleListPresenter } from "~/presenters/v3/ScheduleListPresenter.server";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { UpsertSchedule } from "~/v3/schedules";
import { ServiceValidationError } from "~/v3/services/baseService.server";
import { UpsertTaskScheduleService } from "~/v3/services/upsertTaskSchedule.server";
const SearchParamsSchema = z.object({
page: z.coerce.number().int().positive().optional(),
perPage: z.coerce.number().int().positive().optional(),
});
export async function action({ request }: ActionFunctionArgs) {
// Ensure this is a POST request
if (request.method.toUpperCase() !== "POST") {
return { status: 405, body: "Method Not Allowed" };
}
// Authenticate the request
const authenticationResult = await authenticateApiRequest(request);
if (!authenticationResult) {
return json({ error: "Invalid or Missing API Key" }, { status: 401 });
}
const rawBody = await request.json();
const body = CreateScheduleOptions.safeParse(rawBody);
if (!body.success) {
return json({ error: "Invalid request body", issues: body.error.issues }, { status: 400 });
}
const service = new UpsertTaskScheduleService();
try {
const options: UpsertSchedule = {
taskIdentifier: body.data.task,
cron: body.data.cron,
environments: [authenticationResult.environment.id],
externalId: body.data.externalId,
deduplicationKey: body.data.deduplicationKey,
};
const schedule = await service.call(authenticationResult.environment.projectId, options);
const responseObject: ScheduleObject = {
id: schedule.id,
task: schedule.task,
active: schedule.active,
generator: {
type: "CRON",
expression: schedule.cron,
description: schedule.cronDescription,
},
externalId: schedule.externalId ?? undefined,
deduplicationKey: schedule.deduplicationKey,
environments: schedule.environments,
nextRun: schedule.nextRun,
};
return json(responseObject, { status: 200 });
} catch (error) {
if (error instanceof ServiceValidationError) {
return json({ error: error.message }, { status: 422 });
}
return json(
{ error: error instanceof Error ? error.message : "Internal Server Error" },
{ status: 500 }
);
}
}
export async function loader({ request }: LoaderFunctionArgs) {
// Authenticate the request
const authenticationResult = await authenticateApiRequest(request);
if (!authenticationResult) {
return json({ error: "Invalid or Missing API Key" }, { status: 401 });
}
const rawSearchParams = new URL(request.url).searchParams;
const params = SearchParamsSchema.safeParse(Object.fromEntries(rawSearchParams.entries()));
if (!params.success) {
return json(
{ error: "Invalid request parameters", issues: params.error.issues },
{ status: 400 }
);
}
const presenter = new ScheduleListPresenter();
const result = await presenter.call({
projectId: authenticationResult.environment.projectId,
page: params.data.page ?? 1,
pageSize: params.data.perPage,
environments: [authenticationResult.environment.id],
});
return {
data: result.schedules.map((schedule) => ({
id: schedule.friendlyId,
task: schedule.taskIdentifier,
generator: {
type: "CRON",
expression: schedule.cron,
description: schedule.cronDescription,
},
deduplicationKey: schedule.userProvidedDeduplicationKey
? schedule.deduplicationKey
: undefined,
externalId: schedule.externalId,
active: schedule.active,
nextRun: schedule.nextRun,
environments: schedule.environments,
})),
pagination: {
currentPage: result.currentPage,
totalPages: result.totalPages,
count: result.totalCount,
},
};
}
+2 -2
View File
@@ -23,7 +23,7 @@ export async function action({ request }: ActionFunctionArgs) {
const anyBody = await request.json();
const body = GetPersonalAccessTokenRequestSchema.safeParse(anyBody);
if (!body.success) {
return json({ message: generateErrorMessage(body.error.issues) }, { status: 422 });
return json({ error: generateErrorMessage(body.error.issues) }, { status: 422 });
}
try {
@@ -45,6 +45,6 @@ export async function action({ request }: ActionFunctionArgs) {
return json({ error: error.message }, { status: 400 });
}
return json({ error: "Something went wrong" }, { status: 500 });
return json({ error: "Something went wrong" }, { status: 400 });
}
}
@@ -25,7 +25,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
const parsed = ParamsSchema.safeParse(params);
if (!parsed.success) {
return json({ error: "Invalid or Missing runId" }, { status: 400 });
return json({ error: "Invalid or Missing run id" }, { status: 400 });
}
const { runParam } = parsed.data;
+28 -23
View File
@@ -8,29 +8,34 @@ import { authenticateApiRequestWithPersonalAccessToken } from "~/services/person
export async function loader({ request }: LoaderFunctionArgs) {
logger.info("whoami v2", { url: request.url });
try {
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
if (!authenticationResult) {
return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
}
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
if (!authenticationResult) {
return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
const user = await prisma.user.findUnique({
select: {
email: true,
},
where: {
id: authenticationResult.userId,
},
});
if (!user) {
return json({ error: "User not found" }, { status: 404 });
}
const result: WhoAmIResponse = {
userId: authenticationResult.userId,
email: user.email,
dashboardUrl: env.APP_ORIGIN,
};
return json(result);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Something went wrong";
logger.error("Error in whoami v2", { error: errorMessage });
return json({ error: errorMessage }, { status: 400 });
}
const user = await prisma.user.findUnique({
select: {
email: true,
},
where: {
id: authenticationResult.userId,
},
});
if (!user) {
return json({ error: "User not found" }, { status: 404 });
}
const result: WhoAmIResponse = {
userId: authenticationResult.userId,
email: user.email,
dashboardUrl: env.APP_ORIGIN,
};
return json(result);
}
+10
View File
@@ -0,0 +1,10 @@
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { metricsRegister } from "~/metrics.server";
export async function loader({ request }: LoaderFunctionArgs) {
return new Response(await metricsRegister.metrics(), {
headers: {
"Content-Type": metricsRegister.contentType,
},
});
}
@@ -0,0 +1,252 @@
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,
userId: string
) {
// Register project metrics here
// Register queue metrics here
// Find the dev runtime environment for this project/user
const allEnvironments = await prisma.runtimeEnvironment.findMany({
where: {
projectId,
},
include: {
taskQueues: true,
project: true,
organization: true,
orgMember: true,
},
});
const firstEnv = allEnvironments[0];
if (firstEnv) {
new Gauge({
name: sanitizeMetricName(`trigger_org_queue_concurrency`),
help: `The number of tasks currently being executed in the org environment queue`,
registers: [registry],
async collect() {
const length = await marqs?.currentConcurrencyOfOrg(firstEnv);
if (length) {
this.set(length);
}
},
});
new Gauge({
name: sanitizeMetricName(`trigger_org_queue_concurrency_limit`),
help: `The concurrency limit for the org queue`,
registers: [registry],
async collect() {
const length = await marqs?.getOrgConcurrencyLimit(firstEnv);
if (length) {
this.set(length);
}
},
});
new Gauge({
name: sanitizeMetricName(`trigger_org_queue_capacity`),
help: "The capacity of the org queue",
registers: [registry],
async collect() {
const concurrencyLimit = await marqs?.getOrgConcurrencyLimit(firstEnv);
const currentConcurrency = await marqs?.currentConcurrencyOfOrg(firstEnv);
if (typeof concurrencyLimit === "number" && typeof currentConcurrency === "number") {
this.set(concurrencyLimit - currentConcurrency);
}
},
});
}
for (const env of allEnvironments) {
if (env.type === "DEVELOPMENT" && env.orgMember?.userId === userId) {
await registerEnvironmentMetrics(env, registry);
} else if (env.type !== "DEVELOPMENT") {
await registerEnvironmentMetrics(env, registry);
}
}
}
async function registerEnvironmentMetrics(
env: AuthenticatedEnvironment & { taskQueues: TaskQueue[] },
registry: Registry
) {
new Gauge({
name: sanitizeMetricName(`trigger_env_queue_${env.slug}_concurrency`),
help: `The number of tasks currently being executed in the dev environment queue`,
registers: [registry],
async collect() {
const length = await marqs?.currentConcurrencyOfEnvironment(env);
if (length) {
this.set(length);
}
},
});
new Gauge({
name: sanitizeMetricName(`trigger_env_queue_${env.slug}_concurrency_limit`),
help: `The concurrency limit for the dev environment queue`,
registers: [registry],
async collect() {
const length = await marqs?.getEnvConcurrencyLimit(env);
if (length) {
this.set(length);
}
},
});
new Gauge({
name: sanitizeMetricName(`trigger_env_queue_${env.slug}_capacity`),
help: `The capacity of the dev environment queue`,
registers: [registry],
async collect() {
const concurrencyLimit = await marqs?.getEnvConcurrencyLimit(env);
const currentConcurrency = await marqs?.currentConcurrencyOfEnvironment(env);
if (typeof concurrencyLimit === "number" && typeof currentConcurrency === "number") {
this.set(concurrencyLimit - currentConcurrency);
}
},
});
for (const queue of env.taskQueues) {
registerTaskQueueMetrics(registry, queue, env);
}
}
function registerTaskQueueMetrics(
registry: Registry,
queue: TaskQueue,
env: AuthenticatedEnvironment
) {
new Gauge({
name: sanitizeMetricName(`trigger_${env.slug}_task_queue_${queue.name}_length`),
help: `The number of tasks in the ${queue.name} queue`,
registers: [registry],
async collect() {
const length = await marqs?.lengthOfQueue(env, queue.name);
if (length) {
this.set(length);
}
},
});
new Gauge({
name: sanitizeMetricName(`trigger_${env.slug}_task_queue_${queue.name}_concurrency`),
help: `The number of tasks currently being executed in the ${queue.name} queue`,
registers: [registry],
async collect() {
const length = await marqs?.currentConcurrencyOfQueue(env, queue.name);
if (length) {
this.set(length);
}
},
});
new Gauge({
name: sanitizeMetricName(`trigger_${env.slug}_task_queue_${queue.name}_concurrency_limit`),
help: `The concurrency limit for the ${queue.name} queue`,
registers: [registry],
async collect() {
const length = await marqs?.getQueueConcurrencyLimit(env, queue.name);
if (length) {
this.set(length);
}
},
});
new Gauge({
name: sanitizeMetricName(`trigger_${env.slug}_task_queue_${queue.name}_capacity`),
help: `The capacity of the ${queue.name} queue`,
registers: [registry],
async collect() {
const concurrencyLimit = await marqs?.getQueueConcurrencyLimit(env, queue.name);
const currentConcurrency = await marqs?.currentConcurrencyOfQueue(env, queue.name);
if (typeof concurrencyLimit === "number" && typeof currentConcurrency === "number") {
this.set(concurrencyLimit - currentConcurrency);
}
},
});
new Gauge({
name: sanitizeMetricName(`trigger_${env.slug}_task_queue_${queue.name}_oldest_message_age`),
help: `The age of the oldest message in the ${queue.name} queue`,
registers: [registry],
async collect() {
const oldestMessage = await marqs?.oldestMessageInQueue(env, queue.name);
if (oldestMessage) {
this.set(oldestMessage);
}
},
});
}
function sanitizeMetricName(name: string) {
return name.replace(/[^a-zA-Z0-9_]/g, "_");
}
@@ -0,0 +1,118 @@
import { useFetcher } from "@remix-run/react";
import { ActionFunctionArgs, json } from "@remix-run/server-runtime";
import { useCallback, useEffect, useState } from "react";
import { z } from "zod";
import { AISparkleIcon } from "~/assets/icons/AISparkleIcon";
import { Button } from "~/components/primitives/Buttons";
import { FormError } from "~/components/primitives/FormError";
import { Label } from "~/components/primitives/Label";
import { env } from "~/env.server";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { requireUserId } from "~/services/session.server";
import { humanToCron } from "~/v3/humanToCron.server";
const schema = z.object({
message: z.string(),
});
export const action = async ({ request }: ActionFunctionArgs) => {
const userId = await requireUserId(request);
if (!env.OPENAI_API_KEY) {
return json(
{
isValid: false as const,
error: "OpenAI API key is not set",
cron: undefined,
},
{ status: 400 }
);
}
const data = await request.json();
const submission = schema.safeParse(data);
if (!submission.success) {
return json(
{
isValid: false as const,
error: "Invalid input",
cron: undefined,
},
{ status: 400 }
);
}
const result = await humanToCron(submission.data.message, userId);
return json(result);
};
type AIGeneratedCronFieldProps = {
onSuccess: (cron: string) => void;
};
export function AIGeneratedCronField({ onSuccess }: AIGeneratedCronFieldProps) {
const fetcher = useFetcher<typeof action>();
const [text, setText] = useState<string>("");
const organization = useOrganization();
const project = useProject();
const isLoading = fetcher.state !== "idle";
const resultData = fetcher.data;
useEffect(() => {
if (resultData?.cron !== undefined) {
onSuccess(resultData.cron);
}
}, [resultData?.cron]);
const submit = useCallback(async (value: string) => {
fetcher.submit(
{ message: value },
{
method: "POST",
action: `/resources/orgs/${organization.slug}/projects/${project.slug}/schedules/new/natural-language`,
encType: "application/json",
}
);
}, []);
return (
<div>
<Label>
<AISparkleIcon className="inline-block h-4 w-4" /> Describe your schedule using natural
language
</Label>
<div
className="rounded-sm p-px"
style={{ background: "linear-gradient(to bottom right, #E543FF, #286399)" }}
>
<div className="rounded-[calc(0.5rem-2px)] bg-background-bright">
<textarea
value={text}
placeholder="e.g. the last Friday of the month at 6am"
onChange={(e) => setText(e.target.value)}
rows={3}
className="m-0 w-full border-0 bg-background-bright px-3 py-2 text-sm text-text-bright scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600 file:border-0 file:bg-transparent file:text-base file:font-medium focus:border-0 focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50"
/>
<div className="flex justify-end gap-2 px-2 pb-2">
<Button
type="button"
variant="tertiary/small"
disabled={isLoading}
LeadingIcon={isLoading ? "spinner" : AISparkleIcon}
onClick={() => submit(text)}
>
{isLoading ? "Generating" : "Generate"}
</Button>
</div>
</div>
</div>
{resultData?.isValid === false ? (
<FormError className="mt-2">{resultData.error}</FormError>
) : null}
</div>
);
}
@@ -0,0 +1,398 @@
import { conform, useForm } from "@conform-to/react";
import { parse } from "@conform-to/zod";
import { CheckIcon, XMarkIcon } from "@heroicons/react/20/solid";
import { Form, useActionData, useLocation, useNavigation } from "@remix-run/react";
import { ActionFunctionArgs, json } from "@remix-run/server-runtime";
import { parseExpression } from "cron-parser";
import cronstrue from "cronstrue";
import { useState } from "react";
import {
environmentTextClassName,
environmentTitle,
} from "~/components/environments/EnvironmentLabel";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Checkbox } from "~/components/primitives/Checkbox";
import { DateTime } from "~/components/primitives/DateTime";
import { Fieldset } from "~/components/primitives/Fieldset";
import { FormError } from "~/components/primitives/FormError";
import { Header2, Header3 } from "~/components/primitives/Headers";
import { Hint } from "~/components/primitives/Hint";
import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { Paragraph } from "~/components/primitives/Paragraph";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/primitives/Select";
import {
Table,
TableBody,
TableCell,
TableHeader,
TableHeaderCell,
TableRow,
} from "~/components/primitives/Table";
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 { EditableScheduleElements } from "~/presenters/v3/EditSchedulePresenter.server";
import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import { ProjectParamSchema, docsPath, v3SchedulesPath } from "~/utils/pathBuilder";
import { CronPattern, UpsertSchedule } from "~/v3/schedules";
import { UpsertTaskScheduleService } from "~/v3/services/upsertTaskSchedule.server";
import { AIGeneratedCronField } from "../resources.orgs.$organizationSlug.projects.$projectParam.schedules.new.natural-language";
const cronFormat = `* * * * *
┬ ┬ ┬ ┬ ┬
│ │ │ │ |
│ │ │ │ └ day of week (0 - 7, 1L - 7L) (0 or 7 is Sun)
│ │ │ └───── month (1 - 12)
│ │ └────────── day of month (1 - 31, L)
│ └─────────────── hour (0 - 23)
└──────────────────── minute (0 - 59)`;
export const action = async ({ request, params }: ActionFunctionArgs) => {
const userId = await requireUserId(request);
const { organizationSlug, projectParam } = ProjectParamSchema.parse(params);
const formData = await request.formData();
const submission = parse(formData, { schema: UpsertSchedule });
if (!submission.value) {
return json(submission);
}
try {
//first check that the user has access to the project
const project = await prisma.project.findUnique({
where: {
slug: projectParam,
organization: {
members: {
some: {
userId,
},
},
},
},
select: { id: true },
});
if (!project) {
throw new Error("Project not found");
}
const createSchedule = new UpsertTaskScheduleService();
const result = await createSchedule.call(project.id, submission.value);
return redirectWithSuccessMessage(
v3SchedulesPath({ slug: organizationSlug }, { slug: projectParam }),
request,
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 });
}
};
type CronPatternResult =
| {
isValid: true;
description: string;
}
| {
isValid: false;
error: string;
};
export function UpsertScheduleForm({
schedule,
possibleTasks,
possibleEnvironments,
showGenerateField,
}: EditableScheduleElements & { showGenerateField: boolean }) {
const lastSubmission = useActionData();
const [cronPattern, setCronPattern] = useState<string>(schedule?.cron ?? "");
const navigation = useNavigation();
const isLoading = navigation.state !== "idle";
const organization = useOrganization();
const project = useProject();
const location = useLocation();
const [form, { taskIdentifier, cron, externalId, environments, deduplicationKey }] = useForm({
id: "create-schedule",
// TODO: type this
lastSubmission: lastSubmission as any,
shouldRevalidate: "onSubmit",
onValidate({ formData }) {
return parse(formData, { schema: UpsertSchedule });
},
});
let cronPatternResult: CronPatternResult | undefined = undefined;
let nextRuns: Date[] | undefined = undefined;
if (cronPattern !== "") {
const result = CronPattern.safeParse(cronPattern);
if (!result.success) {
cronPatternResult = {
isValid: false,
error: result.error.errors[0].message,
};
} else {
try {
const expression = parseExpression(cronPattern, { utc: true });
cronPatternResult = {
isValid: true,
description: cronstrue.toString(cronPattern),
};
nextRuns = Array.from({ length: 5 }, (_, i) => {
const utc = expression.next().toDate();
return utc;
});
} catch (e) {
cronPatternResult = {
isValid: false,
error: e instanceof Error ? e.message : JSON.stringify(e),
};
}
}
}
const mode = schedule ? "edit" : "new";
return (
<Form
method="post"
action={`/resources/orgs/${organization.slug}/projects/${project.slug}/schedules/new`}
{...form.props}
className="grid h-full max-h-full grid-rows-[2.5rem_1fr_3.25rem] overflow-hidden bg-background-bright"
>
<div className="mx-3 flex items-center justify-between gap-2 border-b border-grid-dimmed">
<Header2 className={cn("whitespace-nowrap")}>
{schedule?.friendlyId ? "Edit schedule" : "New schedule"}
</Header2>
</div>
<div className="overflow-y-scroll scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
<div className="p-3">
{schedule && <input type="hidden" name="friendlyId" value={schedule.friendlyId} />}
<Fieldset>
<InputGroup>
<Label htmlFor={taskIdentifier.id}>Task</Label>
<SelectGroup>
<Select
{...conform.input(taskIdentifier, { type: "select" })}
defaultValue={schedule?.taskIdentifier}
>
<SelectTrigger size="medium" width="full">
<SelectValue placeholder="Select task" className="ml-2 p-0" />
</SelectTrigger>
<SelectContent>
{possibleTasks.map((task) => (
<SelectItem key={task} value={task}>
<Paragraph
variant="extra-small"
className="pl-0.5 transition group-hover:text-text-bright"
>
{task}
</Paragraph>
</SelectItem>
))}
</SelectContent>
</Select>
</SelectGroup>
<FormError id={taskIdentifier.errorId}>{taskIdentifier.error}</FormError>
</InputGroup>
{showGenerateField && <AIGeneratedCronField onSuccess={setCronPattern} />}
<InputGroup>
<Label
htmlFor={cron.id}
tooltip={
<div className="spacy-y-3">
<Paragraph variant="extra-small">We support this CRON format:</Paragraph>
<code>
<pre>{cronFormat}</pre>
</code>
<Paragraph variant="extra-small">"L" means the last.</Paragraph>
</div>
}
>
CRON pattern (UTC)
</Label>
<Input
{...conform.input(cron, { type: "text" })}
placeholder="? ? ? ? ?"
required={true}
value={cronPattern}
onChange={(e) => {
setCronPattern(e.target.value);
}}
/>
{cronPatternResult === undefined ? (
<Hint>Enter a CRON pattern or use natural language above.</Hint>
) : cronPatternResult.isValid ? (
<ValidCronMessage isValid={true} message={`${cronPatternResult.description}.`} />
) : (
<ValidCronMessage isValid={false} message={cronPatternResult.error} />
)}
</InputGroup>
{nextRuns !== undefined && (
<div className="flex flex-col gap-1">
<Header3>Next 5 runs</Header3>
<Table>
<TableHeader>
<TableRow>
<TableHeaderCell>UTC</TableHeaderCell>
<TableHeaderCell>Local time</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{nextRuns.map((run, index) => (
<TableRow key={index}>
<TableCell>
<DateTime date={run} timeZone="UTC" />
</TableCell>
<TableCell>
<DateTime date={run} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
<InputGroup>
<Label>Environments</Label>
<div className="flex flex-wrap items-center gap-2">
{possibleEnvironments.map((environment) => (
<Checkbox
key={environment.id}
id={environment.id}
value={environment.id}
name="environments"
type="radio"
label={
<span
className={cn("text-xs uppercase", environmentTextClassName(environment))}
>
{environmentTitle(environment, environment.userName)}
</span>
}
defaultChecked={
schedule?.instances.find((i) => i.environmentId === environment.id) !==
undefined
}
variant="button"
/>
))}
</div>
<Hint>
Select all the environments where you want this schedule to run. Note that scheduled
tasks in dev environments will only run while you are connected with the dev CLI
</Hint>
<FormError id={environments.errorId}>{environments.error}</FormError>
</InputGroup>
<InputGroup>
<Label required={false} htmlFor={externalId.id}>
External ID
</Label>
<Input
{...conform.input(externalId, { type: "text" })}
placeholder="Optionally specify your own ID, e.g. user id"
defaultValue={schedule?.externalId ?? undefined}
/>
<Hint>
Optionally, you can specify your own IDs (like a user ID) and then use it inside the
run function of your task. This allows you to have per-user CRON tasks.{" "}
<TextLink to={docsPath("v3/tasks-scheduled")}>Read the docs.</TextLink>
</Hint>
<FormError id={externalId.errorId}>{externalId.error}</FormError>
</InputGroup>
<InputGroup>
<Label required={false} htmlFor={deduplicationKey.id}>
Deduplication key
</Label>
<Input
{...conform.input(deduplicationKey, { type: "text" })}
disabled={schedule !== undefined}
defaultValue={
schedule?.userProvidedDeduplicationKey ? schedule?.deduplicationKey : undefined
}
/>
{schedule && (
<Paragraph variant="small">
You can't edit the Deduplication key on an existing schedule.
</Paragraph>
)}
<Hint>
Optionally specify a key, you can only create one schedule with this key. This is
very useful when using the SDK and you don't want to create duplicate schedules for
a user.
</Hint>
<FormError id={deduplicationKey.errorId}>{deduplicationKey.error}</FormError>
</InputGroup>
<FormError>{form.error}</FormError>
</Fieldset>
</div>
</div>
<div className="flex items-center justify-between gap-2 border-t border-grid-dimmed px-2">
<div className="flex items-center gap-4">
<LinkButton
to={`${v3SchedulesPath(organization, project)}${location.search}`}
variant="minimal/medium"
>
Cancel
</LinkButton>
</div>
<div className="flex items-center gap-4">
<Button
variant="primary/medium"
type="submit"
disabled={isLoading}
shortcut={{ key: "enter", modifiers: ["meta"] }}
LeadingIcon={isLoading ? "spinner" : undefined}
>
{buttonText(mode, isLoading)}
</Button>
</div>
</div>
</Form>
);
}
function buttonText(mode: "edit" | "new", isLoading: boolean) {
switch (mode) {
case "edit":
return isLoading ? "Updating schedule" : "Update schedule";
case "new":
return isLoading ? "Creating schedule" : "Create schedule";
}
}
function ValidCronMessage({ isValid, message }: { isValid: boolean; message: string }) {
return (
<Paragraph variant="small">
<span className="mr-1">
{isValid ? (
<CheckIcon className="-mt-0.5 mr-1 inline-block h-4 w-4 text-success" />
) : (
<XMarkIcon className="-mt-0.5 mr-1 inline-block h-4 w-4 text-error" />
)}
<span className={isValid ? "text-success" : "text-error"}>
{isValid ? "Valid pattern:" : "Invalid pattern:"}
</span>
</span>
<span>{message}</span>
</Paragraph>
);
}
@@ -0,0 +1,85 @@
import { parse } from "@conform-to/zod";
import { ActionFunction, json } from "@remix-run/node";
import { z } from "zod";
import { prisma } from "~/db.server";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import { logger } from "~/services/logger.server";
import { v3RunPath } from "~/utils/pathBuilder";
import { ReplayTaskRunService } from "~/v3/services/replayTaskRun.server";
const FormSchema = z.object({
failedRedirect: z.string(),
});
const ParamSchema = z.object({
runParam: z.string(),
});
export const action: ActionFunction = async ({ request, params }) => {
const { runParam } = ParamSchema.parse(params);
const formData = await request.formData();
const submission = parse(formData, { schema: FormSchema });
if (!submission.value) {
return json(submission);
}
try {
const taskRun = await prisma.taskRun.findUnique({
where: {
friendlyId: runParam,
},
include: {
project: {
include: {
organization: true,
},
},
},
});
if (!taskRun) {
return redirectWithErrorMessage(submission.value.failedRedirect, request, "Run not found");
}
const replayRunService = new ReplayTaskRunService();
const newRun = await replayRunService.call(taskRun);
if (!newRun) {
return redirectWithErrorMessage(
submission.value.failedRedirect,
request,
"Failed to replay run"
);
}
const runPath = v3RunPath(
{
slug: taskRun.project.organization.slug,
},
{ slug: taskRun.project.slug },
{ friendlyId: newRun.friendlyId }
);
return redirectWithSuccessMessage(runPath, request, `Replaying run`);
} catch (error) {
if (error instanceof Error) {
logger.error("Failed to replay run", {
error: {
name: error.name,
message: error.message,
stack: error.stack,
},
});
return redirectWithErrorMessage(submission.value.failedRedirect, request, error.message);
} else {
logger.error("Failed to replay run", { error });
return redirectWithErrorMessage(
submission.value.failedRedirect,
request,
JSON.stringify(error)
);
}
}
};
@@ -0,0 +1,45 @@
import { DateField } from "~/components/primitives/DateField";
import { Header2 } from "~/components/primitives/Headers";
import { AbsoluteTimeFrame } from "~/components/runs/TimeFrameFilter";
export default function Story() {
return (
<div className="m-8 space-y-8">
<div className="flex flex-col gap-4">
<Header2>Size = small</Header2>
<DateField label="From (UTC)" granularity="second" showNowButton showClearButton />
<DateField
label="From (UTC)"
defaultValue={new Date()}
granularity="second"
showNowButton
showClearButton
/>
<AbsoluteTimeFrame onValueChange={() => {}} />
<AbsoluteTimeFrame
from={new Date(Date.now() - 1000 * 60 * 60)}
to={new Date()}
onValueChange={() => {}}
/>
</div>
<div className="flex flex-col gap-4">
<Header2>Size = medium</Header2>
<DateField
label="From (UTC)"
granularity="second"
showNowButton
showClearButton
variant="medium"
/>
<DateField
label="From (UTC)"
defaultValue={new Date()}
granularity="second"
showNowButton
showClearButton
variant="medium"
/>
</div>
</div>
);
}
@@ -1,15 +1,7 @@
import { Form } from "@remix-run/react";
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
import { MainCenteredContainer } from "~/components/layout/AppLayout";
import { Button } from "~/components/primitives/Buttons";
import { Fieldset } from "~/components/primitives/Fieldset";
import { FormButtons } from "~/components/primitives/FormButtons";
import { FormError } from "~/components/primitives/FormError";
import { FormTitle } from "~/components/primitives/FormTitle";
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 { TimeFrameFilter } from "~/components/runs/TimeFrameFilter";
export default function Story() {
return (
@@ -64,14 +56,14 @@ function InputFieldSet({ disabled }: { disabled?: boolean }) {
disabled={disabled}
variant="large"
placeholder="Search"
icon={<EnvironmentLabel environment={{ type: "DEVELOPMENT" }} />}
icon={<EnvironmentLabel environment={{ type: "DEVELOPMENT" }} size="large" />}
shortcut="⌘K"
/>
<Input
disabled={disabled}
variant="medium"
placeholder="Search"
icon={<EnvironmentLabel environment={{ type: "DEVELOPMENT" }} />}
icon={<EnvironmentLabel environment={{ type: "DEVELOPMENT" }} size="large" />}
shortcut="⌘K"
/>
<Input
@@ -114,6 +114,10 @@ const stories: Story[] = [
},
{
sectionTitle: "Forms",
name: "Date fields",
slug: "date-fields",
},
{
name: "Simple form",
slug: "simple-form",
},
@@ -1,10 +1,10 @@
import { PersonalAccessToken } from "@trigger.dev/database";
import { customAlphabet, nanoid } from "nanoid";
import nodeCrypto from "node:crypto";
import { z } from "zod";
import { prisma } from "~/db.server";
import { env } from "~/env.server";
import { logger } from "./logger.server";
import { PersonalAccessToken } from "@trigger.dev/database";
const tokenValueLength = 40;
//lowercase only, removed 0 and l to avoid confusion
@@ -54,7 +54,6 @@ export async function getPersonalAccessTokenFromAuthorizationCode(authorizationC
},
where: {
code: authorizationCode,
createdAt: {
gte: tenMinutesAgo,
},
@@ -199,13 +198,25 @@ export async function createPersonalAccessTokenFromAuthorizationCode(
},
});
//we only allow you to have one CLI PAT at a time
//we only allow you to have one CLI PAT at a time, so return this
if (existingCliPersonalAccessToken) {
await prisma.personalAccessToken.delete({
//associate this authorization code with the existing personal access token
await prisma.authorizationCode.update({
where: {
id: existingCliPersonalAccessToken.id,
code: authorizationCode,
},
data: {
personalAccessTokenId: existingCliPersonalAccessToken.id,
},
});
//we don't return the decrypted token
return {
id: existingCliPersonalAccessToken.id,
name: existingCliPersonalAccessToken.name,
userId: existingCliPersonalAccessToken.userId,
obfuscateToken: existingCliPersonalAccessToken.obfuscatedToken,
};
}
const token = await createPersonalAccessToken({
+26
View File
@@ -35,6 +35,8 @@ import { ResumeBatchRunService } from "~/v3/services/resumeBatchRun.server";
import { ResumeTaskDependencyService } from "~/v3/services/resumeTaskDependency.server";
import { TimeoutDeploymentService } from "~/v3/services/timeoutDeployment.server";
import { eventRepository } from "~/v3/eventRepository.server";
import { ExecuteTasksWaitingForDeployService } from "~/v3/services/executeTasksWaitingForDeploy";
import { TriggerScheduledTaskService } from "~/v3/services/triggerScheduledTask.server";
const workerCatalog = {
indexEndpoint: z.object({
@@ -128,6 +130,12 @@ const workerCatalog = {
fromStatus: z.string(),
errorMessage: z.string(),
}),
"v3.executeTasksWaitingForDeploy": z.object({
backgroundWorkerId: z.string(),
}),
"v3.triggerScheduledTask": z.object({
instanceId: z.string(),
}),
};
const executionWorkerCatalog = {
@@ -507,6 +515,24 @@ function getWorkerQueue() {
return await service.call(payload.deploymentId, payload.fromStatus, payload.errorMessage);
},
},
"v3.executeTasksWaitingForDeploy": {
priority: 0,
maxAttempts: 5,
handler: async (payload, job) => {
const service = new ExecuteTasksWaitingForDeployService();
return await service.call(payload.backgroundWorkerId);
},
},
"v3.triggerScheduledTask": {
priority: 0,
maxAttempts: 3,
handler: async (payload, job) => {
const service = new TriggerScheduledTaskService();
return await service.call(payload.instanceId);
},
},
},
});
}
+18
View File
@@ -0,0 +1,18 @@
export function isValidDatabaseUrl(url: string) {
try {
const databaseUrl = new URL(url);
const schemaFromSearchParam = databaseUrl.searchParams.get("schema");
if (schemaFromSearchParam === "") {
console.error(
"Invalid Database URL: The schema search param can't have an empty value. To use the `public` schema, either omit the schema param entirely or specify it in full: `?schema=public`"
);
return false;
}
return true;
} catch (err) {
console.error(err);
return false;
}
}
+30
View File
@@ -14,6 +14,8 @@ import { Job } from "~/models/job.server";
import type { Organization } from "~/models/organization.server";
import type { Project } from "~/models/project.server";
import { objectToSearchParams } from "./searchParams";
import { ScheduleListFilters } from "~/components/runs/v3/ScheduleFilters";
import { useLocation } from "@remix-run/react";
export type OrgForPath = Pick<Organization, "slug">;
export type ProjectForPath = Pick<Project, "slug">;
@@ -90,6 +92,10 @@ export const v3DeploymentParams = ProjectParamSchema.extend({
deploymentParam: z.string(),
});
export const v3ScheduleParams = ProjectParamSchema.extend({
scheduleParam: z.string(),
});
export function trimTrailingSlash(path: string) {
return path.replace(/\/$/, "");
}
@@ -382,6 +388,30 @@ export function v3RunStreamingPath(
return `${v3RunPath(organization, project, run)}/stream`;
}
export function v3SchedulesPath(organization: OrgForPath, project: ProjectForPath) {
return `${v3ProjectPath(organization, project)}/schedules`;
}
export function v3SchedulePath(
organization: OrgForPath,
project: ProjectForPath,
schedule: { friendlyId: string }
) {
return `${v3ProjectPath(organization, project)}/schedules/${schedule.friendlyId}`;
}
export function v3EditSchedulePath(
organization: OrgForPath,
project: ProjectForPath,
schedule: { friendlyId: string }
) {
return `${v3ProjectPath(organization, project)}/schedules/edit/${schedule.friendlyId}`;
}
export function v3NewSchedulePath(organization: OrgForPath, project: ProjectForPath) {
return `${v3ProjectPath(organization, project)}/schedules/new`;
}
export function v3ProjectSettingsPath(organization: OrgForPath, project: ProjectForPath) {
return `${v3ProjectPath(organization, project)}/settings`;
}
@@ -6,11 +6,10 @@ import {
} from "@trigger.dev/core/v3";
import { Evt } from "evt";
import { randomUUID } from "node:crypto";
import type { CloseEvent, ErrorEvent, MessageEvent, WebSocket } from "ws";
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { DevQueueConsumer } from "./marqs/devQueueConsumer.server";
import type { WebSocket, MessageEvent, CloseEvent, ErrorEvent } from "ws";
import { env } from "~/env.server";
export class AuthenticatedSocketConnection {
public id: string;
@@ -20,7 +19,11 @@ export class AuthenticatedSocketConnection {
private _consumer: DevQueueConsumer;
private _messageHandler: ZodMessageHandler<typeof clientWebsocketMessages>;
constructor(public ws: WebSocket, public authenticatedEnv: AuthenticatedEnvironment) {
constructor(
public ws: WebSocket,
public authenticatedEnv: AuthenticatedEnvironment,
private readonly ipAddress: string | string[]
) {
this.id = randomUUID();
this._sender = new ZodMessageSender({
@@ -43,7 +46,9 @@ export class AuthenticatedSocketConnection {
},
});
this._consumer = new DevQueueConsumer(authenticatedEnv, this._sender);
this._consumer = new DevQueueConsumer(authenticatedEnv, this._sender, {
ipAddress: Array.isArray(this.ipAddress) ? this.ipAddress.join(", ") : this.ipAddress,
});
ws.addEventListener("message", this.#handleMessage.bind(this));
ws.addEventListener("close", this.#handleClose.bind(this));
@@ -93,7 +98,11 @@ export class AuthenticatedSocketConnection {
await this._consumer.stop();
this.onClose.post(ev);
const result = this.onClose.post(ev);
logger.debug("[AuthenticatedSocketConnection] Called onClose", {
result,
});
}
async #handleError(ev: ErrorEvent) {

Some files were not shown because too many files have changed in this diff Show More