Compare commits

...

73 Commits

Author SHA1 Message Date
Eric Allam c04cdfde8c Fix pnpm lock file 2023-12-07 13:00:32 +00:00
github-actions[bot] 750c1ff1e3 chore: Update version for release (#783)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2023-12-07 12:59:53 +00:00
Eric Allam 0f9f010206 @trigger.dev/airtable: Removed named imports (#782) 2023-12-07 12:53:58 +00:00
Eric Allam 11f8ff1bb4 Fixing endless retrying of non-successful run executions
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 0s
🚀 Publish Trigger.dev Docker / units (push) Failing after 0s
🚀 Publish Trigger.dev Docker / e2e (push) Failing after 0s
🚀 Publish Trigger.dev Docker / publish (push) Has been skipped
2023-12-06 21:01:41 +00:00
Eric Allam 85b3352764 Fix pnpm lock file 2023-12-06 20:05:49 +00:00
github-actions[bot] 9fb8dceef4 chore: Update version for release (#781)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2023-12-06 20:05:29 +00:00
Eric Allam 0c14e4cdfe Stop importing subpaths in @trigger.dev/airtable (#780) 2023-12-06 20:02:37 +00:00
D-K-P 2099c6308e Latest lock file 2023-12-06 15:45:43 +00:00
github-actions[bot] 08e6cad28a chore: Update version for release (#779)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2023-12-06 15:42:36 +00:00
Eric Allam f3efcc0c28 Moved Logger to core-backend, no longer importing node:buffer in core/react (#778) 2023-12-06 15:37:50 +00:00
Eric Allam 37ef335b66 Resolve pnpm lock file 2023-12-06 11:29:58 +00:00
github-actions[bot] 33d555d00e chore: Update version for release (#776)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2023-12-06 11:28:06 +00:00
Eric Allam 17f6f29d05 Feature: Support multiple runtimes other than Node.js (#774)
🚀 Publish Trigger.dev Docker / e2e (push) Failing after 0s
🚀 Publish Trigger.dev Docker / units (push) Failing after 5s
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 5s
🚀 Publish Trigger.dev Docker / publish (push) Has been skipped
2023-12-06 10:23:37 +00:00
Eric Allam 08f7c639ef Maximum background fetch timeout is now 5 minutes
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 1s
🚀 Publish Trigger.dev Docker / units (push) Failing after 0s
🚀 Publish Trigger.dev Docker / e2e (push) Failing after 0s
🚀 Publish Trigger.dev Docker / publish (push) Has been skipped
2023-12-04 15:02:15 +00:00
github-actions[bot] 1567239718 chore: Update version for release (#771)
* chore: Update version for release

* Release lockfile

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: nicktrn <55853254+nicktrn@users.noreply.github.com>
2023-12-04 14:14:41 +00:00
nicktrn de652c1dfb Fix: Shopify tasks and KV get() return types (#770)
* Improve KV get types

* Fix shopify find and all task return types

* Add changeset
2023-12-04 12:37:55 +00:00
Eric Allam 1f3733b70f Fix resuming timed out or auto-yielded executions
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 5s
🚀 Publish Trigger.dev Docker / units (push) Failing after 21s
🚀 Publish Trigger.dev Docker / e2e (push) Failing after 21s
🚀 Publish Trigger.dev Docker / publish (push) Has been skipped
2023-12-04 09:20:40 +00:00
Dan b5aea6c534 Replaced showcase links -> api (#764)
* Updated the examples section with new links / changed showcase link to project showcase

* Added links to the api pages for all of our integrations

* Deleted card from the airtable tasks page
2023-12-01 17:35:54 +00:00
Eric Allam 0769dc4315 Fixed the event names for stripe docs 2023-12-01 08:42:05 +00:00
Eric Allam 5d00fc7cdb Update pnpm lock file for release 2023-11-30 21:54:43 +00:00
github-actions[bot] 00b0c3e02e chore: Update version for release (#765)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2023-11-30 21:54:13 +00:00
Eric Allam 7e3a82ef47 Issue #747: Add Stripe invoice triggers 2023-11-30 21:48:45 +00:00
James Ritchie 5dda6cd16c Added the border to the page header on the Runs page
🚀 Publish Trigger.dev Docker / units (push) Failing after 4s
🚀 Publish Trigger.dev Docker / e2e (push) Failing after 3s
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 21s
🚀 Publish Trigger.dev Docker / publish (push) Has been skipped
2023-11-30 10:32:38 +00:00
Matt Aitken 76b7fb2337 The status API endpoints return only the original set of statuses 2023-11-30 10:31:27 +00:00
Matt Aitken 68cbfd8d23 Log out the run status 2023-11-30 10:29:48 +00:00
Eric Allam 41a49f6bb2 Make the run list query more efficient
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 0s
🚀 Publish Trigger.dev Docker / units (push) Failing after 4s
🚀 Publish Trigger.dev Docker / e2e (push) Failing after 4s
🚀 Publish Trigger.dev Docker / publish (push) Has been skipped
2023-11-29 19:28:15 +00:00
Eric Allam 2f755158b4 Updated pnpm lock file 2023-11-29 11:25:27 +00:00
github-actions[bot] 419b93809d chore: Update version for release (#757)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2023-11-29 11:24:58 +00:00
Eric Allam bd4bc51daa Update plain (#762)
* updated the plain SDK

* Updated the action to support the new Threads SDK

* latest lock file

---------

Co-authored-by: James Ritchie <james@jamesritchie.co.uk>
2023-11-29 10:58:06 +00:00
Eric Allam ff540c9e4a Revert "Update plain (#761)"
🚀 Publish Trigger.dev Docker / units (push) Failing after 0s
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 3s
🚀 Publish Trigger.dev Docker / e2e (push) Failing after 5s
🚀 Publish Trigger.dev Docker / publish (push) Has been skipped
This reverts commit 3f217ff3e0.
2023-11-29 10:51:13 +00:00
Eric Allam 3f217ff3e0 Update plain (#761)
🚀 Publish Trigger.dev Docker / units (push) Failing after 0s
🚀 Publish Trigger.dev Docker / e2e (push) Failing after 0s
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 5s
🚀 Publish Trigger.dev Docker / publish (push) Has been skipped
* updated the plain SDK

* Updated the action to support the new Threads SDK

---------

Co-authored-by: James Ritchie <james@jamesritchie.co.uk>
2023-11-29 10:49:37 +00:00
Eric Allam 9cb39bf7d7 Added Redis example env vars 2023-11-29 10:34:54 +00:00
Eric Allam ca05d5f603 @trigger.dev/openai: Adding additional assistant tasks 2023-11-28 22:48:31 +00:00
Hemachandar 4dc46cbbe4 fix: transform Headers object to Record type (#760) 2023-11-28 18:42:50 +00:00
nicktrn 1dcd87a2aa URI encode keys (#758) 2023-11-28 18:05:15 +00:00
Eric Allam c4cb98af5c Clarified the run execution limits 2023-11-28 16:23:35 +00:00
Eric Allam 6ebd435e81 Feature: Run execution concurrency limits (#750)
* WIP execution concurrency controls implemented via Redis

- Split up resuming a run and executing a run
- Added some new statuses to better show what is going on in a run
- Removed preprocessing runs

* WIP

* Convert to using ZSETs and adding env vars

* Removed unused import

* Improve run number generation using advistory locks, and only on start

* More execution concurrency stuff

* Add support for job concurrency limits and concurrency limit groups

* Create wild-swans-battle.md

* Increase slots refresh timeout to 10s

* Try to fix Redis connection issues

* Don’t be so strict about the APP_ENV

* Add the blank tls option to the normal redis client as well

* Add docs
2023-11-28 16:21:06 +00:00
Eric Allam caf203c084 Made webhook index stats optional
🚀 Publish Trigger.dev Docker / units (push) Failing after 4s
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 4s
🚀 Publish Trigger.dev Docker / e2e (push) Failing after 19s
🚀 Publish Trigger.dev Docker / publish (push) Has been skipped
2023-11-28 15:04:24 +00:00
Eric Allam dbc2e3f713 Refreshed pnpm lock 2023-11-28 14:53:37 +00:00
github-actions[bot] a5b49cb61f chore: Update version for release (#753)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2023-11-28 14:53:06 +00:00
nicktrn 096151c014 Fix: Shopify subpath imports, enhance docs (#755)
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 0s
🚀 Publish Trigger.dev Docker / units (push) Failing after 0s
🚀 Publish Trigger.dev Docker / e2e (push) Failing after 0s
🚀 Publish Trigger.dev Docker / publish (push) Has been skipped
* Fix invalid subpath imports

* Remove @trigger.dev/tsup dep

* Update lockfile

* Small github docs fix

* Pull runtime adaptor import out of integration

* Fix io.store docs examples

* Add sendEvents to task library

* Add KV to task library and small fixes

* Suppress duplicate http endpoint warnings

* Changeset

* Add task returns
2023-11-27 16:03:03 +00:00
Dan 5d28f1a3d6 Update github-triggers.mdx 2023-11-27 10:32:21 +00:00
Eric Allam 56ae0ed0d3 Only tag v.docker tags with the latest tag 2023-11-24 16:46:06 +00:00
Eric Allam a2f5dc8bc6 Only attempt to login to Docker if the workflow event is a push 2023-11-24 14:16:41 +00:00
Eric Allam d56b2eded5 Features: new login page (#754)
* Initial commit and WIP new login page

* added wrapper to fix layout

* Improved the layout and style of the page

* Added a new extra-large button style (and added it to storybook)

* Moved the cusom onboarding style tooltip to the Tooltip component

* Populated the bento grid with items

* Imrpoved the quote styling

* Tooltip doesn’t stay if you mouse over it

* Added more content including a list of quote that cycle on page load

* New quote mark positioning

* Added the rest of the content to the tooltips

* Made the bento grid responsive

* Moved the login page layout to a seaparte component

* Login pages now use the new layout component

* Extra large button style tweak

* Match the large input style with the large button style

* Cleaned up imports and made the Ts & Cs text the same for magic links

* Layout fix

* Quotes don’t push content off the screen as much

* Added openai integration to the grid

* Make the quote fit if the screen height is narrower

* Style update to the magic link sent state

* Icon colour updates

* fixed text centering on mobile

* Icons are more responsive

* removed unnecessary w-fulls

* Added 2 onboarding questions

* Revalidate form on submit, not input

* Typo fixes

* Name field focuses by default

* Added conform to the employees question

* Update pnpm lock file

* Implement referral source and company size on managed cloud

* Fixed some type errors

---------

Co-authored-by: James Ritchie <james@jamesritchie.co.uk>
2023-11-24 14:08:50 +00:00
nicktrn 0ae9adafbf Fix: Shopify build and webhook indexing (#752)
* Revert to old tsup config

* Make webhooks index optional
2023-11-24 11:45:33 +00:00
Eric Allam 03403ad9cb Fix for pull request workflow (#751)
* Allow PRs that have /publish-docker in the body to cause docker images to be published

* Also ignore mdx file changes

* pr_checks now uses pull_request instead of pull_request_target
2023-11-24 11:19:06 +00:00
nicktrn 067e19fec9 Feature: New Webhook Triggers, KV Store, and Shopify integration (#745)
* Attach webhook to http endpoint

* Create internal webhook handler job

* Fix delivery

* Add webhook trigger

* Index webhooks

* Fix webhook connect

* Webhook activation

* Rename to TriggerWebhook

* Save webhooks to index

* Extend register payload

* Revert attachSource

* Add update webhook helper

* Use crud interface for registration

* Remove filter from params

* Destructure options

* Missing ohash dep

* Chainable trigger filter

* Optional request verification

* Handler call with context

* Update config on register success

* Rename to webhookData

* Add basic key-value store

* Improve kv store

* Webhook trigger UI

* Webhook delivery UI

* Some fixes

* Airtable experiments

* Fix tabs transitions

* Only run register if config differs

* Use subtasks for delivery

* Missing dep

* Error handing and fixes

* Namespace cursor

* Fix hmac verification

* Gut Airtable

* Lockfile

* Template optional api key

* Template event sources and icons

* Template remove filter from params

* Fix models template

* Template fixme

* Fix webhook trigger header

* Actually send params for registration..

* Add back oauthed client

* Verify signature header encoding param

* Fix delivery context

* Webhook create retry with prior delete

* Webhook source type fixes

* Webhook config merge

* Update webhook icon

* Verify webhook task options

* Rename handler to generateEvents

* Send event icons

* Shopify with webhooks

* Shopify job-catalog entry

* Lockfile

* Add webhook migration

* Better icons

* Type fixes

* Make param type optional

* Scope type

* More examples

* More schemas

* Trigger catalog

* Bump versions

* Just a few more events they said

* Simplify session and config

* Typed serializer

* Fix payload type

* Remove unused getters

* Reduce logging output

* Rest resource tasks

* Lockfile

* Bump version

* Remaining triggers

* Fix event types

* Fix import

* Link from webhook trigger to http endpoint

* Use rest resource tasks for crud

* Fix rest save type

* Improve KV types

* Improve attach webhook logging

* Make header schema more lenient

* Fix save type again

* Final error callback

* Shorten keys

* Remove ohash diff

* Disable Airtable webhooks

* Webhook environments

* Fix shopify error import

* Link to integration and http endpoint

* Integration catalog entry

* Disable oauth

* Require api key, simplify client secret

* Move trigger types

* Update catalog example

* Small KV endpoint refactor

* Fix custom migration

* Webhook environment migration

* Improve crud types

* Remove unused schema

* Fix crud context type

* KV limits and HTTP verbs

* KV improvements

* Jobless webhook delivery

* Airtable delivery fixes

* Make deliveries sexy again

* Some docs

* Fix payload types.. again

* Final payload pass

* Schema cleanup

* Refactor handler service

* Remove more stale schemas

* registerJobNamespace import

* Expose KV on TriggerClient

* Remove dummy click handler

* Remove unused components

* Fix delivery pagination

* Enable registration rerun button

* Comment out registration route action handler

* Rename KV as not tied to IO anymore

* Swap api with trigger client

* Disabled fields param

* KV docs

* Changeset

* Shopify docs fixes
2023-11-24 10:22:12 +00:00
nicktrn 2cce68b5f7 Revert "Remove the pg listen code to see if it’s causing DB issues" (#749)
This reverts commit e8e7c116d1.
2023-11-24 09:30:48 +00:00
Dan e3c3aa91e1 Added Clerk / Cal.com / HubSpot / Hugging Face / Mailgun / Novu / WhatsApp HTTP endpoint examples (#748)
Co-authored-by: Matt Aitken <matt@mattaitken.com>
2023-11-23 16:11:34 +00:00
Marius Vilholm 5c0ca83852 Fix wrong import in example for useRunDetails (#744)
Thanks @mariusvilholm
2023-11-23 15:35:33 +00:00
Marius Vilholm 2b24104c97 Fix wrong import in example of useEventRunDetails (#743) 2023-11-23 15:35:02 +00:00
Marius Vilholm 5977a5aa51 Fix spelling in OpenAI Assistants (#742)
Co-authored-by: Matt Aitken <matt@mattaitken.com>
2023-11-23 15:34:30 +00:00
Eric Allam 131d0dbea4 Adding an example of using OpenAI GPT 4 Vision 2023-11-21 10:23:11 +00:00
Eric Allam 59277731c4 Fix pnpm lock file 2023-11-16 16:32:14 +00:00
github-actions[bot] 742d16087b chore: Update version for release (#739)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2023-11-16 16:31:41 +00:00
Eric Allam 0ab51d62bc Don’t send notifications for internal runs and add isTest to the notification
🚀 Publish Trigger.dev Docker / publish (push) Has been skipped
🚀 Publish Trigger.dev Docker / e2e (push) Failing after 19s
🚀 Publish Trigger.dev Docker / units (push) Failing after 3s
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 6s
2023-11-16 14:45:39 +00:00
Eric Allam 756024da78 Add support for Run Notifications #401 (#738)
* Add support for Run Notifications #401

* Fixed typo and added changeset
2023-11-16 14:19:42 +00:00
Dan c47ad5e038 Docs improvements and added API catalog links (#737)
* integration docs various improvements

* Added optional comments to step by step guide

* Added API catalog card to the integrations intro

* Added Api catalog button to the examples job tab

* Tweaked copy of api catalog card

* Update introduction.mdx

---------

Co-authored-by: Matt Aitken <matt@mattaitken.com>
2023-11-16 11:50:26 +00:00
Dan 888344797e Added code examples and modified blank state on the integrations & APIs page (#736)
* Added more API icons

* Updated the title

* Added resources.codeexample.ts to pull in example code from github

* Added regex to remove redundant express code from the examples

* Added cal examples

* Code changes onSubmit

* Improved the code loading state

* Removed request integration button

* Improved the Customhelp copy

* Fixed styling issues and removed imports

* Added all the code examples

* Don’t show title if there’s 1 or less examples

* Updated copy for the non example version

* Added example code to the apis without examples, added a focus state

* Fixed titles

* Got rid of the phantom gap

* Made the copy more generic so it works on the integration pages as well

* Removed import

* Added view examples button to the integration pages

* Removed fetch/sdk option from integration sheet

* Apply code example regex on the server

---------

Co-authored-by: Matt Aitken <matt@mattaitken.com>
2023-11-15 17:38:24 +00:00
Eric Allam 4a7caa68b6 Record when run executions start and finish
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 0s
🚀 Publish Trigger.dev Docker / units (push) Failing after 0s
🚀 Publish Trigger.dev Docker / e2e (push) Failing after 0s
🚀 Publish Trigger.dev Docker / publish (push) Has been skipped
2023-11-15 15:18:32 +00:00
Matt Aitken 3bc54d36f7 SQS logging: fixed error and message, plus using tracing 2023-11-15 13:32:40 +00:00
Matt Aitken 5295a3f798 Changed SQS error callbacks to log to error 2023-11-15 13:26:57 +00:00
Eric Allam 14429b8b66 Fix “transaction already closed” error when running tasks (#735) 2023-11-14 23:22:34 +00:00
Matt Aitken 5dd1bcc589 sendEvents proxy uses batches of 10 (max for SQS) 2023-11-14 21:34:08 +00:00
Matt Aitken ed5ee16de5 Better errors now get to users of sendEvent 2023-11-14 21:33:47 +00:00
Matt Aitken 076185d19f Added a perf test for sendEvents 2023-11-14 21:32:46 +00:00
Matt Aitken 1e07964d81 The parallel perf test is now driven by numbers 2023-11-14 15:46:34 +00:00
Matt Aitken f406e59c45 Configuration and docs for deploying the proxy to Cloudflare 2023-11-14 14:04:42 +00:00
Eric Allam 75550f535c Fix undefined task outputs
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 5s
🚀 Publish Trigger.dev Docker / e2e (push) Failing after 0s
🚀 Publish Trigger.dev Docker / units (push) Failing after 19s
🚀 Publish Trigger.dev Docker / publish (push) Has been skipped
And also fix the SSE connection getting reset on a infinite loop when runs are complete
2023-11-13 16:18:41 +00:00
Matt Aitken 2b48e6d04b Feature: sendEvent can use an AWS SQS queue to increase reliability and throughput (#729)
* Initial commit (by create-cloudflare CLI)

* Changed the prettier rules

* Started writing Readme and got dev working

* Validate the request, parse the event and respond with the correct format

* Use ulidx instead of ulid in core

* Removed some boilerplate and use new function for sendEvent

* Removed old ulid package

* When handling a webhook, it’s not an error if the HTTP endpoint environment isn’t found

* If the env vars aren’t set, log the request and pass it through

* Pass requests through. Setup the env vars

* Flipped the API key detection because we can enforce that it’s the shape of a private API key

* Progress with sendEvent proxy

* Events are being put on the SQS queue

* The SQS event queue is working

* Make sure there’s a timestamp before enqueuing the evnet

* Log the sqs_event, not API key

* Added a bit more detail to the readme

* Improved the Cloudflare logs

* We don’t need the global.window hack for AWS, just globalThis

* Removed commented out wrangler.toml values

* Reworked the proxy to make it easier to add more endpoints

* Use json utility to send Responses

* Start work on proxying bulk events
2023-11-13 15:08:01 +00:00
Matt Aitken fe14947bb0 Removed waitForEvent beta warning. Made a snippet so we can use this in future easily. 2023-11-10 18:24:48 +00:00
Matt Aitken 0afa119721 Use node-fetch for invokeEphemeralEvents instead of @whatwg-node/fetch (#734)
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 0s
🚀 Publish Trigger.dev Docker / units (push) Failing after 0s
🚀 Publish Trigger.dev Docker / e2e (push) Failing after 0s
🚀 Publish Trigger.dev Docker / publish (push) Has been skipped
2023-11-10 17:36:21 +00:00
497 changed files with 23894 additions and 4609 deletions
+5
View File
@@ -12,6 +12,11 @@ APP_ENV=development
APP_ORIGIN=http://localhost:3030
NODE_ENV=development
# Redis is used for concurrency control
# REDIS_HOST="localhost"
# REDIS_PORT="6379"
# REDIS_TLS_DISABLED="true"
# OPTIONAL VARIABLES
# This is used for validating emails that are allowed to log in. Every email that do not match this regex will be rejected.
# WHITELISTED_EMAILS="authorized@yahoo\.com|authorized@gmail\.com"
+1
View File
@@ -7,6 +7,7 @@ jobs:
runs-on: buildjet-4vcpu-ubuntu-2204
steps:
- name: 🐳 Login to Docker Hub
if: github.event_name == 'push'
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
+2 -1
View File
@@ -1,11 +1,12 @@
name: 🤖 PR Checks
on:
pull_request_target:
pull_request:
branches:
- main
paths-ignore:
- "**.md"
- "**.mdx"
- ".github/CODEOWNERS"
- ".github/ISSUE_TEMPLATE/**"
+72
View File
@@ -0,0 +1,72 @@
name: "🐳 Publish Docker"
on:
workflow_call:
jobs:
publish:
runs-on: buildjet-4vcpu-ubuntu-2204
outputs:
version: ${{ steps.get_version.outputs.version }}
short_sha: ${{ steps.get_commit.outputs.sha_short }}
steps:
- name: 🐳 Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: ⬇️ Checkout repo
uses: actions/checkout@v3
- name: 🆚 Get the version
id: get_version
run: |
IMAGE_TAG="${GITHUB_REF#refs/tags/}"
if [[ $GITHUB_REF == refs/tags/* ]]; then
if [[ $IMAGE_TAG == v.docker.* ]]; then
ORIGINAL_VERSION="${IMAGE_TAG#v.docker.}"
IMAGE_TAG="v${ORIGINAL_VERSION}"
elif [[ $IMAGE_TAG == build-* ]]; then
IMAGE_TAG="${IMAGE_TAG#build-}"
fi
echo "IMAGE_TAG=${IMAGE_TAG}"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
# Handle main branch specifically
IMAGE_TAG="main"
echo "IMAGE_TAG=${IMAGE_TAG}"
else
echo "Invalid reference: ${GITHUB_REF}"
exit 1
fi
echo "::set-output name=version::${IMAGE_TAG}"
- name: 🔢 Get the commit hash
id: get_commit
run: |
echo ::set-output name=sha_short::$(echo ${{ github.sha }} | cut -c1-7)
- name: 🐳 Build Docker Image
run: |
docker build -t release_build_image -f ./docker/Dockerfile .
- name: 🐙 Login to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: 🐙 Push to GitHub Container Registry
run: |
docker tag release_build_image $REGISTRY/$REPOSITORY:$IMAGE_TAG
docker push $REGISTRY/$REPOSITORY:$IMAGE_TAG
env:
REGISTRY: ghcr.io/triggerdotdev
REPOSITORY: trigger.dev
IMAGE_TAG: ${{ steps.get_version.outputs.version }}
- name: 🐙 Push 'latest' to GitHub Container Registry
if: startsWith(github.ref, 'refs/tags/v.docker')
run: |
docker tag release_build_image $REGISTRY/$REPOSITORY:latest
docker push $REGISTRY/$REPOSITORY:latest
env:
REGISTRY: ghcr.io/triggerdotdev
REPOSITORY: trigger.dev
+3 -69
View File
@@ -4,9 +4,9 @@ on:
push:
branches:
- main
- improvements/*
tags:
- "v.docker.*"
- "build-*"
paths:
- ".github/workflows/publish.yml"
- "packages/**"
@@ -51,71 +51,5 @@ jobs:
publish:
needs: [typecheck, units, e2e]
runs-on: buildjet-4vcpu-ubuntu-2204
outputs:
version: ${{ steps.get_version.outputs.version }}
short_sha: ${{ steps.get_commit.outputs.sha_short }}
steps:
- name: 🐳 Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: ⬇️ Checkout repo
uses: actions/checkout@v3
- name: 🆚 Get the version
id: get_version
run: |
IMAGE_TAG="${GITHUB_REF#refs/tags/}"
if [[ $GITHUB_REF == refs/tags/* ]]; then
if [[ $IMAGE_TAG == v.docker.* ]]; then
ORIGINAL_VERSION="${IMAGE_TAG#v.docker.}"
IMAGE_TAG="v${ORIGINAL_VERSION}"
fi
echo "IMAGE_TAG=${IMAGE_TAG}"
elif [[ $GITHUB_REF == refs/heads/improvements/* ]]; then
ORIGINAL_VERSION="${GITHUB_REF#refs/heads/improvements/}"
IMAGE_TAG="${ORIGINAL_VERSION}.rc"
echo "IMAGE_TAG=${IMAGE_TAG}"
elif [[ $GITHUB_REF == refs/heads/* ]]; then
IMAGE_TAG="${GITHUB_REF#refs/heads/}"
echo "IMAGE_TAG=${IMAGE_TAG}"
else
echo "Invalid reference: ${GITHUB_REF}"
exit 1
fi
echo "::set-output name=version::${IMAGE_TAG}"
- name: 🔢 Get the commit hash
id: get_commit
run: |
echo ::set-output name=sha_short::$(echo ${{ github.sha }} | cut -c1-7)
- name: 🐳 Build Docker Image
run: |
docker build -t release_build_image -f ./docker/Dockerfile .
- name: 🐙 Login to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: 🐙 Push to GitHub Container Registry
run: |
docker tag release_build_image $REGISTRY/$REPOSITORY:$IMAGE_TAG
docker push $REGISTRY/$REPOSITORY:$IMAGE_TAG
env:
REGISTRY: ghcr.io/triggerdotdev
REPOSITORY: trigger.dev
IMAGE_TAG: ${{ steps.get_version.outputs.version }}
- name: 🐙 Push 'latest' to GitHub Container Registry
if: startsWith(github.ref, 'refs/tags/')
run: |
docker tag release_build_image $REGISTRY/$REPOSITORY:latest
docker push $REGISTRY/$REPOSITORY:latest
env:
REGISTRY: ghcr.io/triggerdotdev
REPOSITORY: trigger.dev
uses: ./.github/workflows/publish-docker.yml
secrets: inherit
+10
View File
@@ -22,6 +22,16 @@ jobs:
node-version: 18
cache: "pnpm"
- name: ⎔ Setup Deno
uses: denoland/setup-deno@v1
with:
deno-version: v1.x
- name: ⎔ Setup bun
uses: oven-sh/setup-bun@v1
with:
bun-version: "1.0.15"
- name: 📥 Download deps
run: pnpm install --frozen-lockfile
+1 -1
View File
@@ -1,4 +1,4 @@
{
"deno.enablePaths": ["references/deno-reference"],
"deno.enablePaths": ["references/deno-reference", "runtime_tests/tests/deno"],
"debug.toolBarLocation": "commandCenter"
}
+7
View File
@@ -0,0 +1,7 @@
REWRITE_HOSTNAME=
AWS_SQS_ACCESS_KEY_ID=
AWS_SQS_SECRET_ACCESS_KEY=
AWS_SQS_QUEUE_URL=
AWS_SQS_REGION=
#optional
#REWRITE_PORT=
+13
View File
@@ -0,0 +1,13 @@
# http://editorconfig.org
root = true
[*]
indent_style = tab
tab_width = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.yml]
indent_style = space
+172
View File
@@ -0,0 +1,172 @@
# Logs
logs
_.log
npm-debug.log_
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Runtime data
pids
_.pid
_.seed
\*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
\*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
\*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
\*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.\*
# wrangler project
.dev.vars
.wrangler/
+11
View File
@@ -0,0 +1,11 @@
{
"semi": true,
"singleQuote": false,
"jsxSingleQuote": false,
"trailingComma": "es5",
"bracketSpacing": true,
"bracketSameLine": false,
"printWidth": 100,
"tabWidth": 2,
"useTabs": false
}
+60
View File
@@ -0,0 +1,60 @@
# proxy
## 0.0.9
### Patch Changes
- @trigger.dev/core@2.3.3
## 0.0.8
### Patch Changes
- @trigger.dev/core@2.3.2
## 0.0.7
### Patch Changes
- Updated dependencies [f3efcc0c]
- @trigger.dev/core@2.3.1
## 0.0.6
### Patch Changes
- Updated dependencies [17f6f29d]
- @trigger.dev/core@2.3.0
## 0.0.5
### Patch Changes
- @trigger.dev/core@2.2.11
## 0.0.4
### Patch Changes
- @trigger.dev/core@2.2.10
## 0.0.3
### Patch Changes
- Updated dependencies [6ebd435e]
- @trigger.dev/core@2.2.9
## 0.0.2
### Patch Changes
- Updated dependencies [067e19fe]
- @trigger.dev/core@2.2.8
## 0.0.1
### Patch Changes
- Updated dependencies [756024da]
- @trigger.dev/core@2.2.7
+68
View File
@@ -0,0 +1,68 @@
# Trigger.dev proxy
This is an optional module that can be used to proxy and queue requests to the Trigger.dev API.
## Why?
The Trigger.dev API is designed to be fast and reliable. However, if you have a lot of traffic, you may want to use this proxy to queue requests to the API. It intercepts some requests to the API and adds them to an AWS SQS queue, then the webapp can be setup to process the queue.
## Current features
- Intercepts `sendEvent` requests and adds them to an AWS SQS queue. The webapp then reads from the queue and creates the events.
## Setup
### Create an AWS SQS queue
In AWS you should create a new AWS SQS queue with appropriate security settings. You will need the queue URL for the next step.
### Environment variables
#### Cloudflare secrets
Locally you should copy the `.dev.var.example` file to `.dev.var` and fill in the values.
When deploying you should use `wrangler` (the Cloudflare CLI tool) to set secrets. Make sure you set the correct --env ("staging" or "prod")
```bash
wrangler secret put REWRITE_HOSTNAME --env staging
wrangler secret put AWS_SQS_ACCESS_KEY_ID --env staging
wrangler secret put AWS_SQS_SECRET_ACCESS_KEY --env staging
wrangler secret put AWS_SQS_QUEUE_URL --env staging
wrangler secret put AWS_SQS_REGION --env staging
```
You need to set your API CNAME entry to be proxied by Cloudflare. You can do this in the Cloudflare dashboard.
#### Webapp
These env vars also need setting in the webapp.
```bash
AWS_SQS_REGION
AWS_SQS_ACCESS_KEY_ID
AWS_SQS_SECRET_ACCESS_KEY
AWS_SQS_QUEUE_URL
AWS_SQS_BATCH_SIZE
```
## Deployment
Staging:
```bash
npx wrangler@latest deploy --route "<your-api-subdomain>/*" --env staging
```
Prod:
```bash
npx wrangler@latest deploy --route "<your-api-subdomain>/*" --env prod
```
## Development
Set the environment variables as described above.
1. `pnpm install`
2. `pnpm run dev --filter proxy`
+21
View File
@@ -0,0 +1,21 @@
{
"name": "proxy",
"version": "0.0.9",
"private": true,
"scripts": {
"deploy": "wrangler deploy",
"dev": "wrangler dev"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20230419.0",
"typescript": "^5.0.4",
"wrangler": "^3.0.0"
},
"dependencies": {
"@aws-sdk/client-sqs": "^3.445.0",
"@trigger.dev/core": "workspace:*",
"ulidx": "^2.2.1",
"zod": "3.22.3",
"zod-error": "1.5.0"
}
}
+20
View File
@@ -0,0 +1,20 @@
import { z } from "zod";
const AuthorizationHeaderSchema = z.string().regex(/^Bearer .+$/);
export function getApiKeyFromRequest(request: Request) {
const rawAuthorization = request.headers.get("Authorization");
const authorization = AuthorizationHeaderSchema.safeParse(rawAuthorization);
if (!authorization.success) {
return;
}
const apiKey = authorization.data.replace(/^Bearer /, "");
const type = isPrivateApiKey(apiKey) ? ("PRIVATE" as const) : ("PUBLIC" as const);
return { apiKey, type };
}
function isPrivateApiKey(key: string) {
return key.startsWith("tr_");
}
+87
View File
@@ -0,0 +1,87 @@
import { SQSClient, SendMessageCommand } from "@aws-sdk/client-sqs";
import { ApiEventLog, SendEventBodySchema } from "@trigger.dev/core";
import { generateErrorMessage } from "zod-error";
import { Env } from "..";
import { getApiKeyFromRequest } from "../apikey";
import { json } from "../json";
import { calculateDeliverAt } from "./utils";
/** Adds the event to an AWS SQS queue, so it can be consumed from the main Trigger.dev API */
export async function queueEvent(request: Request, env: Env): Promise<Response> {
//check there's a private API key
const apiKeyResult = getApiKeyFromRequest(request);
if (!apiKeyResult || apiKeyResult.type !== "PRIVATE") {
return json(
{ error: "Invalid or Missing API key" },
{
status: 401,
}
);
}
//parse the request body
try {
const anyBody = await request.json();
const body = SendEventBodySchema.safeParse(anyBody);
if (!body.success) {
return json(
{ error: generateErrorMessage(body.error.issues) },
{
status: 422,
}
);
}
// The AWS SDK tries to use crypto from off of the window,
// so we need to trick it into finding it where it expects it
globalThis.global = globalThis;
const client = new SQSClient({
region: env.AWS_SQS_REGION,
credentials: {
accessKeyId: env.AWS_SQS_ACCESS_KEY_ID,
secretAccessKey: env.AWS_SQS_SECRET_ACCESS_KEY,
},
});
const timestamp = body.data.event.timestamp ?? new Date();
//add the event to the queue
const send = new SendMessageCommand({
// use wrangler secrets to provide this global variable
QueueUrl: env.AWS_SQS_QUEUE_URL,
MessageBody: JSON.stringify({
event: { ...body.data.event, timestamp },
options: body.data.options,
apiKey: apiKeyResult.apiKey,
}),
});
const queuedEvent = await client.send(send);
console.log("Queued event", queuedEvent);
//respond with the event
const event: ApiEventLog = {
id: body.data.event.id,
name: body.data.event.name,
payload: body.data.event.payload,
context: body.data.event.context,
timestamp,
deliverAt: calculateDeliverAt(body.data.options),
};
return json(event, {
status: 200,
});
} catch (e) {
console.error("queueEvent error", e);
return json(
{
error: `Failed to send event: ${e instanceof Error ? e.message : JSON.stringify(e)}`,
},
{
status: 422,
}
);
}
}
+112
View File
@@ -0,0 +1,112 @@
import { SQSClient, SendMessageBatchCommand } from "@aws-sdk/client-sqs";
import { ApiEventLog, SendBulkEventsBodySchema } from "@trigger.dev/core";
import { generateErrorMessage } from "zod-error";
import { Env } from "..";
import { getApiKeyFromRequest } from "../apikey";
import { json } from "../json";
import { calculateDeliverAt } from "./utils";
/** Adds the event to an AWS SQS queue, so it can be consumed from the main Trigger.dev API */
export async function queueEvents(request: Request, env: Env): Promise<Response> {
//check there's a private API key
const apiKeyResult = getApiKeyFromRequest(request);
if (!apiKeyResult || apiKeyResult.type !== "PRIVATE") {
return json(
{ error: "Invalid or Missing API key" },
{
status: 401,
}
);
}
//parse the request body
try {
const anyBody = await request.json();
const body = SendBulkEventsBodySchema.safeParse(anyBody);
if (!body.success) {
return json(
{ error: generateErrorMessage(body.error.issues) },
{
status: 422,
}
);
}
// The AWS SDK tries to use crypto from off of the window,
// so we need to trick it into finding it where it expects it
globalThis.global = globalThis;
const client = new SQSClient({
region: env.AWS_SQS_REGION,
credentials: {
accessKeyId: env.AWS_SQS_ACCESS_KEY_ID,
secretAccessKey: env.AWS_SQS_SECRET_ACCESS_KEY,
},
});
const updatedEvents: ApiEventLog[] = body.data.events.map((event) => {
const timestamp = event.timestamp ?? new Date();
return {
...event,
payload: event.payload,
timestamp,
};
});
//divide updatedEvents into multiple batches of 10 (max size SQS accepts)
const batches: ApiEventLog[][] = [];
let currentBatch: ApiEventLog[] = [];
for (let i = 0; i < updatedEvents.length; i++) {
currentBatch.push(updatedEvents[i]);
if (currentBatch.length === 10) {
batches.push(currentBatch);
currentBatch = [];
}
}
if (currentBatch.length > 0) {
batches.push(currentBatch);
}
//loop through the batches and send them
for (let i = 0; i < batches.length; i++) {
const batch = batches[i];
//add the event to the queue
const send = new SendMessageBatchCommand({
// use wrangler secrets to provide this global variable
QueueUrl: env.AWS_SQS_QUEUE_URL,
Entries: batch.map((event, index) => ({
Id: `event-${index}`,
MessageBody: JSON.stringify({
event,
options: body.data.options,
apiKey: apiKeyResult.apiKey,
}),
})),
});
const queuedEvent = await client.send(send);
console.log("Queued events", queuedEvent);
}
//respond with the events
const events: ApiEventLog[] = updatedEvents.map((event) => ({
...event,
payload: event.payload,
deliverAt: calculateDeliverAt(body.data.options),
}));
return json(events, {
status: 200,
});
} catch (e) {
console.error("queueEvents error", e);
return json(
{
error: `Failed to send events: ${e instanceof Error ? e.message : JSON.stringify(e)}`,
},
{
status: 422,
}
);
}
}
+15
View File
@@ -0,0 +1,15 @@
import { SendEventOptions } from "@trigger.dev/core";
export function calculateDeliverAt(options?: SendEventOptions) {
// If deliverAt is a string and a valid date, convert it to a Date object
if (options?.deliverAt) {
return options?.deliverAt;
}
// deliverAfter is the number of seconds to wait before delivering the event
if (options?.deliverAfter) {
return new Date(Date.now() + options.deliverAfter * 1000);
}
return undefined;
}
+67
View File
@@ -0,0 +1,67 @@
import { queueEvent } from "./events/queueEvent";
import { queueEvents } from "./events/queueEvents";
export interface Env {
/** The hostname needs to be changed to allow requests to pass to the Trigger.dev platform */
REWRITE_HOSTNAME: string;
REWRITE_PORT?: string;
AWS_SQS_ACCESS_KEY_ID: string;
AWS_SQS_SECRET_ACCESS_KEY: string;
AWS_SQS_QUEUE_URL: string;
AWS_SQS_REGION: string;
}
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
if (!env.REWRITE_HOSTNAME) throw new Error("Missing REWRITE_HOSTNAME");
console.log("url", request.url);
if (!queueingIsEnabled(env)) {
console.log("Missing AWS credentials. Passing through to the origin.");
return redirectToOrigin(request, env);
}
const url = new URL(request.url);
switch (url.pathname) {
case "/api/v1/events": {
if (request.method === "POST") {
return queueEvent(request, env);
}
break;
}
case "/api/v1/events/bulk": {
if (request.method === "POST") {
return queueEvents(request, env);
}
break;
}
}
//the same request but with the hostname (and port) changed
return redirectToOrigin(request, env);
},
};
function redirectToOrigin(request: Request, env: Env) {
const newUrl = new URL(request.url);
newUrl.hostname = env.REWRITE_HOSTNAME;
newUrl.port = env.REWRITE_PORT || newUrl.port;
const requestInit: RequestInit = {
method: request.method,
headers: request.headers,
body: request.body,
};
console.log("rewritten url", newUrl.toString());
return fetch(newUrl.toString(), requestInit);
}
function queueingIsEnabled(env: Env) {
return (
env.AWS_SQS_ACCESS_KEY_ID &&
env.AWS_SQS_SECRET_ACCESS_KEY &&
env.AWS_SQS_QUEUE_URL &&
env.AWS_SQS_REGION
);
}
+13
View File
@@ -0,0 +1,13 @@
export function json(body: any, init?: ResponseInit) {
const headers = {
"content-type": "application/json",
...(init?.headers ?? {}),
};
const responseInit: ResponseInit = {
...(init ?? {}),
headers,
};
return new Response(JSON.stringify(body), responseInit);
}
+35
View File
@@ -0,0 +1,35 @@
{
"compilerOptions": {
"target": "es2021" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
"lib": [
"es2021"
] /* Specify a set of bundled library declaration files that describe the target runtime environment. */,
"jsx": "react" /* Specify what JSX code is generated. */,
"module": "es2022" /* Specify what module code is generated. */,
"moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */,
"types": [
"@cloudflare/workers-types"
] /* Specify type package names to be included without being referenced in a source file. */,
"resolveJsonModule": true /* Enable importing .json files */,
"allowJs": true /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */,
"checkJs": false /* Enable error reporting in type-checked JavaScript files. */,
"noEmit": true /* Disable emitting files from a compilation. */,
"isolatedModules": true /* Ensure that each file can be safely transpiled without relying on other imports. */,
"allowSyntheticDefaultImports": true /* Allow 'import x from y' when a module doesn't have a default export. */,
"forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
"strict": true /* Enable all strict type-checking options. */,
"skipLibCheck": true /* Skip type checking all .d.ts files. */,
"baseUrl": ".",
"paths": {
"@trigger.dev/core": ["../../packages/core/src/index"],
"@trigger.dev/core/*": ["../../packages/core/src/*"]
}
}
}
+7
View File
@@ -0,0 +1,7 @@
name = "proxy"
main = "src/index.ts"
compatibility_date = "2023-10-30"
compatibility_flags = [ "nodejs_compat" ]
[env.staging]
[env.prod]
+22 -1
View File
@@ -16,19 +16,23 @@ export type JobEnvironment = {
lastRun?: Date;
version: string;
enabled: boolean;
concurrencyLimit?: number | null;
concurrencyLimitGroup?: { name: string; concurrencyLimit: number } | null;
};
type JobStatusTableProps = {
environments: JobEnvironment[];
displayStyle?: "short" | "long";
};
export function JobStatusTable({ environments }: JobStatusTableProps) {
export function JobStatusTable({ environments, displayStyle = "short" }: JobStatusTableProps) {
return (
<Table fullWidth>
<TableHeader>
<TableRow>
<TableHeaderCell>Env</TableHeaderCell>
<TableHeaderCell>Last Run</TableHeaderCell>
{displayStyle === "long" && <TableHeaderCell>Concurrency</TableHeaderCell>}
<TableHeaderCell alignment="right">Version</TableHeaderCell>
<TableHeaderCell alignment="right">Status</TableHeaderCell>
</TableRow>
@@ -42,6 +46,23 @@ export function JobStatusTable({ environments }: JobStatusTableProps) {
<TableCell>
{environment.lastRun ? <DateTime date={environment.lastRun} /> : "Never Run"}
</TableCell>
{displayStyle === "long" && (
<TableCell>
{environment.concurrencyLimitGroup ? (
<span className="flex items-center gap-1">
<span>{environment.concurrencyLimitGroup.name}</span>
<span className="text-gray-400">
({environment.concurrencyLimitGroup.concurrencyLimit})
</span>
</span>
) : typeof environment.concurrencyLimit === "number" ? (
<span className="text-gray-400">{environment.concurrencyLimit}</span>
) : (
<span className="text-gray-400">Not specified</span>
)}
</TableCell>
)}
<TableCell alignment="right">{environment.version}</TableCell>
<TableCell alignment="right">
<ActiveBadge active={environment.enabled} />
@@ -0,0 +1,341 @@
import {
BoltIcon,
CloudIcon,
CodeBracketIcon,
CodeBracketSquareIcon,
HeartIcon,
ServerStackIcon,
} from "@heroicons/react/24/solid";
import { useEffect, useState } from "react";
import { cn } from "~/utils/cn";
import { LogoType } from "./LogoType";
import { LinkButton } from "./primitives/Buttons";
import { Header3 } from "./primitives/Headers";
import { Icon } from "./primitives/Icon";
import { Paragraph } from "./primitives/Paragraph";
import { TextLink } from "./primitives/TextLink";
import { LoginTooltip } from "./primitives/Tooltip";
interface QuoteType {
quote: string;
person: string;
}
const quotes: QuoteType[] = [
{
quote: "Trigger.dev is redefining background jobs for modern developers.",
person: "Paul Copplestone, Supabase",
},
{
quote:
"Trigger.dev is a great way to automate email campaigns with Resend, and we've heard nothing but good things from our mutual customers.",
person: "Zeno Rocha, Resend",
},
{
quote: "We love Trigger.dev and its had a big impact in dev iteration velocity already.",
person: "André Neves, ZBD",
},
{
quote:
"Weve been looking for a product like Trigger.dev for a really long time - automation that's simple and developer-focused.",
person: "Han Wang, Mintlify",
},
];
const layout = "group grid place-items-center text-center overflow-hidden";
const gridCell = "hover:bg-midnight-850 rounded-lg transition bg-midnight-850/40";
const opacity = "opacity-10 group-hover:opacity-100 transition group-hover:scale-105";
const logos = "h-[60%] w-[60%] transition grayscale group-hover:grayscale-0";
const features = "h-[60%] w-[60%] text-gray-500 grayscale transition group-hover:grayscale-0";
const wide = "col-span-2";
const wider = "col-span-3 row-span-2";
const mediumSquare = "col-span-2 row-span-2";
const hidden = "hidden xl:grid";
export function LoginPageLayout({ children }: { children: React.ReactNode }) {
const [randomQuote, setRandomQuote] = useState<QuoteType | null>(null);
useEffect(() => {
const randomIndex = Math.floor(Math.random() * quotes.length);
setRandomQuote(quotes[randomIndex]);
}, []);
return (
<main className="grid h-full grid-cols-12">
<div className="border-midnight-750 z-10 col-span-12 border-r bg-midnight-850 md:col-span-6">
<div className="flex h-full flex-col items-center justify-between p-6">
<div className="flex w-full items-center justify-between">
<a href="https://trigger.dev">
<LogoType className="w-36" />
</a>
<LinkButton
to="https://trigger.dev/docs"
variant={"secondary/small"}
LeadingIcon="docs"
>
Documentation
</LinkButton>
</div>
<div className="flex h-full max-w-sm items-center justify-center">{children}</div>
<Paragraph variant="extra-small" className="text-center">
Having login issues? <TextLink href="mailto:help@trigger.dev">Email us</TextLink> or{" "}
<TextLink href="https://trigger.dev/discord">ask us in Discord</TextLink>
</Paragraph>
</div>
</div>
<div className="hidden grid-cols-3 grid-rows-6 gap-4 p-4 md:col-span-6 md:grid xl:grid-cols-5">
<LoginTooltip side="bottom" content={<ServerlessTooltipContent />}>
<div className={cn(layout, gridCell, mediumSquare)}>
<ServerStackIcon className={cn(opacity, features, "group-hover:text-green-500")} />
</div>
</LoginTooltip>
<LoginTooltip side="bottom" content={<SlackTooltipContent />}>
<div className={cn(layout, gridCell)}>
<Icon icon="slack" className={cn(logos, opacity)} />
</div>
</LoginTooltip>
<LoginTooltip side="left" content={<TriggerTooltipContent />}>
<div className={cn(layout, gridCell, mediumSquare, hidden)}>
<BoltIcon className={cn(opacity, features, "group-hover:text-yellow-500")} />
</div>
</LoginTooltip>
<LoginTooltip side="bottom" content={<StripeTooltipContent />} className="max-w-[15rem]">
<div className={cn("", layout, gridCell)}>
<Icon icon="stripe" className={cn(logos, opacity)} />
</div>
</LoginTooltip>
<LoginTooltip side="top" content={<QuoteTooltipContent />}>
<div className={cn(layout, gridCell, wider)}>
<div className="px-4">
<Header3 className="relative text-2xl font-normal leading-8 text-gray-600 transition before:relative before:right-1 before:top-0 before:text-4xl before:text-slate-600 before:opacity-20 before:content-['❝'] group-hover:text-slate-500 group-hover:before:opacity-30 lg-height:text-xl md-height:text-lg">
{randomQuote?.quote}
</Header3>
<Paragraph
variant="small"
className="mt-4 text-gray-700 transition group-hover:text-slate-600"
>
{randomQuote?.person}
</Paragraph>
</div>
</div>
</LoginTooltip>
<LoginTooltip side="left" content={<OpenaiTooltipContent />}>
<div className={cn("", layout, gridCell, hidden)}>
<Icon icon="openai" className={cn(logos, opacity)} />
</div>
</LoginTooltip>
<LoginTooltip side="left" content={<SendgridTooltipContent />}>
<div className={cn(layout, gridCell, hidden)}>
<Icon icon="sendgrid" className={cn(logos, opacity)} />
</div>
</LoginTooltip>
<LoginTooltip side="left" content={<ReactHooksTooltipContent />}>
<div className={cn(layout, gridCell, mediumSquare, hidden)}>
<Icon icon="react" className={cn(opacity, features, "group-hover:text-green-500")} />
</div>
</LoginTooltip>
<LoginTooltip side="right" content={<AirtableTooltipContent />}>
<div className={cn("", layout, gridCell)}>
<Icon icon="airtable" className={cn(logos, opacity)} />
</div>
</LoginTooltip>
<LoginTooltip side="top" content={<InYourCodebaseTooltipContent />}>
<div className={cn(layout, gridCell, mediumSquare)}>
<CodeBracketSquareIcon className={cn(opacity, features, "group-hover:text-rose-500")} />
</div>
</LoginTooltip>
<LoginTooltip side="right" content={<SupabaseTooltipContent />}>
<div className={cn(layout, gridCell)}>
<Icon icon="supabase" className={cn(logos, opacity)} />
</div>
</LoginTooltip>
<LoginTooltip side="left" content={<CloudTooltipContent />}>
<div className={cn(layout, gridCell, wide, hidden)}>
<CloudIcon className={cn(opacity, features, "h-20 w-20 group-hover:text-blue-600")} />
</div>
</LoginTooltip>
</div>
</main>
);
}
function SlackTooltipContent() {
return (
<>
<div className="mb-2 flex items-center gap-x-1.5">
<Icon icon="slack" className="h-5 w-5" />
<Paragraph variant="base/bright" className="font-semibold">
Slack Integration
</Paragraph>
</div>
<Paragraph variant="base">Post messages to your team when your Job is triggered.</Paragraph>
</>
);
}
function StripeTooltipContent() {
return (
<>
<div className="mb-2 flex items-center gap-x-1.5">
<Icon icon="stripe" className="h-5 w-5" />
<Paragraph variant="base/bright" className="font-semibold">
Stripe Integration
</Paragraph>
</div>
<Paragraph variant="base">Trigger payments, emails, subscription upgrades</Paragraph>
</>
);
}
function SupabaseTooltipContent() {
return (
<>
<div className="mb-2 flex items-center gap-x-1.5">
<Icon icon="supabase" className="h-5 w-5" />
<Paragraph variant="base/bright" className="font-semibold">
Supabase Integration
</Paragraph>
</div>
<Paragraph variant="base">React to changes in your database.</Paragraph>
</>
);
}
function SendgridTooltipContent() {
return (
<>
<div className="mb-2 flex items-center gap-x-1.5">
<Icon icon="sendgrid" className="h-5 w-5" />
<Paragraph variant="base/bright" className="font-semibold">
SendGrid Integration
</Paragraph>
</div>
<Paragraph variant="base">
Create a drip campaign, trigger an onboarding sequence and more
</Paragraph>
</>
);
}
function AirtableTooltipContent() {
return (
<>
<div className="mb-2 flex items-center gap-x-1.5">
<Icon icon="airtable" className="h-5 w-5" />
<Paragraph variant="base/bright" className="font-semibold">
Airtable Integration
</Paragraph>
</div>
<Paragraph variant="base">
Update your Airtable records when you make a Stripe sale, receive a new Typeform response
and more
</Paragraph>
</>
);
}
function OpenaiTooltipContent() {
return (
<>
<div className="mb-2 flex items-center gap-x-1.5">
<Icon icon="openai" className="h-5 w-5" />
<Paragraph variant="base/bright" className="font-semibold">
OpenAI Integration
</Paragraph>
</div>
<Paragraph variant="base">Generate text, images, code and more with OpenAI's API.</Paragraph>
</>
);
}
function TriggerTooltipContent() {
return (
<>
<div className="mb-2 flex items-center gap-x-1.5">
<BoltIcon className="h-5 w-5 text-yellow-500" />
<Paragraph variant="base/bright" className="font-semibold">
Triggering your Job
</Paragraph>
</div>
<Paragraph variant="base">
Trigger your Jobs with a webhook, on a recurring schedule, or from your own custom events.
</Paragraph>
</>
);
}
function QuoteTooltipContent() {
return (
<>
<div className="flex items-center gap-x-1.5">
<HeartIcon className="h-5 w-5 text-rose-500" />
<Paragraph variant="base/bright" className="font-semibold">
Loved by developers
</Paragraph>
</div>
</>
);
}
function InYourCodebaseTooltipContent() {
return (
<>
<div className="mb-2 flex items-center gap-x-1.5">
<CodeBracketSquareIcon className="h-5 w-5 text-rose-500" />
<Paragraph variant="base/bright" className="font-semibold">
In your codebase
</Paragraph>
</div>
<Paragraph variant="base">
Create background jobs where they belong: in your codebase.
</Paragraph>
</>
);
}
function CloudTooltipContent() {
return (
<>
<div className="mb-2 flex items-center gap-x-1.5">
<CloudIcon className="h-5 w-5 text-blue-600" />
<Paragraph variant="base/bright" className="font-semibold">
Zero infrastructure
</Paragraph>
</div>
<Paragraph variant="base">
Use our SDK to write Jobs in your codebase and deploy as you normally do.
</Paragraph>
</>
);
}
function ServerlessTooltipContent() {
return (
<>
<div className="mb-2 flex items-center gap-x-1.5">
<ServerStackIcon className="h-5 w-5 text-green-500" />
<Paragraph variant="base/bright" className="font-semibold">
Full serverless support
</Paragraph>
</div>
<Paragraph variant="base">
Run long-running background jobs without worrying about timeouts.
</Paragraph>
</>
);
}
function ReactHooksTooltipContent() {
return (
<>
<div className="mb-2 flex items-center gap-x-1.5">
<Icon icon="react" className="h-5 w-5" />
<Paragraph variant="base/bright" className="font-semibold">
Show Job progress in your UI
</Paragraph>
</div>
<Paragraph variant="base">
Use our React hooks to display a real-time status to your users.
</Paragraph>
</>
);
}
+190
View File
@@ -0,0 +1,190 @@
export function LogoType({ className }: { className?: string }) {
return (
<svg viewBox="0 0 751 130" xmlns="http://www.w3.org/2000/svg" className={className}>
<path
d="M195.022 16.2676H135.445H137.799V32.5096H157.858V102.4H174.84V32.5096H195.022V16.2676Z"
fill="url(#paint0_linear_228_1439)"
/>
<path
d="M211.265 51.4587V40.8767H195.391V102.4H211.265V72.9917C211.265 60.0719 221.725 56.3805 229.97 57.3648V39.6463C222.218 39.6463 214.465 43.0916 211.265 51.4587Z"
fill="url(#paint1_linear_228_1439)"
/>
<path
d="M246.954 33.494C252.368 33.494 256.799 29.0644 256.799 23.7734C256.799 18.4824 252.368 13.9297 246.954 13.9297C241.662 13.9297 237.232 18.4824 237.232 23.7734C237.232 29.0644 241.662 33.494 246.954 33.494ZM239.078 102.4H254.953V40.8767H239.078V102.4Z"
fill="url(#paint2_linear_228_1439)"
/>
<path
d="M315.253 40.8768V48.5056C310.946 42.7224 304.301 39.1542 295.563 39.1542C278.089 39.1542 264.921 53.4275 264.921 70.6539C264.921 88.0033 278.089 102.154 295.563 102.154C304.301 102.154 310.946 98.5853 315.253 92.8021V99.4466C315.253 109.167 309.1 114.581 299.132 114.581C289.656 114.581 285.596 110.767 283.011 105.968L269.475 113.72C274.889 123.687 285.472 128.731 298.64 128.731C314.884 128.731 330.758 119.626 330.758 99.4466V40.8768H315.253ZM298.025 87.5112C288.057 87.5112 280.796 80.4975 280.796 70.6539C280.796 60.9332 288.057 53.9196 298.025 53.9196C307.992 53.9196 315.253 60.9332 315.253 70.6539C315.253 80.4975 307.992 87.5112 298.025 87.5112Z"
fill="url(#paint3_linear_228_1439)"
/>
<path
d="M390.936 40.8768V48.5056C386.629 42.7224 379.983 39.1542 371.246 39.1542C353.772 39.1542 340.604 53.4275 340.604 70.6539C340.604 88.0033 353.772 102.154 371.246 102.154C379.983 102.154 386.629 98.5853 390.936 92.8021V99.4466C390.936 109.167 384.783 114.581 374.815 114.581C365.339 114.581 361.278 110.767 358.694 105.968L345.157 113.72C350.572 123.687 361.155 128.731 374.322 128.731C390.566 128.731 406.441 119.626 406.441 99.4466V40.8768H390.936ZM373.707 87.5112C363.739 87.5112 356.479 80.4975 356.479 70.6539C356.479 60.9332 363.739 53.9196 373.707 53.9196C383.675 53.9196 390.936 60.9332 390.936 70.6539C390.936 80.4975 383.675 87.5112 373.707 87.5112Z"
fill="url(#paint4_linear_228_1439)"
/>
<path
d="M432.9 78.1597H479.293C479.663 76.0679 479.909 73.9761 479.909 71.6383C479.909 53.5505 466.987 39.1542 448.775 39.1542C429.454 39.1542 416.287 53.3044 416.287 71.6383C416.287 89.9721 429.331 104.122 450.005 104.122C461.819 104.122 471.048 99.3236 476.832 90.9564L464.034 83.5737C461.327 87.142 456.404 89.726 450.251 89.726C441.883 89.726 435.115 86.2807 432.9 78.1597ZM432.654 65.8551C434.5 57.9802 440.284 53.4274 448.775 53.4274C455.42 53.4274 462.065 56.9958 464.034 65.8551H432.654Z"
fill="url(#paint5_linear_228_1439)"
/>
<path
d="M505.199 51.4587V40.8767H489.324V102.4H505.199V72.9917C505.199 60.0719 515.659 56.3805 523.904 57.3648V39.6463C516.151 39.6463 508.398 43.0916 505.199 51.4587Z"
fill="url(#paint6_linear_228_1439)"
/>
<path
d="M529.934 103.999C535.717 103.999 540.394 99.3235 540.394 93.5404C540.394 87.7572 535.717 83.0815 529.934 83.0815C524.15 83.0815 519.473 87.7572 519.473 93.5404C519.473 99.3235 524.15 103.999 529.934 103.999Z"
fill="url(#paint7_linear_228_1439)"
/>
<path
d="M596.632 16.2676V48.1364C592.202 42.4763 585.679 39.1541 576.696 39.1541C560.206 39.1541 546.67 53.3044 546.67 71.6382C546.67 89.972 560.206 104.122 576.696 104.122C585.679 104.122 592.202 100.8 596.632 95.1399V102.4H612.506V16.2676L596.632 16.2676ZM579.65 88.9876C569.805 88.9876 562.544 81.9741 562.544 71.6382C562.544 61.3024 569.805 54.2887 579.65 54.2887C589.371 54.2887 596.632 61.3024 596.632 71.6382C596.632 81.9741 589.371 88.9876 579.65 88.9876Z"
fill="url(#paint8_linear_228_1439)"
/>
<path
d="M637.98 78.1597H684.373C684.742 76.0679 684.989 73.9761 684.989 71.6383C684.989 53.5505 672.067 39.1542 653.855 39.1542C634.534 39.1542 621.367 53.3044 621.367 71.6383C621.367 89.9721 634.411 104.122 655.085 104.122C666.899 104.122 676.128 99.3236 681.912 90.9564L669.114 83.5737C666.407 87.142 661.484 89.726 655.331 89.726C646.963 89.726 640.195 86.2807 637.98 78.1597ZM637.734 65.8551C639.58 57.9802 645.363 53.4274 653.855 53.4274C660.5 53.4274 667.145 56.9958 669.114 65.8551H637.734Z"
fill="url(#paint9_linear_228_1439)"
/>
<path
d="M732.859 40.8768L717.846 83.9428L702.955 40.8768H685.481L708.862 102.4H726.952L750.333 40.8768H732.859Z"
fill="url(#paint10_linear_228_1439)"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M35.664 42.3949L59.4114 1.26865L118.264 103.194H0.558823L24.3062 62.0665L41.1046 71.7643L34.157 83.7971H84.6657L59.4114 40.0612L52.4637 52.094L35.664 42.3949Z"
fill="url(#paint11_linear_228_1439)"
/>
<defs>
<linearGradient
id="paint0_linear_228_1439"
x1="329.674"
y1="150.079"
x2="329.674"
y2="13.9297"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#41FF54" />
<stop offset="1" stopColor="#E7FF52" />
</linearGradient>
<linearGradient
id="paint1_linear_228_1439"
x1="329.674"
y1="150.079"
x2="329.674"
y2="13.9297"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#41FF54" />
<stop offset="1" stopColor="#E7FF52" />
</linearGradient>
<linearGradient
id="paint2_linear_228_1439"
x1="329.674"
y1="150.079"
x2="329.674"
y2="13.9297"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#41FF54" />
<stop offset="1" stopColor="#E7FF52" />
</linearGradient>
<linearGradient
id="paint3_linear_228_1439"
x1="329.674"
y1="150.079"
x2="329.674"
y2="13.9297"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#41FF54" />
<stop offset="1" stopColor="#E7FF52" />
</linearGradient>
<linearGradient
id="paint4_linear_228_1439"
x1="329.674"
y1="150.079"
x2="329.674"
y2="13.9297"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#41FF54" />
<stop offset="1" stopColor="#E7FF52" />
</linearGradient>
<linearGradient
id="paint5_linear_228_1439"
x1="329.674"
y1="150.079"
x2="329.674"
y2="13.9297"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#41FF54" />
<stop offset="1" stopColor="#E7FF52" />
</linearGradient>
<linearGradient
id="paint6_linear_228_1439"
x1="329.674"
y1="150.079"
x2="329.674"
y2="13.9297"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#41FF54" />
<stop offset="1" stopColor="#E7FF52" />
</linearGradient>
<linearGradient
id="paint7_linear_228_1439"
x1="634.903"
y1="139.717"
x2="651.436"
y2="25.9719"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#2563EB" />
<stop offset="1" stopColor="#A855F7" />
</linearGradient>
<linearGradient
id="paint8_linear_228_1439"
x1="634.903"
y1="139.717"
x2="651.436"
y2="25.9719"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#2563EB" />
<stop offset="1" stopColor="#A855F7" />
</linearGradient>
<linearGradient
id="paint9_linear_228_1439"
x1="634.903"
y1="139.717"
x2="651.436"
y2="25.9719"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#2563EB" />
<stop offset="1" stopColor="#A855F7" />
</linearGradient>
<linearGradient
id="paint10_linear_228_1439"
x1="634.903"
y1="139.717"
x2="651.436"
y2="25.9719"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#2563EB" />
<stop offset="1" stopColor="#A855F7" />
</linearGradient>
<linearGradient
id="paint11_linear_228_1439"
x1="95.8593"
y1="103.194"
x2="94.7607"
y2="31.2381"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#41FF54" />
<stop offset="1" stopColor="#E7FF52" />
</linearGradient>
</defs>
</svg>
);
}
@@ -1,6 +1,6 @@
import React, { useState } from "react";
import { ApiAuthenticationMethodApiKey, Integration } from "~/services/externalApis/types";
import { docsIntegrationPath } from "~/utils/pathBuilder";
import { Integration } from "~/services/externalApis/types";
import { apiReferencePath, docsIntegrationPath } from "~/utils/pathBuilder";
import { LinkButton } from "../primitives/Buttons";
import { Header1, Header2 } from "../primitives/Headers";
import { NamedIconInBox } from "../primitives/NamedIcon";
@@ -48,6 +48,15 @@ export function ConnectToIntegrationSheet({
<Paragraph variant="small">{integration.description}</Paragraph>
)}
</div>
<LinkButton
to={apiReferencePath(integration.identifier)}
variant="secondary/small"
TrailingIcon="arrow-up-right"
trailingIconClassName="h-4 w-4 text-slate-400"
target="_blank"
>
View examples
</LinkButton>
<LinkButton
to={docsIntegrationPath(integration.identifier)}
variant="secondary/small"
@@ -83,13 +92,6 @@ export function ConnectToIntegrationSheet({
variant="description"
/>
)}
<RadioGroupItem
id="custom"
value="custom"
label="Fetch/Existing SDK"
description={`Alternatively, use ${integration.name} without our integration.`}
variant="description"
/>
</RadioGroup>
{integrationMethod && (
@@ -132,7 +134,5 @@ function SelectedIntegrationMethod({
callbackUrl={callbackUrl}
/>
);
case "custom":
return <CustomHelp name={integration.name} />;
}
}
@@ -1,79 +1,124 @@
import { CodeBlock } from "../code/CodeBlock";
import { useState } from "react";
import { CodeExample } from "~/routes/resources.codeexample";
import { Api } from "~/services/externalApis/apis.server";
import { cn } from "~/utils/cn";
import { Feedback } from "../Feedback";
import { Header1, Header2 } from "../primitives/Headers";
import { Paragraph } from "../primitives/Paragraph";
import { TextLink } from "../primitives/TextLink";
const fallbackExamples = [
{
title: "Post to Slack when meetings are booked or cancelled.",
slug: "cal-slack-meeting-alert",
version: "1.0.0",
codeUrl: "https://github.com/triggerdotdev/api-reference/raw/main/src/cal-http-endpoint.ts",
},
{
title: "Translate some text with DeepL.",
slug: "translate-text-with-deepl",
version: "1.0.0",
codeUrl: "https://github.com/triggerdotdev/api-reference/raw/main/src/deepl.ts",
},
{
title: "Create a Discord bot and send a message to a channel.",
slug: "discord-bot-send-message",
version: "1.0.0",
codeUrl: "https://github.com/triggerdotdev/api-reference/raw/main/src/discord.ts",
},
{
title: "Retrieve a Notion page by ID.",
slug: "retrieve-notion-page",
version: "1.0.0",
codeUrl: "https://github.com/triggerdotdev/api-reference/raw/main/src/notion.ts",
},
];
export function CustomHelp({ api }: { api: Api }) {
const [selectedExample, setSelectedExample] = useState(0);
const changeCodeExample = (index: number) => {
setSelectedExample(index);
};
export function CustomHelp({ name }: { name: string }) {
return (
<div className="mt-4">
<Header1 className="mb-2">You can use any API with requests or an SDK</Header1>
<Header2 className="mb-2">How to use an SDK</Header2>
<Header1 className="mb-2">Using an API with an SDK or requests</Header1>
<Paragraph spacing>
You can call SDK methods from inside the run function, but you should wrap them in a Task to
make sure they're resumable.
You can use Trigger.dev with any existing Node SDK or even just using fetch. You can
subscribe to any API with{" "}
<TextLink href="https://trigger.dev/docs/documentation/concepts/http-endpoints">
HTTP endpoints
</TextLink>{" "}
and perform actions by wrapping tasks using{" "}
<TextLink
href="https://trigger.dev/docs/documentation/guides/writing-jobs-step-by-step#create-your-own-tasks"
className="font-mono"
>
io.runTask
</TextLink>
. This makes your background job resumable and appear in our dashboard.
</Paragraph>
<Paragraph spacing>Here's an example with the official GitHub SDK</Paragraph>
<CodeBlock
code={`
client.defineJob({
id: "scheduled-job-1",
name: "Scheduled Job 1",
version: "0.1.1",
trigger: cronTrigger({
cron: "*/5 * * * *", // every 5 minutes
}),
run: async (payload, io, ctx) => {
//wrap an SDK call in io.runTask so it's resumable and displays in logs
const repo = await io.runTask(
"Get repo",
async () => {
//this is the regular GitHub SDK
const response = await octokit.rest.repos.get({
owner: "triggerdotdev",
repo: "trigger.dev",
});
return response.data;
},
//you can add metadata to the task to improve the display in the logs
{ name: "Get repo", icon: "github" }
);
},
});
`}
highlightedRanges={[[9, 22]]}
className="mb-4"
/>
<Header2 className="mb-2">How to use fetch</Header2>
<Paragraph spacing>
You can use the fetch API to make requests to any API. Or a different request library like
axios if you'd prefer. Again wrapping the request in a Task will make sure it's resumable.
</Paragraph>
<CodeBlock
code={`
client.defineJob({
id: "scheduled-job-1",
name: "Scheduled Job 1",
version: "0.1.1",
trigger: cronTrigger({
cron: "*/5 * * * *", // every 5 minutes
}),
run: async (payload, io, ctx) => {
//wrap anything in io.runTask so it's resumable and displays in logs
const repo = await io.runTask(
"Get org",
async () => {
//you can use fetch, axios, or any other library to make requests
const response = await fetch('https://api.github.com/orgs/nodejs');
return response.json();
},
//you can add metadata to the task to improve the display in the logs
{ name: "Get org", icon: "github" }
);
},
});
`}
highlightedRanges={[[9, 19]]}
className="mb-4"
/>
{api.examples && api.examples.length > 0 ? (
<>
<Header2 className="mb-2">Example {api.name} code</Header2>
<Paragraph spacing className="mb-4">
This is how you can use {api.name} with Trigger.dev. This code can be copied and
modified to suit your use-case.
</Paragraph>
{api.examples.length > 1 && (
<div className=" flex w-full flex-row gap-4 overflow-x-scroll scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700">
{api.examples?.map((example, index) => (
<button
onClick={() => changeCodeExample(index)}
key={example.codeUrl}
className={cn(
"w-64 min-w-[16rem] p-2 transition-colors duration-300 sm:w-full sm:rounded",
"border-px focus:border-px cursor-pointer border border-slate-900 bg-slate-900 text-slate-300 transition duration-300 hover:bg-slate-800 focus:border focus:border-indigo-600"
)}
>
{example.title}
</button>
))}
</div>
)}
<CodeExample example={api.examples[selectedExample]} />
</>
) : (
<>
<Header2 className="mb-2">Example code using fetch / an existing SDK</Header2>
<Paragraph spacing className="mb-4">
You can use one of our examples below as a starting point / reference for your projects.
Please{" "}
<Feedback
button={
<span className="cursor-pointer text-indigo-500 transition duration-300 hover:text-indigo-400">
reach out to us
</span>
}
defaultValue="help"
/>{" "}
if you're having any issues.
</Paragraph>
<div className=" flex w-full flex-row gap-4 overflow-x-scroll scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700 sm:h-full">
{fallbackExamples.map((example, index) => (
<button
onClick={() => changeCodeExample(index)}
key={example.codeUrl}
className={cn(
"w-64 min-w-[16rem] p-2 transition-colors duration-300 sm:w-full sm:rounded",
"border-px focus:border-px cursor-pointer border border-slate-900 bg-slate-900 text-slate-300 transition duration-300 hover:bg-slate-800 focus:border focus:border-indigo-600"
)}
>
{example.title}
</button>
))}
</div>
<CodeExample example={fallbackExamples[selectedExample]} />
</>
)}
</div>
);
}
@@ -1,14 +1,10 @@
import { useFetcher } from "@remix-run/react";
import React from "react";
import { Api } from "~/services/externalApis/apis";
import { Button } from "../primitives/Buttons";
import { Callout } from "../primitives/Callout";
import { Api } from "~/services/externalApis/apis.server";
import { Header1 } from "../primitives/Headers";
import { NamedIconInBox } from "../primitives/NamedIcon";
import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTrigger } from "../primitives/Sheet";
import { CustomHelp } from "./CustomHelp";
import { CheckIcon } from "@heroicons/react/24/solid";
import { useFetcher } from "@remix-run/react";
import { Paragraph } from "../primitives/Paragraph";
export function NoIntegrationSheet({
api,
@@ -31,27 +27,9 @@ export function NoIntegrationSheet({
<NamedIconInBox name={api.identifier} className="h-9 w-9" />
<Header1>{api.name}</Header1>
</div>
{requested ? (
<div className="flex items-center gap-1">
<CheckIcon className="h-4 w-4 text-green-500" />
<Paragraph variant="small">
We'll let you know when the Integration is available.
</Paragraph>
</div>
) : (
<fetcher.Form method="post" action={`/resources/apivote/${api.identifier}`}>
<Button
variant="primary/small"
disabled={isLoading}
LeadingIcon={isLoading ? "spinner-white" : undefined}
>
{isLoading ? "Saving…" : `I want an Integration for ${api.name}`}
</Button>
</fetcher.Form>
)}
</SheetHeader>
<SheetBody>
<CustomHelp name={api.name} />
<CustomHelp api={api} />
</SheetBody>
</SheetContent>
</Sheet>
@@ -78,10 +78,16 @@ export function PageBodyPadding({ children }: { children: React.ReactNode }) {
return <div className="p-4">{children}</div>;
}
export function MainCenteredContainer({ children }: { children: React.ReactNode }) {
export function MainCenteredContainer({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div className="h-full w-full overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700">
<div className="mx-auto mt-[25vh] max-w-xs overflow-y-auto">{children}</div>
<div className={cn("mx-auto mt-[25vh] max-w-xs overflow-y-auto", className)}>{children}</div>
</div>
);
}
@@ -26,6 +26,7 @@ import {
projectEnvironmentsPath,
projectHttpEndpointsPath,
projectPath,
projectRunsPath,
projectSetupPath,
projectTriggersPath,
} from "~/utils/pathBuilder";
@@ -120,6 +121,12 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
to={projectPath(organization, project)}
data-action="jobs"
/>
<SideMenuItem
name="Runs"
icon="runs"
iconColor="text-teal-500"
to={projectRunsPath(organization, project)}
/>
<SideMenuItem
name="Triggers"
icon="trigger"
@@ -106,6 +106,33 @@ const variant = {
shortcutVariant: "medium" as const,
shortcut: "ml-1.5 -mr-0.5 border-bright/40 text-bright group-hover:border-bright/60",
},
"primary/extra-large": {
textColor: "text-bright group-hover:text-white transition group-disabled:text-dimmed/80",
button:
"h-12 px-2 text-md font-medium bg-indigo-600 group-hover:bg-indigo-500/90 disabled:opacity-50",
icon: "h-5",
iconSpacing: undefined,
shortcutVariant: undefined,
shortcut: undefined,
},
"secondary/extra-large": {
textColor: "text-dimmed",
button:
"h-12 px-2 text-md text-dimmed group-hover:text-bright transition font-medium bg-slate-800 group-hover:bg-slate-700/70 disabled:opacity-50",
icon: "h-5",
iconSpacing: undefined,
shortcutVariant: undefined,
shortcut: undefined,
},
"danger/extra-large": {
textColor: "text-bright group-hover:text-white transition group-disabled:text-bright/50",
button:
"h-12 px-2 text-md bg-rose-600 group-hover:bg-rose-500 group-disabled:opacity-50 group-disabled:group-hover:bg-rose-600",
icon: "h-5",
iconSpacing: undefined,
shortcutVariant: "medium" as const,
shortcut: "ml-1.5 -mr-0.5 border-bright/40 text-bright group-hover:border-bright/60",
},
"menu-item": {
textColor: "text-bright px-1",
button:
@@ -277,8 +304,9 @@ export const Button = forwardRef<HTMLButtonElement, ButtonPropsType>(
}
);
type LinkPropsType = Pick<LinkProps, "to" | "target"> & React.ComponentProps<typeof ButtonContent>;
export const LinkButton = ({ to, ...props }: LinkPropsType) => {
type LinkPropsType = Pick<LinkProps, "to" | "target" | "onClick"> &
React.ComponentProps<typeof ButtonContent>;
export const LinkButton = ({ to, onClick, ...props }: LinkPropsType) => {
const innerRef = useRef<HTMLAnchorElement>(null);
if (props.shortcut) {
useShortcutKeys({
@@ -297,6 +325,7 @@ export const LinkButton = ({ to, ...props }: LinkPropsType) => {
href={to.toString()}
ref={innerRef}
className={cn("group outline-none", props.fullWidth ? "w-full" : "")}
onClick={onClick}
>
<ButtonContent {...props} />
</ExtLink>
@@ -307,6 +336,7 @@ export const LinkButton = ({ to, ...props }: LinkPropsType) => {
to={to}
ref={innerRef}
className={cn("group outline-none", props.fullWidth ? "w-full" : "")}
onClick={onClick}
>
<ButtonContent {...props} />
</Link>
@@ -2,7 +2,7 @@ import { cn } from "~/utils/cn";
const headerVariants = {
header1: {
text: "font-sans text-base md:text-lg lg:text-xl leading-5 md:leading-6 lg:leading-7 font-semibold",
text: "font-sans text-2xl leading-5 md:leading-6 lg:leading-7 font-semibold",
spacing: "mb-2",
},
header2: {
@@ -6,7 +6,7 @@ import { NamedIcon } from "./NamedIcon";
const variants = {
large: {
input:
"px-3 flex h-10 w-full text-bright rounded-md border border-slate-800 bg-slate-850 text-sm ring-offset-background transition file:border-0 file:bg-transparent file:text-base file:font-medium placeholder:text-muted-foreground hover:border-slate-750 hover:bg-slate-800 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-0 focus:border-ring focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
"px-3 flex h-10 w-full text-bright rounded-[3px] border border-slate-800 bg-slate-850 text-sm ring-offset-background transition file:border-0 file:bg-transparent file:text-base file:font-medium placeholder:text-muted-foreground hover:border-slate-750 hover:bg-slate-800 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-0 focus:border-ring focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
iconSize: "h-4 w-4 ml-3",
iconOffset: "pl-[34px]",
@@ -33,7 +33,7 @@ const variants = {
},
tertiary: {
input:
"px-1 flex h-6 w-full text-bright rounded bg-transparent border border-transparent transition hover:border-slate-800 hover:bg-slate-850 focus:border-slate-800 focus:bg-slate-850 text-xs ring-offset-background transition file:border-0 file:bg-transparent file:text-xs file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50",
"px-1 flex h-6 w-full text-bright rounded bg-transparent border border-transparent hover:border-slate-800 hover:bg-slate-850 focus:border-slate-800 focus:bg-slate-850 text-xs ring-offset-background transition file:border-0 file:bg-transparent file:text-xs file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50",
iconSize: "h-3 w-3 ml-1.5",
iconOffset: "pl-[21px]",
@@ -100,7 +100,7 @@ export function PageInfoProperty({
}: {
icon?: string | React.ReactNode;
label?: string;
value: React.ReactNode;
value?: React.ReactNode;
to?: string;
}) {
if (to === undefined) {
@@ -121,17 +121,18 @@ function PageInfoPropertyContent({
}: {
icon?: string | React.ReactNode;
label?: string;
value: React.ReactNode;
value?: React.ReactNode;
}) {
return (
<div className="flex items-center gap-1">
{icon && typeof icon === "string" ? <NamedIcon name={icon} className="h-4 w-4" /> : icon}
{label && (
<Paragraph variant="extra-small/caps" className="mt-0.5 whitespace-nowrap">
{label}:
{label}
{value && ":"}
</Paragraph>
)}
<Paragraph variant="small">{value}</Paragraph>
{value && <Paragraph variant="small">{value}</Paragraph>}
</div>
);
}
@@ -8,9 +8,10 @@ export type TabsProps = {
to: string;
}[];
className?: string;
layoutId: string
};
export function Tabs({ tabs, className }: TabsProps) {
export function Tabs({ tabs, className, layoutId }: TabsProps) {
return (
<div className={cn(`flex flex-row gap-x-6 border-b border-ui-border`, className)}>
{tabs.map((tab, index) => (
@@ -26,7 +27,7 @@ export function Tabs({ tabs, className }: TabsProps) {
{tab.label}
</span>
{isActive || isPending ? (
<motion.div layoutId="underline" className="mt-1 h-0.5 w-full bg-indigo-500" />
<motion.div layoutId={layoutId} className="mt-1 h-0.5 w-full bg-indigo-500" />
) : (
<div className="mt-1 h-0.5 w-full bg-slate-500 opacity-0 transition duration-200 group-hover:opacity-100" />
)}
@@ -61,4 +61,34 @@ function SimpleTooltip({
);
}
export function LoginTooltip({
children,
side,
content,
className,
}: {
children: React.ReactNode;
side: "top" | "bottom" | "left" | "right";
content: React.ReactNode | string;
className?: string;
}) {
return (
<TooltipProvider delayDuration={2500} disableHoverableContent>
<Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent
className={cn(
"max-w-xs border-slate-800 bg-slate-900 px-5 py-4 backdrop-blur-md",
className
)}
side={side}
sideOffset={14}
>
{content}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider, TooltipArrow, SimpleTooltip };
+25 -27
View File
@@ -10,13 +10,14 @@ import {
useNavigate,
useNavigation,
} from "@remix-run/react";
import { JobRunStatus, RuntimeEnvironmentType } from "@trigger.dev/database";
import { RuntimeEnvironmentType } from "@trigger.dev/database";
import { useMemo } from "react";
import { usePathName } from "~/hooks/usePathName";
import type { RunBasicStatus } from "~/models/jobRun.server";
import { ViewRun } from "~/presenters/RunPresenter.server";
import { cancelSchema } from "~/routes/resources.runs.$runId.cancel";
import { schema } from "~/routes/resources.runs.$runId.rerun";
import { formatDuration } from "~/utils";
import { formatDuration, formatDurationMilliseconds } from "~/utils";
import { cn } from "~/utils/cn";
import { runCompletedPath, runTaskPath, runTriggerPath } from "~/utils/pathBuilder";
import { CodeBlock } from "../code/CodeBlock";
@@ -38,14 +39,7 @@ import {
} from "../primitives/PageHeader";
import { Paragraph } from "../primitives/Paragraph";
import { Popover, PopoverContent, PopoverTrigger } from "../primitives/Popover";
import {
RunBasicStatus,
RunStatusIcon,
RunStatusLabel,
hasFinished,
runBasicStatus,
runStatusTitle,
} from "../runs/RunStatuses";
import { RunStatusIcon, RunStatusLabel, runStatusTitle } from "../runs/RunStatuses";
import {
RunPanel,
RunPanelBody,
@@ -95,8 +89,6 @@ export function RunOverview({ run, trigger, showRerun, paths }: RunOverviewProps
}
}, [pathName]);
const basicStatus = runBasicStatus(run.status);
return (
<PageContainer>
<PageHeader>
@@ -106,7 +98,9 @@ export function RunOverview({ run, trigger, showRerun, paths }: RunOverviewProps
to: paths.back,
text: "Runs",
}}
title={`Run #${run.number}`}
title={
typeof run.number === "number" ? `Run #${run.number}` : `Run ${run.id.slice(0, 8)}`
}
/>
<PageButtons>
{run.isTest && (
@@ -115,15 +109,15 @@ export function RunOverview({ run, trigger, showRerun, paths }: RunOverviewProps
Test run
</span>
)}
{showRerun && hasFinished(run.status) && (
{showRerun && run.isFinished && (
<RerunPopover
runId={run.id}
runsPath={paths.runsPath}
environmentType={run.environment.type}
status={basicStatus}
status={run.basicStatus}
/>
)}
{!hasFinished(run.status) && <CancelRun runId={run.id} />}
{!run.isFinished && <CancelRun runId={run.id} />}
</PageButtons>
</PageTitleRow>
<PageInfoRow>
@@ -146,7 +140,17 @@ export function RunOverview({ run, trigger, showRerun, paths }: RunOverviewProps
<PageInfoProperty
icon={"clock"}
label={"Duration"}
value={formatDuration(run.startedAt, run.completedAt)}
value={formatDuration(run.startedAt, run.completedAt, { style: "short" })}
/>
<PageInfoProperty
icon={"hourglass"}
label={"Execution Time"}
value={formatDurationMilliseconds(run.executionDuration, { style: "short" })}
/>
<PageInfoProperty
icon={"list-numbers"}
label={"Execution Count"}
value={<>{run.executionCount}</>}
/>
</PageInfoGroup>
<PageInfoGroup alignment="right">
@@ -211,10 +215,10 @@ export function RunOverview({ run, trigger, showRerun, paths }: RunOverviewProps
);
})
) : (
<BlankTasks status={run.status} basicStatus={basicStatus} />
<BlankTasks status={run.basicStatus} />
)}
</div>
{(basicStatus === "COMPLETED" || basicStatus === "FAILED") && (
{(run.basicStatus === "COMPLETED" || run.basicStatus === "FAILED") && (
<div>
<Header2 className={cn("mb-2")}>Run Summary</Header2>
<RunPanel
@@ -285,14 +289,8 @@ export function RunOverview({ run, trigger, showRerun, paths }: RunOverviewProps
);
}
function BlankTasks({
status,
basicStatus,
}: {
status: JobRunStatus;
basicStatus: RunBasicStatus;
}) {
switch (basicStatus) {
function BlankTasks({ status }: { status: RunBasicStatus }) {
switch (status) {
default:
case "COMPLETED":
return <Paragraph variant="small">There were no tasks for this run.</Paragraph>;
+25 -11
View File
@@ -33,7 +33,19 @@ import { Spinner } from "../primitives/Spinner";
import type { DetailedTask } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam.runs.$runParam.tasks.$taskParam/route";
export function TaskDetail({ task }: { task: DetailedTask }) {
const { name, description, icon, status, params, properties, output, style, attempts } = task;
const {
name,
description,
icon,
status,
params,
properties,
output,
outputIsUndefined,
style,
attempts,
noop,
} = task;
const startedAt = task.startedAt ? new Date(task.startedAt) : undefined;
const completedAt = task.completedAt ? new Date(task.completedAt) : undefined;
@@ -140,16 +152,18 @@ export function TaskDetail({ task }: { task: DetailedTask }) {
<Paragraph variant="small">No input</Paragraph>
)}
</div>
<div className="mt-4 flex flex-col gap-2">
<Header3>Output</Header3>
{output ? (
<ClientOnly fallback={<Spinner />}>
{() => <CodeBlock code={output} maxLines={35} />}
</ClientOnly>
) : (
<Paragraph variant="small">No output</Paragraph>
)}
</div>
{!noop && (
<div className="mt-4 flex flex-col gap-2">
<Header3>Output</Header3>
{output && !outputIsUndefined ? (
<ClientOnly fallback={<Spinner />}>
{() => <CodeBlock code={output} maxLines={35} />}
</ClientOnly>
) : (
<Paragraph variant="small">No output</Paragraph>
)}
</div>
)}
</RunPanelBody>
</RunPanel>
);
+21 -43
View File
@@ -3,6 +3,7 @@ import {
CheckCircleIcon,
ClockIcon,
ExclamationTriangleIcon,
PauseCircleIcon,
WrenchIcon,
XCircleIcon,
} from "@heroicons/react/24/solid";
@@ -10,18 +11,6 @@ import type { JobRunStatus } from "@trigger.dev/database";
import { cn } from "~/utils/cn";
import { Spinner } from "../primitives/Spinner";
export function hasFinished(status: JobRunStatus): boolean {
return (
status === "SUCCESS" ||
status === "FAILURE" ||
status === "ABORTED" ||
status === "TIMED_OUT" ||
status === "CANCELED" ||
status === "UNRESOLVED_AUTH" ||
status === "INVALID_PAYLOAD"
);
}
export function RunStatus({ status }: { status: JobRunStatus }) {
return (
<span className="flex items-center gap-1">
@@ -40,49 +29,26 @@ export function RunStatusIcon({ status, className }: { status: JobRunStatus; cla
case "SUCCESS":
return <CheckCircleIcon className={cn(runStatusClassNameColor(status), className)} />;
case "PENDING":
case "WAITING_TO_CONTINUE":
return <ClockIcon className={cn(runStatusClassNameColor(status), className)} />;
case "QUEUED":
return <ClockIcon className={cn(runStatusClassNameColor(status), className)} />;
case "WAITING_TO_EXECUTE":
return <PauseCircleIcon className={cn(runStatusClassNameColor(status), className)} />;
case "PREPROCESSING":
case "STARTED":
case "EXECUTING":
return <Spinner className={cn(runStatusClassNameColor(status), className)} />;
case "FAILURE":
return <XCircleIcon className={cn(runStatusClassNameColor(status), className)} />;
case "TIMED_OUT":
return <ExclamationTriangleIcon className={cn(runStatusClassNameColor(status), className)} />;
case "UNRESOLVED_AUTH":
case "FAILURE":
case "ABORTED":
case "INVALID_PAYLOAD":
return <XCircleIcon className={cn(runStatusClassNameColor(status), className)} />;
case "WAITING_ON_CONNECTIONS":
return <WrenchIcon className={cn(runStatusClassNameColor(status), className)} />;
case "ABORTED":
return <XCircleIcon className={cn(runStatusClassNameColor(status), className)} />;
case "PREPROCESSING":
return <Spinner className={cn(runStatusClassNameColor(status), className)} />;
case "CANCELED":
return <NoSymbolIcon className={cn(runStatusClassNameColor(status), className)} />;
}
}
export type RunBasicStatus = "WAITING" | "PENDING" | "RUNNING" | "COMPLETED" | "FAILED";
export function runBasicStatus(status: JobRunStatus): RunBasicStatus {
switch (status) {
case "WAITING_ON_CONNECTIONS":
case "QUEUED":
case "PREPROCESSING":
case "PENDING":
return "PENDING";
case "STARTED":
return "RUNNING";
case "FAILURE":
case "TIMED_OUT":
case "UNRESOLVED_AUTH":
case "CANCELED":
case "ABORTED":
case "INVALID_PAYLOAD":
return "FAILED";
case "SUCCESS":
return "COMPLETED";
default: {
const _exhaustiveCheck: never = status;
throw new Error(`Non-exhaustive match for value: ${status}`);
@@ -99,7 +65,12 @@ export function runStatusTitle(status: JobRunStatus): string {
case "STARTED":
return "In progress";
case "QUEUED":
case "WAITING_TO_EXECUTE":
return "Queued";
case "EXECUTING":
return "Executing";
case "WAITING_TO_CONTINUE":
return "Waiting";
case "FAILURE":
return "Failed";
case "TIMED_OUT":
@@ -130,9 +101,12 @@ export function runStatusClassNameColor(status: JobRunStatus): string {
case "PENDING":
return "text-slate-500";
case "STARTED":
case "EXECUTING":
case "WAITING_TO_CONTINUE":
case "WAITING_TO_EXECUTE":
return "text-blue-500";
case "QUEUED":
return "text-amber-300";
return "text-slate-500";
case "FAILURE":
case "UNRESOLVED_AUTH":
case "INVALID_PAYLOAD":
@@ -147,5 +121,9 @@ export function runStatusClassNameColor(status: JobRunStatus): string {
return "text-blue-500";
case "CANCELED":
return "text-slate-500";
default: {
const _exhaustiveCheck: never = status;
throw new Error(`Non-exhaustive match for value: ${status}`);
}
}
}
+24 -7
View File
@@ -1,7 +1,7 @@
import { StopIcon } from "@heroicons/react/24/outline";
import { CheckIcon } from "@heroicons/react/24/solid";
import { JobRunStatus, RuntimeEnvironmentType } from "@trigger.dev/database";
import { formatDuration } from "~/utils";
import { formatDuration, formatDurationMilliseconds } from "~/utils";
import { EnvironmentLabel } from "../environments/EnvironmentLabel";
import { DateTime } from "../primitives/DateTime";
import { Paragraph } from "../primitives/Paragraph";
@@ -20,14 +20,16 @@ import { RunStatus } from "./RunStatuses";
type RunTableItem = {
id: string;
number: number;
number: number | null;
environment: {
type: RuntimeEnvironmentType;
};
job: { title: string; slug: string };
status: JobRunStatus;
startedAt: Date | null;
completedAt: Date | null;
createdAt: Date | null;
executionDuration: number;
version: string;
isTest: boolean;
};
@@ -35,6 +37,7 @@ type RunTableItem = {
type RunsTableProps = {
total: number;
hasFilters: boolean;
showJob?: boolean;
runs: RunTableItem[];
isLoading?: boolean;
runsParentPath: string;
@@ -45,6 +48,7 @@ export function RunsTable({
hasFilters,
runs,
isLoading = false,
showJob = false,
runsParentPath,
}: RunsTableProps) {
return (
@@ -52,10 +56,12 @@ export function RunsTable({
<TableHeader>
<TableRow>
<TableHeaderCell>Run</TableHeaderCell>
{showJob && <TableHeaderCell>Job</TableHeaderCell>}
<TableHeaderCell>Env</TableHeaderCell>
<TableHeaderCell>Status</TableHeaderCell>
<TableHeaderCell>Started</TableHeaderCell>
<TableHeaderCell>Duration</TableHeaderCell>
<TableHeaderCell>Exec Time</TableHeaderCell>
<TableHeaderCell>Test</TableHeaderCell>
<TableHeaderCell>Version</TableHeaderCell>
<TableHeaderCell>Created at</TableHeaderCell>
@@ -66,19 +72,24 @@ export function RunsTable({
</TableHeader>
<TableBody>
{total === 0 && !hasFilters ? (
<TableBlankRow colSpan={8}>
<NoRuns title="No Runs found for this Job" />
<TableBlankRow colSpan={showJob ? 10 : 9}>
<NoRuns title="No Runs found" />
</TableBlankRow>
) : runs.length === 0 ? (
<TableBlankRow colSpan={8}>
<TableBlankRow colSpan={showJob ? 10 : 9}>
<NoRuns title="No Runs match your filters" />
</TableBlankRow>
) : (
runs.map((run) => {
const path = `${runsParentPath}/${run.id}/trigger`;
const path = showJob
? `${runsParentPath}/jobs/${run.job.slug}/runs/${run.id}/trigger`
: `${runsParentPath}/${run.id}/trigger`;
return (
<TableRow key={run.id}>
<TableCell to={path}>#{run.number}</TableCell>
<TableCell to={path}>
{typeof run.number === "number" ? `#${run.number}` : "-"}
</TableCell>
{showJob && <TableCell to={path}>{run.job.slug}</TableCell>}
<TableCell to={path}>
<EnvironmentLabel environment={run.environment} />
</TableCell>
@@ -93,6 +104,11 @@ export function RunsTable({
style: "short",
})}
</TableCell>
<TableCell to={path}>
{formatDurationMilliseconds(run.executionDuration, {
style: "short",
})}
</TableCell>
<TableCell to={path}>
{run.isTest ? (
<CheckIcon className="h-4 w-4 text-slate-400" />
@@ -121,6 +137,7 @@ export function RunsTable({
</Table>
);
}
function NoRuns({ title }: { title: string }) {
return (
<div className="flex items-center justify-center">
@@ -0,0 +1,126 @@
import { StopIcon } from "@heroicons/react/24/outline";
import { CheckIcon } from "@heroicons/react/24/solid";
import { RuntimeEnvironmentType } from "@trigger.dev/database";
import { formatDuration } from "~/utils";
import { EnvironmentLabel } from "../environments/EnvironmentLabel";
import { DateTime } from "../primitives/DateTime";
import { Paragraph } from "../primitives/Paragraph";
import { Spinner } from "../primitives/Spinner";
import {
Table,
TableBlankRow,
TableBody,
TableCell,
TableHeader,
TableHeaderCell,
TableRow,
} from "../primitives/Table";
import { RunStatus } from "./RunStatuses";
type RunTableItem = {
id: string;
number: number;
environment: {
type: RuntimeEnvironmentType;
};
error: string | null;
createdAt: Date | null;
deliveredAt: Date | null;
verified: boolean;
};
type RunsTableProps = {
total: number;
hasFilters: boolean;
runs: RunTableItem[];
isLoading?: boolean;
runsParentPath: string;
};
export function WebhookDeliveryRunsTable({
total,
hasFilters,
runs,
isLoading = false,
runsParentPath,
}: RunsTableProps) {
return (
<Table>
<TableHeader>
<TableRow>
<TableHeaderCell>Run</TableHeaderCell>
<TableHeaderCell>Env</TableHeaderCell>
<TableHeaderCell>Status</TableHeaderCell>
<TableHeaderCell>Last Error</TableHeaderCell>
<TableHeaderCell>Started</TableHeaderCell>
<TableHeaderCell>Duration</TableHeaderCell>
<TableHeaderCell>Verified</TableHeaderCell>
<TableHeaderCell>Created at</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{total === 0 && !hasFilters ? (
<TableBlankRow colSpan={8}>
<NoRuns title="No Runs found for this Job" />
</TableBlankRow>
) : runs.length === 0 ? (
<TableBlankRow colSpan={8}>
<NoRuns title="No Runs match your filters" />
</TableBlankRow>
) : (
runs.map((run) => {
return (
<TableRow key={run.id}>
<TableCell>#{run.number}</TableCell>
<TableCell>
<EnvironmentLabel environment={run.environment} />
</TableCell>
<TableCell>
<RunStatus
status={
!run.deliveredAt
? "STARTED"
: run.error || !run.verified
? "FAILURE"
: "SUCCESS"
}
/>
</TableCell>
<TableCell>{run.error?.slice(0, 30) ?? ""}</TableCell>
<TableCell>{run.createdAt ? <DateTime date={run.createdAt} /> : ""}</TableCell>
<TableCell>
{formatDuration(run.createdAt, run.deliveredAt, {
style: "short",
})}
</TableCell>
<TableCell>
{run.verified ? (
<CheckIcon className="h-4 w-4 text-slate-400" />
) : (
<StopIcon className="h-4 w-4 text-slate-850" />
)}
</TableCell>
<TableCell>{run.createdAt ? <DateTime date={run.createdAt} /> : ""}</TableCell>
</TableRow>
);
})
)}
{isLoading && (
<TableBlankRow
colSpan={8}
className="absolute left-0 top-0 flex h-full w-full items-center justify-center gap-2 bg-slate-900/90"
>
<Spinner /> <span className="text-dimmed">Loading</span>
</TableBlankRow>
)}
</TableBody>
</Table>
);
}
function NoRuns({ title }: { title: string }) {
return (
<div className="flex items-center justify-center">
<Paragraph className="w-auto">{title}</Paragraph>
</div>
);
}
@@ -275,6 +275,31 @@ function ButtonList({ primary }: { primary: string }) {
</div>
</div>
</div>
<Header1 className="mb-2 mt-8">Extra Large buttons</Header1>
<div className="grid grid-cols-1 gap-8 border-b border-slate-700 pb-8">
<div className="flex flex-col gap-2">
<div className="flex flex-col items-start gap-2">
<Button variant="primary/extra-large" fullWidth>
<NamedIcon name={"github"} className={"mr-1.5 h-5 w-5"} />
Continue with GitHub
</Button>
<Button variant="secondary/extra-large" fullWidth>
<NamedIcon
name={"envelope"}
className={"mr-1.5 h-5 w-5 transition group-hover:text-bright"}
/>
Continue with Email
</Button>
<Button variant="danger/extra-large" fullWidth>
<NamedIcon
name={"trash-can"}
className={"mr-1.5 h-5 w-5 text-bright transition group-hover:text-bright"}
/>
This is a delete button
</Button>
</div>
</div>
</div>
<Header1 className="mb-2 mt-8">Menu items</Header1>
<div className="grid grid-cols-1">
<div className="flex flex-col items-start gap-1 rounded border border-slate-800 bg-slate-850 p-1">
+1 -1
View File
@@ -8,4 +8,4 @@ export const EXECUTE_JOB_RETRY_LIMIT = 10;
export const MAX_RUN_YIELDED_EXECUTIONS = 100;
export const RUN_CHUNK_EXECUTION_BUFFER = 350;
export const MAX_RUN_CHUNK_EXECUTION_LIMIT = 120000; // 2 minutes
export const RESPONSE_TIMEOUT_STATUS_CODES = [408, 504];
export const VERCEL_RESPONSE_TIMEOUT_STATUS_CODES = [408, 504];
+1 -1
View File
@@ -48,7 +48,7 @@ export async function $transaction<R>(
return await (prisma as PrismaClient).$transaction(fn, options);
} catch (error) {
if (isPrismaKnownError(error)) {
logger.debug("prisma.$transaction error", {
logger.error("prisma.$transaction error", {
code: error.code,
meta: error.meta,
stack: error.stack,
+4
View File
@@ -16,6 +16,8 @@ import {
OperatingSystemPlatform,
} from "./components/primitives/OperatingSystemProvider";
import { env } from "./env.server";
import { getSharedSqsEventConsumer } from "./services/events/sqsEventConsumer";
import { singleton } from "./utils/singleton";
const ABORT_DELAY = 30000;
@@ -190,3 +192,5 @@ function logError(error: unknown, request?: Request) {
}
console.error(error);
}
const sqsEventConsumer = singleton("sqsEventConsumer", getSharedSqsEventConsumer);
+22 -8
View File
@@ -18,14 +18,7 @@ const EnvironmentSchema = z.object({
REMIX_APP_PORT: z.string().optional(),
LOGIN_ORIGIN: z.string().default("http://localhost:3030"),
APP_ORIGIN: z.string().default("http://localhost:3030"),
APP_ENV: z
.union([
z.literal("development"),
z.literal("production"),
z.literal("test"),
z.literal("staging"),
])
.default(process.env.NODE_ENV),
APP_ENV: z.string().default(process.env.NODE_ENV),
SECRET_STORE: SecretStoreOptionsSchema.default("DATABASE"),
POSTHOG_PROJECT_KEY: z.string().optional(),
TELEMETRY_TRIGGER_API_KEY: z.string().optional(),
@@ -49,7 +42,28 @@ const EnvironmentSchema = z.object({
TASK_OPERATION_WORKER_CONCURRENCY: z.coerce.number().int().default(10),
TASK_OPERATION_WORKER_POLL_INTERVAL: z.coerce.number().int().default(1000),
GRACEFUL_SHUTDOWN_TIMEOUT: z.coerce.number().int().default(60000),
/** Optional. Only used if you use the apps/proxy */
AWS_SQS_REGION: z.string().optional(),
/** Optional. Only used if you use the apps/proxy */
AWS_SQS_ACCESS_KEY_ID: z.string().optional(),
/** Optional. Only used if you use the apps/proxy */
AWS_SQS_SECRET_ACCESS_KEY: z.string().optional(),
/** Optional. Only used if you use the apps/proxy */
AWS_SQS_QUEUE_URL: z.string().optional(),
AWS_SQS_BATCH_SIZE: z.coerce.number().int().optional().default(10),
DISABLE_SSE: z.string().optional(),
// Redis options
REDIS_HOST: z.string().optional(),
REDIS_READER_HOST: z.string().optional(),
REDIS_READER_PORT: z.coerce.number().optional(),
REDIS_PORT: z.coerce.number().optional(),
REDIS_USERNAME: z.string().optional(),
REDIS_PASSWORD: z.string().optional(),
REDIS_TLS_DISABLED: z.string().optional(),
DEFAULT_ORG_EXECUTION_CONCURRENCY_LIMIT: z.coerce.number().int().default(10),
DEFAULT_DEV_ENV_EXECUTION_ATTEMPTS: z.coerce.number().int().positive().default(1),
});
export type Environment = z.infer<typeof EnvironmentSchema>;
+7 -2
View File
@@ -3,6 +3,7 @@ import { useEffect, useState } from "react";
type EventSourceOptions = {
init?: EventSourceInit;
event?: string;
disabled?: boolean;
};
/**
@@ -13,11 +14,15 @@ type EventSourceOptions = {
*/
export function useEventSource(
url: string | URL,
{ event = "message", init }: EventSourceOptions = {}
{ event = "message", init, disabled }: EventSourceOptions = {}
) {
const [data, setData] = useState<string | null>(null);
useEffect(() => {
if (disabled) {
return;
}
const eventSource = new EventSource(url, init);
eventSource.addEventListener(event ?? "message", handler);
@@ -32,7 +37,7 @@ export function useEventSource(
eventSource.removeEventListener(event ?? "message", handler);
eventSource.close();
};
}, [url, event, init]);
}, [url, event, init, disabled]);
return data;
}
+23 -3
View File
@@ -1,4 +1,4 @@
import { RESPONSE_TIMEOUT_STATUS_CODES } from "~/consts";
import { VERCEL_RESPONSE_TIMEOUT_STATUS_CODES } from "~/consts";
import { prisma } from "~/db.server";
import { Prettify } from "~/lib.es5";
@@ -20,13 +20,33 @@ export async function findEndpoint(id: string) {
});
}
export function detectResponseIsTimeout(response?: Response) {
export function detectResponseIsTimeout(rawBody: string, response?: Response) {
if (!response) {
return false;
}
return (
RESPONSE_TIMEOUT_STATUS_CODES.includes(response.status) ||
isResponseVercelTimeout(response) ||
isResponseDenoDeployTimeout(rawBody, response) ||
isResponseCloudflareTimeout(rawBody, response)
);
}
function isResponseCloudflareTimeout(rawBody: string, response: Response) {
return (
response.status === 503 &&
rawBody.includes("Worker exceeded resource limits") &&
typeof response.headers.get("cf-ray") === "string"
);
}
function isResponseVercelTimeout(response: Response) {
return (
VERCEL_RESPONSE_TIMEOUT_STATUS_CODES.includes(response.status) ||
response.headers.get("x-vercel-error") === "FUNCTION_INVOCATION_TIMEOUT"
);
}
function isResponseDenoDeployTimeout(rawBody: string, response: Response) {
return response.status === 502 && rawBody.includes("TIME_LIMIT");
}
+56
View File
@@ -0,0 +1,56 @@
import type { JobRun, JobRunStatus } from "@trigger.dev/database";
const COMPLETED_STATUSES: Array<JobRun["status"]> = [
"CANCELED",
"ABORTED",
"SUCCESS",
"TIMED_OUT",
"INVALID_PAYLOAD",
"FAILURE",
"UNRESOLVED_AUTH",
];
export function isRunCompleted(status: JobRunStatus) {
return COMPLETED_STATUSES.includes(status);
}
export type RunBasicStatus = "WAITING" | "PENDING" | "RUNNING" | "COMPLETED" | "FAILED";
export function runBasicStatus(status: JobRunStatus): RunBasicStatus {
switch (status) {
case "WAITING_ON_CONNECTIONS":
case "QUEUED":
case "PREPROCESSING":
case "PENDING":
return "PENDING";
case "STARTED":
case "EXECUTING":
case "WAITING_TO_CONTINUE":
case "WAITING_TO_EXECUTE":
return "RUNNING";
case "FAILURE":
case "TIMED_OUT":
case "UNRESOLVED_AUTH":
case "CANCELED":
case "ABORTED":
case "INVALID_PAYLOAD":
return "FAILED";
case "SUCCESS":
return "COMPLETED";
default: {
const _exhaustiveCheck: never = status;
throw new Error(`Non-exhaustive match for value: ${status}`);
}
}
}
export function runOriginalStatus(status: JobRunStatus) {
switch (status) {
case "EXECUTING":
case "WAITING_TO_CONTINUE":
case "WAITING_TO_EXECUTE":
return "STARTED";
default:
return status;
}
}
@@ -1,47 +0,0 @@
import { JobRun } from "@trigger.dev/database";
import { PrismaClientOrTransaction } from "~/db.server";
import { executionWorker } from "~/services/worker.server";
export async function dequeueRunExecutionV2(run: JobRun, tx: PrismaClientOrTransaction) {
return await executionWorker.dequeue(`job_run:${run.id}`, {
tx,
});
}
export type EnqueueRunExecutionV3Options = {
runAt?: Date;
skipRetrying?: boolean;
};
export async function enqueueRunExecutionV3(
run: JobRun,
tx: PrismaClientOrTransaction,
options: EnqueueRunExecutionV3Options = {}
) {
const reason = run.status === "PREPROCESSING" ? "PREPROCESS" : "EXECUTE_JOB";
return await executionWorker.enqueue(
"performRunExecutionV3",
{
id: run.id,
reason: reason,
},
{
tx,
runAt: options.runAt,
queueName: `job_run:${run.id}`,
jobKey: `job_run:${reason}:${run.id}`,
maxAttempts: options.skipRetrying ? 1 : undefined,
}
);
}
export async function dequeueRunExecutionV3(run: JobRun, tx: PrismaClientOrTransaction) {
await executionWorker.dequeue(`job_run:EXECUTE_JOB:${run.id}`, {
tx,
});
await executionWorker.dequeue(`job_run:PREPROCESS:${run.id}`, {
tx,
});
}
@@ -21,7 +21,8 @@ export async function createOrganization(
title,
userId,
projectName,
}: Pick<Organization, "title"> & {
companySize,
}: Pick<Organization, "title" | "companySize"> & {
userId: User["id"];
projectName: string;
},
@@ -47,6 +48,7 @@ export async function createOrganization(
title,
userId,
projectName,
companySize,
},
attemptCount + 1
);
@@ -56,6 +58,7 @@ export async function createOrganization(
data: {
title,
slug: uniqueOrgSlug,
companySize,
members: {
create: {
userId: userId,
+7 -4
View File
@@ -1,7 +1,10 @@
import type { JobRun, Task, TaskAttempt } from "@trigger.dev/database";
import { CachedTask, ServerTask } from "@trigger.dev/core";
export type TaskWithAttempts = Task & { attempts: TaskAttempt[]; run: JobRun };
export type TaskWithAttempts = Task & {
attempts: TaskAttempt[];
run: { forceYieldImmediately: boolean };
};
export function taskWithAttemptsToServerTask(task: TaskWithAttempts): ServerTask {
return {
@@ -15,7 +18,7 @@ export function taskWithAttemptsToServerTask(task: TaskWithAttempts): ServerTask
status: task.status,
description: task.description,
params: task.params as any,
output: task.output as any,
output: task.outputIsUndefined ? undefined : (task.output as any),
context: task.context as any,
properties: task.properties as any,
style: task.style as any,
@@ -32,7 +35,7 @@ export function taskWithAttemptsToServerTask(task: TaskWithAttempts): ServerTask
export type TaskForCaching = Pick<
Task,
"id" | "status" | "idempotencyKey" | "noop" | "output" | "parentId"
"id" | "status" | "idempotencyKey" | "noop" | "output" | "parentId" | "outputIsUndefined"
>;
export function prepareTasksForCaching(
@@ -105,7 +108,7 @@ function prepareTaskForCaching(task: TaskForCaching): CachedTask {
status: task.status,
idempotencyKey: task.idempotencyKey,
noop: task.noop,
output: task.output as any,
output: task.outputIsUndefined ? undefined : (task.output as any),
parentId: task.parentId,
};
}
+3 -1
View File
@@ -162,12 +162,14 @@ export function updateUser({
name,
email,
marketingEmails,
referralSource,
}: Pick<User, "id" | "name" | "email"> & {
marketingEmails?: boolean;
referralSource?: string;
}) {
return prisma.user.update({
where: { id },
data: { name, email, marketingEmails, confirmedBasicDetails: true },
data: { name, email, marketingEmails, referralSource, confirmedBasicDetails: true },
});
}
+60 -5
View File
@@ -14,7 +14,8 @@ import { run as graphileRun, parseCronItems } from "graphile-worker";
import omit from "lodash.omit";
import { z } from "zod";
import { PrismaClient, PrismaClientOrTransaction } from "~/db.server";
import { workerLogger as logger } from "~/services/logger.server";
import { PgListenService } from "~/services/db/pgListen.server";
import { workerLogger as logger, trace } from "~/services/logger.server";
export interface MessageCatalogSchema {
[key: string]: z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion<any, any>;
@@ -93,6 +94,11 @@ export type ZodWorkerCleanupOptions = {
type ZodWorkerReporter = (event: string, properties: Record<string, any>) => Promise<void>;
export interface ZodWorkerRateLimiter {
forbiddenFlags(): Promise<string[]>;
wrapTask(t: Task, rescheduler: Task): Task;
}
export type ZodWorkerOptions<TMessageCatalog extends MessageCatalogSchema> = {
name: string;
runnerOptions: RunnerOptions;
@@ -103,6 +109,7 @@ export type ZodWorkerOptions<TMessageCatalog extends MessageCatalogSchema> = {
cleanup?: ZodWorkerCleanupOptions;
reporter?: ZodWorkerReporter;
shutdownTimeoutInMs?: number;
rateLimiter?: ZodWorkerRateLimiter;
};
export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
@@ -115,6 +122,7 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
#runner?: GraphileRunner;
#cleanup: ZodWorkerCleanupOptions | undefined;
#reporter?: ZodWorkerReporter;
#rateLimiter?: ZodWorkerRateLimiter;
#shutdownTimeoutInMs?: number;
#shuttingDown = false;
@@ -127,6 +135,7 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
this.#recurringTasks = options.recurringTasks;
this.#cleanup = options.cleanup;
this.#reporter = options.reporter;
this.#rateLimiter = options.rateLimiter;
this.#shutdownTimeoutInMs = options.shutdownTimeoutInMs ?? 60000; // default to 60 seconds
}
@@ -150,6 +159,7 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
noHandleSignals: true,
taskList: this.#createTaskListFromTasks(),
parsedCronItems,
forbiddenFlags: this.#rateLimiter?.forbiddenFlags.bind(this.#rateLimiter),
});
if (!this.#runner) {
@@ -166,6 +176,21 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
this.#runner?.events.on("pool:listen:success", async ({ workerPool, client }) => {
this.#logDebug("pool:listen:success");
// hijack client instance to listen and react to incoming NOTIFY events
const pgListen = new PgListenService(client, this.#name, logger);
await pgListen.on("trigger:graphile:migrate", async ({ latestMigration }) => {
this.#logDebug("Detected incoming migration", { latestMigration });
if (latestMigration > 10) {
// already migrated past v0.14 - nothing to do
return;
}
// simulate SIGTERM to trigger graceful shutdown
this._handleSignal("SIGTERM");
});
});
this.#runner?.events.on("pool:listen:error", ({ error }) => {
@@ -270,7 +295,7 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
spec,
});
const job = await this.#addJob(
const { job, durationInMs } = await this.#addJob(
identifier as string,
payload,
spec,
@@ -282,6 +307,7 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
payload,
spec,
job,
durationInMs,
});
return job;
@@ -304,6 +330,8 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
spec: TaskSpec,
tx: PrismaClientOrTransaction
) {
const now = performance.now();
const results = await tx.$queryRawUnsafe(
`SELECT * FROM ${this.graphileWorkerSchema}.add_job(
identifier => $1::text,
@@ -327,6 +355,8 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
spec.jobKeyMode || null
);
const durationInMs = performance.now() - now;
const rows = AddJobResultsSchema.safeParse(results);
if (!rows.success) {
@@ -337,7 +367,7 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
const job = rows.data[0];
return job as GraphileJob;
return { job: job as GraphileJob, durationInMs: Math.floor(durationInMs) };
}
async #removeJob(jobKey: string, tx: PrismaClientOrTransaction) {
@@ -374,7 +404,11 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
return this.#handleMessage(key, payload, helpers);
};
taskList[key] = task;
if (this.#rateLimiter) {
taskList[key] = this.#rateLimiter.wrapTask(task, this.#rescheduleTask.bind(this));
} else {
taskList[key] = task;
}
}
for (const [key] of Object.entries(this.#recurringTasks ?? {})) {
@@ -404,6 +438,19 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
return taskList;
}
async #rescheduleTask(payload: unknown, helpers: JobHelpers) {
this.#logDebug("Rescheduling task", { payload, job: helpers.job });
await this.enqueue(helpers.job.task_identifier, payload, {
runAt: helpers.job.run_at,
queueName: helpers.job.queue_name ?? undefined,
priority: helpers.job.priority,
jobKey: helpers.job.key ?? undefined,
flags: Object.keys(helpers.job.flags ?? []),
maxAttempts: helpers.job.max_attempts,
});
}
#createCronItemsFromRecurringTasks() {
const cronItems: CronItem[] = [];
@@ -471,7 +518,15 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
throw new Error(`No task for message type: ${String(typeName)}`);
}
await task.handler(payload, job);
await trace(
{
worker_job: job,
worker_name: this.#name,
},
async () => {
await task.handler(payload, job);
}
);
}
async #handleRecurringTask(
@@ -1,11 +1,9 @@
import { TriggerHttpEndpoint } from "@trigger.dev/database";
import { z } from "zod";
import { PrismaClient, prisma } from "~/db.server";
import { Project } from "~/models/project.server";
import { User } from "~/models/user.server";
import { sortEnvironments } from "~/services/environmentSort.server";
import { httpEndpointUrl } from "~/services/httpendpoint/HandleHttpEndpointService";
import { getSecretStore } from "~/services/secrets/secretStore.server";
import { projectPath } from "~/utils/pathBuilder";
export class HttpEndpointPresenter {
#prismaClient: PrismaClient;
@@ -17,10 +15,12 @@ export class HttpEndpointPresenter {
public async call({
userId,
projectSlug,
organizationSlug,
httpEndpointKey,
}: {
userId: string;
projectSlug: string;
organizationSlug: string;
httpEndpointKey: string;
}) {
const httpEndpoint = await this.#prismaClient.triggerHttpEndpoint.findFirst({
@@ -57,6 +57,12 @@ export class HttpEndpointPresenter {
},
},
},
webhook: {
select: {
id: true,
key: true,
},
},
},
where: {
key: httpEndpointKey,
@@ -138,11 +144,15 @@ export class HttpEndpointPresenter {
?.webhookUrl,
}));
const projectRootPath = projectPath({ slug: organizationSlug }, { slug: projectSlug });
return {
httpEndpoint: {
...httpEndpoint,
httpEndpointEnvironments,
webhookLink: httpEndpoint.webhook
? `${projectRootPath}/triggers/webhooks/${httpEndpoint.webhook.id}`
: undefined,
},
environments: relevantEnvironments,
unconfiguredEnvironments: relevantEnvironments.filter(
@@ -3,7 +3,7 @@ import { PrismaClient, prisma } from "~/db.server";
import { env } from "~/env.server";
import { Organization } from "~/models/organization.server";
import { Project } from "~/models/project.server";
import { Api, apisList } from "~/services/externalApis/apis";
import { Api, apisList } from "~/services/externalApis/apis.server";
import { integrationCatalog } from "~/services/externalApis/integrationCatalog.server";
import { Integration, OAuthClientSchema } from "~/services/externalApis/types";
import { getSecretStore } from "~/services/secrets/secretStore.server";
@@ -43,6 +43,13 @@ export class JobPresenter {
eventSpecification: true,
properties: true,
status: true,
concurrencyLimit: true,
concurrencyLimitGroup: {
select: {
name: true,
concurrencyLimit: true,
},
},
runs: {
select: {
createdAt: true,
@@ -186,6 +193,8 @@ export class JobPresenter {
enabled: alias.version.status === "ACTIVE",
lastRun: alias.version.runs.at(0)?.createdAt,
version: alias.version.version,
concurrencyLimit: alias.version.concurrencyLimit,
concurrencyLimitGroup: alias.version.concurrencyLimitGroup,
}));
const projectRootPath = projectPath({ slug: organizationSlug }, { slug: projectSlug });
@@ -1,19 +1,20 @@
import { PrismaClient, prisma } from "~/db.server";
import { z } from "zod";
import { PrismaClient, prisma } from "~/db.server";
import { DirectionSchema } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam._index/route";
export type Direction = z.infer<typeof DirectionSchema>;
type RunListOptions = {
userId: string;
jobSlug: string;
jobSlug?: string;
organizationSlug: string;
projectSlug: string;
direction?: Direction;
cursor?: string;
pageSize?: number;
};
const PAGE_SIZE = 20;
const DEFAULT_PAGE_SIZE = 20;
export type RunList = Awaited<ReturnType<RunListPresenter["call"]>>;
@@ -31,9 +32,45 @@ export class RunListPresenter {
projectSlug,
direction = "forward",
cursor,
pageSize = DEFAULT_PAGE_SIZE,
}: RunListOptions) {
const directionMultiplier = direction === "forward" ? 1 : -1;
// Find the organization that the user is a member of
const organization = await this.#prismaClient.organization.findFirstOrThrow({
where: {
slug: organizationSlug,
members: { some: { userId } },
},
});
// Find the project scoped to the organization
const project = await this.#prismaClient.project.findFirstOrThrow({
where: {
slug: projectSlug,
organizationId: organization.id,
},
});
// Find all runtimeEnvironments that the user has access to
const environments = await this.#prismaClient.runtimeEnvironment.findMany({
where: {
projectId: project.id,
OR: [
{ orgMember: { userId } },
{ orgMemberId: null },
]
}
});
const job = jobSlug ? await this.#prismaClient.job.findFirstOrThrow({
where: {
slug: jobSlug,
projectId: project.id,
},
}) : undefined;
const runs = await this.#prismaClient.jobRun.findMany({
select: {
id: true,
@@ -41,6 +78,7 @@ export class RunListPresenter {
startedAt: true,
completedAt: true,
createdAt: true,
executionDuration: true,
isTest: true,
status: true,
environment: {
@@ -59,41 +97,34 @@ export class RunListPresenter {
version: true,
},
},
job: {
select: {
slug: true,
title: true,
},
},
},
where: {
job: {
slug: jobSlug,
},
project: {
slug: projectSlug,
},
organization: { slug: organizationSlug, members: { some: { userId } } },
environment: {
OR: [
{
orgMember: null,
},
{
orgMember: {
userId,
},
},
],
jobId: job?.id,
projectId: project.id,
organizationId: organization.id,
environmentId: {
in: environments.map((environment) => environment.id),
},
},
orderBy: [{ id: "desc" }],
//take an extra page to tell if there are more
take: directionMultiplier * (PAGE_SIZE + 1),
//take an extra record to tell if there are more
take: directionMultiplier * (pageSize + 1),
//skip the cursor if there is one
skip: cursor ? 1 : 0,
cursor: cursor
? {
id: cursor,
}
id: cursor,
}
: undefined,
});
const hasMore = runs.length > PAGE_SIZE;
const hasMore = runs.length > pageSize;
//get cursors for next and previous pages
let next: string | undefined;
@@ -102,19 +133,21 @@ export class RunListPresenter {
case "forward":
previous = cursor ? runs.at(0)?.id : undefined;
if (hasMore) {
next = runs[PAGE_SIZE - 1]?.id;
next = runs[pageSize - 1]?.id;
}
break;
case "backward":
if (hasMore) {
previous = runs[1]?.id;
next = runs[pageSize]?.id;
} else {
next = runs[pageSize - 1]?.id;
}
next = runs[PAGE_SIZE - 1]?.id;
break;
}
const runsToReturn =
direction === "backward" && hasMore ? runs.slice(1, PAGE_SIZE + 1) : runs.slice(0, PAGE_SIZE);
direction === "backward" && hasMore ? runs.slice(1, pageSize + 1) : runs.slice(0, pageSize);
return {
runs: runsToReturn.map((run) => ({
@@ -123,6 +156,7 @@ export class RunListPresenter {
startedAt: run.startedAt,
completedAt: run.completedAt,
createdAt: run.createdAt,
executionDuration: run.executionDuration,
isTest: run.isTest,
status: run.status,
version: run.version?.version ?? "unknown",
@@ -131,6 +165,7 @@ export class RunListPresenter {
slug: run.environment.slug,
userId: run.environment.orgMember?.userId,
},
job: run.job,
})),
pagination: {
next,
@@ -5,6 +5,7 @@ import {
StyleSchema,
} from "@trigger.dev/core";
import { PrismaClient, prisma } from "~/db.server";
import { isRunCompleted, runBasicStatus } from "~/models/jobRun.server";
import { mergeProperties } from "~/utils/mergeProperties.server";
import { taskListToTree } from "~/utils/taskListToTree";
@@ -67,6 +68,8 @@ export class RunPresenter {
id: run.id,
number: run.number,
status: run.status,
basicStatus: runBasicStatus(run.status),
isFinished: isRunCompleted(run.status),
startedAt: run.startedAt,
completedAt: run.completedAt,
isTest: run.isTest,
@@ -82,6 +85,8 @@ export class RunPresenter {
runConnections: run.runConnections,
missingConnections: run.missingConnections,
error: runError,
executionDuration: run.executionDuration,
executionCount: run.executionCount,
};
}
@@ -112,6 +117,8 @@ export class RunPresenter {
isTest: true,
properties: true,
output: true,
executionCount: true,
executionDuration: true,
version: {
select: {
version: true,
@@ -1,5 +1,4 @@
import { RedactSchema } from "@trigger.dev/core";
import { StyleSchema } from "@trigger.dev/core";
import { RedactSchema, StyleSchema } from "@trigger.dev/core";
import { PrismaClient, prisma } from "~/db.server";
import { mergeProperties } from "~/utils/mergeProperties.server";
import { Redactor } from "~/utils/redactor";
@@ -58,6 +57,7 @@ export class TaskDetailsPresenter {
outputProperties: true,
params: true,
output: true,
outputIsUndefined: true,
error: true,
startedAt: true,
completedAt: true,
@@ -89,9 +89,11 @@ export class TaskDetailsPresenter {
return {
...task,
redact: undefined,
output: task.output
? JSON.stringify(this.#stringifyOutputWithRedactions(task.output, task.redact), null, 2)
: undefined,
output: JSON.stringify(
this.#stringifyOutputWithRedactions(task.output, task.redact),
null,
2
),
connection: task.runConnection,
params: task.params as Record<string, any>,
properties: mergeProperties(task.properties, task.outputProperties),
@@ -101,7 +103,7 @@ export class TaskDetailsPresenter {
#stringifyOutputWithRedactions(output: any, redact: unknown): any {
if (!output) {
return;
return output;
}
const parsedRedact = RedactSchema.safeParse(redact);
@@ -0,0 +1,117 @@
import { PrismaClient, prisma } from "~/db.server";
import { Direction } from "./RunListPresenter.server";
type RunListOptions = {
userId: string;
webhookId: string;
direction?: Direction;
cursor?: string;
};
const PAGE_SIZE = 20;
export type WebhookDeliveryList = Awaited<ReturnType<WebhookDeliveryListPresenter["call"]>>;
export class WebhookDeliveryListPresenter {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call({ userId, webhookId, direction = "forward", cursor }: RunListOptions) {
const directionMultiplier = direction === "forward" ? 1 : -1;
const runs = await this.#prismaClient.webhookRequestDelivery.findMany({
select: {
id: true,
number: true,
createdAt: true,
deliveredAt: true,
verified: true,
error: true,
environment: {
select: {
type: true,
slug: true,
orgMember: {
select: {
userId: true,
},
},
},
},
},
where: {
webhookId,
environment: {
OR: [
{
orgMember: null,
},
{
orgMember: {
userId,
},
},
],
},
},
orderBy: [{ id: "desc" }],
//take an extra page to tell if there are more
take: directionMultiplier * (PAGE_SIZE + 1),
//skip the cursor if there is one
skip: cursor ? 1 : 0,
cursor: cursor
? {
id: cursor,
}
: undefined,
});
const hasMore = runs.length > PAGE_SIZE;
//get cursors for next and previous pages
let next: string | undefined;
let previous: string | undefined;
switch (direction) {
case "forward":
previous = cursor ? runs.at(0)?.id : undefined;
if (hasMore) {
next = runs[PAGE_SIZE - 1]?.id;
}
break;
case "backward":
if (hasMore) {
previous = runs[1]?.id;
next = runs[PAGE_SIZE]?.id;
} else {
next = runs[PAGE_SIZE - 1]?.id;
}
break;
}
const runsToReturn =
direction === "backward" && hasMore ? runs.slice(1, PAGE_SIZE + 1) : runs.slice(0, PAGE_SIZE);
return {
runs: runsToReturn.map((run) => ({
id: run.id,
number: run.number,
createdAt: run.createdAt,
deliveredAt: run.deliveredAt,
verified: run.verified,
error: run.error,
environment: {
type: run.environment.type,
slug: run.environment.slug,
userId: run.environment.orgMember?.userId,
},
})),
pagination: {
next,
previous,
},
};
}
}
@@ -0,0 +1,96 @@
import { User, Webhook } from "@trigger.dev/database";
import { PrismaClient, prisma } from "~/db.server";
import { Organization } from "~/models/organization.server";
import { Project } from "~/models/project.server";
import { Direction } from "./RunListPresenter.server";
import { organizationPath, projectPath } from "~/utils/pathBuilder";
import { WebhookDeliveryListPresenter } from "./WebhookDeliveryListPresenter.server";
export class WebhookDeliveryPresenter {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call({
userId,
projectSlug,
organizationSlug,
webhookId,
direction = "forward",
cursor,
}: {
userId: User["id"];
projectSlug: Project["slug"];
organizationSlug: Organization["slug"];
webhookId: Webhook["id"];
direction?: Direction;
cursor?: string;
}) {
const webhook = await this.#prismaClient.webhook.findUnique({
select: {
id: true,
key: true,
active: true,
integration: {
select: {
id: true,
title: true,
slug: true,
definitionId: true,
setupStatus: true,
definition: {
select: {
icon: true,
},
},
},
},
httpEndpoint: {
select: {
key: true,
},
},
createdAt: true,
updatedAt: true,
params: true,
},
where: {
id: webhookId,
},
});
if (!webhook) {
throw new Error("Webhook source not found");
}
const deliveryListPresenter = new WebhookDeliveryListPresenter(this.#prismaClient);
const orgRootPath = organizationPath({ slug: organizationSlug });
const projectRootPath = projectPath({ slug: organizationSlug }, { slug: projectSlug });
const requestDeliveries = await deliveryListPresenter.call({
userId,
webhookId: webhook.id,
direction,
cursor,
});
return {
webhook: {
id: webhook.id,
key: webhook.key,
active: webhook.active,
integration: webhook.integration,
integrationLink: `${orgRootPath}/integrations/${webhook.integration.slug}`,
httpEndpoint: webhook.httpEndpoint,
httpEndpointLink: `${projectRootPath}/http-endpoints/${webhook.httpEndpoint.key}`,
createdAt: webhook.createdAt,
updatedAt: webhook.updatedAt,
params: webhook.params,
requestDeliveries,
},
};
}
}
@@ -0,0 +1,106 @@
import { User, Webhook } from "@trigger.dev/database";
import { PrismaClient, prisma } from "~/db.server";
import { Organization } from "~/models/organization.server";
import { Project } from "~/models/project.server";
import { Direction, RunListPresenter } from "./RunListPresenter.server";
import { organizationPath, projectPath } from "~/utils/pathBuilder";
export class WebhookSourcePresenter {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call({
userId,
projectSlug,
organizationSlug,
webhookId,
direction = "forward",
cursor,
getDeliveryRuns = false,
}: {
userId: User["id"];
projectSlug: Project["slug"];
organizationSlug: Organization["slug"];
webhookId: Webhook["id"];
direction?: Direction;
cursor?: string;
getDeliveryRuns?: boolean;
}) {
const webhook = await this.#prismaClient.webhook.findUnique({
select: {
id: true,
key: true,
active: true,
integration: {
select: {
id: true,
title: true,
slug: true,
definitionId: true,
setupStatus: true,
definition: {
select: {
icon: true,
},
},
},
},
httpEndpoint: {
select: {
key: true,
},
},
createdAt: true,
updatedAt: true,
params: true,
},
where: {
id: webhookId,
},
});
if (!webhook) {
throw new Error("Webhook source not found");
}
const runListPresenter = new RunListPresenter(this.#prismaClient);
const jobSlug = getDeliveryRuns
? getDeliveryJobSlug(webhook.key)
: getRegistrationJobSlug(webhook.key);
const runList = await runListPresenter.call({
userId,
jobSlug,
organizationSlug,
projectSlug,
direction,
cursor,
});
const orgRootPath = organizationPath({ slug: organizationSlug });
const projectRootPath = projectPath({ slug: organizationSlug }, { slug: projectSlug });
return {
trigger: {
id: webhook.id,
key: webhook.key,
active: webhook.active,
integration: webhook.integration,
integrationLink: `${orgRootPath}/integrations/${webhook.integration.slug}`,
httpEndpoint: webhook.httpEndpoint,
httpEndpointLink: `${projectRootPath}/http-endpoints/${webhook.httpEndpoint.key}`,
createdAt: webhook.createdAt,
updatedAt: webhook.updatedAt,
params: webhook.params,
runList,
},
};
}
}
const getRegistrationJobSlug = (key: string) => `webhook.register.${key}`;
const getDeliveryJobSlug = (key: string) => `webhook.deliver.${key}`;
@@ -0,0 +1,71 @@
import { Organization, User } from "@trigger.dev/database";
import { PrismaClient, prisma } from "~/db.server";
import { Project } from "~/models/project.server";
export class WebhookTriggersPresenter {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call({
userId,
projectSlug,
organizationSlug,
}: {
userId: User["id"];
projectSlug: Project["slug"];
organizationSlug: Organization["slug"];
}) {
const webhooks = await this.#prismaClient.webhook.findMany({
select: {
id: true,
key: true,
active: true,
params: true,
integration: {
select: {
id: true,
title: true,
slug: true,
definitionId: true,
setupStatus: true,
definition: {
select: {
icon: true,
},
},
},
},
webhookEnvironments: {
select: {
id: true,
environment: {
select: {
type: true
}
}
}
},
createdAt: true,
updatedAt: true,
},
where: {
project: {
slug: projectSlug,
organization: {
slug: organizationSlug,
members: {
some: {
userId,
},
},
},
},
},
});
return { webhooks };
}
}
@@ -10,6 +10,8 @@ import {
PageTitleRow,
PageTitle,
PageButtons,
PageInfoRow,
PageInfoGroup,
} from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import { useOrganization } from "~/hooks/useOrganizations";
@@ -38,6 +40,13 @@ export default function Page() {
</LinkButton>
</PageButtons>
</PageTitleRow>
<PageInfoRow>
<PageInfoGroup alignment="right">
<Paragraph variant="extra-small" className="text-slate-600">
UID: {organization.id}
</Paragraph>
</PageInfoGroup>
</PageInfoRow>
</PageHeader>
<PageBody>
<ul className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
@@ -82,7 +82,7 @@ export default function Integrations() {
<PageContainer>
<PageHeader>
<PageTitleRow>
<PageTitle title="Integrations" />
<PageTitle title="Integrations & APIs" />
<PageButtons>
<LinkButton
to={docsPath("/integrations/introduction")}
@@ -113,7 +113,7 @@ export default function Integrations() {
/>
</PageInfoGroup>
</PageInfoRow>
<PageTabs tabs={tabs} />
<PageTabs layoutId="integrations" tabs={tabs} />
</PageHeader>
<PageBody scrollable={true}>
@@ -105,12 +105,14 @@ export default function Page() {
};
}, [selected, clients]);
const isAnyClientFullyConfigured = useMemo(() => {
return clients.some((client) => {
const { DEVELOPMENT, PRODUCTION } = client.endpoints;
return PRODUCTION.state === "configured" && DEVELOPMENT.state === PRODUCTION.state;
});
}, [clients]);
const isAnyClientFullyConfigured = clients.some((client) => {
const { DEVELOPMENT, PRODUCTION, STAGING } = client.endpoints;
return (
PRODUCTION.state === "configured" ||
DEVELOPMENT.state === "configured" ||
(STAGING && STAGING.state === "configured")
);
});
const organization = useOrganization();
const project = useProject();
@@ -14,6 +14,9 @@ import { Help, HelpContent, HelpTrigger } from "~/components/primitives/Help";
import {
PageButtons,
PageHeader,
PageInfoGroup,
PageInfoProperty,
PageInfoRow,
PageTitle,
PageTitleRow,
} from "~/components/primitives/PageHeader";
@@ -38,13 +41,15 @@ import { HttpEndpointParamSchema, docsPath, projectHttpEndpointsPath } from "~/u
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { projectParam, httpEndpointParam } = HttpEndpointParamSchema.parse(params);
const { projectParam, organizationSlug, httpEndpointParam } =
HttpEndpointParamSchema.parse(params);
const presenter = new HttpEndpointPresenter();
try {
const result = await presenter.call({
userId,
projectSlug: projectParam,
organizationSlug,
httpEndpointKey: httpEndpointParam,
});
@@ -98,6 +103,17 @@ export default function Page() {
</LinkButton>
</PageButtons>
</PageTitleRow>
{httpEndpoint.webhook && (
<PageInfoRow>
<PageInfoGroup>
<PageInfoProperty
icon="webhook"
label="Webhook Trigger"
to={httpEndpoint.webhookLink}
/>
</PageInfoGroup>
</PageInfoRow>
)}
</PageHeader>
<PageBody>
<Help defaultOpen={true}>
@@ -1,9 +1,16 @@
import { useLocation } from "@remix-run/react";
import { LinkButton } from "~/components/primitives/Buttons";
import { Direction, RunList } from "~/presenters/RunListPresenter.server";
import { WebhookDeliveryList } from "~/presenters/WebhookDeliveryListPresenter.server";
import { cn } from "~/utils/cn";
export function ListPagination({ list, className }: { list: RunList; className?: string }) {
export function ListPagination({
list,
className,
}: {
list: RunList | WebhookDeliveryList;
className?: string;
}) {
return (
<div className={cn("flex items-center gap-1", className)}>
<PreviousButton cursor={list.pagination.previous} />
@@ -15,31 +22,39 @@ export function ListPagination({ list, className }: { list: RunList; className?:
function NextButton({ cursor }: { cursor?: string }) {
const path = useCursorPath(cursor, "forward");
return path ? (
return (
<LinkButton
to={path}
to={path ?? "#"}
variant={"tertiary/small"}
TrailingIcon="chevron-right"
className="flex items-center"
className={cn(
"flex items-center",
!path && "cursor-default opacity-50 group-hover:bg-transparent group-hover:text-slate-800"
)}
onClick={(e) => !path && e.preventDefault()}
>
Next
</LinkButton>
) : null;
);
}
function PreviousButton({ cursor }: { cursor?: string }) {
const path = useCursorPath(cursor, "backward");
return path ? (
return (
<LinkButton
to={path}
to={path ?? "#"}
variant={"tertiary/small"}
LeadingIcon="chevron-left"
className="flex items-center"
className={cn(
"flex items-center",
!path && "cursor-default opacity-50 group-hover:bg-transparent group-hover:text-slate-800"
)}
onClick={(e) => !path && e.preventDefault()}
>
Prev
</LinkButton>
) : null;
);
}
function useCursorPath(cursor: string | undefined, direction: Direction) {
@@ -72,8 +72,8 @@ export default function Page() {
<div className={cn("grid h-fit gap-4", open ? "grid-cols-2" : "grid-cols-1")}>
<div>
<div className="mb-2 flex items-center justify-end gap-x-2">
<ListPagination list={list} />
<HelpTrigger title="How do I run my Job?" />
<ListPagination list={list} />
</div>
<RunsTable
total={list.runs.length}
@@ -1,6 +1,5 @@
import { Await, useLoaderData } from "@remix-run/react";
import { LoaderFunctionArgs, SerializeFrom, defer } from "@remix-run/server-runtime";
import { Suspense } from "react";
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { UseDataFunctionReturn, typedjson, useTypedLoaderData } from "remix-typedjson";
import { Spinner } from "~/components/primitives/Spinner";
import { TaskDetail } from "~/components/run/TaskDetail";
import { TaskDetailsPresenter } from "~/presenters/TaskDetailsPresenter.server";
@@ -12,26 +11,28 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const { taskParam } = TaskParamsSchema.parse(params);
const presenter = new TaskDetailsPresenter();
const taskPromise = presenter.call({
const task = await presenter.call({
userId,
id: taskParam,
});
return defer({
taskPromise,
return typedjson({
task,
});
};
export type DetailedTask = NonNullable<Awaited<SerializeFrom<typeof loader>["taskPromise"]>>;
export type DetailedTask = NonNullable<UseDataFunctionReturn<typeof loader>["task"]>;
export default function Page() {
const { taskPromise } = useLoaderData<typeof loader>();
const { task } = useTypedLoaderData<typeof loader>();
return (
<Suspense fallback={<Spinner />}>
<Await resolve={taskPromise} errorElement={<p>Error loading task!</p>}>
{(resolvedTask) => resolvedTask && <TaskDetail task={resolvedTask as any} />}
</Await>
</Suspense>
);
if (!task) {
return (
<div className="flex h-full items-center justify-center">
<Spinner />
</div>
);
}
return <TaskDetail task={task} />;
}
@@ -67,6 +67,7 @@ export default function Page() {
const revalidator = useRevalidator();
const events = useEventSource(runStreamingPath(organization, project, job, run), {
event: "message",
disabled: !!run.completedAt,
});
useEffect(() => {
if (events !== null) {
@@ -24,7 +24,7 @@ export default function Page() {
const project = useProject();
return (
<Help defaultOpen>
<Help>
{(open) => (
<div className={cn("grid h-fit gap-4", open ? "grid-cols-2" : "grid-cols-1")}>
<div className="w-full">
@@ -32,7 +32,7 @@ export default function Page() {
<Header2 className="mb-2 flex items-center gap-1">Environments</Header2>
<HelpTrigger title="How do disable a Job?" />
</div>
<JobStatusTable environments={job.environments} />
<JobStatusTable environments={job.environments} displayStyle="long" />
<div className="mt-4 flex w-full items-center justify-end gap-x-3">
{job.status === "ACTIVE" && (
<Paragraph variant="small">
@@ -297,7 +297,9 @@ export default function Page() {
label={<DateTime date={run.created} />}
description={
<>
Run #{run.number}{" "}
{typeof run.number === "number"
? `Run #${run.number}`
: `Run ${run.id.slice(0, 8)}`}
<span className={runStatusClassNameColor(run.status)}>
{runStatusTitle(run.status).toLocaleLowerCase()}
</span>
@@ -154,6 +154,7 @@ export default function Job() {
)}
<PageTabs
layoutId="jobs"
tabs={[
{ label: "Runs", to: jobPath(organization, project, job) },
{ label: "Test", to: jobTestPath(organization, project, job) },
@@ -0,0 +1,88 @@
import { useNavigation } from "@remix-run/react";
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { LinkButton } from "~/components/primitives/Buttons";
import {
PageButtons,
PageDescription,
PageHeader,
PageTitle,
PageTitleRow,
} from "~/components/primitives/PageHeader";
import { RunsTable } from "~/components/runs/RunsTable";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { RunListPresenter } from "~/presenters/RunListPresenter.server";
import { requireUserId } from "~/services/session.server";
import { ProjectParamSchema, docsPath, projectPath } from "~/utils/pathBuilder";
import { ListPagination } from "../_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam._index/ListPagination";
import { RunListSearchSchema } from "../_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam._index/route";
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 searchParams = RunListSearchSchema.parse(s);
const presenter = new RunListPresenter();
const list = await presenter.call({
userId,
projectSlug: projectParam,
organizationSlug,
direction: searchParams.direction,
cursor: searchParams.cursor,
pageSize: 25,
});
return typedjson({
list,
});
};
export default function Page() {
const { list } = useTypedLoaderData<typeof loader>();
const navigation = useNavigation();
const isLoading = navigation.state !== "idle";
const organization = useOrganization();
const project = useProject();
return (
<PageContainer>
<PageHeader>
<PageTitleRow>
<PageTitle title={`${project.name} Runs`} />
<PageButtons>
<LinkButton
LeadingIcon={"docs"}
to={docsPath("documentation/concepts/runs")}
variant="secondary/small"
>
Run documentation
</LinkButton>
</PageButtons>
</PageTitleRow>
<PageDescription>All Job Runs in this project</PageDescription>
</PageHeader>
<PageBody scrollable={false}>
<div className="h-full overflow-y-auto p-4 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700">
<div className="mb-2 flex items-center justify-end gap-x-2">
<ListPagination list={list} />
</div>
<RunsTable
total={list.runs.length}
hasFilters={false}
showJob={true}
runs={list.runs}
isLoading={isLoading}
runsParentPath={projectPath(organization, project)}
/>
<ListPagination list={list} className="mt-2 justify-end" />
</div>
</PageBody>
</PageContainer>
);
}
@@ -0,0 +1,145 @@
import { CheckCircleIcon, XCircleIcon } from "@heroicons/react/24/solid";
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
import { LabelValueStack } from "~/components/primitives/LabelValueStack";
import { NamedIcon } from "~/components/primitives/NamedIcon";
import { Paragraph } from "~/components/primitives/Paragraph";
import {
Table,
TableBlankRow,
TableBody,
TableCell,
TableCellChevron,
TableHeader,
TableHeaderCell,
TableRow,
} from "~/components/primitives/Table";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { WebhookTriggersPresenter } from "~/presenters/WebhookTriggersPresenter.server";
import { requireUser } from "~/services/session.server";
import { cn } from "~/utils/cn";
import { Handle } from "~/utils/handle";
import { ProjectParamSchema, trimTrailingSlash, webhookTriggerPath } from "~/utils/pathBuilder";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const user = await requireUser(request);
const { organizationSlug, projectParam } = ProjectParamSchema.parse(params);
const presenter = new WebhookTriggersPresenter();
const data = await presenter.call({
userId: user.id,
organizationSlug,
projectSlug: projectParam,
});
return typedjson(data);
};
export const handle: Handle = {
breadcrumb: (match) => (
<BreadcrumbLink to={trimTrailingSlash(match.pathname)} title="Webhook Triggers" />
),
};
export default function Integrations() {
const { webhooks } = useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const project = useProject();
return (
<>
<Paragraph variant="small" spacing>
A Webhook Trigger runs a Job when it receives a matching payload at a registered HTTP Endpoint.
</Paragraph>
<Table containerClassName="mt-4">
<TableHeader>
<TableRow>
<TableHeaderCell>Key</TableHeaderCell>
<TableHeaderCell>Integration</TableHeaderCell>
<TableHeaderCell>Properties</TableHeaderCell>
<TableHeaderCell>Environment</TableHeaderCell>
<TableHeaderCell>Active</TableHeaderCell>
<TableHeaderCell hiddenLabel>Go to page</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{webhooks.length > 0 ? (
webhooks.map((w) => {
const path = webhookTriggerPath(organization, project, w);
return (
<TableRow key={w.id} className={cn(!w.active && "bg-rose-500/30")}>
<TableCell to={path}>{w.key}</TableCell>
<TableCell to={path}>
<div className="flex items-center gap-1">
<NamedIcon
name={w.integration.definition.icon ?? w.integration.definitionId}
className="h-8 w-8"
/>
<LabelValueStack
label={w.integration.title}
value={w.integration.slug}
variant="primary"
/>
</div>
</TableCell>
<TableCell to={path}>
{w.params && (
<SimpleTooltip
button={
<div className="flex max-w-[200px] items-start justify-start gap-5 truncate">
{Object.entries(w.params).map(([label, value], index) => (
<LabelValueStack
key={index}
label={label}
value={value}
className="last:truncate"
/>
))}
</div>
}
content={
<div className="flex flex-col gap-2">
{Object.entries(w.params).map(([label, value], index) => (
<LabelValueStack key={index} label={label} value={value} />
))}
</div>
}
/>
)}
</TableCell>
<TableCell to={path}>
<div className="flex items-center justify-end gap-1">
{w.webhookEnvironments.map((env) => (
<EnvironmentLabel
key={env.id}
environment={env.environment}
/>
))}
</div>
</TableCell>
<TableCell to={path}>
{w.active ? (
<CheckCircleIcon className="h-6 w-6 text-green-500" />
) : (
<XCircleIcon className="h-6 w-6 text-rose-500" />
)}
</TableCell>
<TableCellChevron to={path} />
</TableRow>
);
})
) : (
<TableBlankRow colSpan={100}>
<Paragraph>No External triggers</Paragraph>
</TableBlankRow>
)}
</TableBody>
</Table>
</>
);
}
@@ -17,6 +17,7 @@ import {
docsPath,
projectScheduledTriggersPath,
projectTriggersPath,
projectWebhookTriggersPath,
trimTrailingSlash,
} from "~/utils/pathBuilder";
@@ -45,6 +46,7 @@ export default function Page() {
</PageTitleRow>
<PageDescription>A Trigger is what starts a Job Run.</PageDescription>
<PageTabs
layoutId="triggers"
tabs={[
{
label: "External Triggers",
@@ -54,6 +56,10 @@ export default function Page() {
label: "Scheduled Triggers",
to: projectScheduledTriggersPath(organization, project),
},
{
label: "Webhook Triggers",
to: projectWebhookTriggersPath(organization, project),
},
]}
/>
</PageHeader>
@@ -1,6 +1,5 @@
import { Await, useLoaderData } from "@remix-run/react";
import { LoaderFunctionArgs, defer } from "@remix-run/server-runtime";
import { Suspense } from "react";
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { Spinner } from "~/components/primitives/Spinner";
import { TaskDetail } from "~/components/run/TaskDetail";
import { TaskDetailsPresenter } from "~/presenters/TaskDetailsPresenter.server";
@@ -12,24 +11,26 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const { taskParam } = TriggerSourceRunTaskParamsSchema.parse(params);
const presenter = new TaskDetailsPresenter();
const taskPromise = presenter.call({
const task = await presenter.call({
userId,
id: taskParam,
});
return defer({
taskPromise,
return typedjson({
task,
});
};
export default function Page() {
const { taskPromise } = useLoaderData<typeof loader>();
const { task } = useTypedLoaderData<typeof loader>();
return (
<Suspense fallback={<Spinner />}>
<Await resolve={taskPromise} errorElement={<p>Error loading task!</p>}>
{(resolvedTask) => resolvedTask && <TaskDetail task={resolvedTask as any} />}
</Await>
</Suspense>
);
if (!task) {
return (
<div className="flex h-full items-center justify-center">
<Spinner />
</div>
);
}
return <TaskDetail task={task} />;
}
@@ -0,0 +1,181 @@
import { json } from "@remix-run/node";
import type { ActionFunction, LoaderFunctionArgs } from "@remix-run/server-runtime";
import { Fragment } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
import { BreadcrumbIcon } from "~/components/primitives/BreadcrumbIcon";
import { Callout, variantClasses } from "~/components/primitives/Callout";
import { Paragraph } from "~/components/primitives/Paragraph";
import { RunsTable } from "~/components/runs/RunsTable";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useTypedMatchData } from "~/hooks/useTypedMatchData";
import { requireUser, requireUserId } from "~/services/session.server";
import { Handle } from "~/utils/handle";
import {
TriggerSourceParamSchema,
projectTriggersPath,
externalTriggerPath,
trimTrailingSlash,
webhookTriggerRunsParentPath,
projectWebhookTriggersPath,
} from "~/utils/pathBuilder";
import { ListPagination } from "../_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam._index/ListPagination";
import { RunListSearchSchema } from "../_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam._index/route";
import { Button } from "~/components/primitives/Buttons";
import { Form, useActionData, useNavigation } from "@remix-run/react";
import { cn } from "~/utils/cn";
import { conform, useForm } from "@conform-to/react";
import { parse } from "@conform-to/zod";
import { z } from "zod";
import { ActivateSourceService } from "~/services/sources/activateSource.server";
import { redirectWithSuccessMessage } from "~/models/message.server";
import { WebhookSourcePresenter } from "~/presenters/WebhookSourcePresenter.server";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const user = await requireUser(request);
const { organizationSlug, projectParam, triggerParam } = TriggerSourceParamSchema.parse(params);
const url = new URL(request.url);
const s = Object.fromEntries(url.searchParams.entries());
const searchParams = RunListSearchSchema.parse(s);
const presenter = new WebhookSourcePresenter();
const { trigger } = await presenter.call({
userId: user.id,
organizationSlug,
projectSlug: projectParam,
webhookId: triggerParam,
direction: searchParams.direction,
cursor: searchParams.cursor,
});
if (!trigger) {
throw new Response("Trigger not found", {
status: 404,
statusText: "Not Found",
});
}
return typedjson({ trigger });
};
const schema = z.object({
jobId: z.string(),
});
/* export const action: ActionFunction = async ({ request, params }) => {
const userId = await requireUserId(request);
const { organizationSlug, projectParam, triggerParam } = TriggerSourceParamSchema.parse(params);
const formData = await request.formData();
const submission = parse(formData, { schema });
if (!submission.value) {
return json(submission);
}
try {
const service = new ActivateSourceService();
const result = await service.call(triggerParam);
return redirectWithSuccessMessage(
externalTriggerPath({ slug: organizationSlug }, { slug: projectParam }, { id: triggerParam }),
request,
`Retrying registration now`
);
} catch (error: any) {
return json({ errors: { body: error.message } }, { status: 400 });
}
}; */
export const handle: Handle = {
//this one is complicated because we render outside the parent route (using triggers_ in the path)
breadcrumb: (match, matches) => {
const data = useTypedMatchData<typeof loader>(match);
if (!data) return null;
const org = useOrganization(matches);
const project = useProject(matches);
return (
<Fragment>
<BreadcrumbLink to={projectTriggersPath(org, project)} title="Triggers" />
<BreadcrumbIcon />
<BreadcrumbLink to={projectWebhookTriggersPath(org, project)} title="Webhook Triggers" />
<BreadcrumbIcon />
<BreadcrumbLink
to={trimTrailingSlash(match.pathname)}
title={data.trigger.key}
/>
</Fragment>
);
},
};
export default function Page() {
const { trigger } = useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const project = useProject();
const navigation = useNavigation();
const lastSubmission = useActionData();
const [form, { jobId }] = useForm({
id: "trigger-registration-retry",
// TODO: type this
lastSubmission: lastSubmission as any,
onValidate({ formData }) {
return parse(formData, { schema });
},
});
const isLoading = navigation.state === "submitting" && navigation.formData !== undefined;
return (
<>
<Paragraph variant="small" spacing>
Webhook Triggers need to be registered with the external service. You can see the list
of attempted registrations below.
</Paragraph>
{!trigger.active &&
<Form method="post" {...form.props}>
<Callout variant="error" className="justiy-between mb-4 items-center">
<Paragraph variant="small" className={cn(variantClasses.error.textColor, "grow")}>
Registration hasn't succeeded yet, check the runs below.
</Paragraph>
{/* <input
{...conform.input(jobId, { type: "hidden" })}
defaultValue={trigger.registrationJob?.id}
/>
<Button
variant="danger/small"
type="submit"
name={conform.INTENT}
value="retry"
disabled={isLoading}
LeadingIcon={isLoading ? "spinner-white" : undefined}
>
{isLoading ? "Retrying…" : "Retry now"}
</Button> */}
</Callout>
</Form>}
{trigger.runList ? (
<>
<ListPagination list={trigger.runList} className="mb-2 justify-end" />
<RunsTable
runs={trigger.runList.runs}
total={trigger.runList.runs.length}
hasFilters={false}
runsParentPath={webhookTriggerRunsParentPath(organization, project, trigger)}
/>
<ListPagination list={trigger.runList} className="mt-2 justify-end" />
</>
) : (
<Callout variant="warning">No registration runs found</Callout>
)}
</>
);
}
@@ -0,0 +1,108 @@
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { Fragment } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
import { BreadcrumbIcon } from "~/components/primitives/BreadcrumbIcon";
import { Callout } from "~/components/primitives/Callout";
import { Paragraph } from "~/components/primitives/Paragraph";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useTypedMatchData } from "~/hooks/useTypedMatchData";
import { requireUser } from "~/services/session.server";
import { Handle } from "~/utils/handle";
import {
TriggerSourceParamSchema,
projectTriggersPath,
projectWebhookTriggersPath,
trimTrailingSlash,
webhookTriggerDeliveryRunsParentPath,
webhookTriggerPath,
} from "~/utils/pathBuilder";
import { ListPagination } from "../_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam._index/ListPagination";
import { RunListSearchSchema } from "../_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam._index/route";
import { WebhookDeliveryPresenter } from "~/presenters/WebhookDeliveryPresenter.server";
import { WebhookDeliveryRunsTable } from "~/components/runs/WebhookDeliveryRunsTable";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const user = await requireUser(request);
const { organizationSlug, projectParam, triggerParam } = TriggerSourceParamSchema.parse(params);
const url = new URL(request.url);
const s = Object.fromEntries(url.searchParams.entries());
const searchParams = RunListSearchSchema.parse(s);
const presenter = new WebhookDeliveryPresenter();
const { webhook } = await presenter.call({
userId: user.id,
organizationSlug,
projectSlug: projectParam,
webhookId: triggerParam,
direction: searchParams.direction,
cursor: searchParams.cursor,
});
if (!webhook) {
throw new Response("Trigger not found", {
status: 404,
statusText: "Not Found",
});
}
return typedjson({ webhook });
};
export const handle: Handle = {
//this one is complicated because we render outside the parent route (using triggers_ in the path)
breadcrumb: (match, matches) => {
const data = useTypedMatchData<typeof loader>(match);
if (!data) return null;
const org = useOrganization(matches);
const project = useProject(matches);
return (
<Fragment>
<BreadcrumbLink to={projectTriggersPath(org, project)} title="Triggers" />
<BreadcrumbIcon />
<BreadcrumbLink to={projectWebhookTriggersPath(org, project)} title="Webhook Triggers" />
<BreadcrumbIcon />
<BreadcrumbLink
to={webhookTriggerPath(org, project, { id: data.webhook.id })}
title={data.webhook.key}
/>
<BreadcrumbIcon />
<BreadcrumbLink to={trimTrailingSlash(match.pathname)} title="Deliveries" />
</Fragment>
);
},
};
export default function Page() {
const { webhook } = useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const project = useProject();
return (
<>
<Paragraph variant="small" spacing>
Webhook payloads are delivered to clients for validation and event generation. You can see
the list of attempted deliveries below.
</Paragraph>
{webhook.requestDeliveries ? (
<>
<ListPagination list={webhook.requestDeliveries} className="mb-2 justify-end" />
<WebhookDeliveryRunsTable
runs={webhook.requestDeliveries.runs}
total={webhook.requestDeliveries.runs.length}
hasFilters={false}
runsParentPath={webhookTriggerDeliveryRunsParentPath(organization, project, webhook)}
/>
<ListPagination list={webhook.requestDeliveries} className="mt-2 justify-end" />
</>
) : (
<Callout variant="warning">No registration runs found</Callout>
)}
</>
);
}
@@ -0,0 +1,109 @@
import { Outlet } from "@remix-run/react";
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { NamedIcon } from "~/components/primitives/NamedIcon";
import {
PageHeader,
PageInfoGroup,
PageInfoProperty,
PageInfoRow,
PageTabs,
PageTitle,
PageTitleRow,
} from "~/components/primitives/PageHeader";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { requireUser } from "~/services/session.server";
import {
TriggerSourceParamSchema,
projectWebhookTriggersPath,
webhookDeliveryPath,
webhookTriggerPath,
} from "~/utils/pathBuilder";
import { RunListSearchSchema } from "../_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam._index/route";
import { WebhookSourcePresenter } from "~/presenters/WebhookSourcePresenter.server";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const user = await requireUser(request);
const { organizationSlug, projectParam, triggerParam } = TriggerSourceParamSchema.parse(params);
const url = new URL(request.url);
const s = Object.fromEntries(url.searchParams.entries());
const searchParams = RunListSearchSchema.parse(s);
const presenter = new WebhookSourcePresenter();
const { trigger } = await presenter.call({
userId: user.id,
organizationSlug,
projectSlug: projectParam,
webhookId: triggerParam,
direction: searchParams.direction,
cursor: searchParams.cursor,
});
if (!trigger) {
throw new Response("Trigger not found", {
status: 404,
statusText: "Not Found",
});
}
return typedjson({ trigger });
};
export default function Page() {
const { trigger } = useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const project = useProject();
return (
<PageContainer>
<PageHeader hideBorder>
<PageTitleRow>
<PageTitle
title={trigger.key}
backButton={{
to: projectWebhookTriggersPath(organization, project),
text: "Webhook Triggers",
}}
/>
</PageTitleRow>
<PageInfoRow>
<PageInfoGroup>
<PageInfoProperty
icon={trigger.integration.definition.icon ?? trigger.integration.definitionId}
label={trigger.integration.title ?? ""}
value={trigger.integration.slug}
to={trigger.integrationLink}
/>
<PageInfoProperty
icon="webhook"
label="HTTP Endpoint"
to={trigger.httpEndpointLink}
/>
</PageInfoGroup>
</PageInfoRow>
<PageTabs
layoutId="webhook-trigger"
tabs={[
{
label: "Registrations",
to: webhookTriggerPath(organization, project, trigger),
},
{
label: "Deliveries",
to: webhookDeliveryPath(organization, project, trigger),
},
]}
/>
</PageHeader>
<PageBody scrollable={false}>
<div className="h-full overflow-y-auto p-4 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700">
<Outlet />
</div>
</PageBody>
</PageContainer>
);
}
@@ -0,0 +1,20 @@
import { useTypedRouteLoaderData } from "remix-typedjson";
import { RunCompletedDetail } from "~/components/run/RunCompletedDetail";
import type { loader as runLoader } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.$runParam/route";
function useTriggerRegisterRun() {
const routeMatch = useTypedRouteLoaderData<typeof runLoader>(
"routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.$runParam"
);
if (!routeMatch || !routeMatch.run) {
throw new Error("No run found");
}
return routeMatch.run;
}
export default function RunCompletedPage() {
const run = useTriggerRegisterRun();
return <RunCompletedDetail run={run} />;
}
@@ -0,0 +1,13 @@
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { z } from "zod";
import { RunStreamPresenter } from "~/presenters/RunStreamPresenter.server";
import { requireUserId } from "~/services/session.server";
export async function loader({ request, params }: LoaderFunctionArgs) {
await requireUserId(request);
const { runParam } = z.object({ runParam: z.string() }).parse(params);
const presenter = new RunStreamPresenter();
return presenter.call({ request, runId: runParam });
}
@@ -0,0 +1,35 @@
import { Await, useLoaderData } from "@remix-run/react";
import { LoaderFunctionArgs, defer } from "@remix-run/server-runtime";
import { Suspense } from "react";
import { Spinner } from "~/components/primitives/Spinner";
import { TaskDetail } from "~/components/run/TaskDetail";
import { TaskDetailsPresenter } from "~/presenters/TaskDetailsPresenter.server";
import { requireUserId } from "~/services/session.server";
import { TriggerSourceRunTaskParamsSchema } from "~/utils/pathBuilder";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { taskParam } = TriggerSourceRunTaskParamsSchema.parse(params);
const presenter = new TaskDetailsPresenter();
const taskPromise = presenter.call({
userId,
id: taskParam,
});
return defer({
taskPromise,
});
};
export default function Page() {
const { taskPromise } = useLoaderData<typeof loader>();
return (
<Suspense fallback={<Spinner />}>
<Await resolve={taskPromise} errorElement={<p>Error loading task!</p>}>
{(resolvedTask) => resolvedTask && <TaskDetail task={resolvedTask as any} />}
</Await>
</Suspense>
);
}
@@ -0,0 +1,34 @@
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { TriggerDetail } from "~/components/run/TriggerDetail";
import { TriggerDetailsPresenter } from "~/presenters/TriggerDetailsPresenter.server";
import { TriggerSourceRunParamsSchema } from "~/utils/pathBuilder";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const { runParam } = TriggerSourceRunParamsSchema.parse(params);
const presenter = new TriggerDetailsPresenter();
const trigger = await presenter.call(runParam);
if (!trigger) {
throw new Response(null, {
status: 404,
});
}
return typedjson({
trigger,
});
};
export default function Page() {
const { trigger } = useTypedLoaderData<typeof loader>();
return (
<TriggerDetail
trigger={trigger}
event={{ icon: "webhook", title: "Register Webhook" }}
properties={[]}
/>
);
}
@@ -0,0 +1,135 @@
import { useRevalidator } from "@remix-run/react";
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { Fragment, useEffect } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { useEventSource } from "remix-utils/sse/react";
import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
import { BreadcrumbIcon } from "~/components/primitives/BreadcrumbIcon";
import { RunOverview } from "~/components/run/RunOverview";
import { prisma } from "~/db.server";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useTypedMatchData } from "~/hooks/useTypedMatchData";
import { RunPresenter } from "~/presenters/RunPresenter.server";
import { requireUserId } from "~/services/session.server";
import { Handle } from "~/utils/handle";
import {
TriggerSourceRunParamsSchema,
projectWebhookTriggersPath,
trimTrailingSlash,
webhookTriggerPath,
webhookTriggerRunPath,
webhookTriggerRunStreamingPath,
webhookTriggerRunsParentPath,
} from "~/utils/pathBuilder";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { runParam, triggerParam } = TriggerSourceRunParamsSchema.parse(params);
const presenter = new RunPresenter();
const run = await presenter.call({
userId,
id: runParam,
});
const trigger = await prisma.webhook.findUnique({
select: {
id: true,
key: true,
integration: {
select: {
id: true,
title: true,
slug: true,
definitionId: true,
setupStatus: true,
},
},
},
where: {
id: triggerParam,
},
});
if (!run || !trigger) {
throw new Response(null, {
status: 404,
});
}
return typedjson({
run,
trigger,
});
};
export const handle: Handle = {
breadcrumb: (match, matches) => {
const data = useTypedMatchData<typeof loader>(match);
if (!data) return null;
const org = useOrganization(matches);
const project = useProject(matches);
return (
<Fragment>
<BreadcrumbLink to={projectWebhookTriggersPath(org, project)} title="Triggers" />
<BreadcrumbIcon />
<BreadcrumbLink to={projectWebhookTriggersPath(org, project)} title="Webhook Triggers" />
<BreadcrumbIcon />
<BreadcrumbLink
to={webhookTriggerPath(org, project, { id: data.trigger.id })}
title={data.trigger.key}
/>
<BreadcrumbIcon />
<BreadcrumbLink
to={webhookTriggerPath(org, project, { id: data.trigger.id })}
title="Registrations"
/>
<BreadcrumbIcon />
{data && data.run && (
<BreadcrumbLink
to={trimTrailingSlash(match.pathname)}
title={`Run #${data.run.number}`}
/>
)}
</Fragment>
);
},
};
export default function Page() {
const { run, trigger } = useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const project = useProject();
const revalidator = useRevalidator();
const events = useEventSource(
webhookTriggerRunStreamingPath(organization, project, trigger, run),
{
event: "message",
}
);
useEffect(() => {
if (events !== null) {
revalidator.revalidate();
}
// WARNING Don't put the revalidator in the useEffect deps array or bad things will happen
}, [events]); // eslint-disable-line react-hooks/exhaustive-deps
return (
<RunOverview
run={run}
trigger={{ icon: "webhook", title: "Register Webhook" }}
showRerun={true}
paths={{
back: webhookTriggerPath(organization, project, { id: trigger.id }),
run: webhookTriggerRunPath(organization, project, { id: trigger.id }, run),
runsPath: webhookTriggerRunsParentPath(organization, project, {
id: trigger.id,
}),
}}
/>
);
}
@@ -0,0 +1,20 @@
import { useTypedRouteLoaderData } from "remix-typedjson";
import { RunCompletedDetail } from "~/components/run/RunCompletedDetail";
import type { loader as runLoader } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.delivery.$runParam/route";
function useTriggerRegisterRun() {
const routeMatch = useTypedRouteLoaderData<typeof runLoader>(
"routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.delivery.$runParam"
);
if (!routeMatch || !routeMatch.run) {
throw new Error("No run found");
}
return routeMatch.run;
}
export default function RunCompletedPage() {
const run = useTriggerRegisterRun();
return <RunCompletedDetail run={run} />;
}
@@ -0,0 +1,13 @@
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { z } from "zod";
import { RunStreamPresenter } from "~/presenters/RunStreamPresenter.server";
import { requireUserId } from "~/services/session.server";
export async function loader({ request, params }: LoaderFunctionArgs) {
await requireUserId(request);
const { runParam } = z.object({ runParam: z.string() }).parse(params);
const presenter = new RunStreamPresenter();
return presenter.call({ request, runId: runParam });
}
@@ -0,0 +1,35 @@
import { Await, useLoaderData } from "@remix-run/react";
import { LoaderFunctionArgs, defer } from "@remix-run/server-runtime";
import { Suspense } from "react";
import { Spinner } from "~/components/primitives/Spinner";
import { TaskDetail } from "~/components/run/TaskDetail";
import { TaskDetailsPresenter } from "~/presenters/TaskDetailsPresenter.server";
import { requireUserId } from "~/services/session.server";
import { TriggerSourceRunTaskParamsSchema } from "~/utils/pathBuilder";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { taskParam } = TriggerSourceRunTaskParamsSchema.parse(params);
const presenter = new TaskDetailsPresenter();
const taskPromise = presenter.call({
userId,
id: taskParam,
});
return defer({
taskPromise,
});
};
export default function Page() {
const { taskPromise } = useLoaderData<typeof loader>();
return (
<Suspense fallback={<Spinner />}>
<Await resolve={taskPromise} errorElement={<p>Error loading task!</p>}>
{(resolvedTask) => resolvedTask && <TaskDetail task={resolvedTask as any} />}
</Await>
</Suspense>
);
}
@@ -0,0 +1,34 @@
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { TriggerDetail } from "~/components/run/TriggerDetail";
import { TriggerDetailsPresenter } from "~/presenters/TriggerDetailsPresenter.server";
import { TriggerSourceRunParamsSchema } from "~/utils/pathBuilder";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const { runParam } = TriggerSourceRunParamsSchema.parse(params);
const presenter = new TriggerDetailsPresenter();
const trigger = await presenter.call(runParam);
if (!trigger) {
throw new Response(null, {
status: 404,
});
}
return typedjson({
trigger,
});
};
export default function Page() {
const { trigger } = useTypedLoaderData<typeof loader>();
return (
<TriggerDetail
trigger={trigger}
event={{ icon: "mail-fast", title: "Deliver Webhook" }}
properties={[]}
/>
);
}
@@ -0,0 +1,132 @@
import { useRevalidator } from "@remix-run/react";
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { Fragment, useEffect } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { useEventSource } from "remix-utils/sse/react";
import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
import { BreadcrumbIcon } from "~/components/primitives/BreadcrumbIcon";
import { RunOverview } from "~/components/run/RunOverview";
import { prisma } from "~/db.server";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useTypedMatchData } from "~/hooks/useTypedMatchData";
import { RunPresenter } from "~/presenters/RunPresenter.server";
import { requireUserId } from "~/services/session.server";
import { Handle } from "~/utils/handle";
import {
TriggerSourceRunParamsSchema,
projectWebhookTriggersPath,
trimTrailingSlash,
webhookDeliveryPath,
webhookTriggerDeliveryRunPath,
webhookTriggerDeliveryRunsParentPath,
webhookTriggerPath,
webhookTriggerRunStreamingPath,
} from "~/utils/pathBuilder";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { runParam, triggerParam } = TriggerSourceRunParamsSchema.parse(params);
const presenter = new RunPresenter();
const run = await presenter.call({
userId,
id: runParam,
});
const trigger = await prisma.webhook.findUnique({
select: {
id: true,
integration: {
select: {
id: true,
title: true,
slug: true,
definitionId: true,
setupStatus: true,
},
},
},
where: {
id: triggerParam,
},
});
if (!run || !trigger) {
throw new Response(null, {
status: 404,
});
}
return typedjson({
run,
trigger,
});
};
export const handle: Handle = {
breadcrumb: (match, matches) => {
const data = useTypedMatchData<typeof loader>(match);
if (!data) return null;
const org = useOrganization(matches);
const project = useProject(matches);
return (
<Fragment>
<BreadcrumbLink to={projectWebhookTriggersPath(org, project)} title="Triggers" />
<BreadcrumbIcon />
<BreadcrumbLink to={projectWebhookTriggersPath(org, project)} title="Webhook Triggers" />
<BreadcrumbIcon />
<BreadcrumbLink
to={webhookTriggerPath(org, project, { id: data.trigger.id })}
title={`${data.trigger.integration.title}: ${data.trigger.integration.slug}`}
/>
<BreadcrumbIcon />
<BreadcrumbLink to={webhookDeliveryPath(org, project, { id: data.trigger.id })} title="Deliveries" />
<BreadcrumbIcon />
{data && data.run && (
<BreadcrumbLink
to={trimTrailingSlash(match.pathname)}
title={`Run #${data.run.number}`}
/>
)}
</Fragment>
);
},
};
export default function Page() {
const { run, trigger } = useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const project = useProject();
const revalidator = useRevalidator();
const events = useEventSource(
webhookTriggerRunStreamingPath(organization, project, trigger, run),
{
event: "message",
}
);
useEffect(() => {
if (events !== null) {
revalidator.revalidate();
}
// WARNING Don't put the revalidator in the useEffect deps array or bad things will happen
}, [events]); // eslint-disable-line react-hooks/exhaustive-deps
return (
<RunOverview
run={run}
trigger={{ icon: "mail-fast", title: "Deliver Webhook" }}
showRerun={false}
paths={{
back: webhookDeliveryPath(organization, project, { id: trigger.id }),
run: webhookTriggerDeliveryRunPath(organization, project, { id: trigger.id }, run),
runsPath: webhookTriggerDeliveryRunsParentPath(organization, project, {
id: trigger.id,
}),
}}
/>
);
}
+84 -43
View File
@@ -1,6 +1,7 @@
import { conform, useForm } from "@conform-to/react";
import { parse } from "@conform-to/zod";
import type { ActionFunction, LoaderFunction, LoaderFunctionArgs } from "@remix-run/node";
import { RadioGroup } from "@radix-ui/react-radio-group";
import type { ActionFunction, LoaderFunctionArgs } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { Form, useActionData } from "@remix-run/react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
@@ -15,6 +16,8 @@ import { Hint } from "~/components/primitives/Hint";
import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { RadioGroupItem } from "~/components/primitives/RadioButton";
import { useFeatures } from "~/hooks/useFeatures";
import { createOrganization } from "~/models/organization.server";
import { NewOrganizationPresenter } from "~/presenters/NewOrganizationPresenter.server";
import { commitCurrentProjectSession, setCurrentProjectId } from "~/services/currentProject.server";
@@ -24,6 +27,7 @@ import { projectPath, rootPath } from "~/utils/pathBuilder";
const schema = z.object({
orgName: z.string().min(3).max(50),
projectName: z.string().min(3).max(50),
companySize: z.string().optional(),
});
export const loader = async ({ request }: LoaderFunctionArgs) => {
@@ -51,6 +55,7 @@ export const action: ActionFunction = async ({ request }) => {
title: submission.value.orgName,
userId,
projectName: submission.value.projectName,
companySize: submission.value.companySize ?? null,
});
const project = organization.projects[0];
@@ -69,6 +74,7 @@ export const action: ActionFunction = async ({ request }) => {
export default function NewOrganizationPage() {
const { hasOrganizations } = useTypedLoaderData<typeof loader>();
const lastSubmission = useActionData();
const { isManagedCloud } = useFeatures();
const [form, { orgName, projectName }] = useForm({
id: "create-organization",
@@ -77,52 +83,87 @@ export default function NewOrganizationPage() {
onValidate({ formData }) {
return parse(formData, { schema });
},
shouldRevalidate: "onSubmit",
});
return (
<MainCenteredContainer>
<div>
<FormTitle LeadingIcon="organization" title="Create a new Organization" />
<Form method="post" {...form.props}>
<Fieldset>
<InputGroup>
<Label htmlFor={orgName.id}>Organization name</Label>
<Input
{...conform.input(orgName, { type: "text" })}
placeholder="Your Organization name"
icon="organization"
/>
<Hint>E.g. your company name or your workspace name.</Hint>
<FormError id={orgName.errorId}>{orgName.error}</FormError>
</InputGroup>
<InputGroup>
<Label htmlFor={projectName.id}>Project name</Label>
<Input
{...conform.input(projectName, { type: "text" })}
placeholder="Your Project name"
icon="folder"
/>
<Hint>Your Jobs will live inside this Project.</Hint>
<FormError id={projectName.errorId}>{projectName.error}</FormError>
</InputGroup>
<FormButtons
confirmButton={
<Button type="submit" variant={"primary/small"} TrailingIcon="arrow-right">
Create
</Button>
}
cancelButton={
hasOrganizations ? (
<LinkButton to={rootPath()} variant={"secondary/small"}>
Cancel
</LinkButton>
) : null
}
<MainCenteredContainer className="max-w-[22rem]">
<FormTitle LeadingIcon="organization" title="Create an Organization" />
<Form method="post" {...form.props}>
<Fieldset>
<InputGroup>
<Label htmlFor={orgName.id}>Organization name</Label>
<Input
{...conform.input(orgName, { type: "text" })}
placeholder="Your Organization name"
icon="organization"
autoFocus
/>
</Fieldset>
</Form>
</div>
<Hint>E.g. your company name or your workspace name.</Hint>
<FormError id={orgName.errorId}>{orgName.error}</FormError>
</InputGroup>
<InputGroup>
<Label htmlFor={projectName.id}>Project name</Label>
<Input
{...conform.input(projectName, { type: "text" })}
placeholder="Your Project name"
icon="folder"
/>
<Hint>Your Jobs will live inside this Project.</Hint>
<FormError id={projectName.errorId}>{projectName.error}</FormError>
</InputGroup>
{isManagedCloud && (
<InputGroup>
<Label htmlFor={projectName.id}>Number of employees</Label>
<RadioGroup name="companySize" className="flex items-center justify-between gap-2">
<RadioGroupItem
id="employees-1-5"
label="1-5"
value={"1-5"}
variant="button/small"
className="grow"
/>
<RadioGroupItem
id="employees-6-49"
label="6-49"
value={"6-49"}
variant="button/small"
className="grow"
/>
<RadioGroupItem
id="employees-50-99"
label="50-99"
value={"50-99"}
variant="button/small"
className="grow"
/>
<RadioGroupItem
id="employees-100+"
label="100+"
value={"100+"}
variant="button/small"
className="grow"
/>
</RadioGroup>
</InputGroup>
)}
<FormButtons
confirmButton={
<Button type="submit" variant={"primary/small"} TrailingIcon="arrow-right">
Create
</Button>
}
cancelButton={
hasOrganizations ? (
<LinkButton to={rootPath()} variant={"secondary/small"}>
Cancel
</LinkButton>
) : null
}
/>
</Fieldset>
</Form>
</MainCenteredContainer>
);
}
@@ -3,6 +3,7 @@ import { json } from "@remix-run/server-runtime";
import { GetEvent } from "@trigger.dev/core";
import { z } from "zod";
import { prisma } from "~/db.server";
import { runOriginalStatus } from "~/models/jobRun.server";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { apiCors } from "~/utils/apiCors";
@@ -49,7 +50,7 @@ function toJSON(eventRecord: FoundEventRecord): GetEvent {
updatedAt: eventRecord.updatedAt,
runs: eventRecord.runs.map((run) => ({
id: run.id,
status: run.status,
status: runOriginalStatus(run.status),
startedAt: run.startedAt,
completedAt: run.completedAt,
})),
@@ -3,6 +3,7 @@ import { json } from "@remix-run/server-runtime";
import { JobRunStatusRecordSchema } from "@trigger.dev/core";
import { z } from "zod";
import { prisma } from "~/db.server";
import { runOriginalStatus } from "~/models/jobRun.server";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { apiCors } from "~/utils/apiCors";
@@ -66,7 +67,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
json({
run: {
id: run.id,
status: run.status,
status: runOriginalStatus(run.status),
output: run.output,
},
statuses: parsedStatuses,
@@ -191,7 +191,8 @@ export class CompleteRunTaskService {
},
data: {
status: "COMPLETED",
output: taskBody.output ?? undefined,
output: taskBody.output as any,
outputIsUndefined: typeof taskBody.output === "undefined",
completedAt: new Date(),
outputProperties: taskBody.properties,
},
@@ -2,21 +2,16 @@ import type { ActionFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import {
API_VERSIONS,
RunTaskBodyOutput,
RunTaskBodyOutputSchema,
RunTaskResponseWithCachedTasksBody,
ServerTask,
} from "@trigger.dev/core";
import { TaskStatus } from "@trigger.dev/database";
import { z } from "zod";
import { $transaction, PrismaClient, prisma } from "~/db.server";
import { env } from "~/env.server";
import { prepareTasksForCaching, taskWithAttemptsToServerTask } from "~/models/task.server";
import { PrismaClient, prisma } from "~/db.server";
import { prepareTasksForCaching } from "~/models/task.server";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { generateSecret } from "~/services/sources/utils.server";
import { ulid } from "~/services/ulid.server";
import { taskOperationWorker, workerQueue } from "~/services/worker.server";
import { RunTaskService } from "~/services/tasks/runTask.server";
const ParamsSchema = z.object({
runId: z.string(),
@@ -161,184 +156,3 @@ class ChangeRequestLazyLoadedCachedTasks {
};
}
}
export class RunTaskService {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call(
runId: string,
idempotencyKey: string,
taskBody: RunTaskBodyOutput
): Promise<ServerTask | undefined> {
const task = await $transaction(this.#prismaClient, async (tx) => {
const existingTask = await tx.task.findUnique({
where: {
runId_idempotencyKey: {
runId,
idempotencyKey,
},
},
include: {
attempts: true,
run: true,
},
});
const delayUntilInFuture = taskBody.delayUntil && taskBody.delayUntil.getTime() > Date.now();
const callbackEnabled = taskBody.callback?.enabled;
if (existingTask) {
if (existingTask.status === "CANCELED") {
const existingTaskStatus =
delayUntilInFuture || callbackEnabled || taskBody.trigger
? "WAITING"
: taskBody.noop
? "COMPLETED"
: "RUNNING";
const resumedExistingTask = await tx.task.update({
where: {
id: existingTask.id,
},
data: {
status: existingTaskStatus,
startedAt: new Date(),
completedAt: existingTaskStatus === "COMPLETED" ? new Date() : undefined,
},
include: {
run: true,
attempts: true,
},
});
return resumedExistingTask;
}
return existingTask;
}
const run = await tx.jobRun.findUnique({
where: {
id: runId,
},
select: {
status: true,
},
});
if (!run) throw new Error("Run not found");
// If task.delayUntil is set and is in the future, we'll set the task's status to "WAITING", else set it to RUNNING
let status: TaskStatus;
if (run.status === "CANCELED") {
status = "CANCELED";
} else {
status =
delayUntilInFuture || callbackEnabled || taskBody.trigger
? "WAITING"
: taskBody.noop
? "COMPLETED"
: "RUNNING";
}
const taskId = ulid();
const callbackUrl = callbackEnabled
? `${env.APP_ORIGIN}/api/v1/tasks/${taskId}/callback/${generateSecret(12)}`
: undefined;
const task = await tx.task.create({
data: {
id: taskId,
idempotencyKey,
displayKey: taskBody.displayKey,
runConnection: taskBody.connectionKey
? {
connect: {
runId_key: {
runId,
key: taskBody.connectionKey,
},
},
}
: undefined,
icon: taskBody.icon,
run: {
connect: {
id: runId,
},
},
parent: taskBody.parentId ? { connect: { id: taskBody.parentId } } : undefined,
name: taskBody.name ?? "Task",
description: taskBody.description,
status,
startedAt: new Date(),
completedAt: status === "COMPLETED" || status === "CANCELED" ? new Date() : undefined,
noop: taskBody.noop,
delayUntil: taskBody.delayUntil,
params: taskBody.params ?? undefined,
properties: this.#filterProperties(taskBody.properties) ?? undefined,
redact: taskBody.redact ?? undefined,
operation: taskBody.operation,
callbackUrl,
style: taskBody.style ?? { style: "normal" },
childExecutionMode: taskBody.parallel ? "PARALLEL" : "SEQUENTIAL",
attempts: {
create: {
number: 1,
status: "PENDING",
},
},
},
include: {
run: true,
attempts: true,
},
});
if (task.status === "RUNNING" && typeof taskBody.operation === "string") {
// We need to schedule the operation
await taskOperationWorker.enqueue(
"performTaskOperation",
{
id: task.id,
},
{ tx, runAt: task.delayUntil ?? undefined, jobKey: `operation:${task.id}` }
);
} else if (task.status === "WAITING" && callbackUrl && taskBody.callback) {
if (taskBody.callback.timeoutInSeconds > 0) {
// We need to schedule the callback timeout
await workerQueue.enqueue(
"processCallbackTimeout",
{
id: task.id,
},
{
tx,
runAt: new Date(Date.now() + taskBody.callback.timeoutInSeconds * 1000),
jobKey: `process-callback:${task.id}`,
}
);
}
}
return task;
});
return task ? taskWithAttemptsToServerTask(task) : undefined;
}
#filterProperties(properties: RunTaskBodyOutput["properties"]): RunTaskBodyOutput["properties"] {
if (!properties) return;
return properties.filter((property) => {
if (!property) return false;
return typeof property.label === "string" && typeof property.text === "string";
});
}
}
+2 -1
View File
@@ -1,6 +1,7 @@
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { z } from "zod";
import { runOriginalStatus } from "~/models/jobRun.server";
import { ApiRunPresenter } from "~/presenters/ApiRunPresenter.server";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { apiCors } from "~/utils/apiCors";
@@ -79,7 +80,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
request,
json({
id: jobRun.id,
status: jobRun.status,
status: runOriginalStatus(jobRun.status),
startedAt: jobRun.startedAt,
updatedAt: jobRun.updatedAt,
completedAt: jobRun.completedAt,
+156
View File
@@ -0,0 +1,156 @@
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { assertExhaustive } from "@trigger.dev/core";
import { z } from "zod";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { KeyValueStore } from "~/services/store/keyValueStore.server";
const ParamsSchema = z.object({
key: z.string(),
});
const MAX_BODY_BYTE_LENGTH = 256 * 1024;
export async function action({ request, params }: ActionFunctionArgs) {
logger.info("Key-value store action", { url: request.url });
const ActionMethodSchema = z.enum(["DELETE", "PUT"]);
const parsedMethod = ActionMethodSchema.safeParse(request.method.toUpperCase());
if (!parsedMethod.success) {
return json({ error: "Method Not Allowed" }, { status: 405 });
}
const parsedParams = ParamsSchema.safeParse(params);
if (!parsedParams.success) {
logger.info("Invalid params", { params });
return json({ error: "Invalid params" }, { status: 400 });
}
const authenticationResult = await authenticateApiRequest(request);
if (!authenticationResult) {
logger.info("Invalid or missing api key", { url: request.url });
return json({ error: "Invalid or Missing API key" }, { status: 401 });
}
const authenticatedEnv = authenticationResult.environment;
const store = new KeyValueStore(authenticatedEnv);
const decodedKey = decodeURIComponent(parsedParams.data.key);
try {
switch (parsedMethod.data) {
case "DELETE": {
const deleted = await store.delete(decodedKey);
return json({ action: "DELETE", key: decodedKey, deleted });
}
case "PUT": {
const value = await request.text();
const serializedValueBytes = value.length;
if (serializedValueBytes > MAX_BODY_BYTE_LENGTH) {
logger.info("Max request body size exceeded", { serializedValueBytes });
return json(
{ error: `Max request body size exceeded: ${MAX_BODY_BYTE_LENGTH} bytes` },
{ status: 413 }
);
}
const setValue = await store.set(decodedKey, value);
return json({ action: "SET", key: decodedKey, value: setValue });
}
default: {
assertExhaustive(parsedMethod.data);
}
}
} catch (error) {
if (error instanceof Error) {
logger.error("Error peforming key-value store action", {
method: parsedMethod.data,
url: request.url,
error: error.message,
});
return json({ error: error.message }, { status: 400 });
}
return json({ error: "Something went wrong" }, { status: 500 });
}
}
export async function loader({ request, params }: LoaderFunctionArgs) {
logger.info("Key-value store loader", { url: request.url });
const parsedParams = ParamsSchema.safeParse(params);
if (!parsedParams.success) {
logger.info("Invalid params", { params });
return json({ error: "Invalid params" }, { status: 400 });
}
const ActionMethodSchema = z.enum(["GET", "HEAD"]);
const parsedMethod = ActionMethodSchema.safeParse(request.method.toUpperCase());
if (!parsedMethod.success) {
return json({ error: "Method Not Allowed" }, { status: 405 });
}
const authenticationResult = await authenticateApiRequest(request);
if (!authenticationResult) {
logger.info("Invalid or missing api key", { url: request.url });
return json({ error: "Invalid or Missing API key" }, { status: 401 });
}
const authenticatedEnv = authenticationResult.environment;
const store = new KeyValueStore(authenticatedEnv);
const { key } = parsedParams.data;
try {
switch (parsedMethod.data) {
case "GET": {
const value = await store.get(key);
return json({ action: "GET", key, value });
}
case "HEAD": {
const has = await store.has(key);
if (!has) {
return new Response("Key not found", { status: 404 });
}
return new Response("Key found", { status: 200 });
}
default: {
assertExhaustive(parsedMethod.data);
}
}
} catch (error) {
if (error instanceof Error) {
logger.error("Error peforming key-value store action", {
method: parsedMethod.data,
url: request.url,
error: error.message,
});
return json({ error: error.message }, { status: 400 });
}
return json({ error: "Something went wrong" }, { status: 500 });
}
}

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